911 lines
32 KiB
TypeScript
911 lines
32 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
|
|
/** Authentic drop SFX sound linker from dropsound column (e.g. 'item_gold', 'item_armor', 'item_weapon'). */
|
|
readonly dropsound?: string | undefined
|
|
/** Authentic drop animation frame at which dropsound triggers (from dropsfxframe column). */
|
|
readonly dropsfxframe?: number | undefined
|
|
}
|
|
|
|
/** 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<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
|
|
|
|
// --- 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
|
|
}
|
|
|
|
/** 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 !== '')
|
|
const dropsound = textCell(row, 'dropsound', textCell(row, 'DropSound', '')) || undefined
|
|
const dropsfxframe = numberCell(row, 'dropsfxframe', numberCell(row, 'DropSfxFrame', 0)) || undefined
|
|
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))),
|
|
...(dropsound ? { dropsound } : {}),
|
|
...(dropsfxframe !== undefined ? { dropsfxframe } : {}),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)))
|
|
|
|
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<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.
|
|
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)
|
|
}
|
|
|
|
set gold(amount: number) {
|
|
const target = Math.max(0, Math.floor(amount))
|
|
const goldIndices: number[] = []
|
|
this.items.forEach((p, idx) => {
|
|
if (p.item.base?.id === 'gold') goldIndices.push(idx)
|
|
})
|
|
for (let i = goldIndices.length - 1; i >= 0; i--) {
|
|
const p = this.items[goldIndices[i]!]!
|
|
const id = this.cells[p.y * this.width + p.x]
|
|
for (let r = 0; r < (p.item.invHeight ?? 1); r++) {
|
|
for (let c = 0; c < (p.item.invWidth ?? 1); c++) {
|
|
const cellIdx = (p.y + r) * this.width + (p.x + c)
|
|
if (this.cells[cellIdx] === id) this.cells[cellIdx] = null
|
|
}
|
|
}
|
|
this.items.splice(goldIndices[i]!, 1)
|
|
}
|
|
if (target > 0) {
|
|
this.add(goldItem(target))
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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: 2_500_000,
|
|
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.floor(amount))
|
|
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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|