~bigbes/game-prototype-ftl

ef9c93cb40971e289078f19b638960d6fa65a5c7 — Eugene Blikh a month ago aa11b70
docs(crew-movement): plan + resume summary
2 files changed, 132 insertions(+), 0 deletions(-)

M docs/tasks/crew-movement.md
A docs/tasks/summary-crew-movement-resume.md
M docs/tasks/crew-movement.md => docs/tasks/crew-movement.md +84 -0
@@ 47,3 47,87 @@ Second milestone of the `ftl-shape` vertical slice. Adds interactive crew: click
- Fail fast: unreachable paths return empty, not "closest-so-far". Clicking outside the ship is a no-op, not a teleport.
- Separate data from rendering and input — `crew.go` has no `ebiten.` imports; `render.go` never mutates crew state.
- Deterministic core: same input sequence and timestep → identical game state.

## Plan

Approach: three phases, TDD-first on the two pure units (tiles/pathing, then crew tick), then a single integration phase that wires input + rendering into `main.go` and `render.go`. Greenfield feature, no backwards-compat risk.

### Phase 1 — Walkability and pathfinding (TDD)

- **1.1** `tiles_test.go` (create) — tests first, must fail before 1.2.
  - `TestWalkableTiles_coversAllRooms` — `walkableTiles(NewPlayerShip())` returns the exact union of the 5 rooms (54 tiles total: 9+12+12+12+9); spot-check tiles inside each room are present and an off-ship tile is absent.
  - `TestWalkableTiles_ignoresOutside` — tiles at `(0,0)` and `(20,20)` are absent.
  - `TestBFSPath_sameTile` — `bfsPath(walk, {4,10}, {4,10})` → `nil`/empty.
  - `TestBFSPath_adjacentTile` — `bfsPath(walk, {4,10}, {5,10})` → `[[2]int{{5,10}}]`.
  - `TestBFSPath_unreachable` — from walkable tile to off-ship tile → empty.
  - `TestBFSPath_throughShields` — `bfsPath(walk, Pilot-center, Engines-center)` is non-empty, contains only walkable tiles, ends on the Engines tile, and length equals Manhattan distance (7 for `(4,10)→(11,10)`).
  - `TestBFSPath_deterministic` — two consecutive calls produce equal slices.

- **1.2** `tiles.go` (create)
  - `package main`; no Ebitengine imports.
  - `func walkableTiles(s Ship) map[[2]int]bool` — iterates `s.Rooms`, inserts every `(x, y)` with `GridX ≤ x < GridX+GridW`, `GridY ≤ y < GridY+GridH`.
  - `func bfsPath(walk map[[2]int]bool, from, to [2]int) [][2]int` — 4-neighbour BFS with fixed neighbour ordering `{{+1,0},{-1,0},{0,+1},{0,-1}}` (for determinism), visited-set, parent map; returns waypoints excluding `from`, including `to`. Empty on same-tile or unreachable.
  - Invariant: pure, no rendering/input deps; `bfsPath` deterministic via fixed neighbour order.

- Commit: `crew-movement: walkability + BFS pathfinding with tests`

### Phase 2 — Crew type and movement tick (TDD)

- **2.1** `crew_test.go` (create)
  - `TestNewStartingCrew_count` — returns 3 crew.
  - `TestNewStartingCrew_positions` — Alice at `(4,10)` (Pilot), Bob at `(7,10)` (Shields), Carol at `(11,10)` (Engines); initials `'A'`, `'B'`, `'C'`; distinct colours.
  - `TestUpdateCrew_idle` — empty `Path`, any `dt` → all fields unchanged.
  - `TestUpdateCrew_subTileProgress` — path `[[2]int{{5,10}}]`, `dt = 0.1` (< `TileTime`) → `TileX/TileY` unchanged, `MoveT ≈ 0.1/TileTime`, path unchanged.
  - `TestUpdateCrew_tileSnap` — path `[[2]int{{5,10}}]`, `dt = TileTime` → `TileX==5`, `Path` empty, `MoveT == 0`.
  - `TestUpdateCrew_multiStepOvershoot` — path `[{5,10},{6,10}]`, `dt = 1.5*TileTime` → `TileX==6`, remaining path `[{6,10}]` wait, check logic. Actually: start `(4,10)`, path `[{5,10},{6,10}]`; `dt=1.5*TileTime` means advance 1.5 tiles. After 1 tile: `(5,10)`, path `[{6,10}]`, `MoveT=0`. Remaining 0.5 tile into next: `MoveT≈0.5`, `TileX==5`, path `[{6,10}]`. Assert that final state.
  - `TestUpdateCrew_canonicalIntegral` — after any tick, `TileX` and `TileY` are integers equal to the last crossed waypoint (never fractional); `MoveT` is render-time only.

