diff --git a/.gitignore b/.gitignore index 784c0e4..26022db 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ settings/* !settings/ui_builder_draft.json !settings/ui_builder_published.json !settings/hockey_api.json +!settings/hockey_timers.json !settings/stat2tv_credentials.example.json # Local secrets / machine-specific runtime settings (never commit). diff --git a/hockey_data/config.py b/hockey_data/config.py index f05c04a..4397de5 100644 --- a/hockey_data/config.py +++ b/hockey_data/config.py @@ -170,6 +170,33 @@ DEFAULT_PUBLIC_SETTINGS: dict[str, Any] = { "score_external_away_key": "", } + +# Timer and numerical-strength rules are stored separately from Stat2TV/API +# connection settings. Keep this list explicit so both migration and future +# updates use one source of truth. +TIMER_STRENGTH_SETTING_KEYS: tuple[str, ...] = ( + "timer_period_minutes", + "timer_regular_overtime_minutes", + "timer_playoff_overtime_minutes", + "timer_reset_on_period_change", + "strength_regulation_skaters", + "strength_regular_overtime_skaters", + "strength_playoff_overtime_skaters", + "strength_min_skaters", + "strength_regulation_display", + "strength_regular_overtime_display", + "strength_playoff_overtime_display", + "strength_powerplay_label", + "strength_shorthanded_label", + "strength_show_shorthanded", + "strength_state_labels", +) + + +def _timer_strength_defaults() -> dict[str, Any]: + return {key: DEFAULT_PUBLIC_SETTINGS[key] for key in TIMER_STRENGTH_SETTING_KEYS} + + DEFAULT_SECRET_SETTINGS: dict[str, str] = { "username": "", "password": "", @@ -192,16 +219,56 @@ class HockeySettingsStore: self.settings_dir = Path(settings_dir) self.settings_dir.mkdir(parents=True, exist_ok=True) self.public_file = self.settings_dir / "hockey_api.json" + self.timer_rules_file = self.settings_dir / "hockey_timers.json" self.secret_file = self.settings_dir / "stat2tv_credentials.local.json" self._lock = RLock() self._ensure_files() def _ensure_files(self) -> None: + public_defaults = { + key: value + for key, value in DEFAULT_PUBLIC_SETTINGS.items() + if key not in TIMER_STRENGTH_SETTING_KEYS + } if not self.public_file.exists(): self.public_file.write_text( - json.dumps(DEFAULT_PUBLIC_SETTINGS, ensure_ascii=False, indent=2), + json.dumps(public_defaults, ensure_ascii=False, indent=2), encoding="utf-8", ) + + # One-time migration from the historical all-in-one hockey_api.json. + # Existing operator values win over defaults, then the migrated keys are + # removed from hockey_api.json so there is only one persisted source. + public_raw = self._read_json(self.public_file) + timer_rules = { + **_timer_strength_defaults(), + **self._read_json(self.timer_rules_file), + } + legacy_timer_keys = [ + key for key in TIMER_STRENGTH_SETTING_KEYS if key in public_raw + ] + # If legacy keys still exist, they are the latest values from the old + # layout and must win even when a template hockey_timers.json was copied + # together with an update package. + for key in legacy_timer_keys: + timer_rules[key] = public_raw[key] + if legacy_timer_keys or not self.timer_rules_file.exists(): + self.timer_rules_file.write_text( + json.dumps(timer_rules, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + migrated_public = { + key: value + for key, value in public_raw.items() + if key not in TIMER_STRENGTH_SETTING_KEYS + } + if migrated_public != public_raw: + self.public_file.write_text( + json.dumps(migrated_public, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + if not self.secret_file.exists(): self.secret_file.write_text( json.dumps(DEFAULT_SECRET_SETTINGS, ensure_ascii=False, indent=2), @@ -237,7 +304,11 @@ class HockeySettingsStore: def public(self) -> dict[str, Any]: with self._lock: - raw = {**DEFAULT_PUBLIC_SETTINGS, **self._read_json(self.public_file)} + raw = { + **DEFAULT_PUBLIC_SETTINGS, + **self._read_json(self.public_file), + **self._read_json(self.timer_rules_file), + } raw["base_url"] = self._normalise_url( os.getenv("STAT2TV_BASE_URL", str(raw["base_url"])) @@ -419,18 +490,34 @@ class HockeySettingsStore: def update_public(self, payload: dict[str, Any]) -> dict[str, Any]: allowed = set(DEFAULT_PUBLIC_SETTINGS) + timer_keys = set(TIMER_STRENGTH_SETTING_KEYS) + public_defaults = { + key: value + for key, value in DEFAULT_PUBLIC_SETTINGS.items() + if key not in timer_keys + } with self._lock: - current = {**DEFAULT_PUBLIC_SETTINGS, **self._read_json(self.public_file)} + current_public = {**public_defaults, **self._read_json(self.public_file)} + current_timers = {**_timer_strength_defaults(), **self._read_json(self.timer_rules_file)} for key in allowed: - if key in payload: - current[key] = payload[key] - current["base_url"] = self._normalise_url(str(current["base_url"])) - current["ui_language"] = self._language(current["ui_language"], "ru") - current["fallback_language"] = self._language( - current["fallback_language"], "ru" + if key not in payload: + continue + if key in timer_keys: + current_timers[key] = payload[key] + else: + current_public[key] = payload[key] + + current_public["base_url"] = self._normalise_url(str(current_public["base_url"])) + current_public["ui_language"] = self._language(current_public["ui_language"], "ru") + current_public["fallback_language"] = self._language( + current_public["fallback_language"], "ru" ) self.public_file.write_text( - json.dumps(current, ensure_ascii=False, indent=2), + json.dumps(current_public, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + self.timer_rules_file.write_text( + json.dumps(current_timers, ensure_ascii=False, indent=2), encoding="utf-8", ) return self.status() diff --git a/hockey_data/mapping_context.py b/hockey_data/mapping_context.py index c018a72..32fb868 100644 --- a/hockey_data/mapping_context.py +++ b/hockey_data/mapping_context.py @@ -86,21 +86,26 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = ( {"key": "selected_home_penalty_player_id", "label": "Удаление левой команды — игрок external ID", "category": "Удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, {"key": "selected_home_penalty_player_db_id", "label": "Удаление левой команды — игрок DB ID", "category": "Удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, {"key": "selected_home_penalty_team_penalty", "label": "Удаление левой команды — командное (1/0)", "category": "Удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"}, + {"key": "selected_home_penalty_time_type", "label": "Удаление левой команды — тип времени (2 / 2+2 / 4 / 5 / 5+20 / 10)", "category": "Удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text", "description": "Точное название выбранного пресета штрафного времени, а не суммарная длительность в миллисекундах."}, {"key": "selected_away_penalty_id", "label": "Удаление правой команды — ID удаления", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"}, {"key": "selected_away_penalty_player_id", "label": "Удаление правой команды — игрок external ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, {"key": "selected_away_penalty_player_db_id", "label": "Удаление правой команды — игрок DB ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, {"key": "selected_away_penalty_team_penalty", "label": "Удаление правой команды — командное (1/0)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"}, + {"key": "selected_away_penalty_time_type", "label": "Удаление правой команды — тип времени (2 / 2+2 / 4 / 5 / 5+20 / 10)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text", "description": "Точное название выбранного пресета штрафного времени, а не суммарная длительность в миллисекундах."}, {"key": "active_home_penalty_id", "label": "Активное удаление левой команды — ID", "category": "Активные удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"}, {"key": "active_home_penalty_player_id", "label": "Активное удаление левой команды — игрок external ID", "category": "Активные удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, {"key": "active_home_penalty_player_db_id", "label": "Активное удаление левой команды — игрок DB ID", "category": "Активные удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, {"key": "active_home_penalty_team_penalty", "label": "Активное удаление левой команды — командное (1/0)", "category": "Активные удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"}, + {"key": "active_home_penalty_time_type", "label": "Активное удаление левой команды — тип времени (2 / 2+2 / 4 / 5 / 5+20 / 10)", "category": "Активные удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text", "description": "Пресет штрафного времени текущего удаления; доступен сразу после назначения времени, до запуска countdown."}, {"key": "active_away_penalty_id", "label": "Активное удаление правой команды — ID", "category": "Активные удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"}, {"key": "active_away_penalty_player_id", "label": "Активное удаление правой команды — игрок external ID", "category": "Активные удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, {"key": "active_away_penalty_player_db_id", "label": "Активное удаление правой команды — игрок DB ID", "category": "Активные удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, {"key": "active_away_penalty_team_penalty", "label": "Активное удаление правой команды — командное (1/0)", "category": "Активные удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"}, + {"key": "active_away_penalty_time_type", "label": "Активное удаление правой команды — тип времени (2 / 2+2 / 4 / 5 / 5+20 / 10)", "category": "Активные удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text", "description": "Пресет штрафного времени текущего удаления; доступен сразу после назначения времени, до запуска countdown."}, {"key": "selected_penalty_infraction_id", "label": "Нарушение выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, {"key": "selected_penalty_preset_id", "label": "Длительность выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, + {"key": "selected_penalty_time_type", "label": "Тип времени выбранного удаления (2 / 2+2 / 4 / 5 / 5+20 / 10)", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text", "description": "Точное название выбранного пресета штрафного времени. Например: 2, 2+2, 4, 5, 5+20, 10."}, {"key": "selected_penalty_side", "label": "Сторона выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, {"key": "selected_penalty_event_time", "label": "Время события выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, {"key": "selected_penalty_status", "label": "Статус выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, @@ -126,18 +131,22 @@ MATCH_SHARED_CONTEXT_KEYS: frozenset[str] = frozenset({ "selected_home_penalty_player_id", "selected_home_penalty_player_db_id", "selected_home_penalty_team_penalty", + "selected_home_penalty_time_type", "selected_away_penalty_id", "selected_away_penalty_player_id", "selected_away_penalty_player_db_id", "selected_away_penalty_team_penalty", + "selected_away_penalty_time_type", "active_home_penalty_id", "active_home_penalty_player_id", "active_home_penalty_player_db_id", "active_home_penalty_team_penalty", + "active_home_penalty_time_type", "active_away_penalty_id", "active_away_penalty_player_id", "active_away_penalty_player_db_id", "active_away_penalty_team_penalty", + "active_away_penalty_time_type", }) diff --git a/settings/hockey_timers.json b/settings/hockey_timers.json new file mode 100644 index 0000000..a3ec43c --- /dev/null +++ b/settings/hockey_timers.json @@ -0,0 +1,96 @@ +{ + "timer_period_minutes": 20, + "timer_regular_overtime_minutes": 5, + "timer_playoff_overtime_minutes": 20, + "timer_reset_on_period_change": true, + "strength_regulation_skaters": 5, + "strength_regular_overtime_skaters": 3, + "strength_playoff_overtime_skaters": 5, + "strength_min_skaters": 3, + "strength_regulation_display": "pp", + "strength_regular_overtime_display": "numbers", + "strength_playoff_overtime_display": "pp", + "strength_powerplay_label": "PP", + "strength_shorthanded_label": "PK", + "strength_show_shorthanded": false, + "strength_state_labels": { + "regulation": { + "5x5": { + "ru": "", + "en": "" + }, + "5x4": { + "ru": "БОЛ", + "en": "PP" + }, + "4x4": { + "ru": "4 на 4", + "en": "4 on 4" + }, + "5x3": { + "ru": "5 на 3", + "en": "5 on 3" + }, + "4x3": { + "ru": "4 на 3", + "en": "4 on 3" + }, + "3x3": { + "ru": "3 на 3", + "en": "3 on 3" + } + }, + "regular_overtime": { + "3x3": { + "ru": "3 на 3", + "en": "3 on 3" + }, + "4x3": { + "ru": "4 на 3", + "en": "4 on 3" + }, + "4x4": { + "ru": "4 на 4", + "en": "4 on 4" + }, + "5x3": { + "ru": "5 на 3", + "en": "5 on 3" + }, + "5x4": { + "ru": "5 на 4", + "en": "5 on 4" + }, + "5x5": { + "ru": "5 на 5", + "en": "5 on 5" + } + }, + "playoff_overtime": { + "5x5": { + "ru": "", + "en": "" + }, + "5x4": { + "ru": "PP", + "en": "PP" + }, + "4x4": { + "ru": "4 на 4", + "en": "4 on 4" + }, + "5x3": { + "ru": "5 на 3", + "en": "5 on 3" + }, + "4x3": { + "ru": "4 на 3", + "en": "4 on 3" + }, + "3x3": { + "ru": "3 на 3", + "en": "3 on 3" + } + } + } +} diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index e76f763..1e4a20c 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -5322,6 +5322,7 @@ function startCustomTooltips() { player_id: String(playerIds.externalId || ""), player_db_id: String(playerIds.dbId || ""), team_penalty: Boolean(event.teamPenalty), + time_type: String(event.preset || ""), side: String(side || event.player?.side || event.side || ""), }; } @@ -5333,15 +5334,24 @@ function startCustomTooltips() { const home = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("home", { includePrepared: true })[0] || null, "home"); const away = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("away", { includePrepared: true })[0] || null, "away"); + const boardComponent = hockeyPenaltyBoardComponent(); + const selectedEvent = boardComponent ? selectedHockeyEvent(boardComponent) : null; + const selectedTimeType = String(selectedEvent?.preset || ""); const values = { active_home_penalty_id: home?.penalty_id || "", active_home_penalty_player_id: home?.player_id || "", active_home_penalty_player_db_id: home?.player_db_id || "", active_home_penalty_team_penalty: home ? (home.team_penalty ? "1" : "0") : "", + active_home_penalty_time_type: home?.time_type || "", active_away_penalty_id: away?.penalty_id || "", active_away_penalty_player_id: away?.player_id || "", active_away_penalty_player_db_id: away?.player_db_id || "", active_away_penalty_team_penalty: away ? (away.team_penalty ? "1" : "0") : "", + active_away_penalty_time_type: away?.time_type || "", + // Keep the currently highlighted Game-tab penalty type ready before + // penalty_ready/penalty_assigned invokes any vMix trigger. + selected_penalty_preset_id: selectedTimeType, + selected_penalty_time_type: selectedTimeType, }; const signature = JSON.stringify([gameId, values]); if (!force && signature === state.hockeyPenaltyMappingContextSignature) return false; @@ -7655,6 +7665,7 @@ function openTimerQuickEditor(focusActionId = "") { team_penalty: Boolean(event.teamPenalty), infraction: event.infraction ? clone(event.infraction) : null, preset: event.preset, + time_type: String(event.preset || ""), remaining_ms: Math.round(event.remainingMs), duration_ms: Math.round(event.durationMs), running: event.running, @@ -8758,12 +8769,14 @@ async function hockeyClearSelectedPenaltyMappingContext(side, { clearCommon = fa selected_home_penalty_player_id: "", selected_home_penalty_player_db_id: "", selected_home_penalty_team_penalty: "", + selected_home_penalty_time_type: "", } : { selected_away_penalty_id: "", selected_away_penalty_player_id: "", selected_away_penalty_player_db_id: "", selected_away_penalty_team_penalty: "", + selected_away_penalty_time_type: "", }; if (clearCommon) Object.assign(values, { selected_penalty_id: "", @@ -8773,6 +8786,7 @@ async function hockeyClearSelectedPenaltyMappingContext(side, { clearCommon = fa selected_penalty_team_id: "", selected_penalty_infraction_id: "", selected_penalty_preset_id: "", + selected_penalty_time_type: "", selected_penalty_side: "", selected_penalty_event_time: "", selected_penalty_status: "", @@ -8833,6 +8847,7 @@ async function hockeySelectPenaltyForPreview(component, event) { team_penalty: Boolean(event.teamPenalty), infraction_id: String(event.infraction?.id || ""), preset_id: String(event.preset || ""), + time_type: String(event.preset || ""), side, event_time: String(event.eventTime || ""), remaining_ms: Math.max(0, Number(event.remainingMs || 0)), @@ -8864,6 +8879,7 @@ async function hockeySelectPenaltyForPreview(component, event) { selected_penalty_team_id: detail.team_id, selected_penalty_infraction_id: detail.infraction_id, selected_penalty_preset_id: detail.preset_id, + selected_penalty_time_type: detail.time_type, selected_penalty_side: detail.side, selected_penalty_event_time: detail.event_time, selected_penalty_status: detail.status, @@ -8878,6 +8894,7 @@ async function hockeySelectPenaltyForPreview(component, event) { selected_home_penalty_player_id: detail.player_id, selected_home_penalty_player_db_id: detail.player_db_id, selected_home_penalty_team_penalty: detail.team_penalty ? "1" : "0", + selected_home_penalty_time_type: detail.time_type, }); } else if (side === "away") { Object.assign(values, { @@ -8885,6 +8902,7 @@ async function hockeySelectPenaltyForPreview(component, event) { selected_away_penalty_player_id: detail.player_id, selected_away_penalty_player_db_id: detail.player_db_id, selected_away_penalty_team_penalty: detail.team_penalty ? "1" : "0", + selected_away_penalty_time_type: detail.time_type, }); }