From 3109b86d71b0c8924d2f4f6e18a0cb5dcf659da9 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Sun, 21 Jun 2026 22:58:26 -0700 Subject: [PATCH] =?UTF-8?q?Slice=203:=20Expedition=20Combat=20Spine=20?= =?UTF-8?q?=E2=80=94=20epoch-seeded=20zone=20waves=20(DR-040)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reactivate the dormant Expedition region as a procedural combat venue. v1 loop: walk the gate -> fight an epoch-seeded enemy wave in the expedition -> clear -> return -> flat Ore reward (once per epoch) -> escalated retaliation base siege. - New sim types: ZoneEnemyTag, ZoneEnemyDirector (+ ZoneEnemyPrefab buffer), ZoneEnemyState, ZoneEnemyMath (grunt->charger composition by epoch). ZoneEnemyDirectorSystem (server, Burst): drip-spawns the wave at a deterministic ring under a MaxAlive cap while a player is out and the base is Calm; marks ClearedThisEpoch on a real clear. [UpdateAfter(ExpeditionFieldSystem)] only (avoids a sort cycle). - BLOCKER 1: EnemyAISystem region-filters target selection (player + structure snapshots gain parallel region lists; no base structures / no Core fallback for expedition husks). - BLOCKER 3: WaveSystem, ThreatDirectorSystem timeout cull, and CyclePhaseSystem DefendCleared + Core-breach cull all count/cull RegionTag{Base} husks only (the breach cull was caught region-blind by the post-impl review: a base breach wiped the live expedition wave and spuriously paid the reward). - BLOCKER 4: reward de-duped via CycleRuntime.LastRewardedEpoch + ClearedThisEpoch; ExpeditionGateSystem deposits RewardOre once/epoch. - ExpeditionFieldSystem teardown also culls zone enemies + region- guards the clutter loop. Subscene wired with the director + roster. 368/368 EditMode green + clean netcode Play smoke. Docs: DR-040 -> built, session log, CLAUDE.md cross-region tag-reaudit rule. Co-Authored-By: Claude Opus 4.8 --- .../Combat/ZoneEnemyDirectorAuthoring.cs | 65 ++++++ .../Combat/ZoneEnemyDirectorAuthoring.cs.meta | 2 + .../Scripts/Server/Combat/EnemyAISystem.cs | 36 ++-- .../Scripts/Server/Combat/WaveSystem.cs | 18 +- .../Server/Combat/ZoneEnemyDirectorSystem.cs | 134 ++++++++++++ .../Combat/ZoneEnemyDirectorSystem.cs.meta | 2 + .../Server/Economy/ExpeditionFieldSystem.cs | 18 +- .../Scripts/Server/World/CyclePhaseSystem.cs | 24 ++- .../Server/World/ExpeditionGateSystem.cs | 31 ++- .../Server/World/ThreatDirectorSystem.cs | 21 +- .../Scripts/Simulation/Combat/EnemyAIMath.cs | 31 +++ .../Simulation/Combat/ZoneEnemyComponents.cs | 57 ++++++ .../Combat/ZoneEnemyComponents.cs.meta | 2 + .../Simulation/Combat/ZoneEnemyMath.cs | 42 ++++ .../Simulation/Combat/ZoneEnemyMath.cs.meta | 2 + .../Simulation/World/CycleComponents.cs | 6 + Assets/_Project/Subscenes/Gameplay.unity | 153 +++++--------- .../_Project/Tests/EditMode/ChargerTests.cs | 2 + .../Tests/EditMode/CyclePhaseSystemTests.cs | 37 +++- .../Tests/EditMode/EndgameWinLoseTests.cs | 10 +- .../Tests/EditMode/EnemyAIMathTests.cs | 60 ++++++ .../EditMode/ExpeditionGateRewardTests.cs | 109 ++++++++++ .../ExpeditionGateRewardTests.cs.meta | 2 + .../_Project/Tests/EditMode/KnockbackTests.cs | 2 + .../_Project/Tests/EditMode/TelegraphTests.cs | 2 + .../EditMode/ThreatDirectorSystemTests.cs | 5 +- .../EditMode/ZoneEnemyDirectorSystemTests.cs | 193 ++++++++++++++++++ .../ZoneEnemyDirectorSystemTests.cs.meta | 2 + .../Tests/EditMode/ZoneEnemyMathTests.cs | 60 ++++++ .../Tests/EditMode/ZoneEnemyMathTests.cs.meta | 2 + CLAUDE.md | 6 +- ...21_Slice3_Expedition_Combat_Spine_Build.md | 49 +++++ .../DR-040_Slice3_Expedition_Combat_Spine.md | 20 +- 33 files changed, 1044 insertions(+), 161 deletions(-) create mode 100644 Assets/_Project/Scripts/Authoring/Combat/ZoneEnemyDirectorAuthoring.cs create mode 100644 Assets/_Project/Scripts/Authoring/Combat/ZoneEnemyDirectorAuthoring.cs.meta create mode 100644 Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs create mode 100644 Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyComponents.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyComponents.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs create mode 100644 Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs create mode 100644 Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs create mode 100644 Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs.meta create mode 100644 Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs create mode 100644 Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs.meta create mode 100644 Docs/Vault/07_Sessions/2026/2026-06-21_Slice3_Expedition_Combat_Spine_Build.md diff --git a/Assets/_Project/Scripts/Authoring/Combat/ZoneEnemyDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/Combat/ZoneEnemyDirectorAuthoring.cs new file mode 100644 index 000000000..30ba17e03 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/ZoneEnemyDirectorAuthoring.cs @@ -0,0 +1,65 @@ +using ProjectM.Simulation; +using Unity.Entities; +using UnityEngine; + +namespace ProjectM.Authoring +{ + /// + /// Authoring for the expedition zone-enemy director (singleton). Place ONE on a GameObject in the gameplay + /// subscene; the server-only ZoneEnemyDirectorSystem reads it. Bakes a + /// config + a pool (index 0 = Grunt variant, index 1 = Charger variant — the + /// epoch-seeded composition picks between them) + the initial . The entity carries no + /// transform; only the referenced prefabs need a runtime transform. The prefabs ALIAS the existing Husk ghost + /// prefabs, so zone enemies reuse the whole combat/readability stack with no new ghost type. + /// + public class ZoneEnemyDirectorAuthoring : MonoBehaviour + { + [Tooltip("Zone-enemy variant prefabs. Index 0 = Grunt, index 1 = Charger. Each must carry EnemyAuthoring + an interpolated GhostAuthoringComponent.")] + public GameObject[] EnemyPrefabs; + + [Min(1), Tooltip("Max concurrent live zone enemies (the v1 ghost-relevancy budget).")] public int MaxAlive = 12; + [Min(0f)] public float RingRadius = 14f; + [Min(1)] public int RingSlots = 10; + [Min(1), Tooltip("Ticks between individual spawns within a wave (~60/sec).")] public int SpawnIntervalTicks = 30; + [Min(0), Tooltip("Grunts in the epoch-1 wave (held roughly constant as the epoch climbs).")] public int GruntsPerWave = 4; + [Min(0), Tooltip("Chargers in the epoch-1 wave (grows ~1 per epoch -> charger-heavy).")] public int ChargersPerWave = 1; + [Min(0), Tooltip("Flat Ore banked to the shared ledger on a real clear, once per sortie.")] public int RewardOre = 25; + + private class ZoneEnemyDirectorBaker : Baker + { + public override void Bake(ZoneEnemyDirectorAuthoring authoring) + { + var entity = GetEntity(authoring, TransformUsageFlags.None); + + AddComponent(entity, new ZoneEnemyDirector + { + MaxAlive = authoring.MaxAlive, + RingRadius = authoring.RingRadius, + RingSlots = authoring.RingSlots, + SpawnIntervalTicks = authoring.SpawnIntervalTicks, + GruntsPerWave = authoring.GruntsPerWave, + ChargersPerWave = authoring.ChargersPerWave, + RewardOre = authoring.RewardOre, + }); + + var buffer = AddBuffer(entity); + if (authoring.EnemyPrefabs != null) + { + foreach (var prefab in authoring.EnemyPrefabs) + { + if (prefab != null) + buffer.Add(new ZoneEnemyPrefab { Prefab = GetEntity(prefab, TransformUsageFlags.Dynamic) }); + } + } + + AddComponent(entity, new ZoneEnemyState + { + SpawnCounter = 0, + RemainingToSpawn = 0, + NextSpawnTick = 0, + SeededEpoch = 0, + }); + } + } + } +} diff --git a/Assets/_Project/Scripts/Authoring/Combat/ZoneEnemyDirectorAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Combat/ZoneEnemyDirectorAuthoring.cs.meta new file mode 100644 index 000000000..ab91c88e4 --- /dev/null +++ b/Assets/_Project/Scripts/Authoring/Combat/ZoneEnemyDirectorAuthoring.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5918787bb2ebb7747afe473a7937ed53 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs index 8a72a5afc..1b04c342e 100644 --- a/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/EnemyAISystem.cs @@ -40,8 +40,9 @@ namespace ProjectM.Server // Snapshot living player targets once this tick (stable query order). var playerEntities = new NativeList(Allocator.Temp); var playerPositions = new NativeList(Allocator.Temp); - foreach (var (xform, health, entity) in - SystemAPI.Query, RefRO>() + var playerRegions = new NativeList(Allocator.Temp); + foreach (var (xform, health, region, entity) in + SystemAPI.Query, RefRO, RefRO>() .WithAll() .WithEntityAccess()) { @@ -49,6 +50,7 @@ namespace ProjectM.Server continue; // don't chase or strike a corpse playerEntities.Add(entity); playerPositions.Add(xform.ValueRO.Position); + playerRegions.Add(region.ValueRO.Region); } // EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon carry Health; automation @@ -56,8 +58,9 @@ namespace ProjectM.Server // the base even with every player dead/away (the locked 'push for structures' fork). var structureEntities = new NativeList(Allocator.Temp); var structurePositions = new NativeList(Allocator.Temp); - foreach (var (sx, sh, se) in - SystemAPI.Query, RefRO>() + var structureRegions = new NativeList(Allocator.Temp); + foreach (var (sx, sh, sr, se) in + SystemAPI.Query, RefRO, RefRO>() .WithAll() .WithEntityAccess()) { @@ -65,6 +68,7 @@ namespace ProjectM.Server continue; // skip a structure already at 0 (pending destroy this tick) structureEntities.Add(se); structurePositions.Add(sx.ValueRO.Position); + structureRegions.Add(sr.ValueRO.Region); } // END-1: the Engine Core is a FALLBACK target. When no living player/structure remains, undefended @@ -77,8 +81,10 @@ namespace ProjectM.Server { playerEntities.Dispose(); playerPositions.Dispose(); + playerRegions.Dispose(); structureEntities.Dispose(); structurePositions.Dispose(); + structureRegions.Dispose(); return; } @@ -95,12 +101,14 @@ namespace ProjectM.Server bool sweep = havePhysics && envMask != 0u; const float SweepRadius = 0.5f; // collide-and-slide sphere radius for Husk movement - foreach (var (xform, stats, cooldown, knockback, windup) in + foreach (var (xform, stats, cooldown, knockback, windup, region) in SystemAPI.Query, RefRO, RefRW, - RefRW, RefRW>() + RefRW, RefRW, RefRO>() .WithAll().WithNone()) { float3 pos = xform.ValueRO.Position; + byte huskRegion = region.ValueRO.Region; + bool huskCoreAlive = coreAlive && huskRegion == RegionId.Base; // Knockback overrides seek/strike for its window — EnemyAISystem stays the SOLE writer of Position. var kb = knockback.ValueRO; @@ -122,8 +130,8 @@ namespace ProjectM.Server // EB-1 fortress aggro: nearest of players (weight 1) + structures (StructureAggroWeight) — a wall/ // turret is the preferred target unless a player is in the way (closer after weighting). - EnemyAIMath.PickWeightedNearest(pos, playerPositions, structurePositions, structAggro, out bool tgtIsStruct, out int tgtIdx); - if (tgtIdx < 0 && !coreAlive) + EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, huskRegion, structAggro, out bool tgtIsStruct, out int tgtIdx); + if (tgtIdx < 0 && !huskCoreAlive) continue; // no player/structure and no Core -> nothing to seek Entity targetEntity = tgtIdx < 0 ? Entity.Null : (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]); @@ -201,12 +209,14 @@ namespace ProjectM.Server uint ChargerWindupTicks = (uint)math.max(1f, tune.ChargerWindupTicks); // readable telegraph lead uint ChargerWhiffStaggerTicks = (uint)math.max(1f, tune.ChargerWhiffStaggerTicks); // punish window uint chargerWhiffsThisTick = 0; - foreach (var (xform, stats, cooldown, knockback, windup, lunge) in + foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in SystemAPI.Query, RefRO, RefRW, - RefRW, RefRW, RefRW>() + RefRW, RefRW, RefRW, RefRO>() .WithAll()) { float3 pos = xform.ValueRO.Position; + byte cHuskRegion = region.ValueRO.Region; + bool cHuskCoreAlive = coreAlive && cHuskRegion == RegionId.Base; // 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer). var kb = knockback.ValueRO; @@ -227,8 +237,8 @@ namespace ProjectM.Server } // EB-1 fortress aggro: same weighted target selection as the Grunt pass (shared helper). - EnemyAIMath.PickWeightedNearest(pos, playerPositions, structurePositions, structAggro, out bool cIsStruct, out int cIdx); - if (cIdx < 0 && !coreAlive) + EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, cHuskRegion, structAggro, out bool cIsStruct, out int cIdx); + if (cIdx < 0 && !cHuskCoreAlive) continue; Entity cTargetEntity = cIdx < 0 ? Entity.Null : (cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]); @@ -340,8 +350,10 @@ namespace ProjectM.Server ecb.Dispose(); playerEntities.Dispose(); playerPositions.Dispose(); + playerRegions.Dispose(); structureEntities.Dispose(); structurePositions.Dispose(); + structureRegions.Dispose(); } // Swept collide-and-slide for server-authoritative Husk movement: sphere-cast the intended step against diff --git a/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs b/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs index 9f90aef51..ec136cb5d 100644 --- a/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs +++ b/Assets/_Project/Scripts/Server/Combat/WaveSystem.cs @@ -23,7 +23,6 @@ namespace ProjectM.Server [UpdateInGroup(typeof(SimulationSystemGroup))] public partial struct WaveSystem : ISystem { - EntityQuery m_AliveHusks; [BurstCompile] public void OnCreate(ref SystemState state) @@ -31,7 +30,6 @@ namespace ProjectM.Server state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); - m_AliveHusks = state.GetEntityQuery(ComponentType.ReadOnly()); } [BurstCompile] @@ -100,11 +98,19 @@ namespace ProjectM.Server wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks)); } } - else if (m_AliveHusks.CalculateEntityCount() == 0) + else { - // Wave cleared: calm before the next. - wave.Phase = WavePhase.Lull; - wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.LullTicks)); + // Wave fully spawned: cleared only when no BASE husk remains. Expedition zone enemies are also + // EnemyTag but RegionTag{Expedition}; they must NOT hold the base siege open (DR-040 BLOCKER 3). + int baseHusks = 0; + foreach (var hr in SystemAPI.Query>().WithAll()) + if (hr.ValueRO.Region == RegionId.Base) baseHusks++; + if (baseHusks == 0) + { + // Wave cleared: calm before the next. + wave.Phase = WavePhase.Lull; + wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.LullTicks)); + } } } diff --git a/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs b/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs new file mode 100644 index 000000000..2ea3e261b --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs @@ -0,0 +1,134 @@ +using ProjectM.Simulation; +using Unity.Burst; +using Unity.Collections; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Server +{ + /// + /// Server-only expedition zone-enemy director: while a player is OUT in the expedition region and the base is in + /// , it seeds and drip-spawns one epoch-seeded combat wave around the expedition + /// origin. The wave size + grunt/charger composition is pure of the + /// (grunt-heavy -> charger-heavy as the epoch climbs), spawned one + /// every at a deterministic ring, capped at + /// concurrent (the v1 ghost-relevancy budget). Each enemy is the + /// existing Husk ghost prefab + RegionTag{Expedition} + , so it reuses the whole + /// combat/readability/AI stack (the per-region AI filter keeps it seeking the expedition player only). When the + /// wave is fully spawned and every zone enemy is dead, it marks once — + /// the gate's once-per-epoch Ore reward reads that on the player's return. + /// + /// Ordering: [UpdateAfter(ExpeditionFieldSystem)] ONLY. ExpeditionFieldSystem is itself + /// [UpdateAfter(CyclePhaseSystem)], so ALSO declaring [UpdateBefore(CyclePhaseSystem)] here (as the + /// v1 plan first sketched) would close a CyclePhase->Field->Zone->CyclePhase sort cycle that throws at Play + /// world creation and is invisible to EditMode. Running after the field manager also reads the freshly-bumped + /// epoch + current phase. Zone enemies are interpolated ghosts, moved server-only — no prediction. + /// + [BurstCompile] + [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)] + [UpdateInGroup(typeof(SimulationSystemGroup))] + [UpdateAfter(typeof(ExpeditionFieldSystem))] + public partial struct ZoneEnemyDirectorSystem : ISystem + { + EntityQuery m_ZoneEnemies; + + [BurstCompile] + public void OnCreate(ref SystemState state) + { + state.RequireForUpdate(); + state.RequireForUpdate(); + state.RequireForUpdate(); + m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly()); + } + + [BurstCompile] + public void OnUpdate(ref SystemState state) + { + var serverTick = SystemAPI.GetSingleton().ServerTick; + if (!serverTick.IsValid) + return; + uint now = serverTick.TickIndexForValidTick; + + // Per-player presence: only run while someone is OUT in the expedition (mirrors ExpeditionFieldSystem). + int expeditionPlayers = 0; + foreach (var region in SystemAPI.Query>().WithAll()) + if (region.ValueRO.Region == RegionId.Expedition) + expeditionPlayers++; + if (expeditionPlayers == 0) + return; // nobody out there: the field manager owns teardown, we do nothing + + var directorEntity = SystemAPI.GetSingletonEntity(); + var dir = SystemAPI.GetComponent(directorEntity); + var zs = SystemAPI.GetComponent(directorEntity); + var prefabs = SystemAPI.GetBuffer(directorEntity); + if (prefabs.Length == 0) + return; + + var cycleEntity = SystemAPI.GetSingletonEntity(); + var cycle = SystemAPI.GetComponent(cycleEntity); + var runtime = SystemAPI.GetComponent(cycleEntity); + int epoch = runtime.ExpeditionEpoch; + + // (Re)seed this epoch's wave once — its OWN counter, never WaveState's. + if (zs.SeededEpoch != epoch) + { + zs.SeededEpoch = epoch; + zs.SpawnCounter = 0; + zs.RemainingToSpawn = ZoneEnemyMath.WaveSize(epoch, dir.GruntsPerWave, dir.ChargersPerWave); + zs.NextSpawnTick = TickUtil.NonZero(now); // first enemy this tick + } + + int aliveZone = m_ZoneEnemies.CalculateEntityCount(); + + if (zs.RemainingToSpawn > 0) + { + // Spawn only in Calm (a base Siege pauses the expedition wave; it resumes when the base is safe), one + // per cadence, and only while under the concurrent cap. + bool calm = cycle.Phase == CyclePhase.Calm; + bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick); + if (calm && dueNow && aliveZone < math.max(1, dir.MaxAlive)) + { + float3 baseCenter = new float3(0f, 1f, 0f); + if (SystemAPI.TryGetSingleton(out var anchor)) + baseCenter = BaseGridMath.PlotCenter(anchor); + float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter); + + int slot = (int)zs.SpawnCounter; + bool charger = ZoneEnemyMath.IsChargerSlot(epoch, slot, dir.GruntsPerWave, dir.ChargersPerWave); + int prefabIdx = charger ? 1 : 0; + if (prefabIdx >= prefabs.Length) prefabIdx = prefabs.Length - 1; + var prefab = prefabs[prefabIdx].Prefab; + + float3 pos = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius); + pos.y = origin.y; + + var ecb = new EntityCommandBuffer(Allocator.Temp); + var enemy = ecb.Instantiate(prefab); + // Preserve the prefab's baked Scale ([GhostField]) + rotation — FromPosition would reset Scale->1. + var baked = state.EntityManager.GetComponentData(prefab); + ecb.SetComponent(enemy, baked.WithPosition(pos)); + ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition }); + ecb.AddComponent(enemy); + ecb.Playback(state.EntityManager); + ecb.Dispose(); + + zs.SpawnCounter += 1; + zs.RemainingToSpawn -= 1; + zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks)); + } + } + else if (aliveZone == 0 && runtime.ClearedThisEpoch == 0) + { + // Wave fully spawned AND every zone enemy dead -> a REAL clear (the player killed them; the empty-edge + // teardown can't reach here because we early-return when no one is out). Mark once; the gate pays the + // once-per-epoch Ore reward on the player's return to base. + runtime.ClearedThisEpoch = 1; + SystemAPI.SetComponent(cycleEntity, runtime); + } + + SystemAPI.SetComponent(directorEntity, zs); + } + } +} diff --git a/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs.meta b/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs.meta new file mode 100644 index 000000000..8b29587c0 --- /dev/null +++ b/Assets/_Project/Scripts/Server/Combat/ZoneEnemyDirectorSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4dc121e99d0e53640b5e6815640a0bc6 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs b/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs index 69746ae29..272f5a4a8 100644 --- a/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs +++ b/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs @@ -50,7 +50,10 @@ namespace ProjectM.Server // empty -> occupied: a new sortie begins; bump the epoch so the field reseeds fresh. if (occupied && !wasOccupied) + { runtime.ExpeditionEpoch += 1; + runtime.ClearedThisEpoch = 0; // a fresh sortie has not been cleared yet (gates the once-per-epoch reward) + } float3 baseCenter = new float3(0f, 1f, 0f); if (SystemAPI.TryGetSingleton(out var anchor)) @@ -112,7 +115,7 @@ namespace ProjectM.Server runtime.LastSpawnedEpoch = runtime.ExpeditionEpoch; } - // DESTROY: the last player left the expedition — clear the whole field (nodes + clutter). + // DESTROY: the last player left the expedition — clear the whole field (nodes + clutter + zone enemies). if (wasOccupied && !occupied) { // Only EXPEDITION nodes — the base field is permanent RegionTag{Base} and must NOT be torn down here. @@ -120,8 +123,17 @@ namespace ProjectM.Server SystemAPI.Query, RefRO>().WithEntityAccess()) if (region.ValueRO.Region == RegionId.Expedition) ecb.DestroyEntity(e); - foreach (var (bc, e) in SystemAPI.Query>().WithEntityAccess()) - ecb.DestroyEntity(e); + // Blight clutter is Expedition-only today; guard by region defensively (DR-040 MINOR 1). + foreach (var (region, e) in + SystemAPI.Query>().WithAll().WithEntityAccess()) + if (region.ValueRO.Region == RegionId.Expedition) + ecb.DestroyEntity(e); + // Zone combat enemies share this single lifetime point (DR-040). EnemyTag + ZoneEnemyTag + + // RegionTag{Expedition}; disjoint from the node/clutter queries, so no double-destroy. + foreach (var (region, e) in + SystemAPI.Query>().WithAll().WithEntityAccess()) + if (region.ValueRO.Region == RegionId.Expedition) + ecb.DestroyEntity(e); } runtime.PrevExpeditionOccupied = (byte)(occupied ? 1 : 0); diff --git a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs index b88859f72..440d51d5f 100644 --- a/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs +++ b/Assets/_Project/Scripts/Server/World/CyclePhaseSystem.cs @@ -25,14 +25,11 @@ namespace ProjectM.Server [UpdateBefore(typeof(WaveSystem))] public partial struct CyclePhaseSystem : ISystem { - EntityQuery m_AliveHusks; - [BurstCompile] public void OnCreate(ref SystemState state) { state.RequireForUpdate(); state.RequireForUpdate(); - m_AliveHusks = state.GetEntityQuery(ComponentType.ReadOnly()); } [BurstCompile] @@ -104,15 +101,17 @@ namespace ProjectM.Server cycle.Phase = CyclePhase.Calm; cycle.PhaseEndTick = 0; - // The siege ends: despawn every remaining Husk (the locked despawn-on-breach fork) + reset the wave - // so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths. - var husks = m_AliveHusks.ToEntityArray(Allocator.Temp); + // The siege ends: despawn the base siege Husks (the locked despawn-on-breach fork) + reset the + // wave so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths. var ecb = new EntityCommandBuffer(Allocator.Temp); - for (int hi = 0; hi < husks.Length; hi++) - ecb.DestroyEntity(husks[hi]); + // Slice 3: cull the BASE wave only — an Expedition wave runs in its own region and must + // survive a base Core breach. A region-blind EnemyTag wipe would also spuriously trip the + // zone director's aliveZone==0 clear/reward edge. Mirrors ThreatDirectorSystem + DefendCleared. + foreach (var (hr, he) in SystemAPI.Query>().WithAll().WithEntityAccess()) + if (hr.ValueRO.Region == RegionId.Base) + ecb.DestroyEntity(he); ecb.Playback(state.EntityManager); ecb.Dispose(); - husks.Dispose(); if (SystemAPI.TryGetSingletonEntity(out var waveLost)) { var wl = SystemAPI.GetComponent(waveLost); @@ -189,9 +188,14 @@ namespace ProjectM.Server { if (!SystemAPI.TryGetSingleton(out var wave)) return false; + // Cleared only when no BASE husk remains: expedition zone enemies (EnemyTag + RegionTag{Expedition}) + // must not hold the base siege open (DR-040 BLOCKER 3 — same global-count soft-lock as WaveSystem). + int baseHusks = 0; + foreach (var hr in SystemAPI.Query>().WithAll()) + if (hr.ValueRO.Region == RegionId.Base) baseHusks++; return wave.WaveNumber > defendStartWave && wave.RemainingToSpawn == 0 - && m_AliveHusks.CalculateEntityCount() == 0; + && baseHusks == 0; } } } diff --git a/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs b/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs index 167f34554..5da5c48d3 100644 --- a/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs +++ b/Assets/_Project/Scripts/Server/World/ExpeditionGateSystem.cs @@ -73,12 +73,33 @@ namespace ProjectM.Server // A player returned to base from an expedition -> signal the ThreatDirector (it sizes/arms any // retaliation siege). The gate teleports the returner out of its radius, so this fires once per return. - if (returnedToBase && SystemAPI.TryGetSingletonEntity(out var threatEntity)) + if (returnedToBase) { - var threat = SystemAPI.GetComponent(threatEntity); - threat.PendingReturns += 1; - threat.ExpeditionsCompleted += 1; - SystemAPI.SetComponent(threatEntity, threat); + if (SystemAPI.TryGetSingletonEntity(out var threatEntity)) + { + var threat = SystemAPI.GetComponent(threatEntity); + threat.PendingReturns += 1; + threat.ExpeditionsCompleted += 1; + SystemAPI.SetComponent(threatEntity, threat); + } + + // Once-per-epoch zone-clear reward: a returner banks flat Ore IFF this epoch's expedition wave was + // actually cleared and not yet rewarded. Resolved ONCE here (not per-returner) so two same-tick co-op + // returns pay exactly once (DR-040 BLOCKER 4) and gate re-entry before a clear can't farm (MINOR 2). + if (SystemAPI.HasSingleton() + && SystemAPI.TryGetSingleton(out var zoneDir) + && SystemAPI.HasSingleton()) + { + var cycleEntity = SystemAPI.GetSingletonEntity(); + var runtime = SystemAPI.GetComponent(cycleEntity); + if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch) + { + var ledger = SystemAPI.GetBuffer(SystemAPI.GetSingletonEntity()); + StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre); + runtime.LastRewardedEpoch = runtime.ExpeditionEpoch; + SystemAPI.SetComponent(cycleEntity, runtime); + } + } } } } diff --git a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs index ccfea6d78..8303b7689 100644 --- a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs +++ b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs @@ -28,7 +28,6 @@ namespace ProjectM.Server [UpdateBefore(typeof(CyclePhaseSystem))] public partial struct ThreatDirectorSystem : ISystem { - EntityQuery m_Husks; [BurstCompile] public void OnCreate(ref SystemState state) @@ -37,7 +36,6 @@ namespace ProjectM.Server state.RequireForUpdate(); state.RequireForUpdate(); state.RequireForUpdate(); - m_Husks = state.GetEntityQuery(ComponentType.ReadOnly()); } [BurstCompile] @@ -109,17 +107,14 @@ namespace ProjectM.Server var start = new NetworkTick(threat.SiegeStartTick); if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks) { - // Collapse the siege: cull every remaining Husk (a cached tag query, never RefRO on a tag). - var husks = m_Husks.ToEntityArray(Allocator.Temp); - if (husks.Length > 0) - { - var ecb = new EntityCommandBuffer(Allocator.Temp); - for (int i = 0; i < husks.Length; i++) - ecb.DestroyEntity(husks[i]); - ecb.Playback(state.EntityManager); - ecb.Dispose(); - } - husks.Dispose(); + // Collapse the siege: cull every remaining BASE Husk only (expedition zone enemies are also + // EnemyTag but RegionTag{Expedition}; the timeout must not destroy them — DR-040 BLOCKER 3). + var ecb = new EntityCommandBuffer(Allocator.Temp); + foreach (var (hr, he) in SystemAPI.Query>().WithAll().WithEntityAccess()) + if (hr.ValueRO.Region == RegionId.Base) + ecb.DestroyEntity(he); + ecb.Playback(state.EntityManager); + ecb.Dispose(); if (SystemAPI.TryGetSingletonEntity(out var waveEntity)) { diff --git a/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs b/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs index 78bfccdb5..3956d1c80 100644 --- a/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs +++ b/Assets/_Project/Scripts/Simulation/Combat/EnemyAIMath.cs @@ -95,5 +95,36 @@ namespace ProjectM.Simulation if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; } } } + + /// + /// Region-scoped variant of : + /// considers ONLY targets whose stored region byte equals , so a Husk seeks within + /// its own region only (an expedition Husk never paths across the 1000-unit gap to a base player/structure, + /// and a base Husk never to an expedition target). / + /// are parallel to the position lists; the returned + /// maps to the FULL list so the caller's by-index entity lookup stays valid. Pure, Burst-safe (byte compares). + /// + public static void PickWeightedNearest(float3 from, NativeList playerPositions, + NativeList playerRegions, NativeList structurePositions, NativeList structureRegions, + byte region, float structureWeight, out bool isStructure, out int index) + { + isStructure = false; + index = -1; + float bestSq = float.MaxValue; + for (int i = 0; i < playerPositions.Length; i++) + { + if (playerRegions[i] != region) continue; + float sq = math.lengthsq(playerPositions[i].xz - from.xz); + if (sq < bestSq) { bestSq = sq; index = i; isStructure = false; } + } + float w = math.max(0f, structureWeight); + float wsq = w * w; // applied to SQUARED distance so the weight scales true distance + for (int i = 0; i < structurePositions.Length; i++) + { + if (structureRegions[i] != region) continue; + float sq = math.lengthsq(structurePositions[i].xz - from.xz) * wsq; + if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; } + } + } } } diff --git a/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyComponents.cs b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyComponents.cs new file mode 100644 index 000000000..406137da0 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyComponents.cs @@ -0,0 +1,57 @@ +using Unity.Entities; + +namespace ProjectM.Simulation +{ + /// + /// Marker on a runtime-spawned EXPEDITION combat enemy (a Husk variant the server's ZoneEnemyDirectorSystem + /// instantiates in the expedition region). Distinguishes zone enemies from base-siege Husks for the single + /// teardown point in ExpeditionFieldSystem and for the per-epoch clear count. Zone enemies ALSO keep + /// EnemyTag + RegionTag{Expedition}, so Slice-1 readability/health-bars/telegraphs/damage and the + /// per-region AI target filter all work for them unchanged. + /// + public struct ZoneEnemyTag : IComponentData { } + + /// + /// Baked config for the expedition zone-enemy director (one singleton in the gameplay subscene; the server-only + /// ZoneEnemyDirectorSystem reads it). One epoch-seeded wave per sortie: a + + /// baseline that shifts grunt->charger as the expedition epoch climbs (the + /// highest-leverage variety lever), spawned one every at a deterministic ring of + /// slots / around the expedition origin, capped at + /// concurrent (the v1 ghost-relevancy mitigation). On a real clear the returning player + /// banks to the shared ledger, once per epoch. + /// + public struct ZoneEnemyDirector : IComponentData + { + public int MaxAlive; + public float RingRadius; + public int RingSlots; + public int SpawnIntervalTicks; + public int GruntsPerWave; + public int ChargersPerWave; + public int RewardOre; + } + + /// + /// Baked pool of zone-enemy prefab variants the director draws from by composition. Index 0 = Grunt variant, + /// index 1 = Charger variant (see ). Aliases the existing Husk ghost + /// prefabs so zone enemies reuse the whole combat/readability stack with no new ghost type. + /// + public struct ZoneEnemyPrefab : IBufferElementData + { + public Entity Prefab; + } + + /// + /// Runtime state of the zone-enemy director (server-only singleton; NOT replicated). Tracks its OWN spawn + /// counter (never WaveState.SpawnCounter), how many enemies remain to spawn for the current epoch's wave, the + /// next spawn tick, and the epoch the current wave was seeded for (re-syncs when CycleRuntime.ExpeditionEpoch + /// bumps). All session-scoped: 0-defaults safely on load (no SaveData field — DR-040 defers SaveData v6). + /// + public struct ZoneEnemyState : IComponentData + { + public uint SpawnCounter; + public int RemainingToSpawn; + public uint NextSpawnTick; + public int SeededEpoch; + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyComponents.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyComponents.cs.meta new file mode 100644 index 000000000..fbeff8d04 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyComponents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0aedc87a4719411418ca5f914af613b8 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs new file mode 100644 index 000000000..3102b0e13 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs @@ -0,0 +1,42 @@ +using Unity.Mathematics; + +namespace ProjectM.Simulation +{ + /// + /// Pure, deterministic composition math for the expedition zone-enemy wave — no RNG state, no wall-clock — so the + /// per-epoch wave is reproducible across restarts/saves and EditMode-unit-testable without an ECS world (mirrors + /// / ProductionMath). The highest-leverage Slice-3 variety lever: the encounter + /// COMPOSITION shifts grunt-heavy -> charger-heavy as the expedition epoch climbs (grunt count stays + /// fixed; the per-epoch growth is all chargers). + /// + public static class ZoneEnemyMath + { + /// + /// Total enemies in this epoch's wave: the baked + + /// baseline plus one extra per epoch beyond the first (a gentle ramp). Lower-bounded at 1 so an occupied + /// expedition always has a fight. is the monotonic sortie counter (>=1 in practice). + /// + public static int WaveSize(int epoch, int gruntsPerWave, int chargersPerWave) + { + int e = math.max(1, epoch); + int baseCount = math.max(0, gruntsPerWave) + math.max(0, chargersPerWave); + return math.max(1, baseCount + (e - 1)); + } + + /// + /// Deterministic grunt/charger pick for spawn of this epoch's wave. The charger + /// count is + (epoch - 1), clamped to the wave size, assigned to the LAST + /// slots; everything earlier is a Grunt. So the grunt count stays fixed at + /// and the wave skews charger-heavy as the epoch climbs. Returns true for a Charger slot. Stable per + /// (epoch, slot) — a replayed wave is identical. Pure integer math (Burst-safe; no enum, no RNG). + /// + public static bool IsChargerSlot(int epoch, int slot, int gruntsPerWave, int chargersPerWave) + { + int e = math.max(1, epoch); + int size = WaveSize(epoch, gruntsPerWave, chargersPerWave); + int chargers = math.clamp(math.max(0, chargersPerWave) + (e - 1), 0, size); + int s = ((slot % size) + size) % size; + return s >= size - chargers; + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs.meta b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs.meta new file mode 100644 index 000000000..82a72a1a6 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Combat/ZoneEnemyMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bff328839d124da48bd8a43b9d77f543 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs b/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs index 913f45873..3b6bd9f14 100644 --- a/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs +++ b/Assets/_Project/Scripts/Simulation/World/CycleComponents.cs @@ -76,5 +76,11 @@ namespace ProjectM.Simulation /// Previous-tick expedition occupancy (1 = at least one player out), for the empty<->occupied edge. public byte PrevExpeditionOccupied; + + /// The a zone-clear Ore reward was last banked for — gates the once-per-epoch reward so two same-tick co-op returners pay once and gate re-entry can't farm (int equality, never tick math). + public int LastRewardedEpoch; + + /// 1 once the current epoch's expedition wave has FULLY spawned and been cleared to zero live zone enemies; reset to 0 on the empty->occupied epoch bump. The reward fires only on a REAL clear. + public byte ClearedThisEpoch; } } diff --git a/Assets/_Project/Subscenes/Gameplay.unity b/Assets/_Project/Subscenes/Gameplay.unity index b474b9d11..37c3a9d12 100644 --- a/Assets/_Project/Subscenes/Gameplay.unity +++ b/Assets/_Project/Subscenes/Gameplay.unity @@ -2848,54 +2848,6 @@ Transform: m_Children: [] m_Father: {fileID: 0} m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} ---- !u!1 &1527461111 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 1527461113} - - component: {fileID: 1527461112} - m_Layer: 0 - m_Name: UpgradePickupSpawner - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!114 &1527461112 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1527461111} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: e367eb55e2c2248f18be10d6c3c9ad67, type: 3} - m_Name: - m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.UpgradePickupSpawnerAuthoring - PickupPrefab: {fileID: 3885353946372160549, guid: d7dd0078c7b1f4cf995ca3a4b1155569, type: 3} - Count: 2 - Spacing: 3 - Origin: {x: -4, y: 0, z: 6} ---- !u!4 &1527461113 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 1527461111} - 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 &1542636139 GameObject: m_ObjectHideFlags: 0 @@ -3360,6 +3312,60 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 15.610933, y: 5, z: 1.5} m_Center: {x: 0, y: 2.5, z: 0} +--- !u!1 &1957070222 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1957070224} + - component: {fileID: 1957070223} + m_Layer: 0 + m_Name: ZoneEnemyDirector + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &1957070223 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1957070222} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5918787bb2ebb7747afe473a7937ed53, type: 3} + m_Name: + m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.ZoneEnemyDirectorAuthoring + EnemyPrefabs: + - {fileID: 3885353946372160549, guid: a6c2004a3cc32cc44b1bb7a795f86519, type: 3} + - {fileID: 3885353946372160549, guid: 0d42b4a8ef76489458ee1ecf51b4dbca, type: 3} + MaxAlive: 12 + RingRadius: 14 + RingSlots: 10 + SpawnIntervalTicks: 30 + GruntsPerWave: 4 + ChargersPerWave: 1 + RewardOre: 25 +--- !u!4 &1957070224 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1957070222} + 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 &1966551274 GameObject: m_ObjectHideFlags: 0 @@ -3670,54 +3676,6 @@ BoxCollider: serializedVersion: 3 m_Size: {x: 15.610933, y: 5, z: 1.5} m_Center: {x: 0, y: 2.5, z: 0} ---- !u!1 &2143686865 -GameObject: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - serializedVersion: 6 - m_Component: - - component: {fileID: 2143686867} - - component: {fileID: 2143686866} - m_Layer: 0 - m_Name: TrainingDummySpawner - m_TagString: Untagged - m_Icon: {fileID: 0} - m_NavMeshLayer: 0 - m_StaticEditorFlags: 0 - m_IsActive: 1 ---- !u!114 &2143686866 -MonoBehaviour: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2143686865} - m_Enabled: 1 - m_EditorHideFlags: 0 - m_Script: {fileID: 11500000, guid: 51cc543c146d84239ba6dc219221df18, type: 3} - m_Name: - m_EditorClassIdentifier: ProjectM.Authoring::ProjectM.Authoring.TrainingDummySpawnerAuthoring - DummyPrefab: {fileID: 6681740481886397972, guid: 9ee6032b77c444d07a1dc9dfbf5abd68, type: 3} - Count: 3 - Spacing: 3 - Origin: {x: 0, y: 0, z: 8} ---- !u!4 &2143686867 -Transform: - m_ObjectHideFlags: 0 - m_CorrespondingSourceObject: {fileID: 0} - m_PrefabInstance: {fileID: 0} - m_PrefabAsset: {fileID: 0} - m_GameObject: {fileID: 2143686865} - 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 &2145598868 GameObject: m_ObjectHideFlags: 0 @@ -3773,9 +3731,7 @@ SceneRoots: m_Roots: - {fileID: 1498433572} - {fileID: 1379903946} - - {fileID: 2143686867} - {fileID: 409538539} - - {fileID: 1527461113} - {fileID: 1435545288} - {fileID: 874705788} - {fileID: 1930969067} @@ -3791,3 +3747,4 @@ SceneRoots: - {fileID: 1268920429} - {fileID: 722706770} - {fileID: 2145598870} + - {fileID: 1957070224} diff --git a/Assets/_Project/Tests/EditMode/ChargerTests.cs b/Assets/_Project/Tests/EditMode/ChargerTests.cs index 346fc4dbd..3a4cf5cb6 100644 --- a/Assets/_Project/Tests/EditMode/ChargerTests.cs +++ b/Assets/_Project/Tests/EditMode/ChargerTests.cs @@ -44,6 +44,7 @@ namespace ProjectM.Tests em.AddComponentData(e, new Health { Current = 100f, Max = 100f }); em.AddComponent(e); em.AddBuffer(e); + em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); return e; } @@ -59,6 +60,7 @@ namespace ProjectM.Tests em.AddComponent(e); em.SetComponentEnabled(e, false); // baked DISABLED on the real Charger (spawns not-lunging) em.AddComponent(e); + em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); return e; } diff --git a/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs b/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs index 2894deee1..658a79dc9 100644 --- a/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs +++ b/Assets/_Project/Tests/EditMode/CyclePhaseSystemTests.cs @@ -173,8 +173,9 @@ namespace ProjectM.Tests ledger.Add(new StorageEntry { ItemId = 2, Count = 100 }); ledger.Add(new StorageEntry { ItemId = 4, Count = 40 }); MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 3); - em.CreateEntity(typeof(EnemyTag)); // two live husks the team failed to clear - em.CreateEntity(typeof(EnemyTag)); + // two live BASE husks the team failed to clear (RegionTag defaults to Region 0 = Base) + em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); + em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); group.Update(); @@ -193,6 +194,38 @@ namespace ProjectM.Tests } } + [Test] + public void Base_Overrun_Disperses_Base_Husks_But_Spares_Expedition_Husks() + { + // Slice 3 regression: a BASE Core breach must NOT wipe an in-progress EXPEDITION wave (both share + // EnemyTag but live in different regions). A region-blind cull would also spuriously trip the zone + // director's aliveZone==0 clear/reward edge on the player's return. + var (world, group) = MakeWorld("BaseOverrunSparesExpedition", serverTick: 200); + using (world) + { + var em = world.EntityManager; + var cycle = MakeCycle(em, CyclePhase.Siege, defendStartWave: 5); + em.AddComponentData(cycle, new GoalProgress { Charge = 3, Target = 10 }); + em.AddComponentData(cycle, new CoreIntegrity { Current = 0, Max = 100, OverrunTick = 0 }); // breached + var ledger = em.AddBuffer(cycle); + ledger.Add(new StorageEntry { ItemId = 2, Count = 100 }); + ledger.Add(new StorageEntry { ItemId = 4, Count = 40 }); + MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 3); + em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); // BASE husk (RegionTag defaults to Region 0 = Base) + var exp = em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); + em.SetComponentData(exp, new RegionTag { Region = RegionId.Expedition }); // a husk out on the expedition + + group.Update(); + + Assert.IsTrue(em.Exists(exp), "the expedition husk survives a base Core breach."); + using var huskQ = em.CreateEntityQuery(typeof(EnemyTag)); + Assert.AreEqual(1, huskQ.CalculateEntityCount(), + "only the BASE husk is dispersed by the breach; the in-progress expedition wave is untouched."); + Assert.AreEqual(RegionId.Expedition, em.GetComponentData(exp).Region, + "the survivor is the expedition-region husk."); + } + } + [Test] public void Overrun_Resolves_Once_Then_Stays_Calm_Without_Recharging() { diff --git a/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs b/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs index dbc3e4f4d..ee12d0a76 100644 --- a/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs +++ b/Assets/_Project/Tests/EditMode/EndgameWinLoseTests.cs @@ -184,8 +184,8 @@ namespace ProjectM.Tests ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 }); ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 }); MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 3); - em.CreateEntity(typeof(EnemyTag)); - em.CreateEntity(typeof(EnemyTag)); + em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); + em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); group.Update(); @@ -216,8 +216,8 @@ namespace ProjectM.Tests ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 }); ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 }); MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0); - em.CreateEntity(typeof(EnemyTag)); - em.CreateEntity(typeof(EnemyTag)); + em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); + em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); group.Update(); @@ -275,7 +275,7 @@ namespace ProjectM.Tests var w = em.CreateEntity(typeof(WaveState)); em.SetComponentData(w, new WaveState { RemainingToSpawn = 5, Phase = WavePhase.Spawning }); for (int i = 0; i < 3; i++) - em.CreateEntity(typeof(EnemyTag)); + em.CreateEntity(typeof(EnemyTag), typeof(RegionTag)); // base husks (RegionTag defaults to Base) group.Update(); diff --git a/Assets/_Project/Tests/EditMode/EnemyAIMathTests.cs b/Assets/_Project/Tests/EditMode/EnemyAIMathTests.cs index 0c984f84b..3b94ec01a 100644 --- a/Assets/_Project/Tests/EditMode/EnemyAIMathTests.cs +++ b/Assets/_Project/Tests/EditMode/EnemyAIMathTests.cs @@ -170,5 +170,65 @@ namespace ProjectM.Tests Assert.AreEqual(-1, idx); } + // ---- Region-aware PickWeightedNearest (Slice 3: per-region target filter, DR-040 BLOCKER 1) ---- + + [Test] + public void PickWeightedNearest_Region_BaseHusk_PicksBasePlayer_IgnoringCloserExpedition() + { + using var players = new NativeList(Allocator.Temp); + using var pRegions = new NativeList(Allocator.Temp); + using var structs = new NativeList(Allocator.Temp); + using var sRegions = new NativeList(Allocator.Temp); + players.Add(new float3(10, 0, 0)); pRegions.Add(RegionId.Base); // base player, far + players.Add(new float3(3, 0, 0)); pRegions.Add(RegionId.Expedition); // expedition player, closer + EnemyAIMath.PickWeightedNearest(float3.zero, players, pRegions, structs, sRegions, + RegionId.Base, 0.7f, out bool isStruct, out int idx); + Assert.IsFalse(isStruct); + Assert.AreEqual(0, idx, "a base Husk targets the base player (idx 0), never the closer expedition player across the gap"); + } + + [Test] + public void PickWeightedNearest_Region_ExpeditionHusk_PicksExpeditionPlayer() + { + using var players = new NativeList(Allocator.Temp); + using var pRegions = new NativeList(Allocator.Temp); + using var structs = new NativeList(Allocator.Temp); + using var sRegions = new NativeList(Allocator.Temp); + players.Add(new float3(10, 0, 0)); pRegions.Add(RegionId.Base); + players.Add(new float3(3, 0, 0)); pRegions.Add(RegionId.Expedition); + EnemyAIMath.PickWeightedNearest(float3.zero, players, pRegions, structs, sRegions, + RegionId.Expedition, 0.7f, out bool isStruct, out int idx); + Assert.IsFalse(isStruct); + Assert.AreEqual(1, idx, "an expedition Husk targets the expedition player (idx 1)"); + } + + [Test] + public void PickWeightedNearest_Region_ExpeditionHusk_NoExpeditionTarget_ReturnsMinusOne() + { + using var players = new NativeList(Allocator.Temp); + using var pRegions = new NativeList(Allocator.Temp); + using var structs = new NativeList(Allocator.Temp); + using var sRegions = new NativeList(Allocator.Temp); + players.Add(new float3(3, 0, 0)); pRegions.Add(RegionId.Base); // only a base player + structs.Add(new float3(5, 0, 0)); sRegions.Add(RegionId.Base); // only a base structure + EnemyAIMath.PickWeightedNearest(float3.zero, players, pRegions, structs, sRegions, + RegionId.Expedition, 0.7f, out bool isStruct, out int idx); + Assert.AreEqual(-1, idx, "an expedition Husk with only base targets finds nothing in-region -> idles (no Core fallback)"); + } + + [Test] + public void PickWeightedNearest_Region_BaseHusk_StillRazesBaseStructures() + { + using var players = new NativeList(Allocator.Temp); + using var pRegions = new NativeList(Allocator.Temp); + using var structs = new NativeList(Allocator.Temp); + using var sRegions = new NativeList(Allocator.Temp); + structs.Add(new float3(0, 0, 12)); sRegions.Add(RegionId.Base); + EnemyAIMath.PickWeightedNearest(float3.zero, players, pRegions, structs, sRegions, + RegionId.Base, 0.7f, out bool isStruct, out int idx); + Assert.IsTrue(isStruct, "a base Husk still targets a base structure (the fortress-aggro path is unchanged in-region)"); + Assert.AreEqual(0, idx); + } + } } diff --git a/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs b/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs new file mode 100644 index 000000000..bce8ef522 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs @@ -0,0 +1,109 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Core; +using Unity.Entities; +using Unity.Mathematics; +using Unity.Transforms; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into + /// (DR-040 BLOCKER 4). A returning player banks flat Ore to the shared ledger + /// IFF this epoch's expedition wave was actually cleared and not yet rewarded — and never twice for the same + /// epoch (the co-op same-tick / gate-re-entry de-dup). + /// + public class ExpeditionGateRewardTests + { + static (World world, SimulationSystemGroup group, Entity cycle) MakeWorld(string name, + int epoch, byte clearedThisEpoch, int lastRewardedEpoch) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + + // CycleDirector-like entity: cycle state/runtime + the shared resource ledger + threat state. + var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime), typeof(ResourceLedger), typeof(ThreatState)); + em.SetComponentData(cyc, new CycleState { Phase = CyclePhase.Calm }); + em.SetComponentData(cyc, new CycleRuntime + { + ExpeditionEpoch = epoch, ClearedThisEpoch = clearedThisEpoch, LastRewardedEpoch = lastRewardedEpoch, + }); + em.AddBuffer(cyc); + + // Zone-enemy director singleton (only RewardOre matters to the reward fold). + var dir = em.CreateEntity(typeof(ZoneEnemyDirector)); + em.SetComponentData(dir, new ZoneEnemyDirector { RewardOre = 25 }); + + // A gate Expedition->Base sitting at the expedition origin. + var gate = em.CreateEntity(typeof(ExpeditionGate), typeof(LocalTransform)); + em.SetComponentData(gate, new ExpeditionGate + { + FromRegion = RegionId.Expedition, ToRegion = RegionId.Base, Radius = 3f, ArrivalPos = new float3(0, 1, 0), + }); + em.SetComponentData(gate, LocalTransform.FromPosition(new float3(1000, 1, 0))); + + return (world, group, cyc); + } + + static Entity MakeExpeditionPlayerAtGate(EntityManager em) + { + var e = em.CreateEntity(); + em.AddComponentData(e, new RegionTag { Region = RegionId.Expedition }); + em.AddComponentData(e, LocalTransform.FromPosition(new float3(1000, 1, 0))); + em.AddComponent(e); + return e; + } + + static int OreInLedger(EntityManager em, Entity cyc) + { + var buf = em.GetBuffer(cyc); + for (int i = 0; i < buf.Length; i++) + if (buf[i].ItemId == (ushort)ResourceId.Ore) return buf[i].Count; + return 0; + } + + [Test] + public void Cleared_Return_Banks_Ore_Once() + { + var (world, group, cyc) = MakeWorld("GateRewardOnce", epoch: 1, clearedThisEpoch: 1, lastRewardedEpoch: 0); + using (world) + { + var em = world.EntityManager; + var player = MakeExpeditionPlayerAtGate(em); + + group.Update(); // player walks the gate back to base -> reward + + Assert.AreEqual(25, OreInLedger(em, cyc), "a cleared return banks RewardOre to the shared ledger"); + Assert.AreEqual(1, em.GetComponentData(cyc).LastRewardedEpoch, "the epoch is marked rewarded"); + + // Force a second same-epoch return (the player is back in the expedition at the gate). + em.SetComponentData(player, new RegionTag { Region = RegionId.Expedition }); + em.SetComponentData(player, LocalTransform.FromPosition(new float3(1000, 1, 0))); + + group.Update(); // returns again, but the epoch was already rewarded + + Assert.AreEqual(25, OreInLedger(em, cyc), "the same epoch never pays twice (co-op / re-entry de-dup)"); + } + } + + [Test] + public void Uncleared_Return_Banks_Nothing() + { + var (world, group, cyc) = MakeWorld("GateRewardUncleared", epoch: 1, clearedThisEpoch: 0, lastRewardedEpoch: 0); + using (world) + { + var em = world.EntityManager; + MakeExpeditionPlayerAtGate(em); + + group.Update(); + + Assert.AreEqual(0, OreInLedger(em, cyc), "returning without clearing the wave banks nothing (no farming)"); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs.meta b/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs.meta new file mode 100644 index 000000000..9cd6cf164 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ExpeditionGateRewardTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 825422a92eb4b1d4eb4cdafc57884a01 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/KnockbackTests.cs b/Assets/_Project/Tests/EditMode/KnockbackTests.cs index 77f2a08cd..24ca9f218 100644 --- a/Assets/_Project/Tests/EditMode/KnockbackTests.cs +++ b/Assets/_Project/Tests/EditMode/KnockbackTests.cs @@ -83,6 +83,7 @@ namespace ProjectM.Tests em.AddComponentData(e, new Health { Current = 100f, Max = 100f }); em.AddComponent(e); em.AddBuffer(e); + em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); return e; } @@ -95,6 +96,7 @@ namespace ProjectM.Tests em.AddComponentData(e, kb); em.AddComponentData(e, new AttackWindup()); em.AddComponent(e); + em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); return e; } diff --git a/Assets/_Project/Tests/EditMode/TelegraphTests.cs b/Assets/_Project/Tests/EditMode/TelegraphTests.cs index 18d26effd..49ea7724c 100644 --- a/Assets/_Project/Tests/EditMode/TelegraphTests.cs +++ b/Assets/_Project/Tests/EditMode/TelegraphTests.cs @@ -44,6 +44,7 @@ namespace ProjectM.Tests em.AddComponentData(e, new Health { Current = 100f, Max = 100f }); em.AddComponent(e); em.AddBuffer(e); + em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); return e; } @@ -56,6 +57,7 @@ namespace ProjectM.Tests em.AddComponentData(e, new KnockbackState()); em.AddComponentData(e, new AttackWindup()); em.AddComponent(e); + em.AddComponentData(e, new RegionTag { Region = RegionId.Base }); return e; } diff --git a/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs b/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs index 1587a6fc3..d4efda0f2 100644 --- a/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs +++ b/Assets/_Project/Tests/EditMode/ThreatDirectorSystemTests.cs @@ -138,7 +138,10 @@ namespace ProjectM.Tests // Three Husks still on the field with no one to clear them. for (int i = 0; i < 3; i++) - em.CreateEntity(typeof(EnemyTag)); + { + var h = em.CreateEntity(typeof(EnemyTag)); + em.AddComponentData(h, new RegionTag { Region = RegionId.Base }); + } group.Update(); diff --git a/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs b/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs new file mode 100644 index 000000000..04f78cbeb --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs @@ -0,0 +1,193 @@ +using NUnit.Framework; +using ProjectM.Server; +using ProjectM.Simulation; +using Unity.Collections; +using Unity.Core; +using Unity.Entities; +using Unity.Mathematics; +using Unity.NetCode; +using Unity.Transforms; + +namespace ProjectM.Tests +{ + /// + /// Plain-Entities EditMode tests for the server-only . A bare world is + /// seeded with NetworkTime, a CycleDirector entity (CycleState + CycleRuntime) and a zone-enemy director + /// (ZoneEnemyDirector + ZoneEnemyState + a Prefab-tagged enemy in the ZoneEnemyPrefab buffer). Pins: it spawns + /// only while a player is OUT in the expedition AND the base is Calm; tags spawns RegionTag{Expedition} + + /// ZoneEnemyTag at the deterministic ring origin (Scale preserved); and marks CycleRuntime.ClearedThisEpoch on a + /// real clear. + /// + public class ZoneEnemyDirectorSystemTests + { + static (World world, SimulationSystemGroup group, Entity cycle) MakeWorld(string name, uint serverTick, byte phase, int epoch) + { + var world = new World(name); + var group = world.GetOrCreateSystemManaged(); + group.AddSystemToUpdateList(world.GetOrCreateSystem()); + group.SortSystems(); + world.SetTime(new TimeData(elapsedTime: 0f, deltaTime: 1f / 60f)); + var em = world.EntityManager; + var nt = em.CreateEntity(typeof(NetworkTime)); + em.SetComponentData(nt, new NetworkTime { ServerTick = new NetworkTick(serverTick) }); + var cyc = em.CreateEntity(typeof(CycleState), typeof(CycleRuntime)); + em.SetComponentData(cyc, new CycleState { Phase = phase }); + em.SetComponentData(cyc, new CycleRuntime { ExpeditionEpoch = epoch }); + return (world, group, cyc); + } + + static Entity MakeZonePrefab(EntityManager em) + { + var e = em.CreateEntity(typeof(LocalTransform), typeof(EnemyTag)); + em.SetComponentData(e, LocalTransform.Identity); // Scale = 1 so WithPosition keeps it + em.AddComponent(e); + return e; + } + + static Entity MakeDirector(EntityManager em, Entity grunt, Entity charger, + int maxAlive, int gruntsPerWave, int chargersPerWave, + uint nextSpawnTick, int remainingToSpawn, int seededEpoch, uint spawnCounter) + { + var e = em.CreateEntity(typeof(ZoneEnemyDirector), typeof(ZoneEnemyState)); + em.SetComponentData(e, new ZoneEnemyDirector + { + MaxAlive = maxAlive, RingRadius = 14f, RingSlots = 10, SpawnIntervalTicks = 10, + GruntsPerWave = gruntsPerWave, ChargersPerWave = chargersPerWave, RewardOre = 25, + }); + em.SetComponentData(e, new ZoneEnemyState + { + SpawnCounter = spawnCounter, RemainingToSpawn = remainingToSpawn, + NextSpawnTick = nextSpawnTick, SeededEpoch = seededEpoch, + }); + var buf = em.AddBuffer(e); + buf.Add(new ZoneEnemyPrefab { Prefab = grunt }); + buf.Add(new ZoneEnemyPrefab { Prefab = charger }); + return e; + } + + static Entity MakeExpeditionPlayer(EntityManager em, float3 pos) + { + var e = em.CreateEntity(); + em.AddComponentData(e, new RegionTag { Region = RegionId.Expedition }); + em.AddComponentData(e, LocalTransform.FromPosition(pos)); + em.AddComponent(e); + return e; + } + + static int ZoneCount(EntityManager em) + { + using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag)); + return q.CalculateEntityCount(); + } + + [Test] + public void Spawns_Expedition_Tagged_Enemy_When_Occupied_And_Calm() + { + var (world, group, _) = MakeWorld("ZoneSpawn", serverTick: 100, phase: CyclePhase.Calm, epoch: 1); + using (world) + { + var em = world.EntityManager; + var grunt = MakeZonePrefab(em); + var charger = MakeZonePrefab(em); + var dir = MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, + nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0); + MakeExpeditionPlayer(em, new float3(1000, 1, 0)); + + group.Update(); + + Assert.AreEqual(1, ZoneCount(em), "one zone enemy spawns this tick"); + using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(RegionTag)); + var arr = q.ToComponentDataArray(Allocator.Temp); + Assert.AreEqual(RegionId.Expedition, arr[0].Region, "the spawn is tagged RegionTag{Expedition}"); + arr.Dispose(); + + var zs = em.GetComponentData(dir); + Assert.AreEqual(1u, zs.SpawnCounter, "spawn counter advanced"); + Assert.AreEqual(1, zs.RemainingToSpawn, "wave size 2 seeded, 1 spawned -> 1 remaining"); + } + } + + [Test] + public void Spawn_Lands_On_Expedition_Ring_Origin_With_Scale_Preserved() + { + var (world, group, _) = MakeWorld("ZoneRing", serverTick: 100, phase: CyclePhase.Calm, epoch: 1); + using (world) + { + var em = world.EntityManager; + var grunt = MakeZonePrefab(em); + var charger = MakeZonePrefab(em); + MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, + nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0); + MakeExpeditionPlayer(em, new float3(1000, 1, 0)); + + group.Update(); + + using var q = em.CreateEntityQuery(typeof(ZoneEnemyTag), typeof(LocalTransform)); + var arr = q.ToComponentDataArray(Allocator.Temp); + // origin = base(0,1,0) + (1000,0,0); ring slot 0 of a 10-slot radius-14 ring -> +X. + Assert.AreEqual(1014f, arr[0].Position.x, 1e-2f, "deterministic ring slot 0 at the expedition origin (+radius on X)"); + Assert.AreEqual(1f, arr[0].Scale, 1e-3f, "baked Scale preserved (WithPosition, not FromPosition)"); + arr.Dispose(); + } + } + + [Test] + public void Does_Not_Spawn_When_No_Expedition_Player() + { + var (world, group, _) = MakeWorld("ZoneEmpty", serverTick: 100, phase: CyclePhase.Calm, epoch: 1); + using (world) + { + var em = world.EntityManager; + var grunt = MakeZonePrefab(em); + var charger = MakeZonePrefab(em); + MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, + nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0); + // no expedition player out there + + group.Update(); + + Assert.AreEqual(0, ZoneCount(em), "nobody out in the expedition -> nothing spawns"); + } + } + + [Test] + public void Does_Not_Spawn_During_Base_Siege() + { + var (world, group, _) = MakeWorld("ZoneSiege", serverTick: 100, phase: CyclePhase.Siege, epoch: 1); + using (world) + { + var em = world.EntityManager; + var grunt = MakeZonePrefab(em); + var charger = MakeZonePrefab(em); + MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, + nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 0, spawnCounter: 0); + MakeExpeditionPlayer(em, new float3(1000, 1, 0)); + + group.Update(); + + Assert.AreEqual(0, ZoneCount(em), "the expedition wave pauses while the base is under siege (Calm-only spawning)"); + } + } + + [Test] + public void Cleared_Wave_Marks_ClearedThisEpoch() + { + var (world, group, cyc) = MakeWorld("ZoneCleared", serverTick: 100, phase: CyclePhase.Calm, epoch: 1); + using (world) + { + var em = world.EntityManager; + var grunt = MakeZonePrefab(em); + var charger = MakeZonePrefab(em); + // already seeded this epoch + fully spawned (RemainingToSpawn 0) + no live zone enemies. + MakeDirector(em, grunt, charger, maxAlive: 12, gruntsPerWave: 2, chargersPerWave: 0, + nextSpawnTick: 0, remainingToSpawn: 0, seededEpoch: 1, spawnCounter: 2); + MakeExpeditionPlayer(em, new float3(1000, 1, 0)); + + group.Update(); + + Assert.AreEqual((byte)1, em.GetComponentData(cyc).ClearedThisEpoch, + "wave fully spawned + no live zone enemies -> a real clear is marked"); + } + } + } +} diff --git a/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs.meta b/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs.meta new file mode 100644 index 000000000..b0e3cb5fd --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ZoneEnemyDirectorSystemTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f307978f01b668b42ba10eea2029091b \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs b/Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs new file mode 100644 index 000000000..df6bf65df --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs @@ -0,0 +1,60 @@ +using NUnit.Framework; +using ProjectM.Simulation; + +namespace ProjectM.Tests +{ + /// + /// Pure-function tests for (no ECS world): the deterministic, save-reproducible + /// expedition-wave composition. Pins the lower bound, the per-epoch ramp, and the grunt-heavy -> charger-heavy + /// shift (grunt count held fixed; the per-epoch growth is all chargers). + /// + public class ZoneEnemyMathTests + { + [Test] + public void WaveSize_LowerBoundedAtOne() + { + Assert.AreEqual(1, ZoneEnemyMath.WaveSize(0, 0, 0), "an occupied expedition always has at least one enemy"); + Assert.AreEqual(1, ZoneEnemyMath.WaveSize(-5, 0, 0), "epoch is floored at 1 internally"); + } + + [Test] + public void WaveSize_BaselinePlusOnePerEpoch() + { + Assert.AreEqual(5, ZoneEnemyMath.WaveSize(1, 4, 1), "epoch 1: 4 grunts + 1 charger"); + Assert.AreEqual(7, ZoneEnemyMath.WaveSize(3, 4, 1), "epoch 3: baseline 5 + (3-1) ramp"); + } + + [Test] + public void IsChargerSlot_Epoch1_GruntsFirst_OneChargerLast() + { + // epoch 1, G=4 C=1 -> size 5, only the last slot is a charger. + Assert.IsFalse(ZoneEnemyMath.IsChargerSlot(1, 0, 4, 1)); + Assert.IsFalse(ZoneEnemyMath.IsChargerSlot(1, 3, 4, 1)); + Assert.IsTrue(ZoneEnemyMath.IsChargerSlot(1, 4, 4, 1)); + } + + [Test] + public void Composition_GruntCountFixed_ChargerShareGrowsWithEpoch() + { + AssertComposition(epoch: 1, grunts: 4, chargers: 1, expectGrunts: 4, expectChargers: 1); + AssertComposition(epoch: 5, grunts: 4, chargers: 1, expectGrunts: 4, expectChargers: 5); + } + + static void AssertComposition(int epoch, int grunts, int chargers, int expectGrunts, int expectChargers) + { + int size = ZoneEnemyMath.WaveSize(epoch, grunts, chargers); + int g = 0, c = 0; + for (int slot = 0; slot < size; slot++) + if (ZoneEnemyMath.IsChargerSlot(epoch, slot, grunts, chargers)) c++; else g++; + Assert.AreEqual(expectGrunts, g, $"grunt count at epoch {epoch}"); + Assert.AreEqual(expectChargers, c, $"charger count at epoch {epoch}"); + } + + [Test] + public void IsChargerSlot_Deterministic() + { + for (int slot = 0; slot < 9; slot++) + Assert.AreEqual(ZoneEnemyMath.IsChargerSlot(5, slot, 4, 1), ZoneEnemyMath.IsChargerSlot(5, slot, 4, 1)); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs.meta b/Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs.meta new file mode 100644 index 000000000..91fb4defd --- /dev/null +++ b/Assets/_Project/Tests/EditMode/ZoneEnemyMathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e61ab20e496e331449ba6e26440ad000 \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index efa573908..7857ef55c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Multiplayer game on **Unity DOTS (Entities) + Netcode for Entities** — server- - **Size check** — bash: `wc -c CLAUDE.md` · PowerShell: `(Get-Item CLAUDE.md).Length`. Must be `< 40960`. - **Archive, don't delete.** When trimming, append the verbose / least-hot detail to the obsidian reference note `Docs/Vault/_Meta/CLAUDE_Build_Gotchas_Archive.md` under a **new dated heading** (never overwrite an older snapshot), and leave a one-line pointer + the relevant `[[DR-###]]` link here. Design rationale already lives in the per-milestone DRs (`Docs/Vault/07_Sessions/_Decisions/DR-###`). - **Net-zero rule:** every addition is paid for by a condensation elsewhere. Keep only the hottest, highest-recurrence operational rules inline (flag them **★**); depth lives in the archive + DRs. -- Condensation history: 2026-06-04 · 06-07 · 06-08 · 06-13 (M1–END-2 long-form → archive); 06-17 (6.5 stack swap). +- Condensation history: 06-04 → 06-17 (M1–END-2 long-form + 6.5 stack swap → archive). ## Stack — Unity 6.5.0 (`6000.5.0f1`, stable) as of 2026-06-17 @@ -73,8 +73,8 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui - **A dev/debug `IRpcCommand` wire TYPE must be UNCONDITIONAL (no `#if`)** — the reflection-built RpcCollection hash must match across release/dev peers or the handshake refuses; `#if UNITY_EDITOR`-gate only the send/receive SYSTEMS, never the request struct. **Re-mean bytes, don't rename**: unchanged byte VALUES keep the `[GhostField]` serializer identical → re-bake-free (only authoring *default-value* edits re-bake the subscene). - **Derive enableable gates instead of replicating them.** e.g. player `Dead` = a LOCAL enableable derived every predicted tick from replicated `Health<=0` (rollback-correct, no `[GhostEnabledBit]`). To write the bit on a disabled entity the query must visit it (`.WithPresent()`); **bake the enableable DISABLED** so instances spawn off. Respawn/death *timing* is server-only. - **Cooldown/spawn "next tick" sentinels:** route every stored tick through **`TickUtil.NonZero(...)`** (a computed `ServerTick+delay` can wrap to 0, the "ready" sentinel) and compare with `NetworkTick.IsNewerThan` / `.TicksSince`, **never** raw `uint <` / subtraction. -- **`GhostRelevancy` for region splits:** use `GhostRelevancyMode.SetIsIrrelevant` (not `SetIsRelevant`) so untagged/global ghosts stay relevant for free — only enumerate cross-region ghosts to hide. `RegionTag{byte Region}` is **server-only, NOT a `[GhostField]`**. `RelevantGhostForConnection{int Connection (=NetworkId.Value); int Ghost (=GhostInstance.ghostId)}`. See [[DR-013_M6_Aether_Cycle_Region_Split]]. -- **Shared GLOBAL state (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost**, never a region-tagged one (`SetIsIrrelevant` would hide it cross-region). Resolve a ledger buffer via a DISTINCT tag (`ResourceLedger`), **never `GetSingleton`** when a second `StorageEntry` buffer exists elsewhere → "multiple instances" throw. +- **`GhostRelevancy` for region splits:** use `GhostRelevancyMode.SetIsIrrelevant` (not `SetIsRelevant`) so untagged/global ghosts stay relevant for free — only enumerate cross-region ghosts to hide. `RegionTag{byte Region}` is **server-only, NOT a `[GhostField]`**. **★ A 2nd region sharing an EXISTING tag (`EnemyTag`) → re-audit every query/cull over it: once-safe global despawns/cleared-checks then wipe or block cross-region (DR-031, DR-040).** `RelevantGhostForConnection` = `{int Connection=NetworkId.Value; int Ghost=ghostId}`. See [[DR-013_M6_Aether_Cycle_Region_Split]]. +- **Shared GLOBAL state (cycle phase, resource ledger, goal meter) rides an UNTAGGED ghost**, never a region-tagged one (`SetIsIrrelevant` would hide it cross-region). Resolve the ledger via its DISTINCT `ResourceLedger` tag (the multi-`StorageEntry` "multiple instances" rule — EB-2 line). - **Frontend world lifecycle (menu → on-demand worlds) ★:** `CreateLocalWorld` is `internal` in 1.13.2 — use public `CreateClientWorld`/`CreateServerWorld` (they register the `ServerWorld`/`ClientWorld` statics the UI reads); menu world via `DefaultWorldInitialization.Initialize(name, false)`. **Never dispose/create worlds inside an ECS system** — do it on a frame-boundary coroutine (`SessionRunner`, `DontDestroyOnLoad`). The gameplay subscene streams in ONLY if a netcode world is the `DefaultGameObjectInjectionWorld` at `LoadScene` time. See [[DR-019_Frontend_Menu_Settings_Saves_Build]]. ### Physics & character controller diff --git a/Docs/Vault/07_Sessions/2026/2026-06-21_Slice3_Expedition_Combat_Spine_Build.md b/Docs/Vault/07_Sessions/2026/2026-06-21_Slice3_Expedition_Combat_Spine_Build.md new file mode 100644 index 000000000..206e8525b --- /dev/null +++ b/Docs/Vault/07_Sessions/2026/2026-06-21_Slice3_Expedition_Combat_Spine_Build.md @@ -0,0 +1,49 @@ +--- +date: 2026-06-21 +type: session +tags: +- session +- combat +- expedition +- netcode +- procgen +- slice +- slice-3 +permalink: gamevault/07-sessions/2026/2026-06-21-slice3-expedition-combat-spine-build +--- + +# Slice 3 — Expedition Combat Spine (build) + +> Built the v1 loop locked in [[DR-040_Slice3_Expedition_Combat_Spine]] — the third build slice of the co-op-roguelite redirect ([[DR-037_Procedural_Expedition_Spine_Two_Classes_Persistent_Meta]]), the heaviest netcode slice. Full [[dots-dev]] Feature track, two-review sandwich. Reuses the region split ([[DR-013_M6_Aether_Cycle_Region_Split]]), the base-local core loop ([[DR-031_Base_Mining_Loop_Cohesion]]), epoch-seeded chassis, ring math, and the threat retaliation ([[DR-017_Persistent_Base_Player_Driven_Pacing]]). + +## The loop that shipped +Walk to the Expedition gate → the dormant Expedition region (`base+(1000,0,0)`) drip-spawns an **epoch-seeded enemy wave** in `Calm` → fight & clear it → return to base → a **flat Ore reward** (once per epoch) → the existing `PendingReturns` retaliation arms an **escalated base siege**. Variety comes from epoch-seeded **composition** (grunt-heavy → charger-heavy as the epoch climbs), not arena layout — the highest-leverage lever per the pre-build research. + +## What shipped +- **New sim types** (`ProjectM.Simulation`): `ZoneEnemyTag` (marker); `ZoneEnemyDirector{int MaxAlive; float RingRadius; int RingSlots; int SpawnIntervalTicks; int GruntsPerWave; int ChargersPerWave; int RewardOre}` baked singleton + `ZoneEnemyPrefab` buffer (aliases the existing werewolf/charger prefabs); `ZoneEnemyState{uint SpawnCounter; int RemainingToSpawn; uint NextSpawnTick; int SeededEpoch}` (server-only, its OWN counter — never `WaveState`'s). `ZoneEnemyMath` (pure: `WaveSize` / `IsChargerSlot`, grunt count fixed, chargers grow with epoch, assigned to the LAST slots). +- **`ZoneEnemyDirectorSystem`** (server, `[BurstCompile]`): runs only while a player is out (a `PlayerTag` whose `RegionTag.Region==Expedition`) and the base is `Calm`; (re)seeds the wave per epoch; spawns one-per-cadence under the `MaxAlive` cap at a deterministic ring via `ecb.Instantiate` + `baked.WithPosition` (preserves the `[GhostField]` Scale) + `AddComponent RegionTag{Expedition}`+`ZoneEnemyTag`; marks `CycleRuntime.ClearedThisEpoch=1` on a real clear. +- **`EnemyAISystem` region filter** (BLOCKER 1): player + structure snapshots gained parallel `NativeList` regions; both seek loops gained `RefRO` + a region-aware `EnemyAIMath.PickWeightedNearest` overload; an expedition husk gets no base structures and no Core fallback (idles if its arena is empty). Burst-safe byte compares. +- **Base-only cleared-checks** (BLOCKER 3): `WaveSystem`, `ThreatDirectorSystem` SiegeTimeout cull, and `CyclePhaseSystem.DefendCleared` all count/cull `RegionTag{Base}` husks only. +- **Reward de-dup** (BLOCKER 4): `CycleRuntime` gained `LastRewardedEpoch` (int) + `ClearedThisEpoch` (byte); `ExpeditionGateSystem` deposits `RewardOre` to the shared `ResourceLedger` once per epoch, only after a real clear. +- **Teardown** (`ExpeditionFieldSystem`): epoch bump also resets `ClearedThisEpoch`; destroy-loop now also culls `ZoneEnemyTag`; added the defensive `RegionTag{Expedition}` guard to the `BlightClutter` loop. +- **Subscene:** wired a `ZoneEnemyDirector` GameObject (authoring + roster = werewolf + charger) into `Gameplay.unity`, re-baked. + +## Deviations from the locked plan (all deliberate) +- System named **`ZoneEnemyDirectorSystem`** (plan: `ZoneEnemySpawnSystem`). +- **Ordering = `[UpdateAfter(ExpeditionFieldSystem)]` ONLY.** The plan also sketched `[UpdateBefore(CyclePhaseSystem)]`, but ExpeditionFieldSystem is already `UpdateAfter(CyclePhaseSystem)`, so that would close a `CyclePhase→Field→Zone→CyclePhase` **sort cycle** — which throws at Play world-creation and is **invisible to plain-Entities EditMode** (a recurring trap). Caught at grounding; confirmed clean by the Play smoke. +- `ZoneEnemyState.SeededEpoch` is `int` (matches `int ExpeditionEpoch`). + +## The post-impl review earned its keep again +The re-run review (`wf_7b45e3c0-b81`, 3 lenses + synth; the first attempt had died on a session-quota limit — empty findings that *look* like a clean pass, [[workflow-agent-quota-failures-look-clean]]) surfaced **1 HIGH**: + +**A region-blind Core-breach cull — a THIRD cull path beyond BLOCKER 3's two.** `CyclePhaseSystem`'s overrun/soft-loss branch despawned **all** `EnemyTag` entities. Pre-slice that was safe (every enemy was base-region) — but Slice 3 makes expedition enemies share `EnemyTag`, so a **base** Core breach wiped a live **expedition** wave AND spuriously satisfied the zone director's `aliveZone==0` clear/reward edge (a free Ore reward on return). I had *rationalised* leaving this cull global during implementation ("a Core overrun is terminal"). Fixed to a Base-only filter matching the BLOCKER-3 siblings; removed the now-dead `m_AliveHusks` field; locked with regression test `CyclePhaseSystemTests.Base_Overrun_Disperses_Base_Husks_But_Spares_Expedition_Husks`. Same class of bug the post-impl review caught in [[DR-031_Base_Mining_Loop_Cohesion]] — [[post-impl-review-catches-simplification-regressions]] holds twice now. + +## Verification +- **L1:** console clean (0 ProjectM errors/warnings). +- **L2:** **368/368 EditMode** (`ProjectM.Tests.EditMode`) — region-filter target correctness (+4), `ZoneEnemyMath` composition (6), `ZoneEnemyDirectorSystem` world tests (5), reward de-dup (2), the breach regression (1), + the pre-existing fixtures patched with `RegionTag{Base}`. +- **Play smoke:** ServerWorld/ClientWorld boot with **no sort-cycle, no Burst ICE**; the subscene re-baked the singleton (MaxAlive 12 / Ring 14×10 / interval 30 / 4 grunts +1 charger / 25 Ore) + the 2-prefab roster; replicating the director's real spawn sequence tags each enemy `RegionTag{Expedition}`+`ZoneEnemyTag` with no Add-throw and **baked Scale preserved** (0.8 werewolf via `WithPosition`); zero runtime errors; clean cleanup. + +## Open / next +- **Fun-gate (operator):** a hands-on playtest of the full walk-gate→fight→return→reward→siege loop — the logic is unit-covered, the *feel* is not. +- **Operator fork still open:** OPTIONAL-vs-REQUIRED sortie (default OPTIONAL). +- **Deferred to v2 (logged in DR-040):** arena-layout pool; zone-theme/depth byte + pre-gate HUD; the felt-beat replicated reward + `TimedModifier` buff; SaveData v6; mini-boss; RegionRelevancy incremental caching. diff --git a/Docs/Vault/07_Sessions/_Decisions/DR-040_Slice3_Expedition_Combat_Spine.md b/Docs/Vault/07_Sessions/_Decisions/DR-040_Slice3_Expedition_Combat_Spine.md index 102aec063..6d355b017 100644 --- a/Docs/Vault/07_Sessions/_Decisions/DR-040_Slice3_Expedition_Combat_Spine.md +++ b/Docs/Vault/07_Sessions/_Decisions/DR-040_Slice3_Expedition_Combat_Spine.md @@ -1,7 +1,7 @@ --- id: DR-040 -title: Slice 3 — Expedition Combat Spine (v1 plan, reviewed + locked) -status: accepted +title: Slice 3 — Expedition Combat Spine (v1 plan, reviewed + locked → built) +status: built date: 2026-06-18 tags: - decision @@ -52,4 +52,18 @@ Netcode: **PROCEED WITH BLOCKERS RESOLVED** (the chassis is sound + heavily reus - **EditMode tests (planned):** region-filter target correctness; deterministic epoch scatter (same epoch → identical spawns); WaveSystem cleared-check ignores expedition enemies; zone-clear reward deposits once per epoch. **Play-validation:** zone enemies spawn `RegionTag{Expedition}` (visible to expedition player, NOT a base-only connection); kill them, return, retaliation siege arms; expedition player gets no base-Husk aggro; server==client; relevancy cost sane. - **Deferred to v2 (logged):** arena-layout pool + authoring; zone-theme/depth byte + pre-gate HUD ("DEPTH 5 — BOSS"); the felt-beat replicated reward + `TimedModifier` buff; SaveData v6 (persist epoch); mini-boss; layout×encounter decoupling; sequential zones; RegionRelevancy incremental caching. - **Open (operator):** the OPTIONAL-vs-REQUIRED sortie fork (default OPTIONAL); the Slice 3 fun-gate; the still-open Slice 1 + 2 fun-gates. -- **Status:** reviewed + locked, **NOT built** — the build is the immediate next unit (start with the new components + the three surgical server-count fixes, then the `EnemyAISystem` region-filter (the riskiest, test it), then `ZoneEnemySpawnSystem` + authoring + the reward, then tests + Play-validate, then commit). Full review (verdicts/blockers/forks/sources) in the run transcript `wf_b8033e26-1f5`. +- **Status:** **BUILT + verified 2026-06-21** (see build record below). Full pre-build review (verdicts/blockers/forks/sources) in the run transcript `wf_b8033e26-1f5`. + +## Build record (2026-06-21) + +Built per the locked plan; all four blockers landed. **368/368 EditMode green + a clean netcode Play smoke** (ServerWorld/ClientWorld boot with no sort-cycle, the subscene re-baked the `ZoneEnemyDirector` singleton + 2-prefab roster, real prefabs instantiate `RegionTag{Expedition}`+`ZoneEnemyTag` with baked Scale preserved, zero runtime errors). + +**Deviations from the locked text (all deliberate, caught at grounding):** +- **System renamed `ZoneEnemyDirectorSystem`** (was `ZoneEnemySpawnSystem` in the plan). +- **Ordering is `[UpdateAfter(ExpeditionFieldSystem)]` ONLY** — the plan's extra `[UpdateBefore(CyclePhaseSystem)]` would close a `CyclePhase→Field→Zone→CyclePhase` sort cycle that throws at Play world-creation and is invisible to EditMode (ExpeditionFieldSystem is itself `UpdateAfter(CyclePhaseSystem)`). Grounding catch. +- `ZoneEnemyState.SeededEpoch` is **`int`** (not `uint`) to match `int CycleRuntime.ExpeditionEpoch`; `CycleRuntime` gained **both** `LastRewardedEpoch` (int) **and** `ClearedThisEpoch` (byte) — the director sets `ClearedThisEpoch=1` on a real clear, the gate pays `RewardOre` once per epoch on return. + +**Post-impl adversarial review (run `wf_7b45e3c0-b81`, 3 lenses + synth) — 1 HIGH found + fixed:** +- **Region-blind Core-breach cull (a THIRD cull path beyond BLOCKER 3's two).** `CyclePhaseSystem`'s overrun/soft-loss branch despawned **all** `EnemyTag` entities — pre-slice that was safe (all enemies were base), but Slice 3 makes expedition enemies share `EnemyTag`, so a **base** Core breach wiped a live **expedition** wave AND spuriously tripped the zone director's `aliveZone==0` clear/reward edge. **Fix:** Base-only region filter matching the BLOCKER-3 siblings (`ThreatDirectorSystem` timeout + `DefendCleared`); dead `m_AliveHusks` field removed. Locked with regression test `CyclePhaseSystemTests.Base_Overrun_Disperses_Base_Husks_But_Spares_Expedition_Husks`. (Lesson: a cull/query that was region-safe before a region split must be re-audited the moment a second region shares its tag — same class the post-impl review caught in DR-031.) + +**Still open (operator):** the OPTIONAL-vs-REQUIRED sortie fork (default OPTIONAL); the Slice 3 fun-gate (needs a hands-on playtest of the full walk-gate→fight→return→reward→siege loop — logic is unit-covered, feel is not).