поправлены сложные удаления

This commit is contained in:
2026-08-20 15:06:17 +03:00
parent 559888cc0c
commit 3dc21e4619
6 changed files with 109 additions and 44 deletions

View File

@@ -5542,8 +5542,18 @@ class HockeyDataService:
and away_penalties > 0 and away_penalties > 0
and home_skaters == away_skaters and home_skaters == away_skaters
) )
home_label = state_label if advantage_side == "home" or coincidental_penalties else "" complex_mixed_penalties = bool(
away_label = state_label if advantage_side == "away" or coincidental_penalties else "" 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 = { phase_labels = {
"regulation": ("Основное время", "Regulation"), "regulation": ("Основное время", "Regulation"),
"regular_overtime": ("Овертайм регулярки", "Regular OT"), "regular_overtime": ("Овертайм регулярки", "Regular OT"),

View File

@@ -12,8 +12,8 @@ def test_penalty_display_routes_single_side_to_opposite_advantage_target():
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 'if (home.length && away.length)' in block
assert 'return { home: [], away: home.slice(0, 1), routedToAdvantage: true };' in block assert 'return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };' in block
assert 'return { home: away.slice(0, 1), away: [], routedToAdvantage: true };' 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(): def test_penalty_rebalance_and_initial_timer_use_same_display_plan():

View File

@@ -44,15 +44,14 @@ 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_uses_strength_advantage_side(): def test_penalty_plate_routing_defers_powerplay_while_both_sides_have_penalties():
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 'getByPath(state.data, "hockey.game_control.strength")' in block assert 'if (home.length && away.length)' in block
assert 'if (advantageSide === "home")' in block assert 'return { home: take(home), away: take(away), routedToAdvantage: false, advantageSide: "" };' 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 '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): def test_database_schema_reference_lists_real_hockey_tables(tmp_path: Path):

View File

@@ -14,11 +14,11 @@ 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_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") 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 (advantageSide === "home")' in block assert 'if (home.length && away.length)' in block
assert 'return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };' in block assert 'routedToAdvantage: false' in block
assert 'if (advantageSide === "away")' in block assert 'if (home.length)' in block
assert 'return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };' in block assert 'if (away.length)' in block

View File

@@ -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

View File

@@ -4635,42 +4635,35 @@ function startCustomTooltips() {
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0));
} }
// BUILD85: HOME/AWAY penalty targets describe the TEAM THAT HAS THE NUMERICAL // BUILD88: complex/coincidental penalties must not immediately switch the
// ADVANTAGE, not the bench that committed the penalty. This matters not only // scorebug extension into a power-play side merely because the raw skater
// for a normal 5x4/5x3 power play, but also for 4x3 and 3x4 when both benches // count is 4x3/3x4. While both benches still have active penalties, keep one
// still have active penalties. The authoritative advantage_side is calculated // penalty on each team's own target. Only after one bench becomes completely
// by the hockey strength engine. When strength is equal (4x4 / 3x3), keep the // clear does the remaining penalty move to the opposite target (the team that
// traditional one-per-side coincidental display. If the strength payload has // is now actually shown as playing on the power play). If both benches clear
// not arrived yet, the single-penalty fallback still routes to the opposite // together, the plan is empty and both extra plates are removed.
// target exactly as BUILD83 did.
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 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; const take = (items) => soonestOnly ? items.slice(0, 1) : items;
if (advantageSide === "home") { // Complex/coincidental state: do not announce a power-play side yet.
// 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" };
}
if (home.length && away.length) { if (home.length && away.length) {
return { home: take(home), away: take(away), routedToAdvantage: false, advantageSide: "" }; 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 (home.length) {
if (soonestOnly) return { home: [], away: home.slice(0, 1), routedToAdvantage: true }; return { home: [], away: take(home), routedToAdvantage: true, advantageSide: "away" };
return { home: [], away: home, routedToAdvantage: true, advantageSide: "away" };
} }
// Only AWAY still has a penalty -> HOME is the displayed PP side.
if (away.length) { if (away.length) {
if (soonestOnly) return { home: away.slice(0, 1), away: [], routedToAdvantage: true }; return { home: take(away), away: [], routedToAdvantage: true, advantageSide: "home" };
return { home: away, away: [], routedToAdvantage: true, advantageSide: "home" };
} }
// Both sides cleared at the same tick: show nothing.
return { home: [], away: [], routedToAdvantage: false, advantageSide: "" }; return { home: [], away: [], routedToAdvantage: false, advantageSide: "" };
} }
@@ -11136,12 +11129,10 @@ 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(() => {});
// BUILD87: penalty routing depends on the recalculated numerical strength. // BUILD88: keep a second rebalance after the authoritative control payload
// The local penalty editor can rebalance a few milliseconds before the // arrives. The display plan itself intentionally keeps HOME/AWAY on their
// timer save response arrives, while state.data still contains the old // own sides while both benches have active penalties; once one bench clears,
// 4x4/5x5 strength. Re-run the routing as soon as the authoritative // this refresh moves the remaining timer to the actual power-play side.
// control payload is stored so 2 penalties vs 1 immediately becomes
// 3x4/4x3 and the plate moves to the team with the advantage.
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));
@@ -13288,7 +13279,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<div class="shortcut-finish-action-list">${timerFinishActionRows(step)}</div> <div class="shortcut-finish-action-list">${timerFinishActionRows(step)}</div>
<div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.</small></div> <div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.</small></div>
<p class="shortcut-step-note">Режим <b>Text mirror</b> рекомендуется: веб-таймер является источником истины и раз в секунду отправляет <code>SetText</code> строго в выбранные <code>Input + SelectedName</code>. Режим <b>Countdown</b> оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление. Пока удаления есть у обеих команд, HOME/AWAY показываются по своим сторонам. Когда штраф остаётся только у одной команды, его таймер автоматически переезжает на Input противоположной команды — стороны большинства. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p> <p class="shortcut-step-note">Режим <b>Text mirror</b> рекомендуется: веб-таймер является источником истины и раз в секунду отправляет <code>SetText</code> строго в выбранные <code>Input + SelectedName</code>. Режим <b>Countdown</b> оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление. При сложных/обоюдных удалениях, пока штрафы есть у обеих команд, HOME/AWAY остаются на своих сторонах и режим «играют в большинстве» не включается. Только когда одна сторона полностью очистится, оставшийся таймер переезжает на Input противоположной команды — стороны большинства. Если обе стороны очистились одновременно, дополнительные плашки просто снимаются. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p>
</div>`; </div>`;
} else if (step.type === "delay") { } else if (step.type === "delay") {
body.innerHTML = `<div class="shortcut-step-grid"><label>Задержка, мс<input type="number" min="0" max="10000" step="10" data-step-field="milliseconds" value="${Number(step.milliseconds) || 0}"></label></div>`; body.innerHTML = `<div class="shortcut-step-grid"><label>Задержка, мс<input type="number" min="0" max="10000" step="10" data-step-field="milliseconds" value="${Number(step.milliseconds) || 0}"></label></div>`;