diablo2-web/scripts/verify-items.ts

194 lines
10 KiB
TypeScript

/**
* M3 item checks: tables, affix rolling, inventory and drops, headless.
*
* Item systems fail in ways that are tedious to catch by playing: an affix that
* rolls on a type it should not, a stack that silently discards a potion, an
* item that occupies cells it never clears, or a drop table that is not
* reproducible. Each of those is a plain assertion here instead.
*
* Usage: node scripts/verify-items.ts
*/
import {
Inventory, affixEligible, affixesFromTable, createItem, goldItem, itemBasesFromTable,
rollAffix, rollDrop, totalStat,
} from '../src/game/items.ts'
import type { Affix, ItemBase } from '../src/game/items.ts'
import { Rng } from '../src/game/rng.ts'
import { parseTable } from '../src/game/tables.ts'
const problems: string[] = []
let checks = 0
/**
* Assert one condition.
*
* @param condition - the condition to hold.
* @param description - what it means.
*/
function expect(condition: boolean, description: string): void {
checks += 1
if (!condition) problems.push(description)
}
// --- tables -----------------------------------------------------------------
const weapons = itemBasesFromTable(parseTable([
'Id\tName\tType\tInvWidth\tInvHeight\tDamage\tValue\tLevel\tMaxStack',
'swd\tShort Sword\tweap\t1\t3\t5\t30\t1\t1',
'bsc\tBuckler\tarmo\t2\t2\t\t12\t1\t1\t\t',
'lsw\tLong Sword\tweap\t1\t3\t12\t120\t8\t1',
'axe\tHand Axe\tweap\t1\t3\t8\t60\t\t1',
].join('\n')), 'weapon')
expect(weapons.length === 4, 'every weapon row becomes a base')
expect(weapons[0]?.invWidth === 1 && weapons[0]?.invHeight === 3, 'footprints come from the table')
expect(weapons[1]?.damage === 0, 'a missing damage cell becomes zero, not NaN')
expect(weapons[2]?.level === 8, 'level gates are read')
expect(weapons[3]?.level === 1, 'a missing level falls back to 1')
expect(weapons[0]?.tags.includes('weap') === true, 'the type column becomes the tag list')
const misc = itemBasesFromTable(parseTable([
'Id\tName\tType\tInvWidth\tInvHeight\tMaxStack\tValue',
'hp1\tMinor Healing Potion\tmisc\t1\t1\t5\t20',
].join('\n')), 'misc')
expect(misc[0]?.maxStack === 5, 'stack limits come from the table')
const prefixes = affixesFromTable(parseTable([
'Id\tName\tLevel\titype1\titype2\tmod1code\tmod1min\tmod1max\tmod2code\tmod2min\tmod2max',
'cruel\tCruel\t12\tweap\t\tmaxdamage\t30\t40\t\t\t',
'sturdy\tSturdy\t3\tarmo\t\tdefense\t5\t9\t\t\t',
'fine\tFine\t5\tweap\tarmo\tmaxdamage\t2\t4\tdefense\t1\t3',
'nomod\tBroken Row\t1\tweap\t\t\t\t\t\t\t', // no modifier: must be skipped
].join('\r\n')), 'prefix')
expect(prefixes.length === 3, 'rows without a modifier are skipped')
expect(prefixes[0]?.modifiers.length === 1, 'a single modifier is read')
expect(prefixes[1]?.itemTypes.join(',') === 'armo', 'itype columns build the type list')
expect(prefixes[2]?.modifiers.length === 2, 'two modifier slots are read')
const suffixes = affixesFromTable(parseTable([
'Id\tName\tLevel\titype1\tmod1code\tmod1min\tmod1max',
'of_might\tof Might\t5\tweap\tstrength\t2\t5',
'of_the_fox\tof the Fox\t3\t\tdexterity\t1\t3', // no itype: any item
].join('\n')), 'suffix')
expect(suffixes[1]?.itemTypes.length === 0, 'an affix with no itype applies to anything')
// --- eligibility ------------------------------------------------------------
const sword = weapons[0]!
const buckler = weapons[1]!
const longSword = weapons[2]!
expect(affixEligible(prefixes[0]!, sword, 12), 'a weapon affix fits a weapon at its level')
expect(!affixEligible(prefixes[0]!, sword, 11), 'an affix above the item level is refused')
expect(!affixEligible(prefixes[0]!, buckler, 20), 'a weapon affix is refused by armor')
expect(affixEligible(prefixes[1]!, buckler, 3), 'an armor affix fits armor')
expect(affixEligible(suffixes[1]!, buckler, 10), 'an unrestricted affix fits any base')
// --- rolling ----------------------------------------------------------------
const rollWith = (seed: number): string => {
const rolled = rollAffix(prefixes, sword, 20, new Rng(seed))
return JSON.stringify(rolled)
}
expect(rollWith(7) === rollWith(7), 'the same seed rolls the same affix and the same numbers')
const rollVariety = new Set([1, 2, 3, 4, 5, 6, 7, 8].map(rollWith))
expect(rollVariety.size > 1, 'different seeds roll differently')
const rolled = rollAffix(prefixes, sword, 20, new Rng(3))
expect(rolled !== null && rolled.rolls.length === rolled.affix.modifiers.length, 'each modifier gets a roll')
expect(rolled !== null && rolled.rolls.every(r => r.max >= r.min && r.max <= (r as { max: number }).max), 'rolls stay inside the affix range')
const neverEligible = rollAffix([prefixes[1]!], sword, 20, new Rng(1))
expect(neverEligible === null, 'no eligible affix rolls nothing rather than an illegal one')
// --- item construction ------------------------------------------------------
// Only one suffix is eligible here, so "the modifier shows up as a stat" is a
// statement about the pipeline rather than about which affix the seed picked.
const might = suffixes.find(affix => affix.id === 'of_might')!
const item = createItem(sword, prefixes, [might], new Rng(11), { level: 20, prefixChance: 1, suffixChance: 1 })
expect(item.prefix !== null && item.suffix !== null, 'forced chances produce both affixes')
expect(item.name === `${item.prefix!.name} ${sword.name} ${item.suffix!.name}`, 'the name is prefix + base + suffix')
expect((item.stats.damage ?? 0) >= sword.damage, 'base damage survives into the stats')
expect((item.stats.strength ?? 0) >= 2, 'affix modifiers appear as stats')
expect(item.suffix?.id === 'of_might', 'the only eligible suffix is the one rolled')
expect(item.value > sword.value, 'an affixed item is worth more than its base')
const plain = createItem(sword, prefixes, suffixes, new Rng(11), { level: 20, prefixChance: 0, suffixChance: 0 })
expect(plain.prefix === null && plain.suffix === null, 'zero chances roll no affixes')
expect(plain.name === sword.name, 'a plain item is named after its base')
const potion = misc[0]!
const stacked = createItem(potion, prefixes, suffixes, new Rng(1), { level: 1, prefixChance: 0, suffixChance: 0, stack: 99 })
expect(stacked.stack === potion.maxStack, 'a stack cannot exceed the base limit')
// --- inventory --------------------------------------------------------------
const bag = new Inventory(4, 3)
expect(bag.totalCells === 12 && bag.usedCells === 0, 'a new inventory is empty')
expect(bag.canPlace(1, 3, 0, 0), 'a tall item fits where there is room')
expect(!bag.canPlace(1, 3, 0, 1), 'an item that would leave the grid is refused')
expect(bag.canPlace(4, 1, 0, 0), 'an item exactly as wide as the grid fits')
expect(!bag.canPlace(5, 1, 0, 0), 'an item wider than the grid is refused')
const placedSword = bag.add(item)
expect(placedSword !== null, 'an item can be added')
expect(bag.usedCells === 3, 'adding occupies the item footprint')
expect(!bag.canPlace(1, 1, 0, 0), 'occupied cells refuse other items')
expect(bag.add(plain) !== null, 'a second item finds the next free slot')
expect(bag.contents.length === 2, 'both items are tracked')
// stacking onto an existing pile, with the remainder in a new slot
const potionBag = new Inventory(4, 2)
const firstPotion = potionBag.add(createItem(potion, prefixes, suffixes, new Rng(2), { level: 1, prefixChance: 0, suffixChance: 0, stack: 3 }))
expect(firstPotion !== null, 'the first pile lands')
const secondPotion = potionBag.add(createItem(potion, prefixes, suffixes, new Rng(2), { level: 1, prefixChance: 0, suffixChance: 0, stack: 4 }))
expect(secondPotion !== null, 'the second pile lands somewhere')
expect(potionBag.contents.length === 2, 'a stack that overflows opens a second slot instead of discarding')
const totalPotions = potionBag.contents.reduce((sum, entry) => sum + entry.item.stack, 0)
expect(totalPotions === 7, 'no potion is lost across the overflow')
const removed = bag.remove(placedSword!)
expect(removed && bag.usedCells === 3, `removing an item clears exactly its cells (used ${String(bag.usedCells)})`)
expect(bag.canPlace(1, 3, 0, 0), 'the freed cells can be reused')
const tiny = new Inventory(1, 1)
expect(tiny.add(item) === null, 'an item with no room is refused rather than dropped silently')
expect(tiny.add(goldItem(100)) !== null, 'gold fits where the sword did not')
expect(tiny.gold === 100, 'gold is summed from its piles')
const goldBag = new Inventory(2, 1)
goldBag.add(goldItem(4000))
goldBag.add(goldItem(4000))
expect(goldBag.gold === 8000, 'gold piles stack up to their limit and spill into another slot')
// --- drops ------------------------------------------------------------------
const bases = [...weapons, potion]
const dropOptions = { level: 10, dropChance: 1, goldChance: 0, goldRange: [10, 20] as const }
const runDrops = (seed: number): string => JSON.stringify(
Array.from({ length: 8 }, (_, index) => rollDrop(bases, prefixes, suffixes, new Rng(seed + index), dropOptions)),
)
expect(runDrops(5) === runDrops(5), 'the same seed produces the same drops')
expect(new Set([1, 2, 3, 4, 5, 6].map(runDrops)).size > 1, 'different seeds produce different drops')
expect(rollDrop(bases, prefixes, suffixes, new Rng(1), { ...dropOptions, dropChance: 0 }).kind === 'nothing', 'a zero drop chance drops nothing')
expect(rollDrop(bases, prefixes, suffixes, new Rng(1), { ...dropOptions, dropChance: 1 }).kind === 'item', 'a guaranteed drop is an item')
const goldDrop = rollDrop(bases, prefixes, suffixes, new Rng(1), { ...dropOptions, goldChance: 1 })
expect(goldDrop.kind === 'gold' && goldDrop.amount >= 10 && goldDrop.amount <= 20, 'gold drops inside its range')
// An item level below every base's requirement still has to yield something.
const lowLevelDrops = Array.from({ length: 12 }, (_, index) =>
rollDrop([longSword], prefixes, suffixes, new Rng(100 + index), { ...dropOptions, level: 1 }))
expect(lowLevelDrops.every(drop => drop.kind === 'item'), 'a table with no base under the level still drops the base it has')
// --- derived stats ----------------------------------------------------------
const sheet = new Inventory(4, 4)
const armour: ItemBase = { ...buckler, defense: 12, tags: ['armo'] }
sheet.add(createItem(armour, prefixes, suffixes, new Rng(4), { level: 20, prefixChance: 1, suffixChance: 0 }))
const defence = totalStat(sheet.contents, 'defense')
expect(defence >= 12, 'worn armor contributes its defense to the character sheet')
console.log(`checks ${String(checks)}`)
console.log(`problems ${String(problems.length)}`)
for (const problem of problems.slice(0, 12)) console.log(` - ${problem}`)
console.log(problems.length === 0 ? 'RESULT item behaviours hold' : 'RESULT FAILED')
process.exit(problems.length === 0 ? 0 : 1)