# systems-power
**Branch:** `systems-power`
Third milestone of the `ftl-shape` vertical slice. Adds the data + UI layer for player-allocatable reactor power across the 5 ship systems. No gameplay effect this milestone — the combat milestone consumes the data later.
## Design
**Purpose.** Introduce per-system power: the player ship has a fixed reactor capacity, each system has a current power level and a per-system cap, and the player allocates power via clickable HUD bars at the bottom of the screen. Visual feedback shows powered vs unpowered cells and dims unpowered system rooms in the ship view. The combat milestone later wires shields/weapons/engines behavior on top of this data.
**In scope.** `System` data model (per-role power level + cap); fixed reactor capacity; bottom-screen HUD strip with one stacked-cell column per system + a `Reactor: used/cap` readout; left-click in column → +1 power, right-click → −1 power, both gated by caps; unpowered system rooms rendered dimmer in the ship view; disabling the browser context menu so right-click works in WASM.
**Out of scope.** Damage to systems; per-weapon / per-shield-layer / engine-evasion power costs; crew manning bonuses; reactor upgrades, scrap, store, events; drag-to-allocate; animations on power change.
**Chosen approach.**
- **Data — new file `systems.go`** (pure, no Ebitengine):
- `type System struct { Role RoomRole; PowerLevel, MaxLevel int }`.
- `func NewStartingSystems() []System` — Pilot 0/1, Weapons 0/4, Shields 0/4, MedBay 0/2, Engines 0/4. Indexed by `int(RoomRole)`.
- `func reactorUsed(sys []System) int` — sum of `PowerLevel`.
- `func addPower(sys []System, role RoomRole, reactorCap int) bool` — true if level incremented; rejects if system at cap or reactor full.
- `func removePower(sys []System, role RoomRole) bool` — true if level decremented; rejects if level==0.
- `const ReactorCap = 8` — fixed for this milestone.
- **HUD layout — new file `hud.go`** (pure, no Ebitengine; constants + math only):
- HUD strip occupies virtual `y ∈ [HudY=272, 360)` — 88 px tall, below the ship which ends at row 240.
- 5 columns, each `HudColW=40` wide, starting at `HudX=200`. Column band spans `x ∈ [200, 400)`, leaving the `x ∈ [0, 200)` band for the `Reactor: used/cap` readout and a 240-px right gutter.
- Each column shows `MaxLevel` cells stacked bottom-up; cell size 12×10 with 2 px gap.
- `func hudHitTest(px, py int) (role RoomRole, ok bool)` — returns the role whose column the pixel falls into, or `ok=false` for gutters / outside HUD region.
- **Rendering — `render.go`** (modify):
- Add `func drawHud(screen *ebiten.Image, reactor int, sys []System)` — for each column: draw cells (role tint if `i < PowerLevel`, dim ~30% tint if `i < MaxLevel` but unpowered), 1-px outline per cell, role name above the column, `Reactor: used/cap` text to the left of the columns.
- Modify `drawShip` to take a `powered map[RoomRole]bool` (or equivalent) so unpowered system rooms render at ~60% brightness. Single new arg.
- **Input — `main.go`** (modify):
- `Update` on left-click: if `cy >= HudY` → call `hudHitTest`; if hit, `addPower(...)`. Else (HUD miss) fall through to the existing crew select / move logic, gated on `cy < HudY` so HUD-area clicks never reach crew code.
- On right-click (`inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight)`): if `cy >= HudY` and `hudHitTest` hits → `removePower(...)`. Right-click outside HUD is a no-op.
- **WASM context menu — `web/index.html`** (modify): add `
` so right-click works in browser without spawning the OS context menu.
**Tradeoffs that settled the decisions.**
- Flat `[]System` indexed by role (vs `map[RoomRole]System`) — keeps iteration order stable for HUD column rendering and is trivial to look up. Map adds an ordering decision with no benefit at 5 entries.
- `ReactorCap` as const (vs a `Reactor` field on `Game`) — one less moving part; combat milestone can promote it. YAGNI.
- Pure `hud.go` (vs putting hit-test in `render.go`) — hit-testing is logic, not drawing. Mirrors the established `tiles.go` / `render.go` split.
- Dimming unpowered rooms (vs no in-ship signal) — without it, allocation is invisible above the HUD and the milestone feels dead. A 30% darken of the room fill is one extra branch in `roleColor`.
- Right-click for remove (vs shift-click) — FTL idiom; the WASM context-menu issue is a one-line `oncontextmenu="return false"` fix.
**Unknowns.** Whether 30% dimming on unpowered rooms is enough visual signal at 640×360 zoom — resolved at verify; bump to 50% or add a "POWERED OFF" overlay tag if too subtle.
**TDD: yes** — `addPower`, `removePower`, `reactorUsed`, and `hudHitTest` are pure functions with clear inputs/outputs and meaningful branching (cap reached, reactor full, level zero, column hit/miss, gutter miss). Rendering and input wiring stay manual smoke (browser).
### Invariants
- IV1 — `systems.go` and `hud.go` must not import Ebitengine. Power state and HUD geometry are pure.
- IV2 — `addPower` / `removePower` are the only mutators of `System.PowerLevel`. Renderer never writes to `System`.
- IV3 — `reactorUsed(sys) ≤ ReactorCap` holds after every `addPower` / `removePower` call.
- IV4 — `0 ≤ PowerLevel ≤ MaxLevel` for every system at all times.
- IV5 — Single codebase builds unchanged for `GOOS=js GOARCH=wasm` and native targets; no `//go:build` directives, no platform-suffixed files.
- IV6 — HUD geometry constants live in `hud.go` and are referenced by both render and input paths; no duplication.
### Principles
- PC1 — Fail fast: invalid clicks (cap reached, reactor full, level zero, gutter) are silent no-ops, not partial state changes or wrap-arounds.
- PC2 — Tests cover pure logic (`addPower`, `removePower`, `reactorUsed`, `hudHitTest`); rendering and input wiring stay manual.
- PC3 — Separate data from rendering and input: `systems.go` doesn't know HUD pixel layout; `hud.go` doesn't know Ebitengine; `render.go` reads only.
### Assumptions
- AS1 — `inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight)` works under `GOOS=js GOARCH=wasm`. Documented to; left-click was verified in crew-movement.
- AS2 — `` is sufficient to suppress the browser context menu so right-click reaches the canvas. Standard Ebitengine WASM pattern.
- AS3 — The ship layout's bottom edge stays at virtual y ≤ 240, leaving the 272–360 band free for the HUD. True for `NewPlayerShip()` as defined; revisit if the ship grows.
### 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
## Verify
**Result:** passed
Positive:
- CK1 — `go test ./... -v` → 32/32 pass (9 systems + 5 hud + 18 pre-existing)
- CK2 — `go build ./...` → clean
- CK3 — `GOOS=js GOARCH=wasm go build` → clean (~12 MB)
- CK4 — `go vet ./...` → clean
- CK5 — Browser smoke at `http://localhost:8765/index.html` (clean reload): HUD renders `Reactor: 0/8`, 5 labelled columns (Pilot 1, Weap 4, Shld 4, Med 2, Eng 4), dim placeholder cells with white outlines, ship rooms rendered at unpowered (dim) tint
- CK6 — Visual confirmation that powered vs dim contrast is present (rooms render at ~60% brightness vs the documented `roleColor` values, matching `dimRoleColor`)
Negative:
- CK7 — `TestAddPower_systemAtCap` covers cap rejection (level unchanged, returns false)
- CK8 — `TestAddPower_reactorFull` covers reactor-full rejection (level unchanged, returns false)
- CK9 — `TestRemovePower_zeroLevel` covers underflow rejection
- CK10 — `TestHudHitTest_aboveHud` / `_leftOfColumns` / `_rightOfColumns` cover gutter / out-of-region returning ok=false
Invariants / assumptions:
- CK11 (IV1) — `grep "hajimehoshi\|inpututil" ship.go tiles.go crew.go systems.go hud.go` → empty
- CK12 (IV2) — `grep "PowerLevel\s*=\|\.PowerLevel++\|\.PowerLevel--" render.go` → empty (renderer reads only)
- CK13 (IV3, IV4) — `TestAddPower_invariantsHold` runs 50 round-robin add attempts, asserts `reactorUsed ≤ ReactorCap` and `0 ≤ PowerLevel ≤ MaxLevel` after every call → pass
- CK14 (IV5) — no `//go:build` directives, no platform-suffixed files; native + WASM both build clean
- CK15 (IV6) — HUD constants only defined in `hud.go:8-11`; referenced (not redefined) by `main.go:39,47,66` and `render.go:95,103,105,108,111,117`
- CK16 (AS1) — Right-click WASM behavior — see Notes (smoke deferred)
- CK17 (AS2) — `` present in `web/index.html:8`
- CK18 (AS3) — Ship max bottom edge: `ship.go` MedBay GridY=12 + GridH=3 = tile row 15 = pixel y=240, leaves 32-px gap to HudY=272
Interfaces:
- CK19 (IF1) — `addPower(g.systems, role, ReactorCap)` invoked at `main.go:42` with declared signature
- CK20 (IF2) — `removePower(g.systems, role)` invoked at `main.go:68` with declared signature
- CK21 (IF3) — `reactorUsed(sys)` invoked at `render.go:94` for the readout
- CK22 (IF4) — `hudHitTest(cx, cy)` invoked at `main.go:41, 67` with declared signature
- CK23 (IF5) — HUD constants consumed at the call sites listed in CK15
- CK24 (IF6) — `System` slice consumed by `Game.systems`; `NewStartingSystems()` invoked at `main.go:96`; `ReactorCap` consumed at `main.go:42` and `render.go:94`
Smoke: browser visual baseline at `Reactor: 0/8` confirms HUD and ship rendering. Click-driven state transitions exercised exhaustively by unit tests; full real-mouse interactive smoke deferred (see Notes).
Notes:
- Interactive click smoke (left-click → addPower, right-click → removePower, browser context-menu suppression) could not be driven via synthesized DOM events: PointerEvent / MouseEvent dispatched on the canvas from `evaluate_script` reach the canvas (verified via capture-phase listener) but do not appear to be picked up by Ebitengine's WASM input layer. The underlying logic is fully covered by 14 unit tests; AS1 and AS2 are deferred to a real-mouse user pass.
- UK1 resolved partially: dimming is in effect (factor 0.6 in `dimRoleColor`) but the visual contrast against the powered tint is subtle at 640×360. Reviewer / user may want to increase to 0.4 or add a "POWERED OFF" overlay tag if visibility matters during play.