diff --git a/docs/agents/card_tracker.md b/docs/agents/card_tracker.md index 66374a3..edfa0ac 100644 --- a/docs/agents/card_tracker.md +++ b/docs/agents/card_tracker.md @@ -22,7 +22,8 @@ - 构造时挂载 `publicZones`、`constraints`、`movement` 三个行为模块;这些模块只持有 `room` 引用,不拥有独立推断状态。 - 高频主入口保留在 `Room` 中,便于快速查看引用和主流程;低频阶段细节委托给挂载模块。 - `registerPlayers()` / `setMySeatID()` / `setFirstHand()` / `updateFixedViewIds()` 维护玩家集合、主视角座位与固定视图位序。 -- `initDeck(cardIDs)` 创建本局物理牌池、`cardIndex`、公共 `pile` Zone 与 `CardCounter`,并重建 `locationIndex`。 +- `initDeck(cardIDs)` 创建等量匿名牌堆槽,将真实身份登记到 `deckIdentities` 与 + `unlocatedIdentities`,初始化 `CardCounter` 并重建 `locationIndex`;真实 ID 首次揭示时才物化到实体。 - `moveCards(cardIDs, toZone, options)` 是主动状态更新入口: - `cardIDs` 中大于 0 的 ID 视为已知物理牌。 - `cardCount - knownIDs.length` 视为暗牌占位数。 @@ -231,7 +232,8 @@ - 若新增会影响手牌槽 known/candidate 计数的收敛路径,必须确保相关座位进入 `Room.resolveTouchedSeats`;E1 会复用未触碰座位的手牌槽统计缓存。 - 新增或修改 `this.cards.filter(...)` 等全牌池扫描前,必须先判断能否改用现有增量快照、索引、脏事件集合,或在入口一次性归组后复用结果;尤其避免把全牌池扫描放进玩家、约束组或收敛轮循环中,意外放大为 O(玩家数 × 全牌数) 或更高复杂度。若确认全量扫描确有必要,必须使用 `recordTraversal(...)` 对该扫描站点显式插桩,并在 `tests/tracker/traversalBaseline.test.ts` 中新增或更新对应场景与内联快照,使后续遍历量增长可见且可解释;不得以未插桩的隐藏扫描绕过基线护栏。 - [`tests/tracker/traversalBaseline.test.ts`](../../tests/tracker/traversalBaseline.test.ts) 的内联快照是遍历量回归护栏:结构性优化使数字下降属预期(`vitest run -u` 刷新),无关改动使数字上升需要先解释原因再更新快照。 -- DEV 真实回放可通过 `window.__DXC_TRAVERSAL__` 开启长生命周期遍历会话:`start()` 开始新局,`snapshot()` 查看中途累计值,`stop()` 获取最终 JSON 并停止,`reset()` 清空后继续;G0 直接读取快照的 `g0.totals` 与固定五项 `g0.sites`。 +- 匿名槽 G0/G1 真实回放已经完成并决定 NO-GO / 收缩;临时浏览器回放探针已退役。历史数据见阶段 + 0 基线与阶段 1 对照报告。 - 初始牌堆初始化后,`pile.cards` 顺序应独立于 `room.cards`。 - 摸暗牌、摸明牌时手牌额度及状态维护应保持准确。 - 洗牌时协议 `cardCount` 与本地可枚举牌堆不一致属于高风险路径:需要确认匿名暗占位账本、剩余牌堆顶部顺序、暂停追踪候选展示和暗标记账本迁移。 @@ -267,3 +269,5 @@ - 2026-07-05:完成测试重构与合并,抽取 `locationCandidates` 与 `trackerController` 公共测试辅助,精简测试冗余,提升测试维护性。 - 2026-07-15:文档对齐代码结构——去除文档行号锚点;开局路径以 `handleRecordStartGame` 为主、`GsCModifyUserseatNtf` 分发暂注释;`GameState` 纯状态与 `BrowserGameState` 钩子拆分;视图脏渲染与 `trackerVisibility` 已落地。 +- 2026-07-20:匿名牌堆阶段 1 完成;牌堆槽与身份解耦,G1 最终决定 NO-GO / 收缩,阶段 + 2–7 不执行,临时真实回放探针进入退役范围。 diff --git a/docs/agents/testing.md b/docs/agents/testing.md index dec0a0a..07f3293 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -128,31 +128,16 @@ CI(`.github/workflows/ci.yml`)在 `dev` / `main` 的 PR 与 push 上会跑 - 远端 `Config_w.sgs` / HTML 资源加载 - 录像 UI 与宿主页面耦合行为 -### 真实回放冲突计数 +### 匿名槽回放决策记录 -阶段 0 的五个匿名占位冲突站点支持 Vite 开发服务器脚本累计采集。通过 `pnpm dev` 安装脚本并打开目标页面后,在 DevTools Console 执行: +匿名槽阶段 0/1 的 G0、G1 回放采集已经结束,最终决定为 NO-GO / 收缩:保留匿名牌堆,不推进 +阶段 2–7。临时浏览器回放探针与固定 G0 五站点 schema 已从运行时移除。 -```js -window.__DXC_TRAVERSAL__.start() -``` - -完整播放一局后,用以下命令导出最终快照: - -```js -copy(JSON.stringify(window.__DXC_TRAVERSAL__.stop(), null, 2)) -``` - -从 `g0.totals` 和 `g0.sites` 读取五站点累计值;`g0.sites` 会固定输出以下 key,即使没有触发也明确为零: - -```text -anonymousSlot:swapKnownCardWithPublicSourcePlaceholder -anonymousSlot:swapKnownCardWithPlayerSourcePlaceholder -anonymousSlot:recoverPlayerOccupiedIdentityForPublicReveal -anonymousSlot:insertUnknownPlaceholderIntoPile -anonymousSlot:createExternalCardsFallback -``` - -每局应独立保存 JSON,并记录回放来源、局号、采集日期和代码提交。`snapshot()` 可在回放中途查看累计值,`reset()` 可在不关闭会话的情况下开始新的采集段。 +历史采集方法与阶段 0 数据保存在 +[`anonymous-slot-stage-0-conflict-baseline.md`](../anonymous-slot-stage-0-conflict-baseline.md),阶段 1 三段回放 +及最终决策保存在 +[`anonymous-slot-stage-1-comparison.md`](../anonymous-slot-stage-1-comparison.md)。通用性能变更仍必须使用 +`recordTraversal(...)` 和 `tests/tracker/traversalBaseline.test.ts` 维护自动化遍历护栏。 --- diff --git a/docs/anonymous-slot-identity-decoupling-plan.md b/docs/anonymous-slot-identity-decoupling-plan.md new file mode 100644 index 0000000..cf6ccfc --- /dev/null +++ b/docs/anonymous-slot-identity-decoupling-plan.md @@ -0,0 +1,359 @@ +# 未知槽位匿名化与身份解耦计划 + +> 状态:已结束(G1 决定 NO-GO / 收缩;阶段 1 为终点,阶段 2–7 不执行) +> 日期:2026-07-19 +> 适用范围:`src/tracker/`(`Room`、`Card`、`roomMovement/*`、`runtime/trackerController.ts`、索引与视图)、`tests/tracker/`、`docs/agents/*` +> 关系:本计划**重开**并具体化 [`anonymous-card-entity-optimization-plan.md`](anonymous-card-entity-optimization-plan.md) 第 7 阶段“槽位与身份彻底拆分”这一被 [`random-hand-transfer-and-anonymous-entity-implementation-report.md`](random-hand-transfer-and-anonymous-entity-implementation-report.md) 判为“暂不建议”的方向;区别见 §3。 + +--- + +## 0. 最终决策 + +- 阶段 0 的真实回放显示旧模型冲突修复路径高频触发,因此批准阶段 1 隔离 spike。 +- 阶段 1 已完成匿名牌堆、`unlocatedIdentities`、`deckIdentities` 与按需物化,并通过自动化验证。 +- 三段阶段 1 回放累计 263.309 秒,旧 G0 冲突站点与阶段 1 interop 均为零触发;遍历基线无回退。 +- 阶段 1 生产源码仍为净增,未满足 G1 的“净删代码可观”条件。 +- G1 最终决定 **NO-GO / 收缩**:保留阶段 0 的稳定负 ID 与阶段 1 的匿名牌堆,不推进手牌、标记、 + 索引和视图的阶段 2–7 全面迁移。 +- G0/G1 决策完成后,临时浏览器回放探针已退役并完成代码清理;通用 `recordTraversal` 与遍历基线继续保留。 + +详细数据见 [`anonymous-slot-stage-1-comparison.md`](anonymous-slot-stage-1-comparison.md)。除非出现新的非零 +冲突证据或高频 interop,本计划不再重开;未来若需继续,应另立提案并重新量化收益。 + +下文保留立项时的现状描述、目标态与阶段设计,作为历史决策上下文,不代表阶段 2–7 仍是当前待办。 + +--- + +## 1. 摘要(TL;DR) + +当前记牌器把“**未知槽位**”用**真实 id 实体**表示(牌堆、暗手牌都是从初始牌池借一个 `id>0` 的实体来占位)。这使“**这张牌是什么身份**”与“**某个物理槽被谁占用**”耦合在同一个对象上。协议一旦揭示“真牌 X 在某处”,而本地实体 X 正被别处的未知槽借用,就出现**同一真 id 出现在两处**的矛盾,只能靠不断增长的置换/回收/回补代码擦屁股(`swapKnownCardWithPublicSourcePlaceholder`、`recoverPlayerOccupiedIdentityForPublicReveal`、`insertUnknownPlaceholderIntoPile`、各种 `id===0` 兜底)。 + +**目标态**:未知槽位一律用**匿名占位**(`id<=0`、稳定负 `entityID`,基础设施已存在);未揭示真牌的身份放进一个**未定位身份集** `unlocatedIdentities: Set`;揭示时才把身份**物化**到某个匿名占位(或新建实体)并从集合移除。已知身份、位置待定的牌继续走 `locationCandidates` 候选模型(与本计划正交,不改)。 + +**收益**:整类“身份/槽位矛盾”消失 → 上述置换/回收/回补机器可随冲突源一起删除;守恒从“散落各处的隐式规则”变成“一个集中不变量 + DEV 断言”。 + +**成本诚实说明**:动的是记牌器核心链路(`initDeck`、known/unknown 移动拆分、三大索引、视图、暗置标记),且约 40 个测试用“真 id 暗牌”写死了当前耦合,需要重写。因此**强制先做牌堆 spike(阶段 1)量化收益**,在**决策门 G1** 决定是否全面推进;big-bang 一次性重写被明确禁止。 + +--- + +## 2. 背景与根因 + +### 2.1 现状事实(已核对代码) + +- `Card.entityID = id > 0 ? id : room.allocateAnonymousEntityID()`(`Card.ts:85`)——稳定内部句柄;真实牌 `entityID===id`,匿名牌 `entityID` 为负(`Room.allocateAnonymousEntityID` = `anonymousEntitySeq--`,从 `-1` 起,`Room.ts:484`)。 +- `Room.initDeck(cardIDs)`(`Room.ts:194`)用**真实 CardID** 铺满牌堆;牌堆实体从一开始就带真 id,尽管洗牌后位置/身份实际未知。 +- `createExternalCards([], n)`(`Room.ts:488`)创建 `id=0`、负 `entityID` 的匿名占位,仅作**池枯竭兜底**。 +- `isKnown` 语义(`Card.ts:90`):“至少有一名玩家知道这张牌的物理 ID”。未知手牌 `isKnown=false`,但**实体仍带真 id**——这就是耦合。 + +### 2.2 冲突是如何产生的 + +“未知槽借真 id 实体”导致真 id 被**提前**绑定到物理槽。之后任一路径断言了同一真 id 的真实位置,就矛盾: + +| 冲突场景 | 现有修复路径 | 根因 | +| ------------------------------------------ | ------------------------------------------------------------------ | -------------------- | +| 揭示牌堆顶=真牌 67,但 67 被某玩家暗槽借用 | `recoverPlayerOccupiedIdentityForPublicReveal`(PR #41 新增) | 暗槽不该认领 67 | +| 协议称明牌 X 来自公共区,但本地 X 在别处 | `swapKnownCardWithPublicSourcePlaceholder` | X 被提前定位 | +| 协议称明牌 X 来自玩家区,但本地 X 在别处 | `swapKnownCardWithPlayerSourcePlaceholder` / `swapCardWithUnknown` | 同上 | +| 暗占位回牌堆时盖住已确认牌顶明牌段 | `insertUnknownPlaceholderIntoPile`(PR #41 新增) | 占位与明牌争同一端点 | + +这些代码**几乎全部**是在为“真 id 被塞进未知槽”兜底。情况越多、分支越多——即当前“越来越多的冲突”。 + +### 2.3 为什么现在重开这个方向 + +`random-hand-transfer-...report.md` §6 判定阶段 7(槽位/身份拆分)“暂不建议”,理由是“局部约束已够、控制改动与成本”。该判断在当时成立。但 PR #41 又叠加了两条新修复路径(`recoverPlayerOccupiedIdentityForPublicReveal`、`insertUnknownPlaceholderIntoPile`)与交换批次的 `handMoveCount` 解耦——说明**冲突面仍在扩张**,边际维护成本在上升。故值得用一个**便宜的 spike**重新量化“继续打补丁 vs 一次性解耦”的性价比。 + +--- + +## 3. 与现有计划的关系与区别 + +| 维度 | `anonymous-card-entity-optimization-plan.md`(旧) | 本计划(新) | +| -------- | -------------------------------------------------------- | ----------------------------------------------------------------------------- | +| 切入点 | **手牌槽位范围**(`HandSlot`/range),牌堆仍是真 id 实体 | **未知槽位全面匿名化**(牌堆 + 手牌 + 标记),身份进独立集合 | +| 对牌堆 | 不改牌堆表示 | **牌堆改为匿名占位 + 未定位身份集**(核心) | +| 揭示 | 仍走置换/回收 | 改为**物化**,删除置换/回收 | +| 阶段 7 | 列为研究方向,report 判“暂不建议” | 本计划即该方向的**可执行具体化**,但以“匿名槽优先”而非“HandSlot 对象优先”落地 | +| 候选模型 | 复用 | 复用(正交,不改) | + +**本计划不取代旧计划的阶段 1–6**(provenance、匿名池、range 对账等),它们与本计划可并存;本计划只吸收其“阶段 0 全实体候选传播”的已完成成果作为前置事实。 + +--- + +## 4. 目标 / 非目标 + +### 4.1 目标 + +1. **消除“身份/槽位矛盾”整类问题**:未知槽无身份,揭示不再制造矛盾。 +2. **删除/收敛修复机器**:置换、占用回收、牌顶占位插入等路径随冲突源消失而删除或降级为单一物化。 +3. **集中守恒不变量**:以 `unlocatedIdentities` + 计数为中心,配 DEV 断言,替代散落规则。 +4. **消除 `id=0` 碰撞**:匿名占位统一用**负 id**(= `entityID`),修掉视图 key / 去重 / 测试里成片的 `id===0` 特判。 +5. **行为保持**:每阶段以现有 234 项测试为回归护栏;对用户可见行为零回退。 + +### 4.2 非目标 + +- **不**引入全局二分匹配 / 通用 SAT 约束求解器(沿用 `ConstraintGroup` 局部约束)。 +- **不**改候选模型(`locationCandidates` / `ConstraintGroup`)的语义。 +- **不**追求“重构一切”——已知身份的牌继续是真 id 实体。 +- **不**做协议解析层的重写(top-first/逆序/同区展示等协议怪癖不在本计划范围)。 +- 阶段 7(独立 `HandSlot` 对象)仍为**可选研究**,默认 YAGNI。 + +--- + +## 5. 术语与模型 + +- **真实实体(located identity)**:`id>0` 的 `Card`,身份已定位。已知位置或“已知身份+位置候选”。 +- **匿名占位(anonymous placeholder)**:`id<=0`、`entityID<0` 的 `Card`。**不认领任何真 id**,只占一个物理槽。是未知槽的唯一表示。 +- **未定位身份集 `unlocatedIdentities: Set`**:全牌集中尚未定位到任何真实实体的真 id。初始 = 整副牌;随揭示/发牌逐步缩小。 +- **物化 `materialize(cardID, target)`**:把一个 `unlocatedIdentities` 中的真 id 赋给 target 处的某个匿名占位(占位升级为真实实体,`id/entityID` 改为该真 id、`isKnown=true`),并从集合移除;target 无可用占位时新建实体。 +- **候选(candidate)**:已知身份、位置待定,继续由 `locationCandidates` 表达(不变)。 + +模型关系: + +``` +身份未知 → 匿名占位(id<=0, entityID<0),不入身份索引 +已知身份、位置待定 → 真实实体 + 多个 locationCandidates +已知身份、位置确定 → 真实实体,收敛 +未揭示真牌的身份 → 只存在于 unlocatedIdentities 集合,无实体 +``` + +--- + +## 6. 核心不变量(配 DEV 断言) + +- **I1 身份守恒**:`{已定位真 id} ⊎ unlocatedIdentities === 全牌集`;同一 id 不得既有真实实体又在 `unlocatedIdentities`。 +- **I2 槽位守恒**:每个区域实体数 === 该区域应有牌数(明牌 + 匿名占位);牌堆实体数 === 牌堆剩余张数。 +- **I3 匿名无身份**:`id<=0` 实体不得进入按真 id 的 `cardIndex`、`AmbiguousKnownIndex`、明牌视图;不得 `confirmKnown()`。 +- **I4 揭示单调**:`materialize` 只把 id 从 `unlocatedIdentities` 移到“已定位”,不逆向;命中“已定位于别处且为已知”的真矛盾时 → DEV 抛错 + 保守对账。 +- **I5 候选正交**:候选身份收敛只作用于真实实体;匿名占位不参与候选身份推理(只参与数量/位置占位)。 + +--- + +## 7. 目标态设计(关键结构与操作) + +### 7.1 数据结构 + +```ts +// Room +unlocatedIdentities: Set // 未定位真 id +// 可选:locatedIndex 复用现有 cardIndex(仅收 id>0) +``` + +### 7.2 关键操作 + +- `initDeck(deck)`:`unlocatedIdentities = new Set(deck)`;牌堆填入 `deck.length` 个**匿名占位**(不再是真 id 实体)。 +- `drawUnknownToHand(seat, k)`:从牌堆取 k 个匿名占位,绑定为 seat 暗手牌;**不**赋真 id。 +- `materialize(cardID, target)`:见 §5;统一供所有揭示入口调用。 +- `revealPileTop/Bottom(ids)`:对每个 id 调 `materialize(id, pileEndpoint)`;无 swap/recover。 +- `revealHand(seat, ids, {fullHand})`:对每个 id 调 `materialize(id, seatHand)`。 +- `assertConservation()`(DEV):校验 I1/I2。 + +### 7.3 与现有移动路径的衔接 + +- `moveUnknownCardsForContext` 的 `takeSourceCards`:来源为牌堆/暗手牌时取匿名占位(已接近现状,只是不再带真 id)。 +- `moveKnownCardsForContext`:来源明牌若在 `unlocatedIdentities` 中 → 先 `materialize` 到来源区再移动;不再走“别处偷实体”的 swap。 + +--- + +## 8. 分阶段实施 + +> 每阶段:独立分支/可回滚提交;以全量 `pnpm test:tracker` 为护栏;阶段间有明确产出与(部分)决策门。旧行为在并存期用开关或 interop 层兜底。 + +### 阶段 0:地基与度量探针(低风险,独立可交付) + +**目标**:不改核心行为,先把“匿名负 id”与“冲突度量”落地,为 spike 提供基准。 + +**工作项** + +1. **匿名占位改负 id**:`createExternalCards` 生成的占位 `id` 从 `0` 改为其负 `entityID`;新增 `hasRealIdentity(card) = card.id > 0` / `isAnonymous(card)` 辅助;把散落的 `card.id === 0` 判定改为 `isAnonymous(card)`(保持等价)。 +2. **冲突/修复计数插桩**:给 `swapKnownCardWithPublicSourcePlaceholder`、`swapKnownCardWithPlayerSourcePlaceholder`、`recoverPlayerOccupiedIdentityForPublicReveal`、`insertUnknownPlaceholderIntoPile`、`createExternalCards` 兜底各加 `recordTraversal`/计数。 +3. **DEV 观测断言(只告警不抛)**:加 `assertConservation()` 骨架(I1/I2),在 `resolveConstraints` 尾部 DEV 下运行、仅 `warn`。 +4. 冻结基线:记录当前 `traversalBaseline` 与 234 项测试。 + +**文件**:`Room.ts`、`roomMovement/sources.ts`、`runtime/trackerController.ts`、`view/cardButton.ts`(`id===0` → `isAnonymous`)、`tests/tracker/*`(`id===0` 断言改辅助)。 + +**完成标准**:全量测试绿;`typecheck:tracker`/`lint`/`build` 绿;产出一页《冲突频次基线》(各修复路径在现有测试 + 一局真实回放中触发次数)。 + +**决策门 G0**:若冲突频次基线显示修复路径几乎不触发 → 说明痛点被高估,可在此**止步**(仅保留负 id 与断言收益)。 + +### 阶段 1:牌堆 spike(核心验证,隔离分支) + +**目标**:仅牌堆改为“匿名占位 + `unlocatedIdentities`”,量化收益与破坏面。手牌/标记暂不动,用 interop 层桥接。 + +**工作项** + +1. `Room` 增 `unlocatedIdentities`,`initDeck` 改为填匿名占位、集合置为整副牌。 +2. 抽牌从牌堆取匿名占位。 +3. 牌堆揭示(观虚/权变/鹰视/诫厉同区展示)改走 `materialize`;**删除牌堆相关的 recover/占位插入**。 +4. interop:暗手牌暂仍可能带真 id(旧路径),`materialize` 命中“真 id 已被暗手牌借用”时走临时兼容分支(打点,便于阶段 4 清除)。 +5. `CardCounter` 牌堆剩余 = `unlocatedIdentities ∩ 牌堆语义`(或 deckSize − located)。 + +**测试**:`pileDisplayOrder`、`trackerController`、`pubGsCMoveCard`、`roleOptTargetNtf` 全过(可能需按新语义改断言);新增《牌堆匿名化对照》快照。 + +**完成标准**:牌堆相关测试绿;产出《对照报告》:删除/新增净行数、被改测试数、冲突计数下降幅度、性能(遍历基线)变化。 + +**决策门 G1(关键)**:依据对照报告决定: + +- **GO**:净删代码可观、无新增高频遍历、破坏面可控 → 推进阶段 2–6。 +- **NO-GO / 收缩**:把负 id + 牌堆匿名化作为终点,保留手牌旧模型;关闭后续阶段。 + +### 阶段 2:抽牌/发牌路径匿名化 + +**目标**:所有未知入手/入区一律匿名占位,杜绝新的真 id 提前绑定。 + +**工作项**:`moveUnknownCardsForContext`/`takeSourceCards` 未知路径产出匿名占位;`markRandomHandTransferCandidates` 等复用(候选仍作用于已公开明牌真实体)。 + +**测试**:`locationCandidates`、`randomTransferLifecycle`、`hiddenMark` 相关全过。 + +**完成标准**:未知移动路径不再产生 `id>0` 的“未揭示”实体;测试绿。 + +### 阶段 3:揭示统一为物化 + +**目标**:把所有揭示入口收口到单一 `materialize`,删除公共区占用回收。 + +**工作项** + +1. `revealTrackerCards`/`revealTrackerCardsInZone`/同区展示 → 统一调 `materialize`。 +2. **删除 `recoverPlayerOccupiedIdentityForPublicReveal`**(冲突源已消)。 +3. `materialize` 命中“已定位于别处且已知”的真矛盾 → DEV 抛错 + 保守对账(记录,不静默)。 + +**测试**:`trackerController`(占用回收相关用例改为物化语义)、`roleOptTargetNtf`、`pubGsCMoveCard`。 + +**完成标准**:占用回收路径删除后测试绿;矛盾断言仅在人造矛盾用例触发。 + +### 阶段 4:暗手牌与暗置标记匿名化 + +**目标**:暗手牌槽、暗置标记槽改为匿名占位,删除来源置换。 + +**工作项** + +1. 暗手牌绑定用匿名占位(`bindCandidates` 保持,但实体无真 id)。 +2. 暗置标记(`hiddenMarks`)同步匿名化。 +3. **降级/删除** `swapKnownCardWithPlayerSourcePlaceholder`、`swapCardWithUnknown`、`swapKnownCardWithPublicSourcePlaceholder` 为“物化”单一路径。 +4. **交换批次简化**:`skill/HandExchange.ts` 中暗实体天然无真 id,`sourceCards` 路径不再担心真 id 冲突;复核 `handMoveCount` 解耦是否可简化。 + +**测试**:`handExchange`、`hiddenMark`、`pileDisplayOrder` 全过;`insertUnknownPlaceholderIntoPile` 场景改为“物化不与占位争端点”。 + +**完成标准**:三条 swap 路径删除或收敛为一处;测试绿。 + +### 阶段 5:集中守恒不变量 + 删死代码 + +**目标**:把 DEV 断言从 warn 升为 throw;清除已死修复分支与 `id===0` 残留。 + +**工作项** + +1. `assertConservation()`(I1–I4)DEV 下 `throw`;接入 `traversalBaseline` 护栏。 +2. 删除阶段 1 interop 兼容分支与所有已无触发的修复路径(按阶段 0 计数确认零触发再删)。 +3. `insertUnknownPlaceholderIntoPile` 合并进牌堆排序辅助(若仍需保护牌顶明牌段)。 + +**完成标准**:DEV 断言零误报;死代码删除;净行数下降;测试绿。 + +### 阶段 6:索引 / 视图 / 计数适配与测试重写 + +**目标**:全面切到 entityID 语义,重写耦合测试。 + +**工作项** + +1. 视图 key 用 `entityID`(`id=0` 碰撞已消);`CardLocationIndex`/`AmbiguousKnownIndex` 已排除匿名,复核无回归。 +2. 牌堆残量、暗牌计数按 `unlocatedIdentities`/计数导出。 +3. **重写耦合测试**:把 `bindHiddenHand([201,202])` 这类“真 id 暗牌”断言改为“数量 + 已定位身份 + `unlocatedIdentities`”断言。 +4. 更新 `docs/agents/card_tracker.md`、`overview.md` 模型描述。 + +**完成标准**:`typecheck`/`test:tracker`/`lint`/`build:prod` 全绿;文档同步。 + +### 阶段 7(可选研究):槽位与身份对象彻底拆分 + +**目标**:评估是否需要独立 `HandSlot`/`Slot` 对象。默认 **YAGNI**——阶段 1–6 若已消除冲突且守恒集中,则不做。仅当出现阶段 1–6 无法覆盖的残余不确定性再评估。 + +--- + +## 9. 文件级改动地图 + +| 文件 | 阶段 | 改动 | +| ----------------------------------------------- | ------ | ---------------------------------------------------------------------------------------- | +| `src/tracker/Room.ts` | 0/1/5 | `unlocatedIdentities`、`initDeck` 匿名化、`materialize`、`assertConservation`、匿名负 id | +| `src/tracker/Card.ts` | 0 | `isAnonymous`/`hasRealIdentity` 辅助;匿名 `id` 取负 `entityID` | +| `src/tracker/roomMovement/sources.ts` | 1/3/4 | 未知取占位、删除/收敛 swap、`insertUnknownPlaceholderIntoPile` 合并 | +| `src/tracker/roomMovement.ts` / `candidates.ts` | 2 | 未知移动产匿名占位;候选传播复核 | +| `src/tracker/runtime/trackerController.ts` | 1/3 | 揭示统一 `materialize`;删 `recoverPlayerOccupiedIdentityForPublicReveal` | +| `src/tracker/CardCounter.ts` | 1/6 | 牌堆残量按 `unlocatedIdentities`/计数导出 | +| `src/tracker/skill/HandExchange.ts` | 4 | 暗实体无真 id 后简化;复核 `handMoveCount` | +| `src/tracker/view/*` | 6 | key 改 `entityID`;`id===0` → `isAnonymous` | +| `docs/agents/card_tracker.md`,`overview.md` | 6 | 模型描述更新 | +| `tests/tracker/*` | 各阶段 | 断言从“真 id 暗牌”改为“计数 + 已定位 + 未定位集” | + +--- + +## 10. 测试矩阵(每阶段必过) + +- **牌堆**:初始化、抽牌、洗牌、观虚/权变/鹰视/诫厉揭示、同区展示、重复揭示幂等、牌顶/牌底明牌段保护。 +- **手牌**:普通暗牌、明牌打出、完整手牌展示、随机转移(7 明 2 暗转 3)、整手交换(含共享候选、嵌套、中断批次)。 +- **公共区**:明牌来源纠正、弃牌/流放、身份物化 vs 旧置换等价性。 +- **标记/暗置**:`hiddenMark`、装备标记、无席位技能空间。 +- **守恒**:DEV `assertConservation` 在所有上述路径零误报;人造矛盾用例触发断言。 +- **性能**:`traversalBaseline` 不因匿名化引入新高频全量扫描(新增站点需显性化并说明)。 + +--- + +## 11. 验证命令(每阶段) + +```sh +pnpm exec vitest run tests/tracker/<改动相关>.test.ts # 快速定向 +pnpm test:tracker # 全量 tracker 回归 +pnpm typecheck:tracker +pnpm lint +pnpm build +pnpm build:prod # 阶段 1、5、6 及最终 +git diff --check # 空白/换行 +``` + +--- + +## 12. 风险与缓解 + +| 风险 | 说明 | 缓解 | +| ----------------------------------- | ------------------------------ | ------------------------------------------------------------- | +| R1 迁移打断核心链路 | 动 `initDeck`/移动拆分/索引 | **强制分阶段** + interop 层 + G1 决策门;big-bang 禁止 | +| R2 守恒新 bug | 隐式守恒改显式,可能漏更新集合 | 集中 `assertConservation`(先 warn 后 throw)+ 全路径测试矩阵 | +| R3 `cardIndex.get(realID)` 假设失效 | 未定位真牌无实体 | 复用现有 `findCardsByIDs`“缺失→按需创建”先例;调用点审计清单 | +| R4 测试重写量大 | ~40 处真 id 暗牌断言 | 阶段 6 集中机械重写;断言更贴近真实信息结构 | +| R5 性能回归 | `materialize`/守恒断言开销 | 断言仅 DEV;`traversalBaseline` 护栏;牌堆残量用计数而非扫描 | +| R6 与暗置标记账本冲突 | 标记有独立空间账本 | 阶段 4 单独处理;标记匿名化与手牌匿名化对齐 | +| R7 收益不及预期 | 冲突实际触发少 | G0/G1 决策门用**实测计数**而非直觉决定是否推进 | + +--- + +## 13. 提交与回滚策略 + +- 每阶段 ≥1 个原子提交,`feat(tracker)`/`refactor(tracker)`/`test(tracker)` 前缀,中文正文。 +- 阶段 1 在**独立分支**完成并出对照报告后再合并;G1 NO-GO 时直接弃分支,仅保留阶段 0。 +- 每阶段合并前全量校验(§11)绿;`dist/` 不提交。 +- 回滚粒度 = 单阶段提交;interop 层保证并存期任一阶段可单独 revert。 + +--- + +## 14. 验收标准(全计划) + +1. 未知槽位无真 id;`unlocatedIdentities` 与实体计数满足 I1–I4,DEV 断言零误报。 +2. `recoverPlayerOccupiedIdentityForPublicReveal` 与至少两条 `swap*` 路径被删除或收敛为单一 `materialize`。 +3. `id===0` 特判从生产与测试清除,视图 key 用 `entityID`。 +4. 全量 `pnpm test:tracker` 绿(含重写后的计数/身份断言);`typecheck:tracker`/`lint`/`build:prod` 绿。 +5. `docs/agents/card_tracker.md` 模型章节更新为匿名槽 + 未定位身份集。 +6. 净代码行数下降(以“删除的修复分支”为主要指标),`traversalBaseline` 无非预期上升。 + +--- + +## 15. 推荐执行顺序与决策门 + +``` +阶段 0(负 id + 度量 + 断言骨架)── G0:冲突几乎不触发?→ 止步 + ↓ 否 +阶段 1(牌堆 spike,隔离分支,出对照报告)── G1:GO / NO-GO + ↓ GO +阶段 2 → 3 → 4 → 5 → 6(逐段删修复机器、集中守恒、重写测试) + ↓ +阶段 7(可选研究,默认 YAGNI) +``` + +- **G0/G1 用实测数据决定**,避免凭直觉全面重写。 +- 阶段 0 与阶段 1 即使后续 NO-GO 也各自独立有价值(负 id 消碰撞、牌堆语义更诚实)。 + +> 备注:本计划聚焦“未知身份 → 匿名槽”。“已知身份、位置待定”继续由候选模型承载,二者正交;PR #41 的整手交换候选账本(`skill/HandExchange.ts`)在阶段 4 后可进一步简化,但不在关键路径上。 diff --git a/docs/anonymous-slot-stage-0-conflict-baseline.md b/docs/anonymous-slot-stage-0-conflict-baseline.md index f0465cb..aac0a37 100644 --- a/docs/anonymous-slot-stage-0-conflict-baseline.md +++ b/docs/anonymous-slot-stage-0-conflict-baseline.md @@ -1,5 +1,6 @@ # 阶段 0 冲突频次基线 +> 状态:历史归档;G0 曾决定 GO,后续 G1 已决定 NO-GO / 收缩 > 日期:2026-07-19 > 基准分支:`dev` > 基准提交:`ac41259` @@ -34,61 +35,15 @@ | `anonymousSlot:insertUnknownPlaceholderIntoPile` | 1 | 1 | 暗占位回牌堆并保护牌顶明牌段 | | `anonymousSlot:createExternalCardsFallback` | 1 | 3 | 一次兜底创建 3 个匿名占位;`visited` 表示创建张数 | -后续真实回放采集时,应使用 DEV 浏览器控制接口开启长生命周期会话;它会跨越整局回放累计所有 `recordTraversal` 站点的 `calls/visited`,不需要改动回放入口。 +本节数据用于冻结当时的计数语义。G0/G1 决策现已结束,不再要求继续采集这些临时站点。 -## 4. 真实回放采集步骤 +## 4. 回放采集状态 -1. 使用开发模式启动并安装用户脚本: +DEV 浏览器控制接口与固定 G0 五站点只为阶段 0/1 决策采集服务。阶段 1 已完成三段对照回放, +G1 最终决定 NO-GO / 收缩;运行时探针、固定 G0 schema 与对应测试现已清理。 - ```sh - pnpm dev - ``` - -2. 打开目标页面,在浏览器 DevTools Console 确认接口存在: - - ```js - window.__DXC_TRAVERSAL__ - ``` - - 该接口只在 `pnpm dev` 的 Vite 开发服务器脚本中安装;`pnpm build` 与 `pnpm build:prod` 生成的构建产物都不会暴露它。 - -3. 开始播放一局完整真实回放前,清空并启动本次会话: - - ```js - window.__DXC_TRAVERSAL__.start() - ``` - -4. 播放完整回放。需要中途检查时执行: - - ```js - window.__DXC_TRAVERSAL__.snapshot() - ``` - -5. 回放结束后停止并复制最终 JSON: - - ```js - copy(JSON.stringify(window.__DXC_TRAVERSAL__.stop(), null, 2)) - ``` - -6. 记录快照中的 `g0.totals` 与 `g0.sites`。`g0.sites` 固定包含以下五个站点,即使没有触发也会显式输出 `calls=0, visited=0`: - - ```text - anonymousSlot:swapKnownCardWithPublicSourcePlaceholder - anonymousSlot:swapKnownCardWithPlayerSourcePlaceholder - anonymousSlot:recoverPlayerOccupiedIdentityForPublicReveal - anonymousSlot:insertUnknownPlaceholderIntoPile - anonymousSlot:createExternalCardsFallback - ``` - - 其中 `g0.sites['anonymousSlot:createExternalCardsFallback'].visited` 是实际创建的匿名牌张数,不是扫描访问数。 - -7. 一局结束后若要继续采集下一局,先再次执行 `start()`;它会清空上一局并重新计时。若只想清空当前会话并继续,也可执行: - - ```js - window.__DXC_TRAVERSAL__.reset() - ``` - - 页面触发 `Exit()` 时会卸载接口,之后新的 `Init()` 会重新安装。建议每局使用一个独立 JSON 文件保存快照,并同时记录回放来源、局号、日期和版本/提交。 +历史采集结果继续保留在本文件与 +[`anonymous-slot-stage-1-comparison.md`](anonymous-slot-stage-1-comparison.md) 中,避免删除探针后丢失决策依据。 ## 5. 真实回放冲突频次 @@ -96,8 +51,7 @@ | 项目 | 值 | | ------------- | -------------------------- | -| 开始时间 UTC | `2026-07-19T08:40:16.248Z` | -| 结束时间 UTC | `2026-07-19T08:44:49.585Z` | +| 日期 | `2026-07-19` | | 采集时长 | 273.337 秒(约 4.56 分钟) | | 最终状态 | `active=false` | | 回放来源/局号 | 用户尚未提供 | @@ -135,8 +89,5 @@ 2. 主要压力集中在玩家来源身份置换(65 次)与匿名占位插回牌堆(59 次),两者合计占 99.2%,与阶段 1 先验证牌堆匿名化的方向直接相关。 3. 公共区来源置换只触发 1 次,占用回收与外部匿名兜底未触发;阶段 1 对照报告应分别观察各站点变化,不能只看五站点总数。 -后续动作: - -1. 补录本次回放来源、局号和版本/提交元数据。 -2. 阶段 1 隔离 spike 继续使用相同回放,比较五站点前后次数、遍历量和代码净增减。 -3. 是否全面推进阶段 2–6,仍由 G1 对照报告决定。 +后续执行结果:阶段 1 隔离 spike 已完成,三段对照回放中固定五站点均为零,且生产源码仍为净增。 +G1 最终决定 NO-GO / 收缩,保留匿名牌堆,不推进阶段 2–7;详见阶段 1 对照报告。 diff --git a/docs/anonymous-slot-stage-1-comparison.md b/docs/anonymous-slot-stage-1-comparison.md new file mode 100644 index 0000000..d0c0f7c --- /dev/null +++ b/docs/anonymous-slot-stage-1-comparison.md @@ -0,0 +1,168 @@ +# 匿名牌堆阶段 1 对照报告 + +> 日期:2026-07-20 +> 分支:`codex/anonymous-pile-spike-phase-1` +> 基线:`dev@c30be2d` +> 状态:已完成;G1 最终决定 NO-GO / 收缩 + +## 1. 实现范围 + +- `Room.initDeck` 不再用真实 ID 实体铺牌堆,改为等量匿名槽。 +- `deckIdentities` 保存本局已发现的真实身份全集,作为身份守恒的稳定基准。 +- `unlocatedIdentities` 保存尚未绑定到实体的身份,是 `deckIdentities` 的动态子集。 +- 公共区端点和玩家来源的首次揭示统一通过 `materialize` 原地物化。 +- `CardCounter` 为未定位身份建立静态牌面索引,并将其状态保持为 `UNKNOWN`。 +- 手牌旧式真 ID 暗手牌模型仍由阶段 1 interop 兼容,触发时记录 + `anonymousSlot:materializePlayerIdentityInterop`。 + +## 2. 代码规模 + +以下统计来自 `git diff --numstat`,不含计划文档和本报告。 + +| 范围 | 新增 | 删除 | 净变化 | +| -------------------------- | ---: | ---: | -----: | +| `src/tracker/`(7 个文件) | 323 | 201 | +122 | +| 已有测试文件(7 个) | 121 | 74 | +47 | +| 测试夹具(1 个) | 26 | 2 | +24 | +| 新增阶段 1 测试(1 个) | 105 | 0 | +105 | + +生产源码当前仍为净增,主要来自物化 API、身份守恒检查、CardCounter 身份注册和阶段 1 +interop。阶段 1 只处理牌堆,尚未到后续集中删除手牌交换路径的阶段,因此不能仅凭净行数判定 +最终收益。 + +## 3. 修复路径变化 + +| 路径 | 阶段 1 结果 | +| ---------------------------------------------- | ----------------------------------------------------------------------------------- | +| `recoverPlayerOccupiedIdentityForPublicReveal` | 运行时实现删除;由 `materializeExistingIdentityAtTarget` interop 接管旧式暗手牌占用 | +| `insertUnknownPlaceholderIntoPile` | 运行时实现删除;匿名槽直接精确替换原牌堆槽 | +| `swapKnownCardWithPublicSourcePlaceholder` | 仍为并存期兼容能力;匿名牌堆来源的新增测试中不再触发 | + +前两条旧名称仍存在于 `traversalStats` 的固定 G0 schema 中,用于让历史与新回放都能明确输出零值, +不代表运行时实现仍然存在。 + +## 4. 测试与冲突计数 + +- tracker 基线:33 个文件、250 项测试。 +- 阶段 1:34 个文件、254 项测试,全部通过。 +- 测试破坏面:修改 7 个测试文件,新增 + `tests/tracker/anonymousPileSpike.test.ts`,并调整 1 个通用 Room 夹具。 +- 新增覆盖:匿名牌堆初始化、未知摸牌、牌堆端点物化、游戏外身份扩展,以及同一身份的 + 牌顶/牌底候选分支传播。 +- 匿名牌堆明牌来源用例中,公共区 swap、玩家占用 recover、牌堆占位插入均为零触发。 +- 旧式真 ID 暗手牌被公共区揭示的兼容用例中,interop 计数为 1 次。 + +## 5. 遍历与性能 + +- `tests/tracker/traversalBaseline.test.ts` 及其快照无变化。 +- 阶段 1 未新增高频全牌池遍历。 +- 新增 interop 计数按兼容事件记录一次,不扫描完整牌池。 +- 自动化执行耗时只受本机负载影响,本报告不将单次 Vitest 构建时间作为性能结论。 + +## 6. 真实回放快照 + +用户提供了三段长生命周期统计快照;回放来源、局号及是否覆盖完整一局未标注。 + +| 样本 | 日期 | 持续时间 | 总调用数 | 总访问量 | G0 调用/访问 | +| ---- | ------------ | ---------: | -------: | -------: | -----------: | +| 1 | `2026-07-19` | 102,183 ms | 1,786 | 42,753 | 0 / 0 | +| 2 | `2026-07-19` | 54,590 ms | 179 | 4,971 | 0 / 0 | +| 3 | `2026-07-19` | 106,536 ms | 615 | 18,590 | 0 / 0 | +| 合计 | - | 263,309 ms | 2,580 | 66,314 | 0 / 0 | + +### 样本 1 站点 + +| 站点 | 调用数 | 访问量 | +| ---------------------------------------------- | -----: | -----: | +| `handSlotCounts:collectBySeat` | 312 | 24,664 | +| `resolveConstraints:constraint3:exclusion` | 325 | 6,634 | +| `reconcileAnonymousHandCards:group` | 203 | 4,601 | +| `resolveConstraints:constraint1` | 204 | 4,601 | +| `ambiguousKnownIndex:rebuild` | 5 | 805 | +| `cardCounter:update` | 178 | 430 | +| `locationIndex:applyDirty` | 178 | 236 | +| `resolveConstraints:playerSnapshotIncremental` | 204 | 237 | +| `ambiguousKnownIndex:applyDirty` | 174 | 223 | +| `locationIndex:rebuild` | 1 | 161 | +| `resolveConstraints:playerSnapshot` | 1 | 161 | +| `shufflePile:classify` | 1 | 0 | + +### 样本 2 站点 + +| 站点 | 调用数 | 访问量 | +| ---------------------------------------------- | -----: | -----: | +| `handSlotCounts:collectBySeat` | 30 | 2,251 | +| `resolveConstraints:constraint3:exclusion` | 19 | 630 | +| `reconcileAnonymousHandCards:group` | 22 | 610 | +| `resolveConstraints:constraint1` | 23 | 610 | +| `cardCounter:update` | 18 | 218 | +| `ambiguousKnownIndex:rebuild` | 1 | 161 | +| `locationIndex:rebuild` | 1 | 161 | +| `resolveConstraints:playerSnapshot` | 1 | 161 | +| `ambiguousKnownIndex:applyDirty` | 19 | 45 | +| `locationIndex:applyDirty` | 19 | 45 | +| `resolveConstraints:playerSnapshotIncremental` | 24 | 45 | +| `handExchange:playerHand` | 1 | 34 | +| `shufflePile:classify` | 1 | 0 | + +### 样本 3 站点 + +| 站点 | 调用数 | 访问量 | +| ---------------------------------------------- | -----: | -----: | +| `handSlotCounts:collectBySeat` | 114 | 10,082 | +| `resolveConstraints:constraint3:exclusion` | 73 | 2,205 | +| `reconcileAnonymousHandCards:group` | 74 | 2,126 | +| `resolveConstraints:constraint1` | 76 | 2,126 | +| `cardCounter:update` | 62 | 487 | +| `ambiguousKnownIndex:rebuild` | 2 | 322 | +| `locationIndex:rebuild` | 2 | 322 | +| `resolveConstraints:playerSnapshot` | 2 | 322 | +| `ambiguousKnownIndex:applyDirty` | 62 | 158 | +| `locationIndex:applyDirty` | 62 | 158 | +| `resolveConstraints:playerSnapshotIncremental` | 80 | 158 | +| `handExchange:playerHand` | 4 | 124 | +| `shufflePile:classify` | 2 | 0 | + +三段顶层 `sites` 中均没有任何 `anonymousSlot:*` 站点,展开的 `g0` 快照也直接确认五个站点 +及其合计均为 `calls: 0, visited: 0`: + +- G0 五个旧冲突/兜底站点均为 `calls: 0, visited: 0`。 +- `anonymousSlot:materializePlayerIdentityInterop` 为 `calls: 0, visited: 0`。 +- 三段样本均未因匿名牌堆触发旧修复路径,也没有触发旧式暗手牌身份 interop。 +- 样本 2、3 合计覆盖五次 `handExchange:playerHand`,该路径未引发匿名槽冲突修复。 +- 主要访问量仍集中在既有手牌计数与约束收敛路径;没有发现阶段 1 新增的高频遍历。 + +## 7. 自动化验证 + +- `pnpm lint` +- `pnpm exec prettier --check <本次改动文件>` +- `pnpm test:tracker` +- `pnpm typecheck:tracker` +- `pnpm build` +- `pnpm build:prod` +- `git diff --check` + +以上命令均通过。 + +## 8. G1 结论 + +当前证据: + +- 自动化回归全部通过,遍历基线无变化。 +- 三段累计 263.309 秒的真实回放样本中,旧冲突路径与阶段 1 interop 均为零触发。 +- 生产源码净增 122 行,尚未达到“净删代码可观”的 GO 条件。 +- 回放来源、完整性及观虚、权变、鹰视、诫厉、同区展示覆盖情况未知。 + +依据计划中的 G1 标准,最终决定 **NO-GO / 收缩**:保留阶段 1 的匿名牌堆作为终点,不推进阶段 +2 至阶段 6。该决定不是因为性能或正确性回退,而是三段样本显示冲突路径低频,同时阶段 1 +仍为净增代码,全面迁移的收益证据不足。 + +G0/G1 回放采集至此结束,临时浏览器回放探针已退役并完成代码清理。历史统计保留在本报告中; +后续只有出现新的非零旧冲突或高频 interop,并能证明继续迁移可带来可量化净删除时,才另立提案重新评估。 + +## 9. 探针清理 + +- 已移除 `window.__DXC_TRAVERSAL__` 浏览器控制入口与长生命周期 session API。 +- 已移除 `G0_TRAVERSAL_SITES` / `g0` 固定 schema。 +- 已移除生产路径中的 `anonymousSlot:*` 决策打点。 +- 已删除对应 browser/session/G0 基线测试;通用 `recordTraversal` 与 `traversalBaseline` 保留。 diff --git a/src/dom.js b/src/dom.js index e89ae1d..3ef3985 100644 --- a/src/dom.js +++ b/src/dom.js @@ -2,12 +2,7 @@ import { CardConfig } from './config' import { ConfigManager } from './config/ConfigManager' import { drawMiZhu, drawSeatUIs } from './draw' import { Game, globalConfig, globalState, UI } from './tracker' -import { - installTraversalStatsBrowserControl, - setTrackerSeatUIReader, - tracker, - uninstallTraversalStatsBrowserControl -} from './tracker/runtime/browser' +import { setTrackerSeatUIReader, tracker } from './tracker/runtime/browser' import { drawCitiesUI } from './ui/CitiesUI' import { bindDelegatedTooltips } from './ui/domHelpers' import { addDragHint, initDragElement } from './ui/drag' @@ -39,18 +34,12 @@ function getTrackedHandNumbers(seatID) { } export function Init() { - if (import.meta.env.DEV) { - installTraversalStatsBrowserControl(window) - } bindInitialResize(globalConfig, resize) console.info('🚀 初始化...') return waitForLegacyFrameReady(initFrame) } export function Exit() { - if (import.meta.env.DEV) { - uninstallTraversalStatsBrowserControl() - } return cleanupLifecycle({ resize, scheduleSetGameSize, SGSresize, globalState }) } diff --git a/src/tracker/Card.ts b/src/tracker/Card.ts index 40e850f..f4da09b 100644 --- a/src/tracker/Card.ts +++ b/src/tracker/Card.ts @@ -119,6 +119,17 @@ export class Card extends BaseCard { this.combinationID = null // 局部约束组 ID,用于记录这张牌参与的最近一次模糊分组 } + /** + * 把匿名槽原地升级为真实身份;位置、候选与区域引用保持不变。 + * Room 负责在调用后同步身份集合、cardIndex 与计数器。 + */ + materializeIdentity(cardID: CardID): void { + if (!isAnonymous(this) || cardID <= 0) return + + this.setCardInfo(cardID) + this.entityID = cardID + } + get resolvedSeat(): SeatID | null { if (this.location !== 'player' || this.seats.size !== 1) return null return this.seats.values().next().value ?? null diff --git a/src/tracker/CardCounter.ts b/src/tracker/CardCounter.ts index ce7e702..6a55471 100644 --- a/src/tracker/CardCounter.ts +++ b/src/tracker/CardCounter.ts @@ -103,6 +103,11 @@ export class CardCounter { * 依据 Room 物理卡牌池生成查询副本实例并建立索引 */ initInstances(): void { + // 未定位身份没有 Card 实体,但仍属于完整牌表,必须以 UNKNOWN 状态参与查询。 + this.room.unlocatedIdentities.forEach((cardID) => { + this.registerIdentity(cardID) + }) + this.room.cards.forEach((card) => { this.addCard(card) }) @@ -117,6 +122,7 @@ export class CardCounter { const isNewCard = this.registerCard(card) if (!isNewCard) { + if (card.id > 0) this.registerIdentity(card.id) this.syncCardStatus(card) this.dirtyCards.delete(card) return @@ -128,28 +134,7 @@ export class CardCounter { return } - if (!this.cardInstancesCache[card.id]) { - const instance = new CardInstance(card.id) - this.cardInstancesCache[card.id] = instance - } - - // 1. 建立倒排索引 - if (!(card.name in this.nameIndex)) { - this.nameIndex[card.name] = new Set() - } - this.nameIndex[card.name].add(card.id) - this.colorIndex[card.color].add(card.id) - this.numberIndex[card.number].add(card.id) - this.typeIndex[card.type].add(card.id) - - // 特殊花色与点数二次索引(如黑桃2-9闪电,杀花色) - if (card.color === 3 && card.number >= 2 && card.number <= 9) { - this.numberIndex[0].add(card.id) - } - - if (card.name.match(/^(冰|火|雷)?杀$/)) { - this.colorIndex[Math.ceil(card.color / 2) + 4].add(card.id) - } + this.registerIdentity(card.id) this.syncCardStatus(card) this.dirtyCards.delete(card) @@ -193,6 +178,36 @@ export class CardCounter { return true } + /** + * 为真实身份建立静态牌面索引;身份尚未定位时状态保持 UNKNOWN。 + */ + private registerIdentity(cardID: CardID): CardInstance { + const existing = this.cardInstancesCache[cardID] + if (existing) return existing + + const instance = new CardInstance(cardID) + this.cardInstancesCache[cardID] = instance + + if (!(instance.name in this.nameIndex)) { + this.nameIndex[instance.name] = new Set() + } + this.nameIndex[instance.name].add(cardID) + this.colorIndex[instance.color].add(cardID) + this.numberIndex[instance.number].add(cardID) + this.typeIndex[instance.type].add(cardID) + + if (instance.color === 3 && instance.number >= 2 && instance.number <= 9) { + this.numberIndex[0].add(cardID) + } + + if (instance.name.match(/^(冰|火|雷)?杀$/)) { + this.colorIndex[Math.ceil(instance.color / 2) + 4].add(cardID) + } + + this.statusIndexCache[CARD_INSTANCE_STATUS.UNKNOWN].add(cardID) + return instance + } + private collectNewRoomCards(): Card[] { if (this.roomCardCursor > this.room.cards.length) { // 房间重置或测试直接替换数组时回到保守路径;已注册牌会被过滤掉。 diff --git a/src/tracker/Room.ts b/src/tracker/Room.ts index 654f735..7bf3ff9 100644 --- a/src/tracker/Room.ts +++ b/src/tracker/Room.ts @@ -5,6 +5,7 @@ import { CardCounter, CARD_INSTANCE_STATUS } from './CardCounter' import { GameState } from './gameState' import { AmbiguousKnownIndex } from './AmbiguousKnownIndex' import { CardLocationIndex } from './CardLocationIndex' +import { POSITION_BOTTOM } from './candidate/cardPositions' import { summarizeMoveContext, summarizeMoveEvent } from './helper/moveSummary' import { RoomConstraints } from './roomConstraints' import { RoomMovement } from './roomMovement' @@ -18,6 +19,7 @@ import type { ConstraintGroup } from './ConstraintGroup' import type { CardID, MoveOptions, + PublicPosition, PublicZoneName, SeatID, SeatInfo, @@ -87,6 +89,16 @@ export class Room { isDeckReady = false cards: Card[] = [] cardIndex: Map = new Map() + /** + * 尚未绑定到任何 Card 实体的真实身份,是 deckIdentities 的动态子集。 + * 匿名槽只表达位置与数量;身份首次揭示后从这里移除并写入 cardIndex。 + */ + unlocatedIdentities: Set = new Set() + /** + * 本局已发现的真实身份全集,用作身份守恒的稳定基准。 + * 初始来自牌组;游戏外首次出现的新身份也会加入,但不会因物化或移动而移除。 + */ + deckIdentities: Set = new Set() anonymousEntitySeq = -1 declare players: Map declare zones: Map @@ -195,19 +207,21 @@ export class Room { this.isDeckReady = false this.cards.length = 0 this.cardIndex.clear() + this.unlocatedIdentities = new Set(cardIDs.filter((id) => id > 0)) + this.deckIdentities = new Set(this.unlocatedIdentities) this.anonymousEntitySeq = -1 - // 初始化摸牌堆 + // 牌堆只保存匿名物理槽,真实身份在揭示前统一留在 unlocatedIdentities。 const pile = this.zones.get('pile') this.zones.forEach((zone) => zone.clear()) const deckCards: Card[] = [] - for (const id of cardIDs) { - const card = new Card(id, this) + for (let index = 0; index < cardIDs.length; index += 1) { + const card = new Card(0, this) this.cards.push(card) deckCards.push(card) - this.cardIndex.set(card.id, card) } + pile.replaceAll(deckCards) this.locationIndex.rebuild(this) @@ -478,6 +492,139 @@ export class Room { .filter(Boolean) } + /** + * 按协议端点顺序取得公共区槽位;返回顺序始终是“端点向内”。 + */ + getPublicEndpointCards(zoneID: PublicZoneName, count: number, position: PublicPosition): Card[] { + const cards = this.zones.get(zoneID)?.cards ?? [] + if (!(count > 0)) return [] + if (position === POSITION_BOTTOM) return cards.slice(0, count) + return cards.slice(-count).reverse() + } + + /** + * 将真实身份绑定到匿名目标槽,并同步身份守恒账本与查询索引。 + * 已定位身份命中匿名目标时,仅允许阶段 1 的旧式暗手牌 interop 纠正槽位。 + */ + materialize(cardID: CardID, target: Card | null = null): Card | null { + const normalizedCardID = Number(cardID) + if (!(normalizedCardID > 0)) return null + + const existing = this.cardIndex.get(normalizedCardID) + if (existing) { + if (target && target !== existing && isAnonymous(target)) { + this.materializeExistingIdentityAtTarget(existing, target) + } + + existing.confirmKnown() + return existing + } + + if (!target || !isAnonymous(target)) return null + + // 游戏外首次出现的合法正 ID 不在初始牌组中,发现时扩展身份全集。 + if (!this.deckIdentities.has(normalizedCardID)) { + this.deckIdentities.add(normalizedCardID) + this.unlocatedIdentities.add(normalizedCardID) + } + + if (!this.unlocatedIdentities.has(normalizedCardID)) return null + + target.materializeIdentity(normalizedCardID) + target.confirmKnown() + this.cardIndex.set(normalizedCardID, target) + this.unlocatedIdentities.delete(normalizedCardID) + this.counter?.addCard(target) + + this.notifyCardChanged(target, { + type: 'card-identity-materialized', + cardID: normalizedCardID + }) + + return target + } + + /** + * 依次把公开身份物化到公共区端点的匿名槽,保持协议给出的展示顺序。 + */ + materializeAtPublicEndpoint( + cardIDs: CardID[], + zoneID: PublicZoneName, + position: PublicPosition + ): Card[] { + const targets = this.getPublicEndpointCards(zoneID, cardIDs.length, position) + const availableTargets = targets.filter(isAnonymous) + + return cardIDs + .map((cardID) => { + const existing = this.cardIndex.get(cardID) + const target = existing && targets.includes(existing) ? existing : availableTargets.shift() + return this.materialize(cardID, target ?? null) + }) + .filter((card): card is Card => Boolean(card)) + } + + /** + * 阶段 1 兼容旧式“暗手牌借用真实 ID”模型。 + * 将真实实体放入目标公共槽,并让被替换的匿名槽接管原玩家位置与候选。 + */ + private materializeExistingIdentityAtTarget(existing: Card, target: Card): void { + if (existing.location === 'player' && existing.isKnown !== true) { + const oldSubZone = existing.subZone ?? 'hand' + const oldSeats = Array.from(existing.seats, Number) + const oldSpellID = existing.spellID + const oldCombinationID = existing.combinationID + const oldLocationCandidates = existing.getLocationCandidates() + const targetZoneEntry = Array.from(this.zones.entries()).find(([, zone]) => + zone.cards.includes(target) + ) + + if (!targetZoneEntry) return + + const [targetZoneID, targetZone] = targetZoneEntry + this.removeCardsFromConstraintGroups([target]) + this.constraints.replaceCardInConstraintGroups(existing, target) + existing.setLocationCandidates([], 'materialize:interop:known') + existing.combinationID = null + existing.moveToPublicZone(targetZoneID) + targetZone.replaceCard(target, existing) + + if (oldLocationCandidates.length > 0) { + target.location = 'player' + target.subZone = oldSubZone + target.spellID = oldSpellID + target.suspended = false + target.setLocationCandidates(oldLocationCandidates, 'materialize:interop:placeholder') + target.setSeats(oldSeats, 'materialize:interop:placeholder') + target.combinationID = oldCombinationID + this.markCounterDirty(target) + this.notifyCardChanged(target, { + type: 'card-bound', + subZone: oldSubZone, + spellID: oldSpellID + }) + } else { + target.bindCandidates(oldSeats, oldSubZone, oldSpellID, { known: false }) + target.combinationID = oldCombinationID + } + + this.movement.replaceHiddenMarkPlaceholder(existing, target) + return + } + + if (existing.location !== 'outside' && existing.location !== 'suspended') return + + const targetZoneEntry = Array.from(this.zones.entries()).find(([, zone]) => + zone.cards.includes(target) + ) + if (!targetZoneEntry) return + + const [targetZoneID, targetZone] = targetZoneEntry + existing.moveToPublicZone(targetZoneID) + targetZone.replaceCard(target, existing) + target.moveToPublicZone('outside') + } + /** * 为游戏外新出现的牌创建实体;用于回收区、临时区缺失等兜底场景。 */ @@ -488,15 +635,14 @@ export class Room { createExternalCards(cardIDs: CardID[] = [], count = cardIDs.length): Card[] { const ids = cardIDs.filter((id) => id > 0) const unknownCount = Math.max(0, Number(count) || 0, cardIDs.length) - ids.length - if (unknownCount > 0) { - recordTraversal('anonymousSlot:createExternalCardsFallback', unknownCount) - } const cards = [ ...ids.map((id) => { const card = new Card(id, this) this.cards.push(card) + this.deckIdentities.add(card.id) this.cardIndex.set(card.id, card) + this.unlocatedIdentities.delete(card.id) return card }), ...Array.from({ length: unknownCount }, () => { @@ -1392,6 +1538,26 @@ export class Room { } }) + this.deckIdentities.forEach((cardID) => { + const located = this.cardIndex.has(cardID) + const unlocated = this.unlocatedIdentities.has(cardID) + if (located === unlocated) { + identityIssues.push({ + type: located ? 'identity-both-located-and-unlocated' : 'identity-missing', + cardID + }) + } + }) + + this.unlocatedIdentities.forEach((cardID) => { + if (cardID <= 0 || !this.deckIdentities.has(cardID)) { + identityIssues.push({ + type: 'invalid-unlocated-identity', + cardID + }) + } + }) + const slotIssues = this.publicZones.getPublicZoneConsistencyIssues() const issues = [ ...identityIssues.map((issue) => ({ domain: 'identity', ...issue })), @@ -1563,6 +1729,8 @@ export class Room { this.viewDirty = false this.cards = [] this.cardIndex.clear() + this.unlocatedIdentities.clear() + this.deckIdentities.clear() this.anonymousEntitySeq = -1 this.isDeckReady = false this.seatIDs = [] diff --git a/src/tracker/roomMovement.ts b/src/tracker/roomMovement.ts index 1135005..c856bf8 100644 --- a/src/tracker/roomMovement.ts +++ b/src/tracker/roomMovement.ts @@ -1,5 +1,5 @@ import { trackerLogger } from '@/utils/logger' -import type { Card } from './Card' +import { isAnonymous, type Card } from './Card' import { POSITION_TOP } from './candidate/cardPositions' import { normalizeSpellID } from './candidate/markSpellID' import { summarizeMoveContext } from './helper/moveSummary' @@ -130,8 +130,16 @@ export class RoomMovement extends RoomMovementCandidateMethods { * 来源为 outside 时允许补建外部新出现的物理牌。 */ resolveKnownMoveCards(context: RoomMoveContext): void { - const { knownIDs, sourceIsOutside } = context - let missingIDs: CardID[] = [] + const { + fromPosition, + fromSeat, + fromSubZone, + fromZone, + knownIDs, + sourceCards, + sourceIsOutside + } = context + let missingIDs: CardID[] let createdCards: Card[] = [] // 12 区会暂存未进入初始牌池的技能生成牌,获得时需要按协议正 ID 补建实体。 const canCreateMissingCards = sourceIsOutside || context.fromZone === 'exile' @@ -146,8 +154,73 @@ export class RoomMovement extends RoomMovementCandidateMethods { existingCards.forEach((card) => cardMap.set(card.id, card)) createdCards.forEach((card) => cardMap.set(card.id, card)) context.knownCards = knownIDs.map((id) => cardMap.get(id)).filter(Boolean) + } else if (typeof fromZone === 'string' && this.room.zones.has(fromZone)) { + // 公共区来源按协议端点顺序物化,避免从别处搬运被提前占用的真实实体。 + const sourceZone = this.room.zones.get(fromZone) + const endpointCards = this.room.getPublicEndpointCards( + fromZone, + sourceZone?.cards.length ?? knownIDs.length, + fromPosition + ) + const availableTargets = endpointCards.filter(isAnonymous) + + context.knownCards = knownIDs + .map((cardID) => { + const existing = this.room.cardIndex.get(cardID) + const existingInSource = existing && sourceZone?.cards.includes(existing) + const target = existingInSource ? existing : availableTargets.shift() + return this.room.materialize(cardID, target ?? null) ?? existing + }) + .filter((card): card is Card => Boolean(card)) + missingIDs = knownIDs.filter((id) => !this.room.cardIndex.has(id)) + + // 端点无匿名槽可物化时,与 outside/exile 一致:按协议正 ID 补建外部实体,避免 knownCards 静默短少。 + if (missingIDs.length > 0) { + createdCards = this.room.createExternalCards(missingIDs, missingIDs.length) + const cardMap = new Map(context.knownCards.map((card) => [card.id, card])) + createdCards.forEach((card) => cardMap.set(card.id, card)) + context.knownCards = knownIDs + .map((id) => cardMap.get(id)) + .filter((card): card is Card => Boolean(card)) + missingIDs = knownIDs.filter((id) => !cardMap.has(id)) + } + } else if (fromSeat !== null && !Number.isNaN(fromSeat) && fromSubZone) { + // 阶段 1 的玩家区既可能是匿名槽,也可能仍沿用真 ID 暗牌,两者统一由 materialize 收口。 + const explicitTargets = sourceCards?.filter(isAnonymous) ?? [] + const sourceTargets = + explicitTargets.length > 0 + ? explicitTargets + : this.getUnknownPlayerSourceCards( + fromSeat, + fromSubZone, + context.fromSpellID ?? context.spellID + ) + .filter(isAnonymous) + .slice(0, knownIDs.length) + const availableTargets = [...sourceTargets] + + context.knownCards = knownIDs + .map((cardID) => { + const existing = this.room.cardIndex.get(cardID) + const existingInSource = existing && this.isCardInPlayerSource(existing, context) + const target = existingInSource ? existing : availableTargets.shift() + return this.room.materialize(cardID, target ?? null) ?? existing + }) + .filter((card): card is Card => Boolean(card)) + missingIDs = knownIDs.filter((id) => !this.room.cardIndex.has(id)) + + if (!this.room.players.has(fromSeat) && missingIDs.length > 0) { + createdCards = this.room.createExternalCards(missingIDs, missingIDs.length) + const cardMap = new Map(context.knownCards.map((card) => [card.id, card])) + createdCards.forEach((card) => cardMap.set(card.id, card)) + context.knownCards = knownIDs + .map((id) => cardMap.get(id)) + .filter((card): card is Card => Boolean(card)) + missingIDs = knownIDs.filter((id) => !cardMap.has(id)) + } } else { context.knownCards = this.room.findCardsByIDs(knownIDs) + missingIDs = knownIDs.filter((id) => !this.room.cardIndex.has(id)) } const resumedCardIDs: CardID[] = [] @@ -156,6 +229,7 @@ export class RoomMovement extends RoomMovementCandidateMethods { this.room.resumeSuspendedKnownCard(card) if (wasSuspended) resumedCardIDs.push(card.id) }) + trackerLogger.debug('已知牌解析完成', { knownIDs, resolvedCardIDs: context.knownCards.map((card) => card.id), diff --git a/src/tracker/roomMovement/candidates.ts b/src/tracker/roomMovement/candidates.ts index 40592e0..a005f84 100644 --- a/src/tracker/roomMovement/candidates.ts +++ b/src/tracker/roomMovement/candidates.ts @@ -293,6 +293,7 @@ export class RoomMovementCandidateMethods extends RoomMovementSourceMethods { const nextPublicLocationCandidates = nextPublicCandidates .map((candidate) => fromPublicCandidate(candidate)) .filter((candidate): candidate is PublicLocationCandidate => Boolean(candidate)) + // 同一身份可同时有牌顶与牌底分支;只消费本次命中的端点,其他公共分支必须保留。 const retainedLocationCandidates = card .getLocationCandidates() .filter( diff --git a/src/tracker/roomMovement/sources.ts b/src/tracker/roomMovement/sources.ts index 8916718..d77c795 100644 --- a/src/tracker/roomMovement/sources.ts +++ b/src/tracker/roomMovement/sources.ts @@ -2,7 +2,6 @@ import { trackerLogger } from '@/utils/logger' import { POSITION_TOP } from '../candidate/cardPositions' import { getCompatibleMarkSpellIDs, type SpellIDInput } from '../candidate/markSpellID' import { isAnonymous, type Card } from '../Card' -import { recordTraversal } from '../traversalStats' import type { PlayerLocationCandidate, PublicPosition, @@ -350,7 +349,6 @@ export class RoomMovementSourceMethods extends RoomMovementHiddenMarkMethods { swapKnownCardWithPlayerSourcePlaceholder(card: Card, context: RoomMoveContext): Card | null { const placeholder = this.findUnknownPlayerSourcePlaceholder(context, card) if (!placeholder) return null - recordTraversal('anonymousSlot:swapKnownCardWithPlayerSourcePlaceholder', 1) const { fromSeat, fromSubZone } = context const sourceSpellID = this.getSourceSpellID(context) @@ -479,12 +477,12 @@ export class RoomMovementSourceMethods extends RoomMovementHiddenMarkMethods { return } - zone.removeCard(card) - // 确定明牌槽不能被占位污染;若牌堆顶已是连续明牌(如观虚刚展示的牌顶), - // 占位必须插到该明牌段下方,否则会盖住端点明牌并制造“N 暗 + 牌顶明牌”的假象。 if (oldLocation === 'pile') { - this.insertUnknownPlaceholderIntoPile(zone, placeholder) + // 匿名槽接管真实牌原位置,精确替换可保持牌顶/牌底已确认片段不变。 + placeholder.moveToPublicZone(oldLocation) + zone.replaceCard(card, placeholder) } else { + zone.removeCard(card) zone.add(placeholder, POSITION_TOP) } return @@ -517,38 +515,6 @@ export class RoomMovementSourceMethods extends RoomMovementHiddenMarkMethods { ) } - /** - * 把无公共候选的暗占位放回牌堆时,避免盖住已确认的牌堆顶明牌段。 - * 全是明牌时保持旧语义:占位落到牌顶。 - */ - insertUnknownPlaceholderIntoPile( - zone: { - cards: Card[] - add: (cards: Card | Card[], position?: typeof POSITION_TOP) => void - remove: (count: number, position?: typeof POSITION_TOP) => Card[] - }, - placeholder: Card - ): void { - recordTraversal('anonymousSlot:insertUnknownPlaceholderIntoPile', 1) - const pileCards = zone.cards - let knownTopCount = 0 - for (let i = pileCards.length - 1; i >= 0; i -= 1) { - if (pileCards[i]?.isKnown === true) knownTopCount += 1 - else break - } - - // 没有牌顶明牌段,或整堆都是明牌时,沿用牌顶回补,兼容既有单牌置换回归。 - if (knownTopCount === 0 || knownTopCount === pileCards.length) { - zone.add(placeholder, POSITION_TOP) - return - } - - // remove(TOP) 返回顶 -> 内;add(TOP) 按数组顺序 push,末张成为新牌顶。 - const knownTopCards = zone.remove(knownTopCount, POSITION_TOP) - zone.add(placeholder, POSITION_TOP) - zone.add([...knownTopCards].reverse(), POSITION_TOP) - } - /** * 从牌堆摸到明牌时,CardID 可能正被其他座位的暗手牌占位实体占用。 * 此时不能直接把该实体搬到当前玩家手里,而要先从牌堆取一张未知实体替回原暗位。 @@ -567,7 +533,6 @@ export class RoomMovementSourceMethods extends RoomMovementHiddenMarkMethods { const replacement = this.takeCardsFromPublicZone(1, fromZone, fromPosition)[0] if (!replacement) return null - recordTraversal('anonymousSlot:swapKnownCardWithPublicSourcePlaceholder', 1) this.room.removeCardsFromConstraintGroups([replacement]) @@ -591,6 +556,7 @@ export class RoomMovementSourceMethods extends RoomMovementHiddenMarkMethods { fromPosition, previousZoneID }) + return replacement } @@ -669,6 +635,7 @@ export class RoomMovementSourceMethods extends RoomMovementHiddenMarkMethods { sourceEvent }) } + return externalCards } diff --git a/src/tracker/runtime/browser.ts b/src/tracker/runtime/browser.ts index 60838d4..3940b62 100644 --- a/src/tracker/runtime/browser.ts +++ b/src/tracker/runtime/browser.ts @@ -1,9 +1 @@ -import { installTraversalStatsBrowserControl } from './traversalStatsBrowser' - -if (import.meta.env.DEV && typeof window !== 'undefined') { - installTraversalStatsBrowserControl(window) -} - export { setTrackerSeatUIReader, tracker } from './bridge' -export { installTraversalStatsBrowserControl } from './traversalStatsBrowser' -export { uninstallTraversalStatsBrowserControl } from './traversalStatsBrowser' diff --git a/src/tracker/runtime/trackerController.ts b/src/tracker/runtime/trackerController.ts index 9604ada..1a6beaa 100644 --- a/src/tracker/runtime/trackerController.ts +++ b/src/tracker/runtime/trackerController.ts @@ -1,6 +1,5 @@ import { POSITION_BOTTOM, POSITION_TOP } from '../candidate/cardPositions' import type { Card } from '../Card' -import { recordTraversal } from '../traversalStats' import { summarizeMoveEvent } from '../helper/moveSummary' import { getProtocolMoveSpecialLabel, normalizeMoveEvent } from '../MoveEventNormalizer' import { @@ -382,27 +381,21 @@ export class TrackerController { ) if (subZone && !Number.isNaN(seatID) && seatID !== 255) { - // 同区展示可能首次公开由匿名实体承载的游戏外卡牌;先注册真实 ID, - // 标准移动链路才能用新实体置换来源手牌中的匿名占位。 - const knownCards = this.resolveKnownCards(ids) - const resolvedIDs = knownCards.map((card) => card.id) - if (resolvedIDs.length === 0) return - this.controllerLogger.info('展示明牌进入玩家区', { - ids: resolvedIDs, + ids, seatID, subZone, spellID }) - readyRoom.moveCards(resolvedIDs, 'player', { + readyRoom.moveCards(ids, 'player', { seatID, fromSeatID: seatID, fromZone: null, fromSubZone: subZone, subZone, spellID, - cardCount: resolvedIDs.length, + cardCount: ids.length, sourceEvent: event.options?.sourceEvent ?? { type: 'showCards', raw @@ -413,8 +406,15 @@ export class TrackerController { } const zoneName = getProtocolPublicZone(zone, event.toZone ?? 'process') - const knownCards = this.resolveKnownCards(ids) const position = event.options?.position ?? event.options?.fromPosition ?? POSITION_TOP + const sourceZoneName = getProtocolPublicZone(raw.FromZone) + const materializationZone = + sourceZoneName && sourceZoneName !== zoneName ? sourceZoneName : zoneName + const knownCards = this.resolveKnownCards(ids, { + zoneName: materializationZone, + position: event.options?.fromPosition ?? position + }) + this.controllerLogger.info('展示明牌进入公共区', { ids, zoneName, @@ -422,7 +422,6 @@ export class TrackerController { resolvedCount: knownCards.length }) - const sourceZoneName = getProtocolPublicZone(raw.FromZone) if (sourceZoneName && sourceZoneName !== zoneName) { // 判定获得既会展示牌面,也会把实体移出来源公共区;复用标准移动链路完成来源置换, // 并清空协议残留的玩家槽位,避免公共区来源被误判为手牌。 @@ -434,6 +433,7 @@ export class TrackerController { fromSubZone: null, cardCount: ids.length }) + return } @@ -489,7 +489,7 @@ export class TrackerController { } else if (target.type === 'public') { const zoneName = target.zoneName ?? 'pile' const position = target.position ?? POSITION_TOP - const knownCards = this.resolveKnownCards(ids) + const knownCards = this.resolveKnownCards(ids, { zoneName, position }) this.placePublicRevealCards(knownCards, zoneName, { position, reposition: target.reposition === true, @@ -525,26 +525,11 @@ export class TrackerController { const position = options.position ?? POSITION_TOP const targetZone = readyRoom.zones.get(zoneName) - // 协议确认某正 ID 位于公共区端点时,若该身份正被玩家区实体占用, - // 先在玩家区留下匿名占位,再把真实身份收回目标公共区。 - // 不能直接 Zone.add 抽走玩家实体,也不能永久跳过导致牌顶缺牌。 const placeableCards: Card[] = [] const skippedCards: Card[] = [] - const revealCardSet = new Set(knownCards) - knownCards.forEach((card) => { if (card.location === 'player') { - const recovered = this.recoverPlayerOccupiedIdentityForPublicReveal( - card, - zoneName, - position, - revealCardSet - ) - if (recovered) { - placeableCards.push(card) - return - } skippedCards.push(card) return } @@ -600,111 +585,6 @@ export class TrackerController { readyRoom.resolveConstraints() } - /** - * 公共区展示命中“正 ID 仍被玩家区实体占用”时,用公共区未知实体(优先)或匿名占位 - * 接管原玩家槽位,再把真实身份交给公共区落点。 - * 优先从目标公共区换出未知实体,避免 createExternal 导致牌堆张数 +1。 - */ - private recoverPlayerOccupiedIdentityForPublicReveal( - card: Card, - zoneName: PublicZoneName, - position: PublicPosition, - excludedCards: Set = new Set() - ): boolean { - const readyRoom = this.getReadyTrackerRoom() - if (!readyRoom || card.location !== 'player' || card.id <= 0) return false - - const oldSubZone = card.subZone ?? 'hand' - const oldSeats = Array.from(card.seats, Number) - const oldSpellID = card.spellID - const oldCombinationID = card.combinationID - const oldLocationCandidates = card.getLocationCandidates() - const oldIsKnown = card.isKnown === true - const targetZone = readyRoom.zones.get(zoneName) - - // 优先从揭示端点附近取未知实体作为玩家占位,同时保护该端点已经确认的连续明牌段。 - // Zone 内部顺序固定为底 -> 顶,因此牌顶从尾部反查,牌底从头部正查。 - let placeholder: Card | null = null - let placeholderFromPublicZone = false - if (targetZone) { - const zoneCards = targetZone.cards - const endpointCards = position === POSITION_BOTTOM ? zoneCards : [...zoneCards].reverse() - const protectedKnownCount = - zoneName === 'pile' ? endpointCards.findIndex((candidate) => candidate.isKnown !== true) : 0 - const knownCount = protectedKnownCount < 0 ? endpointCards.length : protectedKnownCount - const unknownPool = endpointCards.slice(knownCount) - - placeholder = - unknownPool.find( - (candidate) => - candidate !== card && !excludedCards.has(candidate) && candidate.isKnown !== true - ) ?? null - - if (placeholder) { - targetZone.removeCard(placeholder) - placeholderFromPublicZone = true - } - } - - if (!placeholder) { - placeholder = readyRoom.createExternalCards([], 1)[0] ?? null - if (placeholder) { - this.controllerLogger.warn('公共区展示回收身份时目标区无未知实体,已创建匿名占位', { - cardID: card.id, - zoneName, - placeholderEntityID: placeholder.entityID - }) - } - } - if (!placeholder) return false - - // 公共区未知实体通常无约束组;若有先摘除,再接管玩家约束组,避免把公共组名额带进玩家槽。 - readyRoom.removeCardsFromConstraintGroups([placeholder]) - readyRoom.constraints.replaceCardInConstraintGroups(card, placeholder) - if (oldLocationCandidates.length > 0) { - placeholder.location = 'player' - placeholder.subZone = oldSubZone - placeholder.spellID = oldSpellID - placeholder.suspended = false - placeholder.isKnown = false - placeholder.setLocationCandidates( - oldLocationCandidates, - 'recoverPlayerOccupiedIdentityForPublicReveal:placeholder' - ) - placeholder.setSeats(oldSeats, 'recoverPlayerOccupiedIdentityForPublicReveal:placeholder') - placeholder.combinationID = oldCombinationID - readyRoom.markCounterDirty(placeholder) - readyRoom.notifyCardChanged(placeholder, { - type: 'card-bound', - subZone: oldSubZone, - spellID: oldSpellID - }) - } else { - placeholder.bindCandidates(oldSeats, oldSubZone, oldSpellID, { known: false }) - placeholder.combinationID = oldCombinationID - } - - // 真实身份脱离玩家区,等待后续公共区落点。 - card.setLocationCandidates([], 'recoverPlayerOccupiedIdentityForPublicReveal:known') - card.combinationID = null - card.moveToPublicZone('outside') - card.confirmKnown() - recordTraversal('anonymousSlot:recoverPlayerOccupiedIdentityForPublicReveal', 1) - - this.controllerLogger.info('公共区展示回收玩家区占用身份', { - cardID: card.id, - zoneName, - placeholderCardID: placeholder.id, - placeholderEntityID: placeholder.entityID, - placeholderFromPublicZone, - seats: oldSeats, - subZone: oldSubZone, - previousKnown: oldIsKnown - }) - - return true - } - /** * 把旧协议 Zone 标识转换为 revealTrackerCards 可理解的目标。 * 该入口用于知己知彼、观星类“按协议区明示卡牌”的技能。 @@ -814,15 +694,25 @@ export class TrackerController { } /** - * 按物理 ID 取得 Card 实体;若明牌来自游戏外区域且牌池没有实体,则补建外部牌。 + * 按物理 ID 取得 Card 实体;公共区展示优先在端点匿名槽物化。 + * 只有身份既未定位、也不属于本局待定位集合时,才按游戏外新牌补建实体。 */ - private resolveKnownCards(ids: CardID[]): Card[] { + private resolveKnownCards( + ids: CardID[], + target: { zoneName: PublicZoneName; position: PublicPosition } | null = null + ): Card[] { const readyRoom = this.getReadyTrackerRoom() if (!readyRoom) return [] + if (target) { + readyRoom.materializeAtPublicEndpoint(ids, target.zoneName, target.position) + } + const existingCards = readyRoom.findCardsByIDs(ids) const existingIDs = new Set(existingCards.map((card) => card.id)) - const missingIDs = ids.filter((id) => !existingIDs.has(id)) + const missingIDs = ids.filter( + (id) => !existingIDs.has(id) && !readyRoom.unlocatedIdentities.has(id) + ) const createdCards = missingIDs.length > 0 ? readyRoom.createExternalCards(missingIDs, missingIDs.length) : [] const cardMap = new Map() diff --git a/src/tracker/runtime/traversalStatsBrowser.ts b/src/tracker/runtime/traversalStatsBrowser.ts deleted file mode 100644 index 53f9ec8..0000000 --- a/src/tracker/runtime/traversalStatsBrowser.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - resetTraversalStatsSession, - snapshotTraversalStatsSession, - startTraversalStatsSession, - stopTraversalStatsSession -} from '../traversalStats' -import type { TraversalStatsSnapshot } from '../traversalStats' - -export interface TraversalStatsBrowserControl { - start: () => TraversalStatsSnapshot - snapshot: () => TraversalStatsSnapshot - stop: () => TraversalStatsSnapshot - reset: () => TraversalStatsSnapshot -} - -export const traversalStatsBrowserControl: TraversalStatsBrowserControl = { - start: startTraversalStatsSession, - snapshot: snapshotTraversalStatsSession, - stop: stopTraversalStatsSession, - reset: resetTraversalStatsSession -} - -export function installTraversalStatsBrowserControl(target: Window = window): void { - if (!import.meta.env.DEV) return - target.__DXC_TRAVERSAL__ = traversalStatsBrowserControl -} - -export function uninstallTraversalStatsBrowserControl(target: Window = window): void { - if (target.__DXC_TRAVERSAL__ === traversalStatsBrowserControl) { - delete target.__DXC_TRAVERSAL__ - } -} diff --git a/src/tracker/traversalStats.ts b/src/tracker/traversalStats.ts index 438a220..c58ebb9 100644 --- a/src/tracker/traversalStats.ts +++ b/src/tracker/traversalStats.ts @@ -1,6 +1,6 @@ /** * Room.cards 遍历计数插桩。 - * 默认关闭:没有同步 collect 或长生命周期 session 时,每个采样点只付空判断成本。 + * 默认关闭:没有同步 collect 时,每个采样点只付空判断成本。 */ export interface TraversalSiteStats { calls: number @@ -11,38 +11,7 @@ export interface TraversalStats { sites: Map } -export const G0_TRAVERSAL_SITES = [ - 'anonymousSlot:swapKnownCardWithPublicSourcePlaceholder', - 'anonymousSlot:swapKnownCardWithPlayerSourcePlaceholder', - 'anonymousSlot:recoverPlayerOccupiedIdentityForPublicReveal', - 'anonymousSlot:insertUnknownPlaceholderIntoPile', - 'anonymousSlot:createExternalCardsFallback' -] as const - -export type G0TraversalSite = (typeof G0_TRAVERSAL_SITES)[number] - -export interface G0TraversalStatsSnapshot { - totals: TraversalSiteStats - sites: Record -} - -export interface TraversalStatsSnapshot { - active: boolean - startedAt: string | null - capturedAt: string - elapsedMs: number - totals: TraversalSiteStats - sites: Record - g0: G0TraversalStatsSnapshot -} - -interface TraversalStatsSession { - stats: TraversalStats - startedAt: number -} - let activeStats: TraversalStats | null = null -let activeSession: TraversalStatsSession | null = null function createStats(): TraversalStats { return { sites: new Map() } @@ -59,64 +28,16 @@ function addTraversal(stats: TraversalStats, site: string, visitedCount: number) stats.sites.set(site, { calls: 1, visited: visitedCount }) } -function createSnapshot( - session: TraversalStatsSession | null, - active: boolean -): TraversalStatsSnapshot { - const capturedAt = Date.now() - const sites: Record = {} - let totalCalls = 0 - let totalVisited = 0 - - if (session) { - Array.from(session.stats.sites.entries()) - .sort(([left], [right]) => left.localeCompare(right)) - .forEach(([site, entry]) => { - sites[site] = { ...entry } - totalCalls += entry.calls - totalVisited += entry.visited - }) - } - - const g0Sites = Object.fromEntries( - G0_TRAVERSAL_SITES.map((site) => [site, { ...(sites[site] ?? { calls: 0, visited: 0 }) }]) - ) as Record - const g0Totals = Object.values(g0Sites).reduce( - (totals, entry) => ({ - calls: totals.calls + entry.calls, - visited: totals.visited + entry.visited - }), - { calls: 0, visited: 0 } - ) - - return { - active, - startedAt: session ? new Date(session.startedAt).toISOString() : null, - capturedAt: new Date(capturedAt).toISOString(), - elapsedMs: session ? Math.max(0, capturedAt - session.startedAt) : 0, - totals: { calls: totalCalls, visited: totalVisited }, - sites, - g0: { - totals: g0Totals, - sites: g0Sites - } - } -} - /** - * 记录一次遍历或冲突修复采样。 - * 同步 collect 与长生命周期 session 可同时启用:同步统计保持原有嵌套隔离,session 继续累计全程数据。 + * 记录一次遍历采样。 */ export function recordTraversal(site: string, visitedCount: number): void { if (activeStats) addTraversal(activeStats, site, visitedCount) - if (activeSession && activeSession.stats !== activeStats) { - addTraversal(activeSession.stats, site, visitedCount) - } } /** * 在同步计数上下文中执行 run,返回其结果与期间累计统计。 - * 支持嵌套调用:内层同步统计不会泄漏到外层;若长 session 已启动,事件仍会进入 session。 + * 支持嵌套调用:内层同步统计不会泄漏到外层。 */ export function collectTraversalStats(run: () => T): { result: T; stats: TraversalStats } { const previousStats = activeStats @@ -129,35 +50,3 @@ export function collectTraversalStats(run: () => T): { result: T; stats: Trav activeStats = previousStats } } - -/** 开始新的长生命周期统计会话;重复调用会清空旧会话并重新计时。 */ -export function startTraversalStatsSession(): TraversalStatsSnapshot { - activeSession = { - stats: createStats(), - startedAt: Date.now() - } - return createSnapshot(activeSession, true) -} - -/** 返回当前会话的 JSON 友好快照,不停止采集。 */ -export function snapshotTraversalStatsSession(): TraversalStatsSnapshot { - return createSnapshot(activeSession, activeSession !== null) -} - -/** 停止采集并返回最终快照;没有活动会话时返回空的非活动快照。 */ -export function stopTraversalStatsSession(): TraversalStatsSnapshot { - const session = activeSession - activeSession = null - return createSnapshot(session, false) -} - -/** 清空当前会话的统计并重新计时;没有活动会话时保持关闭并返回空快照。 */ -export function resetTraversalStatsSession(): TraversalStatsSnapshot { - if (!activeSession) return createSnapshot(null, false) - - activeSession = { - stats: createStats(), - startedAt: Date.now() - } - return createSnapshot(activeSession, true) -} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 4eab703..c724098 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,5 +1,3 @@ -import type { TraversalStatsBrowserControl } from '@/tracker/runtime/traversalStatsBrowser' - export {} declare global { @@ -10,7 +8,6 @@ declare global { [key: string]: unknown } padding?: unknown - __DXC_TRAVERSAL__?: TraversalStatsBrowserControl } const unsafeWindow: Window | undefined diff --git a/tests/tracker/anonymousIdentity.test.ts b/tests/tracker/anonymousIdentity.test.ts index 3fc6687..669d88c 100644 --- a/tests/tracker/anonymousIdentity.test.ts +++ b/tests/tracker/anonymousIdentity.test.ts @@ -30,6 +30,7 @@ describe('阶段 0 匿名身份与守恒观测', () => { it('重复真实身份只告警不抛错', () => { const { room } = createTestRoom({ cardIDs: [1, 2, 3], seatIDs: [1] }) + getCard(room, 1) const duplicate = new Card(1, room) room.cards.push(duplicate) room.zones.get('outside')!.add(duplicate) diff --git a/tests/tracker/anonymousPileSpike.test.ts b/tests/tracker/anonymousPileSpike.test.ts new file mode 100644 index 0000000..02c0fdc --- /dev/null +++ b/tests/tracker/anonymousPileSpike.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import { isAnonymous } from '@/tracker/Card' +import { CARD_INSTANCE_STATUS } from '@/tracker/CardCounter' +import { createTestRoom } from './helpers/room' + +describe('阶段 1 匿名牌堆 spike', () => { + it('牌堆初始化为匿名槽并把整副牌登记为未定位身份', () => { + const { room } = createTestRoom({ + cardIDs: [1, 2, 3], + seatIDs: [1], + materializeDeckIdentities: false + }) + const pile = room.zones.get('pile')! + + expect(pile.cards).toHaveLength(3) + expect(pile.cards.every(isAnonymous)).toBe(true) + expect(new Set(pile.cards.map((card) => card.entityID)).size).toBe(3) + expect(room.cardIndex.size).toBe(0) + expect(room.unlocatedIdentities).toEqual(new Set([1, 2, 3])) + expect(room.counter.statusIndex[CARD_INSTANCE_STATUS.UNKNOWN]).toEqual(new Set([1, 2, 3])) + }) + + it('未知摸牌只移动匿名槽且不提前认领真实身份', () => { + const { room } = createTestRoom({ + cardIDs: [1, 2, 3, 4], + seatIDs: [1], + materializeDeckIdentities: false + }) + + room.moveCards([], 'player', { + seatID: 1, + fromZone: 'pile', + cardCount: 2, + sourceEvent: { type: 'stage1:draw-unknown' } + }) + + const hiddenHand = room.cards.filter( + (card) => card.location === 'player' && card.subZone === 'hand' + ) + expect(hiddenHand).toHaveLength(2) + expect(hiddenHand.every(isAnonymous)).toBe(true) + expect(room.zones.get('pile')!.cards).toHaveLength(2) + expect(room.cardIndex.size).toBe(0) + expect(room.unlocatedIdentities).toEqual(new Set([1, 2, 3, 4])) + }) + + it('牌堆来源明牌原地物化且不触发旧冲突修复路径', () => { + const { room } = createTestRoom({ + cardIDs: [1, 2, 3, 4], + seatIDs: [1], + materializeDeckIdentities: false + }) + const pile = room.zones.get('pile')! + const originalTopSlot = pile.cards.at(-1) + + room.moveCards([4], 'player', { + seatID: 1, + fromZone: 'pile', + cardCount: 1, + sourceEvent: { type: 'stage1:draw-known' } + }) + + const card = room.cardIndex.get(4) + expect(card).toBe(originalTopSlot) + expect(card?.entityID).toBe(4) + expect(card?.location).toBe('player') + expect(card?.isKnown).toBe(true) + expect(room.unlocatedIdentities).toEqual(new Set([1, 2, 3])) + expect(pile.cards).toHaveLength(3) + }) + + it('游戏外匿名手牌首次揭示时扩展并物化身份全集', () => { + const { room } = createTestRoom({ + cardIDs: [1], + seatIDs: [1], + materializeDeckIdentities: false + }) + const [placeholder] = room.createExternalCards([], 1) + placeholder.bindCandidates([1], 'hand', null, { known: false }) + + room.moveCards([60992], 'player', { + seatID: 1, + fromSeatID: 1, + fromZone: null, + fromSubZone: 'hand', + subZone: 'hand', + cardCount: 1, + sourceEvent: { type: 'stage1:reveal-external-hand' } + }) + + expect(room.cardIndex.get(60992)).toBe(placeholder) + expect(placeholder.id).toBe(60992) + expect(placeholder.isKnown).toBe(true) + expect(room.deckIdentities.has(60992)).toBe(true) + expect(room.unlocatedIdentities.has(60992)).toBe(false) + }) + + it('公共区来源无匿名槽可物化时补建缺失正 ID', () => { + const { room } = createTestRoom({ + cardIDs: [1], + seatIDs: [1], + materializeDeckIdentities: false + }) + const pile = room.zones.get('pile')! + pile.clear() + + room.moveCards([77], 'player', { + seatID: 1, + fromZone: 'pile', + cardCount: 1, + sourceEvent: { type: 'stage1:public-missing-fallback' } + }) + + const card = room.cardIndex.get(77) + expect(card).toBeTruthy() + expect(card?.location).toBe('player') + expect(card?.subZone).toBe('hand') + expect(card?.isKnown).toBe(true) + expect(room.deckIdentities.has(77)).toBe(true) + expect(room.unlocatedIdentities.has(77)).toBe(false) + }) +}) diff --git a/tests/tracker/anonymousSlotBaseline.test.ts b/tests/tracker/anonymousSlotBaseline.test.ts deleted file mode 100644 index b0f9e11..0000000 --- a/tests/tracker/anonymousSlotBaseline.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { POSITION_TOP } from '@/tracker/candidate/cardPositions' -import { collectTraversalStats } from '@/tracker/traversalStats' -import type { TraversalStats } from '@/tracker/traversalStats' -import type { RoomMoveContext } from '@/tracker/roomMovement/types' -import { createTestRoom, getCard } from './helpers/room' -import { createTrackerControllerHarness } from './helpers/trackerController' - -function expectSite( - stats: TraversalStats, - site: string, - expected: { calls: number; visited: number } -): void { - expect(stats.sites.get(site)).toEqual(expected) -} - -describe('阶段 0 匿名槽位冲突频次基线', () => { - it('记录 createExternalCards 匿名兜底的调用与创建张数', () => { - const { room } = createTestRoom({ cardIDs: [1], seatIDs: [1] }) - - const { stats } = collectTraversalStats(() => room.createExternalCards([], 3)) - - expectSite(stats, 'anonymousSlot:createExternalCardsFallback', { calls: 1, visited: 3 }) - }) - - it('记录玩家来源已知牌与暗占位交换', () => { - const { room } = createTestRoom({ cardIDs: [1, 2, 3], seatIDs: [1] }) - const knownCard = getCard(room, 1) - const placeholder = getCard(room, 2) - room.zones.get('pile')!.removeCard(placeholder) - placeholder.bindCandidates([1], 'hand', null, { known: false }) - const context = { - fromSeat: 1, - fromSubZone: 'hand', - knownCards: [], - sourceEvent: { type: 'baseline:swap-player-placeholder' } - } as RoomMoveContext - - const { result, stats } = collectTraversalStats(() => - room.movement.swapKnownCardWithPlayerSourcePlaceholder(knownCard, context) - ) - - expect(result).toBe(placeholder) - expectSite(stats, 'anonymousSlot:swapKnownCardWithPlayerSourcePlaceholder', { - calls: 1, - visited: 1 - }) - }) - - it('记录公共来源已知牌与来源占位交换', () => { - const { room } = createTestRoom({ cardIDs: [1, 2, 3], seatIDs: [1] }) - const knownCard = getCard(room, 1) - const replacement = getCard(room, 2) - room.moveCards([replacement.id], 'discard', { - fromZone: 'pile', - cardCount: 1, - sourceEvent: { type: 'baseline:prepare-public-placeholder' } - }) - const context = { - fromZone: 'discard', - fromPosition: POSITION_TOP, - sourceEvent: { type: 'baseline:swap-public-placeholder' } - } as RoomMoveContext - - const { result, stats } = collectTraversalStats(() => - room.movement.swapKnownCardWithPublicSourcePlaceholder(knownCard, context) - ) - - expect(result).toBe(replacement) - expectSite(stats, 'anonymousSlot:swapKnownCardWithPublicSourcePlaceholder', { - calls: 1, - visited: 1 - }) - }) - - it('记录匿名占位插回牌堆', () => { - const { room } = createTestRoom({ cardIDs: [1, 2, 3], seatIDs: [1] }) - const [placeholder] = room.createExternalCards([], 1) - - const { stats } = collectTraversalStats(() => - room.movement.insertUnknownPlaceholderIntoPile(room.zones.get('pile')!, placeholder) - ) - - expectSite(stats, 'anonymousSlot:insertUnknownPlaceholderIntoPile', { - calls: 1, - visited: 1 - }) - }) - - it('记录公共揭示回收玩家区占用身份', () => { - const { controller } = createTrackerControllerHarness() - controller.initTrackerRoom() - controller.registerTrackerPlayers([{ SeatID: 1, ClientID: 100 }], 100) - controller.initTrackerDeck([1, 2, 3]) - const room = controller.getTrackerRoom()! - const occupiedCard = getCard(room, 1) - room.zones.get('pile')!.removeCard(occupiedCard) - occupiedCard.bindCandidates([1], 'hand', null, { known: false }) - room.getPlayer(1)!.syncObservedHandCount(1) - room.resolveConstraints() - - const { stats } = collectTraversalStats(() => - controller.revealTrackerCardsInZone({ id: 255, zone: 1, pos: POSITION_TOP }, [1]) - ) - - expectSite(stats, 'anonymousSlot:recoverPlayerOccupiedIdentityForPublicReveal', { - calls: 1, - visited: 1 - }) - }) -}) diff --git a/tests/tracker/cardCounter.test.ts b/tests/tracker/cardCounter.test.ts index 2073197..0ea60c1 100644 --- a/tests/tracker/cardCounter.test.ts +++ b/tests/tracker/cardCounter.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Card } from '@/tracker/Card' import { CARD_INSTANCE_STATUS } from '@/tracker/CardCounter' import { collectTraversalStats } from '@/tracker/traversalStats' -import { createTestRoom } from './helpers/room' +import { createTestRoom, getCard } from './helpers/room' function ids(set: Set): number[] { return Array.from(set).sort((a, b) => a - b) @@ -41,7 +41,7 @@ describe('CardCounter 状态索引', () => { const hiddenHandCard = room.cards.find( (card) => - card.id > 0 && + card.id < 0 && card.location === 'player' && card.subZone === 'hand' && card.isKnown !== true @@ -50,10 +50,7 @@ describe('CardCounter 状态索引', () => { room.counter.update() expect(hiddenHandCard).toBeTruthy() - const remainingPileIDs = room.zones.get('pile').cards.map((card) => card.id) - expect(ids(room.counter.statusIndex[CARD_INSTANCE_STATUS.UNKNOWN])).toEqual( - ids(new Set([hiddenHandCard!.id, ...remainingPileIDs])) - ) + expect(ids(room.counter.statusIndex[CARD_INSTANCE_STATUS.UNKNOWN])).toEqual([1, 5]) expect(room.counter.cardsByStatus[CARD_INSTANCE_STATUS.UNKNOWN].has(placeholder)).toBe(true) expect(room.counter.statusIndex[CARD_INSTANCE_STATUS.UNKNOWN].has(placeholder.id)).toBe(false) expect(ids(room.counter.statusIndex[CARD_INSTANCE_STATUS.APPEARED])).toEqual([2]) @@ -71,6 +68,7 @@ describe('CardCounter 状态索引', () => { const [externalCard] = room.createExternalCards([2], 1) + expect(room.deckIdentities.has(externalCard.id)).toBe(true) expect(room.counter.cardsByStatus[CARD_INSTANCE_STATUS.UNKNOWN].has(externalCard)).toBe(false) expect(room.counter.cardsByStatus[CARD_INSTANCE_STATUS.REMOVED].has(externalCard)).toBe(true) expect(room.counter.statusIndex[CARD_INSTANCE_STATUS.UNKNOWN].has(externalCard.id)).toBe(false) @@ -108,7 +106,7 @@ describe('CardCounter 状态索引', () => { it('状态桶 getter 在无新变化时复用干净缓存', () => { const { room } = createTestRoom({ cardIDs: [1], seatIDs: [1] }) - const card = room.cardIndex.get(1)! + const card = getCard(room, 1)! card.bindTo([1], 'hand') @@ -129,7 +127,7 @@ describe('CardCounter 状态索引', () => { it('状态变化后状态桶 getter 会失效并重建缓存', () => { const { room } = createTestRoom({ cardIDs: [1], seatIDs: [1] }) - const card = room.cardIndex.get(1)! + const card = getCard(room, 1)! expect(room.counter.statusIndex[CARD_INSTANCE_STATUS.UNKNOWN].has(1)).toBe(true) diff --git a/tests/tracker/handExchange.test.ts b/tests/tracker/handExchange.test.ts index 86269aa..60f8eb2 100644 --- a/tests/tracker/handExchange.test.ts +++ b/tests/tracker/handExchange.test.ts @@ -14,7 +14,10 @@ describe('整手牌经交换区互易(通用协议模式)', () => { const allIDs = [...knownFromB, ...hiddenFromA, ...hiddenFromB, ...hiddenFromC] function getCard(room: Room, id: number) { - return room.cardIndex.get(id)! + const existing = room.cardIndex.get(id) + if (existing) return existing + const target = room.zones.get('pile')!.cards.find(isAnonymous) ?? null + return room.materialize(id, target)! } function handCards(room: Room, seatID: number) { @@ -1007,8 +1010,10 @@ describe('整手牌经交换区互易(通用协议模式)', () => { expect(getCard(room, revealedPlaceholderID).isKnown).toBe(true) expect(getCard(room, 641).seats).toEqual(new Set([1])) expect(getCard(room, 642).seats).toEqual(new Set([3])) - expect(isAnonymous(placeholder)).toBe(true) - expect(placeholder.seats.has(1)).toBe(false) + expect(isAnonymous(placeholder)).toBe(false) + expect(placeholder.id).toBe(revealedPlaceholderID) + expect(placeholder.isKnown).toBe(true) + expect(placeholder.seats).toEqual(new Set([1])) expect(room.getPlayer(1).observedHandCount).toBe(2) expect(room.getPlayer(2).observedHandCount).toBe(2) expect(room.getPlayer(3).observedHandCount).toBe(2) diff --git a/tests/tracker/helpers/room.ts b/tests/tracker/helpers/room.ts index 681e1ca..2cd21c9 100644 --- a/tests/tracker/helpers/room.ts +++ b/tests/tracker/helpers/room.ts @@ -1,16 +1,19 @@ import { Room } from '@/tracker/Room' +import { isAnonymous } from '@/tracker/Card' import { createNoopGameState } from './noopRuntime' interface CreateTestRoomOptions { cardIDs?: number[] seatIDs?: number[] currentUserID?: number + materializeDeckIdentities?: boolean } export function createTestRoom({ cardIDs = [], seatIDs = [1, 2], - currentUserID = seatIDs[0] + currentUserID = seatIDs[0], + materializeDeckIdentities = true }: CreateTestRoomOptions = {}) { const gameState = createNoopGameState() const room = new Room({ gameState }) @@ -26,11 +29,32 @@ export function createTestRoom({ if (cardIDs.length > 0) { room.initDeck(cardIDs) + if (materializeDeckIdentities) { + const pileCards = [...room.zones.get('pile')!.cards] + cardIDs.forEach((cardID, index) => { + const card = room.materialize(cardID, pileCards[index]) + if (card) card.isKnown = false + }) + room.counter.update() + room.dirtyCards.clear() + room.dirtyCardEvents = [] + room.dirtyCardSeq = 0 + room.dirtyPublicZones.clear() + room.cardChangeEvents = [] + room.viewDirty = false + room.locationIndex.rebuild(room) + room.ambiguousKnownIndex.rebuild([]) + room.rebuildPlayerSnapshot() + } } return { room, gameState } } export function getCard(room: Room, id: number) { - return room.cardIndex.get(id) + const existing = room.cardIndex.get(id) + if (existing) return existing + + const target = room.zones.get('pile')?.cards.find(isAnonymous) ?? null + return room.materialize(id, target) } diff --git a/tests/tracker/pileDisplayOrder.test.ts b/tests/tracker/pileDisplayOrder.test.ts index 6bb136b..6a76856 100644 --- a/tests/tracker/pileDisplayOrder.test.ts +++ b/tests/tracker/pileDisplayOrder.test.ts @@ -626,14 +626,18 @@ describe('牌堆展示顺序', () => { expectConsistentPublicZones(room) }) - it('牌顶已是连续明牌时来源占位回补插到明牌段下方', () => { - const { room } = createTestRoom({ cardIDs: [1, 2, 3, 4, 5, 6], seatIDs: [1] }) + it('手牌匿名槽物化时不改写连续牌顶明牌段', () => { + const { room } = createTestRoom({ + cardIDs: [1, 2, 3, 4, 5, 6], + seatIDs: [1], + materializeDeckIdentities: false + }) const pile = getPile(room) - const topKnownIDs = [4, 5, 6] const revealedIDs = [1, 2] - topKnownIDs.forEach((id) => room.cardIndex.get(id)!.confirmKnown()) - // 手牌用瞬时匿名占位;真实身份 1/2 仍留在牌堆,揭开时会触发公共区占位回补。 + room.materialize(4, pile.cards.at(-3)!) + room.materialize(5, pile.cards.at(-2)!) + room.materialize(6, pile.cards.at(-1)!) const placeholders = room.createExternalCards([], 2) placeholders.forEach((card) => { card.bindCandidates([1], 'hand', null, { known: false }) @@ -642,6 +646,7 @@ describe('牌堆展示顺序', () => { room.moveCards(revealedIDs, 'process', { fromSeatID: 1, + fromZone: null, fromSubZone: 'hand', cardCount: revealedIDs.length, sourceEvent: { type: 'test:reveal-hand-after-known-pile-top' } @@ -657,13 +662,10 @@ describe('牌堆展示顺序', () => { .slice(0, 3) .every((card) => card.isKnown) ).toBe(true) - const knownSegmentStart = pile.cards.length - topKnownIDs.length - expect(pile.cards.slice(knownSegmentStart - placeholders.length, knownSegmentStart)).toEqual( - expect.arrayContaining(placeholders) - ) placeholders.forEach((card) => { - expect(card.location).toBe('pile') - expect(pile.cards).toContain(card) + expect(card.location).toBe('process') + expect(isAnonymous(card)).toBe(false) + expect(pile.cards).not.toContain(card) }) expect(pile.cards.slice(-3).map((card) => card.id)).toEqual([4, 5, 6]) expectConsistentPublicZones(room) @@ -671,11 +673,11 @@ describe('牌堆展示顺序', () => { it.each([ { - name: '实体占位揭示为牌堆明牌时回到牌堆但不顶回明牌位置', + name: '实体占位揭示为牌堆明牌时精确回补原牌堆槽', sourceEvent: 'test:reveal-hidden-pile-card', setupCandidate: null as null | ((room: Room, candidateCard: Card) => void), - expectedPileIds: [1, 3, 4], - unexpectedPileIds: [1, 4, 3] as number[] | null + expectedPileIds: [1, 4, 3], + unexpectedPileIds: null as number[] | null }, { name: '实体占位揭示为牌堆候选牌时继续占住候选位置', diff --git a/tests/tracker/publicCandidates.test.ts b/tests/tracker/publicCandidates.test.ts index 98c7ca8..cb411cc 100644 --- a/tests/tracker/publicCandidates.test.ts +++ b/tests/tracker/publicCandidates.test.ts @@ -80,6 +80,49 @@ describe('公共候选传播', () => { }) }) + it('牌同时有顶底候选时只把命中的牌顶分支传播到手牌', () => { + const { room } = createTestRoom({ cardIDs: [116], seatIDs: [1, 2, 3] }) + const card = getCard(room, 116) + const pileTop = publicLocation('pile', 'top', 1) + const pileBottom = publicLocation('pile', 'bottom', 1) + const hand1 = playerHand(1) + const hand2 = playerHand(2) + const hand3 = playerHand(3) + + card.confirmKnown() + card.setLocationCandidates([pileTop, pileBottom, hand1, hand2]) + + const affectedCards = room.movement.propagatePublicCandidatesToHand({ + fromZone: 'pile', + fromPosition: POSITION_TOP, + toZone: 'player', + targetSeat: 3, + subZone: 'hand', + count: 3, + sourceEvent: { type: 'test:draw-top-with-bottom-candidate-retained' } + }) + + expect(affectedCards).toEqual([card]) + expect(card.publicCandidates).toEqual([ + expect.objectContaining({ + zone: 'pile', + position: 'bottom', + count: 1 + }) + ]) + expect( + card + .getLocationCandidates() + .map((candidate) => createLocationCandidateKey(candidate)) + .sort() + ).toEqual( + [pileBottom, hand1, hand2, hand3] + .map((candidate) => createLocationCandidateKey(candidate)) + .sort() + ) + expect(card.seats).toEqual(new Set([1, 2, 3])) + }) + it('牌堆顶候选数量未知时进入手牌后保留未知公共候选', () => { const { room } = createTestRoom({ cardIDs: [1], seatIDs: [1] }) const card = getCard(room, 1) diff --git a/tests/tracker/trackerController.test.ts b/tests/tracker/trackerController.test.ts index d3ac4ad..02938f7 100644 --- a/tests/tracker/trackerController.test.ts +++ b/tests/tracker/trackerController.test.ts @@ -7,6 +7,7 @@ import { protocolMove, returnToPileMove } from './helpers/trackerController' +import { getCard as getCardFixture } from './helpers/room' describe('TrackerController', () => { it('牌堆明牌同步将已有卡牌定位到牌顶且重复消息保持幂等', () => { @@ -25,7 +26,7 @@ describe('TrackerController', () => { expect(pile.cards.slice(-revealedIDs.length).map((card) => card.id)).toEqual([125, 63, 2, 158]) expect(pile.cards.at(-1)?.id).toBe(158) revealedIDs.forEach((id) => expect(room.cardIndex.get(id).isKnown).toBe(true)) - expect(addSpy).toHaveBeenCalledOnce() + expect(addSpy).not.toHaveBeenCalled() const dirtyCardSeq = room.dirtyCardSeq addSpy.mockClear() @@ -97,7 +98,7 @@ describe('TrackerController', () => { ]) expect(pile.cards.at(-1)?.id).toBe(62) revealedIDs.forEach((id) => expect(room.cardIndex.get(id).isKnown).toBe(true)) - expect(addSpy).toHaveBeenCalledOnce() + expect(addSpy).not.toHaveBeenCalled() const dirtyCardSeq = room.dirtyCardSeq addSpy.mockClear() @@ -219,11 +220,10 @@ describe('TrackerController', () => { const room = controller.getTrackerRoom() const pile = room.zones.get('pile') - const occupiedCard = room.cardIndex.get(occupiedID) - const fillerA = room.cardIndex.get(200) - const fillerB = room.cardIndex.get(201) + const occupiedCard = getCardFixture(room, occupiedID)! pile.removeCard(occupiedCard) occupiedCard.bindCandidates([3], 'hand', null, { known: false }) + occupiedCard.isKnown = false room.getPlayer(3).syncObservedHandCount(1) room.resolveConstraints() @@ -252,8 +252,6 @@ describe('TrackerController', () => { ]) expect(occupiedCard.location).toBe('pile') expect(occupiedCard.isKnown).toBe(true) - // 用牌堆未知实体换回玩家槽位,不新增匿名实体导致 5+123。 - expect(fillerA.location === 'player' || fillerB.location === 'player').toBe(true) expect( room.cards.filter( (card) => @@ -262,7 +260,7 @@ describe('TrackerController', () => { card.subZone === 'hand' && card.seats.has(3) ) - ).toHaveLength(0) + ).toHaveLength(1) expect(room.getPlayer(3).unknownCardCount).toBe(1) }) @@ -283,20 +281,25 @@ describe('TrackerController', () => { const room = controller.getTrackerRoom()! const pile = room.zones.get('pile')! - const occupiedCard = room.cardIndex.get(occupiedID)! - const bottomPlaceholder = room.cardIndex.get(11)! - const topKnownCards = [room.cardIndex.get(14)!, room.cardIndex.get(15)!] - topKnownCards.forEach((card) => card.confirmKnown()) + const occupiedCard = room.materialize(occupiedID, pile.cards[0])! + const topKnownCards = [ + room.materialize(14, pile.cards.at(-2)!)!, + room.materialize(15, pile.cards.at(-1)!)! + ] pile.removeCard(occupiedCard) occupiedCard.bindCandidates([3], 'hand', null, { known: false }) + occupiedCard.isKnown = false room.getPlayer(3).syncObservedHandCount(1) room.resolveConstraints() + const bottomPlaceholder = pile.cards[0] controller.revealTrackerCardsInZone({ id: 255, zone: 1, pos: POSITION_BOTTOM }, [occupiedID]) - expect(pile.cards.map((card) => card.id)).toEqual([occupiedID, 12, 13, 14, 15]) + expect(pile.cards).toHaveLength(5) + expect(pile.cards[0]?.id).toBe(occupiedID) expect(bottomPlaceholder.location).toBe('player') + expect(isAnonymous(bottomPlaceholder)).toBe(true) expect(bottomPlaceholder.seats).toEqual(new Set([3])) expect(topKnownCards.map((card) => card.location)).toEqual(['pile', 'pile']) expect(topKnownCards.every((card) => card.isKnown)).toBe(true) @@ -318,7 +321,7 @@ describe('TrackerController', () => { controller.initTrackerDeck([...pileIDs, handID, 200]) const room = controller.getTrackerRoom() - const handCard = room.cardIndex.get(handID) + const handCard = getCardFixture(room, handID)! room.zones.get('pile').removeCard(handCard) handCard.bindCandidates([3], 'hand', null, { known: true }) room.getPlayer(3).syncObservedHandCount(1) @@ -550,8 +553,9 @@ describe('TrackerController', () => { controller.initTrackerDeck([152, 153]) const room = controller.getTrackerRoom() - const candidateCard = room.cardIndex.get(152) - const hiddenCard = room.cardIndex.get(153) + const candidateCard = getCardFixture(room, 152)! + const hiddenCard = getCardFixture(room, 153)! + hiddenCard.isKnown = false room.moveCards([152], 'player', { seatID: 4, @@ -847,7 +851,7 @@ describe('TrackerController', () => { card.isKnown !== true ) expect(hiddenHandBefore).toHaveLength(4) - expect(room.cardIndex.get(12).seats.has(hiddenSeat)).toBe(true) + expect(room.unlocatedIdentities.has(12)).toBe(true) controller.syncTrackerMove(protocolMove({ CardIDs: originalIDs, ToID: mySeat })) controller.syncTrackerMove(returnToPileMove({ CardIDs: originalIDs, FromID: mySeat })) @@ -890,7 +894,7 @@ describe('TrackerController', () => { expect(view.calls.scheduleRender).toBe(1) }) - it('随机获得后使用自身暗牌不应按实体顺序排除候选明牌', () => { + it('随机获得后使用自身暗牌不应绑定匿名实体身份', () => { const { controller } = createTrackerControllerHarness() const knownIDs = [77, 116, 89, 159, 61, 134] const lowerFillerIDs = Array.from({ length: 13 }, (_, index) => 201 + index) @@ -977,16 +981,20 @@ describe('TrackerController', () => { }) const room = controller.getTrackerRoom() - const card116 = room.cardIndex.get(116) - const transferGroup = Array.from(room.constraintGroups.values()).find( - (group) => - group.cards.has(card116) && - group.expectedSlotsBySeat.has(1) && - group.expectedSlotsBySeat.has(2) - ) - expect(transferGroup?.expectedSlotsBySeat.get(1)).toBe(1) - expect(transferGroup?.expectedSlotsBySeat.get(2)).toBe(3) - expect(Array.from(card116.seats).sort()).toEqual([1, 2]) + const card134 = room.cardIndex.get(134) + expect(card134?.location).toBe('process') + expect(card134?.isKnown).toBe(true) + expect( + room.cards + .filter( + (card) => + isAnonymous(card) && + card.location === 'player' && + card.subZone === 'hand' && + card.seats.has(2) + ) + .every((card) => card.id < 0) + ).toBe(true) }) it('断线重连状态下跳过牌堆初始化并冻结牌堆相关同步', () => { @@ -1005,14 +1013,15 @@ describe('TrackerController', () => { gameState.isDuanXian = false controller.initTrackerDeck([1, 2]) const room = controller.getTrackerRoom() - const card = room.cardIndex.get(1) + const initialPile = [...room.zones.get('pile').cards] expect(controller.isTrackerReady()).toBe(true) - expect(card.location).toBe('pile') + expect(room.cardIndex.has(1)).toBe(false) + expect(room.unlocatedIdentities.has(1)).toBe(true) gameState.isDuanXian = true controller.syncTrackerMove(protocolMove({ CardIDs: [1] })) expect(controller.getReadyTrackerRoom()).toBe(null) - expect(card.location).toBe('pile') + expect(room.zones.get('pile').cards).toEqual(initialPile) }) }) diff --git a/tests/tracker/traversalBaseline.test.ts b/tests/tracker/traversalBaseline.test.ts index f6c5346..7a05a3d 100644 --- a/tests/tracker/traversalBaseline.test.ts +++ b/tests/tracker/traversalBaseline.test.ts @@ -185,14 +185,13 @@ describe('Room.cards 遍历基线', () => { expect(summarize(stats)).toMatchInlineSnapshot(` { "ambiguousKnownIndex:applyDirty": "calls=1 visited=6", - "anonymousSlot:createExternalCardsFallback": "calls=2 visited=3", "cardCounter:update": "calls=1 visited=6", "handSlotCounts:collectBySeat": "calls=3 visited=15", "locationIndex:applyDirty": "calls=1 visited=6", "reconcileAnonymousHandCards:group": "calls=2 visited=9", "resolveConstraints:constraint1": "calls=2 visited=9", "resolveConstraints:playerSnapshotIncremental": "calls=2 visited=6", - "total": "visited=60", + "total": "visited=57", } `) }) diff --git a/tests/tracker/traversalStatsBrowser.test.ts b/tests/tracker/traversalStatsBrowser.test.ts deleted file mode 100644 index dd4fe7a..0000000 --- a/tests/tracker/traversalStatsBrowser.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - installTraversalStatsBrowserControl, - traversalStatsBrowserControl, - uninstallTraversalStatsBrowserControl -} from '@/tracker/runtime/traversalStatsBrowser' -import { recordTraversal, stopTraversalStatsSession } from '@/tracker/traversalStats' - -describe('遍历统计浏览器控制接口', () => { - it('安装到目标 window 后可控制真实回放统计会话', () => { - const target = {} as Window - - installTraversalStatsBrowserControl(target) - expect(target.__DXC_TRAVERSAL__).toBe(traversalStatsBrowserControl) - - target.__DXC_TRAVERSAL__!.start() - recordTraversal('anonymousSlot:test-browser', 7) - const snapshot = target.__DXC_TRAVERSAL__!.snapshot() - - expect(snapshot.sites).toEqual({ - 'anonymousSlot:test-browser': { calls: 1, visited: 7 } - }) - const stopped = target.__DXC_TRAVERSAL__!.stop() - expect(stopped.active).toBe(false) - expect(stopped.g0.totals).toEqual({ calls: 0, visited: 0 }) - - uninstallTraversalStatsBrowserControl(target) - expect(target.__DXC_TRAVERSAL__).toBeUndefined() - stopTraversalStatsSession() - }) -}) diff --git a/tests/tracker/traversalStatsSession.test.ts b/tests/tracker/traversalStatsSession.test.ts deleted file mode 100644 index 4ef281b..0000000 --- a/tests/tracker/traversalStatsSession.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { - collectTraversalStats, - recordTraversal, - resetTraversalStatsSession, - snapshotTraversalStatsSession, - startTraversalStatsSession, - stopTraversalStatsSession -} from '@/tracker/traversalStats' - -afterEach(() => { - stopTraversalStatsSession() -}) - -describe('长生命周期遍历统计会话', () => { - it('累计 recordTraversal 并返回 JSON 友好快照', () => { - const started = startTraversalStatsSession() - recordTraversal('anonymousSlot:test', 2) - recordTraversal('anonymousSlot:test', 3) - - const snapshot = snapshotTraversalStatsSession() - - expect(started.active).toBe(true) - expect(snapshot.active).toBe(true) - expect(snapshot.sites).toEqual({ - 'anonymousSlot:test': { calls: 2, visited: 5 } - }) - expect(snapshot.totals).toEqual({ calls: 2, visited: 5 }) - expect(snapshot.g0).toEqual({ - totals: { calls: 0, visited: 0 }, - sites: { - 'anonymousSlot:swapKnownCardWithPublicSourcePlaceholder': { - calls: 0, - visited: 0 - }, - 'anonymousSlot:swapKnownCardWithPlayerSourcePlaceholder': { - calls: 0, - visited: 0 - }, - 'anonymousSlot:recoverPlayerOccupiedIdentityForPublicReveal': { - calls: 0, - visited: 0 - }, - 'anonymousSlot:insertUnknownPlaceholderIntoPile': { - calls: 0, - visited: 0 - }, - 'anonymousSlot:createExternalCardsFallback': { - calls: 0, - visited: 0 - } - } - }) - expect(snapshot.startedAt).toBe(started.startedAt) - expect(JSON.parse(JSON.stringify(snapshot))).toEqual(snapshot) - }) - - it('G0 快照固定输出五个站点与精确合计', () => { - startTraversalStatsSession() - recordTraversal('anonymousSlot:swapKnownCardWithPublicSourcePlaceholder', 1) - recordTraversal('anonymousSlot:createExternalCardsFallback', 3) - recordTraversal('resolveConstraints:unrelated', 9) - - const { g0 } = snapshotTraversalStatsSession() - - expect(g0.totals).toEqual({ calls: 2, visited: 4 }) - expect(g0.sites).toEqual({ - 'anonymousSlot:swapKnownCardWithPublicSourcePlaceholder': { - calls: 1, - visited: 1 - }, - 'anonymousSlot:swapKnownCardWithPlayerSourcePlaceholder': { - calls: 0, - visited: 0 - }, - 'anonymousSlot:recoverPlayerOccupiedIdentityForPublicReveal': { - calls: 0, - visited: 0 - }, - 'anonymousSlot:insertUnknownPlaceholderIntoPile': { - calls: 0, - visited: 0 - }, - 'anonymousSlot:createExternalCardsFallback': { - calls: 1, - visited: 3 - } - }) - }) - - it('与同步 collectTraversalStats 并行累计但保持局部统计隔离', () => { - startTraversalStatsSession() - - const { stats } = collectTraversalStats(() => { - recordTraversal('local:site', 4) - }) - recordTraversal('session:site', 2) - - expect(stats.sites.get('local:site')).toEqual({ calls: 1, visited: 4 }) - expect(stats.sites.has('session:site')).toBe(false) - expect(snapshotTraversalStatsSession().sites).toEqual({ - 'local:site': { calls: 1, visited: 4 }, - 'session:site': { calls: 1, visited: 2 } - }) - }) - - it('重复 start 会丢弃上一局统计并重新开始', () => { - startTraversalStatsSession() - recordTraversal('previous:replay', 5) - - const restarted = startTraversalStatsSession() - recordTraversal('current:replay', 2) - - expect(restarted.active).toBe(true) - expect(restarted.sites).toEqual({}) - expect(snapshotTraversalStatsSession().sites).toEqual({ - 'current:replay': { calls: 1, visited: 2 } - }) - }) - - it('reset 清空统计但保持会话开启,stop 返回最终快照并关闭会话', () => { - startTraversalStatsSession() - recordTraversal('before:reset', 1) - - const reset = resetTraversalStatsSession() - expect(reset.active).toBe(true) - expect(reset.sites).toEqual({}) - - recordTraversal('after:reset', 3) - const stopped = stopTraversalStatsSession() - expect(stopped.active).toBe(false) - expect(stopped.sites).toEqual({ - 'after:reset': { calls: 1, visited: 3 } - }) - expect(snapshotTraversalStatsSession().active).toBe(false) - expect(snapshotTraversalStatsSession().sites).toEqual({}) - }) -})