reldens
Version:
Reldens - MMORPG Platform
473 lines (388 loc) • 17.1 kB
Markdown
# Items System Implementation - Complete Documentation
## Overview
The Reldens Items System manages player inventory, equipment, and item modifiers. It uses the `@reldens/items-system` package for core functionality and integrates with the `@reldens/modifiers` package for stat modifications.
## Architecture
### Core Components
1. **ItemsServer** (`@reldens/items-system`) - Server-side inventory manager
2. **Inventory** (`@reldens/items-system`) - Base inventory container
3. **ItemBase** - Base class for all items
4. **Equipment** - Specialized item type for equippable items
5. **Modifier** (`@reldens/modifiers`) - Handles stat modifications
6. **StorageObserver** - Persists inventory changes to database
### Directory Structure
**lib/inventory/**
- **client/** - Client-side inventory UI and rendering
- **server/** - Server-side inventory logic
- items-factory.js - Creates item instances from database models
- message-actions.js - Handles equip/unequip/trade messages
- models-manager.js - Database operations
- storage-observer.js - Event listeners for persistence
- plugin.js - Inventory feature plugin
- **subscribers/** - Event subscribers
- player-subscriber.js - Creates player inventory on login
- player-death-subscriber.js
- constants.js
## Item Creation Flow
### When Player Logs In
**Entry Point**: `lib/inventory/server/plugin.js` line 50-51
```javascript
this.events.on('reldens.createPlayerStatsAfter', async (client, userModel, currentPlayer, room) => {
await PlayerSubscriber.createPlayerInventory(client, currentPlayer, room, this.events, this.modelsManager);
});
```
**Sequence**:
1. **Player Stats Loaded** (`lib/users/server/plugin.js` lines 289-309)
- Stats loaded from `players_stats` table
- Set on `currentPlayer.stats` and `currentPlayer.statsBase`
- Event `reldens.createPlayerStatsAfter` fires
2. **Inventory Creation** (`lib/inventory/server/subscribers/player-subscriber.js` lines 30-63)
```javascript
let serverProps = {
owner: currentPlayer, // The player schema instance
client: new ClientWrapper({client, room}),
persistence: true,
ownerIdProperty: 'player_id',
eventsManager: events,
modelsManager: modelsManager,
itemClasses: {...},
groupClasses: {...},
itemsModelData: room.config.inventory.items
};
let inventoryServer = new ItemsServer(serverProps);
inventoryServer.dataServer = new StorageObserver(inventoryServer.manager, modelsManager);
```
3. **Items Loading** (`lib/inventory/server/storage-observer.js` lines 169-182)
```javascript
async loadOwnerItems(){
let itemsModels = await this.modelsManager.loadOwnerItems(this.manager.getOwnerId());
let itemsInstances = await ItemsFactory.fromModelsList(itemsModels, this.manager);
await this.manager.fireEvent(ItemsEvents.LOADED_OWNER_ITEMS, this, itemsInstances, itemsModels);
await this.manager.setItems(itemsInstances);
}
```
4. **Item Instance Creation** (`lib/inventory/server/items-factory.js` lines 40-71)
```javascript
static async fromModel(itemInventoryModel, manager){
let itemClass = sc.get(
manager.itemClasses,
itemInventoryModel.related_items_item.key,
manager.types.classByTypeId(itemInventoryModel.related_items_item.type)
);
let itemObj = new itemClass(itemProps);
if (itemObj.isType(ItemsConst.TYPES.EQUIPMENT)) {
itemObj.equipped = (1 === itemInventoryModel.is_active); // Mark as equipped if active
}
await this.enrichWithModifiers(itemInventoryModel, itemObj, manager);
return itemObj;
}
```
5. **Modifier Creation** (`lib/inventory/server/items-factory.js` lines 79-93)
```javascript
static async enrichWithModifiers(itemInventoryModel, itemObj, manager){
let modifiers = {};
for(let modifierData of itemInventoryModel.related_items_item.related_items_item_modifiers){
if(modifierData.operation !== ModifierConst.OPS.SET){
modifierData.value = Number(modifierData.value);
}
modifierData.target = manager.owner; // Set target to currentPlayer
modifiers[modifierData.id] = new Modifier(modifierData);
}
itemObj.modifiers = modifiers;
}
```
### Critical Timing
- **BEFORE items load**: `currentPlayer.stats` is set (fresh object from database)
- **DURING item creation**: Modifiers get `target = manager.owner = currentPlayer`
- **AFTER items load**: Modifiers have correct reference to `currentPlayer.stats`
## Equipment Flow
### Manual Equip (User Action)
**Entry Point**: User clicks equip button, client sends message, server receives
1. **Message Reception** (`lib/inventory/server/message-actions.js` lines 71-73)
```javascript
if(InventoryConst.ACTIONS.EQUIP === data.act){
return await this.executeEquipAction(playerSchema, data);
}
```
2. **Execute Equip Action** (`lib/inventory/server/message-actions.js` lines 360-373)
```javascript
async executeEquipAction(playerSchema, data){
let item = playerSchema.inventory.manager.items[data.idx];
if(!item.equipped){
this.unEquipPrevious(item.group_id, playerSchema.inventory.manager.items); // Unequip same group
await item.equip(); // Equip new item
return true;
}
await item.unequip(); // If already equipped, unequip
return true;
}
```
3. **Item Equip Method** (`npm-packages/reldens-items/lib/item/type/equipment.js` lines 26-35)
```javascript
async equip(applyMods){
this.equipped = true;
await this.manager.fireEvent(ItemsEvents.EQUIP_ITEM, this);
if(applyMods === false || this.manager.applyModifiersAuto === false){
return false;
}
await this.applyModifiers(); // Apply modifiers automatically
}
```
4. **Apply Modifiers** (`npm-packages/reldens-items/lib/item/type/item-base.js` lines 90-105)
```javascript
async changeModifiers(revert){
await this.manager.fireEvent(ItemsEvents.EQUIP_BEFORE+(revert ? 'Revert': 'Apply')+'Modifiers', this);
let modifiersKeys = Object.keys(this.modifiers);
let methodName = revert ? 'revert' : 'apply';
for(let i of modifiersKeys){
this.modifiers[i][methodName](this.target); // this.target is false, but modifier has its own target
}
return this.manager.fireEvent(ItemsEvents.EQUIP+(revert ? 'Reverted' : 'Applied')+'Modifiers', this);
}
```
5. **Modifier Execute** (`npm-packages/reldens-modifiers/lib/modifier.js` lines 84-108)
```javascript
execute(target, revert = false, useBasePropertyToGetValue = false, applyOnBaseProperty = false){
// If target param is false, use this.target (set to currentPlayer in factory)
if(target){
this.target = target;
}
let newValue = this.getModifiedValue(revert, useBasePropertyToGetValue);
let applyToProp = applyOnBaseProperty ? this.basePropertyKey : this.propertyKey;
this.setOwnerProperty(applyToProp, newValue); // Sets currentPlayer.stats.atk
this.state = revert ? ModifierConst.MOD_REVERTED : ModifierConst.MOD_APPLIED;
return true;
}
```
6. **Property Manager Sets Value** (`npm-packages/reldens-modifiers/lib/property-manager.js` lines 22-34)
```javascript
manageOwnerProperty(propertyOwner, propertyString, value){
let propertyPathParts = propertyString.split('/'); // ['stats', 'atk']
let childPropertyOwner = this.extractChildPropertyOwner(propertyOwner, propertyPathParts); // Get stats object
let propertyKey = propertyPathParts[propertyPathParts.length-1]; // 'atk'
if('undefined' === typeof value && !sc.hasOwn(childPropertyOwner, propertyKey)){
ErrorManager.error('Invalid property "'+propertyKey+'" from path: "'+propertyPathParts.join('/')+'"].');
}
if('undefined' !== typeof value){
childPropertyOwner[propertyKey] = value; // Sets stats.atk = newValue
}
return childPropertyOwner[propertyKey];
}
```
7. **Stats Persistence** (`lib/inventory/server/storage-observer.js` lines 68-79)
```javascript
this.manager.listenEvent(
ItemsEvents.EQUIP+'AppliedModifiers',
this.updateAppliedModifiers.bind(this),
...
);
async updateAppliedModifiers(item){
return await this.modelsManager.onChangedModifiers(item, ModifierConst.MOD_APPLIED);
}
```
8. **Persist Data** (`lib/inventory/server/models-manager.js` lines 127-131)
```javascript
async onChangedModifiers(item, action){
return await item.manager.owner.persistData({act: action, item: item});
}
```
9. **Save Player Stats** (`lib/rooms/server/scene.js` lines 228-234)
```javascript
currentPlayer.persistData = async (params) => {
await this.savePlayedTime(currentPlayer);
await this.savePlayerState(currentPlayer.sessionId);
await this.savePlayerStats(currentPlayer, client); // Saves stats to database
};
```
10. **Client Update** (`lib/rooms/server/scene.js` lines 759-763)
```javascript
client.send('*', {
act: GameConst.PLAYER_STATS,
stats: playerSchema.stats,
statsBase: playerSchema.statsBase
});
```
## Modifier Operations
From `@reldens/modifiers/lib/constants.js`:
**1. INC - Increase (flat)**
- Apply: `value + operand`
- Revert: `value - operand`
**2. DEC - Decrease**
- Apply: `value - operand`
- Revert: `value + operand`
**3. DIV - Divide**
- Apply: `value / operand`
- Revert: `value * operand`
**4. MUL - Multiply**
- Apply: `value * operand`
- Revert: `value / operand`
**5. INC_P - Increase by %**
- Apply: `value + (value * operand / 100)`
- Revert: Complex percentage revert
**6. DEC_P - Decrease by %**
- Apply: `value - (value * operand / 100)`
- Revert: Complex percentage revert
**7. SET - Set value**
- Apply: `operand`
- Revert: `false`
**8. METHOD - Custom method**
- Apply: Calls custom method on modifier
- Revert: Calls custom method
**9. SET_N - Set (alt)**
- Apply: `operand`
- Revert: `false`
### INC_P (Increase Percentage) Calculation
From `@reldens/modifiers/lib/calculator.js` lines 30-37:
**Apply**:
```javascript
return originalValue + Math.round(originalValue * operationValue / 100);
```
Example: atk=100, value=5 results in 100 + Math.round(100 * 5 / 100) = 100 + 5 = 105
**Revert**:
```javascript
let revertValue = Math.ceil(originalValue - (originalValue / (100 - operationValue)) * 100);
return originalValue + revertValue;
```
Example: atk=105, value=5 results in Math.ceil(105 - (105/95)*100) = Math.ceil(-5.26) = -5 then 105 + (-5) = 100
## Database Schema
### items_item (Item Definitions)
```sql
CREATE TABLE `items_item` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`key` varchar(255) NOT NULL,
`type` int(11) NOT NULL,
`group_id` int(10) unsigned DEFAULT NULL,
`label` varchar(255) DEFAULT NULL,
`description` text,
`qty_limit` int(11) DEFAULT NULL,
`uses_limit` int(11) DEFAULT NULL,
`useTimeOut` int(11) DEFAULT NULL,
`execTimeOut` int(11) DEFAULT NULL,
`customData` text,
PRIMARY KEY (`id`)
);
```
### items_item_modifiers (Item Modifier Definitions)
```sql
CREATE TABLE `items_item_modifiers` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`item_id` int(10) unsigned NOT NULL,
`key` varchar(255) NOT NULL,
`property_key` varchar(255) NOT NULL,
`operation` int(11) NOT NULL,
`value` varchar(255) NOT NULL,
`maxProperty` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
FOREIGN KEY (`item_id`) REFERENCES `items_item` (`id`)
);
```
- `item_id`: References the item this modifier belongs to
- `key`: Modifier identifier (e.g., 'atk')
- `property_key`: Path to property to modify (e.g., 'stats/atk')
- `operation`: Operation ID (1-9, see Modifier Operations table)
- `value`: Value to apply (as string, converted to number if not SET operation)
- `maxProperty`: Optional max value property path (e.g., 'statsBase/hp')
### items_inventory (Player Item Instances)
```sql
CREATE TABLE `items_inventory` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`owner_id` int(10) unsigned NOT NULL,
`item_id` int(10) unsigned NOT NULL,
`qty` int(11) NOT NULL,
`remaining_uses` int(11) DEFAULT NULL,
`is_active` tinyint(1) DEFAULT 0,
PRIMARY KEY (`id`),
FOREIGN KEY (`owner_id`) REFERENCES `players` (`id`),
FOREIGN KEY (`item_id`) REFERENCES `items_item` (`id`)
);
```
- `owner_id`: Player ID who owns this item instance
- `item_id`: References the item definition
- `qty`: Quantity (-1 for unlimited)
- `remaining_uses`: Uses left (if item has uses limit)
- `is_active`: 1 if equipped, 0 if not (for equipment items only)
## Event Flow
### Equipment Events Sequence
1. `ItemsEvents.EQUIP_ITEM` - Fired when equip() starts
- **Listener**: `StorageObserver.saveEquippedItemAsActive()` - Updates `is_active=1` in database
2. `ItemsEvents.EQUIP_BEFORE+'Apply'+'Modifiers'` - Before modifiers are applied
- No default listeners
3. `ItemsEvents.EQUIP+'Applied'+'Modifiers'` - After modifiers are applied
- **Listener**: `StorageObserver.updateAppliedModifiers()` - Calls `persistData()` to save stats
4. `reldens.playerPersistDataBefore` - Before data persistence
- Custom hooks can intercept here
5. `reldens.savePlayerStatsUpdateClient` - After stats saved, before client update
- **Listener**: `UsersPlugin.updateClientsWithPlayerStats()` - Updates life bar UI
6. Client receives `GameConst.PLAYER_STATS` message with updated stats
### Unequip Events Sequence
1. `ItemsEvents.UNEQUIP_ITEM` - Fired when unequip() starts
- **Listener**: `StorageObserver.saveUnequippedItemAsInactive()` - Updates `is_active=0` in database
2. `ItemsEvents.EQUIP_BEFORE+'Revert'+'Modifiers'` - Before modifiers are reverted
- No default listeners
3. `ItemsEvents.EQUIP+'Reverted'+'Modifiers'` - After modifiers are reverted
- **Listener**: `StorageObserver.updateRevertedModifiers()` - Calls `persistData()` to save stats
4-6. Same persistence and client update flow as equip
## Testing Checklist
- Equip item - Stats increase correctly
- Unequip item - Stats revert to base value
- Logout with equipped item - Stats saved correctly
- Login with equipped item - Stats loaded with modifiers applied
- Unequip after login - Stats revert to base value correctly
- Multiple items in same group - Only one equipped at a time
- Percentage modifiers - Calculate correctly for different base values
- Flat modifiers - Add/subtract exact values
- Max/min property limits - Respect statsBase maximums
## Performance Considerations
- Modifiers are applied synchronously in a loop (item-base.js line 101-103)
- For items with many modifiers, this could cause brief delay
- Stats are saved to database after every equip/unequip operation
- Consider batching stats updates if players frequently swap equipment
## Extension Points
### Custom Item Types
Create custom item class extending ItemBase or Equipment:
```javascript
const Equipment = require('@reldens/items-system').ItemBase;
class MagicWeapon extends Equipment {
async equip(applyMods){
// Custom equip logic
await super.equip(applyMods);
// Post-equip custom logic
}
}
```
Register in `server/customClasses/inventory/items`:
```javascript
itemClasses: {
'magic_sword': MagicWeapon
}
```
### Custom Modifiers
Create custom modifier with METHOD operation:
```javascript
const { Modifier } = require('@reldens/modifiers');
class CustomModifier extends Modifier {
customCalculation(modifier, propertyValue){
// Your custom logic
return newValue;
}
}
```
Set in database:
```sql
INSERT INTO items_item_modifiers VALUES (
NULL, item_id, 'custom', 'stats/custom', 8, 'customCalculation', NULL
);
```
### Event Hooks
Hook into any event for custom logic:
```javascript
events.on('reldens.createdPlayerSchema', async (client, userModel, currentPlayer, room) => {
// Custom logic when player is created
});
inventoryServer.manager.listenEvent(ItemsEvents.EQUIP_ITEM, async (item) => {
// Custom logic when any item is equipped
});
```
## References
- `@reldens/items-system` package: D:\dap\work\reldens\npm-packages\reldens-items
- `@reldens/modifiers` package: D:\dap\work\reldens\npm-packages\reldens-modifiers
- Sample data: D:\dap\work\reldens\src\migrations\production\reldens-sample-data-v4.0.0.sql