diablo2-web/src/game/items.ts

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)
}