feat(items): 用 TC 树替换 rollDrop 并彻底删除 demo 物品数据 (Issue #107)
This commit is contained in:
parent
33ce4e79b8
commit
064e956de6
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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-/)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue