Compare commits
No commits in common. "370dcc05b1299acfa00948e51b6a90ddfb86bd24" and "a0a20fb06b59a5efcd30f8b1db50727d373c60d2" have entirely different histories.
370dcc05b1
...
a0a20fb06b
|
|
@ -113,8 +113,6 @@ 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. */
|
||||||
|
|
@ -187,14 +185,6 @@ 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. */
|
||||||
|
|
@ -639,22 +629,6 @@ 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
|
||||||
|
|
@ -665,7 +639,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(buildKillEvent(monster))
|
world.events.push({ kind: 'kill', x: monster.x, y: monster.y, text: monster.stats.name, subjectId: monster.stats.id })
|
||||||
attacker.xp += monster.stats.xp
|
attacker.xp += monster.stats.xp
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -801,7 +775,7 @@ function tickPlayer(
|
||||||
target.state = 'dead'
|
target.state = 'dead'
|
||||||
target.corpseTicks = 100
|
target.corpseTicks = 100
|
||||||
world.kills += 1
|
world.kills += 1
|
||||||
world.events.push(buildKillEvent(target))
|
world.events.push({ kind: 'kill', x: target.x, y: target.y, text: target.stats.name, subjectId: target.stats.id })
|
||||||
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,3 +1,4 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
|
@ -10,6 +11,18 @@ 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 },
|
||||||
|
|
|
||||||
|
|
@ -1,596 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,10 +7,6 @@ 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'
|
||||||
|
|
@ -59,12 +55,9 @@ 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[] | undefined
|
itemBases: readonly ItemBase[]
|
||||||
prefixAffixes?: readonly Affix[] | undefined
|
prefixAffixes: readonly Affix[]
|
||||||
suffixAffixes?: readonly Affix[] | undefined
|
suffixAffixes: readonly Affix[]
|
||||||
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[]
|
||||||
|
|
@ -328,61 +321,19 @@ export class GameEngine {
|
||||||
this.metrics.questXp += reward.xp
|
this.metrics.questXp += reward.xp
|
||||||
this.metrics.questGold += reward.gold
|
this.metrics.questGold += reward.gold
|
||||||
}
|
}
|
||||||
if (this.opts.dropTables) {
|
const drop = rollDrop(this.opts.itemBases, this.opts.prefixAffixes, this.opts.suffixAffixes, this.loot, {
|
||||||
const difficulty = this.opts.difficulty ?? 'normal'
|
level: player.level + 2,
|
||||||
const monsterRank = event.monsterRank ?? 'normal'
|
dropChance: 0.8,
|
||||||
const monsterType = event.monsterType ?? (monsterRank === 'unique' ? 3 : monsterRank === 'champion' ? 2 : 1)
|
goldChance: 0.3,
|
||||||
const monsterLevel = event.monsterLevel ?? (player.level + 2)
|
goldRange: [5, 40],
|
||||||
|
})
|
||||||
let tcName = ''
|
this.metrics.dropsRolled += 1
|
||||||
if (event.superUniqueId && this.opts.dropTables.superUniques.has(event.superUniqueId)) {
|
if (drop.kind === 'gold') {
|
||||||
tcName = this.opts.dropTables.superUniques.get(event.superUniqueId)!.treasureClass
|
this.bag.add(goldItem(drop.amount))
|
||||||
}
|
} else if (drop.kind === 'item') {
|
||||||
if (!tcName) {
|
this.ground.push({ x: event.x, y: event.y, item: drop.item })
|
||||||
const monsterKind = this.opts.dropTables.monsterKinds.get(event.subjectId ?? '') ?? this.opts.monsterKinds?.get(event.subjectId ?? '')
|
this.metrics.lastDrops.push(drop.item.name)
|
||||||
if (monsterKind) {
|
if (this.metrics.lastDrops.length > 6) this.metrics.lastDrops.shift()
|
||||||
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,8 +106,6 @@ 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>
|
||||||
|
|
@ -334,7 +332,3 @@ export function rollBaseUpgrade(
|
||||||
|
|
||||||
return { base, isUber: false }
|
return { base, isUber: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Alias for rollBaseUpgrade conforming to spec nomenclature. */
|
|
||||||
export const upgradeItemBase = rollBaseUpgrade
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -234,10 +234,6 @@ 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. */
|
||||||
|
|
@ -246,31 +242,6 @@ 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`.
|
||||||
*
|
*
|
||||||
|
|
@ -293,44 +264,7 @@ 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(),
|
||||||
|
|
@ -384,16 +318,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -1183,7 +1108,6 @@ 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
|
||||||
|
|
@ -1207,7 +1131,6 @@ 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],
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1487,7 +1410,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, level)
|
const base = monsterStatsOf(group.kind, rollRng, walkSpeedPx, scaleAt(level), groupArt)
|
||||||
// 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.
|
||||||
|
|
@ -1519,7 +1442,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, bossLevel)
|
const bossBase = monsterStatsOf(bossKind, suRng, walkSpeedPx, scaleAt(bossLevel), bossArt)
|
||||||
const bossScaled: MonsterStats = {
|
const bossScaled: MonsterStats = {
|
||||||
...bossBase,
|
...bossBase,
|
||||||
id: suSpec.monsterId,
|
id: suSpec.monsterId,
|
||||||
|
|
@ -1532,7 +1455,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, mLevel)
|
const mBase = monsterStatsOf(minionKind, suRng, walkSpeedPx, scaleAt(mLevel), minionArt)
|
||||||
const mScaled: MonsterStats = {
|
const mScaled: MonsterStats = {
|
||||||
...mBase,
|
...mBase,
|
||||||
id: suSpec.minionMonsterId,
|
id: suSpec.minionMonsterId,
|
||||||
|
|
|
||||||
|
|
@ -373,39 +373,3 @@ 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,9 +546,3 @@ 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,8 +48,7 @@ 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_SKILLS, DEMO_NPCS, DEMO_QUESTS } from "../game/demo-data.ts"
|
import { DEMO_EXPERIENCE, DEMO_BASES, DEMO_AFFIXES, 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'
|
||||||
|
|
@ -1673,7 +1672,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.
|
||||||
*/
|
*/
|
||||||
async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: number): Promise<void> {
|
function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: number): 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')!
|
||||||
|
|
@ -1736,12 +1735,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
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,
|
||||||
|
|
@ -1753,9 +1746,9 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
// 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,
|
||||||
dropTables,
|
itemBases: DEMO_BASES,
|
||||||
difficulty: 'normal',
|
prefixAffixes: DEMO_AFFIXES,
|
||||||
monsterKinds: dropTables.monsterKinds,
|
suffixAffixes: DEMO_AFFIXES,
|
||||||
skills: DEMO_SKILLS,
|
skills: DEMO_SKILLS,
|
||||||
npcDefs,
|
npcDefs,
|
||||||
questDefs: DEMO_QUESTS,
|
questDefs: DEMO_QUESTS,
|
||||||
|
|
@ -2836,7 +2829,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)
|
||||||
})
|
})
|
||||||
await runScene(runtime, renderer, started)
|
runScene(runtime, renderer, started)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2859,7 +2852,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)
|
||||||
await runScene(runtime, renderer, started)
|
runScene(runtime, renderer, started)
|
||||||
return
|
return
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastError = err
|
lastError = err
|
||||||
|
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
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