Update scenario.json.erb: Change NPC sprite and add closing debrief triggers

- Updated spriteSheet for Sarah Martinez from "female_office_worker" to "female_blowse".
- Added new event triggers for closing debrief upon entering the main office area and confronting Derek.
- Modified Agent 0x99 to use a person type NPC with updated event mappings and properties.
This commit is contained in:
Z. Cliffe Schreuders
2026-02-17 16:33:49 +00:00
parent a5f0b9164d
commit f30dd7f279
14 changed files with 244 additions and 64 deletions

View File

@@ -1,4 +1,7 @@
{
"cursor.general.disableHttp2": true,
"chat.agent.maxRequests": 100
"chat.agent.maxRequests": 100,
"chat.tools.terminal.autoApprove": {
"bin/inklecate": true
}
}

View File

@@ -1134,7 +1134,7 @@ module BreakEscape
stdout, stderr, status = Open3.capture3(
inklecate_path.to_s,
'-o', output_path,
'-jo', output_path,
ink_path.to_s
)

View File

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 154 KiB

View File

Before

Width:  |  Height:  |  Size: 957 B

After

Width:  |  Height:  |  Size: 957 B

View File

@@ -440,9 +440,9 @@ export function preload() {
this.load.atlas('female_scientist',
'characters/female_scientist.png',
'characters/female_scientist.json');
this.load.atlas('woman_blowse',
'characters/woman_blowse.png',
'characters/woman_blowse.json');
this.load.atlas('female_blowse',
'characters/female_blowse.png',
'characters/female_blowse.json');
// Male characters
this.load.atlas('male_hacker_hood',

View File

@@ -246,6 +246,41 @@ export function processGameActionTags(tags, ui) {
}
}
break;
case 'transition_to_person_chat':
{
// Format: transition_to_person_chat:npcId|background|knot
// Example: # transition_to_person_chat:closing_debrief_trigger|assets/backgrounds/hq1.png|start
const [targetNpcId, background, targetKnot] = param ? param.split('|').map(s => s.trim()) : [];
if (!targetNpcId) {
result.message = '⚠️ transition_to_person_chat requires npcId parameter';
console.warn(result.message);
break;
}
console.log('🔄 Transitioning to person-chat:', { targetNpcId, background, targetKnot });
// Close current phone-chat minigame
if (window.MinigameFramework && window.MinigameFramework.currentMinigame) {
window.MinigameFramework.currentMinigame.complete(false);
}
// Small delay before starting person-chat
setTimeout(() => {
if (window.MinigameFramework) {
window.MinigameFramework.startMinigame('person-chat', {
npcId: targetNpcId,
background: background || null,
startKnot: targetKnot || null
});
}
}, 100);
result.success = true;
result.message = `🔄 Transitioning to person-chat with ${targetNpcId}`;
}
break;
case 'clone_keycard':
// Parameter is the card_id to clone
// Look up card data from NPC's rfidCard property

View File

@@ -365,8 +365,31 @@ export class PhoneChatMinigame extends MinigameScene {
// Load conversation history
const history = this.history.loadHistory();
// Filter out bark-only messages to check if there's real conversation history
const conversationHistory = history.filter(msg => !msg.metadata?.isBark);
// Determine target knot (needed before clearing history)
const safeParams = this.params || {};
const explicitStartKnot = safeParams.startKnot;
const targetKnot = explicitStartKnot || npc.currentKnot || 'start';
// If navigating to a new knot explicitly (e.g., from timed message),
// clear the non-timed history to avoid showing old messages from previous visits to this knot
if (explicitStartKnot && history.length > 0) {
console.log(`🧹 Explicit knot navigation detected - clearing old conversation messages (keeping timed/bark notifications)`);
console.log('📝 History before filtering:', history.map(m => ({ text: m.text.substring(0, 40), timed: m.timed, isBark: m.isBark })));
// Keep only timed messages and barks (notifications), remove old Ink dialogue
// Note: metadata is spread directly onto message object, not nested
const filteredHistory = history.filter(msg => msg.isBark || msg.timed);
console.log('📝 History after filtering:', filteredHistory.map(m => ({ text: m.text.substring(0, 40), timed: m.timed, isBark: m.isBark })));
// Update NPCManager's conversation history directly
this.npcManager.conversationHistory.set(this.npcId, filteredHistory);
// Update what we'll display
history.splice(0, history.length, ...filteredHistory);
}
// Filter out bark-only and timed messages to check if there's real conversation history
// (timed messages are just notifications, not actual Ink dialogue)
const conversationHistory = history.filter(msg => !msg.isBark && !msg.timed);
const hasConversationHistory = conversationHistory.length > 0;
// Show all history (including barks) in the UI
@@ -377,11 +400,12 @@ export class PhoneChatMinigame extends MinigameScene {
}
// Load and start Ink story
// Support both storyJSON (inline) and storyPath (file)
let storySource = npc.storyJSON || npc.inkStoryPath;
// Prefer Rails API endpoint if storyPath exists (ensures fresh story after path changes)
console.log(`📱 openConversation - npc.storyJSON exists: ${!!npc.storyJSON}, npc.storyPath: ${npc.storyPath}, npc.inkStoryPath: ${npc.inkStoryPath}`);
let storySource = null;
// If no storyJSON but storyPath exists, use Rails API endpoint
if (!storySource && npc.storyPath) {
// If storyPath exists, use Rails API endpoint (ensures fresh load after story path changes)
if (npc.storyPath) {
const gameId = window.breakEscapeConfig?.gameId;
if (gameId) {
storySource = `/break_escape/games/${gameId}/ink?npc=${npcId}`;
@@ -389,6 +413,11 @@ export class PhoneChatMinigame extends MinigameScene {
}
}
// Fallback to storyJSON or inkStoryPath
if (!storySource) {
storySource = npc.storyJSON || npc.inkStoryPath;
}
if (!storySource) {
console.error(`❌ No story source found for ${npcId}`);
this.ui.showNotification('No conversation available', 'error');
@@ -405,20 +434,25 @@ export class PhoneChatMinigame extends MinigameScene {
this.isConversationActive = true;
// Check if we have saved story state to restore
if (hasConversationHistory && npc.storyState) {
// Restore previous story state
// BUT: if startKnot was explicitly provided (e.g., from timed message),
// navigate to that knot instead of restoring old state
if (hasConversationHistory && npc.storyState && !explicitStartKnot) {
// Restore previous story state (only if no explicit knot override)
console.log('📚 Restoring story state from previous conversation');
this.conversation.restoreState(npc.storyState);
// Show current choices without continuing
this.showCurrentChoices();
} else {
// Navigate to starting knot for first time
const safeParams = this.params || {};
const startKnot = safeParams.startKnot || npc.currentKnot || 'start';
this.conversation.goToKnot(startKnot);
// Navigate to starting knot (either first time, or explicit navigation request)
if (explicitStartKnot) {
console.log(`📱 Explicit navigation to knot: ${explicitStartKnot} (overriding saved state)`);
} else {
console.log(`📱 Navigating to knot: ${targetKnot}`);
}
this.conversation.goToKnot(targetKnot);
// First time opening - show intro message and choices
// Continue story to get fresh content and choices
this.continueStory();
}
}
@@ -470,6 +504,9 @@ export class PhoneChatMinigame extends MinigameScene {
return;
}
console.log('🎬 continueStory() called');
console.trace('Call stack'); // This will show us where continueStory is being called from
// Show typing indicator briefly
this.ui.showTypingIndicator();
@@ -530,6 +567,7 @@ export class PhoneChatMinigame extends MinigameScene {
console.log('📖 Accumulated messages:', accumulatedMessages.length);
console.log('🏷️ Accumulated tags:', accumulatedTags);
console.log('📝 Messages detail:', accumulatedMessages);
// If story has ended
if (lastResult.hasEnded && accumulatedMessages.length === 0) {

View File

@@ -317,7 +317,15 @@ export default class PhoneChatUI {
// Filter to only allowed NPCs if npcIds was specified
if (this.allowedNpcIds && this.allowedNpcIds.length > 0) {
console.log(`🔍 Filtering contacts: allowed NPCs = ${this.allowedNpcIds.join(', ')}`);
npcs = npcs.filter(npc => this.allowedNpcIds.includes(npc.id));
npcs = npcs.filter(npc => {
// Include if in allowed list
if (this.allowedNpcIds.includes(npc.id)) {
return true;
}
// Include if has conversation history (i.e., has been activated by events)
const history = this.npcManager.getConversationHistory(npc.id);
return history && history.length > 0;
});
console.log(`✅ Filtered to ${npcs.length} contacts`);
}

View File

@@ -11,7 +11,7 @@ export default class NPCManager {
this.eventListeners = new Map(); // Track registered listeners for cleanup
this.triggeredEvents = new Map(); // Track which events have been triggered per NPC
this.conversationHistory = new Map(); // Track conversation history per NPC: { npcId: [ {type, text, timestamp, choiceText} ] }
this.timedMessages = []; // Scheduled messages: { npcId, text, triggerTime, delivered, phoneId }
this.timedMessages = []; // Scheduled messages: { npcId, text, triggerTime, delivered, phoneId, targetKnot }
this.timedConversations = []; // Scheduled conversations: { npcId, targetKnot, triggerTime, delivered }
this.gameStartTime = Date.now(); // Track when game started for timed messages
this.timerInterval = null; // Timer for checking timed messages
@@ -315,7 +315,9 @@ export default class NPCManager {
cooldown: mapping.cooldown,
condition: mapping.condition,
maxTriggers: mapping.maxTriggers, // Add max trigger limit
conversationMode: mapping.conversationMode // Add conversation mode (e.g., 'person-chat')
conversationMode: mapping.conversationMode, // Add conversation mode (e.g., 'person-chat')
changeStoryPath: mapping.changeStoryPath, // Change the NPC's story file
sendTimedMessage: mapping.sendTimedMessage // Send a timed message when event triggers
};
console.log(` 📌 Registering listener for event: ${eventPattern}${config.knot}`);
@@ -403,10 +405,41 @@ export default class NPCManager {
triggered.lastTime = now;
this.triggeredEvents.set(eventKey, triggered);
// Update NPC's current knot if specified
if (config.knot) {
npc.currentKnot = config.knot;
console.log(`📍 Updated ${npcId} current knot to: ${config.knot}`);
// Update NPC's current knot if specified (use targetKnot or knot for backwards compatibility)
const knotToSet = config.targetKnot || config.knot;
if (knotToSet) {
npc.currentKnot = knotToSet;
console.log(`📍 Updated ${npcId} current knot to: ${knotToSet}`);
}
// Change NPC's story path if specified (switches conversation to different Ink file)
if (config.changeStoryPath) {
console.log(`📖 BEFORE changeStoryPath - npc.storyPath: ${npc.storyPath}, npc.storyJSON exists: ${!!npc.storyJSON}`);
npc.storyPath = config.changeStoryPath;
// Clear cached story state so new story loads fresh
delete npc.storyState;
delete npc.storyJSON;
// Clear cached InkEngine so it reloads with new story
if (this.inkEngineCache.has(npcId)) {
this.inkEngineCache.delete(npcId);
}
// Clear ALL conversation history (new timed message will be added fresh)
this.conversationHistory.set(npcId, []);
console.log(`📖 AFTER changeStoryPath - npc.storyPath: ${npc.storyPath}, npc.storyJSON exists: ${!!npc.storyJSON}`);
console.log(`📖 Changed ${npcId} story path to: ${config.changeStoryPath} (cleared all caches and history)`);
}
// Send timed message if specified
if (config.sendTimedMessage) {
const msgConfig = config.sendTimedMessage;
this.scheduleTimedMessage({
npcId: npcId,
text: msgConfig.message,
delay: msgConfig.delay || 0,
phoneId: npc.phoneId,
targetKnot: msgConfig.targetKnot || null
});
console.log(`📨 Scheduled timed message for ${npcId}: "${msgConfig.message}" (delay: ${msgConfig.delay}ms, targetKnot: ${msgConfig.targetKnot || 'default'})`);
}
// Debug: Log the full config to see what we're working with
@@ -473,9 +506,11 @@ export default class NPCManager {
// Start the person-chat minigame
if (window.MinigameFramework) {
console.log(`✅ Starting person-chat minigame for ${npcId}`);
const knotToUse = config.targetKnot || config.knot || npc.currentKnot;
window.MinigameFramework.startMinigame('person-chat', null, {
npcId: npc.id,
startKnot: config.knot || npc.currentKnot,
startKnot: knotToUse,
background: config.background || null,
scenario: window.gameScenario
});
console.log(`[NPCManager] Event '${eventPattern}' triggered for NPC '${npcId}' → person-chat conversation`);
@@ -602,7 +637,7 @@ export default class NPCManager {
// Schedule a timed message to be delivered after a delay
// opts: { npcId, text, triggerTime (ms from game start) OR delay (ms from now), phoneId }
scheduleTimedMessage(opts) {
const { npcId, text, triggerTime, delay, phoneId } = opts;
const { npcId, text, triggerTime, delay, phoneId, targetKnot } = opts;
if (!npcId || !text) {
console.error('[NPCManager] scheduleTimedMessage requires npcId and text');
@@ -617,6 +652,7 @@ export default class NPCManager {
text,
triggerTime: actualTriggerTime, // milliseconds from game start
phoneId: phoneId || 'player_phone',
targetKnot: targetKnot || null,
delivered: false
});
@@ -722,7 +758,7 @@ export default class NPCManager {
return;
}
// Add message to conversation history
// Add message to conversation history (represents the incoming mobile chat message)
this.addMessage(message.npcId, 'npc', message.text, {
timed: true,
phoneId: message.phoneId
@@ -741,7 +777,7 @@ export default class NPCManager {
message: message.text,
avatar: npc.avatar,
inkStoryPath: npc.storyPath,
startKnot: npc.currentKnot,
startKnot: message.targetKnot || npc.currentKnot,
phoneId: message.phoneId
});
}
@@ -749,7 +785,7 @@ export default class NPCManager {
console.log(`[NPCManager] Delivered timed message from ${message.npcId}:`, message.text);
}
// Deliver a timed conversation (start person-chat minigame at specified knot)
// Deliver a timed conversation (start person-chat or phone-chat minigame at specified knot)
_deliverTimedConversation(conversation) {
const npc = this.getNPC(conversation.npcId);
if (!npc) {
@@ -760,17 +796,28 @@ export default class NPCManager {
// Update NPC's current knot to the target knot
npc.currentKnot = conversation.targetKnot;
// Check if MinigameFramework is available to start the person-chat minigame
// Check if MinigameFramework is available to start the appropriate minigame
if (window.MinigameFramework && typeof window.MinigameFramework.startMinigame === 'function') {
console.log(`🎭 Starting timed conversation for ${conversation.npcId} at knot: ${conversation.targetKnot}`);
window.MinigameFramework.startMinigame('person-chat', null, {
npcId: conversation.npcId,
title: npc.displayName || conversation.npcId,
background: conversation.background // Optional background image path
});
// Determine which minigame type to start based on NPC type
if (npc.npcType === 'phone') {
console.log(`📱 Starting timed phone conversation for ${conversation.npcId} at knot: ${conversation.targetKnot}`);
window.MinigameFramework.startMinigame('phone-chat', null, {
npcId: conversation.npcId,
phoneId: npc.phoneId || 'player_phone',
title: 'Phone'
});
} else {
console.log(`🎭 Starting timed person conversation for ${conversation.npcId} at knot: ${conversation.targetKnot}`);
window.MinigameFramework.startMinigame('person-chat', null, {
npcId: conversation.npcId,
title: npc.displayName || conversation.npcId,
background: conversation.background // Optional background image path
});
}
} else {
console.warn(`[NPCManager] MinigameFramework not available to start person-chat for timed conversation`);
console.warn(`[NPCManager] MinigameFramework not available to start conversation for timed conversation`);
}
console.log(`[NPCManager] Delivered timed conversation from ${conversation.npcId} to knot: ${conversation.targetKnot}`);

View File

@@ -30,20 +30,7 @@ VAR audit_wrong_answers = 0 // Number of incorrect assessments
// ================================================
=== start ===
#speaker:agent_0x99
Agent 0x99: {player_name}, return to HQ for debrief.
Agent 0x99: Operation Shatter is neutralized. Let's review what happened.
+ [On my way]
-> debrief_location
// ================================================
// DEBRIEF LOCATION
// ================================================
=== debrief_location ===
[SAFETYNET HQ - Agent 0x99's Office]
#speaker:agent_0x99

View File

@@ -15,8 +15,22 @@ VAR operation_shatter_reported = false
VAR player_name = "Agent 0x00"
VAR current_task = ""
VAR talked_to_maya = false
VAR talked_to_kevin = false
VAR discussed_operation = false
// Closing debrief variables
VAR final_choice = ""
VAR objectives_completed = 0
VAR lore_collected = 0
VAR found_casualty_projections = false
VAR found_target_database = false
VAR maya_identity_protected = true
VAR kevin_choice = ""
VAR kevin_protected = false
VAR security_audit_completed = false
VAR audit_correct_answers = 0
VAR audit_wrong_answers = 0
// ================================================
// START: PHONE SUPPORT
// ================================================
@@ -485,3 +499,20 @@ Agent 0x99: Confrontation, silent extraction, or public exposure. Each has conse
Agent 0x99: Good luck, {player_name}. You've got this.
#exit_conversation
-> support_hub
// ================================================
// CLOSING DEBRIEF - Mission Complete
// ================================================
=== closing_debrief ===
#speaker:agent_0x99
Agent 0x99: Operation Shatter is neutralized. Let's review what happened.
+ [On my way]
#set_global:start_debrief_cutscene:true
#exit_conversation
-> END
#exit_conversation
-> END

File diff suppressed because one or more lines are too long

View File

@@ -285,7 +285,7 @@ password_hints = "Common passwords: Marketing123, Campaign2024, Viral_Dynamics_A
"displayName": "Sarah Martinez",
"npcType": "person",
"position": { "x": 4, "y": 1.5 },
"spriteSheet": "female_office_worker",
"spriteSheet": "female_blowse",
"spriteTalk": "assets/characters/hacker-red-talk.png",
"spriteConfig": {
"idleFrameRate": 2,
@@ -352,25 +352,56 @@ password_hints = "Common passwords: Marketing123, Campaign2024, Viral_Dynamics_A
"eventPattern": "item_picked_up:contingency_files",
"targetKnot": "event_contingency_found",
"onceOnly": true
},
{
"eventPattern": "room_entered:main_office_area",
"targetKnot": "closing_debrief",
"onceOnly": true,
"sendTimedMessage": {
"delay": 1000,
"message": "Mission complete. Return to HQ for debrief.",
"targetKnot": "closing_debrief"
}
},
{
"eventPattern": "global_variable_changed:derek_confronted",
"targetKnot": "closing_debrief",
"condition": "value === true",
"onceOnly": true,
"sendTimedMessage": {
"delay": 1000,
"message": "Mission complete. Return to HQ for debrief.",
"targetKnot": "closing_debrief"
}
}
]
},
{
"id": "closing_debrief_trigger",
"id": "closing_debrief_person",
"displayName": "Agent 0x99",
"npcType": "phone",
"storyPath": "scenarios/m01_first_contact/ink/m01_closing_debrief.json",
"avatar": "assets/characters/female_spy_headshot.png",
"phoneId": "player_phone",
"currentKnot": "start",
"eventMappings": [
"npcType": "person",
"eventMapping": [
{
"eventPattern": "global_variable_changed:derek_confronted",
"targetKnot": "start",
"eventPattern": "global_variable_changed:start_debrief_cutscene",
"condition": "value === true",
"conversationMode": "person-chat",
"targetKnot": "debrief_location",
"background": "assets/backgrounds/hq1.png",
"onceOnly": true
}
]
],
"position": { "x": 500, "y": 500 },
"spriteSheet": "female_spy",
"avatar": "assets/characters/female_spy_headshot.png",
"spriteConfig": {
"idleFrameRate": 6,
"walkFrameRate": 10
},
"storyPath": "scenarios/m01_first_contact/ink/m01_phone_agent0x99.json",
"currentKnot": "debrief_location",
"behavior": {
"initiallyHidden": true
}
}
],
"objects": [