diff --git a/EDITOR_ACCESS.md b/EDITOR_ACCESS.md index 5c7c698..5f7c9c4 100644 --- a/EDITOR_ACCESS.md +++ b/EDITOR_ACCESS.md @@ -1,22 +1,24 @@ # Защита конструктора -## Формула ежедневного PIN +## PIN доступа + +Начиная с BUILD98 используется один фиксированный PIN конструктора: ```text -PIN[i] = (DDMM[i] + MASK[i]) mod 10 +1993 ``` -Маска находится в: +Его можно переопределить переменной окружения: ```text -settings/editor_security.json +EDITOR_FIXED_PIN ``` -Или может быть переопределена переменными окружения: +Старые поля `daily_mask` и `master_pin` в `settings/editor_security.json` сохраняются только для совместимости со старыми конфигурациями и больше не участвуют в проверке входа. + +Дополнительные настройки сессии: ```text -EDITOR_PIN_MASK -EDITOR_MASTER_PIN EDITOR_PIN_TIMEZONE EDITOR_SESSION_MINUTES EDITOR_MAX_ATTEMPTS @@ -27,7 +29,7 @@ EDITOR_LOCK_SECONDS 1. Оператор открывает `/`. 2. Нажимает `Ctrl+Shift+E`. -3. Вводит ежедневный PIN. +3. Вводит PIN `1993`. 4. Редактирует черновик. 5. Проверяет через «Предпросмотр». 6. Нажимает «Опубликовать». diff --git a/app.py b/app.py index 8ac1bad..377caf3 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,9 @@ 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.21.1" +BUILD_VERSION = "2026.08.24.2" +# compatibility: BUILD_VERSION = "2026.08.24.1" +# compatibility: BUILD_VERSION = "2026.08.21.1" # compatibility: BUILD_VERSION = "2026.08.20.17" # compatibility: BUILD_VERSION = "2026.08.20.16" # compatibility: BUILD_VERSION = "2026.08.20.15" diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index 850dd72..d150f8b 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -2308,6 +2308,96 @@ class VmixAgentHub: "updated_at": row.updated_at.isoformat() if row.updated_at else "", } + @staticmethod + def _prepared_saved_fields(row: VmixPreparedTitle) -> dict[str, dict[str, str]]: + try: + payload = json.loads(row.field_values_json or "{}") + except Exception: + payload = {} + if not isinstance(payload, dict): + return {} + result: dict[str, dict[str, str]] = {} + for raw_name, raw_entry in list(payload.items())[:500]: + name = str(raw_name or "").strip()[:300] + if not name: + continue + if isinstance(raw_entry, dict): + value = "" if raw_entry.get("value") is None else str(raw_entry.get("value")) + field_type = str(raw_entry.get("type") or "text").strip().lower() + else: + value = "" if raw_entry is None else str(raw_entry) + field_type = "text" + if field_type not in {"text", "image", "source", "color", "colour"}: + field_type = "text" + result[name] = {"value": value[:8000], "type": field_type} + return result + + @staticmethod + def _prepared_next_default_name(session: Any, *, user_id: str, device_uuid: str, game_id: str) -> str: + query = select(VmixPreparedTitle.name).where(VmixPreparedTitle.wfl_user_id == user_id) + if device_uuid: + query = query.where(VmixPreparedTitle.device_uuid == device_uuid) + if game_id: + query = query.where(VmixPreparedTitle.game_external_id == game_id) + used: set[int] = set() + for raw_name in session.scalars(query): + match = re.fullmatch(r"\s*Заготовка\s+(\d+)\s*", str(raw_name or ""), flags=re.IGNORECASE) + if match: + used.add(int(match.group(1))) + number = 1 + while number in used: + number += 1 + return f"Заготовка {number}" + + @staticmethod + def _prepared_field_update_commands( + *, + input_ref: str, + available_fields: list[dict[str, Any]], + raw_values: Any, + existing_fields: dict[str, dict[str, str]] | None = None, + ) -> tuple[dict[str, dict[str, str]], list[dict[str, Any]]]: + values = raw_values if isinstance(raw_values, dict) else {} + saved_fields = dict(existing_fields or {}) + allowed: dict[str, dict[str, Any]] = { + str(field.get("name") or ""): field + for field in available_fields + if isinstance(field, dict) and str(field.get("name") or "") + } + # An old Agent inventory may temporarily miss fields of the freshly + # cloned Input. Existing saved fields are still safe edit targets. + for name, entry in saved_fields.items(): + allowed.setdefault(name, {"name": name, "type": str(entry.get("type") or "text")}) + + commands: list[dict[str, Any]] = [] + for raw_name, raw_entry in list(values.items())[:240]: + name = str(raw_name or "").strip()[:300] + field = allowed.get(name) + if not name or field is None: + continue + if isinstance(raw_entry, dict): + value = "" if raw_entry.get("value") is None else str(raw_entry.get("value")) + requested_type = str(raw_entry.get("type") or "").strip().lower() + else: + value = "" if raw_entry is None else str(raw_entry) + requested_type = "" + existing_type = str(saved_fields.get(name, {}).get("type") or "").strip().lower() + field_type = str(field.get("type") or requested_type or existing_type or "text").strip().lower() + if field_type not in {"text", "image", "source", "color", "colour"}: + field_type = requested_type if requested_type in {"text", "image", "source", "color", "colour"} else "text" + saved_fields[name] = {"value": value[:8000], "type": field_type} + # Empty text deliberately clears inherited text. Some vMix title + # engines reject an empty image/source/color, so leave those alone. + if not value and field_type in {"image", "source", "color", "colour"}: + continue + commands.append({ + "Function": _mapping_value_function(field_type), + "Input": input_ref, + "SelectedName": name, + "Value": value, + }) + return saved_fields, commands + async def list_prepared_titles(self, user: HockeyUser, *, device_id: str = "", game_id: str = "") -> dict[str, Any]: with self.database.session() as session: device = self._prepared_device_for_user(session, user, device_id) @@ -2330,7 +2420,12 @@ class VmixAgentHub: if found is not None: row.clone_input_key = str(found.get("key") or row.clone_input_key or "")[:128] row.clone_input_number = str(found.get("number") or row.clone_input_number or "")[:32] - row.clone_input_title = str(found.get("title") or row.clone_input_title or "")[:300] + found_title = str(found.get("title") or "")[:300] + # Right after SetInputName the Agent inventory can lag by one + # scan. Do not overwrite a freshly stored desired title with + # the old inherited source name during that short window. + if found_title and (str(row.clone_input_title or "") != str(row.name or "") or found_title == str(row.name or "")): + row.clone_input_title = found_title items = [self._prepared_title_payload(row, inventory) for row in rows] return { "device_id": device.device_uuid, @@ -2463,6 +2558,7 @@ class VmixAgentHub: session_token = str(getattr(payload, "session_token", "") or "").strip() with self.database.session() as session: device = self._prepared_device_for_user(session, user, requested_device) + current_game_id = str(device.current_match_external_id or "").strip() inventory = self._prepared_inventory_from_row(device) source = self._prepared_find_input( inventory, @@ -2490,9 +2586,22 @@ class VmixAgentHub: timeout=6.0, ) target_device_id = str(create_result.get("device_id") or device.device_uuid) - match_id = str(create_result.get("match_id") or "") + match_id = str(create_result.get("match_id") or current_game_id or "").strip() + + requested_name = str(getattr(payload, "name", "") or "").strip()[:200] + if requested_name: + title_name = requested_name + else: + with self.database.session() as session: + title_name = self._prepared_next_default_name( + session, + user_id=user.id, + device_uuid=target_device_id, + game_id=match_id, + ) clone: dict[str, Any] | None = None + current_inputs: list[dict[str, Any]] = [] for _ in range(12): await asyncio.sleep(0.18) with self.database.session() as session: @@ -2505,50 +2614,43 @@ class VmixAgentHub: break clone_key = str(clone.get("key") or "") if clone else "" clone_number = str(clone.get("number") or expected_number) if clone else expected_number - clone_title = str(clone.get("title") or source_title) if clone else source_title - clone_ref = clone_key or clone_number or clone_title + clone_ref = clone_key or clone_number or str(clone.get("title") or source_title if clone else source_title) raw_values = getattr(payload, "field_values", {}) - raw_values = raw_values if isinstance(raw_values, dict) else {} source_fields = source.get("fields") if isinstance(source.get("fields"), list) else [] - allowed = {str(field.get("name") or ""): field for field in source_fields if isinstance(field, dict) and str(field.get("name") or "")} - commands: list[dict[str, Any]] = [] - saved_fields: dict[str, dict[str, str]] = {} - for name, field in list(allowed.items())[:240]: - if name not in raw_values: - continue - raw_entry = raw_values.get(name) - if isinstance(raw_entry, dict): - value = "" if raw_entry.get("value") is None else str(raw_entry.get("value")) - requested_type = str(raw_entry.get("type") or "") - else: - value = "" if raw_entry is None else str(raw_entry) - requested_type = "" - field_type = str(field.get("type") or requested_type or "text").lower() - if field_type not in {"text", "image", "source", "color", "colour"}: - field_type = requested_type.lower() if requested_type.lower() in {"text", "image", "source", "color", "colour"} else "text" - saved_fields[name] = {"value": value[:8000], "type": field_type} - # Empty text is meaningful (clear inherited content). Empty image/color is - # treated as "leave inherited value" because vMix rejects some empty sources. - if not value and field_type in {"image", "source", "color", "colour"}: - continue - commands.append({ - "Function": _mapping_value_function(field_type), - "Input": clone_ref, - "SelectedName": name, - "Value": value, - }) - set_result = None - if commands: - set_result = await self.run_vmix_sequence_for_user( - user, - commands, - device_id=target_device_id, - session_token=session_token, - timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)), - ) + saved_fields, field_commands = self._prepared_field_update_commands( + input_ref=clone_ref, + available_fields=source_fields, + raw_values=raw_values, + ) + + # BUILD99: the prepared title name is also the actual vMix Input display + # name. vMix exposes SetInputName through the Shortcut/API surface. + commands: list[dict[str, Any]] = [ + {"Function": "SetInputName", "Input": clone_ref, "Value": title_name}, + *field_commands, + ] + + # vMix supports selecting categories via API, but does not expose a + # supported shortcut to create/label a custom category or assign an + # Input to it. Keep the safe fallback requested by the operator: the + # prepared title stays at the end of the project. CreateVirtualInput + # normally appends already; MoveInput makes that deterministic if the + # project reordered while the clone was being discovered. + current_numbers = [int(str(item.get("number") or "")) for item in current_inputs if str(item.get("number") or "").isdigit()] + end_position = max(current_numbers) if current_numbers else 0 + if end_position > 0 and clone_number.isdigit() and int(clone_number) != end_position: + commands.append({"Function": "MoveInput", "Input": clone_ref, "Value": str(end_position)}) + clone_number = str(end_position) + + set_result = await self.run_vmix_sequence_for_user( + user, + commands, + device_id=target_device_id, + session_token=session_token, + timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)), + ) - title_name = str(getattr(payload, "name", "") or "").strip()[:200] or f"{source_title or 'Title'} · заготовка" source_kind = re.sub(r"[^A-Za-z0-9_-]+", "_", str(getattr(payload, "source_kind", "manual") or "manual").strip())[:32] or "manual" source_ref_name = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(getattr(payload, "source_ref", "") or "").strip())[:128] with self.database.session() as session: @@ -2564,7 +2666,7 @@ class VmixAgentHub: source_input_title=source_title, clone_input_key=clone_key, clone_input_number=clone_number, - clone_input_title=clone_title, + clone_input_title=title_name, field_values_json=json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")), created_by=user.id, created_at=_utcnow(), @@ -2575,7 +2677,88 @@ class VmixAgentHub: result = self._prepared_title_payload(row) result["ok"] = True result["create_result"] = create_result - result["set_result"] = set_result or {"ok": True, "applied": 0} + result["set_result"] = set_result + result["placement"] = { + "requested_category": "Заготовки", + "mode": "end", + "category_supported": False, + "reason": "vmix_shortcut_api_has_no_input_category_assignment", + } + return result + + async def update_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]: + session_token = str(getattr(payload, "session_token", "") or "").strip() + with self.database.session() as session: + row = session.get(VmixPreparedTitle, int(prepared_id)) + if row is None or row.wfl_user_id != user.id: + raise HTTPException(status_code=404, detail="Заготовка не найдена") + requested_device = str(getattr(payload, "device_id", "") or row.device_uuid or "").strip() + device = self._prepared_device_for_user(session, user, requested_device) + inventory = self._prepared_inventory_from_row(device) + clone = self._prepared_find_input( + inventory, + key=str(row.clone_input_key or ""), + number=str(row.clone_input_number or ""), + title=str(row.clone_input_title or row.name or ""), + ) + source = self._prepared_find_input( + inventory, + key=str(row.source_input_key or ""), + number=str(row.source_input_number or ""), + title=str(row.source_input_title or ""), + ) + clone_ref = str(clone.get("key") or clone.get("number") or "") if clone is not None else str(row.clone_input_key or row.clone_input_number or row.clone_input_title or "") + if not clone_ref: + raise HTTPException(status_code=409, detail="Клонированный Input больше не найден в vMix") + old_fields = self._prepared_saved_fields(row) + available_fields = clone.get("fields") if clone is not None and isinstance(clone.get("fields"), list) else [] + if not available_fields and source is not None and isinstance(source.get("fields"), list): + available_fields = source.get("fields") + old_name = str(row.name or "").strip() + requested_name = str(getattr(payload, "name", "") or "").strip()[:200] + title_name = requested_name or old_name + if not title_name: + title_name = self._prepared_next_default_name( + session, + user_id=user.id, + device_uuid=str(row.device_uuid or device.device_uuid or ""), + game_id=str(row.game_external_id or device.current_match_external_id or ""), + ) + saved_fields, field_commands = self._prepared_field_update_commands( + input_ref=clone_ref, + available_fields=available_fields, + raw_values=getattr(payload, "field_values", {}), + existing_fields=old_fields, + ) + target_device_id = device.device_uuid + + commands: list[dict[str, Any]] = [ + {"Function": "SetInputName", "Input": clone_ref, "Value": title_name}, + *field_commands, + ] + set_result = await self.run_vmix_sequence_for_user( + user, + commands, + device_id=target_device_id, + session_token=session_token, + timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)), + ) + + with self.database.session() as session: + row = session.get(VmixPreparedTitle, int(prepared_id)) + if row is None or row.wfl_user_id != user.id: + raise HTTPException(status_code=404, detail="Заготовка не найдена") + row.name = title_name + row.clone_input_title = title_name[:300] + if clone is not None: + row.clone_input_key = str(clone.get("key") or row.clone_input_key or "")[:128] + row.clone_input_number = str(clone.get("number") or row.clone_input_number or "")[:32] + row.field_values_json = json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")) + row.updated_at = _utcnow() + result = self._prepared_title_payload(row) + result["ok"] = True + result["set_result"] = set_result + result["updated_existing_input"] = True return result async def preview_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]: @@ -3798,6 +3981,13 @@ class PreparedTitleCreatePayload(BaseModel): field_values: dict[str, Any] = Field(default_factory=dict) +class PreparedTitleUpdatePayload(BaseModel): + name: str = Field(default="", max_length=200) + device_id: str = Field(default="", max_length=128) + session_token: str = Field(default="", max_length=128) + field_values: dict[str, Any] = Field(default_factory=dict) + + class PreparedTitlePreviewPayload(BaseModel): device_id: str = Field(default="", max_length=128) session_token: str = Field(default="", max_length=128) @@ -3871,6 +4061,14 @@ def create_hockey_agent_router( ) -> dict[str, Any]: return await hub.create_prepared_title(user, payload) + @router.put("/api/hockey/prepared-titles/{prepared_id}") + async def prepared_title_update( + prepared_id: int, + payload: PreparedTitleUpdatePayload, + user: HockeyUser = Depends(auth_dependency), + ) -> dict[str, Any]: + return await hub.update_prepared_title(prepared_id, user, payload) + @router.post("/api/hockey/prepared-titles/{prepared_id}/preview") async def prepared_title_preview( prepared_id: int, diff --git a/tests/test_build93_countdown_reseed_on_start.py b/tests/test_build93_countdown_reseed_on_start.py index 7b3ae73..495b0f1 100644 --- a/tests/test_build93_countdown_reseed_on_start.py +++ b/tests/test_build93_countdown_reseed_on_start.py @@ -8,9 +8,10 @@ APP = (ROOT / "app.py").read_text(encoding="utf-8") def test_game_countdown_resume_is_reseeded_before_start(): block = APP_JS.split("async function syncActiveVmixGameCountdown", 1)[1].split("function penaltyMirrorKey", 1)[0] assert '["timer_start", "timer_restart", "timer_resume"].includes(eventName)' in block - set_pos = block.index('Function: "SetCountdown"') - start_pos = block.index('Function: "StartCountdown"') - assert set_pos < start_pos + assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "start")' in block + helper = APP_JS.split("function vmixCountdownSyncCommands", 1)[1].split("function buildShortcutRuntimeContext", 1)[0] + start_branch = helper.rsplit("return [", 1)[1] + assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"') assert 'eventName === "timer_resume"' not in block @@ -18,8 +19,9 @@ def test_hockey_timer_shortcut_reseeds_game_and_penalty_on_resume(): block = APP_JS.split('case "hockey_vmix_timers_start":', 1)[1].split('case "delay":', 1)[0] # Resume must no longer have a StartCountdown-only branch. assert '} else if (action === "resume") {' not in block - assert block.count('Function: "SetCountdown"') >= 2 - assert block.count('Function: "StartCountdown"') >= 2 + assert block.count('vmixCountdownSyncCommands(') >= 2 + assert '() => gameTimerState.currentMs' in block + assert '() => event.remainingMs' in block def test_penalty_rebalance_pairs_every_start_with_current_web_value(): diff --git a/tests/test_build95_countdown_explicit_start_stop_sync.py b/tests/test_build95_countdown_explicit_start_stop_sync.py index 39e72fe..eb64d98 100644 --- a/tests/test_build95_countdown_explicit_start_stop_sync.py +++ b/tests/test_build95_countdown_explicit_start_stop_sync.py @@ -27,20 +27,21 @@ def test_penalty_vmix_display_uses_only_started_or_paused_active_penalties(): def test_main_countdown_start_pause_stop_all_hard_sync_web_value(): block = _block("async function syncActiveVmixGameCountdown", "function penaltyMirrorKey") - start = block.split('if (["timer_start", "timer_restart", "timer_resume"].includes(eventName))', 1)[1].split('} else if (eventName === "timer_pause")', 1)[0] - assert start.index('Function: "SetCountdown"') < start.index('Function: "StartCountdown"') - pause = block.split('eventName === "timer_pause"', 1)[1].split('} else if (["timer_stop", "timer_finished"]', 1)[0] - assert pause.index('Function: "PauseCountdown"') < pause.index('Function: "SetCountdown"') - stop = block.split('["timer_stop", "timer_finished"].includes(eventName)', 1)[1].split('} else if (["timer_reset"', 1)[0] - assert stop.index('Function: "StopCountdown"') < stop.index('Function: "SetCountdown"') + assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "start")' in block + assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "pause")' in block + assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop")' in block + helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") + start_branch = helper.rsplit("return [", 1)[1] + assert start_branch.index('Function: "SuspendCountdown"') < start_branch.index('Function: "SetCountdown"') + assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"') def test_penalty_pause_freezes_before_reseed_and_start_reseeds_before_run(): block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource") - assert 'stopCommands.push({ Function: "PauseCountdown"' in block + assert 'stopCommands.push({ Function: "SuspendCountdown"' in block assert 'setCommands.push({ Function: "SetCountdown"' in block assert 'runCommands.push({ Function: "StartCountdown"' in block - assert "Never StartCountdown for a prepared item" in block + assert "prepared/paused penalty" in block def test_combined_shortcut_changes_web_first_then_synchronizes_vmix(): @@ -48,8 +49,9 @@ def test_combined_shortcut_changes_web_first_then_synchronizes_vmix(): web_game = block.index("if (step.start_web_game)") vmix_game = block.index("if (step.sync_vmix_game && step.game_vmix_input)") assert web_game < vmix_game - assert block.index('Function: "PauseCountdown"') < block.index('Function: "SetCountdown"', block.index('Function: "PauseCountdown"')) - assert 'Function: "StartCountdown"' in block + assert 'vmixCountdownSyncCommands(' in block + assert '() => gameTimerState.currentMs' in block + assert '() => event.remainingMs' in block def test_build95_runtime_version(): diff --git a/tests/test_build98_timer_countdown_sync_fixed_pin.py b/tests/test_build98_timer_countdown_sync_fixed_pin.py new file mode 100644 index 0000000..e715e82 --- /dev/null +++ b/tests/test_build98_timer_countdown_sync_fixed_pin.py @@ -0,0 +1,67 @@ +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +from ui_builder.auth import EditorAuthManager + +ROOT = Path(__file__).resolve().parents[1] +APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8") +APP = (ROOT / "app.py").read_text(encoding="utf-8") +AUTH = (ROOT / "ui_builder/auth.py").read_text(encoding="utf-8") + + +def _block(start: str, end: str) -> str: + return APP_JS.split(start, 1)[1].split(end, 1)[0] + + +def test_vmix_queue_resolves_countdown_values_at_actual_send_time(): + compact = _block("function compactVmixCommand", "function currentRuntimeVmixDeviceId") + assert 'typeof rawValue === "function" ? rawValue() : rawValue' in compact + sender = _block("async function sendRuntimeVmixSequence", "function splitVmixInputs") + assert 'const rawCommands = typeof commands === "function" ? commands() : commands;' in sender + assert 'const clean = (rawCommands || []).map(compactVmixCommand)' in sender + + +def test_countdown_start_is_deterministic_suspend_set_start(): + helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") + start_branch = helper.split('return [', 3)[-1] + assert start_branch.index('Function: "SuspendCountdown"') < start_branch.index('Function: "SetCountdown"') + assert start_branch.index('Function: "SetCountdown"') < start_branch.index('Function: "StartCountdown"') + assert 'Value: currentValue' in helper + + +def test_pause_uses_suspend_not_toggle_pausecountdown(): + helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext") + pause = helper.split('if (action === "pause")', 1)[1].split('return [', 1)[1].split('];', 1)[0] + assert 'Function: "SuspendCountdown"' in pause + assert 'Function: "PauseCountdown"' not in pause + assert pause.index('Function: "SuspendCountdown"') < pause.index('Function: "SetCountdown"') + + +def test_penalty_rebalance_hard_syncs_running_countdown_and_pauses_stably(): + block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource") + assert 'stopCommands.push({ Function: "SuspendCountdown"' in block + assert 'Value: () => vmixCountdownValue(entry.event.remainingMs)' in block + assert 'runCommands.push({ Function: "StartCountdown"' in block + assert 'Function: "PauseCountdown"' not in block + + +def test_running_penalty_is_preferred_over_paused_shorter_penalty(): + sorted_block = _block("function sortedPenaltyEntries", "function penaltyLocalAdvantageSide") + assert 'const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running));' in sorted_block + display = _block("function penaltyDisplayEntriesByTargetSide", "function rememberPenaltyAdvantagePlan") + assert 'const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running));' in display + + +def test_fixed_pin_1993_replaces_daily_login(tmp_path, monkeypatch): + monkeypatch.delenv("EDITOR_FIXED_PIN", raising=False) + auth = EditorAuthManager(tmp_path) + assert auth.settings["fixed_pin"] == "1993" + # The old daily algorithm remains diagnostic-only; for 24.08.2026 its + # default result is 6225, but login validation no longer uses it. + assert auth.daily_pin(datetime(2026, 8, 24, 12, 0, tzinfo=ZoneInfo("Europe/Moscow"))) == "6225" + assert 'valid = hmac.compare_digest(supplied, str(self.settings["fixed_pin"]))' in AUTH + + +def test_build98_runtime_version(): + assert 'BUILD_VERSION = "2026.08.24.1"' in APP diff --git a/tests/test_build99_prepared_title_editing.py b/tests/test_build99_prepared_title_editing.py new file mode 100644 index 0000000..e1208ef --- /dev/null +++ b/tests/test_build99_prepared_title_editing.py @@ -0,0 +1,95 @@ +import asyncio +import json +import types +from pathlib import Path +from types import SimpleNamespace + +from sqlalchemy import select + +from hockey_data.agent_bridge import VmixAgentHub +from hockey_data.auth_bridge import HockeyUser +from hockey_data.models import VmixDevice, VmixPreparedTitle +from tests.support import LocalTestDatabase + +ROOT = Path(__file__).resolve().parents[1] +APP_JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8") +BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8") +APP = (ROOT / "app.py").read_text(encoding="utf-8") + + +def test_build99_frontend_and_routes_exist(): + assert 'preparedTitleEditingId: ""' in APP_JS + assert 'data-prepared-edit=' in APP_JS + assert 'method: "PUT"' in APP_JS + assert '@router.put("/api/hockey/prepared-titles/{prepared_id}")' in BRIDGE + assert '"Function": "SetInputName"' in BRIDGE + assert 'return f"Заготовка {number}"' in BRIDGE + assert 'BUILD_VERSION = "2026.08.24.2"' in APP + + +def test_blank_name_gets_number_and_existing_clone_is_editable(tmp_path): + database = LocalTestDatabase(tmp_path / "prepared99.sqlite3") + database.create_all() + hub = VmixAgentHub(database) # type: ignore[arg-type] + user = HockeyUser(id="prep99", login="prep99", display_name="Prep99") + source = { + "key": "source-key", + "number": "12", + "title": "COMPARE", + "type": "GT", + "fields": [{"name": "Name.Text", "type": "text", "index": "0"}], + } + inventory = {"inputs": [source]} + with database.session() as session: + session.add(VmixDevice( + device_uuid="prep-device", + device_secret_hash="x" * 64, + name="Prep device", + wfl_user_id=user.id, + is_active_for_account=True, + vmix_connected=True, + current_match_external_id="9001", + project_inventory_json=json.dumps(inventory), + )) + + calls = [] + + async def fake_sequence(self, _user, commands, *, device_id="", session_token="", timeout=0, **kwargs): + calls.append(commands) + if commands and commands[0].get("Function") == "CreateVirtualInput": + cloned = {"inputs": [source, {**source, "key": "clone-key", "number": "13", "title": "COMPARE"}]} + with database.session() as session: + device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == "prep-device")) + device.project_inventory_json = json.dumps(cloned) + return {"ok": True, "device_id": device_id or "prep-device", "match_id": "9001"} + + hub.run_vmix_sequence_for_user = types.MethodType(fake_sequence, hub) + created = asyncio.run(hub.create_prepared_title(user, SimpleNamespace( + name="", + device_id="prep-device", + session_token="", + source_input_key="source-key", + source_input_number="12", + source_input_title="COMPARE", + source_kind="manual", + source_ref="", + field_values={"Name.Text": {"type": "text", "value": "Иванов"}}, + ))) + assert created["name"] == "Заготовка 1" + assert created["clone_input"]["title"] == "Заготовка 1" + assert any(c.get("Function") == "SetInputName" and c.get("Value") == "Заготовка 1" for c in calls[1]) + + updated = asyncio.run(hub.update_prepared_title(created["id"], user, SimpleNamespace( + name="Сравнение вратарей", + device_id="prep-device", + session_token="", + field_values={"Name.Text": {"type": "text", "value": "Петров"}}, + ))) + assert updated["name"] == "Сравнение вратарей" + assert updated["field_values"]["Name.Text"]["value"] == "Петров" + assert any(c.get("Function") == "SetInputName" and c.get("Value") == "Сравнение вратарей" for c in calls[-1]) + with database.session() as session: + row = session.get(VmixPreparedTitle, created["id"]) + assert row is not None + assert row.name == "Сравнение вратарей" + assert row.clone_input_title == "Сравнение вратарей" diff --git a/tests/test_shortcut_sequences.py b/tests/test_shortcut_sequences.py index 6d20ccb..020499f 100644 --- a/tests/test_shortcut_sequences.py +++ b/tests/test_shortcut_sequences.py @@ -106,7 +106,7 @@ def test_shortcut_editor_layout_fixes_add_step_buttons_and_fullscreen_modal() -> def test_hockey_timer_shortcut_supports_native_countdown_and_legacy_text_mirror() -> None: app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") - assert 'Function: "PauseCountdown"' in app_js + assert 'Function: "SuspendCountdown"' in app_js assert 'Function: "StopCountdown"' in app_js assert 'hockey_timer_command' in app_js assert 'game_vmix_mode' in app_js @@ -183,8 +183,11 @@ def test_hockey_timer_targets_persist_selected_names_and_finish_actions(tmp_path def test_hockey_countdown_uses_selected_name_for_game_and_penalty_targets() -> None: app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") - assert 'Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name' in app_js - assert 'Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name' in app_js + assert 'function vmixCountdownSyncCommands(input, selectedName' in app_js + assert 'SelectedName: String(selectedName || "").trim()' in app_js + assert 'step.game_vmix_selected_name, () => gameTimerState.currentMs' in app_js + assert 'target.input, target.selected_name, () => event.remainingMs' in app_js + assert 'Input: target.input, SelectedName: target.selected_name' in app_js assert 'vmixTextSelectedNameOptions' in app_js assert 'data-penalty-target-field="selected_name"' in app_js assert 'data-finish-action-add' in app_js @@ -249,7 +252,7 @@ def test_build90_native_countdown_avoids_per_second_transport() -> None: app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") assert 'game_vmix_mode !== "countdown"' in app_js assert 'const countdownMode = step.penalty_vmix_mode === "countdown"' in app_js - assert 'Function: "PauseCountdown"' in app_js + assert 'Function: "SuspendCountdown"' in app_js assert 'Function: "StopCountdown"' in app_js assert 'signal: controller.signal' in app_js assert 'vmixCommandQueue: Promise.resolve()' in app_js diff --git a/ui_builder/auth.py b/ui_builder/auth.py index 9c77585..b13e0d1 100644 --- a/ui_builder/auth.py +++ b/ui_builder/auth.py @@ -18,6 +18,9 @@ from pydantic import BaseModel, Field DEFAULT_SECURITY: dict[str, Any] = { + # BUILD98: editor access is a fixed local PIN. The older daily PIN and + # master PIN fields are retained only so existing config files still parse. + "fixed_pin": "1993", "daily_mask": "4827", "master_pin": "638194", "timezone": "Europe/Moscow", @@ -43,11 +46,9 @@ class FailureState: class EditorAuthManager: """Local PIN guard for the visual editor. - Daily PIN: - digits(DDMM) + digits(mask), each result modulo 10. - - Example: - 1607 + 4827 = 5424 + BUILD98 uses one fixed PIN (1993 by default). The old daily-PIN helpers are + kept for backward-compatible diagnostics only and are no longer accepted + by the login endpoint. """ def __init__(self, settings_dir: Path) -> None: @@ -76,6 +77,7 @@ class EditorAuthManager: result.update(raw) overrides = { + "fixed_pin": os.getenv("EDITOR_FIXED_PIN"), "daily_mask": os.getenv("EDITOR_PIN_MASK"), "master_pin": os.getenv("EDITOR_MASTER_PIN"), "timezone": os.getenv("EDITOR_PIN_TIMEZONE"), @@ -93,6 +95,9 @@ class EditorAuthManager: "1", "true", "yes", "on" } + fixed = "".join(character for character in str(result.get("fixed_pin", "1993")) if character.isdigit()) + result["fixed_pin"] = fixed if 4 <= len(fixed) <= 12 else "1993" + mask = "".join(character for character in str(result["daily_mask"]) if character.isdigit()) result["daily_mask"] = mask[:4].ljust(4, "0") if mask else "4827" @@ -217,10 +222,7 @@ class EditorAuthManager: }, ) - valid = ( - hmac.compare_digest(supplied, self.daily_pin()) - or hmac.compare_digest(supplied, str(self.settings["master_pin"])) - ) + valid = hmac.compare_digest(supplied, str(self.settings["fixed_pin"])) if not valid: failure.attempts += 1 remaining = max( diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index ec269e7..150777c 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -120,6 +120,7 @@ preparedTitleSourceKey: "", preparedTitleFieldValues: {}, preparedTitleName: "", + preparedTitleEditingId: "", preparedTitlePanelId: "", preparedTitleMappingSources: [], preparedTitleSnapshotLoading: false, @@ -4134,6 +4135,36 @@ function startCustomTooltips() { return `${padTimer(hours)}:${padTimer(minutes)}:${padTimer(seconds)}`; } + function vmixCountdownSyncCommands(input, selectedName, millisecondsProvider, action = "start") { + const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() }; + const currentValue = () => { + const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider; + return vmixCountdownValue(milliseconds); + }; + if (!target.Input || !target.SelectedName) return []; + if (action === "stop") { + return [ + { Function: "StopCountdown", ...target }, + { Function: "SetCountdown", ...target, Value: currentValue }, + ]; + } + if (action === "pause") { + // vMix PauseCountdown is a toggle (pause/resume). SuspendCountdown is the + // deterministic pause-only command, so it cannot accidentally resume a timer. + return [ + { Function: "SuspendCountdown", ...target }, + { Function: "SetCountdown", ...target, Value: currentValue }, + ]; + } + return [ + // Hard-sync every launch: freeze any stale title countdown first, sample + // Runtime at actual send time, then start from exactly that value. + { Function: "SuspendCountdown", ...target }, + { Function: "SetCountdown", ...target, Value: currentValue }, + { Function: "StartCountdown", ...target }, + ]; + } + function buildShortcutRuntimeContext(sequence = null) { const timers = {}; state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => { @@ -4213,8 +4244,13 @@ function startCustomTooltips() { } function compactVmixCommand(command) { + // BUILD98: countdown values may be supplied as functions. They are resolved + // only when the command actually reaches the front of the browser vMix queue, + // so a delayed Agent/ACK cannot make vMix start several seconds behind Runtime. + const source = typeof command === "function" ? command() : command; const result = {}; - Object.entries(command || {}).forEach(([key, value]) => { + Object.entries(source || {}).forEach(([key, rawValue]) => { + const value = typeof rawValue === "function" ? rawValue() : rawValue; if (value === null || value === undefined) return; if (key !== "Value" && String(value) === "") return; result[key] = value; @@ -4522,10 +4558,13 @@ function startCustomTooltips() { } async function sendRuntimeVmixSequence(commands, execution = null) { - const clean = (commands || []).map(compactVmixCommand).filter((command) => command.Function); - if (!clean.length) return { ok: true, applied: 0, results: [] }; - + // Do not resolve dynamic command values here. A sequence can sit behind a + // previous Agent request for a few seconds; countdowns must be sampled at + // the instant this queued request is really sent. const run = async () => { + const rawCommands = typeof commands === "function" ? commands() : commands; + const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function); + if (!clean.length) return { ok: true, applied: 0, results: [] }; state.vmixCommandQueueDepth += 1; const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), 7000); @@ -4621,26 +4660,17 @@ function startCustomTooltips() { const input = String(step.game_vmix_input || "").trim(); const selectedName = String(step.game_vmix_selected_name || "").trim(); if (!input || !selectedName) continue; - const target = { Input: input, SelectedName: selectedName }; + const valueProvider = () => timerState.currentMs; if (["timer_start", "timer_restart", "timer_resume"].includes(eventName)) { - // BUILD95: every launch/resume is a hard Runtime -> vMix sync. - commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); - commands.push({ Function: "StartCountdown", ...target }); + commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "start")); } else if (eventName === "timer_pause") { - // Freeze vMix first, then overwrite it with the exact web value. - commands.push({ Function: "PauseCountdown", ...target }); - commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); + commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "pause")); } else if (["timer_stop", "timer_finished"].includes(eventName)) { - commands.push({ Function: "StopCountdown", ...target }); - commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); + commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop")); } else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) { - if (timerState.running) { - commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); - commands.push({ Function: "StartCountdown", ...target }); - } else { - commands.push({ Function: "PauseCountdown", ...target }); - commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); - } + commands.push(...vmixCountdownSyncCommands( + input, selectedName, valueProvider, timerState.running ? "start" : "pause" + )); } } if (!commands.length) return false; @@ -4648,7 +4678,6 @@ function startCustomTooltips() { return true; } - function penaltyMirrorKey(component, event) { return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`; } @@ -4713,8 +4742,15 @@ function startCustomTooltips() { function sortedPenaltyEntries(side = "") { return activeHockeyPenaltyEntries() .filter((item) => !side || item.side === side) - .sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) - || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); + .sort((a, b) => { + // BUILD98: a paused penalty cannot be the next real strength transition + // while another active penalty is actually running. Prefer running + // clocks; only fall back to paused ones when every active clock is paused. + const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running)); + if (runningOrder) return runningOrder; + return Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) + || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0); + }); } // BUILD89: the scorebug has only ONE penalty/power-play plate at a time. @@ -4758,8 +4794,12 @@ function startCustomTooltips() { function penaltyDisplayEntriesByTargetSide(step) { const home = sortedPenaltyEntries("home"); const away = sortedPenaltyEntries("away"); - const all = [...home, ...away].sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) - || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); + const all = [...home, ...away].sort((a, b) => { + const runningOrder = Number(Boolean(b.event?.running)) - Number(Boolean(a.event?.running)); + if (runningOrder) return runningOrder; + return Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) + || Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0); + }); if (!all.length) { return { home: [], away: [], routedToAdvantage: false, advantageSide: "", plateSide: "", holdingEqualStrength: false, transitionEntry: null }; } @@ -4946,20 +4986,21 @@ function startCustomTooltips() { }); const startingCountdown = Boolean(entry.event.running) && (force || assignmentChanged || previous?.running !== true); - // BUILD93 invariant: whenever StartCountdown is emitted, SetCountdown with - // the current web value is emitted immediately before it. if (entry.event.running) { if (force || assignmentChanged || startingCountdown) { - setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) }); + // BUILD98: deterministic hard sync. Suspend is pause-only in vMix; + // PauseCountdown is a toggle and could accidentally resume a stale timer. + stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name }); + setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) }); } if (startingCountdown) { runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); } } else if (force || assignmentChanged || previous?.running !== false) { - // BUILD95: on every pause/stop, freeze first and then seed the - // exact Runtime value. Never StartCountdown for a prepared item. - stopCommands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name }); - setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) }); + // Freeze the title and seed the exact Runtime value, but never run a + // prepared/paused penalty until the web event itself is running. + stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name }); + setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) }); } } else { assignedMirrorKeys.add(eventKey); @@ -5147,12 +5188,13 @@ function startCustomTooltips() { setVmixTimerMirror(step.game_timer_action_id || "hockey_game_timer", step.game_vmix_input, step.game_vmix_selected_name); commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) }); } else if (pausing) { - commands.push({ Function: "PauseCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name }); - commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) }); + commands.push(...vmixCountdownSyncCommands( + step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "pause" + )); } else { - // BUILD95: every start/resume is SetCountdown -> StartCountdown. - commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) }); - commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name }); + commands.push(...vmixCountdownSyncCommands( + step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "start" + )); } } @@ -5176,17 +5218,14 @@ function startCustomTooltips() { }); commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) }); } else { + const eventRunning = Boolean(event.running); state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), { - eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "countdown", running: !pausing, + eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "countdown", running: eventRunning, }); - if (pausing) { - commands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name }); - commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) }); - } else { - // BUILD95: every penalty launch/resume is re-seeded from the web timer first. - commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) }); - commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); - } + commands.push(...vmixCountdownSyncCommands( + target.input, target.selected_name, () => event.remainingMs, + (pausing || !eventRunning) ? "pause" : "start" + )); } }); }); @@ -9337,9 +9376,10 @@ async function hockeyLoadPreparedTitlesWorkspace({ force = false, render = true function hockeySelectPreparedSource(input, { clearPanel = false } = {}) { if (!input) return false; + state.preparedTitleEditingId = ""; state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input); hockeyPreparedFieldValuesForInput(input, { preserve: false }); - if (!state.preparedTitleName) state.preparedTitleName = `${String(input.title || `Input ${input.number || ""}`).trim()} · заготовка`; + state.preparedTitleName = ""; if (clearPanel) { state.preparedTitlePanelId = ""; state.preparedTitleMappingSources = []; @@ -9394,7 +9434,7 @@ async function hockeyCreatePreparedTitle() { toast("Сначала выберите исходный vMix Input", true); return false; } - const name = String(state.preparedTitleName || "").trim() || `${input.title || `Input ${input.number || ""}`} · заготовка`; + const name = String(state.preparedTitleName || "").trim(); const fieldValues = {}; Object.entries(state.preparedTitleFieldValues || {}).forEach(([fieldName, entry]) => { if (!entry?.touched) return; @@ -9423,6 +9463,7 @@ async function hockeyCreatePreparedTitle() { }); toast(`Заготовка сохранена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`); state.preparedTitleName = ""; + state.preparedTitleEditingId = ""; state.preparedTitlePanelId = ""; state.preparedTitleMappingSources = []; state.preparedTitlesLoadedKey = ""; @@ -9437,6 +9478,90 @@ async function hockeyCreatePreparedTitle() { } } +function hockeyPreparedInventoryMatch(ref = {}) { + const inputs = hockeyPreparedInventoryInputs(); + return inputs.find((item) => + (ref?.key && String(item.key || "") === String(ref.key)) || + (ref?.number && String(item.number || "") === String(ref.number)) || + (ref?.title && String(item.title || "") === String(ref.title)) + ) || null; +} + +function hockeyBeginEditPreparedTitle(id) { + const item = state.preparedTitles.find((entry) => String(entry?.id) === String(id)); + if (!item) return false; + const input = hockeyPreparedInventoryMatch(item.clone_input) || hockeyPreparedInventoryMatch(item.source_input); + state.preparedTitleEditingId = String(item.id || ""); + state.preparedTitleName = String(item.name || ""); + state.preparedTitlePanelId = ""; + state.preparedTitleMappingSources = []; + state.preparedTitleSourceKey = input ? hockeyPreparedSourceIdentity(input) : ""; + state.preparedTitleFieldValues = {}; + if (input) hockeyPreparedFieldValuesForInput(input, { preserve: false }); + Object.entries(item.field_values || {}).forEach(([name, raw]) => { + if (!name) return; + const entry = raw && typeof raw === "object" ? raw : { value: raw, type: "text" }; + const currentType = state.preparedTitleFieldValues?.[name]?.type || String(entry.type || "text"); + state.preparedTitleFieldValues[name] = { + type: currentType, + value: String(entry.value ?? ""), + touched: true, + }; + }); + renderRuntime(); + return true; +} + +function hockeyCancelPreparedEdit() { + state.preparedTitleEditingId = ""; + state.preparedTitleName = ""; + state.preparedTitleSourceKey = ""; + state.preparedTitleFieldValues = {}; + renderRuntime(); + return true; +} + +async function hockeyUpdatePreparedTitle() { + const id = String(state.preparedTitleEditingId || "").trim(); + if (!id) return false; + const fieldValues = {}; + Object.entries(state.preparedTitleFieldValues || {}).forEach(([fieldName, entry]) => { + if (!entry?.touched) return; + fieldValues[fieldName] = { + type: String(entry?.type || "text"), + value: String(entry?.value ?? ""), + }; + }); + state.preparedTitlesLoading = true; + renderRuntime(); + try { + const payload = await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: String(state.preparedTitleName || "").trim(), + device_id: currentRuntimeVmixDeviceId(), + session_token: currentRuntimeHockeySessionToken(), + field_values: fieldValues, + }), + }); + toast(`Заготовка обновлена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`); + state.preparedTitleEditingId = ""; + state.preparedTitleName = ""; + state.preparedTitleSourceKey = ""; + state.preparedTitleFieldValues = {}; + state.preparedTitlesLoadedKey = ""; + await hockeyLoadPreparedTitlesWorkspace({ force: true, render: false }); + return true; + } catch (error) { + toast(`Не удалось обновить заготовку: ${error.message}`, true); + return false; + } finally { + state.preparedTitlesLoading = false; + renderRuntime(); + } +} + async function hockeyPreviewPreparedTitle(id) { try { await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}/preview`, { @@ -9457,6 +9582,7 @@ async function hockeyDeletePreparedTitle(id) { try { await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" }); state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id)); + if (String(state.preparedTitleEditingId || "") === String(id)) hockeyCancelPreparedEdit(); renderRuntime(); return true; } catch (error) { @@ -9467,8 +9593,9 @@ async function hockeyDeletePreparedTitle(id) { function hockeyOpenPreparedFromPlayerPanel(panel) { if (!panel) return false; + state.preparedTitleEditingId = ""; state.preparedTitlePanelId = String(panel.id || ""); - state.preparedTitleName = `${String(panel.label || "Выбор игроков")} · заготовка`; + state.preparedTitleName = ""; state.preparedTitleSourceKey = ""; state.preparedTitleFieldValues = {}; state.preparedTitleMappingSources = []; @@ -9491,6 +9618,8 @@ function renderHockeyPreparedTitlesWorkspace() { : []; const panel = state.preparedTitlePanelId ? hockeyPlayerSelectionPanels().find((item) => item.id === state.preparedTitlePanelId) : null; const mappingSources = Array.isArray(state.preparedTitleMappingSources) ? state.preparedTitleMappingSources : []; + const editingId = String(state.preparedTitleEditingId || "").trim(); + const editingItem = editingId ? state.preparedTitles.find((item) => String(item?.id) === editingId) : null; root.innerHTML = `
@@ -9510,8 +9639,8 @@ function renderHockeyPreparedTitlesWorkspace() {
${selected ? ` -
ИСТОЧНИК#${escapeHtml(selected.number || "—")} · ${escapeHtml(selected.title || "Input")}${escapeHtml(selected.type || "")} · элементов ${fields.length}
${panel ? `` : ``}
- +
${editingItem ? "РЕДАКТИРОВАНИЕ" : "ИСТОЧНИК"}#${escapeHtml(selected.number || "—")} · ${escapeHtml(selected.title || "Input")}${editingItem ? `Заготовка #${escapeHtml(editingItem.id)} · изменяется существующий Input` : `${escapeHtml(selected.type || "")} · элементов ${fields.length}`}
${editingItem ? `` : panel ? `` : ``}
+
${fields.map((field) => { const name = String(field?.name || ""); const type = hockeyPreparedFieldType(field); @@ -9519,12 +9648,12 @@ function renderHockeyPreparedTitlesWorkspace() { const color = /^#[0-9A-Fa-f]{6}$/.test(value) ? value : "#ffffff"; return ``; }).join("") || `
У Input нет доступных элементов
`}
-
При сохранении vMix создаст виртуальную копию этого Input в конце проекта и заполнит её текущими значениями.
+
${editingItem ? "Изменения применятся к уже созданному vMix Input: название и отредактированные поля обновятся без создания новой копии." : "Создаётся виртуальная копия, Input получает имя заготовки. vMix API не даёт назначить/создать категорию программно, поэтому копия остаётся в конце проекта."}
` : `
Выберите vMix InputСправа появятся все его .Text / .Source / .Color элементы для ручной заготовки.
`}
`; @@ -9562,6 +9691,7 @@ function renderHockeyPreparedTitlesWorkspace() { renderRuntime(); }); root.querySelector("[data-prepared-apply-mapping]")?.addEventListener("click", () => hockeyApplyPreparedMappingSnapshot()); + root.querySelector("[data-prepared-cancel-edit]")?.addEventListener("click", () => hockeyCancelPreparedEdit()); root.querySelector("[data-prepared-name]")?.addEventListener("input", (event) => { state.preparedTitleName = event.target.value || ""; }); root.querySelectorAll("[data-prepared-field]").forEach((inputNode) => inputNode.addEventListener("input", () => { const name = inputNode.dataset.preparedField || ""; @@ -9579,6 +9709,8 @@ function renderHockeyPreparedTitlesWorkspace() { state.preparedTitleFieldValues[name] = { type: "color", value: picker.value, touched: true }; })); root.querySelector("[data-prepared-save]")?.addEventListener("click", () => hockeyCreatePreparedTitle()); + root.querySelector("[data-prepared-update]")?.addEventListener("click", () => hockeyUpdatePreparedTitle()); + root.querySelectorAll("[data-prepared-edit]").forEach((button) => button.addEventListener("click", () => hockeyBeginEditPreparedTitle(button.dataset.preparedEdit))); root.querySelectorAll("[data-prepared-preview]").forEach((button) => button.addEventListener("click", () => hockeyPreviewPreparedTitle(button.dataset.preparedPreview))); root.querySelectorAll("[data-prepared-delete]").forEach((button) => button.addEventListener("click", () => hockeyDeletePreparedTitle(button.dataset.preparedDelete))); return root; @@ -12935,7 +13067,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
Защищённый режим конструктора -

Введите ежедневный четырёхзначный PIN или аварийный мастер-PIN.

+

Введите PIN доступа к конструктору.