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.
+