feat(person-chat): Implement multi-character support and enhance dialogue handling

This commit is contained in:
Z. Cliffe Schreuders
2025-11-04 22:11:32 +00:00
parent e0cef60f37
commit 518d8916be
7 changed files with 400 additions and 121 deletions

View File

@@ -60,18 +60,67 @@ export class PersonChatMinigame extends MinigameScene {
spriteSheet: 'hacker'
};
// Build character index for multi-character support
this.characters = this.buildCharacterIndex();
// Modules
this.ui = null;
this.conversation = null;
// State
this.isConversationActive = false;
this.currentSpeaker = null; // Track current speaker ('npc', 'player', or NPC id)
this.currentSpeaker = null; // Track current speaker ID ('player' or NPC id)
this.lastResult = null; // Store last continue() result for choice handling
console.log(`🎭 PersonChatMinigame created for NPC: ${this.npcId}`);
}
/**
* Build index of all available characters (player + NPCs)
* @returns {Object} Map of character ID to character data
*/
buildCharacterIndex() {
const characters = {};
// Add player
characters['player'] = this.playerData;
// Add main NPC
characters[this.npc.id] = this.npc;
// Add other NPCs from scenario if available
if (this.scenario.npcs && Array.isArray(this.scenario.npcs)) {
this.scenario.npcs.forEach(npc => {
if (npc.id !== this.npc.id) {
characters[npc.id] = npc;
}
});
}
console.log(`👥 Built character index with ${Object.keys(characters).length} characters:`, Object.keys(characters));
return characters;
}
/**
* Get character data by ID
* @param {string} characterId - Character ID (player, npc_id, etc.)
* @returns {Object} Character data
*/
getCharacterById(characterId) {
if (!characterId) return this.npc; // Fallback to main NPC
// Handle legacy speaker values
if (characterId === 'npc') {
return this.npc;
}
if (characterId === 'player') {
return this.playerData;
}
// Look up by ID
return this.characters[characterId] || this.npc;
}
/**
* Initialize the minigame UI and components
*/
@@ -93,7 +142,8 @@ export class PersonChatMinigame extends MinigameScene {
game: this.game,
npc: this.npc,
playerSprite: this.player,
playerData: this.playerData
playerData: this.playerData,
characters: this.characters // Pass multi-character support
}, this.npcManager);
this.ui.render();
@@ -224,13 +274,51 @@ export class PersonChatMinigame extends MinigameScene {
}
/**
* Determine who is speaking based on Ink tags or content
* Determine who is speaking based on Ink tags
* Supports speaker tags like:
* - # speaker:player
* - # speaker:npc (defaults to main NPC)
* - # speaker:npc_id:character_id (specific character)
*
* @param {Object} result - Result from conversation.continue()
* @returns {string} Speaker ('npc' or 'player')
* @returns {string} Character ID of speaker (player, npc_id, or main NPC id)
*/
determineSpeaker(result) {
// Use the shared helper function from chat-helpers
return determineSpeakerFromTags(result.tags, 'npc');
if (!result.tags || result.tags.length === 0) {
return this.npc.id; // Default to main NPC
}
// Check tags in reverse order to find the last speaker tag (current speaker)
for (let i = result.tags.length - 1; i >= 0; i--) {
const tag = result.tags[i].trim().toLowerCase();
// Handle multi-part speaker tags like "speaker:npc:test_npc_back"
if (tag.startsWith('speaker:')) {
const parts = tag.split(':');
if (parts.length === 2) {
// Simple speaker tag: speaker:player or speaker:npc
const speaker = parts[1];
if (speaker === 'player') return 'player';
if (speaker === 'npc') return this.npc.id; // Default NPC
} else if (parts.length === 3) {
// Specific character tag: speaker:npc:character_id
const characterId = parts[2];
return this.characters[characterId] ? characterId : this.npc.id;
} else if (parts.length > 3) {
// Handle IDs with colons like speaker:npc:test_npc_back
const characterId = parts.slice(2).join(':');
return this.characters[characterId] ? characterId : this.npc.id;
}
}
// Fallback for non-speaker: tags
if (tag === 'player') return 'player';
if (tag === 'npc') return this.npc.id;
}
// No speaker tag found - default to main NPC
return this.npc.id;
}
/**
@@ -246,6 +334,9 @@ export class PersonChatMinigame extends MinigameScene {
// Get the choice text from lastResult before making the choice
const choiceText = this.lastResult.choices[choiceIndex]?.text || '';
// Clear choice buttons immediately
this.ui.hideChoices();
// Make choice in conversation (this also calls continue() internally)
const result = this.conversation.makeChoice(choiceIndex);
@@ -254,42 +345,10 @@ export class PersonChatMinigame extends MinigameScene {
this.ui.showDialogue(choiceText, 'player');
}
// Then display the result (NPC response) after a small delay
// Then display the result (dialogue blocks) after a small delay
setTimeout(() => {
// Extract NPC-only content from the accumulated result
// Split by speaker tag to separate player and NPC dialogue
let npcText = '';
let npcTags = [];
// Find the section with speaker:npc tag
if (result.tags && result.tags.includes('speaker:npc')) {
// Split the text by lines and reconstruct based on tags
const lines = result.text.split('\n').filter(line => line.trim());
const tagIndex = result.tags.indexOf('speaker:npc');
// Get number of player lines before NPC (based on speaker:player tag position)
const playerTagIndex = result.tags.indexOf('speaker:player');
let playerLineCount = playerTagIndex >= 0 ? 1 : 0;
// Skip player lines and collect NPC lines
npcText = lines.slice(playerLineCount).join('\n').trim();
npcTags = result.tags.filter((tag, idx) => idx >= tagIndex);
console.log(`📄 Extracted NPC text: "${npcText.substring(0, 50)}..."`);
} else {
// Fallback: use full result if no speaker tag
npcText = result.text;
npcTags = result.tags;
}
// Create a new result with only the NPC's dialogue
const npcOnlyResult = {
...result,
text: npcText,
tags: npcTags
};
this.displayDialogueResult(npcOnlyResult);
// Process accumulated dialogue by splitting into individual speaker blocks
this.displayAccumulatedDialogue(result);
}, 1500);
} catch (error) {
console.error('❌ Error handling choice:', error);
@@ -297,6 +356,128 @@ export class PersonChatMinigame extends MinigameScene {
}
}
/**
* Display accumulated dialogue by splitting into individual speaker blocks
* @param {Object} result - Result with potentially multiple lines and tags
*/
displayAccumulatedDialogue(result) {
if (!result.text || !result.tags) {
// No content to display
if (result.hasEnded) {
this.endConversation();
}
return;
}
// Split text into lines
const lines = result.text.split('\n').filter(line => line.trim());
// We have lines and tags - pair them up
// Each tag corresponds to a line (or group of lines before the next tag)
if (lines.length === 0) {
if (result.hasEnded) {
this.endConversation();
}
return;
}
// Create dialogue blocks: each block is one or more consecutive lines with the same speaker
const dialogueBlocks = this.createDialogueBlocks(lines, result.tags);
// Display blocks sequentially with delays
this.displayDialogueBlocksSequentially(dialogueBlocks, result, 0);
}
/**
* Create dialogue blocks from lines and speaker tags
* @param {Array<string>} lines - Text lines
* @param {Array<string>} tags - Speaker tags
* @returns {Array<Object>} Array of {speaker, text} blocks
*/
createDialogueBlocks(lines, tags) {
const blocks = [];
let blockIndex = 0;
// Group lines by speaker based on tags
for (let tagIdx = 0; tagIdx < tags.length; tagIdx++) {
const tag = tags[tagIdx];
// Determine speaker from tag - support multiple formats
let speaker = 'npc'; // default
if (tag.includes('speaker:player')) {
speaker = 'player';
} else if (tag.includes('speaker:npc:')) {
// Extract character ID from speaker:npc:character_id format
const match = tag.match(/speaker:npc:(\S+)/);
if (match && match[1]) {
speaker = match[1];
}
} else if (tag === 'player' || tag.includes('player')) {
speaker = 'player';
}
// Find how many lines belong to this speaker (until next tag or end)
const nextTagIdx = tagIdx + 1;
const startLineIdx = blockIndex;
// Count lines for this speaker - lines between this tag and the next
let endLineIdx = lines.length;
if (nextTagIdx < tags.length) {
// There's another tag coming, but we need to figure out how many lines
// For now, assume 1 line per tag (common case)
endLineIdx = startLineIdx + 1;
}
// Collect the text for this speaker
const blockText = lines.slice(startLineIdx, endLineIdx).join('\n').trim();
if (blockText) {
blocks.push({ speaker, text: blockText, tag });
}
blockIndex = endLineIdx;
}
return blocks;
}
/**
* Display dialogue blocks sequentially
* @param {Array<Object>} blocks - Array of dialogue blocks
* @param {Object} originalResult - Original result from Ink
* @param {number} blockIndex - Current block index
*/
displayDialogueBlocksSequentially(blocks, originalResult, blockIndex) {
if (blockIndex >= blocks.length) {
// All blocks displayed, check if story has ended
if (originalResult.hasEnded) {
setTimeout(() => this.endConversation(), 1000);
} else {
// Try to continue for more dialogue
console.log('⏸️ Blocks finished, checking for more dialogue...');
setTimeout(() => {
const nextLine = this.conversation.continue();
if (nextLine.text && nextLine.text.trim()) {
this.displayAccumulatedDialogue(nextLine);
} else if (nextLine.hasEnded) {
this.endConversation();
}
}, 2000);
}
return;
}
// Display current block
const block = blocks[blockIndex];
console.log(`📋 Displaying block ${blockIndex + 1}/${blocks.length}: ${block.speaker}`);
this.ui.showDialogue(block.text, block.speaker);
// Display next block after delay
setTimeout(() => {
this.displayDialogueBlocksSequentially(blocks, originalResult, blockIndex + 1);
}, 2000);
}
/**
* Display dialogue from a result object (without calling continue() again)
* @param {Object} result - Story result from conversation.continue()
@@ -340,9 +521,23 @@ export class PersonChatMinigame extends MinigameScene {
console.log('⏳ Auto-continuing in 2 seconds...');
setTimeout(() => this.showCurrentDialogue(), 2000);
} else {
// No choices and can't continue - story will end
console.log('✓ Waiting for story to end...');
setTimeout(() => this.endConversation(), 1000);
// No choices and can't continue - check if there's more content
// Try to continue anyway (for linear scripted conversations)
console.log('⏸️ No more choices, attempting to continue for next line...');
setTimeout(() => {
const nextLine = this.conversation.continue();
if (nextLine.text && nextLine.text.trim()) {
// There's more dialogue to show
this.displayDialogueResult(nextLine);
} else if (nextLine.hasEnded) {
// Story has truly ended
this.endConversation();
} else {
// No text but story isn't ended - wait a bit and end
console.log('✓ No more dialogue - ending conversation');
setTimeout(() => this.endConversation(), 1000);
}
}, 2000);
}
} catch (error) {
console.error('❌ Error displaying dialogue:', error);

View File

@@ -17,7 +17,7 @@ export default class PersonChatUI {
/**
* Create UI component
* @param {HTMLElement} container - Container for UI
* @param {Object} params - Configuration (game, npc, playerSprite)
* @param {Object} params - Configuration (game, npc, playerSprite, characters)
* @param {NPCManager} npcManager - NPC manager for sprite access
*/
constructor(container, params, npcManager) {
@@ -28,6 +28,7 @@ export default class PersonChatUI {
this.npc = params.npc;
this.playerSprite = params.playerSprite;
this.playerData = params.playerData || {};
this.characters = params.characters || {}; // Multi-character support
// UI elements
this.elements = {
@@ -48,7 +49,7 @@ export default class PersonChatUI {
this.portraitRenderer = null;
// State
this.currentSpeaker = null; // 'npc' or 'player'
this.currentSpeaker = null; // Character ID
this.hasContinued = false; // Track if user has clicked continue
console.log('📱 PersonChatUI created');
@@ -173,25 +174,36 @@ export default class PersonChatUI {
/**
* Display dialogue text with speaker
* @param {string} text - Dialogue text to display
* @param {string} speaker - Speaker name ('npc' or 'player')
* @param {string} characterId - Character ID ('player', 'npc', or specific NPC ID)
*/
showDialogue(text, speaker = 'npc') {
this.currentSpeaker = speaker;
showDialogue(text, characterId = 'npc') {
this.currentSpeaker = characterId;
console.log(`📝 showDialogue called with speaker: ${speaker}, text length: ${text?.length || 0}`);
console.log(`📝 dialogueText element:`, this.elements.dialogueText);
console.log(`📝 speakerName element:`, this.elements.speakerName);
console.log(`📝 showDialogue called with character: ${characterId}, text length: ${text?.length || 0}`);
// Get character data
let character = this.characters[characterId];
if (!character) {
// Fallback for legacy speaker values
if (characterId === 'player') {
character = this.playerData;
} else if (characterId === 'npc' || !characterId) {
character = this.npc;
}
}
// Determine display name
const displayName = character?.displayName || (characterId === 'player' ? 'You' : 'NPC');
const speakerType = characterId === 'player' ? 'player' : 'npc';
// Update speaker name and label
const displayName = speaker === 'npc' ? (this.npc?.displayName || 'NPC') : 'You';
this.elements.portraitLabel.textContent = displayName;
this.elements.speakerName.textContent = displayName;
console.log(`📝 Set speaker name to: ${displayName}`);
// Update speaker styling
this.elements.portraitSection.className = `person-chat-portrait-section speaker-${speaker}`;
this.elements.speakerName.className = `person-chat-speaker-name ${speaker}-speaker`;
this.elements.portraitSection.className = `person-chat-portrait-section speaker-${speakerType}`;
this.elements.speakerName.className = `person-chat-speaker-name ${speakerType}-speaker`;
// Update dialogue text
this.elements.dialogueText.textContent = text;
@@ -199,7 +211,7 @@ export default class PersonChatUI {
console.log(`📝 Set dialogue text, element content: "${this.elements.dialogueText.textContent}"`);
// Reset portrait for new speaker
this.updatePortraitForSpeaker(speaker);
this.updatePortraitForSpeaker(characterId, character);
// Reset continue button state
this.hasContinued = false;
@@ -207,33 +219,32 @@ export default class PersonChatUI {
/**
* Update portrait for the current speaker
* @param {string} speaker - 'npc' or 'player'
* @param {string} characterId - Character ID
* @param {Object} character - Character data
*/
updatePortraitForSpeaker(speaker) {
updatePortraitForSpeaker(characterId, character) {
try {
if (!this.portraitRenderer) {
if (!this.portraitRenderer || !character) {
return;
}
// Update sprite data for current speaker
if (speaker === 'npc' && this.npc) {
// Use the actual NPC object to preserve all properties (including spriteTalk)
this.portraitRenderer.npc = this.npc;
this.portraitRenderer.setupSpriteInfo();
this.portraitRenderer.render();
} else if (speaker === 'player') {
// Create player NPC object from playerData with spriteTalk
if (characterId === 'player' || character.id === 'player') {
// Create player object for portrait rendering
this.portraitRenderer.npc = {
id: 'player',
displayName: this.playerData.displayName || 'Agent 0x00',
spriteSheet: this.playerData.spriteSheet || 'hacker',
spriteTalk: this.playerData.spriteTalk || 'assets/characters/hacker-talk.png',
spriteConfig: this.playerData.spriteConfig || {},
_sprite: this.playerSprite
displayName: character.displayName || 'Agent 0x00',
spriteSheet: character.spriteSheet || 'hacker',
spriteTalk: character.spriteTalk || 'assets/characters/hacker-talk.png',
spriteConfig: character.spriteConfig || {}
};
this.portraitRenderer.setupSpriteInfo();
this.portraitRenderer.render();
} else {
// Use NPC character object
this.portraitRenderer.npc = character;
}
this.portraitRenderer.setupSpriteInfo();
this.portraitRenderer.render();
} catch (error) {
console.error('❌ Error updating portrait:', error);
}

View File

@@ -1,49 +1,61 @@
// Test Ink script for development
VAR test_counter = 0
VAR player_visited_room = false
=== start ===
# speaker: TestNPC
# type: bark
Hello! This is a test message from Ink.
~ test_counter++
-> END
=== test_room_reception ===
# speaker: TestNPC
# type: bark
{player_visited_room:
You're back in reception.
- else:
Welcome to reception! This is your first time here.
~ player_visited_room = true
}
-> END
=== test_item_lockpick ===
# speaker: TestNPC
# type: bark
You picked up a lockpick! Nice find.
~ test_counter++
-> END
// Test Ink script - Multi-character conversation with camera focus
// Demonstrates player, Front NPC (test_npc_front), and Back NPC (test_npc_back) in dialogue
VAR conversation_started = false
=== hub ===
# speaker: TestNPC
# type: conversation
What would you like to test?
Counter: {test_counter}
+ [Test choice 1] -> test_1
+ [Test choice 2] -> test_2
+ [Exit] -> END
# speaker:npc:test_npc_back
Welcome! This is a group conversation test. Let me introduce you to my colleague.
+ [Listen in on the introduction] -> group_meeting
=== group_meeting ===
# speaker:npc:test_npc_back
Agent, meet my colleague from the back office. BACK
-> colleague_introduction
=== colleague_introduction ===
# speaker:npc:test_npc_front
Nice to meet you! I'm the lead technician here. FRONT.
-> player_question
=== player_question ===
# speaker:player
What kind of work do you both do here?
-> front_npc_explains
=== front_npc_explains ===
# speaker:npc:test_npc_back
Well, I handle the front desk operations and guest interactions. But my colleague here...
-> colleague_responds
=== colleague_responds ===
# speaker:npc:test_npc_front
I manage all the backend systems and security infrastructure. Together, we keep everything running smoothly.
-> player_follow_up
=== player_follow_up ===
# speaker:player
That sounds like a well-coordinated operation!
-> front_npc_agrees
=== front_npc_agrees ===
# speaker:npc:test_npc_back
It really is! We've been working together for several years now. Communication is key.
-> colleague_adds
=== colleague_adds ===
# speaker:npc:test_npc_front
Exactly. And we're always looking for talented people like you to join our team.
-> player_closing
=== player_closing ===
# speaker:player
I appreciate the opportunity. I'll definitely consider it.
-> conversation_end
=== conversation_end ===
# speaker:npc:test_npc_back
Great! Feel free to explore and let us know if you have any questions.
-> END
=== test_1 ===
# speaker: TestNPC
You selected test choice 1!
Counter: {test_counter}
-> hub
=== test_2 ===
# speaker: TestNPC
You selected test choice 2!
~ test_counter++
-> hub

View File

@@ -1 +0,0 @@
{"inkVersion":21,"root":[[["done",{"#n":"g-0"}],null],"done",{"start":["#","^speaker: TestNPC","/#","#","^type: bark","/#","^Hello! This is a test message from Ink.","\n","ev",{"VAR?":"test_counter"},1,"+",{"VAR=":"test_counter","re":true},"/ev","end",null],"test_room_reception":["#","^speaker: TestNPC","/#","#","^type: bark","/#","ev",{"VAR?":"player_visited_room"},"/ev",[{"->":".^.b","c":true},{"b":["\n","^You're back in reception.","\n",{"->":".^.^.^.11"},null]}],[{"->":".^.b"},{"b":["\n","^Welcome to reception! This is your first time here.","\n","ev",true,"/ev",{"VAR=":"player_visited_room","re":true},{"->":".^.^.^.11"},null]}],"nop","\n","end",null],"test_item_lockpick":["#","^speaker: TestNPC","/#","#","^type: bark","/#","^You picked up a lockpick! Nice find.","\n","ev",{"VAR?":"test_counter"},1,"+",{"VAR=":"test_counter","re":true},"/ev","end",null],"hub":[["#","^speaker: TestNPC","/#","#","^type: conversation","/#","^What would you like to test?","\n","^Counter: ","ev",{"VAR?":"test_counter"},"out","/ev","\n","ev","str","^Test choice 1","/str","/ev",{"*":".^.c-0","flg":4},"ev","str","^Test choice 2","/str","/ev",{"*":".^.c-1","flg":4},"ev","str","^Exit","/str","/ev",{"*":".^.c-2","flg":4},{"c-0":["^ ",{"->":"test_1"},"\n",null],"c-1":["^ ",{"->":"test_2"},"\n",null],"c-2":["^ ","end","\n",null]}],null],"test_1":["#","^speaker: TestNPC","/#","^You selected test choice 1!","\n","^Counter: ","ev",{"VAR?":"test_counter"},"out","/ev","\n",{"->":"hub"},null],"test_2":["#","^speaker: TestNPC","/#","^You selected test choice 2!","\n","ev",{"VAR?":"test_counter"},1,"+",{"VAR=":"test_counter","re":true},"/ev",{"->":"hub"},null],"global decl":["ev",0,{"VAR=":"test_counter"},false,{"VAR=":"player_visited_room"},"/ev","end",null]}],"listDefs":{}}

61
scenarios/ink/test2.ink Normal file
View File

@@ -0,0 +1,61 @@
// Test Ink script - Multi-character conversation with camera focus
// Demonstrates player, Front NPC (test_npc_front), and Back NPC (test_npc_back) in dialogue
VAR conversation_started = false
=== hub ===
# speaker:npc:test_npc_back
Welcome! This is a group conversation test. Let me introduce you to my colleague.
+ [Listen in on the introduction] -> group_meeting
=== group_meeting ===
# speaker:npc:test_npc_back
Agent, meet my colleague from the back office. BACK
-> colleague_introduction
=== colleague_introduction ===
# speaker:npc:test_npc_front
Nice to meet you! I'm the lead technician here. FRONT.
-> player_question
=== player_question ===
# speaker:player
What kind of work do you both do here?
-> front_npc_explains
=== front_npc_explains ===
# speaker:npc:test_npc_back
Well, I handle the front desk operations and guest interactions. But my colleague here...
-> colleague_responds
=== colleague_responds ===
# speaker:npc:test_npc_front
I manage all the backend systems and security infrastructure. Together, we keep everything running smoothly.
-> player_follow_up
=== player_follow_up ===
# speaker:player
That sounds like a well-coordinated operation!
-> front_npc_agrees
=== front_npc_agrees ===
# speaker:npc:test_npc_back
It really is! We've been working together for several years now. Communication is key.
-> colleague_adds
=== colleague_adds ===
# speaker:npc:test_npc_front
Exactly. And we're always looking for talented people like you to join our team.
-> player_closing
=== player_closing ===
# speaker:player
I appreciate the opportunity. I'll definitely consider it.
-> conversation_end
=== conversation_end ===
# speaker:npc:test_npc_back
Great! Feel free to explore and let us know if you have any questions.
-> END

1
scenarios/ink/test2.json Normal file
View File

@@ -0,0 +1 @@
{"inkVersion":21,"root":[[["done",{"#n":"g-0"}],null],"done",{"hub":[["#","^speaker:npc:test_npc_back","/#","^Welcome! This is a group conversation test. Let me introduce you to my colleague.","\n","ev","str","^Listen in on the introduction","/str","/ev",{"*":".^.c-0","flg":4},{"c-0":["^ ",{"->":"group_meeting"},"\n",null]}],null],"group_meeting":["#","^speaker:npc:test_npc_back","/#","^Agent, meet my colleague from the back office. BACK","\n",{"->":"colleague_introduction"},null],"colleague_introduction":["#","^speaker:npc:test_npc_front","/#","^Nice to meet you! I'm the lead technician here. FRONT.","\n",{"->":"player_question"},null],"player_question":["#","^speaker:player","/#","^What kind of work do you both do here?","\n",{"->":"front_npc_explains"},null],"front_npc_explains":["#","^speaker:npc:test_npc_back","/#","^Well, I handle the front desk operations and guest interactions. But my colleague here...","\n",{"->":"colleague_responds"},null],"colleague_responds":["#","^speaker:npc:test_npc_front","/#","^I manage all the backend systems and security infrastructure. Together, we keep everything running smoothly.","\n",{"->":"player_follow_up"},null],"player_follow_up":["#","^speaker:player","/#","^That sounds like a well-coordinated operation!","\n",{"->":"front_npc_agrees"},null],"front_npc_agrees":["#","^speaker:npc:test_npc_back","/#","^It really is! We've been working together for several years now. Communication is key.","\n",{"->":"colleague_adds"},null],"colleague_adds":["#","^speaker:npc:test_npc_front","/#","^Exactly. And we're always looking for talented people like you to join our team.","\n",{"->":"player_closing"},null],"player_closing":["#","^speaker:player","/#","^I appreciate the opportunity. I'll definitely consider it.","\n",{"->":"conversation_end"},null],"conversation_end":["#","^speaker:npc:test_npc_back","/#","^Great! Feel free to explore and let us know if you have any questions.","\n","end",null],"global decl":["ev",false,{"VAR=":"conversation_started"},"/ev","end",null]}],"listDefs":{}}

View File

@@ -42,7 +42,7 @@
"idleFrameStart": 20,
"idleFrameEnd": 23
},
"storyPath": "scenarios/ink/test.ink.json",
"storyPath": "scenarios/ink/test2.json",
"currentKnot": "hub"
}
],