855 lines
40 KiB
TypeScript
855 lines
40 KiB
TypeScript
/**
|
||
* Tier 1 — Requirement-Driven Feature Coverage E2E Tests (`tests/e2e-module-split/tier1-feature-coverage.test.ts`).
|
||
*
|
||
* Covers primary happy-path behavior for all 10 features (`F1`–`F10`) in `PROJECT.md § Feature Inventory`
|
||
* (`>= 5` tests per feature, `52` tests total), using dynamic `await import(...)`, `node:fs` inspection,
|
||
* and AST/source verification against the post-split module layout.
|
||
*/
|
||
|
||
import { readdirSync, statSync } from 'node:fs'
|
||
import { describe, expect, it } from 'vitest'
|
||
import {
|
||
CANONICAL_SRC_MODULES,
|
||
LEGACY_HTML_FILES,
|
||
LEGACY_SRC_DIRS,
|
||
SRC_DIR,
|
||
UNIVERSAL_DT1_PATHS,
|
||
collectTsFiles,
|
||
createSyntheticArchiveSource,
|
||
existsInRepo,
|
||
extractImportSpecifiers,
|
||
importRepoModule,
|
||
isDirInRepo,
|
||
readJsonFile,
|
||
readRepoFile,
|
||
resolveImportTargetRel,
|
||
} from './helpers'
|
||
|
||
describe('Tier 1 — Feature Coverage: Module Split & Single Entry Consolidation (F1–F10)', () => {
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// F1: src/common/ Pure Module & Data Registry Decoupling (6 tests)
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
describe('F1: src/common/ Pure Module & Data Registry Decoupling', () => {
|
||
it('F1.1: populates all 11 canonical subdirectories under src/common/', () => {
|
||
const requiredSubdirs = [
|
||
'src/common/mpq',
|
||
'src/common/formats',
|
||
'src/common/data',
|
||
'src/common/drlg',
|
||
'src/common/world',
|
||
'src/common/items',
|
||
'src/common/skills',
|
||
'src/common/units',
|
||
'src/common/i18n',
|
||
'src/common/rng',
|
||
'src/common/pack-contract',
|
||
]
|
||
for (const dir of requiredSubdirs) {
|
||
expect(isDirInRepo(dir), `Expected directory ${dir} to exist`).toBe(true)
|
||
const tsFiles = collectTsFiles(dir)
|
||
expect(tsFiles.length, `Expected ${dir} to contain .ts files`).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('F1.2: configures tsconfig.common.json with pure ES2022/ES2023 lib and empty types: []', () => {
|
||
expect(existsInRepo('tsconfig.common.json')).toBe(true)
|
||
const cfg = readJsonFile('tsconfig.common.json')
|
||
expect(Array.isArray(cfg.compilerOptions?.lib)).toBe(true)
|
||
const libs = (cfg.compilerOptions.lib as string[]).map((s) => s.toUpperCase())
|
||
expect(libs.some((l) => l.startsWith('ES202'))).toBe(true)
|
||
expect(libs).not.toContain('DOM')
|
||
expect(libs).not.toContain('DOM.ITERABLE')
|
||
expect(cfg.compilerOptions?.types).toEqual([])
|
||
expect(cfg.include).toContain('src/common/**/*.ts')
|
||
})
|
||
|
||
it('F1.3: instantiates D2DataRegistry via injected ArchiveSource in src/common/data/data-registry.ts', async () => {
|
||
const mod = await importRepoModule('src/common/data/data-registry.ts')
|
||
expect(typeof mod.D2DataRegistry).toBe('function')
|
||
expect(typeof mod.getSharedDataRegistry).toBe('function')
|
||
expect(Array.isArray(mod.MPQ_LOAD_ORDER)).toBe(true)
|
||
expect(mod.MPQ_LOAD_ORDER).toContain('Patch_D2.mpq')
|
||
|
||
const mockSource = createSyntheticArchiveSource()
|
||
if (typeof mod.resetSharedDataRegistryCache === 'function') {
|
||
mod.resetSharedDataRegistryCache()
|
||
}
|
||
const registry =
|
||
typeof mod.D2DataRegistry.fromArchiveSource === 'function'
|
||
? await mod.D2DataRegistry.fromArchiveSource(mockSource)
|
||
: await mod.D2DataRegistry.fromMountedArchives(mockSource)
|
||
|
||
expect(registry).toBeDefined()
|
||
const fireBolt = registry.getSkill(36)
|
||
expect(fireBolt).toBeDefined()
|
||
expect(fireBolt.skill).toBe('Fire Bolt')
|
||
expect(fireBolt.charClass).toBe('sor')
|
||
})
|
||
|
||
it('F1.4: exports deterministic D2Rng in src/common/rng/d2-rng.ts with bit-identical sequences', async () => {
|
||
const mod = await importRepoModule('src/common/rng/d2-rng.ts')
|
||
expect(typeof mod.D2Rng).toBe('function')
|
||
|
||
const rngA = new mod.D2Rng(0x12345678)
|
||
const rngB = new mod.D2Rng(0x12345678)
|
||
const seqA = Array.from({ length: 16 }, () => rngA.nextUint32())
|
||
const seqB = Array.from({ length: 16 }, () => rngB.nextUint32())
|
||
expect(seqA).toEqual(seqB)
|
||
expect(new Set(seqA).size).toBe(16)
|
||
})
|
||
|
||
it('F1.5: exports SplitMix64 pickVariant in src/common/ without 45-degree diagonal stripe correlation', async () => {
|
||
const mod = await importRepoModule(
|
||
'src/common/world/d2map.ts',
|
||
'src/common/rng/variants.ts',
|
||
)
|
||
const pickVariant =
|
||
mod.pickVariant ??
|
||
(await importRepoModule('src/common/world/d2map.ts')).pickVariant
|
||
expect(typeof pickVariant).toBe('function')
|
||
|
||
const candidates = [
|
||
{ library: 0, tile: 0, weight: 1, animated: false, frameIndex: 0 },
|
||
{ library: 0, tile: 1, weight: 1, animated: false, frameIndex: 0 },
|
||
{ library: 0, tile: 2, weight: 1, animated: false, frameIndex: 0 },
|
||
{ library: 0, tile: 3, weight: 1, animated: false, frameIndex: 0 },
|
||
]
|
||
let diagMatches = 0
|
||
let totalDiag = 0
|
||
const N = 32
|
||
const grid: number[][] = []
|
||
for (let y = 0; y < N; y += 1) {
|
||
const row: number[] = []
|
||
for (let x = 0; x < N; x += 1) {
|
||
row.push(pickVariant(candidates, x, y, 123456).tile)
|
||
}
|
||
grid.push(row)
|
||
}
|
||
for (let y = 0; y < N - 1; y += 1) {
|
||
for (let x = 0; x < N - 1; x += 1) {
|
||
if (grid[y]![x] === grid[y + 1]![x + 1]) diagMatches += 1
|
||
totalDiag += 1
|
||
}
|
||
}
|
||
const rate = diagMatches / totalDiag
|
||
expect(rate).toBeGreaterThan(0.15)
|
||
expect(rate).toBeLessThan(0.35)
|
||
})
|
||
|
||
it('F1.6: exports pure lookupTbl, toGameLang, and toTblLang in src/common/i18n/ decoupled from DOM state', async () => {
|
||
const mod = await importRepoModule(
|
||
'src/common/i18n/tbl.ts',
|
||
'src/common/i18n/lang.ts',
|
||
'src/common/i18n/index.ts',
|
||
)
|
||
expect(typeof mod.lookupTbl).toBe('function')
|
||
expect(typeof mod.toGameLang).toBe('function')
|
||
expect(typeof mod.toTblLang).toBe('function')
|
||
expect(mod.toGameLang('ENG')).toBe('en')
|
||
expect(mod.toGameLang('CHI')).toBe('zh')
|
||
expect(mod.toTblLang('en')).toBe('ENG')
|
||
expect(mod.toTblLang('zh')).toBe('CHI')
|
||
})
|
||
})
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// F2: Shared Pack Contract (src/common/pack-contract/) (5 tests)
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
describe('F2: Shared Pack Contract (src/common/pack-contract/)', () => {
|
||
it('F2.1: provides src/common/pack-contract/index.ts with zero imports outside src/common/**', () => {
|
||
expect(existsInRepo('src/common/pack-contract/index.ts')).toBe(true)
|
||
const files = collectTsFiles('src/common/pack-contract')
|
||
expect(files.length).toBeGreaterThan(0)
|
||
for (const file of files) {
|
||
const src = readRepoFile(file)
|
||
const specs = extractImportSpecifiers(src)
|
||
for (const spec of specs) {
|
||
const target = resolveImportTargetRel(file, spec)
|
||
expect(target.startsWith('src/common/')).toBe(true)
|
||
}
|
||
}
|
||
})
|
||
|
||
it('F2.2: exports fail-fast universal DT1 and pack manifest validators from src/common/pack-contract/', async () => {
|
||
const contract = await importRepoModule('src/common/pack-contract/index.ts')
|
||
expect(typeof contract.validateUniversalDt1Libraries).toBe('function')
|
||
expect(typeof contract.validatePackManifest).toBe('function')
|
||
})
|
||
|
||
it('F2.3: validateUniversalDt1Libraries accepts libraries containing Blank.dt1, InvisWal.dt1 (style=49 seq=7), and Warp.dt1', async () => {
|
||
const contract = await importRepoModule('src/common/pack-contract/index.ts')
|
||
const validLibraries = [
|
||
{
|
||
path: 'data\\global\\tiles\\Act1\\Outdoors\\Blank.dt1',
|
||
archivePath: 'data\\global\\tiles\\Act1\\Outdoors\\Blank.dt1',
|
||
tiles: [{ type: 0, style: 0, sequence: 0 }],
|
||
},
|
||
{
|
||
path: 'data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1',
|
||
archivePath: 'data\\global\\tiles\\Act1\\Barracks\\InvisWal.dt1',
|
||
tiles: [{ type: 0, style: 49, sequence: 7 }],
|
||
},
|
||
{
|
||
path: 'data\\global\\tiles\\Act1\\Barracks\\Warp.dt1',
|
||
archivePath: 'data\\global\\tiles\\Act1\\Barracks\\Warp.dt1',
|
||
tiles: [{ type: 0, style: 0, sequence: 0 }],
|
||
},
|
||
]
|
||
expect(() => contract.validateUniversalDt1Libraries(validLibraries)).not.toThrow()
|
||
})
|
||
|
||
it('F2.4: validatePackManifest accepts a clean manifest with missingFiles === 0 and missingTiles === 0', async () => {
|
||
const contract = await importRepoModule('src/common/pack-contract/index.ts')
|
||
const cleanManifest = {
|
||
schema: 1,
|
||
missingFiles: 0,
|
||
missingTiles: 0,
|
||
entries: [{ name: 'Data\\Global\\Excel\\Levels.txt', offset: 0, length: 128 }],
|
||
}
|
||
expect(() => contract.validatePackManifest(cleanManifest)).not.toThrow()
|
||
})
|
||
|
||
it('F2.5: decouples src/client/map/map-service.ts from src/baker/ via src/common/pack-contract/', () => {
|
||
const mapServiceCode = readRepoFile('src/client/map/map-service.ts')
|
||
const specs = extractImportSpecifiers(mapServiceCode)
|
||
expect(specs.some((s) => s.includes('pack-tiles'))).toBe(false)
|
||
expect(specs.some((s) => s.includes('baker'))).toBe(false)
|
||
expect(specs.some((s) => s.includes('pack-contract'))).toBe(true)
|
||
})
|
||
})
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// F3: Elimination of All 23 Server-to-Client Reverse Dependencies (5 tests)
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
describe('F3: Elimination of All 23 Server-to-Client Reverse Dependencies', () => {
|
||
it('F3.1: exports pure inventory grid model and gold limits from src/common/items/inventory-model.ts', async () => {
|
||
const invModel = await importRepoModule(
|
||
'src/common/items/inventory-model.ts',
|
||
'src/common/items/items.ts',
|
||
)
|
||
expect(typeof invModel.getInventoryGoldLimit).toBe('function')
|
||
expect(invModel.getInventoryGoldLimit(1)).toBe(10000)
|
||
expect(invModel.getInventoryGoldLimit(99)).toBe(990000)
|
||
expect(invModel.INV_COLS ?? 10).toBe(10)
|
||
expect(invModel.INV_ROWS ?? 4).toBe(4)
|
||
})
|
||
|
||
it('F3.2: exports pure belt potion model and compatibility rules from src/common/items/belt-model.ts', async () => {
|
||
const beltModel = await importRepoModule('src/common/items/belt-model.ts')
|
||
expect(beltModel.BELT_COLS).toBe(4)
|
||
expect(beltModel.BELT_POTION_CATALOG).toBeDefined()
|
||
expect(typeof beltModel.itemToBeltPotion).toBe('function')
|
||
expect(typeof beltModel.areBeltPotionsCompatible).toBe('function')
|
||
|
||
const hp1 = beltModel.itemToBeltPotion({ code: 'hp1', name: 'Minor Healing Potion' })
|
||
const hp5 = beltModel.itemToBeltPotion({ code: 'hp5', name: 'Super Healing Potion' })
|
||
const mp1 = beltModel.itemToBeltPotion({ code: 'mp1', name: 'Minor Mana Potion' })
|
||
expect(hp1).not.toBeNull()
|
||
expect(hp5).not.toBeNull()
|
||
expect(mp1).not.toBeNull()
|
||
expect(beltModel.areBeltPotionsCompatible(hp1, hp5)).toBe(true)
|
||
expect(beltModel.areBeltPotionsCompatible(hp1, mp1)).toBe(false)
|
||
})
|
||
|
||
it('F3.3: exports itemToUiInventoryItem and 1.13c weapon handedness sets from src/common/items/item-bridge.ts', async () => {
|
||
const bridge = await importRepoModule(
|
||
'src/common/items/item-bridge.ts',
|
||
'src/common/items/inventory-model.ts',
|
||
)
|
||
expect(typeof bridge.itemToUiInventoryItem).toBe('function')
|
||
expect(bridge.BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES).toBeInstanceOf(Set)
|
||
expect(bridge.BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.size).toBe(18)
|
||
expect(bridge.BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has('2hs')).toBe(true)
|
||
expect(bridge.BARBARIAN_ONE_OR_TWO_HANDED_SWORD_CODES.has('7gd')).toBe(true)
|
||
})
|
||
|
||
it('F3.4: exports 7-class CANONICAL_CHARSTATS and resolvePlayerCharToken from src/common/units/classes.ts', async () => {
|
||
const classesMod = await importRepoModule('src/common/units/classes.ts')
|
||
expect(classesMod.CHARACTER_CLASS_CODES).toEqual([
|
||
'ama',
|
||
'sor',
|
||
'nec',
|
||
'pal',
|
||
'bar',
|
||
'dru',
|
||
'ass',
|
||
])
|
||
expect(typeof classesMod.resolvePlayerCharToken).toBe('function')
|
||
expect(classesMod.resolvePlayerCharToken('ass')).toBe('ai')
|
||
expect(classesMod.resolvePlayerCharToken('dru')).toBe('dz')
|
||
expect(classesMod.resolvePlayerCharToken('sor')).toBe('so')
|
||
expect(classesMod.resolvePlayerCharToken('pal')).toBe('pa')
|
||
})
|
||
|
||
it('F3.5: exports getSkillName, isPassiveSkill, isLeftUsableSkill in src/common/skills/ and subTileToScene in src/common/world/', async () => {
|
||
const skillTree = await importRepoModule('src/common/skills/skill-tree.ts')
|
||
expect(typeof skillTree.getSkillName).toBe('function')
|
||
expect(typeof skillTree.isPassiveSkill).toBe('function')
|
||
expect(typeof skillTree.isLeftUsableSkill).toBe('function')
|
||
expect(skillTree.isPassiveSkill(37)).toBe(true) // Warmth
|
||
expect(skillTree.isPassiveSkill(36)).toBe(false) // Fire Bolt
|
||
expect(skillTree.isLeftUsableSkill(36)).toBe(true) // Fire Bolt
|
||
|
||
const d2map = await importRepoModule('src/common/world/d2map.ts')
|
||
expect(typeof d2map.subTileToScene).toBe('function')
|
||
})
|
||
})
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// F4: src/baker/ Offline Baker Module & Unified CLI (src/baker/cli.ts) (5 tests)
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
describe('F4: src/baker/ Offline Baker Module & Unified CLI (src/baker/cli.ts)', () => {
|
||
it('F4.1: organizes src/baker/ into cli.ts, io/, packers/, extract/, and audit/', () => {
|
||
expect(existsInRepo('src/baker/cli.ts')).toBe(true)
|
||
expect(isDirInRepo('src/baker/io')).toBe(true)
|
||
expect(isDirInRepo('src/baker/packers')).toBe(true)
|
||
expect(isDirInRepo('src/baker/extract')).toBe(true)
|
||
expect(isDirInRepo('src/baker/audit')).toBe(true)
|
||
})
|
||
|
||
it('F4.2: relocates Node MPQ file-source.ts and png.ts into src/baker/io/ and removes file-source.ts from src/common/mpq/', () => {
|
||
expect(existsInRepo('src/baker/io/file-source.ts')).toBe(true)
|
||
expect(existsInRepo('src/baker/io/png.ts')).toBe(true)
|
||
expect(existsInRepo('src/baker/io/drlg-mpq-source.ts')).toBe(true)
|
||
expect(existsInRepo('src/baker/io/tbl-writer.ts')).toBe(true)
|
||
expect(existsInRepo('src/common/mpq/file-source.ts')).toBe(false)
|
||
})
|
||
|
||
it('F4.3: populates src/baker/packers/ with all pack-*.ts scripts and src/baker/audit/ with all 4 offline verifiers', () => {
|
||
const requiredPackers = [
|
||
'src/baker/packers/pack-tiles.ts',
|
||
'src/baker/packers/pack-act-assets.ts',
|
||
'src/baker/packers/pack-entity-assets.ts',
|
||
'src/baker/packers/pack-missiles.ts',
|
||
'src/baker/packers/pack-overlays.ts',
|
||
'src/baker/packers/pack-ui.ts',
|
||
'src/baker/packers/pack-animdata.ts',
|
||
'src/baker/packers/pack-characters.ts',
|
||
]
|
||
for (const p of requiredPackers) {
|
||
expect(existsInRepo(p), `Expected packer ${p}`).toBe(true)
|
||
}
|
||
|
||
const requiredAuditors = [
|
||
'src/baker/audit/verify-packs.ts',
|
||
'src/baker/audit/verify-entity-packs.ts',
|
||
'src/baker/audit/verify-tiles.ts',
|
||
'src/baker/audit/verify-tile-alignment.ts',
|
||
]
|
||
for (const a of requiredAuditors) {
|
||
expect(existsInRepo(a), `Expected auditor ${a}`).toBe(true)
|
||
}
|
||
})
|
||
|
||
it('F4.4: configures tsconfig.baker.json with types: ["node"] and excludes src/client/** and src/server/**', () => {
|
||
expect(existsInRepo('tsconfig.baker.json')).toBe(true)
|
||
const cfg = readJsonFile('tsconfig.baker.json')
|
||
expect(cfg.compilerOptions?.types).toEqual(['node'])
|
||
const libs = (cfg.compilerOptions?.lib as string[] | undefined)?.map((s) =>
|
||
s.toUpperCase(),
|
||
) ?? []
|
||
expect(libs).not.toContain('DOM')
|
||
const excludeStr = JSON.stringify(cfg.exclude ?? [])
|
||
expect(excludeStr).toContain('src/client')
|
||
expect(excludeStr).toContain('src/server')
|
||
})
|
||
|
||
it('F4.5: wires npm run bake to src/baker/cli.ts and updates pack:* scripts in package.json', () => {
|
||
const pkg = readJsonFile('package.json')
|
||
expect(typeof pkg.scripts?.bake).toBe('string')
|
||
expect(pkg.scripts.bake).toContain('src/baker/cli.ts')
|
||
expect(pkg.scripts['pack:tiles']).toContain('src/baker/')
|
||
expect(pkg.scripts['pack:entities']).toContain('src/baker/')
|
||
expect(pkg.scripts['pack:missiles']).toContain('src/baker/')
|
||
})
|
||
})
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// F5: tools/ CLI Consolidation & Legacy Relay Deletion (5 tests)
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
describe('F5: tools/ CLI Consolidation & Legacy Relay Deletion', () => {
|
||
it('F5.1: consolidates developer, bot, replay, unlock, and verification CLI scripts into tools/', () => {
|
||
const requiredTools = [
|
||
'tools/d2-bot.ts',
|
||
'tools/netproto-replay.ts',
|
||
'tools/d2s-unlock.ts',
|
||
'tools/verify-play.ts',
|
||
'tools/audit-levels-browser.ts',
|
||
]
|
||
for (const tool of requiredTools) {
|
||
expect(existsInRepo(tool), `Expected CLI tool ${tool} to exist`).toBe(true)
|
||
}
|
||
expect(collectTsFiles('tools').length).toBeGreaterThanOrEqual(80)
|
||
})
|
||
|
||
it('F5.2: migrates tools/lib/drlg-oracle.ts and tools/d2s-unlock.ts with intact exports', async () => {
|
||
expect(existsInRepo('tools/lib/drlg-oracle.ts')).toBe(true)
|
||
const unlockMod = await importRepoModule('tools/d2s-unlock.ts')
|
||
expect(unlockMod).toBeDefined()
|
||
expect(Object.keys(unlockMod).length).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('F5.3: deletes legacy P2P lockstep server scripts net-relay.ts and net-server.ts', () => {
|
||
expect(existsInRepo('scripts/net-relay.ts')).toBe(false)
|
||
expect(existsInRepo('scripts/net-server.ts')).toBe(false)
|
||
expect(existsInRepo('tools/net-relay.ts')).toBe(false)
|
||
expect(existsInRepo('tools/net-server.ts')).toBe(false)
|
||
})
|
||
|
||
it('F5.4: removes the legacy top-level scripts/ directory completely', () => {
|
||
expect(existsInRepo('scripts')).toBe(false)
|
||
expect(collectTsFiles('scripts')).toEqual([])
|
||
})
|
||
|
||
it('F5.5: updates package.json tool scripts to target tools/ and removes net-server script', () => {
|
||
const pkg = readJsonFile('package.json')
|
||
const scriptsJson = JSON.stringify(pkg.scripts ?? {})
|
||
expect(scriptsJson).not.toContain('scripts/')
|
||
expect(pkg.scripts?.['net-server']).toBeUndefined()
|
||
expect(pkg.scripts?.['net']).toBeUndefined()
|
||
expect(pkg.scripts?.['verify:play']).toContain('tools/verify-play.ts')
|
||
})
|
||
})
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// F6: src/server/ Authoritative Simulation Module & LocalLoopbackAdapter (6 tests)
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
describe('F6: src/server/ Authoritative Simulation Module & LocalLoopbackAdapter', () => {
|
||
it('F6.1: populates all 8 canonical subdirectories under src/server/', () => {
|
||
const requiredSubdirs = [
|
||
'src/server/engine',
|
||
'src/server/ai',
|
||
'src/server/skills',
|
||
'src/server/items',
|
||
'src/server/world',
|
||
'src/server/save',
|
||
'src/server/scene',
|
||
'src/server/adapter',
|
||
]
|
||
for (const dir of requiredSubdirs) {
|
||
expect(isDirInRepo(dir), `Expected directory ${dir} to exist`).toBe(true)
|
||
expect(collectTsFiles(dir).length).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('F6.2: exports GameEngine and worldToCell from src/server/engine/engine.ts', async () => {
|
||
const engineMod = await importRepoModule('src/server/engine/engine.ts')
|
||
expect(typeof engineMod.GameEngine).toBe('function')
|
||
expect(typeof engineMod.worldToCell).toBe('function')
|
||
const cellCoords = engineMod.worldToCell({ widthPx: 800, heightPx: 600, overlap: () => 0 }, 80, 40)
|
||
expect(cellCoords).toEqual({ cellX: 1, cellY: 0 })
|
||
})
|
||
|
||
it('F6.3: registers 7-class skill handlers in src/server/skills/registry.ts without node:* builtins', async () => {
|
||
const regSource = readRepoFile('src/server/skills/registry.ts')
|
||
expect(regSource).not.toContain('node:fs')
|
||
expect(regSource).not.toContain('node:path')
|
||
expect(regSource).not.toContain('getBuiltinModule')
|
||
|
||
const regMod = await importRepoModule('src/server/skills/registry.ts')
|
||
expect(regMod).toBeDefined()
|
||
}, 20_000)
|
||
|
||
it('F6.4: implements LocalLoopbackAdapter in src/server/adapter/LocalLoopbackAdapter.ts conforming to GameServerAdapter', async () => {
|
||
const adapterMod = await importRepoModule(
|
||
'src/server/adapter/LocalLoopbackAdapter.ts',
|
||
'src/server/adapter/index.ts',
|
||
)
|
||
const LocalLoopbackAdapter =
|
||
adapterMod.LocalLoopbackAdapter ?? adapterMod.default
|
||
expect(typeof LocalLoopbackAdapter).toBe('function')
|
||
|
||
const adapter = new LocalLoopbackAdapter({ tickRateMs: 0 })
|
||
expect(typeof adapter.connect).toBe('function')
|
||
expect(typeof adapter.disconnect).toBe('function')
|
||
expect(typeof adapter.send).toBe('function')
|
||
expect(typeof adapter.onEvent).toBe('function')
|
||
expect(typeof adapter.isConnected).toBe('boolean')
|
||
expect(typeof adapter.tick).toBe('number')
|
||
adapter.disconnect()
|
||
})
|
||
|
||
it('F6.5: connects LocalLoopbackAdapter, emits initial ServerEvents, and advances ticks', async () => {
|
||
const adapterMod = await importRepoModule(
|
||
'src/server/adapter/LocalLoopbackAdapter.ts',
|
||
'src/server/adapter/index.ts',
|
||
)
|
||
const LocalLoopbackAdapter =
|
||
adapterMod.LocalLoopbackAdapter ?? adapterMod.default
|
||
const events: any[] = []
|
||
const adapter = new LocalLoopbackAdapter({
|
||
act: 1,
|
||
levelId: 1,
|
||
seed: 0x113c,
|
||
tickRateMs: 0,
|
||
})
|
||
const unsub = adapter.onEvent((ev: any) => {
|
||
events.push(ev)
|
||
})
|
||
await adapter.connect()
|
||
expect(adapter.isConnected).toBe(true)
|
||
|
||
if (typeof adapter.stepTick === 'function') {
|
||
adapter.stepTick()
|
||
}
|
||
adapter.send({ type: 'WalkToLocation', x: 100, y: 100 })
|
||
if (typeof adapter.stepTick === 'function') {
|
||
adapter.stepTick()
|
||
}
|
||
expect(events.length).toBeGreaterThan(0)
|
||
unsub()
|
||
adapter.disconnect()
|
||
expect(adapter.isConnected).toBe(false)
|
||
})
|
||
|
||
it('F6.6: configures tsconfig.server.json without DOM lib and excludes src/client/** and src/baker/**', () => {
|
||
expect(existsInRepo('tsconfig.server.json')).toBe(true)
|
||
const cfg = readJsonFile('tsconfig.server.json')
|
||
const libs = (cfg.compilerOptions?.lib as string[] | undefined)?.map((s) =>
|
||
s.toUpperCase(),
|
||
) ?? []
|
||
expect(libs).not.toContain('DOM')
|
||
const excludeStr = JSON.stringify(cfg.exclude ?? [])
|
||
expect(excludeStr).toContain('src/client')
|
||
expect(excludeStr).toContain('src/baker')
|
||
})
|
||
})
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// F7: src/client/ Pure Browser Module & MapService Node Decoupling (5 tests)
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
describe('F7: src/client/ Pure Browser Module & MapService Node Decoupling', () => {
|
||
it('F7.1: consolidates all browser modules under src/client/', () => {
|
||
expect(existsInRepo('src/client/main.ts')).toBe(true)
|
||
const requiredSubdirs = [
|
||
'src/client/render',
|
||
'src/client/ui',
|
||
'src/client/frontend',
|
||
'src/client/scene',
|
||
'src/client/audio',
|
||
'src/client/sim',
|
||
'src/client/i18n',
|
||
'src/client/map',
|
||
'src/client/session',
|
||
'src/client/world',
|
||
]
|
||
for (const dir of requiredSubdirs) {
|
||
expect(isDirInRepo(dir), `Expected client directory ${dir}`).toBe(true)
|
||
expect(collectTsFiles(dir).length).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('F7.2: decouples MapService in src/client/map/map-service.ts from node:fs/node:path via fromLoader', async () => {
|
||
const code = readRepoFile('src/client/map/map-service.ts')
|
||
expect(code).not.toContain('node:fs')
|
||
expect(code).not.toContain('node:path')
|
||
expect(code).not.toContain("node:${'fs'}")
|
||
expect(code).not.toContain("node:${'path'}")
|
||
|
||
const mod = await importRepoModule('src/client/map/map-service.ts')
|
||
expect(typeof mod.MapService).toBe('function')
|
||
expect(typeof mod.MapService.fromLoader).toBe('function')
|
||
expect(
|
||
typeof mod.MapService.fromPackedUrls === 'function' ||
|
||
typeof mod.MapService.fromPackedUrl === 'function',
|
||
).toBe(true)
|
||
})
|
||
|
||
it('F7.3: provides browser language state in src/client/i18n/lang.ts and re-exports lookupTbl', async () => {
|
||
const langMod = await importRepoModule('src/client/i18n/lang.ts')
|
||
expect(typeof langMod.detectLangFromUrl).toBe('function')
|
||
expect(typeof langMod.getTblLang).toBe('function')
|
||
expect(typeof langMod.setGameLang).toBe('function')
|
||
expect(typeof langMod.lookupTbl).toBe('function')
|
||
expect(langMod.TOOLBAR_I18N).toBeDefined()
|
||
expect(langMod.detectLangFromUrl('http://localhost/play.html?lang=en')).toBe('ENG')
|
||
expect(langMod.detectLangFromUrl('http://localhost/play.html?lang=zh')).toBe('CHI')
|
||
})
|
||
|
||
it('F7.4: decouples HudManager and WorldRenderer in src/client/ from server GameEngine', () => {
|
||
const hudCode = readRepoFile('src/client/ui/hud-manager.ts')
|
||
const rendererCode = readRepoFile('src/client/scene/world-renderer.ts')
|
||
for (const spec of [
|
||
...extractImportSpecifiers(hudCode),
|
||
...extractImportSpecifiers(rendererCode),
|
||
]) {
|
||
expect(spec).not.toContain('/server/')
|
||
expect(spec).not.toContain('/game/')
|
||
}
|
||
})
|
||
|
||
it('F7.5: configures tsconfig.client.json with DOM lib, types: [], and excludes src/server/** & src/baker/**', () => {
|
||
expect(existsInRepo('tsconfig.client.json')).toBe(true)
|
||
const cfg = readJsonFile('tsconfig.client.json')
|
||
const libs = (cfg.compilerOptions?.lib as string[] | undefined)?.map((s) =>
|
||
s.toUpperCase(),
|
||
) ?? []
|
||
expect(libs).toContain('DOM')
|
||
expect(cfg.compilerOptions?.types).toEqual([])
|
||
const excludeStr = JSON.stringify(cfg.exclude ?? [])
|
||
expect(excludeStr).toContain('src/server')
|
||
expect(excludeStr).toContain('src/baker')
|
||
})
|
||
})
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// F8: Single HTML Entry (play.html) Consolidation & Legacy Entry Removal (5 tests)
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
describe('F8: Single HTML Entry (play.html) Consolidation & Legacy Entry Removal', () => {
|
||
it('F8.1: retains play.html with /src/client/main.ts entry and D2Exocet / D2Formal436 @font-face declarations', () => {
|
||
expect(existsInRepo('play.html')).toBe(true)
|
||
const html = readRepoFile('play.html')
|
||
expect(html).toContain('/src/client/main.ts')
|
||
expect(html).toContain('@font-face')
|
||
expect(html).toContain('D2Exocet')
|
||
expect(html).toContain('D2Formal436')
|
||
})
|
||
|
||
it('F8.2: deletes all 5 legacy HTML files (index.html, acts.html, acts-zh.html, acts-en.html, bnet.html)', () => {
|
||
for (const legacyHtml of LEGACY_HTML_FILES) {
|
||
expect(existsInRepo(legacyHtml), `Expected ${legacyHtml} to be deleted`).toBe(false)
|
||
}
|
||
})
|
||
|
||
it('F8.3: configures vite.config.ts with play.html as the sole rollup input', () => {
|
||
const viteCfg = readRepoFile('vite.config.ts')
|
||
expect(viteCfg).toContain('play.html')
|
||
for (const legacyHtml of LEGACY_HTML_FILES) {
|
||
expect(viteCfg).not.toContain(legacyHtml)
|
||
}
|
||
})
|
||
|
||
it('F8.4: deletes obsolete entry glue files (src/main.ts, src/walk.ts, bnet-main.ts, act-scene.ts, net-scene.ts, src/net/)', () => {
|
||
const obsoletePaths = [
|
||
'src/main.ts',
|
||
'src/walk.ts',
|
||
'src/frontend/bnet-main.ts',
|
||
'src/client/frontend/bnet-main.ts',
|
||
'src/scene/act-scene.ts',
|
||
'src/client/scene/act-scene.ts',
|
||
'src/scene/net-scene.ts',
|
||
'src/client/scene/net-scene.ts',
|
||
'src/scene/backend/net-simulation.ts',
|
||
'src/server/scene/net-simulation.ts',
|
||
'src/net',
|
||
]
|
||
for (const p of obsoletePaths) {
|
||
expect(existsInRepo(p), `Expected obsolete path ${p} to be deleted`).toBe(false)
|
||
}
|
||
})
|
||
|
||
it('F8.5: preserves deterministic state hash calculation (computeLockstepHash / computeStateHash) in src/server/engine/', async () => {
|
||
const engineMod = await importRepoModule(
|
||
'src/server/engine/state-hash.ts',
|
||
'src/server/engine/engine.ts',
|
||
)
|
||
const hashFn =
|
||
engineMod.computeLockstepHash ??
|
||
engineMod.computeStateHash ??
|
||
(await importRepoModule('src/server/engine/engine.ts')).computeLockstepHash ??
|
||
(await importRepoModule('src/server/engine/engine.ts')).computeStateHash
|
||
expect(typeof hashFn).toBe('function')
|
||
})
|
||
})
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// F9: Compiler-Level Isolation & Boundary Gate (tests/arch/boundaries.test.ts) (5 tests)
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
describe('F9: Compiler-Level Isolation & Zero-Exception Boundary Gate', () => {
|
||
it('F9.1: wires all 5 module tsconfig.*.json files into package.json typecheck', () => {
|
||
const configs = [
|
||
'tsconfig.common.json',
|
||
'tsconfig.netproto.json',
|
||
'tsconfig.server.json',
|
||
'tsconfig.client.json',
|
||
'tsconfig.baker.json',
|
||
]
|
||
const pkg = readJsonFile('package.json')
|
||
const typecheckCmd = String(pkg.scripts?.typecheck ?? '')
|
||
for (const cfg of configs) {
|
||
expect(existsInRepo(cfg), `Expected ${cfg} to exist`).toBe(true)
|
||
expect(typecheckCmd, `Expected npm run typecheck to include ${cfg}`).toContain(cfg)
|
||
}
|
||
})
|
||
|
||
it('F9.2: ensures src/ contains ONLY the 5 canonical module directories (common, netproto, server, client, baker)', () => {
|
||
const entries = readdirSync(SRC_DIR)
|
||
for (const entry of entries) {
|
||
const full = `${SRC_DIR}/${entry}`
|
||
const isDir = statSync(full).isDirectory()
|
||
expect(isDir, `Unexpected top-level file in src/: ${entry}`).toBe(true)
|
||
}
|
||
expect(entries.sort()).toEqual([...CANONICAL_SRC_MODULES].sort())
|
||
for (const legacyDir of LEGACY_SRC_DIRS) {
|
||
expect(existsInRepo(`src/${legacyDir}`)).toBe(false)
|
||
}
|
||
})
|
||
|
||
it('F9.3: provides tests/arch/boundaries.test.ts enforcing module isolation with 0 whitelist exceptions', () => {
|
||
expect(existsInRepo('tests/arch/boundaries.test.ts')).toBe(true)
|
||
const content = readRepoFile('tests/arch/boundaries.test.ts')
|
||
expect(content).toContain('common')
|
||
expect(content).toContain('netproto')
|
||
expect(content).toContain('server')
|
||
expect(content).toContain('client')
|
||
expect(content).toContain('baker')
|
||
expect(content).toContain('play.html')
|
||
})
|
||
|
||
it('F9.4: maintains tests/netproto/boundary.test.ts and src/netproto/index.ts public surface', async () => {
|
||
expect(existsInRepo('tests/netproto/boundary.test.ts')).toBe(true)
|
||
const netproto = await importRepoModule('src/netproto/index.ts')
|
||
expect(typeof netproto.createD2OnlineFlow).toBe('function')
|
||
expect(typeof netproto.decodeD2gsS2cPacket).toBe('function')
|
||
expect(typeof netproto.encodeD2gsClientCommand).toBe('function')
|
||
})
|
||
|
||
it('F9.5: relocates co-located test files to tests/ so src/** has zero test files', () => {
|
||
const srcTests = collectTsFiles('src').filter(
|
||
(f) => f.endsWith('.test.ts') || f.endsWith('.spec.ts'),
|
||
)
|
||
expect(srcTests).toEqual([])
|
||
expect(
|
||
existsInRepo('tests/automap-table.test.ts') ||
|
||
existsInRepo('tests/common/formats/automap-table.test.ts'),
|
||
).toBe(true)
|
||
expect(existsInRepo('tests/frontend/flow-state.test.ts')).toBe(true)
|
||
})
|
||
})
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// F10: Full 1.13c Parity, Replay, Baker & DRLG Invariants (5 tests)
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
describe('F10: Full 1.13c Parity, Replay, Baker & DRLG Invariants', () => {
|
||
it('F10.1: exports UNIVERSAL_DT1S in src/common/world/acts.ts and appends all 3 in resolveLevelLibraries', async () => {
|
||
const actsMod = await importRepoModule('src/common/world/acts.ts')
|
||
expect(actsMod.UNIVERSAL_DT1S).toEqual(UNIVERSAL_DT1_PATHS)
|
||
expect(typeof actsMod.resolveLevelLibraries).toBe('function')
|
||
})
|
||
|
||
it('F10.2: enforces COLLIDE_MASK_INVALID (COLLIDE_BLANK | COLLIDE_WALL) on empty/void cells in src/common/world/d2map.ts', async () => {
|
||
const d2map = await importRepoModule('src/common/world/d2map.ts')
|
||
expect(d2map.COLLIDE_WALL).toBe(0x0001)
|
||
expect(d2map.COLLIDE_BLANK).toBe(0x0020)
|
||
expect(d2map.COLLIDE_MASK_INVALID & d2map.COLLIDE_BLANK).toBe(d2map.COLLIDE_BLANK)
|
||
expect(d2map.COLLIDE_MASK_INVALID & d2map.COLLIDE_WALL).toBe(d2map.COLLIDE_WALL)
|
||
|
||
const emptyDs1 = {
|
||
version: 18,
|
||
width: 4,
|
||
height: 4,
|
||
act: 1,
|
||
substitutionType: 0,
|
||
wallLayers: 1,
|
||
floorLayers: 1,
|
||
objects: [],
|
||
npcPathOffset: null,
|
||
cells: Array.from({ length: 4 }, () =>
|
||
Array.from({ length: 4 }, () => ({
|
||
floors: [],
|
||
walls: [],
|
||
shadows: [],
|
||
substitutions: [],
|
||
})),
|
||
),
|
||
}
|
||
const scene = d2map.buildIsoMapScene(emptyDs1, [], 0)
|
||
for (let i = 0; i < scene.blocked.length; i += 1) {
|
||
expect(scene.blocked[i]).toBe(1)
|
||
expect(scene.collisionMasks[i] & d2map.COLLIDE_MASK_INVALID).toBe(
|
||
d2map.COLLIDE_MASK_INVALID,
|
||
)
|
||
}
|
||
})
|
||
|
||
it('F10.3: ensures findIsoSpawn in src/common/world/d2map.ts rejects void geometric center and spawns on connected floor', async () => {
|
||
const d2map = await importRepoModule('src/common/world/d2map.ts')
|
||
const walkableTile = {
|
||
direction: 0,
|
||
roofHeight: 0,
|
||
materialFlags: 0,
|
||
height: -80,
|
||
width: 160,
|
||
type: 0,
|
||
style: 1,
|
||
sequence: 0,
|
||
rarityFrameIndex: 1,
|
||
animated: 0,
|
||
subTileFlags: Array.from({ length: 25 }, () => ({
|
||
blockWalk: false,
|
||
blockPlayerWalk: false,
|
||
blockLight: false,
|
||
blockLos: false,
|
||
blockJump: false,
|
||
raw: 0,
|
||
})),
|
||
minBlockY: -80,
|
||
bitmapHeight: 80,
|
||
blocks: [],
|
||
}
|
||
const dt1 = { versionMajor: 7, versionMinor: 6, warnings: [], tiles: [walkableTile] }
|
||
const cells = Array.from({ length: 16 }, (_, y) =>
|
||
Array.from({ length: 16 }, (_, x) =>
|
||
x >= 1 && x <= 3 && y >= 1 && y <= 3
|
||
? {
|
||
floors: [{ prop1: 1, sequence: 0, style: 1, hidden: false, unknown1: 0, unknown2: 0 }],
|
||
walls: [],
|
||
shadows: [],
|
||
substitutions: [],
|
||
}
|
||
: { floors: [], walls: [], shadows: [], substitutions: [] },
|
||
),
|
||
)
|
||
const scene = d2map.buildIsoMapScene(
|
||
{
|
||
version: 18,
|
||
width: 16,
|
||
height: 16,
|
||
act: 1,
|
||
substitutionType: 0,
|
||
wallLayers: 1,
|
||
floorLayers: 1,
|
||
objects: [],
|
||
npcPathOffset: null,
|
||
cells,
|
||
},
|
||
[dt1],
|
||
0,
|
||
)
|
||
const spawn = d2map.findIsoSpawn(scene)
|
||
expect(spawn).not.toBeNull()
|
||
expect(d2map.isBlockedAt(scene, spawn.x, spawn.y)).toBe(false)
|
||
const spawnCell = d2map.cellAt(scene, spawn.x, spawn.y)
|
||
expect(Math.floor(spawnCell.x)).toBeGreaterThanOrEqual(1)
|
||
expect(Math.floor(spawnCell.x)).toBeLessThanOrEqual(3)
|
||
})
|
||
|
||
it('F10.4: preserves 1.13c SuperUniques boss & minion class fidelity in src/common/units/', async () => {
|
||
const monstersData = await importRepoModule(
|
||
'src/common/units/monsters-data.ts',
|
||
'src/common/units/monsters.ts',
|
||
)
|
||
const monsterMapping = await importRepoModule('src/common/units/monster-mapping.ts')
|
||
const parsed = monstersData.readSuperUniques(monstersData.CANONICAL_SUPERUNIQUES_TABLE)
|
||
const byId = new Map<string, any>(parsed.map((p: any) => [p.id, p]))
|
||
expect(byId.get('Beetleburst')?.monsterId).toBe('scarab2')
|
||
expect(byId.get('Dark Elder')?.monsterId).toBe('zombie5')
|
||
expect(byId.get('Witch Doctor Endugu')?.monsterId).toBe('fetishshaman4')
|
||
expect(monsterMapping.resolveMonsterArtSpec('minion1')).toEqual({
|
||
token: 'XX',
|
||
weapon: 'hth',
|
||
})
|
||
})
|
||
|
||
it('F10.5: replays 02-d2gs-town-walk-chat.d2cap via tools/netproto-replay.ts with zero unknown/opaque/error packets', async () => {
|
||
const replayMod = await importRepoModule('tools/netproto-replay.ts')
|
||
const capText = readRepoFile('tests/fixtures/netproto/02-d2gs-town-walk-chat.d2cap')
|
||
const res = replayMod.replayD2CapText(capText, { strict: true })
|
||
expect(res.unknownPackets).toBe(0)
|
||
expect(res.opaquePackets).toBe(0)
|
||
expect(res.protocolErrors).toBe(0)
|
||
expect(res.gameS2cPackets).toBeGreaterThan(0)
|
||
})
|
||
})
|
||
})
|