@vylos/core 0.1.0
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/package.json +34 -0
- package/src/components/app/EngineView.vue +40 -0
- package/src/components/app/GameShell.vue +265 -0
- package/src/components/app/LoadingScreen.vue +10 -0
- package/src/components/app/MainMenu.vue +243 -0
- package/src/components/core/BackgroundLayer.vue +27 -0
- package/src/components/core/ChoicePanel.vue +115 -0
- package/src/components/core/CustomOverlay.vue +27 -0
- package/src/components/core/DialogueBox.vue +144 -0
- package/src/components/core/DrawableOverlay.vue +109 -0
- package/src/components/core/ForegroundLayer.vue +31 -0
- package/src/components/menu/ActionOverlay.vue +126 -0
- package/src/components/menu/LocationOverlay.vue +136 -0
- package/src/components/menu/PauseMenu.vue +196 -0
- package/src/components/menu/SaveLoadMenu.vue +377 -0
- package/src/components/menu/SettingsMenu.vue +111 -0
- package/src/components/menu/TopBar.vue +65 -0
- package/src/composables/useConfig.ts +4 -0
- package/src/composables/useEngine.ts +16 -0
- package/src/composables/useGameState.ts +9 -0
- package/src/composables/useLanguage.ts +31 -0
- package/src/engine/core/CheckpointManager.ts +122 -0
- package/src/engine/core/Engine.ts +272 -0
- package/src/engine/core/EngineFactory.ts +102 -0
- package/src/engine/core/EventRunner.ts +488 -0
- package/src/engine/errors/EventEndError.ts +7 -0
- package/src/engine/errors/InterruptSignal.ts +10 -0
- package/src/engine/errors/JumpSignal.ts +10 -0
- package/src/engine/errors/StateValidationError.ts +13 -0
- package/src/engine/managers/ActionManager.ts +62 -0
- package/src/engine/managers/EventManager.ts +166 -0
- package/src/engine/managers/HistoryManager.ts +84 -0
- package/src/engine/managers/InputManager.ts +117 -0
- package/src/engine/managers/LanguageManager.ts +51 -0
- package/src/engine/managers/LocationManager.ts +76 -0
- package/src/engine/managers/NavigationManager.ts +75 -0
- package/src/engine/managers/SaveManager.ts +86 -0
- package/src/engine/managers/SettingsManager.ts +70 -0
- package/src/engine/managers/WaitManager.ts +47 -0
- package/src/engine/schemas/baseGameState.schema.ts +19 -0
- package/src/engine/schemas/checkpoint.schema.ts +11 -0
- package/src/engine/schemas/engineState.schema.ts +59 -0
- package/src/engine/schemas/location.schema.ts +21 -0
- package/src/engine/storage/VylosStorage.ts +131 -0
- package/src/engine/types/actions.ts +20 -0
- package/src/engine/types/checkpoint.ts +31 -0
- package/src/engine/types/config.ts +9 -0
- package/src/engine/types/dialogue.ts +15 -0
- package/src/engine/types/engine.ts +85 -0
- package/src/engine/types/events.ts +117 -0
- package/src/engine/types/game-state.ts +15 -0
- package/src/engine/types/index.ts +10 -0
- package/src/engine/types/locations.ts +32 -0
- package/src/engine/types/plugin.ts +11 -0
- package/src/engine/types/save.ts +40 -0
- package/src/engine/utils/TimeHelper.ts +39 -0
- package/src/engine/utils/logger.ts +43 -0
- package/src/env.d.ts +17 -0
- package/src/index.ts +74 -0
- package/src/stores/engineState.ts +127 -0
- package/src/stores/gameState.ts +49 -0
- package/tests/engine/ActionManager.test.ts +94 -0
- package/tests/engine/CheckpointManager.test.ts +136 -0
- package/tests/engine/EventManager.test.ts +145 -0
- package/tests/engine/EventRunner.test.ts +318 -0
- package/tests/engine/HistoryManager.test.ts +113 -0
- package/tests/engine/LocationManager.test.ts +128 -0
- package/tests/engine/schemas.test.ts +250 -0
- package/tests/engine/utils.test.ts +75 -0
- package/tests/integration/game-loop.test.ts +201 -0
- package/tests/safety/event-validation.test.ts +102 -0
- package/tests/safety/state-schema.test.ts +96 -0
- package/tests/setup.ts +2 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import { toRaw } from 'vue';
|
|
2
|
+
import type {
|
|
3
|
+
VylosAPI,
|
|
4
|
+
VylosEvent,
|
|
5
|
+
BaseGameState,
|
|
6
|
+
TextEntry,
|
|
7
|
+
SayOptions,
|
|
8
|
+
ChoiceItem,
|
|
9
|
+
CheckpointType,
|
|
10
|
+
ChoiceOption,
|
|
11
|
+
DialogueState,
|
|
12
|
+
} from '../types';
|
|
13
|
+
import { CheckpointManager } from './CheckpointManager';
|
|
14
|
+
import { WaitManager } from '../managers/WaitManager';
|
|
15
|
+
import { JumpSignal } from '../errors/JumpSignal';
|
|
16
|
+
import { EventEndError } from '../errors/EventEndError';
|
|
17
|
+
import { InterruptSignal } from '../errors/InterruptSignal';
|
|
18
|
+
import { logger } from '../utils/logger';
|
|
19
|
+
import { interpolate } from '../utils/TimeHelper';
|
|
20
|
+
|
|
21
|
+
export interface EventRunnerCallbacks {
|
|
22
|
+
/** Called when dialogue should be displayed */
|
|
23
|
+
onSay(text: string, speaker: string | null): void;
|
|
24
|
+
/** Called when choices should be displayed */
|
|
25
|
+
onChoice(options: Array<{ text: string; value: string; disabled?: boolean }>): void;
|
|
26
|
+
/** Called to update background */
|
|
27
|
+
onSetBackground(path: string): void;
|
|
28
|
+
/** Called to update foreground */
|
|
29
|
+
onSetForeground(path: string | null): void;
|
|
30
|
+
/** Called to show overlay */
|
|
31
|
+
onShowOverlay(componentId: string, props?: Record<string, unknown>): void;
|
|
32
|
+
/** Called to hide overlay */
|
|
33
|
+
onHideOverlay(): void;
|
|
34
|
+
/** Called when location changes */
|
|
35
|
+
onSetLocation(locationId: string): void;
|
|
36
|
+
/** Called to clear dialogue/choices (between steps) */
|
|
37
|
+
onClear(): void;
|
|
38
|
+
/** Resolve a TextEntry to a string using current language */
|
|
39
|
+
resolveText(entry: string | TextEntry): string;
|
|
40
|
+
/** Get current game state */
|
|
41
|
+
getState(): BaseGameState;
|
|
42
|
+
/** Set game state (after rollback) */
|
|
43
|
+
setState(state: BaseGameState): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Data returned when browsing history steps */
|
|
47
|
+
export interface HistoryStep {
|
|
48
|
+
type: 'say' | 'choice';
|
|
49
|
+
dialogue?: DialogueState | null;
|
|
50
|
+
choiceOptions?: ChoiceOption[];
|
|
51
|
+
choiceResult?: string;
|
|
52
|
+
stepIndex: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* EventRunner implements VylosAPI and drives event execution.
|
|
57
|
+
*
|
|
58
|
+
* It uses native async/await: each `say()` / `choice()` pauses the event function.
|
|
59
|
+
* The WaitManager creates promises that the UI resolves when the player interacts.
|
|
60
|
+
*
|
|
61
|
+
* During rollback, it replays the event by instantly resolving promises
|
|
62
|
+
* with stored checkpoint data until reaching the target step.
|
|
63
|
+
*/
|
|
64
|
+
export class EventRunner implements VylosAPI {
|
|
65
|
+
readonly checkpoints: CheckpointManager;
|
|
66
|
+
private waitManager = new WaitManager();
|
|
67
|
+
private callbacks: EventRunnerCallbacks;
|
|
68
|
+
private currentStep = 0;
|
|
69
|
+
private interrupted = false;
|
|
70
|
+
|
|
71
|
+
/** History browsing index (-1 = live, not browsing) */
|
|
72
|
+
private browseIndex = -1;
|
|
73
|
+
/** The live dialogue being displayed when history browsing started */
|
|
74
|
+
private liveDialogue: { text: string; speaker: string | null } | null = null;
|
|
75
|
+
/** Current background path (tracked for checkpoint storage) */
|
|
76
|
+
private currentBackground: string | null = null;
|
|
77
|
+
|
|
78
|
+
/** Snapshot of game state before event started (for redo) */
|
|
79
|
+
private initialState: BaseGameState | null = null;
|
|
80
|
+
/** Reference to the currently executing event (for redo) */
|
|
81
|
+
private currentEvent: VylosEvent | null = null;
|
|
82
|
+
/** Pending redo request (set by UI, consumed by redo loop) */
|
|
83
|
+
private pendingRedo: { step: number; choice: string } | null = null;
|
|
84
|
+
|
|
85
|
+
constructor(callbacks: EventRunnerCallbacks) {
|
|
86
|
+
this.callbacks = callbacks;
|
|
87
|
+
this.checkpoints = new CheckpointManager();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Whether the player is browsing text history */
|
|
91
|
+
get isBrowsingHistory(): boolean {
|
|
92
|
+
return this.browseIndex >= 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Current event ID (null if no event executing) */
|
|
96
|
+
get currentEventId(): string | null {
|
|
97
|
+
return this.currentEvent?.id ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Get initial state snapshot for save (deep clone) */
|
|
101
|
+
getInitialState(): BaseGameState | null {
|
|
102
|
+
return this.initialState ? structuredClone(this.initialState) : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Get the live dialogue for restoring display after exiting history */
|
|
106
|
+
getLiveDialogue(): { text: string; speaker: string | null } | null {
|
|
107
|
+
return this.liveDialogue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Go back one step in history. Returns step data or null if at beginning. */
|
|
111
|
+
historyBack(): HistoryStep | null {
|
|
112
|
+
const targetIndex = this.browseIndex === -1
|
|
113
|
+
? this.checkpoints.count - 1 // Start from last completed checkpoint
|
|
114
|
+
: this.browseIndex - 1;
|
|
115
|
+
|
|
116
|
+
if (targetIndex < 0) return null;
|
|
117
|
+
|
|
118
|
+
// Find a browsable checkpoint (say with dialogue, or choice with options)
|
|
119
|
+
let idx = targetIndex;
|
|
120
|
+
while (idx >= 0) {
|
|
121
|
+
const cp = this.checkpoints.getAt(idx);
|
|
122
|
+
if (cp?.dialogue || cp?.choiceOptions) break;
|
|
123
|
+
idx--;
|
|
124
|
+
}
|
|
125
|
+
if (idx < 0) return null;
|
|
126
|
+
|
|
127
|
+
this.browseIndex = idx;
|
|
128
|
+
return this.buildHistoryStep(idx);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Go forward one step in history. Returns step data or null if exiting history. */
|
|
132
|
+
historyForward(): HistoryStep | null {
|
|
133
|
+
if (this.browseIndex === -1) return null;
|
|
134
|
+
|
|
135
|
+
// Find next browsable checkpoint after current
|
|
136
|
+
let idx = this.browseIndex + 1;
|
|
137
|
+
while (idx < this.checkpoints.count) {
|
|
138
|
+
const cp = this.checkpoints.getAt(idx);
|
|
139
|
+
if (cp?.dialogue || cp?.choiceOptions) break;
|
|
140
|
+
idx++;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (idx >= this.checkpoints.count) {
|
|
144
|
+
// Reached the end — return to live
|
|
145
|
+
this.browseIndex = -1;
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.browseIndex = idx;
|
|
150
|
+
return this.buildHistoryStep(idx);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Exit history browsing without returning step data */
|
|
154
|
+
exitHistoryBrowsing(): void {
|
|
155
|
+
this.browseIndex = -1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Request a choice redo from history (called by UI) */
|
|
159
|
+
requestRedoChoice(stepIndex: number, newChoice: string): void {
|
|
160
|
+
this.pendingRedo = { step: stepIndex, choice: newChoice };
|
|
161
|
+
this.waitManager.reject(new InterruptSignal('choice redo'));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Execute an event, handling jump signals, interrupts, and redo */
|
|
165
|
+
async executeEvent(event: VylosEvent): Promise<void> {
|
|
166
|
+
this.initialState = structuredClone(toRaw(this.callbacks.getState()));
|
|
167
|
+
this.currentEvent = event;
|
|
168
|
+
this.checkpoints.clear();
|
|
169
|
+
this.currentStep = 0;
|
|
170
|
+
this.interrupted = false;
|
|
171
|
+
this.browseIndex = -1;
|
|
172
|
+
this.liveDialogue = null;
|
|
173
|
+
this.pendingRedo = null;
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
await this.runEventExecution(event);
|
|
177
|
+
} finally {
|
|
178
|
+
this.callbacks.onClear();
|
|
179
|
+
this.initialState = null;
|
|
180
|
+
this.currentEvent = null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Re-execute an event for rollback, fast-forwarding to targetStep */
|
|
185
|
+
async rollbackTo(event: VylosEvent, targetStep: number): Promise<void> {
|
|
186
|
+
const restoredState = this.checkpoints.prepareRollback(targetStep);
|
|
187
|
+
if (!restoredState) {
|
|
188
|
+
logger.warn('Cannot rollback to step', targetStep);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.callbacks.setState(restoredState);
|
|
193
|
+
this.currentStep = 0;
|
|
194
|
+
this.interrupted = false;
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const state = this.callbacks.getState();
|
|
198
|
+
await event.execute(this, state);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error instanceof JumpSignal) throw error;
|
|
201
|
+
if (error instanceof EventEndError) return;
|
|
202
|
+
if (error instanceof InterruptSignal) return;
|
|
203
|
+
throw error;
|
|
204
|
+
} finally {
|
|
205
|
+
this.callbacks.onClear();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Resume a saved mid-event execution (for load). Checkpoints must be restored externally first. */
|
|
210
|
+
async resumeEvent(event: VylosEvent, savedInitialState: BaseGameState): Promise<void> {
|
|
211
|
+
this.initialState = structuredClone(savedInitialState);
|
|
212
|
+
this.currentEvent = event;
|
|
213
|
+
this.currentStep = 0;
|
|
214
|
+
this.interrupted = false;
|
|
215
|
+
this.browseIndex = -1;
|
|
216
|
+
this.liveDialogue = null;
|
|
217
|
+
this.pendingRedo = null;
|
|
218
|
+
|
|
219
|
+
// Restore to initial state, then replay through all stored checkpoints
|
|
220
|
+
this.callbacks.setState(structuredClone(savedInitialState));
|
|
221
|
+
this.checkpoints.setReplayTo(this.checkpoints.count);
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await this.runEventExecution(event);
|
|
225
|
+
} finally {
|
|
226
|
+
this.callbacks.onClear();
|
|
227
|
+
this.initialState = null;
|
|
228
|
+
this.currentEvent = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Interrupt the current event execution */
|
|
233
|
+
interrupt(reason: string): void {
|
|
234
|
+
this.interrupted = true;
|
|
235
|
+
this.waitManager.reject(new InterruptSignal(reason));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// --- VylosAPI Implementation ---
|
|
239
|
+
|
|
240
|
+
async say(text: string | TextEntry, options?: SayOptions): Promise<void> {
|
|
241
|
+
this.checkInterrupt();
|
|
242
|
+
|
|
243
|
+
let resolvedText = this.callbacks.resolveText(text);
|
|
244
|
+
|
|
245
|
+
// Interpolate variables
|
|
246
|
+
if (options?.variables) {
|
|
247
|
+
resolvedText = interpolate(resolvedText, options.variables);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Resolve speaker
|
|
251
|
+
let speaker: string | null = null;
|
|
252
|
+
if (options?.from) {
|
|
253
|
+
speaker = this.callbacks.resolveText(options.from);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// If replaying, fast-forward: capture checkpoint and resolve immediately
|
|
257
|
+
if (this.checkpoints.isReplaying) {
|
|
258
|
+
this.checkpoints.advanceReplay();
|
|
259
|
+
this.currentStep++;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Track the live dialogue (for history browsing restoration)
|
|
264
|
+
this.liveDialogue = { text: resolvedText, speaker };
|
|
265
|
+
|
|
266
|
+
// Show dialogue in UI
|
|
267
|
+
this.callbacks.onSay(resolvedText, speaker);
|
|
268
|
+
|
|
269
|
+
// Wait for player to click continue
|
|
270
|
+
await this.waitManager.wait();
|
|
271
|
+
|
|
272
|
+
// Exit history browsing if active
|
|
273
|
+
this.browseIndex = -1;
|
|
274
|
+
|
|
275
|
+
// Capture checkpoint after interaction (store dialogue for history)
|
|
276
|
+
this.checkpoints.capture(
|
|
277
|
+
this.callbacks.getState(),
|
|
278
|
+
'say' as CheckpointType,
|
|
279
|
+
undefined,
|
|
280
|
+
{
|
|
281
|
+
dialogue: { text: resolvedText, speaker, isNarration: !speaker },
|
|
282
|
+
background: this.currentBackground,
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
this.liveDialogue = null;
|
|
287
|
+
this.callbacks.onClear();
|
|
288
|
+
this.currentStep++;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async choice<T extends string>(items: ChoiceItem<T>[]): Promise<T> {
|
|
292
|
+
this.checkInterrupt();
|
|
293
|
+
|
|
294
|
+
// Filter by condition
|
|
295
|
+
const available = items.filter(item => !item.condition || item.condition());
|
|
296
|
+
|
|
297
|
+
// Resolve text entries
|
|
298
|
+
const resolvedOptions = available.map(item => ({
|
|
299
|
+
text: this.callbacks.resolveText(item.text),
|
|
300
|
+
value: item.value,
|
|
301
|
+
disabled: item.disabled,
|
|
302
|
+
}));
|
|
303
|
+
|
|
304
|
+
// If replaying, return stored choice result
|
|
305
|
+
if (this.checkpoints.isReplaying) {
|
|
306
|
+
const storedResult = this.checkpoints.getReplayChoiceResult();
|
|
307
|
+
this.checkpoints.advanceReplay();
|
|
308
|
+
this.currentStep++;
|
|
309
|
+
if (storedResult !== undefined) {
|
|
310
|
+
return storedResult as T;
|
|
311
|
+
}
|
|
312
|
+
// Fallback: if no stored result, pick first option
|
|
313
|
+
logger.warn('No stored choice result during replay, using first option');
|
|
314
|
+
return resolvedOptions[0].value as T;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Show choices in UI
|
|
318
|
+
this.callbacks.onChoice(resolvedOptions);
|
|
319
|
+
|
|
320
|
+
// Wait for player selection
|
|
321
|
+
const result = await this.waitManager.wait<string>();
|
|
322
|
+
|
|
323
|
+
// Capture checkpoint with choice result and options (for history redo)
|
|
324
|
+
this.checkpoints.capture(
|
|
325
|
+
this.callbacks.getState(),
|
|
326
|
+
'choice' as CheckpointType,
|
|
327
|
+
result,
|
|
328
|
+
{ choiceOptions: resolvedOptions },
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
this.callbacks.onClear();
|
|
332
|
+
this.currentStep++;
|
|
333
|
+
return result as T;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
setBackground(path: string): void {
|
|
337
|
+
this.currentBackground = path;
|
|
338
|
+
this.callbacks.onSetBackground(path);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
setForeground(path: string | null): void {
|
|
342
|
+
this.callbacks.onSetForeground(path);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async showOverlay(componentId: string, props?: Record<string, unknown>): Promise<void> {
|
|
346
|
+
this.checkInterrupt();
|
|
347
|
+
|
|
348
|
+
if (this.checkpoints.isReplaying) {
|
|
349
|
+
this.checkpoints.advanceReplay();
|
|
350
|
+
this.currentStep++;
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
this.callbacks.onShowOverlay(componentId, props);
|
|
355
|
+
await this.waitManager.wait();
|
|
356
|
+
|
|
357
|
+
this.checkpoints.capture(
|
|
358
|
+
this.callbacks.getState(),
|
|
359
|
+
'overlay' as CheckpointType,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
this.currentStep++;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
hideOverlay(): void {
|
|
366
|
+
this.callbacks.onHideOverlay();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
jump(eventId: string): never {
|
|
370
|
+
throw new JumpSignal(eventId);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
end(): never {
|
|
374
|
+
throw new EventEndError();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async wait(ms: number): Promise<void> {
|
|
378
|
+
this.checkInterrupt();
|
|
379
|
+
|
|
380
|
+
if (this.checkpoints.isReplaying) {
|
|
381
|
+
this.checkpoints.advanceReplay();
|
|
382
|
+
this.currentStep++;
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await new Promise<void>(resolve => setTimeout(resolve, ms));
|
|
387
|
+
|
|
388
|
+
this.checkpoints.capture(
|
|
389
|
+
this.callbacks.getState(),
|
|
390
|
+
'wait' as CheckpointType,
|
|
391
|
+
);
|
|
392
|
+
this.currentStep++;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
setLocation(locationId: string): void {
|
|
396
|
+
this.callbacks.onSetLocation(locationId);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
playSfx(_path: string): void {
|
|
400
|
+
// TODO: Audio system
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
playMusic(_path: string): void {
|
|
404
|
+
// TODO: Audio system
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
stopMusic(): void {
|
|
408
|
+
// TODO: Audio system
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Resolve the current wait (called by UI) */
|
|
412
|
+
resolveWait(value?: unknown): void {
|
|
413
|
+
this.waitManager.resolve(value);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// --- Private helpers ---
|
|
417
|
+
|
|
418
|
+
/** Build a HistoryStep from a checkpoint at the given index */
|
|
419
|
+
private buildHistoryStep(idx: number): HistoryStep {
|
|
420
|
+
const cp = this.checkpoints.getAt(idx)!;
|
|
421
|
+
if (cp.choiceOptions) {
|
|
422
|
+
return {
|
|
423
|
+
type: 'choice',
|
|
424
|
+
choiceOptions: cp.choiceOptions,
|
|
425
|
+
choiceResult: cp.choiceResult,
|
|
426
|
+
stepIndex: idx,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
type: 'say',
|
|
431
|
+
dialogue: cp.dialogue,
|
|
432
|
+
stepIndex: idx,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Inner execution loop that supports redo by restarting the event */
|
|
437
|
+
private async runEventExecution(event: VylosEvent): Promise<void> {
|
|
438
|
+
while (true) {
|
|
439
|
+
try {
|
|
440
|
+
const state = this.callbacks.getState();
|
|
441
|
+
await event.execute(this, state);
|
|
442
|
+
return; // success — event completed normally
|
|
443
|
+
} catch (error) {
|
|
444
|
+
if (error instanceof InterruptSignal) {
|
|
445
|
+
if (this.pendingRedo) {
|
|
446
|
+
this.setupRedo();
|
|
447
|
+
continue; // retry with redo setup
|
|
448
|
+
}
|
|
449
|
+
logger.debug('Event interrupted:', error.reason);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (error instanceof JumpSignal) throw error;
|
|
453
|
+
if (error instanceof EventEndError) {
|
|
454
|
+
logger.debug('Event ended normally');
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Prepare for redo: inject new choice, set replay, restore initial state */
|
|
463
|
+
private setupRedo(): void {
|
|
464
|
+
const redo = this.pendingRedo!;
|
|
465
|
+
this.pendingRedo = null;
|
|
466
|
+
|
|
467
|
+
// Inject the new choice at the target step
|
|
468
|
+
this.checkpoints.updateChoiceAt(redo.step, redo.choice);
|
|
469
|
+
// Replay from beginning through the target step (inclusive), then live
|
|
470
|
+
this.checkpoints.setReplayTo(redo.step + 1);
|
|
471
|
+
|
|
472
|
+
// Restore game state to before the event started
|
|
473
|
+
this.callbacks.setState(structuredClone(this.initialState!));
|
|
474
|
+
|
|
475
|
+
// Reset execution state
|
|
476
|
+
this.currentStep = 0;
|
|
477
|
+
this.browseIndex = -1;
|
|
478
|
+
this.liveDialogue = null;
|
|
479
|
+
this.interrupted = false;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Check if execution should be interrupted */
|
|
483
|
+
private checkInterrupt(): void {
|
|
484
|
+
if (this.interrupted) {
|
|
485
|
+
throw new InterruptSignal('execution interrupted');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Thrown when engine needs to interrupt event execution (e.g., load game) */
|
|
2
|
+
export class InterruptSignal extends Error {
|
|
3
|
+
readonly reason: string;
|
|
4
|
+
|
|
5
|
+
constructor(reason: string) {
|
|
6
|
+
super(`Event interrupted: ${reason}`);
|
|
7
|
+
this.name = 'InterruptSignal';
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Thrown by engine.jump() to transfer control to another event */
|
|
2
|
+
export class JumpSignal extends Error {
|
|
3
|
+
readonly targetEventId: string;
|
|
4
|
+
|
|
5
|
+
constructor(targetEventId: string) {
|
|
6
|
+
super(`Jump to event: ${targetEventId}`);
|
|
7
|
+
this.name = 'JumpSignal';
|
|
8
|
+
this.targetEventId = targetEventId;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ZodError } from 'zod';
|
|
2
|
+
|
|
3
|
+
/** Thrown when state fails Zod validation after event execution */
|
|
4
|
+
export class StateValidationError extends Error {
|
|
5
|
+
readonly zodError: ZodError;
|
|
6
|
+
|
|
7
|
+
constructor(zodError: ZodError) {
|
|
8
|
+
const issues = zodError.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', ');
|
|
9
|
+
super(`State validation failed: ${issues}`);
|
|
10
|
+
this.name = 'StateValidationError';
|
|
11
|
+
this.zodError = zodError;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { VylosAction, BaseGameState, TextEntry } from '../types';
|
|
2
|
+
import { logger } from '../utils/logger';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Manages player actions: registration, filtering by location and unlock conditions.
|
|
6
|
+
*/
|
|
7
|
+
export class ActionManager {
|
|
8
|
+
private actions = new Map<string, VylosAction>();
|
|
9
|
+
|
|
10
|
+
/** Register an action */
|
|
11
|
+
register(action: VylosAction): void {
|
|
12
|
+
this.actions.set(action.id, action);
|
|
13
|
+
logger.debug(`Action registered: ${action.id}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Register multiple actions */
|
|
17
|
+
registerAll(actions: VylosAction[]): void {
|
|
18
|
+
for (const action of actions) {
|
|
19
|
+
this.register(action);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Get an action by ID */
|
|
24
|
+
get(id: string): VylosAction | undefined {
|
|
25
|
+
return this.actions.get(id);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Get all available actions for a location, filtered by unlock conditions */
|
|
29
|
+
getAvailable(locationId: string, state: BaseGameState): VylosAction[] {
|
|
30
|
+
const available: VylosAction[] = [];
|
|
31
|
+
|
|
32
|
+
for (const action of this.actions.values()) {
|
|
33
|
+
// Filter by location (global actions have no locationId)
|
|
34
|
+
if (action.locationId && action.locationId !== locationId) continue;
|
|
35
|
+
|
|
36
|
+
// Check unlock condition
|
|
37
|
+
if (action.unlocked && !action.unlocked(state)) continue;
|
|
38
|
+
|
|
39
|
+
available.push(action);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return available;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Execute an action by ID */
|
|
46
|
+
execute(id: string, state: BaseGameState): boolean {
|
|
47
|
+
const action = this.actions.get(id);
|
|
48
|
+
if (!action) {
|
|
49
|
+
logger.warn(`Action not found: ${id}`);
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
action.execute(state);
|
|
54
|
+
logger.debug(`Action executed: ${id}`);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Clear all actions */
|
|
59
|
+
clear(): void {
|
|
60
|
+
this.actions.clear();
|
|
61
|
+
}
|
|
62
|
+
}
|