From fba4181e6e0ef230f9c1fd35b85105d68da78d75 Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:14:01 +0800 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/card_tracker.md | 6 ++---- docs/agents/testing.md | 7 +++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/agents/card_tracker.md b/docs/agents/card_tracker.md index edfa0ac..e4a8c43 100644 --- a/docs/agents/card_tracker.md +++ b/docs/agents/card_tracker.md @@ -232,8 +232,7 @@ - 若新增会影响手牌槽 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` 刷新),无关改动使数字上升需要先解释原因再更新快照。 -- 匿名槽 G0/G1 真实回放已经完成并决定 NO-GO / 收缩;临时浏览器回放探针已退役。历史数据见阶段 - 0 基线与阶段 1 对照报告。 +- 匿名槽 G0/G1 真实回放已经完成并决定 NO-GO / 收缩;临时浏览器回放探针已退役。历史数据见本地归档 [`plans/anonymous-entity-and-slot.md`](../../plans/anonymous-entity-and-slot.md)。 - 初始牌堆初始化后,`pile.cards` 顺序应独立于 `room.cards`。 - 摸暗牌、摸明牌时手牌额度及状态维护应保持准确。 - 洗牌时协议 `cardCount` 与本地可枚举牌堆不一致属于高风险路径:需要确认匿名暗占位账本、剩余牌堆顶部顺序、暂停追踪候选展示和暗标记账本迁移。 @@ -269,5 +268,4 @@ - 2026-07-05:完成测试重构与合并,抽取 `locationCandidates` 与 `trackerController` 公共测试辅助,精简测试冗余,提升测试维护性。 - 2026-07-15:文档对齐代码结构——去除文档行号锚点;开局路径以 `handleRecordStartGame` 为主、`GsCModifyUserseatNtf` 分发暂注释;`GameState` 纯状态与 `BrowserGameState` 钩子拆分;视图脏渲染与 `trackerVisibility` 已落地。 -- 2026-07-20:匿名牌堆阶段 1 完成;牌堆槽与身份解耦,G1 最终决定 NO-GO / 收缩,阶段 - 2–7 不执行,临时真实回放探针进入退役范围。 +- 2026-07-20:匿名牌堆阶段 1 完成;牌堆槽与身份解耦,G1 最终决定 NO-GO / 收缩,阶段 2–7 不执行,临时真实回放探针退役。决策归档见 [`plans/anonymous-entity-and-slot.md`](../../plans/anonymous-entity-and-slot.md)。 diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 07f3293..d1955b2 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -133,10 +133,9 @@ CI(`.github/workflows/ci.yml`)在 `dev` / `main` 的 PR 与 push 上会跑 匿名槽阶段 0/1 的 G0、G1 回放采集已经结束,最终决定为 NO-GO / 收缩:保留匿名牌堆,不推进 阶段 2–7。临时浏览器回放探针与固定 G0 五站点 schema 已从运行时移除。 -历史采集方法与阶段 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)。通用性能变更仍必须使用 +历史决策、阶段 0 冲突基线与阶段 1 对照数据已合并到本地归档 +[`plans/anonymous-entity-and-slot.md`](../../plans/anonymous-entity-and-slot.md) +(该目录被 `.gitignore` 忽略)。通用性能变更仍必须使用 `recordTraversal(...)` 和 `tests/tracker/traversalBaseline.test.ts` 维护自动化遍历护栏。 --- From 50829d31e2258cef870b92832846cb61668ac27b Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:51:11 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(tracker):=20=E4=BF=AE=E6=AD=A3=E9=9A=8F?= =?UTF-8?q?=E6=9C=BA=E6=89=8B=E7=89=8C=E8=BD=AC=E7=A7=BB=E5=80=99=E9=80=89?= =?UTF-8?q?=E4=BC=A0=E6=92=AD=E9=97=A8=E6=A7=9B=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 跨座位残留候选不再误报 entityOverflow - 全暗来源跳过无展示价值的 N 选 K,回退默认暗牌移动 - 有观测快照时用 O(1) total/unknown 判断,避免手牌全表扫描 - 补充清议弃牌堆回填与随机转移回归 --- src/tracker/MoveEventNormalizer.ts | 2 + src/tracker/roomMovement.ts | 11 +- src/tracker/roomMovement/candidates.ts | 104 +++++++++++++++-- src/tracker/roomMovement/types.ts | 10 ++ tests/tracker/handCountObservation.test.ts | 123 +++++++++++++++++++++ tests/tracker/spellEffects.test.ts | 63 +++++++++++ 6 files changed, 302 insertions(+), 11 deletions(-) diff --git a/src/tracker/MoveEventNormalizer.ts b/src/tracker/MoveEventNormalizer.ts index 743d165..256345c 100644 --- a/src/tracker/MoveEventNormalizer.ts +++ b/src/tracker/MoveEventNormalizer.ts @@ -33,6 +33,8 @@ import type { CardID, NormalizedMoveEvent, RawMoveCardEvent, RawMoveEventType } // 20: '锻造', // 21: '展示', // 22: '替换装备', +// Mode_ZIRUO = 23; +// Mode_GAMEOVER_NOTIFY = 24; // 255: '系统' // diff --git a/src/tracker/roomMovement.ts b/src/tracker/roomMovement.ts index c856bf8..525daca 100644 --- a/src/tracker/roomMovement.ts +++ b/src/tracker/roomMovement.ts @@ -268,10 +268,14 @@ export class RoomMovement extends RoomMovementCandidateMethods { targetSeat: targetHandSeat, count: handMoveCount, unknownCount, + sourceHandTotalObserved: context.sourceHandTotalObserved, + sourceHandTotalBefore: context.sourceHandTotalBefore, + sourceHandUnknownCount: context.sourceHandUnknownCount, sourceEvent }) ) { - // 随机获得来源手牌时,来源明牌不能直接消失,而是扩展为“来源/目标都可能持有”。 + // 随机获得来源手牌时:若来源仍有明牌,扩展为“来源/目标都可能持有”; + // 来源全暗则走下方默认暗牌移动,不必做无展示价值的 N 选 K。 const propagatedCards = this.markRandomHandTransferCandidates({ fromSeat: sourceHandSeat, targetSeat: targetHandSeat, @@ -279,8 +283,13 @@ export class RoomMovement extends RoomMovementCandidateMethods { sourceTotalBefore: context.sourceHandTotalObserved ? context.sourceHandTotalBefore : undefined, + sourceHandTotalObserved: context.sourceHandTotalObserved, + sourceUnknownCount: context.sourceHandTotalObserved + ? context.sourceHandUnknownCount + : undefined, sourceEvent }) + // 完整候选覆盖已经表达了这次 K 张转移;此时不能再确定性挑选暗实体搬到目标。 // 返回空数组表示候选建模失败,仍允许默认未知移动路径执行保守回退。 context.skipUnknownMovement = propagatedCards.length > 0 diff --git a/src/tracker/roomMovement/candidates.ts b/src/tracker/roomMovement/candidates.ts index a005f84..2bf3862 100644 --- a/src/tracker/roomMovement/candidates.ts +++ b/src/tracker/roomMovement/candidates.ts @@ -26,20 +26,43 @@ import { } from './types' export class RoomMovementCandidateMethods extends RoomMovementSourceMethods { + /** + * 判断玩家间随机手牌转移是否值得做完整实体候选传播。 + * + * 仅在跨座位、有暗牌数量,且来源手牌至少存在一张明/候选明牌时返回 true。 + * 全暗来源没有可展示身份,N 选 K 只会制造无 UI 价值的双边暗候选, + * 此时应回退默认暗牌移动。 + * + * 有观测手牌快照时用 O(1) 比较 total/unknown,避免 getKnownHandCardsBySeat 全表扫描。 + */ shouldPropagateRandomHandTransferCandidates({ sourceSeat, targetSeat, count, unknownCount, + sourceHandTotalObserved = false, + sourceHandTotalBefore = 0, + sourceHandUnknownCount = 0, sourceEvent }: RandomHandTransferCheckOptions): boolean { + // 非跨座位、无转移张数,或席位缺失时,不构成随机手牌转移。 if (!(count > 0) || sourceSeat === null || targetSeat === null || sourceSeat === targetSeat) { return false } + // 纯展示事件不改物理归属,不需要候选传播。 if (sourceEvent?.type === 'showCards') return false + // 没有暗牌数量时,已知牌路径已足够,不必走随机转移建模。 + if (!(unknownCount > 0)) return false + + // 候选传播的核心价值是“来源明牌不能直接消失,而要扩展为来源/目标都可能持有”。 + // unknown = observed - known - candidateKnown;total > unknown 即至少有一张明/候选明。 + // 未观测时无法 O(1) 判定,放行给 mark 在单次手牌扫描里收口。 + if (sourceHandTotalObserved) { + return sourceHandTotalBefore > sourceHandUnknownCount + } - return unknownCount > 0 + return true } /** @@ -50,6 +73,8 @@ export class RoomMovementCandidateMethods extends RoomMovementSourceMethods { targetSeat, count, sourceTotalBefore, + sourceHandTotalObserved = false, + sourceUnknownCount, sourceEvent }: RandomHandTransferOptions): Card[] { if (!(count > 0) || fromSeat === null || targetSeat === null || fromSeat === targetSeat) { @@ -57,41 +82,99 @@ export class RoomMovementCandidateMethods extends RoomMovementSourceMethods { } const sourcePlayer = this.room.getPlayer(fromSeat) + // 有观测快照时先 O(1) 拒绝全暗来源,避免无意义的手牌扫描与 N 选 K。 + const observedSourceTotal = + sourceTotalBefore ?? + (sourcePlayer?.hasObservedHandCount ? sourcePlayer.observedHandCount : undefined) + const observedUnknownCount = + sourceUnknownCount ?? + (sourcePlayer?.hasObservedHandCount ? sourcePlayer.unknownCardCount : undefined) + if ( + (sourceHandTotalObserved || sourcePlayer?.hasObservedHandCount) && + typeof observedSourceTotal === 'number' && + typeof observedUnknownCount === 'number' && + observedSourceTotal <= observedUnknownCount + ) { + return [] + } + const existingSourceCards = this.getPlayerHandCardsBySeat(fromSeat) + // 未观测路径:借这次必要扫描顺带确认是否存在可展示明牌。 + if ( + !(sourceHandTotalObserved || sourcePlayer?.hasObservedHandCount) && + !existingSourceCards.some((card) => card.isKnown === true) + ) { + return [] + } + + // 唯一归属实体才按 1 实体 = 1 手牌槽计;多座位候选只表示“可能在该手牌”。 + const exclusiveSourceCards = existingSourceCards.filter((card) => card.seats.size === 1) + const ambiguousSourceCards = existingSourceCards.filter((card) => card.seats.size > 1) // 手牌数 delta 在候选传播前已经应用,因此优先使用 createMoveContext 保存的移动前快照。 // 直接读取 sourcePlayer.observedHandCount 会把“转移后的 6 张”误当成候选全集大小。 const sourceTotal = sourceTotalBefore ?? (sourcePlayer?.hasObservedHandCount ? sourcePlayer.observedHandCount - : existingSourceCards.length) + : exclusiveSourceCards.length > 0 + ? exclusiveSourceCards.length + : existingSourceCards.length) + + // 只有唯一归属实体能完整覆盖转移前手牌时,才能建立 N 选 K 约束。 + // 多座位候选可多于观测手牌数(例如上一次随机转移后仍保留双边候选),不能据此判 overflow。 + if (sourceTotal < count || sourceTotal <= 0) { + trackerLogger.warn('随机手牌转移无法建立完整实体候选覆盖', { + fromSeat, + targetSeat, + count, + sourceTotal, + existingEntityCount: existingSourceCards.length, + exclusiveEntityCount: exclusiveSourceCards.length, + ambiguousEntityCount: ambiguousSourceCards.length, + reason: 'insufficientSource' + }) + + return [] + } - // 只有实体可以完整覆盖转移前手牌时,才能建立 N 选 K 约束。 - // 覆盖关系自相矛盾时返回空数组,让调用方继续走保守的默认未知移动。 - if (sourceTotal < count || sourceTotal <= 0 || existingSourceCards.length > sourceTotal) { + if (exclusiveSourceCards.length > sourceTotal) { trackerLogger.warn('随机手牌转移无法建立完整实体候选覆盖', { fromSeat, targetSeat, count, sourceTotal, existingEntityCount: existingSourceCards.length, - reason: existingSourceCards.length > sourceTotal ? 'entityOverflow' : 'insufficientSource' + exclusiveEntityCount: exclusiveSourceCards.length, + ambiguousEntityCount: ambiguousSourceCards.length, + reason: 'entityOverflow' }) + return [] } + // 唯一归属实体已完整覆盖手牌时,优先只用它们建模,避免把上一次随机转移的 + // 跨座位残留候选再次并入本次 N 选 K,造成 existingEntityCount > sourceTotal。 + const seedSourceCards = + exclusiveSourceCards.length === sourceTotal ? exclusiveSourceCards : existingSourceCards + // 协议确认了手牌总数,却没有足够的真实实体时,用匿名实体补齐“确定存在”的槽位。 // 这里不从牌堆猜测物理 ID,避免错误身份进一步污染牌堆顺序和后续明牌收敛。 - const missingEntityCount = sourceTotal - existingSourceCards.length + const missingEntityCount = Math.max(0, sourceTotal - seedSourceCards.length) const fallbackCards = missingEntityCount > 0 ? this.room.createExternalCards([], missingEntityCount) : [] fallbackCards.forEach((card) => { card.bindCandidates([fromSeat], 'hand', null, { known: false }) }) - const sourceCandidateCards = existingSourceCards.concat(fallbackCards) - // createExternalCards 理论上应精确返回请求数量;保留此门槛,避免部分覆盖也接管默认移动。 - if (sourceCandidateCards.length !== sourceTotal) return [] + const sourceCandidateCards = seedSourceCards.concat(fallbackCards) + // 唯一归属完整覆盖时要求 N === sourceTotal;否则至少覆盖 sourceTotal。 + if ( + exclusiveSourceCards.length === sourceTotal + ? sourceCandidateCards.length !== sourceTotal + : sourceCandidateCards.length < sourceTotal + ) { + return [] + } // 保留实体原有的其他席位候选,再加入本次目标席位,避免覆盖前序不确定性。 const candidateSeats = Array.from( @@ -114,6 +197,7 @@ export class RoomMovementCandidateMethods extends RoomMovementSourceMethods { subZone: 'hand', spellID: null }) + const targetHandKey = createLocationCandidateKey({ type: 'player', seatID: targetSeat, diff --git a/src/tracker/roomMovement/types.ts b/src/tracker/roomMovement/types.ts index 8ea23cb..d8cc21b 100644 --- a/src/tracker/roomMovement/types.ts +++ b/src/tracker/roomMovement/types.ts @@ -123,6 +123,12 @@ export interface RandomHandTransferCheckOptions { targetSeat: SeatID | null count: number unknownCount: number + /** 移动前是否已观测到来源手牌总数。 */ + sourceHandTotalObserved?: boolean + /** 候选传播前保存的来源手牌总数。 */ + sourceHandTotalBefore?: number + /** 候选传播前保存的来源未知手牌额度。 */ + sourceHandUnknownCount?: number sourceEvent?: MoveSourceEvent } @@ -132,6 +138,10 @@ export interface RandomHandTransferOptions { count: number /** 候选传播前保存的来源手牌总数,避免被已应用的移动 delta 覆盖。 */ sourceTotalBefore?: number + /** 移动前是否已观测到来源手牌总数;与 sourceTotalBefore/sourceUnknownCount 一起做 O(1) 门槛。 */ + sourceHandTotalObserved?: boolean + /** 候选传播前保存的来源未知手牌额度。 */ + sourceUnknownCount?: number sourceEvent?: MoveSourceEvent } diff --git a/tests/tracker/handCountObservation.test.ts b/tests/tracker/handCountObservation.test.ts index c52a2cd..a7be0a0 100644 --- a/tests/tracker/handCountObservation.test.ts +++ b/tests/tracker/handCountObservation.test.ts @@ -106,6 +106,57 @@ describe('玩家手牌数观测', () => { expect(targetPlayer.candidateHandCards).toEqual(knownIDs.map((id) => getCard(room, id))) }) + it('来源全暗随机获取时不传播手牌候选,直接移动一张暗实体', () => { + const infoSpy = vi.spyOn(trackerLogger, 'info').mockImplementation(() => {}) + const { room } = createTestRoom({ cardIDs: [11, 12, 13, 14, 15, 16], seatIDs: [0, 1] }) + + try { + room.moveCards([], 'player', { + seatID: 1, + subZone: 'hand', + fromZone: 'pile', + cardCount: 4, + sourceEvent: { type: 'test:draw-four-unknown' } + }) + + const sourceBefore = room.movement.getPlayerHandCardsBySeat(1) + expect(sourceBefore).toHaveLength(4) + expect(sourceBefore.every((card) => card.isKnown !== true)).toBe(true) + + room.moveCards([], 'player', { + fromZone: null, + fromSeatID: 1, + fromSubZone: 'hand', + seatID: 0, + subZone: 'hand', + cardCount: 1, + sourceEvent: { type: 'test:all-hidden-random-gain' } + }) + + const sourceAfter = room.movement.getPlayerHandCardsBySeat(1) + const targetAfter = room.movement.getPlayerHandCardsBySeat(0) + const transferGroup = Array.from(room.constraintGroups.values()).find( + (group) => + (group.sourceEvent as { type?: string } | null)?.type === 'test:all-hidden-random-gain' + ) + + expect(infoSpy).not.toHaveBeenCalledWith( + '手牌候选传播', + expect.objectContaining({ fromSeat: 1, targetSeat: 0 }) + ) + // 全暗时不建 N 选 K,直接搬一张暗实体;默认未知移动可能不带同名 sourceEvent 组。 + expect(transferGroup?.expectedSlotsBySeat?.get(1)).toBeUndefined() + expect(sourceAfter).toHaveLength(3) + expect(targetAfter).toHaveLength(1) + expect(targetAfter[0]?.isKnown).not.toBe(true) + expect(targetAfter[0]?.seats.has(0)).toBe(true) + expect(room.getPlayer(1).observedHandCount).toBe(3) + expect(room.getPlayer(0).observedHandCount).toBe(1) + } finally { + infoSpy.mockRestore() + } + }) + it('随机转移已有暗实体完整覆盖手牌槽时不重复补建匿名实体', () => { const knownIDs = [116] const hiddenIDs = [130, 131, 132] @@ -142,6 +193,78 @@ describe('玩家手牌数观测', () => { expect(room.getPlayer(2).unknownCardCount).toBe(2) }) + it('来源已有跨座位候选时,后续随机转移不因 entityOverflow 放弃覆盖', () => { + const warnSpy = vi.spyOn(trackerLogger, 'warn').mockImplementation(() => {}) + const { room } = createTestRoom({ cardIDs: [11, 12, 13, 14, 15, 16], seatIDs: [1, 5] }) + + try { + // 1 号:1 明 + 2 暗,再额外叠一张跨座位匿名候选。 + // seats.has(1) 实体数 = 4,唯一归属实体 = 3,对应 sourceTotal=3。 + // 至少 1 张明牌才会触发候选传播门槛。 + room.moveCards([11], 'player', { + seatID: 1, + subZone: 'hand', + fromZone: 'pile', + cardCount: 1, + sourceEvent: { type: 'test:draw-one-known' } + }) + room.moveCards([], 'player', { + seatID: 1, + subZone: 'hand', + fromZone: 'pile', + cardCount: 2, + sourceEvent: { type: 'test:draw-two-unknown' } + }) + + const residualCandidate = room.createExternalCards([], 1)[0] + residualCandidate.bindCandidates([1, 2], 'hand', null, { known: false }) + residualCandidate.isKnown = false + room.notifyCardChanged(residualCandidate, { type: 'test:residual-multi-seat-candidate' }) + + const sourceEntitiesBefore = room.movement.getPlayerHandCardsBySeat(1) + expect(sourceEntitiesBefore).toHaveLength(4) + expect(sourceEntitiesBefore.filter((card) => card.seats.size === 1)).toHaveLength(3) + expect(sourceEntitiesBefore.some((card) => card.isKnown === true)).toBe(true) + expect(room.getPlayer(1).observedHandCount).toBe(3) + + room.moveCards([], 'player', { + fromZone: null, + fromSeatID: 1, + fromSubZone: 'hand', + seatID: 5, + subZone: 'hand', + cardCount: 1, + sourceEvent: { type: 'test:random-transfer-after-residual-candidate' } + }) + + const transferGroup = Array.from(room.constraintGroups.values()).find( + (group) => + (group.sourceEvent as { type?: string } | null)?.type === + 'test:random-transfer-after-residual-candidate' + ) + + expect(warnSpy).not.toHaveBeenCalledWith( + '随机手牌转移无法建立完整实体候选覆盖', + expect.objectContaining({ reason: 'entityOverflow' }) + ) + expect(transferGroup).toBeDefined() + expect(transferGroup?.cards.size).toBe(3) + expect(transferGroup?.expectedSlotsBySeat.get(1)).toBe(2) + expect(transferGroup?.expectedSlotsBySeat.get(5)).toBe(1) + expect(room.getPlayer(1).observedHandCount).toBe(2) + expect(room.getPlayer(5).observedHandCount).toBe(1) + // 只有本次唯一归属覆盖的 3 张进入 N 选 K;残留跨座位候选不掺入。 + expect( + Array.from(transferGroup?.cards ?? []).every( + (card) => card.seats.has(1) && card.seats.has(5) && card.seats.size === 2 + ) + ).toBe(true) + expect(residualCandidate.seats.has(5)).toBe(false) + } finally { + warnSpy.mockRestore() + } + }) + it('共享部分暗实体的约束包保留不同座位的可行覆盖', () => { const { room } = createTestRoom({ cardIDs: [130, 131, 132], seatIDs: [1, 2, 3] }) const [first, shared, third] = [130, 131, 132].map((id) => getCard(room, id)) diff --git a/tests/tracker/spellEffects.test.ts b/tests/tracker/spellEffects.test.ts index 12e1a95..e7710f1 100644 --- a/tests/tracker/spellEffects.test.ts +++ b/tests/tracker/spellEffects.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest' vi.mock('@/draw', () => ({ drawChengXiang: vi.fn() })) import { applySpellEffect, spellEffectHandlers } from '@/handler/spellEffects' +import { createTrackerControllerHarness, protocolMove } from './helpers/trackerController' function createGameState(initialState = {}) { const states = new Map(Object.entries(initialState).map(([key, value]) => [Number(key), value])) @@ -121,4 +122,66 @@ describe('技能副作用注册表', () => { expect(context.CardIDs).toEqual([11, 12]) expect(game.getSpellState(3157)).toBeUndefined() }) + + it('清议回填后经 tracker 同步,弃牌堆明牌进入目标手牌', () => { + const { controller } = createTrackerControllerHarness() + const cardIDs = [11, 12] + + controller.initTrackerRoom() + controller.registerTrackerPlayers([{ SeatID: 1, ClientID: 100 }], 100) + controller.initTrackerDeck([...cardIDs, 100, 101]) + + controller.syncTrackerMove( + protocolMove({ + CardIDs: cardIDs, + CardCount: cardIDs.length, + FromZone: 1, + ToZone: 2, + ToID: 255, + MoveType: 4 + }) + ) + + const room = controller.getTrackerRoom() + expect(room.zones.get('discard')?.cards.map((card) => card.id)).toEqual(cardIDs) + + const game = createGameState({ 3157: cardIDs }) + const context = createContext({ + game, + SpellID: 3157, + CardIDs: [0, 0], + CardCount: 2, + FromZone: 2, + ToZone: 5, + ToID: 1 + }) + + applySpellEffect(context) + + controller.syncTrackerMove( + protocolMove({ + CardIDs: context.CardIDs, + CardCount: context.CardCount, + FromZone: 2, + FromID: 255, + ToZone: 5, + ToID: 1, + MoveType: 5, + SpellID: 3157 + }) + ) + + const handCards = room.cards.filter( + (card) => card.location === 'player' && card.subZone === 'hand' && card.seats.has(1) + ) + + expect(context.CardIDs).toEqual(cardIDs) + expect(game.getSpellState(3157)).toBeUndefined() + expect(room.zones.get('discard')?.cards.map((card) => card.id)).toEqual([]) + expect(handCards.map((card) => card.id).sort((a, b) => a - b)).toEqual(cardIDs) + handCards.forEach((card) => { + expect(card.isKnown).toBe(true) + expect(card.seats.has(1)).toBe(true) + }) + }) }) From a4430608718ae5018f7186b897b5102a719e8723 Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:39:33 +0800 Subject: [PATCH 3/7] fix(tracker): secondary hand transfer --- src/tracker/Card.ts | 89 +++++++++++++++ src/tracker/roomMovement/candidates.ts | 1 + src/tracker/state.ts | 2 +- tests/tracker/locationCandidates.test.ts | 25 +++++ .../secondaryRandomHandTransfer.test.ts | 102 ++++++++++++++++++ 5 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 tests/tracker/secondaryRandomHandTransfer.test.ts diff --git a/src/tracker/Card.ts b/src/tracker/Card.ts index f4da09b..5a604dd 100644 --- a/src/tracker/Card.ts +++ b/src/tracker/Card.ts @@ -162,6 +162,11 @@ export class Card extends BaseCard { .filter((candidate): candidate is PublicCandidate => Boolean(candidate)) } + /** + * 规整座位输入为 Set。 + * 兼容单值 / 数组 / Set,并丢弃 null、undefined 与 NaN。 + * 这是 seats 写路径的统一入口格式化,不改变牌的候选语义。 + */ normalizeSeats(seats: SeatInput): Set { const rawSeats = seats instanceof Set || Array.isArray(seats) ? Array.from(seats) : [seats] return new Set( @@ -204,6 +209,14 @@ export class Card extends BaseCard { this.syncTimestamp() } + /** + * 根据当前 seats 投影同步 owner / resolvedSeat,并在变化时发出脏事件。 + * + * 注意: + * - seats.size === 1 只表示 owner 已确定,不等于手牌/标记等子区域已确定。 + * - previousSeats 必须在候选收缩前捕获;被剔除的座位只会出现在事件快照里。 + * - 调用方可传入 SeatChangeSnapshot,避免 setSeats 过程中读到半更新状态。 + */ syncOwnerFromSeats(reason = 'syncOwnerFromSeats', change: SeatChangeSnapshot = {}): boolean { const previousOwner = change.previousOwner ?? this.owner const previousResolvedSeat = @@ -244,6 +257,16 @@ export class Card extends BaseCard { return false } + /** + * 收敛 seats 投影,并尽量同步 locationCandidates。 + * + * 语义边界: + * - 主要用途是“过滤/收敛”,不是“新增完整位置候选”。 + * - 已有 locationCandidates / subZoneCandidates 时:只按 seats 过滤对应玩家位置候选。 + * - 尚无完整位置候选且 seats > 1 时:按当前 subZone 兼容生成玩家位置候选。 + * - 若要扩展候选座位,应优先用 addSeat / setLocationCandidates,避免依赖本方法静默新增。 + * - seats 只是位置候选的座位级投影;owner 确定不代表具体子区域已落定。 + */ setSeats(nextSeats: SeatInput, reason = 'setSeats'): boolean { const normalizedSeats = this.normalizeSeats(nextSeats) const previousOwner = this.owner @@ -349,15 +372,81 @@ export class Card extends BaseCard { return candidatesChanged || ownerChanged } + /** + * 扩展候选座位。 + * + * - 尚无完整位置候选时:走 setSeats 的兼容扩座位路径。 + * - 已有完整位置候选时:真正追加目标座位的玩家位置候选,而不是只过滤旧候选。 + * 默认优先复制已有 hand 候选模板;没有 hand 时复制现有玩家子区模板。 + */ addSeat(seat: SeatInput, reason = 'addSeat'): boolean { const normalizedSeats = this.normalizeSeats(seat) if (normalizedSeats.size === 0) return false + if (this.hasLocationCandidates()) { + const templates = this.getSeatExpansionTemplates( + getPlayerLocationCandidates(this.locationCandidates) + ) + const nextCandidates = this.locationCandidates.slice() + let changed = false + + normalizedSeats.forEach((seatID) => { + templates.forEach((template) => { + const candidate: PlayerLocationCandidate = { + type: 'player', + seatID, + subZone: template.subZone, + spellID: template.spellID + } + const key = createLocationCandidateKey(candidate) + if (!key || this.hasLocationCandidate(key)) return + + nextCandidates.push(candidate) + changed = true + }) + }) + + if (!changed) return false + return this.setLocationCandidates(nextCandidates, reason) + } + const nextSeats = new Set(this.seats) normalizedSeats.forEach((seatID) => nextSeats.add(seatID)) return this.setSeats(nextSeats, reason) } + /** + * 推导 addSeat 需要追加的位置模板。 + * 有 hand 候选时只扩 hand,避免把标记区等局部候选误复制到新座位。 + */ + private getSeatExpansionTemplates( + playerCandidates: PlayerLocationCandidate[] + ): { subZone: SubZone; spellID: SpellID | null }[] { + if (playerCandidates.length === 0) { + const subZone = this.subZone ?? 'hand' + return [ + { + subZone, + spellID: subZone === 'mark' ? this.spellID : null + } + ] + } + + const handCandidates = playerCandidates.filter((candidate) => candidate.subZone === 'hand') + const sourceCandidates = handCandidates.length > 0 ? handCandidates : playerCandidates + const templates = new Map() + + sourceCandidates.forEach((candidate) => { + const spellID = candidate.subZone === 'mark' ? candidate.spellID : null + const key = `${candidate.subZone}:${spellID ?? ''}` + if (!templates.has(key)) { + templates.set(key, { subZone: candidate.subZone, spellID }) + } + }) + + return Array.from(templates.values()) + } + deleteSeat(seat: SeatInput, reason = 'deleteSeat'): boolean { const normalizedSeats = this.normalizeSeats(seat) if (normalizedSeats.size === 0) return false diff --git a/src/tracker/roomMovement/candidates.ts b/src/tracker/roomMovement/candidates.ts index 2bf3862..67a7d55 100644 --- a/src/tracker/roomMovement/candidates.ts +++ b/src/tracker/roomMovement/candidates.ts @@ -181,6 +181,7 @@ export class RoomMovementCandidateMethods extends RoomMovementSourceMethods { new Set(sourceCandidateCards.flatMap((card) => Array.from(card.seats)).concat(targetSeat)) ) + // addSeat 会在已有完整位置候选时真正追加目标手牌候选,不再只过滤旧候选。 sourceCandidateCards.forEach((card) => { card.addSeat(targetSeat, 'randomHandTransferCandidates') }) diff --git a/src/tracker/state.ts b/src/tracker/state.ts index 95cec15..66512af 100644 --- a/src/tracker/state.ts +++ b/src/tracker/state.ts @@ -108,7 +108,7 @@ const UI: UIState = { stackCardAreaY: 0, MAX_SEAT_WIDTH: 149, inPopFirstTarget: false, - unscaledWidth: 146, + unscaledWidth: 146, // 手牌框宽度 unscaledHeight: 172, MAX_HEIGHT: 180, MAX_WIDTH: 150, diff --git a/tests/tracker/locationCandidates.test.ts b/tests/tracker/locationCandidates.test.ts index c69ac0f..9a53b0b 100644 --- a/tests/tracker/locationCandidates.test.ts +++ b/tests/tracker/locationCandidates.test.ts @@ -152,4 +152,29 @@ describe('完整位置候选回归', () => { [createLocationCandidateKey(seatTwoHand), createLocationCandidateKey(seatTwoMark)].sort() ) }) + + it('已有完整位置候选时 addSeat 会真正追加目标座位候选', () => { + const { room } = createTestRoom({ cardIDs: [1], seatIDs: [0, 2, 7] }) + const card = getCard(room, 1) + const seatZeroHand = playerLocation(0, 'hand') + const seatSevenHand = playerLocation(7, 'hand') + const seatTwoHand = playerLocation(2, 'hand') + + card.confirmKnown() + card.setLocationCandidates([seatZeroHand, seatSevenHand]) + + expect(card.addSeat(2, 'test:add-seat-with-location-candidates')).toBe(true) + expect(locationKeys(card)).toEqual( + [ + createLocationCandidateKey(seatZeroHand), + createLocationCandidateKey(seatTwoHand), + createLocationCandidateKey(seatSevenHand) + ].sort() + ) + expect( + Array.from(card.seats) + .map((seatID) => Number(seatID)) + .sort((a, b) => a - b) + ).toEqual([0, 2, 7]) + }) }) diff --git a/tests/tracker/secondaryRandomHandTransfer.test.ts b/tests/tracker/secondaryRandomHandTransfer.test.ts new file mode 100644 index 0000000..a9037f4 --- /dev/null +++ b/tests/tracker/secondaryRandomHandTransfer.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { createTestRoom, getCard } from './helpers/room' + +describe('二次随机手牌转移时的候选传播', () => { + function setupAmbiguousHand() { + const knownIDs = [112] + const hiddenIDs = [201, 202, 203, 204, 205] + const allIDs = [...knownIDs, ...hiddenIDs] + const { room } = createTestRoom({ cardIDs: allIDs, seatIDs: [0, 2, 7] }) + + const sourceCards = allIDs.map((id) => getCard(room, id)) + room.clearCardsFromPublicZones(sourceCards) + sourceCards.forEach((card) => { + card.bindCandidates([0], 'hand', null, { known: knownIDs.includes(card.id) }) + if (hiddenIDs.includes(card.id)) { + card.isKnown = false + room.notifyCardChanged(card, { type: 'test:hidden-card' }) + } + }) + room.getPlayer(0).syncObservedHandCount(6) + room.getPlayer(2).syncObservedHandCount(0) + room.getPlayer(7).syncObservedHandCount(0) + + // 先形成 0/7 双边候选,供后续 7->2 二次转移使用。 + room.moveCards([], 'player', { + fromZone: null, + fromSeatID: 0, + fromSubZone: 'hand', + seatID: 7, + subZone: 'hand', + cardCount: 2, + sourceEvent: { type: 'seed:random-partial-transfer' } + }) + + return { room, knownIDs, hiddenIDs, allIDs } + } + + function expectSeats(cardIDs: number[], room: ReturnType['room'], seats: number[]) { + cardIDs.forEach((id) => { + expect( + Array.from(getCard(room, id).seats) + .map((seatID) => Number(seatID)) + .sort((a, b) => a - b) + ).toEqual(seats) + }) + } + + it.each([ + { + name: '1 张手牌', + transferCount: 1, + sourceObservedBefore: 2, + expectedSourceAfter: 1, + expectedTargetAfter: 1, + expectedSeats: [0, 2, 7] + }, + { + name: '全部手牌', + transferCount: 7, + sourceObservedBefore: 7, + expectedSourceAfter: 0, + expectedTargetAfter: 7, + expectedSeats: [0, 2] + } + ])( + '二次暗转移($name)应把目标座位写入已有完整位置候选', + ({ + transferCount, + sourceObservedBefore, + expectedSourceAfter, + expectedTargetAfter, + expectedSeats + }) => { + const { room, knownIDs, hiddenIDs } = setupAmbiguousHand() + const card112 = getCard(room, 112) + + expectSeats([112], room, [0, 7]) + expect(room.getPlayer(0).observedHandCount).toBe(4) + expect(room.getPlayer(7).observedHandCount).toBe(2) + + // 全部手牌场景会把 7 号位观测总数抬到协议整手数,再做整手暗转移。 + room.getPlayer(7).syncObservedHandCount(sourceObservedBefore) + room.moveCards([], 'player', { + fromZone: null, + fromSeatID: 7, + fromSubZone: 'hand', + seatID: 2, + subZone: 'hand', + cardCount: transferCount, + sourceEvent: { + type: transferCount === sourceObservedBefore ? 'case:whole-hand-transfer' : 'case:one-card-transfer', + spellID: 209 + } + }) + + expect(room.getPlayer(7).observedHandCount).toBe(expectedSourceAfter) + expect(room.getPlayer(2).observedHandCount).toBe(expectedTargetAfter) + expectSeats([...knownIDs, ...hiddenIDs], room, expectedSeats) + expect(Array.from(card112.seats).map(Number).sort((a, b) => a - b)).toEqual(expectedSeats) + } + ) +}) \ No newline at end of file From 81b9ace9a85f8e18bbc81bad277edddd4e65215a Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:40:27 +0800 Subject: [PATCH 4/7] =?UTF-8?q?chore:=20=E4=BF=AE=E6=94=B9=20=E6=89=8B?= =?UTF-8?q?=E7=89=8C=E6=A1=86=20UI=20=E5=AE=BD=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- html/iframe.html | 3 +-- src/dom.js | 4 ++-- src/draw.js | 8 +++++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/html/iframe.html b/html/iframe.html index 89a8613..dad5604 100644 --- a/html/iframe.html +++ b/html/iframe.html @@ -1051,7 +1051,7 @@ } .shoupai.unknown-card { - color: #999; + color: #bbb; background: 0; } @@ -1164,7 +1164,6 @@ .sorderContainer { /* 默认字体大小,可以根据需要调整 */ font-size: 13px; - width: 100%; /* 使用Flexbox布局 */ display: flex; /* 垂直居中对齐子元素 */ diff --git a/src/dom.js b/src/dom.js index 3ef3985..00cbb93 100644 --- a/src/dom.js +++ b/src/dom.js @@ -63,7 +63,7 @@ function scheduleSetGameSize() { updateTimeout = setTimeout(() => { setGameSize() updateTimeout = null - }, 500) // 延迟 100 毫秒调用 setGameSize + }, 500) } export async function initFrame(force = false) { @@ -107,7 +107,7 @@ function setGameSize() { UI.height = (SystemContext.gameHeight * SystemContext.gameScale) >> 0 UI.scale = SystemContext.gameScale - if (UI.seatUIs && UI.seatUIs.length > 0) { + if (UI.seatUIs?.length > 0) { getSeatUiPos() drawSeatUIs() } diff --git a/src/draw.js b/src/draw.js index f0e6d3d..cc30f49 100644 --- a/src/draw.js +++ b/src/draw.js @@ -276,7 +276,13 @@ export function drawSeatUIs() { return } - const defaultWidth = (UI.unscaledWidth + UI.paddingRight) * UI.scale + // 此处计算 手牌框宽度 存在一个问题 某些情况下缩放比例不太正确 + // 假设游戏缩放 那应该变大吗? 假设系统缩放呢? + // 假设系统缩放是1.0 游戏缩放1.5 手牌框就会变大 实际上武将框渲染的很小 + // 假设系统缩放是1.0 游戏缩放1.0 高分屏武将框不变,间隙很大,这时手牌框应该要变大 + // 手牌框需要变大的情况寥寥无几 不用乘上游戏缩放感觉会更好 + // * UI.scale / window.devicePixelRatio + const defaultWidth = UI.unscaledWidth + UI.paddingRight for (const seat of UI.seatUIs) { if (!hasSeatPosition(seat)) continue From 724e0c08547e9b9030df2a724f7d1169034e1a4f Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:48:04 +0800 Subject: [PATCH 5/7] Fix seat candidate handling and adjust seat UI layout and tests (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tracker): secondary hand transfer * chore: 修改 手牌框 UI 宽度 --- html/iframe.html | 3 +- src/dom.js | 4 +- src/draw.js | 8 +- src/tracker/Card.ts | 89 +++++++++++++++ src/tracker/roomMovement/candidates.ts | 1 + src/tracker/state.ts | 2 +- tests/tracker/locationCandidates.test.ts | 25 +++++ .../secondaryRandomHandTransfer.test.ts | 102 ++++++++++++++++++ 8 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 tests/tracker/secondaryRandomHandTransfer.test.ts diff --git a/html/iframe.html b/html/iframe.html index 89a8613..dad5604 100644 --- a/html/iframe.html +++ b/html/iframe.html @@ -1051,7 +1051,7 @@ } .shoupai.unknown-card { - color: #999; + color: #bbb; background: 0; } @@ -1164,7 +1164,6 @@ .sorderContainer { /* 默认字体大小,可以根据需要调整 */ font-size: 13px; - width: 100%; /* 使用Flexbox布局 */ display: flex; /* 垂直居中对齐子元素 */ diff --git a/src/dom.js b/src/dom.js index 3ef3985..00cbb93 100644 --- a/src/dom.js +++ b/src/dom.js @@ -63,7 +63,7 @@ function scheduleSetGameSize() { updateTimeout = setTimeout(() => { setGameSize() updateTimeout = null - }, 500) // 延迟 100 毫秒调用 setGameSize + }, 500) } export async function initFrame(force = false) { @@ -107,7 +107,7 @@ function setGameSize() { UI.height = (SystemContext.gameHeight * SystemContext.gameScale) >> 0 UI.scale = SystemContext.gameScale - if (UI.seatUIs && UI.seatUIs.length > 0) { + if (UI.seatUIs?.length > 0) { getSeatUiPos() drawSeatUIs() } diff --git a/src/draw.js b/src/draw.js index f0e6d3d..cc30f49 100644 --- a/src/draw.js +++ b/src/draw.js @@ -276,7 +276,13 @@ export function drawSeatUIs() { return } - const defaultWidth = (UI.unscaledWidth + UI.paddingRight) * UI.scale + // 此处计算 手牌框宽度 存在一个问题 某些情况下缩放比例不太正确 + // 假设游戏缩放 那应该变大吗? 假设系统缩放呢? + // 假设系统缩放是1.0 游戏缩放1.5 手牌框就会变大 实际上武将框渲染的很小 + // 假设系统缩放是1.0 游戏缩放1.0 高分屏武将框不变,间隙很大,这时手牌框应该要变大 + // 手牌框需要变大的情况寥寥无几 不用乘上游戏缩放感觉会更好 + // * UI.scale / window.devicePixelRatio + const defaultWidth = UI.unscaledWidth + UI.paddingRight for (const seat of UI.seatUIs) { if (!hasSeatPosition(seat)) continue diff --git a/src/tracker/Card.ts b/src/tracker/Card.ts index f4da09b..5a604dd 100644 --- a/src/tracker/Card.ts +++ b/src/tracker/Card.ts @@ -162,6 +162,11 @@ export class Card extends BaseCard { .filter((candidate): candidate is PublicCandidate => Boolean(candidate)) } + /** + * 规整座位输入为 Set。 + * 兼容单值 / 数组 / Set,并丢弃 null、undefined 与 NaN。 + * 这是 seats 写路径的统一入口格式化,不改变牌的候选语义。 + */ normalizeSeats(seats: SeatInput): Set { const rawSeats = seats instanceof Set || Array.isArray(seats) ? Array.from(seats) : [seats] return new Set( @@ -204,6 +209,14 @@ export class Card extends BaseCard { this.syncTimestamp() } + /** + * 根据当前 seats 投影同步 owner / resolvedSeat,并在变化时发出脏事件。 + * + * 注意: + * - seats.size === 1 只表示 owner 已确定,不等于手牌/标记等子区域已确定。 + * - previousSeats 必须在候选收缩前捕获;被剔除的座位只会出现在事件快照里。 + * - 调用方可传入 SeatChangeSnapshot,避免 setSeats 过程中读到半更新状态。 + */ syncOwnerFromSeats(reason = 'syncOwnerFromSeats', change: SeatChangeSnapshot = {}): boolean { const previousOwner = change.previousOwner ?? this.owner const previousResolvedSeat = @@ -244,6 +257,16 @@ export class Card extends BaseCard { return false } + /** + * 收敛 seats 投影,并尽量同步 locationCandidates。 + * + * 语义边界: + * - 主要用途是“过滤/收敛”,不是“新增完整位置候选”。 + * - 已有 locationCandidates / subZoneCandidates 时:只按 seats 过滤对应玩家位置候选。 + * - 尚无完整位置候选且 seats > 1 时:按当前 subZone 兼容生成玩家位置候选。 + * - 若要扩展候选座位,应优先用 addSeat / setLocationCandidates,避免依赖本方法静默新增。 + * - seats 只是位置候选的座位级投影;owner 确定不代表具体子区域已落定。 + */ setSeats(nextSeats: SeatInput, reason = 'setSeats'): boolean { const normalizedSeats = this.normalizeSeats(nextSeats) const previousOwner = this.owner @@ -349,15 +372,81 @@ export class Card extends BaseCard { return candidatesChanged || ownerChanged } + /** + * 扩展候选座位。 + * + * - 尚无完整位置候选时:走 setSeats 的兼容扩座位路径。 + * - 已有完整位置候选时:真正追加目标座位的玩家位置候选,而不是只过滤旧候选。 + * 默认优先复制已有 hand 候选模板;没有 hand 时复制现有玩家子区模板。 + */ addSeat(seat: SeatInput, reason = 'addSeat'): boolean { const normalizedSeats = this.normalizeSeats(seat) if (normalizedSeats.size === 0) return false + if (this.hasLocationCandidates()) { + const templates = this.getSeatExpansionTemplates( + getPlayerLocationCandidates(this.locationCandidates) + ) + const nextCandidates = this.locationCandidates.slice() + let changed = false + + normalizedSeats.forEach((seatID) => { + templates.forEach((template) => { + const candidate: PlayerLocationCandidate = { + type: 'player', + seatID, + subZone: template.subZone, + spellID: template.spellID + } + const key = createLocationCandidateKey(candidate) + if (!key || this.hasLocationCandidate(key)) return + + nextCandidates.push(candidate) + changed = true + }) + }) + + if (!changed) return false + return this.setLocationCandidates(nextCandidates, reason) + } + const nextSeats = new Set(this.seats) normalizedSeats.forEach((seatID) => nextSeats.add(seatID)) return this.setSeats(nextSeats, reason) } + /** + * 推导 addSeat 需要追加的位置模板。 + * 有 hand 候选时只扩 hand,避免把标记区等局部候选误复制到新座位。 + */ + private getSeatExpansionTemplates( + playerCandidates: PlayerLocationCandidate[] + ): { subZone: SubZone; spellID: SpellID | null }[] { + if (playerCandidates.length === 0) { + const subZone = this.subZone ?? 'hand' + return [ + { + subZone, + spellID: subZone === 'mark' ? this.spellID : null + } + ] + } + + const handCandidates = playerCandidates.filter((candidate) => candidate.subZone === 'hand') + const sourceCandidates = handCandidates.length > 0 ? handCandidates : playerCandidates + const templates = new Map() + + sourceCandidates.forEach((candidate) => { + const spellID = candidate.subZone === 'mark' ? candidate.spellID : null + const key = `${candidate.subZone}:${spellID ?? ''}` + if (!templates.has(key)) { + templates.set(key, { subZone: candidate.subZone, spellID }) + } + }) + + return Array.from(templates.values()) + } + deleteSeat(seat: SeatInput, reason = 'deleteSeat'): boolean { const normalizedSeats = this.normalizeSeats(seat) if (normalizedSeats.size === 0) return false diff --git a/src/tracker/roomMovement/candidates.ts b/src/tracker/roomMovement/candidates.ts index 2bf3862..67a7d55 100644 --- a/src/tracker/roomMovement/candidates.ts +++ b/src/tracker/roomMovement/candidates.ts @@ -181,6 +181,7 @@ export class RoomMovementCandidateMethods extends RoomMovementSourceMethods { new Set(sourceCandidateCards.flatMap((card) => Array.from(card.seats)).concat(targetSeat)) ) + // addSeat 会在已有完整位置候选时真正追加目标手牌候选,不再只过滤旧候选。 sourceCandidateCards.forEach((card) => { card.addSeat(targetSeat, 'randomHandTransferCandidates') }) diff --git a/src/tracker/state.ts b/src/tracker/state.ts index 95cec15..66512af 100644 --- a/src/tracker/state.ts +++ b/src/tracker/state.ts @@ -108,7 +108,7 @@ const UI: UIState = { stackCardAreaY: 0, MAX_SEAT_WIDTH: 149, inPopFirstTarget: false, - unscaledWidth: 146, + unscaledWidth: 146, // 手牌框宽度 unscaledHeight: 172, MAX_HEIGHT: 180, MAX_WIDTH: 150, diff --git a/tests/tracker/locationCandidates.test.ts b/tests/tracker/locationCandidates.test.ts index c69ac0f..9a53b0b 100644 --- a/tests/tracker/locationCandidates.test.ts +++ b/tests/tracker/locationCandidates.test.ts @@ -152,4 +152,29 @@ describe('完整位置候选回归', () => { [createLocationCandidateKey(seatTwoHand), createLocationCandidateKey(seatTwoMark)].sort() ) }) + + it('已有完整位置候选时 addSeat 会真正追加目标座位候选', () => { + const { room } = createTestRoom({ cardIDs: [1], seatIDs: [0, 2, 7] }) + const card = getCard(room, 1) + const seatZeroHand = playerLocation(0, 'hand') + const seatSevenHand = playerLocation(7, 'hand') + const seatTwoHand = playerLocation(2, 'hand') + + card.confirmKnown() + card.setLocationCandidates([seatZeroHand, seatSevenHand]) + + expect(card.addSeat(2, 'test:add-seat-with-location-candidates')).toBe(true) + expect(locationKeys(card)).toEqual( + [ + createLocationCandidateKey(seatZeroHand), + createLocationCandidateKey(seatTwoHand), + createLocationCandidateKey(seatSevenHand) + ].sort() + ) + expect( + Array.from(card.seats) + .map((seatID) => Number(seatID)) + .sort((a, b) => a - b) + ).toEqual([0, 2, 7]) + }) }) diff --git a/tests/tracker/secondaryRandomHandTransfer.test.ts b/tests/tracker/secondaryRandomHandTransfer.test.ts new file mode 100644 index 0000000..a9037f4 --- /dev/null +++ b/tests/tracker/secondaryRandomHandTransfer.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { createTestRoom, getCard } from './helpers/room' + +describe('二次随机手牌转移时的候选传播', () => { + function setupAmbiguousHand() { + const knownIDs = [112] + const hiddenIDs = [201, 202, 203, 204, 205] + const allIDs = [...knownIDs, ...hiddenIDs] + const { room } = createTestRoom({ cardIDs: allIDs, seatIDs: [0, 2, 7] }) + + const sourceCards = allIDs.map((id) => getCard(room, id)) + room.clearCardsFromPublicZones(sourceCards) + sourceCards.forEach((card) => { + card.bindCandidates([0], 'hand', null, { known: knownIDs.includes(card.id) }) + if (hiddenIDs.includes(card.id)) { + card.isKnown = false + room.notifyCardChanged(card, { type: 'test:hidden-card' }) + } + }) + room.getPlayer(0).syncObservedHandCount(6) + room.getPlayer(2).syncObservedHandCount(0) + room.getPlayer(7).syncObservedHandCount(0) + + // 先形成 0/7 双边候选,供后续 7->2 二次转移使用。 + room.moveCards([], 'player', { + fromZone: null, + fromSeatID: 0, + fromSubZone: 'hand', + seatID: 7, + subZone: 'hand', + cardCount: 2, + sourceEvent: { type: 'seed:random-partial-transfer' } + }) + + return { room, knownIDs, hiddenIDs, allIDs } + } + + function expectSeats(cardIDs: number[], room: ReturnType['room'], seats: number[]) { + cardIDs.forEach((id) => { + expect( + Array.from(getCard(room, id).seats) + .map((seatID) => Number(seatID)) + .sort((a, b) => a - b) + ).toEqual(seats) + }) + } + + it.each([ + { + name: '1 张手牌', + transferCount: 1, + sourceObservedBefore: 2, + expectedSourceAfter: 1, + expectedTargetAfter: 1, + expectedSeats: [0, 2, 7] + }, + { + name: '全部手牌', + transferCount: 7, + sourceObservedBefore: 7, + expectedSourceAfter: 0, + expectedTargetAfter: 7, + expectedSeats: [0, 2] + } + ])( + '二次暗转移($name)应把目标座位写入已有完整位置候选', + ({ + transferCount, + sourceObservedBefore, + expectedSourceAfter, + expectedTargetAfter, + expectedSeats + }) => { + const { room, knownIDs, hiddenIDs } = setupAmbiguousHand() + const card112 = getCard(room, 112) + + expectSeats([112], room, [0, 7]) + expect(room.getPlayer(0).observedHandCount).toBe(4) + expect(room.getPlayer(7).observedHandCount).toBe(2) + + // 全部手牌场景会把 7 号位观测总数抬到协议整手数,再做整手暗转移。 + room.getPlayer(7).syncObservedHandCount(sourceObservedBefore) + room.moveCards([], 'player', { + fromZone: null, + fromSeatID: 7, + fromSubZone: 'hand', + seatID: 2, + subZone: 'hand', + cardCount: transferCount, + sourceEvent: { + type: transferCount === sourceObservedBefore ? 'case:whole-hand-transfer' : 'case:one-card-transfer', + spellID: 209 + } + }) + + expect(room.getPlayer(7).observedHandCount).toBe(expectedSourceAfter) + expect(room.getPlayer(2).observedHandCount).toBe(expectedTargetAfter) + expectSeats([...knownIDs, ...hiddenIDs], room, expectedSeats) + expect(Array.from(card112.seats).map(Number).sort((a, b) => a - b)).toEqual(expectedSeats) + } + ) +}) \ No newline at end of file From dadadda99ce94ed35f57ef12ba66950e5cfece40 Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:11:01 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E6=89=8B=E6=B0=94=E5=8D=A1=E5=9B=9E?= =?UTF-8?q?=E7=89=8C=E5=A0=86=E5=8C=BF=E5=90=8D=E5=8C=96=E4=B8=8E=E6=96=97?= =?UTF-8?q?=E5=9C=B0=E4=B8=BB=E5=85=88=E6=89=8B=E8=AF=AF=E8=AE=BE=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(tracker): 手气卡回牌堆真正匿名化 resetKnownToUnknown 仅 isKnown=false 会在牌堆留下正 ID 未知槽, 后续暗摸会原样绑成正 ID 独占暗手并破坏 discardKnown 物化。 * fix(doudizhu): 不再用叫分结果误设先手 Type 44 只是叫分操作回包,不是最终地主结果;误用其 SeatID 设置先手。 * test(doudizhu): 对齐叫分回包不再设置先手的断言 Type 44/45 不再调用 setTrackerFirstHand,更新 roleSpellOptRep 回归。 * fix(tracker): 匿名化时经候选路径清空座位 直接清空 locationCandidates 不会同步 owner,seats 会回退旧座位。 --- src/handler/CGsRoleSpellOptRep.js | 12 ++++--- src/tracker/CardCounter.ts | 18 ++++++++++ src/tracker/Room.ts | 46 +++++++++++++++++++++++++ src/tracker/roomMovement.ts | 13 +++++-- tests/tracker/roleSpellOptRep.test.ts | 15 ++++++-- tests/tracker/trackerController.test.ts | 46 +++++++++++++++++++++---- 6 files changed, 134 insertions(+), 16 deletions(-) diff --git a/src/handler/CGsRoleSpellOptRep.js b/src/handler/CGsRoleSpellOptRep.js index dbd9608..f4225c2 100644 --- a/src/handler/CGsRoleSpellOptRep.js +++ b/src/handler/CGsRoleSpellOptRep.js @@ -11,13 +11,15 @@ function revealCardsInProtocolZone(id, cardIDs, zone = PROTOCOL_HAND_ZONE, pos = } // Type 级结果不一定绑定单一技能,例如叫分和开局初始牌通知。 -function handleResultType({ Datas, SeatID, Type }) { +function handleResultType({ Datas, Type }) { switch (Type) { - // 斗地主叫分结果 Datas: [300] data_count: 1 + // 44: 斗地主玩家叫分结果 Datas: [300] data_count: 1 只是当前操作的返回 并非最终结果 case 44: - if (SeatID !== undefined) { - tracker.setTrackerFirstHand(SeatID) - } + break + + // 45: 斗地主地主结果 Datas: [betBaseCoin,betCoinMulti] [300, 1] + case 45: + // 但是这里是系统播报 没办法判断谁是地主 break // TODO 初始牌 SpellID == 0 diff --git a/src/tracker/CardCounter.ts b/src/tracker/CardCounter.ts index 6a55471..b8e23ef 100644 --- a/src/tracker/CardCounter.ts +++ b/src/tracker/CardCounter.ts @@ -279,6 +279,24 @@ export class CardCounter { } } + /** + * 手气卡等路径把已定位正 ID 槽匿名化后:实体离开 identity 索引,身份回到 UNKNOWN 未定位桶。 + */ + releaseLocatedIdentityToUnknown(card: Card, previousCardID: CardID): void { + if (!(previousCardID > 0) || !card) return + + this.cardsByStatusCache.forEach((set) => set.delete(card)) + this.statusIndexCache.forEach((set) => set.delete(previousCardID)) + + const instance = this.cardInstancesCache[previousCardID] + if (instance) { + instance.status = CARD_INSTANCE_STATUS.UNKNOWN + } + this.statusIndexCache[CARD_INSTANCE_STATUS.UNKNOWN].add(previousCardID) + this.cardsByStatusCache[CARD_INSTANCE_STATUS.UNKNOWN].add(card) + this.dirtyCards.delete(card) + } + /** * 单维度快速过滤查询 * @param dimension 'nameIndex' | 'colorIndex' | 'numberIndex' | 'typeIndex' diff --git a/src/tracker/Room.ts b/src/tracker/Room.ts index b23c82d..ef977bc 100644 --- a/src/tracker/Room.ts +++ b/src/tracker/Room.ts @@ -503,6 +503,52 @@ export class Room { return cards.slice(-count).reverse() } + /** + * 把手气卡回牌堆等路径上的已定位正 ID 槽真正匿名化: + * 实体保留在牌堆位置,身份回到 unlocatedIdentities,供后续揭示时再物化。 + */ + anonymizeLocatedIdentity(card: Card, reason = 'anonymizeLocatedIdentity'): CardID | null { + if (!card || !hasRealIdentity(card) || card.id <= 0) return null + if (this.cardIndex.get(card.id) !== card) return null + + const previousCardID = card.id + const previousEntityID = card.entityID + + this.cardIndex.delete(previousCardID) + this.unlocatedIdentities.add(previousCardID) + this.deckIdentities.add(previousCardID) + + const nextEntityID = this.allocateAnonymousEntityID() + card.entityID = nextEntityID + card.setCardInfo(nextEntityID) + card.isKnown = false + card.suspended = false + card.combinationID = null + card.spellID = null + // 必须走候选写路径:直接清空 locationCandidates 不会同步 clear owner, + // seats 在 location 仍为 player 时会回退到旧座位投影。 + card.setLocationCandidates([], `${reason}:candidates`) + card.clearSeats(`${reason}:seats`) + card.subZone = null + this.suspendedKnownCards.delete(card) + + if (this.counter) { + this.counter.releaseLocatedIdentityToUnknown(card, previousCardID) + } else { + this.markCounterDirty(card) + } + + this.notifyCardChanged(card, { + type: 'card-identity-anonymized', + previousCardID, + previousEntityID, + entityID: nextEntityID, + reason + }) + + return previousCardID + } + /** * 将真实身份绑定到匿名目标槽,并同步身份守恒账本与查询索引。 * 已定位身份命中匿名目标时,仅允许阶段 1 的旧式暗手牌 interop 纠正槽位。 diff --git a/src/tracker/roomMovement.ts b/src/tracker/roomMovement.ts index 525daca..4fbe99f 100644 --- a/src/tracker/roomMovement.ts +++ b/src/tracker/roomMovement.ts @@ -695,11 +695,20 @@ export class RoomMovement extends RoomMovementCandidateMethods { return } - // 手气卡把明牌洗回牌堆后,这些实体重新成为未知牌,后续重摸才能按牌堆实体处理。 + // 手气卡把明牌洗回牌堆后,必须真正匿名化槽位: + // 只 isKnown=false 会留下正 ID 未知牌,后续暗摸会原样绑成正 ID 独占暗手。 if (resetKnownToUnknown === true && toZone === 'pile') { knownCards.forEach((card) => { + const previousCardID = this.room.anonymizeLocatedIdentity( + card, + 'moveKnownCardsForContext:resetKnownToUnknown' + ) card.reset() - resetKnownCardIDs.push(card.id) + if (previousCardID !== null) { + resetKnownCardIDs.push(previousCardID) + } else if (card.id > 0) { + resetKnownCardIDs.push(card.id) + } }) } diff --git a/tests/tracker/roleSpellOptRep.test.ts b/tests/tracker/roleSpellOptRep.test.ts index 7d14d23..e7cf942 100644 --- a/tests/tracker/roleSpellOptRep.test.ts +++ b/tests/tracker/roleSpellOptRep.test.ts @@ -26,7 +26,7 @@ describe('CGsRoleSpellOptRep', () => { Game.phase = 0 }) - it('Type 44 将叫分座位同步为先手', () => { + it('Type 44 叫分回包不设置先手', () => { handleRoleSpellOptRep({ Datas: [300], SeatID: 2, @@ -34,7 +34,18 @@ describe('CGsRoleSpellOptRep', () => { Type: 44 }) - expect(setTrackerFirstHand).toHaveBeenCalledWith(2) + expect(setTrackerFirstHand).not.toHaveBeenCalled() + }) + + it('Type 45 地主结果播报当前不设置先手', () => { + handleRoleSpellOptRep({ + Datas: [300, 1], + SeatID: 2, + SpellID: 0, + Type: 45 + }) + + expect(setTrackerFirstHand).not.toHaveBeenCalled() }) it('Type 72 在开局阶段累计去重后的正 CardID', () => { diff --git a/tests/tracker/trackerController.test.ts b/tests/tracker/trackerController.test.ts index 5046544..1a5d369 100644 --- a/tests/tracker/trackerController.test.ts +++ b/tests/tracker/trackerController.test.ts @@ -625,14 +625,14 @@ describe('TrackerController', () => { controller.syncTrackerMove(returnToPileMove({ CardIDs: cardIDs, FromID: 3 })) - const pileIDs = room.zones.get('pile').cards.map((card) => card.id) + const pileCards = room.zones.get('pile').cards + expect(pileCards).toHaveLength(cardIDs.length + 1) + expect(pileCards.every((card) => card.id < 0 && card.entityID < 0)).toBe(true) + expect(pileCards.every((card) => card.isKnown !== true)).toBe(true) cardIDs.forEach((id) => { - const card = room.cardIndex.get(id) - expect(card.location).toBe('pile') - expect(card.subZone).toBe(null) - expect(card.isKnown).toBe(false) - expect(card.seats.size).toBe(0) - expect(pileIDs).toContain(id) + expect(room.cardIndex.has(id)).toBe(false) + expect(room.unlocatedIdentities.has(id)).toBe(true) + expect(room.deckIdentities.has(id)).toBe(true) }) }) @@ -822,6 +822,38 @@ describe('TrackerController', () => { ).toEqual([]) }) + it('手气卡回牌堆真正匿名化后,后续暗摸不会得到正ID暗手', () => { + const { controller } = createTrackerControllerHarness() + const knownIDs = [7, 139] + const seatID = 1 + + controller.initTrackerRoom() + controller.registerTrackerPlayers([{ SeatID: seatID, ClientID: 100 }], 100) + controller.initTrackerDeck([...knownIDs, 1, 2, 3, 4]) + controller.syncTrackerMove(protocolMove({ CardIDs: knownIDs, ToID: seatID })) + controller.syncTrackerMove(returnToPileMove({ CardIDs: knownIDs, FromID: seatID })) + + const room = controller.getTrackerRoom() + expect(room.zones.get('pile').cards.every((card) => card.id < 0)).toBe(true) + + controller.syncTrackerMove( + protocolMove({ + CardIDs: [], + CardCount: 2, + ToID: seatID + }) + ) + + const handCards = room.cards.filter( + (card) => card.location === 'player' && card.subZone === 'hand' && card.seats.has(seatID) + ) + expect(handCards).toHaveLength(2) + expect(handCards.every((card) => card.id < 0 && card.isKnown !== true)).toBe(true) + knownIDs.forEach((id) => { + expect(room.unlocatedIdentities.has(id)).toBe(true) + expect(room.cardIndex.has(id)).toBe(false) + }) + }) it('手气卡重摸明牌命中其他座位暗占位时保持牌堆与手牌数量', () => { const { controller } = createTrackerControllerHarness() const hiddenSeat = 2 From d781a04343c53414f76da63ad4e96331c7cb4f36 Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:44:48 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(tracker):=20=E5=8C=BF=E5=90=8D=E5=8C=96?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E8=A1=A5=20warn=20=E5=B9=B6=E5=BB=B6?= =?UTF-8?q?=E5=90=8E=20counter=20dirty=20=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resetKnownToUnknown 时 anonymize 失败记录 card/index 不一致 - releaseLocatedIdentityToUnknown 移到 notifyCardChanged 之后 --- src/tracker/Room.ts | 12 ++++++------ src/tracker/roomMovement.ts | 8 ++++++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/tracker/Room.ts b/src/tracker/Room.ts index ef977bc..74440a8 100644 --- a/src/tracker/Room.ts +++ b/src/tracker/Room.ts @@ -532,12 +532,6 @@ export class Room { card.subZone = null this.suspendedKnownCards.delete(card) - if (this.counter) { - this.counter.releaseLocatedIdentityToUnknown(card, previousCardID) - } else { - this.markCounterDirty(card) - } - this.notifyCardChanged(card, { type: 'card-identity-anonymized', previousCardID, @@ -546,6 +540,12 @@ export class Room { reason }) + if (this.counter) { + this.counter.releaseLocatedIdentityToUnknown(card, previousCardID) + } else { + this.markCounterDirty(card) + } + return previousCardID } diff --git a/src/tracker/roomMovement.ts b/src/tracker/roomMovement.ts index 4fbe99f..c1bfc0f 100644 --- a/src/tracker/roomMovement.ts +++ b/src/tracker/roomMovement.ts @@ -703,6 +703,14 @@ export class RoomMovement extends RoomMovementCandidateMethods { card, 'moveKnownCardsForContext:resetKnownToUnknown' ) + if (previousCardID === null) { + trackerLogger.warn('已知牌匿名化失败:card/index 不一致,继续重置槽位', { + reason: 'moveKnownCardsForContext:resetKnownToUnknown', + cardID: card.id, + entityID: card.entityID, + isKnown: card.isKnown + }) + } card.reset() if (previousCardID !== null) { resetKnownCardIDs.push(previousCardID)