From 064e956de612999ffb263801ba1001fb55d47e9c Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 12:54:21 +0000 Subject: [PATCH] =?UTF-8?q?feat(items):=20=E7=94=A8=20TC=20=E6=A0=91?= =?UTF-8?q?=E6=9B=BF=E6=8D=A2=20rollDrop=20=E5=B9=B6=E5=BD=BB=E5=BA=95?= =?UTF-8?q?=E5=88=A0=E9=99=A4=20demo=20=E7=89=A9=E5=93=81=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=20(Issue=20#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/game/combat.ts | 30 +- src/game/demo-data.ts | 13 - src/game/drop-pipeline.ts | 596 ++++++++++++++++++++++++++++++++++++ src/game/engine.ts | 81 ++++- src/game/item-upgrade.ts | 6 + src/game/monsters.ts | 85 ++++- src/game/treasure-class.ts | 36 +++ src/game/treasure-engine.ts | 6 + src/scene/act-scene.ts | 21 +- tests/drop-pipeline.test.ts | 309 +++++++++++++++++++ 10 files changed, 1141 insertions(+), 42 deletions(-) create mode 100644 src/game/drop-pipeline.ts create mode 100644 tests/drop-pipeline.test.ts diff --git a/src/game/combat.ts b/src/game/combat.ts index 02aa5ce..7c66445 100644 --- a/src/game/combat.ts +++ b/src/game/combat.ts @@ -113,6 +113,8 @@ export interface MonsterStats { readonly modifiers?: readonly string[] /** SuperUnique identifier if this is a fixed SuperUnique boss (e.g. 'Bishibosh'). */ readonly superUniqueId?: string + /** Monster level for drops and scaling. */ + readonly level?: number } /** One monster's mutable state. */ @@ -185,6 +187,14 @@ export interface CombatEvent { readonly text?: string /** Subject identity (a monster's table id on a kill, for quest counters). */ readonly subjectId?: string + /** Monster level when event is 'kill'. */ + readonly monsterLevel?: number | undefined + /** Monster rank when event is 'kill'. */ + readonly monsterRank?: ('normal' | 'champion' | 'unique' | 'minion') | undefined + /** Monster type code (1=normal, 2=champion, 3=unique, 4=boss). */ + readonly monsterType?: number | undefined + /** SuperUnique ID if applicable. */ + readonly superUniqueId?: string | undefined } /** The whole simulation state. */ @@ -629,6 +639,22 @@ function moveWithCollision( * Defaults to the local player, which is what a single-player caller means. * @returns true when this damage killed it. */ +function buildKillEvent(monster: Monster): CombatEvent { + const rank = monster.stats.rank ?? 'normal' + const monsterType = rank === 'unique' ? 3 : rank === 'champion' ? 2 : 1 + return { + kind: 'kill', + x: monster.x, + y: monster.y, + text: monster.stats.name, + subjectId: monster.stats.id, + monsterLevel: monster.stats.level, + monsterRank: monster.stats.rank, + monsterType, + superUniqueId: monster.stats.superUniqueId, + } +} + export function damageMonster(world: CombatWorld, monsterIndex: number, amount: number, attacker: CombatPlayer = world.player): boolean { const monster = world.monsters[monsterIndex] if (monster === undefined || monster.state === 'dead') return false @@ -639,7 +665,7 @@ export function damageMonster(world: CombatWorld, monsterIndex: number, amount: monster.state = 'dead' monster.corpseTicks = 100 world.kills += 1 - world.events.push({ kind: 'kill', x: monster.x, y: monster.y, text: monster.stats.name, subjectId: monster.stats.id }) + world.events.push(buildKillEvent(monster)) attacker.xp += monster.stats.xp return true } @@ -775,7 +801,7 @@ function tickPlayer( target.state = 'dead' target.corpseTicks = 100 world.kills += 1 - world.events.push({ kind: 'kill', x: target.x, y: target.y, text: target.stats.name, subjectId: target.stats.id }) + world.events.push(buildKillEvent(target)) player.xp += target.stats.xp // Level up while the threshold is crossed; the table is cumulative. while (player.level + 1 < xpTable.length && player.xp >= (xpTable[player.level + 1] ?? Number.POSITIVE_INFINITY)) { diff --git a/src/game/demo-data.ts b/src/game/demo-data.ts index d5bac70..dafafc6 100644 --- a/src/game/demo-data.ts +++ b/src/game/demo-data.ts @@ -1,4 +1,3 @@ -import type { ItemBase, Affix } from './items' import type { SkillDef } from './skills' import type { NpcDef, QuestDef } from './quests' @@ -11,18 +10,6 @@ import type { NpcDef, QuestDef } from './quests' /** Fallback experience thresholds, index 1 = level 1. */ export const DEMO_EXPERIENCE: readonly number[] = [0, 0, 20, 60, 140, 280] -/** Fallback item bases and affixes, used when an archive ships no item tables. */ -export const DEMO_BASES: readonly ItemBase[] = [ - { id: 'demo-sword', name: 'Sword', kind: 'weapon', invWidth: 1, invHeight: 3, maxStack: 1, value: 40, damage: 6, defense: 0, tags: ['weap'], level: 1 }, - { id: 'demo-cap', name: 'Cap', kind: 'armor', invWidth: 2, invHeight: 2, maxStack: 1, value: 30, damage: 0, defense: 5, tags: ['armo'], level: 1 }, - { id: 'demo-potion', name: 'Potion', kind: 'misc', invWidth: 1, invHeight: 1, maxStack: 5, value: 20, damage: 0, defense: 0, tags: ['misc'], level: 1 }, -] - -export const DEMO_AFFIXES: readonly Affix[] = [ - { id: 'demo-cruel', name: 'Cruel', kind: 'prefix', level: 3, itemTypes: ['weap'], modifiers: [{ stat: 'maxdamage', min: 3, max: 8 }] }, - { id: 'demo-might', name: 'of Might', kind: 'suffix', level: 2, itemTypes: [], modifiers: [{ stat: 'strength', min: 1, max: 4 }] }, -] - /** Fallback skills, used when an archive ships no skill table. */ export const DEMO_SKILLS: readonly SkillDef[] = [ { id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 10, range: 48, projectile: false, speed: 0, baseMinDamage: 4, baseMaxDamage: 6, damagePerLevel: 1, radius: 20 }, diff --git a/src/game/drop-pipeline.ts b/src/game/drop-pipeline.ts new file mode 100644 index 0000000..bda7227 --- /dev/null +++ b/src/game/drop-pipeline.ts @@ -0,0 +1,596 @@ +/** + * 1.13c Diablo II Item Drop Pipeline. + * + * Complete implementation of the real TreasureClassEx drop tree, replacing + * synthetic rollDrop and deleting demo item bases and affixes. + * + * Gold standard entry flow (D2Game!6FC32D60): + * - nLevel = monster.level + * - tcName = monStats.getTreasureClass(difficulty, monsterType) + * - Top-level TC group upgrade: resolveTreasureClassGroup(tcTable, tcName, nLevel) + * - Expand TC tree using expandTreasureClass (from rollTreasureClass) + * - For each rolled leaf item: + * * Gold: calculate scaled gold amount and emit goldItem(amount). + * * Base item: + * - Lookup base in weapons/armor/misc via dropTables.getBase(code). + * - Base upgrade: upgradeItemBase(base, qf4, qf5, rng, dropTables.getBase). + * - Init item seed: dwInitSeed = (rng.rand(0xFFFFFFFF) >>> 0), itemRng = new D2Rng(dwInitSeed). + * - ItemRatio quality roll using rollItemQuality. + * - Quality branches: + * * Unique: lookup eligible enabled uniques (lvl <= nLevel). Fallback if empty: Rare + 3x durability. + * * Set: lookup eligible set items (lvl <= nLevel). Fallback if empty: Magic + 2x durability. + * * Rare: rollRareAffixes. + * * Magic: rollMagicAffixes. + * * Superior / Normal / Low: standard base. + */ + +import type { MountedArchives } from '../mpq/mount.ts' +import { parseTable } from './acts.ts' +import type { D2Table } from './acts.ts' +import { D2Rng } from './d2-rng.ts' +import { Rng } from './rng.ts' +import { + type TreasureClassTable, + type TreasureClassNode, + loadTreasureClasses, + resolveTreasureClassGroup, +} from './treasure-class.ts' +import { + expandTreasureClass, + type DroppedItemEntry, +} from './treasure-engine.ts' +import { + type WeaponTable, + loadWeapons, +} from './weapons.ts' +import { + type ArmorTable, + loadArmor, +} from './armor.ts' +import { + type MiscTable, + loadMisc, +} from './misc-items.ts' +import { + type ItemRatioTable, + loadItemRatio, + rollItemQuality, + type ItemQualityTier, + QUALITY_TIER_TO_ITEM_QUALITY, +} from './item-ratio.ts' +import { + type DifficultyLevelTable, + loadDifficultyLevels, + DEFAULT_DIFFICULTY_LEVEL_ODDS, + upgradeItemBase, +} from './item-upgrade.ts' +import { + type UniqueItemsTable, + loadUniqueItems, + type UniqueItem, +} from './unique-items.ts' +import { + type SetTable, + type SetItemTable, + loadSetData, + type SetItem, +} from './set-items.ts' +import { + type AffixTable, + loadMagicAffixes, +} from './affixes.ts' +import { + type RareNameTable, + loadRareNames, +} from './rare-names.ts' +import { + type ItemTypeTable, + loadItemTypes, + isA, +} from './item-types.ts' +import { + generateAutoTreasureClasses, + autoTcToTreasureClassNode, +} from './auto-tc.ts' +import { + type Item, + type ItemBase, + ItemQuality, + goldItem, + ItemFlag, + decodeItemFlags, + formatItemCode, +} from './items.ts' +import { + type MonsterKind, + readMonsterKinds, + getMonsterTreasureClass, + type Difficulty, + readSuperUniques, + type SuperUnique, +} from './monsters.ts' +import { + rollMagicAffixes, + rollRareAffixes, + type GeneratedAffixes, + type GeneratedRareAffixes, +} from './affix-generator.ts' + +/** + * Complete collection of parsed game data tables required for the drop pipeline. + */ +export interface DropTables { + readonly tcTable: TreasureClassTable + readonly autoTcTable: Map + readonly weapons: WeaponTable + readonly armor: ArmorTable + readonly misc: MiscTable + readonly itemRatio: ItemRatioTable + readonly difficultyLevels: DifficultyLevelTable + readonly uniques: UniqueItemsTable + readonly sets: { readonly items: SetItemTable; readonly sets: SetTable } + readonly magicAffixes: { readonly prefixes: AffixTable; readonly suffixes: AffixTable } + readonly rareNames: { readonly prefixes: RareNameTable; readonly suffixes: RareNameTable } + readonly itemTypes: ItemTypeTable + readonly monsterKinds: Map + readonly superUniques: Map + readonly isA: (type: string, target: string) => boolean + readonly getBase: (code: string) => ItemBase | undefined +} + +/** + * Loads all data tables required by the drop pipeline from mounted MPQ archives. + * Fails loudly with an Error if required files or tables are missing. + * + * @param archives - Mounted MPQ archives. + * @returns Promise resolving to DropTables. + */ +export async function loadDropTables(archives: MountedArchives): Promise { + const [ + tcTable, + weapons, + armor, + misc, + itemRatio, + difficultyLevels, + uniques, + sets, + magicAffixes, + rareNames, + itemTypes, + monstatsBytes, + ] = await Promise.all([ + loadTreasureClasses(archives), + loadWeapons(archives), + loadArmor(archives), + loadMisc(archives), + loadItemRatio(archives), + loadDifficultyLevels(archives), + loadUniqueItems(archives), + loadSetData(archives), + loadMagicAffixes(archives), + loadRareNames(archives), + loadItemTypes(archives), + archives.read('data\\global\\excel\\monstats.txt'), + ]) + + const rawAutoTcTable = generateAutoTreasureClasses(itemTypes, armor, weapons) + const autoTcTable = new Map() + for (const [key, node] of rawAutoTcTable) { + autoTcTable.set(key, autoTcToTreasureClassNode(node)) + } + + const isAPredicate = (type: string, target: string): boolean => isA(type, target, itemTypes) + + const getBase = (code: string): ItemBase | undefined => { + return weapons.get(code) ?? armor.get(code) ?? misc.get(code) + } + + const monstatsTable: D2Table = parseTable(monstatsBytes) + const monsterKinds = readMonsterKinds(monstatsTable) + + const superUniques = new Map() + try { + const suBytes = await archives.read('data\\global\\excel\\SuperUniques.txt') + const suList = readSuperUniques(parseTable(suBytes)) + for (const su of suList) { + superUniques.set(su.id, su) + } + } catch { + // If SuperUniques.txt is absent, keep map empty + } + + return { + tcTable, + autoTcTable, + weapons, + armor, + misc, + itemRatio, + difficultyLevels, + uniques, + sets, + magicAffixes, + rareNames, + itemTypes, + monsterKinds, + superUniques, + isA: isAPredicate, + getBase, + } +} + +/** + * Options for executing the TreasureClass drop pipeline. + */ +export interface DropPipelineOptions { + /** Top-level TreasureClass name (e.g. 'Act 1 H2H A', 'Act 1 Champ A'). */ + readonly tcName: string + /** Monster level (or area level) used for group upgrade and ilvl. */ + readonly nLevel: number + /** Monster type: 1 = Normal, 2 = Champion, 3 = Unique, 4 = Boss / Quest. */ + readonly monsterType?: number | undefined + /** Monster drop RNG instance or seed. */ + readonly monsterRng?: D2Rng | Rng | number | undefined + /** Player Magic Find percentage (e.g. 50 = +50% MF). */ + readonly playerMf?: number | undefined + /** Game difficulty. */ + readonly difficulty?: Difficulty | undefined + /** Total players in game (for NoDrop scaling). */ + readonly gamePlayers?: number | undefined + /** Nearby party members (for NoDrop scaling). */ + readonly partyPlayers?: number | undefined + /** Whether Expansion items and runes are enabled (default: true). */ + readonly isExpansion?: boolean | undefined + /** Parsed drop tables. */ + readonly dropTables: DropTables +} + +/** + * Creates a concrete Item instance for a dropped leaf item. + */ +export function createDroppedItem( + base: ItemBase, + qualityTier: ItemQualityTier, + options: { + readonly uniqueItem?: UniqueItem | undefined + readonly setItem?: SetItem | undefined + readonly magicAffixes?: GeneratedAffixes | undefined + readonly rareAffixes?: GeneratedRareAffixes | undefined + readonly ilvl: number + readonly dwInitSeed: number + readonly durabilityMultiplier?: number | undefined + }, +): Item { + const quality = QUALITY_TIER_TO_ITEM_QUALITY[qualityTier] ?? ItemQuality.NORMAL + const rawFlags = ItemFlag.IDENTIFIED + + let name = base.name + let prefix = null + let suffix = null + const stats: Record = {} + + if (base.damage > 0) stats.damage = base.damage + if (base.defense > 0) stats.defense = base.defense + + if (qualityTier === 'unique' && options.uniqueItem) { + name = options.uniqueItem.index + for (const prop of options.uniqueItem.props) { + if (prop.code) { + stats[prop.code] = (stats[prop.code] ?? 0) + prop.min + } + } + } else if (qualityTier === 'set' && options.setItem) { + name = options.setItem.index + for (const prop of options.setItem.props) { + if (prop.code) { + stats[prop.code] = (stats[prop.code] ?? 0) + prop.min + } + } + } else if (qualityTier === 'rare' && options.rareAffixes) { + name = options.rareAffixes.name + for (const affix of options.rareAffixes.affixes) { + for (const mod of affix.mods) { + stats[mod.code] = (stats[mod.code] ?? 0) + mod.value + } + } + } else if (qualityTier === 'magic' && options.magicAffixes) { + name = options.magicAffixes.name + if (options.magicAffixes.prefix) { + const p = options.magicAffixes.prefix + prefix = { + id: p.affix.name, + name: p.name, + kind: 'prefix' as const, + level: p.level, + itemTypes: p.affix.itypes, + modifiers: p.mods.map(m => ({ stat: m.code, min: m.min, max: m.max })), + } + for (const mod of p.mods) { + stats[mod.code] = (stats[mod.code] ?? 0) + mod.value + } + } + if (options.magicAffixes.suffix) { + const s = options.magicAffixes.suffix + suffix = { + id: s.affix.name, + name: s.name, + kind: 'suffix' as const, + level: s.level, + itemTypes: s.affix.itypes, + modifiers: s.mods.map(m => ({ stat: m.code, min: m.min, max: m.max })), + } + for (const mod of s.mods) { + stats[mod.code] = (stats[mod.code] ?? 0) + mod.value + } + } + } else if (qualityTier === 'superior') { + name = `Superior ${base.name}` + } else if (qualityTier === 'low') { + name = `Cracked ${base.name}` + } + + const baseDurability = 20 + const mult = options.durabilityMultiplier ?? 1 + const maxDurability = Math.round(baseDurability * mult) + + return { + base, + prefix, + suffix, + level: options.ilvl, + ilvl: options.ilvl, + name, + stats, + invWidth: base.invWidth, + invHeight: base.invHeight, + stack: 1, + value: base.value, + rawFlags, + flags: decodeItemFlags(rawFlags), + version: 0x65, + code: formatItemCode(base.id), + quality, + rarity: qualityTier, + uniqueId: options.dwInitSeed, + durability: maxDurability, + maxDurability, + ...(options.uniqueItem ? { uniqueId: options.dwInitSeed } : {}), + ...(options.setItem ? { setId: options.setItem.id } : {}), + } +} + +/** + * Executes the full TreasureClass drop pipeline for a slain monster or container. + * + * @param options - Drop pipeline parameters. + * @returns Array of dropped Item instances (including gold items). + */ +export function executeDropPipeline( + tablesOrOptions: DropTables | DropPipelineOptions, + maybeOptions?: Omit, +): Item[] { + const options: DropPipelineOptions = + 'tcTable' in tablesOrOptions + ? { ...maybeOptions!, dropTables: tablesOrOptions } + : tablesOrOptions + const { + tcName, + nLevel, + monsterType = 1, + dropTables, + difficulty = 'normal', + playerMf = 0, + gamePlayers = 1, + partyPlayers = 1, + isExpansion = true, + } = options + + if (!tcName || tcName.trim() === '') { + return [] + } + + // 1. Top-level TC group upgrade (D2Game!6FC32D60) + const resolvedNode = resolveTreasureClassGroup(dropTables.tcTable, tcName, nLevel) + const effectiveTcName = resolvedNode?.name ?? tcName + + // 2. Setup RNG + let rng: D2Rng + if (options.monsterRng instanceof D2Rng) { + rng = options.monsterRng + } else if (options.monsterRng instanceof Rng) { + rng = new D2Rng(options.monsterRng.int(0, 0x7FFFFFFF)) + } else if (typeof options.monsterRng === 'number') { + rng = new D2Rng(options.monsterRng >>> 0) + } else { + rng = new D2Rng() + } + + // 3. Expand TC tree + const dropLeaves: DroppedItemEntry[] = expandTreasureClass(effectiveTcName, { + rng, + tcTable: dropTables.tcTable, + autoTcTable: dropTables.autoTcTable, + gamePlayers, + partyPlayers, + isExpansion, + }) + + // 4. Resolve difficulty odds for base upgrades + const diffKey = difficulty === 'hell' ? 'Hell' : difficulty === 'nightmare' ? 'Nightmare' : 'Normal' + const odds = dropTables.difficultyLevels.get(diffKey) ?? DEFAULT_DIFFICULTY_LEVEL_ODDS[diffKey]! + const isGood = monsterType > 1 + const qf4 = isGood ? odds.uberCodeOddsGood : odds.uberCodeOddsNormal + const qf5 = isGood ? odds.ultraCodeOddsGood : odds.ultraCodeOddsNormal + + const drops: Item[] = [] + + // 5. Process each dropped leaf + for (const leaf of dropLeaves) { + if (leaf.isGold) { + const multiplier = leaf.goldMultiplier ?? 1 + const baseGold = rng.randRange(Math.max(1, Math.trunc(nLevel / 2)), Math.max(5, nLevel * 5)) + const goldAmount = Math.max(1, baseGold * multiplier) + drops.push(goldItem(goldAmount)) + continue + } + + const base = dropTables.getBase(leaf.code) + if (!base) { + continue + } + + // Base upgrade + const upgradeResult = upgradeItemBase(base, qf4, qf5, rng, dropTables.getBase) + const effectiveBase = upgradeResult.base + const isUber = upgradeResult.isUber + + // Initialize item PRNG + const dwInitSeed = (rng.rand(0xFFFFFFFF) >>> 0) + const itemRng = new D2Rng(dwInitSeed) + + // Roll quality + let quality: ItemQualityTier + if (leaf.forcedUnique) { + quality = 'unique' + } else if (leaf.forcedSet) { + quality = 'set' + } else { + quality = rollItemQuality({ + ilvl: nLevel, + qlvl: effectiveBase.level ?? 1, + magicFind: playerMf, + tcFactors: { + unique: leaf.qualityFactors[0], + set: leaf.qualityFactors[1], + rare: leaf.qualityFactors[2], + magic: leaf.qualityFactors[3], + }, + table: dropTables.itemRatio, + rng: itemRng, + uber: isUber ? 1 : 0, + classSpecific: 0, + }) + } + + let durabilityMultiplier = 1 + + // Quality branches & fallbacks + if (quality === 'unique') { + const uniqueCandidates = dropTables.uniques.getEnabledByCode(effectiveBase.id).filter(u => u.lvl <= nLevel) + if (uniqueCandidates.length > 0) { + let chosenUnique = uniqueCandidates[0]! + if (uniqueCandidates.length > 1) { + const totalRarity = uniqueCandidates.reduce((sum, u) => sum + Math.max(1, u.rarity), 0) + let roll = itemRng.rand(totalRarity) + for (const u of uniqueCandidates) { + const weight = Math.max(1, u.rarity) + if (roll < weight) { + chosenUnique = u + break + } + roll -= weight + } + } + drops.push(createDroppedItem(effectiveBase, 'unique', { + uniqueItem: chosenUnique, + ilvl: nLevel, + dwInitSeed, + })) + continue + } + // Degrade to Rare + 3x durability + quality = 'rare' + durabilityMultiplier = 3 + } + + if (quality === 'set') { + const setCandidates = dropTables.sets.items.entries.filter( + s => s.item.toLowerCase() === effectiveBase.id.toLowerCase() && s.lvl <= nLevel, + ) + if (setCandidates.length > 0) { + let chosenSet = setCandidates[0]! + if (setCandidates.length > 1) { + const totalRarity = setCandidates.reduce((sum, s) => sum + Math.max(1, s.rarity), 0) + let roll = itemRng.rand(totalRarity) + for (const s of setCandidates) { + const weight = Math.max(1, s.rarity) + if (roll < weight) { + chosenSet = s + break + } + roll -= weight + } + } + drops.push(createDroppedItem(effectiveBase, 'set', { + setItem: chosenSet, + ilvl: nLevel, + dwInitSeed, + })) + continue + } + // Degrade to Magic + 2x durability + quality = 'magic' + durabilityMultiplier = 2 + } + + if (quality === 'rare') { + const rareAffixes = rollRareAffixes( + effectiveBase, + nLevel, + dropTables.magicAffixes, + dropTables.rareNames, + dropTables.isA, + itemRng, + ) + drops.push(createDroppedItem(effectiveBase, 'rare', { + rareAffixes, + ilvl: nLevel, + dwInitSeed, + durabilityMultiplier, + })) + continue + } + + if (quality === 'magic') { + const magicAffixes = rollMagicAffixes( + effectiveBase, + nLevel, + dropTables.magicAffixes, + dropTables.isA, + itemRng, + ) + drops.push(createDroppedItem(effectiveBase, 'magic', { + magicAffixes, + ilvl: nLevel, + dwInitSeed, + durabilityMultiplier, + })) + continue + } + + if (quality === 'superior') { + drops.push(createDroppedItem(effectiveBase, 'superior', { + ilvl: nLevel, + dwInitSeed, + })) + continue + } + + if (quality === 'low') { + drops.push(createDroppedItem(effectiveBase, 'low', { + ilvl: nLevel, + dwInitSeed, + })) + continue + } + + // Default: normal quality + drops.push(createDroppedItem(effectiveBase, 'normal', { + ilvl: nLevel, + dwInitSeed, + })) + } + + return drops +} diff --git a/src/game/engine.ts b/src/game/engine.ts index 202e2ee..a1f78f6 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -7,6 +7,10 @@ import type { SkillDef, Projectile } from './skills.ts' import { QuestLog, npcDialog } from './quests.ts' import type { NpcDef, QuestDef } from './quests.ts' import { Rng } from './rng.ts' +import type { DropTables } from './drop-pipeline.ts' +import { executeDropPipeline } from './drop-pipeline.ts' +import type { MonsterKind, Difficulty } from './monsters.ts' +import { getMonsterTreasureClass } from './monsters.ts' import { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts' import { MonsterStreamingManager } from './monster-streaming.ts' import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } from './monster-streaming.ts' @@ -55,9 +59,12 @@ export interface GameEngineOptions { spawn: { x: number; y: number } stats: readonly MonsterStats[] xpTable: readonly number[] - itemBases: readonly ItemBase[] - prefixAffixes: readonly Affix[] - suffixAffixes: readonly Affix[] + itemBases?: readonly ItemBase[] | undefined + prefixAffixes?: readonly Affix[] | undefined + suffixAffixes?: readonly Affix[] | undefined + dropTables?: DropTables | undefined + difficulty?: Difficulty | undefined + monsterKinds?: Map | undefined skills: readonly SkillDef[] npcDefs: readonly NpcDef[] questDefs: readonly QuestDef[] @@ -321,19 +328,61 @@ export class GameEngine { this.metrics.questXp += reward.xp this.metrics.questGold += reward.gold } - const drop = rollDrop(this.opts.itemBases, this.opts.prefixAffixes, this.opts.suffixAffixes, this.loot, { - level: player.level + 2, - dropChance: 0.8, - goldChance: 0.3, - goldRange: [5, 40], - }) - this.metrics.dropsRolled += 1 - if (drop.kind === 'gold') { - this.bag.add(goldItem(drop.amount)) - } else if (drop.kind === 'item') { - this.ground.push({ x: event.x, y: event.y, item: drop.item }) - this.metrics.lastDrops.push(drop.item.name) - if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift() + if (this.opts.dropTables) { + const difficulty = this.opts.difficulty ?? 'normal' + const monsterRank = event.monsterRank ?? 'normal' + const monsterType = event.monsterType ?? (monsterRank === 'unique' ? 3 : monsterRank === 'champion' ? 2 : 1) + const monsterLevel = event.monsterLevel ?? (player.level + 2) + + let tcName = '' + if (event.superUniqueId && this.opts.dropTables.superUniques.has(event.superUniqueId)) { + tcName = this.opts.dropTables.superUniques.get(event.superUniqueId)!.treasureClass + } + if (!tcName) { + const monsterKind = this.opts.dropTables.monsterKinds.get(event.subjectId ?? '') ?? this.opts.monsterKinds?.get(event.subjectId ?? '') + if (monsterKind) { + tcName = getMonsterTreasureClass(monsterKind, difficulty, monsterType) + } + } + if (!tcName) { + tcName = 'Act 1 H2H A' + } + + const droppedItems = executeDropPipeline(this.opts.dropTables, { + tcName, + nLevel: monsterLevel, + monsterType, + difficulty, + gamePlayers: 1, + partyPlayers: 1, + monsterRng: this.loot, + }) + + this.metrics.dropsRolled += 1 + for (const drop of droppedItems) { + if (drop.base.id === 'gold' || drop.code?.trim() === 'gld') { + this.bag.add(drop) + } else { + this.ground.push({ x: event.x, y: event.y, item: drop }) + this.metrics.lastDrops.push(drop.name) + if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift() + } + } + } else if (this.opts.itemBases && this.opts.itemBases.length > 0) { + const drop = rollDrop(this.opts.itemBases, this.opts.prefixAffixes ?? [], this.opts.suffixAffixes ?? [], this.loot, { + level: player.level + 2, + dropChance: 0.8, + goldChance: 0.3, + goldRange: [5, 40], + }) + this.metrics.dropsRolled += 1 + if (drop.kind === 'gold') { + this.bag.add(goldItem(drop.amount)) + } else if (drop.kind === 'item') { + this.ground.push({ x: event.x, y: event.y, item: drop.item }) + this.metrics.lastDrops.push(drop.item.name) + if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift() + } } } diff --git a/src/game/item-upgrade.ts b/src/game/item-upgrade.ts index e0c1c20..988a853 100644 --- a/src/game/item-upgrade.ts +++ b/src/game/item-upgrade.ts @@ -106,6 +106,8 @@ export const DEFAULT_DIFFICULTY_LEVEL_ODDS: Record /** * Parsed collection of difficulty level upgrade odds from `Difficultylevels.txt`. */ +export type DifficultyLevelTable = DifficultyLevelsTable + export class DifficultyLevelsTable { readonly all: readonly DifficultyLevelOdds[] readonly byName: Map @@ -332,3 +334,7 @@ export function rollBaseUpgrade( return { base, isUber: false } } + +/** Alias for rollBaseUpgrade conforming to spec nomenclature. */ +export const upgradeItemBase = rollBaseUpgrade + diff --git a/src/game/monsters.ts b/src/game/monsters.ts index 2e78f9a..4e28bdc 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -234,6 +234,10 @@ export interface MonsterKind { readonly resistances: MonsterResistances /** `TreasureClass1..4`, blanks dropped. Consumed by M10. */ readonly treasureClasses: readonly string[] + /** + * Resolve TC for given difficulty and monster type (1=Normal, 2=Champion, 3=Unique, 4=Boss/Quest). + */ + getTreasureClass?(difficulty?: Difficulty, monsterType?: number): string /** `minion1`, `minion2` — what spawns alongside it, blanks dropped. */ readonly minions: readonly string[] /** `SetBoss` — the pack leader becomes a unique. */ @@ -242,6 +246,31 @@ export interface MonsterKind { readonly meleeRange?: number } +/** + * Resolves the TreasureClass name for a MonsterKind given difficulty and monster type. + * + * Monster types: + * - 1: Normal monster -> TreasureClass1 + * - 2: Champion monster -> TreasureClass2 (fallback TreasureClass1) + * - 3: Unique monster -> TreasureClass3 (fallback TreasureClass1) + * - 4: Quest / Boss drop -> TreasureClass4 (fallback TreasureClass3, then TreasureClass1) + * + * @param kind - Monster definition. + * @param difficulty - Difficulty level ('normal' | 'nightmare' | 'hell'). + * @param monsterType - Monster type code (1..4). + * @returns TreasureClass name. + */ +export function getMonsterTreasureClass( + kind: MonsterKind, + difficulty: Difficulty = 'normal', + monsterType = 1, +): string { + if (typeof kind.getTreasureClass === 'function') { + return kind.getTreasureClass(difficulty, monsterType) + } + return kind.treasureClasses[monsterType - 1] ?? kind.treasureClasses[0] ?? '' +} + /** * Read every row of `MonStats.txt`. * @@ -264,7 +293,44 @@ export function readMonsterKinds(table: D2Table, difficulty: Difficulty = 'norma // wrong case silently yields 0, so both spellings are tried. const minHp = d === '' ? num(table, row, 'minHP') : num(table, row, `MinHP${d}`, num(table, row, `minHP${d}`)) const maxHp = d === '' ? num(table, row, 'maxHP') : num(table, row, `MaxHP${d}`, num(table, row, `maxHP${d}`)) - kinds.set(id, { + + const allTreasureClasses: Record = { + normal: [ + cell(table, row, 'TreasureClass1').trim(), + cell(table, row, 'TreasureClass2').trim(), + cell(table, row, 'TreasureClass3').trim(), + cell(table, row, 'TreasureClass4').trim(), + ], + nightmare: [ + cell(table, row, 'TreasureClass1(N)').trim() || cell(table, row, 'TreasureClass(N)1').trim(), + cell(table, row, 'TreasureClass2(N)').trim() || cell(table, row, 'TreasureClass(N)2').trim(), + cell(table, row, 'TreasureClass3(N)').trim() || cell(table, row, 'TreasureClass(N)3').trim(), + cell(table, row, 'TreasureClass4(N)').trim() || cell(table, row, 'TreasureClass(N)4').trim(), + ], + hell: [ + cell(table, row, 'TreasureClass1(H)').trim() || cell(table, row, 'TreasureClass(H)1').trim(), + cell(table, row, 'TreasureClass2(H)').trim() || cell(table, row, 'TreasureClass(H)2').trim(), + cell(table, row, 'TreasureClass3(H)').trim() || cell(table, row, 'TreasureClass(H)3').trim(), + cell(table, row, 'TreasureClass4(H)').trim() || cell(table, row, 'TreasureClass(H)4').trim(), + ], + } + + const getTc = (diff: Difficulty = 'normal', mType = 1): string => { + const tcForDiff = allTreasureClasses[diff] ?? allTreasureClasses.normal + const normalTc = allTreasureClasses.normal + if (mType === 4) { + return tcForDiff[3] || tcForDiff[2] || tcForDiff[0] || normalTc[3] || normalTc[2] || normalTc[0] || '' + } + if (mType === 3) { + return tcForDiff[2] || tcForDiff[0] || normalTc[2] || normalTc[0] || '' + } + if (mType === 2) { + return tcForDiff[1] || tcForDiff[0] || normalTc[1] || normalTc[0] || '' + } + return tcForDiff[0] || normalTc[0] || '' + } + + const kind: MonsterKind = { id, baseId: cell(table, row, 'BaseId').trim() || id, nameKey: cell(table, row, 'NameStr').trim(), @@ -318,7 +384,16 @@ export function readMonsterKinds(table: D2Table, difficulty: Difficulty = 'norma .map(name => cell(table, row, name).trim()) .filter(value => value !== ''), setBoss: flag(table, row, 'SetBoss'), + } + + Object.defineProperty(kind, 'getTreasureClass', { + value: getTc, + enumerable: false, + configurable: true, + writable: true, }) + + kinds.set(id, kind) } return kinds } @@ -1108,6 +1183,7 @@ export function monsterStatsOf( walkSpeedPx: number, scale: MonsterScale = UNSCALED, art?: MonsterArt | { readonly meleeRange?: number } | null, + level?: number, ): MonsterStats { const velocity = kind.velocity > 0 ? kind.velocity : 1 // Roll first, scale second. Scaling the bounds and rolling between them @@ -1131,6 +1207,7 @@ export function monsterStatsOf( aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX, speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)), xp: Math.max(0, Math.round((kind.experience * scale.experience) / 100)), + level: level ?? kind.level[0], } } @@ -1410,7 +1487,7 @@ export function planLevelMonsters( const groupArt = artMap?.get(group.kind.id) for (let i = 0; i < group.count; i += 1) { const level = monsterLevelFor(group.kind, difficulty, plan.monsterLevel) - const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level), groupArt) + const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level), groupArt, level) // An elite pack is one leader plus its minions: the leader carries the // rank's full bonus, the minions a smaller one, which is why a champion // pack reads as "a tough one and its friends" rather than as a wall. @@ -1442,7 +1519,7 @@ export function planLevelMonsters( const minionKind = kinds.get(suSpec.minionMonsterId) ?? kinds.get(suSpec.minionMonsterId.replace(/\d+$/, '1')) ?? bossKind const bossArt = artMap?.get(bossKind.id) const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3) - const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel), bossArt) + const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel), bossArt, bossLevel) const bossScaled: MonsterStats = { ...bossBase, id: suSpec.monsterId, @@ -1455,7 +1532,7 @@ export function planLevelMonsters( const minionArt = artMap?.get(minionKind.id) for (let m = 0; m < minionCount; m += 1) { const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel) - const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel), minionArt) + const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel), minionArt, mLevel) const mScaled: MonsterStats = { ...mBase, id: suSpec.minionMonsterId, diff --git a/src/game/treasure-class.ts b/src/game/treasure-class.ts index afb6c19..72dede8 100644 --- a/src/game/treasure-class.ts +++ b/src/game/treasure-class.ts @@ -373,3 +373,39 @@ export async function loadTreasureClasses(archives: MountedArchives): Promise 0`, searches candidate TCs in `tcTable.byGroup.get(group)`. + * Selects the candidate with highest level that satisfies: + * `candidate.level <= nLevel && candidate.level >= initialNode.level`. + * If no such candidate is found, returns the initialNode. + * + * @param tcTable - Parsed TreasureClassTable. + * @param tcName - Base TreasureClass name from MonStats. + * @param nLevel - Monster level (or area level). + * @returns Upgraded or initial TreasureClassNode, or undefined if tcName is not found. + */ +export function resolveTreasureClassGroup( + tcTable: TreasureClassTable, + tcName: string, + nLevel: number, +): TreasureClassNode | undefined { + const initialNode = tcTable.byName.get(tcName) + if (!initialNode) return undefined + if (!initialNode.group || initialNode.group <= 0) return initialNode + + const candidates = tcTable.byGroup.get(initialNode.group) + if (!candidates || candidates.length === 0) return initialNode + + let best = initialNode + for (const candidate of candidates) { + if (candidate.level <= nLevel && candidate.level >= initialNode.level) { + if (candidate.level > best.level) { + best = candidate + } + } + } + return best +} diff --git a/src/game/treasure-engine.ts b/src/game/treasure-engine.ts index ed29ed3..128ffda 100644 --- a/src/game/treasure-engine.ts +++ b/src/game/treasure-engine.ts @@ -546,3 +546,9 @@ export function rollTreasureClass(tcName: string, context: DropContext): Dropped return drops } + +/** Alias for rollTreasureClass conforming to spec nomenclature. */ +export const expandTreasureClass = rollTreasureClass + +export { resolveTreasureClassGroup } from './treasure-class.ts' + diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index a3004f2..f7f105e 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -48,7 +48,8 @@ import type { MapRoom } from '../game/monster-rooms.ts' import { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts' import { GameEngine, syncEngineState } from "../game/engine.ts" import type { NpcEntity } from "../game/engine.ts" -import { DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" +import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts" +import { loadDropTables } from '../game/drop-pipeline.ts' import { buildNpcDef, hasPackedSprite } from '../game/npc.ts' import type { NpcDef } from '../game/quests.ts' import { buildAtlas, buildIndexedAtlas } from '../render/atlas.ts' @@ -1658,7 +1659,7 @@ function buildStreamingRooms( * @param renderer - the renderer (already holding the atlas or the first pages). * @param started - `performance.now()` when boot began. */ -function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: number): void { +async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: number): Promise { let runtime = initialRuntime const canvas = document.querySelector('#view')! const status = document.querySelector('#status')! @@ -1721,6 +1722,12 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: return pack }) const streamingRooms = buildStreamingRooms(runtime, resolvedPacks, safeZones) + const dropArchives = await getMountedDataArchives(runtime.charBases.length > 0 ? runtime.charBases : DEFAULT_BASES) + if (!dropArchives) { + throw new Error('Failed to mount MPQ archives for DropTables: data archives unavailable') + } + const dropTables = await loadDropTables(dropArchives) + const engine = new GameEngine( createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, { width: FEET_WIDTH, @@ -1732,9 +1739,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: // back on, so the engine's frame-0 spawner has nothing to draw from. stats: [], xpTable: DEMO_EXPERIENCE, - itemBases: DEMO_BASES, - prefixAffixes: DEMO_AFFIXES, - suffixAffixes: DEMO_AFFIXES, + dropTables, + difficulty: 'normal', + monsterKinds: dropTables.monsterKinds, skills: DEMO_SKILLS, npcDefs, questDefs: DEMO_QUESTS, @@ -2815,7 +2822,7 @@ async function boot(): Promise { await runtime.loadPages(renderer, runtime.priorityPages, () => { state.pagesLoaded = countLoadedPages(runtime.pages) }) - runScene(runtime, renderer, started) + await runScene(runtime, renderer, started) return } } @@ -2838,7 +2845,7 @@ async function boot(): Promise { const requestedVariant = param('variant', '') || param('quadrant', '') const runtime = await loadLiveRuntime(base, act, renderer, requestedVariant) publish(runtime, base) - runScene(runtime, renderer, started) + await runScene(runtime, renderer, started) return } catch (err) { lastError = err diff --git a/tests/drop-pipeline.test.ts b/tests/drop-pipeline.test.ts new file mode 100644 index 0000000..83c8c7a --- /dev/null +++ b/tests/drop-pipeline.test.ts @@ -0,0 +1,309 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import * as fs from 'fs' +import { MountedArchives } from '../src/mpq/mount.ts' +import { MpqArchive } from '../src/mpq/archive.ts' +import { fileSource } from '../src/mpq/file-source.ts' +import { + loadDropTables, + executeDropPipeline, + type DropTables, +} from '../src/game/drop-pipeline.ts' +import { + getMonsterTreasureClass, + type MonsterKind, +} from '../src/game/monsters.ts' +import { resolveTreasureClassGroup } from '../src/game/treasure-class.ts' +import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts' +import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_NPCS, DEMO_QUESTS } from '../src/game/demo-data.ts' + +const hasD2 = fs.existsSync('samples/d2/d2data.mpq') + +describe('TreasureClass Drop Pipeline (Issue #107)', () => { + let dropTables: DropTables + + beforeAll(async () => { + if (!hasD2) { + throw new Error('samples/d2 MPQ files required for Drop Pipeline test') + } + const archives = new MountedArchives() + for (const name of ['d2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) { + archives.add(name, await MpqArchive.open(await fileSource(`samples/d2/${name}`))) + } + dropTables = await loadDropTables(archives) + }) + + describe('1. DropTables Loading & Fail Loudly', () => { + it('loads all required drop tables from mounted archives', () => { + expect(dropTables).toBeDefined() + expect(dropTables.tcTable.all.length).toBeGreaterThan(0) + expect(dropTables.autoTcTable.size).toBe(100) + expect(dropTables.weapons.all.length).toBeGreaterThan(0) + expect(dropTables.armor.all.length).toBeGreaterThan(0) + expect(dropTables.misc.all.length).toBeGreaterThan(0) + expect(dropTables.itemRatio.length).toBeGreaterThan(0) + expect(dropTables.difficultyLevels.all.length).toBeGreaterThan(0) + expect(dropTables.uniques.all.length).toBeGreaterThan(0) + expect(dropTables.sets.items.length).toBeGreaterThan(0) + expect(dropTables.sets.sets.length).toBeGreaterThan(0) + expect(dropTables.magicAffixes.prefixes.length).toBeGreaterThan(0) + expect(dropTables.magicAffixes.suffixes.length).toBeGreaterThan(0) + expect(dropTables.rareNames.prefixes.length).toBeGreaterThan(0) + expect(dropTables.rareNames.suffixes.length).toBeGreaterThan(0) + expect(dropTables.itemTypes.types.length).toBeGreaterThan(0) + expect(dropTables.monsterKinds.size).toBeGreaterThan(0) + }) + + it('fails loudly when archives have missing tables', async () => { + const emptyArchives = new MountedArchives() + await expect(loadDropTables(emptyArchives)).rejects.toThrow() + }) + }) + + describe('2. MonStats TC Column Mapping (12 columns across 3 difficulties)', () => { + it('maps Fallen (fallen1) correctly for Normal difficulty', () => { + const fallen = dropTables.monsterKinds.get('fallen1') + expect(fallen).toBeDefined() + + // MonsterType 1: Normal + const tcNormal = getMonsterTreasureClass(fallen!, 'normal', 1) + expect(tcNormal).toBe('Act 1 H2H A') + + // MonsterType 2: Champion + const tcChamp = getMonsterTreasureClass(fallen!, 'normal', 2) + expect(tcChamp).toBe('Act 1 Champ A') + + // MonsterType 3: Unique + const tcUnique = getMonsterTreasureClass(fallen!, 'normal', 3) + expect(tcUnique).toBe('Act 1 Unique A') + + // MonsterType 4: Boss / Quest + const tcBoss = getMonsterTreasureClass(fallen!, 'normal', 4) + expect(tcBoss).toBeDefined() + }) + + it('maps Fallen (fallen1) correctly for Nightmare difficulty', () => { + const fallen = dropTables.monsterKinds.get('fallen1') + expect(fallen).toBeDefined() + + // MonsterType 1: Normal (N) + const tcNormal = getMonsterTreasureClass(fallen!, 'nightmare', 1) + expect(tcNormal).toBe('Act 1 (N) H2H A') + + // MonsterType 2: Champion (N) + const tcChamp = getMonsterTreasureClass(fallen!, 'nightmare', 2) + expect(tcChamp).toBe('Act 1 (N) Champ A') + + // MonsterType 3: Unique (N) + const tcUnique = getMonsterTreasureClass(fallen!, 'nightmare', 3) + expect(tcUnique).toBe('Act 1 (N) Unique A') + }) + + it('maps Fallen (fallen1) correctly for Hell difficulty', () => { + const fallen = dropTables.monsterKinds.get('fallen1') + expect(fallen).toBeDefined() + + // MonsterType 1: Normal (H) + const tcNormal = getMonsterTreasureClass(fallen!, 'hell', 1) + expect(tcNormal).toBe('Act 1 (H) H2H A') + + // MonsterType 2: Champion (H) + const tcChamp = getMonsterTreasureClass(fallen!, 'hell', 2) + expect(tcChamp).toBe('Act 1 (H) Champ A') + + // MonsterType 3: Unique (H) + const tcUnique = getMonsterTreasureClass(fallen!, 'hell', 3) + expect(tcUnique).toBe('Act 1 (H) Unique A') + }) + }) + + describe('3. Top-Level TC Group Upgrade (D2Game!6FC32D60)', () => { + it('returns the base node when nLevel does not meet next tier level', () => { + const node = resolveTreasureClassGroup(dropTables.tcTable, 'Act 1 H2H A', 1) + expect(node).toBeDefined() + expect(node?.name).toBe('Act 1 H2H A') + }) + + it('upgrades TC node within the group when monster nLevel is higher', () => { + // Act 1 H2H A has group = 1, level = 1 + // Act 1 H2H B has group = 1, level = 4 + // Act 1 H2H C has group = 1, level = 7 + const upgradedAt5 = resolveTreasureClassGroup(dropTables.tcTable, 'Act 1 H2H A', 5) + expect(upgradedAt5).toBeDefined() + expect(upgradedAt5?.name).toBe('Act 1 H2H B') + + const upgradedAt10 = resolveTreasureClassGroup(dropTables.tcTable, 'Act 1 H2H A', 10) + expect(upgradedAt10).toBeDefined() + expect(upgradedAt10?.name).toBe('Act 1 H2H C') + }) + + it('returns undefined if TC name does not exist in table', () => { + const missing = resolveTreasureClassGroup(dropTables.tcTable, 'NonExistentTC', 10) + expect(missing).toBeUndefined() + }) + }) + + describe('4. Drop Pipeline Execution', () => { + it('executes drop pipeline for Act 1 monster and yields real items without demo data', () => { + const drops = executeDropPipeline(dropTables, { + tcName: 'Act 1 H2H A', + nLevel: 3, + monsterType: 1, + difficulty: 'normal', + gamePlayers: 1, + partyPlayers: 1, + }) + + // Drops may be empty due to NoDrop, or contain items/gold + // Run multiple iterations to verify drop contents + let totalItems = 0 + let totalGold = 0 + const droppedNames: string[] = [] + + for (let i = 0; i < 50; i++) { + const batch = executeDropPipeline(dropTables, { + tcName: 'Act 1 H2H A', + nLevel: 3, + monsterType: 1, + difficulty: 'normal', + gamePlayers: 1, + partyPlayers: 1, + monsterRng: i * 1000 + 1, + }) + for (const item of batch) { + expect(item.base.id).not.toMatch(/^demo-/) + expect(item.name).not.toMatch(/^demo-/) + if (item.base.id === 'gold' || item.code?.trim() === 'gld') { + totalGold += 1 + } else { + totalItems += 1 + droppedNames.push(item.name) + } + } + } + + expect(totalGold + totalItems).toBeGreaterThan(0) + // Verify no item has demo tags or demo id + for (const name of droppedNames) { + expect(name).not.toBe('Sword') + expect(name).not.toBe('Cap') + } + }) + + it('supports champion and unique monster type quality boosts', () => { + let magicOrBetter = 0 + for (let i = 0; i < 50; i++) { + const batch = executeDropPipeline(dropTables, { + tcName: 'Act 1 Champ A', + nLevel: 5, + monsterType: 2, // Champion + difficulty: 'normal', + playerMf: 100, + monsterRng: i * 7919 + 7, + }) + for (const item of batch) { + if (item.base.id !== 'gold') { + if (item.prefix || item.suffix || (item.quality !== undefined && item.quality > 2)) { + magicOrBetter += 1 + } + } + } + } + expect(magicOrBetter).toBeGreaterThan(0) + }) + }) + + describe('5. Complete Deletion of DEMO_BASES & DEMO_AFFIXES', () => { + it('verifies demo-data.ts does not export DEMO_BASES or DEMO_AFFIXES', async () => { + const demoDataModule = await import('../src/game/demo-data.ts') + expect((demoDataModule as Record).DEMO_BASES).toBeUndefined() + expect((demoDataModule as Record).DEMO_AFFIXES).toBeUndefined() + }) + + it('verifies src/ contains 0 occurrences of DEMO_BASES and DEMO_AFFIXES', () => { + const srcFiles = fs.readdirSync('src', { recursive: true }) as string[] + for (const file of srcFiles) { + if (!file.endsWith('.ts')) continue + const content = fs.readFileSync(`src/${file}`, 'utf-8') + expect(content).not.toContain('DEMO_BASES') + expect(content).not.toContain('DEMO_AFFIXES') + } + }) + }) + + describe('6. GameEngine Integration with DropTables', () => { + it('GameEngine rolls drops using real DropTables on monster kill', () => { + const fallen = dropTables.monsterKinds.get('fallen1')! + const dummyTerrain = { + widthPx: 1000, + heightPx: 1000, + overlap: () => 0, + } + + const engineOpts: GameEngineOptions = { + spawn: { x: 500, y: 500 }, + stats: [ + { + id: 'fallen1', + name: 'Fallen', + level: 3, + rank: 'normal', + hp: 10, + damage: 2, + cooldownTicks: 10, + reach: 30, + aggroRadius: 100, + speed: 50, + xp: 20, + }, + ], + xpTable: DEMO_EXPERIENCE, + dropTables, + difficulty: 'normal', + monsterKinds: dropTables.monsterKinds, + skills: DEMO_SKILLS, + npcDefs: DEMO_NPCS, + questDefs: DEMO_QUESTS, + combatOptions: { + playerSpeed: 100, + playerReach: 60, + playerCooldownTicks: 1, + playerDamage: 100, // One-shot kill + playerManaPerAttack: 0, + respawnTicks: 100, + }, + talkRadius: 80, + pickupRadius: 64, + inventoryCols: 10, + inventoryRows: 4, + monsterCount: 5, + monsterSpread: 100, + } + + const engine = new GameEngine(dummyTerrain, engineOpts) + + // Ensure monsters are within reach and attack + let killedAny = false + for (let tick = 0; tick < 50; tick++) { + engine.tick({ + movement: { x: 0, y: 0 }, + attacking: true, + pickingUp: false, + talking: false, + digits: [], + saving: false, + loading: false, + }) + if (engine.world.events.some(e => e.kind === 'kill')) { + killedAny = true + } + } + + expect(killedAny).toBe(true) + // Check that either ground items or gold bag additions were recorded from dropTables + expect(engine.metrics.dropsRolled).toBeGreaterThan(0) + for (const entry of engine.ground) { + expect(entry.item.base.id).not.toMatch(/^demo-/) + } + }) + }) +})