diff --git a/Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs b/Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs
new file mode 100644
index 000000000..bd4eb792d
--- /dev/null
+++ b/Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs
@@ -0,0 +1,51 @@
+using ProjectM.Simulation;
+using Unity.Entities;
+using UnityEngine;
+
+namespace ProjectM.Authoring
+{
+ ///
+ /// Authoring for the home-base mining field (). Place ONE in the gameplay
+ /// subscene. = the SAME ResourceNode ghost prefab the expedition uses; the server
+ /// system overrides each instance to RegionTag{Base} + ResourceId.Ore and scatters them in the
+ /// [, ] annulus around the base plot center. Defaults are
+ /// sized to the baked 32x32 plot (square corner reach ~22.6) inside the ~28.7 boundary ring, so nodes form a
+ /// reachable perimeter ring that never sits on a build cell.
+ ///
+ public class BaseFieldSpawnerAuthoring : MonoBehaviour
+ {
+ [Tooltip("Resource-node ghost prefab (ResourceNodeAuthoring + GhostAuthoring). Reuse the expedition node prefab.")]
+ public GameObject NodePrefab;
+
+ [Tooltip("Live base-node target; the field refills toward this each respawn pass.")]
+ [Min(1)] public int TargetCount = 10;
+
+ [Tooltip("Inner scatter radius — clears the build plot corner reach (~22.6) + spawn ring.")]
+ [Min(0f)] public float InnerRadius = 23.5f;
+
+ [Tooltip("Outer scatter radius — stays inside the walkable boundary ring (~28.7).")]
+ [Min(0f)] public float OuterRadius = 27f;
+
+ [Tooltip("Server ticks (@60) between top-up passes.")]
+ [Min(1)] public int RespawnIntervalTicks = 600;
+
+ private class BaseFieldSpawnerBaker : Baker
+ {
+ public override void Bake(BaseFieldSpawnerAuthoring authoring)
+ {
+ var entity = GetEntity(authoring, TransformUsageFlags.None);
+ AddComponent(entity, new BaseFieldSpawner
+ {
+ Prefab = authoring.NodePrefab != null
+ ? GetEntity(authoring.NodePrefab, TransformUsageFlags.Dynamic)
+ : Entity.Null,
+ TargetCount = authoring.TargetCount,
+ InnerRadius = authoring.InnerRadius,
+ OuterRadius = authoring.OuterRadius,
+ RespawnIntervalTicks = authoring.RespawnIntervalTicks,
+ });
+ AddComponent(entity, new BaseFieldRuntime { Epoch = 0, NextSpawnTick = 0u });
+ }
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs.meta b/Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs.meta
new file mode 100644
index 000000000..adfe96de1
--- /dev/null
+++ b/Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c4055a6a779d06949ae23b16334b810e
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs
index afa26350d..f720a28f2 100644
--- a/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs
+++ b/Assets/_Project/Scripts/Authoring/World/CycleDirectorAuthoring.cs
@@ -30,6 +30,16 @@ namespace ProjectM.Authoring
[Tooltip("Max server ticks a siege may run before it auto-collapses (no soft-lock). 0 = no cap.")]
public uint SiegeTimeoutTicks = 3600;
+ [Header("Threat — scheduled base sieges")]
+ [Tooltip("A timed cadence arms a base siege even without an expedition trip (keeps the base loop stakeful).")]
+ public bool ScheduleEnabled = true;
+
+ [Tooltip("Server ticks (@60) between scheduled base sieges. First fire is one interval out (mine/build grace).")]
+ public uint ScheduleIntervalTicks = 2700;
+
+ [Tooltip("Extra Husks per surviving wave (siege size = SiegeSizeBase + this * WaveNumber). 0 = flat.")]
+ public int ScheduleSizePerWave = 1;
+
private class CycleDirectorBaker : Baker
{
@@ -53,6 +63,9 @@ namespace ProjectM.Authoring
SizePerExpeditionResource = authoring.SiegeSizePerResource,
StartCondition = ThreatStartCondition.Immediate,
SiegeTimeoutTicks = authoring.SiegeTimeoutTicks,
+ ScheduleEnabled = (byte)(authoring.ScheduleEnabled ? 1 : 0),
+ ScheduleIntervalTicks = authoring.ScheduleIntervalTicks,
+ ScheduleSizePerWave = authoring.ScheduleSizePerWave,
});
}
}
diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
index 0d222f4c9..0a16c7bcc 100644
--- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
+++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs
@@ -152,9 +152,12 @@ namespace ProjectM.Client
var cam = Camera.main;
bool onExpedition = cam != null && cam.transform.position.x > ExpeditionRegionXMin;
_locationText.text = onExpedition
- ? "ON EXPEDITION - return through the gate"
- : "AT BASE - deploy through the gate when you're ready";
- _locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f);
+ ? "ON EXPEDITION - carve the frontier, then return"
+ : siege
+ ? "DEFEND THE BASE - hold the line"
+ : "MINE THE CRYSTALS - any attack harvests Ore, then BUILD";
+ _locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f)
+ : siege ? new Color(1f, 0.55f, 0.4f) : new Color(0.6f, 0.95f, 0.7f);
// ---- Goal (hex-pip meter, or a continuous bar for large targets) ----
if (SystemAPI.TryGetSingleton(out var goal))
diff --git a/Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs b/Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs
new file mode 100644
index 000000000..d5d5f775d
--- /dev/null
+++ b/Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs
@@ -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
+{
+ ///
+ /// Server-only home-base mining-field manager. Keeps the live RegionTag{Base} ResourceNode count topped up to
+ /// so the gather -> build -> survive loop lives AT the base (no
+ /// expedition trip). Unlike (edge-triggered on player presence) this is a
+ /// TICK-CADENCED top-up: every 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.
+ ///
+ [BurstCompile]
+ [WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
+ [UpdateInGroup(typeof(SimulationSystemGroup))]
+ public partial struct BaseFieldSpawnSystem : ISystem
+ {
+ [BurstCompile]
+ public void OnCreate(ref SystemState state)
+ {
+ state.RequireForUpdate();
+ state.RequireForUpdate();
+ state.RequireForUpdate();
+ }
+
+ [BurstCompile]
+ public void OnUpdate(ref SystemState state)
+ {
+ var serverTick = SystemAPI.GetSingleton().ServerTick;
+ if (!serverTick.IsValid)
+ return;
+ uint now = serverTick.TickIndexForValidTick;
+
+ var spawnerEntity = SystemAPI.GetSingletonEntity();
+ var spawner = SystemAPI.GetComponent(spawnerEntity);
+ var runtime = SystemAPI.GetComponent(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>().WithAll())
+ if (region.ValueRO.Region == RegionId.Base)
+ liveBase++;
+
+ int deficit = spawner.TargetCount - liveBase;
+ if (deficit > 0)
+ {
+ var anchor = SystemAPI.GetSingleton();
+ float3 center = BaseGridMath.PlotCenter(anchor);
+ var baseXform = SystemAPI.GetComponent(spawner.Prefab);
+ var prefabNode = SystemAPI.GetComponent(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);
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs.meta b/Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs.meta
new file mode 100644
index 000000000..7e6c28cf4
--- /dev/null
+++ b/Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 346e9c0fb92e7b94fa3761222fc2ff1e
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs b/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs
index c47e6912d..69746ae29 100644
--- a/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs
+++ b/Assets/_Project/Scripts/Server/Economy/ExpeditionFieldSystem.cs
@@ -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>().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>().WithEntityAccess())
+ if (region.ValueRO.Region == RegionId.Expedition)
+ ecb.DestroyEntity(e);
foreach (var (bc, e) in SystemAPI.Query>().WithEntityAccess())
ecb.DestroyEntity(e);
}
diff --git a/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs b/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs
index 6769ed557..899a22952 100644
--- a/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs
+++ b/Assets/_Project/Scripts/Server/Economy/ResourceHarvestSystem.cs
@@ -35,6 +35,7 @@ namespace ProjectM.Server
ComponentLookup m_GhostOwnerLookup;
BufferLookup m_InvLookup;
+ ComponentLookup m_RegionLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
@@ -43,6 +44,7 @@ namespace ProjectM.Server
state.RequireForUpdate();
m_GhostOwnerLookup = state.GetComponentLookup(true);
m_InvLookup = state.GetBufferLookup(false);
+ m_RegionLookup = state.GetComponentLookup(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(out var itemDb);
var playerByConn = new NativeHashMap(8, Allocator.Temp);
@@ -72,6 +75,7 @@ namespace ProjectM.Server
var tgtYieldPerHit = new NativeList(Allocator.Temp);
var tgtVariant = new NativeList(Allocator.Temp);
var tgtIsClutter = new NativeList(Allocator.Temp);
+ var tgtToLedger = new NativeList(Allocator.Temp);
foreach (var (xform, hr, node, e) in
SystemAPI.Query, RefRO, RefRO>().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(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();
}
}
}
diff --git a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs
index 2aa6f59e7..3cb8cc5ae 100644
--- a/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs
+++ b/Assets/_Project/Scripts/Server/World/ThreatDirectorSystem.cs
@@ -66,6 +66,27 @@ namespace ProjectM.Server
threat.PendingReturns = 0; // consume regardless so returns can't pile up
}
+ // ---- SOURCE: scheduled base sieges. A timed cadence arms a siege even with NO expedition trip, so
+ // the base-defense loop has stakes on its own. The first fire is one full interval out (a mine/build
+ // grace window); size escalates by the live wave number. All ticks wrap-safe (TickUtil.NonZero). ----
+ if (config.ScheduleEnabled != 0 && config.ScheduleIntervalTicks > 0)
+ {
+ if (threat.NextScheduledTick == 0 || cycle.Phase != CyclePhase.Calm)
+ {
+ // Seed, and DEFER while a siege runs, so the next scheduled siege is always one full interval
+ // AFTER the current one resolves -> a guaranteed calm/build window even if a siege runs long.
+ threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
+ }
+ else if (cycle.Phase == CyclePhase.Calm && threat.PendingSiegeSize == 0
+ && !new NetworkTick(threat.NextScheduledTick).IsNewerThan(serverTick))
+ {
+ int wave = SystemAPI.TryGetSingleton(out var ws) ? ws.WaveNumber : 0;
+ threat.PendingSiegeSize = math.max(1, config.SizeBase + config.ScheduleSizePerWave * wave);
+ threat.ArmTick = TickUtil.NonZero(now + config.PostExpeditionDelayTicks);
+ threat.NextScheduledTick = TickUtil.NonZero(now + config.ScheduleIntervalTicks);
+ }
+ }
+
// ---- BOUNDED RESOLUTION: a Siege can't drag forever. Record its start; after SiegeTimeoutTicks cull
// the remaining Husks + stop spawning so CyclePhaseSystem's DefendCleared returns the base to Calm. ----
if (cycle.Phase == CyclePhase.Siege)
diff --git a/Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs b/Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs
new file mode 100644
index 000000000..035bc096b
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs
@@ -0,0 +1,46 @@
+using Unity.Entities;
+
+namespace ProjectM.Simulation
+{
+ ///
+ /// Baked singleton describing the HOME-BASE mining field: a ring of harvestable resource nodes scattered
+ /// around the base so the gather -> build -> survive loop lives on ONE screen (no expedition trip required).
+ /// DISTINCT from (the expedition field) so the two never collide on a
+ /// GetSingleton. keeps the live RegionTag{Base} node count
+ /// topped up to on a tick cadence; nodes scatter UNIFORMLY-IN-RADIUS in the annulus
+ /// [, ] around BaseGridMath.PlotCenter (inner clears the
+ /// square build plot + spawn ring, outer stays inside the walkable boundary ring) and are overridden to
+ /// RegionTag{Base} + ResourceId.Ore (the sole build currency, kept legible — never the expedition's
+ /// Aether/Ore/Biomass round-robin). Place ONE authoring in the gameplay subscene.
+ ///
+ public struct BaseFieldSpawner : IComponentData
+ {
+ /// Baked resource-node ghost prefab to instantiate (reuses the expedition node prefab).
+ public Entity Prefab;
+
+ /// Desired live base-node count; the system refills toward this each respawn pass.
+ public int TargetCount;
+
+ /// Inner scatter radius (world units) from the plot center — must clear the build plot + spawn ring.
+ public float InnerRadius;
+
+ /// Outer scatter radius (world units) — must stay inside the boundary ring so nodes are reachable.
+ public float OuterRadius;
+
+ /// Server ticks between top-up passes (a depleted field refills toward TargetCount on this cadence).
+ public int RespawnIntervalTicks;
+ }
+
+ ///
+ /// Server-only runtime state for , baked beside
+ /// . NOT replicated. seeds the per-node scatter RNG
+ /// (monotonic, so a top-up never repeats a layout); gates the cadence (wrap-safe
+ /// via TickUtil.NonZero + NetworkTick.IsNewerThan, never raw uint). NextSpawnTick == 0 means "fire now" so the
+ /// first pass seeds the field without waiting for a tick that never comes.
+ ///
+ public struct BaseFieldRuntime : IComponentData
+ {
+ public int Epoch;
+ public uint NextSpawnTick;
+ }
+}
diff --git a/Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs.meta b/Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs.meta
new file mode 100644
index 000000000..34b6ade31
--- /dev/null
+++ b/Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7a18d4457f559ef49bcd3122dcdb6d82
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs
index 8c927bbf5..db76214af 100644
--- a/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs
+++ b/Assets/_Project/Scripts/Simulation/Player/MeleeComboSystem.cs
@@ -49,11 +49,15 @@ namespace ProjectM.Simulation
public partial struct MeleeComboSystem : ISystem
{
ComponentLookup m_KnockbackLookup;
+ ComponentLookup m_RegionLookup;
+ BufferLookup m_InvLookup;
[BurstCompile]
public void OnCreate(ref SystemState state)
{
m_KnockbackLookup = state.GetComponentLookup(isReadOnly: false);
+ m_RegionLookup = state.GetComponentLookup(isReadOnly: true);
+ m_InvLookup = state.GetBufferLookup(isReadOnly: false);
state.RequireForUpdate();
}
@@ -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(out var ledgerEntity);
+ m_RegionLookup.Update(ref state);
+ m_InvLookup.Update(ref state);
+ var meleePlayerByConn = new NativeHashMap(8, Allocator.Temp);
+ foreach (var (po, pe) in SystemAPI.Query>().WithAll().WithEntityAccess())
+ meleePlayerByConn[po.ValueRO.NetworkId] = pe;
+ var harvEntity = new NativeList(Allocator.Temp);
+ var harvPos = new NativeList(Allocator.Temp);
+ var harvRemaining = new NativeList(Allocator.Temp);
+ var harvYieldId = new NativeList(Allocator.Temp);
+ var harvPerHit = new NativeList(Allocator.Temp);
+ var harvIsClutter = new NativeList(Allocator.Temp);
+ var harvVariant = new NativeList(Allocator.Temp);
+ var harvToLedger = new NativeList(Allocator.Temp);
+ foreach (var (hx, node, he) in
+ SystemAPI.Query, RefRO>().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>().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(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(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)
diff --git a/Assets/_Project/Scripts/Simulation/World/ThreatComponents.cs b/Assets/_Project/Scripts/Simulation/World/ThreatComponents.cs
index e2a3c1de6..70dea5794 100644
--- a/Assets/_Project/Scripts/Simulation/World/ThreatComponents.cs
+++ b/Assets/_Project/Scripts/Simulation/World/ThreatComponents.cs
@@ -40,6 +40,9 @@ namespace ProjectM.Simulation
public float HeatThreshold;
public byte ScheduleEnabled;
public uint ScheduleIntervalTicks;
+ /// Extra Husks per surviving wave for a SCHEDULED base siege (size = SizeBase + this*WaveNumber). 0 = flat SizeBase.
+ public int ScheduleSizePerWave;
+
}
/// Start-condition constants for (bytes — never an enum, never in an RPC).