775 lines
30 KiB
TypeScript
775 lines
30 KiB
TypeScript
/**
|
||
* Tier 5 — White-Box Adversarial & Post-Implementation Hardening Suite (20 Tests)
|
||
*
|
||
* Stress-tests the real implementation across R1–R5 for:
|
||
* - Out-of-order / burst S2C packet & WorldEvent streams (socketed child/parent ordering,
|
||
* rapid 0x21/0x22 skill bonus updates, rapid 0x0C LifeManaChange bursts, belt slot collisions)
|
||
* - Re-entrant panel state transitions (P2P trade cancel mid-cursor-drag, NPC menu -> Identify -> Talk,
|
||
* quest reward panel interrupts)
|
||
* - Stale unit ID handling (removed warp/NPC/ground item via 0x0A UnitRemove)
|
||
* - Rapid belt shift-clicks + mercenary life/death transitions & paperdoll ('O') locking
|
||
* - Full-roundtrip binary C2S packet byte & C2S_PACKET_SIZES verification across all newly wired commands
|
||
*/
|
||
|
||
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
C2S_PACKET_SIZES,
|
||
decodeD2gsS2cPacket,
|
||
validateC2sPacket,
|
||
} from '../../src/netproto/index.ts'
|
||
import {
|
||
assertNoUndefinedText,
|
||
bridgeOnlineItemToUi,
|
||
createOnlineHudSession,
|
||
encodeClientCommandBytes,
|
||
extractStatStrings,
|
||
getLeftPanelOrigin,
|
||
makeItemData,
|
||
readU16LE,
|
||
readU32LE,
|
||
} from './helpers.ts'
|
||
|
||
const EMPTY_TABLES = {
|
||
itemStatCost: new Map(),
|
||
armor: new Map(),
|
||
weapons: new Map(),
|
||
misc: new Map(),
|
||
getStatMeta: () => undefined,
|
||
getStatCost: () => undefined,
|
||
getItemBaseMeta: () => undefined,
|
||
getItemMeta: () => undefined,
|
||
} as const
|
||
|
||
describe('Tier 5 — White-Box Adversarial & Post-Implementation Hardening (20 Tests)', () => {
|
||
// =========================================================================
|
||
// Section 1: R1 — Out-of-Order & Burst Container / Tooltip / Belt Streams
|
||
// =========================================================================
|
||
describe('R1 Adversarial — Out-of-Order Socket Streams, Belt Collisions & Stat Bursts', () => {
|
||
it('T5.1: socketed child (location=6) arriving after parent (0x9C) dynamically updates parent usedSockets and tooltip stats on re-sync', () => {
|
||
const { world, hud, hudModel } = createOnlineHudSession()
|
||
|
||
// 1. Parent 2-socket armor arrives first with 0 children
|
||
const armor = makeItemData({
|
||
id: 9001,
|
||
code: 'qui',
|
||
quality: 2,
|
||
location: 0,
|
||
container: 'inventory',
|
||
gridX: 0,
|
||
gridY: 0,
|
||
invWidth: 2,
|
||
invHeight: 3,
|
||
socketed: true,
|
||
totalSockets: 2,
|
||
usedSockets: 0,
|
||
stats: [{ statId: 31, param: 0, value: 11 }],
|
||
})
|
||
world.apply({ type: 'ItemEvent', action: 0x00, item: armor })
|
||
hudModel.syncFromWorld()
|
||
|
||
let uiArmor = hud.inventory.inventory.find((i) => i.id === 9001)!
|
||
expect(uiArmor).toBeDefined()
|
||
expect(uiArmor.sockets).toBe(2)
|
||
expect(uiArmor.usedSockets).toBe(0)
|
||
|
||
// 2. Two socketed children (location=6) arrive subsequently via 0x9D OwnedItemAction (action=0x00 PutInContainer)
|
||
const chippedRuby = makeItemData({
|
||
id: 9002,
|
||
code: 'gcr',
|
||
quality: 2,
|
||
location: 6,
|
||
container: 'socket',
|
||
ownerType: 4,
|
||
ownerId: 9001,
|
||
gridX: 0,
|
||
gridY: 0,
|
||
stats: [{ statId: 39, param: 0, value: 12 }], // Fire Resist +12%
|
||
})
|
||
const tirRune = makeItemData({
|
||
id: 9003,
|
||
code: 'r03',
|
||
quality: 2,
|
||
location: 6,
|
||
container: 'socket',
|
||
ownerType: 4,
|
||
ownerId: 9001,
|
||
gridX: 1,
|
||
gridY: 0,
|
||
stats: [{ statId: 138, param: 0, value: 2 }], // +2 to Mana after each Kill
|
||
})
|
||
world.apply({ type: 'ItemEvent', action: 0x00, item: chippedRuby })
|
||
world.apply({ type: 'ItemEvent', action: 0x00, item: tirRune })
|
||
hudModel.syncFromWorld()
|
||
|
||
uiArmor = hud.inventory.inventory.find((i) => i.id === 9001)!
|
||
expect(uiArmor.usedSockets).toBe(2)
|
||
expect(uiArmor.socketedGems?.length).toBe(2)
|
||
const statLines = extractStatStrings(uiArmor.statsEn ?? uiArmor.stats).join(' | ')
|
||
expect(statLines).toMatch(/10 to Life/i)
|
||
expect(statLines).toMatch(/2 to Mana/i)
|
||
})
|
||
|
||
it('T5.2: replacing a belt slot via action=0x0E evicts the old potion from world.containers.allById and belt map without orphan leaks', () => {
|
||
const { world, hud, hudModel, sentCommands, clearRecorded } = createOnlineHudSession()
|
||
|
||
const oldPotion = makeItemData({
|
||
id: 9010,
|
||
code: 'hp1',
|
||
location: 2,
|
||
container: 'belt',
|
||
gridX: 2,
|
||
gridY: 0,
|
||
})
|
||
const newPotion = makeItemData({
|
||
id: 9011,
|
||
code: 'rvl',
|
||
location: 2,
|
||
container: 'belt',
|
||
gridX: 2,
|
||
gridY: 0,
|
||
})
|
||
|
||
world.apply({ type: 'ItemEvent', action: 0x0e, item: oldPotion })
|
||
hudModel.syncFromWorld()
|
||
expect(world.containers.beltBySlot.get(2)?.id).toBe(9010)
|
||
expect(world.containers.allById.has(9010)).toBe(true)
|
||
|
||
// Overwrite belt slot 2 with newPotion
|
||
world.apply({ type: 'ItemEvent', action: 0x0e, item: newPotion })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(world.containers.beltBySlot.get(2)?.id).toBe(9011)
|
||
expect(world.containers.belt.has(9010)).toBe(false)
|
||
expect(world.containers.allById.has(9010)).toBe(false)
|
||
expect(hud.inventory.belt[2]?.id).toBe(9011)
|
||
|
||
clearRecorded()
|
||
hudModel.useBeltSlot(2, false)
|
||
expect(sentCommands).toContainEqual({ type: 'BeltUse', itemId: 9011, shift: false })
|
||
})
|
||
|
||
it('T5.3: action=0x02 (ToCursor) with container=inventory does not hijack the cursor slot unless location===4 or container===cursor', () => {
|
||
const { world, hud, hudModel } = createOnlineHudSession()
|
||
|
||
const bagItem = makeItemData({
|
||
id: 9020,
|
||
code: 'rin',
|
||
location: 0,
|
||
container: 'inventory',
|
||
gridX: 4,
|
||
gridY: 2,
|
||
})
|
||
// Adversarial packet where action is 0x02 but item is still in inventory (location=0)
|
||
world.apply({ type: 'ItemEvent', action: 0x02, item: bagItem })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(world.containers.cursor).toBeNull()
|
||
expect(hud.inventory.cursorItem).toBeNull()
|
||
expect(hud.inventory.inventory.some((i) => i.id === 9020)).toBe(true)
|
||
})
|
||
|
||
it('T5.4: rapid burst of SkillList (0x94/0x21/0x22) and PlayerLifeManaChange (0x0C) events converges to exact final state without duplicate hotkeys', () => {
|
||
const { world, hud, hudModel } = createOnlineHudSession({ selfId: 1, charClass: 1 })
|
||
|
||
// Rapid burst of skill updates on Fire Bolt (36) and Teleport (54)
|
||
for (let lvl = 1; lvl <= 10; lvl++) {
|
||
world.apply({
|
||
type: 'SkillList',
|
||
unitId: 1,
|
||
skills: [
|
||
{ skillId: 36, skill: 36, level: lvl, baseLevel: lvl, bonus: lvl % 3 },
|
||
{ skillId: 54, skill: 54, level: 1, baseLevel: 1, bonus: lvl },
|
||
],
|
||
})
|
||
}
|
||
// Rapid burst of PlayerLifeManaChange events
|
||
for (let i = 0; i < 15; i++) {
|
||
world.apply({
|
||
type: 'PlayerLifeManaChange',
|
||
life: 100 + i * 5,
|
||
mana: 80 + i * 3,
|
||
stamina: 50 + i,
|
||
x: 5100 + i,
|
||
y: 5050 + i,
|
||
vx: 0,
|
||
vy: 0,
|
||
})
|
||
}
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(hud.skillTree.hardPoints.get(36)).toBe(10)
|
||
expect(hud.skillTree.bonusLevels.get(36)).toBe(1)
|
||
expect(hud.skillTree.hardPoints.get(54)).toBe(1)
|
||
expect(hud.skillTree.bonusLevels.get(54)).toBe(10)
|
||
|
||
// Hotkeys list must not duplicate skills across burst syncs
|
||
const skills36 = hud.hotkeys.availableSkills.filter((s) => s.skillId === 36)
|
||
expect(skills36.length).toBe(1)
|
||
expect(skills36[0]!.level).toBe(11)
|
||
})
|
||
|
||
it('T5.5: bridgeOnlineItemToUi handles armor with explicitDefense and statId=31 without double-adding base ac or crashing on unknown affixes', () => {
|
||
const item = makeItemData({
|
||
id: 9030,
|
||
code: 'qui',
|
||
quality: 4, // Magic
|
||
identified: true,
|
||
defense: 15,
|
||
magicPrefixId: 99999, // Out-of-range prefix ID
|
||
magicSuffixId: 88888, // Out-of-range suffix ID
|
||
stats: [
|
||
{ statId: 31, param: 0, value: 15 }, // ArmorClass
|
||
{ statId: 194, param: 0, value: 2 }, // NumSockets
|
||
],
|
||
})
|
||
const ui = bridgeOnlineItemToUi(item)
|
||
expect(ui.defense).toBe(15)
|
||
expect(ui.sockets).toBe(2)
|
||
expect(ui.nameEn.length).toBeGreaterThan(0)
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// Section 2: R1.3 & R2 — Weapon Swap & Multi-Act NPC Adversarial Transitions
|
||
// =========================================================================
|
||
describe('R1.3 & R2 Adversarial — Weapon Swap Cycles, Unsolicited S2C 0x27 & NPC Menu Re-entry', () => {
|
||
it('T5.6: rapid WeaponSwap (0x60) command emissions do not mutate hud.inventory.weaponSwapSet until S2C 0x97 arrives', () => {
|
||
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
|
||
|
||
expect(hud.inventory.weaponSwapSet).toBe(0)
|
||
hudModel.swapWeapons()
|
||
hudModel.swapWeapons()
|
||
hudModel.swapWeapons()
|
||
|
||
expect(sentCommands.filter((c) => c.type === 'SwapWeapons').length).toBe(3)
|
||
expect(sentPackets.filter((p) => p[0] === 0x60 && p.byteLength === 1).length).toBe(3)
|
||
// Must remain 0 until server confirms via 0x97 (ItemEvent action 0x17)
|
||
expect(hud.inventory.weaponSwapSet).toBe(0)
|
||
|
||
world.apply({ type: 'WeaponSwitch' })
|
||
hudModel.syncFromWorld()
|
||
expect(hud.inventory.weaponSwapSet).toBe(1)
|
||
|
||
world.apply({ type: 'WeaponSwitch' })
|
||
hudModel.syncFromWorld()
|
||
expect(hud.inventory.weaponSwapSet).toBe(0)
|
||
})
|
||
|
||
it('T5.7: unsolicited S2C 0x27 NPC Info packet when no NpcInit was noted stores npcInfoRaw without hijacking npcInteraction.npcId', () => {
|
||
const { world, worldPanels, hudModel } = createOnlineHudSession()
|
||
|
||
const pkt27 = new Uint8Array(40)
|
||
pkt27[0] = 0x27
|
||
pkt27[1] = 0x01 // unitType = monster
|
||
pkt27[2] = 0x8c
|
||
pkt27[3] = 0x23 // unitId = 9100
|
||
const ev = decodeD2gsS2cPacket(pkt27, { tables: EMPTY_TABLES, encoding: 'gbk', rttMs: 0 })
|
||
world.apply(ev)
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(world.npcInteraction.npcId).toBeNull()
|
||
expect(worldPanels.npcMenu).toBeNull()
|
||
expect(world.npcInteraction.npcInfoRaw).toBeDefined()
|
||
})
|
||
|
||
it('T5.8: clicking Cain identify closes menu, and subsequent NpcInit + syncFromWorld cleanly re-opens Cain menu for Talk/Trade', () => {
|
||
const { world, worldPanels, hudModel, sentCommands, clearRecorded } = createOnlineHudSession()
|
||
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 9101, classId: 244, name: 'Deckard Cain', x: 5104, y: 5054 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 9101 } })
|
||
hudModel.syncFromWorld()
|
||
expect(worldPanels.npcMenu).not.toBeNull()
|
||
|
||
worldPanels.clickNpcMenuOption('identify')
|
||
expect(sentCommands).toContainEqual({ type: 'NpcIdentifyItems', npcId: 9101 })
|
||
expect(worldPanels.npcMenu).toBeNull()
|
||
|
||
// Re-initiate interaction with same NPC 9101
|
||
clearRecorded()
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 9101 } })
|
||
hudModel.syncFromWorld()
|
||
expect(worldPanels.npcMenu).not.toBeNull()
|
||
expect(worldPanels.npcMenu?.npcId).toBe(9101)
|
||
|
||
worldPanels.clickNpcMenuOption('talk')
|
||
expect(sentCommands).toContainEqual({ type: 'QuestMessage', npcId: 9101, messageId: 0 })
|
||
})
|
||
|
||
it('T5.9: Escape key / closeActiveNpcPanel while NPC menu is open emits 0x30 NpcCancel and clears world.npcInteraction.npcId', () => {
|
||
const { world, worldPanels, hudModel, sentCommands, sentPackets } = createOnlineHudSession()
|
||
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 9102, classId: 148, name: 'Akara', x: 5104, y: 5054 },
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: 9102 } })
|
||
hudModel.syncFromWorld()
|
||
expect(worldPanels.npcMenu).not.toBeNull()
|
||
|
||
hudModel.closeActiveNpcPanel()
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(world.npcInteraction.npcId).toBeNull()
|
||
expect(worldPanels.npcMenu).toBeNull()
|
||
expect(sentCommands).toContainEqual({ type: 'NpcCancel', npcId: 9102 })
|
||
expect(sentPackets.some((p) => p[0] === 0x30 && p.byteLength === 9 && readU32LE(p, 5) === 9102)).toBe(true)
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// Section 3: R3 — Mercenary Life/Death Transitions & Paperdoll ('O') Locking
|
||
// =========================================================================
|
||
describe('R3 Adversarial — Mercenary Death Mid-Session, Equipment Sync & Revive Guards', () => {
|
||
it('T5.10: mercenary dying (UnitMode mode=12, lifePct=0) locks O hotkey from opening merc paperdoll and enables captain resurrect option', () => {
|
||
const { world, hud, worldPanels, hudModel, dom } = createOnlineHudSession({ selfId: 1 })
|
||
|
||
// Assign active mercenary (lifePct=100)
|
||
world.apply({
|
||
type: 'Merc',
|
||
kind: 'assign',
|
||
ownerId: 1,
|
||
mercId: 9201,
|
||
classId: 271,
|
||
nameId: 10,
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(hud.merc?.isDead).toBe(false)
|
||
|
||
// Mercenary dies (mode=12, lifePct=0) and server reports reviveCost via 0x9B
|
||
world.apply({
|
||
type: 'UnitMode',
|
||
unitType: 1,
|
||
id: 9201,
|
||
mode: 12,
|
||
lifePct: 0,
|
||
})
|
||
world.apply({
|
||
type: 'Merc',
|
||
kind: 'reviveCost',
|
||
deadNameId: 10,
|
||
reviveCost: 12500,
|
||
})
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(hud.merc?.isDead).toBe(true)
|
||
expect(hud.merc?.reviveCost).toBe(12500)
|
||
expect((worldPanels as any).hasDeadMercenary).toBe(true)
|
||
|
||
// Pressing 'O' when merc is dead must NOT open the merc paperdoll
|
||
hud.leftPanel = 'none'
|
||
dom.dispatchKeyDown('o')
|
||
expect(hud.leftPanel).toBe('none')
|
||
})
|
||
|
||
it('T5.11: equipping and swapping mercenary items across slots 1, 3, 4 keeps both merc.equipped (numeric id) and merc.equipment (string id) synchronized and renders without undefined', () => {
|
||
const { world, hud, hudModel, dom, sentCommands, sentPackets, clearRecorded, renderFrame } =
|
||
createOnlineHudSession({ selfId: 1 })
|
||
|
||
world.apply({
|
||
type: 'Merc',
|
||
kind: 'assign',
|
||
ownerId: 1,
|
||
mercId: 9202,
|
||
classId: 338,
|
||
nameId: 12,
|
||
})
|
||
|
||
const helm = makeItemData({
|
||
id: 9211,
|
||
opcode: 0x9d,
|
||
action: 0x06,
|
||
code: 'cap',
|
||
location: 1,
|
||
container: 'merc',
|
||
bodyLoc: 1,
|
||
equippedSlot: 1,
|
||
})
|
||
const armor = makeItemData({
|
||
id: 9213,
|
||
opcode: 0x9d,
|
||
action: 0x06,
|
||
code: 'qui',
|
||
location: 1,
|
||
container: 'merc',
|
||
bodyLoc: 3,
|
||
equippedSlot: 3,
|
||
})
|
||
const weapon = makeItemData({
|
||
id: 9214,
|
||
opcode: 0x9d,
|
||
action: 0x06,
|
||
code: 'spc',
|
||
location: 1,
|
||
container: 'merc',
|
||
bodyLoc: 4,
|
||
equippedSlot: 4,
|
||
})
|
||
|
||
world.apply({ type: 'ItemEvent', action: 0x06, item: helm })
|
||
world.apply({ type: 'ItemEvent', action: 0x06, item: armor })
|
||
world.apply({ type: 'ItemEvent', action: 0x06, item: weapon })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(hud.merc?.equipped.head?.id).toBe(9211)
|
||
expect(hud.merc?.equipped.torso?.id).toBe(9213)
|
||
expect(hud.merc?.equipped.rightHand?.id).toBe(9214)
|
||
expect(hud.merc?.equipment.head?.id).toBe('9211')
|
||
expect(hud.merc?.equipment.torso?.id).toBe('9213')
|
||
expect(hud.merc?.equipment.rightHand?.id).toBe('9214')
|
||
|
||
dom.dispatchKeyDown('o')
|
||
const ctx = renderFrame()
|
||
assertNoUndefinedText(ctx)
|
||
const texts = ctx.drawnTexts.map((t) => t.text).join(' ')
|
||
expect(texts.includes('cap') || texts.includes('Cap') || texts.includes('帽子')).toBe(true)
|
||
expect(
|
||
texts.includes('qui') || texts.includes('Quilted Armor') || texts.includes('布甲'),
|
||
).toBe(true)
|
||
expect(texts.includes('spc') || texts.includes('Scepter') || texts.includes('權杖')).toBe(
|
||
true,
|
||
)
|
||
|
||
// Click merc slot 4 (Right Hand / Weapon: ox + 36..92, oy + 98..210) -> C2S 0x61 MercItem (3B)
|
||
clearRecorded()
|
||
const { ox, oy } = getLeftPanelOrigin()
|
||
hud.handleClick(ox + 64, oy + 154)
|
||
expect(sentCommands).toContainEqual({ type: 'MercItem', slot: 4 })
|
||
expect(
|
||
sentPackets.some((p) => p[0] === 0x61 && p.byteLength === 3 && readU16LE(p, 1) === 4),
|
||
).toBe(true)
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// Section 4: R4 — Stale Unit IDs, Warp Removal, SkillCast & Death Guards
|
||
// =========================================================================
|
||
describe('R4 Adversarial — Stale Unit Removal (0x0A), SkillCast Decoding & Death Lockout', () => {
|
||
it('T5.12: removing a warp or ground item via S2C 0x0A UnitRemove purges it from world units and containers immediately', () => {
|
||
const { world, hudModel } = createOnlineHudSession()
|
||
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: {
|
||
unitType: 5,
|
||
id: 9301,
|
||
classId: 2,
|
||
warpId: 2,
|
||
name: 'To Cave',
|
||
x: 5110,
|
||
y: 5060,
|
||
} as any,
|
||
})
|
||
const groundSword = makeItemData({
|
||
id: 9302,
|
||
action: 0x03,
|
||
code: 'ssd',
|
||
location: 3,
|
||
container: 'ground',
|
||
worldX: 5112,
|
||
worldY: 5062,
|
||
})
|
||
world.apply({ type: 'ItemEvent', action: 0x03, item: groundSword })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(world.getUnit('tile', 9301)).toBeDefined()
|
||
expect(world.getUnit('item', 9302)).toBeDefined()
|
||
|
||
// Server despawns both via UnitRemove (0x0A)
|
||
world.apply({ type: 'UnitRemove', unitType: 5, id: 9301 })
|
||
world.apply({ type: 'UnitRemove', unitType: 4, id: 9302 })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(world.getUnit('tile', 9301)).toBeUndefined()
|
||
expect(world.getUnit('item', 9302)).toBeUndefined()
|
||
})
|
||
|
||
it('T5.13: decoding S2C 0x4C (unit-target SkillCast) and 0x4D (ground-target SkillCast) updates caster castState and facing direction', () => {
|
||
const { world, hudModel } = createOnlineHudSession({ selfId: 1, x: 5100, y: 5050 })
|
||
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: 9350, classId: 1, name: 'Fallen', x: 5120, y: 5050 },
|
||
})
|
||
|
||
// S2C 0x4C (16B): [0x4c, unitType=0, unitId(4)=1, skillId(2)=47, lvl=1, unk=1, targetId(4)=9350, 0, 0]
|
||
const pkt4c = new Uint8Array(16)
|
||
const v4c = new DataView(pkt4c.buffer)
|
||
pkt4c[0] = 0x4c
|
||
pkt4c[1] = 0
|
||
v4c.setUint32(2, 1, true)
|
||
v4c.setUint16(6, 47, true)
|
||
pkt4c[8] = 1
|
||
pkt4c[9] = 1
|
||
v4c.setUint32(10, 9350, true)
|
||
|
||
const ev4c = decodeD2gsS2cPacket(pkt4c, { tables: EMPTY_TABLES, encoding: 'gbk', rttMs: 0 })
|
||
world.apply(ev4c)
|
||
hudModel.syncFromWorld()
|
||
|
||
const selfUnit = world.getUnit('player', 1) as Record<string, any> | undefined
|
||
const castState = selfUnit?.castState ?? (world.self as Record<string, any>).castState
|
||
expect(castState).toBeDefined()
|
||
expect(castState.skillId).toBe(47)
|
||
})
|
||
|
||
it('T5.14: player death (life=0) sets world.isDead=true, and S2C PlayerLifeManaChange at town spawn clears isDead after 0x41 Resurrect', () => {
|
||
const { world, hud, hudModel, sentCommands, sentPackets } = createOnlineHudSession({
|
||
selfId: 1,
|
||
life: 100,
|
||
maxLife: 100,
|
||
})
|
||
|
||
world.apply({
|
||
type: 'PlayerLifeManaChange',
|
||
life: 0,
|
||
mana: 40,
|
||
stamina: 50,
|
||
x: 5100,
|
||
y: 5050,
|
||
vx: 0,
|
||
vy: 0,
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(world.isDead).toBe(true)
|
||
expect((hud as any).isDead).toBe(true)
|
||
|
||
hudModel.resurrect()
|
||
expect(sentCommands).toContainEqual({ type: 'Resurrect' })
|
||
expect(sentPackets.some((p) => p[0] === 0x41 && p.byteLength === 1)).toBe(true)
|
||
|
||
// Server restores life in town
|
||
world.apply({
|
||
type: 'PlayerLifeManaChange',
|
||
life: 100,
|
||
mana: 50,
|
||
stamina: 80,
|
||
x: 5104,
|
||
y: 5054,
|
||
vx: 0,
|
||
vy: 0,
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(world.isDead).toBe(false)
|
||
expect((hud as any).isDead).toBe(false)
|
||
})
|
||
})
|
||
|
||
// =========================================================================
|
||
// Section 5: R5 — Re-Entrant Trade Cancels, Party Departures & Full C2S Audit
|
||
// =========================================================================
|
||
describe('R5 Adversarial — Re-Entrant Trade Aborts, Party Leaves & C2S Binary Audit', () => {
|
||
it('T5.15: receiving S2C 0x77 action=0x0C (Trade Cancel) while trade window is open with partner items and gold resets trade state and closes left panel', () => {
|
||
const { world, hud, worldPanels, hudModel, renderFrame } = createOnlineHudSession({ selfId: 1 })
|
||
|
||
// Open trade window (0x77 action 0x01)
|
||
world.apply({ type: 'Trade', kind: 'button', action: 0x01 })
|
||
const partnerRing = makeItemData({
|
||
id: 9401,
|
||
code: 'rin',
|
||
location: 0,
|
||
container: 'tradePartner',
|
||
storePage: 2,
|
||
storagePage: 2,
|
||
gridX: 0,
|
||
gridY: 0,
|
||
})
|
||
world.apply({ type: 'ItemEvent', action: 0x00, item: partnerRing })
|
||
world.apply({ type: 'Trade', kind: 'gold', isSelf: false, amount: 25000 })
|
||
world.apply({ type: 'Trade', kind: 'button', action: 0x05 }) // Partner clicked accept
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(world.trade.active).toBe(true)
|
||
expect(world.trade.partnerAccepted).toBe(true)
|
||
expect(world.trade.partnerGold).toBe(25000)
|
||
expect(hud.leftPanel).toBe('trade')
|
||
|
||
const ctx = renderFrame()
|
||
assertNoUndefinedText(ctx)
|
||
expect(ctx.drawnTexts.some((t) => t.text.includes('Partner Gold: 25000'))).toBe(true)
|
||
|
||
// Server cancels trade mid-session (0x77 action 0x0C)
|
||
world.apply({ type: 'Trade', kind: 'button', action: 0x0c })
|
||
hudModel.syncFromWorld()
|
||
|
||
expect(world.trade.active).toBe(false)
|
||
expect(world.trade.partnerAccepted).toBe(false)
|
||
expect(world.trade.selfAccepted).toBe(false)
|
||
expect(hud.leftPanel).toBe('none')
|
||
expect((worldPanels as any).activePanel).toBe('none')
|
||
})
|
||
|
||
it('T5.16: S2C 0x5C PlayerLeaveGame (partyId=0xffff, empty charName) removes the departed player from world.party and hud.partyMembers', () => {
|
||
const { world, hud, hudModel } = createOnlineHudSession({ selfId: 1 })
|
||
|
||
world.apply({
|
||
type: 'Party',
|
||
kind: 'roster',
|
||
playerId: 9450,
|
||
charClass: 3,
|
||
charLevel: 42,
|
||
partyId: 1,
|
||
charName: 'PaladinBro',
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(world.party.has(9450)).toBe(true)
|
||
expect((hud as any).partyMembers.some((m: any) => m.playerId === 9450)).toBe(true)
|
||
|
||
// 0x5C leaves game -> partyId=0xffff, empty charName
|
||
world.apply({
|
||
type: 'Party',
|
||
kind: 'roster',
|
||
playerId: 9450,
|
||
partyId: 0xffff,
|
||
charName: '',
|
||
})
|
||
hudModel.syncFromWorld()
|
||
expect(world.party.has(9450)).toBe(false)
|
||
expect((hud as any).partyMembers.some((m: any) => m.playerId === 9450)).toBe(false)
|
||
})
|
||
|
||
it('T5.17: Charsi imbue, Larzuk socket, and Anya personalize panels open inventory alongside leftPanel and emit 0x38 NpcEntityAction(action=0x58, npcId, itemId) on canvas Confirm click', () => {
|
||
const { world, hud, worldPanels, hudModel, sentCommands, sentPackets, clearRecorded, renderFrame } =
|
||
createOnlineHudSession()
|
||
|
||
const { ox, oy } = getLeftPanelOrigin()
|
||
for (const reward of [
|
||
{ npcId: 9501, hcIdx: 154, name: 'Charsi', option: 'imbue' as const, itemId: 9601 },
|
||
{ npcId: 9502, hcIdx: 511, name: 'Larzuk', option: 'add_sockets' as const, itemId: 9602 },
|
||
{ npcId: 9503, hcIdx: 512, name: 'Anya', option: 'personalize' as const, itemId: 9603 },
|
||
]) {
|
||
clearRecorded()
|
||
world.apply({
|
||
type: 'UnitAssign',
|
||
unit: { unitType: 1, id: reward.npcId, classId: reward.hcIdx, name: reward.name, x: 5100, y: 5050 },
|
||
})
|
||
world.apply({
|
||
type: 'ItemEvent',
|
||
item: makeItemData({
|
||
id: reward.itemId,
|
||
code: 'cap',
|
||
location: 4,
|
||
container: 'cursor',
|
||
}),
|
||
})
|
||
world.noteOutgoingCommand({ type: 'NpcInit', target: { unitType: 1, id: reward.npcId } })
|
||
hudModel.syncFromWorld()
|
||
|
||
worldPanels.clickNpcMenuOption(reward.option)
|
||
expect(hud.leftPanel).toBe(reward.option)
|
||
expect(hud.rightPanel).toBe('inv')
|
||
|
||
const ctx = renderFrame()
|
||
assertNoUndefinedText(ctx)
|
||
|
||
// Place cursor item into 1-item slot box (ox + 116..204, oy + 120..236)
|
||
hud.handleClick(ox + 160, oy + 178)
|
||
const ctx2 = renderFrame()
|
||
assertNoUndefinedText(ctx2)
|
||
|
||
clearRecorded()
|
||
// Click Confirm button box (ox + 112..208, oy + 280..312)
|
||
hud.handleClick(ox + 160, oy + 296)
|
||
expect(sentCommands).toContainEqual({
|
||
type: 'NpcEntityAction',
|
||
action: 0,
|
||
npcId: reward.npcId,
|
||
complement: reward.itemId,
|
||
})
|
||
expect(
|
||
sentPackets.some(
|
||
(p) =>
|
||
p[0] === 0x38 &&
|
||
p.byteLength === 13 &&
|
||
readU32LE(p, 1) === 0 &&
|
||
readU32LE(p, 5) === reward.npcId &&
|
||
readU32LE(p, 9) === reward.itemId,
|
||
),
|
||
).toBe(true)
|
||
}
|
||
})
|
||
|
||
it('T5.18: ClickButton (0x4F) 32-bit gold complement encodes low u16 at bytes [3..4] and high u16 at bytes [5..6] per 1.13c word order', () => {
|
||
const goldAmount = 0x0012_3456 // 1,193,046 gold
|
||
const pkts = encodeClientCommandBytes({
|
||
type: 'ClickButton',
|
||
button: 0x0007,
|
||
complement: goldAmount,
|
||
})
|
||
const pkt = pkts[0]!
|
||
expect(pkt.byteLength).toBe(C2S_PACKET_SIZES[0x4f])
|
||
expect(pkt[0]).toBe(0x4f)
|
||
expect(readU16LE(pkt, 1)).toBe(0x0007)
|
||
expect(readU16LE(pkt, 3)).toBe(0x3456)
|
||
expect(readU16LE(pkt, 5)).toBe(0x0012)
|
||
expect(validateC2sPacket(pkt).valid).toBe(true)
|
||
})
|
||
|
||
it('T5.19: all newly wired C2S opcodes match exact C2S_PACKET_SIZES entries and pass validateC2sPacket', () => {
|
||
const cases: Array<{ opcode: number; cmd: Record<string, any> }> = [
|
||
{ opcode: 0x09, cmd: { type: 'WarpTo', unitType: 5, warpId: 101 } },
|
||
{ opcode: 0x13, cmd: { type: 'Interact', target: { unitType: 2, id: 102 } } },
|
||
{ opcode: 0x16, cmd: { type: 'ItemPickup', itemId: 103, toCursor: true } },
|
||
{ opcode: 0x17, cmd: { type: 'ItemDrop', itemId: 104 } },
|
||
{ opcode: 0x18, cmd: { type: 'ItemPlace', itemId: 105, x: 2, y: 3, buffer: 0 } },
|
||
{ opcode: 0x19, cmd: { type: 'ItemToCursor', itemId: 106 } },
|
||
{ opcode: 0x1a, cmd: { type: 'ItemEquip', itemId: 107, slot: 4 } },
|
||
{ opcode: 0x1c, cmd: { type: 'ItemUnequip', slot: 4 } },
|
||
{ opcode: 0x1d, cmd: { type: 'ItemSwap', itemId: 108, slot: 4 } },
|
||
{ opcode: 0x1f, cmd: { type: 'SwapContainerItem', cursorItemId: 109, replacedItemId: 110, x: 1, y: 1 } },
|
||
{ opcode: 0x20, cmd: { type: 'ItemUse', itemId: 111, at: { x: 5100, y: 5050 } } },
|
||
{ opcode: 0x23, cmd: { type: 'BeltPut', itemId: 114, slot: 2 } },
|
||
{ opcode: 0x24, cmd: { type: 'BeltRemove', itemId: 115 } },
|
||
{ opcode: 0x25, cmd: { type: 'BeltSwap', cursorItemId: 116, beltItemId: 117 } },
|
||
{ opcode: 0x26, cmd: { type: 'BeltUse', itemId: 118, useOnMerc: 1 } },
|
||
{ opcode: 0x27, cmd: { type: 'IdentifyItem', itemId: 119, scrollId: 120 } },
|
||
{ opcode: 0x28, cmd: { type: 'SocketItem', itemId: 121, targetItemId: 122 } },
|
||
{ opcode: 0x29, cmd: { type: 'ScrollToBook', scrollId: 123, bookId: 124 } },
|
||
{ opcode: 0x31, cmd: { type: 'QuestMessage', npcId: 125, messageId: 0 } },
|
||
{ opcode: 0x36, cmd: { type: 'MercHire', npcId: 126, mercId: 127 } },
|
||
{ opcode: 0x38, cmd: { type: 'NpcEntityAction', action: 0, npcId: 128, complement: 40 } },
|
||
{ opcode: 0x41, cmd: { type: 'Resurrect' } },
|
||
{ opcode: 0x4f, cmd: { type: 'ClickButton', button: 2, complement: 0 } },
|
||
{ opcode: 0x5d, cmd: { type: 'Hostile', button: 1, hostile: true, targetPlayerId: 129 } },
|
||
{ opcode: 0x5e, cmd: { type: 'PartyAction', action: 0x06, targetPlayerId: 130 } },
|
||
{ opcode: 0x60, cmd: { type: 'SwapWeapons' } },
|
||
{ opcode: 0x61, cmd: { type: 'MercItem', slot: 3 } },
|
||
{ opcode: 0x62, cmd: { type: 'MercResurrect', npcId: 131 } },
|
||
]
|
||
|
||
for (const { opcode, cmd } of cases) {
|
||
const pkts = encodeClientCommandBytes(cmd)
|
||
expect(pkts.length).toBeGreaterThan(0)
|
||
const pkt = pkts[0]!
|
||
expect(pkt[0]).toBe(opcode)
|
||
expect(pkt.byteLength).toBe(C2S_PACKET_SIZES[opcode])
|
||
expect(validateC2sPacket(pkt).valid).toBe(true)
|
||
}
|
||
})
|
||
|
||
it('T5.20: architectural import boundary guard — no file in src/client/** imports directly from src/netproto/d2gs/**', async () => {
|
||
const fs = await import('node:fs')
|
||
const path = await import('node:path')
|
||
|
||
const walk = (dir: string): string[] => {
|
||
const out: string[] = []
|
||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||
const full = path.join(dir, entry.name)
|
||
if (entry.isDirectory()) out.push(...walk(full))
|
||
else if (entry.name.endsWith('.ts')) out.push(full)
|
||
}
|
||
return out
|
||
}
|
||
|
||
const clientFiles = walk(path.resolve(process.cwd(), 'src/client'))
|
||
expect(clientFiles.length).toBeGreaterThan(0)
|
||
for (const file of clientFiles) {
|
||
const content = fs.readFileSync(file, 'utf8')
|
||
expect(content).not.toMatch(/from\s+['"][^'"]*netproto\/d2gs\//)
|
||
}
|
||
})
|
||
})
|
||
})
|