возможно исправил ошибку таймера с vMix
This commit is contained in:
2
app.py
2
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
|
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.3"
|
BUILD_VERSION = "2026.08.24.4"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.2"
|
# compatibility: BUILD_VERSION = "2026.08.24.2"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.1"
|
# compatibility: BUILD_VERSION = "2026.08.24.1"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.21.1"
|
# compatibility: BUILD_VERSION = "2026.08.21.1"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def test_build100_frontend_shortcut_edges_and_pending_press_are_guarded() -> Non
|
|||||||
assert 'if (!state.pendingShortcutSequenceRuns.has(sequence.id))' in APP_JS
|
assert 'if (!state.pendingShortcutSequenceRuns.has(sequence.id))' in APP_JS
|
||||||
assert 'one stale/broken title must not prevent the remaining title' in APP_JS
|
assert 'one stale/broken title must not prevent the remaining title' in APP_JS
|
||||||
assert 'requestTimeoutMs = Math.min(60000' in APP_JS
|
assert 'requestTimeoutMs = Math.min(60000' in APP_JS
|
||||||
assert 'BUILD_VERSION = "2026.08.24.3"' in APP
|
assert 'BUILD_VERSION = "2026.08.24.4"' in APP
|
||||||
|
|
||||||
|
|
||||||
def test_build100_interactive_shortcut_is_single_command_acked_and_continues_after_failure(tmp_path: Path) -> None:
|
def test_build100_interactive_shortcut_is_single_command_acked_and_continues_after_failure(tmp_path: Path) -> None:
|
||||||
|
|||||||
58
tests/test_build101_simple_timer_runtime.py
Normal file
58
tests/test_build101_simple_timer_runtime.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
def _block(start: str, end: str) -> str:
|
||||||
|
return APP_JS.split(start, 1)[1].split(end, 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_timer_transport_is_independent_from_generic_vmix_queue():
|
||||||
|
block = _block("function sendRuntimeVmixTimerSequence", "function splitVmixInputs")
|
||||||
|
assert 'fetch("/api/hockey/vmix/sequence"' in block
|
||||||
|
assert 'keepalive: true' in block
|
||||||
|
assert 'state.vmixCommandQueue' not in block
|
||||||
|
assert 'sequence_id' not in block.split('body: JSON.stringify({', 1)[1]
|
||||||
|
assert 'Runtime continues locally' in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_timer_start_stop_protocol_is_minimal():
|
||||||
|
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
||||||
|
assert 'if (action === "stop" || action === "pause")' in helper
|
||||||
|
stop_branch = helper.split('if (action === "stop" || action === "pause")', 1)[1].split('return [', 1)[1].split('];', 1)[0]
|
||||||
|
assert stop_branch.index('Function: "StopCountdown"') < stop_branch.index('Function: "SetCountdown"')
|
||||||
|
start_branch = helper.rsplit('return [', 1)[1]
|
||||||
|
assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"')
|
||||||
|
assert 'SuspendCountdown' not in helper
|
||||||
|
assert 'PauseCountdown' not in helper
|
||||||
|
|
||||||
|
|
||||||
|
def test_combined_hockey_timer_shortcut_never_waits_for_agent():
|
||||||
|
block = APP_JS.split('case "hockey_vmix_timers_start":', 1)[1].split('case "delay":', 1)[0]
|
||||||
|
assert 'sendRuntimeVmixTimerSequence(commands);' in block
|
||||||
|
assert 'await sendRuntimeVmixSequence(commands' not in block
|
||||||
|
assert 'fire-and-forget' in block
|
||||||
|
# Local state changes remain ahead of any vMix transport.
|
||||||
|
assert block.index('if (step.start_web_game)') < block.index('if (commands.length) sendRuntimeVmixTimerSequence(commands);')
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_and_penalty_resync_do_not_use_generic_queue():
|
||||||
|
game = _block("async function syncActiveVmixGameCountdown", "function penaltyMirrorKey")
|
||||||
|
penalty = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource")
|
||||||
|
assert 'sendRuntimeVmixTimerSequence(commands);' in game
|
||||||
|
assert 'await sendRuntimeVmixSequence(commands)' not in game
|
||||||
|
assert 'sendRuntimeVmixTimerSequence(commands);' in penalty
|
||||||
|
assert 'await sendRuntimeVmixSequence(commands)' not in penalty
|
||||||
|
|
||||||
|
|
||||||
|
def test_busy_shortcut_can_toggle_timer_without_waiting_for_title_ack():
|
||||||
|
block = _block("function shortcutSequenceTimerControlSteps", "function handleConfiguredShortcutCombo")
|
||||||
|
assert 'runShortcutTimerControlsWhileBusy(sequence, meta)' in block
|
||||||
|
assert 'timer controls are never queued behind title/Agent ACK traffic' in block
|
||||||
|
assert '["timer_command", "hockey_vmix_timers_start"]' in block
|
||||||
|
|
||||||
|
|
||||||
|
def test_build101_runtime_version():
|
||||||
|
assert 'BUILD_VERSION = "2026.08.24.4"' in APP
|
||||||
@@ -32,15 +32,16 @@ def test_main_countdown_start_pause_stop_all_hard_sync_web_value():
|
|||||||
assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop")' in block
|
assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop")' in block
|
||||||
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
||||||
start_branch = helper.rsplit("return [", 1)[1]
|
start_branch = helper.rsplit("return [", 1)[1]
|
||||||
assert start_branch.index('Function: "SuspendCountdown"') < start_branch.index('Function: "SetCountdown"')
|
assert 'Function: "SuspendCountdown"' not in start_branch
|
||||||
assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"')
|
assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"')
|
||||||
|
|
||||||
|
|
||||||
def test_penalty_pause_freezes_before_reseed_and_start_reseeds_before_run():
|
def test_penalty_pause_freezes_before_reseed_and_start_reseeds_before_run():
|
||||||
block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource")
|
block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource")
|
||||||
assert 'stopCommands.push({ Function: "SuspendCountdown"' in block
|
assert 'stopCommands.push({ Function: "StopCountdown"' in block
|
||||||
assert 'setCommands.push({ Function: "SetCountdown"' in block
|
assert 'setCommands.push({ Function: "SetCountdown"' in block
|
||||||
assert 'runCommands.push({ Function: "StartCountdown"' in block
|
assert 'runCommands.push({ Function: "StartCountdown"' in block
|
||||||
|
assert 'sendRuntimeVmixTimerSequence(commands)' in block
|
||||||
assert "prepared/paused penalty" in block
|
assert "prepared/paused penalty" in block
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,27 +22,31 @@ def test_vmix_queue_resolves_countdown_values_at_actual_send_time():
|
|||||||
assert 'const clean = (rawCommands || []).map(compactVmixCommand)' in sender
|
assert 'const clean = (rawCommands || []).map(compactVmixCommand)' in sender
|
||||||
|
|
||||||
|
|
||||||
def test_countdown_start_is_deterministic_suspend_set_start():
|
def test_countdown_start_is_simple_set_start():
|
||||||
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
||||||
start_branch = helper.split('return [', 3)[-1]
|
start_branch = helper.split('return [', 3)[-1]
|
||||||
assert start_branch.index('Function: "SuspendCountdown"') < start_branch.index('Function: "SetCountdown"')
|
assert 'Function: "SuspendCountdown"' not in start_branch
|
||||||
assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"')
|
assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"')
|
||||||
assert 'Value: currentValue' in helper
|
assert 'Value: currentValue' in helper
|
||||||
|
|
||||||
|
|
||||||
def test_pause_uses_suspend_not_toggle_pausecountdown():
|
def test_pause_and_stop_use_stop_then_set():
|
||||||
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
||||||
pause = helper.split('if (action === "pause")', 1)[1].split('return [', 1)[1].split('];', 1)[0]
|
branch = helper.split('if (action === "stop" || action === "pause")', 1)[1].split('return [', 1)[1].split('];', 1)[0]
|
||||||
assert 'Function: "SuspendCountdown"' in pause
|
assert 'Function: "StopCountdown"' in branch
|
||||||
assert 'Function: "PauseCountdown"' not in pause
|
assert 'Function: "SetCountdown"' in branch
|
||||||
assert pause.index('Function: "SuspendCountdown"') < pause.index('Function: "SetCountdown"')
|
assert branch.index('Function: "StopCountdown"') < branch.index('Function: "SetCountdown"')
|
||||||
|
assert 'Function: "SuspendCountdown"' not in branch
|
||||||
|
assert 'Function: "PauseCountdown"' not in branch
|
||||||
|
|
||||||
|
|
||||||
def test_penalty_rebalance_hard_syncs_running_countdown_and_pauses_stably():
|
def test_penalty_rebalance_hard_syncs_running_countdown_and_pauses_stably():
|
||||||
block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource")
|
block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource")
|
||||||
assert 'stopCommands.push({ Function: "SuspendCountdown"' in block
|
assert 'stopCommands.push({ Function: "StopCountdown"' in block
|
||||||
assert 'Value: () => vmixCountdownValue(entry.event.remainingMs)' in block
|
assert 'Value: () => vmixCountdownValue(entry.event.remainingMs)' in block
|
||||||
assert 'runCommands.push({ Function: "StartCountdown"' in block
|
assert 'runCommands.push({ Function: "StartCountdown"' in block
|
||||||
|
assert 'sendRuntimeVmixTimerSequence(commands)' in block
|
||||||
|
assert 'Function: "SuspendCountdown"' not in block
|
||||||
assert 'Function: "PauseCountdown"' not in block
|
assert 'Function: "PauseCountdown"' not in block
|
||||||
|
|
||||||
|
|
||||||
@@ -64,4 +68,4 @@ def test_fixed_pin_1993_replaces_daily_login(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_build98_runtime_version():
|
def test_build98_runtime_version():
|
||||||
assert 'BUILD_VERSION = "2026.08.24.1"' in APP
|
assert 'BUILD_VERSION = "2026.08.24.4"' in APP
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ def test_shortcut_editor_layout_fixes_add_step_buttons_and_fullscreen_modal() ->
|
|||||||
|
|
||||||
def test_hockey_timer_shortcut_supports_native_countdown_and_legacy_text_mirror() -> None:
|
def test_hockey_timer_shortcut_supports_native_countdown_and_legacy_text_mirror() -> None:
|
||||||
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
assert 'Function: "SuspendCountdown"' in app_js
|
assert 'Function: "StopCountdown"' in app_js
|
||||||
|
assert 'Function: "SuspendCountdown"' not in app_js
|
||||||
assert 'Function: "StopCountdown"' in app_js
|
assert 'Function: "StopCountdown"' in app_js
|
||||||
assert 'hockey_timer_command' in app_js
|
assert 'hockey_timer_command' in app_js
|
||||||
assert 'game_vmix_mode' in app_js
|
assert 'game_vmix_mode' in app_js
|
||||||
@@ -252,7 +253,8 @@ def test_build90_native_countdown_avoids_per_second_transport() -> None:
|
|||||||
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
|
||||||
assert 'game_vmix_mode !== "countdown"' in app_js
|
assert 'game_vmix_mode !== "countdown"' in app_js
|
||||||
assert 'const countdownMode = step.penalty_vmix_mode === "countdown"' in app_js
|
assert 'const countdownMode = step.penalty_vmix_mode === "countdown"' in app_js
|
||||||
assert 'Function: "SuspendCountdown"' in app_js
|
assert 'Function: "StopCountdown"' in app_js
|
||||||
|
assert 'Function: "SuspendCountdown"' not in app_js
|
||||||
assert 'Function: "StopCountdown"' in app_js
|
assert 'Function: "StopCountdown"' in app_js
|
||||||
assert 'signal: controller.signal' in app_js
|
assert 'signal: controller.signal' in app_js
|
||||||
assert 'vmixCommandQueue: Promise.resolve()' in app_js
|
assert 'vmixCommandQueue: Promise.resolve()' in app_js
|
||||||
|
|||||||
@@ -4153,30 +4153,22 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function vmixCountdownSyncCommands(input, selectedName, millisecondsProvider, action = "start") {
|
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.
|
||||||
const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() };
|
const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() };
|
||||||
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") {
|
if (action === "stop" || action === "pause") {
|
||||||
return [
|
return [
|
||||||
{ Function: "StopCountdown", ...target },
|
{ Function: "StopCountdown", ...target },
|
||||||
{ Function: "SetCountdown", ...target, Value: currentValue },
|
{ Function: "SetCountdown", ...target, Value: currentValue },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (action === "pause") {
|
|
||||||
// vMix PauseCountdown is a toggle (pause/resume). SuspendCountdown is the
|
|
||||||
// deterministic pause-only command, so it cannot accidentally resume a timer.
|
|
||||||
return [
|
|
||||||
{ Function: "SuspendCountdown", ...target },
|
|
||||||
{ Function: "SetCountdown", ...target, Value: currentValue },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
// Hard-sync every launch: freeze any stale title countdown first, sample
|
|
||||||
// Runtime at actual send time, then start from exactly that value.
|
|
||||||
{ Function: "SuspendCountdown", ...target },
|
|
||||||
{ Function: "SetCountdown", ...target, Value: currentValue },
|
{ Function: "SetCountdown", ...target, Value: currentValue },
|
||||||
{ Function: "StartCountdown", ...target },
|
{ Function: "StartCountdown", ...target },
|
||||||
];
|
];
|
||||||
@@ -4648,6 +4640,55 @@ function startCustomTooltips() {
|
|||||||
return queued;
|
return queued;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendRuntimeVmixTimerSequence(commands) {
|
||||||
|
// BUILD101: timer transport is deliberately independent from the generic
|
||||||
|
// shortcut/title queue. The web timer has already changed state before this
|
||||||
|
// function is called, so Agent/vMix delivery is best-effort and must never
|
||||||
|
// block the operator from pressing Start/Stop again.
|
||||||
|
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: [] });
|
||||||
|
|
||||||
|
const request = fetch("/api/hockey/vmix/sequence", {
|
||||||
|
method: "POST",
|
||||||
|
cache: "no-store",
|
||||||
|
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.
|
||||||
|
body: JSON.stringify({
|
||||||
|
commands: clean,
|
||||||
|
device_id: currentRuntimeVmixDeviceId(),
|
||||||
|
session_token: currentRuntimeHockeySessionToken(),
|
||||||
|
}),
|
||||||
|
}).then(async (response) => {
|
||||||
|
let payload = {};
|
||||||
|
try { payload = await response.json(); } catch (_) {}
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn("vMix timer sync skipped", payload?.detail || `HTTP ${response.status}`);
|
||||||
|
return { ok: false, applied: 0, requested: clean.length, results: [], error: payload?.detail || `HTTP ${response.status}` };
|
||||||
|
}
|
||||||
|
const resultRows = Array.isArray(payload?.results) ? payload.results : [];
|
||||||
|
const successfulCommands = clean.filter((_command, index) => {
|
||||||
|
const row = resultRows[index];
|
||||||
|
return !row || row.ok !== false;
|
||||||
|
});
|
||||||
|
trackRuntimeOverlayCommands(successfulCommands, null);
|
||||||
|
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
|
||||||
|
if (payload?.ok === false) console.warn("vMix timer sync partially failed", payload);
|
||||||
|
return payload;
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn("vMix timer sync unavailable; Runtime continues locally", error);
|
||||||
|
return { ok: false, applied: 0, requested: clean.length, results: [], error: String(error?.message || error || "vmix_timer_sync_error") };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Do not await this from timer controls. The returned Promise is only useful
|
||||||
|
// for diagnostics/tests; local Runtime state is never rolled back on failure.
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
function splitVmixInputs(value) {
|
function splitVmixInputs(value) {
|
||||||
return String(value || "").split(/[;,\n]+/).map((item) => item.trim()).filter(Boolean);
|
return String(value || "").split(/[;,\n]+/).map((item) => item.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -4710,7 +4751,7 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!commands.length) return false;
|
if (!commands.length) return false;
|
||||||
await sendRuntimeVmixSequence(commands);
|
sendRuntimeVmixTimerSequence(commands);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5024,9 +5065,9 @@ 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) {
|
||||||
// BUILD98: deterministic hard sync. Suspend is pause-only in vMix;
|
// BUILD101: while the web penalty is running, only reseed then run.
|
||||||
// PauseCountdown is a toggle and could accidentally resume a stale timer.
|
// Do not inject an extra Stop before every Start; Runtime already owns
|
||||||
stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name });
|
// the state and the short SetCountdown+Start pair is enough.
|
||||||
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) {
|
||||||
@@ -5035,7 +5076,7 @@ function startCustomTooltips() {
|
|||||||
} else if (force || assignmentChanged || previous?.running !== false) {
|
} else if (force || assignmentChanged || previous?.running !== false) {
|
||||||
// Freeze the title and seed the exact Runtime value, but never run a
|
// Freeze the title and seed the exact Runtime value, but never run a
|
||||||
// prepared/paused penalty until the web event itself is running.
|
// prepared/paused penalty until the web event itself is running.
|
||||||
stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name });
|
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) });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -5098,7 +5139,7 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
// Old plate OUT/Stop first, then set/start the single current countdown, then IN.
|
// Old plate OUT/Stop first, then set/start the single current countdown, then IN.
|
||||||
const commands = [...outCommands, ...stopCommands, ...setCommands, ...runCommands, ...inCommands];
|
const commands = [...outCommands, ...stopCommands, ...setCommands, ...runCommands, ...inCommands];
|
||||||
if (commands.length) await sendRuntimeVmixSequence(commands);
|
if (commands.length) sendRuntimeVmixTimerSequence(commands);
|
||||||
return commands.length;
|
return commands.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5218,9 +5259,13 @@ function startCustomTooltips() {
|
|||||||
const penalties = currentHockeyPenaltyEntries();
|
const penalties = currentHockeyPenaltyEntries();
|
||||||
|
|
||||||
if (step.sync_vmix_game && step.game_vmix_input) {
|
if (step.sync_vmix_game && step.game_vmix_input) {
|
||||||
if (!gameTimerState) throw new Error(`Основной таймер «${step.game_timer_action_id}» не найден`);
|
if (!gameTimerState || !step.game_vmix_selected_name) {
|
||||||
if (!step.game_vmix_selected_name) throw new Error("Выберите Text / SelectedName основного таймера в vMix");
|
console.warn("vMix game timer sync skipped: timer target is incomplete", {
|
||||||
if (step.game_vmix_mode === "text") {
|
action_id: step.game_timer_action_id || "hockey_game_timer",
|
||||||
|
input: step.game_vmix_input || "",
|
||||||
|
selected_name: step.game_vmix_selected_name || "",
|
||||||
|
});
|
||||||
|
} else if (step.game_vmix_mode === "text") {
|
||||||
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) {
|
||||||
@@ -5245,7 +5290,10 @@ function startCustomTooltips() {
|
|||||||
sideEntries.forEach(({ component, event }, index) => {
|
sideEntries.forEach(({ component, event }, index) => {
|
||||||
const target = targets[index] || null;
|
const target = targets[index] || null;
|
||||||
if (!target?.input) return;
|
if (!target?.input) return;
|
||||||
if (!target.selected_name) throw new Error(`Для таймера удаления ${side === "home" ? "HOME" : "AWAY"} выберите Text / SelectedName`);
|
if (!target.selected_name) {
|
||||||
|
console.warn(`vMix penalty timer ${side === "home" ? "HOME" : "AWAY"} skipped: SelectedName is empty`, target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const sourceSide = String(event.player?.side || event.side || side);
|
const sourceSide = String(event.player?.side || event.side || side);
|
||||||
if (step.penalty_vmix_mode === "text") {
|
if (step.penalty_vmix_mode === "text") {
|
||||||
setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side: sourceSide, overlay: target.overlay });
|
setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side: sourceSide, overlay: target.overlay });
|
||||||
@@ -5267,17 +5315,21 @@ function startCustomTooltips() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const vmixResult = commands.length ? await sendRuntimeVmixSequence(commands, execution) : { ok: true, applied: 0 };
|
// BUILD101: local timer state is already final at this point. vMix sync is
|
||||||
|
// fire-and-forget and cannot keep the Shortcut in a "running" state while
|
||||||
|
// Agent is offline/slow. The next Space press therefore always controls
|
||||||
|
// Runtime immediately.
|
||||||
|
if (commands.length) sendRuntimeVmixTimerSequence(commands);
|
||||||
|
|
||||||
if (step.start_web_game && step.game_vmix_mode === "text" && gameTimer && gameTimerState) {
|
if (step.start_web_game && step.game_vmix_mode === "text" && gameTimer && gameTimerState) {
|
||||||
await pushVmixTimerMirror(gameTimer, gameTimerState, { force: true });
|
pushVmixTimerMirror(gameTimer, gameTimerState, { force: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
if (step.penalty_vmix_mode === "text" && !pausing) {
|
if (step.penalty_vmix_mode === "text" && !pausing) {
|
||||||
for (const { component, event } of penalties) {
|
penalties.forEach(({ component, event }) => {
|
||||||
await pushVmixPenaltyMirror(component, event, { force: true });
|
pushVmixPenaltyMirror(component, event, { force: true }).catch(() => {});
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
return { ok: true, action, vmix: vmixResult, penalties: penalties.length };
|
return { ok: true, action, vmix: { ok: true, queued: commands.length }, penalties: penalties.length };
|
||||||
}
|
}
|
||||||
case "delay":
|
case "delay":
|
||||||
await new Promise((resolve) => setTimeout(resolve, clamp(Number(step.milliseconds) || 0, 0, 10000)));
|
await new Promise((resolve) => setTimeout(resolve, clamp(Number(step.milliseconds) || 0, 0, 10000)));
|
||||||
@@ -5290,13 +5342,33 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortcutSequenceTimerControlSteps(sequence) {
|
||||||
|
return (sequence?.steps || []).filter((step) => step?.enabled !== false && ["timer_command", "hockey_vmix_timers_start"].includes(step?.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runShortcutTimerControlsWhileBusy(sequence, meta = {}) {
|
||||||
|
const timerSteps = shortcutSequenceTimerControlSteps(sequence);
|
||||||
|
if (!timerSteps.length) return false;
|
||||||
|
const execution = { sequence_id: "", sequence_name: "", button_id: "" };
|
||||||
|
timerSteps.forEach((step) => {
|
||||||
|
// runShortcutSequenceStep performs the local state change synchronously before
|
||||||
|
// its Promise resolves. BUILD101 timer/vMix transport does not await Agent.
|
||||||
|
runShortcutSequenceStep(sequence, step, execution).catch((error) => {
|
||||||
|
console.warn("Timer shortcut while sequence busy failed locally", error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function runShortcutSequence(sequenceOrId, meta = {}) {
|
async function runShortcutSequence(sequenceOrId, meta = {}) {
|
||||||
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
|
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
|
||||||
if (!sequence || sequence.enabled === false) return false;
|
if (!sequence || sequence.enabled === false) return false;
|
||||||
if (state.runningShortcutSequences.has(sequence.id)) {
|
if (state.runningShortcutSequences.has(sequence.id)) {
|
||||||
// Remember at most ONE follow-up action. This makes Start → Stop reliable when
|
// BUILD101: timer controls are never queued behind title/Agent ACK traffic.
|
||||||
// Agent ACK is still pending, while repeated impatient Space presses cannot
|
// If the timer is already visibly running, the next physical Space press must
|
||||||
// accumulate five future toggles and flip the timer back and forth later.
|
// stop it immediately even while some unrelated title command is still waiting.
|
||||||
|
if (runShortcutTimerControlsWhileBusy(sequence, meta)) return true;
|
||||||
|
// Non-timer shortcuts keep one safe follow-up action rather than accumulating.
|
||||||
if (!state.pendingShortcutSequenceRuns.has(sequence.id)) {
|
if (!state.pendingShortcutSequenceRuns.has(sequence.id)) {
|
||||||
state.pendingShortcutSequenceRuns.set(sequence.id, { ...meta, queued: true });
|
state.pendingShortcutSequenceRuns.set(sequence.id, { ...meta, queued: true });
|
||||||
}
|
}
|
||||||
@@ -13774,7 +13846,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> рекомендуется и используется по умолчанию: при каждом запуске или продолжении веб сначала отправляет актуальное время через <code>SetCountdown</code>, затем <code>StartCountdown</code>; каждую секунду значение не передаётся. <b>Text mirror</b> оставлен только как режим совместимости для старых титров. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывается на стороне команды преимущества и отсчитывает ближайшее изменение численного состава; при чистом равном обоюдном удалении плашка не выводится. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p>
|
<p class="shortcut-step-note">Режим <b>Countdown vMix</b>: веб-таймер является главным. Start сразу меняет состояние Runtime и независимо отправляет <code>SetCountdown → StartCountdown</code> в vMix; Pause/Stop сразу останавливают Runtime и независимо отправляют <code>StopCountdown → SetCountdown</code>. Ошибка Agent не блокирует управление таймером. Каждую секунду значение в 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>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user