1. поправлено работа с таймерами в vMix

2. поправлено немного Заготовки
This commit is contained in:
2026-08-24 12:44:45 +03:00
parent 17c3fcfbac
commit e09a1eeff5
11 changed files with 641 additions and 135 deletions

View File

@@ -1,22 +1,24 @@
# Защита конструктора # Защита конструктора
## Формула ежедневного PIN ## PIN доступа
Начиная с BUILD98 используется один фиксированный PIN конструктора:
```text ```text
PIN[i] = (DDMM[i] + MASK[i]) mod 10 1993
``` ```
Маска находится в: Его можно переопределить переменной окружения:
```text ```text
settings/editor_security.json EDITOR_FIXED_PIN
``` ```
Или может быть переопределена переменными окружения: Старые поля `daily_mask` и `master_pin` в `settings/editor_security.json` сохраняются только для совместимости со старыми конфигурациями и больше не участвуют в проверке входа.
Дополнительные настройки сессии:
```text ```text
EDITOR_PIN_MASK
EDITOR_MASTER_PIN
EDITOR_PIN_TIMEZONE EDITOR_PIN_TIMEZONE
EDITOR_SESSION_MINUTES EDITOR_SESSION_MINUTES
EDITOR_MAX_ATTEMPTS EDITOR_MAX_ATTEMPTS
@@ -27,7 +29,7 @@ EDITOR_LOCK_SECONDS
1. Оператор открывает `/`. 1. Оператор открывает `/`.
2. Нажимает `Ctrl+Shift+E`. 2. Нажимает `Ctrl+Shift+E`.
3. Вводит ежедневный PIN. 3. Вводит PIN `1993`.
4. Редактирует черновик. 4. Редактирует черновик.
5. Проверяет через «Предпросмотр». 5. Проверяет через «Предпросмотр».
6. Нажимает «Опубликовать». 6. Нажимает «Опубликовать».

4
app.py
View File

@@ -29,7 +29,9 @@ from ui_builder import install_ui_builder
from khl_site.khl_data_center import APP as khl_site_app from khl_site.khl_data_center import APP as khl_site_app
BASE_DIR = Path(__file__).resolve().parent 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.17"
# compatibility: BUILD_VERSION = "2026.08.20.16" # compatibility: BUILD_VERSION = "2026.08.20.16"
# compatibility: BUILD_VERSION = "2026.08.20.15" # compatibility: BUILD_VERSION = "2026.08.20.15"

View File

