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).
|
||||
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
|
||||
|
||||
@@ -23,7 +23,6 @@ namespace ProjectM.Server
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
public partial struct WaveSystem : ISystem
|
||||
{
|
||||
EntityQuery m_AliveHusks;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
@@ -31,7 +30,6 @@ namespace ProjectM.Server
|
||||
state.RequireForUpdate<WaveDirector>();
|
||||
state.RequireForUpdate<WaveState>();
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
m_AliveHusks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -100,11 +98,19 @@ namespace ProjectM.Server
|
||||
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.SpawnIntervalTicks));
|
||||
}
|
||||
}
|
||||
else if (m_AliveHusks.CalculateEntityCount() == 0)
|
||||
else
|
||||
{
|
||||
// Wave cleared: calm before the next.
|
||||
wave.Phase = WavePhase.Lull;
|
||||
wave.NextActionTick = TickUtil.NonZero(now + (uint)math.max(1, director.LullTicks));
|
||||
// Wave fully spawned: cleared only when no BASE husk remains. Expedition zone enemies are also
|
||||
// EnemyTag but RegionTag{Expedition}; they must NOT hold the base siege open (DR-040 BLOCKER 3).
|
||||
int baseHusks = 0;
|
||||
foreach (var hr in SystemAPI.Query<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.
|
||||
if (occupied && !wasOccupied)
|
||||
{
|
||||
runtime.ExpeditionEpoch += 1;
|
||||
runtime.ClearedThisEpoch = 0; // a fresh sortie has not been cleared yet (gates the once-per-epoch reward)
|
||||
}
|
||||
|
||||
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
@@ -112,7 +115,7 @@ namespace ProjectM.Server
|
||||
runtime.LastSpawnedEpoch = runtime.ExpeditionEpoch;
|
||||
}
|
||||
|
||||
// DESTROY: the last player left the expedition — clear the whole field (nodes + clutter).
|
||||
// DESTROY: the last player left the expedition — clear the whole field (nodes + clutter + zone enemies).
|
||||
if (wasOccupied && !occupied)
|
||||
{
|
||||
// Only EXPEDITION nodes — the base field is permanent RegionTag{Base} and must NOT be torn down here.
|
||||
@@ -120,8 +123,17 @@ namespace ProjectM.Server
|
||||
SystemAPI.Query<RefRO<ResourceNode>, RefRO<RegionTag>>().WithEntityAccess())
|
||||
if (region.ValueRO.Region == RegionId.Expedition)
|
||||
ecb.DestroyEntity(e);
|
||||
foreach (var (bc, e) in SystemAPI.Query<RefRO<BlightClutter>>().WithEntityAccess())
|
||||
ecb.DestroyEntity(e);
|
||||
// Blight clutter is Expedition-only today; guard by region defensively (DR-040 MINOR 1).
|
||||
foreach (var (region, e) in
|
||||
SystemAPI.Query<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);
|
||||
|
||||
@@ -25,14 +25,11 @@ namespace ProjectM.Server
|
||||
[UpdateBefore(typeof(WaveSystem))]
|
||||
public partial struct CyclePhaseSystem : ISystem
|
||||
{
|
||||
EntityQuery m_AliveHusks;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<CycleState>();
|
||||
m_AliveHusks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -104,15 +101,17 @@ namespace ProjectM.Server
|
||||
cycle.Phase = CyclePhase.Calm;
|
||||
cycle.PhaseEndTick = 0;
|
||||
|
||||
// The siege ends: despawn every remaining Husk (the locked despawn-on-breach fork) + reset the wave
|
||||
// so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths.
|
||||
var husks = m_AliveHusks.ToEntityArray(Allocator.Temp);
|
||||
// The siege ends: despawn the base siege Husks (the locked despawn-on-breach fork) + reset the
|
||||
// wave so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths.
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
for (int hi = 0; hi < husks.Length; hi++)
|
||||
ecb.DestroyEntity(husks[hi]);
|
||||
// Slice 3: cull the BASE wave only — an Expedition wave runs in its own region and must
|
||||
// survive a base Core breach. A region-blind EnemyTag wipe would also spuriously trip the
|
||||
// zone director's aliveZone==0 clear/reward edge. Mirrors ThreatDirectorSystem + DefendCleared.
|
||||
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithEntityAccess())
|
||||
if (hr.ValueRO.Region == RegionId.Base)
|
||||
ecb.DestroyEntity(he);
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
husks.Dispose();
|
||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveLost))
|
||||
{
|
||||
var wl = SystemAPI.GetComponent<WaveState>(waveLost);
|
||||
@@ -189,9 +188,14 @@ namespace ProjectM.Server
|
||||
{
|
||||
if (!SystemAPI.TryGetSingleton<WaveState>(out var wave))
|
||||
return false;
|
||||
// Cleared only when no BASE husk remains: expedition zone enemies (EnemyTag + RegionTag{Expedition})
|
||||
// must not hold the base siege open (DR-040 BLOCKER 3 — same global-count soft-lock as WaveSystem).
|
||||
int baseHusks = 0;
|
||||
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>())
|
||||
if (hr.ValueRO.Region == RegionId.Base) baseHusks++;
|
||||
return wave.WaveNumber > defendStartWave
|
||||
&& 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
|
||||
// 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);
|
||||
threat.PendingReturns += 1;
|
||||
threat.ExpeditionsCompleted += 1;
|
||||
SystemAPI.SetComponent(threatEntity, threat);
|
||||
if (SystemAPI.TryGetSingletonEntity<ThreatState>(out var threatEntity))
|
||||
{
|
||||
var threat = SystemAPI.GetComponent<ThreatState>(threatEntity);
|
||||
threat.PendingReturns += 1;
|
||||
threat.ExpeditionsCompleted += 1;
|
||||
SystemAPI.SetComponent(threatEntity, threat);
|
||||
}
|
||||
|
||||
// Once-per-epoch zone-clear reward: a returner banks flat Ore IFF this epoch's expedition wave was
|
||||
// actually cleared and not yet rewarded. Resolved ONCE here (not per-returner) so two same-tick co-op
|
||||
// returns pay exactly once (DR-040 BLOCKER 4) and gate re-entry before a clear can't farm (MINOR 2).
|
||||
if (SystemAPI.HasSingleton<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))]
|
||||
public partial struct ThreatDirectorSystem : ISystem
|
||||
{
|
||||
EntityQuery m_Husks;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
@@ -37,7 +36,6 @@ namespace ProjectM.Server
|
||||
state.RequireForUpdate<CycleState>();
|
||||
state.RequireForUpdate<ThreatState>();
|
||||
state.RequireForUpdate<ThreatConfig>();
|
||||
m_Husks = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>());
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -109,17 +107,14 @@ namespace ProjectM.Server
|
||||
var start = new NetworkTick(threat.SiegeStartTick);
|
||||
if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks)
|
||||
{
|
||||
// Collapse the siege: cull every remaining Husk (a cached tag query, never RefRO on a tag).
|
||||
var husks = m_Husks.ToEntityArray(Allocator.Temp);
|
||||
if (husks.Length > 0)
|
||||
{
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
for (int i = 0; i < husks.Length; i++)
|
||||
ecb.DestroyEntity(husks[i]);
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
husks.Dispose();
|
||||
// Collapse the siege: cull every remaining BASE Husk only (expedition zone enemies are also
|
||||
// EnemyTag but RegionTag{Expedition}; the timeout must not destroy them — DR-040 BLOCKER 3).
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithEntityAccess())
|
||||
if (hr.ValueRO.Region == RegionId.Base)
|
||||
ecb.DestroyEntity(he);
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
|
||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
|
||||
{
|
||||
|
||||
@@ -95,5 +95,36 @@ namespace ProjectM.Simulation
|
||||
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>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user