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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user