From 16bf7280505808447f1286fb381084f53bd5e303 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Fri, 17 Jul 2026 17:27:56 +0800 Subject: [PATCH 01/42] =?UTF-8?q?chore:=20retire=20starter/workflows=20+?= =?UTF-8?q?=20release=20machinery=20=E2=80=94=20slides=20are=20source=20of?= =?UTF-8?q?=20truth,=20learners=20fork=20monorepo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 35 +++++--- scripts/release-launchpad.sh | 78 ------------------ scripts/release-launchpad.test.sh | 48 ----------- scripts/verify-fork-sync.sh | 52 ------------ scripts/verify-workflows.sh | 61 -------------- starter/README.learner.md | 53 ------------- starter/workflows/deploy.cicd1.yml | 41 ---------- starter/workflows/deploy.cicd2.yml | 54 ------------- starter/workflows/deploy.cicd3.yml | 83 ------------------- starter/workflows/deploy.cicd4.yml | 123 ----------------------------- 10 files changed, 23 insertions(+), 605 deletions(-) delete mode 100755 scripts/release-launchpad.sh delete mode 100755 scripts/release-launchpad.test.sh delete mode 100755 scripts/verify-fork-sync.sh delete mode 100755 scripts/verify-workflows.sh delete mode 100644 starter/README.learner.md delete mode 100644 starter/workflows/deploy.cicd1.yml delete mode 100644 starter/workflows/deploy.cicd2.yml delete mode 100644 starter/workflows/deploy.cicd3.yml delete mode 100644 starter/workflows/deploy.cicd4.yml diff --git a/CLAUDE.md b/CLAUDE.md index 824d6f9..03bfc23 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) @@ -68,7 +68,9 @@ The one integration point. Keep it stable; slides and the reference workflows de 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. +- **Transport:** a workflow step POSTs one event per stage it reports. The taught form (CI/CD 3 + slides) is a **single liftoff report** after the Pages deploy; extra beats (pad, abort-on-failure) + are optional operator flourishes, never required of learners. ``` POST $BOARD_URL/api/event @@ -86,6 +88,9 @@ Content-Type: application/json } ``` +- **Minimal payload:** only `callsign` + a known `stage`/`status` are required — the board defaults + `color`/`shipModel` when absent or invalid (`board/src/room.js`). Slides have learners hardcode + their own colour in the JSON body (no config-extraction plumbing in the workflow). - `$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. @@ -112,8 +117,11 @@ Frozen — slides quote these verbatim. `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.yml` 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 via `jq`, pad/abort beats, `env:` indirection) never earned its place. A + session's reference state is *derived* by running its amali on a test fork (see Distribution). - **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 +130,16 @@ 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`. +- **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 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\"]" From 60232c41c8997a61e8f28693c8355d1049e256bf Mon Sep 17 00:00:00 2001 From: opariffazman Date: Fri, 17 Jul 2026 17:41:50 +0800 Subject: [PATCH 02/42] =?UTF-8?q?docs:=20reconcile=20contracts=20to=20taug?= =?UTF-8?q?ht=20reality=20=E2=80=94=20deploy.yaml=20snapshot=20end-of-S3,?= =?UTF-8?q?=20minimal=20report=20payload,=20no=20VITE=5FCALLSIGN,=20README?= =?UTF-8?q?=20un-scaffolded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 99 ++++++++++++++++++++++++++++++++++++++++++++----------- README.md | 28 +++++++--------- 2 files changed, 90 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 03bfc23..fa0c6fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,33 +64,33 @@ 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. +- **Config:** `ship.config.json` drives the learner's *site*; the *board* only sees what the report + step sends. The taught step hardcodes the learner's colour in the JSON body and omits `shipModel` + — so taught ships all render the default model, hue-set by `color` (hex or a named-palette colour; + board normalizes, greys/blacks stay neutral). `shipName` is cosmetic, never identity. - **Transport:** a workflow step POSTs one event per stage it reports. The taught form (CI/CD 3 slides) is a **single liftoff report** after the Pages deploy; extra beats (pad, abort-on-failure) are optional operator flourishes, never required of learners. +The taught report (CI/CD 3 slide 14, quoted verbatim — the whole wire contract learners meet): + ``` 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" } ``` -- **Minimal payload:** only `callsign` + a known `stage`/`status` are required — the board defaults - `color`/`shipModel` when absent or invalid (`board/src/room.js`). Slides have learners hardcode - their own colour in the JSON body (no config-extraction plumbing in the workflow). +- **Board accepts more than the slides teach** (operator flourishes only, never asked of learners): + `stage` ∈ `pad | build | test | clearance | liftoff`, `status` ∈ `running | passed | failed | + aborted | shipped`, plus optional `shipModel` (`fighter · interceptor · hauler · scout`), + `version`, `siteUrl`. Required: just `callsign` + a known `stage`/`status` (see + `board/src/room.js`). +- **The taught `curl` deliberately has NO `-f` flag:** on a 401 the step stays green and the run log + prints `{"error":"unauthorized"}` — CI/CD 3 Amali 3 (wrong-token proof) depends on exactly this. + Do not "harden" it to `curl -fsS` in slides or reference branches. + - `$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. @@ -110,18 +110,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). -- **The slides are the source of truth for the workflow** — learners build `deploy.yml` from the +- **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 via `jq`, pad/abort beats, `env:` indirection) never earned its place. 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:`; each learner's `color` is their own value +(`#22d3ee` = the slide example): + +```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: > + curl -X POST ${{ vars.BOARD_URL }}/api/event + -H "Authorization: Bearer ${{ secrets.SHIPIT_TOKEN }}" + -H "Content-Type: application/json" + -d '{"callsign":"${{ github.actor }}","stage":"liftoff","status":"shipped","color":"#22d3ee"}' + +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 @@ -151,7 +210,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 | From fb7d1838c6d007544bdaa4770238a0292fa1426a Mon Sep 17 00:00:00 2001 From: opariffazman Date: Fri, 17 Jul 2026 18:15:39 +0800 Subject: [PATCH 03/42] chore: fixed dockerfile --- board/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) 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 --- From 61d4464584a1d79b1981daca376f60ba9c939494 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Fri, 17 Jul 2026 20:02:29 +0800 Subject: [PATCH 04/42] =?UTF-8?q?feat(launchpad):=20scripts/report.sh=20?= =?UTF-8?q?=E2=80=94=20prop-owned=20board=20report,=20learner=20workflow?= =?UTF-8?q?=20calls=20it=20with=20env=20BOARD=5FURL+SHIPIT=5FTOKEN;=20e2e-?= =?UTF-8?q?tested=20202/401/missing-env?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 68 +++++++++++++++++++++++-------------- launchpad/scripts/report.sh | 37 ++++++++++++++++++++ 2 files changed, 80 insertions(+), 25 deletions(-) create mode 100755 launchpad/scripts/report.sh diff --git a/CLAUDE.md b/CLAUDE.md index fa0c6fc..bf383a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,32 +64,47 @@ 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:** `ship.config.json` drives the learner's *site*; the *board* only sees what the report - step sends. The taught step hardcodes the learner's colour in the JSON body and omits `shipModel` - — so taught ships all render the default model, hue-set by `color` (hex or a named-palette colour; - board normalizes, greys/blacks stay neutral). `shipName` is cosmetic, never identity. -- **Transport:** a workflow step POSTs one event per stage it reports. The taught form (CI/CD 3 - slides) is a **single liftoff report** after the Pages deploy; extra beats (pad, abort-on-failure) - are optional operator flourishes, never required of learners. +- **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: -The taught report (CI/CD 3 slide 14, quoted verbatim — the whole wire contract learners meet): +```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", "stage": "liftoff", "status": "shipped", "color": "#22d3ee" } +{ "callsign": "octocat", "stage": "liftoff", "status": "shipped", + "color": "#22d3ee", "shipModel": "fighter" } ``` -- **Board accepts more than the slides teach** (operator flourishes only, never asked of learners): +`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`). + +- **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 `shipModel` (`fighter · interceptor · hauler · scout`), - `version`, `siteUrl`. Required: just `callsign` + a known `stage`/`status` (see - `board/src/room.js`). -- **The taught `curl` deliberately has NO `-f` flag:** on a 401 the step stays green and the run log - prints `{"error":"unauthorized"}` — CI/CD 3 Amali 3 (wrong-token proof) depends on exactly this. - Do not "harden" it to `curl -fsS` in slides or reference branches. + 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`). +- **The script's `curl` deliberately has NO `-f` flag** (pinned in `report.sh`'s header too): on a + 401 the step stays green and the run log prints `{"error":"unauthorized"}` — CI/CD 3 Amali 3 + (wrong-token proof) depends on exactly this. Do not "harden" it to `-fsS`. - `$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 @@ -123,14 +138,15 @@ Frozen — slides quote these verbatim. - **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 via `jq`, pad/abort beats, `env:` indirection) never earned its place. A - session's reference state is *derived* by running its amali on a test fork (see Distribution). + (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:`; each learner's `color` is their own value -(`#22d3ee` = the slide example): +`workflow_dispatch` was dropped when S3 rewrote `on:`: ```yaml name: deploy @@ -167,11 +183,10 @@ jobs: path: launchpad/dist - uses: actions/deploy-pages@v5 - name: Lapor ke papan - run: > - curl -X POST ${{ vars.BOARD_URL }}/api/event - -H "Authorization: Bearer ${{ secrets.SHIPIT_TOKEN }}" - -H "Content-Type: application/json" - -d '{"callsign":"${{ github.actor }}","stage":"liftoff","status":"shipped","color":"#22d3ee"}' + run: bash scripts/report.sh + env: + BOARD_URL: ${{ vars.BOARD_URL }} + SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} permissions: pages: write @@ -197,6 +212,9 @@ conventional `github-pages` environment. S4 extends this file; it must not restr - **`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. diff --git a/launchpad/scripts/report.sh b/launchpad/scripts/report.sh new file mode 100755 index 0000000..7b3e497 --- /dev/null +++ b/launchpad/scripts/report.sh @@ -0,0 +1,37 @@ +#!/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. +# +# curl has NO -f on purpose: a rejected report (401) must keep the step green +# and print {"error":"unauthorized"} in the run log — CI/CD 3 Amali 3 depends +# on exactly that. Do not "harden" this to -fsS. +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")); + process.stdout.write(JSON.stringify({ + callsign: process.env.GITHUB_ACTOR, + stage: process.argv[2], + status: process.argv[3], + color: cfg.color, + shipModel: cfg.shipModel, + })); +' "$DIR/../ship.config.json" "${1:-liftoff}" "${2:-shipped}")" + +curl -sS -X POST "$BOARD_URL/api/event" \ + -H "Authorization: Bearer $SHIPIT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$BODY" +echo From c2ca98013fb1341700f8dd87eb568cc4f8190104 Mon Sep 17 00:00:00 2001 From: Ariff Azman Date: Sat, 18 Jul 2026 18:27:23 +0800 Subject: [PATCH 05/42] =?UTF-8?q?feat:=20typing=20race=20mode=20=E2=80=94?= =?UTF-8?q?=20teach=20why=20a=20backend=20server=20exists=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(spec): typing race mode — board race mode + launchpad READY button Board gains a multiplayer typing-race mode (Three.js ortho 2D view, server-authoritative race state, /play cockpit, operator control). Launchpad stays static, gains a READY button + hostname-derived callsign. Reuses the S3 report/roster contract; race entry gated on the roster. Teaches "why a backend server" via the static-vs-server contrast; staged as cicd3 experience + cicd4 dissect/rebuild. Slides reconciliation tracked in Infratify/slides-devops-bootcamp#161. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(plan): typing race mode implementation plan (7 tasks, TDD) Co-Authored-By: Claude Opus 4.8 (1M context) * feat(board): authoritative race state machine * fix(board): keep race total an immutable cap across rounds * feat(board): session-gated command corpus * feat(board): cockpit ws + roster-gated join + operator race control * feat(launchpad): derive callsign from Pages hostname * feat(launchpad): READY button linking to the board cockpit Co-Authored-By: Claude Opus 4.8 (1M context) * feat(board): typing cockpit page (/play) * fix(board): lowercase callsign at ingest + robust cockpit resync * feat(board): ortho race view + operator view switch + server HUD * fix(board): share ship disposal cascade; race view frees GPU resources * fix(board): race-view fallback, session validation, corpus + comment; test readyHref Co-Authored-By: Claude Opus 4.8 (1M context) * docs(spec): wire VITE_BOARD_URL at build; note lowercase callsign display From whole-branch review: the READY button needs VITE_BOARD_URL on the npm run build step (build-time var, not the S3 report runtime BOARD_URL), else the race is unreachable — tracked for the slides in #161. Board now canonicalizes callsigns lowercase at ingest; document the display change. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- board/client/index.html | 1 + board/client/main.js | 42 +- board/client/play.css | 10 + board/client/play.html | 18 + board/client/play.js | 68 + board/client/race-fallback.js | 50 + board/client/race-view.js | 115 ++ board/client/scene.js | 38 +- board/client/ship-mesh.js | 36 + board/client/style.css | 15 + board/client/track.js | 7 + board/client/track.test.js | 22 + board/client/typing.js | 8 + board/client/typing.test.js | 14 + board/src/app.js | 69 +- board/src/corpus.js | 46 + board/src/index.js | 4 +- board/src/messages.js | 10 + board/src/race.js | 55 + board/src/room.js | 4 +- board/test/corpus.test.js | 34 + board/test/race.test.js | 72 + board/test/room.test.js | 4 + board/test/server.test.js | 54 + board/vite.config.js | 12 +- docs/plans/2026-07-18-typing-race-mode.md | 1296 +++++++++++++++++ .../2026-07-18-typing-race-mode-design.md | 275 ++++ launchpad/src/callsign.js | 12 + launchpad/src/callsign.test.mjs | 16 + launchpad/src/main.js | 6 +- launchpad/src/ready.js | 19 + launchpad/src/ready.test.mjs | 37 + launchpad/src/style.css | 19 + 33 files changed, 2422 insertions(+), 66 deletions(-) create mode 100644 board/client/play.css create mode 100644 board/client/play.html create mode 100644 board/client/play.js create mode 100644 board/client/race-fallback.js create mode 100644 board/client/race-view.js create mode 100644 board/client/track.js create mode 100644 board/client/track.test.js create mode 100644 board/client/typing.js create mode 100644 board/client/typing.test.js create mode 100644 board/src/corpus.js create mode 100644 board/src/race.js create mode 100644 board/test/corpus.test.js create mode 100644 board/test/race.test.js create mode 100644 docs/plans/2026-07-18-typing-race-mode.md create mode 100644 docs/specs/2026-07-18-typing-race-mode-design.md create mode 100644 launchpad/src/callsign.js create mode 100644 launchpad/src/callsign.test.mjs create mode 100644 launchpad/src/ready.js create mode 100644 launchpad/src/ready.test.mjs 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..7b5c2b8 100644 --- a/board/client/main.js +++ b/board/client/main.js @@ -1,19 +1,24 @@ import './style.css'; import { createScene } from './scene.js'; +import { createRaceView } from './race-view.js'; +import { createRaceFallback } from './race-fallback.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 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 lastRaceShips = []; // race positions +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,32 @@ 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 = shouldUseFallback({ gl, reducedMotion: mql.matches }) ? createRaceFallback(app) : createRaceView(app); + v.update(lastRaceShips); + return v; +} + +function setMode(next) { + if (next === mode) return; + view.dispose(); + mode = next; + if (mode === 'race') { view = makeRace(); if (hud) hud.hidden = false; } + else { view = makeOrbit(shouldUseFallback({ gl, reducedMotion: mql.matches })); if (hud) hud.hidden = true; } +} + mql.addEventListener('change', (e) => { view.dispose(); - view = makeView(shouldUseFallback({ gl, reducedMotion: e.matches })); + view = mode === 'race' ? makeRace() : makeOrbit(shouldUseFallback({ gl, reducedMotion: e.matches })); }); window.addEventListener('pagehide', () => view.dispose()); @@ -48,8 +63,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') { + 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); diff --git a/board/client/play.css b/board/client/play.css new file mode 100644 index 0000000..57e1137 --- /dev/null +++ b/board/client/play.css @@ -0,0 +1,10 @@ +: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; } diff --git a/board/client/play.html b/board/client/play.html new file mode 100644 index 0000000..aeab80f --- /dev/null +++ b/board/client/play.html @@ -0,0 +1,18 @@ + + + + + + Cockpit — Ship It + + + +
+

connecting…

+
+

+      
+    
+ + + diff --git a/board/client/play.js b/board/client/play.js new file mode 100644 index 0000000..eee8a09 --- /dev/null +++ b/board/client/play.js @@ -0,0 +1,68 @@ +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 +let synced = false; // becomes 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); + 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 = () => { 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; // a new round just started (server reset us to 0) + // during a running round, keep the local optimistic `completed`; the server is authoritative and silently rejects bad progress + prevPhase = m.phase; + 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(); diff --git a/board/client/race-fallback.js b/board/client/race-fallback.js new file mode 100644 index 0000000..6e2ad21 --- /dev/null +++ b/board/client/race-fallback.js @@ -0,0 +1,50 @@ +// board/client/race-fallback.js +// Static (no requestAnimationFrame, reduced-motion-safe) DOM leaderboard for +// the race view — mirrors fallback.js's role for the orbit scene, so a +// no-WebGL / reduced-motion projector still shows race progress. +const ROUND_SIZE = 12; // pinned round size, same simplification race-view.js uses + +// Same interface as createRaceView: { update(ships), dispose() }. +export function createRaceFallback(container) { + const el = document.createElement('div'); + el.className = 'race-fallback'; + container.append(el); + + return { + update(ships) { + const sorted = [...ships].sort((a, b) => { + const d = (b.completed || 0) - (a.completed || 0); + if (d !== 0) return d; + return a.callsign < b.callsign ? -1 : a.callsign > b.callsign ? 1 : 0; + }); + + el.innerHTML = ''; + for (const s of sorted) { + const completed = s.completed || 0; + const row = document.createElement('div'); + row.className = 'row'; + + const cs = document.createElement('span'); + cs.className = 'cs'; + cs.textContent = `@${s.callsign}`; + row.append(cs); + + const track = document.createElement('span'); + track.className = 'track'; + const fill = document.createElement('span'); + fill.className = 'fill'; + fill.style.width = `${Math.min(100, (completed / ROUND_SIZE) * 100)}%`; + track.append(fill); + row.append(track); + + const n = document.createElement('span'); + n.className = 'n'; + n.textContent = String(completed); + row.append(n); + + el.append(row); + } + }, + dispose() { el.remove(); }, + }; +} diff --git a/board/client/race-view.js b/board/client/race-view.js new file mode 100644 index 0000000..6072952 --- /dev/null +++ b/board/client/race-view.js @@ -0,0 +1,115 @@ +// board/client/race-view.js +import * as THREE from 'three'; +import { createShip, preloadShipTemplates, disposeShip, disposeObject3D } 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); disposeShip(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); disposeShip(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); disposeShip(rec.group); } + ships.clear(); + if (templates) for (const tpl of templates.values()) disposeObject3D(tpl); + finish.geometry.dispose(); finish.material.dispose(); + renderer.dispose(); + renderer.domElement.remove(); + }, + }; +} diff --git a/board/client/scene.js b/board/client/scene.js index 50f0d91..27e176d 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, 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'; @@ -222,39 +222,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/ship-mesh.js b/board/client/ship-mesh.js index 58921bc..9b676e8 100644 --- a/board/client/ship-mesh.js +++ b/board/client/ship-mesh.js @@ -70,6 +70,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/style.css b/board/client/style.css index 7d3bc20..89f86f7 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,11 @@ 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; } + +/* race leaderboard fallback (no-WebGL / reduced-motion) — static, no animation */ +.race-fallback { position: fixed; inset: 120px 16px 16px; overflow: auto; display: grid; gap: 8px; align-content: start; } +.race-fallback .row { display: grid; grid-template-columns: 120px 1fr 32px; align-items: center; gap: 10px; font-size: 16px; padding: 4px 8px; border-left: 2px solid var(--line); } +.race-fallback .cs { font-weight: 700; } +.race-fallback .track { position: relative; height: 10px; border-radius: 5px; background: var(--panel); border: 1px solid var(--line); overflow: hidden; } +.race-fallback .fill { position: absolute; inset: 0 auto 0 0; display: block; height: 100%; background: var(--cyan); } +.race-fallback .n { text-align: right; opacity: .85; } diff --git a/board/client/track.js b/board/client/track.js new file mode 100644 index 0000000..79cf454 --- /dev/null +++ b/board/client/track.js @@ -0,0 +1,7 @@ +// 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 }; +} diff --git a/board/client/track.test.js b/board/client/track.test.js new file mode 100644 index 0000000..d731651 --- /dev/null +++ b/board/client/track.test.js @@ -0,0 +1,22 @@ +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); +}); diff --git a/board/client/typing.js b/board/client/typing.js new file mode 100644 index 0000000..86a75ff --- /dev/null +++ b/board/client/typing.js @@ -0,0 +1,8 @@ +// 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 }; +} diff --git a/board/client/typing.test.js b/board/client/typing.test.js new file mode 100644 index 0000000..d4e24b1 --- /dev/null +++ b/board/client/typing.test.js @@ -0,0 +1,14 @@ +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 +}); diff --git a/board/src/app.js b/board/src/app.js index b4ed1b9..c610e09 100644 --- a/board/src/app.js +++ b/board/src/app.js @@ -5,12 +5,15 @@ 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 { 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 +36,20 @@ 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 } = {}) { 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 + + // 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 { @@ -48,8 +61,32 @@ export function createServer({ port = 3000, token = null, publicDir = DIST } = { 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, () => { + const s = body.session ? String(body.session) : session; + if (!SESSIONS[s]) return json(res, 400, { error: 'unknown session' }); + session = s; + 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 === '/play') rel = '/play.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 +99,33 @@ 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.progress(ws.callsign, m.completed); + 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()); 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, server, wss, + roster, race, server, wss, close() { clearInterval(tick); 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..76e139d --- /dev/null +++ b/board/src/corpus.js @@ -0,0 +1,46 @@ +// 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; +} 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/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..8a76ea8 --- /dev/null +++ b/board/src/race.js @@ -0,0 +1,55 @@ +// 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.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; + } +} 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..8517321 --- /dev/null +++ b/board/test/corpus.test.js @@ -0,0 +1,34 @@ +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 app .')); + 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); +}); diff --git a/board/test/race.test.js b/board/test/race.test.js new file mode 100644 index 0000000..082f273 --- /dev/null +++ b/board/test/race.test.js @@ -0,0 +1,72 @@ +// 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); +}); + +test('total stays the configured cap across rounds of differing length', () => { + const r = new Race({ total: 3 }); + r.start(prompts(2)); // fewer than the cap + assert.equal(r.total, 3); + r.reset(); + r.start(prompts(5)); // more than the cap + assert.equal(r.total, 3); + assert.equal(r.prompts.length, 3); // clamped to the cap +}); 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..9dcae96 100644 --- a/board/test/server.test.js +++ b/board/test/server.test.js @@ -83,3 +83,57 @@ 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.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(); } +}); diff --git a/board/vite.config.js b/board/vite.config.js index ead9eae..ecbf8a1 100644 --- a/board/vite.config.js +++ b/board/vite.config.js @@ -1,9 +1,17 @@ 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. Two pages: the +// projector spectator (index.html) and the laptop cockpit (play.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') } }, + }, }); 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/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/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; } From 5bb9b18fe8cf03fb4f8d875b33d583ff38a4ba6c Mon Sep 17 00:00:00 2001 From: opariffazman Date: Sat, 18 Jul 2026 22:17:55 +0800 Subject: [PATCH 06/42] feat(board): ship goes LIVE (green) only when learner's Pages site is reachable (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit report.sh derives the Pages URL (GITHUB_REPOSITORY_OWNER + repo name) and sends it as siteUrl — inside the script, so the taught two-line env: surface (BOARD_URL + SHIPIT_TOKEN) is unchanged. The board probes each siteUrl (HEAD, on arrival + 30s sweep) and broadcasts a per-ship `live` flag; the orbit spectator shows a green LIVE halo (fallback: a LIVE badge) only when the real site answers 200. Ties the S3 token + report to the actual deploy artifact. - board/src/liveness.js: bounded-concurrency reachability sweep; non-200/timeout = not-live-yet, never an error; periodic re-check handles fresh-deploy 404 lag - CLAUDE.md: formalize siteUrl shape + live semantics in the event contract - board/test/liveness.test.js: 6 cases (200 flips live, throw/404 stay neutral, 404->200 re-sweep, no-siteUrl skipped, HEAD+timeout signal) Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 13 ++++++-- board/client/fallback.js | 1 + board/client/scene.js | 5 ++- board/client/ship-mesh.js | 22 ++++++++++++- board/client/style.css | 1 + board/client/theme.js | 1 + board/src/app.js | 15 ++++++--- board/src/liveness.js | 58 +++++++++++++++++++++++++++++++++++ board/test/liveness.test.js | 61 +++++++++++++++++++++++++++++++++++++ launchpad/scripts/report.sh | 19 ++++++++++++ 10 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 board/src/liveness.js create mode 100644 board/test/liveness.test.js diff --git a/CLAUDE.md b/CLAUDE.md index bf383a9..f4e770b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,17 +91,26 @@ Authorization: Bearer $SHIPIT_TOKEN Content-Type: application/json { "callsign": "octocat", "stage": "liftoff", "status": "shipped", - "color": "#22d3ee", "shipModel": "fighter" } + "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`). +`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` deliberately has NO `-f` flag** (pinned in `report.sh`'s header too): on a 401 the step stays green and the run log prints `{"error":"unauthorized"}` — CI/CD 3 Amali 3 (wrong-token proof) depends on exactly this. Do not "harden" it to `-fsS`. 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/scene.js b/board/client/scene.js index 27e176d..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, disposeShip, disposeObject3D } 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); diff --git a/board/client/ship-mesh.js b/board/client/ship-mesh.js index 9b676e8..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; } diff --git a/board/client/style.css b/board/client/style.css index 89f86f7..53c1ce8 100644 --- a/board/client/style.css +++ b/board/client/style.css @@ -52,6 +52,7 @@ 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; } /* race leaderboard fallback (no-WebGL / reduced-motion) — static, no animation */ .race-fallback { position: fixed; inset: 120px 16px 16px; overflow: auto; display: grid; gap: 8px; align-content: start; } 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/src/app.js b/board/src/app.js index c610e09..42f2c88 100644 --- a/board/src/app.js +++ b/board/src/app.js @@ -7,6 +7,7 @@ import { WebSocketServer } from 'ws'; import { Roster, sanitizeEvent } from './room.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'); @@ -36,7 +37,7 @@ function readBody(req, limit = 64 * 1024) { }); } -export function createServer({ port = 3000, token = null, operatorKey = 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(); @@ -44,6 +45,10 @@ export function createServer({ port = 3000, token = null, operatorKey = null, pu 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) { @@ -59,6 +64,7 @@ export function createServer({ port = 3000, token = null, operatorKey = null, pu 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') { @@ -118,14 +124,15 @@ export function createServer({ port = 3000, token = null, operatorKey = null, pu }); const tick = setInterval(() => { - if (dirty) { 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, race, 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/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/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/launchpad/scripts/report.sh b/launchpad/scripts/report.sh index 7b3e497..4ba2120 100755 --- a/launchpad/scripts/report.sh +++ b/launchpad/scripts/report.sh @@ -8,6 +8,12 @@ # 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 has NO -f on purpose: a rejected report (401) must keep the step green # and print {"error":"unauthorized"} in the run log — CI/CD 3 Amali 3 depends # on exactly that. Do not "harden" this to -fsS. @@ -21,12 +27,25 @@ 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}")" From 268ed335ba6c96e16312a83ece1198340c30d651 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Sat, 18 Jul 2026 22:18:44 +0800 Subject: [PATCH 07/42] fix(launchpad): restore valid ship.config.json color (#22d3ee) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts f63f012 "broke color on purpose" — the S2 red-gate teaching artifact is no longer needed now the cohort has finished. Bare 22d3ee failed the preflight gate (exit 1) and rendered slate on the board; #22d3ee passes and renders cyan. Co-Authored-By: Claude Opus 4.8 (1M context) --- launchpad/ship.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/launchpad/ship.config.json b/launchpad/ship.config.json index 0221b94..34b4809 100644 --- a/launchpad/ship.config.json +++ b/launchpad/ship.config.json @@ -1,6 +1,6 @@ { "shipName": "Nebula Runner", - "color": "22d3ee", + "color": "#22d3ee", "shipModel": "fighter", "emblem": "comet" } From 27cf8d6c905655123e6c41ea9ed49a0a510b4379 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 17:41:06 +0800 Subject: [PATCH 08/42] feat(launchpad): report.sh 401 fails the step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit curl -sS --fail-with-body: wrong token now exits non-zero (red run) while still printing {"error":"unauthorized"} in the log. Replaces the pinned no -f stay-green behavior — cohort finished, red X + reason is the clearer wrong-token demo. Plain -f would swallow the body. CLAUDE.md pin updated; slides repo needs hand-update before next cohort. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 8 +++++--- launchpad/scripts/report.sh | 9 +++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f4e770b..1fac829 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,9 +111,11 @@ Content-Type: application/json 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` deliberately has NO `-f` flag** (pinned in `report.sh`'s header too): on a - 401 the step stays green and the run log prints `{"error":"unauthorized"}` — CI/CD 3 Amali 3 - (wrong-token proof) depends on exactly this. Do not "harden" it to `-fsS`. +- **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 diff --git a/launchpad/scripts/report.sh b/launchpad/scripts/report.sh index 4ba2120..2aefb36 100755 --- a/launchpad/scripts/report.sh +++ b/launchpad/scripts/report.sh @@ -14,9 +14,10 @@ # 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 has NO -f on purpose: a rejected report (401) must keep the step green -# and print {"error":"unauthorized"} in the run log — CI/CD 3 Amali 3 depends -# on exactly that. Do not "harden" this to -fsS. +# 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}" @@ -49,7 +50,7 @@ BODY="$(node -e ' })); ' "$DIR/../ship.config.json" "${1:-liftoff}" "${2:-shipped}")" -curl -sS -X POST "$BOARD_URL/api/event" \ +curl -sS --fail-with-body -X POST "$BOARD_URL/api/event" \ -H "Authorization: Bearer $SHIPIT_TOKEN" \ -H "Content-Type: application/json" \ -d "$BODY" From b31c50a7059f9d2767ab43a361a23fee5d80a225 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 20:14:52 +0800 Subject: [PATCH 09/42] =?UTF-8?q?docs:=20unified=202D=20race=20UI=20design?= =?UTF-8?q?=20=E2=80=94=20shared=20track,=20live=20frac=20progress,=20/ope?= =?UTF-8?q?rator=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-07-20-unified-race-ui-design.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/specs/2026-07-20-unified-race-ui-design.md 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..123404e --- /dev/null +++ b/docs/specs/2026-07-20-unified-race-ui-design.md @@ -0,0 +1,164 @@ +# 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; when rows get + shorter than ~14px the callsign label hides (full name stays as `title`). The `me` row gets + `flex-grow: 2` plus accent highlight so it stays findable at any density. +- **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. `finished`: + podium banner overlays top 3 by `finishedAt`. Finished ships show `✦` + final rank on their row. +- **Theme-aware** via the existing CSS custom-property scheme in `style.css`/`play.css`. + +## `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**; prompt-completion messages bypass the + throttle and send immediately (as today). +- 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 (status line, prompt, input) + pinned at bottom. The single-bar `#track`/`#me` mockup DOM is removed. +- `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. From bef7f4ae3bdd23b8f1a30c9eff07086ee6894053 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 20:20:54 +0800 Subject: [PATCH 10/42] =?UTF-8?q?docs:=20unified=20race=20UI=20implementat?= =?UTF-8?q?ion=20plan=20=E2=80=94=209=20TDD=20tasks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/plans/2026-07-20-unified-race-ui.md | 1072 ++++++++++++++++++++++ 1 file changed, 1072 insertions(+) create mode 100644 docs/plans/2026-07-20-unified-race-ui.md 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. From 7d030934c59b44d96d5cb7862c165256fa22e6d2 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 20:23:28 +0800 Subject: [PATCH 11/42] feat(board): race racers carry display-only typing frac via report() Co-Authored-By: Claude Fable 5 --- board/src/race.js | 23 ++++++++++++++---- board/test/race.test.js | 52 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/board/src/race.js b/board/src/race.js index 8a76ea8..70bf5de 100644 --- a/board/src/race.js +++ b/board/src/race.js @@ -1,6 +1,9 @@ // 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; @@ -11,7 +14,7 @@ export class Race { } join(callsign) { - if (!this.racers.has(callsign)) this.racers.set(callsign, { completed: 0, finishedAt: null }); + if (!this.racers.has(callsign)) this.racers.set(callsign, { completed: 0, finishedAt: null, frac: 0 }); return this.racers.get(callsign); } @@ -19,7 +22,7 @@ export class Race { 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; } + for (const r of this.racers.values()) { r.completed = 0; r.finishedAt = null; r.frac = 0; } return this; } @@ -29,20 +32,32 @@ export class Race { 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; } + 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, + callsign, completed: r.completed, finishedAt: r.finishedAt, frac: r.frac, })); return { phase: this.phase, total: this.total, prompts: this.prompts, ships }; } diff --git a/board/test/race.test.js b/board/test/race.test.js index 082f273..c62aa42 100644 --- a/board/test/race.test.js +++ b/board/test/race.test.js @@ -8,7 +8,7 @@ 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 }); + assert.deepEqual(a, { completed: 0, finishedAt: null, frac: 0 }); r.join('octocat'); assert.equal(r.snapshot().ships.length, 1); }); @@ -70,3 +70,53 @@ test('total stays the configured cap across rounds of differing length', () => { assert.equal(r.total, 3); assert.equal(r.prompts.length, 3); // clamped to the cap }); + +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); +}); From 9e4b2020e73112b78ab4c02a63d1e98d29770fa2 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 20:31:56 +0800 Subject: [PATCH 12/42] feat(board): ws progress carries typing frac into race broadcasts Co-Authored-By: Claude Fable 5 --- board/src/app.js | 2 +- board/test/server.test.js | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/board/src/app.js b/board/src/app.js index 42f2c88..e3cc90b 100644 --- a/board/src/app.js +++ b/board/src/app.js @@ -114,7 +114,7 @@ export function createServer({ port = 3000, token = null, operatorKey = null, pu race.join(m.callsign); raceDirty = true; } else if (m.t === 'progress' && ws.callsign && Number.isInteger(m.completed)) { - race.progress(ws.callsign, m.completed); + race.report(ws.callsign, m.completed, m.frac); raceDirty = true; } }); diff --git a/board/test/server.test.js b/board/test/server.test.js index 9dcae96..c409284 100644 --- a/board/test/server.test.js +++ b/board/test/server.test.js @@ -137,3 +137,22 @@ test('operator endpoints require the operator key when set', async () => { 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(); } +}); From 50bb721f458155a6c9d01ffbaa94118cd73b287a Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 20:36:24 +0800 Subject: [PATCH 13/42] =?UTF-8?q?feat(board):=20pure=20race=20row=20math?= =?UTF-8?q?=20=E2=80=94=20progressOf,=20laneOrder,=20ranks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- board/client/race-layout.js | 30 ++++++++++++++++++++++++++ board/client/race-layout.test.js | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 board/client/race-layout.js create mode 100644 board/client/race-layout.test.js 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); +}); From d7a190b8719c28d96a49404b7f0ce9d4405a852a Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 20:41:14 +0800 Subject: [PATCH 14/42] feat(board): cached GLB->PNG ship sprites for the 2D race track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-time WebGL renders of each (shipModel, color) pair to small PNG sprites, cached as data-URLs. OrthographicCamera bounds (±1.6) chosen to frame ships with accessories (label, trail, halo) that extend to ~1.2-1.8 world units, at reasonable fill for fallback rendering when live WebGL unavailable. Co-Authored-By: Claude Fable 5 --- board/client/ship-sprite.js | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 board/client/ship-sprite.js diff --git a/board/client/ship-sprite.js b/board/client/ship-sprite.js new file mode 100644 index 0000000..819edf8 --- /dev/null +++ b/board/client/ship-sprite.js @@ -0,0 +1,50 @@ +// 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); +} From c3b8e98ff3c26cca384b3e2cfde2094e98a6eb7e Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 20:47:48 +0800 Subject: [PATCH 15/42] fix(board): cache GLB load promise, hide sprite labels in race track - Cache preloadShipTemplates() promise at module scope so all sprite renders share one load, not re-fetching/re-parsing all 4 GLB models on every render() call - Hide THREE.Sprite label children (the "@callsign" label added by createShip) after creating each ship and before rendering, so cached sprite images carry only the ship model, not the stray "@" glyph Co-Authored-By: Claude Fable 5 --- board/client/ship-sprite.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/board/client/ship-sprite.js b/board/client/ship-sprite.js index 819edf8..e8fa26f 100644 --- a/board/client/ship-sprite.js +++ b/board/client/ship-sprite.js @@ -9,6 +9,7 @@ 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 +let templatesPromise; // cache the promise; all sprite renders share one load function setup() { try { @@ -29,11 +30,13 @@ function setup() { } async function render(shipModel, color) { - const templates = await preloadShipTemplates(); + 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 }); + ship.traverse((o) => { if (o.isSprite) o.visible = false; }); // hide the "@callsign" label — sprites are ship-only 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); From a320d9359f640db5e84ed4f8877c1cf67e1c3ed8 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 20:51:22 +0800 Subject: [PATCH 16/42] =?UTF-8?q?feat(board):=20shared=20DOM=20race-track?= =?UTF-8?q?=20component=20=E2=80=94=20rows,=20sprites,=20live=20glide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- board/client/race-track.css | 51 +++++++++++++++++ board/client/race-track.js | 107 ++++++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 board/client/race-track.css create mode 100644 board/client/race-track.js diff --git a/board/client/race-track.css b/board/client/race-track.css new file mode 100644 index 0000000..4d5dbbf --- /dev/null +++ b/board/client/race-track.css @@ -0,0 +1,51 @@ +/* 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; } diff --git a/board/client/race-track.js b/board/client/race-track.js new file mode 100644 index 0000000..ca4b93a --- /dev/null +++ b/board/client/race-track.js @@ -0,0 +1,107 @@ +// 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(); }, + }; +} From 21805937370dbb7fa7737751714b84239036d60a Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 20:58:41 +0800 Subject: [PATCH 17/42] fix(board): race-track banner apostrophe broke the string literal Co-Authored-By: Claude Fable 5 --- board/client/race-track.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/board/client/race-track.js b/board/client/race-track.js index ca4b93a..803454b 100644 --- a/board/client/race-track.js +++ b/board/client/race-track.js @@ -64,7 +64,7 @@ export function createRaceTrack(container, { me = null } = {}) { } function bannerText(phase, ships) { - if (phase === 'idle') return ships.length ? 'WAITING FOR LAUNCH…' : 'NO RACERS YET — open your ship's READY link'; + 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) From ce9732f16fe616f9cdd21bab6451ac2da84b6a03 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 21:02:10 +0800 Subject: [PATCH 18/42] feat(board): projector renders race via shared race-track; drop 3D race scene Co-Authored-By: Claude Fable 5 --- board/client/main.js | 16 ++--- board/client/race-fallback.js | 50 --------------- board/client/race-view.js | 115 ---------------------------------- board/client/style.css | 8 --- board/client/track.js | 7 --- board/client/track.test.js | 22 ------- 6 files changed, 8 insertions(+), 210 deletions(-) delete mode 100644 board/client/race-fallback.js delete mode 100644 board/client/race-view.js delete mode 100644 board/client/track.js delete mode 100644 board/client/track.test.js diff --git a/board/client/main.js b/board/client/main.js index 7b5c2b8..9796630 100644 --- a/board/client/main.js +++ b/board/client/main.js @@ -1,7 +1,6 @@ import './style.css'; import { createScene } from './scene.js'; -import { createRaceView } from './race-view.js'; -import { createRaceFallback } from './race-fallback.js'; +import { createRaceTrack } from './race-track.js'; import { createFallback, detectWebGL, shouldUseFallback } from './fallback.js'; const app = document.getElementById('app'); @@ -13,7 +12,7 @@ const gl = detectWebGL(); const mql = window.matchMedia('(prefers-reduced-motion: reduce)'); let lastShips = []; // roster (orbit) -let lastRaceShips = []; // race positions +let lastRace = { phase: 'idle', total: 12, ships: [] }; // race state (rows view) let mode = 'orbit'; // 'orbit' | 'race' let view = makeOrbit(shouldUseFallback({ gl, reducedMotion: mql.matches })); @@ -38,8 +37,8 @@ function makeOrbit(useFallback) { } function makeRace() { - const v = shouldUseFallback({ gl, reducedMotion: mql.matches }) ? createRaceFallback(app) : createRaceView(app); - v.update(lastRaceShips); + const v = createRaceTrack(app); // its own fallback: DOM-only, reduced-motion via CSS + v.update(lastRace); return v; } @@ -52,8 +51,9 @@ function setMode(next) { } mql.addEventListener('change', (e) => { + if (mode !== 'orbit') return; // race track handles reduced motion in CSS view.dispose(); - view = mode === 'race' ? makeRace() : makeOrbit(shouldUseFallback({ gl, reducedMotion: e.matches })); + view = makeOrbit(shouldUseFallback({ gl, reducedMotion: e.matches })); }); window.addEventListener('pagehide', () => view.dispose()); @@ -66,9 +66,9 @@ function connect() { 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 || []; + lastRace = { phase: m.phase, total: m.total, ships: m.ships || [] }; setMode(m.view === 'race' ? 'race' : 'orbit'); - if (mode === 'race') view.update(lastRaceShips); + if (mode === 'race') view.update(lastRace); if (hudClients) hudClients.textContent = String(m.clients ?? 0); } }; diff --git a/board/client/race-fallback.js b/board/client/race-fallback.js deleted file mode 100644 index 6e2ad21..0000000 --- a/board/client/race-fallback.js +++ /dev/null @@ -1,50 +0,0 @@ -// board/client/race-fallback.js -// Static (no requestAnimationFrame, reduced-motion-safe) DOM leaderboard for -// the race view — mirrors fallback.js's role for the orbit scene, so a -// no-WebGL / reduced-motion projector still shows race progress. -const ROUND_SIZE = 12; // pinned round size, same simplification race-view.js uses - -// Same interface as createRaceView: { update(ships), dispose() }. -export function createRaceFallback(container) { - const el = document.createElement('div'); - el.className = 'race-fallback'; - container.append(el); - - return { - update(ships) { - const sorted = [...ships].sort((a, b) => { - const d = (b.completed || 0) - (a.completed || 0); - if (d !== 0) return d; - return a.callsign < b.callsign ? -1 : a.callsign > b.callsign ? 1 : 0; - }); - - el.innerHTML = ''; - for (const s of sorted) { - const completed = s.completed || 0; - const row = document.createElement('div'); - row.className = 'row'; - - const cs = document.createElement('span'); - cs.className = 'cs'; - cs.textContent = `@${s.callsign}`; - row.append(cs); - - const track = document.createElement('span'); - track.className = 'track'; - const fill = document.createElement('span'); - fill.className = 'fill'; - fill.style.width = `${Math.min(100, (completed / ROUND_SIZE) * 100)}%`; - track.append(fill); - row.append(track); - - const n = document.createElement('span'); - n.className = 'n'; - n.textContent = String(completed); - row.append(n); - - el.append(row); - } - }, - dispose() { el.remove(); }, - }; -} diff --git a/board/client/race-view.js b/board/client/race-view.js deleted file mode 100644 index 6072952..0000000 --- a/board/client/race-view.js +++ /dev/null @@ -1,115 +0,0 @@ -// board/client/race-view.js -import * as THREE from 'three'; -import { createShip, preloadShipTemplates, disposeShip, disposeObject3D } 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); disposeShip(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); disposeShip(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); disposeShip(rec.group); } - ships.clear(); - if (templates) for (const tpl of templates.values()) disposeObject3D(tpl); - finish.geometry.dispose(); finish.material.dispose(); - renderer.dispose(); - renderer.domElement.remove(); - }, - }; -} diff --git a/board/client/style.css b/board/client/style.css index 53c1ce8..a72d622 100644 --- a/board/client/style.css +++ b/board/client/style.css @@ -53,11 +53,3 @@ html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; f .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; } - -/* race leaderboard fallback (no-WebGL / reduced-motion) — static, no animation */ -.race-fallback { position: fixed; inset: 120px 16px 16px; overflow: auto; display: grid; gap: 8px; align-content: start; } -.race-fallback .row { display: grid; grid-template-columns: 120px 1fr 32px; align-items: center; gap: 10px; font-size: 16px; padding: 4px 8px; border-left: 2px solid var(--line); } -.race-fallback .cs { font-weight: 700; } -.race-fallback .track { position: relative; height: 10px; border-radius: 5px; background: var(--panel); border: 1px solid var(--line); overflow: hidden; } -.race-fallback .fill { position: absolute; inset: 0 auto 0 0; display: block; height: 100%; background: var(--cyan); } -.race-fallback .n { text-align: right; opacity: .85; } diff --git a/board/client/track.js b/board/client/track.js deleted file mode 100644 index 79cf454..0000000 --- a/board/client/track.js +++ /dev/null @@ -1,7 +0,0 @@ -// 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 }; -} diff --git a/board/client/track.test.js b/board/client/track.test.js deleted file mode 100644 index d731651..0000000 --- a/board/client/track.test.js +++ /dev/null @@ -1,22 +0,0 @@ -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); -}); From 3a474f812ba6f8bbb4b68362cac3437049bc891e Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 21:07:28 +0800 Subject: [PATCH 19/42] feat(board): cockpit shows the full shared race field + live frac typing Co-Authored-By: Claude Fable 5 --- board/client/play.css | 16 +++++++++++----- board/client/play.html | 10 ++++++---- board/client/play.js | 34 +++++++++++++++++++++++++++------- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/board/client/play.css b/board/client/play.css index 57e1137..2f47ffc 100644 --- a/board/client/play.css +++ b/board/client/play.css @@ -1,10 +1,16 @@ :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 { max-width: 640px; margin: 0 auto; padding: 2rem 1rem; display: flex; flex-direction: column; gap: 1rem; } +#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; } -#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; } +#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; } diff --git a/board/client/play.html b/board/client/play.html index aeab80f..5c85348 100644 --- a/board/client/play.html +++ b/board/client/play.html @@ -8,10 +8,12 @@
-

connecting…

-
-

-      
+      
+
+

connecting…

+

+        
+      
diff --git a/board/client/play.js b/board/client/play.js index eee8a09..0bd2c5d 100644 --- a/board/client/play.js +++ b/board/client/play.js @@ -1,17 +1,18 @@ 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 me = document.getElementById('me'); +const track = createRaceTrack(document.getElementById('field'), { me: callsign }); let prompts = []; let phase = 'idle'; -let completed = 0; // my confirmed position -let synced = false; // becomes true once we've trusted the server's position after (re)connect +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() { @@ -20,7 +21,6 @@ function render() { 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; @@ -31,8 +31,25 @@ function render() { : '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; } @@ -43,9 +60,10 @@ function connect() { 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; // a new round just started (server reset us to 0) - // during a running round, keep the local optimistic `completed`; the server is authoritative and silently rejects bad progress + 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(); } }; @@ -58,11 +76,13 @@ function connect() { 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; } +if (!callsign) { statusEl.textContent = 'No callsign — open this from your ship\'s READY button.'; entry.disabled = true; } else connect(); From 2c263066350f720246a28ac86fc2cc9ddd84a7c4 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 21:15:10 +0800 Subject: [PATCH 20/42] fix(board): cancel pending frac report on prompt completion A pending throttle timer can fire after the user completes a prompt, sending a stale fraction measured against the old prompt with the new completed index, causing phantom progress on the next prompt. Cancel the pending timer and zero its cached fraction whenever a completion is sent. Co-Authored-By: Claude Fable 5 --- board/client/play.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/board/client/play.js b/board/client/play.js index 0bd2c5d..af0aa08 100644 --- a/board/client/play.js +++ b/board/client/play.js @@ -35,7 +35,7 @@ function render() { // this and send immediately in the input handler. function fracSender(ws) { let timer = null, latest = 0; - return (frac) => { + const send = (frac) => { latest = frac; if (timer) return; timer = setTimeout(() => { @@ -45,6 +45,8 @@ function fracSender(ws) { } }, 100); }; + send.cancel = () => { if (timer) { clearTimeout(timer); timer = null; } latest = 0; }; + return send; } function connect() { @@ -72,6 +74,7 @@ function connect() { const { matched, done } = typedState(target, entry.value); promptEl.dataset.matched = String(matched); if (done && phase === 'running') { + sendFrac.cancel(); completed += 1; entry.value = ''; ws.send(JSON.stringify({ t: 'progress', completed })); From 31aadda6dd0d905dc22bfde767a26067788f2835 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 21:19:25 +0800 Subject: [PATCH 21/42] =?UTF-8?q?feat(board):=20/operator=20console=20?= =?UTF-8?q?=E2=80=94=20start/reset/view=20buttons=20replace=20curl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- board/client/operator.css | 12 ++++++++++ board/client/operator.html | 30 +++++++++++++++++++++++++ board/client/operator.js | 46 ++++++++++++++++++++++++++++++++++++++ board/src/app.js | 1 + board/vite.config.js | 2 +- 5 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 board/client/operator.css create mode 100644 board/client/operator.html create mode 100644 board/client/operator.js 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..3702cdc --- /dev/null +++ b/board/client/operator.js @@ -0,0 +1,46 @@ +// 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(); diff --git a/board/src/app.js b/board/src/app.js index e3cc90b..e1bc0b2 100644 --- a/board/src/app.js +++ b/board/src/app.js @@ -93,6 +93,7 @@ export function createServer({ port = 3000, token = null, operatorKey = null, pu // 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'); } diff --git a/board/vite.config.js b/board/vite.config.js index ecbf8a1..d46c45f 100644 --- a/board/vite.config.js +++ b/board/vite.config.js @@ -12,6 +12,6 @@ export default defineConfig({ build: { outDir: '../dist', emptyOutDir: true, - rollupOptions: { input: { main: r('client/index.html'), play: r('client/play.html') } }, + rollupOptions: { input: { main: r('client/index.html'), play: r('client/play.html'), operator: r('client/operator.html') } }, }, }); From 54610fdd03d67504eea632285b2f08445385a606 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 21:25:11 +0800 Subject: [PATCH 22/42] fix(board): operator console survives locked-down localStorage Wrap localStorage access in try-catch to degrade gracefully when storage is locked (private mode, enterprise policy) instead of throwing and killing module initialization before event handlers and WebSocket connect wire up, leaving the console silently inert. Co-Authored-By: Claude Fable 5 --- board/client/operator.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/board/client/operator.js b/board/client/operator.js index 3702cdc..1ee5e59 100644 --- a/board/client/operator.js +++ b/board/client/operator.js @@ -9,8 +9,12 @@ 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); +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} …`; From 1eba0da4b4d33980fe427a0b8366662ce190be56 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 22:03:06 +0800 Subject: [PATCH 23/42] =?UTF-8?q?fix(board):=20final-review=20wave=20?= =?UTF-8?q?=E2=80=94=20start=20guard,=20running=20podium,=20hud=20hide,=20?= =?UTF-8?q?doc=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - operator.js: disable START while a race is running (WS race-message handler); RESET stays always enabled as the escape hatch - race-track.js: show podium medals during 'running' phase too (a ghost racer who never finishes would otherwise block them forever) - race.js:12: comment now lists `frac` on the racer shape - vite.config.js: header comment now says three pages, including operator.html - main.js: hide the orbit HUD legend (#hud) while in race mode — it occludes the top rows at 40-ship density - docs spec: race-track is dark-only with local custom properties (not the light/dark scheme); dense-label-hide threshold documented as 25+ racers, matching the shipped DENSE_AT constant Co-Authored-By: Claude Fable 5 --- board/client/main.js | 6 ++++-- board/client/operator.js | 11 +++++++++-- board/client/race-track.js | 19 +++++++++++++++---- board/src/race.js | 2 +- board/vite.config.js | 5 +++-- .../2026-07-20-unified-race-ui-design.md | 7 ++++--- 6 files changed, 36 insertions(+), 14 deletions(-) diff --git a/board/client/main.js b/board/client/main.js index 9796630..51f9fd5 100644 --- a/board/client/main.js +++ b/board/client/main.js @@ -7,6 +7,7 @@ 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)'); @@ -46,8 +47,9 @@ function setMode(next) { if (next === mode) return; view.dispose(); mode = next; - if (mode === 'race') { view = makeRace(); if (hud) hud.hidden = false; } - else { view = makeOrbit(shouldUseFallback({ gl, reducedMotion: mql.matches })); if (hud) hud.hidden = true; } + // 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) => { diff --git a/board/client/operator.js b/board/client/operator.js index 1ee5e59..3ff53d0 100644 --- a/board/client/operator.js +++ b/board/client/operator.js @@ -33,7 +33,8 @@ async function call(path, body) { } } -document.getElementById('start').onclick = () => call('/api/race/start', { session: sessionEl.value }); +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' }); @@ -42,7 +43,13 @@ 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}`; + 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(); diff --git a/board/client/race-track.js b/board/client/race-track.js index 803454b..4a9e212 100644 --- a/board/client/race-track.js +++ b/board/client/race-track.js @@ -63,13 +63,24 @@ export function createRaceTrack(container, { me = null } = {}) { }); } + 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 = 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 ✦'; + 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 ''; } diff --git a/board/src/race.js b/board/src/race.js index 70bf5de..2a5b8eb 100644 --- a/board/src/race.js +++ b/board/src/race.js @@ -9,7 +9,7 @@ export class Race { 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.racers = new Map(); // callsign -> { completed, finishedAt, frac } this._seq = 0; // monotonic finish-order counter } diff --git a/board/vite.config.js b/board/vite.config.js index d46c45f..3a9d814 100644 --- a/board/vite.config.js +++ b/board/vite.config.js @@ -4,8 +4,9 @@ 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). +// 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: './', diff --git a/docs/specs/2026-07-20-unified-race-ui-design.md b/docs/specs/2026-07-20-unified-race-ui-design.md index 123404e..7fa477b 100644 --- a/docs/specs/2026-07-20-unified-race-ui-design.md +++ b/docs/specs/2026-07-20-unified-race-ui-design.md @@ -60,8 +60,8 @@ Same `{ update, dispose }` shape as every other view so `main.js` swaps it freel 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; when rows get - shorter than ~14px the callsign label hides (full name stays as `title`). The `me` row gets + 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. - **Motion.** Ship position `left: calc(p × (100% − sprite width))` where `p = progressOf(completed, frac, total)`. CSS `transition: left 150ms linear`; with ~10 Hz @@ -71,7 +71,8 @@ Same `{ update, dispose }` shape as every other view so `main.js` swaps it freel fallback, which is why `race-fallback.js` dies. - **Phases.** `idle`: dimmed rows + "WAITING FOR LAUNCH…" banner. `running`: live. `finished`: podium banner overlays top 3 by `finishedAt`. Finished ships show `✦` + final rank on their row. -- **Theme-aware** via the existing CSS custom-property scheme in `style.css`/`play.css`. +- **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 From d462be1cb4e22d1c47507f35e70b6a371df6109c Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 22:04:53 +0800 Subject: [PATCH 24/42] docs: spec notes running-phase podium medals Co-Authored-By: Claude Fable 5 --- docs/specs/2026-07-20-unified-race-ui-design.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/specs/2026-07-20-unified-race-ui-design.md b/docs/specs/2026-07-20-unified-race-ui-design.md index 7fa477b..97c43b5 100644 --- a/docs/specs/2026-07-20-unified-race-ui-design.md +++ b/docs/specs/2026-07-20-unified-race-ui-design.md @@ -69,8 +69,10 @@ Same `{ update, dispose }` shape as every other view so `main.js` swaps it freel 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. `finished`: - podium banner overlays top 3 by `finishedAt`. Finished ships show `✦` + final rank on their row. +- **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. From 77a10b13165937a81575c0d701c9288494b9feac Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 22:41:49 +0800 Subject: [PATCH 25/42] =?UTF-8?q?fix(board):=20race=20sprites=20=E2=80=94?= =?UTF-8?q?=203/4=20view,=20hull-fit=20framing,=20128px=20canvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure side-on rendered the low-poly hulls as a ~13px sliver. Camera now frames the hull's real bounding box (label/trail/ring stripped first — Box3 counts invisible children), ship pitched+yawed to a 3/4 view, and the canvas doubled for hiDPI. Co-Authored-By: Claude Fable 5 --- board/client/ship-sprite.js | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/board/client/ship-sprite.js b/board/client/ship-sprite.js index e8fa26f..ce77a4e 100644 --- a/board/client/ship-sprite.js +++ b/board/client/ship-sprite.js @@ -4,9 +4,9 @@ // 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'; +import { createShip, preloadShipTemplates, disposeShip, disposeObject3D } from './ship-mesh.js'; -const SIZE = 64; +const SIZE = 128; // 2x the largest on-screen box (~26px CSS) 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 @@ -36,9 +36,28 @@ async function render(shipModel, color) { if (!ctx) return null; const template = templates.get(shipModel) || templates.get('fighter'); const ship = createShip({ callsign: '', color, shipModel, template }); - ship.traverse((o) => { if (o.isSprite) o.visible = false; }); // hide the "@callsign" label — sprites are ship-only - ship.rotation.y = Math.PI / 2; // side profile, nose toward +x — matches track direction + // 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); } + } + // 3/4 view, nose toward +x: pure side-on renders these low-poly hulls as a + // thin sliver — yawing off-axis and pitching the top toward the camera keeps + // the track direction readable while showing an actual ship silhouette. + ship.rotation.set(0.55, 1.0, 0); ctx.scene.add(ship); + ship.updateMatrixWorld(true); + // Frame the camera to the hull so it fills the canvas regardless of model size. + const box = new THREE.Box3().setFromObject(ship); + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + const half = (Math.max(size.x, size.y) / 2) * 1.1 || 1.6; + ctx.camera.left = center.x - half; + ctx.camera.right = center.x + half; + ctx.camera.top = center.y + half; + ctx.camera.bottom = center.y - half; + ctx.camera.updateProjectionMatrix(); ctx.renderer.render(ctx.scene, ctx.camera); const url = ctx.renderer.domElement.toDataURL('image/png'); ctx.scene.remove(ship); From 176ec56996899c49206f5626892ef41cd62a0504 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 23:03:05 +0800 Subject: [PATCH 26/42] =?UTF-8?q?fix(board):=20race=20sprites=20face=20the?= =?UTF-8?q?=20track=20=E2=80=94=20per-model=20nose=20axis,=20bigger=20boxe?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Quaternius GLBs disagree on nose axis (scout/hauler +z — engine nozzles and cockpit confirm — fighter/interceptor -z), so the yaw flips per model. Camera world-matrix updated before view-space framing (was identity on the very first render, which produced a blank sprite), the ship stays level under an elevated camera instead of the fake nose-down pitch, and on-screen boxes grow 26/16px to 38/24px. Co-Authored-By: Claude Fable 5 --- board/client/race-track.css | 4 ++-- board/client/ship-sprite.js | 47 +++++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/board/client/race-track.css b/board/client/race-track.css index 4d5dbbf..7f1f923 100644 --- a/board/client/race-track.css +++ b/board/client/race-track.css @@ -2,7 +2,7 @@ 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; + --ship-w: 38px; --accent: #22d3ee; --lane-bg: #1e293b; --fg: #e2e8f0; @@ -46,6 +46,6 @@ /* 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'] { --ship-w: 24px; } .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/ship-sprite.js b/board/client/ship-sprite.js index ce77a4e..8471d8d 100644 --- a/board/client/ship-sprite.js +++ b/board/client/ship-sprite.js @@ -6,7 +6,8 @@ import * as THREE from 'three'; import { createShip, preloadShipTemplates, disposeShip, disposeObject3D } from './ship-mesh.js'; -const SIZE = 128; // 2x the largest on-screen box (~26px CSS) so hiDPI stays crisp +const SIZE = 128; // ~3x the largest on-screen box so hiDPI stays crisp +const NOSE_POS_Z = new Set(['scout', 'hauler']); // models whose nose points +z instead of -z (scout: engine nozzles confirm; hauler: cockpit-forward reading) 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 @@ -16,8 +17,10 @@ function setup() { 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, 0, 10); + 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); @@ -42,21 +45,35 @@ async function render(shipModel, color) { for (const o of [...ship.children]) { if (o.isSprite || o.visible === false) { ship.remove(o); disposeObject3D(o); } } - // 3/4 view, nose toward +x: pure side-on renders these low-poly hulls as a - // thin sliver — yawing off-axis and pitching the top toward the camera keeps - // the track direction readable while showing an actual ship silhouette. - ship.rotation.set(0.55, 1.0, 0); + // 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. The GLBs disagree on + // which axis the nose points down (verified by eye per model), hence the set. + ship.rotation.y = NOSE_POS_Z.has(shipModel) ? Math.PI / 2 - 0.4 : -(Math.PI / 2) - 0.4; ctx.scene.add(ship); ship.updateMatrixWorld(true); - // Frame the camera to the hull so it fills the canvas regardless of model size. + // 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); - const size = box.getSize(new THREE.Vector3()); - const center = box.getCenter(new THREE.Vector3()); - const half = (Math.max(size.x, size.y) / 2) * 1.1 || 1.6; - ctx.camera.left = center.x - half; - ctx.camera.right = center.x + half; - ctx.camera.top = center.y + half; - ctx.camera.bottom = center.y - half; + 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'); @@ -69,4 +86,4 @@ 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 From d32a3d10732458bdd07a76903da1ffed823cacbf Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 23:15:44 +0800 Subject: [PATCH 27/42] fix(board): race sprites face right on all models, +30% size; cockpit auto-focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - All four GLBs nose down +z — drop the per-model NOSE_POS_Z split that had fighter/interceptor mirrored to face left on the track. - Ship box 38px -> 50px (dense 24px -> 31px). - Focus the typing input when the race enables it (autofocus dies while the input is disabled pre-race, leaving keystrokes going nowhere) and refocus it on any click. Co-Authored-By: Claude Fable 5 --- board/client/play.js | 7 +++++++ board/client/race-track.css | 4 ++-- board/client/ship-sprite.js | 9 ++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/board/client/play.js b/board/client/play.js index af0aa08..e4b102d 100644 --- a/board/client/play.js +++ b/board/client/play.js @@ -21,7 +21,11 @@ function render() { if (phase === 'running' && completed < prompts.length) { const { matched } = typedState(target, entry.value); promptEl.dataset.matched = String(matched); + 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; } @@ -87,5 +91,8 @@ function connect() { ws.onerror = () => ws.close(); } +// Click/tap anywhere returns focus to the input — racers never hunt for it. +document.addEventListener('click', () => { 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-track.css b/board/client/race-track.css index 7f1f923..0a98b7f 100644 --- a/board/client/race-track.css +++ b/board/client/race-track.css @@ -2,7 +2,7 @@ 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: 38px; + --ship-w: 50px; --accent: #22d3ee; --lane-bg: #1e293b; --fg: #e2e8f0; @@ -46,6 +46,6 @@ /* 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: 24px; } +.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/ship-sprite.js b/board/client/ship-sprite.js index 8471d8d..7cd48d9 100644 --- a/board/client/ship-sprite.js +++ b/board/client/ship-sprite.js @@ -6,8 +6,7 @@ import * as THREE from 'three'; import { createShip, preloadShipTemplates, disposeShip, disposeObject3D } from './ship-mesh.js'; -const SIZE = 128; // ~3x the largest on-screen box so hiDPI stays crisp -const NOSE_POS_Z = new Set(['scout', 'hauler']); // models whose nose points +z instead of -z (scout: engine nozzles confirm; hauler: cockpit-forward reading) +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 @@ -47,9 +46,9 @@ async function render(shipModel, color) { } // 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. The GLBs disagree on - // which axis the nose points down (verified by eye per model), hence the set. - ship.rotation.y = NOSE_POS_Z.has(shipModel) ? Math.PI / 2 - 0.4 : -(Math.PI / 2) - 0.4; + // 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 From b86067b592b9c7bea394930dc1048a487adc49e0 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 23:24:21 +0800 Subject: [PATCH 28/42] =?UTF-8?q?feat(board):=20idle=20race=20VFX=20?= =?UTF-8?q?=E2=80=94=20starfield=20drift,=20ship=20bob,=20engine=20glow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSS-only (no rAF, honours prefers-reduced-motion): - Parallax starfield drifting left on the track background — seamless loop, each layer shifts a whole multiple of its tile size. - Ships bob/tilt inside their position box (never fights the `left` transition), phase + duration hashed from callsign so 40 ships drift out of sync. - Blurred engine-glow streak off each tail, tinted with the racer's colour, flickering like a thruster. Co-Authored-By: Claude Fable 5 --- board/client/race-track.css | 56 +++++++++++++++++++++++++++++++++++-- board/client/race-track.js | 7 +++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/board/client/race-track.css b/board/client/race-track.css index 0a98b7f..7fd200e 100644 --- a/board/client/race-track.css +++ b/board/client/race-track.css @@ -12,7 +12,19 @@ padding: 0.5rem 1rem; font: 13px/1.2 ui-monospace, Menlo, Consolas, monospace; color: var(--fg); - background: #0b1220; + /* 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); } @@ -39,11 +51,49 @@ 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 { 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); } +} + +@media (prefers-reduced-motion: reduce) { + .race-track { animation: none; } + .race-row .ship { transition: none; } + .race-row .ship .sprite, + .race-row .ship .glyph { animation: none; } + .race-row .ship::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; } diff --git a/board/client/race-track.js b/board/client/race-track.js index 4a9e212..f31823b 100644 --- a/board/client/race-track.js +++ b/board/client/race-track.js @@ -32,6 +32,12 @@ export function createRaceTrack(container, { me = null } = {}) { 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'; @@ -56,6 +62,7 @@ export function createRaceTrack(container, { me = null } = {}) { 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; } From afc5da2574f10e98814d8041971c603a29f5ba6c Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 23:34:47 +0800 Subject: [PATCH 29/42] =?UTF-8?q?feat(board):=20ENTER=20runs=20the=20comma?= =?UTF-8?q?nd=20=E2=80=94=20hold=20at=200.9,=20boost=20on=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fully typed command no longer auto-completes: the ship holds just short of the prompt boundary (frac 0.9) with an "ENTER to run ⏎" cue, and ENTER sends the completion — jumping the ship forward with a one-shot lunge+flare boost (track.boost()). ENTER on a wrong or incomplete line shakes the input and sends nothing. Terminal muscle memory: typing a command and running it are different beats. Co-Authored-By: Claude Fable 5 --- board/client/play.css | 8 ++++++ board/client/play.js | 26 ++++++++++++++----- board/client/race-track.css | 22 ++++++++++++++-- board/client/race-track.js | 12 +++++++++ .../2026-07-20-unified-race-ui-design.md | 7 +++-- 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/board/client/play.css b/board/client/play.css index 2f47ffc..bbfb634 100644 --- a/board/client/play.css +++ b/board/client/play.css @@ -11,6 +11,14 @@ body { margin: 0; background: #0b1220; color: #e2e8f0; font: 16px/1.4 ui-monospa } #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; } +#prompt[data-done='1'] { outline: 2px solid #22d3ee; } #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; } +#entry.shake { animation: entry-shake 0.25s; } +@keyframes entry-shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } +} +@media (prefers-reduced-motion: reduce) { #entry.shake { animation: none; } } diff --git a/board/client/play.js b/board/client/play.js index e4b102d..9980f0d 100644 --- a/board/client/play.js +++ b/board/client/play.js @@ -18,9 +18,11 @@ let prevPhase = 'idle'; function render() { const target = prompts[completed] || ''; promptEl.textContent = target; + let done = false; if (phase === 'running' && completed < prompts.length) { - const { matched } = typedState(target, entry.value); - promptEl.dataset.matched = String(matched); + const st = typedState(target, entry.value); + done = st.done; + promptEl.dataset.matched = String(st.matched); const wasDisabled = entry.disabled; entry.disabled = false; // `autofocus` dies while the input is disabled pre-race — without this, @@ -29,8 +31,9 @@ function render() { } else { entry.disabled = true; } + promptEl.dataset.done = done ? '1' : ''; statusEl.textContent = - phase === 'running' ? `RACING — ${completed}/${prompts.length}` + phase === 'running' ? (done ? 'ENTER to run ⏎' : `RACING — ${completed}/${prompts.length}`) : phase === 'finished' ? 'FINISHED ✦' : 'waiting for race…'; } @@ -76,15 +79,26 @@ function connect() { entry.oninput = () => { const target = prompts[completed] || ''; const { matched, done } = typedState(target, entry.value); - promptEl.dataset.matched = String(matched); + // A fully typed command holds just short of the boundary — ENTER runs it + // (the boost). Typing a command and running it stay distinct beats. + if (phase === 'running' && target.length > 0) sendFrac(done ? 0.9 : matched / target.length); + render(); + }; + entry.onkeydown = (e) => { + if (e.key !== 'Enter') return; + const target = prompts[completed] || ''; + const { done } = typedState(target, entry.value); if (done && phase === 'running') { sendFrac.cancel(); completed += 1; entry.value = ''; ws.send(JSON.stringify({ t: 'progress', completed })); + track.boost(callsign); render(); - } else if (phase === 'running' && target.length > 0) { - sendFrac(matched / target.length); + } else if (phase === 'running') { + entry.classList.remove('shake'); + void entry.offsetWidth; // restart the animation on rapid re-trigger + entry.classList.add('shake'); } }; ws.onclose = () => { statusEl.textContent = 'disconnected — reconnecting…'; setTimeout(connect, 1000); }; diff --git a/board/client/race-track.css b/board/client/race-track.css index 7fd200e..72c49ca 100644 --- a/board/client/race-track.css +++ b/board/client/race-track.css @@ -86,12 +86,30 @@ 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 { animation: none; } - .race-row .ship::before { animation: none; opacity: 0.5; } + .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 */ diff --git a/board/client/race-track.js b/board/client/race-track.js index f31823b..c7375d5 100644 --- a/board/client/race-track.js +++ b/board/client/race-track.js @@ -118,8 +118,20 @@ export function createRaceTrack(container, { me = null } = {}) { } } + // 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/docs/specs/2026-07-20-unified-race-ui-design.md b/docs/specs/2026-07-20-unified-race-ui-design.md index 97c43b5..7e4d80b 100644 --- a/docs/specs/2026-07-20-unified-race-ui-design.md +++ b/docs/specs/2026-07-20-unified-race-ui-design.md @@ -103,8 +103,11 @@ Cockpit → server (WS), extending the existing message — no new type: ``` - `frac` = matched-prefix length ÷ current prompt length, clamped 0–1. Sent on keystroke, - **trailing-throttled to one message per 100 ms**; prompt-completion messages bypass the - throttle and send immediately (as today). + **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)`. From 97fbb2134ed73af9c270133a577a7fb5d54e2296 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 23:39:30 +0800 Subject: [PATCH 30/42] feat(board): cockpit pins own ship row above the typing dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The me row moves to the bottom of the stack (last flex order + margin-top auto) so it sits directly above the prompt/input — the ship moves where the racer's eyes already are. A thin rule splits it from the pack; rank chip still shows the real rank. Projector order (no me) unchanged. Co-Authored-By: Claude Fable 5 --- board/client/race-track.css | 4 ++++ board/client/race-track.js | 6 +++++- docs/specs/2026-07-20-unified-race-ui-design.md | 5 ++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/board/client/race-track.css b/board/client/race-track.css index 72c49ca..ae86123 100644 --- a/board/client/race-track.css +++ b/board/client/race-track.css @@ -35,6 +35,10 @@ .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; } diff --git a/board/client/race-track.js b/board/client/race-track.js index c7375d5..dca4eb0 100644 --- a/board/client/race-track.js +++ b/board/client/race-track.js @@ -15,6 +15,7 @@ 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'); @@ -111,7 +112,10 @@ export function createRaceTrack(container, { me = null } = {}) { 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 + // 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); } diff --git a/docs/specs/2026-07-20-unified-race-ui-design.md b/docs/specs/2026-07-20-unified-race-ui-design.md index 7e4d80b..951c516 100644 --- a/docs/specs/2026-07-20-unified-race-ui-design.md +++ b/docs/specs/2026-07-20-unified-race-ui-design.md @@ -62,7 +62,10 @@ Same `{ update, dispose }` shape as every other view so `main.js` swaps it freel - **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. + `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 From 52666b3123f722b42337c591fb8b8e94d6adc6ab Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 23:45:43 +0800 Subject: [PATCH 31/42] =?UTF-8?q?feat(board):=20synthesized=20cockpit=20SF?= =?UTF-8?q?X=20=E2=80=94=20Web=20Audio,=20zero=20asset=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every sound is oscillators/filtered noise built at call time (sfx.js): keystroke tick (pitch-jittered), wrong-char thud, command-ready chirp, ENTER-boost noise-sweep whoosh, error buzz, race-start beeps, finish fanfare. AudioContext unlocks on first click/keydown (autoplay policy); mute toggle in the dock persists to localStorage (guarded for private mode). Muted skips synthesis entirely. Co-Authored-By: Claude Fable 5 --- board/client/play.css | 3 ++ board/client/play.html | 5 ++- board/client/play.js | 20 +++++++++- board/client/sfx.js | 90 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 board/client/sfx.js diff --git a/board/client/play.css b/board/client/play.css index bbfb634..0a29e9d 100644 --- a/board/client/play.css +++ b/board/client/play.css @@ -9,7 +9,10 @@ body { margin: 0; background: #0b1220; color: #e2e8f0; font: 16px/1.4 ui-monospa 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; } #prompt { font-size: 1.4rem; margin: 0; padding: 0.6rem 0.9rem; background: #111a2e; border-radius: 8px; min-height: 1.8rem; } #prompt[data-done='1'] { outline: 2px solid #22d3ee; } #entry { font: inherit; font-size: 1.1rem; padding: 0.6rem 0.9rem; background: #0f172a; color: #e2e8f0; border: 2px solid #334155; border-radius: 8px; } diff --git a/board/client/play.html b/board/client/play.html index 5c85348..7fb5e81 100644 --- a/board/client/play.html +++ b/board/client/play.html @@ -10,7 +10,10 @@
-

connecting…

+
+

connecting…

+ +

         
       
diff --git a/board/client/play.js b/board/client/play.js index 9980f0d..7d3f53f 100644 --- a/board/client/play.js +++ b/board/client/play.js @@ -1,6 +1,7 @@ import './play.css'; import { typedState } 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(); @@ -14,6 +15,12 @@ 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 lastTyped = { len: 0, matched: 0, done: false }; // for keystroke sfx deltas + +const muteBtn = document.getElementById('mute'); +const showMute = () => { muteBtn.textContent = sfx.muted ? '🔇' : '🔊'; }; +muteBtn.onclick = () => { sfx.muted = !sfx.muted; showMute(); }; +showMute(); function render() { const target = prompts[completed] || ''; @@ -71,6 +78,7 @@ function connect() { 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(); @@ -79,12 +87,18 @@ function connect() { entry.oninput = () => { const target = prompts[completed] || ''; const { matched, done } = typedState(target, entry.value); + if (phase === 'running') { + if (entry.value.length > lastTyped.len) (matched > lastTyped.matched ? sfx.key() : sfx.miss()); + if (done && !lastTyped.done) sfx.ready(); + } + lastTyped = { len: entry.value.length, matched, done }; // A fully typed command holds just short of the boundary — ENTER runs it // (the boost). Typing a command and running it stay distinct beats. if (phase === 'running' && target.length > 0) sendFrac(done ? 0.9 : matched / target.length); render(); }; entry.onkeydown = (e) => { + sfx.unlock(); if (e.key !== 'Enter') return; const target = prompts[completed] || ''; const { done } = typedState(target, entry.value); @@ -92,10 +106,13 @@ function connect() { sendFrac.cancel(); completed += 1; entry.value = ''; + lastTyped = { len: 0, matched: 0, done: false }; 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(); entry.classList.remove('shake'); void entry.offsetWidth; // restart the animation on rapid re-trigger entry.classList.add('shake'); @@ -106,7 +123,8 @@ function connect() { } // Click/tap anywhere returns focus to the input — racers never hunt for it. -document.addEventListener('click', () => { if (!entry.disabled) entry.focus(); }); +// 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/sfx.js b/board/client/sfx.js new file mode 100644 index 0000000..84b8b88 --- /dev/null +++ b/board/client/sfx.js @@ -0,0 +1,90 @@ +// 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 } = {}) { + 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); + g.gain.setValueAtTime(gain, t); + 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 } = {}) { + if (muted) return; + const a = ctx(); + if (!a) return; + const t = a.currentTime; + 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 = 'bandpass'; + 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); +} + +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: short high tick, pitch-jittered so a line of typing + // doesn't sound like a metronome. + key() { blip(1500 + Math.random() * 600, { dur: 0.03, type: 'square', gain: 0.045 }); }, + // Wrong character: low dull thud. + miss() { blip(150, { dur: 0.06, type: 'square', gain: 0.06 }); }, + // Command fully typed (awaiting ENTER): two rising chirps. + ready() { blip(880, { dur: 0.07, gain: 0.12 }); blip(1320, { dur: 0.09, gain: 0.12, at: 0.07 }); }, + // ENTER boost: noise sweep + rising sawtooth — the thruster. + boost() { whoosh({ dur: 0.35, from: 300, to: 3200, gain: 0.25 }); blip(220, { dur: 0.3, type: 'sawtooth', gain: 0.1, slide: 440 }); }, + // ENTER on a wrong line: short low buzz. + error() { blip(110, { dur: 0.18, type: 'sawtooth', gain: 0.12 }); }, + // Race went live: two marks and a GO. + go() { blip(660, { dur: 0.09, gain: 0.15 }); blip(660, { dur: 0.09, gain: 0.15, at: 0.15 }); blip(990, { dur: 0.25, gain: 0.18, at: 0.3 }); }, + // All prompts done: little ascending fanfare. + finish() { [523, 659, 784, 1047].forEach((f, i) => blip(f, { dur: 0.12, gain: 0.15, at: i * 0.09 })); }, +}; From b2dc482993c59879baa6e1c8a5db71efb4af3ba1 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Mon, 20 Jul 2026 23:50:56 +0800 Subject: [PATCH 32/42] feat(board): retheme cockpit SFX from arcade to spaceship Same sound API, new palette: sines/triangles with soft attack envelopes instead of square-wave bleeps. Keystroke = dampened console tap (lowpass noise puff + faint triangle), ready = single long sonar ping, boost = deep thruster (low noise sweep + rising rumble), error = descending double denied thunk, go = two low countdown marks + ignition rumble, finish = warm docking-complete chord swell instead of a fanfare arpeggio. blip() gains an attack ramp; whoosh() gains at/filter-type. Co-Authored-By: Claude Fable 5 --- board/client/sfx.js | 62 ++++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/board/client/sfx.js b/board/client/sfx.js index 84b8b88..cc7c310 100644 --- a/board/client/sfx.js +++ b/board/client/sfx.js @@ -25,7 +25,7 @@ function ctx() { return ac; } -function blip(freq, { dur = 0.08, type = 'sine', gain = 0.15, at = 0, slide = 0 } = {}) { +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; @@ -35,18 +35,20 @@ function blip(freq, { dur = 0.08, type = 'sine', gain = 0.15, at = 0, slide = 0 o.type = type; o.frequency.setValueAtTime(freq, t); if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(1, freq + slide), t + dur); - g.gain.setValueAtTime(gain, t); + // 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 } = {}) { +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; + 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); @@ -54,7 +56,7 @@ function whoosh({ dur = 0.3, from = 400, to = 2400, gain = 0.2 } = {}) { const src = a.createBufferSource(); src.buffer = buf; const f = a.createBiquadFilter(); - f.type = 'bandpass'; + f.type = type; f.Q.value = 1; f.frequency.setValueAtTime(from, t); f.frequency.exponentialRampToValueAtTime(to, t + dur); @@ -68,23 +70,43 @@ function whoosh({ dur = 0.3, from = 400, to = 2400, gain = 0.2 } = {}) { 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: short high tick, pitch-jittered so a line of typing - // doesn't sound like a metronome. - key() { blip(1500 + Math.random() * 600, { dur: 0.03, type: 'square', gain: 0.045 }); }, - // Wrong character: low dull thud. - miss() { blip(150, { dur: 0.06, type: 'square', gain: 0.06 }); }, - // Command fully typed (awaiting ENTER): two rising chirps. - ready() { blip(880, { dur: 0.07, gain: 0.12 }); blip(1320, { dur: 0.09, gain: 0.12, at: 0.07 }); }, - // ENTER boost: noise sweep + rising sawtooth — the thruster. - boost() { whoosh({ dur: 0.35, from: 300, to: 3200, gain: 0.25 }); blip(220, { dur: 0.3, type: 'sawtooth', gain: 0.1, slide: 440 }); }, - // ENTER on a wrong line: short low buzz. - error() { blip(110, { dur: 0.18, type: 'sawtooth', gain: 0.12 }); }, - // Race went live: two marks and a GO. - go() { blip(660, { dur: 0.09, gain: 0.15 }); blip(660, { dur: 0.09, gain: 0.15, at: 0.15 }); blip(990, { dur: 0.25, gain: 0.18, at: 0.3 }); }, - // All prompts done: little ascending fanfare. - finish() { [523, 659, 784, 1047].forEach((f, i) => blip(f, { dur: 0.12, gain: 0.15, at: i * 0.09 })); }, + // 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 }); + }, }; From 3956de91f94cd8f5d351aa8c5cd73e07471e20e4 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Tue, 21 Jul 2026 00:13:18 +0800 Subject: [PATCH 33/42] feat(board): race prompts become sequenced slide-verbatim stories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the random command grab-bag with ordered STORIES per session, inventoried verbatim from the 2026 slide decks (linux/git/github/docker/ aws/ci-cd): cicd3 = "fork to first contact" (16 prompts: gh auth login → fork shipit → gate → build → push → secret → workflow run → Pages URL); cicd4 = "capstone: container to your own server" (20: sync upstream → test → docker build/run/tag → ssh → compose up → PR merge). Each line's precondition is set up by the line before — run top to bottom it is one coherent workflow, so prompts are never shuffled: the order is the lesson. Race total now follows the prompt list length (16/20, no longer capped at 12); empty list keeps the prior total so progress math never divides by zero. pickPrompts(session) returns the story copy in order. Fixed server.test.js so an assertion failure can no longer skip the WS close and hang the suite on server.close(). Co-Authored-By: Claude Fable 5 --- board/src/app.js | 2 +- board/src/corpus.js | 92 ++++++++++++++++++++++----------------- board/src/race.js | 5 ++- board/test/corpus.test.js | 49 +++++++++++---------- board/test/race.test.js | 25 ++++++++--- board/test/server.test.js | 4 +- 6 files changed, 106 insertions(+), 71 deletions(-) diff --git a/board/src/app.js b/board/src/app.js index e1bc0b2..16b302a 100644 --- a/board/src/app.js +++ b/board/src/app.js @@ -73,7 +73,7 @@ export function createServer({ port = 3000, token = null, operatorKey = null, pu const s = body.session ? String(body.session) : session; if (!SESSIONS[s]) return json(res, 400, { error: 'unknown session' }); session = s; - race.start(pickPrompts(session, race.total)); + race.start(pickPrompts(session)); raceDirty = true; return json(res, 202, { ok: true }); }); diff --git a/board/src/corpus.js b/board/src/corpus.js index 76e139d..02eb390 100644 --- a/board/src/corpus.js +++ b/board/src/corpus.js @@ -1,46 +1,58 @@ -// 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', +// 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', ], - 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', + // 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', ], }; -// 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] || []); -} +// Known race sessions (app.js validates /api/race/start against these keys). +export const SESSIONS = STORIES; -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; +export function pickPrompts(session) { + return [...(STORIES[session] || [])]; } diff --git a/board/src/race.js b/board/src/race.js index 2a5b8eb..7d0c60a 100644 --- a/board/src/race.js +++ b/board/src/race.js @@ -19,7 +19,10 @@ export class Race { } start(prompts) { - this.prompts = prompts.slice(0, this.total); + // 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; } diff --git a/board/test/corpus.test.js b/board/test/corpus.test.js index 8517321..562c52f 100644 --- a/board/test/corpus.test.js +++ b/board/test/corpus.test.js @@ -1,34 +1,39 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { CORPUS, SESSIONS, pool, pickPrompts } from '../src/corpus.js'; +import { STORIES, SESSIONS, 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 app .')); - assert.equal(p.length, SESSIONS.cicd3.reduce((n, k) => n + CORPUS[k].length, 0)); +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('pool is empty for an unknown session', () => { - assert.deepEqual(pool('nope'), []); +test('pickPrompts returns the full story in slide order', () => { + assert.deepEqual(pickPrompts('cicd3'), STORIES.cicd3); + assert.deepEqual(pickPrompts('cicd4'), STORIES.cicd4); }); -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 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 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 is empty for an unknown session', () => { + assert.deepEqual(pickPrompts('nope'), []); }); -test('pickPrompts caps at pool size', () => { - const picks = pickPrompts('cicd3', 99999); - assert.equal(picks.length, pool('cicd3').length); +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/race.test.js b/board/test/race.test.js index c62aa42..8d63bdf 100644 --- a/board/test/race.test.js +++ b/board/test/race.test.js @@ -61,14 +61,27 @@ test('reset returns to idle and zeroes racers but keeps them joined', () => { assert.equal(r.snapshot().ships[0].completed, 0); }); -test('total stays the configured cap across rounds of differing length', () => { +test('total follows the prompt list across rounds of differing length', () => { const r = new Race({ total: 3 }); - r.start(prompts(2)); // fewer than the cap - assert.equal(r.total, 3); + r.start(prompts(2)); + assert.equal(r.total, 2); r.reset(); - r.start(prompts(5)); // more than the cap - assert.equal(r.total, 3); - assert.equal(r.prompts.length, 3); // clamped to the cap + 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', () => { diff --git a/board/test/server.test.js b/board/test/server.test.js index c409284..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`, { @@ -116,7 +117,8 @@ test('operator can start a race; cockpit progress advances the snapshot', async 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); + 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 From 27236699bfdc9705752a0b267c3b501a8b65a010 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Tue, 21 Jul 2026 00:21:40 +0800 Subject: [PATCH 34/42] =?UTF-8?q?feat(board):=20terminal-style=20strict=20?= =?UTF-8?q?typing=20=E2=80=94=20greyed=20command=20lights=20up=20in=20plac?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cockpit dock becomes a terminal window (title bar, $ prompt, block caret): the command sits greyed on the line and lights up character by character as it's typed — no visible input field. A hidden 1px input catches keystrokes (and summons mobile keyboards); its value is snapped to the correct prefix after every event, so a wrong key never lands: the caret flashes red with a thud, the cursor stays put, and backspace has nothing to do (and is disabled). typing.js's typedState is replaced by the pure advance(target, at, incoming) cursor walk, node-tested. Co-Authored-By: Claude Fable 5 --- board/client/play.css | 41 +++++++-- board/client/play.html | 10 ++- board/client/play.js | 86 ++++++++++++------- board/client/typing.js | 22 +++-- board/client/typing.test.js | 24 ++++-- .../2026-07-20-unified-race-ui-design.md | 9 +- 6 files changed, 134 insertions(+), 58 deletions(-) diff --git a/board/client/play.css b/board/client/play.css index 0a29e9d..757e2bb 100644 --- a/board/client/play.css +++ b/board/client/play.css @@ -13,15 +13,40 @@ body { margin: 0; background: #0b1220; color: #e2e8f0; font: 16px/1.4 ui-monospa #status { color: #94a3b8; margin: 0; } #mute { background: none; border: none; padding: 0; cursor: pointer; font-size: 1rem; opacity: 0.7; } #mute:hover { opacity: 1; } -#prompt { font-size: 1.4rem; margin: 0; padding: 0.6rem 0.9rem; background: #111a2e; border-radius: 8px; min-height: 1.8rem; } -#prompt[data-done='1'] { outline: 2px solid #22d3ee; } -#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; } -#entry.shake { animation: entry-shake 0.25s; } -@keyframes entry-shake { +/* 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; } +#line { margin: 0; padding: 0.9rem 1rem 1.1rem; font-size: 1.3rem; line-height: 1.5; white-space: pre-wrap; word-break: break-all; } +#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); } } -@media (prefers-reduced-motion: reduce) { #entry.shake { animation: none; } } +/* 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 index 7fb5e81..06d452a 100644 --- a/board/client/play.html +++ b/board/client/play.html @@ -14,8 +14,14 @@

connecting…

-

-        
+        
+
+ + shipit — cockpit +
+
$ 
+ +
diff --git a/board/client/play.js b/board/client/play.js index 7d3f53f..23eba5e 100644 --- a/board/client/play.js +++ b/board/client/play.js @@ -1,35 +1,51 @@ import './play.css'; -import { typedState } from './typing.js'; +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 promptEl = document.getElementById('prompt'); +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 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 lastTyped = { len: 0, matched: 0, done: false }; // for keystroke sfx deltas +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 target = prompts[completed] || ''; - promptEl.textContent = target; - let done = false; - if (phase === 'running' && completed < prompts.length) { - const st = typedState(target, entry.value); - done = st.done; - promptEl.dataset.matched = String(st.matched); + 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, @@ -38,15 +54,14 @@ function render() { } else { entry.disabled = true; } - promptEl.dataset.done = done ? '1' : ''; statusEl.textContent = - phase === 'running' ? (done ? 'ENTER to run ⏎' : `RACING — ${completed}/${prompts.length}`) + 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 input handler. +// this and send immediately in the keydown handler. function fracSender(ws) { let timer = null, latest = 0; const send = (frac) => { @@ -84,38 +99,47 @@ function connect() { 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 target = prompts[completed] || ''; - const { matched, done } = typedState(target, entry.value); - if (phase === 'running') { - if (entry.value.length > lastTyped.len) (matched > lastTyped.matched ? sfx.key() : sfx.miss()); - if (done && !lastTyped.done) sfx.ready(); + 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(); + } } - lastTyped = { len: entry.value.length, matched, done }; - // A fully typed command holds just short of the boundary — ENTER runs it - // (the boost). Typing a command and running it stay distinct beats. - if (phase === 'running' && target.length > 0) sendFrac(done ? 0.9 : matched / target.length); + 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; - const target = prompts[completed] || ''; - const { done } = typedState(target, entry.value); - if (done && phase === 'running') { + if (lineDone() && phase === 'running') { sendFrac.cancel(); completed += 1; + currentTarget = null; + typedCount = 0; entry.value = ''; - lastTyped = { len: 0, matched: 0, done: false }; 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(); - entry.classList.remove('shake'); - void entry.offsetWidth; // restart the animation on rapid re-trigger - entry.classList.add('shake'); + 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); }; diff --git a/board/client/typing.js b/board/client/typing.js index 86a75ff..2dbe2bb 100644 --- a/board/client/typing.js +++ b/board/client/typing.js @@ -1,8 +1,16 @@ -// 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 }; +// 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 index d4e24b1..1ab279d 100644 --- a/board/client/typing.test.js +++ b/board/client/typing.test.js @@ -1,14 +1,22 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { typedState } from './typing.js'; +import { advance } 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('advance consumes matching characters', () => { + assert.equal(advance('git push', 0, 'git'), 3); + assert.equal(advance('git push', 3, ' push'), 8); }); -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 +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/docs/specs/2026-07-20-unified-race-ui-design.md b/docs/specs/2026-07-20-unified-race-ui-design.md index 951c516..7c13dc1 100644 --- a/docs/specs/2026-07-20-unified-race-ui-design.md +++ b/docs/specs/2026-07-20-unified-race-ui-design.md @@ -123,8 +123,13 @@ Cockpit → server (WS), extending the existing message — no new type: ## Cockpit (`play.html` / `play.js`) -- Layout: `race-track` container fills the screen; typing dock (status line, prompt, input) - pinned at bottom. The single-bar `#track`/`#me` mockup DOM is removed. +- 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 From a34382cccc3f8758911839e29e9bd95561cb66f2 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Tue, 21 Jul 2026 00:35:41 +0800 Subject: [PATCH 35/42] fix(board): terminal command always fits one line white-space: pre (no wrap), dock widened 640 -> 760px, and the line font clamps down on narrow windows so the longest story command (the 53-char fork) never breaks across lines. Co-Authored-By: Claude Fable 5 --- board/client/play.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/board/client/play.css b/board/client/play.css index 757e2bb..6f1774b 100644 --- a/board/client/play.css +++ b/board/client/play.css @@ -5,7 +5,7 @@ body { margin: 0; background: #0b1220; color: #e2e8f0; font: 16px/1.4 ui-monospa #field { flex: 1; min-height: 0; } #dock { border-top: 1px solid #1e293b; - width: 100%; max-width: 640px; margin: 0 auto; box-sizing: border-box; + 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; } @@ -22,7 +22,9 @@ body { margin: 0; background: #0b1220; color: #e2e8f0; font: 16px/1.4 ui-monospa .dot.y { background: #713f12; } .dot.g { background: #14532d; } #termtitle { margin-left: 8px; color: #64748b; font-size: 0.75rem; } -#line { margin: 0; padding: 0.9rem 1rem 1.1rem; font-size: 1.3rem; line-height: 1.5; white-space: pre-wrap; word-break: break-all; } +/* 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; } From 5e6b01f662d94c659376f53e7d8df5ba2cf74274 Mon Sep 17 00:00:00 2001 From: Muhamad Hisyam Date: Tue, 21 Jul 2026 22:51:21 +0800 Subject: [PATCH 36/42] Pisahkan CI dan CD berdasarkan status pull request --- .github/workflows/deploy.yaml | 67 +++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index c027ccf..64d3f35 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,58 +1,89 @@ name: Ship It + on: - push: - branches: [main] pull_request: branches: [main] + types: [opened, synchronize, reopened, closed] -# Amali 5: permission untuk deploy GitHub Pages +# Permission untuk deploy GitHub Pages permissions: + contents: read 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. +# Semua arahan run dijalankan dari folder launchpad defaults: run: working-directory: launchpad jobs: test: + # CI berjalan selagi Pull Request belum berjaya di-merge + if: github.event.pull_request.merged == false runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + 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 + + - name: Install dependencies + run: npm clean-install + + - name: Run pre-flight test + run: npm run test + + - name: Report result to dashboard + 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 dikekalkan, tetapi always() membolehkan deploy diperiksa + # walaupun job test dilangkau ketika PR sudah di-merge + 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 + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node.js + 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 + + - name: Install dependencies + run: npm clean-install + + - name: Build application + run: npm run build env: VITE_CALLSIGN: ${{ github.actor }} - - uses: actions/upload-pages-artifact@v5 + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v5 with: path: launchpad/dist - - id: deploy - uses: actions/deploy-pages@v5 + + - name: Deploy GitHub Pages + id: deploy + uses: actions/deploy-pages@v5 \ No newline at end of file From e261635848a39ad7cbefae85f4f1c1c5b2c43631 Mon Sep 17 00:00:00 2001 From: Muhamad Hisyam Date: Tue, 21 Jul 2026 23:16:50 +0800 Subject: [PATCH 37/42] Fix duplicated CI CD workflow steps --- .github/workflows/deploy.yaml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 64d3f35..8fb1bd1 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -5,7 +5,6 @@ on: branches: [main] types: [opened, synchronize, reopened, closed] -# Permission untuk deploy GitHub Pages permissions: contents: read pages: write @@ -15,14 +14,12 @@ concurrency: group: pages cancel-in-progress: true -# Semua arahan run dijalankan dari folder launchpad defaults: run: working-directory: launchpad jobs: test: - # CI berjalan selagi Pull Request belum berjaya di-merge if: github.event.pull_request.merged == false runs-on: ubuntu-latest @@ -50,8 +47,6 @@ jobs: SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} deploy: - # needs:test dikekalkan, tetapi always() membolehkan deploy diperiksa - # walaupun job test dilangkau ketika PR sudah di-merge needs: test if: always() && github.event.pull_request.merged == true runs-on: ubuntu-latest @@ -86,4 +81,4 @@ jobs: - name: Deploy GitHub Pages id: deploy - uses: actions/deploy-pages@v5 \ No newline at end of file + uses: actions/deploy-pages@v5 From 4609031c98c4e4d672303a3b6c394368110f3d99 Mon Sep 17 00:00:00 2001 From: Muhamad Hisyam Date: Tue, 21 Jul 2026 23:26:31 +0800 Subject: [PATCH 38/42] Add board URL to Vite build --- .github/workflows/deploy.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 8fb1bd1..ab0e24c 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -72,6 +72,7 @@ jobs: - name: Build application run: npm run build env: + VITE_BOARD_URL: ${{ vars.BOARD_URL }} VITE_CALLSIGN: ${{ github.actor }} - name: Upload GitHub Pages artifact From 56b3f8785d5f335952e946e2e73917c804204cc0 Mon Sep 17 00:00:00 2001 From: Muhamad Hisyam Date: Tue, 21 Jul 2026 23:29:17 +0800 Subject: [PATCH 39/42] Configure separate CI and CD workflow --- .github/workflows/deploy.yaml | 69 ++++++++--------------------------- 1 file changed, 15 insertions(+), 54 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index ab0e24c..d314967 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,19 +1,9 @@ -name: Ship It +name: cicd on: pull_request: - branches: [main] types: [opened, synchronize, reopened, closed] -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: true - defaults: run: working-directory: launchpad @@ -24,24 +14,11 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: 20 - cache: npm - cache-dependency-path: launchpad/package-lock.json - - - name: Install dependencies - run: npm clean-install - - - name: Run pre-flight test - run: npm run test - - - name: Report result to dashboard - run: bash scripts/report.sh + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + - run: npm clean-install + - run: npm run test + - run: bash scripts/report.sh env: BOARD_URL: ${{ vars.BOARD_URL }} SHIPIT_TOKEN: ${{ secrets.SHIPIT_TOKEN }} @@ -51,35 +28,19 @@ jobs: if: always() && github.event.pull_request.merged == true runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version: 20 - cache: npm - cache-dependency-path: launchpad/package-lock.json - - - name: Install dependencies - run: npm clean-install - - - name: Build application - run: npm run build + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + - run: npm clean-install + - run: npm run build env: VITE_BOARD_URL: ${{ vars.BOARD_URL }} VITE_CALLSIGN: ${{ github.actor }} - - - name: Upload GitHub Pages artifact - uses: actions/upload-pages-artifact@v5 + - uses: actions/upload-pages-artifact@v5 with: path: launchpad/dist + - uses: actions/deploy-pages@v5 - - name: Deploy GitHub Pages - id: deploy - uses: actions/deploy-pages@v5 +permissions: + pages: write + id-token: write From ca30ff67678c9f48c7bc65cfdc343947a3fd4804 Mon Sep 17 00:00:00 2001 From: Muhamad Hisyam Date: Tue, 21 Jul 2026 23:34:14 +0800 Subject: [PATCH 40/42] Update CI CD workflow based on pull request --- .github/workflows/deploy.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index d314967..c42259e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -12,7 +12,6 @@ jobs: test: if: github.event.pull_request.merged == false runs-on: ubuntu-latest - steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 @@ -27,7 +26,6 @@ jobs: needs: test if: always() && github.event.pull_request.merged == true runs-on: ubuntu-latest - steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 From 32d6cc08296d0d42051aa073766fabd8043233b8 Mon Sep 17 00:00:00 2001 From: Muhamad Hisyam Date: Tue, 21 Jul 2026 23:38:52 +0800 Subject: [PATCH 41/42] Trigger workflow with updated board URL From b5757e2dc70b414581b6fa39125914b20232ac41 Mon Sep 17 00:00:00 2001 From: Muhamad Hisyam Date: Wed, 22 Jul 2026 00:05:40 +0800 Subject: [PATCH 42/42] Retry CI with class token