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

@@ -4,7 +4,7 @@ import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudio
const manifest = {
events: { transponder_pass: { effect: "effects/transponder_pass.wav" }, start_tone: { effect: "effects/start_beep.wav" } },
voice_events: { sv: { drivers_ready: "Redo", start_sequence: "Start på signalen", track_call: "Stopp på banan" }, en: { drivers_ready: "Ready" } },
voice_events: { sv: { drivers_ready: "Redo", start_sequence: "Start på signalen", track_call: "Stopp på banan", clock_running: "Klockan går." }, en: { drivers_ready: "Ready" } },
};
test("race audio settings normalize volume, delay, language and switches", () => {
@@ -87,6 +87,34 @@ test("staggered start mode calls out each car in grid order, 3s apart, after the
]);
});
function createRecordingEngine(calls, settings = {}) {
return createRaceAudioEngine({
fetchImpl: async () => ({ ok: true, json: async () => manifest }),
audioFactory: (src) => ({ play: () => { calls.push(["effect", src.split("/").pop()]); return Promise.resolve(); }, volume: 1 }),
speak: async (text) => { calls.push(["voice", text]); return true; },
wait: async (ms) => calls.push(["wait", ms]),
getSettings: () => normalizeRaceAudioSettings({ startCountdownSeconds: 0, startLights: false, minStartDelay: 2, maxStartDelay: 2, randomStartDelay: false, ...settings }),
});
}
test("a flying start has no start procedure: horn, then 'clock is running' (EFRA 9.2b)", async () => {
const calls = [];
const engine = createRecordingEngine(calls, { startLights: true });
const states = [];
engine.subscribeStartLights((state) => states.push(state));
await engine.runStartSequence({ id: "q1", type: "qualification", startMode: "flying" }, { onTone: () => calls.push(["timing-start"]), skipCountdown: true });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(states, ["IDLE", "GRID", "GO"], "no lights and no random delay");
assert.deepEqual(calls, [["effect", "start_beep.wav"], ["timing-start"], ["voice", "Klockan går."]]);
});
test("after the prologue a grid start skips its own count-down and intro calls", async () => {
const calls = [];
const engine = createRecordingEngine(calls, { startCountdownSeconds: 30 });
await engine.runStartSequence({ id: "f1", type: "final", startMode: "position" }, { onTone: () => calls.push(["timing-start"]), skipCountdown: true, skipIntro: true });
assert.deepEqual(calls, [["wait", 2000], ["effect", "start_beep.wav"], ["timing-start"]], "only the 0-5 s delay and the horn are left");
});
test("a mass-start session never calls out individual cars, even if a grid order is passed", async () => {
const calls = [];
const engine = createRaceAudioEngine({
@@ -124,7 +152,7 @@ test("cancelling a pending start prevents the official start callback", async ()
test("event catalog covers every supported race-audio event", () => {
const keys = new Set(RACE_AUDIO_EVENT_DEFINITIONS.map((item) => item.key));
[
"grid_open", "grid_closed", "race_1_minute", "race_30_seconds", "race_10_seconds", "drivers_ready", "start_sequence", "start_tone", "false_start", "restart_race", "staggered_release", "warmup_start", "warmup_1_minute", "warmup_end",
"grid_open", "grid_closed", "race_1_minute", "race_30_seconds", "race_10_seconds", "drivers_ready", "start_sequence", "start_tone", "false_start", "restart_race", "staggered_release", "prologue_start", "prologue_minutes", "prologue_grid", "prologue_count", "prologue_hold", "prologue_resumed", "prologue_cancelled", "clock_running",
"transponder_pass", "lap_completed", "personal_best", "fastest_lap", "track_record", "training_stable_pace", "new_leader", "position_gain", "position_loss", "final_lap", "one_minute_remaining", "thirty_seconds_remaining", "ten_seconds_remaining", "time_expired", "race_finished",
"track_call", "track_clear", "yellow_flag", "red_flag", "race_paused", "race_resumed", "marshal_warning", "marshal_required", "penalty", "stop_and_go", "drive_through", "disqualified",
"pit_lane_open", "pit_lane_closed", "entering_pits", "leaving_pits", "pit_speed_warning",