322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
/**
|
||
* 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)
|
||
}
|
||
})
|
||
})
|