feat(items): 用 TC 树替换 rollDrop 并彻底删除 demo 物品数据 (Issue #107)

This commit is contained in:
troytt 2026-09-18 12:54:21 +00:00
parent 33ce4e79b8
commit 064e956de6
10 changed files with 1141 additions and 42 deletions

View File

@ -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)) {

View File

@ -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 },

596
src/game/drop-pipeline.ts Normal file
View File

@ -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<string, TreasureClassNode>
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<string, MonsterKind>
readonly superUniques: Map<string, SuperUnique>
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<DropTables> {
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<string, TreasureClassNode>()
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<string, SuperUnique>()
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<string, number> = {}
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<DropPipelineOptions, 'dropTables'>,
): 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
}

View File

@ -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<string, MonsterKind> | 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()
}
}
}

View File

@ -106,6 +106,8 @@ export const DEFAULT_DIFFICULTY_LEVEL_ODDS: Record<string, DifficultyLevelOdds>
/**
* Parsed collection of difficulty level upgrade odds from `Difficultylevels.txt`.
*/
export type DifficultyLevelTable = DifficultyLevelsTable
export class DifficultyLevelsTable {
readonly all: readonly DifficultyLevelOdds[]
readonly byName: Map<string, DifficultyLevelOdds>
@ -332,3 +334,7 @@ export function rollBaseUpgrade(
return { base, isUber: false }
}
/** Alias for rollBaseUpgrade conforming to spec nomenclature. */
export const upgradeItemBase = rollBaseUpgrade

View File

@ -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<Difficulty, [string, string, string, string]> = {
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,

View File

@ -373,3 +373,39 @@ export async function loadTreasureClasses(archives: MountedArchives): Promise<Tr
const d2Table = parseTable(bytes)
return parseTreasureClassTable(d2Table)
}
/**
* Resolves top-level TreasureClass group upgrade per Diablo II 1.13c rules (D2Game!6FC32D60).
*
* If initial TC has `group > 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
}

View File

@ -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'

View File

@ -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<void> {
let runtime = initialRuntime
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
const status = document.querySelector<HTMLElement>('#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<void> {
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<void> {
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

309
tests/drop-pipeline.test.ts Normal file
View File

@ -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<string, unknown>).DEMO_BASES).toBeUndefined()
expect((demoDataModule as Record<string, unknown>).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-/)
}
})
})
})