diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index c027ccf..c42259e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,58 +1,44 @@ -name: Ship It +name: cicd + on: - push: - branches: [main] pull_request: - branches: [main] + types: [opened, synchronize, reopened, closed] -# Amali 5: permission untuk deploy GitHub Pages -permissions: - pages: write - id-token: write - contents: read - -concurrency: - group: pages - cancel-in-progress: true - -# The ship app lives in launchpad/ in the learner fork; run every step there. defaults: run: working-directory: launchpad jobs: test: + if: github.event.pull_request.merged == false runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 - with: - node-version: 20 - cache: npm - cache-dependency-path: launchpad/package-lock.json - run: npm clean-install - - run: npm run test # node scripts/preflight.mjs — a bad ship.config.json ABORTS here + - run: npm run test + - run: bash scripts/report.sh + env: + BOARD_URL: ${{ vars.BOARD_URL }} + SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} deploy: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: test # deploy only runs if the pre-flight gate is green + needs: test + if: always() && github.event.pull_request.merged == true runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 - with: - node-version: 20 - cache: npm - cache-dependency-path: launchpad/package-lock.json - run: npm clean-install - run: npm run build env: + VITE_BOARD_URL: ${{ vars.BOARD_URL }} VITE_CALLSIGN: ${{ github.actor }} - uses: actions/upload-pages-artifact@v5 with: path: launchpad/dist - - id: deploy - uses: actions/deploy-pages@v5 + - uses: actions/deploy-pages@v5 + +permissions: + pages: write + id-token: write diff --git a/CLAUDE.md b/CLAUDE.md index 824d6f9..1fac829 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,8 +40,8 @@ Distinct from its siblings (do not blur them): Pages; extensible to CF Pages). **No image** — the ship is static-only. Three.js, like `devops-bootcamp-app`. - **`shipit-board` is dual-role:** the *shared* Mission Control on instructor EC2, **and** the artifact each learner **builds + deploys to their own EC2** in the S4 capstone. -- The freed `shipit-launchpad` name is reused for the **forkable learner repo** - `Infratify/shipit-launchpad` (see Distribution below), not an image. +- Learners fork **this monorepo** (`Infratify/devops-bootcamp-shipit`) and work in `launchpad/` + (see Distribution below). The planned payload-only `shipit-launchpad` release repo was never used. ## PINNED — the 4-session arc (lean: one concept per session) @@ -64,28 +64,59 @@ already did `docker build` + ECR by hand in the AWS sessions — S4 *automates t The one integration point. Keep it stable; slides and the reference workflows depend on it. - **Identity** = the learner's GitHub username (`${{ github.actor }}`), used as `callsign`. -- **Config** the board needs comes from the learner's `ship.config.json`: `color` (hex **or** a - named-palette colour; sets the ship's hue — every saturated texel takes that hue; greys/blacks stay - neutral — and drives the UI accent) and `shipModel` (which of the 4 ships the board renders in - orbit); `shipName` is a cosmetic label, not identity. -- **Transport:** each workflow stage POSTs one event to the board. +- **Config:** the report script reads `color` + `shipModel` from the learner's `ship.config.json`, + so the board renders each learner's real colour AND model (hex or a named-palette colour; board + normalizes, greys/blacks stay neutral). `shipName` is cosmetic, never identity. +- **Transport:** `launchpad/scripts/report.sh` (shipped in this repo — learners call it, never write + it) POSTs one event per invocation. The learner workflow adds ONE step (CI/CD 3 Amali 2) — a + **single liftoff report** after the Pages deploy: + +```yaml +- name: Lapor ke papan + run: bash scripts/report.sh + env: + BOARD_URL: ${{ vars.BOARD_URL }} + SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} +``` + + That `env:` block IS the lesson surface — one `vars` line, one `secrets` line, nothing else. The + HTTP mechanics live in the script. Extra beats (`report.sh pad running`, abort-on-failure) are + optional operator flourishes, never required of learners. + +What the script sends (slides show this as *anatomy* — learners read it, never type it): ``` POST $BOARD_URL/api/event Authorization: Bearer $SHIPIT_TOKEN Content-Type: application/json -{ - "callsign": "octocat", // GitHub username - "stage": "build", // pad | build | test | clearance | liftoff - "status": "passed", // running | passed | failed | aborted | shipped - "color": "#22d3ee", // hex or a colour name (e.g. "red"); board normalizes → hex, sets the ship's hue - "shipModel":"fighter", // from ship.config.json: fighter · interceptor · hauler · scout - "version": "v3", // optional; image/site tag (for rollback demo) - "siteUrl": "https://…" // optional; the live deployed site to link from orbit -} +{ "callsign": "octocat", "stage": "liftoff", "status": "shipped", + "color": "#22d3ee", "shipModel": "fighter", + "siteUrl": "https://octocat.github.io/devops-bootcamp-shipit/" } ``` +`callsign` = `GITHUB_ACTOR` (Actions sets it automatically — no templating in the learner file); +`color`/`shipModel` read from `ship.config.json`; stage/status from the script args (default +`liftoff shipped`). `siteUrl` is **derived inside `report.sh`** from the vars Actions already sets +(`GITHUB_REPOSITORY_OWNER` + repo name → `https://.github.io[/]/`) — NOT from the taught +`env:` block, which stays the pinned two lines. Omitted when the script runs outside Actions. + +- **Board accepts more than the taught report** (operator flourishes only, never asked of learners): + `stage` ∈ `pad | build | test | clearance | liftoff`, `status` ∈ `running | passed | failed | + aborted | shipped`, plus optional `version`, `siteUrl`. Required: just `callsign` + a known + `stage`/`status` — `color`/`shipModel` default when absent or invalid (see `board/src/room.js`). +- **`siteUrl` → LIVE (green).** When an event carries a `siteUrl`, the board probes it (`HEAD`, on + arrival + a periodic sweep) and broadcasts a per-ship `live` boolean on the roster; a ship shows a + green **LIVE** halo only when its *real* Pages site answers `200` (see `board/src/liveness.js`). + This ties the token + report to the actual deploy — a reported ship that never went live stays + neutral. A fresh deploy can 404 for ~1 min; the periodic re-check flips it green when the site + actually comes up. Non-200/timeout = "not live yet," never an error. +- **The script's `curl` uses `--fail-with-body`** (pinned in `report.sh`'s header too): on a 401 + the step **fails** (non-zero exit → red run) AND the run log prints `{"error":"unauthorized"}`, + so the wrong-token demo shows both the red X and the reason. Changed 2026-07-20 (was: no `-f`, + stay-green — the shape CI/CD 3 Amali 3 was delivered with). Plain `-f` would swallow the + response body — don't switch to it. + - `$BOARD_URL` is a **public** repo/environment **variable**. - `$SHIPIT_TOKEN` is the **secret** taught in CI/CD 3 — a ship with no/late token can't report to Mission Control (the "unauthorized" lesson). Do NOT accept unauthenticated events in prod mode. @@ -105,15 +136,77 @@ Frozen — slides quote these verbatim. (recolours the ship — sets its hue to `color`; every saturated texel takes that hue, greys/blacks stay neutral — and drives the UI accent) · `shipModel` ∈ `fighter · interceptor · hauler · scout` · `emblem` ∈ `comet · bolt · star · ring · delta · phoenix`. `callsign` is **not** in config — it's the GitHub - username, injected via `VITE_CALLSIGN` at build. + username. On the board it comes from `${{ github.actor }}` in the report step. The site *can* + display it via `VITE_CALLSIGN` at build, but **the taught workflow never sets it** — the app + falls back to empty (`launchpad/src/main.js`); don't assume the microsite shows a callsign. - The ship is one of four low-poly spaceships (Quaternius, CC0), hue-set by `color`; the site and board both render whichever `shipModel` the learner picked. -- **The S2 fitness gate** is a config **validation** check (not a unit test): `npm test` → +- **The S2 fitness gate** is a config **validation** check (not a unit test): taught as + `npm run test` (in `launchpad/`, via the workflow's `defaults.run.working-directory`) → `node scripts/preflight.mjs` validates `ship.config.json` and **exits non-zero (ABORT)** on a bad config (unparseable, bad hex, unknown emblem, over-long name). Teaches the *exit-code gate* (a DevOps skill), not test authoring (a developer skill). -- **Reference workflows** live as the `cicd1..4` **answer-key branches** on `Infratify/shipit-launchpad` - (each = the full correct state at that session's end); slides lift the `deploy.yml` from them. +- **The slides are the source of truth for the workflow** — learners build `deploy.yaml` from the + building blocks on the slides, nothing else. The authored answer keys (`starter/workflows/`) were + retired 2026-07-17: learners shipped a simpler file than they prescribed, and the extra plumbing + (config extraction, pad/abort beats) never earned its place *in the learner's workflow* — it now + lives in the prop's `launchpad/scripts/report.sh` instead, behind a two-line `env:` mapping that + doubles as the secrets/vars demo surface. A session's reference state is *derived* by running its + amali on a test fork (see Distribution). + +**Taught workflow — end of S3.** Snapshot derived from the delivered decks 2026-07-17, NOT a spec — +regenerate from the slides if in doubt. Filename is `.github/workflows/deploy.yaml` (set in CI/CD 1: +`.yaml`, not `.yml`); `permissions` sits at the bottom because that's where CI/CD 1 adds it; S1's +`workflow_dispatch` was dropped when S3 rewrote `on:`: + +```yaml +name: deploy +on: + push: + branches: [main] + pull_request: + +defaults: + run: + working-directory: launchpad + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + - run: npm clean-install + - run: npm run test + + deploy: + needs: test + if: github.event_name == 'push' + environment: production + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + - run: npm clean-install + - run: npm run build + - uses: actions/upload-pages-artifact@v5 + with: + path: launchpad/dist + - uses: actions/deploy-pages@v5 + - name: Lapor ke papan + run: bash scripts/report.sh + env: + BOARD_URL: ${{ vars.BOARD_URL }} + SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} + +permissions: + pages: write + id-token: write +``` + +No `contents: read` — checkout still works because learner forks are public. The deploy job binds +`environment: production` (a learner-created environment with Required reviewers), NOT the +conventional `github-pages` environment. S4 extends this file; it must not restructure it. - **Per-session commands** (kelas-taip-bersama): fork → author `deploy.yml` step-by-step per session → `git push` → watch. Full list in the spec §7. - **Slides drift note:** the bootcamp slides repo (`~/repo/slides-devops-bootcamp`) quotes the two @@ -122,13 +215,19 @@ Frozen — slides quote these verbatim. ## PINNED — learner distribution (fork, not template) -- Learners **fork** `Infratify/shipit-launchpad`. Branches: **`main`** = payload only - (`ship/` + `ship.config.json` + `preflight`, **no workflow, no `board/`**); **`cicd1..4`** = answer - keys (`board/` enters at `cicd4`). -- **The learner authors the workflow** — `.github/workflows/deploy.yml` is NOT shipped; they write - it, it grows one job per session. That is the lesson. -- **Discipline rule (load-bearing):** upstream `main` must never gain a workflow or re-touch - `ship.config.json` after release, so learner **sync-fork** stays conflict-free. +- Learners **fork** `Infratify/devops-bootcamp-shipit` (this monorepo) and work in `launchpad/` + (workflow steps use `working-directory: launchpad`). The payload-only `shipit-launchpad` release + repo + its build scripts (`scripts/release-launchpad.sh` & co.) were retired 2026-07-17, never used. +- **The learner authors the workflow** — `.github/workflows/deploy.yml` is NOT shipped on `main`; + they write it from the slide building blocks, and it grows each session. That is the lesson. +- **`cicdN` reference branches** (recovery/diff aid, not a spec): before each session the operator + follows that session's amali verbatim on a test fork — proving the slides run green — and pushes + the resulting state as branch `cicdN` on `Infratify/devops-bootcamp-shipit`. +- **CI/CD 3 operator dep:** push `launchpad/scripts/report.sh` to upstream `main` before class + (new file — sync-fork safe); learners fetch it with `gh repo sync` + `git pull` at the start of + Amali 2 — the first live use of the fork model's "sync for instructor fixes" promise. +- **Discipline rule (load-bearing):** upstream `main` must never gain `.github/workflows/` or + re-touch `launchpad/ship.config.json`, so learner **sync-fork** stays conflict-free. ## Conventions @@ -140,7 +239,7 @@ Frozen — slides quote these verbatim. ## Bootcamp integration (context; the arc itself lives in the slides repo) -`~/repo/slides-devops-bootcamp` → `outlines/2026/cicd1..4.md` + `slides/2026/cicd1..4/`. The +`~/repo/slides-devops-bootcamp` → `outlines/2026/ci-cd1..4.md` + `slides/2026/ci-cd1..4/`. The `$SHIPIT_TOKEN` is the CI/CD 3 secret; the **S4 deploy has the learner's pipeline build the `board` image, push it to their GHCR, and deploy it to their own EC2 (from AWS 2) via SSM** — with a rollback demo (redeploy the previous tag) as stretch. The instructor's shared board runs on diff --git a/README.md b/README.md index 06beb9a..8980485 100644 --- a/README.md +++ b/README.md @@ -13,29 +13,23 @@ CI/CD-native. ## Status -🚧 **Scaffold only.** This repo currently holds the brief, not the build. +**Built and in use.** Learners fork this repo in CI/CD 1 and grow `.github/workflows/deploy.yaml` +one session at a time, from the building blocks on the slides — the workflow file is deliberately +NOT shipped here. `CLAUDE.md` holds the design rationale + the pinned contracts, including a +snapshot of the taught workflow. -- **`PROMPT.md`** — kickoff brief. Open a fresh Claude session here and start from it. -- **`CLAUDE.md`** — design rationale + the pinned pipeline↔board contract. - -## Layout (target) +## Layout ``` -board/ Mission Control — ws hub + Three.js projector spectator → ghcr.io/infratify/shipit-board (:3000) -launchpad/ the learner ship microsite template (build/test/Dockerfile) → ghcr.io/infratify/shipit-launchpad (:8080) -scripts/ e2e.sh — full loop proof (to be written) -docs/ specs + plans +board/ Mission Control — ws hub + Three.js projector spectator → ghcr.io/infratify/shipit-board (:3000) +launchpad/ the learner ship microsite — static Vite + Three.js, ships to GitHub Pages (no image) ``` ## The 4-session arc it serves | Session | Launch phase | Teaches | |---|---|---| -| CI/CD 1 | Pad live on Pages | first workflow · trigger · job/step/runner | -| CI/CD 2 | Fuelling + systems check | build · test gate (red = ABORT) · matrix · artifact | -| CI/CD 3 | Launch clearance | secrets · environment · manual approval | -| CI/CD 4 | LIFTOFF → orbit | image → GHCR → deploy to EC2 · tags · rollback | - -## Next step - -Read `PROMPT.md`, then brainstorm → spec → plan before writing code. +| CI/CD 1 | Pad live on Pages | first workflow · trigger · job/step/runner · Pages deploy | +| CI/CD 2 | Systems check | test gate (red = ABORT) · `needs` · branch protection | +| CI/CD 3 | First contact + clearance | secret vs variable · report to Mission Control · push/PR trigger split · manual approval | +| CI/CD 4 | LIFTOFF → orbit | pipeline builds the board image → GHCR → deploy to your EC2 | diff --git a/board/Dockerfile b/board/Dockerfile index d0911fd..1a78b0d 100644 --- a/board/Dockerfile +++ b/board/Dockerfile @@ -5,6 +5,8 @@ COPY package.json package-lock.json ./ RUN npm ci COPY vite.config.js ./ COPY client ./client +# client/ship-mesh.js imports ../src/ships.js — the client shares it with the server +COPY src ./src RUN npm run build # → /app/dist # --- runtime --- diff --git a/board/client/fallback.js b/board/client/fallback.js index c4c86f4..a90aa35 100644 --- a/board/client/fallback.js +++ b/board/client/fallback.js @@ -24,6 +24,7 @@ export function createFallback(container) { @${escapeHtml(s.callsign)} ${escapeHtml(s.stage)} · ${escapeHtml(s.status)} ${escapeHtml(s.shipModel || '')} + ${s.live ? 'LIVE' : ''} `).join(''); }, dispose() { el.remove(); }, diff --git a/board/client/index.html b/board/client/index.html index 8c5a794..c39b2d1 100644 --- a/board/client/index.html +++ b/board/client/index.html @@ -17,6 +17,7 @@

Mission Control

+
diff --git a/board/client/main.js b/board/client/main.js index a17f19f..51f9fd5 100644 --- a/board/client/main.js +++ b/board/client/main.js @@ -1,19 +1,24 @@ import './style.css'; import { createScene } from './scene.js'; +import { createRaceTrack } from './race-track.js'; import { createFallback, detectWebGL, shouldUseFallback } from './fallback.js'; const app = document.getElementById('app'); const count = document.getElementById('count'); const toasts = document.getElementById('toasts'); +const hud = document.getElementById('race-hud'); +const orbitHud = document.getElementById('hud'); +const hudClients = document.getElementById('hud-clients'); const gl = detectWebGL(); const mql = window.matchMedia('(prefers-reduced-motion: reduce)'); -let lastShips = []; -let view = makeView(shouldUseFallback({ gl, reducedMotion: mql.matches })); +let lastShips = []; // roster (orbit) +let lastRace = { phase: 'idle', total: 12, ships: [] }; // race state (rows view) +let mode = 'orbit'; // 'orbit' | 'race' +let view = makeOrbit(shouldUseFallback({ gl, reducedMotion: mql.matches })); function showLiftoff(callsign) { if (!toasts) return; - // Cap the stack so a class-end launch burst can't run toasts off-screen over the scene. while (toasts.children.length >= 5) toasts.firstChild.remove(); const el = document.createElement('div'); el.className = 'toast'; @@ -23,22 +28,34 @@ function showLiftoff(callsign) { setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 400); }, 3000); } -function makeView(useFallback) { +function makeOrbit(useFallback) { const v = useFallback ? createFallback(app) : createScene(app, { onLiftoff: showLiftoff, - onPreloadError: () => { - view.dispose(); - view = createFallback(app); - view.update(lastShips); - }, + onPreloadError: () => { view.dispose(); view = createFallback(app); view.update(lastShips); }, }); v.update(lastShips); return v; } +function makeRace() { + const v = createRaceTrack(app); // its own fallback: DOM-only, reduced-motion via CSS + v.update(lastRace); + return v; +} + +function setMode(next) { + if (next === mode) return; + view.dispose(); + mode = next; + // The orbit HUD legend occludes the top rows at 40-ship density in race mode. + if (mode === 'race') { view = makeRace(); if (hud) hud.hidden = false; if (orbitHud) orbitHud.hidden = true; } + else { view = makeOrbit(shouldUseFallback({ gl, reducedMotion: mql.matches })); if (hud) hud.hidden = true; if (orbitHud) orbitHud.hidden = false; } +} + mql.addEventListener('change', (e) => { + if (mode !== 'orbit') return; // race track handles reduced motion in CSS view.dispose(); - view = makeView(shouldUseFallback({ gl, reducedMotion: e.matches })); + view = makeOrbit(shouldUseFallback({ gl, reducedMotion: e.matches })); }); window.addEventListener('pagehide', () => view.dispose()); @@ -48,8 +65,13 @@ function connect() { let m; try { m = JSON.parse(e.data); } catch { return; } if (m.t === 'roster' && Array.isArray(m.ships)) { lastShips = m.ships; - view.update(lastShips); + if (mode === 'orbit') view.update(lastShips); if (count) count.textContent = `${lastShips.length} ship${lastShips.length === 1 ? '' : 's'}`; + } else if (m.t === 'race') { + lastRace = { phase: m.phase, total: m.total, ships: m.ships || [] }; + setMode(m.view === 'race' ? 'race' : 'orbit'); + if (mode === 'race') view.update(lastRace); + if (hudClients) hudClients.textContent = String(m.clients ?? 0); } }; ws.onclose = () => setTimeout(connect, 1000); diff --git a/board/client/operator.css b/board/client/operator.css new file mode 100644 index 0000000..37615a2 --- /dev/null +++ b/board/client/operator.css @@ -0,0 +1,12 @@ +:root { color-scheme: dark; } +body { margin: 0; background: #0b1220; color: #e2e8f0; font: 16px/1.5 ui-monospace, Menlo, Consolas, monospace; } +#ops { max-width: 420px; margin: 0 auto; padding: 2rem 1rem; display: flex; flex-direction: column; gap: 1rem; } +h1 { font-size: 1.1rem; letter-spacing: 0.1em; color: #22d3ee; margin: 0; } +label { display: flex; flex-direction: column; gap: 0.3rem; color: #94a3b8; font-size: 0.9rem; } +input, select { font: inherit; padding: 0.5rem 0.75rem; background: #0f172a; color: #e2e8f0; border: 2px solid #334155; border-radius: 8px; } +input:focus, select:focus { outline: none; border-color: #22d3ee; } +.buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; } +button { font: inherit; padding: 0.75rem; background: #111a2e; color: #e2e8f0; border: 2px solid #334155; border-radius: 8px; cursor: pointer; } +button:hover { border-color: #22d3ee; } +#start { grid-column: 1 / -1; border-color: #22d3ee; color: #22d3ee; } +#result, #live { margin: 0; color: #94a3b8; min-height: 1.5em; } diff --git a/board/client/operator.html b/board/client/operator.html new file mode 100644 index 0000000..7e684a8 --- /dev/null +++ b/board/client/operator.html @@ -0,0 +1,30 @@ + + + + + + Operator — Ship It + + + +
+

MISSION CONTROL · OPS

+ + +
+ + + + +
+

+

connecting…

+
+ + + diff --git a/board/client/operator.js b/board/client/operator.js new file mode 100644 index 0000000..3ff53d0 --- /dev/null +++ b/board/client/operator.js @@ -0,0 +1,57 @@ +// board/client/operator.js +// Instructor console: drives the three operator endpoints (key-guarded HTTP +// POSTs) and mirrors the public race broadcast read-only. The key lives in +// localStorage on the operator's device — classroom-grade, rotate per cohort. +import './operator.css'; + +const keyEl = document.getElementById('key'); +const sessionEl = document.getElementById('session'); +const resultEl = document.getElementById('result'); +const liveEl = document.getElementById('live'); + +const storage = { + get() { try { return localStorage.getItem('shipit-operator-key') || ''; } catch { return ''; } }, + set(v) { try { localStorage.setItem('shipit-operator-key', v); } catch { /* storage locked — key lives only in the field */ } }, +}; +keyEl.value = storage.get(); +keyEl.oninput = () => storage.set(keyEl.value); + +async function call(path, body) { + resultEl.textContent = `${path} …`; + try { + const res = await fetch(path, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${keyEl.value}` }, + body: JSON.stringify(body || {}), + }); + resultEl.textContent = + res.status === 202 ? `${path} → 202 ✓` + : res.status === 401 ? `${path} → 401 wrong key` + : `${path} → ${res.status}`; + } catch (err) { + resultEl.textContent = `${path} → ${err.message}`; + } +} + +const startBtn = document.getElementById('start'); +startBtn.onclick = () => call('/api/race/start', { session: sessionEl.value }); +document.getElementById('reset').onclick = () => call('/api/race/reset'); +document.getElementById('view-orbit').onclick = () => call('/api/view', { view: 'orbit' }); +document.getElementById('view-race').onclick = () => call('/api/view', { view: 'race' }); + +function connect() { + const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`); + ws.onmessage = (e) => { + let m; try { m = JSON.parse(e.data); } catch { return; } + if (m.t === 'race') { + liveEl.textContent = `phase: ${m.phase} · racers: ${(m.ships || []).length} · viewers: ${m.clients ?? 0}`; + // Starting mid-round zeroes the server while cockpits keep optimistic + // positions — a wedged round; RESET first (RESET stays always enabled, + // it's the escape hatch). + startBtn.disabled = m.phase === 'running'; + } + }; + ws.onclose = () => setTimeout(connect, 1000); + ws.onerror = () => ws.close(); +} +connect(); diff --git a/board/client/play.css b/board/client/play.css new file mode 100644 index 0000000..6f1774b --- /dev/null +++ b/board/client/play.css @@ -0,0 +1,54 @@ +:root { color-scheme: dark; } +html, body { height: 100%; } +body { margin: 0; background: #0b1220; color: #e2e8f0; font: 16px/1.4 ui-monospace, Menlo, Consolas, monospace; } +#cockpit { height: 100dvh; display: flex; flex-direction: column; } +#field { flex: 1; min-height: 0; } +#dock { + border-top: 1px solid #1e293b; + width: 100%; max-width: 760px; margin: 0 auto; box-sizing: border-box; + padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom)); + display: flex; flex-direction: column; gap: 0.5rem; +} +#statusrow { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; } +#status { color: #94a3b8; margin: 0; } +#mute { background: none; border: none; padding: 0; cursor: pointer; font-size: 1rem; opacity: 0.7; } +#mute:hover { opacity: 1; } +/* The terminal: one line, greyed-out command lighting up as you type it. */ +#term { position: relative; background: #020617; border: 1px solid #1e293b; border-radius: 8px; overflow: hidden; } +#term[data-done='1'] { border-color: #22d3ee; box-shadow: 0 0 0 1px #22d3ee; } +#termbar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; background: #0f172a; border-bottom: 1px solid #1e293b; } +#termbar .dot { width: 10px; height: 10px; border-radius: 50%; } +.dot.r { background: #7f1d1d; } +.dot.y { background: #713f12; } +.dot.g { background: #14532d; } +#termtitle { margin-left: 8px; color: #64748b; font-size: 0.75rem; } +/* One command, one line — sized so the longest story command (55 chars with + the $ prompt) fits the dock without wrapping. */ +#line { margin: 0; padding: 0.9rem 1rem 1.1rem; font-size: clamp(0.75rem, 2.1vw, 1.15rem); line-height: 1.5; white-space: pre; overflow: hidden; } +#term:not([data-active='1']) #line { opacity: 0.45; } +#ps1 { color: #4ade80; } +#typed { color: #f1f5f9; } +#rest { color: #475569; } +#caret { + display: inline-block; width: 0.6ch; height: 1.2em; margin: 0 1px; + background: #22d3ee; vertical-align: text-bottom; + animation: caret-blink 1s steps(1) infinite; +} +#term[data-done='1'] #caret { background: #4ade80; } +#term:not([data-active='1']) #caret { visibility: hidden; } +#caret.err { background: #f43f5e; animation: none; } +@keyframes caret-blink { 50% { opacity: 0; } } +#term.shake { animation: term-shake 0.25s; } +@keyframes term-shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } +} +/* The input only exists to catch keystrokes (and summon mobile keyboards); + the terminal line above is the visible surface. Not display:none — iOS + won't focus/keyboard an undisplayed input. */ +#entry { position: absolute; opacity: 0; left: 0; bottom: 0; width: 1px; height: 1px; border: 0; padding: 0; } +@media (prefers-reduced-motion: reduce) { + #term.shake { animation: none; } + #caret { animation: none; } +} diff --git a/board/client/play.html b/board/client/play.html new file mode 100644 index 0000000..06d452a --- /dev/null +++ b/board/client/play.html @@ -0,0 +1,29 @@ + + + + + + Cockpit — Ship It + + + +
+
+
+
+

connecting…

