Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions docs/agents/card_tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Comment thread
llsccm marked this conversation as resolved.
- 初始牌堆初始化后,`pile.cards` 顺序应独立于 `room.cards`。
- 摸暗牌、摸明牌时手牌额度及状态维护应保持准确。
- 洗牌时协议 `cardCount` 与本地可枚举牌堆不一致属于高风险路径:需要确认匿名暗占位账本、剩余牌堆顶部顺序、暂停追踪候选展示和暗标记账本迁移。
Expand Down Expand Up @@ -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)。
7 changes: 3 additions & 4 deletions docs/agents/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 维护自动化遍历护栏。

---
Expand Down
3 changes: 1 addition & 2 deletions html/iframe.html
Original file line number Diff line number Diff line change
Expand Up @@ -1051,7 +1051,7 @@
}

.shoupai.unknown-card {
color: #999;
color: #bbb;
background: 0;
}

Expand Down Expand Up @@ -1164,7 +1164,6 @@
.sorderContainer {
/* 默认字体大小,可以根据需要调整 */
font-size: 13px;
width: 100%;
/* 使用Flexbox布局 */
display: flex;
/* 垂直居中对齐子元素 */
Expand Down
4 changes: 2 additions & 2 deletions src/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ function scheduleSetGameSize() {
updateTimeout = setTimeout(() => {
setGameSize()
updateTimeout = null
}, 500) // 延迟 100 毫秒调用 setGameSize
}, 500)
}

export async function initFrame(force = false) {
Expand Down Expand Up @@ -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()
}
Expand Down
8 changes: 7 additions & 1 deletion src/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions src/handler/CGsRoleSpellOptRep.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 89 additions & 0 deletions src/tracker/Card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ export class Card extends BaseCard {
.filter((candidate): candidate is PublicCandidate => Boolean(candidate))
}

/**
* 规整座位输入为 Set<SeatID>。
* 兼容单值 / 数组 / Set,并丢弃 null、undefined 与 NaN。
* 这是 seats 写路径的统一入口格式化,不改变牌的候选语义。
*/
normalizeSeats(seats: SeatInput): Set<SeatID> {
const rawSeats = seats instanceof Set || Array.isArray(seats) ? Array.from(seats) : [seats]
return new Set(
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string, { subZone: SubZone; spellID: SpellID | null }>()

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
Expand Down
18 changes: 18 additions & 0 deletions src/tracker/CardCounter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions src/tracker/MoveEventNormalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import type { CardID, NormalizedMoveEvent, RawMoveCardEvent, RawMoveEventType }
// 20: '锻造',
// 21: '展示',
// 22: '替换装备',
// Mode_ZIRUO = 23;
// Mode_GAMEOVER_NOTIFY = 24;
// 255: '系统'
//

Expand Down
46 changes: 46 additions & 0 deletions src/tracker/Room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

this.notifyCardChanged(card, {
type: 'card-identity-anonymized',
previousCardID,
previousEntityID,
entityID: nextEntityID,
reason
})

if (this.counter) {
this.counter.releaseLocatedIdentityToUnknown(card, previousCardID)
} else {
this.markCounterDirty(card)
}

return previousCardID
}

/**
* 将真实身份绑定到匿名目标槽,并同步身份守恒账本与查询索引。
* 已定位身份命中匿名目标时,仅允许阶段 1 的旧式暗手牌 interop 纠正槽位。
Expand Down
32 changes: 29 additions & 3 deletions src/tracker/roomMovement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,19 +268,28 @@ 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,
count: handMoveCount,
sourceTotalBefore: context.sourceHandTotalObserved
? context.sourceHandTotalBefore
: undefined,
sourceHandTotalObserved: context.sourceHandTotalObserved,
sourceUnknownCount: context.sourceHandTotalObserved
? context.sourceHandUnknownCount
: undefined,
sourceEvent
})

// 完整候选覆盖已经表达了这次 K 张转移;此时不能再确定性挑选暗实体搬到目标。
// 返回空数组表示候选建模失败,仍允许默认未知移动路径执行保守回退。
context.skipUnknownMovement = propagatedCards.length > 0
Expand Down Expand Up @@ -686,11 +695,28 @@ 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'
)
if (previousCardID === null) {
trackerLogger.warn('已知牌匿名化失败:card/index 不一致,继续重置槽位', {
reason: 'moveKnownCardsForContext:resetKnownToUnknown',
cardID: card.id,
entityID: card.entityID,
isKnown: card.isKnown
})
}
card.reset()
resetKnownCardIDs.push(card.id)
if (previousCardID !== null) {
resetKnownCardIDs.push(previousCardID)
} else if (card.id > 0) {
resetKnownCardIDs.push(card.id)
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand Down
Loading