From aff16d4fa0a7d226f4d8d6748802755f0b6c1cd2 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Tue, 28 Apr 2026 03:35:22 +0300 Subject: [PATCH] docs(systems-power): record approved plan systems-power --- docs/tasks/systems-power.md | 97 +++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/docs/tasks/systems-power.md b/docs/tasks/systems-power.md index 6e0da0f832d09e822ae41bb3b9f07dc78bdef996..814f8014d2f23a69908360ec052b089e888d788e 100644 --- a/docs/tasks/systems-power.md +++ b/docs/tasks/systems-power.md @@ -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` — level0 → 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 `` → `` 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 +