Files
Project-M/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs
T
kronic 62e48a3b0b 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>
2026-08-07 12:59:39 -07:00

226 lines
12 KiB
C#

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 resource harvest + Blight-clutter clearing: sweeps each surviving projectile's this-tick travel
/// segment against a UNIFIED target set of resource-node ghosts AND Blight-clutter ghosts, deposits the hit
/// target's yield into the GLOBAL resource ledger (the CycleDirector's <see cref="StorageEntry"/> buffer,
/// resolved via <see cref="ResourceLedger"/> — NEVER GetSingleton&lt;StorageEntry&gt;, which would collide with
/// the base storage container) and decrements its Remaining; the target despawns at &lt;= 0. Nodes deposit
/// <see cref="ResourceNode.ResourceId"/> @ HarvestPerHit; clutter deposits <see cref="BlightClutter.ScrapResourceId"/>
/// @ ScrapPerHit (a small "minor scrap" trickle — carving through the frontier). UNIFYING the two into one sweep
/// is a CORRECTNESS requirement: two separate sweeps would each DestroyEntity a projectile that overlaps a node
/// AND a clutter piece — a double DestroyEntity throws at ECB playback. Runs in the plain server
/// SimulationSystemGroup <c>[UpdateAfter(PredictedSimulationSystemGroup)]</c> — after ProjectileDamageSystem has
/// consumed Health-target hits and range-expired projectiles, so this only sees true survivors. The swept
/// segment is reconstructed from <see cref="Projectile.LastStep"/> (written by ProjectileMoveSystem in the
/// fixed-step group), so it is tunnelling-safe WITHOUT depending on this plain group's variable-frame DeltaTime.
/// A target hit by two projectiles in one tick deposits twice but is destroyed exactly once. Relies on the
/// asserted ~1000-unit base/expedition coordinate gap so a base projectile can never reach an expedition target.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
[UpdateAfter(typeof(PredictedSimulationSystemGroup))]
public partial struct ResourceHarvestSystem : ISystem
{
const float k_ProjectileRadius = Tuning.HarvestProjectileRadius;
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
ComponentLookup<RegionTag> m_RegionLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<Projectile>();
state.RequireForUpdate<ResourceLedger>();
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(true);
m_RegionLookup = state.GetComponentLookup<RegionTag>(true);
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var ledgerEntity = SystemAPI.GetSingletonEntity<ResourceLedger>();
// Fuse scheduling needs the server tick; absent/invalid (plain test worlds) -> explosive pops fall
// back to plain destroy (old behaviour).
bool haveTick = SystemAPI.TryGetSingleton<Unity.NetCode.NetworkTime>(out var hvNetTime)
&& hvNetTime.ServerTick.IsValid;
uint nowTick = haveTick ? hvNetTime.ServerTick.TickIndexForValidTick : 0u;
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
// 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_RegionLookup.Update(ref state);
// Snapshot all harvest/clear targets (nodes + clutter) once this tick into a UNIFIED set.
var tgtEntity = new NativeList<Entity>(Allocator.Temp);
var tgtPos = new NativeList<float2>(Allocator.Temp);
var tgtRadius = new NativeList<float>(Allocator.Temp);
var tgtRemaining = new NativeList<int>(Allocator.Temp);
var tgtYieldId = new NativeList<byte>(Allocator.Temp);
var tgtYieldPerHit = new NativeList<float>(Allocator.Temp);
var tgtVariant = new NativeList<byte>(Allocator.Temp);
var tgtIsClutter = new NativeList<bool>(Allocator.Temp);
var tgtToLedger = new NativeList<bool>(Allocator.Temp);
foreach (var (xform, hr, node, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<ResourceNode>>().WithEntityAccess())
{
if (node.ValueRO.Remaining <= 0) continue; // spent (a lit fuse) is not a target
tgtEntity.Add(e);
tgtPos.Add(xform.ValueRO.Position.xz);
tgtRadius.Add(hr.ValueRO.Value);
tgtRemaining.Add(node.ValueRO.Remaining);
tgtYieldId.Add(node.ValueRO.ResourceId);
tgtYieldPerHit.Add(node.ValueRO.HarvestPerHit);
tgtVariant.Add(0);
tgtIsClutter.Add(false);
tgtToLedger.Add(m_RegionLookup.HasComponent(e) && m_RegionLookup[e].Region == RegionId.Base);
}
foreach (var (xform, hr, clutter, e) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<HitRadius>, RefRO<BlightClutter>>().WithEntityAccess())
{
if (clutter.ValueRO.Remaining <= 0) continue; // lit-fuse barrel: unhittable, detonation owns it
tgtEntity.Add(e);
tgtPos.Add(xform.ValueRO.Position.xz);
tgtRadius.Add(hr.ValueRO.Value);
tgtRemaining.Add(clutter.ValueRO.Remaining);
tgtYieldId.Add(clutter.ValueRO.ScrapResourceId);
tgtYieldPerHit.Add(clutter.ValueRO.ScrapPerHit);
tgtVariant.Add(clutter.ValueRO.Variant);
tgtIsClutter.Add(true);
tgtToLedger.Add(m_RegionLookup.HasComponent(e) && m_RegionLookup[e].Region == RegionId.Base);
}
var destroyed = new NativeArray<bool>(tgtEntity.Length, Allocator.Temp);
var ecb = new EntityCommandBuffer(Allocator.Temp);
foreach (var (xform, proj, projEntity) in
SystemAPI.Query<RefRO<LocalTransform>, RefRO<Projectile>>().WithEntityAccess())
{
float3 cur = xform.ValueRO.Position;
float2 segEnd = cur.xz;
float2 segStart = segEnd - proj.ValueRO.Direction * proj.ValueRO.LastStep;
float2 seg = segEnd - segStart;
float segLenSq = math.lengthsq(seg);
int bestIdx = -1;
float bestT = float.MaxValue;
bool overlappedCleared = false; // struck a target a sibling projectile already cleared THIS tick
for (int i = 0; i < tgtEntity.Length; i++)
{
float2 tp = tgtPos[i];
float t = segLenSq > 1e-8f ? math.saturate(math.dot(tp - segStart, seg) / segLenSq) : 0f;
float2 closest = segStart + t * seg;
float hitDist = tgtRadius[i] + k_ProjectileRadius;
if (math.distancesq(tp, closest) > hitDist * hitDist)
continue;
if (destroyed[i]) { overlappedCleared = true; continue; }
if (t < bestT) { bestT = t; bestIdx = i; }
}
if (bestIdx < 0)
{
// No LIVE target on the segment. If the shot still overlapped a target a sibling projectile
// cleared this same tick, consume it anyway (a hit always spends the shot); else it's a miss.
if (overlappedCleared)
ecb.DestroyEntity(projEntity);
continue;
}
// A positive baked yield must always make progress: a raw (int) truncation of a sub-1.0 per-hit
// value would deposit 0 AND never decrement Remaining -> an immortal target that silently eats shots.
int amount = math.max(1, (int)tgtYieldPerHit[bestIdx]);
// DECOUPLED deposit (cover review wf_e14dd739-069): DESTRUCTIBLE COVER has ScrapPerHit=0 — it
// breaks in Remaining hits but yields NOTHING (durability must not be an economy faucet).
// Nodes/clutter (PerHit >= 1) keep deposit == decrement exactly as before.
// zero means ZERO (cover); any POSITIVE yield still credits >= 1 (the fractional-yield guard).
int deposit = tgtYieldPerHit[bestIdx] > 0f ? amount : 0;
byte yieldId = tgtYieldId[bestIdx];
// 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.
if (deposit > 0)
HarvestMath.DepositYield(yieldId, deposit, ledger, true);
int rem = tgtRemaining[bestIdx] - amount;
tgtRemaining[bestIdx] = rem;
ecb.DestroyEntity(projEntity);
if (rem <= 0)
{
if (!destroyed[bestIdx])
{
destroyed[bestIdx] = true;
if (tgtIsClutter[bestIdx] && tgtVariant[bestIdx] == 3 && haveTick)
{
// EXPLOSIVE pop (Variant 3): light the fuse instead of destroying — the replicated
// Remaining=0 on a still-alive barrel is the client's unambiguous fuse cue;
// HazardExplosionSystem detonates + destroys (Exploding_Barrels_Build_Spec).
SystemAPI.SetComponent(tgtEntity[bestIdx], new BlightClutter
{
Remaining = 0,
Variant = tgtVariant[bestIdx],
ScrapResourceId = tgtYieldId[bestIdx],
ScrapPerHit = tgtYieldPerHit[bestIdx],
});
ecb.AddComponent(tgtEntity[bestIdx], new BarrelFuse
{
ExplodeTick = TickUtil.NonZero(nowTick + Tuning.BarrelFuseTicks),
});
}
else
ecb.DestroyEntity(tgtEntity[bestIdx]);
}
}
else if (tgtIsClutter[bestIdx])
{
// Persist the decremented Remaining (replicated GhostField) so depletion carries across ticks.
SystemAPI.SetComponent(tgtEntity[bestIdx], new BlightClutter
{
Remaining = rem,
Variant = tgtVariant[bestIdx],
ScrapResourceId = tgtYieldId[bestIdx],
ScrapPerHit = tgtYieldPerHit[bestIdx],
});
}
else
{
SystemAPI.SetComponent(tgtEntity[bestIdx], new ResourceNode
{
ResourceId = tgtYieldId[bestIdx],
Remaining = rem,
HarvestPerHit = tgtYieldPerHit[bestIdx],
});
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
destroyed.Dispose();
tgtEntity.Dispose();
tgtPos.Dispose();
tgtRadius.Dispose();
tgtRemaining.Dispose();
tgtYieldId.Dispose();
tgtYieldPerHit.Dispose();
tgtVariant.Dispose();
tgtIsClutter.Dispose();
tgtToLedger.Dispose();
}
}
}