diff --git a/specs/Barracks.spec.md b/specs/Barracks.spec.md index 58ae6b9..0d067ab 100644 --- a/specs/Barracks.spec.md +++ b/specs/Barracks.spec.md @@ -129,22 +129,3 @@ The UI must filter the raw roster list locally. **CoA 4: Selection Persistence** - If the roster is re-sorted, the currently selected unit remains selected. - ---- - -## **6. Prompt for Coding Agent** - -"Create `src/ui/screens/barracks-screen.js` as a LitElement. - -**Imports:** - -- `gameStateManager`, `rosterManager`. -- `CharacterSheet` (for dynamic import). - -**Functionality:** - -1. **Load:** On `connectedCallback`, fetch `rosterManager.roster`. -2. **Render:** > \* Left Column: `map` over filtered units to create `unit-card` buttons. - - Right Column: Detail view. If `selectedUnit` is injured, show `Heal Button` with calculated cost. -3. **Healing:** Implement `_handleHeal()`. access `gameStateManager.activeRunData` (or wallet state). Deduct funds, update unit HP, trigger save. Dispatch event to update Hub header. -4. **Inspect:** Dispatch `open-character-sheet` event (handled by `index.html`) OR instantiate the modal internally if preferred for layout stacking." diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index 857aadd..3e1428a 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -2625,6 +2625,17 @@ export class GameLoop { if (unit.activeClassId) { rosterUnit.activeClassId = unit.activeClassId; } + // Save equipment/loadout + if (unit.loadout) { + rosterUnit.loadout = JSON.parse(JSON.stringify(unit.loadout)); + } + if (unit.equipment) { + rosterUnit.equipment = JSON.parse(JSON.stringify(unit.equipment)); + } + // Save current health + if (unit.currentHealth !== undefined) { + rosterUnit.currentHealth = unit.currentHealth; + } console.log(`Saved progression for ${unit.name} (roster ID: ${rosterId})`); } } diff --git a/src/core/GameStateManager.js b/src/core/GameStateManager.js index 1c3d4e6..0728368 100644 --- a/src/core/GameStateManager.js +++ b/src/core/GameStateManager.js @@ -54,7 +54,7 @@ class GameStateManagerClass { /** @type {RosterManager} */ this.rosterManager = new RosterManager(); /** @type {MissionManager} */ - this.missionManager = new MissionManager(); + this.missionManager = new MissionManager(this.persistence); /** @type {import("../managers/NarrativeManager.js").NarrativeManager} */ this.narrativeManager = narrativeManager; // Track the singleton instance @@ -117,7 +117,7 @@ class GameStateManagerClass { this.activeRunData = null; this.combatState = null; this.rosterManager = new RosterManager(); - this.missionManager = new MissionManager(); + this.missionManager = new MissionManager(this.persistence); // Recreate hub inventory and market manager this.hubStash = new InventoryContainer("HUB_VAULT"); this.hubInventoryManager = new InventoryManager( @@ -152,7 +152,7 @@ class GameStateManagerClass { } // 2. Load Hub Stash - this._loadHubStash(); + await this._loadHubStash(); // 3. Initialize Market Manager await this.marketManager.init(); @@ -535,11 +535,11 @@ class GameStateManagerClass { * Loads the hub stash from persistence. * @private */ - _loadHubStash() { + async _loadHubStash() { try { - const saved = localStorage.getItem("aether_shards_hub_stash"); - if (saved) { - const hubData = JSON.parse(saved); + // Try IndexedDB first + const hubData = await this.persistence.loadHubStash(); + if (hubData) { if (hubData.currency) { this.hubStash.currency.aetherShards = hubData.currency.aetherShards || 0; @@ -551,7 +551,23 @@ class GameStateManagerClass { this.hubStash.addItem(item); }); } - console.log("Loaded hub stash from persistence"); + console.log("Loaded hub stash from IndexedDB"); + return; + } + + // Fallback: migrate from localStorage if it exists + const saved = localStorage.getItem("aether_shards_hub_stash"); + if (saved) { + const legacyData = JSON.parse(saved); + if (legacyData.currency || legacyData.items) { + // Migrate to IndexedDB + await this.persistence.saveHubStash(legacyData); + // Load from IndexedDB + await this._loadHubStash(); + // Remove legacy localStorage entry + localStorage.removeItem("aether_shards_hub_stash"); + console.log("Migrated hub stash from localStorage to IndexedDB"); + } } } catch (error) { console.warn("Failed to load hub stash:", error); @@ -563,15 +579,14 @@ class GameStateManagerClass { * @private */ async _saveHubStash() { - // Save hub stash data to persistence + // Save hub stash data to IndexedDB // This ensures rewards persist across sessions try { const hubData = { currency: this.hubStash.currency, items: this.hubStash.getAllItems(), }; - // Save hub stash to localStorage - localStorage.setItem("aether_shards_hub_stash", JSON.stringify(hubData)); + await this.persistence.saveHubStash(hubData); } catch (error) { console.warn("Failed to save hub stash:", error); } diff --git a/src/core/Persistence.js b/src/core/Persistence.js index 3863bd6..e806941 100644 --- a/src/core/Persistence.js +++ b/src/core/Persistence.js @@ -13,7 +13,9 @@ const RUN_STORE = "Runs"; const ROSTER_STORE = "Roster"; const MARKET_STORE = "Market"; const CAMPAIGN_STORE = "Campaign"; -const VERSION = 4; // Bumped version to add Campaign store +const HUB_STASH_STORE = "HubStash"; +const UNLOCKS_STORE = "Unlocks"; +const VERSION = 5; // Bumped version to add HubStash and Unlocks stores /** * Handles game data persistence using IndexedDB. @@ -57,6 +59,16 @@ export class Persistence { if (!db.objectStoreNames.contains(CAMPAIGN_STORE)) { db.createObjectStore(CAMPAIGN_STORE, { keyPath: "id" }); } + + // Create Hub Stash Store if missing + if (!db.objectStoreNames.contains(HUB_STASH_STORE)) { + db.createObjectStore(HUB_STASH_STORE, { keyPath: "id" }); + } + + // Create Unlocks Store if missing + if (!db.objectStoreNames.contains(UNLOCKS_STORE)) { + db.createObjectStore(UNLOCKS_STORE, { keyPath: "id" }); + } }; request.onsuccess = (e) => { @@ -163,6 +175,50 @@ export class Persistence { return result ? result.data : null; } + // --- HUB STASH DATA --- + + /** + * Saves hub stash data (currency and items). + * @param {Object} hubStashData - Hub stash data with currency and items + * @returns {Promise} + */ + async saveHubStash(hubStashData) { + if (!this.db) await this.init(); + return this._put(HUB_STASH_STORE, { id: "hub_stash", data: hubStashData }); + } + + /** + * Loads hub stash data. + * @returns {Promise} - Hub stash data with currency and items, or null + */ + async loadHubStash() { + if (!this.db) await this.init(); + const result = await this._get(HUB_STASH_STORE, "hub_stash"); + return result ? result.data : null; + } + + // --- UNLOCKS DATA --- + + /** + * Saves unlocked classes. + * @param {string[]} unlocks - Array of unlocked class IDs + * @returns {Promise} + */ + async saveUnlocks(unlocks) { + if (!this.db) await this.init(); + return this._put(UNLOCKS_STORE, { id: "unlocks", data: unlocks }); + } + + /** + * Loads unlocked classes. + * @returns {Promise} - Array of unlocked class IDs + */ + async loadUnlocks() { + if (!this.db) await this.init(); + const result = await this._get(UNLOCKS_STORE, "unlocks"); + return result ? result.data : []; + } + // --- INTERNAL HELPERS --- /** diff --git a/src/index.js b/src/index.js index 9697be7..16628be 100644 --- a/src/index.js +++ b/src/index.js @@ -25,7 +25,13 @@ const uiLayer = document.getElementById("ui-layer"); let currentCharacterSheet = null; window.addEventListener("open-character-sheet", async (e) => { - let { unit, unitId, readOnly = false, inventory = [] } = e.detail; + let { + unit, + unitId, + readOnly = false, + inventory = [], + skillTree = null, + } = e.detail; // Resolve unit from ID if needed if (!unit && unitId && gameStateManager.gameLoop?.unitManager) { @@ -64,8 +70,12 @@ window.addEventListener("open-character-sheet", async (e) => { const { CharacterSheet } = await import("./ui/components/character-sheet.js"); // Generate skill tree using SkillTreeFactory if available - let skillTree = null; - if (gameStateManager.gameLoop?.classRegistry && unit.activeClassId) { + // Use provided skillTree if available (e.g., from barracks), otherwise generate + if ( + !skillTree && + gameStateManager.gameLoop?.classRegistry && + unit.activeClassId + ) { try { const { SkillTreeFactory } = await import( "./factories/SkillTreeFactory.js" @@ -111,11 +121,17 @@ window.addEventListener("open-character-sheet", async (e) => { characterSheet.inventory = inventory; characterSheet.gameMode = gameStateManager.currentState === "STATE_COMBAT" ? "DUNGEON" : "HUB"; - characterSheet.treeDef = skillTree; // Pass generated tree - // Pass inventoryManager from gameLoop if available - if (gameStateManager.gameLoop?.inventoryManager) { + // Use provided skillTree if available (from barracks), otherwise use generated one + characterSheet.treeDef = skillTree || null; // Pass generated tree + // Pass inventoryManager - use hubInventoryManager in Hub mode, gameLoop's in combat + if ( + gameStateManager.currentState === "STATE_COMBAT" && + gameStateManager.gameLoop?.inventoryManager + ) { characterSheet.inventoryManager = gameStateManager.gameLoop.inventoryManager; + } else if (gameStateManager.hubInventoryManager) { + characterSheet.inventoryManager = gameStateManager.hubInventoryManager; } // Handle close event diff --git a/src/managers/InventoryManager.js b/src/managers/InventoryManager.js index f5e7e83..929225c 100644 --- a/src/managers/InventoryManager.js +++ b/src/managers/InventoryManager.js @@ -120,11 +120,11 @@ export class InventoryManager { * @private */ _getStashForItem(itemInstance) { - // Check both stashes - if (this.runStash.findItem(itemInstance.uid)) { + // Check both stashes (runStash may be null in Hub mode) + if (this.runStash && this.runStash.findItem(itemInstance.uid)) { return this.runStash; } - if (this.hubStash.findItem(itemInstance.uid)) { + if (this.hubStash && this.hubStash.findItem(itemInstance.uid)) { return this.hubStash; } return null; diff --git a/src/managers/MarketManager.js b/src/managers/MarketManager.js index b0d18ab..0c9d3f5 100644 --- a/src/managers/MarketManager.js +++ b/src/managers/MarketManager.js @@ -38,7 +38,12 @@ export class MarketManager { * @param {import("../managers/InventoryManager.js").InventoryManager} inventoryManager - Inventory manager (for hubStash access) * @param {import("../managers/MissionManager.js").MissionManager} [missionManager] - Mission manager (optional, for tier calculation) */ - constructor(persistence, itemRegistry, inventoryManager, missionManager = null) { + constructor( + persistence, + itemRegistry, + inventoryManager, + missionManager = null + ) { /** @type {import("../core/Persistence.js").Persistence} */ this.persistence = persistence; /** @type {import("../managers/ItemRegistry.js").ItemRegistry} */ @@ -207,7 +212,9 @@ export class MarketManager { */ _generateMerchantStock(allItems, allowedTypes, rarityWeights, count) { // Filter by allowed types - const filtered = allItems.filter((item) => allowedTypes.includes(item.type)); + const filtered = allItems.filter((item) => + allowedTypes.includes(item.type) + ); if (filtered.length === 0) return []; @@ -328,9 +335,22 @@ export class MarketManager { // 4. Mark as purchased marketItem.purchased = true; - // 5. Save state + // 5. Save market state await this.persistence.saveMarketState(this.marketState); + // 6. Save hub stash to localStorage (via GameStateManager) + // Import GameStateManager to access _saveHubStash + const { gameStateManager } = await import("../core/GameStateManager.js"); + await gameStateManager._saveHubStash(); + + // 7. Dispatch wallet-updated event to refresh UI + window.dispatchEvent( + new CustomEvent("wallet-updated", { + bubbles: true, + composed: true, + }) + ); + return true; } catch (error) { console.error("Error buying item:", error); @@ -370,7 +390,19 @@ export class MarketManager { // 3. Add currency this.inventoryManager.hubStash.currency.aetherShards += sellPrice; - // 4. Create buyback entry (limit 10) + // 4. Save hub stash to localStorage (via GameStateManager) + const { gameStateManager } = await import("../core/GameStateManager.js"); + await gameStateManager._saveHubStash(); + + // 5. Dispatch wallet-updated event to refresh UI + window.dispatchEvent( + new CustomEvent("wallet-updated", { + bubbles: true, + composed: true, + }) + ); + + // 6. Create buyback entry (limit 10) if (this.marketState.buyback.length >= 10) { this.marketState.buyback.shift(); // Remove oldest } @@ -388,7 +420,7 @@ export class MarketManager { this.marketState.buyback.push(buybackItem); - // 5. Save state + // 7. Save market state await this.persistence.saveMarketState(this.marketState); return true; @@ -434,7 +466,9 @@ export class MarketManager { * Cleanup - remove event listeners. */ destroy() { - window.removeEventListener("mission-victory", this._boundHandleMissionVictory); + window.removeEventListener( + "mission-victory", + this._boundHandleMissionVictory + ); } } - diff --git a/src/managers/MissionManager.js b/src/managers/MissionManager.js index 4386712..b923af9 100644 --- a/src/managers/MissionManager.js +++ b/src/managers/MissionManager.js @@ -14,7 +14,13 @@ import { narrativeManager } from './NarrativeManager.js'; * @class */ export class MissionManager { - constructor() { + /** + * @param {import("../core/Persistence.js").Persistence} [persistence] - Persistence manager (optional) + */ + constructor(persistence = null) { + /** @type {import("../core/Persistence.js").Persistence | null} */ + this.persistence = persistence; + // Campaign State /** @type {string | null} */ this.activeMissionId = null; @@ -496,17 +502,22 @@ export class MissionManager { } /** - * Unlocks classes and stores them in localStorage. + * Unlocks classes and stores them in IndexedDB via Persistence. * @param {string[]} classIds - Array of class IDs to unlock */ - unlockClasses(classIds) { - const storageKey = 'aether_shards_unlocks'; + async unlockClasses(classIds) { let unlocks = []; try { - const stored = localStorage.getItem(storageKey); - if (stored) { - unlocks = JSON.parse(stored); + // Load from IndexedDB + if (this.persistence) { + unlocks = await this.persistence.loadUnlocks(); + } else { + // Fallback: try localStorage migration + const stored = localStorage.getItem('aether_shards_unlocks'); + if (stored) { + unlocks = JSON.parse(stored); + } } } catch (e) { console.error('Failed to load unlocks from storage:', e); @@ -519,10 +530,22 @@ export class MissionManager { } }); - // Save back to storage + // Save back to IndexedDB try { - localStorage.setItem(storageKey, JSON.stringify(unlocks)); - console.log('Unlocked classes:', classIds); + if (this.persistence) { + await this.persistence.saveUnlocks(unlocks); + console.log('Unlocked classes:', classIds); + + // Migrate from localStorage if it exists + if (localStorage.getItem('aether_shards_unlocks')) { + localStorage.removeItem('aether_shards_unlocks'); + console.log('Migrated unlocks from localStorage to IndexedDB'); + } + } else { + // Fallback to localStorage if persistence not available + localStorage.setItem('aether_shards_unlocks', JSON.stringify(unlocks)); + console.log('Unlocked classes (localStorage fallback):', classIds); + } } catch (e) { console.error('Failed to save unlocks to storage:', e); } diff --git a/src/ui/components/character-sheet.js b/src/ui/components/character-sheet.js index 911e444..4b1e3d7 100644 --- a/src/ui/components/character-sheet.js +++ b/src/ui/components/character-sheet.js @@ -598,6 +598,34 @@ export class CharacterSheet extends LitElement { transform: scale(1.1); } + .item-card.can-equip { + border-color: #00ff00; + box-shadow: 0 0 10px rgba(0, 255, 0, 0.5); + } + + .item-card.can-equip:hover { + border-color: #00ff00; + box-shadow: 0 0 15px rgba(0, 255, 0, 0.8); + transform: scale(1.15); + } + + .item-card.cannot-equip { + opacity: 0.5; + border-color: #ff6666; + cursor: not-allowed; + } + + .item-card.cannot-equip:hover { + border-color: #ff6666; + box-shadow: 0 0 10px rgba(255, 102, 102, 0.5); + transform: none; + } + + .item-card:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .item-card img { width: 100%; height: 100%; @@ -1041,9 +1069,18 @@ export class CharacterSheet extends LitElement { return html` - ` - )} + `; + })} ${filteredInventory.length === 0 ? html`

