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

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

@@ -5537,23 +5537,13 @@ class HockeyDataService:
elif state_key not in {"5x5", ""}: elif state_key not in {"5x5", ""}:
state_label = state_key.replace("x", " on " if language_key == "en" else " на ") state_label = state_key.replace("x", " on " if language_key == "en" else " на ")
coincidental_penalties = bool( # BUILD89: television output has only one power-play plate. Equal
home_penalties > 0 # coincidental strength never gets a side caption. As soon as a real
and away_penalties > 0 # numerical advantage exists (including 4x3 / 3x4 with penalties on both
and home_skaters == away_skaters # benches), expose the configured state label only on the advantage side.
) # `state_label` itself remains available in Mapping for custom graphics.
complex_mixed_penalties = bool( home_label = state_label if advantage_side == "home" else ""
home_penalties > 0 away_label = state_label if advantage_side == "away" else ""
and away_penalties > 0
and home_skaters != away_skaters
)
# BUILD88: numerical strength can already be 4x3/3x4 while both benches
# still have active penalties. Keep advantage_side truthful for stats and
# Mapping, but do not output the PP caption until one bench is completely
# clear. Equal coincidental states (4x4/3x3) still show the configured
# value on both sides as requested in BUILD81.
home_label = state_label if coincidental_penalties or (advantage_side == "home" and not complex_mixed_penalties) else ""
away_label = state_label if coincidental_penalties or (advantage_side == "away" and not complex_mixed_penalties) else ""
phase_labels = { phase_labels = {
"regulation": ("Основное время", "Regulation"), "regulation": ("Основное время", "Regulation"),
"regular_overtime": ("Овертайм регулярки", "Regular OT"), "regular_overtime": ("Овертайм регулярки", "Regular OT"),

View File

@@ -8,7 +8,7 @@ def test_ready_penalty_opens_target_when_scoreboard_is_live():
end = APP_JS.index("function finishActionMatchesSource", start) end = APP_JS.index("function finishActionMatchesSource", start)
snippet = APP_JS[start:end] snippet = APP_JS[start:end]
assert "if (hockeyScoreboardIsLive())" in snippet assert "if (hockeyScoreboardIsLive())" in snippet
assert 'commands.push({ Function: `OverlayInput${overlay}In`, Input: target.input });' in snippet assert 'inCommands.push({ Function: `OverlayInput${overlay}In`, Input: target.input });' in snippet
def test_penalty_overlay_auto_in_does_not_retrigger_visible_target(): def test_penalty_overlay_auto_in_does_not_retrigger_visible_target():

View File

@@ -50,7 +50,7 @@ def test_mapping_input_picker_sorts_by_number_and_fields_naturally():
assert "const allFields = [...(currentInput.fields || [])].sort(mappingVmixFieldOrder);" in ADMIN assert "const allFields = [...(currentInput.fields || [])].sort(mappingVmixFieldOrder);" in ADMIN
def test_coincidental_penalties_fill_both_home_and_away_labels(): def test_coincidental_penalties_do_not_fill_side_powerplay_labels():
settings = { settings = {
"strength_regulation_skaters": 5, "strength_regulation_skaters": 5,
"strength_min_skaters": 3, "strength_min_skaters": 3,
@@ -62,5 +62,5 @@ def test_coincidental_penalties_fill_both_home_and_away_labels():
value = HockeyDataService._strength_payload(settings, stage="regular", current_period="1", timer_state=timer_state, language="ru") value = HockeyDataService._strength_payload(settings, stage="regular", current_period="1", timer_state=timer_state, language="ru")
assert value["advantage_side"] == "" assert value["advantage_side"] == ""
assert value["state_key"] == "4x4" assert value["state_key"] == "4x4"
assert value["home_label"] == "4 на 4" assert value["home_label"] == ""
assert value["away_label"] == "4 на 4" assert value["away_label"] == ""

View File

@@ -7,13 +7,15 @@ APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8") BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8")
def test_penalty_display_routes_single_side_to_opposite_advantage_target(): def test_penalty_display_routes_only_one_transition_timer_to_advantage_target():
start = APP_JS.index("function penaltyDisplayEntriesByTargetSide") start = APP_JS.index("function penaltyDisplayEntriesByTargetSide")
end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) end = APP_JS.index("function hockeyPenaltySideMappingDetail", start)
block = APP_JS[start:end] block = APP_JS[start:end]
assert 'if (home.length && away.length)' in block assert 'function penaltyLocalAdvantageSide' in APP_JS
assert 'return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };' in block assert 'const all = [...home, ...away].sort' in block
assert 'return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };' in block assert 'if (!advantageSide)' in block
assert 'home: advantageSide === "home"' in block
assert 'away: advantageSide === "away"' in block
def test_penalty_rebalance_and_initial_timer_use_same_display_plan(): def test_penalty_rebalance_and_initial_timer_use_same_display_plan():

View File

@@ -44,14 +44,16 @@ def test_strength_engine_reports_advantage_for_4x3_and_3x4():
assert away_advantage["advantage_side"] == "away" assert away_advantage["advantage_side"] == "away"
def test_penalty_plate_routing_defers_powerplay_while_both_sides_have_penalties(): def test_penalty_plate_routing_uses_single_advantage_side_and_global_shortest_timer():
start = APP_JS.index("function penaltyDisplayEntriesByTargetSide") start = APP_JS.index("function penaltyDisplayEntriesByTargetSide")
end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) end = APP_JS.index("function hockeyPenaltySideMappingDetail", start)
block = APP_JS[start:end] block = APP_JS[start:end]
assert 'if (home.length && away.length)' in block assert 'const all = [...home, ...away].sort' in block
assert 'return { home: take(home), away: take(away), routedToAdvantage: false, advantageSide: "" };' in block assert 'const advantageSide = penaltyLocalAdvantageSide(home.length, away.length);' in block
assert 'return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };' in block assert 'if (!advantageSide)' in block
assert 'return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };' in block assert 'transitionEntry = all[0]' in block
assert 'home: advantageSide === "home"' in block
assert 'away: advantageSide === "away"' in block
def test_database_schema_reference_lists_real_hockey_tables(tmp_path: Path): def test_database_schema_reference_lists_real_hockey_tables(tmp_path: Path):

View File

@@ -14,11 +14,12 @@ def test_authoritative_strength_change_rebalances_penalty_targets():
assert "Penalty strength rebalance error" in block assert "Penalty strength rebalance error" in block
def test_penalty_plan_rebalances_after_strength_refresh_without_forcing_complex_powerplay(): def test_penalty_plan_rebalances_after_strength_refresh_to_single_advantage_plate():
start = APP_JS.index("function penaltyDisplayEntriesByTargetSide") start = APP_JS.index("function penaltyDisplayEntriesByTargetSide")
end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) end = APP_JS.index("function hockeyPenaltySideMappingDetail", start)
block = APP_JS[start:end] block = APP_JS[start:end]
assert 'if (home.length && away.length)' in block assert 'penaltyLocalAdvantageSide(home.length, away.length)' in block
assert 'routedToAdvantage: false' in block assert 'transitionEntry = all[0]' in block
assert 'if (home.length)' in block assert 'routedToAdvantage: true' in block
assert 'if (away.length)' in block assert 'if (!advantageSide)' in block

