поправлены в какой то раз удаления для верхнего счета

This commit is contained in:
2026-08-20 15:20:19 +03:00
parent 3dc21e4619
commit 8790a1e113
9 changed files with 244 additions and 78 deletions

View File

@@ -84,6 +84,7 @@
vmixPenaltyMirrors: new Map(),
activeHockeyVmixTimerSteps: new Set(),
vmixPenaltyTargetAssignments: new Map(),
hockeyPenaltyAdvantageCycle: { hadAdvantage: false, lastAdvantageSide: "" },
hockeyPenaltyMappingContextSignature: "",
hockeyPenaltyMappingContextPending: false,
hockeyPenaltyMappingContextQueued: false,
@@ -4635,36 +4636,76 @@ function startCustomTooltips() {
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
}
// BUILD88: complex/coincidental penalties must not immediately switch the
// scorebug extension into a power-play side merely because the raw skater
// count is 4x3/3x4. While both benches still have active penalties, keep one
// penalty on each team's own target. Only after one bench becomes completely
// clear does the remaining penalty move to the opposite target (the team that
// is now actually shown as playing on the power play). If both benches clear
// together, the plan is empty and both extra plates are removed.
// BUILD89: the scorebug has only ONE penalty/power-play plate at a time.
// The plate belongs to the team that currently has the numerical advantage.
// Its timer is the next strength-transition timer: the shortest remaining
// active penalty across BOTH benches. Pure coincidental/equal strength shows
// no penalty plate at all. When that shortest timer expires we recalculate and
// keep the same single plate with the next timer if an advantage still exists.
function penaltyLocalAdvantageSide(homeCount, awayCount) {
const strength = getByPath(state.data, "hockey.game_control.strength") || {};
const authoritativeHome = Number(strength.home_penalties);
const authoritativeAway = Number(strength.away_penalties);
if (Number.isFinite(authoritativeHome) && Number.isFinite(authoritativeAway)
&& authoritativeHome === homeCount && authoritativeAway === awayCount) {
const side = String(strength.advantage_side || "");
if (side === "home" || side === "away") return side;
return "";
}
const base = Math.max(3, Math.min(6, Number(strength.base_skaters || 5)));
const minimum = Math.max(2, Math.min(base, Number(strength.minimum_skaters || 3)));
const mode = String(strength.penalty_mode || "subtract");
let homeSkaters;
let awaySkaters;
if (mode === "add_opponent") {
homeSkaters = Math.min(5, base + awayCount);
awaySkaters = Math.min(5, base + homeCount);
} else {
homeSkaters = Math.max(minimum, base - homeCount);
awaySkaters = Math.max(minimum, base - awayCount);
}
return homeSkaters > awaySkaters ? "home" : awaySkaters > homeSkaters ? "away" : "";
}
function penaltyDisplayEntriesByTargetSide(step) {
const home = sortedPenaltyEntries("home");
const away = sortedPenaltyEntries("away");
const soonestOnly = String(step?.penalty_display_mode || "soonest") !== "all";
const take = (items) => soonestOnly ? items.slice(0, 1) : items;
// Complex/coincidental state: do not announce a power-play side yet.
if (home.length && away.length) {
return { home: take(home), away: take(away), routedToAdvantage: false, advantageSide: "" };
const all = [...home, ...away].sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0)
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
if (!all.length) {
return { home: [], away: [], routedToAdvantage: false, advantageSide: "", transitionEntry: null };
}
// Only HOME still has a penalty -> AWAY is the displayed PP side.
if (home.length) {
return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };
const advantageSide = penaltyLocalAdvantageSide(home.length, away.length);
if (!advantageSide) {
return { home: [], away: [], routedToAdvantage: false, advantageSide: "", transitionEntry: all[0] || null };
}
// Only AWAY still has a penalty -> HOME is the displayed PP side.
if (away.length) {
return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };
}
const transitionEntry = all[0] || null;
return {
home: advantageSide === "home" && transitionEntry ? [transitionEntry] : [],
away: advantageSide === "away" && transitionEntry ? [transitionEntry] : [],
routedToAdvantage: true,
advantageSide,
transitionEntry,
};
}
// Both sides cleared at the same tick: show nothing.
return { home: [], away: [], routedToAdvantage: false, advantageSide: "" };
function rememberPenaltyAdvantagePlan(displayPlan) {
if (!displayPlan?.routedToAdvantage || !["home", "away"].includes(String(displayPlan.advantageSide || ""))) return;
state.hockeyPenaltyAdvantageCycle.hadAdvantage = true;
state.hockeyPenaltyAdvantageCycle.lastAdvantageSide = String(displayPlan.advantageSide);
}
function resetPenaltyAdvantageCycle() {
state.hockeyPenaltyAdvantageCycle.hadAdvantage = false;
state.hockeyPenaltyAdvantageCycle.lastAdvantageSide = "";
}
function penaltyFullStrengthSide() {
const advantage = String(state.hockeyPenaltyAdvantageCycle.lastAdvantageSide || "");
return advantage === "home" ? "away" : advantage === "away" ? "home" : "";
}
function hockeyPenaltySideMappingDetail(item, side) {
@@ -4750,7 +4791,9 @@ function startCustomTooltips() {
}
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true } = {}) {
const commands = [];
const outCommands = [];
const setCommands = [];
const inCommands = [];
const assignedMirrorKeys = new Set();
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
const step = hockeyTimerSyncStepById(stepId);
@@ -4760,6 +4803,7 @@ function startCustomTooltips() {
}
if (step.penalty_vmix_mode !== "text") continue;
const displayPlan = penaltyDisplayEntriesByTargetSide(step);
rememberPenaltyAdvantagePlan(displayPlan);
for (const side of ["home", "away"]) {
const allTargets = sequencePenaltyTargets(step, side);
const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all";
@@ -4790,7 +4834,7 @@ function startCustomTooltips() {
const value = formatHockeyPenaltyTime(entry.event.remainingMs);
const mirror = state.vmixPenaltyMirrors.get(eventKey);
if (force || !mirror || mirror.lastValue !== value || previous?.eventKey !== eventKey) {
commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: value });
setCommands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: value });
if (mirror) mirror.lastValue = value;
}
@@ -4804,15 +4848,15 @@ function startCustomTooltips() {
if (!targetWasVisible) {
if (previous?.input && (String(previous.input) !== String(target.input) || String(previous.overlay || overlay) !== overlay)) {
const previousOverlay = ["1", "2", "3", "4"].includes(String(previous.overlay || "")) ? String(previous.overlay) : overlay;
commands.push({ Function: `OverlayInput${previousOverlay}Out`, Input: previous.input });
outCommands.push({ Function: `OverlayInput${previousOverlay}Out`, Input: previous.input });
}
commands.push({ Function: `OverlayInput${overlay}In`, Input: target.input });
inCommands.push({ Function: `OverlayInput${overlay}In`, Input: target.input });
}
}
} else {
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) {
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
commands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
}
state.vmixPenaltyTargetAssignments.delete(assignmentKey);
}
@@ -4822,7 +4866,7 @@ function startCustomTooltips() {
const previous = state.vmixPenaltyTargetAssignments.get(assignmentKey);
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) {
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
commands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
}
state.vmixPenaltyTargetAssignments.delete(assignmentKey);
});
@@ -4833,6 +4877,10 @@ function startCustomTooltips() {
state.vmixPenaltyMirrors.delete(mirrorKey);
}
}
// Always take the old single plate OUT before putting the new side IN.
// HOME and AWAY targets frequently share the same Overlay slot; sending IN
// first and OUT second would remove the newly selected plate.
const commands = [...outCommands, ...setCommands, ...inCommands];
if (commands.length) await sendRuntimeVmixSequence(commands);
return commands.length;
}
@@ -4875,7 +4923,16 @@ function startCustomTooltips() {
if (source === "game" && String(step.game_timer_action_id || "hockey_game_timer") !== gameActionId) return;
normalizeTimerFinishActions(step.timer_finish_actions).forEach((action) => {
if (!finishActionMatchesSource(action, source, side)) return;
if (source === "penalty" && action.only_when_side_clear !== false && Number(meta.remaining_on_side || 0) > 0) return;
if (source === "penalty" && action.only_when_side_clear !== false) {
// BUILD89: the full-strength/final plate is a FINAL transition only.
// Do not fire it when a coincidental timer ends while another penalty
// is still active, and do not fire it after a purely coincidental
// sequence that never produced a numerical advantage.
if (Number(meta.remaining_total || 0) > 0) return;
if (!Boolean(meta.had_advantage)) return;
const fullStrengthSide = String(meta.full_strength_side || "");
if (fullStrengthSide && side !== fullStrengthSide) return;
}
actions.push(action);
});
});
@@ -4946,6 +5003,7 @@ function startCustomTooltips() {
if (step.sync_vmix_penalties) {
const displayPlan = penaltyDisplayEntriesByTargetSide(step);
rememberPenaltyAdvantagePlan(displayPlan);
const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all";
const activeHomeTargets = soonestOnly ? homeTargets.slice(0, 1) : homeTargets;
const activeAwayTargets = soonestOnly ? awayTargets.slice(0, 1) : awayTargets;
@@ -6734,9 +6792,12 @@ function openTimerQuickEditor(focusActionId = "") {
...hockeyPenaltyContext(event)
});
state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event));
const hadAdvantage = Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage);
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) && 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;
@@ -6747,7 +6808,16 @@ function openTimerQuickEditor(focusActionId = "") {
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
.catch((error) => console.error("Penalty target finish rebalance error", error))
.finally(() => fireConfiguredTimerFinishActions("penalty", { side, component, event, remaining_on_side: remainingOnSide }));
.finally(() => {
fireConfiguredTimerFinishActions("penalty", {
side, component, event,
remaining_on_side: remainingOnSide,
remaining_total: remainingTotal,
had_advantage: hadAdvantage,
full_strength_side: fullStrengthSide,
});
if (remainingTotal <= 0) resetPenaltyAdvantageCycle();
});
return true;
} else if (command === "set_time") {
event.remainingMs = Math.max(0, parseTimerMilliseconds(rawValue, event.remainingMs));
@@ -6783,6 +6853,7 @@ function openTimerQuickEditor(focusActionId = "") {
});
state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event));
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
if (!board.penalties.some((item) => !item.finished && hockeyEventReady(item))) resetPenaltyAdvantageCycle();
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }).catch((error) => console.error("Penalty target remove rebalance error", error));
return true;
}
@@ -6878,6 +6949,9 @@ function openTimerQuickEditor(focusActionId = "") {
persistHockeyBoard(component, board, true);
refreshHockeyBoardNodes(component);
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
const hadAdvantage = Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage);
const fullStrengthSide = penaltyFullStrengthSide();
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();
@@ -6888,8 +6962,15 @@ function openTimerQuickEditor(focusActionId = "") {
.finally(() => {
finishBySide.forEach((event, side) => {
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 });
fireConfiguredTimerFinishActions("penalty", {
side, component, event,
remaining_on_side: remainingOnSide,
remaining_total: remainingTotal,
had_advantage: hadAdvantage,
full_strength_side: fullStrengthSide,
});
});
if (remainingTotal <= 0) resetPenaltyAdvantageCycle();
});
return;
}
@@ -11129,10 +11210,9 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|| hockeyStrengthMappingSignature(previousControl) !== hockeyStrengthMappingSignature(payload);
if (strengthChanged) {
hockeyRefreshVmixMappingForStrength(gameId, previousControl, payload).catch(() => {});
// BUILD88: keep a second rebalance after the authoritative control payload
// arrives. The display plan itself intentionally keeps HOME/AWAY on their
// own sides while both benches have active penalties; once one bench clears,
// this refresh moves the remaining timer to the actual power-play side.
// 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) {
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
.catch((error) => console.error("Penalty strength rebalance error", error));
@@ -11441,6 +11521,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
refreshHockeyBoardNodes(boardComponent);
}
state.hockeyTimerGameId = String(gameId || "");
resetPenaltyAdvantageCycle();
state.hockeyPenaltyMappingContextSignature = "";
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
}