diff --git a/app.py b/app.py index ceac762..6e98fc0 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,7 @@ from ui_builder import install_ui_builder from khl_site.khl_data_center import APP as khl_site_app BASE_DIR = Path(__file__).resolve().parent -BUILD_VERSION = "2026.08.24.10" +BUILD_VERSION = "2026.08.24.11" # compatibility: BUILD_VERSION = "2026.08.24.8" # compatibility: BUILD_VERSION = "2026.08.24.7" # compatibility: BUILD_VERSION = "2026.08.24.6" diff --git a/tests/test_build108_native_pause_no_resync.py b/tests/test_build108_native_pause_no_resync.py new file mode 100644 index 0000000..229cd8d --- /dev/null +++ b/tests/test_build108_native_pause_no_resync.py @@ -0,0 +1,66 @@ +from pathlib import Path + +APP_JS = Path(__file__).resolve().parents[1] / "ui_builder" / "static" / "app.js" +TEXT = APP_JS.read_text(encoding="utf-8") + + +def block(start_marker: str, end_marker: str) -> str: + start = TEXT.index(start_marker) + end = TEXT.index(end_marker, start) + return TEXT[start:end] + + +def test_game_clock_helper_only_seeds_on_set(): + helper = block( + "function vmixGameCountdownControlCommands", + "function buildShortcutRuntimeContext", + ) + pause_branch = helper[helper.index('if (action === "pause"'):helper.index('if (action === "set"')] + set_branch = helper[helper.index('if (action === "set"'):helper.index('return [{ Function: "StartCountdown"')] + start_branch = helper[helper.index('return [{ Function: "StartCountdown"'):] + + assert 'Function: "PauseCountdown"' in pause_branch + assert 'ChangeCountdown' not in pause_branch + assert 'SetCountdown' not in pause_branch + assert 'StopCountdown' not in pause_branch + + assert 'Function: "ChangeCountdown"' in set_branch + assert 'Function: "StartCountdown"' not in set_branch + assert 'Function: "PauseCountdown"' not in set_branch + + assert 'Function: "StartCountdown"' in start_branch + assert 'ChangeCountdown' not in start_branch + assert 'PauseCountdown' not in start_branch + + +def test_space_game_timer_uses_control_only_helper(): + section = block('case "hockey_vmix_timers_start":', 'case "delay":') + game = section[:section.index('if (step.sync_vmix_penalties)')] + assert 'vmixGameCountdownControlCommands' in game + assert '"pause"' in game + assert '"start"' in game + # Generic helper may still be used later for penalty countdowns, but never in main game branch. + assert 'vmixCountdownSyncCommands' not in game + + +def test_explicit_time_change_seeds_even_before_first_space(): + section = block( + 'async function syncActiveVmixGameCountdown', + 'function configuredHockeyVmixGameCountdownTargets', + ) + assert 'explicitSeedEvent' in section + assert 'timer_set_time' in section + assert 'timer_reset' in section + assert 'state.config.shortcut_sequences' in section + assert 'vmixGameCountdownControlCommands(input, selectedName, valueProvider, "set")' in section + + +def test_pause_event_never_writes_browser_time_to_vmix(): + section = block( + 'async function syncActiveVmixGameCountdown', + 'function configuredHockeyVmixGameCountdownTargets', + ) + pause = section[section.index('eventName === "timer_pause"'):section.index('timer_stop', section.index('eventName === "timer_pause"'))] + assert '"pause"' in pause + assert '"set"' not in pause + assert 'ChangeCountdown' not in pause diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 1397c87..5576ef4 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -4196,6 +4196,23 @@ function startCustomTooltips() { ]; } + function vmixGameCountdownControlCommands(input, selectedName, millisecondsProvider, action = "start") { + // BUILD108: the native vMix game clock is seeded only on an explicit time change. + // Space must never re-seed the countdown: Start/Resume only starts it and Pause/Stop + // only pauses it. This keeps the already-running native vMix countdown continuous + // and removes jumps caused by writing the browser value during a pause transition. + const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() }; + if (!target.Input || !target.SelectedName) return []; + if (action === "pause" || action === "stop") { + return [{ Function: "PauseCountdown", ...target }]; + } + if (action === "set") { + const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider; + return [{ Function: "ChangeCountdown", ...target, Value: vmixCountdownValue(milliseconds) }]; + } + return [{ Function: "StartCountdown", ...target }]; + } + function buildShortcutRuntimeContext(sequence = null) { const timers = {}; state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => { @@ -4754,25 +4771,50 @@ function startCustomTooltips() { async function syncActiveVmixGameCountdown(component, timerState, eventName) { if (!component?.action_id || eventName === "timer_tick") return false; + const explicitSeedEvent = ["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time", "timer_restart"].includes(eventName); + const candidateSteps = []; + if (explicitSeedEvent) { + // BUILD108: an explicit time edit must seed the configured vMix game clock even + // before Space has ever been pressed. This is the moment where 20:00 / 05:00 + // (or any manual value) is transferred to vMix. + (state.config.shortcut_sequences || []).forEach((sequence) => { + if (sequence?.enabled === false) return; + (sequence.steps || []).forEach((step) => { + if (step?.enabled === false || step?.type !== "hockey_vmix_timers_start") return; + candidateSteps.push(step); + }); + }); + } else { + Array.from(state.activeHockeyVmixTimerSteps).forEach((stepId) => { + const step = hockeyTimerSyncStepById(stepId); + if (step) candidateSteps.push(step); + }); + } + const commands = []; - for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) { - const step = hockeyTimerSyncStepById(stepId); + const seenTargets = new Set(); + for (const step of candidateSteps) { if (!step || step.enabled === false || !step.sync_vmix_game || step.game_vmix_mode !== "countdown") continue; if (String(step.game_timer_action_id || "hockey_game_timer") !== String(component.action_id)) continue; const input = String(step.game_vmix_input || "").trim(); const selectedName = String(step.game_vmix_selected_name || "").trim(); if (!input || !selectedName) continue; + const targetKey = `${input}\u0000${selectedName}`; + if (seenTargets.has(targetKey)) continue; + seenTargets.add(targetKey); const valueProvider = () => timerState.currentMs; - if (["timer_start", "timer_restart", "timer_resume"].includes(eventName)) { - commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "start")); + + if (["timer_start", "timer_resume"].includes(eventName)) { + commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "start")); + } else if (eventName === "timer_restart") { + commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "set")); + commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "start")); } else if (eventName === "timer_pause") { - commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "pause")); + commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "pause")); } else if (["timer_stop", "timer_finished"].includes(eventName)) { - commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop")); + commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "stop")); } else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) { - commands.push(...vmixCountdownSyncCommands( - input, selectedName, valueProvider, timerState.running ? "start" : "set" - )); + commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "set")); } } if (!commands.length) return false; @@ -4805,12 +4847,13 @@ function startCustomTooltips() { async function syncConfiguredScoreboardCountdownsToRuntime() { const commands = []; configuredHockeyVmixGameCountdownTargets().forEach(({ input, selectedName, timerState }) => { - commands.push(...vmixCountdownSyncCommands( - input, - selectedName, - () => timerState.currentMs, - timerState.running ? "start" : "set" - )); + // F1 only seeds a stopped/paused game clock. If it is already running, + // do not touch its native vMix time at all. + if (!timerState.running) { + commands.push(...vmixGameCountdownControlCommands( + input, selectedName, () => timerState.currentMs, "set" + )); + } }); if (!commands.length) return { ok: true, applied: 0, requested: 0 }; // Await only WebSocket dispatch (never vMix ACK) so the countdown reseed reaches the @@ -5335,11 +5378,14 @@ function startCustomTooltips() { setVmixTimerMirror(step.game_timer_action_id || "hockey_game_timer", step.game_vmix_input, step.game_vmix_selected_name); commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) }); } else if (pausing) { - commands.push(...vmixCountdownSyncCommands( + // BUILD108 Space pause: native pause only, absolutely no time writeback. + commands.push(...vmixGameCountdownControlCommands( step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "pause" )); } else { - commands.push(...vmixCountdownSyncCommands( + // BUILD108 Space start/resume: native start only. The countdown was seeded + // when the operator set/reset the period time (or by F1 before first start). + commands.push(...vmixGameCountdownControlCommands( step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "start" )); } @@ -13921,7 +13967,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
Режим Countdown vMix: веб-таймер является источником времени. Перед Start/Resume vMix выполняет ChangeCountdown(время Runtime) → StartCountdown; при Pause/Stop выполняет PauseCountdown → ChangeCountdown(время Runtime). StopCountdown в обычном управлении не используется, потому что он сбрасывает countdown. Команды идут в Agent без ACK отдельными кадрами строго по порядку. Каждую секунду значение в vMix не передаётся. Text mirror оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.
Режим Countdown vMix: время передаётся в vMix только при явной установке/сбросе (например, 20:00 или 05:00). После этого Space не синхронизирует значение: Start/Resume отправляет только StartCountdown, Pause/Stop — только PauseCountdown. Это исключает скачки времени при паузе. StopCountdown в обычном управлении не используется. Команды идут в Agent без ACK. Text mirror оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.