werewolftown_agent / REFACTOR_HANDBOOK.md
gabiwzx's picture
refactor: 五层架构重构 M1-M8 完成
1c66cf8
|
Raw
History Blame Contribute Delete
89.5 kB
# 交易系统重构手册(Refactor Handbook)
> **定位**:这不是一次性重构方案,而是**长期操作手册**。任何未来的代码改动(bug 修复 / 加新策略 / 调参 / 换模型 / 新对手模式)都应该沿本手册操作,产出符合规范的代码 + 自测 + 部署 + 观察闭环。
>
> **读者**:你自己、本地 agent、任何未来接手的人。
>
> **配套文档**
> - `20260713_strategy_inventory.md` — 现存 136 条策略的资产台账
> - `谁是卧底-大厂操盘手竞赛规则.md` — 游戏规则(唯一权威)
> - `config/strategy_<profile>.yaml` — 所有可调参数(本手册产出的目录)
>
> **版本约定**:本手册自身也用 semver:本次为 `v1.0.0`。修改架构规则算 major,加分类/规范算 minor,勘误算 patch。改本手册必须同时改 `CHANGELOG` 段。
---
## 目录
- [第 1 章 — 使用方式](#第-1-章--使用方式)
- [第 2 章 — 目标架构:五层与数据流](#第-2-章--目标架构五层与数据流)
- [第 3 章 — 每层职责边界(Rules of Layer)](#第-3-章--每层职责边界rules-of-layer)
- [第 4 章 — 变更分类决策树](#第-4-章--变更分类决策树)
- [第 5 章 — 各类变更的操作规范](#第-5-章--各类变更的操作规范)
- [5.1 加一条新策略](#51-加一条新策略)
- [5.2 修改已有策略](#52-修改已有策略)
- [5.3 调整数值参数](#53-调整数值参数)
- [5.4 修 bug(正确性问题)](#54-修-bug正确性问题)
- [5.5 加一个 Feature(衍生指标)](#55-加一个-feature衍生指标)
- [5.6 加一个新的 Event 类型](#56-加一个新的-event-类型)
- [5.7 修改 LLM 参与方式](#57-修改-llm-参与方式)
- [5.8 修改基建 / 状态层](#58-修改基建--状态层)
- [第 6 章 — 通用编码规范](#第-6-章--通用编码规范)
- [第 7 章 — 自测规范](#第-7-章--自测规范)
- [第 8 章 — 部署规范](#第-8-章--部署规范)
- [第 9 章 — 观察规范](#第-9-章--观察规范)
- [第 10 章 — 常见坑与冲突协议](#第-10-章--常见坑与冲突协议)
- [第 11 章 — 术语与命名约定](#第-11-章--术语与命名约定)
- [第 12 章 — PR 变更 Checklist(贴在每次 PR 里)](#第-12-章--pr-变更-checklist贴在每次-pr-里)
- [CHANGELOG](#changelog)
---
## 第 1 章 — 使用方式
### 1.1 什么时候读本手册
**必读**
- 拿到"加一个策略""改一个策略""改一个参数""修一个 bug"的任何需求时
- 部署一版新代码之前
- 部署之后要看"改动到底生效了没"时
- 出现线上事故要回滚时
**建议读**
- 加入项目后,第 2、3 章一次读通
- 每次改本手册后(跟随 CHANGELOG)
### 1.2 怎么用
**标准流程(黄金四步)**
```
需求 ──► 第 4 章分类决策树,判定属于 5.1–5.8 哪一类
──► 打开对应小节,照规范改
──► 走第 7 章自测(unit + feature + replay 三层)
──► 走第 8 章部署(canary → 观察 → 全量或回滚)
```
**不要跳步**:手册里每一条规范都是有历史事故垫底的(大部分能追溯到 `STRATEGY_V*.md` 里一次具体的翻车)。跳步 = 重复历史事故。
### 1.3 手册与代码的关系
- **代码是执行体,手册是契约**。冲突时以手册为准,代码要改
- 除非改的是 bug 修正,否则不允许"代码这么写因为历史原因"这类理由
- 手册规定"必须"的事情,本地 agent 必须做;"建议"的事情可以延后但要 TODO 标注
### 1.4 兼容原则(重要)
> 本地代码持续被 agent 修改,本手册可能落后于代码状态。**手册规定的是"结构和流程",不是"具体行数"**
- 手册里的目录结构和文件名是**契约**,代码里必须存在
- 手册里的**行号从不精确引用**——只用"某函数""某模块"等语义定位
- 新加的策略不需要改手册;**新的策略类型**(比如"元策略")才需要
- 遇到手册说的东西代码里找不到,第一动作:向用户/上游确认是"代码欠改"还是"手册需更新"
---
## 第 2 章 — 目标架构:五层与数据流
### 2.1 五层总图
```
┌─────────────────────────────────────────────────────────────────┐
│ L5. Transport 层 (app.py + agent_shell.py) │
│ HTTP 入口 / 竞速网关 / FastAPI 端点 │
├─────────────────────────────────────────────────────────────────┤
│ L4. State 层 (state/) │
│ 唯一 Source of Truth (SoT) │
│ - GameState: dataclass, 不可变 │
│ - Reducer: (state, event) → new_state 纯函数, event sourcing│
│ - PendingLedger / Replay 持久化 │
├─────────────────────────────────────────────────────────────────┤
│ L3. Feature 层 (features/) │
│ 唯一负责"数值计算"的地方 │
│ - cluster_gain, opp_needs, card_value, pool_left… │
│ - 全是纯函数, 输入 GameState, 输出 dataclass, 100% 可测试 │
├─────────────────────────────────────────────────────────────────┤
│ L2. Strategy 层 (strategies/) │
│ 唯一负责"决策"的地方 │
│ - class Strategy: match(ctx)/act(ctx) 协议 │
│ - 每个策略一个文件, ≤120 行, 单一职责 │
│ - StrategyRouter 按 priority 串起来 │
├─────────────────────────────────────────────────────────────────┤
│ L1. LLM Adapter 层 (llm/) │
│ 唯一调用外部模型的地方 │
│ - LLMArbitrator.pick(): 从多个 HIT/UNSURE 里选一个策略 │
│ - SpeechGen.render(): 生成话术 message │
│ - VisionParser.parse(): 解析图片棋盘 │
└─────────────────────────────────────────────────────────────────┘
```
**核心不变量(Invariants)**
1. **上层依赖下层,下层永远不知道上层**。L2 引用 L3、L4;L3 引用 L4;L4 不引用任何上层
2. **L5 只做 IO**;L4 只做状态管理;L3 只做计算;L2 只做决策;L1 只做外部调用
3. **State 只在 perceive 时变更**;interact 期间 State 冻结(快照)
4. **每一层可独立替换**:换 LLM provider 只改 L1;换定价公式只改 L3;加策略只加 L2
### 2.2 目录结构(契约)
```
werewolftown/
├── __init__.py
├── agent.py # WerewolfTownLLMAgent 主类 (L5)
├── transport/ # L5
│ ├── endpoints.py # FastAPI 端点定义
│ ├── race_gate.py # 并发竞速网关(async 版)
│ └── debug_views.py # /debug/* HTML 视图
├── state/ # L4
│ ├── game_state.py # GameState dataclass
│ ├── events.py # Event 类型 + 枚举
│ ├── reducer.py # 纯函数 (state, event) → state
│ ├── pending_ledger.py # pending/sold/cancelled 统一账本
│ └── persistence.py # replay 落盘 + 恢复
├── features/ # L3
│ ├── clusters.py # 集群 / 边际增益 / 满额分析
│ ├── card_value.py # card_value(state, card, purpose)
│ ├── plot_value.py # plot_value(state, plot, viewer)
│ ├── opponents.py # opp_needs / leader / afk / strong
│ ├── pool.py # pool_left / scarcity
│ └── pricing.py # snap_price / dead_zone / lambda
├── strategies/ # L2
│ ├── base.py # Strategy Protocol + DecisionContext + MatchResult
│ ├── router.py # StrategyRouter
│ ├── select_plots/ # 选地策略族
│ │ ├── enumerate_combos.py
│ │ └── ...
│ ├── plan_build/ # 建设策略族
│ │ └── greedy_plus_local_search.py
│ ├── propose_trade/ # 12+ 个发起策略
│ │ ├── _order.py # priority 声明
│ │ ├── r4_leader_skip.py
│ │ ├── buy_card_for_full_cluster.py
│ │ ├── buy_adjacent_plot.py
│ │ ├── buy_plot_combo.py
│ │ ├── ...
│ │ └── skip.py
│ └── evaluate_trade/ # 7+ 个评估策略
│ ├── _order.py
│ ├── multi_deal_pick_best.py
│ ├── reject_no_empty.py
│ └── ...
├── llm/ # L1
│ ├── client.py # OpenAI 兼容客户端
│ ├── arbitrator.py # 从多候选选一个
│ ├── speech.py # 话术生成
│ └── vision.py # 视觉解析
├── constants/ # 静态数据
│ ├── board_grid.py # 52 工位邻接表(不可变)
│ ├── shop_rules.py # FULL_COUNTS / CARD_POOL / REVENUE_TABLE
│ └── phases.py # GamePhase 枚举
├── sanitize.py # 玩家名/message/JSON 过滤
config/
├── strategy_stable.yaml # 生产参数(默认)
├── strategy_canary.yaml # 灰度参数
└── strategy_dev.yaml # 本地实验
replays/
└── <game_id>/
├── events.jsonl # perceive 事件流
├── decisions.jsonl # interact 决策日志
└── meta.json # 对局元信息
tests/
├── unit/ # 纯函数单元测试
├── features/ # Feature 层测试
├── strategies/ # 策略层测试
└── replay/ # 完整对局回放测试
```
### 2.3 数据流总图
```
perceive(req) interact(req)
│ │
▼ ▼
┌─────────────┐ ┌───────────────┐
│ L5 shell │ │ L5 shell │
│ - dedupe │ │ - race_gate │
│ - persist │ │ - snapshot │
└──────┬──────┘ └───────┬───────┘
│ Event │ ctx
▼ │
┌─────────────┐ │
│ L4 reducer │ │
│ 纯函数 │ │
│ state < │◄─── (state, event) → new_state ──┤
│ append │ │
└──────┬──────┘ │
│ new_state │
▼ ▼
┌────────────────────────────────────────────────┐
│ L3 features (纯函数, on demand) │
│ 从 state 派生 cluster / opp_needs / pricing… │
└────────────────────┬───────────────────────────┘
│ DecisionContext (frozen)
┌─────────────┐
│ L2 router │ 按 priority 遍历策略
│ │ ┌── all MISS → default
│ strategy │──┤── 1 HIT → strategy.act()
│ match/act │ └── multi UNSURE → LLM 仲裁
└──────┬──────┘
│ Action
┌────────────┐
│ L1 speech │ 只填 message
│ (可选) │
└──────┬─────┘
│ AgentResp
HTTP response
```
**关键性质**
- perceive 路径**只写 state**,不做决策
- interact 路径**只读 state**,state 保持冻结
- Feature 层是**惰性计算**——DecisionContext 只暴露 property/method,策略用到才算
- 一次 interact 内,同一个 ctx 被所有策略共享,保证一致性视图
---
## 第 3 章 — 每层职责边界(Rules of Layer)
> 每层都有 **DO / DON'T / 判断标准**。违反标准的 PR 直接打回。
---
### 3.1 L5 Transport 层
**DO**
- 处理 HTTP 请求解析、响应封装
- 并发竞速(同一 cache_key 只跑一次真实逻辑,其它请求 await future)
- Replay 事件流落盘(perceive 事件一到就 append)
- 装配子系统:从 config 装载 State/Feature/Strategy/LLM,注入依赖
- 提供 `/debug/*` HTML 视图
**DON'T**
- 做任何决策计算(去 L2)
- 直接读写 memory 变量(去 L4)
- 调 LLM(去 L1)
**判断标准**
- 如果这个文件里出现"if round == X" 或 "if my_cash > Y" → 违规
- 如果这个文件里 import 了 L2/L1,是合法的(装配);但不能反过来
- 单文件应 ≤ 200 行
---
### 3.2 L4 State 层
**DO**
- 定义 `GameState` dataclass(不可变,`frozen=True` 或用 pyrsistent)
- 定义 `Event` 类型枚举 + 每个事件的字段
- 实现 `reduce(state: GameState, event: Event) -> GameState` **纯函数**
- 实现 `PendingLedger`(合并旧 `pending_proposals` / `recent_sold_plots` / `sold_this_round` / `ft_cancel_*`
- 实现 `persistence.py``save_event(game_id, event)` / `load_events(game_id) -> list[Event]` / `rebuild_state(events) -> GameState`
- 提供 `apply(event)` 便捷方法:包装 reducer + 落盘
**DON'T**
- 引用任何 L3/L2/L1 模块
- 有任何"策略""建议""定价"字样的函数
- 直接调用 `random``time.time()``os.getenv()`(这些是副作用)
- 允许"允许 memory 被外部改写"的接口(如 `set_variable`
**判断标准**
- 每个函数必须能这样测试:`assert reduce(state_before, event) == state_after`
- state 一定是 dataclass,字段命名明确(`cash: int``plots: frozenset[int]`
- 不允许 `int``str` 混用作为 key
- 服务器重启后 `rebuild_state(load_events(game_id))` 必须能 100% 还原
**关键契约**
- `GameState` 必须包含:`player_id``round``cash``plots``shops``built_on: dict[int, str]``other_players: dict[str, PlayerView]``pending: PendingLedger``initial_cash`
- `GameState.replace(**kwargs)` 返回新实例,不改原实例
- 所有集合用 `frozenset` / `tuple`,杜绝共享可变引用
---
### 3.3 L3 Feature 层
**DO**
- 纯函数:输入 `GameState` + 参数,输出 dataclass 或原始类型
- 计算集群 / 满额进度 / 边际增益 / 稀缺度 / 领先者 / opp_needs
- 提供缓存装饰器 `@feature_cache`(同一 GameState 实例内缓存)
- 命名规范:动词 + 名词,明确返回值语义
-`calc_cluster_revenue(...)`, `card_value_for_selfuse(...)`, `pool_remaining(...)`
-`analyze()`, `get_stuff()`, `compute()`
**DON'T**
- 有任何 if/else 输出"接受""拒绝""跳过"(决策去 L2)
- 引用 L2/L1
- 调用 `random`(策略层需要随机化就让 ctx 传入 rng)
- 修改传入的 state(必须返回新对象)
**判断标准**
- 每个函数写完先想:**能不能一句话说清"输入 X 输出 Y"**?说不清就是没抽象好
- 每个函数必须能这样测试:`assert calc_cluster_gain(state1, plot=5, shop="Happy") == 30000`
- 大部分函数应 ≤ 30 行;≥ 50 行说明需要拆
**关键契约**
- `card_value(state, card, purpose: Literal["selfuse","sell","combo"]) -> int` 是统一入口,杜绝多头维护
- `plot_value(state, plot, viewer: str) -> int``viewer` 的视角算价值
- `Offer` dataclass 用 `to_me` / `from_me` 命名字段,禁止传 `offer_*/demand_*` 4 元组
---
### 3.4 L2 Strategy 层
**DO**
- 每个策略一个文件,实现 `Strategy` 协议
- 通过 `match(ctx) -> MatchResult` 声明"我要不要处理这个局面"
- 通过 `act(ctx) -> Action` 产出决策
- `_order.py` 里显式声明本 phase 下所有策略的优先级
- 复杂策略可拆多个 `_helper.py`,但 helper 必须在同一策略目录下、且不跨策略引用
**DON'T**
- 引用其他策略模块(跨策略共享逻辑 → 提到 L3 features 或 helper)
- 直接调用 LLM(走 `ctx.request_llm_arbitration(...)`
- 修改 ctx / state
-`import random`(用 `ctx.rng`
- 单文件 > 120 行(超了就拆)
**判断标准**
- 一个策略的 `match()` 应能一句话说清"什么时候触发"
- 一个策略的 `act()` 应能一句话说清"触发后返回什么"
- 如果你在一个策略里写"如果 round==4 走 A、否则走 B"——那是**两个策略**,拆开
- 策略之间不允许知道彼此的存在(除了 `_order.py` 声明优先级)
**关键契约**
- `match()` 返回 `MatchResult.HIT` / `MatchResult.MISS` / `MatchResult.UNSURE`
- `act()` 只返回 `Action` dataclass;`Action``AgentResp` 的转换在 L5
- 每个策略必须暴露 `NAME: str``PRIORITY: int``PHASE: GamePhase`
---
### 3.5 L1 LLM Adapter 层
**DO**
- 唯一的 OpenAI 客户端封装(文本 + 视觉分开)
- `LLMArbitrator.pick(ctx, candidates: list[Strategy]) -> Strategy`
- `SpeechGen.render(action: Action) -> str` (生成 message,禁止改 action 其它字段)
- `VisionParser.parse(snapshot) -> BoardView`
- Prompt 模板集中在 `llm/prompts/` 目录,一个用途一个文件
**DON'T**
- 做任何数值计算 / 决策 / 定价
- 直接输出 `AgentResp`
- Prompt 里塞对手 message(永远走 sanitize 层,且 L1 不主动读 message)
- 有超过 10 个 Prompt 模板(Prompt 爆炸 = 抽象没做好)
**判断标准**
- LLM 的输出永远是 **枚举**(策略名 / 话术字符串 / JSON dict),不是自由文本
- LLM 输出必须走 pydantic 校验;失败 fallback 到确定性策略
- 单次 LLM 调用 ≤ 4s;超时兜底
**关键契约**
- `LLMArbitrator.pick()` 返回值必须是 `candidates` 里的一员,否则 raise
- LLM 失败不能让决策失败——所有调用点都有代码兜底
---
### 3.6 依赖方向速查表
| 层 | 可以 import 的 | 不可以 import 的 |
|---|---|---|
| L5 | 所有下层 | — |
| L4 | 只有 `constants/` + `sanitize.py` | L5, L3, L2, L1 |
| L3 | L4 + `constants/` | L5, L2, L1 |
| L2 | L3 + L4 + `constants/` | L5, L1(除 `ctx.request_llm_*`) |
| L1 | 只有 `sanitize.py` + `constants/` | L5, L4, L3, L2 |
**跨层通信只能通过 DecisionContext / Action / Event 三种数据契约。**
---
## 第 4 章 — 变更分类决策树
> 拿到一个需求(对局观察、bug 报告、想加的新策略),先走这棵树,落到 5.1–5.8 的一个小节。
```
需求进来
├─ 是"决策错了"(agent 做了/没做某件事,我觉得应该反过来)
│ │
│ ├─ 触发条件本身就没有对应策略 ────► 5.1 加一条新策略
│ ├─ 策略触发了但产出错 ─────────────► 5.2 修改已有策略
│ ├─ 策略逻辑对,但阈值/价格不对 ────► 5.3 调整数值参数
│ └─ 策略逻辑对,但传入的数值就是错的
│ │
│ ├─ 状态错(cash/plots/built_on 不对)─► 5.8 修改基建 / 状态层
│ └─ 派生指标错(cluster/lead/opp_needs)─► 5.5 加/修 Feature
├─ 是"代码崩了 / 超时 / 数据丢失" ─────────► 5.4 修 bug
├─ 是"游戏规则变了 / 后端消息类型变了" ────► 5.6 加一个新的 Event 类型
├─ 是"想让 LLM 参与更多 / 更少" ───────────► 5.7 修改 LLM 参与方式
└─ 是"想改架构本身 / 加新的一层" ──────────► 走本手册的第 2、3 章升级流程
```
### 4.1 分类判断口诀
- **加什么** → 5.1(策略)/ 5.5(Feature)/ 5.6(Event)
- **改什么** → 5.2(策略行为)/ 5.3(参数)/ 5.7(LLM 用法)
- **修什么** → 5.4(bug)/ 5.8(基建)
### 4.2 何时需要跨类
一次改动可能跨类:
| 场景 | 落地路径 |
|---|---|
| 新策略需要一个之前没算过的指标 | **先** 5.5 加 Feature,**再** 5.1 加策略 |
| 修策略时发现下面的估值有 bug | **拆两个 PR**:先 5.4 修 bug(含回归测试),再 5.2 改策略 |
| 加 Event 后要让某策略响应它 | **先** 5.6 加 Event + reducer,**再** 5.5/5.1 更新 Feature/策略 |
**原则:一个 PR 只做一件事**。跨类改动分多个 PR,每个 PR 都过 checklist。
---
## 第 5 章 — 各类变更的操作规范
### 5.1 加一条新策略
#### 5.1.1 什么时候用这条
- 观察到某个局面 agent 应该做 X 但代码里没有对应逻辑
- 想尝试一种全新的博弈套路(如"卡领先者""废卡换地")
- 冠军对局分析出的新战术
**不适用**:现有策略触发但产出错 → 走 5.2;现有策略触发条件太宽/太窄 → 走 5.2 而不是 5.1
#### 5.1.2 判断放哪层
新策略永远在 **L2 strategies/** 下。判断哪个子目录:
| Phase | 目录 | 触发时机 |
|---|---|---|
| SELECT_PLOTS | `strategies/select_plots/` | 每回合选地 |
| PLAN_BUILD | `strategies/plan_build/` | 每回合建设 |
| PROPOSE_TRADE | `strategies/propose_trade/` | 每次 agent 主动发起交易 |
| EVALUATE_TRADE | `strategies/evaluate_trade/` | 每次收到别人交易要约 |
#### 5.1.3 代码规范
**文件模板** `strategies/propose_trade/buy_adjacent_plot.py`
```python
"""BuyAdjacentPlot — R1-R3 无 R4 领先者时,主动买邻接工位扩集群。
来源:L4-11, L4-12, L4-14, L4-18, L4-20(inventory 里的编号,方便追溯)
"""
from __future__ import annotations
from ..base import Strategy, MatchResult, Action
from ...features.plot_value import find_best_buy_candidate
from ...features.opponents import is_me_leader
from ...features.pricing import buy_discount, max_buy, snap_price
NAME = "buy_adjacent_plot"
PHASE = "propose_trade"
PRIORITY = 30 # 见 _order.py 中的编号规则
def match(ctx) -> MatchResult:
if ctx.round == 4 and is_me_leader(ctx.state):
return MatchResult.MISS
if not ctx.is_directed:
return MatchResult.MISS
cand = find_best_buy_candidate(ctx.state)
if cand is None:
return MatchResult.MISS
if cand.net < 0:
return MatchResult.MISS
return MatchResult.HIT
def act(ctx) -> Action:
cand = find_best_buy_candidate(ctx.state) # feature 层缓存, 不重算
price = snap_price(min(
int(cand.gain * ctx.cfg.buy_discount[ctx.round]),
ctx.cfg.max_buy[ctx.round],
))
return Action.initiate(
target=cand.owner,
offer_cash=price,
demand_plots=[cand.plot_id],
speech_hint="buy_plot", # SpeechGen 会用此 hint 生成 message
)
```
**必须**
- `NAME` / `PHASE` / `PRIORITY` 三个模块级常量
- `match(ctx)` 只读 ctx,不改
- `act(ctx)` 只调 features 和 config,不直接算复杂数值
- 所有随机化用 `ctx.rng`
- 所有数值参数从 `ctx.cfg.<name>` 读,禁止硬编码
- 顶部注释写清"来源"(inventory 编号或者对局出处),便于追溯
**禁止**
- 在策略里 import 其它策略
- 在策略里 `time.time()` / `random.random()` / `os.getenv()`
- 单文件超过 120 行(拆 helper 到同目录)
#### 5.1.4 注册到 Router
打开 `strategies/propose_trade/_order.py`
```python
from . import (
r4_leader_skip,
buy_card_for_full_cluster,
buy_adjacent_plot, # ← 新加的
buy_plot_combo,
...
)
# 优先级从小到大,第一个 HIT 就返回
STRATEGIES_IN_ORDER = [
r4_leader_skip, # priority 10
buy_card_for_full_cluster, # priority 20
buy_adjacent_plot, # priority 30 ← 插到这里
buy_plot_combo, # priority 40
...
]
```
**优先级编号规则**
- 每个策略的 `PRIORITY` 是它在 `_order.py` 里的相对位置 × 10
- 插入新策略时,`PRIORITY` 也要更新
- 优先级冲突(同 phase 两个策略 PRIORITY 相同)→ CI 会报错
#### 5.1.5 必配的三件套
**必须**同时提交:
1. **策略代码**(本节主内容)
2. **参数**(如果引入了新数字,配到 `config/strategy_stable.yaml`
3. **测试**
- `tests/strategies/test_buy_adjacent_plot.py`:至少 3 个用例(HIT / MISS 边界 / act 输出)
- `tests/replay/test_buy_adjacent_plot_replay.py`:至少 1 个真实对局回放证明这条策略被走到
#### 5.1.6 自测
```bash
# unit
pytest tests/strategies/test_buy_adjacent_plot.py -v
# 全策略层
pytest tests/strategies/ -v
# 从一场历史 replay 回放,检查这条策略被触发过
python -m werewolftown.tools.replay replays/<game_id> \
--assert-strategy buy_adjacent_plot
```
#### 5.1.7 PR checklist(本节专属,通用见第 12 章)
- [ ] 单文件 ≤ 120 行
- [ ] `NAME/PHASE/PRIORITY` 三常量齐全
- [ ] `_order.py` 已注册
- [ ] 硬编码数字全部挪到 `strategy_stable.yaml`
- [ ] `tests/strategies/` 新增了针对性单测(HIT/MISS/act)
- [ ] `tests/replay/` 至少一个真实对局回放证明触发过
- [ ] 顶部注释写清"来源"(inventory 编号或对局 game_id)
### 5.2 修改已有策略
#### 5.2.1 什么时候用这条
- 策略被触发了,但产出的 Action 不对(价格错、目标错、方向错)
- 策略触发条件太宽(不该触发时触发)或太窄(该触发时没触发)
- 策略里某段逻辑与 Feature 层重复,可以下沉
#### 5.2.2 判断影响面
**第一步:读该策略的顶部注释**。找到"来源"里的 inventory 编号,看历史上这条策略是为什么加进来的。
**第二步:查 `_order.py`,看这条策略的邻居**(前一个和后一个 priority)。改这条策略可能影响:
- 更高优先级的策略 → **通常不影响**(更高优先级的先 return 了)
- 更低优先级的策略 → **可能影响**(本策略从 MISS 改 HIT 会抢走它们的机会;反之亦然)
**第三步:如果条件收紧**(HIT → MISS 变多),下一个策略要能接住;**如果条件放宽**(MISS → HIT 变多),要确认没抢占后续更合适的策略。
#### 5.2.3 代码规范
**只允许 3 种改动**
| 改动 | 允许 | 判断 |
|---|---|---|
| A. 收紧/放宽 `match()` 条件 | ✅ | 只改 match,不改 act |
| B. 改 `act()` 产出 | ✅ | 只改 act,不改 match |
| C. 拆分/合并策略 | ✅ | **必须**新加/删文件,不允许原地扩容 |
**禁止**
- 在原有策略里加"如果 X 走 A、否则走 B" → 拆成两个策略
- 让 match 有副作用(改 state、调 LLM、动 rng)
- 把参数从 config 挪回硬编码
#### 5.2.4 兼容性检查
改一条策略前必须做:
```bash
# 1. 找出所有回放里被这条策略处理过的局面
python -m werewolftown.tools.replay_scan \
--strategy buy_adjacent_plot \
--output before.csv
# 2. 改完策略后再跑一次
python -m werewolftown.tools.replay_scan \
--strategy buy_adjacent_plot \
--output after.csv
# 3. diff 两份结果
python -m werewolftown.tools.replay_diff before.csv after.csv
```
Diff 里会显示:
- **仍然 HIT 的局面**(占多数是好事,说明改动没颠覆本策略)
- **HIT → MISS**(本次改动让哪些局面不再触发)
- **MISS → HIT**(哪些新增局面被这条策略接管)
- **HIT 后 Action 变化**(价格从 X 变 Y)
diff 结果需要作为 PR 描述的一部分,并解释每类变化是否符合预期。
#### 5.2.5 参数与逻辑的边界(关键)
改策略前先问:**这个改动真的是逻辑变化,还是只是参数变化?**
- "R1 买地折扣从 0.5 改到 0.55" → 是 **5.3 调整数值参数**,不是 5.2
- "R1 除了买邻接地还要考虑跨街区候选" → 是 5.2(改 match/act 逻辑)
- "把 R4 领先者不买地改成也可以买地" → 拆策略,走 5.1(新加一个 `R4LeaderConservativeBuy`)
**如果 60% 以上的改动是数字,先走 5.3**。
#### 5.2.6 自测
```bash
# 该策略单测
pytest tests/strategies/test_<strategy_name>.py -v
# 若改动可能影响相邻策略,跑该 phase 全套
pytest tests/strategies/propose_trade/ -v
# 回放差异分析
python -m werewolftown.tools.replay_diff before.csv after.csv --min-diff 5%
```
#### 5.2.7 PR checklist
- [ ] 顶部注释里补一条"改动记录:`<date> - <理由>`"
- [ ] 若加了新 `match` 分支,`_order.py` 里检查过邻居
- [ ] 若引入新数字,参数已配到 `strategy_stable.yaml`
- [ ] 回放差异分析(before.csv vs after.csv)附在 PR 描述中
- [ ] 单测覆盖新增分支
- [ ] 单文件仍 ≤ 120 行
### 5.3 调整数值参数
#### 5.3.1 什么时候用这条
- 策略逻辑对,但阈值/价格偏高偏低
- 想微调"更保守"或"更激进"
- 冠军对局分析出的具体数字更合理
**不适用**:如果调数字会导致策略"是否触发"发生变化(例如把 accept_threshold 从 5000 改到 -5000)——这算逻辑变化,走 5.2
#### 5.3.2 参数目录结构
所有可调数字都在 `config/` 下:
```yaml
# config/strategy_stable.yaml — 生产环境
version: "20260713"
description: "V18-Fix5 之后的稳定版"
pricing:
buy_discount:
R1: 0.50
R2: 0.45
R3: 0.35
R4: 0.35
max_buy:
R1: 25000
R2: 20000
R3: 15000
R4: 10000
price_snap_step: 500
card_value:
base_price_by_fc:
2: 30000
3: 20000
4: 12000
5: 8000
market_val_by_fc:
2: 15000
3: 15000
4: 12000
5: 10000
scarcity_lambda:
pool_le_1: 0.55
pool_le_2: 0.40
R4: 0.50
fc_le_2: 0.35
default: 0.25
threshold:
accept:
R1: [12000, 18000] # uniform range
R2: [12000, 18000]
R3: [8000, 12000]
R4: 0 # 定值
reject:
R1: [-10000, -6000]
R2: [-10000, -6000]
R3: [-8000, -4000]
R4: 0
# ... 见附录 A 数值参数索引
```
**必须**:
- 每个参数都要有语义化名字,不能是 `param1` `x`
- 分组按功能:`pricing/` / `card_value/` / `threshold/` / `build/` / `select/`
- 每个大分组顶部一句话说明用途
#### 5.3.3 修改流程
**先改 canary,不改 stable**:
```bash
# 1. copy stable → canary(如果 canary 落后了)
cp config/strategy_stable.yaml config/strategy_canary.yaml
# 2. 只改 canary 的对应参数
vim config/strategy_canary.yaml
# 3. 加一条 CHANGELOG 到 canary 顶部
```
**参数改动的 CHANGELOG 格式**
```yaml
changelog:
- date: "20260714"
field: "pricing.buy_discount.R1"
from: 0.50
to: 0.55
reason: "对局 358890 中 R1 买地净值 20k 但我们只出 15k 被拒,测试 0.55 提升成交率"
author: "user"
```
改完 canary,走部署(见第 8 章)灰度 → 观察 → 全量。
#### 5.3.4 判断能不能只改参数
**试探:把新参数值填进现有测试**
```python
def test_accept_threshold_r1():
ctx = build_ctx(round=1, cash=50000, offer_net=10000)
# 用 canary 参数
with load_config("strategy_canary.yaml"):
assert evaluate(ctx).action == "ACCEPT"
# 用 stable 参数
with load_config("strategy_stable.yaml"):
assert evaluate(ctx).action == "REJECT"
```
如果这样的对照测试写得出来 → 说明确实只是参数变化,走 5.3;
如果对照测试要求"新代码分支"才能通过 → 说明是逻辑变化,走 5.2
#### 5.3.5 自测
```bash
# 用 canary 参数跑一遍策略层测试
STRATEGY_PROFILE=canary pytest tests/strategies/ -v
# 用 canary 跑历史回放,看总收益趋势
python -m werewolftown.tools.replay_batch \
--profile canary \
--games replays/last_20/ \
--report canary_vs_stable.md
```
`canary_vs_stable.md` 会输出:
- 每局改动前后 agent 总现金差
- 平均差值
- 分位数(P25 / P50 / P75)
只有平均差 > 0 且 P25 也不劣化,才能走灰度部署。
#### 5.3.6 PR checklist
- [ ] 只改了 `config/strategy_canary.yaml`,没动代码
- [ ] `changelog:` 段有条目:字段/from/to/reason/author
- [ ] 20 场历史 replay 的 canary vs stable 报告附在 PR 描述里
- [ ] 平均总现金差 > 0,且 P25 分位数不劣化
### 5.4 修 bug(正确性问题)
#### 5.4.1 什么时候用这条
- 代码抛异常 / 超时
- 数据错(state 里的 cash/plots/built_on 与实际不符)
- 策略应该 HIT 但没 HIT(match 里有笔误、import 错)
- 明显的算术错(净值 -245000 那种量级偏差)
#### 5.4.2 修 bug 的黄金流程
**第一步:先复现,不要先改**
```bash
# 1. 找到出问题的 game_id(HF 日志、用户报告、监控告警都能给)
GAME_ID=358890
# 2. 从 replays/<game_id>/ 拉出事件流和决策日志
python -m werewolftown.tools.replay replays/$GAME_ID --verbose
# 3. 定位到出错的那次 interact 决策
# 输出会打印: [round=2 phase=propose_trade] strategy=buy_adjacent_plot
# elapsed=45s, action=SKIP, exception=NameError('remaining')
```
**如果 replay 无法复现,先补 replay 采集**:这是基建 bug(去 5.8),不是策略 bug。
**第二步:写失败测试**
```python
# tests/strategies/test_buy_adjacent_plot.py
def test_bug_20260713_undefined_remaining():
"""Bug: 2026-07-13 room-358890 R2 propose_trade 抛 NameError('remaining')."""
ctx = load_from_replay("replays/358890/", round=2, phase="propose_trade")
# 期望:至少不能崩,返回 SKIP 或有效 Action
result = strategy.act(ctx)
assert result is not None
assert result.action in {"initiate", "skip"}
```
**这条测试必须先失败,才能证明 bug 存在**
**第三步:修复**
修复只做**最小改动**——只让上面那条测试通过,不做重构、不做优化。
**第四步:证明修复不引入回归**
```bash
pytest tests/strategies/ tests/features/ tests/unit/ -v
python -m werewolftown.tools.replay_batch replays/last_20/ --profile stable_with_fix
```
replay batch 里所有 game 的最终 agent 现金都应 >= 修前值(允许微调)。
#### 5.4.3 常见 bug 分类与定位
| 症状 | 常见原因 | 定位起点 |
|---|---|---|
| NameError / UnboundLocalError | 函数内 import 或变量未定义 | 该函数模块级 import |
| 数据源不对 | State 层重放丢事件 | `state/reducer.py`, `state/persistence.py` |
| 决策超时 | LLM 兜底 + LLM 超时 | 检查是否走到了 L1;策略 match 是否覆盖到该场景 |
| 视角反转(net 反了号) | Offer dataclass 用错方向 | 该策略的 `act()` 里的 Offer 构造 |
| 循环 import / lazy import 藏 bug | 函数体内 import | 移到模块级;跑 mypy/ruff 应能扫出 |
| int/str key 不一致 | State 里 built_on 序列化问题 | `state/game_state.py` 的类型注解 |
#### 5.4.4 修 bug 的红线
- **禁止**通过 `try/except: return SKIP` 掩盖 bug(旧代码的巨坑,见 inventory 第 2.2 节 P1-5)
- **禁止**通过"再加一个 if"打补丁到多层——如果某个 bug 需要跨层修,先谈重构(见 5.8)
- **必须**在 PR 描述里写清"这个 bug 是被隐藏了多久(前一次涉及此代码的改动日期到发现日期)",帮助我们判断监控是否有盲区
#### 5.4.5 PR checklist
- [ ] `tests/` 有一条以 game_id 命名的回归测试(`test_bug_YYYYMMDD_<short_desc>`)
- [ ] 该测试在修前失败、修后通过
- [ ] 修改范围最小化(不夹带重构)
- [ ] 全量测试通过 + last 20 replay batch 无回归
- [ ] PR 描述里包含:"bug 首次可能出现时间 / 被发现时间 / 潜伏原因"
### 5.5 加一个 Feature(衍生指标)
#### 5.5.1 什么时候用这条
- 你正要写的一个策略需要一个之前没算过的指标
- 有两个以上策略都要用到同一个数字,且这个数字目前散落在各处(临时算或复制粘贴)
- L2 策略层里出现"import 另一个策略的 helper"(禁止 → 抽 L3)
**不适用**:如果这个指标已经在 features/ 里存在,不要重写;如果只在一处用到,也可以先留在 helper 里,等第二次用到再抽
#### 5.5.2 判断放哪个模块
现有六个功能包:
| 模块 | 职责 | 示例 |
|---|---|---|
| `features/clusters.py` | 集群相关 | `calc_cluster_gain`, `find_typed_clusters`, `achievable_size` |
| `features/card_value.py` | 卡估值 | `card_value(state, card, purpose)`, `card_market_val` |
| `features/plot_value.py` | 工位估值 | `plot_value(state, plot, viewer)`, `find_best_buy_candidate` |
| `features/opponents.py` | 对手分析 | `opp_needs`, `is_leader`, `detect_afk`, `detect_strong` |
| `features/pool.py` | 牌池 | `pool_remaining`, `scarcity_lambda` |
| `features/pricing.py` | 定价工具 | `snap_price`, `dead_zone_adjust`, `buy_discount`, `max_buy` |
**判断口诀**:新 feature 的输入主体是什么?
- 输入是 (state, plot) → `plot_value.py`
- 输入是 (state, card) → `card_value.py`
- 输入是 (state, opponent_pid) → `opponents.py`
- 输入是 (state,) 且返回集群统计 → `clusters.py`
- 输入是 (state,) 且返回稀缺度 → `pool.py`
- 是数值变换工具 → `pricing.py`
不满足以上,可新开模块,但必须在本手册第 2.2 节目录树里追加。
#### 5.5.3 代码规范
```python
# features/plot_value.py
from dataclasses import dataclass
from typing import Literal
from ..state.game_state import GameState
from ..constants.shop_rules import FULL_COUNTS
Viewer = Literal["me", "opponent"]
@dataclass(frozen=True)
class BuyCandidate:
"""一个可买入的工位候选,附带估值细节。"""
plot_id: int
owner: str
gain: int # 净收益(未减价)
best_shop: str # 若买入应该建哪种业务
is_adjacent: bool # 是否邻接我方工位
net: int # gain - suggested_price
def plot_value(state: GameState, plot_id: int, viewer: Viewer = "me") -> int:
"""从 viewer 视角计算一个工位的估值。
- viewer="me": 我方买入该地能获得的最大边际增益
- viewer="opponent": 对手买入该地能获得的最大边际增益 (卖地时用)
"""
...
```
**必须**
- 每个函数第一行 docstring 说清"输入 / 输出 / 语义"
- 返回值是 dataclass(`frozen=True`)或原始类型,不返回 dict
- 输入 state 不能被修改(拿 frozen dataclass 就免这条)
- 加类型注解,`mypy --strict` 能过
- 遵循命名规范:`calc_*` 计算类,`find_*` 查找类,`is_*/has_*` 布尔,`<name>_value` 估值
**禁止**
-`if round == 4: skip_this()` 之类的决策逻辑(决策去 L2)
- 依赖 `random`
- 修改全局变量
- 单函数 > 50 行
#### 5.5.4 缓存
大量 feature 会被同一个决策周期里多次调用,避免重算:
```python
from ..features._cache import feature_cache
@feature_cache
def find_best_buy_candidate(state: GameState) -> BuyCandidate | None:
"""在 (state, {}) 上缓存: 同一 state 实例内不重算。"""
...
```
`feature_cache``id(state)` 做 key,state 一变缓存失效(state 不可变,一旦有事件到达就产生新 state 实例)。
#### 5.5.5 自测
```bash
pytest tests/features/test_plot_value.py -v
```
**测试规范**
- 每个 feature 至少 3 个 case(正常 / 边界 / 空输入)
-`tests/features/fixtures.py` 里的 `make_state(...)` 便捷构造 state
- 断言必须给具体数字(不是 `> 0` 这种模糊断言),因为策略层依赖具体值
```python
def test_plot_value_adjacent_full_cluster():
state = make_state(
my_plots=[4, 5], built_on={4: "Happy", 5: "Happy"}, shops=["Happy"],
)
# plot=6 邻接 4,5 (Happy 2 连) + 手牌 Happy → 满额 3 连
v = plot_value(state, 6, viewer="me")
assert v == 70000 - 30000 # 3 连满额 70k - 之前 2 连 30k
```
#### 5.5.6 PR checklist
- [ ] Feature 放在合适的模块(按 5.5.2 判断)
- [ ] 输入 state,输出 dataclass/原始类型,无副作用
- [ ] docstring 说清输入输出语义
- [ ] 类型注解完整
- [ ] `tests/features/test_<module>.py` 覆盖 ≥ 3 个 case
- [ ] 若加了 `@feature_cache`,验证过 state 变更后缓存失效
### 5.6 加一个新的 Event 类型
#### 5.6.1 什么时候用这条
- 游戏引擎新增了 perceive 消息类型(如新的 phase 事件)
- 某种局面 State 层需要新的字段来追踪(如新增 opp_needs 缓存)
- 后端字段格式变化(比如 `offer_shops` 从字符串改成数组)
**不适用**:如果只是把已有事件更完整地记录到 state,这是 5.8 的活;如果只是让某策略响应已有事件,这是 5.1/5.2
#### 5.6.2 五步走
**Step 1: 在 `state/events.py` 定义事件类型**
```python
from dataclasses import dataclass
from typing import Literal
@dataclass(frozen=True)
class DealCountered:
"""引擎推送的还价事件。"""
kind: Literal["deal_countered"] = "deal_countered"
game_id: str
round: int
deal_id: str
source: str # 还价方
target: str # 被还价方
offer_cash: int
offer_plots: tuple[int, ...]
offer_shops: tuple[str, ...]
demand_cash: int
demand_plots: tuple[int, ...]
demand_shops: tuple[str, ...]
ts: float # 事件到达时间戳
```
**Step 2: 在 `state/reducer.py` 加 case**
```python
def reduce(state: GameState, event: Event) -> GameState:
match event:
case DealProposed(...): return _reduce_deal_proposed(state, event)
case DealCountered(...): return _reduce_deal_countered(state, event) # ← 新加
...
```
**Step 3: 在 `transport/endpoints.py` 加解析**
```python
def _req_to_event(req: AgentReq) -> Event:
match req.status.value:
case "deal_proposed": return _parse_deal_proposed(req)
case "deal_countered": return _parse_deal_countered(req) # ← 新加
...
```
**Step 4: 加 replay 兼容**
Replay 落盘的 JSON 里加新事件类型,`load_events` 要能反序列化:
```python
# state/persistence.py
_EVENT_TYPES = {
"deal_proposed": DealProposed,
"deal_countered": DealCountered, # ← 新加
...
}
```
**Step 5: 更新受影响的 features**
如果新事件带来的 state 变化会影响某些 feature 的计算,那些 feature 的缓存要考虑到(通常 state 变化自动使缓存失效,但要显式测试)。
#### 5.6.3 事件的兼容性红线
- **旧 replay 必须能加载**:新加事件类型不能让旧的 events.jsonl 里的记录反序列化失败
- **事件字段只加不删**:如果要重命名字段,通过 property 别名兼容
- **event schema 版本化**`state/events.py` 顶部维护 `SCHEMA_VERSION`,改动时递增
- **replay 里的事件带 schema_version**:`load_events` 遇到高版本要提示升级
#### 5.6.4 自测
```bash
# 1. 单测 reducer
pytest tests/state/test_reducer_deal_countered.py -v
# 2. 用带新事件的模拟 replay 验证 rebuild_state 无回归
pytest tests/state/test_persistence.py -v
# 3. 用旧的 replay 验证兼容性
pytest tests/state/test_replay_backward_compat.py -v
```
#### 5.6.5 PR checklist
- [ ] `state/events.py` 定义了新事件类
- [ ] `state/reducer.py` 加了对应 case,纯函数
- [ ] `transport/endpoints.py` 加了 req → event 解析
- [ ] `state/persistence.py` 的 `_EVENT_TYPES` 注册了新类型
- [ ] `tests/state/` 有新事件的 reducer 测试 + replay 序列化/反序列化测试
- [ ] 旧 replay 数据仍能正常加载(backward-compat 测试)
- [ ] 若字段命名有变,写了别名保持兼容
### 5.7 修改 LLM 参与方式
#### 5.7.1 什么时候用这条
- 想让某个策略的某个参数由 LLM 填("模式 3:LLM 填参数")
- 想让 LLM 在多个候选策略间仲裁("模式 2:LLM 仲裁")
- 想把某个已经稳定的 LLM 调用点固化为纯代码("从模式 2/3 降到模式 1")
- 想改话术生成规则
#### 5.7.2 四种 LLM 参与模式(关键概念)
| 模式 | 描述 | 用途 | 代码入口 |
|---|---|---|---|
| M1 纯代码 | LLM 完全不参与决策 | 成熟策略 | 策略 `act()` 直接返回 Action |
| M2 LLM 仲裁 | 多个策略 HIT/UNSURE,LLM 从候选里挑一个 | 策略打架时 | `ctx.request_llm_arbitration(candidates)` |
| M3 LLM 填参 | 策略返回 partial Action,LLM 填空 | 参数不好定 | `Action.partial(price=None, ...)` + `SpeechGen.fill()` |
| M4 LLM 全接管 | 所有策略 MISS,LLM 自由决策 | 未知场景兜底 | `LLMFullDecide.decide(ctx)` |
**演化方向:M4 → M3 → M2 → M1**,越往右越确定,越可控。
#### 5.7.3 何时升/降模式
**从 M4 降到 M3**(LLM 全接管 → LLM 只填参数):
- 观察到某场景 LLM 决策大方向稳定,但价格上下 30%
- 加一条策略:`match` 就是 M4 的触发条件,`act` 返回 `Action.partial(price=None)`
- LLM 只填 price,逻辑固化在策略里
**从 M3 降到 M2**(LLM 填参 → LLM 只仲裁):
- 观察到某参数在两三个离散值之间跳(比如 "10k 或 15k")
- 拆成 2-3 个子策略(分别对应不同参数值),全部 UNSURE
- LLM 从这几个里挑一个
**从 M2 降到 M1**(LLM 仲裁 → 纯代码):
- 观察到 LLM 仲裁结果稳定倾向某一策略
- 把仲裁改为硬性优先级
- 或者把两个候选策略合并为一个,删掉冲突
**M1 → M2/M3 升级**(新场景无法完全代码化时):
- 加一个策略,match 返回 UNSURE 而不是 HIT
- 让 LLM 仲裁参与
#### 5.7.4 加/改 LLM 调用点的规范
**只有 3 个合法调用点**(全部在 L1):
1. `LLMArbitrator.pick(ctx, candidates)` — 从多候选选一个策略名
2. `SpeechGen.render(action, hint)` — 生成 message
3. `VisionParser.parse(snapshot)` — 解析图片
**新增第 4 个调用点必须先改本手册第 3.5 节**。
**调用点必须满足**:
```python
# llm/arbitrator.py
class LLMArbitrator:
def pick(self, ctx: DecisionContext, candidates: list[Strategy]) -> Strategy:
# 1. 输出必须是候选之一
# 2. 输出用 pydantic 模型强约束
# 3. 超时/失败必须有代码 fallback
try:
resp = self._call_with_timeout(...)
picked = self._parse(resp, candidates)
return picked
except (TimeoutError, ValidationError):
# fallback: 用 priority 最高的候选
return sorted(candidates, key=lambda s: s.PRIORITY)[0]
```
**Prompt 必须**:
- 不含对手 message(sanitize 层已经过滤,但 L1 也不主动读)
- 用结构化格式(system 是稳定契约,user 是当前局面)
- 用 pydantic 定义输出 schema:`class ArbitrationResp(BaseModel): picked: str`
#### 5.7.5 话术(SpeechGen)规范
话术是当前 LLM 最重要的用途。规则:
- 每个 Action 携带 `speech_hint`(枚举:`"buy_plot"`, `"sell_card"`, `"counter_up"`, `"counter_down"`, `"reject"`, `"skip"`, ...)
- `SpeechGen.render(action, hint)` 根据 hint 选 prompt 模板生成 message
- 生成失败 fallback 到固定短话术(见 inventory L4-33, L6-09)
- 话术生成不影响 Action 的其它字段——**只填 `message` 字段**
#### 5.7.6 自测
```bash
# LLM 层单测(用 mocked client)
pytest tests/llm/ -v
# 端到端:某场景应该走 M3
pytest tests/replay/test_llm_participation.py::test_M3_price_filled -v
```
**关键测试**:LLM 超时 / 返回 gibberish / 返回不在候选里的值 → 都要能兜底不崩
#### 5.7.7 PR checklist
- [ ] 明确本次改动是模式升级还是降级(M2↔M3 等),写在 PR 描述里
- [ ] 若新增调用点,本手册第 3.5 节同步更新
- [ ] Prompt 通过 pydantic schema 约束输出
- [ ] LLM 失败 fallback 是纯代码
- [ ] 每次 LLM 调用有超时保护(≤ 4s)
- [ ] 单测覆盖:正常返回、超时、格式错误、无效值 四种
### 5.8 修改基建 / 状态层
#### 5.8.1 什么时候用这条
- Reducer 有 bug(应用某个事件后 state 与实际不符)
- Replay 无法完整恢复(服务器重启后状态残缺)
- 并发竞速网关行为异常
- 想改 State 的数据结构(比如把 `plots: list` 改成 `frozenset`)
**警告**:**这层改动风险最大**。任何 L4 的改动都可能牵一发动全身——上面所有 L3 features 都依赖 State 的字段名和语义。**动 L4 前必须先谈**(本手册也算"上级",需要更新第 3.2 节和第 2.2 节)。
#### 5.8.2 判断改动范围
三个层次,越往下越危险:
| 层次 | 例子 | 危险度 |
|---|---|---|
| A. 只改 reducer 里某个 case 的实现 | 修 `deal_accepted` 的资产转移 bug | 中 |
| B. 加/删 State 字段 | 加一个 `pending: PendingLedger` | 高 |
| C. 改 State 序列化格式 | `built_on` 从 dict 改成 tuple | 极高 |
**A 直接走本节流程;B 要更新第 3.2 节;C 要更新第 2.2 节 + backward-compat 迁移**
#### 5.8.3 A 类改动流程(修 reducer bug)
1. **找到那个 case**(比如 `_reduce_deal_accepted`
2. **写失败测试**:用 replay 里的一段 `(state_before, event, expected_state_after)`
3. **改 reducer**,只让这个测试通过
4. **跑所有 reducer 单测 + 全 replay 重放**:任何一场 replay 重放出来的 state 与录制时不一致 → 回滚
#### 5.8.4 B 类改动流程(加/删 State 字段)
**加字段是安全的**(默认值兼容旧数据):
```python
@dataclass(frozen=True)
class GameState:
# ... 现有字段
afk_scores: dict[str, int] = field(default_factory=dict) # 新加
```
Reducer 里在相应事件更新这个字段。旧 replay 加载后该字段是空 dict,不影响策略——策略应该能处理"空字段"作为初始状态。
**删字段绝对禁止**:改为标注 `@deprecated`,保留字段但不再写入。等所有依赖它的 feature/策略都不用了,才能真正删。**删除的 PR 单独提**
#### 5.8.5 C 类改动流程(改序列化格式)
这是最麻烦的场景。必须走:
1. **加新字段并双写**:新旧格式同时保留(比如 `built_on_dict``built_on_tuple`
2. **等一个上线周期**:观察新格式所有 case 都正常
3. **切换 features/策略到新字段**:一个 PR 切一个消费方
4. **观察一个上线周期**:老字段没人读了
5. **删老字段**:单独 PR
**旧 replay 的兼容**`persistence.py``load_events` 需要探测 schema_version,用旧解析器读,然后 reducer 无缝升级为新 state。
#### 5.8.6 竞速网关(`transport/race_gate.py`)改动
因为你的补充说明第 5 点:**后端不发显式重发标记**,我们只能靠 `cache_key` 推断。任何这方面改动必须:
- 保留 cache_key 计算逻辑(`message` 字段仍要排除,以防对手在附言里做小改动)
- 竞速用 async future(不再 threading.Event)
- 保留兜底:如果 2 个请求都超时了怎么办(返回上一次 known-good decision 或 SKIP)
**测试用例必须包含**
- 同一 key 的两个请求并发到达
- 第一个请求超时、第二个请求正常返回
- 两个请求都异常
- 请求 key 相同但 message 字段不同 → 仍然合并(因为 cache_key 排除了 message)
#### 5.8.7 自测
```bash
# 单测 reducer
pytest tests/state/ -v
# 完整回放:所有 replays/ 里的对局都能无损重放
python -m werewolftown.tools.replay_all_check --strict
# 并发压测(如果改了 race_gate)
python -m werewolftown.tools.race_stress --n 100 --key-dup-rate 0.3
```
#### 5.8.8 PR checklist
- [ ] 明确改动是 A/B/C 哪类(PR 描述前置声明)
- [ ] A 类:新增回归测试,全 replay 通过
- [ ] B 类:新字段有默认值,本手册第 3.2 节同步更新
- [ ] C 类:分成"加字段"→"切消费方"→"删旧字段"三个 PR
- [ ] 若涉及 race_gate:并发压测通过
- [ ] 若涉及 persistence:backward-compat 测试通过
---
## 第 6 章 — 通用编码规范
> 跨层、跨变更类型的共同底线。任何 PR 都要过。
### 6.1 Python 版本 & 依赖
- Python **3.11+**(`Dockerfile` 已升级;`list[int]`/`str | None` 等语法可用)
- 全 `from __future__ import annotations`
- 类型注解 100%(`mypy --strict` 能过);不放任 `# type: ignore` 通过 CI
### 6.2 命名规范
| 类型 | 规则 | 例 |
|---|---|---|
| 模块 | snake_case | `plot_value.py` |
| 类 | PascalCase | `BuyCandidate`, `Strategy` |
| 函数 | snake_case,动词开头 | `calc_cluster_gain`, `find_best_buy_candidate` |
| 常量 | UPPER_SNAKE_CASE | `FULL_COUNTS`, `CARD_POOL` |
| 私有函数 | `_snake_case` | `_reduce_deal_proposed` |
| 类型别名 | PascalCase | `Viewer = Literal["me", "opponent"]` |
**语义前缀**
- `calc_*` → 计算数值
- `find_*` → 查找候选
- `is_*` / `has_*` → 返回 bool
- `apply_*` → 有副作用(只允许在 L4 L5)
- `render_*` → 生成字符串
### 6.3 import 规范
**必须**
- 所有 import 放模块顶部,**不允许函数内 import**(历史巨坑,见第 10 章 P10-3)
- 顺序:标准库 → 第三方 → 本项目
- 一行一个 import
**唯一例外**
- `if TYPE_CHECKING:` 块内的循环 import 破解
- 明确的性能优化(罕见,且必须有注释解释)
**禁止**
- `from ..foo import *`
- 循环 import(用 `if TYPE_CHECKING:` 破除)
### 6.4 数据类规范
-`@dataclass(frozen=True)` 除非明确要求可变
-`frozenset` / `tuple` 代替 `set` / `list` 作为字段类型(避免共享可变引用)
-`Enum` / `Literal` 代替字符串字面量
### 6.5 副作用管理
**禁止在 L2/L3 出现**
- `import random` / `random.random()` → 用 `ctx.rng`
- `time.time()` → 用 `ctx.now`
- `os.getenv(...)` → 用 `ctx.cfg.<field>`
- `logger.info(...)` 用于记录决策 → 用 `ctx.decision_log`
原因:这些副作用让代码无法用同一 state 稳定重放。
### 6.6 日志规范
**必须结构化**
```python
ctx.decision_log.emit(
strategy_name=NAME,
stage="match",
result="HIT",
features={"gain": cand.gain, "net": cand.net},
)
```
不再用 `logger.warning("[eval] 代码兜底 ACCEPT: net=X")` 这种字符串日志。观察平台需要结构化数据。
### 6.7 错误处理
**只允许两种 except**
1. **业务边界处**(L5 endpoints):捕获所有异常,落成结构化 error_log,返回 SKIP
2. **明确的可恢复异常**(LLM 超时、JSON 解析失败):catch 指定异常类型,fallback 到确定性代码
**禁止**
- `except Exception: pass` / `except Exception: return None`(历史巨坑)
- 在业务函数里裸 `try/except`(应该让异常冒泡到 L5)
### 6.8 单文件行数上限
| 层 | 单文件建议 | 硬上限 |
|---|---|---|
| L2 策略 | ≤ 80 | 120 |
| L3 features 单模块 | ≤ 200 | 300 |
| L4 reducer | ≤ 300 | 400 |
| L5 endpoints | ≤ 150 | 200 |
| L1 LLM 模块 | ≤ 150 | 200 |
**超硬上限**:CI 报错,PR 打回。
### 6.9 注释规范
- 每个策略 / feature / event 类型的顶部必须有 docstring 说明 "做什么、什么时候用、来源"
- 复杂算法段前加一行注释说明"为什么这样算"
- **禁止**用注释残留旧代码("# 老版本本来是这样的...")——直接删
### 6.10 config / 参数使用
- 所有可调数字**必须**通过 `ctx.cfg` 访问
- 禁止 hardcode magic number
- 参数默认值放在 `config/strategy_stable.yaml`,代码里只做 `ctx.cfg.foo` 消费
### 6.11 时区 / 时间戳
- 事件时间戳一律 UTC(`time.time()`
- 展示时才转本地时区
- 不用 `datetime.now()`(无时区意识)
### 6.12 前置检查工具
**必装**
- `ruff`(linter + formatter,替代 flake8 + black)
- `mypy`(类型检查,`--strict`
- `pytest`(测试)
- `pytest-cov`(覆盖率,目标 ≥ 80%)
CI 里跑:
```bash
ruff check .
ruff format --check .
mypy --strict werewolftown/
pytest --cov=werewolftown --cov-fail-under=80
```
---
## 第 7 章 — 自测规范
> 三层测试金字塔:unit(快、多)→ features(中、覆盖数值正确性)→ replay(慢、覆盖真实场景)
### 7.1 测试目录结构
```
tests/
├── conftest.py # 通用 fixture:make_state, make_ctx, load_config
├── unit/ # 纯函数单测
│ ├── test_board_grid.py # 邻接表
│ ├── test_shop_rules.py # 收益表
│ ├── test_sanitize.py # 消毒
│ └── test_race_gate.py # 并发网关
├── features/ # Feature 层
│ ├── test_clusters.py
│ ├── test_card_value.py
│ ├── test_plot_value.py
│ ├── test_opponents.py
│ ├── test_pool.py
│ └── test_pricing.py
├── strategies/ # 策略层
│ ├── test_select_plots/
│ ├── test_plan_build/
│ ├── test_propose_trade/
│ │ ├── test_r4_leader_skip.py
│ │ ├── test_buy_adjacent_plot.py
│ │ └── ...
│ └── test_evaluate_trade/
├── state/ # State 层
│ ├── test_reducer.py
│ ├── test_persistence.py
│ └── test_replay_backward_compat.py
├── llm/ # LLM 层(用 mock client)
│ ├── test_arbitrator.py
│ ├── test_speech.py
│ └── test_vision.py
└── replay/ # 完整对局回放
├── fixtures/ # game_id 命名的 replay 数据
├── test_replay_smoke.py # 20 场 replay smoke test
├── test_replay_regression.py # 特定 game_id 的历史 bug 回归
└── test_strategy_coverage.py # 每条 KEEP 策略至少一场触发
```
### 7.2 三层测试的目的
| 层 | 目的 | 频率 | 时间目标 |
|---|---|---|---|
| unit | 验证纯函数正确性 | 每次改 L4/L1 | < 1s |
| features | 验证数值计算与游戏规则一致 | 每次改 L3 | < 10s |
| strategies | 验证策略触发条件与产出 | 每次改 L2 | < 30s |
| state | 验证 reducer 与 replay 兼容性 | 每次改 L4 | < 20s |
| llm | 验证 LLM 兜底与超时 | 每次改 L1 | < 15s |
| replay | 验证端到端 + 无回归 | 每次 PR 合入前 | < 5min |
**红线**:所有 pytest 加起来 < 6 分钟;超过就要抽样跑或并行化
### 7.3 关键 fixture
`tests/conftest.py` 提供 4 个核心 fixture:
```python
@pytest.fixture
def make_state():
"""构造 GameState 的便捷函数:make_state(my_plots=[1,2], round=2, ...)."""
...
@pytest.fixture
def make_ctx(make_state):
"""构造 DecisionContext:make_ctx(state=..., phase=..., rng_seed=42)."""
...
@pytest.fixture
def load_config():
"""context manager:with load_config('canary') as cfg: ctx = make_ctx(cfg=cfg)."""
...
@pytest.fixture
def replay_at():
"""从 replay 拉出某局某回合某 phase 的 ctx:replay_at('358890', round=2, phase='propose_trade')."""
...
```
**必须用 fixture,禁止在测试里手工构造 state**——保持一致性。
### 7.4 unit 测试规范
```python
def test_calc_cluster_revenue_full():
"""Happy(fc=4) 4 连满额 = 10 万。"""
assert calc_cluster_revenue(cluster_size=4, full_count=4) == 100_000
def test_calc_cluster_revenue_multi_full():
"""8 个连通 fc=2 卡 = 4 个满额 = 16 万。"""
assert calc_cluster_revenue(cluster_size=8, full_count=2) == 160_000
```
**规范**
- 一个测试测一个断言
- 用具体数字,不用 `> 0` 这种模糊断言
- 名字说明测的什么条件(`test_<函数>_<场景>`
### 7.5 features 测试规范
```python
def test_plot_value_adjacent_full_cluster(make_state):
"""邻接现有 Happy 2 连的 plot,我方买入可扩到满额 3 连。"""
state = make_state(
my_plots=[4, 5],
built_on={4: "Happy", 5: "Happy"},
shops=["Happy"],
round=2,
)
# plot 6 邻接 4,5,手牌有 Happy
v = plot_value(state, plot_id=6, viewer="me")
# 3 连满额 70k - 之前 2 连 30k = 40k,× 剩余 3 轮
assert v == 40_000 * 3
```
**规范**
- fixture 构造 state,一行一场景
- 断言写明"预期收益如何算出来的"(注释)
- 覆盖:正常 / 边界 / 空输入 / 极端值
### 7.6 strategies 测试规范
每个策略至少 4 类测试:
```python
class TestBuyAdjacentPlot:
def test_hit_normal(self, make_ctx):
"""R2 有邻接可买候选 → HIT。"""
ctx = make_ctx(round=2, my_plots=[4,5], has_adjacent_buy=True)
assert buy_adjacent_plot.match(ctx) == MatchResult.HIT
def test_miss_r4_leader(self, make_ctx):
"""R4 我方是领先者 → MISS,让给 R4LeaderSkip。"""
ctx = make_ctx(round=4, is_me_leader=True)
assert buy_adjacent_plot.match(ctx) == MatchResult.MISS
def test_miss_no_candidate(self, make_ctx):
"""没有邻接候选 → MISS。"""
ctx = make_ctx(round=2, my_plots=[])
assert buy_adjacent_plot.match(ctx) == MatchResult.MISS
def test_act_price_snap(self, make_ctx):
"""价格取整到 500。"""
ctx = make_ctx(round=2, buy_candidate_gain=20_100)
act = buy_adjacent_plot.act(ctx)
assert act.offer_cash % 500 == 0
```
### 7.7 replay 测试规范
**Replay smoke**
```python
@pytest.mark.parametrize("game_id", RECENT_20_GAMES)
def test_replay_no_crash(game_id):
"""任意场 replay 完整跑一遍不能崩。"""
events = load_events(f"replays/{game_id}/events.jsonl")
state = GameState.initial(player_id="子涵")
for event in events:
state = reduce(state, event)
# 最后一个 event 是 phase_result,state 里应有 4 个玩家现金记录
assert len(state.other_players) + 1 == 4
```
**Strategy coverage**
```python
@pytest.mark.parametrize("strategy_name", KEEP_STRATEGIES_LIST)
def test_strategy_touched_in_history(strategy_name):
"""每条 KEEP 策略在最近 50 场里至少被触发过 1 次。"""
hits = replay_scan(strategies=[strategy_name], games=recent_50_games())
assert hits > 0, f"{strategy_name} 从未被触发,可能是死代码"
```
**Regression**
```python
def test_bug_20260713_undefined_remaining():
"""Bug: 2026-07-13 room-358890 R2 抛 NameError('remaining')."""
ctx = replay_at("358890", round=2, phase="propose_trade")
resp = decide(ctx)
assert resp is not None
assert resp.action in {"initiate", "skip"}
```
### 7.8 覆盖率要求
- unit + features + state + llm 加权覆盖率 ≥ 80%(`pytest --cov-fail-under=80`
- 每条 KEEP 策略必须有至少 1 个专属测试
- 每次修 bug 必须有对应回归测试
- 覆盖率降低的 PR 打回
### 7.9 测试环境隔离
- 测试**永远**`strategy_dev.yaml`(不影响 stable/canary)
- LLM 用 mock:`tests/llm/mocks.py` 提供 `MockLLMClient`,返回预设 response
- 视觉:所有测试 `DISABLE_VISION=true`
- 每个测试独立 rng:`ctx = make_ctx(rng_seed=42)`
### 7.10 自测流程速查
改动后本地跑:
```bash
# 快速验证(约 30 秒)
pytest tests/unit tests/features tests/strategies -x --ff
# 完整验证(约 5 分钟)
pytest --cov=werewolftown --cov-fail-under=80
# 若改了策略/参数,加跑 replay
pytest tests/replay/ -v
```
PR 前**必须** 通过完整验证。
---
## 第 8 章 — 部署规范
> HF Space 单进程环境 + 后端 60s 超时约束下的最小可行 canary。
### 8.1 部署环境认知
- **平台**:Hugging Face Space (Docker)
- **限制**
- 单容器单进程;HF Space 一般给 2G 内存/2 CPU
- 重启会清空 `/tmp` 和进程内存(**因此 replay 必须持久化**
- HF 提供 `/data` 目录(挂载持久磁盘),重启不丢
- **能力**
- Space 有 API 秘钥(`API_KEY` / `VISION_API_KEY`
- 通过 `Dockerfile` 环境变量控制模型
- HF Space 每次 push 会自动 rebuild → 存在 build 期间旧版本仍在服务的窗口
- **不能**
- 多实例并行(想 A/B 只能进程内路由)
- 蓝绿部署(HF Space 单实例)
### 8.2 三档配置
```yaml
# config/strategy_stable.yaml — 生产
# config/strategy_canary.yaml — 灰度(1 场里概率路由 X%)
# config/strategy_dev.yaml — 本地实验
```
**运行时选择**
```python
# transport/endpoints.py
def _pick_profile(game_id: str) -> str:
canary_ratio = float(os.getenv("CANARY_RATIO", "0.0"))
if canary_ratio == 0.0:
return "stable"
# 用 game_id 哈希决定本局用哪个 profile;同一局全程一致
h = int(hashlib.md5(game_id.encode()).hexdigest(), 16) / 2**128
return "canary" if h < canary_ratio else "stable"
```
**关键性质****canary 路由按 game_id 粒度**,一局内绝不换 profile(否则 State 层参数中途改变,行为诡异)。
### 8.3 部署流程
**步骤**:
1. **代码合入主分支** → git push → HF Space 自动 rebuild
2. **rebuild 期间**(约 3-5 分钟):老版本仍在服务
3. **rebuild 完成后**:新代码上线,`CANARY_RATIO` 默认 `0.0`(所有对局走 stable)
4. **打开灰度**:在 HF Space Settings 里改环境变量 `CANARY_RATIO=0.1`(10% 对局走 canary)
5. **观察 24 小时**:看 canary_vs_stable 报告
6. **决策**:
- 若 canary 有正收益 → 提升 `CANARY_RATIO` 到 0.5、1.0
- 若 canary 有负收益 → 关闭 `CANARY_RATIO=0.0`,回滚代码
### 8.4 灰度策略
**参数改动**(走 5.3):
- 只改 `strategy_canary.yaml`,不动代码
- `CANARY_RATIO=0.1` 起步
- 观察 20+ 场对局
- 平均总现金差 > 0 且 P25 不劣化 → 参数从 canary 提升到 stable(改 stable.yaml)
**代码改动**(走 5.1/5.2/5.4/5.5/5.6/5.7/5.8):
- 用 `PROFILE_STRATEGIES` 环境变量分流:
```python
# transport/endpoints.py
def _register_strategies(profile: str):
if profile == "canary":
return canary_strategies # 含新策略
return stable_strategies # 老策略
```
- 参数与代码分流独立控制
- 观察窗口至少 20 场
### 8.5 回滚
**参数回滚**(分钟级):
- 直接把 canary.yaml 里的字段还原
- 或 `CANARY_RATIO=0.0`
**代码回滚**(半小时级):
- Git revert 到上一个 stable commit
- Push → HF rebuild
- rebuild 期间 canary 仍在跑(**这是隐患**,必要时同时 `CANARY_RATIO=0.0`)
**紧急回滚**(若 canary 引发系统性错误):
- 立即 `CANARY_RATIO=0.0`
- 观察 stable 是否恢复正常
- 排查 canary 问题
- 修好后重新灰度
### 8.6 部署前 checklist
- [ ] 所有测试通过(含 replay batch)
- [ ] canary_vs_stable 报告:平均总现金差 > 0 且 P25 不劣化
- [ ] 已在本地 `STRATEGY_PROFILE=canary` 跑过 10+ 场 replay
- [ ] PR 描述里有"预期胜率变化"和"关键观察指标"
- [ ] 老 replay 兼容性测试通过(如果动过 L4)
- [ ] Git tag 加一版号(如 `v2026.07.14.canary1`),便于回滚
### 8.7 部署后 checklist
- [ ] HF Space rebuild 成功(看构建日志)
- [ ] 前 5 局对局无 crash(看第 9 章观察规范)
- [ ] canary_vs_stable 数据在观测面板可看
- [ ] 若 24h 内出现严重问题 → 走"紧急回滚"
### 8.8 特殊场景
**Push 完发现有 bug 想快速回滚**:
- 立即 `CANARY_RATIO=0.0`
- 若 stable 也受影响(比如状态层 bug):立即 git revert push
**HF Space 冷启动**:
- 第一场对局会因 pip install 或 Docker 拉取慢
- 冷启动期出错的对局记录清晰,不算 canary 事故
**Space 反复重启**:
- 检查 `/data/replays/` 有没有累计过多(每局约 200KB)
- 定期清理 `/data/replays/` 中 30 天前的对局
---
## 第 9 章 — 观察规范
> 你之前的 pain point:改了策略但不知道有没有生效。这一章的目标是**任何改动上线后 24 小时内可以拿到量化答案**。
### 9.1 观察的三个层次
| 层 | 目的 | 输出 |
|---|---|---|
| L9.1 事件流原始记录 | 复现 / 回放 | `replays/<game_id>/events.jsonl` + `decisions.jsonl` |
| L9.2 结构化指标 | 快速判断"有没有生效"、"有没有坏" | `metrics/<date>/*.jsonl` |
| L9.3 对局报告 | 一局结束的自动复盘 | `reports/<game_id>.md` |
### 9.2 事件流持久化(L9.1)
**目录结构**:
```
/data/
└── replays/
└── <game_id>/
├── meta.json # 局 meta:player_id, players, start_ts, end_ts, profile
├── events.jsonl # perceive 事件流(一行一 event)
├── decisions.jsonl # interact 决策日志(一行一决策)
└── llm_calls.jsonl # LLM 调用记录(可选,脱敏后)
```
**写入时机**:
- `events.jsonl`:每次 perceive 事件到达,L4 reducer apply 前 append
- `decisions.jsonl`:每次 interact 返回前 append
- `meta.json`:`phase_start` 时创建,`phase_result` 时补 end_ts
**格式**:
```json
// events.jsonl 一行
{"kind":"deal_countered","ts":1720839200.12,"round":2,"deal_id":"deal_x1","source":"心怡",...}
// decisions.jsonl 一行
{"ts":1720839200.5,"round":2,"phase":"propose_trade","game_id":"358890",
"profile":"canary","strategy":"buy_adjacent_plot",
"match_result":"HIT","action":{"kind":"initiate","target":"心怡","offer_cash":15000,"demand_plots":[6]},
"features":{"gain":40000,"net":25000,"is_leader":false},
"elapsed_ms":250,"llm_used":false}
```
**必须**:
- `strategy` 字段是选中策略的 `NAME`
- `features` 是决策依据的关键数字(够重构复现)
- `profile` 记录了本局走 stable 还是 canary
- `llm_used` 记录了本次决策是否用到 LLM
### 9.3 结构化指标(L9.2)
每小时 cron 从 `replays/` 里聚合出:
```
/data/metrics/
└── 20260714/
├── strategy_hits.jsonl # 每策略每小时命中次数
├── strategy_action.jsonl # 每策略每小时产出的 Action 汇总(价格分布)
├── llm_calls.jsonl # LLM 调用次数 / 成功率 / 延迟
├── errors.jsonl # 错误 / 超时统计
└── canary_vs_stable.jsonl # canary/stable 分组的收益对比
```
`canary_vs_stable.jsonl` 是最重要的一份:
```json
{"hour":"2026-07-14T10","profile":"stable","games":5,"avg_cash":425000,"p25":380000,"p50":420000,"p75":465000}
{"hour":"2026-07-14T10","profile":"canary","games":3,"avg_cash":448000,"p25":410000,"p50":445000,"p75":490000}
```
### 9.4 对局报告(L9.3)
每局 `phase_result` 后自动生成 markdown 报告:
```markdown
# Game 358890 报告
- 玩家: 子涵 (第 2 名, $445k)
- Profile: canary
- 时长: 12min
- 最大集群: Happy 3 连(工位 4-5-6)
## 每回合决策
| R | Phase | 策略 | 结果 | 关键指标 |
|---|---|---|---|---|
| 1 | select_plots | enumerate_combos | 选 [4,5,7] | score=95000 |
| 1 | propose_trade | buy_adjacent_plot | 出 15k 买浩宇 6 号 | net=25000 |
| 1 | evaluate_trade | accept_on_net | ACCEPT 心怡卖 5 号 | net=8000 |
| ... |
## 异常
- R2 propose_trade: LLM 兜底触发 (2.3s)
- R3 evaluate_trade: 卡估值 net=-245000 (可能 bug)
```
**报告用来做什么**:
- 复盘本局所有决策
- 一眼看出策略是否被触发
- 异常段自动醒目
- 可以对着 PR 描述里的"预期决策"逐条验证
### 9.5 观察面板(/debug/*)
Web 面板(HF Space 上通过 `https://<space>/debug/` 访问)提供:
```
/debug/games # 最近 50 局列表
/debug/games/<game_id> # 单局报告(自动生成的 markdown)
/debug/strategies # 每策略近 7 日命中数柱状图
/debug/canary_report # 最近 24h canary vs stable 汇总
/debug/errors # 最近 100 条错误 + 超时
/debug/prompts # 最近 100 次 LLM 调用(现有的,增强分组)
```
### 9.6 一次改动的观察路径
**你改了什么** → **打开哪个视图 → 期望看到什么**:
| 改动类型 | 看这个视图 | 期望信号 |
|---|---|---|
| 5.1 加新策略 | `/debug/strategies` | 新策略 24h 内命中次数 > 0 |
| 5.2 改策略 | `/debug/strategies/<name>` | 命中次数变化 + Action 分布变化,符合 PR 预期 |
| 5.3 调参数 | `/debug/canary_report` | canary 平均现金 > stable |
| 5.4 修 bug | `/debug/errors` | 相关错误从计数消失 |
| 5.5 加 Feature | `/debug/games/<game_id>` | 相关策略的 `features` 字段里出现新指标 |
| 5.6 加 Event | `/debug/games/<game_id>` | events.jsonl 里出现新事件类型 |
| 5.7 改 LLM 参与 | `/debug/prompts` + `llm_calls.jsonl` | 调用次数 / 成功率符合预期 |
| 5.8 改基建 | `/debug/errors` + replay all check | 无新增 error;全 replay 无损重放 |
### 9.7 SLO(服务质量目标)
**这些红线,一旦触碰立即回滚**:
| 指标 | 阈值 | 数据源 |
|---|---|---|
| 决策异常率 | ≤ 1% | `errors.jsonl` |
| 决策超时率(>50s) | ≤ 0.5% | `decisions.jsonl` |
| LLM 兜底率 | ≤ 5% | `decisions.jsonl` 里 `llm_used=true` |
| 单局决策失败次数 | ≤ 5(后端会下线) | `decisions.jsonl` |
| 死代码策略(0 hit / 24h) | 0 | `strategy_hits.jsonl` |
| 上线 canary 后胜率下降 | ≤ 5% | `canary_vs_stable.jsonl` |
### 9.8 长时间趋势观察
**周报**:`reports/weekly/YYYY-Www.md`
```markdown
# 2026-W28 周报
- 总局数: 87
- 胜率: 34% (stable) / 41% (canary)
- ELO 变化: +23
- 触发最多的策略: buy_adjacent_plot (156 次)
- 从未触发的策略: sell_cheap_non_adjacent (0 次) → 需要考虑删除
- LLM 兜底次数: 12 / 87*7 = 2% 决策
- 新增 bug: 2 (已修)
- 最大遗憾: R2 358950 - 应该买卡但走了 SKIP
```
**从周报判断**:
- 死代码策略(0 触发)应该删或改条件
- 高 LLM 兜底率的场景应该抽取为策略
- 胜率没提升的策略应该重新审视
### 9.9 观察工具的实现
**不需要重写监控平台**——用最简单的方式:
- `metrics/*.jsonl` 用 cron/scheduled_tasks 每小时聚合
- `reports/*.md` 由 `phase_result` 事件触发生成
- `/debug/*` 用 FastAPI + Jinja 模板生成静态 HTML
- 数据不入数据库,都是 jsonl 文件
**保留手段**:老的 `hf_monitor_log.txt` cron 依然跑,但用途从"tail HF log"变成"tail decision_log 并跑异常检测"
---
## 第 10 章 — 常见坑与冲突协议
> 这一章是"历史事故档案",每一条都能追溯到 STRATEGY_V*.md 里一次具体翻车。**每次改动前**扫一眼,避免踩同样的坑。
### P10-1 状态多源头(agent 拿不到最新数据的根源)
**症状**:agent 决策时依据的 cash/plots/built_on 与实际不符
**历史事故**:V16-Fix7、V18-Fix4#1、Fix4#3、V17 §D6 全部在打补丁
**根因**:memory 变量、事件流重放、req 覆盖、纠正函数四个地方都能改状态
**规范**:**只 L4 State 层管状态**;L2/L3 只读;perceive 事件是唯一写入源
**判断**:如果你在策略里想写 `agent.memory.set_variable(...)` → 停下来,改本手册
### P10-2 视角混淆(净值反号)
**症状**:`_evaluate_deal_value` 算出 net = -245000(应为 +30000 之类的合理值)
**历史事故**:V18 §2 全部
**根因**:`_evaluate_deal_value` 期望"我方视角",但 Path A/B/C/F20 传参视角不一致
**规范**:**Offer dataclass 用 `to_me` / `from_me` 命名**,禁止再传 `offer_*/demand_*` 4 元组
**判断**:写策略时如果需要 "swap",说明抽象有问题
### P10-3 函数内 lazy import 隐藏 bug
**症状**:模块级 import 错了 3 周没人发现(`from ..tools.counter_deal import _validate_offer_against_assets` — 实际在 initiate_trade.py)
**历史事故**:V17 §D1
**根因**:`_try_code_only_eval` 函数体内 import 才会执行,import 错误被 `except Exception: return None` 静默吞
**规范**:**所有 import 放模块顶部**;ruff 应能扫出未使用 import;CI 强制 `mypy --strict`
**判断**:任何函数体内 `from ... import ...` → PR 打回,除非明确 comment 解释
### P10-4 `except Exception` 掩盖 bug
**症状**:某个策略实际从未生效,但日志只有 `WARNING [xxx] 代码兜底异常,降级LLM`
**历史事故**:V17 §D3(Fix4/Fix5/Fix6 定价改动从未执行过)
**规范**:**除 L5 边界 + 明确可恢复异常,禁止 catch Exception**
**判断**:`try/except: return None` 是重大 code smell
### P10-5 状态"降级模式"(黑天鹅制造机)
**症状**:某场对局 agent 从头到尾 SKIP 所有交易,全 REJECT 所有要约
**历史事故**:V14-Fix F6 引入 → V17 §D4 阉割 → 待修改文档要求彻底删
**根因**:一次误判(比如 R1 事件断层)触发 `degraded_mode=True`,整局静默失败
**规范**:**任何"整局静默改行为"的开关都禁止**;数据不全时应该明确暴露,让上层决定
**判断**:不设 kill switch;能算就算,算不了报错
### P10-6 事件去重键太宽
**症状**:某场 `deal_accepted` 事件到达两次但 settle_assets 只跑一次,第一次 crash 时资产未落账
**历史事故**:V14-Fix F7 引入的 `_seen_perceive_events`
**规范**:**用幂等 settle**(每个事件带唯一 id,reducer 里做幂等判断),不用去重集合
**判断**:如果需要"跳过已见事件",说明 reducer 不幂等
### P10-7 R4 决策清仓打架
**症状**:R4 一次决策同时被两个"清仓策略"处理
**历史事故**:V16-Fix2 F10 + V16 R12 步骤 4 未清理老代码
**规范**:**每个 phase 里同一 return path 只允许一个策略 HIT**;重叠策略必须合并
**判断**:定期扫 `_order.py`,寻找"逻辑重叠但没显式互斥"的策略对
### P10-8 fc>=5 卡的估值坑
**症状**:花 45k 买 3 张 Happy 卡,最后建了 1 张
**历史事故**:V18-Fix2 (`_compute_card_cluster_gain` 里没考虑 fc>=5)
**规范**:**所有卡估值路径统一走 `card_value(state, card, purpose)`**,一处判断 fc>=5
**判断**:如果代码里出现"if fc>=5",检查 6 处买卡路径是否都判了
### P10-9 built_on int/str key
**症状**:`state["built_on"][6]` KeyError,实际存的是 `"6"`
**历史事故**:JSON 序列化后 str key,运算需要 int key
**规范**:**State 层字段用 `dict[int, str]`**,所有输入立即转 int
**判断**:`get_built_on()` 这种"每次都转"的模式 = state 层没做对
### P10-10 阻塞的 asyncio + threading 混用
**症状**:`interact()` 在 uvicorn worker 里被阻塞
**历史事故**:`_async_interact` 里的 `loop.is_running()` 分支
**规范**:**全 async;用 async future 做竞速**,不用 threading.Event
**判断**:`import threading` 出现在 L5 之外的地方 → 打回
### P10-11 参数散落 200+ 处
**症状**:改一个 `buy_discount` 要 grep 全项目
**历史事故**:全项目通病
**规范**:**所有 magic number 挪到 `config/strategy_*.yaml`**;代码里只有 `ctx.cfg.xxx`
**判断**:`if round == 4: xxx = 12000` 出现在策略里 → 参数化
### P10-12 死代码策略
**症状**:某条策略从未被走到,但仍占用维护成本
**历史事故**:L4-31 F22 倒卖(已被 Fix3#5 移除但代码可能残留)
**规范**:**周报会自动扫出"0 hit / 7 天"的策略,必须删除或明确废弃**
**判断**:`test_strategy_touched_in_history` 会强制这条
### 冲突协议:两个改动同时命中
**场景**:本地 agent 正在写策略 A,PR 未合入;你要修 bug B,B 影响 A 依赖的 feature
**协议**:
1. **B 优先**:bug 修复不等策略
2. A 的 PR 需要 rebase 到 B 之后
3. A 的作者(本地 agent)需要重新跑 canary vs stable,并附新的报告到 PR
**场景**:你要加新 Feature X,本地 agent 已经在另一 PR 加了功能重叠的 Feature Y
**协议**:
1. 先合入其中一个
2. 另一个 rebase 后,把重叠部分改为直接调用已合入的 Feature
3. **禁止 X 和 Y 都保留**——两个 feature 做同一件事必然会漂移
---
## 第 11 章 — 术语与命名约定
### 11.1 领域术语
| 术语 | 定义 | 备注 |
|---|---|---|
| **State** | 一局对局的完整数据快照 | L4 内的 `GameState` |
| **Event** | perceive 收到的一条消息 | 引擎推送 → 落到 events.jsonl |
| **Action** | interact 要返回的一次决策 | L2 策略的输出 |
| **Reducer** | `(state, event) → new_state` 纯函数 | 唯一改状态的地方 |
| **Feature** | 从 state 派生的数值指标 | L3 纯函数 |
| **Strategy** | 决策单元 | L2 一个文件 |
| **DecisionContext** | interact 一次决策的只读上下文 | 含 state + features + rng + cfg |
| **MatchResult** | 策略是否要接管本次决策 | HIT / MISS / UNSURE |
| **Profile** | 一份完整参数配置 | stable / canary / dev |
| **Offer** | 一次交易报价的双方视角 | `to_me` / `from_me` |
| **PendingLedger** | 追踪本回合已卖/挂起/取消 | 合并旧 pending_proposals 等 |
| **Speech Hint** | 话术生成模板名 | `"buy_plot"`, `"sell_card"`, ... |
### 11.2 游戏规则术语(不改,直接沿用引擎)
- `plot` = 工位(1-52)
- `shop` = 业务("淘小宝"/"猫天天"等 8 种)
- `built_on` = {plot_id: shop_type},物理事实
- `cluster` = 同类连通集群
- `full_count` (`fc`) = 满额数 (2/3/4/5)
- `round` = 回合 (1-4)
- `quota` = 配额(选地保留数 / 交易次数 / 还价次数)
- `deal_id` = 交易 ID (`deal_xxx``free_xxx`)
- `phase` = 阶段(SELECT_PLOTS / PROPOSE_TRADE 等)
### 11.3 视角约定
**在代码里**永远:
- `me` / `opponent` 而不是 `self` / `other` / `owner`
- `to_me` / `from_me` 而不是 `offer_*` / `demand_*`
- `viewer=me``viewer=opponent` 参数化
**在 Prompt 里**(跟引擎对齐):
- 保留 `offer_*` / `demand_*`,但只在 L1 内使用;L2/L3 一律 to_me/from_me
### 11.4 文件命名约定
| 类型 | 规则 |
|---|---|
| 策略文件 | `snake_case.py`,动词/结果描述 (`buy_adjacent_plot.py`, `reject_bad_swap.py`) |
| Feature 模块 | `snake_case.py`,主体名词 (`plot_value.py`, `opponents.py`) |
| Reducer case | `_reduce_<event_name>()` |
| Test 文件 | `test_<被测模块>.py` |
| Bug 回归测试 | `test_bug_YYYYMMDD_<short_desc>()` |
| Config 文件 | `strategy_<profile_name>.yaml` |
| Replay 目录 | `replays/<game_id>/` |
| Report 文件 | `reports/games/<game_id>.md`, `reports/weekly/YYYY-Www.md` |
---
## 第 12 章 — PR 变更 Checklist(贴在每次 PR 里)
**贴到 PR 描述最上方,逐项打勾。未打勾的项必须解释理由。**
### 12.1 通用(所有 PR 必过)
- [ ] 明确变更类型(对应 5.1–5.8 哪一节)
- [ ] 顶部 docstring 说清"这次改了什么,为什么"
- [ ] `ruff check .` 通过
- [ ] `ruff format --check .` 通过
- [ ] `mypy --strict werewolftown/` 通过
- [ ] `pytest --cov=werewolftown --cov-fail-under=80` 通过
- [ ] 无函数内 `import`(P10-3)
- [ ] 无 `except Exception: pass/return None`(P10-4)
- [ ] 无硬编码 magic number(P10-11)
- [ ] 单文件行数在硬上限之内(第 6.8 节)
- [ ] Git commit 有清晰 message + Signed-off-by
### 12.2 加/改策略专用(5.1、5.2)
- [ ] 策略文件 ≤ 120 行
- [ ] `NAME` / `PHASE` / `PRIORITY` 三常量齐全
- [ ] `_order.py` 已注册 + priority 与邻居不冲突
- [ ] 顶部注释写清"来源"(inventory 编号或 game_id)
- [ ] `tests/strategies/` 有针对性单测(HIT/MISS/act 覆盖)
- [ ] `tests/replay/` 至少 1 场真实对局证明触发
- [ ] (改策略时)`replay_diff` 报告附在 PR 描述
- [ ] (改策略时)"改动记录" 更新到顶部注释
### 12.3 参数调整专用(5.3)
- [ ] 只改 `config/strategy_canary.yaml`(不动 stable,不动代码)
- [ ] `changelog:` 段有条目(字段/from/to/reason/author)
- [ ] 20 场 replay 的 canary vs stable 报告附 PR
- [ ] 平均总现金差 > 0 且 P25 不劣化
- [ ] 有对照单测证明"仅参数变化,非逻辑变化"
### 12.4 修 bug 专用(5.4)
- [ ] `tests/` 有以 game_id 命名的回归测试 `test_bug_YYYYMMDD_...`
- [ ] 测试在修前 fail、修后 pass
- [ ] 修改范围最小(无夹带重构)
- [ ] Last 20 replay batch 无回归
- [ ] PR 描述:"bug 潜伏时间 / 发现时间 / 潜伏原因"
### 12.5 加 Feature 专用(5.5)
- [ ] Feature 放在合适的模块(按 5.5.2 判断)
- [ ] 输入 state,输出 dataclass/原始类型,无副作用
- [ ] docstring 说清输入输出语义
- [ ] 类型注解完整
- [ ] `tests/features/test_<module>.py` 覆盖 ≥ 3 case
- [ ] 若用 `@feature_cache`,验证过缓存失效
### 12.6 加 Event 专用(5.6)
- [ ] `state/events.py` 定义新事件类,`frozen=True`
- [ ] `state/reducer.py` 加对应 case
- [ ] `transport/endpoints.py` 加 req → event 解析
- [ ] `state/persistence.py:_EVENT_TYPES` 注册新类型
- [ ] `tests/state/` 有 reducer 测试 + backward-compat 测试
- [ ] 旧 replay 仍能加载
### 12.7 LLM 参与改动专用(5.7)
- [ ] 明确模式升降(M2↔M3 等)
- [ ] Prompt 通过 pydantic schema 约束输出
- [ ] LLM 失败 fallback 是纯代码
- [ ] 超时保护 ≤ 4s
- [ ] 单测:正常返回、超时、格式错误、无效值 四种
### 12.8 基建 / 状态层改动专用(5.8)
- [ ] 明确 A/B/C 类
- [ ] A 类:回归测试 + 全 replay 通过
- [ ] B 类:新字段有默认值 + 本手册 3.2 节更新
- [ ] C 类:分三个 PR(加字段 / 切消费方 / 删旧字段)
- [ ] race_gate 改动:并发压测通过
- [ ] persistence 改动:backward-compat 通过
### 12.9 部署前必查
- [ ] canary_vs_stable 报告:均值 > 0,P25 不劣化
- [ ] 本地 STRATEGY_PROFILE=canary 跑过 10+ 场 replay
- [ ] Git tag 加版号
- [ ] PR 描述:"预期胜率变化" + "关键观察指标"
### 12.10 部署后必看(24h 内)
- [ ] `/debug/errors` 无新增错误
- [ ] `/debug/strategies` 新策略命中 > 0
- [ ] `/debug/canary_report` 数据符合预期
- [ ] SLO 未触碰(第 9.7 节)
---
## CHANGELOG
### v1.0.0 — 2026-07-13
- 首版发布
- 建立五层架构(L1–L5)与 8 类变更规范(5.1–5.8)
- 覆盖:编码规范 / 自测 / 部署 / 观察 / 常见坑 / PR checklist
- 配套文档:`20260713_strategy_inventory.md`(136 条策略台账)
### 未来版本规则
- **patch**(1.0.x):勘误、示例更新、trivia
- **minor**(1.x.0):新增变更类型、新观察规范、新工具集成
- **major**(x.0.0):架构变化(如加新层)、契约破坏性变更
### 加条 CHANGELOG 的格式
```markdown
### v1.1.0 — 2026-08-01
- 加入第 5.9 节:多语言 message 支持
- 更新 6.11 节:时区处理示例
- 修正 5.3.2 里参数结构示意的错别字(fc4 → fc_4)
```