@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.
- package/LICENSE +21 -0
- package/README.md +328 -0
- package/dist/action-context-factory.d.ts +11 -0
- package/dist/action-context-factory.d.ts.map +1 -0
- package/dist/action-context-factory.js +258 -0
- package/dist/action-context-factory.js.map +1 -0
- package/dist/capability-dispatch-helper.d.ts +106 -0
- package/dist/capability-dispatch-helper.d.ts.map +1 -0
- package/dist/capability-dispatch-helper.js +269 -0
- package/dist/capability-dispatch-helper.js.map +1 -0
- package/dist/command-executor.d.ts +53 -0
- package/dist/command-executor.d.ts.map +1 -0
- package/dist/command-executor.js +329 -0
- package/dist/command-executor.js.map +1 -0
- package/dist/event-adapter.d.ts +44 -0
- package/dist/event-adapter.d.ts.map +1 -0
- package/dist/event-adapter.js +127 -0
- package/dist/event-adapter.js.map +1 -0
- package/dist/event-sequencer.d.ts +73 -0
- package/dist/event-sequencer.d.ts.map +1 -0
- package/dist/event-sequencer.js +134 -0
- package/dist/event-sequencer.js.map +1 -0
- package/dist/events/event-emitter.d.ts +34 -0
- package/dist/events/event-emitter.d.ts.map +1 -0
- package/dist/events/event-emitter.js +67 -0
- package/dist/events/event-emitter.js.map +1 -0
- package/dist/game-engine.d.ts +292 -0
- package/dist/game-engine.d.ts.map +1 -0
- package/dist/game-engine.js +1631 -0
- package/dist/game-engine.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +62 -0
- package/dist/index.js.map +1 -0
- package/dist/narrative/index.d.ts +5 -0
- package/dist/narrative/index.d.ts.map +1 -0
- package/dist/narrative/index.js +10 -0
- package/dist/narrative/index.js.map +1 -0
- package/dist/narrative/narrative-settings.d.ts +73 -0
- package/dist/narrative/narrative-settings.d.ts.map +1 -0
- package/dist/narrative/narrative-settings.js +28 -0
- package/dist/narrative/narrative-settings.js.map +1 -0
- package/dist/parser-interface.d.ts +77 -0
- package/dist/parser-interface.d.ts.map +1 -0
- package/dist/parser-interface.js +48 -0
- package/dist/parser-interface.js.map +1 -0
- package/dist/platform-operations.d.ts +83 -0
- package/dist/platform-operations.d.ts.map +1 -0
- package/dist/platform-operations.js +218 -0
- package/dist/platform-operations.js.map +1 -0
- package/dist/save-restore-service.d.ts +133 -0
- package/dist/save-restore-service.d.ts.map +1 -0
- package/dist/save-restore-service.js +446 -0
- package/dist/save-restore-service.js.map +1 -0
- package/dist/scheduler/index.d.ts +9 -0
- package/dist/scheduler/index.d.ts.map +1 -0
- package/dist/scheduler/index.js +25 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/scheduler/scheduler-service.d.ts +75 -0
- package/dist/scheduler/scheduler-service.d.ts.map +1 -0
- package/dist/scheduler/scheduler-service.js +310 -0
- package/dist/scheduler/scheduler-service.js.map +1 -0
- package/dist/scheduler/seeded-random.d.ts +7 -0
- package/dist/scheduler/seeded-random.d.ts.map +1 -0
- package/dist/scheduler/seeded-random.js +11 -0
- package/dist/scheduler/seeded-random.js.map +1 -0
- package/dist/scheduler/types.d.ts +134 -0
- package/dist/scheduler/types.d.ts.map +1 -0
- package/dist/scheduler/types.js +9 -0
- package/dist/scheduler/types.js.map +1 -0
- package/dist/shared-data-keys.d.ts +53 -0
- package/dist/shared-data-keys.d.ts.map +1 -0
- package/dist/shared-data-keys.js +29 -0
- package/dist/shared-data-keys.js.map +1 -0
- package/dist/story.d.ts +211 -0
- package/dist/story.d.ts.map +1 -0
- package/dist/story.js +60 -0
- package/dist/story.js.map +1 -0
- package/dist/test-helpers/mock-text-service.d.ts +11 -0
- package/dist/test-helpers/mock-text-service.d.ts.map +1 -0
- package/dist/test-helpers/mock-text-service.js +47 -0
- package/dist/test-helpers/mock-text-service.js.map +1 -0
- package/dist/turn-event-processor.d.ts +89 -0
- package/dist/turn-event-processor.d.ts.map +1 -0
- package/dist/turn-event-processor.js +144 -0
- package/dist/turn-event-processor.js.map +1 -0
- package/dist/types.d.ts +214 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/vocabulary-manager.d.ts +35 -0
- package/dist/vocabulary-manager.d.ts.map +1 -0
- package/dist/vocabulary-manager.js +74 -0
- package/dist/vocabulary-manager.js.map +1 -0
- 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
|