Slice 3: Expedition Combat Spine — epoch-seeded zone waves (DR-040)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
using ProjectM.Simulation;
|
||||||
|
using Unity.Entities;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace ProjectM.Authoring
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Authoring for the expedition zone-enemy director (singleton). Place ONE on a GameObject in the gameplay
|
||||||
|
/// subscene; the server-only <c>ZoneEnemyDirectorSystem</c> reads it. Bakes a <see cref="ZoneEnemyDirector"/>
|
||||||
|
/// config + a <see cref="ZoneEnemyPrefab"/> pool (index 0 = Grunt variant, index 1 = Charger variant — the
|
||||||
|
/// epoch-seeded composition picks between them) + the initial <see cref="ZoneEnemyState"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
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<ZoneEnemyDirectorAuthoring>
|
||||||
|
{
|
||||||
|
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<ZoneEnemyPrefab>(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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5918787bb2ebb7747afe473a7937ed53
|
||||||
@@ -40,8 +40,9 @@ namespace ProjectM.Server
|
|||||||
// Snapshot living player targets once this tick (stable query order).
|
// Snapshot living player targets once this tick (stable query order).
|
||||||
var playerEntities = new NativeList<Entity>(Allocator.Temp);
|
var playerEntities = new NativeList<Entity>(Allocator.Temp);
|
||||||
var playerPositions = new NativeList<float3>(Allocator.Temp);
|
var playerPositions = new NativeList<float3>(Allocator.Temp);
|
||||||
foreach (var (xform, health, entity) in
|
var playerRegions = new NativeList<byte>(Allocator.Temp);
|
||||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
|
foreach (var (xform, health, region, entity) in
|
||||||
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
|
||||||
.WithAll<PlayerTag>()
|
.WithAll<PlayerTag>()
|
||||||
.WithEntityAccess())
|
.WithEntityAccess())
|
||||||
{
|
{
|
||||||
@@ -49,6 +50,7 @@ namespace ProjectM.Server
|
|||||||
continue; // don't chase or strike a corpse
|
continue; // don't chase or strike a corpse
|
||||||
playerEntities.Add(entity);
|
playerEntities.Add(entity);
|
||||||
playerPositions.Add(xform.ValueRO.Position);
|
playerPositions.Add(xform.ValueRO.Position);
|
||||||
|
playerRegions.Add(region.ValueRO.Region);
|
||||||
}
|
}
|
||||||
|
|
||||||
// EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon carry Health; automation
|
// 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).
|
// the base even with every player dead/away (the locked 'push for structures' fork).
|
||||||
var structureEntities = new NativeList<Entity>(Allocator.Temp);
|
var structureEntities = new NativeList<Entity>(Allocator.Temp);
|
||||||
var structurePositions = new NativeList<float3>(Allocator.Temp);
|
var structurePositions = new NativeList<float3>(Allocator.Temp);
|
||||||
foreach (var (sx, sh, se) in
|
var structureRegions = new NativeList<byte>(Allocator.Temp);
|
||||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
|
foreach (var (sx, sh, sr, se) in
|
||||||
|
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
|
||||||
.WithAll<PlacedStructure>()
|
.WithAll<PlacedStructure>()
|
||||||
.WithEntityAccess())
|
.WithEntityAccess())
|
||||||
{
|
{
|
||||||
@@ -65,6 +68,7 @@ namespace ProjectM.Server
|
|||||||
continue; // skip a structure already at 0 (pending destroy this tick)
|
continue; // skip a structure already at 0 (pending destroy this tick)
|
||||||
structureEntities.Add(se);
|
structureEntities.Add(se);
|
||||||
structurePositions.Add(sx.ValueRO.Position);
|
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
|
// 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();
|
playerEntities.Dispose();
|
||||||
playerPositions.Dispose();
|
playerPositions.Dispose();
|
||||||
|
playerRegions.Dispose();
|
||||||
structureEntities.Dispose();
|
structureEntities.Dispose();
|
||||||
structurePositions.Dispose();
|
structurePositions.Dispose();
|
||||||
|
structureRegions.Dispose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,12 +101,14 @@ namespace ProjectM.Server
|
|||||||
bool sweep = havePhysics && envMask != 0u;
|
bool sweep = havePhysics && envMask != 0u;
|
||||||
const float SweepRadius = 0.5f; // collide-and-slide sphere radius for Husk movement
|
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<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||||
RefRW<KnockbackState>, RefRW<AttackWindup>>()
|
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
|
||||||
.WithAll<EnemyTag>().WithNone<LungeState>())
|
.WithAll<EnemyTag>().WithNone<LungeState>())
|
||||||
{
|
{
|
||||||
float3 pos = xform.ValueRO.Position;
|
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.
|
// Knockback overrides seek/strike for its window — EnemyAISystem stays the SOLE writer of Position.
|
||||||
var kb = knockback.ValueRO;
|
var kb = knockback.ValueRO;
|
||||||
@@ -122,8 +130,8 @@ namespace ProjectM.Server
|
|||||||
|
|
||||||
// EB-1 fortress aggro: nearest of players (weight 1) + structures (StructureAggroWeight) — a wall/
|
// 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).
|
// 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);
|
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, huskRegion, structAggro, out bool tgtIsStruct, out int tgtIdx);
|
||||||
if (tgtIdx < 0 && !coreAlive)
|
if (tgtIdx < 0 && !huskCoreAlive)
|
||||||
continue; // no player/structure and no Core -> nothing to seek
|
continue; // no player/structure and no Core -> nothing to seek
|
||||||
Entity targetEntity = tgtIdx < 0 ? Entity.Null
|
Entity targetEntity = tgtIdx < 0 ? Entity.Null
|
||||||
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
|
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
|
||||||
@@ -201,12 +209,14 @@ namespace ProjectM.Server
|
|||||||
uint ChargerWindupTicks = (uint)math.max(1f, tune.ChargerWindupTicks); // readable telegraph lead
|
uint ChargerWindupTicks = (uint)math.max(1f, tune.ChargerWindupTicks); // readable telegraph lead
|
||||||
uint ChargerWhiffStaggerTicks = (uint)math.max(1f, tune.ChargerWhiffStaggerTicks); // punish window
|
uint ChargerWhiffStaggerTicks = (uint)math.max(1f, tune.ChargerWhiffStaggerTicks); // punish window
|
||||||
uint chargerWhiffsThisTick = 0;
|
uint chargerWhiffsThisTick = 0;
|
||||||
foreach (var (xform, stats, cooldown, knockback, windup, lunge) in
|
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
|
||||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>>()
|
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
|
||||||
.WithAll<EnemyTag>())
|
.WithAll<EnemyTag>())
|
||||||
{
|
{
|
||||||
float3 pos = xform.ValueRO.Position;
|
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).
|
// 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer).
|
||||||
var kb = knockback.ValueRO;
|
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).
|
// 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);
|
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, cHuskRegion, structAggro, out bool cIsStruct, out int cIdx);
|
||||||
if (cIdx < 0 && !coreAlive)
|
if (cIdx < 0 && !cHuskCoreAlive)
|
||||||
continue;
|
continue;
|
||||||
Entity cTargetEntity = cIdx < 0 ? Entity.Null
|
Entity cTargetEntity = cIdx < 0 ? Entity.Null
|
||||||
: (cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]);
|
: (cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]);
|
||||||
@@ -340,8 +350,10 @@ namespace ProjectM.Server
|
|||||||
ecb.Dispose();
|
ecb.Dispose();
|
||||||
playerEntities.Dispose();
|
playerEntities.Dispose();
|
||||||
playerPositions.Dispose();
|
playerPositions.Dispose();
|
||||||
|
playerRegions.Dispose();
|
||||||
structureEntities.Dispose();
|
structureEntities.Dispose();
|
||||||
structurePositions.Dispose();
|
structurePositions.Dispose();
|
||||||
|
structureRegions.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swept collide-and-slide for server-authoritative Husk movement: sphere-cast the intended step against
|
// Swept collide-and-slide for server-authoritative Husk movement: sphere-cast the intended step against
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ namespace ProjectM.Server
|
|||||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||||
public partial struct WaveSystem : ISystem
|
public partial struct WaveSystem : ISystem
|
||||||
{
|
{
|
||||||
EntityQuery m_AliveHusks;
|
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
public void OnCreate(ref SystemState state)
|
public void OnCreate(ref SystemState state)
|
||||||
@@ -31,7 +30,6 @@ namespace ProjectM.Server
|
|||||||
state.RequireForUpdate<WaveDirector>();
|
state.RequireForUpdate<WaveDirector>();
|
||||||
state.RequireForUpdate<WaveState>();
|
state.RequireForUpdate<WaveState>();
|
||||||
state.RequireForUpdate<NetworkTime>();
|
state.RequireForUpdate<NetworkTime>();
|
||||||
m_AliveHusks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
@@ -100,11 +98,19 @@ namespace ProjectM.Server
|
|||||||
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks));
|
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 fully spawned: cleared only when no BASE husk remains. Expedition zone enemies are also
|
||||||
wave.Phase = WavePhase.Lull;
|
// EnemyTag but RegionTag{Expedition}; they must NOT hold the base siege open (DR-040 BLOCKER 3).
|
||||||
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.LullTicks));
|
int baseHusks = 0;
|
||||||
|
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Server-only expedition zone-enemy director: while a player is OUT in the expedition region and the base is in
|
||||||
|
/// <see cref="CyclePhase.Calm"/>, it seeds and drip-spawns one epoch-seeded combat wave around the expedition
|
||||||
|
/// origin. The wave size + grunt/charger composition is pure <see cref="ZoneEnemyMath"/> of the
|
||||||
|
/// <see cref="CycleRuntime.ExpeditionEpoch"/> (grunt-heavy -> charger-heavy as the epoch climbs), spawned one
|
||||||
|
/// every <see cref="ZoneEnemyDirector.SpawnIntervalTicks"/> at a deterministic ring, capped at
|
||||||
|
/// <see cref="ZoneEnemyDirector.MaxAlive"/> concurrent (the v1 ghost-relevancy budget). Each enemy is the
|
||||||
|
/// existing Husk ghost prefab + <c>RegionTag{Expedition}</c> + <see cref="ZoneEnemyTag"/>, 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 <see cref="CycleRuntime.ClearedThisEpoch"/> once —
|
||||||
|
/// the gate's once-per-epoch Ore reward reads that on the player's return.
|
||||||
|
///
|
||||||
|
/// Ordering: <c>[UpdateAfter(ExpeditionFieldSystem)]</c> ONLY. ExpeditionFieldSystem is itself
|
||||||
|
/// <c>[UpdateAfter(CyclePhaseSystem)]</c>, so ALSO declaring <c>[UpdateBefore(CyclePhaseSystem)]</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<NetworkTime>();
|
||||||
|
state.RequireForUpdate<CycleState>();
|
||||||
|
state.RequireForUpdate<ZoneEnemyDirector>();
|
||||||
|
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[BurstCompile]
|
||||||
|
public void OnUpdate(ref SystemState state)
|
||||||
|
{
|
||||||
|
var serverTick = SystemAPI.GetSingleton<NetworkTime>().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<RefRO<RegionTag>>().WithAll<PlayerTag>())
|
||||||
|
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<ZoneEnemyDirector>();
|
||||||
|
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
|
||||||
|
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
|
||||||
|
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
|
||||||
|
if (prefabs.Length == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||||
|
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
||||||
|
var runtime = SystemAPI.GetComponent<CycleRuntime>(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<BaseAnchor>(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<LocalTransform>(prefab);
|
||||||
|
ecb.SetComponent(enemy, baked.WithPosition(pos));
|
||||||
|
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
|
||||||
|
ecb.AddComponent<ZoneEnemyTag>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 4dc121e99d0e53640b5e6815640a0bc6
|
||||||
@@ -50,7 +50,10 @@ namespace ProjectM.Server
|
|||||||
|
|
||||||
// empty -> occupied: a new sortie begins; bump the epoch so the field reseeds fresh.
|
// empty -> occupied: a new sortie begins; bump the epoch so the field reseeds fresh.
|
||||||
if (occupied && !wasOccupied)
|
if (occupied && !wasOccupied)
|
||||||
|
{
|
||||||
runtime.ExpeditionEpoch += 1;
|
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);
|
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||||
@@ -112,7 +115,7 @@ namespace ProjectM.Server
|
|||||||
runtime.LastSpawnedEpoch = runtime.ExpeditionEpoch;
|
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)
|
if (wasOccupied && !occupied)
|
||||||
{
|
{
|
||||||
// Only EXPEDITION nodes — the base field is permanent RegionTag{Base} and must NOT be torn down here.
|
// 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<ResourceNode>, RefRO<RegionTag>>().WithEntityAccess())
|
SystemAPI.Query<RefRO<ResourceNode>, RefRO<RegionTag>>().WithEntityAccess())
|
||||||
if (region.ValueRO.Region == RegionId.Expedition)
|
if (region.ValueRO.Region == RegionId.Expedition)
|
||||||
ecb.DestroyEntity(e);
|
ecb.DestroyEntity(e);
|
||||||
foreach (var (bc, e) in SystemAPI.Query<RefRO<BlightClutter>>().WithEntityAccess())
|
// Blight clutter is Expedition-only today; guard by region defensively (DR-040 MINOR 1).
|
||||||
ecb.DestroyEntity(e);
|
foreach (var (region, e) in
|
||||||
|
SystemAPI.Query<RefRO<RegionTag>>().WithAll<BlightClutter>().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<RefRO<RegionTag>>().WithAll<ZoneEnemyTag>().WithEntityAccess())
|
||||||
|
if (region.ValueRO.Region == RegionId.Expedition)
|
||||||
|
ecb.DestroyEntity(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.PrevExpeditionOccupied = (byte)(occupied ? 1 : 0);
|
runtime.PrevExpeditionOccupied = (byte)(occupied ? 1 : 0);
|
||||||
|
|||||||
@@ -25,14 +25,11 @@ namespace ProjectM.Server
|
|||||||
[UpdateBefore(typeof(WaveSystem))]
|
[UpdateBefore(typeof(WaveSystem))]
|
||||||
public partial struct CyclePhaseSystem : ISystem
|
public partial struct CyclePhaseSystem : ISystem
|
||||||
{
|
{
|
||||||
EntityQuery m_AliveHusks;
|
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
public void OnCreate(ref SystemState state)
|
public void OnCreate(ref SystemState state)
|
||||||
{
|
{
|
||||||
state.RequireForUpdate<NetworkTime>();
|
state.RequireForUpdate<NetworkTime>();
|
||||||
state.RequireForUpdate<CycleState>();
|
state.RequireForUpdate<CycleState>();
|
||||||
m_AliveHusks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
@@ -104,15 +101,17 @@ namespace ProjectM.Server
|
|||||||
cycle.Phase = CyclePhase.Calm;
|
cycle.Phase = CyclePhase.Calm;
|
||||||
cycle.PhaseEndTick = 0;
|
cycle.PhaseEndTick = 0;
|
||||||
|
|
||||||
// The siege ends: despawn every remaining Husk (the locked despawn-on-breach fork) + reset the wave
|
// The siege ends: despawn the base siege Husks (the locked despawn-on-breach fork) + reset the
|
||||||
// so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths.
|
// wave so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths.
|
||||||
var husks = m_AliveHusks.ToEntityArray(Allocator.Temp);
|
|
||||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
for (int hi = 0; hi < husks.Length; hi++)
|
// Slice 3: cull the BASE wave only — an Expedition wave runs in its own region and must
|
||||||
ecb.DestroyEntity(husks[hi]);
|
// 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<RefRO<RegionTag>>().WithAll<EnemyTag>().WithEntityAccess())
|
||||||
|
if (hr.ValueRO.Region == RegionId.Base)
|
||||||
|
ecb.DestroyEntity(he);
|
||||||
ecb.Playback(state.EntityManager);
|
ecb.Playback(state.EntityManager);
|
||||||
ecb.Dispose();
|
ecb.Dispose();
|
||||||
husks.Dispose();
|
|
||||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveLost))
|
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveLost))
|
||||||
{
|
{
|
||||||
var wl = SystemAPI.GetComponent<WaveState>(waveLost);
|
var wl = SystemAPI.GetComponent<WaveState>(waveLost);
|
||||||
@@ -189,9 +188,14 @@ namespace ProjectM.Server
|
|||||||
{
|
{
|
||||||
if (!SystemAPI.TryGetSingleton<WaveState>(out var wave))
|
if (!SystemAPI.TryGetSingleton<WaveState>(out var wave))
|
||||||
return false;
|
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<RefRO<RegionTag>>().WithAll<EnemyTag>())
|
||||||
|
if (hr.ValueRO.Region == RegionId.Base) baseHusks++;
|
||||||
return wave.WaveNumber > defendStartWave
|
return wave.WaveNumber > defendStartWave
|
||||||
&& wave.RemainingToSpawn == 0
|
&& wave.RemainingToSpawn == 0
|
||||||
&& m_AliveHusks.CalculateEntityCount() == 0;
|
&& baseHusks == 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,12 +73,33 @@ namespace ProjectM.Server
|
|||||||
|
|
||||||
// A player returned to base from an expedition -> signal the ThreatDirector (it sizes/arms any
|
// 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.
|
// retaliation siege). The gate teleports the returner out of its radius, so this fires once per return.
|
||||||
if (returnedToBase && SystemAPI.TryGetSingletonEntity<ThreatState>(out var threatEntity))
|
if (returnedToBase)
|
||||||
{
|
{
|
||||||
var threat = SystemAPI.GetComponent<ThreatState>(threatEntity);
|
if (SystemAPI.TryGetSingletonEntity<ThreatState>(out var threatEntity))
|
||||||
threat.PendingReturns += 1;
|
{
|
||||||
threat.ExpeditionsCompleted += 1;
|
var threat = SystemAPI.GetComponent<ThreatState>(threatEntity);
|
||||||
SystemAPI.SetComponent(threatEntity, threat);
|
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<CycleState>()
|
||||||
|
&& SystemAPI.TryGetSingleton<ZoneEnemyDirector>(out var zoneDir)
|
||||||
|
&& SystemAPI.HasSingleton<ResourceLedger>())
|
||||||
|
{
|
||||||
|
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||||
|
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
||||||
|
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
|
||||||
|
{
|
||||||
|
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||||
|
StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre);
|
||||||
|
runtime.LastRewardedEpoch = runtime.ExpeditionEpoch;
|
||||||
|
SystemAPI.SetComponent(cycleEntity, runtime);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ namespace ProjectM.Server
|
|||||||
[UpdateBefore(typeof(CyclePhaseSystem))]
|
[UpdateBefore(typeof(CyclePhaseSystem))]
|
||||||
public partial struct ThreatDirectorSystem : ISystem
|
public partial struct ThreatDirectorSystem : ISystem
|
||||||
{
|
{
|
||||||
EntityQuery m_Husks;
|
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
public void OnCreate(ref SystemState state)
|
public void OnCreate(ref SystemState state)
|
||||||
@@ -37,7 +36,6 @@ namespace ProjectM.Server
|
|||||||
state.RequireForUpdate<CycleState>();
|
state.RequireForUpdate<CycleState>();
|
||||||
state.RequireForUpdate<ThreatState>();
|
state.RequireForUpdate<ThreatState>();
|
||||||
state.RequireForUpdate<ThreatConfig>();
|
state.RequireForUpdate<ThreatConfig>();
|
||||||
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[BurstCompile]
|
[BurstCompile]
|
||||||
@@ -109,17 +107,14 @@ namespace ProjectM.Server
|
|||||||
var start = new NetworkTick(threat.SiegeStartTick);
|
var start = new NetworkTick(threat.SiegeStartTick);
|
||||||
if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks)
|
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).
|
// Collapse the siege: cull every remaining BASE Husk only (expedition zone enemies are also
|
||||||
var husks = m_Husks.ToEntityArray(Allocator.Temp);
|
// EnemyTag but RegionTag{Expedition}; the timeout must not destroy them — DR-040 BLOCKER 3).
|
||||||
if (husks.Length > 0)
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||||
{
|
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithEntityAccess())
|
||||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
if (hr.ValueRO.Region == RegionId.Base)
|
||||||
for (int i = 0; i < husks.Length; i++)
|
ecb.DestroyEntity(he);
|
||||||
ecb.DestroyEntity(husks[i]);
|
ecb.Playback(state.EntityManager);
|
||||||
ecb.Playback(state.EntityManager);
|
ecb.Dispose();
|
||||||
ecb.Dispose();
|
|
||||||
}
|
|
||||||
husks.Dispose();
|
|
||||||
|
|
||||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
|
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -95,5 +95,36 @@ namespace ProjectM.Simulation
|
|||||||
if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; }
|
if (sq < bestSq) { bestSq = sq; index = i; isStructure = true; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Region-scoped variant of <see cref="PickWeightedNearest(float3,NativeList{float3},NativeList{float3},float,out bool,out int)"/>:
|
||||||
|
/// considers ONLY targets whose stored region byte equals <paramref name="region"/>, 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). <paramref name="playerRegions"/> /
|
||||||
|
/// <paramref name="structureRegions"/> are parallel to the position lists; the returned <paramref name="index"/>
|
||||||
|
/// maps to the FULL list so the caller's by-index entity lookup stays valid. Pure, Burst-safe (byte compares).
|
||||||
|
/// </summary>
|
||||||
|
public static void PickWeightedNearest(float3 from, NativeList<float3> playerPositions,
|
||||||
|
NativeList<byte> playerRegions, NativeList<float3> structurePositions, NativeList<byte> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using Unity.Entities;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <c>EnemyTag</c> + <c>RegionTag{Expedition}</c>, so Slice-1 readability/health-bars/telegraphs/damage and the
|
||||||
|
/// per-region AI target filter all work for them unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public struct ZoneEnemyTag : IComponentData { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="GruntsPerWave"/> +
|
||||||
|
/// <see cref="ChargersPerWave"/> baseline that shifts grunt->charger as the expedition epoch climbs (the
|
||||||
|
/// highest-leverage variety lever), spawned one every <see cref="SpawnIntervalTicks"/> at a deterministic ring of
|
||||||
|
/// <see cref="RingSlots"/> slots / <see cref="RingRadius"/> around the expedition origin, capped at
|
||||||
|
/// <see cref="MaxAlive"/> concurrent (the v1 ghost-relevancy mitigation). On a real clear the returning player
|
||||||
|
/// banks <see cref="RewardOre"/> to the shared ledger, once per epoch.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baked pool of zone-enemy prefab variants the director draws from by composition. Index 0 = Grunt variant,
|
||||||
|
/// index 1 = Charger variant (see <see cref="ZoneEnemyMath.IsChargerSlot"/>). Aliases the existing Husk ghost
|
||||||
|
/// prefabs so zone enemies reuse the whole combat/readability stack with no new ghost type.
|
||||||
|
/// </summary>
|
||||||
|
public struct ZoneEnemyPrefab : IBufferElementData
|
||||||
|
{
|
||||||
|
public Entity Prefab;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public struct ZoneEnemyState : IComponentData
|
||||||
|
{
|
||||||
|
public uint SpawnCounter;
|
||||||
|
public int RemainingToSpawn;
|
||||||
|
public uint NextSpawnTick;
|
||||||
|
public int SeededEpoch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0aedc87a4719411418ca5f914af613b8
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Unity.Mathematics;
|
||||||
|
|
||||||
|
namespace ProjectM.Simulation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="EnemyAIMath"/> / <c>ProductionMath</c>). The highest-leverage Slice-3 variety lever: the encounter
|
||||||
|
/// COMPOSITION shifts grunt-heavy -> charger-heavy as the expedition <c>epoch</c> climbs (grunt count stays
|
||||||
|
/// fixed; the per-epoch growth is all chargers).
|
||||||
|
/// </summary>
|
||||||
|
public static class ZoneEnemyMath
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Total enemies in this epoch's wave: the baked <paramref name="gruntsPerWave"/> + <paramref name="chargersPerWave"/>
|
||||||
|
/// baseline plus one extra per epoch beyond the first (a gentle ramp). Lower-bounded at 1 so an occupied
|
||||||
|
/// expedition always has a fight. <paramref name="epoch"/> is the monotonic sortie counter (>=1 in practice).
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic grunt/charger pick for spawn <paramref name="slot"/> of this epoch's wave. The charger
|
||||||
|
/// count is <paramref name="chargersPerWave"/> + (epoch - 1), clamped to the wave size, assigned to the LAST
|
||||||
|
/// slots; everything earlier is a Grunt. So the grunt count stays fixed at <paramref name="gruntsPerWave"/>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bff328839d124da48bd8a43b9d77f543
|
||||||
@@ -76,5 +76,11 @@ namespace ProjectM.Simulation
|
|||||||
|
|
||||||
/// <summary>Previous-tick expedition occupancy (1 = at least one player out), for the empty<->occupied edge.</summary>
|
/// <summary>Previous-tick expedition occupancy (1 = at least one player out), for the empty<->occupied edge.</summary>
|
||||||
public byte PrevExpeditionOccupied;
|
public byte PrevExpeditionOccupied;
|
||||||
|
|
||||||
|
/// <summary>The <see cref="ExpeditionEpoch"/> 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).</summary>
|
||||||
|
public int LastRewardedEpoch;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public byte ClearedThisEpoch;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2848,54 +2848,6 @@ Transform:
|
|||||||
m_Children: []
|
m_Children: []
|
||||||
m_Father: {fileID: 0}
|
m_Father: {fileID: 0}
|
||||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 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
|
--- !u!1 &1542636139
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -3360,6 +3312,60 @@ BoxCollider:
|
|||||||
serializedVersion: 3
|
serializedVersion: 3
|
||||||
m_Size: {x: 15.610933, y: 5, z: 1.5}
|
m_Size: {x: 15.610933, y: 5, z: 1.5}
|
||||||
m_Center: {x: 0, y: 2.5, z: 0}
|
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
|
--- !u!1 &1966551274
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -3670,54 +3676,6 @@ BoxCollider:
|
|||||||
serializedVersion: 3
|
serializedVersion: 3
|
||||||
m_Size: {x: 15.610933, y: 5, z: 1.5}
|
m_Size: {x: 15.610933, y: 5, z: 1.5}
|
||||||
m_Center: {x: 0, y: 2.5, z: 0}
|
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
|
--- !u!1 &2145598868
|
||||||
GameObject:
|
GameObject:
|
||||||
m_ObjectHideFlags: 0
|
m_ObjectHideFlags: 0
|
||||||
@@ -3773,9 +3731,7 @@ SceneRoots:
|
|||||||
m_Roots:
|
m_Roots:
|
||||||
- {fileID: 1498433572}
|
- {fileID: 1498433572}
|
||||||
- {fileID: 1379903946}
|
- {fileID: 1379903946}
|
||||||
- {fileID: 2143686867}
|
|
||||||
- {fileID: 409538539}
|
- {fileID: 409538539}
|
||||||
- {fileID: 1527461113}
|
|
||||||
- {fileID: 1435545288}
|
- {fileID: 1435545288}
|
||||||
- {fileID: 874705788}
|
- {fileID: 874705788}
|
||||||
- {fileID: 1930969067}
|
- {fileID: 1930969067}
|
||||||
@@ -3791,3 +3747,4 @@ SceneRoots:
|
|||||||
- {fileID: 1268920429}
|
- {fileID: 1268920429}
|
||||||
- {fileID: 722706770}
|
- {fileID: 722706770}
|
||||||
- {fileID: 2145598870}
|
- {fileID: 2145598870}
|
||||||
|
- {fileID: 1957070224}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ namespace ProjectM.Tests
|
|||||||
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
|
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
|
||||||
em.AddComponent<PlayerTag>(e);
|
em.AddComponent<PlayerTag>(e);
|
||||||
em.AddBuffer<DamageEvent>(e);
|
em.AddBuffer<DamageEvent>(e);
|
||||||
|
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ namespace ProjectM.Tests
|
|||||||
em.AddComponent<IsLunging>(e);
|
em.AddComponent<IsLunging>(e);
|
||||||
em.SetComponentEnabled<IsLunging>(e, false); // baked DISABLED on the real Charger (spawns not-lunging)
|
em.SetComponentEnabled<IsLunging>(e, false); // baked DISABLED on the real Charger (spawns not-lunging)
|
||||||
em.AddComponent<EnemyTag>(e);
|
em.AddComponent<EnemyTag>(e);
|
||||||
|
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -173,8 +173,9 @@ namespace ProjectM.Tests
|
|||||||
ledger.Add(new StorageEntry { ItemId = 2, Count = 100 });
|
ledger.Add(new StorageEntry { ItemId = 2, Count = 100 });
|
||||||
ledger.Add(new StorageEntry { ItemId = 4, Count = 40 });
|
ledger.Add(new StorageEntry { ItemId = 4, Count = 40 });
|
||||||
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 3);
|
MakeWaveState(em, waveNumber: 6, phase: WavePhase.Spawning, remainingToSpawn: 3);
|
||||||
em.CreateEntity(typeof(EnemyTag)); // two live husks the team failed to clear
|
// two live BASE husks the team failed to clear (RegionTag defaults to Region 0 = Base)
|
||||||
em.CreateEntity(typeof(EnemyTag));
|
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
||||||
|
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
||||||
|
|
||||||
group.Update();
|
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<StorageEntry>(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<RegionTag>(exp).Region,
|
||||||
|
"the survivor is the expedition-region husk.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void Overrun_Resolves_Once_Then_Stays_Calm_Without_Recharging()
|
public void Overrun_Resolves_Once_Then_Stays_Calm_Without_Recharging()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ namespace ProjectM.Tests
|
|||||||
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 });
|
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 });
|
||||||
ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 });
|
ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 });
|
||||||
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 3);
|
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 3);
|
||||||
em.CreateEntity(typeof(EnemyTag));
|
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
||||||
em.CreateEntity(typeof(EnemyTag));
|
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
||||||
|
|
||||||
group.Update();
|
group.Update();
|
||||||
|
|
||||||
@@ -216,8 +216,8 @@ namespace ProjectM.Tests
|
|||||||
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 });
|
ledger.Add(new StorageEntry { ItemId = ResourceId.Ore, Count = 100 });
|
||||||
ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 });
|
ledger.Add(new StorageEntry { ItemId = ResourceId.Charge, Count = 40 });
|
||||||
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0);
|
MakeWave(em, waveNumber: 6, phase: WavePhase.Spawning, remaining: 0);
|
||||||
em.CreateEntity(typeof(EnemyTag));
|
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
||||||
em.CreateEntity(typeof(EnemyTag));
|
em.CreateEntity(typeof(EnemyTag), typeof(RegionTag));
|
||||||
|
|
||||||
group.Update();
|
group.Update();
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ namespace ProjectM.Tests
|
|||||||
var w = em.CreateEntity(typeof(WaveState));
|
var w = em.CreateEntity(typeof(WaveState));
|
||||||
em.SetComponentData(w, new WaveState { RemainingToSpawn = 5, Phase = WavePhase.Spawning });
|
em.SetComponentData(w, new WaveState { RemainingToSpawn = 5, Phase = WavePhase.Spawning });
|
||||||
for (int i = 0; i < 3; i++)
|
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();
|
group.Update();
|
||||||
|
|
||||||
|
|||||||
@@ -170,5 +170,65 @@ namespace ProjectM.Tests
|
|||||||
Assert.AreEqual(-1, idx);
|
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<float3>(Allocator.Temp);
|
||||||
|
using var pRegions = new NativeList<byte>(Allocator.Temp);
|
||||||
|
using var structs = new NativeList<float3>(Allocator.Temp);
|
||||||
|
using var sRegions = new NativeList<byte>(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<float3>(Allocator.Temp);
|
||||||
|
using var pRegions = new NativeList<byte>(Allocator.Temp);
|
||||||
|
using var structs = new NativeList<float3>(Allocator.Temp);
|
||||||
|
using var sRegions = new NativeList<byte>(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<float3>(Allocator.Temp);
|
||||||
|
using var pRegions = new NativeList<byte>(Allocator.Temp);
|
||||||
|
using var structs = new NativeList<float3>(Allocator.Temp);
|
||||||
|
using var sRegions = new NativeList<byte>(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<float3>(Allocator.Temp);
|
||||||
|
using var pRegions = new NativeList<byte>(Allocator.Temp);
|
||||||
|
using var structs = new NativeList<float3>(Allocator.Temp);
|
||||||
|
using var sRegions = new NativeList<byte>(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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plain-Entities EditMode tests for the once-per-epoch zone-clear reward folded into
|
||||||
|
/// <see cref="ExpeditionGateSystem"/> (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).
|
||||||
|
/// </summary>
|
||||||
|
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<SimulationSystemGroup>();
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<ExpeditionGateSystem>());
|
||||||
|
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<StorageEntry>(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<PlayerTag>(e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int OreInLedger(EntityManager em, Entity cyc)
|
||||||
|
{
|
||||||
|
var buf = em.GetBuffer<StorageEntry>(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<CycleRuntime>(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)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 825422a92eb4b1d4eb4cdafc57884a01
|
||||||
@@ -83,6 +83,7 @@ namespace ProjectM.Tests
|
|||||||
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
|
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
|
||||||
em.AddComponent<PlayerTag>(e);
|
em.AddComponent<PlayerTag>(e);
|
||||||
em.AddBuffer<DamageEvent>(e);
|
em.AddBuffer<DamageEvent>(e);
|
||||||
|
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +96,7 @@ namespace ProjectM.Tests
|
|||||||
em.AddComponentData(e, kb);
|
em.AddComponentData(e, kb);
|
||||||
em.AddComponentData(e, new AttackWindup());
|
em.AddComponentData(e, new AttackWindup());
|
||||||
em.AddComponent<EnemyTag>(e);
|
em.AddComponent<EnemyTag>(e);
|
||||||
|
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ namespace ProjectM.Tests
|
|||||||
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
|
em.AddComponentData(e, new Health { Current = 100f, Max = 100f });
|
||||||
em.AddComponent<PlayerTag>(e);
|
em.AddComponent<PlayerTag>(e);
|
||||||
em.AddBuffer<DamageEvent>(e);
|
em.AddBuffer<DamageEvent>(e);
|
||||||
|
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ namespace ProjectM.Tests
|
|||||||
em.AddComponentData(e, new KnockbackState());
|
em.AddComponentData(e, new KnockbackState());
|
||||||
em.AddComponentData(e, new AttackWindup());
|
em.AddComponentData(e, new AttackWindup());
|
||||||
em.AddComponent<EnemyTag>(e);
|
em.AddComponent<EnemyTag>(e);
|
||||||
|
em.AddComponentData(e, new RegionTag { Region = RegionId.Base });
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,7 +138,10 @@ namespace ProjectM.Tests
|
|||||||
|
|
||||||
// Three Husks still on the field with no one to clear them.
|
// Three Husks still on the field with no one to clear them.
|
||||||
for (int i = 0; i < 3; i++)
|
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();
|
group.Update();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Plain-Entities EditMode tests for the server-only <see cref="ZoneEnemyDirectorSystem"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
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<SimulationSystemGroup>();
|
||||||
|
group.AddSystemToUpdateList(world.GetOrCreateSystem<ZoneEnemyDirectorSystem>());
|
||||||
|
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<Prefab>(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<ZoneEnemyPrefab>(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<PlayerTag>(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<RegionTag>(Allocator.Temp);
|
||||||
|
Assert.AreEqual(RegionId.Expedition, arr[0].Region, "the spawn is tagged RegionTag{Expedition}");
|
||||||
|
arr.Dispose();
|
||||||
|
|
||||||
|
var zs = em.GetComponentData<ZoneEnemyState>(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<LocalTransform>(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<CycleRuntime>(cyc).ClearedThisEpoch,
|
||||||
|
"wave fully spawned + no live zone enemies -> a real clear is marked");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f307978f01b668b42ba10eea2029091b
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using NUnit.Framework;
|
||||||
|
using ProjectM.Simulation;
|
||||||
|
|
||||||
|
namespace ProjectM.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Pure-function tests for <see cref="ZoneEnemyMath"/> (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).
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e61ab20e496e331449ba6e26440ad000
|
||||||
@@ -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`.
|
- **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-###`).
|
- **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.
|
- **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
|
## 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).
|
- **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<Dead>()`); **bake the enableable DISABLED** so instances spawn off. Respawn/death *timing* is server-only.
|
- **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<Dead>()`); **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.
|
- **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]].
|
- **`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 a ledger buffer via a DISTINCT tag (`ResourceLedger`), **never `GetSingleton<StorageEntry>`** when a second `StorageEntry` buffer exists elsewhere → "multiple instances" throw.
|
- **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]].
|
- **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
|
### Physics & character controller
|
||||||
|
|||||||
@@ -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<byte>` regions; both seek loops gained `RefRO<RegionTag>` + 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.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: DR-040
|
id: DR-040
|
||||||
title: Slice 3 — Expedition Combat Spine (v1 plan, reviewed + locked)
|
title: Slice 3 — Expedition Combat Spine (v1 plan, reviewed + locked → built)
|
||||||
status: accepted
|
status: built
|
||||||
date: 2026-06-18
|
date: 2026-06-18
|
||||||
tags:
|
tags:
|
||||||
- decision
|
- 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.
|
- **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.
|
- **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.
|
- **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).
|
||||||
|
|||||||
Reference in New Issue
Block a user