diff --git a/README.md b/README.md index d68bc51..dd647c4 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,8 @@ from a phone. 2. **Open Match Setup.** Double-tap the touch bar on Stack-chan's head, or briefly press Power, to show the setup QR. Scan it with your phone. 3. **Choose what to watch.** Select a match, supported team (or Neutral), - optional pregame position (or No position), and commentary style, then tap - **Start watching**. + optional pregame position (or No position), commentary style, and whether + to use spoiler protection, then tap **Start watching**. 4. **Wait for confirmation.** The watcher validates the ESPN/Kalshi pairing, updates its configuration, and acknowledges the device. The new selection takes effect without restarting the watcher or device. Changing only the @@ -91,6 +91,8 @@ not the primary QR flow. facts, with compact on-device balloons. - Separate support and position perspectives, including explicit conflict and uncertain-event wording. +- Optional spoiler protection suppresses proactive Kalshi alerts while + confirmed ESPN events and passive probability/ticker updates continue. - Phone-based match setup with live Chinese/English switching and hot reload. - Automatic fixture discovery, adaptive polling, quiet hours, and standalone market tracking when no match is available. @@ -102,17 +104,21 @@ not the primary QR flow. ### Daily start -From the repository root of an existing, configured checkout, keep the watcher -computer awake and run: +Update the repository and restart the watcher. Support/position wording, +position-based market selection, and the global player catalog remain +watcher-only changes. The phone spoiler-protection switch requires Matchday +Mod 1.6.0, but the official host can stay in place. ```sh python3 tools/stackchan_kalshi_watch.py \ --config config/kalshi_watchlist.json --watch ``` -Daily startup does not require cloning, pulling, or reflashing. If speech is -enabled, also start the optional LAN TTS service described in -[Getting started](docs/getting-started.md). +Deploy `config/espn_player_catalog.json` with the watcher checkout. For exact +upgrade boundaries, see the bilingual +[Matchday MOD 1.6.0 notes](docs/releases/1.6.0.md), +[1.5.0 notes](docs/releases/1.5.0.md), and +[1.4.0 commentary-style notes](docs/releases/1.4.0.md). ### First installation @@ -145,8 +151,8 @@ The currently documented device release is [Matchday Mod 1.5.0](docs/releases/1. | [Development](docs/development.md) | Repository map, tests, archive builds, and replay tooling | | [Host preparation](host/README.md) | Partition and optional CJK-font patches | | [Commentary styles PRD](docs/commentary-styles-prd.md) | Product rules and implementation contract | -| [Release notes](docs/releases/) | Version-specific changes and upgrade boundaries | -| [Troubleshooting Wiki](https://github.com/xymeow/stackchan-matchday/wiki/Troubleshooting) | FAQ, networking checks, and field debugging | +| [Release notes](docs/releases/1.6.0.md) | Version-specific upgrade boundaries | +| [GitHub Wiki](https://github.com/xymeow/stackchan-matchday/wiki) | Human- and agent-friendly navigation, FAQ, and field debugging | English guides link to their Chinese counterparts at the top of each page. Repository documentation is the versioned source of truth; the Wiki is the @@ -164,6 +170,9 @@ short operational index and troubleshooting layer. - Commentary style can change during a match without resetting ESPN history, market baselines, queues, or polling state. API compatibility is documented in the [Device API guide](docs/device-api.md). +- Spoiler protection can also change live. It silences proactive Kalshi + alerts, not the passive probability bar or ticker, and never suppresses + confirmed ESPN events. ### For maintainers and AI agents diff --git a/README.zh-CN.md b/README.zh-CN.md index 4bd0f11..40e5edf 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -49,8 +49,8 @@ watcher,可把 CoreS3 机器人变成世界杯陪看搭子:屏幕持续显 电脑和 Stack-chan 必须位于同一个可信局域网。 2. **唤出设置码。** 双击头顶触摸条或短按 Power 键,屏幕会显示设置二维码;轻点 一次可关闭,90 秒后也会自动关闭。 -3. **用手机选择。** 扫码后选择语言、比赛、支持队、可选持仓和播报语气,再点 - “开始看球”。页面显示 watcher 已确认后,配置立即生效,无需重启。 +3. **用手机选择。** 扫码后选择语言、比赛、支持队、可选持仓、播报语气和是否开启 + 防剧透,再点“开始看球”。页面显示 watcher 已确认后,配置立即生效,无需重启。 4. **一起看球。** Stack-chan 会持续更新旗帜、概率和 ticker,并响应比分、文字直播 与盘口变化。 @@ -100,6 +100,8 @@ Kalshi + ESPN ──只读──> Python watcher ──HTTP──> Stack-chan Ma - 对进球、红黄牌、换人、险情、比赛阶段和赛果做出反应。 - `casual`、`balanced`、`professional` 三档播报语气,可在比赛中即时切换。 - 支持队和持仓队分开建模;重大事件会自然说明持仓利好或利空。 +- 可选防剧透模式:关闭 Kalshi 主动提醒,同时保留 ESPN 已确认事件与静默更新的 + 概率条和 ticker。 - 由设备托管的中英文手机设置页,以及 watcher 的配置热更新与确认链路。 - 自动发现比赛、自适应轮询、免打扰时段和无比赛市场跟踪。 - 可选局域网 TTS;服务不可用时,视觉反馈和提示音仍然工作。 @@ -116,6 +118,10 @@ python3 tools/stackchan_kalshi_watch.py \ --config config/kalshi_watchlist.json --watch ``` +示例 ticker 是占位值;从手机设置页选择真实比赛后,watcher 会校验 ESPN 与 Kalshi +的双方匹配并原子更新配置。旧版升级前请查看 +[Matchday Mod 1.6.0 说明](docs/releases/1.6.0.md)及[全部版本说明](docs/releases/); +设备手机页的防剧透开关需要更新 watcher 与 Mod,但无需重刷官方 host。 日常启动不需要 `git pull`、重新克隆或刷机。需要语音时,再启动 [安装与升级](docs/getting-started.zh-CN.md)中说明的可选局域网 TTS 服务。 @@ -162,6 +168,8 @@ watcher。完整、可复现的命令见[安装与升级](docs/getting-started.z - ESPN 接口并非正式 API;缺失或含糊的上游细节会被忽略,不会猜测足球事实。 - 比赛中切换语气不会重置 ESPN 历史、盘口基线、队列或轮询状态;API 兼容性见 [设备 API](docs/device-api.zh-CN.md)。 +- 防剧透也可在比赛中即时切换:只关闭 Kalshi 主动提醒,不隐藏概率条或 ticker, + 也不会屏蔽 ESPN 已确认事件。 ### 给维护者和 AI agent diff --git a/config/kalshi_watchlist.example.json b/config/kalshi_watchlist.example.json index 316919a..9273208 100644 --- a/config/kalshi_watchlist.example.json +++ b/config/kalshi_watchlist.example.json @@ -4,6 +4,7 @@ "stackchan_serial_port": "/dev/cu.usbmodem101", "stackchan_serial_baud": 115200, "language": "zh", + "spoiler_free_mode": false, "voice_transport": "clip", "mac_voice": {"zh": "Tingting", "en": "Samantha"}, "mac_say_rate": 185, diff --git a/docs/configuration.md b/docs/configuration.md index 6f8bf4b..34c907a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,9 +4,10 @@ The phone setup page is the normal control surface. It changes all labels immediately, persists its language on the device, and applies the selected -match, supported team, optional position, and commentary style through the -watcher's pending/ack flow. The watcher validates the ESPN/Kalshi pairing, -atomically updates its local JSON, hot-reloads, and acknowledges Stack-chan. +match, supported team, optional position, commentary style, and spoiler +protection through the watcher's pending/ack flow. The watcher validates the +ESPN/Kalshi pairing, atomically updates its local JSON, hot-reloads, and +acknowledges Stack-chan. ## Match Setup flow @@ -48,6 +49,7 @@ legacy string or a localized object: ```json { "language": "en", + "spoiler_free_mode": false, "mac_voice": {"zh": "Tingting", "en": "Samantha"}, "espn": { "commentary_style": "balanced", @@ -122,6 +124,22 @@ effects, celebrations, expressions, lights, priority, and alert switches are unchanged. Switching during a match affects newly generated alerts without replaying old ESPN events or resetting market baselines, queues, or polling. +## Spoiler protection + +Top-level `spoiler_free_mode` is a persistent global boolean and defaults to +`false`. Both phone setup pages expose Normal and Spoiler protection choices. + +When enabled, the watcher still polls Kalshi and silently updates market +baselines, the probability bar, and ticker, but suppresses all proactive +Kalshi-derived alerts, including price/spread or market-status changes, +closing notices, and suspected-goal signals. Confirmed ESPN events, phases, +and results continue normally. + +The dedicated hot-update path applies during a match without resetting ESPN +history, market state, polling, or the selected fixture. Enabling protection +also drops queued market alerts and consumes their current baselines, so +turning it off later does not replay accumulated movement. + ## Support and position perspectives The supported team defines everyday co-watching language. Routine shots, diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 795850c..1861afd 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -2,8 +2,8 @@ [English](configuration.md) | [简体中文](configuration.zh-CN.md) -本文说明 watcher 的持久配置、手机 Match Setup、语言与三档播报、支持/持仓视角、 -球员目录和独立市场模式。配置示例位于 +本文说明 watcher 的持久配置、手机 Match Setup、语言与三档播报、防剧透模式、 +支持/持仓视角、球员目录和独立市场模式。配置示例位于 `config/kalshi_watchlist.example.json`;请把本机修改写入未提交的 `config/kalshi_watchlist.json`。 @@ -33,6 +33,7 @@ IPv4 地址或主机名,不支持 IPv6 字面地址。 "stackchan_host": "stackchan.local", "stackchan_transport": "http", "language": "zh", + "spoiler_free_mode": false, "setup_server": { "enabled": true, "host": "127.0.0.1", @@ -108,6 +109,19 @@ python3 tools/stackchan_kalshi_watch.py discover --query 关键词 语气只改变文案,不改变 TTS 音色和语速、音效、动作、表情、灯效、优先级或提醒 开关。比赛中切换立即作用于新提醒,但不会重播旧事件。 +## 防剧透模式 + +顶层 `spoiler_free_mode` 是全局持久布尔值,旧配置缺少时默认 `false`。设备和 +watcher 的手机设置页都提供“普通 / 防剧透”选项。 + +开启后 watcher 仍持续轮询 Kalshi,并静默更新盘口基线、概率条和 ticker,但不会 +主动播报任何 Kalshi 派生提醒,包括价格/价差、市场状态变化、临近收盘和疑似进球。 +ESPN 已确认的事件、比赛阶段与赛果照常播报。 + +独立热更新链路可在比赛中即时切换,不会重置 ESPN 已读历史、盘口状态、轮询或当前 +比赛。开启时还会丢弃队列中尚未播出的盘口提醒并消费当前基线,之后关闭不会补播 +累积变化。 + ## 支持队与持仓队 - `espn.favorite_team` 是支持队,决定普通射门、扑救、角球等日常陪看视角;可中立。 diff --git a/docs/development.md b/docs/development.md index 1bd92ca..74604db 100644 --- a/docs/development.md +++ b/docs/development.md @@ -30,6 +30,12 @@ The Python suite covers watcher behavior, Match Setup, commentary contracts, and device-facing integration helpers. The Node suite exercises the mod web surface without installing it on hardware. +Spoiler-protection changes should prove that Kalshi-derived alerts are +suppressed while market state and passive displays keep updating, confirmed +ESPN alerts remain eligible, queued market alerts are purged on hot enable, +and a later disable does not replay accumulated movement. Cover watcher-local +and device pending/ack paths, including the boolean `false` case. + ## Build without installing Build a mod archive from the upstream Stack-chan `firmware/` directory: diff --git a/docs/development.zh-CN.md b/docs/development.zh-CN.md index ca6a11d..cd37fc3 100644 --- a/docs/development.zh-CN.md +++ b/docs/development.zh-CN.md @@ -36,6 +36,9 @@ Python 测试覆盖 watcher、Match Setup、结构化事实、文案和投递行 - 疑似进球与等待确认在所有语气中保持不确定性。 - 专业细节只来自可可靠解析的 ESPN 字段;缺失值不能触发猜测。 - 只切换语气不能重复旧事件或重置盘口、告警与轮询状态。 +- 防剧透开启后应抑制 Kalshi 派生提醒,但继续更新盘口状态与常驻显示;ESPN 已确认 + 事件仍可投递。热开启需要清空待播盘口提醒,之后关闭不能补播累积变化。 +- watcher 本机与设备 pending/ack 两条链路都要覆盖,尤其要测试布尔值 `false`。 - Match Setup 需要覆盖设备 pending/ack 与 watcher 原子更新两端。 - 投递失败测试应验证退避和有限重试,避免设备重启风暴。 diff --git a/docs/device-api.md b/docs/device-api.md index de0e702..e6a7f39 100644 --- a/docs/device-api.md +++ b/docs/device-api.md @@ -72,6 +72,8 @@ The device-facing setup surface uses these endpoints under `/api/match-setup`: - `/pending` lets the watcher retrieve that selection. - `/ack` confirms success or reports validation failure back to the phone. - `/language` updates the persisted device language. +- `/style` queues a commentary-style-only update. +- `/spoiler` queues a spoiler-protection-only update. These endpoints form a pending/ack handshake: the phone writes to Stack-chan, the watcher validates and atomically updates its local configuration, and the @@ -94,6 +96,23 @@ Changing only the style must not reset ESPN event history, market baselines, alert queues, or polling state, and must not replay old commentary. The new style applies to subsequently generated alerts. +## Spoiler-protection endpoints + +The device accepts a strict JSON boolean at `POST /api/match-setup/spoiler`: + +```json +{"spoiler_free_mode":true} +``` + +It relays the preference through pending/ack. The watcher-local service accepts +the same body at `POST /api/setup/spoiler`, and `GET /api/setup/status` reports +the effective `spoiler_free_mode` value. + +Changing only this preference must not reload the selected match or reset ESPN +history, market baselines, or polling. Enabling it drops queued Kalshi alerts; +confirmed ESPN events continue, while the probability bar and ticker still +update silently. + ## Calling conventions - Use every endpoint only on a trusted LAN; the device API has no diff --git a/docs/device-api.zh-CN.md b/docs/device-api.zh-CN.md index 2275a59..e370bb4 100644 --- a/docs/device-api.zh-CN.md +++ b/docs/device-api.zh-CN.md @@ -84,6 +84,7 @@ curl --request POST \ | `/api/match-setup/ack` | watcher 确认或拒绝应用结果 | | `/api/match-setup/language` | 更新界面语言 | | `/api/match-setup/style` | 更新播报语气 | +| `/api/match-setup/spoiler` | 更新防剧透模式 | 这是一个异步确认流程:设备收到手机选择不代表 watcher 已采用。客户端应显示待处理 状态,直到 watcher 返回 ack;不要在提交后自行假定配置成功。 @@ -111,6 +112,30 @@ Content-Type: application/json `GET /api/setup/status` 返回 watcher 当前生效值。切换只影响新生成文案,不应重置 ESPN 已读事件、盘口基线、告警队列或轮询状态。 +### 即时切换防剧透模式 + +设备接收严格的 JSON 布尔值: + +```http +POST /api/match-setup/spoiler +Content-Type: application/json + +{"spoiler_free_mode":true} +``` + +它通过 pending/ack 流程转发。watcher 本机设置服务的对应接口为: + +```http +POST /api/setup/spoiler +Content-Type: application/json + +{"spoiler_free_mode":true} +``` + +`GET /api/setup/status` 返回当前生效的 `spoiler_free_mode`。只切换该偏好不会重载 +比赛或重置 ESPN 历史、盘口基线与轮询;开启后丢弃队列中的 Kalshi 提醒,已确认的 +ESPN 事件照常播报,概率条和 ticker 继续静默更新。 + ## 调用约定 - 所有接口仅用于可信局域网;设备 API 无认证并开放 CORS。 diff --git a/docs/getting-started.md b/docs/getting-started.md index 5a6b1f5..719fcc8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,11 +8,11 @@ building the Matchday mod, starting the watcher, and optionally enabling LAN speech. Already running Matchday? Read the version-specific -[1.5.0](releases/1.5.0.md) and [1.4.0](releases/1.4.0.md) notes before rebuilding. -Support/position-aware wording, position-based market selection, and the global -player catalog are watcher-only updates. An installed 1.5.0 mod can stay in -place: deploy the watcher code and `config/espn_player_catalog.json`, then -restart the watcher process. +[1.6.0](releases/1.6.0.md), [1.5.0](releases/1.5.0.md), and +[1.4.0](releases/1.4.0.md) notes before rebuilding. Spoiler protection on the +device phone page requires both the 1.6.0 watcher and mod, but not a host +reflash. The 1.5.0 support/position wording and global player catalog remain +watcher-only updates. ## Requirements diff --git a/docs/getting-started.zh-CN.md b/docs/getting-started.zh-CN.md index b538315..f94e498 100644 --- a/docs/getting-started.zh-CN.md +++ b/docs/getting-started.zh-CN.md @@ -186,6 +186,7 @@ curl --request POST --data-binary "say 比赛日准备好了" \ | 变更 | 通常需要更新 | | --- | --- | | watcher 文案、球员目录、解析和轮询 | watcher 代码与配置;重启 Python 进程 | +| 防剧透手机开关 | watcher 与 Matchday Mod 1.6.0;无需重刷 host | | Mod 页面、设备动作、命令或资源 | 重新构建并安装 Mod | | `xs` 分区或 CJK host 字体 | 重新应用补丁并烧录 host | | macOS TTS 服务实现 | 重启 TTS 服务;不需要刷设备 | diff --git a/docs/releases/1.5.0.md b/docs/releases/1.5.0.md index 4673c40..0d8deb6 100644 --- a/docs/releases/1.5.0.md +++ b/docs/releases/1.5.0.md @@ -56,6 +56,14 @@ A formal-name source is not treated as permission to invent a nickname. ### Reliability +- Goal and final-result feedback is now explicitly two-stage in the watcher: + the local fanfare and head motion finish before dynamic LAN TTS starts. The + TTS request remains blocking, so the watcher no longer opens four status + connections per second while the WAV stream is active. A TTS-only failure + does not replay an already delivered goal celebration. +- Suspected-goal market alerts restore the punchier "market jumped / possible + goal / don't blink" cadence while still naming the inferred team and + explicitly waiting for commentary confirmation. - Tone clips are skipped (successfully) while a TTS stream is playing instead of erroring into the watcher's retry path — one of the two halves of the mid-match retry-storm fix. @@ -109,6 +117,11 @@ A formal-name source is not treated as permission to invent a nickname. ### 可靠性 +- 进球和赛果反馈改为 watcher 两段式执行:先完成本地音效与头部动作,再启动局域网 + 动态 TTS。TTS 请求保持阻塞,WAV 播放期间不再每秒发出 4 次状态查询;如果仅语音 + 失败,也不会把已经播过的进球庆祝重新来一遍。 +- 盘口疑似进球提醒恢复“盘口突变 / 很可能进球 / 先别眨眼”的紧张感,同时继续 + 明确球队,并保留等待文字直播确认的不确定性。 - TTS 播放中的音效改为成功跳过而不是报错,不再触发 watcher 的失败重试—— 这是比赛中"重试风暴"修复的设备侧一半。 - watcher 失败投递改为退避 10 秒后重试,普通实况最多重试 2 次(进球/点球/红牌 diff --git a/docs/releases/1.6.0.md b/docs/releases/1.6.0.md new file mode 100644 index 0000000..2f8f817 --- /dev/null +++ b/docs/releases/1.6.0.md @@ -0,0 +1,109 @@ +# Matchday MOD 1.6.0 + +[English](#english) | [简体中文](#简体中文) + +## English + +### Spoiler protection + +- Adds a persistent global `spoiler_free_mode` preference, defaulting to + `false` for existing configurations. +- When enabled, the watcher still polls Kalshi and updates market baselines, + the probability bar, and ticker, but suppresses every proactive + Kalshi-derived alert—including price/spread or market-status changes, + closing notices, and suspected-goal signals. +- Confirmed ESPN events, match phases, and results continue normally. The mode + does not turn delayed market signals into confirmed football events. +- Turning protection on during a match immediately drops queued market alerts + and consumes their current baselines, so turning it off later does not replay + or catch up old market movement. + +### Phone setup and API + +- Both the device `/setup` page and the watcher-local setup page provide + Normal and Spoiler protection choices with the currently effective value. +- The device sends `POST /api/match-setup/spoiler`; the watcher-local service + accepts `POST /api/setup/spoiler`. Both use + `{"spoiler_free_mode":true|false}`. +- The dedicated hot-update path persists globally without resetting ESPN + history, market state, polling, or the selected match. +- Full fixture and standalone-market setup also carry the preference. Legacy + clients that omit it preserve the existing effective value. + +### Reliability fixes + +- The device HTTP service no longer handles requests inside the accept loop. + Previously one request whose body stalled (a phone leaving Wi-Fi mid-POST) + froze the entire server until reboot, and every spoken alert made the server + deaf for the whole playback, piling up watcher retries. Requests are now + served concurrently with a 45s watchdog per connection, a concurrency cap, + and automatic listener restart. Reproduced and verified on device: a stalled + POST no longer blocks other clients, and `/api/status` stays responsive + through a 36-second speech. +- The HTTP listener is now vendored into the mod (`matchday/listen-safe`) with + both connection promises observed at creation. Upstream leaves them + unobserved until `respondWith`, and an early peer reset then aborts the + release build — the source of silent `unhandled rejection` device restarts. +- `lastAbort` is consumed at boot, so `/api/status` only ever reports an abort + of the run immediately before the current boot. +- Remote TTS is vendored (`matchday/tts-remote-safe`) with a 1500ms stream + buffer and a 16-slot audio queue (the host pins 600ms, and the default + 8-slot queue halves whatever is configured). This removes the audible + mid-sentence dropouts on long speeches; hosts without the audio modules fall + back to the host `tts-remote` automatically. + +### Upgrade boundary + +- Update the watcher and install Matchday Mod 1.6.0 to expose the toggle on the + device phone page and keep its effective state synchronized. +- The official host firmware, partition layout, optional CJK font, and LAN TTS + service are unchanged and do not need to be rebuilt. +- The passive probability bar and ticker remain visible in this first version. + Fully hiding or freezing market visuals is a separate possible mode. + +## 简体中文 + +### 防剧透模式 + +- 新增全局持久偏好 `spoiler_free_mode`;旧配置缺少该字段时默认 `false`,保持原有 + 行为。 +- 开启后 watcher 仍持续轮询 Kalshi,并更新盘口基线、概率条和 ticker,但不再主动 + 播报任何 Kalshi 派生提醒,包括价格/价差或市场状态变化、临近收盘和疑似进球。 +- ESPN 已确认的比赛事件、阶段与赛果照常播报;防剧透模式不会把延迟的盘口信号当成 + 已确认足球事实。 +- 比赛中开启后会立即丢弃队列里尚未播出的盘口提醒,并消费当前基线;之后关闭时不会 + 补播或追赶此前累积的盘口变化。 + +### 手机设置与接口 + +- 设备 `/setup` 页面和 watcher 本机设置页都新增“普通 / 防剧透”选项,并显示当前 + 生效值。 +- 设备使用 `POST /api/match-setup/spoiler`,watcher 本机服务使用 + `POST /api/setup/spoiler`;请求体均为 `{"spoiler_free_mode":true|false}`。 +- 独立热更新链路会持久保存全局偏好,但不会重置 ESPN 已读历史、盘口状态、轮询或 + 当前比赛。 +- 完整比赛设置和独立市场模式也会携带该偏好;旧客户端省略字段时保留当前生效值。 + +### 可靠性修复 + +- 设备 HTTP 服务不再在 accept 循环内处理请求。此前只要有一个请求的 body 断流 + (手机发送中途离开 Wi-Fi),整个服务就永久卡死直到重启;且每次语音播报期间 + 服务全程失聪,watcher 重试成堆。现在每个连接独立处理,配 45 秒看门狗、并发 + 上限和 listener 自动重启。真机复现并验证:断流 POST 不再阻塞其他请求,36 秒 + 长播报期间 `/api/status` 全程可响应。 +- HTTP listener 内置进 mod(`matchday/listen-safe`),两个连接 promise 创建即被 + 观察。上游版本在 `respondWith` 之前无人观察,对端过早断开会让 release 构建 + 直接 abort——这正是"unhandled rejection"静默重启的来源。 +- `lastAbort` 启动时读取后即清除,`/api/status` 只报告紧邻本次启动的上一轮 + abort,不再残留旧账。 +- 远程 TTS 内置进 mod(`matchday/tts-remote-safe`),流缓冲提高到 1500ms、音频 + 队列 16 槽(host 固定 600ms,且默认 8 槽队列会把实际深度再腰斩)。长句中间 + 的可听断音由此消除;host 缺音频模块时自动回落到原 `tts-remote`。 + +### 升级边界 + +- 需要同时更新 watcher 并安装 Matchday Mod 1.6.0,设备手机页才会显示开关并同步 + 当前状态。 +- 官方 host firmware、分区表、可选 CJK 字体和局域网 TTS 均未改变,无需重新构建。 +- 第一版仍保留静默更新的概率条与 ticker;完全隐藏或冻结盘口视觉信息可作为另一种 + 后续模式。 diff --git a/host/sdk-patches/0001-esp32-tcp-clear-callbacks-safe.patch b/host/sdk-patches/0001-esp32-tcp-clear-callbacks-safe.patch new file mode 100644 index 0000000..2e45d6e --- /dev/null +++ b/host/sdk-patches/0001-esp32-tcp-clear-callbacks-safe.patch @@ -0,0 +1,125 @@ +diff --git a/modules/io/socket/lwip/tcp.c b/modules/io/socket/lwip/tcp.c +index 0f90023..63a2820 100644 +--- a/modules/io/socket/lwip/tcp.c ++++ b/modules/io/socket/lwip/tcp.c +@@ -262,6 +262,11 @@ void xs_tcp_destructor(void *data) + void removeTCPCallbacks(TCP tcp) + { + if (tcp->skt) { ++ // Testing the maintainer-suggested variant (moddable#1655): clear the ++ // callback argument first so any late callback observes NULL (the ++ // guards in tcpReceive/tcpSent/tcpError), then clear the callbacks ++ // with plain pointer stores — no tcpip-thread marshaling. ++ tcp_arg(tcp->skt, NULL); + tcp_recv(tcp->skt, NULL); + tcp_sent(tcp->skt, NULL); + tcp_err(tcp->skt, NULL); +@@ -358,7 +363,11 @@ void xs_tcp_read(xsMachine *the) + buffer->fragment = fragment->next; + buffer->fragmentOffset = 0; + if (NULL == buffer->fragment) { ++ // Detach under the same guard tcpReceive uses to append, so ++ // its tail walk never lands on a node about to be freed. ++ builtinCriticalSectionBegin(); + tcp->buffers = buffer->next; ++ builtinCriticalSectionEnd(); + tcp_recved_safe(tcp->skt, buffer->pb->tot_len); + pbuf_free_safe(buffer->pb); + c_free(buffer); +@@ -535,6 +544,9 @@ void tcpError(void *arg, err_t err) + { + TCP tcp = arg; + ++ if (NULL == tcp) // callbacks cleared during teardown ++ return; ++ + removeTCPCallbacks(tcp); + tcp->skt = NULL; // "pcb is already freed when this callback is called" + if (kTCPError & tcp->triggerable) +@@ -546,6 +558,12 @@ err_t tcpReceive(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err) + TCP tcp = arg; + TCPBuffer buffer; + ++ if (NULL == tcp) { // callbacks cleared during teardown ++ if (pb) ++ pbuf_free(pb); ++ return ERR_OK; ++ } ++ + if ((NULL == pb) || (ERR_OK != err)) { //@@ when is err set here? + removeTCPCallbacks(tcp); + #if ESP32 +@@ -572,6 +590,10 @@ err_t tcpReceive(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err) + + modInstrumentationAdjust(NetworkBytesRead, buffer->bytes); + ++ // The XS task pops and frees head nodes in xs_tcp_read concurrently with ++ // this append; guard the pointer surgery so the tail walk cannot traverse ++ // a node being detached/freed. ++ builtinCriticalSectionBegin(); + if (tcp->buffers) { + TCPBuffer walker; + +@@ -581,6 +603,7 @@ err_t tcpReceive(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err) + } + else + tcp->buffers = buffer; ++ builtinCriticalSectionEnd(); + + if (tcp->triggerable & kTCPReadable) + tcpTrigger(tcp, kTCPReadable); +@@ -590,6 +613,9 @@ err_t tcpReceive(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err) + + err_t tcpSent(void *arg, struct tcp_pcb *pcb, u16_t len) + { ++ if (NULL == arg) // callbacks cleared during teardown ++ return ERR_OK; ++ + tcpTrigger((TCP)arg, kTCPWritable); + return ERR_OK; + } +diff --git a/modules/network/socket/lwip/modLwipSafe.c b/modules/network/socket/lwip/modLwipSafe.c +index a4cc7fb..dc699dd 100644 +--- a/modules/network/socket/lwip/modLwipSafe.c ++++ b/modules/network/socket/lwip/modLwipSafe.c +@@ -169,6 +169,28 @@ void tcp_recved_safe(struct tcp_pcb *tcpPCB, u16_t len) + tcpip_callback_with_block(tcp_recved_INLWIP, msg, 1); + } + ++static err_t tcp_clear_callbacks_INLWIP(struct tcpip_api_call_data *tcpMsg) ++{ ++ LwipMsg msg = (LwipMsg)tcpMsg; ++ if (msg->tcpPCB) { ++ tcp_arg(msg->tcpPCB, NULL); ++ tcp_recv(msg->tcpPCB, NULL); ++ tcp_sent(msg->tcpPCB, NULL); ++ tcp_err(msg->tcpPCB, NULL); ++ } ++ return ERR_OK; ++} ++ ++// Synchronous: when this returns, no lwIP callback for this pcb is running ++// or can run again, so the caller may safely free its callback argument. ++void tcp_clear_callbacks_safe(struct tcp_pcb *tcpPCB) ++{ ++ LwipMsgRecord msg = { ++ .tcpPCB = tcpPCB, ++ }; ++ tcpip_api_call(tcp_clear_callbacks_INLWIP, &msg.call); ++} ++ + static err_t tcp_listen_INLWIP(struct tcpip_api_call_data *tcpMsg) + { + LwipMsg msg = (LwipMsg)tcpMsg; +diff --git a/modules/network/socket/lwip/modLwipSafe.h b/modules/network/socket/lwip/modLwipSafe.h +index 717808d..0f5c94f 100644 +--- a/modules/network/socket/lwip/modLwipSafe.h ++++ b/modules/network/socket/lwip/modLwipSafe.h +@@ -52,6 +52,7 @@ + #include "lwip/raw.h" + + struct tcp_pcb *tcp_new_safe(void); ++ void tcp_clear_callbacks_safe(struct tcp_pcb *tcpPCB); + err_t tcp_connect_safe(struct tcp_pcb *tcpPCB, const ip_addr_t *ipaddr, u16_t port, tcp_connected_fn connected); + u8_t pbuf_free_safe(struct pbuf *p); + err_t tcp_bind_safe(struct tcp_pcb *tcpPCB, const ip_addr_t *ipaddr, u16_t port); diff --git a/host/sdk-patches/README.md b/host/sdk-patches/README.md new file mode 100644 index 0000000..d513837 --- /dev/null +++ b/host/sdk-patches/README.md @@ -0,0 +1,54 @@ +# Moddable SDK patches (machine-local) + +Unlike `host/patches/` (which apply to the stack-chan checkout), these apply +to the **Moddable SDK** checkout itself (`$MODDABLE`, tested against 8.2.3): + +```sh +cd "$MODDABLE" +git apply /path/to/stackchan-matchday/host/sdk-patches/0001-esp32-tcp-clear-callbacks-safe.patch +``` + +Then rebuild and reflash the host. + +## 0001-esp32-tcp-clear-callbacks-safe.patch + +Fixes spontaneous `RTC_SW_CPU_RST` reboots caused by a use-after-free race in +the ECMA-419 TCP socket glue: `doClose`/`xs_tcp_destructor` (XS task) cleared +lwIP callbacks with raw `tcp_recv/tcp_sent/tcp_err` calls and then freed the +socket record while the lwIP `tcpip_thread` could still be inside +`tcpReceive` with that record as its argument. Diagnosed from serial-console +panics (`Guru Meditation LoadProhibited` in `tcpReceive`, +`modules/io/socket/lwip/tcp.c`), symbolicated with +`xtensa-esp32s3-elf-addr2line`; 24 such reboots captured in one evening of +ordinary watcher traffic. + +The patch fixes two independent races behind the same panic signature +(discussed with the maintainer in +[moddable#1655](https://github.com/Moddable-OpenSource/moddable/issues/1655)): + +1. **Teardown use-after-free.** `removeTCPCallbacks()` now clears the lwIP + callback argument first (`tcp_arg(pcb, NULL)`) and then the callbacks — + plain pointer stores, per maintainer guidance; no tcpip-thread marshaling + needed. `tcpReceive`/`tcpSent`/`tcpError` gain NULL-argument guards so any + late delivery observes NULL and bails. (An earlier revision marshaled the + clear via `tcpip_api_call`; soak testing showed the simpler arg-first + variant is sufficient. The unused `tcp_clear_callbacks_safe` helper may + remain in modLwipSafe from that revision.) +2. **Receive-buffer list race.** `tcp->buffers` was appended to by the lwIP + task (`tcpReceive` walks to the tail) while the XS task pops and frees + head nodes in `xs_tcp_read` — with no synchronization, the tail walk can + traverse a node mid-free. Both sides now do their pointer surgery inside + `builtinCriticalSection` (heap operations stay outside the critical + section). This was the direct cause of the `LoadProhibited` panics at the + `walker->next` walk and kept crashing hosts that had only fix 1. + +Soak evidence: the device previously crashed every 15–25 minutes under +ordinary watcher traffic; with both fixes it has run clean for hours +(instrumented via the abort hook so app-level restarts are separable). + +Reported and fixed upstream: issue +[moddable#1655](https://github.com/Moddable-OpenSource/moddable/issues/1655), +PR [moddable#1656](https://github.com/Moddable-OpenSource/moddable/pull/1656) +(same two changes). Once the PR lands, drop this patch and update the SDK +instead. `mcconfig` rebuilds pick the change up automatically; the mod does +not need to be rebuilt. diff --git a/mod/audio.js b/mod/audio.js index 6d67713..cd1f9e4 100644 --- a/mod/audio.js +++ b/mod/audio.js @@ -21,6 +21,12 @@ const TTS_DOMAIN = 'tts' const TTS_DEFAULT_PORT = 8787 const TTS_SAMPLE_RATE = 24000 const TTS_VOLUME = 0.7 +// 600ms (the host default) is not enough headroom: XS busy bursts while +// speaking starve the streamer, and every excursion past the buffer is an +// audible mid-sentence dropout. The LAN TTS server delivers the whole WAV far +// faster than realtime, so a deeper buffer costs memory (~72KB at 24kHz) but +// no perceptible start latency. +const TTS_BUFFER_MS = 1500 // Tone patterns per named reaction: [hz, ms] pairs played in order. const TONE_CLIPS = new Map([ @@ -107,13 +113,27 @@ function ensureRemoteTts() { if (!target.host) return undefined const key = `${target.host}:${target.port}` if (remoteTts === undefined || remoteTtsKey !== key) { - const { TTS } = Modules.importNow('tts-remote') - remoteTts = new TTS({ - host: target.host, - port: target.port, - sampleRate: TTS_SAMPLE_RATE, - volume: TTS_VOLUME, - }) + try { + const { RemoteTTS } = Modules.importNow('matchday/tts-remote-safe') + remoteTts = new RemoteTTS({ + host: target.host, + port: target.port, + sampleRate: TTS_SAMPLE_RATE, + volume: TTS_VOLUME, + bufferDuration: TTS_BUFFER_MS, + }) + } catch (error) { + // Host without the audio modules the vendored streamer needs: keep the + // pre-1.6 behavior and let the host's own tts-remote decide. + trace(`[matchday] vendored tts unavailable (${error}); using host tts-remote\n`) + const { TTS } = Modules.importNow('tts-remote') + remoteTts = new TTS({ + host: target.host, + port: target.port, + sampleRate: TTS_SAMPLE_RATE, + volume: TTS_VOLUME, + }) + } remoteTtsKey = key } return remoteTts diff --git a/mod/commands.js b/mod/commands.js index 8b6bf3c..1adf7c4 100644 --- a/mod/commands.js +++ b/mod/commands.js @@ -207,6 +207,7 @@ export function statusPayload() { network: networkPayload(), lastCommand: state.lastCommand, lastError: state.lastError, + lastAbort: state.diagnostics.lastAbort ?? '', } } diff --git a/mod/http-server-safe.js b/mod/http-server-safe.js index 6616ed9..8ed5e44 100644 --- a/mod/http-server-safe.js +++ b/mod/http-server-safe.js @@ -4,9 +4,18 @@ // and it leaves respondWith() rejections unhandled when a client disconnects. // Either failure aborts the complete XS runtime, including the touch handlers. import Headers from 'headers' -import listen from 'listen' +import listen from 'matchday/listen-safe' +import Timer from 'timer' import { URLSearchParams } from 'url' +// A spoken alert holds its /api/command handler for the whole playback, so the +// watchdog must outlast the longest legitimate response. +const REQUEST_TIMEOUT_MS = 45_000 +// lwIP's TCP pcb pool also serves the outbound TTS stream; shed load before +// queued requests can exhaust it. +const MAX_ACTIVE_REQUESTS = 6 +const LISTENER_RESTART_DELAY_MS = 1_000 + class Request { raw #bodyResult @@ -177,38 +186,90 @@ class HttpServerService { delete = (path, handler) => this.#routes.delete.set(path, handler) options = (path, handler) => this.#routes.options.set(path, handler) + #active = 0 + constructor(options = {}) { - this.#listen(options.port).catch((error) => { - trace(`[matchday] HTTP listener stopped: ${error}\n`) - }) + this.#run(options.port) + } + + async #run(port) { + // Listener-level errors (e.g. out of pcbs during a burst) reject the + // accept iterator; without a restart the server would stay dead until + // the next reboot. + for (;;) { + try { + await this.#listen(port) + } catch (error) { + trace(`[matchday] HTTP listener stopped: ${error}\n`) + } + await new Promise((resolve) => Timer.set(resolve, LISTENER_RESTART_DELAY_MS)) + trace('[matchday] HTTP listener restarting\n') + } } async #listen(port) { + // Never await request handling here: one request whose body stalls (a + // phone that left WiFi mid-POST) or whose handler is slow (a spoken alert) + // must not stop every other client from being served. for await (const connection of listen({ port })) { - let context - let response + this.#serve(connection) + } + } + #serve(connection) { + if (this.#active >= MAX_ACTIVE_REQUESTS) { + // respondWith would wait for the full request body, which a wedged peer + // never delivers; dropping the connection is the only safe shed. try { - context = new Context(connection.request) - const routes = this.#routes[context.req.method] - const handler = routes ? routes.get(context.req.path) : undefined - response = handler ? await handler(context) : context.text('Resource Not Found', 404) - if (!response) response = context.text('Empty Response', 500) - } catch (error) { - trace(`[matchday] HTTP handler failed: ${error}\n`) - response = context ? context.text('Internal Server Error', 500) : new Response('Internal Server Error', { status: 500 }) - } + connection.close() + } catch (_closeError) {} + return + } + this.#active++ + + let settled = false + const finish = () => { + if (settled) return + settled = true + this.#active-- + Timer.clear(watchdog) + } + const watchdog = Timer.set(() => { + if (settled) return + trace('[matchday] HTTP request timed out; closing connection\n') + // Closing rejects the listener's pending request/response promises, so + // the in-flight #handle chain settles through its rejection path. + try { + connection.close() + } catch (_closeError) {} + finish() + }, REQUEST_TIMEOUT_MS) - // Keep accepting queued requests while the response is written, but - // attach the rejection handler synchronously so a dropped phone/browser - // connection can never become an unhandled promise rejection. - connection.respondWith(response).catch((error) => { - trace(`[matchday] HTTP response closed: ${error}\n`) - try { - connection.close() - } catch (_closeError) {} - }) + this.#handle(connection).then(finish, (error) => { + trace(`[matchday] HTTP response closed: ${error}\n`) + try { + connection.close() + } catch (_closeError) {} + finish() + }) + } + + async #handle(connection) { + let context + let response + + try { + context = new Context(connection.request) + const routes = this.#routes[context.req.method] + const handler = routes ? routes.get(context.req.path) : undefined + response = handler ? await handler(context) : context.text('Resource Not Found', 404) + if (!response) response = context.text('Empty Response', 500) + } catch (error) { + trace(`[matchday] HTTP handler failed: ${error}\n`) + response = context ? context.text('Internal Server Error', 500) : new Response('Internal Server Error', { status: 500 }) } + + await connection.respondWith(response) } } diff --git a/mod/listen-safe.js b/mod/listen-safe.js new file mode 100644 index 0000000..5b778c7 --- /dev/null +++ b/mod/listen-safe.js @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2021-2025 Moddable Tech, Inc. + * + * This file is part of the Moddable SDK Runtime. + * + * The Moddable SDK Runtime is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Moddable SDK Runtime is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with the Moddable SDK Runtime. If not, see . + * + */ + +// Vendored from $(MODDABLE)/examples/io/listener/listen/listen.js (LGPL-3.0, +// notice above) so the mod does not depend on the host's copy. Two guards are +// added, marked [matchday]: the request and response promises are observed as +// soon as they are created. In the upstream module a connection that errors +// before the app consumes it (a shed connection, or a peer reset while a +// handler is still running) rejects promises that have no reaction attached +// yet; on a release build XS turns that into a silent device restart +// ("unhandled rejection" abort). + +import HTTPServer from "embedded:network/http/server" +import Listener from "embedded:io/socket/listener"; + +import Headers from "headers"; +import URL from "url"; + +class Request { + #url; + #method; + #headers; + #body; + constructor(url, options) { + this.#url = url; + this.#method = options.method; + this.#headers = options.headers; + this.#body = options.body; + } + get bodyUsed() { + return this.#body ? false : true; + } + get headers() { + return this.#headers; + } + get method() { + return this.#method; + } + get url() { + return this.#url; + } + async arrayBuffer() { + let body = this.#body; + if (body) { + this.#body = undefined; + body = await body; + } + return body; + } + async json() { + let body = this.#body; + if (body) { + this.#body = undefined; + body = await body; + if (body) { + body = String.fromArrayBuffer(body); + return JSON.parse(body); + } + } + return body; + } + async text() { + let body = this.#body; + if (body) { + this.#body = undefined; + body = await body; + if (body) + body = String.fromArrayBuffer(body); + } + return body; + } +} + +class Response { + #body; + #headers; + #status = 200; + constructor(body, options) { + if (!(body instanceof ArrayBuffer)) { + body = body.toString(); + body = ArrayBuffer.fromString(body); + } + this.#body = body; + + const headers = (options && options.headers) ? options.headers : new Headers; + if ((headers.get("content-length") == undefined)) { + headers.set("content-length", body.byteLength); + } + this.#headers = headers; + + this.#status = (options && options.status) ? options.status : 200; + } + get body() { + return this.#body; + } + get headers() { + return this.#headers; + } + get status() { + return this.#status; + } + async arrayBuffer() { + let body = this.#body; + if (body) { + this.#body = undefined; + body = await body; + } + return body; + } + async json() { + let body = this.#body; + if (body) { + this.#body = undefined; + body = await body; + body = String.fromArrayBuffer(body); + return JSON.parse(body); + } + return body; + } + async text() { + let body = this.#body; + if (body) { + this.#body = undefined; + body = await body; + body = String.fromArrayBuffer(body); + } + return body; + } +} + +async function* listen(options) { + const connectionQueue = []; + const promiseQueue = []; + const port = options?.port ?? 80; + const base = "http://localhost:" + port; + new HTTPServer({ + io: Listener, + port, + onConnect(_connection_) { + let requestPromise = new Promise((resolveRequest, rejectRequest) => { + let requestBody = null; + let responsePromise = new Promise((resolveResponse, rejectResponse) => { + let responseBody = null; + let offset = 0; + let length = 0; + _connection_.accept({ + onRequest(_request_) { + const { method, path, headers } = _request_; + const request = new Request(new URL(path, base), { method, path, headers, body:requestPromise }); + const connection = { + close() { + _connection_.close(); + }, + async respondWith(response) { + response = await response; + responseBody = await response.arrayBuffer(); + if (responseBody) + length = responseBody.byteLength; + const _response_ = await responsePromise; + _response_.status = response.status; + _response_.headers = response.headers; + _connection_.respond(_response_); + } + }; + Object.defineProperty(connection, "request", { value:request, enumerable:true }); // read only + if (promiseQueue.length == 0) { + connectionQueue.push(connection); + } + else { + const promise = promiseQueue.shift(); + promise.resolveEvent(connection); + } + }, + onReadable(count) { + if (requestBody) + requestBody = requestBody.concat(this.read(count)); + else + requestBody = this.read(count); + }, + onResponse(_response_) { + resolveRequest(requestBody); + resolveResponse(_response_); + }, + onWritable(count) { + if (responseBody) { + if (length > 0) { + if (count > length) + count = length; + let view = new DataView(responseBody, offset, count); + this.write(view); + offset += count; + length -= count; + } + else + this.write(); + } + else + this.write(); + }, + onError(error) { + rejectRequest(error); + rejectResponse(error); + } + }); + }); + // [matchday] Observe the response promise as soon as it exists. + // onError rejects it whenever the peer resets, including while a + // handler is still running (before respondWith attaches) or when + // the server sheds the connection without ever consuming it. + responsePromise.then(undefined, () => {}); + }); + // [matchday] Same guard for the request promise: a shed or + // never-consumed connection has no Request wrapper observing it. + requestPromise.then(undefined, () => {}); + }, + onError(message) { + const error = new Error(message); + if (promiseQueue.length == 0) { + connectionQueue.push(error); + } + else { + const promise = promiseQueue.shift(); + promise.rejectEvent(error); + } + } + }) + while (true) { + yield new Promise((resolveEvent, rejectEvent) => { + if (connectionQueue.length == 0) + promiseQueue.push({ resolveEvent, rejectEvent }); + else { + const connection = connectionQueue.shift(); + if (connection instanceof Error) + rejectEvent(connection); + else + resolveEvent(connection); + } + }) + } +} + +export { Headers, Response } +export default listen; diff --git a/mod/manifest.json b/mod/manifest.json index 8bcdd03..345b83d 100644 --- a/mod/manifest.json +++ b/mod/manifest.json @@ -7,6 +7,8 @@ "matchday/audio": "./audio", "matchday/commands": "./commands", "matchday/http-server-safe": "./http-server-safe", + "matchday/listen-safe": "./listen-safe", + "matchday/tts-remote-safe": "./tts-remote-safe", "matchday/web": "./web" }, "resources": { diff --git a/mod/mod.js b/mod/mod.js index 2f24443..4444885 100644 --- a/mod/mod.js +++ b/mod/mod.js @@ -20,6 +20,7 @@ import { nowTicks, readPreference, safeNetGet, + savePreference, state, toNumber, MIN_IDLE_LOOK_INTERVAL_MS, @@ -39,6 +40,20 @@ import { import { runCommand } from 'matchday/commands' import { startHttp } from 'matchday/web' +// Release hosts restart on uncaught exceptions with no console trace left +// behind (fxAbort -> esp_restart). With the host's MODDEF_XS_ABORTHOOK, this +// hook runs first: record why we died so /api/status can report it after the +// reboot. Returning false lets the restart proceed. +globalThis.abort = function (status, exception) { + try { + const reason = `${status ?? 'abort'}: ${exception ?? ''}`.slice(0, 160) + savePreference('lastAbort', reason) + } catch (_error) { + // never throw from the abort hook + } + return false +} + const SETUP_POWER_DEBOUNCE_MS = 600 const SETUP_TOP_TAP_MAX_MS = 500 const SETUP_TOP_MIN_INTER_TAP_MS = 120 @@ -106,6 +121,15 @@ function restoreSettings(robot) { state.matchSetup.language = pending.language } } + + const lastAbort = String(readPreference('lastAbort', '')) + if (lastAbort) { + state.diagnostics.lastAbort = lastAbort + trace(`[matchday] previous run aborted: ${lastAbort}\n`) + // Consume the record so /api/status only ever reports an abort of the + // run immediately before this boot, not one from days ago. + savePreference('lastAbort', undefined) + } trace('[matchday] settings restored\n') } catch (error) { trace(`[matchday] settings restore failed ${error}\n`) diff --git a/mod/state.js b/mod/state.js index ad039f3..50d39b7 100644 --- a/mod/state.js +++ b/mod/state.js @@ -4,7 +4,7 @@ import Net from 'net' import Time from 'time' export const MOD_NAME = 'stackchan_matchday' -export const MOD_VERSION = '1.5.0' +export const MOD_VERSION = '1.6.0' export const PREF_DOMAIN = 'stackchan' export const COMMENTARY_STYLES = ['casual', 'balanced', 'professional'] @@ -95,6 +95,7 @@ export const state = { matchSetup: { language: 'zh', commentaryStyle: 'balanced', + spoilerFreeMode: false, options: [], current: {}, pending: null, diff --git a/mod/tts-remote-safe.js b/mod/tts-remote-safe.js new file mode 100644 index 0000000..5b6e640 --- /dev/null +++ b/mod/tts-remote-safe.js @@ -0,0 +1,97 @@ +// Remote-TTS streamer vendored from stack-chan's speeches/tts-remote.ts so the +// audio buffer depth is tunable from the mod. The host pins bufferDuration to +// 600ms; XS-thread busy bursts while speaking (balloon marquee, HTTP handling) +// can exceed that, and every excursion is an audible mid-sentence dropout +// (WavStreamer traces "Ready: false" / "Ready: true" around each one — two per +// long sentence in the 2026-07-14 baseline capture). The TTS server sends a +// complete WAV over the LAN, so a deeper buffer fills in well under realtime +// and start latency is unaffected. +// +// Host audio modules are resolved lazily; on a host without them the +// constructor throws and the caller falls back to the host's own tts-remote. +import Modules from 'modules' + +const DEFAULT_SAMPLE_RATE = 24000 +const DEFAULT_VOLUME = 0.5 +const DEFAULT_BUFFER_MS = 1500 + +class RemoteTTS { + #AudioOut + #WavStreamer + #calculatePower + + constructor(props) { + this.onPlayed = props.onPlayed + this.onDone = props.onDone + this.streaming = false + this.host = props.host + this.port = props.port + this.sampleRate = props.sampleRate ?? DEFAULT_SAMPLE_RATE + this.volume = props.volume ?? DEFAULT_VOLUME + this.bufferDuration = props.bufferDuration ?? DEFAULT_BUFFER_MS + + this.#AudioOut = Modules.importNow('pins/audioout') + this.#WavStreamer = Modules.importNow('wavstreamer') + if (this.onPlayed) this.#calculatePower = Modules.importNow('calculate-power') + } + + async stream(key, volume) { + if (this.streaming) { + throw new Error('already playing') + } + this.streaming = true + const { onPlayed, onDone } = this + const calculatePower = this.#calculatePower + return new Promise((resolve, reject) => { + // WavStreamer splits the buffer into 8 blocks and each block occupies + // two queue slots (samples + callback) while it keeps 2 slots free, so + // the default queueLength of 8 caps the in-flight audio at about half of + // bufferDuration. 16 slots let the full depth actually queue. + this.audio = new this.#AudioOut({ streams: 1, sampleRate: this.sampleRate, queueLength: 16 }) + this.audio.enqueue(0, this.#AudioOut.Volume, Math.round((volume ?? this.volume) * 256)) + const audio = this.audio + let streamer + streamer = new this.#WavStreamer({ + http: device.network.http, + host: this.host, + path: key, + port: this.port, + bufferDuration: this.bufferDuration, + audio: { + out: audio, + stream: 0, + }, + onPlayed(buffer) { + if (onPlayed && calculatePower) onPlayed(calculatePower(buffer)) + }, + onReady(state) { + trace(`Ready: ${state}\n`) + if (state) { + audio.start() + } else { + audio.stop() + } + }, + onError: (e) => { + trace('ERROR: ', e, '\n') + this.streaming = false + streamer?.close() + this.audio?.close() + this.audio = undefined + reject(e) + }, + onDone: () => { + trace('DONE\n') + this.streaming = false + streamer?.close() + this.audio?.close() + this.audio = undefined + onDone?.() + resolve() + }, + }) + }) + } +} + +export { RemoteTTS } diff --git a/mod/web.js b/mod/web.js index c88e210..7694534 100644 --- a/mod/web.js +++ b/mod/web.js @@ -41,6 +41,15 @@ function applyCommentaryStyle(commentaryStyle) { return true } +function applySpoilerFreeMode(spoilerFreeMode) { + if (typeof spoilerFreeMode !== 'boolean') return false + if (state.matchSetup.spoilerFreeMode !== spoilerFreeMode) { + state.matchSetup.spoilerFreeMode = spoilerFreeMode + savePreference('spoilerFreeMode', spoilerFreeMode) + } + return true +} + function response(value, status, contentType) { const body = ArrayBuffer.fromString(String(value)) return new Response(body, { @@ -84,6 +93,7 @@ function matchSetupPayload() { ok: true, language: state.matchSetup.language, commentary_style: state.matchSetup.commentaryStyle, + spoiler_free_mode: state.matchSetup.spoilerFreeMode, options: state.matchSetup.options, current: state.matchSetup.current, pending: state.matchSetup.pending, @@ -100,6 +110,7 @@ function syncMatchSetup(payload) { } applyLanguage(payload?.language ?? state.matchSetup.current?.language, true) applyCommentaryStyle(payload?.commentary_style ?? state.matchSetup.current?.commentary_style) + applySpoilerFreeMode(payload?.spoiler_free_mode ?? state.matchSetup.current?.spoiler_free_mode) return { ok: true, text: `ok match setup options ${state.matchSetup.options.length}\n` } } @@ -124,6 +135,24 @@ function queueCommentaryStyle(payload) { return { ok: true, text: 'ok commentary style queued\n', pending } } +function queueSpoilerFreeMode(payload) { + if (typeof payload?.spoiler_free_mode !== 'boolean') { + return { ok: false, status: 400, text: 'error spoiler_free_mode must be a boolean\n' } + } + if (state.matchSetup.pending) { + return { ok: false, status: 409, text: 'error another match setup request is pending\n' } + } + const pending = { + request_id: String(payload?.request_id ?? nowTicks()), + spoiler_only: true, + spoiler_free_mode: payload.spoiler_free_mode, + } + state.matchSetup.pending = pending + state.matchSetup.lastResult = null + savePreference('matchSetupPending', JSON.stringify(pending)) + return { ok: true, text: 'ok spoiler-free mode queued\n', pending } +} + function queueMatchSetup(payload) { const language = String(payload?.language ?? state.matchSetup.language) .trim() @@ -139,9 +168,21 @@ function queueMatchSetup(payload) { if (!COMMENTARY_STYLES.includes(commentaryStyle)) { return { ok: false, status: 400, text: 'error commentary_style must be casual, balanced, or professional\n' } } - // A full setup can absorb a style-only request because it carries the same - // global preference. Never overwrite an unrelated match/market request. - if (state.matchSetup.pending && !state.matchSetup.pending.style_only) { + if (payload?.spoiler_free_mode !== undefined && typeof payload.spoiler_free_mode !== 'boolean') { + return { ok: false, status: 400, text: 'error spoiler_free_mode must be a boolean\n' } + } + // Legacy full-setup clients may omit the new preference. Preserve a pending + // phone toggle across the immediate full apply, otherwise keep the current + // effective value. + const spoilerFreeMode = + typeof payload?.spoiler_free_mode === 'boolean' + ? payload.spoiler_free_mode + : typeof state.matchSetup.pending?.spoiler_free_mode === 'boolean' + ? state.matchSetup.pending.spoiler_free_mode + : state.matchSetup.spoilerFreeMode + // A full setup can absorb a preference-only request because it carries the + // same global settings. Never overwrite an unrelated match/market request. + if (state.matchSetup.pending && !state.matchSetup.pending.style_only && !state.matchSetup.pending.spoiler_only) { return { ok: false, status: 409, text: 'error another match setup request is pending\n' } } @@ -158,6 +199,7 @@ function queueMatchSetup(payload) { kalshi_url: kalshiUrl, language, commentary_style: commentaryStyle, + spoiler_free_mode: spoilerFreeMode, } state.matchSetup.pending = pending state.matchSetup.lastResult = null @@ -192,6 +234,7 @@ function queueMatchSetup(payload) { } pending.language = language pending.commentary_style = commentaryStyle + pending.spoiler_free_mode = spoilerFreeMode state.matchSetup.pending = pending state.matchSetup.lastResult = null savePreference('matchSetupPending', JSON.stringify(pending)) @@ -215,9 +258,13 @@ function acknowledgeMatchSetup(payload) { if (state.matchSetup.lastResult && pending?.style_only) { state.matchSetup.lastResult.style_only = true } + if (state.matchSetup.lastResult && pending?.spoiler_only) { + state.matchSetup.lastResult.spoiler_only = true + } if (payload?.ok) { applyLanguage(payload?.language) applyCommentaryStyle(payload?.commentary_style) + applySpoilerFreeMode(payload?.spoiler_free_mode) } savePreference('matchSetupPending', undefined) return { ok: true, text: 'ok match setup acknowledged\n' } @@ -332,6 +379,7 @@ function setupPageHtml() {

