diff --git a/hockey_data/service.py b/hockey_data/service.py index 4917406..d395d23 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -5537,23 +5537,13 @@ class HockeyDataService: elif state_key not in {"5x5", ""}: state_label = state_key.replace("x", " on " if language_key == "en" else " на ") - coincidental_penalties = bool( - home_penalties > 0 - and away_penalties > 0 - and home_skaters == away_skaters - ) - complex_mixed_penalties = bool( - home_penalties > 0 - 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 "" + # BUILD89: television output has only one power-play plate. Equal + # coincidental strength never gets a side caption. As soon as a real + # numerical advantage exists (including 4x3 / 3x4 with penalties on both + # benches), expose the configured state label only on the advantage side. + # `state_label` itself remains available in Mapping for custom graphics. + home_label = state_label if advantage_side == "home" else "" + away_label = state_label if advantage_side == "away" else "" phase_labels = { "regulation": ("Основное время", "Regulation"), "regular_overtime": ("Овертайм регулярки", "Regular OT"), diff --git a/tests/test_build77_penalty_overlay_auto_in.py b/tests/test_build77_penalty_overlay_auto_in.py index a71cd17..948cde3 100644 --- a/tests/test_build77_penalty_overlay_auto_in.py +++ b/tests/test_build77_penalty_overlay_auto_in.py @@ -8,7 +8,7 @@ def test_ready_penalty_opens_target_when_scoreboard_is_live(): end = APP_JS.index("function finishActionMatchesSource", start) snippet = APP_JS[start:end] 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(): diff --git a/tests/test_build81_penalty_mapping_sort_and_mutual_labels.py b/tests/test_build81_penalty_mapping_sort_and_mutual_labels.py index 41604d9..3a1b368 100644 --- a/tests/test_build81_penalty_mapping_sort_and_mutual_labels.py +++ b/tests/test_build81_penalty_mapping_sort_and_mutual_labels.py @@ -50,7 +50,7 @@ def test_mapping_input_picker_sorts_by_number_and_fields_naturally(): 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 = { "strength_regulation_skaters": 5, "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") assert value["advantage_side"] == "" assert value["state_key"] == "4x4" - assert value["home_label"] == "4 на 4" - assert value["away_label"] == "4 на 4" + assert value["home_label"] == "" + assert value["away_label"] == "" diff --git a/tests/test_build83_penalty_advantage_and_server_onair.py b/tests/test_build83_penalty_advantage_and_server_onair.py index e91e5f4..d2bd4ce 100644 --- a/tests/test_build83_penalty_advantage_and_server_onair.py +++ b/tests/test_build83_penalty_advantage_and_server_onair.py @@ -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") -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") end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) block = APP_JS[start:end] - assert 'if (home.length && away.length)' in block - assert 'return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };' in block - assert 'return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };' in block + assert 'function penaltyLocalAdvantageSide' in APP_JS + assert 'const all = [...home, ...away].sort' 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(): diff --git a/tests/test_build85_penalty_advantage_sql_reference.py b/tests/test_build85_penalty_advantage_sql_reference.py index abb8d05..bdc567a 100644 --- a/tests/test_build85_penalty_advantage_sql_reference.py +++ b/tests/test_build85_penalty_advantage_sql_reference.py @@ -44,14 +44,16 @@ def test_strength_engine_reports_advantage_for_4x3_and_3x4(): 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") end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) block = APP_JS[start:end] - assert 'if (home.length && away.length)' in block - assert 'return { home: take(home), away: take(away), routedToAdvantage: false, advantageSide: "" };' in block - assert 'return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };' in block - assert 'return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };' in block + assert 'const all = [...home, ...away].sort' in block + assert 'const advantageSide = penaltyLocalAdvantageSide(home.length, away.length);' in block + assert 'if (!advantageSide)' 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): diff --git a/tests/test_build87_penalty_strength_rebalance.py b/tests/test_build87_penalty_strength_rebalance.py index 1ba0141..893c794 100644 --- a/tests/test_build87_penalty_strength_rebalance.py +++ b/tests/test_build87_penalty_strength_rebalance.py @@ -14,11 +14,12 @@ def test_authoritative_strength_change_rebalances_penalty_targets(): 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") end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) block = APP_JS[start:end] - assert 'if (home.length && away.length)' in block - assert 'routedToAdvantage: false' in block - assert 'if (home.length)' in block - assert 'if (away.length)' in block + assert 'penaltyLocalAdvantageSide(home.length, away.length)' in block + assert 'transitionEntry = all[0]' in block + assert 'routedToAdvantage: true' in block + assert 'if (!advantageSide)' in block + diff --git a/tests/test_build88_complex_penalty_display.py b/tests/test_build88_complex_penalty_display.py index 9f0f7af..d8e9e81 100644 --- a/tests/test_build88_complex_penalty_display.py +++ b/tests/test_build88_complex_penalty_display.py @@ -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 = { "strength_regulation_skaters": 5, "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["advantage_side"] == "away" assert value["home_label"] == "" - assert value["away_label"] == "" + assert value["away_label"] == "Играют в большинстве" 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"] == "Играют в большинстве" -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") end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) block = APP_JS[start:end] - assert 'if (home.length && away.length)' in block - assert 'return { home: take(home), away: take(away), routedToAdvantage: false, advantageSide: "" };' in block - assert 'return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };' in block - assert 'return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };' in block - assert 'return { home: [], away: [], routedToAdvantage: false, advantageSide: "" };' in block + assert 'const all = [...home, ...away].sort' in block + assert 'return { home: [], away: [], routedToAdvantage: false' in block + assert 'home: advantageSide === "home" && transitionEntry ? [transitionEntry] : []' in block + assert 'away: advantageSide === "away" && transitionEntry ? [transitionEntry] : []' in block diff --git a/tests/test_build89_single_penalty_plate_transition.py b/tests/test_build89_single_penalty_plate_transition.py new file mode 100644 index 0000000..b1e9941 --- /dev/null +++ b/tests/test_build89_single_penalty_plate_transition.py @@ -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 diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 0fcbce0..a69b8f9 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -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(() => {}); }