From e1ed08a8032e45f9010f6fbb56f06386d4500cb6 Mon Sep 17 00:00:00 2001 From: Luis Gonzalez Date: Thu, 11 Jun 2026 14:59:51 -0700 Subject: [PATCH] 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) --- .../Economy/BaseFieldSpawnerAuthoring.cs | 51 ++++++++ .../Economy/BaseFieldSpawnerAuthoring.cs.meta | 2 + .../Authoring/World/CycleDirectorAuthoring.cs | 13 ++ .../Scripts/Client/Presentation/HudSystem.cs | 9 +- .../Server/Economy/BaseFieldSpawnSystem.cs | 101 ++++++++++++++ .../Economy/BaseFieldSpawnSystem.cs.meta | 2 + .../Server/Economy/ExpeditionFieldSystem.cs | 7 +- .../Server/Economy/ResourceHarvestSystem.cs | 12 +- .../Server/World/ThreatDirectorSystem.cs | 21 +++ .../Simulation/Economy/BaseFieldSpawner.cs | 46 +++++++ .../Economy/BaseFieldSpawner.cs.meta | 2 + .../Simulation/Player/MeleeComboSystem.cs | 123 ++++++++++++++++++ .../Simulation/World/ThreatComponents.cs | 3 + 13 files changed, 386 insertions(+), 6 deletions(-) create mode 100644 Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs create mode 100644 Assets/_Project/Scripts/Authoring/Economy/BaseFieldSpawnerAuthoring.cs.meta create mode 100644 Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs create mode 100644 Assets/_Project/Scripts/Server/Economy/BaseFieldSpawnSystem.cs.meta create mode 100644 Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs create mode 100644 Assets/_Project/Scripts/Simulation/Economy/BaseFieldSpawner.cs.meta 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).