Docs: EB-1 session log + DR-032; CLAUDE.md machines-can-die
Session log + DR-032 (structures reuse Health/DamageEvent, Destructible-not-PlacedStructure, fortress targeting, persistence v3, loss feedback; both adversarial reviews). CLAUDE.md: EB-1 build-gotcha bullet + persistence v3 floor-gate; net condensation of M7/inventory/UITK/juice reference bullets. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,16 +89,17 @@ Long-form originals + the milestone each came from: `Docs/Vault/_Meta/CLAUDE_Bui
|
|||||||
- **`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]].
|
- **`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.
|
- **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:** nearest living Husk in-region within Range, on `NextTick` cooldown appends a direct `DamageEvent{SourceNetworkId=-1}` → reuses `HealthApplyDamageSystem`. No projectile → no tunnelling.
|
- **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.
|
||||||
|
- **EB-1 machines can die ★ (DR-032):** structures (Turret/Wall/Pylon) bake `Health`(`[GhostField]`)+a `DamageEvent` buffer+a `Destructible` tag (NO `HitRadius`/NO `EffectiveCharacterStats` → clamp-to-0-then-die); `HealthApplyDamageSystem` destroys a `Destructible` at 0 (NOT bare `PlacedStructure` — M7 machines share it; occupancy auto-frees). `EnemyAISystem` fortress-targets the weighted-nearest of players+structures (snapshot ABOVE the early-return → an undefended base is razed; `EnemyAIMath.PickWeightedNearest`; `StructureAggroWeight` knob, <1 prefers structures, SQUARED). Loss VFX = proximity-gated `StructureFeedbackSystem` (`CombatFeedbackSystem` gated `!isStructure`). See [[DR-032_EB1_Machines_Can_Die]].
|
||||||
- **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.
|
- **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; 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]].
|
- **M7 Automation (server-only; TRIMMED from the live palette, code intact) ★:** `Harvester`/`Conveyor`/`Fabricator` on `PlacedStructure`; server-only `MachineInput`/`MachineOutput`; plain server group; catch-up `ProductionMath.CyclesDue` (**lower-bound 0**); `RuntimePlacedTag` = player-built. See [[DR-020_M7_Automation_Production_Chains]].
|
||||||
- **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]].
|
- **Per-player inventory + equipment + items ★:** harvest routes by node region (DR-031): **BASE node → shared `ResourceLedger`** (build currency, no `G`-friction), **Expedition/un-tagged → PERSONAL `InventorySlot`** (`[GhostField]` `OwnerSendType.All`, spill→ledger) — BOTH projectile (`ResourceHarvestSystem`) + melee (`MeleeComboSystem` server block, `Remaining` write-back), region via OPTIONAL `ComponentLookup<RegionTag>` (not a query column → no fixture-drop). `G`-key `InventoryDepositRequest` deposits personal→ledger. Items = `ItemDatabase` blob (`ushort ItemId` subsumes `ResourceId`; mods INLINE on `ItemDefBlob`, NOT nested — by-value `TryGetItem` reads nested empty). Equip = event-driven `EquipSystem` (weapon→`AbilityRef.Id`; gear→`StatModifier`s by slot-`SourceId`). 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]].
|
- **Disk persistence (`SaveData`, single-slot atomic JSON, versioned/additive) ★:** **born-correct load** (`CycleDirectorSpawnSystem` stages `PendingSave` AT SPAWN; `BaseRestoreSystem` replays structures charge-free, REMAINING-tick cooldowns, **EB-1 v3** per-structure HP set SAME-ECB born-correct). `SaveService.Load` = additive floor `[MinLoadableVersion=2, Current]` (old saves load; a missing field 0-defaults). See [[DR-019_Frontend_Menu_Settings_Saves_Build]] · [[DR-032_EB1_Machines_Can_Die]].
|
||||||
|
|
||||||
### Presentation / juice / VFX
|
### Presentation / juice / VFX
|
||||||
- **All juice/HUD = client-only managed `SystemBase` in `PresentationSystemGroup`** (once/frame, no rollback double-fire) that OBSERVES replicated state, never mutates the sim. Read ECS via `SystemAPI.Query` + `EntityManager.CompleteDependencyBeforeRO<T>()` — NOT a MonoBehaviour `LateUpdate` (job-safety throw). `Entity` is a stable client dict key for a ghost's lifetime — **prune the cache each frame** (a pruned enemy = a kill → death VFX); **never `DestroyEntity` a ghost from the client** (`GhostDespawnSystem` owns despawn). Hit-stop = a camera punch, **never `Time.timeScale`** (corrupts the sim).
|
- **All juice/HUD = client-only observe-only `SystemBase` in `PresentationSystemGroup`** (once/frame, no rollback double-fire), never mutates the sim. Read ECS via `SystemAPI.Query` + `EntityManager.CompleteDependencyBeforeRO<T>()` — NOT MonoBehaviour `LateUpdate` (job-safety throw). `Entity` = a stable client dict key per ghost lifetime — **prune the cache each frame** (a pruned ghost = a kill/loss → death VFX); **never `DestroyEntity` a ghost client-side** (`GhostDespawnSystem` owns despawn). Hit-stop = camera punch, **never `Time.timeScale`**.
|
||||||
- **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`).
|
- **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).
|
- **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]].
|
- **UITK HUD + menus ★:** `MenuUi` owns the palette/factories/`PanelSettings`/`EventSystem` plumbing; `HudSystem` = a `PresentationSystemGroup` observe-only `SystemBase` owning a runtime `UIDocument` (`sortingOrder 50`, root `pickingMode = Ignore`, tree built once `rootVisualElement != null`). **Runtime UITK needs `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` ghost → `BuildPlaceRequest` RPC, right-click/Esc cancel, `[`/`]`/R rotate. See [[DR-021_HUD_UITK_BuildPalette]].
|
||||||
- **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]].
|
- **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)
|
### Art import (HDRP store packs → URP)
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
date: 2026-06-11
|
||||||
|
type: session
|
||||||
|
tags:
|
||||||
|
- session
|
||||||
|
- combat
|
||||||
|
- structures
|
||||||
|
- enemy-ai
|
||||||
|
- persistence
|
||||||
|
- netcode
|
||||||
|
- eb-1
|
||||||
|
permalink: gamevault/07-sessions/2026/2026-06-11-eb1-machines-can-die
|
||||||
|
---
|
||||||
|
|
||||||
|
# EB-1 — machines can die (structures get HP, Husks raze the base, a wounded base persists)
|
||||||
|
|
||||||
|
> Operator: "Completely implement EB-1." Path A milestone; forks locked in [[DR-029_Path_A_Fork_Locks]] (Husks push for the base/structures, you defend; a wounded base persists across save/quit; structure destruction is EB-1's job — the END-1 Core bar is later). Full [[dots-dev]] Feature track under **ultracode**: mandatory pre-code adversarial review (caught 1 blocker + 8 majors) + post-code adversarial review (CLEAN). Locked → [[DR-032_EB1_Machines_Can_Die]].
|
||||||
|
|
||||||
|
## What shipped — reuse the combat spine
|
||||||
|
EB-1 reuses the exact ownerless-interpolated-ghost + server-written `Health.Current` + `DamageEvent`-buffer spine that Husks already prove in production. No parallel structure-health system.
|
||||||
|
|
||||||
|
### Structures get HP + can die
|
||||||
|
- `TurretAuthoring` (MaxHp 120) + `StructureAuthoring` (Wall/Pylon MaxHp 150) now bake `Health{Current=Max=MaxHp}` + `AddBuffer<DamageEvent>` + a new empty **`Destructible`** tag. **No `HitRadius`** (deliberate — `ProjectileDamageSystem` needs Health+HitRadius, so player shots never friendly-fire your own turret). Re-baked (Health.Current is a `[GhostField]` → hash change; structures are runtime-spawned so no prespawn-baseline trap).
|
||||||
|
- `HealthApplyDamageSystem` destroy gate += `HasComponent<Destructible>`. Structures carry **no `EffectiveCharacterStats`**, so they take the `math.max(0,..)` branch and CAN reach 0 (giving a structure stats would make it immortal — a gate comment records this). Occupancy auto-frees (`BuildPlaceSystem` derives it from live ghosts). At-most-once destroy (N Husk hits sum, one `DestroyEntity`).
|
||||||
|
- **`Destructible` tag, NOT bare `PlacedStructure`:** that identity is shared by the reserved M7 automation machines whose conveyor cargo-drop teardown is unhandled — the tag lets each destructible opt in.
|
||||||
|
|
||||||
|
### Fortress targeting (the locked aggro fork)
|
||||||
|
- `EnemyAISystem` now snapshots live structures (`PlacedStructure`+`Health`, skip HP<=0) **ABOVE** the early-return (guard → `players==0 && structures==0`) so Husks keep razing the base with every player dead/away.
|
||||||
|
- Both the Grunt and Charger passes call the shared pure `EnemyAIMath.PickWeightedNearest(from, players, structures, weight, out isStructure, out index)` — the weight is a **squared** factor on structure distance (so `<1` prefers structures, a closer player "in the way" still wins). The strike appends to the resolved `targetEntity` (player or structure, which now has the buffer).
|
||||||
|
- **`StructureAggroWeight`** is a `TuningConfig` knob (default 0.7, **value-clamp ≥0** — the default `≥1` branch would have silently floored 0.7 to 1.0 and killed the behavior), threaded through all five projections + `DebugTuningReport` + a `DebugOverlay` row (live-tunable, MC-0 pattern).
|
||||||
|
|
||||||
|
### Persistence v3 (wounded base persists)
|
||||||
|
- `StructureSave.HP` + `PendingStructure.HP`; `SaveStructureScan` reads `Health.Current` **guarded by `HasComponent<Health>`** (an automation machine without Health would crash the autosave path, which has no try/catch); `BaseRestoreSystem` adds a `ComponentLookup<Health>` and sets `Health{Current = p.HP>0 ? p.HP : baked Max, Max = baked}` in the **SAME ecb** as Instantiate (born-correct GhostField; a deferred set would leak baked Max for one snapshot). `SaveData.CurrentVersion 2→3` + `MinLoadableVersion=2`; `SaveService.Load` gate is now a **floor `[2,3]`** so OLD v2 saves still load (a missing HP field 0-defaults → the `0→Max` restore guard → full HP). `WorldLauncher` uses the extracted pure `SaveApply.ToPending(StructureSave)` — the **5th persistence site** the pre-code review caught (omitting HP there silently restores every structure at full HP, a bug no JSON round-trip test catches).
|
||||||
|
|
||||||
|
### Loss has weight (feedback)
|
||||||
|
- `CombatFeedbackSystem` suppresses structures (`bool isStructure = HasComponent<PlacedStructure>`; the hit-spark/damage-number line AND the player-death line are gated `&& !isStructure` — else a dying wall fired the HUMAN player-death cue + a cyan damage-number storm).
|
||||||
|
- New client-only `StructureFeedbackSystem` (observe-only, PresentationSystemGroup): an amber chip on an HP decrease (camera-SILENT so a siege's hits never sustain shake), a LOUD red-orange burst + camera punch on destruction. **Proximity-gated** (copy of `WorldFeedbackSystem`'s range gate) so the base→expedition RegionRelevancy despawn (every base structure drops at once) stays silent. De-duped (an HP<=0 edge sets `DeathFired` so the prune-cleanup skips it). `StructureFeelConfig` static bridge with a `[RuntimeInitializeOnLoadMethod]` reset.
|
||||||
|
|
||||||
|
## Adversarial reviews (both workflowed, ultracode)
|
||||||
|
- **Pre-code** (6 lenses → synthesis): caught the **server-crash BLOCKER** (appending a `DamageEvent` to a structure whose prefab lacks the buffer → ECB playback throws mid-wave) + 8 majors (the `Destructible`-not-`PlacedStructure` M7 trap; the early-return move; the `ClampKnob` value-vs-tick misclassification; the `SaveService` floor not blanket; the born-correct same-ECB Health restore; the 5-site HP flow incl. `WorldLauncher`; the `CombatFeedbackSystem` suppression; the `StructureFeedbackSystem` proximity gate). **All folded in before a line of code** — this is why it landed clean.
|
||||||
|
- **Post-code** (3 lenses → adversarial verify → synthesis): **CLEAN** — all 5 candidate findings refuted, 0 confirmed bugs, no changes required.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- **EditMode: 312/312** (302 prior + 10 new: HealthApplyDamage Destructible ×2, PickWeightedNearest ×5, persistence v3/backward-compat/staging-map ×3; + the `StructureAggroWeight` default pin). Zero console errors at every compile.
|
||||||
|
- **Play (live netcode, focused editor):** the **re-bake** is confirmed — all 3 catalog prefabs carry `Health`(120/150/150)+`Destructible`+`DamageEvent`; `StructureAggroWeight=0.7`; an end-to-end **spawn turret → append lethal damage → destroyed** worked in the running ServerWorld; **0 console errors/exceptions**.
|
||||||
|
- **Operator hands-on fun-gate is OPEN** ("loses have weight"): build a turret → survive into a Siege → watch Husks push for + raze it → feel the loss → it persists wounded across Continue. Tune `StructureAggroWeight` (overlay "Struct aggro w") + the per-prefab `MaxHp` to taste.
|
||||||
|
|
||||||
|
## Deliberate cuts (kept END-1 + scope out)
|
||||||
|
- **Wall physical-blocking of Husks** — out of scope (structures are ghosts inert to the DOTS PhysicsWorld; a damage-sponge AI target fully delivers "machines can die"). A wall is still meaningful: Husks prefer the nearest structure, so a forward wall soaks strikes that would hit a turret.
|
||||||
|
- **No aggregate base-health HUD meter / replicated loss counter** — that is END-1 Core integrity, kept firmly OUT. Feedback is purely per-structure + client-local; the only new wire surface is the per-structure `Health.Current` `[GhostField]`.
|
||||||
|
- **Per-structure health bar / progressive damage tint** — demoted to optional-for-EB-1 (recommended next if the bare chip+burst doesn't read enough anticipation).
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- New: `Simulation/Combat/Destructible.cs`; `Client/Presentation/StructureFeedbackSystem.cs`, `StructureFeelConfig.cs`.
|
||||||
|
- Modified: `Authoring/Building/TurretAuthoring.cs`, `StructureAuthoring.cs`; `Server/Combat/HealthApplyDamageSystem.cs`, `EnemyAISystem.cs`; `Simulation/Combat/EnemyAIMath.cs`; `Simulation/Debug/TuningConfig.cs`, `Client/Debug/DebugOverlay.cs`; `Simulation/Persistence/SaveData.cs`, `SaveComponents.cs`, `SaveStructureScan.cs`, `SaveApply.cs`, `SaveService.cs`; `Server/Automation/BaseRestoreSystem.cs`; `Client/UI/WorldLauncher.cs`; `Client/Presentation/CombatFeedbackSystem.cs`.
|
||||||
|
- Tests: `HealthApplyDamageSystemTests.cs`, `EnemyAIMathTests.cs`, `SavePersistenceTests.cs`, `TuningConfigTests.cs`.
|
||||||
|
|
||||||
|
## Next-session intent
|
||||||
|
Operator runs the EB-1 fun-gate (does a structure loss have weight?). If it lands, the natural next Path A braid is **EB-2 (felt spend — turret ammo from harvest)** which closes the factory→defense pipe so the mined Ore is *spent*, then **END-1 (losable Core integrity bar)** + **END-2 (win/lose)**. If the loss doesn't read, add the per-structure damage tint (pattern in `CombatFeedbackSystem`) before moving on.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
id: DR-032
|
||||||
|
title: EB-1 = machines can die — structures reuse the Health/DamageEvent spine, Husks fortress-target them, a wounded base persists (SaveData v3)
|
||||||
|
status: accepted
|
||||||
|
date: 2026-06-11
|
||||||
|
tags:
|
||||||
|
- decision
|
||||||
|
- design
|
||||||
|
- combat
|
||||||
|
- structures
|
||||||
|
- netcode
|
||||||
|
- persistence
|
||||||
|
- eb-1
|
||||||
|
permalink: gamevault/07-sessions/decisions/dr-032-eb1-machines-can-die
|
||||||
|
---
|
||||||
|
|
||||||
|
# DR-032 — EB-1: Machines Can Die
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Session [[2026-06-11_EB1_Machines_Can_Die]] · forks locked [[DR-029_Path_A_Fork_Locks]] · combat thesis [[DR-028_Combat_Primary_Verb_Depth_First]] · base loop [[DR-031_Base_Mining_Loop_Cohesion]] · build/structures [[DR-014_M6_Build_Structures_Automation_Foundation]] · persistence [[DR-019_Frontend_Menu_Settings_Saves_Build]].
|
||||||
|
|
||||||
|
Operator: "Completely implement EB-1." The base loop (DR-031) lets you mine→build, but a built structure had no stakes — it could not be lost. EB-1's locked forks (DR-029): Husks **push for the base/structures** (you defend; live-singleton tunable); a **wounded base persists** across save/quit (structure HP in SaveData v3); structure destruction is EB-1's job (the END-1 Core integrity bar is later). Ran the standing **pre-code adversarial review** (1 blocker + 8 majors, all folded in) and a **post-code** review (CLEAN).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**1. Structures reuse the existing combat spine — no parallel system.** `Health{[GhostField] Current; Max}` + a `DamageEvent` buffer are baked onto the Turret/Wall/Pylon ghost prefabs (the same ownerless-interpolated-ghost shape Husks use). The strike append + drain + clamp + destroy all reuse `HealthApplyDamageSystem`. Rejected a parallel `StructureHealth`/`StructureDamage` system (would duplicate the proven spine + lose the `Health.Current` `[GhostField]` the feedback edge-detect needs).
|
||||||
|
|
||||||
|
**2. A `Destructible` tag gates destruction, NOT bare `PlacedStructure`.** `HealthApplyDamageSystem`'s destroy gate becomes `TrainingDummyTag || EnemyTag || HasComponent<Destructible>`. `PlacedStructure` is a SHARED identity worn by the reserved M7 automation machines (Harvester/Fabricator/Conveyor) whose conveyor cargo-drop teardown is unhandled — a bare-`PlacedStructure` gate would make every machine destructible the instant it got Health. The tag (zero-size, ghost-hash-neutral) lets each destructible opt in. Structures carry **no `EffectiveCharacterStats`** so they take the `math.max(0,..)` clamp branch and CAN die — adding stats would make them immortal (a gate comment records this anti-pattern). **No `HitRadius`** on structures (deliberate): `ProjectileDamageSystem` needs Health+HitRadius, so player shots never friendly-fire a turret.
|
||||||
|
|
||||||
|
**3. Fortress targeting via one shared, weighted helper.** `EnemyAISystem` snapshots live structures ABOVE the early-return (guard `players==0 && structures==0`) so Husks raze an undefended base. Both the Grunt and Charger passes call the pure `EnemyAIMath.PickWeightedNearest(from, players, structures, weight, out isStructure, out index)` — the weight is a **squared** factor on structure distance (a linear weight in a `lengthsq` comparison is mathematically wrong), so `<1` prefers structures while a sufficiently-closer player "in the way" still wins. One helper makes both archetypes aggro bit-identically and is unit-testable. `StructureAggroWeight` is a **`TuningConfig` knob** (default 0.7), NOT a standalone singleton (a separate singleton duplicates the SetTuning/broadcast/overlay plumbing and never reaches MPPM thin clients). It is a **value knob (clamp ≥0)** — the default `≥1` clamp branch would silently floor 0.7 to 1.0 and kill the "structures preferred" behavior.
|
||||||
|
|
||||||
|
**4. Persistence v3 is additive and threads HP through FIVE sites.** `StructureSave.HP`, `PendingStructure.HP`, `SaveStructureScan` (read `Health.Current`, guarded by `HasComponent<Health>` so an automation machine doesn't crash the try/catch-less autosave path), `BaseRestoreSystem` (set Health in the SAME ecb as Instantiate — born-correct `[GhostField]`; Max + the `0→Max` fallback sourced from the BAKED prefab, never the save), and `WorldLauncher.StagePendingSave` (via the extracted pure `SaveApply.ToPending`). `SaveData.CurrentVersion 2→3` + `MinLoadableVersion=2`; the `SaveService.Load` gate is a **floor `[2,3]`** (a blanket `<=Current` would accept v0/v1 garbage into a restore that assumes the structure-array shape), so old v2 saves still load — a missing HP field 0-defaults and the `0→Max` guard restores them full.
|
||||||
|
|
||||||
|
**5. Loss has weight without an aggregate meter.** `CombatFeedbackSystem` is suppressed for structures (else a dying wall fires the HUMAN player-death cue + a cyan damage-number storm — both gated `&& !isStructure`). A new client-only `StructureFeedbackSystem` observes `PlacedStructure`+`Health`: a camera-silent amber chip on a HP decrease, a LOUD burst + camera punch on destruction, **proximity-gated** (the base→expedition RegionRelevancy despawn drops every base structure at once — a naive prune=death would storm a false "base wiped") and de-duped (`DeathFired`). No aggregate base-health HUD / replicated loss counter — that is END-1 Core integrity, kept OUT; the only new wire surface is the per-structure `Health.Current` `[GhostField]`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- A built structure now has stakes: Husks prefer it, it takes damage, it can die, and the wound persists across Continue — closing the "build has weight" half of the loop. A forward wall soaks strikes that would hit a turret (meaningful even without physical blocking).
|
||||||
|
- 312/312 EditMode (10 new); live netcode Play clean (re-bake verified, end-to-end destroy proven, 0 errors). **Operator fun-gate OPEN** ("does the loss have weight?"). Live knobs: `StructureAggroWeight` (overlay) + per-prefab `MaxHp`.
|
||||||
|
- The pre-code review's 1-blocker-8-majors → a CLEAN post-code review is the strongest evidence yet that the mandatory pre-code adversarial pass on a netcode-heavy slice prevents bugs rather than finding them late.
|
||||||
|
|
||||||
|
## Alternatives considered (rejected)
|
||||||
|
|
||||||
|
- **Bare `HasComponent<PlacedStructure>` destroy gate** — shared with reserved automation machines (unhandled cargo teardown); `Destructible` tag instead.
|
||||||
|
- **A parallel `StructureHealth`/`StructureDamage` system** — duplicates the spine + loses the `[GhostField]` the feedback needs.
|
||||||
|
- **A standalone `StructureAggroWeight` singleton** — duplicates the live-tuning plumbing; a `TuningConfig` knob instead.
|
||||||
|
- **Wall physical-blocking of Husks** — a conscious cut (needs a dynamic `CollisionWorld` of player-built structures); a damage-sponge AI target suffices for "machines can die". Revisit in a later EB.
|
||||||
|
- **Adding `EffectiveCharacterStats` to structures for an HP ceiling** — anti-pattern; it would clamp to a non-zero floor and never die.
|
||||||
Reference in New Issue
Block a user