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,151 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative equipment handler (<see cref="EquipRequest"/> / <see cref="UnequipRequest"/> RPCs).
|
||||
/// Resolves the sender's player (SourceConnection -> NetworkId -> GhostOwner, the AbilityUpgradeSystem /
|
||||
/// InventoryDepositSystem owner-map idiom) and applies the change IN-PLACE: moves the item between the
|
||||
/// personal <see cref="InventorySlot"/> bag and the <see cref="EquipmentSlot"/> loadout (buffer index = slot),
|
||||
/// and adds/strips the item's inline stat mods as <see cref="StatModifier"/>s tagged by a
|
||||
/// per-slot SourceId (<c>Tuning.EquipSourceIdBase + slot</c>), stripped TARGET-AGNOSTICALLY via
|
||||
/// <see cref="TimedModifierUtil.RemoveBySourceId"/>. (LANTERN purge: weapons are stat-sticks — the old
|
||||
/// weapon->ability grant is deleted; abilities live in the 4-socket Spark loadout.)
|
||||
///
|
||||
/// Effects are EVENT-DRIVEN (applied once here): StatModifier is a [GhostField] buffer re-folded by the
|
||||
/// predicted StatRecomputeSystem every tick and replicated to the owner, so the swap is prediction-correct
|
||||
/// and survives respawn (the entity persists).
|
||||
/// Atomicity: an equip into an occupied slot verifies the bag can hold the swapped-out item BEFORE any
|
||||
/// withdrawal and rejects otherwise — no item loss (the co-op-placement commit-in-place rule). Plain server
|
||||
/// SimulationSystemGroup (NOT predicted -> applied once, no rollback double-apply); only the request entity
|
||||
/// destroy is deferred to the ECB.
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct EquipSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<ItemDatabase>();
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAny<EquipRequest, UnequipRequest>().WithAll<ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var itemDb = SystemAPI.GetSingleton<ItemDatabase>();
|
||||
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, entity) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>>()
|
||||
.WithAll<PlayerTag, InventorySlot, EquipmentSlot>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = entity;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (request, receive, requestEntity) in
|
||||
SystemAPI.Query<RefRO<EquipRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
||||
{
|
||||
if (TryResolvePlayer(ref state, playerByConn, receive.ValueRO.SourceConnection, out var player))
|
||||
HandleEquip(ref state, itemDb, player, request.ValueRO.ItemId);
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
|
||||
foreach (var (request, receive, requestEntity) in
|
||||
SystemAPI.Query<RefRO<UnequipRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
||||
{
|
||||
if (TryResolvePlayer(ref state, playerByConn, receive.ValueRO.SourceConnection, out var player))
|
||||
HandleUnequip(ref state, itemDb, player, request.ValueRO.Slot);
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
playerByConn.Dispose();
|
||||
}
|
||||
|
||||
static bool TryResolvePlayer(ref SystemState state, NativeHashMap<int, Entity> map, Entity conn, out Entity player)
|
||||
{
|
||||
player = Entity.Null;
|
||||
return state.EntityManager.HasComponent<NetworkId>(conn)
|
||||
&& map.TryGetValue(state.EntityManager.GetComponentData<NetworkId>(conn).Value, out player);
|
||||
}
|
||||
|
||||
static void HandleEquip(ref SystemState state, ItemDatabase itemDb, Entity player, ushort itemId)
|
||||
{
|
||||
ref var db = ref itemDb.Value.Value;
|
||||
if (!db.TryGetItem(itemId, out var def)) return;
|
||||
byte slot = def.EquipSlot;
|
||||
if (slot >= EquipSlotId.Count) return; // not equippable (255 or out of range)
|
||||
|
||||
var bag = state.EntityManager.GetBuffer<InventorySlot>(player);
|
||||
if (InventoryMath.CountOf(bag, itemId) < 1) return; // the sender isn't carrying it
|
||||
|
||||
var slots = state.EntityManager.GetBuffer<EquipmentSlot>(player);
|
||||
ushort oldItem = slots[slot].ItemId;
|
||||
|
||||
// Atomicity: if the slot is occupied, the bag MUST be able to hold the swapped-out item before we
|
||||
// touch anything; reject the whole equip otherwise so the old item is never lost.
|
||||
if (oldItem != 0 && !InventoryMath.CanDeposit(bag, oldItem, 1, StackMaxOf(ref db, oldItem), Tuning.InventoryMaxSlots))
|
||||
return;
|
||||
|
||||
// Commit in-place.
|
||||
InventoryMath.Withdraw(bag, itemId, 1);
|
||||
if (oldItem != 0)
|
||||
{
|
||||
InventoryMath.Deposit(bag, oldItem, 1, StackMaxOf(ref db, oldItem), Tuning.InventoryMaxSlots);
|
||||
StripSlotEffects(ref state, player, slot);
|
||||
}
|
||||
|
||||
slots[slot] = new EquipmentSlot { ItemId = itemId };
|
||||
ApplySlotEffects(ref state, player, slot, def);
|
||||
}
|
||||
|
||||
static void HandleUnequip(ref SystemState state, ItemDatabase itemDb, Entity player, byte slot)
|
||||
{
|
||||
if (slot >= EquipSlotId.Count) return;
|
||||
ref var db = ref itemDb.Value.Value;
|
||||
|
||||
var slots = state.EntityManager.GetBuffer<EquipmentSlot>(player);
|
||||
ushort item = slots[slot].ItemId;
|
||||
if (item == 0) return; // nothing equipped
|
||||
|
||||
var bag = state.EntityManager.GetBuffer<InventorySlot>(player);
|
||||
if (!InventoryMath.CanDeposit(bag, item, 1, StackMaxOf(ref db, item), Tuning.InventoryMaxSlots))
|
||||
return; // bag full -> can't unequip (no item loss)
|
||||
|
||||
InventoryMath.Deposit(bag, item, 1, StackMaxOf(ref db, item), Tuning.InventoryMaxSlots);
|
||||
slots[slot] = new EquipmentSlot { ItemId = 0 };
|
||||
StripSlotEffects(ref state, player, slot);
|
||||
}
|
||||
|
||||
static void ApplySlotEffects(ref SystemState state, Entity player, byte slot, ItemDefBlob def)
|
||||
{
|
||||
// LANTERN purge: weapons are stat-sticks — the old weapon->AbilityRef ability grant is deleted
|
||||
// (abilities live in the 4-socket Spark loadout).
|
||||
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
|
||||
uint sourceId = Tuning.EquipSourceIdBase + (uint)slot;
|
||||
for (int i = 0; i < ItemDefBlob.MaxMods; i++)
|
||||
{
|
||||
var m = def.GetMod(i);
|
||||
if (m.Target == 255) continue;
|
||||
mods.Add(new StatModifier { Target = m.Target, Op = m.Op, Value = m.Value, SourceId = sourceId });
|
||||
}
|
||||
}
|
||||
|
||||
static void StripSlotEffects(ref SystemState state, Entity player, byte slot)
|
||||
{
|
||||
var mods = state.EntityManager.GetBuffer<StatModifier>(player);
|
||||
TimedModifierUtil.RemoveBySourceId(mods, Tuning.EquipSourceIdBase + (uint)slot);
|
||||
}
|
||||
|
||||
static int StackMaxOf(ref ItemDatabaseBlob db, ushort itemId)
|
||||
=> db.TryGetItem(itemId, out var d) && d.StackMax > 0 ? d.StackMax : 1;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 187144e115a815c4fae51eaa9e95012f
|
||||
@@ -1,84 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.NetCode;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-authoritative handler for <see cref="InventoryDepositRequest"/> RPCs: moves items from the
|
||||
/// sender's PERSONAL <see cref="InventorySlot"/> inventory into the shared base stockpile (the global
|
||||
/// <see cref="ResourceLedger"/> the build/upgrade/automation economy spends from). Resolves the sender's
|
||||
/// player (SourceConnection -> NetworkId -> GhostOwner) via the AbilityUpgradeSystem owner-map idiom,
|
||||
/// then withdraws from the player's inventory and deposits into the ledger IN-PLACE (buffer mutation is not
|
||||
/// a structural change). <c>ItemId == 0</c> ("deposit all") is handled BEFORE any per-item withdraw and
|
||||
/// never writes a 0-id row. Resolves the ledger via <c>GetSingletonEntity<ResourceLedger>()</c> then
|
||||
/// <c>GetBuffer<StorageEntry>()</c> — NEVER <c>GetSingleton<StorageEntry></c> (the base
|
||||
/// container owns a second StorageEntry buffer). Plain server SimulationSystemGroup (not predicted, so the
|
||||
/// effect applies exactly once — no rollback double-apply).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
public partial struct InventoryDepositSystem : ISystem
|
||||
{
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<ResourceLedger>();
|
||||
var builder = new EntityQueryBuilder(Allocator.Temp)
|
||||
.WithAll<InventoryDepositRequest, ReceiveRpcCommandRequest>();
|
||||
state.RequireForUpdate(state.GetEntityQuery(builder));
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(SystemAPI.GetSingletonEntity<ResourceLedger>());
|
||||
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, entity) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = entity;
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
foreach (var (request, receive, requestEntity) in
|
||||
SystemAPI.Query<RefRO<InventoryDepositRequest>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
|
||||
{
|
||||
var conn = receive.ValueRO.SourceConnection;
|
||||
if (SystemAPI.HasComponent<NetworkId>(conn)
|
||||
&& playerByConn.TryGetValue(SystemAPI.GetComponent<NetworkId>(conn).Value, out var player))
|
||||
{
|
||||
var inv = SystemAPI.GetBuffer<InventorySlot>(player);
|
||||
var req = request.ValueRO;
|
||||
|
||||
if (req.ItemId == 0)
|
||||
{
|
||||
// Deposit EVERYTHING: drain each non-empty stack into the ledger, then clear the bag.
|
||||
for (int i = 0; i < inv.Length; i++)
|
||||
{
|
||||
var slot = inv[i];
|
||||
if (slot.ItemId != 0 && slot.Count > 0)
|
||||
StorageMath.Deposit(ledger, slot.ItemId, slot.Count);
|
||||
}
|
||||
inv.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Count <= 0 means "all of that item"; Withdraw clamps to what is available.
|
||||
int want = req.Count <= 0 ? int.MaxValue : req.Count;
|
||||
int moved = InventoryMath.Withdraw(inv, req.ItemId, want);
|
||||
if (moved > 0)
|
||||
StorageMath.Deposit(ledger, req.ItemId, moved);
|
||||
}
|
||||
}
|
||||
|
||||
ecb.DestroyEntity(requestEntity);
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
playerByConn.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 25b1bdf13ad8a6d4ca48bef112b98d28
|
||||
@@ -34,7 +34,6 @@ namespace ProjectM.Server
|
||||
const float k_ProjectileRadius = Tuning.HarvestProjectileRadius;
|
||||
|
||||
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
|
||||
BufferLookup<InventorySlot> m_InvLookup;
|
||||
ComponentLookup<RegionTag> m_RegionLookup;
|
||||
|
||||
[BurstCompile]
|
||||
@@ -43,7 +42,6 @@ namespace ProjectM.Server
|
||||
state.RequireForUpdate<Projectile>();
|
||||
state.RequireForUpdate<ResourceLedger>();
|
||||
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(true);
|
||||
m_InvLookup = state.GetBufferLookup<InventorySlot>(false);
|
||||
m_RegionLookup = state.GetComponentLookup<RegionTag>(true);
|
||||
}
|
||||
|
||||
@@ -58,18 +56,11 @@ namespace ProjectM.Server
|
||||
uint nowTick = haveTick ? hvNetTime.ServerTick.TickIndexForValidTick : 0u;
|
||||
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
|
||||
|
||||
// Resolve the harvesting player from the projectile's GhostOwner so yield lands in their PERSONAL
|
||||
// inventory. Owner read via a cached lookup (optional); the owner->player map + item catalog are
|
||||
// hoisted out of the per-hit sweep (invariant for the tick).
|
||||
// 2026-08-07 audit purge: yield used to route to the firing player's PERSONAL inventory and spill to
|
||||
// the ledger. The inventory layer went with the shell — all yield now credits the ledger directly.
|
||||
m_GhostOwnerLookup.Update(ref state);
|
||||
m_InvLookup.Update(ref state);
|
||||
m_RegionLookup.Update(ref state);
|
||||
bool haveDb = SystemAPI.TryGetSingleton<ItemDatabase>(out var itemDb);
|
||||
|
||||
var playerByConn = new NativeHashMap<int, Entity>(8, Allocator.Temp);
|
||||
foreach (var (owner, playerEntity) in
|
||||
SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerTag, InventorySlot>().WithEntityAccess())
|
||||
playerByConn[owner.ValueRO.NetworkId] = playerEntity;
|
||||
|
||||
// Snapshot all harvest/clear targets (nodes + clutter) once this tick into a UNIFIED set.
|
||||
var tgtEntity = new NativeList<Entity>(Allocator.Temp);
|
||||
@@ -163,13 +154,8 @@ namespace ProjectM.Server
|
||||
// Route the yield into the HARVESTING player's PERSONAL inventory. The projectile carries the
|
||||
// firing player's GhostOwner (AbilityFireSystem); the owner is read OPTIONALLY (cached lookup) so
|
||||
// an un-owned projectile (or a test projectile with no GhostOwner) falls through to the ledger.
|
||||
Entity harvester = Entity.Null;
|
||||
if (m_GhostOwnerLookup.HasComponent(projEntity)
|
||||
&& playerByConn.TryGetValue(m_GhostOwnerLookup[projEntity].NetworkId, out var ownedPlayer))
|
||||
harvester = ownedPlayer;
|
||||
if (deposit > 0)
|
||||
HarvestMath.DepositYield(yieldId, deposit, tgtToLedger[bestIdx], harvester,
|
||||
m_InvLookup, ledger, true, haveDb, itemDb);
|
||||
HarvestMath.DepositYield(yieldId, deposit, ledger, true);
|
||||
int rem = tgtRemaining[bestIdx] - amount;
|
||||
tgtRemaining[bestIdx] = rem;
|
||||
ecb.DestroyEntity(projEntity);
|
||||
@@ -224,7 +210,6 @@ namespace ProjectM.Server
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
playerByConn.Dispose();
|
||||
destroyed.Dispose();
|
||||
tgtEntity.Dispose();
|
||||
tgtPos.Dispose();
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
using ProjectM.Simulation;
|
||||
using Unity.Burst;
|
||||
using Unity.Collections;
|
||||
using Unity.Entities;
|
||||
using Unity.Mathematics;
|
||||
using Unity.NetCode;
|
||||
using Unity.Transforms;
|
||||
|
||||
namespace ProjectM.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-only per-ROOM field seeder — the Step-5 successor of the presence-keyed <c>ExpeditionFieldSystem</c>.
|
||||
/// When the run FSM has a room active (<see cref="RunInfo.Lifecycle"/> == InRoom) and
|
||||
/// <see cref="RunRuntime.RoomEpoch"/> has advanced past the epoch this system last seeded (int equality, never
|
||||
/// tick math), it resolves the active room's <see cref="RoomPlan"/> from the map node RunDirectorSystem published
|
||||
/// (<see cref="RunRuntime.CurrentNodeId"/> — the single plan authority; NEVER re-derived here) and scatters
|
||||
/// <c>plan.NodeCount</c> resource nodes — FLOORED by the run-wide scarcity budget
|
||||
/// <see cref="RunRuntime.NodeBudgetRemaining"/>, which this system spends down (documented co-write: RunDirector
|
||||
/// STAGES the budget at launch; this system only decrements it) — plus a light Blight-clutter dressing, all
|
||||
/// inside the room's shape at <see cref="RegionMath.ExpeditionRoomOrigin"/>(base, ActiveSubSlot). Every spawn is
|
||||
/// stamped <see cref="RoomTag"/>{room} (the teardown contract) on top of the prefab-baked RegionTag{Expedition};
|
||||
/// Scale is preserved via <c>baked.WithPosition</c> (never FromPosition).
|
||||
///
|
||||
/// Teardown: room-advance/return teardown belongs to RunDirectorSystem (RoomTeardown, Step 7). This system keeps
|
||||
/// ONE defensive sweep — Staging with any <see cref="RoomTag"/> alive → destroy them all (idempotent; covers
|
||||
/// abort/disconnect edges). Untagged ghosts (base field, structures) are structurally untouchable.
|
||||
///
|
||||
/// Ordering: <c>[UpdateAfter(RunDirectorSystem)]</c> so it reads the freshly-advanced room state same-tick.
|
||||
/// The old inherited <c>[UpdateAfter(CyclePhaseSystem)]</c> is deliberately DROPPED and NO CyclePhase edge may
|
||||
/// ever return to the room chain (the Play-only sort-cycle rule — invisible to EditMode).
|
||||
/// </summary>
|
||||
[BurstCompile]
|
||||
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
|
||||
[UpdateInGroup(typeof(SimulationSystemGroup))]
|
||||
[UpdateAfter(typeof(RunDirectorSystem))]
|
||||
public partial struct RoomFieldSystem : ISystem
|
||||
{
|
||||
/// <summary>Max clutter pieces per room — cosmetic ghosts still cost relevancy, keep the dressing light.</summary>
|
||||
const int MaxClutterPerRoom = 8;
|
||||
|
||||
EntityQuery m_RoomTagged;
|
||||
|
||||
[BurstCompile]
|
||||
public void OnCreate(ref SystemState state)
|
||||
{
|
||||
state.RequireForUpdate<ResourceFieldSpawner>();
|
||||
state.RequireForUpdate<RunInfo>();
|
||||
state.RequireForUpdate<RunRuntime>();
|
||||
m_RoomTagged = state.GetEntityQuery(ComponentType.ReadOnly<RoomTag>());
|
||||
}
|
||||
|
||||
[BurstCompile]
|
||||
public void OnUpdate(ref SystemState state)
|
||||
{
|
||||
var dirEntity = SystemAPI.GetSingletonEntity<RunInfo>();
|
||||
var info = SystemAPI.GetComponent<RunInfo>(dirEntity);
|
||||
var run = SystemAPI.GetComponent<RunRuntime>(dirEntity);
|
||||
|
||||
var spawnerEntity = SystemAPI.GetSingletonEntity<ResourceFieldSpawner>();
|
||||
var spawner = SystemAPI.GetComponent<ResourceFieldSpawner>(spawnerEntity);
|
||||
|
||||
// One-shot: attach this system's server-only bookkeeping beside the baked spawner singleton.
|
||||
if (!SystemAPI.HasComponent<RoomFieldState>(spawnerEntity))
|
||||
{
|
||||
state.EntityManager.AddComponentData(spawnerEntity, new RoomFieldState());
|
||||
return; // structural change — clean re-read next tick
|
||||
}
|
||||
var rf = SystemAPI.GetComponent<RoomFieldState>(spawnerEntity);
|
||||
|
||||
var ecb = new EntityCommandBuffer(Allocator.Temp);
|
||||
|
||||
if (info.Lifecycle == RunLifecycle.InRoom)
|
||||
{
|
||||
if (rf.LastSpawnedRoomEpoch != run.RoomEpoch && spawner.Prefab != Entity.Null)
|
||||
{
|
||||
float3 baseCenter = new float3(0f, 1f, 0f);
|
||||
if (SystemAPI.TryGetSingleton<BaseAnchor>(out var anchor))
|
||||
baseCenter = BaseGridMath.PlotCenter(anchor);
|
||||
float3 origin = RegionMath.ExpeditionRoomOrigin(baseCenter, run.ActiveSubSlot);
|
||||
|
||||
// Single plan authority: the node RunDirector published — never re-derived from the col/path.
|
||||
var map = RunMapMath.Generate(run.RunSeed);
|
||||
var node = map.NodeAt(run.CurrentNodeId);
|
||||
var plan = RoomLayoutMath.Plan(node, info.CurrentRoom, info.RoomCount);
|
||||
byte room = (byte)(info.CurrentRoom & 0xFF);
|
||||
|
||||
// Scarcity: the run-wide budget floors this room's count and is spent down (never negative).
|
||||
int count = math.min(plan.NodeCount, math.max(0, run.NodeBudgetRemaining));
|
||||
if (count > 0)
|
||||
{
|
||||
var baked = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
|
||||
var prefabNode = SystemAPI.GetComponent<ResourceNode>(spawner.Prefab);
|
||||
var rng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x0DEu) | 1u);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var e = ecb.Instantiate(spawner.Prefab);
|
||||
float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, count, ref rng);
|
||||
ecb.SetComponent(e, baked.WithPosition(pos));
|
||||
// Rarity-weighted resource type (Step 11): Ore 45% (building) / Biomass 40% (walls,
|
||||
// fabricator) / AETHER 15% — the scarce permanent-meta currency, felt when it drops.
|
||||
var rn = prefabNode;
|
||||
int roll = rng.NextInt(0, 100);
|
||||
rn.ResourceId = roll < 15 ? ResourceId.Aether : roll < 60 ? ResourceId.Ore : ResourceId.Biomass;
|
||||
ecb.SetComponent(e, rn);
|
||||
ecb.AddComponent(e, new RoomTag { Room = room });
|
||||
}
|
||||
run.NodeBudgetRemaining -= count;
|
||||
SystemAPI.SetComponent(dirEntity, run); // the documented budget co-write (spend only)
|
||||
}
|
||||
|
||||
// Clutter dressing (OPTIONAL singleton) — a DISTINCT seed so it never co-locates with nodes.
|
||||
if (SystemAPI.TryGetSingleton<ClutterFieldSpawner>(out var clutter)
|
||||
&& clutter.Prefab != Entity.Null)
|
||||
{
|
||||
var cBaked = SystemAPI.GetComponent<LocalTransform>(clutter.Prefab);
|
||||
var cProto = SystemAPI.GetComponent<BlightClutter>(clutter.Prefab);
|
||||
var crng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0xC17u) | 1u);
|
||||
int cCount = math.min(math.max(1, clutter.Count), MaxClutterPerRoom);
|
||||
for (int i = 0; i < cCount; i++)
|
||||
{
|
||||
var e = ecb.Instantiate(clutter.Prefab);
|
||||
float3 pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, cCount, ref crng);
|
||||
ecb.SetComponent(e, cBaked.WithPosition(pos));
|
||||
var bc = cProto;
|
||||
// ~25% EXPLOSIVE (Variant 3, the hazard — Exploding_Barrels_Build_Spec); rest stay cosmetic 0-2.
|
||||
bc.Variant = crng.NextFloat() < 0.25f ? (byte)3 : (byte)(i % 3);
|
||||
ecb.SetComponent(e, bc);
|
||||
ecb.AddComponent(e, new RoomTag { Room = room });
|
||||
}
|
||||
}
|
||||
|
||||
// DESTRUCTIBLE COVER (OPTIONAL singleton; review wf_e14dd739-069): never in Boss rooms (the boss
|
||||
// has no depenetration backstop) and never inside the origin keep-out ring (player landing +
|
||||
// portal). Variant stays the prefab's 4 — NEVER rerolled (the clutter block's explosive
|
||||
// reroll must not leak into this copy).
|
||||
if (plan.RoomType != RoomTypeId.Boss
|
||||
&& SystemAPI.TryGetSingleton<CoverFieldSpawner>(out var cover)
|
||||
&& cover.Prefab != Entity.Null)
|
||||
{
|
||||
var kBaked = SystemAPI.GetComponent<LocalTransform>(cover.Prefab);
|
||||
var krng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0xC0Eu) | 1u);
|
||||
int kCount = math.clamp(cover.Count, 0, 6);
|
||||
const float KeepOutFromOrigin = 7f;
|
||||
for (int i = 0; i < kCount; i++)
|
||||
{
|
||||
float3 pos = origin;
|
||||
for (int attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, kCount, ref krng);
|
||||
if (math.distance(pos.xz, origin.xz) >= KeepOutFromOrigin) break;
|
||||
}
|
||||
if (math.distance(pos.xz, origin.xz) < KeepOutFromOrigin) continue; // unlucky draws: drop the piece
|
||||
var e = ecb.Instantiate(cover.Prefab);
|
||||
ecb.SetComponent(e, kBaked.WithPosition(pos));
|
||||
ecb.AddComponent(e, new RoomTag { Room = room });
|
||||
}
|
||||
}
|
||||
|
||||
// BLIGHT GEYSER (OPTIONAL singleton; Geyser_Build_Spec / review wf_900e9965-8f0): a PERMANENT
|
||||
// periodic BOTH-SIDES AoE hazard, ONLY in Blight-biome rooms (gate on the LOCAL plan, like the
|
||||
// cover block). Never Boss rooms — a DESIGN choice (the geyser has no collider, so unlike cover it
|
||||
// is NOT the depenetration concern). Distinct hash sub-stream 0x6E7; keep-out ring around origin.
|
||||
// BORN-CORRECT: stamp NextEruptTick from the LIVE ServerTick (staggered per instance so eruptions
|
||||
// desync) so the first snapshot never carries the 0 sentinel; if NetworkTime is invalid this tick
|
||||
// the geyser ships 0 and GeyserEruptSystem lazy-stamps it born-correct instead (never a storm).
|
||||
if (plan.Biome == RoomBiomeId.Blight
|
||||
&& plan.RoomType != RoomTypeId.Boss
|
||||
&& SystemAPI.TryGetSingleton<GeyserFieldSpawner>(out var geyser)
|
||||
&& geyser.Prefab != Entity.Null)
|
||||
{
|
||||
uint eruptStamp = 0u;
|
||||
if (SystemAPI.TryGetSingleton<NetworkTime>(out var gnt) && gnt.ServerTick.IsValid)
|
||||
eruptStamp = gnt.ServerTick.TickIndexForValidTick;
|
||||
var gBaked = SystemAPI.GetComponent<LocalTransform>(geyser.Prefab);
|
||||
var grng = new Random(RunMapMath.Hash(run.RunSeed, (uint)run.CurrentNodeId, 0x6E7u) | 1u);
|
||||
int gCount = math.clamp(geyser.Count, 0, 4);
|
||||
const float GeyserKeepOut = 7f;
|
||||
for (int i = 0; i < gCount; i++)
|
||||
{
|
||||
float3 pos = origin;
|
||||
for (int attempt = 0; attempt < 8; attempt++)
|
||||
{
|
||||
pos = RoomLayoutMath.ScatterInShape(plan.ShapeId, origin, i, gCount, ref grng);
|
||||
if (math.distance(pos.xz, origin.xz) >= GeyserKeepOut) break;
|
||||
}
|
||||
if (math.distance(pos.xz, origin.xz) < GeyserKeepOut) continue; // unlucky draws: drop the piece
|
||||
var e = ecb.Instantiate(geyser.Prefab);
|
||||
ecb.SetComponent(e, gBaked.WithPosition(pos));
|
||||
// born-correct + per-instance stagger so geysers desync; 0 only if NetworkTime was invalid.
|
||||
uint next = eruptStamp != 0u
|
||||
? TickUtil.NonZero(eruptStamp + Tuning.GeyserPeriodTicks + (uint)i * 60u)
|
||||
: 0u;
|
||||
ecb.SetComponent(e, new Geyser { NextEruptTick = next });
|
||||
ecb.AddComponent(e, new RoomTag { Room = room });
|
||||
}
|
||||
}
|
||||
|
||||
rf.LastSpawnedRoomEpoch = run.RoomEpoch;
|
||||
SystemAPI.SetComponent(spawnerEntity, rf);
|
||||
}
|
||||
}
|
||||
else if (info.Lifecycle == RunLifecycle.Staging && !m_RoomTagged.IsEmpty)
|
||||
{
|
||||
// Defensive sweep: no run active but room ghosts linger (abort/disconnect edge) — clear every room.
|
||||
var ents = m_RoomTagged.ToEntityArray(Allocator.Temp);
|
||||
for (int i = 0; i < ents.Length; i++)
|
||||
ecb.DestroyEntity(ents[i]);
|
||||
ents.Dispose();
|
||||
}
|
||||
|
||||
ecb.Playback(state.EntityManager);
|
||||
ecb.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b2ba012b5e31bcc48b50dd14220c9fc5
|
||||
Reference in New Issue
Block a user