From 5ff91d5dd77442acf3b712426d451d2b823098eb Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:49:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E7=84=9A=E5=B7=A2?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E8=A1=A5=E4=B8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/protocols/PubGsCMoveCard-3752.md | 53 +++++++ docs/protocols/README.md | 6 + src/tracker/runtime/moveEventHandlers.ts | 4 + src/tracker/skill/FenChao.ts | 50 +++++++ tests/tracker/fenChao.test.ts | 177 +++++++++++++++++++++++ 5 files changed, 290 insertions(+) create mode 100644 docs/protocols/PubGsCMoveCard-3752.md create mode 100644 src/tracker/skill/FenChao.ts create mode 100644 tests/tracker/fenChao.test.ts diff --git a/docs/protocols/PubGsCMoveCard-3752.md b/docs/protocols/PubGsCMoveCard-3752.md new file mode 100644 index 0000000..3044145 --- /dev/null +++ b/docs/protocols/PubGsCMoveCard-3752.md @@ -0,0 +1,53 @@ +# 焚巢(SpellID=3752):从弃牌堆获得牌 + +## 协议样例 + +```json +{ + "CardCount": 2, + "CardIDs": [], + "FromID": 255, + "FromPosition": 65282, + "FromZone": 2, + "FromZoneParam": 0, + "MoveType": 18, + "SpellID": 3752, + "ToID": 7, + "ToPosition": 65280, + "ToZone": 5, + "ToZoneParam": 0 +} +``` + +`FromZone=2` 表示弃牌堆,`FromID=255` 是公共区占位;`ToZone=5`、`ToID=7` 表示进入座位 7 +的手牌。`MoveType=18` 为获得,`FromPosition=65282` 表示协议未给出精确来源位置。 + +## 当前推断规则 + +根据实测获得结果反推,焚巢从当前弃牌堆中优先获得**最早进入弃牌堆**的以下牌: + +- 杀,包括火杀、雷杀、冰杀。 +- 决斗、南蛮入侵、万箭齐发、火攻。 + +所有符合条件的牌共用入堆顺序,不按牌名设优先级,也不限制每种牌只取一张。获得张数以 +`CardCount` 为准;牌离开弃牌堆后再次进入时,按重新入堆的顺序处理。 + +运行时 `Card.name` 已经由 `CardConfig` 归一化,南蛮入侵、万箭齐发分别为 `南蛮`、`万箭`。 +弃牌区数组按底到顶保存,因此正向筛选即可选择最早入堆的牌。 + +## 适配边界 + +- 仅处理 `SpellID=3752`、`FromZone=2`、`ToZone=5`、`MoveType=18`、 + `FromPosition=65282`,且 `CardIDs` 没有正 ID 的事件。 +- 保留原始 `CardIDs`,通过 `sourceCards` 指定已有弃牌实体,交由通用移动同步手牌数、位置和明牌。 +- 协议已给出正 ID 时,以协议为准;无匹配牌时保持默认移动路径。 +- 本地可识别的目标牌不足时,复用通用来源补足规则,不重复消费选中实体。这不能保证缺失牌面或 + 缺失弃牌历史下的推断准确性。 +- 此规则来自当前样例及结果观察,后续有反例时应同步调整筛选规则与回归测试。 + +## 代码与验证 + +- 技能装饰:`src/tracker/skill/FenChao.ts` 的 `decorateFenChao`。 +- 默认注册:`src/tracker/runtime/moveEventHandlers.ts` 的 `registerDefaultMoveEventHandlers`。 +- 回归:`tests/tracker/fenChao.test.ts`。 +- 协议索引:[README.md](README.md)。 diff --git a/docs/protocols/README.md b/docs/protocols/README.md index 7b06083..237a288 100644 --- a/docs/protocols/README.md +++ b/docs/protocols/README.md @@ -15,6 +15,12 @@ | [`move-position.md`](move-position.md) | `PubGsCMoveCard` 目标位置 | 公共区顶、底、随机与普通数值精确插槽 | `ToPosition` 通用解析;普通整数按目标区底端零基定位 | | [`hand-exchange.md`](hand-exchange.md) | `PubGsCMoveCard` 整手交换 | 双方整手牌经 `exchange(10)` 互易;示例 SpellID=`121` | `MoveType=11` + `5<->10` + 整手张数;不绑单一 SpellID | +## `PubGsCMoveCard` + +| 文档 | SpellID | 场景 | 关键识别 | +| --- | ---: | --- | --- | +| [`PubGsCMoveCard-3752.md`](PubGsCMoveCard-3752.md) | `3752` | 焚巢从弃牌堆获得最早入堆的指定牌 | `2 -> 5`、`MoveType=18`、随机来源且无正 `CardIDs` | + ## `GsCRoleOptTargetNtf` | 文档 | SpellID | 场景 | 关键识别 | diff --git a/src/tracker/runtime/moveEventHandlers.ts b/src/tracker/runtime/moveEventHandlers.ts index 56bcd61..815a19e 100644 --- a/src/tracker/runtime/moveEventHandlers.ts +++ b/src/tracker/runtime/moveEventHandlers.ts @@ -1,5 +1,6 @@ import { POSITION_RANDOM } from '../candidate/cardPositions' import type { Room } from '../Room' +import decorateFenChao from '../skill/FenChao' import decorateGuanXu, { isGuanXuSpellID } from '../skill/GuanXu' import decorateHandExchange from '../skill/HandExchange' import decorateJieLi from '../skill/JieLi' @@ -125,6 +126,9 @@ export function registerDefaultMoveEventHandlers(room: Room): void { //【思泣】:协议不公开返回牌 ID,按弃牌堆顺序筛选红牌实体作为明确来源。 room.registerMoveEventHandler(3543, decorateSiQi) + //【焚巢】:从弃牌堆暗取时,优先最早入堆的杀、决斗、南蛮、万箭和火攻。 + room.registerMoveEventHandler(3752, decorateFenChao) + // 魔吕布【夺炁】: 3730/3731 获取修正。 room.registerMoveEventHandler(3730, decorateDuoQiMove) room.registerMoveEventHandler(3731, decorateDuoQiMove) diff --git a/src/tracker/skill/FenChao.ts b/src/tracker/skill/FenChao.ts new file mode 100644 index 0000000..ae826a6 --- /dev/null +++ b/src/tracker/skill/FenChao.ts @@ -0,0 +1,50 @@ +import { POSITION_RANDOM } from '../candidate/cardPositions' +import type { Card } from '../Card' +import { MOVE_TYPE } from '../MoveEventNormalizer' +import { PROTOCOL_ZONE } from '../protocolZones' +import type { Room } from '../Room' +import { + getCount, + getRaw, + hasPositiveID, + nextGroupID, + patchEvent, + type MoveEventDraft +} from './moveEventUtils' + +const FEN_CHAO_SPELL_ID = 3752 +// CardConfig 将南蛮入侵、万箭齐发归一为简称,属性杀保留各自名称。 +const FEN_CHAO_CARD_NAMES = new Set(['杀', '火杀', '雷杀', '冰杀', '决斗', '南蛮', '万箭', '火攻']) + +export default function decorateFenChao(event: MoveEventDraft, room: Room): MoveEventDraft { + const raw = getRaw(event) + if ( + hasPositiveID(event.cardIDs) || + Number(raw.FromZone) !== PROTOCOL_ZONE.DISCARD || + Number(raw.ToZone) !== PROTOCOL_ZONE.HAND || + Number(raw.MoveType) !== MOVE_TYPE.GAIN || + Number(raw.FromPosition) !== POSITION_RANDOM + ) { + return event + } + + const cardCount = getCount(event) + if (cardCount === 0) return event + + const sourceCards: Card[] = [] + // 按实测结果推断焚巢优先获得最早入堆的目标牌;弃牌数组按底 -> 顶保存。 + for (const card of room.zones.get('discard')?.cards ?? []) { + if (!FEN_CHAO_CARD_NAMES.has(card.name)) continue + sourceCards.push(card) + if (sourceCards.length === cardCount) break + } + + if (sourceCards.length === 0) return event + + return patchEvent(event, { + options: { + sourceCards, + combinationID: nextGroupID(room, FEN_CHAO_SPELL_ID, 'fenchao_discard_candidate') + } + }) +} diff --git a/tests/tracker/fenChao.test.ts b/tests/tracker/fenChao.test.ts new file mode 100644 index 0000000..44e626c --- /dev/null +++ b/tests/tracker/fenChao.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' +import { POSITION_RANDOM, POSITION_TOP } from '@/tracker/candidate/cardPositions' +import { normalizeMoveEvent } from '@/tracker/MoveEventNormalizer' +import type { RawMoveCardEvent } from '@/tracker/types' +import { getCard } from './helpers/room' +import { createTrackerControllerHarness, protocolMove } from './helpers/trackerController' + +function createRoomWithDiscard(cardNames: string[]) { + const { controller } = createTrackerControllerHarness() + controller.initTrackerRoom() + controller.registerTrackerPlayers( + [ + { SeatID: 1, ClientID: 100 }, + { SeatID: 7, ClientID: 700 } + ], + 100 + ) + + const cardIDs = cardNames.map((_, index) => index + 1) + controller.initTrackerDeck(cardIDs) + controller.syncTrackerMove(protocolMove({ CardIDs: cardIDs, ToZone: 2, ToID: 255, MoveType: 16 })) + + const room = controller.getTrackerRoom()! + cardIDs.forEach((cardID, index) => { + getCard(room, cardID)!.name = cardNames[index] + }) + + return { controller, room } +} + +function createFenChaoMove(overrides: RawMoveCardEvent = {}) { + return protocolMove({ + CardCount: 2, + CardIDs: [], + FromID: 255, + FromPosition: POSITION_RANDOM, + FromZone: 2, + FromZoneParam: 0, + MoveType: 18, + SpellID: 3752, + ToID: 7, + ToPosition: POSITION_TOP, + ToZone: 5, + ToZoneParam: 0, + ...overrides + }) +} + +describe('焚巢弃牌堆取牌', () => { + it.each([{ CardIDs: [] }, { CardIDs: [0, 0] }])( + '未公开牌 ID $CardIDs 时按入堆先后获得两张目标明牌', + ({ CardIDs }) => { + const { controller, room } = createRoomWithDiscard([ + '闪', + '决斗', + '桃', + '杀', + '南蛮', + '万箭', + '火攻' + ]) + const message = createFenChaoMove({ CardIDs }) + const originalCardIDs = [...CardIDs] + + controller.syncTrackerMove(message) + + const player = room.players.get(7)! + expect(player.knownHandCards.map((card) => card.id).sort((a, b) => a - b)).toEqual([2, 4]) + expect(player.observedHandCount).toBe(2) + expect(player.unknownCardCount).toBe(0) + expect(room.zones.get('discard')!.cards.map((card) => card.id)).toEqual([1, 3, 5, 6, 7]) + expect(room.zones.get('pile')!.cards).toEqual([]) + expect(room.cards).toHaveLength(7) + expect(message.CardIDs).toEqual(originalCardIDs) + } + ) + + it.each(['杀', '火杀', '雷杀', '冰杀', '决斗', '南蛮', '万箭', '火攻'])( + '识别 %s 并优先获得同名牌中最早入堆的一张', + (name) => { + const { controller, room } = createRoomWithDiscard(['闪', name, '桃', name, '无懈']) + + controller.syncTrackerMove(createFenChaoMove({ CardCount: 1 })) + + expect(room.players.get(7)!.knownHandCards.map((card) => card.id)).toEqual([2]) + expect(room.zones.get('discard')!.cards.map((card) => card.id)).toEqual([1, 3, 4, 5]) + } + ) + + it('连续获得按当前弃牌顺序继续筛选,张数以协议为准', () => { + const { controller, room } = createRoomWithDiscard([ + '火攻', + '闪', + '南蛮', + '决斗', + '杀', + '万箭', + '桃' + ]) + + controller.syncTrackerMove(createFenChaoMove()) + controller.syncTrackerMove(createFenChaoMove({ CardCount: 3 })) + + const player = room.players.get(7)! + expect(player.knownHandCards.map((card) => card.id).sort((a, b) => a - b)).toEqual([ + 1, 3, 4, 5, 6 + ]) + expect(player.observedHandCount).toBe(5) + expect(player.unknownCardCount).toBe(0) + expect(room.zones.get('discard')!.cards.map((card) => card.id)).toEqual([2, 7]) + }) + + it('牌离开后重新弃入时,以本次入堆顺序为准', () => { + const { controller, room } = createRoomWithDiscard(['杀', '火攻', '决斗', '闪']) + controller.syncTrackerMove(createFenChaoMove({ CardCount: 1, CardIDs: [1], SpellID: 0 })) + controller.syncTrackerMove( + protocolMove({ CardIDs: [1], FromZone: 5, FromID: 7, ToZone: 2, ToID: 255, MoveType: 4 }) + ) + + controller.syncTrackerMove(createFenChaoMove()) + + expect( + room.players + .get(7)! + .knownHandCards.map((card) => card.id) + .sort((a, b) => a - b) + ).toEqual([2, 3]) + expect(room.zones.get('discard')!.cards.map((card) => card.id)).toEqual([4, 1]) + }) + + it('协议明确给出牌 ID 时沿用真实身份,不按最早入堆规则覆盖', () => { + const { controller, room } = createRoomWithDiscard(['杀', '闪', '决斗', '火攻']) + + controller.syncTrackerMove(createFenChaoMove({ CardCount: 1, CardIDs: [4] })) + + expect(room.players.get(7)!.knownHandCards.map((card) => card.id)).toEqual([4]) + expect(room.zones.get('discard')!.cards.map((card) => card.id)).toEqual([1, 2, 3]) + }) + + it('来源含匿名牌时复用不同实体补足数量,保留已识别明牌', () => { + const { controller, room } = createRoomWithDiscard(['火攻', '闪']) + controller.syncTrackerMove( + protocolMove({ CardCount: 1, CardIDs: [], FromZone: 0, ToZone: 2, ToID: 255, MoveType: 19 }) + ) + + controller.syncTrackerMove(createFenChaoMove()) + + const player = room.players.get(7)! + expect(player.knownHandCards.map((card) => card.id)).toEqual([1]) + expect(player.observedHandCount).toBe(2) + expect(player.unknownCardCount).toBe(1) + expect(room.zones.get('discard')!.cards.map((card) => card.id)).toEqual([2]) + expect(room.cards).toHaveLength(3) + }) + + it.each([ + { scenario: '其他技能', overrides: { SpellID: 9999 } }, + { scenario: '牌堆来源', overrides: { FromZone: 1 } }, + { scenario: '非手牌目标', overrides: { ToZone: 6 } }, + { scenario: '非获得移动', overrides: { MoveType: 4 } }, + { scenario: '明确端点取牌', overrides: { FromPosition: POSITION_TOP } }, + { scenario: '零张移动', overrides: { CardCount: 0 } } + ])('$scenario 不应用焚巢来源推断', ({ overrides }) => { + const { room } = createRoomWithDiscard(['杀', '决斗', '闪']) + const event = room.decorateMoveEvent(normalizeMoveEvent(createFenChaoMove(overrides))) + + expect(event.options.sourceCards).toBeUndefined() + expect(room.zones.get('discard')!.cards.map((card) => card.id)).toEqual([1, 2, 3]) + }) + + it('没有目标牌时保留默认移动,不将其他锦囊纳入焚巢筛选', () => { + const { room } = createRoomWithDiscard(['闪', '桃', '借刀', '水淹', '火烧']) + const event = room.decorateMoveEvent(normalizeMoveEvent(createFenChaoMove())) + + expect(event.options.sourceCards).toBeUndefined() + }) +}) From 0e567cb3437abc3bc637d6ac6e86b9a123a2aac5 Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:59:49 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E7=BC=A9=E7=9F=AD=E8=AF=A5=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=E8=A1=8C=E4=BB=A5=E7=AC=A6=E5=90=88=20Markdown=20?= =?UTF-8?q?=E8=A1=8C=E5=AE=BD=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- docs/protocols/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/protocols/README.md b/docs/protocols/README.md index 237a288..6979e93 100644 --- a/docs/protocols/README.md +++ b/docs/protocols/README.md @@ -19,7 +19,7 @@ | 文档 | SpellID | 场景 | 关键识别 | | --- | ---: | --- | --- | -| [`PubGsCMoveCard-3752.md`](PubGsCMoveCard-3752.md) | `3752` | 焚巢从弃牌堆获得最早入堆的指定牌 | `2 -> 5`、`MoveType=18`、随机来源且无正 `CardIDs` | +| [焚巢](PubGsCMoveCard-3752.md) | `3752` | 弃牌堆取目标牌 | `2 -> 5`、获得、随机、无正 ID | ## `GsCRoleOptTargetNtf`