тест 11

This commit is contained in:
2026-08-25 16:56:38 +03:00
parent 9ab53ac8d5
commit 9b4db7dc04
4745 changed files with 130 additions and 31476998 deletions

View File

@@ -4862,6 +4862,26 @@ function startCustomTooltips() {
return await sendRuntimeVmixTimerSequence(commands);
}
async function waitForVmixStrengthMappingIdle(timeoutMs = 2500) {
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
while (state.vmixStrengthMappingRefreshPending && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 20));
}
return !state.vmixStrengthMappingRefreshPending;
}
async function syncPreparedPenaltyStateBeforeScoreboard() {
const gameId = String(hockeyTimerSelectedGameId() || "").trim();
const pendingTimerState = Boolean(state.hockeyTimerDirty || state.hockeyTimerSaveTimer || state.hockeyTimerSavePromise);
if (gameId && pendingTimerState) {
await hockeyPersistGameTimers(gameId, { force: true });
}
// A background save may already be pushing the new 5x4 / 4x3 caption through
// Mapping. Give that tiny only-changed update a chance to finish before OverlayIn.
if (state.vmixStrengthMappingRefreshPending) await waitForVmixStrengthMappingIdle();
return await rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true });
}
function penaltyMirrorKey(component, event) {
return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`;
}
@@ -4923,8 +4943,26 @@ function startCustomTooltips() {
return null;
}
function configuredHockeyVmixPenaltySyncSteps() {
const steps = [];
const seen = new Set();
(state.config.shortcut_sequences || []).forEach((sequence) => {
if (!sequence || sequence.enabled === false) return;
(sequence.steps || []).forEach((step) => {
if (!step || step.enabled === false || step.type !== "hockey_vmix_timers_start" || !step.sync_vmix_penalties) return;
const key = String(step.id || "");
if (!key || seen.has(key)) return;
seen.add(key);
steps.push(step);
});
});
return steps;
}
function sortedPenaltyEntries(side = "") {
return activeHockeyPenaltyEntries()
// BUILD113: a fully assigned penalty is preloaded into the scorebug before
// its countdown starts. Start/Pause only controls whether the clock runs.
return currentHockeyPenaltyEntries()
.filter((item) => !side || item.side === side)
.sort((a, b) => {
// BUILD98: a paused penalty cannot be the next real strength transition
@@ -5123,15 +5161,23 @@ function startCustomTooltips() {
return `${String(step?.id || "")}:${side}:${String(target?.id || "")}`;
}
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true, preservePausedCountdown = false } = {}) {
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true, preservePausedCountdown = false, includeConfigured = false } = {}) {
const outCommands = [];
const stopCommands = [];
const setCommands = [];
const runCommands = [];
const inCommands = [];
const assignedMirrorKeys = new Set();
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
const managedSteps = new Map();
Array.from(state.activeHockeyVmixTimerSteps).forEach((stepId) => {
const step = hockeyTimerSyncStepById(stepId);
if (step) managedSteps.set(String(stepId), step);
});
if (includeConfigured) {
configuredHockeyVmixPenaltySyncSteps().forEach((step) => managedSteps.set(String(step.id || ""), step));
}
const managedStepIds = new Set(Array.from(managedSteps.keys()).filter(Boolean));
for (const [stepId, step] of managedSteps.entries()) {
if (!step || step.enabled === false || !step.sync_vmix_penalties) {
state.activeHockeyVmixTimerSteps.delete(stepId);
continue;
@@ -5242,7 +5288,7 @@ function startCustomTooltips() {
}
}
for (const [mirrorKey, mirror] of Array.from(state.vmixPenaltyMirrors.entries())) {
if (mirror?.stepId && state.activeHockeyVmixTimerSteps.has(mirror.stepId) && !assignedMirrorKeys.has(mirrorKey)) {
if (mirror?.stepId && managedStepIds.has(String(mirror.stepId)) && !assignedMirrorKeys.has(mirrorKey)) {
state.vmixPenaltyMirrors.delete(mirrorKey);
}
}
@@ -5512,7 +5558,11 @@ function startCustomTooltips() {
return true;
}
if (sequence.is_scoreboard_sequence) {
// BUILD113: preload all dynamic scorebug fields before the first OverlayIn.
// Game time is written immediately, then a prepared penalty is flushed so its
// countdown and configured numerical-strength caption are already in vMix.
await syncConfiguredScoreboardCountdownsToRuntime();
await syncPreparedPenaltyStateBeforeScoreboard();
}
const stepErrors = [];
for (const [stepIndex, step] of (sequence.steps || []).entries()) {
@@ -6977,10 +7027,22 @@ function openTimerQuickEditor(focusActionId = "") {
...hockeyPenaltyContext(event)
});
}
// BUILD95: filling in a penalty must not touch/start the vMix countdown.
// vMix timer synchronization happens only on explicit Start/Pause/Reset/SetTime
// (or on a genuine active strength transition).
hockeySyncPenaltySideMappingContext().catch(() => {});
if (hockeyEventReady(event)) {
// BUILD113: assigning the penalty is enough to prepare the scorebug. Seed the
// configured vMix countdown while STOPPED and persist the timer snapshot so
// Mapping receives the new numerical-strength caption before OverlayIn.
hockeyScheduleTimerSave(true);
queueMicrotask(() => {
const gameId = String(hockeyTimerSelectedGameId() || "").trim();
if (gameId) {
hockeyPersistGameTimers(gameId, { force: true })
.catch((error) => console.error("Prepared penalty strength save error", error));
}
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true })
.catch((error) => console.error("Prepared penalty vMix preload error", error));
});
}
}
function createHockeyPenaltyDraft(component, seed = {}, source = "manual") {
@@ -7261,8 +7323,8 @@ function openTimerQuickEditor(focusActionId = "") {
const fullStrengthSide = penaltyFullStrengthSide();
board.penalties = board.penalties.filter((item) => item.id !== event.id);
const side = String(event.player?.side || event.side || "").toLowerCase();
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item)).length;
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item)).length;
const clearCommonSelection = board.selectedEventId === event.id;
const clearSideSelection = board.selectedPreviewEventIds?.[side] === event.id;
if (clearCommonSelection) board.selectedEventId = null;
@@ -7271,7 +7333,7 @@ function openTimerQuickEditor(focusActionId = "") {
persistHockeyBoard(component, board, true);
refreshHockeyBoardNodes(component);
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true })
.catch((error) => console.error("Penalty target finish rebalance error", error))
.finally(() => {
fireConfiguredTimerFinishActions("penalty", {
@@ -7321,21 +7383,22 @@ function openTimerQuickEditor(focusActionId = "") {
});
state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event));
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
if (!board.penalties.some((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item))) {
if (!board.penalties.some((item) => !item.finished && hockeyEventReady(item))) {
resetPenaltyAdvantageCycle();
hockeyScheduleTimerSave(true);
}
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }).catch((error) => console.error("Penalty target remove rebalance error", error));
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true }).catch((error) => console.error("Penalty target remove rebalance error", error));
return true;
}
persistHockeyBoard(component, board, true);
refreshHockeyBoardNodes(component);
if (options.syncVmix !== false && state.activeHockeyVmixTimerSteps.size && ["start", "pause", "reset", "set_time"].includes(command)) {
if (options.syncVmix !== false && ["start", "pause", "reset", "set_time"].includes(command)) {
rebalanceVmixPenaltyTargets({
force: true,
hideUnused: true,
preservePausedCountdown: command === "pause",
includeConfigured: true,
}).catch((error) => console.error("Penalty countdown state sync error", error));
}
return true;
@@ -7429,17 +7492,17 @@ function openTimerQuickEditor(focusActionId = "") {
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
const hadAdvantage = Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage);
const fullStrengthSide = penaltyFullStrengthSide();
const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item)).length;
const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item)).length;
const finishBySide = new Map();
completedEvents.forEach((event) => {
const side = String(event.player?.side || event.side || "").toLowerCase();
if (side && !finishBySide.has(side)) finishBySide.set(side, event);
});
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true })
.catch((error) => console.error("Penalty target ticker rebalance error", error))
.finally(() => {
finishBySide.forEach((event, side) => {
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
fireConfiguredTimerFinishActions("penalty", {
side, component, event,
remaining_on_side: remainingOnSide,
@@ -11792,16 +11855,15 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
// BUILD89: keep a second rebalance after the authoritative control payload
// arrives. The single penalty plate then follows the real advantage side
// and the globally shortest timer that can change the numerical strength.
if (state.activeHockeyVmixTimerSteps.size) {
// 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 })
if (configuredHockeyVmixPenaltySyncSteps().length || state.activeHockeyVmixTimerSteps.size) {
// BUILD113: a prepared penalty already changes numerical strength, so keep
// its stopped countdown and target side aligned with the authoritative payload.
rebalanceVmixPenaltyTargets({ force: false, hideUnused: true, includeConfigured: true })
.catch((error) => console.error("Penalty strength rebalance error", error));
}
}
if (previousControl && hockeyTeamStateFlagSignature(previousControl) !== hockeyTeamStateFlagSignature(payload) && hockeyTeamStateScoreboardIsLive()) {
hockeySyncTeamStateOverlays({ force: true }).catch((error) => console.error("vMix team-state overlay sync error", error));
hockeySyncTeamStateOverlays({ force: false }).catch((error) => console.error("vMix team-state overlay sync error", error));
}
if (dispatch) {
window.dispatchEvent(new CustomEvent("hockey:game-control-updated", {
@@ -11942,9 +12004,43 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
});
}
async function hockeyApplyTeamStateOverlayImmediately(key, active) {
if (!hockeyTeamStateScoreboardIsLive()) return 0;
const setting = hockeyTeamStateSetting(key);
const previous = state.hockeyTeamStateOverlayActive.get(key) || null;
const commands = [];
const shouldShow = Boolean(active) && setting.enabled && Boolean(setting.input);
if (shouldShow) {
if (previous && (previous.input !== setting.input || previous.overlay !== setting.overlay)) {
commands.push({ Function: `OverlayInput${previous.overlay}Out`, Input: previous.input });
}
if (!previous || previous.input !== setting.input || previous.overlay !== setting.overlay) {
commands.push({ Function: `OverlayInput${setting.overlay}In`, Input: setting.input });
}
state.hockeyTeamStateOverlayActive.set(key, { input: setting.input, overlay: setting.overlay });
} else if (previous) {
commands.push({ Function: `OverlayInput${previous.overlay}Out`, Input: previous.input });
state.hockeyTeamStateOverlayActive.delete(key);
}
if (commands.length) await sendRuntimeVmixSequence(commands);
return commands.length;
}
async function hockeyToggleMatchFlag(key) {
const current = Boolean(hockeyMatchFlags()[key]);
return hockeySetMatchFlags({ [key]: !current });
const next = !current;
const live = hockeyTeamStateScoreboardIsLive();
if (live) {
// BUILD113: delayed penalty / empty net are operator-live controls. Do not wait
// for the database round-trip before showing/removing the configured overlay.
hockeyApplyTeamStateOverlayImmediately(key, next).catch((error) => console.error("Immediate team-state overlay error", error));
}
try {
return await hockeySetMatchFlags({ [key]: next });
} catch (error) {
if (live) hockeyApplyTeamStateOverlayImmediately(key, current).catch(() => {});
throw error;
}
}
function hockeyPrematchFlagKey(buttonId) {
@@ -14734,6 +14830,11 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
} finally {
state.hockeyTimerHydrating = false;
}
// BUILD113: changing/setting the period immediately writes 20:00 / 05:00
// (or the configured period value) into vMix. The operator should never have
// to put the scorebug on air first just to seed its clock.
syncConfiguredScoreboardCountdownsToRuntime()
.catch((error) => console.error("Immediate game countdown preload error", error));
}
if (state.activeTab === "shootout") renderRuntime();
});