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:
Claude
2025-11-15 01:07:23 +00:00
parent 33ee1424bc
commit ad942112cf
8 changed files with 878 additions and 7 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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}`);

View File

@@ -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

View 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
View 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

View 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
}
}

View File

@@ -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": [
{