выделение удалений, и заготовки

This commit is contained in:
2026-08-20 13:33:42 +03:00
parent 3cea13c103
commit 381e630c77
7 changed files with 1511 additions and 49 deletions

View File

@@ -17,7 +17,7 @@ from sqlalchemy import and_, delete, desc, select
from .auth_bridge import HockeyUser from .auth_bridge import HockeyUser
from .database import HockeyDatabase from .database import HockeyDatabase
from .mapping_context import MappingDataService from .mapping_context import MappingDataService
from .models import (MappingSqlDataSource, OperatorSession, UserPreference, VmixAssignment, VmixDevice, VmixMappingProfile, VmixMappingField) from .models import (MappingSqlDataSource, OperatorSession, UserPreference, VmixAssignment, VmixDevice, VmixMappingProfile, VmixMappingField, VmixPreparedTitle)
AGENT_PROTOCOL_VERSION = 1 AGENT_PROTOCOL_VERSION = 1
@@ -2010,6 +2010,394 @@ class VmixAgentHub:
}) })
return {"devices": items} return {"devices": items}
@staticmethod
def _prepared_inventory_from_row(device: VmixDevice | None) -> dict[str, Any]:
if device is None:
return {}
try:
parsed = json.loads(device.project_inventory_json or "{}")
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
@staticmethod
def _prepared_find_input(inventory: dict[str, Any], *, key: str = "", number: str = "", title: str = "") -> dict[str, Any] | None:
inputs = inventory.get("inputs") if isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) else []
key = str(key or "").strip()
number = str(number or "").strip()
title = str(title or "").strip()
if key:
found = next((item for item in inputs if isinstance(item, dict) and str(item.get("key") or "").strip() == key), None)
if found is not None:
return found
if number:
found = next((item for item in inputs if isinstance(item, dict) and str(item.get("number") or "").strip() == number), None)
if found is not None:
return found
if title:
matches = [item for item in inputs if isinstance(item, dict) and str(item.get("title") or "").strip() == title]
if len(matches) == 1:
return matches[0]
return None
def _prepared_device_for_user(self, session: Any, user: HockeyUser, requested_device_id: str = "") -> VmixDevice:
requested = str(requested_device_id or "").strip()
if requested:
requested = self.normalise_device_id(requested)
device = session.scalar(select(VmixDevice).where(and_(VmixDevice.device_uuid == requested, VmixDevice.wfl_user_id == user.id)))
if device is None:
raise HTTPException(status_code=404, detail="Agent не принадлежит текущему аккаунту")
return device
rows = list(session.scalars(
select(VmixDevice)
.where(and_(VmixDevice.wfl_user_id == user.id, VmixDevice.is_active_for_account.is_(True)))
.order_by(desc(VmixDevice.last_seen_at))
))
if not rows:
raise HTTPException(status_code=409, detail="Hockey Agent не выбран")
live = next((row for row in rows if row.device_uuid in self._live and bool(row.vmix_connected)), None)
return live or rows[0]
@staticmethod
def _prepared_title_payload(row: VmixPreparedTitle, inventory: dict[str, Any] | None = None) -> dict[str, Any]:
try:
fields = json.loads(row.field_values_json or "{}")
if not isinstance(fields, dict):
fields = {}
except Exception:
fields = {}
clone_key = str(row.clone_input_key or "")
clone_number = str(row.clone_input_number or "")
clone_title = str(row.clone_input_title or "")
if inventory:
match = VmixAgentHub._prepared_find_input(inventory, key=clone_key, number=clone_number, title=clone_title)
if match is not None:
clone_key = str(match.get("key") or clone_key)
clone_number = str(match.get("number") or clone_number)
clone_title = str(match.get("title") or clone_title)
return {
"id": row.id,
"name": row.name,
"game_id": row.game_external_id,
"device_id": row.device_uuid,
"source_kind": row.source_kind,
"source_ref": row.source_ref,
"source_input": {
"key": row.source_input_key,
"number": row.source_input_number,
"title": row.source_input_title,
},
"clone_input": {"key": clone_key, "number": clone_number, "title": clone_title},
"field_values": fields,
"created_at": row.created_at.isoformat() if row.created_at else "",
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
}
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)
resolved_game = str(game_id or device.current_match_external_id or "").strip()
query = select(VmixPreparedTitle).where(VmixPreparedTitle.wfl_user_id == user.id)
if resolved_game:
query = query.where(VmixPreparedTitle.game_external_id == resolved_game)
if device.device_uuid:
query = query.where(VmixPreparedTitle.device_uuid == device.device_uuid)
rows = list(session.scalars(query.order_by(desc(VmixPreparedTitle.created_at), desc(VmixPreparedTitle.id))))
inventory = self._prepared_inventory_from_row(device)
# Refresh clone key/title opportunistically after Agent inventory catches up.
for row in rows:
found = 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 ""),
)
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]
items = [self._prepared_title_payload(row, inventory) for row in rows]
return {
"device_id": device.device_uuid,
"game_id": resolved_game,
"items": items,
}
def _prepared_mapping_context(self, session: Any, device: VmixDevice, user: HockeyUser) -> tuple[VmixMappingProfile | None, dict[str, Any], dict[str, Any]]:
profile = self._active_mapping_profile_for_device(session, device)
if profile is None:
return None, {}, {}
assignment = session.scalar(
select(VmixAssignment)
.where(and_(VmixAssignment.device_id == device.id, VmixAssignment.active.is_(True)))
.order_by(desc(VmixAssignment.id))
)
context = {
"game_id": str(device.current_match_external_id or ""),
"device_id": device.device_uuid,
"assignment_id": str(device.current_assignment_key or ""),
"tournament_id": str(assignment.tournament_external_id or "") if assignment is not None else "",
}
preference = session.get(UserPreference, user.id)
if preference is not None:
language = str(preference.vmix_language or preference.display_language or "").strip().lower()
if language in {"ru", "en"}:
context["ui_language"] = language
inventory = self._prepared_inventory_from_row(device)
return profile, context, inventory
async def prepared_mapping_sources(self, user: HockeyUser, *, device_id: str = "", panel_id: str = "") -> dict[str, Any]:
panel_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel_id or "").strip()).strip("_")[:64]
with self.database.session() as session:
device = self._prepared_device_for_user(session, user, device_id)
profile, _context, inventory = self._prepared_mapping_context(session, device, user)
if profile is None:
return {"device_id": device.device_uuid, "profile_id": 0, "items": []}
fields = list(session.scalars(
select(VmixMappingField)
.where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
if panel_id:
prefix = f"player_select.{panel_id}."
fields = [field for field in fields if str(field.data_key or "").startswith(prefix)]
grouped: dict[str, dict[str, Any]] = {}
for field in fields:
identity = str(field.vmix_input_key or field.vmix_input_title or field.vmix_input_number or "").strip()
if not identity:
continue
item = grouped.setdefault(identity, {
"key": str(field.vmix_input_key or ""),
"number": str(field.vmix_input_number or ""),
"title": str(field.vmix_input_title or ""),
"mapped_fields": 0,
})
item["mapped_fields"] += 1
for item in grouped.values():
found = self._prepared_find_input(inventory, key=item["key"], number=item["number"], title=item["title"])
if found is not None:
item.update({
"key": str(found.get("key") or item["key"]),
"number": str(found.get("number") or item["number"]),
"title": str(found.get("title") or item["title"]),
"type": str(found.get("type") or ""),
})
items = sorted(grouped.values(), key=lambda value: (int(value.get("number") or 999999) if str(value.get("number") or "").isdigit() else 999999, str(value.get("title") or "").casefold()))
return {"device_id": device.device_uuid, "profile_id": profile.id, "items": items}
async def prepared_mapping_snapshot(
self,
user: HockeyUser,
*,
device_id: str = "",
input_key: str = "",
input_number: str = "",
input_title: str = "",
panel_id: str = "",
) -> dict[str, Any]:
panel_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel_id or "").strip()).strip("_")[:64]
with self.database.session() as session:
device = self._prepared_device_for_user(session, user, device_id)
profile, context, inventory = self._prepared_mapping_context(session, device, user)
if profile is None:
return {"device_id": device.device_uuid, "profile_id": 0, "fields": []}
source = self._prepared_find_input(inventory, key=input_key, number=input_number, title=input_title)
source_key = str(source.get("key") or input_key or "") if source else str(input_key or "")
source_number = str(source.get("number") or input_number or "") if source else str(input_number or "")
source_title = str(source.get("title") or input_title or "") if source else str(input_title or "")
fields = list(session.scalars(
select(VmixMappingField)
.where(and_(VmixMappingField.profile_id == profile.id, VmixMappingField.enabled.is_(True)))
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
if panel_id:
prefix = f"player_select.{panel_id}."
fields = [field for field in fields if str(field.data_key or "").startswith(prefix)]
def target_matches(field: VmixMappingField) -> bool:
if source_key and str(field.vmix_input_key or "") == source_key:
return True
if source_title and str(field.vmix_input_title or "") == source_title:
return True
if source_number and str(field.vmix_input_number or "") == source_number:
return True
return False
fields = [field for field in fields if target_matches(field)]
catalog = self.mapping_data.data_catalog(user, context)
by_key = {str(item.get("key") or ""): item for item in (catalog.get("items") or []) if item.get("key")}
result_fields = []
for field in fields:
item = by_key.get(str(field.data_key or ""))
if item is None:
continue
result_fields.append({
"name": str(field.vmix_field or ""),
"type": str(field.field_type or "text"),
"data_key": str(field.data_key or ""),
"value": "" if item.get("value") is None else str(item.get("value")),
})
return {
"device_id": device.device_uuid,
"game_id": str(device.current_match_external_id or ""),
"profile_id": profile.id,
"input": {"key": source_key, "number": source_number, "title": source_title},
"fields": result_fields,
}
async def create_prepared_title(self, user: HockeyUser, payload: Any) -> dict[str, Any]:
requested_device = str(getattr(payload, "device_id", "") or "").strip()
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)
inventory = self._prepared_inventory_from_row(device)
source = self._prepared_find_input(
inventory,
key=str(getattr(payload, "source_input_key", "") or ""),
number=str(getattr(payload, "source_input_number", "") or ""),
title=str(getattr(payload, "source_input_title", "") or ""),
)
if source is None:
raise HTTPException(status_code=404, detail="Исходный vMix Input не найден в текущем проекте")
source_key = str(source.get("key") or "")
source_number = str(source.get("number") or "")
source_title = str(source.get("title") or "")
source_ref = source_key or source_number or source_title
before_inputs = [item for item in (inventory.get("inputs") or []) if isinstance(item, dict)]
before_keys = {str(item.get("key") or "") for item in before_inputs if str(item.get("key") or "")}
before_numbers = {str(item.get("number") or "") for item in before_inputs if str(item.get("number") or "")}
numeric_numbers = [int(value) for value in before_numbers if value.isdigit()]
expected_number = str((max(numeric_numbers) if numeric_numbers else len(before_inputs)) + 1)
create_result = await self.run_vmix_sequence_for_user(
user,
[{"Function": "CreateVirtualInput", "Input": source_ref}],
device_id=device.device_uuid,
session_token=session_token,
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 "")
clone: dict[str, Any] | None = None
for _ in range(12):
await asyncio.sleep(0.18)
with self.database.session() as session:
refreshed = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == target_device_id))
current_inventory = self._prepared_inventory_from_row(refreshed)
current_inputs = [item for item in (current_inventory.get("inputs") or []) if isinstance(item, dict)]
candidates = [item for item in current_inputs if (str(item.get("key") or "") and str(item.get("key") or "") not in before_keys) or (str(item.get("number") or "") and str(item.get("number") or "") not in before_numbers)]
if candidates:
clone = sorted(candidates, key=lambda item: int(item.get("number") or 0) if str(item.get("number") or "").isdigit() else 0)[-1]
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
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)),
)
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:
row = VmixPreparedTitle(
wfl_user_id=user.id,
game_external_id=match_id,
device_uuid=target_device_id,
name=title_name,
source_kind=source_kind,
source_ref=source_ref_name,
source_input_key=source_key,
source_input_number=source_number,
source_input_title=source_title,
clone_input_key=clone_key,
clone_input_number=clone_number,
clone_input_title=clone_title,
field_values_json=json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")),
created_by=user.id,
created_at=_utcnow(),
updated_at=_utcnow(),
)
session.add(row)
session.flush()
result = self._prepared_title_payload(row)
result["ok"] = True
result["create_result"] = create_result
result["set_result"] = set_result or {"ok": True, "applied": 0}
return result
async def preview_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
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)
found = 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 ""),
)
input_ref = str(found.get("key") or found.get("number") or "") if found is not None else str(row.clone_input_key or row.clone_input_number or row.clone_input_title or "")
if not input_ref:
raise HTTPException(status_code=409, detail="Клонированный Input больше не найден в vMix")
return await self.run_vmix_sequence_for_user(
user,
[{"Function": "PreviewInput", "Input": input_ref}],
device_id=device.device_uuid,
session_token=str(getattr(payload, "session_token", "") or ""),
timeout=4.0,
)
async def delete_prepared_title(self, prepared_id: int, user: HockeyUser) -> dict[str, Any]:
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="Заготовка не найдена")
# Deliberately do not RemoveInput in vMix: deleting a web list entry must
# never unexpectedly destroy a studio graphic that may already be used.
session.delete(row)
return {"ok": True, "id": int(prepared_id), "vmix_input_removed": False}
async def latest_vmix_inventory(self) -> dict[str, Any]: async def latest_vmix_inventory(self) -> dict[str, Any]:
"""Return the most recent connected vMix project inventory for editor helpers. """Return the most recent connected vMix project inventory for editor helpers.
@@ -3183,6 +3571,23 @@ class MappingSqlPreviewPayload(BaseModel):
context: dict[str, Any] = Field(default_factory=dict) context: dict[str, Any] = Field(default_factory=dict)
class PreparedTitleCreatePayload(BaseModel):
name: str = Field(default="", max_length=200)
device_id: str = Field(default="", max_length=128)
session_token: str = Field(default="", max_length=128)
source_input_key: str = Field(default="", max_length=128)
source_input_number: str = Field(default="", max_length=32)
source_input_title: str = Field(default="", max_length=300)
source_kind: str = Field(default="manual", max_length=32)
source_ref: 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)
class AgentHelloPayload(BaseModel): class AgentHelloPayload(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
type: str = "hello" type: str = "hello"
@@ -3215,6 +3620,57 @@ def create_hockey_agent_router(
) -> dict[str, Any]: ) -> dict[str, Any]:
return await hub.vmix_inventory_for_user(user, device_id=device_id) return await hub.vmix_inventory_for_user(user, device_id=device_id)
@router.get("/api/hockey/prepared-titles")
async def prepared_titles_list(
device_id: str = Query("", max_length=128),
game_id: str = Query("", max_length=64),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.list_prepared_titles(user, device_id=device_id, game_id=game_id)
@router.get("/api/hockey/prepared-titles/mapping-sources")
async def prepared_titles_mapping_sources(
device_id: str = Query("", max_length=128),
panel_id: str = Query("", max_length=64),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.prepared_mapping_sources(user, device_id=device_id, panel_id=panel_id)
@router.get("/api/hockey/prepared-titles/mapping-snapshot")
async def prepared_titles_mapping_snapshot(
device_id: str = Query("", max_length=128),
input_key: str = Query("", max_length=128),
input_number: str = Query("", max_length=32),
input_title: str = Query("", max_length=300),
panel_id: str = Query("", max_length=64),
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.prepared_mapping_snapshot(
user, device_id=device_id, input_key=input_key, input_number=input_number, input_title=input_title, panel_id=panel_id
)
@router.post("/api/hockey/prepared-titles")
async def prepared_title_create(
payload: PreparedTitleCreatePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.create_prepared_title(user, payload)
@router.post("/api/hockey/prepared-titles/{prepared_id}/preview")
async def prepared_title_preview(
prepared_id: int,
payload: PreparedTitlePreviewPayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.preview_prepared_title(prepared_id, user, payload)
@router.delete("/api/hockey/prepared-titles/{prepared_id}")
async def prepared_title_delete(
prepared_id: int,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.delete_prepared_title(prepared_id, user)
@router.post("/api/hockey/agents/devices/{device_id}/pair") @router.post("/api/hockey/agents/devices/{device_id}/pair")
async def pair_device( async def pair_device(
device_id: str, device_id: str,

View File

@@ -76,6 +76,15 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = (
{"key": "selected_away_penalty_player_id", "label": "Удаление правой команды — игрок external ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, {"key": "selected_away_penalty_player_id", "label": "Удаление правой команды — игрок external ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
{"key": "selected_away_penalty_player_db_id", "label": "Удаление правой команды — игрок DB ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"}, {"key": "selected_away_penalty_player_db_id", "label": "Удаление правой команды — игрок DB ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
{"key": "selected_away_penalty_team_penalty", "label": "Удаление правой команды — командное (1/0)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"}, {"key": "selected_away_penalty_team_penalty", "label": "Удаление правой команды — командное (1/0)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"},
{"key": "active_home_penalty_id", "label": "Активное удаление левой команды — ID", "category": "Активные удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"},
{"key": "active_home_penalty_player_id", "label": "Активное удаление левой команды — игрок external ID", "category": "Активные удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
{"key": "active_home_penalty_player_db_id", "label": "Активное удаление левой команды — игрок DB ID", "category": "Активные удаления — левая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
{"key": "active_home_penalty_team_penalty", "label": "Активное удаление левой команды — командное (1/0)", "category": "Активные удаления — левая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"},
{"key": "active_away_penalty_id", "label": "Активное удаление правой команды — ID", "category": "Активные удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection"},
{"key": "active_away_penalty_player_id", "label": "Активное удаление правой команды — игрок external ID", "category": "Активные удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
{"key": "active_away_penalty_player_db_id", "label": "Активное удаление правой команды — игрок DB ID", "category": "Активные удаления — правая команда", "entity_type": "player", "scope": "match_shared", "source_type": "selection"},
{"key": "active_away_penalty_team_penalty", "label": "Активное удаление правой команды — командное (1/0)", "category": "Активные удаления — правая команда", "entity_type": "penalty", "scope": "match_shared", "source_type": "selection", "value_type": "text"},
{"key": "selected_penalty_infraction_id", "label": "Нарушение выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, {"key": "selected_penalty_infraction_id", "label": "Нарушение выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_preset_id", "label": "Длительность выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, {"key": "selected_penalty_preset_id", "label": "Длительность выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_side", "label": "Сторона выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"}, {"key": "selected_penalty_side", "label": "Сторона выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
@@ -95,9 +104,9 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = (
) )
# These values describe the live state of the match itself, not a private UI # Side-specific penalty selections and active-penalty mirrors are shared inside
# selection of one account. They must be visible to Mapping regardless of which # one match. This lets the operator select a HOME/AWAY penalty while an admin
# operator/admin account opened the Mapping editor. # watches Mapping from another account without getting a private copy.
MATCH_SHARED_CONTEXT_KEYS: frozenset[str] = frozenset({ MATCH_SHARED_CONTEXT_KEYS: frozenset[str] = frozenset({
"selected_home_penalty_id", "selected_home_penalty_id",
"selected_home_penalty_player_id", "selected_home_penalty_player_id",
@@ -107,6 +116,14 @@ MATCH_SHARED_CONTEXT_KEYS: frozenset[str] = frozenset({
"selected_away_penalty_player_id", "selected_away_penalty_player_id",
"selected_away_penalty_player_db_id", "selected_away_penalty_player_db_id",
"selected_away_penalty_team_penalty", "selected_away_penalty_team_penalty",
"active_home_penalty_id",
"active_home_penalty_player_id",
"active_home_penalty_player_db_id",
"active_home_penalty_team_penalty",
"active_away_penalty_id",
"active_away_penalty_player_id",
"active_away_penalty_player_db_id",
"active_away_penalty_team_penalty",
}) })

View File

@@ -981,6 +981,39 @@ class VmixAssignment(Base):
) )
class VmixPreparedTitle(Base):
"""A match-scoped vMix title clone prepared by an operator.
The actual graphic lives in vMix. This row only remembers which source was
cloned, where the clone ended up and the field snapshot that was applied.
"""
__tablename__ = "hockey_vmix_prepared_titles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
wfl_user_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
game_external_id: Mapped[str] = mapped_column(String(64), nullable=False, default="", index=True)
device_uuid: Mapped[str] = mapped_column(String(128), nullable=False, default="", index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False, default="")
source_kind: Mapped[str] = mapped_column(String(32), nullable=False, default="manual")
source_ref: Mapped[str] = mapped_column(String(128), nullable=False, default="")
source_input_key: Mapped[str] = mapped_column(String(128), nullable=False, default="")
source_input_number: Mapped[str] = mapped_column(String(32), nullable=False, default="")
source_input_title: Mapped[str] = mapped_column(String(300), nullable=False, default="")
clone_input_key: Mapped[str] = mapped_column(String(128), nullable=False, default="")
clone_input_number: Mapped[str] = mapped_column(String(32), nullable=False, default="")
clone_input_title: Mapped[str] = mapped_column(String(300), nullable=False, default="")
field_values_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_by: Mapped[str] = mapped_column(String(128), nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
Index("ix_hockey_prepared_titles_user_game", "wfl_user_id", "game_external_id", "created_at"),
Index("ix_hockey_prepared_titles_device", "device_uuid", "created_at"),
)
class VmixMappingProfile(Base): class VmixMappingProfile(Base):
__tablename__ = "hockey_vmix_mapping_profiles" __tablename__ = "hockey_vmix_mapping_profiles"

View File

@@ -2,15 +2,36 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8") APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
ADMIN = (ROOT / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8") MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8")
def test_side_penalty_mapping_context_is_derived_from_active_penalties(): def test_side_penalty_mapping_context_is_derived_from_active_penalties_without_overwriting_manual_selection():
start = APP.index("async function hockeySyncPenaltySideMappingContext") start = APP.index("async function hockeySyncPenaltySideMappingContext")
end = APP.index("function penaltyTargetAssignmentKey", start) end = APP.index("function penaltyTargetAssignmentKey", start)
snippet = APP[start:end] snippet = APP[start:end]
assert 'sortedPenaltyEntries("home")[0]' in snippet assert 'sortedPenaltyEntries("home")[0]' in snippet
assert 'sortedPenaltyEntries("away")[0]' in snippet assert 'sortedPenaltyEntries("away")[0]' in snippet
for key in (
"active_home_penalty_id",
"active_home_penalty_player_id",
"active_home_penalty_player_db_id",
"active_home_penalty_team_penalty",
"active_away_penalty_id",
"active_away_penalty_player_id",
"active_away_penalty_player_db_id",
"active_away_penalty_team_penalty",
):
assert f"{key}:" in snippet
# BUILD84: selected_* is an explicit operator choice, so the automatic mirror
# must never replace it with the first active penalty.
assert "selected_home_penalty_id:" not in snippet
assert "selected_away_penalty_id:" not in snippet
def test_manual_side_penalty_selection_keeps_separate_mapping_identifiers():
start = APP.index("async function hockeySelectPenaltyForPreview")
end = APP.index("function hockeyPenaltyCard", start)
snippet = APP[start:end]
for key in ( for key in (
"selected_home_penalty_id", "selected_home_penalty_id",
"selected_home_penalty_player_id", "selected_home_penalty_player_id",
@@ -22,25 +43,5 @@ def test_side_penalty_mapping_context_is_derived_from_active_penalties():
"selected_away_penalty_team_penalty", "selected_away_penalty_team_penalty",
): ):
assert f"{key}:" in snippet assert f"{key}:" in snippet
assert 'fetch("/api/hockey/context/batch"' in snippet assert key in MAPPING
assert "const playerIds = hockeyPenaltyPlayerIdentifiers(component, event);" in snippet
def test_penalty_context_sync_runs_when_penalty_changes_and_clears_on_finish():
assert "hockeySyncPenaltySideMappingContext().catch(() => {});" in APP
assert APP.count("hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});") >= 3
def test_saved_penalty_player_keeps_external_and_database_ids():
start = APP.index("function hockeyCompactPlayer")
end = APP.index("function hockeyCompactInfraction", start)
snippet = APP[start:end]
assert "const externalId" in snippet
assert "const dbId" in snippet
assert "externalId," in snippet
assert "dbId," in snippet
def test_mapping_admin_refreshes_live_after_runtime_context_update():
assert 'window.addEventListener("hockey:mapping-context-updated"' in ADMIN
assert "await loadMappingCatalog()" in ADMIN
assert "renderMapping();" in ADMIN

View File

@@ -0,0 +1,192 @@
from pathlib import Path
from hockey_data.agent_bridge import VmixAgentHub
from hockey_data.models import VmixPreparedTitle
ROOT = Path(__file__).resolve().parents[1]
APP = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8")
BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8")
STYLES = (ROOT / "ui_builder/static/styles.css").read_text(encoding="utf-8")
def test_home_and_away_penalties_have_independent_manual_selection():
assert 'selectedPreviewEventIds: { home: "", away: "" }' in APP
assert 'board.selectedPreviewEventIds[side] = event.id' in APP
assert 'card.classList.toggle("is-preview-selected", hockeyPenaltyIsPreviewSelected(board, event));' in APP
# The old global selectedEventId must not drive the visual selection class.
penalty_start = APP.index("function hockeyPenaltyCard")
penalty_end = APP.index("function createHockeyDraftFromDrop", penalty_start)
penalty_card = APP[penalty_start:penalty_end]
assert 'card.classList.toggle("is-selected", board.selectedEventId === event.id)' not in penalty_card
assert '.hpd-team-penalty-card.team-home.is-preview-selected' in STYLES
assert '.hpd-team-penalty-card.team-away.is-preview-selected' in STYLES
def test_selected_penalty_context_is_manual_and_active_penalty_context_is_separate():
select_start = APP.index("async function hockeySelectPenaltyForPreview")
select_end = APP.index("function hockeyPenaltyCard", select_start)
select_block = APP[select_start:select_end]
for key in (
"selected_home_penalty_id",
"selected_home_penalty_player_id",
"selected_home_penalty_player_db_id",
"selected_home_penalty_team_penalty",
"selected_away_penalty_id",
"selected_away_penalty_player_id",
"selected_away_penalty_player_db_id",
"selected_away_penalty_team_penalty",
):
assert key in select_block
assert key in MAPPING
sync_start = APP.index("async function hockeySyncPenaltySideMappingContext")
sync_end = APP.index("function penaltyTargetAssignmentKey", sync_start)
sync_block = APP[sync_start:sync_end]
assert "active_home_penalty_id:" in sync_block
assert "active_away_penalty_id:" in sync_block
assert "selected_home_penalty_id:" not in sync_block
assert "selected_away_penalty_id:" not in sync_block
def test_player_selection_values_survive_reload_using_server_state_and_browser_backup():
for token in (
"function hockeyPlayerSelectionStorageKey",
"function hockeyBackupPlayerSelectionValues",
"function hockeyStoredPlayerSelectionValues",
"hockeyBackupPlayerSelectionValues(gameId, payload?.values || {})",
'if (!control || String(control.game_id || "") !== String(gameId)) return false;',
"const hasServerSelectionKeys",
):
assert token in APP
assert "localStorage.setItem(hockeyPlayerSelectionStorageKey(id)" in APP
def test_player_block_editor_is_compact_single_line():
assert "/* BUILD84 — compact, single-line player-block editor. */" in STYLES
assert "min-height:48px" in STYLES
assert "grid-template-columns:24px 66px" in STYLES
assert '> Главный</label>' in APP
assert '> Свёрнут</label>' in APP
def test_prepared_titles_workspace_exists_and_clones_to_vmix():
assert '{ id: "prepared_titles", label: "Заготовки" }' in APP
for token in (
"function renderHockeyPreparedTitlesWorkspace()",
"function hockeyOpenPreparedFromPlayerPanel(panel)",
"data-player-panel-prepared",
"/api/hockey/prepared-titles",
"Сохранить новым Input",
"Подставить из Mapping",
"▶ Preview",
):
assert token in APP
assert VmixPreparedTitle.__tablename__ == "hockey_vmix_prepared_titles"
assert '{"Function": "CreateVirtualInput", "Input": source_ref}' in BRIDGE
assert '{"Function": "PreviewInput", "Input": input_ref}' in BRIDGE
assert '@router.post("/api/hockey/prepared-titles")' in BRIDGE
assert '@router.get("/api/hockey/prepared-titles/mapping-snapshot")' in BRIDGE
def test_prepared_input_resolution_prefers_stable_key_then_number_then_unique_title():
inventory = {
"inputs": [
{"key": "stable-a", "number": "7", "title": "TITLE"},
{"key": "stable-b", "number": "9", "title": "OTHER"},
]
}
assert VmixAgentHub._prepared_find_input(inventory, key="stable-b")["number"] == "9"
assert VmixAgentHub._prepared_find_input(inventory, number="7")["key"] == "stable-a"
assert VmixAgentHub._prepared_find_input(inventory, title="OTHER")["key"] == "stable-b"
duplicate = {"inputs": [{"key": "a", "title": "SAME"}, {"key": "b", "title": "SAME"}]}
assert VmixAgentHub._prepared_find_input(duplicate, title="SAME") is None
def test_prepared_title_creation_clones_last_input_and_applies_field_snapshot(tmp_path):
import asyncio
import json
import types
from types import SimpleNamespace
from sqlalchemy import select
from hockey_data.auth_bridge import HockeyUser
from hockey_data.models import VmixDevice, VmixPreparedTitle
from tests.support import LocalTestDatabase
database = LocalTestDatabase(tmp_path / "prepared.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
user = HockeyUser(id="prep-user", login="prep", display_name="Prep")
source_inventory = {
"inputs": [
{
"key": "source-key",
"number": "12",
"title": "COMPARE",
"type": "GT",
"fields": [
{"name": "Name.Text", "type": "text", "index": "0"},
{"name": "Photo.Source", "type": "image", "index": "1"},
],
}
]
}
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(source_inventory),
))
calls = []
async def fake_sequence(self, _user, commands, *, device_id="", session_token="", timeout=0):
calls.append(commands)
if commands and commands[0].get("Function") == "CreateVirtualInput":
cloned = {
"inputs": source_inventory["inputs"] + [
{
"key": "clone-key",
"number": "13",
"title": "COMPARE",
"type": "GT",
"fields": source_inventory["inputs"][0]["fields"],
}
]
}
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)
payload = SimpleNamespace(
name="Compare ready",
device_id="prep-device",
session_token="session",
source_input_key="source-key",
source_input_number="12",
source_input_title="COMPARE",
source_kind="player_selection",
source_ref="compare_home",
field_values={
"Name.Text": {"type": "text", "value": "Иванов"},
"Photo.Source": {"type": "image", "value": r"D:\\Photo\\ivanov.png"},
},
)
result = asyncio.run(hub.create_prepared_title(user, payload))
assert result["clone_input"]["number"] == "13"
assert calls[0] == [{"Function": "CreateVirtualInput", "Input": "source-key"}]
assert any(command.get("Function") == "SetText" and command.get("Input") == "clone-key" for command in calls[1])
assert any(command.get("Function") == "SetImage" and command.get("SelectedName") == "Photo.Source" for command in calls[1])
with database.session() as session:
row = session.scalar(select(VmixPreparedTitle))
assert row is not None
assert row.clone_input_key == "clone-key"
assert row.clone_input_number == "13"
assert row.source_ref == "compare_home"

View File

@@ -107,6 +107,17 @@
shortcutInventory: { device_id: "", device_name: "", online: false, vmix_connected: false, inventory: { inputs: [] }, devices: [] }, shortcutInventory: { device_id: "", device_name: "", online: false, vmix_connected: false, inventory: { inputs: [] }, devices: [] },
shortcutInventoryLoading: false, shortcutInventoryLoading: false,
shortcutEditorScrollTop: 0, shortcutEditorScrollTop: 0,
preparedTitles: [],
preparedTitleInventory: { device_id: "", device_name: "", inventory: { inputs: [] } },
preparedTitlesLoading: false,
preparedTitlesLoadedKey: "",
preparedTitleSearch: "",
preparedTitleSourceKey: "",
preparedTitleFieldValues: {},
preparedTitleName: "",
preparedTitlePanelId: "",
preparedTitleMappingSources: [],
preparedTitleSnapshotLoading: false,
modalLocked: false, modalLocked: false,
timerQuickEditorInterval: null, timerQuickEditorInterval: null,
timers: {}, timers: {},
@@ -2550,6 +2561,13 @@ function startCustomTooltips() {
state.config.tabs = (Array.isArray(state.config.tabs) ? state.config.tabs : []).filter((tab) => tab?.id !== "prematch"); state.config.tabs = (Array.isArray(state.config.tabs) ? state.config.tabs : []).filter((tab) => tab?.id !== "prematch");
state.config.components = components.filter((component) => component?.type !== "hockey_prematch_panel" && component?.action_id !== "hockey_prematch_panel"); state.config.components = components.filter((component) => component?.type !== "hockey_prematch_panel" && component?.action_id !== "hockey_prematch_panel");
if (!state.config.tabs.length) state.config.tabs = [{ id: "main", label: "Игра" }]; if (!state.config.tabs.length) state.config.tabs = [{ id: "main", label: "Игра" }];
// BUILD84: "Заготовки" is a built-in runtime workspace rather than a canvas
// component. Keep it near the game tab and recreate it if an older config
// does not contain it yet.
if (!state.config.tabs.some((tab) => tab?.id === "prepared_titles")) {
const mainIndex = Math.max(0, state.config.tabs.findIndex((tab) => tab?.id === "main"));
state.config.tabs.splice(mainIndex + 1, 0, { id: "prepared_titles", label: "Заготовки" });
}
if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id; if (state.activeTab === "prematch") state.activeTab = state.config.tabs.find((tab) => tab.id === "main")?.id || state.config.tabs[0].id;
} }
@@ -4665,14 +4683,14 @@ function startCustomTooltips() {
const home = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("home")[0] || null, "home"); const home = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("home")[0] || null, "home");
const away = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("away")[0] || null, "away"); const away = hockeyPenaltySideMappingDetail(sortedPenaltyEntries("away")[0] || null, "away");
const values = { const values = {
selected_home_penalty_id: home?.penalty_id || "", active_home_penalty_id: home?.penalty_id || "",
selected_home_penalty_player_id: home?.player_id || "", active_home_penalty_player_id: home?.player_id || "",
selected_home_penalty_player_db_id: home?.player_db_id || "", active_home_penalty_player_db_id: home?.player_db_id || "",
selected_home_penalty_team_penalty: home ? (home.team_penalty ? "1" : "0") : "", active_home_penalty_team_penalty: home ? (home.team_penalty ? "1" : "0") : "",
selected_away_penalty_id: away?.penalty_id || "", active_away_penalty_id: away?.penalty_id || "",
selected_away_penalty_player_id: away?.player_id || "", active_away_penalty_player_id: away?.player_id || "",
selected_away_penalty_player_db_id: away?.player_db_id || "", active_away_penalty_player_db_id: away?.player_db_id || "",
selected_away_penalty_team_penalty: away ? (away.team_penalty ? "1" : "0") : "", active_away_penalty_team_penalty: away ? (away.team_penalty ? "1" : "0") : "",
}; };
const signature = JSON.stringify([gameId, values]); const signature = JSON.stringify([gameId, values]);
if (!force && signature === state.hockeyPenaltyMappingContextSignature) return false; if (!force && signature === state.hockeyPenaltyMappingContextSignature) return false;
@@ -6715,8 +6733,11 @@ function openTimerQuickEditor(focusActionId = "") {
board.penalties = board.penalties.filter((item) => item.id !== event.id); board.penalties = board.penalties.filter((item) => item.id !== event.id);
const side = String(event.player?.side || event.side || "").toLowerCase(); const side = String(event.player?.side || event.side || "").toLowerCase();
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length; const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
if (board.selectedEventId === event.id) board.selectedEventId = null; const clearCommonSelection = board.selectedEventId === event.id;
if (board.selectedPreviewEventIds?.[side] === event.id) board.selectedPreviewEventIds[side] = ""; const clearSideSelection = board.selectedPreviewEventIds?.[side] === event.id;
if (clearCommonSelection) board.selectedEventId = null;
if (clearSideSelection) board.selectedPreviewEventIds[side] = "";
if (clearSideSelection) hockeyClearSelectedPenaltyMappingContext(side, { clearCommon: clearCommonSelection }).catch(() => {});
persistHockeyBoard(component, board, true); persistHockeyBoard(component, board, true);
refreshHockeyBoardNodes(component); refreshHockeyBoardNodes(component);
hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {});
@@ -6735,10 +6756,13 @@ function openTimerQuickEditor(focusActionId = "") {
emitHockeyEventUpdated(component, event, "event-time"); emitHockeyEventUpdated(component, event, "event-time");
} else if (command === "remove") { } else if (command === "remove") {
board.penalties = board.penalties.filter((item) => item.id !== eventId); board.penalties = board.penalties.filter((item) => item.id !== eventId);
if (board.selectedEventId === eventId) board.selectedEventId = null; const clearCommonSelection = board.selectedEventId === eventId;
if (clearCommonSelection) board.selectedEventId = null;
{ {
const removedSide = String(event.player?.side || event.side || "").toLowerCase(); const removedSide = String(event.player?.side || event.side || "").toLowerCase();
if (board.selectedPreviewEventIds?.[removedSide] === eventId) board.selectedPreviewEventIds[removedSide] = ""; const clearSideSelection = board.selectedPreviewEventIds?.[removedSide] === eventId;
if (clearSideSelection) board.selectedPreviewEventIds[removedSide] = "";
if (clearSideSelection) hockeyClearSelectedPenaltyMappingContext(removedSide, { clearCommon: clearCommonSelection }).catch(() => {});
} }
addHockeyHistory( addHockeyHistory(
component, component,
@@ -7342,6 +7366,57 @@ function hockeyPenaltyPlayerIdentifiers(component, event) {
return { externalId, dbId }; return { externalId, dbId };
} }
async function hockeyClearSelectedPenaltyMappingContext(side, { clearCommon = false } = {}) {
const gameId = hockeyTimerSelectedGameId();
const cleanSide = ["home", "away"].includes(String(side || "").toLowerCase()) ? String(side).toLowerCase() : "";
if (!gameId || !cleanSide) return false;
const values = cleanSide === "home"
? {
selected_home_penalty_id: "",
selected_home_penalty_player_id: "",
selected_home_penalty_player_db_id: "",
selected_home_penalty_team_penalty: "",
}
: {
selected_away_penalty_id: "",
selected_away_penalty_player_id: "",
selected_away_penalty_player_db_id: "",
selected_away_penalty_team_penalty: "",
};
if (clearCommon) Object.assign(values, {
selected_penalty_id: "",
selected_penalty_player_id: "",
selected_penalty_player_db_id: "",
selected_penalty_team_penalty: "",
selected_penalty_team_id: "",
selected_penalty_infraction_id: "",
selected_penalty_preset_id: "",
selected_penalty_side: "",
selected_penalty_event_time: "",
selected_penalty_status: "",
selected_penalty_remaining_ms: "",
selected_penalty_duration_ms: "",
selected_event_id: "",
});
try {
const response = await fetch("/api/hockey/context/batch", {
method: "POST", cache: "no-store", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
values,
context: {
game_id: gameId,
device_id: currentRuntimeVmixDeviceId(),
session_token: currentRuntimeHockeySessionToken(),
},
}),
});
return response.ok;
} catch (_) {
return false;
}
}
function hockeyPenaltyIsPreviewSelected(board, event) { function hockeyPenaltyIsPreviewSelected(board, event) {
const side = String(event.player?.side || event.side || "").toLowerCase(); const side = String(event.player?.side || event.side || "").toLowerCase();
return ["home", "away"].includes(side) return ["home", "away"].includes(side)
@@ -7363,6 +7438,7 @@ async function hockeySelectPenaltyForPreview(component, event) {
: side === "away" : side === "away"
? String(getByPath(state.data, "hockey.selected_game.away.id") || getByPath(state.data, "hockey.selected_game.away.external_id") || "") ? String(getByPath(state.data, "hockey.selected_game.away.id") || getByPath(state.data, "hockey.selected_game.away.external_id") || "")
: ""; : "";
const playerIds = hockeyPenaltyPlayerIdentifiers(component, event);
const detail = { const detail = {
item_id: String(event.external_id || event.id || ""), item_id: String(event.external_id || event.id || ""),
value: String(event.external_id || event.id || ""), value: String(event.external_id || event.id || ""),
@@ -7461,7 +7537,6 @@ function hockeyPenaltyCard(component, event, runtime) {
const side = event.player?.side || event.side || "neutral"; const side = event.player?.side || event.side || "neutral";
const card = div(`hpd-team-penalty-card team-${side}`); const card = div(`hpd-team-penalty-card team-${side}`);
card.dataset.penaltyId = event.id; card.dataset.penaltyId = event.id;
card.classList.toggle("is-selected", board.selectedEventId === event.id);
card.classList.toggle("is-preview-selected", hockeyPenaltyIsPreviewSelected(board, event)); card.classList.toggle("is-preview-selected", hockeyPenaltyIsPreviewSelected(board, event));
card.classList.toggle("is-running", event.running); card.classList.toggle("is-running", event.running);
card.classList.toggle("is-finished", event.finished); card.classList.toggle("is-finished", event.finished);
@@ -7817,8 +7892,8 @@ function openHockeyPrematchButtonsEditor() {
<label>ID для Mapping<input type="text" maxlength="48" data-player-panel-field="id" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.id)}"></label> <label>ID для Mapping<input type="text" maxlength="48" data-player-panel-field="id" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.id)}"></label>
<label>Слотов<input type="number" min="1" max="6" data-player-panel-field="slots" data-player-panel-id="${escapeHtml(panel.id)}" value="${panel.slots}"></label> <label>Слотов<input type="number" min="1" max="6" data-player-panel-field="slots" data-player-panel-id="${escapeHtml(panel.id)}" value="${panel.slots}"></label>
<label>Правило<select data-player-panel-field="rule" data-player-panel-id="${escapeHtml(panel.id)}">${triggerSelectOptions([["any","Любые игроки"],["home","Только левая команда"],["away","Только правая команда"],["different_teams","Разные команды"]], panel.rule)}</select></label> <label>Правило<select data-player-panel-field="rule" data-player-panel-id="${escapeHtml(panel.id)}">${triggerSelectOptions([["any","Любые игроки"],["home","Только левая команда"],["away","Только правая команда"],["different_teams","Разные команды"]], panel.rule)}</select></label>
<label class="shortcut-inline-check"><input type="checkbox" data-player-panel-field="sync_selected_player" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.sync_selected_player ? "checked" : ""}> Главный выбранный игрок</label> <label class="shortcut-inline-check" title="Использовать первый слот как общий selected_player"><input type="checkbox" data-player-panel-field="sync_selected_player" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.sync_selected_player ? "checked" : ""}> Главный</label>
<label class="shortcut-inline-check"><input type="checkbox" data-player-panel-field="collapsed_default" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.collapsed_default ? "checked" : ""}> По умолчанию свёрнут</label> <label class="shortcut-inline-check" title="Сворачивать блок при первом открытии"><input type="checkbox" data-player-panel-field="collapsed_default" data-player-panel-id="${escapeHtml(panel.id)}" ${panel.collapsed_default ? "checked" : ""}> Свёрнут</label>
<label class="player-selection-editor-description">Описание<input type="text" maxlength="300" data-player-panel-field="description" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.description)}"></label> <label class="player-selection-editor-description">Описание<input type="text" maxlength="300" data-player-panel-field="description" data-player-panel-id="${escapeHtml(panel.id)}" value="${escapeHtml(panel.description)}"></label>
<div class="player-selection-editor-order"><button type="button" class="mini-btn" data-player-panel-up="${escapeHtml(panel.id)}" ${index <= 0 ? "disabled" : ""}>↑</button><button type="button" class="mini-btn" data-player-panel-down="${escapeHtml(panel.id)}" ${index >= draftPlayerPanels.length - 1 ? "disabled" : ""}>↓</button></div> <div class="player-selection-editor-order"><button type="button" class="mini-btn" data-player-panel-up="${escapeHtml(panel.id)}" ${index <= 0 ? "disabled" : ""}>↑</button><button type="button" class="mini-btn" data-player-panel-down="${escapeHtml(panel.id)}" ${index >= draftPlayerPanels.length - 1 ? "disabled" : ""}>↓</button></div>
<button type="button" class="icon-btn danger" data-player-panel-delete="${escapeHtml(panel.id)}" title="Удалить блок">×</button> <button type="button" class="icon-btn danger" data-player-panel-delete="${escapeHtml(panel.id)}" title="Удалить блок">×</button>
@@ -8189,6 +8264,7 @@ async function hockeySetMatchValues(patch, { refreshMapping = true } = {}) {
if (!Object.keys(values).length) return null; if (!Object.keys(values).length) return null;
const language = hockeyGameControlLanguage(); const language = hockeyGameControlLanguage();
const payload = await hockeyUpdateGameControl(gameId, "/control/values", { method: "PUT", body: JSON.stringify({ values, language }) }); const payload = await hockeyUpdateGameControl(gameId, "/control/values", { method: "PUT", body: JSON.stringify({ values, language }) });
hockeyBackupPlayerSelectionValues(gameId, payload?.values || values);
if (refreshMapping) await hockeyRefreshQuickPanelMapping(); if (refreshMapping) await hockeyRefreshQuickPanelMapping();
return payload; return payload;
} }
@@ -8469,6 +8545,43 @@ function hockeyEventCategoryLabels(language) {
: {all:"Все",goal:"Гол",penalty:"Удаление",shot:"Бросок",shootout:"Буллит",period:"Период",timeout:"Тайм-аут",goalie:"Вратарь",comment:"Комментарий",info:"Событие"}; : {all:"Все",goal:"Гол",penalty:"Удаление",shot:"Бросок",shootout:"Буллит",period:"Период",timeout:"Тайм-аут",goalie:"Вратарь",comment:"Комментарий",info:"Событие"};
} }
function hockeyPlayerSelectionStorageKey(gameId) {
return `hockey:player-selections:${String(gameId || "").trim()}`;
}
function hockeyPlayerSelectionValuesOnly(values) {
const result = {};
Object.entries(values && typeof values === "object" ? values : {}).forEach(([key, value]) => {
if (String(key).startsWith("player_select.")) result[String(key)] = String(value ?? "");
});
return result;
}
function hockeyBackupPlayerSelectionValues(gameId, values) {
const id = String(gameId || "").trim();
if (!id) return false;
const selected = hockeyPlayerSelectionValuesOnly(values);
if (!Object.keys(selected).length) return false;
try {
localStorage.setItem(hockeyPlayerSelectionStorageKey(id), JSON.stringify({ game_id: id, saved_at: Date.now(), values: selected }));
return true;
} catch (_) {
return false;
}
}
function hockeyStoredPlayerSelectionValues(gameId) {
const id = String(gameId || "").trim();
if (!id) return {};
try {
const payload = JSON.parse(localStorage.getItem(hockeyPlayerSelectionStorageKey(id)) || "{}");
if (String(payload?.game_id || "") !== id || !payload?.values || typeof payload.values !== "object") return {};
return hockeyPlayerSelectionValuesOnly(payload.values);
} catch (_) {
return {};
}
}
function hockeyPlayerSelectionPanels() { function hockeyPlayerSelectionPanels() {
return normalizePlayerSelectionPanels(state.config.player_selection_panels).filter((panel) => panel.enabled !== false); return normalizePlayerSelectionPanels(state.config.player_selection_panels).filter((panel) => panel.enabled !== false);
} }
@@ -8616,8 +8729,17 @@ async function hockeyEnsurePlayerSelectionRuntimeValues() {
const gameId = hockeyTimerSelectedGameId(); const gameId = hockeyTimerSelectedGameId();
const panels = hockeyPlayerSelectionPanels(); const panels = hockeyPlayerSelectionPanels();
if (!gameId || !panels.length || state.hockeyPlayerPanelSeedPending) return false; if (!gameId || !panels.length || state.hockeyPlayerPanelSeedPending) return false;
// Do not seed empty values before the saved GameControlState has arrived.
// On a hard refresh that race used to erase an already prepared comparison.
const control = getByPath(state.data, "hockey.game_control");
if (!control || String(control.game_id || "") !== String(gameId)) return false;
const current = hockeyMatchRuntimeValues(); const current = hockeyMatchRuntimeValues();
const patch = {}; const patch = {};
const hasServerSelectionKeys = Object.keys(current).some((key) => String(key).startsWith("player_select."));
if (!hasServerSelectionKeys) {
const backup = hockeyStoredPlayerSelectionValues(gameId);
Object.entries(backup).forEach(([key, value]) => { patch[key] = value; });
}
for (const panel of panels) { for (const panel of panels) {
const meta = { const meta = {
[`player_select.${panel.id}._label`]: panel.label, [`player_select.${panel.id}._label`]: panel.label,
@@ -8715,7 +8837,7 @@ function renderHockeyPlayerSelectionWindows() {
</div>`; </div>`;
}).join("")} }).join("")}
</div> </div>
${filled ? `<button type="button" class="hockey-player-select-clear" data-player-panel-clear-all="${escapeHtml(panel.id)}">Очистить блок</button>` : ""} ${filled ? `<div class="hockey-player-select-footer"><button type="button" class="hockey-player-select-prepared" data-player-panel-prepared="${escapeHtml(panel.id)}"> В заготовки</button><button type="button" class="hockey-player-select-clear" data-player-panel-clear-all="${escapeHtml(panel.id)}">Очистить блок</button></div>` : ""}
</div>`; </div>`;
stack.appendChild(node); stack.appendChild(node);
node.querySelector("[data-player-panel-collapse]")?.addEventListener("click", () => { node.querySelector("[data-player-panel-collapse]")?.addEventListener("click", () => {
@@ -8773,6 +8895,7 @@ function renderHockeyPlayerSelectionWindows() {
event.preventDefault(); event.stopPropagation(); event.preventDefault(); event.stopPropagation();
await hockeyClearPlayerSelectionSlot(panel, Number(button.dataset.playerPanelClearSlot || 1)); await hockeyClearPlayerSelectionSlot(panel, Number(button.dataset.playerPanelClearSlot || 1));
})); }));
node.querySelector("[data-player-panel-prepared]")?.addEventListener("click", () => hockeyOpenPreparedFromPlayerPanel(panel));
node.querySelector("[data-player-panel-clear-all]")?.addEventListener("click", async () => hockeyClearPlayerSelectionPanel(panel)); node.querySelector("[data-player-panel-clear-all]")?.addEventListener("click", async () => hockeyClearPlayerSelectionPanel(panel));
} }
setTimeout(() => hockeyEnsurePlayerSelectionRuntimeValues().catch(() => {}), 0); setTimeout(() => hockeyEnsurePlayerSelectionRuntimeValues().catch(() => {}), 0);
@@ -8789,6 +8912,389 @@ function renderHockeyRuntimeSideWindows() {
return Boolean(players || pbp); return Boolean(players || pbp);
} }
function hockeyPreparedNumber(value, fallback = 999999) {
const raw = String(value ?? "").trim();
return /^\d+$/.test(raw) ? Number(raw) : fallback;
}
function hockeyPreparedInventoryInputs() {
const inventory = state.preparedTitleInventory?.inventory || {};
return (Array.isArray(inventory.inputs) ? inventory.inputs : [])
.filter((item) => item && typeof item === "object" && Array.isArray(item.fields) && item.fields.length)
.slice()
.sort((a, b) => hockeyPreparedNumber(a.number) - hockeyPreparedNumber(b.number) || String(a.title || "").localeCompare(String(b.title || ""), "ru", { numeric: true, sensitivity: "base" }));
}
function hockeyPreparedSourceIdentity(input) {
if (!input) return "";
return String(input.key || input.number || input.title || "").trim();
}
function hockeyPreparedSelectedInput() {
const identity = String(state.preparedTitleSourceKey || "").trim();
if (!identity) return null;
return hockeyPreparedInventoryInputs().find((item) => hockeyPreparedSourceIdentity(item) === identity || String(item.key || "") === identity || String(item.number || "") === identity) || null;
}
function hockeyPreparedFieldType(field) {
const explicit = String(field?.type || "").toLowerCase();
if (["color", "colour"].includes(explicit) || /\.Color$/i.test(String(field?.name || ""))) return "color";
if (["image", "source"].includes(explicit) || /\.Source$/i.test(String(field?.name || ""))) return "image";
return "text";
}
function hockeyPreparedFieldValuesForInput(input, { preserve = true } = {}) {
const next = {};
(Array.isArray(input?.fields) ? input.fields : []).forEach((field) => {
const name = String(field?.name || "").trim();
if (!name) return;
const type = hockeyPreparedFieldType(field);
const previous = state.preparedTitleFieldValues?.[name];
next[name] = {
type,
value: preserve && previous && typeof previous === "object" ? String(previous.value ?? "") : "",
touched: Boolean(preserve && previous && typeof previous === "object" && previous.touched),
};
});
state.preparedTitleFieldValues = next;
return next;
}
function hockeyPreparedWorkspaceKey() {
return [currentRuntimeVmixDeviceId(), hockeyTimerSelectedGameId()].join("|");
}
async function hockeyLoadPreparedMappingSources(panelId = state.preparedTitlePanelId, { render = false } = {}) {
const clean = String(panelId || "").trim();
if (!clean) {
state.preparedTitleMappingSources = [];
if (render && state.activeTab === "prepared_titles") renderRuntime();
return [];
}
try {
const params = new URLSearchParams();
const deviceId = currentRuntimeVmixDeviceId();
if (deviceId) params.set("device_id", deviceId);
params.set("panel_id", clean);
const payload = await api(`/api/hockey/prepared-titles/mapping-sources?${params.toString()}`);
state.preparedTitleMappingSources = Array.isArray(payload?.items) ? payload.items : [];
if (!state.preparedTitleSourceKey && state.preparedTitleMappingSources.length === 1) {
const source = state.preparedTitleMappingSources[0];
const inventoryMatch = hockeyPreparedInventoryInputs().find((item) =>
(source.key && String(item.key || "") === String(source.key)) ||
(source.number && String(item.number || "") === String(source.number)) ||
(source.title && String(item.title || "") === String(source.title))
);
if (inventoryMatch) {
state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(inventoryMatch);
hockeyPreparedFieldValuesForInput(inventoryMatch, { preserve: false });
}
}
if (render && state.activeTab === "prepared_titles") renderRuntime();
return state.preparedTitleMappingSources;
} catch (error) {
console.error("Prepared title mapping sources error", error);
state.preparedTitleMappingSources = [];
return [];
}
}
async function hockeyLoadPreparedTitlesWorkspace({ force = false, render = true } = {}) {
if (state.preparedTitlesLoading) return false;
const key = hockeyPreparedWorkspaceKey();
if (!force && state.preparedTitlesLoadedKey === key && state.preparedTitleInventory?.inventory) {
if (state.preparedTitlePanelId && !state.preparedTitleMappingSources.length) await hockeyLoadPreparedMappingSources(state.preparedTitlePanelId);
return true;
}
state.preparedTitlesLoading = true;
if (render && state.activeTab === "prepared_titles") renderRuntime();
try {
const deviceId = currentRuntimeVmixDeviceId();
const gameId = hockeyTimerSelectedGameId();
const invParams = new URLSearchParams();
if (deviceId) invParams.set("device_id", deviceId);
const listParams = new URLSearchParams();
if (deviceId) listParams.set("device_id", deviceId);
if (gameId) listParams.set("game_id", gameId);
const [inventoryPayload, preparedPayload] = await Promise.all([
api(`/api/hockey/agents/vmix-inventory${invParams.toString() ? `?${invParams}` : ""}`),
api(`/api/hockey/prepared-titles${listParams.toString() ? `?${listParams}` : ""}`),
]);
state.preparedTitleInventory = inventoryPayload || { device_id: "", device_name: "", inventory: { inputs: [] } };
state.preparedTitles = Array.isArray(preparedPayload?.items) ? preparedPayload.items : [];
state.preparedTitlesLoadedKey = key;
const selected = hockeyPreparedSelectedInput();
if (state.preparedTitleSourceKey && !selected) {
state.preparedTitleSourceKey = "";
state.preparedTitleFieldValues = {};
} else if (selected && !Object.keys(state.preparedTitleFieldValues || {}).length) {
hockeyPreparedFieldValuesForInput(selected, { preserve: false });
}
if (state.preparedTitlePanelId) await hockeyLoadPreparedMappingSources(state.preparedTitlePanelId);
if (state.preparedTitleAutoSnapshotPending && state.preparedTitleSourceKey) {
state.preparedTitleAutoSnapshotPending = false;
setTimeout(() => hockeyApplyPreparedMappingSnapshot().catch(() => {}), 0);
}
} catch (error) {
toast(`Заготовки: ${error.message}`, true);
console.error(error);
} finally {
state.preparedTitlesLoading = false;
if (render && state.activeTab === "prepared_titles") renderRuntime();
}
return true;
}
function hockeySelectPreparedSource(input, { clearPanel = false } = {}) {
if (!input) return false;
state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input);
hockeyPreparedFieldValuesForInput(input, { preserve: false });
if (!state.preparedTitleName) state.preparedTitleName = `${String(input.title || `Input ${input.number || ""}`).trim()} · заготовка`;
if (clearPanel) {
state.preparedTitlePanelId = "";
state.preparedTitleMappingSources = [];
}
renderRuntime();
return true;
}
async function hockeyApplyPreparedMappingSnapshot() {
const input = hockeyPreparedSelectedInput();
if (!input) {
toast("Сначала выберите vMix Input", true);
return false;
}
state.preparedTitleSnapshotLoading = true;
renderRuntime();
try {
const params = new URLSearchParams();
const deviceId = currentRuntimeVmixDeviceId();
if (deviceId) params.set("device_id", deviceId);
if (input.key) params.set("input_key", input.key);
if (input.number) params.set("input_number", input.number);
if (input.title) params.set("input_title", input.title);
if (state.preparedTitlePanelId) params.set("panel_id", state.preparedTitlePanelId);
const payload = await api(`/api/hockey/prepared-titles/mapping-snapshot?${params.toString()}`);
const fields = Array.isArray(payload?.fields) ? payload.fields : [];
if (!fields.length) {
toast(state.preparedTitlePanelId ? "Для этого блока и Input нет Mapping-связей" : "Для этого Input нет Mapping-связей", true);
return false;
}
const next = { ...(state.preparedTitleFieldValues || {}) };
fields.forEach((field) => {
const name = String(field?.name || "");
if (!name || !Object.prototype.hasOwnProperty.call(next, name)) return;
next[name] = { type: hockeyPreparedFieldType(field), value: String(field?.value ?? ""), touched: true };
});
state.preparedTitleFieldValues = next;
toast(`Подставлено из Mapping: ${fields.length}`);
return true;
} catch (error) {
toast(`Mapping → заготовка: ${error.message}`, true);
return false;
} finally {
state.preparedTitleSnapshotLoading = false;
renderRuntime();
}
}
async function hockeyCreatePreparedTitle() {
const input = hockeyPreparedSelectedInput();
if (!input) {
toast("Сначала выберите исходный vMix Input", true);
return false;
}
const name = String(state.preparedTitleName || "").trim() || `${input.title || `Input ${input.number || ""}`} · заготовка`;
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 api("/api/hockey/prepared-titles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
device_id: currentRuntimeVmixDeviceId(),
session_token: currentRuntimeHockeySessionToken(),
source_input_key: String(input.key || ""),
source_input_number: String(input.number || ""),
source_input_title: String(input.title || ""),
source_kind: state.preparedTitlePanelId ? "player_selection" : "manual",
source_ref: state.preparedTitlePanelId || "",
field_values: fieldValues,
}),
});
toast(`Заготовка сохранена${payload?.clone_input?.number ? ` · Input #${payload.clone_input.number}` : ""}`);
state.preparedTitleName = "";
state.preparedTitlePanelId = "";
state.preparedTitleMappingSources = [];
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 api(`/api/hockey/prepared-titles/${encodeURIComponent(id)}/preview`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken() }),
});
toast("Заготовка отправлена в Preview");
return true;
} catch (error) {
toast(`Preview: ${error.message}`, true);
return false;
}
}
async function hockeyDeletePreparedTitle(id) {
if (!confirm("Убрать заготовку из списка? Сам vMix Input останется в проекте.")) return false;
try {
await api(`/api/hockey/prepared-titles/${encodeURIComponent(id)}`, { method: "DELETE" });
state.preparedTitles = state.preparedTitles.filter((item) => String(item.id) !== String(id));
renderRuntime();
return true;
} catch (error) {
toast(`Удаление заготовки: ${error.message}`, true);
return false;
}
}
function hockeyOpenPreparedFromPlayerPanel(panel) {
if (!panel) return false;
state.preparedTitlePanelId = String(panel.id || "");
state.preparedTitleName = `${String(panel.label || "Выбор игроков")} · заготовка`;
state.preparedTitleSourceKey = "";
state.preparedTitleFieldValues = {};
state.preparedTitleMappingSources = [];
state.preparedTitleAutoSnapshotPending = true;
state.preparedTitlesLoadedKey = "";
activateRuntimeTab("prepared_titles", { source: "player-selection" });
setTimeout(() => hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}), 0);
return true;
}
function renderHockeyPreparedTitlesWorkspace() {
const root = document.createElement("section");
root.className = "hockey-prepared-workspace";
const inputs = hockeyPreparedInventoryInputs();
const search = String(state.preparedTitleSearch || "").trim().toLowerCase();
const filtered = inputs.filter((item) => !search || `${item.number || ""} ${item.title || ""} ${item.type || ""}`.toLowerCase().includes(search));
const selected = hockeyPreparedSelectedInput();
const fields = selected && Array.isArray(selected.fields)
? selected.fields.slice().sort((a, b) => String(a?.name || "").localeCompare(String(b?.name || ""), "ru", { numeric: true, sensitivity: "base" }))
: [];
const panel = state.preparedTitlePanelId ? hockeyPlayerSelectionPanels().find((item) => item.id === state.preparedTitlePanelId) : null;
const mappingSources = Array.isArray(state.preparedTitleMappingSources) ? state.preparedTitleMappingSources : [];
root.innerHTML = `
<div class="hockey-prepared-head">
<div><span>VMIX PRESETS</span><strong>Заготовки</strong><small>${escapeHtml(state.preparedTitleInventory?.device_name || "Agent не выбран")} · матч ${escapeHtml(hockeyTimerSelectedGameId() || "—")}</small></div>
<button type="button" data-prepared-refresh ${state.preparedTitlesLoading ? "disabled" : ""}>↻ Обновить vMix</button>
</div>
${panel ? `<div class="hockey-prepared-context"><div><span>БЛОК ИГРОКОВ</span><strong>${escapeHtml(panel.label)}</strong><small>Выберите титр, связанный с <code>player_select.${escapeHtml(panel.id)}.*</code>, затем подставьте текущие данные.</small></div><button type="button" data-prepared-clear-context>×</button></div>` : ""}
<div class="hockey-prepared-layout">
<aside class="hockey-prepared-inputs">
<label class="hockey-prepared-search"><span>Поиск Input</span><input type="search" value="${escapeHtml(state.preparedTitleSearch)}" placeholder="номер или название"></label>
${mappingSources.length ? `<div class="hockey-prepared-recommended"><span>Подходят для ${escapeHtml(panel?.label || "блока")}</span>${mappingSources.map((item) => `<button type="button" data-prepared-mapping-source="${escapeHtml(String(item.key || item.number || item.title || ""))}"><b>#${escapeHtml(item.number || "—")}</b><span>${escapeHtml(item.title || "Input")}</span><small>${escapeHtml(item.mapped_fields || 0)} связей</small></button>`).join("")}</div>` : ""}
<div class="hockey-prepared-input-list">${filtered.map((item) => {
const identity = hockeyPreparedSourceIdentity(item);
const active = selected && hockeyPreparedSourceIdentity(selected) === identity;
return `<button type="button" class="${active ? "active" : ""}" data-prepared-input="${escapeHtml(identity)}"><b>#${escapeHtml(item.number || "—")}</b><span>${escapeHtml(item.title || "Без названия")}</span><small>${escapeHtml(item.type || "")}${Array.isArray(item.fields) ? ` · ${item.fields.length}` : ""}</small></button>`;
}).join("") || `<div class="hockey-prepared-empty">Нет Inputs с редактируемыми элементами</div>`}</div>
</aside>
<main class="hockey-prepared-editor">
${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>
<label class="hockey-prepared-name"><span>Название заготовки</span><input type="text" data-prepared-name value="${escapeHtml(state.preparedTitleName)}" placeholder="Например: Сравнение вратарей · студия"></label>
<div class="hockey-prepared-fields">${fields.map((field) => {
const name = String(field?.name || "");
const type = hockeyPreparedFieldType(field);
const value = String(state.preparedTitleFieldValues?.[name]?.value ?? "");
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>`;
}).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-editor-empty"><b>Выберите vMix Input</b><span>Справа появятся все его .Text / .Source / .Color элементы для ручной заготовки.</span></div>`}
</main>
<aside class="hockey-prepared-saved">
<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>
</aside>
</div>`;
root.querySelector("[data-prepared-refresh]")?.addEventListener("click", () => {
state.preparedTitlesLoadedKey = "";
hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {});
});
root.querySelector(".hockey-prepared-search input")?.addEventListener("input", (event) => {
state.preparedTitleSearch = event.target.value || "";
renderRuntime();
setTimeout(() => el.runtimeStage.querySelector(".hockey-prepared-search input")?.focus({ preventScroll: true }), 0);
});
root.querySelectorAll("[data-prepared-input]").forEach((button) => button.addEventListener("click", () => {
const identity = button.dataset.preparedInput || "";
const input = inputs.find((item) => hockeyPreparedSourceIdentity(item) === identity);
if (input) hockeySelectPreparedSource(input);
}));
root.querySelectorAll("[data-prepared-mapping-source]").forEach((button) => button.addEventListener("click", async () => {
const identity = button.dataset.preparedMappingSource || "";
const source = mappingSources.find((item) => String(item.key || item.number || item.title || "") === identity);
const input = source ? inputs.find((item) =>
(source.key && String(item.key || "") === String(source.key)) ||
(source.number && String(item.number || "") === String(source.number)) ||
(source.title && String(item.title || "") === String(source.title))
) : null;
if (!input) return;
state.preparedTitleSourceKey = hockeyPreparedSourceIdentity(input);
hockeyPreparedFieldValuesForInput(input, { preserve: false });
renderRuntime();
await hockeyApplyPreparedMappingSnapshot();
}));
root.querySelector("[data-prepared-clear-context]")?.addEventListener("click", () => {
state.preparedTitlePanelId = "";
state.preparedTitleMappingSources = [];
renderRuntime();
});
root.querySelector("[data-prepared-apply-mapping]")?.addEventListener("click", () => hockeyApplyPreparedMappingSnapshot());
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 || "";
if (!name) return;
state.preparedTitleFieldValues[name] = { type: inputNode.dataset.preparedFieldType || "text", value: inputNode.value || "", touched: true };
if ((inputNode.dataset.preparedFieldType || "") === "color") {
const picker = root.querySelector(`[data-prepared-color="${CSS.escape(name)}"]`);
if (picker && /^#[0-9A-Fa-f]{6}$/.test(inputNode.value || "")) picker.value = inputNode.value;
}
}));
root.querySelectorAll("[data-prepared-color]").forEach((picker) => picker.addEventListener("input", () => {
const name = picker.dataset.preparedColor || "";
const text = root.querySelector(`[data-prepared-field="${CSS.escape(name)}"]`);
if (text) text.value = picker.value;
state.preparedTitleFieldValues[name] = { type: "color", value: picker.value, touched: true };
}));
root.querySelector("[data-prepared-save]")?.addEventListener("click", () => hockeyCreatePreparedTitle());
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;
}
function hockeyEventCategory(item) { function hockeyEventCategory(item) {
const declared = String(item?.category || "").trim().toLowerCase(); const declared = String(item?.category || "").trim().toLowerCase();
// Prefer the explicit Stat2TV code (`pn`, `go`, ...). `type` can contain a // Prefer the explicit Stat2TV code (`pn`, `go`, ...). `type` can contain a
@@ -10588,6 +11094,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
{ hockey: { game_control: payload } }, { hockey: { game_control: payload } },
{ render } { render }
); );
hockeyBackupPlayerSelectionValues(gameId, payload?.values || {});
if (!previousControl || hockeyStrengthMappingSignature(previousControl) !== hockeyStrengthMappingSignature(payload)) { if (!previousControl || hockeyStrengthMappingSignature(previousControl) !== hockeyStrengthMappingSignature(payload)) {
hockeyRefreshVmixMappingForStrength(gameId, previousControl, payload).catch(() => {}); hockeyRefreshVmixMappingForStrength(gameId, previousControl, payload).catch(() => {});
} }
@@ -11832,6 +12339,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
} }
function runtimeHasFixedHockeyCanvas() { function runtimeHasFixedHockeyCanvas() {
if (state.activeTab === "prepared_titles") return true;
return state.config.components.some((component) => return state.config.components.some((component) =>
[ [
"hockey_penalty_dashboard", "hockey_penalty_dashboard",
@@ -11936,11 +12444,18 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
el.runtimeStage.innerHTML = ""; el.runtimeStage.innerHTML = "";
state.timerNodes = new Map(); state.timerNodes = new Map();
state.hockeyPenaltyBoardNodes = new Map(); state.hockeyPenaltyBoardNodes = new Map();
if (state.activeTab === "prepared_titles") {
el.runtimeStage.appendChild(renderHockeyPreparedTitlesWorkspace());
if (state.preparedTitlesLoadedKey !== hockeyPreparedWorkspaceKey() && !state.preparedTitlesLoading) {
setTimeout(() => hockeyLoadPreparedTitlesWorkspace({ force: true }).catch(() => {}), 0);
}
} else {
state.config.components.filter((component) => !component.hidden && component.props?.runtimeHidden !== true && state.runtimeVisibility[component.action_id] !== false && isEffectivelyOnActiveTab(component)).sort((a, b) => Number(a.z) - Number(b.z)).forEach((component) => { state.config.components.filter((component) => !component.hidden && component.props?.runtimeHidden !== true && state.runtimeVisibility[component.action_id] !== false && isEffectivelyOnActiveTab(component)).sort((a, b) => Number(a.z) - Number(b.z)).forEach((component) => {
const wrapper = document.createElement("div"); wrapper.className = "runtime-component"; Object.assign(wrapper.style, { left: `${component.x}px`, top: `${component.y}px`, width: `${component.w}px`, height: `${component.h}px`, zIndex: String(component.z) }); wrapper.appendChild(renderComponent(component, true)); el.runtimeStage.appendChild(wrapper); const wrapper = document.createElement("div"); wrapper.className = "runtime-component"; Object.assign(wrapper.style, { left: `${component.x}px`, top: `${component.y}px`, width: `${component.w}px`, height: `${component.h}px`, zIndex: String(component.z) }); wrapper.appendChild(renderComponent(component, true)); el.runtimeStage.appendChild(wrapper);
}); });
renderHockeyRuntimeSideWindows(); renderHockeyRuntimeSideWindows();
renderHockeyQuickCommandDock(); renderHockeyQuickCommandDock();
}
Object.entries(shootoutRosterScroll).forEach(([side, top]) => { Object.entries(shootoutRosterScroll).forEach(([side, top]) => {
const list = el.runtimeStage.querySelector(`.hso-roster.side-${side} .hso-player-list`); const list = el.runtimeStage.querySelector(`.hso-roster.side-${side} .hso-player-list`);
if (list) list.scrollTop = top; if (list) list.scrollTop = top;

View File

@@ -7223,6 +7223,15 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
.hpd-team-penalty-card.is-preview-selected { .hpd-team-penalty-card.is-preview-selected {
outline: 2px solid rgba(255, 255, 255, .72); outline: 2px solid rgba(255, 255, 255, .72);
outline-offset: -2px; outline-offset: -2px;
box-shadow: 0 0 0 3px rgba(255,255,255,.06);
}
.hpd-team-penalty-card.team-home.is-preview-selected {
outline-color: color-mix(in srgb,var(--hpd-home) 82%,#fff);
box-shadow: 0 0 0 3px color-mix(in srgb,var(--hpd-home) 18%,transparent);
}
.hpd-team-penalty-card.team-away.is-preview-selected {
outline-color: color-mix(in srgb,var(--hpd-away) 82%,#fff);
box-shadow: 0 0 0 3px color-mix(in srgb,var(--hpd-away) 18%,transparent);
} }
.hpd-team-card-identifiers { .hpd-team-card-identifiers {
display: grid; display: grid;
@@ -7433,3 +7442,242 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
user-select:none; user-select:none;
} }
.hockey-player-select-slot:hover .hockey-player-select-drag { color:#9ab2c6; } .hockey-player-select-slot:hover .hockey-player-select-drag { color:#9ab2c6; }
/* BUILD84 — compact, single-line player-block editor. */
.player-selection-editor.prematch-editor-section .player-selection-editor-list { gap:4px; }
.player-selection-editor-row {
grid-template-columns:24px 66px minmax(150px,1fr) minmax(135px,.9fr) 52px minmax(135px,.9fr) 84px 82px minmax(150px,1fr) 56px 30px;
gap:5px;
align-items:center;
min-height:48px;
padding:4px 5px;
border-radius:8px;
}
.player-selection-editor-row label { gap:2px; font-size:6.5px; line-height:1.05; }
.player-selection-editor-row input,
.player-selection-editor-row select { height:26px; padding-top:3px; padding-bottom:3px; font-size:9px; }
.player-selection-editor-row .shortcut-inline-check {
align-self:center;
justify-self:start;
gap:4px;
white-space:nowrap;
font-size:7px;
}
.player-selection-editor-row .shortcut-inline-check input { width:16px; height:16px; padding:0; }
.player-selection-editor-description { min-width:120px; }
.player-selection-editor-order { align-items:center; }
.player-selection-editor-order .mini-btn,
.player-selection-editor-row > .icon-btn { width:26px; min-width:26px; height:26px; padding:0; }
@media (max-width:1450px) {
.player-selection-editor-row {
grid-template-columns:24px 64px minmax(130px,1fr) minmax(120px,.9fr) 50px minmax(120px,.9fr) 80px 78px minmax(130px,1fr) 54px 28px;
}
}
/* BUILD84 — prepared vMix title workspace + player block handoff. */
.hockey-player-select-footer {
display:flex;
justify-content:space-between;
align-items:center;
gap:6px;
margin-top:7px;
}
.hockey-player-select-prepared {
padding:5px 8px;
color:#071a1d;
border:1px solid #54e5c4;
border-radius:7px;
background:#48dfbd;
font-size:7px;
font-weight:950;
cursor:pointer;
}
.hockey-player-select-prepared:hover { filter:brightness(1.08); }
.hockey-player-select-footer .hockey-player-select-clear { margin-left:auto; }
.hockey-prepared-workspace {
position:absolute;
inset:0;
display:grid;
grid-template-rows:auto auto minmax(0,1fr);
gap:9px;
padding:14px;
box-sizing:border-box;
color:#dcecff;
background:linear-gradient(180deg,#0a1522 0%,#09131f 100%);
overflow:hidden;
}
.hockey-prepared-head,
.hockey-prepared-context,
.hockey-prepared-source,
.hockey-prepared-savebar,
.hockey-prepared-saved-head {
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
}
.hockey-prepared-head {
min-height:52px;
padding:0 12px;
border:1px solid #263f56;
border-radius:11px;
background:#0c1b2a;
}
.hockey-prepared-head > div,
.hockey-prepared-context > div,
.hockey-prepared-source > div { min-width:0; display:grid; gap:2px; }
.hockey-prepared-head span,
.hockey-prepared-context span,
.hockey-prepared-source span,
.hockey-prepared-saved-head span {
color:#56e3c1;
font-size:7px;
font-weight:950;
letter-spacing:.08em;
}
.hockey-prepared-head strong { font-size:16px; line-height:1; }
.hockey-prepared-head small,
.hockey-prepared-context small,
.hockey-prepared-source small { color:#7891a8; font-size:8px; }
.hockey-prepared-head button,
.hockey-prepared-context button,
.hockey-prepared-source button,
.hockey-prepared-savebar button,
.hockey-prepared-saved article button {
min-height:29px;
padding:0 10px;
border:1px solid #31516a;
border-radius:7px;
color:#cfe3f3;
background:#10263a;
font-size:8px;
font-weight:900;
cursor:pointer;
}
.hockey-prepared-head button:hover,
.hockey-prepared-source button:hover,
.hockey-prepared-saved article button:hover { border-color:#54dcbf; color:#fff; }
.hockey-prepared-head button:disabled,
.hockey-prepared-source button:disabled,
.hockey-prepared-savebar button:disabled { opacity:.5; cursor:default; }
.hockey-prepared-context {
min-height:42px;
padding:7px 10px;
border:1px solid #31586a;
border-radius:10px;
background:#0d2431;
}
.hockey-prepared-context code { color:#86dfca; }
.hockey-prepared-context > button { width:29px; padding:0; color:#8ba3b8; }
.hockey-prepared-layout {
min-height:0;
display:grid;
grid-template-columns:310px minmax(0,1fr) 310px;
gap:9px;
}
.hockey-prepared-inputs,
.hockey-prepared-editor,
.hockey-prepared-saved {
min-width:0;
min-height:0;
border:1px solid #263f56;
border-radius:11px;
background:#0b1927;
overflow:hidden;
}
.hockey-prepared-inputs { display:grid; grid-template-rows:auto auto minmax(0,1fr); padding:9px; gap:7px; }
.hockey-prepared-search { display:grid; gap:4px; color:#7f98af; font-size:7px; font-weight:900; }
.hockey-prepared-search input,
.hockey-prepared-name input,
.hockey-prepared-field input[type="text"] {
width:100%;
min-width:0;
height:30px;
box-sizing:border-box;
padding:0 9px;
border:1px solid #2b465e;
border-radius:7px;
outline:0;
color:#e5f2ff;
background:#0c2133;
font-size:9px;
font-weight:700;
font-family:inherit;
}
.hockey-prepared-search input:focus,
.hockey-prepared-name input:focus,
.hockey-prepared-field input[type="text"]:focus { border-color:#4ed9bd; box-shadow:0 0 0 1px rgba(78,217,189,.18); }
.hockey-prepared-input-list,
.hockey-prepared-saved-list { min-height:0; overflow:auto; display:grid; align-content:start; gap:4px; padding-right:2px; }
.hockey-prepared-input-list > button,
.hockey-prepared-recommended button {
width:100%;
display:grid;
grid-template-columns:42px minmax(0,1fr) auto;
align-items:center;
gap:7px;
min-height:37px;
padding:5px 7px;
text-align:left;
border:1px solid #243d53;
border-radius:7px;
color:#a8bdd0;
background:#0d2031;
cursor:pointer;
}
.hockey-prepared-input-list > button:hover,
.hockey-prepared-input-list > button.active,
.hockey-prepared-recommended button:hover { border-color:#3e826f; background:#102b3c; }
.hockey-prepared-input-list > button.active { box-shadow:inset 3px 0 0 #4dddbc; }
.hockey-prepared-input-list b,
.hockey-prepared-recommended b { color:#61e0c2; font:900 8px "Roboto Mono",Consolas,monospace; }
.hockey-prepared-input-list span,
.hockey-prepared-recommended span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; font-weight:900; color:#e0effb; }
.hockey-prepared-input-list small,
.hockey-prepared-recommended small { color:#6f879d; font-size:7px; white-space:nowrap; }
.hockey-prepared-recommended { display:grid; gap:4px; padding:6px; border:1px solid #2d554d; border-radius:8px; background:#0d2728; }
.hockey-prepared-recommended > span { color:#70bea9; font-size:7px; font-weight:900; }
.hockey-prepared-editor { display:grid; grid-template-rows:auto auto minmax(0,1fr) auto; gap:8px; padding:9px; }
.hockey-prepared-source { padding:7px 8px; border:1px solid #29475e; border-radius:8px; background:#0d2031; }
.hockey-prepared-source strong { font-size:10px; }
.hockey-prepared-name { display:grid; gap:4px; color:#8099ae; font-size:7px; font-weight:900; }
.hockey-prepared-fields { min-height:0; overflow:auto; display:grid; align-content:start; gap:4px; padding-right:3px; }
.hockey-prepared-field {
min-height:42px;
display:grid;
grid-template-columns:minmax(160px,.8fr) minmax(220px,1.2fr);
align-items:center;
gap:10px;
padding:5px 7px;
border:1px solid #223b51;
border-radius:7px;
background:#0c1d2d;
}
.hockey-prepared-field > span { min-width:0; display:flex; align-items:baseline; gap:7px; }
.hockey-prepared-field > span b { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font:850 8px "Roboto Mono",Consolas,monospace; color:#cfe0ee; }
.hockey-prepared-field > span small { color:#52768e; font-size:6px; text-transform:uppercase; }
.hockey-prepared-field > div { min-width:0; display:flex; align-items:center; gap:5px; }
.hockey-prepared-field input[type="color"] { width:32px; min-width:32px; height:30px; padding:2px; border:1px solid #2b465e; border-radius:6px; background:#0c2133; }
.hockey-prepared-savebar { padding-top:7px; border-top:1px solid #21394d; }
.hockey-prepared-savebar small { max-width:520px; color:#70899f; font-size:7px; line-height:1.35; }
.hockey-prepared-savebar button { min-width:155px; color:#071a1d; border-color:#54e5c4; background:#48dfbd; }
.hockey-prepared-saved { display:grid; grid-template-rows:auto minmax(0,1fr); padding:9px; gap:7px; }
.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 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 > 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 strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:9px; color:#e1eef8; }
.hockey-prepared-saved article small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#71899e; font-size:7px; }
.hockey-prepared-saved article > div:last-child { display:flex; gap:4px; }
.hockey-prepared-saved article button { min-height:25px; padding:0 7px; font-size:7px; }
.hockey-prepared-saved article button.danger { width:25px; min-width:25px; padding:0; color:#ff7d91; border-color:#593646; }
.hockey-prepared-empty,
.hockey-prepared-editor-empty { display:grid; place-items:center; align-content:center; gap:5px; min-height:90px; padding:16px; text-align:center; color:#637e94; font-size:8px; }
.hockey-prepared-editor-empty { height:100%; }
.hockey-prepared-editor-empty b { color:#b9cfdf; font-size:13px; }
@media (max-width:1100px) {
.hockey-prepared-layout { grid-template-columns:250px minmax(0,1fr) 250px; }
.hockey-prepared-field { grid-template-columns:1fr; gap:4px; }
}