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,19 +0,0 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client -> server request to build a structure of <see cref="StructureType"/> at grid cell
|
||||
/// (<see cref="CellX"/>, <see cref="CellZ"/>). A one-off action, so an RPC (mirrors StorageOpRequest).
|
||||
/// StructureType is a byte; the cell is two int scalars (NOT an int2) to stay
|
||||
/// within the project's scalar-only RPC payload precedent (avoids first-of-its-kind composite-math-in-RPC
|
||||
/// codegen risk on Netcode 1.x). The server re-validates legality + cost authoritatively.
|
||||
/// </summary>
|
||||
public struct BuildPlaceRequest : IRpcCommand
|
||||
{
|
||||
public byte StructureType;
|
||||
public int CellX;
|
||||
public int CellZ;
|
||||
public byte Direction;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dbcc491dc3dd853459cd8cfad2458b17
|
||||
@@ -1,24 +0,0 @@
|
||||
using Unity.Collections;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, deterministic build-placement helpers (unit-tested like <see cref="BaseGridMath"/> /
|
||||
/// <c>StorageMath</c>). Occupancy is DERIVED from the live structure set each placement (the structure
|
||||
/// ghosts are the source of truth — restart- and replay-order-safe), never cached on the immutable
|
||||
/// baked <see cref="BaseAnchor"/>. The server passes a Temp <see cref="NativeHashSet{T}"/> of occupied
|
||||
/// cells built by scanning live <see cref="PlacedStructure"/> ghosts.
|
||||
/// </summary>
|
||||
public static class BuildPlacementMath
|
||||
{
|
||||
/// <summary>True if <paramref name="cell"/> is occupied in the derived set.</summary>
|
||||
public static bool IsOccupied(in NativeHashSet<int2> occupied, int2 cell) => occupied.Contains(cell);
|
||||
|
||||
/// <summary>Full server placement legality: the cell is in-plot (half-open, negative-safe) AND not occupied.</summary>
|
||||
public static bool CanPlace(in BaseAnchor anchor, in NativeHashSet<int2> occupied, int2 cell)
|
||||
{
|
||||
return BaseGridMath.IsCellInPlot(anchor, cell) && !occupied.Contains(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 203dcbd4f9cc089408633b0bb6ccb2c1
|
||||
@@ -1,31 +0,0 @@
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure validity check for the client build-placement PREVIEW (the ground-ghost colour) — the same legality
|
||||
/// the server re-validates authoritatively in BuildPlaceSystem, computed client-side so the ghost can read
|
||||
/// green (valid) vs red (why-not). No managed types / RNG / wall-clock → unit-testable. The caller supplies
|
||||
/// the live occupancy result + the affordability inputs (it owns the structure scan + the ledger read).
|
||||
/// </summary>
|
||||
public static class BuildPreviewMath
|
||||
{
|
||||
public const byte Valid = 0;
|
||||
public const byte OutOfPlot = 1;
|
||||
public const byte Occupied = 2;
|
||||
public const byte Unaffordable = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate placement at <paramref name="cell"/>: must be in-plot, unoccupied, and affordable.
|
||||
/// <paramref name="occupied"/> = the caller's live-structure cell check; <paramref name="have"/>/<paramref name="cost"/>
|
||||
/// the resource on hand vs the catalog cost. Returns the first failing reason, else <see cref="Valid"/>.
|
||||
/// </summary>
|
||||
public static byte Evaluate(in BaseAnchor anchor, int2 cell, bool occupied, int have, int cost)
|
||||
{
|
||||
if (!BaseGridMath.IsCellInPlot(anchor, cell)) return OutOfPlot;
|
||||
if (occupied) return Occupied;
|
||||
if (have < cost) return Unaffordable;
|
||||
return Valid;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4deb41f803f65bd4b946354e4a2adcf2
|
||||
@@ -1,72 +0,0 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Structure type ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard). Ids 1-4 are
|
||||
/// RETIRED (EB-2 turret defense + M7 automation machines, deleted in the LANTERN purge) and stay reserved
|
||||
/// so PlacedStructure.Type's [GhostField] serializer + old save bytes never re-mean.
|
||||
/// </summary>
|
||||
public static class StructureType
|
||||
{
|
||||
public const byte None = 0;
|
||||
// RETIRED ids — reserved, do not reuse:
|
||||
public const byte Turret = 1;
|
||||
public const byte Harvester = 2;
|
||||
public const byte Fabricator = 3;
|
||||
public const byte Conveyor = 4;
|
||||
// Live buildables:
|
||||
public const byte Wall = 5;
|
||||
public const byte Pylon = 6;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A built base structure occupying one grid cell. An ownerless INTERPOLATED ghost (RegionTag{Base},
|
||||
/// world-owned, runtime-spawned by BuildPlaceSystem). <see cref="Type"/> is the only replicated field
|
||||
/// (a cheap byte for client visual branching); <see cref="Cell"/> is server-only (clients derive it from
|
||||
/// the replicated LocalTransform via <see cref="BaseGridMath.WorldToCell"/>, so it stays off the wire).
|
||||
/// <see cref="NextTick"/> / <see cref="LastProcessedTick"/> are server-only raw NetworkTick values
|
||||
/// (<see cref="TickUtil.NonZero"/>-guarded; 0 = inactive), kept for future timed structures (the turret
|
||||
/// cooldown + production catch-up that used them are retired — LANTERN purge).
|
||||
/// </summary>
|
||||
public struct PlacedStructure : IComponentData
|
||||
{
|
||||
/// <summary>Structure type (see <see cref="StructureType"/>); the only replicated field.</summary>
|
||||
[GhostField] public byte Type;
|
||||
|
||||
/// <summary>Occupied grid cell (server-only; clients derive it from LocalTransform).</summary>
|
||||
public int2 Cell;
|
||||
|
||||
/// <summary>Next action tick (server-only). 0 = inactive.</summary>
|
||||
public uint NextTick;
|
||||
|
||||
/// <summary>Last tick this structure was processed (server-only). Stamped at spawn.</summary>
|
||||
public uint LastProcessedTick;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One row of the build catalog: cost + prefab per structure type. Modeled on AbilityPrefabElement
|
||||
/// (prefab baked via GetEntity, NEVER inside a blob — blobs don't remap entity refs).
|
||||
/// </summary>
|
||||
public struct StructureCatalogEntry : IBufferElementData
|
||||
{
|
||||
public byte Type;
|
||||
public Entity Prefab;
|
||||
public byte CostResourceId;
|
||||
public int CostAmount;
|
||||
}
|
||||
|
||||
/// <summary>Tag on the baked singleton carrying the <see cref="StructureCatalogEntry"/> buffer (the build cost/prefab table).</summary>
|
||||
public struct StructureCatalog : IComponentData { }
|
||||
|
||||
/// <summary>
|
||||
/// Marks a structure PLACED by a player at runtime (BuildPlaceSystem) or restored from a save — i.e. the
|
||||
/// persistable set, as opposed to anything baked into the subscene. SaveWriteSystem scans only these and
|
||||
/// BaseRestoreSystem re-adds the tag, so save/restore is the single source of truth for player builds.
|
||||
/// Server-only (not replicated). (Re-homed here from the retired automation components — LANTERN purge.)
|
||||
/// </summary>
|
||||
public struct RuntimePlacedTag : IComponentData { }
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00d3379caf4807d4ebd97432848dd5d5
|
||||
@@ -19,7 +19,7 @@ namespace ProjectM.Simulation
|
||||
/// AbilitySocket (loadout), SocketCooldown (hot per-socket cooldown), EffectiveSocketStats (per-socket
|
||||
/// folded stats from StatRecomputeSystem). To stay under the 7-type SystemAPI.Query cap, the query holds
|
||||
/// only PlayerInput/PlayerFacing/LocalTransform/GhostOwner and reads the socket data by entity via
|
||||
/// BufferLookup/ComponentLookup (mirroring the BoonEffects lookup).
|
||||
/// BufferLookup/ComponentLookup.okup.
|
||||
///
|
||||
/// SpawnId key (owner14 | socket2 | fireCount12 | fork4) reserves socket bits so two sockets firing the
|
||||
/// same-prefab projectile on one tick classify to DISTINCT ghosts (the review's NP-1/RS-1/DB-3 fix). The
|
||||
@@ -37,11 +37,9 @@ namespace ProjectM.Simulation
|
||||
[BurstCompile]
|
||||
public partial struct AbilityFireSystem : ISystem
|
||||
{
|
||||
// Server-only knockback stamp for the Cone (guarded + boss-immune).
|
||||
// Server-only knockback stamp for the Cone.ss-immune).
|
||||
ComponentLookup<KnockbackState> m_KnockbackLookup;
|
||||
ComponentLookup<BossState> m_BossLookup;
|
||||
// Owner-replicated mechanic-changer boons, read by the player entity.
|
||||
ComponentLookup<BoonEffects> m_BoonEffectsLookup;
|
||||
// Boss knockback-immunity and BoonEffects lookups deleted 2026-08-07 (audit purge).
|
||||
// LANTERN socket kit, read by the player entity so the fire query stays at 4 type args (7-arg cap).
|
||||
BufferLookup<AbilitySocket> m_SocketLookup;
|
||||
ComponentLookup<SocketCooldown> m_SocketCdLookup;
|
||||
@@ -65,8 +63,7 @@ namespace ProjectM.Simulation
|
||||
state.RequireForUpdate<AbilityDatabase>();
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
||||
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
||||
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
|
||||
|
||||
m_SocketLookup = state.GetBufferLookup<AbilitySocket>(isReadOnly: true);
|
||||
m_SocketCdLookup = state.GetComponentLookup<SocketCooldown>(isReadOnly: false);
|
||||
m_EffSocketLookup = state.GetBufferLookup<EffectiveSocketStats>(isReadOnly: true);
|
||||
@@ -96,8 +93,7 @@ namespace ProjectM.Simulation
|
||||
var tcfg = SystemAPI.TryGetSingleton<TuningConfig>(out var tcv) ? tcv : TuningConfig.Defaults();
|
||||
uint coneContact = (uint)math.max(0f, tcfg.ConeContactTicks);
|
||||
m_KnockbackLookup.Update(ref state);
|
||||
m_BossLookup.Update(ref state);
|
||||
m_BoonEffectsLookup.Update(ref state);
|
||||
|
||||
m_SocketLookup.Update(ref state);
|
||||
m_SocketCdLookup.Update(ref state);
|
||||
m_EffSocketLookup.Update(ref state);
|
||||
@@ -135,8 +131,8 @@ namespace ProjectM.Simulation
|
||||
var effSockets = m_EffSocketLookup[entity];
|
||||
var cd = m_SocketCdLookup[entity]; // struct copy; written back after mutation
|
||||
|
||||
BoonEffects bfx = m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity] : default;
|
||||
bool pull = (bfx.Flags & BoonFlag.KnockToPull) != 0;
|
||||
// Boons deleted 2026-08-07 (audit purge): pull was the KnockToPull mechanic-changer.
|
||||
const bool pull = false;
|
||||
|
||||
// 07-21 G6 (review wf_98bf1268): fire a DUE scheduled cone BEFORE the cast loop (the
|
||||
// MeleeCleavePending idiom — wrap-safe elapsed compare, tick-batch-proof, consumed by zeroing).
|
||||
@@ -154,7 +150,7 @@ namespace ProjectM.Simulation
|
||||
{
|
||||
float2 pFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
|
||||
FireCone(xform.ValueRO.Position, pFace, effSockets[pend.Socket], owner.ValueRO.NetworkId,
|
||||
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup);
|
||||
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup);
|
||||
}
|
||||
m_ConePendingLookup[entity] = default; // consume (drop on mismatch)
|
||||
}
|
||||
@@ -223,7 +219,7 @@ namespace ProjectM.Simulation
|
||||
{
|
||||
float2 fFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction);
|
||||
FireCone(xform.ValueRO.Position, fFace, effSockets[armed.Socket], owner.ValueRO.NetworkId,
|
||||
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup);
|
||||
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup);
|
||||
}
|
||||
m_ConePendingLookup[entity] = new ConeContactPending
|
||||
{
|
||||
@@ -236,7 +232,7 @@ namespace ProjectM.Simulation
|
||||
// Legacy immediate (knob 0, or a plain test world without the baked pending slot).
|
||||
float2 cFace = FacingMath.ResolveAim(input.ValueRO.Aim, facing.ValueRO.Direction); // manual-aim (07-15): cursor wins; facing fallback = resting gamepad stick
|
||||
FireCone(xform.ValueRO.Position, cFace, es, owner.ValueRO.NetworkId,
|
||||
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup, m_BossLookup);
|
||||
serverTick, pull, coneTargets, coneTargetPos, ref ecb, ref m_KnockbackLookup);
|
||||
}
|
||||
}
|
||||
cd.Set(sk, TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, es.CooldownTicks)));
|
||||
@@ -333,10 +329,10 @@ namespace ProjectM.Simulation
|
||||
dir = AutoTarget.Resolve(xform.ValueRO.Position, rawAim, es.AutoTargetRange, es.AutoTargetConeRadians, candidates);
|
||||
}
|
||||
|
||||
byte pierce = bfx.Pierce;
|
||||
byte chain = bfx.Chain;
|
||||
byte pierce = 0; // boon Pierce deleted 2026-08-07
|
||||
byte chain = 0; // boon Chain deleted 2026-08-07
|
||||
byte projFlags = (byte)((pull ? ProjectileEffectFlag.Pull : 0) | adef.EffectFlags);
|
||||
int shots = 1 + math.min((int)bfx.Fork, 8);
|
||||
int shots = 1; // boon Fork deleted 2026-08-07
|
||||
|
||||
for (int s = 0; s < shots; s++)
|
||||
{
|
||||
@@ -391,7 +387,7 @@ namespace ProjectM.Simulation
|
||||
static void FireCone(float3 casterPos, float2 face, in EffectiveSocketStats es, int ownerNetId,
|
||||
NetworkTick serverTick, bool pull, in NativeList<Entity> coneTargets,
|
||||
in NativeList<float3> coneTargetPos, ref EntityCommandBuffer ecb,
|
||||
ref ComponentLookup<KnockbackState> knockbackLookup, in ComponentLookup<BossState> bossLookup)
|
||||
ref ComponentLookup<KnockbackState> knockbackLookup)
|
||||
{
|
||||
float cRange = math.max(0.1f, es.Range);
|
||||
float cCosHalf = math.cos(math.clamp(es.AutoTargetConeRadians, 0.01f, 3.14159f));
|
||||
@@ -406,7 +402,7 @@ namespace ProjectM.Simulation
|
||||
SourceNetworkId = ownerNetId,
|
||||
SourceTick = cStamp,
|
||||
});
|
||||
KnockbackUtil.Stamp(ref knockbackLookup, bossLookup, coneTargets[ci],
|
||||
KnockbackUtil.Stamp(ref knockbackLookup, coneTargets[ci],
|
||||
casterPos, coneTargetPos[ci], face, Tuning.KnockbackSpeed,
|
||||
TickUtil.NonZero(serverTick.TickIndexForValidTick + (uint)math.max(1, Tuning.KnockbackDurationTicks)), pull);
|
||||
}
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// One authored boon in the catalog blob. A boon is EITHER a flat-stat modifier (<see cref="Kind"/>==0 —
|
||||
/// <see cref="Target"/>/<see cref="Op"/>/<see cref="Value"/> map 1:1 onto a <see cref="StatModifier"/> row, the
|
||||
/// original path) OR a Phase-1.7 MECHANIC-CHANGER (<see cref="Kind"/>==1 — <see cref="EffectKind"/> selects the
|
||||
/// hook; for the stacking kinds Pierce/Fork/Chain <see cref="Value"/> is the per-pick count delta, else it's a
|
||||
/// flag). Bytes, never enums, on the baked path. <see cref="Id"/> is the stable key the replicated
|
||||
/// <c>BoonOffer</c> options + pick RPC carry. <see cref="Weight"/> is the rarity draw weight (common 100 /
|
||||
/// uncommon 60 / rare 30 / epic 10). <see cref="ClassMask"/>: bit0 = Warrior (classId 0), bit1 = Ranger
|
||||
/// (classId 1), 3 = both. <see cref="Family"/> tags synergy/dedup — no two same-family options in one deal.
|
||||
/// </summary>
|
||||
public struct BoonDefBlob
|
||||
{
|
||||
public byte Id;
|
||||
public byte Target; // StatTarget as byte (Kind==0)
|
||||
public byte Op; // ModOp as byte (Kind==0)
|
||||
public float Value; // Kind==0: modifier magnitude; Kind==1 stacking: per-pick count delta
|
||||
public byte Weight;
|
||||
public byte ClassMask;
|
||||
public byte Kind; // 0 = stat, 1 = mechanic-changer (Phase 1.7)
|
||||
public byte EffectKind; // BoonEffectKind byte (Kind==1)
|
||||
public byte Family; // BoonFamily byte — dedup/dominated/bias tag
|
||||
public FixedString64Bytes Name;
|
||||
public FixedString128Bytes Desc;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synergy/dedup tags for <see cref="BoonDefBlob.Family"/>. Bytes (Burst-safe). No two options of the same
|
||||
/// family are offered in one deal (dominated-offer protection — kills the "-15% vs -25% cooldown" case); owning
|
||||
/// a mechanic family biases future offers toward it (light build-bias).
|
||||
/// </summary>
|
||||
public static class BoonFamily
|
||||
{
|
||||
public const byte None = 0;
|
||||
public const byte Projectile = 1;
|
||||
public const byte Melee = 2;
|
||||
public const byte Mobility = 3;
|
||||
public const byte OnKill = 4;
|
||||
public const byte StatDamage = 5;
|
||||
public const byte StatHealth = 6;
|
||||
public const byte StatSpeed = 7;
|
||||
public const byte StatCooldown = 8;
|
||||
}
|
||||
|
||||
/// <summary>The baked boon pool (config blob, both worlds, NOT replicated — the AbilityDatabase pattern).</summary>
|
||||
public struct BoonCatalogBlob
|
||||
{
|
||||
public BlobArray<BoonDefBlob> Defs;
|
||||
}
|
||||
|
||||
/// <summary>Singleton component carrying the baked catalog (place ONE BoonCatalogAuthoring in the subscene).</summary>
|
||||
public struct BoonCatalog : IComponentData
|
||||
{
|
||||
public BlobAssetReference<BoonCatalogBlob> Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only bookkeeping for <c>BoonOfferSystem</c>, attached at runtime beside the catalog singleton (the
|
||||
/// RoomFieldState idiom): the RoomEpoch offers were last drawn for — int equality, one offer set per room.
|
||||
/// </summary>
|
||||
public struct BoonOfferState : IComponentData
|
||||
{
|
||||
public int OfferedRoomEpoch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure, deterministic boon selection math — integer-hash only (<c>RunMapMath.Hash</c> chain, no RNG state), so
|
||||
/// an offer is a reproducible function of (runSeed, room, player, ownedState-at-draw). The owned-state input is
|
||||
/// safe because <c>BoonOfferSystem</c> draws each player exactly ONCE per RoomEpoch (the OfferedRoomEpoch latch)
|
||||
/// — the client never re-runs it. EditMode-tested.
|
||||
/// </summary>
|
||||
public static class BoonMath
|
||||
{
|
||||
/// <summary>Class-mask bit for a wire class id (0 = Warrior, 1 = Ranger).</summary>
|
||||
public static byte MaskFor(byte classId) => (byte)(1 << (classId & 1));
|
||||
|
||||
/// <summary>
|
||||
/// Draw up to 3 DISTINCT, rarity-weighted, class-filtered boon ids from the pool. Deterministic per
|
||||
/// (<paramref name="offerSeed"/>, <paramref name="owned"/>). Phase 1.7: a non-stacking FLAG effect the player
|
||||
/// already owns is excluded (dedup); no two options share a <see cref="BoonFamily"/> in one deal
|
||||
/// (dominated-offer protection); a candidate whose family matches an owned effect's family draws at ×1.5
|
||||
/// weight (light build-bias). Falls back deterministically when draws collide. Returns the number of
|
||||
/// distinct ids (tail repeats the last when the legal pool has fewer than 3).
|
||||
/// </summary>
|
||||
public static int PickBoons(uint offerSeed, byte classId, in BoonEffects owned, ref BoonCatalogBlob pool,
|
||||
out byte o0, out byte o1, out byte o2)
|
||||
{
|
||||
byte classBit = MaskFor(classId);
|
||||
int ownedFamilies = OwnedFamilyMask(owned);
|
||||
|
||||
var candidates = new FixedList128Bytes<byte>(); // catalog indices
|
||||
var weights = new FixedList128Bytes<byte>(); // biased draw weight per candidate (parallel)
|
||||
int totalWeight = 0;
|
||||
for (int i = 0; i < pool.Defs.Length && candidates.Length < candidates.Capacity; i++)
|
||||
{
|
||||
if ((pool.Defs[i].ClassMask & classBit) == 0) continue;
|
||||
if (pool.Defs[i].Weight == 0) continue;
|
||||
if (IsOwnedFlag(pool.Defs[i], owned)) continue; // non-stacking flag already held → dedup
|
||||
int w = pool.Defs[i].Weight;
|
||||
byte fam = pool.Defs[i].Family;
|
||||
if (fam != 0 && (ownedFamilies & (1 << fam)) != 0)
|
||||
w += w / 2; // ×1.5 build-bias (integer)
|
||||
if (w > 255) w = 255;
|
||||
candidates.Add((byte)i);
|
||||
weights.Add((byte)w);
|
||||
totalWeight += w;
|
||||
}
|
||||
|
||||
o0 = o1 = o2 = 0;
|
||||
if (candidates.Length == 0)
|
||||
return 0;
|
||||
|
||||
var picked = new FixedList32Bytes<byte>(); // picked catalog indices
|
||||
var pickedFamilies = new FixedList32Bytes<byte>(); // families used this deal (fam != 0)
|
||||
uint salt = 0;
|
||||
while (picked.Length < 3 && picked.Length < candidates.Length)
|
||||
{
|
||||
uint roll = RunMapMath.Hash(offerSeed, (uint)picked.Length, salt) % (uint)totalWeight;
|
||||
int chosen = candidates.Length - 1;
|
||||
int acc = 0;
|
||||
for (int c = 0; c < candidates.Length; c++)
|
||||
{
|
||||
acc += weights[c];
|
||||
if (roll < (uint)acc) { chosen = c; break; }
|
||||
}
|
||||
byte drawn = candidates[chosen];
|
||||
byte fam = pool.Defs[drawn].Family;
|
||||
|
||||
bool dup = false;
|
||||
for (int p = 0; p < picked.Length; p++)
|
||||
if (picked[p] == drawn) { dup = true; break; }
|
||||
bool famClash = false;
|
||||
if (!dup && fam != 0)
|
||||
for (int p = 0; p < pickedFamilies.Length; p++)
|
||||
if (pickedFamilies[p] == fam) { famClash = true; break; }
|
||||
|
||||
if (!dup && !famClash)
|
||||
{
|
||||
picked.Add(drawn);
|
||||
if (fam != 0) pickedFamilies.Add(fam);
|
||||
salt = 0;
|
||||
}
|
||||
else if (++salt > 16)
|
||||
{
|
||||
// Rejection budget spent — deterministic linear fill (first unused, family-distinct if possible).
|
||||
AddFallback(ref picked, ref pickedFamilies, candidates, ref pool);
|
||||
salt = 0;
|
||||
}
|
||||
}
|
||||
|
||||
o0 = picked.Length > 0 ? pool.Defs[picked[0]].Id : (byte)0;
|
||||
o1 = picked.Length > 1 ? pool.Defs[picked[1]].Id : o0;
|
||||
o2 = picked.Length > 2 ? pool.Defs[picked[2]].Id : o1;
|
||||
return picked.Length;
|
||||
}
|
||||
|
||||
/// <summary>Deterministic tail-fill when the weighted draw keeps colliding: take the first unused candidate
|
||||
/// that is family-distinct from the deal; if none, the first unused (family clash tolerated as last resort so
|
||||
/// the deal never wedges below 3 while candidates remain).</summary>
|
||||
static void AddFallback(ref FixedList32Bytes<byte> picked, ref FixedList32Bytes<byte> pickedFamilies,
|
||||
in FixedList128Bytes<byte> candidates, ref BoonCatalogBlob pool)
|
||||
{
|
||||
int firstUnused = -1;
|
||||
for (int c = 0; c < candidates.Length; c++)
|
||||
{
|
||||
byte cand = candidates[c];
|
||||
bool used = false;
|
||||
for (int p = 0; p < picked.Length; p++)
|
||||
if (picked[p] == cand) { used = true; break; }
|
||||
if (used) continue;
|
||||
if (firstUnused < 0) firstUnused = cand;
|
||||
byte cfam = pool.Defs[cand].Family;
|
||||
bool clash = false;
|
||||
if (cfam != 0)
|
||||
for (int p = 0; p < pickedFamilies.Length; p++)
|
||||
if (pickedFamilies[p] == cfam) { clash = true; break; }
|
||||
if (clash) continue;
|
||||
picked.Add(cand);
|
||||
if (cfam != 0) pickedFamilies.Add(cfam);
|
||||
return;
|
||||
}
|
||||
if (firstUnused >= 0)
|
||||
picked.Add((byte)firstUnused);
|
||||
}
|
||||
|
||||
/// <summary>True when a candidate is a non-stacking FLAG effect the player already owns (dedup). Pierce/Fork/
|
||||
/// Chain STACK, so they're never excluded. Byte switch — Burst-safe.</summary>
|
||||
static bool IsOwnedFlag(in BoonDefBlob d, in BoonEffects owned)
|
||||
{
|
||||
if (d.Kind != 1) return false;
|
||||
switch (d.EffectKind)
|
||||
{
|
||||
case BoonEffectKind.DashTrail: return (owned.Flags & BoonFlag.DashTrail) != 0;
|
||||
case BoonEffectKind.FinisherDetonate: return (owned.Flags & BoonFlag.FinisherDetonate) != 0;
|
||||
case BoonEffectKind.KnockToPull: return (owned.Flags & BoonFlag.KnockToPull) != 0;
|
||||
case BoonEffectKind.Siphon: return (owned.Flags & BoonFlag.Siphon) != 0;
|
||||
case BoonEffectKind.Frenzy: return (owned.Flags & BoonFlag.Frenzy) != 0;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bitmask (indexed by <see cref="BoonFamily"/> value) of the MECHANIC families the player owns —
|
||||
/// drives the ×1.5 build-bias. Stat families are never marked (build-bias is mechanic-synergy only).</summary>
|
||||
static int OwnedFamilyMask(in BoonEffects owned)
|
||||
{
|
||||
int m = 0;
|
||||
if (owned.Pierce != 0 || owned.Fork != 0 || owned.Chain != 0) m |= 1 << BoonFamily.Projectile;
|
||||
if ((owned.Flags & (BoonFlag.FinisherDetonate | BoonFlag.KnockToPull)) != 0) m |= 1 << BoonFamily.Melee;
|
||||
if ((owned.Flags & BoonFlag.DashTrail) != 0) m |= 1 << BoonFamily.Mobility;
|
||||
if ((owned.Flags & (BoonFlag.Siphon | BoonFlag.Frenzy)) != 0) m |= 1 << BoonFamily.OnKill;
|
||||
return m;
|
||||
}
|
||||
|
||||
/// <summary>Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids).</summary>
|
||||
public static int FindDef(ref BoonCatalogBlob pool, byte id)
|
||||
{
|
||||
for (int i = 0; i < pool.Defs.Length; i++)
|
||||
if (pool.Defs[i].Id == id) return i;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The DEFAULT Phase-1.7 boon table + the blob builder the baker AND EditMode tests share — 8 mechanic-changers
|
||||
/// (<see cref="BoonDefBlob.Kind"/>==1) + 4 strong flat-stat boons (Kind==0). Ids are within-session stable (both
|
||||
/// worlds bake the same code; boons never persist across saves — stripped on the Returning edge).
|
||||
/// </summary>
|
||||
public static class BoonCatalogData
|
||||
{
|
||||
/// <summary>Build the default catalog blob (caller owns/disposes the reference).</summary>
|
||||
public static BlobAssetReference<BoonCatalogBlob> BuildDefault(Allocator allocator = Allocator.Persistent)
|
||||
{
|
||||
var builder = new BlobBuilder(Allocator.Temp);
|
||||
ref var root = ref builder.ConstructRoot<BoonCatalogBlob>();
|
||||
var defs = builder.Allocate(ref root.Defs, 12);
|
||||
int i = 0;
|
||||
// ---- 8 mechanic-changers (Kind=1). mask: 1=Warrior, 2=Ranger, 3=both. Projectile boons are Ranger-only
|
||||
// (the Warrior's Fire is a cone, not a projectile). ----
|
||||
defs[i++] = Effect(1, BoonEffectKind.Pierce, 1f, 100, 2, BoonFamily.Projectile, "Piercing Shots", "Your shots pierce +1 enemy");
|
||||
defs[i++] = Effect(2, BoonEffectKind.Fork, 1f, 60, 2, BoonFamily.Projectile, "Split Shot", "Fire +1 extra shot in a spread");
|
||||
defs[i++] = Effect(3, BoonEffectKind.Chain, 1f, 60, 2, BoonFamily.Projectile, "Ricochet", "Your shots chain to +1 nearby enemy");
|
||||
defs[i++] = Effect(4, BoonEffectKind.FinisherDetonate, 0f, 60, 1, BoonFamily.Melee, "Detonating Finisher", "Your combo finisher blasts an AoE");
|
||||
defs[i++] = Effect(5, BoonEffectKind.DashTrail, 0f, 100, 3, BoonFamily.Mobility, "Blade Dash", "Dashing damages enemies you pass through");
|
||||
defs[i++] = Effect(6, BoonEffectKind.KnockToPull, 0f, 30, 3, BoonFamily.Melee, "Gravity Pull", "Your knockback drags enemies IN");
|
||||
defs[i++] = Effect(7, BoonEffectKind.Siphon, 0f, 60, 3, BoonFamily.OnKill, "Siphon", "Killing an enemy heals you");
|
||||
defs[i++] = Effect(8, BoonEffectKind.Frenzy, 0f, 30, 3, BoonFamily.OnKill, "Frenzy", "A kill briefly speeds your abilities");
|
||||
// ---- 4 strong flat-stat boons (Kind=0) ----
|
||||
defs[i++] = Stat(9, StatTarget.Damage, ModOp.PercentAdd, 0.50f, 30, 3, BoonFamily.StatDamage, "Executioner", "+50% ability damage");
|
||||
defs[i++] = Stat(10, StatTarget.MaxHealth, ModOp.Flat, 60f, 100, 3, BoonFamily.StatHealth, "Titan's Vigor", "+60 max health");
|
||||
defs[i++] = Stat(11, StatTarget.MoveSpeed, ModOp.PercentAdd, 0.18f, 100, 3, BoonFamily.StatSpeed, "Fleet Foot", "+18% move speed");
|
||||
defs[i++] = Stat(12, StatTarget.CooldownTicks, ModOp.PercentMult, -0.25f, 60, 3, BoonFamily.StatCooldown, "Berserker's Pace", "-25% ability cooldown");
|
||||
var blob = builder.CreateBlobAssetReference<BoonCatalogBlob>(allocator);
|
||||
builder.Dispose();
|
||||
return blob;
|
||||
}
|
||||
|
||||
/// <summary>A flat-stat boon row (Kind=0 — appends a <see cref="StatModifier"/>).</summary>
|
||||
static BoonDefBlob Stat(byte id, StatTarget target, ModOp op, float value, byte weight, byte mask, byte family,
|
||||
string name, string desc)
|
||||
{
|
||||
return new BoonDefBlob
|
||||
{
|
||||
Id = id,
|
||||
Target = (byte)target,
|
||||
Op = (byte)op,
|
||||
Value = value,
|
||||
Weight = weight,
|
||||
ClassMask = mask,
|
||||
Kind = 0,
|
||||
EffectKind = BoonEffectKind.None,
|
||||
Family = family,
|
||||
Name = new FixedString64Bytes(name),
|
||||
Desc = new FixedString128Bytes(desc),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>A mechanic-changer boon row (Kind=1 — mutates <see cref="BoonEffects"/>). <paramref name="value"/>
|
||||
/// is the stacking count delta for Pierce/Fork/Chain (usually 1), ignored for flag effects.</summary>
|
||||
static BoonDefBlob Effect(byte id, byte effectKind, float value, byte weight, byte mask, byte family,
|
||||
string name, string desc)
|
||||
{
|
||||
return new BoonDefBlob
|
||||
{
|
||||
Id = id,
|
||||
Target = 0,
|
||||
Op = 0,
|
||||
Value = value,
|
||||
Weight = weight,
|
||||
ClassMask = mask,
|
||||
Kind = 1,
|
||||
EffectKind = effectKind,
|
||||
Family = family,
|
||||
Name = new FixedString64Bytes(name),
|
||||
Desc = new FixedString128Bytes(desc),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00909311d983d7a43afc195595aff217
|
||||
@@ -1,61 +0,0 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase 1.7 mechanic-changer boon state on a player — the run-scoped counterpart to the flat-stat
|
||||
/// <see cref="StatModifier"/> band. Stackable counts (<see cref="Pierce"/>/<see cref="Fork"/>/<see cref="Chain"/>)
|
||||
/// and boolean <see cref="Flags"/> (see <see cref="BoonFlag"/>) that combat systems read to alter behaviour.
|
||||
/// <para>
|
||||
/// Replicated <see cref="SendToOwnerType.SendToOwner"/> (matching <c>BoonOffer</c>): rollback-correctness is
|
||||
/// provided by the <c>[GhostField]</c>s themselves — the owner is the sole predicting client and needs the
|
||||
/// replicated Fork/Pierce/Chain so its OWN predict-spawned projectiles (in <c>AbilityFireSystem</c>, which
|
||||
/// filters <c>.WithAll<Simulate>()</c>) don't mispredict. Non-owning clients render forked/pierced/chained
|
||||
/// projectiles as interpolated server ghosts and never read the shooter's effects; every other read is
|
||||
/// server-only. NOT <see cref="SendToOwnerType.All"/> — the send type is not what enables rollback, the
|
||||
/// <c>[GhostField]</c> is.
|
||||
/// </para>
|
||||
/// Baked INERT (all 0) on the player prefab (the <c>BoonOffer</c> idiom) so a pick is a non-structural mutate;
|
||||
/// zeroed on the Returning edge in <c>RunDirectorSystem</c> alongside the StatModifier band strips.
|
||||
/// </summary>
|
||||
[GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)]
|
||||
public struct BoonEffects : IComponentData
|
||||
{
|
||||
/// <summary>Extra enemy hits a projectile survives before despawning (stacks).</summary>
|
||||
[GhostField] public byte Pierce;
|
||||
/// <summary>Extra spread projectiles spawned per shot (stacks).</summary>
|
||||
[GhostField] public byte Fork;
|
||||
/// <summary>Targets a projectile chains to after a hit (stacks).</summary>
|
||||
[GhostField] public byte Chain;
|
||||
/// <summary>Boolean effect bits — see <see cref="BoonFlag"/>.</summary>
|
||||
[GhostField] public byte Flags;
|
||||
}
|
||||
|
||||
/// <summary>Bit masks for <see cref="BoonEffects.Flags"/>. Plain byte consts (never an enum compared in Burst).</summary>
|
||||
public static class BoonFlag
|
||||
{
|
||||
public const byte DashTrail = 1; // dashing damages enemies passed through
|
||||
public const byte FinisherDetonate = 2; // the melee combo finisher blasts an AoE
|
||||
public const byte KnockToPull = 4; // this player's knockback pulls enemies IN instead of away
|
||||
public const byte Siphon = 8; // killing an enemy heals this player
|
||||
public const byte Frenzy = 16; // a kill grants a short cooldown-reduction surge
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stable byte discriminator for a <c>BoonDefBlob</c> mechanic-changer effect (0 = a plain stat boon).
|
||||
/// Bytes only — Burst-safe, never an enum compared inside a Bursted system.
|
||||
/// </summary>
|
||||
public static class BoonEffectKind
|
||||
{
|
||||
public const byte None = 0;
|
||||
public const byte Pierce = 1;
|
||||
public const byte Fork = 2;
|
||||
public const byte Chain = 3;
|
||||
public const byte DashTrail = 4;
|
||||
public const byte FinisherDetonate = 5;
|
||||
public const byte KnockToPull = 6;
|
||||
public const byte Siphon = 7;
|
||||
public const byte Frenzy = 8;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76c925707efba46478fb9c697d391e0d
|
||||
@@ -1,17 +0,0 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client → server boon pick: <see cref="Index"/> (0/1/2) into the sender's OWN replicated <c>BoonOffer</c>
|
||||
/// options. Server-validated (<c>Pending==1</c>, index in range, <c>RunInfo.Lifecycle==RoomReward</c> — the
|
||||
/// D-F4 gate that stops a grace-timeout straggler pick landing after the Returning-edge strip). UNCONDITIONAL
|
||||
/// wire type, blittable scalar. Declared at Step 3 (wire front-load); consumed by <c>BoonApplySystem</c> from
|
||||
/// Step 10.
|
||||
/// </summary>
|
||||
public struct BoonPickRequest : IRpcCommand
|
||||
{
|
||||
/// <summary>The chosen option slot: 0, 1, or 2.</summary>
|
||||
public byte Index;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cc9680d4a4c9a334396b60bb97d75b3b
|
||||
@@ -1,40 +0,0 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY working state for the expedition BOSS (a scaled Charger that <see cref="Server"/>'s
|
||||
/// RoomEnemyDirectorSystem tags at spawn). NOT replicated and NOT baked — added at runtime via ECB on the boss
|
||||
/// entity, so it needs no ghost-hash change (a runtime-added replicated component would not replicate anyway;
|
||||
/// this one is deliberately server-only, like <see cref="LungeState"/>/<see cref="KnockbackState"/>).
|
||||
/// <para>
|
||||
/// Component PRESENCE is the boss discriminator: BossAISystem is the SOLE mover/attacker of
|
||||
/// <c>.WithAll<EnemyTag, BossState>()</c>, and EnemyAISystem's Charger MOVE pass excludes it via
|
||||
/// <c>.WithNone<BossState>()</c> so exactly one system writes the boss's Position/AttackWindup. The boss does
|
||||
/// NOT use LungeState (its signature move is a telegraphed radial SLAM, not a lunge) — so EnemyAISystem's
|
||||
/// IsLunging derive visits it but sees <c>LungeState.UntilTick==0</c> and derives the bit off (harmless single
|
||||
/// writer). <see cref="Phase"/> is a byte (never a C# enum on a Bursted path — the cross-assembly-enum ICE rule).
|
||||
/// All tick fields route through <c>TickUtil.NonZero</c> and compare with <see cref="Unity.NetCode.NetworkTick"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public struct BossState : IComponentData
|
||||
{
|
||||
/// <summary>1 = phase one (heavy Charger + slam), 2 = phase two (<50% HP: faster + summons adds). Byte, not enum.</summary>
|
||||
public byte Phase;
|
||||
|
||||
/// <summary>Earliest raw tick the boss may begin its next radial SLAM wind-up (NonZero; 0 = ready).</summary>
|
||||
public uint SlamReadyTick;
|
||||
|
||||
/// <summary>Earliest raw tick the boss may summon its next add pack (phase two only; NonZero; 0 = ready).</summary>
|
||||
public uint SummonReadyTick;
|
||||
|
||||
/// <summary>Earliest raw tick the boss may begin its next LUNGE wind-up (B4; NonZero; 0 = ready).</summary>
|
||||
public uint LungeReadyTick;
|
||||
|
||||
/// <summary>Which attack the live AttackWindup belongs to: 0 = radial slam, 1 = lunge (B4 — slam and lunge
|
||||
/// share the one replicated windup field; this server-only byte disambiguates the elapse branch). The client
|
||||
/// distinguishes via the IsLunging ghost bit instead (BossAISystem holds LungeState.UntilTick through the
|
||||
/// lunge wind-up + travel, so EnemyAISystem's derive turns the bit on).</summary>
|
||||
public byte PendingAttack;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fdf576e1f07162e43bae89c4ccc06dec
|
||||
@@ -3,57 +3,25 @@ using Unity.Entities;
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// The ONE in-place class-swap effect, shared by the editor dev tool (DebugOp.SetClass) and the player-facing
|
||||
/// base ClassSelect (Staging). A class swap is much more than re-seeding: the pre-code review (DR-046) confirmed
|
||||
/// that swapping only the class-seed band leaves the OLD class's PERMANENT META rows on the buffer and omits the
|
||||
/// NEW class's — so a base swap would drain Aether into the wrong class's record and mis-set Max HP. This helper
|
||||
/// mirrors the (previously editor-only) full swap: <see cref="ClassTraits.Reapply"/> (class-seed band) + the meta
|
||||
/// band strip + per-class <see cref="MetaTierState"/> replay. The caller then writes AbilityRef/PlayerClass/
|
||||
/// AbilityCooldown and calls <see cref="HealClamp"/> (a static can't resolve singletons or SystemAPI.SetComponent,
|
||||
/// so the caller passes the resolved pieces). Server-authoritative + prediction-correct (StatRecomputeSystem
|
||||
/// refolds EffectiveCharacterStats next tick).
|
||||
/// The ONE in-place frame-swap effect, shared by the editor dev tool (DebugOp.SetClass) and the player-facing
|
||||
/// frame select. Re-seeds the frame's stat band via <see cref="ClassTraits.Reapply"/>; the caller then writes
|
||||
/// FrameId/PlayerClass, re-seeds the socket loadout, and calls <see cref="HealClamp"/> (a static can't resolve
|
||||
/// singletons or SystemAPI.SetComponent, so the caller passes the resolved pieces). Server-authoritative +
|
||||
/// prediction-correct (StatRecomputeSystem refolds EffectiveCharacterStats next tick).
|
||||
///
|
||||
/// HISTORY (2026-08-07 audit purge): this also used to strip and replay a PERMANENT-META band
|
||||
/// (MetaUpgradeCatalog + MetaTierState) so an Aether-bought upgrade followed the frame across a swap. The meta
|
||||
/// shop belonged to the superseded base/expedition direction and was deleted; only the frame band remains.
|
||||
/// </summary>
|
||||
public static class ClassSwapUtil
|
||||
{
|
||||
/// <summary>Re-seed the class band + re-sync the permanent-meta band for <paramref name="rawClass"/> on
|
||||
/// <paramref name="mods"/>. Returns the normalized class + its Fire ability id (the caller sets AbilityRef).
|
||||
/// <paramref name="haveMeta"/> false (no catalog/record) skips the meta replay (the strip still runs).</summary>
|
||||
/// <summary>Re-seed the class band + re-sync the permanent-meta band for <paramref name="rawClass"/> on
|
||||
/// <paramref name="mods"/>. Returns the normalized class (the caller writes FrameId/PlayerClass + re-seeds
|
||||
/// the socket loadout). <paramref name="haveMeta"/> false (no catalog/record) skips the meta replay (the
|
||||
/// strip still runs).</summary>
|
||||
public static void Apply(byte rawClass, DynamicBuffer<StatModifier> mods,
|
||||
bool haveMeta, in MetaUpgradeCatalog metaCat, DynamicBuffer<MetaTierState> metaRecord,
|
||||
out byte newClass)
|
||||
/// <summary>Re-seed the frame stat band for <paramref name="rawClass"/> on <paramref name="mods"/>.
|
||||
/// Returns the normalized frame id (the caller writes FrameId/PlayerClass + re-seeds the socket
|
||||
/// loadout).</summary>
|
||||
public static void Apply(byte rawClass, DynamicBuffer<StatModifier> mods, out byte newClass)
|
||||
{
|
||||
newClass = ClassTraits.Normalize(rawClass);
|
||||
ClassTraits.Reapply(newClass, mods);
|
||||
|
||||
// Strip the OLD class's meta rows (Reapply only touched the class-seed band), then replay the NEW class's
|
||||
// persisted tiers (the GoInGame skip/clamp rules) so the permanent channel stays correct across the swap.
|
||||
TimedModifierUtil.RemoveBySourceIdRange(mods, Tuning.MetaSourceIdBase,
|
||||
Tuning.MetaSourceIdBase + Tuning.MetaSourceIdSpan);
|
||||
if (haveMeta && metaCat.Value.IsCreated && metaRecord.IsCreated)
|
||||
{
|
||||
ref var metaPool = ref metaCat.Value.Value;
|
||||
byte metaBit = BoonMath.MaskFor(newClass);
|
||||
for (int mi = 0; mi < metaRecord.Length; mi++)
|
||||
{
|
||||
if (metaRecord[mi].ClassId != newClass || metaRecord[mi].Tier == 0) continue;
|
||||
int defIdx = MetaMath.FindDef(ref metaPool, metaRecord[mi].UpgradeId);
|
||||
if (defIdx < 0) continue;
|
||||
if ((metaPool.Defs[defIdx].ClassMask & metaBit) == 0) continue;
|
||||
byte metaTier = metaRecord[mi].Tier < metaPool.Defs[defIdx].MaxTier
|
||||
? metaRecord[mi].Tier : metaPool.Defs[defIdx].MaxTier;
|
||||
mods.Add(new StatModifier
|
||||
{
|
||||
Target = metaPool.Defs[defIdx].Target,
|
||||
Op = metaPool.Defs[defIdx].Op,
|
||||
Value = metaPool.Defs[defIdx].ValuePerTier * metaTier,
|
||||
SourceId = Tuning.MetaSourceIdBase + metaRecord[mi].UpgradeId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Heal/down-clamp a LIVING player's Current to the new class's full max (blob base folded with the
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — a hostile Spitter projectile: a server-spawned, OWNERLESS INTERPOLATED ghost moved server-only in the
|
||||
/// plain SimulationSystemGroup (NOT predicted — like the Husks that fire it). It replicates ONLY the stock
|
||||
/// LocalTransform (no hand-written [GhostField]); this component is server-only state. It deliberately carries NO
|
||||
/// Health, so it is invisible to every WithAll<Health> target loop (player melee/projectile hit-tests can
|
||||
/// never see it — fork 2a: spits are pure dodge/dash checks, NOT shootable). Integrated by
|
||||
/// EnemyProjectileMoveSystem and swept-hit-tested against players + structures by EnemyProjectileDamageSystem.
|
||||
/// </summary>
|
||||
public struct EnemyProjectile : IComponentData
|
||||
{
|
||||
/// <summary>Planar heading (world XZ -> float2 x,y), unit length, locked at spawn.</summary>
|
||||
public float2 Direction;
|
||||
|
||||
/// <summary>Travel speed (world units/second).</summary>
|
||||
public float Speed;
|
||||
|
||||
/// <summary>Damage applied to the first valid same-region target hit.</summary>
|
||||
public float Damage;
|
||||
|
||||
/// <summary>Max travel distance before it expires (world units).</summary>
|
||||
public float Range;
|
||||
|
||||
/// <summary>Accumulated travelled distance (server-only; drives range-expiry).</summary>
|
||||
public float DistanceTravelled;
|
||||
|
||||
/// <summary>Distance moved on the LAST tick (= Speed * the server fixed step). The damage system rebuilds the
|
||||
/// swept segment as cur - Direction*LastStep — NEVER a fresh SystemAPI.Time.DeltaTime (this system runs in the
|
||||
/// PLAIN group where that dt is the wall-frame delta, not the fixed step). Prevents high-speed tunnelling.</summary>
|
||||
public float LastStep;
|
||||
|
||||
/// <summary>Region byte (RegionId.Base/Expedition), copied from the firing Spitter. The damage system skips any
|
||||
/// target whose RegionTag.Region != this — relevancy hides cross-region ghosts from CLIENTS, but the SERVER
|
||||
/// world holds base + expedition players 1000u apart, so server damage needs its OWN region guard.</summary>
|
||||
public byte Region;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baked subscene singleton: the Spitter projectile ghost prefab + the concurrent soft-cap. The server reads it
|
||||
/// via GetSingleton (the prefab Entity lives HERE, never per-Spitter — mirrors AbilityDatabase / WaveEnemyPrefab).
|
||||
/// MaxLiveProjectiles bounds the RegionRelevancySystem O(ghosts x conn)/tick loop: a Spitter at/over the cap
|
||||
/// soft-fails its shot (no cooldown burn — the EB-2 turret soft-fail pattern).
|
||||
/// </summary>
|
||||
public struct SpitterProjectilePrefab : IComponentData
|
||||
{
|
||||
public Entity Prefab;
|
||||
public int MaxLiveProjectiles;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a31a7b0c834ae24db480005ffdb6a15
|
||||
@@ -5,20 +5,23 @@ namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared knockback stamp for melee/cone hits. Guarded exactly as the two call sites were: the target must own
|
||||
/// <see cref="KnockbackState"/> (dummies/structures lacking it would throw at ECB playback if written) and must
|
||||
/// NOT be a boss (<see cref="BossState"/> = knockback-immune, A4). The planar (XZ) heading is
|
||||
/// normalize(targetPos - sourcePos), falling back to <paramref name="faceFallback"/> when that delta is
|
||||
/// degenerate. Deduplicates the identical stamps in <see cref="AbilityFireSystem"/> (Warrior cone) and
|
||||
/// <see cref="MeleeComboSystem"/> (melee cleave). Callers still gate their own speed/window (e.g. the melee
|
||||
/// <see cref="KnockbackState"/> (dummies/structures lacking it would throw at ECB playback if written). The
|
||||
/// planar (XZ) heading is normalize(targetPos - sourcePos), falling back to <paramref name="faceFallback"/>
|
||||
/// when that delta is degenerate. Deduplicates the identical stamps in <see cref="AbilityFireSystem"/> (cone)
|
||||
/// and <see cref="MeleeComboSystem"/> (melee cleave). Callers still gate their own speed/window (e.g. the melee
|
||||
/// KnockSpeed > 0 check) before calling.
|
||||
///
|
||||
/// The former BossState knockback-immunity gate was removed with the boss purge (2026-08-07 audit): the boss
|
||||
/// query required LungeState, which no prefab baked, so BossAISystem matched nothing and the immunity branch
|
||||
/// was unreachable. Reintroduce a per-target immunity flag when the LANTERN shelf-boss lands (Phase 6).
|
||||
/// </summary>
|
||||
static class KnockbackUtil
|
||||
{
|
||||
public static void Stamp(ref ComponentLookup<KnockbackState> lookup, in ComponentLookup<BossState> bossLookup,
|
||||
public static void Stamp(ref ComponentLookup<KnockbackState> lookup,
|
||||
Entity target, float3 sourcePos, float3 targetPos, float2 faceFallback, float speed, uint untilTick, bool pull = false)
|
||||
{
|
||||
if (!lookup.HasComponent(target) || bossLookup.HasComponent(target))
|
||||
return;
|
||||
if (!lookup.HasComponent(target))
|
||||
return;;
|
||||
|
||||
float3 delta = targetPos - sourcePos;
|
||||
float2 dir = math.lengthsq(delta.xz) > 1e-6f ? math.normalize(delta.xz) : faceFallback;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-1 — server-only Charger lunge state (a KnockbackState SHAPE-twin). Component PRESENCE is the Charger
|
||||
/// discriminator (no enum / brain byte — honours the Burst cross-assembly-enum rule; EnemyAISystem is Bursted):
|
||||
/// a Husk variant baked with LungeState is driven by the Charger branch, every other Husk by the Grunt branch
|
||||
/// (which excludes these via <c>.WithNone<LungeState>()</c>). On a wind-up commit the Charger LOCKS
|
||||
/// <see cref="Dir"/> toward the target and travels at <see cref="Speed"/> until <see cref="UntilTick"/> — dealing
|
||||
/// contact damage if it connects, or staggering into a punish window if it whiffs (wall-stop or overshoot).
|
||||
/// NOT a <c>[GhostField]</c> (the lunged position replicates via the stock LocalTransform variant, like
|
||||
/// KnockbackState). All ticks via <c>TickUtil.NonZero</c>; compared with <see cref="Unity.NetCode.NetworkTick"/> only.
|
||||
/// </summary>
|
||||
public struct LungeState : IComponentData
|
||||
{
|
||||
/// <summary>Fixed planar lunge heading, locked at commit (world XZ -> float2 x,y).</summary>
|
||||
public float2 Dir;
|
||||
|
||||
/// <summary>Lunge speed (world units/s); only meaningful while <see cref="UntilTick"/> is active.</summary>
|
||||
public float Speed;
|
||||
|
||||
/// <summary>Raw tick the lunge ends (NonZero). <c>0</c> = not lunging. Active while .IsNewerThan(serverTick).</summary>
|
||||
public uint UntilTick;
|
||||
|
||||
/// <summary>Raw tick the whiff-stagger punish window ends (NonZero; set at BOTH whiff sites). 0 = not
|
||||
/// staggered — or already punished: HealthApplyDamageSystem zeroes it when the first player-sourced hit
|
||||
/// lands so a window counts ONCE in DevTelemetry.ChargerWhiffPunishesLanded. The attack lockout itself
|
||||
/// rides EnemyAttackCooldown.NextAttackTick; this field only scores the punish.</summary>
|
||||
public uint StaggerUntilTick;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// REPLICATED enableable MID-LUNGE flag on a Charger (Slice 1, Feature D). ENABLED for exactly the ticks a
|
||||
/// Charger is committed to its locked-direction lunge (<see cref="LungeState.UntilTick"/> active), DISABLED
|
||||
/// otherwise. The ONLY replicated Charger surface beyond the stock LocalTransform — a <c>[GhostEnabledBit]</c>,
|
||||
/// NOT a [GhostField], because the client needs only on/off: the lunge HEADING is already carried by the
|
||||
/// replicated LocalTransform.Rotation (EnemyAISystem writes LookRotationSafe(lungeDir) each lunge tick), so the
|
||||
/// client indicator derives direction via AnimParamMath.PlanarForward like the danger cone already does. Fixes
|
||||
/// the cue VANISHING at commit (AttackWindup zeroes on commit, so a windup-gated cone disappears exactly when
|
||||
/// the danger is realest): this bit STAYS on through the committed travel. Server-derived once per tick from
|
||||
/// LungeState.UntilTick in EnemyAISystem (the sole LungeState writer); BAKE DISABLED (a Charger spawns
|
||||
/// not-lunging) + visit via .WithPresent<IsLunging>() to write the bit while disabled (the Dead idiom).
|
||||
/// </summary>
|
||||
[GhostEnabledBit]
|
||||
public struct IsLunging : IComponentData, IEnableableComponent { }
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cc65446b98bef1040bc5b9beaac094ba
|
||||
@@ -1,30 +0,0 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — baked weighted-composition table shared by BOTH enemy directors (the expedition
|
||||
/// ZoneEnemyDirectorSystem and the base-siege WaveSystem). Pure integer weights consumed by the deterministic
|
||||
/// <see cref="ZoneEnemyMath"/>.{WaveSlots, KindForSlot, PackSizeForSlot} functions (no enum, no RNG ->
|
||||
/// replay/save-stable). Per kind: a base count + a per-epoch ramp; the Grunt count is the REMAINDER (slots minus
|
||||
/// the others) so it stays a fixed floor while chargers / spitters / swarmer-slots grow as the epoch (expedition)
|
||||
/// or wave (base siege) climbs. A "swarmer slot" expands to a PackSize cluster at spawn (PackSizeForSlot), so one
|
||||
/// slot = one pack. The LEGACY band {GruntBase=g, ChargerBase=c, ChargerPerEpoch=1, rest 0} reproduces the old
|
||||
/// 2-type <see cref="ZoneEnemyMath.WaveSize"/> / <see cref="ZoneEnemyMath.IsChargerSlot"/> exactly (a parity test
|
||||
/// pins this, so the base-siege size curve is provably unchanged where it must be).
|
||||
/// </summary>
|
||||
public struct MixBands : IComponentData
|
||||
{
|
||||
public int GruntBase;
|
||||
public int ChargerBase;
|
||||
public int SpitterBase;
|
||||
public int SwarmerSlotBase;
|
||||
public int ChargerPerEpoch;
|
||||
public int SpitterPerEpoch;
|
||||
public int SwarmerSlotPerEpoch;
|
||||
|
||||
/// <summary>Exposed-but-default-0 epoch ramp for the swarmer PACK size (PackSizeForSlot adds
|
||||
/// SwarmerPackPerEpoch*(epoch-1) to the director's base pack size). v1 keeps it 0 = fixed pack size.</summary>
|
||||
public int SwarmerPackPerEpoch;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 850f904d96b1c7d41959dddbdbf0b4b5
|
||||
@@ -1,42 +0,0 @@
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>One base "prep loadout" option: spend a base resource before launch for a RUN-SCOPED stat buff
|
||||
/// (stripped on the Returning edge like a boon). Mechanical fields only — the HUD supplies display labels.</summary>
|
||||
public struct PrepRow
|
||||
{
|
||||
public byte Id;
|
||||
public byte CostResId; // ResourceId.*
|
||||
public int Cost;
|
||||
public byte Target; // StatTarget
|
||||
public byte Op; // ModOp
|
||||
public float Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The base PREP-LOADOUT catalog (DR-046): the player funds each run's power from base resources at Staging. A
|
||||
/// purchase appends ONE run-scoped <see cref="StatModifier"/> in the prep SourceId band
|
||||
/// (<see cref="Tuning.PrepSourceIdBase"/> + Id), which <see cref="Server"/>'s PrepPurchaseSystem gates once-per-run
|
||||
/// by that SourceId's PRESENCE (its lifetime == the band, stripped on Returning — so it re-buys next run for free,
|
||||
/// no separate latch). A plain managed static table (read by the non-Burst receiver + the managed HUD).
|
||||
/// </summary>
|
||||
public static class PrepCatalog
|
||||
{
|
||||
public static readonly PrepRow[] Rows =
|
||||
{
|
||||
new PrepRow { Id = 0, CostResId = ResourceId.Ore, Cost = 30, Target = (byte)StatTarget.MaxHealth, Op = (byte)ModOp.Flat, Value = 30f },
|
||||
new PrepRow { Id = 1, CostResId = ResourceId.Biomass, Cost = 40, Target = (byte)StatTarget.MoveSpeed, Op = (byte)ModOp.PercentMult, Value = 0.12f },
|
||||
new PrepRow { Id = 2, CostResId = ResourceId.Aether, Cost = 25, Target = (byte)StatTarget.MeleeDamage, Op = (byte)ModOp.PercentMult, Value = 0.20f },
|
||||
new PrepRow { Id = 3, CostResId = ResourceId.Aether, Cost = 25, Target = (byte)StatTarget.Damage, Op = (byte)ModOp.PercentMult, Value = 0.20f },
|
||||
};
|
||||
|
||||
public static int Count => Rows.Length;
|
||||
|
||||
public static bool TryGet(byte id, out PrepRow row)
|
||||
{
|
||||
for (int i = 0; i < Rows.Length; i++)
|
||||
if (Rows[i].Id == id) { row = Rows[i]; return true; }
|
||||
row = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ef0c16b1e46d22c42bf38db14b2983b5
|
||||
@@ -1,16 +0,0 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client → server: buy a base PREP-LOADOUT option (<see cref="PrepCatalog"/> id). Honored ONLY in Staging; the
|
||||
/// server prices it from the catalog (never on the wire), does an in-loop <see cref="StorageMath.TotalOf"/>
|
||||
/// pre-check BEFORE <see cref="StorageMath.Withdraw"/> (DR-014 atomicity), and appends the run-scoped
|
||||
/// <see cref="StatModifier"/> once per run (gated by the prep SourceId's presence). UNCONDITIONAL wire type.
|
||||
/// </summary>
|
||||
public struct PrepPurchaseRequest : IRpcCommand
|
||||
{
|
||||
/// <summary>Prep-catalog option id.</summary>
|
||||
public byte OptionId;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e945968f38977974f926709051f28609
|
||||
@@ -1,45 +0,0 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// MC-2 — server-only Spitter "reposition" brain state. Component PRESENCE is the Spitter discriminator (no
|
||||
/// enum / brain byte — honours the Burst cross-assembly-enum rule; EnemyAISystem is Bursted): a Husk variant
|
||||
/// baked with SpitterState is driven by the ranged range-band branch, mutually exclusive with the Charger
|
||||
/// branch (the AI partitions Spitter = .WithAll<EnemyTag,SpitterState>().WithNone<LungeState>() so no
|
||||
/// enemy is ever double-moved). The Spitter holds a PREFERRED RANGE band from its target — retreating if too
|
||||
/// close, advancing if too far — and fires a TELEGRAPHED, dodgeable projectile on its OWN fire gate. If
|
||||
/// cornered (no retreat room) within CorneredRange it falls back to the Grunt seek+strike. NOT a [GhostField]
|
||||
/// (only server systems read it). All ticks via TickUtil.NonZero; compared with NetworkTick only.
|
||||
/// </summary>
|
||||
public struct SpitterState : IComponentData
|
||||
{
|
||||
/// <summary>Band centre: the distance the Spitter tries to hold from its target (world units).</summary>
|
||||
public float PreferredRange;
|
||||
|
||||
/// <summary>Half-width dead-zone around PreferredRange; inside [pref-tol, pref+tol] the Spitter holds.</summary>
|
||||
public float RangeTolerance;
|
||||
|
||||
/// <summary>Muzzle speed baked onto the spit projectile (world units/second).</summary>
|
||||
public float ProjectileSpeed;
|
||||
|
||||
/// <summary>If the target closes within this distance AND the Spitter can't retreat, it melee-falls-back.</summary>
|
||||
public float CorneredRange;
|
||||
|
||||
/// <summary>Telegraph wind-up lead in ticks before the spit fires (the dodge window). Baked (v1 not
|
||||
/// live-tunable); keep >= ~24 (> interp delay) so a player reacting to the aim-line can clear the shot.</summary>
|
||||
public int WindupTicks;
|
||||
|
||||
/// <summary>Server-only fire gate: raw tick of the earliest tick it may spit again (NonZero; 0 = ready). Its
|
||||
/// OWN gate, never EnemyAttackCooldown. Compared via NetworkTick.IsNewerThan.</summary>
|
||||
public uint NextShotTick;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MC-2 — pure marker for a Swarmer "surround" enemy: mechanically a Grunt (NO AI branch — it falls through the
|
||||
/// Grunt seek+strike pass) with swarm-tuned baked EnemyStats (fast, low-HP, fast frequent low-chip bites). The
|
||||
/// tag drives only (a) the director's CLUSTER spawn (PackSize swarmers in one tick) and (b) a client tint. Keeps
|
||||
/// EnemyTag + RegionTag like every Husk, so readability / health-bars / damage / region-AI all work unchanged.
|
||||
/// </summary>
|
||||
public struct SwarmerTag : IComponentData { }
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: be9404154fd4f964099918079d2da6b8
|
||||
@@ -3,101 +3,32 @@ using Unity.Mathematics;
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, deterministic composition math for the expedition zone-enemy wave — no RNG state, no wall-clock — so the
|
||||
/// per-epoch wave is reproducible across restarts/saves and EditMode-unit-testable without an ECS world (mirrors
|
||||
/// <see cref="EnemyAIMath"/> / <c>ProductionMath</c>). The highest-leverage Slice-3 variety lever: the encounter
|
||||
/// COMPOSITION shifts grunt-heavy -> charger-heavy as the expedition <c>epoch</c> climbs (grunt count stays
|
||||
/// fixed; the per-epoch growth is all chargers).
|
||||
/// Pure, deterministic wave-size math — no RNG state, no wall-clock — so a wave is reproducible across
|
||||
/// restarts/saves and EditMode-unit-testable without an ECS world (mirrors <see cref="EnemyAIMath"/>).
|
||||
///
|
||||
/// HISTORY (2026-08-07 audit purge): this class used to carry a 4-kind weighted composition
|
||||
/// (Grunt/Charger/Spitter/Swarmer) driven by a MixBands struct. The Charger/Spitter/Swarmer authoring
|
||||
/// components were on ZERO prefabs, so LungeState/SpitterState/SwarmerTag were never baked and every
|
||||
/// branch of that math resolved to Grunt at runtime — the escalation curve was inert while 734 lines of
|
||||
/// green tests certified it. The composition layer was deleted; the LANTERN bestiary (Drowner, Grindylow,
|
||||
/// Wrecker, Wisp-Choir) will reintroduce variety through the CreatureKit path, not through this file.
|
||||
/// Recover the old version from git if the weighted-slot model is wanted again.
|
||||
/// </summary>
|
||||
public static class ZoneEnemyMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Total enemies in this epoch's wave: the baked <paramref name="gruntsPerWave"/> + <paramref name="chargersPerWave"/>
|
||||
/// baseline plus one extra per epoch beyond the first (a gentle ramp). Lower-bounded at 1 so an occupied
|
||||
/// expedition always has a fight. <paramref name="epoch"/> is the monotonic sortie counter (>=1 in practice).
|
||||
/// </summary>
|
||||
public static int WaveSize(int epoch, int gruntsPerWave, int chargersPerWave)
|
||||
{
|
||||
int e = math.max(1, epoch);
|
||||
int baseCount = math.max(0, gruntsPerWave) + math.max(0, chargersPerWave);
|
||||
return math.max(1, baseCount + (e - 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic grunt/charger pick for spawn <paramref name="slot"/> of this epoch's wave. The charger
|
||||
/// count is <paramref name="chargersPerWave"/> + (epoch - 1), clamped to the wave size, assigned to the LAST
|
||||
/// slots; everything earlier is a Grunt. So the grunt count stays fixed at <paramref name="gruntsPerWave"/>
|
||||
/// and the wave skews charger-heavy as the epoch climbs. Returns true for a Charger slot. Stable per
|
||||
/// (epoch, slot) — a replayed wave is identical. Pure integer math (Burst-safe; no enum, no RNG).
|
||||
/// </summary>
|
||||
public static bool IsChargerSlot(int epoch, int slot, int gruntsPerWave, int chargersPerWave)
|
||||
{
|
||||
int e = math.max(1, epoch);
|
||||
int size = WaveSize(epoch, gruntsPerWave, chargersPerWave);
|
||||
int chargers = math.clamp(math.max(0, chargersPerWave) + (e - 1), 0, size);
|
||||
int s = ((slot % size) + size) % size;
|
||||
return s >= size - chargers;
|
||||
}
|
||||
|
||||
// ---- MC-2: 4-type weighted composition (Grunt/Charger/Spitter/Swarmer), shared by both directors ----
|
||||
// Kind bytes (NO C# enum — directors index a per-Kind prefab buffer by these; EnemyAISystem is Bursted).
|
||||
/// <summary>The single enemy kind. Directors index a per-Kind prefab buffer by this byte; kept as a
|
||||
/// byte (not an enum) because <c>EnemyAISystem</c> is Bursted and cross-assembly enums trip Burst.</summary>
|
||||
public const byte KindGrunt = 0;
|
||||
public const byte KindCharger = 1;
|
||||
public const byte KindSpitter = 2;
|
||||
public const byte KindSwarmer = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Total SLOTS in this epoch/wave under <paramref name="bands"/>: GruntBase + the per-kind ramped counts
|
||||
/// (charger/spitter/swarmer-slot = base + perEpoch*(epoch-1)). Lower-bounded at 1 so there is always a fight.
|
||||
/// A swarmer SLOT expands to a pack at spawn (<see cref="PackSizeForSlot"/>), so this counts packs, not
|
||||
/// individual swarmers. For the LEGACY band it equals <see cref="WaveSize"/> (parity-tested). Pure integer.
|
||||
/// Total enemies in this wave: <paramref name="baseCount"/> plus one extra per epoch beyond the first
|
||||
/// (a gentle ramp). Lower-bounded at 1 so an occupied arena always has a fight. <paramref name="epoch"/>
|
||||
/// is the monotonic wave counter (>=1 in practice). Pure integer math; Burst-safe.
|
||||
/// </summary>
|
||||
public static int WaveSlots(int epoch, in MixBands bands)
|
||||
public static int WaveSize(int epoch, int baseCount)
|
||||
{
|
||||
int e = math.max(1, epoch);
|
||||
int grunts = math.max(0, bands.GruntBase);
|
||||
int chargers = math.max(0, bands.ChargerBase + bands.ChargerPerEpoch * (e - 1));
|
||||
int spitters = math.max(0, bands.SpitterBase + bands.SpitterPerEpoch * (e - 1));
|
||||
int swarmers = math.max(0, bands.SwarmerSlotBase + bands.SwarmerSlotPerEpoch * (e - 1));
|
||||
return math.max(1, grunts + chargers + spitters + swarmers);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic Kind byte for spawn <paramref name="slot"/> of this epoch/wave. Slots are partitioned in a
|
||||
/// FIXED order — Grunts, then Spitters, then Chargers, then Swarmer-slots last — so the wave skews threat-heavy
|
||||
/// as the ramped counts climb (Grunts are the remainder = a fixed floor). Any leftover slot (when the kinds
|
||||
/// under-fill the max(1,..) floor) defaults to Grunt. Stable per (epoch, slot). For the LEGACY band this
|
||||
/// returns KindCharger on exactly the slots the old <see cref="IsChargerSlot"/> did (parity-tested). Pure.
|
||||
/// </summary>
|
||||
public static byte KindForSlot(int epoch, int slot, in MixBands bands)
|
||||
{
|
||||
int e = math.max(1, epoch);
|
||||
int size = WaveSlots(epoch, bands);
|
||||
int chargers = math.max(0, bands.ChargerBase + bands.ChargerPerEpoch * (e - 1));
|
||||
int spitters = math.max(0, bands.SpitterBase + bands.SpitterPerEpoch * (e - 1));
|
||||
int swarmers = math.max(0, bands.SwarmerSlotBase + bands.SwarmerSlotPerEpoch * (e - 1));
|
||||
int grunts = math.max(0, size - chargers - spitters - swarmers); // remainder = fixed grunt floor
|
||||
|
||||
int s = ((slot % size) + size) % size;
|
||||
if (s < grunts) return KindGrunt;
|
||||
s -= grunts;
|
||||
if (s < spitters) return KindSpitter;
|
||||
s -= spitters;
|
||||
if (s < chargers) return KindCharger;
|
||||
s -= chargers;
|
||||
if (s < swarmers) return KindSwarmer;
|
||||
return KindGrunt; // defensive: unreachable while counts sum to size
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Swarmer cluster size for a swarmer slot: <paramref name="basePackSize"/> plus the (default-0)
|
||||
/// <see cref="MixBands.SwarmerPackPerEpoch"/> ramp. Lower-bounded at 1. v1 bakes the ramp 0 -> a fixed pack;
|
||||
/// the field is exposed for later tuning.
|
||||
/// </summary>
|
||||
public static int PackSizeForSlot(int epoch, int slot, in MixBands bands, int basePackSize)
|
||||
{
|
||||
int e = math.max(1, epoch);
|
||||
return math.max(1, basePackSize + math.max(0, bands.SwarmerPackPerEpoch) * (e - 1));
|
||||
return math.max(1, math.max(0, baseCount) + (e - 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
@@ -6,49 +5,29 @@ namespace ProjectM.Simulation
|
||||
/// <summary>
|
||||
/// Shared harvest-yield deposit routing used by BOTH the projectile-sweep harvest (ResourceHarvestSystem) and
|
||||
/// the melee-cone harvest (MeleeComboSystem), so the two can't drift. (They previously did: the melee path
|
||||
/// hard-coded <see cref="Tuning.DefaultStackMax"/> and silently ignored per-item stack caps.) Base-region yield
|
||||
/// credits the shared ledger DIRECTLY (the build-currency pool); expedition / un-tagged yield routes to the
|
||||
/// harvesting player's PERSONAL inventory — per-item StackMax from the item catalog, fallback DefaultStackMax —
|
||||
/// and spills any overflow to the ledger (the no-loss valve). Pure + Burst-friendly.
|
||||
/// hard-coded a stack cap and silently ignored per-item limits.) All yield credits the shared
|
||||
/// <see cref="ResourceLedger"/> directly.
|
||||
///
|
||||
/// HISTORY (2026-08-07 audit purge): yield used to route to a PERSONAL InventorySlot bag for expedition-region
|
||||
/// targets and spill to the ledger. The inventory/equipment layer was already marked PAUSED in CLAUDE.md and
|
||||
/// belonged to the superseded base/expedition direction, so it was deleted along with the shell; harvest is now
|
||||
/// single-sink. When LANTERN's carried-vs-banked cargo distinction lands (Phase 2), reintroduce the second sink
|
||||
/// here rather than at the two call sites — that is the whole point of this class.
|
||||
/// </summary>
|
||||
public static class HarvestMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Routes one harvested yield to its sink. Returns true if the yield landed somewhere (inventory or ledger);
|
||||
/// callers use this to avoid consuming a target for zero credit (e.g. no ledger singleton present).
|
||||
/// <paramref name="player"/> may be <see cref="Entity.Null"/> (unresolvable owner) — the yield then falls
|
||||
/// through to the ledger. <paramref name="ledger"/> is only touched when <paramref name="haveLedger"/> is true.
|
||||
/// Routes one harvested yield to the shared ledger. Returns true if the yield landed somewhere; callers use
|
||||
/// this to avoid consuming a target for zero credit (e.g. no ledger singleton present).
|
||||
/// <paramref name="ledger"/> is only touched when <paramref name="haveLedger"/> is true.
|
||||
/// </summary>
|
||||
public static bool DepositYield(
|
||||
byte yieldId, int amount, bool toLedger, Entity player,
|
||||
BufferLookup<InventorySlot> invLookup,
|
||||
DynamicBuffer<StorageEntry> ledger, bool haveLedger,
|
||||
bool haveDb, in ItemDatabase itemDb)
|
||||
public static bool DepositYield(byte yieldId, int amount, DynamicBuffer<StorageEntry> ledger, bool haveLedger)
|
||||
{
|
||||
int remainder = amount;
|
||||
bool deposited = false;
|
||||
if (amount <= 0 || !haveLedger)
|
||||
return false;
|
||||
|
||||
if (!toLedger && player != Entity.Null && invLookup.HasBuffer(player))
|
||||
{
|
||||
int stackMax = Tuning.DefaultStackMax;
|
||||
if (haveDb && itemDb.Value.IsCreated)
|
||||
{
|
||||
ref var blob = ref itemDb.Value.Value;
|
||||
if (blob.TryGetItem(yieldId, out var def) && def.StackMax > 0)
|
||||
stackMax = def.StackMax;
|
||||
}
|
||||
var inv = invLookup[player];
|
||||
remainder = InventoryMath.Deposit(inv, yieldId, amount, stackMax, Tuning.InventoryMaxSlots);
|
||||
deposited = true;
|
||||
}
|
||||
|
||||
if (remainder > 0 && haveLedger)
|
||||
{
|
||||
StorageMath.Deposit(ledger, yieldId, remainder);
|
||||
deposited = true;
|
||||
}
|
||||
|
||||
return deposited;
|
||||
StorageMath.Deposit(ledger, yieldId, amount);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Tag marking the shared home-base storage container. All state lives in the entity's
|
||||
/// <see cref="StorageEntry"/> buffer. In M5 there is exactly one (server-spawned at a fixed base
|
||||
/// cell), so server systems resolve it as a singleton. Server-authoritative and world-resident, so
|
||||
/// its contents survive a player disconnect (no disk persistence yet).
|
||||
/// </summary>
|
||||
public struct SharedStorageContainer : IComponentData { }
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8de8d91f5d8f0a64c87b0847ae85c564
|
||||
@@ -1,33 +0,0 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client -> server request to deposit into or withdraw from the shared storage container. A one-off
|
||||
/// action, so it is an RPC (not a per-tick predicted input). Op is stored as a byte (see
|
||||
/// <see cref="StorageOp"/>) rather than an enum to keep the generated serializer trivial and avoid the
|
||||
/// cross-assembly enum-codegen hazard. No target entity is carried: M5 has a single shared container,
|
||||
/// which the server resolves as a singleton (entity refs are not stable across worlds).
|
||||
/// </summary>
|
||||
public struct StorageOpRequest : IRpcCommand
|
||||
{
|
||||
/// <summary>Operation code (see <see cref="StorageOp"/>): 0 = deposit, 1 = withdraw.</summary>
|
||||
public byte Op;
|
||||
|
||||
/// <summary>Item to deposit/withdraw.</summary>
|
||||
public ushort ItemId;
|
||||
|
||||
/// <summary>Quantity to deposit/withdraw (server clamps withdraw to available).</summary>
|
||||
public int Count;
|
||||
}
|
||||
|
||||
/// <summary>Operation codes for <see cref="StorageOpRequest.Op"/> (byte to keep RPC serialization trivial).</summary>
|
||||
public static class StorageOp
|
||||
{
|
||||
/// <summary>Add items to the shared container.</summary>
|
||||
public const byte Deposit = 0;
|
||||
|
||||
/// <summary>Remove items from the shared container.</summary>
|
||||
public const byte Withdraw = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dc9ec88867d746e45b9204331b5bab51
|
||||
@@ -1,20 +0,0 @@
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton baked into the gameplay subscene, holding the baked storage-container ghost prefab and
|
||||
/// the base-grid cell to spawn it at. A one-shot server system instantiates the prefab at
|
||||
/// BaseGridMath.CellToWorld(anchor, Cell) and then destroys this singleton. Mirrors the
|
||||
/// UpgradePickupSpawner / PlayerSpawner pattern.
|
||||
/// </summary>
|
||||
public struct StorageSpawner : IComponentData
|
||||
{
|
||||
/// <summary>Baked storage-container ghost prefab to instantiate.</summary>
|
||||
public Entity Prefab;
|
||||
|
||||
/// <summary>Base-grid cell at which to place the container (cell center, on the base plane).</summary>
|
||||
public int2 Cell;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a2e4fad83fa03b4890d736b388a9917
|
||||
@@ -1,27 +0,0 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client -> server request to equip an item from the sender's personal inventory into the slot the
|
||||
/// catalog assigns it (<see cref="ItemDefBlob.EquipSlot"/>). A one-off action, so it is an RPC (not a
|
||||
/// per-tick predicted input); applied exactly once server-only in the plain SimulationSystemGroup. Carries
|
||||
/// only the ItemId — the server derives the target slot from the catalog, so a client can't force a weapon
|
||||
/// into the armor slot. Unconditional wire type (no #if); the server resolves the sender via SourceConnection.
|
||||
/// </summary>
|
||||
public struct EquipRequest : IRpcCommand
|
||||
{
|
||||
/// <summary>The inventory item to equip; the server resolves its slot + effects from the catalog.</summary>
|
||||
public ushort ItemId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client -> server request to unequip whatever occupies <see cref="Slot"/> (an <see cref="EquipSlotId"/>),
|
||||
/// returning the item to the personal inventory and stripping its effects. Unconditional wire type.
|
||||
/// </summary>
|
||||
public struct UnequipRequest : IRpcCommand
|
||||
{
|
||||
/// <summary>The <see cref="EquipSlotId"/> to clear.</summary>
|
||||
public byte Slot;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad5475188aa05de45a231f937b19f069
|
||||
@@ -1,31 +0,0 @@
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Equipment-slot ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard). The player's
|
||||
/// <see cref="EquipmentSlot"/> buffer holds one row PER slot in this fixed order (the buffer index IS the
|
||||
/// slot), so these double as both the catalog's <see cref="ItemDefBlob.EquipSlot"/> value and the buffer
|
||||
/// index. The Weapon slot grants its item's <see cref="ItemDefBlob.GrantedAbilityId"/> into AbilityRef;
|
||||
/// every slot grants the item's inline stat mods. <see cref="Tool"/> is reserved for Phase 2 (tool-gated
|
||||
/// harvesting). 255 = not equippable.
|
||||
/// </summary>
|
||||
public static class EquipSlotId
|
||||
{
|
||||
/// <summary>Weapon: grants the item's ability (AbilityRef.Id) + its stat mods.</summary>
|
||||
public const byte Weapon = 0;
|
||||
|
||||
/// <summary>Armor: grants the item's stat mods.</summary>
|
||||
public const byte Armor = 1;
|
||||
|
||||
/// <summary>Trinket: grants the item's stat mods.</summary>
|
||||
public const byte Trinket = 2;
|
||||
|
||||
/// <summary>Tool (axe/pickaxe) — reserved for Phase 2 tool-gated harvesting.</summary>
|
||||
public const byte Tool = 3;
|
||||
|
||||
/// <summary>Number of equipment slots (the baked <see cref="EquipmentSlot"/> buffer length).</summary>
|
||||
public const byte Count = 4;
|
||||
|
||||
/// <summary>Sentinel: this item is not equippable.</summary>
|
||||
public const byte None = 255;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ce8addc5ae6832a449d1b6ad459ab21a
|
||||
@@ -1,26 +0,0 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// One equipment slot on the player. The per-player buffer holds exactly <see cref="EquipSlotId.Count"/>
|
||||
/// rows in fixed slot order (the buffer INDEX is the slot — Weapon=0/Armor=1/Trinket=2/Tool=3), so only the
|
||||
/// equipped item id needs to replicate; there is no separate Slot field to desync. A [GhostField]
|
||||
/// <see cref="SendToOwnerType.All"/> buffer (a <see cref="StatModifier"/>/<see cref="InventorySlot"/> twin)
|
||||
/// so the owning client's HUD can show its loadout; the server is the SOLE writer (EquipSystem).
|
||||
///
|
||||
/// The actual effects — AbilityRef.Id from the Weapon slot + StatModifiers per slot — are applied
|
||||
/// EVENT-DRIVEN by EquipSystem (once per equip/unequip), NOT re-derived from this buffer each tick; this
|
||||
/// buffer is the replicated record of WHAT is equipped (HUD-facing + persistence-ready), not the effect.
|
||||
/// NOTE: adding this [GhostField] buffer changes the player ghost serialization hash → the player
|
||||
/// prefab/subscene MUST be re-baked consistently in both worlds (see <see cref="InventorySlot"/>).
|
||||
/// </summary>
|
||||
[GhostComponent(OwnerSendType = SendToOwnerType.All)]
|
||||
[InternalBufferCapacity(4)]
|
||||
public struct EquipmentSlot : IBufferElementData
|
||||
{
|
||||
/// <summary>Item equipped in this slot (0 = empty). The buffer INDEX is the <see cref="EquipSlotId"/>.</summary>
|
||||
[GhostField] public ushort ItemId;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac0f8812307d3ef43bbed63d1e3fb737
|
||||
@@ -1,23 +0,0 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client -> server request to move items from the sender's PERSONAL inventory into the shared base
|
||||
/// stockpile (the global <see cref="ResourceLedger"/> the build/upgrade/automation economy spends from).
|
||||
/// A one-off action, so it is an RPC (not a per-tick predicted input), and the server applies it exactly
|
||||
/// once in the plain SimulationSystemGroup (no rollback double-apply). Payload is plain blittable scalars
|
||||
/// (no entity refs, no enum): the server resolves the sender's player from the RPC's SourceConnection. The
|
||||
/// wire type is UNCONDITIONAL (never #if-gated) so the RpcCollection hash matches across release/dev peers;
|
||||
/// only the send/receive SYSTEMS may be #if-gated.
|
||||
/// </summary>
|
||||
public struct InventoryDepositRequest : IRpcCommand
|
||||
{
|
||||
/// <summary>Item to deposit, or 0 to deposit EVERYTHING the player is carrying. The server branches on
|
||||
/// 0 BEFORE any per-item withdraw and never writes a 0-id row.</summary>
|
||||
public ushort ItemId;
|
||||
|
||||
/// <summary>Quantity to deposit; <= 0 means "all of that item" (ignored when ItemId is 0).</summary>
|
||||
public int Count;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a815da9a948230e46bc4f7154887613e
|
||||
@@ -1,110 +0,0 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, deterministic stacking logic for a player's <see cref="InventorySlot"/> buffer (no RNG /
|
||||
/// wall-clock / singleton access, so server and any future prediction agree). Parallels
|
||||
/// <see cref="StorageMath"/> in spirit but does NOT collapse into it: StorageMath is an unbounded
|
||||
/// single-row merge, whereas this enforces a per-item stack cap and a max slot count and supports multiple
|
||||
/// stacks of the same item once a stack fills. DynamicBuffer is a handle, so mutations apply to the
|
||||
/// underlying entity buffer; growing it (buffer.Add) is a resize, NOT a structural change, so it is safe to
|
||||
/// call while iterating a different query. Unit-tested in EditMode via a plain Entities world.
|
||||
/// </summary>
|
||||
public static class InventoryMath
|
||||
{
|
||||
/// <summary>
|
||||
/// Add <paramref name="count"/> of <paramref name="itemId"/>: first tops up existing non-full stacks
|
||||
/// of that item, then appends new stacks (each capped at <paramref name="stackMax"/>) while a free slot
|
||||
/// remains (buffer length < <paramref name="maxSlots"/>). Returns the REMAINDER that did not fit
|
||||
/// (0 if everything was deposited). No-op (returns 0) for count <= 0; a positive count of itemId 0
|
||||
/// returns the full count (nothing deposited — never writes a 0-id row). stackMax < 1 = unbounded.
|
||||
/// </summary>
|
||||
public static int Deposit(DynamicBuffer<InventorySlot> buffer, ushort itemId, int count, int stackMax, int maxSlots)
|
||||
{
|
||||
if (count <= 0) return 0;
|
||||
if (itemId == 0) return count;
|
||||
if (stackMax < 1) stackMax = int.MaxValue;
|
||||
|
||||
// Top up existing stacks of this item.
|
||||
for (int i = 0; i < buffer.Length && count > 0; i++)
|
||||
{
|
||||
if (buffer[i].ItemId != itemId) continue;
|
||||
var e = buffer[i];
|
||||
int space = stackMax - e.Count;
|
||||
if (space <= 0) continue;
|
||||
int add = space < count ? space : count;
|
||||
e.Count += add;
|
||||
buffer[i] = e;
|
||||
count -= add;
|
||||
}
|
||||
|
||||
// Append new stacks while a slot is free.
|
||||
while (count > 0 && buffer.Length < maxSlots)
|
||||
{
|
||||
int add = stackMax < count ? stackMax : count;
|
||||
buffer.Add(new InventorySlot { ItemId = itemId, Count = add });
|
||||
count -= add;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove up to <paramref name="count"/> of <paramref name="itemId"/> across all its stacks, clamped to
|
||||
/// what is available; drops a stack that reaches zero. Returns the amount actually withdrawn (0 if none).
|
||||
/// Iterates back-to-front so RemoveAt does not skip a stack. No-op for count <= 0 or itemId 0.
|
||||
/// </summary>
|
||||
public static int Withdraw(DynamicBuffer<InventorySlot> buffer, ushort itemId, int count)
|
||||
{
|
||||
if (count <= 0 || itemId == 0) return 0;
|
||||
|
||||
int taken = 0;
|
||||
for (int i = buffer.Length - 1; i >= 0 && count > 0; i--)
|
||||
{
|
||||
if (buffer[i].ItemId != itemId) continue;
|
||||
var e = buffer[i];
|
||||
int t = e.Count < count ? e.Count : count;
|
||||
e.Count -= t;
|
||||
taken += t;
|
||||
count -= t;
|
||||
if (e.Count <= 0)
|
||||
buffer.RemoveAt(i);
|
||||
else
|
||||
buffer[i] = e;
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-mutating check: would depositing <paramref name="count"/> of <paramref name="itemId"/> FULLY fit
|
||||
/// (top-up existing stacks + new stacks within <paramref name="maxSlots"/>)? Used by the equip swap to
|
||||
/// guarantee the swapped-out item has room BEFORE any withdrawal (no item loss). Mirrors Deposit's space math.
|
||||
/// </summary>
|
||||
public static bool CanDeposit(DynamicBuffer<InventorySlot> buffer, ushort itemId, int count, int stackMax, int maxSlots)
|
||||
{
|
||||
if (count <= 0) return true;
|
||||
if (itemId == 0) return false;
|
||||
if (stackMax < 1) stackMax = int.MaxValue;
|
||||
|
||||
long space = 0;
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
if (buffer[i].ItemId == itemId)
|
||||
space += stackMax - buffer[i].Count;
|
||||
int freeSlots = maxSlots - buffer.Length;
|
||||
if (freeSlots > 0)
|
||||
space += (long)freeSlots * stackMax;
|
||||
return space >= count;
|
||||
}
|
||||
|
||||
/// <summary>Total quantity of <paramref name="itemId"/> across all stacks (0 if absent).</summary>
|
||||
public static int CountOf(DynamicBuffer<InventorySlot> buffer, ushort itemId)
|
||||
{
|
||||
int total = 0;
|
||||
for (int i = 0; i < buffer.Length; i++)
|
||||
if (buffer[i].ItemId == itemId)
|
||||
total += buffer[i].Count;
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c8560f6c2e717b943bed78d40ea87404
|
||||
@@ -1,35 +0,0 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// One (item, count) row in a player's PERSONAL inventory. The per-player DynamicBuffer of these is the
|
||||
/// server-authoritative source of what that player is carrying. A structural twin of
|
||||
/// <see cref="StatModifier"/>: a [GhostField] buffer with <see cref="SendToOwnerType.All"/> so the owning
|
||||
/// (predicting) client receives its own inventory — without it the owner, being the owner, would not get
|
||||
/// the owner-typed buffer at all and the HUD would read empty. BOTH fields carry [GhostField]; the
|
||||
/// [GhostComponent] attribute alone does NOT auto-replicate fields (an un-annotated field ships as a
|
||||
/// silent zero), so the annotations mirror <see cref="StorageEntry"/> field-for-field.
|
||||
///
|
||||
/// REPLICATION DISCIPLINE — the ONLY writers are server-only: <see cref="ProjectM.Server.ResourceHarvestSystem"/>
|
||||
/// (harvest yield) and the deposit-to-base RPC handler, both in the plain server SimulationSystemGroup. So
|
||||
/// there is no predicted-loop double-apply and the owner never mispredicts its inventory — it is a pure
|
||||
/// server-authored snapshot. NEVER mutate this from a client predicted system (that would reintroduce a
|
||||
/// double-apply / mispredict path). ItemId is the same opaque ushort id space as <see cref="StorageEntry"/>
|
||||
/// and the <see cref="ItemDatabase"/> catalog.
|
||||
///
|
||||
/// NOTE: adding this [GhostField] buffer CHANGES the player ghost serialization hash — the player prefab /
|
||||
/// subscene MUST be re-baked (consistently in both worlds) or the connect handshake desyncs.
|
||||
/// </summary>
|
||||
[GhostComponent(OwnerSendType = SendToOwnerType.All)]
|
||||
[InternalBufferCapacity(Tuning.InventoryMaxSlots)]
|
||||
public struct InventorySlot : IBufferElementData
|
||||
{
|
||||
/// <summary>Item carried in this slot (0 = empty/unused; aligns with InventoryMath's 0-id no-op).</summary>
|
||||
[GhostField] public ushort ItemId;
|
||||
|
||||
/// <summary>Quantity in this slot (bounded by the item's StackMax when deposited via InventoryMath).</summary>
|
||||
[GhostField] public int Count;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f151c780df9917d4089b2944f3ffb12d
|
||||
@@ -1,26 +0,0 @@
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Broad item-category ids (a byte, not an enum, per the cross-assembly enum-in-Burst hazard that
|
||||
/// already de-Bursted ProjectileClassificationSystem). The category lets systems and UI treat an item
|
||||
/// generically (a resource stacks and is spendable at the base; a tool/weapon is equippable; a
|
||||
/// consumable is used) without a per-id switch. Stored in the ItemDatabase blob's <see cref="ItemDefBlob"/>.
|
||||
/// </summary>
|
||||
public static class ItemCategory
|
||||
{
|
||||
/// <summary>Stackable raw material (Aether/Ore/Biomass). Spendable at the base / for crafting.</summary>
|
||||
public const byte Resource = 0;
|
||||
|
||||
/// <summary>Gathering tool (axe/pickaxe). Equippable; gates + scales harvesting (Phase 2).</summary>
|
||||
public const byte Tool = 1;
|
||||
|
||||
/// <summary>Weapon. Equipping it grants its ability + stat modifiers (Phase 1).</summary>
|
||||
public const byte Weapon = 2;
|
||||
|
||||
/// <summary>Wearable gear (armour/trinket). Equipping it grants stat modifiers (Phase 1).</summary>
|
||||
public const byte Gear = 3;
|
||||
|
||||
/// <summary>One-shot consumable (potion/charge). Used from the inventory (later phase).</summary>
|
||||
public const byte Consumable = 4;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fa1718754184d2a418b1099ef7e3ae34
|
||||
@@ -1,17 +0,0 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Singleton handle to the baked item-definition database (config, not replicated — baked identically
|
||||
/// into both worlds from the gameplay subscene, exactly like <see cref="AbilityDatabase"/>). A distinct
|
||||
/// component type, so <c>GetSingleton<ItemDatabase>()</c> resolves independently of the ability
|
||||
/// database (singleton-ness is per type). Optional at runtime: consumers that read it use
|
||||
/// <c>TryGetSingleton</c> and fall back to defaults (e.g. <c>Tuning.DefaultStackMax</c>) so the sim still
|
||||
/// runs before the catalog is authored.
|
||||
/// </summary>
|
||||
public struct ItemDatabase : IComponentData
|
||||
{
|
||||
public BlobAssetReference<ItemDatabaseBlob> Value;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6b355888562be7349b8754168375db9b
|
||||
@@ -1,104 +0,0 @@
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// One authored item definition, baked immutable into the <see cref="ItemDatabase"/> blob. This is the
|
||||
/// single source of truth for everything an item IS — resources, tools, weapons, gear, consumables — so
|
||||
/// adding game content is an authoring row + re-bake, with no code change. Id space is the SAME
|
||||
/// <c>ushort</c> space as <see cref="StorageEntry.ItemId"/> / <see cref="InventorySlot.ItemId"/>, and it
|
||||
/// SUBSUMES the low <see cref="ResourceId"/> byte ids (Aether=1/Ore=2/Biomass=3) — a resource is just a
|
||||
/// low-id item of <see cref="ItemCategory.Resource"/>. KEEP ids 1-3 stable for the existing resources and
|
||||
/// reserve new item ids > 3; 0 = none. Entity/prefab refs do NOT live here (blobs don't remap entity
|
||||
/// refs) — a future companion buffer carries those, exactly like AbilityPrefabElement.
|
||||
///
|
||||
/// The blob is config (baked identically into both worlds, NOT replicated, NOT in SaveData), so growing
|
||||
/// this struct later (a granted-ability id, a StatModifier-spec array, a slot id for Phase 1/2/3) is a
|
||||
/// pure re-bake with zero migration: no SaveData version bump, no ghost-hash change, no desync. <see cref="Tier"/>
|
||||
/// is baked NOW because the project's progression axis is gear tiers, so Phase 2/3 tier gating is a
|
||||
/// content-only edit.
|
||||
/// </summary>
|
||||
/// <summary>One stat-modifier grant on an equippable item, stored INLINE (NOT a nested BlobArray: a nested
|
||||
/// BlobArray is a relative-offset pointer that corrupts the moment <see cref="ItemDatabaseBlob.TryGetItem"/>
|
||||
/// returns the containing <see cref="ItemDefBlob"/> BY VALUE — the same copy hazard the class note warns about).
|
||||
/// Target 255 = unused.</summary>
|
||||
public struct ItemModSpec
|
||||
{
|
||||
/// <summary><see cref="StatTarget"/> as a byte; 255 = unused slot.</summary>
|
||||
public byte Target;
|
||||
/// <summary><see cref="ModOp"/> as a byte.</summary>
|
||||
public byte Op;
|
||||
/// <summary>Magnitude (flat amount or fractional percent).</summary>
|
||||
public float Value;
|
||||
}
|
||||
|
||||
public struct ItemDefBlob
|
||||
{
|
||||
/// <summary>Stable item id (ushort; 1-3 reserved for the existing resources, keep stable for saves).</summary>
|
||||
public ushort ItemId;
|
||||
|
||||
/// <summary>Broad category (see <see cref="ItemCategory"/>), stored as a byte.</summary>
|
||||
public byte Category;
|
||||
|
||||
/// <summary>Progression tier (0 = base). Higher-tier tools harvest higher-tier nodes / hit harder (Phase 2/3).</summary>
|
||||
public byte Tier;
|
||||
|
||||
/// <summary>Max units that stack in a single inventory slot (1 for non-stacking equipment).</summary>
|
||||
public int StackMax;
|
||||
|
||||
/// <summary>Equip slot (see <see cref="EquipSlotId"/>); 255 = not equippable.</summary>
|
||||
public byte EquipSlot;
|
||||
|
||||
/// <summary>Up to <see cref="MaxMods"/> INLINE stat-mod grants applied while equipped (Target 255 = unused). Inline, not a nested BlobArray.</summary>
|
||||
public ItemModSpec Mod0, Mod1, Mod2, Mod3;
|
||||
|
||||
/// <summary>Designer-facing display name (shown in the HUD inventory panel).</summary>
|
||||
public FixedString64Bytes Name;
|
||||
|
||||
/// <summary>Number of inline mod slots.</summary>
|
||||
public const int MaxMods = 4;
|
||||
|
||||
/// <summary>Indexed access to the inline mod slots (returns a copy — safe, ItemModSpec holds no BlobArray).</summary>
|
||||
public ItemModSpec GetMod(int i)
|
||||
{
|
||||
switch (i)
|
||||
{
|
||||
case 0: return Mod0;
|
||||
case 1: return Mod1;
|
||||
case 2: return Mod2;
|
||||
default: return Mod3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable designer-authored item database, baked from ScriptableObjects to a blob asset and shared by
|
||||
/// every entity (Burst-fast, zero per-instance cost). Looked up by stable <see cref="ItemDefBlob.ItemId"/>
|
||||
/// — ID-KEYED, never by array index, so inserting a new item never renumbers existing ids.
|
||||
///
|
||||
/// NOTE: the lookup is intentionally NOT a 'readonly' method. A readonly struct method forces a defensive
|
||||
/// copy of a field when calling a non-readonly member on it; copying a BlobArray breaks its relative-offset
|
||||
/// pointer, so the array would read as empty. A plain (non-readonly) method accesses the BlobArray in place.
|
||||
/// Always reach this through 'ref blob.Value' (mirrors <see cref="AbilityDatabaseBlob"/>).
|
||||
/// </summary>
|
||||
public struct ItemDatabaseBlob
|
||||
{
|
||||
public BlobArray<ItemDefBlob> Items;
|
||||
|
||||
/// <summary>Linear lookup by item id (the array is tiny). Returns false if not present.</summary>
|
||||
public bool TryGetItem(ushort id, out ItemDefBlob def)
|
||||
{
|
||||
for (int i = 0; i < Items.Length; i++)
|
||||
{
|
||||
if (Items[i].ItemId == id)
|
||||
{
|
||||
def = Items[i];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
def = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ae0cc6bfb8578c42bff8e300078cf2d
|
||||
@@ -1,116 +0,0 @@
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// One authored PERMANENT meta upgrade: tiered (buy tier owned+1 up to <see cref="MaxTier"/>), priced in Aether
|
||||
/// with a linear ramp (<c>cost(owned) = BaseCost + owned*CostGrowth</c>), class-gated by <see cref="ClassMask"/>
|
||||
/// (bit0 = Warrior, bit1 = Ranger — resolve via <see cref="BoonMath.MaskFor"/>, NEVER a raw <c>1<<ClassId</c>:
|
||||
/// the stored ClassId is the normalized FrameKind 2/3). <see cref="Id"/> is the stable APPEND-ONLY key
|
||||
/// persisted in SaveData v6 and keyed into the live StatModifier as <c>Tuning.MetaSourceIdBase + Id</c>.
|
||||
/// DISTINCT from <see cref="BoonDefBlob"/> — boons are run-scoped single-shots; overloading one catalog would
|
||||
/// blur the two channels (DR-037). <see cref="PrereqId"/> = 0xFF means no prerequisite (v1 ships a FLAT catalog
|
||||
/// — the operator default; trees are an authoring change, not a code change).
|
||||
/// </summary>
|
||||
public struct MetaUpgradeDefBlob
|
||||
{
|
||||
public byte Id;
|
||||
public byte ClassMask;
|
||||
public byte Target; // StatTarget as byte
|
||||
public byte Op; // ModOp as byte
|
||||
public byte MaxTier;
|
||||
public float ValuePerTier;
|
||||
public int BaseCost;
|
||||
public int CostGrowth;
|
||||
public byte PrereqId; // 0xFF = none
|
||||
public byte PrereqTier;
|
||||
public FixedString64Bytes Name;
|
||||
public FixedString128Bytes Desc;
|
||||
}
|
||||
|
||||
/// <summary>The baked permanent-upgrade pool (config blob, both worlds, NOT replicated).</summary>
|
||||
public struct MetaUpgradeCatalogBlob
|
||||
{
|
||||
public BlobArray<MetaUpgradeDefBlob> Defs;
|
||||
}
|
||||
|
||||
/// <summary>Singleton carrying the baked meta catalog (ONE MetaCatalogAuthoring in the gameplay subscene).</summary>
|
||||
public struct MetaUpgradeCatalog : IComponentData
|
||||
{
|
||||
public BlobAssetReference<MetaUpgradeCatalogBlob> Value;
|
||||
}
|
||||
|
||||
/// <summary>Pure helpers over the meta catalog + the director's <see cref="MetaTierState"/> record.</summary>
|
||||
public static class MetaMath
|
||||
{
|
||||
/// <summary>Find a def index by its stable id (-1 when absent — callers preserve-and-skip unknown ids).</summary>
|
||||
public static int FindDef(ref MetaUpgradeCatalogBlob pool, byte id)
|
||||
{
|
||||
for (int i = 0; i < pool.Defs.Length; i++)
|
||||
if (pool.Defs[i].Id == id) return i;
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// <summary>The owned tier of (class, upgrade) in the record buffer (absent row = 0).</summary>
|
||||
public static byte TierOf(DynamicBuffer<MetaTierState> record, byte classId, byte upgradeId)
|
||||
{
|
||||
for (int i = 0; i < record.Length; i++)
|
||||
if (record[i].ClassId == classId && record[i].UpgradeId == upgradeId) return record[i].Tier;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Aether cost of buying tier owned+1 (linear ramp; compute from the CLAMPED owned tier).</summary>
|
||||
public static int CostForTier(in MetaUpgradeDefBlob def, byte ownedClamped)
|
||||
=> def.BaseCost + ownedClamped * def.CostGrowth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The DEFAULT v1 meta table + the blob builder the baker AND EditMode tests share (the BoonCatalogData
|
||||
/// pattern; an empty designer-row list on the authoring bakes this verbatim). FLAT catalog — every
|
||||
/// PrereqId = 0xFF (operator default: validate the economy before prereq trees). Ids append-only.
|
||||
/// Priced for the 15%-Aether node economy (~2-5 Aether per lucky room).
|
||||
/// </summary>
|
||||
public static class MetaCatalogData
|
||||
{
|
||||
public static BlobAssetReference<MetaUpgradeCatalogBlob> BuildDefault(Allocator allocator = Allocator.Persistent)
|
||||
{
|
||||
var builder = new BlobBuilder(Allocator.Temp);
|
||||
ref var root = ref builder.ConstructRoot<MetaUpgradeCatalogBlob>();
|
||||
var defs = builder.Allocate(ref root.Defs, 8);
|
||||
int i = 0;
|
||||
// id, mask(1=Warrior,2=Ranger,3=both), target, op, maxTier, valuePerTier, baseCost, growth, name, desc
|
||||
defs[i++] = Make(1, 3, StatTarget.MaxHealth, ModOp.Flat, 5, 15f, 10, 5, "Reinforced Frame", "+15 max health per tier");
|
||||
defs[i++] = Make(2, 3, StatTarget.Damage, ModOp.PercentAdd, 5, 0.08f, 12, 6, "Sharpened Arsenal", "+8% ability damage per tier");
|
||||
defs[i++] = Make(3, 3, StatTarget.CooldownTicks, ModOp.PercentMult, 3, -0.06f, 15, 10, "Swift Recovery", "-6% ability cooldown per tier");
|
||||
defs[i++] = Make(4, 3, StatTarget.MoveSpeed, ModOp.PercentAdd, 3, 0.05f, 10, 8, "Fleet Stride", "+5% move speed per tier");
|
||||
defs[i++] = Make(5, 1, StatTarget.MeleeDamage, ModOp.PercentAdd, 4, 0.10f, 12, 6, "Warrior's Might", "+10% melee damage per tier");
|
||||
defs[i++] = Make(6, 1, StatTarget.MeleeRange, ModOp.PercentAdd, 3, 0.08f, 10, 6, "Warrior's Reach", "+8% melee reach per tier");
|
||||
defs[i++] = Make(7, 2, StatTarget.Range, ModOp.PercentAdd, 4, 0.10f, 12, 6, "Ranger's Longshot", "+10% projectile range per tier");
|
||||
defs[i++] = Make(8, 2, StatTarget.ProjectileSpeed, ModOp.PercentAdd, 3, 0.10f, 10, 6, "Ranger's Velocity", "+10% projectile speed per tier");
|
||||
var blob = builder.CreateBlobAssetReference<MetaUpgradeCatalogBlob>(allocator);
|
||||
builder.Dispose();
|
||||
return blob;
|
||||
}
|
||||
|
||||
static MetaUpgradeDefBlob Make(byte id, byte mask, StatTarget target, ModOp op, byte maxTier,
|
||||
float valuePerTier, int baseCost, int growth, string name, string desc)
|
||||
{
|
||||
return new MetaUpgradeDefBlob
|
||||
{
|
||||
Id = id,
|
||||
ClassMask = mask,
|
||||
Target = (byte)target,
|
||||
Op = (byte)op,
|
||||
MaxTier = maxTier,
|
||||
ValuePerTier = valuePerTier,
|
||||
BaseCost = baseCost,
|
||||
CostGrowth = growth,
|
||||
PrereqId = 0xFF,
|
||||
PrereqTier = 0,
|
||||
Name = new FixedString64Bytes(name),
|
||||
Desc = new FixedString128Bytes(desc),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee5e173c41773dc4189279241bf322b7
|
||||
@@ -1,76 +0,0 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// One owned permanent meta-upgrade tier, keyed by (class, upgrade). The CycleDirector's DynamicBuffer of these is
|
||||
/// the authoritative per-class meta-progression record. A GLOBAL <c>[GhostField]</c> buffer on the ownerless
|
||||
/// interpolated director ghost (no OwnerSendType — the party invests together and every client reads it for the
|
||||
/// shop), mirroring <see cref="StorageEntry"/>. Persisted to disk (SaveData v6) and re-applied born-correct at
|
||||
/// player spawn as meta-band <see cref="StatModifier"/>s. Bytes only (Burst/serialization safe). Sparse — an
|
||||
/// absent (class, upgrade) row means tier 0.
|
||||
/// </summary>
|
||||
[InternalBufferCapacity(24)]
|
||||
public struct MetaTierState : IBufferElementData
|
||||
{
|
||||
/// <summary>Owning class id (Warrior/Ranger — the FrameKind anchor).</summary>
|
||||
[GhostField] public byte ClassId;
|
||||
/// <summary>Upgrade id (append-only key into the meta catalog).</summary>
|
||||
[GhostField] public byte UpgradeId;
|
||||
/// <summary>Owned tier (>=1; an absent row = 0).</summary>
|
||||
[GhostField] public byte Tier;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only singleton on the CycleDirector: the FIRST-COMMIT latch for the co-op route choice. Written IN-PLACE
|
||||
/// (immediate SystemAPI.SetComponent, not a deferred ECB) inside <c>RouteSelectSystem</c>'s drain loop so two
|
||||
/// same-tick picks cannot both observe <see cref="HasPick"/>==0 (the DR-014 atomicity idiom). NOT replicated.
|
||||
/// </summary>
|
||||
public struct RouteCommand : IComponentData
|
||||
{
|
||||
/// <summary>1 once a route has been committed for the current (RunEpoch, layer).</summary>
|
||||
public byte HasPick;
|
||||
/// <summary>The committed option index (into the replicated RouteOpt* set).</summary>
|
||||
public byte OptionIndex;
|
||||
/// <summary>Run epoch the pick is for (stale-reject guard).</summary>
|
||||
public int ForRunEpoch;
|
||||
/// <summary>Layer the pick is for (stale-reject guard).</summary>
|
||||
public int ForLayer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only singleton on the CycleDirector: the room-exit PORTAL interact latch (DR-046). PortalInteractReceiveSystem
|
||||
/// sets <see cref="HasInteract"/> when a player interacts the portal during RoomExplore; RunDirectorSystem (the sole
|
||||
/// RunInfo/RunRuntime writer) reads it to advance the run + tear the room down, then clears it. NOT replicated.
|
||||
/// Added unconditionally at director spawn (like RouteCommand).
|
||||
/// </summary>
|
||||
public struct PortalCommand : IComponentData
|
||||
{
|
||||
/// <summary>1 once a participant has interacted the room-exit portal this RoomExplore.</summary>
|
||||
public byte HasInteract;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Server-only persisted meta counters on the CycleDirector (mirrored to the replicated <see cref="RunInfo"/> for
|
||||
/// the HUD). Added UNCONDITIONALLY at director spawn (like CycleRuntime/ThreatState/RunPhase) so a New-Game boot
|
||||
/// has the component the bank block reads; restored values are SetComponent'd only inside the save-present block.
|
||||
/// NOT replicated.
|
||||
/// </summary>
|
||||
public struct MetaCounters : IComponentData
|
||||
{
|
||||
public int RunsCompleted;
|
||||
public int MaxDepthReached;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-only tag of a player's class id, added at spawn by <c>GoInGameServerSystem</c>. Lets the meta systems
|
||||
/// resolve which per-class tier record to seed / spend against (class was previously only an <c>AbilityRef</c> /
|
||||
/// wire concern). NOT replicated.
|
||||
/// </summary>
|
||||
public struct PlayerClass : IComponentData
|
||||
{
|
||||
public byte ClassId;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 307374d6819017f4da4bbfe64f11e7c5
|
||||
@@ -1,18 +0,0 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client → server permanent meta-upgrade purchase: <see cref="UpgradeId"/> keys the baked meta catalog. The
|
||||
/// TIER is SERVER-COMPUTED (a purchase always buys owned+1) — putting a tier on the wire would invite
|
||||
/// desync/cheat. Server-validated (<c>RunInfo.Lifecycle==Staging</c> phase gate, class mask, prereq, MaxTier,
|
||||
/// Aether affordability) with DR-014 in-loop ledger atomicity so two same-tick purchases on barely-enough Aether
|
||||
/// cannot both pass. UNCONDITIONAL wire type. Declared at Step 3 (wire front-load); consumed by
|
||||
/// <c>MetaSpendSystem</c> from Step 13.
|
||||
/// </summary>
|
||||
public struct MetaSpendRequest : IRpcCommand
|
||||
{
|
||||
/// <summary>Meta-catalog upgrade id (append-only key).</summary>
|
||||
public byte UpgradeId;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 71ddd683487e8704197b8eeddcb2c339
|
||||
@@ -1,38 +0,0 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// The ONE collector of the permanent-meta save slice (the SaveStructureScan idiom): reads the director's
|
||||
/// replicated <see cref="MetaTierState"/> buffer + server-only <see cref="MetaCounters"/> into the
|
||||
/// <see cref="SaveData"/> v6 fields. Shared by BOTH save writers — <c>SaveWriteSystem</c> (autosave) AND
|
||||
/// <c>WorldLauncher.TrySaveFromServer</c> (quit-to-menu) — so the writers can never drift; the quit path
|
||||
/// silently omitting these fields would WIPE all permanent progression on the most common exit (the meta
|
||||
/// review's top blocker). Rows are copied VERBATIM (unknown ids round-trip).
|
||||
/// </summary>
|
||||
public static class MetaSaveScan
|
||||
{
|
||||
public static void Collect(EntityManager em, Entity director,
|
||||
out MetaUpgradeSave[] rows, out int runsCompleted, out int maxDepthReached)
|
||||
{
|
||||
rows = System.Array.Empty<MetaUpgradeSave>();
|
||||
runsCompleted = 0;
|
||||
maxDepthReached = 0;
|
||||
|
||||
if (em.HasBuffer<MetaTierState>(director))
|
||||
{
|
||||
var buf = em.GetBuffer<MetaTierState>(director, true);
|
||||
rows = new MetaUpgradeSave[buf.Length];
|
||||
for (int i = 0; i < buf.Length; i++)
|
||||
rows[i] = new MetaUpgradeSave { ClassId = buf[i].ClassId, UpgradeId = buf[i].UpgradeId, Tier = buf[i].Tier };
|
||||
}
|
||||
|
||||
if (em.HasComponent<MetaCounters>(director))
|
||||
{
|
||||
var counters = em.GetComponentData<MetaCounters>(director);
|
||||
runsCompleted = counters.RunsCompleted;
|
||||
maxDepthReached = counters.MaxDepthReached;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 83b313e94b9f4304993358deca12e6e2
|
||||
@@ -1,43 +0,0 @@
|
||||
using System.Collections.Generic;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Scans a server world for PLAYER-built structures (<see cref="PlacedStructure"/> + <see cref="RuntimePlacedTag"/>)
|
||||
/// into the flat SaveData arrays — the SINGLE shared scan used by BOTH the autosave (SaveWriteSystem) and the
|
||||
/// quit-to-menu save (WorldLauncher), so the two paths can never drift (only RuntimePlacedTag structures are saved;
|
||||
/// anything baked into the subscene is the subscene's source of truth, not the save's). Managed (List/array) —
|
||||
/// runs only on a save, never in the hot loop.
|
||||
/// </summary>
|
||||
public static class SaveStructureScan
|
||||
{
|
||||
public static void Collect(EntityManager em, uint nowTick, out StructureSave[] structures)
|
||||
{
|
||||
var structs = new List<StructureSave>();
|
||||
|
||||
using var q = em.CreateEntityQuery(
|
||||
ComponentType.ReadOnly<PlacedStructure>(),
|
||||
ComponentType.ReadOnly<RuntimePlacedTag>());
|
||||
using var entities = q.ToEntityArray(Allocator.Temp);
|
||||
|
||||
for (int k = 0; k < entities.Length; k++)
|
||||
{
|
||||
var e = entities[k];
|
||||
var ps = em.GetComponentData<PlacedStructure>(e);
|
||||
|
||||
structs.Add(new StructureSave
|
||||
{
|
||||
Type = ps.Type,
|
||||
CellX = ps.Cell.x,
|
||||
CellZ = ps.Cell.y,
|
||||
// EB-1: guarded so structures without Health don't crash the autosave path (no try/catch).
|
||||
HP = em.HasComponent<Health>(e) ? em.GetComponentData<Health>(e).Current : 0f,
|
||||
});
|
||||
}
|
||||
|
||||
structures = structs.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47b137b2d90c6154d8c195f8c491f0d8
|
||||
@@ -1,28 +0,0 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// A player's private choice-of-3 boon offer for the just-cleared room. OWNER-ONLY replication
|
||||
/// (<see cref="SendToOwnerType.SendToOwner"/>): the offer is an observe-only HUD read of the LOCAL player — no
|
||||
/// prediction, no teammate read — so the traffic-minimal owner-only path is correct (Play-validated at Step 9;
|
||||
/// the proven fallback is <see cref="SendToOwnerType.All"/>, which for HUD purposes still only surfaces each
|
||||
/// player's component on the client that owns that ghost). Written by <c>BoonOfferSystem</c> on the RoomReward
|
||||
/// entry edge (options drawn deterministically from Hash(RunSeed, room, NetworkId)); <see cref="Pending"/> is
|
||||
/// cleared by <c>BoonApplySystem</c> on a valid pick and zeroed by the Returning-edge strip. INERT until Step 9 —
|
||||
/// baked at Step 3 so the player ghost re-bakes exactly ONCE for the whole redesign.
|
||||
/// </summary>
|
||||
[GhostComponent(OwnerSendType = SendToOwnerType.SendToOwner)]
|
||||
public struct BoonOffer : IComponentData
|
||||
{
|
||||
/// <summary>1 = awaiting this player's pick.</summary>
|
||||
[GhostField] public byte Pending;
|
||||
/// <summary>Boon catalog id of option 0.</summary>
|
||||
[GhostField] public byte Option0;
|
||||
/// <summary>Boon catalog id of option 1.</summary>
|
||||
[GhostField] public byte Option1;
|
||||
/// <summary>Boon catalog id of option 2.</summary>
|
||||
[GhostField] public byte Option2;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c3c9cb8a1b819fd4b8474206e766076e
|
||||
@@ -1,21 +0,0 @@
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Phase 1.7 Blade-Dash bookkeeping — SERVER-ONLY, plain (NOT a <c>[GhostField]</c>, so no ghost-hash impact;
|
||||
/// it piggybacks the <see cref="BoonEffects"/> player re-bake). Keys the per-dash "hit once" dedup to
|
||||
/// <see cref="DashState.StartTick"/> (which is <c>TickUtil.NonZero(now)</c> on every dash and cannot be relied
|
||||
/// upon to reset) rather than to any DashState clear edge: <c>DashTrailDamageSystem</c> clears <see cref="Hit"/>
|
||||
/// whenever the current StartTick differs from <see cref="LastStartTick"/>. Server-only (no rollback) so the
|
||||
/// accumulator is safe to persist across ticks.
|
||||
/// </summary>
|
||||
public struct DashTrailState : IComponentData
|
||||
{
|
||||
/// <summary>The <see cref="DashState.StartTick"/> the <see cref="Hit"/> set currently belongs to.</summary>
|
||||
public uint LastStartTick;
|
||||
/// <summary>Enemies already struck by the CURRENT dash's trail (one hit per enemy per dash).</summary>
|
||||
public FixedList64Bytes<Entity> Hit;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 248bd87d96cfc5b43b4681a203756c5c
|
||||
@@ -21,8 +21,6 @@ namespace ProjectM.Simulation
|
||||
public uint Stamp;
|
||||
public uint KnockUntil;
|
||||
public bool IsFinisher; // Phase 1.7: this swing is the combo finisher
|
||||
public bool Detonate; // Phase 1.7: attacker has the FinisherDetonate boon
|
||||
public bool Pull; // Phase 1.7: attacker has the KnockToPull boon
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -52,12 +50,8 @@ namespace ProjectM.Simulation
|
||||
public partial struct MeleeComboSystem : ISystem
|
||||
{
|
||||
ComponentLookup<KnockbackState> m_KnockbackLookup;
|
||||
ComponentLookup<BossState> m_BossLookup; // A4: the boss is knockback-immune (no melee stunlock out of its slams)
|
||||
|
||||
ComponentLookup<RegionTag> m_RegionLookup;
|
||||
BufferLookup<InventorySlot> m_InvLookup;
|
||||
BufferLookup<StatModifier> m_StatModLookup;
|
||||
ComponentLookup<BoonEffects> m_BoonEffectsLookup; // Phase 1.7 (player query is at the 7-type cap -> lookup)
|
||||
ComponentLookup<MeleeCleavePending> m_PendingLookup; // 07-20 G2.1 scheduled cleave (query at cap -> lookup)
|
||||
|
||||
/// <summary>Phase 1.7 Detonating Finisher blast radius (planar, tunable).</summary>
|
||||
@@ -69,7 +63,7 @@ namespace ProjectM.Simulation
|
||||
/// (G2.2); damage/knockback keep the classic finisher mult.</summary>
|
||||
static PendingCleave BuildCleave(byte step, byte comboLen, float baseDamage, float baseRange, float knockSpeed,
|
||||
float finisherMult, float finisherRangeMult, uint stamp, uint knockUntil, float3 from, float2 aim,
|
||||
float2 facingDir, int ownerId, byte bflags, bool hasMods, DynamicBuffer<StatModifier> mods)
|
||||
float2 facingDir, int ownerId, bool hasMods, DynamicBuffer<StatModifier> mods)
|
||||
{
|
||||
bool fin = step >= comboLen;
|
||||
float d = math.max(0f, hasMods ? StatMath.Apply(baseDamage, StatTarget.MeleeDamage, mods) : baseDamage);
|
||||
@@ -85,8 +79,7 @@ namespace ProjectM.Simulation
|
||||
Stamp = stamp,
|
||||
KnockUntil = knockUntil,
|
||||
IsFinisher = fin,
|
||||
Detonate = (bflags & BoonFlag.FinisherDetonate) != 0,
|
||||
Pull = (bflags & BoonFlag.KnockToPull) != 0,
|
||||
// Detonate/Pull came from boon flags (deleted 2026-08-07 audit purge).
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,12 +87,8 @@ namespace ProjectM.Simulation
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
|
||||
m_BossLookup = state.GetComponentLookup<BossState>(isReadOnly: true);
|
||||
|
||||
m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true);
|
||||
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
|
||||
m_StatModLookup = state.GetBufferLookup<StatModifier>(isReadOnly: true);
|
||||
m_BoonEffectsLookup = state.GetComponentLookup<BoonEffects>(isReadOnly: true);
|
||||
m_PendingLookup = state.GetComponentLookup<MeleeCleavePending>(isReadOnly: false);
|
||||
state.RequireForUpdate<NetworkTime>();
|
||||
}
|
||||
@@ -134,7 +123,6 @@ namespace ProjectM.Simulation
|
||||
// when at least one swing actually started — no per-tick enemy gather on idle/client ticks).
|
||||
var cleaves = isServer ? new NativeList<PendingCleave>(Allocator.Temp) : default;
|
||||
m_StatModLookup.Update(ref state);
|
||||
m_BoonEffectsLookup.Update(ref state); // Phase 1.7: per-player boon flags (read inside the player loop)
|
||||
m_PendingLookup.Update(ref state); // 07-20 G2.1: scheduled cleave slots (server-only writes)
|
||||
|
||||
foreach (var (mc, control, input, facing, xform, owner, ds, entity) in
|
||||
@@ -164,7 +152,6 @@ namespace ProjectM.Simulation
|
||||
cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
|
||||
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
|
||||
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
|
||||
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
|
||||
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
|
||||
m_PendingLookup[entity] = default;
|
||||
}
|
||||
@@ -237,7 +224,6 @@ namespace ProjectM.Simulation
|
||||
cleaves.Add(BuildCleave(pend.Step, comboLen, baseDamage, baseRange, knockSpeed,
|
||||
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
|
||||
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
|
||||
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
|
||||
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
|
||||
m_PendingLookup[entity] = new MeleeCleavePending { ResolveTick = TickUtil.NonZero(now + ct), Step = swingStep };
|
||||
}
|
||||
@@ -246,7 +232,6 @@ namespace ProjectM.Simulation
|
||||
cleaves.Add(BuildCleave(swingStep, comboLen, baseDamage, baseRange, knockSpeed,
|
||||
finisherMult, finisherRangeMult, stamp, knockUntil, xform.ValueRO.Position,
|
||||
input.ValueRO.Aim, facing.ValueRO.Direction, owner.ValueRO.NetworkId,
|
||||
m_BoonEffectsLookup.HasComponent(entity) ? m_BoonEffectsLookup[entity].Flags : (byte)0,
|
||||
m_StatModLookup.HasBuffer(entity), m_StatModLookup.HasBuffer(entity) ? m_StatModLookup[entity] : default));
|
||||
}
|
||||
}
|
||||
@@ -273,14 +258,8 @@ namespace ProjectM.Simulation
|
||||
// pool) just like a base projectile hit. SERVER-ONLY (this whole block) — interpolated node ghosts
|
||||
// are never rolled back, so the deposit + destroy fire exactly once per swing.
|
||||
bool haveLedger = SystemAPI.TryGetSingletonEntity<ResourceLedger>(out var ledgerEntity);
|
||||
bool haveDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
|
||||
DynamicBuffer<StorageEntry> ledger = default;
|
||||
if (haveLedger) ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
|
||||
m_RegionLookup.Update(ref state);
|
||||
m_InvLookup.Update(ref state);
|
||||
var meleePlayerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (po, pe) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
|
||||
meleePlayerByConn[po.ValueRO.NetworkId] = pe;
|
||||
var harvEntity = new NativeList<Entity>(Allocator.Temp);
|
||||
var harvPos = new NativeList<float3>(Allocator.Temp);
|
||||
var harvRemaining = new NativeList<int>(Allocator.Temp);
|
||||
@@ -288,7 +267,6 @@ namespace ProjectM.Simulation
|
||||
var harvPerHit = new NativeList<float>(Allocator.Temp);
|
||||
var harvIsClutter = new NativeList<bool>(Allocator.Temp);
|
||||
var harvVariant = new NativeList<byte>(Allocator.Temp);
|
||||
var harvToLedger = new NativeList<bool>(Allocator.Temp);
|
||||
foreach (var (hx, node, he) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<ResourceNode>>().WithEntityAccess())
|
||||
{
|
||||
@@ -301,7 +279,6 @@ namespace ProjectM.Simulation
|
||||
harvPerHit.Add(node.ValueRO.HarvestPerHit);
|
||||
harvIsClutter.Add(false);
|
||||
harvVariant.Add(0);
|
||||
harvToLedger.Add(m_RegionLookup.HasComponent(he) && m_RegionLookup[he].Region == RegionId.Base);
|
||||
}
|
||||
foreach (var (hx, clutter, he) in
|
||||
SystemAPI.Query<RefRO<LocalTransform>, RefRO<BlightClutter>>().WithEntityAccess())
|
||||
@@ -315,13 +292,11 @@ namespace ProjectM.Simulation
|
||||
harvPerHit.Add(clutter.ValueRO.ScrapPerHit);
|
||||
harvIsClutter.Add(true);
|
||||
harvVariant.Add(clutter.ValueRO.Variant);
|
||||
harvToLedger.Add(m_RegionLookup.HasComponent(he) && m_RegionLookup[he].Region == RegionId.Base);
|
||||
}
|
||||
var harvDestroyed = new NativeArray<bool>(harvEntity.Length, Allocator.Temp);
|
||||
|
||||
|
||||
m_KnockbackLookup.Update(ref state);
|
||||
m_BossLookup.Update(ref state);
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
for (int s = 0; s < cleaves.Length; s++)
|
||||
@@ -339,29 +314,8 @@ namespace ProjectM.Simulation
|
||||
SourceTick = c.Stamp,
|
||||
});
|
||||
if (c.KnockSpeed > 0f)
|
||||
KnockbackUtil.Stamp(ref m_KnockbackLookup, m_BossLookup, target,
|
||||
c.From, enemyPositions[i], c.Face, c.KnockSpeed, c.KnockUntil, c.Pull);
|
||||
}
|
||||
}
|
||||
// Phase 1.7 Detonating Finisher: a finisher swing with the boon blasts a planar AoE around its
|
||||
// origin (mirrors HazardExplosionSystem). Cone+blast overlap is the normal DamageEvent-summation.
|
||||
for (int s = 0; s < cleaves.Length; s++)
|
||||
{
|
||||
var dc = cleaves[s];
|
||||
if (!dc.IsFinisher || !dc.Detonate)
|
||||
continue;
|
||||
float detRadSq = k_DetonateRadius * k_DetonateRadius;
|
||||
for (int i = 0; i < enemyEntities.Length; i++)
|
||||
{
|
||||
float2 dd = new float2(enemyPositions[i].x - dc.From.x, enemyPositions[i].z - dc.From.z);
|
||||
if (math.lengthsq(dd) > detRadSq)
|
||||
continue;
|
||||
ecb.AppendToBuffer(enemyEntities[i], new DamageEvent
|
||||
{
|
||||
Amount = dc.Damage,
|
||||
SourceNetworkId = dc.OwnerId,
|
||||
SourceTick = dc.Stamp,
|
||||
});
|
||||
KnockbackUtil.Stamp(ref m_KnockbackLookup, target,
|
||||
c.From, enemyPositions[i], c.Face, c.KnockSpeed, c.KnockUntil);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,17 +337,12 @@ namespace ProjectM.Simulation
|
||||
// zero means ZERO (cover); any POSITIVE yield still credits >= 1 (the fractional-yield guard).
|
||||
int deposit = harvPerHit[i] > 0f ? amount : 0;
|
||||
byte yieldId = harvYieldId[i];
|
||||
// Route by region: Base nodes credit the shared ledger DIRECTLY (the build pool); an
|
||||
// expedition / un-tagged target goes to the swinging player's PERSONAL inventory (spill to
|
||||
// ledger), mirroring ResourceHarvestSystem. Only deplete if the yield landed somewhere —
|
||||
// never consume a node for zero credit (e.g. no ledger singleton present).
|
||||
Entity meleeHarvester = Entity.Null;
|
||||
if (meleePlayerByConn.TryGetValue(hc.OwnerId, out var meleePlayer))
|
||||
meleeHarvester = meleePlayer;
|
||||
// All yield credits the shared ledger — the PERSONAL inventory sink was deleted with the
|
||||
// superseded shell (2026-08-07 audit). Only deplete if the yield landed somewhere; never
|
||||
// consume a node for zero credit (e.g. no ledger singleton present).
|
||||
if (deposit > 0)
|
||||
{
|
||||
bool deposited = HarvestMath.DepositYield(yieldId, deposit, harvToLedger[i], meleeHarvester,
|
||||
m_InvLookup, ledger, haveLedger, haveDb, itemDb);
|
||||
bool deposited = HarvestMath.DepositYield(yieldId, deposit, ledger, haveLedger);
|
||||
if (!deposited)
|
||||
continue; // never consume a YIELDING target for zero credit
|
||||
}
|
||||
@@ -455,8 +404,6 @@ namespace ProjectM.Simulation
|
||||
harvIsClutter.Dispose();
|
||||
harvVariant.Dispose();
|
||||
harvDestroyed.Dispose();
|
||||
harvToLedger.Dispose();
|
||||
meleePlayerByConn.Dispose();
|
||||
}
|
||||
|
||||
if (cleaves.IsCreated)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// The player's expedition ready-check flag — a plain send-to-all <c>[GhostField]</c> byte on the player ghost so
|
||||
/// EVERY client can render the "N/M READY" staging panel (owner-only would hide teammates' readiness). Written
|
||||
/// server-only by <c>ReadyToggleSystem</c> from the <see cref="ReadyToggleRequest"/> RPC — honored during Staging
|
||||
/// AND the Launching countdown (an un-ready during the countdown is the launch-abort escape hatch); cleared for
|
||||
/// all players by <c>RunDirectorSystem</c> on the Returning edge. The N/M derivation counts live PlayerTag ghosts,
|
||||
/// which is valid ONLY because the party is co-located at base while Staging (the co-location invariant — N7);
|
||||
/// ready toggles are refused in every other lifecycle state.
|
||||
/// </summary>
|
||||
public struct PlayerReady : IComponentData
|
||||
{
|
||||
/// <summary>1 = ready to launch; 0 = not ready.</summary>
|
||||
[GhostField] public byte Value;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5423c043f8023a246b4244eb34e5fbdd
|
||||
@@ -1,35 +0,0 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
// NOTE (LANTERN purge): CycleState/CyclePhase/CycleRuntime (the Calm↔Siege macro-loop) are DELETED.
|
||||
// ExpeditionObjective below is the surviving replicated room-objective readout (live consumers:
|
||||
// RoomEnemyDirectorSystem writes it; RunDirectorSystem/HudSystem read it).
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// DR-042 C7b — a SMALL replicated summary of the current expedition objective so the client HUD can show an
|
||||
/// "enemies remaining / cleared — return to claim" readout. Rides the GLOBAL UNTAGGED director ghost so
|
||||
/// GhostRelevancy.SetIsIrrelevant never hides it
|
||||
/// cross-region — a base teammate can't see the expedition's own (region-tagged, relevancy-hidden) enemy
|
||||
/// ghosts. SOLE writer: ZoneEnemyDirectorSystem (server, plain group), written ABOVE its early-returns
|
||||
/// (snapshot-above-early-return) so the readout never freezes stale. byte/short, never enum (writer is [BurstCompile]).
|
||||
/// </summary>
|
||||
public struct ExpeditionObjective : IComponentData
|
||||
{
|
||||
/// <summary>0 = Idle (no sortie active), 1 = Active (wave in progress), 2 = Cleared (return to claim).</summary>
|
||||
[GhostField] public byte State;
|
||||
|
||||
/// <summary>Live zone enemies remaining (alive + not-yet-spawned) while Active; 0 when Idle/Cleared.</summary>
|
||||
[GhostField] public short Remaining;
|
||||
}
|
||||
|
||||
/// <summary>State constants for <see cref="ExpeditionObjective.State"/> (byte, not enum — Burst/serialization).</summary>
|
||||
public static class ExpeditionObjectiveState
|
||||
{
|
||||
public const byte Idle = 0;
|
||||
public const byte Active = 1;
|
||||
public const byte Cleared = 2;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ca714d222c4d2ed48aaaad7bbe6ec8fc
|
||||
@@ -1,12 +0,0 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client → server: interact with the room-exit portal to leave (advance the run). Client-gated on proximity +
|
||||
/// the RoomExplore lifecycle (both replicated/derivable client-side); the server honors it ONLY in RoomExplore
|
||||
/// from an expedition player, setting <see cref="PortalCommand"/> for RunDirectorSystem (the sole RunInfo writer)
|
||||
/// to consume. Empty payload. UNCONDITIONAL wire type.
|
||||
/// </summary>
|
||||
public struct PortalInteractRequest : IRpcCommand { }
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d527637b76c7a545817f6f388533248
|
||||
@@ -1,16 +0,0 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client → server ready-check toggle — an explicit SET (not a flip), so a duplicated/late RPC is idempotent.
|
||||
/// UNCONDITIONAL wire type (never #if — the reflection-built RpcCollection hash must match across peers; only
|
||||
/// send/receive SYSTEMS may be gated). Blittable scalar payload per the project RPC rules. Handled by
|
||||
/// <c>ReadyToggleSystem</c> (Staging/Launching only).
|
||||
/// </summary>
|
||||
public struct ReadyToggleRequest : IRpcCommand
|
||||
{
|
||||
/// <summary>1 = ready, 0 = not ready.</summary>
|
||||
public byte Ready;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7489e354b7c8d0a46a155fa1d2c22bcc
|
||||
@@ -1,139 +0,0 @@
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, deterministic per-room layout math: resolves a map node (<see cref="RunMapNode"/>) into a concrete
|
||||
/// <see cref="RoomPlan"/> and scatters points within the room's shape. No RNG state (scatter takes a
|
||||
/// caller-seeded <see cref="Random"/> by ref); no wall-clock — EditMode-unit-testable and save/replay reproducible
|
||||
/// (mirrors <see cref="ZoneEnemyMath"/> / <see cref="RunMapMath"/>). Archetype numbers (per-shape radius, per-type
|
||||
/// node counts) are const tables here; an authored <c>RoomArchetype</c> blob can later back these when RoomFieldSystem
|
||||
/// wants designer-tuned variety, without changing this signature's callers.
|
||||
/// </summary>
|
||||
public static class RoomLayoutMath
|
||||
{
|
||||
/// <summary>Resolve a map node + its depth into the concrete room spec the server lays out.</summary>
|
||||
public static RoomPlan Plan(in RunMapNode node, int layer, int roomCount)
|
||||
{
|
||||
return new RoomPlan
|
||||
{
|
||||
RoomType = node.RoomType,
|
||||
Biome = node.Biome,
|
||||
ShapeId = node.ShapeId,
|
||||
Radius = ShapeRadius(node.ShapeId),
|
||||
NodeCount = BaseNodeCount(node.RoomType),
|
||||
DifficultyEpoch = DifficultyEpoch(layer, node.RoomType),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Depth-based difficulty rung fed to <see cref="ZoneEnemyMath"/>: deeper rooms are harder (layer+1 floor),
|
||||
/// with Elite/Boss bumps. Lower-bounded at 1. Pure integer.
|
||||
/// </summary>
|
||||
public static int DifficultyEpoch(int layer, byte roomType)
|
||||
{
|
||||
int d = math.max(1, layer + 1);
|
||||
if (roomType == RoomTypeId.Elite) d += 2;
|
||||
if (roomType == RoomTypeId.Boss) d += 3;
|
||||
return d;
|
||||
}
|
||||
|
||||
/// <summary>Base resource-node count per room type (before the run-wide scarcity budget floors it). Reward
|
||||
/// rooms are dense; combat/elite lean; the Boss room is minimal.</summary>
|
||||
public static int BaseNodeCount(byte roomType)
|
||||
{
|
||||
switch (roomType)
|
||||
{
|
||||
case RoomTypeId.Reward: return 5;
|
||||
case RoomTypeId.Combat: return 2;
|
||||
case RoomTypeId.Elite: return 2;
|
||||
case RoomTypeId.Boss: return 1;
|
||||
default: return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Arena scatter radius (world units) for a shape id.</summary>
|
||||
public static float ShapeRadius(byte shapeId)
|
||||
{
|
||||
switch (shapeId)
|
||||
{
|
||||
case RoomShapeId.Wide: return 24f;
|
||||
case RoomShapeId.Long: return 24f;
|
||||
case RoomShapeId.Cross: return 22f;
|
||||
case RoomShapeId.Disk:
|
||||
default: return 18f;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic scatter of point <paramref name="index"/> of <paramref name="count"/> within the room's
|
||||
/// shape around <paramref name="center"/>, using a caller-seeded RNG. Every returned point satisfies
|
||||
/// <see cref="ContainsPoint"/> for the same shape/center (asserted in tests). Y is preserved from
|
||||
/// <paramref name="center"/>. <paramref name="index"/>/<paramref name="count"/> are reserved for future
|
||||
/// even-spacing variants; today the RNG draw is the sole source of position.
|
||||
/// </summary>
|
||||
public static float3 ScatterInShape(byte shapeId, float3 center, int index, int count, ref Random rng)
|
||||
{
|
||||
float r = ShapeRadius(shapeId);
|
||||
switch (shapeId)
|
||||
{
|
||||
case RoomShapeId.Wide:
|
||||
{
|
||||
float x = rng.NextFloat(-r, r);
|
||||
float z = rng.NextFloat(-r * 0.5f, r * 0.5f);
|
||||
return new float3(center.x + x, center.y, center.z + z);
|
||||
}
|
||||
case RoomShapeId.Long:
|
||||
{
|
||||
float x = rng.NextFloat(-r * 0.5f, r * 0.5f);
|
||||
float z = rng.NextFloat(-r, r);
|
||||
return new float3(center.x + x, center.y, center.z + z);
|
||||
}
|
||||
case RoomShapeId.Cross:
|
||||
{
|
||||
bool horiz = rng.NextInt(0, 2) == 0;
|
||||
float along = rng.NextFloat(-r, r);
|
||||
float across = rng.NextFloat(-r * 0.25f, r * 0.25f);
|
||||
return horiz
|
||||
? new float3(center.x + along, center.y, center.z + across)
|
||||
: new float3(center.x + across, center.y, center.z + along);
|
||||
}
|
||||
case RoomShapeId.Disk:
|
||||
default:
|
||||
{
|
||||
float ang = rng.NextFloat(0f, math.PI * 2f);
|
||||
float rad = r * math.sqrt(rng.NextFloat(0f, 1f)); // area-uniform
|
||||
return new float3(center.x + math.cos(ang) * rad, center.y, center.z + math.sin(ang) * rad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True iff planar point <paramref name="p"/> lies within the shape's footprint around <paramref name="center"/>
|
||||
/// (the exact bound <see cref="ScatterInShape"/> produces). Used to validate scatter and (later) placement.
|
||||
/// </summary>
|
||||
public static bool ContainsPoint(byte shapeId, float3 center, float3 p)
|
||||
{
|
||||
const float eps = 1e-3f;
|
||||
float r = ShapeRadius(shapeId);
|
||||
float dx = p.x - center.x;
|
||||
float dz = p.z - center.z;
|
||||
switch (shapeId)
|
||||
{
|
||||
case RoomShapeId.Wide:
|
||||
return math.abs(dx) <= r + eps && math.abs(dz) <= r * 0.5f + eps;
|
||||
case RoomShapeId.Long:
|
||||
return math.abs(dx) <= r * 0.5f + eps && math.abs(dz) <= r + eps;
|
||||
case RoomShapeId.Cross:
|
||||
{
|
||||
bool horizArm = math.abs(dx) <= r + eps && math.abs(dz) <= r * 0.25f + eps;
|
||||
bool vertArm = math.abs(dz) <= r + eps && math.abs(dx) <= r * 0.25f + eps;
|
||||
return horizArm || vertArm;
|
||||
}
|
||||
case RoomShapeId.Disk:
|
||||
default:
|
||||
return dx * dx + dz * dz <= (r + eps) * (r + eps);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7979eb74587ba004885f89b140b15f00
|
||||
@@ -1,24 +0,0 @@
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// The resolved, concrete spec for ONE room the party is about to enter — a pure function of its map node
|
||||
/// (<see cref="RunMapNode"/>) + its depth, produced by <see cref="RoomLayoutMath.Plan"/>. Consumed server-side by
|
||||
/// the room field/enemy directors to lay out resources + seed the enemy wave; transient (never replicated —
|
||||
/// the client only needs the small published <c>RunInfo</c> mirror for the HUD). All-value, unmanaged, Burst-safe.
|
||||
/// </summary>
|
||||
public struct RoomPlan
|
||||
{
|
||||
/// <summary><see cref="RoomTypeId"/> (drives node/enemy density + the difficulty bump).</summary>
|
||||
public byte RoomType;
|
||||
/// <summary><see cref="RoomBiomeId"/> (cosmetic, forwarded to the client HUD/atmosphere).</summary>
|
||||
public byte Biome;
|
||||
/// <summary><see cref="RoomShapeId"/> (the arena footprint scatter uses).</summary>
|
||||
public byte ShapeId;
|
||||
/// <summary>Arena scatter radius (world units) for this shape.</summary>
|
||||
public float Radius;
|
||||
/// <summary>Base number of resource nodes to scatter (before the run-wide scarcity budget floors it).</summary>
|
||||
public int NodeCount;
|
||||
/// <summary>Depth-based difficulty rung fed to <c>ZoneEnemyMath</c> (higher = harder; Elite/Boss bump it).</summary>
|
||||
public int DifficultyEpoch;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fc7dbc4b8c341e746b1cbe11f3a9ad86
|
||||
@@ -1,44 +0,0 @@
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Stamps a runtime-spawned expedition ghost as belonging to ONE room of the current run (nodes, clutter, zone
|
||||
/// enemies — everything the room's directors instantiate). Server-only, NOT a <c>[GhostField]</c> (clients never
|
||||
/// see rooms, only relevancy-scoped ghosts). Teardown of room <c>i</c> filters on <see cref="Room"/> — the
|
||||
/// hard-learned DR-031/DR-040 lesson that a shared-tag global cull wipes the OTHER room the moment two rooms
|
||||
/// transiently coexist (the ping-pong sub-slot handoff). <see cref="Room"/> = <c>CurrentRoom & 0xFF</c>.
|
||||
/// </summary>
|
||||
public struct RoomTag : IComponentData
|
||||
{
|
||||
/// <summary>The 0-based room index this entity belongs to (low byte).</summary>
|
||||
public byte Room;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ONE way a room's contents die: a <see cref="RoomTag"/>-filtered destroy. Type-agnostic — every room-scoped
|
||||
/// ghost carries the tag, so one query covers nodes/clutter/enemies with no per-type sweep and no double-destroy
|
||||
/// (each entity is visited exactly once). Callers pass their cached all-<see cref="RoomTag"/> query + an ECB
|
||||
/// (structural changes stay batched). Pure/static so EditMode pins the cross-room-wipe regression directly.
|
||||
/// </summary>
|
||||
public static class RoomTeardown
|
||||
{
|
||||
/// <summary>Queue destruction of every entity stamped <see cref="RoomTag"/>.Room == <paramref name="room"/>.</summary>
|
||||
public static int DestroyRoom(EntityQuery allRoomTagged, EntityCommandBuffer ecb, byte room)
|
||||
{
|
||||
var entities = allRoomTagged.ToEntityArray(Allocator.Temp);
|
||||
var tags = allRoomTagged.ToComponentDataArray<RoomTag>(Allocator.Temp);
|
||||
int destroyed = 0;
|
||||
for (int i = 0; i < entities.Length; i++)
|
||||
{
|
||||
if (tags[i].Room != room) continue;
|
||||
ecb.DestroyEntity(entities[i]);
|
||||
destroyed++;
|
||||
}
|
||||
entities.Dispose();
|
||||
tags.Dispose();
|
||||
return destroyed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7fd0f5749da96e14db98fc4e6ada65b0
|
||||
@@ -1,27 +0,0 @@
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Client → server route choice at a RouteSelect gate. <see cref="OptionIndex"/> indexes the REPLICATED
|
||||
/// <c>RunInfo.RouteOpt*</c> option set (never a raw map column — the server re-validates against its own
|
||||
/// <c>NextMask</c>, so a divergent client can only send an index the server rejects).
|
||||
/// <see cref="ForRunEpoch"/>/<see cref="ForLayer"/> stale-reject a pick that arrives after the party already
|
||||
/// advanced. First ACCEPTED commit wins (the in-place <c>RouteCommand</c> latch — DR-014 atomicity).
|
||||
/// UNCONDITIONAL wire type, blittable scalars only. Declared at Step 3 (wire front-load, one RpcCollection hash
|
||||
/// change for the whole redesign); consumed by <c>RouteSelectSystem</c> from Step 8.
|
||||
/// </summary>
|
||||
public struct RouteSelectRequest : IRpcCommand
|
||||
{
|
||||
/// <summary>Index into the replicated RouteOpt* set (0..RouteOptionCount-1).</summary>
|
||||
public byte OptionIndex;
|
||||
/// <summary>RE-MEANED (Step-8 review, zero wire churn): carries <c>(int)RunInfo.RunSeed</c> — the
|
||||
/// replicated, per-run-unique, never-zero run-identity token — NOT the server-only RunEpoch (which a client
|
||||
/// cannot know). The server accepts iff <c>(uint)ForRunEpoch == RunRuntime.RunSeed</c>: the full cross-run
|
||||
/// stale-reject at zero RpcCollection-hash cost (re-mean bytes, don't rename).</summary>
|
||||
public int ForRunEpoch;
|
||||
/// <summary>The layer this pick was made for — <c>RunInfo.CurrentRoom</c> VERBATIM (during a gate that is
|
||||
/// still the just-CLEARED layer; never +1 — the server compares the same un-incremented value).</summary>
|
||||
public int ForLayer;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 24000ef6fe52691408d4247341cbb189
|
||||
@@ -1,85 +0,0 @@
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Lifecycle states for a co-op expedition run (<see cref="RunInfo.Lifecycle"/>). A <c>byte</c>, never an enum
|
||||
/// (Burst/serialization safe), APPEND-ONLY. <see cref="RouteSelect"/> is appended after the original four so no
|
||||
/// value is re-meaned. The party is at the base hub in <see cref="Staging"/>; a discrete run spans
|
||||
/// <see cref="Launching"/>→<see cref="InRoom"/>→<see cref="RoomReward"/>→<see cref="RouteSelect"/> (loop) →
|
||||
/// <see cref="Returning"/>→<see cref="Staging"/>.
|
||||
/// </summary>
|
||||
public static class RunLifecycle
|
||||
{
|
||||
/// <summary>Party in the base hub; ready-check active; no expedition ghosts exist.</summary>
|
||||
public const byte Staging = 0;
|
||||
/// <summary>All-ready launch transient: seed chosen, party teleporting into room 0.</summary>
|
||||
public const byte Launching = 1;
|
||||
/// <summary>Active room populated; party fighting/looting.</summary>
|
||||
public const byte InRoom = 2;
|
||||
/// <summary>Room cleared; per-player boon offers pending; room torn down.</summary>
|
||||
public const byte RoomReward = 3;
|
||||
/// <summary>Run ended (boss cleared / party wiped / all left): teleport home, bank, → Staging.</summary>
|
||||
public const byte Returning = 4;
|
||||
/// <summary>Boons picked; party choosing the next branch (no room materialized — the teardown gap).</summary>
|
||||
public const byte RouteSelect = 5;
|
||||
/// <summary>DR-046: room cleared + boon picked, but the room + resource NODES persist and a portal is up.
|
||||
/// Party loots; interacting the portal (or a soft-timeout) tears the room down + advances (RouteSelect, or
|
||||
/// Returning if the boss fell). Append-only byte value — no ghost re-mean.</summary>
|
||||
public const byte RoomExplore = 6;
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The REPLICATED run-lifecycle summary the whole party observes — a server-decided, client-observed FSM on the
|
||||
/// GLOBAL untagged CycleDirector ghost (so it is relevant cross-region for free, like <see cref="CycleState"/>/
|
||||
/// <see cref="GoalProgress"/>/<see cref="RunOutcome"/>). SOLE writer: <c>RunDirectorSystem</c>. Distinct from
|
||||
/// <see cref="CycleState.Phase"/> (that stays the BASE Calm↔Siege posture for retaliation/final sieges).
|
||||
///
|
||||
/// Fields split three ways: the lifecycle/room readout (HUD "Room i/N", biome cross-fade), the branching-map
|
||||
/// wire (<see cref="RunSeed"/> so the client regenerates the map for DISPLAY, + <see cref="CurrentCol"/> and the
|
||||
/// authoritative reachable <c>RouteOpt*</c> the clickable options bind to), and a two-field mirror of the
|
||||
/// persisted meta counters for the HUD. All integers/bytes → replicate exact (no quantization). Adding this
|
||||
/// <c>[GhostField]</c> component re-hashes the runtime-spawned director ghost (server + client bake the same
|
||||
/// prefab → hash matches), exactly like <see cref="CoreIntegrity"/>/<see cref="RunOutcome"/>.
|
||||
/// </summary>
|
||||
public struct RunInfo : IComponentData
|
||||
{
|
||||
// ---- lifecycle + room readout ----
|
||||
/// <summary><see cref="RunLifecycle"/>.</summary>
|
||||
[GhostField] public byte Lifecycle;
|
||||
/// <summary>0-based depth of the active room (HUD "Room CurrentRoom+1 / RoomCount").</summary>
|
||||
[GhostField] public int CurrentRoom;
|
||||
/// <summary>Total rooms this run (== map layer count, seed-varied in [6,10]).</summary>
|
||||
[GhostField] public int RoomCount;
|
||||
/// <summary><see cref="RoomTypeId"/> of the active room.</summary>
|
||||
[GhostField] public byte CurrentRoomType;
|
||||
/// <summary><see cref="RoomBiomeId"/> of the active room (client atmosphere cross-fade).</summary>
|
||||
[GhostField] public byte CurrentBiome;
|
||||
/// <summary>Server tick the launch countdown elapses (0 = none). Via <see cref="TickUtil.NonZero"/>; compared with IsNewerThan.</summary>
|
||||
[GhostField] public uint LaunchTick;
|
||||
|
||||
// ---- branching map wire ----
|
||||
/// <summary>The run seed — clients regenerate the map layout for DISPLAY via <see cref="RunMapMath.Generate"/> (no gameplay authority).</summary>
|
||||
[GhostField] public uint RunSeed;
|
||||
/// <summary>The party's current column in the active layer.</summary>
|
||||
[GhostField] public byte CurrentCol;
|
||||
/// <summary>Number of reachable next-room options (0 unless <see cref="RunLifecycle.RouteSelect"/>).</summary>
|
||||
[GhostField] public byte RouteOptionCount;
|
||||
/// <summary>Reachable next-layer column for option 0 (authoritative — the clickable button binds to this, not the regen).</summary>
|
||||
[GhostField] public byte RouteOpt0Col;
|
||||
[GhostField] public byte RouteOpt1Col;
|
||||
[GhostField] public byte RouteOpt2Col;
|
||||
/// <summary><see cref="RoomTypeId"/> of option 0 (so the HUD labels the choice).</summary>
|
||||
[GhostField] public byte RouteOpt0Type;
|
||||
[GhostField] public byte RouteOpt1Type;
|
||||
[GhostField] public byte RouteOpt2Type;
|
||||
|
||||
// ---- persisted-meta HUD mirror ----
|
||||
/// <summary>Runs completed (boss-cleared), mirrored for the HUD from the persisted meta counters.</summary>
|
||||
[GhostField] public int RunsCompleted;
|
||||
/// <summary>Deepest room reached across runs, mirrored for the HUD.</summary>
|
||||
[GhostField] public int MaxDepthReached;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 71c6d427dd2c6aa4789f0ab94833ac71
|
||||
@@ -1,108 +0,0 @@
|
||||
using System;
|
||||
using Unity.Collections;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Room-TYPE ids for a run-map node. A <c>byte</c>, never a C# enum — kept Burst-safe, serialization/replay-stable,
|
||||
/// and APPEND-ONLY (a persisted meta / replay reproducibility depends on these values never being re-meaned).
|
||||
/// </summary>
|
||||
public static class RoomTypeId
|
||||
{
|
||||
public const byte Combat = 0;
|
||||
public const byte Elite = 1;
|
||||
public const byte Reward = 2;
|
||||
public const byte Boss = 3;
|
||||
public const byte Count = 4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Room SHAPE ids — the arena footprint <see cref="RoomLayoutMath.ScatterInShape"/> places nodes/enemies within.
|
||||
/// A <c>byte</c> (append-only). Resolved to a concrete radius/footprint by <see cref="RoomLayoutMath"/>.
|
||||
/// </summary>
|
||||
public static class RoomShapeId
|
||||
{
|
||||
public const byte Disk = 0; // circular arena (area-uniform scatter)
|
||||
public const byte Wide = 1; // rectangle, wider on X
|
||||
public const byte Long = 2; // rectangle, longer on Z
|
||||
public const byte Cross = 3; // plus/cross of two bars
|
||||
public const byte Count = 4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cosmetic BIOME ids — resolved to atmosphere/fog/tint by the client presentation layer (WorldAtmosphereSystem)
|
||||
/// per room. A <c>byte</c> (append-only); purely visual, no gameplay authority.
|
||||
/// </summary>
|
||||
public static class RoomBiomeId
|
||||
{
|
||||
public const byte Meadow = 0;
|
||||
public const byte Arid = 1;
|
||||
public const byte Cavern = 2;
|
||||
public const byte Blight = 3;
|
||||
public const byte Count = 4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One node in the branching run-map DAG (Slay-the-Spire style). 4 bytes, unmanaged. <see cref="NextMask"/> is a
|
||||
/// bit set: bit <c>j</c> ⇒ this node can advance to column <c>j</c> of the NEXT layer (<c>j < RunMap.MaxWidth</c>).
|
||||
/// A node with <see cref="NextMask"/> == 0 is a terminal (the single Boss node). Generated purely from the run seed
|
||||
/// by <see cref="RunMapMath.Generate"/>, so it is identical on server + client (client regenerates for display).
|
||||
/// </summary>
|
||||
public struct RunMapNode : IEquatable<RunMapNode>
|
||||
{
|
||||
/// <summary><see cref="RoomTypeId"/>.</summary>
|
||||
public byte RoomType;
|
||||
/// <summary><see cref="RoomBiomeId"/> (cosmetic).</summary>
|
||||
public byte Biome;
|
||||
/// <summary><see cref="RoomShapeId"/>.</summary>
|
||||
public byte ShapeId;
|
||||
/// <summary>Reachable next-layer columns: bit <c>j</c> ⇒ column <c>j</c> of the next layer. 0 = terminal (Boss).</summary>
|
||||
public byte NextMask;
|
||||
|
||||
public bool Equals(RunMapNode o) =>
|
||||
RoomType == o.RoomType && Biome == o.Biome && ShapeId == o.ShapeId && NextMask == o.NextMask;
|
||||
public override bool Equals(object o) => o is RunMapNode n && Equals(n);
|
||||
public override int GetHashCode() => RoomType | (Biome << 8) | (ShapeId << 16) | (NextMask << 24);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A generated branching run map: a layered DAG the party traverses one node per layer. TRANSIENT — regenerated
|
||||
/// from the run seed via <see cref="RunMapMath.Generate"/> and NEVER a ghost buffer / never persisted (only the
|
||||
/// seed + the party's current column ride the wire). Fixed stride of <see cref="MaxWidth"/> per layer, so the
|
||||
/// stable node key <c>nodeId = layer*MaxWidth + col</c> resolves the SAME room regardless of the path taken —
|
||||
/// which keeps per-room content (layout, boons) deterministic. Bounded to <see cref="MaxNodes"/> so it lives in a
|
||||
/// <see cref="FixedList512Bytes{T}"/> (30 × 4 B = 120 B).
|
||||
/// </summary>
|
||||
public struct RunMap
|
||||
{
|
||||
/// <summary>Max layers (run length is seed-varied within [6, <see cref="MaxLayers"/>]).</summary>
|
||||
public const int MaxLayers = 10;
|
||||
/// <summary>Max nodes per layer (branch width 1–3).</summary>
|
||||
public const int MaxWidth = 3;
|
||||
/// <summary>Node-buffer capacity (fixed stride): <see cref="MaxLayers"/> × <see cref="MaxWidth"/>.</summary>
|
||||
public const int MaxNodes = MaxLayers * MaxWidth;
|
||||
|
||||
/// <summary>Nodes, fixed stride <see cref="MaxWidth"/> per layer (<c>LayerCount*MaxWidth</c> entries; columns
|
||||
/// ≥ <see cref="Width"/> are absent/unused).</summary>
|
||||
public FixedList512Bytes<RunMapNode> Nodes;
|
||||
/// <summary>Per-layer branch width (<see cref="LayerCount"/> entries, each in [1, <see cref="MaxWidth"/>]).</summary>
|
||||
public FixedList64Bytes<byte> LayerWidths;
|
||||
/// <summary>Number of layers this run (== room count, in [6, <see cref="MaxLayers"/>]).</summary>
|
||||
public byte LayerCount;
|
||||
|
||||
/// <summary>Stable node key for a (layer, col) — fixed stride, so the same key is the same room on any path.</summary>
|
||||
public static int NodeId(int layer, int col) => layer * MaxWidth + col;
|
||||
/// <summary>Branch width of a layer.</summary>
|
||||
public int Width(int layer) => LayerWidths[layer];
|
||||
/// <summary>Node at (layer, col).</summary>
|
||||
public RunMapNode Node(int layer, int col) => Nodes[NodeId(layer, col)];
|
||||
/// <summary>Node by stable id.</summary>
|
||||
public RunMapNode NodeAt(int nodeId) => Nodes[nodeId];
|
||||
/// <summary>Layer of a node id.</summary>
|
||||
public int LayerOf(int nodeId) => nodeId / MaxWidth;
|
||||
/// <summary>Column of a node id.</summary>
|
||||
public int ColOf(int nodeId) => nodeId % MaxWidth;
|
||||
/// <summary>The single Boss node id (last layer, column 0).</summary>
|
||||
public int BossNodeId => NodeId(LayerCount - 1, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 59ab777c8e8ab794895a7236f5212758
|
||||
@@ -1,225 +0,0 @@
|
||||
using Unity.Collections;
|
||||
using Unity.Mathematics;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure, deterministic generator for the branching run-map DAG — no RNG state, no wall-clock, INTEGER-HASH ONLY
|
||||
/// (no <see cref="Unity.Mathematics.Random"/>, whose draw order is fragile across the multi-pass edge build and
|
||||
/// which the client would have to replay bit-identically). A run map is therefore a pure function of the run seed,
|
||||
/// so server + client regenerate the SAME graph — the server keeps gameplay authority (the party's column + the
|
||||
/// reachable options ride the wire), the client regenerates only to DRAW the map. Mirrors the
|
||||
/// <see cref="ZoneEnemyMath"/> pure-math discipline.
|
||||
///
|
||||
/// Structure: layer 0 = a single Combat landing node; interior layers width 2–3, weighted-typed
|
||||
/// (Combat 60 / Reward 25 / Elite 15, with an all-Reward-layer guard); the second-to-last layer is an all-Elite
|
||||
/// gate (so every start→boss path passes ≥1 Elite); the last layer = the single Boss terminal. Edges: a primary
|
||||
/// pass (every source gets ≥1 proportional out-edge, jittered, sometimes widened) + a coverage pass (every target
|
||||
/// gets ≥1 in-edge), which together guarantee full reachability from the root and exactly one terminal.
|
||||
/// </summary>
|
||||
public static class RunMapMath
|
||||
{
|
||||
// ---- deterministic integer hashing (order-independent combine + a final avalanche) ----
|
||||
|
||||
static uint Mix(uint h)
|
||||
{
|
||||
h ^= h >> 16; h *= 0x7feb352du;
|
||||
h ^= h >> 15; h *= 0x846ca68bu;
|
||||
h ^= h >> 16;
|
||||
return h;
|
||||
}
|
||||
|
||||
static uint Combine(uint h, uint v)
|
||||
{
|
||||
// boost-style hash_combine
|
||||
h ^= v + 0x9e3779b9u + (h << 6) + (h >> 2);
|
||||
return h;
|
||||
}
|
||||
|
||||
/// <summary>Deterministic hash of a salt tuple (integer-only, well-mixed, never dependent on draw order).</summary>
|
||||
public static uint Hash(uint a) => Mix(Combine(0x811c9dc5u, a));
|
||||
public static uint Hash(uint a, uint b) => Mix(Combine(Combine(0x811c9dc5u, a), b));
|
||||
public static uint Hash(uint a, uint b, uint c) => Mix(Combine(Combine(Combine(0x811c9dc5u, a), b), c));
|
||||
public static uint Hash(uint a, uint b, uint c, uint d) =>
|
||||
Mix(Combine(Combine(Combine(Combine(0x811c9dc5u, a), b), c), d));
|
||||
|
||||
/// <summary>
|
||||
/// Generate the branching run map for <paramref name="runSeed"/>. Deterministic + identical on both worlds.
|
||||
/// </summary>
|
||||
public static RunMap Generate(uint runSeed)
|
||||
{
|
||||
uint s = math.max(1u, runSeed);
|
||||
int L = 6 + (int)(Hash(s, 0x1Au) % 5u); // run length in [6,10]
|
||||
|
||||
var map = new RunMap { LayerCount = (byte)L };
|
||||
|
||||
// Per-layer branch widths: single landing + single boss, interior 2–3.
|
||||
map.LayerWidths = new FixedList64Bytes<byte>();
|
||||
for (int layer = 0; layer < L; layer++)
|
||||
{
|
||||
byte w = (layer == 0 || layer == L - 1)
|
||||
? (byte)1
|
||||
: (byte)(2 + (int)(Hash(s, (uint)layer, 0x11u) % 2u)); // 2 or 3
|
||||
map.LayerWidths.Add(w);
|
||||
}
|
||||
|
||||
// Nodes: fixed stride MaxWidth per layer (absent columns left default).
|
||||
map.Nodes = new FixedList512Bytes<RunMapNode>();
|
||||
int slots = L * RunMap.MaxWidth;
|
||||
for (int i = 0; i < slots; i++) map.Nodes.Add(default);
|
||||
|
||||
// Types / biome / shape.
|
||||
for (int layer = 0; layer < L; layer++)
|
||||
{
|
||||
int w = map.LayerWidths[layer];
|
||||
byte layerBiome = (byte)(Hash(s, (uint)layer, 0xB1u) % RoomBiomeId.Count);
|
||||
bool anyNonReward = false;
|
||||
for (int col = 0; col < w; col++)
|
||||
{
|
||||
byte type = PickType(s, layer, col, L);
|
||||
if (type != RoomTypeId.Reward) anyNonReward = true;
|
||||
byte shape = (byte)(Hash(s, (uint)layer, (uint)col, 0x5Au) % RoomShapeId.Count);
|
||||
map.Nodes[RunMap.NodeId(layer, col)] = new RunMapNode
|
||||
{
|
||||
RoomType = type,
|
||||
Biome = layerBiome,
|
||||
ShapeId = shape,
|
||||
NextMask = 0,
|
||||
};
|
||||
}
|
||||
// Guard: never an entire interior layer of only Reward rooms → force column 0 to Combat.
|
||||
if (!anyNonReward && w > 0)
|
||||
{
|
||||
int id0 = RunMap.NodeId(layer, 0);
|
||||
var n = map.Nodes[id0];
|
||||
n.RoomType = RoomTypeId.Combat;
|
||||
map.Nodes[id0] = n;
|
||||
}
|
||||
}
|
||||
|
||||
BuildEdges(ref map, s);
|
||||
return map;
|
||||
}
|
||||
|
||||
static byte PickType(uint s, int layer, int col, int L)
|
||||
{
|
||||
if (layer == 0) return RoomTypeId.Combat; // guaranteed landing room
|
||||
if (layer == L - 1) return RoomTypeId.Boss; // single terminal
|
||||
if (layer == L - 2) return RoomTypeId.Elite; // all-Elite gate (≥1 Elite on every path)
|
||||
uint r = Hash(s, (uint)layer, (uint)col, 0xC0u) % 100u; // Combat 60 / Reward 25 / Elite 15
|
||||
if (r < 60u) return RoomTypeId.Combat;
|
||||
if (r < 85u) return RoomTypeId.Reward;
|
||||
return RoomTypeId.Elite;
|
||||
}
|
||||
|
||||
static void BuildEdges(ref RunMap map, uint s)
|
||||
{
|
||||
int L = map.LayerCount;
|
||||
for (int l = 0; l < L - 1; l++)
|
||||
{
|
||||
int w = map.LayerWidths[l];
|
||||
int wn = map.LayerWidths[l + 1];
|
||||
|
||||
// Primary: every source gets a proportional out-edge (± jitter), sometimes widened to a neighbor.
|
||||
for (int c = 0; c < w; c++)
|
||||
{
|
||||
int t = ProportionalCol(c, w, wn);
|
||||
int jitter = (int)(Hash(s, (uint)l, (uint)c, 0xEDu) % 3u) - 1; // -1, 0, +1
|
||||
t = math.clamp(t + jitter, 0, wn - 1);
|
||||
SetEdge(ref map, l, c, t);
|
||||
|
||||
if (wn > 1 && Hash(s, (uint)l, (uint)c, 0x2Bu) % 100u < 35u)
|
||||
{
|
||||
int dir = (Hash(s, (uint)l, (uint)c, 0x2Cu) % 2u) == 0u ? -1 : 1;
|
||||
int t2 = math.clamp(t + dir, 0, wn - 1);
|
||||
SetEdge(ref map, l, c, t2);
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage: every target in the next layer must have ≥1 in-edge (forces convergence on the Boss).
|
||||
for (int tcol = 0; tcol < wn; tcol++)
|
||||
{
|
||||
if (!HasInEdge(ref map, l, tcol))
|
||||
{
|
||||
int src = ProportionalCol(tcol, wn, w);
|
||||
SetEdge(ref map, l, src, tcol);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static int ProportionalCol(int from, int fromWidth, int toWidth)
|
||||
{
|
||||
if (fromWidth <= 1 || toWidth <= 1) return toWidth / 2;
|
||||
return (int)math.round((float)from * (toWidth - 1) / (fromWidth - 1));
|
||||
}
|
||||
|
||||
static void SetEdge(ref RunMap map, int layer, int col, int targetCol)
|
||||
{
|
||||
int id = RunMap.NodeId(layer, col);
|
||||
var n = map.Nodes[id];
|
||||
n.NextMask |= (byte)(1 << targetCol);
|
||||
map.Nodes[id] = n;
|
||||
}
|
||||
|
||||
static bool HasInEdge(ref RunMap map, int layer, int targetCol)
|
||||
{
|
||||
int w = map.LayerWidths[layer];
|
||||
byte bit = (byte)(1 << targetCol);
|
||||
for (int c = 0; c < w; c++)
|
||||
if ((map.Nodes[RunMap.NodeId(layer, c)].NextMask & bit) != 0) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The columns of the NEXT layer reachable from node (<paramref name="layer"/>, <paramref name="col"/>).
|
||||
/// Empty for the Boss/last layer. This is the authoritative set the route-choice offer is drawn from.
|
||||
/// </summary>
|
||||
public static int ReachableOptions(in RunMap map, int layer, int col, out FixedList32Bytes<byte> cols)
|
||||
{
|
||||
cols = new FixedList32Bytes<byte>();
|
||||
if (layer < 0 || layer >= map.LayerCount - 1) return 0;
|
||||
byte mask = map.Node(layer, col).NextMask;
|
||||
int wn = map.Width(layer + 1);
|
||||
for (int j = 0; j < wn; j++)
|
||||
if ((mask & (1 << j)) != 0) cols.Add((byte)j);
|
||||
return cols.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True iff every PRESENT node is reachable from the root (0,0) via the edges (BFS). Used to assert the
|
||||
/// generator never strands a node or the Boss. O(nodes).
|
||||
/// </summary>
|
||||
public static bool AllNodesReachable(in RunMap map)
|
||||
{
|
||||
var visited = new FixedList128Bytes<byte>();
|
||||
for (int i = 0; i < RunMap.MaxNodes; i++) visited.Add(0);
|
||||
|
||||
var stack = new FixedList128Bytes<byte>();
|
||||
int root = RunMap.NodeId(0, 0);
|
||||
visited[root] = 1;
|
||||
stack.Add((byte)root);
|
||||
|
||||
while (stack.Length > 0)
|
||||
{
|
||||
int id = stack[stack.Length - 1];
|
||||
stack.RemoveAt(stack.Length - 1);
|
||||
int layer = map.LayerOf(id);
|
||||
if (layer >= map.LayerCount - 1) continue;
|
||||
byte mask = map.NodeAt(id).NextMask;
|
||||
int wn = map.Width(layer + 1);
|
||||
for (int j = 0; j < wn; j++)
|
||||
{
|
||||
if ((mask & (1 << j)) == 0) continue;
|
||||
int nid = RunMap.NodeId(layer + 1, j);
|
||||
if (visited[nid] == 0) { visited[nid] = 1; stack.Add((byte)nid); }
|
||||
}
|
||||
}
|
||||
|
||||
for (int layer = 0; layer < map.LayerCount; layer++)
|
||||
for (int col = 0; col < map.Width(layer); col++)
|
||||
if (visited[RunMap.NodeId(layer, col)] == 0) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f2e10313b5eeac468bef027c5c7be62
|
||||
@@ -1,16 +0,0 @@
|
||||
using Unity.Entities;
|
||||
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// SERVER-ONLY roster tag for the players conscripted into the CURRENT run — stamped on every connected
|
||||
/// player at the launch edge (Launching → room 0), removed on the Returning edge. Room advances teleport
|
||||
/// ONLY participants: a dead-respawned member (back at base) is re-conscripted on the next advance (the
|
||||
/// operator-locked default), while a mid-run late JOINER — who never readied — stays safely at base until
|
||||
/// the next Staging (spec §2.2 closed-party rule; post-impl review, confirmed medium). NOT a [GhostField];
|
||||
/// disconnect cleanup is free (the tag dies with the player ghost's LinkedEntityGroup despawn).
|
||||
/// </summary>
|
||||
public struct RunParticipant : IComponentData
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 92795e88279d04e42af70f8ec1032cc7
|
||||
@@ -1,60 +0,0 @@
|
||||
namespace ProjectM.Simulation
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-only working state for the run FSM — lives on the CycleDirector beside <see cref="RunInfo"/> but is
|
||||
/// NOT replicated (adding fields here never re-bakes the ghost). Owned/written by <c>RunDirectorSystem</c>.
|
||||
/// Determinism: <see cref="RunSeed"/> = max(1, Hash(<see cref="RunEpoch"/>, <see cref="HostSalt"/>)) — monotonic
|
||||
/// int, never a tick, equality-compared. Tick sentinels (<see cref="RewardGraceTick"/>/<see cref="RouteGraceTick"/>)
|
||||
/// route through <see cref="TickUtil.NonZero"/> and compare via NetworkTick.IsNewerThan (never raw uint).
|
||||
/// </summary>
|
||||
public struct RunRuntime : Unity.Entities.IComponentData
|
||||
{
|
||||
// ---- run identity / seed ----
|
||||
/// <summary>Working copy of the run seed (mirrored to the replicated <see cref="RunInfo.RunSeed"/>).</summary>
|
||||
public uint RunSeed;
|
||||
/// <summary>Monotonic run counter; bumped on the Staging→Launching edge so each run reseeds. Equality-compared.</summary>
|
||||
public int RunEpoch;
|
||||
/// <summary>Per-playthrough salt folded into <see cref="RunSeed"/> for cross-session map variety (seeded at spawn, non-tick).</summary>
|
||||
public uint HostSalt;
|
||||
|
||||
// ---- room traversal ----
|
||||
/// <summary>Monotonic room-seed counter; bumped per room advance so the field/enemy directors reseed. Equality-compared.</summary>
|
||||
public int RoomEpoch;
|
||||
/// <summary>Which of the two ping-pong sub-arena slots the active room occupies (CurrentRoom & 1).</summary>
|
||||
public byte ActiveSubSlot;
|
||||
/// <summary>The active room's stable map node id (single plan authority — field/enemy directors read this, never re-derive).</summary>
|
||||
public int CurrentNodeId;
|
||||
/// <summary>The active room's column (mirrors <see cref="RunInfo.CurrentCol"/>).</summary>
|
||||
public byte CurrentCol;
|
||||
/// <summary>The active room's <see cref="RoomTypeId"/> (single plan authority).</summary>
|
||||
public byte CurrentRoomType;
|
||||
|
||||
// ---- scarcity / banking latches ----
|
||||
/// <summary>Run-wide remaining resource-node allotment (floors each room's scatter; decrements per node) → true scarcity.</summary>
|
||||
public int NodeBudgetRemaining;
|
||||
/// <summary>The <see cref="RunEpoch"/> the terminal bank last fired for — equality latch so a multi-tick Returning banks once.</summary>
|
||||
public int LastBankedRunEpoch;
|
||||
/// <summary>1 iff the run ended by a genuine BOSS clear (gates the win-meter/RunsCompleted credit; 0 on abort/wipe).</summary>
|
||||
public byte LastTerminalCleared;
|
||||
/// <summary>Rooms actually CLEARED this run (bumped on each InRoom→RoomReward edge; reset at launch) — the
|
||||
/// honest depth the terminal bank records into MaxDepthReached (never the planned RoomCount — D-F3).</summary>
|
||||
public int RoomsClearedThisRun;
|
||||
|
||||
// ---- ready / grace ----
|
||||
/// <summary>Previous-tick all-ready state (rising-edge latch for the Staging→Launching launch).</summary>
|
||||
public byte WasAllReady;
|
||||
/// <summary>Server tick the RoomReward boon-pick grace elapses (NonZero; IsNewerThan-compared).</summary>
|
||||
public uint RewardGraceTick;
|
||||
/// <summary>Server tick the RouteSelect grace elapses → auto-pick lowest-index reachable (NonZero; IsNewerThan-compared).</summary>
|
||||
public uint RouteGraceTick;
|
||||
|
||||
/// <summary>DR-046: RoomExplore soft-timeout (NonZero; auto-advance if nobody interacts the portal), so the
|
||||
/// loot window can never softlock. Set on entering RoomExplore, compared via NetworkTick.IsNewerThan.</summary>
|
||||
public uint ExploreGraceTick;
|
||||
|
||||
|
||||
// ---- boons ----
|
||||
/// <summary>Monotonic per-run boon-pick counter → distinct SourceIds in the run-scoped boon band; reset each run.</summary>
|
||||
public uint BoonPickCounter;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6936c1ec155a69a45a957a2f2dac1c3f
|
||||
Reference in New Issue
Block a user