@@ 100,3 100,116 @@ Fourth and last gameplay milestone of the `ftl-shape` vertical slice. Adds a sin
- 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
+
+## Conclusion
+
+### 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 (explicit interfaces) and the plan bullet's intent ("call sites use `s.Rooms` indexing by role").
+
+### Known risks
+
+- Tests cannot be executed in this Claude Code session: Ebitengine's GLFW init panics on `currentMouseLocation()` when no Window Server is attached. Native build, WASM build, and `go vet ./...` are clean. PH1 + PH2 unit tests must be run by the user in a real terminal at verify time (see `## Verify`).