Add staggered-start car call-outs and a per-driver done indicator

Staggered-start qualifying heats now announce each car's transponder
number in grid order (~3s apart) right after the official start,
matching what a track announcer does when releasing cars one at a
time; mass/position starts are unaffected. The Timing leaderboard and
every overlay variant now also show a green "Done" flag next to a
driver's name once their own personal timing window (which already
started at their own first crossing) has elapsed, even while the
session is still running for other drivers.

Also confirmed two things already worked correctly: finals already
always start everyone together with the shared countdown/lights/tone
sequence, and practice's time-up announcement already says "finish
the lap you are on" - only a "2 minutes remaining" cue is genuinely
missing from the existing 1min/30s/10s ladder, left as an open
question for the user.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-10 21:31:01 +02:00
parent ba2d78779f
commit 1565baab2a
12 changed files with 167 additions and 9 deletions

View File

@@ -66,6 +66,43 @@ test("start lights, random delay and tone precede the official start callback",
assert.deepEqual(calls.slice(-3), [["wait", 2500], ["effect", "/race-sounds/sounds/effects/start_beep.wav"], ["timing-start"]]);
});
test("staggered start mode calls out each car in grid order, 3s apart, after the official start", async () => {
const calls = [];
const engine = createRaceAudioEngine({
fetchImpl: async () => ({ ok: true, json: async () => ({ ...manifest, voice_events: { sv: { ...manifest.voice_events.sv, staggered_release: "Bil {tp}." } } }) }),
audioFactory: (src) => ({ play: () => { calls.push(["effect", src]); 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: 0, maxStartDelay: 0 }),
});
await engine.runStartSequence(
{ id: "q1h1", type: "qualification", startMode: "staggered" },
{ onTone: () => calls.push(["timing-start"]), staggerOrder: ["111", "222", "333"] }
);
const afterStart = calls.slice(calls.findIndex((call) => call[0] === "timing-start") + 1);
assert.deepEqual(afterStart, [
["voice", "Bil 111."], ["wait", 3000],
["voice", "Bil 222."], ["wait", 3000],
["voice", "Bil 333."],
]);
});
test("a mass-start session never calls out individual cars, even if a grid order is passed", async () => {
const calls = [];
const engine = createRaceAudioEngine({
fetchImpl: async () => ({ ok: true, json: async () => manifest }),
audioFactory: () => ({ play: () => Promise.resolve(), volume: 1 }),
speak: async (text) => { calls.push(text); return true; },
wait: async () => {},
getSettings: () => normalizeRaceAudioSettings({ startCountdownSeconds: 0, startLights: false, minStartDelay: 0, maxStartDelay: 0 }),
});
await engine.runStartSequence(
{ id: "final", type: "final", startMode: "mass" },
{ staggerOrder: ["111", "222"] }
);
assert.ok(!calls.some((text) => text.startsWith("Bil") || text.startsWith("Car")), "no per-car call-out for a mass start: " + JSON.stringify(calls));
});
test("cancelling a pending start prevents the official start callback", async () => {
let releaseDelay;
let started = false;
@@ -87,7 +124,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",
"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",
"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",