mirror of
https://github.com/cliffe/BreakEscape.git
synced 2026-02-21 11:18:08 +00:00
Add character registry and NPC management features
- Implemented a global character registry to manage player and NPC data. - Added methods for setting the player, registering NPCs, and retrieving character information. - Integrated character registry updates into the NPC manager for seamless NPC registration. - Created test scenarios for line prefix speaker format, including narrator modes and background changes. - Developed comprehensive NPC sprite test scenario with various NPC interactions and items.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
# Global Character Registry Implementation
|
||||
|
||||
## Overview
|
||||
Implemented a global character registry system that maintains all available characters (player + NPCs) for the game, enabling reliable speaker resolution in multi-character conversations.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Character Registry System (`js/systems/character-registry.js`)
|
||||
Global object `window.characterRegistry` with methods:
|
||||
- **setPlayer(playerData)** - Register player at game initialization
|
||||
- **registerNPC(npcId, npcData)** - Register NPC when npcManager registers it
|
||||
- **getCharacter(characterId)** - Get specific character by ID
|
||||
- **getAllCharacters()** - Get complete dictionary of all characters
|
||||
- **hasCharacter(characterId)** - Check if character exists
|
||||
- **clearNPCs()** - Clear all NPCs (for scenario transitions)
|
||||
- **debug()** - Log registry state
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Initialization Phase** (game.js):
|
||||
```
|
||||
createPlayer()
|
||||
→ window.player = player
|
||||
→ characterRegistry.setPlayer(playerData)
|
||||
```
|
||||
|
||||
2. **NPC Registration Phase** (npc-manager.js):
|
||||
```
|
||||
npcManager.registerNPC(id, opts)
|
||||
→ this.npcs.set(id, entry)
|
||||
→ characterRegistry.registerNPC(id, entry) ← NEW
|
||||
```
|
||||
|
||||
3. **Person-Chat Minigame Phase** (person-chat-minigame.js):
|
||||
```
|
||||
buildCharacterIndex()
|
||||
→ if window.characterRegistry exists
|
||||
→ return characterRegistry.getAllCharacters()
|
||||
→ Otherwise fallback to legacy local building
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
✅ **Automatic Population**: NPCs automatically added when registered via npcManager
|
||||
✅ **Global Access**: Available to any minigame or system that needs speaker resolution
|
||||
✅ **Early Availability**: Player and room NPCs available before person-chat minigame starts
|
||||
✅ **Backward Compatible**: Falls back to legacy local character index building if registry unavailable
|
||||
✅ **Clean Separation**: Registry only knows about character data, not minigame logic
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **NEW: `js/systems/character-registry.js`**
|
||||
- 100+ lines of character registry implementation
|
||||
- Self-documenting with comprehensive JSDoc comments
|
||||
|
||||
2. **`js/main.js`**
|
||||
- Added import: `import './systems/character-registry.js';`
|
||||
- Ensures registry available before any scripts that need it
|
||||
|
||||
3. **`js/core/game.js`**
|
||||
- After `window.player = player`, added:
|
||||
```javascript
|
||||
if (window.characterRegistry && window.player) {
|
||||
const playerData = { id, displayName, spriteSheet, ... };
|
||||
window.characterRegistry.setPlayer(playerData);
|
||||
}
|
||||
```
|
||||
|
||||
4. **`js/systems/npc-manager.js`**
|
||||
- In `registerNPC()` method, after `this.npcs.set(realId, entry)`:
|
||||
```javascript
|
||||
if (window.characterRegistry) {
|
||||
window.characterRegistry.registerNPC(realId, entry);
|
||||
}
|
||||
```
|
||||
|
||||
5. **`js/minigames/person-chat/person-chat-minigame.js`**
|
||||
- Simplified `buildCharacterIndex()` to:
|
||||
```javascript
|
||||
if (window.characterRegistry) {
|
||||
return characterRegistry.getAllCharacters();
|
||||
}
|
||||
// Fallback to legacy local building...
|
||||
```
|
||||
|
||||
## Test Verification
|
||||
|
||||
To verify the system is working:
|
||||
|
||||
1. **Check Console Logs**:
|
||||
```
|
||||
✅ Character Registry system initialized
|
||||
✅ Character Registry: Added player (Agent 0x00)
|
||||
✅ Character Registry: Added NPC test_npc_front (displayName: Helper NPC)
|
||||
✅ Character Registry: Added NPC test_npc_back (displayName: Back NPC)
|
||||
```
|
||||
|
||||
2. **Check Character Resolution**:
|
||||
```
|
||||
👥 Using global character registry with 3 characters: ['player', 'test_npc_front', 'test_npc_back']
|
||||
```
|
||||
|
||||
3. **Check Speaker Display**:
|
||||
- When `test_npc_back: "Welcome..."` appears in dialogue
|
||||
- Should resolve to "Back NPC" (from registry displayName)
|
||||
- Not show raw ID "test_npc_back"
|
||||
|
||||
## Browser Console Debug
|
||||
|
||||
Available in browser console:
|
||||
```javascript
|
||||
window.characterRegistry.debug()
|
||||
// Output:
|
||||
// 📋 Character Registry: {
|
||||
// playerCount: 1,
|
||||
// npcCount: 5,
|
||||
// totalCharacters: 6,
|
||||
// characters: ['player', 'test_npc_front', 'test_npc_back', ...]
|
||||
// }
|
||||
|
||||
window.characterRegistry.getCharacter('test_npc_back')
|
||||
// Returns: { id, displayName: 'Back NPC', spriteSheet, ... }
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **No More Missing Secondary NPCs**: All room NPCs automatically available before minigame starts
|
||||
2. **Consistent Speaker Resolution**: Single source of truth for all characters
|
||||
3. **Extensible**: Easy to add more features (character filtering, abilities, etc.)
|
||||
4. **Debuggable**: Clear logging and debug methods for troubleshooting
|
||||
5. **Maintainable**: Centralized character management separate from minigame logic
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Reload game with hard refresh to clear cache
|
||||
2. Test line-prefix speaker format with secondary NPCs
|
||||
3. Verify both `test_npc_front` and `test_npc_back` display correct names
|
||||
4. Check console for character registry logs
|
||||
@@ -0,0 +1,126 @@
|
||||
# Global Character Registry - Quick Reference
|
||||
|
||||
## What It Does
|
||||
Maintains a single, global registry of all characters (player + NPCs) that's populated automatically as the game loads. Used by person-chat minigame for reliable speaker resolution in conversations.
|
||||
|
||||
## How to Use (Developer)
|
||||
|
||||
### Access the Registry
|
||||
```javascript
|
||||
// Get all characters
|
||||
window.characterRegistry.getAllCharacters()
|
||||
// Returns: { player: {...}, npc_id_1: {...}, npc_id_2: {...} }
|
||||
|
||||
// Get specific character
|
||||
window.characterRegistry.getCharacter('test_npc_back')
|
||||
// Returns: { id, displayName, spriteSheet, ... }
|
||||
|
||||
// Check if character exists
|
||||
window.characterRegistry.hasCharacter('some_npc')
|
||||
// Returns: true or false
|
||||
|
||||
// Debug current state
|
||||
window.characterRegistry.debug()
|
||||
```
|
||||
|
||||
### NPCs Added Automatically When:
|
||||
1. **Game initializes**: Player registered via `game.js`
|
||||
2. **NPC is registered**: Via `npcManager.registerNPC(id, opts)` in npc-lazy-loader.js
|
||||
3. **Room loads**: NPCs from scenario json automatically registered by npc-lazy-loader
|
||||
|
||||
### How Person-Chat Uses It
|
||||
```javascript
|
||||
// In person-chat-minigame.js buildCharacterIndex():
|
||||
buildCharacterIndex() {
|
||||
if (window.characterRegistry) {
|
||||
// Use global registry (has all characters already)
|
||||
return characterRegistry.getAllCharacters();
|
||||
}
|
||||
// Fallback to legacy local building if needed
|
||||
}
|
||||
```
|
||||
|
||||
## Console Debug Commands
|
||||
|
||||
```javascript
|
||||
// See all registered characters
|
||||
window.characterRegistry.debug()
|
||||
|
||||
// Check if specific NPC is registered
|
||||
window.characterRegistry.hasCharacter('test_npc_back')
|
||||
|
||||
// Get NPC display name
|
||||
window.characterRegistry.getCharacter('test_npc_back').displayName
|
||||
// Should output: "Back NPC"
|
||||
|
||||
// Get all character IDs
|
||||
Object.keys(window.characterRegistry.getAllCharacters())
|
||||
// Should output: ['player', 'test_npc_front', 'test_npc_back', ...]
|
||||
|
||||
// Clear all NPCs (for scenario transitions)
|
||||
window.characterRegistry.clearNPCs()
|
||||
```
|
||||
|
||||
## Expected Console Output
|
||||
|
||||
When game loads:
|
||||
```
|
||||
✅ Character Registry system initialized
|
||||
✅ Character Registry: Added player (Agent 0x00)
|
||||
✅ Character Registry: Added NPC test_npc_front (displayName: Helper NPC)
|
||||
✅ Character Registry: Added NPC test_npc_back (displayName: Back NPC)
|
||||
```
|
||||
|
||||
When person-chat starts:
|
||||
```
|
||||
👥 Using global character registry with 3 characters: ['player', 'test_npc_front', 'test_npc_back']
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `js/systems/character-registry.js` | NEW: 100+ lines |
|
||||
| `js/main.js` | Added import of character-registry.js |
|
||||
| `js/core/game.js` | Register player in registry after initialization |
|
||||
| `js/systems/npc-manager.js` | Register NPCs in registry when they're registered |
|
||||
| `js/minigames/person-chat/person-chat-minigame.js` | Use global registry instead of building local index |
|
||||
|
||||
## How It Fixes the Bug
|
||||
|
||||
**Before**: Secondary NPCs like `test_npc_back` weren't in the character index when person-chat minigame started, so speaker resolution failed.
|
||||
|
||||
**After**:
|
||||
1. `test_npc_back` registered in npcManager → immediately added to characterRegistry
|
||||
2. When person-chat minigame starts, it uses characterRegistry
|
||||
3. `test_npc_back` is already there with displayName "Back NPC"
|
||||
4. Line-prefix parsing works: `test_npc_back:` → resolves to "Back NPC" ✅
|
||||
|
||||
## Scenario Example
|
||||
|
||||
Room with 3 NPCs in `scenario.json`:
|
||||
```json
|
||||
"test_room": {
|
||||
"npcs": [
|
||||
{ "id": "test_npc_front", "displayName": "Helper NPC", ... },
|
||||
{ "id": "test_npc_back", "displayName": "Back NPC", ... },
|
||||
{ "id": "test_npc_influence", "displayName": "Expert", ... }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Character Registry after room loads:
|
||||
```javascript
|
||||
{
|
||||
player: { id: 'player', displayName: 'Agent 0x00', ... },
|
||||
test_npc_front: { id: 'test_npc_front', displayName: 'Helper NPC', ... },
|
||||
test_npc_back: { id: 'test_npc_back', displayName: 'Back NPC', ... },
|
||||
test_npc_influence: { id: 'test_npc_influence', displayName: 'Expert', ... }
|
||||
}
|
||||
```
|
||||
|
||||
When dialogue line is `test_npc_back: "Welcome..."`:
|
||||
1. Parser extracts speaker: `test_npc_back`
|
||||
2. Looks up in characterRegistry: ✅ found
|
||||
3. Gets displayName: "Back NPC"
|
||||
4. Shows: "Back NPC: Welcome..."
|
||||
@@ -149,6 +149,22 @@
|
||||
color: #ff9a4a;
|
||||
}
|
||||
|
||||
/* PHASE 4: Narrator mode styling */
|
||||
.person-chat-portrait-section.narrator-mode {
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.person-chat-speaker-name.narrator-speaker {
|
||||
display: none; /* Hide speaker name in narrator mode */
|
||||
}
|
||||
|
||||
/* Dialogue text styling for narrator */
|
||||
.person-chat-portrait-section.narrator-mode + .person-chat-caption-area .person-chat-dialogue-text {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Dialogue text box */
|
||||
.person-chat-dialogue-box {
|
||||
background-color: transparent;
|
||||
|
||||
@@ -532,6 +532,18 @@ export async function create() {
|
||||
// Store player globally for access from other modules
|
||||
window.player = player;
|
||||
|
||||
// Register player in global character registry for speaker resolution
|
||||
if (window.characterRegistry && window.player) {
|
||||
const playerData = {
|
||||
id: 'player',
|
||||
displayName: window.gameState?.playerName || 'Agent 0x00',
|
||||
spriteSheet: 'hacker',
|
||||
spriteTalk: 'assets/characters/hacker-talk.png',
|
||||
metadata: {}
|
||||
};
|
||||
window.characterRegistry.setPlayer(playerData);
|
||||
}
|
||||
|
||||
// Create door opening animation (for N/S doors)
|
||||
this.anims.create({
|
||||
key: 'door_open',
|
||||
|
||||
@@ -8,6 +8,9 @@ import { initializeDebugSystem } from './systems/debug.js?v=7';
|
||||
import { initializeUI } from './ui/panels.js?v=9';
|
||||
import { initializeModals } from './ui/modals.js?v=7';
|
||||
|
||||
// Import character registry system
|
||||
import './systems/character-registry.js';
|
||||
|
||||
// Import minigame framework
|
||||
import './minigames/index.js';
|
||||
|
||||
|
||||
@@ -80,15 +80,25 @@ export class PersonChatMinigame extends MinigameScene {
|
||||
this.lastResult = null; // Store last continue() result for choice handling
|
||||
this.isClickThroughMode = false; // If true, player must click to advance between dialogue lines (starts in AUTO mode)
|
||||
this.pendingContinueCallback = null; // Callback waiting for player click in click-through mode
|
||||
this.isProcessingDialogue = false; // PHASE 0: State locking to prevent race conditions during dialogue advancement
|
||||
|
||||
console.log(`🎭 PersonChatMinigame created for NPC: ${this.npcId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build index of all available characters (player + NPCs)
|
||||
* Uses global character registry populated as NPCs are registered
|
||||
* @returns {Object} Map of character ID to character data
|
||||
*/
|
||||
buildCharacterIndex() {
|
||||
// Use global character registry if available
|
||||
if (window.characterRegistry) {
|
||||
const allCharacters = window.characterRegistry.getAllCharacters();
|
||||
console.log(`👥 Using global character registry with ${Object.keys(allCharacters).length} characters:`, Object.keys(allCharacters));
|
||||
return allCharacters;
|
||||
}
|
||||
|
||||
// Fallback to legacy local building if registry not available
|
||||
const characters = {};
|
||||
|
||||
// Add player
|
||||
@@ -98,12 +108,13 @@ export class PersonChatMinigame extends MinigameScene {
|
||||
characters[this.npc.id] = this.npc;
|
||||
|
||||
// Add other NPCs from current room (NPCs are now per-room, not at scenario root)
|
||||
const currentRoom = window.currentRoom || this.npc.roomId;
|
||||
// Use npc.roomId first, fallback to window.currentRoom
|
||||
const currentRoom = this.npc.roomId || window.currentRoom;
|
||||
if (currentRoom && this.scenario.rooms && this.scenario.rooms[currentRoom]) {
|
||||
const roomNPCs = this.scenario.rooms[currentRoom].npcs || [];
|
||||
roomNPCs.forEach(npc => {
|
||||
if (npc.id !== this.npc.id) {
|
||||
// Look up NPC data from npcManager for complete displayName
|
||||
// Look up NPC data from npcManager for complete displayName and other properties
|
||||
const npcData = window.npcManager?.getNPC(npc.id) || npc;
|
||||
characters[npc.id] = npcData;
|
||||
}
|
||||
@@ -111,7 +122,7 @@ export class PersonChatMinigame extends MinigameScene {
|
||||
}
|
||||
|
||||
// Fallback to legacy root-level NPCs for backward compatibility
|
||||
if (!Object.keys(characters).length > 2 && this.scenario.npcs && Array.isArray(this.scenario.npcs)) {
|
||||
if (Object.keys(characters).length <= 2 && this.scenario.npcs && Array.isArray(this.scenario.npcs)) {
|
||||
this.scenario.npcs.forEach(npc => {
|
||||
if (npc.id !== this.npc.id && !characters[npc.id]) {
|
||||
characters[npc.id] = npc;
|
||||
@@ -544,6 +555,182 @@ export class PersonChatMinigame extends MinigameScene {
|
||||
return this.npc.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* PHASE 1: Parse a dialogue line for speaker prefix format
|
||||
*
|
||||
* Validates that dialogue text is not empty (ignores "Speaker: " lines)
|
||||
* Case-insensitive speaker IDs ("Player:", "player:", "PLAYER:" all work)
|
||||
* First colon is delimiter ("Speaker: Text: with: colons" → speaker="Speaker", text="Text: with: colons")
|
||||
* Rejects prefixes where speaker ID doesn't exist in character index
|
||||
* Handles Narrator[character_id]: syntax for narrator with character portrait
|
||||
*
|
||||
* @param {string} line - Dialogue line to parse
|
||||
* @returns {Object|null} Object with {speaker, text, isNarrator, narratorCharacter} or null if no prefix
|
||||
*/
|
||||
parseDialogueLine(line) {
|
||||
if (!line || typeof line !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
line = line.trim();
|
||||
if (!line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for Narrator[character]: pattern first (highest priority)
|
||||
const narratorMatch = line.match(/^Narrator\s*\[\s*([^\]]*)\s*\]\s*:\s*(.+)$/i);
|
||||
if (narratorMatch) {
|
||||
const characterId = narratorMatch[1].trim();
|
||||
const text = narratorMatch[2].trim();
|
||||
|
||||
// Must have non-empty text
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If character ID is provided, validate it exists
|
||||
if (characterId && !this.characters[characterId]) {
|
||||
console.warn(`⚠️ parseDialogueLine: Unknown character in Narrator[${characterId}], treating as unprefixed`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
speaker: 'narrator',
|
||||
text: text,
|
||||
isNarrator: true,
|
||||
narratorCharacter: characterId || null
|
||||
};
|
||||
}
|
||||
|
||||
// Check for basic Narrator: pattern
|
||||
const basicNarratorMatch = line.match(/^Narrator\s*:\s*(.+)$/i);
|
||||
if (basicNarratorMatch) {
|
||||
const text = basicNarratorMatch[1].trim();
|
||||
|
||||
// Must have non-empty text
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
speaker: 'narrator',
|
||||
text: text,
|
||||
isNarrator: true,
|
||||
narratorCharacter: null
|
||||
};
|
||||
}
|
||||
|
||||
// Check for regular Speaker: pattern
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex === -1) {
|
||||
// No colon - not a prefixed line
|
||||
return null;
|
||||
}
|
||||
|
||||
const speakerId = line.substring(0, colonIndex).trim();
|
||||
const text = line.substring(colonIndex + 1).trim();
|
||||
|
||||
// Speaker ID must not be empty
|
||||
if (!speakerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Text must not be empty (reject lines like "Speaker: ")
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate speaker exists in characters
|
||||
const normalizedSpeaker = this.normalizeSpeakerId(speakerId);
|
||||
if (!normalizedSpeaker) {
|
||||
// Speaker not found - treat as unprefixed line
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
speaker: normalizedSpeaker,
|
||||
text: text,
|
||||
isNarrator: false,
|
||||
narratorCharacter: null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PHASE 1: Normalize speaker ID for consistent lookup
|
||||
*
|
||||
* Converts raw speaker ID to canonical form and validates existence
|
||||
* Returns null if speaker ID doesn't exist in character index
|
||||
*
|
||||
* @param {string} speakerId - Raw speaker ID from dialogue line
|
||||
* @returns {string|null} Normalized speaker ID or null if invalid
|
||||
*/
|
||||
normalizeSpeakerId(speakerId) {
|
||||
if (!speakerId || typeof speakerId !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = speakerId.toLowerCase().trim();
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle special cases
|
||||
if (normalized === 'player') {
|
||||
return this.characters['player'] ? 'player' : null;
|
||||
}
|
||||
|
||||
if (normalized === 'npc') {
|
||||
return this.npc.id;
|
||||
}
|
||||
|
||||
// Look up by ID (case-sensitive after normalization to lowercase)
|
||||
for (const [id, character] of Object.entries(this.characters)) {
|
||||
if (id.toLowerCase() === normalized) {
|
||||
return id; // Return original casing
|
||||
}
|
||||
}
|
||||
|
||||
// Not found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PHASE 4.5: Parse a background change line
|
||||
*
|
||||
* Background syntax: Background[filename]: optional text after (ignored)
|
||||
* Example: "Background[scary-room.png]: The room transforms..."
|
||||
*
|
||||
* @param {string} line - Dialogue line to parse
|
||||
* @returns {string|null} Background filename if valid, null otherwise
|
||||
*/
|
||||
parseBackgroundLine(line) {
|
||||
if (!line || typeof line !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
line = line.trim();
|
||||
if (!line) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Match Background[filename]: optional text pattern
|
||||
const bgMatch = line.match(/^Background\s*\[\s*([^\]]+)\s*\]\s*(?::\s*(.*))?$/i);
|
||||
if (!bgMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filename = bgMatch[1].trim();
|
||||
|
||||
// Background filename must not be empty
|
||||
if (!filename) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`🎨 PHASE 4.5: Parsed background change to: ${filename}`);
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle choice selection
|
||||
* @param {number} choiceIndex - Index of selected choice
|
||||
@@ -631,112 +818,170 @@ export class PersonChatMinigame extends MinigameScene {
|
||||
* @param {Object} result - Result with potentially multiple lines and tags
|
||||
*/
|
||||
displayAccumulatedDialogue(result) {
|
||||
if (!result.text || !result.tags) {
|
||||
// No content to display
|
||||
if (result.hasEnded) {
|
||||
// Story ended - save state and show message
|
||||
if (this.inkEngine && this.inkEngine.story) {
|
||||
npcConversationStateManager.saveNPCState(this.npcId, this.inkEngine.story);
|
||||
}
|
||||
this.ui.showDialogue('(Conversation ended - press ESC to close)', 'system');
|
||||
console.log('🏁 Story has reached an end point');
|
||||
}
|
||||
// PHASE 0: State locking to prevent race conditions during rapid dialogue advancement
|
||||
if (this.isProcessingDialogue) {
|
||||
console.log('⏳ Already processing dialogue, ignoring call');
|
||||
return;
|
||||
}
|
||||
this.isProcessingDialogue = true;
|
||||
|
||||
// Process any game action tags (give_item, unlock_door, etc.) BEFORE displaying dialogue
|
||||
if (result.tags && result.tags.length > 0) {
|
||||
console.log('🏷️ Processing action tags from accumulated dialogue:', result.tags);
|
||||
processGameActionTags(result.tags, this.ui);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Story ended - save state and show message
|
||||
if (this.inkEngine && this.inkEngine.story) {
|
||||
npcConversationStateManager.saveNPCState(this.npcId, this.inkEngine.story);
|
||||
try {
|
||||
if (!result.text || !result.tags) {
|
||||
// No content to display
|
||||
if (result.hasEnded) {
|
||||
// Story ended - save state and show message
|
||||
if (this.inkEngine && this.inkEngine.story) {
|
||||
npcConversationStateManager.saveNPCState(this.npcId, this.inkEngine.story);
|
||||
}
|
||||
this.ui.showDialogue('(Conversation ended - press ESC to close)', 'system');
|
||||
console.log('🏁 Story has reached an end point');
|
||||
}
|
||||
this.ui.showDialogue('(Conversation ended - press ESC to close)', 'system');
|
||||
console.log('🏁 Story has reached an end point');
|
||||
return;
|
||||
}
|
||||
return;
|
||||
|
||||
// Process any game action tags (give_item, unlock_door, etc.) BEFORE displaying dialogue
|
||||
if (result.tags && result.tags.length > 0) {
|
||||
console.log('🏷️ Processing action tags from accumulated dialogue:', result.tags);
|
||||
processGameActionTags(result.tags, this.ui);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Story ended - save state and show message
|
||||
if (this.inkEngine && this.inkEngine.story) {
|
||||
npcConversationStateManager.saveNPCState(this.npcId, this.inkEngine.story);
|
||||
}
|
||||
this.ui.showDialogue('(Conversation ended - press ESC to close)', 'system');
|
||||
console.log('🏁 Story has reached an end point');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create dialogue blocks: each block is one or more consecutive lines with the same speaker
|
||||
// PHASE 0: Pass result object so createDialogueBlocks can use determineSpeaker() for tag-based fallback
|
||||
const dialogueBlocks = this.createDialogueBlocks(lines, result.tags, result);
|
||||
|
||||
// Display blocks sequentially with delays
|
||||
this.displayDialogueBlocksSequentially(dialogueBlocks, result, 0);
|
||||
} finally {
|
||||
// PHASE 0: Unlock state on all exit paths (including errors)
|
||||
this.isProcessingDialogue = false;
|
||||
}
|
||||
|
||||
// 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
|
||||
* When speaker tags are missing, dialogue defaults to the main NPC being talked to
|
||||
*
|
||||
* PHASE 3: Enhanced to support line prefix format
|
||||
* PHASE 4.5: Enhanced to detect and extract background changes
|
||||
* - First checks if line is background change (Background[...])
|
||||
* - Then tries to parse line prefix format using parseDialogueLine()
|
||||
* - Falls back to tag-based grouping if no prefix found
|
||||
* - Handles speaker changes mid-dialogue
|
||||
* - Groups consecutive lines from same speaker into single block
|
||||
*
|
||||
* @param {Array<string>} lines - Text lines
|
||||
* @param {Array<string>} tags - Speaker tags
|
||||
* @returns {Array<Object>} Array of {speaker, text} blocks
|
||||
* @param {Array<string>} tags - Speaker tags (for fallback)
|
||||
* @param {Object} result - Result object for tag-based fallback
|
||||
* @returns {Array<Object>} Array of {speaker, text, isNarrator, narratorCharacter, backgroundChange} blocks
|
||||
*/
|
||||
createDialogueBlocks(lines, tags) {
|
||||
createDialogueBlocks(lines, tags, result) {
|
||||
const blocks = [];
|
||||
let blockIndex = 0;
|
||||
let currentSpeaker = null;
|
||||
let currentText = '';
|
||||
let currentIsNarrator = false;
|
||||
let currentNarratorCharacter = null;
|
||||
let currentBackgroundChange = null;
|
||||
|
||||
// Special case: NO tags at all - all lines belong to main NPC
|
||||
if (!tags || tags.length === 0) {
|
||||
if (lines.length > 0) {
|
||||
const allText = lines.join('\n').trim();
|
||||
if (allText) {
|
||||
blocks.push({ speaker: this.npc.id, text: allText, tag: null });
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// PHASE 4.5: Check for background change first
|
||||
const bgChange = this.parseBackgroundLine(line);
|
||||
if (bgChange) {
|
||||
// Save current block if we have one
|
||||
if (currentSpeaker !== null && currentText.trim()) {
|
||||
blocks.push({
|
||||
speaker: currentSpeaker,
|
||||
text: currentText.trim(),
|
||||
isNarrator: currentIsNarrator,
|
||||
narratorCharacter: currentNarratorCharacter,
|
||||
backgroundChange: currentBackgroundChange
|
||||
});
|
||||
}
|
||||
|
||||
// Create background-only block
|
||||
blocks.push({
|
||||
speaker: null,
|
||||
text: '',
|
||||
isNarrator: false,
|
||||
narratorCharacter: null,
|
||||
backgroundChange: bgChange
|
||||
});
|
||||
|
||||
// Reset for next speaker
|
||||
currentSpeaker = null;
|
||||
currentText = '';
|
||||
currentIsNarrator = false;
|
||||
currentNarratorCharacter = null;
|
||||
currentBackgroundChange = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to parse line prefix format
|
||||
const parsed = this.parseDialogueLine(line);
|
||||
|
||||
if (parsed) {
|
||||
// This line has a prefix - speaker changed!
|
||||
// First, save current block if we have one
|
||||
if (currentSpeaker !== null && currentText.trim()) {
|
||||
blocks.push({
|
||||
speaker: currentSpeaker,
|
||||
text: currentText.trim(),
|
||||
isNarrator: currentIsNarrator,
|
||||
narratorCharacter: currentNarratorCharacter,
|
||||
backgroundChange: currentBackgroundChange
|
||||
});
|
||||
}
|
||||
|
||||
// Start new block with parsed line
|
||||
currentSpeaker = parsed.speaker;
|
||||
currentText = parsed.text;
|
||||
currentIsNarrator = parsed.isNarrator;
|
||||
currentNarratorCharacter = parsed.narratorCharacter;
|
||||
currentBackgroundChange = null;
|
||||
} else {
|
||||
// No prefix - continues current speaker
|
||||
if (currentSpeaker === null) {
|
||||
// First line without prefix - use tag-based or default speaker
|
||||
currentSpeaker = this.determineSpeaker(result);
|
||||
currentIsNarrator = false;
|
||||
currentNarratorCharacter = null;
|
||||
currentBackgroundChange = null;
|
||||
}
|
||||
|
||||
// Add to current text (newline-separated)
|
||||
currentText += (currentText ? '\n' : '') + line;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// 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
|
||||
// Default to main NPC if no speaker tag found
|
||||
let speaker = this.npc.id;
|
||||
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;
|
||||
// Don't forget the last block!
|
||||
if (currentSpeaker !== null && currentText.trim()) {
|
||||
blocks.push({
|
||||
speaker: currentSpeaker,
|
||||
text: currentText.trim(),
|
||||
isNarrator: currentIsNarrator,
|
||||
narratorCharacter: currentNarratorCharacter,
|
||||
backgroundChange: currentBackgroundChange
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📝 PHASE 3: createDialogueBlocks created ${blocks.length} blocks from ${lines.length} lines`);
|
||||
return blocks;
|
||||
}
|
||||
|
||||
@@ -796,6 +1041,26 @@ export class PersonChatMinigame extends MinigameScene {
|
||||
|
||||
// Display current block's lines one at a time with accumulation
|
||||
const block = blocks[blockIndex];
|
||||
|
||||
// PHASE 4.5: Handle background changes before displaying dialogue
|
||||
if (block.backgroundChange) {
|
||||
console.log(`🎨 Background change block detected: ${block.backgroundChange}`);
|
||||
|
||||
// Change background and move to next block
|
||||
this.changeBackground(block.backgroundChange);
|
||||
|
||||
this.scheduleDialogueAdvance(() => {
|
||||
this.displayDialogueBlocksSequentially(blocks, originalResult, blockIndex + 1, 0, '');
|
||||
}, DIALOGUE_AUTO_ADVANCE_DELAY);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip empty dialogue blocks
|
||||
if (!block.text || !block.text.trim()) {
|
||||
this.displayDialogueBlocksSequentially(blocks, originalResult, blockIndex + 1, 0, '');
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = block.text.split('\n').filter(line => line.trim());
|
||||
|
||||
if (lineIndex >= lines.length) {
|
||||
@@ -810,8 +1075,14 @@ export class PersonChatMinigame extends MinigameScene {
|
||||
|
||||
console.log(`📋 Displaying line ${lineIndex + 1}/${lines.length} from block ${blockIndex + 1}/${blocks.length}: ${block.speaker}`);
|
||||
|
||||
// Show accumulated text (all lines up to and including current line)
|
||||
this.ui.showDialogue(newAccumulatedText, block.speaker);
|
||||
// PHASE 4: Show accumulated text with narrator support
|
||||
this.ui.showDialogue(
|
||||
newAccumulatedText,
|
||||
block.speaker,
|
||||
false, // preserveChoices
|
||||
block.isNarrator || false, // isNarrator
|
||||
block.narratorCharacter || null // narratorCharacter
|
||||
);
|
||||
|
||||
// Display next line after delay
|
||||
this.scheduleDialogueAdvance(() => {
|
||||
@@ -1011,6 +1282,32 @@ export class PersonChatMinigame extends MinigameScene {
|
||||
super.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* PHASE 4.5: Change background image for current portrait
|
||||
*
|
||||
* Updates the portrait renderer's background image and re-renders
|
||||
*
|
||||
* @param {string} backgroundFilename - Filename of new background image
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async changeBackground(backgroundFilename) {
|
||||
if (!backgroundFilename || !this.ui || !this.ui.portraitRenderer) {
|
||||
console.warn(`⚠️ changeBackground: Invalid background or portrait renderer`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🎨 Changing background to: ${backgroundFilename}`);
|
||||
|
||||
// Call setBackground to load and render the new background
|
||||
this.ui.portraitRenderer.setBackground(backgroundFilename);
|
||||
|
||||
console.log(`✅ Background changed successfully`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error changing background: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
* @param {string} message - Error message to display
|
||||
|
||||
@@ -336,6 +336,22 @@ export default class PersonChatPortraits {
|
||||
img.src = bgSrc;
|
||||
}
|
||||
|
||||
/**
|
||||
* PHASE 4.5: Change background to a new image
|
||||
* @param {string} newBackgroundPath - Path to new background image
|
||||
*/
|
||||
setBackground(newBackgroundPath) {
|
||||
if (!newBackgroundPath) {
|
||||
console.warn('⚠️ setBackground: No background path provided');
|
||||
return;
|
||||
}
|
||||
|
||||
this.backgroundPath = newBackgroundPath;
|
||||
this.backgroundImage = null; // Clear old image
|
||||
console.log(`🎨 Setting new background: ${newBackgroundPath}`);
|
||||
this.loadBackgroundImage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw background image at same pixel scale as character sprite
|
||||
* Fills the canvas while maintaining sprite's pixel scale (may extend beyond canvas if larger)
|
||||
@@ -685,6 +701,30 @@ export default class PersonChatPortraits {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PHASE 4: Clear the portrait canvas (for narrator mode without portrait)
|
||||
*/
|
||||
clearPortrait() {
|
||||
if (!this.canvas || !this.ctx) return;
|
||||
|
||||
// Clear canvas to black
|
||||
this.ctx.fillStyle = '#000';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Draw placeholder text
|
||||
this.ctx.fillStyle = '#666';
|
||||
this.ctx.font = '16px Arial';
|
||||
this.ctx.textAlign = 'center';
|
||||
this.ctx.textBaseline = 'middle';
|
||||
this.ctx.fillText(
|
||||
'Narrator',
|
||||
this.canvas.width / 2,
|
||||
this.canvas.height / 2
|
||||
);
|
||||
|
||||
console.log('🖼️ Portrait cleared for narrator mode');
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy portrait and cleanup
|
||||
*/
|
||||
|
||||
@@ -212,47 +212,79 @@ export default class PersonChatUI {
|
||||
* @param {string} text - Dialogue text to display
|
||||
* @param {string} characterId - Character ID ('player', 'npc', or specific NPC ID)
|
||||
* @param {boolean} preserveChoices - If true, don't hide existing choices
|
||||
* @param {boolean} isNarrator - PHASE 4: If true, display as narrator mode (centered, no speaker name)
|
||||
* @param {string} narratorCharacter - PHASE 4: For Narrator[character]: format, character to show portrait of
|
||||
*/
|
||||
showDialogue(text, characterId = 'npc', preserveChoices = false) {
|
||||
showDialogue(text, characterId = 'npc', preserveChoices = false, isNarrator = false, narratorCharacter = null) {
|
||||
this.currentSpeaker = characterId;
|
||||
|
||||
console.log(`📝 showDialogue called with character: ${characterId}, text length: ${text?.length || 0}`);
|
||||
console.log(`📝 showDialogue called with character: ${characterId}, text length: ${text?.length || 0}, narrator: ${isNarrator}`);
|
||||
|
||||
// Get character data
|
||||
let character = this.characters[characterId];
|
||||
if (!character) {
|
||||
// Fallback for legacy speaker values or main NPC ID
|
||||
if (characterId === 'player') {
|
||||
character = this.playerData;
|
||||
} else if (characterId === 'npc' || !characterId) {
|
||||
character = this.npc;
|
||||
} else if (characterId === this.npc?.id) {
|
||||
// Main NPC passed by ID - use main NPC data
|
||||
character = this.npc;
|
||||
// PHASE 4: Handle narrator mode
|
||||
if (isNarrator) {
|
||||
// Narrator mode - centered text, no speaker name
|
||||
this.elements.portraitSection.className = 'person-chat-portrait-section narrator-mode';
|
||||
this.elements.speakerName.className = 'person-chat-speaker-name narrator-speaker';
|
||||
this.elements.portraitLabel.textContent = ''; // No label in narrator mode
|
||||
this.elements.speakerName.textContent = 'Narrator'; // Hidden by CSS but available
|
||||
|
||||
// If narratorCharacter is specified, show that character's portrait
|
||||
if (narratorCharacter) {
|
||||
const character = this.characters[narratorCharacter];
|
||||
if (character) {
|
||||
this.updatePortraitForSpeaker(narratorCharacter, character);
|
||||
}
|
||||
} else {
|
||||
// No character portrait in pure narrator mode
|
||||
if (this.portraitRenderer) {
|
||||
this.portraitRenderer.clearPortrait();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📝 Narrator mode: character=${narratorCharacter}, portrait shown=${!!narratorCharacter}`);
|
||||
} else {
|
||||
// Normal dialogue mode
|
||||
// Get character data
|
||||
let character = this.characters[characterId];
|
||||
if (!character) {
|
||||
// Fallback for legacy speaker values or main NPC ID
|
||||
if (characterId === 'player') {
|
||||
character = this.playerData;
|
||||
} else if (characterId === 'npc' || !characterId) {
|
||||
character = this.npc;
|
||||
} else if (characterId === this.npc?.id) {
|
||||
// Main NPC passed by ID - use main NPC data
|
||||
character = this.npc;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine display name
|
||||
// If no character found, use the character ID itself formatted as display name
|
||||
const displayName = character?.displayName || (characterId === 'player' ? 'You' :
|
||||
characterId === 'npc' ? 'NPC' :
|
||||
// Format character ID: convert snake_case or camelCase to Title Case
|
||||
characterId.replace(/[_-]/g, ' ').replace(/\b\w/g, l => l.toUpperCase()));
|
||||
|
||||
const speakerType = characterId === 'player' ? 'player' : 'npc';
|
||||
|
||||
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-${speakerType}`;
|
||||
this.elements.speakerName.className = `person-chat-speaker-name ${speakerType}-speaker`;
|
||||
|
||||
// Reset portrait for new speaker
|
||||
this.updatePortraitForSpeaker(characterId, character);
|
||||
}
|
||||
|
||||
// Determine display name
|
||||
const displayName = character?.displayName || (characterId === 'player' ? 'You' : 'NPC');
|
||||
const speakerType = characterId === 'player' ? 'player' : 'npc';
|
||||
|
||||
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-${speakerType}`;
|
||||
this.elements.speakerName.className = `person-chat-speaker-name ${speakerType}-speaker`;
|
||||
|
||||
// Update dialogue text
|
||||
this.elements.dialogueText.textContent = text;
|
||||
|
||||
console.log(`📝 Set dialogue text, element content: "${this.elements.dialogueText.textContent}"`);
|
||||
|
||||
// Reset portrait for new speaker
|
||||
this.updatePortraitForSpeaker(characterId, character);
|
||||
|
||||
// Hide choices only if not preserving them (i.e., when transitioning from choices back to text)
|
||||
if (!preserveChoices) {
|
||||
this.hideChoices();
|
||||
|
||||
99
public/break_escape/js/systems/character-registry.js
Normal file
99
public/break_escape/js/systems/character-registry.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Global Character Registry
|
||||
* ========================
|
||||
* Maintains a registry of all characters (player, NPCs) available in the game.
|
||||
* This registry is populated as NPCs are registered via npcManager, and as rooms are loaded.
|
||||
* The person-chat minigame uses this registry for speaker resolution.
|
||||
*
|
||||
* When an NPC is registered via npcManager.registerNPC(), it's automatically added here.
|
||||
* Format: { id: { id, displayName, spriteSheet, spriteTalk, ... }, ... }
|
||||
*/
|
||||
|
||||
window.characterRegistry = {
|
||||
// Player character - set when game initializes
|
||||
player: null,
|
||||
|
||||
// All NPCs registered in the game
|
||||
npcs: {},
|
||||
|
||||
/**
|
||||
* Add player to registry
|
||||
* @param {Object} playerData - Player object with id, displayName, etc.
|
||||
*/
|
||||
setPlayer(playerData) {
|
||||
this.player = playerData;
|
||||
console.log(`✅ Character Registry: Added player (${playerData.displayName})`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Register an NPC in the character registry
|
||||
* Called automatically when npcManager.registerNPC() is invoked
|
||||
* @param {string} npcId - NPC identifier
|
||||
* @param {Object} npcData - Full NPC data object
|
||||
*/
|
||||
registerNPC(npcId, npcData) {
|
||||
this.npcs[npcId] = npcData;
|
||||
console.log(`✅ Character Registry: Added NPC ${npcId} (displayName: ${npcData.displayName})`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a character (player or NPC) by ID
|
||||
* @param {string} characterId - Character identifier
|
||||
* @returns {Object|null} Character data or null if not found
|
||||
*/
|
||||
getCharacter(characterId) {
|
||||
if (characterId === 'player') {
|
||||
return this.player;
|
||||
}
|
||||
return this.npcs[characterId] || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all available characters for speaker resolution
|
||||
* Combines player and all registered NPCs
|
||||
* @returns {Object} Dictionary of all characters
|
||||
*/
|
||||
getAllCharacters() {
|
||||
const all = {};
|
||||
if (this.player) {
|
||||
all['player'] = this.player;
|
||||
}
|
||||
Object.assign(all, this.npcs);
|
||||
return all;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a character exists in registry
|
||||
* @param {string} characterId - Character identifier
|
||||
* @returns {boolean} True if character exists
|
||||
*/
|
||||
hasCharacter(characterId) {
|
||||
if (characterId === 'player') {
|
||||
return this.player !== null;
|
||||
}
|
||||
return characterId in this.npcs;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all registered NPCs (used for scenario transitions)
|
||||
*/
|
||||
clearNPCs() {
|
||||
this.npcs = {};
|
||||
console.log(`🗑️ Character Registry: Cleared all NPCs`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Debug: Log current registry state
|
||||
*/
|
||||
debug() {
|
||||
const chars = Object.keys(this.getAllCharacters());
|
||||
console.log(`📋 Character Registry:`, {
|
||||
playerCount: this.player ? 1 : 0,
|
||||
npcCount: Object.keys(this.npcs).length,
|
||||
totalCharacters: chars.length,
|
||||
characters: chars
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
console.log('✅ Character Registry system initialized');
|
||||
@@ -77,6 +77,11 @@ export default class NPCManager {
|
||||
|
||||
this.npcs.set(realId, entry);
|
||||
|
||||
// Register in global character registry for speaker resolution
|
||||
if (window.characterRegistry) {
|
||||
window.characterRegistry.registerNPC(realId, entry);
|
||||
}
|
||||
|
||||
// Initialize conversation history for this NPC
|
||||
if (!this.conversationHistory.has(realId)) {
|
||||
this.conversationHistory.set(realId, []);
|
||||
|
||||
170
scenarios/ink/test-line-prefix.ink
Normal file
170
scenarios/ink/test-line-prefix.ink
Normal file
@@ -0,0 +1,170 @@
|
||||
// Test Line Prefix Speaker Format
|
||||
// Tests for PHASE 1-4.5: Speaker prefixes, narrator mode, background changes
|
||||
//
|
||||
// Test Coverage:
|
||||
// - Line prefix format: "Speaker: Text"
|
||||
// - Narrator mode: "Narrator: Text"
|
||||
// - Narrator with character: "Narrator[character]: Text"
|
||||
// - Background changes: "Background[filename]: optional text"
|
||||
// - Mixed format with traditional tags
|
||||
// - Multi-line dialogue with speaker changes
|
||||
|
||||
VAR test_phase = 0
|
||||
|
||||
=== start ===
|
||||
~ test_phase = 1
|
||||
PHASE 1: Line Prefix Speaker Format
|
||||
Please observe the dialogue displays with speaker names derived from line prefixes.
|
||||
-> phase_1_basic
|
||||
|
||||
=== phase_1_basic ===
|
||||
// Test basic line prefix format with regular speakers
|
||||
// Each line should show the speaker name correctly from the prefix
|
||||
|
||||
Player: Hello! I'm here for the security briefing.
|
||||
test_npc_front: Welcome to the facility. I'll run through the basics for you.
|
||||
test_npc_front: We use biometric authentication and keycard access throughout the building.
|
||||
Player: That sounds comprehensive. Any recent incidents?
|
||||
test_npc_front: Nothing major. System updates last week, everything's stable now.
|
||||
|
||||
+ [Continue to Phase 2] -> phase_2_narrator
|
||||
+ [Repeat Phase 1] -> phase_1_basic
|
||||
|
||||
=== phase_2_narrator ===
|
||||
~ test_phase = 2
|
||||
PHASE 2: Narrator Mode Testing
|
||||
Narrator: The sun begins to set over the facility grounds. An eerie silence fills the corridors.
|
||||
Narrator: Your briefing complete, you prepare to move deeper into the building.
|
||||
|
||||
+ [Continue] -> phase_2_narrator_with_character
|
||||
|
||||
=== phase_2_narrator_with_character ===
|
||||
// Narrator with character portrait displayed
|
||||
Narrator[test_npc_front]: The guard glances nervously at the security monitors.
|
||||
Narrator[test_npc_front]: Something seems different about the usual patrol rotation today.
|
||||
|
||||
+ [Continue] -> phase_3_background_changes
|
||||
|
||||
=== phase_3_background_changes ===
|
||||
~ test_phase = 3
|
||||
PHASE 3: Background Changes Testing
|
||||
Background[lab-normal.png]: The laboratory appears normal at first glance.
|
||||
test_npc_back: Welcome to the lab. Everything is operating within normal parameters.
|
||||
Background[lab-alert.png]: Suddenly, the room plunges into emergency lighting!
|
||||
test_npc_back: Wait... the alarm system just triggered. Something's wrong with the main servers!
|
||||
test_npc_back: We need to get to the server room immediately!
|
||||
|
||||
+ [Continue] -> phase_4_mixed_format
|
||||
|
||||
=== phase_4_mixed_format ===
|
||||
~ test_phase = 4
|
||||
PHASE 4: Mixed Format Testing
|
||||
Now we'll test mixing line prefix format with traditional tag-based dialogue.
|
||||
Test that both systems work together smoothly.
|
||||
|
||||
Player: What's the emergency protocol?
|
||||
test_npc_front: Protocol Alpha - all personnel to designated zones.
|
||||
test_npc_front: The facility goes into lockdown automatically.
|
||||
Narrator: Red emergency lights begin flashing throughout the building.
|
||||
Player: How long until the lockdown is complete?
|
||||
test_npc_front: Usually takes about 30 seconds for all doors to secure.
|
||||
test_npc_back: The server room needs to stay accessible for diagnostics!
|
||||
|
||||
+ [Continue] -> phase_5_speaker_changes
|
||||
|
||||
=== phase_5_speaker_changes ===
|
||||
~ test_phase = 5
|
||||
PHASE 5: Multi-Speaker Dialogue Testing
|
||||
Testing rapid speaker changes using line prefixes.
|
||||
|
||||
Player: Who's in charge here?
|
||||
test_npc_front: I am, in the security department.
|
||||
test_npc_back: But I'm responsible for the lab systems.
|
||||
test_npc_front: Right, so we need both perspectives.
|
||||
Player: What do you recommend?
|
||||
test_npc_back: We need lab access first.
|
||||
test_npc_front: I'll authorize it - follow me.
|
||||
Narrator: The tension in the room is palpable as both professionals prepare for action.
|
||||
|
||||
+ [Continue] -> phase_6_narrator_variations
|
||||
|
||||
=== phase_6_narrator_variations ===
|
||||
~ test_phase = 6
|
||||
PHASE 6: Narrator Variations Testing
|
||||
|
||||
Narrator: Pure narrator - no character portrait shown
|
||||
Narrator[test_npc_back]: Narrator with test_npc_back portrait
|
||||
Narrator[test_npc_front]: Narrator with security guard portrait
|
||||
Background[hallway-dark.png]: The hallway descends into darkness
|
||||
Narrator: Another narration after background change
|
||||
Player: Are we ready to proceed?
|
||||
Narrator[test_npc_front]: The guard nods solemnly
|
||||
|
||||
+ [Continue] -> phase_7_comprehensive
|
||||
|
||||
=== phase_7_comprehensive ===
|
||||
~ test_phase = 7
|
||||
PHASE 7: Comprehensive Scene
|
||||
A scene combining all features.
|
||||
|
||||
Background[facility-entrance.png]: You stand at the facility entrance
|
||||
Narrator[test_npc_front]: The security guard approaches your position
|
||||
test_npc_front: Welcome to the facility. I hope you're here for a good reason.
|
||||
Player: I'm here to investigate the recent incident.
|
||||
test_npc_front: Incident? There is no incident.
|
||||
Background[security-office.png]: The guard escorts you to the security office
|
||||
Narrator: Inside, screens display various security feeds
|
||||
Narrator[test_npc_back]: From a monitor, a test_npc_back's face appears urgently
|
||||
test_npc_back: We need to talk. Alone.
|
||||
test_npc_front: What's this about?
|
||||
test_npc_back: It's classified. Speaker confidential access only.
|
||||
Narrator: The guard looks conflicted but nods slowly
|
||||
test_npc_front: Alright. Go ahead. But I'm logging this.
|
||||
|
||||
+ [Investigate Further] -> phase_8_edge_cases
|
||||
+ [Conclude Test] -> ending
|
||||
|
||||
=== phase_8_edge_cases ===
|
||||
~ test_phase = 8
|
||||
PHASE 8: Edge Cases & Stress Testing
|
||||
|
||||
// Multiple consecutive narrator lines
|
||||
Narrator: The tension builds.
|
||||
Narrator: Everything is at stake.
|
||||
Narrator: Time seems to slow down.
|
||||
|
||||
// Multiple consecutive same speaker
|
||||
Player: I need answers.
|
||||
Player: Real answers.
|
||||
Player: Not more evasions.
|
||||
|
||||
// Rapid background changes
|
||||
Background[room-a.png]: Location: Room A
|
||||
Background[room-b.png]: Location: Room B
|
||||
Background[room-c.png]: Location: Room C
|
||||
|
||||
// Mixed speakers after backgrounds
|
||||
test_npc_back: The data is clear.
|
||||
test_npc_front: But the implications are severe.
|
||||
Player: We need to act fast.
|
||||
Narrator: The three of you exchange worried glances.
|
||||
|
||||
+ [Conclude] -> ending
|
||||
|
||||
=== ending ===
|
||||
~ test_phase = 9
|
||||
All test phases completed!
|
||||
Total phases run: {test_phase}
|
||||
|
||||
Thank you for testing the line prefix speaker format implementation.
|
||||
The dialogue system should now support:
|
||||
✓ Line prefix format (Speaker: Text)
|
||||
✓ Narrator mode (Narrator: Text)
|
||||
✓ Narrator with character (Narrator[char]: Text)
|
||||
✓ Background changes (Background[file]: Text)
|
||||
✓ Multi-speaker dialogue
|
||||
✓ Mixed format compatibility
|
||||
✓ Edge case handling
|
||||
|
||||
Test complete.
|
||||
-> END
|
||||
1
scenarios/ink/test-line-prefix.json
Normal file
1
scenarios/ink/test-line-prefix.json
Normal file
File diff suppressed because one or more lines are too long
170
scenarios/ink/test-line-prefix2.ink
Normal file
170
scenarios/ink/test-line-prefix2.ink
Normal file
@@ -0,0 +1,170 @@
|
||||
// Test Line Prefix Speaker Format
|
||||
// Tests for PHASE 1-4.5: Speaker prefixes, narrator mode, background changes
|
||||
//
|
||||
// Test Coverage:
|
||||
// - Line prefix format: "Speaker: Text"
|
||||
// - Narrator mode: "Narrator: Text"
|
||||
// - Narrator with character: "Narrator[character]: Text"
|
||||
// - Background changes: "Background[filename]: optional text"
|
||||
// - Mixed format with traditional tags
|
||||
// - Multi-line dialogue with speaker changes
|
||||
|
||||
VAR test_phase = 0
|
||||
|
||||
=== start ===
|
||||
~ test_phase = 1
|
||||
PHASE 1: Line Prefix Speaker Format
|
||||
Please observe the dialogue displays with speaker names derived from line prefixes.
|
||||
-> phase_1_basic
|
||||
|
||||
=== phase_1_basic ===
|
||||
// Test basic line prefix format with regular speakers
|
||||
// Each line should show the speaker name correctly from the prefix
|
||||
|
||||
Player: Hello! I'm here for the security briefing.
|
||||
security_guard: Welcome to the facility. I'll run through the basics for you.
|
||||
security_guard: We use biometric authentication and keycard access throughout the building.
|
||||
Player: That sounds comprehensive. Any recent incidents?
|
||||
security_guard: Nothing major. System updates last week, everything's stable now.
|
||||
|
||||
+ [Continue to Phase 2] -> phase_2_narrator
|
||||
+ [Repeat Phase 1] -> phase_1_basic
|
||||
|
||||
=== phase_2_narrator ===
|
||||
~ test_phase = 2
|
||||
PHASE 2: Narrator Mode Testing
|
||||
Narrator: The sun begins to set over the facility grounds. An eerie silence fills the corridors.
|
||||
Narrator: Your briefing complete, you prepare to move deeper into the building.
|
||||
|
||||
+ [Continue] -> phase_2_narrator_with_character
|
||||
|
||||
=== phase_2_narrator_with_character ===
|
||||
// Narrator with character portrait displayed
|
||||
Narrator[security_guard]: The guard glances nervously at the security monitors.
|
||||
Narrator[security_guard]: Something seems different about the usual patrol rotation today.
|
||||
|
||||
+ [Continue] -> phase_3_background_changes
|
||||
|
||||
=== phase_3_background_changes ===
|
||||
~ test_phase = 3
|
||||
PHASE 3: Background Changes Testing
|
||||
Background[lab-normal.png]: The laboratory appears normal at first glance.
|
||||
scientist: Welcome to the lab. Everything is operating within normal parameters.
|
||||
Background[lab-alert.png]: Suddenly, the room plunges into emergency lighting!
|
||||
scientist: Wait... the alarm system just triggered. Something's wrong with the main servers!
|
||||
scientist: We need to get to the server room immediately!
|
||||
|
||||
+ [Continue] -> phase_4_mixed_format
|
||||
|
||||
=== phase_4_mixed_format ===
|
||||
~ test_phase = 4
|
||||
PHASE 4: Mixed Format Testing
|
||||
Now we'll test mixing line prefix format with traditional tag-based dialogue.
|
||||
Test that both systems work together smoothly.
|
||||
|
||||
Player: What's the emergency protocol?
|
||||
security_guard: Protocol Alpha - all personnel to designated zones.
|
||||
security_guard: The facility goes into lockdown automatically.
|
||||
Narrator: Red emergency lights begin flashing throughout the building.
|
||||
Player: How long until the lockdown is complete?
|
||||
security_guard: Usually takes about 30 seconds for all doors to secure.
|
||||
scientist: The server room needs to stay accessible for diagnostics!
|
||||
|
||||
+ [Continue] -> phase_5_speaker_changes
|
||||
|
||||
=== phase_5_speaker_changes ===
|
||||
~ test_phase = 5
|
||||
PHASE 5: Multi-Speaker Dialogue Testing
|
||||
Testing rapid speaker changes using line prefixes.
|
||||
|
||||
Player: Who's in charge here?
|
||||
security_guard: I am, in the security department.
|
||||
scientist: But I'm responsible for the lab systems.
|
||||
security_guard: Right, so we need both perspectives.
|
||||
Player: What do you recommend?
|
||||
scientist: We need lab access first.
|
||||
security_guard: I'll authorize it - follow me.
|
||||
Narrator: The tension in the room is palpable as both professionals prepare for action.
|
||||
|
||||
+ [Continue] -> phase_6_narrator_variations
|
||||
|
||||
=== phase_6_narrator_variations ===
|
||||
~ test_phase = 6
|
||||
PHASE 6: Narrator Variations Testing
|
||||
|
||||
Narrator: Pure narrator - no character portrait shown
|
||||
Narrator[scientist]: Narrator with scientist portrait
|
||||
Narrator[security_guard]: Narrator with security guard portrait
|
||||
Background[hallway-dark.png]: The hallway descends into darkness
|
||||
Narrator: Another narration after background change
|
||||
Player: Are we ready to proceed?
|
||||
Narrator[security_guard]: The guard nods solemnly
|
||||
|
||||
+ [Continue] -> phase_7_comprehensive
|
||||
|
||||
=== phase_7_comprehensive ===
|
||||
~ test_phase = 7
|
||||
PHASE 7: Comprehensive Scene
|
||||
A scene combining all features.
|
||||
|
||||
Background[facility-entrance.png]: You stand at the facility entrance
|
||||
Narrator[security_guard]: The security guard approaches your position
|
||||
security_guard: Welcome to the facility. I hope you're here for a good reason.
|
||||
Player: I'm here to investigate the recent incident.
|
||||
security_guard: Incident? There is no incident.
|
||||
Background[security-office.png]: The guard escorts you to the security office
|
||||
Narrator: Inside, screens display various security feeds
|
||||
Narrator[scientist]: From a monitor, a scientist's face appears urgently
|
||||
scientist: We need to talk. Alone.
|
||||
security_guard: What's this about?
|
||||
scientist: It's classified. Speaker confidential access only.
|
||||
Narrator: The guard looks conflicted but nods slowly
|
||||
security_guard: Alright. Go ahead. But I'm logging this.
|
||||
|
||||
+ [Investigate Further] -> phase_8_edge_cases
|
||||
+ [Conclude Test] -> ending
|
||||
|
||||
=== phase_8_edge_cases ===
|
||||
~ test_phase = 8
|
||||
PHASE 8: Edge Cases & Stress Testing
|
||||
|
||||
// Multiple consecutive narrator lines
|
||||
Narrator: The tension builds.
|
||||
Narrator: Everything is at stake.
|
||||
Narrator: Time seems to slow down.
|
||||
|
||||
// Multiple consecutive same speaker
|
||||
Player: I need answers.
|
||||
Player: Real answers.
|
||||
Player: Not more evasions.
|
||||
|
||||
// Rapid background changes
|
||||
Background[room-a.png]: Location: Room A
|
||||
Background[room-b.png]: Location: Room B
|
||||
Background[room-c.png]: Location: Room C
|
||||
|
||||
// Mixed speakers after backgrounds
|
||||
scientist: The data is clear.
|
||||
security_guard: But the implications are severe.
|
||||
Player: We need to act fast.
|
||||
Narrator: The three of you exchange worried glances.
|
||||
|
||||
+ [Conclude] -> ending
|
||||
|
||||
=== ending ===
|
||||
~ test_phase = 9
|
||||
All test phases completed!
|
||||
Total phases run: {test_phase}
|
||||
|
||||
Thank you for testing the line prefix speaker format implementation.
|
||||
The dialogue system should now support:
|
||||
✓ Line prefix format (Speaker: Text)
|
||||
✓ Narrator mode (Narrator: Text)
|
||||
✓ Narrator with character (Narrator[char]: Text)
|
||||
✓ Background changes (Background[file]: Text)
|
||||
✓ Multi-speaker dialogue
|
||||
✓ Mixed format compatibility
|
||||
✓ Edge case handling
|
||||
|
||||
Test complete.
|
||||
-> END
|
||||
1
scenarios/ink/test-line-prefix2.json
Normal file
1
scenarios/ink/test-line-prefix2.json
Normal file
File diff suppressed because one or more lines are too long
186
scenarios/npc-sprite-test3/scenario.json.erb
Normal file
186
scenarios/npc-sprite-test3/scenario.json.erb
Normal file
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"scenario_brief": "Test scenario for NPC sprite functionality",
|
||||
"globalVariables": {
|
||||
"player_joined_organization": false
|
||||
},
|
||||
"startRoom": "test_room",
|
||||
|
||||
"startItemsInInventory": [
|
||||
{
|
||||
"type": "phone",
|
||||
"name": "Your Phone 0",
|
||||
"takeable": true,
|
||||
"phoneId": "player_phone",
|
||||
"npcIds": ["gossip_girl"],
|
||||
"observations": "Your personal phone with some interesting contacts"
|
||||
}
|
||||
],
|
||||
|
||||
"player": {
|
||||
"id": "player",
|
||||
"displayName": "Agent 0x00",
|
||||
"spriteSheet": "hacker",
|
||||
"spriteTalk": "assets/characters/hacker-talk.png",
|
||||
"spriteConfig": {
|
||||
"idleFrameStart": 20,
|
||||
"idleFrameEnd": 23
|
||||
}
|
||||
},
|
||||
|
||||
"rooms": {
|
||||
"test_room": {
|
||||
"type": "room_office",
|
||||
"connections": {},
|
||||
"npcs": [
|
||||
{
|
||||
"id": "neye_eve",
|
||||
"displayName": "Neye Eve",
|
||||
"storyPath": "scenarios/ink/neye-eve.json",
|
||||
"avatar": "assets/npc/avatars/npc_adversary.png",
|
||||
"phoneId": "player_phone",
|
||||
"currentKnot": "start",
|
||||
"npcType": "phone"
|
||||
},
|
||||
{
|
||||
"id": "gossip_girl",
|
||||
"displayName": "Gossip Girl",
|
||||
"storyPath": "scenarios/ink/gossip-girl.json",
|
||||
"avatar": "assets/npc/avatars/npc_neutral.png",
|
||||
"phoneId": "player_phone",
|
||||
"currentKnot": "start",
|
||||
"npcType": "phone",
|
||||
"timedMessages": [
|
||||
{
|
||||
"delay": 5000,
|
||||
"message": "Hey! 👋 Got any juicy gossip for me today?",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_npc_front",
|
||||
"displayName": "Helper NPC",
|
||||
"npcType": "person",
|
||||
"position": { "x": 5, "y": 3 },
|
||||
"spriteSheet": "hacker-red",
|
||||
"spriteTalk": "assets/characters/hacker-red-talk.png",
|
||||
"spriteConfig": {
|
||||
"idleFrameStart": 20,
|
||||
"idleFrameEnd": 23
|
||||
},
|
||||
"storyPath": "scenarios/ink/test-line-prefix.json",
|
||||
"currentKnot": "start",
|
||||
"itemsHeld": [
|
||||
{
|
||||
"type": "workstation",
|
||||
"name": "Crypto Analysis Station",
|
||||
"takeable": true,
|
||||
"observations": "A powerful workstation for cryptographic analysis"
|
||||
},
|
||||
{
|
||||
"type": "lockpick",
|
||||
"name": "Lock Pick Kit",
|
||||
"takeable": true,
|
||||
"observations": "A professional lock picking kit with various picks and tension wrenches"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test_npc_back",
|
||||
"displayName": "Back NPC",
|
||||
"npcType": "person",
|
||||
"position": { "x": 6, "y": 8 },
|
||||
"spriteSheet": "hacker",
|
||||
"spriteConfig": {
|
||||
"idleFrameStart": 20,
|
||||
"idleFrameEnd": 23
|
||||
},
|
||||
"storyPath": "scenarios/ink/test2.json",
|
||||
"currentKnot": "hub",
|
||||
"timedConversation": {
|
||||
"delay": 0,
|
||||
"targetKnot": "group_meeting",
|
||||
"background": "assets/backgrounds/hq1.png"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "test_npc_influence",
|
||||
"displayName": "Influence NPC",
|
||||
"npcType": "person",
|
||||
"position": { "x": 2, "y": 2 },
|
||||
"spriteSheet": "hacker",
|
||||
"spriteConfig": {
|
||||
"idleFrameStart": 20,
|
||||
"idleFrameEnd": 23
|
||||
},
|
||||
"storyPath": "scenarios/ink/influence-demo.json",
|
||||
"currentKnot": "start"
|
||||
},
|
||||
{
|
||||
"id": "container_test_npc",
|
||||
"displayName": "Equipment Officer",
|
||||
"npcType": "person",
|
||||
"position": { "x": 8, "y": 5 },
|
||||
"spriteSheet": "hacker-red",
|
||||
"spriteTalk": "assets/characters/hacker-red-talk.png",
|
||||
"spriteConfig": {
|
||||
"idleFrameStart": 20,
|
||||
"idleFrameEnd": 23
|
||||
},
|
||||
"storyPath": "scenarios/ink/equipment-officer.json",
|
||||
"currentKnot": "start",
|
||||
"itemsHeld": [
|
||||
{
|
||||
"type": "phone",
|
||||
"name": "Your Phone 1",
|
||||
"takeable": true,
|
||||
"phoneId": "player_phone",
|
||||
"npcIds": ["gossip_girl"],
|
||||
"observations": "Your personal phone with some interesting contacts"
|
||||
},
|
||||
{
|
||||
"type": "phone",
|
||||
"name": "Your Phone 2",
|
||||
"takeable": true,
|
||||
"phoneId": "player_phone",
|
||||
"npcIds": ["neye_eve"],
|
||||
"observations": "Your personal phone with some interesting contacts"
|
||||
},
|
||||
{
|
||||
"type": "lockpick",
|
||||
"name": "Basic Lock Pick Kit",
|
||||
"takeable": true,
|
||||
"observations": "A basic set of lock picking tools"
|
||||
},
|
||||
{
|
||||
"type": "lockpick",
|
||||
"name": "Advanced Lock Pick Kit",
|
||||
"takeable": true,
|
||||
"observations": "An advanced set with precision tools"
|
||||
},
|
||||
{
|
||||
"type": "workstation",
|
||||
"name": "Analysis Workstation",
|
||||
"takeable": true,
|
||||
"observations": "Portable analysis workstation"
|
||||
},
|
||||
{
|
||||
"type": "keycard",
|
||||
"name": "Security Keycard",
|
||||
"takeable": true,
|
||||
"observations": "Electronic access keycard"
|
||||
},
|
||||
{
|
||||
"type": "notes",
|
||||
"name": "Security Log",
|
||||
"takeable": true,
|
||||
"readable": true,
|
||||
"text": "Unusual after-hours access detected:\n- CEO office: 11:30 PM\n- Server room: 2:15 AM\n- CEO office again: 3:45 AM",
|
||||
"observations": "A concerning security log from last night"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user