diff --git a/agent/README.md b/agent/README.md index 844b455..9aa2494 100644 --- a/agent/README.md +++ b/agent/README.md @@ -74,3 +74,11 @@ Fingerprint проекта рассчитывается по структуре - Local vMix requests use keep-alive HTTP connections and a bounded parallel worker pool. - Empty `Value` is forwarded so SetText can clear stale title fields. - Fully backward compatible server fallback: older Agents continue to work in legacy mode. + + +## 1.5.0 + +- Real vMix Overlay 1-4 tally from the local XML API. +- Active overlays are resolved to Input number, GUID/key and title. +- Tally changes are sent to Hockey Server as `vmix.overlay_state`. +- This lets the web UI highlight the actual title currently on air, including titles prepared via `PreviewInput`. diff --git a/agent/agent.exe b/agent/agent.exe index 7a6358b..8769ab2 100644 Binary files a/agent/agent.exe and b/agent/agent.exe differ diff --git a/agent/core.go b/agent/core.go index c1c04f2..ec8dfc6 100644 --- a/agent/core.go +++ b/agent/core.go @@ -27,10 +27,12 @@ import ( ) const ( - agentVersion = "1.4.0" - protocolVersion = 1 - websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" - heartbeatInterval = 8 * time.Second + agentVersion = "1.5.0" + protocolVersion = 1 + websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + heartbeatInterval = 8 * time.Second + overlayPollInterval = 400 * time.Millisecond + overlayResendInterval = 2 * time.Second ) type Config struct { @@ -75,8 +77,22 @@ type VmixInventory struct { } type vmixAPIXML struct { - Version string `xml:"version"` - Inputs []vmixInputXML `xml:"inputs>input"` + Version string `xml:"version"` + Inputs []vmixInputXML `xml:"inputs>input"` + Overlays []vmixOverlayXML `xml:"overlays>overlay"` +} + +type vmixOverlayXML struct { + Number string `xml:"number,attr"` + Key string `xml:"key,attr"` + Value string `xml:",chardata"` +} + +type VmixOverlayTally struct { + Input string `json:"input"` + InputNumber string `json:"input_number,omitempty"` + InputKey string `json:"input_key,omitempty"` + InputTitle string `json:"input_title,omitempty"` } type vmixInputXML struct { @@ -151,6 +167,7 @@ func (a *Agent) Start() { a.logf("Agent %s started", agentVersion) a.logf("Device: %s (%s)", a.cfg.DeviceName, short(a.cfg.DeviceID)) go a.vmixMonitor() + go a.vmixOverlayMonitor() go a.connectionLoop() } @@ -295,6 +312,130 @@ func (a *Agent) vmixMonitor() { } } +func (a *Agent) vmixOverlayMonitor() { + ticker := time.NewTicker(overlayPollInterval) + defer ticker.Stop() + lastHash := "" + var lastWS *wsConn + lastSentAt := time.Time{} + for { + a.mu.RLock() + vmixURL := a.cfg.VmixURL + ws := a.currentWS + a.mu.RUnlock() + + overlays, connected := scanVmixOverlayTally(vmixURL) + encoded, _ := json.Marshal(overlays) + stateHash := fmt.Sprintf("%t:%s", connected, string(encoded)) + force := ws != nil && ws != lastWS + periodic := ws != nil && (lastSentAt.IsZero() || time.Since(lastSentAt) >= overlayResendInterval) + changed := stateHash != lastHash + if ws != nil && (force || periodic || changed) { + payload := map[string]any{ + "type": "vmix.overlay_state", + "device_id": a.cfg.DeviceID, + "vmix_connected": connected, + "overlays": overlays, + "observed_at": time.Now().UTC().Format(time.RFC3339Nano), + } + if err := ws.WriteJSON(payload); err == nil { + lastHash = stateHash + lastWS = ws + lastSentAt = time.Now() + } + } + if ws == nil { + lastWS = nil + } + + select { + case <-a.stop: + return + case <-ticker.C: + } + } +} + +func scanVmixOverlayTally(base string) (map[string]VmixOverlayTally, bool) { + result := map[string]VmixOverlayTally{} + target := strings.TrimSpace(base) + if target == "" { + return result, false + } + resp, err := vmixHTTPClient.Get(target) + if err != nil { + return result, false + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return result, false + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024*1024)) + if err != nil { + return result, false + } + var api vmixAPIXML + if err := xml.Unmarshal(body, &api); err != nil { + return result, true + } + byNumber := map[string]vmixInputXML{} + byKey := map[string]vmixInputXML{} + for _, input := range api.Inputs { + number := strings.TrimSpace(input.Number) + key := strings.TrimSpace(input.Key) + if number != "" { + byNumber[number] = input + } + if key != "" { + byKey[strings.ToLower(key)] = input + } + } + for _, overlay := range api.Overlays { + layer := strings.TrimSpace(overlay.Number) + if layer != "1" && layer != "2" && layer != "3" && layer != "4" { + continue + } + raw := strings.TrimSpace(overlay.Value) + rawKey := strings.TrimSpace(overlay.Key) + if raw == "" && rawKey == "" { + continue + } + var matched vmixInputXML + found := false + if raw != "" { + if item, ok := byNumber[raw]; ok { + matched, found = item, true + } else if item, ok := byKey[strings.ToLower(raw)]; ok { + matched, found = item, true + } + } + if !found && rawKey != "" { + if item, ok := byKey[strings.ToLower(rawKey)]; ok { + matched, found = item, true + } + } + entry := VmixOverlayTally{Input: raw} + if entry.Input == "" { + entry.Input = rawKey + } + if found { + entry.InputNumber = strings.TrimSpace(matched.Number) + entry.InputKey = strings.TrimSpace(matched.Key) + entry.InputTitle = strings.TrimSpace(matched.Title) + if entry.Input == "" { + entry.Input = entry.InputNumber + } + } else { + entry.InputKey = rawKey + if _, ok := byNumber[raw]; ok || raw != "" { + entry.InputNumber = raw + } + } + result[layer] = entry + } + return result, true +} + func suffixVersion(v string) string { if v == "" { return "" diff --git a/agent/scanner_test.go b/agent/scanner_test.go index d584ad6..71653cf 100644 --- a/agent/scanner_test.go +++ b/agent/scanner_test.go @@ -1,17 +1,57 @@ package main import ( - "net/http" - "net/http/httptest" - "testing" + "net/http" + "net/http/httptest" + "testing" ) func TestScanVmixInventory(t *testing.T) { - xml := `29.0.0.48SKAc:\\logo.png` - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/xml"); _, _ = w.Write([]byte(xml)) })) - defer srv.Close() - status, inv := scanVmix(srv.URL) - if !status.Connected || status.Version != "29.0.0.48" { t.Fatalf("bad status: %+v", status) } - if inv.InputCount != 1 || inv.FieldCount != 2 || len(inv.Fingerprint) != 64 { t.Fatalf("bad inventory: %+v", inv) } - if inv.Inputs[0].Title != "Scorebug" || len(inv.Inputs[0].Fields) != 2 { t.Fatalf("bad input: %+v", inv.Inputs[0]) } + xml := `29.0.0.48SKAc:\\logo.png` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + _, _ = w.Write([]byte(xml)) + })) + defer srv.Close() + status, inv := scanVmix(srv.URL) + if !status.Connected || status.Version != "29.0.0.48" { + t.Fatalf("bad status: %+v", status) + } + if inv.InputCount != 1 || inv.FieldCount != 2 || len(inv.Fingerprint) != 64 { + t.Fatalf("bad inventory: %+v", inv) + } + if inv.Inputs[0].Title != "Scorebug" || len(inv.Inputs[0].Fields) != 2 { + t.Fatalf("bad input: %+v", inv.Inputs[0]) + } +} + +func TestScanVmixOverlayTallyResolvesInputIdentity(t *testing.T) { + xml := `` + + `GEO` + + `SCORE` + + `` + + `5` + + `` + + `` + + `16` + + `` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + _, _ = w.Write([]byte(xml)) + })) + defer srv.Close() + + overlays, connected := scanVmixOverlayTally(srv.URL) + if !connected { + t.Fatal("expected vMix tally connection") + } + if got := overlays["4"]; got.InputNumber != "16" || got.InputKey != "geo-guid" || got.InputTitle != "GEO" { + t.Fatalf("bad overlay 4 tally: %+v", got) + } + if got := overlays["1"]; got.InputNumber != "5" || got.InputKey != "score-guid" { + t.Fatalf("bad overlay 1 tally: %+v", got) + } + if _, ok := overlays["2"]; ok { + t.Fatalf("empty overlay 2 must not be active: %+v", overlays["2"]) + } } diff --git a/app.py b/app.py index 3946efa..5704087 100644 --- a/app.py +++ b/app.py @@ -29,7 +29,8 @@ from ui_builder import install_ui_builder from khl_site.khl_data_center import APP as khl_site_app BASE_DIR = Path(__file__).resolve().parent -BUILD_VERSION = "2026.08.26.1" +BUILD_VERSION = "2026.08.26.2" +# compatibility: BUILD_VERSION = "2026.08.26.1" # compatibility: BUILD_VERSION = "2026.08.25.1" # compatibility: BUILD_VERSION = "2026.08.24.15" # compatibility: BUILD_VERSION = "2026.08.24.14" diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index 94e6265..47f6e75 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -257,8 +257,12 @@ class VmixAgentHub: self._auto_refresh_task: asyncio.Task[None] | None = None self._auto_refresh_next: dict[tuple[str, str], float] = {} self._mapping_value_cache: dict[tuple[str, str, int, str, str, str, str], str] = {} - # BUILD83: server-side mirror of ACK-confirmed runtime Overlay state. + # BUILD83: server-side mirror of runtime Overlay state. self._runtime_overlay_state: dict[str, dict[str, dict[str, str]]] = {} + # BUILD115: true when Overlay state comes from the Agent reading the actual + # local vMix XML API rather than from optimistic command history. + self._runtime_overlay_authoritative: dict[str, bool] = {} + self._runtime_overlay_observed_at: dict[str, str] = {} self._auto_refresh_last_error = "" @staticmethod @@ -292,6 +296,11 @@ class VmixAgentHub: parsed = self._runtime_overlay_command(command.get("Function") or command.get("function")) if parsed is None: return + # A command only tells us what we asked vMix to do. Until the Agent reads + # the XML API again, this local mirror is optimistic rather than a tally. + if not hasattr(self, "_runtime_overlay_authoritative"): + self._runtime_overlay_authoritative = {} + self._runtime_overlay_authoritative[device_id] = False layer, action = parsed device_state = self._runtime_overlay_state.setdefault(device_id, {}) if layer == "all": @@ -325,7 +334,15 @@ class VmixAgentHub: def _runtime_overlay_payload(self, device_id: str) -> dict[str, Any]: source = self._runtime_overlay_state.get(device_id, {}) overlays = {str(layer): dict(value) for layer, value in source.items() if str(layer) in {"1", "2", "3", "4"}} - return {"device_id": device_id, "overlays": overlays} + authoritative = bool(getattr(self, "_runtime_overlay_authoritative", {}).get(device_id, False)) + observed = str(getattr(self, "_runtime_overlay_observed_at", {}).get(device_id) or "") + return { + "device_id": device_id, + "overlays": overlays, + "authoritative": authoritative, + "source": "agent_tally" if authoritative else "command_mirror", + "observed_at": observed, + } def runtime_overlay_state_for_user(self, user: HockeyUser, *, device_id: str = "") -> dict[str, Any]: requested = self.normalise_device_id(device_id) if str(device_id or "").strip() else "" @@ -650,6 +667,8 @@ class VmixAgentHub: current = self._live.get(device_id) if current is not None and current.websocket is websocket: self._live.pop(device_id, None) + self._runtime_overlay_state.pop(device_id, None) + self._runtime_overlay_authoritative[device_id] = False with self.database.session() as session: row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id)) if row is not None: @@ -667,6 +686,7 @@ class VmixAgentHub: row.vmix_connected = bool(vmix.get("connected")) if not row.vmix_connected: self._runtime_overlay_state.pop(device_id, None) + self._runtime_overlay_authoritative[device_id] = False if vmix.get("version") is not None: row.vmix_version = str(vmix.get("version") or "")[:64] if vmix.get("url") is not None: @@ -674,6 +694,46 @@ class VmixAgentHub: if message.get("error") is not None: row.last_error = str(message.get("error") or "")[:1000] + async def receive_overlay_state(self, device_id: str, message: dict[str, Any]) -> None: + raw_overlays = message.get("overlays") if isinstance(message.get("overlays"), dict) else {} + next_state: dict[str, dict[str, str]] = {} + for layer in ("1", "2", "3", "4"): + raw = raw_overlays.get(layer) + if raw is None: + raw = raw_overlays.get(int(layer)) + if not isinstance(raw, dict): + continue + entry = { + "input": str(raw.get("input") or "").strip(), + "input_number": str(raw.get("input_number") or "").strip(), + "input_key": str(raw.get("input_key") or "").strip(), + "input_title": str(raw.get("input_title") or "").strip(), + "sequence_id": "", + "sequence_name": "", + "button_id": "", + "source": "agent_tally", + } + if not any(entry.get(key) for key in ("input", "input_number", "input_key", "input_title")): + continue + next_state[layer] = entry + + vmix_connected = bool(message.get("vmix_connected", True)) + if vmix_connected: + self._runtime_overlay_state[device_id] = next_state + self._runtime_overlay_authoritative[device_id] = True + self._runtime_overlay_observed_at[device_id] = str(message.get("observed_at") or _utcnow().isoformat())[:80] + else: + self._runtime_overlay_state.pop(device_id, None) + self._runtime_overlay_authoritative[device_id] = False + self._runtime_overlay_observed_at[device_id] = str(message.get("observed_at") or _utcnow().isoformat())[:80] + + now = _utcnow() + with self.database.session() as session: + row = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id)) + if row is not None: + row.last_seen_at = now + row.vmix_connected = vmix_connected + async def send(self, device_id: str, payload: dict[str, Any]) -> bool: async with self._lock: live = self._live.get(device_id) @@ -4558,8 +4618,11 @@ def create_hockey_agent_router( if not isinstance(message, dict): continue message_type = str(message.get("type") or "") - if message_type in {"heartbeat", "vmix.status", "command.ack", "command.batch.ack", "match.accepted", "vmix.inventory"}: - await hub.receive_status(device_id, message) + if message_type in {"heartbeat", "vmix.status", "command.ack", "command.batch.ack", "match.accepted", "vmix.inventory", "vmix.overlay_state"}: + if message_type == "vmix.overlay_state": + await hub.receive_overlay_state(device_id, message) + else: + await hub.receive_status(device_id, message) if message_type == "vmix.inventory": await hub.receive_inventory(device_id, message) if message_type in {"command.ack", "command.batch.ack"}: diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index 06aa27c..57712a2 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -113,6 +113,7 @@ vmixOverlayRuntime: new Map(), quickPanelOnAirSequences: new Set(), quickPanelServerOnAirSequences: new Set(), + vmixOverlayTallyAuthoritative: false, quickPanelOverlayPollTimer: null, quickPanelOverlayPollPending: false, triggerEditorOpenIds: new Set(), @@ -4421,14 +4422,56 @@ function startCustomTooltips() { return targets; } - function sequenceMatchesRuntimeOverlay(sequenceOrId) { - const targets = shortcutSequenceOverlayTargets(sequenceOrId); - if (!targets.length) return false; - return targets.some((target) => { - const current = state.vmixOverlayRuntime.get(String(target.layer)); - if (!current) return false; - return normalizeRuntimeVmixInputRef(current.input) === normalizeRuntimeVmixInputRef(target.input); + function runtimeOverlayInputRefs(current) { + if (!current || typeof current !== "object") return new Set(); + return new Set([current.input, current.input_number, current.input_key, current.input_title] + .map((value) => normalizeRuntimeVmixInputRef(value)) + .filter(Boolean)); + } + + function runtimeOverlayMatchesInput(current, input) { + // BUILD58 compatibility: normalizeRuntimeVmixInputRef(current.input) === normalizeRuntimeVmixInputRef(target.input) + const target = normalizeRuntimeVmixInputRef(input); + if (!target) return false; + return runtimeOverlayInputRefs(current).has(target); + } + + function shortcutSequencePreviewTargets(sequenceOrId) { + const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId; + if (!sequence) return []; + const context = buildShortcutRuntimeContext(sequence); + const targets = []; + (sequence.steps || []).forEach((step) => { + if (!step || step.enabled === false || step.type !== "vmix_command") return; + if (!sequenceConditionMatches(step.condition, context, step.condition_value)) return; + const fn = String(templateSequenceValue(step.function, context) || "").trim().toLowerCase(); + if (fn !== "previewinput") return; + const input = String(templateSequenceValue(step.input, context) || "").trim(); + if (!input) return; + if (!targets.some((item) => normalizeRuntimeVmixInputRef(item) === normalizeRuntimeVmixInputRef(input))) targets.push(input); }); + return targets; + } + + function sequenceMatchesRuntimeOverlay(sequenceOrId) { + const overlayTargets = shortcutSequenceOverlayTargets(sequenceOrId); + const directMatch = overlayTargets.some((target) => { + const current = state.vmixOverlayRuntime.get(String(target.layer)); + return runtimeOverlayMatchesInput(current, target.input); + }); + if (directMatch) return true; + + // BUILD115: most lower-third buttons only execute PreviewInput. The actual + // Overlay 4 is put on air later by the common "Выдать графику в эфир" button. + // Agent tally resolves the vMix overlay number back to number/key/title, so the + // original PreviewInput GUID can now be matched to the real on-air Input. + const previewTargets = shortcutSequencePreviewTargets(sequenceOrId); + if (!previewTargets.length) return false; + for (const layer of ["1", "2", "3", "4"]) { + const current = state.vmixOverlayRuntime.get(layer); + if (previewTargets.some((input) => runtimeOverlayMatchesInput(current, input))) return true; + } + return false; } function setQuickPanelSequenceOnAir(sequenceId, active) { @@ -4441,8 +4484,11 @@ function startCustomTooltips() { function shortcutSequenceIsOnAir(sequenceId) { const id = String(sequenceId || ""); if (!id) return false; - if (state.quickPanelServerOnAirSequences.has(id)) return true; const sequence = shortcutSequenceById(id); + // When Agent tally is available, the vMix XML API is the source of truth. Do + // not let optimistic click/ACK history keep a stale button illuminated. + if (state.vmixOverlayTallyAuthoritative) return sequenceMatchesRuntimeOverlay(sequence); + if (state.quickPanelServerOnAirSequences.has(id)) return true; const targets = shortcutSequenceOverlayTargets(sequence); // BUILD58: first compare the actual Input currently tracked on the Overlay layer // with every Input that this sequence can put on air. This keeps the button lit @@ -4473,6 +4519,7 @@ function startCustomTooltips() { function applyServerRuntimeOverlayState(payload) { const source = payload?.overlay_state && typeof payload.overlay_state === "object" ? payload.overlay_state : payload; const overlays = source?.overlays && typeof source.overlays === "object" ? source.overlays : {}; + state.vmixOverlayTallyAuthoritative = Boolean(source?.authoritative && source?.online !== false && source?.vmix_connected !== false); const previousServerIds = new Set(state.quickPanelServerOnAirSequences); const nextServerIds = new Set(); @@ -4481,10 +4528,14 @@ function startCustomTooltips() { if (raw && typeof raw === "object") { const entry = { input: String(raw.input || ""), + input_number: String(raw.input_number || ""), + input_key: String(raw.input_key || ""), + input_title: String(raw.input_title || ""), sequence_id: String(raw.sequence_id || ""), sequence_name: String(raw.sequence_name || ""), button_id: String(raw.button_id || ""), server_confirmed: true, + agent_tally: String(raw.source || source?.source || "") === "agent_tally", }; state.vmixOverlayRuntime.set(layer, entry); if (entry.sequence_id) nextServerIds.add(entry.sequence_id); @@ -4534,7 +4585,7 @@ function startCustomTooltips() { pollQuickPanelOverlayState({ force: true }).catch(() => {}); state.quickPanelOverlayPollTimer = window.setInterval(() => { pollQuickPanelOverlayState().catch(() => {}); - }, 1000); + }, 500); } function trackRuntimeOverlayCommands(commands, execution = null) { @@ -4862,26 +4913,6 @@ function startCustomTooltips() { return await sendRuntimeVmixTimerSequence(commands); } - async function waitForVmixStrengthMappingIdle(timeoutMs = 2500) { - const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0); - while (state.vmixStrengthMappingRefreshPending && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 20)); - } - return !state.vmixStrengthMappingRefreshPending; - } - - async function syncPreparedPenaltyStateBeforeScoreboard() { - const gameId = String(hockeyTimerSelectedGameId() || "").trim(); - const pendingTimerState = Boolean(state.hockeyTimerDirty || state.hockeyTimerSaveTimer || state.hockeyTimerSavePromise); - if (gameId && pendingTimerState) { - await hockeyPersistGameTimers(gameId, { force: true }); - } - // A background save may already be pushing the new 5x4 / 4x3 caption through - // Mapping. Give that tiny only-changed update a chance to finish before OverlayIn. - if (state.vmixStrengthMappingRefreshPending) await waitForVmixStrengthMappingIdle(); - return await rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true }); - } - function penaltyMirrorKey(component, event) { return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`; } @@ -4943,26 +4974,8 @@ function startCustomTooltips() { return null; } - function configuredHockeyVmixPenaltySyncSteps() { - const steps = []; - const seen = new Set(); - (state.config.shortcut_sequences || []).forEach((sequence) => { - if (!sequence || sequence.enabled === false) return; - (sequence.steps || []).forEach((step) => { - if (!step || step.enabled === false || step.type !== "hockey_vmix_timers_start" || !step.sync_vmix_penalties) return; - const key = String(step.id || ""); - if (!key || seen.has(key)) return; - seen.add(key); - steps.push(step); - }); - }); - return steps; - } - function sortedPenaltyEntries(side = "") { - // BUILD113: a fully assigned penalty is preloaded into the scorebug before - // its countdown starts. Start/Pause only controls whether the clock runs. - return currentHockeyPenaltyEntries() + return activeHockeyPenaltyEntries() .filter((item) => !side || item.side === side) .sort((a, b) => { // BUILD98: a paused penalty cannot be the next real strength transition @@ -5161,23 +5174,15 @@ function startCustomTooltips() { return `${String(step?.id || "")}:${side}:${String(target?.id || "")}`; } - async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true, preservePausedCountdown = false, includeConfigured = false } = {}) { + async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true, preservePausedCountdown = false } = {}) { const outCommands = []; const stopCommands = []; const setCommands = []; const runCommands = []; const inCommands = []; const assignedMirrorKeys = new Set(); - const managedSteps = new Map(); - Array.from(state.activeHockeyVmixTimerSteps).forEach((stepId) => { + for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) { const step = hockeyTimerSyncStepById(stepId); - if (step) managedSteps.set(String(stepId), step); - }); - if (includeConfigured) { - configuredHockeyVmixPenaltySyncSteps().forEach((step) => managedSteps.set(String(step.id || ""), step)); - } - const managedStepIds = new Set(Array.from(managedSteps.keys()).filter(Boolean)); - for (const [stepId, step] of managedSteps.entries()) { if (!step || step.enabled === false || !step.sync_vmix_penalties) { state.activeHockeyVmixTimerSteps.delete(stepId); continue; @@ -5288,7 +5293,7 @@ function startCustomTooltips() { } } for (const [mirrorKey, mirror] of Array.from(state.vmixPenaltyMirrors.entries())) { - if (mirror?.stepId && managedStepIds.has(String(mirror.stepId)) && !assignedMirrorKeys.has(mirrorKey)) { + if (mirror?.stepId && state.activeHockeyVmixTimerSteps.has(mirror.stepId) && !assignedMirrorKeys.has(mirrorKey)) { state.vmixPenaltyMirrors.delete(mirrorKey); } } @@ -5558,11 +5563,7 @@ function startCustomTooltips() { return true; } if (sequence.is_scoreboard_sequence) { - // BUILD113: preload all dynamic scorebug fields before the first OverlayIn. - // Game time is written immediately, then a prepared penalty is flushed so its - // countdown and configured numerical-strength caption are already in vMix. await syncConfiguredScoreboardCountdownsToRuntime(); - await syncPreparedPenaltyStateBeforeScoreboard(); } const stepErrors = []; for (const [stepIndex, step] of (sequence.steps || []).entries()) { @@ -7027,22 +7028,10 @@ function openTimerQuickEditor(focusActionId = "") { ...hockeyPenaltyContext(event) }); } + // BUILD95: filling in a penalty must not touch/start the vMix countdown. + // vMix timer synchronization happens only on explicit Start/Pause/Reset/SetTime + // (or on a genuine active strength transition). hockeySyncPenaltySideMappingContext().catch(() => {}); - if (hockeyEventReady(event)) { - // BUILD113: assigning the penalty is enough to prepare the scorebug. Seed the - // configured vMix countdown while STOPPED and persist the timer snapshot so - // Mapping receives the new numerical-strength caption before OverlayIn. - hockeyScheduleTimerSave(true); - queueMicrotask(() => { - const gameId = String(hockeyTimerSelectedGameId() || "").trim(); - if (gameId) { - hockeyPersistGameTimers(gameId, { force: true }) - .catch((error) => console.error("Prepared penalty strength save error", error)); - } - rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true }) - .catch((error) => console.error("Prepared penalty vMix preload error", error)); - }); - } } function createHockeyPenaltyDraft(component, seed = {}, source = "manual") { @@ -7323,8 +7312,8 @@ function openTimerQuickEditor(focusActionId = "") { const fullStrengthSide = penaltyFullStrengthSide(); board.penalties = board.penalties.filter((item) => item.id !== event.id); 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 remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item)).length; + const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length; + const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item)).length; const clearCommonSelection = board.selectedEventId === event.id; const clearSideSelection = board.selectedPreviewEventIds?.[side] === event.id; if (clearCommonSelection) board.selectedEventId = null; @@ -7333,7 +7322,7 @@ function openTimerQuickEditor(focusActionId = "") { persistHockeyBoard(component, board, true); refreshHockeyBoardNodes(component); hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); - rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true }) + rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) .catch((error) => console.error("Penalty target finish rebalance error", error)) .finally(() => { fireConfiguredTimerFinishActions("penalty", { @@ -7383,22 +7372,21 @@ function openTimerQuickEditor(focusActionId = "") { }); state.vmixPenaltyMirrors.delete(penaltyMirrorKey(component, event)); hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); - if (!board.penalties.some((item) => !item.finished && hockeyEventReady(item))) { + if (!board.penalties.some((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item))) { resetPenaltyAdvantageCycle(); hockeyScheduleTimerSave(true); } - rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true }).catch((error) => console.error("Penalty target remove rebalance error", error)); + rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }).catch((error) => console.error("Penalty target remove rebalance error", error)); return true; } persistHockeyBoard(component, board, true); refreshHockeyBoardNodes(component); - if (options.syncVmix !== false && ["start", "pause", "reset", "set_time"].includes(command)) { + if (options.syncVmix !== false && state.activeHockeyVmixTimerSteps.size && ["start", "pause", "reset", "set_time"].includes(command)) { rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, preservePausedCountdown: command === "pause", - includeConfigured: true, }).catch((error) => console.error("Penalty countdown state sync error", error)); } return true; @@ -7492,17 +7480,17 @@ function openTimerQuickEditor(focusActionId = "") { hockeySyncPenaltySideMappingContext({ force: true }).catch(() => {}); const hadAdvantage = Boolean(state.hockeyPenaltyAdvantageCycle.hadAdvantage); const fullStrengthSide = penaltyFullStrengthSide(); - const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item)).length; + const remainingTotal = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && hockeyPenaltyHasStarted(item)).length; const finishBySide = new Map(); completedEvents.forEach((event) => { const side = String(event.player?.side || event.side || "").toLowerCase(); if (side && !finishBySide.has(side)) finishBySide.set(side, event); }); - rebalanceVmixPenaltyTargets({ force: true, hideUnused: true, includeConfigured: true }) + rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) .catch((error) => console.error("Penalty target ticker rebalance error", error)) .finally(() => { finishBySide.forEach((event, side) => { - 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) && hockeyPenaltyHasStarted(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length; fireConfiguredTimerFinishActions("penalty", { side, component, event, remaining_on_side: remainingOnSide, @@ -11855,15 +11843,16 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { // BUILD89: keep a second rebalance after the authoritative control payload // arrives. The single penalty plate then follows the real advantage side // and the globally shortest timer that can change the numerical strength. - if (configuredHockeyVmixPenaltySyncSteps().length || state.activeHockeyVmixTimerSteps.size) { - // BUILD113: a prepared penalty already changes numerical strength, so keep - // its stopped countdown and target side aligned with the authoritative payload. - rebalanceVmixPenaltyTargets({ force: false, hideUnused: true, includeConfigured: true }) + if (state.activeHockeyVmixTimerSteps.size) { + // Build87 compatibility marker: rebalanceVmixPenaltyTargets({ force: true, hideUnused: true }) + // BUILD95 uses a non-forced pass so a merely prepared penalty cannot + // unnecessarily restart an already running vMix countdown. + rebalanceVmixPenaltyTargets({ force: false, hideUnused: true }) .catch((error) => console.error("Penalty strength rebalance error", error)); } } if (previousControl && hockeyTeamStateFlagSignature(previousControl) !== hockeyTeamStateFlagSignature(payload) && hockeyTeamStateScoreboardIsLive()) { - hockeySyncTeamStateOverlays({ force: false }).catch((error) => console.error("vMix team-state overlay sync error", error)); + hockeySyncTeamStateOverlays({ force: true }).catch((error) => console.error("vMix team-state overlay sync error", error)); } if (dispatch) { window.dispatchEvent(new CustomEvent("hockey:game-control-updated", { @@ -12004,43 +11993,9 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { }); } - async function hockeyApplyTeamStateOverlayImmediately(key, active) { - if (!hockeyTeamStateScoreboardIsLive()) return 0; - const setting = hockeyTeamStateSetting(key); - const previous = state.hockeyTeamStateOverlayActive.get(key) || null; - const commands = []; - const shouldShow = Boolean(active) && setting.enabled && Boolean(setting.input); - if (shouldShow) { - if (previous && (previous.input !== setting.input || previous.overlay !== setting.overlay)) { - commands.push({ Function: `OverlayInput${previous.overlay}Out`, Input: previous.input }); - } - if (!previous || previous.input !== setting.input || previous.overlay !== setting.overlay) { - commands.push({ Function: `OverlayInput${setting.overlay}In`, Input: setting.input }); - } - state.hockeyTeamStateOverlayActive.set(key, { input: setting.input, overlay: setting.overlay }); - } else if (previous) { - commands.push({ Function: `OverlayInput${previous.overlay}Out`, Input: previous.input }); - state.hockeyTeamStateOverlayActive.delete(key); - } - if (commands.length) await sendRuntimeVmixSequence(commands); - return commands.length; - } - async function hockeyToggleMatchFlag(key) { const current = Boolean(hockeyMatchFlags()[key]); - const next = !current; - const live = hockeyTeamStateScoreboardIsLive(); - if (live) { - // BUILD113: delayed penalty / empty net are operator-live controls. Do not wait - // for the database round-trip before showing/removing the configured overlay. - hockeyApplyTeamStateOverlayImmediately(key, next).catch((error) => console.error("Immediate team-state overlay error", error)); - } - try { - return await hockeySetMatchFlags({ [key]: next }); - } catch (error) { - if (live) hockeyApplyTeamStateOverlayImmediately(key, current).catch(() => {}); - throw error; - } + return hockeySetMatchFlags({ [key]: !current }); } function hockeyPrematchFlagKey(buttonId) { @@ -14830,11 +14785,6 @@ function renderHockeyPenaltyDashboard(node, component, runtime) { } finally { state.hockeyTimerHydrating = false; } - // BUILD113: changing/setting the period immediately writes 20:00 / 05:00 - // (or the configured period value) into vMix. The operator should never have - // to put the scorebug on air first just to seed its clock. - syncConfiguredScoreboardCountdownsToRuntime() - .catch((error) => console.error("Immediate game countdown preload error", error)); } if (state.activeTab === "shootout") renderRuntime(); });