Docs: base-mining cohesive-loop session log + DR-031; CLAUDE.md base-local loop

Session log + DR-031 (base-local mining, any-attack harvest, scheduled base sieges, Synty asset swap) capturing the diagnosis, locked operator forks, both adversarial reviews, and the tuning knobs. CLAUDE.md: base-local loop is now the model (BaseFieldSpawnSystem + harvest region-routing + ThreatDirector Schedule source); net-neutral condensation of M7/biome/HUD reference bullets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 15:00:30 -07:00
parent 11ff043d6a
commit 35d33f12c1
3 changed files with 141 additions and 6 deletions
+6 -6
View File
@@ -88,10 +88,10 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
- **Build-grid math must be deterministic + integer-stable:** corner-origin, center-returning, **half-open** cell bounds, `math.floor` (not truncation — negatives). Lock `CellSize`/`PlotSize` as a coordinate space once (`BaseGridMath`, EditMode-tested) — changing them invalidates placed structures.
- **`PlacedStructure{[GhostField] byte Type; int2 Cell (server-only); uint NextTick; uint LastProcessedTick}`** on an ownerless interpolated ghost. **Bake the two tick fields** (turret reuses `NextTick` as fire cooldown; they're the offline-catch-up linchpin). Only `Type` replicates (client derives `Cell` via `BaseGridMath.WorldToCell`). Data-driven `StructureCatalog` buffer. **Occupancy is DERIVED** by scanning live structure ghosts into a Temp `NativeHashSet<int2>`, never a mutable buffer on the baked `BaseAnchor`. See [[DR-014_M6_Build_Structures_Automation_Foundation]].
- **Co-op placement atomicity:** commit the `StorageMath.Withdraw` + cell-reservation **in-place inside the RPC foreach** (only `Instantiate` goes through the ECB) so two same-tick requests for one cell can't both pass.
- **Buildable turret = hitscan = reversed `EnemyAISystem`:** nearest living Husk in-region within Range, on `NextTick` cooldown append a direct `DamageEvent{Damage, SourceNetworkId=-1}` → reuses `HealthApplyDamageSystem`. No projectile → no tunnelling, no team model.
- **Buildable turret = hitscan:** nearest living Husk in-region within Range, on `NextTick` cooldown appends a direct `DamageEvent{SourceNetworkId=-1}` → reuses `HealthApplyDamageSystem`. No projectile → no tunnelling.
- **Resource-gated ability tiers / buffs reuse `StatModifier`** (replace/clear-by-SourceId → bounded buffer; `StatRecomputeSystem` folds it into `EffectiveAbilityStats` both worlds). `GoalProgress{[GhostField] int Charge, Target}` rides the global CycleDirector ghost.
- **M7 Automation (server-only, never predicted) ★:** `Harvester`/`Conveyor`/`Fabricator` are buildable machines on the `PlacedStructure` ghost storing `PeriodTicks` + **server-only** `MachineInput`/`MachineOutput` buffers (NOT `[GhostField]`). Production runs in the plain server group `[UpdateAfter(PredictedSimulationSystemGroup)]` (Harvester→Conveyor→Fabricator), replicating only via the global ledger + `PlacedStructure`. Deterministic catch-up via `ProductionMath.CyclesDue` (**lower-bound 0, never 1**; period-0 guarded). `ConveyorMath` order-independent (`CellKey` stable-sort); `RuntimePlacedTag` = player-built. See [[DR-020_M7_Automation_Production_Chains]].
- **Per-player inventory + equipment + items ★:** harvest → the firing player's PERSONAL `InventorySlot` (`[GhostField]` `OwnerSendType.All`; owner via OPTIONAL `ComponentLookup<GhostOwner>` in `ResourceHarvestSystem`, remainder/un-owned → ledger); deposit personal→ledger via the `G`-key `InventoryDepositRequest` RPC. Items = an `ItemDatabase` blob (`AbilityDatabase` twin; `ushort ItemId` subsumes `ResourceId`; ID-keyed; item mods **INLINE on `ItemDefBlob` (Mod0..3), NOT a nested BlobArray** — by-value `TryGetItem` reads a nested one empty). Equip = `EquipmentSlot{[GhostField] ushort ItemId}` (index=slot) + server-only event-driven `EquipSystem`: weapon→`AbilityRef.Id` (swaps prefab+base stats), gear→`StatModifier`s tagged `Tuning.EquipSourceIdBase+slot`, stripped via target-agnostic `RemoveBySourceId`; atomic swap. Session-only. See [[DR-026_Inventory_Equipment_Progression_Foundation]] · [[DR-027_Equipment_Slots_Phase1]].
- **M7 Automation (server-only; TRIMMED from the live build palette, code intact, not in the base loop) ★:** `Harvester`/`Conveyor`/`Fabricator` on `PlacedStructure`; server-only `MachineInput`/`MachineOutput` (NOT `[GhostField]`); Harvester→Conveyor→Fabricator plain server group; catch-up `ProductionMath.CyclesDue` (**lower-bound 0**, period-0 guarded); `RuntimePlacedTag` = player-built. See [[DR-020_M7_Automation_Production_Chains]] · [[DR-031_Base_Mining_Loop_Cohesion]].
- **Per-player inventory + equipment + items ★:** harvest routes by node region (DR-031): **BASE node → shared `ResourceLedger` directly** (build currency; no `G`-friction), **Expedition/un-tagged → firing player's PERSONAL `InventorySlot`** (`[GhostField]` `OwnerSendType.All`, spill→ledger) — for BOTH the projectile (`ResourceHarvestSystem`) and melee (`MeleeComboSystem` server-only block, `Remaining` write-back for VFX); region via OPTIONAL `ComponentLookup<RegionTag>` (not a query column → no fixture-drop). `G`-key `InventoryDepositRequest` RPC deposits personal→ledger. Items = `ItemDatabase` blob (`ushort ItemId` subsumes `ResourceId`; ID-keyed; mods **INLINE on `ItemDefBlob` Mod0..3, NOT nested** — by-value `TryGetItem` reads nested empty). Equip via `EquipmentSlot` + event-driven `EquipSystem` (weapon→`AbilityRef.Id`; gear→`StatModifier`s by slot-`SourceId`, `RemoveBySourceId` strip; atomic). Session-only. See [[DR-026_Inventory_Equipment_Progression_Foundation]] · [[DR-027_Equipment_Slots_Phase1]].
- **Disk persistence (`SaveData`, single-slot atomic JSON, versioned/additive) ★:** **born-correct load** (`CycleDirectorSpawnSystem` stages `PendingSave` AT SPAWN); host-only autosave; `BaseRestoreSystem` replays structures charge-free with REMAINING-tick cooldowns. See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
### Presentation / juice / VFX
@@ -99,11 +99,11 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
- **Asset-free presentation:** procedural `AudioClip.Create` SFX; runtime `ParticleSystem` pool (Sprites/Default + HDR start color); code-built **UI Toolkit** HUD/menus. Edit a prefab asset's component in code via `PrefabUtility.LoadPrefabContents` → modify → **`SaveAsPrefabAsset(root, path)`** → `UnloadPrefabContents`. Watch **shared-material bleed** when re-tinting. ACES tonemapping needs URP color grading mode = HDR (`m_ColorGradingMode=1`).
- **Prototype glue lives in `ProjectM.Client` as MonoBehaviours:** `PrototypeCameraRig` (player-following ARPG cam), `VFXConfig` (static `Instance` + prefab fields bridging authored VFX to `CombatFeedbackSystem`; keep a procedural fallback). A **static presentation bridge must reset on play-enter** via `[RuntimeInitializeOnLoadMethod(SubsystemRegistration)]` (statics survive fast-enter-playmode reloads → stale flash).
- **UITK HUD + menus ★:** `MenuUi` owns the shared palette + factories + `PanelSettings`/`EventSystem` plumbing + `Round`/`Border` helpers; `HudUi`/`HudSystem` extend it. `HudSystem` = a `PresentationSystemGroup` observe-only `SystemBase` owning a runtime `UIDocument` (`sortingOrder 50`); builds the tree on the first frame `rootVisualElement != null`, root `pickingMode = Ignore` (only palette buttons opt back in). **Runtime UITK needs a `PanelSettings` WITH a `themeStyleSheet` AND an `EventSystem` + `InputSystemUIInputModule`** or buttons are silently dead. The **build palette** (lazy from the client `StructureCatalog`) drives click-to-place: green/red `BuildPreviewMath` ground-ghost, left-click → `BuildPlaceRequest` RPC, right-click/Esc cancel, `[`/`]`/R rotate, `Fire` suppressed. See [[DR-021_HUD_UITK_BuildPalette]].
- **Synty HUD skin via a build-safe `HudTheme` ★ (DR-024):** Synty sprites/fonts under `Assets/Synty/…` a runtime name-string `Resources.Load` is **build-stripped**; use a curated `HudTheme : ScriptableObject` (`Assets/_Project/Resources/HudTheme.asset`) of **serialized** refs, loaded null-safe via `HudTheme.Get()` (consumers fall back to flat on null). `unityBackgroundImageTintColor` MULTIPLIES (tint white skins). Don't set `unitySlice*` on 9-slice frame/bar sprites (per-element ERROR; DO for border-0). Some Synty sprites import as **Multiple**`LoadAssetAtPath<Sprite>` null; verify. See [[DR-024_HUD_Synty_Skin_Theme]].
- **Synty HUD skin via a build-safe `HudTheme` ★ (DR-024):** a runtime name-string `Resources.Load` of Synty sprites is **build-stripped** use a curated `HudTheme : ScriptableObject` of serialized refs (`HudTheme.Get()` null-safe, flat fallback). `unityBackgroundImageTintColor` MULTIPLIES; don't set `unitySlice*` on 9-slice sprites (per-element ERROR); Synty sprites may import as **Multiple**`LoadAssetAtPath<Sprite>` null. See [[DR-024_HUD_Synty_Skin_Theme]].
### Art import (HDRP store packs → URP)
- BefourStudios art is **HDRP-authored** → magenta under URP 17.4 + Entities Graphics. **Convert, don't switch pipelines** (HDRP breaks EG): re-author to stock URP/Lit via `EnvArtTools.cs` (menu `ProjectM/Art/1. Convert Curated Env Materials`). Synty art is **URP-native — no conversion**.
- **World = cosmetic Synty nature biomes ★ (DR-025):** `Game.unity` roots `BaseBiome`(Meadow_Forest)@origin + `ExpeditionBiome`(Arid_Desert)@+1000 — classic-URP cosmetics, ground = stock URP/Lit (NOT prop-atlas `S_General` mats). Global `Skybox/Procedural`; per-region fog/ambient cross-fade via client `WorldAtmosphereSystem` (camera X>500). **PNB fog/cloud-ring PREFABS = white torus — don't place.** See [[DR-025_World_Environment_Redo_Natural_Frontier]].
- **World = cosmetic Synty nature biomes ★ (DR-025):** `Game.unity` roots `BaseBiome`(Meadow_Forest)@origin + `ExpeditionBiome`(Arid_Desert)@+1000; ground = stock URP/Lit (NOT prop-atlas `S_General`). Per-region fog/ambient cross-fade via client `WorldAtmosphereSystem` (camera X>500). **PNB fog/cloud-ring prefabs = white torus — don't place.** See [[DR-025_World_Environment_Redo_Natural_Frontier]].
- **A dark-lit screenshot MASKS material bugs — verify material *values*.** `shader.GetPropertyType(idx)`-guard before `GetColor`/`GetFloat`/`GetTexture` (`S_General`'s `_BaseColorMultiply` is a float → `GetColor` returns black). Gate emission on the `_Emissive` flag + a fixture name; keep converted env metallic low (0.10.2).
- **`VolumeProfile.Add<T>()` does NOT persist** (serializes `{fileID:0}`) — use `AssetDatabase.AddObjectToAsset(comp, profile)` + `SaveAssets`, verify on disk.
- **A reverted engine/URP upgrade can stamp `URPGlobalSettings.asset` `m_AssetVersion` AHEAD of the package's `k_LastVersion`** (11>10, from the reverted 6.6 alpha); URP migrates forward-only so `URPPreprocessBuild` rejects it (*"not at last version"*) — **blocks player builds, not editor Play**. Fix: reflection-set `m_AssetVersion` back to `k_LastVersion` + `SaveAssets`.
@@ -140,7 +140,7 @@ Full rationale: [[DR-022_Animation_Pipeline_Rukhanka_Synty]] · [[DR-023_Enemy_A
- `ProjectM.Simulation.GameBootstrap : ClientServerBootstrap` overrides `Initialize` with `AutoConnectPort = 0` (M4 — listen/connect is explicit via the `ConnectionConfig` singleton + per-world ConnectionControlSystems). **Editor default = instant-into-game + MPPM** (creates `ServerWorld` (`WorldFlags.GameServer`) + `ClientWorld` (`WorldFlags.GameClient`)); the `ProjectM/Boot Into Menu (Editor)` EditorPref flips the MAIN editor to the frontend path. **Player builds boot the UITK frontend menu** (`return false` → one menu world, no netcode worlds until a menu choice). See [[DR-019_Frontend_Menu_Settings_Saves_Build]].
- **Scenes:** `Assets/Scenes/MainMenu.unity` (build index 0) boots the UITK frontend (menu world only); `Assets/Scenes/Game.unity` (index 1) holds gameplay with `Assets/_Project/Subscenes/Gameplay.unity` wired in as the baked subscene (GameObject `GameplaySubScene`). `SampleScene`/`DevSandbox` are kept as reference/dev scenes. The on-demand lifecycle (`WorldLauncher`/`SessionRunner`/`MainMenuController`) creates the right worlds per menu choice (Single/Host/Join), THEN `LoadScene(Game)` (subscene-streaming rule above).
- **Region split:** one server world; the expedition lives at `base + (1000,0,0)`, hidden per-connection via `GhostRelevancy` (Netcode gotchas). Place = cosmetic ground/pillars at the +1000 offset; nodes/gates are baked subscene entities. See [[DR-013_M6_Aether_Cycle_Region_Split]].
- **Core loop is base-local ★ (DR-031):** `BaseFieldSpawnSystem` (server) tops up `RegionTag{Base}` Ore nodes around `BaseGridMath.PlotCenter` (DISTINCT `BaseFieldSpawner` singleton; `SetComponent`-override Region+Ore — Add throws; Ore-only). Scheduled base sieges via `ThreatDirectorSystem`'s reserved **Schedule** source (`ScheduleEnabled`/`Interval`/`SizePerWave` on `CycleDirectorAuthoring`) need NO expedition trip; `ExpeditionFieldSystem` teardown region-filtered Expedition-only (else it wipes the base field). The now-dormant expedition still lives at `base+(1000,0,0)`, hidden per-connection via `GhostRelevancy`. See [[DR-031_Base_Mining_Loop_Cohesion]] · [[DR-013_M6_Aether_Cycle_Region_Split]].
## DOTS / ECS conventions (authoritative summary)
@@ -0,0 +1,84 @@
---
date: 2026-06-11
type: session
tags:
- session
- economy
- combat
- harvesting
- netcode
- assets
- loop-cohesion
permalink: gamevault/07-sessions/2026/2026-06-11-base-mining-cohesive-loop
---
# Base-local mining — consolidate combat + economy into one cohesive playable loop
> Operator: "A lot of work has been done on combat & some on inventory & equipment; currently you cannot mine any resources with the base. Consolidate all the changes so it's a cohesive playable loop. Also get rid of any placeholder assets for ores/turrets/anything else — you have plenty of real SM to choose from." Full [[dots-dev]] Feature track under **ultracode** (pre-code adversarial design review + post-code adversarial review, both workflowed). Locked → [[DR-031_Base_Mining_Loop_Cohesion]].
## Diagnosis — why you couldn't mine at the base
The combat half and the economy half were **structurally divorced**, and the economy half was effectively unreachable:
- **Resource nodes only spawned in the Expedition region** (`RegionMath.ExpeditionOffsetX = 1000`u) and only while a player physically stood out there (`ExpeditionFieldSystem` keys off per-player presence). **Nothing was harvestable at the home base.**
- The **only** path to the Expedition is an unsignposted walk-in `ExpeditionGate` — and **nothing in client code sends `RegionTransitRequest`** (no button, no prompt).
- Harvest was **projectile-only**, and MC-4 ([[DR-030_MC4_Combo_Melee_Primary_Verb]]) demoted ranged to the *secondary* button.
- **The hidden loop-breaker:** the ONLY wired siege source was post-expedition retaliation (`ThreatDirectorSystem` arms only on `ThreatState.PendingReturns`, set only by `ExpeditionGateSystem` on a base return). In a base-only loop nobody ever returns → **no siege ever arms → zero waves → all the dash/charger/melee combat work never triggers.**
- Everything was rendered as **placeholder cubes**.
## Locked forks (operator)
1. **Mine at the base** (not the expedition round-trip). 2. **Any attack harvests** (melee cleave AND ranged). 3. **Natural/stone-frontier art**.
## What shipped
### A — Base-local mining (server-only, reuses the node ghost; NO new replicated state)
- New `BaseFieldSpawner`/`BaseFieldRuntime` components + `BaseFieldSpawnerAuthoring` (one in the Gameplay subscene, prefab = the existing `ResourceNode.prefab`).
- New `BaseFieldSpawnSystem` (plain server group): tops the live `RegionTag{Base}` node count up to `TargetCount` (10) on a `NetworkTick` cadence; scatters **uniform-in-radius** in the annulus `[InnerRadius, OuterRadius]` (23.527) around `BaseGridMath.PlotCenter`; overrides each instance via **`SetComponent` (not Add)** to `RegionTag{Base}` + `ResourceId.Ore` (Ore-only — the build currency, no round-robin). First pass fires immediately; RNG seeded from a monotonic `Epoch` (never the tick). Runtime-spawned → dodges the prespawn handshake.
### B — Any-attack harvest
- `MeleeComboSystem`'s **server-only** cleave block now also gathers `ResourceNode`/`BlightClutter` in the cone, deposits, writes `Remaining` back (so `WorldFeedbackSystem` chips fire on melee mining), destroys at-most-once. **Region routing mirrors the projectile path:** Base node → shared `ResourceLedger` directly; Expedition/un-tagged → the swinging player's PERSONAL `InventorySlot` (via an `OwnerId`→player map), spill-to-ledger. Only deplete if the yield actually landed (no zero-credit consume).
- `ResourceHarvestSystem` (projectile) routes Base-region nodes straight to the ledger via an **optional** `ComponentLookup<RegionTag>` (NOT a required query column — that would drop the un-tagged test fixtures + Expedition clutter); default un-tagged → owner-inventory path (preserves `InventoryHarvestTests`).
- **Why base→ledger:** the build economy spends EXCLUSIVELY from the ledger (`BuildPlaceSystem`/`BuildSendSystem`/HUD all read it). Routing base harvest there makes "mine → build" work with zero `G`-deposit friction; the expedition keeps the DR-026 personal-haul.
### F — Base siege cadence (the loop-closer)
- Activated the reserved **Schedule** source in `ThreatDirectorSystem`: arms a `SizeBase + ScheduleSizePerWave*WaveNumber` siege every `ScheduleIntervalTicks` with NO expedition trip; defers `NextScheduledTick` while a siege runs (guaranteed calm/build window even on a long siege). Composes with the post-expedition source (guarded by `Phase==Calm && PendingSiegeSize==0`). New `ThreatConfig.ScheduleSizePerWave`; `CycleDirectorAuthoring` bakes `ScheduleEnabled=true`, `Interval=2700` (45s), `PerWave=1`.
### Blocker fix — ExpeditionFieldSystem teardown
- Its occupied→empty teardown destroyed EVERY `ResourceNode` unfiltered → would wipe the new permanent base field the first time the expedition emptied. Region-filtered the node destroy to `Expedition`-only (clutter stays unfiltered — there is no base clutter).
### C — Real art (swap mesh+material; ghost/authoring/LinkedEntityGroup untouched)
Mechanism: `PrefabUtility.LoadPrefabContents` → strip the root placeholder `MeshFilter`+`MeshRenderer`, reset root `LocalScale` to 1 (Scale is a `[GhostField]` propagated by spawners/BuildPlaceSystem — cosmetic scale goes on the nested child), **nest the Synty model as a "Model" child**, strip its colliders, assign the pack atlas to bare FBXs (Synty FBX import gives a blank "Lit" material — the pre-made *prefabs* carry the atlas) → `SaveAsPrefabAsset`. Uniform nest works for single- and multi-mesh (the Ballista is 7 GameObjects → a root mesh-swap would render 1/7).
| Prefab | → Synty (PolygonFantasyKingdom) |
|---|---|
| ResourceNode | `SM_Prop_Crystal_01` (crystal, +atlas) |
| Turret | `SiegeEngines/SM_Wep_Ballista_Mounted_01` |
| Wall | `SM_Prop_Spike_Wall_01` (palisade) |
| Pylon | `SM_Prop_Crystal_01` (tall beacon) |
| Storage | `Furniture/SM_Prop_Chest_01` |
| BlightClutter | `Environments/SM_Env_Rock_Chunk_01` |
| UpgradePickup | `SM_Prop_Crystal_01` (gem, floating) |
Automation machines (Harvester/Fabricator/Conveyor) **trimmed from the build palette** (catalog refs nulled on the subscene `StructureCatalogAuthoring`) — code stays, no placeholder cube ever shown. Palette is now `[Turret, Wall, Pylon]`.
### HUD
- Replaced the stale "deploy through the gate" copy with phase-aware loop guidance: Calm → "MINE THE CRYSTALS — any attack harvests Ore, then BUILD"; Siege → "DEFEND THE BASE — hold the line".
## Adversarial reviews (both workflowed under ultracode)
- **Pre-code** (6 lenses → synthesis): caught all 3 blockers BEFORE coding — the ExpeditionFieldSystem teardown wipe, the missing base siege source, and base-harvest-must-credit-the-ledger — plus the optional-ComponentLookup trap, the SetComponent-not-Add override, the measured annulus radii, the nest-not-root-swap for the 7-part Ballista. **This is why the slice landed clean.**
- **Post-code** (3 lenses → adversarial verify → synthesis): found 2 real defects I'd introduced when I simplified the melee path: (1) MAJOR — melee deposited EXPEDITION yields into the shared base ledger (no region routing); (2) MINOR — a node was consumed even with no ledger (silent loss). **Both fixed** + two new regression tests.
## Validation
- **EditMode: 302/302** (294 prior + 8 new: BaseFieldSpawn ×3, ExpeditionTeardown ×1, Schedule ×2, MeleeHarvest base/expedition ×2). Zero console errors at every compile.
- **Play (live netcode, focused editor):** server world has **10 Ore crystal nodes**, `RegionTag.Base`, radii 23.726.1; `ThreatConfig.ScheduleEnabled=1`; catalog `[Turret,Wall,Pylon]`; crystals render textured (cyan, not magenta/white) in a reachable ring; schedule sieges fire (loop cycles calm↔siege); **0 console errors/exceptions**.
- **Operator hands-on feel test is OPEN:** walk to a crystal → swing → watch Ore climb → build a ballista → survive a wave.
## Tuning knobs surfaced (sensible defaults shipped)
- **Siege cadence** `ScheduleIntervalTicks=2700` (45s). Bump to 36005400 for 6090s calm/build windows if waves feel too frequent.
- **Ore-ring placement** sits at radius ~2426 (just inside the boundary) because the 32×32 build plot fills the centre and the boundary is ~28.7 — only that thin annulus is conflict-free. If it feels edge-stuck: shrink the plot, widen the boundary ring, or allow some nodes on the grass plot (placeable-on; occupancy only scans `PlacedStructure`).
- **Siege source** chose timed **Schedule**; **Heat** (`HeatPerHarvest` — mining draws the attack) is the reserved alternative that braids the halves harder.
## Files
- New: `Simulation/Economy/BaseFieldSpawner.cs`, `Authoring/Economy/BaseFieldSpawnerAuthoring.cs`, `Server/Economy/BaseFieldSpawnSystem.cs`; tests `BaseFieldSpawnSystemTests.cs`, `ExpeditionFieldTeardownTests.cs`.
- Modified: `Simulation/Player/MeleeComboSystem.cs` (server-only node harvest + region routing), `Server/Economy/ResourceHarvestSystem.cs` (region routing), `Server/World/ThreatDirectorSystem.cs` (schedule source), `Simulation/World/ThreatComponents.cs` (`ScheduleSizePerWave`), `Authoring/World/CycleDirectorAuthoring.cs` (schedule bake), `Server/Economy/ExpeditionFieldSystem.cs` (teardown region-filter), `Client/Presentation/HudSystem.cs` (loop copy); tests `ThreatDirectorSystemTests.cs`, `MeleeComboTests.cs`.
- Assets: 7 prefabs re-meshed to Synty; `Subscenes/Gameplay.unity` (BaseFieldSpawner placed, catalog trimmed); `Prefabs/CycleDirector.prefab` (schedule baked).
## Next-session intent
Operator runs the hands-on feel test (mine → build → survive). Tune the siege cadence + ore-ring to taste. If the loop feels good, the obvious next braids are **EB-1 (machines can die)** / **EB-2 (felt spend — turret ammo from harvest)** which give the mined Ore weight, or wiring `PolygonParticleFX` into the harvest/death VFX. The expedition path remains dormant-but-functional (gather code intact); reviving it would want a signposted gate + a `RegionTransitRequest` sender.
@@ -0,0 +1,51 @@
---
id: DR-031
title: Base-local mining — mine at the base, any attack harvests, scheduled base sieges close the loop
status: accepted
date: 2026-06-11
tags:
- decision
- design
- economy
- combat
- harvesting
- netcode
- loop-cohesion
permalink: gamevault/07-sessions/decisions/dr-031-base-mining-loop-cohesion
---
# DR-031 — Base-local mining + any-attack harvest + scheduled base sieges
## Context
Session [[2026-06-11_Base_Mining_Cohesive_Loop]] · economy foundation [[DR-026_Inventory_Equipment_Progression_Foundation]] · region split [[DR-013_M6_Aether_Cycle_Region_Split]] · melee verb [[DR-030_MC4_Combo_Melee_Primary_Verb]] · world art [[DR-025_World_Environment_Redo_Natural_Frontier]].
Operator: combat got a lot of work, inventory/equipment got some, **but you couldn't mine resources at the base** — consolidate it into a cohesive playable loop and replace placeholder cube assets. Diagnosis: the loop's two halves were divorced. Resource nodes only spawned in the far Expedition region (`RegionMath.ExpeditionOffsetX = 1000`u), only while a player physically stood there, reachable solely by an **unsignposted** walk-in gate (nothing sends `RegionTransitRequest`), harvested by the MC-4-demoted ranged weapon. And the **only** wired siege source was post-expedition retaliation — so a base-only player draws **zero waves**, and all the dash/charger/melee combat never fires. Ran the standing **pre-code adversarial review** (caught 3 blockers) and a **post-code adversarial review** (caught 2 real defects, fixed).
## Decision
**1. Mining lives AT THE BASE** (operator fork; expedition becomes a dormant optional layer). A server-only `BaseFieldSpawnSystem` keeps `RegionTag{Base}` Ore nodes topped up to `TargetCount` in a `[Inner,Outer]` annulus around `BaseGridMath.PlotCenter`. Reuses the existing `ResourceNode` ghost (NO new replicated state) — a **distinct** `BaseFieldSpawner` singleton (reusing `ResourceFieldSpawner` would throw multiple-instances vs `ExpeditionFieldSystem`). Override the baked `RegionTag{Expedition}`→Base via **`SetComponent` not `AddComponent`** (Add throws; a node left Expedition-tagged is hidden from base players by `RegionRelevancy`). **Ore-only** — do NOT copy the expedition's Aether/Ore/Biomass round-robin (Ore is the sole build currency; scattering it breaks palette-affordability legibility). Determinism: RNG seeded from a monotonic `Epoch` (never the tick), cadence via `TickUtil.NonZero`+`NetworkTick.IsNewerThan`, first pass fires immediately, **uniform-in-radius** scatter (not area-weighted `sqrt` — that piles nodes on the outer wall).
**2. ANY attack harvests** (operator fork). Both the melee cleave and the ranged projectile deplete nodes. Melee harvest lives **strictly inside `MeleeComboSystem`'s server-only post-loop cleave block** (NEVER the predicted per-player foreach — interpolated node ghosts aren't rolled back on the client; harvesting there is a determinism/ownership violation + ledger double-credit risk). Writes `Remaining` back via `ecb.SetComponent` so the `[GhostField]` replicates and `WorldFeedbackSystem` chips fire on melee mining too.
**3. Harvest routing by node region (both melee + projectile):** **Base node → the shared `ResourceLedger` directly**; **Expedition / un-tagged → the harvesting player's PERSONAL `InventorySlot`** (spill-to-ledger). The build economy spends EXCLUSIVELY from the ledger (`BuildPlaceSystem`/`BuildSendSystem`/HUD), so routing base harvest there makes "mine → build" work with **zero `G`-deposit friction**; the expedition keeps DR-026's personal-haul. Read `RegionTag` via an **optional `ComponentLookup` (default missing → owner-inventory path)**, NOT a required query column — a required column silently drops un-tagged test fixtures + Expedition clutter (the 2026-06-08 partial-archetype class of bug). Only deplete a target if its yield actually landed somewhere (no zero-credit consume when no ledger exists).
**4. A base-resident siege source closes the loop** (the hidden blocker). Activated the **reserved Schedule source** in `ThreatDirectorSystem`: arms a `SizeBase + ScheduleSizePerWave*WaveNumber` siege every `ScheduleIntervalTicks` with NO expedition trip, deferring `NextScheduledTick` while a siege runs (a guaranteed calm/build window even on a long siege). Composes with the post-expedition source (guarded `Phase==Calm && PendingSiegeSize==0`). Chosen over the **Heat** source (mining accrues heat → arms siege) for predictability + zero harvest-coupling; Heat stays reserved-but-inert as the harder-braided alternative. Defaults: `Interval=2700` (45s), `PerWave=1`, baked on `CycleDirectorAuthoring` — all tunable.
**5. `ExpeditionFieldSystem` teardown region-filtered** to Expedition-only nodes — its old unfiltered destroy would wipe the new permanent base field the first time the expedition emptied (clutter teardown stays unfiltered: there is no base clutter).
**6. Real art by NEST, not root mesh-swap.** Swap each ghost prefab's visual: strip the root placeholder `MeshFilter`+`MeshRenderer`, **reset root `LocalScale` to 1** (Scale is a `[GhostField]` propagated by spawners — cosmetic scale goes on the nested child), nest the Synty model as a `"Model"` child, strip its colliders, assign the pack atlas to bare FBXs (Synty FBX import yields a blank "Lit" material; the pre-made *prefabs* carry the atlas). Nesting is uniform for single- and multi-mesh (the Ballista is a 7-GameObject hierarchy → a root mesh-swap renders 1/7). GhostAuthoring stays root-only (no phantom ghost children); `LinkedEntityGroupAuthoring` auto-collects the child. PolygonFantasyKingdom throughout (one atlas). Automation machines (Harvester/Fabricator/Conveyor) **trimmed from the build palette** — code stays, no placeholder cube shown.
## Consequences
- **The loop closes on one screen:** mine Ore at the base (any attack) → ledger fills → build Turret/Wall/Pylon (real ballista/palisade/crystal) → survive a scheduled wave → repeat. The combat work (dash/charger/melee) now actually triggers at the base.
- 302/302 EditMode (8 new); live netcode Play clean (10 Ore crystals in a reachable ring, schedule sieges firing, 0 errors). **Operator hands-on feel-test is OPEN.**
- **Two tuning forks left to the operator:** siege cadence (45s default) and the ore-ring radius (sits at ~2426m, against the boundary, because the 32×32 build plot fills the centre — only that thin annulus is conflict-free; widen by shrinking the plot / widening the boundary / allowing nodes on the grass).
- Brings forward gather→spend economy work the roadmap had gated behind the combat fun-gates ([[DR-028_Combat_Primary_Verb_Depth_First]]/[[combat-first-depth-before-breadth]]) — the operator's request was the reprioritization; the natural follow-on braids are EB-1 (machines can die) / EB-2 (felt spend) which give the mined Ore weight.
## Alternatives considered (rejected)
- **Fix the expedition round-trip instead** (signpost the gate, add a `RegionTransitRequest` sender) — keeps the region-split design but leaves the loop two-screen + less cohesive. Operator chose base-local.
- **Heat siege source** instead of Schedule — more teachable (mining draws the attack) but unpredictable + can be gamed (don't mine → no waves) and needs harvest→heat plumbing. Reserved.
- **Generalize `ExpeditionFieldSystem` with a region param** rather than a new `BaseFieldSpawnSystem` — rejected: the cadence-top-up model differs from the presence-edge model, and a shared singleton collides.
- **A `HarvestMath.Deposit` helper** to dedup the two harvest paths — rejected as over-abstraction (the shared core is ~3 lines; the resolve-owner/stackMax differs per caller). Inlined.