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
53 changes: 53 additions & 0 deletions docs/protocols/PubGsCMoveCard-3752.md
Original file line number Diff line number Diff line change
@@ -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)。
6 changes: 6 additions & 0 deletions docs/protocols/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | `3752` | 弃牌堆取目标牌 | `2 -> 5`、获得、随机、无正 ID |

## `GsCRoleOptTargetNtf`

| 文档 | SpellID | 场景 | 关键识别 |
Expand Down
4 changes: 4 additions & 0 deletions src/tracker/runtime/moveEventHandlers.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions src/tracker/skill/FenChao.ts
Original file line number Diff line number Diff line change
@@ -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')
}
})
}
177 changes: 177 additions & 0 deletions tests/tracker/fenChao.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})