diff --git a/app.py b/app.py index 2baf4f1..f0c9908 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,8 @@ 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.8" +BUILD_VERSION = "2026.08.24.9" +# compatibility: BUILD_VERSION = "2026.08.24.8" # compatibility: BUILD_VERSION = "2026.08.24.7" # compatibility: BUILD_VERSION = "2026.08.24.6" # compatibility: BUILD_VERSION = "2026.08.24.5" diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index fb57ac9..62123a6 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -1524,23 +1524,26 @@ class VmixAgentHub: # semantics so diagnostics are still useful where exact field delivery matters. interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip()) delivery_mode = str(delivery_mode or "").strip().lower() - fast_timer = delivery_mode == "timer-fast" + fast_timer = delivery_mode in {"timer-fast", "timer-fast-ordered"} + ordered_timer = delivery_mode == "timer-fast-ordered" no_ack_delivery = interactive_shortcut or fast_timer supports_batch = self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 use_batch = (not no_ack_delivery) and supports_batch - if fast_timer: + if ordered_timer: + transport = "timer-ordered-no-ack" + elif fast_timer: transport = "timer-batch-no-ack" if supports_batch else "timer-no-ack" else: transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy") if no_ack_delivery: - # BUILD103: realtime operator traffic (shortcuts + timers) bypasses Mapping's - # ACK queue. Timer pairs use ONE vmix.batch frame on Agent 1.4+ and return - # immediately after WebSocket delivery; the later Agent ACK is intentionally ignored. - # The shared realtime lock preserves exact frame order if Space and an F-key are - # pressed almost simultaneously without reintroducing the slow Mapping lock. + # BUILD106: realtime operator traffic bypasses Mapping's ACK queue. The new + # timer-fast-ordered mode deliberately sends each timer command as its own WebSocket + # frame in strict order (Stop -> Set -> Start) and never waits for Agent/vMix ACK. + # Legacy timer-fast keeps vmix.batch compatibility. The shared realtime lock prevents + # interleaving with F-key traffic without reintroducing the slow Mapping lock. async with self._device_vmix_shortcut_send_lock(target_device_id): - if fast_timer and supports_batch: + if fast_timer and supports_batch and not ordered_timer: request_id = secrets.token_urlsafe(12) delivered = await self.send( target_device_id, diff --git a/tests/test_build106_runtime_authoritative_timer.py b/tests/test_build106_runtime_authoritative_timer.py new file mode 100644 index 0000000..7bca004 --- /dev/null +++ b/tests/test_build106_runtime_authoritative_timer.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +from hockey_data.agent_bridge import VmixAgentHub +from hockey_data.auth_bridge import HockeyUser +from tests.support import LocalTestDatabase + +ROOT = Path(__file__).resolve().parents[1] +APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8") +APP = (ROOT / "app.py").read_text(encoding="utf-8") + + +class FakeWebSocket: + def __init__(self) -> None: + self.client = SimpleNamespace(host="127.0.0.1") + self.sent: list[dict] = [] + + async def send_json(self, payload: dict) -> None: + self.sent.append(payload) + + async def close(self, **_kwargs) -> None: + return None + + +def _block(start: str, end: str) -> str: + return APP_JS.split(start, 1)[1].split(end, 1)[0] + + +def test_countdown_start_hard_reseeds_before_start() -> None: + helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") + assert 'Function: "PauseRender"' in helper + assert 'Function: "StopCountdown"' in helper + assert 'Function: "SetCountdown"' in helper + assert 'Function: "ResumeRender"' in helper + assert 'Function: "StartCountdown"' in helper + assert helper.index('Function: "StopCountdown"') < helper.index('Function: "SetCountdown"') + assert helper.rindex('Function: "SetCountdown"') < helper.rindex('Function: "StartCountdown"') + + +def test_pause_is_reseeded_from_runtime_not_native_clock() -> None: + helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") + branch = helper.split('if (action === "stop" || action === "pause" || action === "set")', 1)[1].split('return reseedStopped;', 1)[0] + assert "Runtime value" in branch + reseed = helper.split("const reseedStopped = [", 1)[1].split("];", 1)[0] + assert 'Function: "StopCountdown"' in reseed + assert 'Function: "SetCountdown"' in reseed + assert 'Value: currentValue' in reseed + + +def test_scoreboard_show_syncs_timer_before_overlay_steps() -> None: + runner = _block("async function runShortcutSequence", "function handleConfiguredShortcutCombo") + assert "if (sequence.is_scoreboard_sequence)" in runner + assert runner.index("await syncConfiguredScoreboardCountdownsToRuntime()") < runner.index("const stepErrors = []") + helper = _block("async function syncConfiguredScoreboardCountdownsToRuntime", "function penaltyMirrorKey") + assert 'timerState.running ? "start" : "set"' in helper + assert "await sendRuntimeVmixTimerSequence(commands)" in helper + + +def test_ordered_timer_transport_sends_individual_frames_without_ack(tmp_path: Path) -> None: + database = LocalTestDatabase(tmp_path / "build106-ordered.sqlite3") + database.create_all() + hub = VmixAgentHub(database) # type: ignore[arg-type] + ws = FakeWebSocket() + user = HockeyUser(id="106", login="operator106", display_name="operator106") + + async def scenario() -> None: + await hub.register( + ws, # type: ignore[arg-type] + { + "device_id": "GFX-TIMER-106", + "device_secret": "u" * 40, + "device_name": "Build106 Timer GFX", + "hostname": "BUILD106-PC", + "agent_version": "1.4.0", + "vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"}, + }, + ) + await hub.pair_device("GFX-TIMER-106", user) + await hub.assign_match( + wfl_user_id=user.id, + tournament_external_id="1437", + game_external_id="106106", + ) + + result = await asyncio.wait_for( + hub.run_vmix_sequence_for_user( + user, + [ + {"Function": "PauseRender", "Input": "SCORE"}, + {"Function": "StopCountdown", "Input": "SCORE", "SelectedName": "TIME.Text"}, + {"Function": "SetCountdown", "Input": "SCORE", "SelectedName": "TIME.Text", "Value": "00:16:01"}, + {"Function": "ResumeRender", "Input": "SCORE"}, + {"Function": "StartCountdown", "Input": "SCORE", "SelectedName": "TIME.Text"}, + ], + delivery_mode="timer-fast-ordered", + ), + timeout=0.5, + ) + + singles = [item for item in ws.sent if item.get("type") == "vmix.command"] + batches = [item for item in ws.sent if item.get("type") == "vmix.batch"] + assert not batches + assert [item["command"]["Function"] for item in singles] == [ + "PauseRender", "StopCountdown", "SetCountdown", "ResumeRender", "StartCountdown" + ] + assert result["ok"] is True + assert result["transport"] == "timer-ordered-no-ack" + assert result["confirmation"] == "not_waited" + assert all(row["ack_waited"] is False for row in result["results"]) + + asyncio.run(scenario()) + + +def test_build106_version() -> None: + assert 'BUILD_VERSION = "2026.08.24.9"' in APP + assert 'delivery_mode: "timer-fast-ordered"' in APP_JS diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index a52d644..748eb39 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -4153,33 +4153,39 @@ function startCustomTooltips() { } function vmixCountdownSyncCommands(input, selectedName, millisecondsProvider, action = "start") { - // BUILD101: keep timer transport intentionally small and deterministic. - // Runtime is authoritative. vMix is only reseeded from the current Runtime - // value when the operator changes state; no ACK is required to change the web timer. + // BUILD106: Runtime is the ONLY source of truth for game/penalty time. + // Important vMix detail: SetCountdown changes the countdown Duration; it does not + // reliably replace the current position of a countdown that was previously started + // or suspended. Therefore every operator state change performs a hard reseed: + // freeze title rendering -> reset native countdown -> set Runtime value -> unfreeze. + // Start/Resume then starts from that freshly seeded Runtime value. const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() }; + const renderTarget = { Input: target.Input }; const currentValue = () => { const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider; return vmixCountdownValue(milliseconds); }; if (!target.Input || !target.SelectedName) return []; - if (action === "stop" || action === "pause") { - // BUILD105: vMix StopCountdown means STOP + RESET TO BEGINNING. It must never - // be used for an operator pause/stop where the current sports time has to freeze. - // SuspendCountdown is the vMix pause-only command and preserves the exact native - // countdown value until the next SetCountdown + StartCountdown. - return [ - { Function: "SuspendCountdown", ...target }, - ]; - } - if (action === "set") { - // Explicit edits/reset still need to write the Runtime value while stopped. - return [ - { Function: "StopCountdown", ...target }, - { Function: "SetCountdown", ...target, Value: currentValue }, - ]; - } - return [ + + const reseedStopped = [ + { Function: "PauseRender", ...renderTarget }, + { Function: "StopCountdown", ...target }, { Function: "SetCountdown", ...target, Value: currentValue }, + { Function: "ResumeRender", ...renderTarget }, + ]; + + if (action === "stop" || action === "pause" || action === "set") { + // Do not trust the native vMix current position on Pause. Freeze at the exact + // Runtime value and leave the native countdown stopped. The next Resume will + // hard-reseed again before StartCountdown. + return reseedStopped; + } + + return [ + { Function: "PauseRender", ...renderTarget }, + { Function: "StopCountdown", ...target }, + { Function: "SetCountdown", ...target, Value: currentValue }, + { Function: "ResumeRender", ...renderTarget }, { Function: "StartCountdown", ...target }, ]; } @@ -4655,8 +4661,9 @@ function startCustomTooltips() { function sendRuntimeVmixTimerSequence(commands) { // BUILD103: timer transport is deliberately independent from the generic // shortcut/title/Mapping ACK queue. The web timer has already changed state before - // this function is called. Server delivery_mode=timer-fast sends the tiny native - // Countdown pair to Agent immediately without waiting for ACK, preferably as one batch. + // this function is called. Server delivery_mode=timer-fast-ordered sends native Countdown commands to Agent + // immediately without ACK, but as separate ordered WebSocket frames. This avoids any + // ambiguity about command ordering inside Agent vmix.batch handling. const rawCommands = typeof commands === "function" ? commands() : commands; const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function); if (!clean.length) return Promise.resolve({ ok: true, applied: 0, requested: 0, results: [] }); @@ -4667,14 +4674,13 @@ function startCustomTooltips() { credentials: "same-origin", keepalive: true, headers: { "Content-Type": "application/json" }, - // Intentionally omit sequence_id/button_id. Agent 1.4+ can then send the - // tiny SetCountdown+Start/Stop pair as one vmix.batch instead of waiting - // for several interactive ACK round-trips. + // Intentionally omit sequence_id/button_id. Timer transport is fire-and-forget, + // but BUILD106 uses ordered single WebSocket frames rather than vmix.batch. body: JSON.stringify({ commands: clean, device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken(), - delivery_mode: "timer-fast", + delivery_mode: "timer-fast-ordered", }), }).then(async (response) => { let payload = {}; @@ -4768,6 +4774,45 @@ function startCustomTooltips() { return true; } + function configuredHockeyVmixGameCountdownTargets() { + const targets = []; + const seen = new Set(); + (state.config.shortcut_sequences || []).forEach((sequence) => { + if (sequence?.enabled === false) return; + (sequence.steps || []).forEach((step) => { + if (!step || step.enabled === false || step.type !== "hockey_vmix_timers_start") return; + if (!step.sync_vmix_game || step.game_vmix_mode !== "countdown") return; + const input = String(step.game_vmix_input || "").trim(); + const selectedName = String(step.game_vmix_selected_name || "").trim(); + if (!input || !selectedName) return; + const timer = componentByActionId(step.game_timer_action_id || "hockey_game_timer"); + if (!timer || !isTimerComponent(timer)) return; + const key = `${input}\u0000${selectedName}`; + if (seen.has(key)) return; + seen.add(key); + targets.push({ input, selectedName, timer, timerState: ensureTimerState(timer) }); + }); + }); + return targets; + } + + async function syncConfiguredScoreboardCountdownsToRuntime() { + const commands = []; + configuredHockeyVmixGameCountdownTargets().forEach(({ input, selectedName, timerState }) => { + commands.push(...vmixCountdownSyncCommands( + input, + selectedName, + () => timerState.currentMs, + timerState.running ? "start" : "set" + )); + }); + if (!commands.length) return { ok: true, applied: 0, requested: 0 }; + // Await only WebSocket dispatch (never vMix ACK) so the countdown reseed reaches the + // Agent before the scoreboard OverlayIn command. This fixes stale timer values when F1 + // shows a scoreboard before Space has ever been pressed. + return await sendRuntimeVmixTimerSequence(commands); + } + function penaltyMirrorKey(component, event) { return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`; } @@ -5078,26 +5123,21 @@ function startCustomTooltips() { && (force || assignmentChanged || previous?.running !== true); if (entry.event.running) { if (force || assignmentChanged || startingCountdown) { - // BUILD101: while the web penalty is running, only reseed then run. - // Do not inject an extra Stop before every Start; Runtime already owns - // the state and the short SetCountdown+Start pair is enough. + // BUILD106: penalty countdowns follow the same Runtime-authoritative rule + // as the game clock. Never continue an old native position: Stop first, + // then seed the exact web remaining time, then Start. + stopCommands.push({ Function: "StopCountdown", Input: target.input, SelectedName: target.selected_name }); setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) }); } if (startingCountdown) { runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); } } else if (force || assignmentChanged || previous?.running !== false) { - // BUILD105: an unchanged prepared/paused penalty must use SuspendCountdown. - // StopCountdown resets a native vMix countdown to its beginning. For a - // reassigned/reset target we may still deliberately Stop + Set below. - if (preservePausedCountdown && !assignmentChanged) { - stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name }); - } else { - stopCommands.push({ Function: "StopCountdown", Input: target.input, SelectedName: target.selected_name }); - } - if (!preservePausedCountdown || assignmentChanged) { - setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) }); - } + // prepared/paused penalty: reseed from Runtime. preservePausedCountdown + // remains in the public call signature for compatibility, but native vMix time is + // never trusted as the authoritative value anymore. + stopCommands.push({ Function: "StopCountdown", Input: target.input, SelectedName: target.selected_name }); + setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) }); } } else { assignedMirrorKeys.add(eventKey); @@ -5419,6 +5459,9 @@ function startCustomTooltips() { else if (String(meta.source || "").startsWith("keyboard")) toast(`Шорткат выполнен: ${sequence.name}`); return true; } + if (sequence.is_scoreboard_sequence) { + await syncConfiguredScoreboardCountdownsToRuntime(); + } const stepErrors = []; for (const [stepIndex, step] of (sequence.steps || []).entries()) { try { @@ -13872,7 +13915,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
${timerFinishActionRows(step)}
${escapeHtml(shortcutInventoryLabel())}Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.
-

Режим Countdown vMix: веб-таймер является главным. Start сразу меняет состояние Runtime и независимо отправляет SetCountdown → StartCountdown в vMix; Pause/Stop сразу останавливают Runtime и независимо отправляют только SuspendCountdown (пауза без сброса), без повторной установки времени. Ошибка Agent не блокирует управление таймером. Каждую секунду значение в vMix не передаётся. Text mirror оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.

+

Режим Countdown vMix: веб-таймер является единственным источником времени. Перед Start/Resume vMix выполняет StopCountdown → SetCountdown(время Runtime) → StartCountdown; при Pause/Stop vMix выполняет StopCountdown → SetCountdown(время Runtime) и остаётся остановленным на точном веб-времени. Команды идут в Agent без ACK отдельными кадрами строго по порядку. Каждую секунду значение в vMix не передаётся. Text mirror оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.

`; } else if (step.type === "delay") { body.innerHTML = `
`;