diablo2-web/tests/e2e-module-split/tier4-real-world-scenarios....

322 lines
12 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 4 — Real-World End-to-End Application Scenarios (`tests/e2e-module-split/tier4-real-world-scenarios.test.ts`).
*
* Covers 6 comprehensive end-to-end scenarios verifying R1, R2, R3, R4 and F1–F10:
* - Scenario 1: Single-Player Loopback Gameplay Lifecycle (`common` + `server` + `netproto` + `client`)
* - Scenario 2: Full `.d2cap` Wire Protocol Replay Pipeline (`01`..`04` captures -> `netproto` -> `ClientWorld`)
* - Scenario 3: Multi-TSConfig Type-Check & Full Repository Module Boundary Audit (`R1`, `R3`, `R4`)
* - Scenario 4: Unified `play.html` Vite Production Bundle Verification (`R2`, `F6`, `F7`)
* - Scenario 5: Full-Spectrum 136-Level Act 1–5 Universal DT1 & Pack Contract Verification (`F1`, `F3`, `F8`)
* - Scenario 6: End-to-End 7-Class, Weapon Handedness, Runeword & SuperUnique Domain Pipeline (`F2`, `F3`, `F5`)
*/
import { execFile } from 'node:child_process'
import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, relative, resolve } from 'node:path'
import { promisify } from 'node:util'
import { describe, expect, it } from 'vitest'
const execFileAsync = promisify(execFile)
import {
CANONICAL_SRC_MODULES,
collectTsFiles,
extractImportSpecifiers,
importRepoModule,
LEGACY_HTML_FILES,
LEGACY_SRC_DIRS,
readJsonFile,
readRepoFile,
resolveImportTargetRel,
ROOT_DIR,
SRC_DIR,
UNIVERSAL_DT1_PATHS,
} from './helpers'
describe('Tier 4: Real-World End-to-End Application Scenarios', () => {
it('Scenario 1: Single-Player Loopback Gameplay Lifecycle (common + server + netproto + client)', 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 netproto = await importRepoModule('src/netproto/index.ts')
const invMod = await importRepoModule('src/common/items/inventory-model.ts')
const beltMod = await importRepoModule('src/common/items/belt-model.ts')
const bridgeMod = await importRepoModule('src/common/items/item-bridge.ts')
// 1. Boot authoritative ServerWorld, LocalLoopbackAdapter, and client OnlineSession
const serverWorld = new serverWorldMod.ServerWorld()
const adapter = new loopbackMod.LocalLoopbackAdapter(serverWorld)
const session = new sessionMod.OnlineSession()
session.attachGameServer(adapter)
// 2. Dispatch movement commands and step authoritative simulation
const moveCmd = { type: 'PlayerMove' as const, targetX: 160, targetY: 180 }
const c2sPackets = netproto.clientCommandToC2sPackets(moveCmd)
expect(c2sPackets.length).toBeGreaterThan(0)
const encodedMove = netproto.encodePacket(c2sPackets[0])
const decodedMove = netproto.decodePacket(encodedMove, 'C2S')
expect(decodedMove.ok).toBe(true)
session.dispatchCommand(moveCmd)
for (let tick = 0; tick < 5; tick++) {
serverWorld.step(40)
}
// 3. Spawn monster and item drop via ServerEvent -> S2C wire packet -> OnlineSession
const spawnMonsterEv = {
type: 'EntitySpawn' as const,
unitType: 1,
unitId: 301,
code: 'fallen1',
name: 'Fallen',
x: 170,
y: 185,
hp: 60,
maxHp: 60,
}
for (const pkt of netproto.serverEventToS2cPackets(spawnMonsterEv as any)) {
const raw = netproto.encodePacket(pkt)
expect(netproto.decodePacket(raw, 'S2C').ok).toBe(true)
}
session.handleServerEvent(spawnMonsterEv as any)
// 4. Verify inventory & belt item flow on dropped loot
const grid: (number | null)[][] = Array.from({ length: invMod.INV_ROWS }, () =>
Array.from({ length: invMod.INV_COLS }, () => null),
)
const droppedSword = {
id: 9001,
code: 'crs',
name: 'Crystal Sword',
type: 'swor',
slot: 'weapon',
quality: 'superior',
level: 15,
identified: true,
gridX: 0,
gridY: 0,
invW: 2,
invH: 3,
minDamage: 5,
maxDamage: 15,
stats: [],
}
const freeSlot = invMod.findFreeGridSlot(grid, droppedSword.invW, droppedSword.invH)
expect(freeSlot).toEqual({ x: 0, y: 0 })
const uiSword = bridgeMod.itemToUiInventoryItem(droppedSword as any)
expect(uiSword.slotType).toBe('weapon')
expect(uiSword.gridW).toBe(2)
expect(uiSword.gridH).toBe(3)
const superHealing = beltMod.itemToBeltPotion({
id: 9002,
code: 'hp4',
name: 'Greater Healing Potion',
type: 'hpot',
quality: 'normal',
level: 1,
identified: true,
gridX: 0,
gridY: 0,
invW: 1,
invH: 1,
stats: [],
})
expect(superHealing).not.toBeNull()
expect(superHealing?.category).toBe('health')
session.leaveGame()
}, 30_000)
it('Scenario 2: Full .d2cap Wire Protocol Replay Pipeline across all 4 captures (01..04)', async () => {
const replayMod = await importRepoModule('tools/netproto-replay.ts')
const captures = [
{
path: 'tests/fixtures/netproto/01-bncs-mcp-lobby.d2cap',
minRecords: 10,
},
{
path: 'tests/fixtures/netproto/02-d2gs-town-walk-chat.d2cap',
minRecords: 10,
},
{
path: 'tests/fixtures/netproto/03-d2gs-items-npc-skills.d2cap',
minRecords: 10,
},
{
path: 'tests/fixtures/netproto/04-d2gs-idle-60s.d2cap',
minRecords: 10,
},
]
for (const cap of captures) {
const text = readRepoFile(cap.path)
const summary = replayMod.replayD2CapText(text, { strict: true })
expect(summary.totalRecords, `${cap.path} totalRecords`).toBeGreaterThanOrEqual(
cap.minRecords,
)
expect(summary.unknownPackets, `${cap.path} unknownPackets`).toBe(0)
expect(summary.opaquePackets, `${cap.path} opaquePackets`).toBe(0)
expect(summary.protocolErrors, `${cap.path} protocolErrors`).toBe(0)
expect(summary.world).toBeDefined()
}
})
it('Scenario 3: Multi-TSConfig Compile-Time Typecheck & Full-Repository Module Dependency DAG Audit', async () => {
// 1. Verify top-level src/ directories match the 5 canonical modules
const srcEntries = readdirSync(SRC_DIR).sort()
expect(srcEntries).toEqual([...CANONICAL_SRC_MODULES].sort())
// 2. Verify dependency DAG across all 5 modules
const allowedModuleTargets: Record<string, Set<string>> = {
common: new Set(['common']),
netproto: new Set(['netproto']),
server: new Set(['common', 'netproto', 'server']),
client: new Set(['common', 'netproto', 'client']),
baker: new Set(['common', 'baker']),
}
const dagViolations: string[] = []
for (const modName of CANONICAL_SRC_MODULES) {
const files = collectTsFiles(`src/${modName}`)
expect(files.length, `src/${modName} must contain .ts files`).toBeGreaterThan(0)
for (const file of files) {
const rel = relative(ROOT_DIR, file)
const specs = extractImportSpecifiers(readRepoFile(rel))
for (const spec of specs) {
const targetRel = resolveImportTargetRel(file, spec)
if (targetRel.startsWith('src/')) {
const targetMod = targetRel.split('/')[1]
if (!allowedModuleTargets[modName].has(targetMod)) {
dagViolations.push(`${rel} (${modName}) -> ${targetRel} (${targetMod})`)
}
}
}
}
}
expect(dagViolations).toEqual([])
// 3. Run tsc --noEmit on all 6 tsconfigs asynchronously so worker event loop stays responsive
const tscBin = resolve(ROOT_DIR, 'node_modules/.bin/tsc')
const configs = [
'tsconfig.common.json',
'tsconfig.netproto.json',
'tsconfig.server.json',
'tsconfig.client.json',
'tsconfig.baker.json',
'tsconfig.json',
]
for (const cfg of configs) {
const { stdout } = await execFileAsync(tscBin, ['--noEmit', '-p', cfg], {
cwd: ROOT_DIR,
encoding: 'utf8',
})
expect(stdout.trim()).toBe('')
}
}, 180_000)
it('Scenario 4: Unified play.html Vite Production Bundle Build Verification (R2)', async () => {
const tmpOutDir = mkdtempSync(join(tmpdir(), 'd2-vite-e2e-build-'))
try {
const viteBin = resolve(ROOT_DIR, 'node_modules/.bin/vite')
await execFileAsync(viteBin, ['build', '--outDir', tmpOutDir, '--emptyOutDir'], {
cwd: ROOT_DIR,
encoding: 'utf8',
})
expect(existsSync(join(tmpOutDir, 'play.html'))).toBe(true)
for (const legacyHtml of LEGACY_HTML_FILES) {
expect(
existsSync(join(tmpOutDir, legacyHtml)),
`Dist must not contain deleted entry ${legacyHtml}`,
).toBe(false)
}
} finally {
rmSync(tmpOutDir, { recursive: true, force: true })
}
}, 120_000)
it('Scenario 5: Full-Spectrum 136-Level Act 1–5 Universal DT1 & Pack Contract Verification', async () => {
const actsMod = await importRepoModule('src/common/world/acts.ts')
const packContractMod = await importRepoModule('src/common/pack-contract/index.ts')
// Verify all 136 level IDs (1..136) resolve libraries with the 3 universal DT1s prepended
for (let levelId = 1; levelId <= 136; levelId++) {
const libs = actsMod.resolveLevelLibraries(levelId)
expect(Array.isArray(libs)).toBe(true)
expect(libs.slice(0, 3), `Level ${levelId} universal DT1 prefix`).toEqual([
...UNIVERSAL_DT1_PATHS,
])
}
// Verify public/act-packs/tiles/index.json satisfies validatePackManifest for all 5 acts
const indexManifest = readJsonFile<{
acts: Record<string, { act: number; missingFiles: number; missingTiles: number }>
}>('public/act-packs/tiles/index.json')
const actEntries = Object.values(indexManifest.acts)
expect(actEntries).toHaveLength(5)
for (const actSummary of actEntries) {
expect(() => packContractMod.validatePackManifest(actSummary)).not.toThrow()
}
})
it('Scenario 6: Complete 7-Class, Weapon Handedness, Runeword & SuperUnique Domain Pipeline', async () => {
const classesMod = await importRepoModule('src/common/units/classes.ts')
const engineMod = await importRepoModule(
'src/server/engine.ts',
'src/server/sim/engine.ts',
)
const bridgeMod = await importRepoModule('src/common/items/item-bridge.ts')
const rwMod = await importRepoModule('src/common/items/runeword.ts')
const monDataMod = await importRepoModule('src/common/units/monsters-data.ts')
const monMapMod = await importRepoModule('src/common/units/monster-mapping.ts')
// 1. All 7 character classes initialize with 1.13c ground-truth attributes
expect(classesMod.CHARACTER_CLASS_CODES).toEqual([
'ama',
'sor',
'nec',
'pal',
'bar',
'dru',
'ass',
])
for (const code of classesMod.CHARACTER_CLASS_CODES) {
const player = engineMod.createPlayerState(code)
expect(player.charClass).toBe(code)
expect(player.level).toBe(1)
expect(player.maxHp).toBeGreaterThan(0)
}
// 2. Weapon handedness sets match 1.13c MPQ table cardinalities
expect(bridgeMod.BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.size).toBe(18)
expect(bridgeMod.STRICT_TWO_HANDED_WEAPON_CODES.size).toBe(119)
expect(bridgeMod.CANONICAL_ONE_HANDED_WEAPON_CODES.size).toBe(169)
expect(bridgeMod.CANONICAL_SHIELD_AND_OFFHAND_CODES.size).toBe(56)
// 3. Runewords: Enigma, Spirit, Insight, Grief
expect(rwMod.checkRunewordMatch(['jah', 'ith', 'ber'], 'armor', 3)?.name).toBe('Enigma')
expect(rwMod.checkRunewordMatch(['tal', 'thul', 'ort', 'amn'], 'shield', 4)?.name).toBe(
'Spirit',
)
expect(rwMod.checkRunewordMatch(['ral', 'tir', 'tal', 'sol'], 'weapon', 4)?.name).toBe(
'Insight',
)
expect(
rwMod.checkRunewordMatch(['eth', 'tir', 'lo', 'mal', 'ral'], 'weapon', 5)?.name,
).toBe('Grief')
// 4. SuperUniques table & monster art mapping
expect(monDataMod.CANONICAL_SUPERUNIQUES_TABLE.length).toBeGreaterThanOrEqual(60)
expect(Object.keys(monMapMod.MONSTER_ART_MAP).length).toBeGreaterThan(50)
// 5. Confirm zero legacy directories remain
for (const legacyDir of LEGACY_SRC_DIRS) {
expect(existsSync(resolve(SRC_DIR, legacyDir))).toBe(false)
}
})
})