diablo2-web/tests/e2e-module-split/tier3-cross-feature.test.ts

468 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tier 3 — Pairwise Cross-Feature Interaction E2E Test Suite (`tests/e2e-module-split/tier3-cross-feature.test.ts`).
*
* Covers 16 pairwise & multi-feature integration tests across F1–F10:
* - F2 + F5: `common/data` (`D2DataRegistry`) + `server/engine` (`buildGameEngine` / `createPlayerState`)
* - F3 + F5: `common/items` (`gems` + `runeword`) + `server/engine` item modifier application
* - F3 + F6: `common/items/item-bridge` + `client/ui/inventory` UI descriptor contract
* - F3 + F6: `common/units/monsters-data` + `common/units/monster-mapping` + `client/scene/entity-art-loader`
* - F1 + F3 + F8: `common/pack-contract` + `common/world/acts` + `baker` universal DT1 contract
* - F4 + F5 + F6: `netproto` + `server/loopback-adapter` + `client/session/online-session` + `client/world/client-world`
* - F4 + F6: `netproto` S2C packet stream + `OnlineSession` + `ClientWorld`
* - F2 + F3 + F6: `common/i18n` + `common/units/monsters-data` + `client/i18n` bilingual switching
* - F3 + F5: `common/items/inventory-model` + `belt-model` + `server/engine`
* - F3 + F5: `common/world/d2map` collision & spawn grounding + `server/world/server-world`
* - F2 + F8: `common/data/data-registry` `ArchiveSource` contract + `baker/mpq-archive-source`
* - F6 + F7: `play.html` + `vite.config.ts` + `client/main.ts`
* - F7 + F9: `tsconfig.client.json` + `src/client/main.ts` full transitive import graph
* - F5 + F9: `tsconfig.server.json` + `src/server/index.ts` full transitive import graph
* - F8 + F9: `tsconfig.baker.json` + `src/baker/index.ts` & `tools/pack-all.ts` transitive import graph
* - F4 + F6 + F10: `tools/netproto-replay.ts` + `src/netproto` + `src/client/world/client-world.ts`
*/
import { existsSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
createSyntheticArchiveSource,
extractImportSpecifiers,
importRepoModule,
readJsonFile,
readRepoFile,
resolveRepoPath,
ROOT_DIR,
UNIVERSAL_DT1_PATHS,
} from './helpers'
function collectTransitiveTsImports(entryRelPath: string): Set<string> {
const visited = new Set<string>()
const queue: string[] = [resolveRepoPath(entryRelPath)]
while (queue.length > 0) {
const currentAbs = queue.shift()!
const currentRel = relative(ROOT_DIR, currentAbs).replace(/\\/g, '/')
if (visited.has(currentRel)) continue
if (!existsSync(currentAbs)) continue
visited.add(currentRel)
const source = readRepoFile(currentRel)
const specs = extractImportSpecifiers(source)
for (const spec of specs) {
if (!spec.startsWith('.')) continue
const baseResolved = resolve(dirname(currentAbs), spec)
const candidates = [
baseResolved,
`${baseResolved}.ts`,
resolve(baseResolved, 'index.ts'),
]
for (const cand of candidates) {
if (existsSync(cand) && cand.endsWith('.ts')) {
queue.push(cand)
break
}
}
}
}
return visited
}
describe('Tier 3: Pairwise Cross-Feature Interactions (F1–F10)', () => {
it('CF1 (F2 + F5): synthetic ArchiveSource in common/data/data-registry powers server/engine without baker', async () => {
const regMod = await importRepoModule('src/common/data/data-registry.ts')
const engineMod = await importRepoModule(
'src/server/engine.ts',
'src/server/sim/engine.ts',
)
regMod.resetSharedDataRegistryCache()
const syntheticSource = createSyntheticArchiveSource()
const registry = regMod.getSharedDataRegistry(syntheticSource)
expect(registry.getSkill(36)?.skill).toBe('Fire Bolt')
const sorPlayer = engineMod.createPlayerState('sor')
expect(sorPlayer.charClass).toBe('sor')
expect(sorPlayer.maxHp).toBeGreaterThan(0)
expect(sorPlayer.maxMana).toBeGreaterThan(0)
regMod.resetSharedDataRegistryCache()
})
it('CF2 (F3 + F5): socketing Jah+Ith+Ber via common/items/gems triggers Enigma in common/items/runeword and server stat evaluation', async () => {
const gemsMod = await importRepoModule('src/common/items/gems.ts')
const rwMod = await importRepoModule('src/common/items/runeword.ts')
let armorHost: any = {
id: 500,
code: 'xtp',
name: 'Mage Plate',
type: 'tors',
slot: 'body',
quality: 'normal',
level: 55,
identified: true,
gridX: 0,
gridY: 0,
invW: 2,
invH: 3,
defense: 250,
numSockets: 3,
sockets: [],
stats: [],
}
for (const [idx, runeCode] of (['r31', 'r06', 'r30'] as const).entries()) {
const runeEntry = rwMod.CANONICAL_RUNES_TABLE.get(runeCode)
expect(runeEntry).toBeDefined()
const runeItem = {
id: 501 + idx,
code: runeCode,
name: runeEntry?.runeName ?? runeCode,
type: 'rune',
quality: 'normal',
level: 1,
identified: true,
gridX: 0,
gridY: 0,
invW: 1,
invH: 1,
isSocketFiller: true,
stats: [],
}
const res = gemsMod.socketItemIntoHost(armorHost, runeItem)
expect(res).not.toBeNull()
armorHost = res.host ?? res
}
expect(armorHost.sockets).toHaveLength(3)
const match = rwMod.checkRunewordMatch(['jah', 'ith', 'ber'], 'armor', 3)
expect(match).not.toBeNull()
expect(match.name).toBe('Enigma')
})
it('CF3 (F3 + F6): common/items/item-bridge converts domain items into UI descriptors consumed by client/ui/inventory', async () => {
const bridgeMod = await importRepoModule('src/common/items/item-bridge.ts')
const invPanelMod = await importRepoModule('src/client/ui/inventory.ts')
expect(invPanelMod).toBeDefined()
const domainSword = {
id: 1001,
code: 'crs',
name: 'Crystal Sword',
type: 'swor',
slot: 'weapon',
quality: 'magic',
level: 12,
identified: true,
gridX: 0,
gridY: 0,
invW: 2,
invH: 3,
minDamage: 5,
maxDamage: 15,
stats: [{ stat: 'item_maxdamage_percent', param: 0, value: 25 }],
}
const uiItem = bridgeMod.itemToUiInventoryItem(domainSword as any)
expect(uiItem.id).toBe('1001')
expect(uiItem.code).toBe('crs')
expect(uiItem.gridW).toBe(2)
expect(uiItem.gridH).toBe(3)
expect(uiItem.slotType).toBe('weapon')
expect(uiItem.spriteCode.length).toBeGreaterThan(0)
})
it('CF4 (F3 + F6): SuperUnique bosses in common/units/monsters-data map to valid art specs and minion art in common/units/monster-mapping', async () => {
const monDataMod = await importRepoModule('src/common/units/monsters-data.ts')
const monMapMod = await importRepoModule('src/common/units/monster-mapping.ts')
const entityArtMod = await importRepoModule('src/client/scene/entity-art-loader.ts')
expect(entityArtMod).toBeDefined()
const corpsefire = monDataMod.CANONICAL_SUPERUNIQUES_TABLE.find(
(s: any) => s.superunique === 'Corpsefire',
)
const rakanishu = monDataMod.CANONICAL_SUPERUNIQUES_TABLE.find(
(s: any) => s.superunique === 'Rakanishu',
)
expect(corpsefire).toBeDefined()
expect(rakanishu).toBeDefined()
// Verify boss class and minion classes resolve in MONSTER_ART_MAP
expect(monMapMod.MONSTER_ART_MAP[corpsefire.class]).toBeDefined()
expect(monMapMod.MONSTER_ART_MAP[rakanishu.class]).toBeDefined()
expect(monMapMod.MONSTER_ART_MAP['fallen1']).toBeDefined()
expect(monMapMod.MONSTER_ART_MAP['fallenshaman1']).toBeDefined()
})
it('CF5 (F1 + F3 + F8): common/world/acts resolveLevelLibraries prepends UNIVERSAL_DT1S and satisfies baker validateUniversalDt1Libraries', async () => {
const actsMod = await importRepoModule('src/common/world/acts.ts')
const bakerMod = await importRepoModule('src/baker/index.ts')
const sampleLevelIds = [1, 2, 40, 75, 103, 109]
for (const levelId of sampleLevelIds) {
const libs = actsMod.resolveLevelLibraries(levelId)
expect(libs.slice(0, 3)).toEqual([...UNIVERSAL_DT1_PATHS])
const syntheticLoadedLibs = libs.map((path: string) => ({
path,
tiles:
path.toLowerCase().endsWith('inviswal.dt1')
? [{ type: 0, style: 49, sequence: 7 }]
: [{ type: 0, style: 0, sequence: 0 }],
}))
expect(() =>
bakerMod.validateUniversalDt1Libraries(syntheticLoadedLibs),
).not.toThrow()
}
})
it('CF6 (F4 + F5 + F6): ServerWorld + LocalLoopbackAdapter + OnlineSession + ClientWorld end-to-end command & event loop', async () => {
const serverWorldMod = await importRepoModule('src/server/world/server-world.ts')
const loopbackMod = await importRepoModule(
'src/server/loopback-adapter.ts',
'src/server/session/loopback-adapter.ts',
)
const sessionMod = await importRepoModule('src/client/session/online-session.ts')
const serverWorld = new serverWorldMod.ServerWorld()
const adapter = new loopbackMod.LocalLoopbackAdapter(serverWorld)
const session = new sessionMod.OnlineSession()
session.attachGameServer(adapter)
session.dispatchCommand({
type: 'PlayerMove',
targetX: 128,
targetY: 144,
})
serverWorld.step(40)
expect(session.world).toBeDefined()
expect(session.world.player).toBeDefined()
})
it('CF7 (F4 + F6): netproto S2C packet sequence decodes and drives OnlineSession + ClientWorld state transitions', async () => {
const netproto = await importRepoModule('src/netproto/index.ts')
const sessionMod = await importRepoModule('src/client/session/online-session.ts')
const session = new sessionMod.OnlineSession()
const eventsToEmit = [
{
type: 'EntitySpawn' as const,
unitType: 0,
unitId: 1,
code: 'sor',
name: 'HeroSorceress',
x: 200,
y: 250,
hp: 100,
maxHp: 100,
},
{
type: 'UnitMove' as const,
unitType: 0,
unitId: 1,
x: 200,
y: 250,
targetX: 220,
targetY: 265,
},
{
type: 'ChatMessage' as const,
speaker: 'Deckard Cain',
text: 'Stay awhile and listen!',
},
]
for (const ev of eventsToEmit) {
const packets = netproto.serverEventToS2cPackets(ev as any)
for (const pkt of packets) {
const raw = netproto.encodePacket(pkt)
const decoded = netproto.decodePacket(raw, 'S2C')
expect(decoded.ok).toBe(true)
}
session.handleServerEvent(ev as any)
}
expect(session.world).toBeDefined()
})
it('CF8 (F2 + F3 + F6): common/i18n language toggle synchronizes with common/units/monsters-data and client/i18n', async () => {
const langMod = await importRepoModule('src/common/i18n/lang.ts')
const monDataMod = await importRepoModule('src/common/units/monsters-data.ts')
const clientI18n = await importRepoModule(
'src/client/i18n/lang.ts',
)
expect(clientI18n).toBeDefined()
langMod.setLang('en')
expect(langMod.getLang()).toBe('en')
const enFallen = monDataMod.monsterDisplayName('fallen1', 'Fallen')
expect(typeof enFallen).toBe('string')
expect(enFallen.length).toBeGreaterThan(0)
langMod.setLang('zh')
expect(langMod.getLang()).toBe('zh')
const zhFallen = monDataMod.monsterDisplayName('fallen1', 'Fallen')
expect(typeof zhFallen).toBe('string')
expect(zhFallen.length).toBeGreaterThan(0)
langMod.setLang('en')
})
it('CF9 (F3 + F5): common/items/inventory-model and belt-model enforce 10x4 grid and 4x4 potion compatibility with server items', async () => {
const invMod = await importRepoModule('src/common/items/inventory-model.ts')
const beltMod = await importRepoModule('src/common/items/belt-model.ts')
const grid: (number | null)[][] = Array.from({ length: invMod.INV_ROWS }, () =>
Array.from({ length: invMod.INV_COLS }, () => null),
)
const slot = invMod.findFreeGridSlot(grid, 2, 3)
expect(slot).toEqual({ x: 0, y: 0 })
const hpPotion = beltMod.itemToBeltPotion({
id: 10,
code: 'hp1',
name: 'Minor Healing Potion',
type: 'hpot',
quality: 'normal',
level: 1,
identified: true,
gridX: 0,
gridY: 0,
invW: 1,
invH: 1,
stats: [],
})
const mpPotion = beltMod.itemToBeltPotion({
id: 11,
code: 'mp1',
name: 'Minor Mana Potion',
type: 'mpot',
quality: 'normal',
level: 1,
identified: true,
gridX: 1,
gridY: 0,
invW: 1,
invH: 1,
stats: [],
})
expect(hpPotion).not.toBeNull()
expect(mpPotion).not.toBeNull()
expect(beltMod.areBeltPotionsCompatible(hpPotion, hpPotion)).toBe(true)
expect(beltMod.areBeltPotionsCompatible(hpPotion, mpPotion)).toBe(false)
})
it('CF10 (F3 + F5): common/world/d2map collision mask and findIsoSpawn ground ServerWorld player on walkable floor cell', async () => {
const d2mapMod = await importRepoModule('src/common/world/d2map.ts')
const serverWorldMod = await importRepoModule('src/server/world/server-world.ts')
const width = 16
const height = 16
const mask = new Uint16Array(width * height)
mask.fill(d2mapMod.COLLIDE_MASK_INVALID)
// Open a connected 4x4 walkable island at (2..5, 2..5)
for (let y = 2; y <= 5; y++) {
for (let x = 2; x <= 5; x++) {
mask[y * width + x] = 0
}
}
const mapLike = {
width,
height,
collisionMask: mask,
rooms: [{ x: 2, y: 2, width: 4, height: 4 }],
Floor: Array.from({ length: height }, (_, y) =>
Array.from({ length: width }, (_, x) =>
x >= 2 && x <= 5 && y >= 2 && y <= 5 ? [1] : [],
),
),
Wall: Array.from({ length: height }, () => Array.from({ length: width }, () => [])),
}
const spawn = d2mapMod.findIsoSpawn(mapLike)
expect(d2mapMod.isBlockedAt(mapLike, Math.floor(spawn.x), Math.floor(spawn.y))).toBe(false)
const world = new serverWorldMod.ServerWorld()
expect(world).toBeDefined()
})
it('CF11 (F2 + F8): baker/mpq-archive-source satisfies common/data/data-registry ArchiveSource contract without circular dependency', async () => {
const regSource = readRepoFile('src/common/data/data-registry.ts')
const regSpecs = extractImportSpecifiers(regSource)
for (const spec of regSpecs) {
expect(spec).not.toContain('baker')
expect(spec).not.toContain('node:')
}
const bakerSource = readRepoFile('src/baker/mpq-archive-source.ts')
expect(bakerSource).toContain('common')
})
it('CF12 (F6 + F7): play.html script entry, vite.config.ts rollupOptions.input, and src/client/main.ts are aligned', () => {
const playHtml = readRepoFile('play.html')
const viteCfg = readRepoFile('vite.config.ts')
expect(playHtml).toContain('/src/client/main.ts')
expect(viteCfg).toContain('play.html')
})
it('CF13 (F7 + F9): transitive import closure of src/client/main.ts stays strictly within common, netproto, and client', () => {
const reachable = collectTransitiveTsImports('src/client/main.ts')
expect(reachable.size).toBeGreaterThan(5)
const violations: string[] = []
for (const rel of reachable) {
if (
!rel.startsWith('src/common/') &&
!rel.startsWith('src/netproto/') &&
!rel.startsWith('src/client/')
) {
violations.push(rel)
}
}
expect(violations).toEqual([])
})
it('CF14 (F5 + F9): transitive import closure of src/server/index.ts stays strictly within common, netproto, and server', () => {
const reachable = collectTransitiveTsImports('src/server/index.ts')
expect(reachable.size).toBeGreaterThan(2)
const violations: string[] = []
for (const rel of reachable) {
if (
!rel.startsWith('src/common/') &&
!rel.startsWith('src/netproto/') &&
!rel.startsWith('src/server/')
) {
violations.push(rel)
}
}
expect(violations).toEqual([])
})
it('CF15 (F8 + F9): transitive import closure of src/baker/index.ts and tools/pack-all.ts never enters src/client or src/server', () => {
const bakerReachable = collectTransitiveTsImports('src/baker/index.ts')
const packAllReachable = collectTransitiveTsImports('tools/pack-all.ts')
const combined = new Set([...bakerReachable, ...packAllReachable])
expect(combined.size).toBeGreaterThan(2)
const violations: string[] = []
for (const rel of combined) {
if (rel.startsWith('src/client/') || rel.startsWith('src/server/')) {
violations.push(rel)
}
}
expect(violations).toEqual([])
})
it('CF16 (F4 + F6 + F10): tools/netproto-replay.ts replays 02-d2gs-town-walk-chat.d2cap and 03-d2gs-items-npc-skills.d2cap into ClientWorld', async () => {
const replayMod = await importRepoModule('tools/netproto-replay.ts')
for (const fixture of [
'tests/fixtures/netproto/02-d2gs-town-walk-chat.d2cap',
'tests/fixtures/netproto/03-d2gs-items-npc-skills.d2cap',
]) {
const text = readRepoFile(fixture)
const res = replayMod.replayD2CapText(text, { strict: true })
expect(res.unknownPackets).toBe(0)
expect(res.opaquePackets).toBe(0)
expect(res.protocolErrors).toBe(0)
expect(res.gameS2cPackets).toBeGreaterThan(0)
expect(res.world).toBeDefined()
}
})
})