This commit is contained in:
2026-08-24 17:06:08 +03:00
parent 1e46f1cb9f
commit bd22229430
4 changed files with 216 additions and 50 deletions

3
app.py
View File

@@ -29,7 +29,8 @@ from ui_builder import install_ui_builder
from khl_site.khl_data_center import APP as khl_site_app from khl_site.khl_data_center import APP as khl_site_app
BASE_DIR = Path(__file__).resolve().parent 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.7"
# compatibility: BUILD_VERSION = "2026.08.24.6" # compatibility: BUILD_VERSION = "2026.08.24.6"
# compatibility: BUILD_VERSION = "2026.08.24.5" # compatibility: BUILD_VERSION = "2026.08.24.5"

View File

@@ -1524,23 +1524,26 @@ class VmixAgentHub:
# semantics so diagnostics are still useful where exact field delivery matters. # 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()) interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip())
delivery_mode = str(delivery_mode or "").strip().lower() 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 no_ack_delivery = interactive_shortcut or fast_timer
supports_batch = self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 supports_batch = self._agent_supports_batch(agent_version) and len(prepared_commands) > 1
use_batch = (not no_ack_delivery) and supports_batch 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" transport = "timer-batch-no-ack" if supports_batch else "timer-no-ack"
else: else:
transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy") transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy")
if no_ack_delivery: if no_ack_delivery:
# BUILD103: realtime operator traffic (shortcuts + timers) bypasses Mapping's # BUILD106: realtime operator traffic bypasses Mapping's ACK queue. The new
# ACK queue. Timer pairs use ONE vmix.batch frame on Agent 1.4+ and return # timer-fast-ordered mode deliberately sends each timer command as its own WebSocket
# immediately after WebSocket delivery; the later Agent ACK is intentionally ignored. # frame in strict order (Stop -> Set -> Start) and never waits for Agent/vMix ACK.
# The shared realtime lock preserves exact frame order if Space and an F-key are # Legacy timer-fast keeps vmix.batch compatibility. The shared realtime lock prevents
# pressed almost simultaneously without reintroducing the slow Mapping lock. # interleaving with F-key traffic without reintroducing the slow Mapping lock.
async with self._device_vmix_shortcut_send_lock(target_device_id): 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) request_id = secrets.token_urlsafe(12)
delivered = await self.send( delivered = await self.send(
target_device_id, target_device_id,

View File

@@ -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

View File

@@ -4153,33 +4153,39 @@ function startCustomTooltips() {
} }
function vmixCountdownSyncCommands(input, selectedName, millisecondsProvider, action = "start") { function vmixCountdownSyncCommands(input, selectedName, millisecondsProvider, action = "start") {
// BUILD101: keep timer transport intentionally small and deterministic. // BUILD106: Runtime is the ONLY source of truth for game/penalty time.
// Runtime is authoritative. vMix is only reseeded from the current Runtime // Important vMix detail: SetCountdown changes the countdown Duration; it does not
// value when the operator changes state; no ACK is required to change the web timer. // 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 target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() };
const renderTarget = { Input: target.Input };
const currentValue = () => { const currentValue = () => {
const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider; const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider;
return vmixCountdownValue(milliseconds); return vmixCountdownValue(milliseconds);
}; };
if (!target.Input || !target.SelectedName) return []; if (!target.Input || !target.SelectedName) return [];
if (action === "stop" || action === "pause") {
// BUILD105: vMix StopCountdown means STOP + RESET TO BEGINNING. It must never const reseedStopped = [
// be used for an operator pause/stop where the current sports time has to freeze. { Function: "PauseRender", ...renderTarget },
// SuspendCountdown is the vMix pause-only command and preserves the exact native { Function: "StopCountdown", ...target },
// 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 [
{ Function: "SetCountdown", ...target, Value: currentValue }, { 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 }, { Function: "StartCountdown", ...target },
]; ];
} }
@@ -4655,8 +4661,9 @@ function startCustomTooltips() {
function sendRuntimeVmixTimerSequence(commands) { function sendRuntimeVmixTimerSequence(commands) {
// BUILD103: timer transport is deliberately independent from the generic // BUILD103: timer transport is deliberately independent from the generic
// shortcut/title/Mapping ACK queue. The web timer has already changed state before // 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 // this function is called. Server delivery_mode=timer-fast-ordered sends native Countdown commands to Agent
// Countdown pair to Agent immediately without waiting for ACK, preferably as one batch. // 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 rawCommands = typeof commands === "function" ? commands() : commands;
const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function); const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function);
if (!clean.length) return Promise.resolve({ ok: true, applied: 0, requested: 0, results: [] }); if (!clean.length) return Promise.resolve({ ok: true, applied: 0, requested: 0, results: [] });
@@ -4667,14 +4674,13 @@ function startCustomTooltips() {
credentials: "same-origin", credentials: "same-origin",
keepalive: true, keepalive: true,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
// Intentionally omit sequence_id/button_id. Agent 1.4+ can then send the // Intentionally omit sequence_id/button_id. Timer transport is fire-and-forget,
// tiny SetCountdown+Start/Stop pair as one vmix.batch instead of waiting // but BUILD106 uses ordered single WebSocket frames rather than vmix.batch.
// for several interactive ACK round-trips.
body: JSON.stringify({ body: JSON.stringify({
commands: clean, commands: clean,
device_id: currentRuntimeVmixDeviceId(), device_id: currentRuntimeVmixDeviceId(),
session_token: currentRuntimeHockeySessionToken(), session_token: currentRuntimeHockeySessionToken(),
delivery_mode: "timer-fast", delivery_mode: "timer-fast-ordered",
}), }),
}).then(async (response) => { }).then(async (response) => {
let payload = {}; let payload = {};
@@ -4768,6 +4774,45 @@ function startCustomTooltips() {
return true; 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) { function penaltyMirrorKey(component, event) {
return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`; return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`;
} }
@@ -5078,26 +5123,21 @@ function startCustomTooltips() {
&& (force || assignmentChanged || previous?.running !== true); && (force || assignmentChanged || previous?.running !== true);
if (entry.event.running) { if (entry.event.running) {
if (force || assignmentChanged || startingCountdown) { if (force || assignmentChanged || startingCountdown) {
// BUILD101: while the web penalty is running, only reseed then run. // BUILD106: penalty countdowns follow the same Runtime-authoritative rule
// Do not inject an extra Stop before every Start; Runtime already owns // as the game clock. Never continue an old native position: Stop first,
// the state and the short SetCountdown+Start pair is enough. // 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) }); setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) });
} }
if (startingCountdown) { if (startingCountdown) {
runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
} }
} else if (force || assignmentChanged || previous?.running !== false) { } else if (force || assignmentChanged || previous?.running !== false) {
// BUILD105: an unchanged prepared/paused penalty must use SuspendCountdown. // prepared/paused penalty: reseed from Runtime. preservePausedCountdown
// StopCountdown resets a native vMix countdown to its beginning. For a // remains in the public call signature for compatibility, but native vMix time is
// reassigned/reset target we may still deliberately Stop + Set below. // never trusted as the authoritative value anymore.
if (preservePausedCountdown && !assignmentChanged) { stopCommands.push({ Function: "StopCountdown", Input: target.input, SelectedName: target.selected_name });
stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name }); setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) });
} 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) });
}
} }
} else { } else {
assignedMirrorKeys.add(eventKey); assignedMirrorKeys.add(eventKey);
@@ -5419,6 +5459,9 @@ function startCustomTooltips() {
else if (String(meta.source || "").startsWith("keyboard")) toast(`Шорткат выполнен: ${sequence.name}`); else if (String(meta.source || "").startsWith("keyboard")) toast(`Шорткат выполнен: ${sequence.name}`);
return true; return true;
} }
if (sequence.is_scoreboard_sequence) {
await syncConfiguredScoreboardCountdownsToRuntime();
}
const stepErrors = []; const stepErrors = [];
for (const [stepIndex, step] of (sequence.steps || []).entries()) { for (const [stepIndex, step] of (sequence.steps || []).entries()) {
try { try {
@@ -13872,7 +13915,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<div class="shortcut-finish-action-list">${timerFinishActionRows(step)}</div> <div class="shortcut-finish-action-list">${timerFinishActionRows(step)}</div>
<div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.</small></div> <div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.</small></div>
<p class="shortcut-step-note">Режим <b>Countdown vMix</b>: веб-таймер является главным. Start сразу меняет состояние Runtime и независимо отправляет <code>SetCountdown → StartCountdown</code> в vMix; Pause/Stop сразу останавливают Runtime и независимо отправляют только <code>SuspendCountdown</code> (пауза без сброса), без повторной установки времени. Ошибка Agent не блокирует управление таймером. Каждую секунду значение в vMix не передаётся. <b>Text mirror</b> оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.</p> <p class="shortcut-step-note">Режим <b>Countdown vMix</b>: веб-таймер является единственным источником времени. Перед Start/Resume vMix выполняет <code>StopCountdown → SetCountdown(время Runtime) → StartCountdown</code>; при Pause/Stop vMix выполняет <code>StopCountdown → SetCountdown(время Runtime)</code> и остаётся остановленным на точном веб-времени. Команды идут в Agent без ACK отдельными кадрами строго по порядку. Каждую секунду значение в vMix не передаётся. <b>Text mirror</b> оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.</p>
</div>`; </div>`;
} else if (step.type === "delay") { } else if (step.type === "delay") {
body.innerHTML = `<div class="shortcut-step-grid"><label>Задержка, мс<input type="number" min="0" max="10000" step="10" data-step-field="milliseconds" value="${Number(step.milliseconds) || 0}"></label></div>`; body.innerHTML = `<div class="shortcut-step-grid"><label>Задержка, мс<input type="number" min="0" max="10000" step="10" data-step-field="milliseconds" value="${Number(step.milliseconds) || 0}"></label></div>`;