@@ -2308,6 +2308,96 @@ class VmixAgentHub:
"updated_at": row.updated_at.isoformat() if row.updated_at else "", "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]: async def list_prepared_titles(self, user: HockeyUser, *, device_id: str = "", game_id: str = "") -> dict[str, Any]:
with self.database.session() as session: with self.database.session() as session:
device = self._prepared_device_for_user(session, user, device_id) device = self._prepared_device_for_user(session, user, device_id)
@@ -2330,7 +2420,12 @@ class VmixAgentHub:
if found is not None: if found is not None:
row.clone_input_key = str(found.get("key") or row.clone_input_key or "")[:128] 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_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] items = [self._prepared_title_payload(row, inventory) for row in rows]
return { return {
"device_id": device.device_uuid, "device_id": device.device_uuid,
@@ -2463,6 +2558,7 @@ class VmixAgentHub:
session_token = str(getattr(payload, "session_token", "") or "").strip() session_token = str(getattr(payload, "session_token", "") or "").strip()
with self.database.session() as session: with self.database.session() as session:
device = self._prepared_device_for_user(session, user, requested_device) 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) inventory = self._prepared_inventory_from_row(device)
source = self._prepared_find_input( source = self._prepared_find_input(
inventory, inventory,
@@ -2490,9 +2586,22 @@ class VmixAgentHub:
timeout=6.0, timeout=6.0,
) )
target_device_id = str(create_result.get("device_id") or device.device_uuid) 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 clone: dict[str, Any] | None = None
current_inputs: list[dict[str, Any]] = []
for _ in range(12): for _ in range(12):
await asyncio.sleep(0.18) await asyncio.sleep(0.18)
with self.database.session() as session: with self.database.session() as session:
@@ -2505,50 +2614,43 @@ class VmixAgentHub:
break break
clone_key = str(clone.get("key") or "") if clone else "" 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_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 str(clone.get("title") or source_title if clone else source_title)
clone_ref = clone_key or clone_number or clone_title
raw_values = getattr(payload, "field_values", {}) 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 [] 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 "")} saved_fields, field_commands = self._prepared_field_update_commands(
commands: list[dict[str, Any]] = [] input_ref=clone_ref,
saved_fields: dict[str, dict[str, str]] = {} available_fields=source_fields,
for name, field in list(allowed.items())[:240]: raw_values=raw_values,
if name not in raw_values: )
continue
raw_entry = raw_values.get(name) # BUILD99: the prepared title name is also the actual vMix Input display
if isinstance(raw_entry, dict): # name. vMix exposes SetInputName through the Shortcut/API surface.
value = "" if raw_entry.get("value") is None else str(raw_entry.get("value")) commands: list[dict[str, Any]] = [
requested_type = str(raw_entry.get("type") or "") {"Function": "SetInputName", "Input": clone_ref, "Value": title_name},
else: *field_commands,
value = "" if raw_entry is None else str(raw_entry) ]
requested_type = ""
field_type = str(field.get("type") or requested_type or "text").lower() # vMix supports selecting categories via API, but does not expose a
if field_type not in {"text", "image", "source", "color", "colour"}: # supported shortcut to create/label a custom category or assign an
field_type = requested_type.lower() if requested_type.lower() in {"text", "image", "source", "color", "colour"} else "text" # Input to it. Keep the safe fallback requested by the operator: the
saved_fields[name] = {"value": value[:8000], "type": field_type} # prepared title stays at the end of the project. CreateVirtualInput
# Empty text is meaningful (clear inherited content). Empty image/color is # normally appends already; MoveInput makes that deterministic if the
# treated as "leave inherited value" because vMix rejects some empty sources. # project reordered while the clone was being discovered.
if not value and field_type in {"image", "source", "color", "colour"}: current_numbers = [int(str(item.get("number") or "")) for item in current_inputs if str(item.get("number") or "").isdigit()]
continue end_position = max(current_numbers) if current_numbers else 0
commands.append({ if end_position > 0 and clone_number.isdigit() and int(clone_number) != end_position:
"Function": _mapping_value_function(field_type), commands.append({"Function": "MoveInput", "Input": clone_ref, "Value": str(end_position)})
"Input": clone_ref, clone_number = str(end_position)
"SelectedName": name,
"Value": value, set_result = await self.run_vmix_sequence_for_user(
}) user,
set_result = None commands,
if commands: device_id=target_device_id,
set_result = await self.run_vmix_sequence_for_user( session_token=session_token,
user, timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)),
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_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] source_ref_name = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(getattr(payload, "source_ref", "") or "").strip())[:128]
with self.database.session() as session: with self.database.session() as session:
@@ -2564,7 +2666,7 @@ class VmixAgentHub:
source_input_title=source_title, source_input_title=source_title,
clone_input_key=clone_key, clone_input_key=clone_key,
clone_input_number=clone_number, 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=(",", ":")), field_values_json=json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")),
created_by=user.id, created_by=user.id,
created_at=_utcnow(), created_at=_utcnow(),
@@ -2575,7 +2677,88 @@ class VmixAgentHub:
result = self._prepared_title_payload(row) result = self._prepared_title_payload(row)
result["ok"] = True result["ok"] = True
result["create_result"] = create_result 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 return result
async def preview_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]: 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) 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): class PreparedTitlePreviewPayload(BaseModel):
device_id: str = Field(default="", max_length=128) device_id: str = Field(default="", max_length=128)
session_token: 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]: ) -> dict[str, Any]:
return await hub.create_prepared_title(user, payload) 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") @router.post("/api/hockey/prepared-titles/{prepared_id}/preview")
async def prepared_title_preview( async def prepared_title_preview(
prepared_id: int, prepared_id: int,

View File

@@ -8,9 +8,10 @@ APP = (ROOT / "app.py").read_text(encoding="utf-8")
def test_game_countdown_resume_is_reseeded_before_start(): def test_game_countdown_resume_is_reseeded_before_start():
block = APP_JS.split("async function syncActiveVmixGameCountdown", 1)[1].split("function penaltyMirrorKey", 1)[0] 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 assert '["timer_start", "timer_restart", "timer_resume"].includes(eventName)' in block
set_pos = block.index('Function: "SetCountdown"') assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "start")' in block
start_pos = block.index('Function: "StartCountdown"') helper = APP_JS.split("function vmixCountdownSyncCommands", 1)[1].split("function buildShortcutRuntimeContext", 1)[0]
assert set_pos < start_pos 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 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] 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. # Resume must no longer have a StartCountdown-only branch.
assert '} else if (action === "resume") {' not in block assert '} else if (action === "resume") {' not in block
assert block.count('Function: "SetCountdown"') >= 2 assert block.count('vmixCountdownSyncCommands(') >= 2
assert block.count('Function: "StartCountdown"') >= 2 assert '() => gameTimerState.currentMs' in block
assert '() => event.remainingMs' in block
def test_penalty_rebalance_pairs_every_start_with_current_web_value(): def test_penalty_rebalance_pairs_every_start_with_current_web_value():

