/** * 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 } /** * Known bitmask flags in D2 item header (`dwFlags`, 32-bit uint). * Corresponds to D2Common!6FD77180 (pack) / D2Common!6FD592C4 (unpack). */ export const ItemFlag = { IDENTIFIED: 1 << 0, // 0x00000001 (item is identified) SOCKETED: 1 << 3, // 0x00000008 (item has sockets / socketCount) NEW_ITEM: 1 << 4, // 0x00000010 (new / unread since game start) IS_EAR: 1 << 7, // 0x00000080 (player ear) STARTER_ITEM: 1 << 8, // 0x00000100 (starter / newbie gear) SIMPLE: 1 << 11, // 0x00000800 (compact item; terminates after code) ETHEREAL: 1 << 12, // 0x00001000 (ethereal; cannot be repaired) ANY: 1 << 13, // 0x00002000 (personalization flag) PERSONALIZED: 1 << 14, // 0x00004000 (has personalized name) GAMBLE: 1 << 15, // 0x00008000 (gambling item) RUNEWORD: 1 << 16, // 0x00010000 (runeword item with runeword data) } as const export type ItemFlagKey = keyof typeof ItemFlag /** Decoded boolean view of the 32-bit header flags bitmask. */ export interface ItemFlagsDecoded { readonly identified?: boolean | undefined readonly socketed?: boolean | undefined readonly newItem?: boolean | undefined readonly isEar?: boolean | undefined readonly starterItem?: boolean | undefined readonly simple?: boolean | undefined readonly ethereal?: boolean | undefined readonly personalized?: boolean | undefined readonly runeword?: boolean | undefined } /** Location / storage mode of the item in D2 bitstream (3 bits). */ export enum ItemMode { STORED = 0, // In inventory, stash, or Horadric Cube EQUIPPED = 1, // Equipped on character body BELT = 2, // Slotted in potion belt GROUND = 3, // Dropped on the ground CURSOR = 4, // Held on mouse cursor DROPPING = 5, // Currently in drop motion SOCKETED = 6, // Socketed into a parent item } /** Body equipment slot (4 bits). */ export enum EquippedSlot { NONE = 0, HEAD = 1, NECK = 2, TORSO = 3, RIGHT_HAND = 4, LEFT_HAND = 5, RIGHT_FINGER = 6, LEFT_FINGER = 7, WAIST = 8, FEET = 9, HANDS = 10, ALT_RIGHT_HAND = 11, ALT_LEFT_HAND = 12, } /** Container / storage page (3 bits). */ export enum StoragePage { INVENTORY = 0, EQUIPPED = 1, BELT = 2, CUBE = 4, STASH = 5, } /** Item location and positioning structure. */ export interface ItemLocation { /** Mode / location category (3 bits, 0-6). */ readonly mode: ItemMode | number /** Ground position X (16 bits) if mode is GROUND (3) or DROPPING (5). */ readonly worldX?: number | undefined /** Ground position Y (16 bits) if mode is GROUND (3) or DROPPING (5). */ readonly worldY?: number | undefined /** Equipped body slot (4 bits) if mode is EQUIPPED (1). */ readonly equippedSlot?: (EquippedSlot | number) | undefined /** Grid column (4 bits, 0-9) in inventory, stash, or cube. */ readonly gridX?: number | undefined /** Grid row (4 bits, 0-9) in inventory, stash, or cube. */ readonly gridY?: number | undefined /** Storage container / page (3 bits). */ readonly storagePage?: (StoragePage | number) | undefined } /** Quality tier of the item (4 bits). */ export enum ItemQuality { LOW = 1, // Inferior / Low Quality / Cracked / Crude / Damaged NORMAL = 2, // Normal base SUPERIOR = 3, // Superior / High Quality MAGIC = 4, // Magic SET = 5, // Set RARE = 6, // Rare UNIQUE = 7, // Unique CRAFTED = 8, // Crafted TEMPERED = 9, // Tempered (expansion prototype remnant) } /** One affix slot for Rare / Crafted items (1 bit present + 11-bit id). */ export interface ItemAffixSlot { /** Whether the affix slot is present/populated (1 bit). */ readonly present: boolean /** Affix ID from MagicPrefix.txt or MagicSuffix.txt (11 bits). */ readonly id?: number | undefined } /** One stat modifier entry from the item stat bitstream block. */ export interface ItemStatEntry { /** Stat ID corresponding to ItemStatCost.txt (9 bits, 0..510; 0x1FF = terminator). */ readonly statId: number /** Resolved stat name if known. */ readonly statName?: string | undefined /** Param bits value (e.g. skill id, class id, charge count) if CSvParam > 0. */ readonly param?: number | undefined /** Numerical value of the stat modifier (encoded with Save Bits and Save Add). */ readonly value: number } /** Concrete rolled property on an Item instance. */ export interface RolledItemProp { readonly code: string readonly param?: string | number | undefined readonly min: number readonly max: number readonly value: number } /** Runeword metadata attached to runeword items. */ export interface ItemRunewordData { /** Runeword ID (12 bits) from Runes.txt. */ readonly id: number /** Runeword parameter / property index (4 bits). */ readonly param?: number | undefined } /** Player ear specific data if IsEar flag is set. */ export interface ItemEarData { /** Class of the ear's victim (3 bits). */ readonly classIndex: number /** Level of the victim (7 bits). */ readonly level: number /** Character name of the victim (7-bit ASCII string). */ readonly name: string } /** * Decode a 32-bit flags integer into an ItemFlagsDecoded object. */ export function decodeItemFlags(rawFlags: number): ItemFlagsDecoded { return { identified: (rawFlags & ItemFlag.IDENTIFIED) !== 0, socketed: (rawFlags & ItemFlag.SOCKETED) !== 0, newItem: (rawFlags & ItemFlag.NEW_ITEM) !== 0, isEar: (rawFlags & ItemFlag.IS_EAR) !== 0, starterItem: (rawFlags & ItemFlag.STARTER_ITEM) !== 0, simple: (rawFlags & ItemFlag.SIMPLE) !== 0, ethereal: (rawFlags & ItemFlag.ETHEREAL) !== 0, personalized: (rawFlags & ItemFlag.PERSONALIZED) !== 0, runeword: (rawFlags & ItemFlag.RUNEWORD) !== 0, } } /** * Encode an ItemFlagsDecoded object back into a 32-bit flags integer. */ export function encodeItemFlags(flags: ItemFlagsDecoded): number { let raw = 0 if (flags.identified) raw |= ItemFlag.IDENTIFIED if (flags.socketed) raw |= ItemFlag.SOCKETED if (flags.newItem) raw |= ItemFlag.NEW_ITEM if (flags.isEar) raw |= ItemFlag.IS_EAR if (flags.starterItem) raw |= ItemFlag.STARTER_ITEM if (flags.simple) raw |= ItemFlag.SIMPLE if (flags.ethereal) raw |= ItemFlag.ETHEREAL if (flags.personalized) raw |= ItemFlag.PERSONALIZED if (flags.runeword) raw |= ItemFlag.RUNEWORD return raw >>> 0 } /** Format an item ID into a 4-character right-padded ASCII item code. */ export function formatItemCode(id: string): string { return (id + ' ').slice(0, 4) } /** Get string representation of item quality. */ export function getItemQualityName(quality: ItemQuality | number): string { switch (quality) { case ItemQuality.LOW: return 'low' case ItemQuality.NORMAL: return 'normal' case ItemQuality.SUPERIOR: return 'superior' case ItemQuality.MAGIC: return 'magic' case ItemQuality.SET: return 'set' case ItemQuality.RARE: return 'rare' case ItemQuality.UNIQUE: return 'unique' case ItemQuality.CRAFTED: return 'crafted' case ItemQuality.TEMPERED: return 'tempered' default: return 'normal' } } /** 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> /** 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 // --- Extended & Bit-Aligned Fields for .d2s Serialization (D2Common!6FD77180) --- /** 32-bit raw header flags bitmask (`dwFlags`). Preserved for lossless round-trip. */ readonly rawFlags?: number | undefined /** Decoded boolean representation of flags for ergonomic inspection. */ readonly flags?: ItemFlagsDecoded | undefined /** Save file format version (10 bits, e.g. 0 or 101/0x65 for 1.10+). */ readonly version?: number | undefined /** Location/mode and grid coordinates. */ readonly location?: ItemLocation | undefined /** 4-character ASCII item code (e.g. "swd ", "gld "). Stored as 32 bits. */ readonly code?: string | undefined /** Number of items socketed inside this item (3 bits, 0..7). */ readonly socketedCount?: number | undefined /** Sockets content list (the socketed items themselves). */ readonly socketedItems?: readonly Item[] | undefined // --- Extended Item Fields (omitted when simple is true) --- /** Unique item GUID / seed (`dwInitSeed`, 32 bits). */ readonly uniqueId?: number | undefined /** Item level (7 bits, 0..127). Mirrors `level`. */ readonly ilvl?: number | undefined /** Item quality tier (4 bits, 1..8). */ readonly quality?: (ItemQuality | number) | undefined /** Rarity string for game/scene compatibility ('normal' | 'magic' | 'rare' | 'unique' | 'set' | 'crafted'). */ readonly rarity?: string | undefined /** Whether graphic variation index is present (1 bit). */ readonly hasGraphic?: boolean | undefined /** Graphic variation index (3 bits) if hasGraphic is true. */ readonly graphic?: number | undefined /** Whether auto-affix is present (1 bit). */ readonly hasAutoAffix?: boolean | undefined /** Automagic / class auto-affix ID (11 bits) from AutoMagic.txt. */ readonly autoAffixId?: number | undefined // --- Quality Branches --- /** Low quality subtype (3 bits: 0 crude, 1 cracked, 2 damaged, 3 low quality). */ readonly qualitySubtype?: number | undefined /** Superior quality subtype (3 bits: 0 ar, 1 dmg, 2 def, etc.). */ readonly superiorSubtype?: number | undefined /** Magic prefix ID (11 bits) from MagicPrefix.txt. */ readonly magicPrefixId?: number | undefined /** Magic suffix ID (11 bits) from MagicSuffix.txt. */ readonly magicSuffixId?: number | undefined /** Set item ID (12 bits) from SetItems.txt. */ readonly setId?: number | undefined /** Rare / Crafted name prefix word ID (8 bits) from RarePrefix.txt. */ readonly rareName1?: number | undefined /** Rare / Crafted name suffix word ID (8 bits) from RareSuffix.txt. */ readonly rareName2?: number | undefined /** 6 Rare / Crafted affix slots (each 1 bit present + 11-bit id). */ readonly rareAffixes?: readonly ItemAffixSlot[] | undefined // --- Special Extensions & Attributes --- /** Runeword data (12 bits id + 4 bits param) if runeword flag is set. */ readonly runeword?: ItemRunewordData | undefined /** Personalized character name if personalized flag is set. */ readonly personalizedName?: string | undefined /** Tome ID (5 bits) for town portal / identify tomes. */ readonly tomeId?: number | undefined /** Realm data (1 bit flag + 96 bits) for Battle.net character saves. */ readonly realmData?: Uint8Array | undefined /** Current / rolled armor defense value (11 bits). */ readonly defense?: number | undefined /** Current durability (8 bits). */ readonly durability?: number | undefined /** Maximum durability (8 bits). */ readonly maxDurability?: number | undefined /** Quantity (9 bits) if stackable (mirrors `stack`). */ readonly quantity?: number | undefined /** Total number of sockets (4 bits, 1..6) if socketed flag is set. */ readonly totalSockets?: number | undefined /** Definition for Unique items from UniqueItems.txt. */ readonly uniqueItemDef?: any | undefined /** Definition for Set items from SetItems.txt. */ readonly setItemDef?: any | undefined /** Rolled affixes and modifiers for Rare items. */ readonly rolledRareAffixes?: any | undefined /** Rolled affixes and modifiers for Magic items. */ readonly rolledMagicAffixes?: any | undefined /** Rolled concrete properties for Unique / Set items. */ readonly rolledProps?: readonly RolledItemProp[] | undefined // --- Stat Modifier Lists --- /** Base stat list entries (each 9-bit statId + params/values, terminated by 0x1FF). */ readonly statList?: readonly ItemStatEntry[] | undefined /** Set bonus stat lists for set items (each block terminated by 0x1FF). */ readonly setBonuses?: readonly (readonly ItemStatEntry[])[] | undefined /** Runeword bonus stat list (terminated by 0x1FF). */ readonly runewordStats?: readonly ItemStatEntry[] | undefined // --- Ear Item Data --- /** Player ear details if IsEar flag is set. */ readonly earData?: ItemEarData | undefined // --- Preservation for Lossless Round-Trip --- /** Unparsed trailing bits or unknown raw bitstream bytes to ensure lossless re-saving. */ readonly unknownBits?: Uint8Array | undefined } /** 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>, 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>, 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 = {} 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))) const code = formatItemCode(base.id) const isMagic = prefix !== null || suffix !== null const quality = isMagic ? ItemQuality.MAGIC : ItemQuality.NORMAL const rawFlags = ItemFlag.IDENTIFIED | (base.kind === 'misc' && base.maxStack > 1 ? ItemFlag.SIMPLE : 0) const defense = base.defense > 0 ? (stats.defense ?? base.defense) : undefined const stack = Math.max(1, Math.min(options.stack ?? 1, base.maxStack)) return { base, prefix: prefix?.affix ?? null, suffix: suffix?.affix ?? null, level: options.level, name, stats, invWidth: base.invWidth, invHeight: base.invHeight, stack, value, rawFlags, flags: decodeItemFlags(rawFlags), version: 0x65, code, ilvl: options.level, quality, rarity: getItemQualityName(quality), defense, quantity: stack, } } /** * 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(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. const maxStack = item.base?.maxStack ?? 1 if (maxStack > 1) { for (const placed of this.items) { const placedBaseId = placed.item.base?.id const itemBaseId = item.base?.id if (!itemBaseId || placedBaseId !== itemBaseId) continue const placedMaxStack = placed.item.base?.maxStack ?? 1 const room = placedMaxStack - placed.item.stack if (room <= 0) continue const moved = Math.min(room, item.stack ?? 1) const nextStack = placed.item.stack + moved const merged: Item = { ...placed.item, stack: nextStack, ...(placed.item.quantity !== undefined ? { quantity: nextStack } : {}), } this.items[this.items.indexOf(placed)] = { ...placed, item: merged } const leftover = (item.stack ?? 1) - moved if (leftover <= 0) return { ...placed, item: merged } return this.add({ ...item, stack: leftover, ...(item.quantity !== undefined ? { quantity: leftover } : {}), }) } } const invWidth = typeof item.invWidth === 'number' ? item.invWidth : (item.base?.invWidth ?? 1) const invHeight = typeof item.invHeight === 'number' ? item.invHeight : (item.base?.invHeight ?? 1) for (let y = 0; y < this.height; y += 1) { for (let x = 0; x < this.width; x += 1) { if (!this.canPlace(invWidth, invHeight, x, y)) continue const id = this.nextId this.nextId += 1 for (let row = y; row < y + invHeight; row += 1) { for (let column = x; column < x + 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), 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)) const rawFlags = ItemFlag.IDENTIFIED | ItemFlag.SIMPLE return { base: GOLD_BASE, prefix: null, suffix: null, level: 0, name: 'Gold', stats: {}, invWidth: 1, invHeight: 1, stack, value: stack, rawFlags, flags: decodeItemFlags(rawFlags), version: 0x65, code: 'gld ', quality: ItemQuality.NORMAL, rarity: 'normal', quantity: 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) }