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
@@ -0,0 +1,101 @@
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 home-base mining-field manager. Keeps the live RegionTag{Base} ResourceNode count topped up to
/// <see cref="BaseFieldSpawner.TargetCount"/> so the gather -> build -> survive loop lives AT the base (no
/// expedition trip). Unlike <see cref="ExpeditionFieldSystem"/> (edge-triggered on player presence) this is a
/// TICK-CADENCED top-up: every <see cref="BaseFieldSpawner.RespawnIntervalTicks"/> it counts live base nodes
/// and instantiates (TargetCount - liveCount) more, scattered UNIFORMLY-IN-RADIUS (rad = inner + r*(outer-inner),
/// NOT the area-weighted sqrt that piles nodes on the outer wall) in the [Inner,Outer] annulus around
/// BaseGridMath.PlotCenter, each overridden via SetComponent (NOT Add — the prefab already bakes
/// RegionTag{Expedition}) to RegionTag{Base} + ResourceId.Ore. The FIRST pass fires immediately
/// (NextSpawnTick seeded 0) so the field seeds without waiting. Deterministic: the scatter RNG is seeded from
/// a monotonic Epoch (never the tick); the cadence gate is wrap-safe NetworkTick math (TickUtil.NonZero +
/// IsNewerThan), never raw uint. Runtime-spawned ghosts dodge the prespawn handshake. Plain server
/// SimulationSystemGroup; server-only, never predicted.
/// </summary>
[BurstCompile]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct BaseFieldSpawnSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<NetworkTime>();
state.RequireForUpdate<BaseFieldSpawner>();
state.RequireForUpdate<BaseAnchor>();
}
[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var serverTick = SystemAPI.GetSingleton<NetworkTime>().ServerTick;
if (!serverTick.IsValid)
return;
uint now = serverTick.TickIndexForValidTick;
var spawnerEntity = SystemAPI.GetSingletonEntity<BaseFieldSpawner>();
var spawner = SystemAPI.GetComponent<BaseFieldSpawner>(spawnerEntity);
var runtime = SystemAPI.GetComponent<BaseFieldRuntime>(spawnerEntity);
if (spawner.Prefab == Entity.Null)
return;
// Cadence gate: first pass (NextSpawnTick == 0) fires immediately; thereafter every RespawnIntervalTicks.
if (runtime.NextSpawnTick != 0u && new NetworkTick(runtime.NextSpawnTick).IsNewerThan(serverTick))
return;
// Count LIVE base-region nodes only (expedition nodes share the ResourceNode type; exclude by region).
int liveBase = 0;
foreach (var region in SystemAPI.Query<RefRO<RegionTag>>().WithAll<ResourceNode>())
if (region.ValueRO.Region == RegionId.Base)
liveBase++;
int deficit = spawner.TargetCount - liveBase;
if (deficit > 0)
{
var anchor = SystemAPI.GetSingleton<BaseAnchor>();
float3 center = BaseGridMath.PlotCenter(anchor);
var baseXform = SystemAPI.GetComponent<LocalTransform>(spawner.Prefab);
var prefabNode = SystemAPI.GetComponent<ResourceNode>(spawner.Prefab);
runtime.Epoch += 1;
var rng = new Random(((uint)runtime.Epoch * 747796405u) | 1u); // epoch-seeded (never the tick), nonzero
float inner = math.max(0f, spawner.InnerRadius);
float outer = math.max(inner + 0.01f, spawner.OuterRadius);
var ecb = new EntityCommandBuffer(Allocator.Temp);
for (int i = 0; i < deficit; i++)
{
var node = ecb.Instantiate(spawner.Prefab);
float ang = rng.NextFloat(0f, math.PI * 2f);
float rad = inner + rng.NextFloat(0f, 1f) * (outer - inner); // UNIFORM in radius
var xform = baseXform;
xform.Position = center + new float3(math.cos(ang) * rad, 0f, math.sin(ang) * rad);
ecb.SetComponent(node, xform);
// Override the baked RegionTag{Expedition} -> Base (else RegionRelevancy hides it from base
// players) and force the resource to Ore (the build currency; base field stays Ore-only).
ecb.SetComponent(node, new RegionTag { Region = RegionId.Base });
var rn = prefabNode;
rn.ResourceId = ResourceId.Ore;
ecb.SetComponent(node, rn);
}
ecb.Playback(state.EntityManager);
ecb.Dispose();
}
runtime.NextSpawnTick = TickUtil.NonZero(now + (uint)math.max(1, spawner.RespawnIntervalTicks));
SystemAPI.SetComponent(spawnerEntity, runtime);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 346e9c0fb92e7b94fa3761222fc2ff1e
@@ -115,8 +115,11 @@ namespace ProjectM.Server
// DESTROY: the last player left the expedition — clear the whole field (nodes + clutter).
if (wasOccupied && !occupied)
{
foreach (var (rn, e) in SystemAPI.Query<RefRO<ResourceNode>>().WithEntityAccess())
ecb.DestroyEntity(e);
// Only EXPEDITION nodes — the base field is permanent RegionTag{Base} and must NOT be torn down here.
foreach (var (rn, region, e) in
SystemAPI.Query<RefRO<ResourceNode>, RefRO<RegionTag>>().WithEntityAccess())
if (region.ValueRO.Region == RegionId.Expedition)
ecb.DestroyEntity(e);
foreach (var (bc, e) in SystemAPI.Query<RefRO<BlightClutter>>().WithEntityAccess())
ecb.DestroyEntity(e);
}
@@ -35,6 +35,7 @@ namespace ProjectM.Server
ComponentLookup<GhostOwner> m_GhostOwnerLookup;
BufferLookup<InventorySlot> m_InvLookup;
ComponentLookup<RegionTag> m_RegionLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
@@ -43,6 +44,7 @@ namespace ProjectM.Server
state.RequireForUpdate<ResourceLedger>();
m_GhostOwnerLookup = state.GetComponentLookup<GhostOwner>(true);
m_InvLookup = state.GetBufferLookup<InventorySlot>(false);
m_RegionLookup = state.GetComponentLookup<RegionTag>(true);
}
[BurstCompile]
@@ -56,6 +58,7 @@ namespace ProjectM.Server
// hoisted out of the per-hit sweep (invariant for the tick).
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);
@@ -72,6 +75,7 @@ namespace ProjectM.Server
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())
@@ -84,6 +88,7 @@ namespace ProjectM.Server
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
@@ -97,6 +102,7 @@ namespace ProjectM.Server
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);
@@ -144,7 +150,10 @@ namespace ProjectM.Server
// 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.
int remainder = amount;
if (m_GhostOwnerLookup.HasComponent(projEntity)
// Base-region nodes credit the SHARED ledger DIRECTLY (the build currency pool); expedition / un-tagged
// nodes keep the personal-inventory reroute (spill-to-ledger). Untagged -> not Base -> inventory path.
if (!tgtToLedger[bestIdx]
&& m_GhostOwnerLookup.HasComponent(projEntity)
&& playerByConn.TryGetValue(m_GhostOwnerLookup[projEntity].NetworkId, out var player)
&& m_InvLookup.HasBuffer(player))
{
@@ -208,6 +217,7 @@ namespace ProjectM.Server
tgtYieldPerHit.Dispose();
tgtVariant.Dispose();
tgtIsClutter.Dispose();
tgtToLedger.Dispose();
}
}
}