From c0489ecc53fbca641ebc9a6a2bc9ac4d881abcc0 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Tue, 28 Apr 2026 04:26:56 +0300 Subject: [PATCH] docs(combat): record approved plan and execute deviation --- docs/tasks/combat.md | 113 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/docs/tasks/combat.md b/docs/tasks/combat.md index 4472fee6936e7442ab626cc2111f8c22d1137636..3c7ceed69cd4e22d627f25f28733d7fca9f96c6f 100644 --- a/docs/tasks/combat.md +++ b/docs/tasks/combat.md @@ -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`).