@kawaijs/runtime 0.1.11 → 0.1.13

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/dist/vm.js CHANGED
@@ -1,32 +1,91 @@
1
1
  import { cloneState, createInitialState } from './state.js';
2
2
  import { HistoryManager } from './history.js';
3
- import { SaveManager } from './save.js';
4
- import { applySetOperation, evaluateCondition } from './evaluator.js';
3
+ import { computeStoryHash, SaveManager } from './save.js';
4
+ import { applySetOperation, evaluateCondition, interpolateVariables, isSafeKey, resolveSpriteZ } from './evaluator.js';
5
5
  export class StoryVM {
6
+ storyHash;
6
7
  story;
7
8
  state;
8
9
  snapshotStack = [];
9
10
  historyManager;
10
11
  saveManager;
12
+ maxSnapshots;
13
+ maxCallDepth;
14
+ maxInstructionsPerBurst;
11
15
  stateChangeListeners = new Set();
12
16
  audioEventListeners = new Set();
17
+ cameraEventListeners = new Set();
18
+ errorListeners = new Set();
19
+ tickListeners = new Set();
20
+ executionTrace = [];
21
+ virtualTimeMs = 0;
13
22
  isExecuting = false;
14
- constructor(story, saveManager) {
23
+ constructor(story, saveManagerOrOptions) {
15
24
  this.story = story;
16
- const startLabel = story.meta.startLabel ?? 'start';
25
+ this.storyHash = computeStoryHash(story);
26
+ const startLabel = story.meta?.startLabel ?? 'start';
17
27
  this.state = createInitialState(startLabel);
18
28
  this.historyManager = new HistoryManager();
19
- this.saveManager = saveManager ?? new SaveManager();
29
+ let sm;
30
+ let maxSnaps = 250;
31
+ let maxDepth = 100;
32
+ let maxBurst = 10_000;
33
+ if (saveManagerOrOptions instanceof SaveManager) {
34
+ sm = saveManagerOrOptions;
35
+ }
36
+ else if (saveManagerOrOptions) {
37
+ sm = saveManagerOrOptions.saveManager;
38
+ if (typeof saveManagerOrOptions.maxSnapshots === 'number') {
39
+ maxSnaps = saveManagerOrOptions.maxSnapshots;
40
+ }
41
+ if (typeof saveManagerOrOptions.maxCallDepth === 'number') {
42
+ maxDepth = saveManagerOrOptions.maxCallDepth;
43
+ }
44
+ if (typeof saveManagerOrOptions.maxInstructionsPerBurst === 'number') {
45
+ maxBurst = saveManagerOrOptions.maxInstructionsPerBurst;
46
+ }
47
+ }
48
+ this.saveManager = sm ?? new SaveManager();
49
+ this.maxSnapshots = Math.max(1, maxSnaps);
50
+ this.maxCallDepth = Math.max(1, maxDepth);
51
+ this.maxInstructionsPerBurst = Math.max(1, maxBurst);
20
52
  }
21
53
  getState() {
22
54
  return this.state;
23
55
  }
56
+ getStory() {
57
+ return this.story;
58
+ }
24
59
  getHistoryManager() {
25
60
  return this.historyManager;
26
61
  }
27
62
  getSaveManager() {
28
63
  return this.saveManager;
29
64
  }
65
+ getExecutionTrace() {
66
+ return [...this.executionTrace];
67
+ }
68
+ clearExecutionTrace() {
69
+ this.executionTrace = [];
70
+ }
71
+ recordTrace(entry) {
72
+ this.executionTrace.push(entry);
73
+ }
74
+ getVirtualTime() {
75
+ return this.virtualTimeMs;
76
+ }
77
+ tick(deltaMs) {
78
+ if (deltaMs <= 0)
79
+ return;
80
+ this.virtualTimeMs += deltaMs;
81
+ for (const listener of this.tickListeners) {
82
+ listener(deltaMs, this.virtualTimeMs);
83
+ }
84
+ }
85
+ onTick(listener) {
86
+ this.tickListeners.add(listener);
87
+ return () => this.tickListeners.delete(listener);
88
+ }
30
89
  onStateChange(listener) {
31
90
  this.stateChangeListeners.add(listener);
32
91
  return () => this.stateChangeListeners.delete(listener);
@@ -35,13 +94,42 @@ export class StoryVM {
35
94
  this.audioEventListeners.add(listener);
36
95
  return () => this.audioEventListeners.delete(listener);
37
96
  }
38
- start() {
39
- const startLabel = this.story.meta.startLabel ?? 'start';
97
+ onCameraEvent(listener) {
98
+ this.cameraEventListeners.add(listener);
99
+ return () => this.cameraEventListeners.delete(listener);
100
+ }
101
+ onError(listener) {
102
+ this.errorListeners.add(listener);
103
+ return () => this.errorListeners.delete(listener);
104
+ }
105
+ emitCameraEvent(event) {
106
+ for (const listener of this.cameraEventListeners) {
107
+ listener(event);
108
+ }
109
+ }
110
+ emitError(error) {
111
+ for (const listener of this.errorListeners) {
112
+ try {
113
+ listener(error);
114
+ }
115
+ catch { }
116
+ }
117
+ }
118
+ start(atLabel) {
119
+ if (this.isExecuting) {
120
+ throw new Error('Cannot call start() while the story VM is already executing instructions.');
121
+ }
122
+ const startLabel = atLabel ?? this.story.meta?.startLabel ?? 'start';
40
123
  if (!this.story.labels[startLabel]) {
41
124
  throw new Error(`Cannot start story: Start label '${startLabel}' not found in story package.`);
42
125
  }
126
+ const prevMusic = this.state.audio.music;
127
+ this.recordTrace(`START ${startLabel}`);
43
128
  this.state = createInitialState(startLabel);
44
129
  this.snapshotStack = [];
130
+ this.virtualTimeMs = 0;
131
+ this.historyManager.clear();
132
+ this.resyncAudio(prevMusic);
45
133
  this.executeUntilWaiting();
46
134
  }
47
135
  /**
@@ -54,9 +142,40 @@ export class StoryVM {
54
142
  // Must make a choice; cannot advance automatically
55
143
  return;
56
144
  }
145
+ if (this.state.hotspots && this.state.hotspots.length > 0) {
146
+ // Must click a hotspot; stage click must not skip.
147
+ return;
148
+ }
149
+ if (this.state.pendingInput) {
150
+ // Must call submitInput(); clicking the stage must not skip the prompt.
151
+ return;
152
+ }
57
153
  this.state = {
58
154
  ...this.state,
59
- isWaitingForInput: false
155
+ isWaitingForInput: false,
156
+ pendingPauseMs: null
157
+ };
158
+ this.executeUntilWaiting();
159
+ }
160
+ /**
161
+ * Completes a pending `input` prompt and continues the story.
162
+ */
163
+ submitInput(value) {
164
+ if (this.isExecuting || !this.state.pendingInput)
165
+ return;
166
+ const variable = this.state.pendingInput.variable;
167
+ const trimmed = String(value ?? '').trim();
168
+ this.recordTrace(`INPUT ${variable}="${trimmed}"`);
169
+ const nextVars = { ...this.state.variables };
170
+ if (isSafeKey(variable)) {
171
+ nextVars[variable] = trimmed;
172
+ }
173
+ this.state = {
174
+ ...this.state,
175
+ variables: nextVars,
176
+ pendingInput: null,
177
+ isWaitingForInput: false,
178
+ pendingPauseMs: null
60
179
  };
61
180
  this.executeUntilWaiting();
62
181
  }
@@ -68,27 +187,86 @@ export class StoryVM {
68
187
  return;
69
188
  }
70
189
  const choice = this.state.choices[choiceIndex];
190
+ this.recordTrace(`CHOOSE ${choiceIndex} (${choice.text}) -> ${choice.targetLabel}`);
191
+ if (!this.story.labels[choice.targetLabel]) {
192
+ const err = new Error(`Runtime Error: Choice target label '${choice.targetLabel}' is not defined in story.`);
193
+ this.emitError(err);
194
+ this.state = {
195
+ ...this.state,
196
+ choices: null,
197
+ hotspots: null,
198
+ pendingPauseMs: null,
199
+ isFinished: true,
200
+ isWaitingForInput: false
201
+ };
202
+ this.notifyStateChanged();
203
+ return;
204
+ }
71
205
  this.state = {
72
206
  ...this.state,
73
207
  choices: null,
208
+ hotspots: null,
74
209
  isWaitingForInput: false,
210
+ pendingPauseMs: null,
75
211
  currentLabel: choice.targetLabel,
76
212
  instructionPointer: 0
77
213
  };
78
214
  this.executeUntilWaiting();
79
215
  }
216
+ /**
217
+ * Selects a clickable hotspot and jumps to its target label.
218
+ */
219
+ selectHotspot(id) {
220
+ if (this.isExecuting || !this.state.hotspots || this.state.hotspots.length === 0) {
221
+ return;
222
+ }
223
+ const hotspot = this.state.hotspots.find((h) => h.id === id);
224
+ if (!hotspot)
225
+ return;
226
+ this.recordTrace(`HOTSPOT ${id} -> ${hotspot.targetLabel}`);
227
+ if (!this.story.labels[hotspot.targetLabel]) {
228
+ const err = new Error(`Runtime Error: Hotspot target label '${hotspot.targetLabel}' is not defined in story.`);
229
+ this.emitError(err);
230
+ this.state = {
231
+ ...this.state,
232
+ hotspots: null,
233
+ pendingPauseMs: null,
234
+ isFinished: true,
235
+ isWaitingForInput: false
236
+ };
237
+ this.notifyStateChanged();
238
+ return;
239
+ }
240
+ this.state = {
241
+ ...this.state,
242
+ hotspots: null,
243
+ choices: null,
244
+ isWaitingForInput: false,
245
+ pendingPauseMs: null,
246
+ windowVisible: true,
247
+ currentLabel: hotspot.targetLabel,
248
+ instructionPointer: 0
249
+ };
250
+ this.executeUntilWaiting();
251
+ }
80
252
  /**
81
253
  * Jumps directly to a label.
82
254
  */
83
255
  jump(labelName) {
256
+ if (this.isExecuting) {
257
+ throw new Error('Cannot call jump() while the story VM is already executing instructions.');
258
+ }
84
259
  if (!this.story.labels[labelName]) {
85
260
  throw new Error(`Target label '${labelName}' not found in story package.`);
86
261
  }
262
+ this.recordTrace(`JUMP_MANUAL ${labelName}`);
87
263
  this.state = {
88
264
  ...this.state,
89
265
  currentLabel: labelName,
90
266
  instructionPointer: 0,
91
267
  choices: null,
268
+ hotspots: null,
269
+ pendingPauseMs: null,
92
270
  isWaitingForInput: false
93
271
  };
94
272
  this.executeUntilWaiting();
@@ -100,12 +278,17 @@ export class StoryVM {
100
278
  if (this.snapshotStack.length <= 1) {
101
279
  return false;
102
280
  }
281
+ const prevMusic = this.state.audio.music;
103
282
  // Pop current snapshot
104
283
  this.snapshotStack.pop();
105
284
  // Restore previous snapshot
106
285
  const prev = this.snapshotStack[this.snapshotStack.length - 1];
107
286
  if (prev) {
108
287
  this.state = cloneState(prev.state);
288
+ const histLen = typeof prev.historyLength === 'number' ? prev.historyLength : this.historyManager.getLength();
289
+ this.historyManager.trimTo(histLen);
290
+ this.recordTrace(`ROLLBACK`);
291
+ this.resyncAudio(prevMusic);
109
292
  this.notifyStateChanged();
110
293
  return true;
111
294
  }
@@ -117,28 +300,94 @@ export class StoryVM {
117
300
  async save(slotId) {
118
301
  const currentSnapshot = this.captureSnapshot();
119
302
  const previewText = this.state.dialogue?.text ?? 'Game in progress';
120
- await this.saveManager.saveSlot(slotId, currentSnapshot, previewText);
303
+ this.recordTrace(`SAVE slot_${slotId}`);
304
+ return await this.saveManager.saveSlot(slotId, currentSnapshot, previewText, this.storyHash, this.historyManager.getEntries());
121
305
  }
122
- async load(slotId) {
123
- const slot = await this.saveManager.loadSlot(slotId);
124
- if (!slot)
125
- return false;
306
+ async load(slotId, validateStoryHash = true) {
307
+ const res = await this.loadWithDetails(slotId, validateStoryHash);
308
+ return res.success;
309
+ }
310
+ async loadWithDetails(slotId, validateStoryHash = true) {
311
+ const res = await this.saveManager.loadSlot(slotId, validateStoryHash ? this.storyHash : undefined);
312
+ if (!res.success || !res.slot) {
313
+ return res;
314
+ }
315
+ this.applyLoadedSlot(res.slot, `LOAD slot_${slotId}`);
316
+ return res;
317
+ }
318
+ /**
319
+ * Restore from an in-memory SaveSlot (continue-links, imported saves).
320
+ * Validates storyHash when present on the slot.
321
+ */
322
+ loadFromSlot(slot, validateStoryHash = true) {
323
+ if (validateStoryHash && slot.storyHash && slot.storyHash !== this.storyHash) {
324
+ return { success: false, reason: 'incompatible_story', slot };
325
+ }
326
+ this.applyLoadedSlot(slot, `LOAD continue_${slot.id}`);
327
+ return { success: true, slot };
328
+ }
329
+ applyLoadedSlot(slot, traceLabel) {
330
+ const prevMusic = this.state.audio.music;
126
331
  this.state = cloneState(slot.snapshot.state);
127
- this.snapshotStack = [slot.snapshot];
332
+ const restoredHistory = slot.historyEntries ?? [];
333
+ this.historyManager.replaceAll(restoredHistory);
334
+ const snap = {
335
+ ...slot.snapshot,
336
+ historyLength: typeof slot.snapshot.historyLength === 'number'
337
+ ? slot.snapshot.historyLength
338
+ : restoredHistory.length
339
+ };
340
+ this.snapshotStack = [snap];
341
+ this.recordTrace(traceLabel);
342
+ this.resyncAudio(prevMusic);
128
343
  this.notifyStateChanged();
129
- return true;
344
+ }
345
+ /**
346
+ * Emit stop/play audio events so the presentation layer matches restored state
347
+ * after load or rollback (snapshots store track ids but not live Audio elements).
348
+ */
349
+ resyncAudio(previousMusic) {
350
+ this.emitAudioEvent({ action: 'stop', channel: 'voice' });
351
+ this.emitAudioEvent({ action: 'stop', channel: 'sound' });
352
+ const nextMusic = this.state.audio.music;
353
+ if (previousMusic === nextMusic) {
354
+ return;
355
+ }
356
+ if (nextMusic) {
357
+ this.emitAudioEvent({
358
+ action: 'play',
359
+ channel: 'music',
360
+ track: nextMusic,
361
+ loop: true
362
+ });
363
+ }
364
+ else {
365
+ this.emitAudioEvent({ action: 'stop', channel: 'music' });
366
+ }
130
367
  }
131
368
  executeUntilWaiting() {
132
369
  if (this.isExecuting)
133
370
  return;
134
371
  this.isExecuting = true;
135
372
  try {
373
+ let instructionsExecuted = 0;
136
374
  while (!this.state.isWaitingForInput && !this.state.isFinished) {
375
+ if (instructionsExecuted >= this.maxInstructionsPerBurst) {
376
+ const err = new Error(`Runtime Error: Exceeded maximum of ${this.maxInstructionsPerBurst} instructions without waiting for input (possible infinite loop at label '${this.state.currentLabel}').`);
377
+ this.emitError(err);
378
+ this.state = {
379
+ ...this.state,
380
+ isFinished: true,
381
+ isWaitingForInput: false
382
+ };
383
+ break;
384
+ }
137
385
  const labelInstructions = this.story.labels[this.state.currentLabel];
138
386
  if (!labelInstructions || this.state.instructionPointer >= labelInstructions.length) {
139
387
  // Handle end of label: check call stack
140
388
  if (this.state.callStack.length > 0) {
141
389
  const topFrame = this.state.callStack[this.state.callStack.length - 1];
390
+ this.recordTrace(`RETURN -> ${topFrame.returnLabel}:${topFrame.returnPointer}`);
142
391
  this.state = {
143
392
  ...this.state,
144
393
  currentLabel: topFrame.returnLabel,
@@ -148,6 +397,7 @@ export class StoryVM {
148
397
  continue;
149
398
  }
150
399
  // Story finished
400
+ this.recordTrace('END');
151
401
  this.state = {
152
402
  ...this.state,
153
403
  isFinished: true,
@@ -161,6 +411,7 @@ export class StoryVM {
161
411
  instructionPointer: this.state.instructionPointer + 1
162
412
  };
163
413
  this.executeInstruction(inst);
414
+ instructionsExecuted++;
164
415
  }
165
416
  if (this.state.isWaitingForInput) {
166
417
  this.recordSnapshot();
@@ -174,18 +425,26 @@ export class StoryVM {
174
425
  executeInstruction(inst) {
175
426
  switch (inst.type) {
176
427
  case 'scene': {
428
+ this.recordTrace(`SCENE ${inst.background}${inst.transition ? ` [${inst.transition}]` : ''}`);
177
429
  this.state = {
178
430
  ...this.state,
179
431
  visual: {
180
432
  background: inst.background,
181
433
  transition: inst.transition ?? null,
182
- characters: {} // Clear characters on new scene
183
- }
434
+ characters: {}, // Clear characters on new scene
435
+ vfx: null,
436
+ activeCG: null,
437
+ defaultLayer: this.state.visual.defaultLayer
438
+ },
439
+ hotspots: null
184
440
  };
185
441
  break;
186
442
  }
187
443
  case 'show': {
444
+ this.recordTrace(`SHOW ${inst.character}${inst.expression ? ` ${inst.expression}` : ''}${inst.position ? ` at ${inst.position}` : ''}`);
188
445
  const charDef = this.state.visual.characters[inst.character] ?? {};
446
+ const layer = inst.layer ?? charDef.layer ?? this.state.visual.defaultLayer ?? undefined;
447
+ const z = resolveSpriteZ(layer, inst.z ?? charDef.z);
189
448
  this.state = {
190
449
  ...this.state,
191
450
  visual: {
@@ -194,7 +453,11 @@ export class StoryVM {
194
453
  ...this.state.visual.characters,
195
454
  [inst.character]: {
196
455
  expression: inst.expression ?? charDef.expression,
197
- position: inst.position ?? charDef.position ?? 'center'
456
+ position: inst.position ?? charDef.position ?? 'center',
457
+ transition: inst.transition ?? charDef.transition,
458
+ layer,
459
+ z,
460
+ cssAnimation: charDef.cssAnimation
198
461
  }
199
462
  }
200
463
  }
@@ -202,6 +465,7 @@ export class StoryVM {
202
465
  break;
203
466
  }
204
467
  case 'hide': {
468
+ this.recordTrace(`HIDE ${inst.character}`);
205
469
  const nextChars = { ...this.state.visual.characters };
206
470
  delete nextChars[inst.character];
207
471
  this.state = {
@@ -217,50 +481,236 @@ export class StoryVM {
217
481
  const charDef = inst.speaker ? this.story.characters[inst.speaker] : undefined;
218
482
  const displayName = charDef?.name ?? inst.speaker;
219
483
  const color = charDef?.color;
484
+ const interpolatedText = interpolateVariables(inst.text, this.state.variables, {
485
+ lang: this.state.lang,
486
+ i18n: this.story.i18n
487
+ });
488
+ this.recordTrace(`DIALOGUE ${displayName ? `[${displayName}] ` : ''}${interpolatedText}`);
220
489
  this.state = {
221
490
  ...this.state,
222
491
  dialogue: {
223
492
  speaker: inst.speaker,
224
493
  speakerDisplayName: displayName,
225
494
  speakerColor: color,
226
- text: inst.text
495
+ text: interpolatedText
227
496
  },
497
+ hotspots: null,
498
+ pendingPauseMs: null,
228
499
  isWaitingForInput: true
229
500
  };
230
- this.historyManager.addEntry(inst.speaker, displayName, inst.text);
501
+ this.historyManager.addEntry(inst.speaker, displayName, interpolatedText);
231
502
  break;
232
503
  }
233
504
  case 'choice': {
505
+ const availableChoices = inst.choices
506
+ .filter(choice => {
507
+ if (!choice.condition)
508
+ return true;
509
+ return evaluateCondition(choice.condition, this.state.variables);
510
+ })
511
+ .map(choice => ({
512
+ ...choice,
513
+ text: interpolateVariables(choice.text, this.state.variables, {
514
+ lang: this.state.lang,
515
+ i18n: this.story.i18n
516
+ })
517
+ }));
518
+ this.recordTrace(`CHOICES [${availableChoices.map(c => c.text).join(', ')}]`);
519
+ if (availableChoices.length === 0) {
520
+ // No visible choices — jump to menu fallthrough rather than soft-locking.
521
+ const fallback = inst.fallbackLabel;
522
+ const err = new Error('Runtime Warning: Choice menu evaluated to zero available options; continuing.');
523
+ this.emitError(err);
524
+ if (fallback && this.story.labels[fallback]) {
525
+ this.state = {
526
+ ...this.state,
527
+ choices: null,
528
+ pendingPauseMs: null,
529
+ isWaitingForInput: false,
530
+ currentLabel: fallback,
531
+ instructionPointer: 0
532
+ };
533
+ }
534
+ else {
535
+ this.state = {
536
+ ...this.state,
537
+ choices: null,
538
+ pendingPauseMs: null,
539
+ isWaitingForInput: false
540
+ };
541
+ }
542
+ break;
543
+ }
234
544
  this.state = {
235
545
  ...this.state,
236
- choices: inst.choices,
546
+ choices: availableChoices,
547
+ hotspots: null,
548
+ pendingPauseMs: null,
237
549
  isWaitingForInput: true
238
550
  };
239
551
  break;
240
552
  }
553
+ case 'vfx': {
554
+ this.recordTrace(`VFX ${inst.effect}${inst.intensity !== undefined ? ` ${inst.intensity}` : ''}${inst.color ? ` ${inst.color}` : ''}`);
555
+ if (inst.effect === 'stop') {
556
+ this.state = {
557
+ ...this.state,
558
+ visual: {
559
+ ...this.state.visual,
560
+ vfx: null
561
+ }
562
+ };
563
+ break;
564
+ }
565
+ const prev = this.state.visual.vfx;
566
+ // Tint is an overlay: keep rain/sakura/snow/fog particles running underneath.
567
+ if (inst.effect === 'tint') {
568
+ const baseEffect = prev && prev.effect !== 'tint' && prev.effect !== 'stop' ? prev.effect : 'tint';
569
+ this.state = {
570
+ ...this.state,
571
+ visual: {
572
+ ...this.state.visual,
573
+ vfx: {
574
+ effect: baseEffect,
575
+ intensity: prev?.intensity,
576
+ color: inst.color ?? (typeof inst.intensity === 'string' ? inst.intensity : undefined)
577
+ }
578
+ }
579
+ };
580
+ break;
581
+ }
582
+ this.state = {
583
+ ...this.state,
584
+ visual: {
585
+ ...this.state.visual,
586
+ vfx: {
587
+ effect: inst.effect,
588
+ intensity: inst.intensity,
589
+ // Preserve active tint color when switching weather effects
590
+ color: prev?.color
591
+ }
592
+ }
593
+ };
594
+ break;
595
+ }
596
+ case 'camera': {
597
+ this.recordTrace(`CAMERA ${inst.action}${inst.duration !== undefined ? ` ${inst.duration}` : ''}`);
598
+ this.emitCameraEvent({
599
+ action: inst.action,
600
+ duration: inst.duration
601
+ });
602
+ break;
603
+ }
604
+ case 'pause': {
605
+ this.recordTrace(`PAUSE${inst.duration !== undefined ? ` ${inst.duration}` : ''}`);
606
+ // Duration is treated as milliseconds (matches README / parser examples like `pause 1200`).
607
+ this.state = {
608
+ ...this.state,
609
+ pendingPauseMs: inst.duration !== undefined && inst.duration > 0 ? inst.duration : null,
610
+ isWaitingForInput: true
611
+ };
612
+ break;
613
+ }
614
+ case 'cg': {
615
+ const unlockKey = inst.unlockId || inst.image;
616
+ this.recordTrace(`CG ${inst.image}`);
617
+ const nextUnlocked = Object.assign(Object.create(null), this.state.unlockedCGs);
618
+ nextUnlocked[unlockKey] = true;
619
+ this.state = {
620
+ ...this.state,
621
+ visual: {
622
+ ...this.state.visual,
623
+ activeCG: inst.image
624
+ },
625
+ unlockedCGs: nextUnlocked
626
+ };
627
+ break;
628
+ }
241
629
  case 'jump': {
630
+ const target = inst.targetLabel;
631
+ this.recordTrace(`JUMP ${target}`);
632
+ if (!this.story.labels[target]) {
633
+ const err = new Error(`Runtime Error: Jump target label '${target}' is not defined in story.`);
634
+ this.emitError(err);
635
+ this.state = {
636
+ ...this.state,
637
+ isFinished: true,
638
+ isWaitingForInput: false
639
+ };
640
+ break;
641
+ }
242
642
  this.state = {
243
643
  ...this.state,
244
- currentLabel: inst.targetLabel,
644
+ currentLabel: target,
645
+ instructionPointer: 0
646
+ };
647
+ break;
648
+ }
649
+ case 'call': {
650
+ const target = inst.targetLabel;
651
+ this.recordTrace(`CALL ${target}`);
652
+ if (this.state.callStack.length >= this.maxCallDepth) {
653
+ const err = new Error(`Runtime Error: Maximum call stack depth of ${this.maxCallDepth} exceeded.`);
654
+ this.emitError(err);
655
+ this.state = {
656
+ ...this.state,
657
+ isFinished: true,
658
+ isWaitingForInput: false
659
+ };
660
+ break;
661
+ }
662
+ if (!this.story.labels[target]) {
663
+ const err = new Error(`Runtime Error: Call target label '${target}' is not defined in story.`);
664
+ this.emitError(err);
665
+ this.state = {
666
+ ...this.state,
667
+ isFinished: true,
668
+ isWaitingForInput: false
669
+ };
670
+ break;
671
+ }
672
+ this.state = {
673
+ ...this.state,
674
+ callStack: [
675
+ ...this.state.callStack,
676
+ {
677
+ returnLabel: this.state.currentLabel,
678
+ returnPointer: this.state.instructionPointer
679
+ }
680
+ ],
681
+ currentLabel: target,
245
682
  instructionPointer: 0
246
683
  };
247
684
  break;
248
685
  }
249
686
  case 'set': {
250
687
  const currentVal = this.state.variables[inst.variable];
251
- const nextVal = applySetOperation(currentVal, inst.operator, inst.value, this.state.variables);
688
+ const nextVal = applySetOperation(currentVal, inst.operator, inst.value, this.state.variables, inst.isVariable);
689
+ this.recordTrace(`SET ${inst.variable} ${inst.operator ?? '='} ${String(nextVal)}`);
690
+ const nextVars = Object.assign(Object.create(null), this.state.variables);
691
+ if (isSafeKey(inst.variable)) {
692
+ nextVars[inst.variable] = nextVal;
693
+ }
252
694
  this.state = {
253
695
  ...this.state,
254
- variables: {
255
- ...this.state.variables,
256
- [inst.variable]: nextVal
257
- }
696
+ variables: nextVars
258
697
  };
259
698
  break;
260
699
  }
261
700
  case 'branch': {
262
701
  const conditionVal = evaluateCondition(inst.condition, this.state.variables);
263
702
  const targetLabel = conditionVal ? inst.thenLabel : (inst.elseLabel ?? inst.thenLabel);
703
+ this.recordTrace(`BRANCH ${inst.condition} (${conditionVal}) -> ${targetLabel}`);
704
+ if (!this.story.labels[targetLabel]) {
705
+ const err = new Error(`Runtime Error: Branch target label '${targetLabel}' is not defined in story.`);
706
+ this.emitError(err);
707
+ this.state = {
708
+ ...this.state,
709
+ isFinished: true,
710
+ isWaitingForInput: false
711
+ };
712
+ break;
713
+ }
264
714
  this.state = {
265
715
  ...this.state,
266
716
  currentLabel: targetLabel,
@@ -269,6 +719,7 @@ export class StoryVM {
269
719
  break;
270
720
  }
271
721
  case 'play_audio': {
722
+ this.recordTrace(`PLAY_AUDIO ${inst.channel}:${inst.track}`);
272
723
  this.emitAudioEvent({
273
724
  action: 'play',
274
725
  channel: inst.channel,
@@ -288,6 +739,7 @@ export class StoryVM {
288
739
  break;
289
740
  }
290
741
  case 'stop_audio': {
742
+ this.recordTrace(`STOP_AUDIO ${inst.channel}`);
291
743
  this.emitAudioEvent({
292
744
  action: 'stop',
293
745
  channel: inst.channel,
@@ -304,9 +756,162 @@ export class StoryVM {
304
756
  }
305
757
  break;
306
758
  }
759
+ case 'input': {
760
+ const prompt = interpolateVariables(inst.prompt, this.state.variables, {
761
+ lang: this.state.lang,
762
+ i18n: this.story.i18n
763
+ });
764
+ this.recordTrace(`INPUT_WAIT ${inst.variable} "${prompt}"`);
765
+ this.state = {
766
+ ...this.state,
767
+ pendingInput: { variable: inst.variable, prompt },
768
+ choices: null,
769
+ hotspots: null,
770
+ pendingPauseMs: null,
771
+ isWaitingForInput: true
772
+ };
773
+ break;
774
+ }
775
+ case 'window': {
776
+ this.recordTrace(`WINDOW ${inst.action}`);
777
+ this.state = {
778
+ ...this.state,
779
+ windowVisible: inst.action === 'show'
780
+ };
781
+ break;
782
+ }
783
+ case 'theme': {
784
+ const name = sanitizeCssToken(inst.name);
785
+ this.recordTrace(`THEME ${name}`);
786
+ this.state = {
787
+ ...this.state,
788
+ theme: name || null
789
+ };
790
+ break;
791
+ }
792
+ case 'style': {
793
+ const target = sanitizeCssToken(inst.target) || 'root';
794
+ const name = sanitizeCssToken(inst.name) || 'default';
795
+ this.recordTrace(`STYLE ${target}=${name}`);
796
+ this.state = {
797
+ ...this.state,
798
+ styleClasses: {
799
+ ...this.state.styleClasses,
800
+ [target]: name
801
+ }
802
+ };
803
+ break;
804
+ }
805
+ case 'hotspot': {
806
+ const collected = [
807
+ {
808
+ id: inst.id,
809
+ x: inst.x,
810
+ y: inst.y,
811
+ w: inst.w,
812
+ h: inst.h,
813
+ targetLabel: inst.targetLabel
814
+ }
815
+ ];
816
+ const labelInstructions = this.story.labels[this.state.currentLabel] ?? [];
817
+ while (this.state.instructionPointer < labelInstructions.length) {
818
+ const nextInst = labelInstructions[this.state.instructionPointer];
819
+ if (!nextInst || nextInst.type !== 'hotspot')
820
+ break;
821
+ this.state = {
822
+ ...this.state,
823
+ instructionPointer: this.state.instructionPointer + 1
824
+ };
825
+ collected.push({
826
+ id: nextInst.id,
827
+ x: nextInst.x,
828
+ y: nextInst.y,
829
+ w: nextInst.w,
830
+ h: nextInst.h,
831
+ targetLabel: nextInst.targetLabel
832
+ });
833
+ }
834
+ this.recordTrace(`HOTSPOTS [${collected.map((h) => h.id).join(', ')}]`);
835
+ this.state = {
836
+ ...this.state,
837
+ hotspots: collected,
838
+ choices: null,
839
+ pendingPauseMs: null,
840
+ isWaitingForInput: true
841
+ };
842
+ break;
843
+ }
844
+ case 'layer': {
845
+ const name = sanitizeCssToken(inst.name) || 'master';
846
+ this.recordTrace(`LAYER ${name}`);
847
+ this.state = {
848
+ ...this.state,
849
+ visual: {
850
+ ...this.state.visual,
851
+ defaultLayer: name
852
+ }
853
+ };
854
+ break;
855
+ }
856
+ case 'animate': {
857
+ const existing = this.state.visual.characters[inst.character];
858
+ if (!existing) {
859
+ this.recordTrace(`ANIMATE skipped (missing ${inst.character})`);
860
+ break;
861
+ }
862
+ const animName = sanitizeCssToken(inst.animation) || 'fade-in';
863
+ this.recordTrace(`ANIMATE ${inst.character} ${animName}`);
864
+ this.state = {
865
+ ...this.state,
866
+ visual: {
867
+ ...this.state.visual,
868
+ characters: {
869
+ ...this.state.visual.characters,
870
+ [inst.character]: {
871
+ ...existing,
872
+ cssAnimation: {
873
+ name: animName,
874
+ durationMs: inst.durationMs,
875
+ token: Date.now()
876
+ }
877
+ }
878
+ }
879
+ }
880
+ };
881
+ break;
882
+ }
883
+ case 'unlock': {
884
+ const catalog = this.story.achievements?.find((a) => a.id === inst.id);
885
+ const entry = {
886
+ id: inst.id,
887
+ title: inst.title ?? catalog?.title ?? inst.id,
888
+ description: inst.description ?? catalog?.description,
889
+ unlockedAt: Date.now()
890
+ };
891
+ this.recordTrace(`UNLOCK ${inst.id}`);
892
+ this.state = {
893
+ ...this.state,
894
+ achievements: {
895
+ ...this.state.achievements,
896
+ [inst.id]: entry
897
+ }
898
+ };
899
+ this.persistAchievement(entry);
900
+ break;
901
+ }
902
+ case 'lang': {
903
+ const code = inst.code.trim().toLowerCase() || 'en';
904
+ this.recordTrace(`LANG ${code}`);
905
+ this.state = {
906
+ ...this.state,
907
+ lang: code
908
+ };
909
+ break;
910
+ }
307
911
  case 'return': {
308
912
  if (this.state.callStack.length > 0) {
309
913
  const topFrame = this.state.callStack[this.state.callStack.length - 1];
914
+ this.recordTrace(`RETURN -> ${topFrame.returnLabel}:${topFrame.returnPointer}`);
310
915
  this.state = {
311
916
  ...this.state,
312
917
  currentLabel: topFrame.returnLabel,
@@ -315,6 +920,7 @@ export class StoryVM {
315
920
  };
316
921
  }
317
922
  else {
923
+ this.recordTrace('END');
318
924
  this.state = {
319
925
  ...this.state,
320
926
  isFinished: true,
@@ -329,14 +935,14 @@ export class StoryVM {
329
935
  return {
330
936
  id: `snap_${Date.now()}_${this.snapshotStack.length}`,
331
937
  timestamp: Date.now(),
332
- state: cloneState(this.state)
938
+ state: cloneState(this.state),
939
+ historyLength: this.historyManager.getLength()
333
940
  };
334
941
  }
335
942
  recordSnapshot() {
336
943
  const snap = this.captureSnapshot();
337
944
  this.snapshotStack.push(snap);
338
- // Limit snapshot history to last 50 steps
339
- if (this.snapshotStack.length > 50) {
945
+ if (this.snapshotStack.length > this.maxSnapshots) {
340
946
  this.snapshotStack.shift();
341
947
  }
342
948
  }
@@ -351,5 +957,65 @@ export class StoryVM {
351
957
  listener(event);
352
958
  }
353
959
  }
960
+ persistAchievement(entry) {
961
+ if (typeof localStorage === 'undefined')
962
+ return;
963
+ try {
964
+ const key = `kawaijs_achievements_${this.storyHash}`;
965
+ const raw = localStorage.getItem(key);
966
+ const map = raw ? JSON.parse(raw) : {};
967
+ map[entry.id] = entry;
968
+ localStorage.setItem(key, JSON.stringify(map));
969
+ }
970
+ catch {
971
+ // ignore quota / private mode
972
+ }
973
+ }
974
+ /** Merge locally persisted achievements into the current state (call after start/load). */
975
+ hydrateAchievements() {
976
+ if (typeof localStorage === 'undefined')
977
+ return;
978
+ try {
979
+ const key = `kawaijs_achievements_${this.storyHash}`;
980
+ const raw = localStorage.getItem(key);
981
+ if (!raw)
982
+ return;
983
+ const map = JSON.parse(raw);
984
+ this.state = {
985
+ ...this.state,
986
+ achievements: {
987
+ ...map,
988
+ ...this.state.achievements
989
+ }
990
+ };
991
+ this.notifyStateChanged();
992
+ }
993
+ catch {
994
+ // ignore
995
+ }
996
+ }
997
+ /** Export achievements as a plain JSON-serializable object (itch / analytics). */
998
+ exportAchievementsJson() {
999
+ return {
1000
+ storyHash: this.storyHash,
1001
+ exportedAt: Date.now(),
1002
+ achievements: Object.values(this.state.achievements)
1003
+ };
1004
+ }
1005
+ /** Set active language for `{t:key}` lookups (also used by `?lang=`). */
1006
+ setLang(code) {
1007
+ const normalized = code.trim().toLowerCase() || 'en';
1008
+ this.state = {
1009
+ ...this.state,
1010
+ lang: normalized
1011
+ };
1012
+ this.notifyStateChanged();
1013
+ }
1014
+ }
1015
+ function sanitizeCssToken(raw) {
1016
+ return String(raw ?? '')
1017
+ .trim()
1018
+ .toLowerCase()
1019
+ .replace(/[^a-z0-9_-]+/g, '');
354
1020
  }
355
1021
  //# sourceMappingURL=vm.js.map