using ProjectM.Simulation;
using Unity.Burst;
using Unity.Collections;
using Unity.Entities;
using Unity.NetCode;
using Unity.Transforms;
namespace ProjectM.Server
{
///
/// Server-only, one-shot spawner for the GLOBAL cycle-director ghost (mirrors SharedStorageSpawnSystem,
/// but MINUS the RegionTag — the director must stay global so GhostRelevancy keeps it relevant to every
/// region). On its first update it reads the baked + NetworkTime,
/// instantiates the ghost, initializes (Expedition, cycle 1, PhaseEndTick =
/// now + the initial phase delay), adds the server-only , and
/// places it at the base center (preserving the prefab's baked LocalTransform scale — FromPosition would
/// reset the replicated Scale GhostField), then destroys the spawner so it idles.
///
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial struct CycleDirectorSpawnSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate();
state.RequireForUpdate();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton().ServerTick;
if (!serverTick.IsValid)
return;
var spawnerEntity = SystemAPI.GetSingletonEntity();
var spawner = SystemAPI.GetComponent(spawnerEntity);
var ecb = new EntityCommandBuffer(Allocator.Temp);
if (spawner.Prefab != Entity.Null)
{
var director = ecb.Instantiate(spawner.Prefab);
// Place at the base center, preserving the prefab's baked scale/rotation.
var xform = SystemAPI.GetComponent(spawner.Prefab);
if (SystemAPI.TryGetSingleton(out var anchor))
xform.Position = BaseGridMath.PlotCenter(anchor);
ecb.SetComponent(director, xform);
// Boot the run-state in Calm (the persistent default) — no timer; ThreatDirector arms sieges.
ecb.SetComponent(director, new CycleState
{
Phase = CyclePhase.Calm,
CycleNumber = 1,
PhaseEndTick = 0u,
});
ecb.AddComponent(director, new CycleRuntime { DefendStartWave = 0 });
ecb.AddComponent(director, new ThreatState());
// END-2: server-only run-phase marker (Normal until the goal cap arms the final siege). Added at
// 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(PortalCommand)); // DR-046 room-exit portal interact latch
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;
// ghost never serializes a default GoalProgress / empty ledger to clients (no replication flicker).
if (SystemAPI.TryGetSingletonEntity(out var pendingEntity))
{
var pending = SystemAPI.GetComponent(pendingEntity);
if (pending.HasData != 0)
{
// END-2: clamp the restored Target to the baked run-length so a pre-v5 save carrying the old
// Target=10 still honours the slice's baked Target=4 (the final siege stays reachable).
int bakedTarget = SystemAPI.HasComponent(spawner.Prefab)
? SystemAPI.GetComponent(spawner.Prefab).Target : pending.GoalTarget;
int restoredTarget = pending.GoalTarget > 0 && pending.GoalTarget < bakedTarget
? pending.GoalTarget : bakedTarget;
ecb.SetComponent(director, new GoalProgress { Charge = pending.GoalCharge, Target = restoredTarget });
var srcLedger = SystemAPI.GetBuffer(pendingEntity);
var destLedger = ecb.SetBuffer(director);
SaveApply.WriteLedger(srcLedger, destLedger);
restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore (C6c)
// END-1: born-correct the Engine Core. Max comes from the BAKED prefab (never the save); a
// persisted wounded Current (>0) restores clamped to Max, else (0 = pre-v4 save) born full.
if (SystemAPI.HasComponent(spawner.Prefab))
{
var bakedCore = SystemAPI.GetComponent(spawner.Prefab);
int restoredCore = pending.CoreCurrent > 0
? (pending.CoreCurrent < bakedCore.Max ? pending.CoreCurrent : bakedCore.Max)
: bakedCore.Max;
ecb.SetComponent(director, new CoreIntegrity { Current = restoredCore, Max = bakedCore.Max, OverrunTick = 0u });
}
// END-2: born-correct the terminal run outcome (a won/lost run loads finished + halted; a pre-v5
// 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(pendingEntity);
var metaDst = ecb.SetBuffer(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(spawner.Prefab))
{
var runInfo = SystemAPI.GetComponent(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);
}
// DR-042 C6c: NEW game only (no restored ledger) -> seed a little Ore so the build loop isn't a cold
// deadlock (a turret needs Charge from a Fabricator that costs Ore you haven't mined yet). Appended
// BEFORE Playback so the ghost first-serializes WITH the seed (no empty-ledger replication flicker).
if (!restoredLedger)
ecb.AppendToBuffer(director, new StorageEntry { ItemId = ResourceId.Ore, Count = Tuning.StartingOre });
// Host-only autosave flag; SaveWriteSystem consumes it on the Siege->Calm checkpoint.
ecb.AddComponent(director, new SaveRequest { Pending = 0 });
}
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
ecb.DestroyEntity(spawnerEntity);
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
}
}