473 lines
17 KiB
TypeScript
473 lines
17 KiB
TypeScript
/**
|
||
* 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-panel` UI descriptor contract
|
||
* - F3 + F6: `common/units/monsters-data` + `common/units/monster-mapping` + `client/world/act-runtime`
|
||
* - 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/launcher/play-entry.ts`
|
||
* - F7 + F9: `tsconfig.client.json` + `src/client/launcher/play-entry.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-panel', async () => {
|
||
const bridgeMod = await importRepoModule('src/common/items/item-bridge.ts')
|
||
const invPanelMod = await importRepoModule('src/client/ui/inventory-panel.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 actRuntimeMod = await importRepoModule(
|
||
'src/client/world/act-runtime.ts',
|
||
'src/client/map/act-runtime.ts',
|
||
)
|
||
expect(actRuntimeMod).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/labels.ts',
|
||
'src/client/i18n/index.ts',
|
||
'src/client/i18n/monster-names.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/launcher/play-entry.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/launcher/play-entry.ts stays strictly within common, netproto, and client', () => {
|
||
const reachable = collectTransitiveTsImports('src/client/launcher/play-entry.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()
|
||
}
|
||
})
|
||
})
|