From c82ec2cbfb0d9758c442c3748900f2e486afbca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=AE=D1=80=D0=B8=D0=B9=20=D0=A7=D0=B5=D1=80=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=BA=D0=BE?= Date: Mon, 24 Aug 2026 16:14:29 +0300 Subject: [PATCH] =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=201=20=D0=B2=D1=80?= =?UTF-8?q?=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 3 +- hockey_data/agent_bridge.py | 105 ++++++++++----- ...est_build103_space_timer_fast_transport.py | 120 ++++++++++++++++++ ui_builder/static/app.js | 9 +- 4 files changed, 203 insertions(+), 34 deletions(-) create mode 100644 tests/test_build103_space_timer_fast_transport.py diff --git a/app.py b/app.py index 274273e..ea52ae1 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.24.5" +BUILD_VERSION = "2026.08.24.6" +# compatibility: BUILD_VERSION = "2026.08.24.5" # compatibility: BUILD_VERSION = "2026.08.24.4" # compatibility: BUILD_VERSION = "2026.08.24.2" # compatibility: BUILD_VERSION = "2026.08.24.1" diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index aee99b4..fb57ac9 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -1403,6 +1403,7 @@ class VmixAgentHub: sequence_id: str = "", sequence_name: str = "", button_id: str = "", + delivery_mode: str = "", timeout: float = 4.0, ) -> dict[str, Any]: """Execute commands on exactly one Agent bound to this browser/match session.""" @@ -1522,49 +1523,92 @@ class VmixAgentHub: # for this transport. Mapping and generic runtime sync keep their existing ACK # semantics so diagnostics are still useful where exact field delivery matters. interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip()) - use_batch = (not interactive_shortcut) and self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 - transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy") + delivery_mode = str(delivery_mode or "").strip().lower() + fast_timer = delivery_mode == "timer-fast" + no_ack_delivery = interactive_shortcut or fast_timer + supports_batch = self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 + use_batch = (not no_ack_delivery) and supports_batch + if fast_timer: + transport = "timer-batch-no-ack" if supports_batch else "timer-no-ack" + else: + transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy") - if interactive_shortcut: - # Preserve order between operator shortcuts, but do not wait behind Mapping's - # command ACK lock. A shortcut returns as soon as its frames are on the Agent socket. + if no_ack_delivery: + # BUILD103: realtime operator traffic (shortcuts + timers) bypasses Mapping's + # ACK queue. Timer pairs use ONE vmix.batch frame on Agent 1.4+ and return + # immediately after WebSocket delivery; the later Agent ACK is intentionally ignored. + # The shared realtime lock preserves exact frame order if Space and an F-key are + # pressed almost simultaneously without reintroducing the slow Mapping lock. async with self._device_vmix_shortcut_send_lock(target_device_id): - for index, command in enumerate(prepared_commands): - function = str(command.get("Function") or "") + if fast_timer and supports_batch: request_id = secrets.token_urlsafe(12) delivered = await self.send( target_device_id, { - "type": "vmix.command", + "type": "vmix.batch", "protocol": AGENT_PROTOCOL_VERSION, "request_id": request_id, "device_id": target_device_id, "assignment_id": assignment_id, "match_id": match_id, - "command": command, + "commands": prepared_commands, }, ) - item = { - "index": index, - "function": function, - "ok": bool(delivered), - "reason": "" if delivered else "Agent сейчас offline", - "delivery": "agent-websocket", - "ack_waited": False, - } - results.append(item) - if delivered: - # With no ACK wait the local ON AIR state is intentionally optimistic: - # success means delivery to the live Agent WebSocket, not vMix confirmation. - self._track_runtime_overlay_command( + for index, command in enumerate(prepared_commands): + function = str(command.get("Function") or "") + results.append({ + "index": index, + "function": function, + "ok": bool(delivered), + "reason": "" if delivered else "Agent сейчас offline", + "delivery": "agent-websocket-batch", + "ack_waited": False, + }) + if delivered: + self._track_runtime_overlay_command( + target_device_id, + command, + sequence_id=sequence_id, + sequence_name=sequence_name, + button_id=button_id, + ) + else: + for index, command in enumerate(prepared_commands): + function = str(command.get("Function") or "") + request_id = secrets.token_urlsafe(12) + delivered = await self.send( target_device_id, - command, - sequence_id=sequence_id, - sequence_name=sequence_name, - button_id=button_id, + { + "type": "vmix.command", + "protocol": AGENT_PROTOCOL_VERSION, + "request_id": request_id, + "device_id": target_device_id, + "assignment_id": assignment_id, + "match_id": match_id, + "command": command, + }, ) - else: - break + item = { + "index": index, + "function": function, + "ok": bool(delivered), + "reason": "" if delivered else "Agent сейчас offline", + "delivery": "agent-websocket", + "ack_waited": False, + } + results.append(item) + if delivered: + # With no ACK wait the local ON AIR state is intentionally optimistic: + # success means delivery to the live Agent WebSocket, not vMix confirmation. + self._track_runtime_overlay_command( + target_device_id, + command, + sequence_id=sequence_id, + sequence_name=sequence_name, + button_id=button_id, + ) + else: + break else: async with self._device_vmix_send_lock(target_device_id): if use_batch: @@ -1637,7 +1681,8 @@ class VmixAgentHub: "failed": len(failed), "results": results, "transport": transport, - "confirmation": "not_waited" if interactive_shortcut else "ack", + # BUILD102 compatibility marker: "confirmation": "not_waited" if interactive_shortcut else "ack" + "confirmation": "not_waited" if no_ack_delivery else "ack", "overlay_state": self._runtime_overlay_payload(target_device_id), } @@ -3914,6 +3959,7 @@ class RuntimeVmixSequencePayload(BaseModel): sequence_id: str = Field(default="", max_length=160) sequence_name: str = Field(default="", max_length=300) button_id: str = Field(default="", max_length=160) + delivery_mode: str = Field(default="", max_length=32) class SelectSessionDevicePayload(BaseModel): @@ -4259,6 +4305,7 @@ def create_hockey_agent_router( sequence_id=payload.sequence_id, sequence_name=payload.sequence_name, button_id=payload.button_id, + delivery_mode=payload.delivery_mode, ) @router.get("/api/hockey/vmix/overlay-state") diff --git a/tests/test_build103_space_timer_fast_transport.py b/tests/test_build103_space_timer_fast_transport.py new file mode 100644 index 0000000..9818b73 --- /dev/null +++ b/tests/test_build103_space_timer_fast_transport.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from types import SimpleNamespace + +from hockey_data.agent_bridge import VmixAgentHub +from hockey_data.auth_bridge import HockeyUser +from tests.support import LocalTestDatabase + +ROOT = Path(__file__).resolve().parents[1] +APP_JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8") +BRIDGE = (ROOT / "hockey_data" / "agent_bridge.py").read_text(encoding="utf-8") +APP = (ROOT / "app.py").read_text(encoding="utf-8") + + +class FakeWebSocket: + def __init__(self) -> None: + self.client = SimpleNamespace(host="127.0.0.1") + self.sent: list[dict] = [] + + async def send_json(self, payload: dict) -> None: + self.sent.append(payload) + + async def close(self, **_kwargs) -> None: + return None + + +def test_space_edge_is_single_fire_and_local_timer_changes_before_transport() -> None: + # One physical Space press fires once; repeats are swallowed until keyup. + assert "state.pressedShortcutKeys.has(physicalKey)" in APP_JS + assert "if (event.repeat) return false;" in APP_JS + assert "state.pressedShortcutKeys.add(physicalKey)" in APP_JS + assert "state.pressedShortcutKeys.delete(physicalKey)" in APP_JS + assert 'combo: "Space"' in APP_JS + + block = APP_JS.split('case "hockey_vmix_timers_start":', 1)[1].split('case "delay":', 1)[0] + assert block.index("if (step.start_web_game)") < block.index("if (commands.length) sendRuntimeVmixTimerSequence(commands);") + assert 'delivery_mode: "timer-fast"' in APP_JS + + +def test_timer_fast_backend_uses_one_no_ack_batch_and_bypasses_mapping_lock(tmp_path: Path) -> None: + database = LocalTestDatabase(tmp_path / "build103-timer-fast.sqlite3") + database.create_all() + hub = VmixAgentHub(database) # type: ignore[arg-type] + ws = FakeWebSocket() + user = HockeyUser(id="103", login="operator103", display_name="operator103") + + async def scenario() -> None: + await hub.register( + ws, # type: ignore[arg-type] + { + "device_id": "GFX-TIMER-FAST", + "device_secret": "t" * 40, + "device_name": "Build103 Timer GFX", + "hostname": "BUILD103-PC", + "agent_version": "1.4.0", + "vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"}, + }, + ) + await hub.pair_device("GFX-TIMER-FAST", user) + assigned = await hub.assign_match( + wfl_user_id=user.id, + tournament_external_id="1437", + game_external_id="103103", + ) + assert assigned is not None + + # Mapping may be stuck waiting for ACK. Space timer transport must not wait for it. + mapping_lock = hub._device_vmix_send_lock("GFX-TIMER-FAST") + await mapping_lock.acquire() + try: + result = await asyncio.wait_for( + hub.run_vmix_sequence_for_user( + user, + [ + {"Function": "SetCountdown", "Input": "SCORE", "SelectedName": "TIME.Text", "Value": "00:18:42"}, + {"Function": "StartCountdown", "Input": "SCORE", "SelectedName": "TIME.Text"}, + ], + delivery_mode="timer-fast", + ), + timeout=0.5, + ) + finally: + mapping_lock.release() + + stop_result = await asyncio.wait_for( + hub.run_vmix_sequence_for_user( + user, + [ + {"Function": "StopCountdown", "Input": "SCORE", "SelectedName": "TIME.Text"}, + {"Function": "SetCountdown", "Input": "SCORE", "SelectedName": "TIME.Text", "Value": "00:18:41"}, + ], + delivery_mode="timer-fast", + ), + timeout=0.5, + ) + + batches = [item for item in ws.sent if item.get("type") == "vmix.batch"] + assert len(batches) == 2 + assert [item["Function"] for item in batches[0]["commands"]] == ["SetCountdown", "StartCountdown"] + assert [item["Function"] for item in batches[1]["commands"]] == ["StopCountdown", "SetCountdown"] + assert not [item for item in ws.sent if item.get("type") == "vmix.command"] + for payload in (result, stop_result): + assert payload["ok"] is True + assert payload["transport"] == "timer-batch-no-ack" + assert payload["confirmation"] == "not_waited" + assert payload["applied"] == 2 + assert all(row["ack_waited"] is False for row in payload["results"]) + assert not hub._pending_commands + + asyncio.run(scenario()) + + +def test_timer_fast_contract_and_build_version() -> None: + assert 'delivery_mode: str = Field(default="", max_length=32)' in BRIDGE + assert 'fast_timer = delivery_mode == "timer-fast"' in BRIDGE + assert 'transport = "timer-batch-no-ack" if supports_batch else "timer-no-ack"' in BRIDGE + assert '"type": "vmix.batch"' in BRIDGE + assert 'BUILD_VERSION = "2026.08.24.6"' in APP diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index beb62b2..4174902 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -4643,10 +4643,10 @@ function startCustomTooltips() { } function sendRuntimeVmixTimerSequence(commands) { - // BUILD101: timer transport is deliberately independent from the generic - // shortcut/title queue. The web timer has already changed state before this - // function is called, so Agent/vMix delivery is best-effort and must never - // block the operator from pressing Start/Stop again. + // BUILD103: timer transport is deliberately independent from the generic + // shortcut/title/Mapping ACK queue. The web timer has already changed state before + // this function is called. Server delivery_mode=timer-fast sends the tiny native + // Countdown pair to Agent immediately without waiting for ACK, preferably as one batch. const rawCommands = typeof commands === "function" ? commands() : commands; const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function); if (!clean.length) return Promise.resolve({ ok: true, applied: 0, requested: 0, results: [] }); @@ -4664,6 +4664,7 @@ function startCustomTooltips() { commands: clean, device_id: currentRuntimeVmixDeviceId(), session_token: currentRuntimeHockeySessionToken(), + delivery_mode: "timer-fast", }), }).then(async (response) => { let payload = {};