diff --git a/CLAUDE.md b/CLAUDE.md index 4b22657..96c4e63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,9 +53,14 @@ single-screen previews stay relay-free via `/?test=1&scenario=…` (see README) remaps in `server/index.js`, so update the Dockerfile + CSP when changing them. - **Series, not single runs:** a session is N runs (host picks 3/5/7 in the lobby, like the difficulty switch — `SET_RUNS`/`RUNS_UPDATE`). The SkiEngine stays run-only (no series - concept); the series tally lives in `display/main.js` (`seriesScores`, `runIndex`). Points - per run come from the pure `seriesPoints()` in `protocol.js` (linear N..1, DNF 0 — tested in - `tests/series.test.js`); cumulative score + champion are folded in `endRun`/`buildSeriesRows`. + concept); the series tally lives in `display/SeriesTally.js` (run index/length/over-flag, + per-player banked scores, points folding + standings-row derivation) — a dependency-free, + THREE-free class (injected `seriesPoints`) so the Node tests load it directly + (`tests/seriesTally.test.js`). `main.js` owns the surrounding lifecycle/IO (intermission + timer, broadcasts, DOM). Points per run come from the pure `seriesPoints()` in `protocol.js` + (linear N..1, DNF 0 — tested in `tests/series.test.js`); cumulative score + champion are + folded in `endRun`/`tally.buildRows` (the "Run X of N" header wording is shared with the + phone board via `shared/seriesFormat.js`). Between runs the display auto-advances after an `INTERMISSION` countdown. `?runs=N`/`?intermission=N` pin them for previews/tests (any N≥1; `runs=1` = the old single-run flow — E2E helpers use it). - Slope layout is plain data in `public/shared/slopes.js`; `SlopeBuilder.js` turns it into a diff --git a/public/controller/SwipeInput.js b/public/controller/SwipeInput.js index 2987873..07de60c 100644 --- a/public/controller/SwipeInput.js +++ b/public/controller/SwipeInput.js @@ -79,10 +79,15 @@ export class SwipeInput { this._down = this._down.bind(this); this._up = this._up.bind(this); - this._bindKeys(); + this._onKeyDown = this._onKeyDown.bind(this); + this._onKeyUp = this._onKeyUp.bind(this); } start() { + if (typeof window !== 'undefined') { + window.addEventListener('keydown', this._onKeyDown); + window.addEventListener('keyup', this._onKeyUp); + } if (!this.surface) return; this.surface.addEventListener('pointerdown', this._down); this.surface.addEventListener('pointerup', this._up); @@ -90,6 +95,10 @@ export class SwipeInput { this.surface.addEventListener('pointerleave', this._up); } stop() { + if (typeof window !== 'undefined') { + window.removeEventListener('keydown', this._onKeyDown); + window.removeEventListener('keyup', this._onKeyUp); + } if (this.surface) { this.surface.removeEventListener('pointerdown', this._down); this.surface.removeEventListener('pointerup', this._up); @@ -184,45 +193,43 @@ export class SwipeInput { // ArrowDown = front flip, Q = spin-left, E = spin-right. (ArrowLeft/Right + A/D are carve, // owned by TiltInput, so they're left alone here.) Each trick key maps to the // same gesture angle a real flick would produce. - _bindKeys() { - if (typeof window === 'undefined') return; - // Never steal keys from a text field (the name input): these are - // window-level listeners, live from construction, and their preventDefault - // would otherwise swallow "s"/space/arrows while typing a name — and fire - // phantom brake/flick callbacks (buzz, HUD flashes) on every keystroke. - const typing = (e) => { - const t = e.target; - return !!(t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)); - }; - window.addEventListener('keydown', (e) => { - if (typing(e)) return; - const k = e.key.toLowerCase(); - if (k === 's') { - // keyboard brake is unambiguous (no flick to disambiguate) — confirm at once - if (!this._keyBrake) { this._keyBrake = true; this._braking = true; this._brakeConfirmed = true; this.onBrakeStart(); } - e.preventDefault(); - } else if (k === 'arrowup' || k === ' ') { - if (!this._keyUp) { this._keyUp = true; this._fireFlick(Math.PI / 2); } // back flip (air); nothing on the snow - e.preventDefault(); - } else if (k === 'arrowdown') { - if (!this._keyDown) { this._keyDown = true; this._fireFlick(-Math.PI / 2); } // front flip - e.preventDefault(); - } else if (k === 'q') { - if (!this._keyLeft) { this._keyLeft = true; this._fireFlick(Math.PI); } // spin left - e.preventDefault(); - } else if (k === 'e') { - if (!this._keyRight) { this._keyRight = true; this._fireFlick(0); } // spin right - e.preventDefault(); - } - }); - window.addEventListener('keyup', (e) => { - if (typing(e)) return; - const k = e.key.toLowerCase(); - if (k === 's') { this._keyBrake = false; this._endBrake(); e.preventDefault(); } - else if (k === 'arrowup' || k === ' ') { this._keyUp = false; e.preventDefault(); } - else if (k === 'arrowdown') { this._keyDown = false; e.preventDefault(); } - else if (k === 'q') { this._keyLeft = false; e.preventDefault(); } - else if (k === 'e') { this._keyRight = false; e.preventDefault(); } - }); + // Never steal keys from a text field (the name input): these are + // window-level listeners (added in start(), removed in stop() — symmetric + // with the pointer listeners), and their preventDefault would otherwise + // swallow "s"/space/arrows while typing a name — and fire phantom + // brake/flick callbacks (buzz, HUD flashes) on every keystroke. + _typing(e) { + const t = e.target; + return !!(t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)); + } + _onKeyDown(e) { + if (this._typing(e)) return; + const k = e.key.toLowerCase(); + if (k === 's') { + // keyboard brake is unambiguous (no flick to disambiguate) — confirm at once + if (!this._keyBrake) { this._keyBrake = true; this._braking = true; this._brakeConfirmed = true; this.onBrakeStart(); } + e.preventDefault(); + } else if (k === 'arrowup' || k === ' ') { + if (!this._keyUp) { this._keyUp = true; this._fireFlick(Math.PI / 2); } // back flip (air); nothing on the snow + e.preventDefault(); + } else if (k === 'arrowdown') { + if (!this._keyDown) { this._keyDown = true; this._fireFlick(-Math.PI / 2); } // front flip + e.preventDefault(); + } else if (k === 'q') { + if (!this._keyLeft) { this._keyLeft = true; this._fireFlick(Math.PI); } // spin left + e.preventDefault(); + } else if (k === 'e') { + if (!this._keyRight) { this._keyRight = true; this._fireFlick(0); } // spin right + e.preventDefault(); + } + } + _onKeyUp(e) { + if (this._typing(e)) return; + const k = e.key.toLowerCase(); + if (k === 's') { this._keyBrake = false; this._endBrake(); e.preventDefault(); } + else if (k === 'arrowup' || k === ' ') { this._keyUp = false; e.preventDefault(); } + else if (k === 'arrowdown') { this._keyDown = false; e.preventDefault(); } + else if (k === 'q') { this._keyLeft = false; e.preventDefault(); } + else if (k === 'e') { this._keyRight = false; e.preventDefault(); } } } diff --git a/public/controller/TiltInput.js b/public/controller/TiltInput.js index e7d8003..d63eb82 100644 --- a/public/controller/TiltInput.js +++ b/public/controller/TiltInput.js @@ -70,7 +70,8 @@ export class TiltInput { this._timer = null; this._onOrient = this._onOrient.bind(this); - this._bindKeys(); + this._onKeyDown = this._onKeyDown.bind(this); + this._onKeyUp = this._onKeyUp.bind(this); this._initSurface(); } @@ -111,10 +112,18 @@ export class TiltInput { start() { if (this._timer) return; + if (typeof window !== 'undefined') { + window.addEventListener('keydown', this._onKeyDown); + window.addEventListener('keyup', this._onKeyUp); + } const interval = 1000 / SEND_HZ; this._timer = setInterval(() => this._tick(), interval); } stop() { + if (typeof window !== 'undefined') { + window.removeEventListener('keydown', this._onKeyDown); + window.removeEventListener('keyup', this._onKeyUp); + } clearInterval(this._timer); this._timer = null; } @@ -156,22 +165,21 @@ export class TiltInput { } // --- keyboard fallback / testing (works over plain HTTP — no sensors) --- - _bindKeys() { - if (typeof window === 'undefined') return; - const set = (e, down) => { - // Never steal keys from a text field (the name input) — the - // preventDefault below would swallow "a"/"d"/arrows while typing a name. - const t = e.target; - if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; - const k = e.key.toLowerCase(); - if (k === 'arrowleft' || k === 'a') { this._keyL = down; e.preventDefault(); } - else if (k === 'arrowright' || k === 'd') { this._keyR = down; e.preventDefault(); } - else return; - this._key = (this._keyR ? 1 : 0) - (this._keyL ? 1 : 0); - }; - window.addEventListener('keydown', (e) => set(e, true)); - window.addEventListener('keyup', (e) => set(e, false)); + // Listeners are added in start() and removed in stop() (symmetric with the + // deviceorientation listener), so re-instantiation never leaks handlers. + _setKey(e, down) { + // Never steal keys from a text field (the name input) — the + // preventDefault below would swallow "a"/"d"/arrows while typing a name. + const t = e.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return; + const k = e.key.toLowerCase(); + if (k === 'arrowleft' || k === 'a') { this._keyL = down; e.preventDefault(); } + else if (k === 'arrowright' || k === 'd') { this._keyR = down; e.preventDefault(); } + else return; + this._key = (this._keyR ? 1 : 0) - (this._keyL ? 1 : 0); } + _onKeyDown(e) { this._setKey(e, true); } + _onKeyUp(e) { this._setKey(e, false); } // Carve is via tilt; the control surface just needs to not scroll/zoom under // the player's thumb while they ski (the swipe gestures share this surface). diff --git a/public/controller/ui.js b/public/controller/ui.js index a4500d5..034180c 100644 --- a/public/controller/ui.js +++ b/public/controller/ui.js @@ -1,6 +1,7 @@ // Small controller-UI helpers shared by the live phone (main.js) and the gallery // preview (TestHarness.js) so the two can't drift. No globals, no relay — pure DOM. import { buildLevelSeg as buildSeg, paintLevelSeg } from '../shared/levelSeg.js'; +import { runTag } from '../shared/seriesFormat.js'; // Latency chip (bottom-right). halfMs is one-way (RTT/2); halfMs < 0 means the // PONG is overdue (no signal). viaFastlane lights the bolt when the reading came @@ -66,7 +67,7 @@ export function renderResultsBoard(rows, { over, seriesOver, runIndex, runTotal, // Header tag + champion banner (overall board only). const tag = document.getElementById('result-runtag'); - if (tag) tag.textContent = runTotal ? (seriesOver ? `Final standings · ${runTotal} runs` : `Run ${runIndex} of ${runTotal}`) : ''; + if (tag) tag.textContent = runTotal ? runTag(runIndex, runTotal, seriesOver) : ''; const champEl = document.getElementById('result-champ'); if (champEl) { const winners = seriesOver ? rows.filter((r) => r.champion) : []; diff --git a/public/display/SeriesTally.js b/public/display/SeriesTally.js new file mode 100644 index 0000000..3189c24 --- /dev/null +++ b/public/display/SeriesTally.js @@ -0,0 +1,117 @@ +// SeriesTally — the cumulative scoring across a SERIES of runs, lifted out of +// display/main.js so the trickiest run-to-run state lives in one testable unit. +// +// A session is `runsTotal` head-to-head runs with points accumulating to an overall +// champion. This owns: the current run index (1-based; 0 in the lobby), the series +// length, the over-flag, the per-player banked scores, and the points/folding/row +// derivation. It does NOT touch the DOM, the net, or the engine — main.js keeps that +// lifecycle/IO (the intermission timer, broadcasts, rendering). `seriesPoints` is +// injected (the pure place-→points rule from shared/protocol.js) so this module is +// dependency-free / THREE-free and the Node tests (tests/seriesTally.test.js) load +// it directly — the same discipline the SkiEngine follows. +// +// Scores are keyed by playerId so a player leaving/reconnecting (and CPU-id reuse) +// doesn't drop their tally; the LIVE run's points are layered on top of the banked +// totals by buildRows each render, so the final run is never double-counted (it is +// folded only at advance time, and the final run isn't folded at all). +export class SeriesTally { + constructor(seriesPoints, runsTotal) { + this._seriesPoints = seriesPoints; + this.runsTotal = runsTotal; // series length (host's lobby pick; survives reset) + this.runIndex = 0; // current run, 1-based; 0 in the lobby + this.seriesOver = false; // the final run is in the books → overall board + this.scores = new Map(); // playerId -> { name, colorIndex, ai, points } through COMPLETED runs + } + + // Wipe the tally for a fresh series (keeps the host's runsTotal pick). + reset() { + this.scores = new Map(); + this.runIndex = 0; + this.seriesOver = false; + } + + // Step into the next run. + startNextRun() { this.runIndex += 1; } + + // Mark the just-ended run: the series is over once the final run is in the books. + // Returns the new seriesOver so the caller can branch on it. + endCurrentRun() { + this.seriesOver = this.runIndex >= this.runsTotal; + return this.seriesOver; + } + + // Host's lobby pick for the series length. + setRunsTotal(n) { this.runsTotal = n; } + + // Cross-device reconnect: carry a player's banked points onto their new slot. + rekey(oldId, newId) { + if (this.scores.has(oldId)) { + this.scores.set(newId, this.scores.get(oldId)); + this.scores.delete(oldId); + } + } + + // This run's series points for every finisher, keyed by playerId. `res` is the + // engine getResults() `results` array (1-based rank, finished flag). + _pointsFor(res) { + const m = new Map(); + for (const r of res) m.set(r.playerId, this._seriesPoints(r.rank, res.length, r.finished)); + return m; + } + + // Bank the just-finished run's points into the cumulative tally. Called BEFORE the + // run's session is torn down (it reads the live field + results). Not called for the + // final run — the overall board derives totals as banked(prior runs) + final-run + // points, so the last run is never double-counted. + fold(field, resultsObj) { + const pts = this._pointsFor((resultsObj && resultsObj.results) || []); + for (const p of field) { + const cur = this.scores.get(p.peerIndex) || { points: 0 }; + this.scores.set(p.peerIndex, { + name: p.name, colorIndex: p.colorIndex, ai: !!p.ai, + points: cur.points + (pts.get(p.peerIndex) || 0), + }); + } + } + + // The ordered standings rows for the CURRENT run, each carrying this-run `points` + // and the cumulative `score` (banked total through prior runs + this run's points). + // Shared by the phone broadcast and the big-screen board so they can't drift. Per + // run the rows stay in finish order; once the series is over they're sorted by total + // score (the overall ranking), `place` renumbered, and the leader(s) flagged + // champion. `resultsObj` is the engine's getResults() output; `field` names/colours + // the rows (both passed in so a no-relay TestHarness with its own session drives + // this exact path). + buildRows(resultsObj, field) { + const res = (resultsObj && resultsObj.results) || []; + const byId = new Map(field.map((p) => [p.peerIndex, p])); + const pts = this._pointsFor(res); + const rows = res.map((r) => { + const p = byId.get(r.playerId) || {}; + const point = pts.get(r.playerId) || 0; + const prior = (this.scores.get(r.playerId) || { points: 0 }).points; + return { + playerId: r.playerId, name: p.name || 'Skier', + colorIndex: p.colorIndex || 0, ai: !!p.ai, + finished: r.finished, time: r.time, dnf: !!r.dnf, + place: r.rank, points: point, score: prior + point, + }; + }); + if (this.seriesOver) { + // Overall ranking: most points first, this run's finish order breaks ties. + rows.sort((a, b) => b.score - a.score || a.place - b.place); + const top = rows.length ? rows[0].score : 0; + rows.forEach((row, i) => { row.place = i + 1; row.champion = top > 0 && row.score === top; }); + } + return rows; + } + + // Stage series state for a no-relay preview (the gallery 'results' scenario) so the + // live render path shows a mid-series or final board. Never used in live play. + stagePreview({ index = 1, total = this.runsTotal, over = false, scores = null } = {}) { + this.runIndex = index; + this.runsTotal = total; + this.seriesOver = over; + if (scores) this.scores = new Map(Object.entries(scores).map(([id, points]) => [id, { points }])); + } +} diff --git a/public/display/SkiTrails.js b/public/display/SkiTrails.js index cd477c9..e15e762 100644 --- a/public/display/SkiTrails.js +++ b/public/display/SkiTrails.js @@ -93,8 +93,12 @@ export class SkiTrails { t.lastAir = false; t.count++; - t.posAttr.needsUpdate = true; - t.nrmAttr.needsUpdate = true; + // Only this one slot (4 verts = VPP*3 = 12 array elements at offset vb) changed, + // so scope the GPU upload to that range instead of re-uploading the whole buffer. + // start/count are in array-INDEX units; three's WebGLAttributes auto-clears + // updateRanges after each upload, so we re-add the range every recorded point. + t.posAttr.addUpdateRange(vb, VPP * 3); t.posAttr.needsUpdate = true; + t.nrmAttr.addUpdateRange(vb, VPP * 3); t.nrmAttr.needsUpdate = true; this._rebuildIndex(t); } @@ -122,6 +126,8 @@ export class SkiTrails { idx[n++] = a + 2; idx[n++] = b + 3; idx[n++] = b + 2; } t.geom.setDrawRange(0, n); + // Index is deliberately uploaded in full (no updateRange): the ring-buffer wrap + // seam makes incremental index updates unsafe to do hastily — separate concern. t.idxAttr.needsUpdate = true; } diff --git a/public/display/SlopeBuilder.js b/public/display/SlopeBuilder.js index 32cb61d..1d0c231 100644 --- a/public/display/SlopeBuilder.js +++ b/public/display/SlopeBuilder.js @@ -6,7 +6,7 @@ // Returns { centerline, length, slopeWidth, groundY, ramps, obstacles, def }. import * as THREE from 'three'; import { Centerline } from './Centerline.js'; -import { generateSlope } from '../shared/slopes.js'; +import { generateSlope, obstacleRadius } from '../shared/slopes.js'; const DEG = Math.PI / 180; const STEP = 2.0; // arclength between centerline samples (Catmull-Rom smooths between) @@ -107,10 +107,10 @@ export function buildSlope(def) { const groundY = minY - 0.4; const ramps = (def.ramps || []).map((r) => ({ - s: r.at * length, lat: r.lat || 0, radius: r.radius || 1.5, width: r.width || 2.4, + s: r.at * length, lat: r.lat || 0, width: r.width || 2.4, })); const obstacles = (def.obstacles || []).map((o) => ({ - s: o.at * length, lat: o.lat || 0, radius: o.radius || (o.kind === 'rock' ? 0.85 : 0.7), kind: o.kind || 'tree', + s: o.at * length, lat: o.lat || 0, radius: o.radius || obstacleRadius(o.kind), kind: o.kind || 'tree', })); // The finish-gate posts (drawn by the renderer's FinishGate) are solid obstacles // too: clipping one at the line wipes you out like a tree/rock. At the OUTER piste diff --git a/public/display/SlopeScenery.js b/public/display/SlopeScenery.js index dd9cf4c..5b3b8b1 100644 --- a/public/display/SlopeScenery.js +++ b/public/display/SlopeScenery.js @@ -5,7 +5,7 @@ // no renderer state. SceneRenderer owns cameras/lighting/skiers and calls these // from setTrack. import * as THREE from 'three'; -import { mulberry32 } from '../shared/slopes.js'; +import { mulberry32, obstacleRadius } from '../shared/slopes.js'; import { hitSL, SKI_HALF } from './engine/SkiEngine.js'; const _up = new THREE.Vector3(0, 1, 0); @@ -468,7 +468,7 @@ export function addObstacle(group, cl, o, hitboxDebug) { g.quaternion.setFromUnitVectors(_up, up); group.add(g); if (hitboxDebug) { - group.add(debugCircle(f, o.lat || 0, o.radius || (o.kind === 'rock' ? 0.7 : 0.8), 0xff2244)); + group.add(debugCircle(f, o.lat || 0, o.radius || obstacleRadius(o.kind), 0xff2244)); } } diff --git a/public/display/TestHarness.js b/public/display/TestHarness.js index a188850..37f3877 100644 --- a/public/display/TestHarness.js +++ b/public/display/TestHarness.js @@ -160,7 +160,7 @@ export function runDisplayScenario(cfg, ctx) { // own session, not main.js's): a points tally across runsTotal runs, the real // series board via setSeriesPreview + showResults, and an auto-advance between // runs. seriesScores holds totals through COMPLETED runs (this run's points are - // layered on live by buildSeriesRows); runIndex is the current run (1-based). + // layered on live by tally.buildRows); runIndex is the current run (1-based). const runsTotal = cfg.runsTotal || 5; let runIndex = 1; let seriesScores = {}; // playerId -> points through prior runs @@ -192,7 +192,7 @@ export function runDisplayScenario(cfg, ctx) { soloOver = true; audio.stopWind(); audio.finish(); seriesOver = runIndex >= runsTotal; // Stage the series state so showResults renders the real board: scores are - // the totals through PRIOR runs; buildSeriesRows adds this run's points on top. + // the totals through PRIOR runs; tally.buildRows adds this run's points on top. if (setSeriesPreview) setSeriesPreview({ index: runIndex, total: runsTotal, over: seriesOver, scores: { ...seriesScores } }); showResults(results, field); if (!seriesOver) startInter(); // hold the scores a beat, then auto-advance @@ -201,7 +201,7 @@ export function runDisplayScenario(cfg, ctx) { return s; } // Bank a finished run's points into the prior-runs tally (mirrors the live - // foldRunScores). window.seriesPoints is protocol.js's shared scoring rule. + // tally.fold). window.seriesPoints is protocol.js's shared scoring rule. function foldRun(resultsObj) { const res = (resultsObj && resultsObj.results) || []; for (const r of res) seriesScores[r.playerId] = (seriesScores[r.playerId] || 0) + window.seriesPoints(r.rank, res.length, r.finished); diff --git a/public/display/engine/SkiEngine.js b/public/display/engine/SkiEngine.js index 5d925f2..14b1c19 100644 --- a/public/display/engine/SkiEngine.js +++ b/public/display/engine/SkiEngine.js @@ -116,6 +116,7 @@ const TBONE_CLOSING = 6.0; // lateral closing speed (u/s) above which a side-on // ---- Jump / air --------------------------------------------------------- export const GRAV_AIR = 22.0; // u/s² pulling you back to the snow while airborne (lower = more hang time for tricks) const RAMP_POP = 7.5; // u/s up from hitting a ramp (auto-launch, ∝ speed → ~0.9u apex). NB: "flick up to jump" on the snow was removed — ramps launch you automatically. +const RAMP_HALF_S = 1.5; // half the down-slope length of a kicker's footprint — half the 3.0-long kicker box SlopeScenery.addRamp draws. const LAND_CLEAN_ACROSS = 0.42; // |across| under this on touchdown = clean landing (keep speed + boost) const LAND_BOOST = 1.18; // clean big-air landing multiplies speed briefly const LAND_BOOST_MIN_AIR = 1.2; // …only if the jump cleared at least this height @@ -189,13 +190,12 @@ export class SkiEngine { // draws the same numbers), an obstacle's its radius; you're on either the // moment your body circle touches it. this.ramps = (track.ramps || []).map((r) => ({ - s: r.s, lat: r.lat || 0, halfS: 1.5, halfW: (r.width || 2.4) / 2 + s: r.s, lat: r.lat || 0, halfS: RAMP_HALF_S, halfW: (r.width || 2.4) / 2 })); this.obstacles = (track.obstacles || []).map((o) => ({ - // default footprints match the rendered props at body height: a tree's - // low canopy reaches ~0.8 from the trunk where it meets a shoulder; a - // rock is its 0.7 icosahedron. - s: o.s, lat: o.lat || 0, radius: o.radius || (o.kind === 'rock' ? 0.7 : 0.8), kind: o.kind || 'tree' + // the builder supplies per-kind radius (slopes.obstacleRadius); this is only + // a degenerate fallback for hand-built tracks/tests. + s: o.s, lat: o.lat || 0, radius: o.radius || 0.8, kind: o.kind || 'tree' })); // Start line: spread the field evenly across the groomed piste in DISTINCT diff --git a/public/display/main.js b/public/display/main.js index b248592..30941d2 100644 --- a/public/display/main.js +++ b/public/display/main.js @@ -8,9 +8,11 @@ import { buildGeneratedSlope } from './SlopeBuilder.js'; import { RunSession } from './RunSession.js'; import { AiController, AI_PERSONALITIES } from './AiDriver.js'; import { SlopeAudio } from './Audio.js'; +import { SeriesTally } from './SeriesTally.js'; import { keepScreenOn } from '../shared/WakeLock.js'; import { initDebugMenu } from '../shared/DebugMenu.js'; import { buildLevelSeg, paintLevelSeg } from '../shared/levelSeg.js'; +import { runTag } from '../shared/seriesFormat.js'; const { MSG, ROOM_STATE, COUNTDOWN_SECONDS, MAX_PLAYERS, SKIER_COLORS, LEVELS, DEFAULT_LEVEL, @@ -43,7 +45,7 @@ let currentLevel = isLevel(params.get('level')) ? params.get('level') : DEFAULT_ // otherwise it starts at DEFAULT_RUNS and follows the host's choice. Locked once a // series starts (the selector is lobby-only). const runsParam = parseInt(params.get('runs'), 10); -let runsTotal = Number.isInteger(runsParam) && runsParam >= 1 ? runsParam : DEFAULT_RUNS; +const initialRuns = Number.isInteger(runsParam) && runsParam >= 1 ? runsParam : DEFAULT_RUNS; // Between-runs auto-advance countdown (seconds). `?intermission=N` shortens it for // tests/previews; live play uses the INTERMISSION_SECONDS default. const interParam = parseInt(params.get('intermission'), 10); @@ -94,9 +96,15 @@ function showSoundHint() { d.id = 'sound-hint'; d.textContent = '🔈 Click or press a key for sound'; document.body.appendChild(d); - // Some displays allow audio without a gesture (kiosk autoplay permission) — - // then the context unlocks on its own and the hint should clear itself. - const t = setInterval(() => { if (audio.ready) { d.remove(); clearInterval(t); } }, 500); + // Some displays allow audio without a gesture (kiosk autoplay permission) — then + // the context unlocks on its own and the hint clears itself. Bounded to ~30s so a + // display that never unlocks (no gesture, no autoplay) doesn't poll forever; a + // later gesture still clears the hint via the unlockAudio listeners above. + let ticks = 0; + const t = setInterval(() => { + if (audio.ready) { d.remove(); clearInterval(t); } + else if (++ticks >= 60) clearInterval(t); + }, 500); } const trackOpts = { hitbox: params.get('hitbox') === '1' }; // ?hitbox=1 — wireframe collision footprints // The grade colour for the run's tier (Blue/Red/Black), used to cap the edge @@ -134,16 +142,15 @@ let coastSettled = false; // one-shot: final board refresh once the coast-out let lastHud = 0; // ---- series state -------------------------------------------------------- -// A series is `runsTotal` head-to-head runs with points accumulating to an -// overall champion. `runIndex` is the current run (1-based; 0 in the lobby). -// `seriesScores` is the running tally through COMPLETED runs only (the live run's -// points are added on top each render), keyed by playerId so it survives a player -// leaving/reconnecting and CPU-id reuse. `seriesOver` flips on the final run's end -// → the overall board (sorted by total). Between runs the display auto-advances -// after a short intermission countdown. -let runIndex = 0; -let seriesOver = false; -let seriesScores = new Map(); // playerId -> { name, colorIndex, ai, points } +// A series is `tally.runsTotal` head-to-head runs with points accumulating to an +// overall champion. The SeriesTally (display/SeriesTally.js) owns the run index, +// length, over-flag, per-player banked scores and the points/folding/row derivation +// — a dependency-free, unit-tested unit (tests/seriesTally.test.js). main.js keeps +// the lifecycle/IO around it: the intermission timer, net broadcasts and DOM. The +// live run's points layer on top of the banked tally each render, so the final run +// is never double-counted. Between runs the display auto-advances after a short +// intermission countdown. +const tally = new SeriesTally(seriesPoints, initialRuns); let intermissionTimer = null; // ---- net ----------------------------------------------------------------- @@ -166,7 +173,7 @@ const net = new DisplayNet({ // Every WELCOME carries the room's current difficulty + series length so a // joiner's lobby selectors land on the right tier/count; mid-run/results // extras ride alongside. - const extra = { level: currentLevel, runs: runsTotal }; + const extra = { level: currentLevel, runs: tally.runsTotal }; if (session) { if (raceEnded) extra.standings = standingsPayload(true); else if (paused) extra.paused = true; @@ -262,7 +269,7 @@ function rekeyPlayer(oldId, newId) { for (const p of currentField) { if (p.peerIndex === oldId) p.peerIndex = newId; } // Carry their accumulated series points onto the new slot so a cross-device // reconnect mid-series doesn't lose the score they've banked. - if (seriesScores.has(oldId)) { seriesScores.set(newId, seriesScores.get(oldId)); seriesScores.delete(oldId); } + tally.rekey(oldId, newId); } // Dropped-seat reconnect cards: a QR centred in each disconnected player's @@ -352,15 +359,15 @@ function renderRuns() { // Items reuse the LEVELS shape ({ id, label, color }); the brand colour fills // the active segment (runs have no per-option colour like the piste grades). buildLevelSeg(seg, RUN_COUNTS.map((n) => ({ id: String(n), label: String(n), color: 'var(--brand)' })), setRuns); - paintLevelSeg(seg, String(runsTotal), false); // always live on the big screen — no host gate + paintLevelSeg(seg, String(tally.runsTotal), false); // always live on the big screen — no host gate } function setRuns(runs) { const n = parseInt(runs, 10); - if (!isRunCount(n) || n === runsTotal) return; - runsTotal = n; + if (!isRunCount(n) || n === tally.runsTotal) return; + tally.setRunsTotal(n); renderRuns(); - net.broadcast({ type: MSG.RUNS_UPDATE, runs: runsTotal }); + net.broadcast({ type: MSG.RUNS_UPDATE, runs: tally.runsTotal }); } // ---- field build (humans + AI fill) ------------------------------------- @@ -477,21 +484,19 @@ function driveLobbyRace(dt) { // only kick a series off from the lobby with no live session — scores can't be // wiped mid-run). function onStartPressed() { - if (raceEnded) { if (seriesOver) newSeriesFromResults(); return; } + if (raceEnded) { if (tally.seriesOver) newSeriesFromResults(); return; } if (!session && net.roomState === ROOM_STATE.LOBBY) startSeries(); } // Kick off a fresh series: wipe the tally and drop into run 1. From the lobby // (host Start) or the final board ("Play again", via newSeriesFromResults). function startSeries() { - seriesScores = new Map(); - runIndex = 0; - seriesOver = false; + tally.reset(); startNextRun(); } function startNextRun() { - runIndex += 1; + tally.startNextRun(); startRun(); } @@ -500,9 +505,9 @@ function startNextRun() { // when the countdown elapses. Guarded so a double-fire no-ops: once startRun flips // raceEnded back to false a re-entry returns early. function advanceToNextRun() { - if (!raceEnded || seriesOver) return; + if (!raceEnded || tally.seriesOver) return; clearIntermission(); - foldRunScores(); + if (session) tally.fold(currentField, session.getResults()); // bank this run's points before teardown teardownRun(); startNextRun(); } @@ -692,70 +697,15 @@ function lateJoiners() { return net.roster().filter((p) => p.connected !== false && !inField.has(p.peerIndex)); } -// This run's series points for every skier, keyed by playerId. `seriesPoints` -// lives in shared/protocol.js (one definition for both sides + the Node tests). -function pointsForResults(res) { - const m = new Map(); - for (const r of res) m.set(r.playerId, seriesPoints(r.rank, res.length, r.finished)); - return m; -} - -// Bank the just-finished run's points into the cumulative tally. Called from -// advanceToNextRun BEFORE the session is torn down (it reads the live field + -// results). seriesScores then holds totals through this run; the next run's points -// are layered on top live again. Not called for the final run — the overall board -// derives its totals as seriesScores(prior runs) + the final run's points, so the -// last run is never double-counted. -function foldRunScores() { - if (!session) return; - const pts = pointsForResults(session.getResults().results); - for (const p of currentField) { - const cur = seriesScores.get(p.peerIndex) || { points: 0 }; - seriesScores.set(p.peerIndex, { - name: p.name, colorIndex: p.colorIndex, ai: !!p.ai, - points: cur.points + (pts.get(p.peerIndex) || 0), - }); - } -} - -// The ordered standings rows for the CURRENT run, each carrying this-run `points` -// and the cumulative `score` (banked total through prior runs + this run's points). -// Shared by the phone broadcast (standingsPayload) and the big-screen board -// (showResults) so they can't drift. Per-run the rows stay in finish order; once -// the series is over they're sorted by total score (the overall ranking), `place` -// renumbered, and the leader(s) flagged champion. `resultsObj` is the engine's -// getResults() output; `field` names/colours the rows. (Both are passed in rather -// than read off the module `session` so the no-relay TestHarness, which owns its -// own session, drives this exact path.) -function buildSeriesRows(resultsObj, field) { - const res = (resultsObj && resultsObj.results) || []; - const byId = new Map(field.map((p) => [p.peerIndex, p])); - const pts = pointsForResults(res); - const rows = res.map((r) => { - const p = byId.get(r.playerId) || {}; - const point = pts.get(r.playerId) || 0; - const prior = (seriesScores.get(r.playerId) || { points: 0 }).points; - return { - playerId: r.playerId, name: p.name || 'Skier', - colorIndex: p.colorIndex || 0, ai: !!p.ai, - finished: r.finished, time: r.time, dnf: !!r.dnf, - place: r.rank, points: point, score: prior + point, - }; - }); - if (seriesOver) { - // Overall ranking: most points first, this run's finish order breaks ties. - rows.sort((a, b) => b.score - a.score || a.place - b.place); - const top = rows.length ? rows[0].score : 0; - rows.forEach((row, i) => { row.place = i + 1; row.champion = top > 0 && row.score === top; }); - } - return rows; -} +// The cumulative tally (run index/length/over-flag, banked scores, points folding +// and the standings-row derivation) lives in the SeriesTally `tally` — see the +// series-state block above. main.js only wires it to the net/DOM below. function standingsPayload(over) { - const rows = buildSeriesRows(session.getResults(), currentField); + const rows = tally.buildRows(session.getResults(), currentField); return { type: MSG.STANDINGS, - over, seriesOver, runIndex, runTotal: runsTotal, + over, seriesOver: tally.seriesOver, runIndex: tally.runIndex, runTotal: tally.runsTotal, hostPeerIndex: net.flow.host, total: rows.length, // Late joiners ride along under the board (newPlayer) so their own phone — @@ -777,7 +727,7 @@ function broadcastStandings(over) { if (session) net.broadcast(standingsPayload( // settle point; the intermissionTimer guard keeps it to a single start. No-op on // the final run (the overall board never advances) or once the field has emptied. function maybeStartIntermission() { - if (!raceEnded || seriesOver || intermissionTimer) return; + if (!raceEnded || tally.seriesOver || intermissionTimer) return; if (!session || !(session.engine.raceOver || coastSettled)) return; startIntermission(); } @@ -796,7 +746,7 @@ function startIntermission() { }, 1000); } function pushIntermission(n) { - net.broadcast({ type: MSG.INTERMISSION, n, runIndex, runTotal: runsTotal }); + net.broadcast({ type: MSG.INTERMISSION, n, runIndex: tally.runIndex, runTotal: tally.runsTotal }); const line = el('results-intermission'); if (line) line.textContent = `Next run in ${n}…`; } @@ -808,7 +758,7 @@ function endRun(results) { if (raceEnded) return; // panel already up (humans done) — onRaceEvent keeps it refreshed raceEnded = true; raceEndedAt = performance.now(); - seriesOver = runIndex >= runsTotal; // the final run is in the books → overall board + tally.endCurrentRun(); // the final run is in the books → overall board net.flow.transitionTo(ROOM_STATE.RESULTS); broadcastStandings(true); audio.stopWind(); @@ -831,7 +781,7 @@ function endRun(results) { // finish anymore), covering the timeout end where raceOver never trips. `joiners` // lets the harness preview late-join rows; live renders derive them themselves. function showResults(results, field = currentField, settled = false, joiners = null) { - const rows = buildSeriesRows(results, field); + const rows = tally.buildRows(results, field); const list = el('results-list'); if (list) { list.innerHTML = ''; @@ -852,7 +802,7 @@ function showResults(results, field = currentField, settled = false, joiners = n `` + `${escapeHtml(r.name)}${r.ai ? ' CPU' : ''}` + `${time}` + - `${seriesOver ? '' : (r.finished ? '+' + r.points : '')}` + + `${tally.seriesOver ? '' : (r.finished ? '+' + r.points : '')}` + `${r.score}`; list.appendChild(li); } @@ -875,14 +825,15 @@ function showResults(results, field = currentField, settled = false, joiners = n } } - // Header: "Run X of N" mid-series, "Final standings" once it's over. + // Header: "Run X of N" mid-series, "Final standings" once it's over (shared + // wording with the phone board via shared/seriesFormat.js). const tag = el('results-runtag'); - if (tag) tag.textContent = runIndex ? (seriesOver ? `Final standings · ${runsTotal} runs` : `Run ${runIndex} of ${runsTotal}`) : ''; + if (tag) tag.textContent = tally.runIndex ? runTag(tally.runIndex, tally.runsTotal, tally.seriesOver) : ''; // Champion banner — only on the overall board. Co-champions on a points tie. const champ = el('results-champ'); if (champ) { - const winners = seriesOver ? rows.filter((r) => r.champion) : []; + const winners = tally.seriesOver ? rows.filter((r) => r.champion) : []; if (winners.length) { champ.classList.remove('hidden'); // Assigned via textContent (below), so names need no escaping — the browser @@ -896,9 +847,9 @@ function showResults(results, field = currentField, settled = false, joiners = n // no button, just the intermission countdown; only the final board offers a // button ("Play again" → a brand-new series). const again = el('results-again'); - if (again) { again.textContent = 'Play again'; again.classList.toggle('hidden', !seriesOver); } + if (again) { again.textContent = 'Play again'; again.classList.toggle('hidden', !tally.seriesOver); } const line = el('results-intermission'); - if (line) line.classList.toggle('hidden', seriesOver); + if (line) line.classList.toggle('hidden', tally.seriesOver); const res = el('results'); if (res) res.classList.remove('hidden'); @@ -907,18 +858,15 @@ function showResults(results, field = currentField, settled = false, joiners = n // Stage series state for a no-relay preview (the gallery 'results' scenario) so // showResults renders a mid-series or final board through the live path. Injected // into the TestHarness — never used in live play. -function setSeriesPreview({ index = 1, total = runsTotal, over = false, scores = null } = {}) { - runIndex = index; - runsTotal = total; - seriesOver = over; - if (scores) seriesScores = new Map(Object.entries(scores).map(([id, points]) => [id, { points }])); +function setSeriesPreview(opts = {}) { + tally.stagePreview(opts); } // Tear down the current run and roll a FRESH random slope for the next one (live // play only — test mode pins a stable seed). Shared by "New game" (→ lobby), // the between-runs auto-advance, and "Play again" (→ a new series); all get a new -// mountain. Does NOT touch seriesScores — advanceToNextRun banks the run's points -// (foldRunScores) before calling this, and startSeries resets the tally. +// mountain. Does NOT touch the tally — advanceToNextRun banks the run's points +// (tally.fold) before calling this, and startSeries/returnToLobby reset the tally. function teardownRun() { clearIntermission(); if (session) { session.dispose(); session = null; } @@ -936,7 +884,7 @@ function returnToLobby() { teardownRun(); // Abort the series outright: reset the tally so the lobby (and the next series) // starts clean. - runIndex = 0; seriesOver = false; seriesScores = new Map(); + tally.reset(); net.flow.transitionTo(ROOM_STATE.LOBBY); net.broadcast({ type: MSG.GAME_END }); scene.orbit = true; @@ -1098,7 +1046,7 @@ el('pause-continue') && el('pause-continue').addEventListener('click', resumeRun el('pause-newgame') && el('pause-newgame').addEventListener('click', returnToLobby); // The big-screen results button shows only on the final board ("Play again" → a // brand-new series); mid-series the board auto-advances with no button. -el('results-again') && el('results-again').addEventListener('click', () => { if (seriesOver) newSeriesFromResults(); }); +el('results-again') && el('results-again').addEventListener('click', () => { if (tally.seriesOver) newSeriesFromResults(); }); el('results-newgame') && el('results-newgame').addEventListener('click', returnToLobby); window.addEventListener('keydown', (e) => { if (e.key === 'g' && net.roomState === ROOM_STATE.LOBBY) startSeries(); // dev: start without a phone @@ -1119,7 +1067,7 @@ if (params.get('test') === '1' || scenario) { import('./TestHarness.js').then(({ runDisplayScenario }) => runDisplayScenario( // scenarios default to a 4-skier field (solo = you + 3 CPU); ?players=N overrides. // `runsTotal`/`seriesOver` stage the results scenario as a mid-series or final board. - { scenario: scenario || 'running', players: parseInt(params.get('players'), 10) || 4, host: parseInt(params.get('host'), 10) || 0, cam: params.get('cam'), runsTotal, seriesOver: params.get('over') === '1', intermission: intermissionSeconds }, + { scenario: scenario || 'running', players: parseInt(params.get('players'), 10) || 4, host: parseInt(params.get('host'), 10) || 0, cam: params.get('cam'), runsTotal: tally.runsTotal, seriesOver: params.get('over') === '1', intermission: intermissionSeconds }, // Inject the REAL render fns so the harness previews the live DOM path rather // than a hand-copy (which drifts — see renderRoster/showResults). { scene, slope, AiController, AI_PERSONALITIES, RunSession, renderRoster, renderLevel, renderRuns, showResults, setSeriesPreview, buildReconnectCard, audio, showSoundHint, rerollSlope, lobbyCrossfade } diff --git a/public/shared/seriesFormat.js b/public/shared/seriesFormat.js new file mode 100644 index 0000000..9e688a9 --- /dev/null +++ b/public/shared/seriesFormat.js @@ -0,0 +1,8 @@ +// Series header tag — "Run X of N" mid-series, "Final standings · N runs" once the +// series is decided. Shared by the big screen (display/main.js showResults) and the +// phone board (controller/ui.js renderResultsBoard) so the two copies can't drift. +// Each caller keeps its own "is there a series yet?" guard (the display gates on the +// run index, the phone on the run total); this only owns the wording. +export function runTag(runIndex, runTotal, seriesOver) { + return seriesOver ? `Final standings · ${runTotal} runs` : `Run ${runIndex} of ${runTotal}`; +} diff --git a/public/shared/slopes.js b/public/shared/slopes.js index 4fd0237..92a1932 100644 --- a/public/shared/slopes.js +++ b/public/shared/slopes.js @@ -40,6 +40,17 @@ export function mulberry32(seed) { }; } +// obstacleRadius(kind) — the SINGLE SOURCE OF TRUTH for the per-kind footprint radii +// of the PROCEDURAL obstacles (rock / tree — snow-contact half-widths). Consumed by +// SlopeBuilder (it stamps each obstacle's resolved `radius`) and SlopeScenery +// (hitbox-debug outline). The import-free SkiEngine never imports this — it consumes +// the builder-supplied radius and keeps only a kind-agnostic fallback for hand-built +// tracks/tests. (Finish-gate 'post' obstacles aren't procedural: SlopeBuilder injects +// them with their own explicit radius, so they never fall back to this.) +export function obstacleRadius(kind) { + return kind === 'rock' ? 0.85 : 0.7; +} + const _clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x); const r1 = (x) => Math.round(x * 10) / 10; // one decimal const r3 = (x) => Math.round(x * 1000) / 1000; // three decimals diff --git a/tests/centerline.test.js b/tests/centerline.test.js new file mode 100644 index 0000000..d144b3f --- /dev/null +++ b/tests/centerline.test.js @@ -0,0 +1,165 @@ +'use strict'; + +// Centerline unit tests. Unlike engine.test.js (which feeds the engine a THREE- +// free stub centerline), these tests exercise the REAL Catmull-Rom spline math +// in public/display/Centerline.js — so they use the REAL THREE.Vector3 (the +// `three` devDep, which Centerline.js imports). The module is ES; we load it via +// dynamic import() from this CommonJS test (same trick as the sibling tests). + +const test = require('node:test'); +const assert = require('node:assert'); + +const loadCenterline = () => import('../public/display/Centerline.js'); +const loadThree = () => import('three'); + +// Build a Centerline from an array of points. Each point may be a Vector3 or +// {x,y,z}; up defaults to +y. `s` is cumulative segment length (monotonic). +async function build(points, upDir) { + const { Centerline } = await loadCenterline(); + const THREE = await loadThree(); + const up0 = upDir || new THREE.Vector3(0, 1, 0); + let s = 0; + const samples = []; + points.forEach((p, i) => { + const pos = p instanceof THREE.Vector3 ? p.clone() : new THREE.Vector3(p.x, p.y, p.z); + if (i > 0) s += pos.distanceTo(samples[i - 1].pos); + samples.push({ pos, up: up0.clone(), s }); + }); + return { cl: new Centerline(samples, s), THREE, samples }; +} + +// A descending polyline: advances in +x while dropping in -y, up roughly +y. +async function descending(n = 8, dx = 10, dy = -3) { + const { Centerline } = await loadCenterline(); + const THREE = await loadThree(); + const pts = []; + for (let i = 0; i < n; i++) pts.push(new THREE.Vector3(i * dx, i * dy, 0)); + return build(pts); +} + +const finite = (v) => Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z); + +test('three resolves under Node with a real Vector3', async () => { + const THREE = await loadThree(); + assert.strictEqual(typeof THREE.Vector3, 'function'); + const v = new THREE.Vector3(1, 2, 2); + assert.ok(Math.abs(v.length() - 3) < 1e-9); +}); + +test('sampleAt(0) returns the first sample position', async () => { + const { cl, samples } = await descending(); + const r = cl.sampleAt(0); + assert.ok(r.pos.distanceTo(samples[0].pos) < 1e-6, 'pos == first sample'); +}); + +test('sampleAt(length) returns the last sample position', async () => { + const { cl, samples } = await descending(); + const r = cl.sampleAt(cl.length); + assert.ok(r.pos.distanceTo(samples[samples.length - 1].pos) < 1e-6, 'pos == last sample'); +}); + +test('s < 0 and s > length clamp (and stay finite)', async () => { + const { cl } = await descending(); + const at0 = cl.sampleAt(0); + const before = cl.sampleAt(-5); + assert.ok(before.pos.distanceTo(at0.pos) < 1e-9, 'sampleAt(-5) == sampleAt(0)'); + + const atEnd = cl.sampleAt(cl.length); + const after = cl.sampleAt(cl.length + 5); + assert.ok(after.pos.distanceTo(atEnd.pos) < 1e-9, 'sampleAt(length+5) == sampleAt(length)'); + + for (const r of [before, after, at0, atEnd]) { + assert.ok(finite(r.pos) && finite(r.tangent), 'all components finite (no NaN)'); + } +}); + +test('pos and tangent are finite and tangent is unit length across [0, length]', async () => { + const { cl } = await descending(); + const N = 200; + for (let k = 0; k <= N; k++) { + const s = (cl.length * k) / N; + const r = cl.sampleAt(s); + assert.ok(finite(r.pos), `pos finite at s=${s}`); + assert.ok(finite(r.tangent), `tangent finite at s=${s}`); + assert.ok(finite(r.up) && finite(r.lateral), `up/lateral finite at s=${s}`); + assert.ok(Math.abs(r.tangent.length() - 1) < 1e-6, `tangent unit at s=${s}`); + } +}); + +test('arclength monotonicity: position advances along the dominant axis', async () => { + // Dominant axis is +x (dx=10 >> |dy|=3). + const { cl } = await descending(); + const N = 100; + let prevX = -Infinity; + let prevY = Infinity; + for (let k = 0; k <= N; k++) { + const s = (cl.length * k) / N; + const r = cl.sampleAt(s); + assert.ok(r.pos.x >= prevX - 1e-9, `x non-decreasing at s=${s} (${r.pos.x} >= ${prevX})`); + assert.ok(r.pos.y <= prevY + 1e-9, `y non-increasing (descending) at s=${s}`); + prevX = r.pos.x; + prevY = r.pos.y; + } +}); + +test('tangent points down the dominant axis of travel', async () => { + const { cl } = await descending(); + for (const s of [0, cl.length * 0.25, cl.length * 0.5, cl.length * 0.75, cl.length]) { + const r = cl.sampleAt(s); + assert.ok(r.tangent.x > 0, `tangent advances +x at s=${s} (got ${r.tangent.x})`); + assert.ok(r.tangent.y < 0, `tangent descends -y at s=${s} (got ${r.tangent.y})`); + } +}); + +test('two-point centerline: finite pos/tangent at ends and interior', async () => { + const { cl } = await build([ + { x: 0, y: 0, z: 0 }, + { x: 10, y: -4, z: 0 }, + ]); + assert.ok(cl.length > 0, 'has positive length'); + for (const s of [0, cl.length * 0.5, cl.length]) { + const r = cl.sampleAt(s); + assert.ok(finite(r.pos), `pos finite at s=${s}`); + assert.ok(finite(r.tangent), `tangent finite at s=${s}`); + assert.ok(Math.abs(r.tangent.length() - 1) < 1e-6, `tangent unit at s=${s}`); + } + // Endpoints still pin to the actual sample positions. + assert.ok(cl.sampleAt(0).pos.distanceTo(new (await loadThree()).Vector3(0, 0, 0)) < 1e-6); +}); + +test('duplicated point / zero-length span does not divide by zero', async () => { + // Index 2 duplicates index 1 → a zero-length span (sB == sC). Exercises the + // 1e-3 / 1e-6 nudges in sampleAt. + const { cl } = await build([ + { x: 0, y: 0, z: 0 }, + { x: 10, y: -3, z: 0 }, + { x: 10, y: -3, z: 0 }, // duplicate + { x: 20, y: -6, z: 0 }, + { x: 30, y: -9, z: 0 }, + ]); + const N = 50; + for (let k = 0; k <= N; k++) { + const s = (cl.length * k) / N; + const r = cl.sampleAt(s); + assert.ok(finite(r.pos), `pos finite at s=${s} (no div-by-zero)`); + assert.ok(finite(r.tangent), `tangent finite at s=${s}`); + assert.ok(finite(r.up) && finite(r.lateral), `up/lateral finite at s=${s}`); + assert.ok(Math.abs(r.tangent.length() - 1) < 1e-6, `tangent unit at s=${s}`); + } +}); + +test('leading duplicated point (zero-length first span) stays finite', async () => { + // First two points coincide → the stencil's first span is degenerate at s=0. + const { cl } = await build([ + { x: 0, y: 0, z: 0 }, + { x: 0, y: 0, z: 0 }, // duplicate of the start + { x: 10, y: -3, z: 0 }, + { x: 20, y: -6, z: 0 }, + ]); + for (const s of [0, 1e-9, cl.length * 0.5, cl.length]) { + const r = cl.sampleAt(s); + assert.ok(finite(r.pos), `pos finite at s=${s}`); + assert.ok(finite(r.tangent), `tangent finite at s=${s}`); + assert.ok(Math.abs(r.tangent.length() - 1) < 1e-6, `tangent unit at s=${s}`); + } +}); diff --git a/tests/seriesTally.test.js b/tests/seriesTally.test.js new file mode 100644 index 0000000..35ab3f9 --- /dev/null +++ b/tests/seriesTally.test.js @@ -0,0 +1,142 @@ +// SeriesTally unit tests — the cumulative cross-run scoring lifted out of +// display/main.js. Previously this logic (points folding, row derivation, champion +// flagging, sorting) had NO direct unit coverage — only end-to-end in +// tests/e2e/series.spec.js. SeriesTally is dependency-injected with the pure +// seriesPoints rule, so it loads under Node with no DOM/net/THREE. +// +// SeriesTally.js is an ES module (`export class`); protocol.js is a classic browser +// script with a CommonJS export tail, so it's pulled in via a default import of its +// module.exports object. +import test from 'node:test'; +import assert from 'node:assert'; +import { SeriesTally } from '../public/display/SeriesTally.js'; +import protocol from '../public/shared/protocol.js'; +const { seriesPoints } = protocol; + +// A field of 4 (the always-4 game field): names/colours for the rows. +const FIELD = [ + { peerIndex: 'a', name: 'Ann', colorIndex: 0, ai: false }, + { peerIndex: 'b', name: 'Bo', colorIndex: 1, ai: false }, + { peerIndex: 'c', name: 'Cy', colorIndex: 2, ai: true }, + { peerIndex: 'd', name: 'Di', colorIndex: 3, ai: true }, +]; + +// Build an engine-style getResults() object from a [playerId, rank, finished, time] +// list (rank is 1-based finishing order; finishers first, then DNFs). +function results(rows) { + return { results: rows.map(([playerId, rank, finished, time]) => ({ playerId, rank, finished, time })) }; +} + +test('a fresh tally starts in the lobby with no scores', () => { + const t = new SeriesTally(seriesPoints, 5); + assert.strictEqual(t.runIndex, 0); + assert.strictEqual(t.runsTotal, 5); + assert.strictEqual(t.seriesOver, false); + assert.strictEqual(t.scores.size, 0); +}); + +test('startNextRun advances the index; endCurrentRun flips over only on the final run', () => { + const t = new SeriesTally(seriesPoints, 3); + t.startNextRun(); assert.strictEqual(t.runIndex, 1); + assert.strictEqual(t.endCurrentRun(), false); + t.startNextRun(); assert.strictEqual(t.runIndex, 2); + assert.strictEqual(t.endCurrentRun(), false); + t.startNextRun(); assert.strictEqual(t.runIndex, 3); + assert.strictEqual(t.endCurrentRun(), true); + assert.strictEqual(t.seriesOver, true); +}); + +test('buildRows carries this-run points + cumulative score, keeping finish order mid-series', () => { + const t = new SeriesTally(seriesPoints, 3); + t.startNextRun(); + // Run 1 finishing order: a, b, c, d → points 4/3/2/1. + const rows = t.buildRows(results([['a', 1, true, 30.0], ['b', 2, true, 31.0], ['c', 3, true, 32.0], ['d', 4, true, 33.0]]), FIELD); + assert.deepStrictEqual(rows.map((r) => r.playerId), ['a', 'b', 'c', 'd']); // finish order + assert.deepStrictEqual(rows.map((r) => r.points), [4, 3, 2, 1]); + assert.deepStrictEqual(rows.map((r) => r.score), [4, 3, 2, 1]); // nothing banked yet + assert.deepStrictEqual(rows.map((r) => r.place), [1, 2, 3, 4]); + assert.strictEqual(rows[0].name, 'Ann'); + assert.strictEqual(rows[2].ai, true); +}); + +test('fold banks a run; the next run layers on top without double-counting', () => { + const t = new SeriesTally(seriesPoints, 3); + t.startNextRun(); + const r1 = results([['a', 1, true, 30], ['b', 2, true, 31], ['c', 3, true, 32], ['d', 4, true, 33]]); + t.fold(FIELD, r1); // bank 4/3/2/1 + assert.strictEqual(t.scores.get('a').points, 4); + assert.strictEqual(t.scores.get('d').points, 1); + t.startNextRun(); + // Run 2 order: d, c, b, a → +4/+3/+2/+1 on top of the banked 1/2/3/4. + const rows = t.buildRows(results([['d', 1, true, 29], ['c', 2, true, 30], ['b', 3, true, 31], ['a', 4, true, 32]]), FIELD); + const byId = new Map(rows.map((r) => [r.playerId, r])); + assert.strictEqual(byId.get('d').score, 1 + 4); // banked 1 + this run 4 + assert.strictEqual(byId.get('a').score, 4 + 1); // banked 4 + this run 1 + assert.strictEqual(byId.get('b').score, 3 + 2); + assert.strictEqual(byId.get('c').score, 2 + 3); +}); + +test('a DNF earns 0 points and reads as not-finished in the row', () => { + const t = new SeriesTally(seriesPoints, 1); + t.startNextRun(); + const rows = t.buildRows(results([['a', 1, true, 30], ['b', 2, true, 31], ['c', 3, false, null], ['d', 4, false, null]]), FIELD); + const byId = new Map(rows.map((r) => [r.playerId, r])); + assert.strictEqual(byId.get('c').points, 0); + assert.strictEqual(byId.get('c').finished, false); + assert.strictEqual(byId.get('a').points, 4); +}); + +test('when the series is over, rows sort by total score and the leader is champion', () => { + const t = new SeriesTally(seriesPoints, 2); + t.startNextRun(); + t.fold(FIELD, results([['a', 1, true, 30], ['b', 2, true, 31], ['c', 3, true, 32], ['d', 4, true, 33]])); // banked a4 b3 c2 d1 + t.startNextRun(); + t.endCurrentRun(); // 2 of 2 → seriesOver + // Final run: b wins it. Totals: a 4+? ... + // order b,a,c,d → +4/+3/+2/+1. Totals: a 4+3=7, b 3+4=7, c 2+2=4, d 1+1=2. + const rows = t.buildRows(results([['b', 1, true, 29], ['a', 2, true, 30], ['c', 3, true, 31], ['d', 4, true, 32]]), FIELD); + assert.strictEqual(t.seriesOver, true); + assert.deepStrictEqual(rows.map((r) => r.score), [7, 7, 4, 2]); // sorted by score desc + assert.deepStrictEqual(rows.map((r) => r.place), [1, 2, 3, 4]); // renumbered + // a and b tie at the top → co-champions; c and d are not. + assert.strictEqual(rows[0].champion, true); + assert.strictEqual(rows[1].champion, true); + assert.strictEqual(rows[2].champion, false); + assert.strictEqual(rows[3].champion, false); +}); + +test('rekey carries a player\'s banked points onto a new slot (cross-device reconnect)', () => { + const t = new SeriesTally(seriesPoints, 3); + t.startNextRun(); + t.fold(FIELD, results([['a', 1, true, 30], ['b', 2, true, 31], ['c', 3, true, 32], ['d', 4, true, 33]])); + assert.strictEqual(t.scores.get('a').points, 4); + t.rekey('a', 'z'); + assert.strictEqual(t.scores.has('a'), false); + assert.strictEqual(t.scores.get('z').points, 4); + t.rekey('missing', 'q'); // no-op when the old id was never scored + assert.strictEqual(t.scores.has('q'), false); +}); + +test('reset wipes scores/index/over but keeps the host runsTotal pick; setRunsTotal changes it', () => { + const t = new SeriesTally(seriesPoints, 5); + t.startNextRun(); + t.fold(FIELD, results([['a', 1, true, 30], ['b', 2, true, 31], ['c', 3, true, 32], ['d', 4, true, 33]])); + t.endCurrentRun(); + t.reset(); + assert.strictEqual(t.runIndex, 0); + assert.strictEqual(t.seriesOver, false); + assert.strictEqual(t.scores.size, 0); + assert.strictEqual(t.runsTotal, 5); // survives reset + t.setRunsTotal(7); + assert.strictEqual(t.runsTotal, 7); +}); + +test('stagePreview injects mid-series or final-board state for no-relay previews', () => { + const t = new SeriesTally(seriesPoints, 5); + t.stagePreview({ index: 2, total: 5, over: false, scores: { a: 7, b: 4 } }); + assert.strictEqual(t.runIndex, 2); + assert.strictEqual(t.runsTotal, 5); + assert.strictEqual(t.seriesOver, false); + assert.strictEqual(t.scores.get('a').points, 7); + assert.strictEqual(t.scores.get('b').points, 4); +}); diff --git a/tests/slopes.test.js b/tests/slopes.test.js index d8904f8..a5fb469 100644 --- a/tests/slopes.test.js +++ b/tests/slopes.test.js @@ -30,6 +30,22 @@ test('generateSlope is deterministic per seed and varies across seeds', async () assert.notDeepStrictEqual(generateSlope(0).pieces, generateSlope(1).pieces, 'seed 0 ≠ seed 1'); }); +test('obstacleRadius is the single source of truth for footprint radii', async () => { + const { obstacleRadius, generateSlope } = await load(); + assert.strictEqual(obstacleRadius('rock'), 0.85, 'rock footprint radius'); + assert.strictEqual(obstacleRadius('tree'), 0.7, 'tree footprint radius'); + // Every NON-'post' obstacle on a freshly generated slope resolves to the + // per-kind radius obstacleRadius() defines — generated defs carry no explicit + // radius, so the builder default (`o.radius || obstacleRadius(o.kind)`) IS this. + for (let seed = 0; seed < 50; seed++) { + for (const o of generateSlope(seed).obstacles) { + if (o.kind === 'post') continue; + const radius = o.radius || obstacleRadius(o.kind); + assert.strictEqual(radius, obstacleRadius(o.kind), `seed ${seed}: ${o.kind} radius`); + } + } +}); + test('every piece descends within a sane pitch band', async () => { const { generateSlope } = await load(); for (let seed = 0; seed < 150; seed++) {