+ +
+
+
+ + shipit — cockpit +
+
$ 
+ +
+
+
+ + + diff --git a/board/client/play.js b/board/client/play.js new file mode 100644 index 0000000..23eba5e --- /dev/null +++ b/board/client/play.js @@ -0,0 +1,154 @@ +import './play.css'; +import { advance } from './typing.js'; +import { createRaceTrack } from './race-track.js'; +import { sfx } from './sfx.js'; + +const params = new URLSearchParams(location.search); +const callsign = (params.get('callsign') || '').toLowerCase(); +const statusEl = document.getElementById('status'); +const typedEl = document.getElementById('typed'); +const restEl = document.getElementById('rest'); +const caretEl = document.getElementById('caret'); +const termEl = document.getElementById('term'); +const entry = document.getElementById('entry'); +const track = createRaceTrack(document.getElementById('field'), { me: callsign }); + +let prompts = []; +let phase = 'idle'; +let completed = 0; // my confirmed position (optimistic; server is authoritative) +let synced = false; // true once we've trusted the server's position after (re)connect +let prevPhase = 'idle'; +let typedCount = 0; // strict cursor into the current prompt — wrong keys never move it +let currentTarget = null; + +const muteBtn = document.getElementById('mute'); +const showMute = () => { muteBtn.textContent = sfx.muted ? '🔇' : '🔊'; }; +muteBtn.onclick = () => { sfx.muted = !sfx.muted; showMute(); }; +showMute(); + +const target = () => prompts[completed] || ''; +const lineDone = () => { const t = target(); return t.length > 0 && typedCount === t.length; }; + +let errTimer = null; +function rejectKey() { + sfx.miss(); + caretEl.classList.add('err'); + clearTimeout(errTimer); + errTimer = setTimeout(() => caretEl.classList.remove('err'), 180); +} + +function render() { + const t = target(); + if (t !== currentTarget) { currentTarget = t; typedCount = 0; entry.value = ''; } + typedEl.textContent = t.slice(0, typedCount); + restEl.textContent = t.slice(typedCount); + const active = phase === 'running' && completed < prompts.length; + termEl.dataset.active = active ? '1' : ''; + termEl.dataset.done = lineDone() ? '1' : ''; + if (active) { + const wasDisabled = entry.disabled; + entry.disabled = false; + // `autofocus` dies while the input is disabled pre-race — without this, + // race start leaves focus nowhere and keystrokes go to the page. + if (wasDisabled) entry.focus(); + } else { + entry.disabled = true; + } + statusEl.textContent = + phase === 'running' ? (lineDone() ? 'ENTER to run ⏎' : `RACING — ${completed}/${prompts.length}`) + : phase === 'finished' ? 'FINISHED ✦' + : 'waiting for race…'; +} + +// Trailing throttle: at most one frac report per 100ms. Completions bypass +// this and send immediately in the keydown handler. +function fracSender(ws) { + let timer = null, latest = 0; + const send = (frac) => { + latest = frac; + if (timer) return; + timer = setTimeout(() => { + timer = null; + if (ws.readyState === WebSocket.OPEN && phase === 'running') { + ws.send(JSON.stringify({ t: 'progress', completed, frac: latest })); + } + }, 100); + }; + send.cancel = () => { if (timer) { clearTimeout(timer); timer = null; } latest = 0; }; + return send; +} + +function connect() { + const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`); + const sendFrac = fracSender(ws); + ws.onopen = () => { synced = false; statusEl.textContent = 'joining…'; ws.send(JSON.stringify({ t: 'join', callsign })); }; + ws.onmessage = (e) => { + let m; try { m = JSON.parse(e.data); } catch { return; } + if (m.t === 'denied') { statusEl.textContent = 'Ship not found — run your pipeline first.'; entry.disabled = true; return; } + if (m.t === 'race') { + prompts = m.prompts || []; + phase = m.phase; + const mine = (m.ships || []).find((s) => s.callsign === callsign); + const serverCompleted = mine ? mine.completed : 0; + if (!synced) { completed = serverCompleted; synced = true; } // (re)connect/reload: trust the server's position + else if (m.phase === 'running' && prevPhase !== 'running') completed = serverCompleted; // new round: server reset us to 0 + // during a running round, keep the local optimistic `completed`; the server silently rejects bad progress + if (m.phase === 'running' && prevPhase !== 'running') sfx.go(); + prevPhase = m.phase; + track.update({ phase: m.phase, total: m.total, ships: m.ships || [] }); + render(); + } + }; + // The hidden input feeds the terminal line. Its value is snapped to the + // correct prefix after every event, so only the newly typed characters are + // judged — a wrong key simply never lands (no backspace needed, or allowed). + entry.oninput = () => { + const t = target(); + if (phase !== 'running' || !t) { entry.value = ''; return; } + const prefix = t.slice(0, typedCount); + const v = entry.value; + if (v.length > prefix.length) { + const next = v.startsWith(prefix) ? advance(t, typedCount, v.slice(prefix.length)) : typedCount; + if (next > typedCount) { + typedCount = next; + sfx.key(); + if (typedCount === t.length) sfx.ready(); + } else { + rejectKey(); + } + } + entry.value = t.slice(0, typedCount); // snap: deletions and wrong keys are no-ops + sendFrac(typedCount === t.length ? 0.9 : typedCount / t.length); + render(); + }; + entry.onkeydown = (e) => { + sfx.unlock(); + if (e.key === 'Backspace') { e.preventDefault(); return; } // nothing to erase — wrong keys never landed + if (e.key !== 'Enter') return; + if (lineDone() && phase === 'running') { + sendFrac.cancel(); + completed += 1; + currentTarget = null; + typedCount = 0; + entry.value = ''; + ws.send(JSON.stringify({ t: 'progress', completed })); + track.boost(callsign); + if (completed >= prompts.length) sfx.finish(); else sfx.boost(); + render(); + } else if (phase === 'running') { + sfx.error(); + termEl.classList.remove('shake'); + void termEl.offsetWidth; // restart the animation on rapid re-trigger + termEl.classList.add('shake'); + } + }; + ws.onclose = () => { statusEl.textContent = 'disconnected — reconnecting…'; setTimeout(connect, 1000); }; + ws.onerror = () => ws.close(); +} + +// Click/tap anywhere returns focus to the input — racers never hunt for it. +// Doubles as the audio unlock gesture. +document.addEventListener('click', () => { sfx.unlock(); if (!entry.disabled) entry.focus(); }); + +if (!callsign) { statusEl.textContent = 'No callsign — open this from your ship\'s READY button.'; entry.disabled = true; } +else connect(); diff --git a/board/client/race-layout.js b/board/client/race-layout.js new file mode 100644 index 0000000..4c8442c --- /dev/null +++ b/board/client/race-layout.js @@ -0,0 +1,30 @@ +// board/client/race-layout.js +// Pure race → row math for the shared 2D track (successor of track.js's role). +// Node-tested; no DOM, no Three.js. +const byCallsign = (a, b) => (a.callsign < b.callsign ? -1 : a.callsign > b.callsign ? 1 : 0); +const clamp01 = (n) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0); + +export function progressOf(completed, frac, total) { + if (!(total > 0)) return 0; + const done = Math.min(Math.max(completed || 0, 0), total); + const partial = done >= total ? 0 : clamp01(frac); + return Math.min(1, (done + partial) / total); +} + +// Stable lane assignment: rows never reorder mid-race — ships only move +// horizontally. Rank is a separate, updating number (see ranks()). +export function laneOrder(ships) { + return [...ships].sort(byCallsign); +} + +export function ranks(ships) { + const sorted = [...ships].sort((a, b) => { + const af = a.finishedAt != null, bf = b.finishedAt != null; + if (af && bf) return a.finishedAt - b.finishedAt; + if (af !== bf) return af ? -1 : 1; + const d = ((b.completed || 0) + clamp01(b.frac)) - ((a.completed || 0) + clamp01(a.frac)); + if (d !== 0) return d; + return byCallsign(a, b); + }); + return new Map(sorted.map((s, i) => [s.callsign, i + 1])); +} diff --git a/board/client/race-layout.test.js b/board/client/race-layout.test.js new file mode 100644 index 0000000..d3d750b --- /dev/null +++ b/board/client/race-layout.test.js @@ -0,0 +1,36 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { progressOf, laneOrder, ranks } from './race-layout.js'; + +test('progressOf blends completed and frac over total, clamped', () => { + assert.equal(progressOf(0, 0, 12), 0); + assert.equal(progressOf(6, 0.5, 12), 6.5 / 12); + assert.equal(progressOf(12, 0.9, 12), 1); // finished: frac no longer counts + assert.equal(progressOf(13, 0, 12), 1); // over-report clamps + assert.equal(progressOf(3, -1, 12), 3 / 12); // junk frac clamps low + assert.equal(progressOf(3, 2, 12), 4 / 12); // junk frac clamps high + assert.equal(progressOf(5, 0.5, 0), 0); // degenerate total +}); + +test('laneOrder sorts alphabetically without mutating input', () => { + const ships = [{ callsign: 'zed' }, { callsign: 'ada' }, { callsign: 'mel' }]; + const out = laneOrder(ships); + assert.deepEqual(out.map((s) => s.callsign), ['ada', 'mel', 'zed']); + assert.equal(ships[0].callsign, 'zed'); // input untouched +}); + +test('ranks: finished first by finishedAt, then by completed+frac, ties alphabetical', () => { + const ships = [ + { callsign: 'slow', completed: 2, frac: 0.1, finishedAt: null }, + { callsign: 'winner', completed: 12, frac: 0, finishedAt: 1 }, + { callsign: 'second', completed: 12, frac: 0, finishedAt: 2 }, + { callsign: 'fast', completed: 7, frac: 0.5, finishedAt: null }, + { callsign: 'alsofast', completed: 7, frac: 0.5, finishedAt: null }, + ]; + const r = ranks(ships); + assert.equal(r.get('winner'), 1); + assert.equal(r.get('second'), 2); + assert.equal(r.get('alsofast'), 3); // tie with fast → alphabetical + assert.equal(r.get('fast'), 4); + assert.equal(r.get('slow'), 5); +}); diff --git a/board/client/race-track.css b/board/client/race-track.css new file mode 100644 index 0000000..ae86123 --- /dev/null +++ b/board/client/race-track.css @@ -0,0 +1,123 @@ +/* board/client/race-track.css — the shared race view (projector + cockpit). + Dark-only, same palette family as style.css / play.css. Rows flex-share the + viewport height so 40+ racers fit with zero scrolling. */ +.race-track { + --ship-w: 50px; + --accent: #22d3ee; + --lane-bg: #1e293b; + --fg: #e2e8f0; + --dim: #94a3b8; + display: flex; flex-direction: column; + width: 100%; height: 100%; min-height: 0; box-sizing: border-box; + padding: 0.5rem 1rem; + font: 13px/1.2 ui-monospace, Menlo, Consolas, monospace; + color: var(--fg); + /* Drifting starfield: layered star tiles at different speeds (parallax). + Each layer's shift is a whole multiple of its tile size — seamless loop. */ + background-color: #0b1220; + background-image: + radial-gradient(2px 2px at 25% 20%, rgba(226, 232, 240, 0.55), transparent 60%), + radial-gradient(1.5px 1.5px at 70% 60%, rgba(226, 232, 240, 0.4), transparent 60%), + radial-gradient(2.5px 2.5px at 45% 85%, rgba(148, 163, 184, 0.5), transparent 60%), + radial-gradient(1.5px 1.5px at 90% 35%, rgba(148, 163, 184, 0.4), transparent 60%); + background-size: 260px 260px, 340px 340px, 420px 420px, 300px 300px; + animation: race-stars 60s linear infinite; +} +@keyframes race-stars { + to { background-position: -260px 0, -680px 0, -420px 0, -900px 0; } +} +.race-banner { min-height: 1.3em; padding: 0.15rem 0; text-align: center; color: var(--dim); } +.race-track[data-phase='finished'] .race-banner { color: var(--accent); } + +.race-rows { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 2px; } +.race-track[data-phase='idle'] .race-rows { opacity: 0.55; } + +.race-row { flex: 1 1 0; min-height: 10px; max-height: 48px; display: flex; align-items: center; gap: 0.5rem; } +.race-row.me { flex-grow: 2; max-height: 64px; } +.race-row.me .cs { color: var(--accent); font-weight: 700; } +/* Cockpit: the own-ship row is pinned last (see race-track.js) — margin-top + auto hugs it to the bottom edge, right above the typing dock, with a thin + rule splitting it from the pack. */ +.race-track[data-me] .race-row.me { margin-top: auto; border-top: 1px solid var(--lane-bg); padding-top: 4px; } + +.race-row .rank { width: 2ch; text-align: right; color: var(--dim); font-size: 0.85em; } +.race-row .cs { width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.race-row .meta { width: 8ch; text-align: right; color: var(--dim); font-variant-numeric: tabular-nums; } + +.race-row .lane { position: relative; flex: 1; min-width: 0; height: 6px; border-radius: 999px; background: var(--lane-bg); } +.race-row .lane::after { /* finish line */ + content: ''; position: absolute; right: 0; top: -5px; bottom: -5px; width: 2px; + background: var(--accent); opacity: 0.6; +} +.race-row .ship { + position: absolute; left: 0; top: 50%; transform: translateY(-50%); + width: var(--ship-w); height: var(--ship-w); + display: grid; place-items: center; + transition: left 150ms linear; +} +.race-row .ship .sprite { width: 100%; height: 100%; display: block; object-fit: contain; } +.race-row .ship .glyph { font-size: 16px; line-height: 1; } +.race-row .ship .glyph::before { content: '▶'; } + +/* Idle float: the sprite bobs inside its (transitioned) position box, so the + two transforms never fight. --bob-dur/--bob-delay are set per row from a + callsign hash — 40 ships drift out of sync, stable across updates. */ +.race-row .ship .sprite, +.race-row .ship .glyph { + animation: race-bob var(--bob-dur, 3s) ease-in-out var(--bob-delay, 0s) infinite; +} +@keyframes race-bob { + 0%, 100% { transform: translateY(-7%) rotate(-2deg); } + 50% { transform: translateY(7%) rotate(2deg); } +} + +/* Engine glow: a blurred exhaust streak off the ship's tail, tinted with the + racer's colour, flickering like a burning thruster. */ +.race-row .ship::before { + content: ''; + position: absolute; + right: 74%; top: 50%; + width: 45%; height: 16%; + border-radius: 999px; + background: linear-gradient(to left, var(--ship-color, var(--accent)), transparent); + filter: blur(2px); + transform: translateY(-50%); + transform-origin: right center; + animation: race-thrust 0.9s ease-in-out var(--bob-delay, 0s) infinite; +} +@keyframes race-thrust { + 0%, 100% { opacity: 0.75; transform: translateY(-50%) scaleX(1); } + 50% { opacity: 0.3; transform: translateY(-50%) scaleX(0.65); } +} + +/* ENTER boost: a one-shot lunge + flare layered over the position glide. + race-track.js pulses the class for ~500ms. */ +.race-row .ship.boost .sprite, +.race-row .ship.boost .glyph { animation: race-boost 0.45s ease-out; } +@keyframes race-boost { + 0% { transform: translateX(-4px) scale(1); } + 35% { transform: translateX(6px) scale(1.35) rotate(2deg); } + 100% { transform: translateX(0) scale(1); } +} +.race-row .ship.boost::before { animation: race-flare 0.45s ease-out; } +@keyframes race-flare { + 0% { opacity: 1; transform: translateY(-50%) scaleX(2.2); } + 100% { opacity: 0.5; transform: translateY(-50%) scaleX(1); } +} + +@media (prefers-reduced-motion: reduce) { + .race-track { animation: none; } + .race-row .ship { transition: none; } + .race-row .ship .sprite, + .race-row .ship .glyph, + .race-row .ship.boost .sprite, + .race-row .ship.boost .glyph { animation: none; } + .race-row .ship::before, + .race-row .ship.boost::before { animation: none; opacity: 0.5; } +} + +/* 25+ racers: reclaim label space, shrink sprites, keep every row legible */ +.race-rows[data-dense='1'] .cs { display: none; } +.race-rows[data-dense='1'] { --ship-w: 31px; } +.race-rows[data-dense='1'] .race-row { gap: 0.3rem; } +.race-rows[data-dense='1'] .meta { font-size: 0.8em; width: 6ch; } diff --git a/board/client/race-track.js b/board/client/race-track.js new file mode 100644 index 0000000..dca4eb0 --- /dev/null +++ b/board/client/race-track.js @@ -0,0 +1,141 @@ +// board/client/race-track.js +// THE race view. Projector and cockpit render this same DOM component: one row +// per racer stacked top-to-bottom (stable alphabetical — rows never reorder), +// ships glide left→right by completed+frac. No rAF and no WebGL at render time +// (sprites are pre-rendered data-URLs), so reduced-motion/no-WebGL needs no +// separate fallback — this component IS the fallback. +import { progressOf, laneOrder, ranks } from './race-layout.js'; +import { shipSprite } from './ship-sprite.js'; +import './race-track.css'; + +const NEUTRAL = '#94a3b8'; // matches the board's roster default colour +const DENSE_AT = 25; // hide callsign labels at this many racers (~<14px rows) +const MEDALS = ['🥇', '🥈', '🥉']; + +export function createRaceTrack(container, { me = null } = {}) { + const root = document.createElement('div'); + root.className = 'race-track'; + if (me) root.dataset.me = '1'; + const banner = document.createElement('div'); + banner.className = 'race-banner'; + const rowsEl = document.createElement('div'); + rowsEl.className = 'race-rows'; + root.append(banner, rowsEl); + container.append(root); + + const rows = new Map(); // callsign -> { el, rankEl, ship, img, glyph, meta, spriteKey } + let disposed = false; + + function ensureRow(s) { + let r = rows.get(s.callsign); + if (r) return r; + const el = document.createElement('div'); + el.className = 'race-row'; + if (me && s.callsign === me) el.classList.add('me'); + el.title = `@${s.callsign}`; + // Deterministic per-callsign phase: ships bob/flicker out of sync without + // re-rolling on every update (negative delay = start mid-cycle, no pop). + let h = 0; + for (const ch of s.callsign) h = (h * 31 + ch.charCodeAt(0)) >>> 0; + el.style.setProperty('--bob-dur', `${(2.6 + (h % 90) / 60).toFixed(2)}s`); + el.style.setProperty('--bob-delay', `-${((h >> 4) % 300) / 100}s`); + + const rankEl = document.createElement('span'); rankEl.className = 'rank'; + const label = document.createElement('span'); label.className = 'cs'; + label.textContent = `@${s.callsign}`; + const lane = document.createElement('span'); lane.className = 'lane'; + const ship = document.createElement('span'); ship.className = 'ship'; + const glyph = document.createElement('span'); glyph.className = 'glyph'; + const img = document.createElement('img'); img.className = 'sprite'; img.alt = ''; img.hidden = true; + ship.append(glyph, img); + lane.append(ship); + const meta = document.createElement('span'); meta.className = 'meta'; + el.append(rankEl, label, lane, meta); + rowsEl.append(el); + + r = { el, rankEl, ship, img, glyph, meta, spriteKey: null }; + rows.set(s.callsign, r); + return r; + } + + function applySprite(r, s) { + const key = `${s.shipModel}|${s.color}`; + if (r.spriteKey === key) return; + r.spriteKey = key; + r.glyph.style.color = s.color || NEUTRAL; + r.el.style.setProperty('--ship-color', s.color || NEUTRAL); + shipSprite(s.shipModel, s.color).then((url) => { + if (disposed || r.spriteKey !== key) return; // stale render — a newer look won + if (url) { r.img.src = url; r.img.hidden = false; r.glyph.hidden = true; } + else { r.img.hidden = true; r.glyph.hidden = false; } + }); + } + + function medalsText(ships) { + const podium = ships.filter((s) => s.finishedAt != null) + .sort((a, b) => a.finishedAt - b.finishedAt).slice(0, 3) + .map((s, i) => `${MEDALS[i]} @${s.callsign}`); + return podium.length ? podium.join(' ') : ''; + } + + function bannerText(phase, ships) { + if (phase === 'idle') return ships.length ? 'WAITING FOR LAUNCH…' : 'NO RACERS YET — open your ship’s READY link'; + if (phase === 'finished') { + const podium = medalsText(ships); + return podium ? `FINISH ✦ ${podium}` : 'FINISH ✦'; + } + // A ghost racer who never finishes blocks the server's 'finished' phase + // forever, so winners must show as they land, not just at round end. + if (phase === 'running') { + const podium = medalsText(ships); + if (podium) return podium; + } + return ''; + } + + function update(state) { + const { phase, total = 12, ships = [] } = state; + root.dataset.phase = phase; + rowsEl.dataset.dense = ships.length >= DENSE_AT ? '1' : ''; + banner.textContent = bannerText(phase, ships); + + const order = laneOrder(ships); + const rk = ranks(ships); + const seen = new Set(); + order.forEach((s, i) => { + seen.add(s.callsign); + const r = ensureRow(s); + applySprite(r, s); + const p = progressOf(s.completed, s.frac, total); + r.ship.style.left = `calc((100% - var(--ship-w)) * ${p.toFixed(4)})`; + r.rankEl.textContent = String(rk.get(s.callsign)); + r.meta.textContent = s.finishedAt != null + ? `✦ #${rk.get(s.callsign)}` + : `${((s.completed || 0) + (s.frac || 0)).toFixed(1)}/${total}`; + // Alphabetical slot, whatever the DOM insertion order was — except the + // cockpit's own ship, pinned to the bottom so it sits right above the + // typing dock (your ship moves where your eyes already are). + r.el.style.order = me && s.callsign === me ? String(order.length) : String(i); + }); + for (const [callsign, r] of rows) { + if (!seen.has(callsign)) { r.el.remove(); rows.delete(callsign); } + } + } + + // Pulse the ENTER-boost flourish on one ship (lunge + engine flare). + function boost(callsign) { + const r = rows.get(callsign); + if (!r) return; + r.ship.classList.remove('boost'); + void r.ship.offsetWidth; // restart the animation on rapid re-trigger + r.ship.classList.add('boost'); + clearTimeout(r.boostTimer); + r.boostTimer = setTimeout(() => r.ship.classList.remove('boost'), 500); + } + + return { + update, + boost, + dispose() { disposed = true; root.remove(); rows.clear(); }, + }; +} diff --git a/board/client/scene.js b/board/client/scene.js index 50f0d91..4884170 100644 --- a/board/client/scene.js +++ b/board/client/scene.js @@ -3,7 +3,7 @@ import * as THREE from 'three'; import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/addons/postprocessing/RenderPass.js'; import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js'; -import { createShip, setEmissiveBoost, setTrail, setGrounded, preloadShipTemplates } from './ship-mesh.js'; +import { createShip, setEmissiveBoost, setTrail, setGrounded, setLive, preloadShipTemplates, disposeShip, disposeObject3D } from './ship-mesh.js'; import { placement } from './placement.js'; import { orbitAngle } from './orbit.js'; import { launchPhase, isComplete, easeInCubic, easeInOutCubic } from './launch.js'; @@ -70,6 +70,7 @@ export function createScene(container, { onLiftoff, onPreloadError } = {}) { ships.set(s.callsign, rec); } rec.data = s; rec.index = i; + setLive(rec.group, s.live); // green halo when the real Pages site is reachable const zone = placement(s).zone; if (zone !== rec.lastZone) setGrounded(rec.group, zone === 'grounded'); @@ -154,12 +155,14 @@ export function createScene(container, { onLiftoff, onPreloadError } = {}) { const { map, count } = orbitingIndex(); const damp = 1 - Math.exp(-DAMP_K * dt); + const livePulse = 0.35 + 0.35 * (0.5 + 0.5 * Math.sin(elapsedMs * 0.004)); // ~0.35→0.70 for (const rec of ships.values()) { targetFor(rec, map.get(rec.data.callsign) ?? 0, count, tmp); if (!rec.pos) rec.pos = tmp.clone(); // snap on first sight else if (rec.launch) applyLaunch(rec, tmp); // scripted beat overrides damping else rec.pos.lerp(tmp, damp); // ease toward target — no teleports rec.group.position.copy(rec.pos); + if (rec.group.userData.live) rec.group.userData.liveRing.material.opacity = livePulse; } composer.render(); raf = requestAnimationFrame(tick); @@ -222,39 +225,3 @@ export function createScene(container, { onLiftoff, onPreloadError } = {}) { }, }; } - -// Texture-cascading dispose — carried from launchpad M1. Label sprite + trail -// carry a texture/material, so this cascade is load-bearing. -function disposeObject3D(obj) { - obj.traverse((node) => { - if (node.isMesh || node.isSprite) { - node.geometry?.dispose?.(); - const mats = Array.isArray(node.material) ? node.material : [node.material]; - for (const m of mats) disposeMaterial(m); - } - }); -} -function disposeMaterial(material) { - if (!material) return; - for (const value of Object.values(material)) if (value?.isTexture) value.dispose(); - material.dispose(); -} - -// A ship clone shares the template's geometry + textures; disposing those would -// break sibling clones. createShip flags cloned-model nodes: node.userData -// .sharedGeometry and material.userData.keepTextures. Skip those; dispose the -// rest (the trail + the callsign label the clone uniquely owns). -function disposeShip(group) { - group.traverse((node) => { - if (!node.isMesh && !node.isSprite) return; - if (node.geometry && !node.userData.sharedGeometry) node.geometry.dispose(); - const mats = Array.isArray(node.material) ? node.material : [node.material]; - for (const m of mats) { - if (!m) continue; - if (!m.userData.keepTextures) { - for (const v of Object.values(m)) if (v?.isTexture) v.dispose(); - } - m.dispose(); - } - }); -} diff --git a/board/client/sfx.js b/board/client/sfx.js new file mode 100644 index 0000000..cc7c310 --- /dev/null +++ b/board/client/sfx.js @@ -0,0 +1,112 @@ +// board/client/sfx.js +// Synthesized cockpit sounds — Web Audio only, no asset files. Every sound is +// oscillators/filtered noise built at call time. The AudioContext unlocks on +// the first user gesture (browsers block audio before one) via unlock(). + +const store = { + get(k) { try { return localStorage.getItem(k); } catch { return null; } }, + set(k, v) { try { localStorage.setItem(k, v); } catch { /* private mode */ } }, +}; + +let ac = null; +let master = null; +let muted = store.get('shipit-sfx') === 'off'; + +function ctx() { + if (!ac) { + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return null; + ac = new AC(); + master = ac.createGain(); + master.gain.value = 0.5; + master.connect(ac.destination); + } + if (ac.state === 'suspended') ac.resume(); + return ac; +} + +function blip(freq, { dur = 0.08, type = 'sine', gain = 0.15, at = 0, slide = 0, attack = 0.004 } = {}) { + if (muted) return; + const a = ctx(); + if (!a) return; + const t = a.currentTime + at; + const o = a.createOscillator(); + const g = a.createGain(); + o.type = type; + o.frequency.setValueAtTime(freq, t); + if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(1, freq + slide), t + dur); + // Soft attack — instant-on sines click and read as arcade bleeps. + g.gain.setValueAtTime(0.0001, t); + g.gain.linearRampToValueAtTime(gain, t + attack); + g.gain.exponentialRampToValueAtTime(0.001, t + dur); + o.connect(g).connect(master); + o.start(t); + o.stop(t + dur + 0.02); +} + +function whoosh({ dur = 0.3, from = 400, to = 2400, gain = 0.2, at = 0, type = 'bandpass' } = {}) { + if (muted) return; + const a = ctx(); + if (!a) return; + const t = a.currentTime + at; + const len = Math.ceil(a.sampleRate * dur); + const buf = a.createBuffer(1, len, a.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; + const src = a.createBufferSource(); + src.buffer = buf; + const f = a.createBiquadFilter(); + f.type = type; + f.Q.value = 1; + f.frequency.setValueAtTime(from, t); + f.frequency.exponentialRampToValueAtTime(to, t + dur); + const g = a.createGain(); + g.gain.setValueAtTime(gain, t); + g.gain.exponentialRampToValueAtTime(0.001, t + dur); + src.connect(f); + f.connect(g); + g.connect(master); + src.start(t); + src.stop(t + dur + 0.02); +} + +// Cockpit palette, not arcade: sines/triangles with soft attacks, low +// registers, filtered-noise air. No square waves, no rising chiptune runs. +export const sfx = { + get muted() { return muted; }, + set muted(v) { muted = v; store.set('shipit-sfx', v ? 'off' : 'on'); }, + unlock() { if (!muted) ctx(); }, + // Correct keystroke: dampened console tap — a puff of low noise with a + // faint mid tone, pitch-jittered so a line of typing isn't a metronome. + key() { + whoosh({ dur: 0.025, from: 900, to: 500, gain: 0.05, type: 'lowpass' }); + blip(520 + Math.random() * 120, { dur: 0.03, type: 'triangle', gain: 0.04 }); + }, + // Wrong character: soft low bump, pitch sagging. + miss() { blip(90, { dur: 0.08, type: 'sine', gain: 0.07, slide: -35 }); }, + // Command fully typed (awaiting ENTER): single sonar ping, long tail. + ready() { blip(880, { dur: 0.35, type: 'sine', gain: 0.08, attack: 0.01 }); }, + // ENTER boost: deep thruster — low noise sweep + a rumble rising under it. + boost() { + whoosh({ dur: 0.5, from: 120, to: 1400, gain: 0.28 }); + blip(50, { dur: 0.45, type: 'sine', gain: 0.18, slide: 70 }); + }, + // ENTER on a wrong line: descending double "denied" thunk. + error() { + blip(160, { dur: 0.12, type: 'triangle', gain: 0.1, slide: -60 }); + blip(110, { dur: 0.15, type: 'triangle', gain: 0.1, slide: -40, at: 0.12 }); + }, + // Race went live: two low countdown marks, then ignition — tone + rumble. + go() { + blip(392, { dur: 0.1, type: 'sine', gain: 0.12 }); + blip(392, { dur: 0.1, type: 'sine', gain: 0.12, at: 0.18 }); + blip(523, { dur: 0.4, type: 'sine', gain: 0.15, at: 0.36 }); + whoosh({ dur: 0.6, from: 80, to: 400, gain: 0.12, at: 0.36, type: 'lowpass' }); + }, + // All prompts done: docking-complete — a warm low chord swelling in, with + // one soft ping on top. No fanfare arpeggio. + finish() { + [130.8, 196, 261.6].forEach((f) => blip(f, { dur: 1.0, type: 'sine', gain: 0.09, attack: 0.15 })); + blip(784, { dur: 0.6, type: 'sine', gain: 0.07, at: 0.25, attack: 0.02 }); + }, +}; diff --git a/board/client/ship-mesh.js b/board/client/ship-mesh.js index 58921bc..26f3767 100644 --- a/board/client/ship-mesh.js +++ b/board/client/ship-mesh.js @@ -48,10 +48,30 @@ export function createShip({ callsign, color, shipModel, template }) { label.position.y = 0.72; group.add(label); - group.userData = { callsign, color, shipModel, mat, trail, baseEmissive: 0.35 }; + // LIVE halo — a green ring under the ship, shown only when the learner's real + // Pages site answers 200. Additive so it blooms; opacity pulses in scene.tick. + const liveMat = new THREE.MeshBasicMaterial({ + color: new THREE.Color(PALETTE.live), transparent: true, opacity: 0, + side: THREE.DoubleSide, blending: THREE.AdditiveBlending, depthWrite: false, + }); + const liveRing = new THREE.Mesh(new THREE.RingGeometry(0.46, 0.56, 40), liveMat); + liveRing.rotation.x = -Math.PI / 2; // lie flat — a halo beneath the hull + liveRing.position.y = -0.42; + liveRing.visible = false; + group.add(liveRing); + + group.userData = { callsign, color, shipModel, mat, trail, liveRing, live: false, baseEmissive: 0.35 }; return group; } +// Toggle the LIVE halo. Idempotent; scene.tick pulses its opacity while visible. +export function setLive(group, on) { + const { liveRing } = group.userData; + if (!liveRing) return; + group.userData.live = !!on; + liveRing.visible = !!on; +} + export function setEmissiveBoost(group, intensity) { if (group.userData.mat) group.userData.mat.emissiveIntensity = intensity; } @@ -70,6 +90,42 @@ export function setGrounded(group, on) { mat.emissiveIntensity = on ? 0.6 : baseEmissive; } +// Texture-cascading dispose — carried from launchpad M1. Label sprite + trail +// carry a texture/material, so this cascade is load-bearing. +export function disposeObject3D(obj) { + obj.traverse((node) => { + if (node.isMesh || node.isSprite) { + node.geometry?.dispose?.(); + const mats = Array.isArray(node.material) ? node.material : [node.material]; + for (const m of mats) disposeMaterial(m); + } + }); +} +function disposeMaterial(material) { + if (!material) return; + for (const value of Object.values(material)) if (value?.isTexture) value.dispose(); + material.dispose(); +} + +// A ship clone shares the template's geometry + textures; disposing those would +// break sibling clones. createShip flags cloned-model nodes: node.userData +// .sharedGeometry and material.userData.keepTextures. Skip those; dispose the +// rest (the trail + the callsign label the clone uniquely owns). +export function disposeShip(group) { + group.traverse((node) => { + if (!node.isMesh && !node.isSprite) return; + if (node.geometry && !node.userData.sharedGeometry) node.geometry.dispose(); + const mats = Array.isArray(node.material) ? node.material : [node.material]; + for (const m of mats) { + if (!m) continue; + if (!m.userData.keepTextures) { + for (const v of Object.values(m)) if (v?.isTexture) v.dispose(); + } + m.dispose(); + } + }); +} + // SET every saturated texel's hue to `hueFrac` ([0,1)), in-shader, after the // base-colour texture is sampled. Setting (not rotating) lands exactly on the // chosen colour on any model — the 4 ships share one atlas with no base hue. diff --git a/board/client/ship-sprite.js b/board/client/ship-sprite.js new file mode 100644 index 0000000..7cd48d9 --- /dev/null +++ b/board/client/ship-sprite.js @@ -0,0 +1,88 @@ +// board/client/ship-sprite.js +// One-time GLB → 2D sprite renders for the race track. Each (shipModel, color) +// pair is rendered once to a small transparent canvas and cached as a data-URL; +// after that the race is plain DOM — no per-frame WebGL. Resolves null when +// WebGL or the models are unavailable; the track shows a tinted glyph instead. +import * as THREE from 'three'; +import { createShip, preloadShipTemplates, disposeShip, disposeObject3D } from './ship-mesh.js'; + +const SIZE = 128; // ~2.5x the largest on-screen box so hiDPI stays crisp +const cache = new Map(); // `${shipModel}|${color}` -> Promise +let ctx; // lazy { renderer, scene, camera }; null = WebGL unavailable +let templatesPromise; // cache the promise; all sprite renders share one load + +function setup() { + try { + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setSize(SIZE, SIZE); + const scene = new THREE.Scene(); + // Elevated 3/4 vantage: ship stays level (no fake nose-down pitch), the + // camera looks down at ~29° so the top surface and silhouette both read. + const camera = new THREE.OrthographicCamera(-1.6, 1.6, 1.6, -1.6, 0.1, 50); + camera.position.set(0, 5.5, 10); + camera.lookAt(0, 0, 0); + scene.add(new THREE.HemisphereLight(0xffffff, 0x334155, 0.9)); + const key = new THREE.DirectionalLight(0xffffff, 0.9); + key.position.set(2, 4, 6); + scene.add(key); + return { renderer, scene, camera }; + } catch { + return null; + } +} + +async function render(shipModel, color) { + templatesPromise ??= preloadShipTemplates(); + const templates = await templatesPromise; + if (ctx === undefined) ctx = setup(); + if (!ctx) return null; + const template = templates.get(shipModel) || templates.get('fighter'); + const ship = createShip({ callsign: '', color, shipModel, template }); + // Strip the non-hull extras (label sprite; invisible trail/liveRing meshes): + // Box3.setFromObject counts them even when invisible, which would inflate the + // framing below and shrink the hull to a speck in the canvas. + for (const o of [...ship.children]) { + if (o.isSprite || o.visible === false) { ship.remove(o); disposeObject3D(o); } + } + // Yaw the nose toward +x (the track direction), leaning slightly toward the + // viewer so the sprite isn't a flat side sliver; the camera above adds the + // top-down component — the ship itself stays level. All four GLBs point + // their nose down +z (confirmed on-screen against the track). + ship.rotation.y = Math.PI / 2 - 0.4; + ctx.scene.add(ship); + ship.updateMatrixWorld(true); + // Frame the hull in CAMERA space (the camera is tilted, so a world-space + // box would mis-frame): project the world box's corners into view space + // and fit the ortho frustum around them. + const box = new THREE.Box3().setFromObject(ship); + ctx.camera.updateMatrixWorld(true); // lazy until first render — without this the FIRST sprite frames against identity and comes out empty + const view = new THREE.Matrix4().copy(ctx.camera.matrixWorld).invert(); + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const x of [box.min.x, box.max.x]) { + for (const y of [box.min.y, box.max.y]) { + for (const z of [box.min.z, box.max.z]) { + const v = new THREE.Vector3(x, y, z).applyMatrix4(view); + minX = Math.min(minX, v.x); maxX = Math.max(maxX, v.x); + minY = Math.min(minY, v.y); maxY = Math.max(maxY, v.y); + } + } + } + const half = (Math.max(maxX - minX, maxY - minY) / 2) * 1.08 || 1.6; + const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2; + ctx.camera.left = cx - half; + ctx.camera.right = cx + half; + ctx.camera.top = cy + half; + ctx.camera.bottom = cy - half; + ctx.camera.updateProjectionMatrix(); + ctx.renderer.render(ctx.scene, ctx.camera); + const url = ctx.renderer.domElement.toDataURL('image/png'); + ctx.scene.remove(ship); + disposeShip(ship); + return url; +} + +export function shipSprite(shipModel, color) { + const k = `${shipModel}|${color}`; + if (!cache.has(k)) cache.set(k, render(shipModel, color).catch(() => null)); + return cache.get(k); +} \ No newline at end of file diff --git a/board/client/style.css b/board/client/style.css index 7d3bc20..a72d622 100644 --- a/board/client/style.css +++ b/board/client/style.css @@ -25,6 +25,13 @@ html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; f .legend .orb { background: var(--cyan); box-shadow: 0 0 6px var(--cyan); } .legend .gnd { background: #f0505a; } +#race-hud { + position: fixed; bottom: 14px; left: 16px; z-index: 2; + padding: 8px 14px; border: 1px solid var(--line); border-radius: 8px; + background: var(--panel); backdrop-filter: blur(6px); + font-size: 13px; opacity: .85; +} + /* LIFTOFF toasts — corner stack; reduced-motion users never see the scene, but guard anyway so the transition degrades to an instant show/hide. */ #toasts { position: fixed; top: 14px; right: 16px; z-index: 3; display: grid; gap: 8px; justify-items: end; } @@ -45,3 +52,4 @@ html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; f .roster .st { opacity: .8; } .roster .st-passed, .roster .st-shipped { color: var(--cyan); } .roster .st-failed, .roster .st-aborted { color: #f0505a; } +.roster .live { margin-left: auto; font-weight: 700; font-size: 12px; letter-spacing: .08em; color: #06110b; background: #2fe37a; border-radius: 3px; padding: 1px 6px; } diff --git a/board/client/theme.js b/board/client/theme.js index 9d9592f..665258a 100644 --- a/board/client/theme.js +++ b/board/client/theme.js @@ -13,6 +13,7 @@ export const PALETTE = { labelText: '#eaf6ff', // callsign fill labelOutline: '#04121f', // callsign stroke (legibility over any tint) grounded: '#f0505a', // ABORT marker + live: '#2fe37a', // LIVE halo — real Pages site answered 200 (blooms green) }; export const LAYOUT = { diff --git a/board/client/typing.js b/board/client/typing.js new file mode 100644 index 0000000..2dbe2bb --- /dev/null +++ b/board/client/typing.js @@ -0,0 +1,16 @@ +// Pure keystroke evaluation for the cockpit's strict terminal line: wrong keys +// never land, so the typed prefix is always correct and backspace has nothing +// to do. Correctness is judged client-side (the server stays authoritative +// over position — see the spec's security note). +// +// advance(target, at, incoming) -> new cursor: consumes incoming characters, +// advancing only while each matches the target at the cursor; the first wrong +// character stops the walk and the rest is dropped. +export function advance(target, at, incoming) { + let n = at; + for (const ch of incoming) { + if (n < target.length && ch === target[n]) n += 1; + else break; + } + return n; +} diff --git a/board/client/typing.test.js b/board/client/typing.test.js new file mode 100644 index 0000000..1ab279d --- /dev/null +++ b/board/client/typing.test.js @@ -0,0 +1,22 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { advance } from './typing.js'; + +test('advance consumes matching characters', () => { + assert.equal(advance('git push', 0, 'git'), 3); + assert.equal(advance('git push', 3, ' push'), 8); +}); + +test('advance stops at the first wrong character and drops the rest', () => { + assert.equal(advance('git', 1, 'xt'), 1); + assert.equal(advance('git push', 3, ' pxsh'), 5); // " p" lands, "xsh" dropped +}); + +test('advance never walks past the target', () => { + assert.equal(advance('ls', 2, 'anything'), 2); + assert.equal(advance('ls', 0, 'ls -l'), 2); +}); + +test('advance with empty incoming is a no-op', () => { + assert.equal(advance('ls', 1, ''), 1); +}); diff --git a/board/src/app.js b/board/src/app.js index b4ed1b9..16b302a 100644 --- a/board/src/app.js +++ b/board/src/app.js @@ -5,12 +5,16 @@ import path from 'node:path'; import crypto from 'node:crypto'; import { WebSocketServer } from 'ws'; import { Roster, sanitizeEvent } from './room.js'; -import { parse, rosterMsg } from './messages.js'; +import { parse, rosterMsg, raceMsg } from './messages.js'; +import { Race } from './race.js'; +import { createLiveness } from './liveness.js'; +import { pickPrompts, SESSIONS } from './corpus.js'; const DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist'); const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', + '.glb': 'model/gltf-binary', }; function send(ws, msg) { try { if (ws.readyState === 1) ws.send(msg); } catch { /* ignore */ } } @@ -33,10 +37,24 @@ function readBody(req, limit = 64 * 1024) { }); } -export function createServer({ port = 3000, token = null, publicDir = DIST } = {}) { +export function createServer({ port = 3000, token = null, operatorKey = null, publicDir = DIST, fetchImpl = fetch } = {}) { const roster = new Roster(); + const race = new Race({ total: 12 }); const clients = new Set(); - let dirty = false; + let view = 'orbit'; // projector view: 'orbit' | 'race' + let session = 'cicd3'; + let dirty = false; // roster changed + let raceDirty = false; // race state or view changed + // Liveness: a ship is LIVE only when its real Pages site answers 200. Reported + // siteUrls are probed on arrival + on a periodic sweep; a flip marks dirty so + // the next tick rebroadcasts the roster with fresh `live` flags. + const liveness = createLiveness({ roster, fetchImpl, onChange: () => { dirty = true; } }); + + // Operator-guarded control endpoints. When operatorKey is null, open (dev). + function operate(req, res, fn) { + if (operatorKey && !authorized(req, operatorKey)) return json(res, 401, { error: 'unauthorized' }); + return fn(); + } const server = http.createServer(async (req, res) => { try { @@ -46,10 +64,36 @@ export function createServer({ port = 3000, token = null, publicDir = DIST } = { if (!event) return json(res, 400, { error: 'invalid event: need callsign + known stage/status' }); roster.upsert(event); dirty = true; + liveness.probe(event); // check the real site now — snappy first-contact green return json(res, 202, { ok: true }); } + if (req.method === 'POST' && req.url === '/api/race/start') { + const body = parse(await readBody(req)) || {}; + return operate(req, res, () => { + const s = body.session ? String(body.session) : session; + if (!SESSIONS[s]) return json(res, 400, { error: 'unknown session' }); + session = s; + race.start(pickPrompts(session)); + raceDirty = true; + return json(res, 202, { ok: true }); + }); + } + if (req.method === 'POST' && req.url === '/api/race/reset') { + await readBody(req); + return operate(req, res, () => { race.reset(); raceDirty = true; return json(res, 202, { ok: true }); }); + } + if (req.method === 'POST' && req.url === '/api/view') { + const body = parse(await readBody(req)) || {}; + return operate(req, res, () => { + if (body.view === 'orbit' || body.view === 'race') view = body.view; + raceDirty = true; + return json(res, 202, { ok: true }); + }); + } // static: serve the Vite-built client let rel = decodeURIComponent((req.url || '/').split('?')[0]); + if (rel === '/play') rel = '/play.html'; + if (rel === '/operator') rel = '/operator.html'; if (rel === '/' || rel === '') rel = '/index.html'; const file = path.join(publicDir, path.normalize(rel)); if (!file.startsWith(publicDir)) { res.writeHead(403); return res.end('forbidden'); } @@ -62,27 +106,34 @@ export function createServer({ port = 3000, token = null, publicDir = DIST } = { const wss = new WebSocketServer({ server }); wss.on('connection', (ws) => { clients.add(ws); - // Snapshot goes out on the next dirty-tick rather than synchronously here: - // a same-tick send can land in the same TCP read as the WS handshake - // response, racing the client's own post-'open' listener setup. - dirty = true; + dirty = true; raceDirty = true; // send the roster + race snapshot on the next tick + ws.on('message', (buf) => { + let m; try { m = JSON.parse(buf.toString()); } catch { return; } + if (m.t === 'join' && typeof m.callsign === 'string') { + if (!roster.has(m.callsign)) return send(ws, JSON.stringify({ t: 'denied', reason: 'not-on-roster' })); + ws.callsign = m.callsign; + race.join(m.callsign); + raceDirty = true; + } else if (m.t === 'progress' && ws.callsign && Number.isInteger(m.completed)) { + race.report(ws.callsign, m.completed, m.frac); + raceDirty = true; + } + }); const drop = () => clients.delete(ws); ws.on('close', drop); ws.on('error', drop); - // spectators are read-only; inbound messages ignored }); const tick = setInterval(() => { - if (!dirty) return; - dirty = false; - const msg = rosterMsg(roster.list()); - for (const ws of clients) send(ws, msg); + if (dirty) { dirty = false; const msg = rosterMsg(roster.list().map((s) => ({ ...s, live: liveness.isLive(s.callsign) }))); for (const ws of clients) send(ws, msg); } + if (raceDirty) { raceDirty = false; const msg = raceMsg(race.snapshot(), view, clients.size, roster); for (const ws of clients) send(ws, msg); } }, 50); server.listen(port); + liveness.start(); return { get port() { const a = server.address(); return a && typeof a === 'object' ? a.port : port; }, - roster, server, wss, - close() { clearInterval(tick); wss.close(); return new Promise((r) => server.close(r)); }, + roster, race, liveness, server, wss, + close() { clearInterval(tick); liveness.stop(); wss.close(); return new Promise((r) => server.close(r)); }, }; } diff --git a/board/src/corpus.js b/board/src/corpus.js new file mode 100644 index 0000000..02eb390 --- /dev/null +++ b/board/src/corpus.js @@ -0,0 +1,58 @@ +// The race's command stories. Every command is verbatim from the bootcamp +// slides (inventory: ~/repo/slides-devops-bootcamp/slides/2026, up to CI/CD 3), +// and each story is SEQUENCED — run top to bottom it is one coherent workflow, +// every line's precondition set up by the line before. The order IS the +// lesson, so prompts are never shuffled. +export const STORIES = { + // CI/CD 3 race — "fork to first contact": sign in, fork the ship, pass the + // gate, build, push, wire the secret, launch the pipeline, find your URL. + cicd3: [ + 'gh auth login', + 'gh repo fork Infratify/devops-bootcamp-shipit --clone', + 'cd devops-bootcamp-shipit', + 'code .', + 'git status', + 'cd launchpad && npm run test', + 'npm run build', + 'git add .', + 'git commit -m "kemas laman"', + 'git push', + 'gh secret set SHIPIT_TOKEN', + 'gh secret list', + 'gh workflow run deploy.yaml', + 'gh workflow view -w', + 'echo $?', + 'gh api repos/{owner}/{repo}/pages --jq .html_url', + ], + // CI/CD 4 race — "capstone: container to your own server": sync the fork, + // pass the gate, containerise, verify, ship it to EC2, merge the ritual PR. + cicd4: [ + 'git fetch upstream', + 'git merge upstream/main', + 'cd launchpad && npm run test', + 'npm run build', + 'docker build -t web:v1 .', + 'docker images', + 'docker run -d -p 8080:80 --name web web:v1', + 'docker ps', + 'docker logs -f web', + 'docker tag web:v1 infratify/web:v1', + 'aws sts get-caller-identity', + 'ssh bootcamp', + 'curl -fsSL https://get.docker.com | sudo sh', + 'docker compose pull', + 'docker compose up -d', + 'docker compose ps', + 'curl -s https://checkip.amazonaws.com', + 'exit', + 'gh pr create --fill', + 'gh pr merge --squash --delete-branch', + ], +}; + +// Known race sessions (app.js validates /api/race/start against these keys). +export const SESSIONS = STORIES; + +export function pickPrompts(session) { + return [...(STORIES[session] || [])]; +} diff --git a/board/src/index.js b/board/src/index.js index 5c42c7b..4b62e8c 100644 --- a/board/src/index.js +++ b/board/src/index.js @@ -2,8 +2,9 @@ import { createServer } from './app.js'; const port = Number(process.env.PORT) || 3000; const token = process.env.SHIPIT_TOKEN || null; +const operatorKey = process.env.OPERATOR_KEY || null; -createServer({ port, token }); +createServer({ port, token, operatorKey }); if (token) { console.log(`[board] Mission Control on :${port} — auth ENFORCED (Bearer $SHIPIT_TOKEN)`); @@ -11,3 +12,4 @@ if (token) { console.warn('[board] SHIPIT_TOKEN unset — accepting UNAUTHENTICATED events (dev mode)'); console.log(`[board] Mission Control on :${port}`); } +if (!operatorKey) console.warn('[board] OPERATOR_KEY unset — race controls are OPEN (dev mode)'); diff --git a/board/src/liveness.js b/board/src/liveness.js new file mode 100644 index 0000000..3446392 --- /dev/null +++ b/board/src/liveness.js @@ -0,0 +1,58 @@ +// board/src/liveness.js +// Tracks which learners' Pages sites are actually reachable (HTTP 200), so the +// board can show a ship as LIVE only when its *real* deploy responds — not just +// because report.sh POSTed an event. Pure of the HTTP server: hand it a roster +// (anything with .list() -> [{ callsign, siteUrl? }]) and it sweeps each entry's +// siteUrl on an interval, flipping a per-callsign boolean. +// +// Why a periodic re-check, not a one-shot verify: a fresh GitHub Pages deploy +// can 404 for ~1 min after the report lands. A single probe would miss it; the +// sweep flips the ship green the moment the site actually comes up. A non-200 or +// timeout means "not live yet," never an error. + +export const DEFAULTS = { interval: 30_000, timeout: 4_000, concurrency: 8 }; + +export function createLiveness({ + roster, + fetchImpl = fetch, + interval = DEFAULTS.interval, + timeout = DEFAULTS.timeout, + concurrency = DEFAULTS.concurrency, + onChange = () => {}, +} = {}) { + const live = new Map(); // callsign -> boolean + let timer = null; + + async function reachable(url) { + try { + const res = await fetchImpl(url, { + method: 'HEAD', + redirect: 'follow', + signal: AbortSignal.timeout(timeout), + }); + return res.status === 200; + } catch { return false; } // DNS/timeout/network → not live yet, not an error + } + + async function probe(entry) { + if (!entry?.siteUrl) return; + const ok = await reachable(entry.siteUrl); + if (live.get(entry.callsign) !== ok) { live.set(entry.callsign, ok); onChange(); } + } + + // One bounded-concurrency pass over every roster entry carrying a siteUrl. + async function sweep() { + const targets = roster.list().filter((e) => e.siteUrl); + for (let i = 0; i < targets.length; i += concurrency) { + await Promise.all(targets.slice(i, i + concurrency).map(probe)); + } + } + + return { + isLive: (callsign) => live.get(callsign) === true, + probe, // probe one entry now — call on arrival for snappy first-contact + sweep, // one full pass — exposed for tests + start() { if (!timer) { sweep(); timer = setInterval(sweep, interval); timer.unref?.(); } }, + stop() { if (timer) { clearInterval(timer); timer = null; } }, + }; +} diff --git a/board/src/messages.js b/board/src/messages.js index ce73b52..f9a14dd 100644 --- a/board/src/messages.js +++ b/board/src/messages.js @@ -3,3 +3,13 @@ export function parse(raw) { catch { return null; } } export const rosterMsg = (ships) => JSON.stringify({ t: 'roster', ships }); + +// Enrich race positions with each ship's roster appearance (color/shipModel). +export const raceMsg = (snap, view, clients, roster) => JSON.stringify({ + t: 'race', view, clients, + phase: snap.phase, total: snap.total, prompts: snap.prompts, + ships: snap.ships.map((s) => { + const r = roster.get(s.callsign); + return { ...s, color: r?.color, shipModel: r?.shipModel }; + }), +}); diff --git a/board/src/race.js b/board/src/race.js new file mode 100644 index 0000000..7d0c60a --- /dev/null +++ b/board/src/race.js @@ -0,0 +1,73 @@ +// board/src/race.js +// The in-memory, authoritative race. Pure and node-testable, like room.js. +// The server owns positions + phase; cockpits only report their next completion. + +const clamp01 = (n) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0); + +export class Race { + constructor({ total = 12 } = {}) { + this.total = total; + this.phase = 'idle'; // idle | running | finished + this.prompts = []; // identical ordered command list for every racer + this.racers = new Map(); // callsign -> { completed, finishedAt, frac } + this._seq = 0; // monotonic finish-order counter + } + + join(callsign) { + if (!this.racers.has(callsign)) this.racers.set(callsign, { completed: 0, finishedAt: null, frac: 0 }); + return this.racers.get(callsign); + } + + start(prompts) { + // The story sets the distance: total follows the prompt list (empty list + // keeps the configured default so progress math never divides by zero). + this.prompts = prompts.slice(); + if (this.prompts.length) this.total = this.prompts.length; + this.phase = 'running'; + this._seq = 0; + for (const r of this.racers.values()) { r.completed = 0; r.finishedAt = null; r.frac = 0; } + return this; + } + + progress(callsign, completed) { + if (this.phase !== 'running') return null; + const r = this.racers.get(callsign); + if (!r) return null; + if (completed !== r.completed + 1 || completed > this.total) return r; // out-of-order/replay + r.completed = completed; + r.frac = 0; + if (r.completed >= this.total && r.finishedAt == null) r.finishedAt = ++this._seq; + if (this._allFinished()) this.phase = 'finished'; + return r; + } + + // One entry point for cockpit reports: a completion advances; a same-index + // report only refreshes the display-only typing fraction. + report(callsign, completed, frac) { + if (this.phase !== 'running') return null; + const r = this.racers.get(callsign); + if (!r) return null; + if (completed === r.completed + 1) return this.progress(callsign, completed); + if (completed === r.completed) r.frac = clamp01(frac); + return r; + } + + reset() { + this.phase = 'idle'; + this.prompts = []; + for (const r of this.racers.values()) { r.completed = 0; r.finishedAt = null; r.frac = 0; } + } + + snapshot() { + const ships = [...this.racers.entries()].map(([callsign, r]) => ({ + callsign, completed: r.completed, finishedAt: r.finishedAt, frac: r.frac, + })); + return { phase: this.phase, total: this.total, prompts: this.prompts, ships }; + } + + _allFinished() { + if (this.racers.size === 0) return false; + for (const r of this.racers.values()) if (r.finishedAt == null) return false; + return true; + } +} diff --git a/board/src/room.js b/board/src/room.js index 657fc8e..f20ea96 100644 --- a/board/src/room.js +++ b/board/src/room.js @@ -22,7 +22,7 @@ function cleanUrl(v) { // cosmetics. Returns null when the event is unusable. export function sanitizeEvent(raw) { raw = raw ?? {}; - const callsign = cleanStr(raw.callsign, 39); // GitHub username max length + const callsign = cleanStr(raw.callsign, 39).toLowerCase(); // GitHub usernames are case-insensitive; Pages hostnames are lowercase — canonicalize if (!callsign) return null; if (!STAGES.includes(raw.stage)) return null; if (!STATUSES.includes(raw.status)) return null; @@ -46,4 +46,6 @@ export class Roster { upsert(event) { this.ships.set(event.callsign, event); return event; } // latest-wins list() { return [...this.ships.values()]; } get size() { return this.ships.size; } + has(callsign) { return this.ships.has(callsign); } + get(callsign) { return this.ships.get(callsign); } } diff --git a/board/test/corpus.test.js b/board/test/corpus.test.js new file mode 100644 index 0000000..562c52f --- /dev/null +++ b/board/test/corpus.test.js @@ -0,0 +1,39 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { STORIES, SESSIONS, pickPrompts } from '../src/corpus.js'; + +test('every session story is a non-empty list of distinct commands', () => { + for (const [session, story] of Object.entries(STORIES)) { + assert.ok(story.length > 0, `${session} empty`); + assert.equal(new Set(story).size, story.length, `${session} has duplicates`); + for (const cmd of story) assert.ok(typeof cmd === 'string' && cmd.length > 0); + } +}); + +test('pickPrompts returns the full story in slide order', () => { + assert.deepEqual(pickPrompts('cicd3'), STORIES.cicd3); + assert.deepEqual(pickPrompts('cicd4'), STORIES.cicd4); +}); + +test('pickPrompts returns a copy, not the story itself', () => { + const a = pickPrompts('cicd3'); + a.push('rm -rf /'); + assert.notEqual(a.length, STORIES.cicd3.length); +}); + +test('pickPrompts is empty for an unknown session', () => { + assert.deepEqual(pickPrompts('nope'), []); +}); + +test('SESSIONS exposes the same keys app.js validates against', () => { + assert.deepEqual(Object.keys(SESSIONS), Object.keys(STORIES)); + assert.ok(SESSIONS.cicd3); + assert.ok(SESSIONS.cicd4); +}); + +test('stories open with their sequencing anchors', () => { + // cicd3 starts by signing in and forking THE repo; cicd4 starts by syncing. + assert.equal(STORIES.cicd3[0], 'gh auth login'); + assert.ok(STORIES.cicd3[1].includes('devops-bootcamp-shipit')); + assert.equal(STORIES.cicd4[0], 'git fetch upstream'); +}); diff --git a/board/test/liveness.test.js b/board/test/liveness.test.js new file mode 100644 index 0000000..207f8d2 --- /dev/null +++ b/board/test/liveness.test.js @@ -0,0 +1,61 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createLiveness } from '../src/liveness.js'; + +const rosterOf = (...entries) => ({ list: () => entries }); +const ok = () => Promise.resolve({ status: 200 }); +const notFound = () => Promise.resolve({ status: 404 }); + +test('a reachable siteUrl (200) flips the ship live; onChange fires once per flip', async () => { + let changes = 0; + const roster = rosterOf({ callsign: 'octocat', siteUrl: 'https://octocat.github.io/x/' }); + const l = createLiveness({ roster, fetchImpl: ok, onChange: () => { changes += 1; } }); + await l.sweep(); + assert.equal(l.isLive('octocat'), true); + assert.equal(changes, 1); + await l.sweep(); // still 200 → no state change → no extra onChange + assert.equal(changes, 1); +}); + +test('a non-200 site is never live', async () => { + const roster = rosterOf({ callsign: 'mayday', siteUrl: 'https://mayday.github.io/x/' }); + const l = createLiveness({ roster, fetchImpl: notFound }); + await l.sweep(); + assert.equal(l.isLive('mayday'), false); +}); + +test('a fetch that throws (timeout/DNS) is treated as not-live, not an error', async () => { + const roster = rosterOf({ callsign: 'ghost', siteUrl: 'https://ghost.github.io/x/' }); + const l = createLiveness({ roster, fetchImpl: () => Promise.reject(new Error('timeout')) }); + await assert.doesNotReject(l.sweep()); + assert.equal(l.isLive('ghost'), false); +}); + +test('a first-deploy 404 that later turns 200 flips green on a re-sweep', async () => { + let phase = 404; + const roster = rosterOf({ callsign: 'late', siteUrl: 'https://late.github.io/x/' }); + const l = createLiveness({ roster, fetchImpl: () => Promise.resolve({ status: phase }) }); + await l.sweep(); + assert.equal(l.isLive('late'), false); + phase = 200; // deploy finished + await l.sweep(); + assert.equal(l.isLive('late'), true); +}); + +test('entries without a siteUrl are skipped (no fetch, never live)', async () => { + let called = 0; + const roster = rosterOf({ callsign: 'nosite' }); + const l = createLiveness({ roster, fetchImpl: () => { called += 1; return ok(); } }); + await l.sweep(); + assert.equal(called, 0); + assert.equal(l.isLive('nosite'), false); +}); + +test('HEAD is used with a timeout signal', async () => { + let seen = null; + const roster = rosterOf({ callsign: 'octocat', siteUrl: 'https://octocat.github.io/x/' }); + const l = createLiveness({ roster, fetchImpl: (url, opts) => { seen = opts; return ok(); } }); + await l.sweep(); + assert.equal(seen.method, 'HEAD'); + assert.ok(seen.signal, 'passes an AbortSignal so a hung site cannot stall the sweep'); +}); diff --git a/board/test/race.test.js b/board/test/race.test.js new file mode 100644 index 0000000..8d63bdf --- /dev/null +++ b/board/test/race.test.js @@ -0,0 +1,135 @@ +// board/test/race.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Race } from '../src/race.js'; + +const prompts = (n) => Array.from({ length: n }, (_, i) => `cmd${i + 1}`); + +test('join is idempotent and starts at the line', () => { + const r = new Race({ total: 3 }); + const a = r.join('octocat'); + assert.deepEqual(a, { completed: 0, finishedAt: null, frac: 0 }); + r.join('octocat'); + assert.equal(r.snapshot().ships.length, 1); +}); + +test('start sets running phase, prompts, and zeroes racers', () => { + const r = new Race({ total: 3 }); + r.join('octocat'); + r.start(prompts(3)); + assert.equal(r.phase, 'running'); + assert.deepEqual(r.prompts, ['cmd1', 'cmd2', 'cmd3']); + assert.equal(r.snapshot().ships[0].completed, 0); +}); + +test('progress advances only on the expected next index', () => { + const r = new Race({ total: 3 }); + r.join('octocat'); + r.start(prompts(3)); + assert.equal(r.progress('octocat', 1).completed, 1); + assert.equal(r.progress('octocat', 3).completed, 1); // gap ignored + assert.equal(r.progress('octocat', 1).completed, 1); // replay ignored + assert.equal(r.progress('octocat', 2).completed, 2); +}); + +test('progress is ignored when not running or unknown racer', () => { + const r = new Race({ total: 3 }); + assert.equal(r.progress('nobody', 1), null); // idle + r.join('octocat'); r.start(prompts(3)); + assert.equal(r.progress('ghost', 1), null); // not joined +}); + +test('finishing records finish order; all-finished flips phase', () => { + const r = new Race({ total: 2 }); + r.join('a'); r.join('b'); r.start(prompts(2)); + r.progress('a', 1); r.progress('a', 2); + assert.equal(r.phase, 'running'); // b not done + const a = r.snapshot().ships.find((s) => s.callsign === 'a'); + assert.equal(a.finishedAt, 1); + r.progress('b', 1); r.progress('b', 2); + assert.equal(r.phase, 'finished'); + const b = r.snapshot().ships.find((s) => s.callsign === 'b'); + assert.equal(b.finishedAt, 2); +}); + +test('reset returns to idle and zeroes racers but keeps them joined', () => { + const r = new Race({ total: 2 }); + r.join('a'); r.start(prompts(2)); r.progress('a', 1); + r.reset(); + assert.equal(r.phase, 'idle'); + assert.deepEqual(r.prompts, []); + assert.equal(r.snapshot().ships[0].completed, 0); +}); + +test('total follows the prompt list across rounds of differing length', () => { + const r = new Race({ total: 3 }); + r.start(prompts(2)); + assert.equal(r.total, 2); + r.reset(); + r.start(prompts(5)); + assert.equal(r.total, 5); + assert.equal(r.prompts.length, 5); + r.reset(); + r.start([]); // empty list keeps the previous total + assert.equal(r.total, 5); +}); + +test('finish keys off the dynamic total', () => { + const r = new Race({ total: 12 }); + r.join('a'); + r.start(prompts(2)); + r.progress('a', 1); + assert.equal(r.phase, 'running'); + r.progress('a', 2); + assert.equal(r.phase, 'finished'); +}); + +test('report stores clamped frac without advancing', () => { + const r = new Race({ total: 3 }); + r.join('octocat'); + r.start(prompts(3)); + r.report('octocat', 0, 0.5); + let s = r.snapshot().ships[0]; + assert.equal(s.completed, 0); + assert.equal(s.frac, 0.5); + r.report('octocat', 0, 7); // over → clamp 1 + assert.equal(r.snapshot().ships[0].frac, 1); + r.report('octocat', 0, -2); // under → clamp 0 + assert.equal(r.snapshot().ships[0].frac, 0); + r.report('octocat', 0, 'zzz'); // junk → 0 + assert.equal(r.snapshot().ships[0].frac, 0); +}); + +test('report advances on next index and zeroes frac; gaps ignored', () => { + const r = new Race({ total: 3 }); + r.join('octocat'); + r.start(prompts(3)); + r.report('octocat', 0, 0.9); + r.report('octocat', 1, 0); // completion + let s = r.snapshot().ships[0]; + assert.equal(s.completed, 1); + assert.equal(s.frac, 0); + r.report('octocat', 3, 0.5); // gap → fully ignored + s = r.snapshot().ships[0]; + assert.equal(s.completed, 1); + assert.equal(s.frac, 0); +}); + +test('report is ignored when idle or racer unknown', () => { + const r = new Race({ total: 3 }); + assert.equal(r.report('nobody', 0, 0.5), null); // idle + r.join('octocat'); + r.start(prompts(3)); + assert.equal(r.report('ghost', 0, 0.5), null); // not joined +}); + +test('start and reset zero frac', () => { + const r = new Race({ total: 3 }); + r.join('octocat'); + r.start(prompts(3)); + r.report('octocat', 0, 0.7); + r.reset(); + assert.equal(r.snapshot().ships[0].frac, 0); + r.start(prompts(3)); + assert.equal(r.snapshot().ships[0].frac, 0); +}); diff --git a/board/test/room.test.js b/board/test/room.test.js index fcaa38a..d439e7e 100644 --- a/board/test/room.test.js +++ b/board/test/room.test.js @@ -16,6 +16,10 @@ test('sanitizeEvent trims + caps callsign, rejects empty', () => { assert.equal(sanitizeEvent({ ...base, callsign: 123 }), null); }); +test('sanitizeEvent lowercases the callsign (case-insensitive usernames; lowercase Pages hostnames)', () => { + assert.equal(sanitizeEvent({ ...base, callsign: 'JohnDoe' }).callsign, 'johndoe'); +}); + test('sanitizeEvent rejects unknown stage/status', () => { assert.equal(sanitizeEvent({ ...base, stage: 'orbit' }), null); assert.equal(sanitizeEvent({ ...base, status: 'exploded' }), null); diff --git a/board/test/server.test.js b/board/test/server.test.js index 476dd0a..f965132 100644 --- a/board/test/server.test.js +++ b/board/test/server.test.js @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import WebSocket from 'ws'; import { createServer } from '../src/app.js'; +import { STORIES } from '../src/corpus.js'; const post = (port, body, headers = {}) => fetch(`http://localhost:${port}/api/event`, { @@ -83,3 +84,77 @@ test('POST shipModel survives into the ws roster', async () => { spectator.close(); } finally { await app.close(); } }); + +const opHeader = (key) => ({ authorization: `Bearer ${key}` }); +const postTo = (port, path, body, headers = {}) => + fetch(`http://localhost:${port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body || {}), + }); + +test('cockpit join is denied for a callsign not on the roster', async () => { + const app = createServer({ port: 0, token: null, operatorKey: null }); + const port = app.port; + try { + const cockpit = await openClient(port); + await nextMsg(cockpit, (m) => m.t === 'roster'); + cockpit.send(JSON.stringify({ t: 'join', callsign: 'stranger' })); + const denied = await nextMsg(cockpit, (m) => m.t === 'denied'); + assert.equal(denied.reason, 'not-on-roster'); + cockpit.close(); + } finally { await app.close(); } +}); + +test('operator can start a race; cockpit progress advances the snapshot', async () => { + const app = createServer({ port: 0, token: null, operatorKey: 'op-key' }); + const port = app.port; + try { + await post(port, ev); // ev = octocat, from existing top-of-file fixture — now on the roster + const cockpit = await openClient(port); + await nextMsg(cockpit, (m) => m.t === 'roster'); + cockpit.send(JSON.stringify({ t: 'join', callsign: 'octocat' })); + + assert.equal((await postTo(port, '/api/race/start', { session: 'cicd3' }, opHeader('op-key'))).status, 202); + const running = await nextMsg(cockpit, (m) => m.t === 'race' && m.phase === 'running'); + assert.deepEqual(running.prompts, STORIES.cicd3); // the full story, in slide order + assert.equal(running.total, STORIES.cicd3.length); + const mine = running.ships.find((s) => s.callsign === 'octocat'); + assert.equal(mine.completed, 0); + assert.equal(mine.color, '#22d3ee'); // enriched from the roster + + cockpit.send(JSON.stringify({ t: 'progress', completed: 1 })); + const advanced = await nextMsg(cockpit, (m) => m.t === 'race' && m.ships.some((s) => s.callsign === 'octocat' && s.completed === 1)); + assert.ok(advanced); + cockpit.close(); + } finally { await app.close(); } +}); + +test('operator endpoints require the operator key when set', async () => { + const app = createServer({ port: 0, token: null, operatorKey: 'op-key' }); + const port = app.port; + try { + assert.equal((await postTo(port, '/api/race/start', {})).status, 401); + assert.equal((await postTo(port, '/api/race/start', {}, opHeader('wrong'))).status, 401); + assert.equal((await postTo(port, '/api/view', { view: 'race' }, opHeader('op-key'))).status, 202); + } finally { await app.close(); } +}); + +test('cockpit frac ripples into the race broadcast without advancing', async () => { + const app = createServer({ port: 0, token: null, operatorKey: 'op-key' }); + const port = app.port; + try { + await post(port, ev); + const cockpit = await openClient(port); + await nextMsg(cockpit, (m) => m.t === 'roster'); + cockpit.send(JSON.stringify({ t: 'join', callsign: 'octocat' })); + await postTo(port, '/api/race/start', { session: 'cicd3' }, opHeader('op-key')); + await nextMsg(cockpit, (m) => m.t === 'race' && m.phase === 'running'); + + cockpit.send(JSON.stringify({ t: 'progress', completed: 0, frac: 0.5 })); + const partial = await nextMsg(cockpit, (m) => + m.t === 'race' && m.ships.some((s) => s.callsign === 'octocat' && s.frac === 0.5)); + assert.equal(partial.ships.find((s) => s.callsign === 'octocat').completed, 0); + cockpit.close(); + } finally { await app.close(); } +}); diff --git a/board/vite.config.js b/board/vite.config.js index ead9eae..3a9d814 100644 --- a/board/vite.config.js +++ b/board/vite.config.js @@ -1,9 +1,18 @@ import { defineConfig } from 'vite'; +import { fileURLToPath } from 'node:url'; + +const r = (p) => fileURLToPath(new URL(p, import.meta.url)); // The client lives in client/; build it to board/dist, which the Node server -// serves static. base: './' so it works behind any path. +// serves static. base: './' so it works behind any path. Three pages: the +// projector spectator (index.html), the laptop cockpit (play.html), and the +// instructor operator console (operator.html). export default defineConfig({ root: 'client', base: './', - build: { outDir: '../dist', emptyOutDir: true }, + build: { + outDir: '../dist', + emptyOutDir: true, + rollupOptions: { input: { main: r('client/index.html'), play: r('client/play.html'), operator: r('client/operator.html') } }, + }, }); diff --git a/docs/plans/2026-07-18-typing-race-mode.md b/docs/plans/2026-07-18-typing-race-mode.md new file mode 100644 index 0000000..22e8572 --- /dev/null +++ b/docs/plans/2026-07-18-typing-race-mode.md @@ -0,0 +1,1296 @@ +# Typing Race Mode Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a multiplayer typing-race mode to the board (server-authoritative race state, a `/play` cockpit, a Three.js orthographic race view, operator control) and a READY button to the launchpad, reusing the existing S3 report/roster contract. + +**Architecture:** The board already is a WebSocket hub with an in-memory roster broadcast on a 50 ms tick. This plan adds a pure, node-testable race state machine (`race.js`) and command corpus (`corpus.js`) beside the existing `room.js`; wires inbound cockpit messages (join/progress) and operator HTTP endpoints into `app.js`; and adds two board client surfaces — a typing cockpit (`play.html`) and a Three.js ortho race view that reuses the existing ship meshes. The launchpad stays a static site; it only derives the learner's callsign from `location.hostname` and links to `BOARD_URL/play?callsign=…`. + +**Tech Stack:** Node 20 ESM, `ws`, Three.js (bundled via Vite, no CDN), Node's built-in `node --test`. + +## Global Constraints + +- Node 20, ESM only. No CDN — Three.js is bundled by Vite. +- Tests: Node's built-in `node --test` only. **No vitest, no Playwright.** The one learner-facing gate stays `launchpad/scripts/preflight.mjs`. +- `SHIPIT_TOKEN` is a server-only secret — it must **never** appear in any client bundle. The cockpit WebSocket is unauthenticated by design. +- Callsign identity = the learner's GitHub username, derived at runtime from `location.hostname`. Do **not** add `VITE_CALLSIGN` to any workflow. +- Race entry is gated on the roster: a callsign may only race if it is already present (green pipeline = the ticket). +- Board `board/src/ships.js` must stay byte-identical to `launchpad/src/ship-schema.js` for the shared fields — do not edit the ship registry / hue math in this plan. +- Theme-aware, WebGL + reduced-motion fallbacks (follow the existing `fallback.js` pattern). +- Pure logic lives in its own module with a `node --test` file (repo pattern: `orbit.js` ↔ `orbit.test.js`); DOM/Three render shells are thin and unit-test-free. + +--- + +### Task 1: Race state machine (`board/src/race.js`) + +The authoritative, in-memory race. Pure and node-testable, mirroring `room.js`. The server owns each racer's position and the round phase; clients only report completions. + +**Files:** +- Create: `board/src/race.js` +- Test: `board/test/race.test.js` + +**Interfaces:** +- Consumes: nothing (pure). +- Produces: + - `class Race` + - `new Race({ total = 12 })` + - `race.phase` → `'idle' | 'running' | 'finished'` + - `race.total` → number + - `race.prompts` → `string[]` + - `race.join(callsign)` → racer record `{ completed, finishedAt }` (idempotent; adds at start line) + - `race.start(prompts)` → `this` (sets prompts, phase `running`, resets all racers) + - `race.progress(callsign, completed)` → racer record or `null` (accepts only the expected next index) + - `race.reset()` → sets phase `idle`, clears prompts, zeroes racers + - `race.snapshot()` → `{ phase, total, prompts, ships: [{ callsign, completed, finishedAt }] }` + +- [ ] **Step 1: Write the failing test** + +```js +// board/test/race.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Race } from '../src/race.js'; + +const prompts = (n) => Array.from({ length: n }, (_, i) => `cmd${i + 1}`); + +test('join is idempotent and starts at the line', () => { + const r = new Race({ total: 3 }); + const a = r.join('octocat'); + assert.deepEqual(a, { completed: 0, finishedAt: null }); + r.join('octocat'); + assert.equal(r.snapshot().ships.length, 1); +}); + +test('start sets running phase, prompts, and zeroes racers', () => { + const r = new Race({ total: 3 }); + r.join('octocat'); + r.start(prompts(3)); + assert.equal(r.phase, 'running'); + assert.deepEqual(r.prompts, ['cmd1', 'cmd2', 'cmd3']); + assert.equal(r.snapshot().ships[0].completed, 0); +}); + +test('progress advances only on the expected next index', () => { + const r = new Race({ total: 3 }); + r.join('octocat'); + r.start(prompts(3)); + assert.equal(r.progress('octocat', 1).completed, 1); + assert.equal(r.progress('octocat', 3).completed, 1); // gap ignored + assert.equal(r.progress('octocat', 1).completed, 1); // replay ignored + assert.equal(r.progress('octocat', 2).completed, 2); +}); + +test('progress is ignored when not running or unknown racer', () => { + const r = new Race({ total: 3 }); + assert.equal(r.progress('nobody', 1), null); // idle + r.join('octocat'); r.start(prompts(3)); + assert.equal(r.progress('ghost', 1), null); // not joined +}); + +test('finishing records finish order; all-finished flips phase', () => { + const r = new Race({ total: 2 }); + r.join('a'); r.join('b'); r.start(prompts(2)); + r.progress('a', 1); r.progress('a', 2); + assert.equal(r.phase, 'running'); // b not done + const a = r.snapshot().ships.find((s) => s.callsign === 'a'); + assert.equal(a.finishedAt, 1); + r.progress('b', 1); r.progress('b', 2); + assert.equal(r.phase, 'finished'); + const b = r.snapshot().ships.find((s) => s.callsign === 'b'); + assert.equal(b.finishedAt, 2); +}); + +test('reset returns to idle and zeroes racers but keeps them joined', () => { + const r = new Race({ total: 2 }); + r.join('a'); r.start(prompts(2)); r.progress('a', 1); + r.reset(); + assert.equal(r.phase, 'idle'); + assert.deepEqual(r.prompts, []); + assert.equal(r.snapshot().ships[0].completed, 0); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd board && node --test test/race.test.js` +Expected: FAIL — `Cannot find module '../src/race.js'`. + +- [ ] **Step 3: Write minimal implementation** + +```js +// board/src/race.js +// The in-memory, authoritative race. Pure and node-testable, like room.js. +// The server owns positions + phase; cockpits only report their next completion. +export class Race { + constructor({ total = 12 } = {}) { + this.total = total; + this.phase = 'idle'; // idle | running | finished + this.prompts = []; // identical ordered command list for every racer + this.racers = new Map(); // callsign -> { completed, finishedAt } + this._seq = 0; // monotonic finish-order counter + } + + join(callsign) { + if (!this.racers.has(callsign)) this.racers.set(callsign, { completed: 0, finishedAt: null }); + return this.racers.get(callsign); + } + + start(prompts) { + this.prompts = prompts.slice(0, this.total); + this.total = this.prompts.length; + this.phase = 'running'; + this._seq = 0; + for (const r of this.racers.values()) { r.completed = 0; r.finishedAt = null; } + return this; + } + + progress(callsign, completed) { + if (this.phase !== 'running') return null; + const r = this.racers.get(callsign); + if (!r) return null; + if (completed !== r.completed + 1 || completed > this.total) return r; // out-of-order/replay + r.completed = completed; + if (r.completed >= this.total && r.finishedAt == null) r.finishedAt = ++this._seq; + if (this._allFinished()) this.phase = 'finished'; + return r; + } + + reset() { + this.phase = 'idle'; + this.prompts = []; + for (const r of this.racers.values()) { r.completed = 0; r.finishedAt = null; } + } + + snapshot() { + const ships = [...this.racers.entries()].map(([callsign, r]) => ({ + callsign, completed: r.completed, finishedAt: r.finishedAt, + })); + return { phase: this.phase, total: this.total, prompts: this.prompts, ships }; + } + + _allFinished() { + if (this.racers.size === 0) return false; + for (const r of this.racers.values()) if (r.finishedAt == null) return false; + return true; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd board && node --test test/race.test.js` +Expected: PASS — all 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add board/src/race.js board/test/race.test.js +git commit -m "feat(board): authoritative race state machine" +``` + +--- + +### Task 2: Session-gated command corpus (`board/src/corpus.js`) + +The pool of CLI commands the race draws prompts from, filtered by session so a race only ever shows commands taught up to that point. + +**Files:** +- Create: `board/src/corpus.js` +- Test: `board/test/corpus.test.js` + +**Interfaces:** +- Consumes: nothing (pure). +- Produces: + - `CORPUS` → `{ linux: string[], git: string[], gh: string[], docker: string[], aws: string[] }` + - `SESSIONS` → `{ cicd3: string[], cicd4: string[] }` (tool keys unlocked per session) + - `pool(session)` → `string[]` (flattened unlocked commands; empty array for unknown session) + - `pickPrompts(session, n = 12, rand = Math.random)` → `string[]` (n distinct commands) + +- [ ] **Step 1: Write the failing test** + +```js +// board/test/corpus.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { CORPUS, SESSIONS, pool, pickPrompts } from '../src/corpus.js'; + +test('pool flattens the unlocked tools for a session', () => { + const p = pool('cicd3'); + assert.ok(p.includes('git status')); + assert.ok(p.includes('docker build -t')); + assert.equal(p.length, SESSIONS.cicd3.reduce((n, k) => n + CORPUS[k].length, 0)); +}); + +test('pool is empty for an unknown session', () => { + assert.deepEqual(pool('nope'), []); +}); + +test('pickPrompts returns n distinct commands from the pool', () => { + const picks = pickPrompts('cicd3', 12); + assert.equal(picks.length, 12); + assert.equal(new Set(picks).size, 12); + const p = pool('cicd3'); + for (const cmd of picks) assert.ok(p.includes(cmd)); +}); + +test('pickPrompts is deterministic given a rand stub', () => { + const first = () => 0; + const a = pickPrompts('cicd3', 5, first); + const b = pickPrompts('cicd3', 5, first); + assert.deepEqual(a, b); +}); + +test('pickPrompts caps at pool size', () => { + const picks = pickPrompts('cicd3', 99999); + assert.equal(picks.length, pool('cicd3').length); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd board && node --test test/corpus.test.js` +Expected: FAIL — `Cannot find module '../src/corpus.js'`. + +- [ ] **Step 3: Write minimal implementation** + +```js +// board/src/corpus.js +// Commands the race can quiz, grouped by tool. Session-gated so a race only +// shows commands taught up to that point (inventory: slides repo, up to CI/CD 3). +export const CORPUS = { + linux: [ + 'ls -l', 'cd ..', 'pwd', 'cat file.txt', 'mkdir build', 'touch app.js', + 'cp a.txt b.txt', 'mv old new', 'rm -f tmp', 'chmod +x run.sh', 'grep -r TODO', + 'ps aux', 'kill -9 123', 'curl -sS localhost', 'ssh ec2-user@host', 'tail -f log', + ], + git: [ + 'git init', 'git status', 'git add .', 'git commit -m "wip"', 'git push', + 'git pull', 'git switch main', 'git checkout -b feat', 'git merge dev', 'git log --oneline', + ], + gh: [ + 'gh repo fork', 'gh repo sync', 'gh pr create', 'gh pr merge', 'gh secret set SHIPIT_TOKEN', + 'gh variable set BOARD_URL', 'gh secret list', 'gh workflow view', + ], + docker: [ + 'docker build -t app .', 'docker run -d -p 3000:3000 app', 'docker ps -a', 'docker pull nginx', + 'docker logs -f app', 'docker exec -it app sh', 'docker stop app', 'docker push app', 'docker compose up -d', + ], + aws: [ + 'aws configure', 'aws sts get-caller-identity', 'aws s3 ls', 'aws s3 cp f s3://b', + 'aws ec2 describe-instances', 'aws ssm start-session', 'aws ecr get-login-password', + ], +}; + +// Which tool pools are unlocked by (i.e. taught by) a given session. +export const SESSIONS = { + cicd3: ['linux', 'git', 'gh', 'docker', 'aws'], + cicd4: ['linux', 'git', 'gh', 'docker', 'aws'], +}; + +export function pool(session) { + const tools = SESSIONS[session] || []; + return tools.flatMap((k) => CORPUS[k] || []); +} + +export function pickPrompts(session, n = 12, rand = Math.random) { + const avail = [...pool(session)]; + const picked = []; + while (picked.length < n && avail.length) { + const i = Math.floor(rand() * avail.length); + picked.push(avail.splice(i, 1)[0]); + } + return picked; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd board && node --test test/corpus.test.js` +Expected: PASS — all 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add board/src/corpus.js board/test/corpus.test.js +git commit -m "feat(board): session-gated command corpus" +``` + +--- + +### Task 3: Server wiring — cockpit WebSocket, roster gate, operator control (`board/src/app.js`) + +Wire the race + corpus into the HTTP/WS server: accept inbound cockpit `join`/`progress`, gate `join` on the roster, add operator HTTP endpoints to start/reset a race and toggle the projector view, and broadcast an enriched race snapshot on its own dirty tick. + +**Files:** +- Modify: `board/src/room.js` (add `has`/`get` to `Roster`) +- Modify: `board/src/messages.js` (add `raceMsg`) +- Modify: `board/src/app.js` (cockpit WS, operator endpoints, race broadcast) +- Modify: `board/src/index.js` (read `OPERATOR_KEY`) +- Test: `board/test/server.test.js` (append cases) + +**Interfaces:** +- Consumes: `Race` (Task 1), `pickPrompts` (Task 2), `Roster`/`sanitizeEvent` (existing), `parse`/`rosterMsg` (existing). +- Produces: + - `Roster.prototype.has(callsign)` → boolean + - `Roster.prototype.get(callsign)` → event or undefined + - `raceMsg(snapshot, view, clients)` → JSON string `{ t:'race', view, clients, phase, total, prompts, ships }` where each `ship` is enriched with `color`/`shipModel` from the roster + - `createServer({ port, token, operatorKey, publicDir })` — now also handles `POST /api/race/start` `{ session }`, `POST /api/race/reset`, `POST /api/view` `{ view }` (all guarded by `operatorKey` when set), and inbound cockpit WS messages + - Cockpit inbound: `{ t:'join', callsign }`, `{ t:'progress', completed }` + - Server → cockpit on gate failure: `{ t:'denied', reason:'not-on-roster' }` + +- [ ] **Step 1: Write the failing test** + +```js +// board/test/server.test.js — APPEND these to the existing file +import { Race } from '../src/race.js'; // (add alongside existing imports if not present) + +const opHeader = (key) => ({ authorization: `Bearer ${key}` }); +const postTo = (port, path, body, headers = {}) => + fetch(`http://localhost:${port}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body || {}), + }); + +test('cockpit join is denied for a callsign not on the roster', async () => { + const app = createServer({ port: 0, token: null, operatorKey: null }); + const port = app.port; + try { + const cockpit = await openClient(port); + await nextMsg(cockpit, (m) => m.t === 'roster'); + cockpit.send(JSON.stringify({ t: 'join', callsign: 'stranger' })); + const denied = await nextMsg(cockpit, (m) => m.t === 'denied'); + assert.equal(denied.reason, 'not-on-roster'); + cockpit.close(); + } finally { await app.close(); } +}); + +test('operator can start a race; cockpit progress advances the snapshot', async () => { + const app = createServer({ port: 0, token: null, operatorKey: 'op-key' }); + const port = app.port; + try { + await post(port, ev); // ev = octocat, from existing top-of-file fixture — now on the roster + const cockpit = await openClient(port); + await nextMsg(cockpit, (m) => m.t === 'roster'); + cockpit.send(JSON.stringify({ t: 'join', callsign: 'octocat' })); + + assert.equal((await postTo(port, '/api/race/start', { session: 'cicd3' }, opHeader('op-key'))).status, 202); + const running = await nextMsg(cockpit, (m) => m.t === 'race' && m.phase === 'running'); + assert.equal(running.prompts.length, 12); + const mine = running.ships.find((s) => s.callsign === 'octocat'); + assert.equal(mine.completed, 0); + assert.equal(mine.color, '#22d3ee'); // enriched from the roster + + cockpit.send(JSON.stringify({ t: 'progress', completed: 1 })); + const advanced = await nextMsg(cockpit, (m) => m.t === 'race' && m.ships.some((s) => s.callsign === 'octocat' && s.completed === 1)); + assert.ok(advanced); + cockpit.close(); + } finally { await app.close(); } +}); + +test('operator endpoints require the operator key when set', async () => { + const app = createServer({ port: 0, token: null, operatorKey: 'op-key' }); + const port = app.port; + try { + assert.equal((await postTo(port, '/api/race/start', {})).status, 401); + assert.equal((await postTo(port, '/api/race/start', {}, opHeader('wrong'))).status, 401); + assert.equal((await postTo(port, '/api/view', { view: 'race' }, opHeader('op-key'))).status, 202); + } finally { await app.close(); } +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd board && node --test test/server.test.js` +Expected: FAIL — join is ignored (no `denied`), `/api/race/start` 404s. + +- [ ] **Step 3a: Add roster helpers** + +```js +// board/src/room.js — add two methods inside class Roster + has(callsign) { return this.ships.has(callsign); } + get(callsign) { return this.ships.get(callsign); } +``` + +- [ ] **Step 3b: Add the race message builder** + +```js +// board/src/messages.js — append +// Enrich race positions with each ship's roster appearance (color/shipModel). +export const raceMsg = (snap, view, clients, roster) => JSON.stringify({ + t: 'race', view, clients, + phase: snap.phase, total: snap.total, prompts: snap.prompts, + ships: snap.ships.map((s) => { + const r = roster.get(s.callsign); + return { ...s, color: r?.color, shipModel: r?.shipModel }; + }), +}); +``` + +- [ ] **Step 3c: Rewrite `board/src/app.js`** + +```js +// board/src/app.js +import http from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { WebSocketServer } from 'ws'; +import { Roster, sanitizeEvent } from './room.js'; +import { parse, rosterMsg, raceMsg } from './messages.js'; +import { Race } from './race.js'; +import { pickPrompts } from './corpus.js'; + +const DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dist'); +const MIME = { + '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', + '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', + '.glb': 'model/gltf-binary', +}; + +function send(ws, msg) { try { if (ws.readyState === 1) ws.send(msg); } catch { /* ignore */ } } +const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); }; + +// Constant-time bearer check. +function authorized(req, token) { + const m = /^Bearer (.+)$/.exec(req.headers['authorization'] || ''); + if (!m) return false; + const a = Buffer.from(m[1]), b = Buffer.from(token); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +function readBody(req, limit = 64 * 1024) { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', (c) => { data += c; if (data.length > limit) req.destroy(new Error('too large')); }); + req.on('end', () => resolve(data)); + req.on('error', reject); + }); +} + +export function createServer({ port = 3000, token = null, operatorKey = null, publicDir = DIST } = {}) { + const roster = new Roster(); + const race = new Race({ total: 12 }); + const clients = new Set(); + let view = 'orbit'; // projector view: 'orbit' | 'race' + let session = 'cicd3'; + let dirty = false; // roster changed + let raceDirty = false; // race state or view changed + + // Operator-guarded control endpoints. When operatorKey is null, open (dev). + function operate(req, res, fn) { + if (operatorKey && !authorized(req, operatorKey)) return json(res, 401, { error: 'unauthorized' }); + return fn(); + } + + const server = http.createServer(async (req, res) => { + try { + if (req.method === 'POST' && req.url === '/api/event') { + if (token && !authorized(req, token)) return json(res, 401, { error: 'unauthorized' }); + const event = sanitizeEvent(parse(await readBody(req)) || {}); + if (!event) return json(res, 400, { error: 'invalid event: need callsign + known stage/status' }); + roster.upsert(event); + dirty = true; + return json(res, 202, { ok: true }); + } + if (req.method === 'POST' && req.url === '/api/race/start') { + const body = parse(await readBody(req)) || {}; + return operate(req, res, () => { + if (body.session) session = String(body.session); + race.start(pickPrompts(session, race.total)); + raceDirty = true; + return json(res, 202, { ok: true }); + }); + } + if (req.method === 'POST' && req.url === '/api/race/reset') { + await readBody(req); + return operate(req, res, () => { race.reset(); raceDirty = true; return json(res, 202, { ok: true }); }); + } + if (req.method === 'POST' && req.url === '/api/view') { + const body = parse(await readBody(req)) || {}; + return operate(req, res, () => { + if (body.view === 'orbit' || body.view === 'race') view = body.view; + raceDirty = true; + return json(res, 202, { ok: true }); + }); + } + // static: serve the Vite-built client + let rel = decodeURIComponent((req.url || '/').split('?')[0]); + if (rel === '/' || rel === '') rel = '/index.html'; + const file = path.join(publicDir, path.normalize(rel)); + if (!file.startsWith(publicDir)) { res.writeHead(403); return res.end('forbidden'); } + const buf = await readFile(file); + res.writeHead(200, { 'content-type': MIME[path.extname(file)] || 'application/octet-stream' }); + res.end(buf); + } catch { res.writeHead(404); res.end('not found'); } + }); + + const wss = new WebSocketServer({ server }); + wss.on('connection', (ws) => { + clients.add(ws); + dirty = true; raceDirty = true; // snapshot on next tick (see room note) + ws.on('message', (buf) => { + let m; try { m = JSON.parse(buf.toString()); } catch { return; } + if (m.t === 'join' && typeof m.callsign === 'string') { + if (!roster.has(m.callsign)) return send(ws, JSON.stringify({ t: 'denied', reason: 'not-on-roster' })); + ws.callsign = m.callsign; + race.join(m.callsign); + raceDirty = true; + } else if (m.t === 'progress' && ws.callsign && Number.isInteger(m.completed)) { + race.progress(ws.callsign, m.completed); + raceDirty = true; + } + }); + const drop = () => clients.delete(ws); + ws.on('close', drop); + ws.on('error', drop); + }); + + const tick = setInterval(() => { + if (dirty) { dirty = false; const msg = rosterMsg(roster.list()); for (const ws of clients) send(ws, msg); } + if (raceDirty) { raceDirty = false; const msg = raceMsg(race.snapshot(), view, clients.size, roster); for (const ws of clients) send(ws, msg); } + }, 50); + + server.listen(port); + return { + get port() { const a = server.address(); return a && typeof a === 'object' ? a.port : port; }, + roster, race, server, wss, + close() { clearInterval(tick); wss.close(); return new Promise((r) => server.close(r)); }, + }; +} +``` + +Note on the operator endpoints: each reads its JSON body with `await readBody(req)` *before* calling `operate`, because `readBody` is async while `operate`'s callback is sync — the `await` cannot live inside the callback. + +- [ ] **Step 3d: Wire `OPERATOR_KEY` in the entrypoint** + +```js +// board/src/index.js +import { createServer } from './app.js'; + +const port = Number(process.env.PORT) || 3000; +const token = process.env.SHIPIT_TOKEN || null; +const operatorKey = process.env.OPERATOR_KEY || null; + +createServer({ port, token, operatorKey }); + +if (token) { + console.log(`[board] Mission Control on :${port} — auth ENFORCED (Bearer $SHIPIT_TOKEN)`); +} else { + console.warn('[board] SHIPIT_TOKEN unset — accepting UNAUTHENTICATED events (dev mode)'); + console.log(`[board] Mission Control on :${port}`); +} +if (!operatorKey) console.warn('[board] OPERATOR_KEY unset — race controls are OPEN (dev mode)'); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd board && node --test` +Expected: PASS — existing `room`/`server` tests plus the three new server cases and Tasks 1–2. + +- [ ] **Step 5: Commit** + +```bash +git add board/src/app.js board/src/index.js board/src/room.js board/src/messages.js board/test/server.test.js +git commit -m "feat(board): cockpit ws + roster-gated join + operator race control" +``` + +--- + +### Task 4: Launchpad callsign derivation (`launchpad/src/callsign.js`) + +Derive the learner's GitHub username from the Pages hostname at runtime — no build var, no pin reversal. + +**Files:** +- Create: `launchpad/src/callsign.js` +- Test: `launchpad/src/callsign.test.mjs` + +**Interfaces:** +- Consumes: nothing (pure for the tested part). +- Produces: + - `callsignFromHostname(hostname)` → string (username for `.github.io`, else `''`) + - `resolveCallsign()` → string (hostname first, then `VITE_CALLSIGN`, then `''`) — thin runtime wrapper + +- [ ] **Step 1: Write the failing test** + +```js +// launchpad/src/callsign.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { callsignFromHostname } from './callsign.js'; + +test('extracts the username from a github.io hostname', () => { + assert.equal(callsignFromHostname('octocat.github.io'), 'octocat'); + assert.equal(callsignFromHostname('My-User.github.io'), 'my-user'); // lowercased +}); + +test('returns empty for non-Pages hostnames', () => { + assert.equal(callsignFromHostname('localhost'), ''); + assert.equal(callsignFromHostname('example.com'), ''); + assert.equal(callsignFromHostname('octocat.github.io.evil.com'), ''); + assert.equal(callsignFromHostname(''), ''); + assert.equal(callsignFromHostname(undefined), ''); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd launchpad && node --test src/callsign.test.mjs` +Expected: FAIL — `Cannot find module './callsign.js'`. + +- [ ] **Step 3: Write minimal implementation** + +```js +// launchpad/src/callsign.js +// Identity = the learner's GitHub username, derived from the Pages hostname at +// runtime: `user.github.io` (user + project pages both) -> `user`. No build var, +// no VITE_CALLSIGN in the taught workflow. Falls back to VITE_CALLSIGN then ''. +export function callsignFromHostname(hostname) { + if (typeof hostname !== 'string') return ''; + const m = /^([a-z0-9-]+)\.github\.io$/.exec(hostname.toLowerCase()); + return m ? m[1] : ''; +} + +export function resolveCallsign() { + return callsignFromHostname(window.location.hostname) || import.meta.env.VITE_CALLSIGN || ''; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd launchpad && node --test src/callsign.test.mjs` +Expected: PASS — both tests. + +- [ ] **Step 5: Commit** + +```bash +git add launchpad/src/callsign.js launchpad/src/callsign.test.mjs +git commit -m "feat(launchpad): derive callsign from Pages hostname" +``` + +--- + +### Task 5: Launchpad READY button + +Give the static site a button that carries the learner to the board cockpit as themselves. Inert (hidden) until both a callsign and `VITE_BOARD_URL` are known. + +**Files:** +- Create: `launchpad/src/ready.js` +- Modify: `launchpad/src/main.js` (use `resolveCallsign`, mount the button) +- Modify: `launchpad/src/style.css` (button styling) + +**Interfaces:** +- Consumes: `resolveCallsign` (Task 4). +- Produces: `renderReady(root, callsign)` → the button element or `null` (null when no callsign or no `VITE_BOARD_URL`). + +- [ ] **Step 1: Write `ready.js`** + +```js +// launchpad/src/ready.js +// The one bridge from the static site to the server: a link to the board cockpit, +// carrying the learner's callsign. Hidden unless we know both who they are and +// where the board is. BOARD_URL is a public build var — never a secret. +export function readyHref(boardUrl, callsign) { + if (!boardUrl || !callsign) return null; + return `${boardUrl.replace(/\/$/, '')}/play?callsign=${encodeURIComponent(callsign)}`; +} + +export function renderReady(root, callsign) { + const href = readyHref(import.meta.env.VITE_BOARD_URL, callsign); + if (!href) return null; + const a = document.createElement('a'); + a.className = 'ready'; + a.href = href; + a.textContent = 'READY ▸ JOIN RACE'; + root.append(a); + return a; +} +``` + +- [ ] **Step 2: Wire it into `main.js`** + +Replace the callsign line and add the button mount. In `launchpad/src/main.js`: + +```js +// change the import block top of file — add: +import { resolveCallsign } from './callsign.js'; +import { renderReady } from './ready.js'; + +// replace: const callsign = import.meta.env.VITE_CALLSIGN || ''; +const callsign = resolveCallsign(); +``` + +Then, at the very end of the file (after the `if/else` fallback block), mount the button so it appears in both the WebGL and fallback paths: + +```js +renderReady(app, callsign); +``` + +- [ ] **Step 3: Style the button** + +```css +/* launchpad/src/style.css — append */ +.ready { + position: fixed; + bottom: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 20; + padding: 0.7rem 1.4rem; + font: 700 0.95rem/1 ui-monospace, Menlo, Consolas, monospace; + letter-spacing: 0.08em; + color: #0b1220; + background: var(--ship-color, #22d3ee); + border-radius: 999px; + text-decoration: none; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15), 0 8px 30px rgba(0, 0, 0, 0.45); + transition: transform 0.12s ease, box-shadow 0.12s ease; +} +.ready:hover { transform: translateX(-50%) translateY(-2px); } +.ready:focus-visible { outline: 2px solid #fff; outline-offset: 3px; } +``` + +- [ ] **Step 4: Verify the build succeeds** + +Run: `cd launchpad && VITE_BOARD_URL=https://board.example npm run build` +Expected: PASS — build completes, no errors. (Manual check: `dist/assets/*.js` contains the `/play?callsign=` string. With `VITE_BOARD_URL` unset the button is simply absent.) + +- [ ] **Step 5: Commit** + +```bash +git add launchpad/src/ready.js launchpad/src/main.js launchpad/src/style.css +git commit -m "feat(launchpad): READY button linking to the board cockpit" +``` + +--- + +### Task 6: Board cockpit page (`board/client/play.*`) + +The laptop typing surface. Connects to the board, joins as the URL's callsign, shows the current command, and reports each exact-match completion. Registered as a second Vite entry. + +**Files:** +- Create: `board/client/typing.js` +- Test: `board/client/typing.test.js` +- Create: `board/client/play.html` +- Create: `board/client/play.js` +- Modify: `board/vite.config.js` (multi-page input) + +**Interfaces:** +- Consumes: race broadcast `{ t:'race', phase, prompts, ships }`, `{ t:'denied' }` (Task 3). +- Produces: + - `typedState(target, input)` → `{ matched: number, done: boolean }` (`matched` = length of the correct leading prefix; `done` = exact full match) + - cockpit sends `{ t:'join', callsign }` on open and `{ t:'progress', completed }` on each completion. + +- [ ] **Step 1: Write the failing test** + +```js +// board/client/typing.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { typedState } from './typing.js'; + +test('matched counts the correct leading prefix', () => { + assert.deepEqual(typedState('git status', 'git s'), { matched: 5, done: false }); + assert.deepEqual(typedState('git status', 'git x'), { matched: 4, done: false }); + assert.deepEqual(typedState('git status', ''), { matched: 0, done: false }); +}); + +test('done is true only on an exact full match', () => { + assert.deepEqual(typedState('ls -l', 'ls -l'), { matched: 5, done: true }); + assert.deepEqual(typedState('ls -l', 'ls -l '), { matched: 5, done: false }); // trailing extra +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd board && node --test client/typing.test.js` +Expected: FAIL — `Cannot find module './typing.js'`. + +- [ ] **Step 3: Write `typing.js`** + +```js +// board/client/typing.js +// Pure keystroke evaluation for the cockpit. `matched` drives per-character +// colouring; `done` fires the progress report. Correctness is judged client-side +// (the server stays authoritative over position — see the spec's security note). +export function typedState(target, input) { + let matched = 0; + while (matched < input.length && matched < target.length && input[matched] === target[matched]) matched++; + return { matched, done: input === target }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd board && node --test client/typing.test.js` +Expected: PASS — both tests. + +- [ ] **Step 5: Write the cockpit page** + +```html + + + + + + + Cockpit — Ship It + + + +
+

