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[]
|
readonly modifiers?: readonly string[]
|
||||||
/** SuperUnique identifier if this is a fixed SuperUnique boss (e.g. 'Bishibosh'). */
|
/** SuperUnique identifier if this is a fixed SuperUnique boss (e.g. 'Bishibosh'). */
|
||||||
readonly superUniqueId?: string
|
readonly superUniqueId?: string
|
||||||
|
/** Monster level for drops and scaling. */
|
||||||
|
readonly level?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One monster's mutable state. */
|
/** One monster's mutable state. */
|
||||||
|
|
@ -185,6 +187,14 @@ export interface CombatEvent {
|
||||||
readonly text?: string
|
readonly text?: string
|
||||||
/** Subject identity (a monster's table id on a kill, for quest counters). */
|
/** Subject identity (a monster's table id on a kill, for quest counters). */
|
||||||
readonly subjectId?: string
|
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. */
|
/** The whole simulation state. */
|
||||||
|
|
@ -629,6 +639,22 @@ function moveWithCollision(
|
||||||
* Defaults to the local player, which is what a single-player caller means.
|
* Defaults to the local player, which is what a single-player caller means.
|
||||||
* @returns true when this damage killed it.
|
* @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 {
|
export function damageMonster(world: CombatWorld, monsterIndex: number, amount: number, attacker: CombatPlayer = world.player): boolean {
|
||||||
const monster = world.monsters[monsterIndex]
|
const monster = world.monsters[monsterIndex]
|
||||||
if (monster === undefined || monster.state === 'dead') return false
|
if (monster === undefined || monster.state === 'dead') return false
|
||||||
|
|
@ -639,7 +665,7 @@ export function damageMonster(world: CombatWorld, monsterIndex: number, amount:
|
||||||
monster.state = 'dead'
|
monster.state = 'dead'
|
||||||
monster.corpseTicks = 100
|
monster.corpseTicks = 100
|
||||||
world.kills += 1
|
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
|
attacker.xp += monster.stats.xp
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -775,7 +801,7 @@ function tickPlayer(
|
||||||
target.state = 'dead'
|
target.state = 'dead'
|
||||||
target.corpseTicks = 100
|
target.corpseTicks = 100
|
||||||
world.kills += 1
|
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
|
player.xp += target.stats.xp
|
||||||
// Level up while the threshold is crossed; the table is cumulative.
|
// 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)) {
|
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 { SkillDef } from './skills'
|
||||||
import type { NpcDef, QuestDef } from './quests'
|
import type { NpcDef, QuestDef } from './quests'
|
||||||
|
|
||||||
|
|
@ -11,18 +10,6 @@ import type { NpcDef, QuestDef } from './quests'
|
||||||
/** Fallback experience thresholds, index 1 = level 1. */
|
/** Fallback experience thresholds, index 1 = level 1. */
|
||||||
export const DEMO_EXPERIENCE: readonly number[] = [0, 0, 20, 60, 140, 280]
|
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. */
|
/** Fallback skills, used when an archive ships no skill table. */
|
||||||
export const DEMO_SKILLS: readonly SkillDef[] = [
|
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 },
|
{ 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 { QuestLog, npcDialog } from './quests.ts'
|
||||||
import type { NpcDef, QuestDef } from './quests.ts'
|
import type { NpcDef, QuestDef } from './quests.ts'
|
||||||
import { Rng } from './rng.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 { captureSnapshot, parseSnapshot, restoreSnapshot, serializeSnapshot } from './save.ts'
|
||||||
import { MonsterStreamingManager } from './monster-streaming.ts'
|
import { MonsterStreamingManager } from './monster-streaming.ts'
|
||||||
import type { MonsterStreamingOptions, StreamingRoomDef, StreamingRoomState } 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 }
|
spawn: { x: number; y: number }
|
||||||
stats: readonly MonsterStats[]
|
stats: readonly MonsterStats[]
|
||||||
xpTable: readonly number[]
|
xpTable: readonly number[]
|
||||||
itemBases: readonly ItemBase[]
|
itemBases?: readonly ItemBase[] | undefined
|
||||||
prefixAffixes: readonly Affix[]
|
prefixAffixes?: readonly Affix[] | undefined
|
||||||
suffixAffixes: readonly Affix[]
|
suffixAffixes?: readonly Affix[] | undefined
|
||||||
|
dropTables?: DropTables | undefined
|
||||||
|
difficulty?: Difficulty | undefined
|
||||||
|
monsterKinds?: Map<string, MonsterKind> | undefined
|
||||||
skills: readonly SkillDef[]
|
skills: readonly SkillDef[]
|
||||||
npcDefs: readonly NpcDef[]
|
npcDefs: readonly NpcDef[]
|
||||||
questDefs: readonly QuestDef[]
|
questDefs: readonly QuestDef[]
|
||||||
|
|
@ -321,7 +328,48 @@ export class GameEngine {
|
||||||
this.metrics.questXp += reward.xp
|
this.metrics.questXp += reward.xp
|
||||||
this.metrics.questGold += reward.gold
|
this.metrics.questGold += reward.gold
|
||||||
}
|
}
|
||||||
const drop = rollDrop(this.opts.itemBases, this.opts.prefixAffixes, this.opts.suffixAffixes, this.loot, {
|
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,
|
level: player.level + 2,
|
||||||
dropChance: 0.8,
|
dropChance: 0.8,
|
||||||
goldChance: 0.3,
|
goldChance: 0.3,
|
||||||
|
|
@ -336,6 +384,7 @@ export class GameEngine {
|
||||||
if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift()
|
if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (input.pickingUp && this.ground.length > 0) {
|
if (input.pickingUp && this.ground.length > 0) {
|
||||||
let bestIndex = -1
|
let bestIndex = -1
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ export const DEFAULT_DIFFICULTY_LEVEL_ODDS: Record<string, DifficultyLevelOdds>
|
||||||
/**
|
/**
|
||||||
* Parsed collection of difficulty level upgrade odds from `Difficultylevels.txt`.
|
* Parsed collection of difficulty level upgrade odds from `Difficultylevels.txt`.
|
||||||
*/
|
*/
|
||||||
|
export type DifficultyLevelTable = DifficultyLevelsTable
|
||||||
|
|
||||||
export class DifficultyLevelsTable {
|
export class DifficultyLevelsTable {
|
||||||
readonly all: readonly DifficultyLevelOdds[]
|
readonly all: readonly DifficultyLevelOdds[]
|
||||||
readonly byName: Map<string, DifficultyLevelOdds>
|
readonly byName: Map<string, DifficultyLevelOdds>
|
||||||
|
|
@ -332,3 +334,7 @@ export function rollBaseUpgrade(
|
||||||
|
|
||||||
return { base, isUber: false }
|
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
|
readonly resistances: MonsterResistances
|
||||||
/** `TreasureClass1..4`, blanks dropped. Consumed by M10. */
|
/** `TreasureClass1..4`, blanks dropped. Consumed by M10. */
|
||||||
readonly treasureClasses: readonly string[]
|
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. */
|
/** `minion1`, `minion2` — what spawns alongside it, blanks dropped. */
|
||||||
readonly minions: readonly string[]
|
readonly minions: readonly string[]
|
||||||
/** `SetBoss` — the pack leader becomes a unique. */
|
/** `SetBoss` — the pack leader becomes a unique. */
|
||||||
|
|
@ -242,6 +246,31 @@ export interface MonsterKind {
|
||||||
readonly meleeRange?: number
|
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`.
|
* 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.
|
// 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 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}`))
|
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,
|
id,
|
||||||
baseId: cell(table, row, 'BaseId').trim() || id,
|
baseId: cell(table, row, 'BaseId').trim() || id,
|
||||||
nameKey: cell(table, row, 'NameStr').trim(),
|
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())
|
.map(name => cell(table, row, name).trim())
|
||||||
.filter(value => value !== ''),
|
.filter(value => value !== ''),
|
||||||
setBoss: flag(table, row, 'SetBoss'),
|
setBoss: flag(table, row, 'SetBoss'),
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(kind, 'getTreasureClass', {
|
||||||
|
value: getTc,
|
||||||
|
enumerable: false,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
kinds.set(id, kind)
|
||||||
}
|
}
|
||||||
return kinds
|
return kinds
|
||||||
}
|
}
|
||||||
|
|
@ -1108,6 +1183,7 @@ export function monsterStatsOf(
|
||||||
walkSpeedPx: number,
|
walkSpeedPx: number,
|
||||||
scale: MonsterScale = UNSCALED,
|
scale: MonsterScale = UNSCALED,
|
||||||
art?: MonsterArt | { readonly meleeRange?: number } | null,
|
art?: MonsterArt | { readonly meleeRange?: number } | null,
|
||||||
|
level?: number,
|
||||||
): MonsterStats {
|
): MonsterStats {
|
||||||
const velocity = kind.velocity > 0 ? kind.velocity : 1
|
const velocity = kind.velocity > 0 ? kind.velocity : 1
|
||||||
// Roll first, scale second. Scaling the bounds and rolling between them
|
// 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,
|
aggroRadius: kind.aiDistance > 0 ? kind.aiDistance * PIXELS_PER_SUBTILE : DEFAULT_AGGRO_PX,
|
||||||
speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)),
|
speed: Math.max(8, Math.round((velocity / PLAYER_WALK_VELOCITY) * walkSpeedPx)),
|
||||||
xp: Math.max(0, Math.round((kind.experience * scale.experience) / 100)),
|
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)
|
const groupArt = artMap?.get(group.kind.id)
|
||||||
for (let i = 0; i < group.count; i += 1) {
|
for (let i = 0; i < group.count; i += 1) {
|
||||||
const level = monsterLevelFor(group.kind, difficulty, plan.monsterLevel)
|
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
|
// 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
|
// 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.
|
// 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 minionKind = kinds.get(suSpec.minionMonsterId) ?? kinds.get(suSpec.minionMonsterId.replace(/\d+$/, '1')) ?? bossKind
|
||||||
const bossArt = artMap?.get(bossKind.id)
|
const bossArt = artMap?.get(bossKind.id)
|
||||||
const bossLevel = monsterLevelFor(bossKind, difficulty, plan.monsterLevel + 3)
|
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 = {
|
const bossScaled: MonsterStats = {
|
||||||
...bossBase,
|
...bossBase,
|
||||||
id: suSpec.monsterId,
|
id: suSpec.monsterId,
|
||||||
|
|
@ -1455,7 +1532,7 @@ export function planLevelMonsters(
|
||||||
const minionArt = artMap?.get(minionKind.id)
|
const minionArt = artMap?.get(minionKind.id)
|
||||||
for (let m = 0; m < minionCount; m += 1) {
|
for (let m = 0; m < minionCount; m += 1) {
|
||||||
const mLevel = monsterLevelFor(minionKind, difficulty, plan.monsterLevel)
|
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 = {
|
const mScaled: MonsterStats = {
|
||||||
...mBase,
|
...mBase,
|
||||||
id: suSpec.minionMonsterId,
|
id: suSpec.minionMonsterId,
|
||||||
|
|
|
||||||
|
|
@ -373,3 +373,39 @@ export async function loadTreasureClasses(archives: MountedArchives): Promise<Tr
|
||||||
const d2Table = parseTable(bytes)
|
const d2Table = parseTable(bytes)
|
||||||
return parseTreasureClassTable(d2Table)
|
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
|
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 { SUB_TILES_PER_TILE, depthInsertIndex } from '../game/map.ts'
|
||||||
import { GameEngine, syncEngineState } from "../game/engine.ts"
|
import { GameEngine, syncEngineState } from "../game/engine.ts"
|
||||||
import type { NpcEntity } 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 { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
||||||
import type { NpcDef } from '../game/quests.ts'
|
import type { NpcDef } from '../game/quests.ts'
|
||||||
import { buildAtlas, buildIndexedAtlas } from '../render/atlas.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 renderer - the renderer (already holding the atlas or the first pages).
|
||||||
* @param started - `performance.now()` when boot began.
|
* @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
|
let runtime = initialRuntime
|
||||||
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
|
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
|
||||||
const status = document.querySelector<HTMLElement>('#status')!
|
const status = document.querySelector<HTMLElement>('#status')!
|
||||||
|
|
@ -1721,6 +1722,12 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
return pack
|
return pack
|
||||||
})
|
})
|
||||||
const streamingRooms = buildStreamingRooms(runtime, resolvedPacks, safeZones)
|
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(
|
const engine = new GameEngine(
|
||||||
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
|
createIsoTerrain(runtime.grid, runtime.widthPx, runtime.heightPx, {
|
||||||
width: FEET_WIDTH,
|
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.
|
// back on, so the engine's frame-0 spawner has nothing to draw from.
|
||||||
stats: [],
|
stats: [],
|
||||||
xpTable: DEMO_EXPERIENCE,
|
xpTable: DEMO_EXPERIENCE,
|
||||||
itemBases: DEMO_BASES,
|
dropTables,
|
||||||
prefixAffixes: DEMO_AFFIXES,
|
difficulty: 'normal',
|
||||||
suffixAffixes: DEMO_AFFIXES,
|
monsterKinds: dropTables.monsterKinds,
|
||||||
skills: DEMO_SKILLS,
|
skills: DEMO_SKILLS,
|
||||||
npcDefs,
|
npcDefs,
|
||||||
questDefs: DEMO_QUESTS,
|
questDefs: DEMO_QUESTS,
|
||||||
|
|
@ -2815,7 +2822,7 @@ async function boot(): Promise<void> {
|
||||||
await runtime.loadPages(renderer, runtime.priorityPages, () => {
|
await runtime.loadPages(renderer, runtime.priorityPages, () => {
|
||||||
state.pagesLoaded = countLoadedPages(runtime.pages)
|
state.pagesLoaded = countLoadedPages(runtime.pages)
|
||||||
})
|
})
|
||||||
runScene(runtime, renderer, started)
|
await runScene(runtime, renderer, started)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2838,7 +2845,7 @@ async function boot(): Promise<void> {
|
||||||
const requestedVariant = param('variant', '') || param('quadrant', '')
|
const requestedVariant = param('variant', '') || param('quadrant', '')
|
||||||
const runtime = await loadLiveRuntime(base, act, renderer, requestedVariant)
|
const runtime = await loadLiveRuntime(base, act, renderer, requestedVariant)
|
||||||
publish(runtime, base)
|
publish(runtime, base)
|
||||||
runScene(runtime, renderer, started)
|
await runScene(runtime, renderer, started)
|
||||||
return
|
return
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = 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