diablo2-web/tests/e2e-parity/helpers.ts

805 lines
23 KiB
TypeScript

/**
* Shared Test Helpers for the Diablo II v1.13c Parity E2E Test Suite (`tests/e2e-parity/`).
*
* Provides authoritative fixtures, mock environments, math oracles, and packet
* validators adhering strictly to Diablo II v1.13c decompiled specifications.
*/
import { vi } from 'vitest'
import {
ORTHO_CELL_HEIGHT,
ORTHO_CELL_WIDTH,
ORTHO_SUB_TILE_HEIGHT,
ORTHO_SUB_TILE_WIDTH,
COLLIDE_BLANK,
COLLIDE_CORPSE,
COLLIDE_DOOR,
COLLIDE_ITEM,
COLLIDE_MASK_INVALID,
COLLIDE_MISSILE,
COLLIDE_MISSILE_BARRIER,
COLLIDE_MONSTER,
COLLIDE_NONE,
COLLIDE_NOPLAYER,
COLLIDE_NO_PATH,
COLLIDE_OBJECT,
COLLIDE_PET,
COLLIDE_PLAYER,
COLLIDE_PRESET,
COLLIDE_VISIBLE,
COLLIDE_WALL,
COLLIDE_WATER,
pickVariant,
} from '../../src/common/world/d2map.ts'
import { DRLG_UNIVERSAL_DT1 } from '../../src/common/drlg/drlg-map.ts'
import {
CHAR_ANIM_CLIPS_113C,
computeAnimFrameIndex,
computeDiminishingRate113c,
computeEffectiveFcr,
computeEffectiveFhr,
computeEffectiveIas,
computeOneShotDurationMs,
computeOneShotTicks,
computeStepSpeed256,
D2_TICK_MS,
STAT_FASTERATTACKRATE,
STAT_FASTERBLOCKRATE,
STAT_FASTERCASTRATE,
STAT_FASTERHITRECOVERY,
STAT_SKILL_IAS,
type CharAnimClip113c,
type CharAnimClipKey,
} from '../../src/common/units/char-anim-speed.ts'
import {
calculateHitChance,
calculatePlayerBlockChance,
} from '../../src/common/skills/formulas.ts'
import {
CANONICAL_113C_MISSILES,
getMissileTxtData,
getOverlayTxtData,
getSkillCastOverlay,
} from '../../src/common/skills/missiles-data.ts'
import {
C2S_PACKET_SIZES,
S2C_PACKET_SIZES,
BncsCreateAccountError,
BncsCreateAccountStatus,
BncsOpcode,
encodeBncsCreateAccount2,
type ClientCommand,
type ItemData,
type ServerEvent,
} from '../../src/netproto/index.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { ClientUnit } from '../../src/client/world/client-unit.ts'
import { CommandMapper, ViewportCamera } from '../../src/client/input/command-mapper.ts'
import {
VIEWPORT_800x600,
getViewportProfile,
subTileToWorldOrtho,
worldOrthoToSubTile,
worldToScreen,
screenToWorld,
subTileToScreen,
screenToSubTile,
computeViewportAnchors,
computePanelCameraShift,
} from '../../src/client/view/viewport-profile.ts'
import { buildCollisionGridFromLevelView } from '../../src/client/view/scene-source.ts'
import {
buildSceneDrawables,
transToBlendMode,
type RenderableUnitInput,
} from '../../src/client/view/unit-drawables.ts'
import { HudModel } from '../../src/client/ui-model/hud-model.ts'
import {
INV_COLS,
INV_ROWS,
INV_GRID_ORIGIN,
INVENTORY_TXT_LAYOUTS,
getInventoryLayout,
InventoryPanel,
WEAPON_SWAP_TABS_BOUNDS,
resolveWeaponSwapTabClick,
STASH_GOLD_CAP,
getInventoryGoldLimit,
findFreeGridSlot,
type UiInventoryItem,
type GridPlacement,
} from '../../src/client/ui/inventory.ts'
import {
CUBE_GRID_ORIGIN,
WorldPanelsHud,
getActQuests,
getActWaypoints,
computeNpcMenuAnchorY,
} from '../../src/client/ui/world-panels.ts'
import {
CharacterSheetPanel,
type CharacterAttributes,
} from '../../src/client/ui/character-sheet.ts'
import { D2FontRenderer } from '../../src/client/ui/font.ts'
import { HudManager } from '../../src/client/ui/hud-manager.ts'
import { Minimap } from '../../src/client/ui/minimap.ts'
import { onlineItemDataToUiInventoryItem } from '../../src/common/items/item-bridge.ts'
import {
BeltModel,
getBeltRowsForCode,
getBeltSlotBox,
itemToBeltPotion,
BELT_COLS,
BELT_ROWS,
} from '../../src/common/items/belt-model.ts'
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../src/common/units/monsters-data.ts'
import {
AVATAR_COMPONENT_NAMES,
CAMPFIRE_FLAME_CONTROL_SPEC,
CAMPFIRE_CLASS_SPECS,
CANONICAL_D2LAUNCH_TBL_BY_ID,
D2_COLORS_TXT_PALETTE,
charSummaryToRosterCharacter,
createInitialFlowState,
enterCharCreate,
filterAccountName,
filterCharNameInput,
formatCharacterLevelAndClass,
isValidAccountChar,
isValidCharacterName,
canAcceptCharNameChar,
mapCreateAccountServerStatus,
parseMcpAvatarStatstring,
resolveClassNameByTbl,
resolveStringByTblId,
selectCampfireClass,
toggleChatGem,
validateNewAccountInputs,
} from '../../src/client/frontend/flow-state.ts'
import {
FrontendRenderer,
CAMPFIRE_OVERLAY_SPECS,
type FrontendManifest,
} from '../../src/client/frontend/frontend-renderer.ts'
import {
lookupTbl,
requireTbl,
getLang,
setLang,
getGameLang,
setGameLang,
getTblLang,
resolveLangFromPathname,
detectLangFromUrl,
} from '../../src/client/i18n/lang.ts'
import { isTownLevel } from '../../src/common/world/scene-population.ts'
import { decode0x8ECorpseAssign } from '../../src/netproto/d2gs/s2c/party.ts'
export {
isTownLevel,
decode0x8ECorpseAssign,
// Map & Collision Constants
ORTHO_CELL_HEIGHT,
ORTHO_CELL_WIDTH,
ORTHO_SUB_TILE_HEIGHT,
ORTHO_SUB_TILE_WIDTH,
COLLIDE_BLANK,
COLLIDE_CORPSE,
COLLIDE_DOOR,
COLLIDE_ITEM,
COLLIDE_MASK_INVALID,
COLLIDE_MISSILE,
COLLIDE_MISSILE_BARRIER,
COLLIDE_MONSTER,
COLLIDE_NONE,
COLLIDE_NOPLAYER,
COLLIDE_NO_PATH,
COLLIDE_OBJECT,
COLLIDE_PET,
COLLIDE_PLAYER,
COLLIDE_PRESET,
COLLIDE_VISIBLE,
COLLIDE_WALL,
COLLIDE_WATER,
pickVariant,
DRLG_UNIVERSAL_DT1,
// Viewport & Projection
VIEWPORT_800x600,
getViewportProfile,
subTileToWorldOrtho,
worldOrthoToSubTile,
worldToScreen,
screenToWorld,
subTileToScreen,
screenToSubTile,
computeViewportAnchors,
computePanelCameraShift,
buildCollisionGridFromLevelView,
// Animation & Speed Constants / Math
CHAR_ANIM_CLIPS_113C,
computeAnimFrameIndex,
computeDiminishingRate113c,
computeEffectiveFcr,
computeEffectiveFhr,
computeEffectiveIas,
computeOneShotDurationMs,
computeOneShotTicks,
computeStepSpeed256,
D2_TICK_MS,
STAT_FASTERATTACKRATE,
STAT_FASTERBLOCKRATE,
STAT_FASTERCASTRATE,
STAT_FASTERHITRECOVERY,
STAT_SKILL_IAS,
// Formulas
calculateHitChance,
calculatePlayerBlockChance,
// Missiles, Scene Drawables & Overlays
CANONICAL_113C_MISSILES,
getMissileTxtData,
getOverlayTxtData,
getSkillCastOverlay,
buildSceneDrawables,
transToBlendMode,
type RenderableUnitInput,
// Protocol & Tables
C2S_PACKET_SIZES,
S2C_PACKET_SIZES,
BncsCreateAccountError,
BncsCreateAccountStatus,
BncsOpcode,
encodeBncsCreateAccount2,
// Client Classes, Combat, UI, & Items
ClientWorld,
ClientUnit,
CommandMapper,
ViewportCamera,
HudModel,
HudManager,
INV_COLS,
INV_ROWS,
INV_GRID_ORIGIN,
INVENTORY_TXT_LAYOUTS,
getInventoryLayout,
InventoryPanel,
WEAPON_SWAP_TABS_BOUNDS,
resolveWeaponSwapTabClick,
STASH_GOLD_CAP,
getInventoryGoldLimit,
findFreeGridSlot,
type UiInventoryItem,
type GridPlacement,
CUBE_GRID_ORIGIN,
WorldPanelsHud,
getActQuests,
getActWaypoints,
computeNpcMenuAnchorY,
CharacterSheetPanel,
type CharacterAttributes,
D2FontRenderer,
Minimap,
onlineItemDataToUiInventoryItem,
BeltModel,
getBeltRowsForCode,
getBeltSlotBox,
itemToBeltPotion,
BELT_COLS,
BELT_ROWS,
// Frontend flow, Renderer, & i18n
AVATAR_COMPONENT_NAMES,
CAMPFIRE_FLAME_CONTROL_SPEC,
CAMPFIRE_CLASS_SPECS,
CAMPFIRE_OVERLAY_SPECS,
CANONICAL_D2LAUNCH_TBL_BY_ID,
D2_COLORS_TXT_PALETTE,
FrontendRenderer,
charSummaryToRosterCharacter,
computeMonsterMoveSpeedSubTilesPerSec,
createInitialFlowState,
enterCharCreate,
filterAccountName,
filterCharNameInput,
formatCharacterLevelAndClass,
isValidAccountChar,
isValidCharacterName,
canAcceptCharNameChar,
mapCreateAccountServerStatus,
parseMcpAvatarStatstring,
resolveClassNameByTbl,
resolveStringByTblId,
selectCampfireClass,
toggleChatGem,
validateNewAccountInputs,
lookupTbl,
requireTbl,
getLang,
setLang,
getGameLang,
setGameLang,
getTblLang,
resolveLangFromPathname,
detectLangFromUrl,
}
// ─────────────────────────────────────────────────────────────────────────────
// Coordinate Conversions (Sub-Tile <-> Ortho Isometric Screen)
// ─────────────────────────────────────────────────────────────────────────────
/**
* Projects continuous sub-tile coordinates into 2:1 isometric screen coordinates
* by delegating directly to production `subTileToWorldOrtho` (`src/client/view/viewport-profile.ts`).
*/
export function subTileToOrthoScreen(subX: number, subY: number): { x: number; y: number } {
return subTileToWorldOrtho(subX, subY, 0, 0)
}
/**
* Inverse projects 2:1 isometric screen coordinates back to continuous sub-tile coordinates
* by delegating directly to production `worldOrthoToSubTile` (`src/client/view/viewport-profile.ts`).
*/
export function orthoScreenToSubTile(screenX: number, screenY: number): { x: number; y: number } {
return worldOrthoToSubTile(screenX, screenY, 0, 0)
}
/**
* Converts tile cell coordinates (each 5x5 sub-tiles) to top-left sub-tile coordinates.
*/
export function tileToSubTile(tileX: number, tileY: number, subOffset = { x: 0, y: 0 }): { x: number; y: number } {
return {
x: tileX * 5 + subOffset.x,
y: tileY * 5 + subOffset.y,
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Production-Backed Collision Grid Oracle
// ─────────────────────────────────────────────────────────────────────────────
export interface CollisionGridOracle {
readonly width: number
readonly height: number
readonly world: ClientWorld
getCollision(subX: number, subY: number): number
setCollision(subX: number, subY: number, mask: number): void
isWalkable(subX: number, subY: number): boolean
hasLineOfSight(x0: number, y0: number, x1: number, y1: number): boolean
}
/**
* Creates a collision grid backed by a real `ClientWorld` instance (`setSubTileBlocked` / `isSubTileBlocked`)
* alongside 1.13c multi-layer collision bitmasks (`COLLIDE_*` from `src/common/world/d2map.ts`).
*/
export function createCollisionGrid(width: number, height: number, defaultMask = COLLIDE_NONE): CollisionGridOracle {
const data = new Uint16Array(width * height).fill(defaultMask)
const world = new ClientWorld()
const groundBlockingMask = COLLIDE_WALL | COLLIDE_BLANK | COLLIDE_OBJECT | COLLIDE_DOOR
if ((defaultMask & groundBlockingMask) !== 0) {
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
world.setSubTileBlocked(x, y, true)
}
}
}
const oracle: CollisionGridOracle = {
width,
height,
world,
getCollision(subX: number, subY: number): number {
if (subX < 0 || subX >= width || subY < 0 || subY >= height) {
return COLLIDE_BLANK | COLLIDE_WALL // Void out-of-bounds protection
}
return data[subY * width + subX]!
},
setCollision(subX: number, subY: number, mask: number): void {
if (subX >= 0 && subX < width && subY >= 0 && subY < height) {
data[subY * width + subX] = mask
world.setSubTileBlocked(subX, subY, (mask & groundBlockingMask) !== 0)
}
},
isWalkable(subX: number, subY: number): boolean {
if (subX < 0 || subX >= width || subY < 0 || subY >= height) {
return false
}
const flags = oracle.getCollision(subX, subY)
const blockedByMask = (flags & groundBlockingMask) !== 0
const blockedInWorld = world.isSubTileBlocked(subX, subY)
return !blockedByMask && !blockedInWorld
},
hasLineOfSight(x0: number, y0: number, x1: number, y1: number): boolean {
// Bresenham raycast checking COLLIDE_VISIBLE and COLLIDE_MISSILE_BARRIER
let dx = Math.abs(x1 - x0)
let dy = Math.abs(y1 - y0)
let sx = x0 < x1 ? 1 : -1
let sy = y0 < y1 ? 1 : -1
let err = dx - dy
let currX = x0
let currY = y0
while (true) {
const mask = oracle.getCollision(currX, currY)
if ((mask & (COLLIDE_VISIBLE | COLLIDE_MISSILE_BARRIER | COLLIDE_WALL)) !== 0) {
// Blocked by barrier
if (currX !== x0 || currY !== y0) {
return false
}
}
if (currX === x1 && currY === y1) break
const e2 = 2 * err
if (e2 > -dy) {
err -= dy
currX += sx
}
if (e2 < dx) {
err += dx
currY += sy
}
}
return true
},
}
return oracle
}
// ─────────────────────────────────────────────────────────────────────────────
// Item Data Builder
// ─────────────────────────────────────────────────────────────────────────────
export function makeParityItem(partial: Partial<ItemData>): ItemData {
const id = partial.id ?? partial.itemId ?? 1000
const code = partial.code ?? 'cap'
const container = partial.container ?? 'inventory'
return {
id,
itemId: id,
opcode: 0x9c,
action: 0,
category: 0,
flags: 0,
rawFlags: 0,
version: 101,
location: 0,
container,
bodyLoc: 0,
equippedSlot: 0,
gridX: 0,
gridY: 0,
storePage: 0,
storagePage: 0,
worldX: 0,
worldY: 0,
x: 0,
y: 0,
code,
rawCode: code.padEnd(4, ' '),
compact: false,
simple: false,
identified: true,
socketed: false,
ethereal: false,
runeword: false,
personalized: false,
isEar: false,
newItem: false,
starterItem: false,
broken: false,
inSocket: false,
equipped: false,
socketedChildCount: 0,
invWidth: 2,
invHeight: 2,
totalSockets: 0,
usedSockets: 0,
quantity: 1,
stats: [],
...partial,
...(partial.id !== undefined || partial.itemId !== undefined ? { id, itemId: id } : {}),
} as ItemData
}
// ─────────────────────────────────────────────────────────────────────────────
// Parity Client Session Fixture
// ─────────────────────────────────────────────────────────────────────────────
export interface ParitySessionFixture {
readonly world: ClientWorld
readonly hudModel: HudModel
readonly sentCommands: ClientCommand[]
readonly selfUnit: import('../../src/client/world/client-unit.ts').ClientUnit | undefined
tick(ticks?: number): void
applyServerEvents(...events: ServerEvent[]): void
clearCommands(): void
}
export function createParitySessionFixture(options: { selfId?: number; charClass?: number } = {}): ParitySessionFixture {
const selfId = options.selfId ?? 1
const world = new ClientWorld()
const sentCommands: ClientCommand[] = []
// Initialize self player in ClientWorld via PlayerAssign
world.apply({
type: 'PlayerAssign',
unitId: selfId,
classId: options.charClass ?? 1, // 1 = Sorceress
x: 100,
y: 100,
mode: 1, // Neutral
})
const hudModel = new HudModel({
world,
onCommand: (cmd) => sentCommands.push(cmd),
})
hudModel.syncFromWorld()
return {
world,
hudModel,
sentCommands,
get selfUnit() {
return world.getUnit('player', world.selfId)
},
tick(ticks = 1) {
for (let i = 0; i < ticks; i++) {
world.tick(D2_TICK_MS)
}
hudModel.syncFromWorld()
},
applyServerEvents(...events: ServerEvent[]) {
for (const ev of events) {
world.apply(ev)
}
hudModel.syncFromWorld()
},
clearCommands() {
sentCommands.length = 0
},
}
}
// ─────────────────────────────────────────────────────────────────────────────
// HUD & Frontend Canvas Recording Helpers
// ─────────────────────────────────────────────────────────────────────────────
export function createMockHudCanvasContext() {
const drawTextCalls: { text: string; x: number; y: number; color?: string; font?: string }[] = []
const font = new D2FontRenderer()
vi.spyOn(font, 'drawText').mockImplementation((_ctx, text, x, y, opts) => {
drawTextCalls.push({
text: String(text),
x,
y,
...(opts?.color !== undefined ? { color: opts.color } : {}),
...(opts?.font !== undefined ? { font: opts.font } : {}),
})
return String(text).length * 8
})
const ctx = {
save: vi.fn(),
restore: vi.fn(),
translate: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
beginPath: vi.fn(),
moveTo: vi.fn(),
lineTo: vi.fn(),
arc: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
fillText: vi.fn(),
measureText: vi.fn((t: string) => ({ width: t.length * 7 })),
fillStyle: '#000',
strokeStyle: '#000',
lineWidth: 1,
globalAlpha: 1,
} as unknown as CanvasRenderingContext2D
return { ctx, font, drawTextCalls }
}
export interface FrontendDrawImageCall {
readonly img: unknown
readonly args: readonly number[]
readonly compositeOp: string
readonly alpha: number
readonly filter: string
}
export function createRecordingFrontendRenderer() {
const drawCalls: FrontendDrawImageCall[] = []
const stateStack: Array<{ compositeOp: string; alpha: number; filter: string; fillStyle: string }> = []
const ctx = {
imageSmoothingEnabled: false,
globalCompositeOperation: 'source-over',
globalAlpha: 1,
filter: 'none',
fillStyle: '#000000',
strokeStyle: '#000000',
lineWidth: 1,
save() {
stateStack.push({
compositeOp: this.globalCompositeOperation,
alpha: this.globalAlpha,
filter: this.filter,
fillStyle: this.fillStyle,
})
},
restore() {
const prev = stateStack.pop()
if (prev) {
this.globalCompositeOperation = prev.compositeOp
this.globalAlpha = prev.alpha
this.filter = prev.filter
this.fillStyle = prev.fillStyle
}
},
clearRect() {},
fillRect() {},
strokeRect() {},
beginPath() {},
moveTo() {},
lineTo() {},
stroke() {},
rect() {},
clip() {},
drawImage(img: unknown, ...args: number[]) {
drawCalls.push({
img,
args,
compositeOp: this.globalCompositeOperation,
alpha: this.globalAlpha,
filter: this.filter,
})
},
}
const fakeCanvas = {
width: 800,
height: 600,
style: { cursor: 'none' },
getContext: () => ctx,
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
} as unknown as HTMLCanvasElement
const state = createInitialFlowState()
const renderer = new FrontendRenderer(fakeCanvas, state)
const stubImg = (id: string) => ({ __id: id, width: 800, height: 600 }) as unknown as HTMLImageElement
for (const key of [
'bg-main-menu.png',
'bg-char-create.png',
'bg-char-select.png',
'bg-waiting-room.png',
'popup-340x224.png',
'popup-okcancel.png',
'terms-of-use-bg.png',
'scrollbar.png',
'btn-popup-small.png',
'create-game-bg.png',
'charselectbox.png',
'realmselect.png',
'btn-realm-thin.png',
'btn-wide.png',
'btn-3wide.png',
'btn-wide02.png',
'btn-medium.png',
'btn-short.png',
'btn-tall.png',
'textbox.png',
'clickbox.png',
'joingameclickbox.png',
'radiobutton.png',
'gembutton.png',
'btn-chatleft.png',
'btn-chatrighttop.png',
'btn-chatright.png',
'btn-game.png',
'btn-create-cancel.png',
'btn-create-game.png',
'grey-level-box.png',
'cursor-hand.png',
]) {
renderer.images.set(key, stubImg(key))
}
const glyphs = Array.from({ length: 256 }, (_, code) => ({
code,
width: 8,
height: 14,
x: (code % 16) * 16,
y: Math.floor(code / 16) * 16,
frameW: 8,
frameH: 14,
}))
for (const fontName of [
'font6',
'font8',
'font16',
'font24',
'font30',
'font42',
'fontformal10',
'fontformal11',
'fontformal12',
'fontexocet8',
'fontexocet10',
'fontridiculous',
]) {
renderer.fontMetas[fontName] = {
name: fontName,
lineHeight: 14,
capHeight: 12,
atlasWidth: 256,
atlasHeight: 256,
glyphs,
}
for (const colorIdx of [0, 1, 2, 3, 4, 5, 7, 8, 9]) {
renderer.images.set(`fonts/${fontName}-c${colorIdx}.png`, stubImg(`fonts/${fontName}-c${colorIdx}.png`))
}
}
const canonicalStringsById = Object.fromEntries(
Object.entries(CANONICAL_D2LAUNCH_TBL_BY_ID).map(([id, val]) => [String(id), val]),
)
const minimalManifest: FrontendManifest = {
version: '1.13c',
decodedDc6Count: 0,
fontColors: [0, 1, 2, 3, 4, 5, 7, 8, 9],
animStrips: {},
campfireClasses: {} as FrontendManifest['campfireClasses'],
charAvatars: {},
strings: {
...canonicalStringsById,
'5127': 'Select Hero Class',
'5272': 'Select Your Character',
singlePlayer: 'SINGLE PLAYER',
battleNet: 'BATTLE.NET',
otherMultiplayer: 'OTHER MULTIPLAYER',
exitDiablo2: 'EXIT DIABLO II',
credits: 'CREDITS',
cinematics: 'CINEMATICS',
loginTitle: 'Battle.net Log In',
loginInstruction:
"To use an existing Battle.net account, enter the information below and click 'Log In'. Otherwise, click 'Create New Account'.",
accountName: 'Account Name',
accountPassword: 'Password',
password: 'Password',
logIn: 'LOG IN',
changePassword: 'CHANGE PASSWORD',
createNewAccount: 'CREATE NEW ACCOUNT',
accountSettings: 'ACCOUNT SETTINGS',
selectCharacterTitle: 'Select Your Character',
createNewCharacter: 'CREATE NEW CHARACTER',
convertToExpansion: 'CONVERT TO EXPANSION',
deleteCharacter: 'DELETE CHARACTER',
'5273': 'CREATE NEW CHARACTER',
'5274': 'DELETE CHARACTER',
'22729': 'CONVERT TO EXPANSION',
cancel: 'CANCEL',
ok: 'OK',
tblById: {
...canonicalStringsById,
'5127': 'Select Hero Class',
'5272': 'Select Your Character',
},
} as unknown as FrontendManifest['strings'],
}
renderer.manifest = minimalManifest
renderer.ready = true
return { renderer, state, ctx, drawCalls }
}