connecting…

+
+

+      
+    
+ + + +``` + +```js +// board/client/play.js +import './play.css'; +import { typedState } from './typing.js'; + +const params = new URLSearchParams(location.search); +const callsign = (params.get('callsign') || '').toLowerCase(); +const statusEl = document.getElementById('status'); +const promptEl = document.getElementById('prompt'); +const entry = document.getElementById('entry'); +const me = document.getElementById('me'); + +let prompts = []; +let phase = 'idle'; +let completed = 0; // my confirmed position + +function render() { + const target = prompts[completed] || ''; + promptEl.textContent = target; + if (phase === 'running' && completed < prompts.length) { + const { matched } = typedState(target, entry.value); + promptEl.dataset.matched = String(matched); + me.style.left = `${(completed / Math.max(1, prompts.length)) * 100}%`; + entry.disabled = false; + } else { + entry.disabled = true; + } + statusEl.textContent = + phase === 'running' ? `RACING — ${completed}/${prompts.length}` + : phase === 'finished' ? 'FINISHED ✦' + : 'waiting for race…'; +} + +function connect() { + const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`); + ws.onopen = () => { statusEl.textContent = 'joining…'; ws.send(JSON.stringify({ t: 'join', callsign })); }; + ws.onmessage = (e) => { + let m; try { m = JSON.parse(e.data); } catch { return; } + if (m.t === 'denied') { statusEl.textContent = 'Ship not found — run your pipeline first.'; entry.disabled = true; return; } + if (m.t === 'race') { + prompts = m.prompts || []; + phase = m.phase; + const mine = (m.ships || []).find((s) => s.callsign === callsign); + if (mine && phase !== 'running') completed = mine.completed; // resync when idle/finished + if (phase === 'running' && mine && mine.completed === 0 && completed !== 0) completed = 0; // fresh round + render(); + } + }; + entry.oninput = () => { + const target = prompts[completed] || ''; + const { matched, done } = typedState(target, entry.value); + promptEl.dataset.matched = String(matched); + if (done && phase === 'running') { + completed += 1; + entry.value = ''; + ws.send(JSON.stringify({ t: 'progress', completed })); + render(); + } + }; + ws.onclose = () => { statusEl.textContent = 'disconnected — reconnecting…'; setTimeout(connect, 1000); }; + ws.onerror = () => ws.close(); +} + +if (!callsign) { statusEl.textContent = 'No callsign — open this from your ship’s READY button.'; entry.disabled = true; } +else connect(); +``` + +```css +/* board/client/play.css */ +:root { color-scheme: dark; } +body { margin: 0; background: #0b1220; color: #e2e8f0; font: 16px/1.4 ui-monospace, Menlo, Consolas, monospace; } +#cockpit { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; display: flex; flex-direction: column; gap: 1rem; } +#status { color: #94a3b8; margin: 0; } +#track { position: relative; height: 10px; background: #1e293b; border-radius: 999px; } +#me { position: absolute; top: -3px; left: 0; width: 16px; height: 16px; border-radius: 50%; background: #22d3ee; transition: left 0.15s ease; } +#prompt { font-size: 1.6rem; margin: 0; padding: 0.75rem 1rem; background: #111a2e; border-radius: 8px; min-height: 2rem; } +#entry { font: inherit; font-size: 1.2rem; padding: 0.75rem 1rem; background: #0f172a; color: #e2e8f0; border: 2px solid #334155; border-radius: 8px; } +#entry:focus { outline: none; border-color: #22d3ee; } +#entry:disabled { opacity: 0.5; } +``` + +- [ ] **Step 6: Register the second Vite entry** + +```js +// board/vite.config.js +import { defineConfig } from 'vite'; +import { fileURLToPath } from 'node:url'; + +const r = (p) => fileURLToPath(new URL(p, import.meta.url)); + +// The client lives in client/; build it to board/dist, which the Node server +// serves static. base: './' so it works behind any path. Two pages: the +// projector spectator (index.html) and the laptop cockpit (play.html). +export default defineConfig({ + root: 'client', + base: './', + build: { + outDir: '../dist', + emptyOutDir: true, + rollupOptions: { input: { main: r('client/index.html'), play: r('client/play.html') } }, + }, +}); +``` + +The server already serves any built file under `dist`; requests to `/play` resolve because the cockpit's built page is `dist/play.html`. Add a fallback so the extensionless `/play` maps to `play.html` — in `board/src/app.js`, just before the `rel === '/' ...` line: + +```js + if (rel === '/play') rel = '/play.html'; +``` + +- [ ] **Step 7: Verify the build + serve** + +Run: `cd board && npm run build && node --test client/typing.test.js` +Expected: PASS — build emits `dist/index.html` and `dist/play.html`; typing test green. + +- [ ] **Step 8: Commit** + +```bash +git add board/client/typing.js board/client/typing.test.js board/client/play.html board/client/play.js board/client/play.css board/vite.config.js board/src/app.js +git commit -m "feat(board): typing cockpit page (/play)" +``` + +--- + +### Task 7: Board race view (Three.js ortho) + projector view switch + +The projector's race track. A Three.js orthographic scene that reuses the existing ship meshes, positions each ship by race progress, and shows a live server HUD. `main.js` switches between the orbit view and the race view on the operator's toggle. + +**Files:** +- Create: `board/client/track.js` +- Test: `board/client/track.test.js` +- Create: `board/client/race-view.js` +- Modify: `board/client/main.js` (handle `{ t:'race' }`, swap views, drive the HUD) +- Modify: `board/client/index.html` (HUD element) + +**Interfaces:** +- Consumes: `preloadShipTemplates`, `createShip` (existing `ship-mesh.js`); race broadcast `{ t:'race', view, clients, phase, ships }` (Task 3). +- Produces: + - `trackPosition(completed, total, lane, { length = 16, gap = 1.1 } = {})` → `{ x, y, z }` + - `createRaceView(container)` → `{ update(ships), dispose() }` (same shape as `createScene`) + +- [ ] **Step 1: Write the failing test** + +```js +// board/client/track.test.js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { trackPosition } from './track.js'; + +test('x runs left→right with progress, centered on the track', () => { + const start = trackPosition(0, 12, 0, { length: 12 }); + const end = trackPosition(12, 12, 0, { length: 12 }); + assert.equal(start.x, -6); + assert.equal(end.x, 6); +}); + +test('lane sets the vertical slot; z is flat', () => { + const lane0 = trackPosition(0, 12, 0, { gap: 1 }); + const lane2 = trackPosition(0, 12, 2, { gap: 1 }); + assert.equal(lane2.y - lane0.y, 2); + assert.equal(lane0.z, 0); +}); + +test('total of 0 does not divide by zero', () => { + const p = trackPosition(0, 0, 0, { length: 12 }); + assert.equal(Number.isFinite(p.x), true); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd board && node --test client/track.test.js` +Expected: FAIL — `Cannot find module './track.js'`. + +- [ ] **Step 3: Write `track.js`** + +```js +// board/client/track.js +// Pure race → world mapping. x is progress along the track (centered on 0); +// y is the racer's lane; z is flat. Mirrors orbit.js's role for the orbit scene. +export function trackPosition(completed, total, lane, { length = 16, gap = 1.1 } = {}) { + const frac = total > 0 ? Math.min(1, completed / total) : 0; + return { x: frac * length - length / 2, y: lane * gap, z: 0 }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd board && node --test client/track.test.js` +Expected: PASS — all 3 tests. + +- [ ] **Step 5: Write `race-view.js`** + +```js +// board/client/race-view.js +import * as THREE from 'three'; +import { createShip, preloadShipTemplates } from './ship-mesh.js'; +import { trackPosition } from './track.js'; +import { PALETTE } from './theme.js'; + +// A side-on orthographic race track. Reuses the same GLB ships as the orbit +// scene; positions them by race progress. Same { update, dispose } shape as +// createScene so main.js can swap the two freely. +export function createRaceView(container) { + const scene = new THREE.Scene(); + scene.background = new THREE.Color(PALETTE.bg); + + const aspect = container.clientWidth / Math.max(1, container.clientHeight); + const H = 10; // half-height of the ortho frustum in world units + const camera = new THREE.OrthographicCamera(-H * aspect, H * aspect, H, -H, 0.1, 100); + camera.position.set(0, 3, 20); camera.lookAt(0, 3, 0); + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(container.clientWidth, container.clientHeight); + container.append(renderer.domElement); + + scene.add(new THREE.HemisphereLight(PALETTE.hemiSky, PALETTE.hemiGround, 0.8)); + const key = new THREE.DirectionalLight(PALETTE.dir, 0.9); key.position.set(2, 6, 8); scene.add(key); + + // Finish line at the right edge of the track. + const finish = new THREE.Mesh( + new THREE.PlaneGeometry(0.15, 16), + new THREE.MeshBasicMaterial({ color: PALETTE.ring }), + ); + finish.position.set(8, 3, -0.5); scene.add(finish); + + const ships = new Map(); // callsign -> { group, data } + let templates = null; + let pending = null; + let disposed = false; + const clock = new THREE.Clock(); + const tmp = new THREE.Vector3(); + + function laneOf(list) { + const sorted = [...list].sort((a, b) => (a.callsign < b.callsign ? -1 : 1)); + const map = new Map(); + sorted.forEach((s, i) => map.set(s.callsign, i - (sorted.length - 1) / 2)); + return map; + } + + function update(list) { + if (!templates) { pending = list; return; } + const seen = new Set(); + const lanes = laneOf(list); + list.forEach((s) => { + seen.add(s.callsign); + let rec = ships.get(s.callsign); + if (!rec || rec.data.color !== s.color || rec.data.shipModel !== s.shipModel) { + if (rec) scene.remove(rec.group); + const template = templates.get(s.shipModel) || templates.get('fighter'); + const group = createShip({ callsign: s.callsign, color: s.color || '#94a3b8', shipModel: s.shipModel, template }); + group.rotation.y = Math.PI / 2; // nose down the track (+x) + scene.add(group); + rec = { group }; + ships.set(s.callsign, rec); + } + rec.data = s; + rec.target = trackPosition(s.completed || 0, s.total || 12, lanes.get(s.callsign) || 0); + }); + for (const [callsign, rec] of ships) { + if (!seen.has(callsign)) { scene.remove(rec.group); ships.delete(callsign); } + } + } + + let raf = 0; + function frame() { + const dt = clock.getDelta(); + const damp = 1 - Math.exp(-6 * dt); + for (const rec of ships.values()) { + if (!rec.target) continue; + tmp.set(rec.target.x, 3 + rec.target.y, rec.target.z); + rec.group.position.lerp(tmp, damp); + } + renderer.render(scene, camera); + raf = requestAnimationFrame(frame); + } + frame(); + + function onResize() { + const w = container.clientWidth, h = container.clientHeight, a = w / Math.max(1, h); + camera.left = -H * a; camera.right = H * a; camera.top = H; camera.bottom = -H; + camera.updateProjectionMatrix(); + renderer.setSize(w, h); + } + window.addEventListener('resize', onResize); + onResize(); + + preloadShipTemplates().then((t) => { + if (disposed) return; + templates = t; + if (pending) { const l = pending; pending = null; update(l); } + }).catch(() => { /* orbit view owns the fallback path */ }); + + return { + update, + dispose() { + disposed = true; + cancelAnimationFrame(raf); + window.removeEventListener('resize', onResize); + for (const rec of ships.values()) scene.remove(rec.group); + ships.clear(); + finish.geometry.dispose(); finish.material.dispose(); + renderer.dispose(); + renderer.domElement.remove(); + }, + }; +} +``` + +- [ ] **Step 6: Add the HUD element to the projector page** + +```html + + +``` + +- [ ] **Step 7: Switch views in `main.js`** + +Rewrite `board/client/main.js`'s connect + view management so a `{ t:'race' }` message with `view: 'race'` swaps in the race view and `view: 'orbit'` swaps back, and the HUD reflects `clients`: + +```js +// board/client/main.js +import './style.css'; +import { createScene } from './scene.js'; +import { createRaceView } from './race-view.js'; +import { createFallback, detectWebGL, shouldUseFallback } from './fallback.js'; + +const app = document.getElementById('app'); +const count = document.getElementById('count'); +const toasts = document.getElementById('toasts'); +const hud = document.getElementById('hud'); +const hudClients = document.getElementById('hud-clients'); +const gl = detectWebGL(); +const mql = window.matchMedia('(prefers-reduced-motion: reduce)'); + +let lastShips = []; // roster (orbit) +let lastRaceShips = []; // race positions +let mode = 'orbit'; // 'orbit' | 'race' +let view = makeOrbit(shouldUseFallback({ gl, reducedMotion: mql.matches })); + +function showLiftoff(callsign) { + if (!toasts) return; + while (toasts.children.length >= 5) toasts.firstChild.remove(); + const el = document.createElement('div'); + el.className = 'toast'; + el.textContent = `LIFTOFF ✦ @${callsign}`; + toasts.append(el); + requestAnimationFrame(() => el.classList.add('show')); + setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 400); }, 3000); +} + +function makeOrbit(useFallback) { + const v = useFallback ? createFallback(app) : createScene(app, { + onLiftoff: showLiftoff, + onPreloadError: () => { view.dispose(); view = createFallback(app); view.update(lastShips); }, + }); + v.update(lastShips); + return v; +} + +function setMode(next) { + if (next === mode) return; + view.dispose(); + mode = next; + if (mode === 'race') { view = createRaceView(app); view.update(lastRaceShips); if (hud) hud.hidden = false; } + else { view = makeOrbit(shouldUseFallback({ gl, reducedMotion: mql.matches })); if (hud) hud.hidden = true; } +} + +mql.addEventListener('change', (e) => { + if (mode !== 'orbit') return; + view.dispose(); + view = makeOrbit(shouldUseFallback({ gl, reducedMotion: e.matches })); +}); +window.addEventListener('pagehide', () => view.dispose()); + +function connect() { + const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`); + ws.onmessage = (e) => { + let m; try { m = JSON.parse(e.data); } catch { return; } + if (m.t === 'roster' && Array.isArray(m.ships)) { + lastShips = m.ships; + if (mode === 'orbit') view.update(lastShips); + if (count) count.textContent = `${lastShips.length} ship${lastShips.length === 1 ? '' : 's'}`; + } else if (m.t === 'race') { + lastRaceShips = m.ships || []; + setMode(m.view === 'race' ? 'race' : 'orbit'); + if (mode === 'race') view.update(lastRaceShips); + if (hudClients) hudClients.textContent = String(m.clients ?? 0); + } + }; + ws.onclose = () => setTimeout(connect, 1000); + ws.onerror = () => ws.close(); +} +connect(); +``` + +- [ ] **Step 8: Verify build + all board tests** + +Run: `cd board && npm run build && node --test` +Expected: PASS — build emits `dist/index.html` + `dist/play.html`; every board test green (race, corpus, room, server, orbit, placement, launch, fallback, typing, track). + +- [ ] **Step 9: Commit** + +```bash +git add board/client/track.js board/client/track.test.js board/client/race-view.js board/client/main.js board/client/index.html +git commit -m "feat(board): ortho race view + operator view switch + server HUD" +``` + +--- + +## Self-Review + +**1. Spec coverage:** + +| Spec item | Task | +|---|---| +| Launchpad stays static + READY button | Task 5 | +| Callsign from `location.hostname`, no `VITE_CALLSIGN` | Tasks 4, 5 | +| `VITE_BOARD_URL` public var, button hidden when unset | Task 5 | +| Board `/play` cockpit, inbound WS | Tasks 3, 6 | +| Authoritative `race.js` | Task 1 | +| Roster gate (green pipeline = ticket) | Task 3 | +| Operator control (`OPERATOR_KEY`, start/reset/view) | Task 3 | +| Session-gated corpus, N=12, tiers by tool | Task 2 | +| Three.js ortho race view reusing `.glb` ships | Task 7 | +| Live server HUD (clients connected) | Task 7 | +| WS protocol (join/progress/race/roster/denied) | Tasks 3, 6, 7 | +| Token never client-side; cockpit WS unauthenticated | Tasks 3, 6 (no token in any client file) | +| Reuse roster color/shipModel for appearance | Task 3 (enrich), Task 7 (render) | +| Tests: `node --test` only, preflight untouched | all tasks | + +Kill-server demo, two-URL contrast, and cicd3/cicd4 session framing are operator/slides concerns (slides issue #161), not code — no task required. + +**2. Placeholder scan:** The only intentional "write this cleanly yourself" is the operator-endpoint note in Task 3 Step 3c, which is immediately followed by the complete real code for all three endpoints. No TBD/TODO/"handle edge cases" left. + +**3. Type consistency:** `race.snapshot()` → `{ phase, total, prompts, ships }` is consumed by `raceMsg` (Task 3) and rendered by cockpit (Task 6) and race view (Task 7) with matching field names (`completed`, `total`, `color`, `shipModel`). `typedState` returns `{ matched, done }` — consumed only in Task 6. `trackPosition(completed, total, lane, opts)` — consumed only in Task 7. `createRaceView` returns `{ update, dispose }` matching `createScene`'s shape used by `main.js`. Consistent. + +## Notes for the executor + +- Run all board tests with `cd board && node --test` (auto-discovers `test/*.test.js` and `client/*.test.js`). +- Run launchpad unit tests with `cd launchpad && node --test`. +- `board/scripts/smoke.sh` boots the built server for a manual end-to-end check; use it after Task 7 to click through orbit → race with `curl -X POST -H "Authorization: Bearer $OPERATOR_KEY" localhost:3000/api/race/start`. +- Nothing here touches `.github/workflows/` or `launchpad/ship.config.json` — the sync-fork discipline rule holds. diff --git a/docs/plans/2026-07-20-unified-race-ui.md b/docs/plans/2026-07-20-unified-race-ui.md new file mode 100644 index 0000000..87ff1e8 --- /dev/null +++ b/docs/plans/2026-07-20-unified-race-ui.md @@ -0,0 +1,1072 @@ +# Unified 2D Race UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** One shared DOM race component rendered by both the projector and the learner cockpit, with per-keystroke smooth ship movement and a `/operator` console page replacing curl. + +**Architecture:** A pure-DOM rows component (`race-track.js`) replaces the Three.js race scene and its DOM fallback; ship identity comes from one-time GLB→PNG sprite snapshots (`ship-sprite.js`). The WS `progress` message gains a display-only `frac` field so ships glide between prompt completions. A new static `operator.html` page drives the three existing operator endpoints. + +**Tech Stack:** Node 20, ESM, `ws`, Three.js (sprite snapshots only), Vite multi-page build, `node --test`. + +**Spec:** `docs/specs/2026-07-20-unified-race-ui-design.md` — read it before starting any task. + +## Global Constraints + +- Node 20, ESM everywhere. Fail loud. No CDN — everything bundled by Vite. +- Tests: `node --test` only (run as `npm test` from `board/`). **No vitest, no Playwright.** +- `frac` is display-only: ranking, finishing, and phase transitions key off `completed` alone. +- Race row order on screen is stable-alphabetical by callsign; rows NEVER reorder mid-race. +- Reduced-motion support = CSS `prefers-reduced-motion` disables the ship transition; no JS branch. +- Do not touch: orbit view (`scene.js`, `fallback.js`, `orbit.js`, `placement.js`), roster/event contract (`room.js`, `/api/event`), `launchpad/` anything. +- All commands below run from `/home/debian/repo/devops-bootcamp-shipit/board` unless stated. +- Commit messages: conventional commits, end body with `Co-Authored-By: Claude Fable 5 `. + +--- + +### Task 1: Server — `frac` on Race + `report()` entry point + +**Files:** +- Modify: `board/src/race.js` +- Test: `board/test/race.test.js` + +**Interfaces:** +- Consumes: existing `Race` class (`join/start/progress/reset/snapshot`). +- Produces: `race.report(callsign: string, completed: number, frac: number) -> racer|null` — advance when `completed === racer.completed + 1` (delegates to `progress()`, zeroes `frac`), update `racer.frac` (clamped 0–1, non-finite→0) when `completed === racer.completed`, ignore otherwise. `snapshot().ships[i]` gains `frac: number`. Task 2 calls `report()` from the WS handler; Task 5 reads `frac` from broadcasts. + +- [ ] **Step 1: Write the failing tests** — append to `board/test/race.test.js`: + +```js +test('report stores clamped frac without advancing', () => { + const r = new Race({ total: 3 }); + r.join('octocat'); + r.start(prompts(3)); + r.report('octocat', 0, 0.5); + let s = r.snapshot().ships[0]; + assert.equal(s.completed, 0); + assert.equal(s.frac, 0.5); + r.report('octocat', 0, 7); // over → clamp 1 + assert.equal(r.snapshot().ships[0].frac, 1); + r.report('octocat', 0, -2); // under → clamp 0 + assert.equal(r.snapshot().ships[0].frac, 0); + r.report('octocat', 0, 'zzz'); // junk → 0 + assert.equal(r.snapshot().ships[0].frac, 0); +}); + +test('report advances on next index and zeroes frac; gaps ignored', () => { + const r = new Race({ total: 3 }); + r.join('octocat'); + r.start(prompts(3)); + r.report('octocat', 0, 0.9); + r.report('octocat', 1, 0); // completion + let s = r.snapshot().ships[0]; + assert.equal(s.completed, 1); + assert.equal(s.frac, 0); + r.report('octocat', 3, 0.5); // gap → fully ignored + s = r.snapshot().ships[0]; + assert.equal(s.completed, 1); + assert.equal(s.frac, 0); +}); + +test('report is ignored when idle or racer unknown', () => { + const r = new Race({ total: 3 }); + assert.equal(r.report('nobody', 0, 0.5), null); // idle + r.join('octocat'); + r.start(prompts(3)); + assert.equal(r.report('ghost', 0, 0.5), null); // not joined +}); + +test('start and reset zero frac', () => { + const r = new Race({ total: 3 }); + r.join('octocat'); + r.start(prompts(3)); + r.report('octocat', 0, 0.7); + r.reset(); + assert.equal(r.snapshot().ships[0].frac, 0); + r.start(prompts(3)); + assert.equal(r.snapshot().ships[0].frac, 0); +}); +``` + +Also update the existing first test (line 11's `deepEqual`) — new racers carry `frac`: + +```js + assert.deepEqual(a, { completed: 0, finishedAt: null, frac: 0 }); +``` + +- [ ] **Step 2: Run tests, verify the new ones fail** + +Run: `npm test` +Expected: the 4 new tests + the updated `join` test FAIL (`frac` undefined / `report is not a function`); everything else passes. + +- [ ] **Step 3: Implement in `board/src/race.js`** + +Add a module-level helper above the class: + +```js +const clamp01 = (n) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0); +``` + +Change `join`, `start`, `progress`, `reset`, `snapshot`, and add `report`: + +```js + join(callsign) { + if (!this.racers.has(callsign)) this.racers.set(callsign, { completed: 0, finishedAt: null, frac: 0 }); + return this.racers.get(callsign); + } + + start(prompts) { + this.prompts = prompts.slice(0, this.total); + this.phase = 'running'; + this._seq = 0; + for (const r of this.racers.values()) { r.completed = 0; r.finishedAt = null; r.frac = 0; } + return this; + } + + progress(callsign, completed) { + if (this.phase !== 'running') return null; + const r = this.racers.get(callsign); + if (!r) return null; + if (completed !== r.completed + 1 || completed > this.total) return r; // out-of-order/replay + r.completed = completed; + r.frac = 0; + if (r.completed >= this.total && r.finishedAt == null) r.finishedAt = ++this._seq; + if (this._allFinished()) this.phase = 'finished'; + return r; + } + + // One entry point for cockpit reports: a completion advances; a same-index + // report only refreshes the display-only typing fraction. + report(callsign, completed, frac) { + if (this.phase !== 'running') return null; + const r = this.racers.get(callsign); + if (!r) return null; + if (completed === r.completed + 1) return this.progress(callsign, completed); + if (completed === r.completed) r.frac = clamp01(frac); + return r; + } + + reset() { + this.phase = 'idle'; + this.prompts = []; + for (const r of this.racers.values()) { r.completed = 0; r.finishedAt = null; r.frac = 0; } + } + + snapshot() { + const ships = [...this.racers.entries()].map(([callsign, r]) => ({ + callsign, completed: r.completed, finishedAt: r.finishedAt, frac: r.frac, + })); + return { phase: this.phase, total: this.total, prompts: this.prompts, ships }; + } +``` + +(`messages.js` needs NO change — `raceMsg` spreads snapshot ships, so `frac` flows into broadcasts automatically. Task 2's server test proves it.) + +- [ ] **Step 4: Run tests, verify all pass** + +Run: `npm test` +Expected: PASS, zero failures. + +- [ ] **Step 5: Commit** + +```bash +git add src/race.js test/race.test.js +git commit -m "feat(board): race racers carry display-only typing frac via report() + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: Server — WS handler accepts `frac`, broadcast round-trip test + +**Files:** +- Modify: `board/src/app.js:116-119` +- Test: `board/test/server.test.js` + +**Interfaces:** +- Consumes: `race.report(callsign, completed, frac)` from Task 1. +- Produces: WS wire contract `{ t: 'progress', completed: int, frac?: number }` accepted from joined cockpits; broadcast `race` message ships now include `frac`. Task 7's cockpit sends this shape. + +- [ ] **Step 1: Write the failing test** — append to `board/test/server.test.js` (reuses `post`, `openClient`, `nextMsg`, `postTo`, `opHeader`, `ev` already defined in that file): + +```js +test('cockpit frac ripples into the race broadcast without advancing', async () => { + const app = createServer({ port: 0, token: null, operatorKey: 'op-key' }); + const port = app.port; + try { + await post(port, ev); + const cockpit = await openClient(port); + await nextMsg(cockpit, (m) => m.t === 'roster'); + cockpit.send(JSON.stringify({ t: 'join', callsign: 'octocat' })); + await postTo(port, '/api/race/start', { session: 'cicd3' }, opHeader('op-key')); + await nextMsg(cockpit, (m) => m.t === 'race' && m.phase === 'running'); + + cockpit.send(JSON.stringify({ t: 'progress', completed: 0, frac: 0.5 })); + const partial = await nextMsg(cockpit, (m) => + m.t === 'race' && m.ships.some((s) => s.callsign === 'octocat' && s.frac === 0.5)); + assert.equal(partial.ships.find((s) => s.callsign === 'octocat').completed, 0); + cockpit.close(); + } finally { await app.close(); } +}); +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `npm test` +Expected: new test FAILS with `timeout` (server never stores frac); all others pass. + +- [ ] **Step 3: Implement** — in `board/src/app.js`, replace the `progress` branch of the WS message handler: + +```js + } else if (m.t === 'progress' && ws.callsign && Number.isInteger(m.completed)) { + race.report(ws.callsign, m.completed, m.frac); + raceDirty = true; + } +``` + +(Only the function call changes: `race.progress(ws.callsign, m.completed)` → `race.report(ws.callsign, m.completed, m.frac)`.) + +- [ ] **Step 4: Run tests, verify all pass** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/app.js test/server.test.js +git commit -m "feat(board): ws progress carries typing frac into race broadcasts + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: Client — pure row math (`race-layout.js`) + +**Files:** +- Create: `board/client/race-layout.js` +- Test: `board/client/race-layout.test.js` + +**Interfaces:** +- Consumes: nothing (pure). +- Produces (Task 5 imports all three): + - `progressOf(completed: number, frac: number, total: number) -> number` 0..1 + - `laneOrder(ships: {callsign}[]) -> ships[]` stable alphabetical copy + - `ranks(ships: {callsign, completed, frac, finishedAt}[]) -> Map` (1-based) + +- [ ] **Step 1: Write the failing tests** — create `board/client/race-layout.test.js`: + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { progressOf, laneOrder, ranks } from './race-layout.js'; + +test('progressOf blends completed and frac over total, clamped', () => { + assert.equal(progressOf(0, 0, 12), 0); + assert.equal(progressOf(6, 0.5, 12), 6.5 / 12); + assert.equal(progressOf(12, 0.9, 12), 1); // finished: frac no longer counts + assert.equal(progressOf(13, 0, 12), 1); // over-report clamps + assert.equal(progressOf(3, -1, 12), 3 / 12); // junk frac clamps low + assert.equal(progressOf(3, 2, 12), 4 / 12); // junk frac clamps high + assert.equal(progressOf(5, 0.5, 0), 0); // degenerate total +}); + +test('laneOrder sorts alphabetically without mutating input', () => { + const ships = [{ callsign: 'zed' }, { callsign: 'ada' }, { callsign: 'mel' }]; + const out = laneOrder(ships); + assert.deepEqual(out.map((s) => s.callsign), ['ada', 'mel', 'zed']); + assert.equal(ships[0].callsign, 'zed'); // input untouched +}); + +test('ranks: finished first by finishedAt, then by completed+frac, ties alphabetical', () => { + const ships = [ + { callsign: 'slow', completed: 2, frac: 0.1, finishedAt: null }, + { callsign: 'winner', completed: 12, frac: 0, finishedAt: 1 }, + { callsign: 'second', completed: 12, frac: 0, finishedAt: 2 }, + { callsign: 'fast', completed: 7, frac: 0.5, finishedAt: null }, + { callsign: 'alsofast', completed: 7, frac: 0.5, finishedAt: null }, + ]; + const r = ranks(ships); + assert.equal(r.get('winner'), 1); + assert.equal(r.get('second'), 2); + assert.equal(r.get('alsofast'), 3); // tie with fast → alphabetical + assert.equal(r.get('fast'), 4); + assert.equal(r.get('slow'), 5); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `npm test` +Expected: the 3 new tests FAIL (`Cannot find module ... race-layout.js`); rest pass. + +- [ ] **Step 3: Implement** — create `board/client/race-layout.js`: + +```js +// board/client/race-layout.js +// Pure race → row math for the shared 2D track (successor of track.js's role). +// Node-tested; no DOM, no Three.js. +const byCallsign = (a, b) => (a.callsign < b.callsign ? -1 : a.callsign > b.callsign ? 1 : 0); +const clamp01 = (n) => (Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0); + +export function progressOf(completed, frac, total) { + if (!(total > 0)) return 0; + const done = Math.min(Math.max(completed || 0, 0), total); + const partial = done >= total ? 0 : clamp01(frac); + return Math.min(1, (done + partial) / total); +} + +// Stable lane assignment: rows never reorder mid-race — ships only move +// horizontally. Rank is a separate, updating number (see ranks()). +export function laneOrder(ships) { + return [...ships].sort(byCallsign); +} + +export function ranks(ships) { + const sorted = [...ships].sort((a, b) => { + const af = a.finishedAt != null, bf = b.finishedAt != null; + if (af && bf) return a.finishedAt - b.finishedAt; + if (af !== bf) return af ? -1 : 1; + const d = ((b.completed || 0) + clamp01(b.frac)) - ((a.completed || 0) + clamp01(a.frac)); + if (d !== 0) return d; + return byCallsign(a, b); + }); + return new Map(sorted.map((s, i) => [s.callsign, i + 1])); +} +``` + +- [ ] **Step 4: Run tests, verify all pass** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add client/race-layout.js client/race-layout.test.js +git commit -m "feat(board): pure race row math — progressOf, laneOrder, ranks + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: Client — GLB sprite snapshots (`ship-sprite.js`) + +**Files:** +- Create: `board/client/ship-sprite.js` +- Read first (do not modify): `board/client/ship-mesh.js` — confirm the exported names `preloadShipTemplates`, `createShip`, `disposeShip` and how `createShip` is called in `race-view.js:57-58`; if `createShip` renders a callsign label for non-empty callsigns, keep passing `callsign: ''`. + +**Interfaces:** +- Consumes: `preloadShipTemplates(): Promise`, `createShip({callsign, color, shipModel, template}): THREE.Group`, `disposeShip(group)` from `ship-mesh.js`. +- Produces: `shipSprite(shipModel: string, color: string) -> Promise` — PNG data-URL, or `null` when WebGL/assets unavailable. Cached per `(shipModel, color)`; never rejects. Task 5 consumes. + +No unit test — WebGL rendering is browser-only; validated by hand in Task 9 (repo convention: props are pedagogy-first, visual paths are hand-verified). + +- [ ] **Step 1: Implement** — create `board/client/ship-sprite.js`: + +```js +// board/client/ship-sprite.js +// One-time GLB → 2D sprite renders for the race track. Each (shipModel, color) +// pair is rendered once to a small transparent canvas and cached as a data-URL; +// after that the race is plain DOM — no per-frame WebGL. Resolves null when +// WebGL or the models are unavailable; the track shows a tinted glyph instead. +import * as THREE from 'three'; +import { createShip, preloadShipTemplates, disposeShip } from './ship-mesh.js'; + +const SIZE = 64; +const cache = new Map(); // `${shipModel}|${color}` -> Promise +let ctx; // lazy { renderer, scene, camera }; null = WebGL unavailable + +function setup() { + try { + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setSize(SIZE, SIZE); + const scene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1.6, 1.6, 1.6, -1.6, 0.1, 50); + camera.position.set(0, 0, 10); + camera.lookAt(0, 0, 0); + scene.add(new THREE.HemisphereLight(0xffffff, 0x334155, 0.9)); + const key = new THREE.DirectionalLight(0xffffff, 0.9); + key.position.set(2, 4, 6); + scene.add(key); + return { renderer, scene, camera }; + } catch { + return null; + } +} + +async function render(shipModel, color) { + const templates = await preloadShipTemplates(); + if (ctx === undefined) ctx = setup(); + if (!ctx) return null; + const template = templates.get(shipModel) || templates.get('fighter'); + const ship = createShip({ callsign: '', color, shipModel, template }); + ship.rotation.y = Math.PI / 2; // side profile, nose toward +x — matches track direction + ctx.scene.add(ship); + ctx.renderer.render(ctx.scene, ctx.camera); + const url = ctx.renderer.domElement.toDataURL('image/png'); + ctx.scene.remove(ship); + disposeShip(ship); + return url; +} + +export function shipSprite(shipModel, color) { + const k = `${shipModel}|${color}`; + if (!cache.has(k)) cache.set(k, render(shipModel, color).catch(() => null)); + return cache.get(k); +} +``` + +If reading `ship-mesh.js` shows ships are much larger/smaller than ~2 world units (race-view framed them in a 20-unit frustum with 1.1 lane gaps, so ~1 unit is expected), adjust the ortho bounds (`±1.6`) so the ship fills ~80% of the canvas — note the chosen value in the commit message. + +- [ ] **Step 2: Verify it builds** + +Run: `npm run build` +Expected: vite build succeeds (this also type-sanity-checks the imports resolve). + +- [ ] **Step 3: Commit** + +```bash +git add client/ship-sprite.js +git commit -m "feat(board): cached GLB->PNG ship sprites for the 2D race track + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 5: Client — the shared race component (`race-track.js` + CSS) + +**Files:** +- Create: `board/client/race-track.js` +- Create: `board/client/race-track.css` + +**Interfaces:** +- Consumes: `progressOf/laneOrder/ranks` (Task 3), `shipSprite` (Task 4). +- Produces: `createRaceTrack(container: HTMLElement, { me?: string|null }) -> { update(state), dispose() }` where `state = { phase: 'idle'|'running'|'finished', total: number, ships: [{callsign, completed, frac, finishedAt, color, shipModel}] }` — i.e. the WS `race` message minus `prompts`. Tasks 6 & 7 consume. + +No unit test (DOM component; the math it leans on is tested in Task 3). Hand-verified in Task 9. + +- [ ] **Step 1: Implement `board/client/race-track.js`** + +```js +// board/client/race-track.js +// THE race view. Projector and cockpit render this same DOM component: one row +// per racer stacked top-to-bottom (stable alphabetical — rows never reorder), +// ships glide left→right by completed+frac. No rAF and no WebGL at render time +// (sprites are pre-rendered data-URLs), so reduced-motion/no-WebGL needs no +// separate fallback — this component IS the fallback. +import { progressOf, laneOrder, ranks } from './race-layout.js'; +import { shipSprite } from './ship-sprite.js'; +import './race-track.css'; + +const NEUTRAL = '#94a3b8'; // matches the board's roster default colour +const DENSE_AT = 25; // hide callsign labels at this many racers (~<14px rows) +const MEDALS = ['🥇', '🥈', '🥉']; + +export function createRaceTrack(container, { me = null } = {}) { + const root = document.createElement('div'); + root.className = 'race-track'; + const banner = document.createElement('div'); + banner.className = 'race-banner'; + const rowsEl = document.createElement('div'); + rowsEl.className = 'race-rows'; + root.append(banner, rowsEl); + container.append(root); + + const rows = new Map(); // callsign -> { el, rankEl, ship, img, glyph, meta, spriteKey } + let disposed = false; + + function ensureRow(s) { + let r = rows.get(s.callsign); + if (r) return r; + const el = document.createElement('div'); + el.className = 'race-row'; + if (me && s.callsign === me) el.classList.add('me'); + el.title = `@${s.callsign}`; + + const rankEl = document.createElement('span'); rankEl.className = 'rank'; + const label = document.createElement('span'); label.className = 'cs'; + label.textContent = `@${s.callsign}`; + const lane = document.createElement('span'); lane.className = 'lane'; + const ship = document.createElement('span'); ship.className = 'ship'; + const glyph = document.createElement('span'); glyph.className = 'glyph'; + const img = document.createElement('img'); img.className = 'sprite'; img.alt = ''; img.hidden = true; + ship.append(glyph, img); + lane.append(ship); + const meta = document.createElement('span'); meta.className = 'meta'; + el.append(rankEl, label, lane, meta); + rowsEl.append(el); + + r = { el, rankEl, ship, img, glyph, meta, spriteKey: null }; + rows.set(s.callsign, r); + return r; + } + + function applySprite(r, s) { + const key = `${s.shipModel}|${s.color}`; + if (r.spriteKey === key) return; + r.spriteKey = key; + r.glyph.style.color = s.color || NEUTRAL; + shipSprite(s.shipModel, s.color).then((url) => { + if (disposed || r.spriteKey !== key) return; // stale render — a newer look won + if (url) { r.img.src = url; r.img.hidden = false; r.glyph.hidden = true; } + else { r.img.hidden = true; r.glyph.hidden = false; } + }); + } + + function bannerText(phase, ships) { + if (phase === 'idle') return ships.length ? 'WAITING FOR LAUNCH…' : 'NO RACERS YET — open your ship’s READY link'; + if (phase === 'finished') { + const podium = ships.filter((s) => s.finishedAt != null) + .sort((a, b) => a.finishedAt - b.finishedAt).slice(0, 3) + .map((s, i) => `${MEDALS[i]} @${s.callsign}`); + return podium.length ? `FINISH ✦ ${podium.join(' ')}` : 'FINISH ✦'; + } + return ''; + } + + function update(state) { + const { phase, total = 12, ships = [] } = state; + root.dataset.phase = phase; + rowsEl.dataset.dense = ships.length >= DENSE_AT ? '1' : ''; + banner.textContent = bannerText(phase, ships); + + const order = laneOrder(ships); + const rk = ranks(ships); + const seen = new Set(); + order.forEach((s, i) => { + seen.add(s.callsign); + const r = ensureRow(s); + applySprite(r, s); + const p = progressOf(s.completed, s.frac, total); + r.ship.style.left = `calc((100% - var(--ship-w)) * ${p.toFixed(4)})`; + r.rankEl.textContent = String(rk.get(s.callsign)); + r.meta.textContent = s.finishedAt != null + ? `✦ #${rk.get(s.callsign)}` + : `${((s.completed || 0) + (s.frac || 0)).toFixed(1)}/${total}`; + r.el.style.order = String(i); // alphabetical slot, whatever the DOM insertion order was + }); + for (const [callsign, r] of rows) { + if (!seen.has(callsign)) { r.el.remove(); rows.delete(callsign); } + } + } + + return { + update, + dispose() { disposed = true; root.remove(); rows.clear(); }, + }; +} +``` + +- [ ] **Step 2: Implement `board/client/race-track.css`** + +```css +/* board/client/race-track.css — the shared race view (projector + cockpit). + Dark-only, same palette family as style.css / play.css. Rows flex-share the + viewport height so 40+ racers fit with zero scrolling. */ +.race-track { + --ship-w: 26px; + --accent: #22d3ee; + --lane-bg: #1e293b; + --fg: #e2e8f0; + --dim: #94a3b8; + display: flex; flex-direction: column; + width: 100%; height: 100%; min-height: 0; box-sizing: border-box; + padding: 0.5rem 1rem; + font: 13px/1.2 ui-monospace, Menlo, Consolas, monospace; + color: var(--fg); + background: #0b1220; +} +.race-banner { min-height: 1.3em; padding: 0.15rem 0; text-align: center; color: var(--dim); } +.race-track[data-phase='finished'] .race-banner { color: var(--accent); } + +.race-rows { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 2px; } +.race-track[data-phase='idle'] .race-rows { opacity: 0.55; } + +.race-row { flex: 1 1 0; min-height: 10px; max-height: 48px; display: flex; align-items: center; gap: 0.5rem; } +.race-row.me { flex-grow: 2; max-height: 64px; } +.race-row.me .cs { color: var(--accent); font-weight: 700; } + +.race-row .rank { width: 2ch; text-align: right; color: var(--dim); font-size: 0.85em; } +.race-row .cs { width: 14ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.race-row .meta { width: 8ch; text-align: right; color: var(--dim); font-variant-numeric: tabular-nums; } + +.race-row .lane { position: relative; flex: 1; min-width: 0; height: 6px; border-radius: 999px; background: var(--lane-bg); } +.race-row .lane::after { /* finish line */ + content: ''; position: absolute; right: 0; top: -5px; bottom: -5px; width: 2px; + background: var(--accent); opacity: 0.6; +} +.race-row .ship { + position: absolute; left: 0; top: 50%; transform: translateY(-50%); + width: var(--ship-w); height: var(--ship-w); + display: grid; place-items: center; + transition: left 150ms linear; +} +@media (prefers-reduced-motion: reduce) { .race-row .ship { transition: none; } } +.race-row .ship .sprite { width: 100%; height: 100%; display: block; object-fit: contain; } +.race-row .ship .glyph { font-size: 12px; line-height: 1; } +.race-row .ship .glyph::before { content: '▶'; } + +/* 25+ racers: reclaim label space, shrink sprites, keep every row legible */ +.race-rows[data-dense='1'] .cs { display: none; } +.race-rows[data-dense='1'] { --ship-w: 16px; } +.race-rows[data-dense='1'] .race-row { gap: 0.3rem; } +.race-rows[data-dense='1'] .meta { font-size: 0.8em; width: 6ch; } +``` + +- [ ] **Step 3: Verify build** + +Run: `npm run build` +Expected: success. + +- [ ] **Step 4: Commit** + +```bash +git add client/race-track.js client/race-track.css +git commit -m "feat(board): shared DOM race-track component — rows, sprites, live glide + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 6: Projector adopts race-track; delete the old race renderers + +**Files:** +- Modify: `board/client/main.js` +- Delete: `board/client/race-view.js`, `board/client/race-fallback.js`, `board/client/track.js`, `board/client/track.test.js` + +**Interfaces:** +- Consumes: `createRaceTrack` (Task 5); WS `race` message (`phase`, `total`, `ships`, `view`, `clients`). +- Produces: nothing new — projector behaviour: `view: 'race'` shows the track, `'orbit'` unchanged. + +- [ ] **Step 1: Rewrite the race path in `board/client/main.js`** + +Replace the two race imports (lines 3–4) with one: + +```js +import { createRaceTrack } from './race-track.js'; +``` + +Replace the `lastRaceShips` state (line 16) with the full race state: + +```js +let lastRace = { phase: 'idle', total: 12, ships: [] }; // race state (rows view) +``` + +Replace `makeRace` (lines 40–44): + +```js +function makeRace() { + const v = createRaceTrack(app); // its own fallback: DOM-only, reduced-motion via CSS + v.update(lastRace); + return v; +} +``` + +Replace the reduced-motion listener (lines 54–57) — only orbit needs a rebuild now: + +```js +mql.addEventListener('change', (e) => { + if (mode !== 'orbit') return; // race track handles reduced motion in CSS + view.dispose(); + view = makeOrbit(shouldUseFallback({ gl, reducedMotion: e.matches })); +}); +``` + +Replace the race branch of `ws.onmessage` (lines 68–73): + +```js + } else if (m.t === 'race') { + lastRace = { phase: m.phase, total: m.total, ships: m.ships || [] }; + setMode(m.view === 'race' ? 'race' : 'orbit'); + if (mode === 'race') view.update(lastRace); + if (hudClients) hudClients.textContent = String(m.clients ?? 0); + } +``` + +- [ ] **Step 2: Delete the superseded files** + +```bash +git rm client/race-view.js client/race-fallback.js client/track.js client/track.test.js +``` + +- [ ] **Step 3: Verify nothing else referenced them, tests pass, build passes** + +Run: `grep -rn "race-view\|race-fallback\|from './track" client/ src/ && echo LEFTOVER || echo CLEAN` +Expected: `CLEAN` (grep finds nothing). +Run: `npm test` +Expected: PASS (track.test.js is gone; nothing else breaks). +Run: `npm run build` +Expected: success. + +- [ ] **Step 4: Commit** + +```bash +git add client/main.js +git commit -m "feat(board): projector renders race via shared race-track; drop 3D race scene + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 7: Cockpit — full-field view + typing dock + frac reporting + +**Files:** +- Modify: `board/client/play.html`, `board/client/play.js`, `board/client/play.css` + +**Interfaces:** +- Consumes: `createRaceTrack` (Task 5), `typedState` (existing `typing.js`), WS wire contract from Task 2. +- Produces: cockpit sends `{ t: 'progress', completed, frac }` trailing-throttled at 100 ms; completions send immediately (unchanged shape plus frac path). + +- [ ] **Step 1: Replace `board/client/play.html` body** + +```html + + + + + + Cockpit — Ship It + + + +
+
+
+

