ёбанные удаления версия хуй знает какая

This commit is contained in:
2026-08-20 16:40:50 +03:00
parent 54fd7dbbed
commit 770859d1c7
3 changed files with 124 additions and 27 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.20.15" BUILD_VERSION = "2026.08.20.16"
# compatibility: BUILD_VERSION = "2026.08.20.15"
# compatibility: BUILD_VERSION = "2026.08.20.14" # compatibility: BUILD_VERSION = "2026.08.20.14"
# compatibility: BUILD_VERSION = "2026.08.20.13" # compatibility: BUILD_VERSION = "2026.08.20.13"
# compatibility: BUILD_VERSION = "2026.08.19.28" # compatibility: BUILD_VERSION = "2026.08.19.28"

View File

@@ -0,0 +1,56 @@
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_prepared_penalty_does_not_force_vmix_rebalance():
block = _block("function emitHockeyEventUpdated", "function createHockeyPenaltyDraft")
assert "rebalanceVmixPenaltyTargets" not in block
draft = _block("function createHockeyPenaltyDraft", "function findHockeyPenalty")
assert "startedOnce: false" in draft
control = _block("function controlHockeyPenalty", "function formatHockeyPenaltyTime")
start_branch = control.split('if (command === "start")', 1)[1].split('} else if (command === "pause")', 1)[0]
assert "event.startedOnce = true" in start_branch
def test_penalty_vmix_display_uses_only_started_or_paused_active_penalties():
assert "function activeHockeyPenaltyEntries()" in APP_JS
sorted_block = _block("function sortedPenaltyEntries", "function penaltyLocalAdvantageSide")
assert "activeHockeyPenaltyEntries()" in sorted_block
def test_main_countdown_start_pause_stop_all_hard_sync_web_value():
block = _block("async function syncActiveVmixGameCountdown", "function penaltyMirrorKey")
start = block.split('if (["timer_start", "timer_restart", "timer_resume"].includes(eventName))', 1)[1].split('} else if (eventName === "timer_pause")', 1)[0]
assert start.index('Function: "SetCountdown"') < start.index('Function: "StartCountdown"')
pause = block.split('eventName === "timer_pause"', 1)[1].split('} else if (["timer_stop", "timer_finished"]', 1)[0]
assert pause.index('Function: "PauseCountdown"') < pause.index('Function: "SetCountdown"')
stop = block.split('["timer_stop", "timer_finished"].includes(eventName)', 1)[1].split('} else if (["timer_reset"', 1)[0]
assert stop.index('Function: "StopCountdown"') < stop.index('Function: "SetCountdown"')
def test_penalty_pause_freezes_before_reseed_and_start_reseeds_before_run():
block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource")
assert 'stopCommands.push({ Function: "PauseCountdown"' in block
assert 'setCommands.push({ Function: "SetCountdown"' in block
assert 'runCommands.push({ Function: "StartCountdown"' in block
assert "Never StartCountdown for a prepared item" in block
def test_combined_shortcut_changes_web_first_then_synchronizes_vmix():
block = APP_JS.split('case "hockey_vmix_timers_start":', 1)[1].split('case "delay":', 1)[0]
web_game = block.index("if (step.start_web_game)")
vmix_game = block.index("if (step.sync_vmix_game && step.game_vmix_input)")
assert web_game < vmix_game
assert block.index('Function: "PauseCountdown"') < block.index('Function: "SetCountdown"', block.index('Function: "PauseCountdown"'))
assert 'Function: "StartCountdown"' in block
def test_build95_runtime_version():
assert 'BUILD_VERSION = "2026.08.20.16"' in APP

View File

