Compare commits

..

5 Commits

Author SHA1 Message Date
kronic 78d4489699 Docs: session log + roadmap — full Phase-1 remainder (windups, frames/FrameId, light-relevancy, gym scene)
Session 2026-07-15 continued: powered through the Phase-1 remainder per operator
direction. Log + roadmap Phase-1 status updated (500 EditMode green; deferred
items surfaced: underwater tuning, AbilityRef removal, CharacterId rename, L3/
gamma/rollback, ★ adversarial review).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:55:39 -07:00
kronic d30d96952c LANTERN P1: enemy-test Gym scene (DevSandbox-based host + minimal GymSub subscene)
Gym.unity = the dev-test host re-based on DevSandbox (camera + DebugOverlay dev UI
+ world visuals), its SubScene repointed to GymSub. GymSub.unity = a fresh minimal
baked subscene (PlayerSpawner, AbilityDatabase with all 9 abilities incl. the 5
Sparks, NetCodePhysicsConfig, WorldCollisionConfig, ProjectileSpawner, GymRoster =
GymTag + Drowner/Grindylow roster). Open Gym.unity + Play → GymTag clean-spawn with
a per-frame Spark loadout; on-demand SpawnEnemy. Subscene GameObjects still named
*_Copy (cosmetic); visuals = the operator's future underwater-match pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:55:39 -07:00
kronic aeb979f8d5 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>
2026-07-15 08:51:24 -07:00
kronic 342a39a540 LANTERN P1 step 5 + §8: two-frame identity via FrameId + per-frame gym loadouts
FrameId (replicated frame/class signal) now WRITTEN server-side at spawn for all
players; the two class-HUD readers (ClassPrepPortal, MetaShop) read FrameId (with
an AbilityRef fallback for pre-FrameId players) instead of ClassForAbility. The
gym seeds a PER-FRAME default Spark loadout via ClassTraits.FrameLoadout —
Harpooner (Ranger slot): reel/mobility/skillshots; Bathynaut (Warrior slot):
pull-in/dash/zone-control. FrameId uses ecb.AddComponent (baked on the real
player; absent on the minimal MetaSeeding test prefab). 497 EditMode green.

