~bigbes/game-prototype-ftl

aff16d4fa0a7d226f4d8d6748802755f0b6c1cd2 — Eugene Blikh 30 days ago 3b45141
docs(systems-power): record approved plan

systems-power
1 files changed, 97 insertions(+), 0 deletions(-)

M docs/tasks/systems-power.md
M docs/tasks/systems-power.md => docs/tasks/systems-power.md +97 -0
@@ 73,3 73,100 @@ Third milestone of the `ftl-shape` vertical slice. Adds the data + UI layer for 
### Unknowns

- UK1 — Whether 30% dimming on unpowered rooms is enough visual signal at 640×360 zoom. Resolved at verify by browser smoke.

## Plan

Approach: TDD-first on the two pure units (systems data, then HUD geometry), then one integration phase that wires `drawHud`, room dimming, and right-click input. Greenfield feature; no backwards-compat risk.

### PH1 — Systems data model (TDD)

- **1.1** `systems_test.go` (create) — tests first, must fail before 1.2.
  - `TestNewStartingSystems_layout` — returns 5 systems; indexed by `int(RoomRole)` so `sys[int(RolePilot)].Role == RolePilot` etc.; caps are Pilot 1, Weapons 4, Shields 4, MedBay 2, Engines 4; all `PowerLevel == 0`.
  - `TestReactorUsed_zero` — fresh systems → `reactorUsed == 0`.
  - `TestReactorUsed_sums` — set arbitrary `PowerLevel`s → returns the sum.
  - `TestAddPower_happy` — level<cap, reactor not full → returns true, level becomes `n+1`.
  - `TestAddPower_systemAtCap` — level==MaxLevel → returns false, level unchanged.
  - `TestAddPower_reactorFull` — `reactorUsed == ReactorCap` and target system below its cap → returns false, level unchanged.
  - `TestAddPower_invariantsHold` — after every `addPower` call (pass/fail), `reactorUsed ≤ ReactorCap` and every `0 ≤ PowerLevel ≤ MaxLevel`. Covers IV3, IV4.
  - `TestRemovePower_happy` — level>0 → returns true, level becomes `n-1`.
  - `TestRemovePower_zeroLevel` — level==0 → returns false, level unchanged.
- **1.2** `systems.go` (create)
  - `package main`; no Ebitengine import. Respects: IV1.
  - `const ReactorCap = 8`.
  - `type System struct { Role RoomRole; PowerLevel, MaxLevel int }`.
  - `func NewStartingSystems() []System` — slice of 5 entries indexed by `int(RoomRole)`; caps as in the test.
  - `func reactorUsed(sys []System) int` — sum.
  - `func addPower(sys []System, role RoomRole, reactorCap int) bool` — locate by `Role` field (linear scan, 5 elements); reject if at cap or `reactorUsed == reactorCap`; else `++PowerLevel`. Respects: IV2, IV3, IV4.
  - `func removePower(sys []System, role RoomRole) bool` — locate by `Role`; reject if level==0; else `--PowerLevel`. Respects: IV2, IV4.
- Commit: `systems-power: System data + addPower/removePower with tests`

### PH2 — HUD geometry & hit-test (TDD)

- **2.1** `hud_test.go` (create) — tests first, must fail before 2.2.
  - `TestHudHitTest_aboveHud` — `py < HudY` → `ok=false`.
  - `TestHudHitTest_leftOfColumns` — `px < HudX` (reactor-readout band) → `ok=false`.
  - `TestHudHitTest_rightOfColumns` — `px >= HudX + 5*HudColW` → `ok=false`.
  - `TestHudHitTest_eachColumnCenter` — pixel at the centre of column `i` → returns `RoomRole(i)`, `ok=true`. Loop over the 5 roles.
  - `TestHudHitTest_columnEdges` — left edge of column `i` (inclusive) → role `i`; right edge (exclusive) → role `i+1` or `ok=false` for the last column.
- **2.2** `hud.go` (create)
  - `package main`; no Ebitengine import. Respects: IV1, IV6.
  - `const ( HudY = 272; HudX = 200; HudColW = 40; HudColCount = 5 )`.
  - `func hudHitTest(px, py int) (RoomRole, bool)` — gate on `py >= HudY` and `HudX <= px < HudX+HudColCount*HudColW`; column index `(px-HudX)/HudColW`; return `RoomRole(idx), true`. No gutter inside the strip — columns are flush to keep hit-test simple.
- Commit: `systems-power: HUD geometry + hudHitTest with tests`

### PH3 — Render & input integration (manual smoke)

