feat(race-day): start heats with an EFRA/IFMAR count-down and flying start

Race Day Start now runs a count-down (Go, 30 s - 5 min, default per
event) with calls every minute, 30 s and 10 s, grid call and 9..3
count for grid starts, hold/resume/+1 min/to 30 s/to 10 s/Go now, and
starts the heat by itself when it runs out. The transponder check runs
during the count-down, replacing the separate warm-up.

Adds a flying start mode (horn + clock is running, own clock per car)
as the qualifying default, and the latest personal clock start rules
from EFRA 9.3 / IFMAR 4.2.3 for flying and staggered starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-30 21:27:20 +02:00
parent 1bc75f8ec1
commit 5bfb67ea02
27 changed files with 884 additions and 334 deletions

View File

@@ -0,0 +1,121 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
recordTransponderSighting, buildWarmupCheck, createPrologue, getPrologueRemainingMs, holdPrologue, resumePrologue,
setPrologueRemaining, takeDuePrologueCall, getPrologueHandoverMs, normalizePrologueSec, formatPrologueTime,
PROLOGUE_OPTIONS_SEC, getLatestClockStartTs, usesPersonalClock,
} from "../src/prologue_logic.js";
const entrants = [
{ id: "a", name: "Anna", transponder: "1001" },
{ id: "b", name: "Bo", transponder: "1002" },
{ id: "c", name: "Cia", transponder: "" },
];
test("a driver is green only once their transponder crosses after the warm-up started", () => {
const sightings = new Map();
recordTransponderSighting(sightings, "1001", 500); // before the warm-up - must not count
recordTransponderSighting(sightings, "1002", 1500);
let check = buildWarmupCheck({ entrants, sightings, since: 1000 });
assert.deepEqual(check.rows.map((row) => row.seen), [false, true, false]);
assert.equal(check.seenCount, 1);
assert.equal(check.total, 3);
assert.deepEqual(check.missing.map((row) => row.name), ["Anna", "Cia"], "a driver with no transponder is never green");
recordTransponderSighting(sightings, "1001", 2000);
check = buildWarmupCheck({ entrants, sightings, since: 1000 });
assert.equal(check.rows[0].seen, true);
assert.equal(check.rows[0].lastSeenAt, 2000);
});
test("unknown transponders are listed newest first, naming the owner if it belongs to another driver", () => {
const sightings = new Map();
recordTransponderSighting(sightings, "9999", 1100);
recordTransponderSighting(sightings, "7777", 1200);
recordTransponderSighting(sightings, "1001", 1300);
recordTransponderSighting(sightings, "5555", 900); // before the warm-up
const check = buildWarmupCheck({ entrants, drivers: [...entrants, { id: "x", name: "Xerxes", transponder: "7777" }], sightings, since: 1000 });
assert.deepEqual(check.unknownTransponders.map((item) => [item.transponder, item.ownerName]), [["7777", "Xerxes"], ["9999", ""]]);
});
test("sightings count repeat passings and stay bounded", () => {
const sightings = new Map();
recordTransponderSighting(sightings, " 42 ", 1);
recordTransponderSighting(sightings, "42", 2);
assert.equal(sightings.get("42").count, 2);
recordTransponderSighting(sightings, "", 3);
assert.equal(sightings.size, 1);
for (let i = 0; i < 600; i += 1) recordTransponderSighting(sightings, `t${i}`, 10 + i);
assert.equal(sightings.size, 500);
assert.equal(sightings.has("t599"), true, "the newest are kept");
});
test("prologue counts down, holds and resumes without losing time", () => {
let prologue = createPrologue("s1", 120000, 0);
assert.equal(getPrologueRemainingMs(prologue, 30000), 90000);
prologue = holdPrologue(prologue, 30000);
assert.equal(getPrologueRemainingMs(prologue, 500000), 90000, "a held count-down doesn't move");
prologue = resumePrologue(prologue, 500000);
assert.equal(getPrologueRemainingMs(prologue, 510000), 80000);
assert.equal(getPrologueRemainingMs(prologue, 999999), 0);
assert.equal(getPrologueRemainingMs(null), 0);
});
// Runs a prologue second by second and collects what the announcer says.
function collectCalls(totalMs, startMode) {
let prologue = createPrologue("s1", totalMs, 0);
const calls = [];
for (let now = 0; now <= totalMs; now += 250) {
const next = takeDuePrologueCall(prologue, startMode, now);
prologue = next.prologue;
if (next.call) calls.push(next.call.variables?.seconds ?? next.call.variables?.minutes ?? next.call.event);
}
return calls;
}
test("flying start calls the EFRA 9.2b marks: 1 min, 30 s, 10 s (2 min is the opening call)", () => {
assert.deepEqual(collectCalls(120000, "flying"), ["race_1_minute", "race_30_seconds", "race_10_seconds"]);
assert.deepEqual(collectCalls(300000, "flying"), [4, 3, 2, "race_1_minute", "race_30_seconds", "race_10_seconds"]);
assert.deepEqual(collectCalls(30000, "flying"), ["race_10_seconds"]);
});
test("grid start calls cars to the grid at 30 s and counts 10..3 second by second (IFMAR 4.2.5-4.2.6)", () => {
assert.deepEqual(collectCalls(90000, "position"), ["race_1_minute", "prologue_grid", "race_10_seconds", 9, 8, 7, 6, 5, 4, 3]);
assert.equal(getPrologueHandoverMs("position"), 3000, "the start signal follows 0-5 s after the 3 s mark");
assert.equal(getPrologueHandoverMs("flying"), 0);
});
test("jumping to 10 s marks the skipped calls done and says only the latest", () => {
let prologue = createPrologue("s1", 120000, 0);
prologue = setPrologueRemaining(prologue, 10000, 1000);
const { prologue: after, call } = takeDuePrologueCall(prologue, "flying", 1000);
assert.equal(call.event, "race_10_seconds");
assert.deepEqual(after.announced, ["m1", "s30", "s10"]);
// +1 min from there brings the 1 min / 30 s calls back.
const back = setPrologueRemaining(after, 70000, 1000);
assert.deepEqual(back.announced, []);
assert.equal(getPrologueRemainingMs(back, 1000), 70000);
});
test("prologue lengths snap to the offered choices", () => {
assert.deepEqual(PROLOGUE_OPTIONS_SEC, [0, 30, 60, 90, 120, 180, 300]);
assert.equal(normalizePrologueSec(90), 90);
assert.equal(normalizePrologueSec("0"), 0);
assert.equal(normalizePrologueSec(45), 120);
assert.equal(normalizePrologueSec(undefined), 120);
assert.equal(formatPrologueTime(90000, "sv"), "1 minut och 30 sekunder");
assert.equal(formatPrologueTime(120000, "en"), "2 minutes");
assert.equal(formatPrologueTime(30000, "sv"), "30 sekunder");
});
test("latest personal clock start follows EFRA 9.3 / IFMAR 4.2.3", () => {
const passings = [{ timestamp: 30000, lapMs: 20000, validLap: true }, { timestamp: 25000, lapMs: 3000, validLap: false }];
// Flying: 150 % of the first completed lap after the horn.
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "flying" }, { passings }), 10000 + 30000);
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "flying" }, { passings: [] }), null, "unknown until someone completes a lap");
// Staggered: when the first car completes a lap, or 1 minute after the start.
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "staggered" }, { passings }), 30000);
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "staggered" }, { passings: [] }), 70000);
assert.equal(getLatestClockStartTs({ startedAt: 10000, startMode: "mass" }, { passings }), null);
assert.equal(usesPersonalClock("flying"), true);
assert.equal(usesPersonalClock("position"), false);
});