View File

@@ -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(): def test_main_countdown_start_pause_stop_all_hard_sync_web_value():
block = _block("async function syncActiveVmixGameCountdown", "function penaltyMirrorKey") 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 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "start")' in block
assert start.index('Function: "SetCountdown"') < start.index('Function: "StartCountdown"') assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "pause")' in block
pause = block.split('eventName === "timer_pause"', 1)[1].split('} else if (["timer_stop", "timer_finished"]', 1)[0] assert 'vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop")' in block
assert pause.index('Function: "PauseCountdown"') < pause.index('Function: "SetCountdown"') helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
stop = block.split('["timer_stop", "timer_finished"].includes(eventName)', 1)[1].split('} else if (["timer_reset"', 1)[0] start_branch = helper.rsplit("return [", 1)[1]
assert stop.index('Function: "StopCountdown"') < stop.index('Function: "SetCountdown"') 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(): def test_penalty_pause_freezes_before_reseed_and_start_reseeds_before_run():
block = _block("async function rebalanceVmixPenaltyTargets", "function finishActionMatchesSource") 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 'setCommands.push({ Function: "SetCountdown"' in block
assert 'runCommands.push({ Function: "StartCountdown"' 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(): 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)") web_game = block.index("if (step.start_web_game)")
vmix_game = block.index("if (step.sync_vmix_game && step.game_vmix_input)") vmix_game = block.index("if (step.sync_vmix_game && step.game_vmix_input)")
assert web_game < vmix_game assert web_game < vmix_game
assert block.index('Function: "PauseCountdown"') < block.index('Function: "SetCountdown"', block.index('Function: "PauseCountdown"')) assert 'vmixCountdownSyncCommands(' in block
assert 'Function: "StartCountdown"' in block assert '() => gameTimerState.currentMs' in block
assert '() => event.remainingMs' in block
def test_build95_runtime_version(): def test_build95_runtime_version():

View File

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

View File

@@ -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 == "Сравнение вратарей"

View File

@@ -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: 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") 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 'Function: "StopCountdown"' in app_js
assert 'hockey_timer_command' in app_js assert 'hockey_timer_command' in app_js
assert 'game_vmix_mode' 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: 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") 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 vmixCountdownSyncCommands(input, selectedName' in app_js
assert 'Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name' 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 'vmixTextSelectedNameOptions' in app_js
assert 'data-penalty-target-field="selected_name"' in app_js assert 'data-penalty-target-field="selected_name"' in app_js
assert 'data-finish-action-add' 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") app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
assert 'game_vmix_mode !== "countdown"' in app_js assert 'game_vmix_mode !== "countdown"' in app_js
assert 'const countdownMode = step.penalty_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 'Function: "StopCountdown"' in app_js
assert 'signal: controller.signal' in app_js assert 'signal: controller.signal' in app_js
assert 'vmixCommandQueue: Promise.resolve()' in app_js assert 'vmixCommandQueue: Promise.resolve()' in app_js

View File