- No items available + ${this.selectedSlot + ? `No items available for ${this._getSlotLabel( + this.selectedSlot + )}` + : "No items available"}

` : ""} diff --git a/src/ui/screens/BarracksScreen.js b/src/ui/screens/BarracksScreen.js new file mode 100644 index 0000000..d2fd7e5 --- /dev/null +++ b/src/ui/screens/BarracksScreen.js @@ -0,0 +1,1028 @@ +import { LitElement, html, css } from "lit"; +import { gameStateManager } from "../../core/GameStateManager.js"; +import { Explorer } from "../../units/Explorer.js"; +import { + theme, + buttonStyles, + cardStyles, + progressBarStyles, +} from "../styles/theme.js"; + +/** + * BarracksScreen.js + * The Squad Quarters - Barracks UI component for managing the roster. + * @class + */ +export class BarracksScreen extends LitElement { + static get styles() { + return [ + theme, + buttonStyles, + cardStyles, + progressBarStyles, + css` + :host { + display: block; + background: var(--color-bg-secondary); + border: var(--border-width-medium) solid var(--color-border-default); + padding: var(--spacing-xl); + max-width: 1400px; + max-height: 85vh; + overflow: hidden; + color: var(--color-text-primary); + font-family: var(--font-family); + display: grid; + grid-template-columns: 60% 40%; + grid-template-rows: auto 1fr; + grid-template-areas: + "header header" + "roster detail"; + gap: var(--spacing-lg); + } + + .header { + grid-area: header; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: var(--border-width-medium) solid + var(--color-border-default); + padding-bottom: var(--spacing-md); + } + + .header h2 { + margin: 0; + color: var(--color-accent-cyan); + font-size: var(--font-size-4xl); + } + + .roster-info { + display: flex; + gap: var(--spacing-lg); + align-items: center; + } + + .roster-count { + font-size: var(--font-size-lg); + color: var(--color-text-secondary); + } + + .filter-bar { + display: flex; + gap: var(--spacing-sm); + } + + .filter-button { + background: var(--color-bg-card); + border: var(--border-width-medium) solid var(--color-border-default); + padding: var(--spacing-sm) var(--spacing-md); + cursor: pointer; + transition: all var(--transition-normal); + font-family: inherit; + color: var(--color-text-primary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + text-transform: uppercase; + } + + .filter-button:hover:not(.active) { + border-color: var(--color-accent-cyan); + background: rgba(0, 255, 255, 0.1); + } + + .filter-button.active { + border-color: var(--color-accent-gold); + background: rgba(255, 215, 0, 0.2); + color: var(--color-accent-gold); + } + + .sort-bar { + display: flex; + gap: var(--spacing-sm); + margin-top: var(--spacing-sm); + } + + .sort-button { + background: transparent; + border: var(--border-width-thin) solid var(--color-border-default); + padding: var(--spacing-xs) var(--spacing-sm); + cursor: pointer; + transition: all var(--transition-normal); + font-family: inherit; + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + } + + .sort-button:hover { + border-color: var(--color-accent-cyan); + color: var(--color-text-primary); + } + + /* Roster List */ + .roster-list { + grid-area: roster; + display: flex; + flex-direction: column; + gap: var(--spacing-md); + overflow-y: auto; + padding-right: var(--spacing-sm); + } + + .unit-card { + background: var(--color-bg-card); + border: var(--border-width-medium) solid var(--color-border-default); + padding: var(--spacing-md); + cursor: pointer; + transition: all var(--transition-normal); + display: grid; + grid-template-columns: 80px 1fr auto; + gap: var(--spacing-md); + align-items: center; + } + + .unit-card:hover { + border-color: var(--color-accent-cyan); + background: rgba(0, 255, 255, 0.1); + } + + .unit-card.selected { + border-color: var(--color-accent-gold); + background: rgba(255, 215, 0, 0.2); + box-shadow: var(--shadow-glow-gold); + } + + .unit-card.injured { + border-left: var(--border-width-thick) solid var(--color-accent-red); + } + + .unit-card.ready { + border-left: var(--border-width-thick) solid var(--color-accent-green); + } + + .unit-portrait { + width: 80px; + height: 80px; + background: rgba(0, 0, 0, 0.8); + border: var(--border-width-medium) solid var(--color-border-default); + border-radius: var(--border-radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-3xl); + overflow: hidden; + } + + .unit-portrait img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .unit-info { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + } + + .unit-name { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + } + + .unit-meta { + display: flex; + gap: var(--spacing-md); + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + } + + .unit-class { + display: flex; + align-items: center; + gap: var(--spacing-xs); + } + + .unit-level { + color: var(--color-accent-gold); + } + + .unit-hp-bar { + width: 100%; + margin-top: var(--spacing-xs); + } + + .unit-status { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: var(--spacing-xs); + } + + .status-badge { + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--border-radius-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + text-transform: uppercase; + } + + .status-badge.ready { + background: var(--color-accent-green); + color: #000; + } + + .status-badge.injured { + background: var(--color-accent-red); + color: white; + } + + .status-badge.dead { + background: var(--color-border-dark); + color: var(--color-text-secondary); + } + + /* Detail Sidebar */ + .detail-sidebar { + grid-area: detail; + background: var(--color-bg-panel); + border: var(--border-width-medium) solid var(--color-border-default); + padding: var(--spacing-lg); + display: flex; + flex-direction: column; + gap: var(--spacing-lg); + overflow-y: auto; + } + + .detail-header { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + border-bottom: var(--border-width-medium) solid + var(--color-border-default); + padding-bottom: var(--spacing-md); + } + + .detail-preview { + width: 100%; + height: 200px; + background: rgba(0, 0, 0, 0.8); + border: var(--border-width-medium) solid var(--color-border-default); + border-radius: var(--border-radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--font-size-5xl); + overflow: hidden; + } + + .detail-preview img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .detail-stats { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); + } + + .stat-row { + display: flex; + justify-content: space-between; + font-size: var(--font-size-sm); + } + + .stat-label { + color: var(--color-text-secondary); + } + + .stat-value { + color: var(--color-text-primary); + font-weight: var(--font-weight-bold); + } + + .detail-actions { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + margin-top: var(--spacing-md); + } + + .action-button { + width: 100%; + padding: var(--spacing-md); + font-size: var(--font-size-base); + } + + .heal-cost { + font-size: var(--font-size-sm); + color: var(--color-accent-gold); + margin-top: var(--spacing-xs); + } + + .empty-state { + grid-column: 1 / -1; + text-align: center; + padding: var(--spacing-2xl); + color: var(--color-text-secondary); + } + `, + ]; + } + + static get properties() { + return { + units: { type: Array }, + selectedUnitId: { type: String }, + filter: { type: String }, + sort: { type: String }, + healingCostPerHp: { type: Number }, + wallet: { type: Object }, + }; + } + + constructor() { + super(); + this.units = []; + this.selectedUnitId = null; + this.filter = "ALL"; + this.sort = "LEVEL_DESC"; + this.healingCostPerHp = 0.5; // 1 HP = 0.5 Shards + this.wallet = { aetherShards: 0, ancientCores: 0 }; + } + + connectedCallback() { + super.connectedCallback(); + this._loadRoster(); + this._loadWallet(); + } + + _loadRoster() { + try { + const roster = gameStateManager.rosterManager.roster || []; + // Convert ExplorerData to RosterUnitData format + this.units = roster.map((unitData) => + this._convertToRosterUnitData(unitData) + ); + this.requestUpdate(); + } catch (error) { + console.error("Error loading roster:", error); + this.units = []; + this.requestUpdate(); + } + } + + async _loadWallet() { + try { + if (gameStateManager.hubStash?.currency) { + this.wallet = { + aetherShards: gameStateManager.hubStash.currency.aetherShards || 0, + ancientCores: gameStateManager.hubStash.currency.ancientCores || 0, + }; + } else { + const runData = await gameStateManager.persistence.loadRun(); + if (runData?.inventory?.runStash?.currency) { + this.wallet = { + aetherShards: runData.inventory.runStash.currency.aetherShards || 0, + ancientCores: runData.inventory.runStash.currency.ancientCores || 0, + }; + } + } + } catch (error) { + console.warn("Could not load wallet data:", error); + } + this.requestUpdate(); + } + + /** + * Converts ExplorerData to RosterUnitData format + * @param {import("../../units/types.js").ExplorerData} unitData + * @returns {Object} + */ + _convertToRosterUnitData(unitData) { + // Calculate maxHp from class mastery + let maxHp = 100; // Default + let currentHp = 100; + let level = 1; + + const activeClassId = unitData.activeClassId || unitData.classId; + if (unitData.classMastery && unitData.classMastery[activeClassId]) { + const mastery = unitData.classMastery[activeClassId]; + level = mastery.level || 1; + + // Try to get class definition to calculate accurate HP + let classDef = null; + if (gameStateManager.gameLoop?.classRegistry) { + classDef = gameStateManager.gameLoop.classRegistry.get(activeClassId); + } + + if (classDef && classDef.base_stats) { + // Calculate maxHp: base + (level - 1) * growth + maxHp = classDef.base_stats.health || 100; + if (classDef.growth_rates && classDef.growth_rates.health) { + maxHp += classDef.growth_rates.health * (level - 1); + } + } else { + // Fallback: estimate from level + maxHp = 100 + (level - 1) * 8; + } + } + + // Check if unit has stored currentHealth (from mission return) - check this FIRST + if ( + unitData.currentHealth !== undefined && + unitData.currentHealth !== null + ) { + currentHp = unitData.currentHealth; + } else { + // Infer currentHp from status only if not stored + if (unitData.status === "READY") { + currentHp = maxHp; + } else if (unitData.status === "INJURED") { + // Assume injured units are at 50% HP + currentHp = Math.floor(maxHp * 0.5); + } else if (unitData.status === "DEAD") { + currentHp = 0; + } else { + // Default to maxHp for other statuses + currentHp = maxHp; + } + } + + // Try to get portrait path + let portrait = unitData.portrait || unitData.image; + if (!portrait) { + // Default portrait path based on class + portrait = `assets/images/portraits/${activeClassId.toLowerCase()}.png`; + } + + return { + id: unitData.id, + name: unitData.name, + classId: activeClassId, + level: level, + currentHp: currentHp, + maxHp: maxHp, + status: unitData.status || "READY", + portrait: portrait, + equipment: unitData.equipment || null, // Store equipment reference + loadout: unitData.loadout || null, // Store loadout reference + rawData: unitData, // Keep reference to original data + }; + } + + _getFilteredUnits() { + let filtered = [...this.units]; + + // Apply filter + if (this.filter === "READY") { + filtered = filtered.filter((u) => u.status === "READY"); + } else if (this.filter === "INJURED") { + filtered = filtered.filter((u) => u.currentHp < u.maxHp); + } + + // Apply sort + if (this.sort === "LEVEL_DESC") { + filtered.sort((a, b) => b.level - a.level); + } else if (this.sort === "NAME_ASC") { + filtered.sort((a, b) => a.name.localeCompare(b.name)); + } else if (this.sort === "HP_ASC") { + filtered.sort((a, b) => a.currentHp - b.currentHp); + } + + return filtered; + } + + _getSelectedUnit() { + if (!this.selectedUnitId) return null; + return this.units.find((u) => u.id === this.selectedUnitId); + } + + _calculateHealCost(unit) { + if (!unit || unit.currentHp >= unit.maxHp) return 0; + const missingHp = unit.maxHp - unit.currentHp; + return Math.ceil(missingHp * this.healingCostPerHp); + } + + _onUnitClick(unitId) { + this.selectedUnitId = unitId; + this.requestUpdate(); + } + + _onFilterClick(filter) { + this.filter = filter; + this.requestUpdate(); + } + + _onSortClick(sort) { + this.sort = sort; + this.requestUpdate(); + } + + async _handleHeal() { + const unit = this._getSelectedUnit(); + if (!unit || unit.currentHp >= unit.maxHp) return; + + const cost = this._calculateHealCost(unit); + if (this.wallet.aetherShards < cost) { + alert( + `Insufficient funds. Need ${cost} Shards, have ${this.wallet.aetherShards}.` + ); + return; + } + + // Deduct currency + if (gameStateManager.hubStash) { + if (!gameStateManager.hubStash.currency) { + gameStateManager.hubStash.currency = { + aetherShards: 0, + ancientCores: 0, + }; + } + gameStateManager.hubStash.currency.aetherShards -= cost; + this.wallet.aetherShards = + gameStateManager.hubStash.currency.aetherShards; + } + + // Update unit HP + const rosterUnit = gameStateManager.rosterManager.roster.find( + (u) => u.id === unit.id + ); + if (rosterUnit) { + rosterUnit.currentHealth = unit.maxHp; + rosterUnit.status = "READY"; + } + + // Update local unit data + unit.currentHp = unit.maxHp; + unit.status = "READY"; + + // Save roster and wallet + await gameStateManager.persistence.saveRoster( + gameStateManager.rosterManager.save() + ); + await gameStateManager.persistence.saveHubStash(gameStateManager.hubStash); + + // Dispatch event to update Hub header (use nextTick to ensure state is updated) + Promise.resolve().then(() => { + window.dispatchEvent( + new CustomEvent("wallet-updated", { + detail: { wallet: { ...this.wallet } }, + bubbles: true, + composed: true, + }) + ); + }); + + this.requestUpdate(); + } + + async _handleInspect() { + const unit = this._getSelectedUnit(); + if (!unit) return; + + // Create a proper Explorer instance from roster data + const rosterData = unit.rawData; + const activeClassId = rosterData.activeClassId || rosterData.classId; + + // Get class definition + let classDef = null; + if (gameStateManager.gameLoop?.classRegistry) { + classDef = gameStateManager.gameLoop.classRegistry.get(activeClassId); + } + + // If not found in registry, load directly from JSON files + if (!classDef) { + // Map class IDs to their JSON filenames + const classFileMap = { + CLASS_VANGUARD: "vanguard", + CLASS_WEAVER: "aether_weaver", + CLASS_AETHER_WEAVER: "aether_weaver", + CLASS_SCAVENGER: "scavenger", + CLASS_TINKER: "tinker", + CLASS_CUSTODIAN: "custodian", + CLASS_SAPPER: "sapper", + CLASS_FIELD_ENGINEER: "field_engineer", + CLASS_BATTLE_MAGE: "battle_mage", + CLASS_ARCANE_SCOURGE: "arcane_scourge", + CLASS_AETHER_SENTINEL: "aether_sentinel", + }; + + const className = + classFileMap[activeClassId] || + activeClassId.toLowerCase().replace("class_", ""); + + try { + // Use fetch to load the class definition JSON file + const response = await fetch(`assets/data/classes/${className}.json`); + if (response.ok) { + classDef = await response.json(); + } else { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + } catch (error) { + console.error( + `Could not load class definition for ${activeClassId} (tried ${className}.json}):`, + error + ); + } + } + + if (!classDef) { + console.error(`Class definition not found for ${activeClassId}`); + return; + } + + // Create Explorer instance + const explorer = new Explorer( + rosterData.id, + rosterData.name, + activeClassId, + classDef + ); + + // Restore classMastery and activeClassId + if (rosterData.classMastery) { + explorer.classMastery = JSON.parse( + JSON.stringify(rosterData.classMastery) + ); + } + if (rosterData.activeClassId) { + explorer.activeClassId = rosterData.activeClassId; + } + + // Recalculate stats based on restored mastery + if (explorer.classMastery[activeClassId]) { + explorer.recalculateBaseStats(classDef); + } + + // Restore current health + if ( + rosterData.currentHealth !== undefined && + rosterData.currentHealth !== null + ) { + explorer.currentHealth = rosterData.currentHealth; + } else { + // Set to max if ready, or appropriate value based on status + if (rosterData.status === "READY") { + explorer.currentHealth = explorer.maxHealth; + } else if (rosterData.status === "INJURED") { + explorer.currentHealth = Math.floor(explorer.maxHealth * 0.5); + } + } + + // Restore equipment/loadout if stored + if (rosterData.equipment) { + explorer.equipment = { ...explorer.equipment, ...rosterData.equipment }; + } + if (rosterData.loadout) { + explorer.loadout = { ...explorer.loadout, ...rosterData.loadout }; + } + + // Set portrait from roster data or unit data + if (unit.portrait) { + explorer.portrait = unit.portrait; + } else if (rosterData.portrait || rosterData.image) { + explorer.portrait = rosterData.portrait || rosterData.image; + } else { + // Default portrait path + explorer.portrait = `assets/images/portraits/${activeClassId.toLowerCase()}.png`; + } + + // Generate skill tree if gameLoop isn't running + let skillTree = null; + if ( + !gameStateManager.gameLoop?.classRegistry && + classDef && + classDef.skillTreeData + ) { + try { + const { SkillTreeFactory } = await import( + "../../factories/SkillTreeFactory.js" + ); + + // Load skill tree template + const templateResponse = await fetch( + "assets/data/skill_trees/template_standard_30.json" + ); + if (templateResponse.ok) { + const template = await templateResponse.json(); + const templateRegistry = { [template.id]: template }; + + // Get skill registry + const { skillRegistry } = await import( + "../../managers/SkillRegistry.js" + ); + await skillRegistry.loadAll(); // Ensure skills are loaded + + // Convert Map to object for SkillTreeFactory + const skillMap = Object.fromEntries(skillRegistry.skills); + + // Create factory and generate tree + const factory = new SkillTreeFactory(templateRegistry, skillMap); + skillTree = factory.createTree(classDef); + } + } catch (error) { + console.warn("Failed to generate skill tree from barracks:", error); + } + } + + // Dispatch event to open character sheet with proper Explorer instance + window.dispatchEvent( + new CustomEvent("open-character-sheet", { + detail: { + unitId: unit.id, + unit: explorer, // Pass the proper Explorer instance + skillTree: skillTree, // Pass generated skill tree if available + }, + bubbles: true, + composed: true, + }) + ); + } + + async _handleDismiss() { + const unit = this._getSelectedUnit(); + if (!unit) return; + + const confirmed = confirm( + `Are you sure you want to permanently dismiss ${unit.name}? This cannot be undone.` + ); + if (!confirmed) return; + + // Remove from roster + const index = gameStateManager.rosterManager.roster.findIndex( + (u) => u.id === unit.id + ); + if (index > -1) { + gameStateManager.rosterManager.roster.splice(index, 1); + } + + // Remove from local units + this.units = this.units.filter((u) => u.id !== unit.id); + + // Clear selection if dismissed unit was selected + if (this.selectedUnitId === unit.id) { + this.selectedUnitId = null; + } + + // Save roster + await gameStateManager.persistence.saveRoster( + gameStateManager.rosterManager.save() + ); + + this.requestUpdate(); + } + + _handleClose() { + this.dispatchEvent( + new CustomEvent("close-barracks", { + bubbles: true, + composed: true, + }) + ); + } + + _renderUnitCard(unit) { + const isSelected = unit.id === this.selectedUnitId; + const statusClass = unit.status.toLowerCase(); + const hpPercent = (unit.currentHp / unit.maxHp) * 100; + + return html` +
this._onUnitClick(unit.id)} + > +
+ ${unit.portrait + ? html`${unit.name} { + e.target.style.display = "none"; + const fallback = unit.classId + ? unit.classId.replace("CLASS_", "")[0] + : "?"; + e.target.parentElement.textContent = fallback; + }} + />` + : unit.classId + ? unit.classId.replace("CLASS_", "")[0] + : "?"} +
+
+
${unit.name}
+
+ ${unit.classId?.replace("CLASS_", "") || "Unknown"} + Lv. ${unit.level} +
+
+
+
+
+ ${unit.currentHp}/${unit.maxHp} HP +
+
+
+
+
+ ${unit.status} +
+
+ `; + } + + _renderDetailSidebar() { + const unit = this._getSelectedUnit(); + if (!unit) { + return html` +
+
+

Select a unit to view details

+
+
+ `; + } + + const healCost = this._calculateHealCost(unit); + const canHeal = + unit.currentHp < unit.maxHp && this.wallet.aetherShards >= healCost; + const isInjured = unit.currentHp < unit.maxHp; + const hpPercent = (unit.currentHp / unit.maxHp) * 100; + + return html` +
+
+
+ ${unit.portrait + ? html`${unit.name} { + e.target.style.display = "none"; + const fallback = unit.classId + ? unit.classId.replace("CLASS_", "")[0] + : "?"; + e.target.parentElement.textContent = fallback; + }} + />` + : unit.classId + ? unit.classId.replace("CLASS_", "")[0] + : "?"} +
+
+

+ ${unit.name} +

+

+ ${unit.classId?.replace("CLASS_", "") || "Unknown"} • Level + ${unit.level} +

+
+
+ +
+
+ Health + ${unit.currentHp} / ${unit.maxHp} +
+
+
+
${Math.round(hpPercent)}%
+
+
+ Status + ${unit.status} +
+
+ +
+ + ${isInjured + ? html` + + ${!canHeal && healCost > 0 + ? html` +
+ Insufficient funds. Need ${healCost} Shards. +
+ ` + : ""} + ` + : html` + + `} + +
+
+ `; + } + + render() { + const filteredUnits = this._getFilteredUnits(); + const rosterCount = this.units.length; + const rosterLimit = gameStateManager.rosterManager.rosterLimit || 12; + + return html` +
+
+

The Squad Quarters

+
+ Roster: ${rosterCount}/${rosterLimit} +
+ + + +
+
+ + Sort: + + + + +
+
+
+ +
+ +
+ ${filteredUnits.length === 0 + ? html` +
+

No units found matching the current filter.

+
+ ` + : filteredUnits.map((unit) => this._renderUnitCard(unit))} +
+ + ${this._renderDetailSidebar()} + `; + } +} + +customElements.define("barracks-screen", BarracksScreen); diff --git a/src/ui/screens/hub-screen.js b/src/ui/screens/hub-screen.js index 2d7c0d0..85480a0 100644 --- a/src/ui/screens/hub-screen.js +++ b/src/ui/screens/hub-screen.js @@ -215,10 +215,13 @@ export class HubScreen extends LitElement { super.connectedCallback(); this._loadData(); - // Bind the handler so we can remove it later + // Bind the handlers so we can remove them later this._boundHandleStateChange = this._handleStateChange.bind(this); + this._boundHandleWalletUpdate = this._handleWalletUpdate.bind(this); // Listen for state changes to update data window.addEventListener("gamestate-changed", this._boundHandleStateChange); + // Listen for wallet updates from Barracks + window.addEventListener("wallet-updated", this._boundHandleWalletUpdate); } disconnectedCallback() { @@ -229,6 +232,12 @@ export class HubScreen extends LitElement { this._boundHandleStateChange ); } + if (this._boundHandleWalletUpdate) { + window.removeEventListener( + "wallet-updated", + this._boundHandleWalletUpdate + ); + } } async _loadData() { @@ -281,6 +290,17 @@ export class HubScreen extends LitElement { this._loadData(); } + _handleWalletUpdate(e) { + // Update wallet from event detail + if (e.detail?.wallet) { + this.wallet = e.detail.wallet; + this.requestUpdate(); + } else { + // Fallback: reload wallet data + this._loadData(); + } + } + _openOverlay(type) { this.activeOverlay = type; this.requestUpdate(); @@ -336,20 +356,9 @@ export class HubScreen extends LitElement { break; case "BARRACKS": overlayComponent = html` -
-

BARRACKS

-

Total Units: ${this.rosterSummary.total}

-

Ready: ${this.rosterSummary.ready}

-

Injured: ${this.rosterSummary.injured}

- -
+ `; break; case "MARKET": @@ -419,6 +428,10 @@ export class HubScreen extends LitElement { if (this.activeOverlay === "MARKET") { import("./marketplace-screen.js").catch(console.error); } + // Trigger async import when BARRACKS overlay is opened + if (this.activeOverlay === "BARRACKS") { + import("./BarracksScreen.js").catch(console.error); + } return html`
diff --git a/test/core/GameStateManager.test.js b/test/core/GameStateManager.test.js index f8c02bf..86bc0cf 100644 --- a/test/core/GameStateManager.test.js +++ b/test/core/GameStateManager.test.js @@ -25,6 +25,10 @@ describe("Core: GameStateManager (Singleton)", () => { saveCampaign: sinon.stub().resolves(), loadMarketState: sinon.stub().resolves(null), saveMarketState: sinon.stub().resolves(), + loadHubStash: sinon.stub().resolves(null), + saveHubStash: sinon.stub().resolves(), + loadUnlocks: sinon.stub().resolves([]), + saveUnlocks: sinon.stub().resolves(), }; // Inject Mock (replacing the real Persistence instance) gameStateManager.persistence = mockPersistence; @@ -80,6 +84,41 @@ describe("Core: GameStateManager (Singleton)", () => { ); }); + it("Should load hub stash from IndexedDB on init", async () => { + const hubStashData = { + currency: { aetherShards: 500, ancientCores: 10 }, + items: [ + { uid: "ITEM_1", defId: "ITEM_RUSTY_BLADE", isNew: false, quantity: 1 }, + ], + }; + mockPersistence.loadHubStash.resolves(hubStashData); + + await gameStateManager.init(); + + expect(mockPersistence.loadHubStash.calledOnce).to.be.true; + expect(gameStateManager.hubStash.currency.aetherShards).to.equal(500); + expect(gameStateManager.hubStash.currency.ancientCores).to.equal(10); + expect(gameStateManager.hubStash.getAllItems()).to.have.length(1); + }); + + it("Should save hub stash to IndexedDB", async () => { + gameStateManager.hubStash.currency.aetherShards = 1000; + gameStateManager.hubStash.addItem({ + uid: "ITEM_2", + defId: "ITEM_SCRAP_PLATE", + isNew: true, + quantity: 1, + }); + + await gameStateManager._saveHubStash(); + + expect(mockPersistence.saveHubStash.calledOnce).to.be.true; + const savedData = mockPersistence.saveHubStash.firstCall.args[0]; + expect(savedData.currency.aetherShards).to.equal(1000); + expect(savedData.items).to.have.length(1); + expect(savedData.items[0].defId).to.equal("ITEM_SCRAP_PLATE"); + }); + it("CoA 3: handleEmbark should initialize run, save, and start engine", async () => { // Mock RosterManager.recruitUnit to return async unit with generated name const mockRecruitedUnit = { diff --git a/test/core/GameStateManager/hub-integration.test.js b/test/core/GameStateManager/hub-integration.test.js index 2e7db19..907bf25 100644 --- a/test/core/GameStateManager/hub-integration.test.js +++ b/test/core/GameStateManager/hub-integration.test.js @@ -22,6 +22,10 @@ describe("Core: GameStateManager - Hub Integration", () => { saveCampaign: sinon.stub().resolves(), loadMarketState: sinon.stub().resolves(null), saveMarketState: sinon.stub().resolves(), + loadHubStash: sinon.stub().resolves(null), + saveHubStash: sinon.stub().resolves(), + loadUnlocks: sinon.stub().resolves([]), + saveUnlocks: sinon.stub().resolves(), }; gameStateManager.persistence = mockPersistence; diff --git a/test/core/GameStateManager/inventory-integration.test.js b/test/core/GameStateManager/inventory-integration.test.js index 15f7371..5bb7c0a 100644 --- a/test/core/GameStateManager/inventory-integration.test.js +++ b/test/core/GameStateManager/inventory-integration.test.js @@ -57,6 +57,10 @@ describe("Core: GameStateManager - Inventory Integration", () => { loadRun: sinon.stub().resolves(null), loadRoster: sinon.stub().resolves(null), saveRoster: sinon.stub().resolves(), + loadHubStash: sinon.stub().resolves(null), + saveHubStash: sinon.stub().resolves(), + loadUnlocks: sinon.stub().resolves([]), + saveUnlocks: sinon.stub().resolves(), }; gameStateManager.persistence = mockPersistence; diff --git a/test/managers/MissionManager.test.js b/test/managers/MissionManager.test.js index 2629a71..cf38dc3 100644 --- a/test/managers/MissionManager.test.js +++ b/test/managers/MissionManager.test.js @@ -6,9 +6,16 @@ import { narrativeManager } from "../../src/managers/NarrativeManager.js"; describe("Manager: MissionManager", () => { let manager; let mockNarrativeManager; + let mockPersistence; beforeEach(() => { - manager = new MissionManager(); + // Create mock persistence + mockPersistence = { + loadUnlocks: sinon.stub().resolves([]), + saveUnlocks: sinon.stub().resolves(), + }; + + manager = new MissionManager(mockPersistence); // Mock narrativeManager mockNarrativeManager = { @@ -530,30 +537,25 @@ describe("Manager: MissionManager", () => { window.removeEventListener("mission-rewards", rewardSpy); }); - it("CoA 27: Should unlock classes and store in localStorage", () => { - manager.unlockClasses(["CLASS_TINKER", "CLASS_SAPPER"]); + it("CoA 27: Should unlock classes and store in IndexedDB", async () => { + await manager.unlockClasses(["CLASS_TINKER", "CLASS_SAPPER"]); - const stored = localStorage.getItem("aether_shards_unlocks"); - expect(stored).to.exist; - - const unlocks = JSON.parse(stored); - expect(unlocks).to.include("CLASS_TINKER"); - expect(unlocks).to.include("CLASS_SAPPER"); + expect(mockPersistence.saveUnlocks.calledOnce).to.be.true; + const savedUnlocks = mockPersistence.saveUnlocks.firstCall.args[0]; + expect(savedUnlocks).to.include("CLASS_TINKER"); + expect(savedUnlocks).to.include("CLASS_SAPPER"); }); - it("CoA 28: Should merge new unlocks with existing unlocks", () => { - localStorage.setItem( - "aether_shards_unlocks", - JSON.stringify(["CLASS_VANGUARD"]) - ); + it("CoA 28: Should merge new unlocks with existing unlocks", async () => { + // Set up existing unlocks + mockPersistence.loadUnlocks.resolves(["CLASS_VANGUARD"]); - manager.unlockClasses(["CLASS_TINKER"]); + await manager.unlockClasses(["CLASS_TINKER"]); - const unlocks = JSON.parse( - localStorage.getItem("aether_shards_unlocks") - ); - expect(unlocks).to.include("CLASS_VANGUARD"); - expect(unlocks).to.include("CLASS_TINKER"); + expect(mockPersistence.saveUnlocks.calledOnce).to.be.true; + const savedUnlocks = mockPersistence.saveUnlocks.firstCall.args[0]; + expect(savedUnlocks).to.include("CLASS_VANGUARD"); + expect(savedUnlocks).to.include("CLASS_TINKER"); }); it("CoA 29: Should distribute faction reputation rewards", () => { diff --git a/test/ui/barracks-screen.test.js b/test/ui/barracks-screen.test.js new file mode 100644 index 0000000..fb35875 --- /dev/null +++ b/test/ui/barracks-screen.test.js @@ -0,0 +1,718 @@ +import { expect } from "@esm-bundle/chai"; +import sinon from "sinon"; +// Import to register custom element +import "../../src/ui/screens/BarracksScreen.js"; +import { gameStateManager } from "../../src/core/GameStateManager.js"; +import vanguardDef from "../../src/assets/data/classes/vanguard.json" with { + type: "json", +}; + +describe("UI: BarracksScreen", () => { + let element; + let container; + let mockPersistence; + let mockRosterManager; + let mockHubStash; + let mockGameLoop; + + beforeEach(async () => { + container = document.createElement("div"); + document.body.appendChild(container); + element = document.createElement("barracks-screen"); + container.appendChild(element); + + // Wait for element to be defined + await element.updateComplete; + + // Create mock hub stash + mockHubStash = { + currency: { + aetherShards: 1000, + ancientCores: 0, + }, + }; + + // Create mock persistence + mockPersistence = { + loadRun: sinon.stub().resolves({ + inventory: { + runStash: { + currency: { + aetherShards: 500, + ancientCores: 0, + }, + }, + }, + }), + saveRoster: sinon.stub().resolves(), + saveHubStash: sinon.stub().resolves(), + }; + + // Create mock class registry + const mockClassRegistry = new Map(); + mockClassRegistry.set("CLASS_VANGUARD", vanguardDef); + + // Create mock game loop with class registry + mockGameLoop = { + classRegistry: mockClassRegistry, + }; + + // Create mock roster with test units + const testRoster = [ + { + id: "UNIT_1", + name: "Valerius", + classId: "CLASS_VANGUARD", + activeClassId: "CLASS_VANGUARD", + status: "READY", + classMastery: { + CLASS_VANGUARD: { + level: 3, + xp: 150, + skillPoints: 2, + unlockedNodes: [], + }, + }, + history: { missions: 2, kills: 5 }, + }, + { + id: "UNIT_2", + name: "Aria", + classId: "CLASS_VANGUARD", + activeClassId: "CLASS_VANGUARD", + status: "INJURED", + currentHealth: 60, // Injured unit with stored HP + classMastery: { + CLASS_VANGUARD: { + level: 2, + xp: 80, + skillPoints: 1, + unlockedNodes: [], + }, + }, + history: { missions: 1, kills: 2 }, + }, + { + id: "UNIT_3", + name: "Kael", + classId: "CLASS_VANGUARD", + activeClassId: "CLASS_VANGUARD", + status: "READY", + classMastery: { + CLASS_VANGUARD: { + level: 5, + xp: 300, + skillPoints: 3, + unlockedNodes: [], + }, + }, + history: { missions: 5, kills: 12 }, + }, + ]; + + // Create mock roster manager + mockRosterManager = { + roster: testRoster, + rosterLimit: 12, + getDeployableUnits: sinon.stub().returns(testRoster.filter((u) => u.status === "READY")), + save: sinon.stub().returns({ + roster: testRoster, + graveyard: [], + }), + }; + + // Replace gameStateManager properties with mocks + gameStateManager.persistence = mockPersistence; + gameStateManager.rosterManager = mockRosterManager; + gameStateManager.hubStash = mockHubStash; + gameStateManager.gameLoop = mockGameLoop; + }); + + afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + }); + + // Helper to wait for LitElement update + async function waitForUpdate() { + await element.updateComplete; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + // Helper to query shadow DOM + function queryShadow(selector) { + return element.shadowRoot?.querySelector(selector); + } + + function queryShadowAll(selector) { + return element.shadowRoot?.querySelectorAll(selector) || []; + } + + describe("CoA 1: Roster Synchronization", () => { + it("should load roster from RosterManager on connectedCallback", async () => { + await waitForUpdate(); + // Wait for async _loadRoster to complete + let attempts = 0; + while (element.units.length === 0 && attempts < 20) { + await new Promise((resolve) => setTimeout(resolve, 50)); + attempts++; + } + + expect(element.units.length).to.equal(3); + const unitCards = queryShadowAll(".unit-card"); + expect(unitCards.length).to.equal(3); + }); + + it("should display roster count correctly", async () => { + await waitForUpdate(); + + const rosterCount = queryShadow(".roster-count"); + expect(rosterCount).to.exist; + expect(rosterCount.textContent).to.include("3/12"); + }); + + it("should update roster when unit is dismissed", async () => { + await waitForUpdate(); + + // Select a unit first + const unitCards = queryShadowAll(".unit-card"); + unitCards[0].click(); + await waitForUpdate(); + + // Mock confirm to return true + const originalConfirm = window.confirm; + window.confirm = sinon.stub().returns(true); + + // Click dismiss button + const dismissButton = queryShadow(".btn-danger"); + expect(dismissButton).to.exist; + dismissButton.click(); + await waitForUpdate(); + + // Restore confirm + window.confirm = originalConfirm; + + // Verify unit was removed + const updatedCards = queryShadowAll(".unit-card"); + expect(updatedCards).to.have.length(2); + expect(mockRosterManager.roster).to.have.length(2); + expect(mockPersistence.saveRoster.called).to.be.true; + }); + }); + + describe("CoA 2: Healing Transaction", () => { + it("should calculate heal cost correctly", async () => { + await waitForUpdate(); + + // Select injured unit + const unitCards = queryShadowAll(".unit-card"); + const injuredCard = Array.from(unitCards).find((card) => + card.classList.contains("injured") + ); + expect(injuredCard).to.exist; + injuredCard.click(); + await waitForUpdate(); + + // Check heal button exists and shows cost + const healButton = queryShadow(".btn-primary"); + expect(healButton).to.exist; + expect(healButton.textContent).to.include("Treat Wounds"); + }); + + it("should heal unit when heal button is clicked", async () => { + await waitForUpdate(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Select injured unit (UNIT_2 with 60 HP) + const unitCards = queryShadowAll(".unit-card"); + const injuredCard = Array.from(unitCards).find((card) => + card.classList.contains("injured") + ); + expect(injuredCard).to.exist; + injuredCard.click(); + await waitForUpdate(); + + // Get the selected unit to check actual maxHp + const selectedUnit = element._getSelectedUnit(); + expect(selectedUnit).to.exist; + const expectedCost = Math.ceil((selectedUnit.maxHp - selectedUnit.currentHp) * 0.5); + + const healButton = queryShadow(".btn-primary"); + expect(healButton).to.exist; + expect(healButton.textContent).to.include(expectedCost.toString()); + + // Click heal + healButton.click(); + await waitForUpdate(); + + // Verify unit was healed + const injuredUnit = mockRosterManager.roster.find((u) => u.id === "UNIT_2"); + expect(injuredUnit.currentHealth).to.equal(selectedUnit.maxHp); // Max HP + expect(injuredUnit.status).to.equal("READY"); + + // Verify currency was deducted + const expectedRemaining = 1000 - expectedCost; + expect(mockHubStash.currency.aetherShards).to.equal(expectedRemaining); + + // Verify save was called + expect(mockPersistence.saveRoster.called).to.be.true; + expect(mockPersistence.saveHubStash.called).to.be.true; + }); + + it("should disable heal button when unit has full HP", async () => { + await waitForUpdate(); + + // Select healthy unit + const unitCards = queryShadowAll(".unit-card"); + const readyCard = Array.from(unitCards).find( + (card) => !card.classList.contains("injured") + ); + readyCard.click(); + await waitForUpdate(); + + // Check that heal button is disabled or shows "Full Health" + const healButton = queryShadow(".btn:disabled"); + expect(healButton).to.exist; + expect(healButton.textContent).to.include("Full Health"); + }); + + it("should show insufficient funds message when wallet is too low", async () => { + // Set low wallet balance + mockHubStash.currency.aetherShards = 10; + element.wallet = { aetherShards: 10, ancientCores: 0 }; + await waitForUpdate(); + + // Select injured unit + const unitCards = queryShadowAll(".unit-card"); + const injuredCard = Array.from(unitCards).find((card) => + card.classList.contains("injured") + ); + injuredCard.click(); + await waitForUpdate(); + + // Check for insufficient funds message + const healCost = queryShadow(".heal-cost"); + expect(healCost).to.exist; + expect(healCost.textContent).to.include("Insufficient funds"); + + // Heal button should be disabled + const healButton = queryShadow(".btn-primary"); + expect(healButton).to.exist; + expect(healButton.disabled).to.be.true; + }); + + it("should dispatch wallet-updated event after healing", async () => { + await waitForUpdate(); + // Wait for units to load + let attempts = 0; + while (element.units.length === 0 && attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 50)); + attempts++; + } + + let walletUpdatedEvent = null; + const handler = (e) => { + walletUpdatedEvent = e; + }; + window.addEventListener("wallet-updated", handler); + + // Select injured unit and heal + const unitCards = queryShadowAll(".unit-card"); + const injuredCard = Array.from(unitCards).find((card) => + card.classList.contains("injured") + ); + expect(injuredCard).to.exist; + injuredCard.click(); + await waitForUpdate(); + + const healButton = queryShadow(".btn-primary"); + expect(healButton).to.exist; + healButton.click(); + await waitForUpdate(); + + // Wait for event dispatch + attempts = 0; + while (!walletUpdatedEvent && attempts < 20) { + await new Promise((resolve) => setTimeout(resolve, 50)); + attempts++; + } + + expect(walletUpdatedEvent).to.exist; + expect(walletUpdatedEvent.detail.wallet).to.exist; + expect(walletUpdatedEvent.detail.wallet.aetherShards).to.be.lessThan(1000); + + window.removeEventListener("wallet-updated", handler); + }); + }); + + describe("CoA 3: Navigation", () => { + it("should dispatch open-character-sheet event when Inspect is clicked", async () => { + await waitForUpdate(); + // Wait for units to load + let attempts = 0; + while (element.units.length === 0 && attempts < 10) { + await new Promise((resolve) => setTimeout(resolve, 50)); + attempts++; + } + + let characterSheetEvent = null; + const handler = (e) => { + characterSheetEvent = e; + }; + window.addEventListener("open-character-sheet", handler); + + // Find UNIT_1 (Valerius) in the list + const valeriusIndex = element.units.findIndex((u) => u.id === "UNIT_1"); + expect(valeriusIndex).to.be.greaterThan(-1); + + // Select UNIT_1 + const unitCards = queryShadowAll(".unit-card"); + expect(unitCards.length).to.be.greaterThan(0); + // Find the card that contains "Valerius" + const valeriusCard = Array.from(unitCards).find((card) => + card.textContent.includes("Valerius") + ); + expect(valeriusCard).to.exist; + valeriusCard.click(); + await waitForUpdate(); + + // Verify selection + expect(element.selectedUnitId).to.equal("UNIT_1"); + + // Click inspect button (first action button) + const actionButtons = queryShadowAll(".action-button"); + expect(actionButtons.length).to.be.greaterThan(0); + actionButtons[0].click(); + await waitForUpdate(); + + // Wait for event + attempts = 0; + while (!characterSheetEvent && attempts < 20) { + await new Promise((resolve) => setTimeout(resolve, 50)); + attempts++; + } + + expect(characterSheetEvent).to.exist; + expect(characterSheetEvent.detail.unitId).to.equal("UNIT_1"); + expect(characterSheetEvent.detail.unit).to.exist; + + window.removeEventListener("open-character-sheet", handler); + }); + + it("should maintain selection state when character sheet is opened", async () => { + await waitForUpdate(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Find injured unit (UNIT_2) in the list + const injuredUnitIndex = element.units.findIndex((u) => u.id === "UNIT_2"); + expect(injuredUnitIndex).to.be.greaterThan(-1); + + // Select the injured unit + const unitCards = queryShadowAll(".unit-card"); + const injuredCard = Array.from(unitCards).find((card) => + card.classList.contains("injured") + ); + expect(injuredCard).to.exist; + injuredCard.click(); + await waitForUpdate(); + + expect(element.selectedUnitId).to.equal("UNIT_2"); + + // Open character sheet (doesn't change selection) + const actionButtons = queryShadowAll(".action-button"); + expect(actionButtons.length).to.be.greaterThan(0); + actionButtons[0].click(); // Inspect button + + // Selection should remain + expect(element.selectedUnitId).to.equal("UNIT_2"); + }); + }); + + describe("CoA 4: Selection Persistence", () => { + it("should maintain selection when roster is re-sorted", async () => { + await waitForUpdate(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Find injured unit (UNIT_2) and select it + const unitCards = queryShadowAll(".unit-card"); + const injuredCard = Array.from(unitCards).find((card) => + card.classList.contains("injured") + ); + expect(injuredCard).to.exist; + injuredCard.click(); + await waitForUpdate(); + + expect(element.selectedUnitId).to.equal("UNIT_2"); + + // Change sort + const sortButtons = queryShadowAll(".sort-button"); + expect(sortButtons.length).to.be.greaterThan(0); + sortButtons[1].click(); // Name sort + await waitForUpdate(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Selection should persist + expect(element.selectedUnitId).to.equal("UNIT_2"); + + // Verify selected card is still highlighted + const selectedCard = queryShadow(".unit-card.selected"); + expect(selectedCard).to.exist; + }); + }); + + describe("Filtering", () => { + it("should filter units by READY status", async () => { + await waitForUpdate(); + + const filterButtons = queryShadowAll(".filter-button"); + const readyFilter = Array.from(filterButtons).find((btn) => + btn.textContent.includes("Ready") + ); + readyFilter.click(); + await waitForUpdate(); + + const unitCards = queryShadowAll(".unit-card"); + expect(unitCards).to.have.length(2); // Only READY units + }); + + it("should filter units by INJURED status", async () => { + await waitForUpdate(); + + const filterButtons = queryShadowAll(".filter-button"); + const injuredFilter = Array.from(filterButtons).find((btn) => + btn.textContent.includes("Injured") + ); + injuredFilter.click(); + await waitForUpdate(); + + const unitCards = queryShadowAll(".unit-card"); + expect(unitCards).to.have.length(1); // Only INJURED unit + expect(unitCards[0].classList.contains("injured")).to.be.true; + }); + + it("should show all units when ALL filter is selected", async () => { + await waitForUpdate(); + + // First filter to INJURED + const filterButtons = queryShadowAll(".filter-button"); + const injuredFilter = Array.from(filterButtons).find((btn) => + btn.textContent.includes("Injured") + ); + injuredFilter.click(); + await waitForUpdate(); + + // Then switch to ALL + const allFilter = Array.from(filterButtons).find((btn) => + btn.textContent.includes("All") + ); + allFilter.click(); + await waitForUpdate(); + + const unitCards = queryShadowAll(".unit-card"); + expect(unitCards).to.have.length(3); // All units + }); + }); + + describe("Sorting", () => { + it("should sort units by level (descending)", async () => { + await waitForUpdate(); + + // Default sort should be LEVEL_DESC + const unitCards = queryShadowAll(".unit-card"); + expect(unitCards.length).to.be.greaterThan(0); + + // First unit should be highest level (UNIT_3, level 5) + const firstCard = unitCards[0]; + expect(firstCard.textContent).to.include("Kael"); + }); + + it("should sort units by name (ascending)", async () => { + await waitForUpdate(); + + const sortButtons = queryShadowAll(".sort-button"); + const nameSort = Array.from(sortButtons).find((btn) => + btn.textContent.includes("Name") + ); + nameSort.click(); + await waitForUpdate(); + + const unitCards = queryShadowAll(".unit-card"); + expect(unitCards.length).to.be.greaterThan(0); + // First should be alphabetically first (Aria) + expect(unitCards[0].textContent).to.include("Aria"); + }); + + it("should sort units by HP (ascending)", async () => { + await waitForUpdate(); + + const sortButtons = queryShadowAll(".sort-button"); + const hpSort = Array.from(sortButtons).find((btn) => + btn.textContent.includes("HP") + ); + hpSort.click(); + await waitForUpdate(); + + const unitCards = queryShadowAll(".unit-card"); + expect(unitCards.length).to.be.greaterThan(0); + // First should be lowest HP (injured unit) + expect(unitCards[0].classList.contains("injured")).to.be.true; + }); + }); + + describe("Unit Card Rendering", () => { + it("should render unit cards with correct information", async () => { + await waitForUpdate(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const unitCards = queryShadowAll(".unit-card"); + expect(unitCards.length).to.equal(3); + + // Check that all units are rendered (order may vary by sort) + const allNames = Array.from(unitCards).map((card) => card.textContent); + expect(allNames.some((text) => text.includes("Valerius"))).to.be.true; + expect(allNames.some((text) => text.includes("Aria"))).to.be.true; + expect(allNames.some((text) => text.includes("Kael"))).to.be.true; + }); + + it("should show HP bar with correct percentage", async () => { + await waitForUpdate(); + + // Find injured unit card + const unitCards = queryShadowAll(".unit-card"); + const injuredCard = Array.from(unitCards).find((card) => + card.classList.contains("injured") + ); + + const hpBar = injuredCard.querySelector(".progress-bar-fill"); + expect(hpBar).to.exist; + // Should be around 50% (60/120) + const width = hpBar.style.width; + expect(width).to.include("%"); + }); + + it("should highlight selected unit card", async () => { + await waitForUpdate(); + + const unitCards = queryShadowAll(".unit-card"); + unitCards[0].click(); + await waitForUpdate(); + + const selectedCard = queryShadow(".unit-card.selected"); + expect(selectedCard).to.exist; + expect(selectedCard).to.equal(unitCards[0]); + }); + }); + + describe("Detail Sidebar", () => { + it("should show empty state when no unit is selected", async () => { + await waitForUpdate(); + + const emptyState = queryShadow(".empty-state"); + expect(emptyState).to.exist; + expect(emptyState.textContent).to.include("Select a unit"); + }); + + it("should display unit details when unit is selected", async () => { + await waitForUpdate(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Find Valerius unit + const valeriusUnit = element.units.find((u) => u.name === "Valerius"); + expect(valeriusUnit).to.exist; + + // Find and click the card for Valerius + const unitCards = queryShadowAll(".unit-card"); + const valeriusCard = Array.from(unitCards).find((card) => + card.textContent.includes("Valerius") + ); + expect(valeriusCard).to.exist; + valeriusCard.click(); + await waitForUpdate(); + + const detailSidebar = queryShadow(".detail-sidebar"); + expect(detailSidebar).to.exist; + expect(detailSidebar.textContent).to.include("Valerius"); + expect(detailSidebar.textContent).to.include("Level"); + }); + + it("should show heal button for injured units", async () => { + await waitForUpdate(); + + // Select injured unit + const unitCards = queryShadowAll(".unit-card"); + const injuredCard = Array.from(unitCards).find((card) => + card.classList.contains("injured") + ); + injuredCard.click(); + await waitForUpdate(); + + const healButton = queryShadow(".btn-primary"); + expect(healButton).to.exist; + expect(healButton.textContent).to.include("Treat Wounds"); + }); + + it("should show dismiss button", async () => { + await waitForUpdate(); + + const unitCards = queryShadowAll(".unit-card"); + unitCards[0].click(); + await waitForUpdate(); + + const dismissButton = queryShadow(".btn-danger"); + expect(dismissButton).to.exist; + expect(dismissButton.textContent).to.include("Dismiss"); + }); + }); + + describe("Close Functionality", () => { + it("should dispatch close-barracks event when close button is clicked", async () => { + await waitForUpdate(); + + let closeEvent = null; + element.addEventListener("close-barracks", (e) => { + closeEvent = e; + }); + + const closeButton = queryShadow(".btn-close"); + expect(closeButton).to.exist; + closeButton.click(); + + expect(closeEvent).to.exist; + }); + }); + + describe("HP Calculation", () => { + it("should calculate maxHp from class definition and level", async () => { + await waitForUpdate(); + + // UNIT_1 is level 3, should have base + (3-1) * growth + // Vanguard base health: 120, growth: 10 + // Expected: 120 + (3-1) * 10 = 140 + const unitCards = queryShadowAll(".unit-card"); + unitCards[0].click(); + await waitForUpdate(); + + const detailSidebar = queryShadow(".detail-sidebar"); + // Should show HP values + expect(detailSidebar.textContent).to.match(/\d+\s*\/\s*\d+/); + }); + + it("should use stored currentHealth if available", async () => { + await waitForUpdate(); + + // UNIT_2 has currentHealth: 60 stored + const unitCards = queryShadowAll(".unit-card"); + const injuredCard = Array.from(unitCards).find((card) => + card.classList.contains("injured") + ); + injuredCard.click(); + await waitForUpdate(); + + const detailSidebar = queryShadow(".detail-sidebar"); + // Should show 60 in HP display + expect(detailSidebar.textContent).to.include("60"); + }); + }); +}); +