mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-20 13:50:46 +00:00
- Introduced a data-driven global variable system to manage narrative state across NPC interactions. - Added support for global variables in scenario JSON, allowing for easy extension and management. - Implemented synchronization of global variables between Ink stories and the game state, ensuring real-time updates across conversations. - Enhanced state persistence, allowing global variables to survive page reloads and be restored during conversations. - Created comprehensive documentation and testing guides to facilitate usage and verification of the new system.
345 lines
14 KiB
JavaScript
345 lines
14 KiB
JavaScript
/**
|
||
* NPC Conversation State Manager
|
||
*
|
||
* Persists NPC conversation state (Ink variables, choices, progress) across multiple conversations.
|
||
* Stores serialized story state so NPCs remember their relationships and story progression.
|
||
*
|
||
* @module npc-conversation-state
|
||
*/
|
||
|
||
class NPCConversationStateManager {
|
||
constructor() {
|
||
this.conversationStates = new Map(); // { npcId: { storyState, variables, ... } }
|
||
console.log('🗂️ NPC Conversation State Manager initialized');
|
||
}
|
||
|
||
/**
|
||
* Save the current state of an NPC's conversation
|
||
*
|
||
* Important: When story has ended, we save ONLY the variables (not the story state/progress).
|
||
* This preserves character relationships and earned rewards while allowing the story to restart fresh.
|
||
*
|
||
* @param {string} npcId - NPC identifier
|
||
* @param {Object} story - The Ink story object
|
||
* @param {boolean} forceFullState - If true, save full state even if story has ended (for in-progress saves)
|
||
*/
|
||
saveNPCState(npcId, story, forceFullState = false) {
|
||
if (!npcId || !story) return;
|
||
|
||
try {
|
||
const state = {
|
||
timestamp: Date.now(),
|
||
hasEnded: story.state.hasEnded
|
||
};
|
||
|
||
// Always save the variables (favour, items earned, flags, etc.)
|
||
// These persist across conversations even when story ends
|
||
if (story.variablesState) {
|
||
// Filter out has_* variables (derived from itemsHeld, will be re-synced on load)
|
||
const filteredVariables = {};
|
||
for (const [key, value] of Object.entries(story.variablesState)) {
|
||
// Skip dynamically-synced item inventory variables
|
||
if (!key.startsWith('has_lockpick') &&
|
||
!key.startsWith('has_workstation') &&
|
||
!key.startsWith('has_phone') &&
|
||
!key.startsWith('has_keycard')) {
|
||
filteredVariables[key] = value;
|
||
}
|
||
}
|
||
state.variables = filteredVariables;
|
||
console.log(`💾 Saved variables for ${npcId}:`, state.variables);
|
||
}
|
||
|
||
// Save global variables snapshot for restoration
|
||
state.globalVariablesSnapshot = { ...window.gameState.globalVariables };
|
||
console.log(`💾 Saved global variables snapshot:`, state.globalVariablesSnapshot);
|
||
|
||
// Only save full story state if story is still active OR if explicitly forced
|
||
if (!story.state.hasEnded || forceFullState) {
|
||
try {
|
||
state.storyState = story.state.ToJson();
|
||
console.log(`💾 Saved full story state for ${npcId} (active story)`);
|
||
} catch (serializeError) {
|
||
// If serialization fails (due to dynamic variables), just save variables
|
||
console.warn(`⚠️ Could not serialize full story state for ${npcId}, saving variables only:`, serializeError.message);
|
||
}
|
||
} else {
|
||
console.log(`💾 Saved variables only for ${npcId} (story ended - will restart fresh)`);
|
||
}
|
||
|
||
this.conversationStates.set(npcId, state);
|
||
console.log(`✅ NPC state persisted for: ${npcId}`, {
|
||
timestamp: new Date(state.timestamp).toLocaleTimeString(),
|
||
hasEnded: state.hasEnded,
|
||
hasVariables: !!state.variables,
|
||
hasStoryState: !!state.storyState
|
||
});
|
||
} catch (error) {
|
||
console.error(`❌ Error saving NPC state for ${npcId}:`, error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Restore the state of an NPC's conversation
|
||
*
|
||
* Strategy:
|
||
* - If full story state exists (story was mid-conversation): restore it completely
|
||
* - If only variables exist (story had ended): load variables but let story start fresh
|
||
*
|
||
* @param {string} npcId - NPC identifier
|
||
* @param {Object} story - The Ink story object to restore into
|
||
* @returns {boolean} True if state was restored
|
||
*/
|
||
restoreNPCState(npcId, story) {
|
||
if (!npcId || !story) return false;
|
||
|
||
const state = this.conversationStates.get(npcId);
|
||
if (!state) {
|
||
console.log(`ℹ️ No saved state for NPC: ${npcId} (first conversation)`);
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
// Restore global variables first (before story state/variables)
|
||
if (state.globalVariablesSnapshot) {
|
||
window.gameState.globalVariables = { ...state.globalVariablesSnapshot };
|
||
console.log(`✅ Restored global variables:`, state.globalVariablesSnapshot);
|
||
}
|
||
|
||
// If we have saved story state, restore it completely (mid-conversation state)
|
||
if (state.storyState) {
|
||
story.state.LoadJson(state.storyState);
|
||
console.log(`✅ Restored full story state for NPC: ${npcId}`, {
|
||
savedAt: new Date(state.timestamp).toLocaleTimeString(),
|
||
reason: 'In-progress conversation'
|
||
});
|
||
return true;
|
||
}
|
||
|
||
// If we only have variables (story ended), restore just the variables
|
||
if (state.variables) {
|
||
// Load variables into the story
|
||
for (const [key, value] of Object.entries(state.variables)) {
|
||
story.variablesState[key] = value;
|
||
}
|
||
console.log(`✅ Restored variables for NPC: ${npcId}`, {
|
||
savedAt: new Date(state.timestamp).toLocaleTimeString(),
|
||
reason: 'Story ended - restarting fresh with saved variables',
|
||
variables: state.variables
|
||
});
|
||
return true;
|
||
}
|
||
|
||
console.log(`ℹ️ No saveable data for NPC: ${npcId}`);
|
||
return false;
|
||
} catch (error) {
|
||
console.error(`❌ Error restoring NPC state for ${npcId}:`, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get the current state for an NPC (for debugging)
|
||
* @param {string} npcId - NPC identifier
|
||
* @returns {Object|null} Conversation state or null if not found
|
||
*/
|
||
getNPCState(npcId) {
|
||
return this.conversationStates.get(npcId) || null;
|
||
}
|
||
|
||
/**
|
||
* Clear the state for an NPC (useful for resetting conversations)
|
||
* @param {string} npcId - NPC identifier
|
||
*/
|
||
clearNPCState(npcId) {
|
||
if (this.conversationStates.has(npcId)) {
|
||
this.conversationStates.delete(npcId);
|
||
console.log(`🗑️ Cleared conversation state for NPC: ${npcId}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear all NPC states (useful for scenario reset)
|
||
*/
|
||
clearAllStates() {
|
||
const count = this.conversationStates.size;
|
||
this.conversationStates.clear();
|
||
console.log(`🗑️ Cleared all NPC conversation states (${count} NPCs)`);
|
||
}
|
||
|
||
/**
|
||
* Get list of NPCs with saved state
|
||
* @returns {Array<string>} Array of NPC IDs with persistent state
|
||
*/
|
||
getSavedNPCs() {
|
||
return Array.from(this.conversationStates.keys());
|
||
}
|
||
|
||
// ============================================================
|
||
// GLOBAL VARIABLE MANAGEMENT (for cross-NPC narrative state)
|
||
// ============================================================
|
||
|
||
/**
|
||
* Get list of global variable names from scenario
|
||
* @returns {Array<string>} Names of global variables
|
||
*/
|
||
getGlobalVariableNames() {
|
||
const scenarioGlobals = window.gameScenario?.globalVariables || {};
|
||
return Object.keys(scenarioGlobals);
|
||
}
|
||
|
||
/**
|
||
* Check if a variable is global (either declared in scenario or uses global_ prefix)
|
||
* @param {string} name - Variable name
|
||
* @returns {boolean} True if variable is global
|
||
*/
|
||
isGlobalVariable(name) {
|
||
// Check scenario declaration
|
||
if (window.gameState?.globalVariables?.hasOwnProperty(name)) {
|
||
return true;
|
||
}
|
||
// Check naming convention
|
||
if (name.startsWith('global_')) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Auto-discover global_* variables from story and add to global store
|
||
* @param {Object} story - Ink story object
|
||
*/
|
||
discoverGlobalVariables(story) {
|
||
if (!story?.variablesState?._defaultGlobalVariables) return;
|
||
|
||
const declaredVars = Array.from(story.variablesState._defaultGlobalVariables.keys());
|
||
const globalVars = declaredVars.filter(name => name.startsWith('global_'));
|
||
|
||
// Add to window.gameState.globalVariables if not already present
|
||
globalVars.forEach(name => {
|
||
if (!window.gameState.globalVariables.hasOwnProperty(name)) {
|
||
const value = story.variablesState[name];
|
||
window.gameState.globalVariables[name] = value;
|
||
console.log(`🔍 Auto-discovered global variable: ${name} = ${value}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Sync global variables from window.gameState to Ink story
|
||
* @param {Object} story - Ink story object
|
||
*/
|
||
syncGlobalVariablesToStory(story) {
|
||
if (!story || !window.gameState?.globalVariables) return;
|
||
|
||
// Sync all global variables to this story
|
||
Object.entries(window.gameState.globalVariables).forEach(([name, value]) => {
|
||
// Only sync if variable exists in this story
|
||
if (story.variablesState.GlobalVariableExistsWithName(name)) {
|
||
try {
|
||
story.variablesState[name] = value;
|
||
console.log(`✅ Synced ${name} = ${value} to story`);
|
||
} catch (err) {
|
||
console.warn(`⚠️ Could not sync ${name}:`, err.message);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Sync global variables from Ink story back to window.gameState
|
||
* @param {Object} story - Ink story object
|
||
* @returns {Array} Array of changed variables
|
||
*/
|
||
syncGlobalVariablesFromStory(story) {
|
||
if (!story || !window.gameState?.globalVariables) return [];
|
||
|
||
const changed = [];
|
||
Object.keys(window.gameState.globalVariables).forEach(name => {
|
||
if (story.variablesState.GlobalVariableExistsWithName(name)) {
|
||
// Use the indexer which automatically unwraps Ink's Value objects
|
||
// According to Ink source: this[variableName] returns (varContents as Runtime.Value).valueObject
|
||
const newValue = story.variablesState[name];
|
||
|
||
// Compare and update if changed
|
||
const oldValue = window.gameState.globalVariables[name];
|
||
if (oldValue !== newValue) {
|
||
window.gameState.globalVariables[name] = newValue;
|
||
changed.push({ name, value: newValue });
|
||
console.log(`🔄 Global variable ${name} changed from ${oldValue} to ${newValue}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
return changed;
|
||
}
|
||
|
||
/**
|
||
* Observe changes to global variables in Ink and sync back to window.gameState
|
||
* @param {Object} story - Ink story object
|
||
* @param {string} npcId - NPC ID for logging
|
||
*/
|
||
observeGlobalVariableChanges(story, npcId) {
|
||
if (!story?.variablesState) return;
|
||
|
||
// Use Ink's built-in variable change observer
|
||
story.variablesState.variableChangedEvent = (variableName, newValue) => {
|
||
// Check if this is a global variable
|
||
if (this.isGlobalVariable(variableName)) {
|
||
console.log(`🌐 Global variable changed: ${variableName} = ${newValue} (from ${npcId})`);
|
||
|
||
// Update window.gameState
|
||
const unwrappedValue = newValue?.valueObject ?? newValue;
|
||
window.gameState.globalVariables[variableName] = unwrappedValue;
|
||
|
||
// Broadcast to other loaded stories
|
||
this.broadcastGlobalVariableChange(variableName, unwrappedValue, npcId);
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Broadcast a global variable change to all other loaded Ink stories
|
||
* @param {string} variableName - Variable name
|
||
* @param {*} value - New value
|
||
* @param {string} sourceNpcId - NPC ID that triggered the change (to avoid feedback loop)
|
||
*/
|
||
broadcastGlobalVariableChange(variableName, value, sourceNpcId) {
|
||
if (!window.npcManager?.inkEngineCache) return;
|
||
|
||
// Sync to all loaded stories except the source
|
||
window.npcManager.inkEngineCache.forEach((inkEngine, npcId) => {
|
||
if (npcId !== sourceNpcId && inkEngine?.story) {
|
||
const story = inkEngine.story;
|
||
if (story.variablesState.GlobalVariableExistsWithName(variableName)) {
|
||
try {
|
||
// Temporarily disable event to prevent loops
|
||
const oldHandler = story.variablesState.variableChangedEvent;
|
||
story.variablesState.variableChangedEvent = null;
|
||
|
||
story.variablesState[variableName] = value;
|
||
|
||
// Re-enable event
|
||
story.variablesState.variableChangedEvent = oldHandler;
|
||
|
||
console.log(`📡 Broadcasted ${variableName} = ${value} to ${npcId}`);
|
||
} catch (err) {
|
||
console.warn(`⚠️ Could not broadcast to ${npcId}:`, err.message);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Create global instance
|
||
const npcConversationStateManager = new NPCConversationStateManager();
|
||
|
||
// Export for use in modules
|
||
export default npcConversationStateManager;
|
||
|
||
// Also attach to window for global access
|
||
if (typeof window !== 'undefined') {
|
||
window.npcConversationStateManager = npcConversationStateManager;
|
||
}
|