LANTERN purge: delete the superseded base/expedition shell (audit H1/H3/M5)
The 2026-08-06 audit found the shipping scene was still the abandoned co-op-Hades game with LANTERN combat bolted on, and that a third of the codebase was live code for a direction abandoned on 2026-07-13. Operator chose deletion over freezing: "everything is saved in source control if needed. I want the project to be clean." DELETED (~140 source files, Scripts 335->231, Tests 77->43): - Enemy variants + boss (H3). ChargerAuthoring / SpitterAuthoring / SwarmerAuthoring were attached to ZERO prefabs, so LungeState / SpitterState / SwarmerTag were never baked: ~272 lines of Bursted AI passes, BossAISystem (261 lines) and the whole MixBands escalation curve could not match a single chunk at runtime, while 734 lines of green tests certified them. Both shipping enemy prefabs were already byte-identical in stats. - Run/room lifecycle: RunDirector FSM, RunInfo/RunMap/RoomPlan/RoomTag, route select, portal interact, ready-check, room field/teardown. - Meta shop, prep loadout, boons (incl. KillRewardSystem and DashTrailDamageSystem, which existed only to serve boon flags). - Build palette + structures, shared storage, inventory/equipment (already recorded PAUSED in CLAUDE.md). - The HUD panels driving all of the above (HudSystem 1168 -> 610). KEPT deliberately: BaseGridMath + BaseAnchor (8 systems use PlotCenter for spawn rings, respawn and dynamic light), the resource ledger + StorageMath, the save system, region/relevancy. Three of these were in the delete set until I checked their consumers — worth remembering that the file-level manifest was wrong about them. Also folds in audit finding M5: PlayerClass was a second, server-only copy of the byte FrameId already replicates. It existed for the meta shop; with that gone, FrameId is the single frame identity. Harvest is now single-sink (ledger). HarvestMath keeps its shape so LANTERN's carried-vs-banked cargo split lands in one place, not two. 295/295 EditMode green, zero compile errors. Subscene re-bake and Play validation follow in the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,101 +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>
|
||||
/// One-shot server restore of player-built structures for a "Continue" session. The menu (WorldLauncher) stages a
|
||||
/// <see cref="PendingStructure"/> carrier in the fresh ServerWorld BEFORE the gameplay subscene streams; this
|
||||
/// system waits (RequireForUpdate) for the streamed <see cref="StructureCatalog"/> + <see cref="BaseAnchor"/> +
|
||||
/// a valid NetworkTime, then replays each saved structure CHARGE-FREE: Instantiate the catalog prefab at the
|
||||
/// saved cell (preserving the baked Scale), restore the wounded HP born-correct, re-tag RegionTag{Base} +
|
||||
/// RuntimePlacedTag, then DESTROY the carrier so it never runs again. The ledger restores separately +
|
||||
/// absolutely via CycleDirectorSpawnSystem's born-correct load (no double-spend, no Withdraw here).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct BaseRestoreSystem : ISystem
|
||||
{
|
||||
ComponentLookup<LocalTransform> m_TransformLookup;
|
||||
ComponentLookup<Health> m_HealthLookup;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
|
||||
m_HealthLookup = state.GetComponentLookup<Health>(isReadOnly: true);
|
||||
state.RequireForUpdate<StructureCatalog>();
|
||||
state.RequireForUpdate<BaseAnchor>();
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<PendingStructure>()));
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
m_TransformLookup.Update(ref state);
|
||||
m_HealthLookup.Update(ref state);
|
||||
|
||||
var anchor = SystemAPI.GetSingleton<BaseAnchor>();
|
||||
var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>());
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (pending, carrier) in
|
||||
SystemAPI.Query<DynamicBuffer<PendingStructure>>().WithEntityAccess())
|
||||
{
|
||||
for (int s = 0; s < pending.Length; s++)
|
||||
{
|
||||
var p = pending[s];
|
||||
|
||||
int entryIdx = -1;
|
||||
for (int i = 0; i < catalog.Length; i++)
|
||||
if (catalog[i].Type == p.Type) { entryIdx = i; break; }
|
||||
if (entryIdx < 0 || catalog[entryIdx].Prefab == Entity.Null)
|
||||
continue; // type not in the catalog (e.g. a save from a newer build) -> skip, don't crash
|
||||
|
||||
var prefab = catalog[entryIdx].Prefab;
|
||||
var structure = ecb.Instantiate(prefab);
|
||||
|
||||
int2 cell = new int2(p.CellX, p.CellZ);
|
||||
var xform = m_TransformLookup[prefab];
|
||||
xform.Position = BaseGridMath.CellToWorld(anchor, cell); // preserve baked Scale (FromPosition would reset it)
|
||||
ecb.SetComponent(structure, xform);
|
||||
|
||||
ecb.SetComponent(structure, new PlacedStructure
|
||||
{
|
||||
Type = p.Type,
|
||||
Cell = cell,
|
||||
NextTick = 0u, // cooldown restore retired with the automation chain (LANTERN purge)
|
||||
LastProcessedTick = TickUtil.NonZero(now),
|
||||
});
|
||||
// EB-1: restore the wounded HP born-correct in the SAME ecb as Instantiate (Health.Current is a
|
||||
// [GhostField]; a deferred set would leak baked Max to clients for one snapshot). Max + the
|
||||
// 0->full fallback come from the BAKED prefab, never the save.
|
||||
if (m_HealthLookup.HasComponent(prefab))
|
||||
{
|
||||
var hm = m_HealthLookup[prefab];
|
||||
ecb.SetComponent(structure, new Health { Current = p.HP > 0f ? p.HP : hm.Max, Max = hm.Max });
|
||||
}
|
||||
ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base });
|
||||
ecb.AddComponent<RuntimePlacedTag>(structure);
|
||||
}
|
||||
|
||||
ecb.DestroyEntity(carrier);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4003027ade5ccd5418e300d87e5c5e14
|
||||
@@ -1,106 +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>
|
||||
/// Server-authoritative structure placement (handles <see cref="BuildPlaceRequest"/> RPCs). Derives
|
||||
/// occupancy by scanning live <see cref="PlacedStructure"/> ghosts into a Temp NativeHashSet (structures
|
||||
/// are the source of truth — no cached buffer on the immutable BaseAnchor). For each request it validates
|
||||
/// catalog/legality/occupancy/cost, and on success commits IN-PLACE (StorageMath.Withdraw on the global
|
||||
/// ledger + reserve the cell in the set) so two same-tick requests for one cell can't both pass — the
|
||||
/// StorageOpReceiveSystem in-place idiom — then instantiates the catalog prefab at the cell center
|
||||
/// (RegionTag{Base}, world-owned, NextTick=0, LastProcessedTick stamped). Plain server SimulationSystemGroup
|
||||
/// (not predicted → applied once). Rejects invalid requests silently.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct BuildPlaceSystem : ISystem
|
||||
{
|
||||
ComponentLookup<LocalTransform> m_TransformLookup;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_TransformLookup = state.GetComponentLookup<LocalTransform>(isReadOnly: true);
|
||||
state.RequireForUpdate<StructureCatalog>();
|
||||
state.RequireForUpdate<BaseAnchor>();
|
||||
state.RequireForUpdate<ResourceLedger>();
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAll<BuildPlaceRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
m_TransformLookup.Update(ref state);
|
||||
uint now = SystemAPI.GetSingleton<NetworkTime>().ServerTick.TickIndexForValidTick;
|
||||
var anchor = SystemAPI.GetSingleton<BaseAnchor>();
|
||||
|
||||
var catalog = SystemAPI.GetBuffer<StructureCatalogEntry>(SystemAPI.GetSingletonEntity<StructureCatalog>());
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||
|
||||
// Derive occupancy from the live structure set (authoritative).
|
||||
var occupied = new NativeHashSet<int2>(64, Allocator.Temp);
|
||||
foreach (var ps in SystemAPI.Query<RefRO<PlacedStructure>>())
|
||||
occupied.Add(ps.ValueRO.Cell);
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (request, receive, requestEntity) in
|
||||
SystemAPI.Query<RefRO<BuildPlaceRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
||||
{
|
||||
var req = request.ValueRO;
|
||||
int2 cell = new int2(req.CellX, req.CellZ);
|
||||
|
||||
int entryIdx = -1;
|
||||
for (int i = 0; i < catalog.Length; i++)
|
||||
if (catalog[i].Type == req.StructureType) { entryIdx = i; break; }
|
||||
|
||||
if (entryIdx >= 0 && catalog[entryIdx].Prefab != Entity.Null
|
||||
&& BuildPlacementMath.CanPlace(anchor, occupied, cell))
|
||||
{
|
||||
var entry = catalog[entryIdx];
|
||||
|
||||
int have = 0;
|
||||
for (int i = 0; i < ledger.Length; i++)
|
||||
if (ledger[i].ItemId == entry.CostResourceId) { have = ledger[i].Count; break; }
|
||||
|
||||
if (have >= entry.CostAmount)
|
||||
{
|
||||
// Commit IN-PLACE so a second same-tick request sees the spend + reservation.
|
||||
StorageMath.Withdraw(ledger, entry.CostResourceId, entry.CostAmount);
|
||||
occupied.Add(cell);
|
||||
|
||||
var structure = ecb.Instantiate(entry.Prefab);
|
||||
var xform = m_TransformLookup[entry.Prefab];
|
||||
xform.Position = BaseGridMath.CellToWorld(anchor, cell); // preserve baked Scale
|
||||
ecb.SetComponent(structure, xform);
|
||||
ecb.SetComponent(structure, new PlacedStructure
|
||||
{
|
||||
Type = req.StructureType,
|
||||
Cell = cell,
|
||||
NextTick = 0u,
|
||||
LastProcessedTick = 0u, // 0 = uninitialized; the production systems set the baseline on first encounter (turret ignores it)
|
||||
});
|
||||
ecb.AddComponent(structure, new RegionTag { Region = RegionId.Base });
|
||||
ecb.AddComponent<RuntimePlacedTag>(structure); // player-built -> persisted by SaveStructureScan
|
||||
}
|
||||
}
|
||||
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
occupied.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d1886c7056b315e42b7754f50c43c59e
|
||||
@@ -1,157 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server receiver for <see cref="BoonPickRequest"/> + the reward-grace AUTO-PICK backstop. A valid pick
|
||||
/// (sender resolved, <c>RunInfo.Lifecycle == RoomReward</c> — the D-F4 gate — <c>Pending == 1</c>, index in
|
||||
/// range, option id known to the catalog) appends ONE <see cref="StatModifier"/> in the run-scoped BOON band
|
||||
/// (<c>Tuning.BoonSourceIdBase + BoonPickCounter++</c> — distinct rows, one range-strip clears the run) and
|
||||
/// clears <c>Pending</c>; the buffer mutation is non-structural and folds through the unchanged
|
||||
/// StatRecomputeSystem on both worlds (rollback-correct). When the reward grace elapses, every still-pending
|
||||
/// EXPEDITION player is auto-dealt <c>Option0</c> (the operator's default un-picked policy — a player always
|
||||
/// gets something) so the run never stalls on an AFK picker.
|
||||
///
|
||||
/// Ordering: <c>[UpdateBefore(RunDirectorSystem)]</c> — ALL RPC receivers sit before the director (the
|
||||
/// ReadyToggle/RouteSelect symmetry). This closes the D-F4 straggler race STRUCTURALLY: on the tick the
|
||||
/// director strips (Returning), a straggler pick is rejected here FIRST (lifecycle is already past RoomReward),
|
||||
/// so nothing can append after the strip; and the auto-pick lands before the director's exit gate reads
|
||||
/// Pending. Requests are ALWAYS destroyed. No CyclePhase edge (the room-chain hard rule).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(RunDirectorSystem))]
|
||||
public partial struct BoonApplySystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<BoonCatalog>();
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
state.RequireForUpdate<RunRuntime>();
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
}
|
||||
|
||||
[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 rewarding = info.Lifecycle == RunLifecycle.RoomReward;
|
||||
|
||||
var catalog = SystemAPI.GetComponent<BoonCatalog>(SystemAPI.GetSingletonEntity<BoonCatalog>());
|
||||
if (!catalog.Value.IsCreated)
|
||||
return;
|
||||
ref var pool = ref catalog.Value.Value;
|
||||
|
||||
bool runDirty = false;
|
||||
|
||||
// ---- explicit picks (drained every tick so stale requests die even outside RoomReward) ----
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, entity) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, BoonOffer, StatModifier>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = entity;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (receive, req, requestEntity) in
|
||||
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<BoonPickRequest>>().WithEntityAccess())
|
||||
{
|
||||
var conn = receive.ValueRO.SourceConnection;
|
||||
if (rewarding
|
||||
&& req.ValueRO.Index < 3
|
||||
&& SystemAPI.HasComponent<NetworkId>(conn)
|
||||
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
|
||||
{
|
||||
var offer = SystemAPI.GetComponent<BoonOffer>(player);
|
||||
if (offer.Pending == 1)
|
||||
{
|
||||
byte id = req.ValueRO.Index == 2 ? offer.Option2
|
||||
: req.ValueRO.Index == 1 ? offer.Option1 : offer.Option0;
|
||||
if (Apply(ref state, player, id, ref pool, ref run))
|
||||
{
|
||||
offer.Pending = 0;
|
||||
SystemAPI.SetComponent(player, offer);
|
||||
runDirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
playerByConn.Dispose();
|
||||
|
||||
// ---- reward-grace auto-pick backstop (Option0 — the player always gets something) ----
|
||||
if (rewarding && run.RewardGraceTick != 0u)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (serverTick.IsValid && !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick))
|
||||
{
|
||||
foreach (var (offer, region, entity) in
|
||||
SystemAPI.Query<RefRW<BoonOffer>, RefRO<RegionTag>>()
|
||||
.WithAll<PlayerTag, StatModifier>().WithEntityAccess())
|
||||
{
|
||||
if (offer.ValueRO.Pending != 1 || region.ValueRO.Region != RegionId.Expedition)
|
||||
continue;
|
||||
if (Apply(ref state, entity, offer.ValueRO.Option0, ref pool, ref run))
|
||||
runDirty = true;
|
||||
offer.ValueRW.Pending = 0; // cleared even if the id was unknown — never wedge the gate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (runDirty)
|
||||
SystemAPI.SetComponent(dirEntity, run); // the documented BoonPickCounter co-write (band provenance)
|
||||
}
|
||||
|
||||
/// <summary>Append the boon's StatModifier in the run-scoped band. False iff the id is unknown/zero.</summary>
|
||||
static bool Apply(ref SystemState state, Entity player, byte boonId, ref BoonCatalogBlob pool, ref RunRuntime run)
|
||||
{
|
||||
if (boonId == 0)
|
||||
return false;
|
||||
int idx = BoonMath.FindDef(ref pool, boonId);
|
||||
if (idx < 0)
|
||||
return false; // unknown id (catalog drift) — preserve-and-skip, never throw
|
||||
|
||||
if (pool.Defs[idx].Kind == 1)
|
||||
{
|
||||
// Phase 1.7 mechanic-changer: mutate the baked-present BoonEffects (non-structural) instead of
|
||||
// appending a StatModifier. Bytes only (Burst-safe switch). No BoonPickCounter bump (no band row).
|
||||
if (!state.EntityManager.HasComponent<BoonEffects>(player))
|
||||
return false; // real players are baked with it; skip defensively otherwise
|
||||
var fx = state.EntityManager.GetComponentData<BoonEffects>(player);
|
||||
byte delta = (byte)pool.Defs[idx].Value;
|
||||
switch (pool.Defs[idx].EffectKind)
|
||||
{
|
||||
case BoonEffectKind.Pierce: fx.Pierce = (byte)(fx.Pierce + delta); break;
|
||||
case BoonEffectKind.Fork: fx.Fork = (byte)(fx.Fork + delta); break;
|
||||
case BoonEffectKind.Chain: fx.Chain = (byte)(fx.Chain + delta); break;
|
||||
case BoonEffectKind.DashTrail: fx.Flags |= BoonFlag.DashTrail; break;
|
||||
case BoonEffectKind.FinisherDetonate: fx.Flags |= BoonFlag.FinisherDetonate; break;
|
||||
case BoonEffectKind.KnockToPull: fx.Flags |= BoonFlag.KnockToPull; break;
|
||||
case BoonEffectKind.Siphon: fx.Flags |= BoonFlag.Siphon; break;
|
||||
case BoonEffectKind.Frenzy: fx.Flags |= BoonFlag.Frenzy; break;
|
||||
default: return false; // unknown effect kind — preserve-and-skip
|
||||
}
|
||||
state.EntityManager.SetComponentData(player, fx);
|
||||
return true;
|
||||
}
|
||||
|
||||
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
|
||||
mods.Add(new StatModifier
|
||||
{
|
||||
Target = pool.Defs[idx].Target,
|
||||
Op = pool.Defs[idx].Op,
|
||||
Value = pool.Defs[idx].Value,
|
||||
SourceId = Tuning.BoonSourceIdBase + (run.BoonPickCounter % Tuning.BoonSourceIdSpan),
|
||||
});
|
||||
run.BoonPickCounter += 1;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5749745bedc86ca4396b9a3911ef8773
|
||||
@@ -1,78 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-only choice-of-3 boon dealer: once per <see cref="RunRuntime.RoomEpoch"/> (int-equality latch on
|
||||
/// <see cref="BoonOfferState"/>, attached beside the catalog singleton), when the run FSM enters RoomReward it
|
||||
/// draws each EXPEDITION player's 3 distinct, rarity-weighted, class-filtered options via
|
||||
/// <see cref="BoonMath.PickBoons"/> — deterministically seeded from Hash(RunSeed, room, NetworkId) — and writes
|
||||
/// the player's owner-only replicated <see cref="BoonOffer"/> (Pending=1). A base-region player (dead-respawned,
|
||||
/// late joiner) gets NO offer and never holds the gate (RunDirector counts only Pending!=0). BoonApplySystem
|
||||
/// (Step 10) consumes picks; the Returning-edge strip zeroes stragglers.
|
||||
///
|
||||
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> — on the RoomReward ENTRY tick this runs after the
|
||||
/// transition, so offers exist BEFORE RunDirector's exit gate first evaluates (next tick). No CyclePhase edge.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(RunDirectorSystem))]
|
||||
public partial struct BoonOfferSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<BoonCatalog>();
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
state.RequireForUpdate<RunRuntime>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var catalogEntity = SystemAPI.GetSingletonEntity<BoonCatalog>();
|
||||
|
||||
// One-shot: attach this system's latch beside the catalog singleton (the RoomFieldState idiom).
|
||||
if (!SystemAPI.HasComponent<BoonOfferState>(catalogEntity))
|
||||
{
|
||||
state.EntityManager.AddComponentData(catalogEntity, new BoonOfferState());
|
||||
return; // structural change — clean re-read next tick
|
||||
}
|
||||
|
||||
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
|
||||
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
|
||||
if (info.Lifecycle != RunLifecycle.RoomReward)
|
||||
return;
|
||||
|
||||
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
|
||||
var offered = SystemAPI.GetComponent<BoonOfferState>(catalogEntity);
|
||||
if (offered.OfferedRoomEpoch == run.RoomEpoch)
|
||||
return; // this room's offers are already dealt
|
||||
|
||||
var catalog = SystemAPI.GetComponent<BoonCatalog>(catalogEntity);
|
||||
if (!catalog.Value.IsCreated)
|
||||
return;
|
||||
ref var pool = ref catalog.Value.Value;
|
||||
|
||||
foreach (var (offer, owner, region, cls, fx) in
|
||||
SystemAPI.Query<RefRW<BoonOffer>, RefRO<GhostOwner>, RefRO<RegionTag>, RefRO<PlayerClass>, RefRO<BoonEffects>>()
|
||||
.WithAll<PlayerTag>())
|
||||
{
|
||||
if (region.ValueRO.Region != RegionId.Expedition)
|
||||
continue; // home-bound players (dead-respawned, joiners) are dealt nothing
|
||||
|
||||
// Deterministic per-player draw: reconnect-stable per session, replay-reproducible per (seed, room, player, owned-effects-at-draw).
|
||||
uint offerSeed = RunMapMath.Hash(run.RunSeed, (uint)info.CurrentRoom, (uint)owner.ValueRO.NetworkId) | 1u;
|
||||
BoonMath.PickBoons(offerSeed, cls.ValueRO.ClassId, fx.ValueRO, ref pool, out byte o0, out byte o1, out byte o2);
|
||||
offer.ValueRW = new BoonOffer { Pending = 1, Option0 = o0, Option1 = o1, Option2 = o2 };
|
||||
}
|
||||
|
||||
offered.OfferedRoomEpoch = run.RoomEpoch;
|
||||
SystemAPI.SetComponent(catalogEntity, offered);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d3715c60d2cc2348ac4ff7600006d23
|
||||
@@ -1,261 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Physics;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative EXPEDITION BOSS brain — the SOLE mover/attacker of <c>.WithAll<EnemyTag, BossState>()</c>
|
||||
/// (EnemyAISystem's Charger MOVE pass excludes it via <c>.WithNone<BossState>()</c>, so exactly one system
|
||||
/// writes the boss's Position/Rotation/AttackWindup — the sole-writer invariant). Runs SERVER-ONLY in the plain
|
||||
/// <see cref="SimulationSystemGroup"/> <c>[UpdateAfter(EnemyAISystem)]</c> (a linear chain, no sort cycle), once per
|
||||
/// tick (interpolated ghost, no rollback → no Simulate filter, no IsFirstTimeFullyPredictingTick).
|
||||
///
|
||||
/// v2 boss = a real fight (operator-locked): chase the nearest living expedition player, then a telegraphed radial
|
||||
/// SLAM — the client danger cue rides the replicated <see cref="AttackWindup"/> [GhostField] (CombatFeedbackSystem
|
||||
/// draws a boss-scale ring). At/below <see cref="Tuning.BossPhase2HealthFraction"/> HP it enters phase two: faster,
|
||||
/// slams more often, and periodically summons swarmer adds. B4 (Phase 1): the boss ALSO lunges - a telegraphed
|
||||
/// gap-closer on its own cooldown when the target sits outside slam reach; LungeState.UntilTick spans the
|
||||
/// windup+travel so EnemyAISystem's IsLunging derive replicates the tell (the client suppresses the slam ring
|
||||
/// off that bit), and BossState.PendingAttack (server-only byte) tells the shared windup-elapse branch WHICH
|
||||
/// attack fires. Knockback-immune (the stamp
|
||||
/// sites skip BossState; this system also clears any residual so nothing else can shove it). Summoned adds go
|
||||
/// through <see cref="ZoneEnemySpawnUtil"/> so they carry the SAME ZoneEnemyTag/RoomTag/RegionTag stack the
|
||||
/// room-clear gate + teardown depend on (dropping one would leak adds or clear the room early). All ticks route
|
||||
/// through <c>TickUtil.NonZero</c> and compare with <see cref="NetworkTick"/> only (never raw uint).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(EnemyAISystem))]
|
||||
public partial struct BossAISystem : ISystem
|
||||
{
|
||||
EntityQuery m_Bosses;
|
||||
EntityQuery m_ZoneEnemies;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
m_Bosses = state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>(), ComponentType.ReadOnly<BossState>(), ComponentType.Exclude<Dying>());
|
||||
state.RequireForUpdate(m_Bosses);
|
||||
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>(), ComponentType.Exclude<Dying>()); // summon cap counts LIVING only (B3)
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
float dt = SystemAPI.Time.DeltaTime;
|
||||
|
||||
// Living EXPEDITION players — the boss's only valid targets. Snapshot once (stable query order).
|
||||
var playerEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var playerPositions = new NativeList<float3>(Allocator.Temp);
|
||||
foreach (var (xform, health, region, entity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
|
||||
.WithAll<PlayerTag>().WithEntityAccess())
|
||||
{
|
||||
if (health.ValueRO.Current <= 0f || region.ValueRO.Region != RegionId.Expedition)
|
||||
continue;
|
||||
playerEntities.Add(entity);
|
||||
playerPositions.Add(xform.ValueRO.Position);
|
||||
}
|
||||
|
||||
// Collide-and-slide setup (mirrors EnemyAISystem).
|
||||
bool havePhysics = SystemAPI.TryGetSingleton<PhysicsWorldSingleton>(out var physics);
|
||||
uint envMask = SystemAPI.TryGetSingleton<WorldCollisionConfig>(out var worldCol) ? worldCol.EnvironmentMask : 0u;
|
||||
uint sweepMask = envMask | worldCol.StructureMask;
|
||||
var envFilter = new CollisionFilter { BelongsTo = ~0u, CollidesWith = sweepMask, GroupIndex = 0 };
|
||||
bool sweep = havePhysics && sweepMask != 0u;
|
||||
const float SweepRadius = 0.8f; // the boss is a big body
|
||||
|
||||
int liveZone = m_ZoneEnemies.CalculateEntityCount();
|
||||
|
||||
// Summon resources (phase two): the swarmer prefab + baked transform + the current room byte.
|
||||
bool haveDirector = SystemAPI.TryGetSingletonEntity<ZoneEnemyDirector>(out var directorEntity);
|
||||
Entity swarmerPrefab = Entity.Null;
|
||||
LocalTransform swarmerBaked = default;
|
||||
if (haveDirector)
|
||||
{
|
||||
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
|
||||
if (prefabs.Length > ZoneEnemyMath.KindSwarmer)
|
||||
{
|
||||
swarmerPrefab = prefabs[ZoneEnemyMath.KindSwarmer].Prefab;
|
||||
if (swarmerPrefab != Entity.Null)
|
||||
swarmerBaked = state.EntityManager.GetComponentData<LocalTransform>(swarmerPrefab);
|
||||
}
|
||||
}
|
||||
byte roomByte = SystemAPI.TryGetSingleton<RunInfo>(out var runInfo) ? (byte)(runInfo.CurrentRoom & 0xFF) : (byte)0;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (xform, stats, health, boss, windup, knockback, lunge) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRO<Health>, RefRW<BossState>,
|
||||
RefRW<AttackWindup>, RefRW<KnockbackState>, RefRW<LungeState>>()
|
||||
.WithAll<EnemyTag, BossState>().WithNone<Dying>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
|
||||
// Knockback-immune: never recoil (A4). Zero any residual so a competing stamp can't shove the boss.
|
||||
if (knockback.ValueRO.UntilTick != 0u) knockback.ValueRW.UntilTick = 0u;
|
||||
|
||||
// Phase from the boss's own Current vs (server-side, real ×BossHealthMultiplier) Max.
|
||||
float maxHp = math.max(1f, health.ValueRO.Max);
|
||||
byte phase = health.ValueRO.Current <= maxHp * Tuning.BossPhase2HealthFraction ? (byte)2 : (byte)1;
|
||||
boss.ValueRW.Phase = phase;
|
||||
|
||||
// Target: nearest living expedition player.
|
||||
int tgt = -1; float bestSq = float.MaxValue;
|
||||
for (int i = 0; i < playerPositions.Length; i++)
|
||||
{
|
||||
float d = math.distancesq(pos, playerPositions[i]);
|
||||
if (d < bestSq) { bestSq = d; tgt = i; }
|
||||
}
|
||||
if (tgt < 0)
|
||||
continue; // no valid target -> idle (InRoom-abort handles a fully-empty expedition)
|
||||
float3 targetPos = playerPositions[tgt];
|
||||
|
||||
// Face the target (planar) at all times, incl. while telegraphing.
|
||||
float3 toTarget = targetPos - pos; toTarget.y = 0f;
|
||||
if (math.lengthsq(toTarget) > 1e-6f)
|
||||
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(toTarget), math.up());
|
||||
|
||||
// --- SLAM in progress: root (the telegraph) until it lands, then AoE all players in the ring. ---
|
||||
uint windRaw = windup.ValueRO.WindUpUntilTick;
|
||||
if (windRaw != 0u)
|
||||
{
|
||||
var wt = new NetworkTick(windRaw);
|
||||
if (!(wt.IsValid && wt.IsNewerThan(serverTick)))
|
||||
{
|
||||
// B4: the windup elapse fires whichever attack was PENDING - the shared AttackWindup field
|
||||
// alone cannot tell them apart (review-confirmed: the naive reuse slams on a lunge elapse).
|
||||
if (boss.ValueRO.PendingAttack == 1)
|
||||
{
|
||||
// Lunge commit: lock direction at travel start (the Charger contract - dodge DURING
|
||||
// travel with dash i-frames). No unique damage: arriving re-opens the slam threat.
|
||||
lunge.ValueRW.Dir = math.normalizesafe(toTarget.xz, new float2(0f, 1f));
|
||||
lunge.ValueRW.Speed = Tuning.BossLungeSpeed;
|
||||
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + Tuning.BossLungeDurationTicks);
|
||||
windup.ValueRW.WindUpUntilTick = 0u;
|
||||
continue;
|
||||
}
|
||||
float slamSq = Tuning.BossSlamRadius * Tuning.BossSlamRadius;
|
||||
for (int i = 0; i < playerEntities.Length; i++)
|
||||
{
|
||||
if (math.distancesq(pos, playerPositions[i]) > slamSq)
|
||||
continue;
|
||||
ecb.AppendToBuffer(playerEntities[i], new DamageEvent
|
||||
{
|
||||
Amount = Tuning.BossSlamDamage,
|
||||
SourceNetworkId = -1, // environment / boss, not a player
|
||||
SourceTick = TickUtil.NonZero(now),
|
||||
});
|
||||
}
|
||||
windup.ValueRW.WindUpUntilTick = 0u;
|
||||
uint baseCd = Tuning.BossSlamCooldownTicks;
|
||||
uint cd = phase == 2
|
||||
? (uint)math.max(1f, baseCd * Tuning.BossPhase2SlamCooldownMult)
|
||||
: baseCd;
|
||||
boss.ValueRW.SlamReadyTick = TickUtil.NonZero(now + cd);
|
||||
}
|
||||
continue; // rooted while winding up (the tell); rotation already written above
|
||||
}
|
||||
|
||||
// --- B4 LUNGE travel in progress: committed movement along the locked direction. Wall-stop or
|
||||
// timer ends it (the Charger contract); the replicated IsLunging bit rides LungeState.UntilTick. ---
|
||||
if (lunge.ValueRO.UntilTick != 0u)
|
||||
{
|
||||
var blt = new NetworkTick(lunge.ValueRO.UntilTick);
|
||||
if (blt.IsValid && blt.IsNewerThan(serverTick))
|
||||
{
|
||||
float3 intended = pos + new float3(lunge.ValueRO.Dir.x, 0f, lunge.ValueRO.Dir.y) * (lunge.ValueRO.Speed * dt);
|
||||
intended.y = pos.y;
|
||||
float3 moved = sweep ? EnemyMoveUtil.SweptMove(in physics, pos, intended, SweepRadius, envFilter) : intended;
|
||||
xform.ValueRW.Position = moved;
|
||||
if (math.lengthsq(lunge.ValueRO.Dir) > 1e-6f)
|
||||
xform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(lunge.ValueRO.Dir.x, 0f, lunge.ValueRO.Dir.y), math.up());
|
||||
float intendedDist = math.distance(pos.xz, intended.xz);
|
||||
float actualDist = math.distance(pos.xz, moved.xz);
|
||||
if (intendedDist > 1e-4f && actualDist < intendedDist * 0.5f)
|
||||
{
|
||||
lunge.ValueRW.UntilTick = 0u; // wall-stop -> end the travel early
|
||||
boss.ValueRW.PendingAttack = 0;
|
||||
boss.ValueRW.LungeReadyTick = TickUtil.NonZero(now + Tuning.BossLungeCooldownTicks);
|
||||
}
|
||||
continue; // committed this tick
|
||||
}
|
||||
lunge.ValueRW.UntilTick = 0u; // travel done
|
||||
boss.ValueRW.PendingAttack = 0;
|
||||
boss.ValueRW.LungeReadyTick = TickUtil.NonZero(now + Tuning.BossLungeCooldownTicks);
|
||||
}
|
||||
|
||||
// --- Chase (no active slam). ---
|
||||
float speed = stats.ValueRO.MoveSpeed * (phase == 2 ? Tuning.BossPhase2SpeedMult : 1f);
|
||||
float stopDist = stats.ValueRO.AttackRange * 0.9f;
|
||||
float3 vel = EnemyAIMath.SeekVelocity(pos, targetPos, speed, stopDist);
|
||||
float3 newPos = pos + vel * dt; newPos.y = pos.y;
|
||||
if (sweep) newPos = EnemyMoveUtil.SweptMove(in physics, pos, newPos, SweepRadius, envFilter);
|
||||
xform.ValueRW.Position = newPos;
|
||||
|
||||
// Slam gate: ready + a player inside (ring + a small lead) -> commit a telegraphed slam.
|
||||
bool slamReady = boss.ValueRO.SlamReadyTick == 0u
|
||||
|| !new NetworkTick(boss.ValueRO.SlamReadyTick).IsNewerThan(serverTick);
|
||||
float lead = Tuning.BossSlamRadius + 1.5f;
|
||||
float tgtDistSq = math.distancesq(newPos, targetPos);
|
||||
if (slamReady && tgtDistSq <= lead * lead)
|
||||
{
|
||||
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + Tuning.BossSlamWindupTicks);
|
||||
boss.ValueRW.PendingAttack = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// B4 lunge gate: target out of slam reach but within lunge range -> telegraphed gap-closer.
|
||||
// LungeState.UntilTick spans windup+travel so the IsLunging ghost bit (derived by EnemyAISystem
|
||||
// from LungeState) is ON for the whole move - the client suppresses the slam ring off that bit.
|
||||
bool lungeReady = boss.ValueRO.LungeReadyTick == 0u
|
||||
|| !new NetworkTick(boss.ValueRO.LungeReadyTick).IsNewerThan(serverTick);
|
||||
if (lungeReady
|
||||
&& tgtDistSq >= Tuning.BossLungeMinRange * Tuning.BossLungeMinRange
|
||||
&& tgtDistSq <= Tuning.BossLungeMaxRange * Tuning.BossLungeMaxRange)
|
||||
{
|
||||
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + Tuning.BossLungeWindupTicks);
|
||||
boss.ValueRW.PendingAttack = 1;
|
||||
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + Tuning.BossLungeWindupTicks + Tuning.BossLungeDurationTicks);
|
||||
}
|
||||
}
|
||||
|
||||
// Summon (phase two only): ready + under the live cap + a swarmer prefab wired.
|
||||
if (phase == 2 && swarmerPrefab != Entity.Null && liveZone < Tuning.BossSummonLiveCap)
|
||||
{
|
||||
bool summonReady = boss.ValueRO.SummonReadyTick == 0u
|
||||
|| !new NetworkTick(boss.ValueRO.SummonReadyTick).IsNewerThan(serverTick);
|
||||
if (summonReady)
|
||||
{
|
||||
int toSpawn = math.min(Tuning.BossSummonCount, Tuning.BossSummonLiveCap - liveZone);
|
||||
for (int k = 0; k < toSpawn; k++)
|
||||
{
|
||||
float3 spawnPos = EnemyAIMath.ClusterOffset(newPos, k, math.max(1, toSpawn), 2.5f);
|
||||
spawnPos.y = newPos.y;
|
||||
ZoneEnemySpawnUtil.Spawn(ecb, swarmerPrefab, in swarmerBaked, spawnPos, RegionId.Expedition, roomByte);
|
||||
liveZone++;
|
||||
}
|
||||
boss.ValueRW.SummonReadyTick = TickUtil.NonZero(now + Tuning.BossSummonCooldownTicks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
playerEntities.Dispose();
|
||||
playerPositions.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 105d73021b780c449a16ea72bcc29b69
|
||||
@@ -9,36 +9,33 @@ namespace ProjectM.Server
|
||||
/// Server receiver for <see cref="ClassSelectRequest"/> — the player picks their frame at base. Honored ONLY in
|
||||
/// Staging (frame = a between-runs choice; mid-run it would desync the fight). Resolves sender → player (the
|
||||
/// MetaSpend/ReadyToggle idiom), then applies the FULL in-place swap via <see cref="ClassSwapUtil"/> (class seeds +
|
||||
/// permanent-meta re-sync), writes FrameId / PlayerClass, re-seeds the 4-socket Spark loadout, and calls
|
||||
/// permanent-meta re-sync), writes FrameId, re-seeds the 4-socket Spark loadout, and calls
|
||||
/// <see cref="ClassSwapUtil.HealClamp"/>. Plain server group, before RunDirectorSystem (the receiver convention);
|
||||
/// requests are ALWAYS destroyed. NOT Burst-compiled (a cross-assembly blob+buffer helper on a low-frequency RPC).
|
||||
/// </summary>
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(RunDirectorSystem))]
|
||||
public partial struct ClassSelectReceiveSystem : ISystem
|
||||
{
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<ClassSelectRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(b));
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
}
|
||||
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
|
||||
// 2026-08-07 audit purge: this used to accept a frame swap only during RunInfo Lifecycle==Staging
|
||||
// (the base-phase gate). With the run FSM gone the gym accepts a swap at any time.
|
||||
const bool accept = true;
|
||||
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, e) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = e;
|
||||
|
||||
// Meta re-sync inputs (on the director/ledger ghost). dir stays Null if the catalog is absent (guarded).
|
||||
Entity dir = Entity.Null;
|
||||
bool haveMeta = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat)
|
||||
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir) && SystemAPI.HasBuffer<MetaTierState>(dir);
|
||||
// 2026-08-07 audit purge: the permanent-meta re-sync (MetaUpgradeCatalog + MetaTierState) went
|
||||
// with the meta shop; a frame swap now re-seeds only the frame stat band.
|
||||
bool haveDb = SystemAPI.TryGetSingleton<AbilityDatabase>(out var abilityDb);
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
@@ -54,13 +51,12 @@ namespace ProjectM.Server
|
||||
if (!SystemAPI.HasBuffer<AbilitySocket>(player)) continue;
|
||||
|
||||
var mods = SystemAPI.GetBuffer<StatModifier>(player);
|
||||
var metaRecord = haveMeta ? SystemAPI.GetBuffer<MetaTierState>(dir) : default;
|
||||
ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, haveMeta, metaCat, metaRecord, out byte newClass);
|
||||
ClassSwapUtil.Apply(req.ValueRO.ClassId, mods, out byte newClass);
|
||||
|
||||
if (SystemAPI.HasComponent<FrameId>(player))
|
||||
SystemAPI.SetComponent(player, new FrameId { Value = newClass });
|
||||
if (SystemAPI.HasComponent<PlayerClass>(player))
|
||||
SystemAPI.SetComponent(player, new PlayerClass { ClassId = newClass });
|
||||
|
||||
|
||||
// Re-seed the 4-socket Spark loadout for the new frame + clear its cooldowns (fires now).
|
||||
ClassTraits.FrameLoadout(newClass, out byte f0, out byte f1, out byte f2, out byte f3);
|
||||
var sockets = SystemAPI.GetBuffer<AbilitySocket>(player);
|
||||
|
||||
@@ -1,139 +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>
|
||||
/// Phase 1.7 "Blade Dash" boon (<see cref="BoonFlag.DashTrail"/>): while a player is inside its dash blink window,
|
||||
/// living enemies within <see cref="k_Radius"/> of the player take damage — one hit per enemy per dash. SERVER-ONLY
|
||||
/// (enemies are interpolated ghosts the client never predicts — mirrors the melee cleave / cone / projectile-damage
|
||||
/// pattern), inside the predicted group after <see cref="DashSystem"/> (dash state committed) and before
|
||||
/// <c>HealthApplyDamageSystem</c> (the DamageEvent drains the same tick). Enemies carry no <c>DashState</c>, so the
|
||||
/// dash-i-frame negation branch in HealthApplyDamageSystem is skipped — harmless.
|
||||
///
|
||||
/// Dedup is keyed to <see cref="DashState.StartTick"/> (which is <c>TickUtil.NonZero(now)</c> on every dash and has
|
||||
/// NO reliable clear edge on a release server): <see cref="DashTrailState.Hit"/> is cleared whenever the current
|
||||
/// StartTick differs from <see cref="DashTrailState.LastStartTick"/>. Server-only ⇒ no rollback, so persisting the
|
||||
/// accumulator across ticks is safe. A per-tick radius test (run every blink tick) approximates the swept path; the
|
||||
/// per-tick dash step (<~0.6u) is well inside the radius, so a thin enemy is not tunnelled. Hit-set overflow stops
|
||||
/// adding (a possible re-hit on a very crowded dash — accepted v1 cap).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(DashSystem))]
|
||||
[UpdateBefore(typeof(HealthApplyDamageSystem))]
|
||||
public partial struct DashTrailDamageSystem : ISystem
|
||||
{
|
||||
const float k_Radius = 1.6f; // planar hit radius around the dashing player (tunable)
|
||||
const float k_Damage = 12f; // per-enemy damage for a dash pass (tunable)
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<DashTrailState>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var nt = SystemAPI.GetSingleton<NetworkTime>();
|
||||
var serverTick = nt.ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
|
||||
// Snapshot living enemies once (positions + radii + entities), stable query order.
|
||||
var enemyEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var enemyPositions = new NativeList<float3>(Allocator.Temp);
|
||||
var enemyRadii = new NativeList<float>(Allocator.Temp);
|
||||
foreach (var (tx, hr, hp, te) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<Health>>()
|
||||
.WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess())
|
||||
{
|
||||
if (hp.ValueRO.Current <= 0f) continue;
|
||||
enemyEntities.Add(te);
|
||||
enemyPositions.Add(tx.ValueRO.Position);
|
||||
enemyRadii.Add(hr.ValueRO.Value);
|
||||
}
|
||||
|
||||
if (enemyEntities.Length == 0)
|
||||
{
|
||||
enemyEntities.Dispose(); enemyPositions.Dispose(); enemyRadii.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
uint stamp = TickUtil.NonZero(serverTick.TickIndexForValidTick);
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (xform, dash, trail, owner, fx) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<DashState>, RefRW<DashTrailState>,
|
||||
RefRO<GhostOwner>, RefRO<BoonEffects>>()
|
||||
.WithAll<PlayerTag, Simulate>())
|
||||
{
|
||||
if ((fx.ValueRO.Flags & BoonFlag.DashTrail) == 0)
|
||||
continue;
|
||||
|
||||
uint startRaw = dash.ValueRO.StartTick;
|
||||
if (startRaw == 0u)
|
||||
continue; // never dashed
|
||||
|
||||
// Inside the blink (i-frame) window [StartTick, IFrameUntilTick)?
|
||||
var startTick = new NetworkTick(startRaw);
|
||||
var untilTick = new NetworkTick(dash.ValueRO.IFrameUntilTick);
|
||||
bool dashing = startTick.IsValid && untilTick.IsValid
|
||||
&& !startTick.IsNewerThan(serverTick) && untilTick.IsNewerThan(serverTick);
|
||||
if (!dashing)
|
||||
continue;
|
||||
|
||||
// New dash → reset the per-dash hit set (StartTick changes every dash; no reliable DashState clear).
|
||||
if (trail.ValueRO.LastStartTick != startRaw)
|
||||
{
|
||||
trail.ValueRW.Hit.Clear();
|
||||
trail.ValueRW.LastStartTick = startRaw;
|
||||
}
|
||||
|
||||
float3 p = xform.ValueRO.Position;
|
||||
int ownerId = owner.ValueRO.NetworkId;
|
||||
for (int i = 0; i < enemyEntities.Length; i++)
|
||||
{
|
||||
var enemy = enemyEntities[i];
|
||||
if (HitContains(trail.ValueRO, enemy))
|
||||
continue;
|
||||
float2 d = new float2(enemyPositions[i].x - p.x, enemyPositions[i].z - p.z);
|
||||
float reach = k_Radius + enemyRadii[i];
|
||||
if (math.lengthsq(d) > reach * reach)
|
||||
continue;
|
||||
|
||||
if (trail.ValueRO.Hit.Length >= trail.ValueRO.Hit.Capacity) break; // hit-cap: never damage an enemy we can't record (else re-hit every tick)
|
||||
ecb.AppendToBuffer(enemy, new DamageEvent
|
||||
{
|
||||
Amount = k_Damage,
|
||||
SourceNetworkId = ownerId, // a real player id (legit Charger whiff-punish credit)
|
||||
SourceTick = stamp,
|
||||
});
|
||||
if (trail.ValueRO.Hit.Length < trail.ValueRO.Hit.Capacity)
|
||||
trail.ValueRW.Hit.Add(enemy);
|
||||
}
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
enemyEntities.Dispose();
|
||||
enemyPositions.Dispose();
|
||||
enemyRadii.Dispose();
|
||||
}
|
||||
|
||||
static bool HitContains(in DashTrailState trail, Entity e)
|
||||
{
|
||||
for (int i = 0; i < trail.Hit.Length; i++)
|
||||
if (trail.Hit[i] == e) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f2a00802a81103745a1d20475a3c7b7b
|
||||
@@ -27,14 +27,13 @@ namespace ProjectM.Server
|
||||
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
|
||||
public partial struct EnemyAISystem : ISystem
|
||||
{
|
||||
EntityQuery m_EnemyProjectiles;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(ComponentType.ReadOnly<EnemyTag>()));
|
||||
m_EnemyProjectiles = state.GetEntityQuery(ComponentType.ReadOnly<EnemyProjectile>());
|
||||
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -62,9 +61,12 @@ namespace ProjectM.Server
|
||||
var structureEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var structurePositions = new NativeList<float3>(Allocator.Temp);
|
||||
var structureRegions = new NativeList<byte>(Allocator.Temp);
|
||||
// Structures were deleted with the shell (2026-08-07 audit purge), so the raze-target snapshot is
|
||||
// empty. The lists stay so the aggro selection below keeps one code path; drop them when the
|
||||
// LANTERN buildables land and give enemies something to attack again.
|
||||
foreach (var (sx, sh, sr, se) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
|
||||
.WithAll<PlacedStructure>()
|
||||
.WithAll<Destructible>()
|
||||
.WithEntityAccess())
|
||||
{
|
||||
if (sh.ValueRO.Current <= 0f)
|
||||
@@ -120,14 +122,14 @@ namespace ProjectM.Server
|
||||
if (sweep)
|
||||
{
|
||||
foreach (var depenXform in SystemAPI.Query<RefRW<LocalTransform>>()
|
||||
.WithAll<EnemyTag>().WithNone<Dying, BossState>().WithNone<TargetDummyTag>())
|
||||
.WithAll<EnemyTag>().WithNone<Dying>().WithNone<TargetDummyTag>())
|
||||
depenXform.ValueRW.Position = EnemyMoveUtil.Depenetrate(in physics, depenXform.ValueRO.Position, SweepRadius, envFilter);
|
||||
}
|
||||
|
||||
foreach (var (xform, stats, cooldown, knockback, windup, region) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRO<RegionTag>>()
|
||||
.WithAll<EnemyTag>().WithNone<LungeState, SpitterState, Dying>().WithNone<TargetDummyTag>())
|
||||
.WithAll<EnemyTag>().WithNone<Dying>().WithNone<TargetDummyTag>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte huskRegion = region.ValueRO.Region;
|
||||
@@ -246,267 +248,13 @@ namespace ProjectM.Server
|
||||
}
|
||||
}
|
||||
|
||||
// --- Charger pass: a Husk variant baked with LungeState commits to a punishable fixed-direction lunge.
|
||||
// Component-presence is the discriminator; the Grunt pass above excludes these via .WithNone<LungeState>().
|
||||
// Charger feel knobs — live-tunable via TuningConfig (MC-0), guarded at the read site. Server-only
|
||||
// (clients never simulate Chargers); the >=1-tick floor avoids a degenerate instant/no-travel lunge.
|
||||
float ChargerLungeSpeed = math.max(0f, tune.ChargerLungeSpeed); // units/s while lunging
|
||||
uint ChargerLungeDurationTicks = (uint)math.max(1f, tune.ChargerLungeDurationTicks); // committed travel
|
||||
uint ChargerWindupTicks = (uint)math.max(1f, tune.ChargerWindupTicks); // readable telegraph lead
|
||||
uint ChargerWhiffStaggerTicks = (uint)math.max(1f, tune.ChargerWhiffStaggerTicks); // punish window
|
||||
uint chargerWhiffsThisTick = 0;
|
||||
foreach (var (xform, stats, cooldown, knockback, windup, lunge, region) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyAttackCooldown>,
|
||||
RefRW<KnockbackState>, RefRW<AttackWindup>, RefRW<LungeState>, RefRO<RegionTag>>()
|
||||
.WithAll<EnemyTag>().WithNone<SpitterState, BossState, Dying>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte cHuskRegion = region.ValueRO.Region;
|
||||
// --- Charger / Spitter / IsLunging passes DELETED 2026-08-07 (audit purge).
|
||||
// ChargerAuthoring, SpitterAuthoring and SwarmerAuthoring were attached to ZERO prefabs, so
|
||||
// LungeState / SpitterState / SwarmerTag were never baked and these three passes could not match a
|
||||
// single chunk at runtime — ~272 lines of Bursted code plus 734 lines of green tests certifying an
|
||||
// escalation curve that always resolved to Grunt. Recover from git if the lunge/spit behaviours are
|
||||
// wanted; the LANTERN bestiary reintroduces variety through the CreatureKit path instead.
|
||||
|
||||
// 1. Knockback wins (and cancels any in-flight lunge so Position keeps a single writer).
|
||||
var kb = knockback.ValueRO;
|
||||
if (kb.UntilTick != 0)
|
||||
{
|
||||
var kbTick = new NetworkTick(kb.UntilTick);
|
||||
if (kbTick.IsValid && kbTick.IsNewerThan(serverTick))
|
||||
{
|
||||
float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt);
|
||||
kpos.y = pos.y;
|
||||
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
|
||||
xform.ValueRW.Position = kpos;
|
||||
if (kb.Speed >= tune.StaggerKnockbackSpeed)
|
||||
{
|
||||
windup.ValueRW.WindUpUntilTick = 0; // B2 poise: only a HEAVY hit breaks the windup / committed lunge
|
||||
lunge.ValueRW.UntilTick = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
knockback.ValueRW.UntilTick = 0;
|
||||
}
|
||||
|
||||
// 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)
|
||||
continue;
|
||||
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;
|
||||
if (lg.UntilTick != 0)
|
||||
{
|
||||
var lgTick = new NetworkTick(lg.UntilTick);
|
||||
if (lgTick.IsValid && lgTick.IsNewerThan(serverTick))
|
||||
{
|
||||
float3 intended = pos + new float3(lg.Dir.x, 0f, lg.Dir.y) * (lg.Speed * dt);
|
||||
intended.y = pos.y;
|
||||
float3 moved = sweep ? SweptMove(in physics, pos, intended, SweepRadius, envFilter) : intended;
|
||||
xform.ValueRW.Position = moved;
|
||||
if (math.lengthsq(lg.Dir) > 1e-6f)
|
||||
xform.ValueRW.Rotation = quaternion.LookRotationSafe(new float3(lg.Dir.x, 0f, lg.Dir.y), math.up());
|
||||
|
||||
if (EnemyAIMath.InAttackRange(moved, cTargetPos, stats.ValueRO.AttackRange))
|
||||
{
|
||||
if (cTargetEntity != Entity.Null) ecb.AppendToBuffer(cTargetEntity, new DamageEvent
|
||||
{
|
||||
Amount = stats.ValueRO.AttackDamage,
|
||||
SourceNetworkId = -1,
|
||||
SourceTick = TickUtil.NonZero(now),
|
||||
});
|
||||
uint cdTicks = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
|
||||
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + cdTicks);
|
||||
lunge.ValueRW.UntilTick = 0; // landed -> end the lunge
|
||||
}
|
||||
else
|
||||
{
|
||||
float intendedDist = math.distance(pos.xz, intended.xz);
|
||||
float actualDist = math.distance(pos.xz, moved.xz);
|
||||
if (intendedDist > 1e-4f && actualDist < intendedDist * 0.5f)
|
||||
{
|
||||
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks);
|
||||
lunge.ValueRW.UntilTick = 0; // wall-stop whiff -> stagger (the punish window)
|
||||
chargerWhiffsThisTick++;
|
||||
lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window
|
||||
}
|
||||
}
|
||||
continue; // committed this tick
|
||||
}
|
||||
|
||||
// Timer elapsed without landing -> overshoot whiff -> stagger, then seek this tick.
|
||||
cooldown.ValueRW.NextAttackTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks);
|
||||
lunge.ValueRW.UntilTick = 0;
|
||||
chargerWhiffsThisTick++;
|
||||
lunge.ValueRW.StaggerUntilTick = TickUtil.NonZero(now + ChargerWhiffStaggerTicks); // scoreable punish window
|
||||
}
|
||||
|
||||
// 3. Seek + face (shared shape with the Grunt path). B3: a whiffed Charger is ROOTED during its
|
||||
// stagger punish window so the advertised punish reads (the player sees it stop). Facing still tracks.
|
||||
bool cStaggered = lunge.ValueRO.StaggerUntilTick != 0u
|
||||
&& new NetworkTick(lunge.ValueRO.StaggerUntilTick).IsNewerThan(serverTick);
|
||||
if (!cStaggered)
|
||||
{
|
||||
float cStop = stats.ValueRO.AttackRange * 0.9f;
|
||||
float3 cvel = EnemyAIMath.SeekVelocity(pos, cTargetPos, stats.ValueRO.MoveSpeed, cStop);
|
||||
float3 cNewPos = pos + cvel * dt; cNewPos.y = pos.y;
|
||||
if (sweep) cNewPos = SweptMove(in physics, pos, cNewPos, SweepRadius, envFilter);
|
||||
xform.ValueRW.Position = cNewPos;
|
||||
}
|
||||
float3 cToTarget = cTargetPos - pos; cToTarget.y = 0f;
|
||||
if (math.lengthsq(cToTarget) > 1e-6f)
|
||||
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(cToTarget), math.up());
|
||||
|
||||
// 4. Commit: a wind-up elapses -> LOCK the lunge direction + fire. NO cancel-on-leave-range — the
|
||||
// whole point is the commit lands even if the player dodged out of range (the punishable tell).
|
||||
uint cWindRaw = windup.ValueRO.WindUpUntilTick;
|
||||
if (cWindRaw != 0)
|
||||
{
|
||||
var cWindTick = new NetworkTick(cWindRaw);
|
||||
if (!(cWindTick.IsValid && cWindTick.IsNewerThan(serverTick)))
|
||||
{
|
||||
float3 toT = cTargetPos - pos; toT.y = 0f;
|
||||
float2 ldir = math.lengthsq(toT) > 1e-6f ? math.normalize(toT.xz) : new float2(0f, 1f);
|
||||
lunge.ValueRW.Dir = ldir;
|
||||
lunge.ValueRW.Speed = ChargerLungeSpeed;
|
||||
lunge.ValueRW.UntilTick = TickUtil.NonZero(now + ChargerLungeDurationTicks);
|
||||
windup.ValueRW.WindUpUntilTick = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
bool cInRange = EnemyAIMath.InAttackRange(pos, cTargetPos, stats.ValueRO.AttackRange);
|
||||
if (cInRange)
|
||||
{
|
||||
bool cReady = cooldown.ValueRO.NextAttackTick == 0
|
||||
|| !new NetworkTick(cooldown.ValueRO.NextAttackTick).IsNewerThan(serverTick);
|
||||
if (cReady)
|
||||
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + ChargerWindupTicks);
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- Spitter pass: a Husk variant baked with SpitterState holds a RANGED range-band and fires a
|
||||
// telegraphed, dodgeable spit. Partitioned .WithAll<SpitterState>().WithNone<LungeState>() (and the Grunt
|
||||
// pass excludes SpitterState) so a Spitter is moved by EXACTLY this pass — the sole-Position-writer rule.
|
||||
bool haveSpit = SystemAPI.TryGetSingleton<SpitterProjectilePrefab>(out var spitCfg) && spitCfg.Prefab != Entity.Null;
|
||||
int liveSpits = m_EnemyProjectiles.CalculateEntityCount();
|
||||
LocalTransform spitBakedLt = default;
|
||||
EnemyProjectile spitBakedProj = default;
|
||||
if (haveSpit)
|
||||
{
|
||||
spitBakedLt = state.EntityManager.GetComponentData<LocalTransform>(spitCfg.Prefab);
|
||||
spitBakedProj = state.EntityManager.GetComponentData<EnemyProjectile>(spitCfg.Prefab);
|
||||
}
|
||||
foreach (var (xform, stats, knockback, windup, spitter, region) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<KnockbackState>,
|
||||
RefRW<AttackWindup>, RefRW<SpitterState>, RefRO<RegionTag>>()
|
||||
.WithAll<EnemyTag, SpitterState>().WithNone<LungeState, Dying>().WithNone<TargetDummyTag>())
|
||||
{
|
||||
float3 pos = xform.ValueRO.Position;
|
||||
byte sRegion = region.ValueRO.Region;
|
||||
|
||||
// 1. Knockback overrides everything (sole Position writer preserved).
|
||||
var kb = knockback.ValueRO;
|
||||
if (kb.UntilTick != 0)
|
||||
{
|
||||
var kbTick = new NetworkTick(kb.UntilTick);
|
||||
if (kbTick.IsValid && kbTick.IsNewerThan(serverTick))
|
||||
{
|
||||
float3 kpos = pos + new float3(kb.Dir.x, 0f, kb.Dir.y) * (kb.Speed * dt);
|
||||
kpos.y = pos.y;
|
||||
if (sweep) kpos = SweptMove(in physics, pos, kpos, SweepRadius, envFilter);
|
||||
xform.ValueRW.Position = kpos;
|
||||
if (kb.Speed >= tune.StaggerKnockbackSpeed)
|
||||
windup.ValueRW.WindUpUntilTick = 0; // B2 poise: light hits nudge, only heavy interrupts
|
||||
continue;
|
||||
}
|
||||
knockback.ValueRW.UntilTick = 0;
|
||||
}
|
||||
|
||||
// 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)
|
||||
continue;
|
||||
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;
|
||||
// Once the player has closed inside CorneredRange the Spitter STANDS (no flee) + point-blanks — so a
|
||||
// melee player who commits can actually catch it (fixes the endless-kite complaint; the spit is dash-dodgeable).
|
||||
bool sCorneredMove = math.distance(pos.xz, sTargetPos.xz) <= sp.CorneredRange;
|
||||
float3 bandVel = sCorneredMove ? float3.zero
|
||||
: EnemyAIMath.BandVelocity(pos, sTargetPos, stats.ValueRO.MoveSpeed, sp.PreferredRange, sp.RangeTolerance);
|
||||
float3 sNewPos = pos + bandVel * dt; sNewPos.y = pos.y;
|
||||
if (sweep) sNewPos = SweptMove(in physics, pos, sNewPos, SweepRadius, envFilter);
|
||||
xform.ValueRW.Position = sNewPos;
|
||||
float3 sToTarget = sTargetPos - pos; sToTarget.y = 0f;
|
||||
if (math.lengthsq(sToTarget) > 1e-6f)
|
||||
xform.ValueRW.Rotation = quaternion.LookRotationSafe(math.normalize(sToTarget), math.up());
|
||||
|
||||
// 4. Telegraphed shot: commit a wind-up (the dodge window) when the shot gate is ready; on elapse,
|
||||
// spawn a spit toward the target. A cornered Spitter still fires (point-blank) — no safe corner.
|
||||
uint sWindRaw = windup.ValueRO.WindUpUntilTick;
|
||||
if (sWindRaw != 0)
|
||||
{
|
||||
var sWindTick = new NetworkTick(sWindRaw);
|
||||
if (!(sWindTick.IsValid && sWindTick.IsNewerThan(serverTick)))
|
||||
{
|
||||
float2 dir2 = math.lengthsq(sToTarget) > 1e-6f ? math.normalize(sToTarget.xz) : new float2(0f, 1f);
|
||||
if (haveSpit && liveSpits < math.max(1, spitCfg.MaxLiveProjectiles))
|
||||
{
|
||||
float3 spawnPos = pos + new float3(dir2.x, 0f, dir2.y) * 0.8f;
|
||||
spawnPos.y = pos.y;
|
||||
var spit = ecb.Instantiate(spitCfg.Prefab);
|
||||
ecb.SetComponent(spit, spitBakedLt.WithPosition(spawnPos)); // preserve baked [GhostField] Scale
|
||||
ecb.SetComponent(spit, new EnemyProjectile
|
||||
{
|
||||
Direction = dir2,
|
||||
Speed = sp.ProjectileSpeed,
|
||||
Damage = stats.ValueRO.AttackDamage,
|
||||
Range = spitBakedProj.Range,
|
||||
DistanceTravelled = 0f,
|
||||
LastStep = 0f,
|
||||
Region = sRegion,
|
||||
});
|
||||
ecb.AddComponent(spit, new RegionTag { Region = sRegion }); // relevancy (the spit prefab bakes none)
|
||||
liveSpits++;
|
||||
uint shotCd = (uint)math.max(1, stats.ValueRO.AttackCooldownTicks);
|
||||
spitter.ValueRW.NextShotTick = TickUtil.NonZero(now + shotCd);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Over the concurrent cap (or no prefab wired): soft-fail — short retry, no full cooldown burn.
|
||||
spitter.ValueRW.NextShotTick = TickUtil.NonZero(now + 8u);
|
||||
}
|
||||
windup.ValueRW.WindUpUntilTick = 0;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
bool sReady = sp.NextShotTick == 0 || !new NetworkTick(sp.NextShotTick).IsNewerThan(serverTick);
|
||||
// In-band gate (DR-041): telegraph + fire ONLY when holding the preferred band, OR when the target has
|
||||
// closed inside CorneredRange (point-blank, no retreat room). While ADVANCING from too far OR
|
||||
// RETREATING from a too-close target it must NOT fire — that IS the hold-range "reposition" question.
|
||||
float sDist = math.length(sToTarget);
|
||||
bool sInBand = math.abs(sDist - sp.PreferredRange) <= sp.RangeTolerance;
|
||||
bool sCornered = sDist <= sp.CorneredRange;
|
||||
if (sReady && (sInBand || sCornered))
|
||||
{
|
||||
uint wTicks = (uint)math.max(1, sp.WindupTicks);
|
||||
windup.ValueRW.WindUpUntilTick = TickUtil.NonZero(now + wTicks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Slice 1 (Feature D): derive the replicated IsLunging cue ONCE per tick from the end-of-tick LungeState
|
||||
// (single point, idempotent — mirrors PlayerDeathStateSystem deriving Dead from Health). .WithPresent so a
|
||||
// Charger whose bit is currently DISABLED is still visited (Entities default-excludes disabled enableables).
|
||||
foreach (var (lunge, isLunging) in
|
||||
SystemAPI.Query<RefRO<LungeState>, EnabledRefRW<IsLunging>>()
|
||||
.WithAll<EnemyTag>().WithPresent<IsLunging>().WithNone<Dying>().WithNone<TargetDummyTag>())
|
||||
{
|
||||
isLunging.ValueRW = lunge.ValueRO.UntilTick != 0u; // lunging iff a committed lunge is live this tick
|
||||
}
|
||||
|
||||
// --- Phase 1 B1: SEPARATION (soft-collision) so hordes stop interpenetrating. Lives INSIDE
|
||||
// EnemyAISystem (the sole enemy-Position writer; BossAISystem runs after and re-owns the boss).
|
||||
@@ -523,17 +271,12 @@ namespace ProjectM.Server
|
||||
foreach (var (sxf, shr, se) in SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>>()
|
||||
.WithAll<EnemyTag>().WithNone<Dying>().WithEntityAccess())
|
||||
{
|
||||
bool movable = !SystemAPI.HasComponent<BossState>(se);
|
||||
bool movable = true;
|
||||
if (movable && SystemAPI.HasComponent<KnockbackState>(se))
|
||||
{
|
||||
var k = SystemAPI.GetComponent<KnockbackState>(se);
|
||||
if (k.UntilTick != 0 && new NetworkTick(k.UntilTick).IsNewerThan(serverTick)) movable = false;
|
||||
}
|
||||
if (movable && SystemAPI.HasComponent<LungeState>(se))
|
||||
{
|
||||
var l = SystemAPI.GetComponent<LungeState>(se);
|
||||
if (l.UntilTick != 0 && new NetworkTick(l.UntilTick).IsNewerThan(serverTick)) movable = false;
|
||||
}
|
||||
sepEnt.Add(se); sepPos.Add(sxf.ValueRO.Position); sepRad.Add(shr.ValueRO.Value); sepMov.Add(movable);
|
||||
}
|
||||
float sepMaxStep = math.max(0f, tune.SeparationMaxSpeed) * dt;
|
||||
@@ -591,7 +334,7 @@ namespace ProjectM.Server
|
||||
float nudgeStep = UnstickNudgeSpeed * dt;
|
||||
foreach (var (nxform, nstats, nav, nregion, nent) in
|
||||
SystemAPI.Query<RefRW<LocalTransform>, RefRO<EnemyStats>, RefRW<EnemyNavState>, RefRO<RegionTag>>()
|
||||
.WithAll<EnemyTag>().WithNone<SpitterState, BossState, Dying>().WithNone<TargetDummyTag>().WithEntityAccess())
|
||||
.WithAll<EnemyTag>().WithNone<Dying>().WithNone<TargetDummyTag>().WithEntityAccess())
|
||||
{
|
||||
float3 npos = nxform.ValueRO.Position;
|
||||
byte nRegion = nregion.ValueRO.Region;
|
||||
@@ -602,12 +345,6 @@ namespace ProjectM.Server
|
||||
var k = SystemAPI.GetComponent<KnockbackState>(nent);
|
||||
committed |= k.UntilTick != 0 && new NetworkTick(k.UntilTick).IsNewerThan(serverTick);
|
||||
}
|
||||
if (SystemAPI.HasComponent<LungeState>(nent))
|
||||
{
|
||||
var l = SystemAPI.GetComponent<LungeState>(nent);
|
||||
committed |= (l.UntilTick != 0 && new NetworkTick(l.UntilTick).IsNewerThan(serverTick))
|
||||
|| (l.StaggerUntilTick != 0 && new NetworkTick(l.StaggerUntilTick).IsNewerThan(serverTick));
|
||||
}
|
||||
|
||||
EnemyAIMath.PickWeightedNearest(npos, playerPositions, playerRegions, structurePositions, structureRegions, nRegion, structAggro, out bool nIsStruct, out int nIdx);
|
||||
bool hasTarget = nIdx >= 0;
|
||||
@@ -669,8 +406,6 @@ namespace ProjectM.Server
|
||||
}
|
||||
}
|
||||
|
||||
if (chargerWhiffsThisTick != 0 && SystemAPI.HasSingleton<DevTelemetry>())
|
||||
SystemAPI.GetSingletonRW<DevTelemetry>().ValueRW.ChargerWhiffWindowsOpened += chargerWhiffsThisTick;
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
|
||||
|
||||
@@ -1,137 +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>
|
||||
/// MC-2 — resolves hostile Spitter projectiles against PLAYERS + STRUCTURES (never other enemies — only
|
||||
/// PlayerTag / PlacedStructure are snapshotted, so a spit can't friendly-fire the Husks), server-only in the
|
||||
/// plain <see cref="SimulationSystemGroup"/> after <see cref="EnemyProjectileMoveSystem"/> (post-move position).
|
||||
/// SWEPT planar hit-test (the DR-018 anti-tunnelling discipline): the travel segment is rebuilt from the STORED
|
||||
/// <see cref="EnemyProjectile.LastStep"/> (cur - Direction*LastStep), NEVER a fresh delta. REGION-FILTERED: a
|
||||
/// target whose <see cref="RegionTag"/>.Region != the spit's Region is skipped — relevancy hides cross-region
|
||||
/// ghosts from CLIENTS, but the server world holds base + expedition players 1000u apart, so server damage needs
|
||||
/// its own guard (the missing-filter blocker the design review caught). On a hit it appends
|
||||
/// DamageEvent{SourceNetworkId=-1, SourceTick=now} (drained the FOLLOWING tick by the predicted
|
||||
/// <c>HealthApplyDamageSystem</c> — appending from the predicted loop would double-apply on rollback; SourceTick
|
||||
/// makes the dash i-frame negation correct across the 1-tick gap, so dash-through-spit works for free) and
|
||||
/// destroys the spit at-most-once; a spit past its Range expires.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(EnemyProjectileMoveSystem))]
|
||||
public partial struct EnemyProjectileDamageSystem : ISystem
|
||||
{
|
||||
/// <summary>Extra forgiveness for the spit's own size, added to a target's hit radius.</summary>
|
||||
const float k_ProjectileRadius = 0.2f;
|
||||
|
||||
/// <summary>Hit radius used for structures, which (by design) bake no HitRadius (so player shots never hit them).</summary>
|
||||
const float k_StructureRadius = 1.0f;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<EnemyProjectile>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid) return; // mirror WaveSystem/ZoneEnemyDirectorSystem — never stamp SourceTick off an invalid tick
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
// Snapshot valid targets once (stable query order). PLAYERS carry HitRadius (PlayerAuthoring);
|
||||
// STRUCTURES deliberately do NOT (so player projectiles never friendly-fire the base) -> a constant.
|
||||
var targetEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
var targetPositions = new NativeList<float3>(Allocator.Temp);
|
||||
var targetRadii = new NativeList<float>(Allocator.Temp);
|
||||
var targetRegions = new NativeList<byte>(Allocator.Temp);
|
||||
|
||||
foreach (var (xform, hitRadius, health, region, e) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<Health>, RefRO<RegionTag>>()
|
||||
.WithAll<PlayerTag>().WithEntityAccess())
|
||||
{
|
||||
if (health.ValueRO.Current <= 0f) continue; // don't hit a corpse
|
||||
targetEntities.Add(e);
|
||||
targetPositions.Add(xform.ValueRO.Position);
|
||||
targetRadii.Add(hitRadius.ValueRO.Value);
|
||||
targetRegions.Add(region.ValueRO.Region);
|
||||
}
|
||||
foreach (var (xform, health, region, e) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Health>, RefRO<RegionTag>>()
|
||||
.WithAll<PlacedStructure>().WithEntityAccess())
|
||||
{
|
||||
if (health.ValueRO.Current <= 0f) continue; // skip a structure pending destroy this tick
|
||||
targetEntities.Add(e);
|
||||
targetPositions.Add(xform.ValueRO.Position);
|
||||
targetRadii.Add(k_StructureRadius);
|
||||
targetRegions.Add(region.ValueRO.Region);
|
||||
}
|
||||
|
||||
var destroyed = new NativeHashSet<Entity>(16, Allocator.Temp);
|
||||
foreach (var (xform, proj, projEntity) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<EnemyProjectile>>().WithEntityAccess())
|
||||
{
|
||||
float3 cur = xform.ValueRO.Position;
|
||||
float2 segEnd = new float2(cur.x, cur.z);
|
||||
float2 dir = proj.ValueRO.Direction;
|
||||
float2 segStart = segEnd - dir * proj.ValueRO.LastStep; // stored move-step, never a fresh dt
|
||||
float2 seg = segEnd - segStart;
|
||||
float segLenSq = math.lengthsq(seg);
|
||||
byte projRegion = proj.ValueRO.Region;
|
||||
|
||||
int bestIdx = -1;
|
||||
float bestT = float.MaxValue;
|
||||
for (int i = 0; i < targetEntities.Length; i++)
|
||||
{
|
||||
if (targetRegions[i] != projRegion) continue; // server-side damage region guard
|
||||
float2 tp = new float2(targetPositions[i].x, targetPositions[i].z);
|
||||
float t = segLenSq > 1e-8f
|
||||
? math.saturate(math.dot(tp - segStart, seg) / segLenSq)
|
||||
: 0f;
|
||||
float2 closest = segStart + t * seg;
|
||||
float hitDist = targetRadii[i] + k_ProjectileRadius;
|
||||
if (math.distancesq(tp, closest) <= hitDist * hitDist && t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
bestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestIdx >= 0)
|
||||
{
|
||||
ecb.AppendToBuffer(targetEntities[bestIdx], new DamageEvent
|
||||
{
|
||||
Amount = proj.ValueRO.Damage,
|
||||
SourceNetworkId = -1, // hostile environment, not a player
|
||||
SourceTick = TickUtil.NonZero(now),
|
||||
});
|
||||
if (destroyed.Add(projEntity))
|
||||
ecb.DestroyEntity(projEntity);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (proj.ValueRO.DistanceTravelled >= proj.ValueRO.Range && destroyed.Add(projEntity))
|
||||
ecb.DestroyEntity(projEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
|
||||
ecb.Dispose();
|
||||
destroyed.Dispose();
|
||||
targetEntities.Dispose();
|
||||
targetPositions.Dispose();
|
||||
targetRadii.Dispose();
|
||||
targetRegions.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f6dbd4ab9a2b154e8d7cb1796904ab6
|
||||
@@ -1,49 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — integrates hostile Spitter projectiles (<see cref="EnemyProjectile"/>) server-only in the plain
|
||||
/// <see cref="SimulationSystemGroup"/> (the spits are ownerless INTERPOLATED ghosts, not predicted — like the
|
||||
/// Husks that fire them). Advances each spit along its locked Direction at Speed*dt, accumulates
|
||||
/// DistanceTravelled, and STORES <see cref="EnemyProjectile.LastStep"/> = Speed*dt so
|
||||
/// <see cref="EnemyProjectileDamageSystem"/> can rebuild the exact swept segment it traversed this tick
|
||||
/// (cur - Direction*LastStep) WITHOUT re-reading a delta in that separate system (the DR-018 swept-tunnelling
|
||||
/// discipline — a fresh delta in the damage pass is the trap). Ordered <c>[UpdateAfter(EnemyAISystem)]</c> (the
|
||||
/// spawner) so a spit moves the same tick it is born. Writes LocalTransform (replicated via the stock variant);
|
||||
/// structural-free. dt is the server fixed step here, exactly as <see cref="EnemyAISystem"/> reads it.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(EnemyAISystem))]
|
||||
public partial struct EnemyProjectileMoveSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<EnemyProjectile>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
float dt = SystemAPI.Time.DeltaTime; // server fixed step in the plain group, same as EnemyAISystem
|
||||
foreach (var (xform, proj) in SystemAPI.Query<RefRW<LocalTransform>, RefRW<EnemyProjectile>>())
|
||||
{
|
||||
float step = proj.ValueRO.Speed * dt;
|
||||
float3 dir = new float3(proj.ValueRO.Direction.x, 0f, proj.ValueRO.Direction.y);
|
||||
float3 from = xform.ValueRO.Position;
|
||||
float3 pos = from + dir * step;
|
||||
pos.y = from.y; // hold the movement plane
|
||||
xform.ValueRW.Position = pos;
|
||||
proj.ValueRW.LastStep = step;
|
||||
proj.ValueRW.DistanceTravelled = proj.ValueRO.DistanceTravelled + step;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9acb4c22874b1fa489433644b90334db
|
||||
@@ -74,7 +74,6 @@ namespace ProjectM.Server
|
||||
bool hasDash = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<DashState>(entity);
|
||||
DashState ds = hasDash ? SystemAPI.GetComponent<DashState>(entity) : default;
|
||||
|
||||
bool isCharger = haveTick && netTime.ServerTick.IsValid && SystemAPI.HasComponent<LungeState>(entity);
|
||||
uint negatedForThisEntity = 0u;
|
||||
float total = 0f;
|
||||
int killerNetId = -1; // Phase 1.7: last player-sourced (non-negated) hit this tick → on-kill boon credit
|
||||
@@ -99,22 +98,9 @@ namespace ProjectM.Server
|
||||
total += dmg[i].Amount;
|
||||
if (dmg[i].SourceNetworkId >= 0) killerNetId = dmg[i].SourceNetworkId; // Phase 1.7 kill credit
|
||||
|
||||
// MC-1 punish scoring: a player-sourced hit (SourceNetworkId >= 0) landing inside a Charger's
|
||||
// whiff-stagger window counts ONCE — zeroing StaggerUntilTick keeps punishes:windows <= 1.
|
||||
if (isCharger && dmg[i].SourceNetworkId >= 0)
|
||||
{
|
||||
var lunge = SystemAPI.GetComponent<LungeState>(entity);
|
||||
if (lunge.StaggerUntilTick != 0u)
|
||||
{
|
||||
var stag = new NetworkTick(lunge.StaggerUntilTick);
|
||||
if (stag.IsValid && stag.IsNewerThan(netTime.ServerTick))
|
||||
{
|
||||
punishesThisTick++;
|
||||
lunge.StaggerUntilTick = 0u;
|
||||
SystemAPI.SetComponent(entity, lunge);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2026-08-07 audit purge: the Charger whiff-punish scoring lived here (a player hit landing
|
||||
// inside LungeState.StaggerUntilTick scored a punish). LungeState was never baked onto any
|
||||
// prefab, so this branch was unreachable; it went with the Charger.
|
||||
}
|
||||
dmg.Clear();
|
||||
if (negatedForThisEntity != 0u)
|
||||
@@ -156,8 +142,6 @@ namespace ProjectM.Server
|
||||
});
|
||||
if (SystemAPI.HasComponent<AttackWindup>(entity)) SystemAPI.SetComponent(entity, default(AttackWindup));
|
||||
if (SystemAPI.HasComponent<KnockbackState>(entity)) SystemAPI.SetComponent(entity, default(KnockbackState));
|
||||
if (SystemAPI.HasComponent<LungeState>(entity)) SystemAPI.SetComponent(entity, default(LungeState));
|
||||
if (SystemAPI.HasComponent<IsLunging>(entity)) SystemAPI.SetComponentEnabled<IsLunging>(entity, false);
|
||||
}
|
||||
}
|
||||
else if (SystemAPI.HasComponent<EnemyTag>(entity) || SystemAPI.HasComponent<Destructible>(entity))
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase 1.7 on-kill boons. When <c>HealthApplyDamageSystem</c> stamps an enemy <see cref="Dying"/> it records the
|
||||
/// crediting player's NetworkId; this system grants that killer their on-kill boons ONCE per corpse:
|
||||
/// <see cref="BoonFlag.Siphon"/> heals the killer (clamped to <see cref="EffectiveCharacterStats.MaxHealth"/>) and
|
||||
/// <see cref="BoonFlag.Frenzy"/> refreshes a short cooldown-reduction buff (<see cref="TimedModifierUtil.Upsert"/> —
|
||||
/// re-stamped, never stacked). Idempotent via the <see cref="Dying.Rewarded"/> latch (a value write, no edge-detect).
|
||||
///
|
||||
/// A SEPARATE system (not folded into HealthApplyDamageSystem) because healing the killer needs RW
|
||||
/// <see cref="Health"/> access, which would alias that system's <c>RefRW<Health></c> victim query. Here the
|
||||
/// only query is <c>RefRW<Dying></c> over enemies, and all killer writes go through ComponentLookup/BufferLookup
|
||||
/// on player entities — no aliasing. Server-only (no rollback) inside the predicted group, after damage application.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(PredictedSimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(HealthApplyDamageSystem))]
|
||||
public partial struct KillRewardSystem : ISystem
|
||||
{
|
||||
ComponentLookup<BoonEffects> m_Fx;
|
||||
ComponentLookup<Health> m_Health;
|
||||
ComponentLookup<EffectiveCharacterStats> m_EffChar;
|
||||
BufferLookup<StatModifier> m_Mods;
|
||||
BufferLookup<TimedModifier> m_Timed;
|
||||
|
||||
const float k_SiphonHeal = 8f; // HP restored per kill (tunable)
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_Fx = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
|
||||
m_Health = state.GetComponentLookup<Health>(isReadOnly: false);
|
||||
m_EffChar = state.GetComponentLookup<EffectiveCharacterStats>(isReadOnly: true);
|
||||
m_Mods = state.GetBufferLookup<StatModifier>(isReadOnly: false);
|
||||
m_Timed = state.GetBufferLookup<TimedModifier>(isReadOnly: false);
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<Dying>(); // only run while a fresh corpse exists
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
|
||||
m_Fx.Update(ref state);
|
||||
m_Health.Update(ref state);
|
||||
m_EffChar.Update(ref state);
|
||||
m_Mods.Update(ref state);
|
||||
m_Timed.Update(ref state);
|
||||
|
||||
// Resolve killers by NetworkId (players only).
|
||||
var playerByNet = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, e) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag>().WithEntityAccess())
|
||||
playerByNet[owner.ValueRO.NetworkId] = e;
|
||||
|
||||
uint until = TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.FrenzyDurationTicks));
|
||||
|
||||
foreach (var (dying, corpse) in SystemAPI.Query<RefRW<Dying>>().WithAll<EnemyTag>().WithEntityAccess())
|
||||
{
|
||||
if (dying.ValueRO.Rewarded != 0)
|
||||
continue;
|
||||
dying.ValueRW.Rewarded = 1; // mark ONCE — idempotent even when the killer can't be resolved
|
||||
|
||||
int killerNet = dying.ValueRO.KillerNetId;
|
||||
if (killerNet < 0 || !playerByNet.TryGetValue(killerNet, out var killer))
|
||||
continue;
|
||||
if (!m_Fx.HasComponent(killer))
|
||||
continue;
|
||||
byte flags = m_Fx[killer].Flags;
|
||||
|
||||
// Siphon: heal the killer, clamped to their effective max (no over-heal; skip a corpse killer).
|
||||
if ((flags & BoonFlag.Siphon) != 0 && m_Health.HasComponent(killer))
|
||||
{
|
||||
var h = m_Health[killer];
|
||||
if (h.Current > 0f)
|
||||
{
|
||||
float max = m_EffChar.HasComponent(killer) ? m_EffChar[killer].MaxHealth : h.Max;
|
||||
h.Current = math.min(h.Current + k_SiphonHeal, max);
|
||||
m_Health[killer] = h;
|
||||
}
|
||||
}
|
||||
|
||||
// Frenzy: refresh (never stack) a short cooldown-reduction buff on the killer.
|
||||
if ((flags & BoonFlag.Frenzy) != 0 && m_Mods.HasBuffer(killer) && m_Timed.HasBuffer(killer))
|
||||
{
|
||||
TimedModifierUtil.Upsert(m_Mods[killer], m_Timed[killer], Tuning.FrenzySourceId,
|
||||
(byte)StatTarget.CooldownTicks, (byte)ModOp.PercentMult, Tuning.FrenzyCooldownMult, until);
|
||||
}
|
||||
}
|
||||
|
||||
playerByNet.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 43348399863cc454a8752cce54cc329d
|
||||
@@ -1,78 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server receiver for <see cref="PrepPurchaseRequest"/> — the base PREP-LOADOUT spend (DR-046). Modeled on
|
||||
/// MetaSpendSystem: Staging-only, resolve sender → player, in-loop against the LIVE ledger (the DR-014 atomicity
|
||||
/// idiom — <see cref="StorageMath.TotalOf"/> pre-check BEFORE <see cref="StorageMath.Withdraw"/>, since Withdraw
|
||||
/// CLAMPS and never rejects). A purchase appends ONE run-scoped <see cref="StatModifier"/> in the prep band
|
||||
/// (<see cref="Tuning.PrepSourceIdBase"/> + option id) on the BUYER only (prep is personal). "Once per run" needs
|
||||
/// NO separate latch: the SourceId's PRESENCE is the gate, and RunDirectorSystem strips the band on Returning, so
|
||||
/// it re-buys next run (finding #7 — latch lifetime == the band). Plain server group, before RunDirectorSystem;
|
||||
/// requests ALWAYS destroyed. NOT Burst-compiled (managed PrepCatalog table + low frequency).
|
||||
/// </summary>
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(RunDirectorSystem))]
|
||||
public partial struct PrepPurchaseSystem : ISystem
|
||||
{
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<PrepPurchaseRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(b));
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
state.RequireForUpdate<ResourceLedger>();
|
||||
}
|
||||
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
|
||||
var director = SystemAPI.GetSingletonEntity<ResourceLedger>();
|
||||
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, e) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, StatModifier>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = e;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (receive, req, reqEntity) in
|
||||
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<PrepPurchaseRequest>>().WithEntityAccess())
|
||||
{
|
||||
ecb.DestroyEntity(reqEntity); // ALWAYS consumed
|
||||
if (!accept) continue;
|
||||
|
||||
var conn = receive.ValueRO.SourceConnection;
|
||||
if (!PlayerResolve.TryResolve(ref state, playerByConn, conn, out var buyer))
|
||||
continue;
|
||||
if (!PrepCatalog.TryGet(req.ValueRO.OptionId, out var row)) continue; // unknown id -> drop
|
||||
|
||||
uint sourceId = Tuning.PrepSourceIdBase + row.Id;
|
||||
var mods = SystemAPI.GetBuffer<StatModifier>(buyer);
|
||||
bool already = false;
|
||||
for (int m = 0; m < mods.Length; m++)
|
||||
if (mods[m].SourceId == sourceId) { already = true; break; } // once per run (band stripped on Returning)
|
||||
if (already) continue;
|
||||
|
||||
// LIVE in-loop ledger check + atomic withdraw (a same-tick second buy on barely-enough can't both pass).
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(director);
|
||||
if (StorageMath.TotalOf(ledger, row.CostResId) < row.Cost) continue; // pre-check: Withdraw CLAMPS
|
||||
StorageMath.Withdraw(ledger, row.CostResId, row.Cost);
|
||||
|
||||
mods.Add(new StatModifier
|
||||
{
|
||||
Target = row.Target,
|
||||
Op = row.Op,
|
||||
Value = row.Value,
|
||||
SourceId = sourceId,
|
||||
});
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
playerByConn.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f052eb701594a3a42ba83e524dd2d28b
|
||||
@@ -52,9 +52,8 @@ namespace ProjectM.Server
|
||||
/// <summary>RW lookup to stamp the server-only homing ReelState on a Reel-flagged (HookPull) hit — the Harpooner reel.</summary>
|
||||
ComponentLookup<ReelState> m_ReelLookup;
|
||||
|
||||
/// <summary>Read-only lookup so a BOSS (BossState) is skipped by the knockback stamp — the boss is
|
||||
/// <summary>Knockback stamp lookup. The former BossState immunity gate went with the boss purge
|
||||
/// knockback-immune (A4) so a solo player can't perma-stunlock it out of its slam wind-ups.</summary>
|
||||
ComponentLookup<BossState> m_BossLookup;
|
||||
|
||||
/// <summary>RW lookup for the per-projectile Phase-1.7 pierce/chain/pull state + re-hit set.</summary>
|
||||
ComponentLookup<ProjectileEffectState> m_FxLookup;
|
||||
@@ -77,7 +76,7 @@ namespace ProjectM.Server
|
||||
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(isReadOnly: true);
|
||||
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
||||
m_ReelLookup = state.GetComponentLookup<ReelState>(isReadOnly: false);
|
||||
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
||||
|
||||
m_FxLookup = state.GetComponentLookup<ProjectileEffectState>(isReadOnly: false);
|
||||
|
||||
// No projectiles → nothing to expire or hit-test; skip the tick (and its allocations) entirely.
|
||||
@@ -90,7 +89,7 @@ namespace ProjectM.Server
|
||||
m_GhostOwnerLookup.Update(ref state);
|
||||
m_KnockbackLookup.Update(ref state);
|
||||
m_ReelLookup.Update(ref state);
|
||||
m_BossLookup.Update(ref state);
|
||||
|
||||
m_FxLookup.Update(ref state);
|
||||
|
||||
bool haveTick = SystemAPI.TryGetSingleton<NetworkTime>(out var nt);
|
||||
@@ -178,7 +177,7 @@ namespace ProjectM.Server
|
||||
|
||||
// Knockback / REEL. Reel (HookPull) stamps the HOMING ReelState (ReelSystem re-aims toward the
|
||||
// caster's live position each tick); otherwise the classic frozen knockback (PULL flips toward the shooter).
|
||||
if (haveTick && m_KnockbackLookup.HasComponent(hitTarget) && !m_BossLookup.HasComponent(hitTarget))
|
||||
if (haveTick && m_KnockbackLookup.HasComponent(hitTarget))
|
||||
{
|
||||
bool reel = hasFx && (fx.Flags & ProjectileEffectFlag.Reel) != 0;
|
||||
if (reel && m_ReelLookup.HasComponent(hitTarget))
|
||||
|
||||
@@ -1,214 +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>
|
||||
/// Server-only per-ROOM enemy director — the Step-6 successor of the presence-keyed <c>ZoneEnemyDirectorSystem</c>.
|
||||
/// While the run FSM has a room active (<see cref="RunInfo.Lifecycle"/> == InRoom) it seeds ONE wave per
|
||||
/// <see cref="RunRuntime.RoomEpoch"/> (int-equality reseed) sized by <see cref="ZoneEnemyMath.WaveSlots"/> indexed
|
||||
/// on the room's <see cref="RoomPlan.DifficultyEpoch"/> (deeper rooms + Elite/Boss types skew heavier — the
|
||||
/// grounded MC-2 mix bands are reused verbatim), drip-spawned one SLOT per cadence at the deterministic ring
|
||||
/// around <see cref="RegionMath.ExpeditionRoomOrigin"/>(base, ActiveSubSlot), under the same
|
||||
/// <see cref="ZoneEnemyDirector.MaxAlive"/> "spawn-the-pack-only-if-it-fits-else-wait" relevancy guard. A
|
||||
/// <see cref="RoomTypeId.Boss"/> room spawns ONE beefed boss instead (health × <see cref="Tuning.BossHealthMultiplier"/>,
|
||||
/// scale × <see cref="Tuning.BossScaleMultiplier"/> — v1's boss is a scaled Charger). Every spawn keeps the full
|
||||
/// stack — EnemyTag + RegionTag{Expedition} + <see cref="ZoneEnemyTag"/> — PLUS <see cref="RoomTag"/>{room} (the
|
||||
/// teardown contract). Scale preserved via <c>baked.WithPosition</c>.
|
||||
///
|
||||
/// The room CLEAR edge surfaces ONLY through the replicated <see cref="ExpeditionObjective"/>.State == Cleared
|
||||
/// (wave fully spawned AND zero alive, latched per seeded epoch) — written FIRST, ABOVE every early-return
|
||||
/// (snapshot-above-early-return) so the HUD never freezes; RunDirectorSystem consumes it one-tick-late (Step 7).
|
||||
/// The old CycleRuntime.ClearedThisEpoch write is gone (the C4 collapse), and the old base-siege Calm gate is
|
||||
/// deliberately DROPPED — a home retaliation siege no longer freezes a live sortie (the DR-042 latent gap).
|
||||
///
|
||||
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> ONLY — reads the freshly-advanced room state same-tick.
|
||||
/// NO CyclePhase edge may ever return to the room chain (Play-only sort-cycle, invisible to EditMode).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(RunDirectorSystem))]
|
||||
public partial struct RoomEnemyDirectorSystem : ISystem
|
||||
{
|
||||
EntityQuery m_ZoneEnemies;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
state.RequireForUpdate<RunRuntime>();
|
||||
state.RequireForUpdate<ZoneEnemyDirector>();
|
||||
m_ZoneEnemies = state.GetEntityQuery(ComponentType.ReadOnly<ZoneEnemyTag>(), ComponentType.Exclude<Dying>()); // room clear + MaxAlive fit count LIVING only (B3) - corpses neither hold the room open nor crowd out spawns
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
var runEntity = SystemAPI.GetSingletonEntity<RunInfo>();
|
||||
var info = SystemAPI.GetComponent<RunInfo>(runEntity);
|
||||
var run = SystemAPI.GetComponent<RunRuntime>(runEntity);
|
||||
bool roomActive = info.Lifecycle == RunLifecycle.InRoom;
|
||||
|
||||
var directorEntity = SystemAPI.GetSingletonEntity<ZoneEnemyDirector>();
|
||||
var dir = SystemAPI.GetComponent<ZoneEnemyDirector>(directorEntity);
|
||||
var zs = SystemAPI.GetComponent<ZoneEnemyState>(directorEntity);
|
||||
|
||||
int aliveZone = m_ZoneEnemies.CalculateEntityCount();
|
||||
|
||||
// REPLICATED objective summary FIRST, above every early-return (snapshot-above-early-return): the HUD
|
||||
// readout must never freeze stale. Cleared latches only for a wave seeded FOR THIS RoomEpoch.
|
||||
if (SystemAPI.HasComponent<ExpeditionObjective>(runEntity))
|
||||
{
|
||||
byte objState;
|
||||
short objRemaining;
|
||||
if (roomActive && (aliveZone > 0 || zs.RemainingToSpawn > 0))
|
||||
{
|
||||
objState = ExpeditionObjectiveState.Active;
|
||||
objRemaining = (short)math.min(aliveZone + zs.RemainingToSpawn, short.MaxValue);
|
||||
}
|
||||
else if (roomActive && zs.SeededEpoch == run.RoomEpoch && zs.RemainingToSpawn == 0 && aliveZone == 0)
|
||||
{
|
||||
objState = ExpeditionObjectiveState.Cleared; // fully spawned + fully dead -> advance-ready
|
||||
objRemaining = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
objState = ExpeditionObjectiveState.Idle;
|
||||
objRemaining = 0;
|
||||
}
|
||||
SystemAPI.SetComponent(runEntity, new ExpeditionObjective { State = objState, Remaining = objRemaining });
|
||||
}
|
||||
|
||||
if (!roomActive)
|
||||
return;
|
||||
|
||||
var prefabs = SystemAPI.GetBuffer<ZoneEnemyPrefab>(directorEntity);
|
||||
if (prefabs.Length == 0)
|
||||
return;
|
||||
|
||||
// Single plan authority: the node RunDirector published — never re-derived here.
|
||||
var map = RunMapMath.Generate(run.RunSeed);
|
||||
var node = map.NodeAt(run.CurrentNodeId);
|
||||
var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount);
|
||||
byte room = (byte)(info.CurrentRoom & 0xFF);
|
||||
bool bossRoom = plan.RoomType == RoomTypeId.Boss;
|
||||
|
||||
var bands = new MixBands
|
||||
{
|
||||
GruntBase = dir.GruntsPerWave,
|
||||
ChargerBase = dir.ChargersPerWave,
|
||||
SpitterBase = dir.SpitterBase,
|
||||
SwarmerSlotBase = dir.SwarmerSlotBase,
|
||||
ChargerPerEpoch = dir.ChargerPerEpoch,
|
||||
SpitterPerEpoch = dir.SpitterPerEpoch,
|
||||
SwarmerSlotPerEpoch = dir.SwarmerSlotPerEpoch,
|
||||
SwarmerPackPerEpoch = dir.SwarmerPackPerEpoch,
|
||||
};
|
||||
|
||||
// (Re)seed once per ROOM (its OWN counter, in SLOTS; a swarmer slot is one pack; a boss room is 1 slot).
|
||||
if (zs.SeededEpoch != run.RoomEpoch)
|
||||
{
|
||||
zs.SeededEpoch = run.RoomEpoch;
|
||||
zs.SpawnCounter = 0;
|
||||
zs.RemainingToSpawn = bossRoom ? 1 : ZoneEnemyMath.WaveSlots(plan.DifficultyEpoch, bands);
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now + Tuning.RoomEntryGraceTicks); // landing grace — let the party orient
|
||||
}
|
||||
|
||||
if (zs.RemainingToSpawn > 0)
|
||||
{
|
||||
bool dueNow = zs.NextSpawnTick == 0 || !new NetworkTick(zs.NextSpawnTick).IsNewerThan(serverTick);
|
||||
if (dueNow)
|
||||
{
|
||||
int slot = (int)zs.SpawnCounter;
|
||||
byte kind = bossRoom ? ZoneEnemyMath.KindCharger
|
||||
: ZoneEnemyMath.KindForSlot(plan.DifficultyEpoch, slot, bands);
|
||||
int packSize = !bossRoom && kind == ZoneEnemyMath.KindSwarmer
|
||||
? ZoneEnemyMath.PackSizeForSlot(plan.DifficultyEpoch, slot, bands, dir.SwarmerPackSize) : 1;
|
||||
|
||||
// MaxAlive counts ENTITIES; spawn the whole pack only if it fits (else WAIT — keep the slot).
|
||||
if (aliveZone + packSize <= math.max(1, dir.MaxAlive))
|
||||
{
|
||||
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
baseCenter = BaseGridMath.PlotCenter(anchor);
|
||||
float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
|
||||
float3 center = bossRoom
|
||||
? origin + new float3(0f, 0f, 12f) // the boss anchors the room center
|
||||
: EnemyAIMath.RingPosition(origin, slot, math.max(1, dir.RingSlots), dir.RingRadius);
|
||||
center.y = origin.y;
|
||||
|
||||
int prefabIdx = kind;
|
||||
if (prefabIdx >= prefabs.Length) prefabIdx = 0; // 4-entry buffer expected; clamp defensively
|
||||
var prefab = prefabs[prefabIdx].Prefab;
|
||||
var baked = state.EntityManager.GetComponentData<LocalTransform>(prefab);
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
for (int k = 0; k < packSize; k++)
|
||||
{
|
||||
float3 pos = packSize > 1
|
||||
? EnemyAIMath.ClusterOffset(center, k, packSize, dir.ClusterTightRadius) : center;
|
||||
pos.y = origin.y;
|
||||
var enemy = ZoneEnemySpawnUtil.Spawn(ecb, prefab, in baked, pos, RegionId.Expedition, room);
|
||||
if (bossRoom)
|
||||
{
|
||||
// Boss = a scaled Charger given a real kit by BossAISystem. Scale the visual AND the
|
||||
// hitbox/reach (so hits register on the big model + its reach matches), multiply Health,
|
||||
// and tag BossState (server-only discriminator) so BossAISystem alone drives it.
|
||||
var bxform = baked.WithPosition(pos);
|
||||
bxform.Scale = baked.Scale * Tuning.BossScaleMultiplier;
|
||||
ecb.SetComponent(enemy, bxform);
|
||||
if (SystemAPI.HasComponent<Health>(prefab))
|
||||
{
|
||||
// B5: party-size HP scaling by LIVING EXPEDITION players at spawn (NOT the
|
||||
// RunParticipant count - dead-respawned members keep the tag while parked at
|
||||
// base). Health.Max is a [GhostField] since DR-046, so the scaled max replicates.
|
||||
int livingParty = 0;
|
||||
foreach (var (pHealth, pRegion) in SystemAPI.Query<RefRO<Health>, RefRO<RegionTag>>().WithAll<PlayerTag>())
|
||||
if (pHealth.ValueRO.Current > 0f && pRegion.ValueRO.Region == RegionId.Expedition) livingParty++;
|
||||
float partyScale = 1f + Tuning.BossHealthPerExtraPlayer * math.max(0, livingParty - 1);
|
||||
var hp = SystemAPI.GetComponent<Health>(prefab);
|
||||
hp.Current *= Tuning.BossHealthMultiplier * partyScale;
|
||||
hp.Max *= Tuning.BossHealthMultiplier * partyScale;
|
||||
ecb.SetComponent(enemy, hp);
|
||||
}
|
||||
if (SystemAPI.HasComponent<HitRadius>(prefab))
|
||||
{
|
||||
var hr = SystemAPI.GetComponent<HitRadius>(prefab);
|
||||
hr.Value *= Tuning.BossScaleMultiplier;
|
||||
ecb.SetComponent(enemy, hr);
|
||||
}
|
||||
if (SystemAPI.HasComponent<EnemyStats>(prefab))
|
||||
{
|
||||
var es = SystemAPI.GetComponent<EnemyStats>(prefab);
|
||||
es.AttackRange *= Tuning.BossScaleMultiplier;
|
||||
ecb.SetComponent(enemy, es);
|
||||
}
|
||||
ecb.AddComponent(enemy, new BossState { Phase = 1 });
|
||||
}
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
|
||||
zs.SpawnCounter += 1; // ONE slot consumed even for a pack
|
||||
zs.RemainingToSpawn -= 1;
|
||||
zs.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, dir.SpawnIntervalTicks));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SystemAPI.SetComponent(directorEntity, zs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3204b510b450f384a93bd49902c65721
|
||||
@@ -48,20 +48,9 @@ namespace ProjectM.Server
|
||||
|
||||
var wave = SystemAPI.GetComponent<WaveState>(directorEntity);
|
||||
|
||||
// MC-2 fork-4a: the base siege adopts the 4-type weighted mix (BaseCount = the Grunt base). The size
|
||||
// curve becomes WaveSlots(wave, bands) — a deliberate, operator-approved redefinition; MaxAlive is the
|
||||
// mandatory cap so spitter spits + swarmer packs can't spike the relevancy loop during the END-game climax.
|
||||
var bands = new MixBands
|
||||
{
|
||||
GruntBase = director.BaseCount,
|
||||
ChargerBase = director.ChargerBase,
|
||||
SpitterBase = director.SpitterBase,
|
||||
SwarmerSlotBase = director.SwarmerSlotBase,
|
||||
ChargerPerEpoch = director.ChargerPerEpoch,
|
||||
SpitterPerEpoch = director.SpitterPerEpoch,
|
||||
SwarmerSlotPerEpoch = director.SwarmerSlotPerEpoch,
|
||||
SwarmerPackPerEpoch = director.SwarmerPackPerEpoch,
|
||||
};
|
||||
// 2026-08-07 audit purge: the 4-type weighted mix (MixBands) is gone. Its Charger/Spitter/Swarmer
|
||||
// authoring was on ZERO prefabs, so every weighted slot resolved to Grunt at runtime anyway. MaxAlive
|
||||
// stays the mandatory cap on the relevancy loop.
|
||||
|
||||
// Ring centre on the base plot when present.
|
||||
float3 center = new float3(0f, 1f, 0f);
|
||||
@@ -77,7 +66,7 @@ namespace ProjectM.Server
|
||||
{
|
||||
// Start the next (bigger) wave.
|
||||
wave.WaveNumber += 1;
|
||||
wave.RemainingToSpawn = ZoneEnemyMath.WaveSlots(wave.WaveNumber, bands);
|
||||
wave.RemainingToSpawn = ZoneEnemyMath.WaveSize(wave.WaveNumber, director.BaseCount);
|
||||
wave.Phase = WavePhase.Spawning;
|
||||
wave.NextActionTick = TickUtil.NonZero(now); // spawn the first Husk this tick
|
||||
}
|
||||
@@ -89,9 +78,8 @@ namespace ProjectM.Server
|
||||
if (dueNow)
|
||||
{
|
||||
int slots = math.max(1, director.RingSlots);
|
||||
byte kind = ZoneEnemyMath.KindForSlot(wave.WaveNumber, wave.SpawnCounter, bands);
|
||||
int packSize = kind == ZoneEnemyMath.KindSwarmer
|
||||
? ZoneEnemyMath.PackSizeForSlot(wave.WaveNumber, wave.SpawnCounter, bands, director.SwarmerPackSize) : 1;
|
||||
const byte kind = ZoneEnemyMath.KindGrunt;
|
||||
const int packSize = 1;
|
||||
|
||||
// Live BASE husks for the entity cap (expedition zone enemies are EnemyTag too -> excluded).
|
||||
int aliveBase = 0;
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace ProjectM.Server
|
||||
ecb.SetComponent(enemy, baked.WithPosition(pos)); // preserve the baked [GhostField] Scale
|
||||
ecb.AddComponent(enemy, new RegionTag { Region = region });
|
||||
ecb.AddComponent<ZoneEnemyTag>(enemy);
|
||||
ecb.AddComponent(enemy, new RoomTag { Room = room });
|
||||
|
||||
return enemy;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ namespace ProjectM.Server
|
||||
const float k_VortexDeadzoneSq = 0.25f; // don't re-aim (or NaN) an enemy already at the zone centre
|
||||
|
||||
ComponentLookup<KnockbackState> m_KnockbackLookup;
|
||||
ComponentLookup<BossState> m_BossLookup;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
@@ -38,7 +37,7 @@ namespace ProjectM.Server
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<ZoneEffect>();
|
||||
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
||||
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
||||
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
@@ -50,7 +49,6 @@ namespace ProjectM.Server
|
||||
uint stamp = TickUtil.NonZero(now);
|
||||
uint reschedule = TickUtil.NonZero(now + ZoneEffect.PulsePeriodTicks);
|
||||
m_KnockbackLookup.Update(ref state);
|
||||
m_BossLookup.Update(ref state);
|
||||
|
||||
// Living enemies once this tick (entities + positions; stable query order).
|
||||
var enemyEntities = new NativeList<Entity>(Allocator.Temp);
|
||||
@@ -91,7 +89,7 @@ namespace ProjectM.Server
|
||||
float d2 = math.lengthsq(to);
|
||||
if (d2 > radiusSq || d2 <= k_VortexDeadzoneSq) continue;
|
||||
var e = enemyEntities[i];
|
||||
if (!m_KnockbackLookup.HasComponent(e) || m_BossLookup.HasComponent(e)) continue;
|
||||
if (!m_KnockbackLookup.HasComponent(e)) continue;
|
||||
m_KnockbackLookup[e] = new KnockbackState
|
||||
{
|
||||
Dir = math.normalize(to),
|
||||
|
||||
@@ -20,7 +20,6 @@ namespace ProjectM.Server
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct GoInGameServerSystem : ISystem
|
||||
{
|
||||
bool _warnedMetaBlocked; // one-shot: a mis-authored subscene must not silently block spawns forever
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
@@ -44,18 +43,9 @@ namespace ProjectM.Server
|
||||
// block spawns forever. GymTag switches to the clean gym path (no meta seeding; a default Spark socket
|
||||
// loadout below instead). The class seeds still apply (harmless: AbilityFireSystem reads sockets).
|
||||
bool isGym = SystemAPI.HasSingleton<GymTag>();
|
||||
MetaUpgradeCatalog metaCatalog = default;
|
||||
DynamicBuffer<MetaTierState> metaRecord = default;
|
||||
if (!isGym && (!SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out metaCatalog) || !metaCatalog.Value.IsCreated
|
||||
|| !SystemAPI.TryGetSingletonBuffer<MetaTierState>(out metaRecord, true)))
|
||||
{
|
||||
if (!_warnedMetaBlocked)
|
||||
{
|
||||
UnityEngine.Debug.LogWarning("GoInGameServerSystem: player spawn waiting on the meta catalog/director (a mis-authored subscene would block spawns forever).");
|
||||
_warnedMetaBlocked = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 2026-08-07 audit purge: spawning used to block until the MetaUpgradeCatalog + MetaTierState buffer
|
||||
// were present (the audit's M12 — a missing catalog stranded the GoInGame RPC and no player ever
|
||||
// spawned). Both are deleted, so that whole gate and its one-shot warning are gone with them.
|
||||
|
||||
var spawner = SystemAPI.GetSingleton<PlayerSpawner>();
|
||||
|
||||
@@ -87,7 +77,9 @@ namespace ProjectM.Server
|
||||
ClassTraits.AppendSeeds(classId, player, ecb);
|
||||
// Expedition redesign: the server-only class anchor the meta systems key on (born-correct meta
|
||||
// seeding at Step 12a + per-class spend at Step 13 resolve the tier record through this).
|
||||
ecb.AddComponent(player, new PlayerClass { ClassId = classId });
|
||||
// 2026-08-07 audit purge: PlayerClass was a second, server-only copy of the same byte FrameId
|
||||
// already replicates (audit finding M5). It existed so the meta shop could key on it; the meta
|
||||
// shop is gone, so FrameId is now the single frame identity.
|
||||
ecb.AddComponent(player, new FrameId { Value = classId }); // Add (not Set): baked on the real player; absent on the minimal test prefab // replicated frame/class signal
|
||||
// Per-frame default Spark loadout on keys 1-4 (UNCONDITIONAL since the legacy path died — without
|
||||
// this a non-gym spawn would have four empty sockets and no abilities at all).
|
||||
@@ -97,32 +89,9 @@ namespace ProjectM.Server
|
||||
sockets.Add(new AbilitySocket { SparkId = f1 });
|
||||
sockets.Add(new AbilitySocket { SparkId = f2 });
|
||||
sockets.Add(new AbilitySocket { SparkId = f3 });
|
||||
// Step 12a: born-correct PERMANENT meta seeding — replay this class's persisted tiers as
|
||||
// meta-band StatModifiers on the just-instantiated player (same ECB as Instantiate, the
|
||||
// ClassTraits idiom). Skip tier 0 / unknown ids (preserve-don't-crash); CLAMP a saved tier above a
|
||||
// rebalanced MaxTier (D-F5). Class gate via BoonMath.MaskFor (ClassId is the normalized
|
||||
// FrameKind 2/3 — a raw 1<<ClassId would compute bits 2/3 and silently skip everything).
|
||||
if (!isGym)
|
||||
{
|
||||
ref var metaPool = ref metaCatalog.Value.Value;
|
||||
byte classBit = BoonMath.MaskFor(classId);
|
||||
for (int m = 0; m < metaRecord.Length; m++)
|
||||
{
|
||||
if (metaRecord[m].ClassId != classId || metaRecord[m].Tier == 0) continue;
|
||||
int defIdx = MetaMath.FindDef(ref metaPool, metaRecord[m].UpgradeId);
|
||||
if (defIdx < 0) continue; // unknown id (catalog drift) — preserved on disk, skipped live
|
||||
if ((metaPool.Defs[defIdx].ClassMask & classBit) == 0) continue;
|
||||
byte tier = metaRecord[m].Tier < metaPool.Defs[defIdx].MaxTier
|
||||
? metaRecord[m].Tier : metaPool.Defs[defIdx].MaxTier;
|
||||
ecb.AppendToBuffer(player, new StatModifier
|
||||
{
|
||||
Target = metaPool.Defs[defIdx].Target,
|
||||
Op = metaPool.Defs[defIdx].Op,
|
||||
Value = metaPool.Defs[defIdx].ValuePerTier * tier,
|
||||
SourceId = Tuning.MetaSourceIdBase + metaRecord[m].UpgradeId,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 2026-08-07 audit purge: born-correct PERMANENT meta seeding replayed each frame's persisted
|
||||
// upgrade tiers as meta-band StatModifiers. The meta shop is deleted, so a spawn now carries only
|
||||
// the frame's own stat band (ClassTraits.AppendSeeds above).
|
||||
|
||||
// Auto-despawn the player when its owning connection is removed.
|
||||
ecb.AppendToBuffer(connection, new LinkedEntityGroup { Value = player });
|
||||
|
||||
@@ -152,18 +152,14 @@ namespace ProjectM.Server
|
||||
&& SystemAPI.HasBuffer<StatModifier>(sender))
|
||||
{
|
||||
var classMods = SystemAPI.GetBuffer<StatModifier>(sender);
|
||||
Entity dir2 = Entity.Null;
|
||||
bool haveMeta2 = SystemAPI.TryGetSingleton<MetaUpgradeCatalog>(out var metaCat2)
|
||||
&& SystemAPI.TryGetSingletonEntity<ResourceLedger>(out dir2) && SystemAPI.HasBuffer<MetaTierState>(dir2);
|
||||
var metaRec2 = haveMeta2 ? SystemAPI.GetBuffer<MetaTierState>(dir2) : default;
|
||||
// DR-046: the FULL swap (class seeds + meta re-sync) lives in the shared ClassSwapUtil,
|
||||
// used by BOTH this dev path and the base ClassSelectReceiveSystem so they cannot drift.
|
||||
ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, haveMeta2, metaCat2, metaRec2,
|
||||
out byte swNewClass);
|
||||
// 2026-08-07 audit purge: the meta re-sync half of the swap went with the meta shop;
|
||||
// ClassSwapUtil now re-seeds only the frame stat band. Shared with the player-facing
|
||||
// ClassSelectReceiveSystem so the two paths cannot drift.
|
||||
ClassSwapUtil.Apply((byte)cmd.ArgA, classMods, out byte swNewClass);
|
||||
if (SystemAPI.HasComponent<FrameId>(sender))
|
||||
SystemAPI.SetComponent(sender, new FrameId { Value = swNewClass });
|
||||
if (SystemAPI.HasComponent<PlayerClass>(sender))
|
||||
SystemAPI.SetComponent(sender, new PlayerClass { ClassId = swNewClass });
|
||||
|
||||
|
||||
ClassTraits.FrameLoadout(swNewClass, out byte sf0, out byte sf1, out byte sf2, out byte sf3);
|
||||
var swSockets = SystemAPI.GetBuffer<AbilitySocket>(sender);
|
||||
swSockets.Clear();
|
||||
|
||||
@@ -56,7 +56,7 @@ namespace ProjectM.Server
|
||||
var prefQ = EntityManager.CreateEntityQuery(new EntityQueryDesc
|
||||
{
|
||||
All = new ComponentType[] { typeof(EnemyTag), typeof(Prefab) },
|
||||
None = new ComponentType[] { typeof(LungeState), typeof(SpitterState) },
|
||||
|
||||
Options = EntityQueryOptions.IncludePrefab,
|
||||
});
|
||||
var prefabs = prefQ.ToEntityArray(Allocator.Temp);
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative equipment handler (<see cref="EquipRequest"/> / <see cref="UnequipRequest"/> RPCs).
|
||||
/// Resolves the sender's player (SourceConnection -> NetworkId -> GhostOwner, the AbilityUpgradeSystem /
|
||||
/// InventoryDepositSystem owner-map idiom) and applies the change IN-PLACE: moves the item between the
|
||||
/// personal <see cref="InventorySlot"/> bag and the <see cref="EquipmentSlot"/> loadout (buffer index = slot),
|
||||
/// and adds/strips the item's inline stat mods as <see cref="StatModifier"/>s tagged by a
|
||||
/// per-slot SourceId (<c>Tuning.EquipSourceIdBase + slot</c>), stripped TARGET-AGNOSTICALLY via
|
||||
/// <see cref="TimedModifierUtil.RemoveBySourceId"/>. (LANTERN purge: weapons are stat-sticks — the old
|
||||
/// weapon->ability grant is deleted; abilities live in the 4-socket Spark loadout.)
|
||||
///
|
||||
/// Effects are EVENT-DRIVEN (applied once here): StatModifier is a [GhostField] buffer re-folded by the
|
||||
/// predicted StatRecomputeSystem every tick and replicated to the owner, so the swap is prediction-correct
|
||||
/// and survives respawn (the entity persists).
|
||||
/// Atomicity: an equip into an occupied slot verifies the bag can hold the swapped-out item BEFORE any
|
||||
/// withdrawal and rejects otherwise — no item loss (the co-op-placement commit-in-place rule). Plain server
|
||||
/// SimulationSystemGroup (NOT predicted -> applied once, no rollback double-apply); only the request entity
|
||||
/// destroy is deferred to the ECB.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct EquipSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<ItemDatabase>();
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAny<EquipRequest, UnequipRequest>().WithAll<ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var itemDb = SystemAPI.GetSingleton<ItemDatabase>();
|
||||
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, entity) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>>()
|
||||
.WithAll<PlayerTag, InventorySlot, EquipmentSlot>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = entity;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (request, receive, requestEntity) in
|
||||
SystemAPI.Query<RefRO<EquipRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
||||
{
|
||||
if (TryResolvePlayer(ref state, playerByConn, receive.ValueRO.SourceConnection, out var player))
|
||||
HandleEquip(ref state, itemDb, player, request.ValueRO.ItemId);
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
|
||||
foreach (var (request, receive, requestEntity) in
|
||||
SystemAPI.Query<RefRO<UnequipRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
||||
{
|
||||
if (TryResolvePlayer(ref state, playerByConn, receive.ValueRO.SourceConnection, out var player))
|
||||
HandleUnequip(ref state, itemDb, player, request.ValueRO.Slot);
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
playerByConn.Dispose();
|
||||
}
|
||||
|
||||
static bool TryResolvePlayer(ref SystemState state, NativeHashMap<int, Entity> map, Entity conn, out Entity player)
|
||||
{
|
||||
player = Entity.Null;
|
||||
return state.EntityManager.HasComponent<NetworkId>(conn)
|
||||
&& map.TryGetValue(state.EntityManager.GetComponentData<NetworkId>(conn).Value, out player);
|
||||
}
|
||||
|
||||
static void HandleEquip(ref SystemState state, ItemDatabase itemDb, Entity player, ushort itemId)
|
||||
{
|
||||
ref var db = ref itemDb.Value.Value;
|
||||
if (!db.TryGetItem(itemId, out var def)) return;
|
||||
byte slot = def.EquipSlot;
|
||||
if (slot >= EquipSlotId.Count) return; // not equippable (255 or out of range)
|
||||
|
||||
var bag = state.EntityManager.GetBuffer<InventorySlot>(player);
|
||||
if (InventoryMath.CountOf(bag, itemId) < 1) return; // the sender isn't carrying it
|
||||
|
||||
var slots = state.EntityManager.GetBuffer<EquipmentSlot>(player);
|
||||
ushort oldItem = slots[slot].ItemId;
|
||||
|
||||
// Atomicity: if the slot is occupied, the bag MUST be able to hold the swapped-out item before we
|
||||
// touch anything; reject the whole equip otherwise so the old item is never lost.
|
||||
if (oldItem != 0 && !InventoryMath.CanDeposit(bag, oldItem, 1, StackMaxOf(ref db, oldItem), Tuning.InventoryMaxSlots))
|
||||
return;
|
||||
|
||||
// Commit in-place.
|
||||
InventoryMath.Withdraw(bag, itemId, 1);
|
||||
if (oldItem != 0)
|
||||
{
|
||||
InventoryMath.Deposit(bag, oldItem, 1, StackMaxOf(ref db, oldItem), Tuning.InventoryMaxSlots);
|
||||
StripSlotEffects(ref state, player, slot);
|
||||
}
|
||||
|
||||
slots[slot] = new EquipmentSlot { ItemId = itemId };
|
||||
ApplySlotEffects(ref state, player, slot, def);
|
||||
}
|
||||
|
||||
static void HandleUnequip(ref SystemState state, ItemDatabase itemDb, Entity player, byte slot)
|
||||
{
|
||||
if (slot >= EquipSlotId.Count) return;
|
||||
ref var db = ref itemDb.Value.Value;
|
||||
|
||||
var slots = state.EntityManager.GetBuffer<EquipmentSlot>(player);
|
||||
ushort item = slots[slot].ItemId;
|
||||
if (item == 0) return; // nothing equipped
|
||||
|
||||
var bag = state.EntityManager.GetBuffer<InventorySlot>(player);
|
||||
if (!InventoryMath.CanDeposit(bag, item, 1, StackMaxOf(ref db, item), Tuning.InventoryMaxSlots))
|
||||
return; // bag full -> can't unequip (no item loss)
|
||||
|
||||
InventoryMath.Deposit(bag, item, 1, StackMaxOf(ref db, item), Tuning.InventoryMaxSlots);
|
||||
slots[slot] = new EquipmentSlot { ItemId = 0 };
|
||||
StripSlotEffects(ref state, player, slot);
|
||||
}
|
||||
|
||||
static void ApplySlotEffects(ref SystemState state, Entity player, byte slot, ItemDefBlob def)
|
||||
{
|
||||
// LANTERN purge: weapons are stat-sticks — the old weapon->AbilityRef ability grant is deleted
|
||||
// (abilities live in the 4-socket Spark loadout).
|
||||
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
|
||||
uint sourceId = Tuning.EquipSourceIdBase + (uint)slot;
|
||||
for (int i = 0; i < ItemDefBlob.MaxMods; i++)
|
||||
{
|
||||
var m = def.GetMod(i);
|
||||
if (m.Target == 255) continue;
|
||||
mods.Add(new StatModifier { Target = m.Target, Op = m.Op, Value = m.Value, SourceId = sourceId });
|
||||
}
|
||||
}
|
||||
|
||||
static void StripSlotEffects(ref SystemState state, Entity player, byte slot)
|
||||
{
|
||||
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
|
||||
TimedModifierUtil.RemoveBySourceId(mods, Tuning.EquipSourceIdBase + (uint)slot);
|
||||
}
|
||||
|
||||
static int StackMaxOf(ref ItemDatabaseBlob db, ushort itemId)
|
||||
=> db.TryGetItem(itemId, out var d) && d.StackMax > 0 ? d.StackMax : 1;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 187144e115a815c4fae51eaa9e95012f
|
||||
@@ -1,84 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative handler for <see cref="InventoryDepositRequest"/> RPCs: moves items from the
|
||||
/// sender's PERSONAL <see cref="InventorySlot"/> inventory into the shared base stockpile (the global
|
||||
/// <see cref="ResourceLedger"/> the build/upgrade/automation economy spends from). Resolves the sender's
|
||||
/// player (SourceConnection -> NetworkId -> GhostOwner) via the AbilityUpgradeSystem owner-map idiom,
|
||||
/// then withdraws from the player's inventory and deposits into the ledger IN-PLACE (buffer mutation is not
|
||||
/// a structural change). <c>ItemId == 0</c> ("deposit all") is handled BEFORE any per-item withdraw and
|
||||
/// never writes a 0-id row. Resolves the ledger via <c>GetSingletonEntity<ResourceLedger>()</c> then
|
||||
/// <c>GetBuffer<StorageEntry>()</c> — NEVER <c>GetSingleton<StorageEntry></c> (the base
|
||||
/// container owns a second StorageEntry buffer). Plain server SimulationSystemGroup (not predicted, so the
|
||||
/// effect applies exactly once — no rollback double-apply).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct InventoryDepositSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<ResourceLedger>();
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAll<InventoryDepositRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, entity) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = entity;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (request, receive, requestEntity) in
|
||||
SystemAPI.Query<RefRO<InventoryDepositRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
||||
{
|
||||
var conn = receive.ValueRO.SourceConnection;
|
||||
if (SystemAPI.HasComponent<NetworkId>(conn)
|
||||
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
|
||||
{
|
||||
var inv = SystemAPI.GetBuffer<InventorySlot>(player);
|
||||
var req = request.ValueRO;
|
||||
|
||||
if (req.ItemId == 0)
|
||||
{
|
||||
// Deposit EVERYTHING: drain each non-empty stack into the ledger, then clear the bag.
|
||||
for (int i = 0; i < inv.Length; i++)
|
||||
{
|
||||
var slot = inv[i];
|
||||
if (slot.ItemId != 0 && slot.Count > 0)
|
||||
StorageMath.Deposit(ledger, slot.ItemId, slot.Count);
|
||||
}
|
||||
inv.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Count <= 0 means "all of that item"; Withdraw clamps to what is available.
|
||||
int want = req.Count <= 0 ? int.MaxValue : req.Count;
|
||||
int moved = InventoryMath.Withdraw(inv, req.ItemId, want);
|
||||
if (moved > 0)
|
||||
StorageMath.Deposit(ledger, req.ItemId, moved);
|
||||
}
|
||||
}
|
||||
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
playerByConn.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 25b1bdf13ad8a6d4ca48bef112b98d28
|
||||
@@ -34,7 +34,6 @@ namespace ProjectM.Server
|
||||
const float k_ProjectileRadius = Tuning.HarvestProjectileRadius;
|
||||
|
||||
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
|
||||
BufferLookup<InventorySlot> m_InvLookup;
|
||||
ComponentLookup<RegionTag> m_RegionLookup;
|
||||
|
||||
[BurstCompile]
|
||||
@@ -43,7 +42,6 @@ namespace ProjectM.Server
|
||||
state.RequireForUpdate<Projectile>();
|
||||
state.RequireForUpdate<ResourceLedger>();
|
||||
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(true);
|
||||
m_InvLookup = state.GetBufferLookup<InventorySlot>(false);
|
||||
m_RegionLookup = state.GetComponentLookup<RegionTag>(true);
|
||||
}
|
||||
|
||||
@@ -58,18 +56,11 @@ namespace ProjectM.Server
|
||||
uint nowTick = haveTick ? hvNetTime.ServerTick.TickIndexForValidTick : 0u;
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
|
||||
|
||||
// Resolve the harvesting player from the projectile's GhostOwner so yield lands in their PERSONAL
|
||||
// inventory. Owner read via a cached lookup (optional); the owner->player map + item catalog are
|
||||
// hoisted out of the per-hit sweep (invariant for the tick).
|
||||
// 2026-08-07 audit purge: yield used to route to the firing player's PERSONAL inventory and spill to
|
||||
// the ledger. The inventory layer went with the shell — all yield now credits the ledger directly.
|
||||
m_GhostOwnerLookup.Update(ref state);
|
||||
m_InvLookup.Update(ref state);
|
||||
m_RegionLookup.Update(ref state);
|
||||
bool haveDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
|
||||
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, playerEntity) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = playerEntity;
|
||||
|
||||
// Snapshot all harvest/clear targets (nodes + clutter) once this tick into a UNIFIED set.
|
||||
var tgtEntity = new NativeList<Entity>(Allocator.Temp);
|
||||
@@ -163,13 +154,8 @@ namespace ProjectM.Server
|
||||
// Route the yield into the HARVESTING player's PERSONAL inventory. The projectile carries the
|
||||
// firing player's GhostOwner (AbilityFireSystem); the owner is read OPTIONALLY (cached lookup) so
|
||||
// an un-owned projectile (or a test projectile with no GhostOwner) falls through to the ledger.
|
||||
Entity harvester = Entity.Null;
|
||||
if (m_GhostOwnerLookup.HasComponent(projEntity)
|
||||
&& playerByConn.TryGetValue(m_GhostOwnerLookup[projEntity].NetworkId, out var ownedPlayer))
|
||||
harvester = ownedPlayer;
|
||||
if (deposit > 0)
|
||||
HarvestMath.DepositYield(yieldId, deposit, tgtToLedger[bestIdx], harvester,
|
||||
m_InvLookup, ledger, true, haveDb, itemDb);
|
||||
HarvestMath.DepositYield(yieldId, deposit, ledger, true);
|
||||
int rem = tgtRemaining[bestIdx] - amount;
|
||||
tgtRemaining[bestIdx] = rem;
|
||||
ecb.DestroyEntity(projEntity);
|
||||
@@ -224,7 +210,6 @@ namespace ProjectM.Server
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
playerByConn.Dispose();
|
||||
destroyed.Dispose();
|
||||
tgtEntity.Dispose();
|
||||
tgtPos.Dispose();
|
||||
|
||||
@@ -1,215 +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>
|
||||
/// Server-only per-ROOM field seeder — the Step-5 successor of the presence-keyed <c>ExpeditionFieldSystem</c>.
|
||||
/// When the run FSM has a room active (<see cref="RunInfo.Lifecycle"/> == InRoom) and
|
||||
/// <see cref="RunRuntime.RoomEpoch"/> has advanced past the epoch this system last seeded (int equality, never
|
||||
/// tick math), it resolves the active room's <see cref="RoomPlan"/> from the map node RunDirectorSystem published
|
||||
/// (<see cref="RunRuntime.CurrentNodeId"/> — the single plan authority; NEVER re-derived here) and scatters
|
||||
/// <c>plan.NodeCount</c> resource nodes — FLOORED by the run-wide scarcity budget
|
||||
/// <see cref="RunRuntime.NodeBudgetRemaining"/>, which this system spends down (documented co-write: RunDirector
|
||||
/// STAGES the budget at launch; this system only decrements it) — plus a light Blight-clutter dressing, all
|
||||
/// inside the room's shape at <see cref="RegionMath.ExpeditionRoomOrigin"/>(base, ActiveSubSlot). Every spawn is
|
||||
/// stamped <see cref="RoomTag"/>{room} (the teardown contract) on top of the prefab-baked RegionTag{Expedition};
|
||||
/// Scale is preserved via <c>baked.WithPosition</c> (never FromPosition).
|
||||
///
|
||||
/// Teardown: room-advance/return teardown belongs to RunDirectorSystem (RoomTeardown, Step 7). This system keeps
|
||||
/// ONE defensive sweep — Staging with any <see cref="RoomTag"/> alive → destroy them all (idempotent; covers
|
||||
/// abort/disconnect edges). Untagged ghosts (base field, structures) are structurally untouchable.
|
||||
///
|
||||
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> so it reads the freshly-advanced room state same-tick.
|
||||
/// The old inherited <c>[UpdateAfter(CyclePhaseSystem)]</c> is deliberately DROPPED and NO CyclePhase edge may
|
||||
/// ever return to the room chain (the Play-only sort-cycle rule — invisible to EditMode).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(RunDirectorSystem))]
|
||||
public partial struct RoomFieldSystem : ISystem
|
||||
{
|
||||
/// <summary>Max clutter pieces per room — cosmetic ghosts still cost relevancy, keep the dressing light.</summary>
|
||||
const int MaxClutterPerRoom = 8;
|
||||
|
||||
EntityQuery m_RoomTagged;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<ResourceFieldSpawner>();
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
state.RequireForUpdate<RunRuntime>();
|
||||
m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly<RoomTag>());
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
|
||||
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
|
||||
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
|
||||
|
||||
var spawnerEntity = SystemAPI.GetSingletonEntity<ResourceFieldSpawner>();
|
||||
var spawner = SystemAPI.GetComponent<ResourceFieldSpawner>(spawnerEntity);
|
||||
|
||||
// One-shot: attach this system's server-only bookkeeping beside the baked spawner singleton.
|
||||
if (!SystemAPI.HasComponent<RoomFieldState>(spawnerEntity))
|
||||
{
|
||||
state.EntityManager.AddComponentData(spawnerEntity, new RoomFieldState());
|
||||
return; // structural change — clean re-read next tick
|
||||
}
|
||||
var rf = SystemAPI.GetComponent<RoomFieldState>(spawnerEntity);
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
if (info.Lifecycle == RunLifecycle.InRoom)
|
||||
{
|
||||
if (rf.LastSpawnedRoomEpoch != run.RoomEpoch && spawner.Prefab != Entity.Null)
|
||||
{
|
||||
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
baseCenter = BaseGridMath.PlotCenter(anchor);
|
||||
float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
|
||||
|
||||
// Single plan authority: the node RunDirector published — never re-derived from the col/path.
|
||||
var map = RunMapMath.Generate(run.RunSeed);
|
||||
var node = map.NodeAt(run.CurrentNodeId);
|
||||
var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount);
|
||||
byte room = (byte)(info.CurrentRoom & 0xFF);
|
||||
|
||||
// Scarcity: the run-wide budget floors this room's count and is spent down (never negative).
|
||||
int count = math.min(plan.NodeCount, math.max(0, run.NodeBudgetRemaining));
|
||||
if (count > 0)
|
||||
{
|
||||
var baked = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
|
||||
var prefabNode = SystemAPI.GetComponent<ResourceNode>(spawner.Prefab);
|
||||
var rng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x0DEu) | 1u);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var e = ecb.Instantiate(spawner.Prefab);
|
||||
float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng);
|
||||
ecb.SetComponent(e, baked.WithPosition(pos));
|
||||
// Rarity-weighted resource type (Step 11): Ore 45% (building) / Biomass 40% (walls,
|
||||
// fabricator) / AETHER 15% — the scarce permanent-meta currency, felt when it drops.
|
||||
var rn = prefabNode;
|
||||
int roll = rng.NextInt(0, 100);
|
||||
rn.ResourceId = roll < 15 ? ResourceId.Aether : roll < 60 ? ResourceId.Ore : ResourceId.Biomass;
|
||||
ecb.SetComponent(e, rn);
|
||||
ecb.AddComponent(e, new RoomTag { Room = room });
|
||||
}
|
||||
run.NodeBudgetRemaining -= count;
|
||||
SystemAPI.SetComponent(dirEntity, run); // the documented budget co-write (spend only)
|
||||
}
|
||||
|
||||
// Clutter dressing (OPTIONAL singleton) — a DISTINCT seed so it never co-locates with nodes.
|
||||
if (SystemAPI.TryGetSingleton<ClutterFieldSpawner>(out var clutter)
|
||||
&& clutter.Prefab != Entity.Null)
|
||||
{
|
||||
var cBaked = SystemAPI.GetComponent<LocalTransform>(clutter.Prefab);
|
||||
var cProto = SystemAPI.GetComponent<BlightClutter>(clutter.Prefab);
|
||||
var crng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0xC17u) | 1u);
|
||||
int cCount = math.min(math.max(1, clutter.Count), MaxClutterPerRoom);
|
||||
for (int i = 0; i < cCount; i++)
|
||||
{
|
||||
var e = ecb.Instantiate(clutter.Prefab);
|
||||
float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, cCount, ref crng);
|
||||
ecb.SetComponent(e, cBaked.WithPosition(pos));
|
||||
var bc = cProto;
|
||||
// ~25% EXPLOSIVE (Variant 3, the hazard — Exploding_Barrels_Build_Spec); rest stay cosmetic 0-2.
|
||||
bc.Variant = crng.NextFloat() < 0.25f ? (byte)3 : (byte)(i % 3);
|
||||
ecb.SetComponent(e, bc);
|
||||
ecb.AddComponent(e, new RoomTag { Room = room });
|
||||
}
|
||||
}
|
||||
|
||||
// DESTRUCTIBLE COVER (OPTIONAL singleton; review wf_e14dd739-069): never in Boss rooms (the boss
|
||||
// has no depenetration backstop) and never inside the origin keep-out ring (player landing +
|
||||
// portal). Variant stays the prefab's 4 — NEVER rerolled (the clutter block's explosive
|
||||
// reroll must not leak into this copy).
|
||||
if (plan.RoomType != RoomTypeId.Boss
|
||||
&& SystemAPI.TryGetSingleton<CoverFieldSpawner>(out var cover)
|
||||
&& cover.Prefab != Entity.Null)
|
||||
{
|
||||
var kBaked = SystemAPI.GetComponent<LocalTransform>(cover.Prefab);
|
||||
var krng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0xC0Eu) | 1u);
|
||||
int kCount = math.clamp(cover.Count, 0, 6);
|
||||
const float KeepOutFromOrigin = 7f;
|
||||
for (int i = 0; i < kCount; i++)
|
||||
{
|
||||
float3 pos = origin;
|
||||
for (int attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, kCount, ref krng);
|
||||
if (math.distance(pos.xz, origin.xz) >= KeepOutFromOrigin) break;
|
||||
}
|
||||
if (math.distance(pos.xz, origin.xz) < KeepOutFromOrigin) continue; // unlucky draws: drop the piece
|
||||
var e = ecb.Instantiate(cover.Prefab);
|
||||
ecb.SetComponent(e, kBaked.WithPosition(pos));
|
||||
ecb.AddComponent(e, new RoomTag { Room = room });
|
||||
}
|
||||
}
|
||||
|
||||
// BLIGHT GEYSER (OPTIONAL singleton; Geyser_Build_Spec / review wf_900e9965-8f0): a PERMANENT
|
||||
// periodic BOTH-SIDES AoE hazard, ONLY in Blight-biome rooms (gate on the LOCAL plan, like the
|
||||
// cover block). Never Boss rooms — a DESIGN choice (the geyser has no collider, so unlike cover it
|
||||
// is NOT the depenetration concern). Distinct hash sub-stream 0x6E7; keep-out ring around origin.
|
||||
// BORN-CORRECT: stamp NextEruptTick from the LIVE ServerTick (staggered per instance so eruptions
|
||||
// desync) so the first snapshot never carries the 0 sentinel; if NetworkTime is invalid this tick
|
||||
// the geyser ships 0 and GeyserEruptSystem lazy-stamps it born-correct instead (never a storm).
|
||||
if (plan.Biome == RoomBiomeId.Blight
|
||||
&& plan.RoomType != RoomTypeId.Boss
|
||||
&& SystemAPI.TryGetSingleton<GeyserFieldSpawner>(out var geyser)
|
||||
&& geyser.Prefab != Entity.Null)
|
||||
{
|
||||
uint eruptStamp = 0u;
|
||||
if (SystemAPI.TryGetSingleton<NetworkTime>(out var gnt) && gnt.ServerTick.IsValid)
|
||||
eruptStamp = gnt.ServerTick.TickIndexForValidTick;
|
||||
var gBaked = SystemAPI.GetComponent<LocalTransform>(geyser.Prefab);
|
||||
var grng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x6E7u) | 1u);
|
||||
int gCount = math.clamp(geyser.Count, 0, 4);
|
||||
const float GeyserKeepOut = 7f;
|
||||
for (int i = 0; i < gCount; i++)
|
||||
{
|
||||
float3 pos = origin;
|
||||
for (int attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, gCount, ref grng);
|
||||
if (math.distance(pos.xz, origin.xz) >= GeyserKeepOut) break;
|
||||
}
|
||||
if (math.distance(pos.xz, origin.xz) < GeyserKeepOut) continue; // unlucky draws: drop the piece
|
||||
var e = ecb.Instantiate(geyser.Prefab);
|
||||
ecb.SetComponent(e, gBaked.WithPosition(pos));
|
||||
// born-correct + per-instance stagger so geysers desync; 0 only if NetworkTime was invalid.
|
||||
uint next = eruptStamp != 0u
|
||||
? TickUtil.NonZero(eruptStamp + Tuning.GeyserPeriodTicks + (uint)i * 60u)
|
||||
: 0u;
|
||||
ecb.SetComponent(e, new Geyser { NextEruptTick = next });
|
||||
ecb.AddComponent(e, new RoomTag { Room = room });
|
||||
}
|
||||
}
|
||||
|
||||
rf.LastSpawnedRoomEpoch = run.RoomEpoch;
|
||||
SystemAPI.SetComponent(spawnerEntity, rf);
|
||||
}
|
||||
}
|
||||
else if (info.Lifecycle == RunLifecycle.Staging && !m_RoomTagged.IsEmpty)
|
||||
{
|
||||
// Defensive sweep: no run active but room ghosts linger (abort/disconnect edge) — clear every room.
|
||||
var ents = m_RoomTagged.ToEntityArray(Allocator.Temp);
|
||||
for (int i = 0; i < ents.Length; i++)
|
||||
ecb.DestroyEntity(ents[i]);
|
||||
ents.Dispose();
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b2ba012b5e31bcc48b50dd14220c9fc5
|
||||
@@ -1,61 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-only, one-shot spawner for the shared home-base storage container (mirrors
|
||||
/// UpgradePickupSpawnSystem). On its first update it reads the baked <see cref="StorageSpawner"/>
|
||||
/// singleton and the <see cref="BaseAnchor"/>, instantiates the container ghost at the cell center
|
||||
/// (<see cref="BaseGridMath.CellToWorld"/>), then destroys the spawner singleton so the system idles
|
||||
/// (spawned exactly once). Runs in the default SimulationSystemGroup (NOT the prediction loop); the
|
||||
/// container replicates to clients as an ownerless interpolated ghost. The container is intentionally
|
||||
/// NOT linked to any connection's LinkedEntityGroup, so it persists across player disconnects.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct SharedStorageSpawnSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<StorageSpawner>();
|
||||
state.RequireForUpdate<BaseAnchor>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var spawnerEntity = SystemAPI.GetSingletonEntity<StorageSpawner>();
|
||||
var spawner = SystemAPI.GetComponent<StorageSpawner>(spawnerEntity);
|
||||
var anchor = SystemAPI.GetSingleton<BaseAnchor>();
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
if (spawner.Prefab != Entity.Null)
|
||||
{
|
||||
var container = ecb.Instantiate(spawner.Prefab);
|
||||
var position = BaseGridMath.CellToWorld(anchor, spawner.Cell);
|
||||
// Phase 0: the grid Y (GridOrigin.y=1) is the CC capsule-CENTER plane; center-pivot structure
|
||||
// meshes look grounded there, but the crate's pivot is at its base -> it floated 1 u. Sit it on
|
||||
// the terrain surface instead.
|
||||
position.y = 0f;
|
||||
// Preserve the prefab's baked scale/rotation (FromPosition would reset Scale to 1).
|
||||
var xform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
|
||||
xform.Position = position;
|
||||
ecb.SetComponent(container, xform);
|
||||
// M6: scope the shared storage to the base region for ghost relevancy.
|
||||
ecb.AddComponent(container, new RegionTag { Region = RegionId.Base });
|
||||
}
|
||||
|
||||
// One-shot: remove the spawner so RequireForUpdate fails and the system idles.
|
||||
ecb.DestroyEntity(spawnerEntity);
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c60c2c14e48ea0c45858cf0054c1663f
|
||||
@@ -1,55 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative handler for <see cref="StorageOpRequest"/> RPCs (deposit/withdraw on the
|
||||
/// shared storage container). Resolves the single <see cref="SharedStorageContainer"/> as a singleton,
|
||||
/// applies the op to its replicated <see cref="StorageEntry"/> buffer via <see cref="StorageMath"/>,
|
||||
/// and destroys the request entity. Runs in the default SimulationSystemGroup (NOT the prediction
|
||||
/// loop), so a server event is applied exactly once (no rollback double-apply). Op is read as a byte
|
||||
/// (see <see cref="StorageOp"/>); the buffer mutation auto-replicates to all clients via GhostField.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct StorageOpReceiveSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<SharedStorageContainer>();
|
||||
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAll<StorageOpRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var containerEntity = SystemAPI.GetSingletonEntity<SharedStorageContainer>();
|
||||
var contents = SystemAPI.GetBuffer<StorageEntry>(containerEntity);
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (request, requestEntity) in
|
||||
SystemAPI.Query<RefRO<StorageOpRequest>>().WithAll<ReceiveRpcCommandRequest>().WithEntityAccess())
|
||||
{
|
||||
var op = request.ValueRO;
|
||||
if (op.Op == StorageOp.Withdraw)
|
||||
StorageMath.Withdraw(contents, op.ItemId, op.Count);
|
||||
else
|
||||
StorageMath.Deposit(contents, op.ItemId, op.Count);
|
||||
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6739144c8fa1bd040ad766919f9535f3
|
||||
@@ -1,147 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server receiver for <see cref="MetaSpendRequest"/> — the PERMANENT meta-upgrade purchase (Aether → tier).
|
||||
/// Honored ONLY in Staging (N4: the base shop is a between-runs surface; mid-run Aether belongs to the run).
|
||||
/// Per request, IN-LOOP against the live director buffers (the DR-014 placement idiom — two same-tick purchases
|
||||
/// on barely-enough Aether cannot both pass): resolve sender → <see cref="PlayerClass"/>, validate catalog id /
|
||||
/// class mask (<see cref="BoonMath.MaskFor"/>, never raw 1<<ClassId) / MaxTier / prereq, price the NEXT tier
|
||||
/// (<see cref="MetaMath.CostForTier"/> — tier is server-computed, never on the wire), then
|
||||
/// <see cref="StorageMath.TotalOf"/> pre-check BEFORE <see cref="StorageMath.Withdraw"/> (Withdraw CLAMPS, it
|
||||
/// never rejects), bump-or-append the <see cref="MetaTierState"/> row, and upsert the ABSOLUTE-value meta
|
||||
/// StatModifier (R-F1: Value = ValuePerTier * newTier, keyed <c>Tuning.MetaSourceIdBase + id</c>) on every
|
||||
/// pre-collected live player of that class (R-F2 — offline classmates get theirs born-correct at next spawn via
|
||||
/// GoInGameServerSystem). Success raises <see cref="SaveRequest"/> so the tier is on disk before a crash.
|
||||
/// Plain server group, before RunDirectorSystem (the receiver convention); requests are ALWAYS destroyed.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(RunDirectorSystem))]
|
||||
public partial struct MetaSpendSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAll<MetaSpendRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
state.RequireForUpdate<MetaUpgradeCatalog>();
|
||||
state.RequireForUpdate<ResourceLedger>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
// N4 phase gate — hoisted (per-tick-uniform, like the ReadyToggle accept flag).
|
||||
bool accept = SystemAPI.GetSingleton<RunInfo>().Lifecycle == RunLifecycle.Staging;
|
||||
|
||||
var catalog = SystemAPI.GetSingleton<MetaUpgradeCatalog>();
|
||||
var director = SystemAPI.GetSingletonEntity<ResourceLedger>();
|
||||
if (!catalog.Value.IsCreated || !SystemAPI.HasBuffer<MetaTierState>(director))
|
||||
accept = false; // authoring hole: drop the requests below (no withdraw happened; nothing to roll back)
|
||||
|
||||
// Sender resolution (SourceConnection → NetworkId → GhostOwner → player, the ReadyToggle idiom).
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
// R-F2: pre-collect the live (player, class) pairs ONCE — a successful purchase upserts the modifier on
|
||||
// every live member of the class, not just the buyer (shared per-class pool, operator default).
|
||||
var classMembers = new NativeList<Entity>(8, Allocator.Temp);
|
||||
var classIds = new NativeList<byte>(8, Allocator.Temp);
|
||||
foreach (var (owner, playerClass, entity) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>, RefRO<PlayerClass>>()
|
||||
.WithAll<PlayerTag, StatModifier>().WithEntityAccess())
|
||||
{
|
||||
playerByConn[owner.ValueRO.NetworkId] = entity;
|
||||
classMembers.Add(entity);
|
||||
classIds.Add(playerClass.ValueRO.ClassId);
|
||||
}
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (receive, req, requestEntity) in
|
||||
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<MetaSpendRequest>>().WithEntityAccess())
|
||||
{
|
||||
ecb.DestroyEntity(requestEntity); // ALWAYS consumed, accepted or not
|
||||
if (!accept) continue;
|
||||
|
||||
var conn = receive.ValueRO.SourceConnection;
|
||||
if (!SystemAPI.HasComponent<NetworkId>(conn)
|
||||
|| !playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var buyer))
|
||||
continue;
|
||||
byte classId = SystemAPI.GetComponent<PlayerClass>(buyer).ClassId;
|
||||
|
||||
ref var pool = ref catalog.Value.Value;
|
||||
int defIdx = MetaMath.FindDef(ref pool, req.ValueRO.UpgradeId);
|
||||
if (defIdx < 0) continue; // unknown id — dropped (a forged/stale request, not a crash)
|
||||
ref var def = ref pool.Defs[defIdx];
|
||||
if ((def.ClassMask & BoonMath.MaskFor(classId)) == 0) continue;
|
||||
|
||||
// LIVE in-loop reads (no hoist — the previous request this tick may have bumped the tier or
|
||||
// drained the ledger; hoisted copies would let both pass).
|
||||
var record = SystemAPI.GetBuffer<MetaTierState>(director);
|
||||
byte owned = MetaMath.TierOf(record, classId, req.ValueRO.UpgradeId);
|
||||
if (owned >= def.MaxTier) continue;
|
||||
if (def.PrereqId != 0xFF && MetaMath.TierOf(record, classId, def.PrereqId) < def.PrereqTier)
|
||||
continue;
|
||||
|
||||
int cost = MetaMath.CostForTier(in def, owned);
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(director);
|
||||
if (StorageMath.TotalOf(ledger, ResourceId.Aether) < cost) continue; // pre-check: Withdraw CLAMPS
|
||||
StorageMath.Withdraw(ledger, ResourceId.Aether, cost); // atomic commit (DR-014)
|
||||
|
||||
byte newTier = (byte)(owned + 1);
|
||||
bool bumped = false;
|
||||
for (int i = 0; i < record.Length; i++)
|
||||
if (record[i].ClassId == classId && record[i].UpgradeId == req.ValueRO.UpgradeId)
|
||||
{
|
||||
record[i] = new MetaTierState { ClassId = classId, UpgradeId = req.ValueRO.UpgradeId, Tier = newTier };
|
||||
bumped = true;
|
||||
break;
|
||||
}
|
||||
if (!bumped)
|
||||
record.Add(new MetaTierState { ClassId = classId, UpgradeId = req.ValueRO.UpgradeId, Tier = newTier });
|
||||
|
||||
// R-F1: ABSOLUTE-value upsert (Value = ValuePerTier * newTier) — never an incremental append; a
|
||||
// second append would double-count in StatRecomputeSystem's sum.
|
||||
uint sourceId = Tuning.MetaSourceIdBase + req.ValueRO.UpgradeId;
|
||||
for (int p = 0; p < classMembers.Length; p++)
|
||||
{
|
||||
if (classIds[p] != classId) continue;
|
||||
var mods = SystemAPI.GetBuffer<StatModifier>(classMembers[p]);
|
||||
bool upserted = false;
|
||||
for (int m = 0; m < mods.Length; m++)
|
||||
if (mods[m].SourceId == sourceId)
|
||||
{
|
||||
var row = mods[m];
|
||||
row.Value = def.ValuePerTier * newTier;
|
||||
mods[m] = row;
|
||||
upserted = true;
|
||||
break;
|
||||
}
|
||||
if (!upserted)
|
||||
mods.Add(new StatModifier
|
||||
{
|
||||
Target = def.Target,
|
||||
Op = def.Op,
|
||||
Value = def.ValuePerTier * newTier,
|
||||
SourceId = sourceId,
|
||||
});
|
||||
}
|
||||
|
||||
// Persist immediately — the tier is real money (Aether); a crash must not eat it.
|
||||
if (SystemAPI.HasComponent<SaveRequest>(director))
|
||||
SystemAPI.SetComponent(director, new SaveRequest { Pending = 1 });
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
playerByConn.Dispose();
|
||||
classMembers.Dispose();
|
||||
classIds.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9b277eb9da63a054db9f9b3e041d582b
|
||||
@@ -37,19 +37,12 @@ namespace ProjectM.Server
|
||||
rows[i] = new LedgerRow { ItemId = buffer[i].ItemId, Count = buffer[i].Count };
|
||||
|
||||
// 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);
|
||||
// v6: the permanent-meta slice via the ONE shared collector.
|
||||
MetaSaveScan.Collect(EntityManager, dir, out var metaRows, out var runsCompleted, out var maxDepth);
|
||||
|
||||
// 2026-08-07 audit purge: structures and the permanent-meta slice are deleted; the save now carries
|
||||
// the LEDGER only. SaveData keeps its Structures / MetaUpgrades / RunsCompleted / MaxDepthReached
|
||||
// fields so a v7 file written before the purge still loads — they are simply written empty now.
|
||||
SaveService.Save(new SaveData
|
||||
{
|
||||
RunsCompleted = runsCompleted,
|
||||
MaxDepthReached = maxDepth,
|
||||
MetaUpgrades = metaRows,
|
||||
|
||||
Ledger = rows,
|
||||
Structures = structures,
|
||||
SavedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,20 +49,13 @@ namespace ProjectM.Server
|
||||
xform.Position = BaseGridMath.PlotCenter(anchor);
|
||||
ecb.SetComponent(director, xform);
|
||||
|
||||
// Expedition redesign: run-FSM working state + the co-op route first-commit latch + the persisted
|
||||
// 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
|
||||
|
||||
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.
|
||||
// Born-correct load: if the menu staged a save (Continue), apply the ledger AT SPAWN so the
|
||||
// director ghost never serializes an empty ledger to clients (no replication flicker).
|
||||
//
|
||||
// 2026-08-07 audit purge: this block also seeded RunRuntime (HostSalt), RouteCommand,
|
||||
// PortalCommand, MetaCounters, MetaTierState and a born-correct RunInfo mirror. All of that was
|
||||
// the superseded base/expedition run-FSM and meta shop; the director now hosts exactly one thing
|
||||
// — the global resource ledger.
|
||||
bool restoredLedger = false;
|
||||
if (SystemAPI.TryGetSingletonEntity<PendingSave>(out var pendingEntity))
|
||||
{
|
||||
@@ -72,35 +65,7 @@ namespace ProjectM.Server
|
||||
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)
|
||||
|
||||
// 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), 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,
|
||||
MaxDepthReached = pending.MaxDepthReached,
|
||||
});
|
||||
var metaSrc = SystemAPI.GetBuffer<PendingMetaRow>(pendingEntity);
|
||||
var metaDst = ecb.SetBuffer<MetaTierState>(director);
|
||||
for (int mi = 0; mi < metaSrc.Length; mi++)
|
||||
metaDst.Add(new MetaTierState { ClassId = metaSrc[mi].ClassId, UpgradeId = metaSrc[mi].UpgradeId, Tier = metaSrc[mi].Tier });
|
||||
if (SystemAPI.HasComponent<RunInfo>(spawner.Prefab))
|
||||
{
|
||||
var runInfo = SystemAPI.GetComponent<RunInfo>(spawner.Prefab); // baked Lifecycle=Staging
|
||||
runInfo.RunsCompleted = pending.RunsCompleted;
|
||||
runInfo.MaxDepthReached = pending.MaxDepthReached;
|
||||
ecb.SetComponent(director, runInfo);
|
||||
}
|
||||
ecb.SetComponent(director, new RunRuntime
|
||||
{
|
||||
HostSalt = RunMapMath.Hash(0x5EED0001u, (uint)pending.RunsCompleted + 1u),
|
||||
});
|
||||
|
||||
restoredLedger = true; // a save restored the ledger -> do NOT seed starting Ore
|
||||
}
|
||||
ecb.DestroyEntity(pendingEntity);
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server receiver for <see cref="PortalInteractRequest"/> — a participant interacting the room-exit portal during
|
||||
/// RoomExplore. Honored ONLY when <c>RunInfo.Lifecycle==RoomExplore</c> and the sender is an EXPEDITION player
|
||||
/// (region gate, the RouteSelect idiom). Sets the server-only <see cref="PortalCommand"/> latch IN-PLACE; it does
|
||||
/// NOT write RunInfo or tear the room down — RunDirectorSystem (the sole FSM/teardown owner) consumes the latch and
|
||||
/// advances. Plain server group, before RunDirectorSystem; requests ALWAYS destroyed; NO CyclePhase edge.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(RunDirectorSystem))]
|
||||
public partial struct PortalInteractReceiveSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
var b = new EntityQueryBuilder(Allocator.Temp).WithAll<PortalInteractRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(b));
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
state.RequireForUpdate<PortalCommand>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
|
||||
bool gateOpen = SystemAPI.GetComponent<RunInfo>(dirEntity).Lifecycle == RunLifecycle.RoomExplore;
|
||||
|
||||
// Sender region lookup (N3 idiom): a base-bound joiner cannot pull the party out of the room.
|
||||
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;
|
||||
|
||||
bool interacted = SystemAPI.GetComponent<PortalCommand>(dirEntity).HasInteract != 0;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (receive, requestEntity) in
|
||||
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>>().WithAll<PortalInteractRequest>().WithEntityAccess())
|
||||
{
|
||||
var conn = receive.ValueRO.SourceConnection;
|
||||
bool valid = gateOpen && !interacted
|
||||
&& SystemAPI.HasComponent<NetworkId>(conn)
|
||||
&& regionByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out byte senderRegion)
|
||||
&& senderRegion == RegionId.Expedition;
|
||||
if (valid)
|
||||
{
|
||||
SystemAPI.SetComponent(dirEntity, new PortalCommand { HasInteract = 1 });
|
||||
interacted = true;
|
||||
}
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
regionByConn.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cfb4147e08bf1b244bef1fa5d71d8b9e
|
||||
@@ -1,61 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server receiver for <see cref="ReadyToggleRequest"/>: resolves the sender (SourceConnection → NetworkId →
|
||||
/// GhostOwner → player entity, the AbilityUpgradeSystem idiom) and SETS <see cref="PlayerReady.Value"/>.
|
||||
/// Honored ONLY while the run FSM is in Staging or Launching — an un-ready during the Launching countdown is the
|
||||
/// launch-abort escape hatch (RunDirectorSystem reverts to Staging); toggles arriving mid-run are dropped (the
|
||||
/// Returning edge clears every flag anyway). Ordered BEFORE RunDirectorSystem so a toggle lands the same tick the
|
||||
/// ready-count is derived. Plain server group (one-off RPC effects never run in the predicted loop); the request
|
||||
/// entity is ALWAYS destroyed.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateBefore(typeof(RunDirectorSystem))]
|
||||
public partial struct ReadyToggleSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAll<ReadyToggleRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
byte lifecycle = SystemAPI.GetSingleton<RunInfo>().Lifecycle;
|
||||
bool accept = lifecycle == RunLifecycle.Staging || lifecycle == RunLifecycle.Launching;
|
||||
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, entity) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, PlayerReady>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = entity;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (receive, req, requestEntity) in
|
||||
SystemAPI.Query<RefRO<ReceiveRpcCommandRequest>, RefRO<ReadyToggleRequest>>().WithEntityAccess())
|
||||
{
|
||||
var conn = receive.ValueRO.SourceConnection;
|
||||
if (accept
|
||||
&& SystemAPI.HasComponent<NetworkId>(conn)
|
||||
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
|
||||
{
|
||||
SystemAPI.SetComponent(player, new PlayerReady { Value = (byte)(req.ValueRO.Ready != 0 ? 1 : 0) });
|
||||
}
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
ecb.Playback(state.EntityManager);
|
||||
playerByConn.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7392579cf8b92e64f9686b58da99f7c2
|
||||
@@ -1,97 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f56898976ef03af499c200e0d6f43b0d
|
||||
@@ -1,438 +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>
|
||||
/// SOLE writer of the replicated run-lifecycle FSM (<see cref="RunInfo"/>) and its server-only working state
|
||||
/// (<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,
|
||||
/// consumed ONE-TICK-LATE by construction — RoomEnemyDirectorSystem writes it after this system each tick, so no
|
||||
/// system-ordering back-edge exists) → RoomReward (cleared room TORN DOWN at entry via <see cref="RoomTeardown"/>;
|
||||
/// boon picks gate the exit from Step 10, all-Pending==0 today) → advance (bump room/epoch, flip the ping-pong
|
||||
/// sub-slot, teleport — teardown-at-entry + spawn-on-advance guarantees ≥1 empty tick and exactly ONE room alive)
|
||||
/// → … → Boss clear → Returning (teleport home + the CLEAR-GATED terminal bank) → Staging. Branching route
|
||||
/// choice (RouteSelect) replaces the fixed col-0 advance at Step 8.
|
||||
///
|
||||
/// 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 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))]
|
||||
public partial struct RunDirectorSystem : ISystem
|
||||
{
|
||||
/// <summary>"All ready → 3-2-1 → go" telegraph (~3 s @ 60). An un-ready during the countdown aborts.</summary>
|
||||
const uint LaunchCountdownTicks = 180;
|
||||
|
||||
/// <summary>Boon-pick grace (~30 s @ 60): RoomReward advances when every survivor picked OR this elapses
|
||||
/// (the AFK/disconnect backstop; the un-picked-offer policy lands with the boons at Step 10).</summary>
|
||||
const uint RewardGraceTicks = 1800;
|
||||
|
||||
/// <summary>Route-choice grace (~30 s @ 60): the gate auto-picks the LOWEST-INDEX reachable option when it
|
||||
/// elapses (the AFK backstop; an accepted pick always beats a same-tick expiry — review F2).</summary>
|
||||
const uint RouteGraceTicks = 1800;
|
||||
|
||||
EntityQuery m_RoomTagged;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
state.RequireForUpdate<RunRuntime>();
|
||||
m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly<RoomTag>());
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
|
||||
if (!serverTick.IsValid)
|
||||
return;
|
||||
uint now = serverTick.TickIndexForValidTick;
|
||||
|
||||
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
|
||||
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
|
||||
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
|
||||
|
||||
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
baseCenter = BaseGridMath.PlotCenter(anchor);
|
||||
|
||||
// Ready-check + party-presence derivation, shared across the states below. The party is co-located at
|
||||
// base while Staging (the N7 co-location invariant), so live PlayerTag ghosts ARE the roster; a
|
||||
// disconnect drops the counts (LinkedEntityGroup despawn) and the checks re-derive clean.
|
||||
int totalPlayers = 0, readyPlayers = 0, expeditionPlayers = 0;
|
||||
foreach (var (ready, region) in
|
||||
SystemAPI.Query<RefRO<PlayerReady>, RefRO<RegionTag>>().WithAll<PlayerTag>())
|
||||
{
|
||||
totalPlayers++;
|
||||
if (ready.ValueRO.Value != 0) readyPlayers++;
|
||||
if (region.ValueRO.Region == RegionId.Expedition) expeditionPlayers++;
|
||||
}
|
||||
bool allReady = totalPlayers > 0 && readyPlayers == totalPlayers;
|
||||
|
||||
switch (info.Lifecycle)
|
||||
{
|
||||
case RunLifecycle.Staging:
|
||||
{
|
||||
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.
|
||||
run.RunEpoch += 1;
|
||||
run.HostSalt = RunMapMath.Hash(run.HostSalt, (uint)run.RunEpoch);
|
||||
run.RunSeed = math.max(1u, RunMapMath.Hash((uint)run.RunEpoch, run.HostSalt));
|
||||
run.NodeBudgetRemaining = Tuning.ExpeditionNodeBudget;
|
||||
run.RoomsClearedThisRun = 0;
|
||||
run.BoonPickCounter = 0; // fresh boon-band provenance per run
|
||||
run.LastTerminalCleared = 0;
|
||||
|
||||
var map = RunMapMath.Generate(run.RunSeed);
|
||||
info.RunSeed = run.RunSeed;
|
||||
info.RoomCount = map.LayerCount;
|
||||
info.LaunchTick = TickUtil.NonZero(now + LaunchCountdownTicks);
|
||||
info.Lifecycle = RunLifecycle.Launching;
|
||||
}
|
||||
run.WasAllReady = (byte)(allReady ? 1 : 0);
|
||||
break;
|
||||
}
|
||||
|
||||
case RunLifecycle.Launching:
|
||||
{
|
||||
// Un-ready during the countdown aborts back to Staging (the telegraph's escape hatch).
|
||||
if (!allReady)
|
||||
{
|
||||
info.LaunchTick = 0u;
|
||||
info.Lifecycle = RunLifecycle.Staging;
|
||||
run.WasAllReady = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
bool due = info.LaunchTick == 0u || !new NetworkTick(info.LaunchTick).IsNewerThan(serverTick);
|
||||
if (due)
|
||||
{
|
||||
// Conscript the party: the launch roster is EVERYONE connected (N7 co-location — all at
|
||||
// base, all ready). Room advances teleport ONLY RunParticipants, so a mid-run late joiner
|
||||
// is never yanked into the fight; the tag is released on the Returning edge.
|
||||
var conscript = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (_, playerE) in
|
||||
SystemAPI.Query<RefRO<PlayerReady>>().WithAll<PlayerTag>().WithEntityAccess())
|
||||
conscript.AddComponent<RunParticipant>(playerE);
|
||||
conscript.Playback(state.EntityManager);
|
||||
conscript.Dispose();
|
||||
|
||||
// Enter room 0 (the guaranteed Combat landing at column 0, sub-slot 0).
|
||||
var map = RunMapMath.Generate(run.RunSeed);
|
||||
EnterRoom(ref state, ref info, ref run, in map, layer: 0, col: 0, baseCenter, bumpEpoch: true);
|
||||
info.LaunchTick = 0u;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case RunLifecycle.InRoom:
|
||||
{
|
||||
// All expedition players gone (disconnect/death-warp edge) → clean abort, no credit.
|
||||
if (expeditionPlayers == 0)
|
||||
{
|
||||
run.LastTerminalCleared = 0;
|
||||
info.Lifecycle = RunLifecycle.Returning;
|
||||
break;
|
||||
}
|
||||
|
||||
// The room clear edge — the replicated objective RoomEnemyDirectorSystem computed LAST tick
|
||||
// (one-tick-late by construction; no ordering back-edge). Teardown happens AT THIS ENTRY, the
|
||||
// next room spawns on the advance tick → ≥1 empty tick, exactly one room alive.
|
||||
if (SystemAPI.HasComponent<ExpeditionObjective>(dirEntity)
|
||||
&& SystemAPI.GetComponent<ExpeditionObjective>(dirEntity).State == ExpeditionObjectiveState.Cleared)
|
||||
{
|
||||
// DR-046: teardown MOVED to the RoomExplore exit — the room + resource nodes persist through
|
||||
// RoomReward + the loot window so the party can mine after clearing.
|
||||
|
||||
run.RoomsClearedThisRun += 1;
|
||||
if (info.CurrentRoom >= info.RoomCount - 1)
|
||||
run.LastTerminalCleared = 1; // the Boss fell — a genuine terminal clear
|
||||
run.RewardGraceTick = TickUtil.NonZero(now + RewardGraceTicks);
|
||||
info.Lifecycle = RunLifecycle.RoomReward;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case RunLifecycle.RoomReward:
|
||||
{
|
||||
if (expeditionPlayers == 0 && run.LastTerminalCleared == 0)
|
||||
{
|
||||
info.Lifecycle = RunLifecycle.Returning;
|
||||
break;
|
||||
}
|
||||
|
||||
// Exit gate: every SURVIVING player has picked (BoonOffer.Pending==0 — inert until Step 10)
|
||||
// OR the grace elapsed (wrap-safe IsNewerThan, never raw uint — F4).
|
||||
// Only EXPEDITION players hold the gate (BoonOfferSystem's documented contract): a player who
|
||||
// died and respawned to base must not stall the party (post-impl review, confirmed major).
|
||||
bool anyPending = false;
|
||||
foreach (var (offer, pregion) in
|
||||
SystemAPI.Query<RefRO<BoonOffer>, RefRO<RegionTag>>().WithAll<PlayerTag>())
|
||||
if (pregion.ValueRO.Region == RegionId.Expedition && offer.ValueRO.Pending != 0)
|
||||
{ anyPending = true; break; }
|
||||
bool graceElapsed = run.RewardGraceTick == 0u
|
||||
|| !new NetworkTick(run.RewardGraceTick).IsNewerThan(serverTick);
|
||||
if (anyPending && !graceElapsed)
|
||||
break;
|
||||
|
||||
run.RewardGraceTick = 0u;
|
||||
// NO offer survives this gate: zero every straggler (a dead-respawned base player the
|
||||
// auto-pick deliberately skips, a grace-expired AFK) so a stale Pending can never wedge the
|
||||
// HUD modal open or stall a later reward gate (post-impl review, confirmed major).
|
||||
foreach (var offer in SystemAPI.Query<RefRW<BoonOffer>>().WithAll<PlayerTag>())
|
||||
offer.ValueRW = default;
|
||||
// DR-046: don't advance yet — open the LOOT WINDOW. The cleared room + its resource nodes persist
|
||||
// (teardown moved to the RoomExplore exit); a portal is up. Leave via the portal or a soft timeout.
|
||||
run.ExploreGraceTick = TickUtil.NonZero(now + Tuning.ExploreGraceTicks);
|
||||
if (SystemAPI.HasComponent<PortalCommand>(dirEntity))
|
||||
SystemAPI.SetComponent(dirEntity, default(PortalCommand)); // fresh portal latch for this window
|
||||
info.Lifecycle = RunLifecycle.RoomExplore;
|
||||
break;
|
||||
}
|
||||
|
||||
case RunLifecycle.RoomExplore:
|
||||
{
|
||||
// DR-046 LOOT WINDOW: the cleared room + its resource nodes persist; a portal is up. Advance when a
|
||||
// participant interacts the portal (PortalCommand, set by PortalInteractReceiveSystem) OR the soft
|
||||
// timeout elapses (never a softlock). Abort if the expedition emptied (unless the boss already fell).
|
||||
if (expeditionPlayers == 0) // DR-046 fix: an empty expedition advances NOW (boss -> Returning banks the win
|
||||
{ // immediately; non-boss -> abort no-credit) — no ~30s ExploreGrace dead-time on the win moment.
|
||||
run.ExploreGraceTick = 0u;
|
||||
info.Lifecycle = RunLifecycle.Returning;
|
||||
break;
|
||||
}
|
||||
bool portalUsed = SystemAPI.HasComponent<PortalCommand>(dirEntity)
|
||||
&& SystemAPI.GetComponent<PortalCommand>(dirEntity).HasInteract != 0;
|
||||
bool exploreTimedOut = run.ExploreGraceTick == 0u
|
||||
|| !new NetworkTick(run.ExploreGraceTick).IsNewerThan(serverTick);
|
||||
if (!portalUsed && !exploreTimedOut)
|
||||
break; // still looting
|
||||
|
||||
run.ExploreGraceTick = 0u;
|
||||
if (SystemAPI.HasComponent<PortalCommand>(dirEntity))
|
||||
SystemAPI.SetComponent(dirEntity, default(PortalCommand));
|
||||
|
||||
// The MOVED teardown: NOW destroy the cleared room (nodes + clutter), then advance.
|
||||
var exploreEcb = new EntityCommandBuffer(Allocator.Temp);
|
||||
RoomTeardown.DestroyRoom(m_RoomTagged, exploreEcb, (byte)(info.CurrentRoom & 0xFF));
|
||||
exploreEcb.Playback(state.EntityManager);
|
||||
exploreEcb.Dispose();
|
||||
|
||||
if (run.LastTerminalCleared != 0)
|
||||
{
|
||||
info.Lifecycle = RunLifecycle.Returning; // boss cleared — go home a winner
|
||||
}
|
||||
else
|
||||
{
|
||||
// Open the branching ROUTE GATE (relocated from RoomReward): publish authoritative reachable
|
||||
// options; RouteSelect is the teardown gap (the room is gone now).
|
||||
var map = RunMapMath.Generate(run.RunSeed);
|
||||
int optionCount = RunMapMath.ReachableOptions(in map, info.CurrentRoom, info.CurrentCol, out var cols);
|
||||
if (optionCount == 0)
|
||||
{
|
||||
info.RouteOptionCount = 0;
|
||||
info.Lifecycle = RunLifecycle.Returning;
|
||||
}
|
||||
else
|
||||
{
|
||||
int nextLayer = info.CurrentRoom + 1;
|
||||
info.RouteOptionCount = (byte)math.min(optionCount, 3);
|
||||
info.RouteOpt0Col = cols.Length > 0 ? cols[0] : (byte)0;
|
||||
info.RouteOpt1Col = cols.Length > 1 ? cols[1] : (byte)0;
|
||||
info.RouteOpt2Col = cols.Length > 2 ? cols[2] : (byte)0;
|
||||
info.RouteOpt0Type = cols.Length > 0 ? map.Node(nextLayer, cols[0]).RoomType : (byte)0;
|
||||
info.RouteOpt1Type = cols.Length > 1 ? map.Node(nextLayer, cols[1]).RoomType : (byte)0;
|
||||
info.RouteOpt2Type = cols.Length > 2 ? map.Node(nextLayer, cols[2]).RoomType : (byte)0;
|
||||
run.RouteGraceTick = TickUtil.NonZero(now + RouteGraceTicks);
|
||||
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
|
||||
SystemAPI.SetComponent(dirEntity, default(RouteCommand));
|
||||
info.Lifecycle = RunLifecycle.RouteSelect;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
case RunLifecycle.RouteSelect:
|
||||
{
|
||||
// Predicate order is LOAD-BEARING (review F2): abort → pick-consume → grace. A same-tick pick
|
||||
// from a vanishing party must never resurrect the run (EnterRoom would conscript base players);
|
||||
// an accepted pick must beat a same-tick grace expiry (the player was told "committed").
|
||||
if (expeditionPlayers == 0)
|
||||
{
|
||||
info.RouteOptionCount = 0; // close the gate ON the abort edge itself (review F3)
|
||||
run.LastTerminalCleared = 0;
|
||||
info.Lifecycle = RunLifecycle.Returning;
|
||||
break;
|
||||
}
|
||||
|
||||
var cmd = SystemAPI.HasComponent<RouteCommand>(dirEntity)
|
||||
? SystemAPI.GetComponent<RouteCommand>(dirEntity)
|
||||
: default;
|
||||
bool routeGraceElapsed = run.RouteGraceTick == 0u
|
||||
|| !new NetworkTick(run.RouteGraceTick).IsNewerThan(serverTick);
|
||||
|
||||
if (cmd.HasPick != 0)
|
||||
{
|
||||
// The party's committed choice (first-accepted-wins latch; any-player-first-commits).
|
||||
byte chosenCol = cmd.OptionIndex == 2 ? info.RouteOpt2Col
|
||||
: cmd.OptionIndex == 1 ? info.RouteOpt1Col : info.RouteOpt0Col;
|
||||
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
|
||||
SystemAPI.SetComponent(dirEntity, default(RouteCommand));
|
||||
run.RouteGraceTick = 0u;
|
||||
var map = RunMapMath.Generate(run.RunSeed);
|
||||
EnterRoom(ref state, ref info, ref run, in map, info.CurrentRoom + 1, chosenCol, baseCenter, bumpEpoch: true);
|
||||
}
|
||||
else if (routeGraceElapsed)
|
||||
{
|
||||
// AFK backstop: deterministic LOWEST-INDEX reachable option (RouteOpt0 is ascending-first).
|
||||
run.RouteGraceTick = 0u;
|
||||
var map = RunMapMath.Generate(run.RunSeed);
|
||||
EnterRoom(ref state, ref info, ref run, in map, info.CurrentRoom + 1, info.RouteOpt0Col, baseCenter, bumpEpoch: true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case RunLifecycle.Returning:
|
||||
{
|
||||
// PARTICIPANT teleport home + region flip + roster release. Only the launch roster comes
|
||||
// home (a mid-run joiner already at base keeps its position); the tag removal re-opens the
|
||||
// next run's conscription cleanly (post-impl review, confirmed medium).
|
||||
int idx = 0;
|
||||
var homebound = new EntityCommandBuffer(Allocator.Temp);
|
||||
foreach (var (region, xform, playerE) in
|
||||
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>()
|
||||
.WithAll<PlayerTag, RunParticipant>().WithEntityAccess())
|
||||
{
|
||||
region.ValueRW.Region = RegionId.Base;
|
||||
var p = baseCenter;
|
||||
p.x += 1.5f * idx;
|
||||
p.y = xform.ValueRO.Position.y;
|
||||
xform.ValueRW.Position = p;
|
||||
homebound.RemoveComponent<RunParticipant>(playerE);
|
||||
idx++;
|
||||
}
|
||||
homebound.Playback(state.EntityManager);
|
||||
homebound.Dispose();
|
||||
|
||||
// THE terminal bank — once per RunEpoch (equality latch, F7), CLEAR-GATED (D-F3).
|
||||
if (run.LastBankedRunEpoch != run.RunEpoch)
|
||||
{
|
||||
run.LastBankedRunEpoch = run.RunEpoch;
|
||||
|
||||
// Always: the honest depth high-water (actual rooms cleared, never the planned count).
|
||||
if (SystemAPI.HasComponent<MetaCounters>(dirEntity))
|
||||
{
|
||||
var meta = SystemAPI.GetComponent<MetaCounters>(dirEntity);
|
||||
meta.MaxDepthReached = math.max(meta.MaxDepthReached, run.RoomsClearedThisRun);
|
||||
if (run.LastTerminalCleared != 0)
|
||||
meta.RunsCompleted += 1;
|
||||
SystemAPI.SetComponent(dirEntity, meta);
|
||||
info.RunsCompleted = meta.RunsCompleted; // HUD mirror
|
||||
info.MaxDepthReached = meta.MaxDepthReached; // HUD mirror
|
||||
}
|
||||
|
||||
// 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
|
||||
// boon-band StatModifier (replicates via the [GhostField] buffer; StatRecompute reverts the
|
||||
// effective stats on both worlds) and zeroes any straggler offer. Class/meta/equip bands are
|
||||
// disjoint and survive. Idempotent — safe on every Returning tick.
|
||||
foreach (var (mods, timed, fx, offer) in
|
||||
SystemAPI.Query<DynamicBuffer<StatModifier>, DynamicBuffer<TimedModifier>, RefRW<BoonEffects>, RefRW<BoonOffer>>().WithAll<PlayerTag>())
|
||||
{
|
||||
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.BoonSourceIdBase,
|
||||
Tuning.BoonSourceIdBase + Tuning.BoonSourceIdSpan);
|
||||
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.PrepSourceIdBase,
|
||||
Tuning.PrepSourceIdBase + Tuning.PrepSourceIdSpan); // DR-046: strip the run-scoped prep loadout too
|
||||
|
||||
// Phase 1.7: zero the mechanic-changer boons + strip the stale Frenzy timed row (its paired
|
||||
// StatModifier is already cleared by the boon-band range-strip above).
|
||||
fx.ValueRW = default;
|
||||
TimedModifierUtil.RemoveBySourceId(timed, Tuning.FrenzySourceId);
|
||||
offer.ValueRW = default;
|
||||
}
|
||||
|
||||
// Clear EVERY ready flag — the next run needs a fresh, deliberate ready-check from everyone.
|
||||
foreach (var ready in SystemAPI.Query<RefRW<PlayerReady>>().WithAll<PlayerTag>())
|
||||
ready.ValueRW.Value = 0;
|
||||
|
||||
run.RewardGraceTick = 0u;
|
||||
run.RouteGraceTick = 0u; // gate hygiene (review F5): no stale grace into the next run
|
||||
if (SystemAPI.HasComponent<RouteCommand>(dirEntity))
|
||||
SystemAPI.SetComponent(dirEntity, default(RouteCommand)); // no leftover latch either
|
||||
run.WasAllReady = 0;
|
||||
run.LastTerminalCleared = 0;
|
||||
|
||||
info.CurrentRoom = 0;
|
||||
info.RouteOptionCount = 0;
|
||||
info.LaunchTick = 0u;
|
||||
info.Lifecycle = RunLifecycle.Staging;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Single write-back point — RunInfo/RunRuntime are ALWAYS published (F12: the HUD readout can never
|
||||
// freeze stale behind a branch's early-break).
|
||||
SystemAPI.SetComponent(dirEntity, info);
|
||||
SystemAPI.SetComponent(dirEntity, run);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enter room (<paramref name="layer"/>, <paramref name="col"/>): publish the node as the single plan
|
||||
/// authority (<see cref="RunRuntime.CurrentNodeId"/>/<c>CurrentRoomType</c> — the field/enemy directors NEVER
|
||||
/// re-derive it), flip the ping-pong sub-slot, bump <see cref="RunRuntime.RoomEpoch"/> so the room systems
|
||||
/// reseed, and teleport the party onto the new origin (Position write in place — never FromPosition).
|
||||
/// </summary>
|
||||
void EnterRoom(ref SystemState state, ref RunInfo info, ref RunRuntime run, in RunMap map,
|
||||
int layer, int col, float3 baseCenter, bool bumpEpoch)
|
||||
{
|
||||
var node = map.Node(layer, col);
|
||||
run.ActiveSubSlot = (byte)(layer & 1);
|
||||
run.CurrentNodeId = RunMap.NodeId(layer, col);
|
||||
run.CurrentCol = (byte)col;
|
||||
run.CurrentRoomType = node.RoomType;
|
||||
if (bumpEpoch)
|
||||
run.RoomEpoch += 1;
|
||||
|
||||
info.CurrentRoom = layer;
|
||||
info.CurrentCol = (byte)col;
|
||||
info.CurrentRoomType = node.RoomType;
|
||||
info.CurrentBiome = node.Biome;
|
||||
info.RouteOptionCount = 0;
|
||||
|
||||
float3 roomOrigin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
|
||||
int idx = 0;
|
||||
foreach (var (region, xform) in
|
||||
SystemAPI.Query<RefRW<RegionTag>, RefRW<LocalTransform>>().WithAll<PlayerTag, RunParticipant>())
|
||||
{
|
||||
region.ValueRW.Region = RegionId.Expedition;
|
||||
var p = roomOrigin;
|
||||
p.x += 1.5f * idx; // small spread so kinematic capsules don't stack
|
||||
p.y = xform.ValueRO.Position.y;
|
||||
xform.ValueRW.Position = p;
|
||||
idx++;
|
||||
}
|
||||
|
||||
info.Lifecycle = RunLifecycle.InRoom;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7c36de338378264e9aa0ad2c2512e7f
|
||||
Reference in New Issue
Block a user