Add comprehensive tests for the InventoryManager and InventoryContainer to validate item management functionalities. Implement integration tests for the CharacterSheet component, ensuring proper interaction with the inventory system. Update the Explorer class to support new inventory features and maintain backward compatibility. Refactor related components for improved clarity and performance.
334 lines
10 KiB
JavaScript
334 lines
10 KiB
JavaScript
/**
|
|
* @typedef {import("./types.js").ClassMastery} ClassMastery
|
|
* @typedef {import("./types.js").Equipment} Equipment
|
|
*/
|
|
|
|
import { Unit } from "./Unit.js";
|
|
|
|
/**
|
|
* Explorer.js
|
|
* Player character class supporting Multi-Class Mastery and Persistent Progression.
|
|
* @class
|
|
*/
|
|
export class Explorer extends Unit {
|
|
/**
|
|
* @param {string} id - Unique unit identifier
|
|
* @param {string} name - Explorer name
|
|
* @param {string} startingClassId - Starting class ID
|
|
* @param {Record<string, unknown>} classDefinition - Class definition data
|
|
*/
|
|
constructor(id, name, startingClassId, classDefinition) {
|
|
super(id, name, "EXPLORER", `${startingClassId}_MODEL`);
|
|
|
|
/** @type {string} */
|
|
this.activeClassId = startingClassId;
|
|
|
|
// Persistent Mastery: Tracks progress for EVERY class this character has played
|
|
// Key: ClassID, Value: { level, xp, skillPoints, unlockedNodes[] }
|
|
/** @type {Record<string, ClassMastery>} */
|
|
this.classMastery = {};
|
|
|
|
// Initialize the starting class entry
|
|
this.initializeMastery(startingClassId);
|
|
|
|
// Hydrate stats based on the provided definition
|
|
if (classDefinition) {
|
|
this.recalculateBaseStats(classDefinition);
|
|
this.currentHealth = this.baseStats.health;
|
|
}
|
|
|
|
// Inventory
|
|
/** @type {Equipment} */
|
|
this.equipment = {
|
|
weapon: null,
|
|
armor: null,
|
|
utility: null,
|
|
relic: null,
|
|
};
|
|
|
|
// Loadout (New inventory system per spec)
|
|
/** @type {Object} */
|
|
this.loadout = {
|
|
mainHand: null,
|
|
offHand: null,
|
|
body: null,
|
|
accessory: null,
|
|
belt: [null, null], // Fixed 2 slots
|
|
};
|
|
|
|
// Active Skills (Populated by Skill Tree)
|
|
/** @type {unknown[]} */
|
|
this.actions = [];
|
|
/** @type {unknown[]} */
|
|
this.passives = [];
|
|
}
|
|
|
|
/**
|
|
* Initializes mastery data for a class.
|
|
* @param {string} classId - Class ID to initialize
|
|
*/
|
|
initializeMastery(classId) {
|
|
if (!this.classMastery[classId]) {
|
|
this.classMastery[classId] = {
|
|
level: 1,
|
|
xp: 0,
|
|
skillPoints: 0,
|
|
unlockedNodes: [],
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Updates base stats based on the active class's base + growth rates * level.
|
|
* @param {Record<string, unknown>} classDef - The JSON definition of the class stats.
|
|
*/
|
|
recalculateBaseStats(classDef) {
|
|
if (classDef.id !== this.activeClassId) {
|
|
console.warn(
|
|
`Mismatch: Recalculating stats for ${this.activeClassId} using definition for ${classDef.id}`
|
|
);
|
|
}
|
|
|
|
const mastery = this.classMastery[this.activeClassId];
|
|
|
|
// 1. Start with Class Defaults
|
|
let stats = { ...classDef.base_stats };
|
|
|
|
// 2. Add Level Growth
|
|
// (Level 1 is base, so growth applies for levels 2+)
|
|
const levelsGained = mastery.level - 1;
|
|
if (levelsGained > 0) {
|
|
for (let stat in classDef.growth_rates) {
|
|
if (stats[stat] !== undefined) {
|
|
stats[stat] += classDef.growth_rates[stat] * levelsGained;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.baseStats = stats;
|
|
this.maxHealth = stats.health; // Update MaxHP cap
|
|
}
|
|
|
|
/**
|
|
* Swaps the active class logic.
|
|
* NOTE: Does NOT check unlock requirements (handled by UI/MetaSystem).
|
|
* @param {string} newClassId - New class ID
|
|
* @param {Record<string, unknown>} newClassDef - New class definition
|
|
*/
|
|
changeClass(newClassId, newClassDef) {
|
|
// 1. Ensure mastery record exists
|
|
this.initializeMastery(newClassId);
|
|
|
|
// 2. Switch ID
|
|
this.activeClassId = newClassId;
|
|
|
|
// 3. Update Model ID (Visuals)
|
|
this.voxelModelID = `${newClassId}_MODEL`;
|
|
|
|
// 4. Recalculate Stats for the new job
|
|
this.recalculateBaseStats(newClassDef);
|
|
|
|
// 5. Reset Current HP to new Max (or keep percentage? Standard is reset)
|
|
this.currentHealth = this.baseStats.health;
|
|
}
|
|
|
|
/**
|
|
* Adds XP to the *current* class.
|
|
* @param {number} amount - XP amount to add
|
|
*/
|
|
gainExperience(amount) {
|
|
const mastery = this.classMastery[this.activeClassId];
|
|
mastery.xp += amount;
|
|
// Level up logic would be handled by a system checking XP curves
|
|
}
|
|
|
|
/**
|
|
* Gets the current level of the active class.
|
|
* @returns {number} - Current level
|
|
*/
|
|
getLevel() {
|
|
return this.classMastery[this.activeClassId].level;
|
|
}
|
|
|
|
/**
|
|
* Initializes starting equipment from class definition.
|
|
* Creates ItemInstance objects and equips them to appropriate slots.
|
|
* @param {Object} itemRegistry - Item registry to get item definitions
|
|
* @param {Record<string, unknown>} classDefinition - Class definition with starting_equipment array
|
|
*/
|
|
initializeStartingEquipment(itemRegistry, classDefinition) {
|
|
if (!itemRegistry || !classDefinition) {
|
|
return;
|
|
}
|
|
|
|
const startingEquipment = classDefinition.starting_equipment;
|
|
if (!Array.isArray(startingEquipment) || startingEquipment.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Map item types to loadout slots
|
|
const typeToSlot = {
|
|
WEAPON: "mainHand",
|
|
ARMOR: "body",
|
|
UTILITY: "offHand", // Default to offHand, but could be belt
|
|
RELIC: "accessory",
|
|
CONSUMABLE: null, // Consumables go to belt or stash
|
|
};
|
|
|
|
let beltIndex = 0;
|
|
|
|
for (const itemDefId of startingEquipment) {
|
|
const itemDef = itemRegistry.get(itemDefId);
|
|
if (!itemDef) {
|
|
console.warn(`Starting equipment item not found: ${itemDefId}`);
|
|
continue;
|
|
}
|
|
|
|
// Create ItemInstance
|
|
const itemInstance = {
|
|
uid: `${itemDefId}_${this.id}_${Date.now()}_${Math.random()
|
|
.toString(36)
|
|
.substr(2, 9)}`,
|
|
defId: itemDefId,
|
|
isNew: false,
|
|
quantity: 1,
|
|
};
|
|
|
|
// Determine slot based on item type
|
|
const itemType = itemDef.type;
|
|
let targetSlot = typeToSlot[itemType];
|
|
|
|
// Special handling for consumables - put in belt
|
|
if (itemType === "CONSUMABLE" || itemType === "MATERIAL") {
|
|
if (beltIndex < 2) {
|
|
this.loadout.belt[beltIndex] = itemInstance;
|
|
beltIndex++;
|
|
continue;
|
|
} else {
|
|
// Belt is full, skip or add to stash later
|
|
console.warn(`Belt full, cannot equip consumable: ${itemDefId}`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Handle UTILITY items - can go to offHand or belt
|
|
if (itemType === "UTILITY") {
|
|
// If offHand is empty, use it; otherwise try belt
|
|
if (!this.loadout.offHand) {
|
|
targetSlot = "offHand";
|
|
} else if (beltIndex < 2) {
|
|
this.loadout.belt[beltIndex] = itemInstance;
|
|
beltIndex++;
|
|
continue;
|
|
} else {
|
|
// Both offHand and belt full, skip
|
|
console.warn(`Cannot equip utility item, slots full: ${itemDefId}`);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Equip to determined slot
|
|
if (targetSlot && this.loadout[targetSlot] === null) {
|
|
this.loadout[targetSlot] = itemInstance;
|
|
} else if (targetSlot) {
|
|
// Slot occupied, skip or log warning
|
|
console.warn(
|
|
`Starting equipment slot ${targetSlot} already occupied, skipping: ${itemDefId}`
|
|
);
|
|
}
|
|
}
|
|
|
|
// Recalculate stats after equipping starting gear
|
|
this.recalculateStats(itemRegistry);
|
|
}
|
|
|
|
/**
|
|
* Recalculates effective stats including equipment bonuses and skill tree stat boosts.
|
|
* This method should be called whenever equipment or skill tree nodes change.
|
|
* @param {Object} [itemRegistry] - Optional item registry to get item definitions
|
|
* @param {Object} [treeDef] - Optional skill tree definition to get stat boosts from unlocked nodes
|
|
*/
|
|
recalculateStats(itemRegistry = null, treeDef = null) {
|
|
// Start with base stats (already calculated from class + level)
|
|
const effectiveStats = { ...this.baseStats };
|
|
|
|
// Apply equipment bonuses if itemRegistry is provided
|
|
if (itemRegistry) {
|
|
// Check mainHand
|
|
if (this.loadout.mainHand) {
|
|
const itemDef = itemRegistry.get(this.loadout.mainHand.defId);
|
|
if (itemDef && itemDef.stats) {
|
|
for (const [stat, value] of Object.entries(itemDef.stats)) {
|
|
effectiveStats[stat] = (effectiveStats[stat] || 0) + value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check offHand
|
|
if (this.loadout.offHand) {
|
|
const itemDef = itemRegistry.get(this.loadout.offHand.defId);
|
|
if (itemDef && itemDef.stats) {
|
|
for (const [stat, value] of Object.entries(itemDef.stats)) {
|
|
effectiveStats[stat] = (effectiveStats[stat] || 0) + value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check body
|
|
if (this.loadout.body) {
|
|
const itemDef = itemRegistry.get(this.loadout.body.defId);
|
|
if (itemDef && itemDef.stats) {
|
|
for (const [stat, value] of Object.entries(itemDef.stats)) {
|
|
effectiveStats[stat] = (effectiveStats[stat] || 0) + value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check accessory
|
|
if (this.loadout.accessory) {
|
|
const itemDef = itemRegistry.get(this.loadout.accessory.defId);
|
|
if (itemDef && itemDef.stats) {
|
|
for (const [stat, value] of Object.entries(itemDef.stats)) {
|
|
effectiveStats[stat] = (effectiveStats[stat] || 0) + value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Belt items don't affect stats (they're consumables)
|
|
}
|
|
|
|
// Apply skill tree stat boosts from unlocked nodes
|
|
if (treeDef && this.classMastery) {
|
|
const mastery = this.classMastery[this.activeClassId];
|
|
if (mastery && mastery.unlockedNodes) {
|
|
for (const nodeId of mastery.unlockedNodes) {
|
|
const nodeDef = treeDef.nodes?.[nodeId];
|
|
if (
|
|
nodeDef &&
|
|
nodeDef.type === "STAT_BOOST" &&
|
|
nodeDef.data &&
|
|
nodeDef.data.stat
|
|
) {
|
|
const statName = nodeDef.data.stat;
|
|
const boostValue = nodeDef.data.value || 0;
|
|
effectiveStats[statName] =
|
|
(effectiveStats[statName] || 0) + boostValue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update maxHealth if health stat changed
|
|
if (effectiveStats.health !== undefined) {
|
|
const oldMaxHealth = this.maxHealth;
|
|
this.maxHealth = effectiveStats.health;
|
|
// Update currentHealth proportionally using the old maxHealth
|
|
if (oldMaxHealth > 0) {
|
|
const healthRatio = this.currentHealth / oldMaxHealth;
|
|
this.currentHealth = Math.floor(effectiveStats.health * healthRatio);
|
|
} else {
|
|
this.currentHealth = effectiveStats.health;
|
|
}
|
|
}
|
|
}
|
|
}
|