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:
@@ -40,8 +40,9 @@ namespace ProjectM.Server
|
||||
// Snapshot living player targets once this tick (stable query order).
|
||||
var playerEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var playerPositions = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (xform, health, entity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
|
||||
var playerRegions = new NativeList<byte>(Allocator.Temp);
|
||||
foreach (var (xform, health, region, entity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
|
||||
.WithAll<PlayerTag>()
|
||||
.WithEntityAccess())
|
||||
{
|
||||
@@ -49,6 +50,7 @@ namespace ProjectM.Server
|
||||
continue; // don't chase or strike a corpse
|
||||
playerEntities.Add(entity);
|
||||
playerPositions.Add(xform.ValueRO.Position);
|
||||
playerRegions.Add(region.ValueRO.Region);
|
||||
}
|
||||
|
||||
// EB-1 fortress aggro: also snapshot live structures (Turret/Wall/Pylon carry Health; automation
|
||||
@@ -56,8 +58,9 @@ namespace ProjectM.Server
|
||||
// the base even with every player dead/away (the locked 'push for structures' fork).
|
||||
var structureEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var structurePositions = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (sx, sh, se) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>>()
|
||||
var structureRegions = new NativeList<byte>(Allocator.Temp);
|
||||
foreach (var (sx, sh, sr, se) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
|
||||
.WithAll<PlacedStructure>()
|
||||
.WithEntityAccess())
|
||||
{
|
||||
@@ -65,6 +68,7 @@ namespace ProjectM.Server
|
||||
continue; // skip a structure already at 0 (pending destroy this tick)
|
||||
structureEntities.Add(se);
|
||||
structurePositions.Add(sx.ValueRO.Position);
|
||||
structureRegions.Add(sr.ValueRO.Region);
|
||||
}
|
||||
|
||||
// END-1: the Engine Core is a FALLBACK target. When no living player/structure remains, undefended
|
||||
@@ -77,8 +81,10 @@ namespace ProjectM.Server
|
||||
{
|
||||
playerEntities.Dispose();
|
||||
playerPositions.Dispose();
|
||||
playerRegions.Dispose();
|
||||
structureEntities.Dispose();
|
||||
structurePositions.Dispose();
|
||||
structureRegions.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,12 +101,14 @@ namespace ProjectM.Server
|
||||
bool sweep = havePhysics && envMask != 0u;
|
||||
const float SweepRadius = 0.5f; // collide-and-slide sphere radius for Husk movement
|
||||
|
||||
foreach (var (xform, stats, cooldown, knockback, windup) in
|
||||
foreach (var (xform, stats, cooldown, knockback, windup, region) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>>()
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
|
||||
.WithAll<EnemyTag>().WithNone<LungeState>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte huskRegion = region.ValueRO.Region;
|
||||
bool huskCoreAlive = coreAlive && huskRegion == RegionId.Base;
|
||||
|
||||
// Knockback overrides seek/strike for its window — EnemyAISystem stays the SOLE writer of Position.
|
||||
var kb = knockback.ValueRO;
|
||||
@@ -122,8 +130,8 @@ namespace ProjectM.Server
|
||||
|
||||
// EB-1 fortress aggro: nearest of players (weight 1) + structures (StructureAggroWeight) — a wall/
|
||||
// turret is the preferred target unless a player is in the way (closer after weighting).
|
||||
EnemyAIMath.PickWeightedNearest(pos, playerPositions, structurePositions, structAggro, out bool tgtIsStruct, out int tgtIdx);
|
||||
if (tgtIdx < 0 && !coreAlive)
|
||||
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, huskRegion, structAggro, out bool tgtIsStruct, out int tgtIdx);
|
||||
if (tgtIdx < 0 && !huskCoreAlive)
|
||||
continue; // no player/structure and no Core -> nothing to seek
|
||||
Entity targetEntity = tgtIdx < 0 ? Entity.Null
|
||||
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
|
||||
@@ -201,12 +209,14 @@ namespace ProjectM.Server
|
||||
uint ChargerWindupTicks = (uint)math.max(1f, tune.ChargerWindupTicks); // readable telegraph lead
|
||||
uint ChargerWhiffStaggerTicks = (uint)math.max(1f, tune.ChargerWhiffStaggerTicks); // punish window
|
||||
uint chargerWhiffsThisTick = 0;
|
||||
foreach (var (xform, stats, cooldown, knockback, windup, lunge) in
|
||||
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>>()
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
|
||||
.WithAll<EnemyTag>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte cHuskRegion = region.ValueRO.Region;
|
||||
bool cHuskCoreAlive = coreAlive && cHuskRegion == RegionId.Base;
|
||||
|
||||
// 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer).
|
||||
var kb = knockback.ValueRO;
|
||||
@@ -227,8 +237,8 @@ namespace ProjectM.Server
|
||||
}
|
||||
|
||||
// EB-1 fortress aggro: same weighted target selection as the Grunt pass (shared helper).
|
||||
EnemyAIMath.PickWeightedNearest(pos, playerPositions, structurePositions, structAggro, out bool cIsStruct, out int cIdx);
|
||||
if (cIdx < 0 && !coreAlive)
|
||||
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, cHuskRegion, structAggro, out bool cIsStruct, out int cIdx);
|
||||
if (cIdx < 0 && !cHuskCoreAlive)
|
||||
continue;
|
||||
Entity cTargetEntity = cIdx < 0 ? Entity.Null
|
||||
: (cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]);
|
||||
@@ -340,8 +350,10 @@ namespace ProjectM.Server
|
||||
ecb.Dispose();
|
||||
playerEntities.Dispose();
|
||||
playerPositions.Dispose();
|
||||
playerRegions.Dispose();
|
||||
structureEntities.Dispose();
|
||||
structurePositions.Dispose();
|
||||
structureRegions.Dispose();
|
||||
}
|
||||
|
||||
// Swept collide-and-slide for server-authoritative Husk movement: sphere-cast the intended step against
|
||||
|
||||
Reference in New Issue
Block a user