ed65770cc9
Design-review-gated (wf_ebef4e81-dba, GREEN-WITH-CHANGES). The win-driver moves from "survive N base sieges" to "clear N expeditions". The review overturned the literal plan: credit on RETURN, not at the clear edge (clear-edge crediting arms the undefended final base siege -> uncontestable terminal Loss). - ExpeditionGateSystem: now the sole production writer of GoalProgress.Charge — a clamped +1 per cleared expedition folded into the existing once-per-epoch reward block, reusing the LastRewardedEpoch latch (Ore + Charge share fate) + a SaveRequest checkpoint. No new latch, no new GhostField, no ordering change. - CyclePhaseSystem: deleted the survived-siege +1 (the AFK win path). Victory latch unchanged; GoalReached still arms the final base siege at cap. - CycleDirectorAuthoring + CycleDirector.prefab: ScheduleEnabled baked OFF (retaliation-only). A serialized prefab bool ignores the C# field initializer, so the value is flipped in the prefab, not just the code default. - Tests: re-pointed CyclePhaseSystemTests + EndgameWinLoseTests survived-siege assertions; extended ExpeditionGateRewardTests (+1, no-double-credit, clamp). 389/389 EditMode green; clean netcode Play smoke (no sort-cycle, Schedule=0). SaveData stays v5. Docs: DR-042 build record + forks resolved, CLAUDE.md base-loop line, Backlog (A done). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
123 lines
6.4 KiB
C#
123 lines
6.4 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|