Courtside MVP: engine, web app, Docker + Cloudflare Tunnel deploy, docs
Tournament engine (pools, single/double elimination, standings with proper tiebreaks, withdrawals, two-court queue with ETAs and alerts), a single-process Node server with SQLite via node:sqlite and a WebSocket live board, organizer desk, QR landing page that follows the tournament phase, Dockerfile and compose with cloudflared, and documentation for deploying and running a day. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01MB7nCCAscYsb3zzkT6LHZi
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Tournament, validateSet } from '../engine/tournament.js';
|
||||
import { CourtEngine } from '../engine/courts.js';
|
||||
import { roundRobin, makePools, singleElimination, doubleElimination, seedOrder, resetIds } from '../engine/formats.js';
|
||||
|
||||
const setup = (n, opts = {}) => {
|
||||
resetIds();
|
||||
const t = new Tournament({ name: 'test', ...opts });
|
||||
for (let i = 1; i <= n; i++) t.registerTeam({ name: `T${i}`, players: 2 });
|
||||
return t;
|
||||
};
|
||||
const ids = t => [...t.teams.keys()];
|
||||
|
||||
test('round robin: every pair once, each team once per round', () => {
|
||||
resetIds();
|
||||
const ms = roundRobin(['a', 'b', 'c', 'd', 'e']);
|
||||
assert.equal(ms.length, 10);
|
||||
const pairs = new Set(ms.map(m => [m.teamA, m.teamB].sort().join()));
|
||||
assert.equal(pairs.size, 10);
|
||||
for (let r = 1; r <= 5; r++) {
|
||||
const teams = ms.filter(m => m.round === r).flatMap(m => [m.teamA, m.teamB]);
|
||||
assert.equal(new Set(teams).size, teams.length);
|
||||
}
|
||||
});
|
||||
|
||||
test('pools: snake seeding of 12 into 3 pools', () => {
|
||||
const p = makePools(Array.from({ length: 12 }, (_, i) => `s${i + 1}`), 4);
|
||||
assert.deepEqual(p.map(x => x.teams), [['s1', 's6', 's7', 's12'], ['s2', 's5', 's8', 's11'], ['s3', 's4', 's9', 's10']]);
|
||||
});
|
||||
|
||||
test('seed order for 8 is the standard 1-8, 4-5, 2-7, 3-6', () => {
|
||||
assert.deepEqual(seedOrder(8), [1, 8, 4, 5, 2, 7, 3, 6]);
|
||||
});
|
||||
|
||||
test('single elimination with 6 teams gives top two seeds byes into round 2', () => {
|
||||
resetIds();
|
||||
const { matches, size } = singleElimination(['s1', 's2', 's3', 's4', 's5', 's6']);
|
||||
assert.equal(size, 8);
|
||||
const r2 = matches.filter(m => m.round === 2);
|
||||
assert.deepEqual(r2.map(m => [m.teamA, m.teamB]).flat().filter(Boolean).sort(), ['s1', 's2']);
|
||||
assert.equal(matches.filter(m => m.status === 'bye').length, 2);
|
||||
});
|
||||
|
||||
test('scores are validated against rally rules', () => {
|
||||
const r = { pointsTo: 21, winBy: 2, cap: 25 };
|
||||
assert.ok(validateSet(21, 19, r));
|
||||
assert.ok(validateSet(25, 24, r));
|
||||
assert.ok(validateSet(23, 21, r));
|
||||
assert.throws(() => validateSet(21, 20, r));
|
||||
assert.throws(() => validateSet(24, 20, r));
|
||||
assert.throws(() => validateSet(26, 24, r));
|
||||
assert.throws(() => validateSet(19, 17, r));
|
||||
});
|
||||
|
||||
test('withdrawal in forfeit mode: remaining games lost, standings recompute', () => {
|
||||
const t = setup(4);
|
||||
t.closeRegistration(); t.generatePools(4);
|
||||
const [a, b, c, d] = ids(t);
|
||||
const m = t.matches.find(x => [x.teamA, x.teamB].includes(a) && [x.teamA, x.teamB].includes(b));
|
||||
t.recordResult(m.id, [[21, 10]].map(s => m.teamA === a ? s : s.reverse()));
|
||||
t.withdrawTeam(a, { mode: 'forfeit' });
|
||||
const rows = t.standings('A');
|
||||
assert.equal(rows.at(-1).teamId, a);
|
||||
assert.equal(rows.at(-1).status, 'withdrawn');
|
||||
const forfeits = t.matches.filter(x => x.status === 'forfeit');
|
||||
assert.equal(forfeits.length, 2);
|
||||
assert.ok(forfeits.every(x => x.winner !== a));
|
||||
});
|
||||
|
||||
test('withdrawal in void mode removes the team\'s played games from standings', () => {
|
||||
const t = setup(4);
|
||||
t.closeRegistration(); t.generatePools(4);
|
||||
const [a, b] = ids(t);
|
||||
const m = t.matches.find(x => [x.teamA, x.teamB].includes(a) && [x.teamA, x.teamB].includes(b));
|
||||
t.recordResult(m.id, m.teamA === a ? [[21, 10]] : [[10, 21]]);
|
||||
t.withdrawTeam(a, { mode: 'void' });
|
||||
const rowB = t.standings('A').find(r => r.teamId === b);
|
||||
assert.equal(rowB.played, 0);
|
||||
assert.equal(t.matches.filter(x => x.status === 'void').length, 3);
|
||||
});
|
||||
|
||||
test('three-way circular head-to-head falls through to the next tiebreak', () => {
|
||||
const t = setup(3, { rules: { tiebreaks: ['headToHead', 'pointDiff'] } });
|
||||
t.closeRegistration(); t.generatePools(3);
|
||||
const [a, b, c] = ids(t);
|
||||
const find = (x, y) => t.matches.find(m => [m.teamA, m.teamB].includes(x) && [m.teamA, m.teamB].includes(y));
|
||||
const win = (x, y, s) => { const m = find(x, y); t.recordResult(m.id, m.teamA === x ? [s] : [[s[1], s[0]]]); };
|
||||
win(a, b, [21, 10]); win(b, c, [21, 15]); win(c, a, [21, 19]);
|
||||
const rows = t.standings('A');
|
||||
assert.deepEqual(rows.map(r => r.w), [1, 1, 1]);
|
||||
assert.equal(rows[0].teamId, a); // best point diff (+9)
|
||||
assert.equal(rows[0].decidedBy, 'pointDiff');
|
||||
});
|
||||
|
||||
test('court engine: two courts, no team on two courts, alerts once per match', () => {
|
||||
let clock = 0;
|
||||
const t = setup(8, { stages: ['pool'] });
|
||||
t.closeRegistration(); t.generatePools(4);
|
||||
const eng = new CourtEngine(t, { now: () => clock });
|
||||
eng.start();
|
||||
assert.equal(eng.courts.filter(c => c.matchId).length, 2);
|
||||
let guard = 0;
|
||||
while (t.phase === 'live' && guard++ < 100) {
|
||||
const busy = eng.courts.filter(c => c.matchId).flatMap(c => { const m = t.match(c.matchId); return [m.teamA, m.teamB]; });
|
||||
assert.equal(new Set(busy).size, busy.length, 'a team is on two courts');
|
||||
const c = eng.courts.find(c => c.matchId);
|
||||
clock += 12 * 60_000;
|
||||
eng.recordResult(c.matchId, [[21, 15]]);
|
||||
}
|
||||
assert.equal(t.phase, 'final');
|
||||
const upNow = eng.alerts.filter(a => a.kind === 'up_now');
|
||||
assert.equal(upNow.length, 12);
|
||||
assert.equal(new Set(upNow.map(a => a.match)).size, 12);
|
||||
});
|
||||
|
||||
test('court engine: paused court is skipped, resume picks up the queue', () => {
|
||||
let clock = 0;
|
||||
const t = setup(4, { stages: ['pool'] });
|
||||
t.closeRegistration(); t.generatePools(4);
|
||||
const eng = new CourtEngine(t, { now: () => clock });
|
||||
eng.start();
|
||||
eng.pauseCourt(2, 'rain');
|
||||
clock += 10 * 60_000;
|
||||
eng.recordResult(eng.courts[0].matchId, [[21, 5]]);
|
||||
eng.recordResult(eng.courts[1].matchId, [[21, 5]]);
|
||||
assert.equal(eng.courts[1].matchId, null);
|
||||
assert.ok(eng.courts[0].matchId);
|
||||
eng.resumeCourt(2);
|
||||
assert.ok(eng.courts[1].matchId);
|
||||
});
|
||||
|
||||
test('double elimination with 6 teams (byes) plays to a champion', () => {
|
||||
const t = setup(6, { stages: ['bracket'] });
|
||||
t.closeRegistration();
|
||||
t.generateBracket(ids(t), { type: 'double' });
|
||||
let clock = 0;
|
||||
const eng = new CourtEngine(t, { now: () => clock });
|
||||
eng.start();
|
||||
let guard = 0;
|
||||
while (t.phase === 'live' && guard++ < 100) {
|
||||
const c = eng.courts.find(c => c.matchId);
|
||||
assert.ok(c, 'engine stalled');
|
||||
clock += 10 * 60_000;
|
||||
const m = t.match(c.matchId);
|
||||
// lower seed (later id) always loses, except grand final where LB side wins to force the reset
|
||||
const lbWins = m.label === 'Grand final';
|
||||
eng.recordResult(m.id, lbWins ? [[10, 21]] : [[21, 10]]);
|
||||
}
|
||||
assert.equal(t.phase, 'final');
|
||||
const reset = t.matches.find(m => m.label === 'Bracket reset');
|
||||
assert.equal(reset.status, 'final');
|
||||
assert.equal(t.champion(), t.teamName(reset.winner));
|
||||
});
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end smoke test against a running server. Usage: BASE=http://localhost:3000 ADMIN_PASSWORD=... test/smoke.sh
|
||||
set -euo pipefail
|
||||
BASE=${BASE:-http://localhost:3123}
|
||||
PW=${ADMIN_PASSWORD:-test}
|
||||
J=$(mktemp); trap 'rm -f $J' EXIT
|
||||
c() { curl -s -b "$J" -c "$J" "$@"; }
|
||||
fail() { echo "FAIL: $*"; exit 1; }
|
||||
|
||||
echo "· login"
|
||||
c -o /dev/null -w '%{http_code}' -X POST "$BASE/admin/login" --data-urlencode "password=$PW" --data-urlencode "next=/admin" | grep -q 303 || fail login
|
||||
echo "· create tournament"
|
||||
LOC=$(c -o /dev/null -w '%{redirect_url}' -X POST "$BASE/admin/new" --data-urlencode "name=Smoke Test 2s" --data-urlencode "courtCount=2" --data-urlencode "teamSize=2" --data-urlencode "pointsTo=21" --data-urlencode "winBy=2" --data-urlencode "cap=25" --data-urlencode "bestOf=1" --data-urlencode "stages=pool,bracket")
|
||||
SLUG=$(echo "$LOC" | sed -E 's#.*/admin/t/([^?]+).*#\1#'); echo " slug=$SLUG"
|
||||
echo "· public register 8 teams"
|
||||
for n in "Net Gains" "Block Party" "Dig It" "Kiss My Ace" "Setting Ducks" "The Spikers" "Sandbaggers" "Beach Please"; do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/t/$SLUG/register" --data-urlencode "name=$n" --data-urlencode "captain=Cap" --data-urlencode "players=2")
|
||||
[ "$code" = 303 ] || fail "register $n -> $code"
|
||||
done
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$BASE/t/$SLUG/register" --data-urlencode "name=net gains" --data-urlencode "players=2" | grep -q 400 || fail "duplicate accepted"
|
||||
curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));if(s.teams.length!==8)process.exit(1);if(JSON.stringify(s).includes("phone"))process.exit(2)' || fail "state.json teams/privacy"
|
||||
echo "· close, pools, live"
|
||||
c -o /dev/null -X POST "$BASE/admin/t/$SLUG/phase" --data-urlencode "phase=closed"
|
||||
c -o /dev/null -w '%{redirect_url}\n' -X POST "$BASE/admin/t/$SLUG/generate" --data-urlencode "what=pools" --data-urlencode "poolSize=4" | grep -q msg= || fail pools
|
||||
c -o /dev/null -w '%{redirect_url}\n' -X POST "$BASE/admin/t/$SLUG/phase" --data-urlencode "phase=live" | grep -q msg= || fail live
|
||||
S=$(curl -s "$BASE/t/$SLUG/state.json")
|
||||
echo "$S" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));if(s.phase!=="live"||s.courts.filter(c=>c.match).length!==2)process.exit(1);console.log(" courts:",s.courts.map(c=>`${c.court}: ${c.match.a.name} v ${c.match.b.name}`).join(" | "));console.log(" up next:",s.upNext.map(u=>`${u.a.name} v ${u.b.name} C${u.court} ~${u.etaMin}m`).join("; "))' || fail "live state"
|
||||
echo "· live score then final on court 1"
|
||||
M=$(echo "$S" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));console.log(s.courts[0].match.id)')
|
||||
c -o /dev/null -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/live" -d "{\"match\":\"$M\",\"a\":18,\"b\":14}"
|
||||
curl -s "$BASE/t/$SLUG/state.json" | grep -q '"live":\[18,14\]' || fail "live score not on board"
|
||||
c -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$M\",\"sets\":\"21-14\"}" | grep -q '"ok":true' || fail "final score"
|
||||
c -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$M\",\"sets\":\"21-20\"}" | grep -q 'win by 2' || fail "bad score accepted"
|
||||
echo "· play out all pool matches"
|
||||
for i in $(seq 1 20); do
|
||||
NEXT=$(curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));const c=s.courts.find(c=>c.match);console.log(c?c.match.id:"")')
|
||||
[ -z "$NEXT" ] && break
|
||||
c -o /dev/null -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$NEXT\",\"sets\":\"21-$((10+i%9))\"}"
|
||||
done
|
||||
echo "· withdraw a team, bracket, finish"
|
||||
TID=$(curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));console.log(s.teams[7].id)')
|
||||
c -o /dev/null -X POST "$BASE/admin/t/$SLUG/withdraw" --data-urlencode "id=$TID" --data-urlencode "mode=forfeit"
|
||||
c -o /dev/null -w '%{redirect_url}\n' -X POST "$BASE/admin/t/$SLUG/generate" --data-urlencode "what=bracket" --data-urlencode "perPool=2" --data-urlencode "wildcards=0" --data-urlencode "type=single" --data-urlencode "thirdPlace=1" | grep -q msg= || fail bracket
|
||||
for i in $(seq 1 12); do
|
||||
NEXT=$(curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));const c=s.courts.find(c=>c.match);console.log(c?c.match.id:"")')
|
||||
[ -z "$NEXT" ] && break
|
||||
c -o /dev/null -H 'content-type: application/json' -X POST "$BASE/admin/t/$SLUG/score" -d "{\"match\":\"$NEXT\",\"sets\":\"21-$((12+i%8))\"}"
|
||||
done
|
||||
curl -s "$BASE/t/$SLUG/state.json" | node -e 'const s=JSON.parse(require("fs").readFileSync(0));if(s.phase!=="final"||!s.champion)process.exit(1);console.log(" champion:",s.champion,"| withdrawn:",s.withdrawn.map(t=>t.name).join(","))' || fail "did not reach final"
|
||||
echo "· pages render"
|
||||
for p in "/" "/t/$SLUG" "/t/$SLUG/display" "/admin/t/$SLUG" "/admin/t/$SLUG/qr"; do
|
||||
code=$(c -o /dev/null -w '%{http_code}' "$BASE$p"); [ "$code" = 200 ] || fail "$p -> $code"
|
||||
done
|
||||
CODE=$(c "$BASE/admin/t/$SLUG" | grep -oE '/team/[A-Z2-9]{4}' | head -1)
|
||||
[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/t/$SLUG$CODE")" = 200 ] || fail "team page"
|
||||
echo "· websocket delivers state"
|
||||
node -e '
|
||||
const WebSocket=require("ws");const ws=new WebSocket(process.argv[1].replace("http","ws")+"/ws/"+process.argv[2]);
|
||||
ws.on("message",m=>{const s=JSON.parse(m);console.log(" ws ok, phase",s.phase);ws.close();process.exit(0);});
|
||||
ws.on("error",e=>{console.log("ws error",e.message);process.exit(1)});setTimeout(()=>process.exit(1),3000)' "$BASE" "$SLUG"
|
||||
echo "· delete"
|
||||
c -o /dev/null -X POST "$BASE/admin/t/$SLUG/delete" --data-urlencode "confirm=yes"
|
||||
[ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/t/$SLUG")" = 404 ] || fail "not deleted"
|
||||
echo "SMOKE OK"
|
||||
Reference in New Issue
Block a user