LANTERN purge B3+B5: delete the cycle/core/win-lose spine + onboarding; save epoch v7
Deletes CyclePhaseSystem, GoalReachedSystem, CoreDamage/CoreRestore, ThreatDirector, CoreIntegrity/GoalProgress/RunPhase/RunOutcome/ThreatState components, CoreVisualFeedbackSystem, and the whole Client/Onboarding slice (+6 test files). Keepers reworked: RunDirectorSystem (UpdateBefore attr + launch guard + goal/threat bank removed; sole SaveRequest raiser now), CycleDirectorSpawnSystem (ledger/meta host only), WaveSystem UNGATED (waves run wherever a WaveDirector is baked), EnemyAISystem core-fallback stripped, AmbientAudioSystem reworked (bed + run cues; no CycleState gate), MusicSystem RunInfo-only, HudSystem big trim (goal meter, core bar, siege banner, terminal banner, outcome flash, onboarding hook all gone), MetaShop/ClassPrep/AimReticle siege gates dropped, DebugOverlay/ops re-meant (SpawnWave=force next wave, EndSiege=quiet arena; SetCalm/AdvanceGoal/SetHeat retired, bytes reserved), TuningConfig Core knobs retired (ids 20-23 reserved), StorageMath.DrainFraction deleted, HowToPlay copy rewritten. Save epoch v7 (fresh epoch, operator-approved): SaveData drops goal/core/outcome + conveyor/machine-IO fields; MinLoadableVersion=7; PendingSave/PendingStructure trimmed; RollTerminalCampaignForward deleted; SaveStructureScan signature slimmed. 390 tests green; Play world-creation clean (player + waves live, no exceptions). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -85,13 +85,7 @@ namespace ProjectM.Server
|
||||
decoyPositions.Add(dx.ValueRO.Position);
|
||||
}
|
||||
|
||||
// END-1: the Engine Core is a FALLBACK target. When no living player/structure remains, undefended
|
||||
// Husks march on the base heart (PlotCenter) so the base can be overrun instead of the swarm idling.
|
||||
bool coreAlive = SystemAPI.HasSingleton<BaseAnchor>()
|
||||
&& SystemAPI.TryGetSingleton<CoreIntegrity>(out var coreInteg) && coreInteg.Current > 0;
|
||||
float3 corePos = coreAlive ? BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>()) : float3.zero;
|
||||
|
||||
if (playerEntities.Length == 0 && structureEntities.Length == 0 && decoyEntities.Length == 0 && !coreAlive)
|
||||
if (playerEntities.Length == 0 && structureEntities.Length == 0 && decoyEntities.Length == 0)
|
||||
{
|
||||
playerEntities.Dispose();
|
||||
playerPositions.Dispose();
|
||||
@@ -137,7 +131,6 @@ namespace ProjectM.Server
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte huskRegion = region.ValueRO.Region;
|
||||
bool huskCoreAlive = coreAlive && huskRegion == RegionId.Base;
|
||||
|
||||
// Knockback overrides seek/strike for its window — EnemyAISystem stays the SOLE writer of Position.
|
||||
var kb = knockback.ValueRO;
|
||||
@@ -178,12 +171,10 @@ namespace ProjectM.Server
|
||||
}
|
||||
else
|
||||
{
|
||||
if (tgtIdx < 0 && !huskCoreAlive)
|
||||
continue; // no decoy, no player/structure, and no Core -> nothing to seek
|
||||
targetEntity = tgtIdx < 0 ? Entity.Null
|
||||
: (tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx]);
|
||||
targetPos = tgtIdx < 0 ? corePos
|
||||
: (tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx]);
|
||||
if (tgtIdx < 0)
|
||||
continue; // no decoy, no player/structure -> nothing to seek
|
||||
targetEntity = tgtIsStruct ? structureEntities[tgtIdx] : playerEntities[tgtIdx];
|
||||
targetPos = tgtIsStruct ? structurePositions[tgtIdx] : playerPositions[tgtIdx];
|
||||
}
|
||||
|
||||
// Seek: stop just inside strike range so the Husk holds position to attack.
|
||||
@@ -271,7 +262,6 @@ namespace ProjectM.Server
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte cHuskRegion = region.ValueRO.Region;
|
||||
bool cHuskCoreAlive = coreAlive && cHuskRegion == RegionId.Base;
|
||||
|
||||
// 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer).
|
||||
var kb = knockback.ValueRO;
|
||||
@@ -296,12 +286,10 @@ namespace ProjectM.Server
|
||||
|
||||
// EB-1 fortress aggro: same weighted target selection as the Grunt pass (shared helper).
|
||||
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, cHuskRegion, structAggro, out bool cIsStruct, out int cIdx);
|
||||
if (cIdx < 0 && !cHuskCoreAlive)
|
||||
if (cIdx < 0)
|
||||
continue;
|
||||
Entity cTargetEntity = cIdx < 0 ? Entity.Null
|
||||
: (cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx]);
|
||||
float3 cTargetPos = cIdx < 0 ? corePos
|
||||
: (cIsStruct ? structurePositions[cIdx] : playerPositions[cIdx]);
|
||||
Entity cTargetEntity = cIsStruct ? structureEntities[cIdx] : playerEntities[cIdx];
|
||||
float3 cTargetPos = cIsStruct ? structurePositions[cIdx] : playerPositions[cIdx];
|
||||
|
||||
// 2. Lunge active: travel the locked direction; damage on contact, or stagger on a wall-stop whiff.
|
||||
var lg = lunge.ValueRO;
|
||||
@@ -414,7 +402,6 @@ namespace ProjectM.Server
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte sRegion = region.ValueRO.Region;
|
||||
bool sCoreAlive = coreAlive && sRegion == RegionId.Base;
|
||||
|
||||
// 1. Knockback overrides everything (sole Position writer preserved).
|
||||
var kb = knockback.ValueRO;
|
||||
@@ -434,14 +421,12 @@ namespace ProjectM.Server
|
||||
knockback.ValueRW.UntilTick = 0;
|
||||
}
|
||||
|
||||
// 2. Target (region-scoped shared helper); Core fallback like the Grunt/Charger passes.
|
||||
// 2. Target (region-scoped shared helper); no target -> idle.
|
||||
EnemyAIMath.PickWeightedNearest(pos, playerPositions, playerRegions, structurePositions, structureRegions, sRegion, structAggro, out bool sIsStruct, out int sIdx);
|
||||
if (sIdx < 0 && !sCoreAlive)
|
||||
if (sIdx < 0)
|
||||
continue;
|
||||
Entity sTargetEntity = sIdx < 0 ? Entity.Null
|
||||
: (sIsStruct ? structureEntities[sIdx] : playerEntities[sIdx]);
|
||||
float3 sTargetPos = sIdx < 0 ? corePos
|
||||
: (sIsStruct ? structurePositions[sIdx] : playerPositions[sIdx]);
|
||||
Entity sTargetEntity = sIsStruct ? structureEntities[sIdx] : playerEntities[sIdx];
|
||||
float3 sTargetPos = sIsStruct ? structurePositions[sIdx] : playerPositions[sIdx];
|
||||
|
||||
// 3. Range-band movement: advance if too far, retreat if too close, hold in-band. Face the target.
|
||||
var sp = spitter.ValueRO;
|
||||
@@ -504,7 +489,7 @@ namespace ProjectM.Server
|
||||
float sDist = math.length(sToTarget);
|
||||
bool sInBand = math.abs(sDist - sp.PreferredRange) <= sp.RangeTolerance;
|
||||
bool sCornered = sDist <= sp.CorneredRange;
|
||||
if (sReady && (sInBand || sCornered) && (sTargetEntity != Entity.Null || sCoreAlive))
|
||||
if (sReady && (sInBand || sCornered))
|
||||
{
|
||||
uint wTicks = (uint)math.max(1, sp.WindupTicks);
|
||||
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + wTicks);
|
||||
@@ -625,9 +610,8 @@ namespace ProjectM.Server
|
||||
}
|
||||
|
||||
EnemyAIMath.PickWeightedNearest(npos, playerPositions, playerRegions, structurePositions, structureRegions, nRegion, structAggro, out bool nIsStruct, out int nIdx);
|
||||
bool nCoreAlive = coreAlive && nRegion == RegionId.Base;
|
||||
bool hasTarget = nIdx >= 0 || nCoreAlive;
|
||||
float3 nTarget = nIdx < 0 ? corePos : (nIsStruct ? structurePositions[nIdx] : playerPositions[nIdx]);
|
||||
bool hasTarget = nIdx >= 0;
|
||||
float3 nTarget = nIdx < 0 ? npos : (nIsStruct ? structurePositions[nIdx] : playerPositions[nIdx]);
|
||||
bool wantsToClose = hasTarget && !committed
|
||||
&& math.distance(npos.xz, nTarget.xz) > nstats.ValueRO.AttackRange * 1.15f;
|
||||
|
||||
|
||||
@@ -39,9 +39,6 @@ namespace ProjectM.Server
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
// Player-driven loop: the base-defense wave only spawns during a Siege.
|
||||
if (SystemAPI.TryGetSingleton<CycleState>(out var cycle) && cycle.Phase != CyclePhase.Siege)
|
||||
return;
|
||||
|
||||
var director = SystemAPI.GetSingleton<WaveDirector>();
|
||||
var directorEntity = SystemAPI.GetSingletonEntity<WaveDirector>();
|
||||
|
||||
@@ -11,12 +11,12 @@ namespace ProjectM.Server
|
||||
/// <summary>
|
||||
/// EDITOR-ONLY server receiver for <see cref="DebugCommandRequest"/> dev-tool RPCs (from the DebugOverlay or
|
||||
/// execute_code). Applies authoritative effects so the dev buttons exercise the REAL server paths and work
|
||||
/// over a live connection too: force/end sieges, grant resources/upgrades, teleport, god-mode, heal/kill,
|
||||
/// advance the goal. Sender-targeted ops resolve the player via SourceConnection -> NetworkId -> GhostOwner
|
||||
/// (the RegionTransitSystem pattern). Plain server SimulationSystemGroup (NOT the predicted loop). Reuses
|
||||
/// StorageMath / StatModifier / RegionMath + the wave/cycle singletons. The whole system is #if UNITY_EDITOR
|
||||
/// (stripped from builds); the wire TYPE (<see cref="DebugCommandRequest"/>) is unconditional so the RPC
|
||||
/// collection hash matches across peers. Non-Burst (managed-simple, editor-only) — perf is irrelevant.
|
||||
/// over a live connection too: force/stop waves, clear enemies, grant resources/upgrades, teleport, god-mode,
|
||||
/// heal/kill, class swap, gym enemy spawns. Sender-targeted ops resolve the player via SourceConnection ->
|
||||
/// NetworkId -> GhostOwner (the RegionTransitSystem pattern). Plain server SimulationSystemGroup (NOT the
|
||||
/// predicted loop). The whole system is #if UNITY_EDITOR (stripped from builds); the wire TYPE
|
||||
/// (<see cref="DebugCommandRequest"/>) is unconditional so the RPC collection hash matches across peers.
|
||||
/// Non-Burst (managed-simple, editor-only) — perf is irrelevant.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
@@ -41,7 +41,8 @@ namespace ProjectM.Server
|
||||
foreach (var (owner, e) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = e;
|
||||
|
||||
bool haveCycle = SystemAPI.TryGetSingletonEntity<CycleState>(out var cycleEntity);
|
||||
uint now = SystemAPI.TryGetSingleton<NetworkTime>(out var netTime) && netTime.ServerTick.IsValid
|
||||
? netTime.ServerTick.TickIndexForValidTick : 0u;
|
||||
|
||||
foreach (var (request, receive, reqEntity) in
|
||||
SystemAPI.Query<RefRO<DebugCommandRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
||||
@@ -54,43 +55,29 @@ namespace ProjectM.Server
|
||||
|
||||
switch (cmd.Op)
|
||||
{
|
||||
case DebugOp.SpawnWave:
|
||||
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
||||
case DebugOp.SpawnWave: // re-meant (LANTERN): force the NEXT wave to start this tick
|
||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var forceWaveE))
|
||||
{
|
||||
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
ts.PendingSiegeSize = math.max(1, cmd.ArgA);
|
||||
ts.ArmTick = 0; // fire as soon as CyclePhaseSystem sees it
|
||||
SystemAPI.SetComponent(cycleEntity, ts);
|
||||
var fw = SystemAPI.GetComponent<WaveState>(forceWaveE);
|
||||
fw.Phase = WavePhase.Lull;
|
||||
fw.NextActionTick = 0; // due immediately -> WaveSystem starts the next (bigger) wave
|
||||
SystemAPI.SetComponent(forceWaveE, fw);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.EndSiege:
|
||||
case DebugOp.SetCalm:
|
||||
case DebugOp.EndSiege: // re-meant (LANTERN): "quiet the arena" — cull husks + push the next wave far out
|
||||
CullHusks(ref ecb);
|
||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var we))
|
||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var stopWaveE))
|
||||
{
|
||||
var w = SystemAPI.GetComponent<WaveState>(we);
|
||||
var w = SystemAPI.GetComponent<WaveState>(stopWaveE);
|
||||
w.Phase = WavePhase.Lull;
|
||||
w.RemainingToSpawn = 0;
|
||||
SystemAPI.SetComponent(we, w);
|
||||
}
|
||||
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
||||
{
|
||||
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
ts.PendingSiegeSize = 0;
|
||||
ts.ArmTick = 0;
|
||||
ts.SiegeStartTick = 0;
|
||||
SystemAPI.SetComponent(cycleEntity, ts);
|
||||
}
|
||||
if (cmd.Op == DebugOp.SetCalm && haveCycle)
|
||||
{
|
||||
var cs = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
||||
cs.Phase = CyclePhase.Calm;
|
||||
cs.PhaseEndTick = 0;
|
||||
SystemAPI.SetComponent(cycleEntity, cs);
|
||||
w.NextActionTick = TickUtil.NonZero(now + 216000u); // ~1 h @ 60 Hz: waves stay quiet for the session
|
||||
SystemAPI.SetComponent(stopWaveE, w);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
case DebugOp.ClearEnemies:
|
||||
CullHusks(ref ecb);
|
||||
break;
|
||||
@@ -147,23 +134,6 @@ namespace ProjectM.Server
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.AdvanceGoal:
|
||||
if (haveCycle && SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
||||
{
|
||||
var g = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
||||
g.Charge += math.max(1, cmd.ArgA);
|
||||
SystemAPI.SetComponent(cycleEntity, g);
|
||||
}
|
||||
break;
|
||||
|
||||
case DebugOp.SetHeat:
|
||||
if (haveCycle && SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
||||
{
|
||||
var ts = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
ts.Heat = cmd.ArgA;
|
||||
SystemAPI.SetComponent(cycleEntity, ts);
|
||||
}
|
||||
break;
|
||||
case DebugOp.SetTuning:
|
||||
if (SystemAPI.TryGetSingleton<TuningConfig>(out var tuningCfg))
|
||||
{
|
||||
|
||||
@@ -7,11 +7,9 @@ namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Host-only autosave writer. A managed <see cref="SystemBase"/> (file IO => NO Burst) that reacts to the
|
||||
/// <see cref="SaveRequest"/> flag the Bursted <c>CyclePhaseSystem</c> raises on the Siege->Calm checkpoint:
|
||||
/// reads the authoritative <see cref="GoalProgress"/> + shared resource ledger off the CycleDirector ghost,
|
||||
/// writes the JSON save (<see cref="SaveService"/>), then clears the flag. ServerSimulation-only, so a pure
|
||||
/// (Join) client never writes. Deliberately carries NO <c>[UpdateAfter(CyclePhaseSystem)]</c> (that would risk
|
||||
/// a sort-cycle); a one-tick-late autosave is irrelevant.
|
||||
/// <see cref="SaveRequest"/> flag <c>RunDirectorSystem</c> raises on the terminal bank: reads the shared
|
||||
/// resource ledger + permanent meta off the director ghost, writes the JSON save (<see cref="SaveService"/>),
|
||||
/// then clears the flag. ServerSimulation-only, so a pure (Join) client never writes.
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial class SaveWriteSystem : SystemBase
|
||||
@@ -32,46 +30,26 @@ namespace ProjectM.Server
|
||||
req.Pending = 0;
|
||||
SystemAPI.SetComponent(dir, req);
|
||||
|
||||
var goal = SystemAPI.HasComponent<GoalProgress>(dir)
|
||||
? SystemAPI.GetComponent<GoalProgress>(dir)
|
||||
: default;
|
||||
|
||||
// END-1: persist the Engine Core integrity (a wounded base stays wounded across save/quit).
|
||||
var core = SystemAPI.HasComponent<CoreIntegrity>(dir)
|
||||
? SystemAPI.GetComponent<CoreIntegrity>(dir)
|
||||
: default;
|
||||
// END-2: persist the terminal run outcome so a won/lost run loads finished (no re-arm on Continue).
|
||||
var outcome = SystemAPI.HasComponent<RunOutcome>(dir)
|
||||
? SystemAPI.GetComponent<RunOutcome>(dir)
|
||||
: default;
|
||||
|
||||
|
||||
// The shared ledger lives on this same CycleDirector ghost (ResourceLedger-tagged StorageEntry buffer).
|
||||
// The shared ledger lives on this same director ghost (ResourceLedger-tagged StorageEntry buffer).
|
||||
var buffer = SystemAPI.GetBuffer<StorageEntry>(dir);
|
||||
var rows = new LedgerRow[buffer.Length];
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
rows[i] = new LedgerRow { ItemId = buffer[i].ItemId, Count = buffer[i].Count };
|
||||
|
||||
// M7: also persist player-built structures + their production tick-state / inventory (single shared scan).
|
||||
// Persist player-built structures (single shared scan; drift-proof vs the quit-to-menu writer).
|
||||
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).
|
||||
SaveStructureScan.Collect(EntityManager, nowTick, out var structures);
|
||||
// v6: the permanent-meta slice via the ONE shared collector.
|
||||
MetaSaveScan.Collect(EntityManager, dir, out var metaRows, out var runsCompleted, out var maxDepth);
|
||||
|
||||
|
||||
SaveService.Save(new SaveData
|
||||
{
|
||||
GoalCharge = goal.Charge,
|
||||
GoalTarget = goal.Target,
|
||||
CoreCurrent = core.Current,
|
||||
RunOutcome = outcome.Value,
|
||||
RunsCompleted = runsCompleted,
|
||||
MaxDepthReached = maxDepth,
|
||||
MetaUpgrades = metaRows,
|
||||
|
||||
Ledger = rows,
|
||||
Structures = structures,
|
||||
StructureIo = structureIo,
|
||||
SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,79 +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>
|
||||
/// END-1 — the Engine Core takes the hit a siege breaks through to. Server-only, plain
|
||||
/// <see cref="SimulationSystemGroup"/> <c>[UpdateAfter(EnemyAISystem)]</c> so it reads each Husk's POST-move
|
||||
/// position this tick (Husks are interpolated ghosts moved server-only by <see cref="EnemyAISystem"/>; the Core
|
||||
/// integrity rides the GLOBAL CycleDirector ghost). Any living Husk within <see cref="CoreReachRadius"/> of the
|
||||
/// base <see cref="BaseGridMath.PlotCenter"/> BREACHES: it drains <c>CoreDamagePerHusk</c> integrity and is
|
||||
/// consumed (despawned via the ECB — at-most-once, each Husk visited once per tick). Pure planar XZ check
|
||||
/// (<see cref="EnemyAIMath.InAttackRange"/>); the per-Husk damage is the live <see cref="TuningConfig"/> knob with
|
||||
/// the baked fallback. Once <see cref="CoreIntegrity.Current"/> hits 0 this system idles — the SOFT-loss edge in
|
||||
/// <see cref="ProjectM.Simulation"/>'s CyclePhaseSystem owns resolution (the locked DR-029 soft fork).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(EnemyAISystem))]
|
||||
public partial struct CoreDamageSystem : ISystem
|
||||
{
|
||||
/// <summary>How close (planar XZ) a Husk must get to the Engine Core to breach it. A STRUCTURAL reach radius
|
||||
/// (not a per-session feel knob) — generous so a Husk pushing into the base interior reads as a breach.</summary>
|
||||
const float CoreReachRadius = 3f;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<CoreIntegrity>();
|
||||
state.RequireForUpdate<BaseAnchor>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>()));
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var coreEntity = SystemAPI.GetSingletonEntity<CoreIntegrity>();
|
||||
var core = SystemAPI.GetComponent<CoreIntegrity>(coreEntity);
|
||||
if (core.Current <= 0)
|
||||
return; // already breached this beat; the lose-edge (CyclePhaseSystem) owns resolution.
|
||||
|
||||
// END-2: once the run is decided (Victory/Loss latched) the Core takes no more damage. Defensive — the
|
||||
// siege already despawned its Husks on resolution; this mirrors the CoreRestoreSystem terminal-halt guard.
|
||||
if (SystemAPI.TryGetSingleton<RunOutcome>(out var endOutcome) && endOutcome.Value != RunOutcomeId.InProgress)
|
||||
return;
|
||||
|
||||
float3 corePos = BaseGridMath.PlotCenter(SystemAPI.GetSingleton<BaseAnchor>());
|
||||
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
|
||||
int dmgPerHusk = (int)math.max(1f, tune.CoreDamagePerHusk);
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
int drained = 0;
|
||||
foreach (var (xform, entity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>>().WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // corpses don't drain the Core (B3)
|
||||
{
|
||||
if (!EnemyAIMath.InAttackRange(xform.ValueRO.Position, corePos, CoreReachRadius))
|
||||
continue;
|
||||
drained += dmgPerHusk;
|
||||
ecb.DestroyEntity(entity); // a breaching Husk is consumed (each Husk visited once -> at-most-once)
|
||||
}
|
||||
|
||||
if (drained > 0)
|
||||
{
|
||||
core.Current = math.max(0, core.Current - drained);
|
||||
SystemAPI.SetComponent(coreEntity, core);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a3eeda43e19f1946abd8e74126c3a62
|
||||
@@ -1,59 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// END-1 — a chipped-but-survived Engine Core heals between sieges, so a breach is a SETBACK you recover from,
|
||||
/// not a death spiral. Server-only, plain <see cref="SimulationSystemGroup"/>. Regenerates ONLY in
|
||||
/// <see cref="CyclePhase.Calm"/> (no regen mid-Siege): +1 integrity every <c>CoreRegenIntervalTicks</c> server
|
||||
/// ticks toward <see cref="CoreIntegrity.Max"/>. Deterministic + server-only (no rollback) so the plain
|
||||
/// <c>now % interval</c> tick gate is safe (the server advances exactly one fixed tick per step). The interval is
|
||||
/// the live <see cref="TuningConfig"/> knob with the baked fallback.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
public partial struct CoreRestoreSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<CoreIntegrity>();
|
||||
state.RequireForUpdate<CycleState>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
// END-2: once the run is decided (Victory/Loss latched), the Core freezes at its terminal value (no regen).
|
||||
if (SystemAPI.TryGetSingleton<RunOutcome>(out var endOutcome) && endOutcome.Value != RunOutcomeId.InProgress)
|
||||
return;
|
||||
|
||||
if (SystemAPI.GetSingleton<CycleState>().Phase != CyclePhase.Calm)
|
||||
return; // heal only between sieges
|
||||
|
||||
var coreEntity = SystemAPI.GetSingletonEntity<CoreIntegrity>();
|
||||
var core = SystemAPI.GetComponent<CoreIntegrity>(coreEntity);
|
||||
if (core.Current >= core.Max)
|
||||
return;
|
||||
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
var tune = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg) ? tcfg : TuningConfig.Defaults();
|
||||
uint interval = (uint)math.max(1f, tune.CoreRegenIntervalTicks);
|
||||
if (now % interval != 0)
|
||||
return;
|
||||
|
||||
core.Current = math.min(core.Max, core.Current + 1);
|
||||
SystemAPI.SetComponent(coreEntity, core);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3acd11f97b97d240b97e8c0ad096df7
|
||||
@@ -8,13 +8,13 @@ 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 + the initial phase delay), 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.
|
||||
/// Server-only, one-shot spawner for the GLOBAL 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
|
||||
/// — the shared-ledger / RunInfo / meta host (its old cycle/siege/goal/core state is retired, LANTERN purge) —
|
||||
/// applies a menu-staged save born-correct, 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)]
|
||||
@@ -49,24 +49,11 @@ namespace ProjectM.Server
|
||||
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.
|
||||
// meta counters — ALL added UNCONDITIONALLY at spawn (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); the save 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
|
||||
@@ -74,47 +61,25 @@ namespace ProjectM.Server
|
||||
ecb.AddComponent(director, default(MetaCounters));
|
||||
|
||||
// Born-correct load: if the menu staged a save (Continue), apply it AT SPAWN so the director
|
||||
// ghost never serializes an empty ledger to clients (no replication flicker).
|
||||
// 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 });
|
||||
|
||||
// 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).
|
||||
// sole-writer rule), 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,
|
||||
@@ -140,13 +105,13 @@ namespace ProjectM.Server
|
||||
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).
|
||||
// DR-042 C6c: NEW game only (no restored ledger) -> seed a little Ore so the build loop isn't a
|
||||
// cold start with nothing to place. 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.
|
||||
// Host-only autosave flag; SaveWriteSystem consumes it (RunDirectorSystem raises it on bank).
|
||||
ecb.AddComponent(director, new SaveRequest { Pending = 0 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;using Unity.Collections;
|
||||
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative macro-loop director for the PLAYER-DRIVEN loop. The base sits in <c>Calm</c>
|
||||
/// (persistent, unhurried — build/prep at your pace, no countdown) until the <see cref="ThreatState"/> arms a
|
||||
/// siege, then flips to <c>Siege</c> (the base-defense wave) and back to <c>Calm</c> when the wave is cleared.
|
||||
/// There is no global "Expedition" phase — being out on an expedition is per-player presence (server-only
|
||||
/// <see cref="RegionTag"/>), read client-side by the HUD, so one global byte never has to represent
|
||||
/// "player A out / player B home." Maintains the replicated <see cref="CycleState"/> singleton and gates
|
||||
/// <see cref="WaveSystem"/> (waves spawn only during Siege). Runs in the plain server SimulationSystemGroup
|
||||
/// before WaveSystem. All timing is wrap-safe NetworkTick math (<see cref="ProjectM.Simulation.TickUtil.NonZero"/>
|
||||
/// + <see cref="Unity.NetCode.NetworkTick.IsNewerThan"/>), never raw uint compares. Lives on the
|
||||
/// runtime-spawned CycleDirector ghost. Supersedes the forced timed Expedition→Defend→Build cycle.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(WaveSystem))]
|
||||
public partial struct CyclePhaseSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<CycleState>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
||||
var runtime = SystemAPI.GetComponent<CycleRuntime>(cycleEntity);
|
||||
|
||||
if (cycle.Phase == CyclePhase.Calm)
|
||||
{
|
||||
// Default calm: no pending siege => no countdown.
|
||||
cycle.PhaseEndTick = 0;
|
||||
|
||||
if (SystemAPI.HasComponent<ThreatState>(cycleEntity))
|
||||
{
|
||||
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
if (threat.PendingSiegeSize > 0)
|
||||
{
|
||||
// Telegraph: mirror the arm tick into the replicated PhaseEndTick so the HUD can show an
|
||||
// "incursion in Ns" countdown (reuses the existing HUD countdown path) while it arms.
|
||||
cycle.PhaseEndTick = threat.ArmTick;
|
||||
|
||||
bool armed = threat.ArmTick == 0
|
||||
|| !new NetworkTick(threat.ArmTick).IsNewerThan(serverTick);
|
||||
|
||||
if (armed && SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
|
||||
{
|
||||
// ---- Calm -> Siege: seed WaveSystem's own Spawning entry atomically. Writing
|
||||
// Phase=Spawning bypasses its Lull escalation recompute (WaveSystem only recomputes
|
||||
// RemainingToSpawn while Phase==Lull), so the siege spawns EXACTLY the director-chosen
|
||||
// size and WaveSystem stays the sole WaveState writer thereafter. ----
|
||||
var w = SystemAPI.GetComponent<WaveState>(waveEntity);
|
||||
runtime.DefendStartWave = w.WaveNumber; // capture BEFORE the bump (DefendCleared tests > this)
|
||||
w.WaveNumber += 1;
|
||||
w.Phase = WavePhase.Spawning;
|
||||
w.RemainingToSpawn = math.max(1, threat.PendingSiegeSize);
|
||||
w.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick
|
||||
SystemAPI.SetComponent(waveEntity, w);
|
||||
|
||||
cycle.Phase = CyclePhase.Siege;
|
||||
cycle.PhaseEndTick = 0; // Siege is wave-driven, not timed.
|
||||
|
||||
threat.PendingSiegeSize = 0; // consume once
|
||||
threat.ArmTick = 0;
|
||||
SystemAPI.SetComponent(cycleEntity, threat);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (cycle.Phase == CyclePhase.Siege)
|
||||
{
|
||||
// END-2: is this the FINAL siege (the goal cap armed it)? Server-only RunPhase marker; HasComponent-
|
||||
// guarded so EditMode worlds without RunPhase keep the pre-END-2 (normal) soft-loss + survival paths.
|
||||
bool isFinal = SystemAPI.HasComponent<RunPhase>(cycleEntity)
|
||||
&& SystemAPI.GetComponent<RunPhase>(cycleEntity).Value == RunPhaseId.FinalDefense;
|
||||
|
||||
// The Engine Core breached to 0 during the siege (checked BEFORE survival). CyclePhaseSystem stays the
|
||||
// sole Phase/WaveState writer; it is ALSO the sole RunOutcome writer (END-2 single-writer).
|
||||
bool overrun = SystemAPI.HasComponent<CoreIntegrity>(cycleEntity)
|
||||
&& SystemAPI.GetComponent<CoreIntegrity>(cycleEntity).Current <= 0;
|
||||
if (overrun)
|
||||
{
|
||||
cycle.Phase = CyclePhase.Calm;
|
||||
cycle.PhaseEndTick = 0;
|
||||
|
||||
// The siege ends: despawn the base siege Husks (the locked despawn-on-breach fork) + reset the
|
||||
// wave so the NEXT armed siege starts clean (WaveSystem idles in Calm anyway). Shared by both paths.
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
// Slice 3: cull the BASE wave only — an Expedition wave runs in its own region and must
|
||||
// survive a base Core breach. A region-blind EnemyTag wipe would also spuriously trip the
|
||||
// zone director's aliveZone==0 clear/reward edge. Mirrors ThreatDirectorSystem + DefendCleared.
|
||||
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // skip corpses: the B3 expiry pass owns their destroy (cross-ECB double-destroy)
|
||||
if (hr.ValueRO.Region == RegionId.Base)
|
||||
ecb.DestroyEntity(he);
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveLost))
|
||||
{
|
||||
var wl = SystemAPI.GetComponent<WaveState>(waveLost);
|
||||
wl.RemainingToSpawn = 0;
|
||||
wl.Phase = WavePhase.Lull;
|
||||
wl.NextActionTick = 0;
|
||||
SystemAPI.SetComponent(waveLost, wl);
|
||||
}
|
||||
|
||||
if (isFinal)
|
||||
{
|
||||
// END-2 TERMINAL LOSS: the final stand fell. Latch Loss + halt (the director stops arming). NO
|
||||
// ledger drain and NO OverrunTick stamp -> the client shows the dedicated terminal Loss banner
|
||||
// (from the replicated RunOutcome), not the soft "the Core will recover" flash.
|
||||
SystemAPI.SetComponent(cycleEntity, new RunOutcome { Value = RunOutcomeId.Loss });
|
||||
}
|
||||
else
|
||||
{
|
||||
// END-1 SOFT LOSS (unchanged): drain a fraction of the shared ledger + stamp the transient
|
||||
// overrun pulse; the base persists wounded and the Core regenerates in Calm (the DR-029 fork).
|
||||
var tuneL = SystemAPI.TryGetSingleton<TuningConfig>(out var tcfgL) ? tcfgL : TuningConfig.Defaults();
|
||||
if (SystemAPI.HasBuffer<StorageEntry>(cycleEntity))
|
||||
{
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(cycleEntity);
|
||||
StorageMath.DrainFraction(ledger, tuneL.CoreOverrunDrainPct);
|
||||
}
|
||||
var coreL = SystemAPI.GetComponent<CoreIntegrity>(cycleEntity);
|
||||
coreL.OverrunTick = TickUtil.NonZero(now);
|
||||
SystemAPI.SetComponent(cycleEntity, coreL);
|
||||
}
|
||||
|
||||
// Autosave the checkpoint (a breach / final loss is a meaningful save point).
|
||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
||||
}
|
||||
else if (DefendCleared(ref state, runtime.DefendStartWave))
|
||||
{
|
||||
cycle.Phase = CyclePhase.Calm;
|
||||
cycle.PhaseEndTick = 0;
|
||||
if (isFinal)
|
||||
{
|
||||
// END-2 TERMINAL WIN: the final siege was survived -> the Engine holds. Latch Victory + halt;
|
||||
// do NOT increment the (already-capped) goal.
|
||||
SystemAPI.SetComponent(cycleEntity, new RunOutcome { Value = RunOutcomeId.Victory });
|
||||
if (SystemAPI.HasComponent<SaveRequest>(cycleEntity))
|
||||
SystemAPI.SetComponent(cycleEntity, new SaveRequest { Pending = 1 });
|
||||
}
|
||||
// DR-042: a SURVIVED base siege no longer advances the win meter — that was the AFK/passive win
|
||||
// path (scheduled sieges auto-armed + auto-collapsed on timeout, so standing still won). The win-
|
||||
// driver moved to EXPEDITION CLEARS: GoalProgress.Charge is now credited per cleared expedition by
|
||||
// ExpeditionGateSystem on the player's RETURN. Surviving a normal siege is still its own reward
|
||||
// (resources kept, Core intact) but is not progress toward Victory. The final-siege Victory latch
|
||||
// above is unchanged — GoalReachedSystem still arms the climactic final siege once Charge hits Target.
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the live wave number on the replicated CycleState for the HUD (single writer).
|
||||
if (SystemAPI.TryGetSingleton<WaveState>(out var waveSync))
|
||||
cycle.WaveNumber = waveSync.WaveNumber;
|
||||
|
||||
SystemAPI.SetComponent(cycleEntity, cycle);
|
||||
SystemAPI.SetComponent(cycleEntity, runtime);
|
||||
}
|
||||
|
||||
// The Siege wave has run for this phase (WaveNumber advanced past the captured start), is fully spawned,
|
||||
// and no Husks remain alive.
|
||||
bool DefendCleared(ref SystemState state, int defendStartWave)
|
||||
{
|
||||
if (!SystemAPI.TryGetSingleton<WaveState>(out var wave))
|
||||
return false;
|
||||
// Cleared only when no BASE husk remains: expedition zone enemies (EnemyTag + RegionTag{Expedition})
|
||||
// must not hold the base siege open (DR-040 BLOCKER 3 — same global-count soft-lock as WaveSystem).
|
||||
int baseHusks = 0;
|
||||
foreach (var hr in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>()) // LIVING only (B3)
|
||||
if (hr.ValueRO.Region == RegionId.Base) baseHusks++;
|
||||
return wave.WaveNumber > defendStartWave
|
||||
&& wave.RemainingToSpawn == 0
|
||||
&& baseHusks == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c325c252dce9fba4a938d5c8db903042
|
||||
@@ -1,90 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// END-2 — arms the FINAL siege when the long-arc goal meter fills. Server-only, plain
|
||||
/// <see cref="SimulationSystemGroup"/>, <c>[UpdateAfter(CyclePhaseSystem)]</c> so it reads
|
||||
/// <see cref="GoalProgress.Charge"/> AFTER the survived-siege increment that may have just reached Target.
|
||||
/// On the <c>Charge >= Target</c> rising edge — guarded by <see cref="RunPhaseId.Normal"/> +
|
||||
/// <see cref="RunOutcomeId.InProgress"/> so it fires EXACTLY once — it:
|
||||
/// <list type="bullet">
|
||||
/// <item>arms a bigger siege through the existing single entry point <see cref="ThreatState.PendingSiegeSize"/>:
|
||||
/// the would-be-next normal siege size (<c>SizeBase + ScheduleSizePerWave*wave</c>) times the live
|
||||
/// <see cref="TuningConfig.FinalSiegeMultiplier"/> (floored at 1 so the final siege is never smaller), telegraphed
|
||||
/// via <see cref="ThreatState.ArmTick"/> (wrap-safe <see cref="TickUtil.NonZero"/>);</item>
|
||||
/// <item>flips <see cref="RunPhase"/> to <see cref="RunPhaseId.FinalDefense"/>.</item>
|
||||
/// </list>
|
||||
/// It NEVER writes <see cref="CycleState"/>.Phase / <c>WaveState</c> (CyclePhaseSystem stays the sole writer) nor
|
||||
/// <see cref="GoalProgress"/>.Charge (CyclePhaseSystem clamps it at the increment site) — it only READS the edge.
|
||||
/// CyclePhaseSystem then consumes <see cref="ThreatState.PendingSiegeSize"/> the next tick exactly like any other
|
||||
/// armed siege; <c>ThreatDirectorSystem</c> stops arming once <see cref="RunPhase"/> leaves Normal, so no normal
|
||||
/// siege can stomp the final one. Plain server group => one run per tick, no rollback/predicted exposure.
|
||||
/// Bytes, never enums (Burst-safe).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(CyclePhaseSystem))]
|
||||
public partial struct GoalReachedSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<CycleState>();
|
||||
state.RequireForUpdate<RunPhase>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||
|
||||
// Exactly-once guards: a decided run, or one already in the final siege, arms nothing.
|
||||
if (SystemAPI.HasComponent<RunOutcome>(cycleEntity)
|
||||
&& SystemAPI.GetComponent<RunOutcome>(cycleEntity).Value != RunOutcomeId.InProgress)
|
||||
return;
|
||||
var runPhase = SystemAPI.GetComponent<RunPhase>(cycleEntity);
|
||||
if (runPhase.Value != RunPhaseId.Normal)
|
||||
return;
|
||||
|
||||
// Goal cap reached? (Charge is clamped to Target at the CyclePhaseSystem increment site.)
|
||||
if (!SystemAPI.HasComponent<GoalProgress>(cycleEntity))
|
||||
return;
|
||||
var goal = SystemAPI.GetComponent<GoalProgress>(cycleEntity);
|
||||
if (goal.Target <= 0 || goal.Charge < goal.Target)
|
||||
return;
|
||||
|
||||
if (!SystemAPI.HasComponent<ThreatState>(cycleEntity) || !SystemAPI.HasComponent<ThreatConfig>(cycleEntity))
|
||||
return;
|
||||
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
var config = SystemAPI.GetComponent<ThreatConfig>(cycleEntity);
|
||||
|
||||
int wave = SystemAPI.TryGetSingleton<WaveState>(out var ws) ? ws.WaveNumber : 0;
|
||||
float mult = math.max(1f, SystemAPI.TryGetSingleton<TuningConfig>(out var tcfg)
|
||||
? tcfg.FinalSiegeMultiplier
|
||||
: TuningConfig.Defaults().FinalSiegeMultiplier);
|
||||
int normalSize = config.SizeBase + config.ScheduleSizePerWave * wave;
|
||||
int finalSize = math.max(1, (int)(normalSize * mult));
|
||||
|
||||
// Arm the final siege (overwrites any pending normal siege — the final supersedes; at the goal-reach tick
|
||||
// PendingSiegeSize is 0 anyway, the just-cleared siege having consumed it). CyclePhaseSystem consumes it.
|
||||
threat.PendingSiegeSize = finalSize;
|
||||
threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
|
||||
SystemAPI.SetComponent(cycleEntity, threat);
|
||||
|
||||
runPhase.Value = RunPhaseId.FinalDefense;
|
||||
SystemAPI.SetComponent(cycleEntity, runPhase);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 472c137c49b85e141b0ee00b1d1fa076
|
||||
@@ -10,8 +10,7 @@ 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).
|
||||
/// (<see cref="RunRuntime"/>).
|
||||
///
|
||||
/// 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,
|
||||
@@ -24,20 +23,12 @@ namespace ProjectM.Server
|
||||
///
|
||||
/// 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).
|
||||
/// boss-clear terminal (<see cref="RunRuntime.LastTerminalCleared"/>) credits RunsCompleted and requests a
|
||||
/// save. An abort/wipe banks NOTHING but the depth high-water (D-F3).
|
||||
/// </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>
|
||||
@@ -95,15 +86,7 @@ namespace ProjectM.Server
|
||||
{
|
||||
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)
|
||||
if (allReady && run.WasAllReady == 0)
|
||||
{
|
||||
// Rising edge → Launching. Seed the run: monotonic epoch + per-playthrough salt lineage,
|
||||
// never a tick, never 0, equality-compared downstream.
|
||||
@@ -364,25 +347,9 @@ case RunLifecycle.RouteSelect:
|
||||
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 });
|
||||
}
|
||||
// Boss-clear only: a save checkpoint (the win-meter/retaliation credits are retired — LANTERN purge).
|
||||
if (run.LastTerminalCleared != 0 && 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
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 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 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
|
||||
/// unattended siege (e.g. an empty base) auto-collapses so the loop can never soft-lock. Runs in the plain
|
||||
/// server SimulationSystemGroup, ordered Gate -> ThreatDirector -> RunState(CyclePhaseSystem) -> Wave so a
|
||||
/// return is consumed the same tick. All timing is wrap-safe NetworkTick math (TickUtil.NonZero +
|
||||
/// NetworkTick.IsNewerThan / TicksSince), never raw uint.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(RunDirectorSystem))]
|
||||
[UpdateBefore(typeof(CyclePhaseSystem))]
|
||||
public partial struct ThreatDirectorSystem : ISystem
|
||||
{
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<CycleState>();
|
||||
state.RequireForUpdate<ThreatState>();
|
||||
state.RequireForUpdate<ThreatConfig>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
var cycleEntity = SystemAPI.GetSingletonEntity<CycleState>();
|
||||
var cycle = SystemAPI.GetComponent<CycleState>(cycleEntity);
|
||||
var threat = SystemAPI.GetComponent<ThreatState>(cycleEntity);
|
||||
var config = SystemAPI.GetComponent<ThreatConfig>(cycleEntity);
|
||||
// END-2: a decided run (Victory/Loss) or one already in the FINAL siege arms NO further sieges. The
|
||||
// SiegeTimeout cull is also disabled during the final siege (a cull -> false Victory). Guarded with
|
||||
// HasComponent so EditMode worlds without RunPhase/RunOutcome keep the pre-END-2 behaviour.
|
||||
byte runPhase = SystemAPI.HasComponent<RunPhase>(cycleEntity)
|
||||
? SystemAPI.GetComponent<RunPhase>(cycleEntity).Value : RunPhaseId.Normal;
|
||||
byte runOutcome = SystemAPI.HasComponent<RunOutcome>(cycleEntity)
|
||||
? SystemAPI.GetComponent<RunOutcome>(cycleEntity).Value : RunOutcomeId.InProgress;
|
||||
bool canArm = runPhase == RunPhaseId.Normal && runOutcome == RunOutcomeId.InProgress;
|
||||
|
||||
|
||||
// ---- SOURCE: post-expedition retaliation. A returning player arms ONE siege (simultaneous returns
|
||||
// collapse to a single arming — extending the de-dup the gate's one-increment-per-return starts). ----
|
||||
if (config.PostExpeditionEnabled != 0 && threat.PendingReturns > 0)
|
||||
{
|
||||
if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0 && canArm)
|
||||
{
|
||||
int size = config.SizeBase + config.SizePerExpeditionResource * 0; // haul-scaling deferred (field baked)
|
||||
threat.PendingSiegeSize = math.max(1, size);
|
||||
threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
|
||||
}
|
||||
threat.PendingReturns = 0; // consume regardless so returns can't pile up
|
||||
}
|
||||
|
||||
// ---- SOURCE: scheduled base sieges. A timed cadence arms a siege even with NO expedition trip, so
|
||||
// the base-defense loop has stakes on its own. The first fire is one full interval out (a mine/build
|
||||
// grace window); size escalates by the live wave number. All ticks wrap-safe (TickUtil.NonZero). ----
|
||||
if (config.ScheduleEnabled != 0 && config.ScheduleIntervalTicks > 0)
|
||||
{
|
||||
if (threat.NextScheduledTick == 0 || cycle.Phase != CyclePhase.Calm)
|
||||
{
|
||||
// Seed, and DEFER while a siege runs, so the next scheduled siege is always one full interval
|
||||
// AFTER the current one resolves -> a guaranteed calm/build window even if a siege runs long.
|
||||
threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
|
||||
}
|
||||
else if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0 && canArm
|
||||
&& !new NetworkTick(threat.NextScheduledTick).IsNewerThan(serverTick))
|
||||
{
|
||||
int wave = SystemAPI.TryGetSingleton<WaveState>(out var ws) ? ws.WaveNumber : 0;
|
||||
threat.PendingSiegeSize = math.max(1, config.SizeBase + config.ScheduleSizePerWave * wave);
|
||||
threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
|
||||
threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- BOUNDED RESOLUTION: a Siege can't drag forever. Record its start; after SiegeTimeoutTicks cull
|
||||
// the remaining Husks + stop spawning so CyclePhaseSystem's DefendCleared returns the base to Calm. ----
|
||||
if (cycle.Phase == CyclePhase.Siege)
|
||||
{
|
||||
if (threat.SiegeStartTick == 0)
|
||||
{
|
||||
threat.SiegeStartTick = TickUtil.NonZero(now);
|
||||
}
|
||||
else if (config.SiegeTimeoutTicks > 0 && runPhase != RunPhaseId.FinalDefense)
|
||||
{
|
||||
var start = new NetworkTick(threat.SiegeStartTick);
|
||||
if (start.IsValid && serverTick.TicksSince(start) > (int)config.SiegeTimeoutTicks)
|
||||
{
|
||||
// Collapse the siege: cull every remaining BASE Husk only (expedition zone enemies are also
|
||||
// EnemyTag but RegionTag{Expedition}; the timeout must not destroy them — DR-040 BLOCKER 3).
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (hr, he) in SystemAPI.Query<RefRO<RegionTag>>().WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess()) // skip corpses: the B3 expiry pass owns their destroy
|
||||
if (hr.ValueRO.Region == RegionId.Base)
|
||||
ecb.DestroyEntity(he);
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
|
||||
if (SystemAPI.TryGetSingletonEntity<WaveState>(out var waveEntity))
|
||||
{
|
||||
var w = SystemAPI.GetComponent<WaveState>(waveEntity);
|
||||
w.RemainingToSpawn = 0;
|
||||
SystemAPI.SetComponent(waveEntity, w);
|
||||
}
|
||||
threat.SiegeStartTick = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
threat.SiegeStartTick = 0; // not under siege
|
||||
}
|
||||
|
||||
SystemAPI.SetComponent(cycleEntity, threat);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3cd1beb28c2b1f84398722a95d1ee784
|
||||
Reference in New Issue
Block a user