- **3.1** `render.go` (modify)
  - In `roleColor`: extract / introduce `dimRoleColor(role RoomRole) color.RGBA` returning ~60% brightness of the powered tint (one switch, parallel to `roleColor`). Used by drawRoom when system unpowered. Respects: IV5.
  - Modify `drawRoom(screen, r Room, powered bool)` (signature change) — picks `roleColor(r.Role)` if `powered`, else `dimRoleColor(r.Role)`. Border + label unchanged.
  - Modify `drawShip(screen, s Ship, sys []System)` (signature change) — for each room, compute `powered := sys[int(r.Role)].PowerLevel > 0`, call `drawRoom(screen, r, powered)`. Respects: PC3 (read-only).
  - Add `func drawHud(screen *ebiten.Image, sys []System)`:
    - Reactor readout: `ebitenutil.DebugPrintAt(screen, fmt.Sprintf("Reactor: %d/%d", reactorUsed(sys), ReactorCap), 8, HudY+8)`.
    - Per column `i` (0..4): role label at `(HudX + i*HudColW + 4, HudY+4)`; cells stacked bottom-up inside the column band, cell size 12×10 with 2 px gap, drawn from row 0 (bottom-most) up to `MaxLevel-1`. Cell `j < PowerLevel` filled with `roleColor`; cell `j ≥ PowerLevel` filled with `dimRoleColor`; 1-px white outline on every cell. Respects: PC3 (read-only), IV6.
- **3.2** `main.go` (modify)
  - `Game` gains `systems []System`. Init in `main()` via `NewStartingSystems()`.
  - `Update`:
    - On `inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft)`: read `cx, cy := ebiten.CursorPosition()`. If `cy >= HudY` → call `hudHitTest(cx, cy)`; if hit, `addPower(g.systems, role, ReactorCap)`, return nil. Else (HUD miss while in HUD band): no-op, return nil. If `cy < HudY` → fall through to existing crew-select / move logic, unchanged.
    - On `inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight)`: if `cy >= HudY` and `hudHitTest` hits → `removePower(g.systems, role)`. Else no-op.
  - `Draw`: replace `drawShip(screen, g.ship)` with `drawShip(screen, g.ship, g.systems)`; after the existing crew loop, call `drawHud(screen, g.systems)`. Respects: IV5.
- **3.3** `web/index.html` (modify)
  - Change `<body>` → `<body oncontextmenu="return false">` so right-click doesn't open the OS menu in the browser. Respects: AS2.
- Commit: `systems-power: drawHud, room dimming, right-click input`

### Test strategy

Per Design `TDD: yes`. Phase 1 and Phase 2 tests are written first and must fail on the pre-implementation tree. Phase 3 has no automated tests — input and rendering need real mouse / display. Manual acceptance via `make play-web`:

- Click each system column in the HUD: a power cell lights up, reactor readout increments, the corresponding room in the ship view brightens.
- Right-click an HUD column: power cell unlights, reactor readout decrements, room dims.
- Click a system column at its `MaxLevel`: no change.
- Allocate up to `ReactorCap=8`: further left-clicks on any column become no-ops until power is removed elsewhere.
- Click in the ship area (above HUD) with crew selected: crew movement still works (no regression).
- Right-click in the ship area: no-op (no regression, no context menu in browser).

### Order & dependencies

- PH1 and PH2 are independent — different files, no shared symbols. Can be implemented in either order or in parallel.
- PH3 depends on both: it consumes `addPower`, `removePower`, `reactorUsed`, `NewStartingSystems`, `ReactorCap`, `hudHitTest`, `HudY`, `HudX`, `HudColW`, `HudColCount`.

### Risks / rollback

- RK1 — `MouseButtonRight` not firing in WASM despite the `oncontextmenu` patch. Mitigation: verify in browser smoke first; if broken, fall back to `Shift+left-click` for power-down (one-line input branch swap, no data-model change).
- RK2 — Dimmed rooms (60% brightness) too subtle at 640×360 — covered by UK1, resolved at verify; if subtle, bump to 50% or add a small overlay tag.
- Rollback: each phase is its own commit; revert PH3 alone leaves PH1+PH2 dead-code but harmless (unexported helpers compile-clean), then revert PH2 / PH1 in turn.

### Interfaces

- IF1 — `func addPower(sys []System, role RoomRole, reactorCap int) bool` — defined in `systems.go`, consumed by `main.go` left-click handler.
- IF2 — `func removePower(sys []System, role RoomRole) bool` — defined in `systems.go`, consumed by `main.go` right-click handler.
- IF3 — `func reactorUsed(sys []System) int` — defined in `systems.go`, consumed by `render.go:drawHud` for the readout.
- IF4 — `func hudHitTest(px, py int) (RoomRole, bool)` — defined in `hud.go`, consumed by `main.go` input handler.
- IF5 — HUD constants `HudY, HudX, HudColW, HudColCount` and `ReactorCap` — defined in `hud.go` / `systems.go`, consumed by `render.go:drawHud` and `main.go` input gate.
- IF6 — `type System struct{...}` and `func NewStartingSystems() []System` — defined in `systems.go`, consumed by `main.go` (init + input) and `render.go:drawShip / drawHud`.

### Interface graph

- PH1                       -> IF1, IF2, IF3, IF6   @ systems.go, systems_test.go
- PH2                       -> IF4, IF5             @ hud.go, hud_test.go
- PH3  IF1, IF2, IF3, IF4, IF5, IF6 ->              @ render.go, main.go, web/index.html