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