mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-21 11:18:08 +00:00
Implement cross-scenario persistent state system
Implemented a comprehensive system for carrying variable state between game
scenarios, enabling:
- NPC trust levels to persist across different missions
- Discussion topics tracking to prevent repetitive conversations
- Narrative decisions with consequences in later scenarios
- Flexible scenario ordering (play missions in any order)
## Core Implementation
### New Module: PersistentStateManager
- Created js/systems/persistent-state-manager.js
- Handles loading, merging, and exporting of carry-over variables
- Supports both simple and metadata format for variable definitions
- Deep clones values to prevent reference issues
- Type validation when loading persistent state
- Structured error codes for debugging (PSM_001-005)
### Scenario Integration
- Modified js/core/game.js to load persistent state via URL parameter
- Persistent state loaded from ?persistentState=filename parameter
- Automatic merging with scenario defaults during initialization
- Visual error notification if persistent state file fails to load
- Detailed console logging for debugging merge operations
### Console Commands (js/main.js)
- window.exportPersistentState() - Export current state as JSON
- window.downloadPersistentState(filename) - Download state as file
- window.viewPersistentState() - View formatted persistent state
### Export Triggers
- Added #export_persistent_state Ink tag handler
- Implemented in chat-helpers.js (shared by person-chat and phone-chat)
- Auto-exports state when triggered from Ink conversations
- Delayed export to ensure all variable syncs complete
### Example Files
- Created persistent-states/ directory with README
- Added example-continued-game.json showing state format
- Comprehensive documentation on usage and file format
### Scenario Updates
- Updated scenarios/ceo_exfil.json with carry-over variables
- Added scenario_id field for tracking
- Defined 6 carry-over variables (trust, topics, narrative flags)
- Included 1 session-only variable for comparison
## Variable Format
Metadata format (carry-over):
{
"npc_trust_helper": {
"default": 50,
"carryOver": true,
"type": "number",
"description": "Trust level with helpful contact"
}
}
Simple format (session-only):
{
"temp_variable": "value"
}
## Persistent State File Format
{
"version": 1,
"timestamp": "ISO-8601-timestamp",
"lastScenario": "scenario_id",
"variables": { /* carry-over variables only */ },
"metadata": { "exportedCount": N, "sessionOnlyCount": M }
}
## Usage
Load with persistent state:
index.html?scenario=ceo_exfil&persistentState=my-progress
Export from console:
window.downloadPersistentState('my-progress.json')
Trigger from Ink:
#export_persistent_state
## Testing
Tested loading, merging, exporting, and error handling.
Verified backward compatibility with existing scenarios.
## Future Enhancements
- Server-side persistence (postToServer method ready)
- State versioning and migrations
- Advanced validation (min/max ranges)
- Debug UI panel for editing state
Addresses requirements from planning_notes/state_save/implementation_plan.md
and incorporates improvements from planning_notes/state_save/review1.md
This commit is contained in:
@@ -6,6 +6,7 @@ import { checkObjectInteractions, setGameInstance } from '../systems/interaction
|
||||
import { introduceScenario } from '../utils/helpers.js?v=19';
|
||||
import '../minigames/index.js?v=2';
|
||||
import SoundManager from '../systems/sound-manager.js?v=1';
|
||||
import { PersistentStateManager } from '../systems/persistent-state-manager.js';
|
||||
|
||||
// Global variables that will be set by main.js
|
||||
let gameScenario;
|
||||
@@ -424,9 +425,32 @@ export function preload() {
|
||||
|
||||
// Add cache buster query parameter to prevent browser caching
|
||||
scenarioFile = `${scenarioFile}${scenarioFile.includes('?') ? '&' : '?'}v=${Date.now()}`;
|
||||
|
||||
|
||||
// Load the specified scenario
|
||||
this.load.json('gameScenarioJSON', scenarioFile);
|
||||
|
||||
// Load persistent state if provided via URL parameter
|
||||
let persistentStateFile = urlParams.get('persistentState');
|
||||
if (persistentStateFile) {
|
||||
console.log('🔄 Persistent state parameter detected:', persistentStateFile);
|
||||
|
||||
// Ensure proper path prefix
|
||||
if (!persistentStateFile.startsWith('persistent-states/')) {
|
||||
persistentStateFile = `persistent-states/${persistentStateFile}`;
|
||||
}
|
||||
|
||||
// Ensure .json extension
|
||||
if (!persistentStateFile.endsWith('.json')) {
|
||||
persistentStateFile = `${persistentStateFile}.json`;
|
||||
}
|
||||
|
||||
// Add cache buster to prevent browser caching
|
||||
persistentStateFile = `${persistentStateFile}?v=${Date.now()}`;
|
||||
|
||||
// Load the persistent state file
|
||||
this.load.json('persistentStateJSON', persistentStateFile);
|
||||
console.log('🔄 Loading persistent state from:', persistentStateFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -458,12 +482,77 @@ export async function create() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize global narrative variables from scenario
|
||||
// Initialize persistent state manager
|
||||
window.persistentStateManager = new PersistentStateManager();
|
||||
|
||||
// Load persistent state from cache if it was loaded
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const persistentStateJSON = this.cache.json.get('persistentStateJSON');
|
||||
|
||||
if (urlParams.has('persistentState')) {
|
||||
if (!persistentStateJSON) {
|
||||
const errorMsg = '⚠️ Persistent state file failed to load';
|
||||
console.error(`❌ [PSM_004] ${errorMsg}`);
|
||||
console.error(' File may be missing, malformed, or network error occurred');
|
||||
console.error(' Proceeding with scenario defaults');
|
||||
|
||||
// Show in-game notification
|
||||
const notification = this.add.text(
|
||||
this.cameras.main.width / 2,
|
||||
20,
|
||||
errorMsg,
|
||||
{
|
||||
fontSize: '18px',
|
||||
backgroundColor: '#cc0000',
|
||||
padding: { x: 15, y: 8 },
|
||||
color: '#ffffff'
|
||||
}
|
||||
).setOrigin(0.5, 0).setDepth(10000).setScrollFactor(0);
|
||||
|
||||
this.time.delayedCall(5000, () => notification.destroy());
|
||||
} else {
|
||||
try {
|
||||
// Validate structure
|
||||
if (!persistentStateJSON.version || !persistentStateJSON.variables) {
|
||||
throw new Error('Invalid persistent state structure - missing version or variables');
|
||||
}
|
||||
|
||||
console.log('✅ Persistent state loaded successfully');
|
||||
console.log(` Version: ${persistentStateJSON.version}`);
|
||||
console.log(` Last Scenario: ${persistentStateJSON.lastScenario}`);
|
||||
console.log(` Timestamp: ${persistentStateJSON.timestamp}`);
|
||||
console.log(` Variables: ${Object.keys(persistentStateJSON.variables).length}`);
|
||||
|
||||
window.persistentStateManager.loadedState = persistentStateJSON;
|
||||
} catch (error) {
|
||||
console.error('❌ [PSM_001] Invalid persistent state JSON:', error.message);
|
||||
console.error(' Proceeding with scenario defaults');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize global narrative variables from scenario (with persistent state merging)
|
||||
if (gameScenario.globalVariables) {
|
||||
window.gameState.globalVariables = { ...gameScenario.globalVariables };
|
||||
console.log('🌐 Initialized global variables:', window.gameState.globalVariables);
|
||||
// Extract carry-over metadata from scenario
|
||||
window.persistentStateManager.extractCarryOverMetadata(gameScenario.globalVariables);
|
||||
|
||||
// Merge persistent state with scenario defaults
|
||||
const mergedVariables = window.persistentStateManager.mergeWithScenarioDefaults(
|
||||
persistentStateJSON?.variables,
|
||||
gameScenario.globalVariables
|
||||
);
|
||||
|
||||
// Initialize global variables with merged values
|
||||
window.gameState.globalVariables = mergedVariables;
|
||||
console.log('🌐 Initialized global variables (with persistent state):', window.gameState.globalVariables);
|
||||
|
||||
// Show diff if persistent state was loaded
|
||||
if (persistentStateJSON?.variables && Object.keys(persistentStateJSON.variables).length > 0) {
|
||||
window.persistentStateManager.logStateDiff(mergedVariables);
|
||||
}
|
||||
} else {
|
||||
window.gameState.globalVariables = {};
|
||||
console.log('🌐 No global variables defined in scenario');
|
||||
}
|
||||
|
||||
// Normalize keyPins in all rooms and objects from 0-100 scale to 25-65 scale
|
||||
|
||||
42
js/main.js
42
js/main.js
@@ -277,7 +277,7 @@ function initializeGame() {
|
||||
console.log(' Enabled:', window.npcManager?.losVisualizationEnabled ?? 'N/A');
|
||||
console.log(' NPCs loaded:', window.npcManager?.npcs?.size ?? 0);
|
||||
console.log(' Graphics objects:', window.npcManager?.losVisualizations?.size ?? 0);
|
||||
|
||||
|
||||
if (window.npcManager?.npcs?.size > 0) {
|
||||
for (const npc of window.npcManager.npcs.values()) {
|
||||
console.log(` NPC: "${npc.id}"`);
|
||||
@@ -287,7 +287,45 @@ function initializeGame() {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Persistent State Console Commands
|
||||
// Export current persistent state as JSON object
|
||||
window.exportPersistentState = function() {
|
||||
if (!window.persistentStateManager) {
|
||||
console.error('❌ Persistent state manager not initialized');
|
||||
console.error(' Manager is initialized during game creation');
|
||||
return null;
|
||||
}
|
||||
const scenarioId = window.gameScenario?.scenario_id || 'game';
|
||||
return window.persistentStateManager.exportPersistentState(scenarioId);
|
||||
};
|
||||
|
||||
// Download persistent state as JSON file
|
||||
window.downloadPersistentState = function(filename) {
|
||||
if (!window.persistentStateManager) {
|
||||
console.error('❌ Persistent state manager not initialized');
|
||||
console.error(' Manager is initialized during game creation');
|
||||
return;
|
||||
}
|
||||
window.persistentStateManager.downloadAsJSON(filename);
|
||||
};
|
||||
|
||||
// View current persistent state (formatted console output)
|
||||
window.viewPersistentState = function() {
|
||||
if (!window.persistentStateManager) {
|
||||
console.error('❌ Persistent state manager not initialized');
|
||||
console.error(' Manager is initialized during game creation');
|
||||
return;
|
||||
}
|
||||
window.persistentStateManager.viewPersistentState();
|
||||
};
|
||||
|
||||
// Log available persistent state commands
|
||||
console.log('💾 Persistent State Commands Available:');
|
||||
console.log(' window.exportPersistentState() - Export state as JSON object');
|
||||
console.log(' window.downloadPersistentState(filename) - Download state as file');
|
||||
console.log(' window.viewPersistentState() - View current persistent state');
|
||||
|
||||
// Initial setup
|
||||
setTimeout(setupPixelArt, 100);
|
||||
}
|
||||
|
||||
@@ -208,7 +208,38 @@ export function processGameActionTags(tags, ui) {
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
case 'export_persistent_state':
|
||||
console.log('📤 Export persistent state triggered from Ink');
|
||||
|
||||
// Add small delay to ensure all variable syncs have completed
|
||||
setTimeout(() => {
|
||||
if (!window.persistentStateManager) {
|
||||
result.message = '❌ Persistent state manager not initialized';
|
||||
console.error(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const scenarioId = window.gameScenario?.scenario_id || 'game';
|
||||
const exported = window.persistentStateManager.exportPersistentState(scenarioId);
|
||||
|
||||
result.success = true;
|
||||
result.message = '📤 Persistent state exported to console';
|
||||
console.log('✅ Persistent state exported:', exported);
|
||||
console.log('💡 Use window.downloadPersistentState() to save as file');
|
||||
|
||||
if (ui) ui.showNotification(result.message, 'success');
|
||||
|
||||
// Future: POST to server
|
||||
// if (window.persistentStateManager.serverEndpoint) {
|
||||
// await window.persistentStateManager.postToServer('/api/save-state', authToken);
|
||||
// }
|
||||
}, 100);
|
||||
|
||||
result.success = true;
|
||||
result.message = 'Exporting persistent state...';
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown tag, log but don't fail
|
||||
console.log(`ℹ️ Unknown game action tag: ${action}`);
|
||||
|
||||
@@ -310,6 +310,10 @@ export default class PersonChatConversation {
|
||||
this.handlePersonalSpace(params[0]);
|
||||
break;
|
||||
|
||||
case 'export_persistent_state':
|
||||
this.handleExportPersistentState();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`⚠️ Unknown tag: ${action}`);
|
||||
}
|
||||
@@ -456,6 +460,36 @@ export default class PersonChatConversation {
|
||||
console.log(`↔️ Set NPC ${this.npcId} personal space: ${distance}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle export_persistent_state tag - export current persistent state
|
||||
* Tag: #export_persistent_state
|
||||
*
|
||||
* Exports the current carry-over variables to console and optionally to server.
|
||||
* Use this at the end of a scenario when the player achieves victory.
|
||||
*/
|
||||
handleExportPersistentState() {
|
||||
console.log('📤 Export persistent state triggered from Ink');
|
||||
|
||||
// Add small delay to ensure all variable syncs have completed
|
||||
setTimeout(() => {
|
||||
if (!window.persistentStateManager) {
|
||||
console.error('❌ Persistent state manager not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
const scenarioId = window.gameScenario?.scenario_id || 'game';
|
||||
const exported = window.persistentStateManager.exportPersistentState(scenarioId);
|
||||
|
||||
console.log('✅ Persistent state exported:', exported);
|
||||
console.log('💡 Use window.downloadPersistentState() to save as file');
|
||||
|
||||
// Future: POST to server
|
||||
// if (window.persistentStateManager.serverEndpoint) {
|
||||
// await window.persistentStateManager.postToServer('/api/save-state', authToken);
|
||||
// }
|
||||
}, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if conversation can continue
|
||||
* @returns {boolean} True if more dialogue/choices available
|
||||
|
||||
384
js/systems/persistent-state-manager.js
Normal file
384
js/systems/persistent-state-manager.js
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Persistent State Manager
|
||||
*
|
||||
* Manages cross-scenario variable persistence, allowing narrative state
|
||||
* to carry over between different game scenarios.
|
||||
*
|
||||
* This is NOT a "save game" feature - it's a cross-scenario narrative
|
||||
* persistence layer that tracks things like:
|
||||
* - NPC trust levels
|
||||
* - Discussion topics covered
|
||||
* - Narrative decisions made
|
||||
*
|
||||
* @module persistent-state-manager
|
||||
*/
|
||||
|
||||
// Error codes for structured logging
|
||||
const PersistentStateErrors = {
|
||||
INVALID_JSON: 'PSM_001',
|
||||
TYPE_MISMATCH: 'PSM_002',
|
||||
MISSING_METADATA: 'PSM_003',
|
||||
LOAD_FAILED: 'PSM_004',
|
||||
INVALID_STRUCTURE: 'PSM_005'
|
||||
};
|
||||
|
||||
/**
|
||||
* Persistent State Manager Class
|
||||
*
|
||||
* @class
|
||||
* @example
|
||||
* const manager = new PersistentStateManager();
|
||||
* manager.extractCarryOverMetadata(scenario.globalVariables);
|
||||
* const merged = manager.mergeWithScenarioDefaults(persistentState, scenario.globalVariables);
|
||||
*/
|
||||
export class PersistentStateManager {
|
||||
constructor() {
|
||||
this.version = 1;
|
||||
this.loadedState = null;
|
||||
this.carryOverMetadata = new Map(); // varName → { default, type, description, carryOver }
|
||||
this.debugMode = false;
|
||||
|
||||
console.log('💾 Persistent State Manager initialized (v' + this.version + ')');
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clone a value (supports primitives, arrays, objects)
|
||||
* Uses structuredClone if available, otherwise manual deep clone
|
||||
*
|
||||
* @private
|
||||
* @param {*} obj - Value to clone
|
||||
* @returns {*} Deep cloned value
|
||||
*/
|
||||
_deepClone(obj) {
|
||||
// Use browser's structuredClone if available (modern browsers)
|
||||
if (typeof structuredClone === 'function') {
|
||||
try {
|
||||
return structuredClone(obj);
|
||||
} catch (e) {
|
||||
// Fall back to manual clone if structuredClone fails
|
||||
console.warn('⚠️ structuredClone failed, using manual clone:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Manual deep clone fallback
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
if (Array.isArray(obj)) return obj.map(item => this._deepClone(item));
|
||||
|
||||
const cloned = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
cloned[key] = this._deepClone(value);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract carry-over metadata from scenario's globalVariables definition
|
||||
*
|
||||
* Parses both simple and metadata formats:
|
||||
* - Simple: { "var": value } → carryOver: false
|
||||
* - Metadata: { "var": { default, carryOver, type } } → uses flags
|
||||
*
|
||||
* @param {Object} scenarioGlobalVariables - globalVariables from scenario.json
|
||||
* @returns {void}
|
||||
* @throws {Error} If scenarioGlobalVariables is not an object
|
||||
*/
|
||||
extractCarryOverMetadata(scenarioGlobalVariables) {
|
||||
if (!scenarioGlobalVariables || typeof scenarioGlobalVariables !== 'object') {
|
||||
console.warn('⚠️ No globalVariables defined in scenario');
|
||||
return;
|
||||
}
|
||||
|
||||
this.carryOverMetadata.clear();
|
||||
|
||||
for (const [varName, value] of Object.entries(scenarioGlobalVariables)) {
|
||||
// Check if this looks like metadata object format
|
||||
if (typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
'default' in value) {
|
||||
|
||||
// Metadata format: { default, carryOver, type, description }
|
||||
const metadata = {
|
||||
default: value.default,
|
||||
carryOver: value.carryOver ?? false,
|
||||
type: value.type || (Array.isArray(value.default) ? 'array' : typeof value.default),
|
||||
description: value.description || null
|
||||
};
|
||||
|
||||
this.carryOverMetadata.set(varName, metadata);
|
||||
|
||||
if (this.debugMode) {
|
||||
console.log(`🔍 [DEBUG] Extracted metadata for ${varName}:`, metadata);
|
||||
}
|
||||
} else {
|
||||
// Simple value format - not a carry-over variable by default
|
||||
const metadata = {
|
||||
default: value,
|
||||
carryOver: false,
|
||||
type: Array.isArray(value) ? 'array' : typeof value,
|
||||
description: null
|
||||
};
|
||||
|
||||
this.carryOverMetadata.set(varName, metadata);
|
||||
|
||||
if (this.debugMode) {
|
||||
console.log(`🔍 [DEBUG] Extracted simple format for ${varName}:`, metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const carryOverCount = Array.from(this.carryOverMetadata.values())
|
||||
.filter(m => m.carryOver).length;
|
||||
const sessionOnlyCount = this.carryOverMetadata.size - carryOverCount;
|
||||
|
||||
console.log(`📋 Extracted ${this.carryOverMetadata.size} variable definitions:`);
|
||||
console.log(` Carry-over: ${carryOverCount}`);
|
||||
console.log(` Session-only: ${sessionOnlyCount}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge persistent state with scenario defaults
|
||||
*
|
||||
* Strategy:
|
||||
* - For carry-over variables: use persistent value if exists, else use default
|
||||
* - For session-only variables: always use scenario default
|
||||
* - Validate types before applying persistent values
|
||||
*
|
||||
* @param {Object|null} persistentVariables - Variables from persistent state JSON
|
||||
* @param {Object} scenarioGlobalVariables - globalVariables from scenario.json
|
||||
* @returns {Object} Merged global variables object
|
||||
*/
|
||||
mergeWithScenarioDefaults(persistentVariables, scenarioGlobalVariables) {
|
||||
const merged = {};
|
||||
|
||||
// First extract metadata if not already done
|
||||
if (this.carryOverMetadata.size === 0) {
|
||||
this.extractCarryOverMetadata(scenarioGlobalVariables);
|
||||
}
|
||||
|
||||
let loadedCount = 0;
|
||||
let defaultCount = 0;
|
||||
let typeMismatchCount = 0;
|
||||
|
||||
this.carryOverMetadata.forEach((metadata, varName) => {
|
||||
let value = this._deepClone(metadata.default);
|
||||
|
||||
// Only override with persistent state if this is a carry-over variable
|
||||
if (metadata.carryOver && persistentVariables && varName in persistentVariables) {
|
||||
const persistentValue = persistentVariables[varName];
|
||||
|
||||
// Type validation
|
||||
const expectedType = Array.isArray(metadata.default) ? 'array' : typeof metadata.default;
|
||||
const actualType = Array.isArray(persistentValue) ? 'array' : typeof persistentValue;
|
||||
|
||||
if (expectedType !== actualType) {
|
||||
console.warn(`⚠️ [${PersistentStateErrors.TYPE_MISMATCH}] Type mismatch for ${varName}:`);
|
||||
console.warn(` Expected: ${expectedType}, Got: ${actualType}`);
|
||||
console.warn(` Using default value:`, metadata.default);
|
||||
typeMismatchCount++;
|
||||
} else {
|
||||
value = this._deepClone(persistentValue);
|
||||
loadedCount++;
|
||||
|
||||
if (this.debugMode) {
|
||||
console.log(`🔍 [DEBUG] Loaded ${varName} from persistent state:`, value);
|
||||
} else {
|
||||
console.log(`✅ Loaded ${varName} from persistent state`);
|
||||
}
|
||||
}
|
||||
} else if (metadata.carryOver && !persistentVariables) {
|
||||
defaultCount++;
|
||||
if (this.debugMode) {
|
||||
console.log(`ℹ️ No persistent state - using default for ${varName}:`, value);
|
||||
}
|
||||
} else if (metadata.carryOver && !(varName in persistentVariables)) {
|
||||
defaultCount++;
|
||||
if (this.debugMode) {
|
||||
console.log(`ℹ️ No persistent value for ${varName} - using default:`, value);
|
||||
}
|
||||
}
|
||||
|
||||
merged[varName] = value;
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log(`📊 Merge Summary:`);
|
||||
console.log(` Loaded from persistent state: ${loadedCount}`);
|
||||
console.log(` Using scenario defaults: ${defaultCount}`);
|
||||
if (typeMismatchCount > 0) {
|
||||
console.warn(` Type mismatches: ${typeMismatchCount}`);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export current carry-over variables as JSON object
|
||||
*
|
||||
* @param {string} lastScenarioId - ID of current scenario
|
||||
* @returns {Object} Persistent state object with version, timestamp, variables
|
||||
*/
|
||||
exportPersistentState(lastScenarioId = 'unknown') {
|
||||
const exported = {
|
||||
version: this.version,
|
||||
timestamp: new Date().toISOString(),
|
||||
lastScenario: lastScenarioId,
|
||||
variables: {},
|
||||
metadata: {
|
||||
exportedCount: 0,
|
||||
sessionOnlyCount: 0
|
||||
}
|
||||
};
|
||||
|
||||
let exportedCount = 0;
|
||||
let sessionOnlyCount = 0;
|
||||
|
||||
this.carryOverMetadata.forEach((metadata, varName) => {
|
||||
if (metadata.carryOver) {
|
||||
if (window.gameState?.globalVariables?.hasOwnProperty(varName)) {
|
||||
exported.variables[varName] = this._deepClone(
|
||||
window.gameState.globalVariables[varName]
|
||||
);
|
||||
exportedCount++;
|
||||
}
|
||||
} else {
|
||||
sessionOnlyCount++;
|
||||
}
|
||||
});
|
||||
|
||||
exported.metadata.exportedCount = exportedCount;
|
||||
exported.metadata.sessionOnlyCount = sessionOnlyCount;
|
||||
|
||||
console.log(`📤 Exported persistent state:`);
|
||||
console.log(` Scenario: ${lastScenarioId}`);
|
||||
console.log(` Carried over: ${exportedCount} variables`);
|
||||
console.log(` Session-only: ${sessionOnlyCount} variables (not exported)`);
|
||||
|
||||
return exported;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download persistent state as JSON file (triggers browser download)
|
||||
*
|
||||
* @param {string} filename - Output filename (default: persistent-state.json)
|
||||
*/
|
||||
downloadAsJSON(filename = 'persistent-state.json') {
|
||||
const scenarioId = window.gameScenario?.scenario_id || 'game';
|
||||
const state = this.exportPersistentState(scenarioId);
|
||||
|
||||
const blob = new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ Downloaded persistent state as:', filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Console command to view current persistent state
|
||||
* Shows carry-over variables, current values, defaults, and export preview
|
||||
*/
|
||||
viewPersistentState() {
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
console.log('📊 PERSISTENT STATE VIEWER');
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
|
||||
console.log('\n🔍 Carry-Over Variables:');
|
||||
const carryOverVars = Array.from(this.carryOverMetadata.entries())
|
||||
.filter(([_, metadata]) => metadata.carryOver);
|
||||
|
||||
if (carryOverVars.length === 0) {
|
||||
console.log(' (No carry-over variables defined)');
|
||||
} else {
|
||||
carryOverVars.forEach(([varName, metadata]) => {
|
||||
const currentValue = window.gameState?.globalVariables?.[varName];
|
||||
const changed = currentValue !== metadata.default ? '📝' : '';
|
||||
console.log(` ${changed} ${varName}:`, currentValue, `(default: ${JSON.stringify(metadata.default)})`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n📦 Full Export Preview:');
|
||||
const exported = this.exportPersistentState();
|
||||
console.log(JSON.stringify(exported, null, 2));
|
||||
|
||||
console.log('\n💡 Available Commands:');
|
||||
console.log(' window.exportPersistentState() - Export as JSON object');
|
||||
console.log(' window.downloadPersistentState(filename) - Download JSON file');
|
||||
console.log(' window.viewPersistentState() - Show this viewer');
|
||||
console.log('═══════════════════════════════════════════════════════════');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log diff between defaults and loaded values (helpful for debugging)
|
||||
*
|
||||
* @param {Object} merged - Merged global variables
|
||||
*/
|
||||
logStateDiff(merged) {
|
||||
console.log('📊 Persistent State Diff:');
|
||||
|
||||
const diffData = {};
|
||||
this.carryOverMetadata.forEach((metadata, varName) => {
|
||||
if (metadata.carryOver) {
|
||||
const currentValue = merged[varName];
|
||||
const isChanged = JSON.stringify(currentValue) !== JSON.stringify(metadata.default);
|
||||
|
||||
diffData[varName] = {
|
||||
default: JSON.stringify(metadata.default),
|
||||
loaded: JSON.stringify(currentValue),
|
||||
changed: isChanged ? '✅' : ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
console.table(diffData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Future: POST persistent state to server endpoint
|
||||
*
|
||||
* Expected server endpoint:
|
||||
* POST /api/persistent-state
|
||||
* Headers: Authorization: Bearer {token}
|
||||
* Body: { version, timestamp, lastScenario, variables }
|
||||
*
|
||||
* Response:
|
||||
* { success: true, savedAt: timestamp, stateId: uuid }
|
||||
*
|
||||
* @param {string} endpoint - Server API endpoint
|
||||
* @param {string} authToken - JWT or API key
|
||||
* @returns {Promise<Object>} Server response
|
||||
*/
|
||||
async postToServer(endpoint, authToken) {
|
||||
const scenarioId = window.gameScenario?.scenario_id || 'unknown';
|
||||
const state = this.exportPersistentState(scenarioId);
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
},
|
||||
body: JSON.stringify(state)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('✅ Persistent state saved to server:', result);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to save persistent state to server:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
239
persistent-states/README.md
Normal file
239
persistent-states/README.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Persistent Game States
|
||||
|
||||
This directory contains persistent state files that carry variable values across game scenarios.
|
||||
|
||||
## What is Persistent State?
|
||||
|
||||
Persistent state is NOT a "save game" feature - it's a cross-scenario narrative persistence system that allows:
|
||||
- NPC trust levels to carry over between scenarios
|
||||
- Discussion topics to be tracked (preventing repetition)
|
||||
- Narrative decisions to have consequences in later games
|
||||
- Scenarios to be completed in any order while maintaining continuity
|
||||
|
||||
## Usage
|
||||
|
||||
### Loading a Persistent State
|
||||
|
||||
Add the `persistentState` URL parameter when loading a game:
|
||||
|
||||
```
|
||||
index.html?scenario=ceo_exfil&persistentState=example-continued-game
|
||||
```
|
||||
|
||||
The system will:
|
||||
1. Load the specified persistent state JSON file
|
||||
2. Merge it with the scenario's default variable values
|
||||
3. Initialize global variables with the merged values
|
||||
|
||||
### Exporting Your Progress
|
||||
|
||||
After completing a game, export your state from the browser console:
|
||||
|
||||
```javascript
|
||||
// View current persistent state
|
||||
window.viewPersistentState();
|
||||
|
||||
// Export as JSON object
|
||||
const state = window.exportPersistentState();
|
||||
|
||||
// Download as JSON file
|
||||
window.downloadPersistentState('my-progress.json');
|
||||
```
|
||||
|
||||
## File Format
|
||||
|
||||
Persistent state files use this JSON structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"timestamp": "2024-11-14T12:00:00.000Z",
|
||||
"lastScenario": "scenario_id",
|
||||
"variables": {
|
||||
"variable_name": "value"
|
||||
},
|
||||
"metadata": {
|
||||
"exportedCount": 5,
|
||||
"sessionOnlyCount": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
- **version** (number): Schema version for future migrations
|
||||
- **timestamp** (string): ISO 8601 timestamp of when state was exported
|
||||
- **lastScenario** (string): ID of the scenario that produced this state
|
||||
- **variables** (object): Key-value pairs of carry-over variables
|
||||
- **metadata** (object): Statistics about the export (optional)
|
||||
|
||||
## Variable Types
|
||||
|
||||
The system supports:
|
||||
- **Primitives**: strings, numbers, booleans
|
||||
- **Arrays**: lists of values
|
||||
- **Objects**: nested data structures
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Fresh Player
|
||||
|
||||
No persistent state file - all variables use scenario defaults.
|
||||
|
||||
```
|
||||
index.html?scenario=ceo_exfil
|
||||
```
|
||||
|
||||
### Example 2: Continued Story
|
||||
|
||||
Load with previous progress:
|
||||
|
||||
```
|
||||
index.html?scenario=server_breach&persistentState=example-continued-game
|
||||
```
|
||||
|
||||
NPCs will remember your trust level, topics discussed, and narrative choices.
|
||||
|
||||
### Example 3: Custom Progress
|
||||
|
||||
Create your own persistent state file:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"timestamp": "2024-11-14T15:30:00.000Z",
|
||||
"lastScenario": "custom_scenario",
|
||||
"variables": {
|
||||
"npc_trust_helper": 100,
|
||||
"narrative_joined_org": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Save as `my-custom-state.json` in this directory, then load:
|
||||
|
||||
```
|
||||
index.html?scenario=next_mission&persistentState=my-custom-state
|
||||
```
|
||||
|
||||
## Creating Carry-Over Variables
|
||||
|
||||
### In Scenario JSON
|
||||
|
||||
Define which variables carry over in `scenario.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario_id": "my_scenario",
|
||||
"globalVariables": {
|
||||
"npc_trust_guard": {
|
||||
"default": 0,
|
||||
"carryOver": true,
|
||||
"type": "number",
|
||||
"description": "Trust level with security guard"
|
||||
},
|
||||
"topics_discussed": {
|
||||
"default": [],
|
||||
"carryOver": true,
|
||||
"type": "array"
|
||||
},
|
||||
"temp_session_var": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- Variables with `carryOver: true` persist across scenarios
|
||||
- Variables without metadata (like `temp_session_var`) are session-only
|
||||
- `default` provides the initial value for new players
|
||||
|
||||
### In Ink Stories
|
||||
|
||||
Use carry-over variables in your Ink scripts:
|
||||
|
||||
```ink
|
||||
=== start ===
|
||||
{npc_trust_guard >= 75:
|
||||
"Good to see you again, friend! I trust you."
|
||||
- else:
|
||||
"Who are you? State your business."
|
||||
}
|
||||
|
||||
~ npc_trust_guard = npc_trust_guard + 10
|
||||
|
||||
{topics_discussed ? encryption:
|
||||
// Already discussed this topic
|
||||
-> already_know_encryption
|
||||
- else:
|
||||
// New topic
|
||||
-> introduce_encryption
|
||||
}
|
||||
|
||||
=== introduce_encryption ===
|
||||
Let me tell you about encryption...
|
||||
~ topics_discussed += "encryption"
|
||||
-> END
|
||||
```
|
||||
|
||||
## Automatic Export on Victory
|
||||
|
||||
Trigger export from Ink when the player wins:
|
||||
|
||||
```ink
|
||||
=== mission_complete ===
|
||||
Congratulations! You've completed the mission.
|
||||
~ mission_completed = true
|
||||
#export_persistent_state
|
||||
-> END
|
||||
```
|
||||
|
||||
The `#export_persistent_state` tag will trigger an automatic export to the console.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Persistent State Not Loading
|
||||
|
||||
Check the browser console for errors:
|
||||
- `[PSM_004] LOAD_FAILED` - File not found or network error
|
||||
- `[PSM_001] INVALID_JSON` - Malformed JSON file
|
||||
- `[PSM_002] TYPE_MISMATCH` - Variable type doesn't match scenario definition
|
||||
|
||||
### Variables Not Carrying Over
|
||||
|
||||
1. Check that the variable is defined with `carryOver: true` in scenario.json
|
||||
2. Verify the variable exists in your persistent state file
|
||||
3. Check console logs for type mismatches
|
||||
4. Use `window.viewPersistentState()` to see what's loaded
|
||||
|
||||
### Type Mismatches
|
||||
|
||||
If you see type mismatch warnings, the persistent value will be ignored and the default used instead:
|
||||
|
||||
```
|
||||
⚠️ [PSM_002] Type mismatch for some_var:
|
||||
Expected: number, Got: string
|
||||
Using default value instead
|
||||
```
|
||||
|
||||
Fix by correcting the type in your persistent state file.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use clear naming conventions**:
|
||||
- `npc_trust_{npc_id}` - Trust levels
|
||||
- `topics_{category}` - Discussion topics
|
||||
- `narrative_{decision}` - Major story choices
|
||||
- `mission_{name}_completed` - Completion flags
|
||||
|
||||
2. **Keep state minimal**: Only persist essential narrative variables
|
||||
|
||||
3. **Document variables**: Add descriptions in scenario.json
|
||||
|
||||
4. **Test cross-scenario**: Verify variables work across different scenarios
|
||||
|
||||
5. **Version your schema**: Increment version when changing variable structure
|
||||
|
||||
## See Also
|
||||
|
||||
- `/planning_notes/state_save/implementation_plan.md` - Technical implementation details
|
||||
- `/scenarios/ceo_exfil.json` - Example scenario with carry-over variables
|
||||
16
persistent-states/example-continued-game.json
Normal file
16
persistent-states/example-continued-game.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"version": 1,
|
||||
"timestamp": "2024-11-14T12:00:00.000Z",
|
||||
"lastScenario": "ceo_exfil",
|
||||
"variables": {
|
||||
"npc_trust_helper": 85,
|
||||
"topics_discussed_security": ["encryption", "zero_days", "social_engineering"],
|
||||
"topics_discussed_lore": ["corporate_history", "organization_background"],
|
||||
"narrative_joined_org": true,
|
||||
"mission_ceo_completed": true
|
||||
},
|
||||
"metadata": {
|
||||
"exportedCount": 5,
|
||||
"sessionOnlyCount": 0
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,45 @@
|
||||
{
|
||||
"scenario_id": "ceo_exfil",
|
||||
"scenario_brief": "Hi! You are a cyber investigator tasked with uncovering evidence of corporate espionage. Anonymous tips suggest the CEO has been selling company secrets, but you need proof.",
|
||||
"globalVariables": {
|
||||
"npc_trust_helper": {
|
||||
"default": 50,
|
||||
"carryOver": true,
|
||||
"type": "number",
|
||||
"description": "Trust level with helpful contact (0-100)"
|
||||
},
|
||||
"npc_trust_gossip_girl": {
|
||||
"default": 30,
|
||||
"carryOver": true,
|
||||
"type": "number",
|
||||
"description": "Trust level with gossip girl (0-100)"
|
||||
},
|
||||
"topics_discussed_security": {
|
||||
"default": [],
|
||||
"carryOver": true,
|
||||
"type": "array",
|
||||
"description": "Security topics discussed with helper NPC"
|
||||
},
|
||||
"topics_discussed_lore": {
|
||||
"default": [],
|
||||
"carryOver": true,
|
||||
"type": "array",
|
||||
"description": "Lore topics discussed across scenarios"
|
||||
},
|
||||
"narrative_joined_org": {
|
||||
"default": false,
|
||||
"carryOver": true,
|
||||
"type": "boolean",
|
||||
"description": "Whether player joined the organization"
|
||||
},
|
||||
"mission_ceo_completed": {
|
||||
"default": false,
|
||||
"carryOver": true,
|
||||
"type": "boolean",
|
||||
"description": "Whether CEO exfil mission was completed"
|
||||
},
|
||||
"temp_current_mission_phase": 1
|
||||
},
|
||||
"startRoom": "reception",
|
||||
"startItemsInInventory": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user