Economy: base-local mining loop (mine at base, any attack harvests, scheduled sieges)

Consolidate the divorced combat + economy halves into one base-local loop. BaseFieldSpawnSystem tops up RegionTag{Base} Ore nodes around the plot; harvest routes Base->shared ledger and Expedition/untagged->personal inventory for BOTH the projectile (ResourceHarvestSystem) and melee (MeleeComboSystem server-only block, writes Remaining back for VFX). Activate the reserved Schedule source in ThreatDirectorSystem so base sieges arm WITHOUT an expedition trip (the loop-closer: previously zero waves ever attacked a base-only player). Region-filter the ExpeditionFieldSystem teardown so it no longer wipes the permanent base field. HudSystem shows phase-aware loop copy. See DR-031.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 14:59:51 -07:00
parent 0d259fb68b
commit e1ed08a803
13 changed files with 386 additions and 6 deletions
@@ -49,11 +49,15 @@ namespace ProjectM.Simulation
public partial struct MeleeComboSystem : ISystem
{
ComponentLookup<KnockbackState> m_KnockbackLookup;
ComponentLookup<RegionTag> m_RegionLookup;
BufferLookup<InventorySlot> m_InvLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_KnockbackLookup = state.GetComponentLookup<KnockbackState>(isReadOnly: false);
m_RegionLookup = state.GetComponentLookup<RegionTag>(isReadOnly: true);
m_InvLookup = state.GetBufferLookup<InventorySlot>(isReadOnly: false);
state.RequireForUpdate<NetworkTime>();
}
@@ -166,6 +170,50 @@ namespace ProjectM.Simulation
enemyEntities.Add(enemyEntity);
enemyPositions.Add(xform.ValueRO.Position);
}
// Gather harvest targets (resource nodes + Blight clutter) ONCE so "any attack harvests": a swing
// depletes every node/clutter in its cone, crediting the shared ResourceLedger (the build currency
// 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);
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);
var harvYieldId = new NativeList<byte>(Allocator.Temp);
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())
{
harvEntity.Add(he);
harvPos.Add(hx.ValueRO.Position);
harvRemaining.Add(node.ValueRO.Remaining);
harvYieldId.Add(node.ValueRO.ResourceId);
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())
{
harvEntity.Add(he);
harvPos.Add(hx.ValueRO.Position);
harvRemaining.Add(clutter.ValueRO.Remaining);
harvYieldId.Add(clutter.ValueRO.ScrapResourceId);
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);
var ecb = new EntityCommandBuffer(Allocator.Temp);
@@ -192,10 +240,85 @@ namespace ProjectM.Simulation
}
}
}
// HARVEST: deplete every node/clutter in each swing's cone, crediting the shared ledger; write
// Remaining back so the [GhostField] replicates -> WorldFeedbackSystem chips fire on melee mining.
for (int s = 0; s < cleaves.Length; s++)
{
var hc = cleaves[s];
for (int i = 0; i < harvEntity.Length; i++)
{
if (harvDestroyed[i])
continue;
if (!MeleeConeMath.InCone(hc.From, hc.Face, hc.Range, cosHalf, harvPos[i]))
continue;
int amount = math.max(1, (int)harvPerHit[i]);
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).
int remainder = amount;
bool deposited = false;
if (!harvToLedger[i]
&& meleePlayerByConn.TryGetValue(hc.OwnerId, out var meleePlayer)
&& m_InvLookup.HasBuffer(meleePlayer))
{
var inv = m_InvLookup[meleePlayer];
remainder = InventoryMath.Deposit(inv, yieldId, amount, Tuning.DefaultStackMax, Tuning.InventoryMaxSlots);
deposited = true;
}
if (remainder > 0 && haveLedger)
{
var ledger = SystemAPI.GetBuffer<StorageEntry>(ledgerEntity);
StorageMath.Deposit(ledger, yieldId, remainder);
deposited = true;
}
if (!deposited)
continue;
int rem = harvRemaining[i] - amount;
harvRemaining[i] = rem;
if (rem <= 0)
{
harvDestroyed[i] = true;
ecb.DestroyEntity(harvEntity[i]);
}
else if (harvIsClutter[i])
{
ecb.SetComponent(harvEntity[i], new BlightClutter
{
Remaining = rem,
Variant = harvVariant[i],
ScrapResourceId = yieldId,
ScrapPerHit = harvPerHit[i],
});
}
else
{
ecb.SetComponent(harvEntity[i], new ResourceNode
{
ResourceId = yieldId,
Remaining = rem,
HarvestPerHit = harvPerHit[i],
});
}
}
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
enemyEntities.Dispose();
enemyPositions.Dispose();
harvEntity.Dispose();
harvPos.Dispose();
harvRemaining.Dispose();
harvYieldId.Dispose();
harvPerHit.Dispose();
harvIsClutter.Dispose();
harvVariant.Dispose();
harvDestroyed.Dispose();
harvToLedger.Dispose();
meleePlayerByConn.Dispose();
}
if (cleaves.IsCreated)