connecting…

+

+        
+      
+
+ + + +``` + +- [ ] **Step 2: Rewrite `board/client/play.js`** + +Keeps verbatim: callsign-from-query, denied handling, reconnect loop, optimistic `completed` with server re-sync. Adds: the race-track field and the throttled frac sender. + +```js +import './play.css'; +import { typedState } from './typing.js'; +import { createRaceTrack } from './race-track.js'; + +const params = new URLSearchParams(location.search); +const callsign = (params.get('callsign') || '').toLowerCase(); +const statusEl = document.getElementById('status'); +const promptEl = document.getElementById('prompt'); +const entry = document.getElementById('entry'); +const track = createRaceTrack(document.getElementById('field'), { me: callsign }); + +let prompts = []; +let phase = 'idle'; +let completed = 0; // my confirmed position (optimistic; server is authoritative) +let synced = false; // true once we've trusted the server's position after (re)connect +let prevPhase = 'idle'; + +function render() { + const target = prompts[completed] || ''; + promptEl.textContent = target; + if (phase === 'running' && completed < prompts.length) { + const { matched } = typedState(target, entry.value); + promptEl.dataset.matched = String(matched); + entry.disabled = false; + } else { + entry.disabled = true; + } + statusEl.textContent = + phase === 'running' ? `RACING — ${completed}/${prompts.length}` + : phase === 'finished' ? 'FINISHED ✦' + : 'waiting for race…'; +} + +// Trailing throttle: at most one frac report per 100ms. Completions bypass +// this and send immediately in the input handler. +function fracSender(ws) { + let timer = null, latest = 0; + return (frac) => { + latest = frac; + if (timer) return; + timer = setTimeout(() => { + timer = null; + if (ws.readyState === WebSocket.OPEN && phase === 'running') { + ws.send(JSON.stringify({ t: 'progress', completed, frac: latest })); + } + }, 100); + }; +} + +function connect() { + const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`); + const sendFrac = fracSender(ws); + ws.onopen = () => { synced = false; statusEl.textContent = 'joining…'; ws.send(JSON.stringify({ t: 'join', callsign })); }; + ws.onmessage = (e) => { + let m; try { m = JSON.parse(e.data); } catch { return; } + if (m.t === 'denied') { statusEl.textContent = 'Ship not found — run your pipeline first.'; entry.disabled = true; return; } + if (m.t === 'race') { + prompts = m.prompts || []; + phase = m.phase; + const mine = (m.ships || []).find((s) => s.callsign === callsign); + const serverCompleted = mine ? mine.completed : 0; + if (!synced) { completed = serverCompleted; synced = true; } // (re)connect/reload: trust the server's position + else if (m.phase === 'running' && prevPhase !== 'running') completed = serverCompleted; // new round: server reset us to 0 + // during a running round, keep the local optimistic `completed`; the server silently rejects bad progress + prevPhase = m.phase; + track.update({ phase: m.phase, total: m.total, ships: m.ships || [] }); + render(); + } + }; + entry.oninput = () => { + const target = prompts[completed] || ''; + const { matched, done } = typedState(target, entry.value); + promptEl.dataset.matched = String(matched); + if (done && phase === 'running') { + completed += 1; + entry.value = ''; + ws.send(JSON.stringify({ t: 'progress', completed })); + render(); + } else if (phase === 'running' && target.length > 0) { + sendFrac(matched / target.length); + } + }; + ws.onclose = () => { statusEl.textContent = 'disconnected — reconnecting…'; setTimeout(connect, 1000); }; + ws.onerror = () => ws.close(); +} + +if (!callsign) { statusEl.textContent = 'No callsign — open this from your ship’s READY button.'; entry.disabled = true; } +else connect(); +``` + +- [ ] **Step 3: Rewrite `board/client/play.css`** + +```css +:root { color-scheme: dark; } +html, body { height: 100%; } +body { margin: 0; background: #0b1220; color: #e2e8f0; font: 16px/1.4 ui-monospace, Menlo, Consolas, monospace; } +#cockpit { height: 100dvh; display: flex; flex-direction: column; } +#field { flex: 1; min-height: 0; } +#dock { + border-top: 1px solid #1e293b; + width: 100%; max-width: 640px; margin: 0 auto; box-sizing: border-box; + padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom)); + display: flex; flex-direction: column; gap: 0.5rem; +} +#status { color: #94a3b8; margin: 0; } +#prompt { font-size: 1.4rem; margin: 0; padding: 0.6rem 0.9rem; background: #111a2e; border-radius: 8px; min-height: 1.8rem; } +#entry { font: inherit; font-size: 1.1rem; padding: 0.6rem 0.9rem; background: #0f172a; color: #e2e8f0; border: 2px solid #334155; border-radius: 8px; } +#entry:focus { outline: none; border-color: #22d3ee; } +#entry:disabled { opacity: 0.5; } +``` + +(The old `#track`/`#me` single-bar styles die with their DOM.) + +- [ ] **Step 4: Tests + build** + +Run: `npm test` +Expected: PASS. +Run: `npm run build` +Expected: success. + +- [ ] **Step 5: Commit** + +```bash +git add client/play.html client/play.js client/play.css +git commit -m "feat(board): cockpit shows the full shared race field + live frac typing + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 8: `/operator` console page + +**Files:** +- Create: `board/client/operator.html`, `board/client/operator.js`, `board/client/operator.css` +- Modify: `board/src/app.js` (static alias, next to the `/play` alias at line ~95), `board/vite.config.js` (third input) + +**Interfaces:** +- Consumes: existing endpoints `POST /api/race/start {session}`, `POST /api/race/reset`, `POST /api/view {view}` with `Authorization: Bearer `; read-only WS `race` broadcasts. +- Produces: instructor-facing page at `/operator`. No new server endpoints; no auth change. + +- [ ] **Step 1: Create `board/client/operator.html`** + +```html + + + + + + Operator — Ship It + + + +
+

