@sharpee/engine 0.9.66-beta → 0.9.67-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/action-context-factory.d.ts +11 -0
  2. package/action-context-factory.d.ts.map +1 -0
  3. package/action-context-factory.js +258 -0
  4. package/action-context-factory.js.map +1 -0
  5. package/capability-dispatch-helper.d.ts +106 -0
  6. package/capability-dispatch-helper.d.ts.map +1 -0
  7. package/capability-dispatch-helper.js +269 -0
  8. package/capability-dispatch-helper.js.map +1 -0
  9. package/command-executor.d.ts +53 -0
  10. package/command-executor.d.ts.map +1 -0
  11. package/command-executor.js +329 -0
  12. package/command-executor.js.map +1 -0
  13. package/event-adapter.d.ts +44 -0
  14. package/event-adapter.d.ts.map +1 -0
  15. package/event-adapter.js +127 -0
  16. package/event-adapter.js.map +1 -0
  17. package/event-sequencer.d.ts +73 -0
  18. package/event-sequencer.d.ts.map +1 -0
  19. package/event-sequencer.js +134 -0
  20. package/event-sequencer.js.map +1 -0
  21. package/events/event-emitter.d.ts +34 -0
  22. package/events/event-emitter.d.ts.map +1 -0
  23. package/events/event-emitter.js +67 -0
  24. package/events/event-emitter.js.map +1 -0
  25. package/game-engine.d.ts +292 -0
  26. package/game-engine.d.ts.map +1 -0
  27. package/game-engine.js +1597 -0
  28. package/game-engine.js.map +1 -0
  29. package/index.d.ts +27 -0
  30. package/index.d.ts.map +1 -0
  31. package/index.js +63 -0
  32. package/index.js.map +1 -0
  33. package/narrative/index.d.ts +5 -0
  34. package/narrative/index.d.ts.map +1 -0
  35. package/narrative/index.js +10 -0
  36. package/narrative/index.js.map +1 -0
  37. package/narrative/narrative-settings.d.ts +73 -0
  38. package/narrative/narrative-settings.d.ts.map +1 -0
  39. package/narrative/narrative-settings.js +28 -0
  40. package/narrative/narrative-settings.js.map +1 -0
  41. package/package.json +1 -15
  42. package/parser-interface.d.ts +77 -0
  43. package/parser-interface.d.ts.map +1 -0
  44. package/parser-interface.js +48 -0
  45. package/parser-interface.js.map +1 -0
  46. package/platform-operations.d.ts +83 -0
  47. package/platform-operations.d.ts.map +1 -0
  48. package/platform-operations.js +218 -0
  49. package/platform-operations.js.map +1 -0
  50. package/save-restore-service.d.ts +125 -0
  51. package/save-restore-service.d.ts.map +1 -0
  52. package/save-restore-service.js +405 -0
  53. package/save-restore-service.js.map +1 -0
  54. package/shared-data-keys.d.ts +53 -0
  55. package/shared-data-keys.d.ts.map +1 -0
  56. package/shared-data-keys.js +29 -0
  57. package/shared-data-keys.js.map +1 -0
  58. package/story.d.ts +216 -0
  59. package/story.d.ts.map +1 -0
  60. package/story.js +60 -0
  61. package/story.js.map +1 -0
  62. package/test-helpers/mock-text-service.d.ts +11 -0
  63. package/test-helpers/mock-text-service.d.ts.map +1 -0
  64. package/test-helpers/mock-text-service.js +47 -0
  65. package/test-helpers/mock-text-service.js.map +1 -0
  66. package/turn-event-processor.d.ts +89 -0
  67. package/turn-event-processor.d.ts.map +1 -0
  68. package/turn-event-processor.js +144 -0
  69. package/turn-event-processor.js.map +1 -0
  70. package/types.d.ts +214 -0
  71. package/types.d.ts.map +1 -0
  72. package/types.js +8 -0
  73. package/types.js.map +1 -0
  74. package/vocabulary-manager.d.ts +35 -0
  75. package/vocabulary-manager.d.ts.map +1 -0
  76. package/vocabulary-manager.js +74 -0
  77. package/vocabulary-manager.js.map +1 -0
