Files
kronic e1ed08a803 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>
2026-06-11 14:59:51 -07:00

102 lines
5.1 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 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);
}
}
}