MISSION CONTROL · OPS

+ + +
+ + + + +
+

+

connecting…

+
+ + + +``` + +(Session options are hardcoded to match `SESSIONS` in `board/src/corpus.js` — if a session is ever added there, add it here.) + +- [ ] **Step 2: Create `board/client/operator.js`** + +```js +// board/client/operator.js +// Instructor console: drives the three operator endpoints (key-guarded HTTP +// POSTs) and mirrors the public race broadcast read-only. The key lives in +// localStorage on the operator's device — classroom-grade, rotate per cohort. +import './operator.css'; + +const keyEl = document.getElementById('key'); +const sessionEl = document.getElementById('session'); +const resultEl = document.getElementById('result'); +const liveEl = document.getElementById('live'); + +keyEl.value = localStorage.getItem('shipit-operator-key') || ''; +keyEl.oninput = () => localStorage.setItem('shipit-operator-key', keyEl.value); + +async function call(path, body) { + resultEl.textContent = `${path} …`; + try { + const res = await fetch(path, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${keyEl.value}` }, + body: JSON.stringify(body || {}), + }); + resultEl.textContent = + res.status === 202 ? `${path} → 202 ✓` + : res.status === 401 ? `${path} → 401 wrong key` + : `${path} → ${res.status}`; + } catch (err) { + resultEl.textContent = `${path} → ${err.message}`; + } +} + +document.getElementById('start').onclick = () => call('/api/race/start', { session: sessionEl.value }); +document.getElementById('reset').onclick = () => call('/api/race/reset'); +document.getElementById('view-orbit').onclick = () => call('/api/view', { view: 'orbit' }); +document.getElementById('view-race').onclick = () => call('/api/view', { view: 'race' }); + +function connect() { + const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}`); + ws.onmessage = (e) => { + let m; try { m = JSON.parse(e.data); } catch { return; } + if (m.t === 'race') liveEl.textContent = `phase: ${m.phase} · racers: ${(m.ships || []).length} · viewers: ${m.clients ?? 0}`; + }; + ws.onclose = () => setTimeout(connect, 1000); + ws.onerror = () => ws.close(); +} +connect(); +``` + +- [ ] **Step 3: Create `board/client/operator.css`** + +```css +:root { color-scheme: dark; } +body { margin: 0; background: #0b1220; color: #e2e8f0; font: 16px/1.5 ui-monospace, Menlo, Consolas, monospace; } +#ops { max-width: 420px; margin: 0 auto; padding: 2rem 1rem; display: flex; flex-direction: column; gap: 1rem; } +h1 { font-size: 1.1rem; letter-spacing: 0.1em; color: #22d3ee; margin: 0; } +label { display: flex; flex-direction: column; gap: 0.3rem; color: #94a3b8; font-size: 0.9rem; } +input, select { font: inherit; padding: 0.5rem 0.75rem; background: #0f172a; color: #e2e8f0; border: 2px solid #334155; border-radius: 8px; } +input:focus, select:focus { outline: none; border-color: #22d3ee; } +.buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; } +button { font: inherit; padding: 0.75rem; background: #111a2e; color: #e2e8f0; border: 2px solid #334155; border-radius: 8px; cursor: pointer; } +button:hover { border-color: #22d3ee; } +#start { grid-column: 1 / -1; border-color: #22d3ee; color: #22d3ee; } +#result, #live { margin: 0; color: #94a3b8; min-height: 1.5em; } +``` + +- [ ] **Step 4: Wire the route + build input** + +In `board/src/app.js`, directly under `if (rel === '/play') rel = '/play.html';` add: + +```js + if (rel === '/operator') rel = '/operator.html'; +``` + +In `board/vite.config.js`, extend `rollupOptions.input`: + +```js + rollupOptions: { input: { main: r('client/index.html'), play: r('client/play.html'), operator: r('client/operator.html') } }, +``` + +- [ ] **Step 5: Tests + build** + +Run: `npm test` +Expected: PASS. +Run: `npm run build && ls dist/operator.html` +Expected: build succeeds; `dist/operator.html` exists. + +- [ ] **Step 6: Commit** + +```bash +git add client/operator.html client/operator.js client/operator.css src/app.js vite.config.js +git commit -m "feat(board): /operator console — start/reset/view buttons replace curl + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 9: End-to-end verification (hand-driven) + +**Files:** none (verification only). + +- [ ] **Step 1: Full test suite + clean build** + +Run: `npm test && npm run build` +Expected: all tests pass, build clean. + +- [ ] **Step 2: Boot a dev board and walk the whole flow** + +```bash +OPERATOR_KEY= node src/index.js # dev mode: operator endpoints open, event POSTs open +``` + +Then verify in a browser (all on `http://localhost:3000`): + +1. Seed two ships: `curl -X POST localhost:3000/api/event -H 'content-type: application/json' -d '{"callsign":"alpha","stage":"liftoff","status":"shipped","color":"#22d3ee","shipModel":"fighter"}'` and the same with `"callsign":"bravo","color":"red","shipModel":"hauler"`. +2. Open `/` (projector), `/play?callsign=alpha` and `/play?callsign=bravo` (two tabs), `/operator` (fourth tab). +3. `/operator`: leave key empty (dev mode), press **VIEW: RACE** → projector flips to the rows view, both ships on the start line, banner `WAITING FOR LAUNCH…`. +4. Press **▶ START RACE** (session cicd3) → both cockpits' inputs enable simultaneously; `/operator` live line shows `phase: running · racers: 2`. +5. Type in alpha's tab: its ship **glides smoothly** mid-prompt on BOTH the cockpit and the projector (not just on completion). Verify bravo's tab shows alpha moving too. +6. Check ship sprites show the real models/colors (cyan fighter, red hauler); rank chips update as they overtake; rows never swap positions. +7. Finish both racers → banner shows `FINISH ✦ 🥇 … 🥈 …`, finished rows show `✦ #N`. +8. **RESET** → back to `WAITING FOR LAUNCH…`; **VIEW: ORBIT** → projector returns to orbit. +9. Reduced-motion: in devtools, emulate `prefers-reduced-motion: reduce` → ships step instead of glide, everything else identical. +10. Wrong key: set any key on the server (`OPERATOR_KEY=k node src/index.js`), enter a wrong key on `/operator`, press START → result line shows `401 wrong key`. + +- [ ] **Step 3: Density sanity check** + +Seed 40 ships and confirm rows shrink, labels hide, no scrolling, `me` row still highlighted: + +```bash +for i in $(seq 1 40); do curl -s -X POST localhost:3000/api/event -H 'content-type: application/json' -d "{\"callsign\":\"crew$i\",\"stage\":\"liftoff\",\"status\":\"shipped\",\"color\":\"cyan\",\"shipModel\":\"scout\"}" > /dev/null; done +``` + +Then join a few via `/play?callsign=crew1` etc., start a race, eyeball `/` at a projector-ish window size AND a phone-ish size (~390×844). + +- [ ] **Step 4: Update memory of delivered state** — nothing to commit if all green; if any step failed, fix forward with a `fix(board):` commit and re-run this task from Step 1. diff --git a/docs/specs/2026-07-18-typing-race-mode-design.md b/docs/specs/2026-07-18-typing-race-mode-design.md new file mode 100644 index 0000000..555ddf6 --- /dev/null +++ b/docs/specs/2026-07-18-typing-race-mode-design.md @@ -0,0 +1,275 @@ +# Ship It — Typing Race Mode design + +**Date:** 2026-07-18 +**Status:** Approved — the three open decisions resolved 2026-07-18 (Three.js ortho · server-authoritative · v1 mechanic) +**Related:** `CLAUDE.md` (pinned contracts), `docs/specs/2026-07-11-ship-it-architecture-design.md` (base architecture), slides reconciliation issue [`Infratify/slides-devops-bootcamp#161`](https://github.com/Infratify/slides-devops-bootcamp/issues/161) + +## Summary + +The board gains a **multiplayer typing-race mode**. Learners type CLI commands they have +already learned (Linux, git, gh, docker, aws) to drive their ship down a shared race track on +the projector. The launchpad gains one thing: a **READY button** that carries the learner from +their own static site to the board. + +The point is not the game. The point is a **contrast that teaches beginners why a backend +server exists**: a static site (their launchpad on Pages) provably *cannot* run a shared, +realtime, multiplayer race — no shared state, no live push. A running server (the board) can. +The learner feels the wall, then in CI/CD 4 builds and deploys the very server that clears it. + +This extends the prop; it does not replace it. The board keeps its roster/orbit "first contact" +view; race mode is an operator-toggled second view. The report → board event contract, the +serverless launchpad, and the S4 container-on-EC2 lesson are all preserved. + +## Pedagogical thesis + +Beginners struggle to see why a backend server is needed — CRUD apps make it look optional. A +multiplayer game makes the need undeniable. The lesson lives in a single contrast the learner +experiences with their own two URLs: + +| | Static site (S1–S3) | Server (S4) | +|---|---|---| +| what it is | files on a CDN | a running process | +| your ship | solo, alone | racing everyone | +| knows about other players? | no | yes | +| can push live updates? | no | yes (WebSocket) | +| where | `user.github.io/…` | `BOARD_URL` | + +The READY button is the journey from the left column to the right: *leaving your file, going to +the server*. Everyone lands on one URL — the hub made visible. The room becomes a live +client-server diagram (laptops = clients, projector = server). + +Supporting teaching moves: the **kill-the-server demo** (stop the board mid-race → every laptop +freezes → "no server = no game"); a **live server HUD** on the projector ("47 clients connected · +broadcasting 20×/sec"); the **postcard vs phone call** metaphor (S3 report = one fire-and-forget +POST; S4 game = a connection that stays open). + +## What changes / what stays + +**Stays (pins preserved):** + +- Launchpad stays **static** — no backend of its own. A static page opening a WebSocket to the + board is still static-hosted. "Serverless S1–S3" and "beginner-simple" hold. +- The **report → board event contract is unchanged.** The race reuses the existing roster + `{ callsign, color, shipModel }` as its player list and ship appearance. +- The board's roster/orbit "first contact" view stays — it is still the S3 payoff. +- S4's pinned concept (build board image → GHCR → deploy to own EC2) is untouched and, in fact, + reinforced: a multiplayer game server genuinely needs a server. +- Conventions hold: Node 20 ESM, no CDN, WebGL + reduced-motion fallbacks, one test gate + (`preflight.mjs`), dev-time `node --test`, no vitest/Playwright. + +**Changes:** + +- Launchpad: add a **READY button** (a plain `` to `BOARD_URL/play?callsign=…`) and derive + the learner's callsign at runtime from `location.hostname` instead of `VITE_CALLSIGN`. +- Board: add **race mode** — a `/play` cockpit page, inbound cockpit WebSocket handling (currently + spectators are read-only), authoritative race state, an operator control surface, and a 2D race + view alongside the existing orbit view. + +**Notably NOT reversed:** the pinned "taught workflow never sets `VITE_CALLSIGN`" decision. Because +identity is derived from the Pages hostname at runtime, the workflow build step gains nothing. No +slides change to the workflow. + +## Architecture + +### Identity flow (two separate moments — do not conflate) + +1. **Pipeline deploy (in the runner, S3).** `report.sh` POSTs one event → roster gains + `{ callsign, color, shipModel }`. This is "registered." Already happens today. +2. **Live play (in the browser, later).** Learner opens their own Pages site → clicks READY → + navigates to the board cockpit → drives their ship by typing. + +The roster from moment 1 is the player list for moment 2. + +**Callsign derivation (launchpad, runtime):** `location.hostname` is `user.github.io` for both +user and project Pages sites, so `hostname.split('.')[0]` yields the GitHub username = callsign. +Zero build change, zero pin reversal. Falls back to `VITE_CALLSIGN` then empty for local dev / +custom domains / org accounts. + +**Roster gate (the ticket):** the board admits `?callsign=X` to the race **only if X is already +on the roster** — i.e. their pipeline actually ran and reported. Not on roster → "Ship not found — +run your pipeline first." A green pipeline is the literal entry ticket. Reuses existing state; adds +none. + +**Callsign canonicalization (implementation note):** the board **lowercases the callsign at ingest** +(`sanitizeEvent`). GitHub usernames are case-insensitive and GitHub Pages hostnames are always +lowercase, so the cockpit (hostname-derived) can only ever produce a lowercase callsign — the roster +must key on the same form or a mixed-case user (`JohnDoe`) is permanently denied. Consequence: the +shared board now **displays callsigns lowercase** (`@johndoe`), including in the existing S3 orbit +view. Not a pin violation — no contract pins display case, and the event-contract example already +uses lowercase — but note it in CLAUDE.md / the slides so the S3 "first contact" label change is +expected. + +### Launchpad changes (stays static) + +- `src/main.js`: replace `const callsign = import.meta.env.VITE_CALLSIGN || ''` with a + hostname-first derivation (new `src/callsign.js`, unit-tested with `node --test`). +- Add a READY button to the overlay: `href = "${VITE_BOARD_URL}/play?callsign=${callsign}"`. + - `VITE_BOARD_URL` is a **public** build var (the board's address is a variable, never a secret). + If unset, the button is hidden (pre-S3 / local dev) — the site degrades to today's behaviour. + - **Build-time wiring (load-bearing — tracked in slides issue #161):** `VITE_BOARD_URL` is read + by Vite at **build** time (`import.meta.env`), so the taught workflow's **build** step must set + it: `env: { VITE_BOARD_URL: ${{ vars.BOARD_URL }} }` on the `npm run build` step. The pinned + S3 report step exposes `BOARD_URL` only at *report* time (runtime), which the build never sees — + so without this the button stays hidden and the race is unreachable in the real learner + deployment. Reuses the existing `BOARD_URL` repo variable; adds no new secret. + - The button is inert until the board is in race mode; the cockpit handles the "waiting for + race" state, so the button can safely exist from the first fork. +- No other launchpad change. It remains the solo "file" half of the contrast. + +### Board changes (race mode) + +- **`/play` cockpit page** (new Vite entry): reads `?callsign`, opens a WebSocket to the board, + renders the typing UI + the learner's own ship, sends progress. This is where the heavy game + client lives — keeping it board-side is deliberate (the server is the hub; everyone comes here). +- **Inbound WebSocket handling** (`app.js`): today `wss` ignores inbound messages. Add a cockpit + message protocol (below). Spectator connections stay read-only. +- **Authoritative race state** (new `src/race.js`, pure + node-testable, mirroring `room.js`): the + server owns the prompt sequence and each ship's position; clients report completions, the server + advances and broadcasts. Authoritative-server is on-theme for the lesson. +- **Operator control surface**: start/reset a race, toggle projector view orbit ↔ race. Guarded by + a separate server-side `OPERATOR_KEY` (kept distinct from `SHIPIT_TOKEN` — the report token and the + race-control key are different concerns). Never exposed to clients. +- **2D race view** (board client): a **Three.js orthographic camera** onto a side-on track, reusing + the existing low-poly `.glb` ships (same assets as the orbit view), rendering all ships from + broadcast race state, plus the live server HUD. Keeps the "board carries the Three.js spectacle" + convention and the existing WebGL/reduced-motion fallback path (a 2D static leaderboard when WebGL + is unavailable). + +### Data flow + +``` +S3 pipeline ──report.sh POST──▶ board roster {callsign,color,shipModel} + │ +learner opens own Pages site ──READY──▶ BOARD_URL/play?callsign=X + │ (roster gate: X must be present) + ▼ + cockpit WS ⇄ board race state ──broadcast──▶ projector 2D race +``` + +## Game design + +### Mechanic (v1 — defaults locked, numbers tunable at playtest) + +Classic typing racer, server-authoritative: + +- **Same prompts for everyone.** At round start the server picks `N` commands from the session + corpus and broadcasts the identical ordered sequence to all cockpits (fairness). Default + **`N = 12`** (≈60–120s for a beginner cohort). +- **Position = commands completed.** The track has `N` segments; a ship advances one segment per + command it completes. Ship position on the projector = `completed / N`. First to `N` wins; + ties broken by server-side completion time. +- **Completion is exact-match.** The cockpit shows the target command; a command counts complete only + when the learner's input exactly matches (backspace allowed). On match the cockpit sends + `{ t: 'progress', callsign, completed }`; the server validates the index is the expected next one, + advances, and broadcasts. +- **Beginner-friendly mistypes.** No lockout, no reset, no hard penalty — a mistype simply costs the + time to fix and retype. Speed differences alone separate the field. +- **Server is authoritative over position and round phase.** Keystroke-level correctness is judged + client-side (lightly spoofable — acceptable, see security); the server owns who is where and when + the round ends. + +### Corpus (session-gated) + +Prompts are drawn only from commands taught up to the current point (~130 forms by CI/CD 3: +Linux ~40, git ~20, gh ~23, docker ~30, aws ~27 — inventory in the slides repo). Natural +difficulty tiers fall out by tool (short Linux basics → longer git/docker → aws one-liners as +"boss" prompts). Corpus lives in a board data file, filterable by session. + +### Race lifecycle + +Instructor/operator-driven: start round, reset, next round. Learners joining mid-round enter at the +start line of the next round; disconnects are handled gracefully (ship parks, rejoins on reconnect). +No persistence beyond the current cohort session (arena pattern). + +## WebSocket protocol + +Extends the current `{ t: 'roster', ships }` broadcast. Cockpit (inbound) and race (outbound) +messages: + +- **inbound** `{ t: 'join', callsign }` — cockpit announces itself; server validates against roster. +- **inbound** `{ t: 'progress', callsign, completed }` — learner finished command index `completed`. +- **outbound** `{ t: 'race', phase, prompts, ships }` — authoritative race snapshot (positions, + current round, phase = `idle | countdown | running | finished`). +- **outbound** `{ t: 'roster', ships }` — unchanged, used by the orbit view. + +Client-side keystroke validation (the cockpit decides "typed correctly"); the server is +authoritative over *position and round*. Keystroke validation is lightly spoofable — acceptable +(see security). + +## Identity & security + +- `SHIPIT_TOKEN` **never** touches the client. It is the CI/CD 3 secret, used only server-side by + `report.sh` in the runner. The Pages bundle is world-readable; embedding the board bearer token + there would leak it. The cockpit WebSocket is therefore **unauthenticated by design**. +- `?callsign=X` is a claim, spoofable by anyone. For a classroom party game this is acceptable and + intentional. The roster gate limits play to callsigns that actually shipped, which is enough. +- The operator control surface is the one privileged path; guard it server-side with an operator key, + never shipped to clients. + +## Session integration + +Race in **both** CI/CD 3 and CI/CD 4, with different jobs (see slides issue #161): + +- **CI/CD 3 — the experience.** Everyone's pipeline has reported → all connected → race on the + instructor board as the celebratory session close. No server concept taught; no new LO. It is the + payoff that *secret worked*. +- **CI/CD 4 — the lesson.** The session's existing productive-failure ("Pages ≠ jalankan server") is + reframed to reference the race they already played, then they build + deploy the board image to + their own EC2. Kill-server demo + "the engine you deployed is that race server" reveal. Optional + short victory re-race. + +**Two servers, two roles:** the shared multiplayer race always runs on **instructor infra** (only a +shared server gives multiplayer); the board each learner deploys to their EC2 in S4 is a **solo +victory lap** running the same software. That distinction is itself teachable. + +The slides live in a separate repo and are reconciled by hand — tracked in issue #161, to be done +in a separate session. + +## Pins & contracts impact + +- Report → board event contract: **unchanged.** +- Serverless S1–S3 launchpad: **preserved** (static site + WebSocket client). +- "Workflow never sets `VITE_CALLSIGN`": **preserved** (hostname-parse instead). +- S4 concept (image → GHCR → EC2): **preserved and reinforced.** +- New public build var `VITE_BOARD_URL` for the launchpad (the board address is a variable, not a + secret). **Must be set on the taught workflow's `npm run build` step** (`env: VITE_BOARD_URL: + ${{ vars.BOARD_URL }}`) or the READY button hides and the race is unreachable — build-time var, not + the S3 report step's runtime `BOARD_URL`. Slides/workflow wiring tracked in issue #161. +- Board **lowercases the callsign at ingest** (canonical, case-insensitive usernames / lowercase + Pages hostnames) — shared-board display becomes lowercase; document in CLAUDE.md / slides. Not a + pin violation (no contract pins display case). +- Board accepts inbound cockpit WebSocket messages (new); production still rejects unauthenticated + *events* on `/api/event` (unchanged) — the cockpit WS is a separate, deliberately unauthenticated + channel. + +## Resolved decisions (2026-07-18) + +1. **Renderer for the 2D race view: Three.js orthographic camera**, reusing the existing low-poly + `.glb` ships — keeps the "board carries the Three.js spectacle" convention and the + WebGL/reduced-motion fallback path, and reuses assets. (Canvas 2D rejected: second renderer + new + fallback path, off-convention.) +2. **Race authority: server-authoritative** over position and round phase, with client-side + keystroke validation. On-theme for "the server holds the truth." (Fully client-reported positions + rejected: too spoofable, and it undercuts the lesson.) +3. **Mechanic: v1 defaults locked** (see Game design) — `N = 12` commands, position = completions, + exact-match completion, beginner-friendly mistypes. Numbers tunable at playtest. + +## Non-goals (YAGNI) + +- No accounts, no auth beyond the roster gate, no persistence beyond the cohort session. +- No anti-cheat beyond the roster gate (party game). +- No launchpad backend, no launchpad-as-cockpit (rejected: it blurs the static-vs-server lesson). +- No new test frameworks (Node's built-in `node --test` only; the one gate stays `preflight.mjs`). + +## Build & delivery timing + +The game must ship in the prop **before CI/CD 3** (the experience race is that session's close), +not before CI/CD 4. This moves the delivery deadline earlier than a CI/CD 4-only payoff would. The +launchpad READY button ships in the fork from the start (static, inert until race mode). + +## Testing + +- Launchpad `callsign.js`: `node --test` unit tests (hostname → callsign, fallbacks). +- Board `race.js`: `node --test` unit tests (pure race-state transitions), mirroring `room.test.js`. +- Board `app.js`: server tests for the cockpit WS join/progress path, mirroring `server.test.js`. +- The one learner-facing gate remains `preflight.mjs`. No vitest, no Playwright. diff --git a/docs/specs/2026-07-20-unified-race-ui-design.md b/docs/specs/2026-07-20-unified-race-ui-design.md new file mode 100644 index 0000000..7c13dc1 --- /dev/null +++ b/docs/specs/2026-07-20-unified-race-ui-design.md @@ -0,0 +1,178 @@ +# Unified 2D Race UI — shared track, live progress, operator console + +**Date:** 2026-07-20 · **Status:** approved (user delegated self-review) +**Supersedes** the race *rendering* half of `2026-07-18-typing-race-mode-design.md`. +Server race authority, roster gate, session corpora, and the operator HTTP endpoints stay as +designed there — this spec changes how the race is *drawn*, how progress is *reported*, and adds +an operator page so race control needs no curl. + +## Goals (user-approved decisions) + +1. **One race view for everyone.** Projector (Mission Control) and learner cockpit (`/play`) + render the race with the *same* DOM component — same rows, same sprites, same motion. +2. **2D rows layout.** One row per racer, stacked top-to-bottom; ships fly left→right toward a + finish line at the right edge. Scales to 40+ racers by shrinking rows, never scrolling. +3. **Own ship visible.** Each racer sees their own row highlighted (and slightly taller), with a + small rendered model of *their* ship (their `shipModel` + `color`). +4. **Live, smooth movement.** Ships glide per keystroke, not per completed prompt: the cockpit + reports fractional progress on the current prompt; positions animate continuously. +5. **Proper operator UI.** A `/operator` page with START (session picker), RESET, and + ORBIT/RACE view buttons replaces curl. + +Non-goals: the 3D orbit view is untouched (spectacle lives there); no persistence; no join auth +beyond the existing roster gate; no anti-cheat beyond the existing monotonic `completed` guard. + +## Architecture + +``` +board/client/ + race-track.js NEW shared DOM race component (projector + cockpit) + race-layout.js NEW pure math: progress fraction, lane order, ranks (node-tested) + ship-sprite.js NEW GLB → cached 2D sprite data-URLs (WebGL once, then plain ) + race-track.css NEW component styles, imported by both entry CSS files + operator.html/.js/.css NEW instructor console page + main.js MOD projector swaps createRaceView/createRaceFallback → createRaceTrack + play.js/.html/.css MOD cockpit = race-track on top + typing dock at bottom + race-view.js DEL (Three.js race scene — superseded) + race-fallback.js DEL (DOM leaderboard — the new component is its own fallback) + track.js DEL (world-coordinate math — superseded by race-layout.js) + *.test.js for deleted files DEL; race-layout.test.js NEW +board/src/ + race.js MOD racer gains display-only `frac` + app.js MOD WS `progress` accepts `frac`; static route alias /operator + messages.js MOD raceMsg ships include `frac` +board/test/race.test.js MOD frac behaviour +board/vite.config.js MOD third rollup input: operator.html +``` + +## `race-track.js` — the shared component + +```js +createRaceTrack(container, { me = null } = {}) // → { update(raceState), dispose() } +// raceState = the WS race message: { phase, total, ships: [...] } +// ships[i] = { callsign, completed, frac, finishedAt, color, shipModel } +``` + +Same `{ update, dispose }` shape as every other view so `main.js` swaps it freely. + +- **Rows.** Flex column filling `container`. Row order is **stable alphabetical** by callsign — + rows never reorder mid-race (ships move only horizontally; rank is shown as a number chip that + updates). Each row: rank chip · `@callsign` label · track lane with finish line · ship sprite · + progress readout (`7.4/12`-style, one decimal). +- **Responsive to 40+.** Rows are `flex: 1 1 0` with `min-height` ≈ 10px and `max-height` ≈ 48px, + so 40 rows split any viewport evenly with no scrolling. Font sizes `clamp()`ed; the callsign + label hides in dense mode, which triggers at 25+ racers (full name stays as `title`). The `me` row gets + `flex-grow: 2` plus accent highlight so it stays findable at any density — and on the + cockpit it is pinned to the BOTTOM of the stack (margin-top auto + last flex order), + directly above the typing dock, so your ship moves where your eyes already are. The + projector (no `me`) stays fully alphabetical. +- **Motion.** Ship position `left: calc(p × (100% − sprite width))` where + `p = progressOf(completed, frac, total)`. CSS `transition: left 150ms linear`; with ~10 Hz + fractional updates this reads as continuous gliding. `prefers-reduced-motion: reduce` disables + the transition (positions still update — discrete steps, no animation). No + `requestAnimationFrame`, no WebGL at render time — the component IS the reduced-capability + fallback, which is why `race-fallback.js` dies. +- **Phases.** `idle`: dimmed rows + "WAITING FOR LAUNCH…" banner. `running`: live — and once any + racer lands, the banner shows podium medals as they arrive (a ghost racer who never finishes + blocks the server's `finished` phase forever, so winners must not wait for it). `finished`: + same podium banner, top 3 by `finishedAt`. Finished ships show `✦` + final rank on their row. +- **Dark-only**, with its own local CSS custom properties (not the light/dark scheme in + `style.css`/`play.css`) — consistent with the board's dark stage pages. + +## `race-layout.js` — pure, node-tested + +- `progressOf(completed, frac, total)` → 0..1, clamped, `frac` counted only below `total`. +- `laneOrder(ships)` → stable alphabetical callsign list (replaces `race-view.js`'s `laneOf`). +- `ranks(ships)` → Map callsign→rank: finished ships by `finishedAt` first, then by + `completed + frac` descending, ties alphabetical. + +## `ship-sprite.js` — GLB snapshot sprites + +- `shipSprite(shipModel, color)` → `Promise` (PNG data-URL, or `null` = no WebGL / + load failure). Cache `Map` keyed `` `${shipModel}|${color}` `` — one render per pair ever. +- Implementation: lazy singleton offscreen `WebGLRenderer` (~64×64, alpha), reuses + `preloadShipTemplates()` + `createShip()` from `ship-mesh.js` (same hue pipeline as orbit), + side profile nose-right (`rotation.y = π/2`), one render, `toDataURL()`, ship disposed; + renderer kept for the next cache miss. +- `null` fallback: the component renders a CSS triangle glyph tinted with the racer's color — + identity degrades gracefully, layout identical. + +## Live progress protocol + +Cockpit → server (WS), extending the existing message — no new type: + +```json +{ "t": "progress", "completed": 7, "frac": 0.42 } +``` + +- `frac` = matched-prefix length ÷ current prompt length, clamped 0–1. Sent on keystroke, + **trailing-throttled to one message per 100 ms**. A fully typed command reports `frac` 0.9 + and holds — the racer must press **ENTER to run it** (terminal muscle-memory beat); the + completion message bypasses the throttle and sends immediately on ENTER, with a local + lunge+flare "boost" flourish on their ship. ENTER on a wrong/incomplete line shakes the + input and sends nothing. +- Server (`race.js`), only while `phase === 'running'`, racer known: + - `m.completed === r.completed + 1` → advance (existing monotonic guard), `r.frac = 0`. + - `m.completed === r.completed` → `r.frac = clamp01(Number(m.frac) || 0)`. + - anything else → ignored (replay/out-of-order, as today). + - `start()`/`reset()` zero `frac`. `snapshot()` ships include `frac`. +- `frac` is **display-only**: finishing, ranking, and `_allFinished` still key off `completed` + alone. A hand-crafted `frac` can wiggle a sprite, never win a race. +- Load: 40 racers × 10 Hz = 400 msg/s inbound of ~40 bytes; broadcasts stay batched on the + existing 50 ms tick. Negligible — **no server resize needed** (user offered; declined). + +## Cockpit (`play.html` / `play.js`) + +- Layout: `race-track` container fills the screen; typing dock pinned at bottom. The dock is a + styled **terminal window** (title bar + `$ ` prompt line): the current command sits greyed out + on the line and **lights up character by character** as the racer types it in place — there is + no visible input field (a hidden input catches keystrokes and summons mobile keyboards). + Typing is **strict**: a wrong key never lands (caret flashes red, thud sfx), so backspace is + unnecessary and disabled — only correct keys advance the cursor. `typing.js` exports the pure + `advance(target, at, incoming)` cursor walk (replaces `typedState`). +- `play.js` keeps: callsign-from-query, roster-denied message, reconnect loop, optimistic + `completed` with server re-sync on reconnect/new-round (existing logic verbatim). Adds: + `createRaceTrack(container, { me: callsign })`, `track.update(m)` on every race message, and + the throttled `frac` sender wired to the existing `typedState()` matcher. + +## Projector (`main.js`) + +- `makeRace()` becomes `createRaceTrack(app)` — no `me`. The WebGL-capability branch and + `race-fallback` import go away for the race path (orbit keeps its own fallback logic). +- `view.update(lastRaceShips)` changes to passing the whole race state (component needs + `phase`/`total`, not just ships). `#race-hud` stays as-is. + +## `/operator` console + +- Static page served by the existing static handler; `app.js` adds route alias + `/operator` → `/operator.html` (mirror of the `/play` alias). New third input in + `vite.config.js`. +- UI: OPERATOR_KEY field (persisted `localStorage['shipit-operator-key']`), session picker + (`cicd3`/`cicd4` — hardcoded to match `SESSIONS`), buttons **START RACE**, **RESET**, + **VIEW: ORBIT/RACE**, wired to the three existing endpoints with + `Authorization: Bearer `; response status shown inline (`202 ✓` / `401 wrong key`). +- Live readout: read-only WS connection (no `join`) shows phase + joined-racer count from the + broadcast race message. No server auth change — WS was always read-open; controls stay + key-guarded HTTP POSTs. +- Ops note (accepted): key sits in operator's localStorage and travels plaintext over the + board's HTTP — same classroom-grade posture as today's curl. Rotate the key per cohort. + +## Testing (`node --test`, no new frameworks) + +- `board/test/race.test.js` — extend: frac clamped, stored only while running, zeroed on + completion/start/reset, ignored for unknown racers, absent `frac` defaults 0, never flips + phase. +- `board/client/race-layout.test.js` — `progressOf` bounds, `laneOrder` stability, `ranks` + finished-first ordering + ties. +- `board/test/server.test.js` — extend if it exercises WS progress: frac round-trips into the + broadcast. +- Deleted files take their tests with them (`track.test.js`; `race-view`/`race-fallback` had + none beyond track's). +- Sprite rendering and CSS sizing are visual — verified by hand against dev board (WebGL and + no-WebGL paths), not unit-tested, per repo convention (props are pedagogy-first). + +## Rollout + +Board-only change — no learner-repo, workflow, or slide-contract impact (`report.sh` event +contract untouched; race WS message extended backward-compatibly: old clients ignore `frac`). +Ship as a normal board release: build, tag, GHCR publish, redeploy instructor EC2. diff --git a/launchpad/scripts/report.sh b/launchpad/scripts/report.sh new file mode 100755 index 0000000..2aefb36 --- /dev/null +++ b/launchpad/scripts/report.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# report.sh — POST one launch event to the Mission Control board. +# +# The learner workflow calls this with just two env values (CI/CD 3 Amali 2): +# BOARD_URL board address (repository variable -> env) +# SHIPIT_TOKEN shared token (repository secret -> env) +# callsign comes from GITHUB_ACTOR (set by Actions automatically); color and +# shipModel come from ship.config.json. Stage/status are overridable for +# operator flourishes — report.sh [stage] [status] — default: liftoff shipped. +# +# siteUrl is derived here from the vars Actions already sets (GITHUB_REPOSITORY_ +# OWNER + repo name -> https://.github.io[/]/), NOT from the taught +# env: block — the learner's two-line surface (BOARD_URL + SHIPIT_TOKEN) is +# pinned. The board probes that URL and only shows the ship LIVE (green) when the +# real Pages site answers 200 (issue #21). Omitted when run outside Actions. +# +# curl uses --fail-with-body on purpose: a rejected report (401) must FAIL the +# step (non-zero exit -> red run) while still printing {"error":"unauthorized"} +# in the run log, so the wrong-token demo shows both the red X and the reason. +# Plain -f would fail but swallow the response body — don't switch to it. +set -euo pipefail + +: "${BOARD_URL:?BOARD_URL not set — register it as a repository variable}" +: "${SHIPIT_TOKEN:?SHIPIT_TOKEN not set — register it as a repository secret}" +: "${GITHUB_ACTOR:?GITHUB_ACTOR not set (GitHub Actions sets this automatically)}" + +DIR="$(cd "$(dirname "$0")" && pwd)" +BODY="$(node -e ' + const fs = require("fs"); + const cfg = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + // GitHub Pages URL from the repo Actions is running in. github.io hostnames are + // lowercase (owner); the project-path segment keeps the repo name as-is. A user/ + // org site (repo == .github.io) lives at the domain root, no path. + const repoFull = process.env.GITHUB_REPOSITORY || ""; + const owner = (process.env.GITHUB_REPOSITORY_OWNER || repoFull.split("/")[0] || "").toLowerCase(); + const repo = repoFull.split("/")[1] || ""; + let siteUrl; + if (owner && repo) { + siteUrl = repo.toLowerCase() === owner + ".github.io" + ? "https://" + owner + ".github.io/" + : "https://" + owner + ".github.io/" + repo + "/"; + } + process.stdout.write(JSON.stringify({ + callsign: process.env.GITHUB_ACTOR, + stage: process.argv[2], + status: process.argv[3], + color: cfg.color, + shipModel: cfg.shipModel, + siteUrl, // undefined outside Actions -> dropped by JSON.stringify + })); +' "$DIR/../ship.config.json" "${1:-liftoff}" "${2:-shipped}")" + +curl -sS --fail-with-body -X POST "$BOARD_URL/api/event" \ + -H "Authorization: Bearer $SHIPIT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$BODY" +echo diff --git a/launchpad/src/callsign.js b/launchpad/src/callsign.js new file mode 100644 index 0000000..1960be2 --- /dev/null +++ b/launchpad/src/callsign.js @@ -0,0 +1,12 @@ +// Identity = the learner's GitHub username, derived from the Pages hostname at +// runtime: `user.github.io` (user + project pages both) -> `user`. No build var, +// no VITE_CALLSIGN in the taught workflow. Falls back to VITE_CALLSIGN then ''. +export function callsignFromHostname(hostname) { + if (typeof hostname !== 'string') return ''; + const m = /^([a-z0-9-]+)\.github\.io$/.exec(hostname.toLowerCase()); + return m ? m[1] : ''; +} + +export function resolveCallsign() { + return callsignFromHostname(window.location.hostname) || import.meta.env.VITE_CALLSIGN || ''; +} diff --git a/launchpad/src/callsign.test.mjs b/launchpad/src/callsign.test.mjs new file mode 100644 index 0000000..abd44a9 --- /dev/null +++ b/launchpad/src/callsign.test.mjs @@ -0,0 +1,16 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { callsignFromHostname } from './callsign.js'; + +test('extracts the username from a github.io hostname', () => { + assert.equal(callsignFromHostname('octocat.github.io'), 'octocat'); + assert.equal(callsignFromHostname('My-User.github.io'), 'my-user'); // lowercased +}); + +test('returns empty for non-Pages hostnames', () => { + assert.equal(callsignFromHostname('localhost'), ''); + assert.equal(callsignFromHostname('example.com'), ''); + assert.equal(callsignFromHostname('octocat.github.io.evil.com'), ''); + assert.equal(callsignFromHostname(''), ''); + assert.equal(callsignFromHostname(undefined), ''); +}); diff --git a/launchpad/src/main.js b/launchpad/src/main.js index 7a91351..1e617c7 100644 --- a/launchpad/src/main.js +++ b/launchpad/src/main.js @@ -3,12 +3,14 @@ import { createScene } from './scene.js'; import { renderOverlay } from './overlay.js'; import { renderTelemetry } from './telemetry.js'; import { shouldUseFallback, detectWebGL, renderFallback } from './fallback.js'; +import { resolveCallsign } from './callsign.js'; +import { renderReady } from './ready.js'; import './style.css'; const app = document.getElementById('app'); document.title = `${ship.shipName} — Ship`; -const callsign = import.meta.env.VITE_CALLSIGN || ''; +const callsign = resolveCallsign(); const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const gl = detectWebGL(); @@ -30,3 +32,5 @@ if (shouldUseFallback({ gl, reducedMotion })) { overlay = renderOverlay(app, ship, callsign); telemetry = renderTelemetry(app, ship, callsign); } + +renderReady(app, callsign); diff --git a/launchpad/src/ready.js b/launchpad/src/ready.js new file mode 100644 index 0000000..afba480 --- /dev/null +++ b/launchpad/src/ready.js @@ -0,0 +1,19 @@ +// launchpad/src/ready.js +// The one bridge from the static site to the server: a link to the board cockpit, +// carrying the learner's callsign. Hidden unless we know both who they are and +// where the board is. BOARD_URL is a public build var — never a secret. +export function readyHref(boardUrl, callsign) { + if (!boardUrl || !callsign) return null; + return `${boardUrl.replace(/\/$/, '')}/play?callsign=${encodeURIComponent(callsign)}`; +} + +export function renderReady(root, callsign) { + const href = readyHref(import.meta.env.VITE_BOARD_URL, callsign); + if (!href) return null; + const a = document.createElement('a'); + a.className = 'ready'; + a.href = href; + a.textContent = 'READY ▸ JOIN RACE'; + root.append(a); + return a; +} diff --git a/launchpad/src/ready.test.mjs b/launchpad/src/ready.test.mjs new file mode 100644 index 0000000..f3e513b --- /dev/null +++ b/launchpad/src/ready.test.mjs @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readyHref } from './ready.js'; + +test('returns null when boardUrl is falsy', () => { + assert.equal(readyHref('', 'octocat'), null); + assert.equal(readyHref(null, 'octocat'), null); + assert.equal(readyHref(undefined, 'octocat'), null); +}); + +test('returns null when callsign is falsy', () => { + assert.equal(readyHref('https://board.example.com', ''), null); + assert.equal(readyHref('https://board.example.com', null), null); + assert.equal(readyHref('https://board.example.com', undefined), null); +}); + +test('strips a trailing slash from boardUrl', () => { + assert.equal( + readyHref('https://board.example.com/', 'octocat'), + 'https://board.example.com/play?callsign=octocat', + ); + assert.equal( + readyHref('https://board.example.com', 'octocat'), + 'https://board.example.com/play?callsign=octocat', + ); +}); + +test('encodeURIComponents the callsign', () => { + assert.equal( + readyHref('https://board.example.com', 'space cadet'), + 'https://board.example.com/play?callsign=space%20cadet', + ); + assert.equal( + readyHref('https://board.example.com', 'a&b=c'), + 'https://board.example.com/play?callsign=a%26b%3Dc', + ); +}); diff --git a/launchpad/src/style.css b/launchpad/src/style.css index 617d314..99a97c6 100644 --- a/launchpad/src/style.css +++ b/launchpad/src/style.css @@ -105,3 +105,22 @@ body { font-family: system-ui, sans-serif; } } @keyframes tm-pulse { 50% { opacity: 0.35; } } @keyframes tm-fill { from { width: 0; } } + +.ready { + position: fixed; + bottom: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 20; + padding: 0.7rem 1.4rem; + font: 700 0.95rem/1 ui-monospace, Menlo, Consolas, monospace; + letter-spacing: 0.08em; + color: #0b1220; + background: var(--ship-color, #22d3ee); + border-radius: 999px; + text-decoration: none; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15), 0 8px 30px rgba(0, 0, 0, 0.45); + transition: transform 0.12s ease, box-shadow 0.12s ease; +} +.ready:hover { transform: translateX(-50%) translateY(-2px); } +.ready:focus-visible { outline: 2px solid #fff; outline-offset: 3px; } diff --git a/scripts/release-launchpad.sh b/scripts/release-launchpad.sh deleted file mode 100755 index 51d5f71..0000000 --- a/scripts/release-launchpad.sh +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env bash -# release-launchpad.sh — assemble the forkable Infratify/shipit-launchpad repo from this monorepo. -# -# main payload only (ship + config + preflight) — NO workflow, NO board/ -# cicd1..4 answer keys; each = main + that session's deploy.yml (board/ enters at cicd4) -# -# Usage: release-launchpad.sh [--out DIR] [--push REMOTE] -# --out DIR where to build the repo (default: /.launchpad-release) -# --push REMOTE after building, force-push main + cicd1..4 to REMOTE (a git URL) -set -euo pipefail - -ROOT="$(git rev-parse --show-toplevel)" -OUT="$ROOT/.launchpad-release" -PUSH="" -while [ $# -gt 0 ]; do - case "$1" in - --out) OUT="$2"; shift 2 ;; - --push) PUSH="$2"; shift 2 ;; - *) echo "unknown arg: $1" >&2; exit 2 ;; - esac -done - -WF="$ROOT/starter/workflows" -for n in 1 2 3 4; do [ -f "$WF/deploy.cicd$n.yml" ] || { echo "missing $WF/deploy.cicd$n.yml" >&2; exit 1; }; done -[ -f "$ROOT/starter/README.learner.md" ] || { echo "missing starter/README.learner.md" >&2; exit 1; } - -# --- stage the payload-only main tree --- -# Uses tar (universally present; no rsync dependency for a tool instructors/CI will run). -# Copy the ship payload skipping the heavy build dirs, then strip dev/monorepo cruft precisely. -STAGE="$(mktemp -d)"; trap 'rm -rf "$STAGE"' EXIT -tar -C "$ROOT/launchpad" --exclude='./node_modules' --exclude='./dist' -cf - . | tar -C "$STAGE" -xf - -rm -f "$STAGE/pnpm-lock.yaml" "$STAGE/pnpm-workspace.yaml" -find "$STAGE" -name '*.test.mjs' -delete -find "$STAGE" -type d -name '__fixtures__' -prune -exec rm -rf {} + -cp "$ROOT/starter/README.learner.md" "$STAGE/README.md" -# drop the dev-only test:unit script from package.json (Node, no jq dependency) -node -e 'const fs=require("fs"),f=process.argv[1],p=JSON.parse(fs.readFileSync(f));delete p.scripts["test:unit"];fs.writeFileSync(f,JSON.stringify(p,null,2)+"\n")' "$STAGE/package.json" - -# --- build the git repo --- -rm -rf "$OUT"; mkdir -p "$OUT" -git -C "$OUT" init -q -git -C "$OUT" config user.email "bootcamp@infratify.dev" -git -C "$OUT" config user.name "Ship It release" - -commit_tree() { git -C "$OUT" add -A && git -C "$OUT" commit -q -m "$1"; } -sync_into() { tar -C "$1" -cf - . | tar -C "$OUT" -xf -; } # copy tree contents into OUT, keeping .git - -# main -git -C "$OUT" checkout -q -b main -sync_into "$STAGE/" -commit_tree "shipit-launchpad: ship payload — fork & build on this (no workflow, no board/)" - -# cicd1..3: layer the workflow as .github/workflows/deploy.yml -prev=main -for n in 1 2 3; do - git -C "$OUT" checkout -q -b "cicd$n" "$prev" - mkdir -p "$OUT/.github/workflows" - cp "$WF/deploy.cicd$n.yml" "$OUT/.github/workflows/deploy.yml" - commit_tree "cicd$n: session $n answer key" - prev="cicd$n" -done - -# cicd4: workflow + the board/ payload (the black box they build) -git -C "$OUT" checkout -q -b cicd4 cicd3 -cp "$WF/deploy.cicd4.yml" "$OUT/.github/workflows/deploy.yml" -mkdir -p "$OUT/board" -tar -C "$ROOT/board" --exclude='./node_modules' --exclude='./dist' -cf - . | tar -C "$OUT/board" -xf - -commit_tree "cicd4: session 4 answer key + board/ (build & ship it)" - -git -C "$OUT" checkout -q main - -echo "built: main cicd1 cicd2 cicd3 cicd4 -> $OUT" - -if [ -n "$PUSH" ]; then - echo "pushing to $PUSH ..." - git -C "$OUT" push --force "$PUSH" main cicd1 cicd2 cicd3 cicd4 - echo "pushed." -fi diff --git a/scripts/release-launchpad.test.sh b/scripts/release-launchpad.test.sh deleted file mode 100755 index ddf8a49..0000000 --- a/scripts/release-launchpad.test.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# Asserts release-launchpad.sh produces the correct branch trees. -set -euo pipefail -ROOT="$(git rev-parse --show-toplevel)" -OUT="$(mktemp -d)"; trap 'rm -rf "$OUT"' EXIT -"$ROOT/scripts/release-launchpad.sh" --out "$OUT" >/dev/null - -git -C "$OUT" rev-parse --verify main >/dev/null || { echo "FAIL: no main"; exit 1; } -for b in cicd1 cicd2 cicd3 cicd4; do - git -C "$OUT" rev-parse --verify "$b" >/dev/null || { echo "FAIL: no $b"; exit 1; } -done - -co() { git -C "$OUT" checkout -q "$1"; } -absent() { [ ! -e "$OUT/$1" ] || { echo "FAIL: $2 has $1"; exit 1; }; } -present() { [ -e "$OUT/$1" ] || { echo "FAIL: $2 missing $1"; exit 1; }; } - -# main: payload only — no workflow, no board, no monorepo cruft, no dev tests -co main -absent ".github/workflows" main -absent "board" main -absent "pnpm-lock.yaml" main -absent "pnpm-workspace.yaml" main -absent "scripts/__fixtures__" main -present "ship.config.json" main -present "scripts/preflight.mjs" main -present "src/main.js" main -[ -z "$(find "$OUT/src" -name '*.test.mjs')" ] || { echo "FAIL: main ships dev tests"; exit 1; } -grep -q '"test:unit"' "$OUT/package.json" && { echo "FAIL: main keeps test:unit"; exit 1; } || true -grep -qi fork "$OUT/README.md" || { echo "FAIL: main README not the learner one"; exit 1; } - -# cicdN: workflow == the Nth end-state; board only at cicd4 -for n in 1 2 3 4; do - co "cicd$n" - present ".github/workflows/deploy.yml" "cicd$n" - diff -q "$OUT/.github/workflows/deploy.yml" "$ROOT/starter/workflows/deploy.cicd$n.yml" >/dev/null \ - || { echo "FAIL: cicd$n workflow != deploy.cicd$n.yml"; exit 1; } -done -co cicd1; absent "board" cicd1 -co cicd3; absent "board" cicd3 -co cicd4; present "board/Dockerfile" cicd4; present "board/src/index.js" cicd4 -absent "board/node_modules" cicd4 - -# ship.config.json frozen == monorepo payload (discipline) -co main -diff -q "$OUT/ship.config.json" "$ROOT/launchpad/ship.config.json" >/dev/null \ - || { echo "FAIL: main ship.config.json drifted from payload"; exit 1; } - -echo "OK: release produces correct main + cicd1..4 trees" diff --git a/scripts/verify-fork-sync.sh b/scripts/verify-fork-sync.sh deleted file mode 100755 index 1b84558..0000000 --- a/scripts/verify-fork-sync.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# verify-fork-sync.sh — prove the load-bearing discipline: a learner fork syncs upstream `main` -# with ZERO conflicts, because upstream main never gains a workflow and never re-touches -# ship.config.json. Local git only — merge mechanics are identical to a GitHub fork. -set -euo pipefail -ROOT="$(git rev-parse --show-toplevel)" -W="$(mktemp -d)"; trap 'rm -rf "$W"' EXIT - -# 1. Build the upstream repo (main + cicd1..4) -"$ROOT/scripts/release-launchpad.sh" --out "$W/upstream" >/dev/null -UP="$W/upstream" - -# 2. Structural invariants on upstream main -git -C "$UP" checkout -q main -[ ! -e "$UP/.github/workflows" ] || { echo "FAIL: upstream main carries a workflow"; exit 1; } -[ ! -e "$UP/board" ] || { echo "FAIL: upstream main carries board/"; exit 1; } -diff -q "$UP/ship.config.json" "$ROOT/launchpad/ship.config.json" >/dev/null \ - || { echo "FAIL: upstream main ship.config.json drifted from the frozen payload"; exit 1; } - -# 3. Clone main as a learner fork -git clone -q --branch main "$UP" "$W/fork" -FORK="$W/fork" -git -C "$FORK" config user.email learner@example.com -git -C "$FORK" config user.name "Learner One" -git -C "$FORK" remote add upstream "$UP" - -# 4. Learner customizes: edit ship.config.json + author their own deploy.yml (the S1 file) -node -e 'const fs=require("fs"),f=process.argv[1],c=JSON.parse(fs.readFileSync(f));c.shipName="Learner One";c.color="#ff8800";fs.writeFileSync(f,JSON.stringify(c,null,2)+"\n")' "$FORK/ship.config.json" -mkdir -p "$FORK/.github/workflows" -cp "$ROOT/starter/workflows/deploy.cicd1.yml" "$FORK/.github/workflows/deploy.yml" -git -C "$FORK" add -A -git -C "$FORK" commit -q -m "my ship + my pipeline" - -# 5. Instructor fix on upstream main — touches src/ ONLY (never config, never a workflow) -git -C "$UP" checkout -q main -printf '\n// instructor: tiny scene tweak\n' >> "$UP/src/main.js" -git -C "$UP" add -A -git -C "$UP" commit -q -m "instructor: tweak scene" - -# 6. Learner syncs upstream main — MUST be conflict-free -git -C "$FORK" fetch -q upstream main -if ! git -C "$FORK" merge --no-edit upstream/main >/dev/null 2>&1; then - echo "FAIL: sync produced a conflict:"; git -C "$FORK" status --short - exit 1 -fi - -# 7. Both the learner's changes and the instructor fix survived -grep -q "Learner One" "$FORK/ship.config.json" || { echo "FAIL: learner config lost"; exit 1; } -grep -q "instructor: tiny scene tweak" "$FORK/src/main.js" || { echo "FAIL: instructor fix not merged"; exit 1; } -[ -f "$FORK/.github/workflows/deploy.yml" ] || { echo "FAIL: learner workflow lost"; exit 1; } - -echo "PASS: fresh fork synced an instructor fix with 0 conflicts (discipline holds)" diff --git a/scripts/verify-workflows.sh b/scripts/verify-workflows.sh deleted file mode 100755 index fafe87d..0000000 --- a/scripts/verify-workflows.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -# verify-workflows.sh — structural + contract checks on the four answer-key workflows. -# Not a CI gate; a fail-loud sanity check the release relies on. Runs actionlint if present. -set -euo pipefail -ROOT="$(git rev-parse --show-toplevel)" -WF="$ROOT/starter/workflows" - -fail() { echo "FAIL: $1" >&2; exit 1; } -# has() checks existence first so a not-yet-created file gives a clean "missing" message. -has() { [ -f "$1" ] || fail "missing $1"; grep -Fq -- "$2" "$1" || fail "$(basename "$1"): missing '$2'"; } - -# YAML-validity / actionlint over whatever answer keys exist at this stage (glob, not a fixed 1..4 -# list) — so this same script is correct at Task 2 (cicd1..2 present) and Task 3 (cicd1..4 present). -for f in "$WF"/deploy.cicd*.yml; do - [ -e "$f" ] || continue - if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' 2>/dev/null; then - python3 -c "import yaml,sys; yaml.safe_load(open('$f'))" || fail "$(basename "$f"): invalid YAML" - fi - if command -v actionlint >/dev/null 2>&1; then - actionlint "$f" || fail "$(basename "$f"): actionlint" - fi -done - -# cicd1 — Pages deploy on push, callsign injected -has "$WF/deploy.cicd1.yml" "branches: [main]" -has "$WF/deploy.cicd1.yml" "VITE_CALLSIGN: \${{ github.actor }}" -has "$WF/deploy.cicd1.yml" "actions/deploy-pages@v4" -has "$WF/deploy.cicd1.yml" "path: launchpad/dist" -has "$WF/deploy.cicd1.yml" "working-directory: launchpad" - -# cicd2 — adds the pre-flight test gate that blocks deploy -has "$WF/deploy.cicd2.yml" "npm test" -has "$WF/deploy.cicd2.yml" "needs: test" -has "$WF/deploy.cicd2.yml" "VITE_CALLSIGN: \${{ github.actor }}" - -# cicd3 — board reporting, pinned contract, >=1 pre-liftoff event -has "$WF/deploy.cicd3.yml" 'Bearer $SHIPIT_TOKEN' -has "$WF/deploy.cicd3.yml" '${{ vars.BOARD_URL }}' # env: line -has "$WF/deploy.cicd3.yml" '/api/event' # curl target (BOARD_URL is an env var there) -has "$WF/deploy.cicd3.yml" '\"stage\":\"pad\",\"status\":\"running\"' # pre-liftoff beat -has "$WF/deploy.cicd3.yml" '\"stage\":\"liftoff\",\"status\":\"shipped\"' -has "$WF/deploy.cicd3.yml" '\"stage\":\"test\",\"status\":\"failed\"' # abort report -has "$WF/deploy.cicd3.yml" "if: failure()" -has "$WF/deploy.cicd3.yml" "needs: test" - -# cicd4 — build board image (GHCR) + SSM deploy to the learner's EC2 -has "$WF/deploy.cicd4.yml" "docker/build-push-action@v6" -has "$WF/deploy.cicd4.yml" "context: board" -has "$WF/deploy.cicd4.yml" "ghcr.io/" -has "$WF/deploy.cicd4.yml" "packages: write" -has "$WF/deploy.cicd4.yml" "aws-actions/configure-aws-credentials@v4" -has "$WF/deploy.cicd4.yml" "aws-region: ap-southeast-1" -has "$WF/deploy.cicd4.yml" "ssm send-command" -has "$WF/deploy.cicd4.yml" "AWS-RunShellScript" -has "$WF/deploy.cicd4.yml" "-p 3000:3000" -has "$WF/deploy.cicd4.yml" "SHIPIT_TOKEN=" -has "$WF/deploy.cicd4.yml" '${{ vars.EC2_INSTANCE_ID }}' -# cicd4 still reports to the SHARED board (S3 step preserved) -has "$WF/deploy.cicd4.yml" '\"stage\":\"liftoff\",\"status\":\"shipped\"' - -echo "OK: verify-workflows (cicd1..4 present, contract + shape asserted)" diff --git a/starter/README.learner.md b/starter/README.learner.md deleted file mode 100644 index 9475eea..0000000 --- a/starter/README.learner.md +++ /dev/null @@ -1,53 +0,0 @@ -# shipit-launchpad — your ship - -Your personal **ship microsite** for the DevOps bootcamp: one of four low-poly Three.js spaceships -(Quaternius, CC0) you customize, and the thing your CI/CD pipeline builds, checks, and ships across -the four sessions. A green pipeline launches your ship into the shared **Mission Control** orbit on -the projector. - -## How this works - -You **forked** this repo. Across four sessions you will **author the pipeline yourself** — the file -`.github/workflows/deploy.yml` does not exist yet; you write it, and it grows one job per session. - -1. **Fork** this repo (you've done this). -2. **Enable Actions** on your fork once: the **Actions** tab → *I understand my workflows, go ahead - and enable them*. -3. **Add the upstream remote** once, so you can reach the answer keys: - `git remote add upstream https://github.com/Infratify/shipit-launchpad && git fetch upstream`. -4. Each session, edit files and `git push`, then watch the **Actions** tab. - -Stuck? The `cicd1`…`cicd4` branches are the answer keys — `git diff upstream/cicd1` to compare, or -reset to one if you're lost. **Sync fork** to pull instructor fixes. - -## Customize it - -Edit **`ship.config.json`** — the only file you need to touch: - -```json -{ - "shipName": "Nebula Runner", - "color": "#22d3ee", - "shipModel": "fighter", - "emblem": "comet" -} -``` - -- `shipName` — up to 24 characters. -- `color` — a hex colour like `#22d3ee` (recolours your ship — sets its hue to `color`). -- `shipModel` — one of: `fighter`, `interceptor`, `hauler`, `scout`. -- `emblem` — one of: `comet`, `bolt`, `star`, `ring`, `delta`, `phoenix`. - -Your **callsign** is your GitHub username — it's set automatically when the pipeline runs. - -## Run it locally - -```bash -npm install -npm run dev # live preview -npm test # pre-flight check — fails (ABORT) if ship.config.json is invalid -npm run build # static site → dist/ -npm run preview # serve the built site on :8080 -``` - -`npm test` is the pre-flight gate: a bad `ship.config.json` exits non-zero and blocks the launch. diff --git a/starter/workflows/deploy.cicd1.yml b/starter/workflows/deploy.cicd1.yml deleted file mode 100644 index 136fba1..0000000 --- a/starter/workflows/deploy.cicd1.yml +++ /dev/null @@ -1,41 +0,0 @@ -name: Ship It -on: - push: - branches: [main] - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: true - -# The ship app lives in launchpad/ in the learner fork; run every step there. -defaults: - run: - working-directory: launchpad - -jobs: - deploy: - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: launchpad/package-lock.json - - run: npm ci - - run: npm run build - env: - VITE_CALLSIGN: ${{ github.actor }} # your callsign = your GitHub username - - uses: actions/upload-pages-artifact@v3 - with: - path: launchpad/dist - - id: deploy - uses: actions/deploy-pages@v4 diff --git a/starter/workflows/deploy.cicd2.yml b/starter/workflows/deploy.cicd2.yml deleted file mode 100644 index 924ec88..0000000 --- a/starter/workflows/deploy.cicd2.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Ship It -on: - push: - branches: [main] - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: true - -# The ship app lives in launchpad/ in the learner fork; run every step there. -defaults: - run: - working-directory: launchpad - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: launchpad/package-lock.json - - run: npm ci - - run: npm test # node scripts/preflight.mjs — a bad ship.config.json ABORTS here - - deploy: - needs: test # deploy only runs if the pre-flight gate is green - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: launchpad/package-lock.json - - run: npm ci - - run: npm run build - env: - VITE_CALLSIGN: ${{ github.actor }} - - uses: actions/upload-pages-artifact@v3 - with: - path: launchpad/dist - - id: deploy - uses: actions/deploy-pages@v4 diff --git a/starter/workflows/deploy.cicd3.yml b/starter/workflows/deploy.cicd3.yml deleted file mode 100644 index cea5d3b..0000000 --- a/starter/workflows/deploy.cicd3.yml +++ /dev/null @@ -1,83 +0,0 @@ -name: Ship It -on: - push: - branches: [main] - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - id: cfg - run: | - echo "color=$(jq -r .color ship.config.json)" >> "$GITHUB_OUTPUT" - echo "model=$(jq -r .shipModel ship.config.json)" >> "$GITHUB_OUTPUT" - - name: Report to Mission Control — on the pad - env: - BOARD_URL: ${{ vars.BOARD_URL }} - SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} - run: | - curl -fsS -X POST "$BOARD_URL/api/event" \ - -H "authorization: Bearer $SHIPIT_TOKEN" \ - -H 'content-type: application/json' \ - -d "{\"callsign\":\"${{ github.actor }}\",\"stage\":\"pad\",\"status\":\"running\",\"color\":\"${{ steps.cfg.outputs.color }}\",\"shipModel\":\"${{ steps.cfg.outputs.model }}\"}" - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - run: npm test - - name: Report ABORT if the pre-flight failed - if: failure() - env: - BOARD_URL: ${{ vars.BOARD_URL }} - SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} - run: | - curl -fsS -X POST "$BOARD_URL/api/event" \ - -H "authorization: Bearer $SHIPIT_TOKEN" \ - -H 'content-type: application/json' \ - -d "{\"callsign\":\"${{ github.actor }}\",\"stage\":\"test\",\"status\":\"failed\",\"color\":\"${{ steps.cfg.outputs.color }}\",\"shipModel\":\"${{ steps.cfg.outputs.model }}\"}" - - deploy: - needs: test - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - uses: actions/checkout@v4 - - id: cfg - run: | - echo "color=$(jq -r .color ship.config.json)" >> "$GITHUB_OUTPUT" - echo "model=$(jq -r .shipModel ship.config.json)" >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - run: npm run build - env: - VITE_CALLSIGN: ${{ github.actor }} - - uses: actions/upload-pages-artifact@v3 - with: - path: dist - - id: deploy - uses: actions/deploy-pages@v4 - - name: Report to Mission Control — liftoff - env: - BOARD_URL: ${{ vars.BOARD_URL }} - SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} - run: | - curl -fsS -X POST "$BOARD_URL/api/event" \ - -H "authorization: Bearer $SHIPIT_TOKEN" \ - -H 'content-type: application/json' \ - -d "{\"callsign\":\"${{ github.actor }}\",\"stage\":\"liftoff\",\"status\":\"shipped\",\"color\":\"${{ steps.cfg.outputs.color }}\",\"shipModel\":\"${{ steps.cfg.outputs.model }}\",\"version\":\"${{ github.sha }}\",\"siteUrl\":\"${{ steps.deploy.outputs.page_url }}\"}" diff --git a/starter/workflows/deploy.cicd4.yml b/starter/workflows/deploy.cicd4.yml deleted file mode 100644 index bb950de..0000000 --- a/starter/workflows/deploy.cicd4.yml +++ /dev/null @@ -1,123 +0,0 @@ -name: Ship It -on: - push: - branches: [main] - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: true - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - id: cfg - run: | - echo "color=$(jq -r .color ship.config.json)" >> "$GITHUB_OUTPUT" - echo "model=$(jq -r .shipModel ship.config.json)" >> "$GITHUB_OUTPUT" - - name: Report to Mission Control — on the pad - env: - BOARD_URL: ${{ vars.BOARD_URL }} - SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} - run: | - curl -fsS -X POST "$BOARD_URL/api/event" \ - -H "authorization: Bearer $SHIPIT_TOKEN" \ - -H 'content-type: application/json' \ - -d "{\"callsign\":\"${{ github.actor }}\",\"stage\":\"pad\",\"status\":\"running\",\"color\":\"${{ steps.cfg.outputs.color }}\",\"shipModel\":\"${{ steps.cfg.outputs.model }}\"}" - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - run: npm test - - name: Report ABORT if the pre-flight failed - if: failure() - env: - BOARD_URL: ${{ vars.BOARD_URL }} - SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} - run: | - curl -fsS -X POST "$BOARD_URL/api/event" \ - -H "authorization: Bearer $SHIPIT_TOKEN" \ - -H 'content-type: application/json' \ - -d "{\"callsign\":\"${{ github.actor }}\",\"stage\":\"test\",\"status\":\"failed\",\"color\":\"${{ steps.cfg.outputs.color }}\",\"shipModel\":\"${{ steps.cfg.outputs.model }}\"}" - - deploy: - needs: test - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - uses: actions/checkout@v4 - - id: cfg - run: | - echo "color=$(jq -r .color ship.config.json)" >> "$GITHUB_OUTPUT" - echo "model=$(jq -r .shipModel ship.config.json)" >> "$GITHUB_OUTPUT" - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - - run: npm ci - - run: npm run build - env: - VITE_CALLSIGN: ${{ github.actor }} - - uses: actions/upload-pages-artifact@v3 - with: - path: dist - - id: deploy - uses: actions/deploy-pages@v4 - - name: Report to Mission Control — liftoff - env: - BOARD_URL: ${{ vars.BOARD_URL }} - SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} - run: | - curl -fsS -X POST "$BOARD_URL/api/event" \ - -H "authorization: Bearer $SHIPIT_TOKEN" \ - -H 'content-type: application/json' \ - -d "{\"callsign\":\"${{ github.actor }}\",\"stage\":\"liftoff\",\"status\":\"shipped\",\"color\":\"${{ steps.cfg.outputs.color }}\",\"shipModel\":\"${{ steps.cfg.outputs.model }}\",\"version\":\"${{ github.sha }}\",\"siteUrl\":\"${{ steps.deploy.outputs.page_url }}\"}" - - # S4: build the board image and run it on YOUR OWN EC2 (your personal Mission Control) - ship: - needs: deploy - runs-on: ubuntu-latest - permissions: - contents: read - packages: write # push the image to your GHCR - steps: - - uses: actions/checkout@v4 - - id: ghcr - run: echo "owner=${GITHUB_ACTOR,,}" >> "$GITHUB_OUTPUT" # GHCR needs a lowercase owner - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push the board image - uses: docker/build-push-action@v6 - with: - context: board - push: true - tags: ghcr.io/${{ steps.ghcr.outputs.owner }}/shipit-board:${{ github.sha }} - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: ap-southeast-1 - - name: Deploy the board to my EC2 via SSM - env: - IMAGE: ghcr.io/${{ steps.ghcr.outputs.owner }}/shipit-board:${{ github.sha }} - SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} - run: | - aws ssm send-command \ - --instance-ids "${{ vars.EC2_INSTANCE_ID }}" \ - --document-name "AWS-RunShellScript" \ - --comment "Ship It S4 — deploy $IMAGE" \ - --parameters commands="[\"docker pull $IMAGE\",\"docker rm -f shipit-board || true\",\"docker run -d --name shipit-board --restart unless-stopped -p 3000:3000 -e SHIPIT_TOKEN=$SHIPIT_TOKEN $IMAGE\"]"