From 24fbc3b5c19a7c039d639ca94b059272946ca82c Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 18 Sep 2026 11:12:17 +0000 Subject: [PATCH] =?UTF-8?q?feat(items):=20=E5=86=BB=E7=BB=93=E7=89=A9?= =?UTF-8?q?=E5=93=81=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84=E4=B8=8E=20.d2s?= =?UTF-8?q?=20=E4=BD=8D=E5=AF=B9=E9=BD=90=E8=A7=84=E6=A0=BC=E9=A2=84?= =?UTF-8?q?=E7=95=99=20(Issue=20#88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/item-d2s-spec.md | 187 +++++++++++ src/game/items.ts | 321 ++++++++++++++++++- tests/item-struct.test.ts | 630 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 1135 insertions(+), 3 deletions(-) create mode 100644 docs/item-d2s-spec.md create mode 100644 tests/item-struct.test.ts diff --git a/docs/item-d2s-spec.md b/docs/item-d2s-spec.md new file mode 100644 index 0000000..24448f7 --- /dev/null +++ b/docs/item-d2s-spec.md @@ -0,0 +1,187 @@ +# Diablo II Item Bitstream Specification (.d2s / D2Common) + +> **Gold Standard Reference**: `D2Common!6FD77180` (Packing / Serialization) and `D2Common!6FD592C4` (Unpacking / Deserialization). +> **Target Version**: Diablo II 1.10 / 1.13c Lord of Destruction character save (`.d2s`) item list. + +--- + +## 1. Overview and Architecture + +Diablo II stores item data inside character save files (`.d2s`), network packets, and memory structures as a **variable-length, bit-aligned stream**. Unlike byte-aligned structures, fields in the item stream are packed tightly with arbitrary bit widths (e.g. 1 bit, 3 bits, 7 bits, 9 bits, 11 bits, 12 bits) without byte padding between fields. + +### 1.1 Bitstream Conventions +- **Byte and Bit Ordering**: Little-endian bitstream. Bits are read from lowest to highest within each byte (LSB first: bit 0 of byte 0, bit 1 of byte 0, ..., bit 7 of byte 0, bit 0 of byte 1, etc.). +- **String Encoding**: + - Item base code (4 bytes / 32 bits): 4 ASCII characters right-padded with ASCII spaces (`0x20`), stored in byte order. + - Personalized and Ear names: Null-terminated strings using 7-bit ASCII characters (`0x00` terminator is 7 zero bits). +- **Two Major Modes**: + 1. **Simple / Compact Item**: Items with the `Simple` flag (bit 11) set (potions, scrolls, gold, gems, runes, keys, simple quest items). Serialization terminates immediately after the item base code and socket count. + 2. **Extended Item**: Items with the `Simple` flag cleared (weapons, armor, jewelry, charms, complex items). Includes full fingerprint, ilvl, quality branch, defense/durability, sockets, and stat list blocks. + +--- + +## 2. Complete Bitstream Layout + +The table below outlines the sequential bitstream order executed by `D2Common!6FD592C4` (unpack) and `D2Common!6FD77180` (pack): + +| Bit Offset (Rel) | Bit Width | Field Name | Type | Condition / Presence | Description | +|---|---|---|---|---|---| +| `0` | 16 | `magic` | ASCII | Always | `"JM"` header magic (`0x4A`, `0x4D`) | +| `16` | 32 | `dwFlags` | Bitmask | Always | Header flags (see §3 for bitmask breakdown) | +| `48` | 10 | `version` | uint | Always | Item format version (`0` or `101` for 1.10+) | +| `58` | 3 | `mode` | uint | Always | Location mode (0: Stored, 1: Equipped, 2: Belt, 3: Ground, 4: Cursor, 5: Dropping, 6: Socketed) | +| `61` | 32 or 15 | **Position Data** | Branch | Always | Dependent on `mode` (see §2.1 below) | +| — | 32 | `itemCode` | ASCII[4] | Always (except Ear) | 4 ASCII characters right-padded with space (e.g. `"swd "`, `"gld "`) | +| — | 3 | `socketedCount` | uint | Always (except Ear) | Number of gems/runes/jewels socketed inside this item (0..7) | +| *CUTOFF* | — | *Simple Item End* | — | `dwFlags.Simple == 1` | **If Simple Item flag is set, serialization STOPS here.** | +| — | 32 | `uniqueId` | uint32 | Extended item | Item GUID / seed (`dwInitSeed`, item fingerprint) | +| — | 7 | `ilvl` | uint | Extended item | Item level (0..127) | +| — | 4 | `quality` | uint | Extended item | Item quality tier (1..8, see §4) | +| — | 1 | `hasGraphic` | bool | Extended item | 1 if graphic variation index is present | +| — | 3 | `graphic` | uint | `hasGraphic == 1` | Graphic variant index (0..7) | +| — | 1 | `hasAutoAffix` | bool | Extended item | 1 if class auto-affix (Automagic) is present | +| — | 11 | `autoAffixId` | uint | `hasAutoAffix == 1` | Automagic affix ID (from `AutoMagic.txt`) | +| — | Variable | **Quality Data** | Branch | Extended item | Dependent on `quality` (see §4) | +| — | 16 | `runewordData` | uint | `dwFlags.Runeword == 1` | 12-bit Runeword ID (`Runes.txt`) + 4-bit parameter | +| — | Variable | `personalizedName`| String | `dwFlags.Personalized == 1`| 7-bit ASCII characters terminated by `0b0000000` (max 15 chars) | +| — | 5 | `tomeId` | uint | Code == `"ibk "` or `"tbk "` | Tome ID (5 bits) | +| — | 1 + 96 | `realmData` | Bytes | Extended item | 1 bit flag; if set, followed by 96 bits of realm data | +| — | 11 | `defense` | uint | Item kind is Armor | Current armor defense (value - 10 + `Armor.minac`) | +| — | 8 (+8) | `durability` | uint | Item has Durability | `maxDurability` (8 bits); if max > 0: `currentDurability` (8 bits) | +| — | 9 | `quantity` | uint | Item is Stackable | Current stack count (e.g. javelins, keys, arrows) | +| — | 4 | `totalSockets` | uint | `dwFlags.Socketed == 1` | Total number of sockets on the item (1..6) | +| — | Variable | **Set Bonuses** | Branch | `quality == 5` (Set) | 5 bits property list count; active set bonus stat lists (each terminated by `0x1FF`) | +| — | Variable | **Base Stat List** | StatList | Extended item | Stat modifier list (9-bit statId + params/values), terminated by `0x1FF` | +| — | Variable | **Runeword Stats** | StatList | `dwFlags.Runeword == 1` | Extra runeword stat list, terminated by `0x1FF` | + +--- + +### 2.1 Positioning Branch (Offset 61) + +The position encoding branches on `mode`: + +1. **Ground / Dropping Mode (`mode == 3` or `mode == 5`)**: + - `worldX`: 16 bits (World coordinate X) + - `worldY`: 16 bits (World coordinate Y) + - *Total*: 32 bits + +2. **Equipped, Belt, Stored, Cursor, Socketed Mode (`mode != 3` and `mode != 5`)**: + - `equippedSlot`: 4 bits (Equipped body slot index) + - `0`: None + - `1`: Head (Helm) + - `2`: Neck (Amulet) + - `3`: Torso (Armor) + - `4`: Right Hand (Primary Weapon / Shield) + - `5`: Left Hand (Secondary Weapon / Shield) + - `6`: Right Finger (Ring) + - `7`: Left Finger (Ring) + - `8`: Waist (Belt) + - `9`: Feet (Boots) + - `10`: Hands (Gloves) + - `11`: Alt Right Hand (Weapon swap) + - `12`: Alt Left Hand (Weapon swap) + - `gridX`: 4 bits (Inventory column `0..9`, Belt column `0..3`) + - `gridY`: 4 bits (Inventory row `0..9`, Belt row `0..3`) + - `storagePage`: 3 bits: + - `0`: Inventory + - `1`: Equipped (Body) + - `2`: Belt + - `4`: Horadric Cube + - `5`: Stash + - *Total*: 15 bits + +--- + +## 3. Header Flags Bitmask (`dwFlags`, 32 bits) + +The 32-bit integer at bit offset 16 controls item state and conditional decoding: + +| Bit | Hex Value | Name | Description | +|---|---|---|---| +| `0` | `0x00000001` | `Identified` | `1` = Item is identified; `0` = Unidentified | +| `1` | `0x00000002` | `Unk1` | Reserved / unused | +| `2` | `0x00000004` | `Unk2` | Reserved / unused | +| `3` | `0x00000008` | `Socketed` | `1` = Item has sockets (and socket count field present) | +| `4` | `0x00000010` | `NewItem` | `1` = Item picked up or generated since game start | +| `5` | `0x00000020` | `Unk5` | Reserved / unused | +| `6` | `0x00000040` | `Unk6` | Reserved / unused | +| `7` | `0x00000080` | `IsEar` | `1` = Item is a player Ear | +| `8` | `0x00000100` | `StarterItem` | `1` = Starter / beginner equipment (e.g. cracked sash) | +| `9` | `0x00000200` | `Unk9` | Reserved / unused | +| `10` | `0x00000400` | `Unk10` | Reserved / unused | +| `11` | `0x00000800` | `Simple` | `1` = Compact item (stops after socketed count); `0` = Extended item | +| `12` | `0x00001000` | `Ethereal` | `1` = Ethereal (cannot be repaired, translucent art, stat bonus) | +| `13` | `0x00002000` | `Any` | Internal personalization / save flag | +| `14` | `0x00004000` | `Personalized` | `1` = Personalized name present (Anya quest reward) | +| `15` | `0x00008000` | `Gamble` | Gambling item flag | +| `16` | `0x00010000` | `Runeword` | `1` = Active Runeword (runeword data and stats present) | +| `17..31` | `0xFFFE0000` | `Reserved` | Engine and server internal flags | + +--- + +## 4. Quality Field and Branches (4 bits) + +The 4-bit `quality` field dictates the affix and identity layout: + +| Value | Quality Tier | Encoded Fields | +|---|---|---| +| `1` | **Low Quality** | `qualitySubtype` (3 bits: `0` crude, `1` cracked, `2` damaged, `3` low quality) | +| `2` | **Normal** | None (standard base item) | +| `3` | **Superior** | `superiorSubtype` (3 bits: attack rating, defense, durability, etc.) | +| `4` | **Magic** | `prefixId` (11 bits, `MagicPrefix.txt`), `suffixId` (11 bits, `MagicSuffix.txt`) | +| `5` | **Set** | `setId` (12 bits, index into `SetItems.txt`) | +| `6` | **Rare** | `rareName1` (8 bits), `rareName2` (8 bits), followed by 6 affix slots (each 1 bit present + 11-bit id) | +| `7` | **Unique** | `uniqueId` (12 bits, index into `UniqueItems.txt`) | +| `8` | **Crafted** | `rareName1` (8 bits), `rareName2` (8 bits), followed by 6 affix slots (each 1 bit present + 11-bit id) | +| `9` | **Tempered** | Rare-style affix structure (expansion prototype remnant) | + +### 4.1 Rare & Crafted Affix Slots (6 Slots) +Rare (6) and Crafted (8) items carry exactly 6 affix slots. For each slot `i = 0..5`: +1. Read `present` (1 bit). +2. If `present == 1`: read `affixId` (11 bits, index into `MagicPrefix.txt` or `MagicSuffix.txt`). +3. If `present == 0`: move to next slot. + +--- + +## 5. Stat Lists and `ItemStatCost.txt` Bit Alignment + +Diablo II encodes magical bonuses and base stats as an open-ended list of properties, terminated by the 9-bit sentinel `0x1FF` (decimal `511`). + +### 5.1 Stat Entry Encoding Loop +1. Read `statId` (9 bits). +2. If `statId == 0x1FF`: **End of Stat List**. +3. Lookup `statId` in `ItemStatCost.txt`: + - `CSvParam` / `Save Param Bits`: If non-zero, read `param` (`CSvParam` bits). Used for skill ID, character class ID, elemental masteries, or charges. + - `CSvBits` / `Save Bits`: Read unsigned integer of length `CSvBits`. + - `CSvSigned`: Indicates signed or unsigned interpretation. + - `Save Add`: Value offset. `finalValue = rawValue - SaveAdd`. +4. Store `{ statId, statName, param, value }` in item's stat list. +5. Repeat from Step 1. + +### 5.2 Multiple Stat Blocks +Extended items can contain multiple stat list blocks in sequence: +- **Base Stats Block**: Always present on extended items; ends with `0x1FF`. +- **Set Item Bonuses**: For Set items, 5 bits indicate bonus properties; each active set bonus tier has its own stat list ending with `0x1FF`. +- **Runeword Bonuses**: For Runewords (`dwFlags.Runeword == 1`), a dedicated runeword bonus stat list follows, ending with `0x1FF`. + +--- + +## 6. Player Ear Items (`dwFlags.IsEar == 1`) + +If bit 7 of `dwFlags` is set: +- Item code is `"ear "`. +- Followed by: + - `classIndex`: 3 bits (0: Amazon, 1: Sorceress, 2: Necromancer, 3: Paladin, 4: Barbarian, 5: Druid, 6: Assassin). + - `level`: 7 bits (Player level at time of death). + - `playerName`: 7-bit ASCII characters terminated by `0b0000000`. +- Ear decoding terminates here; no extended stats or durability are encoded. + +--- + +## 7. Lossless Round-Trip and Preservation Guarantee + +To ensure perfect interoperability between this engine, official Diablo II binaries, and third-party save editors (e.g. Hero Editor, Gomule): + +1. **`rawFlags: number`**: The raw 32-bit flags integer is preserved verbatim so unknown, reserved, or engine-specific flag bits are never stripped or shifted. +2. **`unknownBits?: Uint8Array`**: Any trailing padding bits, vendor/realm payloads, or unparsed bitstream extensions are retained alongside the structured fields. +3. **Deterministic Serialization**: When re-packing to bitstream via `D2Common!6FD77180`, fields are written back in the identical sequence and bit alignments specified in this document. diff --git a/src/game/items.ts b/src/game/items.ts index ddcd7a6..930cd48 100644 --- a/src/game/items.ts +++ b/src/game/items.ts @@ -83,6 +83,200 @@ export interface AffixModifier { 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 +} + +/** 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. */ @@ -105,6 +299,94 @@ export interface Item { 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 + + // --- 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. */ @@ -331,6 +613,13 @@ export function createItem( 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, @@ -340,8 +629,17 @@ export function createItem( stats, invWidth: base.invWidth, invHeight: base.invHeight, - stack: Math.max(1, Math.min(options.stack ?? 1, base.maxStack)), + stack, value, + rawFlags, + flags: decodeItemFlags(rawFlags), + version: 0x65, + code, + ilvl: options.level, + quality, + rarity: getItemQualityName(quality), + defense, + quantity: stack, } } @@ -415,11 +713,20 @@ export class Inventory { 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 } + 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 - moved if (leftover <= 0) return { ...placed, item: merged } - return this.add({ ...item, stack: leftover }) + return this.add({ + ...item, + stack: leftover, + ...(item.quantity !== undefined ? { quantity: leftover } : {}), + }) } } for (let y = 0; y < this.height; y += 1) { @@ -520,6 +827,7 @@ export const GOLD_BASE: ItemBase = { */ 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, @@ -531,6 +839,13 @@ export function goldItem(amount: number): Item { invHeight: 1, stack, value: stack, + rawFlags, + flags: decodeItemFlags(rawFlags), + version: 0x65, + code: 'gld ', + quality: ItemQuality.NORMAL, + rarity: 'normal', + quantity: stack, } } diff --git a/tests/item-struct.test.ts b/tests/item-struct.test.ts new file mode 100644 index 0000000..1f59ba4 --- /dev/null +++ b/tests/item-struct.test.ts @@ -0,0 +1,630 @@ +import { describe, it, expect } from 'vitest' +import { + ItemFlag, + ItemMode, + EquippedSlot, + StoragePage, + ItemQuality, + decodeItemFlags, + encodeItemFlags, + formatItemCode, + getItemQualityName, + createItem, + goldItem, + GOLD_BASE, + Inventory, +} from '../src/game/items.ts' +import type { + Item, + ItemBase, + ItemLocation, + ItemAffixSlot, + ItemStatEntry, + ItemRunewordData, + ItemEarData, +} from '../src/game/items.ts' +import { Rng } from '../src/game/rng.ts' + +describe('Item Bit-Aligned Data Structure & Specifications (D2Common!6FD77180 / D2Common!6FD592C4)', () => { + describe('Header Flags Bitmask & Encoders', () => { + it('defines standard D2 item header flag bitmasks', () => { + expect(ItemFlag.IDENTIFIED).toBe(0x00000001) + expect(ItemFlag.SOCKETED).toBe(0x00000008) + expect(ItemFlag.NEW_ITEM).toBe(0x00000010) + expect(ItemFlag.IS_EAR).toBe(0x00000080) + expect(ItemFlag.STARTER_ITEM).toBe(0x00000100) + expect(ItemFlag.SIMPLE).toBe(0x00000800) + expect(ItemFlag.ETHEREAL).toBe(0x00001000) + expect(ItemFlag.ANY).toBe(0x00002000) + expect(ItemFlag.PERSONALIZED).toBe(0x00004000) + expect(ItemFlag.GAMBLE).toBe(0x00008000) + expect(ItemFlag.RUNEWORD).toBe(0x00010000) + }) + + it('decodes and encodes 32-bit flags bitmask symmetrically', () => { + const flags = { + identified: true, + socketed: true, + newItem: false, + isEar: false, + starterItem: false, + simple: false, + ethereal: true, + personalized: true, + runeword: true, + } + const raw = encodeItemFlags(flags) + expect(raw & ItemFlag.IDENTIFIED).toBeTruthy() + expect(raw & ItemFlag.SOCKETED).toBeTruthy() + expect(raw & ItemFlag.ETHEREAL).toBeTruthy() + expect(raw & ItemFlag.PERSONALIZED).toBeTruthy() + expect(raw & ItemFlag.RUNEWORD).toBeTruthy() + expect(raw & ItemFlag.SIMPLE).toBeFalsy() + + const decoded = decodeItemFlags(raw) + expect(decoded.identified).toBe(true) + expect(decoded.socketed).toBe(true) + expect(decoded.ethereal).toBe(true) + expect(decoded.personalized).toBe(true) + expect(decoded.runeword).toBe(true) + expect(decoded.simple).toBe(false) + expect(decoded.newItem).toBe(false) + }) + }) + + describe('Item Code & Quality Helpers', () => { + it('formats 3-4 character item codes with right-padded spaces to 32 bits (4 bytes)', () => { + expect(formatItemCode('swd')).toBe('swd ') + expect(formatItemCode('gld')).toBe('gld ') + expect(formatItemCode('hpot')).toBe('hpot') + expect(formatItemCode('am01')).toBe('am01') + }) + + it('maps ItemQuality enums to names correctly', () => { + expect(getItemQualityName(ItemQuality.LOW)).toBe('low') + expect(getItemQualityName(ItemQuality.NORMAL)).toBe('normal') + expect(getItemQualityName(ItemQuality.SUPERIOR)).toBe('superior') + expect(getItemQualityName(ItemQuality.MAGIC)).toBe('magic') + expect(getItemQualityName(ItemQuality.SET)).toBe('set') + expect(getItemQualityName(ItemQuality.RARE)).toBe('rare') + expect(getItemQualityName(ItemQuality.UNIQUE)).toBe('unique') + expect(getItemQualityName(ItemQuality.CRAFTED)).toBe('crafted') + expect(getItemQualityName(ItemQuality.TEMPERED)).toBe('tempered') + }) + }) + + describe('Simple / Compact Items (dwFlags.Simple == 1)', () => { + it('creates gold item conforming to simple bitstream layout', () => { + const gold = goldItem(1500) + expect(gold.base.id).toBe('gold') + expect(gold.code).toBe('gld ') + expect(gold.stack).toBe(1500) + expect(gold.quantity).toBe(1500) + expect(gold.flags?.simple).toBe(true) + expect(gold.rawFlags! & ItemFlag.SIMPLE).toBeTruthy() + expect(gold.quality).toBe(ItemQuality.NORMAL) + expect(gold.version).toBe(0x65) + }) + + it('populates simple potion/scroll item without extended fields', () => { + const potionBase: ItemBase = { + id: 'hp1', + name: 'Minor Healing Potion', + kind: 'misc', + invWidth: 1, + invHeight: 1, + maxStack: 5, + value: 20, + damage: 0, + defense: 0, + tags: ['misc'], + level: 1, + } + const item: Item = { + base: potionBase, + prefix: null, + suffix: null, + level: 1, + name: 'Minor Healing Potion', + stats: {}, + invWidth: 1, + invHeight: 1, + stack: 3, + value: 20, + code: 'hp1 ', + rawFlags: ItemFlag.IDENTIFIED | ItemFlag.SIMPLE, + flags: { identified: true, simple: true }, + version: 0x65, + location: { + mode: ItemMode.BELT, + gridX: 2, + gridY: 0, + storagePage: StoragePage.BELT, + }, + quantity: 3, + } + + expect(item.code).toBe('hp1 ') + expect(item.flags?.simple).toBe(true) + expect(item.location?.mode).toBe(ItemMode.BELT) + expect(item.location?.storagePage).toBe(StoragePage.BELT) + expect(item.uniqueId).toBeUndefined() + expect(item.statList).toBeUndefined() + }) + }) + + describe('Extended Item Structure & Location Modes (D2Common!6FD77180)', () => { + const swordBase: ItemBase = { + id: 'crs', + name: 'Crystal Sword', + kind: 'weapon', + invWidth: 2, + invHeight: 3, + maxStack: 1, + value: 1200, + damage: 15, + defense: 0, + tags: ['weap', 'swor'], + level: 11, + } + + it('supports Stored Mode (Inventory, Cube, Stash)', () => { + const storedLocation: ItemLocation = { + mode: ItemMode.STORED, + gridX: 4, + gridY: 2, + storagePage: StoragePage.INVENTORY, + } + expect(storedLocation.mode).toBe(ItemMode.STORED) + expect(storedLocation.gridX).toBe(4) + expect(storedLocation.gridY).toBe(2) + expect(storedLocation.storagePage).toBe(StoragePage.INVENTORY) + expect(storedLocation.worldX).toBeUndefined() + }) + + it('supports Equipped Mode on character body', () => { + const equippedLocation: ItemLocation = { + mode: ItemMode.EQUIPPED, + equippedSlot: EquippedSlot.RIGHT_HAND, + storagePage: StoragePage.EQUIPPED, + } + expect(equippedLocation.mode).toBe(ItemMode.EQUIPPED) + expect(equippedLocation.equippedSlot).toBe(EquippedSlot.RIGHT_HAND) + expect(equippedLocation.storagePage).toBe(StoragePage.EQUIPPED) + }) + + it('supports Ground and Dropping Modes with 16-bit world coordinates', () => { + const groundLocation: ItemLocation = { + mode: ItemMode.GROUND, + worldX: 25140, + worldY: 18420, + } + expect(groundLocation.mode).toBe(ItemMode.GROUND) + expect(groundLocation.worldX).toBe(25140) + expect(groundLocation.worldY).toBe(18420) + expect(groundLocation.equippedSlot).toBeUndefined() + }) + + it('populates Magic Item with prefixId, suffixId, and stats list', () => { + const statEntries: ItemStatEntry[] = [ + { statId: 21, statName: 'tohit', value: 75 }, + { statId: 0, statName: 'strength', value: 12 }, + ] + const magicItem: Item = { + base: swordBase, + prefix: { + id: 'bronze', + name: 'Bronze', + kind: 'prefix', + level: 1, + itemTypes: ['weap'], + modifiers: [{ stat: 'tohit', min: 10, max: 20 }], + }, + suffix: { + id: 'of_might', + name: 'of Might', + kind: 'suffix', + level: 5, + itemTypes: ['weap'], + modifiers: [{ stat: 'strength', min: 1, max: 5 }], + }, + level: 25, + name: 'Bronze Crystal Sword of Might', + stats: { damage: 15, tohit: 75, strength: 12 }, + invWidth: 2, + invHeight: 3, + stack: 1, + value: 2500, + rawFlags: ItemFlag.IDENTIFIED, + flags: { identified: true }, + version: 0x65, + code: 'crs ', + uniqueId: 0xdeadbeef, + ilvl: 25, + quality: ItemQuality.MAGIC, + rarity: 'magic', + magicPrefixId: 104, + magicSuffixId: 215, + durability: 20, + maxDurability: 20, + statList: statEntries, + } + + expect(magicItem.quality).toBe(ItemQuality.MAGIC) + expect(magicItem.magicPrefixId).toBe(104) + expect(magicItem.magicSuffixId).toBe(215) + expect(magicItem.statList?.length).toBe(2) + expect(magicItem.statList?.[0]?.statId).toBe(21) + expect(magicItem.uniqueId).toBe(0xdeadbeef) + }) + + it('populates Rare Item with 6 affix slots and dual rare names', () => { + const rareAffixes: ItemAffixSlot[] = [ + { present: true, id: 105 }, + { present: true, id: 216 }, + { present: true, id: 310 }, + { present: false }, + { present: false }, + { present: false }, + ] + const rareItem: Item = { + base: swordBase, + prefix: null, + suffix: null, + level: 45, + name: 'Cruel Song Crystal Sword', + stats: { damage: 45 }, + invWidth: 2, + invHeight: 3, + stack: 1, + value: 12500, + rawFlags: ItemFlag.IDENTIFIED, + flags: { identified: true }, + version: 0x65, + code: 'crs ', + uniqueId: 0x98765432, + ilvl: 45, + quality: ItemQuality.RARE, + rarity: 'rare', + rareName1: 18, + rareName2: 42, + rareAffixes, + durability: 35, + maxDurability: 35, + } + + expect(rareItem.quality).toBe(ItemQuality.RARE) + expect(rareItem.rareName1).toBe(18) + expect(rareItem.rareName2).toBe(42) + expect(rareItem.rareAffixes?.length).toBe(6) + expect(rareItem.rareAffixes?.[0]?.present).toBe(true) + expect(rareItem.rareAffixes?.[0]?.id).toBe(105) + expect(rareItem.rareAffixes?.[3]?.present).toBe(false) + expect(rareItem.rareAffixes?.[3]?.id).toBeUndefined() + }) + + it('populates Set Item with setId and setBonuses stat list blocks', () => { + const setItem: Item = { + base: swordBase, + prefix: null, + suffix: null, + level: 15, + name: "Civerb's Cudgel", + stats: { damage: 25 }, + invWidth: 2, + invHeight: 3, + stack: 1, + value: 4000, + rawFlags: ItemFlag.IDENTIFIED, + flags: { identified: true }, + version: 0x65, + code: 'crs ', + uniqueId: 0x55aa55aa, + ilvl: 20, + quality: ItemQuality.SET, + rarity: 'set', + setId: 12, + setBonuses: [ + [{ statId: 21, statName: 'tohit', value: 100 }], + [{ statId: 17, statName: 'maxdamage', value: 23 }], + ], + } + + expect(setItem.quality).toBe(ItemQuality.SET) + expect(setItem.setId).toBe(12) + expect(setItem.setBonuses?.length).toBe(2) + expect(setItem.setBonuses?.[0]?.[0]?.value).toBe(100) + }) + + it('populates Unique Item with uniqueId and graphic variant', () => { + const uniqueItem: Item = { + base: swordBase, + prefix: null, + suffix: null, + level: 85, + name: 'The Grandfather', + stats: { damage: 180 }, + invWidth: 2, + invHeight: 3, + stack: 1, + value: 50000, + rawFlags: ItemFlag.IDENTIFIED | ItemFlag.ETHEREAL, + flags: { identified: true, ethereal: true }, + version: 0x65, + code: '7gd ', + uniqueId: 254, + ilvl: 85, + quality: ItemQuality.UNIQUE, + rarity: 'unique', + hasGraphic: true, + graphic: 2, + } + + expect(uniqueItem.quality).toBe(ItemQuality.UNIQUE) + expect(uniqueItem.uniqueId).toBe(254) + expect(uniqueItem.hasGraphic).toBe(true) + expect(uniqueItem.graphic).toBe(2) + expect(uniqueItem.flags?.ethereal).toBe(true) + }) + + it('populates Runeword Item with 12-bit runewordId, param, and runewordStats', () => { + const runewordData: ItemRunewordData = { + id: 48, + param: 5, + } + const runewordStats: ItemStatEntry[] = [ + { statId: 105, statName: 'fastercastrate', value: 35 }, + { statId: 21, statName: 'tohit', value: 250 }, + ] + const runewordItem: Item = { + base: swordBase, + prefix: null, + suffix: null, + level: 54, + name: 'Spirit Crystal Sword', + stats: { fastercastrate: 35 }, + invWidth: 2, + invHeight: 3, + stack: 1, + value: 15000, + rawFlags: ItemFlag.IDENTIFIED | ItemFlag.SOCKETED | ItemFlag.RUNEWORD, + flags: { identified: true, socketed: true, runeword: true }, + version: 0x65, + code: 'crs ', + uniqueId: 0x11223344, + ilvl: 54, + quality: ItemQuality.NORMAL, + totalSockets: 4, + socketedCount: 4, + runeword: runewordData, + runewordStats, + } + + expect(runewordItem.flags?.runeword).toBe(true) + expect(runewordItem.runeword?.id).toBe(48) + expect(runewordItem.runeword?.param).toBe(5) + expect(runewordItem.totalSockets).toBe(4) + expect(runewordItem.runewordStats?.length).toBe(2) + }) + + it('populates Personalized and AutoAffix fields', () => { + const personalizedItem: Item = { + base: swordBase, + prefix: null, + suffix: null, + level: 70, + name: "Anya's Crystal Sword", + stats: {}, + invWidth: 2, + invHeight: 3, + stack: 1, + value: 5000, + rawFlags: ItemFlag.IDENTIFIED | ItemFlag.PERSONALIZED, + flags: { identified: true, personalized: true }, + version: 0x65, + code: 'crs ', + uniqueId: 0xaabbccdd, + ilvl: 70, + quality: ItemQuality.NORMAL, + personalizedName: 'Anya', + hasAutoAffix: true, + autoAffixId: 302, + } + + expect(personalizedItem.flags?.personalized).toBe(true) + expect(personalizedItem.personalizedName).toBe('Anya') + expect(personalizedItem.hasAutoAffix).toBe(true) + expect(personalizedItem.autoAffixId).toBe(302) + }) + + it('populates Player Ear item with EarData (classIndex, level, name)', () => { + const earData: ItemEarData = { + classIndex: 1, // Sorceress + level: 88, + name: 'DarkSorceress', + } + const earItem: Item = { + base: { + id: 'ear', + name: "Sorceress's Ear", + kind: 'misc', + invWidth: 1, + invHeight: 1, + maxStack: 1, + value: 0, + damage: 0, + defense: 0, + tags: ['ear'], + level: 1, + }, + prefix: null, + suffix: null, + level: 88, + name: "DarkSorceress's Ear", + stats: {}, + invWidth: 1, + invHeight: 1, + stack: 1, + value: 0, + rawFlags: ItemFlag.IDENTIFIED | ItemFlag.IS_EAR, + flags: { identified: true, isEar: true }, + version: 0x65, + code: 'ear ', + earData, + } + + expect(earItem.flags?.isEar).toBe(true) + expect(earItem.code).toBe('ear ') + expect(earItem.earData?.classIndex).toBe(1) + expect(earItem.earData?.level).toBe(88) + expect(earItem.earData?.name).toBe('DarkSorceress') + }) + + it('populates Armor defense and Durability fields', () => { + const armorBase: ItemBase = { + id: 'qui', + name: 'Quilted Armor', + kind: 'armor', + invWidth: 2, + invHeight: 3, + maxStack: 1, + value: 50, + damage: 0, + defense: 10, + tags: ['armo'], + level: 1, + } + const armorItem: Item = { + base: armorBase, + prefix: null, + suffix: null, + level: 5, + name: 'Quilted Armor', + stats: { defense: 11 }, + invWidth: 2, + invHeight: 3, + stack: 1, + value: 50, + rawFlags: ItemFlag.IDENTIFIED, + flags: { identified: true }, + version: 0x65, + code: 'qui ', + defense: 11, + durability: 20, + maxDurability: 20, + } + + expect(armorItem.defense).toBe(11) + expect(armorItem.durability).toBe(20) + expect(armorItem.maxDurability).toBe(20) + }) + + it('preserves unknown/unparsed bits for lossless round-trip', () => { + const rawUnknownBits = new Uint8Array([0xfe, 0xca, 0x00, 0x12]) + const roundTripItem: Item = { + base: swordBase, + prefix: null, + suffix: null, + level: 30, + name: 'Crystal Sword', + stats: {}, + invWidth: 2, + invHeight: 3, + stack: 1, + value: 1000, + rawFlags: 0x00010809, // custom server bits + identified + socketed + runeword + flags: { identified: true, socketed: true, runeword: true }, + version: 0x65, + code: 'crs ', + unknownBits: rawUnknownBits, + } + + expect(roundTripItem.rawFlags).toBe(0x00010809) + expect(roundTripItem.unknownBits).toEqual(rawUnknownBits) + }) + }) + + describe('Backward Compatibility with Gameplay & Inventory Systems', () => { + it('createItem populates backward-compatible and bit-aligned fields', () => { + const rng = new Rng(12345) + const base: ItemBase = { + id: 'swd', + name: 'Short Sword', + kind: 'weapon', + invWidth: 1, + invHeight: 3, + maxStack: 1, + value: 30, + damage: 5, + defense: 0, + tags: ['weap'], + level: 1, + } + const item = createItem(base, [], [], rng, { level: 5 }) + + // Original fields: + expect(item.base.id).toBe('swd') + expect(item.level).toBe(5) + expect(item.name).toBe('Short Sword') + expect(item.invWidth).toBe(1) + expect(item.invHeight).toBe(3) + expect(item.stack).toBe(1) + expect(item.value).toBe(30) + expect(item.stats.damage).toBe(5) + + // Bitstream-aligned fields: + expect(item.code).toBe('swd ') + expect(item.ilvl).toBe(5) + expect(item.quality).toBe(ItemQuality.NORMAL) + expect(item.rarity).toBe('normal') + expect(item.version).toBe(0x65) + expect(item.rawFlags! & ItemFlag.IDENTIFIED).toBeTruthy() + expect(item.quantity).toBe(1) + }) + + it('Inventory correctly adds, stacks, and updates quantity and stack', () => { + const inv = new Inventory(10, 4) + const potionBase: ItemBase = { + id: 'hp1', + name: 'Minor Healing Potion', + kind: 'misc', + invWidth: 1, + invHeight: 1, + maxStack: 5, + value: 20, + damage: 0, + defense: 0, + tags: ['misc'], + level: 1, + } + const potion1: Item = { + base: potionBase, + prefix: null, + suffix: null, + level: 1, + name: 'Minor Healing Potion', + stats: {}, + invWidth: 1, + invHeight: 1, + stack: 2, + value: 20, + quantity: 2, + } + const potion2: Item = { + ...potion1, + stack: 2, + quantity: 2, + } + + const p1 = inv.add(potion1) + expect(p1).not.toBeNull() + expect(p1?.item.stack).toBe(2) + + const p2 = inv.add(potion2) + expect(p2).not.toBeNull() + expect(p2?.item.stack).toBe(4) + expect(p2?.item.quantity).toBe(4) + expect(inv.contents.length).toBe(1) + }) + }) +})