feat(items): 冻结物品数据结构与 .d2s 位对齐规格预留 (Issue #88)
This commit is contained in:
parent
5d6dc8a15f
commit
24fbc3b5c1
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue