END-2: final siege + latching win/lose (SL-3)
At GoalProgress.Charge>=Target a new server-only GoalReachedSystem arms a larger final siege (x live FinalSiegeMultiplier) and flips RunPhase=FinalDefense; CyclePhaseSystem latches a REPLICATED RunOutcome (Victory on clear / Loss on Core breach) and halts the director. RunOutcome is a [GhostField] byte on the global CycleDirector ghost (the client banner observes it); RunPhase stays server-only. ThreatDirector/CoreRestore/CoreDamage halt once decided; SiegeTimeout is off during the final siege. SaveData v5 persists the outcome so a won/lost run loads finished. GoalProgress.Target 10->4. Completes Path A's spine. See DR-036. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user