This commit is contained in:
2026-08-24 17:39:28 +03:00
parent f1605470ff
commit 1fbe845b30
3 changed files with 131 additions and 19 deletions

2
app.py
View File

@@ -29,7 +29,7 @@ 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.10" BUILD_VERSION = "2026.08.24.11"
# compatibility: BUILD_VERSION = "2026.08.24.8" # 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"

View File

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

View File

@@ -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) { function buildShortcutRuntimeContext(sequence = null) {
const timers = {}; const timers = {};
state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => { state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => {
@@ -4754,25 +4771,50 @@ function startCustomTooltips() {
async function syncActiveVmixGameCountdown(component, timerState, eventName) { async function syncActiveVmixGameCountdown(component, timerState, eventName) {
if (!component?.action_id || eventName === "timer_tick") return false; 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 = []; const commands = [];
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) { const seenTargets = new Set();
const step = hockeyTimerSyncStepById(stepId); for (const step of candidateSteps) {
if (!step || step.enabled === false || !step.sync_vmix_game || step.game_vmix_mode !== "countdown") continue; 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; if (String(step.game_timer_action_id || "hockey_game_timer") !== String(component.action_id)) continue;
const input = String(step.game_vmix_input || "").trim(); const input = String(step.game_vmix_input || "").trim();
const selectedName = String(step.game_vmix_selected_name || "").trim(); const selectedName = String(step.game_vmix_selected_name || "").trim();
if (!input || !selectedName) continue; if (!input || !selectedName) continue;
const targetKey = `${input}\u0000${selectedName}`;
if (seenTargets.has(targetKey)) continue;
seenTargets.add(targetKey);
const valueProvider = () => timerState.currentMs; 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") { } 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)) { } 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)) { } else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) {
commands.push(...vmixCountdownSyncCommands( commands.push(...vmixGameCountdownControlCommands(input, selectedName, valueProvider, "set"));
input, selectedName, valueProvider, timerState.running ? "start" : "set"
));
} }
} }
if (!commands.length) return false; if (!commands.length) return false;
@@ -4805,12 +4847,13 @@ function startCustomTooltips() {
async function syncConfiguredScoreboardCountdownsToRuntime() { async function syncConfiguredScoreboardCountdownsToRuntime() {
const commands = []; const commands = [];
configuredHockeyVmixGameCountdownTargets().forEach(({ input, selectedName, timerState }) => { configuredHockeyVmixGameCountdownTargets().forEach(({ input, selectedName, timerState }) => {
commands.push(...vmixCountdownSyncCommands( // F1 only seeds a stopped/paused game clock. If it is already running,
input, // do not touch its native vMix time at all.
selectedName, if (!timerState.running) {
() => timerState.currentMs, commands.push(...vmixGameCountdownControlCommands(
timerState.running ? "start" : "set" input, selectedName, () => timerState.currentMs, "set"
)); ));
}
}); });
if (!commands.length) return { ok: true, applied: 0, requested: 0 }; if (!commands.length) return { ok: true, applied: 0, requested: 0 };
// Await only WebSocket dispatch (never vMix ACK) so the countdown reseed reaches the // 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); 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) }); commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) });
} else if (pausing) { } 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" step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "pause"
)); ));
} else { } 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" step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "start"
)); ));
} }
@@ -13921,7 +13967,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/Resume vMix выполняет <code>ChangeCountdown(время Runtime) → StartCountdown</code>; при Pause/Stop выполняет <code>PauseCountdown → ChangeCountdown(время Runtime)</code>. <code>StopCountdown</code> в обычном управлении не используется, потому что он сбрасывает countdown. Команды идут в Agent без ACK отдельными кадрами строго по порядку. Каждую секунду значение в vMix не передаётся. <b>Text mirror</b> оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.</p> <p class="shortcut-step-note">Режим <b>Countdown vMix</b>: время передаётся в vMix только при явной установке/сбросе (например, 20:00 или 05:00). После этого Space не синхронизирует значение: Start/Resume отправляет только <code>StartCountdown</code>, Pause/Stop — только <code>PauseCountdown</code>. Это исключает скачки времени при паузе. <code>StopCountdown</code> в обычном управлении не используется. Команды идут в Agent без ACK. <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>`;