Run Re-Do

This commit is contained in:
2026-07-02 20:41:43 -07:00
parent 86575dd5bc
commit 16e396841e
188 changed files with 8291 additions and 2429 deletions
@@ -1,92 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-authoritative ability-damage upgrade (handles <see cref="AbilityUpgradeRequest"/> RPCs). Resolves
/// the sender's player (SourceConnection -&gt; NetworkId -&gt; GhostOwner) and, if the global ledger affords the
/// Aether cost, withdraws it IN-PLACE and grows a single damage <see cref="StatModifier"/> on the player
/// (replace-by-SourceId so the [InternalBufferCapacity(8)] buffer stays bounded — repeated upgrades grow one
/// row's percent rather than appending). StatRecomputeSystem folds it into EffectiveAbilityStats.Damage on
/// both worlds. Plain server SimulationSystemGroup (not predicted → applied once).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct AbilityUpgradeSystem : ISystem
{
const uint UpgradeSourceId = Tuning.AbilityUpgradeSourceId; // distinct sentinel so the upgrade modifier is found + grown
const float TierStep = Tuning.AbilityUpgradeTierStep; // +25% damage per tier
const int CostAmount = Tuning.AbilityUpgradeCostAmount; // Aether per tier
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ResourceLedger>();
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<AbilityUpgradeRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, entity) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = entity;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, requestEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>>().WithAll<AbilityUpgradeRequest>().WithEntityAccess())
{
var conn = receive.ValueRO.SourceConnection;
if (SystemAPI.HasComponent<NetworkId>(conn)
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
{
int have = 0;
for (int i = 0; i < ledger.Length; i++)
if (ledger[i].ItemId == ResourceId.Aether) { have = ledger[i].Count; break; }
if (have >= CostAmount)
{
StorageMath.Withdraw(ledger, ResourceId.Aether, CostAmount);
var mods = SystemAPI.GetBuffer<StatModifier>(player);
bool grown = false;
for (int i = 0; i < mods.Length; i++)
{
if (mods[i].SourceId == UpgradeSourceId && mods[i].Target == (byte)StatTarget.Damage)
{
var m = mods[i];
m.Value += TierStep;
mods[i] = m;
grown = true;
break;
}
}
if (!grown)
mods.Add(new StatModifier
{
Target = (byte)StatTarget.Damage,
Op = (byte)ModOp.PercentAdd,
Value = TierStep,
SourceId = UpgradeSourceId,
});
}
}
ecb.DestroyEntity(requestEntity);
}
ecb.Playback(state.EntityManager);
playerByConn.Dispose();
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ff2ed6b5fa37a174aa7413f4d2f5d6b3
@@ -0,0 +1,133 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="BoonPickRequest"/> + the reward-grace AUTO-PICK backstop. A valid pick
/// (sender resolved, <c>RunInfo.Lifecycle == RoomReward</c> — the D-F4 gate — <c>Pending == 1</c>, index in
/// range, option id known to the catalog) appends ONE <see cref="StatModifier"/> in the run-scoped BOON band
/// (<c>Tuning.BoonSourceIdBase + BoonPickCounter++</c> — distinct rows, one range-strip clears the run) and
/// clears <c>Pending</c>; the buffer mutation is non-structural and folds through the unchanged
/// StatRecomputeSystem on both worlds (rollback-correct). When the reward grace elapses, every still-pending
/// EXPEDITION player is auto-dealt <c>Option0</c> (the operator's default un-picked policy — a player always
/// gets something) so the run never stalls on an AFK picker.
///
/// Ordering: <c>[UpdateBefore(RunDirectorSystem)]</c> — ALL RPC receivers sit before the director (the
/// ReadyToggle/RouteSelect symmetry). This closes the D-F4 straggler race STRUCTURALLY: on the tick the
/// director strips (Returning), a straggler pick is rejected here FIRST (lifecycle is already past RoomReward),
/// so nothing can append after the strip; and the auto-pick lands before the director's exit gate reads
/// Pending. Requests are ALWAYS destroyed. No CyclePhase edge (the room-chain hard rule).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct BoonApplySystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<BoonCatalog>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
state.RequireForUpdate<NetworkTime>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
bool rewarding = info.Lifecycle == RunLifecycle.RoomReward;
var catalog = SystemAPI.GetComponent<BoonCatalog>(SystemAPI.GetSingletonEntity<BoonCatalog>());
if (!catalog.Value.IsCreated)
return;
ref var pool = ref catalog.Value.Value;
bool runDirty = false;
// ---- explicit picks (drained every tick so stale requests die even outside RoomReward) ----
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, entity) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, BoonOffer, StatModifier>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = entity;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, requestEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<BoonPickRequest>>().WithEntityAccess())
{
var conn = receive.ValueRO.SourceConnection;
if (rewarding
&& req.ValueRO.Index < 3
&& SystemAPI.HasComponent<NetworkId>(conn)
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
{
var offer = SystemAPI.GetComponent<BoonOffer>(player);
if (offer.Pending == 1)
{
byte id = req.ValueRO.Index == 2 ? offer.Option2
: req.ValueRO.Index == 1 ? offer.Option1 : offer.Option0;
if (Apply(ref state, player, id, ref pool, ref run))
{
offer.Pending = 0;
SystemAPI.SetComponent(player, offer);
runDirty = true;
}
}
}
ecb.DestroyEntity(requestEntity);
}
ecb.Playback(state.EntityManager);
playerByConn.Dispose();
// ---- reward-grace auto-pick backstop (Option0 — the player always gets something) ----
if (rewarding && run.RewardGraceTick != 0u)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (serverTick.IsValid && !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick))
{
foreach (var (offer, region, entity) in
SystemAPI.Query<RefRW<BoonOffer>, RefRO<RegionTag>>()
.WithAll<PlayerTag, StatModifier>().WithEntityAccess())
{
if (offer.ValueRO.Pending != 1 || region.ValueRO.Region != RegionId.Expedition)
continue;
if (Apply(ref state, entity, offer.ValueRO.Option0, ref pool, ref run))
runDirty = true;
offer.ValueRW.Pending = 0; // cleared even if the id was unknown — never wedge the gate
}
}
}
if (runDirty)
SystemAPI.SetComponent(dirEntity, run); // the documented BoonPickCounter co-write (band provenance)
}
/// <summary>Append the boon's StatModifier in the run-scoped band. False iff the id is unknown/zero.</summary>
static bool Apply(ref SystemState state, Entity player, byte boonId, ref BoonCatalogBlob pool, ref RunRuntime run)
{
if (boonId == 0)
return false;
int idx = BoonMath.FindDef(ref pool, boonId);
if (idx < 0)
return false; // unknown id (catalog drift) — preserve-and-skip, never throw
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
mods.Add(new StatModifier
{
Target = pool.Defs[idx].Target,
Op = pool.Defs[idx].Op,
Value = pool.Defs[idx].Value,
SourceId = Tuning.BoonSourceIdBase + (run.BoonPickCounter % Tuning.BoonSourceIdSpan),
});
run.BoonPickCounter += 1;
return true;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5749745bedc86ca4396b9a3911ef8773
@@ -0,0 +1,78 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server-only choice-of-3 boon dealer: once per <see cref="RunRuntime.RoomEpoch"/> (int-equality latch on
/// <see cref="BoonOfferState"/>, attached beside the catalog singleton), when the run FSM enters RoomReward it
/// draws each EXPEDITION player's 3 distinct, rarity-weighted, class-filtered options via
/// <see cref="BoonMath.PickBoons"/> — deterministically seeded from Hash(RunSeed, room, NetworkId) — and writes
/// the player's owner-only replicated <see cref="BoonOffer"/> (Pending=1). A base-region player (dead-respawned,
/// late joiner) gets NO offer and never holds the gate (RunDirector counts only Pending!=0). BoonApplySystem
/// (Step 10) consumes picks; the Returning-edge strip zeroes stragglers.
///
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> — on the RoomReward ENTRY tick this runs after the
/// transition, so offers exist BEFORE RunDirector's exit gate first evaluates (next tick). No CyclePhase edge.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(RunDirectorSystem))]
public partial struct BoonOfferSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<BoonCatalog>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var catalogEntity = SystemAPI.GetSingletonEntity<BoonCatalog>();
// One-shot: attach this system's latch beside the catalog singleton (the RoomFieldState idiom).
if (!SystemAPI.HasComponent<BoonOfferState>(catalogEntity))
{
state.EntityManager.AddComponentData(catalogEntity, new BoonOfferState());
return; // structural change — clean re-read next tick
}
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
if (info.Lifecycle != RunLifecycle.RoomReward)
return;
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
var offered = SystemAPI.GetComponent<BoonOfferState>(catalogEntity);
if (offered.OfferedRoomEpoch == run.RoomEpoch)
return; // this room's offers are already dealt
var catalog = SystemAPI.GetComponent<BoonCatalog>(catalogEntity);
if (!catalog.Value.IsCreated)
return;
ref var pool = ref catalog.Value.Value;
foreach (var (offer, owner, region, cls) in
SystemAPI.Query<RefRW<BoonOffer>, RefRO<GhostOwner>, RefRO<RegionTag>, RefRO<PlayerClass>>()
.WithAll<PlayerTag>())
{
if (region.ValueRO.Region != RegionId.Expedition)
continue; // home-bound players (dead-respawned, joiners) are dealt nothing
// Deterministic per-player draw: reconnect-stable per session, replay-reproducible per (seed, room).
uint offerSeed = RunMapMath.Hash(run.RunSeed, (uint)info.CurrentRoom, (uint)owner.ValueRO.NetworkId) | 1u;
BoonMath.PickBoons(offerSeed, cls.ValueRO.ClassId, ref pool, out byte o0, out byte o1, out byte o2);
offer.ValueRW = new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 };
}
offered.OfferedRoomEpoch = run.RoomEpoch;
SystemAPI.SetComponent(catalogEntity, offered);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2d3715c60d2cc2348ac4ff7600006d23
@@ -0,0 +1,192 @@
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 per-ROOM enemy director — the Step-6 successor of the presence-keyed <c>ZoneEnemyDirectorSystem</c>.
/// While the run FSM has a room active (<see cref="RunInfo.Lifecycle"/> == InRoom) it seeds ONE wave per
/// <see cref="RunRuntime.RoomEpoch"/> (int-equality reseed) sized by <see cref="ZoneEnemyMath.WaveSlots"/> indexed
/// on the room's <see cref="RoomPlan.DifficultyEpoch"/> (deeper rooms + Elite/Boss types skew heavier — the
/// grounded MC-2 mix bands are reused verbatim), drip-spawned one SLOT per cadence at the deterministic ring
/// around <see cref="RegionMath.ExpeditionRoomOrigin"/>(base, ActiveSubSlot), under the same
/// <see cref="ZoneEnemyDirector.MaxAlive"/> "spawn-the-pack-only-if-it-fits-else-wait" relevancy guard. A
/// <see cref="RoomTypeId.Boss"/> room spawns ONE beefed boss instead (health × <see cref="Tuning.BossHealthMultiplier"/>,
/// scale × <see cref="Tuning.BossScaleMultiplier"/> — v1's boss is a scaled Charger). Every spawn keeps the full
/// stack — EnemyTag + RegionTag{Expedition} + <see cref="ZoneEnemyTag"/> — PLUS <see cref="RoomTag"/>{room} (the
/// teardown contract). Scale preserved via <c>baked.WithPosition</c>.
///
/// The room CLEAR edge surfaces ONLY through the replicated <see cref="ExpeditionObjective"/>.State == Cleared
/// (wave fully spawned AND zero alive, latched per seeded epoch) — written FIRST, ABOVE every early-return
/// (snapshot-above-early-return) so the HUD never freezes; RunDirectorSystem consumes it one-tick-late (Step 7).
/// The old CycleRuntime.ClearedThisEpoch write is gone (the C4 collapse), and the old base-siege Calm gate is
/// deliberately DROPPED — a home retaliation siege no longer freezes a live sortie (the DR-042 latent gap).
///
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> ONLY — reads the freshly-advanced room state same-tick.
/// NO CyclePhase edge may ever return to the room chain (Play-only sort-cycle, invisible to EditMode).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(RunDirectorSystem))]
public partial struct RoomEnemyDirectorSystem : ISystem
{
EntityQuery m_ZoneEnemies;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
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;
var runEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(runEntity);
var run = SystemAPI.GetComponent<RunRuntime>(runEntity);
bool roomActive = info.Lifecycle == RunLifecycle.InRoom;
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
// REPLICATED objective summary FIRST, above every early-return (snapshot-above-early-return): the HUD
// readout must never freeze stale. Cleared latches only for a wave seeded FOR THIS RoomEpoch.
if (SystemAPI.HasComponent<ExpeditionObjective>(runEntity))
{
byte objState;
short objRemaining;
if (roomActive && (aliveZone > 0 || zs.RemainingToSpawn > 0))
{
objState = ExpeditionObjectiveState.Active;
objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue);
}
else if (roomActive && zs.SeededEpoch == run.RoomEpoch && zs.RemainingToSpawn == 0 && aliveZone == 0)
{
objState = ExpeditionObjectiveState.Cleared; // fully spawned + fully dead -> advance-ready
objRemaining = 0;
}
else
{
objState = ExpeditionObjectiveState.Idle;
objRemaining = 0;
}
SystemAPI.SetComponent(runEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining });
}
if (!roomActive)
return;
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
if (prefabs.Length == 0)
return;
// Single plan authority: the node RunDirector published — never re-derived here.
var map = RunMapMath.Generate(run.RunSeed);
var node = map.NodeAt(run.CurrentNodeId);
var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount);
byte room = (byte)(info.CurrentRoom & 0xFF);
bool bossRoom = plan.RoomType == RoomTypeId.Boss;
var bands = new MixBands
{
GruntBase = dir.GruntsPerWave,
ChargerBase = dir.ChargersPerWave,
SpitterBase = dir.SpitterBase,
SwarmerSlotBase = dir.SwarmerSlotBase,
ChargerPerEpoch = dir.ChargerPerEpoch,
SpitterPerEpoch = dir.SpitterPerEpoch,
SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch,
SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch,
};
// (Re)seed once per ROOM (its OWN counter, in SLOTS; a swarmer slot is one pack; a boss room is 1 slot).
if (zs.SeededEpoch != run.RoomEpoch)
{
zs.SeededEpoch = run.RoomEpoch;
zs.SpawnCounter = 0;
zs.RemainingToSpawn = bossRoom ? 1 : ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, bands);
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
}
if (zs.RemainingToSpawn > 0)
{
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
if (dueNow)
{
int slot = (int)zs.SpawnCounter;
byte kind = bossRoom ? ZoneEnemyMath.KindCharger
: ZoneEnemyMath.KindForSlot(plan.DifficultyEpoch, slot, bands);
int packSize = !bossRoom && kind == ZoneEnemyMath.KindSwarmer
? ZoneEnemyMath.PackSizeForSlot(plan.DifficultyEpoch, slot, bands, dir.SwarmerPackSize) : 1;
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — keep the slot).
if (aliveZone + packSize <= 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.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
float3 center = bossRoom
? origin // the boss anchors the room center
: EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
center.y = origin.y;
int prefabIdx = kind;
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
var prefab = prefabs[prefabIdx].Prefab;
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int k = 0; k < packSize; k++)
{
float3 pos = packSize > 1
? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center;
pos.y = origin.y;
var enemy = ecb.Instantiate(prefab);
var xform = baked.WithPosition(pos); // preserve the baked [GhostField] Scale
if (bossRoom)
xform.Scale = baked.Scale * Tuning.BossScaleMultiplier;
ecb.SetComponent(enemy, xform);
ecb.AddComponent(enemy, new RegionTag { Region = RegionId.Expedition });
ecb.AddComponent<ZoneEnemyTag>(enemy);
ecb.AddComponent(enemy, new RoomTag { Room = room });
if (bossRoom && SystemAPI.HasComponent<Health>(prefab))
{
var hp = SystemAPI.GetComponent<Health>(prefab);
hp.Current *= Tuning.BossHealthMultiplier;
hp.Max *= Tuning.BossHealthMultiplier;
ecb.SetComponent(enemy, hp);
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
zs.SpawnCounter += 1; // ONE slot consumed even for a pack
zs.RemainingToSpawn -= 1;
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
}
}
}
SystemAPI.SetComponent(directorEntity, zs);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3204b510b450f384a93bd49902c65721
@@ -1,189 +0,0 @@
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 -&gt; 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.
///
/// DR-042 C7b: it ALSO writes the replicated <see cref="ExpeditionObjective"/> summary every tick, ABOVE the
/// presence early-return (snapshot-above-early-return), so the client HUD's "enemies remaining / cleared" readout
/// never freezes stale even when nobody is out.
///
/// 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-&gt;Field-&gt;Zone-&gt;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: the SPAWNER only runs while someone is OUT in the expedition (mirrors
// ExpeditionFieldSystem). The objective readout below is written FIRST, every tick, even when nobody's out.
int expeditionPlayers = 0;
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
if (region.ValueRO.Region == RegionId.Expedition)
expeditionPlayers++;
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
int epoch = runtime.ExpeditionEpoch;
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
// DR-042 C7b: write the REPLICATED objective summary FIRST, above the early-returns (snapshot-above-
// early-return) so the HUD never freezes stale. Rides the untagged CycleDirector ghost (cross-region safe).
if (SystemAPI.HasComponent<ExpeditionObjective>(cycleEntity))
{
byte objState;
short objRemaining;
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
{
objState = ExpeditionObjectiveState.Cleared; // cleared but not yet claimed -> "return to claim"
objRemaining = 0;
}
else if (expeditionPlayers > 0 && (aliveZone > 0 || zs.RemainingToSpawn > 0))
{
objState = ExpeditionObjectiveState.Active;
objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue);
}
else
{
objState = ExpeditionObjectiveState.Idle;
objRemaining = 0;
}
SystemAPI.SetComponent(cycleEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining });
}
if (expeditionPlayers == 0)
return; // nobody out there: the field manager owns teardown, the spawner does nothing
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
if (prefabs.Length == 0)
return;
// MC-2: build the 4-type weighted mix band from the director's baked weights (shared math with the base
// siege). GruntsPerWave/ChargersPerWave are the Grunt/Charger base counts.
var bands = new MixBands
{
GruntBase = dir.GruntsPerWave,
ChargerBase = dir.ChargersPerWave,
SpitterBase = dir.SpitterBase,
SwarmerSlotBase = dir.SwarmerSlotBase,
ChargerPerEpoch = dir.ChargerPerEpoch,
SpitterPerEpoch = dir.SpitterPerEpoch,
SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch,
SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch,
};
// (Re)seed this epoch's wave once — its OWN counter (in SLOTS; a swarmer slot is one pack).
if (zs.SeededEpoch != epoch)
{
zs.SeededEpoch = epoch;
zs.SpawnCounter = 0;
zs.RemainingToSpawn = ZoneEnemyMath.WaveSlots(epoch, bands);
zs.NextSpawnTick = TickUtil.NonZero(now); // first slot this tick
}
if (zs.RemainingToSpawn > 0)
{
// Spawn only in Calm (a base Siege pauses the expedition wave), one SLOT per cadence, under the cap.
bool calm = cycle.Phase == CyclePhase.Calm;
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
if (calm && dueNow)
{
int slot = (int)zs.SpawnCounter;
byte kind = ZoneEnemyMath.KindForSlot(epoch, slot, bands);
int packSize = kind == ZoneEnemyMath.KindSwarmer
? ZoneEnemyMath.PackSizeForSlot(epoch, slot, bands, dir.SwarmerPackSize) : 1;
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — don't consume the slot).
if (aliveZone + packSize <= 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);
float3 center = EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
center.y = origin.y;
int prefabIdx = kind;
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
var prefab = prefabs[prefabIdx].Prefab;
// Preserve the prefab's baked Scale ([GhostField]) — FromPosition would reset Scale->1.
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int k = 0; k < packSize; k++)
{
float3 pos = packSize > 1
? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center;
pos.y = origin.y;
var enemy = ecb.Instantiate(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; // ONE slot consumed even for a pack
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. 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);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4dc121e99d0e53640b5e6815640a0bc6
@@ -20,6 +20,8 @@ namespace ProjectM.Server
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct GoInGameServerSystem : ISystem
{
bool _warnedMetaBlocked; // one-shot: a mis-authored subscene must not silently block spawns forever
[BurstCompile]
public void OnCreate(ref SystemState state)
{
@@ -33,6 +35,22 @@ namespace ProjectM.Server
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Step 12a availability guard (review N2/L1-6): the born-correct meta seeding below needs the baked
// catalog + the live director's tier record. Both are per-tick-uniform, so guard ONCE at the TOP,
// BEFORE the ECB exists — a per-request continue would already have marked the connection in-game.
// Nothing is consumed; RequireForUpdate re-passes and the request retries next tick (a ≤1-tick window
// in practice — the director spawns at subscene-stream, before any GoInGame round-trip).
if (!SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCatalog) || !metaCatalog.Value.IsCreated
|| !SystemAPI.TryGetSingletonBuffer<MetaTierState>(out var metaRecord, true))
{
if (!_warnedMetaBlocked)
{
UnityEngine.Debug.LogWarning("GoInGameServerSystem: player spawn waiting on the meta catalog/director (a mis-authored subscene would block spawns forever).");
_warnedMetaBlocked = true;
}
return;
}
var spawner = SystemAPI.GetSingleton<PlayerSpawner>();
// M5 home base: re-root the spawn ring on the baked BaseAnchor when present; fall back
@@ -61,6 +79,34 @@ namespace ProjectM.Server
byte classId = ClassTraits.Normalize(goReq.ValueRO.ClassId);
ecb.SetComponent(player, new AbilityRef { Id = ClassTraits.AbilityFor(classId) });
ClassTraits.AppendSeeds(classId, player, ecb);
// Expedition redesign: the server-only class anchor the meta systems key on (born-correct meta
// seeding at Step 12a + per-class spend at Step 13 resolve the tier record through this).
ecb.AddComponent(player, new PlayerClass { ClassId = classId });
// Step 12a: born-correct PERMANENT meta seeding — replay this class's persisted tiers as
// meta-band StatModifiers on the just-instantiated player (same ECB as Instantiate, the
// ClassTraits idiom). Skip tier 0 / unknown ids (preserve-don't-crash); CLAMP a saved tier above a
// rebalanced MaxTier (D-F5). Class gate via BoonMath.MaskFor (ClassId is the normalized
// CharacterId 2/3 — a raw 1<<ClassId would compute bits 2/3 and silently skip everything).
{
ref var metaPool = ref metaCatalog.Value.Value;
byte classBit = BoonMath.MaskFor(classId);
for (int m = 0; m < metaRecord.Length; m++)
{
if (metaRecord[m].ClassId != classId || metaRecord[m].Tier == 0) continue;
int defIdx = MetaMath.FindDef(ref metaPool, metaRecord[m].UpgradeId);
if (defIdx < 0) continue; // unknown id (catalog drift) — preserved on disk, skipped live
if ((metaPool.Defs[defIdx].ClassMask & classBit) == 0) continue;
byte tier = metaRecord[m].Tier < metaPool.Defs[defIdx].MaxTier
? metaRecord[m].Tier : metaPool.Defs[defIdx].MaxTier;
ecb.AppendToBuffer(player, new StatModifier
{
Target = metaPool.Defs[defIdx].Target,
Op = metaPool.Defs[defIdx].Op,
Value = metaPool.Defs[defIdx].ValuePerTier * tier,
SourceId = Tuning.MetaSourceIdBase + metaRecord[m].UpgradeId,
});
}
}
// Auto-despawn the player when its owning connection is removed.
ecb.AppendToBuffer(connection, new LinkedEntityGroup { Value = player });
@@ -187,6 +187,39 @@ namespace ProjectM.Server
ClassTraits.Reapply(newClass, classMods);
SystemAPI.SetComponent(sender, new AbilityRef { Id = ClassTraits.AbilityFor(newClass) });
// Expedition redesign (dev fork, operator-approved): keep the PERMANENT meta channel in
// sync with the swap. Reapply only strips the CLASS-seed band, so the OLD class's meta
// rows would survive — strip the meta band, replay the NEW class's persisted tiers (the
// GoInGame skip/clamp rules), and repoint the server-only PlayerClass anchor so a later
// MetaSpendRequest buys against the right class record. Runs BEFORE the heal below so the
// refill folds the new class's meta MaxHealth too.
TimedModifierUtil.RemoveBySourceIdRange(classMods, Tuning.MetaSourceIdBase,
Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan);
if (SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat) && metaCat.Value.IsCreated
&& SystemAPI.TryGetSingletonBuffer<MetaTierState>(out var metaRecord, true))
{
ref var metaPool = ref metaCat.Value.Value;
byte metaBit = BoonMath.MaskFor(newClass);
for (int mi = 0; mi < metaRecord.Length; mi++)
{
if (metaRecord[mi].ClassId != newClass || metaRecord[mi].Tier == 0) continue;
int defIdx = MetaMath.FindDef(ref metaPool, metaRecord[mi].UpgradeId);
if (defIdx < 0) continue;
if ((metaPool.Defs[defIdx].ClassMask & metaBit) == 0) continue;
byte metaTier = metaRecord[mi].Tier < metaPool.Defs[defIdx].MaxTier
? metaRecord[mi].Tier : metaPool.Defs[defIdx].MaxTier;
classMods.Add(new StatModifier
{
Target = metaPool.Defs[defIdx].Target,
Op = metaPool.Defs[defIdx].Op,
Value = metaPool.Defs[defIdx].ValuePerTier * metaTier,
SourceId = Tuning.MetaSourceIdBase + metaRecord[mi].UpgradeId,
});
}
}
if (SystemAPI.HasComponent<PlayerClass>(sender))
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = newClass });
// Let the swapped Fire ability fire immediately (both abilities share one cooldown gate).
if (SystemAPI.HasComponent<AbilityCooldown>(sender))
SystemAPI.SetComponent(sender, new AbilityCooldown { NextFireTick = 0 }); // 0 = ready
@@ -1,101 +0,0 @@
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 home-base mining-field manager. Keeps the live RegionTag{Base} ResourceNode count topped up to
/// <see cref="BaseFieldSpawner.TargetCount"/> so the gather -> build -> survive loop lives AT the base (no
/// expedition trip). Unlike <see cref="ExpeditionFieldSystem"/> (edge-triggered on player presence) this is a
/// TICK-CADENCED top-up: every <see cref="BaseFieldSpawner.RespawnIntervalTicks"/> it counts live base nodes
/// and instantiates (TargetCount - liveCount) more, scattered UNIFORMLY-IN-RADIUS (rad = inner + r*(outer-inner),
/// NOT the area-weighted sqrt that piles nodes on the outer wall) in the [Inner,Outer] annulus around
/// BaseGridMath.PlotCenter, each overridden via SetComponent (NOT Add — the prefab already bakes
/// RegionTag{Expedition}) to RegionTag{Base} + ResourceId.Ore. The FIRST pass fires immediately
/// (NextSpawnTick seeded 0) so the field seeds without waiting. Deterministic: the scatter RNG is seeded from
/// a monotonic Epoch (never the tick); the cadence gate is wrap-safe NetworkTick math (TickUtil.NonZero +
/// IsNewerThan), never raw uint. Runtime-spawned ghosts dodge the prespawn handshake. Plain server
/// SimulationSystemGroup; server-only, never predicted.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct BaseFieldSpawnSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<BaseFieldSpawner>();
state.RequireForUpdate<BaseAnchor>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var spawnerEntity = SystemAPI.GetSingletonEntity<BaseFieldSpawner>();
var spawner = SystemAPI.GetComponent<BaseFieldSpawner>(spawnerEntity);
var runtime = SystemAPI.GetComponent<BaseFieldRuntime>(spawnerEntity);
if (spawner.Prefab == Entity.Null)
return;
// Cadence gate: first pass (NextSpawnTick == 0) fires immediately; thereafter every RespawnIntervalTicks.
if (runtime.NextSpawnTick != 0u && new NetworkTick(runtime.NextSpawnTick).IsNewerThan(serverTick))
return;
// Count LIVE base-region nodes only (expedition nodes share the ResourceNode type; exclude by region).
int liveBase = 0;
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<ResourceNode>())
if (region.ValueRO.Region == RegionId.Base)
liveBase++;
int deficit = spawner.TargetCount - liveBase;
if (deficit > 0)
{
var anchor = SystemAPI.GetSingleton<BaseAnchor>();
float3 center = BaseGridMath.PlotCenter(anchor);
var baseXform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
var prefabNode = SystemAPI.GetComponent<ResourceNode>(spawner.Prefab);
runtime.Epoch += 1;
var rng = new Random(((uint)runtime.Epoch * 747796405u) | 1u); // epoch-seeded (never the tick), nonzero
float inner = math.max(0f, spawner.InnerRadius);
float outer = math.max(inner + 0.01f, spawner.OuterRadius);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int i = 0; i < deficit; i++)
{
var node = ecb.Instantiate(spawner.Prefab);
float ang = rng.NextFloat(0f, math.PI * 2f);
float rad = inner + rng.NextFloat(0f, 1f) * (outer - inner); // UNIFORM in radius
var xform = baseXform;
xform.Position = center + new float3(math.cos(ang) * rad, 0f, math.sin(ang) * rad);
ecb.SetComponent(node, xform);
// Override the baked RegionTag{Expedition} -> Base (else RegionRelevancy hides it from base
// players) and force the resource to Ore (the build currency; base field stays Ore-only).
ecb.SetComponent(node, new RegionTag { Region = RegionId.Base });
var rn = prefabNode;
rn.ResourceId = ResourceId.Ore;
ecb.SetComponent(node, rn);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
runtime.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, spawner.RespawnIntervalTicks));
SystemAPI.SetComponent(spawnerEntity, runtime);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 346e9c0fb92e7b94fa3761222fc2ff1e
@@ -1,145 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-only procedural expedition-field manager. Re-keyed off PER-PLAYER PRESENCE (no global phase): it
/// counts players whose server-only <see cref="RegionTag"/> is the Expedition region, and on the
/// empty-&gt;occupied edge (a new sortie) bumps <see cref="CycleRuntime.ExpeditionEpoch"/> and scatters
/// <see cref="ResourceFieldSpawner.Count"/> resource-node ghosts (seeded by the epoch) around the expedition
/// origin — PLUS, if a <see cref="ClutterFieldSpawner"/> singleton is present,
/// <see cref="ClutterFieldSpawner.Count"/> Blight-clutter ghosts (seeded DISTINCTLY so clutter and nodes don't
/// co-locate, Variant round-robined), each RegionTag{Expedition}; on the occupied-&gt;empty edge (the LAST
/// player left) it destroys every node AND every clutter piece. So the field lives as long as anyone is out
/// there, not on a global timer. Plain server SimulationSystemGroup. Server-authoritative; clients despawn
/// ghosts via GhostDespawnSystem. Per-epoch reproducible (the seed is the monotonic int epoch, compared by
/// equality — never tick math, never 0).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(CyclePhaseSystem))]
public partial struct ExpeditionFieldSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ResourceFieldSpawner>();
state.RequireForUpdate<CycleState>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
var spawner = SystemAPI.GetSingleton<ResourceFieldSpawner>();
// Per-player presence: is anyone currently out in the expedition region?
int expeditionPlayers = 0;
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<PlayerTag>())
if (region.ValueRO.Region == RegionId.Expedition)
expeditionPlayers++;
bool occupied = expeditionPlayers > 0;
bool wasOccupied = runtime.PrevExpeditionOccupied != 0;
// 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))
baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.RegionOrigin(RegionId.Expedition, baseCenter);
var ecb = new EntityCommandBuffer(Allocator.Temp);
// SPAWN: a player is out and this epoch has not been seeded yet.
if (occupied
&& runtime.LastSpawnedEpoch != runtime.ExpeditionEpoch
&& spawner.Prefab != Entity.Null)
{
var baseXform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
var prefabNode = SystemAPI.GetComponent<ResourceNode>(spawner.Prefab);
var rng = new Random((uint)math.max(1, runtime.ExpeditionEpoch));
int count = math.max(1, spawner.Count);
for (int i = 0; i < count; i++)
{
var node = ecb.Instantiate(spawner.Prefab);
float ang = rng.NextFloat(0f, math.PI * 2f);
float rad = spawner.Radius * math.sqrt(rng.NextFloat(0f, 1f));
var xform = baseXform;
xform.Position = origin + new float3(math.cos(ang) * rad, 0f, math.sin(ang) * rad);
ecb.SetComponent(node, xform);
// Round-robin the resource type (Aether / Ore / Biomass) over the prefab's baked node.
var rn = prefabNode;
rn.ResourceId = (byte)(ResourceId.Aether + (byte)(i % 3));
ecb.SetComponent(node, rn);
}
// Blight clutter (OPTIONAL singleton): scatter alongside the nodes with a DISTINCT seed so the
// two fields don't co-locate. Round-robin Variant for client visual variety.
if (SystemAPI.TryGetSingleton<ClutterFieldSpawner>(out var clutterSpawner)
&& clutterSpawner.Prefab != Entity.Null)
{
var clutterXform = SystemAPI.GetComponent<LocalTransform>(clutterSpawner.Prefab);
var prefabClutter = SystemAPI.GetComponent<BlightClutter>(clutterSpawner.Prefab);
var crng = new Random((uint)math.max(1, runtime.ExpeditionEpoch * 2 + 1));
int ccount = math.max(1, clutterSpawner.Count);
for (int i = 0; i < ccount; i++)
{
var piece = ecb.Instantiate(clutterSpawner.Prefab);
float ang = crng.NextFloat(0f, math.PI * 2f);
float rad = clutterSpawner.Radius * math.sqrt(crng.NextFloat(0f, 1f));
var xform = clutterXform;
xform.Position = origin + new float3(math.cos(ang) * rad, 0f, math.sin(ang) * rad);
ecb.SetComponent(piece, xform);
var bc = prefabClutter;
bc.Variant = (byte)(i % 3);
ecb.SetComponent(piece, bc);
}
}
runtime.LastSpawnedEpoch = runtime.ExpeditionEpoch;
}
// 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.
foreach (var (rn, region, e) in
SystemAPI.Query<RefRO<ResourceNode>, RefRO<RegionTag>>().WithEntityAccess())
if (region.ValueRO.Region == RegionId.Expedition)
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);
SystemAPI.SetComponent(cycleEntity, runtime);
ecb.Playback(state.EntityManager);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 9267d7809e68ea54caa55378f33e67f6
@@ -0,0 +1,146 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-only per-ROOM field seeder — the Step-5 successor of the presence-keyed <c>ExpeditionFieldSystem</c>.
/// When the run FSM has a room active (<see cref="RunInfo.Lifecycle"/> == InRoom) and
/// <see cref="RunRuntime.RoomEpoch"/> has advanced past the epoch this system last seeded (int equality, never
/// tick math), it resolves the active room's <see cref="RoomPlan"/> from the map node RunDirectorSystem published
/// (<see cref="RunRuntime.CurrentNodeId"/> — the single plan authority; NEVER re-derived here) and scatters
/// <c>plan.NodeCount</c> resource nodes — FLOORED by the run-wide scarcity budget
/// <see cref="RunRuntime.NodeBudgetRemaining"/>, which this system spends down (documented co-write: RunDirector
/// STAGES the budget at launch; this system only decrements it) — plus a light Blight-clutter dressing, all
/// inside the room's shape at <see cref="RegionMath.ExpeditionRoomOrigin"/>(base, ActiveSubSlot). Every spawn is
/// stamped <see cref="RoomTag"/>{room} (the teardown contract) on top of the prefab-baked RegionTag{Expedition};
/// Scale is preserved via <c>baked.WithPosition</c> (never FromPosition).
///
/// Teardown: room-advance/return teardown belongs to RunDirectorSystem (RoomTeardown, Step 7). This system keeps
/// ONE defensive sweep — Staging with any <see cref="RoomTag"/> alive → destroy them all (idempotent; covers
/// abort/disconnect edges). Untagged ghosts (base field, structures) are structurally untouchable.
///
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> so it reads the freshly-advanced room state same-tick.
/// The old inherited <c>[UpdateAfter(CyclePhaseSystem)]</c> is deliberately DROPPED and NO CyclePhase edge may
/// ever return to the room chain (the Play-only sort-cycle rule — invisible to EditMode).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(RunDirectorSystem))]
public partial struct RoomFieldSystem : ISystem
{
/// <summary>Max clutter pieces per room — cosmetic ghosts still cost relevancy, keep the dressing light.</summary>
const int MaxClutterPerRoom = 6;
EntityQuery m_RoomTagged;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ResourceFieldSpawner>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly<RoomTag>());
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
var spawnerEntity = SystemAPI.GetSingletonEntity<ResourceFieldSpawner>();
var spawner = SystemAPI.GetComponent<ResourceFieldSpawner>(spawnerEntity);
// One-shot: attach this system's server-only bookkeeping beside the baked spawner singleton.
if (!SystemAPI.HasComponent<RoomFieldState>(spawnerEntity))
{
state.EntityManager.AddComponentData(spawnerEntity, new RoomFieldState());
return; // structural change — clean re-read next tick
}
var rf = SystemAPI.GetComponent<RoomFieldState>(spawnerEntity);
var ecb = new EntityCommandBuffer(Allocator.Temp);
if (info.Lifecycle == RunLifecycle.InRoom)
{
if (rf.LastSpawnedRoomEpoch != run.RoomEpoch && spawner.Prefab != Entity.Null)
{
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
// Single plan authority: the node RunDirector published — never re-derived from the col/path.
var map = RunMapMath.Generate(run.RunSeed);
var node = map.NodeAt(run.CurrentNodeId);
var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount);
byte room = (byte)(info.CurrentRoom & 0xFF);
// Scarcity: the run-wide budget floors this room's count and is spent down (never negative).
int count = math.min(plan.NodeCount, math.max(0, run.NodeBudgetRemaining));
if (count > 0)
{
var baked = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
var prefabNode = SystemAPI.GetComponent<ResourceNode>(spawner.Prefab);
var rng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x0DEu) | 1u);
for (int i = 0; i < count; i++)
{
var e = ecb.Instantiate(spawner.Prefab);
float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng);
ecb.SetComponent(e, baked.WithPosition(pos));
// Rarity-weighted resource type (Step 11): Ore 45% (building) / Biomass 40% (walls,
// fabricator) / AETHER 15% — the scarce permanent-meta currency, felt when it drops.
var rn = prefabNode;
int roll = rng.NextInt(0, 100);
rn.ResourceId = roll < 15 ? ResourceId.Aether : roll < 60 ? ResourceId.Ore : ResourceId.Biomass;
ecb.SetComponent(e, rn);
ecb.AddComponent(e, new RoomTag { Room = room });
}
run.NodeBudgetRemaining -= count;
SystemAPI.SetComponent(dirEntity, run); // the documented budget co-write (spend only)
}
// Clutter dressing (OPTIONAL singleton) — a DISTINCT seed so it never co-locates with nodes.
if (SystemAPI.TryGetSingleton<ClutterFieldSpawner>(out var clutter)
&& clutter.Prefab != Entity.Null)
{
var cBaked = SystemAPI.GetComponent<LocalTransform>(clutter.Prefab);
var cProto = SystemAPI.GetComponent<BlightClutter>(clutter.Prefab);
var crng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0xC17u) | 1u);
int cCount = math.min(math.max(1, clutter.Count), MaxClutterPerRoom);
for (int i = 0; i < cCount; i++)
{
var e = ecb.Instantiate(clutter.Prefab);
float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, cCount, ref crng);
ecb.SetComponent(e, cBaked.WithPosition(pos));
var bc = cProto;
bc.Variant = (byte)(i % 3);
ecb.SetComponent(e, bc);
ecb.AddComponent(e, new RoomTag { Room = room });
}
}
rf.LastSpawnedRoomEpoch = run.RoomEpoch;
SystemAPI.SetComponent(spawnerEntity, rf);
}
}
else if (info.Lifecycle == RunLifecycle.Staging && !m_RoomTagged.IsEmpty)
{
// Defensive sweep: no run active but room ghosts linger (abort/disconnect edge) — clear every room.
var ents = m_RoomTagged.ToEntityArray(Allocator.Temp);
for (int i = 0; i < ents.Length; i++)
ecb.DestroyEntity(ents[i]);
ents.Dispose();
}
ecb.Playback(state.EntityManager);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b2ba012b5e31bcc48b50dd14220c9fc5
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 032a0f4a47c8f1d459bd341f50d42054
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,147 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="MetaSpendRequest"/> — the PERMANENT meta-upgrade purchase (Aether → tier).
/// Honored ONLY in Staging (N4: the base shop is a between-runs surface; mid-run Aether belongs to the run).
/// Per request, IN-LOOP against the live director buffers (the DR-014 placement idiom — two same-tick purchases
/// on barely-enough Aether cannot both pass): resolve sender → <see cref="PlayerClass"/>, validate catalog id /
/// class mask (<see cref="BoonMath.MaskFor"/>, never raw 1&lt;&lt;ClassId) / MaxTier / prereq, price the NEXT tier
/// (<see cref="MetaMath.CostForTier"/> — tier is server-computed, never on the wire), then
/// <see cref="StorageMath.TotalOf"/> pre-check BEFORE <see cref="StorageMath.Withdraw"/> (Withdraw CLAMPS, it
/// never rejects), bump-or-append the <see cref="MetaTierState"/> row, and upsert the ABSOLUTE-value meta
/// StatModifier (R-F1: Value = ValuePerTier * newTier, keyed <c>Tuning.MetaSourceIdBase + id</c>) on every
/// pre-collected live player of that class (R-F2 — offline classmates get theirs born-correct at next spawn via
/// GoInGameServerSystem). Success raises <see cref="SaveRequest"/> so the tier is on disk before a crash.
/// Plain server group, before RunDirectorSystem (the receiver convention); requests are ALWAYS destroyed.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct MetaSpendSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<MetaSpendRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<MetaUpgradeCatalog>();
state.RequireForUpdate<ResourceLedger>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// N4 phase gate — hoisted (per-tick-uniform, like the ReadyToggle accept flag).
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
var catalog = SystemAPI.GetSingleton<MetaUpgradeCatalog>();
var director = SystemAPI.GetSingletonEntity<ResourceLedger>();
if (!catalog.Value.IsCreated || !SystemAPI.HasBuffer<MetaTierState>(director))
accept = false; // authoring hole: drop the requests below (no withdraw happened; nothing to roll back)
// Sender resolution (SourceConnection → NetworkId → GhostOwner → player, the ReadyToggle idiom).
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
// R-F2: pre-collect the live (player, class) pairs ONCE — a successful purchase upserts the modifier on
// every live member of the class, not just the buyer (shared per-class pool, operator default).
var classMembers = new NativeList<Entity>(8, Allocator.Temp);
var classIds = new NativeList<byte>(8, Allocator.Temp);
foreach (var (owner, playerClass, entity) in
SystemAPI.Query<RefRO<GhostOwner>, RefRO<PlayerClass>>()
.WithAll<PlayerTag, StatModifier>().WithEntityAccess())
{
playerByConn[owner.ValueRO.NetworkId] = entity;
classMembers.Add(entity);
classIds.Add(playerClass.ValueRO.ClassId);
}
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, requestEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<MetaSpendRequest>>().WithEntityAccess())
{
ecb.DestroyEntity(requestEntity); // ALWAYS consumed, accepted or not
if (!accept) continue;
var conn = receive.ValueRO.SourceConnection;
if (!SystemAPI.HasComponent<NetworkId>(conn)
|| !playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var buyer))
continue;
byte classId = SystemAPI.GetComponent<PlayerClass>(buyer).ClassId;
ref var pool = ref catalog.Value.Value;
int defIdx = MetaMath.FindDef(ref pool, req.ValueRO.UpgradeId);
if (defIdx < 0) continue; // unknown id — dropped (a forged/stale request, not a crash)
ref var def = ref pool.Defs[defIdx];
if ((def.ClassMask & BoonMath.MaskFor(classId)) == 0) continue;
// LIVE in-loop reads (no hoist — the previous request this tick may have bumped the tier or
// drained the ledger; hoisted copies would let both pass).
var record = SystemAPI.GetBuffer<MetaTierState>(director);
byte owned = MetaMath.TierOf(record, classId, req.ValueRO.UpgradeId);
if (owned >= def.MaxTier) continue;
if (def.PrereqId != 0xFF && MetaMath.TierOf(record, classId, def.PrereqId) < def.PrereqTier)
continue;
int cost = MetaMath.CostForTier(in def, owned);
var ledger = SystemAPI.GetBuffer<StorageEntry>(director);
if (StorageMath.TotalOf(ledger, ResourceId.Aether) < cost) continue; // pre-check: Withdraw CLAMPS
StorageMath.Withdraw(ledger, ResourceId.Aether, cost); // atomic commit (DR-014)
byte newTier = (byte)(owned + 1);
bool bumped = false;
for (int i = 0; i < record.Length; i++)
if (record[i].ClassId == classId && record[i].UpgradeId == req.ValueRO.UpgradeId)
{
record[i] = new MetaTierState { ClassId = classId, UpgradeId = req.ValueRO.UpgradeId, Tier = newTier };
bumped = true;
break;
}
if (!bumped)
record.Add(new MetaTierState { ClassId = classId, UpgradeId = req.ValueRO.UpgradeId, Tier = newTier });
// R-F1: ABSOLUTE-value upsert (Value = ValuePerTier * newTier) — never an incremental append; a
// second append would double-count in StatRecomputeSystem's sum.
uint sourceId = Tuning.MetaSourceIdBase + req.ValueRO.UpgradeId;
for (int p = 0; p < classMembers.Length; p++)
{
if (classIds[p] != classId) continue;
var mods = SystemAPI.GetBuffer<StatModifier>(classMembers[p]);
bool upserted = false;
for (int m = 0; m < mods.Length; m++)
if (mods[m].SourceId == sourceId)
{
var row = mods[m];
row.Value = def.ValuePerTier * newTier;
mods[m] = row;
upserted = true;
break;
}
if (!upserted)
mods.Add(new StatModifier
{
Target = def.Target,
Op = def.Op,
Value = def.ValuePerTier * newTier,
SourceId = sourceId,
});
}
// Persist immediately — the tier is real money (Aether); a crash must not eat it.
if (SystemAPI.HasComponent<SaveRequest>(director))
SystemAPI.SetComponent(director, new SaveRequest { Pending = 1 });
}
ecb.Playback(state.EntityManager);
playerByConn.Dispose();
classMembers.Dispose();
classIds.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9b277eb9da63a054db9f9b3e041d582b
@@ -55,6 +55,9 @@ namespace ProjectM.Server
// M7: also persist player-built structures + their production tick-state / inventory (single shared scan).
uint nowTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
SaveStructureScan.Collect(EntityManager, nowTick, out var structures, out var structureIo);
// v6: the permanent-meta slice via the ONE shared collector (drift-proof vs the quit-to-menu writer).
MetaSaveScan.Collect(EntityManager, dir, out var metaRows, out var runsCompleted, out var maxDepth);
SaveService.Save(new SaveData
{
@@ -62,6 +65,9 @@ namespace ProjectM.Server
GoalTarget = goal.Target,
CoreCurrent = core.Current,
RunOutcome = outcome.Value,
RunsCompleted = runsCompleted,
MaxDepthReached = maxDepth,
MetaUpgrades = metaRows,
Ledger = rows,
Structures = structures,
@@ -62,6 +62,15 @@ namespace ProjectM.Server
// spawn like CycleRuntime/ThreatState (never on the ghost serializer). RunOutcome is baked on the prefab.
ecb.AddComponent(director, new RunPhase { Value = RunPhaseId.Normal });
// Expedition redesign: run-FSM working state + the co-op route first-commit latch + the persisted
// meta counters — ALL added UNCONDITIONALLY at spawn (the CycleRuntime/ThreatState/RunPhase idiom;
// D-F2: a New-Game boot must have the components the bank block reads; Continue restores VALUES only,
// inside the HasData block — Step 12b). HostSalt starts a fixed non-tick seed lineage (bumped per
// launch); SaveData v6 folds persisted RunsCompleted in at restore so cross-session runs diverge.
ecb.AddComponent(director, new RunRuntime { HostSalt = 0x5EED0001u });
ecb.AddComponent(director, default(RouteCommand));
ecb.AddComponent(director, default(MetaCounters));
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
// DR-042 C6c: a NEW game seeds starting Ore below; a restored save (Continue) keeps its ledger.
bool restoredLedger = false;
@@ -98,6 +107,33 @@ namespace ProjectM.Server
// save / New Game = 0 -> InProgress). Independent of the Core -> NOT nested in the CoreIntegrity guard.
ecb.SetComponent(director, new RunOutcome { Value = pending.RunOutcome });
// v6: restore the permanent meta — counters (VALUES only; the component was added
// unconditionally above, D-F2), the tier record (SetBuffer replaces the baked-empty
// [GhostField] buffer pre-Playback — the StorageEntry idiom — rows VERBATIM incl. unknown
// ids), the born-correct RunInfo HUD mirror (the spawn-time exception to RunDirector's
// sole-writer rule, like CycleState/RunOutcome above), and the HostSalt fold (cross-session
// first-run maps diverge once you've banked clears — the promise at the RunRuntime add).
ecb.SetComponent(director, new MetaCounters
{
RunsCompleted = pending.RunsCompleted,
MaxDepthReached = pending.MaxDepthReached,
});
var metaSrc = SystemAPI.GetBuffer<PendingMetaRow>(pendingEntity);
var metaDst = ecb.SetBuffer<MetaTierState>(director);
for (int mi = 0; mi < metaSrc.Length; mi++)
metaDst.Add(new MetaTierState { ClassId = metaSrc[mi].ClassId, UpgradeId = metaSrc[mi].UpgradeId, Tier = metaSrc[mi].Tier });
if (SystemAPI.HasComponent<RunInfo>(spawner.Prefab))
{
var runInfo = SystemAPI.GetComponent<RunInfo>(spawner.Prefab); // baked Lifecycle=Staging
runInfo.RunsCompleted = pending.RunsCompleted;
runInfo.MaxDepthReached = pending.MaxDepthReached;
ecb.SetComponent(director, runInfo);
}
ecb.SetComponent(director, new RunRuntime
{
HostSalt = RunMapMath.Hash(0x5EED0001u, (uint)pending.RunsCompleted + 1u),
});
}
ecb.DestroyEntity(pendingEntity);
}
@@ -1,122 +0,0 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
namespace ProjectM.Server
{
/// <summary>
/// Server-only walk-in gate transit: a player who walks within a gate's radius (and whose region matches the
/// gate's <see cref="ExpeditionGate.FromRegion"/>) is transited to the gate's ToRegion at its ArrivalPos
/// (RegionTag flipped + LocalTransform teleported — GhostRelevancy re-scopes their ghosts, as in
/// <c>RegionTransitSystem</c>). Returning to BASE signals the ThreatDirector (a completed expedition can draw a
/// retaliation siege) by incrementing <see cref="ProjectM.Simulation.ThreatState.PendingReturns"/>. Plain server
/// SimulationSystemGroup, ordered BEFORE CyclePhaseSystem (Gate -> ThreatDirector -> RunState) so the return is
/// consumed the same tick. Arrival points are offset from the destination gate so a transited player does not
/// immediately re-trigger.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(CyclePhaseSystem))]
public partial struct ExpeditionGateSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ExpeditionGate>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
// Snapshot gates once.
var gateFrom = new NativeList<byte>(Allocator.Temp);
var gateTo = new NativeList<byte>(Allocator.Temp);
var gateRadiusSq = new NativeList<float>(Allocator.Temp);
var gatePos = new NativeList<float2>(Allocator.Temp);
var gateArrival = new NativeList<float3>(Allocator.Temp);
foreach (var (gate, xform) in SystemAPI.Query<RefRO<ExpeditionGate>, RefRO<LocalTransform>>())
{
gateFrom.Add(gate.ValueRO.FromRegion);
gateTo.Add(gate.ValueRO.ToRegion);
gateRadiusSq.Add(gate.ValueRO.Radius * gate.ValueRO.Radius);
gatePos.Add(xform.ValueRO.Position.xz);
gateArrival.Add(gate.ValueRO.ArrivalPos);
}
bool returnedToBase = false;
foreach (var (region, xform) in
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>())
{
byte r = region.ValueRO.Region;
float2 pp = xform.ValueRO.Position.xz;
for (int i = 0; i < gateFrom.Length; i++)
{
if (gateFrom[i] != r) continue;
if (math.distancesq(pp, gatePos[i]) > gateRadiusSq[i]) continue;
region.ValueRW.Region = gateTo[i];
xform.ValueRW.Position = gateArrival[i];
if (gateTo[i] == RegionId.Base)
returnedToBase = true;
break;
}
}
gateFrom.Dispose();
gateTo.Dispose();
gateRadiusSq.Dispose();
gatePos.Dispose();
gateArrival.Dispose();
// 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)
{
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 to the shared ledger AND advances the
// long-arc win meter (DR-042 — EXPEDITION CLEARS, not survived base sieges, are the win-driver:
// CyclePhaseSystem no longer credits Charge, so this is the sole PRODUCTION writer of GoalProgress.Charge).
// 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). Ore + Charge share the SAME LastRewardedEpoch
// latch so they always share fate (never one without the other). The Charge credit is guarded
// independently of the ledger so it still lands in ledger-less worlds.
if (SystemAPI.HasSingleton<CycleState>())
{
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
if (runtime.ClearedThisEpoch != 0 && runtime.LastRewardedEpoch != runtime.ExpeditionEpoch)
{
if (SystemAPI.TryGetSingleton<ZoneEnemyDirector>(out var zoneDir)
&& SystemAPI.HasSingleton<ResourceLedger>())
{
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
StorageMath.Deposit(ledger, (ushort)ResourceId.Ore, zoneDir.RewardOre);
}
if (SystemAPI.HasComponent<GoalProgress>(cycleEntity))
{
// +1 toward the goal per cleared expedition, CLAMPED to Target (single production writer).
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
goal.Charge = math.min(goal.Charge + 1, goal.Target);
SystemAPI.SetComponent(cycleEntity, goal);
}
// Checkpoint the hard-won clear (replaces the deleted survived-siege autosave in CyclePhaseSystem).
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
runtime.LastRewardedEpoch = runtime.ExpeditionEpoch;
SystemAPI.SetComponent(cycleEntity, runtime);
}
}
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 4292536f663eb5c4d92688f6c5bb0368
@@ -0,0 +1,61 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="ReadyToggleRequest"/>: resolves the sender (SourceConnection → NetworkId →
/// GhostOwner → player entity, the AbilityUpgradeSystem idiom) and SETS <see cref="PlayerReady.Value"/>.
/// Honored ONLY while the run FSM is in Staging or Launching — an un-ready during the Launching countdown is the
/// launch-abort escape hatch (RunDirectorSystem reverts to Staging); toggles arriving mid-run are dropped (the
/// Returning edge clears every flag anyway). Ordered BEFORE RunDirectorSystem so a toggle lands the same tick the
/// ready-count is derived. Plain server group (one-off RPC effects never run in the predicted loop); the request
/// entity is ALWAYS destroyed.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct ReadyToggleSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<ReadyToggleRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
state.RequireForUpdate<RunInfo>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
byte lifecycle = SystemAPI.GetSingleton<RunInfo>().Lifecycle;
bool accept = lifecycle == RunLifecycle.Staging || lifecycle == RunLifecycle.Launching;
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
foreach (var (owner, entity) in
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, PlayerReady>().WithEntityAccess())
playerByConn[owner.ValueRO.NetworkId] = entity;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, requestEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<ReadyToggleRequest>>().WithEntityAccess())
{
var conn = receive.ValueRO.SourceConnection;
if (accept
&& SystemAPI.HasComponent<NetworkId>(conn)
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
{
SystemAPI.SetComponent(player, new PlayerReady { Value = (byte)(req.ValueRO.Ready != 0 ? 1 : 0) });
}
ecb.DestroyEntity(requestEntity);
}
ecb.Playback(state.EntityManager);
playerByConn.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7392579cf8b92e64f9686b58da99f7c2
@@ -0,0 +1,96 @@
using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
namespace ProjectM.Server
{
/// <summary>
/// Server receiver for <see cref="RouteSelectRequest"/> — the co-op route choice (any-player-first-commits, the
/// operator's locked authority model). Validates each pick server-authoritatively:
/// <c>Lifecycle == RouteSelect</c> · run identity <c>(uint)ForRunEpoch == RunRuntime.RunSeed</c> (the Step-8
/// review re-mean: the replicated seed IS the run token; the server-only RunEpoch is not client-knowable) ·
/// <c>ForLayer == RunInfo.CurrentRoom</c> (the gate's un-incremented cleared layer) · <c>OptionIndex</c> within
/// the replicated <c>RouteOptionCount</c> · the SENDER's <see cref="RegionTag"/> is Expedition (N3 — a
/// base-bound joiner cannot commit the party's route) · nothing accepted yet this gate.
///
/// FIRST-COMMIT LATCH: the accepted pick is written to the server-only <see cref="RouteCommand"/> via an
/// IMMEDIATE in-place <c>SystemAPI.SetComponent</c> INSIDE the drain loop plus a local accepted flag (the DR-014
/// atomicity idiom) — two same-tick picks can never both observe an open gate; a hoisted read would re-create
/// the exact N1 race the design review killed. <see cref="RouteCommand.ForRunEpoch"/> is stamped from the TRUE
/// server-only epoch (never the client-echoed value). Requests are ALWAYS destroyed. This system writes ONLY
/// RouteCommand — RunDirectorSystem stays the sole RunInfo/RunRuntime writer and consumes the latch
/// (abort → pick → grace, in that order). Ordered before it so a pick can land the same tick it is consumed;
/// NO CyclePhase edge (the room-chain hard rule).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(RunDirectorSystem))]
public partial struct RouteSelectSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
var builder = new EntityQueryBuilder(Allocator.Temp)
.WithAll<RouteSelectRequest, ReceiveRpcCommandRequest>();
state.RequireForUpdate(state.GetEntityQuery(builder));
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
state.RequireForUpdate<RouteCommand>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
bool gateOpen = info.Lifecycle == RunLifecycle.RouteSelect;
// Sender-region lookup (N3): connection NetworkId -> the player's CURRENT region. A player entity
// without RegionTag simply never enters the map -> its pick is a clean reject, never a throw.
var regionByConn = new NativeHashMap<int, byte>(8, Allocator.Temp);
foreach (var (owner, region) in
SystemAPI.Query<RefRO<GhostOwner>, RefRO<RegionTag>>().WithAll<PlayerTag>())
regionByConn[owner.ValueRO.NetworkId] = region.ValueRO.Region;
// Local accepted flag beside the in-place write = the first-commit latch (nothing else writes
// RouteCommand mid-loop; RunDirector's gate-entry clear ran on a previous tick by construction).
bool accepted = SystemAPI.GetComponent<RouteCommand>(dirEntity).HasPick != 0;
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (receive, req, requestEntity) in
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<RouteSelectRequest>>().WithEntityAccess())
{
var conn = receive.ValueRO.SourceConnection;
bool valid = gateOpen
&& !accepted
&& (uint)req.ValueRO.ForRunEpoch == run.RunSeed
&& req.ValueRO.ForLayer == info.CurrentRoom
&& req.ValueRO.OptionIndex < info.RouteOptionCount
&& SystemAPI.HasComponent<NetworkId>(conn)
&& regionByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out byte senderRegion)
&& senderRegion == RegionId.Expedition;
if (valid)
{
// IMMEDIATE in-place commit (never an ECB-deferred write) + the local flag: first pick wins.
SystemAPI.SetComponent(dirEntity, new RouteCommand
{
HasPick = 1,
OptionIndex = req.ValueRO.OptionIndex,
ForRunEpoch = run.RunEpoch, // the TRUE server epoch — never echo the client value
ForLayer = req.ValueRO.ForLayer,
});
accepted = true;
}
ecb.DestroyEntity(requestEntity);
}
ecb.Playback(state.EntityManager);
regionByConn.Dispose();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f56898976ef03af499c200e0d6f43b0d
@@ -0,0 +1,409 @@
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>
/// SOLE writer of the replicated run-lifecycle FSM (<see cref="RunInfo"/>) and its server-only working state
/// (<see cref="RunRuntime"/>) — the expedition redesign's counterpart of CyclePhaseSystem's single-writer
/// discipline (that system stays the sole writer of the BASE Calm↔Siege posture; the two FSMs are distinct).
///
/// Step-7 = the REAL LINEAR traversal: Staging (ready-check) → Launching (3-2-1 telegraph, un-ready aborts) →
/// InRoom (fight; the clear edge arrives as the replicated <see cref="ExpeditionObjective"/>.State == Cleared,
/// consumed ONE-TICK-LATE by construction — RoomEnemyDirectorSystem writes it after this system each tick, so no
/// system-ordering back-edge exists) → RoomReward (cleared room TORN DOWN at entry via <see cref="RoomTeardown"/>;
/// boon picks gate the exit from Step 10, all-Pending==0 today) → advance (bump room/epoch, flip the ping-pong
/// sub-slot, teleport — teardown-at-entry + spawn-on-advance guarantees ≥1 empty tick and exactly ONE room alive)
/// → … → Boss clear → Returning (teleport home + the CLEAR-GATED terminal bank) → Staging. Branching route
/// choice (RouteSelect) replaces the fixed col-0 advance at Step 8.
///
/// The terminal bank (once per RunEpoch, equality-latched): ALWAYS records the honest depth
/// (max(MaxDepthReached, RoomsClearedThisRun) — never the planned RoomCount) and re-stages; ONLY a genuine
/// boss-clear terminal (<see cref="RunRuntime.LastTerminalCleared"/>) credits the win meter
/// (<see cref="GoalProgress"/>.Charge, clamped), RunsCompleted, the retaliation inputs
/// (<see cref="ThreatState"/>.PendingReturns/ExpeditionsCompleted — carried from the retired gate, C7) and
/// requests a save. An abort/wipe banks NOTHING but the depth high-water (D-F3).
///
/// Ordering: <c>[UpdateBefore(CyclePhaseSystem)]</c> ONLY (GoalReachedSystem is [UpdateAfter(CyclePhaseSystem)] —
/// transitively after this system, so the Charge credit lands before it reads the edge). Per the hard rule,
/// NOTHING in the room chain adds another CyclePhase edge (a sort cycle is invisible to EditMode and throws only
/// at Play world creation).
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateBefore(typeof(CyclePhaseSystem))]
public partial struct RunDirectorSystem : ISystem
{
/// <summary>"All ready → 3-2-1 → go" telegraph (~3 s @ 60). An un-ready during the countdown aborts.</summary>
const uint LaunchCountdownTicks = 180;
/// <summary>Boon-pick grace (~30 s @ 60): RoomReward advances when every survivor picked OR this elapses
/// (the AFK/disconnect backstop; the un-picked-offer policy lands with the boons at Step 10).</summary>
const uint RewardGraceTicks = 1800;
/// <summary>Route-choice grace (~30 s @ 60): the gate auto-picks the LOWEST-INDEX reachable option when it
/// elapses (the AFK backstop; an accepted pick always beats a same-tick expiry — review F2).</summary>
const uint RouteGraceTicks = 1800;
EntityQuery m_RoomTagged;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<RunInfo>();
state.RequireForUpdate<RunRuntime>();
m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly<RoomTag>());
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
float3 baseCenter = new float3(0f, 1f, 0f);
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
baseCenter = BaseGridMath.PlotCenter(anchor);
// Ready-check + party-presence derivation, shared across the states below. The party is co-located at
// base while Staging (the N7 co-location invariant), so live PlayerTag ghosts ARE the roster; a
// disconnect drops the counts (LinkedEntityGroup despawn) and the checks re-derive clean.
int totalPlayers = 0, readyPlayers = 0, expeditionPlayers = 0;
foreach (var (ready, region) in
SystemAPI.Query<RefRO<PlayerReady>, RefRO<RegionTag>>().WithAll<PlayerTag>())
{
totalPlayers++;
if (ready.ValueRO.Value != 0) readyPlayers++;
if (region.ValueRO.Region == RegionId.Expedition) expeditionPlayers++;
}
bool allReady = totalPlayers > 0 && readyPlayers == totalPlayers;
switch (info.Lifecycle)
{
case RunLifecycle.Staging:
{
// F2 cross-FSM launch guard: no new run while a final siege arms/runs or the outcome latched.
// Guards default OPEN when the server-only markers are absent (EditMode worlds).
bool launchAllowed =
(!SystemAPI.HasComponent<RunPhase>(dirEntity)
|| SystemAPI.GetComponent<RunPhase>(dirEntity).Value == RunPhaseId.Normal)
&& (!SystemAPI.HasComponent<RunOutcome>(dirEntity)
|| SystemAPI.GetComponent<RunOutcome>(dirEntity).Value == RunOutcomeId.InProgress);
if (allReady && run.WasAllReady == 0 && launchAllowed)
{
// Rising edge → Launching. Seed the run: monotonic epoch + per-playthrough salt lineage,
// never a tick, never 0, equality-compared downstream.
run.RunEpoch += 1;
run.HostSalt = RunMapMath.Hash(run.HostSalt, (uint)run.RunEpoch);
run.RunSeed = math.max(1u, RunMapMath.Hash((uint)run.RunEpoch, run.HostSalt));
run.NodeBudgetRemaining = Tuning.ExpeditionNodeBudget;
run.RoomsClearedThisRun = 0;
run.BoonPickCounter = 0; // fresh boon-band provenance per run
run.LastTerminalCleared = 0;
var map = RunMapMath.Generate(run.RunSeed);
info.RunSeed = run.RunSeed;
info.RoomCount = map.LayerCount;
info.LaunchTick = TickUtil.NonZero(now + LaunchCountdownTicks);
info.Lifecycle = RunLifecycle.Launching;
}
run.WasAllReady = (byte)(allReady ? 1 : 0);
break;
}
case RunLifecycle.Launching:
{
// Un-ready during the countdown aborts back to Staging (the telegraph's escape hatch).
if (!allReady)
{
info.LaunchTick = 0u;
info.Lifecycle = RunLifecycle.Staging;
run.WasAllReady = 0;
break;
}
bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick);
if (due)
{
// Enter room 0 (the guaranteed Combat landing at column 0, sub-slot 0).
var map = RunMapMath.Generate(run.RunSeed);
EnterRoom(ref state, ref info, ref run, in map, layer: 0, col: 0, baseCenter, bumpEpoch: true);
info.LaunchTick = 0u;
}
break;
}
case RunLifecycle.InRoom:
{
// All expedition players gone (disconnect/death-warp edge) → clean abort, no credit.
if (expeditionPlayers == 0)
{
run.LastTerminalCleared = 0;
info.Lifecycle = RunLifecycle.Returning;
break;
}
// The room clear edge — the replicated objective RoomEnemyDirectorSystem computed LAST tick
// (one-tick-late by construction; no ordering back-edge). Teardown happens AT THIS ENTRY, the
// next room spawns on the advance tick → ≥1 empty tick, exactly one room alive.
if (SystemAPI.HasComponent<ExpeditionObjective>(dirEntity)
&& SystemAPI.GetComponent<ExpeditionObjective>(dirEntity).State == ExpeditionObjectiveState.Cleared)
{
var ecb = new EntityCommandBuffer(Allocator.Temp);
RoomTeardown.DestroyRoom(m_RoomTagged, ecb, (byte)(info.CurrentRoom & 0xFF));
ecb.Playback(state.EntityManager);
ecb.Dispose();
run.RoomsClearedThisRun += 1;
if (info.CurrentRoom >= info.RoomCount - 1)
run.LastTerminalCleared = 1; // the Boss fell — a genuine terminal clear
run.RewardGraceTick = TickUtil.NonZero(now + RewardGraceTicks);
info.Lifecycle = RunLifecycle.RoomReward;
}
break;
}
case RunLifecycle.RoomReward:
{
if (expeditionPlayers == 0 && run.LastTerminalCleared == 0)
{
info.Lifecycle = RunLifecycle.Returning;
break;
}
// Exit gate: every SURVIVING player has picked (BoonOffer.Pending==0 — inert until Step 10)
// OR the grace elapsed (wrap-safe IsNewerThan, never raw uint — F4).
bool anyPending = false;
foreach (var offer in SystemAPI.Query<RefRO<BoonOffer>>().WithAll<PlayerTag>())
if (offer.ValueRO.Pending != 0) { anyPending = true; break; }
bool graceElapsed = run.RewardGraceTick == 0u
|| !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick);
if (anyPending && !graceElapsed)
break;
run.RewardGraceTick = 0u;
if (run.LastTerminalCleared != 0)
{
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
}
else
{
// Open the ROUTE GATE (Step 8 — the branching choice): publish the AUTHORITATIVE reachable
// options (the client map panel is regen-for-display; the clickable buttons bind to these
// bytes). The cleared room is already gone — RouteSelect IS the teardown gap; the next room
// materializes only when the choice commits.
var map = RunMapMath.Generate(run.RunSeed);
int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol,
out var cols);
if (optionCount == 0)
{
// Unreachable by construction (every non-terminal node has an out-edge) — a future
// generator regression must abort CLEANLY, never wedge on stale options (review F4).
info.RouteOptionCount = 0;
info.Lifecycle = RunLifecycle.Returning;
}
else
{
int nextLayer = info.CurrentRoom + 1;
info.RouteOptionCount = (byte)math.min(optionCount, 3);
info.RouteOpt0Col = cols.Length > 0 ? cols[0] : (byte)0;
info.RouteOpt1Col = cols.Length > 1 ? cols[1] : (byte)0;
info.RouteOpt2Col = cols.Length > 2 ? cols[2] : (byte)0;
info.RouteOpt0Type = cols.Length > 0 ? map.Node(nextLayer, cols[0]).RoomType : (byte)0;
info.RouteOpt1Type = cols.Length > 1 ? map.Node(nextLayer, cols[1]).RoomType : (byte)0;
info.RouteOpt2Type = cols.Length > 2 ? map.Node(nextLayer, cols[2]).RoomType : (byte)0;
run.RouteGraceTick = TickUtil.NonZero(now + RouteGraceTicks);
// Entry-clear: any accepted pick provably belongs to THIS gate (RouteSelectSystem runs
// BEFORE this system, so it cannot accept on the entry tick).
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
SystemAPI.SetComponent(dirEntity, default(RouteCommand));
info.Lifecycle = RunLifecycle.RouteSelect;
}
}
break;
}
case RunLifecycle.RouteSelect:
{
// Predicate order is LOAD-BEARING (review F2): abort → pick-consume → grace. A same-tick pick
// from a vanishing party must never resurrect the run (EnterRoom would conscript base players);
// an accepted pick must beat a same-tick grace expiry (the player was told "committed").
if (expeditionPlayers == 0)
{
info.RouteOptionCount = 0; // close the gate ON the abort edge itself (review F3)
run.LastTerminalCleared = 0;
info.Lifecycle = RunLifecycle.Returning;
break;
}
var cmd = SystemAPI.HasComponent<RouteCommand>(dirEntity)
? SystemAPI.GetComponent<RouteCommand>(dirEntity)
: default;
bool routeGraceElapsed = run.RouteGraceTick == 0u
|| !new NetworkTick(run.RouteGraceTick).IsNewerThan(serverTick);
if (cmd.HasPick != 0)
{
// The party's committed choice (first-accepted-wins latch; any-player-first-commits).
byte chosenCol = cmd.OptionIndex == 2 ? info.RouteOpt2Col
: cmd.OptionIndex == 1 ? info.RouteOpt1Col : info.RouteOpt0Col;
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
SystemAPI.SetComponent(dirEntity, default(RouteCommand));
run.RouteGraceTick = 0u;
var map = RunMapMath.Generate(run.RunSeed);
EnterRoom(ref state, ref info, ref run, in map, info.CurrentRoom + 1, chosenCol, baseCenter, bumpEpoch: true);
}
else if (routeGraceElapsed)
{
// AFK backstop: deterministic LOWEST-INDEX reachable option (RouteOpt0 is ascending-first).
run.RouteGraceTick = 0u;
var map = RunMapMath.Generate(run.RunSeed);
EnterRoom(ref state, ref info, ref run, in map, info.CurrentRoom + 1, info.RouteOpt0Col, baseCenter, bumpEpoch: true);
}
break;
}
case RunLifecycle.Returning:
{
// Party teleport HOME + region flip back to Base.
int idx = 0;
foreach (var (region, xform) in
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>())
{
region.ValueRW.Region = RegionId.Base;
var p = baseCenter;
p.x += 1.5f * idx;
p.y = xform.ValueRO.Position.y;
xform.ValueRW.Position = p;
idx++;
}
// THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3).
if (run.LastBankedRunEpoch != run.RunEpoch)
{
run.LastBankedRunEpoch = run.RunEpoch;
// Always: the honest depth high-water (actual rooms cleared, never the planned count).
if (SystemAPI.HasComponent<MetaCounters>(dirEntity))
{
var meta = SystemAPI.GetComponent<MetaCounters>(dirEntity);
meta.MaxDepthReached = math.max(meta.MaxDepthReached, run.RoomsClearedThisRun);
if (run.LastTerminalCleared != 0)
meta.RunsCompleted += 1;
SystemAPI.SetComponent(dirEntity, meta);
info.RunsCompleted = meta.RunsCompleted; // HUD mirror
info.MaxDepthReached = meta.MaxDepthReached; // HUD mirror
}
// Boss-clear only: the win meter, the retaliation inputs (C7), and a save checkpoint.
if (run.LastTerminalCleared != 0)
{
if (SystemAPI.HasComponent<GoalProgress>(dirEntity))
{
var goal = SystemAPI.GetComponent<GoalProgress>(dirEntity);
goal.Charge = math.min(goal.Charge + 1, goal.Target);
SystemAPI.SetComponent(dirEntity, goal);
}
if (SystemAPI.HasComponent<ThreatState>(dirEntity))
{
var threat = SystemAPI.GetComponent<ThreatState>(dirEntity);
threat.PendingReturns += 1;
threat.ExpeditionsCompleted += 1;
SystemAPI.SetComponent(dirEntity, threat);
}
if (SystemAPI.HasComponent<SaveRequest>(dirEntity))
SystemAPI.SetComponent(dirEntity, new SaveRequest { Pending = 1 });
}
}
// TWO-CHANNEL strip (DR-037): run boons EXPIRE at home — one range-strip clears every
// boon-band StatModifier (replicates via the [GhostField] buffer; StatRecompute reverts the
// effective stats on both worlds) and zeroes any straggler offer. Class/meta/equip bands are
// disjoint and survive. Idempotent — safe on every Returning tick.
foreach (var (mods, offer) in
SystemAPI.Query<DynamicBuffer<StatModifier>, RefRW<BoonOffer>>().WithAll<PlayerTag>())
{
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase,
Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan);
offer.ValueRW = default;
}
// Clear EVERY ready flag — the next run needs a fresh, deliberate ready-check from everyone.
foreach (var ready in SystemAPI.Query<RefRW<PlayerReady>>().WithAll<PlayerTag>())
ready.ValueRW.Value = 0;
run.RewardGraceTick = 0u;
run.RouteGraceTick = 0u; // gate hygiene (review F5): no stale grace into the next run
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
SystemAPI.SetComponent(dirEntity, default(RouteCommand)); // no leftover latch either
run.WasAllReady = 0;
run.LastTerminalCleared = 0;
info.CurrentRoom = 0;
info.RouteOptionCount = 0;
info.LaunchTick = 0u;
info.Lifecycle = RunLifecycle.Staging;
break;
}
}
// Single write-back point — RunInfo/RunRuntime are ALWAYS published (F12: the HUD readout can never
// freeze stale behind a branch's early-break).
SystemAPI.SetComponent(dirEntity, info);
SystemAPI.SetComponent(dirEntity, run);
}
/// <summary>
/// Enter room (<paramref name="layer"/>, <paramref name="col"/>): publish the node as the single plan
/// authority (<see cref="RunRuntime.CurrentNodeId"/>/<c>CurrentRoomType</c> — the field/enemy directors NEVER
/// re-derive it), flip the ping-pong sub-slot, bump <see cref="RunRuntime.RoomEpoch"/> so the room systems
/// reseed, and teleport the party onto the new origin (Position write in place — never FromPosition).
/// </summary>
void EnterRoom(ref SystemState state, ref RunInfo info, ref RunRuntime run, in RunMap map,
int layer, int col, float3 baseCenter, bool bumpEpoch)
{
var node = map.Node(layer, col);
run.ActiveSubSlot = (byte)(layer & 1);
run.CurrentNodeId = RunMap.NodeId(layer, col);
run.CurrentCol = (byte)col;
run.CurrentRoomType = node.RoomType;
if (bumpEpoch)
run.RoomEpoch += 1;
info.CurrentRoom = layer;
info.CurrentCol = (byte)col;
info.CurrentRoomType = node.RoomType;
info.CurrentBiome = node.Biome;
info.RouteOptionCount = 0;
float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
int idx = 0;
foreach (var (region, xform) in
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag>())
{
region.ValueRW.Region = RegionId.Expedition;
var p = roomOrigin;
p.x += 1.5f * idx; // small spread so kinematic capsules don't stack
p.y = xform.ValueRO.Position.y;
xform.ValueRW.Position = p;
idx++;
}
info.Lifecycle = RunLifecycle.InRoom;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b7c36de338378264e9aa0ad2c2512e7f
@@ -11,8 +11,9 @@ namespace ProjectM.Server
/// Server-only composite ThreatDirector — the data-driven base-attack SCHEDULER. It owns the decision of WHEN
/// and HOW BIG a siege is; <see cref="CyclePhaseSystem"/> owns the Calm↔Siege transition. The single documented
/// hand-off is <see cref="ThreatState.PendingSiegeSize"/> (this system sets it; CyclePhaseSystem consumes it).
/// This slice wires ONE source — POST-EXPEDITION retaliation: a player returning to base (counted as
/// <see cref="ThreatState.PendingReturns"/> by <see cref="ExpeditionGateSystem"/>) arms a siege of
/// This slice wires ONE source — POST-EXPEDITION retaliation: a completed RUN (banked on the boss-clear
/// return by <see cref="RunDirectorSystem"/> into <see cref="ThreatState.PendingReturns"/> — the retired
/// walk-in ExpeditionGateSystem's carry, Step 11) arms a siege ofe of
/// <see cref="ThreatConfig.SizeBase"/> Husks after a <see cref="ThreatConfig.PostExpeditionDelayTicks"/>
/// telegraph. The Heat/Schedule sources are reserved (config baked-but-inert) so they drop in additively with
/// no re-bake. It also enforces a BOUNDED siege lifetime (<see cref="ThreatConfig.SiegeTimeoutTicks"/>): an
@@ -24,7 +25,7 @@ namespace ProjectM.Server
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(ExpeditionGateSystem))]
[UpdateAfter(typeof(RunDirectorSystem))]
[UpdateBefore(typeof(CyclePhaseSystem))]
public partial struct ThreatDirectorSystem : ISystem
{