- **2.2** `crew.go` (create)
  - `package main`; imports `image/color` only (no Ebitengine).
  - `const TileTime = 0.35` — seconds per tile.
  - `type Crew struct { ID int; Name string; Initial rune; Color color.RGBA; TileX, TileY int; Path [][2]int; MoveT float64 }`.
  - `func NewStartingCrew() []Crew` — hard-coded 3 crew: Alice/Bob/Carol at tiles `(4,10)`, `(7,10)`, `(11,10)`, with distinct muted tints (teal, amber, magenta). Initials `'A'`, `'B'`, `'C'`.
  - `func updateCrew(c *Crew, dt float64)` — if `len(c.Path) == 0` return. Advance `c.MoveT += dt / TileTime`. While `c.MoveT >= 1 && len(c.Path) > 0`: snap `(c.TileX, c.TileY) = c.Path[0]`, `c.Path = c.Path[1:]`, `c.MoveT -= 1`. If `len(c.Path) == 0`, force `c.MoveT = 0`.
  - Invariants: `crew.go` does not import Ebitengine; `TileX/TileY` only mutated on waypoint crossing.

- Commit: `crew-movement: crew struct, starting roster, tick`

### Phase 3 — Input and rendering integration

- **3.1** `render.go` (modify — append, no changes to existing code)
  - Add `const CrewRadius = 6` (12 px diameter at `TilePx=16`).
  - Add `func drawCrew(screen *ebiten.Image, c Crew, selected bool)`:
    - Compute interpolated pixel centre: if `len(c.Path) > 0`, `(cx, cy) = lerp((TileX,TileY), Path[0], MoveT)` in tile units; else `(TileX, TileY)`. Multiply by `TilePx`, add `TilePx/2` for tile centre.
    - `vector.DrawFilledCircle(screen, px, py, CrewRadius, c.Color, true)` (antialiased so placeholder doesn't look too harsh; everything else stays non-AA).
    - If `selected`, draw a 1-px white ring: `vector.StrokeCircle(screen, px, py, CrewRadius+1, 1, white, true)`.
    - Label: `ebitenutil.DebugPrintAt(screen, string(c.Initial), int(px)-2, int(py)-5)`.
  - Invariant: reads `Crew`, does not mutate; uses only virtual coords through `TilePx`.

- **3.2** `main.go` (modify)
  - Add imports: `time`.
  - Extend `Game`: `walk map[[2]int]bool`, `crew []Crew`, `selectedCrew int`, `lastTime time.Time`.
  - `main()`: after `NewPlayerShip()`, compute `walk := walkableTiles(ship)`, `crew := NewStartingCrew()`; initialise `Game{ship, walk, crew, -1, time.Time{}}`.
  - `Update()`:
    - First call: `lastTime = time.Now()`, return nil.
    - Else: `dt := time.Since(g.lastTime).Seconds(); g.lastTime = time.Now()`. Clamp `dt` to a sane ceiling (e.g. `0.1`) to avoid huge jumps on resume.
    - For each crew: `updateCrew(&g.crew[i], dt)`.
    - Poll left-click: `if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft)` (new import `inpututil`) — read `cx, cy := ebiten.CursorPosition()` (already in virtual coords). Derive tile `tx, ty := cx/TilePx, cy/TilePx`.
    - Selection: iterate crew; if cursor is within `CrewRadius` of crew centre (tile-space check is sufficient: same tile or adjacent-and-close), set `g.selectedCrew = i` and return.
    - Otherwise, if `g.selectedCrew >= 0` and `g.walk[[2]int{tx,ty}]`: issue move — `from := [2]int{crew.TileX, crew.TileY}; crew.Path = bfsPath(g.walk, from, [2]int{tx,ty}); crew.MoveT = 0`.
  - `Draw()`: after `drawShip`, iterate crew and call `drawCrew(screen, c, i == g.selectedCrew)`.
  - Invariants: virtual-coord drawing only; single codebase WASM-safe (`time.Since` and `inpututil` both work under `GOOS=js`).

- Commit: `crew-movement: wire input, tick, and render`

### Test strategy

Per task `TDD: yes` — tests from Phase 1 and Phase 2 written first, must fail on the pre-implementation tree, then green after the paired source file. Phase 3 has no automated tests (input and rendering need real mouse/display); acceptance is manual via `go run .` and `make play-web`:
- Click each crew in turn: ring appears, persists until another crew or empty click.
- Click a destination tile on a selected crew: crew walks there tile-by-tile, ring follows them.
- Click an unreachable tile (e.g. outside ship): no-op.
- Clicking a new destination on an already-moving crew: path replaces, no crash.

### Open questions / risks

- `inpututil` is a standard Ebitengine subpackage that works under WASM; no conditional imports needed. If it doesn't, Phase 3 falls back to `ebiten.IsMouseButtonPressed` + manual edge detection.
- Mouse coords in the browser: `ebiten.CursorPosition()` is documented to return logical-space coords via `Layout`; this is how WASM-served Ebitengine games handle input. If verify finds the browser returns raw canvas pixels, the fix is a single `(windowW, windowH) := ebiten.WindowSize()` scale step.

A docs/tasks/summary-crew-movement-resume.md => docs/tasks/summary-crew-movement-resume.md +48 -0
@@ 0,0 1,48 @@
# summary-crew-movement-resume

Handoff summary written 2026-04-22. Resumes the `crew-movement` milestone after the plan stage.

## Goal

Building `ftl-shape`, an FTL-like game in Go + Ebitengine (virtual resolution 640×360 at a 1280×720 window) that compiles to native desktop and browser WASM. Vertical slice scope: one ship, one combat encounter, pixel-art placeholders.

## Current state

- `skeleton` milestone **complete and shipped** through the full `up:` workflow (Design → Plan → Execute → Verify → Review). Merge-ready, no findings at confidence ≥80. Native and WASM both verified rendering the 5-room placeholder ship.
- `crew-movement` milestone **in progress**: Design committed at `aa11b70`; Plan written but **uncommitted** in `docs/tasks/crew-movement.md` (git status: `M`). Waiting on user approval before `up:uexecute`.
- 6 commits on `main`; no remote.
- Project memory: `~/.claude/projects/-Users-blikh-data-home-ftl-shape/memory/project_ftl_shape.md`.

## Infrastructure / Environment

Purely local. Go 1.26.2 at `/opt/homebrew/Cellar/go/1.26.2/libexec`. No remote work, no running background processes, no tmux.

## Active blocker

None. Workflow is paused at "plan presented, awaiting approval". Resume with execute.

## Key files

- `docs/tasks/skeleton.md` — done; Design / Plan / Verify / Conclusion all filled.
- `docs/tasks/crew-movement.md` — Design + Plan present; Plan uncommitted.
- `main.go`, `ship.go`, `render.go` — current skeleton code.
- `Makefile` — targets `run`, `build`, `build-wasm`, `serve`, `play-web`.
- `web/index.html`, `.gitignore` — WASM shell and ignore rules.

## What to do next

1. `git -C /Users/blikh/data/home/ftl-shape diff docs/tasks/crew-movement.md` → review the Plan section.
2. On approval: `git -C /Users/blikh/data/home/ftl-shape add docs/tasks/crew-movement.md && git commit -m "docs(crew-movement): plan"`.
3. Invoke `up:uexecute`. The plan dispatches 3 phases: tiles + BFS (TDD) → crew struct + tick (TDD) → input & render integration.
4. **Model**: let `up:implementer` and `up:reviewer` use their default (Sonnet 4.6 — what those agents are designed for). If dispatch errors with `"model ... may not exist or you may not have access to it"`, fall back to `model: "opus"`. That fallback worked every time this session after one transient Sonnet failure; Sonnet is still worth trying first.
5. After execute: `up:uverify` → `up:ureview`.
6. Remaining milestones designed but not yet started: `systems-power`, `combat`, `polish`.

## Gotchas

- Subagent model: try Sonnet default first; fall back to `model: "opus"` only if dispatch errors with model-access failure. See step 4.
- Go ≥1.24 moved `wasm_exec.js` to `$(go env GOROOT)/lib/wasm/wasm_exec.js`; the Makefile uses the new path. Older toolchains will fail loudly (acceptable per Design's fail-fast principle).
- `go build ./...` from the repo root leaves a bare `./ftl-shape` binary. `.gitignore` already covers it along with `bin/`, `web/main.wasm`, `web/wasm_exec.js`.
- Crew-movement plan uses `inpututil.IsMouseButtonJustPressed` + `ebiten.CursorPosition()`. Ebitengine docs state `CursorPosition` returns logical (post-`Layout`) coords, so no manual window → virtual scaling should be needed. Flagged as a browser verify-time smoke in the plan's "Open questions / risks" section — if it misbehaves, scale via `ebiten.WindowSize()`.
- Reviewer noted during skeleton review that Design prose mentions `text/v2` for labels while code uses `ebitenutil.DebugPrintAt`; sub-threshold, deferred as future polish. Crew labels will follow the same convention for consistency.
- The `skeleton` Verify flow includes a live Chrome smoke via the `mcp__chrome-devtools__*` tools plus `python3 -m http.server 8765` in `web/`. Same pattern works for verifying crew-movement's browser behaviour.