diff --git a/app.py b/app.py index 4dedc95..2d181df 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,8 @@ from ui_builder import install_ui_builder from khl_site.khl_data_center import APP as khl_site_app BASE_DIR = Path(__file__).resolve().parent -BUILD_VERSION = "2026.08.20.16" +BUILD_VERSION = "2026.08.20.17" +# compatibility: BUILD_VERSION = "2026.08.20.16" # compatibility: BUILD_VERSION = "2026.08.20.15" # compatibility: BUILD_VERSION = "2026.08.20.14" # compatibility: BUILD_VERSION = "2026.08.20.13" diff --git a/hockey_data/service.py b/hockey_data/service.py index 4df56fe..dae0414 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -5459,6 +5459,18 @@ class HockeyDataService: ) if not assigned: continue + + # BUILD96: a prepared penalty must not change numerical strength until + # the operator explicitly starts it. New Runtime snapshots always send + # startedOnce. Legacy snapshots (without the marker) keep the old + # assigned-immediately behaviour for backward compatibility. + has_started_marker = "startedOnce" in item or "started_once" in item + if has_started_marker: + started = bool(item.get("startedOnce", item.get("started_once", False))) or bool(item.get("running", False)) + else: + started = bool(item.get("running", False)) or remaining_ms < duration_ms or assigned + if not started: + continue if side == "home": home += 1 else: @@ -5588,6 +5600,10 @@ class HockeyDataService: "penalty_board": { "penalties": [], "history": [], + "advantage_cycle": { + "had_advantage": False, + "last_advantage_side": "", + }, }, } @@ -5625,6 +5641,15 @@ class HockeyDataService: history = source_board.get("history") penalties = [item for item in penalties if isinstance(item, dict)] if isinstance(penalties, list) else [] history = [item for item in history if isinstance(item, dict)] if isinstance(history, list) else [] + source_cycle = source_board.get("advantage_cycle") + source_cycle = source_cycle if isinstance(source_cycle, dict) else {} + cycle_side = str(source_cycle.get("last_advantage_side") or "").lower() + if cycle_side not in {"home", "away"}: + cycle_side = "" + advantage_cycle = { + "had_advantage": bool(source_cycle.get("had_advantage", False)) and bool(cycle_side), + "last_advantage_side": cycle_side, + } normalised = { "saved": True, @@ -5638,6 +5663,7 @@ class HockeyDataService: "penalty_board": { "penalties": penalties[:64], "history": history[:64], + "advantage_cycle": advantage_cycle, }, } encoded = json.dumps(normalised, ensure_ascii=False, separators=(",", ":")) diff --git a/tests/test_build96_penalty_strength_transition_state_machine.py b/tests/test_build96_penalty_strength_transition_state_machine.py new file mode 100644 index 0000000..a252760 --- /dev/null +++ b/tests/test_build96_penalty_strength_transition_state_machine.py @@ -0,0 +1,151 @@ +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") +APP = (ROOT / "app.py").read_text(encoding="utf-8") + + +def _penalty(side: str, remaining: int, *, started: bool) -> dict: + return { + "side": side, + "infraction": {"id": "minor"}, + "preset": "2m", + "durationMs": 120000, + "remainingMs": remaining, + "running": False, + "startedOnce": started, + "finished": False, + } + + +def _strength(*penalties: dict) -> dict: + settings = { + "strength_regulation_skaters": 5, + "strength_min_skaters": 3, + "strength_state_labels": { + "regulation": { + "5x4": {"ru": "БОЛ"}, + "4x4": {"ru": "4x4"}, + "4x3": {"ru": "4x3"}, + } + }, + } + return HockeyDataService._strength_payload( + settings, + stage="regular", + current_period="2", + timer_state={"penalty_board": {"penalties": list(penalties)}}, + language="ru", + ) + + +def test_prepared_penalty_does_not_change_strength_until_explicit_start(): + prepared = _strength(_penalty("home", 120000, started=False)) + assert prepared["strength_label"] == "5×5" + assert prepared["advantage_side"] == "" + + started = _strength(_penalty("home", 120000, started=True)) + assert started["strength_label"] == "4×5" + assert started["advantage_side"] == "away" + assert started["state_label"] == "БОЛ" + + +def test_complex_transition_strength_sequence_matches_requested_flow(): + # Initial HOME penalty: AWAY/right has the advantage. + p1 = _penalty("home", 80000, started=True) + value = _strength(p1) + assert value["strength_label"] == "4×5" + assert value["advantage_side"] == "away" + + # Coincidental pair is added while the first penalty is still running. + p2 = _penalty("home", 120000, started=True) + p3 = _penalty("away", 120000, started=True) + value = _strength(p1, p2, p3) + assert value["strength_label"] == "3×4" + assert value["advantage_side"] == "away" + assert value["state_label"] == "4x3" + + # The first penalty expires: the pair remains, so strength is equal 4x4. + value = _strength( + _penalty("home", 40000, started=True), + _penalty("away", 40000, started=True), + ) + assert value["strength_label"] == "4×4" + assert value["advantage_side"] == "" + assert value["state_label"] == "4x4" + + # AWAY/right takes another penalty: HOME/left now has a 4x3 advantage. + value = _strength( + _penalty("home", 30000, started=True), + _penalty("away", 30000, started=True), + _penalty("away", 120000, started=True), + ) + assert value["strength_label"] == "4×3" + assert value["advantage_side"] == "home" + assert value["state_label"] == "4x3" + + # Coincidental pair expires, leaving ordinary HOME power play. + value = _strength(_penalty("away", 90000, started=True)) + assert value["strength_label"] == "5×4" + assert value["advantage_side"] == "home" + assert value["state_label"] == "БОЛ" + + +def test_runtime_holds_single_plate_through_temporary_equal_strength(): + start = APP_JS.index("function penaltyDisplayEntriesByTargetSide") + end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) + block = APP_JS[start:end] + assert "canHoldEqualStrength" in block + assert "state.hockeyPenaltyAdvantageCycle.hadAdvantage" in block + assert "lastAdvantageSide" in block + assert "const canHoldEqualStrength = Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage)" in block + assert 'home: rememberedSide === "home" && transitionEntry ? [transitionEntry] : []' in block + assert 'away: rememberedSide === "away" && transitionEntry ? [transitionEntry] : []' in block + assert 'home: advantageSide === "home" && transitionEntry ? [transitionEntry] : []' in block + assert 'away: advantageSide === "away" && transitionEntry ? [transitionEntry] : []' in block + assert "holdingEqualStrength" in block + # The countdown source remains the globally soonest active penalty. + assert "const transitionEntry = all[0] || null;" in block + + +def test_pure_equal_strength_without_previous_advantage_still_has_no_plate(): + start = APP_JS.index("function penaltyDisplayEntriesByTargetSide") + end = APP_JS.index("function hockeyPenaltySideMappingDetail", start) + block = APP_JS[start:end] + assert "if (!canHoldEqualStrength)" in block + assert "return { home: [], away: [], routedToAdvantage: false" in block + + +def test_advantage_cycle_is_saved_and_restored_for_reload_during_4x4(): + snapshot = APP_JS.split("function hockeyGameTimerSnapshot", 1)[1].split("function hockeyApplySavedTimers", 1)[0] + assert "advantage_cycle" in snapshot + assert "had_advantage" in snapshot + assert "last_advantage_side" in snapshot + restore = APP_JS.split("function hockeyApplySavedTimers", 1)[1].split("async function hockeyPersistGameTimers", 1)[0] + assert "restoredPenaltyAdvantageCycle" in restore + assert "savedCycle.last_advantage_side" in restore + + normalized = HockeyDataService._normalise_game_timer_state({ + "penalty_board": { + "penalties": [], + "history": [], + "advantage_cycle": {"had_advantage": True, "last_advantage_side": "away"}, + } + }) + assert normalized["penalty_board"]["advantage_cycle"] == { + "had_advantage": True, + "last_advantage_side": "away", + } + + +def test_full_strength_waits_for_last_started_penalty_not_prepared_draft(): + control = APP_JS.split("function controlHockeyPenalty", 1)[1].split("function formatHockeyPenaltyTime", 1)[0] + assert "hockeyPenaltyHasStarted(item)" in control + ticker = APP_JS.split("function updateHockeyPenaltyBoards", 1)[1].split("function registerHockeyBoardNode", 1)[0] + assert "remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item)).length" in ticker + + +def test_build96_runtime_version(): + assert 'BUILD_VERSION = "2026.08.20.17"' in APP diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index d670977..ec269e7 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -4749,34 +4749,62 @@ function startCustomTooltips() { return homeSkaters > awaySkaters ? "home" : awaySkaters > homeSkaters ? "away" : ""; } + // BUILD96: one persistent strength-transition plate. + // When the game moves from a real advantage into temporary equal strength + // (for example 4x5 -> 3x4 -> 4x4), keep the same plate on the team that held + // the advantage. Only the configured strength caption and the next transition + // countdown change. A pure coincidental 4x4 that did not originate from an + // advantage still produces no plate. function penaltyDisplayEntriesByTargetSide(step) { const home = sortedPenaltyEntries("home"); const away = sortedPenaltyEntries("away"); 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 }; + return { home: [], away: [], routedToAdvantage: false, advantageSide: "", plateSide: "", holdingEqualStrength: false, transitionEntry: null }; } const advantageSide = penaltyLocalAdvantageSide(home.length, away.length); + const transitionEntry = all[0] || null; if (!advantageSide) { - return { home: [], away: [], routedToAdvantage: false, advantageSide: "", transitionEntry: all[0] || null }; + const rememberedSide = String(state.hockeyPenaltyAdvantageCycle.lastAdvantageSide || ""); + const canHoldEqualStrength = Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage) + && ["home", "away"].includes(rememberedSide); + if (!canHoldEqualStrength) { + return { home: [], away: [], routedToAdvantage: false, advantageSide: "", plateSide: "", holdingEqualStrength: false, transitionEntry }; + } + return { + home: rememberedSide === "home" && transitionEntry ? [transitionEntry] : [], + away: rememberedSide === "away" && transitionEntry ? [transitionEntry] : [], + routedToAdvantage: false, + advantageSide: "", + plateSide: rememberedSide, + holdingEqualStrength: true, + transitionEntry, + }; } - const transitionEntry = all[0] || null; return { home: advantageSide === "home" && transitionEntry ? [transitionEntry] : [], away: advantageSide === "away" && transitionEntry ? [transitionEntry] : [], routedToAdvantage: true, advantageSide, + plateSide: advantageSide, + holdingEqualStrength: false, transitionEntry, }; } function rememberPenaltyAdvantagePlan(displayPlan) { if (!displayPlan?.routedToAdvantage || !["home", "away"].includes(String(displayPlan.advantageSide || ""))) return; + const nextSide = String(displayPlan.advantageSide); + const changed = !state.hockeyPenaltyAdvantageCycle.hadAdvantage + || String(state.hockeyPenaltyAdvantageCycle.lastAdvantageSide || "") !== nextSide; state.hockeyPenaltyAdvantageCycle.hadAdvantage = true; - state.hockeyPenaltyAdvantageCycle.lastAdvantageSide = String(displayPlan.advantageSide); + state.hockeyPenaltyAdvantageCycle.lastAdvantageSide = nextSide; + // BUILD96: persist the owner immediately. This matters if Runtime is reloaded + // during the following temporary equal-strength phase (for example 4x4). + if (changed && !state.hockeyTimerHydrating) hockeyScheduleTimerSave(true); } function resetPenaltyAdvantageCycle() { @@ -6540,12 +6568,15 @@ function openTimerQuickEditor(focusActionId = "") { event.id, Math.round(Number(event.remainingMs || 0) / 1000), Boolean(event.running), + Boolean(event.startedOnce), Boolean(event.finished), event.preset || "", event.player?.id || "", event.infraction?.id || "", ].join(":" )).join(";"), board.history.map((item) => item.id || "").join(","), + Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage), + String(state.hockeyPenaltyAdvantageCycle.lastAdvantageSide || ""), ].join("|"); if ( force @@ -6930,8 +6961,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) && String(item.player?.side || item.side || "").toLowerCase() === side).length; - const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item)).length; + 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 clearCommonSelection = board.selectedEventId === event.id; const clearSideSelection = board.selectedPreviewEventIds?.[side] === event.id; if (clearCommonSelection) board.selectedEventId = null; @@ -6950,7 +6981,10 @@ function openTimerQuickEditor(focusActionId = "") { had_advantage: hadAdvantage, full_strength_side: fullStrengthSide, }); - if (remainingTotal <= 0) resetPenaltyAdvantageCycle(); + if (remainingTotal <= 0) { + resetPenaltyAdvantageCycle(); + hockeyScheduleTimerSave(true); + } }); return true; } else if (command === "set_time") { @@ -6987,7 +7021,10 @@ function openTimerQuickEditor(focusActionId = "") { }); state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event)); hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); - if (!board.penalties.some((item) => !item.finished && hockeyEventReady(item))) resetPenaltyAdvantageCycle(); + if (!board.penalties.some((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item))) { + resetPenaltyAdvantageCycle(); + hockeyScheduleTimerSave(true); + } rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }).catch((error) => console.error("Penalty target remove rebalance error", error)); return true; } @@ -7089,7 +7126,7 @@ 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)).length; + const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item)).length; const finishBySide = new Map(); completedEvents.forEach((event) => { const side = String(event.player?.side || event.side || "").toLowerCase(); @@ -7099,7 +7136,7 @@ function openTimerQuickEditor(focusActionId = "") { .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) && String(item.player?.side || item.side || "").toLowerCase() === side).length; + const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length; fireConfiguredTimerFinishActions("penalty", { side, component, event, remaining_on_side: remainingOnSide, @@ -7108,7 +7145,10 @@ function openTimerQuickEditor(focusActionId = "") { full_strength_side: fullStrengthSide, }); }); - if (remainingTotal <= 0) resetPenaltyAdvantageCycle(); + if (remainingTotal <= 0) { + resetPenaltyAdvantageCycle(); + hockeyScheduleTimerSave(true); + } }); return; } @@ -11573,6 +11613,9 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { durationMs: Math.max(0, Math.round(Number(event.durationMs || 0))), remainingMs: Math.max(0, Math.round(Number(event.remainingMs || 0))), running: Boolean(event.running) && !Boolean(event.finished), + startedOnce: Boolean(event.startedOnce || event.running) + || (Math.max(0, Number(event.durationMs || 0)) > 0 + && Math.max(0, Number(event.remainingMs ?? event.durationMs ?? 0)) < Math.max(0, Number(event.durationMs || 0))), finished: Boolean(event.finished), readyEmitted: Boolean(event.readyEmitted), assignedEmitted: Boolean(event.assignedEmitted), @@ -11611,6 +11654,12 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { penalty_board: { penalties: (board?.penalties || []).slice(0, 64).map(hockeyPenaltySnapshot), history: (board?.history || []).slice(0, 64).map(hockeyHistorySnapshot), + advantage_cycle: { + had_advantage: Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage), + last_advantage_side: ["home", "away"].includes(String(state.hockeyPenaltyAdvantageCycle.lastAdvantageSide || "")) + ? String(state.hockeyPenaltyAdvantageCycle.lastAdvantageSide) + : "", + }, }, }; } @@ -11644,11 +11693,22 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { updateTimerNodes(mainComponent, timerState); } + let restoredPenaltyAdvantageCycle = null; const boardComponent = hockeyPenaltyBoardComponent(); if (boardComponent) { const source = timers?.penalty_board && typeof timers.penalty_board === "object" ? timers.penalty_board : {}; + const savedCycle = source?.advantage_cycle && typeof source.advantage_cycle === "object" + ? source.advantage_cycle + : null; + if (savedCycle) { + const savedSide = String(savedCycle.last_advantage_side || ""); + restoredPenaltyAdvantageCycle = { + hadAdvantage: Boolean(savedCycle.had_advantage) && ["home", "away"].includes(savedSide), + lastAdvantageSide: ["home", "away"].includes(savedSide) ? savedSide : "", + }; + } const board = createHockeyBoardState(boardComponent); board.penalties = (Array.isArray(source.penalties) ? source.penalties : []) .slice(0, 64) @@ -11664,7 +11724,12 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { refreshHockeyBoardNodes(boardComponent); } state.hockeyTimerGameId = String(gameId || ""); - resetPenaltyAdvantageCycle(); + if (restoredPenaltyAdvantageCycle) { + state.hockeyPenaltyAdvantageCycle.hadAdvantage = Boolean(restoredPenaltyAdvantageCycle.hadAdvantage); + state.hockeyPenaltyAdvantageCycle.lastAdvantageSide = String(restoredPenaltyAdvantageCycle.lastAdvantageSide || ""); + } else { + resetPenaltyAdvantageCycle(); + } state.hockeyPenaltyMappingContextSignature = ""; hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); }