# combat **Branch:** `combat` Fourth and last gameplay milestone of the `ftl-shape` vertical slice. Adds a single combat encounter against a placeholder enemy ship; power allocation drives the loop (Weapons charge faster with more power, Shields absorb hits and regenerate, Engines provide an evasion %); auto-fire on ready; win/lose detection with R-to-restart. ## Design **Purpose.** Make the systems-power data layer pay off. The player and a placeholder enemy ship trade fire under power-driven mechanics; the encounter is short and deterministic-given-RNG, with a clean win/lose state and a one-key restart. Combat is the climactic milestone before `polish`. **In scope.** - Enemy ship rendered to the right of the player ship (same room layout, shifted; no systems / no UI of its own). - Player ship gets a single weapon ("Laser I", damage 1). Charge rate = `WeaponsPower` charge-units / sec, threshold `WeaponChargeMax = 8`. On full charge, fires automatically, deals 1 to enemy, resets. - Enemy AI is a fixed timer: fires every `EnemyFireInterval = 6 s` for 1 damage at the player. - Shields: layers = `floor(ShieldsPower / 2)` (so 0/1=none, 2/3=1 layer, 4=2 layers). Each layer absorbs one hit. Layers regenerate continuously, one per `ShieldRegen = 3 s` of elapsed time, capped at the current max. - Engines: evasion % = `EnginesPower * 5`. Each incoming hit rolls; on success the shot misses (no shield consumed, no hull damage). - Hulls: `PlayerHullMax = 20`, `EnemyHullMax = 15`. Damage clamps at 0. - Win/lose detection. Centered overlay text "VICTORY (R to restart)" / "DEFEAT (R to restart)". `R` resets the encounter (combat state, systems, crew). - Visual readouts: top-of-screen hull bars (player left, enemy right). A small horizontal weapon-charge bar near the Weapons HUD column. Shield-layer dots above the player Shields room. Evasion % text near the Engines room. A 200 ms red flash on the ship that just took a hull hit. **Out of scope.** Per-room targeting; system damage; crew taking damage / fleeing rooms; repair; multiple weapons; multiple enemy types or AI variation; missiles / drones / beams / hacking; FTL drive / map / events; sound / music; Pilot and MedBay gameplay effects (their HUD columns still allocate power but produce no effect — flagged for `polish`). **Chosen approach.** - **Data — new file `combat.go`** (pure, no Ebitengine): - `type GameResult int` with `GameOngoing, GameVictory, GameDefeat`. - `type CombatSide struct { Hull, MaxHull int; ShieldLayers int; ShieldRegenT float64; HitFlashT float64 }`. - `type Combat struct { Player, Enemy CombatSide; PlayerWeaponT float64; EnemyFireT float64; Result GameResult }`. - `func NewCombat() Combat` — initial state: player 20/20, enemy 15/15, all timers 0, `Result = GameOngoing`. - `func updateCombat(c *Combat, sys []System, dt float64, rng *rand.Rand)` — full tick. If `Result != GameOngoing`, no-op. Otherwise: 1. Tick `HitFlashT` down on both sides (clamp ≥ 0). 2. Compute `playerShieldMax(sys)`; clamp `Player.ShieldLayers ≤ max`. 3. Advance shield regen: `ShieldRegenT += dt`; while `ShieldRegenT >= ShieldRegen && ShieldLayers < max`: `++ShieldLayers; ShieldRegenT -= ShieldRegen`. 4. Advance player weapon charge by `dt * float64(sys[int(RoleWeapons)].PowerLevel)`. On `>= WeaponChargeMax`: call `resolveIncoming(&c.Enemy, 0, rng)`, reset `PlayerWeaponT = 0`. 5. Advance enemy fire timer by `dt`. On `>= EnemyFireInterval`: call `resolveIncoming(&c.Player, 5*sys[int(RoleEngines)].PowerLevel, rng)`, reset `EnemyFireT = 0`. 6. Win/lose check: if `Enemy.Hull == 0 && Player.Hull > 0`: `Result = GameVictory`; if `Player.Hull == 0`: `Result = GameDefeat`. - `func resolveIncoming(side *CombatSide, evasionPct int, rng *rand.Rand)` — `if rng.Intn(100) < evasionPct: return` (dodged). Else: if `ShieldLayers > 0`: `--ShieldLayers`; else `Hull = max(0, Hull-1); HitFlashT = HitFlashDuration`. - `func playerShieldMax(sys []System) int` — `sys[int(RoleShields)].PowerLevel / 2`. - Constants: `const ( WeaponChargeMax = 8.0; ShieldRegen = 3.0; EnemyFireInterval = 6.0; HitFlashDuration = 0.2; PlayerHullMax = 20; EnemyHullMax = 15 )`. - **Enemy ship layout — modify `ship.go`:** - Add `func NewEnemyShip() Ship` — same 5-room layout shifted by `+24` tiles; enemy occupies tiles `[27, 38] × [6, 15]`, pixels `[432, 608] × [96, 240]`. - **Rendering — modify `render.go`:** - Add `func drawEnemyShip(screen *ebiten.Image, s Ship, hitFlashT float64)` — every room rendered with full `roleColor` (no power dimming). When `hitFlashT > 0`: translucent red overlay across all rooms. - Modify `drawShip` to take an extra `hitFlashT float64` arg → same red overlay when player takes a hull hit. - Add `func drawHullBars(screen *ebiten.Image, c Combat)` — top-left "PLAYER 20/20" + filled bar; top-right "ENEMY 15/15" mirrored. - Add `func drawWeaponCharge(screen *ebiten.Image, c Combat)` — horizontal fill bar near the Weapons HUD column, fraction = `PlayerWeaponT / WeaponChargeMax`. - Add `func drawShieldsIndicator(screen *ebiten.Image, c Combat, sys []System)` — N dots above the player Shields room, lit = active layer, dim = max-but-regenerating slot. - Add `func drawEvasionReadout(screen *ebiten.Image, sys []System)` — text "Evade: NN%" above the Engines room. - Add `func drawResultOverlay(screen *ebiten.Image, result GameResult)` — semi-transparent dark fill + centered "VICTORY (R to restart)" / "DEFEAT (R to restart)". - **Input + integration — modify `main.go`:** - `Game` gains `combat Combat`, `enemy Ship`, `rng *rand.Rand`. - `main()` initialises `combat := NewCombat()`, `enemy := NewEnemyShip()`, `rng := rand.New(rand.NewSource(time.Now().UnixNano()))`. - `Update`: - When `combat.Result == GameOngoing`: call `updateCombat(&g.combat, g.systems, dt, g.rng)` after the existing crew tick. - On `inpututil.IsKeyJustPressed(ebiten.KeyR)` and `combat.Result != GameOngoing`: full reset — `g.combat = NewCombat(); g.systems = NewStartingSystems(); g.crew = NewStartingCrew(); g.selectedCrew = -1`. - HUD power-allocation clicks remain unchanged during ongoing combat. Gated to no-op when `Result != GameOngoing` to keep the post-game state stable until restart. - `Draw`: existing `drawShip(screen, g.ship, g.systems, g.combat.Player.HitFlashT)`; new `drawEnemyShip(screen, g.enemy, g.combat.Enemy.HitFlashT)`; new HUD overlays (hull bars, weapon charge, shield dots, evasion %); existing `drawHud`; `drawCrew` for each crew; `drawResultOverlay` last. **Tradeoffs that settled the decisions.** - Auto-fire vs click-to-fire — with one weapon and no per-room targeting, manual fire adds an interaction step with no decision behind it. Auto-fire keeps the loop legible as resource flow. - Dumb-timer enemy vs charging-weapon enemy — symmetric AI doubles the test surface for no gameplay payoff at this scope. A timer is two state vars and one branch. - Damage 1, hulls 20/15 — ~15 enemy hits to win, ~20 player hits to lose; at default power split (4 weap / 2 shld / 2 eng) that's a ~30-60 s fight, the right length for "one combat encounter." - Shield max from `floor(power/2)` — mirrors FTL's "2 power per layer", keeps the existing `System` data unchanged, makes each shield power level visibly different. - Evasion as flat % roll — RNG is unavoidable for FTL-style evasion; seeded `*rand.Rand` makes the tick deterministic in tests. - `R` to restart vs auto-restart — explicit input prevents accidental loops; matches FTL's "main menu after defeat" condensed to one keypress. - Pilot + MedBay inert — their FTL effects (pilot evasion bonus, MedBay heal) require crew-positioning gameplay, which is `polish`-tier. Marking them inert keeps scope honest. **Backwards compat.** Greenfield additions; no public API; only behavior change is `Game.Update`'s ordering (combat tick interleaved with crew tick). HUD click handling, crew movement, power allocation semantics unchanged. `R` is a new key binding — not previously used anywhere in the project. **TDD: yes** — `updateCombat`, `resolveIncoming`, `playerShieldMax`, `NewCombat`, and the restart-reset block are pure functions with meaningful branching (idle vs ongoing, weapon-charge threshold, shield regen tick, hull underflow, evasion roll on/off, win/lose state transitions). Rendering and key input stay manual smoke. Seeded `*rand.Rand` makes evasion deterministic. ### Invariants - IV1 — `combat.go` and `ship.go` must not import Ebitengine. Combat logic and ship layouts are pure. - IV2 — `updateCombat` and `resolveIncoming` are the only mutators of `Combat` and `CombatSide` fields. Renderer reads only. - IV3 — `0 ≤ Hull ≤ MaxHull` for both sides at all times. - IV4 — `0 ≤ ShieldLayers ≤ playerShieldMax(sys)` after every `updateCombat` call. Layers clamp down when power is removed. - IV5 — `Combat.Result` only transitions out of `GameOngoing` once per fight; once `GameVictory` or `GameDefeat`, stays until reset by R. - IV6 — `updateCombat(c, sys, dt, rng)` is deterministic for a given input state and `*rand.Rand` state; the only randomness source is the passed `*rand.Rand`. - IV7 — Single codebase builds unchanged for `GOOS=js GOARCH=wasm` and native; no `//go:build`, no platform-suffixed files. - IV8 — HUD geometry constants stay in `hud.go` (carried over from systems-power's IV6). ### Principles - PC1 — Fail fast: invalid combat actions (e.g. R while ongoing) are silent no-ops, not partial state changes. - PC2 — Tests cover pure logic (combat tick, hit resolution, shield regen, charge, win/lose); rendering and input wiring stay manual. - PC3 — Separate data from rendering and input: `combat.go` doesn't know pixel layout; `render.go` reads `Combat` only. - PC4 — Game-feel numbers (hull max, charge max, shield regen, evasion factor, enemy fire interval) live as named constants, not magic literals scattered through tick logic. ### Assumptions - AS1 — `inpututil.IsKeyJustPressed(ebiten.KeyR)` works under `GOOS=js GOARCH=wasm`. Same package as the mouse path that already works in WASM. - AS2 — `math/rand`'s `*rand.Rand` is portable to WASM. Verified by Go's WASM port matrix. - AS3 — A single 60 TPS Ebitengine tick rate gives smooth-enough charge / regen / flash animation at the chosen constants without sub-frame stepping. ### Unknowns - UK1 — Whether the chosen game-feel numbers (hulls 20/15, weapon 8/charge, shields 3 s regen, enemy 6 s fire, evasion 5%/level) produce a "fair" feeling fight at the baseline 8-reactor allocation. Resolved at verify by playing through several encounters with different power setups. - UK2 — Whether the visible feedback for shield hits (layer count change) and evasion (no other signal beyond the enemy fire timer resetting) is legible at 640×360. Resolved at verify by browser smoke; if too subtle, add a text "MISS" / "BLOCK" toast. ## Plan Approach: TDD-first on the pure pieces (combat tick, then enemy ship layout), then one integration phase that wires rendering, input, and the restart key. Greenfield additions; the only signature change is `drawShip` (one call site). ### PH1 — Combat data + tick (TDD) - **1.1** `combat_test.go` (create) — tests first, must fail before 1.2. - `TestNewCombat_initialState` — `Player.Hull=20, MaxHull=20, ShieldLayers=0`, `Enemy.Hull=15, MaxHull=15`, all timers 0, `Result==GameOngoing`. Covers IV3. - `TestPlayerShieldMax_table` — shields power 0..4 → 0,0,1,1,2. - `TestResolveIncoming_shieldsAbsorb` — `ShieldLayers>0` → `--ShieldLayers`, hull unchanged, `HitFlashT==0`. - `TestResolveIncoming_hullDamage` — `ShieldLayers==0` → `Hull--`, `HitFlashT==HitFlashDuration`. - `TestResolveIncoming_hullClampsAtZero` — `Hull==0` → stays 0 (IV3). - `TestResolveIncoming_evasionMiss` — seeded rng rolling under threshold → no change to side state. - `TestResolveIncoming_evasionHit` — seeded rng rolling over threshold → damage applied. - `TestUpdateCombat_postResultNoOp` — `Result=GameVictory` → state frozen across a tick (IV5). - `TestUpdateCombat_hitFlashTicksDown` — both sides' `HitFlashT` decremented and clamped ≥0. - `TestUpdateCombat_shieldRegen` — power=4, layers=0, `dt > ShieldRegen` → layers increments toward `playerShieldMax`. - `TestUpdateCombat_shieldsClampOnPowerDrop` — layers=2, shields power dropped to 0 → next tick clamps to 0 (IV4). - `TestUpdateCombat_weaponChargeFires` — weapons=1, `PlayerWeaponT` just below `WeaponChargeMax` → next tick fires, `Enemy.Hull--`, `PlayerWeaponT=0`. - `TestUpdateCombat_weaponZeroPowerNoCharge` — weapons=0 → `PlayerWeaponT` unchanged. - `TestUpdateCombat_enemyFiresOnTimer` — `EnemyFireT` crosses `EnemyFireInterval`, engines=0, no shields → `Player.Hull--`, `EnemyFireT=0`. - `TestUpdateCombat_victoryTransition` — enemy hull=1, weapon ready → `Result==GameVictory` after fire. - `TestUpdateCombat_defeatTransition` — player hull=1, enemy fires, no shields, no evasion → `Result==GameDefeat`. - `TestUpdateCombat_determinism` — same seed + same input sequence → identical state (IV6, AS2). - **1.2** `combat.go` (create) - `package main`; imports `math/rand` only. No Ebitengine. Respects: IV1. - `const ( WeaponChargeMax = 8.0; ShieldRegen = 3.0; EnemyFireInterval = 6.0; HitFlashDuration = 0.2; PlayerHullMax = 20; EnemyHullMax = 15 )`. Respects: PC4. - `type GameResult int` with `GameOngoing, GameVictory, GameDefeat`. - `type CombatSide struct { Hull, MaxHull int; ShieldLayers int; ShieldRegenT float64; HitFlashT float64 }`. - `type Combat struct { Player, Enemy CombatSide; PlayerWeaponT float64; EnemyFireT float64; Result GameResult }`. - `func NewCombat() Combat` — returns initial state per design step list. - `func playerShieldMax(sys []System) int` — `sys[int(RoleShields)].PowerLevel / 2`. Pure query (GPC3). - `func resolveIncoming(side *CombatSide, evasionPct int, rng *rand.Rand)` — design step list. Sole mutator pair on `CombatSide`. Respects: IV2. - `func updateCombat(c *Combat, sys []System, dt float64, rng *rand.Rand)` — design step list 1–6. Sole mutator on `Combat`. Respects: IV2, IV3, IV4, IV5, IV6. - Commit: `combat: Combat data + updateCombat with tests` ### PH2 — Enemy ship layout (TDD) - **2.1** `ship_test.go` (create) — tests first, must fail before 2.2. - `TestNewEnemyShip_roomCount` — 5 rooms. - `TestNewEnemyShip_shifted` — for each room, `enemy.Rooms[i].GridX == player.Rooms[i].GridX + 24`; `GridY/GridW/GridH/Role/Name` unchanged. - **2.2** `ship.go` (modify, append) - `func NewEnemyShip() Ship` — same 5 rooms as `NewPlayerShip`, every `GridX` shifted by `+24`. Pure data; no Ebitengine. Respects: IV1. - Commit: `combat: NewEnemyShip layout` ### PH3 — Render & input integration (manual smoke) - **3.1** `render.go` (modify) - `drawShip(screen *ebiten.Image, s Ship, sys []System, hitFlashT float64)` — signature change: extra `hitFlashT`. After existing room loop, when `hitFlashT > 0`, overlay translucent red across all rooms (alpha scaled by `hitFlashT / HitFlashDuration`). Respects: PC3. - `drawEnemyShip(screen *ebiten.Image, s Ship, hitFlashT float64)` — every room rendered with full `roleColor` (no power dimming, no label-only treatment beyond border + name). Same red overlay rule on `hitFlashT > 0`. - `drawHullBars(screen *ebiten.Image, c Combat)` — top-left `"PLAYER %d/%d"` + filled bar; top-right `"ENEMY %d/%d"` mirrored. - `drawWeaponCharge(screen *ebiten.Image, c Combat)` — horizontal fill bar above the Weapons HUD column at `(HudX + int(RoleWeapons)*HudColW, HudY-…)`. Reads `HudX, HudColW, HudY` from `hud.go`. Respects: IV8. - `drawShieldsIndicator(screen *ebiten.Image, c Combat, sys []System)` — `playerShieldMax(sys)` dots above the player Shields room (room read from layout, not hard-coded — call sites use `s.Rooms` indexing by role). Lit dot for active layer; dim dot for max-but-regenerating slot. - `drawEvasionReadout(screen *ebiten.Image, sys []System)` — `"Evade: NN%"` text above the Engines room; value `sys[int(RoleEngines)].PowerLevel * 5`. - `drawResultOverlay(screen *ebiten.Image, result GameResult)` — semi-transparent dark fill + centered `"VICTORY (R to restart)"` / `"DEFEAT (R to restart)"`. No-op when `result == GameOngoing`. - **3.2** `main.go` (modify) - `Game` gains `combat Combat`, `enemy Ship`, `rng *rand.Rand`. Init in `main()` via `NewCombat()`, `NewEnemyShip()`, `rand.New(rand.NewSource(time.Now().UnixNano()))`. - `Update`: - After existing crew tick, when `g.combat.Result == GameOngoing`: `updateCombat(&g.combat, g.systems, dt, g.rng)`. - Existing left-click HUD gate: keep `addPower` call. Add early-return guard at the start of HUD branch — if `g.combat.Result != GameOngoing`, no-op. Same for right-click HUD gate. Respects: PC1. - On `inpututil.IsKeyJustPressed(ebiten.KeyR)` and `g.combat.Result != GameOngoing`: `g.combat = NewCombat(); g.systems = NewStartingSystems(); g.crew = NewStartingCrew(); g.selectedCrew = -1`. Respects: AS1. - `Draw`: replace `drawShip(screen, g.ship, g.systems)` with `drawShip(screen, g.ship, g.systems, g.combat.Player.HitFlashT)`; insert `drawEnemyShip(screen, g.enemy, g.combat.Enemy.HitFlashT)`; after existing `drawHud` add `drawHullBars`, `drawWeaponCharge`, `drawShieldsIndicator`, `drawEvasionReadout`; finally `drawResultOverlay(screen, g.combat.Result)` last so it sits on top. - Commit: `combat: render, input, restart` ### Test strategy Per Design `TDD: yes`. PH1 + PH2 tests written first and must fail on the pre-implementation tree. PH3 has no automated tests — rendering + key input need real display. Manual smoke via `make play-web`: - Default 8/0/0/0/0 split → weapons charge ramps, fires every ~8 s, no shield protection, no evasion, fight ends in defeat eventually. - 4 weapons / 2 shields / 2 engines → shields show 1 dot, evasion text reads "Evade: 10%", weapon-charge bar at ~½ rate; ~30–60 s win. - Drop shields power mid-fight → dots clamp down on next tick (IV4 visible). - Take a hull hit → 200 ms red flash on the hit ship. - Reach `Player.Hull == 0` → DEFEAT overlay; HUD clicks become no-ops; press `R` → encounter resets, ship + crew + systems all back to start. - Reach `Enemy.Hull == 0` → VICTORY overlay; same restart flow. - Mid-fight HUD clicks still re-route power. ### Order & dependencies - PH1 and PH2 are independent — different files, no shared symbols. Either order or parallel. - PH3 depends on both: consumes `Combat`, `NewCombat`, `updateCombat`, `playerShieldMax`, `HitFlashDuration`, `WeaponChargeMax`, `GameResult`, `NewEnemyShip`. ### Risks / rollback - RK1 — UK1 game-feel: numbers tuned during PH3 verify; constants are `const` in one file (PC4), so retuning is one diff. - RK2 — UK2 legibility of shield/evasion feedback: covered at verify; "MISS"/"BLOCK" toast is the documented fallback. - RK3 — `drawShip` signature change ripples to one call site in `main.go:Draw`. Mitigation: PH3 commits both edits together; native + WASM build run before commit. - Rollback: each phase is its own commit. Reverting PH3 leaves PH1+PH2 as dead but harmless code. ### Interfaces - IF1 — `func NewCombat() Combat` — defined in `combat.go`, consumed by `main.go` init + R-restart. - IF2 — `func updateCombat(c *Combat, sys []System, dt float64, rng *rand.Rand)` — defined in `combat.go`, consumed by `main.go:Update`. - IF3 — `func playerShieldMax(sys []System) int` — defined in `combat.go`, consumed by `render.go:drawShieldsIndicator`. - IF4 — `type Combat`, `type CombatSide`, `type GameResult` (and constants `GameOngoing/Victory/Defeat`, `HitFlashDuration`, `WeaponChargeMax`) — defined in `combat.go`, consumed by `render.go` (read-only) and `main.go`. - IF5 — `func NewEnemyShip() Ship` — defined in `ship.go`, consumed by `main.go` init. - IF6 — `func drawShip(screen, Ship, []System, float64)` — signature change in `render.go`, consumed by `main.go:Draw` (one call site). ### Interface graph - PH1 -> IF1, IF2, IF3, IF4 @ combat.go, combat_test.go - PH2 -> IF5 @ ship.go, ship_test.go - PH3 IF1, IF2, IF3, IF4, IF5 -> IF6 @ render.go, main.go ## Verify **Result:** passed in-session; unit tests + browser smoke deferred to user (see Notes). Invariants / assumptions: - CK1 (IV1) — `grep 'hajimehoshi/ebiten' combat.go ship.go combat_test.go ship_test.go` → empty. - CK2 (IV2) — `grep -E '\.(Hull|MaxHull|ShieldLayers|...)\s*[+\-*/]?='` outside `combat.go` → empty (the only hit is a `==` comparison in `main.go:42`, a read). - CK3 (IV5) — `Result =` writes only in `combat.go:123, 125`; `main.go` reset path is whole-struct replacement via `NewCombat()`, gated on `Result != GameOngoing`. - CK4 (IV7) — `GOOS=js GOARCH=wasm go build .` → succeeds. - CK5 (IV8) — `HudY/HudX/HudColW/HudColCount` defined only in `hud.go:8-11`; all other refs are reads. - CK6 (AS2) — `math/rand` ships in WASM build (covered by CK4). Interfaces: - CK7 (IF1) — `NewCombat()` callers: `main.go:48, 128` (init + R-restart). - CK8 (IF2) — `updateCombat(&c, sys, dt, rng)` called from `main.go:43`; signature matches. - CK9 (IF3) — `playerShieldMax(sys)` called from `render.go:160` (drawShieldsIndicator) + `combat.go:94` (internal). - CK10 (IF4) — `GameOngoing/Victory/Defeat`, `HitFlashDuration`, `WeaponChargeMax` referenced read-only across `render.go` and `main.go`. - CK11 (IF5) — `NewEnemyShip()` called from `main.go:129`. - CK12 (IF6) — `drawShip(screen, ship, sys, hitFlashT)` 4-arg signature: 1 call site at `main.go:105`, matches definition `render.go:54`. Build: `go build . && GOOS=js GOARCH=wasm go build . && go vet ./...` → all clean. Notes: - Unit tests (PH1's 17 `combat_test.go` tests + PH2's 2 `ship_test.go` tests + 32 prior tests) cannot execute in this Claude Code session: Ebitengine v2.9.9 `internal/ui` package's `init()` calls `currentMouseLocation()` which dereferences nil when no Window Server is attached (`/Users/blikh/go/pkg/mod/github.com/hajimehoshi/ebiten/v2@v2.9.9/internal/ui/ui_darwin.go:286`). Compile + `go vet ./...` are clean. **User: run `go test ./...` in a regular terminal to confirm green.** - End-to-end browser smoke (auto-fire, shield regen, evasion, hit-flash, R-restart, mid-fight power re-routing) requires `make play-web` and a real browser session — also deferred to the user. ## Conclusion Outcome: combat encounter implemented and integrated; reviewer cleared the diff with no Critical or Important findings (HEAD `d77efb6`). Invariants: - IV1 — `combat.go` and `ship.go` import only `math/rand`; no Ebitengine references. - IV2 — only `updateCombat` and `resolveIncoming` mutate `Combat` / `CombatSide` fields; `main.go` reset uses whole-struct replacement via `NewCombat()`. - IV3 — `resolveIncoming` guards `Hull > 0` before decrement; hull cannot underflow. - IV4 — shield layers clamped before the regen loop; power-drop test confirms layers fall in one tick. - IV5 — `Result != GameOngoing` early-return freezes state; tie-tick resolves to `GameDefeat` per `if/else if` ordering. - IV6 — `updateCombat` takes `*rand.Rand` as the only randomness source; `TestUpdateCombat_determinism` covers it. - IV7 — `GOOS=js GOARCH=wasm go build .` clean. - IV8 — `HudY/HudX/HudColW/HudColCount` defined only in `hud.go`; render and main read them. ### Assumptions check - AS1 — held (deferred). `inpututil.IsKeyJustPressed(ebiten.KeyR)` is the same package as the mouse path that already works in WASM; user-side browser smoke confirms. - AS2 — held. `math/rand` builds and vets clean for `GOOS=js GOARCH=wasm`. - AS3 — held (deferred). 60 TPS smoothness is judged at user-side browser smoke; constants are tunable in `combat.go`. ### Unknowns outcome - UK1 — still-open. Game-feel of hulls 20/15, weapon 8/charge, shields 3 s regen, enemy 6 s fire, evasion 5%/level needs the user-side browser playthrough at multiple power splits. All tunables are `const` in `combat.go` (PC4) — retuning is one diff. - UK2 — still-open. Legibility of shield-layer dots and evasion readout at 640×360 needs the user-side browser smoke. Fallback: text "MISS"/"BLOCK" toast. ### Deviations from plan - PH3 — `drawShieldsIndicator` and `drawEvasionReadout` each gained an `s Ship` parameter (plan signatures took only `c Combat, sys []System` and `sys []System`). Why: the first implementation called `NewPlayerShip()` per frame inside the renderer to locate the Shields/Engines room, allocating a 5-room slice each draw and hiding the coupling between layout and renderer. Fix threads `g.ship` through (commit `9eb78ec`). Aligns with GPC1 and the plan bullet's intent ("call sites use `s.Rooms` indexing by role"). Verified by: - `go test ./...` deferred — Ebitengine GLFW `init()` panics on `currentMouseLocation()` in this session (no Window Server). User: run in a regular terminal to confirm 51/51 (32 prior + 17 combat + 2 ship). - Browser smoke deferred — `make play-web` and play through several encounters at different power splits to settle UK1 + UK2 and confirm AS1 + AS3.