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
4 changes: 3 additions & 1 deletion docs/agents/card_player_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
## BaseCard:静态元数据

- `BaseCard` 构造时通过 `CardConfig.GetInstance().getCard(id)` 自动拉取 `name`、`color`、
`number`、`type`、`ncn`。
`number`、`type`、`spellId`、`ncn`。
- `spellId` 是卡牌自身技能 ID,匿名牌或配置缺失时为 `0`;与移动协议、标记区使用的
`Card.spellID` 含义不同。焚巢筛牌与红黑杀统计使用此字段识别牌种。
- `setCardInfo(id)` 是元数据刷新入口;匿名槽物化为真实身份时由
`Card.materializeIdentity()` 调用,避免外部直接改写内部展示字段。

Expand Down
55 changes: 55 additions & 0 deletions docs/protocols/PubGsCMoveCard-3752.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 焚巢(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` 为准;牌离开弃牌堆后再次进入时,按重新入堆的顺序处理。

运行时按 `BaseCard.spellId` 识别目标牌:杀(含属性杀)为 `1`、决斗为 `8`、南蛮入侵为 `9`、
万箭齐发为 `10`、火攻为 `83`,不依赖牌名或简称。此字段与移动协议、标记区使用的
`Card.spellID` 不同。
弃牌区数组按底到顶保存,因此正向筛选即可选择最早入堆的牌。

## 适配边界

- 仅处理 `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
1 change: 1 addition & 0 deletions src/handler/GsCUpdateRoleDataExNtf.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function handleUpdateRoleDataExNtf(msg) {

// OPT_DATA_UPDATE_NEW_SPELL
case 17:
// 飞扬技能通过此消息获得
if (!Array.isArray(Datas)) break
if (SeatID !== undefined && SeatID == Game.myID && import.meta.env.DEV) {
console.info(msg)
Expand Down
4 changes: 3 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,7 @@ function main() {
}

main('INIT').then((r) => {
if (r) window._SGSMODULE.push(main)
if (r) {
window._SGSMODULE.push(main)
}
})
3 changes: 2 additions & 1 deletion src/logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export function logic(msg) {

if (ProtoObj.matchName === '单骑无双') {
Game.isRoguelike1v1 = true
Game.needShowName = true
return
}

Expand All @@ -179,7 +180,7 @@ export function logic(msg) {
case 'decodeGsClientUserSeatFlagNtf':
// 新录像两个消息都有 旧录像只有这个消息
handleRecordStartGame(msg)
if (Game.needShowName && globalConfig.showNameSwitch) laya.showName()
if (globalConfig.showNameSwitch && Game.needShowName) laya.showName()
break

case 'GsCUpdateRoleDataNtf':
Expand Down
14 changes: 12 additions & 2 deletions src/runtime/gameAdapter.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Game } from '@/tracker/Game'
import { wait } from '@/utils'
import { addTooltip } from '@/utils/notification'

// 每回合都可以发动
Expand Down Expand Up @@ -499,8 +500,17 @@ export class GameRuntime {
return foundWindow ?? null
}

showName() {
this.gamescene?.seatContainer?.seatUIs?.forEach(({ seat, otherTopManager }) => {
HideStartLoadingListener() {
const GameEventDispatcher = this.ged
if (!GameEventDispatcher) return
GameEventDispatcher.on('HIDE_CHALLENGE_GAME_START', this, this.showName)
Comment thread
llsccm marked this conversation as resolved.
}

async showName() {
const seatUIs = await wait(() => this.gamescene?.seatContainer?.seatUIs)
if (!seatUIs) return

seatUIs.forEach(({ seat, otherTopManager }) => {
if (seat?.playerInfo?.ClientId >= 4e9) return
otherTopManager?.createPlayerNameBg()
otherTopManager?.createPlayerName()
Expand Down
3 changes: 3 additions & 0 deletions src/tracker/BaseCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export class BaseCard {
declare color: number
declare number: number
declare type: number
/** 卡牌自身技能 ID,与运行时 Card.spellID 区分。 */
declare spellId: number
declare ncn: string

/**
Expand All @@ -30,6 +32,7 @@ export class BaseCard {
this.color = cardInfo?.color ?? 0
this.number = cardInfo?.number ?? 0
this.type = cardInfo?.type ?? 0
this.spellId = cardInfo?.spellId ?? 0
this.ncn = cardInfo?.ncn ?? this.name
return this
}
Expand Down
2 changes: 1 addition & 1 deletion src/tracker/CardCounter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export class CardCounter {
this.numberIndex[0].add(cardID)
}

if (instance.name.match(/^(冰|火|雷)?杀$/)) {
if (instance.spellId === 1) {
this.colorIndex[Math.ceil(instance.color / 2) + 4].add(cardID)
}

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
// 杀(含属性杀)、决斗、南蛮入侵、万箭齐发、火攻。
const FEN_CHAO_CARD_SPELL_IDS = new Set([1, 8, 9, 10, 83])

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_SPELL_IDS.has(card.spellId)) 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')
}
})
}
9 changes: 5 additions & 4 deletions src/ui/PeiXiuMapWindow.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PEIXIU_DIRECTIONS } from '../utils/peixiuRouteFeature'
import { getLayoutViewportWidth } from './drag'

const WINDOW_ID = 'peixiu-map-window'
const STYLE_ID = 'peixiu-map-style'
Expand Down Expand Up @@ -281,7 +282,7 @@ function ensureStyle() {
document.head.appendChild(style)
}

function bindDrag(element, handle) {
function bindPeiXiuMapWindowDrag(element, handle) {
cleanupDrag?.()
let pointerId = null
let offsetX = 0
Expand All @@ -290,7 +291,7 @@ function bindDrag(element, handle) {
const move = (event) => {
if (event.pointerId !== pointerId) return
const left = Math.min(
Math.max(0, window.innerWidth - element.offsetWidth),
Math.max(0, getLayoutViewportWidth() - element.offsetWidth),
Math.max(0, event.clientX - offsetX)
)
const top = Math.min(
Expand Down Expand Up @@ -352,7 +353,7 @@ function createWindow() {
document.body.appendChild(element)
if (lastPosition) {
const left = Math.min(
Math.max(0, window.innerWidth - element.offsetWidth),
Math.max(0, getLayoutViewportWidth() - element.offsetWidth),
Math.max(0, lastPosition.left)
)
const top = Math.min(
Expand All @@ -364,7 +365,7 @@ function createWindow() {
element.style.right = 'auto'
lastPosition = { left, top }
}
bindDrag(element, element.querySelector('.peixiu-header'))
bindPeiXiuMapWindowDrag(element, element.querySelector('.peixiu-header'))
return element
}

Expand Down
2 changes: 1 addition & 1 deletion src/ui/drag.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export function addDragHint() {
document.body.appendChild(sidebarHint)
}

function getLayoutViewportWidth() {
export function getLayoutViewportWidth() {
return document.documentElement.clientWidth || window.innerWidth
}

Expand Down
25 changes: 24 additions & 1 deletion tests/tracker/cardCounter.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CardConfig } from '@/config/CardConfig'
import { Card } from '@/tracker/Card'
import { CARD_INSTANCE_STATUS } from '@/tracker/CardCounter'
import { collectTraversalStats } from '@/tracker/traversalStats'
import { createTestRoom, getCard } from './helpers/room'

afterEach(() => {
vi.restoreAllMocks()
})

function ids(set: Set<number>): number[] {
return Array.from(set).sort((a, b) => a - b)
}

describe('CardCounter 状态索引', () => {
it('红黑杀按卡牌技能 ID 分类,显示名不影响识别', () => {
const cardInfos = [
{ name: '更名后的红杀', color: 1, spellId: 1 },
{ name: '火杀', color: 2, spellId: 1 },
{ name: '雷杀', color: 3, spellId: 1 },
{ name: '冰杀', color: 4, spellId: 1 },
{ name: '杀', color: 1, spellId: 8 },
{ name: '雷杀', color: 3 }
]
vi.spyOn(CardConfig.GetInstance(), 'getCard').mockImplementation((id) => cardInfos[id - 1])
const { room } = createTestRoom({ cardIDs: [1, 2, 3, 4, 5, 6], seatIDs: [1] })

room.counter.query('colorIndex', 5)
expect(ids(room.counter.querySet)).toEqual([1, 2])
room.counter.query('colorIndex', 6)
expect(ids(room.counter.querySet)).toEqual([3, 4])
})

it('按未知、出现、弃牌、移出游戏四类同步派生索引', () => {
const { room } = createTestRoom({ cardIDs: [1, 2, 3, 4, 5], seatIDs: [1] })

Expand Down
Loading