поправлены в какой то раз удаления для верхнего счета
This commit is contained in:
@@ -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"),
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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"] == ""
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
91
tests/test_build89_single_penalty_plate_transition.py
Normal file
91
tests/test_build89_single_penalty_plate_transition.py
Normal 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
|
||||||
@@ -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(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user