From 038deac5c74f1df2c2fba0ea5ae7d5d387616fed Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Sat, 5 Sep 2026 08:40:42 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E8=A3=B4=E7=A7=80=E5=9C=B0=E5=9B=BE?= =?UTF-8?q?=E6=8B=96=E6=8B=BD=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/PeiXiuMapWindow.js | 9 +++++---- src/ui/drag.js | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/ui/PeiXiuMapWindow.js b/src/ui/PeiXiuMapWindow.js index f936956..219f4c1 100644 --- a/src/ui/PeiXiuMapWindow.js +++ b/src/ui/PeiXiuMapWindow.js @@ -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' @@ -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 @@ -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( @@ -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( @@ -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 } diff --git a/src/ui/drag.js b/src/ui/drag.js index b77dc6c..f89f8bb 100644 --- a/src/ui/drag.js +++ b/src/ui/drag.js @@ -5,7 +5,7 @@ export function addDragHint() { document.body.appendChild(sidebarHint) } -function getLayoutViewportWidth() { +export function getLayoutViewportWidth() { return document.documentElement.clientWidth || window.innerWidth } From 0736f31023147cd3b415a96d7ad836f6a1dd9112 Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:46:05 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E5=B1=95=E7=A4=BA=E6=98=B5=E7=A7=B0?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E6=97=B6=E6=9C=BA=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/handler/GsCUpdateRoleDataExNtf.js | 1 + src/logic.js | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/handler/GsCUpdateRoleDataExNtf.js b/src/handler/GsCUpdateRoleDataExNtf.js index 9cca56d..a14188d 100644 --- a/src/handler/GsCUpdateRoleDataExNtf.js +++ b/src/handler/GsCUpdateRoleDataExNtf.js @@ -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) diff --git a/src/logic.js b/src/logic.js index c313d4f..867c8b7 100644 --- a/src/logic.js +++ b/src/logic.js @@ -156,6 +156,7 @@ export function logic(msg) { if (ProtoObj.matchName === '单骑无双') { Game.isRoguelike1v1 = true + Game.needShowName = true return } @@ -179,7 +180,6 @@ export function logic(msg) { case 'decodeGsClientUserSeatFlagNtf': // 新录像两个消息都有 旧录像只有这个消息 handleRecordStartGame(msg) - if (Game.needShowName && globalConfig.showNameSwitch) laya.showName() break case 'GsCUpdateRoleDataNtf': @@ -281,6 +281,10 @@ export function logic(msg) { tracker.initTrackerDeck(paidui) } + setTimeout(() => { + if (Game.needShowName && globalConfig.showNameSwitch) laya.showName() + }, 500) + break // 选择武将 From ff5393ad82798862ff16cd89f49ce63ca5e89172 Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:08:48 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E8=B0=83=E6=95=B4=E6=98=B5=E7=A7=B0?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E6=97=B6=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.js | 6 +++++- src/logic.js | 4 ---- src/runtime/gameAdapter.js | 28 ++++++++++++++++++++-------- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/index.js b/src/index.js index 49504e9..be1509d 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ import { logic } from './logic.js' import { Init, Exit } from './dom.js' import { notifyScriptError } from './utils/errorNotifier.js' +import { laya } from './runtime/gameAdapter.js' console.info( '%c三国杀小抄', @@ -56,5 +57,8 @@ function main() { } main('INIT').then((r) => { - if (r) window._SGSMODULE.push(main) + if (r) { + window._SGSMODULE.push(main) + laya.HideStartLoadingListener() + } }) diff --git a/src/logic.js b/src/logic.js index 867c8b7..6c0b0e2 100644 --- a/src/logic.js +++ b/src/logic.js @@ -281,10 +281,6 @@ export function logic(msg) { tracker.initTrackerDeck(paidui) } - setTimeout(() => { - if (Game.needShowName && globalConfig.showNameSwitch) laya.showName() - }, 500) - break // 选择武将 diff --git a/src/runtime/gameAdapter.js b/src/runtime/gameAdapter.js index 2da92c3..7812502 100644 --- a/src/runtime/gameAdapter.js +++ b/src/runtime/gameAdapter.js @@ -1,3 +1,4 @@ +import { globalConfig } from '@/tracker' import { Game } from '@/tracker/Game' import { addTooltip } from '@/utils/notification' @@ -499,15 +500,26 @@ export class GameRuntime { return foundWindow ?? null } + HideStartLoadingListener() { + const GameEventDispatcher = this.ged + if (!GameEventDispatcher) return + GameEventDispatcher.on('HIDE_CHALLENGE_GAME_START', this, this.showName) + } + showName() { - this.gamescene?.seatContainer?.seatUIs?.forEach(({ seat, otherTopManager }) => { - if (seat?.playerInfo?.ClientId >= 4e9) return - otherTopManager?.createPlayerNameBg() - otherTopManager?.createPlayerName() - otherTopManager?.UpdatePlayerName(seat.playerInfo) - otherTopManager?.SetPlayNameVisible(true) - otherTopManager?.layout() - }) + if (!globalConfig.showNameSwitch) return + + setTimeout(() => { + if (!Game.needShowName) return + this.gamescene?.seatContainer?.seatUIs?.forEach(({ seat, otherTopManager }) => { + if (seat?.playerInfo?.ClientId >= 4e9) return + otherTopManager?.createPlayerNameBg() + otherTopManager?.createPlayerName() + otherTopManager?.UpdatePlayerName(seat.playerInfo) + otherTopManager?.SetPlayNameVisible(true) + otherTopManager?.layout() + }) + }, 1000) } /** From 657a5ecc0b8a6dd4d9c99c95afb201d8f5f6abe9 Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:01:16 +0800 Subject: [PATCH 4/6] =?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=20(#110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 添加焚巢移动补丁 * fix: 缩短该表格行以符合 Markdown 行宽规范 Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- 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..6979e93 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) | `3752` | 弃牌堆取目标牌 | `2 -> 5`、获得、随机、无正 ID | + ## `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 6bc96af25f0eecf3dde8f80a37475972835d580a Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:44:14 +0800 Subject: [PATCH 5/6] =?UTF-8?q?feat(tracker):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E5=8D=A1=E7=89=8C=E6=8A=80=E8=83=BD=20ID=20=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E7=89=8C=E7=A7=8D=20(#111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/agents/card_player_model.md | 4 ++- docs/protocols/PubGsCMoveCard-3752.md | 4 ++- src/tracker/BaseCard.ts | 3 ++ src/tracker/CardCounter.ts | 2 +- src/tracker/skill/FenChao.ts | 6 ++-- tests/tracker/cardCounter.test.ts | 25 ++++++++++++- tests/tracker/fenChao.test.ts | 51 +++++++++++++++++++++++---- 7 files changed, 82 insertions(+), 13 deletions(-) diff --git a/docs/agents/card_player_model.md b/docs/agents/card_player_model.md index ec04bfb..a6da7d5 100644 --- a/docs/agents/card_player_model.md +++ b/docs/agents/card_player_model.md @@ -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()` 调用,避免外部直接改写内部展示字段。 diff --git a/docs/protocols/PubGsCMoveCard-3752.md b/docs/protocols/PubGsCMoveCard-3752.md index 3044145..f5f2e0b 100644 --- a/docs/protocols/PubGsCMoveCard-3752.md +++ b/docs/protocols/PubGsCMoveCard-3752.md @@ -32,7 +32,9 @@ 所有符合条件的牌共用入堆顺序,不按牌名设优先级,也不限制每种牌只取一张。获得张数以 `CardCount` 为准;牌离开弃牌堆后再次进入时,按重新入堆的顺序处理。 -运行时 `Card.name` 已经由 `CardConfig` 归一化,南蛮入侵、万箭齐发分别为 `南蛮`、`万箭`。 +运行时按 `BaseCard.spellId` 识别目标牌:杀(含属性杀)为 `1`、决斗为 `8`、南蛮入侵为 `9`、 +万箭齐发为 `10`、火攻为 `83`,不依赖牌名或简称。此字段与移动协议、标记区使用的 +`Card.spellID` 不同。 弃牌区数组按底到顶保存,因此正向筛选即可选择最早入堆的牌。 ## 适配边界 diff --git a/src/tracker/BaseCard.ts b/src/tracker/BaseCard.ts index 2613a06..ad6d0d4 100644 --- a/src/tracker/BaseCard.ts +++ b/src/tracker/BaseCard.ts @@ -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 /** @@ -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 } diff --git a/src/tracker/CardCounter.ts b/src/tracker/CardCounter.ts index 65699b9..8a49257 100644 --- a/src/tracker/CardCounter.ts +++ b/src/tracker/CardCounter.ts @@ -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) } diff --git a/src/tracker/skill/FenChao.ts b/src/tracker/skill/FenChao.ts index ae826a6..33e0920 100644 --- a/src/tracker/skill/FenChao.ts +++ b/src/tracker/skill/FenChao.ts @@ -13,8 +13,8 @@ import { } from './moveEventUtils' const FEN_CHAO_SPELL_ID = 3752 -// CardConfig 将南蛮入侵、万箭齐发归一为简称,属性杀保留各自名称。 -const FEN_CHAO_CARD_NAMES = new Set(['杀', '火杀', '雷杀', '冰杀', '决斗', '南蛮', '万箭', '火攻']) +// 杀(含属性杀)、决斗、南蛮入侵、万箭齐发、火攻。 +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) @@ -34,7 +34,7 @@ export default function decorateFenChao(event: MoveEventDraft, room: Room): Move const sourceCards: Card[] = [] // 按实测结果推断焚巢优先获得最早入堆的目标牌;弃牌数组按底 -> 顶保存。 for (const card of room.zones.get('discard')?.cards ?? []) { - if (!FEN_CHAO_CARD_NAMES.has(card.name)) continue + if (!FEN_CHAO_CARD_SPELL_IDS.has(card.spellId)) continue sourceCards.push(card) if (sourceCards.length === cardCount) break } diff --git a/tests/tracker/cardCounter.test.ts b/tests/tracker/cardCounter.test.ts index d3c1f83..89b6f05 100644 --- a/tests/tracker/cardCounter.test.ts +++ b/tests/tracker/cardCounter.test.ts @@ -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[] { 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] }) diff --git a/tests/tracker/fenChao.test.ts b/tests/tracker/fenChao.test.ts index 44e626c..3c57dd8 100644 --- a/tests/tracker/fenChao.test.ts +++ b/tests/tracker/fenChao.test.ts @@ -1,11 +1,39 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CardConfig } from '@/config/CardConfig' 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 CARD_SPELL_IDS: Record = { + 杀: 1, + 火杀: 1, + 雷杀: 1, + 冰杀: 1, + 决斗: 8, + 南蛮: 9, + 南蛮入侵: 9, + 万箭: 10, + 万箭齐发: 10, + 火攻: 83 +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +function createRoomWithDiscard( + cardNames: string[], + cardSpellIds = cardNames.map((name) => CARD_SPELL_IDS[name] ?? 0) +) { + const cardInfos = cardNames.map((name, index) => ({ + id: index + 1, + name, + spellId: cardSpellIds[index] + })) + vi.spyOn(CardConfig.GetInstance(), 'getCard').mockImplementation((id) => cardInfos[id - 1]) + const { controller } = createTrackerControllerHarness() controller.initTrackerRoom() controller.registerTrackerPlayers( @@ -21,9 +49,6 @@ function createRoomWithDiscard(cardNames: string[]) { 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 } } @@ -75,7 +100,7 @@ describe('焚巢弃牌堆取牌', () => { } ) - it.each(['杀', '火杀', '雷杀', '冰杀', '决斗', '南蛮', '万箭', '火攻'])( + it.each(['杀', '火杀', '雷杀', '冰杀', '决斗', '南蛮', '南蛮入侵', '万箭', '万箭齐发', '火攻'])( '识别 %s 并优先获得同名牌中最早入堆的一张', (name) => { const { controller, room } = createRoomWithDiscard(['闪', name, '桃', name, '无懈']) @@ -87,6 +112,20 @@ describe('焚巢弃牌堆取牌', () => { } ) + it.each([1, 8, 9, 10, 83])('按卡牌技能 ID %i 识别目标牌,不受显示名影响', (spellId) => { + const { controller, room } = createRoomWithDiscard( + ['杀', '更名后的卡牌', '决斗'], + [7, spellId, 0] + ) + + 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]) + expect(getCard(room, 2)!.spellId).toBe(spellId) + expect(getCard(room, 2)!.spellID).toBe(3752) + }) + it('连续获得按当前弃牌顺序继续筛选,张数以协议为准', () => { const { controller, room } = createRoomWithDiscard([ '火攻', From d6841c0b91d524073eb81e051d2c1d66a9847edd Mon Sep 17 00:00:00 2001 From: ll <48448919+llsccm@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:06:48 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E8=8E=B7=E5=8F=96=20seatUIs=20?= =?UTF-8?q?=E5=90=8E=E5=86=8D=E6=B8=B2=E6=9F=93=E6=98=B5=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.js | 2 -- src/logic.js | 1 + src/runtime/gameAdapter.js | 28 +++++++++++++--------------- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/index.js b/src/index.js index be1509d..2caa5fa 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,6 @@ import { logic } from './logic.js' import { Init, Exit } from './dom.js' import { notifyScriptError } from './utils/errorNotifier.js' -import { laya } from './runtime/gameAdapter.js' console.info( '%c三国杀小抄', @@ -59,6 +58,5 @@ function main() { main('INIT').then((r) => { if (r) { window._SGSMODULE.push(main) - laya.HideStartLoadingListener() } }) diff --git a/src/logic.js b/src/logic.js index 6c0b0e2..5c19daf 100644 --- a/src/logic.js +++ b/src/logic.js @@ -180,6 +180,7 @@ export function logic(msg) { case 'decodeGsClientUserSeatFlagNtf': // 新录像两个消息都有 旧录像只有这个消息 handleRecordStartGame(msg) + if (globalConfig.showNameSwitch && Game.needShowName) laya.showName() break case 'GsCUpdateRoleDataNtf': diff --git a/src/runtime/gameAdapter.js b/src/runtime/gameAdapter.js index 7812502..5c73def 100644 --- a/src/runtime/gameAdapter.js +++ b/src/runtime/gameAdapter.js @@ -1,5 +1,5 @@ -import { globalConfig } from '@/tracker' import { Game } from '@/tracker/Game' +import { wait } from '@/utils' import { addTooltip } from '@/utils/notification' // 每回合都可以发动 @@ -506,20 +506,18 @@ export class GameRuntime { GameEventDispatcher.on('HIDE_CHALLENGE_GAME_START', this, this.showName) } - showName() { - if (!globalConfig.showNameSwitch) return - - setTimeout(() => { - if (!Game.needShowName) return - this.gamescene?.seatContainer?.seatUIs?.forEach(({ seat, otherTopManager }) => { - if (seat?.playerInfo?.ClientId >= 4e9) return - otherTopManager?.createPlayerNameBg() - otherTopManager?.createPlayerName() - otherTopManager?.UpdatePlayerName(seat.playerInfo) - otherTopManager?.SetPlayNameVisible(true) - otherTopManager?.layout() - }) - }, 1000) + 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() + otherTopManager?.UpdatePlayerName(seat.playerInfo) + otherTopManager?.SetPlayNameVisible(true) + otherTopManager?.layout() + }) } /**