194 lines
10 KiB
TypeScript
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)
|