@@ -18,6 +18,9 @@ from pydantic import BaseModel, Field
DEFAULT_SECURITY: dict[str, Any] = { 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", "daily_mask": "4827",
"master_pin": "638194", "master_pin": "638194",
"timezone": "Europe/Moscow", "timezone": "Europe/Moscow",
@@ -43,11 +46,9 @@ class FailureState:
class EditorAuthManager: class EditorAuthManager:
"""Local PIN guard for the visual editor. """Local PIN guard for the visual editor.
Daily PIN: BUILD98 uses one fixed PIN (1993 by default). The old daily-PIN helpers are
digits(DDMM) + digits(mask), each result modulo 10. kept for backward-compatible diagnostics only and are no longer accepted
by the login endpoint.
Example:
1607 + 4827 = 5424
""" """
def __init__(self, settings_dir: Path) -> None: def __init__(self, settings_dir: Path) -> None:
@@ -76,6 +77,7 @@ class EditorAuthManager:
result.update(raw) result.update(raw)
overrides = { overrides = {
"fixed_pin": os.getenv("EDITOR_FIXED_PIN"),
"daily_mask": os.getenv("EDITOR_PIN_MASK"), "daily_mask": os.getenv("EDITOR_PIN_MASK"),
"master_pin": os.getenv("EDITOR_MASTER_PIN"), "master_pin": os.getenv("EDITOR_MASTER_PIN"),
"timezone": os.getenv("EDITOR_PIN_TIMEZONE"), "timezone": os.getenv("EDITOR_PIN_TIMEZONE"),
@@ -93,6 +95,9 @@ class EditorAuthManager:
"1", "true", "yes", "on" "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()) 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" result["daily_mask"] = mask[:4].ljust(4, "0") if mask else "4827"
@@ -217,10 +222,7 @@ class EditorAuthManager:
}, },
) )
valid = ( valid = hmac.compare_digest(supplied, str(self.settings["fixed_pin"]))
hmac.compare_digest(supplied, self.daily_pin())
or hmac.compare_digest(supplied, str(self.settings["master_pin"]))
)
if not valid: if not valid:
failure.attempts += 1 failure.attempts += 1
remaining = max( remaining = max(

View File

@@ -120,6 +120,7 @@
preparedTitleSourceKey: "", preparedTitleSourceKey: "",
preparedTitleFieldValues: {}, preparedTitleFieldValues: {},
preparedTitleName: "", preparedTitleName: "",
preparedTitleEditingId: "",
preparedTitlePanelId: "", preparedTitlePanelId: "",
preparedTitleMappingSources: [], preparedTitleMappingSources: [],
preparedTitleSnapshotLoading: false, preparedTitleSnapshotLoading: false,
@@ -4134,6 +4135,36 @@ function startCustomTooltips() {
return `${padTimer(hours)}:${padTimer(minutes)}:${padTimer(seconds)}`; 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) { function buildShortcutRuntimeContext(sequence = null) {
const timers = {}; const timers = {};
state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => { state.config.components.filter((component) => isTimerComponent(component)).forEach((component) => {
@@ -4213,8 +4244,13 @@ function startCustomTooltips() {
} }
function compactVmixCommand(command) { 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 = {}; 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 (value === null || value === undefined) return;
if (key !== "Value" && String(value) === "") return; if (key !== "Value" && String(value) === "") return;
result[key] = value; result[key] = value;
@@ -4522,10 +4558,13 @@ function startCustomTooltips() {
} }
async function sendRuntimeVmixSequence(commands, execution = null) { async function sendRuntimeVmixSequence(commands, execution = null) {
const clean = (commands || []).map(compactVmixCommand).filter((command) => command.Function); // Do not resolve dynamic command values here. A sequence can sit behind a
if (!clean.length) return { ok: true, applied: 0, results: [] }; // previous Agent request for a few seconds; countdowns must be sampled at
// the instant this queued request is really sent.
const run = async () => { 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; state.vmixCommandQueueDepth += 1;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 7000); const timeoutId = window.setTimeout(() => controller.abort(), 7000);
@@ -4621,26 +4660,17 @@ function startCustomTooltips() {
const input = String(step.game_vmix_input || "").trim(); const input = String(step.game_vmix_input || "").trim();
const selectedName = String(step.game_vmix_selected_name || "").trim(); const selectedName = String(step.game_vmix_selected_name || "").trim();
if (!input || !selectedName) continue; if (!input || !selectedName) continue;
const target = { Input: input, SelectedName: selectedName }; const valueProvider = () => timerState.currentMs;
if (["timer_start", "timer_restart", "timer_resume"].includes(eventName)) { if (["timer_start", "timer_restart", "timer_resume"].includes(eventName)) {
// BUILD95: every launch/resume is a hard Runtime -> vMix sync. commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "start"));
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
commands.push({ Function: "StartCountdown", ...target });
} else if (eventName === "timer_pause") { } else if (eventName === "timer_pause") {
// Freeze vMix first, then overwrite it with the exact web value. commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "pause"));
commands.push({ Function: "PauseCountdown", ...target });
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
} else if (["timer_stop", "timer_finished"].includes(eventName)) { } else if (["timer_stop", "timer_finished"].includes(eventName)) {
commands.push({ Function: "StopCountdown", ...target }); commands.push(...vmixCountdownSyncCommands(input, selectedName, valueProvider, "stop"));
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
} else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) { } else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) {
if (timerState.running) { commands.push(...vmixCountdownSyncCommands(
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) }); input, selectedName, valueProvider, timerState.running ? "start" : "pause"
commands.push({ Function: "StartCountdown", ...target }); ));
} else {
commands.push({ Function: "PauseCountdown", ...target });
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
}
} }
} }
if (!commands.length) return false; if (!commands.length) return false;
@@ -4648,7 +4678,6 @@ function startCustomTooltips() {
return true; return true;
} }
function penaltyMirrorKey(component, event) { function penaltyMirrorKey(component, event) {
return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`; return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`;
} }
@@ -4713,8 +4742,15 @@ function startCustomTooltips() {
function sortedPenaltyEntries(side = "") { function sortedPenaltyEntries(side = "") {
return activeHockeyPenaltyEntries() return activeHockeyPenaltyEntries()
.filter((item) => !side || item.side === side) .filter((item) => !side || item.side === side)
.sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) .sort((a, b) => {
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); // 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. // BUILD89: the scorebug has only ONE penalty/power-play plate at a time.
@@ -4758,8 +4794,12 @@ function startCustomTooltips() {
function penaltyDisplayEntriesByTargetSide(step) { function penaltyDisplayEntriesByTargetSide(step) {
const home = sortedPenaltyEntries("home"); const home = sortedPenaltyEntries("home");
const away = sortedPenaltyEntries("away"); const away = sortedPenaltyEntries("away");
const all = [...home, ...away].sort((a, b) => Number(a.event.remainingMs || 0) - Number(b.event.remainingMs || 0) const all = [...home, ...away].sort((a, b) => {
|| Number(a.event.createdAt || 0) - Number(b.event.createdAt || 0)); 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) { if (!all.length) {
return { home: [], away: [], routedToAdvantage: false, advantageSide: "", plateSide: "", holdingEqualStrength: false, transitionEntry: null }; return { home: [], away: [], routedToAdvantage: false, advantageSide: "", plateSide: "", holdingEqualStrength: false, transitionEntry: null };
} }
@@ -4946,20 +4986,21 @@ function startCustomTooltips() {
}); });
const startingCountdown = Boolean(entry.event.running) const startingCountdown = Boolean(entry.event.running)
&& (force || assignmentChanged || previous?.running !== true); && (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 (entry.event.running) {
if (force || assignmentChanged || startingCountdown) { 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) { if (startingCountdown) {
runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name }); runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
} }
} else if (force || assignmentChanged || previous?.running !== false) { } else if (force || assignmentChanged || previous?.running !== false) {
// BUILD95: on every pause/stop, freeze first and then seed the // Freeze the title and seed the exact Runtime value, but never run a
// exact Runtime value. Never StartCountdown for a prepared item. // prepared/paused penalty until the web event itself is running.
stopCommands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name }); 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) }); setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) });
} }
} else { } else {
assignedMirrorKeys.add(eventKey); 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); 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) }); commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) });
} else if (pausing) { } else if (pausing) {
commands.push({ Function: "PauseCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name }); commands.push(...vmixCountdownSyncCommands(
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) }); step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "pause"
));
} else { } else {
// BUILD95: every start/resume is SetCountdown -> StartCountdown. commands.push(...vmixCountdownSyncCommands(
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) }); step.game_vmix_input, step.game_vmix_selected_name, () => gameTimerState.currentMs, "start"
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name }); ));
} }
} }
@@ -5176,17 +5218,14 @@ function startCustomTooltips() {
}); });
commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) }); commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) });
} else { } else {
const eventRunning = Boolean(event.running);
state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), { 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(...vmixCountdownSyncCommands(
commands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name }); target.input, target.selected_name, () => event.remainingMs,
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) }); (pausing || !eventRunning) ? "pause" : "start"
} 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 });
}
} }
}); });
}); });
@@ -9337,9 +9376,10 @@ async function hockeyLoadPreparedTitlesWorkspace({ force = false, render = true
function hockeySelectPreparedSource(input, { clearPanel = false } = {}) { function hockeySelectPreparedSource(input, { clearPanel = false } = {}) {
if (!input) return false; if (!input) return false;
state.preparedTitleEditingId = "";
state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input); state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input);
hockeyPreparedFieldValuesForInput(input, { preserve: false }); hockeyPreparedFieldValuesForInput(input, { preserve: false });
if (!state.preparedTitleName) state.preparedTitleName = `${String(input.title || `Input ${input.number || ""}`).trim()} · заготовка`; state.preparedTitleName = "";
if (clearPanel) { if (clearPanel) {
state.preparedTitlePanelId = ""; state.preparedTitlePanelId = "";
state.preparedTitleMappingSources = []; state.preparedTitleMappingSources = [];
@@ -9394,7 +9434,7 @@ async function hockeyCreatePreparedTitle() {
toast("Сначала выберите исходный vMix Input", true); toast("Сначала выберите исходный vMix Input", true);
return false; return false;
} }
const name = String(state.preparedTitleName || "").trim() || `${input.title || `Input ${input.number || ""}`} · заготовка`; const name = String(state.preparedTitleName || "").trim();
const fieldValues = {}; const fieldValues = {};
Object.entries(state.preparedTitleFieldValues || {}).forEach(([fieldName, entry]) => { Object.entries(state.preparedTitleFieldValues || {}).forEach(([fieldName, entry]) => {
if (!entry?.touched) return; if (!entry?.touched) return;
@@ -9423,6 +9463,7 @@ async function hockeyCreatePreparedTitle() {
}); });
toast(`Заготовка сохранена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`); toast(`Заготовка сохранена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`);
state.preparedTitleName = ""; state.preparedTitleName = "";
state.preparedTitleEditingId = "";
state.preparedTitlePanelId = ""; state.preparedTitlePanelId = "";
state.preparedTitleMappingSources = []; state.preparedTitleMappingSources = [];
state.preparedTitlesLoadedKey = ""; 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) { async function hockeyPreviewPreparedTitle(id) {
try { try {
await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}/preview`, { await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}/preview`, {
@@ -9457,6 +9582,7 @@ async function hockeyDeletePreparedTitle(id) {
try { try {
await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" }); await hockeyGameControlRequest(`/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" });
state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id)); state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id));
if (String(state.preparedTitleEditingId || "") === String(id)) hockeyCancelPreparedEdit();
renderRuntime(); renderRuntime();
return true; return true;
} catch (error) { } catch (error) {
@@ -9467,8 +9593,9 @@ async function hockeyDeletePreparedTitle(id) {
function hockeyOpenPreparedFromPlayerPanel(panel) { function hockeyOpenPreparedFromPlayerPanel(panel) {
if (!panel) return false; if (!panel) return false;
state.preparedTitleEditingId = "";
state.preparedTitlePanelId = String(panel.id || ""); state.preparedTitlePanelId = String(panel.id || "");
state.preparedTitleName = `${String(panel.label || "Выбор игроков")} · заготовка`; state.preparedTitleName = "";
state.preparedTitleSourceKey = ""; state.preparedTitleSourceKey = "";
state.preparedTitleFieldValues = {}; state.preparedTitleFieldValues = {};
state.preparedTitleMappingSources = []; state.preparedTitleMappingSources = [];
@@ -9491,6 +9618,8 @@ function renderHockeyPreparedTitlesWorkspace() {
: []; : [];
const panel = state.preparedTitlePanelId ? hockeyPlayerSelectionPanels().find((item) => item.id === state.preparedTitlePanelId) : null; const panel = state.preparedTitlePanelId ? hockeyPlayerSelectionPanels().find((item) => item.id === state.preparedTitlePanelId) : null;
const mappingSources = Array.isArray(state.preparedTitleMappingSources) ? state.preparedTitleMappingSources : []; 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 = ` root.innerHTML = `
<div class="hockey-prepared-head"> <div class="hockey-prepared-head">
@@ -9510,8 +9639,8 @@ function renderHockeyPreparedTitlesWorkspace() {
</aside> </aside>
<main class="hockey-prepared-editor"> <main class="hockey-prepared-editor">
${selected ? ` ${selected ? `
<div class="hockey-prepared-source"><div><span>ИСТОЧНИК</span><strong>#${escapeHtml(selected.number || "—")} · ${escapeHtml(selected.title || "Input")}</strong><small>${escapeHtml(selected.type || "")} · элементов ${fields.length}</small></div>${panel ? `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>${state.preparedTitleSnapshotLoading ? "Подставляю…" : "Подставить из Mapping"}</button>` : `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>Из Mapping</button>`}</div> <div class="hockey-prepared-source"><div><span>${editingItem ? "РЕДАКТИРОВАНИЕ" : "ИСТОЧНИК"}</span><strong>#${escapeHtml(selected.number || "—")} · ${escapeHtml(selected.title || "Input")}</strong><small>${editingItem ? `Заготовка #${escapeHtml(editingItem.id)} · изменяется существующий Input` : `${escapeHtml(selected.type || "")} · элементов ${fields.length}`}</small></div>${editingItem ? `<button type="button" data-prepared-cancel-edit>Отмена</button>` : panel ? `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>${state.preparedTitleSnapshotLoading ? "Подставляю…" : "Подставить из Mapping"}</button>` : `<button type="button" data-prepared-apply-mapping ${state.preparedTitleSnapshotLoading ? "disabled" : ""}>Из Mapping</button>`}</div>
<label class="hockey-prepared-name"><span>Название заготовки</span><input type="text" data-prepared-name value="${escapeHtml(state.preparedTitleName)}" placeholder="Например: Сравнение вратарей · студия"></label> <label class="hockey-prepared-name"><span>Название заготовки</span><input type="text" data-prepared-name value="${escapeHtml(state.preparedTitleName)}" placeholder="Если пусто — Заготовка N"></label>
<div class="hockey-prepared-fields">${fields.map((field) => { <div class="hockey-prepared-fields">${fields.map((field) => {
const name = String(field?.name || ""); const name = String(field?.name || "");
const type = hockeyPreparedFieldType(field); const type = hockeyPreparedFieldType(field);
@@ -9519,12 +9648,12 @@ function renderHockeyPreparedTitlesWorkspace() {
const color = /^#[0-9A-Fa-f]{6}$/.test(value) ? value : "#ffffff"; const color = /^#[0-9A-Fa-f]{6}$/.test(value) ? value : "#ffffff";
return `<label class="hockey-prepared-field" data-field-type="${escapeHtml(type)}"><span><b>${escapeHtml(name)}</b><small>${escapeHtml(type)}</small></span><div>${type === "color" ? `<input type="color" data-prepared-color="${escapeHtml(name)}" value="${escapeHtml(color)}">` : ""}<input type="text" data-prepared-field="${escapeHtml(name)}" data-prepared-field-type="${escapeHtml(type)}" value="${escapeHtml(value)}" placeholder="${type === "image" ? "путь к изображению" : type === "color" ? "#RRGGBB" : "текст"}"></div></label>`; return `<label class="hockey-prepared-field" data-field-type="${escapeHtml(type)}"><span><b>${escapeHtml(name)}</b><small>${escapeHtml(type)}</small></span><div>${type === "color" ? `<input type="color" data-prepared-color="${escapeHtml(name)}" value="${escapeHtml(color)}">` : ""}<input type="text" data-prepared-field="${escapeHtml(name)}" data-prepared-field-type="${escapeHtml(type)}" value="${escapeHtml(value)}" placeholder="${type === "image" ? "путь к изображению" : type === "color" ? "#RRGGBB" : "текст"}"></div></label>`;
}).join("") || `<div class="hockey-prepared-empty">У Input нет доступных элементов</div>`}</div> }).join("") || `<div class="hockey-prepared-empty">У Input нет доступных элементов</div>`}</div>
<div class="hockey-prepared-savebar"><small>При сохранении vMix создаст виртуальную копию этого Input в конце проекта и заполнит её текущими значениями.</small><button type="button" data-prepared-save ${state.preparedTitlesLoading ? "disabled" : ""}>${state.preparedTitlesLoading ? "Сохраняю" : " Сохранить новым Input"}</button></div> <div class="hockey-prepared-savebar"><small>${editingItem ? "Изменения применятся к уже созданному vMix Input: название и отредактированные поля обновятся без создания новой копии." : "Создаётся виртуальная копия, Input получает имя заготовки. vMix API не даёт назначить/создать категорию программно, поэтому копия остаётся в конце проекта."}</small><button type="button" ${editingItem ? "data-prepared-update" : "data-prepared-save"} ${state.preparedTitlesLoading ? "disabled" : ""}>${state.preparedTitlesLoading ? "Сохраняю" : editingItem ? " Сохранить изменения" : " Сохранить новым Input"}</button></div>
` : `<div class="hockey-prepared-editor-empty"><b>Выберите vMix Input</b><span>Справа появятся все его .Text / .Source / .Color элементы для ручной заготовки.</span></div>`} ` : `<div class="hockey-prepared-editor-empty"><b>Выберите vMix Input</b><span>Справа появятся все его .Text / .Source / .Color элементы для ручной заготовки.</span></div>`}
</main> </main>
<aside class="hockey-prepared-saved"> <aside class="hockey-prepared-saved">
<div class="hockey-prepared-saved-head"><span>СОХРАНЁННЫЕ</span><b>${state.preparedTitles.length}</b></div> <div class="hockey-prepared-saved-head"><span>СОХРАНЁННЫЕ</span><b>${state.preparedTitles.length}</b></div>
<div class="hockey-prepared-saved-list">${state.preparedTitles.map((item) => `<article><div><span>${escapeHtml(item.source_kind === "player_selection" ? "Блок игроков" : "Ручная")}</span><strong>${escapeHtml(item.name || "Заготовка")}</strong><small>Input #${escapeHtml(item.clone_input?.number || "—")} · из #${escapeHtml(item.source_input?.number || "")} ${escapeHtml(item.source_input?.title || "")}</small></div><div><button type="button" data-prepared-preview="${escapeHtml(item.id)}">▶ Preview</button><button type="button" class="danger" data-prepared-delete="${escapeHtml(item.id)}" title="Убрать из списка, не удаляя Input в vMix">×</button></div></article>`).join("") || `<div class="hockey-prepared-empty">Для этого матча заготовок пока нет</div>`}</div> <div class="hockey-prepared-saved-list">${state.preparedTitles.map((item) => `<article class="${String(item.id) === editingId ? "editing" : ""}"><div><span>${escapeHtml(item.source_kind === "player_selection" ? "Блок игроков" : "Ручная")}</span><strong>${escapeHtml(item.name || `Заготовка ${item.id || ""}`)}</strong><small>Input #${escapeHtml(item.clone_input?.number || "—")} · ${escapeHtml(item.clone_input?.title || item.name || "")} · из #${escapeHtml(item.source_input?.number || "")}</small></div><div><button type="button" data-prepared-edit="${escapeHtml(item.id)}" title="Изменить название и данные">✎</button><button type="button" data-prepared-preview="${escapeHtml(item.id)}">▶ Preview</button><button type="button" class="danger" data-prepared-delete="${escapeHtml(item.id)}" title="Убрать из списка, не удаляя Input в vMix">×</button></div></article>`).join("") || `<div class="hockey-prepared-empty">Для этого матча заготовок пока нет</div>`}</div>
</aside> </aside>
</div>`; </div>`;
@@ -9562,6 +9691,7 @@ function renderHockeyPreparedTitlesWorkspace() {
renderRuntime(); renderRuntime();
}); });
root.querySelector("[data-prepared-apply-mapping]")?.addEventListener("click", () => hockeyApplyPreparedMappingSnapshot()); 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.querySelector("[data-prepared-name]")?.addEventListener("input", (event) => { state.preparedTitleName = event.target.value || ""; });
root.querySelectorAll("[data-prepared-field]").forEach((inputNode) => inputNode.addEventListener("input", () => { root.querySelectorAll("[data-prepared-field]").forEach((inputNode) => inputNode.addEventListener("input", () => {
const name = inputNode.dataset.preparedField || ""; const name = inputNode.dataset.preparedField || "";
@@ -9579,6 +9709,8 @@ function renderHockeyPreparedTitlesWorkspace() {
state.preparedTitleFieldValues[name] = { type: "color", value: picker.value, touched: true }; state.preparedTitleFieldValues[name] = { type: "color", value: picker.value, touched: true };
})); }));
root.querySelector("[data-prepared-save]")?.addEventListener("click", () => hockeyCreatePreparedTitle()); 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-preview]").forEach((button) => button.addEventListener("click", () => hockeyPreviewPreparedTitle(button.dataset.preparedPreview)));
root.querySelectorAll("[data-prepared-delete]").forEach((button) => button.addEventListener("click", () => hockeyDeletePreparedTitle(button.dataset.preparedDelete))); root.querySelectorAll("[data-prepared-delete]").forEach((button) => button.addEventListener("click", () => hockeyDeletePreparedTitle(button.dataset.preparedDelete)));
return root; return root;
@@ -12935,7 +13067,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<div class="editor-pin-icon">⌘</div> <div class="editor-pin-icon">⌘</div>
<div class="editor-pin-copy"> <div class="editor-pin-copy">
<strong>Защищённый режим конструктора</strong> <strong>Защищённый режим конструктора</strong>
<p>Введите ежедневный четырёхзначный PIN или аварийный мастер-PIN.</p> <p>Введите PIN доступа к конструктору.</p>
</div> </div>
<label class="editor-pin-field"> <label class="editor-pin-field">
<span>PIN-код</span> <span>PIN-код</span>

View File

@@ -7666,6 +7666,7 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
.hockey-prepared-saved-head { min-height:28px; padding:0 2px; } .hockey-prepared-saved-head { min-height:28px; padding:0 2px; }
.hockey-prepared-saved-head b { min-width:22px; height:18px; display:grid; place-items:center; border-radius:9px; color:#9ec1d8; background:#1b3850; font:900 8px "Roboto Mono",Consolas,monospace; } .hockey-prepared-saved-head b { min-width:22px; height:18px; display:grid; place-items:center; border-radius:9px; color:#9ec1d8; background:#1b3850; font:900 8px "Roboto Mono",Consolas,monospace; }
.hockey-prepared-saved article { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px; align-items:center; padding:7px; border:1px solid #243e54; border-radius:8px; background:#0d2031; } .hockey-prepared-saved article { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:6px; align-items:center; padding:7px; border:1px solid #243e54; border-radius:8px; background:#0d2031; }
.hockey-prepared-saved article.editing { border-color:#4dddbc; box-shadow:inset 3px 0 0 #4dddbc; background:#102b34; }
.hockey-prepared-saved article > div:first-child { min-width:0; display:grid; gap:2px; } .hockey-prepared-saved article > div:first-child { min-width:0; display:grid; gap:2px; }
.hockey-prepared-saved article span { color:#5c8ca6; font-size:6px; font-weight:950; text-transform:uppercase; } .hockey-prepared-saved article span { color:#5c8ca6; font-size:6px; font-weight:950; text-transform:uppercase; }
.hockey-prepared-saved article strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; color:#e1eef8; } .hockey-prepared-saved article strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; color:#e1eef8; }