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

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

View File

@@ -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,