diff --git a/js/core/game.js b/js/core/game.js index fdfb870..35afbdb 100644 --- a/js/core/game.js +++ b/js/core/game.js @@ -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 diff --git a/js/main.js b/js/main.js index 4711056..2959e99 100644 --- a/js/main.js +++ b/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); } diff --git a/js/minigames/helpers/chat-helpers.js b/js/minigames/helpers/chat-helpers.js index 434b8f7..4f808c6 100644 --- a/js/minigames/helpers/chat-helpers.js +++ b/js/minigames/helpers/chat-helpers.js @@ -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}`); diff --git a/js/minigames/person-chat/person-chat-conversation.js b/js/minigames/person-chat/person-chat-conversation.js index 4cfb21e..4d146b4 100644 --- a/js/minigames/person-chat/person-chat-conversation.js +++ b/js/minigames/person-chat/person-chat-conversation.js @@ -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 diff --git a/js/systems/persistent-state-manager.js b/js/systems/persistent-state-manager.js new file mode 100644 index 0000000..354e85e --- /dev/null +++ b/js/systems/persistent-state-manager.js @@ -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} 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; + } + } +} diff --git a/persistent-states/README.md b/persistent-states/README.md new file mode 100644 index 0000000..fac273e --- /dev/null +++ b/persistent-states/README.md @@ -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 diff --git a/persistent-states/example-continued-game.json b/persistent-states/example-continued-game.json new file mode 100644 index 0000000..4bf2709 --- /dev/null +++ b/persistent-states/example-continued-game.json @@ -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 + } +} diff --git a/scenarios/ceo_exfil.json b/scenarios/ceo_exfil.json index e588061..59f76cd 100644 --- a/scenarios/ceo_exfil.json +++ b/scenarios/ceo_exfil.json @@ -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": [ {