Skip to content

Commit 640b3d3

Browse files
Internal: add arena bounds violation signals (#24)
Co-authored-by: LegendaryForge <LegendaryForge@users.noreply.github.com>
1 parent ce4f3d4 commit 640b3d3

File tree

7 files changed

+208
-6
lines changed

7 files changed

+208
-6
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package io.github.legendaryforge.legendary.core.internal.legendary.arena;
2+
3+
import io.github.legendaryforge.legendary.core.api.event.EventBus;
4+
import io.github.legendaryforge.legendary.core.internal.legendary.arena.event.ArenaBoundsViolatedEvent;
5+
import io.github.legendaryforge.legendary.core.internal.legendary.arena.event.ArenaParticipationRevokedEvent;
6+
import java.util.Map;
7+
import java.util.Objects;
8+
import java.util.Set;
9+
import java.util.UUID;
10+
import java.util.concurrent.ConcurrentHashMap;
11+
12+
/**
13+
* Internal invariant that reacts to arena bounds violations.
14+
*
15+
* <p>Signal-only: emits internal events indicating participation should be revoked.
16+
*/
17+
public final class BoundsInvariant implements ArenaInvariant {
18+
19+
private final EventBus bus;
20+
21+
// Tracks whether the instance is currently ACTIVE.
22+
private final Set<UUID> activeInstances = ConcurrentHashMap.newKeySet();
23+
24+
// Dedup per (instanceId, playerId) so we emit revoke once.
25+
private final Map<UUID, Set<UUID>> revoked = new ConcurrentHashMap<>();
26+
27+
public BoundsInvariant(EventBus bus) {
28+
this.bus = Objects.requireNonNull(bus, "bus");
29+
bus.subscribe(ArenaBoundsViolatedEvent.class, this::onBoundsViolated);
30+
}
31+
32+
private void onBoundsViolated(ArenaBoundsViolatedEvent event) {
33+
Objects.requireNonNull(event, "event");
34+
UUID instanceId = event.instanceId();
35+
if (!activeInstances.contains(instanceId)) {
36+
return;
37+
}
38+
Set<UUID> revokedPlayers = revoked.computeIfAbsent(instanceId, id -> ConcurrentHashMap.newKeySet());
39+
if (revokedPlayers.add(event.playerId())) {
40+
bus.post(new ArenaParticipationRevokedEvent(instanceId, event.playerId()));
41+
}
42+
}
43+
44+
@Override
45+
public void onStart(UUID instanceId) {
46+
Objects.requireNonNull(instanceId, "instanceId");
47+
activeInstances.add(instanceId);
48+
}
49+
50+
@Override
51+
public void onEnd(UUID instanceId) {
52+
Objects.requireNonNull(instanceId, "instanceId");
53+
activeInstances.remove(instanceId);
54+
}
55+
56+
@Override
57+
public void onCleanup(UUID instanceId) {
58+
Objects.requireNonNull(instanceId, "instanceId");
59+
activeInstances.remove(instanceId);
60+
revoked.remove(instanceId);
61+
}
62+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package io.github.legendaryforge.legendary.core.internal.legendary.arena.event;
2+
3+
import io.github.legendaryforge.legendary.core.api.event.Event;
4+
import io.github.legendaryforge.legendary.core.internal.legendary.arena.geom.Vec3d;
5+
import java.util.Objects;
6+
import java.util.Optional;
7+
import java.util.UUID;
8+
9+
/** Internal signal emitted when a player is detected outside arena bounds for an encounter instance. */
10+
public record ArenaBoundsViolatedEvent(UUID instanceId, UUID playerId, Optional<Vec3d> position) implements Event {
11+
public ArenaBoundsViolatedEvent {
12+
Objects.requireNonNull(instanceId, "instanceId");
13+
Objects.requireNonNull(playerId, "playerId");
14+
Objects.requireNonNull(position, "position");
15+
}
16+
17+
public static ArenaBoundsViolatedEvent withoutPosition(UUID instanceId, UUID playerId) {
18+
return new ArenaBoundsViolatedEvent(instanceId, playerId, Optional.empty());
19+
}
20+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package io.github.legendaryforge.legendary.core.internal.legendary.arena.event;
2+
3+
import io.github.legendaryforge.legendary.core.api.event.Event;
4+
import java.util.Objects;
5+
import java.util.UUID;
6+
7+
/** Internal signal indicating a player should no longer be treated as an active participant in an arena instance. */
8+
public record ArenaParticipationRevokedEvent(UUID instanceId, UUID playerId) implements Event {
9+
public ArenaParticipationRevokedEvent {
10+
Objects.requireNonNull(instanceId, "instanceId");
11+
Objects.requireNonNull(playerId, "playerId");
12+
}
13+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package io.github.legendaryforge.legendary.core.internal.legendary.arena.geom;
2+
3+
import java.util.Objects;
4+
5+
/** Internal, platform-agnostic arena bounds model. */
6+
public final class ArenaBounds {
7+
8+
private final Vec3d center;
9+
private final double radius;
10+
private final double radiusSquared;
11+
12+
public ArenaBounds(Vec3d center, double radius) {
13+
this.center = Objects.requireNonNull(center, "center");
14+
if (radius <= 0.0) {
15+
throw new IllegalArgumentException("radius must be > 0");
16+
}
17+
this.radius = radius;
18+
this.radiusSquared = radius * radius;
19+
}
20+
21+
public Vec3d center() {
22+
return center;
23+
}
24+
25+
public double radius() {
26+
return radius;
27+
}
28+
29+
public boolean contains(Vec3d position) {
30+
Objects.requireNonNull(position, "position");
31+
return center.distanceSquaredTo(position) <= radiusSquared;
32+
}
33+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.github.legendaryforge.legendary.core.internal.legendary.arena.geom;
2+
3+
import java.util.Objects;
4+
5+
/** Internal, platform-agnostic 3D vector for arena enforcement signals. */
6+
public record Vec3d(double x, double y, double z) {
7+
public Vec3d {
8+
// no-op: record provides immutability; keep validation minimal
9+
}
10+
11+
public double distanceSquaredTo(Vec3d other) {
12+
Objects.requireNonNull(other, "other");
13+
double dx = x - other.x;
14+
double dy = y - other.y;
15+
double dz = z - other.z;
16+
return (dx * dx) + (dy * dy) + (dz * dz);
17+
}
18+
}

src/main/java/io/github/legendaryforge/legendary/core/internal/runtime/DefaultCoreRuntime.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import io.github.legendaryforge.legendary.core.internal.legendary.arena.ArenaInvariantBridge;
1616
import io.github.legendaryforge.legendary.core.internal.legendary.arena.ArenaInvariantRegistry;
1717
import io.github.legendaryforge.legendary.core.internal.legendary.arena.PhaseGateInvariant;
18+
import io.github.legendaryforge.legendary.core.internal.legendary.arena.BoundsInvariant;
1819
import io.github.legendaryforge.legendary.core.internal.legendary.manager.LegendaryAccessEnforcingEncounterManager;
1920
import io.github.legendaryforge.legendary.core.internal.legendary.penalty.NoopLegendaryPenaltyStatus;
2021
import io.github.legendaryforge.legendary.core.internal.legendary.start.DefaultLegendaryStartPolicy;
@@ -86,14 +87,15 @@ public DefaultCoreRuntime(Optional<PlayerDirectory> players, Optional<PartyDirec
8687
durationTelemetry::onEnded);
8788

8889
Set<java.util.UUID> legendaryInstanceIds = ConcurrentHashMap.newKeySet();
89-
PhaseGateInvariant phaseGate = new PhaseGateInvariant();
90+
PhaseGateInvariant phaseGate = new PhaseGateInvariant();
91+
BoundsInvariant bounds = new BoundsInvariant(bus);
9092

91-
ArenaInvariantRegistry arenaRegistry = definitionId -> {
92-
Objects.requireNonNull(definitionId, "definitionId");
93-
return java.util.List.of(phaseGate);
94-
};
93+
ArenaInvariantRegistry arenaRegistry = definitionId -> {
94+
Objects.requireNonNull(definitionId, "definitionId");
95+
return java.util.List.of(phaseGate, bounds);
96+
};
9597

96-
ArenaInvariantBridge.bind(bus, arenaRegistry, legendaryInstanceIds::contains, legendaryInstanceIds::remove);
98+
ArenaInvariantBridge.bind(bus, arenaRegistry, legendaryInstanceIds::contains, legendaryInstanceIds::remove);
9799

98100
EncounterManager base = new DefaultEncounterManager(players, parties, Optional.of(bus));
99101
EncounterManager startGated = new LegendaryStartGatingEncounterManager(
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package io.github.legendaryforge.legendary.core.internal.legendary.arena;
2+
3+
import io.github.legendaryforge.legendary.core.api.event.EventBus;
4+
import io.github.legendaryforge.legendary.core.internal.legendary.arena.event.ArenaBoundsViolatedEvent;
5+
import io.github.legendaryforge.legendary.core.internal.legendary.arena.event.ArenaParticipationRevokedEvent;
6+
import io.github.legendaryforge.legendary.core.internal.event.SimpleEventBus;
7+
import java.util.ArrayList;
8+
import java.util.List;
9+
import java.util.UUID;
10+
import org.junit.jupiter.api.Test;
11+
12+
import static org.junit.jupiter.api.Assertions.*;
13+
14+
final class BoundsInvariantTest {
15+
16+
@Test
17+
void emitsRevokeOnlyWhenActiveAndOnlyOnce() {
18+
EventBus bus = new SimpleEventBus();
19+
BoundsInvariant inv = new BoundsInvariant(bus);
20+
21+
UUID instanceId = UUID.fromString("00000000-0000-0000-0000-000000000010");
22+
UUID playerId = UUID.fromString("00000000-0000-0000-0000-000000000011");
23+
24+
List<ArenaParticipationRevokedEvent> revoked = new ArrayList<>();
25+
bus.subscribe(ArenaParticipationRevokedEvent.class, revoked::add);
26+
27+
// Not active yet -> no revoke
28+
bus.post(ArenaBoundsViolatedEvent.withoutPosition(instanceId, playerId));
29+
assertEquals(0, revoked.size());
30+
31+
inv.onStart(instanceId);
32+
33+
// First violation while active -> revoke once
34+
bus.post(ArenaBoundsViolatedEvent.withoutPosition(instanceId, playerId));
35+
assertEquals(1, revoked.size());
36+
37+
// Duplicate violations -> no additional revoke
38+
bus.post(ArenaBoundsViolatedEvent.withoutPosition(instanceId, playerId));
39+
assertEquals(1, revoked.size());
40+
41+
inv.onEnd(instanceId);
42+
43+
// Ended -> no revoke
44+
bus.post(ArenaBoundsViolatedEvent.withoutPosition(instanceId, playerId));
45+
assertEquals(1, revoked.size());
46+
47+
inv.onCleanup(instanceId);
48+
inv.onStart(instanceId);
49+
50+
// After cleanup, dedup resets
51+
bus.post(ArenaBoundsViolatedEvent.withoutPosition(instanceId, playerId));
52+
assertEquals(2, revoked.size());
53+
}
54+
}

0 commit comments

Comments
 (0)