View File

@@ -17,7 +17,7 @@ def _penalty(side: str, remaining: int = 90000) -> dict:
} }
def test_complex_two_vs_one_keeps_real_strength_but_hides_pp_caption_until_one_side_clears(): def test_complex_two_vs_one_keeps_real_strength_and_outputs_caption_on_advantage_side():
settings = { settings = {
"strength_regulation_skaters": 5, "strength_regulation_skaters": 5,
"strength_min_skaters": 3, "strength_min_skaters": 3,
@@ -33,7 +33,7 @@ def test_complex_two_vs_one_keeps_real_strength_but_hides_pp_caption_until_one_s
assert value["strength_label"] == "3×4" assert value["strength_label"] == "3×4"
assert value["advantage_side"] == "away" assert value["advantage_side"] == "away"
assert value["home_label"] == "" assert value["home_label"] == ""
assert value["away_label"] == "" assert value["away_label"] == "Играют в большинстве"
def test_single_remaining_penalty_outputs_pp_caption_on_opposite_team(): def test_single_remaining_penalty_outputs_pp_caption_on_opposite_team():
@@ -54,12 +54,11 @@ def test_single_remaining_penalty_outputs_pp_caption_on_opposite_team():
assert value["away_label"] == "Играют в большинстве" assert value["away_label"] == "Играют в большинстве"
def test_display_plan_keeps_each_side_while_both_have_penalties_and_clears_when_none(): def test_display_plan_never_outputs_two_penalty_plates():
start = APP_JS.index("function penaltyDisplayEntriesByTargetSide") start = APP_JS.index("function penaltyDisplayEntriesByTargetSide")
end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) end = APP_JS.index("function hockeyPenaltySideMappingDetail", start)
block = APP_JS[start:end] block = APP_JS[start:end]
assert 'if (home.length && away.length)' in block assert 'const all = [...home, ...away].sort' in block
assert 'return { home: take(home), away: take(away), routedToAdvantage: false, advantageSide: "" };' in block assert 'return { home: [], away: [], routedToAdvantage: false' in block
assert 'return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };' in block assert 'home: advantageSide === "home" && transitionEntry ? [transitionEntry] : []' in block
assert 'return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };' in block assert 'away: advantageSide === "away" && transitionEntry ? [transitionEntry] : []' in block
assert 'return { home: [], away: [], routedToAdvantage: false, advantageSide: "" };' in block

View File

@@ -0,0 +1,91 @@
from pathlib import Path
from hockey_data.service import HockeyDataService
ROOT = Path(__file__).resolve().parents[1]
APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
def _penalty(side: str, remaining: int) -> dict:
return {
"side": side,
"infraction": {"code": "TEST"},
"preset": "2",
"durationMs": 120000,
"remainingMs": remaining,
"finished": False,
}
def test_equal_coincidental_penalties_have_no_side_caption():
settings = {
"strength_regulation_skaters": 5,
"strength_min_skaters": 3,
"strength_state_labels": {"regulation": {"4x4": {"ru": "4 на 4"}}},
}
payload = HockeyDataService._strength_payload(
settings,
stage="regular",
current_period="2",
timer_state={"penalty_board": {"penalties": [_penalty("home", 100000), _penalty("away", 100000)]}},
language="ru",
)
assert payload["strength_label"] == "4×4"
assert payload["advantage_side"] == ""
assert payload["home_label"] == ""
assert payload["away_label"] == ""
def test_two_vs_one_penalties_caption_only_advantage_team():
settings = {
"strength_regulation_skaters": 5,
"strength_min_skaters": 3,
"strength_state_labels": {"regulation": {"4x3": {"ru": "Играют в большинстве"}}},
}
payload = HockeyDataService._strength_payload(
settings,
stage="regular",
current_period="2",
timer_state={"penalty_board": {"penalties": [
_penalty("home", 103000),
_penalty("home", 120000),
_penalty("away", 103000),
]}},
language="ru",
)
assert payload["strength_label"] == "3×4"
assert payload["advantage_side"] == "away"
assert payload["home_label"] == ""
assert payload["away_label"] == "Играют в большинстве"
def test_runtime_plan_uses_one_plate_and_global_shortest_transition_timer():
start = APP_JS.index("function penaltyDisplayEntriesByTargetSide")
end = APP_JS.index("function hockeyPenaltySideMappingDetail", start)
block = APP_JS[start:end]
assert 'const all = [...home, ...away].sort' in block
assert 'const transitionEntry = all[0] || null;' in block
assert 'home: advantageSide === "home" && transitionEntry ? [transitionEntry] : []' in block
assert 'away: advantageSide === "away" && transitionEntry ? [transitionEntry] : []' in block
assert 'if (!advantageSide)' in block
assert 'return { home: [], away: [], routedToAdvantage: false' in block
def test_final_full_strength_action_only_runs_after_last_penalty_and_real_advantage():
start = APP_JS.index("function fireConfiguredTimerFinishActions")
end = APP_JS.index("async function runShortcutSequenceStep", start)
block = APP_JS[start:end]
assert 'Number(meta.remaining_total || 0) > 0' in block
assert '!Boolean(meta.had_advantage)' in block
assert 'full_strength_side' in block
assert 'side !== fullStrengthSide' in block
def test_rebalance_sends_old_plate_out_before_new_plate_in():
start = APP_JS.index("async function rebalanceVmixPenaltyTargets")
end = APP_JS.index("function finishActionMatchesSource", start)
block = APP_JS[start:end]
assert 'const outCommands = [];' in block
assert 'const setCommands = [];' in block
assert 'const inCommands = [];' in block
assert 'const commands = [...outCommands, ...setCommands, ...inCommands];' in block

View File

@@ -84,6 +84,7 @@
vmixPenaltyMirrors: new Map(), vmixPenaltyMirrors: new Map(),
activeHockeyVmixTimerSteps: new Set(), activeHockeyVmixTimerSteps: new Set(),
vmixPenaltyTargetAssignments: new Map(), vmixPenaltyTargetAssignments: new Map(),
hockeyPenaltyAdvantageCycle: { hadAdvantage: false, lastAdvantageSide: "" },
hockeyPenaltyMappingContextSignature: "", hockeyPenaltyMappingContextSignature: "",
hockeyPenaltyMappingContextPending: false, hockeyPenaltyMappingContextPending: false,
hockeyPenaltyMappingContextQueued: false, hockeyPenaltyMappingContextQueued: false,
@@ -4635,36 +4636,76 @@ function startCustomTooltips() {
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
} }
// BUILD88: complex/coincidental penalties must not immediately switch the // BUILD89: the scorebug has only ONE penalty/power-play plate at a time.
// scorebug extension into a power-play side merely because the raw skater // The plate belongs to the team that currently has the numerical advantage.
// count is 4x3/3x4. While both benches still have active penalties, keep one // Its timer is the next strength-transition timer: the shortest remaining
// penalty on each team's own target. Only after one bench becomes completely // active penalty across BOTH benches. Pure coincidental/equal strength shows
// clear does the remaining penalty move to the opposite target (the team that // no penalty plate at all. When that shortest timer expires we recalculate and
// is now actually shown as playing on the power play). If both benches clear // keep the same single plate with the next timer if an advantage still exists.
// together, the plan is empty and both extra plates are removed. 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) { function penaltyDisplayEntriesByTargetSide(step) {
const home = sortedPenaltyEntries("home"); const home = sortedPenaltyEntries("home");
const away = sortedPenaltyEntries("away"); const away = sortedPenaltyEntries("away");
const soonestOnly = String(step?.penalty_display_mode || "soonest") !== "all"; const all = [...home, ...away].sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0)
const take = (items) => soonestOnly ? items.slice(0, 1) : items; || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
if (!all.length) {
// Complex/coincidental state: do not announce a power-play side yet. return { home: [], away: [], routedToAdvantage: false, advantageSide: "", transitionEntry: null };
if (home.length && away.length) {
return { home: take(home), away: take(away), routedToAdvantage: false, advantageSide: "" };
} }
// Only HOME still has a penalty -> AWAY is the displayed PP side. const advantageSide = penaltyLocalAdvantageSide(home.length, away.length);
if (home.length) { if (!advantageSide) {
return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" }; return { home: [], away: [], routedToAdvantage: false, advantageSide: "", transitionEntry: all[0] || null };
} }
// Only AWAY still has a penalty -> HOME is the displayed PP side. const transitionEntry = all[0] || null;
if (away.length) { return {
return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" }; home: advantageSide === "home" && transitionEntry ? [transitionEntry] : [],
away: advantageSide === "away" && transitionEntry ? [transitionEntry] : [],
routedToAdvantage: true,
advantageSide,
transitionEntry,
};
} }
// Both sides cleared at the same tick: show nothing. function rememberPenaltyAdvantagePlan(displayPlan) {
return { home: [], away: [], routedToAdvantage: false, advantageSide: "" }; 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) { function hockeyPenaltySideMappingDetail(item, side) {
@@ -4750,7 +4791,9 @@ function startCustomTooltips() {
} }
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true } = {}) { async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true } = {}) {
const commands = []; const outCommands = [];
const setCommands = [];
const inCommands = [];
const assignedMirrorKeys = new Set(); const assignedMirrorKeys = new Set();
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) { for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
const step = hockeyTimerSyncStepById(stepId); const step = hockeyTimerSyncStepById(stepId);
@@ -4760,6 +4803,7 @@ function startCustomTooltips() {
} }
if (step.penalty_vmix_mode !== "text") continue; if (step.penalty_vmix_mode !== "text") continue;
const displayPlan = penaltyDisplayEntriesByTargetSide(step); const displayPlan = penaltyDisplayEntriesByTargetSide(step);
rememberPenaltyAdvantagePlan(displayPlan);
for (const side of ["home", "away"]) { for (const side of ["home", "away"]) {
const allTargets = sequencePenaltyTargets(step, side); const allTargets = sequencePenaltyTargets(step, side);
const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all"; const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all";
@@ -4790,7 +4834,7 @@ function startCustomTooltips() {
const value = formatHockeyPenaltyTime(entry.event.remainingMs); const value = formatHockeyPenaltyTime(entry.event.remainingMs);
const mirror = state.vmixPenaltyMirrors.get(eventKey); const mirror = state.vmixPenaltyMirrors.get(eventKey);
if (force || !mirror || mirror.lastValue !== value || previous?.eventKey !== 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; if (mirror) mirror.lastValue = value;
} }
@@ -4804,15 +4848,15 @@ function startCustomTooltips() {
if (!targetWasVisible) { if (!targetWasVisible) {
if (previous?.input && (String(previous.input) !== String(target.input) || String(previous.overlay || overlay) !== overlay)) { 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; 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 { } else {
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) { 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"; 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); state.vmixPenaltyTargetAssignments.delete(assignmentKey);
} }
@@ -4822,7 +4866,7 @@ function startCustomTooltips() {
const previous = state.vmixPenaltyTargetAssignments.get(assignmentKey); const previous = state.vmixPenaltyTargetAssignments.get(assignmentKey);
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) { 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"; 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); state.vmixPenaltyTargetAssignments.delete(assignmentKey);
}); });
@@ -4833,6 +4877,10 @@ function startCustomTooltips() {
state.vmixPenaltyMirrors.delete(mirrorKey); 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); if (commands.length) await sendRuntimeVmixSequence(commands);
return commands.length; return commands.length;
} }
@@ -4875,7 +4923,16 @@ function startCustomTooltips() {
if (source === "game" && String(step.game_timer_action_id || "hockey_game_timer") !== gameActionId) return; if (source === "game" && String(step.game_timer_action_id || "hockey_game_timer") !== gameActionId) return;
normalizeTimerFinishActions(step.timer_finish_actions).forEach((action) => { normalizeTimerFinishActions(step.timer_finish_actions).forEach((action) => {
if (!finishActionMatchesSource(action, source, side)) return; 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); actions.push(action);
}); });
}); });
@@ -4946,6 +5003,7 @@ function startCustomTooltips() {
if (step.sync_vmix_penalties) { if (step.sync_vmix_penalties) {
const displayPlan = penaltyDisplayEntriesByTargetSide(step); const displayPlan = penaltyDisplayEntriesByTargetSide(step);
rememberPenaltyAdvantagePlan(displayPlan);
const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all"; const soonestOnly = String(step.penalty_display_mode || "soonest") !== "all";
const activeHomeTargets = soonestOnly ? homeTargets.slice(0, 1) : homeTargets; const activeHomeTargets = soonestOnly ? homeTargets.slice(0, 1) : homeTargets;
const activeAwayTargets = soonestOnly ? awayTargets.slice(0, 1) : awayTargets; const activeAwayTargets = soonestOnly ? awayTargets.slice(0, 1) : awayTargets;
@@ -6734,9 +6792,12 @@ function openTimerQuickEditor(focusActionId = "") {
...hockeyPenaltyContext(event) ...hockeyPenaltyContext(event)
}); });
state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, 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); board.penalties = board.penalties.filter((item) => item.id !== event.id);
const side = String(event.player?.side || event.side || "").toLowerCase(); 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 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 clearCommonSelection = board.selectedEventId === event.id;
const clearSideSelection = board.selectedPreviewEventIds?.[side] === event.id; const clearSideSelection = board.selectedPreviewEventIds?.[side] === event.id;
if (clearCommonSelection) board.selectedEventId = null; if (clearCommonSelection) board.selectedEventId = null;
@@ -6747,7 +6808,16 @@ function openTimerQuickEditor(focusActionId = "") {
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
.catch((error) => console.error("Penalty target finish rebalance error", error)) .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; return true;
} else if (command === "set_time") { } else if (command === "set_time") {
event.remainingMs = Math.max(0, parseTimerMilliseconds(rawValue, event.remainingMs)); event.remainingMs = Math.max(0, parseTimerMilliseconds(rawValue, event.remainingMs));
@@ -6783,6 +6853,7 @@ function openTimerQuickEditor(focusActionId = "") {
}); });
state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event)); state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event));
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); 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)); rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }).catch((error) => console.error("Penalty target remove rebalance error", error));
return true; return true;
} }
@@ -6878,6 +6949,9 @@ function openTimerQuickEditor(focusActionId = "") {
persistHockeyBoard(component, board, true); persistHockeyBoard(component, board, true);
refreshHockeyBoardNodes(component); refreshHockeyBoardNodes(component);
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); 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(); const finishBySide = new Map();
completedEvents.forEach((event) => { completedEvents.forEach((event) => {
const side = String(event.player?.side || event.side || "").toLowerCase(); const side = String(event.player?.side || event.side || "").toLowerCase();
@@ -6888,9 +6962,16 @@ function openTimerQuickEditor(focusActionId = "") {
.finally(() => { .finally(() => {
finishBySide.forEach((event, side) => { finishBySide.forEach((event, side) => {
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(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 }); fireConfiguredTimerFinishActions("penalty", {
side, component, event,
remaining_on_side: remainingOnSide,
remaining_total: remainingTotal,
had_advantage: hadAdvantage,
full_strength_side: fullStrengthSide,
}); });
}); });
if (remainingTotal <= 0) resetPenaltyAdvantageCycle();
});
return; return;
} }
@@ -11129,10 +11210,9 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
|| hockeyStrengthMappingSignature(previousControl) !== hockeyStrengthMappingSignature(payload); || hockeyStrengthMappingSignature(previousControl) !== hockeyStrengthMappingSignature(payload);
if (strengthChanged) { if (strengthChanged) {
hockeyRefreshVmixMappingForStrength(gameId, previousControl, payload).catch(() => {}); hockeyRefreshVmixMappingForStrength(gameId, previousControl, payload).catch(() => {});
// BUILD88: keep a second rebalance after the authoritative control payload // BUILD89: keep a second rebalance after the authoritative control payload
// arrives. The display plan itself intentionally keeps HOME/AWAY on their // arrives. The single penalty plate then follows the real advantage side
// own sides while both benches have active penalties; once one bench clears, // and the globally shortest timer that can change the numerical strength.
// this refresh moves the remaining timer to the actual power-play side.
if (state.activeHockeyVmixTimerSteps.size) { if (state.activeHockeyVmixTimerSteps.size) {
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
.catch((error) => console.error("Penalty strength rebalance error", error)); .catch((error) => console.error("Penalty strength rebalance error", error));
@@ -11441,6 +11521,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
refreshHockeyBoardNodes(boardComponent); refreshHockeyBoardNodes(boardComponent);
} }
state.hockeyTimerGameId = String(gameId || ""); state.hockeyTimerGameId = String(gameId || "");
resetPenaltyAdvantageCycle();
state.hockeyPenaltyMappingContextSignature = ""; state.hockeyPenaltyMappingContextSignature = "";
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
} }