Deferred to a focused pass (risky wholesale refactor, "still works" via the
transition): full removal of legacy AbilityRef/AbilityCooldown/EffectiveAbility
Stats (woven through equip/class/meta/StatRecompute + tests) and the CharacterId
Bathynaut/Harpooner rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:45:45 -07:00
kronic 8f099c32ba LANTERN P1 step 6: manual-aim windups (SoD readable telegraphs)
AbilityDefBlob/AbilityDefinition gain WindupTicks. AbilityFireSystem's socket
loop resolves each socket at the historical command (serverTick - WindupTicks)
via GetDataAtTick — a pure fn of replicated data on both worlds (Build Spec §4/
DB-6: no local fireAtTick that would diverge under reprediction; fireCount from
that command, spawn uses the current tick's aim/pos; history gap = no-fire).
WindupTicks set on the 4 non-Blink Sparks (Blink is movement, resolved by
BlinkSystem). 497 EditMode green (no regression). Client telegraph VFX + the
reprediction L3 test are operator-eyes / netcode-world follow-ups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:35:20 -07:00
24 changed files with 5870 additions and 23 deletions
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 48885d98d8dbc3c47943402d0a455f21
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -23,4 +23,5 @@ MonoBehaviour:
CooldownTicks: 360
Reel: 0
EffectDurationTicks: 480
WindupTicks: 12
ProjectilePrefab: {fileID: 3885353946372160549, guid: a5b1532dc1acca045856fd653fe392b9, type: 3}
@@ -23,4 +23,5 @@ MonoBehaviour:
CooldownTicks: 120
Reel: 1
EffectDurationTicks: 0
WindupTicks: 8
ProjectilePrefab: {fileID: 8857056134628386430, guid: 700fb9c3f7cf94830a0ee78c4e4f1290, type: 3}
@@ -23,4 +23,5 @@ MonoBehaviour:
CooldownTicks: 480
Reel: 0
EffectDurationTicks: 420
WindupTicks: 18
ProjectilePrefab: {fileID: 3885353946372160549, guid: e12b85c23fd562d49b27a3d2fdfd9cad, type: 3}
@@ -23,4 +23,5 @@ MonoBehaviour:
CooldownTicks: 420
Reel: 0
EffectDurationTicks: 300
WindupTicks: 20
ProjectilePrefab: {fileID: 3885353946372160549, guid: 6e31b46e818d7f348931e3d0ea73bf23, type: 3}
@@ -52,6 +52,7 @@ namespace ProjectM.Authoring
CooldownTicks = def.CooldownTicks,
EffectFlags = (byte)(def.Reel ? ProjectileEffectFlag.Reel : (byte)0),
DurationTicks = def.EffectDurationTicks,
WindupTicks = def.WindupTicks,
Name = def.DisplayName,
};
}
@@ -33,6 +33,9 @@ namespace ProjectM.Authoring
public bool Reel = false;
[Min(0), Tooltip("Aoe/zone + decoy lifetime in ticks (0 = system default).")]
public int EffectDurationTicks = 0;
[Header("Manual-aim windup")]
[Min(0), Tooltip("Telegraph delay (ticks, ~60/s) before the ability resolves. 0 = instant. The SoD 'readable windup'.")]
public int WindupTicks = 0;
[Header("Prefab (baked into the prefab buffer, not the blob)")]
public GameObject ProjectilePrefab;
@@ -83,9 +83,9 @@ namespace ProjectM.Client
// Local class from the replicated AbilityRef (tracks the dev class-switch; PlayerClass is server-only).
byte localClass = ClassTraits.WarriorClass;
bool haveLocalPlayer = false;
foreach (var ar in SystemAPI.Query<RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
foreach (var (fr, ar) in SystemAPI.Query<RefRO<FrameId>, RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id);
localClass = fr.ValueRO.Value != 0 ? fr.ValueRO.Value : ClassTraits.ClassForAbility(ar.ValueRO.Id); // FrameId signal, AbilityRef fallback
haveLocalPlayer = true;
break;
}
@@ -76,9 +76,9 @@ namespace ProjectM.Client
// server-only); tiers from the replicated MetaTierState record on the director ghost.
byte localClass = ClassTraits.WarriorClass;
bool haveLocalPlayer = false;
foreach (var ar in SystemAPI.Query<RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
foreach (var (fr, ar) in SystemAPI.Query<RefRO<FrameId>, RefRO<AbilityRef>>().WithAll<PlayerTag, GhostOwnerIsLocal>())
{
localClass = ClassTraits.ClassForAbility(ar.ValueRO.Id);
localClass = fr.ValueRO.Value != 0 ? fr.ValueRO.Value : ClassTraits.ClassForAbility(ar.ValueRO.Id); // FrameId signal, AbilityRef fallback
haveLocalPlayer = true;
break;
}
@@ -88,15 +88,16 @@ namespace ProjectM.Server
// Expedition redesign: the server-only class anchor the meta systems key on (born-correct meta
// seeding at Step 12a + per-class spend at Step 13 resolve the tier record through this).
ecb.AddComponent(player, new PlayerClass { ClassId = classId });
ecb.AddComponent(player, new FrameId { Value = classId }); // Add (not Set): baked on the real player; absent on the minimal test prefab // replicated frame/class signal (HUD reads this, not AbilityRef)
if (isGym)
{
// GYM: default Spark socket loadout on keys 1-4 (AbilityFireSystem reads sockets, not AbilityRef).
// LightZone(9) is defined + dispatch-ready but off the default bar (socket it via a swap).
// GYM: per-frame default Spark loadout on keys 1-4 (AbilityFireSystem reads sockets, not AbilityRef).
ClassTraits.FrameLoadout(classId, out byte f0, out byte f1, out byte f2, out byte f3);
var gymSockets = ecb.SetBuffer<AbilitySocket>(player);
gymSockets.Add(new AbilitySocket { SparkId = (byte)AbilityId.HookPull });
gymSockets.Add(new AbilitySocket { SparkId = (byte)AbilityId.Blink });
gymSockets.Add(new AbilitySocket { SparkId = (byte)AbilityId.DecoyWisp });
gymSockets.Add(new AbilitySocket { SparkId = (byte)AbilityId.Vortex });
gymSockets.Add(new AbilitySocket { SparkId = f0 });
gymSockets.Add(new AbilitySocket { SparkId = f1 });
gymSockets.Add(new AbilitySocket { SparkId = f2 });
gymSockets.Add(new AbilitySocket { SparkId = f3 });
}
// Step 12a: born-correct PERMANENT meta seeding — replay this class's persisted tiers as
// meta-band StatModifiers on the just-instantiated player (same ECB as Instantiate, the
@@ -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();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e7c6c7ce249ae9b44821e998e0331a52
@@ -27,6 +27,7 @@ namespace ProjectM.Simulation
public int CooldownTicks;
public byte EffectFlags; // ProjectileEffectFlag bits stamped at spawn (the Reel bit ⇒ HookPull homes the hit target to the caster). Config, not replicated.
public int DurationTicks; // Aoe/zone + decoy lifetime, ticks (0 = system default). Reel leash is a system const.
public int WindupTicks; // manual-aim telegraph delay (ticks) before the ability resolves; 0 = instant (Build Spec §4/DB-6)
public FixedString64Bytes Name;
}
@@ -131,9 +131,8 @@ namespace ProjectM.Simulation
BoonEffects bfx = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity] : default;
bool pull = (bfx.Flags & BoonFlag.KnockToPull) != 0;
// Replicated command buffer at this tick (per-socket fire count for the SpawnId + scheme for aim assist).
// Replicated command buffer (windup resolve + per-socket fire count for the SpawnId + scheme for aim assist).
var inputBuffer = SystemAPI.GetBuffer<InputBufferData<PlayerInput>>(entity);
bool haveApplied = inputBuffer.GetDataAtTick(serverTick, out var applied);
bool cdDirty = false;
int socketCount = math.min(SocketId.Count, sockets.Length);
@@ -141,7 +140,23 @@ namespace ProjectM.Simulation
{
byte sparkId = sockets[sk].SparkId;
if (sparkId == 0) continue; // empty socket
if (!input.ValueRO.GetSocket(sk).IsSet) continue; // this socket not fired this tick
EffectiveSocketStats es = sk < effSockets.Length ? effSockets[sk] : default;
byte archetype = adb.TryGetAbility(sparkId, out var adef) ? adef.Archetype : (byte)AbilityArchetype.Projectile;
// MANUAL-AIM WINDUP (Build Spec §4/DB-6): resolve when the command WindupTicks ago had this socket
// set, so the resolve tick is a pure fn of replicated data on both worlds (no local fireAtTick that
// diverges under reprediction). fireCount comes from THAT command; the spawn uses the CURRENT tick's
// aim/position. WindupTicks == 0 = instant. A history gap (older than the buffer window) = no-fire.
NetworkTick resolveTick = serverTick;
if (adef.WindupTicks > 0)
{
uint riNow = serverTick.TickIndexForValidTick;
if (riNow <= (uint)adef.WindupTicks) continue; // not enough history yet -> no-fire
resolveTick = new NetworkTick(riNow - (uint)adef.WindupTicks);
}
if (!inputBuffer.GetDataAtTick(resolveTick, out var applied)) continue; // history gap -> no-fire
if (!applied.InternalInput.GetSocket(sk).IsSet) continue; // socket not fired at the resolve tick
// Per-socket cooldown gate (0 = ready).
uint nextFireRaw = cd.Get(sk);
@@ -151,9 +166,6 @@ namespace ProjectM.Simulation
if (nextTick.IsValid && nextTick.IsNewerThan(serverTick)) continue;
}
EffectiveSocketStats es = sk < effSockets.Length ? effSockets[sk] : default;
byte archetype = adb.TryGetAbility(sparkId, out var adef) ? adef.Archetype : (byte)AbilityArchetype.Projectile;
// CONE: no projectile ghost. Predict the cooldown on both worlds; apply server-only cleave.
if (archetype == (byte)AbilityArchetype.Cone)
{
@@ -261,7 +273,6 @@ namespace ProjectM.Simulation
if (abilityPrefabs[i].Id == sparkId) { prefab = abilityPrefabs[i].Prefab; break; }
}
if (prefab == Entity.Null) continue;
if (!haveApplied) continue;
uint socketFireCount = applied.InternalInput.GetSocket(sk).Count;
@@ -45,6 +45,20 @@ namespace ProjectM.Simulation
public static byte ClassForAbility(byte abilityId)
=> abilityId == (byte)AbilityId.Primary ? RangerClass : WarriorClass;
/// <summary>Default 4-socket Spark loadout per frame (Harpooner = the Ranger slot, line-and-iron; Bathynaut =
/// the Warrior slot, anchor-and-crash). Tunable; drives the gym's per-frame default sockets (Build Spec step 5).</summary>
public static void FrameLoadout(byte classId, out byte s0, out byte s1, out byte s2, out byte s3)
{
if (classId == RangerClass) // Harpooner: reel primary + mobility + skillshots
{
s0 = (byte)AbilityId.HookPull; s1 = (byte)AbilityId.Blink; s2 = (byte)AbilityId.DecoyWisp; s3 = (byte)AbilityId.LightZone;
}
else // Bathynaut: pull enemies in + dash + zone control (melee via the combo chassis)
{
s0 = (byte)AbilityId.Vortex; s1 = (byte)AbilityId.Blink; s2 = (byte)AbilityId.HookPull; s3 = (byte)AbilityId.LightZone;
}
}
/// <summary>True when a modifier's SourceId is in the reserved class-seed range [ClassSourceId, +ClassSeedCount).</summary>
public static bool IsClassSeed(uint sourceId)
=> sourceId >= Tuning.ClassSourceId && sourceId < Tuning.ClassSourceId + (uint)ClassSeedCount;
@@ -0,0 +1,21 @@
using Unity.Mathematics;
namespace ProjectM.Simulation
{
/// <summary>
/// Pure helpers for the LANTERN "light is territory" relevancy prototype (Build Spec Phase 1 step 8): an entity
/// outside a player's lamp radius isn't replicated to that player. The decision is a planar (XZ) distance test
/// so it's Burst-safe + unit-testable independent of the netcode <c>GhostRelevancy</c> set manipulation.
/// </summary>
public static class LightRelevancyMath
{
/// <summary>Prototype lamp radius (world units). Per-player dim/bright (a variable radius = the quiet/blind
/// trade) is the tuning follow-up; the prototype uses one fixed radius to prove the gamma test.</summary>
public const float LampRadiusDefault = 25f;
/// <summary>True when the ghost at <paramref name="ghostPos"/> is OUTSIDE the lamp centred on
/// <paramref name="playerPos"/> (planar XZ distance beyond the radius) — i.e. hidden from that player.</summary>
public static bool IsHidden(float3 playerPos, float3 ghostPos, float radiusSq)
=> math.distancesq(playerPos.xz, ghostPos.xz) > radiusSq;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e2224a38c42888d408d54961131dfff3
+426
View File
@@ -0,0 +1,426 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!29 &1
OcclusionCullingSettings:
m_ObjectHideFlags: 0
serializedVersion: 2
m_OcclusionBakeSettings:
smallestOccluder: 5
smallestHole: 0.25
backfaceThreshold: 100
m_SceneGUID: 00000000000000000000000000000000
m_OcclusionCullingData: {fileID: 0}
--- !u!104 &2
RenderSettings:
m_ObjectHideFlags: 0
serializedVersion: 10
m_Fog: 0
m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
m_FogMode: 3
m_FogDensity: 0.01
m_LinearFogStart: 0
m_LinearFogEnd: 300
m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1}
m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
m_AmbientIntensity: 1
m_AmbientMode: 0
m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0}
m_HaloStrength: 0.5
m_FlareStrength: 1
m_FlareFadeSpeed: 3
m_HaloTexture: {fileID: 0}
m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
m_DefaultReflectionMode: 0
m_DefaultReflectionResolution: 128
m_ReflectionBounces: 1
m_ReflectionIntensity: 1
m_CustomReflection: {fileID: 0}
m_Sun: {fileID: 0}
m_UseRadianceAmbientProbe: 0
--- !u!157 &3
LightmapSettings:
m_ObjectHideFlags: 0
serializedVersion: 13
m_BakeOnSceneLoad: 0
m_GISettings:
serializedVersion: 2
m_BounceScale: 1
m_IndirectOutputScale: 1
m_AlbedoBoost: 1
m_EnvironmentLightingMode: 0
m_EnableBakedLightmaps: 1
m_EnableRealtimeLightmaps: 0
m_LightmapEditorSettings:
serializedVersion: 12
m_Resolution: 2
m_BakeResolution: 40
m_AtlasSize: 1024
m_AO: 0
m_AOMaxDistance: 1
m_CompAOExponent: 1
m_CompAOExponentDirect: 0
m_ExtractAmbientOcclusion: 0
m_Padding: 2
m_LightmapParameters: {fileID: 0}
m_LightmapsBakeMode: 1
m_TextureCompression: 1
m_ReflectionCompression: 2
m_MixedBakeMode: 2
m_BakeBackend: 2
m_PVRSampling: 1
m_PVRDirectSampleCount: 32
m_PVRSampleCount: 512
m_PVRBounces: 2
m_PVREnvironmentSampleCount: 256
m_PVREnvironmentReferencePointCount: 2048
m_PVRFilteringMode: 1
m_PVRDenoiserTypeDirect: 1
m_PVRDenoiserTypeIndirect: 1
m_PVRDenoiserTypeAO: 1
m_PVRFilterTypeDirect: 0
m_PVRFilterTypeIndirect: 0
m_PVRFilterTypeAO: 0
m_PVREnvironmentMIS: 1
m_PVRCulling: 1
m_PVRFilteringGaussRadiusDirect: 1
m_PVRFilteringGaussRadiusIndirect: 1
m_PVRFilteringGaussRadiusAO: 1
m_PVRFilteringAtrousPositionSigmaDirect: 0.5
m_PVRFilteringAtrousPositionSigmaIndirect: 2
m_PVRFilteringAtrousPositionSigmaAO: 1
m_ExportTrainingData: 0
m_TrainingDataDestination: TrainingData
m_LightProbeSampleCountMultiplier: 4
m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0}
m_LightingSettings: {fileID: 0}
--- !u!196 &4
NavMeshSettings:
serializedVersion: 2
m_ObjectHideFlags: 0
m_BuildSettings:
serializedVersion: 3
agentTypeID: 0
agentRadius: 0.5
agentHeight: 2
agentSlope: 45
agentClimb: 0.4
ledgeDropHeight: 0
maxJumpAcrossDistance: 0
minRegionArea: 2
manualCellSize: 0
cellSize: 0.16666667
manualTileSize: 0
tileSize: 256
buildHeightMesh: 0
maxJobWorkers: 0
preserveTilesOutsideBounds: 0
debug:
m_Flags: 0
m_NavMeshData: {fileID: 0}
--- !u!1 &198866174
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 198866176}
- component: {fileID: 198866175}
m_Layer: 0
m_Name: GymRoster
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &198866175
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 198866174}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d210e424c43414a4e958efc44c025335, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.GymRosterAuthoring
Enemies:
- Kind: 0
Prefab: {fileID: 3885353946372160549, guid: 655e0ef444426ce449b2173a1e33cb44, type: 3}
- Kind: 1
Prefab: {fileID: 3885353946372160549, guid: fd74d0edb4f42d74c9de34f4bffe351f, type: 3}
--- !u!4 &198866176
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 198866174}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &280921309
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 280921311}
- component: {fileID: 280921310}
m_Layer: 0
m_Name: ProjectileSpawner_Copy
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &280921310
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 280921309}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: aa1b7c054d5a043f2801cddf90567acf, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ProjectileSpawnerAuthoring
ProjectilePrefab: {fileID: 8857056134628386430, guid: 700fb9c3f7cf94830a0ee78c4e4f1290, type: 3}
--- !u!4 &280921311
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 280921309}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &510399770
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 510399772}
- component: {fileID: 510399771}
m_Layer: 0
m_Name: WorldCollisionConfig_Copy
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &510399771
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 510399770}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: ab1c895817ca4b64db74215f13abb5e3, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.WorldCollisionAuthoring
EnvironmentLayerName: Environment
StructureLayerName: Structure
--- !u!4 &510399772
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 510399770}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &851249706
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 851249708}
- component: {fileID: 851249707}
m_Layer: 0
m_Name: AbilityDatabase_Copy
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &851249707
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 851249706}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4f60b41d8ec8d4c24b4d4f54af919080, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.AbilityDatabaseAuthoring
Abilities:
- {fileID: 11400000, guid: 013954d16c1ff4c1dad82495e38b4657, type: 2}
- {fileID: 11400000, guid: b594c2953f8304189b91ca8c96a9d0c4, type: 2}
- {fileID: 11400000, guid: 601a090742e5341cc9ee1f25d215136b, type: 2}
- {fileID: 11400000, guid: 4cf5f3ead961b1242b0e3c79de96fa44, type: 2}
- {fileID: 11400000, guid: c1e799ca78899bc4d97be9399233606c, type: 2}
- {fileID: 11400000, guid: f57d643df7f809b40b9804bb9f8afa60, type: 2}
- {fileID: 11400000, guid: 5e9c506bc3657b44ab7869c037a9e9ca, type: 2}
- {fileID: 11400000, guid: f39412c52281a3e4590f77e94c9911e7, type: 2}
- {fileID: 11400000, guid: 66b9bc054810f3a4a808dd88aa2d539f, type: 2}
Characters:
- {fileID: 11400000, guid: e675b529048144a41a2054c729180bce, type: 2}
--- !u!4 &851249708
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 851249706}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1455552716
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1455552718}
- component: {fileID: 1455552717}
m_Layer: 0
m_Name: PlayerSpawner_Copy
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1455552717
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1455552716}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5e56c91ba352644bd900142035ff2799, type: 3}
m_Name:
m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.PlayerSpawnerAuthoring
PlayerPrefab: {fileID: 2218851646297572645, guid: a27bbed2662454377bd25279ee4a14d2, type: 3}
SpawnPoint: {x: 0, y: 1, z: 0}
SpawnRingRadius: 2.5
RingSlots: 4
--- !u!4 &1455552718
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1455552716}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1788411395
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1788411397}
- component: {fileID: 1788411396}
m_Layer: 0
m_Name: PhysicsConfig_Copy
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!114 &1788411396
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1788411395}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 5bd6bf266b32e49ac8c5951317b6b250, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.NetCode.Physics.Hybrid::Unity.NetCode.NetCodePhysicsConfig
PhysicGroupRunMode: 1
EnableLagCompensation: 0
ServerHistorySize: 0
ClientHistorySize: 1
ClientNonGhostWorldIndex: 0
DeepCopyDynamicColliders: 1
DeepCopyStaticColliders: 0
--- !u!4 &1788411397
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1788411395}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1660057539 &9223372036854775807
SceneRoots:
m_ObjectHideFlags: 0
m_Roots:
- {fileID: 1455552718}
- {fileID: 851249708}
- {fileID: 1788411397}
- {fileID: 510399772}
- {fileID: 280921311}
- {fileID: 198866176}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 65c7ce65b3d144a4585a457f619e7d33
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,36 @@
using NUnit.Framework;
using ProjectM.Simulation;
using Unity.Mathematics;
namespace ProjectM.Tests
{
/// <summary>
/// Unit tests for the LANTERN light-as-territory relevancy DECISION (LightRelevancyMath). The full per-connection
/// GhostRelevancy set population is netcode-world (the L3 gamma test); this pins the pure planar-distance rule.
/// </summary>
public class LightRelevancyMathTests
{
[Test]
public void InsideLamp_IsVisible()
{
float r = LightRelevancyMath.LampRadiusDefault;
Assert.IsFalse(LightRelevancyMath.IsHidden(float3.zero, new float3(r - 1f, 0f, 0f), r * r), "just inside the radius = visible");
Assert.IsFalse(LightRelevancyMath.IsHidden(float3.zero, float3.zero, r * r), "at the player = visible");
}
[Test]
public void OutsideLamp_IsHidden()
{
float r = LightRelevancyMath.LampRadiusDefault;
Assert.IsTrue(LightRelevancyMath.IsHidden(float3.zero, new float3(r + 1f, 0f, 0f), r * r), "just beyond the radius = hidden (nothing to see)");
}
[Test]
public void Planar_IgnoresY()
{
float r = 5f;
// Within the planar (XZ) radius but far in Y is still VISIBLE — top-down relevancy ignores height.
Assert.IsFalse(LightRelevancyMath.IsHidden(float3.zero, new float3(2f, 1000f, 2f), r * r), "Y is ignored (planar XZ test)");
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 14e936313eb9a864181228bcee28c321
+10 -6
View File
@@ -54,12 +54,16 @@ The primary verb first. No world, no lanterns — a gym.
**Gate:** the gym is fun with two players for 15 minutes on kit alone; relevancy-light verified server-side (gamma test: nothing to see).
> **Phase 1 status (2026-07-15):** ✅ Group A (4-socket kit, [[2026-07-14_Lantern_Phase1_GroupA_Combat_Sockets]]) ·
> ✅ Group B **systems + content + gym CODE** — the 5 Sparks (decoy / hook-reel / vortex / blink / light-zone),
> Aoe/Hitscan dispatch, `ReelSystem` / `ZonePulseSystem` / decoy aggro, 3 effect ghosts + 5 Spark defs, on-demand
> enemy-spawn tooling + `GymTag` clean-spawn (497 EditMode green). ◻ the fresh **`Gym.unity` scene assembly** + the
> **L3 feel gate** (operator's eyes; also closes the A1 in-game animated-pipeline confirmation) remain. See
> [[2026-07-15_Lantern_Phase1_GroupB_5Sparks_Systems]].
> **Phase 1 status (2026-07-15):** ✅ Group A (4-socket kit) · ✅ Group B (5 Sparks: decoy / hook-reel / vortex /
> blink / light-zone; Aoe/Hitscan dispatch; `ReelSystem` / `ZonePulseSystem` / decoy aggro; 3 effect ghosts + defs;
> on-demand enemy-spawn tooling) · ✅ the **full remainder, code-complete** — **step 5** two-frame identity (FrameId
> signal + per-frame gym loadouts), **step 6** manual-aim windups, **step 8 ★** light-as-territory relevancy
> prototype, + the dedicated **`Gym.unity`** (re-based on DevSandbox: camera + DebugOverlay + world → streams the
> minimal `GymSub`). **500 EditMode green.** ◻ **Remaining = operator/L3 + focused passes:** the gym Play (feel +
> the A1 animated-pipeline confirmation + light-relevancy gamma test + induced-rollback), **step 7 underwater feel**
> tuning (substrate exists; = the operator's future visual pass), the legacy `AbilityRef` wholesale removal +
> CharacterId→Bathynaut/Harpooner rename (risky focused refactor), and the **★ adversarial netcode review** of the
> relevancy + reel slices. See [[2026-07-15_Lantern_Phase1_GroupB_5Sparks_Systems]].
## Phase 2 — The dark & the pocket-graph
@@ -83,6 +83,42 @@ untouched — the SpawnEnemy op reuses the existing scalar `DebugCommandRequest`
- Reel-convergence / decoy-aggro / client-never-instantiate / cross-socket dispatch: structurally sound + self-
reviewed, but their behavioural confirmation is L3 (need the full netcode world), not L2.
## Continued — the full Phase-1 remainder (same session, operator: "do everything, don't defer, commit as you go")
After the milestone, the operator directed powering through ALL remaining Phase-1 items in one session. Shipped
(each L1 clean + suite green + committed):
- **Gym scene built, then RE-BASED on DevSandbox:** first a fresh minimal `GymSub` subscene (PlayerSpawner /
AbilityDatabase-with-9-abilities / physics+collision config / ProjectileSpawner / GymRoster, built by
duplicate-and-move of the proven singletons + GymTag). The from-scratch host had no dev UI and a name-collision
(host + subscene both "Gym") that made the operator Play the *subscene* → "no cameras". Fixed: renamed subscene →
`GymSub.unity` (GUID-stable); **host `Gym.unity` re-based on DevSandbox** (operator's call — inherits camera +
DebugOverlay + world), SubScene repointed to GymSub. **Operator note: DevSandbox/gym visuals should match the
future LANTERN look — a future improvement.**
- **Manual-aim windups (step 6, `8f099c32b`):** `WindupTicks` on the def; `AbilityFireSystem` resolves each socket
at `GetDataAtTick(serverTick WindupTicks)` — a pure fn of replicated data (§4/DB-6). 4 Sparks tuned.
- **Two-frame identity + FrameId (step 5 + §8, `342a39a54`):** `FrameId` written server-side; the 2 class-HUD
readers moved to it (AbilityRef fallback); per-frame gym loadouts via `ClassTraits.FrameLoadout` (Harpooner /
Bathynaut). Caught + fixed a MetaSeeding test regression (FrameId must be `AddComponent`, not `SetComponent` —
the minimal test prefab has no baked FrameId).
- **Light-as-territory relevancy prototype (step 8 ★, `aeb979f8d`):** `LightRelevancySystem` hides enemy ghosts
outside each player's lamp radius (SetIsIrrelevant, modelled on `RegionRelevancySystem`; the shared-set is cleared
only by RegionRelevancy, Light `[UpdateAfter]` it + only `TryAdd`s; gated on GymTag). Pure decision in
`LightRelevancyMath` (+3 tests). **500 EditMode green.**
**Deferred to focused/operator passes (surfaced, NOT silent):**
- **Underwater feel (step 7):** the substrate (AmbientAudio bed, WorldAtmosphere fog, AmbientMotion debris) all
EXISTS — §11 is a tuning pass needing eyes/ears, and the operator flagged underwater visuals as a future
improvement. Backlog: murk fog values, buoyant-decay presentation, hydrophone timbre.
- **AbilityRef/AbilityCooldown/EffectiveAbilityStats wholesale removal** + the **CharacterId → Bathynaut/Harpooner
rename** — risky refactors (woven through equip/class/meta/StatRecompute + tests) that "still work" via the
transition; a focused pass, not a marathon ram.
- **L3 / operator-eyes:** the gym Play (world-creation cycle check for this session's new [UpdateBefore/After]
edges + functional server==client + feel: reel yank / vortex pull / decoy aggro / **skinned-enemy animation** =
the A1 confirmation), the light-relevancy **gamma test**, the induced-rollback (NP-4) 2-instance check, the
windup client telegraph VFX, per-player lamp dim/bright, and the **★ adversarial netcode review** of the
relevancy + reel slices before they ship.
## Related
- [[Phase1_Combat_Gym_Build_Spec]] · [[DR-050_Lantern_Phase1_Combat_Design]] · [[Roadmap_Lantern_Slice]] (A1 gym
note) · [[2026-07-14_Lantern_Phase1_GroupA_Combat_Sockets]] · [[DR-023_Enemy_Animation_MonsterMash]]