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++) {