Stack-chan 赛前设置

播报语言 / Language

播报语气

+

防剧透模式

@@ -339,8 +387,8 @@ function setupPageHtml() { ` @@ -382,6 +437,10 @@ refresh(); // --------------------------------------------------------------------------- export function startHttp(robot) { + const savedSpoilerFreeMode = readPreference('spoilerFreeMode', false) + if (typeof savedSpoilerFreeMode === 'boolean') { + state.matchSetup.spoilerFreeMode = savedSpoilerFreeMode + } const server = new HttpServerService({ port: HTTP_PORT }) server.get('/', () => html(indexHtml())) @@ -395,6 +454,7 @@ export function startHttp(robot) { pending: state.matchSetup.pending, language: state.matchSetup.language, commentary_style: state.matchSetup.commentaryStyle, + spoiler_free_mode: state.matchSetup.spoilerFreeMode, }), ) @@ -431,6 +491,22 @@ export function startHttp(robot) { ) }) + server.post('/api/match-setup/spoiler', async (c) => { + const payload = await readJsonOrText(c) + const result = + typeof payload === 'object' ? queueSpoilerFreeMode(payload) : { ok: false, text: 'error JSON required\n' } + return json( + { + ok: result.ok, + text: result.text, + error: result.ok ? undefined : result.text.trim(), + spoiler_free_mode: state.matchSetup.spoilerFreeMode, + pending: result.pending, + }, + result.ok ? 200 : result.status ?? 400, + ) + }) + server.post('/api/match-setup/apply', async (c) => { const payload = await readJsonOrText(c) const result = typeof payload === 'object' ? queueMatchSetup(payload) : { ok: false, text: 'error JSON required\n' } diff --git a/tools/stackchan_kalshi_watch.py b/tools/stackchan_kalshi_watch.py index 4a10cc9..02335a4 100755 --- a/tools/stackchan_kalshi_watch.py +++ b/tools/stackchan_kalshi_watch.py @@ -93,7 +93,13 @@ COMMENTARY_STYLES = frozenset({"casual", "balanced", "professional"}) REQUEST_TIMEOUT_SECONDS = 12 STACKCHAN_COMMAND_TIMEOUT_SECONDS = 20 -MATCH_SETUP_PENDING_POLL_SECONDS = 2.5 +STACKCHAN_SPEECH_TIMEOUT_SECONDS = 45 +STACKCHAN_FEEDBACK_POLL_SECONDS = 1.0 +# Every poll is a fresh TCP connection to the device. Connection churn is the +# trigger for a use-after-free race in the Moddable lwIP socket glue +# (tcpReceive on the tcpip thread vs. close on the XS thread), so poll no +# faster than the setup-page UX truly needs. +MATCH_SETUP_PENDING_POLL_SECONDS = 5.0 MAX_QUEUED_ALERTS = 24 # A failed delivery must never retry immediately: rapid resends pile balloon # redraws and audio setups onto the device (the tone fallback of each retry @@ -241,6 +247,7 @@ class WatchConfig: ticker_enabled: bool = True display_refresh_seconds: int = DEFAULT_DISPLAY_REFRESH_SECONDS alert_balloon_seconds: int = DEFAULT_ALERT_BALLOON_SECONDS + spoiler_free_mode: bool = False quiet_hours: QuietHours = field(default_factory=QuietHours) probability_bar: ProbabilityBarConfig = field(default_factory=ProbabilityBarConfig) setup_server: SetupServerConfig = field(default_factory=SetupServerConfig) @@ -333,6 +340,7 @@ class Alert: source_event_at: datetime | None = None setup_url: str | None = None is_final: bool = False + spoiler_sensitive: bool = False @dataclass @@ -482,6 +490,20 @@ def apply_live_commentary_style(config: WatchConfig, value: Any) -> str: return style +def normalize_spoiler_free_mode(value: Any) -> bool: + if not isinstance(value, bool): + raise ConfigError("spoiler_free_mode must be a boolean") + return value + + +def apply_live_spoiler_free_mode(config: WatchConfig, value: Any) -> bool: + """Hot-apply only the anti-spoiler preference on the live config object.""" + + enabled = normalize_spoiler_free_mode(value) + config.spoiler_free_mode = enabled + return enabled + + def _config_text(value: Any, language: str, *, path: str, fallback: str = "") -> str: try: return resolve_text(value, language, path=path, fallback=fallback) @@ -766,6 +788,9 @@ def load_config(path: Path, language_override: str | None = None) -> WatchConfig alert_balloon_seconds=max( 1, min(30, int(raw.get("alert_balloon_seconds", DEFAULT_ALERT_BALLOON_SECONDS))) ), + spoiler_free_mode=normalize_spoiler_free_mode( + raw.get("spoiler_free_mode", False) + ), quiet_hours=quiet, probability_bar=probability_bar, setup_server=setup_server, @@ -4011,25 +4036,46 @@ def add_view_context(speech: str) -> str: else: note = "" else: - if support_known and position_known: - if support_good == position_good: - note = ( - "如果属实,支持的球队和仓位都会受益" - if support_good - else "如果属实,支持的球队和仓位都会受损" - ) + if style == "casual": + if support_known and position_known: + if support_good == position_good: + note = ( + "要是真的,咱们支持的球队和仓位都舒服了" + if support_good + else "要是真的,支持的球队和仓位可都难受了" + ) + else: + note = ( + "要是真的,球迷开心,仓位可要承压" + if support_good + else "要是真的,心里难受,不过仓位倒是受益" + ) + elif support_known: + note = "要是真的,咱们支持的球队就舒服了" if support_good else "要是真的,支持方可不好受" + elif position_known: + note = "要是真的,这波对仓位有利" if position_good else "要是真的,这波仓位要承压" else: - note = ( - "如果属实,球迷这边开心,仓位却会承压" - if support_good - else "如果属实,感情上不好受,不过仓位会受益" - ) - elif support_known: - note = "如果属实,这是支持方的好消息" if support_good else "如果属实,这对支持方不利" - elif position_known: - note = "如果属实,当前仓位会受益" if position_good else "如果属实,当前仓位会承压" + note = "" else: - note = "" + if support_known and position_known: + if support_good == position_good: + note = ( + "如果属实,支持的球队和仓位都会受益" + if support_good + else "如果属实,支持的球队和仓位都会受损" + ) + else: + note = ( + "如果属实,球迷这边开心,仓位却会承压" + if support_good + else "如果属实,感情上不好受,不过仓位会受益" + ) + elif support_known: + note = "如果属实,这是支持方的好消息" if support_good else "如果属实,这对支持方不利" + elif position_known: + note = "如果属实,当前仓位会受益" if position_good else "如果属实,当前仓位会承压" + else: + note = "" return join_sentences(config.language, speech, note) if note else speech def ensure_uncertainty(speech: str) -> str: @@ -4088,7 +4134,10 @@ def clause_before(source: str, index: int) -> str: and "确认" in speech and ("等" in speech or "待" in speech) ) - safe = f"盘口突变,{goal_team}疑似进球,等待文字直播确认。" + safe = ( + f"盘口突然{'拉升' if rising else '跳水'}!" + f"{goal_team}这边很可能进球了!先等文字直播确认。" + ) confirmation_suffix = "仍需等待文字直播确认。" if not uncertain or has_unqualified_claim(): return add_view_context(safe) @@ -4118,19 +4167,19 @@ def clause_before(source: str, index: int) -> str: ) if style == "casual": return add_view_context( - f"先别急,{snapshot.label}盘口突然{'拉升' if rising else '跳水'}{abs(rapid_delta)}分," - f"来到{mid}分。{goal_team}进球的可能性更大,不过目前还只是疑似," - "要等文字直播确认。" + f"盘口突然{'拉升' if rising else '跳水'}!" + f"{goal_team}这边很可能进球了!" + "先别眨眼,等文字直播确认!" ) if style == "professional": return add_view_context( - f"{snapshot.label}盘口快速{'上行' if rising else '下挫'}{abs(rapid_delta)}分至{mid}分。" - f"{goal_team}进球的可能性上升,但目前仍属疑似," - "等待文字直播确认。" + f"盘口快速{'上行' if rising else '下挫'}!{snapshot.label}在短时间内" + f"变动{abs(rapid_delta)}分至{mid}分,{goal_team}进球概率骤升," + "但目前仍是疑似。等待文字直播确认!" ) return add_view_context( - f"盘口突然{'拉升' if rising else '跳水'}," - f"{goal_team}疑似进球,等待文字直播确认。" + f"盘口突然{'拉升' if rising else '跳水'}!" + f"{goal_team}这边很可能进球了!先等文字直播确认。" ) @@ -4173,6 +4222,7 @@ def evaluate_market( f"{snapshot.label} status changed to {snapshot.status}.", ), detail=f"status {state.last_status} -> {snapshot.status}", + spoiler_sensitive=True, ) ) @@ -4198,6 +4248,7 @@ def evaluate_market( f"{snapshot.label} closes in about {english_quantity(minutes_left, 'minute')}.", ), detail=f"close_time={snapshot.close_time.isoformat()}", + spoiler_sensitive=True, ) ) state.near_close_alerted = True @@ -4229,6 +4280,7 @@ def evaluate_market( f"{snapshot.label} YES spread {word} to {english_quantity(current_spread, 'cent')}.", ), detail=f"yes_spread {previous_spread}c -> {current_spread}c", + spoiler_sensitive=True, ) ) @@ -4279,6 +4331,7 @@ def evaluate_market( ), clip_id="odds-up" if rising else "odds-down", prefer_dynamic_voice=True, + spoiler_sensitive=True, ) ) state.last_goal_signal_at = now_ts @@ -4312,6 +4365,7 @@ def evaluate_market( speech=speech, detail=f"{side_text(config.side_i_care)} mid {baseline}c -> {mid}c", clip_id=clip_id, + spoiler_sensitive=True, ) ) @@ -4321,6 +4375,65 @@ def evaluate_market( return alerts +def apply_spoiler_policy_to_market_alerts( + enabled: bool, + alerts: list[Alert], + snapshot: MarketSnapshot, + market: MarketConfig, + state: MarketState, + now: datetime, +) -> list[Alert]: + """Consume hidden market moves while retaining non-spoiler market events. + + The alert baseline follows every protected poll, not merely moves large + enough to raise an alert. Turning protection off therefore starts from the + latest visible market state instead of replaying an accumulated move. + """ + + if not enabled: + return alerts + sensitive_alerts = [alert for alert in alerts if alert.spoiler_sensitive] + mid = snapshot.implied_probability(market.side_i_care) + if mid is not None: + state.last_alert_mid_cents = mid + if sensitive_alerts: + state.last_alert_at = now.timestamp() + return [alert for alert in alerts if not alert.spoiler_sensitive] + + +def consume_spoiler_market_baselines( + config: WatchConfig, + snapshots: dict[str, MarketSnapshot], + states: dict[str, MarketState], + now: datetime, +) -> None: + """Consume the latest passive market state when protection is enabled. + + This closes the small toggle-between-polls window: a queued move must not + become a fresh catch-up alert if protection is enabled and then disabled + before the next Kalshi response arrives. + """ + + for market in config.markets: + snapshot = snapshots.get(market.ticker) + state = states.get(market.ticker) + if snapshot is None or state is None: + continue + mid = snapshot.implied_probability(market.side_i_care) + if mid is not None: + state.last_alert_mid_cents = mid + state.last_observed_mid_cents = mid + state.last_yes_spread_cents = snapshot.yes_spread() + state.last_status = snapshot.status + if ( + snapshot.status.lower() not in CLOSED_STATUSES + and snapshot.close_time is not None + and 0 < (snapshot.close_time - now).total_seconds() + <= market.near_close_minutes * 60 + ): + state.near_close_alerted = True + + def mark_alert_sent(alert: Alert, snapshot: MarketSnapshot, config: MarketConfig, state: MarketState, now: datetime) -> None: state.last_alert_at = now.timestamp() mid = snapshot.implied_probability(config.side_i_care) @@ -4397,6 +4510,12 @@ def merge_alert_queue( return queued[:MAX_QUEUED_ALERTS] +def purge_spoiler_sensitive_alerts(queue: list[QueuedAlert]) -> list[QueuedAlert]: + """Drop protected alerts that have not started delivery yet.""" + + return [item for item in queue if not item.alert.spoiler_sensitive] + + def _version_tuple(value: str) -> tuple[int, int, int]: numbers: list[int] = [] for part in value.lstrip("vV").split(".")[:3]: @@ -4619,8 +4738,13 @@ def setup_confirmation_alert(config: WatchConfig) -> Alert: def post_stackchan_http_command(host: str, command: str) -> None: url = f"http://{host}/api/command" req = urllib.request.Request(url, data=command.encode("utf-8"), method="POST") + timeout = ( + STACKCHAN_SPEECH_TIMEOUT_SECONDS + if command.lstrip().lower().startswith("say ") + else STACKCHAN_COMMAND_TIMEOUT_SECONDS + ) with STACKCHAN_DEVICE_HTTP_LOCK: - with urllib.request.urlopen(req, timeout=STACKCHAN_COMMAND_TIMEOUT_SECONDS) as res: + with urllib.request.urlopen(req, timeout=timeout) as res: res.read() @@ -4660,6 +4784,9 @@ def sync_device_match_setup( "commentary_style": str( current.get("commentary_style") or config.espn.commentary_style ), + "spoiler_free_mode": normalize_spoiler_free_mode( + current.get("spoiler_free_mode", config.spoiler_free_mode) + ), "options": options, "current": current, }, @@ -4682,6 +4809,22 @@ def sync_device_commentary_style(config: WatchConfig, style: str) -> bool: return False +def sync_device_spoiler_free_mode(config: WatchConfig, enabled: Any) -> bool: + """Best-effort anti-spoiler sync without replacing match setup options.""" + + if config.stackchan_transport != "http": + return True + try: + post_json( + f"http://{config.stackchan_host}/api/match-setup/spoiler", + {"spoiler_free_mode": normalize_spoiler_free_mode(enabled)}, + timeout=2, + ) + return True + except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError): + return False + + def fetch_device_match_setup_pending(config: WatchConfig) -> dict[str, Any] | None: if config.stackchan_transport != "http": return None @@ -4777,14 +4920,20 @@ def wait_for_stackchan_feedback_idle( file=sys.stderr, ) return True - time.sleep(0.25) + time.sleep(STACKCHAN_FEEDBACK_POLL_SECONDS) print(f"warning: Stack-chan feedback still busy after {timeout:g}s", file=sys.stderr) return False def send_alert(config: WatchConfig, alert: Alert, quiet: bool, dry_run: bool, no_say: bool) -> bool: + # Defense in depth for alerts queued before a live preference change. The + # watcher also purges them eagerly, but the delivery boundary must never + # emit protected market information while the mode is active. + if config.spoiler_free_mode and alert.spoiler_sensitive: + return True timeout_ms = config.alert_balloon_seconds * 1000 commands = [f"face {alert.face}"] + deferred_stackchan_speech = "" if alert.setup_url and config.setup_qr_commands: commands.append(f"setup show {alert.setup_url}") else: @@ -4815,15 +4964,15 @@ def send_alert(config: WatchConfig, alert: Alert, quiet: bool, dry_run: bool, no ) if use_goal_celebration: red, green, blue = alert.light_rgb or (255, 255, 255) + commands.append(f"celebrate goal {red} {green} {blue}") if use_dynamic_voice: - commands.append(f"celebrate say {red} {green} {blue} {alert.speech}") - else: - commands.append(f"celebrate goal {red} {green} {blue}") + deferred_stackchan_speech = alert.speech or "" elif use_result_celebration: red, green, blue = alert.light_rgb or (255, 255, 255) outcome = alert.celebration.removeprefix("result-") - speech = f" {alert.speech}" if alert.speech and config.result_speech_commands else "" - commands.append(f"celebrate result {outcome} {red} {green} {blue}{speech}") + commands.append(f"celebrate result {outcome} {red} {green} {blue}") + if alert.speech and config.result_speech_commands: + deferred_stackchan_speech = alert.speech elif alert.light_rgb: red, green, blue = alert.light_rgb commands.append(f"light flash {red} {green} {blue} 1800 110") @@ -4850,14 +4999,28 @@ def send_alert(config: WatchConfig, alert: Alert, quiet: bool, dry_run: bool, no except (urllib.error.URLError, OSError, RuntimeError) as error: print(f"warning: stackchan commands failed: {error}", file=sys.stderr) return False + celebration_settled = True if uses_celebration and not dry_run: - # The MOD acknowledges celebration commands before their asynchronous - # motion/TTS finishes; serialize subsequent alerts against real state. - wait_for_stackchan_feedback_idle( + # Keep LAN TTS out of the MOD's asynchronous celebration window. The + # local fanfare and head motion finish first; the blocking `say` request + # below then owns the only active watcher/device HTTP exchange while + # the WAV stream is open. + celebration_settled = wait_for_stackchan_feedback_idle( config, - include_light=True, + include_light=not bool(deferred_stackchan_speech), report_last_error=True, ) + if deferred_stackchan_speech and (dry_run or celebration_settled): + try: + send_stackchan_commands( + config, + [f"say {deferred_stackchan_speech}"], + dry_run=dry_run, + ) + except (urllib.error.URLError, OSError, RuntimeError) as error: + # The balloon, fanfare, and motion were already delivered. Do not + # retry the full goal/result and replay it just because TTS failed. + print(f"warning: deferred Stack-chan speech failed: {error}", file=sys.stderr) if alert.speech and should_play_voice: speak_text(config, alert.speech, dry_run=dry_run) return True @@ -4962,7 +5125,7 @@ def send_summary( speech = join_sentences(config.language, *items) try: send_ticker(config, snapshots, dry_run=dry_run) - if not no_say and not quiet and speech: + if not config.spoiler_free_mode and not no_say and not quiet and speech: speak_text(config, speech, dry_run=dry_run) except (urllib.error.URLError, OSError, RuntimeError) as error: print(f"warning: stackchan summary failed: {error}", file=sys.stderr) @@ -5080,6 +5243,8 @@ def run_watch(args: argparse.Namespace) -> int: setup_acknowledgements: dict[str, dict[str, Any]] = {} pending_device_style_sync: str | None = None next_device_style_sync_at = 0.0 + pending_device_spoiler_sync: bool | None = None + next_device_spoiler_sync_at = 0.0 delivery_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="stackchan-delivery") delivery_future: Future[bool] | None = None delivery_item: QueuedAlert | None = None @@ -5108,6 +5273,29 @@ def run_watch(args: argparse.Namespace) -> int: f"commentary style applied: {commentary_style_update}", flush=True, ) + spoiler_free_mode_update = setup_service.take_spoiler_free_mode_update() + if spoiler_free_mode_update is not None: + enabled = apply_live_spoiler_free_mode( + config, + spoiler_free_mode_update, + ) + if enabled: + consume_spoiler_market_baselines( + config, + snapshots, + states, + now, + ) + alert_queue = purge_spoiler_sensitive_alerts(alert_queue) + pending_device_spoiler_sync = enabled + if sync_device_spoiler_free_mode(config, enabled): + pending_device_spoiler_sync = None + else: + next_device_spoiler_sync_at = cycle_monotonic + 5 + print( + f"spoiler-free mode applied: {str(enabled).lower()}", + flush=True, + ) if ( pending_device_style_sync @@ -5118,6 +5306,15 @@ def run_watch(args: argparse.Namespace) -> int: else: next_device_style_sync_at = cycle_monotonic + 5 + if ( + pending_device_spoiler_sync is not None + and cycle_monotonic >= next_device_spoiler_sync_at + ): + if sync_device_spoiler_free_mode(config, pending_device_spoiler_sync): + pending_device_spoiler_sync = None + else: + next_device_spoiler_sync_at = cycle_monotonic + 5 + if setup_service and setup_service.take_reload_requested(): previous_dynamic_voice = config.dynamic_voice_commands previous_result_commands = config.result_celebration_commands @@ -5145,6 +5342,7 @@ def run_watch(args: argparse.Namespace) -> int: next_schedule_refresh_at = 0.0 next_setup_pending_poll_at = 0.0 pending_device_style_sync = None + pending_device_spoiler_sync = None last_poll_tier = "" quiet = in_quiet_hours(config.quiet_hours) try: @@ -5221,11 +5419,25 @@ def run_watch(args: argparse.Namespace) -> int: setup_request = fetch_device_match_setup_pending(config) if setup_request: request_id = str(setup_request.get("request_id") or "") + has_match_selection = any( + setup_request.get(key) + for key in ( + "kalshi_url", + "event_ticker", + "espn_event_id", + ) + ) + spoiler_only_request = bool( + setup_request.get("spoiler_only") + ) or ( + "spoiler_free_mode" in setup_request + and not has_match_selection + and "commentary_style" not in setup_request + ) style_only_request = bool(setup_request.get("style_only")) or ( - bool(setup_request.get("commentary_style")) - and not setup_request.get("kalshi_url") - and not setup_request.get("event_ticker") - and not setup_request.get("espn_event_id") + "commentary_style" in setup_request + and not has_match_selection + and "spoiler_free_mode" not in setup_request ) acknowledgement = setup_acknowledgements.get(request_id) if acknowledgement is None: @@ -5234,7 +5446,29 @@ def run_watch(args: argparse.Namespace) -> int: bool(setup_request.get("kalshi_url")) and not setup_request.get("espn_event_id") ) - if style_only_request: + if spoiler_only_request: + result = setup_service.apply_spoiler_free_mode( + setup_request + ) + enabled = apply_live_spoiler_free_mode( + config, + result["spoiler_free_mode"], + ) + if enabled: + consume_spoiler_market_baselines( + config, + snapshots, + states, + now, + ) + alert_queue = purge_spoiler_sensitive_alerts( + alert_queue + ) + # The device already holds this value; consume + # the local service notification without + # echoing it back through the relay. + setup_service.take_spoiler_free_mode_update() + elif style_only_request: result = setup_service.apply_commentary_style(setup_request) apply_live_commentary_style( config, @@ -5248,11 +5482,36 @@ def run_watch(args: argparse.Namespace) -> int: result = setup_service.apply_market_selection(setup_request) else: result = setup_service.apply_selection(setup_request) + if ( + not spoiler_only_request + and "spoiler_free_mode" in result + ): + enabled = apply_live_spoiler_free_mode( + config, + result["spoiler_free_mode"], + ) + if enabled: + consume_spoiler_market_baselines( + config, + snapshots, + states, + now, + ) + alert_queue = purge_spoiler_sensitive_alerts( + alert_queue + ) acknowledgement = { "request_id": request_id, "ok": True, - "commentary_style": result["commentary_style"], } + if result.get("commentary_style") is not None: + acknowledgement["commentary_style"] = result[ + "commentary_style" + ] + if result.get("spoiler_free_mode") is not None: + acknowledgement["spoiler_free_mode"] = result[ + "spoiler_free_mode" + ] if result.get("label") is not None: acknowledgement["label"] = result["label"] if result.get("language") is not None: @@ -5271,6 +5530,8 @@ def run_watch(args: argparse.Namespace) -> int: acknowledge_device_match_setup(config, acknowledgement) if style_only_request and acknowledgement.get("ok"): pending_device_style_sync = None + if spoiler_only_request and acknowledgement.get("ok"): + pending_device_spoiler_sync = None setup_acknowledgements.pop(request_id, None) except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError): pass @@ -5361,6 +5622,29 @@ def run_watch(args: argparse.Namespace) -> int: pending_device_style_sync = None else: next_device_style_sync_at = time.monotonic() + 5 + spoiler_after_fetch = ( + setup_service.take_spoiler_free_mode_update() + ) + if spoiler_after_fetch is not None: + enabled = apply_live_spoiler_free_mode( + config, + spoiler_after_fetch, + ) + if enabled: + consume_spoiler_market_baselines( + config, + snapshots, + states, + now, + ) + alert_queue = purge_spoiler_sensitive_alerts( + alert_queue + ) + pending_device_spoiler_sync = enabled + if sync_device_spoiler_free_mode(config, enabled): + pending_device_spoiler_sync = None + else: + next_device_spoiler_sync_at = time.monotonic() + 5 kalshi_failures = 0 next_kalshi_poll_at = max( poll_started_at + poll_plan.kalshi_seconds, @@ -5374,6 +5658,14 @@ def run_watch(args: argparse.Namespace) -> int: continue state = states[market.ticker] alerts = evaluate_market(snapshot, market, state, now) + alerts = apply_spoiler_policy_to_market_alerts( + config.spoiler_free_mode, + alerts, + snapshot, + market, + state, + now, + ) for alert in alerts: pending.append((alert, snapshot, market, state)) except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error: @@ -5404,6 +5696,29 @@ def run_watch(args: argparse.Namespace) -> int: pending_device_style_sync = None else: next_device_style_sync_at = time.monotonic() + 5 + spoiler_after_fetch = ( + setup_service.take_spoiler_free_mode_update() + ) + if spoiler_after_fetch is not None: + enabled = apply_live_spoiler_free_mode( + config, + spoiler_after_fetch, + ) + if enabled: + consume_spoiler_market_baselines( + config, + snapshots, + states, + now, + ) + alert_queue = purge_spoiler_sensitive_alerts( + alert_queue + ) + pending_device_spoiler_sync = enabled + if sync_device_spoiler_free_mode(config, enabled): + pending_device_spoiler_sync = None + else: + next_device_spoiler_sync_at = time.monotonic() + 5 previous_poll_tier = poll_plan.tier latest_match = match poll_plan = adaptive_polling_plan(config, latest_match, now) @@ -5429,6 +5744,11 @@ def run_watch(args: argparse.Namespace) -> int: file=sys.stderr, ) + if config.spoiler_free_mode: + pending = [ + item for item in pending if not item[0].spoiler_sensitive + ] + alert_queue = purge_spoiler_sensitive_alerts(alert_queue) alert_queue = merge_alert_queue(alert_queue, pending, cycle_monotonic) current_display_command = persistent_display_command(config, snapshots) diff --git a/tools/stackchan_match_setup.py b/tools/stackchan_match_setup.py index d9ca267..b5c7d87 100644 --- a/tools/stackchan_match_setup.py +++ b/tools/stackchan_match_setup.py @@ -24,6 +24,7 @@ DEFAULT_SETUP_PORT = 8788 DEFAULT_COMMENTARY_STYLE = "balanced" COMMENTARY_STYLES = frozenset({"casual", "balanced", "professional"}) +DEFAULT_SPOILER_FREE_MODE = False def normalize_commentary_style(value: Any) -> str: @@ -34,6 +35,20 @@ def normalize_commentary_style(value: Any) -> str: return style +def normalize_spoiler_free_mode(value: Any, *, path: str = "spoiler_free_mode") -> bool: + """Require a real JSON boolean instead of accepting truthy values.""" + + if type(value) is not bool: + raise ValueError(f"{path} must be a JSON boolean") + return value + + +def configured_spoiler_free_mode(raw: dict[str, Any]) -> bool: + if "spoiler_free_mode" not in raw: + return DEFAULT_SPOILER_FREE_MODE + return normalize_spoiler_free_mode(raw["spoiler_free_mode"]) + + TEAM_METADATA: dict[str, tuple[str, str, str]] = { "Algeria": ("阿尔及利亚", "dz", "#006633"), "Argentina": ("阿根廷", "ar", "#75AADB"), @@ -299,6 +314,7 @@ def __init__( self._config_mutation_lock = threading.RLock() self._reload_requested = threading.Event() self._commentary_style_update: str | None = None + self._spoiler_free_mode_update: bool | None = None self._upcoming_cache: list[dict[str, Any]] = [] self._upcoming_cached_at = 0.0 self._options_cache: list[dict[str, Any]] = [] @@ -318,6 +334,14 @@ def take_commentary_style_update(self) -> str | None: self._commentary_style_update = None return style + def take_spoiler_free_mode_update(self) -> bool | None: + """Return a live anti-spoiler update without requesting a full reload.""" + + with self._lock: + spoiler_free_mode = self._spoiler_free_mode_update + self._spoiler_free_mode_update = None + return spoiler_free_mode + def upcoming_matches(self, force: bool = False) -> list[dict[str, Any]]: with self._lock: if ( @@ -460,6 +484,7 @@ def current_status(self) -> dict[str, Any]: "commentary_style": normalize_commentary_style( espn.get("commentary_style", DEFAULT_COMMENTARY_STYLE) ), + "spoiler_free_mode": configured_spoiler_free_mode(raw), "kalshi_url": str((raw.get("setup_server") or {}).get("last_kalshi_url") or ""), "event_id": str(espn.get("event_id") or ""), "label": resolve_text(espn.get("label"), language, path="espn.label"), @@ -515,9 +540,28 @@ def apply_commentary_style(self, payload: dict[str, Any]) -> dict[str, Any]: self._commentary_style_update = style return {"ok": True, "commentary_style": style} + @serialized_config_mutation + def apply_spoiler_free_mode(self, payload: dict[str, Any]) -> dict[str, Any]: + """Persist and signal an anti-spoiler-only update without reloading.""" + + if "spoiler_free_mode" not in payload: + raise ValueError("spoiler_free_mode is required") + spoiler_free_mode = normalize_spoiler_free_mode(payload["spoiler_free_mode"]) + raw = json.loads(self.config_path.read_text(encoding="utf-8")) + raw["spoiler_free_mode"] = spoiler_free_mode + atomic_write_json(self.config_path, raw) + with self._lock: + self._spoiler_free_mode_update = spoiler_free_mode + return {"ok": True, "spoiler_free_mode": spoiler_free_mode} + @serialized_config_mutation def apply_selection(self, payload: dict[str, Any]) -> dict[str, Any]: language = normalize_language(payload.get("language", self.language), path="language") + requested_spoiler_free_mode = payload.get("spoiler_free_mode") + if "spoiler_free_mode" in payload: + requested_spoiler_free_mode = normalize_spoiler_free_mode( + requested_spoiler_free_mode + ) requested_style = payload.get("commentary_style") commentary_style = ( normalize_commentary_style(requested_style) @@ -574,6 +618,12 @@ def canonical_selected_team(field: str, error: str) -> str: ) raw = json.loads(self.config_path.read_text(encoding="utf-8")) + spoiler_free_mode = ( + requested_spoiler_free_mode + if "spoiler_free_mode" in payload + else configured_spoiler_free_mode(raw) + ) + raw["spoiler_free_mode"] = spoiler_free_mode if not commentary_style: commentary_style = normalize_commentary_style( (raw.get("espn") or {}).get("commentary_style", DEFAULT_COMMENTARY_STYLE) @@ -650,24 +700,22 @@ def canonical_selected_team(field: str, error: str) -> str: "goal_signal_enabled": True, "goal_signal_up_speech": localized_pair( ( - f"{localized[index]}晋级盘口快速上行。" - f"{localized[index]}进球的可能性上升,但目前仍属疑似," - "等待文字直播确认。" + f"盘口突然拉升!{localized[index]}这边很可能进球了!" + "先别眨眼,等文字直播确认!" ), ( - f"The {english[index]} advancement market moved sharply higher. " - f"Possible goal for {english[index]}; awaiting commentary confirmation." + f"The market just jumped! {english[index]} may have scored. " + "Don't blink—waiting for commentary confirmation!" ), ), "goal_signal_down_speech": localized_pair( ( - f"{localized[index]}晋级盘口快速下挫。" - f"{localized[opposing_index]}进球的可能性上升,但目前仍属疑似," - "等待文字直播确认。" + f"盘口突然跳水!{localized[opposing_index]}这边很可能进球了!" + "先别眨眼,等文字直播确认!" ), ( - f"The {english[index]} advancement market moved sharply lower. " - f"Possible goal for {english[opposing_index]}; awaiting commentary confirmation." + f"The market just dropped! {english[opposing_index]} may have scored. " + "Don't blink—waiting for commentary confirmation!" ), ), "goal_signal_up_team": localized_pair( @@ -703,6 +751,7 @@ def canonical_selected_team(field: str, error: str) -> str: "ok": True, "language": language, "commentary_style": commentary_style, + "spoiler_free_mode": spoiler_free_mode, "label": resolve_text(espn["label"], language, path="espn.label"), "label_i18n": { lang: resolve_text(espn["label"], lang, path="espn.label") @@ -724,6 +773,11 @@ def apply_market_selection(self, payload: dict[str, Any]) -> dict[str, Any]: team flags nor a matching fixture. """ language = normalize_language(payload.get("language", self.language), path="language") + requested_spoiler_free_mode = payload.get("spoiler_free_mode") + if "spoiler_free_mode" in payload: + requested_spoiler_free_mode = normalize_spoiler_free_mode( + requested_spoiler_free_mode + ) requested_style = payload.get("commentary_style") commentary_style = ( normalize_commentary_style(requested_style) @@ -748,6 +802,12 @@ def traded_volume(market: dict[str, Any]) -> float: event_title = str(event.get("title") or event.get("sub_title") or event_ticker) raw = json.loads(self.config_path.read_text(encoding="utf-8")) + spoiler_free_mode = ( + requested_spoiler_free_mode + if "spoiler_free_mode" in payload + else configured_spoiler_free_mode(raw) + ) + raw["spoiler_free_mode"] = spoiler_free_mode if not commentary_style: commentary_style = normalize_commentary_style( (raw.get("espn") or {}).get("commentary_style", DEFAULT_COMMENTARY_STYLE) @@ -806,6 +866,7 @@ def traded_volume(market: dict[str, Any]) -> float: "ok": True, "language": language, "commentary_style": commentary_style, + "spoiler_free_mode": spoiler_free_mode, "label": event_title, "label_i18n": {lang: event_title for lang in SUPPORTED_LANGUAGES}, "event_id": "", @@ -868,6 +929,7 @@ def setup_page_html() -> str:

Stack-chan 赛前设置

连接中

播报语言 / Commentary language

选择比赛并点“开始看球”后生效

播报语气

当前生效:自然播报
可在比赛中即时切换,不会重播旧事件
+

防剧透模式

当前生效:普通
防剧透会关闭所有 Kalshi 盘口主动消息,包括价格变化和疑似进球;ESPN 已确认事件照常播报,概率条与 ticker 仍会更新。

未来比赛

正在读取赛程

盘口与直播

@@ -885,11 +947,15 @@ def setup_page_html() -> str:

当前监控

读取中
""" @@ -979,6 +1046,9 @@ def do_POST(self) -> None: # noqa: N802 if path == "/api/setup/style": self._json(self.service.apply_commentary_style(payload)) return + if path == "/api/setup/spoiler": + self._json(self.service.apply_spoiler_free_mode(payload)) + return self._json({"error": "not found"}, 404) except (OSError, ValueError, json.JSONDecodeError, urllib.error.URLError) as error: self._json({"error": str(error)}, 400) diff --git a/tools/test_stackchan_commentary_style_contract.py b/tools/test_stackchan_commentary_style_contract.py index 22dccad..556af10 100644 --- a/tools/test_stackchan_commentary_style_contract.py +++ b/tools/test_stackchan_commentary_style_contract.py @@ -57,7 +57,7 @@ def test_documented_style_endpoints_exist_in_both_services(self): def test_mod_version_and_release_notes_stay_aligned(self): mod_state = (ROOT / "mod" / "state.js").read_text(encoding="utf-8") - release_notes = (ROOT / "docs" / "releases" / "1.5.0.md").read_text( + release_notes = (ROOT / "docs" / "releases" / "1.6.0.md").read_text( encoding="utf-8" ) readmes = "\n".join( @@ -67,9 +67,9 @@ def test_mod_version_and_release_notes_stay_aligned(self): match = re.search(r"MOD_VERSION\s*=\s*'([^']+)'", mod_state) self.assertIsNotNone(match) - self.assertEqual(match.group(1), "1.5.0") - self.assertIn("# Matchday MOD 1.5.0", release_notes) - self.assertIn("docs/releases/1.5.0.md", readmes) + self.assertEqual(match.group(1), "1.6.0") + self.assertIn("# Matchday MOD 1.6.0", release_notes) + self.assertIn("docs/releases/1.6.0.md", readmes) if __name__ == "__main__": diff --git a/tools/test_stackchan_kalshi_watch.py b/tools/test_stackchan_kalshi_watch.py index e10fbce..b18de7f 100644 --- a/tools/test_stackchan_kalshi_watch.py +++ b/tools/test_stackchan_kalshi_watch.py @@ -252,6 +252,7 @@ def test_device_setup_sync_uses_effective_service_language(self): self.assertEqual(post.call_args.args[1]["language"], "en") self.assertEqual(post.call_args.args[1]["commentary_style"], "balanced") + self.assertFalse(post.call_args.args[1]["spoiler_free_mode"]) def test_style_sync_is_lightweight_and_does_not_send_options(self): config = watcher.WatchConfig( @@ -281,6 +282,52 @@ def test_style_sync_is_lightweight_and_does_not_send_options(self): watcher.sync_device_commentary_style(config, "professional") ) + def test_spoiler_sync_uses_dedicated_endpoint_and_preserves_false(self): + config = watcher.WatchConfig( + stackchan_transport="http", + stackchan_host="192.0.2.1", + ) + + with patch.object(watcher, "post_json") as post: + synced = watcher.sync_device_spoiler_free_mode(config, False) + + self.assertTrue(synced) + self.assertEqual( + post.call_args.args, + ( + "http://192.0.2.1/api/match-setup/spoiler", + {"spoiler_free_mode": False}, + ), + ) + self.assertEqual(post.call_args.kwargs, {"timeout": 2}) + + with patch.object( + watcher, + "post_json", + side_effect=watcher.urllib.error.URLError("offline"), + ): + self.assertFalse(watcher.sync_device_spoiler_free_mode(config, True)) + + def test_spoiler_free_mode_defaults_loads_and_validates_boolean(self): + raw = self.localized_config() + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "config.json" + path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + defaulted = watcher.load_config(path) + raw["spoiler_free_mode"] = True + path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + enabled = watcher.load_config(path) + raw["spoiler_free_mode"] = "false" + path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + with self.assertRaisesRegex( + watcher.ConfigError, + "spoiler_free_mode must be a boolean", + ): + watcher.load_config(path) + + self.assertFalse(defaulted.spoiler_free_mode) + self.assertTrue(enabled.spoiler_free_mode) + def test_commentary_style_defaults_validates_and_propagates_to_markets(self): raw = self.localized_config() raw["espn"]["commentary_style"] = "professional" @@ -328,6 +375,22 @@ def test_live_style_update_mutates_only_rendering_preferences(self): self.assertFalse(config.markets[0].alerts_enabled) self.assertEqual(config.markets[0].goal_signal_move_cents, 8) + def test_live_spoiler_update_mutates_only_global_preference(self): + config = watcher.WatchConfig( + espn=espn_config(), + markets=[watcher.MarketConfig("FRA", "法国晋级")], + ) + espn_config_object = config.espn + market_config_object = config.markets[0] + + enabled = watcher.apply_live_spoiler_free_mode(config, True) + + self.assertTrue(enabled) + self.assertTrue(config.spoiler_free_mode) + self.assertIs(config.espn, espn_config_object) + self.assertIs(config.markets[0], market_config_object) + self.assertEqual(config.espn.event_id, "760510") + def test_old_goal_signal_speeches_migrate_exact_team_facts(self): raw = { "language": "zh", @@ -405,6 +468,7 @@ def test_backed_team_win_gets_result_voice_motion_and_team_light(self): self.assertEqual(alert.light_rgb, (0, 85, 164)) self.assertEqual(alert.face, "happy") self.assertFalse(alert.prefer_dynamic_voice) + self.assertFalse(alert.spoiler_sensitive) def test_backed_team_loss_gets_loss_reaction_in_backed_team_color(self): item = { @@ -2429,6 +2493,32 @@ def test_probability_bar_uses_france_yes_and_binary_complement(self): class MarketAlertTests(unittest.TestCase): + def test_spoiler_mode_keeps_summary_display_but_suppresses_summary_speech(self): + market = watcher.MarketConfig("FRA", "法国晋级") + config = watcher.WatchConfig( + markets=[market], + spoiler_free_mode=True, + ) + snapshots = { + market.ticker: watcher.MarketSnapshot( + "FRA", "法国晋级", "active", "E", 69, 71, 29, 31, 70, "", None + ) + } + + with ( + patch.object(watcher, "send_ticker") as send_ticker, + patch.object(watcher, "speak_text") as speak_text, + ): + watcher.send_summary( + config, + snapshots, + dry_run=False, + no_say=False, + ) + + send_ticker.assert_called_once_with(config, snapshots, dry_run=False) + speak_text.assert_not_called() + def test_english_price_and_near_close_speech_use_singular_units(self): market = watcher.MarketConfig( "FRA", @@ -2465,6 +2555,8 @@ def test_english_price_and_near_close_speech_use_singular_units(self): self.assertNotIn("1 minutes", near_close.speech) self.assertIn("midpoint is 1 cent", price_move.speech) self.assertIn("by 1 cent", price_move.speech) + self.assertTrue(near_close.spoiler_sensitive) + self.assertTrue(price_move.spoiler_sensitive) def test_default_english_goal_signal_contains_no_chinese(self): market = watcher.MarketConfig( @@ -2491,6 +2583,140 @@ def test_default_english_goal_signal_contains_no_chinese(self): signal = next(alert for alert in alerts if alert.kind == "market_goal_signal") self.assertFalse(contains_han(signal.balloon)) self.assertFalse(contains_han(signal.speech)) + self.assertTrue(signal.spoiler_sensitive) + + def test_market_spread_alert_is_spoiler_sensitive_price_information(self): + market = watcher.MarketConfig( + "FRA", + "法国晋级", + spread_move_cents=4, + min_seconds_between_alerts=0, + ) + snapshot = watcher.MarketSnapshot( + "FRA", "法国晋级", "active", "E", 45, 55, 45, 55, 50, "", None + ) + state = watcher.MarketState( + last_alert_mid_cents=50, + last_observed_mid_cents=50, + last_yes_spread_cents=2, + ) + + alerts = watcher.evaluate_market( + snapshot, + market, + state, + datetime.now(timezone.utc), + ) + + spread = next(alert for alert in alerts if alert.kind == "spread_widen") + self.assertTrue(spread.spoiler_sensitive) + + def test_market_status_change_is_spoiler_sensitive(self): + market = watcher.MarketConfig("FRA", "法国晋级") + snapshot = watcher.MarketSnapshot( + "FRA", "法国晋级", "closed", "E", 99, 100, 0, 1, 100, "", None + ) + state = watcher.MarketState(last_status="active") + + alerts = watcher.evaluate_market( + snapshot, + market, + state, + datetime.now(timezone.utc), + ) + + status = next(alert for alert in alerts if alert.kind == "status_change") + self.assertTrue(status.spoiler_sensitive) + + def test_hot_enable_consumes_current_snapshot_before_the_next_poll(self): + now = datetime.now(timezone.utc) + market = watcher.MarketConfig( + "FRA", + "法国晋级", + near_close_minutes=10, + ) + config = watcher.WatchConfig(markets=[market]) + snapshot = watcher.MarketSnapshot( + "FRA", + "法国晋级", + "active", + "E", + 69, + 71, + 29, + 31, + 70, + "", + now + timedelta(minutes=5), + ) + state = watcher.MarketState( + last_alert_mid_cents=50, + last_observed_mid_cents=50, + last_yes_spread_cents=8, + last_status="open", + ) + + watcher.consume_spoiler_market_baselines( + config, + {market.ticker: snapshot}, + {market.ticker: state}, + now, + ) + + self.assertEqual(state.last_alert_mid_cents, 70) + self.assertEqual(state.last_observed_mid_cents, 70) + self.assertEqual(state.last_yes_spread_cents, 2) + self.assertEqual(state.last_status, "active") + self.assertTrue(state.near_close_alerted) + + def test_spoiler_mode_suppresses_market_alert_and_consumes_baseline(self): + market = watcher.MarketConfig( + "FRA", + "法国晋级", + alert_move_cents=5, + speak_move_cents=5, + min_seconds_between_alerts=0, + ) + now = datetime.now(timezone.utc) + state = watcher.MarketState( + last_alert_mid_cents=50, + last_observed_mid_cents=50, + ) + protected_snapshot = watcher.MarketSnapshot( + "FRA", "法国晋级", "active", "E", 59, 61, 39, 41, 60, "", None + ) + + protected_alerts = watcher.evaluate_market( + protected_snapshot, + market, + state, + now, + ) + visible_alerts = watcher.apply_spoiler_policy_to_market_alerts( + True, + protected_alerts, + protected_snapshot, + market, + state, + now, + ) + + self.assertEqual(visible_alerts, []) + self.assertEqual(state.last_alert_mid_cents, 60) + self.assertEqual(state.last_alert_at, now.timestamp()) + + # Disabling protection starts from the latest protected price, so the + # old 10-cent jump is not replayed as a catch-up alert. + next_snapshot = watcher.MarketSnapshot( + "FRA", "法国晋级", "active", "E", 60, 62, 38, 40, 61, "", None + ) + after_disable = watcher.evaluate_market( + next_snapshot, + market, + state, + now + timedelta(seconds=1), + ) + self.assertFalse(any(alert.kind == "price_move" for alert in after_disable)) def test_market_price_move_uses_three_distinct_voice_styles(self): snapshot = watcher.MarketSnapshot( @@ -2614,10 +2840,28 @@ def test_suspected_goal_styles_name_team_both_directions_and_keep_uncertainty(se self.assertIn(expected_team, signal.balloon) self.assertTrue("疑似" in signal.speech or "可能" in signal.speech) self.assertIn("确认", signal.speech) - self.assertNotIn("进球了", signal.speech) + self.assertNotIn(f"{expected_team}进球了", signal.speech) + self.assertNotIn("确认进球", signal.speech) + self.assertNotIn("已经进球", signal.speech) self.assertIn("疑似", signal.balloon) self.assertIn("等待确认", signal.balloon) self.assertNotIn("仓位", signal.speech) + if style == "casual": + self.assertEqual( + signal.speech, + f"盘口突然{'拉升' if rising else '跳水'}!" + f"{expected_team}这边很可能进球了!" + "先别眨眼,等文字直播确认!", + ) + elif style == "balanced": + self.assertEqual( + signal.speech, + f"盘口突然{'拉升' if rising else '跳水'}!" + f"{expected_team}这边很可能进球了!先等文字直播确认。", + ) + else: + self.assertIn("进球概率骤升", signal.speech) + self.assertIn(f"至{midpoint}分", signal.speech) def test_suspected_goal_marks_support_position_conflict_conditionally(self): snapshot = watcher.MarketSnapshot( @@ -2646,6 +2890,29 @@ def test_suspected_goal_marks_support_position_conflict_conditionally(self): self.assertIn("球迷这边开心", falling) self.assertIn("仓位却会承压", falling) + def test_casual_suspected_goal_uses_natural_aligned_support_position_context(self): + snapshot = watcher.MarketSnapshot( + "ENG", "英格兰晋级", "active", "E", 69, 71, 29, 31, 70, "", None + ) + market = watcher.MarketConfig( + "ENG", + "英格兰晋级", + commentary_style="casual", + goal_signal_up_team="英格兰", + goal_signal_down_team="挪威", + favorite_team="英格兰", + position_team="英格兰", + tracks_position=True, + ) + + speech = watcher.speech_for_market_goal_signal(snapshot, market, True, 10, 70) + + self.assertEqual( + speech, + "盘口突然拉升!英格兰这边很可能进球了!先别眨眼,等文字直播确认!" + "要是真的,咱们支持的球队和仓位都舒服了。", + ) + def test_unsafe_english_custom_goal_claim_is_replaced_with_uncertainty(self): market = watcher.MarketConfig( "FRA", @@ -2683,8 +2950,8 @@ def test_mixed_chinese_confirmation_and_uncertainty_is_replaced(self): speech = watcher.speech_for_market_goal_signal(snapshot, market, True, 10, 60) self.assertNotIn("法国进球了", speech) - self.assertIn("法国疑似进球", speech) - self.assertIn("等待文字直播确认", speech) + self.assertIn("法国这边很可能进球了", speech) + self.assertIn("先等文字直播确认", speech) def test_inactive_market_freezes_last_trade_without_goal_signal(self): market = watcher.MarketConfig( @@ -2788,6 +3055,52 @@ def test_large_favorite_move_uses_local_odds_clip(self): class AlertDeliveryTests(unittest.TestCase): + def test_delivery_boundary_suppresses_spoiler_sensitive_alert(self): + config = watcher.WatchConfig(spoiler_free_mode=True) + alert = watcher.Alert( + ticker="FRA", + label="法国晋级", + kind="price_move", + priority=110, + face="happy", + balloon="盘口上涨", + speech="盘口上涨", + detail="YES mid 70c -> 80c", + spoiler_sensitive=True, + ) + + with patch.object(watcher, "send_stackchan_commands") as send: + delivered = watcher.send_alert( + config, + alert, + quiet=False, + dry_run=False, + no_say=False, + ) + + self.assertTrue(delivered) + send.assert_not_called() + + def test_blocking_say_uses_the_long_speech_timeout(self): + with patch.object(watcher.urllib.request, "urlopen") as urlopen: + urlopen.return_value.__enter__.return_value.read.return_value = b"" + watcher.post_stackchan_http_command("192.0.2.1", "say 一段完整的进球播报") + + self.assertEqual( + urlopen.call_args.kwargs["timeout"], + watcher.STACKCHAN_SPEECH_TIMEOUT_SECONDS, + ) + + def test_routine_device_command_keeps_the_short_timeout(self): + with patch.object(watcher.urllib.request, "urlopen") as urlopen: + urlopen.return_value.__enter__.return_value.read.return_value = b"" + watcher.post_stackchan_http_command("192.0.2.1", "face happy") + + self.assertEqual( + urlopen.call_args.kwargs["timeout"], + watcher.STACKCHAN_COMMAND_TIMEOUT_SECONDS, + ) + def test_english_schedule_uses_natural_date_prepositions(self): starts_at = "2026-07-10T19:00:00+00:00" local_start = watcher.parse_datetime(starts_at).astimezone() @@ -2827,7 +3140,7 @@ def test_english_setup_confirmation_uses_complete_sentences(self): "Position: France. Monitoring is active.", ) - def test_feedback_idle_wait_tracks_motion_tts_and_light(self): + def test_feedback_idle_wait_tracks_motion_tts_and_light_at_safe_poll_rate(self): config = watcher.WatchConfig(stackchan_host="192.0.2.1") statuses = [ {"celebrating": True, "tts": {"busy": False}, "light": {"on": True}}, @@ -2837,7 +3150,7 @@ def test_feedback_idle_wait_tracks_motion_tts_and_light(self): ] with patch.object(watcher, "http_json", side_effect=statuses) as fetch_status: - with patch.object(watcher.time, "sleep"): + with patch.object(watcher.time, "sleep") as sleep: idle = watcher.wait_for_stackchan_feedback_idle( config, timeout=5, @@ -2846,6 +3159,10 @@ def test_feedback_idle_wait_tracks_motion_tts_and_light(self): self.assertTrue(idle) self.assertEqual(fetch_status.call_count, 4) + self.assertEqual( + [entry.args[0] for entry in sleep.call_args_list], + [watcher.STACKCHAN_FEEDBACK_POLL_SECONDS] * 3, + ) def test_pre_dispatch_idle_wait_does_not_block_on_persistent_light(self): config = watcher.WatchConfig(stackchan_host="192.0.2.1") @@ -2907,7 +3224,7 @@ def test_old_mod_uses_balloon_url_for_schedule_prompt(self): self.assertIn("dry-run stackchan: balloon temp", output.getvalue()) self.assertNotIn("setup show", output.getvalue()) - def test_result_win_uses_atomic_voice_motion_and_light_command(self): + def test_result_win_sequences_motion_before_dynamic_voice(self): config = watcher.WatchConfig( voice_transport="clip", result_celebration_commands=True, @@ -2931,10 +3248,11 @@ def test_result_win_uses_atomic_voice_motion_and_light_command(self): watcher.send_alert(config, alert, quiet=False, dry_run=True, no_say=False) commands = output.getvalue() - self.assertIn( - "dry-run stackchan: celebrate result win 0 85 164 比赛结束。法国二比零摩洛哥。", - commands, - ) + celebration = "dry-run stackchan: celebrate result win 0 85 164" + speech = "dry-run stackchan: say 比赛结束。法国二比零摩洛哥。" + self.assertIn(celebration, commands) + self.assertIn(speech, commands) + self.assertLess(commands.index(celebration), commands.index(speech)) self.assertNotIn("light flash", commands) self.assertNotIn("clip favorite-win", commands) @@ -3049,7 +3367,7 @@ def test_highlight_without_local_clip_uses_dynamic_say(self): ) self.assertNotIn("clip ", commands) - def test_personalized_goal_uses_atomic_voice_motion_and_light_command(self): + def test_personalized_goal_sequences_motion_before_dynamic_voice(self): config = watcher.WatchConfig(voice_transport="clip") alert = watcher.Alert( ticker="ESPN:760510", @@ -3072,15 +3390,102 @@ def test_personalized_goal_uses_atomic_voice_motion_and_light_command(self): commands = output.getvalue() self.assertIn("dry-run stackchan: balloon temp 8000 进球测试", commands) - self.assertIn( - "dry-run stackchan: celebrate say 0 85 164 姆巴佩!姆巴佩!打进去了!", - commands, - ) - self.assertNotIn("dry-run stackchan: celebrate goal", commands) - self.assertNotIn("dry-run stackchan: say ", commands) + celebration = "dry-run stackchan: celebrate goal 0 85 164" + speech = "dry-run stackchan: say 姆巴佩!姆巴佩!打进去了!" + self.assertIn(celebration, commands) + self.assertIn(speech, commands) + self.assertLess(commands.index(celebration), commands.index(speech)) + self.assertNotIn("dry-run stackchan: celebrate say", commands) self.assertNotIn("light flash", commands) self.assertNotIn("clip favorite-goal", commands) + def test_personalized_goal_waits_for_motion_before_sending_dynamic_voice(self): + config = watcher.WatchConfig(voice_transport="clip") + alert = watcher.Alert( + ticker="ESPN:760510", + label="法国 vs 摩洛哥", + kind="espn_goal", + priority=200, + face="happy", + balloon="进球测试", + speech="姆巴佩破门,法国一比零领先。", + detail="test", + clip_id="favorite-goal", + light_rgb=(0, 85, 164), + celebration="goal", + prefer_dynamic_voice=True, + ) + events = [] + + def record_send(_config, commands, dry_run=False): + events.append(("send", list(commands), dry_run)) + + def record_wait( + _config, + timeout=30, + *, + include_light=False, + report_last_error=False, + ): + events.append(("wait", include_light, report_last_error, timeout)) + return True + + with patch.object(watcher, "send_stackchan_commands", side_effect=record_send): + with patch.object( + watcher, + "wait_for_stackchan_feedback_idle", + side_effect=record_wait, + ): + delivered = watcher.send_alert( + config, + alert, + quiet=False, + dry_run=False, + no_say=False, + ) + + self.assertTrue(delivered) + self.assertEqual(events[0], ("wait", False, False, 30)) + self.assertIn("celebrate goal 0 85 164", events[1][1]) + self.assertEqual(events[2], ("wait", False, True, 30)) + self.assertEqual(events[3][1], ["say 姆巴佩破门,法国一比零领先。"]) + + def test_deferred_goal_tts_failure_does_not_replay_delivered_celebration(self): + config = watcher.WatchConfig(voice_transport="clip") + alert = watcher.Alert( + ticker="ESPN:760510", + label="法国 vs 摩洛哥", + kind="espn_goal", + priority=200, + face="happy", + balloon="进球测试", + speech="姆巴佩破门,法国一比零领先。", + detail="test", + clip_id="favorite-goal", + light_rgb=(0, 85, 164), + celebration="goal", + prefer_dynamic_voice=True, + ) + + with patch.object( + watcher, + "send_stackchan_commands", + side_effect=[None, OSError("tts link closed")], + ) as send: + with patch.object(watcher, "wait_for_stackchan_feedback_idle", return_value=True): + with redirect_stderr(io.StringIO()) as stderr: + delivered = watcher.send_alert( + config, + alert, + quiet=False, + dry_run=False, + no_say=False, + ) + + self.assertTrue(delivered) + self.assertEqual(send.call_count, 2) + self.assertIn("deferred Stack-chan speech failed", stderr.getvalue()) + def test_generic_goal_keeps_local_clip_celebration(self): config = watcher.WatchConfig(voice_transport="clip") alert = watcher.Alert( @@ -3178,6 +3583,38 @@ def test_live_and_post_status_override_pregame_time(self): class AlertQueueTests(unittest.TestCase): + def test_enabling_spoiler_mode_purges_market_alerts_and_keeps_espn(self): + market = watcher.Alert( + ticker="FRA", + label="法国晋级", + kind="price_move", + priority=110, + face="happy", + balloon="盘口上涨", + speech="盘口上涨", + detail="YES mid 70c -> 80c", + spoiler_sensitive=True, + ) + espn = watcher.Alert( + ticker="ESPN:760510", + label="法国 vs 摩洛哥", + kind="espn_goal", + priority=1000, + face="happy", + balloon="法国进球", + speech="姆巴佩进球", + detail="Goal! France.", + ) + queue = watcher.merge_alert_queue( + [], + [(market, None, None, None), (espn, None, None, None)], + 100.0, + ) + + protected = watcher.purge_spoiler_sensitive_alerts(queue) + + self.assertEqual([item.alert.kind for item in protected], ["espn_goal"]) + def test_confirmed_goal_drops_unplayed_market_goal_signal(self): signal = watcher.Alert( ticker="FRA", diff --git a/tools/test_stackchan_match_setup.py b/tools/test_stackchan_match_setup.py index d3fe93e..18861ea 100644 --- a/tools/test_stackchan_match_setup.py +++ b/tools/test_stackchan_match_setup.py @@ -6,7 +6,9 @@ import time import unittest from datetime import datetime, timezone +from io import BytesIO from pathlib import Path +from types import SimpleNamespace from unittest.mock import patch import stackchan_match_setup as setup @@ -144,6 +146,7 @@ def test_apply_updates_markets_bar_and_fan_position_atomically(self): "position_team": "", "language": "en", "commentary_style": "professional", + "spoiler_free_mode": True, } ) updated = json.loads(path.read_text(encoding="utf-8")) @@ -152,6 +155,7 @@ def test_apply_updates_markets_bar_and_fan_position_atomically(self): self.assertTrue(result["ok"]) self.assertEqual(result["language"], "en") self.assertEqual(result["commentary_style"], "professional") + self.assertTrue(result["spoiler_free_mode"]) self.assertEqual(result["label"], "Spain vs Belgium") self.assertEqual(updated["language"], "en") self.assertEqual(updated["espn"]["event_id"], "760511") @@ -159,6 +163,7 @@ def test_apply_updates_markets_bar_and_fan_position_atomically(self): self.assertEqual(updated["espn"]["favorite_team"], "Spain") self.assertEqual(updated["espn"]["position_team"], "") self.assertEqual(updated["espn"]["commentary_style"], "professional") + self.assertTrue(updated["spoiler_free_mode"]) self.assertEqual( updated["espn"]["label"], {"zh": "西班牙 vs 比利时", "en": "Spain vs Belgium"}, @@ -174,9 +179,20 @@ def test_apply_updates_markets_bar_and_fan_position_atomically(self): updated["markets"][0]["label"], {"zh": "西班牙晋级", "en": "Spain to advance"}, ) - self.assertIn("西班牙", updated["markets"][0]["goal_signal_up_speech"]["zh"]) - self.assertIn("Spain", updated["markets"][0]["goal_signal_up_speech"]["en"]) - self.assertIn("Belgium", updated["markets"][0]["goal_signal_down_speech"]["en"]) + self.assertEqual( + updated["markets"][0]["goal_signal_up_speech"]["zh"], + "盘口突然拉升!西班牙这边很可能进球了!先别眨眼,等文字直播确认!", + ) + self.assertEqual( + updated["markets"][0]["goal_signal_up_speech"]["en"], + "The market just jumped! Spain may have scored. " + "Don't blink—waiting for commentary confirmation!", + ) + self.assertEqual( + updated["markets"][0]["goal_signal_down_speech"]["en"], + "The market just dropped! Belgium may have scored. " + "Don't blink—waiting for commentary confirmation!", + ) self.assertEqual( updated["markets"][0]["goal_signal_up_team"], {"zh": "西班牙", "en": "Spain"}, @@ -191,6 +207,7 @@ def test_apply_updates_markets_bar_and_fan_position_atomically(self): self.assertEqual(english_status["favorite_team"], "Spain") self.assertEqual(english_status["language"], "en") self.assertEqual(english_status["commentary_style"], "professional") + self.assertTrue(english_status["spoiler_free_mode"]) self.assertTrue(instance.take_reload_requested()) def test_apply_rejects_unsupported_language_before_fetching(self): @@ -201,6 +218,7 @@ def test_apply_rejects_unsupported_language_before_fetching(self): def test_apply_enables_position_team_market_and_goal_signal_direction(self): initial = { + "spoiler_free_mode": True, "probability_bar": {}, "espn": {"team_names": {}, "team_colors": {}}, "setup_server": {}, @@ -230,7 +248,7 @@ def test_apply_enables_position_team_market_and_goal_signal_direction(self): return_value=setup.event_markets(KALSHI_EVENT), ): with patch.object(instance, "_espn_match", return_value=match): - instance.apply_selection( + result = instance.apply_selection( { "event_ticker": "KXWCADVANCE-26JUL10ESPBEL", "espn_event_id": "760511", @@ -242,6 +260,8 @@ def test_apply_enables_position_team_market_and_goal_signal_direction(self): updated = json.loads(path.read_text(encoding="utf-8")) spain_market, belgium_market = updated["markets"] + self.assertTrue(result["spoiler_free_mode"]) + self.assertTrue(updated["spoiler_free_mode"]) self.assertFalse(spain_market["alerts_enabled"]) self.assertFalse(spain_market["show_in_ticker"]) self.assertFalse(spain_market["tracks_position"]) @@ -262,8 +282,14 @@ def test_apply_enables_position_team_market_and_goal_signal_direction(self): belgium_market["goal_signal_down_team"], {"zh": "西班牙", "en": "Spain"}, ) - self.assertIn("比利时", belgium_market["goal_signal_up_speech"]["zh"]) - self.assertIn("西班牙", belgium_market["goal_signal_down_speech"]["zh"]) + self.assertEqual( + belgium_market["goal_signal_up_speech"]["zh"], + "盘口突然拉升!比利时这边很可能进球了!先别眨眼,等文字直播确认!", + ) + self.assertEqual( + belgium_market["goal_signal_down_speech"]["zh"], + "盘口突然跳水!西班牙这边很可能进球了!先别眨眼,等文字直播确认!", + ) def test_apply_canonicalizes_kalshi_team_aliases_to_espn_names(self): initial = { @@ -340,8 +366,101 @@ def test_setup_page_contains_mobile_form_controls(self): self.assertIn('id="style-effective"', page) self.assertIn("showEffectiveStyle(style)", page) self.assertIn("/api/setup/style", page) + self.assertIn('name="spoiler_free_mode"', page) + self.assertIn('id="spoiler-effective"', page) + self.assertIn("所有 Kalshi 盘口主动消息", page) + self.assertIn("ESPN 已确认事件照常播报", page) + self.assertIn("概率条与 ticker 仍会更新", page) + self.assertIn("/api/setup/spoiler", page) + self.assertIn("spoiler_free_mode:selectedSpoilerFreeMode()", page) self.assertIn('id="apply"', page) + def test_status_defaults_spoiler_free_mode_to_false(self): + initial = {"espn": {}, "markets": [{"ticker": "TEST", "label": "test"}]} + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "watch.json" + path.write_text(json.dumps(initial), encoding="utf-8") + instance = service(path) + + status = instance.current_status() + + self.assertFalse(status["spoiler_free_mode"]) + + def test_spoiler_only_update_persists_and_is_consumed_once(self): + initial = { + "spoiler_free_mode": False, + "espn": {"commentary_style": "balanced", "event_id": "keep-me"}, + "runtime_sentinel": {"queue": ["untouched"]}, + "markets": [{"ticker": "TEST", "label": "测试"}], + } + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "watch.json" + path.write_text(json.dumps(initial), encoding="utf-8") + instance = service(path) + + enabled = instance.apply_spoiler_free_mode({"spoiler_free_mode": True}) + self.assertFalse(instance.take_reload_requested()) + self.assertIs(instance.take_spoiler_free_mode_update(), True) + self.assertIsNone(instance.take_spoiler_free_mode_update()) + + disabled = instance.apply_spoiler_free_mode({"spoiler_free_mode": False}) + updated = json.loads(path.read_text(encoding="utf-8")) + self.assertFalse(instance.take_reload_requested()) + self.assertIs(instance.take_spoiler_free_mode_update(), False) + self.assertIsNone(instance.take_spoiler_free_mode_update()) + + self.assertEqual(enabled, {"ok": True, "spoiler_free_mode": True}) + self.assertEqual(disabled, {"ok": True, "spoiler_free_mode": False}) + self.assertFalse(updated["spoiler_free_mode"]) + self.assertEqual(updated["espn"]["event_id"], "keep-me") + self.assertEqual(updated["runtime_sentinel"], {"queue": ["untouched"]}) + + def test_spoiler_only_update_rejects_missing_or_non_boolean_value(self): + initial = {"espn": {}, "markets": [{"ticker": "TEST", "label": "test"}]} + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "watch.json" + path.write_text(json.dumps(initial), encoding="utf-8") + instance = service(path) + + with self.assertRaisesRegex(ValueError, "spoiler_free_mode is required"): + instance.apply_spoiler_free_mode({}) + for invalid in (None, "true", 1, 0, [], {}): + with self.subTest(invalid=invalid): + with self.assertRaisesRegex(ValueError, "must be a JSON boolean"): + instance.apply_spoiler_free_mode( + {"spoiler_free_mode": invalid} + ) + + def test_full_apply_paths_reject_non_boolean_spoiler_value_before_fetching(self): + instance = service(Path("unused.json")) + + for apply in (instance.apply_selection, instance.apply_market_selection): + with self.subTest(apply=apply.__name__): + with self.assertRaisesRegex(ValueError, "must be a JSON boolean"): + apply({"spoiler_free_mode": "true"}) + + def test_spoiler_endpoint_accepts_json_boolean(self): + initial = {"espn": {}, "markets": [{"ticker": "TEST", "label": "test"}]} + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "watch.json" + path.write_text(json.dumps(initial), encoding="utf-8") + instance = service(path) + body = json.dumps({"spoiler_free_mode": True}).encode("utf-8") + handler = object.__new__(setup.SetupRequestHandler) + handler.path = "/api/setup/spoiler" + handler.headers = {"Content-Length": str(len(body))} + handler.rfile = BytesIO(body) + handler.server = SimpleNamespace(service=instance) + responses = [] + handler._json = lambda payload, status=200: responses.append((status, payload)) + + handler.do_POST() + + self.assertEqual( + responses, + [(200, {"ok": True, "spoiler_free_mode": True})], + ) + def test_style_only_update_persists_without_requesting_full_reload(self): initial = { "language": "zh", @@ -440,6 +559,7 @@ def update(style): class StandaloneMarketTests(unittest.TestCase): def test_apply_market_selection_configures_ticker_only_watch(self): initial = { + "spoiler_free_mode": True, "ticker_enabled": False, "probability_bar": {"enabled": True, "market_ticker": "OLD"}, "espn": { @@ -480,6 +600,7 @@ def test_apply_market_selection_configures_ticker_only_watch(self): self.assertTrue(result["ok"]) self.assertEqual(result["label"], "Presidential winner") self.assertEqual(result["language"], "en") + self.assertTrue(result["spoiler_free_mode"]) self.assertEqual(result["event_id"], "") # Top four markets by traded volume, most active first. self.assertEqual( @@ -505,8 +626,36 @@ def test_apply_market_selection_configures_ticker_only_watch(self): ) self.assertFalse(any(market["tracks_position"] for market in updated["markets"])) self.assertEqual(updated["language"], "en") + self.assertTrue(updated["spoiler_free_mode"]) self.assertEqual(updated["setup_server"]["last_event_ticker"], "KXPRES-28") + def test_apply_market_selection_accepts_explicit_spoiler_setting(self): + initial = { + "spoiler_free_mode": True, + "espn": {}, + "setup_server": {}, + "markets": [{"ticker": "OLD", "label": "old"}], + } + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "watch.json" + path.write_text(json.dumps(initial), encoding="utf-8") + instance = service(path) + with patch.object( + instance, + "_kalshi_event_any", + return_value=setup.general_event_markets(KALSHI_GENERAL_EVENT), + ): + result = instance.apply_market_selection( + { + "kalshi_url": "KXPRES-28", + "spoiler_free_mode": False, + } + ) + updated = json.loads(path.read_text(encoding="utf-8")) + + self.assertFalse(result["spoiler_free_mode"]) + self.assertFalse(updated["spoiler_free_mode"]) + def test_apply_market_selection_rejects_unrecognizable_input(self): instance = service(Path("unused.json")) diff --git a/tools/test_stackchan_mod_http_server_behavior.mjs b/tools/test_stackchan_mod_http_server_behavior.mjs new file mode 100644 index 0000000..69ca6d6 --- /dev/null +++ b/tools/test_stackchan_mod_http_server_behavior.mjs @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import vm from 'node:vm' + +// Drive mod/http-server-safe.js with a scripted listener to prove the accept +// loop survives stalled bodies, slow handlers, wedged peers, and bursts. + +const source = fs.readFileSync(new URL('../mod/http-server-safe.js', import.meta.url), 'utf8') +const body = source + .split('\n') + .filter((line) => !line.startsWith('import ') && !line.startsWith('export ')) + .join('\n') + +function makeHarness() { + const pendingConnections = [] + const waiters = [] + const timers = [] + const traces = [] + + async function* listen() { + for (;;) { + if (pendingConnections.length) yield pendingConnections.shift() + else await new Promise((resolve) => waiters.push(resolve)) + } + } + + const context = vm.createContext({ + ArrayBuffer, + JSON, + Object, + String, + Headers: class { + #map = new Map() + get(key) { + return this.#map.get(String(key).toLowerCase()) + } + set(key, value) { + this.#map.set(String(key).toLowerCase(), value) + } + entries() { + return this.#map.entries() + } + }, + URLSearchParams, + Timer: { + set: (callback, ms) => { + const timer = { callback, ms, cleared: false } + timers.push(timer) + return timer + }, + clear: (timer) => { + if (timer) timer.cleared = true + }, + }, + listen, + trace: (text) => traces.push(text), + }) + vm.runInContext('ArrayBuffer.fromString = (s) => new ArrayBuffer(String(s).length)', context) + vm.runInContext('String.fromArrayBuffer = () => ""', context) + vm.runInContext(body, context) + + return { + context, + traces, + timers, + push(connection) { + pendingConnections.push(connection) + const waiter = waiters.shift() + if (waiter) waiter() + }, + } +} + +function makeConnection(method, path, bodyPromise) { + const events = [] + return { + events, + request: { + method, + url: { pathname: path, href: `http://device${path}`, searchParams: new URLSearchParams() }, + headers: { get: () => undefined }, + arrayBuffer: () => bodyPromise ?? Promise.resolve(new ArrayBuffer(0)), + }, + close() { + events.push('close') + }, + respondWith(response) { + events.push(['respond', response.status]) + return Promise.resolve() + }, + } +} + +const settle = async (turns = 8) => { + for (let i = 0; i < turns; i++) await new Promise((resolve) => setImmediate(resolve)) +} + +// 1) A stalled request body must not block other clients (the wedge that froze +// the device: TCP handshakes kept succeeding while no request got answered). +{ + const harness = makeHarness() + const server = vm.runInContext('new HttpServerService({ port: 80 })', harness.context) + harness.context.__server = server + vm.runInContext( + `__server.get('/ping', (c) => c.text('pong', 200)) + __server.post('/slow', async (c) => { await c.req.text(); return c.text('done', 200) })`, + harness.context, + ) + + const stalled = makeConnection('POST', '/slow', new Promise(() => {})) + harness.push(stalled) + await settle() + + const healthy = makeConnection('GET', '/ping') + harness.push(healthy) + await settle() + + assert.deepEqual(healthy.events, [['respond', 200]], 'healthy request must be served while another body stalls') + assert.deepEqual(stalled.events, [], 'stalled request is still pending, not errored') + + // 2) The watchdog reclaims the wedged connection. + const watchdog = harness.timers.find((t) => !t.cleared && t.ms === 45_000) + assert.ok(watchdog, 'stalled request must be under a watchdog timer') + watchdog.callback() + await settle() + assert.deepEqual(stalled.events, ['close'], 'watchdog closes the wedged connection') + assert.ok( + harness.traces.some((t) => t.includes('HTTP request timed out')), + 'timeout is traced', + ) + + // 3) After reclaim, new requests still work and the slot was released. + const after = makeConnection('GET', '/ping') + harness.push(after) + await settle() + assert.deepEqual(after.events, [['respond', 200]]) + assert.equal(vm.runInContext('__server ? 0 : 1', harness.context), 0) +} + +// 4) Load shedding: beyond MAX_ACTIVE_REQUESTS concurrent requests, extra +// connections are dropped instead of exhausting lwIP pcbs. +{ + const harness = makeHarness() + const server = vm.runInContext('new HttpServerService({ port: 80 })', harness.context) + harness.context.__server = server + vm.runInContext(`__server.post('/slow', async (c) => { await c.req.text(); return c.text('done', 200) })`, harness.context) + + const wedged = [] + for (let i = 0; i < 6; i++) { + const connection = makeConnection('POST', '/slow', new Promise(() => {})) + wedged.push(connection) + harness.push(connection) + } + await settle() + + const extra = makeConnection('POST', '/slow', new Promise(() => {})) + harness.push(extra) + await settle() + assert.deepEqual(extra.events, ['close'], 'connection beyond the cap is shed immediately') + assert.ok( + wedged.every((c) => c.events.length === 0), + 'capped connections are untouched', + ) +} + +// 5) Handler failures still answer 500 (previous behavior preserved). +{ + const harness = makeHarness() + const server = vm.runInContext('new HttpServerService({ port: 80 })', harness.context) + harness.context.__server = server + vm.runInContext(`__server.get('/boom', () => { throw new Error('boom') })`, harness.context) + + const boom = makeConnection('GET', '/boom') + harness.push(boom) + await settle() + assert.deepEqual(boom.events, [['respond', 500]]) + assert.ok(harness.traces.some((t) => t.includes('HTTP handler failed'))) + + const missing = makeConnection('GET', '/nope') + harness.push(missing) + await settle() + assert.deepEqual(missing.events, [['respond', 404]]) +} + +// 6) A respondWith rejection (peer dropped) is contained: traced, closed, and +// the slot is released for the next client. +{ + const harness = makeHarness() + const server = vm.runInContext('new HttpServerService({ port: 80 })', harness.context) + harness.context.__server = server + vm.runInContext(`__server.get('/ping', (c) => c.text('pong', 200))`, harness.context) + + const dropped = makeConnection('GET', '/ping') + dropped.respondWith = () => { + dropped.events.push('respond-attempt') + return Promise.reject(new Error('peer gone')) + } + harness.push(dropped) + await settle() + assert.deepEqual(dropped.events, ['respond-attempt', 'close']) + assert.ok(harness.traces.some((t) => t.includes('HTTP response closed'))) + + const next = makeConnection('GET', '/ping') + harness.push(next) + await settle() + assert.deepEqual(next.events, [['respond', 200]]) +} + +console.log('http-server-safe behavior: all assertions passed') diff --git a/tools/test_stackchan_mod_layout.py b/tools/test_stackchan_mod_layout.py index 60554da..978ce92 100644 --- a/tools/test_stackchan_mod_layout.py +++ b/tools/test_stackchan_mod_layout.py @@ -126,6 +126,50 @@ def test_full_setup_absorbs_style_pending_without_losing_the_preference(self): self.assertIn("data.pending&&data.pending.commentary_style", page) self.assertIn("result.status ?? 400", WEB_SOURCE) + def test_device_setup_page_queues_persistent_spoiler_free_changes(self): + self.assertIn("MOD_VERSION = '1.6.0'", STATE_SOURCE) + self.assertIn("spoilerFreeMode: false", STATE_SOURCE) + self.assertIn('name="spoiler_free_mode" value="false"', WEB_SOURCE) + self.assertIn('name="spoiler_free_mode" value="true"', WEB_SOURCE) + self.assertIn("spoilerNormal:'普通'", WEB_SOURCE) + self.assertIn("spoilerFree:'防剧透'", WEB_SOURCE) + self.assertIn("spoilerNormal:'Normal'", WEB_SOURCE) + self.assertIn("spoilerFree:'Spoiler-free'", WEB_SOURCE) + self.assertIn("任何 Kalshi 盘口消息", WEB_SOURCE) + self.assertIn("已确认的 ESPN 比赛事件仍会播报", WEB_SOURCE) + self.assertIn("all proactive Kalshi market alerts stop", WEB_SOURCE) + self.assertIn("probability bar and ticker keep updating", WEB_SOURCE) + self.assertIn("'/api/match-setup/spoiler'", WEB_SOURCE) + self.assertIn("typeof payload?.spoiler_free_mode !== 'boolean'", WEB_SOURCE) + self.assertIn("spoiler_only: true", WEB_SOURCE) + self.assertIn("savePreference('spoilerFreeMode', spoilerFreeMode)", WEB_SOURCE) + self.assertIn("readPreference('spoilerFreeMode', false)", WEB_SOURCE) + + def test_spoiler_free_mode_is_relayed_and_applied_only_after_ack(self): + payload = WEB_SOURCE.split("function matchSetupPayload", 1)[1].split( + "function syncMatchSetup", 1 + )[0] + queue = WEB_SOURCE.split("function queueMatchSetup", 1)[1].split( + "function acknowledgeMatchSetup", 1 + )[0] + acknowledgement = WEB_SOURCE.split("function acknowledgeMatchSetup", 1)[1].split( + "// ---------------------------------------------------------------------------", 1 + )[0] + page = WEB_SOURCE.split("function setupPageHtml", 1)[1].split( + "// ---------------------------------------------------------------------------", 1 + )[0] + + self.assertIn("spoiler_free_mode: state.matchSetup.spoilerFreeMode", payload) + self.assertIn("state.matchSetup.pending?.spoiler_free_mode", queue) + self.assertIn("!state.matchSetup.pending.spoiler_only", queue) + self.assertIn("spoiler_free_mode: spoilerFreeMode", queue) + self.assertIn("pending.spoiler_free_mode = spoilerFreeMode", queue) + self.assertIn("pending?.spoiler_only", acknowledgement) + self.assertIn("applySpoilerFreeMode(payload?.spoiler_free_mode)", acknowledgement) + self.assertIn("spoiler_free_mode:spoilerFreeMode", page) + self.assertIn("data.pending.spoiler_free_mode", page) + self.assertIn('input[name="spoiler_free_mode"]', page) + def test_device_setup_page_offers_standalone_market_watch(self): self.assertIn('id="kalshiUrl"', WEB_SOURCE) self.assertIn('id="watchMarket"', WEB_SOURCE) @@ -137,9 +181,47 @@ def test_mod_uses_disconnect_safe_http_service(self): self.assertIn("from 'matchday/http-server-safe'", WEB_SOURCE) self.assertIn("headers.set('content-length', this.#body.byteLength)", HTTP_SOURCE) self.assertIn("request.arrayBuffer().then", HTTP_SOURCE) - self.assertIn("connection.respondWith(response).catch", HTTP_SOURCE) + # respondWith rejections settle through #handle's rejection handler + self.assertIn("this.#handle(connection).then(finish", HTTP_SOURCE) self.assertIn("HTTP response closed", HTTP_SOURCE) + def test_http_service_isolates_and_bounds_each_request(self): + # One stalled request body or slow handler must not stop the accept + # loop (the pre-1.6 head-of-line blocking wedge), so the loop hands + # every connection off without awaiting it. + self.assertIn("this.#serve(connection)", HTTP_SOURCE) + self.assertNotIn("await handler(context)\n if", HTTP_SOURCE) + # A wedged peer is reclaimed by the watchdog instead of leaking a slot. + self.assertIn("REQUEST_TIMEOUT_MS", HTTP_SOURCE) + self.assertIn("HTTP request timed out", HTTP_SOURCE) + # Load shedding before lwIP's pcb pool (shared with the TTS stream) runs out. + self.assertIn("MAX_ACTIVE_REQUESTS", HTTP_SOURCE) + # A listener-level failure restarts the accept loop instead of killing it. + self.assertIn("HTTP listener restarting", HTTP_SOURCE) + + def test_http_service_uses_rejection_safe_listener(self): + # The vendored listener observes both connection promises at creation; + # upstream leaves them unobserved until respondWith, and an early peer + # reset then aborts the release build ("unhandled rejection" restart). + self.assertIn("from 'matchday/listen-safe'", HTTP_SOURCE) + listen_source = (ROOT / "mod" / "listen-safe.js").read_text(encoding="utf-8") + self.assertIn("responsePromise.then(undefined, () => {})", listen_source) + self.assertIn("requestPromise.then(undefined, () => {})", listen_source) + manifest = (ROOT / "mod" / "manifest.json").read_text(encoding="utf-8") + self.assertIn('"matchday/listen-safe": "./listen-safe"', manifest) + + def test_tts_uses_deep_buffer_with_host_fallback(self): + # The host pins WavStreamer to 600ms; XS busy bursts while speaking + # overrun that and every overrun is an audible mid-sentence dropout. + self.assertIn("matchday/tts-remote-safe", AUDIO_SOURCE) + self.assertIn("TTS_BUFFER_MS = 1500", AUDIO_SOURCE) + # Stock hosts without the audio modules keep the pre-1.6 path. + self.assertIn("Modules.importNow('tts-remote')", AUDIO_SOURCE) + tts_source = (ROOT / "mod" / "tts-remote-safe.js").read_text(encoding="utf-8") + self.assertIn("bufferDuration: this.bufferDuration", tts_source) + manifest = (ROOT / "mod" / "manifest.json").read_text(encoding="utf-8") + self.assertIn('"matchday/tts-remote-safe": "./tts-remote-safe"', manifest) + class MuteBossKeyTests(unittest.TestCase): def test_mute_gates_every_audible_and_motion_path(self): diff --git a/tools/test_stackchan_mod_web_behavior.mjs b/tools/test_stackchan_mod_web_behavior.mjs index 7247ae8..017bd56 100644 --- a/tools/test_stackchan_mod_web_behavior.mjs +++ b/tools/test_stackchan_mod_web_behavior.mjs @@ -24,6 +24,7 @@ const context = vm.createContext({ matchSetup: { language: 'zh', commentaryStyle: 'balanced', + spoilerFreeMode: false, options: [{ event_id: '760511', kalshi_event_ticker: 'KXWCADVANCE-26JUL10ESPBEL', @@ -107,11 +108,81 @@ assert.equal(styleAcknowledged.ok, true) assert.equal(context.state.matchSetup.commentaryStyle, 'casual') assert.equal(context.state.matchSetup.lastResult.style_only, true) +assert.equal(context.queueSpoilerFreeMode({ spoiler_free_mode: 'true' }).status, 400) +const queuedSpoiler = context.queueSpoilerFreeMode({ + request_id: 'spoiler-1', + spoiler_free_mode: true, +}) +assert.equal(queuedSpoiler.ok, true) +assert.equal(context.state.matchSetup.spoilerFreeMode, false) +assert.equal(context.state.matchSetup.pending.spoiler_only, true) +assert.equal(context.state.matchSetup.pending.spoiler_free_mode, true) + +// A user can toggle spoiler protection and immediately submit the fixture. +// The full request must absorb the pending choice instead of reverting it. +const queuedSpoilerMatch = context.queueMatchSetup({ + request_id: 'match-2', + event_ticker: 'KXWCADVANCE-26JUL10ESPBEL', + espn_event_id: '760511', + favorite_team: 'Belgium', + position_team: 'Belgium', + language: 'zh', +}) +assert.equal(queuedSpoilerMatch.ok, true) +assert.equal(context.state.matchSetup.spoilerFreeMode, false) +assert.equal(context.state.matchSetup.pending.request_id, 'match-2') +assert.equal(context.state.matchSetup.pending.spoiler_free_mode, true) + +const spoilerMatchAcknowledged = context.acknowledgeMatchSetup({ + request_id: 'match-2', + ok: true, + language: 'zh', + commentary_style: 'casual', + spoiler_free_mode: true, +}) +assert.equal(spoilerMatchAcknowledged.ok, true) +assert.equal(context.state.matchSetup.spoilerFreeMode, true) +assert.equal(preferences.get('spoilerFreeMode'), true) + +const queuedSpoilerOff = context.queueSpoilerFreeMode({ + request_id: 'spoiler-2', + spoiler_free_mode: false, +}) +assert.equal(queuedSpoilerOff.ok, true) +assert.equal(context.state.matchSetup.spoilerFreeMode, true) +assert.equal(context.queueCommentaryStyle({ commentary_style: 'balanced' }).status, 409) +const spoilerAcknowledged = context.acknowledgeMatchSetup({ + request_id: 'spoiler-2', + ok: true, + spoiler_free_mode: false, +}) +assert.equal(spoilerAcknowledged.ok, true) +assert.equal(context.state.matchSetup.spoilerFreeMode, false) +assert.equal(context.state.matchSetup.lastResult.spoiler_only, true) + +const queuedStandalone = context.queueMatchSetup({ + request_id: 'market-1', + standalone: true, + kalshi_url: 'KXWCADVANCE-26JUL10ESPBEL', + language: 'en', + commentary_style: 'professional', + spoiler_free_mode: true, +}) +assert.equal(queuedStandalone.ok, true) +assert.equal(context.state.matchSetup.pending.spoiler_free_mode, true) +assert.equal(context.acknowledgeMatchSetup({ + request_id: 'market-1', + ok: true, + spoiler_free_mode: true, +}).ok, true) +assert.equal(context.state.matchSetup.spoilerFreeMode, true) + context.state.matchSetup.current = { label: 'keep me' } const optionCount = context.state.matchSetup.options.length -context.syncMatchSetup({ commentary_style: 'professional' }) +context.syncMatchSetup({ commentary_style: 'professional', spoiler_free_mode: false }) assert.equal(context.state.matchSetup.commentaryStyle, 'professional') +assert.equal(context.state.matchSetup.spoilerFreeMode, false) assert.equal(context.state.matchSetup.options.length, optionCount) assert.equal(context.state.matchSetup.current.label, 'keep me') -console.log('ok mod match-setup style relay behavior') +console.log('ok mod match-setup preference relay behavior') diff --git a/tools/test_stackchan_spoiler_free_contract.py b/tools/test_stackchan_spoiler_free_contract.py new file mode 100644 index 0000000..0dfef1c --- /dev/null +++ b/tools/test_stackchan_spoiler_free_contract.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Cross-file release contract for spoiler protection.""" + +from __future__ import annotations + +import json +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +class SpoilerFreeReleaseContractTests(unittest.TestCase): + def test_default_is_opt_in_and_mod_version_is_documented(self): + example = json.loads( + (ROOT / "config" / "kalshi_watchlist.example.json").read_text( + encoding="utf-8" + ) + ) + mod_state = (ROOT / "mod" / "state.js").read_text(encoding="utf-8") + readmes = "\n".join( + (ROOT / name).read_text(encoding="utf-8") + for name in ("README.md", "README.zh-CN.md") + ) + + self.assertIs(example["spoiler_free_mode"], False) + self.assertIn("MOD_VERSION = '1.6.0'", mod_state) + self.assertIn("docs/releases/1.6.0.md", readmes) + + def test_dedicated_endpoints_exist_in_code_and_api_docs(self): + setup = (ROOT / "tools" / "stackchan_match_setup.py").read_text( + encoding="utf-8" + ) + mod_web = (ROOT / "mod" / "web.js").read_text(encoding="utf-8") + documents = "\n".join( + path.read_text(encoding="utf-8") + for path in ( + ROOT / "docs" / "device-api.md", + ROOT / "docs" / "device-api.zh-CN.md", + ROOT / "docs" / "releases" / "1.6.0.md", + ) + ) + + self.assertIn('path == "/api/setup/spoiler"', setup) + self.assertIn("server.post('/api/match-setup/spoiler'", mod_web) + self.assertIn("POST /api/setup/spoiler", documents) + self.assertIn("POST /api/match-setup/spoiler", documents) + + def test_docs_keep_confirmed_espn_and_passive_market_display(self): + documents = "\n".join( + path.read_text(encoding="utf-8") + for path in ( + ROOT / "docs" / "configuration.md", + ROOT / "docs" / "configuration.zh-CN.md", + ROOT / "docs" / "releases" / "1.6.0.md", + ) + ).casefold() + + self.assertIn("confirmed espn", documents) + self.assertIn("概率条", documents) + self.assertIn("ticker", documents) + + +if __name__ == "__main__": + unittest.main()