BUILD119 — ручной счёт + SQL + составы

This commit is contained in:
2026-08-26 13:54:47 +03:00
parent c5b25c23e2
commit fb422d5bf8
10 changed files with 544 additions and 16 deletions

2
app.py
View File

@@ -29,7 +29,7 @@ from ui_builder import install_ui_builder
from khl_site.khl_data_center import APP as khl_site_app from khl_site.khl_data_center import APP as khl_site_app
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
BUILD_VERSION = "2026.08.26.5" BUILD_VERSION = "2026.08.26.6"
# compatibility: BUILD_VERSION = "2026.08.26.1" # compatibility: BUILD_VERSION = "2026.08.26.1"
# compatibility: BUILD_VERSION = "2026.08.25.1" # compatibility: BUILD_VERSION = "2026.08.25.1"
# compatibility: BUILD_VERSION = "2026.08.24.15" # compatibility: BUILD_VERSION = "2026.08.24.15"

View File

@@ -17,7 +17,7 @@ from sqlalchemy import and_, delete, desc, select
from .auth_bridge import HockeyUser from .auth_bridge import HockeyUser
from .database import HockeyDatabase from .database import HockeyDatabase
from .mapping_context import MappingDataService from .mapping_context import MappingDataService
from .models import (MappingSqlDataSource, OperatorSession, UserPreference, VmixAssignment, VmixDevice, VmixMappingProfile, VmixMappingField, VmixPreparedTitle) from .models import (GameControlState, MappingSqlDataSource, OperatorSession, UserPreference, VmixAssignment, VmixDevice, VmixMappingProfile, VmixMappingField, VmixPreparedTitle)
AGENT_PROTOCOL_VERSION = 1 AGENT_PROTOCOL_VERSION = 1
@@ -1106,6 +1106,53 @@ class VmixAgentHub:
"agent_ack": ack, "agent_ack": ack,
} }
@staticmethod
def _score_int(value: Any, fallback: int = 0) -> int:
try:
return max(0, int(float(str(value if value is not None else fallback).strip())))
except (TypeError, ValueError):
return max(0, int(fallback or 0))
def _score_source_settings(self) -> tuple[str, str]:
try:
settings = self.mapping_data.settings.public() if self.mapping_data.settings is not None else {}
except Exception:
settings = {}
return (
str(settings.get("score_external_home_key") or "").strip(),
str(settings.get("score_external_away_key") or "").strip(),
)
def _persist_score_external_snapshot(
self,
game_external_id: str,
*,
home: int,
away: int,
source: str,
) -> None:
game_external_id = str(game_external_id or "").strip()
if not game_external_id:
return
with self.database.session() as session:
row = session.scalar(select(GameControlState).where(GameControlState.game_external_id == game_external_id))
if row is None:
row = GameControlState(game_external_id=game_external_id)
session.add(row)
session.flush()
try:
values = json.loads(str(row.runtime_values_json or "{}"))
except (TypeError, ValueError, json.JSONDecodeError):
values = {}
if not isinstance(values, dict):
values = {}
values["score.external_valid"] = "1"
values["score.external_home"] = str(max(0, int(home)))
values["score.external_away"] = str(max(0, int(away)))
values["score.external_source"] = str(source or "game")[:160]
row.runtime_values_json = json.dumps(values, ensure_ascii=False, separators=(",", ":"))
row.updated_at = _utcnow()
async def apply_mapping_to_device( async def apply_mapping_to_device(
self, self,
device_id: str, device_id: str,
@@ -1200,6 +1247,12 @@ class VmixAgentHub:
if mapping_language: if mapping_language:
supplied_context["ui_language"] = mapping_language supplied_context["ui_language"] = mapping_language
catalog_source_codes = None if source_codes is None else {str(code) for code in source_codes} catalog_source_codes = None if source_codes is None else {str(code) for code in source_codes}
score_home_key, score_away_key = self._score_source_settings()
if catalog_source_codes is not None and "score" in catalog_source_codes:
for score_key in (score_home_key, score_away_key):
dependency_code = self._mapping_source_code(score_key)
if dependency_code and dependency_code not in {"score", "context"}:
catalog_source_codes.add(dependency_code)
if catalog_source_codes is not None: if catalog_source_codes is not None:
for field in fields: for field in fields:
rule = _mapping_rule_payload(getattr(field, "rule_json", "{}")) rule = _mapping_rule_payload(getattr(field, "rule_json", "{}"))
@@ -1214,7 +1267,49 @@ class VmixAgentHub:
if catalog_source_codes is not None if catalog_source_codes is not None
else self.mapping_data.data_catalog(user, supplied_context) else self.mapping_data.data_catalog(user, supplied_context)
) )
by_key = {str(item.get("key") or ""): item for item in (catalog.get("items") or []) if item.get("key")} catalog_items = [item for item in (catalog.get("items") or []) if isinstance(item, dict) and item.get("key")]
by_key = {str(item.get("key") or ""): item for item in catalog_items}
# ``score`` is a natural name for an operator-created SQL source. Keep the
# built-in LIVE score namespace authoritative even if such a source exists.
for item in catalog_items:
key = str(item.get("key") or "")
if key.startswith("score.") and str(item.get("source_code") or "") == "live_control":
by_key[key] = item
def external_score_item(key: str) -> dict[str, Any] | None:
key = str(key or "").strip()
if not key:
return None
candidates = [
item for item in catalog_items
if str(item.get("key") or "") == key and str(item.get("source_code") or "") != "live_control"
]
return candidates[-1] if candidates else (by_key.get(key) if not key.startswith("score.") else None)
score_snapshot = None
if source_codes is None or "score" in {str(code) for code in source_codes}:
fallback_home = self._score_int((by_key.get("score.external_home") or {}).get("value"), 0)
fallback_away = self._score_int((by_key.get("score.external_away") or {}).get("value"), 0)
home_item = external_score_item(score_home_key)
away_item = external_score_item(score_away_key)
resolved_home = self._score_int(home_item.get("value"), fallback_home) if isinstance(home_item, dict) else fallback_home
resolved_away = self._score_int(away_item.get("value"), fallback_away) if isinstance(away_item, dict) else fallback_away
source_label = "mapping_sql" if (score_home_key and score_away_key and isinstance(home_item, dict) and isinstance(away_item, dict)) else "game"
self._persist_score_external_snapshot(match_id, home=resolved_home, away=resolved_away, source=source_label)
if "score.external_home" in by_key:
by_key["score.external_home"]["value"] = resolved_home
if "score.external_away" in by_key:
by_key["score.external_away"]["value"] = resolved_away
manual = str((by_key.get("score.manual") or {}).get("value") or "0").strip().lower() in {"1", "true", "yes", "on"}
if not manual:
if "score.home" in by_key:
by_key["score.home"]["value"] = resolved_home
if "score.away" in by_key:
by_key["score.away"]["value"] = resolved_away
score_snapshot = {
"home": resolved_home, "away": resolved_away, "manual": manual,
"source": source_label, "home_key": score_home_key, "away_key": score_away_key,
}
result: dict[str, Any] = { result: dict[str, Any] = {
"ok": True, "ok": True,
@@ -1225,6 +1320,7 @@ class VmixAgentHub:
"profile_id": profile_id, "profile_id": profile_id,
"profile_name": profile_name, "profile_name": profile_name,
"profile_version": profile_version, "profile_version": profile_version,
"score_snapshot": score_snapshot,
"total": len(fields), "total": len(fields),
"applied": 0, "applied": 0,
"rules_total": 0, "rules_total": 0,
@@ -4382,6 +4478,7 @@ def create_hockey_agent_router(
device_id: str, device_id: str,
only_changed: bool = False, only_changed: bool = False,
active_tab: str = "", active_tab: str = "",
source_code: str = "",
user: HockeyUser = Depends(auth_dependency), user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]: ) -> dict[str, Any]:
device_id = hub.normalise_device_id(device_id) device_id = hub.normalise_device_id(device_id)
@@ -4391,9 +4488,12 @@ def create_hockey_agent_router(
raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту") raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту")
if not device.is_active_for_account: if not device.is_active_for_account:
raise HTTPException(status_code=409, detail="Для этого Agent выключено получение данных") raise HTTPException(status_code=409, detail="Для этого Agent выключено получение данных")
wanted_source = str(source_code or "").strip()
source_codes = {wanted_source} if wanted_source else None
result = await hub.apply_mapping_to_device( result = await hub.apply_mapping_to_device(
device_id, device_id,
reason="runtime_strength_changed" if only_changed else "manual_apply", reason=("runtime_score_changed" if wanted_source == "score" else "runtime_strength_changed") if only_changed else "manual_apply",
source_codes=source_codes,
only_changed=only_changed, only_changed=only_changed,
extra_context={"active_tab": str(active_tab or "").strip()} if str(active_tab or "").strip() else None, extra_context={"active_tab": str(active_tab or "").strip()} if str(active_tab or "").strip() else None,
) )

View File

@@ -163,6 +163,11 @@ DEFAULT_PUBLIC_SETTINGS: dict[str, Any] = {
"strength_state_labels": DEFAULT_STRENGTH_STATE_LABELS, "strength_state_labels": DEFAULT_STRENGTH_STATE_LABELS,
"period_status_labels": DEFAULT_PERIOD_STATUS_LABELS, "period_status_labels": DEFAULT_PERIOD_STATUS_LABELS,
"scoreboard_team_states": DEFAULT_SCOREBOARD_TEAM_STATES, "scoreboard_team_states": DEFAULT_SCOREBOARD_TEAM_STATES,
# Optional Mapping/SQL keys used as the external score baseline. When blank,
# the imported game score is used. Example: score_sql.home / score_sql.away.
"score_external_home_key": "",
"score_external_away_key": "",
} }
DEFAULT_SECRET_SETTINGS: dict[str, str] = { DEFAULT_SECRET_SETTINGS: dict[str, str] = {
@@ -373,6 +378,9 @@ class HockeySettingsStore:
raw[key] = str( raw[key] = str(
raw.get(key) or DEFAULT_PUBLIC_SETTINGS[key] raw.get(key) or DEFAULT_PUBLIC_SETTINGS[key]
).strip() ).strip()
for key in ("score_external_home_key", "score_external_away_key"):
raw[key] = str(raw.get(key) or "").strip()[:160]
return raw return raw
def credentials(self) -> tuple[str, str]: def credentials(self) -> tuple[str, str]:

View File

@@ -624,6 +624,13 @@ class MappingDataService:
context.setdefault("team1_id", game.home_team_external_id or "") context.setdefault("team1_id", game.home_team_external_id or "")
context.setdefault("team2_id", game.away_team_external_id or "") context.setdefault("team2_id", game.away_team_external_id or "")
control = session.scalar(select(GameControlState).where(GameControlState.game_external_id == resolved_game_id)) control = session.scalar(select(GameControlState).where(GameControlState.game_external_id == resolved_game_id))
if game is not None:
score_control = HockeyDataService._score_control_payload(game, control)
context.setdefault("score_home", str(score_control["home"]))
context.setdefault("score_away", str(score_control["away"]))
context.setdefault("score_external_home", str(score_control["external_home"]))
context.setdefault("score_external_away", str(score_control["external_away"]))
context.setdefault("score_manual", "1" if score_control["manual"] else "0")
if control is not None: if control is not None:
runtime_values = HockeyDataService._runtime_values_from_row(control) runtime_values = HockeyDataService._runtime_values_from_row(control)
player_panel_labels: dict[str, str] = {} player_panel_labels: dict[str, str] = {}
@@ -871,6 +878,24 @@ class MappingDataService:
language=language, language=language,
) )
result: list[dict[str, Any]] = [] result: list[dict[str, Any]] = []
score_control = HockeyDataService._score_control_payload(game, control)
score_fields = (
("home", "Счёт HOME · LIVE", score_control["home"], "Оперативный счёт HOME. После первого ручного +/- становится главным до синхронизации."),
("away", "Счёт AWAY · LIVE", score_control["away"], "Оперативный счёт AWAY. После первого ручного +/- становится главным до синхронизации."),
("external_home", "Счёт HOME · SQL/API", score_control["external_home"], "Последний счёт HOME из базы/API; используется для первоначальной и ручной повторной синхронизации."),
("external_away", "Счёт AWAY · SQL/API", score_control["external_away"], "Последний счёт AWAY из базы/API; используется для первоначальной и ручной повторной синхронизации."),
("manual", "Ручной режим счёта", "1" if score_control["manual"] else "0", "1 — LIVE счёт зафиксирован оператором; 0 — LIVE следует SQL/API."),
)
for key, label, value, description in score_fields:
result.append({
"key": f"score.{key}",
"label": label,
"category": "Матч · Оперативный счёт",
"value": value,
"kind": "number" if key != "manual" else "text",
"source_code": "live_control",
"description": description,
})
period_fields = ( period_fields = (
("key", "Ключ периода", "text"), ("key", "Ключ периода", "text"),
("number", "Номер периода / овертайма", "number"), ("number", "Номер периода / овертайма", "number"),

View File

@@ -62,6 +62,8 @@ class PublicSettingsPayload(BaseModel):
strength_state_labels: dict[str, Any] | None = None strength_state_labels: dict[str, Any] | None = None
period_status_labels: dict[str, Any] | None = None period_status_labels: dict[str, Any] | None = None
scoreboard_team_states: dict[str, Any] | None = None scoreboard_team_states: dict[str, Any] | None = None
score_external_home_key: str | None = None
score_external_away_key: str | None = None
class CredentialsPayload(BaseModel): class CredentialsPayload(BaseModel):
@@ -116,6 +118,11 @@ class GameValuesPayload(BaseModel):
language: str = Field(default="ru", pattern="^(ru|en)$") language: str = Field(default="ru", pattern="^(ru|en)$")
class GameScorePayload(BaseModel):
command: str = Field(pattern="^(home_plus|home_minus|away_plus|away_minus|sync)$")
language: str = Field(default="ru", pattern="^(ru|en)$")
class ShootoutSetupPayload(BaseModel): class ShootoutSetupPayload(BaseModel):
initial_attempts: int = Field(default=3) initial_attempts: int = Field(default=3)
reset: bool = False reset: bool = False
@@ -1007,6 +1014,22 @@ def create_hockey_router(
except ValueError as error: except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/games/{external_id}/control/score")
async def update_game_score(
external_id: str,
payload: GameScorePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
try:
return service.set_game_score(
external_id,
command=payload.command,
updated_by=user.id,
language=payload.language,
)
except ValueError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
@router.put("/games/{external_id}/control/values") @router.put("/games/{external_id}/control/values")
async def update_game_values( async def update_game_values(
external_id: str, external_id: str,

View File

@@ -5469,10 +5469,17 @@ class HockeyDataService:
if not assigned: if not assigned:
continue continue
# BUILD113: as soon as the operator has fully assigned a penalty, it # BUILD96: a prepared penalty must not change numerical strength until
# already affects the scorebug numerical strength. The penalty clock may # the operator explicitly starts it. New Runtime snapshots always send
# still be stopped; Start controls only the countdown, not the displayed # startedOnce. Legacy snapshots (without the marker) keep the old
# 5x4 / 4x3 state. Incomplete drafts are filtered out by `assigned` above. # 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": if side == "home":
home += 1 home += 1
else: else:
@@ -5709,6 +5716,41 @@ class HockeyDataService:
result[clean_key] = str(value if value is not None else "")[:512] result[clean_key] = str(value if value is not None else "")[:512]
return result return result
@staticmethod
def _score_control_payload(game: Game, row: GameControlState | None) -> dict[str, Any]:
"""Return the authoritative operator score for one game.
While manual mode is off, LIVE follows the current DB/API score. The first
operator +/- action freezes LIVE into match-scoped runtime values so later
Stat2TV updates cannot overwrite an on-air correction.
"""
game_home = max(0, int(getattr(game, "home_score", 0) or 0))
game_away = max(0, int(getattr(game, "away_score", 0) or 0))
values = HockeyDataService._runtime_values_from_row(row) if row is not None else {}
external_valid = str(values.get("score.external_valid", "0") or "0").strip().lower() in {"1", "true", "yes", "on"}
manual = str(values.get("score.manual", "0") or "0").strip().lower() in {"1", "true", "yes", "on"}
def _stored(key: str, fallback: int) -> int:
try:
return max(0, int(float(str(values.get(key, fallback) or fallback).strip())))
except (TypeError, ValueError):
return fallback
external_home = _stored("score.external_home", game_home) if external_valid else game_home
external_away = _stored("score.external_away", game_away) if external_valid else game_away
live_home = _stored("score.home", external_home) if manual else external_home
live_away = _stored("score.away", external_away) if manual else external_away
return {
"home": live_home,
"away": live_away,
"external_home": external_home,
"external_away": external_away,
"external_source": str(values.get("score.external_source", "game") or "game"),
"manual": manual,
"source": "manual" if manual else "external",
"differs": live_home != external_home or live_away != external_away,
}
@classmethod @classmethod
def _timer_state_from_row( def _timer_state_from_row(
cls, cls,
@@ -5848,6 +5890,7 @@ class HockeyDataService:
"timer_rules": timer_rules, "timer_rules": timer_rules,
"strength": strength, "strength": strength,
"scoreboard_team_states": scoreboard_team_states, "scoreboard_team_states": scoreboard_team_states,
"score": self._score_control_payload(game, control),
"timers": timer_state, "timers": timer_state,
"flags": self._runtime_flags_from_row(control), "flags": self._runtime_flags_from_row(control),
"values": self._runtime_values_from_row(control), "values": self._runtime_values_from_row(control),
@@ -6003,6 +6046,62 @@ class HockeyDataService:
raise ValueError("Матч не найден") raise ValueError("Матч не найден")
return result return result
def set_game_score(
self,
game_external_id: str,
*,
command: str,
updated_by: str = "",
language: str = "ru",
) -> dict[str, Any]:
command = str(command or "").strip().lower()
aliases = {
"home_plus": "home_plus", "home+": "home_plus", "home_inc": "home_plus",
"home_minus": "home_minus", "home-": "home_minus", "home_dec": "home_minus",
"away_plus": "away_plus", "away+": "away_plus", "away_inc": "away_plus",
"away_minus": "away_minus", "away-": "away_minus", "away_dec": "away_minus",
"sync": "sync", "resync": "sync", "external": "sync",
}
command = aliases.get(command, command)
if command not in {"home_plus", "home_minus", "away_plus", "away_minus", "sync"}:
raise ValueError("Неизвестная команда счёта")
with self.database.session() as session:
game = session.scalar(select(Game).where(Game.external_id == str(game_external_id)))
if game is None:
raise ValueError("Матч не найден")
row = self._get_or_create_game_control(session, game.external_id)
current = self._runtime_values_from_row(row)
score = self._score_control_payload(game, row)
if command == "sync":
current["score.manual"] = "0"
current["score.home"] = str(score["external_home"])
current["score.away"] = str(score["external_away"])
else:
home = int(score["home"])
away = int(score["away"])
if command == "home_plus":
home += 1
elif command == "home_minus":
home = max(0, home - 1)
elif command == "away_plus":
away += 1
elif command == "away_minus":
away = max(0, away - 1)
current["score.manual"] = "1"
current["score.home"] = str(home)
current["score.away"] = str(away)
row.runtime_values_json = json.dumps(current, ensure_ascii=False, separators=(",", ":"))
row.updated_by = str(updated_by or "")[:128]
row.updated_at = datetime.utcnow()
result = self.game_control(game_external_id, language=language)
if result is None:
raise ValueError("Матч не найден")
return result
def set_game_period( def set_game_period(
self, self,
game_external_id: str, game_external_id: str,

View File

@@ -817,6 +817,17 @@
<small class="hockey-team-state-inventory-note">vMix: ${escapeHtml(state.timerRulesVmixInventory?.device_name || "Agent не выбран / inventory не загружен")}. Для обновления списка Inputs закройте и снова откройте этот раздел после выбора Agent.</small> <small class="hockey-team-state-inventory-note">vMix: ${escapeHtml(state.timerRulesVmixInventory?.device_name || "Agent не выбран / inventory не загружен")}. Для обновления списка Inputs закройте и снова откройте этот раздел после выбора Agent.</small>
</section> </section>
<section class="hockey-rules-card">
<header><span>06</span><div><strong>Внешний счёт SQL / Mapping</strong><small>Источник начального счёта и кнопки ↻ синхронизации.</small></div></header>
<div class="hockey-rules-example">
<strong>Как использовать:</strong> укажите ключи данных из Mapping для счёта HOME и AWAY, например <code>score_sql.home</code> и <code>score_sql.away</code>. Пока ручной режим не включён, LIVE следует этим значениям. После первого +/ счёт фиксируется вручную; ↻ снова принимает актуальный SQL. Если поля оставить пустыми, используется счёт из карточки матча/API.
</div>
<div class="hockey-rules-fields two">
<label><span>HOME · ключ SQL/Mapping</span><input name="score_external_home_key" maxlength="160" value="${escapeHtml(value.score_external_home_key || "")}" placeholder="Например: score_sql.home"><small>Скопируйте Data key из раздела Mapping.</small></label>
<label><span>AWAY · ключ SQL/Mapping</span><input name="score_external_away_key" maxlength="160" value="${escapeHtml(value.score_external_away_key || "")}" placeholder="Например: score_sql.away"><small>Скопируйте Data key из раздела Mapping.</small></label>
</div>
</section>
<footer class="hockey-rules-actions"> <footer class="hockey-rules-actions">
<div><small>Изменения применяются сразу и сохраняются в общих настройках сервера.</small><strong data-rules-save-status></strong></div> <div><small>Изменения применяются сразу и сохраняются в общих настройках сервера.</small><strong data-rules-save-status></strong></div>
<button type="submit">Сохранить настройки</button> <button type="submit">Сохранить настройки</button>
@@ -857,6 +868,8 @@
strength_state_labels: collectStrengthStateLabels(values), strength_state_labels: collectStrengthStateLabels(values),
period_status_labels: collectPeriodStatusLabels(values), period_status_labels: collectPeriodStatusLabels(values),
scoreboard_team_states: collectScoreboardTeamStates(values, form), scoreboard_team_states: collectScoreboardTeamStates(values, form),
score_external_home_key: String(values.get("score_external_home_key") || "").trim(),
score_external_away_key: String(values.get("score_external_away_key") || "").trim(),
}; };
state.timerRules = await request("/api/hockey/settings", { method: "PUT", body: JSON.stringify(payload) }); state.timerRules = await request("/api/hockey/settings", { method: "PUT", body: JSON.stringify(payload) });
inlineStatus.textContent = "Настройки сохранены"; inlineStatus.textContent = "Настройки сохранены";

View File

@@ -337,7 +337,7 @@ class UIBuilderManager:
"active_tab", "inactive_tab", "active_tab", "inactive_tab",
} }
valid_step_types = { valid_step_types = {
"timer_command", "hockey_penalties_command", "vmix_command", "timer_command", "hockey_penalties_command", "hockey_score_command", "vmix_command",
"hockey_vmix_timers_start", "delay", "dispatch_event", "hockey_vmix_timers_start", "delay", "dispatch_event",
} }
for sequence_index, sequence in enumerate(shortcut_sequences): for sequence_index, sequence in enumerate(shortcut_sequences):
@@ -372,7 +372,7 @@ class UIBuilderManager:
"id": str(item.get("id") or f"penalty-target-{index + 1}"), "id": str(item.get("id") or f"penalty-target-{index + 1}"),
"input": str(item.get("input") or ""), "input": str(item.get("input") or ""),
"selected_name": str(item.get("selected_name") or item.get("selectedName") or ""), "selected_name": str(item.get("selected_name") or item.get("selectedName") or ""),
"overlay": str(item.get("overlay") or "2") if str(item.get("overlay") or "2") in {"1", "2", "3", "4"} else "2", "overlay": str(item.get("overlay") or "2") if str(item.get("overlay") or "2") in {"1", "2", "3", "4", "5", "6", "7", "8"} else "2",
"auto_hide_on_finish": bool(item.get("auto_hide_on_finish", True)), "auto_hide_on_finish": bool(item.get("auto_hide_on_finish", True)),
} for index, item in enumerate(targets[:8])] } for index, item in enumerate(targets[:8])]
@@ -387,7 +387,7 @@ class UIBuilderManager:
if source not in {"game", "any_penalty", "home_penalty", "away_penalty"}: if source not in {"game", "any_penalty", "home_penalty", "away_penalty"}:
source = "game" source = "game"
overlay = str(action.get("overlay") or "1") overlay = str(action.get("overlay") or "1")
if overlay not in {"1", "2", "3", "4"}: if overlay not in {"1", "2", "3", "4", "5", "6", "7", "8"}:
overlay = "1" overlay = "1"
try: try:
duration_ms = int(float(action.get("duration_ms") or 3000)) duration_ms = int(float(action.get("duration_ms") or 3000))
@@ -423,6 +423,7 @@ class UIBuilderManager:
"timer_command": str(step.get("timer_command") or "start"), "timer_command": str(step.get("timer_command") or "start"),
"timer_value": step.get("timer_value", ""), "timer_value": step.get("timer_value", ""),
"penalty_command": str(step.get("penalty_command") or "start"), "penalty_command": str(step.get("penalty_command") or "start"),
"score_command": str(step.get("score_command") or "home_plus") if str(step.get("score_command") or "home_plus") in {"home_plus", "home_minus", "away_plus", "away_minus", "sync"} else "home_plus",
"function": str(step.get("function") or ""), "function": str(step.get("function") or ""),
"input": str(step.get("input") or ""), "input": str(step.get("input") or ""),
"value": step.get("value", ""), "value": step.get("value", ""),
@@ -472,6 +473,12 @@ class UIBuilderManager:
"prevent_default": bool(sequence.get("prevent_default", True)), "prevent_default": bool(sequence.get("prevent_default", True)),
"allow_in_inputs": bool(sequence.get("allow_in_inputs", False)), "allow_in_inputs": bool(sequence.get("allow_in_inputs", False)),
"toggle_all_overlays_on_repeat": bool(sequence.get("toggle_all_overlays_on_repeat", False)), "toggle_all_overlays_on_repeat": bool(sequence.get("toggle_all_overlays_on_repeat", False)),
"repeat_overlay_outs": [
value for value in dict.fromkeys(
int(item) for item in (sequence.get("repeat_overlay_outs") if isinstance(sequence.get("repeat_overlay_outs"), list) else [1, 2, 3])
if str(item).isdigit() and 1 <= int(item) <= 8
)
] or [1, 2, 3],
"sync_hockey_team_states": bool(sequence.get("sync_hockey_team_states", sequence.get("toggle_all_overlays_on_repeat", False))), "sync_hockey_team_states": bool(sequence.get("sync_hockey_team_states", sequence.get("toggle_all_overlays_on_repeat", False))),
"is_scoreboard_sequence": bool(sequence.get("is_scoreboard_sequence", sequence.get("sync_hockey_team_states", sequence.get("toggle_all_overlays_on_repeat", False)))), "is_scoreboard_sequence": bool(sequence.get("is_scoreboard_sequence", sequence.get("sync_hockey_team_states", sequence.get("toggle_all_overlays_on_repeat", False)))),
"scope": "all" if sequence.get("scope") == "all" else "runtime", "scope": "all" if sequence.get("scope") == "all" else "runtime",

View File

@@ -2377,7 +2377,7 @@ function startCustomTooltips() {
} }
function normalizeSequenceStep(step = {}, index = 0) { function normalizeSequenceStep(step = {}, index = 0) {
const allowedTypes = new Set(["timer_command", "hockey_penalties_command", "vmix_command", "hockey_vmix_timers_start", "delay", "dispatch_event"]); const allowedTypes = new Set(["timer_command", "hockey_penalties_command", "hockey_score_command", "vmix_command", "hockey_vmix_timers_start", "delay", "dispatch_event"]);
const allowedConditions = new Set(sequenceConditions.map(([value]) => value)); const allowedConditions = new Set(sequenceConditions.map(([value]) => value));
const type = allowedTypes.has(String(step.type || "")) ? String(step.type) : "vmix_command"; const type = allowedTypes.has(String(step.type || "")) ? String(step.type) : "vmix_command";
const condition = allowedConditions.has(String(step.condition || "")) ? String(step.condition) : "always"; const condition = allowedConditions.has(String(step.condition || "")) ? String(step.condition) : "always";
@@ -2392,6 +2392,7 @@ function startCustomTooltips() {
timer_command: String(step.timer_command || "start"), timer_command: String(step.timer_command || "start"),
timer_value: step.timer_value ?? "", timer_value: step.timer_value ?? "",
penalty_command: String(step.penalty_command || "start"), penalty_command: String(step.penalty_command || "start"),
score_command: ["home_plus", "home_minus", "away_plus", "away_minus", "sync"].includes(String(step.score_command || "")) ? String(step.score_command) : "home_plus",
function: String(step.function || ""), function: String(step.function || ""),
input: String(step.input || ""), input: String(step.input || ""),
value: step.value ?? "", value: step.value ?? "",
@@ -5386,6 +5387,9 @@ function startCustomTooltips() {
entries.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, step.penalty_command || "start", step.timer_value || "")); entries.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, step.penalty_command || "start", step.timer_value || ""));
return { ok: true, applied: entries.length }; return { ok: true, applied: entries.length };
} }
case "hockey_score_command": {
return await hockeyScoreCommand(step.score_command || "home_plus", { refreshMapping: true, announce: false });
}
case "vmix_command": { case "vmix_command": {
const useAlternate = Boolean(step.use_scoreboard_alternate && hockeyScoreboardIsLive() && step.scoreboard_alternate_input); const useAlternate = Boolean(step.use_scoreboard_alternate && hockeyScoreboardIsLive() && step.scoreboard_alternate_input);
const inputTemplate = useAlternate ? step.scoreboard_alternate_input : step.input; const inputTemplate = useAlternate ? step.scoreboard_alternate_input : step.input;
@@ -6810,6 +6814,7 @@ function openTimerQuickEditor(focusActionId = "") {
number: get("numberField", ""), number: get("numberField", ""),
name: get("nameField", `Игрок ${index + 1}`), name: get("nameField", `Игрок ${index + 1}`),
position: get("positionField", ""), position: get("positionField", ""),
role: String(row?.role || ""),
raw: clone(row) raw: clone(row)
}; };
} }
@@ -6833,6 +6838,26 @@ function openTimerQuickEditor(focusActionId = "") {
.slice(0, limit); .slice(0, limit);
} }
function hockeyRosterCounts(players) {
const counts = { total: Array.isArray(players) ? players.length : 0, forwards: 0, defenders: 0, goalkeepers: 0, other: 0 };
(Array.isArray(players) ? players : []).forEach((player) => {
const role = String(player?.role || player?.raw?.role || "").trim().toLowerCase();
const position = String(player?.position || player?.raw?.position || player?.raw?.position_ru || player?.raw?.position_en || "")
.trim().toLowerCase().replaceAll("ё", "е");
if (role === "goalkeeper" || /(^|\s)(вр|врат|goal|gk)(\s|$)/i.test(position)) counts.goalkeepers += 1;
else if (role === "defender" || /защ|defen|\bd\b/i.test(position)) counts.defenders += 1;
else if (role === "forward" || /нап|forward|wing|center|centre|\bf\b/i.test(position)) counts.forwards += 1;
else counts.other += 1;
});
return counts;
}
function hockeyRosterCountMarkup(players) {
const counts = hockeyRosterCounts(players);
const tooltip = `Всего: ${counts.total} · Нападающие: ${counts.forwards} · Защитники: ${counts.defenders} · Вратари: ${counts.goalkeepers}${counts.other ? ` · Не определено: ${counts.other}` : ""}`;
return `<em class="hpd-roster-count-breakdown" data-tooltip="${escapeHtml(tooltip)}" aria-label="${escapeHtml(tooltip)}"><b>${counts.total}</b><span>Н${counts.forwards}</span><span>З${counts.defenders}</span><span>В${counts.goalkeepers}</span></em>`;
}
function hockeyTeamName(component, side) { function hockeyTeamName(component, side) {
const path = side === "home" ? component.props?.homeTeamPath : component.props?.awayTeamPath; const path = side === "home" ? component.props?.homeTeamPath : component.props?.awayTeamPath;
return formatValue(getByPath(state.data, path), side === "home" ? "Хозяева" : "Гости"); return formatValue(getByPath(state.data, path), side === "home" ? "Хозяева" : "Гости");
@@ -7709,7 +7734,7 @@ function readAnyHockeyDragData(event) {
<strong>${escapeHtml(hockeyTeamName(component, side))}</strong> <strong>${escapeHtml(hockeyTeamName(component, side))}</strong>
${getByPath(state.data, `hockey.selected_game.${side}.coach`) ? `<small class="hpd-roster-coach">Тренер: ${escapeHtml(getByPath(state.data, `hockey.selected_game.${side}.coach`))}</small>` : ""} ${getByPath(state.data, `hockey.selected_game.${side}.coach`) ? `<small class="hpd-roster-coach">Тренер: ${escapeHtml(getByPath(state.data, `hockey.selected_game.${side}.coach`))}</small>` : ""}
</div> </div>
<em>${players.length}</em> ${hockeyRosterCountMarkup(players)}
`; `;
panel.appendChild(header); panel.appendChild(header);
@@ -8885,6 +8910,16 @@ async function hockeyRefreshQuickPanelMapping() {
} catch (_) { return false; } } catch (_) { return false; }
} }
async function hockeyRefreshScoreMapping() {
const deviceId = currentRuntimeVmixDeviceId();
if (!deviceId) return false;
try {
const response = await fetch(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/apply-mapping?only_changed=true&source_code=score`, { method: "POST", cache: "no-store", credentials: "same-origin" });
if (!response.ok) return false;
return true;
} catch (_) { return false; }
}
async function hockeySetMatchValues(patch, { refreshMapping = true } = {}) { async function hockeySetMatchValues(patch, { refreshMapping = true } = {}) {
const gameId = hockeyTimerSelectedGameId(); const gameId = hockeyTimerSelectedGameId();
if (!gameId) throw new Error("Сначала выберите матч"); if (!gameId) throw new Error("Сначала выберите матч");
@@ -8901,6 +8936,84 @@ async function hockeySetMatchValues(patch, { refreshMapping = true } = {}) {
return payload; return payload;
} }
function hockeyScoreControl() {
const score = getByPath(state.data, "hockey.game_control.score");
const game = getByPath(state.data, "hockey.selected_game") || {};
const fallbackHome = Number(game?.home?.score ?? game?.home_score ?? 0) || 0;
const fallbackAway = Number(game?.away?.score ?? game?.away_score ?? 0) || 0;
const source = score && typeof score === "object" ? score : {};
return {
home: Math.max(0, Number(source.home ?? fallbackHome) || 0),
away: Math.max(0, Number(source.away ?? fallbackAway) || 0),
external_home: Math.max(0, Number(source.external_home ?? fallbackHome) || 0),
external_away: Math.max(0, Number(source.external_away ?? fallbackAway) || 0),
manual: Boolean(source.manual),
differs: Boolean(source.differs),
source: String(source.source || "external"),
};
}
function hockeyApplyScoreToRuntime(score = null, { render = false } = {}) {
const current = score && typeof score === "object" ? score : hockeyScoreControl();
window.UIBuilderRuntime?.patchData?.({
hockey: {
home: { score: Number(current.home || 0) },
away: { score: Number(current.away || 0) },
},
}, { render });
}
async function hockeyScoreCommand(command, { refreshMapping = true, announce = true } = {}) {
const gameId = hockeyTimerSelectedGameId();
if (!gameId) throw new Error("Сначала выберите матч");
const allowed = new Set(["home_plus", "home_minus", "away_plus", "away_minus", "sync"]);
command = String(command || "").trim();
if (!allowed.has(command)) throw new Error("Неизвестная команда счёта");
const language = hockeyGameControlLanguage();
const before = hockeyScoreControl();
// Before entering manual mode (and before an explicit resync), resolve only the
// configured score SQL source so the operation starts from the freshest baseline.
if (refreshMapping && (!before.manual || command === "sync")) await hockeyRefreshScoreMapping();
const payload = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/control/score`, {
method: "PUT",
body: JSON.stringify({ command, language }),
});
hockeyStoreGameControl(gameId, payload, { render: false, dispatch: true });
hockeyApplyScoreToRuntime(payload?.score, { render: false });
renderHockeyQuickCommandDock();
if (refreshMapping) await hockeyRefreshScoreMapping();
if (announce) {
const score = payload?.score || {};
const label = command === "sync" ? "Счёт синхронизирован" : "Счёт изменён";
toast(`${label}: ${Number(score.home || 0)}:${Number(score.away || 0)}`);
}
return payload;
}
function hockeyScoreDockMarkup() {
const game = getByPath(state.data, "hockey.selected_game") || {};
if (!String(game?.external_id || game?.id || "").trim()) return "";
const score = hockeyScoreControl();
const homeName = String(game?.home?.name || "HOME");
const awayName = String(game?.away?.name || "AWAY");
const tooltip = score.manual
? `Ручной LIVE: ${score.home}:${score.away} · SQL/API: ${score.external_home}:${score.external_away}. Нажмите ↻, чтобы снова принять внешний счёт.`
: `LIVE следует SQL/API: ${score.external_home}:${score.external_away}. Первое +/ переведёт счёт в ручной режим.`;
return `<div class="quick-score-control ${score.manual ? "is-manual" : "is-external"}" data-tooltip="${escapeHtml(tooltip)}">
<span class="quick-score-team" title="${escapeHtml(homeName)}">${escapeHtml(homeName)}</span>
<button type="button" data-hockey-score-command="home_minus" aria-label="HOME минус гол"></button>
<strong>${score.home}</strong>
<button type="button" data-hockey-score-command="home_plus" aria-label="HOME плюс гол">+</button>
<i>:</i>
<button type="button" data-hockey-score-command="away_minus" aria-label="AWAY минус гол"></button>
<strong>${score.away}</strong>
<button type="button" data-hockey-score-command="away_plus" aria-label="AWAY плюс гол">+</button>
<span class="quick-score-team side-away" title="${escapeHtml(awayName)}">${escapeHtml(awayName)}</span>
<button type="button" class="quick-score-sync" data-hockey-score-command="sync" aria-label="Синхронизировать счёт с SQL/API">↻</button>
<small>${score.manual ? "РУЧН" : "SQL"}</small>
</div>`;
}
function quickPanelSelectorMarkup(selector) { function quickPanelSelectorMarkup(selector) {
const current = quickPanelSelectorValue(selector); const current = quickPanelSelectorValue(selector);
const tooltip = selector.description || selector.label; const tooltip = selector.description || selector.label;
@@ -8968,7 +9081,7 @@ function renderHockeyQuickCommandDock() {
<div class="quick-command-tab-scroll">${tabMarkup || `<span class="quick-command-no-tabs">Создайте вкладку для операторских кнопок</span>`}</div> <div class="quick-command-tab-scroll">${tabMarkup || `<span class="quick-command-no-tabs">Создайте вкладку для операторских кнопок</span>`}</div>
<button type="button" class="quick-command-settings" data-quick-command-settings data-tooltip="Настроить вкладки и кнопки">⚙</button> <button type="button" class="quick-command-settings" data-quick-command-settings data-tooltip="Настроить вкладки и кнопки">⚙</button>
</div> </div>
<div class="quick-command-dock-buttons">${buttonMarkup || standaloneMarkup ? `${buttonMarkup}${standaloneMarkup}` : `<span class="quick-command-empty">Во вкладке пока нет кнопок</span>`}</div> <div class="quick-command-dock-buttons">${hockeyScoreDockMarkup()}${buttonMarkup || standaloneMarkup ? `${buttonMarkup}${standaloneMarkup}` : `<span class="quick-command-empty">Во вкладке пока нет кнопок</span>`}</div>
`; `;
el.runtimeButtonDock.querySelectorAll("[data-quick-command-tab]").forEach((tab) => tab.addEventListener("click", () => { el.runtimeButtonDock.querySelectorAll("[data-quick-command-tab]").forEach((tab) => tab.addEventListener("click", () => {
state.quickPanelActiveTab = String(tab.dataset.quickCommandTab || ""); state.quickPanelActiveTab = String(tab.dataset.quickCommandTab || "");
@@ -8996,6 +9109,19 @@ function renderHockeyQuickCommandDock() {
} }
}); });
}); });
el.runtimeButtonDock.querySelectorAll("[data-hockey-score-command]").forEach((control) => control.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
if (control.dataset.busy === "1") return;
control.dataset.busy = "1";
try {
await hockeyScoreCommand(control.dataset.hockeyScoreCommand, { refreshMapping: true, announce: true });
} catch (error) {
toast(`Счёт: ${String(error?.message || error)}`, true);
} finally {
delete control.dataset.busy;
}
}));
el.runtimeButtonDock.querySelectorAll("[data-quick-command-button]").forEach((control) => control.addEventListener("click", async (event) => { el.runtimeButtonDock.querySelectorAll("[data-quick-command-button]").forEach((control) => control.addEventListener("click", async (event) => {
event.preventDefault(); event.preventDefault();
const button = buttons.find((item) => item.id === control.dataset.quickCommandButton); const button = buttons.find((item) => item.id === control.dataset.quickCommandButton);
@@ -11846,8 +11972,14 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
const nextPeriod = String(payload?.current_period || ""); const nextPeriod = String(payload?.current_period || "");
hockeyApplyTimerRules(payload); hockeyApplyTimerRules(payload);
state.hockeyGameControl[String(gameId)] = payload; state.hockeyGameControl[String(gameId)] = payload;
const scorePatch = payload?.score && typeof payload.score === "object"
? {
home: { score: Math.max(0, Number(payload.score.home || 0)) },
away: { score: Math.max(0, Number(payload.score.away || 0)) },
}
: {};
window.UIBuilderRuntime?.patchData?.( window.UIBuilderRuntime?.patchData?.(
{ hockey: { game_control: payload } }, { hockey: { game_control: payload, ...scorePatch } },
{ render } { render }
); );
hockeyBackupPlayerSelectionValues(gameId, payload?.values || {}); hockeyBackupPlayerSelectionValues(gameId, payload?.values || {});
@@ -12269,8 +12401,13 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
state.hockeyTimerSaveTimer = null; state.hockeyTimerSaveTimer = null;
} }
state.hockeyTimerDirty = false; state.hockeyTimerDirty = false;
const payload = await hockeyLoadGameControl(gameId, { force: true, rerender: false }); let payload = await hockeyLoadGameControl(gameId, { force: true, rerender: false });
hockeyApplySavedTimers(gameId, payload?.timers || null); hockeyApplySavedTimers(gameId, payload?.timers || null);
// BUILD119: on a real match switch resolve the configured score SQL keys once
// so a mid-game join immediately starts from the external score baseline.
if (!payload?.score?.manual && await hockeyRefreshScoreMapping()) {
payload = await hockeyLoadGameControl(gameId, { force: true, rerender: false }) || payload;
}
state.hockeyTimerDirty = false; state.hockeyTimerDirty = false;
return payload; return payload;
} finally { } finally {
@@ -13871,6 +14008,10 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
switch (step.type) { switch (step.type) {
case "timer_command": return `Веб-таймер · ${step.timer_command || "start"}`; case "timer_command": return `Веб-таймер · ${step.timer_command || "start"}`;
case "hockey_penalties_command": return `Удаления в вебе · ${step.penalty_command || "start"}`; case "hockey_penalties_command": return `Удаления в вебе · ${step.penalty_command || "start"}`;
case "hockey_score_command": {
const labels = { home_plus: "HOME +1", home_minus: "HOME 1", away_plus: "AWAY +1", away_minus: "AWAY 1", sync: "синхронизация SQL/API" };
return `Счёт · ${labels[step.score_command] || step.score_command || "HOME +1"}`;
}
case "vmix_command": return `vMix · ${step.function || "Function"}${step.input ? ` · Input ${step.input}` : ""}`; case "vmix_command": return `vMix · ${step.function || "Function"}${step.input ? ` · Input ${step.input}` : ""}`;
case "hockey_vmix_timers_start": return `Хоккей · таймеры · ${step.hockey_timer_command === "pause" ? "пауза" : step.hockey_timer_command === "start" ? "старт" : step.hockey_timer_command === "resume" ? "продолжить" : "старт / пауза"}`; case "hockey_vmix_timers_start": return `Хоккей · таймеры · ${step.hockey_timer_command === "pause" ? "пауза" : step.hockey_timer_command === "start" ? "старт" : step.hockey_timer_command === "resume" ? "продолжить" : "старт / пауза"}`;
case "delay": return `Задержка · ${Number(step.milliseconds) || 0} мс`; case "delay": return `Задержка · ${Number(step.milliseconds) || 0} мс`;
@@ -13946,6 +14087,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
${triggerSelectOptions([ ${triggerSelectOptions([
["timer_command","Веб: управление таймером"], ["timer_command","Веб: управление таймером"],
["hockey_penalties_command","Веб: все текущие удаления"], ["hockey_penalties_command","Веб: все текущие удаления"],
["hockey_score_command","Хоккей: ручной счёт +/- / синхронизация"],
["vmix_command","vMix: произвольная команда"], ["vmix_command","vMix: произвольная команда"],
["hockey_vmix_timers_start","Хоккей: синхронный старт / пауза таймеров"], ["hockey_vmix_timers_start","Хоккей: синхронный старт / пауза таймеров"],
["delay","Задержка"], ["delay","Задержка"],
@@ -13976,6 +14118,16 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<label>Дашборд<select data-step-field="target_action_id">${hockeyPenaltyBoardOptions(step.target_action_id)}</select></label> <label>Дашборд<select data-step-field="target_action_id">${hockeyPenaltyBoardOptions(step.target_action_id)}</select></label>
<label>Команда<select data-step-field="penalty_command">${triggerSelectOptions([["start","Запустить все"],["pause","Пауза всем"],["reset","Сбросить все"]], step.penalty_command)}</select></label> <label>Команда<select data-step-field="penalty_command">${triggerSelectOptions([["start","Запустить все"],["pause","Пауза всем"],["reset","Сбросить все"]], step.penalty_command)}</select></label>
</div><p class="shortcut-step-note">Если Action ID не выбран, команда применяется ко всем текущим удалениям во всех хоккейных дашбордах.</p>`; </div><p class="shortcut-step-note">Если Action ID не выбран, команда применяется ко всем текущим удалениям во всех хоккейных дашбордах.</p>`;
} else if (step.type === "hockey_score_command") {
body.innerHTML = `<div class="shortcut-step-grid">
<label>Команда счёта<select data-step-field="score_command">${triggerSelectOptions([
["home_plus","HOME +1 гол"],
["home_minus","HOME 1 гол"],
["away_plus","AWAY +1 гол"],
["away_minus","AWAY 1 гол"],
["sync","Синхронизировать с SQL/API"],
], step.score_command)}</select></label>
</div><p class="shortcut-step-note">Первое ручное +/ фиксирует оперативный LIVE-счёт для текущего матча. Последующие обновления API его не перезаписывают. «Синхронизировать» берёт последний счёт из базы/API и снова включает внешний режим.</p>`;
} else if (step.type === "vmix_command") { } else if (step.type === "vmix_command") {
const functionKnown = !step.function || VMIX_FUNCTIONS.includes(String(step.function)); const functionKnown = !step.function || VMIX_FUNCTIONS.includes(String(step.function));
body.innerHTML = `<div class="shortcut-step-grid vmix-command-grid"> body.innerHTML = `<div class="shortcut-step-grid vmix-command-grid">
@@ -14792,6 +14944,11 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
hockeyActivateGameTimers(gameId); hockeyActivateGameTimers(gameId);
} else if (!state.hockeyGameControl[gameId]) { } else if (!state.hockeyGameControl[gameId]) {
hockeyLoadGameControl(gameId, { force: false, rerender: false }); hockeyLoadGameControl(gameId, { force: false, rerender: false });
} else if (state.hockeyGameControl[gameId]?.score?.manual) {
// Stat2TV polling still refreshes the selected-game object every second.
// In manual score mode immediately restore the operator-authoritative LIVE
// score so a late API response cannot visually roll the score backwards.
hockeyApplyScoreToRuntime(state.hockeyGameControl[gameId].score, { render: false });
} }
}); });
window.addEventListener("beforeunload", () => { window.addEventListener("beforeunload", () => {

View File

@@ -7682,3 +7682,99 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
.hockey-prepared-layout { grid-template-columns:250px minmax(0,1fr) 250px; } .hockey-prepared-layout { grid-template-columns:250px minmax(0,1fr) 250px; }
.hockey-prepared-field { grid-template-columns:1fr; gap:4px; } .hockey-prepared-field { grid-template-columns:1fr; gap:4px; }
} }
/* BUILD119 — roster role counters + manual operational score. */
.hpd-roster-count-breakdown{
flex:0 0 auto;
min-width:0 !important;
display:flex;
align-items:center;
gap:4px;
padding:4px 6px !important;
white-space:nowrap;
}
.hpd-roster-count-breakdown b{
min-width:20px;
color:#dce8f6;
font-size:10px;
font-weight:950;
text-align:center;
}
.hpd-roster-count-breakdown span{
min-width:19px;
padding-left:4px;
border-left:1px solid #294059;
color:#7f96b1;
font-size:8px;
font-weight:900;
letter-spacing:0;
text-align:center;
}
.quick-score-control{
flex:0 0 auto;
height:34px;
display:flex;
align-items:center;
gap:3px;
padding:0 5px;
color:#dce8f5;
border:1px solid #30465e;
border-radius:8px;
background:linear-gradient(180deg,#132238,#0e1a2a);
box-shadow:0 3px 10px rgba(0,0,0,.18);
}
.quick-score-control.is-manual{
border-color:#8d6c36;
background:linear-gradient(180deg,#2d2619,#1d1a13);
}
.quick-score-team{
max-width:68px;
overflow:hidden;
color:#8fa5be;
font-size:8px;
font-weight:900;
text-overflow:ellipsis;
white-space:nowrap;
}
.quick-score-team.side-away{text-align:right}
.quick-score-control button{
width:25px;
height:25px;
padding:0;
color:#cbd9e8;
border:1px solid #314963;
border-radius:6px;
background:#0c1828;
font-size:14px;
font-weight:950;
line-height:1;
}
.quick-score-control button:hover{color:#fff;border-color:#55718f;background:#14253a}
.quick-score-control strong{
min-width:18px;
color:#fff;
font-family:"Roboto Mono","Cascadia Mono",Consolas,monospace;
font-size:15px;
font-variant-numeric:tabular-nums;
font-weight:950;
text-align:center;
}
.quick-score-control i{color:#647d99;font-style:normal;font-size:13px;font-weight:950}
.quick-score-control .quick-score-sync{width:27px;color:#87a0bc;font-size:13px}
.quick-score-control small{
min-width:29px;
padding:3px 4px;
color:#6fe0c5;
border-radius:5px;
background:rgba(72,223,189,.08);
font-size:7px;
font-weight:950;
letter-spacing:.04em;
text-align:center;
}
.quick-score-control.is-manual small{color:#e3bc72;background:rgba(227,188,114,.09)}
@media (max-width:920px){
.quick-score-team{display:none}
.quick-score-control{padding-inline:4px}
}