BUILD119 — ручной счёт + SQL + составы
This commit is contained in:
2
app.py
2
app.py
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = "Настройки сохранены";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user