Implement BarracksScreen for roster management and enhance game state integration
- Introduce the BarracksScreen component to manage the player roster, allowing for unit selection, healing, and dismissal. - Update GameStateManager to support roster persistence and integrate with the new BarracksScreen for seamless data handling. - Enhance UI components for improved user experience, including dynamic filtering and sorting of units. - Implement detailed unit information display and actions for healing and dismissing units. - Add comprehensive tests for the BarracksScreen to validate functionality and integration with the overall game architecture.
This commit is contained in:
parent
cc38ee2808
commit
45276d1bd4
16 changed files with 2240 additions and 105 deletions
|
|
@ -129,22 +129,3 @@ The UI must filter the raw roster list locally.
|
||||||
**CoA 4: Selection Persistence**
|
**CoA 4: Selection Persistence**
|
||||||
|
|
||||||
- If the roster is re-sorted, the currently selected unit remains selected.
|
- 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."
|
|
||||||
|
|
|
||||||
|
|
@ -2625,6 +2625,17 @@ export class GameLoop {
|
||||||
if (unit.activeClassId) {
|
if (unit.activeClassId) {
|
||||||
rosterUnit.activeClassId = 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})`);
|
console.log(`Saved progression for ${unit.name} (roster ID: ${rosterId})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ class GameStateManagerClass {
|
||||||
/** @type {RosterManager} */
|
/** @type {RosterManager} */
|
||||||
this.rosterManager = new RosterManager();
|
this.rosterManager = new RosterManager();
|
||||||
/** @type {MissionManager} */
|
/** @type {MissionManager} */
|
||||||
this.missionManager = new MissionManager();
|
this.missionManager = new MissionManager(this.persistence);
|
||||||
/** @type {import("../managers/NarrativeManager.js").NarrativeManager} */
|
/** @type {import("../managers/NarrativeManager.js").NarrativeManager} */
|
||||||
this.narrativeManager = narrativeManager; // Track the singleton instance
|
this.narrativeManager = narrativeManager; // Track the singleton instance
|
||||||
|
|
||||||
|
|
@ -117,7 +117,7 @@ class GameStateManagerClass {
|
||||||
this.activeRunData = null;
|
this.activeRunData = null;
|
||||||
this.combatState = null;
|
this.combatState = null;
|
||||||
this.rosterManager = new RosterManager();
|
this.rosterManager = new RosterManager();
|
||||||
this.missionManager = new MissionManager();
|
this.missionManager = new MissionManager(this.persistence);
|
||||||
// Recreate hub inventory and market manager
|
// Recreate hub inventory and market manager
|
||||||
this.hubStash = new InventoryContainer("HUB_VAULT");
|
this.hubStash = new InventoryContainer("HUB_VAULT");
|
||||||
this.hubInventoryManager = new InventoryManager(
|
this.hubInventoryManager = new InventoryManager(
|
||||||
|
|
@ -152,7 +152,7 @@ class GameStateManagerClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Load Hub Stash
|
// 2. Load Hub Stash
|
||||||
this._loadHubStash();
|
await this._loadHubStash();
|
||||||
|
|
||||||
// 3. Initialize Market Manager
|
// 3. Initialize Market Manager
|
||||||
await this.marketManager.init();
|
await this.marketManager.init();
|
||||||
|
|
@ -535,11 +535,11 @@ class GameStateManagerClass {
|
||||||
* Loads the hub stash from persistence.
|
* Loads the hub stash from persistence.
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_loadHubStash() {
|
async _loadHubStash() {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem("aether_shards_hub_stash");
|
// Try IndexedDB first
|
||||||
if (saved) {
|
const hubData = await this.persistence.loadHubStash();
|
||||||
const hubData = JSON.parse(saved);
|
if (hubData) {
|
||||||
if (hubData.currency) {
|
if (hubData.currency) {
|
||||||
this.hubStash.currency.aetherShards =
|
this.hubStash.currency.aetherShards =
|
||||||
hubData.currency.aetherShards || 0;
|
hubData.currency.aetherShards || 0;
|
||||||
|
|
@ -551,7 +551,23 @@ class GameStateManagerClass {
|
||||||
this.hubStash.addItem(item);
|
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) {
|
} catch (error) {
|
||||||
console.warn("Failed to load hub stash:", error);
|
console.warn("Failed to load hub stash:", error);
|
||||||
|
|
@ -563,15 +579,14 @@ class GameStateManagerClass {
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
async _saveHubStash() {
|
async _saveHubStash() {
|
||||||
// Save hub stash data to persistence
|
// Save hub stash data to IndexedDB
|
||||||
// This ensures rewards persist across sessions
|
// This ensures rewards persist across sessions
|
||||||
try {
|
try {
|
||||||
const hubData = {
|
const hubData = {
|
||||||
currency: this.hubStash.currency,
|
currency: this.hubStash.currency,
|
||||||
items: this.hubStash.getAllItems(),
|
items: this.hubStash.getAllItems(),
|
||||||
};
|
};
|
||||||
// Save hub stash to localStorage
|
await this.persistence.saveHubStash(hubData);
|
||||||
localStorage.setItem("aether_shards_hub_stash", JSON.stringify(hubData));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to save hub stash:", error);
|
console.warn("Failed to save hub stash:", error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ const RUN_STORE = "Runs";
|
||||||
const ROSTER_STORE = "Roster";
|
const ROSTER_STORE = "Roster";
|
||||||
const MARKET_STORE = "Market";
|
const MARKET_STORE = "Market";
|
||||||
const CAMPAIGN_STORE = "Campaign";
|
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.
|
* Handles game data persistence using IndexedDB.
|
||||||
|
|
@ -57,6 +59,16 @@ export class Persistence {
|
||||||
if (!db.objectStoreNames.contains(CAMPAIGN_STORE)) {
|
if (!db.objectStoreNames.contains(CAMPAIGN_STORE)) {
|
||||||
db.createObjectStore(CAMPAIGN_STORE, { keyPath: "id" });
|
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) => {
|
request.onsuccess = (e) => {
|
||||||
|
|
@ -163,6 +175,50 @@ export class Persistence {
|
||||||
return result ? result.data : null;
|
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<void>}
|
||||||
|
*/
|
||||||
|
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<Object | null>} - 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<void>}
|
||||||
|
*/
|
||||||
|
async saveUnlocks(unlocks) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
return this._put(UNLOCKS_STORE, { id: "unlocks", data: unlocks });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads unlocked classes.
|
||||||
|
* @returns {Promise<string[]>} - 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 ---
|
// --- INTERNAL HELPERS ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
28
src/index.js
28
src/index.js
|
|
@ -25,7 +25,13 @@ const uiLayer = document.getElementById("ui-layer");
|
||||||
let currentCharacterSheet = null;
|
let currentCharacterSheet = null;
|
||||||
|
|
||||||
window.addEventListener("open-character-sheet", async (e) => {
|
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
|
// Resolve unit from ID if needed
|
||||||
if (!unit && unitId && gameStateManager.gameLoop?.unitManager) {
|
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");
|
const { CharacterSheet } = await import("./ui/components/character-sheet.js");
|
||||||
|
|
||||||
// Generate skill tree using SkillTreeFactory if available
|
// Generate skill tree using SkillTreeFactory if available
|
||||||
let skillTree = null;
|
// Use provided skillTree if available (e.g., from barracks), otherwise generate
|
||||||
if (gameStateManager.gameLoop?.classRegistry && unit.activeClassId) {
|
if (
|
||||||
|
!skillTree &&
|
||||||
|
gameStateManager.gameLoop?.classRegistry &&
|
||||||
|
unit.activeClassId
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const { SkillTreeFactory } = await import(
|
const { SkillTreeFactory } = await import(
|
||||||
"./factories/SkillTreeFactory.js"
|
"./factories/SkillTreeFactory.js"
|
||||||
|
|
@ -111,11 +121,17 @@ window.addEventListener("open-character-sheet", async (e) => {
|
||||||
characterSheet.inventory = inventory;
|
characterSheet.inventory = inventory;
|
||||||
characterSheet.gameMode =
|
characterSheet.gameMode =
|
||||||
gameStateManager.currentState === "STATE_COMBAT" ? "DUNGEON" : "HUB";
|
gameStateManager.currentState === "STATE_COMBAT" ? "DUNGEON" : "HUB";
|
||||||
characterSheet.treeDef = skillTree; // Pass generated tree
|
// Use provided skillTree if available (from barracks), otherwise use generated one
|
||||||
// Pass inventoryManager from gameLoop if available
|
characterSheet.treeDef = skillTree || null; // Pass generated tree
|
||||||
if (gameStateManager.gameLoop?.inventoryManager) {
|
// Pass inventoryManager - use hubInventoryManager in Hub mode, gameLoop's in combat
|
||||||
|
if (
|
||||||
|
gameStateManager.currentState === "STATE_COMBAT" &&
|
||||||
|
gameStateManager.gameLoop?.inventoryManager
|
||||||
|
) {
|
||||||
characterSheet.inventoryManager =
|
characterSheet.inventoryManager =
|
||||||
gameStateManager.gameLoop.inventoryManager;
|
gameStateManager.gameLoop.inventoryManager;
|
||||||
|
} else if (gameStateManager.hubInventoryManager) {
|
||||||
|
characterSheet.inventoryManager = gameStateManager.hubInventoryManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle close event
|
// Handle close event
|
||||||
|
|
|
||||||
|
|
@ -120,11 +120,11 @@ export class InventoryManager {
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_getStashForItem(itemInstance) {
|
_getStashForItem(itemInstance) {
|
||||||
// Check both stashes
|
// Check both stashes (runStash may be null in Hub mode)
|
||||||
if (this.runStash.findItem(itemInstance.uid)) {
|
if (this.runStash && this.runStash.findItem(itemInstance.uid)) {
|
||||||
return this.runStash;
|
return this.runStash;
|
||||||
}
|
}
|
||||||
if (this.hubStash.findItem(itemInstance.uid)) {
|
if (this.hubStash && this.hubStash.findItem(itemInstance.uid)) {
|
||||||
return this.hubStash;
|
return this.hubStash;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,12 @@ export class MarketManager {
|
||||||
* @param {import("../managers/InventoryManager.js").InventoryManager} inventoryManager - Inventory manager (for hubStash access)
|
* @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)
|
* @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} */
|
/** @type {import("../core/Persistence.js").Persistence} */
|
||||||
this.persistence = persistence;
|
this.persistence = persistence;
|
||||||
/** @type {import("../managers/ItemRegistry.js").ItemRegistry} */
|
/** @type {import("../managers/ItemRegistry.js").ItemRegistry} */
|
||||||
|
|
@ -207,7 +212,9 @@ export class MarketManager {
|
||||||
*/
|
*/
|
||||||
_generateMerchantStock(allItems, allowedTypes, rarityWeights, count) {
|
_generateMerchantStock(allItems, allowedTypes, rarityWeights, count) {
|
||||||
// Filter by allowed types
|
// 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 [];
|
if (filtered.length === 0) return [];
|
||||||
|
|
||||||
|
|
@ -328,9 +335,22 @@ export class MarketManager {
|
||||||
// 4. Mark as purchased
|
// 4. Mark as purchased
|
||||||
marketItem.purchased = true;
|
marketItem.purchased = true;
|
||||||
|
|
||||||
// 5. Save state
|
// 5. Save market state
|
||||||
await this.persistence.saveMarketState(this.marketState);
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error buying item:", error);
|
console.error("Error buying item:", error);
|
||||||
|
|
@ -370,7 +390,19 @@ export class MarketManager {
|
||||||
// 3. Add currency
|
// 3. Add currency
|
||||||
this.inventoryManager.hubStash.currency.aetherShards += sellPrice;
|
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) {
|
if (this.marketState.buyback.length >= 10) {
|
||||||
this.marketState.buyback.shift(); // Remove oldest
|
this.marketState.buyback.shift(); // Remove oldest
|
||||||
}
|
}
|
||||||
|
|
@ -388,7 +420,7 @@ export class MarketManager {
|
||||||
|
|
||||||
this.marketState.buyback.push(buybackItem);
|
this.marketState.buyback.push(buybackItem);
|
||||||
|
|
||||||
// 5. Save state
|
// 7. Save market state
|
||||||
await this.persistence.saveMarketState(this.marketState);
|
await this.persistence.saveMarketState(this.marketState);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -434,7 +466,9 @@ export class MarketManager {
|
||||||
* Cleanup - remove event listeners.
|
* Cleanup - remove event listeners.
|
||||||
*/
|
*/
|
||||||
destroy() {
|
destroy() {
|
||||||
window.removeEventListener("mission-victory", this._boundHandleMissionVictory);
|
window.removeEventListener(
|
||||||
|
"mission-victory",
|
||||||
|
this._boundHandleMissionVictory
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,13 @@ import { narrativeManager } from './NarrativeManager.js';
|
||||||
* @class
|
* @class
|
||||||
*/
|
*/
|
||||||
export class MissionManager {
|
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
|
// Campaign State
|
||||||
/** @type {string | null} */
|
/** @type {string | null} */
|
||||||
this.activeMissionId = 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
|
* @param {string[]} classIds - Array of class IDs to unlock
|
||||||
*/
|
*/
|
||||||
unlockClasses(classIds) {
|
async unlockClasses(classIds) {
|
||||||
const storageKey = 'aether_shards_unlocks';
|
|
||||||
let unlocks = [];
|
let unlocks = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(storageKey);
|
// Load from IndexedDB
|
||||||
if (stored) {
|
if (this.persistence) {
|
||||||
unlocks = JSON.parse(stored);
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to load unlocks from storage:', 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 {
|
try {
|
||||||
localStorage.setItem(storageKey, JSON.stringify(unlocks));
|
if (this.persistence) {
|
||||||
console.log('Unlocked classes:', classIds);
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to save unlocks to storage:', e);
|
console.error('Failed to save unlocks to storage:', e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -598,6 +598,34 @@ export class CharacterSheet extends LitElement {
|
||||||
transform: scale(1.1);
|
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 {
|
.item-card img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -1041,9 +1069,18 @@ export class CharacterSheet extends LitElement {
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
class="equipment-slot ${slotClass} ${isSelected ? "selected" : ""}"
|
class="equipment-slot ${slotClass} ${isSelected ? "selected" : ""}"
|
||||||
@click="${() => this._handleSlotClick(slotType)}"
|
@click="${(e) => this._handleSlotClick(slotType, e)}"
|
||||||
|
@contextmenu="${(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this._handleUnequip(slotType);
|
||||||
|
}}"
|
||||||
?disabled="${this.readOnly}"
|
?disabled="${this.readOnly}"
|
||||||
aria-label="${label} slot${itemDef ? `: ${itemDef.name}` : " (empty)"}"
|
aria-label="${label} slot${itemDef
|
||||||
|
? `: ${itemDef.name}`
|
||||||
|
: " (empty)"}${itemDef ? " - Right-click to unequip" : ""}"
|
||||||
|
title="${itemDef
|
||||||
|
? `${itemDef.name} - Right-click to unequip`
|
||||||
|
: `${label} slot - Click to select, right-click to unequip`}"
|
||||||
>
|
>
|
||||||
${itemDef
|
${itemDef
|
||||||
? html`<img
|
? html`<img
|
||||||
|
|
@ -1072,11 +1109,18 @@ export class CharacterSheet extends LitElement {
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
class="equipment-slot belt${index + 1} ${isSelected ? "selected" : ""}"
|
class="equipment-slot belt${index + 1} ${isSelected ? "selected" : ""}"
|
||||||
@click="${() => this._handleSlotClick(slotType)}"
|
@click="${(e) => this._handleSlotClick(slotType, e)}"
|
||||||
|
@contextmenu="${(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this._handleUnequip(slotType);
|
||||||
|
}}"
|
||||||
?disabled="${this.readOnly}"
|
?disabled="${this.readOnly}"
|
||||||
aria-label="Belt slot ${index + 1}${itemDef
|
aria-label="Belt slot ${index + 1}${itemDef
|
||||||
? `: ${itemDef.name}`
|
? `: ${itemDef.name} - Right-click to unequip`
|
||||||
: " (empty)"}"
|
: " (empty)"}"
|
||||||
|
title="${itemDef
|
||||||
|
? `${itemDef.name} - Right-click to unequip`
|
||||||
|
: `Belt slot ${index + 1} - Click to select, right-click to unequip`}"
|
||||||
>
|
>
|
||||||
${itemDef
|
${itemDef
|
||||||
? html`<img
|
? html`<img
|
||||||
|
|
@ -1093,10 +1137,18 @@ export class CharacterSheet extends LitElement {
|
||||||
/**
|
/**
|
||||||
* Handles equipment slot click
|
* Handles equipment slot click
|
||||||
* @param {string} slotType - Type of slot clicked
|
* @param {string} slotType - Type of slot clicked
|
||||||
|
* @param {MouseEvent} event - Mouse event (for right-click detection)
|
||||||
*/
|
*/
|
||||||
_handleSlotClick(slotType) {
|
_handleSlotClick(slotType, event) {
|
||||||
if (this.readOnly) return;
|
if (this.readOnly) return;
|
||||||
|
|
||||||
|
// Right-click to unequip
|
||||||
|
if (event && event.button === 2) {
|
||||||
|
event.preventDefault();
|
||||||
|
this._handleUnequip(slotType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.selectedSlot === slotType) {
|
if (this.selectedSlot === slotType) {
|
||||||
this.selectedSlot = null;
|
this.selectedSlot = null;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1106,6 +1158,55 @@ export class CharacterSheet extends LitElement {
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles unequipping an item from a slot
|
||||||
|
* @param {string} slotType - Type of slot to unequip
|
||||||
|
*/
|
||||||
|
_handleUnequip(slotType) {
|
||||||
|
if (!this.inventoryManager || !this.unit) return;
|
||||||
|
|
||||||
|
// Map slot types to InventoryManager slot names
|
||||||
|
const slotMap = {
|
||||||
|
MAIN_HAND: "MAIN_HAND",
|
||||||
|
OFF_HAND: "OFF_HAND",
|
||||||
|
BODY: "BODY",
|
||||||
|
ACCESSORY: "ACCESSORY",
|
||||||
|
BELT_0: "BELT",
|
||||||
|
BELT_1: "BELT",
|
||||||
|
};
|
||||||
|
|
||||||
|
let slot = slotMap[slotType] || slotType;
|
||||||
|
let beltIndex = undefined;
|
||||||
|
|
||||||
|
// Handle belt slots
|
||||||
|
if (slotType === "BELT_0" || slotType === "BELT_1") {
|
||||||
|
beltIndex = slotType === "BELT_0" ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = this.inventoryManager.unequipItem(
|
||||||
|
this.unit,
|
||||||
|
slot,
|
||||||
|
beltIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Dispatch unequip event
|
||||||
|
this.dispatchEvent(
|
||||||
|
new CustomEvent("unequip-item", {
|
||||||
|
detail: {
|
||||||
|
unitId: this.unit.id,
|
||||||
|
slot: slot,
|
||||||
|
},
|
||||||
|
bubbles: true,
|
||||||
|
composed: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
this.selectedSlot = null;
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles item click in inventory
|
* Handles item click in inventory
|
||||||
* @param {Object} item - Item to equip
|
* @param {Object} item - Item to equip
|
||||||
|
|
@ -1113,6 +1214,15 @@ export class CharacterSheet extends LitElement {
|
||||||
_handleItemClick(item) {
|
_handleItemClick(item) {
|
||||||
if (this.readOnly || !this.selectedSlot) return;
|
if (this.readOnly || !this.selectedSlot) return;
|
||||||
|
|
||||||
|
// Check if item can be equipped (class restrictions, etc.)
|
||||||
|
if (item.canEquipCheck === false) {
|
||||||
|
// Item cannot be equipped - show feedback
|
||||||
|
console.warn(
|
||||||
|
`Cannot equip ${item.name} - class restriction or requirements not met`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Use inventoryManager if available
|
// Use inventoryManager if available
|
||||||
if (this.inventoryManager && item.uid) {
|
if (this.inventoryManager && item.uid) {
|
||||||
// Map slot types to InventoryManager slot names
|
// Map slot types to InventoryManager slot names
|
||||||
|
|
@ -1145,6 +1255,17 @@ export class CharacterSheet extends LitElement {
|
||||||
quantity: item.quantity || 1,
|
quantity: item.quantity || 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Double-check with InventoryManager before equipping (safety check)
|
||||||
|
if (!this.inventoryManager.canEquip(this.unit, itemInstance)) {
|
||||||
|
console.warn(
|
||||||
|
`Cannot equip ${item.name} - class restriction or requirements not met`
|
||||||
|
);
|
||||||
|
alert(
|
||||||
|
`Cannot equip ${item.name} - This item has class restrictions or requirements you don't meet.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Use InventoryManager to equip
|
// Use InventoryManager to equip
|
||||||
const success = this.inventoryManager.equipItem(
|
const success = this.inventoryManager.equipItem(
|
||||||
this.unit,
|
this.unit,
|
||||||
|
|
@ -1251,6 +1372,12 @@ export class CharacterSheet extends LitElement {
|
||||||
const itemDef = this.inventoryManager.itemRegistry?.get(
|
const itemDef = this.inventoryManager.itemRegistry?.get(
|
||||||
itemInstance.defId
|
itemInstance.defId
|
||||||
);
|
);
|
||||||
|
// Check if unit can equip this item using InventoryManager
|
||||||
|
const canEquip =
|
||||||
|
this.unit && this.inventoryManager
|
||||||
|
? this.inventoryManager.canEquip(this.unit, itemInstance)
|
||||||
|
: true; // Default to true if we can't check
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uid: itemInstance.uid,
|
uid: itemInstance.uid,
|
||||||
defId: itemInstance.defId,
|
defId: itemInstance.defId,
|
||||||
|
|
@ -1258,8 +1385,10 @@ export class CharacterSheet extends LitElement {
|
||||||
type: itemDef?.type || "UTILITY",
|
type: itemDef?.type || "UTILITY",
|
||||||
stats: itemDef?.stats || {},
|
stats: itemDef?.stats || {},
|
||||||
canEquip: itemDef ? (unit) => itemDef.canEquip(unit) : () => true,
|
canEquip: itemDef ? (unit) => itemDef.canEquip(unit) : () => true,
|
||||||
|
canEquipCheck: canEquip, // Store the result of the check
|
||||||
quantity: itemInstance.quantity,
|
quantity: itemInstance.quantity,
|
||||||
isNew: itemInstance.isNew,
|
isNew: itemInstance.isNew,
|
||||||
|
icon: itemDef?.icon || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1329,6 +1458,27 @@ export class CharacterSheet extends LitElement {
|
||||||
return mastery?.skillPoints || 0;
|
return mastery?.skillPoints || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a human-readable label for a slot type
|
||||||
|
* @param {string} slotType - Slot type (MAIN_HAND, OFF_HAND, etc.)
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
_getSlotLabel(slotType) {
|
||||||
|
const labels = {
|
||||||
|
MAIN_HAND: "Main Hand",
|
||||||
|
OFF_HAND: "Off Hand",
|
||||||
|
BODY: "Body",
|
||||||
|
ACCESSORY: "Accessory",
|
||||||
|
BELT_0: "Belt Slot 1",
|
||||||
|
BELT_1: "Belt Slot 2",
|
||||||
|
WEAPON: "Weapon",
|
||||||
|
ARMOR: "Armor",
|
||||||
|
UTILITY: "Utility",
|
||||||
|
RELIC: "Relic",
|
||||||
|
};
|
||||||
|
return labels[slotType] || slotType;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets class name from class ID
|
* Gets class name from class ID
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
|
|
@ -1512,13 +1662,50 @@ export class CharacterSheet extends LitElement {
|
||||||
role="tabpanel"
|
role="tabpanel"
|
||||||
aria-labelledby="inventory-tab"
|
aria-labelledby="inventory-tab"
|
||||||
>
|
>
|
||||||
${filteredInventory.map(
|
${this.selectedSlot
|
||||||
(item) => html`
|
? html`
|
||||||
|
<div
|
||||||
|
class="equip-hint"
|
||||||
|
style="grid-column: 1/-1; padding: 10px; background: rgba(0, 255, 0, 0.1); border: 1px solid #00ff00; border-radius: 4px; margin-bottom: 10px; text-align: center; color: #00ff00; font-size: 12px;"
|
||||||
|
>
|
||||||
|
<strong>Slot Selected:</strong>
|
||||||
|
${this._getSlotLabel(this.selectedSlot)} - Click
|
||||||
|
an item below to equip it
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<div
|
||||||
|
class="equip-hint"
|
||||||
|
style="grid-column: 1/-1; padding: 10px; background: rgba(0, 255, 255, 0.1); border: 1px solid #00ffff; border-radius: 4px; margin-bottom: 10px; text-align: center; color: #00ffff; font-size: 12px;"
|
||||||
|
>
|
||||||
|
<strong>Tip:</strong> Click an equipment slot on
|
||||||
|
the left, then click an item here to equip it
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
${filteredInventory.map((item) => {
|
||||||
|
// Check if item can actually be equipped (class restrictions, etc.)
|
||||||
|
const canEquipItem = item.canEquipCheck !== false;
|
||||||
|
const isEquippable = this.selectedSlot && canEquipItem;
|
||||||
|
|
||||||
|
return html`
|
||||||
<button
|
<button
|
||||||
class="item-card"
|
class="item-card ${isEquippable
|
||||||
|
? "can-equip"
|
||||||
|
: ""} ${!canEquipItem && this.selectedSlot
|
||||||
|
? "cannot-equip"
|
||||||
|
: ""}"
|
||||||
@click="${() => this._handleItemClick(item)}"
|
@click="${() => this._handleItemClick(item)}"
|
||||||
title="${item.name}"
|
?disabled="${!canEquipItem && this.selectedSlot}"
|
||||||
aria-label="Equip ${item.name}"
|
title="${item.name}${this.selectedSlot
|
||||||
|
? ` - Click to equip to ${this._getSlotLabel(
|
||||||
|
this.selectedSlot
|
||||||
|
)}`
|
||||||
|
: ""}${!canEquipItem && this.selectedSlot
|
||||||
|
? " (Cannot equip - class restriction)"
|
||||||
|
: ""}"
|
||||||
|
aria-label="Equip ${item.name}${!canEquipItem
|
||||||
|
? " (Cannot equip)"
|
||||||
|
: ""}"
|
||||||
>
|
>
|
||||||
${item.icon
|
${item.icon
|
||||||
? html`<img
|
? html`<img
|
||||||
|
|
@ -1527,13 +1714,17 @@ export class CharacterSheet extends LitElement {
|
||||||
/>`
|
/>`
|
||||||
: html`<span aria-hidden="true">📦</span>`}
|
: html`<span aria-hidden="true">📦</span>`}
|
||||||
</button>
|
</button>
|
||||||
`
|
`;
|
||||||
)}
|
})}
|
||||||
${filteredInventory.length === 0
|
${filteredInventory.length === 0
|
||||||
? html`<p
|
? html`<p
|
||||||
style="grid-column: 1/-1; text-align: center; color: #666;"
|
style="grid-column: 1/-1; text-align: center; color: #666;"
|
||||||
>
|
>
|
||||||
No items available
|
${this.selectedSlot
|
||||||
|
? `No items available for ${this._getSlotLabel(
|
||||||
|
this.selectedSlot
|
||||||
|
)}`
|
||||||
|
: "No items available"}
|
||||||
</p>`
|
</p>`
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
1028
src/ui/screens/BarracksScreen.js
Normal file
1028
src/ui/screens/BarracksScreen.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -215,10 +215,13 @@ export class HubScreen extends LitElement {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this._loadData();
|
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._boundHandleStateChange = this._handleStateChange.bind(this);
|
||||||
|
this._boundHandleWalletUpdate = this._handleWalletUpdate.bind(this);
|
||||||
// Listen for state changes to update data
|
// Listen for state changes to update data
|
||||||
window.addEventListener("gamestate-changed", this._boundHandleStateChange);
|
window.addEventListener("gamestate-changed", this._boundHandleStateChange);
|
||||||
|
// Listen for wallet updates from Barracks
|
||||||
|
window.addEventListener("wallet-updated", this._boundHandleWalletUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
|
@ -229,6 +232,12 @@ export class HubScreen extends LitElement {
|
||||||
this._boundHandleStateChange
|
this._boundHandleStateChange
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (this._boundHandleWalletUpdate) {
|
||||||
|
window.removeEventListener(
|
||||||
|
"wallet-updated",
|
||||||
|
this._boundHandleWalletUpdate
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _loadData() {
|
async _loadData() {
|
||||||
|
|
@ -281,6 +290,17 @@ export class HubScreen extends LitElement {
|
||||||
this._loadData();
|
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) {
|
_openOverlay(type) {
|
||||||
this.activeOverlay = type;
|
this.activeOverlay = type;
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
|
|
@ -336,20 +356,9 @@ export class HubScreen extends LitElement {
|
||||||
break;
|
break;
|
||||||
case "BARRACKS":
|
case "BARRACKS":
|
||||||
overlayComponent = html`
|
overlayComponent = html`
|
||||||
<div
|
<barracks-screen
|
||||||
style="background: rgba(20, 20, 30, 0.95); padding: 30px; border: 2px solid #555; max-width: 800px;"
|
@close-barracks=${this._closeOverlay}
|
||||||
>
|
></barracks-screen>
|
||||||
<h2 style="margin-top: 0; color: #00ffff;">BARRACKS</h2>
|
|
||||||
<p>Total Units: ${this.rosterSummary.total}</p>
|
|
||||||
<p>Ready: ${this.rosterSummary.ready}</p>
|
|
||||||
<p>Injured: ${this.rosterSummary.injured}</p>
|
|
||||||
<button
|
|
||||||
@click=${this._closeOverlay}
|
|
||||||
style="margin-top: 20px; padding: 10px 20px; background: #333; border: 2px solid #555; color: white; cursor: pointer;"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
break;
|
break;
|
||||||
case "MARKET":
|
case "MARKET":
|
||||||
|
|
@ -419,6 +428,10 @@ export class HubScreen extends LitElement {
|
||||||
if (this.activeOverlay === "MARKET") {
|
if (this.activeOverlay === "MARKET") {
|
||||||
import("./marketplace-screen.js").catch(console.error);
|
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`
|
return html`
|
||||||
<div class="background"></div>
|
<div class="background"></div>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ describe("Core: GameStateManager (Singleton)", () => {
|
||||||
saveCampaign: sinon.stub().resolves(),
|
saveCampaign: sinon.stub().resolves(),
|
||||||
loadMarketState: sinon.stub().resolves(null),
|
loadMarketState: sinon.stub().resolves(null),
|
||||||
saveMarketState: sinon.stub().resolves(),
|
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)
|
// Inject Mock (replacing the real Persistence instance)
|
||||||
gameStateManager.persistence = mockPersistence;
|
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 () => {
|
it("CoA 3: handleEmbark should initialize run, save, and start engine", async () => {
|
||||||
// Mock RosterManager.recruitUnit to return async unit with generated name
|
// Mock RosterManager.recruitUnit to return async unit with generated name
|
||||||
const mockRecruitedUnit = {
|
const mockRecruitedUnit = {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ describe("Core: GameStateManager - Hub Integration", () => {
|
||||||
saveCampaign: sinon.stub().resolves(),
|
saveCampaign: sinon.stub().resolves(),
|
||||||
loadMarketState: sinon.stub().resolves(null),
|
loadMarketState: sinon.stub().resolves(null),
|
||||||
saveMarketState: sinon.stub().resolves(),
|
saveMarketState: sinon.stub().resolves(),
|
||||||
|
loadHubStash: sinon.stub().resolves(null),
|
||||||
|
saveHubStash: sinon.stub().resolves(),
|
||||||
|
loadUnlocks: sinon.stub().resolves([]),
|
||||||
|
saveUnlocks: sinon.stub().resolves(),
|
||||||
};
|
};
|
||||||
gameStateManager.persistence = mockPersistence;
|
gameStateManager.persistence = mockPersistence;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ describe("Core: GameStateManager - Inventory Integration", () => {
|
||||||
loadRun: sinon.stub().resolves(null),
|
loadRun: sinon.stub().resolves(null),
|
||||||
loadRoster: sinon.stub().resolves(null),
|
loadRoster: sinon.stub().resolves(null),
|
||||||
saveRoster: sinon.stub().resolves(),
|
saveRoster: sinon.stub().resolves(),
|
||||||
|
loadHubStash: sinon.stub().resolves(null),
|
||||||
|
saveHubStash: sinon.stub().resolves(),
|
||||||
|
loadUnlocks: sinon.stub().resolves([]),
|
||||||
|
saveUnlocks: sinon.stub().resolves(),
|
||||||
};
|
};
|
||||||
gameStateManager.persistence = mockPersistence;
|
gameStateManager.persistence = mockPersistence;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,16 @@ import { narrativeManager } from "../../src/managers/NarrativeManager.js";
|
||||||
describe("Manager: MissionManager", () => {
|
describe("Manager: MissionManager", () => {
|
||||||
let manager;
|
let manager;
|
||||||
let mockNarrativeManager;
|
let mockNarrativeManager;
|
||||||
|
let mockPersistence;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
manager = new MissionManager();
|
// Create mock persistence
|
||||||
|
mockPersistence = {
|
||||||
|
loadUnlocks: sinon.stub().resolves([]),
|
||||||
|
saveUnlocks: sinon.stub().resolves(),
|
||||||
|
};
|
||||||
|
|
||||||
|
manager = new MissionManager(mockPersistence);
|
||||||
|
|
||||||
// Mock narrativeManager
|
// Mock narrativeManager
|
||||||
mockNarrativeManager = {
|
mockNarrativeManager = {
|
||||||
|
|
@ -530,30 +537,25 @@ describe("Manager: MissionManager", () => {
|
||||||
window.removeEventListener("mission-rewards", rewardSpy);
|
window.removeEventListener("mission-rewards", rewardSpy);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 27: Should unlock classes and store in localStorage", () => {
|
it("CoA 27: Should unlock classes and store in IndexedDB", async () => {
|
||||||
manager.unlockClasses(["CLASS_TINKER", "CLASS_SAPPER"]);
|
await manager.unlockClasses(["CLASS_TINKER", "CLASS_SAPPER"]);
|
||||||
|
|
||||||
const stored = localStorage.getItem("aether_shards_unlocks");
|
expect(mockPersistence.saveUnlocks.calledOnce).to.be.true;
|
||||||
expect(stored).to.exist;
|
const savedUnlocks = mockPersistence.saveUnlocks.firstCall.args[0];
|
||||||
|
expect(savedUnlocks).to.include("CLASS_TINKER");
|
||||||
const unlocks = JSON.parse(stored);
|
expect(savedUnlocks).to.include("CLASS_SAPPER");
|
||||||
expect(unlocks).to.include("CLASS_TINKER");
|
|
||||||
expect(unlocks).to.include("CLASS_SAPPER");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 28: Should merge new unlocks with existing unlocks", () => {
|
it("CoA 28: Should merge new unlocks with existing unlocks", async () => {
|
||||||
localStorage.setItem(
|
// Set up existing unlocks
|
||||||
"aether_shards_unlocks",
|
mockPersistence.loadUnlocks.resolves(["CLASS_VANGUARD"]);
|
||||||
JSON.stringify(["CLASS_VANGUARD"])
|
|
||||||
);
|
|
||||||
|
|
||||||
manager.unlockClasses(["CLASS_TINKER"]);
|
await manager.unlockClasses(["CLASS_TINKER"]);
|
||||||
|
|
||||||
const unlocks = JSON.parse(
|
expect(mockPersistence.saveUnlocks.calledOnce).to.be.true;
|
||||||
localStorage.getItem("aether_shards_unlocks")
|
const savedUnlocks = mockPersistence.saveUnlocks.firstCall.args[0];
|
||||||
);
|
expect(savedUnlocks).to.include("CLASS_VANGUARD");
|
||||||
expect(unlocks).to.include("CLASS_VANGUARD");
|
expect(savedUnlocks).to.include("CLASS_TINKER");
|
||||||
expect(unlocks).to.include("CLASS_TINKER");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("CoA 29: Should distribute faction reputation rewards", () => {
|
it("CoA 29: Should distribute faction reputation rewards", () => {
|
||||||
|
|
|
||||||
718
test/ui/barracks-screen.test.js
Normal file
718
test/ui/barracks-screen.test.js
Normal file
|
|
@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Loading…
Reference in a new issue