diff --git a/hockey_data/service.py b/hockey_data/service.py index fba537d..4917406 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -5542,8 +5542,18 @@ class HockeyDataService: and away_penalties > 0 and home_skaters == away_skaters ) - home_label = state_label if advantage_side == "home" or coincidental_penalties else "" - away_label = state_label if advantage_side == "away" or coincidental_penalties else "" + 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 "" phase_labels = { "regulation": ("Основное время", "Regulation"), "regular_overtime": ("Овертайм регулярки", "Regular OT"), diff --git a/tests/test_build83_penalty_advantage_and_server_onair.py b/tests/test_build83_penalty_advantage_and_server_onair.py index dbfa8fc..e91e5f4 100644 --- a/tests/test_build83_penalty_advantage_and_server_onair.py +++ b/tests/test_build83_penalty_advantage_and_server_onair.py @@ -12,8 +12,8 @@ def test_penalty_display_routes_single_side_to_opposite_advantage_target(): end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) block = APP_JS[start:end] assert 'if (home.length && away.length)' in block - assert 'return { home: [], away: home.slice(0, 1), routedToAdvantage: true };' in block - assert 'return { home: away.slice(0, 1), away: [], routedToAdvantage: true };' 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 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 42149d8..abb8d05 100644 --- a/tests/test_build85_penalty_advantage_sql_reference.py +++ b/tests/test_build85_penalty_advantage_sql_reference.py @@ -44,15 +44,14 @@ def test_strength_engine_reports_advantage_for_4x3_and_3x4(): assert away_advantage["advantage_side"] == "away" -def test_penalty_plate_routing_uses_strength_advantage_side(): +def test_penalty_plate_routing_defers_powerplay_while_both_sides_have_penalties(): start = APP_JS.index("function penaltyDisplayEntriesByTargetSide") end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) block = APP_JS[start:end] - assert 'getByPath(state.data, "hockey.game_control.strength")' in block - assert 'if (advantageSide === "home")' in block - assert 'return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };' in block - assert 'if (advantageSide === "away")' in block + 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 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 4e48df2..1ba0141 100644 --- a/tests/test_build87_penalty_strength_rebalance.py +++ b/tests/test_build87_penalty_strength_rebalance.py @@ -14,11 +14,11 @@ def test_authoritative_strength_change_rebalances_penalty_targets(): assert "Penalty strength rebalance error" in block -def test_penalty_plan_still_routes_two_vs_one_to_advantage_side(): +def test_penalty_plan_rebalances_after_strength_refresh_without_forcing_complex_powerplay(): start = APP_JS.index("function penaltyDisplayEntriesByTargetSide") end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) block = APP_JS[start:end] - assert 'if (advantageSide === "home")' in block - assert 'return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };' in block - assert 'if (advantageSide === "away")' in block - assert 'return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };' in block + 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 diff --git a/tests/test_build88_complex_penalty_display.py b/tests/test_build88_complex_penalty_display.py new file mode 100644 index 0000000..9f0f7af --- /dev/null +++ b/tests/test_build88_complex_penalty_display.py @@ -0,0 +1,65 @@ +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 = 90000) -> dict: + return { + "side": side, + "infraction": {"code": "TEST"}, + "preset": "2", + "durationMs": 120000, + "remainingMs": remaining, + "finished": False, + } + + +def test_complex_two_vs_one_keeps_real_strength_but_hides_pp_caption_until_one_side_clears(): + settings = { + "strength_regulation_skaters": 5, + "strength_min_skaters": 3, + "strength_state_labels": {"regulation": {"4x3": {"ru": "Играют в большинстве", "en": "Power play"}}}, + } + value = HockeyDataService._strength_payload( + settings, + stage="regular", + current_period="2", + timer_state={"penalty_board": {"penalties": [_penalty("home"), _penalty("home"), _penalty("away")] }}, + language="ru", + ) + assert value["strength_label"] == "3×4" + assert value["advantage_side"] == "away" + assert value["home_label"] == "" + assert value["away_label"] == "" + + +def test_single_remaining_penalty_outputs_pp_caption_on_opposite_team(): + settings = { + "strength_regulation_skaters": 5, + "strength_min_skaters": 3, + "strength_state_labels": {"regulation": {"5x4": {"ru": "Играют в большинстве", "en": "Power play"}}}, + } + value = HockeyDataService._strength_payload( + settings, + stage="regular", + current_period="2", + timer_state={"penalty_board": {"penalties": [_penalty("home")] }}, + language="ru", + ) + assert value["advantage_side"] == "away" + assert value["home_label"] == "" + assert value["away_label"] == "Играют в большинстве" + + +def test_display_plan_keeps_each_side_while_both_have_penalties_and_clears_when_none(): + 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 diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 38058d9..0fcbce0 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -4635,42 +4635,35 @@ function startCustomTooltips() { || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); } - // BUILD85: HOME/AWAY penalty targets describe the TEAM THAT HAS THE NUMERICAL - // ADVANTAGE, not the bench that committed the penalty. This matters not only - // for a normal 5x4/5x3 power play, but also for 4x3 and 3x4 when both benches - // still have active penalties. The authoritative advantage_side is calculated - // by the hockey strength engine. When strength is equal (4x4 / 3x3), keep the - // traditional one-per-side coincidental display. If the strength payload has - // not arrived yet, the single-penalty fallback still routes to the opposite - // target exactly as BUILD83 did. + // 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. function penaltyDisplayEntriesByTargetSide(step) { const home = sortedPenaltyEntries("home"); const away = sortedPenaltyEntries("away"); const soonestOnly = String(step?.penalty_display_mode || "soonest") !== "all"; - const strength = getByPath(state.data, "hockey.game_control.strength") || {}; - const advantageSide = String(strength.advantage_side || "").trim().toLowerCase(); const take = (items) => soonestOnly ? items.slice(0, 1) : items; - if (advantageSide === "home") { - // HOME has more skaters, therefore an AWAY penalty controls the PP clock. - return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" }; - } - if (advantageSide === "away") { - // AWAY has more skaters, therefore a HOME penalty controls the PP clock. - return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" }; - } - + // 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: "" }; } + + // Only HOME still has a penalty -> AWAY is the displayed PP side. if (home.length) { - if (soonestOnly) return { home: [], away: home.slice(0, 1), routedToAdvantage: true }; - return { home: [], away: home, routedToAdvantage: true, advantageSide: "away" }; + return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" }; } + + // Only AWAY still has a penalty -> HOME is the displayed PP side. if (away.length) { - if (soonestOnly) return { home: away.slice(0, 1), away: [], routedToAdvantage: true }; - return { home: away, away: [], routedToAdvantage: true, advantageSide: "home" }; + return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" }; } + + // Both sides cleared at the same tick: show nothing. return { home: [], away: [], routedToAdvantage: false, advantageSide: "" }; } @@ -11136,12 +11129,10 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { || hockeyStrengthMappingSignature(previousControl) !== hockeyStrengthMappingSignature(payload); if (strengthChanged) { hockeyRefreshVmixMappingForStrength(gameId, previousControl, payload).catch(() => {}); - // BUILD87: penalty routing depends on the recalculated numerical strength. - // The local penalty editor can rebalance a few milliseconds before the - // timer save response arrives, while state.data still contains the old - // 4x4/5x5 strength. Re-run the routing as soon as the authoritative - // control payload is stored so 2 penalties vs 1 immediately becomes - // 3x4/4x3 and the plate moves to the team with the advantage. + // 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. if (state.activeHockeyVmixTimerSteps.size) { rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) .catch((error) => console.error("Penalty strength rebalance error", error)); @@ -13288,7 +13279,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
${timerFinishActionRows(step)}
${escapeHtml(shortcutInventoryLabel())}Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.
-

Режим Text mirror рекомендуется: веб-таймер является источником истины и раз в секунду отправляет SetText строго в выбранные Input + SelectedName. Режим Countdown оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление. Пока удаления есть у обеих команд, HOME/AWAY показываются по своим сторонам. Когда штраф остаётся только у одной команды, его таймер автоматически переезжает на Input противоположной команды — стороны большинства. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.

+

Режим Text mirror рекомендуется: веб-таймер является источником истины и раз в секунду отправляет SetText строго в выбранные Input + SelectedName. Режим Countdown оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление. При сложных/обоюдных удалениях, пока штрафы есть у обеих команд, HOME/AWAY остаются на своих сторонах и режим «играют в большинстве» не включается. Только когда одна сторона полностью очистится, оставшийся таймер переезжает на Input противоположной команды — стороны большинства. Если обе стороны очистились одновременно, дополнительные плашки просто снимаются. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.

`; } else if (step.type === "delay") { body.innerHTML = `
`;