diff --git a/app.py b/app.py index 8748170..44fb9d7 100644 --- a/app.py +++ b/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 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.25.1" # compatibility: BUILD_VERSION = "2026.08.24.15" diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index 95816c4..ca89ed1 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -17,7 +17,7 @@ from sqlalchemy import and_, delete, desc, select from .auth_bridge import HockeyUser from .database import HockeyDatabase 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 @@ -1106,6 +1106,53 @@ class VmixAgentHub: "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( self, device_id: str, @@ -1200,6 +1247,12 @@ class VmixAgentHub: if 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} + 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: for field in fields: rule = _mapping_rule_payload(getattr(field, "rule_json", "{}")) @@ -1214,7 +1267,49 @@ class VmixAgentHub: if catalog_source_codes is not None 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] = { "ok": True, @@ -1225,6 +1320,7 @@ class VmixAgentHub: "profile_id": profile_id, "profile_name": profile_name, "profile_version": profile_version, + "score_snapshot": score_snapshot, "total": len(fields), "applied": 0, "rules_total": 0, @@ -4382,6 +4478,7 @@ def create_hockey_agent_router( device_id: str, only_changed: bool = False, active_tab: str = "", + source_code: str = "", user: HockeyUser = Depends(auth_dependency), ) -> dict[str, Any]: device_id = hub.normalise_device_id(device_id) @@ -4391,9 +4488,12 @@ def create_hockey_agent_router( raise HTTPException(status_code=404, detail="Устройство не прикреплено к вашему аккаунту") if not device.is_active_for_account: 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( 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, extra_context={"active_tab": str(active_tab or "").strip()} if str(active_tab or "").strip() else None, ) diff --git a/hockey_data/config.py b/hockey_data/config.py index dce0374..f05c04a 100644 --- a/hockey_data/config.py +++ b/hockey_data/config.py @@ -163,6 +163,11 @@ DEFAULT_PUBLIC_SETTINGS: dict[str, Any] = { "strength_state_labels": DEFAULT_STRENGTH_STATE_LABELS, "period_status_labels": DEFAULT_PERIOD_STATUS_LABELS, "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] = { @@ -373,6 +378,9 @@ class HockeySettingsStore: raw[key] = str( raw.get(key) or DEFAULT_PUBLIC_SETTINGS[key] ).strip() + for key in ("score_external_home_key", "score_external_away_key"): + raw[key] = str(raw.get(key) or "").strip()[:160] + return raw def credentials(self) -> tuple[str, str]: diff --git a/hockey_data/mapping_context.py b/hockey_data/mapping_context.py index 9791ac9..5c9520c 100644 --- a/hockey_data/mapping_context.py +++ b/hockey_data/mapping_context.py @@ -624,6 +624,13 @@ class MappingDataService: context.setdefault("team1_id", game.home_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)) + 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: runtime_values = HockeyDataService._runtime_values_from_row(control) player_panel_labels: dict[str, str] = {} @@ -871,6 +878,24 @@ class MappingDataService: language=language, ) 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 = ( ("key", "Ключ периода", "text"), ("number", "Номер периода / овертайма", "number"), diff --git a/hockey_data/router.py b/hockey_data/router.py index c2bd1f4..ad9c0ca 100644 --- a/hockey_data/router.py +++ b/hockey_data/router.py @@ -62,6 +62,8 @@ class PublicSettingsPayload(BaseModel): strength_state_labels: dict[str, Any] | None = None period_status_labels: 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): @@ -116,6 +118,11 @@ class GameValuesPayload(BaseModel): 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): initial_attempts: int = Field(default=3) reset: bool = False @@ -1007,6 +1014,22 @@ def create_hockey_router( except ValueError as 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") async def update_game_values( external_id: str, diff --git a/hockey_data/service.py b/hockey_data/service.py index 31fc239..3076bf9 100644 --- a/hockey_data/service.py +++ b/hockey_data/service.py @@ -5469,10 +5469,17 @@ class HockeyDataService: if not assigned: continue - # BUILD113: as soon as the operator has fully assigned a penalty, it - # already affects the scorebug numerical strength. The penalty clock may - # still be stopped; Start controls only the countdown, not the displayed - # 5x4 / 4x3 state. Incomplete drafts are filtered out by `assigned` above. + # BUILD96: a prepared penalty must not change numerical strength until + # the operator explicitly starts it. New Runtime snapshots always send + # startedOnce. Legacy snapshots (without the marker) keep the old + # 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": home += 1 else: @@ -5709,6 +5716,41 @@ class HockeyDataService: result[clean_key] = str(value if value is not None else "")[:512] 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 def _timer_state_from_row( cls, @@ -5848,6 +5890,7 @@ class HockeyDataService: "timer_rules": timer_rules, "strength": strength, "scoreboard_team_states": scoreboard_team_states, + "score": self._score_control_payload(game, control), "timers": timer_state, "flags": self._runtime_flags_from_row(control), "values": self._runtime_values_from_row(control), @@ -6003,6 +6046,62 @@ class HockeyDataService: raise ValueError("Матч не найден") 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( self, game_external_id: str, diff --git a/hockey_data/static/admin-directories.js b/hockey_data/static/admin-directories.js index 19376a7..09bdc4a 100644 --- a/hockey_data/static/admin-directories.js +++ b/hockey_data/static/admin-directories.js @@ -817,6 +817,17 @@ vMix: ${escapeHtml(state.timerRulesVmixInventory?.device_name || "Agent не выбран / inventory не загружен")}. Для обновления списка Inputs закройте и снова откройте этот раздел после выбора Agent. +
+
06
Внешний счёт SQL / MappingИсточник начального счёта и кнопки ↻ синхронизации.
+
+ Как использовать: укажите ключи данных из Mapping для счёта HOME и AWAY, например score_sql.home и score_sql.away. Пока ручной режим не включён, LIVE следует этим значениям. После первого +/− счёт фиксируется вручную; ↻ снова принимает актуальный SQL. Если поля оставить пустыми, используется счёт из карточки матча/API. +
+
+ + +
+
+