diff --git a/AGENTS.md b/AGENTS.md index 33097a4..838ebc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ | Serena 记忆库、同步状态与引用对齐检查 | [`docs/agents/serena.md`](docs/agents/serena.md) | 需要操作 `serena` 工具链、管理或修改 `mem:*` 格式的记忆文件。 | | 测试策略、回归命令、补测约定、手工验收清单 | [`docs/agents/testing.md`](docs/agents/testing.md) | 选择验证命令、补充 Vitest 回归、更新遍历基线、撰写 PR 验证说明,或做浏览器手工验收时。 | | 记牌器当前实现、历史设计、重构方案与领域验证清单 | [`docs/agents/card_tracker.md`](docs/agents/card_tracker.md) | 继续推进记牌器能力演进、排查协议同步异常、理解旧链表/Seats 方案或完善领域单测时。 | +| 协议样例与适配说明索引 | [`docs/protocols/README.md`](docs/protocols/README.md) | 按 className / SpellID / 通用模式定位 `docs/protocols/` 专页时。 | | 应用全局生命周期、页面与 UI 框架注入流程、记牌器 Room/View 挂载与对局运行周期 | [`docs/agents/lifecycle.md`](docs/agents/lifecycle.md) | 需要理清小抄初始化与销毁时序、了解 Room 创建与 View 挂载机制、或排查消息分发链路时。 | | 终端执行、跨 Shell 命令与避坑指南 | [`docs/agents/commands.md`](docs/agents/commands.md) | 需要执行构建、校验、文本检索或文件操作命令时。 | diff --git a/docs/agents/card_tracker.md b/docs/agents/card_tracker.md index b3f6aef..c2c32ed 100644 --- a/docs/agents/card_tracker.md +++ b/docs/agents/card_tracker.md @@ -177,9 +177,13 @@ --- +## 协议文档索引 + +- 总入口:`docs/protocols/README.md`(按消息 className / SpellID / 通用模式定位专页)。 + ## 整手牌交换(通用协议模式) -- 协议文档:`docs/protocols/PubGsCMoveCard-spell-121-hand-exchange.md`(以技能 121 为完整示例)。 +- 协议文档:`docs/protocols/hand-exchange.md`(以技能 121 为完整示例)。 - 装饰器:`src/tracker/skill/HandExchange.ts`,经 `decorateGenericMove`(`*`)统一接入,不绑定单一 SpellID。 - 识别门槛:`MoveType=11` + `5<->10` + 整手张数;允许己方整手正 `CardIDs`,避免误伤佐练/诫厉等非整手路径。 - 手牌进 `exchange` 时按 `SpellID + FromID` 登记整批实体;同座位嵌套交换使用后进先出的批次栈,明确空手时也登记零张屏障批次;回手时 `FromID` 是原持有者批次键,目标座位看 `ToID`。 @@ -188,9 +192,21 @@ - 暗实体占位仍随物理批次移动;回到己方并由正 ID 揭示时,真实身份若尚在其它公共区,使用 exchange 暗实体回填原槽位后再把真实身份移入手牌,避免占位残留或重复计数。 - 明牌回填 `cardIDs`,暗实体回填 `sourceCards`;明暗混合批次不共用 `combinationID`。 +## 诫厉观看与交换区暂存(SpellID=3483) + +- 协议文档:`docs/protocols/GsCRoleOptTargetNtf-3483.md`。 +- 观看阶段 `Params` 布局与观虚同类:`[pileCount, handCount, ...pileTop, ...handPartial]`;手牌片段默认是部分手牌,仅当 `handCount` 恰好等于目标整手数时 `fullHand`。 +- 观看/同区展示的牌堆序列是 **top-first**(例:`[81, 99, 124, 4]`,`81` 为顶);后续交换 `CardIDs` 可能整段逆序或混合重排(例进交换区 `[4, 124, 99, 81]`),不能跨消息沿用“第一项=牌顶”。 +- 配对 `PubGsCMoveCard` 为牌堆同区展示(`FromZone=ToZone=1`、`MoveType=21`、两端 `255`);`CardIDs` 即牌堆顶 top-first 序列。 +- 目标通知主动路径:`handleRoleOptTargetNtf` 在 `Param == 1` 时写入 `expectedPileCount`,并同步牌堆顶与目标手牌片段。回归见 `tests/tracker/roleOptTargetNtf.test.ts`。 +- 后续交换序列已文档化:`1->10`(牌堆)+ `5->10`(部分手牌)后拆回 `10->1` / `10->5`;旧 `decorateJieLi` **暂不挂上**,默认走通用移动路径。 +- `PILE_SAME_ZONE_SHOW_SPELL_IDS` **不需要**仅为 `3483` 扩展;该白名单只修正权变/观虚的 RANDOM 端点。诫厉应先判断消息本身是否已明确为同区展示。 +- 不走整手交换账本:`HandExchange` 识别门槛会排除诫厉的非整手、回牌堆路径。 + ## 已知未完成项 - 尚未完整恢复旧版 `cardManager.pack()` 链表推理承载的所有不确定性语义;宴戏、权变、诫厉等技能仍需要用新版 `ConstraintGroup` 做进一步精细化。 +- 诫厉观看阶段目标通知已同步牌堆顶与手牌片段;仍缺:交换默认路径实测/回归,以及按实战序列重写交换装饰(旧 `decorateJieLi` 暂不挂)。 - 主动运行路径不再依赖 `cardManager.findKZ()`;遗留文件中残留的旧 `cardManager` / `Zone` 引用需要后续清理或删除。 - 技能处理器目前仍是偏单牌回调,可能需要向批量拦截器演进。 - 已有 `pnpm test:tracker` 的 Node/Vitest 回归覆盖导入边界、Controller、位置候选、公共候选、位置索引、暗置标记、脏渲染与遍历基线等;仍需补齐更多 `Room.moveCards()` 组合路线与浏览器运行时验证。 diff --git a/docs/protocols/GsCRoleOptTargetNtf-3483.md b/docs/protocols/GsCRoleOptTargetNtf-3483.md new file mode 100644 index 0000000..3cbb5f6 --- /dev/null +++ b/docs/protocols/GsCRoleOptTargetNtf-3483.md @@ -0,0 +1,266 @@ +# `GsCRoleOptTargetNtf` / `PubGsCMoveCard`:诫厉观看牌堆顶与目标部分手牌 + +## 消息用途 + +族钟繇发动诫厉(`SpellID = 3483`)时,会收到一组配对消息: + +1. `GsCRoleOptTargetNtf` 同时提供本次看到的牌堆顶卡牌 ID,以及目标座位的**部分**手牌明牌 ID。 +2. `PubGsCMoveCard` 描述牌堆顶卡牌在牌堆内的同区展示,不代表真实移动或随机洗入牌堆。 + +后续还有牌堆/手牌经交换区再分别回牌堆与回手牌的 `MoveType=11` 序列。 +该序列协议语义见下文;`decorateJieLi` **暂不挂上主动路径**,因为旧装饰假设与已观测实战序列不一致。 + +## 目标通知 + +```text +className: "GsCRoleOptTargetNtf" +SpellID: 3483 +Type: 28 +Param: 1 +SeatID: 3 +SrcSeatID: 3 +targetSeatID: 4 +Timeout: 30 +Params: [4, 2, 81, 99, 124, 4, 91, 158] +``` + +| 字段 | 示例 | 含义 | +| -------------- | --------------------------------: | ------------------------------------------ | +| `SpellID` | `3483` | 诫厉 | +| `Type` | `28` | 选择目标/区域的技能通知 | +| `Param` | `1` | 当前适配阶段;本技能在 `Param == 1` 时处理 | +| `SrcSeatID` | `3` | 发动技能的座位 | +| `targetSeatID` | `4` | 被查看手牌的目标座位 | +| `Params` | `[4, 2, 81, 99, 124, 4, 91, 158]` | 见下方拆分规则 | + +`Params` 布局: + +```text +[pileCount, handCount, ...pileTopCardIDs, ...handCardIDs] +``` + +本例中: + +- `pileCount = 4`,牌堆顶为 `[81, 99, 124, 4]` +- `handCount = 2`,目标座位 `4` 的手牌明牌片段为 `[91, 158]` +- 牌堆顶 `Params` / 同区展示 `CardIDs` 均为 **top-first**:第一项 `81` 是最顶牌,向内依次为 `99`、`124`、`4` +- **手牌片段是目标座位的部分手牌,不一定覆盖其全部手牌**;仅当本地整手数恰好等于 `handCount` 时才升级为 `fullHand` + +当 `Param == 1` 时,当前主动适配: + +1. 若 `Params[0] > 0`,把 `expectedPileCount = pileCount` 写入 `Room.getSkillState(3483)`,供后续牌堆进交换区时的局部分组使用。 +2. 若 `Params.length > 2` 且 `pileCount > 0`,将 `Params.slice(2, 2 + pileCount)` 同步为牌堆顶明牌。 +3. 若 `handCount > 0` 且 `targetSeatID` 不是公共占位座位 `255`,将 + `Params.slice(2 + pileCount, 2 + pileCount + handCount)` 同步为目标座位手牌明牌: + - 默认按**部分手牌**处理(不带 `fullHand`)。 + - 若本地已观测手牌数等于 `handCount`,或无观测时本地手牌实体数等于 `handCount`,则按整手 + `fullHand: true` 同步,并写回该座位的观测手牌数。 + +说明: + +- 同时携带牌堆顶与手牌片段时,目标通知会同步两者。 +- 协议字面仍是“目标手牌片段”;只有在本地事实表明张数恰好覆盖整手时,才升级为 `fullHand`。 +- 仅有牌堆张数(例如 `Params = [4]`)时只写 `expectedPileCount`,不调用明牌同步。 + +## 同区展示 + +```text +CardCount: 4 +CardIDs: [81, 99, 124, 4] +FromID: 255 +FromZone: 1 +FromZoneParam: 0 +MoveType: 21 +SpellID: 3483 +ToID: 255 +ToZone: 1 +ToZoneParam: 0 +``` + +`FromZone = 1`、`ToZone = 1` 且两端 ID 都为 `255`,说明来源和目标都是牌堆。 +结合 `SpellID = 3483` 与 `MoveType = 21`,该消息表示原地展示牌堆顶卡牌: + +- `CardIDs` 不应被当作随机位置的卡牌。 +- 不应把卡牌从牌堆移出后再随机放回。 +- 若协议已明确两端同区且位置一致,归一化会直接识别为同区展示;**不必**仅为 `3483` 强行加入 + `PILE_SAME_ZONE_SHOW_SPELL_IDS`。该白名单只服务于权变/观虚这类“协议位置字段为 RANDOM、需要强制改成牌顶”的场景。 + +当前实现注意: + +- `PILE_SAME_ZONE_SHOW_SPELL_IDS = [7011, 987, 988]` 不纳入 `3483` 是预期策略,不是缺口。 +- 先判断当前 3483 观看消息本身是否已足够明确;只有在实战里也出现 RANDOM 端点、导致无法识别同区展示时,才再考虑扩展白名单。 + +## 与观虚 / 权变的差异 + +| 项目 | 权变 `7011` | 观虚 `987` / `988` | 诫厉 `3483` | +| ------------ | --------------------------------- | ------------------------------------- | ------------------------------------------------------------ | +| 目标通知内容 | 仅牌堆顶 `Params` | `Params` 同时含牌堆顶与目标手牌 | `Params` 同时含牌堆顶与目标手牌 | +| 手牌范围 | 无 | 目标手牌片段(按 `handCount`) | 目标**部分**手牌;`handCount` 不必等于目标全部手牌数 | +| 目标座位 | `targetSeatID = 255` 表示公共牌堆 | `targetSeatID` 是被查看手牌的玩家座位 | 同观虚;`targetSeatID` 是被查看手牌的玩家座位 | +| 移动消息 | 牌堆同区展示 | 牌堆同区展示;手牌由目标通知揭开 | 观看阶段同样是牌堆同区展示;之后还有交换区暂存/回牌堆序列 | +| 后续移动 | 无本技能特化暂存链 | 无本技能特化暂存链 | 牌堆与目标部分手牌进 `exchange(10)`,再分别回牌堆/回目标手牌 | + +## 后续交换序列(已观测实战) + +观看阶段之后,同一 `SpellID=3483` 会继续发出 4 条 `MoveType=11` 交换消息。 +本例承接上文:发动者座位 `3`,目标座位 `4`,观看得到的牌堆顶为 +`[81, 99, 124, 4]`,目标部分手牌为 `[91, 158]`。 + +### 1. 牌堆顶进入交换区 + +```text +CardCount: 4 +CardIDs: [4, 124, 99, 81] +FromID: 255 +FromZone: 1 +FromZoneParam: 0 +MoveType: 11 +SpellID: 3483 +ToID: 3 +ToZone: 10 +ToZoneParam: 0 +``` + +| 字段 | 含义 | +| --------------------------- | ------------------------------------------------------------------------- | +| `FromZone=1` / `FromID=255` | 来自公共牌堆 | +| `ToZone=10` / `ToID=3` | 进入交换区;`ToID` 是发动者座位,用作交换批次归属,不是牌堆 ID | +| `CardIDs=[4, 124, 99, 81]` | 与观看 top-first `[81, 99, 124, 4]` 为**整段逆序**;第一项不再是牌顶 `81` | +| `MoveType=11` | 交换路径,不是同区展示 | + +顺序提醒: + +- 观看/同区展示:`[81, 99, 124, 4]` → `81` 在牌堆顶 +- 本条进交换区:`[4, 124, 99, 81]` → 同一集合,数组方向相反 +- 后续交换消息的 `CardIDs` **不能**再按观看 top-first 解读;应把每条消息的数组顺序视为该次移动自身的协议顺序 + +### 2. 目标部分手牌进入交换区 + +```text +CardCount: 2 +CardIDs: [91, 158] +FromID: 4 +FromZone: 5 +FromZoneParam: 0 +MoveType: 11 +SpellID: 3483 +ToID: 3 +ToZone: 10 +ToZoneParam: 0 +``` + +| 字段 | 含义 | +| ------------------------- | ---------------------------------------------- | +| `FromZone=5` / `FromID=4` | 来自目标座位 `4` 的手牌 | +| `ToZone=10` / `ToID=3` | 同样进入发动者归属的交换区批次 | +| `CardIDs=[91, 158]` | 仅目标**部分**手牌;张数不必等于目标全部手牌 | +| `CardCount=2` | 非整手,因此不能被 `HandExchange` 整手账本接管 | + +此时交换区共 6 张:原牌堆 4 + 原目标手牌 2。 + +### 3. 交换区回牌堆 + +```text +CardCount: 4 +CardIDs: [158, 91, 99, 81] +FromID: 3 +FromZone: 10 +FromZoneParam: 0 +MoveType: 11 +SpellID: 3483 +ToID: 255 +ToZone: 1 +ToZoneParam: 0 +``` + +| 字段 | 含义 | +| --------------------------- | -------------------------------------------------------------------------- | +| `FromZone=10` / `FromID=3` | 从发动者归属的交换区批次取出 | +| `ToZone=1` / `ToID=255` | 回到公共牌堆 | +| `CardIDs=[158, 91, 99, 81]` | **混合来源**:`158/91` 原属目标手牌,`99/81` 原属牌堆顶;协议直接给出正 ID | + +### 4. 交换区回目标手牌 + +```text +CardCount: 2 +CardIDs: [124, 4] +FromID: 3 +FromZone: 10 +FromZoneParam: 0 +MoveType: 11 +SpellID: 3483 +ToID: 4 +ToZone: 5 +ToZoneParam: 0 +``` + +| 字段 | 含义 | +| -------------------------- | ------------------------------------ | +| `FromZone=10` / `FromID=3` | 仍从发动者交换区批次取出 | +| `ToZone=5` / `ToID=4` | 回到目标座位 `4` 手牌 | +| `CardIDs=[124, 4]` | 原牌堆顶中的 2 张;协议直接给出正 ID | + +### 张数守恒与结果 + +```text +观看:pileTop=[81, 99, 124, 4], handPartial=[91, 158] +回牌堆 4 张: [158, 91, 99, 81] +回手牌 2 张: [124, 4] +合计 6 = 4 + 2 +``` + +本例结果可理解为: + +- 目标手牌 `91/158` 与牌堆 `99/81` 进入牌堆 +- 牌堆 `124/4` 进入目标手牌 +- 发动者座位 `3` 不持有这批牌,只作为交换区 `ToID/FromID` 批次键 + +### 协议推理要点 + +1. 四条消息都是 `MoveType=11`,且本样例 **全部给出正 `CardIDs`**,不是全暗路径。 +2. 进交换区时 `ToID`、出交换区时 `FromID` 都是发动者座位;不能把 `FromZone=10` 的 `FromID` 当玩家来源座位。 +3. 回出方向是**拆分**的:一部分 `10 -> 1`,一部分 `10 -> 5`;不是“暂存手牌整批回牌堆”。 +4. 回牌堆/回手牌的 `CardIDs` 都是混合重排后的选择结果,不能假设仍按进区批次原样返回。 +5. **数组顺序会翻转或重排,不能跨消息复用“第一项=牌顶”假设**: + - 观看/同区展示:`[81, 99, 124, 4]` 是 top-first,`81` 为牌顶 + - 牌堆进交换区:`[4, 124, 99, 81]` 是同一 4 张的逆序 + - 回牌堆:`[158, 91, 99, 81]` 已混入手牌来源,既非原 top-first,也非单纯逆序 + - 回手牌:`[124, 4]` 是原牌堆子集,顺序也不再绑定观看数组下标 +6. 因此后续交换适配必须: + - 用正 ID 集合做身份追踪 + - 对每条 `PubGsCMoveCard` 单独解释其 `CardIDs` 顺序 + - 不要把观看阶段数组直接 reverse 后当成回牌堆/回手牌结果 + +## 与旧 `decorateJieLi` 的差异(暂不挂上) + +历史装饰器 `src/tracker/skill/JieLi.ts` 仍保留,但**不要**在 +`registerDefaultMoveEventHandlers()` 中挂上。它与本实战序列至少有这些冲突: + +| 旧装饰假设 | 已观测实战 | +| ---------------------------------------------------------- | --------------------------------------------------- | +| 牌堆进交换区依赖 `FromPosition=RANDOM`,且常按全暗处理 | 本序列直接给正 `CardIDs`,且未见依赖 RANDOM 分支 | +| 手牌进交换区后主要等待“全暗回牌堆”,用 `stagedCards` 补 ID | 回牌堆/回手牌都已给正 ID;回牌堆还是手牌+牌堆混合集 | +| 只特化 `10 -> 1` 回牌堆 | 实战还有 `10 -> 5` 回目标手牌 | +| 把暂存理解成“手牌批次原样回放” | 发动者在交换区完成重选/重排后拆回两个目标区 | + +因此当前策略: + +1. **观看阶段**目标通知继续由 `handleRoleOptTargetNtf` 同步牌堆顶与部分手牌,并写 `expectedPileCount`。 +2. **交换阶段**暂走默认 `PubGsCMoveCard` / `Room.moveCards` 路径,不启用 `decorateJieLi`。 +3. 整手交换通用账本(`HandExchange`)继续排除诫厉:识别门槛要求 `5<->10` 且整手张数;诫厉是部分手牌,且还有 `1<->10`。 +4. 待默认路径验证清楚后,再单独设计诫厉交换装饰,而不是直接复活旧 `decorateJieLi`。 + +## 代码位置 + +- 目标通知与部分手牌明牌:`src/handler/GsCRoleOptTargetNtf.js`(`SpellID = 3483`) +- 牌堆同区展示端点归一化:`src/handler/PubGsCMoveCard.js` 的 `normalizeMovePosition` / `PILE_SAME_ZONE_SHOW_SPELL_IDS` +- 同区展示识别:`src/tracker/MoveEventNormalizer.ts` 的 `isSameZoneShowEvent` +- 历史交换装饰(未挂载):`src/tracker/skill/JieLi.ts` +- 移动装饰注册:`src/tracker/runtime/moveEventHandlers.ts` 的 `registerDefaultMoveEventHandlers` +- 相关排除说明:`docs/protocols/hand-exchange.md`(诫厉不走整手交换账本) + +## 已知适配缺口 + +1. 交换阶段默认路径是否稳定处理 `1->10`、部分手牌 `5->10`、混合 `10->1` / `10->5` 仍需实测与回归。 +2. 旧 `decorateJieLi` 与实战序列不匹配,**暂不挂上**;需要按本文件重写或替换。 +3. 边缘约束仍待用新版 `ConstraintGroup` 精细化;见 `docs/agents/card_tracker.md` 未完成项。 diff --git a/docs/protocols/GsCRoleOptTargetNtf-7011.md b/docs/protocols/GsCRoleOptTargetNtf-7011.md index 0cd24fc..ef4cb19 100644 --- a/docs/protocols/GsCRoleOptTargetNtf-7011.md +++ b/docs/protocols/GsCRoleOptTargetNtf-7011.md @@ -56,6 +56,11 @@ ToZoneParam: 0 - 不应把卡牌从牌堆移出后再随机放回。 - 预处理会把 `FromPosition` 和 `ToPosition` 都归一为牌顶,使后续归一化识别为同区展示并跳过实体重排。 +## 相关技能 + +- 观虚:docs/protocols/GsCRoleOptTargetNtf-987.md +- 诫厉:docs/protocols/GsCRoleOptTargetNtf-3483.md(Params 含牌堆顶与目标部分手牌,且后续有交换区暂存) + ## 代码位置 - 目标通知与牌堆明牌:`src/handler/GsCRoleOptTargetNtf.js` diff --git a/docs/protocols/GsCRoleOptTargetNtf-987.md b/docs/protocols/GsCRoleOptTargetNtf-987.md index 9ee2c40..e7fac5c 100644 --- a/docs/protocols/GsCRoleOptTargetNtf-987.md +++ b/docs/protocols/GsCRoleOptTargetNtf-987.md @@ -89,6 +89,11 @@ ToZoneParam: 0 这样观看 5 张后,牌堆展示仍应是“5 明 + 剩余暗”,而不是多出一张。 +## 相关技能 + +- 权变:docs/protocols/GsCRoleOptTargetNtf-7011.md +- 诫厉:docs/protocols/GsCRoleOptTargetNtf-3483.md(同类 Params 布局,但手牌片段是部分手牌,且后续还有交换区暂存链) + ## 代码位置 - 目标通知与牌堆/手牌明牌:`src/handler/GsCRoleOptTargetNtf.js` diff --git a/docs/protocols/README.md b/docs/protocols/README.md new file mode 100644 index 0000000..654782e --- /dev/null +++ b/docs/protocols/README.md @@ -0,0 +1,54 @@ +# 协议文档索引 + +> 定位记牌器相关协议样例与适配说明时,从本页跳转。只写路径与符号名,不绑源码行号。 + +## 怎么找 + +1. 先按**消息 className** 找大类。 +2. 同一 className 下再按 **SpellID / 场景** 找专页。 +3. 通用协议模式(不绑单一技能)单独放在“通用模式”。 + +## 通用模式 + +| 文档 | 协议 / 模式 | 场景 | 关键识别 | +| --- | --- | --- | --- | +| [`hand-exchange.md`](hand-exchange.md) | `PubGsCMoveCard` 整手交换 | 双方整手牌经 `exchange(10)` 互易;示例 SpellID=`121` | `MoveType=11` + `5<->10` + 整手张数;不绑单一 SpellID | + +## `GsCRoleOptTargetNtf` + +| 文档 | SpellID | 场景 | 关键识别 | +| --- | ---: | --- | --- | +| [`GsCRoleOptTargetNtf-987.md`](GsCRoleOptTargetNtf-987.md) | `987` / `988` | 观虚:观看牌堆顶 + 目标手牌 | `Params=[pileCount,handCount,...pile,...hand]`;配对牌堆同区展示 `MoveType=21` | +| [`GsCRoleOptTargetNtf-3483.md`](GsCRoleOptTargetNtf-3483.md) | `3483` | 诫厉:观看牌堆顶 + 目标部分手牌,后续交换拆回 | 观看同观虚布局;交换 `1->10` + 部分 `5->10` 再 `10->1` / `10->5`;数组顺序可能逆序 | +| [`GsCRoleOptTargetNtf-3876.md`](GsCRoleOptTargetNtf-3876.md) | `3876` | 界强识:目标全部手牌明牌 | `Params` 全是手牌 ID,`fullHand` | +| [`GsCRoleOptTargetNtf-7011.md`](GsCRoleOptTargetNtf-7011.md) | `7011` | 权变:观看牌堆顶 | `targetSeatID=255`;`Params` 即牌堆顶;配对同区展示 `MoveType=21` | + +## `CGsRoleSpellOptRep` + +| 文档 | SpellID | 场景 | 关键识别 | +| --- | ---: | --- | --- | +| [`CGsRoleSpellOptRep.md`](CGsRoleSpellOptRep.md) | 多技能 | 技能操作回复;含鹰视看牌堆顶、裴秀地图等 | `Datas` 语义依赖 `SpellID` + `Type` | + +## `GsCUpdateRoleDataExNtf` + +| 文档 | DataID | 场景 | 关键识别 | +| --- | ---: | --- | --- | +| [`GsCUpdateRoleDataExNtf.md`](GsCUpdateRoleDataExNtf.md) | `4022` | 裴秀地图状态更新 | `Datas=[mapID,currentCell,historyCount,...]`,仅己方 | + +## 相关代码入口 + +| 协议 / 模式 | 处理入口 | 状态 / 装饰 | +| --- | --- | --- | +| `GsCRoleOptTargetNtf` | `src/handler/GsCRoleOptTargetNtf.js` | `tracker.revealTrackerCards` / `Room.getSkillState` | +| `PubGsCMoveCard` | `src/handler/PubGsCMoveCard.js` | `src/tracker/MoveEventNormalizer.ts` → `Room.moveCards` | +| 整手交换 | 经 `decorateGenericMove` | `src/tracker/skill/HandExchange.ts` | +| 诫厉交换(历史,未挂载) | - | `src/tracker/skill/JieLi.ts`(暂不注册) | +| `CGsRoleSpellOptRep` | `src/handler/` 技能回复相关处理器 | 见专页 | +| 裴秀地图 | `src/handler/GsCRoleOptTargetNtf.js` 等 | `src/ui/PeiXiuMapWindow.js` / 路线工具 | + +## 维护约定 + +- 新增协议样例时:在 `docs/protocols/` 建专页,并回填本索引。 +- 文件名优先短、稳:通用模式用场景名(如 `hand-exchange.md`);技能专页可用 `消息-SpellID.md`。 +- 文档互链与 `docs/agents/card_tracker.md` 中的协议入口保持同步。 +- 仓库文档不绑定源码行号。 diff --git a/docs/protocols/PubGsCMoveCard-spell-121-hand-exchange.md b/docs/protocols/hand-exchange.md similarity index 99% rename from docs/protocols/PubGsCMoveCard-spell-121-hand-exchange.md rename to docs/protocols/hand-exchange.md index 49a8218..d5da9da 100644 --- a/docs/protocols/PubGsCMoveCard-spell-121-hand-exchange.md +++ b/docs/protocols/hand-exchange.md @@ -39,7 +39,7 @@ 因此以下路径**不会**被整手交换接管: - 佐练等“先亮 1 张,再 `5->10` 单张暂存”的路径(张数不是整手) -- 诫厉等“手牌进交换区后回牌堆”的路径(目标不是 `ToZone=5`) +- 诫厉等“手牌进交换区后回牌堆”的路径(目标不是 `ToZone=5`;协议见 `docs/protocols/GsCRoleOptTargetNtf-3483.md`) - 骋烈等交换区到标记区/手牌的特化路径(由各自技能装饰器处理) ## 场景前提(示例,SpellID=121) diff --git a/src/handler/GsCRoleOptTargetNtf.js b/src/handler/GsCRoleOptTargetNtf.js index ec5a388..7539ad2 100644 --- a/src/handler/GsCRoleOptTargetNtf.js +++ b/src/handler/GsCRoleOptTargetNtf.js @@ -34,6 +34,34 @@ function revealTargetCards(seatID, cardIDs) { } } +// 部分手牌协议在 handCount 恰好等于目标整手数时,应按 fullHand 同步。 +// 优先信观测手牌数;没有观测时才退回本地手牌实体数。 +function shouldRevealAsFullHand(seatID, handCount) { + const count = Number(handCount) || 0 + if (count <= 0) return false + + const room = tracker.getReadyTrackerRoom() + if (!room) return false + + const player = room.getPlayer?.(seatID) + if (player?.hasObservedHandCount === true) { + return count === Number(player.observedHandCount) + } + + const handCards = + typeof room.refreshPlayerSnapshot === 'function' + ? room + .refreshPlayerSnapshot() + .filter((card) => card.subZone === 'hand' && card.seats?.has?.(Number(seatID))) + : null + + if (Array.isArray(handCards) && handCards.length > 0) { + return count === handCards.length + } + + return false +} + export function handleRoleOptTargetNtf(arg) { const { SpellID, Param, Params, SeatID, SrcSeatID, targetSeatID, Type } = arg @@ -175,14 +203,31 @@ export function handleRoleOptTargetNtf(arg) { break // 族钟繇 诫厉 + // Params: [pileCount, handCount, ...pileTopCardIDs, ...handCardIDs] + // 手牌片段是目标部分手牌,不一定等于全部手牌 case 3483: - if (Param == 1) { - if (Params?.length > 2 && targetSeatID !== undefined && targetSeatID !== 255) { - revealPlayerHandCards(targetSeatID, Params.slice(-Params[1])) - } else if (Params?.[0] > 0) { + if (Param == 1 && Params?.length > 0) { + const pileCount = Number(Params[0]) || 0 + const handCount = Number(Params[1]) || 0 + + if (pileCount > 0) { const trackerRoom = tracker.getReadyTrackerRoom() if (trackerRoom) { - trackerRoom.getSkillState(SpellID).expectedPileCount = Params[0] + trackerRoom.getSkillState(SpellID).expectedPileCount = pileCount + } + } + + if (Params.length > 2) { + if (pileCount > 0) { + revealPileCards(Params.slice(2, 2 + pileCount)) + } + if (handCount > 0 && targetSeatID !== undefined && targetSeatID !== 255) { + const handCardIDs = Params.slice(2 + pileCount, 2 + pileCount + handCount) + revealPlayerHandCards( + targetSeatID, + handCardIDs, + shouldRevealAsFullHand(targetSeatID, handCount) ? { fullHand: true } : {} + ) } } } diff --git a/src/tracker/skill/HandExchange.ts b/src/tracker/skill/HandExchange.ts index 2607896..077be23 100644 --- a/src/tracker/skill/HandExchange.ts +++ b/src/tracker/skill/HandExchange.ts @@ -12,7 +12,7 @@ * - FromZone=10 时 FromID 不能当座位解释,只能当批次键 * * 详细协议说明见: - * docs/protocols/PubGsCMoveCard-spell-121-hand-exchange.md + * docs/protocols/hand-exchange.md */ import { MOVE_TYPE } from '../MoveEventNormalizer' import type { Card } from '../Card' diff --git a/src/tracker/skill/JieLi.ts b/src/tracker/skill/JieLi.ts index 0482295..24aca54 100644 --- a/src/tracker/skill/JieLi.ts +++ b/src/tracker/skill/JieLi.ts @@ -12,7 +12,11 @@ import { } from '../runtime/moveEventHandlers' import type { Room } from '../Room' -// 族钟繇【诫厉】:跨多条移动消息追踪暂存牌,并在全暗回牌堆时补回实体 ID。 +// 族钟繇【诫厉】:历史装饰器,当前不要挂到主动路径。 +// 已观测实战与下列假设不一致: +// 1) 观看 top-first(如 [81,99,124,4],81 为顶)后,交换 CardIDs 可能整段逆序([4,124,99,81]) +// 2) 回出是 10->1 与 10->5 拆分,且回牌堆可能混入手牌来源,不是“暂存手牌整批全暗回牌堆” +// 协议见 docs/protocols/GsCRoleOptTargetNtf-3483.md export default function decorateJieLi(event: MoveEventDraft, room: Room): MoveEventDraft { const raw = getRaw(event) const cardIDs = event.cardIDs ?? [] diff --git a/tests/tracker/roleOptTargetNtf.test.ts b/tests/tracker/roleOptTargetNtf.test.ts index 5b7dbdf..003d907 100644 --- a/tests/tracker/roleOptTargetNtf.test.ts +++ b/tests/tracker/roleOptTargetNtf.test.ts @@ -23,11 +23,13 @@ vi.mock('../../src/draw', () => ({ import { handleRoleOptTargetNtf } from '../../src/handler/GsCRoleOptTargetNtf' import { Game } from '../../src/tracker' +import { tracker } from '../../src/tracker/runtime/browser' describe('GsCRoleOptTargetNtf', () => { beforeEach(() => { destroyPeiXiuMapWindow.mockClear() revealTrackerCards.mockClear() + vi.mocked(tracker.getReadyTrackerRoom).mockReset() Game.deleteSpellState(7009) Game.deleteSpellState(4022) }) @@ -121,4 +123,89 @@ describe('GsCRoleOptTargetNtf', () => { [16, 160, 79, 106] ) }) + + it('诫厉同时公开牌堆顶与目标部分手牌,并记录 expectedPileCount', () => { + const skillState: Record = {} + const getSkillState = vi.fn(() => skillState) + vi.mocked(tracker.getReadyTrackerRoom).mockReturnValue({ + getSkillState, + getPlayer: vi.fn(() => ({ hasObservedHandCount: true, observedHandCount: 5 })) + } as any) + + handleRoleOptTargetNtf({ + Param: 1, + Params: [4, 2, 81, 99, 124, 4, 91, 158], + SeatID: 3, + SpellID: 3483, + SrcSeatID: 3, + Timeout: 30, + Type: 28, + targetSeatID: 4, + className: 'GsCRoleOptTargetNtf' + }) + + expect(getSkillState).toHaveBeenCalledWith(3483) + expect(skillState.expectedPileCount).toBe(4) + expect(revealTrackerCards).toHaveBeenCalledTimes(2) + expect(revealTrackerCards).toHaveBeenNthCalledWith( + 1, + { + type: 'public', + zoneName: 'pile', + reposition: true, + cardIDsTopFirst: true + }, + [81, 99, 124, 4] + ) + expect(revealTrackerCards).toHaveBeenNthCalledWith(2, { type: 'player', seatID: 4 }, [91, 158]) + }) + + it('诫厉 handCount 等于目标整手数时按 fullHand 同步', () => { + const skillState: Record = {} + const getSkillState = vi.fn(() => skillState) + vi.mocked(tracker.getReadyTrackerRoom).mockReturnValue({ + getSkillState, + getPlayer: vi.fn(() => ({ hasObservedHandCount: true, observedHandCount: 2 })) + } as any) + + handleRoleOptTargetNtf({ + Param: 1, + Params: [4, 2, 81, 99, 124, 4, 91, 158], + SeatID: 3, + SpellID: 3483, + SrcSeatID: 3, + Timeout: 30, + Type: 28, + targetSeatID: 4, + className: 'GsCRoleOptTargetNtf' + }) + + expect(revealTrackerCards).toHaveBeenNthCalledWith( + 2, + { type: 'player', seatID: 4, fullHand: true }, + [91, 158] + ) + }) + + it('诫厉仅有牌堆张数时只写入 expectedPileCount', () => { + const skillState: Record = {} + const getSkillState = vi.fn(() => skillState) + vi.mocked(tracker.getReadyTrackerRoom).mockReturnValue({ getSkillState } as any) + + handleRoleOptTargetNtf({ + Param: 1, + Params: [4], + SeatID: 3, + SpellID: 3483, + SrcSeatID: 3, + Timeout: 30, + Type: 28, + targetSeatID: 255, + className: 'GsCRoleOptTargetNtf' + }) + + expect(getSkillState).toHaveBeenCalledWith(3483) + expect(skillState.expectedPileCount).toBe(4) + expect(revealTrackerCards).not.toHaveBeenCalled() + }) })