поправлены сложные удаления
This commit is contained in:
@@ -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"),
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
65
tests/test_build88_complex_penalty_display.py
Normal file
65
tests/test_build88_complex_penalty_display.py
Normal 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
|
||||||
@@ -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>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user