From bbb7c99e3ace05f9f5066fde0f29c290ecce7efd Mon Sep 17 00:00:00 2001 From: Xiaoyi He Date: Fri, 10 Jul 2026 20:22:08 -0700 Subject: [PATCH] Personalize commentary and add player catalog --- README.md | 49 +- README.zh-CN.md | 36 +- config/espn_player_catalog.json | 161 ++++ docs/commentary-styles-prd.md | 55 +- docs/releases/1.5.0.md | 63 ++ tools/stackchan_kalshi_watch.py | 1022 ++++++++++++++++++++++-- tools/stackchan_match_setup.py | 78 +- tools/stackchan_player_catalog.py | 481 +++++++++++ tools/test_stackchan_kalshi_watch.py | 717 ++++++++++++++++- tools/test_stackchan_match_replay.py | 10 +- tools/test_stackchan_match_setup.py | 152 +++- tools/test_stackchan_player_catalog.py | 258 ++++++ 12 files changed, 2932 insertions(+), 150 deletions(-) create mode 100644 config/espn_player_catalog.json create mode 100644 tools/stackchan_player_catalog.py create mode 100644 tools/test_stackchan_player_catalog.py diff --git a/README.md b/README.md index 7869ed6..63bfa8b 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,11 @@ from a phone. > [!IMPORTANT] > This is a read-only match companion. It does not trade, access a Kalshi > account, or provide betting advice. `position_team` is a manually entered -> preference used only to choose the post-match reaction. Kalshi data comes -> from its public REST API; ESPN data comes from publicly reachable, -> undocumented endpoints that may change or lag behind the broadcast. +> preference: the watcher describes its benefit or pressure around major ESPN +> events, results, and Kalshi moves from a confirmed matching position market, +> but never reads a real account. Kalshi data comes from its public REST API; +> ESPN data comes from publicly reachable, undocumented endpoints that may +> change or lag behind the broadcast. ## Features @@ -22,6 +24,8 @@ from a phone. - Goal, card, substitution, close-miss, match-state, and final-result reactions. - Three switchable commentary styles: casual co-watching, balanced narration, and professional play-by-play with ESPN-supplied detail. +- Separate support and position perspectives: routine play follows the team you + support, while major events add position impact and acknowledge conflicts. - A phone setup page hosted by Stack-chan, with live Chinese/English switching. - Double-tap head-touch and Power-button shortcuts for the setup QR code. - Fixture discovery, adaptive polling, hot configuration reload, and quiet hours. @@ -78,7 +82,8 @@ not the primary QR flow. - `tools/` — the watcher, local setup service, macOS TTS server, replay tool, serial helper, asset generator, and tests. The default HTTP workflow uses the Python standard library; serial transport additionally requires `pyserial`. -- `config/` — the example watcher configuration and flag-pack definition. +- `config/` — the example watcher configuration, flag-pack definition, and + global ESPN player catalog. - `docs/` — the [commentary-styles PRD](docs/commentary-styles-prd.md) and version-specific upgrade notes, including the bilingual [Matchday MOD 1.5.0 notes](docs/releases/1.5.0.md) and @@ -112,6 +117,11 @@ update this watcher checkout and reinstall the Matchday mod. You do not need to rebuild or reflash the official host firmware or replace the TTS module; see the [1.4.0 release notes](docs/releases/1.4.0.md). +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 both the watcher code and `config/espn_player_catalog.json`, then +restart the Python watcher process; the mod and host do not need reflashing. + ### 1. Clone and prepare the upstream build environment ```sh @@ -277,8 +287,9 @@ falls back to short tone patterns. 3. **Scan and choose.** Open the QR on a phone, choose 中文 or English, select a match, your team (or Neutral), an optional pregame position (or No position), and a commentary style, then tap **Start watching**. The - position is only a manual final result reaction preference; no account is - read. + Match Setup enables the Kalshi market for the selected position team; with + No position, it enables the first market in the match pair. The position is + still only a manual preference and no account is read. 4. **Wait for confirmation.** The page first says that it is waiting for the watcher. The watcher validates the ESPN/Kalshi pairing, atomically updates the local configuration, hot-reloads, and acknowledges the device. No @@ -341,9 +352,20 @@ User-facing text accepts either a legacy string or a localized object: } ``` -The same localized leaf format works for `player_names`, `star_chants`, and -custom goal-signal speech. Missing English names fall back to ESPN's source -name. A legacy string is used verbatim in both modes. +The watcher first consults the global `config/espn_player_catalog.json`, keyed +by stable ESPN athlete IDs such as `espn:362150`. Entries contain formal +Chinese/English names, manually verified casual nicknames, a featured-player +flag, and an optional goal chant. `balanced`, `professional`, and every device +balloon always use the formal name; only casual speech may use a friendly +nickname. Legacy per-match `player_names` and `star_chants` remain supported +and override the catalog, and custom goal-signal speech keeps the same +localized-leaf format. + +An unmatched player falls back to ESPN's original name; the watcher neither +transliterates nor invents a nickname. When an ESPN roster first appears or +changes, the watcher logs named and featured coverage plus the raw-name +fallback list so the catalog can be completed before a later match without +blocking commentary. ### Commentary styles @@ -370,6 +392,15 @@ Across all three styles, balloons normally stay in the form “time + player/team + event + score”; only punctuation and a small amount of wording vary. +The supported team sets the everyday co-watching point of view. Routine shots, +saves, and corners do not force a position aside. Major ESPN events such as +goals, penalties, red cards, and results add whether the declared position +benefits or comes under pressure; aligned support and position are combined, +while a conflict explicitly separates the emotional and position outcomes. +Only a Kalshi market that Match Setup has matched to the selected position and +marked `tracks_position` uses position wording for price moves or suspected +goals. Suspected events still say “if confirmed” and await commentary. + The selected style also applies to match phases and results, Kalshi market jumps, and suspected-goal alerts. It changes wording only: TTS voice and rate, sound effects, celebrations, expressions, lights, priorities, and alert diff --git a/README.zh-CN.md b/README.zh-CN.md index 918d608..1e96a0a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -10,9 +10,10 @@ Kalshi 晋级市场中的概率,跟随 ESPN 比分与文字直播,并通过 > [!IMPORTANT] > 这是一个只读的比赛陪看工具。它不会交易、不会访问 Kalshi 账户,也不提供投注 -> 建议。`position_team` 只是手动填写的偏好,仅用于选择终场反应。Kalshi 数据来自 -> 其公开 REST API;ESPN 数据来自可公开访问但未正式文档化的接口,可能变更,也 -> 可能落后于电视直播。 +> 建议。`position_team` 只是手动填写的偏好:watcher 会在重大 ESPN 事件、赛果以及 +> 已确认对应持仓的 Kalshi 市场变化中说明利好或利空,但不会读取真实账户。Kalshi +> 数据来自其公开 REST API;ESPN 数据来自可公开访问但未正式文档化的接口,可能 +> 变更,也可能落后于电视直播。 ## 功能 @@ -20,6 +21,8 @@ Kalshi 晋级市场中的概率,跟随 ESPN 比分与文字直播,并通过 - 对进球、红黄牌、换人、险情、比赛状态和终场结果做出反应。 - 三档可即时切换的播报语气:朋友陪看的 `casual`、自然播报的 `balanced`,以及 补充 ESPN 明确信息的专业解说 `professional`。 +- 支持队与持仓队分开建模:普通攻防站在支持队视角,重大事件再自然补充持仓影响, + 两者方向相反时也会明确表达冲突。 - 由 Stack-chan 自己托管的手机设置页,可即时切换中英文。 - 双击头顶触摸条或短按 Power 键唤出设置二维码。 - 自动发现比赛、自适应轮询、配置热更新与免打扰时段。 @@ -79,7 +82,7 @@ watcher 电脑上的 `:8788/setup` 只是可选的本机管理后备页,不是 - `tools/` — watcher、本地设置服务、macOS TTS 服务、比赛回放、串口辅助工具、 素材生成器与测试。默认 HTTP 流程只用 Python 标准库;串口模式另需 `pyserial`。 -- `config/` — watcher 示例配置与旗帜包定义。 +- `config/` — watcher 示例配置、旗帜包定义与全局 ESPN 球员目录。 - `docs/` — [三档播报语气 PRD](docs/commentary-styles-prd.md) 与各版本升级说明, 包括双语的 [Matchday MOD 1.5.0 说明](docs/releases/1.5.0.md)与 [1.4.0 说明](docs/releases/1.4.0.md)。 @@ -109,6 +112,10 @@ export STACKCHAN_DIR="$HOME/src/stack-chan" 重新安装 Matchday Mod;无需重新构建或烧录官方 host firmware,也无需更换 TTS 模块。详见 [1.4.0 版本说明](docs/releases/1.4.0.md)。 +支持/持仓视角文案、按持仓选择市场和全局球员目录属于 watcher-only 更新:已有 +1.5.0 Mod 可直接沿用。请同步更新 watcher 代码与 +`config/espn_player_catalog.json`,然后重启 Python watcher 进程;无需刷写 Mod 或 host。 + ### 1. 克隆仓库并准备上游构建环境 ```sh @@ -262,7 +269,8 @@ curl --request POST --data-binary "say 比赛日准备好了" \ Power 键同样可以开关二维码。 3. **扫码选择比赛。** 用手机打开二维码,选择中文或 English、要看的比赛、支持 球队(也可中立)、可选的赛前持仓(也可“没买”)以及播报语气,再点“开始 - 看球”。赛前持仓只是手动的终场反应偏好,系统不会读取账户。 + 看球”。有持仓时,Match Setup 会启用该队对应的 Kalshi 市场;选择“没买”时 + 默认启用比赛双方列表中的第一个市场。持仓仍只是手动偏好,系统不会读取账户。 4. **等待确认。** 页面会先显示“已提交,等待 watcher”。watcher 会校验 ESPN 与 Kalshi 的双方匹配,原子更新本地配置、热重载并确认设备;无需重启 watcher 或 Stack-chan。 @@ -319,8 +327,16 @@ watcher 还可以每天主动提醒你扫码选比赛。相关配置为 } ``` -`player_names`、`star_chants` 和自定义进球信号语音也支持相同格式。缺少英文名时 -会回退到 ESPN 源名称;旧式字符串会在两种语言下原样使用。 +watcher 会先查全局 `config/espn_player_catalog.json`。目录以稳定的 ESPN athlete +ID(例如 `espn:362150`)为主键,保存正式中英文名、经人工核实的 casual 昵称、 +球星标记和可选进球口号。`balanced`、`professional` 与所有气泡始终使用正式名; +只有 casual 语音可以使用目录中的亲切昵称。旧配置里的 `player_names` 和 +`star_chants` 继续有效,并优先于全局目录;自定义进球信号语音也仍支持上述本地化 +格式。 + +无法匹配目录的球员会回退到 ESPN 原名,不音译、不猜昵称。ESPN roster 首次出现 +或发生变化后,watcher 会在日志输出目录命中数、球星数和原名回退名单,便于赛前 +补齐覆盖,而不会阻断播报。 ### 三档播报语气 @@ -342,6 +358,12 @@ watcher 还可以每天主动提醒你扫码选比赛。相关配置为 三档气泡原则上都保持“时间 + 球员/球队 + 事件 + 比分”,只允许标点和少量措辞 差异。 +支持队决定日常陪看视角;普通射门、扑救、角球等攻防事件不会牵强讨论仓位。进球、 +点球、红牌和赛果等重大 ESPN 事件才会追加持仓利好/利空;支持与持仓同向时合并成 +一句自然反应,方向冲突时分别说明“心里支持谁”和“仓位受益或承压”。只有与所选 +持仓队对应且由 Match Setup 标记为 `tracks_position` 的 Kalshi 市场,盘口变化和 +疑似进球提醒才会使用持仓措辞;疑似事件仍保留“如果属实”和等待确认。 + 语气也覆盖比赛阶段和赛果、Kalshi 盘口突变与疑似进球提醒,并且只改变文案:TTS 音色和语速、音效、庆祝动作、表情、灯效、优先级与提醒开关均保持不变。比赛中可从 任一设置页即时切换;新生成的提醒会立即使用新语气,但不会重播 ESPN 旧事件,也 diff --git a/config/espn_player_catalog.json b/config/espn_player_catalog.json new file mode 100644 index 0000000..c52ad73 --- /dev/null +++ b/config/espn_player_catalog.json @@ -0,0 +1,161 @@ +{ + "schema_version": 1, + "players": { + "espn:45843": { + "aliases": ["Lionel Messi", "L. Messi"], + "display_name": {"zh": "梅西", "en": "Lionel Messi"}, + "featured": true, + "goal_chant": {"zh": "{name}破门了!", "en": "{name} scores!"} + }, + "espn:134947": { + "aliases": ["Kevin De Bruyne", "K. De Bruyne"], + "display_name": {"zh": "德布劳内", "en": "Kevin De Bruyne"}, + "casual_name": {"zh": "丁丁", "en": "De Bruyne"}, + "featured": true, + "goal_chant": {"zh": "{name}破门了!", "en": "{name} scores!"} + }, + "espn:139437": { + "aliases": ["Romelu Lukaku", "R. Lukaku"], + "display_name": {"zh": "卢卡库", "en": "Romelu Lukaku"}, + "featured": true + }, + "espn:142200": { + "aliases": ["Harry Kane", "H. Kane"], + "display_name": {"zh": "凯恩", "en": "Harry Kane"}, + "casual_name": {"zh": "飓风凯恩", "en": "Hurricane Kane"}, + "featured": true, + "goal_chant": {"zh": "{name}破门了!", "en": "{name} scores!"} + }, + "espn:149981": { + "aliases": ["Granit Xhaka", "G. Xhaka"], + "display_name": {"zh": "扎卡", "en": "Granit Xhaka"}, + "featured": true + }, + "espn:158626": { + "aliases": ["Emiliano Martínez", "E. Martínez", "Emiliano Martinez"], + "display_name": {"zh": "埃米利亚诺·马丁内斯", "en": "Emiliano Martinez"}, + "featured": true + }, + "espn:159443": { + "aliases": ["Jordan Pickford", "J. Pickford"], + "display_name": {"zh": "皮克福德", "en": "Jordan Pickford"}, + "featured": true + }, + "espn:174980": { + "aliases": ["Leandro Trossard", "L. Trossard"], + "display_name": {"zh": "特罗萨德", "en": "Leandro Trossard"}, + "featured": true + }, + "espn:188230": { + "aliases": ["Alexander Sørloth", "A. Sørloth", "Alexander Sorloth"], + "display_name": {"zh": "瑟洛特", "en": "Alexander Sorloth"}, + "featured": true + }, + "espn:193610": { + "aliases": ["Ørjan Nyland", "Ø. Nyland", "Orjan Nyland"], + "display_name": {"zh": "尼兰", "en": "Orjan Nyland"}, + "featured": false + }, + "espn:203669": { + "aliases": ["Martin Ødegaard", "M. Ødegaard", "Martin Odegaard"], + "display_name": {"zh": "厄德高", "en": "Martin Odegaard"}, + "featured": true + }, + "espn:214562": { + "aliases": ["Manuel Akanji", "M. Akanji"], + "display_name": {"zh": "阿坎吉", "en": "Manuel Akanji"}, + "featured": true + }, + "espn:219713": { + "aliases": ["Lautaro Martínez", "L. Martínez", "Lautaro Martinez"], + "display_name": {"zh": "劳塔罗·马丁内斯", "en": "Lautaro Martinez"}, + "featured": true + }, + "espn:227765": { + "aliases": ["Dani Olmo", "D. Olmo"], + "display_name": {"zh": "奥尔莫", "en": "Dani Olmo"}, + "featured": true + }, + "espn:229744": { + "aliases": ["Ousmane Dembélé", "O. Dembélé", "Ousmane Dembele"], + "display_name": {"zh": "登贝莱", "en": "Ousmane Dembele"}, + "featured": true + }, + "espn:230945": { + "aliases": ["Marcus Rashford", "M. Rashford"], + "display_name": {"zh": "拉什福德", "en": "Marcus Rashford"}, + "casual_name": {"zh": "拉师傅", "en": "Rashford"}, + "featured": true + }, + "espn:231388": { + "aliases": ["Kylian Mbappé", "K. Mbappé", "Kylian Mbappe"], + "display_name": {"zh": "姆巴佩", "en": "Kylian Mbappe"}, + "casual_name": {"zh": "姆总", "en": "Mbappe"}, + "featured": true, + "goal_chant": {"zh": "{name}破门了!", "en": "{name} scores!"} + }, + "espn:231828": { + "aliases": ["Rodri"], + "display_name": {"zh": "罗德里", "en": "Rodri"}, + "featured": true + }, + "espn:238262": { + "aliases": ["Declan Rice", "D. Rice"], + "display_name": {"zh": "赖斯", "en": "Declan Rice"}, + "featured": true + }, + "espn:250465": { + "aliases": ["Pedri"], + "display_name": {"zh": "佩德里", "en": "Pedri"}, + "featured": true + }, + "espn:253989": { + "aliases": ["Erling Haaland", "E. Haaland"], + "display_name": {"zh": "哈兰德", "en": "Erling Haaland"}, + "casual_name": {"zh": "哈宝", "en": "Haaland"}, + "featured": true, + "goal_chant": {"zh": "{name}破门了!", "en": "{name} scores!"} + }, + "espn:277206": { + "aliases": ["Julián Álvarez", "J. Álvarez", "Julian Alvarez"], + "display_name": {"zh": "阿尔瓦雷斯", "en": "Julian Alvarez"}, + "casual_name": {"zh": "小蜘蛛", "en": "Julian Alvarez"}, + "featured": true, + "goal_chant": {"zh": "{name}破门了!", "en": "{name} scores!"} + }, + "espn:280555": { + "aliases": ["Bukayo Saka", "B. Saka"], + "display_name": {"zh": "萨卡", "en": "Bukayo Saka"}, + "casual_name": {"zh": "小辣椒萨卡", "en": "Little Chilli Saka"}, + "featured": true, + "goal_chant": {"zh": "{name}破门了!", "en": "{name} scores!"} + }, + "espn:283672": { + "aliases": ["Jérémy Doku", "J. Doku", "Jeremy Doku"], + "display_name": {"zh": "多库", "en": "Jeremy Doku"}, + "featured": true + }, + "espn:291281": { + "aliases": ["Jude Bellingham", "J. Bellingham"], + "display_name": {"zh": "贝林厄姆", "en": "Jude Bellingham"}, + "featured": true + }, + "espn:312146": { + "aliases": ["Nico Williams", "N. Williams"], + "display_name": {"zh": "尼科·威廉斯", "en": "Nico Williams"}, + "featured": true + }, + "espn:319368": { + "aliases": ["Antonio Nusa", "A. Nusa"], + "display_name": {"zh": "努萨", "en": "Antonio Nusa"}, + "featured": true + }, + "espn:362150": { + "aliases": ["Lamine Yamal", "L. Yamal"], + "display_name": {"zh": "亚马尔", "en": "Lamine Yamal"}, + "casual_name": {"zh": "小孩哥", "en": "Yamal"}, + "featured": true, + "goal_chant": {"zh": "{name}破门了!", "en": "{name} scores!"} + } + } +} diff --git a/docs/commentary-styles-prd.md b/docs/commentary-styles-prd.md index 7c1d69e..6b0023e 100644 --- a/docs/commentary-styles-prd.md +++ b/docs/commentary-styles-prd.md @@ -1,8 +1,9 @@ # Stack-chan 三档播报语气 PRD -- 目标版本:Matchday MOD 1.4.0 +- 目标版本:Matchday MOD 1.4.0;个性化视角与球员目录为后续 watcher-only 更新 - 状态:已实现 -- 范围:watcher 文案生成、watcher 本地设置页、设备 `/setup` 设置转发 +- 范围:watcher 文案生成、支持/持仓视角、全局球员目录、watcher 本地设置页、 + 设备 `/setup` 设置转发 ## 目标 @@ -31,6 +32,49 @@ ESPN 事件先解析为结构化事实,再交给模板。可用字段包括比 专业档不能直接朗读英文文字直播原文。气泡在三档中都保持精炼,原则上为“时间 + 球员/球队 + 事件 + 比分”;详细信息主要由专业档语音承担。 +### 支持队与持仓视角 + +`favorite_team` 表达用户的情感支持,`position_team` 表达用户手动声明的赛前持仓; +两者可以为空、相同或相反,watcher 不访问任何账户。文案先根据事件所属球队计算 +支持方影响,再只对重大事件计算持仓影响: + +- 普通攻防只使用支持队视角,避免每次射门、扑救或角球都机械谈仓位。 +- 进球、点球、红牌和赛果等重大 ESPN 事件说明持仓利好或利空。 +- 支持与持仓同向时合并表达;方向冲突时分别保留情感反应与仓位结果。 +- Kalshi 盘口变化与疑似进球只有在市场已确认对应 `position_team`,且配置为 + `tracks_position` 时才使用持仓措辞;不确定事件必须保留“如果属实”和等待确认。 + +Match Setup 有持仓时启用该球队对应的市场,并设置 `tracks_position`;无持仓时 +默认启用比赛双方列表中的第一个市场,但不把它描述为用户持仓。 + +### 全局球员目录 + +`config/espn_player_catalog.json` 以 `espn:` 为稳定主键。每条记录可包含 +ESPN 名称别名、正式中英文名、`featured` 标记、经人工核实的 casual 昵称,以及 +可选进球口号。 + +- `balanced`、`professional` 与三档气泡只使用正式名。 +- casual 语音可使用已核实昵称;没有昵称时仍使用正式名。 +- 旧配置的 `espn.player_names` 与 `espn.star_chants` 作为逐场覆盖层继续生效,并 + 优先于全局目录。 +- 未知球员回退到 ESPN 原名;不得自动音译、猜测球星身份或编造昵称。 +- roster 首次出现或变化时,watcher 日志输出正式名覆盖数、球星数与原名回退名单; + 覆盖不足不阻断事件播报。 + +首批中文名与昵称依据公开中文体育媒体及球员相关报道人工核实,包括: +[央视网:亚马尔](https://tv.cctv.com/2025/07/10/VIDE6Snrysk8wmjjqdZaIhGA250710.shtml)、 +[PP 体育:德布劳内](https://www.ppsport.com/article/news/743879.html)、 +[界面新闻:凯恩](https://www.jiemian.com/article/2238945.html)、 +[Sky Sports:萨卡 “Little Chilli”](https://www.skysports.com/watch/video/sports/12340539/why-is-saka-called-little-chilli)、 +[新浪:哈兰德](https://k.sina.com.cn/article_7879995911_1d5af320706801ykoi.html),以及 +[PP 体育:拉什福德“拉师傅”](https://www.ppsport.com/premierleague)、 +[澎湃新闻:阿尔瓦雷斯“小蜘蛛”](https://www.thepaper.cn/newsDetail_forward_21159283)、 +[澎湃新闻:姆巴佩“姆总”](https://www.thepaper.cn/newsDetail_forward_21217626)、 +新华网的[中文阵容](https://app.xinhuanet.com/news/article.html?articleId=20260525a112ddc1a67a498f889ad189e3d7f573)和 +[英文阵容](https://www.xinhuanet.com/sports/20260522/9ac26eb3e77a497582e666931638acbe/c.html),以及 +[阿根廷与瑞士赛事中文名](https://www.xinhuanet.com/sports/20260708/3529b2c9a4f148798a2bdf2e7087edeb/c.html)。 +目录中的昵称仍须逐条人工判断是否适合口播;来源存在正式名并不等于认可任意昵称。 + ### 覆盖范围 语气覆盖 ESPN 比赛事件、比赛阶段和赛果,以及 Kalshi 盘口突变、价格跳动和疑似 @@ -55,6 +99,9 @@ ESPN 事件先解析为结构化事实,再交给模板。可用字段包括比 设置、持久化和配置转发;官方 Stack-chan host firmware 与 TTS 模块不做改动。 升级到 1.4.0 时更新 watcher 并安装新版 Matchday MOD 即可,无需重新烧录 host。 +本轮支持/持仓文案、市场选择与球员目录只修改 watcher;已有 1.5.0 Mod 无需重新 +安装,也无需刷写 host。部署时必须同步 watcher 代码和全局球员目录,并重启 +Python watcher 进程;仅在手机端切换语气仍是即时热更新,不重置比赛状态。 ## 验收标准 @@ -64,6 +111,10 @@ ESPN 事件先解析为结构化事实,再交给模板。可用字段包括比 且语音比气泡更详细。 - 无法可靠解析的英文细节不会产生猜测或错误中文术语。 - 三档 Kalshi 疑似进球文案都明确保留不确定性和等待确认提示。 +- 普通攻防只体现支持队;重大事件同时验证支持/持仓同向、冲突、仅支持、仅持仓和 + 双方均未选择的文案。 +- Match Setup 有持仓时只启用对应市场;无持仓时启用第一市场且不标记为持仓。 +- 正式名、casual 昵称、旧配置覆盖、未知球员原名回退和 roster 覆盖日志均通过测试。 - 只切换语气不会重播旧 ESPN 事件,也不会重置盘口状态、队列或轮询状态。 - watcher、手机设置服务、设备 pending/ack 链路和 MOD 页面通过完整测试。 diff --git a/docs/releases/1.5.0.md b/docs/releases/1.5.0.md index f64a698..4673c40 100644 --- a/docs/releases/1.5.0.md +++ b/docs/releases/1.5.0.md @@ -4,6 +4,41 @@ ## English +### Watcher commentary personalization (watcher-only) + +- Support and position are now separate narration perspectives. Routine play + follows the supported team; major ESPN events and results add position + benefit or pressure, including natural wording when support and position + point in opposite directions. +- Match Setup enables the Kalshi market for the selected position team. With + no position it defaults to the first market, but does not label that market + as a holding. Only a confirmed matching `tracks_position` market adds + position wording to price moves or suspected-goal alerts. +- The new global `config/espn_player_catalog.json` is keyed by ESPN athlete ID. + Balanced/professional speech and balloons use formal names; casual speech may + use a manually verified nickname. Legacy `player_names` and `star_chants` + still override the catalog. Unknown players use ESPN's original name without + an invented nickname. +- When a roster appears or changes, the watcher logs catalog coverage, + featured-player count, and raw-name fallbacks for pre-match maintenance. +- These changes live entirely in the watcher. If Matchday Mod 1.5.0 is already + installed, deploy the watcher code and `config/espn_player_catalog.json`, + then restart the Python watcher process; do not reflash the mod or host. + +Nickname and bilingual-name review used public references including +[CCTV on Yamal](https://tv.cctv.com/2025/07/10/VIDE6Snrysk8wmjjqdZaIhGA250710.shtml), +[PP Sports on De Bruyne](https://www.ppsport.com/article/news/743879.html), +[Jiemian on Kane](https://www.jiemian.com/article/2238945.html), +[Sky Sports on Saka's “Little Chilli” nickname](https://www.skysports.com/watch/video/sports/12340539/why-is-saka-called-little-chilli), +[Sina on Haaland](https://k.sina.com.cn/article_7879995911_1d5af320706801ykoi.html), +[PP Sports' “Master Rashford” usage](https://www.ppsport.com/premierleague), +[The Paper on Julián Álvarez's “Little Spider” nickname](https://www.thepaper.cn/newsDetail_forward_21159283), +[The Paper's use of “Mbappe boss”](https://www.thepaper.cn/newsDetail_forward_21217626), +and Xinhua's [Chinese](https://app.xinhuanet.com/news/article.html?articleId=20260525a112ddc1a67a498f889ad189e3d7f573) +and [English](https://www.xinhuanet.com/sports/20260522/9ac26eb3e77a497582e666931638acbe/c.html) +roster coverage, plus its [Argentina–Switzerland match coverage](https://www.xinhuanet.com/sports/20260708/3529b2c9a4f148798a2bdf2e7087edeb/c.html). +A formal-name source is not treated as permission to invent a nickname. + ### Boss key: one-touch mute - Hold the top touch bar for about one second to toggle mute; a balloon @@ -33,6 +68,34 @@ ## 简体中文 +### watcher 个性化播报(仅更新 watcher) + +- 支持队与持仓队成为两套独立视角:普通攻防只跟随支持队;重大 ESPN 事件和赛果 + 再补充持仓利好/利空,支持与持仓相反时会自然表达冲突。 +- Match Setup 有持仓时启用该队对应的 Kalshi 市场;没买时默认启用第一市场,但不 + 把它描述为持仓。只有已确认对应持仓且标记为 `tracks_position` 的市场,盘口与 + 疑似进球提醒才会加入持仓措辞。 +- 新增以 ESPN athlete ID 为主键的全局 `config/espn_player_catalog.json`。 + balanced、professional 与气泡使用正式名,casual 语音可以使用人工核实的昵称; + 旧 `player_names`、`star_chants` 仍可覆盖目录。未知球员回退 ESPN 原名,不猜昵称。 +- roster 出现或变化后,watcher 日志会输出目录覆盖率、球星数与原名回退名单,便于 + 赛前维护。 +- 以上改动全部位于 watcher。已经安装 Matchday Mod 1.5.0 时,同步 watcher 代码和 + `config/espn_player_catalog.json` 后重启 Python watcher 进程即可;不要重新刷写 Mod 或 host。 + +中文名和昵称人工核验参考了[央视网的亚马尔报道](https://tv.cctv.com/2025/07/10/VIDE6Snrysk8wmjjqdZaIhGA250710.shtml)、 +[PP 体育的德布劳内报道](https://www.ppsport.com/article/news/743879.html)、 +[界面新闻的凯恩报道](https://www.jiemian.com/article/2238945.html)、 +[Sky Sports 对萨卡“小辣椒”昵称的说明](https://www.skysports.com/watch/video/sports/12340539/why-is-saka-called-little-chilli)、 +[新浪的哈兰德报道](https://k.sina.com.cn/article_7879995911_1d5af320706801ykoi.html)、 +[PP 体育的“拉师傅”用例](https://www.ppsport.com/premierleague)、 +[澎湃新闻的阿尔瓦雷斯“小蜘蛛”报道](https://www.thepaper.cn/newsDetail_forward_21159283)、 +[澎湃新闻的“姆总”用例](https://www.thepaper.cn/newsDetail_forward_21217626),以及新华网的 +[中文阵容](https://app.xinhuanet.com/news/article.html?articleId=20260525a112ddc1a67a498f889ad189e3d7f573)与 +[英文阵容](https://www.xinhuanet.com/sports/20260522/9ac26eb3e77a497582e666931638acbe/c.html),以及 +[阿根廷与瑞士比赛报道](https://www.xinhuanet.com/sports/20260708/3529b2c9a4f148798a2bdf2e7087edeb/c.html)。 +正式名来源不等于可以凭空增加昵称,目录仍逐条人工审核。 + ### 老板键:一键静音 - 长按头顶触摸条约一秒即可切换静音;手指未松开时气泡就会确认,静音期间屏幕角落 diff --git a/tools/stackchan_kalshi_watch.py b/tools/stackchan_kalshi_watch.py index c7f2b05..e8d183b 100755 --- a/tools/stackchan_kalshi_watch.py +++ b/tools/stackchan_kalshi_watch.py @@ -11,6 +11,7 @@ import argparse import json import math +import re import socket import subprocess import sys @@ -20,7 +21,7 @@ import urllib.parse import urllib.request from concurrent.futures import Future, ThreadPoolExecutor -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import datetime, time as dt_time, timezone from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from pathlib import Path @@ -41,6 +42,13 @@ resolve_text_map, ) from stackchan_match_setup import MatchSetupService, start_setup_server + from stackchan_player_catalog import ( + PlayerCatalog, + PlayerCatalogError, + ResolvedPlayerProfile, + load_default_player_catalog, + resolve_player_profile, + ) except ModuleNotFoundError: # pragma: no cover - supports importlib-based tests. sys.path.insert(0, str(Path(__file__).parent)) from stackchan_i18n import ( @@ -52,6 +60,13 @@ resolve_text_map, ) from stackchan_match_setup import MatchSetupService, start_setup_server + from stackchan_player_catalog import ( + PlayerCatalog, + PlayerCatalogError, + ResolvedPlayerProfile, + load_default_player_catalog, + resolve_player_profile, + ) DEFAULT_BASE_URL = "https://external-api.kalshi.com/trade-api/v2" @@ -126,6 +141,9 @@ class MarketConfig: goal_signal_down_team: str = "" language: str = "zh" commentary_style: str = DEFAULT_COMMENTARY_STYLE + favorite_team: str = "" + position_team: str = "" + tracks_position: bool = False @dataclass @@ -197,6 +215,11 @@ class ESPNConfig: team_colors: dict[str, str] = field(default_factory=dict) player_names: dict[str, str] = field(default_factory=dict) star_chants: dict[str, str] = field(default_factory=dict) + player_catalog: PlayerCatalog = field( + default_factory=load_default_player_catalog, + repr=False, + compare=False, + ) language: str = "zh" commentary_style: str = DEFAULT_COMMENTARY_STYLE @@ -390,6 +413,20 @@ class ESPNEventFacts: is_equalizer: bool = False +@dataclass(frozen=True) +class EventPerspective: + """How one match event lands for the fan and the configured position. + + The values intentionally describe impact instead of wording. Voice styles + can therefore sound different without quietly changing which side of an + event the user is on. + """ + + support_outcome: str = "neutral" + position_outcome: str = "none" + alignment: str = "neutral" + + @dataclass class ESPNState: initialized: bool = False @@ -397,6 +434,7 @@ class ESPNState: last_status_state: str = "" last_polled_at: float = 0 final_result_announced: bool = False + player_coverage_signature: tuple[str, ...] = field(default_factory=tuple) PendingAlertContext = tuple[ @@ -567,6 +605,10 @@ def load_config(path: Path, language_override: str | None = None) -> WatchConfig raise ConfigError("espn.player_names must be an object") if not isinstance(star_chants_raw, dict): raise ConfigError("espn.star_chants must be an object") + try: + player_catalog = load_default_player_catalog() + except PlayerCatalogError as error: + raise ConfigError(f"cannot load ESPN player catalog: {error}") from error espn = ESPNConfig( language=language, commentary_style=commentary_style, @@ -603,6 +645,7 @@ def load_config(path: Path, language_override: str | None = None) -> WatchConfig team_colors={str(key): str(value) for key, value in team_colors_raw.items()}, player_names=_config_text_map(player_names_raw, language, path="espn.player_names"), star_chants=_config_text_map(star_chants_raw, language, path="espn.star_chants"), + player_catalog=player_catalog, ) markets: list[MarketConfig] = [] @@ -633,6 +676,31 @@ def load_config(path: Path, language_override: str | None = None) -> WatchConfig language, path=f"markets[{idx}].goal_signal_down_team", ) + goal_signal_up_team = _goal_signal_team_fact( + goal_signal_up_team, + goal_signal_up_speech, + espn.team_names, + ) + goal_signal_down_team = _goal_signal_team_fact( + goal_signal_down_team, + goal_signal_down_speech, + espn.team_names, + ) + favorite_team = ( + localized_team_name(espn, espn.favorite_team) if espn.favorite_team else "" + ) + position_team = ( + localized_team_name(espn, espn.position_team) if espn.position_team else "" + ) + inferred_position_market = bool( + position_team + and goal_signal_up_team + and position_team.casefold() == goal_signal_up_team.casefold() + ) + tracks_position = bool( + position_team + and item.get("tracks_position", inferred_position_market) + ) markets.append( MarketConfig( ticker=ticker, @@ -662,16 +730,11 @@ def load_config(path: Path, language_override: str | None = None) -> WatchConfig ), goal_signal_up_speech=goal_signal_up_speech, goal_signal_down_speech=goal_signal_down_speech, - goal_signal_up_team=_goal_signal_team_fact( - goal_signal_up_team, - goal_signal_up_speech, - espn.team_names, - ), - goal_signal_down_team=_goal_signal_team_fact( - goal_signal_down_team, - goal_signal_down_speech, - espn.team_names, - ), + goal_signal_up_team=goal_signal_up_team, + goal_signal_down_team=goal_signal_down_team, + favorite_team=favorite_team, + position_team=position_team, + tracks_position=tracks_position, ) ) @@ -1165,8 +1228,8 @@ def match_score_speech(snapshot: MatchSnapshot, config: ESPNConfig) -> str: return pick( config.language, ( - f"现在{home}和{away}常规比分{snapshot.home.score}比{snapshot.away.score}," - f"点球{snapshot.home.shootout_score or '0'}比{snapshot.away.shootout_score or '0'}" + f"{home}和{away}比赛战成{snapshot.home.score}比{snapshot.away.score}," + f"点球比分{snapshot.home.shootout_score or '0'}比{snapshot.away.shootout_score or '0'}" ), ( f"It is {home} {snapshot.home.score}, {away} {snapshot.away.score}; " @@ -1174,10 +1237,111 @@ def match_score_speech(snapshot: MatchSnapshot, config: ESPNConfig) -> str: f"{snapshot.away.shootout_score or '0'}" ), ) - return pick( - config.language, - f"现在{home}{snapshot.home.score}比{snapshot.away.score}{away}", - f"It is {home} {snapshot.home.score}, {away} {snapshot.away.score}", + if config.language == "en": + return f"It is {home} {snapshot.home.score}, {away} {snapshot.away.score}" + home_score = _score_number(snapshot.home.score) + away_score = _score_number(snapshot.away.score) + if home_score is not None and away_score is not None: + if home_score == away_score: + return f"{home}和{away}打成{snapshot.home.score}比{snapshot.away.score}" + if home_score > away_score: + return f"{home}{snapshot.home.score}比{snapshot.away.score}领先{away}" + return f"{away}{snapshot.away.score}比{snapshot.home.score}领先{home}" + return f"比分为{home}{snapshot.home.score}比{snapshot.away.score}{away}" + + +def commentary_score( + text: str, + snapshot: MatchSnapshot, +) -> tuple[int, int] | None: + """Return the score explicitly embedded in one ESPN commentary row. + + A poll can deliver several missed events at once while the scoreboard on + ``snapshot`` already reflects the newest one. Using the row's own score + prevents an earlier goal from being narrated with the later score. + """ + + details = _commentary_score_details(text, snapshot) + return (details[0], details[1]) if details is not None else None + + +def _commentary_score_details( + text: str, + snapshot: MatchSnapshot, +) -> tuple[int, int, int | None, int | None] | None: + """Parse regular and optional parenthesized shootout scores.""" + + home_aliases = tuple( + value for value in (snapshot.home.name, snapshot.home.abbreviation) if value + ) + away_aliases = tuple( + value for value in (snapshot.away.name, snapshot.away.abbreviation) if value + ) + score_token = r"(\d+)(?:\((\d+)\))?" + ending = r"(?=$|[\s.,;:!?])" + for home_name in home_aliases: + for away_name in away_aliases: + home_first = re.search( + rf"{re.escape(home_name)}\s+{score_token}\s*,\s*" + rf"{re.escape(away_name)}\s+{score_token}{ending}", + text, + re.IGNORECASE, + ) + if home_first: + return ( + int(home_first.group(1)), + int(home_first.group(3)), + int(home_first.group(2)) if home_first.group(2) is not None else None, + int(home_first.group(4)) if home_first.group(4) is not None else None, + ) + away_first = re.search( + rf"{re.escape(away_name)}\s+{score_token}\s*,\s*" + rf"{re.escape(home_name)}\s+{score_token}{ending}", + text, + re.IGNORECASE, + ) + if away_first: + return ( + int(away_first.group(3)), + int(away_first.group(1)), + int(away_first.group(4)) if away_first.group(4) is not None else None, + int(away_first.group(2)) if away_first.group(2) is not None else None, + ) + return None + + +def snapshot_at_commentary_score( + item: dict[str, Any], + snapshot: MatchSnapshot, +) -> MatchSnapshot: + play = item.get("play") or {} + text = str(item.get("text") or play.get("text") or "") + score = _commentary_score_details(text, snapshot) + if score is None: + return snapshot + home_score, away_score, home_shootout, away_shootout = score + has_shootout_score = home_shootout is not None or away_shootout is not None + preserve_shootout = "penalty shootout" in text.casefold() + return replace( + snapshot, + home=replace( + snapshot.home, + score=str(home_score), + shootout_score=( + str(home_shootout or 0) + if has_shootout_score + else snapshot.home.shootout_score if preserve_shootout else "" + ), + ), + away=replace( + snapshot.away, + score=str(away_score), + shootout_score=( + str(away_shootout or 0) + if has_shootout_score + else snapshot.away.shootout_score if preserve_shootout else "" + ), + ), ) @@ -1312,35 +1476,58 @@ def _ends_drinks_break(item: dict[str, Any], snapshot: MatchSnapshot) -> bool: return False -def _configured_player_text(mapping: dict[str, str], player: MatchPlayer | None) -> str: +def _resolved_player_profile( + config: ESPNConfig, + player: MatchPlayer | None, +) -> ResolvedPlayerProfile | None: if player is None: - return "" - normalized = {str(key).casefold(): str(value) for key, value in mapping.items()} - for key in _player_lookup_keys(player.athlete_id, player.name, player.short_name): - value = normalized.get(key) - if value: - return value - return "" + return None + return resolve_player_profile( + config.player_catalog, + athlete_id=player.athlete_id, + name=player.name, + short_name=player.short_name, + language=config.language, + player_names=config.player_names, + star_chants=config.star_chants, + ) def localized_player_name(config: ESPNConfig, player: MatchPlayer | None) -> str: if player is None: return "" - return _configured_player_text(config.player_names, player) or player.name or player.short_name + profile = _resolved_player_profile(config, player) + return (profile.display_name if profile else "") or player.name or player.short_name + + +def spoken_player_name(config: ESPNConfig, player: MatchPlayer | None) -> str: + """Return a nickname only for casual speech; facts and balloons stay formal.""" + + if player is None: + return "" + profile = _resolved_player_profile(config, player) + if ( + profile is not None + and config.commentary_style == "casual" + and profile.casual_name + ): + return profile.casual_name + return (profile.display_name if profile else "") or player.name or player.short_name def player_announcement(config: ESPNConfig, player: MatchPlayer | None) -> str: if player is None: return "" - name = localized_player_name(config, player) - if _configured_player_text(config.star_chants, player): - return name - if player.jersey: - return pick( - config.language, - f"{player.jersey}号球员{name}", - f"number {player.jersey} {name}", - ) + name = spoken_player_name(config, player) + profile = _resolved_player_profile(config, player) + # ESPN already gives us a stable player identity. Repeating "number N + # player" before every unfamiliar name sounds like a roster export rather + # than live commentary, and the number adds no event fact that is missing + # from the name itself. + if config.language == "en" and player.jersey and not ( + profile and profile.featured + ): + return f"number {player.jersey} {name}" return name @@ -1349,11 +1536,13 @@ def render_star_chant( player: MatchPlayer | None, team_name: str, ) -> str: - chant = _configured_player_text(config.star_chants, player) + profile = _resolved_player_profile(config, player) + chant = profile.goal_chant if profile else "" if not chant or player is None: return "" values = { - "name": localized_player_name(config, player), + "name": spoken_player_name(config, player), + "formal_name": localized_player_name(config, player), "number": player.jersey, "team": team_name, } @@ -1362,6 +1551,53 @@ def render_star_chant( return chant.strip() +def player_catalog_coverage( + snapshot: MatchSnapshot, + config: ESPNConfig, +) -> tuple[int, int, int, tuple[str, ...], tuple[str, ...]]: + """Summarize roster coverage without guessing unknown Chinese names.""" + + unique: dict[str, MatchPlayer] = {} + for player in snapshot.players.values(): + identity = player.athlete_id or player.name.casefold() or player.short_name.casefold() + if identity: + unique.setdefault(identity, player) + matched = 0 + featured = 0 + fallback: list[str] = [] + for identity, player in unique.items(): + profile = _resolved_player_profile(config, player) + if profile: + featured += int(profile.featured) + if profile and profile.display_name: + matched += 1 + else: + fallback.append(player.name or player.short_name or identity) + signature = tuple(sorted(unique)) + return len(unique), matched, featured, tuple(sorted(fallback)), signature + + +def report_player_catalog_coverage( + snapshot: MatchSnapshot, + config: ESPNConfig, + state: ESPNState, +) -> None: + total, matched, featured, fallback, signature = player_catalog_coverage(snapshot, config) + if not signature or signature == state.player_coverage_signature: + return + state.player_coverage_signature = signature + message = ( + f"ESPN player catalog: {matched}/{total} named, {featured} featured" + ) + if fallback: + preview = ", ".join(fallback[:8]) + remainder = len(fallback) - min(len(fallback), 8) + message += f"; raw-name fallback: {preview}" + if remainder: + message += f" (+{remainder} more)" + print(message, file=sys.stderr) + + def _event_clock(item: dict[str, Any]) -> str: play = item.get("play") or {} value = str((item.get("time") or {}).get("displayValue") or (play.get("clock") or {}).get("displayValue") or "") @@ -1412,6 +1648,7 @@ def parse_espn_event_facts( play_type = str((play.get("type") or {}).get("type") or "").lower() text = str(item.get("text") or play.get("text") or "") lower_text = text.casefold() + score_snapshot = snapshot_at_commentary_score(item, snapshot) team = _event_team(item, snapshot) primary = _event_player(item, snapshot) if team is None and primary and primary.team_name: @@ -1524,8 +1761,8 @@ def parse_espn_event_facts( elif status_code: event_type, result = "status", status_code - home_score_number = _score_number(snapshot.home.score) - away_score_number = _score_number(snapshot.away.score) + home_score_number = _score_number(score_snapshot.home.score) + away_score_number = _score_number(score_snapshot.away.score) is_equalizer = bool( event_type == "goal" and home_score_number is not None @@ -1646,9 +1883,9 @@ def parse_espn_event_facts( awarded_team=awarded_team, awarded_team_name=(localized_team_name(config, awarded_team) if awarded_team else ""), result=result, - score_text=match_score_text(snapshot, config), - compact_score_text=compact_match_score_text(snapshot, config), - score_speech=match_score_speech(snapshot, config), + score_text=match_score_text(score_snapshot, config), + compact_score_text=compact_match_score_text(score_snapshot, config), + score_speech=match_score_speech(score_snapshot, config), shot_body_part=shot_body_part, shot_area=shot_area, shot_direction=shot_direction, @@ -1662,6 +1899,265 @@ def parse_espn_event_facts( ) +MAJOR_PERSPECTIVE_EVENTS = frozenset( + { + "espn_goal", + "espn_penalty", + "espn_penalty_awarded", + "espn_red_card", + "espn_status", + } +) + + +def _configured_match_team( + snapshot: MatchSnapshot, + config: ESPNConfig, + configured_name: str, +) -> MatchTeam | None: + if not configured_name: + return None + return next( + ( + team + for team in (snapshot.home, snapshot.away) + if is_configured_team(config, configured_name, team) + ), + None, + ) + + +def _configured_event_outcome( + snapshot: MatchSnapshot, + config: ESPNConfig, + configured_name: str, + event_team: MatchTeam | None, + event_team_outcome: int, + *, + missing: str, +) -> str: + configured_team = _configured_match_team(snapshot, config, configured_name) + if configured_team is None or event_team is None or event_team_outcome == 0: + return missing + same_team = is_configured_team(config, configured_team.name, event_team) + benefits = event_team_outcome > 0 if same_team else event_team_outcome < 0 + return "benefit" if benefits else "harm" + + +def event_perspective( + snapshot: MatchSnapshot, + config: ESPNConfig, + facts: ESPNEventFacts, + alert_kind: str, +) -> EventPerspective: + """Return deterministic fan/position impact for one rendered event.""" + + is_final = alert_kind == "espn_status" and facts.status_code in { + "full_time", + "shootout_end", + } + if is_final: + winner = match_winner(snapshot) + + def final_outcome(configured_name: str, missing: str) -> str: + configured_team = _configured_match_team(snapshot, config, configured_name) + if configured_team is None or winner is None: + return missing + return ( + "benefit" + if is_configured_team(config, configured_team.name, winner) + else "harm" + ) + + support_outcome = final_outcome(config.favorite_team, "neutral") + position_outcome = final_outcome(config.position_team, "none") + else: + event_team = facts.awarded_team or facts.team + if alert_kind in {"espn_goal", "espn_penalty_awarded", "espn_corner"}: + team_outcome = 1 + elif alert_kind == "espn_penalty": + team_outcome = 1 if facts.result == "scored" else -1 + elif alert_kind in { + "espn_woodwork", + "espn_shot_saved", + "espn_close_miss", + "espn_shot_blocked", + "espn_red_card", + "espn_yellow_card", + "espn_foul", + }: + team_outcome = -1 + elif alert_kind == "espn_opponent_free_kick": + team_outcome = 1 + else: + team_outcome = 0 + support_outcome = _configured_event_outcome( + snapshot, + config, + config.favorite_team, + event_team, + team_outcome, + missing="neutral", + ) + # Routine chances are not strong enough to imply a meaningful position + # change. Position language is reserved for genuinely consequential + # events so the commentary still sounds like football commentary. + if alert_kind in MAJOR_PERSPECTIVE_EVENTS: + position_outcome = _configured_event_outcome( + snapshot, + config, + config.position_team, + event_team, + team_outcome, + missing="none", + ) + else: + position_outcome = "none" + + if position_outcome == "none": + alignment = "support_only" if support_outcome != "neutral" else "neutral" + elif support_outcome == "neutral": + alignment = "position_only" + elif support_outcome == position_outcome: + alignment = "aligned" + else: + alignment = "conflict" + return EventPerspective(support_outcome, position_outcome, alignment) + + +def _configured_team_label(config: ESPNConfig, value: str, fallback: str) -> str: + return localized_team_name(config, value) if value else fallback + + +def _major_perspective_reaction( + perspective: EventPerspective, + config: ESPNConfig, + style: str, +) -> str: + support = _configured_team_label( + config, + config.favorite_team, + pick(config.language, "支持的球队", "the supported team"), + ) + position = _configured_team_label( + config, + config.position_team, + pick(config.language, "当前持仓", "the current position"), + ) + good = perspective.support_outcome == "benefit" + position_good = perspective.position_outcome == "benefit" + + if config.language == "en": + if perspective.alignment == "aligned": + return ( + f"That helps both {support} and the {position} position" + if good + else f"That hurts both {support} and the {position} position" + ) + if perspective.alignment == "conflict": + return ( + f"Good news for {support}, but the {position} position comes under pressure" + if good + else f"Tough for {support}, although the {position} position benefits" + ) + if perspective.alignment == "position_only": + return ( + f"The {position} position benefits" + if position_good + else f"The {position} position comes under pressure" + ) + if perspective.alignment == "support_only": + return f"That suits {support}" if good else f"That is a setback for {support}" + return "" + + if perspective.alignment == "aligned": + if style == "casual": + return "这一下看着舒服,仓位也跟着受益" if good else "场面难受,仓位也跟着承压" + if style == "professional": + return ( + f"这一变化有利于{support},也利好{position}持仓" + if good + else f"这一变化打击{support},{position}持仓也随之承压" + ) + return ( + f"支持的{support}占到便宜,{position}仓位也随之受益" + if good + else f"支持的{support}受到打击,{position}仓位也跟着承压" + ) + if perspective.alignment == "conflict": + if good: + return ( + "球迷这边开心,仓位却要承压" + if style == "casual" + else f"{support}占优,但{position}仓位因此承压" + ) + return ( + "感情上不好受,不过仓位倒是受益" + if style == "casual" + else f"{support}受到打击,不过{position}仓位从中受益" + ) + if perspective.alignment == "position_only": + if style == "casual": + return "这波对仓位有利" if position_good else "这波对仓位不利" + return f"这对{position}持仓有利" if position_good else f"这对{position}持仓不利" + if perspective.alignment == "support_only": + if style == "casual": + return "这下舒服了" if good else "这下有点难受" + return f"这对支持的{support}是好消息" if good else f"这对支持的{support}不是好消息" + return "" + + +def _routine_support_reaction( + alert_kind: str, + perspective: EventPerspective, + config: ESPNConfig, + style: str, +) -> str: + outcome = perspective.support_outcome + if outcome == "neutral": + return "" + support = _configured_team_label(config, config.favorite_team, "支持的球队") + failed_attack = alert_kind in { + "espn_woodwork", + "espn_shot_saved", + "espn_close_miss", + "espn_shot_blocked", + } + set_piece = alert_kind in {"espn_corner", "espn_opponent_free_kick"} + yellow_card = alert_kind == "espn_yellow_card" + foul = alert_kind == "espn_foul" + good = outcome == "benefit" + if config.language == "en": + if failed_attack: + return f"{support} survives the danger" if good else f"A chance goes begging for {support}" + if set_piece: + return f"A useful opening for {support}" if good else f"{support} must defend this carefully" + if yellow_card or foul: + return f"That helps {support}" if good else f"That is costly for {support}" + return "" + if failed_attack: + if style == "casual": + return "还好,这次守住了" if good else "可惜,这次机会没能兑现" + if style == "professional": + return f"{support}暂时化解险情" if good else f"{support}这次进攻未能转化为进球" + return f"{support}躲过一次威胁" if good else f"{support}错过一次机会" + if set_piece: + if style == "casual": + return "机会来了" if good else "这下要小心" + if style == "professional": + return f"{support}获得继续施压的机会" if good else f"{support}需要应对这次定位球" + return f"{support}迎来进攻机会" if good else f"{support}接下来要注意防守" + if yellow_card: + if style == "casual": + return "对面吃牌,对我们有利" if good else "这张牌不太划算" + return f"这张牌对{support}有利" if good else f"这张牌对{support}不利" + if foul: + if style == "casual": + return "对面这次犯规对我们有利" if good else "这次犯规不太划算" + return f"这次判罚有利于{support}" if good else f"这次判罚对{support}不利" + return "" + + def _fact_actor(facts: ESPNEventFacts, config: ESPNConfig) -> str: player = facts.primary_player_name team = facts.team_name @@ -1673,7 +2169,8 @@ def _fact_actor(facts: ESPNEventFacts, config: ESPNConfig) -> str: def _compact_player_name(config: ESPNConfig, player: MatchPlayer | None) -> str: if player is None or not config.announce_player_names: return "" - configured = _configured_player_text(config.player_names, player) + profile = _resolved_player_profile(config, player) + configured = profile.display_name if profile else "" if config.language == "en": candidates = [ value.split()[-1] @@ -1852,17 +2349,29 @@ def _shot_attempt_text(facts: ESPNEventFacts, config: ESPNConfig) -> str: return f"{area}{attempt}" if area or attempt else "" -def _delivery_text(facts: ESPNEventFacts, config: ESPNConfig) -> str: +def _delivery_text( + facts: ESPNEventFacts, + config: ESPNConfig, + *, + assisted_goal: bool = True, +) -> str: if not facts.assistant_name: return "" assistant_name = facts.assistant_name if config.language == "en" and facts.assistant is not None: assistant_name = localized_player_name(config, facts.assistant) - descriptions = { - "cross": pick(config.language, "传中助攻", "supplies the assist with a cross"), - "through_ball": pick(config.language, "直塞助攻", "supplies the assist with a through ball"), - "pass": pick(config.language, "送出助攻", "supplies the assist"), - } + if assisted_goal: + descriptions = { + "cross": pick(config.language, "传中助攻", "supplies the assist with a cross"), + "through_ball": pick(config.language, "直塞助攻", "supplies the assist with a through ball"), + "pass": pick(config.language, "送出助攻", "supplies the assist"), + } + else: + descriptions = { + "cross": pick(config.language, "送出传中", "supplies the cross"), + "through_ball": pick(config.language, "送出直塞", "supplies the through ball"), + "pass": pick(config.language, "送出传球", "supplies the pass"), + } detail = descriptions.get(facts.delivery) if not detail: return "" @@ -1924,7 +2433,7 @@ def _status_speech(status_code: str, config: ESPNConfig, casual: bool) -> str: professional = { "kickoff": "比赛正式开球", "halftime": "裁判吹响半场结束哨", - "second_half": "下半场比赛开始", + "second_half": "下半场开始", "end_regular": "常规时间结束", "end_extra": "加时赛结束", "shootout_start": "点球大战开始", @@ -1944,6 +2453,250 @@ def _status_speech(status_code: str, config: ESPNConfig, casual: bool) -> str: return (friendly if casual else professional).get(status_code, "") +def _contextual_score_speech(base: Alert, facts: ESPNEventFacts) -> str: + score = facts.score_speech + if not score: + return "" + if base.kind == "espn_status": + if facts.status_code in {"full_time", "shootout_end"}: + if "点球比分" in score: + return f"最终,{score}" + if "领先" in score: + return score.replace("领先", "战胜", 1) + if "打成" in score: + teams, _, scoreline = score.partition("打成") + return f"{teams.replace('和', '与', 1)}{scoreline}战平" + return f"最终比分,{score}" + if facts.status_code in {"halftime", "second_half"}: + return f"半场比分,{score}" + return score + if base.kind == "espn_goal" or ( + base.kind == "espn_penalty" and facts.result == "scored" + ): + return score + if base.kind in {"espn_drinks_break", "espn_drinks_break_end"}: + return f"当前比分,{score}" + return f"比分仍是{score}" + + +def _chinese_espn_speech( + base: Alert, + facts: ESPNEventFacts, + config: ESPNConfig, + style: str, + perspective: EventPerspective, +) -> str: + """Build natural Chinese from parsed facts, never from ESPN prose.""" + + clock = _spoken_clock(facts, config) + team = facts.awarded_team_name or facts.team_name or "场上球队" + player = facts.primary_player_name + if base.kind in { + "espn_red_card", + "espn_yellow_card", + "espn_foul", + "espn_substitution", + "espn_woodwork", + "espn_shot_saved", + "espn_close_miss", + "espn_shot_blocked", + } or (base.kind == "espn_penalty" and facts.result != "scored"): + # Nicknames are for successful/highlight moments. Failed attacks, + # disciplinary events, and personnel changes stay on the formal name. + player = localized_player_name(config, facts.primary_player) + actor = player or team or "场上球员" + keeper = facts.goalkeeper_name or "门将" + incoming = ( + localized_player_name(config, facts.incoming) + if base.kind == "espn_substitution" + else facts.incoming_name + ) or "替补球员" + outgoing = ( + localized_player_name(config, facts.outgoing) + if base.kind == "espn_substitution" + else facts.outgoing_name + ) or "场上球员" + attempt = _shot_attempt_text(facts, config) or "攻门" + located_areas = { + "six_yard_box", + "centre_of_box", + "left_of_box", + "right_of_box", + "outside_box", + "far_post", + } + attempt_actor = f"{actor}{'在' if facts.shot_area in located_areas else ''}{attempt}" + + def open_play_delivery() -> str: + if style == "professional": + return "" + delivery = _delivery_text(facts, config, assisted_goal=False) + if not delivery: + return "" + return f"{delivery}," + + def team_action(action: str) -> str: + if not player: + return f"{team}{action}" + if style == "professional": + return f"{team}球员{player}{action}" + return f"{team}这边,{player}{action}" + + core = base.speech or "" + extra_after_score = "" + if base.kind == "espn_goal": + if player: + if facts.is_equalizer: + action = "扳平比分" + elif style == "casual" and perspective.support_outcome != "harm": + action = "破门啦" + elif style == "professional": + action = "完成破门" + else: + action = "打进一球" + core = f"{clock}{player}为{team}{action}" + else: + core = f"{clock}{team}{'扳平比分' if facts.is_equalizer else '取得进球'}" + chant = render_star_chant(config, facts.primary_player, facts.team_name) + if ( + chant + and config.announce_player_names + and style == "casual" + and perspective.support_outcome == "benefit" + and player in chant + ): + chant = chant if chant.endswith(("。", "!", "?", "!", "?")) else f"{chant}!" + goal_is_explicit = any( + phrase in chant for phrase in ("进球", "打进", "破门", "球进", "命中") + ) + if not goal_is_explicit: + core = join_sentences("zh", f"{clock}{chant}", f"{team}进球了") + else: + core = f"{clock}{chant}" + elif base.kind == "espn_penalty": + if facts.result == "scored": + core = ( + f"{clock}{player}为{team}主罚点球并命中" + if player + else f"{clock}{team}点球命中" + ) + elif facts.result == "saved": + core = ( + f"{clock}{player}为{team}主罚的点球被{keeper}扑出" + if player + else f"{clock}{team}的点球被{keeper}扑出" + ) + else: + core = ( + f"{clock}{player}为{team}主罚点球未能命中" + if player + else f"{clock}{team}主罚点球未能命中" + ) + elif base.kind == "espn_penalty_awarded": + core = f"{clock}裁判判罚点球,{team}获得主罚机会" + if facts.beneficiary_name: + core += f",{facts.beneficiary_name}在禁区内造点" + elif base.kind == "espn_substitution": + verb = "换人" if style != "professional" else "完成换人调整" + core = f"{clock}{team}{verb},{incoming}登场,换下{outgoing}" + if facts.injury_reason: + extra_after_score = f"{outgoing}因伤被换下" + elif base.kind == "espn_woodwork": + core = f"{clock}{team}这次进攻,{open_play_delivery()}{attempt_actor}击中门框" + elif base.kind == "espn_shot_saved": + core = f"{clock}{team}这次进攻,{open_play_delivery()}{attempt_actor},被{keeper}扑出" + elif base.kind == "espn_close_miss": + result = "擦着门边偏出" if facts.close_miss else "偏出球门" + core = f"{clock}{team}这次进攻,{open_play_delivery()}{attempt_actor}{result}" + elif base.kind == "espn_shot_blocked": + core = f"{clock}{team}这次进攻,{open_play_delivery()}{attempt_actor}被防守球员封堵" + elif base.kind == "espn_red_card": + if player: + result = "两黄变一红" if facts.result == "second_yellow" else "吃到红牌" + core = f"{clock}{player}{result},{team}被罚下一人" + else: + core = f"{clock}{team}有球员被红牌罚下" + elif base.kind == "espn_yellow_card": + if style == "professional": + core = ( + f"{clock}裁判向{team}球员{player}出示黄牌警告" + if player + else f"{clock}裁判向{team}一名球员出示黄牌" + ) + else: + core = f"{clock}{team_action('吃到黄牌')}" + elif base.kind == "espn_corner": + core = f"{clock}{team}{'赢得' if style == 'casual' else '获得'}一个角球" + elif base.kind == "espn_opponent_free_kick": + locations = { + "attacking_half": "前场", + "left_wing": "左路", + "right_wing": "右路", + "defensive_half": "后场", + } + location = locations.get(facts.set_piece_location, "") + core = f"{clock}{team}赢得{location}任意球" + if facts.beneficiary_name: + core += f",{facts.beneficiary_name}造到犯规" + elif base.kind == "espn_foul": + core = f"{clock}{team_action('犯规,裁判鸣哨')}" + elif base.kind == "espn_drinks_break": + core = "比赛进入补水时间,先歇口气" + elif base.kind == "espn_drinks_break_end": + core = "补水结束,比赛继续" + elif base.kind == "espn_status": + core = _status_speech(facts.status_code, config, casual=style == "casual") or core + + parts = [core, _contextual_score_speech(base, facts)] + if style == "professional": + assist = _delivery_text( + facts, + config, + assisted_goal=base.kind == "espn_goal", + ) + direction = _direction_text(facts, config) + set_piece = _set_piece_context_text(facts, config) + if base.kind == "espn_goal": + finish: list[str] = [] + if assist: + finish.append(assist) + if facts.primary_player_name and _shot_attempt_text(facts, config): + finish.append( + f"{facts.primary_player_name}{'在' if facts.shot_area else ''}{_shot_attempt_text(facts, config)}" + ) + if direction: + finish.append(f"皮球进入{direction}") + if finish: + parts.append(",".join(finish)) + if set_piece: + parts.append(set_piece) + elif base.kind in { + "espn_woodwork", + "espn_shot_saved", + "espn_close_miss", + "espn_shot_blocked", + }: + detail: list[str] = [] + if assist: + detail.append(assist) + if direction: + detail.append(f"射门攻向{direction}") + if detail: + parts.append(",".join(detail)) + if set_piece: + parts.append(set_piece) + if extra_after_score: + parts.append(extra_after_score) + + if base.kind in MAJOR_PERSPECTIVE_EVENTS: + reaction = _major_perspective_reaction(perspective, config, style) + else: + reaction = _routine_support_reaction(base.kind, perspective, config, style) + if reaction: + parts.append(reaction) + return join_sentences(config.language, *parts) + + def _casual_espn_speech(base: Alert, facts: ESPNEventFacts, config: ESPNConfig) -> str: clock = _spoken_clock(facts, config) actor = _fact_actor(facts, config) @@ -2050,7 +2803,11 @@ def _professional_espn_speech(base: Alert, facts: ESPNEventFacts, config: ESPNCo if config.language == "en" and facts.beneficiary is not None: beneficiary_name = localized_player_name(config, facts.beneficiary) attempt = _shot_attempt_text(facts, config) - assist = _delivery_text(facts, config) + assist = _delivery_text( + facts, + config, + assisted_goal=base.kind == "espn_goal", + ) direction = _direction_text(facts, config) set_piece_context = _set_piece_context_text(facts, config) @@ -2266,20 +3023,27 @@ def render_espn_alert( alert: Alert, facts: ESPNEventFacts, config: ESPNConfig, + snapshot: MatchSnapshot, ) -> Alert: """Apply the selected voice template while preserving alert behavior.""" style = normalize_commentary_style(config.commentary_style) + perspective = event_perspective(snapshot, config, facts, alert.kind) alert.balloon = _compact_espn_balloon(alert, facts, config) - if style == "casual": + if config.language == "zh": + alert.speech = _chinese_espn_speech(alert, facts, config, style, perspective) + elif style == "casual": alert.speech = _casual_espn_speech(alert, facts, config) elif style == "professional": alert.speech = _professional_espn_speech(alert, facts, config) - # Every template carries the same score fact. Existing balanced wording is - # retained verbatim before the missing score sentence is appended. + # English keeps the legacy templates and gets a score appended when needed. + # Chinese already renders a context-aware score (lead/still level/final), + # so appending the raw live-score phrase would duplicate or contradict it. speech = alert.speech or "" if ( + config.language == "en" + and facts.score_speech and facts.score_speech.casefold() not in speech.casefold() ): @@ -2287,6 +3051,14 @@ def render_espn_alert( spoken_clock = _spoken_clock(facts, config) if spoken_clock and not (alert.speech or "").startswith(spoken_clock): alert.speech = f"{spoken_clock}{alert.speech or ''}" + if config.language == "en": + reaction = ( + _major_perspective_reaction(perspective, config, style) + if alert.kind in MAJOR_PERSPECTIVE_EVENTS + else _routine_support_reaction(alert.kind, perspective, config, style) + ) + if reaction: + alert.speech = join_sentences(config.language, alert.speech or "", reaction) return alert @@ -2954,9 +3726,14 @@ def alert_for_espn_commentary( snapshot: MatchSnapshot, config: ESPNConfig, ) -> Alert | None: - facts = parse_espn_event_facts(item, snapshot, config) - alert = _balanced_alert_for_espn_commentary(item, snapshot, config) - return render_espn_alert(alert, facts, config) if alert is not None else None + event_snapshot = snapshot_at_commentary_score(item, snapshot) + facts = parse_espn_event_facts(item, event_snapshot, config) + alert = _balanced_alert_for_espn_commentary(item, event_snapshot, config) + return ( + render_espn_alert(alert, facts, config, event_snapshot) + if alert is not None + else None + ) def _status_change_alert(snapshot: MatchSnapshot, config: ESPNConfig) -> Alert | None: @@ -2999,7 +3776,7 @@ def _status_change_alert(snapshot: MatchSnapshot, config: ESPNConfig) -> Alert | "play": {"type": {"type": "full-time" if snapshot.status_state == "post" else "kickoff"}}, } facts = parse_espn_event_facts(synthetic_item, snapshot, config) - return render_espn_alert(alert, facts, config) + return render_espn_alert(alert, facts, config, snapshot) def _alert_with_source( @@ -3113,36 +3890,58 @@ def speech_for_price_move(snapshot: MarketSnapshot, config: MarketConfig, delta: if config.language == "en": direction = "up" if delta > 0 else "down" intensity = " sharply" if abs(delta) >= config.speak_move_cents else "" + position_sentence = "" + if config.tracks_position: + position_sentence = ( + " This move benefits the current position." + if delta > 0 + else " This move puts the current position under pressure." + ) if style == "casual": return ( f"Heads up—{snapshot.label} {side_text(config.side_i_care)} just moved " f"{direction}{intensity} by {english_quantity(abs(delta), 'cent')} " - f"to {english_quantity(mid, 'cent')}." + f"to {english_quantity(mid, 'cent')}.{position_sentence}" ) if style == "professional": + move_direction = "upward" if delta > 0 else "downward" + move_intensity = "sharp " if abs(delta) >= config.speak_move_cents else "" return ( f"{snapshot.label} {side_text(config.side_i_care)} midpoint is now " - f"{english_quantity(mid, 'cent')}, a {direction}{intensity} move of " + f"{english_quantity(mid, 'cent')}, a {move_intensity}{move_direction} move of " f"{english_quantity(abs(delta), 'cent')} from the previous alert baseline." + f"{position_sentence}" ) return ( f"{snapshot.label} {side_text(config.side_i_care)} midpoint is " f"{english_quantity(mid, 'cent')}, {direction}{intensity} by " f"{english_quantity(abs(delta), 'cent')} since the last alert." + f"{position_sentence}" ) direction = "涨了" if delta > 0 else "跌了" intensity = "大幅" if abs(delta) >= config.speak_move_cents else "" + position_impact = "" + if config.tracks_position: + position_impact = "这波对持仓有利" if delta > 0 else "这波让持仓承压" + position_suffix = f"{position_impact}。" if position_impact else "" if style == "casual": return ( - f"家人们,{snapshot.label} {side_text(config.side_i_care)} 盘口{intensity}{direction}" - f"{abs(delta)}分,现在是{mid}分。" + f"提醒一下,{snapshot.label} {side_text(config.side_i_care)} " + f"刚刚{intensity}{direction}{abs(delta)}分,来到{mid}分。{position_suffix}" ) if style == "professional": + position_sentence = "" + if config.tracks_position: + position_sentence = "当前持仓受益。" if delta > 0 else "当前持仓承压。" return ( - f"{snapshot.label} {side_text(config.side_i_care)} 中间价现报{mid}分," - f"较上次告警基线{intensity}{direction}{abs(delta)}分。" + f"{snapshot.label} {side_text(config.side_i_care)} 中间价" + f"{'升' if delta > 0 else '降'}至{mid}分,较上次变动{abs(delta)}分。" + f"{position_sentence}" ) - return f"{snapshot.label} {side_text(config.side_i_care)} 中间价{mid}分,比上次{intensity}{direction}{abs(delta)}分。" + return ( + f"{snapshot.label} {side_text(config.side_i_care)} 现为{mid}分," + f"较上次{intensity}{direction}{abs(delta)}分。{position_suffix}" + ) def speech_for_market_goal_signal( @@ -3164,6 +3963,72 @@ def speech_for_market_goal_signal( snapshot.label if rising else pick(config.language, "对手方", "the opposing side") ) + def add_view_context(speech: str) -> str: + known_goal_teams = { + name.casefold() + for name in (config.goal_signal_up_team, config.goal_signal_down_team) + if name + } + support_known = bool( + config.favorite_team + and config.favorite_team.casefold() in known_goal_teams + ) + support_good = bool( + support_known + and config.favorite_team.casefold() == goal_team.casefold() + ) + position_known = bool(config.tracks_position and config.position_team) + position_good = rising + if config.language == "en": + if support_known and position_known: + if support_good == position_good: + note = ( + "If confirmed, both the supported team and the position benefit" + if support_good + else "If confirmed, both the supported team and the position suffer" + ) + else: + note = ( + "If confirmed, that helps the supported team but hurts the position" + if support_good + else "If confirmed, that hurts the supported team but helps the position" + ) + elif support_known: + note = ( + "If confirmed, that helps the supported team" + if support_good + else "If confirmed, that hurts the supported team" + ) + elif position_known: + note = ( + "If confirmed, the current position benefits" + if position_good + else "If confirmed, the current position comes under pressure" + ) + else: + note = "" + else: + 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: lower = speech.casefold() @@ -3223,42 +4088,44 @@ def clause_before(source: str, index: int) -> str: safe = f"盘口突变,{goal_team}疑似进球,等待文字直播确认。" confirmation_suffix = "仍需等待文字直播确认。" if not uncertain or has_unqualified_claim(): - return safe + return add_view_context(safe) if awaiting: - return speech - return join_sentences(config.language, speech, confirmation_suffix) + return add_view_context(speech) + return add_view_context(join_sentences(config.language, speech, confirmation_suffix)) if style == "balanced" and configured: return ensure_uncertainty(configured) if config.language == "en": direction = "jumped" if rising else "dropped" if style == "casual": - return ( + return add_view_context( f"Heads up—{snapshot.label} just {direction} {abs(rapid_delta)} cents " f"to {mid}. Possible goal, but hold the celebration; awaiting commentary confirmation." f" The signal points to {goal_team}." ) if style == "professional": - return ( + return add_view_context( f"{snapshot.label} shows a rapid {'upward' if rising else 'downward'} move of " f"{abs(rapid_delta)} cents to {mid}. This is only a possible goal signal; " f"it points to {goal_team}, awaiting commentary confirmation." ) - return ( + return add_view_context( f"The market just {direction} sharply. Possible goal for {goal_team}; " "awaiting commentary confirmation." ) if style == "casual": - return ( - f"家人们,{snapshot.label}盘口突然{'拉升' if rising else '跳水'}{abs(rapid_delta)}分," - f"现在{mid}分。{goal_team}疑似进球,先别急着庆祝,等待文字直播确认。" + return add_view_context( + f"先别急,{snapshot.label}盘口突然{'拉升' if rising else '跳水'}{abs(rapid_delta)}分," + f"来到{mid}分。{goal_team}进球的可能性更大,不过目前还只是疑似," + "要等文字直播确认。" ) if style == "professional": - return ( + return add_view_context( f"{snapshot.label}盘口快速{'上行' if rising else '下挫'}{abs(rapid_delta)}分至{mid}分。" - f"信号指向{goal_team}疑似进球,等待文字直播确认。" + f"{goal_team}进球的可能性上升,但目前仍属疑似," + "等待文字直播确认。" ) - return ( + return add_view_context( f"盘口突然{'拉升' if rising else '跳水'}," f"{goal_team}疑似进球,等待文字直播确认。" ) @@ -4521,6 +5388,7 @@ def run_watch(args: argparse.Namespace) -> int: espn_state.last_polled_at = cycle_monotonic try: match = fetch_espn_match(config.espn) + report_player_catalog_coverage(match, config.espn, espn_state) if setup_service: style_after_fetch = setup_service.take_commentary_style_update() if style_after_fetch: diff --git a/tools/stackchan_match_setup.py b/tools/stackchan_match_setup.py index 60213cc..d9ca267 100644 --- a/tools/stackchan_match_setup.py +++ b/tools/stackchan_match_setup.py @@ -541,13 +541,37 @@ def apply_selection(self, payload: dict[str, Any]) -> dict[str, Any]: ordered_teams = [match["home"], match["away"]] ordered_markets = [market_by_team[normalize_team(team["name"])] for team in ordered_teams] - valid_teams = {str(team["name"]) for team in ordered_teams} - favorite_team = str(payload.get("favorite_team") or "").strip() - position_team = str(payload.get("position_team") or "").strip() - if favorite_team and favorite_team not in valid_teams: - raise ValueError("支持球队不属于这场比赛") - if position_team and position_team not in valid_teams: - raise ValueError("持仓球队不属于这场比赛") + team_by_normalized_name = { + normalize_team(str(team["name"])): str(team["name"]) + for team in ordered_teams + } + + def canonical_selected_team(field: str, error: str) -> str: + selected = str(payload.get(field) or "").strip() + if not selected: + return "" + canonical = team_by_normalized_name.get(normalize_team(selected)) + if canonical is None: + raise ValueError(error) + return canonical + + # The phone form uses the Kalshi outcome label, while ESPN occasionally + # uses an equivalent country name (for example Cape Verde/Cabo Verde). + # Persist the canonical ESPN name after the same normalization that + # already validated the fixture pairing. + favorite_team = canonical_selected_team( + "favorite_team", "支持球队不属于这场比赛" + ) + position_team = canonical_selected_team( + "position_team", "持仓球队不属于这场比赛" + ) + active_market_index = 0 + if position_team: + active_market_index = next( + index + for index, team in enumerate(ordered_teams) + if str(team["name"]) == position_team + ) raw = json.loads(self.config_path.read_text(encoding="utf-8")) if not commentary_style: @@ -604,6 +628,8 @@ def apply_selection(self, payload: dict[str, Any]) -> dict[str, Any]: configured_markets: list[dict[str, Any]] = [] for index, (team, market) in enumerate(zip(ordered_teams, ordered_markets)): + market_is_active = index == active_market_index + opposing_index = 1 - index configured = dict(market_defaults) configured.update( { @@ -613,41 +639,44 @@ def apply_selection(self, payload: dict[str, Any]) -> dict[str, Any]: f"{team['name']} to advance", ), "side_i_care": "yes", - "alerts_enabled": index == 0, - "show_in_ticker": index == 0, + "alerts_enabled": market_is_active, + "show_in_ticker": market_is_active, + "tracks_position": bool(position_team and market_is_active), } ) - if index == 0: + if market_is_active: configured.update( { "goal_signal_enabled": True, "goal_signal_up_speech": localized_pair( ( - f"盘口突然拉升!{localized[0]}这边很可能进球了!" - "先别眨眼,等文字直播确认!" + f"{localized[index]}晋级盘口快速上行。" + f"{localized[index]}进球的可能性上升,但目前仍属疑似," + "等待文字直播确认。" ), ( - f"The market just jumped! {english[0]} may have scored. " - "Waiting for commentary confirmation!" + f"The {english[index]} advancement market moved sharply higher. " + f"Possible goal for {english[index]}; awaiting commentary confirmation." ), ), "goal_signal_down_speech": localized_pair( ( - f"盘口突然跳水!{localized[1]}可能进球了," - "先看场上,等文字直播确认。" + f"{localized[index]}晋级盘口快速下挫。" + f"{localized[opposing_index]}进球的可能性上升,但目前仍属疑似," + "等待文字直播确认。" ), ( - f"The market just dropped! {english[1]} may have scored. " - "Waiting for commentary confirmation." + f"The {english[index]} advancement market moved sharply lower. " + f"Possible goal for {english[opposing_index]}; awaiting commentary confirmation." ), ), "goal_signal_up_team": localized_pair( - localized[0], - english[0], + localized[index], + english[index], ), "goal_signal_down_team": localized_pair( - localized[1], - english[1], + localized[opposing_index], + english[opposing_index], ), } ) @@ -748,6 +777,9 @@ def traded_volume(market: dict[str, Any]) -> float: "alerts_enabled": True, "show_in_ticker": True, "goal_signal_enabled": False, + "favorite_team": "", + "position_team": "", + "tracks_position": False, } ) configured_markets.append(configured) @@ -757,6 +789,8 @@ def traded_volume(market: dict[str, Any]) -> float: espn = raw.setdefault("espn", {}) espn["enabled"] = False espn["commentary_style"] = commentary_style + espn["favorite_team"] = "" + espn["position_team"] = "" setup = raw.setdefault("setup_server", {}) setup["last_kalshi_url"] = kalshi_value diff --git a/tools/stackchan_player_catalog.py b/tools/stackchan_player_catalog.py new file mode 100644 index 0000000..6b709ca --- /dev/null +++ b/tools/stackchan_player_catalog.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Deterministic global player names and nicknames for ESPN match events. + +The catalog is deliberately independent from a single match configuration. ESPN +athlete IDs are the stable primary key; normalized aliases are only a conservative +fallback for feeds or archived samples that omit the ID. A duplicate alias is +considered ambiguous and never resolves to an arbitrary player. + +Existing ``espn.player_names`` and ``espn.star_chants`` mappings remain supported +as per-match overlays through :func:`resolve_player_profile`. Overlay values win +over catalog values, while an unknown player is returned with empty text so the +watcher can keep its existing raw-name fallback. +""" + +from __future__ import annotations + +import json +import re +import unicodedata +from collections import defaultdict +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Any + + +SCHEMA_VERSION = 1 +SUPPORTED_LANGUAGES = ("zh", "en") +DEFAULT_CATALOG_PATH = ( + Path(__file__).resolve().parents[1] / "config" / "espn_player_catalog.json" +) + +_CATALOG_KEY_RE = re.compile(r"^espn:([0-9]+)$") +_SPACE_RE = re.compile(r"\s+") +_DOT_OR_COMMA_RE = re.compile(r"[.,,。]+") +_APOSTROPHE_SPACE_RE = re.compile(r"\s*'\s*") +_HYPHEN_SPACE_RE = re.compile(r"\s*-\s*") +_PUNCTUATION_TRANSLATION = str.maketrans( + { + "\u2018": "'", # left single quotation mark + "\u2019": "'", # right single quotation mark + "\u201b": "'", # single high-reversed-9 quotation mark + "\u02bc": "'", # modifier letter apostrophe + "\uff07": "'", # full-width apostrophe + "`": "'", + "\u2010": "-", # hyphen + "\u2011": "-", # non-breaking hyphen + "\u2012": "-", # figure dash + "\u2013": "-", # en dash + "\u2014": "-", # em dash + "\u2212": "-", # minus sign + "\ufe63": "-", # small hyphen-minus + "\uff0d": "-", # full-width hyphen-minus + } +) +_LATIN_FOLD_TRANSLATION = str.maketrans( + { + "æ": "ae", + "ð": "d", + "đ": "d", + "ı": "i", + "ł": "l", + "ø": "o", + "œ": "oe", + "þ": "th", + "ß": "ss", + } +) + + +class PlayerCatalogError(ValueError): + """Raised when a player catalog does not satisfy the schema.""" + + +def normalize_player_alias(value: Any) -> str: + """Return a stable lookup form for a player name or ESPN ID. + + Matching is case-insensitive and tolerates Unicode compatibility forms, + diacritics, curly apostrophes, typographic hyphens, and spacing around those + characters. Periods and commas are treated as spaces so ESPN short names + such as ``L. Yamal`` also match ``L Yamal``. + """ + + text = unicodedata.normalize("NFKC", str(value or "")).translate( + _PUNCTUATION_TRANSLATION + ) + text = unicodedata.normalize("NFKD", text.casefold()) + text = "".join( + character for character in text if not unicodedata.combining(character) + ) + text = text.translate(_LATIN_FOLD_TRANSLATION) + text = _DOT_OR_COMMA_RE.sub(" ", text) + text = _APOSTROPHE_SPACE_RE.sub("'", text) + text = _HYPHEN_SPACE_RE.sub("-", text) + return _SPACE_RE.sub(" ", text).strip() + + +def _language(value: str) -> str: + normalized = str(value or "zh").strip().lower().replace("_", "-") + if normalized.startswith("zh"): + return "zh" + if normalized.startswith("en"): + return "en" + raise PlayerCatalogError( + f"language must be one of: {', '.join(SUPPORTED_LANGUAGES)}" + ) + + +def _localized_field( + value: Any, + *, + path: str, + require_both: bool, +) -> Mapping[str, str]: + if not isinstance(value, dict): + raise PlayerCatalogError(f"{path} must be an object with zh/en text") + unknown = set(value) - set(SUPPORTED_LANGUAGES) + if unknown: + raise PlayerCatalogError( + f"{path} has unsupported language keys: " + + ", ".join(sorted(map(str, unknown))) + ) + if require_both: + missing = set(SUPPORTED_LANGUAGES) - set(value) + if missing: + raise PlayerCatalogError( + f"{path} is missing: {', '.join(sorted(missing))}" + ) + resolved: dict[str, str] = {} + for language, raw_text in value.items(): + if not isinstance(raw_text, str) or not raw_text.strip(): + raise PlayerCatalogError(f"{path}.{language} must be a non-empty string") + resolved[language] = raw_text.strip() + if not resolved: + raise PlayerCatalogError(f"{path} must contain at least one localized value") + return MappingProxyType(resolved) + + +def _optional_localized_field(value: Any, *, path: str) -> Mapping[str, str]: + if value is None: + return MappingProxyType({}) + return _localized_field(value, path=path, require_both=False) + + +@dataclass(frozen=True) +class PlayerCatalogEntry: + """One global player record keyed by ``espn:``.""" + + key: str + athlete_id: str + aliases: tuple[str, ...] + display_names: Mapping[str, str] + casual_names: Mapping[str, str] + featured: bool + goal_chants: Mapping[str, str] + + def display_name(self, language: str = "zh") -> str: + return self.display_names[_language(language)] + + def casual_name(self, language: str = "zh") -> str: + selected = _language(language) + return self.casual_names.get(selected, "") or self.display_name(selected) + + def goal_chant(self, language: str = "zh") -> str: + return self.goal_chants.get(_language(language), "") + + +@dataclass(frozen=True) +class ResolvedPlayerProfile: + """Catalog values after applying the legacy per-match configuration overlay.""" + + catalog_key: str | None + display_name: str + casual_name: str + featured: bool + goal_chant: str + source: str + + +class PlayerCatalog: + """Validated player records with ambiguity-safe ID and alias lookup.""" + + def __init__(self, entries: Mapping[str, PlayerCatalogEntry]): + self._entries = MappingProxyType(dict(entries)) + alias_index: defaultdict[str, set[str]] = defaultdict(set) + for key, entry in self._entries.items(): + match_values = ( + *entry.aliases, + *entry.display_names.values(), + ) + for value in match_values: + normalized = normalize_player_alias(value) + if normalized: + alias_index[normalized].add(key) + self._alias_index = MappingProxyType( + {alias: frozenset(keys) for alias, keys in alias_index.items()} + ) + + @property + def entries(self) -> Mapping[str, PlayerCatalogEntry]: + return self._entries + + def get(self, key: str) -> PlayerCatalogEntry | None: + normalized_key = _catalog_key(key) + return self._entries.get(normalized_key) if normalized_key else None + + def resolve_alias(self, alias: str) -> PlayerCatalogEntry | None: + keys = self._alias_index.get(normalize_player_alias(alias), frozenset()) + if len(keys) != 1: + return None + return self._entries[next(iter(keys))] + + def resolve( + self, + *, + athlete_id: str = "", + name: str = "", + short_name: str = "", + ) -> PlayerCatalogEntry | None: + """Resolve by stable ESPN ID, otherwise by one unambiguous alias. + + A known ID is authoritative. If it is absent or unknown, every supplied + name must point to the same single record; conflicting or duplicate aliases + return ``None`` rather than guessing. + """ + + raw_athlete_id = str(athlete_id or "").strip() + if raw_athlete_id: + key = _catalog_key(raw_athlete_id) + return self._entries.get(key) if key else None + + candidate_groups: list[frozenset[str]] = [] + for value in (name, short_name): + normalized = normalize_player_alias(value) + if not normalized: + continue + keys = self._alias_index.get(normalized, frozenset()) + if keys: + candidate_groups.append(keys) + if not candidate_groups: + return None + candidates = set(candidate_groups[0]) + for group in candidate_groups[1:]: + candidates.intersection_update(group) + if len(candidates) != 1: + return None + return self._entries[next(iter(candidates))] + + @classmethod + def from_dict(cls, payload: Any) -> "PlayerCatalog": + if not isinstance(payload, dict): + raise PlayerCatalogError("player catalog root must be an object") + unknown = set(payload) - {"schema_version", "players"} + if unknown: + raise PlayerCatalogError( + "player catalog has unsupported keys: " + + ", ".join(sorted(map(str, unknown))) + ) + if payload.get("schema_version") != SCHEMA_VERSION: + raise PlayerCatalogError(f"schema_version must be {SCHEMA_VERSION}") + raw_players = payload.get("players") + if not isinstance(raw_players, dict): + raise PlayerCatalogError("players must be an object") + + entries: dict[str, PlayerCatalogEntry] = {} + for raw_key, raw_entry in raw_players.items(): + key = str(raw_key) + match = _CATALOG_KEY_RE.fullmatch(key) + if match is None: + raise PlayerCatalogError( + f"players[{key!r}] must use an espn: key" + ) + if not isinstance(raw_entry, dict): + raise PlayerCatalogError(f"players[{key!r}] must be an object") + path = f"players[{key!r}]" + allowed = { + "aliases", + "display_name", + "casual_name", + "featured", + "goal_chant", + } + entry_unknown = set(raw_entry) - allowed + if entry_unknown: + raise PlayerCatalogError( + f"{path} has unsupported keys: " + + ", ".join(sorted(map(str, entry_unknown))) + ) + + aliases_raw = raw_entry.get("aliases", []) + if not isinstance(aliases_raw, list) or any( + not isinstance(alias, str) or not alias.strip() + for alias in aliases_raw + ): + raise PlayerCatalogError(f"{path}.aliases must be a list of strings") + aliases = tuple(dict.fromkeys(alias.strip() for alias in aliases_raw)) + display_names = _localized_field( + raw_entry.get("display_name"), + path=f"{path}.display_name", + require_both=True, + ) + casual_names = _optional_localized_field( + raw_entry.get("casual_name"), path=f"{path}.casual_name" + ) + goal_chants = _optional_localized_field( + raw_entry.get("goal_chant"), path=f"{path}.goal_chant" + ) + featured = raw_entry.get("featured", False) + if not isinstance(featured, bool): + raise PlayerCatalogError(f"{path}.featured must be a boolean") + entries[key] = PlayerCatalogEntry( + key=key, + athlete_id=match.group(1), + aliases=aliases, + display_names=display_names, + casual_names=casual_names, + featured=featured, + goal_chants=goal_chants, + ) + return cls(entries) + + @classmethod + def load(cls, path: str | Path) -> "PlayerCatalog": + source = Path(path) + try: + payload = json.loads(source.read_text(encoding="utf-8")) + except OSError as exc: + raise PlayerCatalogError(f"cannot read player catalog {source}: {exc}") from exc + except json.JSONDecodeError as exc: + raise PlayerCatalogError( + f"invalid JSON in player catalog {source}: {exc}" + ) from exc + return cls.from_dict(payload) + + +def _catalog_key(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + key = raw.casefold() if raw.casefold().startswith("espn:") else f"espn:{raw}" + return key if _CATALOG_KEY_RE.fullmatch(key) else "" + + +def _overlay_text(value: Any, language: str, *, path: str) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if not isinstance(value, Mapping): + raise PlayerCatalogError(f"{path} must be a string or zh/en object") + selected = _language(language) + raw = value.get(selected) + if raw is None: + return "" + if not isinstance(raw, str): + raise PlayerCatalogError(f"{path}.{selected} must be a string") + return raw.strip() + + +def legacy_overlay_value( + mapping: Mapping[str, Any] | None, + *, + athlete_id: str = "", + name: str = "", + short_name: str = "", + language: str = "zh", +) -> str: + """Read one normalized legacy mapping value without ambiguous first-match wins. + + An ID-keyed override (``espn:123`` or ``123``) wins over name keys. If + multiple raw keys normalize to the same candidate but contain conflicting + values, the override is ignored. + """ + + if not mapping: + return "" + normalized: defaultdict[str, set[str]] = defaultdict(set) + for raw_key, raw_value in mapping.items(): + key = normalize_player_alias(raw_key) + value = _overlay_text( + raw_value, language, path=f"legacy overlay[{raw_key!r}]" + ) + if key and value: + normalized[key].add(value) + + raw_id = str(athlete_id or "").strip() + if raw_id: + if raw_id.casefold().startswith("espn:"): + id_keys = (raw_id, raw_id.split(":", 1)[1]) + else: + id_keys = (f"espn:{raw_id}", raw_id) + for candidate in id_keys: + values = normalized.get(normalize_player_alias(candidate), set()) + if len(values) == 1: + return next(iter(values)) + if len(values) > 1: + return "" + + groups: list[set[str]] = [] + for candidate in (name, short_name): + values = normalized.get(normalize_player_alias(candidate), set()) + if values: + groups.append(values) + if not groups: + return "" + values = set(groups[0]) + for group in groups[1:]: + values.intersection_update(group) + return next(iter(values)) if len(values) == 1 else "" + + +def resolve_player_profile( + catalog: PlayerCatalog, + *, + athlete_id: str = "", + name: str = "", + short_name: str = "", + language: str = "zh", + player_names: Mapping[str, Any] | None = None, + star_chants: Mapping[str, Any] | None = None, +) -> ResolvedPlayerProfile: + """Resolve catalog text and apply legacy ``player_names``/``star_chants``. + + The returned empty ``display_name`` for ``source == 'unknown'`` is + intentional: callers own the policy for raw names, jersey prefixes, and other + fallbacks. + """ + + selected_language = _language(language) + entry = catalog.resolve( + athlete_id=athlete_id, + name=name, + short_name=short_name, + ) + legacy_name = legacy_overlay_value( + player_names, + athlete_id=athlete_id, + name=name, + short_name=short_name, + language=selected_language, + ) + legacy_chant = legacy_overlay_value( + star_chants, + athlete_id=athlete_id, + name=name, + short_name=short_name, + language=selected_language, + ) + + display_name = legacy_name or ( + entry.display_name(selected_language) if entry is not None else "" + ) + catalog_casual_name = ( + entry.casual_names.get(selected_language, "") if entry is not None else "" + ) + casual_name = catalog_casual_name or display_name + goal_chant = legacy_chant or ( + entry.goal_chant(selected_language) if entry is not None else "" + ) + has_legacy = bool(legacy_name or legacy_chant) + if entry is not None and has_legacy: + source = "catalog+legacy" + elif entry is not None: + source = "catalog" + elif has_legacy: + source = "legacy" + else: + source = "unknown" + return ResolvedPlayerProfile( + catalog_key=entry.key if entry is not None else None, + display_name=display_name, + casual_name=casual_name, + featured=(entry.featured if entry is not None else False) or bool(goal_chant), + goal_chant=goal_chant, + source=source, + ) + + +def load_default_player_catalog() -> PlayerCatalog: + return PlayerCatalog.load(DEFAULT_CATALOG_PATH) diff --git a/tools/test_stackchan_kalshi_watch.py b/tools/test_stackchan_kalshi_watch.py index 0c1d519..870c0f3 100644 --- a/tools/test_stackchan_kalshi_watch.py +++ b/tools/test_stackchan_kalshi_watch.py @@ -7,6 +7,7 @@ import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout +from dataclasses import replace from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import patch @@ -276,6 +277,34 @@ def test_old_goal_signal_speeches_migrate_exact_team_facts(self): self.assertEqual(config.markets[0].goal_signal_up_team, "法国") self.assertEqual(config.markets[0].goal_signal_down_team, "摩洛哥") + def test_market_position_context_is_inferred_only_for_matching_team(self): + raw = self.localized_config() + raw["language"] = "zh" + raw["espn"].update( + { + "favorite_team": "Morocco", + "position_team": "France", + } + ) + raw["markets"][0].update( + { + "goal_signal_up_team": {"zh": "法国", "en": "France"}, + "goal_signal_down_team": {"zh": "摩洛哥", "en": "Morocco"}, + } + ) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "config.json" + path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + matching = watcher.load_config(path) + raw["espn"]["position_team"] = "Morocco" + path.write_text(json.dumps(raw, ensure_ascii=False), encoding="utf-8") + not_matching = watcher.load_config(path) + + self.assertEqual(matching.markets[0].favorite_team, "摩洛哥") + self.assertEqual(matching.markets[0].position_team, "法国") + self.assertTrue(matching.markets[0].tracks_position) + self.assertFalse(not_matching.markets[0].tracks_position) + class ESPNAlertTests(unittest.TestCase): def test_backed_team_win_gets_result_voice_motion_and_team_light(self): @@ -545,9 +574,9 @@ def test_new_france_goal_is_happy_and_localized(self): self.assertIn("法国", alerts[0].balloon) self.assertIn("姆巴佩进球", alerts[0].balloon) self.assertIn("FRA 1-0 MAR", alerts[0].balloon) - self.assertIn("姆巴佩!姆巴佩!打进去了!", alerts[0].speech) + self.assertIn("姆巴佩为法国打进一球", alerts[0].speech) - def test_non_star_goal_uses_jersey_number_and_name(self): + def test_non_star_goal_uses_name_without_roster_style_prefix(self): player = watcher.MatchPlayer("999", "Jean Prospect", "J. Prospect", "24", "France", "FRA") goal = { "sequence": 80, @@ -568,7 +597,84 @@ def test_non_star_goal_uses_jersey_number_and_name(self): self.assertIsNotNone(alert) self.assertIn("Prospect", alert.balloon) - self.assertIn("24号球员Jean Prospect!球进啦!", alert.speech) + self.assertIn("Jean Prospect为法国打进一球", alert.speech) + self.assertNotIn("号球员", alert.speech) + + def test_catalog_nickname_is_casual_only_and_balloon_stays_formal(self): + yamal = watcher.MatchPlayer( + "362150", "Lamine Yamal", "L. Yamal", "19", "France", "FRA" + ) + goal = { + "time": {"displayValue": "32'"}, + "text": "Goal! France. Lamine Yamal scores.", + "play": { + "type": {"type": "goal"}, + "team": {"displayName": "France"}, + "participants": [ + {"athlete": {"id": "362150", "displayName": "Lamine Yamal"}} + ], + }, + } + snapshot = match_snapshot([goal], players={"362150": yamal}) + + for style, expected_name, excluded_name in ( + ("casual", "小孩哥", "亚马尔为法国"), + ("balanced", "亚马尔", "小孩哥"), + ("professional", "亚马尔", "小孩哥"), + ): + config = espn_config() + config.commentary_style = style + config.player_names = {} + config.star_chants = {} + alert = watcher.alert_for_espn_commentary(goal, snapshot, config) + with self.subTest(style=style): + self.assertIn(expected_name, alert.speech) + self.assertNotIn(excluded_name, alert.speech) + self.assertIn("亚马尔", alert.balloon) + self.assertNotIn("小孩哥", alert.balloon) + + red_card = { + **goal, + "text": "Lamine Yamal (France) is shown the red card.", + "play": { + **goal["play"], + "type": {"type": "red-card"}, + }, + } + config = espn_config() + config.commentary_style = "casual" + config.player_names = {} + config.star_chants = {} + card_alert = watcher.alert_for_espn_commentary(red_card, snapshot, config) + self.assertIn("亚马尔", card_alert.speech) + self.assertNotIn("小孩哥", card_alert.speech) + + def test_player_catalog_coverage_counts_profiles_and_raw_fallbacks(self): + yamal = watcher.MatchPlayer( + "362150", "Lamine Yamal", "L. Yamal", "19", "France", "FRA" + ) + unknown = watcher.MatchPlayer( + "999999", "New Prospect", "N. Prospect", "24", "Morocco", "MAR" + ) + snapshot = match_snapshot( + players={ + "362150": yamal, + "lamine yamal": yamal, + "999999": unknown, + "new prospect": unknown, + } + ) + config = espn_config() + config.player_names = {} + config.star_chants = {} + + total, matched, featured, fallback, signature = watcher.player_catalog_coverage( + snapshot, config + ) + + self.assertEqual((total, matched, featured), (2, 1, 1)) + self.assertEqual(fallback, ("New Prospect",)) + self.assertEqual(signature, ("362150", "999999")) def test_roster_parser_indexes_player_id_and_names(self): payload = { @@ -665,7 +771,7 @@ def test_close_range_header_names_shooter_assistant_and_goalkeeper(self): self.assertEqual(alert.priority, 820) self.assertIn("于帕梅卡诺", alert.speech) self.assertIn("登贝莱送出传中", alert.speech) - self.assertIn("布努神扑", alert.speech) + self.assertIn("布努扑出", alert.speech) self.assertTrue(alert.prefer_dynamic_voice) self.assertIsNone(alert.clip_id) @@ -809,8 +915,9 @@ def test_only_opponent_free_kick_is_announced(self): self.assertEqual([alert.kind for alert in alerts], ["espn_opponent_free_kick"]) self.assertEqual(alerts[0].priority, 740) - self.assertIn("摩洛哥获得右路任意球", alerts[0].speech) - self.assertIn("8号球员Azzedine Ounahi", alerts[0].speech) + self.assertIn("摩洛哥赢得右路任意球", alerts[0].speech) + self.assertIn("Azzedine Ounahi造到犯规", alerts[0].speech) + self.assertNotIn("号球员", alerts[0].speech) def test_penalty_drawn_from_foul_commentary_is_announced(self): mbappe = watcher.MatchPlayer("231388", "Kylian Mbappé", "K. Mbappé", "10", "France", "FRA") @@ -836,7 +943,7 @@ def test_penalty_drawn_from_foul_commentary_is_announced(self): self.assertIsNotNone(alert) self.assertEqual(alert.kind, "espn_penalty_awarded") - self.assertIn("姆巴佩在禁区里制造犯规", alert.speech) + self.assertIn("姆巴佩在禁区内造点", alert.speech) def test_injury_substitution_names_player_on_and_player_off(self): mateta = watcher.MatchPlayer( @@ -871,18 +978,19 @@ def test_injury_substitution_names_player_on_and_player_off(self): self.assertIsNotNone(alert) self.assertEqual(alert.kind, "espn_substitution") self.assertEqual(alert.priority, 620) - self.assertIn("15号球员Jean-Philippe Mateta上场", alert.speech) - self.assertIn("姆巴佩被换下", alert.speech) - self.assertIn("姆巴佩可能有伤", alert.speech) + self.assertIn("Jean-Philippe Mateta登场", alert.speech) + self.assertIn("换下姆巴佩", alert.speech) + self.assertNotIn("号球员", alert.speech) + self.assertIn("姆巴佩因伤被换下", alert.speech) class CommentaryStyleTests(unittest.TestCase): def _players(self): mbappe = watcher.MatchPlayer( - "1", "Kylian Mbappé", "K. Mbappé", "10", "France", "FRA" + "231388", "Kylian Mbappé", "K. Mbappé", "10", "France", "FRA" ) dembele = watcher.MatchPlayer( - "2", "Ousmane Dembélé", "O. Dembélé", "11", "France", "FRA" + "229744", "Ousmane Dembélé", "O. Dembélé", "11", "France", "FRA" ) bounou = watcher.MatchPlayer( "3", "Yassine Bounou", "Y. Bounou", "1", "Morocco", "MAR", "G" @@ -906,7 +1014,7 @@ def test_core_facts_are_present_for_representative_events_in_all_styles(self): "participants": [{"athlete": {"displayName": "Kylian Mbappé"}}], }, }, - ("姆巴佩", "进"), + ("姆巴佩",), ("法国", "姆巴佩", "进球"), "in", ), @@ -1002,11 +1110,21 @@ def test_core_facts_are_present_for_representative_events_in_all_styles(self): with self.subTest(style=style, event=name): self.assertIsNotNone(alert) self.assertTrue(alert.speech.startswith("第")) - self.assertIn("现在法国1比0摩洛哥", alert.speech) + expected_score = ( + "法国1比0战胜摩洛哥" + if name == "final" + else "法国1比0领先摩洛哥" + ) + self.assertIn(expected_score, alert.speech) self.assertIn("FRA 1-0 MAR", alert.balloon) self.assertTrue(alert.balloon.startswith(item["time"]["displayValue"])) for term in expected_terms: - self.assertIn(term, alert.speech) + if term == "姆巴佩" and style == "casual": + self.assertTrue( + "姆巴佩" in alert.speech or "姆总" in alert.speech + ) + else: + self.assertIn(term, alert.speech) for term in balloon_terms: self.assertIn(term, alert.balloon) @@ -1049,6 +1167,464 @@ def test_style_changes_only_text_not_goal_behavior(self): self.assertEqual(len(set(balloons)), 1) self.assertEqual(len(set(speeches)), 3) + def test_goal_perspective_distinguishes_alignment_conflict_and_no_position(self): + item = { + "time": {"displayValue": "32'"}, + "text": "Goal! France. Kylian Mbappé scores.", + "play": { + "type": {"type": "goal"}, + "team": {"displayName": "France"}, + "participants": [{"athlete": {"displayName": "Kylian Mbappé"}}], + }, + } + snapshot = match_snapshot([item], players=self._players()) + cases = ( + ("France", "France", ("benefit", "benefit", "aligned")), + ("France", "Morocco", ("benefit", "harm", "conflict")), + ("Morocco", "France", ("harm", "benefit", "conflict")), + ("France", "", ("benefit", "none", "support_only")), + ("", "France", ("neutral", "benefit", "position_only")), + ) + + for favorite, position, expected in cases: + config = espn_config() + config.favorite_team = favorite + config.position_team = position + facts = watcher.parse_espn_event_facts(item, snapshot, config) + perspective = watcher.event_perspective(snapshot, config, facts, "espn_goal") + with self.subTest(favorite=favorite, position=position): + self.assertEqual( + ( + perspective.support_outcome, + perspective.position_outcome, + perspective.alignment, + ), + expected, + ) + + def test_final_score_uses_result_language_instead_of_live_lead_language(self): + item = { + "time": {"displayValue": "90+5'"}, + "text": "Match ends, France 1, Morocco 0.", + "play": {"type": {"type": "full-time"}}, + } + draw_item = { + **item, + "text": "Match ends, France 1, Morocco 1.", + } + config = espn_config() + + winner = watcher.alert_for_espn_commentary( + item, + match_snapshot([item], status="post", home_score="1", away_score="0"), + config, + ) + draw = watcher.alert_for_espn_commentary( + draw_item, + match_snapshot( + [draw_item], status="post", home_score="1", away_score="1" + ), + config, + ) + + self.assertIn("法国1比0战胜摩洛哥", winner.speech) + self.assertNotIn("领先摩洛哥", winner.speech) + self.assertIn("法国与摩洛哥1比1战平", draw.speech) + + def test_same_poll_goals_keep_each_rows_score_in_all_styles(self): + first = { + "sequence": 1, + "time": {"displayValue": "18'"}, + "text": "Goal! France 1, Morocco 0. Kylian Mbappé scores.", + "play": { + "type": {"type": "goal"}, + "team": {"displayName": "France"}, + "participants": [ + { + "athlete": { + "id": "231388", + "displayName": "Kylian Mbappé", + } + } + ], + }, + } + second = { + "sequence": 2, + "time": {"displayValue": "24'"}, + "text": "Goal! France 1, Morocco 1. Hakim Ziyech scores.", + "play": { + "type": {"type": "goal"}, + "team": {"displayName": "Morocco"}, + "participants": [ + {"athlete": {"displayName": "Hakim Ziyech"}} + ], + }, + } + + for style in ("casual", "balanced", "professional"): + config = espn_config() + config.commentary_style = style + alerts = watcher.evaluate_espn_match( + match_snapshot( + [first, second], + home_score="1", + away_score="1", + players=self._players(), + ), + config, + watcher.ESPNState(initialized=True, last_status_state="in"), + ) + + with self.subTest(language="zh", style=style): + self.assertEqual(len(alerts), 2) + self.assertIn("法国1比0领先摩洛哥", alerts[0].speech) + self.assertIn("FRA 1-0 MAR", alerts[0].balloon) + self.assertNotIn("扳平", alerts[0].speech) + self.assertNotIn("1比1", alerts[0].speech) + self.assertIn("法国和摩洛哥打成1比1", alerts[1].speech) + self.assertIn("FRA 1-1 MAR", alerts[1].balloon) + self.assertIn("扳平", alerts[1].speech) + self.assertNotIn("1比0", alerts[1].speech) + + for style in ("casual", "balanced", "professional"): + config = english_espn_config() + config.commentary_style = style + alerts = watcher.evaluate_espn_match( + match_snapshot( + [first, second], + home_score="1", + away_score="1", + players=self._players(), + ), + config, + watcher.ESPNState(initialized=True, last_status_state="in"), + ) + + with self.subTest(language="en", style=style): + self.assertEqual(len(alerts), 2) + self.assertIn("It is France 1, Morocco 0", alerts[0].speech) + self.assertNotIn("It is France 1, Morocco 1", alerts[0].speech) + self.assertIn("It is France 1, Morocco 1", alerts[1].speech) + self.assertNotIn("It is France 1, Morocco 0", alerts[1].speech) + if style == "balanced": + self.assertEqual(alerts[0].speech.count("It is France"), 1) + self.assertEqual(alerts[1].speech.count("It is France"), 1) + + def test_historical_goal_drops_later_shootout_score(self): + goal = { + "sequence": 1, + "time": {"displayValue": "18'"}, + "text": "Goal! France 1, Morocco 0. Kylian Mbappé scores.", + "play": { + "type": {"type": "goal"}, + "team": {"displayName": "France"}, + "participants": [ + { + "athlete": { + "id": "231388", + "displayName": "Kylian Mbappé", + } + } + ], + }, + } + snapshot = match_snapshot( + [goal], + status="post", + home_score="1", + away_score="1", + players=self._players(), + ) + snapshot.home.shootout_score = "4" + snapshot.away.shootout_score = "3" + + alert = watcher.alert_for_espn_commentary(goal, snapshot, espn_config()) + + self.assertIn("法国1比0领先摩洛哥", alert.speech) + self.assertIn("FRA 1-0 MAR", alert.balloon) + for stale_final_fact in ("点球比分", "(4)", "(3)", "1比1"): + self.assertNotIn(stale_final_fact, alert.speech) + self.assertNotIn(stale_final_fact, alert.balloon) + + shootout_end = { + "sequence": 2, + "time": {"displayValue": "120'"}, + "text": "Penalty shootout ends. France win on penalties.", + "play": {"type": {"type": "full-time"}}, + } + shootout_alert = watcher.alert_for_espn_commentary( + shootout_end, snapshot, espn_config() + ) + + self.assertIn("法国和摩洛哥比赛战成1比1", shootout_alert.speech) + self.assertIn("点球比分4比3", shootout_alert.speech) + self.assertNotIn("常规时间", shootout_alert.speech) + + def test_parenthesized_espn_shootout_score_is_kept_per_kick(self): + snapshot = match_snapshot([], status="in", home_score="1", away_score="1") + snapshot.home.shootout_score = "3" + snapshot.away.shootout_score = "4" + rows = ( + ( + "Goal! France 1, Morocco 1(1). Penalty scored.", + "FRA 1(0)-1(1) MAR", + "点球比分0比1", + ), + ( + "Goal! France 1(1), Morocco 1(2). Penalty scored.", + "FRA 1(1)-1(2) MAR", + "点球比分1比2", + ), + ) + + for sequence, (text, compact_score, spoken_score) in enumerate(rows, start=1): + item = { + "sequence": sequence, + "time": {"displayValue": "120'"}, + "text": text, + "play": { + "type": {"type": "penalty---scored"}, + "team": {"displayName": "Morocco"}, + }, + } + alert = watcher.alert_for_espn_commentary(item, snapshot, espn_config()) + with self.subTest(sequence=sequence): + self.assertIn(compact_score, alert.balloon) + self.assertIn(spoken_score, alert.speech) + self.assertNotIn("点球比分3比4", alert.speech) + + final = { + "sequence": 3, + "text": "Match ends, France 1(3), Morocco 1(4).", + "play": {"type": {"type": "full-time"}}, + } + final_alert = watcher.alert_for_espn_commentary( + final, + replace(snapshot, status_state="post"), + espn_config(), + ) + self.assertIn("点球比分3比4", final_alert.speech) + self.assertIn("FRA 1(3)-1(4) MAR", final_alert.balloon) + + def test_opponent_events_avoid_supportive_core_language(self): + goal = { + "time": {"displayValue": "18'"}, + "text": "Goal! France 1, Morocco 0. Kylian Mbappé scores.", + "play": { + "type": {"type": "goal"}, + "team": {"displayName": "France"}, + "participants": [ + { + "athlete": { + "id": "231388", + "displayName": "Kylian Mbappé", + } + } + ], + }, + } + penalty_miss = { + "time": {"displayValue": "39'"}, + "text": "Penalty missed. Kylian Mbappé (France).", + "play": { + "type": {"type": "penalty---missed"}, + "team": {"displayName": "France"}, + "participants": [ + { + "athlete": { + "id": "231388", + "displayName": "Kylian Mbappé", + } + } + ], + }, + } + snapshot = match_snapshot( + [goal, penalty_miss], players=self._players() + ) + + for style in ("casual", "balanced", "professional"): + config = espn_config() + config.commentary_style = style + config.favorite_team = "Morocco" + config.position_team = "" + goal_alert = watcher.alert_for_espn_commentary(goal, snapshot, config) + miss_alert = watcher.alert_for_espn_commentary( + penalty_miss, snapshot, config + ) + + with self.subTest(style=style): + self.assertNotIn("破门啦", goal_alert.speech) + self.assertNotIn("可惜", miss_alert.speech) + + def test_casual_failed_attempts_use_formal_name_not_nickname(self): + yamal = watcher.MatchPlayer( + "362150", "Lamine Yamal", "L. Yamal", "19", "France", "FRA" + ) + missed_shot = { + "time": {"displayValue": "28'"}, + "text": ( + "Attempt missed. Lamine Yamal (France) left footed shot from " + "the centre of the box is close, but misses to the left." + ), + "play": { + "type": {"type": "shot-off-target"}, + "team": {"displayName": "France"}, + "participants": [ + { + "athlete": { + "id": "362150", + "displayName": "Lamine Yamal", + } + } + ], + }, + } + penalty_miss = { + "time": {"displayValue": "39'"}, + "text": "Penalty missed. Lamine Yamal (France).", + "play": { + "type": {"type": "penalty---missed"}, + "team": {"displayName": "France"}, + "participants": [ + { + "athlete": { + "id": "362150", + "displayName": "Lamine Yamal", + } + } + ], + }, + } + config = espn_config() + config.commentary_style = "casual" + config.player_names = {} + config.star_chants = {} + snapshot = match_snapshot( + [missed_shot, penalty_miss], players={"362150": yamal} + ) + + for item in (missed_shot, penalty_miss): + alert = watcher.alert_for_espn_commentary(item, snapshot, config) + with self.subTest(play_type=item["play"]["type"]["type"]): + self.assertIn("亚马尔", alert.speech) + self.assertNotIn("小孩哥", alert.speech) + + def test_professional_failed_attack_uses_delivery_not_assist(self): + config = espn_config() + config.commentary_style = "professional" + players = self._players() + cases = ( + ("with a cross", "登贝莱送出传中"), + ("with a through ball", "登贝莱送出直塞"), + ("", "登贝莱送出传球"), + ) + + for delivery, expected in cases: + suffix = f" {delivery}" if delivery else "" + item = { + "time": {"displayValue": "42'"}, + "text": ( + "Attempt saved. Kylian Mbappé (France) right footed shot " + "from the centre of the box is saved by Yassine Bounou " + f"(Morocco). Assisted by Ousmane Dembélé{suffix}." + ), + "play": { + "type": {"type": "shot-on-target"}, + "team": {"displayName": "France"}, + "participants": [ + { + "athlete": { + "id": "231388", + "displayName": "Kylian Mbappé", + } + }, + { + "athlete": { + "id": "229744", + "displayName": "Ousmane Dembélé", + } + }, + ], + }, + } + alert = watcher.alert_for_espn_commentary( + item, match_snapshot([item], players=players), config + ) + + with self.subTest(delivery=delivery or "pass"): + self.assertIn(expected, alert.speech) + self.assertNotIn("助攻", alert.speech) + + def test_professional_unknown_player_yellow_card_has_clean_subject(self): + item = { + "time": {"displayValue": "51'"}, + "text": "A France player is shown the yellow card.", + "play": { + "type": {"type": "yellow-card"}, + "team": {"displayName": "France"}, + }, + } + config = espn_config() + config.commentary_style = "professional" + + alert = watcher.alert_for_espn_commentary( + item, match_snapshot([item]), config + ) + + self.assertIn("法国一名球员", alert.speech) + self.assertNotIn("球员场上球员", alert.speech) + + def test_all_styles_voice_major_support_and_position_context_naturally(self): + item = { + "time": {"displayValue": "32'"}, + "text": "Goal! France. Kylian Mbappé scores.", + "play": { + "type": {"type": "goal"}, + "team": {"displayName": "France"}, + "participants": [{"athlete": {"displayName": "Kylian Mbappé"}}], + }, + } + snapshot = match_snapshot([item], players=self._players()) + for style in ("casual", "balanced", "professional"): + config = espn_config() + config.favorite_team = "Morocco" + config.position_team = "France" + config.commentary_style = style + speech = watcher.alert_for_espn_commentary(item, snapshot, config).speech + with self.subTest(style=style): + self.assertTrue("姆巴佩" in speech or "姆总" in speech) + for fact in ("法国", "法国1比0领先摩洛哥", "摩洛哥", "仓位"): + self.assertIn(fact, speech) + for mechanical in ("法国的姆巴佩", "现在法国", "家人们", "号球员"): + self.assertNotIn(mechanical, speech) + + def test_routine_save_uses_support_view_without_position_commentary(self): + item = { + "time": {"displayValue": "71'"}, + "text": ( + "Attempt saved. Kylian Mbappé (France) right footed shot from the " + "centre of the box is saved by Yassine Bounou (Morocco)." + ), + "play": { + "type": {"type": "shot-on-target"}, + "team": {"displayName": "France"}, + "participants": [{"athlete": {"displayName": "Kylian Mbappé"}}], + }, + } + snapshot = match_snapshot([item], players=self._players()) + for favorite, support_phrase in (("France", "法国"), ("Morocco", "摩洛哥")): + for style in ("casual", "balanced", "professional"): + config = espn_config() + config.favorite_team = favorite + config.position_team = favorite + config.commentary_style = style + speech = watcher.alert_for_espn_commentary(item, snapshot, config).speech + with self.subTest(favorite=favorite, style=style): + self.assertIn(support_phrase, speech) + self.assertNotIn("仓位", speech) + def test_professional_goal_and_save_use_only_reliably_parsed_details(self): players = self._players() goal = { @@ -1097,9 +1673,9 @@ def test_professional_goal_and_save_use_only_reliably_parsed_details(self): self.assertIn("右脚低射", save_alert.speech) self.assertIn("射门攻向球门右下角", save_alert.speech) self.assertNotIn("Assisted by", goal_alert.speech) - self.assertLess(goal_alert.speech.index("取得进球"), goal_alert.speech.index("传中助攻")) - self.assertLess(goal_alert.speech.index("现在法国1比0摩洛哥"), goal_alert.speech.index("传中助攻")) - self.assertLess(save_alert.speech.index("现在法国1比0摩洛哥"), save_alert.speech.index("射门攻向")) + self.assertLess(goal_alert.speech.index("完成破门"), goal_alert.speech.index("传中助攻")) + self.assertLess(goal_alert.speech.index("法国1比0领先摩洛哥"), goal_alert.speech.index("传中助攻")) + self.assertLess(save_alert.speech.index("法国1比0领先摩洛哥"), save_alert.speech.index("射门攻向")) self.assertGreater(len(goal_alert.speech), len(goal_alert.balloon)) def test_professional_goal_identifies_explicit_far_post_equalizer(self): @@ -1130,7 +1706,7 @@ def test_professional_goal_identifies_explicit_far_post_equalizer(self): self.assertEqual(facts.shot_area, "far_post") self.assertIn("扳平比分", alert.speech) self.assertIn("后点包抄", alert.speech) - self.assertIn("现在法国1比1摩洛哥", alert.speech) + self.assertIn("法国和摩洛哥打成1比1", alert.speech) def test_professional_ignores_unrecognized_prose_instead_of_guessing(self): item = { @@ -1195,9 +1771,9 @@ def test_professional_card_and_miss_do_not_infer_unstated_reasons_or_closeness(s self.assertIn("黄牌警告", yellow_alert.speech) self.assertNotIn("犯规", yellow_alert.speech) - self.assertIn("射偏", ordinary_alert.speech) - self.assertNotIn("稍稍偏出", ordinary_alert.speech) - self.assertIn("稍稍偏出", close_alert.speech) + self.assertIn("偏出球门", ordinary_alert.speech) + self.assertNotIn("擦着门边偏出", ordinary_alert.speech) + self.assertIn("擦着门边偏出", close_alert.speech) def test_player_name_switch_is_honored_in_styled_templates(self): item = { @@ -1246,8 +1822,8 @@ def test_professional_substitution_core_and_score_precede_injury_reason(self): config, ) - self.assertLess(alert.speech.index("完成换人"), alert.speech.index("现在法国1比0摩洛哥")) - self.assertLess(alert.speech.index("现在法国1比0摩洛哥"), alert.speech.index("受伤")) + self.assertLess(alert.speech.index("完成换人"), alert.speech.index("法国1比0领先摩洛哥")) + self.assertLess(alert.speech.index("法国1比0领先摩洛哥"), alert.speech.index("因伤")) def test_chinese_substitution_balloon_finishes_before_default_hide(self): mateta = watcher.MatchPlayer( @@ -1315,7 +1891,10 @@ def test_style_switch_is_immediate_without_replaying_seen_events(self): self.assertEqual(len(first_alerts), 1) self.assertEqual(len(second_alerts), 1) - self.assertIn("家人们", second_alerts[0].speech) + self.assertIn("姆总", second_alerts[0].speech) + self.assertIn("打进去了", second_alerts[0].speech) + self.assertIn("法国1比0领先摩洛哥", second_alerts[0].speech) + self.assertNotIn("家人们", second_alerts[0].speech) self.assertTrue(second_alerts[0].speech.startswith("第12分钟")) def test_status_fallback_uses_explicit_snapshot_clock(self): @@ -1483,7 +2062,10 @@ def test_free_kick_without_location_has_clean_spacing(self): ) self.assertEqual( alert.speech, - "Free kick to Morocco. The player draws the foul. It is France 1, Morocco 0.", + ( + "Free kick to Morocco. The player draws the foul. " + "It is France 1, Morocco 0. France must defend this carefully." + ), ) def test_representative_event_catalog_has_no_hardcoded_chinese(self): @@ -1846,12 +2428,63 @@ def test_market_price_move_uses_three_distinct_voice_styles(self): speeches[style] = watcher.speech_for_price_move(snapshot, market, 6, 76) self.assertEqual(len(set(speeches.values())), 3) - self.assertIn("家人们", speeches["casual"]) - self.assertIn("中间价现报", speeches["professional"]) + self.assertIn("提醒一下", speeches["casual"]) + self.assertNotIn("家人们", speeches["casual"]) + self.assertIn("中间价升至", speeches["professional"]) + self.assertNotIn("告警基线", speeches["professional"]) for speech in speeches.values(): self.assertIn("法国晋级", speech) self.assertIn("76", speech) self.assertIn("6", speech) + self.assertNotIn("持仓", speech) + + tracked = watcher.MarketConfig( + "FRA", + "法国晋级", + tracks_position=True, + position_team="法国", + ) + self.assertIn( + "持仓", + watcher.speech_for_price_move(snapshot, tracked, 6, 76), + ) + + def test_tracked_english_price_moves_name_position_in_all_styles(self): + snapshot = watcher.MarketSnapshot( + "FRA", + "France to advance", + "active", + "E", + 75, + 77, + 23, + 25, + 76, + "", + None, + ) + + for style in ("casual", "balanced", "professional"): + market = watcher.MarketConfig( + "FRA", + "France to advance", + language="en", + commentary_style=style, + speak_move_cents=5, + tracks_position=True, + position_team="France", + ) + rising = watcher.speech_for_price_move(snapshot, market, 6, 76) + falling = watcher.speech_for_price_move(snapshot, market, -6, 64) + + with self.subTest(style=style): + self.assertIn("position", rising.casefold()) + self.assertIn("benefit", rising.casefold()) + self.assertIn("position", falling.casefold()) + self.assertIn("pressure", falling.casefold()) + if style == "professional": + self.assertIn("a sharp upward move", rising) + self.assertNotIn("a sharply upward move", rising) def test_suspected_goal_styles_name_team_both_directions_and_keep_uncertainty(self): for style in ("casual", "balanced", "professional"): @@ -1905,6 +2538,34 @@ def test_suspected_goal_styles_name_team_both_directions_and_keep_uncertainty(se self.assertNotIn("进球了", signal.speech) self.assertIn("疑似", signal.balloon) self.assertIn("等待确认", signal.balloon) + self.assertNotIn("仓位", signal.speech) + + def test_suspected_goal_marks_support_position_conflict_conditionally(self): + snapshot = watcher.MarketSnapshot( + "FRA", "法国晋级", "active", "E", 59, 61, 39, 41, 60, "", None + ) + market = watcher.MarketConfig( + "FRA", + "法国晋级", + commentary_style="balanced", + goal_signal_up_team="法国", + goal_signal_down_team="摩洛哥", + favorite_team="摩洛哥", + position_team="法国", + tracks_position=True, + ) + + rising = watcher.speech_for_market_goal_signal(snapshot, market, True, 10, 60) + falling = watcher.speech_for_market_goal_signal(snapshot, market, False, -10, 40) + + for speech in (rising, falling): + self.assertIn("如果属实", speech) + self.assertIn("仓位", speech) + self.assertIn("确认", speech) + self.assertIn("感情上不好受", rising) + self.assertIn("仓位会受益", rising) + self.assertIn("球迷这边开心", falling) + self.assertIn("仓位却会承压", falling) def test_unsafe_english_custom_goal_claim_is_replaced_with_uncertainty(self): market = watcher.MarketConfig( diff --git a/tools/test_stackchan_match_replay.py b/tools/test_stackchan_match_replay.py index 5f10706..7498c0c 100644 --- a/tools/test_stackchan_match_replay.py +++ b/tools/test_stackchan_match_replay.py @@ -141,7 +141,7 @@ def test_default_replay_selects_goals_and_result_in_order(self): [("1", "0"), ("2", "0"), ("2", "0")], ) self.assertEqual([frame.alert.celebration for frame in frames], ["goal", "goal", "result-win"]) - self.assertIn("现在法国1比0摩洛哥", frames[0].alert.speech) + self.assertIn("法国1比0领先摩洛哥", frames[0].alert.speech) def test_english_replay_goal_and_final_are_fully_localized(self): frames = replay.build_replay_frames( @@ -159,13 +159,17 @@ def test_english_replay_goal_and_final_are_fully_localized(self): self.assertEqual( goal.speech, "At 60', Kylian Mbappé! Kylian Mbappé! He's scored! France's number 10 delivers! " - "It is France 1, Morocco 0.", + "It is France 1, Morocco 0. That helps both France and the France position.", ) self.assertFalse(goal.is_final) self.assertEqual(result.kind, "espn_status") self.assertEqual(result.balloon, "Full time | FRA 2-0 MAR") - self.assertEqual(result.speech, "Full time. It is France 2, Morocco 0.") + self.assertEqual( + result.speech, + "Full time. It is France 2, Morocco 0. " + "That helps both France and the France position.", + ) self.assertTrue(result.is_final) self.assertTrue(watcher.is_final_status_alert(result)) diff --git a/tools/test_stackchan_match_setup.py b/tools/test_stackchan_match_setup.py index e14c078..d3fe93e 100644 --- a/tools/test_stackchan_match_setup.py +++ b/tools/test_stackchan_match_setup.py @@ -199,6 +199,131 @@ def test_apply_rejects_unsupported_language_before_fetching(self): with self.assertRaisesRegex(ValueError, "language must be one of: zh, en"): instance.apply_selection({"language": "fr"}) + def test_apply_enables_position_team_market_and_goal_signal_direction(self): + initial = { + "probability_bar": {}, + "espn": {"team_names": {}, "team_colors": {}}, + "setup_server": {}, + "markets": [ + { + "ticker": "OLD", + "alert_move_cents": 5, + "goal_signal_enabled": True, + "goal_signal_up_speech": "stale up", + "goal_signal_down_speech": "stale down", + "goal_signal_up_team": "stale up team", + "goal_signal_down_team": "stale down team", + } + ], + } + match = setup.parse_scoreboard( + SCOREBOARD, + now=datetime(2026, 7, 9, 20, tzinfo=timezone.utc), + )[0] + 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", + return_value=setup.event_markets(KALSHI_EVENT), + ): + with patch.object(instance, "_espn_match", return_value=match): + instance.apply_selection( + { + "event_ticker": "KXWCADVANCE-26JUL10ESPBEL", + "espn_event_id": "760511", + "favorite_team": "Spain", + "position_team": "Belgium", + "language": "zh", + } + ) + updated = json.loads(path.read_text(encoding="utf-8")) + + spain_market, belgium_market = updated["markets"] + self.assertFalse(spain_market["alerts_enabled"]) + self.assertFalse(spain_market["show_in_ticker"]) + self.assertFalse(spain_market["tracks_position"]) + self.assertFalse(spain_market["goal_signal_enabled"]) + self.assertNotIn("goal_signal_up_speech", spain_market) + self.assertNotIn("goal_signal_down_speech", spain_market) + self.assertNotIn("goal_signal_up_team", spain_market) + self.assertNotIn("goal_signal_down_team", spain_market) + self.assertTrue(belgium_market["alerts_enabled"]) + self.assertTrue(belgium_market["show_in_ticker"]) + self.assertTrue(belgium_market["tracks_position"]) + self.assertTrue(belgium_market["goal_signal_enabled"]) + self.assertEqual( + belgium_market["goal_signal_up_team"], + {"zh": "比利时", "en": "Belgium"}, + ) + self.assertEqual( + 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"]) + + def test_apply_canonicalizes_kalshi_team_aliases_to_espn_names(self): + initial = { + "probability_bar": {}, + "espn": {"team_names": {}, "team_colors": {}}, + "setup_server": {}, + "markets": [{"ticker": "OLD"}], + } + match = { + "event_id": "alias-match", + "starts_at": "2026-07-12T19:00:00+00:00", + "home": setup.team_metadata("Cabo Verde", "CPV", "left"), + "away": setup.team_metadata("Portugal", "POR", "right"), + } + kalshi_event = { + "event": { + "event_ticker": "KXWCADVANCE-ALIAS", + "title": "Cape Verde vs Portugal", + "markets": [ + { + "ticker": "KXWCADVANCE-ALIAS-CPV", + "yes_sub_title": "Cape Verde advances", + }, + { + "ticker": "KXWCADVANCE-ALIAS-POR", + "yes_sub_title": "Portugal advances", + }, + ], + } + } + + 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", + return_value=setup.event_markets(kalshi_event), + ): + with patch.object(instance, "_espn_match", return_value=match): + result = instance.apply_selection( + { + "event_ticker": "KXWCADVANCE-ALIAS", + "espn_event_id": "alias-match", + "favorite_team": "Cape Verde", + "position_team": "Cape Verde", + "language": "zh", + } + ) + updated = json.loads(path.read_text(encoding="utf-8")) + + self.assertEqual(result["favorite_team"], "Cabo Verde") + self.assertEqual(result["position_team"], "Cabo Verde") + self.assertEqual(updated["espn"]["favorite_team"], "Cabo Verde") + self.assertEqual(updated["espn"]["position_team"], "Cabo Verde") + self.assertTrue(updated["markets"][0]["alerts_enabled"]) + self.assertTrue(updated["markets"][0]["tracks_position"]) + self.assertFalse(updated["markets"][1]["alerts_enabled"]) + def test_setup_page_contains_mobile_form_controls(self): page = setup.setup_page_html() @@ -317,9 +442,23 @@ def test_apply_market_selection_configures_ticker_only_watch(self): initial = { "ticker_enabled": False, "probability_bar": {"enabled": True, "market_ticker": "OLD"}, - "espn": {"enabled": True, "event_id": "760511"}, + "espn": { + "enabled": True, + "event_id": "760511", + "favorite_team": "Spain", + "position_team": "Belgium", + }, "setup_server": {}, - "markets": [{"ticker": "OLD", "label": "old", "alert_move_cents": 7}], + "markets": [ + { + "ticker": "OLD", + "label": "old", + "alert_move_cents": 7, + "favorite_team": "Spain", + "position_team": "Belgium", + "tracks_position": True, + } + ], } with tempfile.TemporaryDirectory() as directory: path = Path(directory) / "watch.json" @@ -356,6 +495,15 @@ def test_apply_market_selection_configures_ticker_only_watch(self): self.assertTrue(updated["ticker_enabled"]) self.assertFalse(updated["probability_bar"]["enabled"]) self.assertFalse(updated["espn"]["enabled"]) + self.assertEqual(updated["espn"]["favorite_team"], "") + self.assertEqual(updated["espn"]["position_team"], "") + self.assertTrue( + all(market["favorite_team"] == "" for market in updated["markets"]) + ) + self.assertTrue( + all(market["position_team"] == "" for market in updated["markets"]) + ) + self.assertFalse(any(market["tracks_position"] for market in updated["markets"])) self.assertEqual(updated["language"], "en") self.assertEqual(updated["setup_server"]["last_event_ticker"], "KXPRES-28") diff --git a/tools/test_stackchan_player_catalog.py b/tools/test_stackchan_player_catalog.py new file mode 100644 index 0000000..d2625f8 --- /dev/null +++ b/tools/test_stackchan_player_catalog.py @@ -0,0 +1,258 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +TOOLS = ROOT / "tools" +if str(TOOLS) not in sys.path: + sys.path.insert(0, str(TOOLS)) + +import stackchan_player_catalog as players # noqa: E402 + + +def record( + display_en: str, + display_zh: str, + *, + aliases: list[str] | None = None, + casual_zh: str = "", + chant_zh: str = "", + featured: bool = False, +) -> dict: + value = { + "aliases": aliases or [display_en], + "display_name": {"zh": display_zh, "en": display_en}, + "featured": featured, + } + if casual_zh: + value["casual_name"] = {"zh": casual_zh} + if chant_zh: + value["goal_chant"] = {"zh": chant_zh} + return value + + +class PlayerAliasNormalizationTests(unittest.TestCase): + def test_unicode_accents_apostrophes_and_hyphens_normalize(self): + self.assertEqual( + players.normalize_player_alias(" Ousmane Dembélé "), + players.normalize_player_alias("OUSMANE DEMBELE"), + ) + self.assertEqual( + players.normalize_player_alias("N’Golo Kanté"), + players.normalize_player_alias("N'Golo Kante"), + ) + self.assertEqual( + players.normalize_player_alias("Jean‑Pierre"), + players.normalize_player_alias("Jean-Pierre"), + ) + self.assertEqual( + players.normalize_player_alias("L. Yamal"), + players.normalize_player_alias("L Yamal"), + ) + + +class PlayerCatalogTests(unittest.TestCase): + def catalog(self) -> players.PlayerCatalog: + return players.PlayerCatalog.from_dict( + { + "schema_version": 1, + "players": { + "espn:1": record( + "Ousmane Dembélé", + "登贝莱", + aliases=["Ousmane Dembélé", "O. Dembélé"], + casual_zh="登子", + chant_zh="{name}打进去了!", + featured=True, + ), + "espn:2": record("Alex Smith", "亚历克斯·史密斯"), + "espn:3": record("Alex Smith", "另一位亚历克斯·史密斯"), + }, + } + ) + + def test_stable_id_is_authoritative(self): + entry = self.catalog().resolve( + athlete_id="1", name="Alex Smith", short_name="A. Smith" + ) + self.assertIsNotNone(entry) + self.assertEqual(entry.key, "espn:1") + + def test_unknown_nonempty_id_does_not_fall_back_to_matching_alias(self): + entry = self.catalog().resolve( + athlete_id="999", + name="Ousmane Dembele", + short_name="O. Dembele", + ) + + self.assertIsNone(entry) + + def test_unique_normalized_alias_resolves_without_id(self): + entry = self.catalog().resolve(name="Ousmane Dembele", short_name="O Dembele") + self.assertIsNotNone(entry) + self.assertEqual(entry.athlete_id, "1") + + def test_ambiguous_alias_does_not_guess(self): + catalog = self.catalog() + self.assertIsNone(catalog.resolve(name="Alex Smith")) + self.assertIsNone(catalog.resolve_alias("Alex Smith")) + self.assertEqual(catalog.resolve(athlete_id="2").key, "espn:2") + + def test_schema_requires_numeric_espn_key_and_bilingual_display_name(self): + with self.assertRaisesRegex(players.PlayerCatalogError, "espn: