aether-shards/src/units/Explorer.js
Matthew Mone ac0f3cc396 Enhance testing and integration of inventory and character management systems
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.
2025-12-27 16:54:03 -08:00

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;
}
}
}
}