~bigbes/game-prototype-ftl

c0489ecc53fbca641ebc9a6a2bc9ac4d881abcc0 — Eugene Blikh 30 days ago 9eb78ec
docs(combat): record approved plan and execute deviation
1 files changed, 113 insertions(+), 0 deletions(-)

M docs/tasks/combat.md
M docs/tasks/combat.md => docs/tasks/combat.md +113 -0
@@ 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`).