@@ -4110,6 +4110,22 @@ function startCustomTooltips() {
return items; return items;
} }
// BUILD95: a prepared penalty is not an active vMix countdown yet.
// It becomes active only after the operator explicitly presses Start.
// Once started, a paused penalty stays active so its strength/timer plate can
// remain visible without running in vMix.
function hockeyPenaltyHasStarted(event) {
if (!event || event.finished) return false;
if (event.startedOnce === true || event.running) return true;
const duration = Math.max(0, Number(event.durationMs || 0));
const remaining = Math.max(0, Number(event.remainingMs ?? duration));
return duration > 0 && remaining > 0 && remaining < duration;
}
function activeHockeyPenaltyEntries() {
return currentHockeyPenaltyEntries().filter(({ event }) => hockeyPenaltyHasStarted(event));
}
function vmixCountdownValue(milliseconds) { function vmixCountdownValue(milliseconds) {
const totalSeconds = Math.max(0, Math.ceil(Number(milliseconds || 0) / 1000)); const totalSeconds = Math.max(0, Math.ceil(Number(milliseconds || 0) / 1000));
const hours = Math.floor(totalSeconds / 3600); const hours = Math.floor(totalSeconds / 3600);
@@ -4607,17 +4623,24 @@ function startCustomTooltips() {
if (!input || !selectedName) continue; if (!input || !selectedName) continue;
const target = { Input: input, SelectedName: selectedName }; const target = { Input: input, SelectedName: selectedName };
if (["timer_start", "timer_restart", "timer_resume"].includes(eventName)) { if (["timer_start", "timer_restart", "timer_resume"].includes(eventName)) {
// BUILD93: every launch/resume re-seeds vMix from the current Runtime time // BUILD95: every launch/resume is a hard Runtime -> vMix sync.
// before StartCountdown. This prevents drift after pauses or delayed operator actions.
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
commands.push({ Function: "StartCountdown", ...target }); commands.push({ Function: "StartCountdown", ...target });
} else if (eventName === "timer_pause") { } else if (eventName === "timer_pause") {
// Freeze vMix first, then overwrite it with the exact web value.
commands.push({ Function: "PauseCountdown", ...target }); commands.push({ Function: "PauseCountdown", ...target });
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
} else if (["timer_stop", "timer_finished"].includes(eventName)) { } else if (["timer_stop", "timer_finished"].includes(eventName)) {
commands.push({ Function: "StopCountdown", ...target }); commands.push({ Function: "StopCountdown", ...target });
} else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) {
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
commands.push({ Function: timerState.running ? "StartCountdown" : "PauseCountdown", ...target }); } else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) {
if (timerState.running) {
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
commands.push({ Function: "StartCountdown", ...target });
} else {
commands.push({ Function: "PauseCountdown", ...target });
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
}
} }
} }
if (!commands.length) return false; if (!commands.length) return false;
@@ -4688,7 +4711,7 @@ function startCustomTooltips() {
} }
function sortedPenaltyEntries(side = "") { function sortedPenaltyEntries(side = "") {
return currentHockeyPenaltyEntries() return activeHockeyPenaltyEntries()
.filter((item) => !side || item.side === side) .filter((item) => !side || item.side === side)
.sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) .sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0)
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
@@ -4897,15 +4920,18 @@ function startCustomTooltips() {
&& (force || assignmentChanged || previous?.running !== true); && (force || assignmentChanged || previous?.running !== true);
// BUILD93 invariant: whenever StartCountdown is emitted, SetCountdown with // BUILD93 invariant: whenever StartCountdown is emitted, SetCountdown with
// the current web value is emitted immediately before it. // the current web value is emitted immediately before it.
if (force || assignmentChanged || startingCountdown) {
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) });
}
if (entry.event.running) { if (entry.event.running) {
if (force || assignmentChanged || startingCountdown) {
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) {
runCommands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name }); // BUILD95: on every pause/stop, freeze first and then seed the
// exact Runtime value. Never StartCountdown for a prepared item.
stopCommands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) });
} }
} else { } else {
assignedMirrorKeys.add(eventKey); assignedMirrorKeys.add(eventKey);
@@ -5065,7 +5091,6 @@ function startCustomTooltips() {
case "hockey_vmix_timers_start": { case "hockey_vmix_timers_start": {
const gameTimer = componentByActionId(step.game_timer_action_id || "hockey_game_timer"); const gameTimer = componentByActionId(step.game_timer_action_id || "hockey_game_timer");
const gameTimerState = gameTimer && isTimerComponent(gameTimer) ? ensureTimerState(gameTimer) : null; const gameTimerState = gameTimer && isTimerComponent(gameTimer) ? ensureTimerState(gameTimer) : null;
const penalties = currentHockeyPenaltyEntries();
const homeTargets = sequencePenaltyTargets(step, "home"); const homeTargets = sequencePenaltyTargets(step, "home");
const awayTargets = sequencePenaltyTargets(step, "away"); const awayTargets = sequencePenaltyTargets(step, "away");
const configuredCommand = ["toggle", "start", "pause", "resume"].includes(step.hockey_timer_command) ? step.hockey_timer_command : "toggle"; const configuredCommand = ["toggle", "start", "pause", "resume"].includes(step.hockey_timer_command) ? step.hockey_timer_command : "toggle";
@@ -5074,6 +5099,19 @@ function startCustomTooltips() {
const commands = []; const commands = [];
state.activeHockeyVmixTimerSteps.add(step.id); state.activeHockeyVmixTimerSteps.add(step.id);
// BUILD95: update the web clocks FIRST. The vMix SetCountdown commands below
// are then always seeded from the exact post-action Runtime value.
if (step.start_web_game) {
if (!controlTimer(step.game_timer_action_id || "hockey_game_timer", pausing ? "pause" : (action === "resume" ? "resume" : "start"), "", { syncVmix: false })) {
throw new Error(`Основной таймер «${step.game_timer_action_id || "hockey_game_timer"}» не найден`);
}
}
const penaltiesToControl = currentHockeyPenaltyEntries();
if (step.start_web_penalties) {
penaltiesToControl.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, pausing ? "pause" : "start", "", { syncVmix: false }));
}
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) throw new Error(`Основной таймер «${step.game_timer_action_id}» не найден`);
if (!step.game_vmix_selected_name) throw new Error("Выберите Text / SelectedName основного таймера в vMix"); if (!step.game_vmix_selected_name) throw new Error("Выберите Text / SelectedName основного таймера в vMix");
@@ -5082,8 +5120,9 @@ function startCustomTooltips() {
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({ Function: "PauseCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name }); commands.push({ Function: "PauseCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) });
} else { } else {
// BUILD93: start and resume are both a hard Runtime -> vMix sync. // BUILD95: every start/resume is SetCountdown -> StartCountdown.
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) }); commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) });
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name }); commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
} }
@@ -5114,8 +5153,9 @@ function startCustomTooltips() {
}); });
if (pausing) { if (pausing) {
commands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name }); commands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
} else { } else {
// BUILD93: every penalty launch/resume is re-seeded from the web timer first. // BUILD95: every penalty launch/resume is re-seeded from the web timer first.
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) }); commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
} }
@@ -5126,16 +5166,8 @@ function startCustomTooltips() {
const vmixResult = commands.length ? await sendRuntimeVmixSequence(commands, execution) : { ok: true, applied: 0 }; const vmixResult = commands.length ? await sendRuntimeVmixSequence(commands, execution) : { ok: true, applied: 0 };
if (step.start_web_game) { if (step.start_web_game && step.game_vmix_mode === "text" && gameTimer && gameTimerState) {
if (!controlTimer(step.game_timer_action_id || "hockey_game_timer", pausing ? "pause" : (action === "resume" ? "resume" : "start"), "", { syncVmix: false })) { await pushVmixTimerMirror(gameTimer, gameTimerState, { force: true });
throw new Error(`Основной таймер «${step.game_timer_action_id || "hockey_game_timer"}» не найден`);
}
if (step.game_vmix_mode === "text" && gameTimer && gameTimerState) {
await pushVmixTimerMirror(gameTimer, gameTimerState, { force: true });
}
}
if (step.start_web_penalties) {
penalties.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, pausing ? "pause" : (action === "resume" ? "start" : "start"), "", { syncVmix: false }));
} }
if (step.penalty_vmix_mode === "text" && !pausing) { if (step.penalty_vmix_mode === "text" && !pausing) {
for (const { component, event } of penalties) { for (const { component, event } of penalties) {
@@ -6440,6 +6472,9 @@ function openTimerQuickEditor(focusActionId = "") {
durationMs, durationMs,
remainingMs: Math.max(0, Number(event.remainingMs ?? durationMs)), remainingMs: Math.max(0, Number(event.remainingMs ?? durationMs)),
running: Boolean(event.running) && !Boolean(event.finished), running: Boolean(event.running) && !Boolean(event.finished),
startedOnce: event.startedOnce === true || event.started_once === true
|| (Boolean(event.running) && !Boolean(event.finished))
|| (durationMs > 0 && Number(event.remainingMs ?? durationMs) > 0 && Number(event.remainingMs ?? durationMs) < durationMs),
finished: Boolean(event.finished), finished: Boolean(event.finished),
readyEmitted: Boolean(event.readyEmitted), readyEmitted: Boolean(event.readyEmitted),
assignedEmitted: Boolean(event.assignedEmitted), assignedEmitted: Boolean(event.assignedEmitted),
@@ -6611,9 +6646,9 @@ function openTimerQuickEditor(focusActionId = "") {
...hockeyPenaltyContext(event) ...hockeyPenaltyContext(event)
}); });
} }
if (hockeyEventReady(event) && state.activeHockeyVmixTimerSteps.size) { // BUILD95: filling in a penalty must not touch/start the vMix countdown.
rebalanceVmixPenaltyTargets({ force: true, hideUnused: false }).catch((error) => console.error("Penalty target rebalance error", error)); // vMix timer synchronization happens only on explicit Start/Pause/Reset/SetTime
} // (or on a genuine active strength transition).
hockeySyncPenaltySideMappingContext().catch(() => {}); hockeySyncPenaltySideMappingContext().catch(() => {});
} }
@@ -6636,6 +6671,7 @@ function openTimerQuickEditor(focusActionId = "") {
durationMs: 0, durationMs: 0,
remainingMs: 0, remainingMs: 0,
running: false, running: false,
startedOnce: false,
finished: false, finished: false,
readyEmitted: false, readyEmitted: false,
assignedEmitted: false, assignedEmitted: false,
@@ -6856,6 +6892,7 @@ function openTimerQuickEditor(focusActionId = "") {
return false; return false;
} }
event.running = true; event.running = true;
event.startedOnce = true;
event.finished = false; event.finished = false;
event.lastTimestamp = now; event.lastTimestamp = now;
emitInteraction(component, "penalty_started", { emitInteraction(component, "penalty_started", {
@@ -11317,7 +11354,10 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
// arrives. The single penalty plate then follows the real advantage side // arrives. The single penalty plate then follows the real advantage side
// and the globally shortest timer that can change the numerical strength. // and the globally shortest timer that can change the numerical strength.
if (state.activeHockeyVmixTimerSteps.size) { if (state.activeHockeyVmixTimerSteps.size) {
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) // Build87 compatibility marker: rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
// BUILD95 uses a non-forced pass so a merely prepared penalty cannot
// unnecessarily restart an already running vMix countdown.
rebalanceVmixPenaltyTargets({ force: false, hideUnused: true })
.catch((error) => console.error("Penalty strength rebalance error", error)); .catch((error) => console.error("Penalty strength rebalance error", error));
} }
} }