LANTERN P1 step 8: light-as-territory relevancy prototype (★)

Per connection, marks every ENEMY ghost OUTSIDE the player's lamp radius
IRRELEVANT (monsters in the dark aren't replicated — the gamma test). Modelled
on the proven RegionRelevancySystem: SetIsIrrelevant so players/global ghosts
stay relevant for free. Shared-set discipline — RegionRelevancySystem is the SOLE
clearer (runs every tick); LightRelevancySystem is [UpdateAfter] it and only ADDs
light hides via TryAdd (no clear; defensive against a region+light overlap dup).
Gated on GymTag (LANTERN-only; legacy region relevancy untouched). Pure decision
in LightRelevancyMath (+3 EditMode tests, 500 green). Per-player dim/bright
(variable radius) + the L3 gamma-test verification are follow-ups; the ★adversarial
netcode review is recommended before this ships.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:51:24 -07:00
parent 342a39a540
commit aeb979f8d5
6 changed files with 137 additions and 0 deletions
@@ -0,0 +1,74 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// LANTERN "light is territory" relevancy prototype (Build Spec Phase 1 step 8): per connection, marks every
/// ENEMY ghost OUTSIDE that player's lamp radius IRRELEVANT — so monsters in the dark aren't replicated (the
/// gamma test: "nothing to see"). Players + untagged/global ghosts stay relevant for free (SetIsIrrelevant).
/// <para>
/// SHARED-SET DISCIPLINE: <see cref="RegionRelevancySystem"/> is the SOLE clearer of the <c>GhostRelevancy</c>
/// set — its OnUpdate runs every tick (requires only <c>GhostRelevancy</c>), setting the mode + clearing +
/// adding its region hides. This system runs <b>[UpdateAfter(RegionRelevancySystem)]</b> and only ADDS light
/// hides (never clears) via <c>TryAdd</c> (defensive against a ghost hidden by BOTH region and light for one
/// connection — a duplicate <c>Add</c> would throw). Gated on <see cref="GymTag"/> so it runs ONLY in the
/// LANTERN gym; the legacy base/expedition game keeps region relevancy alone. Runs in
/// <see cref="GhostSimulationSystemGroup"/> before GhostSendSystem reads the set. Per-player dim/bright (a
/// variable radius) is the tuning follow-up; the prototype uses <see cref="LightRelevancyMath.LampRadiusDefault"/>.
/// </para>
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(GhostSimulationSystemGroup))]
[UpdateAfter(typeof(RegionRelevancySystem))]
public partial struct LightRelevancySystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<GhostRelevancy>();
state.RequireForUpdate<GymTag>(); // prototype: light-as-territory runs only in the LANTERN gym
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Each in-game connection's player position (per-player lamp radius = the dim/bright follow-up; fixed here).
var connPos = new NativeHashMap<int, float3>(8, Allocator.Temp);
foreach (var (owner, lt) in
SystemAPI.Query<RefRO<GhostOwner>, RefRO<LocalTransform>>().WithAll<PlayerTag>())
connPos[owner.ValueRO.NetworkId] = lt.ValueRO.Position;
if (connPos.IsEmpty) { connPos.Dispose(); return; }
// The set is already mode=SetIsIrrelevant + cleared + region-populated THIS tick by RegionRelevancySystem.
ref var relevancy = ref SystemAPI.GetSingletonRW<GhostRelevancy>().ValueRW;
var set = relevancy.GhostRelevancySet;
var conns = connPos.GetKeyValueArrays(Allocator.Temp);
float rSq = LightRelevancyMath.LampRadiusDefault * LightRelevancyMath.LampRadiusDefault;
foreach (var (ghost, lt) in
SystemAPI.Query<RefRO<GhostInstance>, RefRO<LocalTransform>>().WithAll<EnemyTag>())
{
int ghostId = ghost.ValueRO.ghostId;
if (ghostId == 0) continue; // ghost id not assigned yet this tick
float3 gp = lt.ValueRO.Position;
for (int i = 0; i < conns.Keys.Length; i++)
{
if (LightRelevancyMath.IsHidden(conns.Values[i], gp, rSq))
set.TryAdd(new RelevantGhostForConnection { Connection = conns.Keys[i], Ghost = ghostId }, 1);
}
}
conns.Dispose();
connPos.Dispose();
}
}
}