diff --git a/src/core/GameLoop.js b/src/core/GameLoop.js index 3e1428a..aaa99ac 100644 --- a/src/core/GameLoop.js +++ b/src/core/GameLoop.js @@ -25,6 +25,7 @@ import { skillRegistry } from "../managers/SkillRegistry.js"; import { InventoryManager } from "../managers/InventoryManager.js"; import { InventoryContainer } from "../models/InventoryContainer.js"; import { itemRegistry } from "../managers/ItemRegistry.js"; +import { narrativeManager } from "../managers/NarrativeManager.js"; // Import class definitions import vanguardDef from "../assets/data/classes/vanguard.json" with { type: "json" }; @@ -1223,6 +1224,13 @@ export class GameLoop { } } } + + // Restore currentHealth from roster (preserve HP that was paid for) + if (rosterUnit.currentHealth !== undefined && rosterUnit.currentHealth !== null) { + // Ensure currentHealth doesn't exceed maxHealth (in case maxHealth increased) + unit.currentHealth = Math.min(rosterUnit.currentHealth, unit.maxHealth || 100); + console.log(`Restored HP for ${unit.name}: ${unit.currentHealth}/${unit.maxHealth} (from roster)`); + } } } @@ -1256,11 +1264,16 @@ export class GameLoop { } } - // Ensure unit starts with full health - // Explorer constructor might set health to 0 if classDef is missing base_stats - if (unit.currentHealth <= 0) { + // Ensure unit has valid health values + // Only set to full health if currentHealth is invalid (0 or negative) and wasn't restored from roster + // This preserves HP that was paid for in the barracks + if (unit.currentHealth <= 0 && (!unit.rosterId || !this.gameStateManager?.rosterManager?.roster.find(r => r.id === unit.rosterId)?.currentHealth)) { + // Only set to full if we didn't restore from roster (new unit or roster had no saved HP) unit.currentHealth = unit.maxHealth || unit.baseStats?.health || 100; - unit.maxHealth = unit.maxHealth || unit.baseStats?.health || 100; + } + // Ensure maxHealth is set + if (!unit.maxHealth) { + unit.maxHealth = unit.baseStats?.health || 100; } this.grid.placeUnit(unit, targetTile); @@ -2588,13 +2601,49 @@ export class GameLoop { // Stop the game loop this.stop(); - // TODO: Show victory screen UI - // For now, just log and transition back to main menu after a delay - setTimeout(() => { - if (this.gameStateManager) { - this.gameStateManager.transitionTo('STATE_MAIN_MENU'); - } - }, 3000); + // Clear the active run from persistence since mission is complete + if (this.gameStateManager) { + this.gameStateManager.clearActiveRun(); + } + + // Wait for the outro narrative to complete before transitioning + // The outro is played in MissionManager.completeActiveMission() + // We'll listen for the narrative-end event to know when it's done + const hasOutro = this.gameStateManager?.missionManager?.currentMissionDef?.narrative?.outro_success; + + if (hasOutro) { + console.log('GameLoop: Waiting for outro narrative to complete...'); + const handleNarrativeEnd = () => { + console.log('GameLoop: Narrative end event received, transitioning to hub'); + narrativeManager.removeEventListener('narrative-end', handleNarrativeEnd); + + // Small delay after narrative ends to let user see the final message + setTimeout(() => { + if (this.gameStateManager) { + this.gameStateManager.transitionTo('STATE_MAIN_MENU'); + } + }, 500); + }; + + narrativeManager.addEventListener('narrative-end', handleNarrativeEnd); + + // Fallback timeout: if narrative doesn't end within 30 seconds, transition anyway + setTimeout(() => { + console.warn('GameLoop: Narrative end timeout - transitioning to hub anyway'); + narrativeManager.removeEventListener('narrative-end', handleNarrativeEnd); + if (this.gameStateManager) { + this.gameStateManager.transitionTo('STATE_MAIN_MENU'); + } + }, 30000); + } else { + // No outro, transition immediately after a short delay + console.log('GameLoop: No outro narrative, transitioning to hub'); + setTimeout(() => { + if (this.gameStateManager) { + this.gameStateManager.transitionTo('STATE_MAIN_MENU'); + } + }, 1000); + } } /** @@ -2663,6 +2712,11 @@ export class GameLoop { // Stop the game loop this.stop(); + // Clear the active run from persistence since mission is failed + if (this.gameStateManager) { + this.gameStateManager.clearActiveRun(); + } + // TODO: Show failure screen UI // For now, just log and transition back to main menu after a delay setTimeout(() => { diff --git a/src/core/GameStateManager.js b/src/core/GameStateManager.js index 0728368..5f445da 100644 --- a/src/core/GameStateManager.js +++ b/src/core/GameStateManager.js @@ -159,8 +159,15 @@ class GameStateManagerClass { // 4. Load Campaign Progress const savedCampaignData = await this.persistence.loadCampaign(); + console.log("Loaded campaign data:", savedCampaignData); if (savedCampaignData) { this.missionManager.load(savedCampaignData); + console.log( + "Campaign data loaded. Completed missions:", + Array.from(this.missionManager.completedMissions) + ); + } else { + console.log("No saved campaign data found"); } // 5. Set up mission rewards listener @@ -426,8 +433,42 @@ class GameStateManagerClass { * @private */ async _saveCampaign() { - const data = this.missionManager.save(); - await this.persistence.saveCampaign(data); + try { + const data = this.missionManager.save(); + console.log("GameStateManager: Saving campaign data:", data); + console.log( + "GameStateManager: Completed missions count:", + this.missionManager.completedMissions.size + ); + console.log( + "GameStateManager: Completed missions array:", + Array.from(this.missionManager.completedMissions) + ); + if ( + !data || + !data.completedMissions || + data.completedMissions.length === 0 + ) { + console.warn( + "GameStateManager: Warning - no completed missions to save!" + ); + } + await this.persistence.saveCampaign(data); + console.log("GameStateManager: Campaign data saved successfully"); + } catch (error) { + console.error("GameStateManager: Error saving campaign data:", error); + throw error; + } + } + + /** + * Clears the active run from memory and persistence. + * Called when a mission is completed or failed. + * @returns {Promise} + */ + async clearActiveRun() { + this.activeRunData = null; + await this.persistence.clearRun(); } /** @@ -468,8 +509,12 @@ class GameStateManagerClass { * @private */ _setupCampaignDataListener() { - window.addEventListener("campaign-data-changed", () => { - this._saveCampaign(); + window.addEventListener("campaign-data-changed", async (event) => { + console.log( + "GameStateManager: Received campaign-data-changed event:", + event.detail + ); + await this._saveCampaign(); }); } diff --git a/src/core/Persistence.js b/src/core/Persistence.js index e806941..540e243 100644 --- a/src/core/Persistence.js +++ b/src/core/Persistence.js @@ -15,7 +15,7 @@ const MARKET_STORE = "Market"; const CAMPAIGN_STORE = "Campaign"; const HUB_STASH_STORE = "HubStash"; const UNLOCKS_STORE = "Unlocks"; -const VERSION = 5; // Bumped version to add HubStash and Unlocks stores +const VERSION = 6; // Bumped version to add Campaign store /** * Handles game data persistence using IndexedDB. @@ -162,7 +162,14 @@ export class Persistence { */ async saveCampaign(campaignData) { if (!this.db) await this.init(); - return this._put(CAMPAIGN_STORE, { id: "campaign_data", data: campaignData }); + console.log("Persistence: Saving campaign data to Campaign store:", campaignData); + try { + await this._put(CAMPAIGN_STORE, { id: "campaign_data", data: campaignData }); + console.log("Persistence: Campaign data saved successfully"); + } catch (error) { + console.error("Persistence: Error saving campaign data:", error); + throw error; + } } /** @@ -171,8 +178,15 @@ export class Persistence { */ async loadCampaign() { if (!this.db) await this.init(); - const result = await this._get(CAMPAIGN_STORE, "campaign_data"); - return result ? result.data : null; + console.log("Persistence: Loading campaign data from Campaign store"); + try { + const result = await this._get(CAMPAIGN_STORE, "campaign_data"); + console.log("Persistence: Loaded campaign data result:", result); + return result ? result.data : null; + } catch (error) { + console.error("Persistence: Error loading campaign data:", error); + return null; + } } // --- HUB STASH DATA --- diff --git a/src/managers/MissionManager.js b/src/managers/MissionManager.js index b923af9..6848333 100644 --- a/src/managers/MissionManager.js +++ b/src/managers/MissionManager.js @@ -419,19 +419,24 @@ export class MissionManager { // Mark mission as completed this.completedMissions.add(this.activeMissionId); + console.log("MissionManager: Mission completed. Active mission ID:", this.activeMissionId); + console.log("MissionManager: Completed missions now:", Array.from(this.completedMissions)); + + // Dispatch event to save campaign data IMMEDIATELY (before outro) + // This ensures the save happens even if the outro doesn't complete + console.log("MissionManager: Dispatching campaign-data-changed event"); + window.dispatchEvent(new CustomEvent('campaign-data-changed', { + detail: { missionCompleted: this.activeMissionId } + })); + console.log("MissionManager: campaign-data-changed event dispatched"); // Distribute rewards this.distributeRewards(); - // Play outro narrative if available + // Play outro narrative if available (after saving) if (this.currentMissionDef.narrative?.outro_success) { await this.playOutro(this.currentMissionDef.narrative.outro_success); } - - // Dispatch event to save campaign data - window.dispatchEvent(new CustomEvent('campaign-data-changed', { - detail: { missionCompleted: this.activeMissionId } - })); } /** diff --git a/src/managers/NarrativeManager.js b/src/managers/NarrativeManager.js index 7601599..c5c3aac 100644 --- a/src/managers/NarrativeManager.js +++ b/src/managers/NarrativeManager.js @@ -58,6 +58,15 @@ export class NarrativeManager extends EventTarget { } const nextId = this.currentNode.next; + + // If the next node is "END", we still need to wait for user to click again + // to actually close the dialogue. This gives them time to read the last message. + if (!nextId || nextId === "END") { + // User clicked on the last message - now close it + this.endSequence(); + return; + } + this._advanceToNode(nextId); } @@ -128,6 +137,11 @@ export class NarrativeManager extends EventTarget { endSequence() { console.log("NarrativeManager: Sequence Ended"); + // Only end if we actually have a sequence active + if (!this.currentSequence) { + console.warn("NarrativeManager: endSequence called but no active sequence"); + return; + } this.currentSequence = null; this.currentNode = null; this.dispatchEvent(new CustomEvent("narrative-end")); diff --git a/src/ui/components/mission-board.js b/src/ui/components/mission-board.js index 679e0b2..0985648 100644 --- a/src/ui/components/mission-board.js +++ b/src/ui/components/mission-board.js @@ -208,6 +208,24 @@ export class MissionBoard extends LitElement { connectedCallback() { super.connectedCallback(); this._loadMissions(); + + // Listen for campaign data changes to refresh completed missions + this._boundHandleCampaignChange = this._handleCampaignChange.bind(this); + window.addEventListener('campaign-data-changed', this._boundHandleCampaignChange); + window.addEventListener('gamestate-changed', this._boundHandleCampaignChange); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._boundHandleCampaignChange) { + window.removeEventListener('campaign-data-changed', this._boundHandleCampaignChange); + window.removeEventListener('gamestate-changed', this._boundHandleCampaignChange); + } + } + + _handleCampaignChange() { + // Reload missions when campaign data changes (mission completed) + this._loadMissions(); } _loadMissions() { diff --git a/src/ui/dialogue-overlay.js b/src/ui/dialogue-overlay.js index e4b93e9..7e8a69f 100644 --- a/src/ui/dialogue-overlay.js +++ b/src/ui/dialogue-overlay.js @@ -127,6 +127,7 @@ export class DialogueOverlay extends LitElement { super(); this.activeNode = null; this.isVisible = false; + this._isProcessingClick = false; // Prevent rapid clicks } connectedCallback() { @@ -150,6 +151,9 @@ export class DialogueOverlay extends LitElement { _onUpdate(e) { this.activeNode = e.detail.node; this.isVisible = e.detail.active; + // Reset processing flag when a new node is shown + // This ensures each message can be clicked independently + this._isProcessingClick = false; } _onEnd() { @@ -160,9 +164,28 @@ export class DialogueOverlay extends LitElement { _handleInput(e) { if (!this.isVisible) return; if (e.code === "Space" || e.code === "Enter") { + // Prevent rapid key presses + if (this._isProcessingClick) { + return; + } + // Only advance if no choices if (!this.activeNode.choices) { - narrativeManager.next(); + this._isProcessingClick = true; + + // Check if this is the last message + const isLastMessage = !this.activeNode.next || this.activeNode.next === "END"; + + if (isLastMessage) { + narrativeManager.endSequence(); + } else { + narrativeManager.next(); + } + + // Reset flag after a short delay + setTimeout(() => { + this._isProcessingClick = false; + }, 300); } } } @@ -175,7 +198,40 @@ export class DialogueOverlay extends LitElement { class="dialogue-box ${this.activeNode.type === "TUTORIAL" ? "type-tutorial" : ""}" - @click="${() => !this.activeNode.choices && narrativeManager.next()}" + @click="${(e) => { + // Prevent event bubbling and default behavior + e.stopPropagation(); + e.preventDefault(); + + // Prevent rapid clicks + if (this._isProcessingClick) { + return; + } + + // Only advance on click if there are no choices + // Check if we're clicking on the content area (not buttons or other interactive elements) + const isContentClick = e.target.closest('.content') && !e.target.closest('button'); + + if (!this.activeNode.choices && isContentClick) { + this._isProcessingClick = true; + + // Check if this is the last message (next === "END") + const isLastMessage = !this.activeNode.next || this.activeNode.next === "END"; + + if (isLastMessage) { + // For the last message, close the dialogue + narrativeManager.endSequence(); + } else { + // For other messages, advance normally + narrativeManager.next(); + } + + // Reset flag after a short delay to prevent double-clicks + setTimeout(() => { + this._isProcessingClick = false; + }, 300); + } + }}" > ${this.activeNode.portrait ? html` @@ -210,7 +266,9 @@ export class DialogueOverlay extends LitElement { ` : html`
- Press SPACE to continue... + ${!this.activeNode.next || this.activeNode.next === "END" + ? "Press SPACE or click to close" + : "Press SPACE to continue..."}
`} diff --git a/src/ui/team-builder.js b/src/ui/team-builder.js index 85cee44..3f9fa02 100644 --- a/src/ui/team-builder.js +++ b/src/ui/team-builder.js @@ -335,7 +335,12 @@ export class TeamBuilder extends LitElement {
${item.name}
- ${this.mode === 'ROSTER' ? `Lvl ${item.level || 1} ${item.classId.replace('CLASS_', '')}` : item.role} + ${this.mode === 'ROSTER' ? (() => { + // Calculate level from classMastery + const activeClassId = item.activeClassId || item.classId; + const level = item.classMastery?.[activeClassId]?.level || 1; + return `Lvl ${level} ${item.classId.replace('CLASS_', '')}`; + })() : item.role}
`; diff --git a/test/core/Persistence.test.js b/test/core/Persistence.test.js index 61d8cd3..2445534 100644 --- a/test/core/Persistence.test.js +++ b/test/core/Persistence.test.js @@ -80,7 +80,7 @@ describe("Core: Persistence", () => { await initPromise; - expect(globalObj.indexedDB.open.calledWith("AetherShardsDB", 4)).to.be.true; + expect(globalObj.indexedDB.open.calledWith("AetherShardsDB", 6)).to.be.true; expect(mockDB.createObjectStore.calledWith("Runs", { keyPath: "id" })).to.be .true; expect(mockDB.createObjectStore.calledWith("Roster", { keyPath: "id" })).to