package/game-engine.js ADDED
@@ -0,0 +1,1597 @@
1
+ "use strict";
2
+ /**
3
+ * Game Engine - Main runtime for Sharpee IF games
4
+ *
5
+ * Manages game state, turn execution, and coordinates all subsystems
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.GameEngine = void 0;
9
+ const world_model_1 = require("./src/index.js");
10
+ const event_processor_1 = require("./src/index.js");
11
+ const stdlib_1 = require("./src/index.js");
12
+ const text_service_1 = require("./src/index.js");
13
+ const core_1 = require("./src/index.js");
14
+ const plugins_1 = require("./src/index.js");
15
+ const narrative_1 = require("./narrative");
16
+ const command_executor_1 = require("./command-executor");
17
+ const action_context_factory_1 = require("./action-context-factory");
18
+ const event_sequencer_1 = require("./event-sequencer");
19
+ const event_adapter_1 = require("./event-adapter");
20
+ const parser_interface_1 = require("./parser-interface");
21
+ const vocabulary_manager_1 = require("./vocabulary-manager");
22
+ const save_restore_service_1 = require("./save-restore-service");
23
+ const turn_event_processor_1 = require("./turn-event-processor");
24
+ /**
25
+ * Main game engine
26
+ */
27
+ class GameEngine {
28
+ world;
29
+ sessionStartTime;
30
+ sessionTurns = 0;
31
+ sessionMoves = 0;
32
+ context;
33
+ config;
34
+ commandExecutor;
35
+ eventProcessor;
36
+ platformEvents;
37
+ actionRegistry;
38
+ textService;
39
+ turnEvents = new Map();
40
+ running = false;
41
+ story;
42
+ languageProvider;
43
+ parser;
44
+ eventListeners = new Map();
45
+ saveRestoreHooks;
46
+ eventSource = (0, core_1.createSemanticEventSource)();
47
+ systemEventSource;
48
+ pendingPlatformOps = [];
49
+ perceptionService;
50
+ pluginRegistry;
51
+ random;
52
+ narrativeSettings;
53
+ // Extracted services (Phase 4 remediation)
54
+ vocabularyManager;
55
+ saveRestoreService;
56
+ turnEventProcessor;
57
+ platformOpHandler;
58
+ // Phase 5: Track if initialized event has been emitted
59
+ hasEmittedInitialized = false;
60
+ constructor(options) {
61
+ this.world = options.world;
62
+ this.perceptionService = options.perceptionService;
63
+ // Register essential engine capabilities (stories can register additional ones)
64
+ // Command history is required for the AGAIN command to function
65
+ this.world.registerCapability(world_model_1.StandardCapabilities.COMMAND_HISTORY, {
66
+ schema: stdlib_1.CommandHistoryCapabilitySchema
67
+ });
68
+ this.config = {
69
+ maxHistory: 100,
70
+ validateEvents: true,
71
+ collectTiming: false,
72
+ maxUndoSnapshots: 10,
73
+ ...options.config
74
+ };
75
+ // Initialize context
76
+ this.context = {
77
+ currentTurn: 1, // Start at 1 per test expectations
78
+ player: options.player,
79
+ history: [],
80
+ metadata: {
81
+ started: new Date(),
82
+ lastPlayed: new Date()
83
+ }
84
+ };
85
+ // Create action registry and register standard actions
86
+ this.actionRegistry = new stdlib_1.StandardActionRegistry();
87
+ for (const action of stdlib_1.standardActions) {
88
+ this.actionRegistry.register(action);
89
+ }
90
+ // Create subsystems
91
+ this.eventProcessor = new event_processor_1.EventProcessor(this.world);
92
+ // Wire WorldModel event handlers to EventProcessor (ADR-086)
93
+ // This ensures handlers registered via world.registerEventHandler() are invoked
94
+ const wiring = {
95
+ registerHandler: (eventType, handler) => {
96
+ this.eventProcessor.registerHandler(eventType, (event, _query) => {
97
+ // The adapted handler doesn't need WorldQuery, it captures world in closure
98
+ // Cast to Effect[] since handler returns unknown[] (to avoid circular deps)
99
+ return handler(event);
100
+ });
101
+ }
102
+ };
103
+ this.world.connectEventProcessor(wiring);
104
+ // Register standard event chains (ADR-094)
105
+ // Must happen after EventProcessor is connected so chains are wired
106
+ (0, stdlib_1.registerStandardChains)(this.world);
107
+ this.platformEvents = (0, core_1.createSemanticEventSource)();
108
+ // Initialize system event source for debug/validation events
109
+ this.systemEventSource = (0, core_1.createGenericEventSource)();
110
+ // Route system events to the engine's event emitter
111
+ this.systemEventSource.subscribe((event) => {
112
+ this.emit('event', {
113
+ id: event.id,
114
+ timestamp: new Date(event.timestamp),
115
+ type: `system.${event.type}`,
116
+ data: event.data,
117
+ sequence: 0,
118
+ turn: this.context.currentTurn,
119
+ scope: 'global'
120
+ });
121
+ });
122
+ this.pluginRegistry = new plugins_1.PluginRegistry();
123
+ this.random = (0, core_1.createSeededRandom)();
124
+ this.narrativeSettings = (0, narrative_1.buildNarrativeSettings)(); // Default: 2nd person
125
+ // Initialize extracted services (Phase 4 remediation)
126
+ this.vocabularyManager = (0, vocabulary_manager_1.createVocabularyManager)();
127
+ this.saveRestoreService = (0, save_restore_service_1.createSaveRestoreService)({
128
+ maxSnapshots: this.config.maxUndoSnapshots ?? 10
129
+ });
130
+ this.turnEventProcessor = (0, turn_event_processor_1.createTurnEventProcessor)(this.perceptionService);
131
+ // Set provided dependencies
132
+ this.languageProvider = options.language;
133
+ this.parser = options.parser;
134
+ this.textService = (0, text_service_1.createTextService)(this.languageProvider);
135
+ // Update action registry with language provider
136
+ this.actionRegistry.setLanguageProvider(this.languageProvider);
137
+ // Wire parser with platform events if supported
138
+ if (this.parser && (0, parser_interface_1.hasPlatformEventEmitter)(this.parser)) {
139
+ this.parser.setPlatformEventEmitter((event) => {
140
+ this.platformEvents.addEvent(event);
141
+ });
142
+ }
143
+ // Create command executor with dependencies
144
+ this.commandExecutor = (0, command_executor_1.createCommandExecutor)(this.world, this.actionRegistry, this.eventProcessor, this.parser, this.systemEventSource);
145
+ // Query handling is now managed by the platform layer
146
+ // Platform owns the QueryManager and handles all queries
147
+ // Note: game.initialized event is emitted in start() to avoid race condition
148
+ // (Phase 5 remediation - removed setTimeout)
149
+ }
150
+ /**
151
+ * Set the story for this engine
152
+ */
153
+ setStory(story) {
154
+ // Emit story loading event
155
+ const loadingEvent = (0, core_1.createStoryLoadingEvent)(story.config.id);
156
+ this.emitGameEvent(loadingEvent);
157
+ this.story = story;
158
+ // Build narrative settings from story config (ADR-089)
159
+ this.narrativeSettings = (0, narrative_1.buildNarrativeSettings)(story.config.narrative);
160
+ // Initialize story-specific world content
161
+ story.initializeWorld(this.world);
162
+ // Create player if needed (or replace existing one)
163
+ const newPlayer = story.createPlayer(this.world);
164
+ this.context.player = newPlayer;
165
+ this.world.setPlayer(newPlayer.id);
166
+ // Configure language provider with narrative settings (ADR-089)
167
+ this.configureLanguageProviderNarrative(newPlayer);
168
+ // Update metadata
169
+ this.context.metadata.title = story.config.title;
170
+ this.context.metadata.author = Array.isArray(story.config.author)
171
+ ? story.config.author.join(', ')
172
+ : story.config.author;
173
+ this.context.metadata.version = story.config.version;
174
+ // Copy implicit actions config to context (ADR-104)
175
+ this.context.implicitActions = story.config.implicitActions;
176
+ // Register any custom actions
177
+ if (story.getCustomActions) {
178
+ const customActions = story.getCustomActions();
179
+ for (const action of customActions) {
180
+ this.actionRegistry.register(action);
181
+ }
182
+ }
183
+ // Story-specific initialization
184
+ if (story.initialize) {
185
+ story.initialize();
186
+ }
187
+ // Emit story loaded event
188
+ const loadedEvent = (0, core_1.createStoryLoadedEvent)({
189
+ id: story.config.id,
190
+ title: story.config.title,
191
+ author: this.context.metadata.author,
192
+ version: story.config.version
193
+ });
194
+ this.emitGameEvent(loadedEvent);
195
+ // Register custom vocabulary if parser is available
196
+ if (story.getCustomVocabulary && this.parser && this.parser.registerVerbs) {
197
+ const customVocab = story.getCustomVocabulary();
198
+ // Register custom verbs
199
+ if (customVocab.verbs && customVocab.verbs.length > 0) {
200
+ this.parser.registerVerbs(customVocab.verbs);
201
+ }
202
+ // Future: Register other vocabulary types
203
+ // if (customVocab.nouns && this.parser.registerNouns) {
204
+ // this.parser.registerNouns(customVocab.nouns);
205
+ // }
206
+ }
207
+ // Notify story that engine is fully initialized
208
+ // Allows stories to register command transformers and other hooks
209
+ if (story.onEngineReady) {
210
+ story.onEngineReady(this);
211
+ }
212
+ }
213
+ /**
214
+ * Get the current parser
215
+ */
216
+ getParser() {
217
+ return this.parser;
218
+ }
219
+ /**
220
+ * Get the current language provider
221
+ */
222
+ getLanguageProvider() {
223
+ return this.languageProvider;
224
+ }
225
+ /**
226
+ * Start the game engine
227
+ */
228
+ start() {
229
+ if (this.running) {
230
+ throw new Error('Engine is already running');
231
+ }
232
+ if (!this.parser) {
233
+ throw new Error('Engine must have a parser before starting');
234
+ }
235
+ if (!this.languageProvider) {
236
+ throw new Error('Engine must have a language provider before starting');
237
+ }
238
+ if (!this.textService) {
239
+ throw new Error('Engine must have a text service before starting');
240
+ }
241
+ if (!this.commandExecutor) {
242
+ throw new Error('Engine must have a command executor before starting');
243
+ }
244
+ // Emit initialized event once (Phase 5 - moved from constructor to avoid race condition)
245
+ if (!this.hasEmittedInitialized) {
246
+ const initializedEvent = (0, core_1.createGameInitializedEvent)();
247
+ this.emitGameEvent(initializedEvent);
248
+ this.hasEmittedInitialized = true;
249
+ }
250
+ // Emit game starting event
251
+ const startingEvent = (0, core_1.createGameStartingEvent)({
252
+ id: this.story?.config.id,
253
+ title: this.context.metadata.title,
254
+ author: this.context.metadata.author,
255
+ version: this.context.metadata.version
256
+ });
257
+ this.emitGameEvent(startingEvent);
258
+ this.running = true;
259
+ this.sessionStartTime = Date.now();
260
+ this.sessionTurns = 0;
261
+ this.sessionMoves = 0;
262
+ // Keep currentTurn as is (already 1 from constructor)
263
+ // Get version info from StoryInfoTrait (or fall back to legacy (world as any) for backward compat)
264
+ const storyInfoEntities = this.world.findByTrait('storyInfo');
265
+ const storyInfoTrait = storyInfoEntities[0]?.get('storyInfo');
266
+ const engineVersion = storyInfoTrait?.engineVersion || this.world.versionInfo?.engineVersion;
267
+ const clientVersion = storyInfoTrait?.clientVersion || this.world.versionInfo?.clientVersion || this.world.clientVersion;
268
+ // Emit game started event
269
+ const startedEvent = (0, core_1.createGameStartedEvent)({
270
+ id: this.story?.config.id,
271
+ title: this.context.metadata.title,
272
+ author: this.context.metadata.author,
273
+ version: this.context.metadata.version,
274
+ buildDate: this.story?.config.buildDate
275
+ }, this.sessionStartTime, engineVersion, clientVersion);
276
+ this.emitGameEvent(startedEvent);
277
+ this.emit('state:changed', this.context);
278
+ }
279
+ /**
280
+ * Stop the game engine
281
+ */
282
+ stop(reason, details) {
283
+ if (!this.running) {
284
+ return;
285
+ }
286
+ // Emit game ending event
287
+ const endingEvent = (0, core_1.createGameEndingEvent)(reason || 'quit', {
288
+ startTime: this.sessionStartTime,
289
+ endTime: Date.now(),
290
+ turns: this.sessionTurns,
291
+ moves: this.sessionMoves
292
+ });
293
+ this.emitGameEvent(endingEvent);
294
+ this.running = false;
295
+ // Emit specific end event based on reason
296
+ const session = {
297
+ startTime: this.sessionStartTime,
298
+ endTime: Date.now(),
299
+ turns: this.sessionTurns,
300
+ moves: this.sessionMoves
301
+ };
302
+ if (reason === 'victory') {
303
+ const wonEvent = (0, core_1.createGameWonEvent)(session, details);
304
+ this.emitGameEvent(wonEvent);
305
+ }
306
+ else if (reason === 'defeat') {
307
+ const lostEvent = (0, core_1.createGameLostEvent)(details?.reason || 'Game over', session);
308
+ this.emitGameEvent(lostEvent);
309
+ }
310
+ else if (reason === 'quit') {
311
+ const quitEvent = (0, core_1.createGameQuitEvent)(session);
312
+ this.emitGameEvent(quitEvent);
313
+ }
314
+ else if (reason === 'abort') {
315
+ const abortedEvent = (0, core_1.createGameAbortedEvent)(details?.error || 'Game aborted', session);
316
+ this.emitGameEvent(abortedEvent);
317
+ }
318
+ // Final game ended event
319
+ const endedEvent = (0, core_1.createGameEndedEvent)(reason || 'quit', session, details);
320
+ this.emitGameEvent(endedEvent);
321
+ // Emit game:over for any ending
322
+ this.emit('game:over', this.context);
323
+ }
324
+ /**
325
+ * Execute a turn
326
+ */
327
+ async executeTurn(input) {
328
+ if (!this.running) {
329
+ throw new Error('Engine is not running');
330
+ }
331
+ if (!this.commandExecutor) {
332
+ throw new Error('Engine must have a story set before executing turns');
333
+ }
334
+ // Create undo snapshot BEFORE processing the turn
335
+ // Skip for meta/info commands that shouldn't create undo points
336
+ // (Phase 6 remediation - use MetaCommandRegistry instead of hardcoded list)
337
+ if (!stdlib_1.MetaCommandRegistry.isNonUndoable(input)) {
338
+ this.createUndoSnapshot();
339
+ }
340
+ // Note: AGAIN/G command handling has been moved to the again action (if.action.again)
341
+ // which emits platform.again_requested event, processed by processPlatformOperations.
342
+ // This enables proper i18n support - each parser package defines its own patterns
343
+ // (e.g., "again"/"g" in English, "encore"/"e" in French).
344
+ // Check if system events are enabled via debug capability
345
+ const debugData = this.world.getCapability('debug');
346
+ if (debugData && (debugData.debugParserEvents || debugData.debugValidationEvents || debugData.debugSystemEvents)) {
347
+ // Emit system events when enabled
348
+ // For now, we'll add these events directly to the result
349
+ // In the future, we could use a proper event source system
350
+ // Note: The actual parser/validation events would be emitted by
351
+ // the parser and validator components when they detect these flags
352
+ }
353
+ const turn = this.context.currentTurn;
354
+ // Validate input
355
+ if (input === null || input === undefined) {
356
+ const errorEvent = event_sequencer_1.eventSequencer.sequence({
357
+ type: 'command.failed',
358
+ data: {
359
+ reason: 'Input cannot be null or undefined',
360
+ input: input
361
+ }
362
+ }, turn);
363
+ return {
364
+ turn,
365
+ input: input,
366
+ success: false,
367
+ events: [errorEvent],
368
+ error: 'Input cannot be null or undefined'
369
+ };
370
+ }
371
+ this.emit('turn:start', turn, input);
372
+ try {
373
+ // Early detection: Parse first to check if this is a meta-command
374
+ // Meta-commands (VERSION, SCORE, HELP, etc.) take a completely separate path
375
+ // that doesn't interact with turn machinery (no turn increment, no NPCs, etc.)
376
+ if (this.parser) {
377
+ // Set world context for parser
378
+ const player = this.world.getPlayer();
379
+ if (player && (0, parser_interface_1.hasWorldContext)(this.parser)) {
380
+ const playerLocation = this.world.getLocation(player.id) || '';
381
+ this.parser.setWorldContext(this.world, player.id, playerLocation);
382
+ }
383
+ // Parse to get action ID
384
+ const parseResult = this.parser.parse(input);
385
+ if (parseResult.success) {
386
+ const parsedCommand = parseResult.value;
387
+ const actionId = parsedCommand.action;
388
+ // Check if this is a meta-command
389
+ if (actionId && stdlib_1.MetaCommandRegistry.isMeta(actionId)) {
390
+ // Route to separate meta-command path
391
+ const metaResult = await this.executeMetaCommand(input, parsedCommand);
392
+ // Convert MetaCommandResult to TurnResult for backward compatibility
393
+ // Turn is included for display context but not incremented
394
+ return {
395
+ type: 'turn', // For backward compatibility with callers that don't check type
396
+ turn,
397
+ input: metaResult.input,
398
+ success: metaResult.success,
399
+ events: metaResult.events.map(e => event_sequencer_1.eventSequencer.sequence(e, turn)),
400
+ error: metaResult.error,
401
+ actionId: metaResult.actionId
402
+ };
403
+ }
404
+ }
405
+ // If parse failed or not a meta-command, fall through to regular execution
406
+ }
407
+ // Regular command path - full turn processing
408
+ // Execute the command
409
+ const result = await this.commandExecutor.execute(input, this.world, this.context, this.config);
410
+ // Get context for event enrichment
411
+ const playerLocation = this.world.getLocation(this.context.player.id);
412
+ const enrichmentContext = {
413
+ turn,
414
+ playerId: this.context.player.id,
415
+ locationId: playerLocation
416
+ };
417
+ // Store events for this turn (convert to SemanticEvent and process through pipeline)
418
+ let semanticEvents = result.events.map(e => {
419
+ const semantic = (0, event_adapter_1.toSemanticEvent)(e);
420
+ return (0, event_adapter_1.processEvent)(semantic, enrichmentContext);
421
+ });
422
+ // Apply perception filtering if service is configured
423
+ // This transforms events based on what the player can perceive
424
+ if (this.perceptionService) {
425
+ semanticEvents = this.perceptionService.filterEvents(semanticEvents, this.context.player, this.world);
426
+ }
427
+ // Merge with any existing events for this turn (e.g., game.started from engine.start())
428
+ const existingEvents = this.turnEvents.get(turn) || [];
429
+ this.turnEvents.set(turn, [...existingEvents, ...semanticEvents]);
430
+ // Also track in event source for save/restore
431
+ for (const semanticEvent of semanticEvents) {
432
+ this.eventSource.emit(semanticEvent);
433
+ // Check if this is a platform request event
434
+ if ((0, core_1.isPlatformRequestEvent)(semanticEvent)) {
435
+ this.pendingPlatformOps.push(semanticEvent);
436
+ }
437
+ }
438
+ // Update command history if command was successful
439
+ // Note: Meta-commands take the early path (executeMetaCommand) and never reach here
440
+ if (result.success) {
441
+ this.updateCommandHistory(result, input, turn);
442
+ // Update pronoun context for "it"/"them"/"him"/"her" resolution (ADR-089)
443
+ if (this.parser && (0, parser_interface_1.hasPronounContext)(this.parser) && result.validatedCommand) {
444
+ this.parser.updatePronounContext(result.validatedCommand, turn);
445
+ }
446
+ }
447
+ // Emit events if configured
448
+ if (this.config.onEvent) {
449
+ for (const event of result.events) {
450
+ this.config.onEvent(event);
451
+ }
452
+ }
453
+ // Always emit events through the engine's event system
454
+ let victoryDetected = false;
455
+ let victoryDetails = null;
456
+ for (const event of result.events) {
457
+ this.emit('event', event);
458
+ // NOTE: Entity handlers (entity.on) are already dispatched by the
459
+ // event-processor in the command executor, so we don't call
460
+ // dispatchEntityHandlers here to avoid duplicate handler invocations.
461
+ // Check for story victory event but don't stop immediately
462
+ // (we're still processing the turn)
463
+ if (event.type === 'story.victory') {
464
+ victoryDetected = true;
465
+ const data = event.data;
466
+ victoryDetails = {
467
+ reason: data?.reason || 'Story completed',
468
+ score: data?.score || 0
469
+ };
470
+ }
471
+ }
472
+ // Run NPC and scheduler ticks after successful player action
473
+ // Order: NPC phase (ADR-070), then scheduler tick (ADR-071)
474
+ // Note: Meta-commands take the early path and never reach here
475
+ if (result.success) {
476
+ const playerLocation = this.world.getLocation(this.context.player.id);
477
+ // Plugin tick loop (ADR-120)
478
+ // Plugins run in priority order (NPC at 100, state machines at 75, scheduler at 50)
479
+ const pluginContext = {
480
+ world: this.world,
481
+ turn,
482
+ playerId: this.context.player.id,
483
+ playerLocation: playerLocation || '',
484
+ random: this.random,
485
+ actionResult: {
486
+ actionId: result.actionId || '',
487
+ success: result.success,
488
+ targetId: result.validatedCommand?.directObject?.entity?.id,
489
+ },
490
+ actionEvents: semanticEvents,
491
+ };
492
+ for (const plugin of this.pluginRegistry.getAll()) {
493
+ const pluginEvents = plugin.onAfterAction(pluginContext);
494
+ if (pluginEvents.length > 0) {
495
+ this.processPluginEvents(pluginEvents, turn, playerLocation);
496
+ }
497
+ }
498
+ }
499
+ // Update context and turn counter
500
+ // Note: Meta-commands take the early path and never reach here
501
+ this.updateContext(result);
502
+ // Update session statistics
503
+ this.sessionTurns++;
504
+ if (result.success) {
505
+ this.sessionMoves++;
506
+ }
507
+ // Process pending platform operations before text service
508
+ if (this.pendingPlatformOps.length > 0) {
509
+ await this.processPlatformOperations(turn);
510
+ // Update result.events with any platform completion events
511
+ const allTurnEvents = this.turnEvents.get(turn) || [];
512
+ result.events = allTurnEvents; // Platform ops may have added completion events
513
+ }
514
+ // Process text output (ADR-096)
515
+ if (this.textService) {
516
+ const turnEvents = this.turnEvents.get(turn) || [];
517
+ const blocks = this.textService.processTurn(turnEvents);
518
+ const output = (0, text_service_1.renderToString)(blocks);
519
+ if (output) {
520
+ this.emit('text:output', output, turn);
521
+ }
522
+ }
523
+ // Clear turn events after processing to prevent accumulation on same turn (meta commands)
524
+ this.turnEvents.set(turn, []);
525
+ // Emit completion
526
+ this.emit('turn:complete', result);
527
+ // Check for victory from events
528
+ if (victoryDetected) {
529
+ this.stop('victory', victoryDetails);
530
+ return result;
531
+ }
532
+ // Check for game over via story.isComplete()
533
+ if (this.isGameOver()) {
534
+ // Check if it's a victory (story completed successfully)
535
+ // For now, assume completion means victory
536
+ // Stories could provide more detail about the type of ending
537
+ this.stop('victory', {
538
+ reason: 'Story completed',
539
+ score: 0
540
+ });
541
+ }
542
+ return result;
543
+ }
544
+ catch (error) {
545
+ this.emit('turn:failed', error, turn);
546
+ throw error;
547
+ }
548
+ }
549
+ /**
550
+ * Execute a meta-command (VERSION, SCORE, HELP, etc.)
551
+ *
552
+ * Meta-commands operate outside the turn cycle:
553
+ * - They don't increment turns
554
+ * - They don't trigger NPC ticks or scheduler
555
+ * - They don't create undo snapshots
556
+ * - They don't get stored in command history
557
+ * - Events are processed immediately through text service (not stored in turnEvents)
558
+ *
559
+ * @param input - Raw command string
560
+ * @param parsedCommand - Parsed command from parser
561
+ * @returns MetaCommandResult with events and success status
562
+ */
563
+ async executeMetaCommand(input, parsedCommand) {
564
+ const events = [];
565
+ try {
566
+ // Validate the command
567
+ const validationResult = this.commandExecutor.validator.validate(parsedCommand);
568
+ if (!validationResult.success) {
569
+ // Validation failed - emit error event using domain event pattern
570
+ const errorEvent = {
571
+ id: `meta_error_${Date.now()}`,
572
+ type: 'if.event.command_error',
573
+ timestamp: Date.now(),
574
+ data: {
575
+ messageId: `if.action.command.${validationResult.error?.code || 'validation_failed'}`,
576
+ params: validationResult.error?.details || {},
577
+ blocked: true,
578
+ reason: validationResult.error?.code || 'validation_failed'
579
+ },
580
+ entities: {}
581
+ };
582
+ events.push(errorEvent);
583
+ // Process error through text service and emit
584
+ this.processMetaEvents(events);
585
+ return {
586
+ type: 'meta',
587
+ input,
588
+ success: false,
589
+ events,
590
+ error: validationResult.error?.code || 'Validation failed',
591
+ actionId: parsedCommand.action
592
+ };
593
+ }
594
+ const command = validationResult.value;
595
+ const action = this.actionRegistry.get(command.actionId);
596
+ if (!action) {
597
+ const errorEvent = {
598
+ id: `meta_error_${Date.now()}`,
599
+ type: 'if.event.command_error',
600
+ timestamp: Date.now(),
601
+ data: {
602
+ messageId: 'if.action.command.action_not_found',
603
+ params: { actionId: command.actionId },
604
+ blocked: true,
605
+ reason: 'action_not_found'
606
+ },
607
+ entities: {}
608
+ };
609
+ events.push(errorEvent);
610
+ this.processMetaEvents(events);
611
+ return {
612
+ type: 'meta',
613
+ input,
614
+ success: false,
615
+ events,
616
+ error: `Action not found: ${command.actionId}`,
617
+ actionId: command.actionId
618
+ };
619
+ }
620
+ // Create action context for meta-command execution
621
+ const scopeResolver = (0, stdlib_1.createScopeResolver)(this.world);
622
+ const actionContext = (0, action_context_factory_1.createActionContext)(this.world, this.context, command, action, scopeResolver);
623
+ // Run action's four-phase pattern
624
+ const actionValidation = action.validate(actionContext);
625
+ let actionEvents;
626
+ if (actionValidation.valid) {
627
+ // Execute and report
628
+ action.execute(actionContext);
629
+ actionEvents = action.report ? action.report(actionContext) : [];
630
+ }
631
+ else {
632
+ // Blocked - get error events
633
+ actionEvents = action.blocked
634
+ ? action.blocked(actionContext, actionValidation)
635
+ : [{
636
+ id: `meta_blocked_${Date.now()}`,
637
+ type: 'if.event.command_error',
638
+ timestamp: Date.now(),
639
+ data: {
640
+ messageId: `if.action.command.${actionValidation.error || 'validation_failed'}`,
641
+ params: actionValidation.params || {},
642
+ blocked: true,
643
+ reason: actionValidation.error || 'validation_failed'
644
+ },
645
+ entities: {}
646
+ }];
647
+ }
648
+ events.push(...actionEvents);
649
+ // Handle platform operations inline (SAVE, RESTORE, QUIT, AGAIN, etc.)
650
+ // These are handled BEFORE text processing so completion events get rendered
651
+ const platformOps = events.filter(core_1.isPlatformRequestEvent);
652
+ for (const op of platformOps) {
653
+ const completionEvents = await this.processMetaPlatformOperation(op);
654
+ events.push(...completionEvents);
655
+ }
656
+ // Process events through text service and emit to clients
657
+ // Events are NOT stored in turnEvents - processed immediately
658
+ this.processMetaEvents(events);
659
+ return {
660
+ type: 'meta',
661
+ input,
662
+ success: actionValidation.valid,
663
+ events,
664
+ actionId: command.actionId
665
+ };
666
+ }
667
+ catch (error) {
668
+ const errorEvent = {
669
+ id: `meta_error_${Date.now()}`,
670
+ type: 'command.failed',
671
+ timestamp: Date.now(),
672
+ data: {
673
+ reason: error.message,
674
+ input
675
+ },
676
+ entities: {}
677
+ };
678
+ events.push(errorEvent);
679
+ this.processMetaEvents(events);
680
+ return {
681
+ type: 'meta',
682
+ input,
683
+ success: false,
684
+ events,
685
+ error: error.message,
686
+ actionId: parsedCommand.action
687
+ };
688
+ }
689
+ }
690
+ /**
691
+ * Process meta-command events: text service → emit to clients
692
+ *
693
+ * - Does NOT store in turnEvents
694
+ * - Passes currentTurn for display context (turn/score shown to player)
695
+ * - Turn counter is NOT incremented
696
+ */
697
+ processMetaEvents(events) {
698
+ if (!this.textService || events.length === 0) {
699
+ return;
700
+ }
701
+ // Emit individual events through engine's event system (for tests/listeners)
702
+ for (const event of events) {
703
+ this.emit('event', event);
704
+ }
705
+ // Process events through text service
706
+ const blocks = this.textService.processTurn(events);
707
+ const output = (0, text_service_1.renderToString)(blocks);
708
+ if (output) {
709
+ // Emit text output with current turn number (for display context only)
710
+ this.emit('text:output', output, this.context.currentTurn);
711
+ }
712
+ }
713
+ /**
714
+ * Process a single platform operation for meta-commands.
715
+ *
716
+ * This is similar to processPlatformOperations but handles one operation
717
+ * at a time and returns completion events for inclusion in the result.
718
+ */
719
+ async processMetaPlatformOperation(platformOp) {
720
+ const completionEvents = [];
721
+ switch (platformOp.type) {
722
+ case core_1.PlatformEventType.SAVE_REQUESTED: {
723
+ const context = platformOp.payload.context;
724
+ if (this.saveRestoreHooks?.onSaveRequested) {
725
+ try {
726
+ const saveData = this.createSaveData();
727
+ if (context?.saveName) {
728
+ saveData.metadata.description = context.saveName;
729
+ }
730
+ if (context?.metadata) {
731
+ Object.assign(saveData.metadata, context.metadata);
732
+ }
733
+ await this.saveRestoreHooks.onSaveRequested(saveData);
734
+ completionEvents.push((0, core_1.createSaveCompletedEvent)(true));
735
+ }
736
+ catch (error) {
737
+ completionEvents.push((0, core_1.createSaveCompletedEvent)(false, error.message));
738
+ }
739
+ }
740
+ else {
741
+ completionEvents.push((0, core_1.createSaveCompletedEvent)(false, 'No save handler registered'));
742
+ }
743
+ break;
744
+ }
745
+ case core_1.PlatformEventType.RESTORE_REQUESTED: {
746
+ if (this.saveRestoreHooks?.onRestoreRequested) {
747
+ try {
748
+ const saveData = await this.saveRestoreHooks.onRestoreRequested();
749
+ if (saveData) {
750
+ this.loadSaveData(saveData);
751
+ completionEvents.push((0, core_1.createRestoreCompletedEvent)(true));
752
+ }
753
+ else {
754
+ completionEvents.push((0, core_1.createRestoreCompletedEvent)(false, 'No save data available'));
755
+ }
756
+ }
757
+ catch (error) {
758
+ completionEvents.push((0, core_1.createRestoreCompletedEvent)(false, error.message));
759
+ }
760
+ }
761
+ else {
762
+ completionEvents.push((0, core_1.createRestoreCompletedEvent)(false, 'No restore handler registered'));
763
+ }
764
+ break;
765
+ }
766
+ case core_1.PlatformEventType.QUIT_REQUESTED: {
767
+ const context = platformOp.payload.context;
768
+ if (this.saveRestoreHooks?.onQuitRequested) {
769
+ const shouldQuit = await this.saveRestoreHooks.onQuitRequested(context);
770
+ if (shouldQuit) {
771
+ this.stop('quit');
772
+ completionEvents.push((0, core_1.createQuitConfirmedEvent)());
773
+ }
774
+ else {
775
+ completionEvents.push((0, core_1.createQuitCancelledEvent)());
776
+ }
777
+ }
778
+ else {
779
+ // No quit hook - auto-confirm
780
+ completionEvents.push((0, core_1.createQuitConfirmedEvent)());
781
+ }
782
+ break;
783
+ }
784
+ case core_1.PlatformEventType.RESTART_REQUESTED: {
785
+ const context = platformOp.payload.context;
786
+ if (this.saveRestoreHooks?.onRestartRequested) {
787
+ const shouldRestart = await this.saveRestoreHooks.onRestartRequested(context);
788
+ if (shouldRestart && this.story) {
789
+ if (this.running)
790
+ this.stop();
791
+ if (this.parser && (0, parser_interface_1.hasPronounContext)(this.parser)) {
792
+ this.parser.resetPronounContext();
793
+ }
794
+ await this.setStory(this.story);
795
+ this.start();
796
+ completionEvents.push((0, core_1.createRestartCompletedEvent)(true));
797
+ }
798
+ else {
799
+ completionEvents.push((0, core_1.createRestartCompletedEvent)(false));
800
+ }
801
+ }
802
+ else if (this.story) {
803
+ // Default: restart
804
+ if (this.running)
805
+ this.stop();
806
+ if (this.parser && (0, parser_interface_1.hasPronounContext)(this.parser)) {
807
+ this.parser.resetPronounContext();
808
+ }
809
+ await this.setStory(this.story);
810
+ this.start();
811
+ completionEvents.push((0, core_1.createRestartCompletedEvent)(true));
812
+ }
813
+ break;
814
+ }
815
+ case core_1.PlatformEventType.UNDO_REQUESTED: {
816
+ const success = this.undo();
817
+ if (success) {
818
+ completionEvents.push((0, core_1.createUndoCompletedEvent)(true, this.context.currentTurn));
819
+ }
820
+ else {
821
+ completionEvents.push((0, core_1.createUndoCompletedEvent)(false, undefined, 'Nothing to undo'));
822
+ }
823
+ break;
824
+ }
825
+ case core_1.PlatformEventType.AGAIN_REQUESTED: {
826
+ const againContext = platformOp.payload.context;
827
+ if (!againContext?.command) {
828
+ completionEvents.push((0, core_1.createAgainFailedEvent)('No command to repeat'));
829
+ }
830
+ else {
831
+ // Recursive call - the repeated command will dispatch normally
832
+ // (meta path if it was meta, regular path if it was regular)
833
+ try {
834
+ await this.executeTurn(againContext.command);
835
+ // The repeated command handles its own text output
836
+ // No completion event needed for successful AGAIN
837
+ }
838
+ catch (error) {
839
+ completionEvents.push((0, core_1.createAgainFailedEvent)(error.message));
840
+ }
841
+ }
842
+ break;
843
+ }
844
+ }
845
+ return completionEvents;
846
+ }
847
+ /**
848
+ * Get current game context
849
+ */
850
+ getContext() {
851
+ return { ...this.context };
852
+ }
853
+ /**
854
+ * Get world model
855
+ */
856
+ getWorld() {
857
+ return this.world;
858
+ }
859
+ /**
860
+ * Get the current story
861
+ */
862
+ getStory() {
863
+ return this.story;
864
+ }
865
+ /**
866
+ * Get the event source for save/restore
867
+ */
868
+ getEventSource() {
869
+ return this.eventSource;
870
+ }
871
+ /**
872
+ * Get narrative settings (ADR-089)
873
+ *
874
+ * Returns the story's narrative perspective and related settings.
875
+ * Use this for text rendering that needs to know 1st/2nd/3rd person.
876
+ */
877
+ getNarrativeSettings() {
878
+ return this.narrativeSettings;
879
+ }
880
+ /**
881
+ * Configure language provider with narrative settings (ADR-089)
882
+ *
883
+ * Sets up the language provider for perspective-aware message resolution.
884
+ * For 3rd person narratives, extracts player pronouns from ActorTrait.
885
+ */
886
+ configureLanguageProviderNarrative(player) {
887
+ // Check if language provider supports narrative settings
888
+ if (!this.languageProvider || !('setNarrativeSettings' in this.languageProvider)) {
889
+ return;
890
+ }
891
+ // Build narrative context for language provider
892
+ const narrativeContext = {
893
+ perspective: this.narrativeSettings.perspective,
894
+ };
895
+ // For 3rd person, get player pronouns from ActorTrait or story config
896
+ if (this.narrativeSettings.perspective === '3rd') {
897
+ // First try story config
898
+ if (this.narrativeSettings.playerPronouns) {
899
+ narrativeContext.playerPronouns = this.narrativeSettings.playerPronouns;
900
+ }
901
+ else {
902
+ // Fall back to player entity's ActorTrait
903
+ const actorTrait = player.get('actor');
904
+ if (actorTrait?.pronouns) {
905
+ // Handle both single PronounSet and array of PronounSets
906
+ narrativeContext.playerPronouns = Array.isArray(actorTrait.pronouns)
907
+ ? actorTrait.pronouns[0]
908
+ : actorTrait.pronouns;
909
+ }
910
+ }
911
+ }
912
+ // Configure language provider
913
+ this.languageProvider.setNarrativeSettings(narrativeContext);
914
+ }
915
+ /**
916
+ * Get plugin registry for registering turn-cycle plugins (ADR-120)
917
+ */
918
+ getPluginRegistry() {
919
+ return this.pluginRegistry;
920
+ }
921
+ /**
922
+ * Get event processor for handler registration (ADR-075)
923
+ */
924
+ getEventProcessor() {
925
+ return this.eventProcessor;
926
+ }
927
+ /**
928
+ * Get the text service
929
+ */
930
+ getTextService() {
931
+ return this.textService;
932
+ }
933
+ /**
934
+ * Set a custom text service
935
+ */
936
+ setTextService(service) {
937
+ this.textService = service;
938
+ }
939
+ /**
940
+ * Register save/restore hooks
941
+ */
942
+ registerSaveRestoreHooks(hooks) {
943
+ this.saveRestoreHooks = hooks;
944
+ }
945
+ /**
946
+ * Register a transformer for parsed commands.
947
+ * Transformers are called after parsing but before validation,
948
+ * allowing stories to modify commands (e.g., for debug tools).
949
+ *
950
+ * @param transformer - Function to transform parsed commands
951
+ */
952
+ registerParsedCommandTransformer(transformer) {
953
+ this.commandExecutor.registerParsedCommandTransformer(transformer);
954
+ }
955
+ /**
956
+ * Unregister a parsed command transformer.
957
+ *
958
+ * @param transformer - The transformer to remove
959
+ * @returns true if the transformer was found and removed
960
+ */
961
+ unregisterParsedCommandTransformer(transformer) {
962
+ return this.commandExecutor.unregisterParsedCommandTransformer(transformer);
963
+ }
964
+ /**
965
+ * Save game state using registered hooks
966
+ */
967
+ async save() {
968
+ if (!this.saveRestoreHooks) {
969
+ return false; // No save capability
970
+ }
971
+ try {
972
+ const saveData = this.createSaveData();
973
+ await this.saveRestoreHooks.onSaveRequested(saveData);
974
+ return true;
975
+ }
976
+ catch (error) {
977
+ console.error('Save failed:', error);
978
+ return false;
979
+ }
980
+ }
981
+ /**
982
+ * Restore game state using registered hooks
983
+ */
984
+ async restore() {
985
+ if (!this.saveRestoreHooks) {
986
+ return false; // No restore capability
987
+ }
988
+ try {
989
+ const saveData = await this.saveRestoreHooks.onRestoreRequested();
990
+ if (!saveData) {
991
+ return false; // User cancelled or no save available
992
+ }
993
+ this.loadSaveData(saveData);
994
+ return true;
995
+ }
996
+ catch (error) {
997
+ console.error('Restore failed:', error);
998
+ return false;
999
+ }
1000
+ }
1001
+ /**
1002
+ * Create an undo snapshot of the current world state
1003
+ */
1004
+ createUndoSnapshot() {
1005
+ this.saveRestoreService.createUndoSnapshot(this.world, this.context.currentTurn);
1006
+ }
1007
+ /**
1008
+ * Undo to previous turn
1009
+ * @returns true if undo succeeded, false if nothing to undo
1010
+ */
1011
+ undo() {
1012
+ const result = this.saveRestoreService.undo(this.world);
1013
+ if (!result) {
1014
+ return false;
1015
+ }
1016
+ // Restore turn counter
1017
+ this.context.currentTurn = result.turn;
1018
+ // Update vocabulary for current scope
1019
+ this.updateScopeVocabulary();
1020
+ this.emit('state:changed', this.context);
1021
+ return true;
1022
+ }
1023
+ /**
1024
+ * Check if undo is available
1025
+ */
1026
+ canUndo() {
1027
+ return this.saveRestoreService.canUndo();
1028
+ }
1029
+ /**
1030
+ * Get number of undo levels available
1031
+ */
1032
+ getUndoLevels() {
1033
+ return this.saveRestoreService.getUndoLevels();
1034
+ }
1035
+ /**
1036
+ * Process events from a plugin through the shared pipeline (ADR-120)
1037
+ * Enriches, filters, stores, and emits events.
1038
+ */
1039
+ processPluginEvents(events, turn, playerLocation) {
1040
+ const enrichmentContext = {
1041
+ turn,
1042
+ playerId: this.context.player.id,
1043
+ locationId: playerLocation ?? undefined
1044
+ };
1045
+ let processed = events.map(e => (0, event_adapter_1.processEvent)(e, enrichmentContext));
1046
+ if (this.perceptionService) {
1047
+ processed = this.perceptionService.filterEvents(processed, this.context.player, this.world);
1048
+ }
1049
+ // Add to turn events
1050
+ const existing = this.turnEvents.get(turn) || [];
1051
+ this.turnEvents.set(turn, [...existing, ...processed]);
1052
+ // Track in event source and check for platform requests
1053
+ for (const event of processed) {
1054
+ this.eventSource.emit(event);
1055
+ if ((0, core_1.isPlatformRequestEvent)(event)) {
1056
+ this.pendingPlatformOps.push(event);
1057
+ }
1058
+ }
1059
+ // Emit through callbacks and event system
1060
+ if (this.config.onEvent) {
1061
+ for (const event of processed) {
1062
+ this.config.onEvent(event);
1063
+ }
1064
+ }
1065
+ for (const event of processed) {
1066
+ this.emit('event', event);
1067
+ this.dispatchEntityHandlers(event);
1068
+ }
1069
+ }
1070
+ /**
1071
+ * Create save data from current engine state
1072
+ */
1073
+ createSaveData() {
1074
+ return this.saveRestoreService.createSaveData(this);
1075
+ }
1076
+ /**
1077
+ * Load save data into engine
1078
+ */
1079
+ loadSaveData(saveData) {
1080
+ const result = this.saveRestoreService.loadSaveData(saveData, this);
1081
+ // Update event source
1082
+ this.eventSource = result.eventSource;
1083
+ // Update context
1084
+ this.context.currentTurn = result.currentTurn;
1085
+ this.context.metadata.lastPlayed = new Date();
1086
+ // Restore turn history
1087
+ this.context.history = this.saveRestoreService.deserializeTurnHistory(saveData.engineState.turnHistory, this.eventSource);
1088
+ // Reset pronoun context - old references may not be valid (ADR-089)
1089
+ if (this.parser && (0, parser_interface_1.hasPronounContext)(this.parser)) {
1090
+ this.parser.resetPronounContext();
1091
+ }
1092
+ // Update vocabulary for current scope
1093
+ this.updateScopeVocabulary();
1094
+ this.emit('state:changed', this.context);
1095
+ }
1096
+ /**
1097
+ * Get turn history
1098
+ */
1099
+ getHistory() {
1100
+ return [...this.context.history];
1101
+ }
1102
+ /**
1103
+ * Get recent events
1104
+ */
1105
+ getRecentEvents(count = 10) {
1106
+ const allEvents = [];
1107
+ // Collect events from recent turns
1108
+ const recentTurns = this.context.history.slice(-Math.ceil(count / 5));
1109
+ for (const turn of recentTurns) {
1110
+ allEvents.push(...turn.events);
1111
+ }
1112
+ // Sort and return most recent
1113
+ return event_sequencer_1.EventSequenceUtils.sort(allEvents).slice(-count);
1114
+ }
1115
+ /**
1116
+ * Update vocabulary for an entity
1117
+ */
1118
+ updateEntityVocabulary(entity, inScope) {
1119
+ this.vocabularyManager.updateEntityVocabulary(entity, inScope);
1120
+ }
1121
+ /**
1122
+ * Update vocabulary for all entities in scope
1123
+ */
1124
+ updateScopeVocabulary() {
1125
+ this.vocabularyManager.updateScopeVocabulary(this.world, this.context.player.id);
1126
+ }
1127
+ /**
1128
+ * Emit a platform event with turn metadata
1129
+ */
1130
+ emitPlatformEvent(event) {
1131
+ const existingData = typeof event.data === 'object' && event.data !== null
1132
+ ? event.data
1133
+ : {};
1134
+ const fullEvent = {
1135
+ ...event,
1136
+ id: `platform_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
1137
+ timestamp: Date.now(),
1138
+ data: {
1139
+ ...existingData,
1140
+ turn: this.context.currentTurn
1141
+ }
1142
+ };
1143
+ this.platformEvents.addEvent(fullEvent);
1144
+ }
1145
+ /**
1146
+ * Update context after a turn
1147
+ */
1148
+ updateContext(result) {
1149
+ // Add to history
1150
+ this.context.history.push(result);
1151
+ // Trim history if needed
1152
+ if (this.context.history.length > this.config.maxHistory) {
1153
+ this.context.history = this.context.history.slice(-this.config.maxHistory);
1154
+ }
1155
+ // Increment turn
1156
+ this.context.currentTurn++;
1157
+ // Update last played
1158
+ this.context.metadata.lastPlayed = new Date();
1159
+ // Update vocabulary for new scope
1160
+ this.updateScopeVocabulary();
1161
+ this.emit('state:changed', this.context);
1162
+ }
1163
+ /**
1164
+ * Update command history capability
1165
+ */
1166
+ updateCommandHistory(result, input, turn) {
1167
+ // Get command history capability
1168
+ const historyData = this.world.getCapability(world_model_1.StandardCapabilities.COMMAND_HISTORY);
1169
+ if (!historyData) {
1170
+ // Command history capability not registered
1171
+ return;
1172
+ }
1173
+ // Note: Meta-commands (again, undo, save, etc.) are excluded by the isMeta check
1174
+ // in executeTurn before calling this function. No need for string-based exclusion.
1175
+ // Get the action ID from the result
1176
+ const actionId = result.actionId;
1177
+ if (!actionId) {
1178
+ // No action was executed (parse error, etc.)
1179
+ return;
1180
+ }
1181
+ // Extract the parsed command structure
1182
+ let parsedCommand = {
1183
+ verb: result.parsedCommand?.action || input.split(' ')[0]
1184
+ };
1185
+ // If we have a full parsed command structure, use it
1186
+ if (result.parsedCommand) {
1187
+ const parsed = result.parsedCommand;
1188
+ // Handle new ParsedCommand structure (has structure property)
1189
+ if (parsed.structure) {
1190
+ parsedCommand = {
1191
+ verb: parsed.structure.verb?.text || parsed.action,
1192
+ directObject: parsed.structure.directObject?.text,
1193
+ preposition: parsed.structure.preposition?.text,
1194
+ indirectObject: parsed.structure.indirectObject?.text
1195
+ };
1196
+ }
1197
+ // Handle old ParsedCommandV1 structure (directObject at top level)
1198
+ // Use type assertion for backward compatibility
1199
+ else {
1200
+ const v1 = parsed;
1201
+ if (v1.directObject || v1.indirectObject) {
1202
+ parsedCommand = {
1203
+ verb: parsed.action,
1204
+ directObject: v1.directObject?.text,
1205
+ preposition: v1.preposition,
1206
+ indirectObject: v1.indirectObject?.text
1207
+ };
1208
+ }
1209
+ }
1210
+ }
1211
+ // Create the history entry
1212
+ const entry = {
1213
+ actionId,
1214
+ originalText: input,
1215
+ parsedCommand,
1216
+ turnNumber: turn,
1217
+ timestamp: Date.now()
1218
+ };
1219
+ // Add to history
1220
+ if (!historyData.entries) {
1221
+ historyData.entries = [];
1222
+ }
1223
+ historyData.entries.push(entry);
1224
+ // Trim to maxEntries if needed
1225
+ const maxEntries = historyData.maxEntries || 100;
1226
+ if (historyData.entries.length > maxEntries) {
1227
+ historyData.entries = historyData.entries.slice(-maxEntries);
1228
+ }
1229
+ }
1230
+ /**
1231
+ * Process pending platform operations
1232
+ */
1233
+ async processPlatformOperations(turn) {
1234
+ const currentTurn = turn ?? this.context.currentTurn;
1235
+ // Ensure there's an entry for the current turn
1236
+ if (!this.turnEvents.has(currentTurn)) {
1237
+ this.turnEvents.set(currentTurn, []);
1238
+ }
1239
+ // IMPORTANT: Save and clear pending ops at START to prevent infinite recursion
1240
+ // When AGAIN calls executeTurn() recursively, that nested call must not see
1241
+ // the same pending operations, or it will process AGAIN_REQUESTED again.
1242
+ const opsToProcess = [...this.pendingPlatformOps];
1243
+ this.pendingPlatformOps = [];
1244
+ // Process each pending operation
1245
+ for (const platformOp of opsToProcess) {
1246
+ try {
1247
+ switch (platformOp.type) {
1248
+ case core_1.PlatformEventType.SAVE_REQUESTED: {
1249
+ const context = platformOp.payload.context;
1250
+ if (this.saveRestoreHooks?.onSaveRequested) {
1251
+ const saveData = this.createSaveData();
1252
+ // Add any additional context from the platform event
1253
+ if (context?.saveName) {
1254
+ saveData.metadata.description = context.saveName;
1255
+ }
1256
+ if (context?.metadata) {
1257
+ Object.assign(saveData.metadata, context.metadata);
1258
+ }
1259
+ await this.saveRestoreHooks.onSaveRequested(saveData);
1260
+ // Emit completion event
1261
+ const completionEvent = (0, core_1.createSaveCompletedEvent)(true);
1262
+ this.eventSource.emit(completionEvent);
1263
+ this.turnEvents.get(currentTurn)?.push(completionEvent);
1264
+ // Also emit through engine's event emitter for tests
1265
+ this.emit('event', completionEvent);
1266
+ }
1267
+ else {
1268
+ // No save hook registered
1269
+ const errorEvent = (0, core_1.createSaveCompletedEvent)(false, 'No save handler registered');
1270
+ this.eventSource.emit(errorEvent);
1271
+ this.turnEvents.get(currentTurn)?.push(errorEvent);
1272
+ // Also emit through engine's event emitter for tests
1273
+ this.emit('event', errorEvent);
1274
+ }
1275
+ break;
1276
+ }
1277
+ case core_1.PlatformEventType.RESTORE_REQUESTED: {
1278
+ const context = platformOp.payload.context;
1279
+ if (this.saveRestoreHooks?.onRestoreRequested) {
1280
+ const saveData = await this.saveRestoreHooks.onRestoreRequested();
1281
+ if (saveData) {
1282
+ this.loadSaveData(saveData);
1283
+ // Emit completion event
1284
+ const completionEvent = (0, core_1.createRestoreCompletedEvent)(true);
1285
+ this.eventSource.emit(completionEvent);
1286
+ this.turnEvents.get(currentTurn)?.push(completionEvent);
1287
+ // Also emit through engine's event emitter for tests
1288
+ this.emit('event', completionEvent);
1289
+ }
1290
+ else {
1291
+ // User cancelled or no save available
1292
+ const errorEvent = (0, core_1.createRestoreCompletedEvent)(false, 'No save data available or restore cancelled');
1293
+ this.eventSource.emit(errorEvent);
1294
+ this.turnEvents.get(currentTurn)?.push(errorEvent);
1295
+ // Also emit through engine's event emitter for tests
1296
+ this.emit('event', errorEvent);
1297
+ }
1298
+ }
1299
+ else {
1300
+ // No restore hook registered
1301
+ const errorEvent = (0, core_1.createRestoreCompletedEvent)(false, 'No restore handler registered');
1302
+ this.eventSource.emit(errorEvent);
1303
+ this.turnEvents.get(currentTurn)?.push(errorEvent);
1304
+ // Also emit through engine's event emitter for tests
1305
+ this.emit('event', errorEvent);
1306
+ }
1307
+ break;
1308
+ }
1309
+ case core_1.PlatformEventType.QUIT_REQUESTED: {
1310
+ const context = platformOp.payload.context;
1311
+ if (this.saveRestoreHooks?.onQuitRequested) {
1312
+ const shouldQuit = await this.saveRestoreHooks.onQuitRequested(context);
1313
+ if (shouldQuit) {
1314
+ // Stop the engine with quit reason
1315
+ this.stop('quit');
1316
+ // Emit confirmation event
1317
+ const confirmEvent = (0, core_1.createQuitConfirmedEvent)();
1318
+ this.eventSource.emit(confirmEvent);
1319
+ const turnEvents = this.turnEvents.get(currentTurn);
1320
+ if (turnEvents) {
1321
+ turnEvents.push(confirmEvent);
1322
+ }
1323
+ // Also emit through engine's event emitter for tests
1324
+ this.emit('event', confirmEvent);
1325
+ }
1326
+ else {
1327
+ // User cancelled quit
1328
+ const cancelEvent = (0, core_1.createQuitCancelledEvent)();
1329
+ this.eventSource.emit(cancelEvent);
1330
+ const turnEvents = this.turnEvents.get(currentTurn);
1331
+ if (turnEvents) {
1332
+ turnEvents.push(cancelEvent);
1333
+ }
1334
+ // Also emit through engine's event emitter for tests
1335
+ this.emit('event', cancelEvent);
1336
+ }
1337
+ }
1338
+ else {
1339
+ // No quit hook registered, auto-confirm
1340
+ const confirmEvent = (0, core_1.createQuitConfirmedEvent)();
1341
+ this.eventSource.emit(confirmEvent);
1342
+ const turnEvents = this.turnEvents.get(currentTurn);
1343
+ if (turnEvents) {
1344
+ turnEvents.push(confirmEvent);
1345
+ }
1346
+ // Also emit through engine's event emitter for tests
1347
+ this.emit('event', confirmEvent);
1348
+ }
1349
+ break;
1350
+ }
1351
+ case core_1.PlatformEventType.RESTART_REQUESTED: {
1352
+ const context = platformOp.payload.context;
1353
+ if (this.saveRestoreHooks?.onRestartRequested) {
1354
+ const shouldRestart = await this.saveRestoreHooks.onRestartRequested(context);
1355
+ if (shouldRestart) {
1356
+ // Emit completion event
1357
+ const completionEvent = (0, core_1.createRestartCompletedEvent)(true);
1358
+ this.eventSource.emit(completionEvent);
1359
+ this.turnEvents.get(currentTurn)?.push(completionEvent);
1360
+ // Also emit through engine's event emitter for tests
1361
+ this.emit('event', completionEvent);
1362
+ // Re-initialize the story
1363
+ if (this.story) {
1364
+ // Stop first if running
1365
+ if (this.running) {
1366
+ this.stop();
1367
+ }
1368
+ // Reset pronoun context (ADR-089)
1369
+ if (this.parser && (0, parser_interface_1.hasPronounContext)(this.parser)) {
1370
+ this.parser.resetPronounContext();
1371
+ }
1372
+ await this.setStory(this.story);
1373
+ this.start();
1374
+ }
1375
+ }
1376
+ else {
1377
+ // Restart was cancelled
1378
+ const cancelEvent = (0, core_1.createRestartCompletedEvent)(false);
1379
+ this.eventSource.emit(cancelEvent);
1380
+ this.turnEvents.get(currentTurn)?.push(cancelEvent);
1381
+ // Also emit through engine's event emitter for tests
1382
+ this.emit('event', cancelEvent);
1383
+ }
1384
+ }
1385
+ else {
1386
+ // No restart hook registered - default behavior is to restart
1387
+ const completionEvent = (0, core_1.createRestartCompletedEvent)(true);
1388
+ this.eventSource.emit(completionEvent);
1389
+ this.turnEvents.get(currentTurn)?.push(completionEvent);
1390
+ // Also emit through engine's event emitter for tests
1391
+ this.emit('event', completionEvent);
1392
+ // Re-initialize the story
1393
+ if (this.story) {
1394
+ // Stop first if running
1395
+ if (this.running) {
1396
+ this.stop();
1397
+ }
1398
+ // Reset pronoun context (ADR-089)
1399
+ if (this.parser && (0, parser_interface_1.hasPronounContext)(this.parser)) {
1400
+ this.parser.resetPronounContext();
1401
+ }
1402
+ await this.setStory(this.story);
1403
+ this.start();
1404
+ }
1405
+ }
1406
+ break;
1407
+ }
1408
+ case core_1.PlatformEventType.UNDO_REQUESTED: {
1409
+ const previousTurn = this.context.currentTurn;
1410
+ const success = this.undo();
1411
+ if (success) {
1412
+ const completionEvent = (0, core_1.createUndoCompletedEvent)(true, this.context.currentTurn);
1413
+ this.eventSource.emit(completionEvent);
1414
+ this.turnEvents.get(currentTurn)?.push(completionEvent);
1415
+ this.emit('event', completionEvent);
1416
+ }
1417
+ else {
1418
+ const errorEvent = (0, core_1.createUndoCompletedEvent)(false, undefined, 'Nothing to undo');
1419
+ this.eventSource.emit(errorEvent);
1420
+ this.turnEvents.get(currentTurn)?.push(errorEvent);
1421
+ this.emit('event', errorEvent);
1422
+ }
1423
+ break;
1424
+ }
1425
+ case core_1.PlatformEventType.AGAIN_REQUESTED: {
1426
+ const againContext = platformOp.payload.context;
1427
+ if (!againContext?.command) {
1428
+ const errorEvent = (0, core_1.createAgainFailedEvent)('No command to repeat');
1429
+ this.eventSource.emit(errorEvent);
1430
+ this.turnEvents.get(currentTurn)?.push(errorEvent);
1431
+ this.emit('event', errorEvent);
1432
+ break;
1433
+ }
1434
+ // Re-execute the stored command
1435
+ // Note: The repeated command goes through normal validation/execution
1436
+ // and its events will be added to the current turn
1437
+ try {
1438
+ const repeatResult = await this.executeTurn(againContext.command);
1439
+ // Merge the repeated command's events into this turn
1440
+ // (executeTurn already stored them, but we want them in currentTurn's context)
1441
+ // The events are already emitted by executeTurn, no need to re-emit
1442
+ }
1443
+ catch (error) {
1444
+ const errorEvent = (0, core_1.createAgainFailedEvent)(error instanceof Error ? error.message : 'Failed to repeat command');
1445
+ this.eventSource.emit(errorEvent);
1446
+ this.turnEvents.get(currentTurn)?.push(errorEvent);
1447
+ this.emit('event', errorEvent);
1448
+ }
1449
+ break;
1450
+ }
1451
+ }
1452
+ }
1453
+ catch (error) {
1454
+ console.error(`Error processing platform operation ${platformOp.type}:`, error);
1455
+ // Emit appropriate error event based on operation type
1456
+ let errorEvent;
1457
+ switch (platformOp.type) {
1458
+ case core_1.PlatformEventType.SAVE_REQUESTED:
1459
+ errorEvent = (0, core_1.createSaveCompletedEvent)(false, error instanceof Error ? error.message : 'Unknown error');
1460
+ break;
1461
+ case core_1.PlatformEventType.RESTORE_REQUESTED:
1462
+ errorEvent = (0, core_1.createRestoreCompletedEvent)(false, error instanceof Error ? error.message : 'Unknown error');
1463
+ break;
1464
+ case core_1.PlatformEventType.QUIT_REQUESTED:
1465
+ errorEvent = (0, core_1.createQuitCancelledEvent)();
1466
+ break;
1467
+ case core_1.PlatformEventType.RESTART_REQUESTED:
1468
+ errorEvent = (0, core_1.createRestartCompletedEvent)(false);
1469
+ break;
1470
+ case core_1.PlatformEventType.UNDO_REQUESTED:
1471
+ errorEvent = (0, core_1.createUndoCompletedEvent)(false, undefined, error instanceof Error ? error.message : 'Unknown error');
1472
+ break;
1473
+ case core_1.PlatformEventType.AGAIN_REQUESTED:
1474
+ errorEvent = (0, core_1.createAgainFailedEvent)(error instanceof Error ? error.message : 'Unknown error');
1475
+ break;
1476
+ default:
1477
+ continue;
1478
+ }
1479
+ this.eventSource.emit(errorEvent);
1480
+ this.turnEvents.get(currentTurn)?.push(errorEvent);
1481
+ // Also emit through engine's event emitter for tests
1482
+ this.emit('event', errorEvent);
1483
+ }
1484
+ }
1485
+ // Note: pendingPlatformOps was cleared at the start of this function
1486
+ }
1487
+ /**
1488
+ * Emit a game lifecycle event.
1489
+ * All game events now use ISemanticEvent with data in the `data` field.
1490
+ * (IGameEvent with `payload` is deprecated - see ADR-097)
1491
+ */
1492
+ emitGameEvent(event) {
1493
+ // Create a GameEvent for the sequencer (internal type)
1494
+ const gameEvent = {
1495
+ type: event.type,
1496
+ data: {
1497
+ ...(typeof event.data === 'object' && event.data !== null ? event.data : {}),
1498
+ id: event.id,
1499
+ timestamp: event.timestamp,
1500
+ entities: event.entities || {}
1501
+ }
1502
+ };
1503
+ // Sequence and emit
1504
+ const sequencedEvent = event_sequencer_1.eventSequencer.sequence(gameEvent, this.context.currentTurn);
1505
+ this.emit('event', sequencedEvent);
1506
+ // Store in turn events for text-service processing
1507
+ if (this.context.currentTurn > 0) {
1508
+ const turnEvents = this.turnEvents.get(this.context.currentTurn) || [];
1509
+ turnEvents.push(event);
1510
+ this.turnEvents.set(this.context.currentTurn, turnEvents);
1511
+ }
1512
+ }
1513
+ /**
1514
+ * Emit an event to listeners
1515
+ */
1516
+ emit(event, ...args) {
1517
+ const listeners = this.eventListeners.get(event);
1518
+ if (listeners) {
1519
+ for (const listener of listeners) {
1520
+ try {
1521
+ listener(...args);
1522
+ }
1523
+ catch (error) {
1524
+ console.error(`Error in event listener for ${event}:`, error);
1525
+ }
1526
+ }
1527
+ }
1528
+ }
1529
+ /**
1530
+ * Dispatch an event to entity handlers (entity.on)
1531
+ * Entities can define handlers for specific event types
1532
+ */
1533
+ dispatchEntityHandlers(event) {
1534
+ // Get all entities that might have handlers
1535
+ const entities = this.world.getAllEntities();
1536
+ for (const entity of entities) {
1537
+ // Check if entity has event handlers defined
1538
+ const handlers = entity.on;
1539
+ if (!handlers || typeof handlers !== 'object') {
1540
+ continue;
1541
+ }
1542
+ // Check if there's a handler for this event type
1543
+ const handler = handlers[event.type];
1544
+ if (typeof handler === 'function') {
1545
+ try {
1546
+ // Call the handler with the event and world
1547
+ const result = handler(event, this.world);
1548
+ // If handler returns events, add them to the current turn
1549
+ if (Array.isArray(result)) {
1550
+ const turnEvents = this.turnEvents.get(this.context.currentTurn) || [];
1551
+ for (const reactionEvent of result) {
1552
+ turnEvents.push(reactionEvent);
1553
+ this.emit('event', reactionEvent);
1554
+ }
1555
+ this.turnEvents.set(this.context.currentTurn, turnEvents);
1556
+ }
1557
+ }
1558
+ catch (error) {
1559
+ console.error(`Error in entity handler for ${entity.id} on ${event.type}:`, error);
1560
+ }
1561
+ }
1562
+ }
1563
+ }
1564
+ /**
1565
+ * Check if game is over
1566
+ */
1567
+ isGameOver() {
1568
+ // Check story-specific completion
1569
+ if (this.story && this.story.isComplete) {
1570
+ return this.story.isComplete();
1571
+ }
1572
+ // Default: game never ends
1573
+ return false;
1574
+ }
1575
+ /**
1576
+ * Add event listener
1577
+ */
1578
+ on(event, listener) {
1579
+ if (!this.eventListeners.has(event)) {
1580
+ this.eventListeners.set(event, new Set());
1581
+ }
1582
+ this.eventListeners.get(event).add(listener);
1583
+ return this;
1584
+ }
1585
+ /**
1586
+ * Remove event listener
1587
+ */
1588
+ off(event, listener) {
1589
+ const listeners = this.eventListeners.get(event);
1590
+ if (listeners) {
1591
+ listeners.delete(listener);
1592
+ }
1593
+ return this;
1594
+ }
1595
+ }
1596
+ exports.GameEngine = GameEngine;
1597
+ //# sourceMappingURL=game-engine.js.map