Files
BreakEscape/js/systems/npc-conversation-state.js
Z. Cliffe Schreuders cb95a857fd Implement global variable system for NPC conversations
- 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.
2025-11-08 15:44:24 +00:00

345 lines
14 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}