419debad74
Scoping/design-gated (wf_7c5a555e-136). Fixes "the base reads as inert after Phase A":
- C7b objective readout: new replicated ExpeditionObjective{[GhostField] byte State, short Remaining} on the
untagged CycleDirector ghost (cross-region safe). Sole writer ZoneEnemyDirectorSystem, written ABOVE its
early-returns (snapshot-above-early-return) so the HUD never freezes stale. Play-verified it replicates
server->client.
- C7a gate prompt + C7b HUD readout: HudSystem shows "GO TO THE EXPEDITION GATE" / "EXPEDITION IN PROGRESS - N
remaining" / "CLEARED - return to claim", below the siege/overrun overrides.
- C6a Aether upgrade button: un-gated BuildSendSystem.UpgradeAbility (was #if UNITY_EDITOR); HudSystem adds a
MenuUi.Button with live affordability tint (the only Aether sink was U-key only).
- C6c cold-start seed: CycleDirectorSpawnSystem seeds Tuning.StartingOre (50) into the ledger on a NEW game only
(born-correct, pre-Playback), killing the silent turret-before-fabricator deadlock. Play-verified seededOre=50.
- C6b Biomass sink: Wall cost Ore->Biomass (the dead currency now has a home). Play-verified WallCostRes=Biomass.
- C6d palette declutter: hide dead Pylon/Harvester/Conveyor from the build palette + trimmed their dev hotkeys
(catalog/prefabs stay baked, code-intact per DR-020).
389/389 EditMode + clean netcode Play smoke (ghost re-hash OK, no exceptions). SaveData stays v5.
C5 (walls block enemies) is the remaining Phase C item, sequenced separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
122 lines
6.8 KiB
C#
122 lines
6.8 KiB
C#
using ProjectM.Simulation;
|
|
using Unity.Burst;
|
|
using Unity.Collections;
|
|
using Unity.Entities;
|
|
using Unity.NetCode;
|
|
using Unity.Transforms;
|
|
|
|
namespace ProjectM.Server
|
|
{
|
|
/// <summary>
|
|
/// 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 <see cref="CycleDirectorSpawner"/> + NetworkTime,
|
|
/// instantiates the ghost, initializes <see cref="CycleState"/> (Expedition, cycle 1, PhaseEndTick =
|
|
/// now + <see cref="CyclePhase.ExpeditionTicks"/>), adds the server-only <see cref="CycleRuntime"/>, 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.
|
|
/// </summary>
|
|
[BurstCompile]
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
public partial struct CycleDirectorSpawnSystem : ISystem
|
|
{
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
state.RequireForUpdate<CycleDirectorSpawner>();
|
|
state.RequireForUpdate<NetworkTime>();
|
|
}
|
|
|
|
[BurstCompile]
|
|
public void OnUpdate(ref SystemState state)
|
|
{
|
|
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
|
if (!serverTick.IsValid)
|
|
return;
|
|
|
|
var spawnerEntity = SystemAPI.GetSingletonEntity<CycleDirectorSpawner>();
|
|
var spawner = SystemAPI.GetComponent<CycleDirectorSpawner>(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<LocalTransform>(spawner.Prefab);
|
|
if (SystemAPI.TryGetSingleton<BaseAnchor>(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 });
|
|
|
|
// 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<PendingSave>(out var pendingEntity))
|
|
{
|
|
var pending = SystemAPI.GetComponent<PendingSave>(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<GoalProgress>(spawner.Prefab)
|
|
? SystemAPI.GetComponent<GoalProgress>(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<PendingSaveLedgerRow>(pendingEntity);
|
|
var destLedger = ecb.SetBuffer<StorageEntry>(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<CoreIntegrity>(spawner.Prefab))
|
|
{
|
|
var bakedCore = SystemAPI.GetComponent<CoreIntegrity>(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 });
|
|
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|