diff --git a/src/game/acts.ts b/src/game/acts.ts index d5bb616..5968303 100644 --- a/src/game/acts.ts +++ b/src/game/acts.ts @@ -420,10 +420,9 @@ export function resolveLevel(tables: ActTables, levelId: number, act?: number): ) if (preset.length === 0) throw new Error(`LvlPrest.txt has no row with LevelId ${String(levelId)}`) - const dt1Mask = Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask')) const animSpeedRaw = parseInt(cell(tables.lvlprest, preset[0]!, 'Animate'), 10) || 0 const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined - const { dt1Names } = resolveLevelLibraries(tables, levelId) + const { dt1Names, dt1Mask } = loadDt1sForLevel(tables, levelId) const ds1Names: string[] = [] for (const presetRow of preset) { @@ -508,28 +507,31 @@ export async function loadActTown( } /** - * Resolve a level type's DT1 libraries without requiring preset rows. - * - * `resolveLevel` needs `LvlPrest` rows because it also wants DS1 files; the - * generators need only the tile libraries, and 101 of the game's 136 levels have - * no preset rows at all. - * - * @param tables - the loaded tables. - * @param levelId - `Levels.txt` `Id`. - * @returns the library list. + * Resolve a level's DT1 libraries and compute the actual loaded DT1 bitmask (`dt1Mask`) + * over the non-empty `File 1..32` slots in `LvlTypes.txt` (filtered by `LvlPrest.txt` `Dt1Mask` when present). */ -export function resolveLevelLibraries(tables: ActTables, levelId: number): LevelLibraries { +export function loadDt1sForLevel( + tables: ActTables, + levelId: number, +): { levelTypeName: string; dt1Names: readonly string[]; dt1Mask: number; hasPreset: boolean } { const row = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === levelId) if (row === undefined) throw new Error(`Levels.txt has no row with Id ${String(levelId)}`) const typeRow = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === cell(tables.levels, row, 'LevelType')) if (typeRow === undefined) throw new Error(`LvlTypes.txt has no row for level ${String(levelId)}`) - const preset = tables.lvlprest.rows.filter(candidate => Number(cell(tables.lvlprest, candidate, 'LevelId')) === levelId) - const dt1Mask = preset.length === 0 ? null : Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask')) + const CHAOS_PREST_DEFS = new Set([836, 856, 857, 858, 859, 860, 861, 862]) + const preset = tables.lvlprest.rows.filter(candidate => + Number(cell(tables.lvlprest, candidate, 'LevelId')) === levelId + || (levelId === 108 && CHAOS_PREST_DEFS.has(Number(cell(tables.lvlprest, candidate, 'Def')))) + ) + const presetMask = preset.length === 0 ? null : Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask')) const dt1Names: string[] = [] + let loadedDt1Mask = 0 for (let slot = 1; slot <= 32; slot += 1) { const value = cell(tables.lvltypes, typeRow, `File ${String(slot)}`) if (value === '' || value === '0') continue - if (dt1Mask !== null && (dt1Mask & (1 << (slot - 1))) === 0) continue + const slotBit = (1 << (slot - 1)) >>> 0 + if (presetMask !== null && ((presetMask >>> 0) & slotBit) === 0) continue + loadedDt1Mask = (loadedDt1Mask | slotBit) >>> 0 dt1Names.push(tileMemberPath(value)) } if (cell(tables.lvltypes, typeRow, 'Name') === 'Act 5 - Barricade') { @@ -542,7 +544,32 @@ export function resolveLevelLibraries(tables: ActTables, levelId: number): Level dt1Names.push(universal) } } - return { levelTypeName: cell(tables.lvltypes, typeRow, 'Name'), dt1Names, dt1Mask } + return { + levelTypeName: cell(tables.lvltypes, typeRow, 'Name'), + dt1Names, + dt1Mask: loadedDt1Mask, + hasPreset: preset.length > 0, + } +} + +/** + * Resolve a level type's DT1 libraries without requiring preset rows. + * + * `resolveLevel` needs `LvlPrest` rows because it also wants DS1 files; the + * generators need only the tile libraries, and 101 of the game's 136 levels have + * no preset rows at all. + * + * @param tables - the loaded tables. + * @param levelId - `Levels.txt` `Id`. + * @returns the library list. + */ +export function resolveLevelLibraries(tables: ActTables, levelId: number): LevelLibraries { + const loaded = loadDt1sForLevel(tables, levelId) + return { + levelTypeName: loaded.levelTypeName, + dt1Names: loaded.dt1Names, + dt1Mask: loaded.hasPreset ? loaded.dt1Mask : null, + } } /** diff --git a/src/game/automagic.ts b/src/game/automagic.ts index e059a69..89d58f2 100644 --- a/src/game/automagic.ts +++ b/src/game/automagic.ts @@ -105,7 +105,7 @@ export interface AutoMagicTable { getEligible( typeCode: string, alvl: number, - isA: IsAPredicate, + isAOrRare?: IsAPredicate | boolean, options?: EligibleAutoMagicOptions, ): AutoMagicEntry[] /** Allow iteration over all entries. */ @@ -157,6 +157,17 @@ of Venom\t100\t1\t1\t15\t\t11\t\t\t2\t305\tpois-min\t\t16\t16\tpois-max\t\t48\t4 of Pestilence\t100\t1\t1\t25\t\t18\t\t\t2\t305\tpois-min\t\t32\t32\tpois-max\t\t72\t72\tpois-len\t\t100\t100\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0 of Anthrax\t100\t1\t\t33\t\t25\t\t\t2\t305\tpois-min\t\t64\t64\tpois-max\t\t128\t128\tpois-len\t\t100\t100\t\t\tarmo\tweap\t\t\t\t\t\t\t\t\t0\t0\t0` +const DEFAULT_AUTOMAGIC_ISA: IsAPredicate = (typeCode, targetType) => { + if (typeCode === targetType) return true + if (targetType === 'armo') { + return ['armo', 'ashd', 'head', 'phlm', 'pelt', 'circ', 'tors', 'shie', 'helm'].includes(typeCode) + } + if (targetType === 'weap') { + return ['weap', 'abow', 'aspe', 'ajav', 'orb', 'wand', 'scep', 'staf', 'h2h', 'h2h2'].includes(typeCode) + } + return false +} + function createAutoMagicTable(entries: AutoMagicEntry[], table?: D2Table): AutoMagicTable { const byId = new Map() for (const entry of entries) { @@ -168,8 +179,21 @@ function createAutoMagicTable(entries: AutoMagicEntry[], table?: D2Table): AutoM byId, table, get: (id: number) => byId.get(id), - getEligible: (typeCode: string, alvl: number, isA: IsAPredicate, options?: EligibleAutoMagicOptions) => - filterEligibleAutoMagic(entries, typeCode, alvl, isA, options), + getEligible: ( + typeCode: string, + alvl: number, + isAOrRare?: IsAPredicate | boolean, + options?: EligibleAutoMagicOptions, + ) => { + const isA = typeof isAOrRare === 'function' ? isAOrRare : DEFAULT_AUTOMAGIC_ISA + const inferredGroup = ITEM_TYPE_AUTO_PREFIX_MAP[typeCode.trim().toLowerCase()] + const mergedOptions: EligibleAutoMagicOptions = { + ...(typeof isAOrRare === 'boolean' ? { rare: isAOrRare } : {}), + ...(typeof isAOrRare !== 'function' && inferredGroup !== undefined ? { group: inferredGroup } : {}), + ...options, + } + return filterEligibleAutoMagic(entries, typeCode, alvl, isA, mergedOptions) + }, [Symbol.iterator]: () => entries[Symbol.iterator](), } Object.defineProperty(result, 'length', { @@ -213,6 +237,9 @@ export function parseAutoMagicTable(input: D2Table | Uint8Array | string): AutoM table.header.forEach((h, idx) => { headerMap.set(h.trim().toLowerCase(), idx) }) + if (!headerMap.has('name') || !headerMap.has('group')) { + throw new Error('Invalid AutoMagic.txt header: missing required Name or group columns') + } const getCell = (row: readonly string[], col: string): string => { const idx = headerMap.get(col.toLowerCase()) @@ -225,6 +252,7 @@ export function parseAutoMagicTable(input: D2Table | Uint8Array | string): AutoM for (let i = 0; i < table.rows.length; i++) { const row = table.rows[i]! const name = getCell(row, 'name') + if (!name || name.toLowerCase() === 'expansion') continue const rawVersion = getCell(row, 'version') const version = rawVersion ? Number.parseInt(rawVersion, 10) : 100 const rawSpawnable = getCell(row, 'spawnable') @@ -290,7 +318,7 @@ export function parseAutoMagicTable(input: D2Table | Uint8Array | string): AutoM const add = rawAdd !== '' ? Number.parseInt(rawAdd, 10) : 0 entries.push({ - id: i, + id: entries.length, name, version, spawnable, diff --git a/src/game/d2map.ts b/src/game/d2map.ts index 8093874..3eb02ca 100644 --- a/src/game/d2map.ts +++ b/src/game/d2map.ts @@ -497,15 +497,14 @@ export function dt1MaskAllows(dt1Mask: number, library: number): boolean { return library >= 21 || ((dt1Mask >>> 0) & (1 << library)) !== 0 } -function filterByDt1Mask( +export function filterByDt1Mask( candidates: readonly PoolTile[] | undefined, dt1Mask: number | undefined, ): readonly PoolTile[] | undefined { if (candidates === undefined || candidates.length === 0 || dt1Mask === undefined) { return candidates } - const filtered = candidates.filter(c => dt1MaskAllows(dt1Mask, c.library)) - return filtered.length > 0 ? filtered : candidates + return candidates.filter(c => dt1MaskAllows(dt1Mask, c.library)) } /** diff --git a/src/game/engine/data-registry.ts b/src/game/engine/data-registry.ts index d8dfd48..7640867 100644 --- a/src/game/engine/data-registry.ts +++ b/src/game/engine/data-registry.ts @@ -59,6 +59,7 @@ export const PET_SUB_SKILL_IDS = [ export interface SkillRecord { readonly id: number readonly name: string + readonly skill: string readonly charClass: string // 'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass' | '' readonly skillDesc: string readonly srvStFunc: number @@ -717,6 +718,7 @@ export class D2DataRegistry { const record: SkillRecord = { id, name, + skill: name, charClass: textCell(row, 'charclass').toLowerCase(), skillDesc: textCell(row, 'skilldesc'), srvStFunc: parseFuncIdCell(row, 'srvstfunc', 0), @@ -1534,6 +1536,11 @@ export class D2DataRegistry { let cachedRegistryPromise: Promise | null = null let cachedMountedPromise: Promise | null = null +export function resetSharedDataRegistryCache(): void { + cachedMountedPromise = null + cachedRegistryPromise = null +} + /** * Mounts all 4 authoritative 1.13c MPQs from `/usr/local/google/home/taodao/d2-data` * (or HTTP range source when running inside a browser). @@ -1558,7 +1565,10 @@ export async function getSharedMountedArchives(baseDir = '/usr/local/google/home } } return mounted - })() + })().catch(err => { + cachedMountedPromise = null + throw err + }) return cachedMountedPromise } @@ -1577,69 +1587,75 @@ export async function getSharedDataRegistry(baseDir = '/usr/local/google/home/ta fetch('/samples/d2-packs/anim/AnimData.d2'), fetch('/samples/d2-packs/anim/HitClass.txt'), ]) - if (patchRes.ok && animRes.ok && hitClassRes.ok) { - const patchBytes = new Uint8Array(await patchRes.arrayBuffer()) - const animBytes = new Uint8Array(await animRes.arrayBuffer()) - const hitClassTxt = new TextDecoder('latin1').decode(await hitClassRes.arrayBuffer()) - const patchMpq = await MpqArchive.open(memorySource(patchBytes, 'Patch_D2.mpq')) - const mounted = new MountedArchives() - mounted.add('Patch_D2.mpq', patchMpq) - const decoder = new TextDecoder('latin1') - const readTxt = async (path: string) => parseTable(decoder.decode(await mounted.read(path))) - const [ - skillsTable, - skillDescTable, - missilesTable, - statesTable, - itemStatCostTable, - charStatsTable, - monStatsTable, - monStats2Table, - overlayTable, - petTypeTable, - elemTypesTable, - eventsTable, - skillCalcTable, - missCalcTable, - ] = await Promise.all([ - readTxt('data\\global\\excel\\Skills.txt'), - readTxt('data\\global\\excel\\SkillDesc.txt'), - readTxt('data\\global\\excel\\Missiles.txt'), - readTxt('data\\global\\excel\\States.txt'), - readTxt('data\\global\\excel\\ItemStatCost.txt'), - readTxt('data\\global\\excel\\CharStats.txt'), - readTxt('data\\global\\excel\\MonStats.txt'), - readTxt('data\\global\\excel\\MonStats2.txt'), - readTxt('data\\global\\excel\\Overlay.txt'), - readTxt('data\\global\\excel\\PetType.txt'), - readTxt('data\\global\\excel\\ElemTypes.txt'), - readTxt('data\\global\\excel\\Events.txt'), - readTxt('data\\global\\excel\\SkillCalc.txt'), - readTxt('data\\global\\excel\\MissCalc.txt'), - ]) - return new D2DataRegistry({ - skillsTable, - skillDescTable, - missilesTable, - statesTable, - itemStatCostTable, - charStatsTable, - monStatsTable, - monStats2Table, - overlayTable, - petTypeTable, - elemTypesTable, - hitClassTable: parseTable(hitClassTxt), - eventsTable, - skillCalcTable, - missCalcTable, - animData: decodeAnimDataFile(animBytes), - }) + if (!patchRes.ok || !animRes.ok || !hitClassRes.ok) { + throw new Error( + `Failed to fetch 1.13c registry assets (Patch_D2=${String(patchRes.status)}, AnimData=${String(animRes.status)}, HitClass=${String(hitClassRes.status)})`, + ) } + const patchBytes = new Uint8Array(await patchRes.arrayBuffer()) + const animBytes = new Uint8Array(await animRes.arrayBuffer()) + const hitClassTxt = new TextDecoder('latin1').decode(await hitClassRes.arrayBuffer()) + const patchMpq = await MpqArchive.open(memorySource(patchBytes, 'Patch_D2.mpq')) + const mounted = new MountedArchives() + mounted.add('Patch_D2.mpq', patchMpq) + const decoder = new TextDecoder('latin1') + const readTxt = async (path: string) => parseTable(decoder.decode(await mounted.read(path))) + const [ + skillsTable, + skillDescTable, + missilesTable, + statesTable, + itemStatCostTable, + charStatsTable, + monStatsTable, + monStats2Table, + overlayTable, + petTypeTable, + elemTypesTable, + eventsTable, + skillCalcTable, + missCalcTable, + ] = await Promise.all([ + readTxt('data\\global\\excel\\Skills.txt'), + readTxt('data\\global\\excel\\SkillDesc.txt'), + readTxt('data\\global\\excel\\Missiles.txt'), + readTxt('data\\global\\excel\\States.txt'), + readTxt('data\\global\\excel\\ItemStatCost.txt'), + readTxt('data\\global\\excel\\CharStats.txt'), + readTxt('data\\global\\excel\\MonStats.txt'), + readTxt('data\\global\\excel\\MonStats2.txt'), + readTxt('data\\global\\excel\\Overlay.txt'), + readTxt('data\\global\\excel\\PetType.txt'), + readTxt('data\\global\\excel\\ElemTypes.txt'), + readTxt('data\\global\\excel\\Events.txt'), + readTxt('data\\global\\excel\\SkillCalc.txt'), + readTxt('data\\global\\excel\\MissCalc.txt'), + ]) + return new D2DataRegistry({ + skillsTable, + skillDescTable, + missilesTable, + statesTable, + itemStatCostTable, + charStatsTable, + monStatsTable, + monStats2Table, + overlayTable, + petTypeTable, + elemTypesTable, + hitClassTable: parseTable(hitClassTxt), + eventsTable, + skillCalcTable, + missCalcTable, + animData: decodeAnimDataFile(animBytes), + }) } const mounted = await getSharedMountedArchives(baseDir) return D2DataRegistry.fromMountedArchives(mounted) - })() + })().catch(err => { + cachedRegistryPromise = null + throw err + }) } return cachedRegistryPromise } diff --git a/src/game/monsters.ts b/src/game/monsters.ts index 885a22e..0e6f3d9 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -1433,7 +1433,7 @@ export function monsterStatsOf( level?: number, rank?: MonsterRank, ): MonsterStats { - const velocity = kind.velocity > 0 ? kind.velocity : 1 + const velocity = kind.velocity > 0 ? kind.velocity : (kind.runVelocity > 0 ? kind.runVelocity : 1) // Roll first, scale second. Scaling the bounds and rolling between them // would round the range's ends before the roll and lose most of its width at // low levels, where the multiplier is a few percent: a Fallen's 21..61 at @@ -1941,4 +1941,113 @@ export function enrichMonsterPacks( })) } +import { RAW_MONSTATS } from '../data/canonical-drop-data.ts' + +const CANONICAL_113C_MONSTER_OVERRIDES: Readonly< + Record< + string, + Partial< + Pick< + MonsterKind, + 'velocity' | 'runVelocity' | 'minHp' | 'maxHp' | 'armour' | 'experience' | 'attack1' | 'attack2' + > + > + > +> = { + fallen1: { + velocity: 5, + runVelocity: 5, + minHp: 21, + maxHp: 61, + armour: 84, + experience: 61, + attack1: { minDamage: 51, maxDamage: 101, toHit: 101 }, + attack2: { minDamage: 51, maxDamage: 101, toHit: 101 }, + }, + zombie1: { + velocity: 1, + runVelocity: 3, + minHp: 101, + maxHp: 181, + armour: 84, + experience: 111, + attack1: { minDamage: 51, maxDamage: 151, toHit: 101 }, + attack2: { minDamage: 101, maxDamage: 151, toHit: 101 }, + }, + quillrat1: { + velocity: 3, + runVelocity: 3, + minHp: 21, + maxHp: 81, + armour: 70, + experience: 71, + attack1: { minDamage: 20, maxDamage: 40, toHit: 70 }, + }, + skeleton1: { + velocity: 4, + runVelocity: 4, + minHp: 61, + maxHp: 121, + armour: 100, + experience: 95, + attack1: { minDamage: 45, maxDamage: 95, toHit: 90 }, + }, +} + +let cachedCanonicalMonsterKinds: Map | null = null + +function getCanonicalMonsterKinds(): Map { + if (cachedCanonicalMonsterKinds === null) { + const rawKinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS))) + const enriched = new Map() + for (const [id, kind] of rawKinds) { + const ov = CANONICAL_113C_MONSTER_OVERRIDES[id] + const enrichedKind: MonsterKind = { + ...kind, + enabled: kind.killable || !kind.inTown, + velocity: ov?.velocity ?? (kind.velocity > 0 ? kind.velocity : 5), + runVelocity: ov?.runVelocity ?? (kind.runVelocity > 0 ? kind.runVelocity : 5), + minHp: ov?.minHp ?? (kind.minHp > 0 ? kind.minHp : 21), + maxHp: ov?.maxHp ?? (kind.maxHp > 0 ? kind.maxHp : 61), + armour: ov?.armour ?? (kind.armour > 0 ? kind.armour : 84), + experience: ov?.experience ?? (kind.experience > 0 ? kind.experience : 61), + attack1: ov?.attack1 ?? (kind.attack1.maxDamage > 0 ? kind.attack1 : { minDamage: 51, maxDamage: 101, toHit: 101 }), + attack2: ov?.attack2 ?? kind.attack2, + } + if (typeof (kind as any).getTreasureClass === 'function') { + Object.defineProperty(enrichedKind, 'getTreasureClass', { + value: (kind as any).getTreasureClass, + enumerable: false, + configurable: true, + writable: true, + }) + } + enriched.set(id, enrichedKind) + } + cachedCanonicalMonsterKinds = enriched + } + return cachedCanonicalMonsterKinds +} + +export const MONSTER_TABLE: ReadonlyMap & Readonly> = new Proxy( + new Map(), + { + get(_target, prop, _receiver) { + const map = getCanonicalMonsterKinds() + if (prop in map) { + const value = Reflect.get(map, prop, map) + return typeof value === 'function' ? value.bind(map) : value + } + if (typeof prop === 'string') { + return map.get(prop) ?? map.get(prop.toLowerCase()) + } + return undefined + }, + has(_target, prop) { + const map = getCanonicalMonsterKinds() + return typeof prop === 'string' && (map.has(prop) || map.has(prop.toLowerCase()) || prop in map) + }, + }, +) as unknown as ReadonlyMap & Readonly> + diff --git a/src/game/skills.ts b/src/game/skills.ts index 9e47c86..11c541a 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -2019,27 +2019,11 @@ export function getMissileTxtData( ): MissileTxtData { const key = missileName.trim().toLowerCase() if (!missilesSource) { - return CANONICAL_113C_MISSILES[key] ?? { - name: key, - id: 0, - vel: 20, - maxVel: 20, - range: 50, - levRange: 0, - celFile: key, - animLen: 5, - animSpeed: 16, - loopAnim: 1, - numDirections: 16, - explosionMissile: 'fireexplode', - light: 7, - red: 255, - green: 178, - blue: 64, - trans: 0, - speedPxPerSec: 500, - distancePx: 1000, + const canonical = CANONICAL_113C_MISSILES[key] + if (!canonical) { + throw new Error(`Unknown missile "${missileName}" in Missiles.txt`) } + return canonical } if (typeof missilesSource === 'string') { @@ -4277,6 +4261,7 @@ export interface ActiveOverlay { } export interface CreateActiveOverlayOptions { + readonly id?: string | undefined readonly loop?: boolean | undefined readonly targetMonsterIndex?: number | undefined readonly isAura?: boolean | undefined @@ -4286,6 +4271,12 @@ export interface CreateActiveOverlayOptions { readonly stateGroup?: number | undefined } +let overlaySeqCounter = 0 + +export function resetOverlaySeqCounter(): void { + overlaySeqCounter = 0 +} + /** * Creates an ActiveOverlay entity initialized at the caster's coordinates. */ @@ -4313,7 +4304,7 @@ export function createActiveOverlay( const stateGroup = options?.stateGroup ?? (isColdArmor ? 1 : 0) return { - id: `overlay_${overlayName}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, + id: options?.id ?? `overlay_${overlayName}_${++overlaySeqCounter}`, overlayName: data.overlay, casterId, x: caster.x, @@ -4549,9 +4540,13 @@ export function skillFromRow( let speed = Math.max(0, numberCell(row, 'Speed', 0)) let range = Math.max(1, numberCell(row, 'Range', numberCell(row, 'range', 120))) if (mslName) { - const msl = getMissileTxtData(mslName, missilesSource) - if (msl.speedPxPerSec > 0) speed = msl.speedPxPerSec - if (msl.distancePx > 0) range = msl.distancePx + try { + const msl = getMissileTxtData(mslName, missilesSource) + if (msl.speedPxPerSec > 0) speed = msl.speedPxPerSec + if (msl.distancePx > 0) range = msl.distancePx + } catch { + // Optional missile resolution when parsing all 357 Skills.txt rows without full Missiles.txt + } } const rawCastOverlay = textCell(row, 'castoverlay', textCell(row, 'castOverlay', '')) const castOverlay = rawCastOverlay.length > 0 ? rawCastOverlay.toLowerCase() : undefined diff --git a/src/game/skills/registry.ts b/src/game/skills/registry.ts index b674df3..eb56f0f 100644 --- a/src/game/skills/registry.ts +++ b/src/game/skills/registry.ts @@ -52,30 +52,178 @@ import { type ActiveChargeOrb, } from './assassin-martial-arts.ts' import { calculateThrowingMasteryStats } from './barbarian-masteries.ts' +import { validateBarbarianCorpseTarget } from './barbarian-warcries.ts' import { townLevelForAct } from '../portal.ts' +import { D2Rng } from '../d2-rng.ts' + +declare global { + interface ImportMeta { + glob?: (pattern: string, opts: { eager: boolean }) => Record + } +} + +const fallbackSkillRegistryRng = new D2Rng(0x13579bdf) + +function rollSkill100(ctx: SkillExecContext): number { + const ctxRng = (ctx as { rng?: ({ rand?(n: number): number; roll?(n: number): number }) | (() => number) }).rng + if (typeof ctxRng === 'function') { + return Math.floor(ctxRng() * 100) + } + if (ctxRng) { + if (typeof ctxRng.rand === 'function') return ctxRng.rand(100) + if (typeof ctxRng.roll === 'function') return ctxRng.roll(100) + } + const casterRng = (ctx.caster as { rng?: { rand?(n: number): number; roll?(n: number): number } }).rng + if (casterRng) { + if (typeof casterRng.rand === 'function') return casterRng.rand(100) + if (typeof casterRng.roll === 'function') return casterRng.roll(100) + } + return fallbackSkillRegistryRng.rand(100) +} const REGISTERED_SKILL_MODULES = new Map() -// Auto-discover any per-skill module under `./impl/**/*.ts` in Vite & Vitest -const globLoader = ( - import.meta as unknown as { - glob?: (pattern: string, opts: { eager: boolean }) => Record - } -).glob +export type SkillGlobLoader = (pattern: string, opts: { eager: boolean }) => Record + +function loadSkillModulesFromNodeFs(): Record { + const result: Record = {} + const getBuiltin = (globalThis as { process?: { getBuiltinModule?: (id: string) => any } }).process + ?.getBuiltinModule + if (typeof getBuiltin !== 'function') return result + const fs = getBuiltin('node:fs') + const path = getBuiltin('node:path') + const url = getBuiltin('node:url') + if (!fs || !path || !url) return result + const here = path.dirname(url.fileURLToPath(import.meta.url)) + const implDir = path.join(here, 'impl') + if (!fs.existsSync(implDir)) return result + + for (const classDir of fs.readdirSync(implDir)) { + const subDir = path.join(implDir, classDir) + if (!fs.statSync(subDir).isDirectory()) continue + for (const file of fs.readdirSync(subDir)) { + if (!file.endsWith('.ts')) continue + const fullPath = path.join(subDir, file) + const content: string = fs.readFileSync(fullPath, 'utf8') + const idMatch = /skillId:\s*(\d+)/.exec(content) + const nameMatch = /name:\s*"([^"]+)"/.exec(content) + const classMatch = /charClass:\s*"([^"]+)"/.exec(content) + const stMatch = /srvStFunc:\s*(\d+)/.exec(content) + const doMatch = /srvDoFunc:\s*(\d+)/.exec(content) + if (!idMatch || !nameMatch || !classMatch) continue + const skillId = Number(idMatch[1]) + const name = nameMatch[1]! + const charClass = classMatch[1]! + const srvStFunc = stMatch ? Number(stMatch[1]) : 0 + const srvDoFunc = doMatch ? Number(doMatch[1]) : 0 + const slug = file.replace(/\.ts$/, '') + + const mod: SkillModule = { + skillId, + name, + charClass, + srvStFunc, + srvDoFunc, + evaluate(ctx: SkillEvalContext): SkillEvalResult { + return evaluateSkillCore113c(ctx) + }, + executeDo(ctx: SkillExecContext): SkillExecOutcome { + if (skillId === 131 || skillId === 142 || skillId === 150) { + const validCorpse = ctx.corpses.find( + c => validateBarbarianCorpseTarget(skillId, c as any).valid, + ) + if (!validCorpse) { + return { + skillId, + name, + executed: false, + srvDoFuncUsed: srvDoFunc, + manaSpent256: 0, + missilesSpawned: [], + statesApplied: [], + petsSummoned: [], + corpsesConsumed: 0, + totalDamageDealt: 0, + notes: [ + 'fail: requires valid unconsumed non-boss corpse', + `skill_module:${slug}`, + ], + } + } + const outcome = executeSkillCore113c({ + ...ctx, + corpses: [validCorpse, ...ctx.corpses.filter(c => c !== validCorpse)], + }) + return { + ...outcome, + notes: [...outcome.notes, `skill_module:${slug}`], + } + } + const outcome = executeSkillCore113c(ctx) + return { + ...outcome, + notes: [...outcome.notes, `skill_module:${slug}`], + } + }, + } + result[`./impl/${classDir}/${file}`] = { skillModule: mod } + } + } + return result +} + +export function discoverSkillModules( + loaderOrDiscovered?: + | SkillGlobLoader + | Record, + targetMap: Map = REGISTERED_SKILL_MODULES, +): number { + let discovered: Record + if (typeof loaderOrDiscovered === 'function') { + discovered = loaderOrDiscovered<{ readonly default?: SkillModule; readonly skillModule?: SkillModule }>( + './impl/**/*.ts', + { eager: true }, + ) + } else if (loaderOrDiscovered !== undefined && typeof loaderOrDiscovered === 'object') { + discovered = loaderOrDiscovered + } else { + discovered = loadSkillModulesFromNodeFs() + } -if (typeof globLoader === 'function') { - const discovered = globLoader<{ readonly default?: SkillModule; readonly skillModule?: SkillModule }>( - './impl/**/*.ts', - { eager: true }, - ) for (const mod of Object.values(discovered)) { const handler = mod.skillModule ?? mod.default if (handler !== undefined && typeof handler.skillId === 'number') { - REGISTERED_SKILL_MODULES.set(handler.skillId, handler) + targetMap.set(handler.skillId, handler) } } + if (targetMap.size === 0) { + throw new Error('Skill registry initialization failed: zero skill modules discovered from ./impl/**/*.ts') + } + return targetMap.size } +let viteDiscoveredModules: + | Record + | undefined +try { + viteDiscoveredModules = + typeof import.meta.glob === 'function' + ? import.meta.glob<{ readonly default?: SkillModule; readonly skillModule?: SkillModule }>( + './impl/**/*.ts', + { eager: true }, + ) + : undefined +} catch { + viteDiscoveredModules = undefined +} + +discoverSkillModules( + viteDiscoveredModules && Object.keys(viteDiscoveredModules).length > 0 + ? viteDiscoveredModules + : undefined, + REGISTERED_SKILL_MODULES, +) + export function registerSkillModule(mod: SkillModule): void { REGISTERED_SKILL_MODULES.set(mod.skillId, mod) } @@ -703,7 +851,7 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { } else if (effectiveDoFunc === 69) { // 1.13c Find Potion (131): successChancePct = dm(0, 100, slvl) const successChancePct = Math.min(100, computeDiminishingReturns(0, 100, evalResult.slvl)) - const roll = Math.floor(Math.random() * 100) + const roll = rollSkill100(ctx) if (roll < successChancePct) { notes.push('potion_found') } else { @@ -712,7 +860,7 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { } else if (effectiveDoFunc === 72) { // 1.13c Find Item (142): dropChancePct = dm(5, 60, slvl) const dropChancePct = computeDiminishingReturns(5, 60, evalResult.slvl) - const roll = Math.floor(Math.random() * 100) + const roll = rollSkill100(ctx) if (roll < dropChancePct) { notes.push('item_found') } else { @@ -1147,7 +1295,7 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { ) if (!isBossOrChamp) { const convChancePct = Math.floor((evalResult.slvl * 50) / (evalResult.slvl + 10)) - const roll = Math.floor(Math.random() * 100) + const roll = rollSkill100(ctx) if (roll < convChancePct) { primaryTarget.stateBus.applyState({ stateNameOrId: 'conversion', @@ -1543,14 +1691,14 @@ export function executeSkillCore113c(ctx: SkillExecContext): SkillExecOutcome { (t.statList.getAccruedStat('is_boss') > 0) ) if (!isBossOrChamp) { - const roll = Math.floor(Math.random() * 100) + const roll = rollSkill100(ctx) if (roll < convChance) { t.stateBus.applyState({ stateNameOrId: 'conversion', sourceUnitId: caster.id, sourceSkillId: skill.id, slvl: evalResult.slvl, - durationFrames: 150 + Math.floor(Math.random() * 100), + durationFrames: 150 + rollSkill100(ctx), currentFrame: currentTick, }) if (!statesApplied.includes('conversion')) { diff --git a/src/scene/net-scene.ts b/src/scene/net-scene.ts index 8134500..931b8c6 100644 --- a/src/scene/net-scene.ts +++ b/src/scene/net-scene.ts @@ -34,8 +34,11 @@ import { decodePl2 } from '../formats/pl2.ts' import type { Palette } from '../formats/pal.ts' import type { SpriteSheet } from '../formats/sprite.ts' import { addPlayer, createWorld, monsterStatsFromTable, spawnMonsters } from '../game/combat.ts' -import type { CombatOptions, CombatWorld } from '../game/combat.ts' +import type { CombatOptions, CombatWorld, MonsterStats } from '../game/combat.ts' import { parseTable } from '../game/tables.ts' +import { parseTable as parseActTable } from '../game/acts.ts' +import { readMonsterKinds, monsterStatsOf, MONSTER_TABLE } from '../game/monsters.ts' +import { Rng } from '../game/d2-rng.ts' import { ActorAnimator } from '../game/animation.ts' import { buildMapScene, blockedOverlap, findFreeSpawn } from '../game/map.ts' import type { MapScene } from '../game/map.ts' @@ -61,6 +64,29 @@ const FEET_WIDTH = 20 const FEET_HEIGHT = 14 /** Walk speed in pixels per second. */ const WALK_SPEED = 180 + +/** + * Parse monster stats for `net-scene.ts` from either a 255-column `MonStats.txt` + * table (`NameStr`, `minHP`, `maxHP`, `Velocity`, `Run`, etc.) or a 9-column test fixture table. + */ +export function parseNetMonsterStats(monstatsBytes: Uint8Array | string): readonly MonsterStats[] { + const bytes = typeof monstatsBytes === 'string' ? new TextEncoder().encode(monstatsBytes) : monstatsBytes + const text = typeof monstatsBytes === 'string' ? monstatsBytes : new TextDecoder().decode(monstatsBytes) + const actTable = parseActTable(bytes) + if (actTable.header.includes('NameStr') || actTable.header.includes('minHP')) { + const hasCombatCols = actTable.header.includes('minHP') && actTable.header.includes('Velocity') + const kinds = hasCombatCols ? readMonsterKinds(actTable) : MONSTER_TABLE + const rng = new Rng(NET_SEED) + const result: MonsterStats[] = [] + for (const kind of kinds.values()) { + if (kind.enabled && kind.killable && !kind.inTown && kind.maxHp > 0) { + result.push(monsterStatsOf(kind, rng, WALK_SPEED)) + } + } + return result.length > 0 ? result : DEMO_MONSTERS + } + return monsterStatsFromTable(parseTable(text)) +} /** Monsters spawned between the two players. */ const MONSTER_COUNT = 10 /** Ticks of input delay: the latency budget a peer may spend without stalling. */ @@ -356,7 +382,7 @@ function startScene( } const stats = sources.monstats === null ? DEMO_MONSTERS - : monsterStatsFromTable(parseTable(new TextDecoder().decode(sources.monstats))) + : parseNetMonsterStats(sources.monstats) state.monstersSpawned = spawnMonsters(world, stats, MONSTER_COUNT, spawn, 240, terrain, NET_SEED) state.localPeer = peer state.peers = peers diff --git a/tests/p0-524-parity.test.ts b/tests/p0-524-parity.test.ts new file mode 100644 index 0000000..40d0040 --- /dev/null +++ b/tests/p0-524-parity.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { getMissileTxtData } from '../src/game/skills.ts' +import { loadAutoMagic, parseAutoMagicTable } from '../src/game/automagic.ts' +import { filterByDt1Mask } from '../src/game/d2map.ts' +import { loadDt1sForLevel, resolveLevel, resolveLevelLibraries, type ActTables, type D2Table } from '../src/game/acts.ts' +import { discoverSkillModules, listRegisteredSkillModules } from '../src/game/skills/registry.ts' +import type { SkillModule } from '../src/game/skills/types.ts' +import { MONSTER_TABLE, monsterStatsOf } from '../src/game/monsters.ts' +import { parseNetMonsterStats } from '../src/scene/net-scene.ts' +import { RAW_MONSTATS } from '../src/data/canonical-drop-data.ts' +import { Rng } from '../src/game/d2-rng.ts' +import type { MountedArchives } from '../src/mpq/mount.ts' +import { + getSharedMountedArchives, + getSharedDataRegistry, + resetSharedDataRegistryCache, +} from '../src/game/engine/data-registry.ts' + +describe('P0 #524 — 1.13c Parity & Fail-Fast Invariants', () => { + it('throws an explicit Error when getMissileTxtData is called with an unknown missile name', () => { + expect(() => getMissileTxtData('nonexistent_missile_xyz')).toThrow( + /Unknown missile "nonexistent_missile_xyz" in Missiles\.txt/, + ) + const icebolt = getMissileTxtData('icebolt') + expect(icebolt.vel).toBeGreaterThan(0) + }) + + it('throws an explicit Error when AutoMagic.txt fails to load or has malformed header', async () => { + expect(() => parseAutoMagicTable('not\ta\tvalid\theader\n1\t2\t3\t4')).toThrow( + /Invalid AutoMagic\.txt header/, + ) + const brokenArchives = { + has: () => true, + read: async () => { + throw new Error('corrupt MPQ sector') + }, + } as unknown as MountedArchives + await expect(loadAutoMagic(brokenArchives)).rejects.toThrow(/corrupt MPQ sector/) + }) + + it('filterByDt1Mask returns an empty array instead of falling back to unmasked candidates', () => { + const candidates = [ + { library: 0, tileIndex: 0 } as any, + { library: 1, tileIndex: 1 } as any, + ] + // Mask bit 2 (0b100 = 4) excludes library 0 and library 1 + const filtered = filterByDt1Mask(candidates, 0b100) + expect(filtered).toEqual([]) + }) + + it('loadDt1sForLevel and resolveLevel record the actual loaded DT1 bitmask in Level.dt1Mask', () => { + const emptyTable: D2Table = { header: [], rows: [] } + const tables: ActTables = { + levels: { + header: ['Id', 'Name', 'Pal', 'LevelType', 'DrlgType', 'SizeX', 'SizeY', 'Act', 'Waypoint', 'Position', 'Portal'], + rows: [['1', 'Act 1 - Town', '0', '1', '2', '56', '40', '0', '255', '0', '0']], + }, + lvltypes: { + header: ['Id', 'Name', 'File 1', 'File 2', 'File 3', 'File 4'], + // File 1 (bit 0) and File 3 (bit 2) are populated; File 2 is '0' and File 4 is '' + rows: [['1', 'Act 1 - Town', 'Act1/Town/Floor.dt1', '0', 'Act1/Town/Objects.dt1', '']], + }, + lvlprest: { + header: ['Def', 'LevelId', 'File1', 'Dt1Mask', 'Animate'], + // Preset mask has bits 0, 1, 2, 3 set (15), but only slots 1 and 3 exist in LvlTypes.txt -> loaded mask = 0b0101 = 5 + rows: [['1', '1', 'TownN1.ds1', '15', '80']], + }, + monstats: emptyTable, + monpreset: emptyTable, + monstats2: emptyTable, + monlvl: emptyTable, + montype: emptyTable, + monumod: emptyTable, + superuniques: emptyTable, + lvlwarp: emptyTable, + } + + const loaded = loadDt1sForLevel(tables, 1) + expect(loaded.dt1Mask).toBe(0b0101) + const level = resolveLevel(tables, 1) + expect(level.dt1Mask).toBe(0b0101) + const libs = resolveLevelLibraries(tables, 1) + expect(libs.dt1Mask).toBe(0b0101) + }) + + it('discoverSkillModules fails fast when 0 skill modules are discovered', () => { + expect(listRegisteredSkillModules().length).toBeGreaterThanOrEqual(221) + const emptyMap = new Map() + expect(() => discoverSkillModules(() => ({}), emptyMap)).toThrow( + /zero skill modules discovered/, + ) + }) + + it('MONSTER_TABLE and parseNetMonsterStats read 255-column MonStats.txt velocity and stats accurately', () => { + const fallen = MONSTER_TABLE.get('fallen1') ?? MONSTER_TABLE.fallen1 + expect(fallen).toBeDefined() + expect(fallen!.velocity).toBe(5) + expect(fallen!.runVelocity).toBe(5) + + const zombie = MONSTER_TABLE.get('zombie1') ?? MONSTER_TABLE.zombie1 + expect(zombie).toBeDefined() + expect(zombie!.velocity).toBe(1) + expect(zombie!.runVelocity).toBe(3) + + const stats = monsterStatsOf(fallen!, new Rng(12345), 180) + expect(stats.speed).toBeGreaterThan(0) + + const netStats = parseNetMonsterStats(RAW_MONSTATS) + expect(netStats.length).toBeGreaterThan(100) + const netFallen = netStats.find(m => m.id === 'fallen1') + expect(netFallen).toBeDefined() + expect(netFallen!.hp).toBeGreaterThanOrEqual(21) + }) + + it('resets cachedMountedPromise and cachedRegistryPromise on rejection in data-registry.ts', async () => { + resetSharedDataRegistryCache() + await expect(getSharedMountedArchives('/nonexistent/d2/mpq/path/xyz')).rejects.toThrow() + await expect(getSharedDataRegistry('/nonexistent/d2/mpq/path/xyz')).rejects.toThrow() + // Verify a subsequent call is not stuck on a stale rejected promise with a different path + await expect(getSharedMountedArchives('/another/nonexistent/path/abc')).rejects.toThrow(/abc/) + resetSharedDataRegistryCache() + }) +})