588 lines
19 KiB
TypeScript
588 lines
19 KiB
TypeScript
/**
|
|
* Items: bases, affixes, inventory and drops.
|
|
*
|
|
* This is the M3 skeleton, and its shape follows how Diablo II actually stores
|
|
* items: an item is a *base* (`weapons.txt` / `armor.txt` / `misc.txt` row) with
|
|
* an optional prefix and suffix from `MagicPrefix.txt` / `MagicSuffix.txt`, whose
|
|
* modifiers name stats from `ItemStatCost.txt`. Nothing is hardcoded here — a
|
|
* sword is a table row with a size and a damage value, and "Cruel" is a table row
|
|
* with a level requirement, a list of eligible item types and a modifier range.
|
|
*
|
|
* Two modelling decisions are worth stating because they are what make the system
|
|
* testable:
|
|
*
|
|
* - **Randomness is injected.** Affix rolls take an {@link Rng}, so a drop is a
|
|
* pure function of its seed and can be replayed exactly in a test.
|
|
* - **The inventory is a grid of occupied cells, not a list.** Diablo II items
|
|
* have width and height, cannot overlap, and can be rotated only in the sense
|
|
* that their shape is fixed — so placement is a real constraint that has to be
|
|
* modelled to make "inventory full" mean anything.
|
|
*
|
|
* Simplifications, called out rather than hidden: affixes are chosen uniformly
|
|
* among eligible ones instead of by the game's level-weighted tables, item
|
|
* requirements (strength/dexterity/level) are stored but not enforced, and
|
|
* durability, sockets and quality tiers (normal/exceptional/elite) are not
|
|
* modelled yet.
|
|
*/
|
|
import { Rng } from './rng.ts'
|
|
import { numberCell, textCell } from './tables.ts'
|
|
import type { DataTable } from './tables.ts'
|
|
|
|
/** What a base is, broadly. */
|
|
export type ItemKind = 'weapon' | 'armor' | 'misc'
|
|
|
|
/** One item base, read from a table row. */
|
|
export interface ItemBase {
|
|
/** Table id (for example `swd` for a short sword). */
|
|
readonly id: string
|
|
/** Display name. */
|
|
readonly name: string
|
|
/** Broad kind. */
|
|
readonly kind: ItemKind
|
|
/** Inventory width in cells. */
|
|
readonly invWidth: number
|
|
/** Inventory height in cells. */
|
|
readonly invHeight: number
|
|
/** How many of this base fit in one cell (1 for most things). */
|
|
readonly maxStack: number
|
|
/** Base gold value. */
|
|
readonly value: number
|
|
/** Weapon damage, when it is a weapon. */
|
|
readonly damage: number
|
|
/** Armor rating, when it is armor. */
|
|
readonly defense: number
|
|
/** Tags affixes match against (the item's type list). */
|
|
readonly tags: readonly string[]
|
|
/** Minimum level before it may drop. */
|
|
readonly level: number
|
|
}
|
|
|
|
/** One affix, read from a prefix or suffix row. */
|
|
export interface Affix {
|
|
/** Table id. */
|
|
readonly id: string
|
|
/** Name as it appears in an item's name. */
|
|
readonly name: string
|
|
/** Which side of the name it attaches to. */
|
|
readonly kind: 'prefix' | 'suffix'
|
|
/** Minimum item level for the affix to be possible. */
|
|
readonly level: number
|
|
/** Item-type tags it applies to; empty means any. */
|
|
readonly itemTypes: readonly string[]
|
|
/** Stat modifiers it contributes. */
|
|
readonly modifiers: readonly AffixModifier[]
|
|
}
|
|
|
|
/** One stat contribution of an affix. */
|
|
export interface AffixModifier {
|
|
/** Stat name, as `ItemStatCost.txt` spells it. */
|
|
readonly stat: string
|
|
/** Minimum roll. */
|
|
readonly min: number
|
|
/** Maximum roll. */
|
|
readonly max: number
|
|
}
|
|
|
|
/** A concrete item. */
|
|
export interface Item {
|
|
/** Base definition. */
|
|
readonly base: ItemBase
|
|
/** Rolled prefix, if any. */
|
|
readonly prefix: Affix | null
|
|
/** Rolled suffix, if any. */
|
|
readonly suffix: Affix | null
|
|
/** Item level the affixes were rolled at. */
|
|
readonly level: number
|
|
/** Final name, affixes included. */
|
|
readonly name: string
|
|
/** Final stats: base plus every rolled modifier. */
|
|
readonly stats: Readonly<Record<string, number>>
|
|
/** Inventory footprint. */
|
|
readonly invWidth: number
|
|
/** Inventory footprint. */
|
|
readonly invHeight: number
|
|
/** How many are stacked here. */
|
|
readonly stack: number
|
|
/** Gold value of one unit. */
|
|
readonly value: number
|
|
}
|
|
|
|
/** A rectangle in the inventory grid. */
|
|
export interface GridPlacement {
|
|
/** Column of the item's left edge. */
|
|
readonly x: number
|
|
/** Row of the item's top edge. */
|
|
readonly y: number
|
|
}
|
|
|
|
/** An item occupying a spot in the inventory. */
|
|
export interface PlacedItem extends GridPlacement {
|
|
/** The item itself. */
|
|
readonly item: Item
|
|
}
|
|
|
|
/** What happened when a drop was rolled. */
|
|
export type DropResult =
|
|
| { readonly kind: 'item'; readonly item: Item }
|
|
| { readonly kind: 'gold'; readonly amount: number }
|
|
| { readonly kind: 'nothing' }
|
|
|
|
/** Default footprint for a base whose row omits one. */
|
|
const DEFAULT_SIZE = 1
|
|
|
|
/**
|
|
* Read an item base from a table row.
|
|
*
|
|
* @param row - the record.
|
|
* @param kind - which table the row came from.
|
|
* @param rowIndex - position, for a fallback id.
|
|
* @returns the base.
|
|
*/
|
|
export function itemBaseFromRow(
|
|
row: Readonly<Record<string, string>>,
|
|
kind: ItemKind,
|
|
rowIndex: number,
|
|
): ItemBase {
|
|
const id = textCell(row, 'Id', textCell(row, 'code', `${kind}${String(rowIndex)}`))
|
|
const tags = textCell(row, 'Type', textCell(row, 'type', kind))
|
|
.split(/[,\s]+/)
|
|
.filter(tag => tag !== '')
|
|
return {
|
|
id,
|
|
name: textCell(row, 'Name', textCell(row, 'name', id)),
|
|
kind,
|
|
invWidth: numberCell(row, 'InvWidth', numberCell(row, 'invwidth', DEFAULT_SIZE)),
|
|
invHeight: numberCell(row, 'InvHeight', numberCell(row, 'invheight', DEFAULT_SIZE)),
|
|
maxStack: Math.max(1, numberCell(row, 'MaxStack', numberCell(row, 'maxstack', 1))),
|
|
value: Math.max(0, numberCell(row, 'Value', numberCell(row, 'cost', 1))),
|
|
damage: Math.max(0, numberCell(row, 'Damage', numberCell(row, 'mindam', 0))),
|
|
defense: Math.max(0, numberCell(row, 'Defense', numberCell(row, 'minac', 0))),
|
|
tags,
|
|
level: Math.max(0, numberCell(row, 'Level', numberCell(row, 'level', 1))),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read every base in a table.
|
|
*
|
|
* @param table - a weapons/armor/misc table.
|
|
* @param kind - which kind the table holds.
|
|
* @returns the bases.
|
|
*/
|
|
export function itemBasesFromTable(table: DataTable, kind: ItemKind): ItemBase[] {
|
|
return table.rows.map((row, index) => itemBaseFromRow(row, kind, index))
|
|
}
|
|
|
|
/**
|
|
* How many modifier slots the loader looks for.
|
|
*
|
|
* Diablo II's affix rows carry `mod1code/mod1min/mod1max` through `mod3…` for
|
|
* prefixes and suffixes alike; three is the shipped maximum.
|
|
*/
|
|
const MODIFIER_SLOTS = 3
|
|
/**
|
|
* How many item-type slots an affix row may restrict itself to (`itype1..7`).
|
|
*/
|
|
const ITYPE_SLOTS = 7
|
|
|
|
/**
|
|
* Read one affix from a prefix/suffix row.
|
|
*
|
|
* @param row - the record.
|
|
* @param kind - which side of the name the affix attaches to.
|
|
* @param rowIndex - position, for a fallback id.
|
|
* @returns the affix, or null when the row has no usable modifier.
|
|
*/
|
|
export function affixFromRow(
|
|
row: Readonly<Record<string, string>>,
|
|
kind: 'prefix' | 'suffix',
|
|
rowIndex: number,
|
|
): Affix | null {
|
|
const modifiers: AffixModifier[] = []
|
|
for (let slot = 1; slot <= MODIFIER_SLOTS; slot += 1) {
|
|
const stat = textCell(row, `mod${String(slot)}code`, textCell(row, `Mod${String(slot)}Code`, ''))
|
|
if (stat === '') continue
|
|
const min = numberCell(row, `mod${String(slot)}min`, numberCell(row, `Mod${String(slot)}Min`, 0))
|
|
const max = numberCell(row, `mod${String(slot)}max`, numberCell(row, `Mod${String(slot)}Max`, min))
|
|
modifiers.push({ stat, min, max: Math.max(min, max) })
|
|
}
|
|
if (modifiers.length === 0) return null
|
|
const itemTypes: string[] = []
|
|
for (let slot = 1; slot <= ITYPE_SLOTS; slot += 1) {
|
|
const value = textCell(row, `itype${String(slot)}`, textCell(row, `IType${String(slot)}`, ''))
|
|
for (const tag of value.split(/[,\s]+/)) if (tag !== '') itemTypes.push(tag)
|
|
}
|
|
const id = textCell(row, 'Id', textCell(row, 'Name', `${kind}${String(rowIndex)}`))
|
|
return {
|
|
id,
|
|
name: textCell(row, 'Name', id),
|
|
kind,
|
|
level: Math.max(0, numberCell(row, 'Level', numberCell(row, 'lvl', 1))),
|
|
itemTypes,
|
|
modifiers,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read every affix in a table.
|
|
*
|
|
* @param table - a `MagicPrefix` or `MagicSuffix` shaped table.
|
|
* @param kind - which side of the name the rows attach to.
|
|
* @returns the affixes, with unusable rows skipped.
|
|
*/
|
|
export function affixesFromTable(table: DataTable, kind: 'prefix' | 'suffix'): Affix[] {
|
|
const affixes: Affix[] = []
|
|
table.rows.forEach((row, index) => {
|
|
const affix = affixFromRow(row, kind, index)
|
|
if (affix !== null) affixes.push(affix)
|
|
})
|
|
return affixes
|
|
}
|
|
|
|
/**
|
|
* Whether an affix may roll on a base at a given item level.
|
|
*
|
|
* @param affix - the affix.
|
|
* @param base - the base item.
|
|
* @param level - the item level being rolled.
|
|
* @returns true when the affix is eligible.
|
|
*/
|
|
export function affixEligible(affix: Affix, base: ItemBase, level: number): boolean {
|
|
if (affix.level > level) return false
|
|
if (affix.itemTypes.length === 0) return true
|
|
// Diablo II matches the affix's `itype` list against the item's type list; an
|
|
// affix naming a type the base does not carry cannot roll on it.
|
|
return affix.itemTypes.some(tag => base.tags.includes(tag))
|
|
}
|
|
|
|
/**
|
|
* Roll one affix for a base.
|
|
*
|
|
* @param affixes - the candidate affixes.
|
|
* @param base - the base item.
|
|
* @param level - the item level.
|
|
* @param rng - the random source.
|
|
* @returns the affix and its rolled modifiers, or null when none is eligible.
|
|
*/
|
|
export function rollAffix(
|
|
affixes: readonly Affix[],
|
|
base: ItemBase,
|
|
level: number,
|
|
rng: Rng,
|
|
): { affix: Affix; rolls: AffixModifier[] } | null {
|
|
const eligible = affixes.filter(affix => affixEligible(affix, base, level))
|
|
const affix = rng.pick(eligible)
|
|
if (affix === undefined) return null
|
|
const rolls = affix.modifiers.map(modifier => ({
|
|
stat: modifier.stat,
|
|
min: rng.int(modifier.min, modifier.max),
|
|
max: rng.int(modifier.min, modifier.max),
|
|
}))
|
|
return { affix, rolls }
|
|
}
|
|
|
|
/** Options controlling how an item is built. */
|
|
export interface CreateItemOptions {
|
|
/** Item level; gates affixes. */
|
|
readonly level: number
|
|
/** Chance that a prefix rolls at all. */
|
|
readonly prefixChance?: number
|
|
/** Chance that a suffix rolls at all. */
|
|
readonly suffixChance?: number
|
|
/** How many units are stacked. */
|
|
readonly stack?: number
|
|
}
|
|
|
|
/**
|
|
* Build an item from a base, rolling its affixes.
|
|
*
|
|
* @param base - the base.
|
|
* @param prefixes - candidate prefixes.
|
|
* @param suffixes - candidate suffixes.
|
|
* @param rng - the random source.
|
|
* @param options - level, chances and stack size.
|
|
* @returns the item.
|
|
*/
|
|
export function createItem(
|
|
base: ItemBase,
|
|
prefixes: readonly Affix[],
|
|
suffixes: readonly Affix[],
|
|
rng: Rng,
|
|
options: CreateItemOptions,
|
|
): Item {
|
|
const prefixChance = options.prefixChance ?? 0.45
|
|
const suffixChance = options.suffixChance ?? 0.45
|
|
const prefix = rng.chance(prefixChance) ? rollAffix(prefixes, base, options.level, rng) : null
|
|
const suffix = rng.chance(suffixChance) ? rollAffix(suffixes, base, options.level, rng) : null
|
|
|
|
const stats: Record<string, number> = {}
|
|
if (base.damage > 0) stats.damage = base.damage
|
|
if (base.defense > 0) stats.defense = base.defense
|
|
for (const rolled of [prefix, suffix]) {
|
|
if (rolled === null) continue
|
|
for (const roll of rolled.rolls) {
|
|
stats[roll.stat] = (stats[roll.stat] ?? 0) + roll.max
|
|
}
|
|
}
|
|
|
|
const name = [prefix?.affix.name, base.name, suffix?.affix.name].filter(part => part !== undefined && part !== '').join(' ')
|
|
// An affixed item is worth more; the multiplier is a stand-in for the game's
|
|
// per-modifier pricing.
|
|
const affixCount = (prefix === null ? 0 : 1) + (suffix === null ? 0 : 1)
|
|
const value = Math.max(1, Math.round(base.value * (1 + affixCount * 0.75)))
|
|
|
|
return {
|
|
base,
|
|
prefix: prefix?.affix ?? null,
|
|
suffix: suffix?.affix ?? null,
|
|
level: options.level,
|
|
name,
|
|
stats,
|
|
invWidth: base.invWidth,
|
|
invHeight: base.invHeight,
|
|
stack: Math.max(1, Math.min(options.stack ?? 1, base.maxStack)),
|
|
value,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The inventory: a grid of cells, each either empty or holding a placed item.
|
|
*/
|
|
export class Inventory {
|
|
/** Grid width in cells. */
|
|
readonly width: number
|
|
/** Grid height in cells. */
|
|
readonly height: number
|
|
private readonly cells: (number | null)[]
|
|
private readonly items: PlacedItem[] = []
|
|
private nextId = 1
|
|
|
|
/**
|
|
* @param width - grid width in cells.
|
|
* @param height - grid height in cells.
|
|
*/
|
|
constructor(width = 10, height = 4) {
|
|
this.width = width
|
|
this.height = height
|
|
this.cells = new Array<number | null>(width * height).fill(null)
|
|
}
|
|
|
|
/** Items currently placed. */
|
|
get contents(): readonly PlacedItem[] {
|
|
return this.items
|
|
}
|
|
|
|
/** Occupied cell count. */
|
|
get usedCells(): number {
|
|
return this.items.reduce((total, placed) => total + placed.item.invWidth * placed.item.invHeight, 0)
|
|
}
|
|
|
|
/** Total cell count. */
|
|
get totalCells(): number {
|
|
return this.width * this.height
|
|
}
|
|
|
|
/**
|
|
* Whether an item of a given size fits at a position.
|
|
*
|
|
* @param width - item width.
|
|
* @param height - item height.
|
|
* @param x - column.
|
|
* @param y - row.
|
|
* @returns true when the whole footprint is inside the grid and empty.
|
|
*/
|
|
canPlace(width: number, height: number, x: number, y: number): boolean {
|
|
if (x < 0 || y < 0 || x + width > this.width || y + height > this.height) return false
|
|
for (let row = y; row < y + height; row += 1) {
|
|
for (let column = x; column < x + width; column += 1) {
|
|
if (this.cells[row * this.width + column] !== null) return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Add an item, stacking onto an existing stack when possible.
|
|
*
|
|
* @param item - the item to add.
|
|
* @returns where it landed, or null when there was no room.
|
|
*/
|
|
add(item: Item): PlacedItem | null {
|
|
// Stack first: a second potion belongs on the first one, not beside it.
|
|
if (item.base.maxStack > 1) {
|
|
for (const placed of this.items) {
|
|
if (placed.item.base.id !== item.base.id) continue
|
|
const room = placed.item.base.maxStack - placed.item.stack
|
|
if (room <= 0) continue
|
|
const moved = Math.min(room, item.stack)
|
|
const merged: Item = { ...placed.item, stack: placed.item.stack + moved }
|
|
this.items[this.items.indexOf(placed)] = { ...placed, item: merged }
|
|
const leftover = item.stack - moved
|
|
if (leftover <= 0) return { ...placed, item: merged }
|
|
return this.add({ ...item, stack: leftover })
|
|
}
|
|
}
|
|
for (let y = 0; y < this.height; y += 1) {
|
|
for (let x = 0; x < this.width; x += 1) {
|
|
if (!this.canPlace(item.invWidth, item.invHeight, x, y)) continue
|
|
const id = this.nextId
|
|
this.nextId += 1
|
|
for (let row = y; row < y + item.invHeight; row += 1) {
|
|
for (let column = x; column < x + item.invWidth; column += 1) {
|
|
this.cells[row * this.width + column] = id
|
|
}
|
|
}
|
|
const placed: PlacedItem = { x, y, item }
|
|
this.items.push(placed)
|
|
return placed
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Remove an item by its placement.
|
|
*
|
|
* @param placed - the placement to clear.
|
|
* @returns true when it was present.
|
|
*/
|
|
remove(placed: PlacedItem): boolean {
|
|
const index = this.items.indexOf(placed)
|
|
if (index === -1) return false
|
|
for (let row = placed.y; row < placed.y + placed.item.invHeight; row += 1) {
|
|
for (let column = placed.x; column < placed.x + placed.item.invWidth; column += 1) {
|
|
this.cells[row * this.width + column] = null
|
|
}
|
|
}
|
|
this.items.splice(index, 1)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Rebuild an inventory from explicit placements.
|
|
*
|
|
* A save restores items where they were, not wherever the first free slot
|
|
* happens to be today: an item that moved on load would be a save that lies.
|
|
* Placements are validated, so a corrupt save fails loudly instead of producing
|
|
* an overlapping bag.
|
|
*
|
|
* @param width - grid width.
|
|
* @param height - grid height.
|
|
* @param entries - placements to restore.
|
|
* @returns the inventory.
|
|
*/
|
|
static restore(width: number, height: number, entries: readonly PlacedItem[]): Inventory {
|
|
const inventory = new Inventory(width, height)
|
|
for (const entry of entries) {
|
|
if (!inventory.canPlace(entry.item.invWidth, entry.item.invHeight, entry.x, entry.y)) {
|
|
throw new Error(`saved item "${entry.item.name}" does not fit at ${String(entry.x)},${String(entry.y)}`)
|
|
}
|
|
const id = inventory.nextId
|
|
inventory.nextId += 1
|
|
for (let row = entry.y; row < entry.y + entry.item.invHeight; row += 1) {
|
|
for (let column = entry.x; column < entry.x + entry.item.invWidth; column += 1) {
|
|
inventory.cells[row * width + column] = id
|
|
}
|
|
}
|
|
inventory.items.push({ x: entry.x, y: entry.y, item: entry.item })
|
|
}
|
|
return inventory
|
|
}
|
|
|
|
/** How much gold is held, summed over gold stacks. */
|
|
get gold(): number {
|
|
return this.items
|
|
.filter(placed => placed.item.base.id === 'gold')
|
|
.reduce((total, placed) => total + placed.item.stack, 0)
|
|
}
|
|
}
|
|
|
|
/** A gold base, created on demand so gold needs no table row. */
|
|
export const GOLD_BASE: ItemBase = {
|
|
id: 'gold',
|
|
name: 'Gold',
|
|
kind: 'misc',
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
maxStack: 5000,
|
|
value: 1,
|
|
damage: 0,
|
|
defense: 0,
|
|
tags: ['gold'],
|
|
level: 0,
|
|
}
|
|
|
|
/**
|
|
* Build a gold pile.
|
|
*
|
|
* @param amount - how much gold.
|
|
* @returns the item.
|
|
*/
|
|
export function goldItem(amount: number): Item {
|
|
const stack = Math.max(1, Math.min(amount, GOLD_BASE.maxStack))
|
|
return {
|
|
base: GOLD_BASE,
|
|
prefix: null,
|
|
suffix: null,
|
|
level: 0,
|
|
name: 'Gold',
|
|
stats: {},
|
|
invWidth: 1,
|
|
invHeight: 1,
|
|
stack,
|
|
value: stack,
|
|
}
|
|
}
|
|
|
|
/** Options for {@link rollDrop}. */
|
|
export interface DropOptions {
|
|
/** Item level to roll at. */
|
|
readonly level: number
|
|
/** Chance that anything drops at all. */
|
|
readonly dropChance: number
|
|
/** Chance that a drop is gold rather than an item. */
|
|
readonly goldChance: number
|
|
/** Gold range when gold drops. */
|
|
readonly goldRange: readonly [number, number]
|
|
}
|
|
|
|
/**
|
|
* Roll what a monster leaves behind.
|
|
*
|
|
* @param bases - candidate bases.
|
|
* @param prefixes - candidate prefixes.
|
|
* @param suffixes - candidate suffixes.
|
|
* @param rng - the random source.
|
|
* @param options - level and chances.
|
|
* @returns the drop.
|
|
*/
|
|
export function rollDrop(
|
|
bases: readonly ItemBase[],
|
|
prefixes: readonly Affix[],
|
|
suffixes: readonly Affix[],
|
|
rng: Rng,
|
|
options: DropOptions,
|
|
): DropResult {
|
|
if (!rng.chance(options.dropChance)) return { kind: 'nothing' }
|
|
if (rng.chance(options.goldChance)) {
|
|
return { kind: 'gold', amount: rng.int(options.goldRange[0], options.goldRange[1]) }
|
|
}
|
|
// Only bases whose own level requirement is satisfied: a monster cannot drop
|
|
// gear that the item level gate forbids.
|
|
const eligible = bases.filter(base => base.level <= options.level)
|
|
const base = rng.pick(eligible.length > 0 ? eligible : bases)
|
|
if (base === undefined) return { kind: 'nothing' }
|
|
return { kind: 'item', item: createItem(base, prefixes, suffixes, rng, { level: options.level }) }
|
|
}
|
|
|
|
/**
|
|
* Sum a held stat across everything carried, for the character sheet.
|
|
*
|
|
* @param items - placed items.
|
|
* @param stat - the stat name.
|
|
* @returns the total.
|
|
*/
|
|
export function totalStat(items: readonly PlacedItem[], stat: string): number {
|
|
return items.reduce((total, placed) => total + (placed.item.stats[stat] ?? 0), 0)
|
|
}
|