813c829420
- New Server/PlayerResolve.TryResolve single-sources the RPC SourceConnection -> NetworkId -> conn->player map resolve (3 sites: ClassSelectReceive, PrepPurchase, DebugCommandReceive); EntityManager reads keep it source-gen-safe from Bursted receivers. - ecb.Dispose() after Playback in 9 Temp-ECB systems (explicit-lifetime hygiene). - The TuningConfig.GetOrDefault(ref state) variant of this tail was REVERTED: state.GetEntityQuery in OnUpdate trips the Entities "creates a query during OnUpdate" diagnostic per system per world (caught in Play smoke) - the SystemAPI.TryGetSingleton idiom is already source-gen-optimal, confirming the original B4 deferral. Verified: 466/466 EditMode green on the final tree, console clean, Play smoke 0 errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
98 lines
5.2 KiB
C#
98 lines
5.2 KiB
C#
using ProjectM.Simulation;
|
|
using Unity.Burst;
|
|
using Unity.Collections;
|
|
using Unity.Entities;
|
|
using Unity.NetCode;
|
|
|
|
namespace ProjectM.Server
|
|
{
|
|
/// <summary>
|
|
/// Server receiver for <see cref="RouteSelectRequest"/> — the co-op route choice (any-player-first-commits, the
|
|
/// operator's locked authority model). Validates each pick server-authoritatively:
|
|
/// <c>Lifecycle == RouteSelect</c> · run identity <c>(uint)ForRunEpoch == RunRuntime.RunSeed</c> (the Step-8
|
|
/// review re-mean: the replicated seed IS the run token; the server-only RunEpoch is not client-knowable) ·
|
|
/// <c>ForLayer == RunInfo.CurrentRoom</c> (the gate's un-incremented cleared layer) · <c>OptionIndex</c> within
|
|
/// the replicated <c>RouteOptionCount</c> · the SENDER's <see cref="RegionTag"/> is Expedition (N3 — a
|
|
/// base-bound joiner cannot commit the party's route) · nothing accepted yet this gate.
|
|
///
|
|
/// FIRST-COMMIT LATCH: the accepted pick is written to the server-only <see cref="RouteCommand"/> via an
|
|
/// IMMEDIATE in-place <c>SystemAPI.SetComponent</c> INSIDE the drain loop plus a local accepted flag (the DR-014
|
|
/// atomicity idiom) — two same-tick picks can never both observe an open gate; a hoisted read would re-create
|
|
/// the exact N1 race the design review killed. <see cref="RouteCommand.ForRunEpoch"/> is stamped from the TRUE
|
|
/// server-only epoch (never the client-echoed value). Requests are ALWAYS destroyed. This system writes ONLY
|
|
/// RouteCommand — RunDirectorSystem stays the sole RunInfo/RunRuntime writer and consumes the latch
|
|
/// (abort → pick → grace, in that order). Ordered before it so a pick can land the same tick it is consumed;
|
|
/// NO CyclePhase edge (the room-chain hard rule).
|
|
/// </summary>
|
|
[BurstCompile]
|
|
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
|
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
|
[UpdateBefore(typeof(RunDirectorSystem))]
|
|
public partial struct RouteSelectSystem : ISystem
|
|
{
|
|
[BurstCompile]
|
|
public void OnCreate(ref SystemState state)
|
|
{
|
|
var builder = new EntityQueryBuilder(Allocator.Temp)
|
|
.WithAll<RouteSelectRequest, ReceiveRpcCommandRequest>();
|
|
state.RequireForUpdate(state.GetEntityQuery(builder));
|
|
state.RequireForUpdate<RunInfo>();
|
|
state.RequireForUpdate<RunRuntime>();
|
|
state.RequireForUpdate<RouteCommand>();
|
|
}
|
|
|
|
[BurstCompile]
|
|
public void OnUpdate(ref SystemState state)
|
|
{
|
|
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
|
|
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
|
|
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
|
|
bool gateOpen = info.Lifecycle == RunLifecycle.RouteSelect;
|
|
|
|
// Sender-region lookup (N3): connection NetworkId -> the player's CURRENT region. A player entity
|
|
// without RegionTag simply never enters the map -> its pick is a clean reject, never a throw.
|
|
var regionByConn = new NativeHashMap<int, byte>(8, Allocator.Temp);
|
|
foreach (var (owner, region) in
|
|
SystemAPI.Query<RefRO<GhostOwner>, RefRO<RegionTag>>().WithAll<PlayerTag>())
|
|
regionByConn[owner.ValueRO.NetworkId] = region.ValueRO.Region;
|
|
|
|
// Local accepted flag beside the in-place write = the first-commit latch (nothing else writes
|
|
// RouteCommand mid-loop; RunDirector's gate-entry clear ran on a previous tick by construction).
|
|
bool accepted = SystemAPI.GetComponent<RouteCommand>(dirEntity).HasPick != 0;
|
|
|
|
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
|
foreach (var (receive, req, requestEntity) in
|
|
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<RouteSelectRequest>>().WithEntityAccess())
|
|
{
|
|
var conn = receive.ValueRO.SourceConnection;
|
|
bool valid = gateOpen
|
|
&& !accepted
|
|
&& (uint)req.ValueRO.ForRunEpoch == run.RunSeed
|
|
&& req.ValueRO.ForLayer == info.CurrentRoom
|
|
&& req.ValueRO.OptionIndex < info.RouteOptionCount
|
|
&& SystemAPI.HasComponent<NetworkId>(conn)
|
|
&& regionByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out byte senderRegion)
|
|
&& senderRegion == RegionId.Expedition;
|
|
|
|
if (valid)
|
|
{
|
|
// IMMEDIATE in-place commit (never an ECB-deferred write) + the local flag: first pick wins.
|
|
SystemAPI.SetComponent(dirEntity, new RouteCommand
|
|
{
|
|
HasPick = 1,
|
|
OptionIndex = req.ValueRO.OptionIndex,
|
|
ForRunEpoch = run.RunEpoch, // the TRUE server epoch — never echo the client value
|
|
ForLayer = req.ValueRO.ForLayer,
|
|
});
|
|
accepted = true;
|
|
}
|
|
|
|
ecb.DestroyEntity(requestEntity);
|
|
}
|
|
ecb.Playback(state.EntityManager);
|
|
ecb.Dispose();
|
|
regionByConn.Dispose();
|
|
}
|
|
}
|
|
}
|