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.
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):
y ∈ [HudY=272, 360) — 88 px tall, below the ship which ends at row 240.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.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):
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.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.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 <body oncontextmenu="return false"> so right-click works in browser without spawning the OS context menu.
Tradeoffs that settled the decisions.
[]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.hud.go (vs putting hit-test in render.go) — hit-testing is logic, not drawing. Mirrors the established tiles.go / render.go split.roleColor.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).
systems.go and hud.go must not import Ebitengine. Power state and HUD geometry are pure.addPower / removePower are the only mutators of System.PowerLevel. Renderer never writes to System.reactorUsed(sys) ≤ ReactorCap holds after every addPower / removePower call.0 ≤ PowerLevel ≤ MaxLevel for every system at all times.GOOS=js GOARCH=wasm and native targets; no //go:build directives, no platform-suffixed files.hud.go and are referenced by both render and input paths; no duplication.addPower, removePower, reactorUsed, hudHitTest); rendering and input wiring stay manual.systems.go doesn't know HUD pixel layout; hud.go doesn't know Ebitengine; render.go reads only.inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) works under GOOS=js GOARCH=wasm. Documented to; left-click was verified in crew-movement.<body oncontextmenu="return false"> is sufficient to suppress the browser context menu so right-click reaches the canvas. Standard Ebitengine WASM pattern.NewPlayerShip() as defined; revisit if the ship grows.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.
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 PowerLevels → 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.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.systems-power: System data + addPower/removePower with testshud_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.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.systems-power: HUD geometry + hudHitTest with testsrender.go (modify)
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.drawRoom(screen, r Room, powered bool) (signature change) — picks roleColor(r.Role) if powered, else dimRoleColor(r.Role). Border + label unchanged.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).func drawHud(screen *ebiten.Image, sys []System):
ebitenutil.DebugPrintAt(screen, fmt.Sprintf("Reactor: %d/%d", reactorUsed(sys), ReactorCap), 8, HudY+8).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.main.go (modify)
Game gains systems []System. Init in main() via NewStartingSystems().Update:
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.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.web/index.html (modify)
<body> → <body oncontextmenu="return false"> so right-click doesn't open the OS menu in the browser. Respects: AS2.systems-power: drawHud, room dimming, right-click inputPer 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:
MaxLevel: no change.ReactorCap=8: further left-clicks on any column become no-ops until power is removed elsewhere.addPower, removePower, reactorUsed, NewStartingSystems, ReactorCap, hudHitTest, HudY, HudX, HudColW, HudColCount.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).func addPower(sys []System, role RoomRole, reactorCap int) bool — defined in systems.go, consumed by main.go left-click handler.func removePower(sys []System, role RoomRole) bool — defined in systems.go, consumed by main.go right-click handler.func reactorUsed(sys []System) int — defined in systems.go, consumed by render.go:drawHud for the readout.func hudHitTest(px, py int) (RoomRole, bool) — defined in hud.go, consumed by main.go input handler.HudY, HudX, HudColW, HudColCount and ReactorCap — defined in hud.go / systems.go, consumed by render.go:drawHud and main.go input gate.type System struct{...} and func NewStartingSystems() []System — defined in systems.go, consumed by main.go (init + input) and render.go:drawShip / drawHud.Result: passed
Positive:
go test ./... -v → 32/32 pass (9 systems + 5 hud + 18 pre-existing)go build ./... → cleanGOOS=js GOARCH=wasm go build → clean (~12 MB)go vet ./... → cleanhttp://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) tintroleColor values, matching dimRoleColor)Negative:
TestAddPower_systemAtCap covers cap rejection (level unchanged, returns false)TestAddPower_reactorFull covers reactor-full rejection (level unchanged, returns false)TestRemovePower_zeroLevel covers underflow rejectionTestHudHitTest_aboveHud / _leftOfColumns / _rightOfColumns cover gutter / out-of-region returning ok=falseInvariants / assumptions:
grep "hajimehoshi\|inpututil" ship.go tiles.go crew.go systems.go hud.go → emptygrep "PowerLevel\s*=\|\.PowerLevel++\|\.PowerLevel--" render.go → empty (renderer reads only)TestAddPower_invariantsHold runs 50 round-robin add attempts, asserts reactorUsed ≤ ReactorCap and 0 ≤ PowerLevel ≤ MaxLevel after every call → pass//go:build directives, no platform-suffixed files; native + WASM both build cleanhud.go:8-11; referenced (not redefined) by main.go:39,47,66 and render.go:95,103,105,108,111,117<body oncontextmenu="return false"> present in web/index.html:8ship.go MedBay GridY=12 + GridH=3 = tile row 15 = pixel y=240, leaves 32-px gap to HudY=272Interfaces:
addPower(g.systems, role, ReactorCap) invoked at main.go:42 with declared signatureremovePower(g.systems, role) invoked at main.go:68 with declared signaturereactorUsed(sys) invoked at render.go:94 for the readouthudHitTest(cx, cy) invoked at main.go:41, 67 with declared signatureSystem slice consumed by Game.systems; NewStartingSystems() invoked at main.go:96; ReactorCap consumed at main.go:42 and render.go:94Smoke: 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:
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.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.Outcome: systems-power milestone shipped — Reactor + per-system power data, click-driven HUD allocation, dim-on-unpowered ship rooms, WASM right-click suppression — at HEAD 3144cda.
Invariants:
grep "hajimehoshi\|inpututil" ship.go tiles.go crew.go systems.go hud.go empty.grep "PowerLevel\s*=\|\.PowerLevel++\|\.PowerLevel--" render.go empty; addPower / removePower are the only mutators.TestAddPower_invariantsHold runs 50 round-robin add attempts and asserts reactorUsed ≤ ReactorCap and 0 ≤ PowerLevel ≤ MaxLevel after every call → green.//go:build, no platform-suffixed files; go build ./... and GOOS=js GOARCH=wasm go build both clean.HudY, HudX, HudColW, HudColCount defined only in hud.go:8-11; main.go and render.go reference, never redefine.removePower could not be exercised end-to-end. The data path is fully unit-tested; deferred to a real-mouse user pass.<body oncontextmenu="return false"> present at web/index.html:8; effect on a real browser context menu deferred to the same real-mouse pass as AS1.MedBay extends to tile-row 14, pixel y=240; HudY=272 leaves a 32-px gap.dimRoleColor returns 60% of the powered tint). At 640×360 the contrast between powered and unpowered rooms is subtle in the baseline screenshot. Visible but not strong; flagged for play-time follow-up.cellBottom = HudY + (VirtualH - HudY) - cellGap as loop-invariant inside drawHud's nested loops, framed as a mild IV6 violation. Resolved at 3144cda by hoisting to a file-scope const and simplifying the algebra. Pushed back on the strict IV6 framing: cellW, cellH, cellGap are also rendering-only HUD-cell visuals living in render.go; centralizing every implicit pixel calculation in hud.go would over-engineer for a single render-only use-site. IV6 protects the constants consumed by both render and input; those (HudY, HudX, HudColW, HudColCount) remain in hud.go.Verified by:
Reactor: 0/8 confirms HUD rendering, ship dimming, layout — the next reader should run the real-mouse smoke for AS1/AS2 before merge if interactive behavior matters for the merge gate.