@vylos/core 0.5.4 → 0.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vylos/core",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/DevOpsBenjamin/Vylos"
@@ -92,6 +92,7 @@ const isRunning = computed(() =>
92
92
  // --- History step helpers ---
93
93
 
94
94
  function applyHistoryStep(step: HistoryStep): void {
95
+ engineState.setForeground(step.foreground ?? null);
95
96
  if (step.type === 'say' && step.dialogue) {
96
97
  engineState.setDialogue(step.dialogue);
97
98
  engineState.setChoices(null);
@@ -1,11 +1,20 @@
1
1
  <template>
2
2
  <Transition name="fg-fade">
3
- <img
3
+ <div
4
4
  v-if="engineState.foreground"
5
- :src="fgUrl"
6
- alt=""
7
- class="absolute inset-0 w-full h-full object-contain z-10 pointer-events-none"
8
- />
5
+ class="absolute inset-0 z-10 pointer-events-none overflow-hidden"
6
+ >
7
+ <img
8
+ :src="fgUrl"
9
+ alt=""
10
+ class="fg-blur absolute inset-0 w-full h-full object-cover"
11
+ />
12
+ <img
13
+ :src="fgUrl"
14
+ alt=""
15
+ class="absolute inset-0 w-full h-full object-contain"
16
+ />
17
+ </div>
9
18
  </Transition>
10
19
  </template>
11
20
 
@@ -20,6 +29,11 @@ const fgUrl = computed(() => engineState.foreground ? assetUrl(engineState.foreg
20
29
  </script>
21
30
 
22
31
  <style scoped>
32
+ .fg-blur {
33
+ filter: blur(80px);
34
+ transform: scale(1.2);
35
+ }
36
+
23
37
  .fg-fade-enter-active,
24
38
  .fg-fade-leave-active {
25
39
  transition: opacity 0.3s ease;
@@ -5,6 +5,7 @@ import type { DialogueState } from '../types/engine';
5
5
  export interface CaptureDisplayData {
6
6
  dialogue?: DialogueState | null;
7
7
  background?: string | null;
8
+ foreground?: string | null;
8
9
  choiceOptions?: ChoiceOption[];
9
10
  }
10
11
 
@@ -46,6 +47,7 @@ export class CheckpointManager {
46
47
  choiceResult,
47
48
  dialogue: display?.dialogue ? structuredClone(toRaw(display.dialogue)) : undefined,
48
49
  background: display?.background,
50
+ foreground: display?.foreground,
49
51
  choiceOptions: display?.choiceOptions ? structuredClone(toRaw(display.choiceOptions)) : undefined,
50
52
  };
51
53
 
@@ -50,6 +50,7 @@ export interface EventRunnerCallbacks {
50
50
  export interface HistoryStep {
51
51
  type: 'say' | 'choice';
52
52
  dialogue?: DialogueState | null;
53
+ foreground?: string | null;
53
54
  choiceOptions?: ChoiceOption[];
54
55
  choiceResult?: string;
55
56
  stepIndex: number;
@@ -77,6 +78,8 @@ export class EventRunner implements VylosAPI {
77
78
  private liveDialogue: { text: string; speaker: VylosCharacter | null } | null = null;
78
79
  /** Current background path (tracked for checkpoint storage) */
79
80
  private currentBackground: string | null = null;
81
+ /** Current foreground path (tracked for checkpoint storage) */
82
+ private currentForeground: string | null = null;
80
83
 
81
84
  /** Snapshot of game state before event started (for redo) */
82
85
  private initialState: VylosGameState | null = null;
@@ -196,6 +199,8 @@ export class EventRunner implements VylosAPI {
196
199
  await this.runEventExecution(event);
197
200
  } finally {
198
201
  this.callbacks.onClear();
202
+ logger.debug(`Event ended — clearing foreground`);
203
+ this.callbacks.onSetForeground(null);
199
204
  this.initialState = null;
200
205
  this.currentEvent = null;
201
206
  }
@@ -223,6 +228,8 @@ export class EventRunner implements VylosAPI {
223
228
  throw error;
224
229
  } finally {
225
230
  this.callbacks.onClear();
231
+ logger.debug(`Rollback ended — clearing foreground`);
232
+ this.callbacks.onSetForeground(null);
226
233
  }
227
234
  }
228
235
 
@@ -244,6 +251,8 @@ export class EventRunner implements VylosAPI {
244
251
  await this.runEventExecution(event);
245
252
  } finally {
246
253
  this.callbacks.onClear();
254
+ logger.debug(`Resume ended — clearing foreground`);
255
+ this.callbacks.onSetForeground(null);
247
256
  this.initialState = null;
248
257
  this.currentEvent = null;
249
258
  }
@@ -297,6 +306,7 @@ export class EventRunner implements VylosAPI {
297
306
  {
298
307
  dialogue: { text: resolvedText, speaker, isNarration: !speaker },
299
308
  background: this.currentBackground,
309
+ foreground: this.currentForeground,
300
310
  },
301
311
  );
302
312
 
@@ -342,7 +352,7 @@ export class EventRunner implements VylosAPI {
342
352
  this.callbacks.getState(),
343
353
  'choice' as CheckpointType,
344
354
  result,
345
- { choiceOptions: resolvedOptions },
355
+ { choiceOptions: resolvedOptions, foreground: this.currentForeground },
346
356
  );
347
357
 
348
358
  this.callbacks.onClear();
@@ -356,6 +366,7 @@ export class EventRunner implements VylosAPI {
356
366
  }
357
367
 
358
368
  setForeground(path: string | null): void {
369
+ this.currentForeground = path;
359
370
  this.callbacks.onSetForeground(path);
360
371
  }
361
372
 
@@ -438,6 +449,7 @@ export class EventRunner implements VylosAPI {
438
449
  if (cp.choiceOptions) {
439
450
  return {
440
451
  type: 'choice',
452
+ foreground: cp.foreground,
441
453
  choiceOptions: cp.choiceOptions,
442
454
  choiceResult: cp.choiceResult,
443
455
  stepIndex: idx,
@@ -446,6 +458,7 @@ export class EventRunner implements VylosAPI {
446
458
  return {
447
459
  type: 'say',
448
460
  dialogue: cp.dialogue,
461
+ foreground: cp.foreground,
449
462
  stepIndex: idx,
450
463
  };
451
464
  }
@@ -1,4 +1,4 @@
1
- import type { VylosLocation, LocationLink, VylosGameState, BackgroundEntry } from '../types';
1
+ import type { VylosLocation, LocationLink, VylosGameState } from '../types';
2
2
  import { resolveBackground } from '../utils/TimeHelper';
3
3
  import { logger } from '../utils/logger';
4
4
 
@@ -22,11 +22,23 @@ export class LocationManager {
22
22
  }
23
23
  }
24
24
 
25
- /** Set the location link graph */
25
+ /** Set the location link graph (replaces all existing links) */
26
26
  setLinks(links: LocationLink[]): void {
27
27
  this.links = links;
28
28
  }
29
29
 
30
+ /** Add directed links from one location to one or more others (additive) */
31
+ link(
32
+ fromId: string,
33
+ toIds: string | string[],
34
+ options?: { condition?: (state: VylosGameState) => boolean },
35
+ ): void {
36
+ const targets = Array.isArray(toIds) ? toIds : [toIds];
37
+ for (const toId of targets) {
38
+ this.links.push({ from: fromId, to: toId, ...options });
39
+ }
40
+ }
41
+
30
42
  /** Get a location by ID */
31
43
  get(id: string): VylosLocation | undefined {
32
44
  return this.locations.get(id);
@@ -15,6 +15,8 @@ export interface Checkpoint {
15
15
  dialogue?: DialogueState | null;
16
16
  /** Background path at this point */
17
17
  background?: string | null;
18
+ /** Foreground path at this point */
19
+ foreground?: string | null;
18
20
  /** Available choice options at this step (for history redo) */
19
21
  choiceOptions?: ChoiceOption[];
20
22
  }
@@ -6,4 +6,6 @@ export interface VylosConfig {
6
6
  defaultLanguage: string;
7
7
  defaultLocation: string;
8
8
  resolution: { width: number; height: number };
9
+ /** The game time at which new games begin. Defaults to 12 if not specified. */
10
+ startGameTime?: number;
9
11
  }
@@ -1,5 +1,26 @@
1
1
  import type { DependencyContainer } from 'tsyringe';
2
- import type { Component } from 'vue';
2
+ import type { Component, Ref } from 'vue';
3
+ import type { VylosGameState } from './game-state';
4
+
5
+ /**
6
+ * Minimal contract for a game state store.
7
+ * Compatible with the default `useGameStateStore` return type.
8
+ * Projects with custom game state can implement this with additional properties.
9
+ */
10
+ export interface VylosGameStore {
11
+ /** Reactive game state ref */
12
+ state: Ref<VylosGameState>;
13
+ /** Get current (unwrapped) game state */
14
+ getState(): VylosGameState;
15
+ /** Replace the entire game state */
16
+ setState(newState: VylosGameState): void;
17
+ /** Deep-clone the current state for saving */
18
+ getSnapshot(): VylosGameState;
19
+ /** Restore a previously saved snapshot */
20
+ restoreSnapshot(snapshot: VylosGameState): void;
21
+ /** Reset to default initial state */
22
+ $reset(): void;
23
+ }
3
24
 
4
25
  /** Plugin interface for project-level engine customization */
5
26
  export interface VylosPlugin {
@@ -8,4 +29,11 @@ export interface VylosPlugin {
8
29
 
9
30
  /** Override Vue components by component ID */
10
31
  components?: Record<string, Component>;
32
+
33
+ /**
34
+ * Provide a custom Pinia game state store factory.
35
+ * When set, setupVylos will use this instead of the default `useGameStateStore`.
36
+ * Useful for projects that extend VylosGameState with additional fields.
37
+ */
38
+ gameStore?(pinia?: any): VylosGameStore;
11
39
  }
package/src/index.ts CHANGED
@@ -20,6 +20,10 @@ export { assetUrl } from './utils/assetUrl';
20
20
  export { useEngineStateStore } from './stores/engineState';
21
21
  export { useGameStateStore } from './stores/gameState';
22
22
 
23
+ // Setup
24
+ export { setupVylos } from './setup';
25
+ export type { SetupOptions } from './setup';
26
+
23
27
  // Core engine
24
28
  export { Engine } from './engine/core/Engine';
25
29
  export type { EngineLoopCallbacks } from './engine/core/Engine';
package/src/setup.ts ADDED
@@ -0,0 +1,211 @@
1
+ import 'reflect-metadata';
2
+ import { createApp, watch, toRaw } from 'vue';
3
+ import { createPinia } from 'pinia';
4
+ import GameShell from './components/app/GameShell.vue';
5
+ import { createEngine } from './engine/core/EngineFactory';
6
+ import { useEngineStateStore } from './stores/engineState';
7
+ import { useGameStateStore } from './stores/gameState';
8
+ import { ENGINE_INJECT_KEY } from './composables/useEngine';
9
+ import { CONFIG_INJECT_KEY } from './composables/useConfig';
10
+ import { LocationManager } from './engine/managers/LocationManager';
11
+ import { ActionManager } from './engine/managers/ActionManager';
12
+ import { LanguageManager } from './engine/managers/LanguageManager';
13
+ import { EnginePhase } from './engine/types/engine';
14
+ import type { VylosConfig } from './engine/types/config';
15
+ import type { VylosPlugin, VylosGameStore } from './engine/types/plugin';
16
+ import type { VylosLocation } from './engine/types/locations';
17
+ import type { VylosEvent, TextEntry } from './engine/types/events';
18
+ import type { VylosAction } from './engine/types/actions';
19
+ import type { VylosCharacter } from './engine/types/dialogue';
20
+ import type { EventRunnerCallbacks } from './engine/core/EventRunner';
21
+ import type { EngineLoopCallbacks } from './engine/core/Engine';
22
+
23
+ export interface SetupOptions {
24
+ config: VylosConfig;
25
+ plugin?: VylosPlugin;
26
+ locations?: VylosLocation[];
27
+ events?: VylosEvent[];
28
+ actions?: VylosAction[];
29
+ initLinks?: (lm: LocationManager) => void;
30
+ /** When true, skip the MainMenu phase and go directly to Running (triggers startGame). */
31
+ skipMainMenu?: boolean;
32
+ }
33
+
34
+ /**
35
+ * One-call setup function that replaces all main.ts boilerplate.
36
+ * Creates the Vue app, Pinia stores, engine, and wires everything together.
37
+ */
38
+ export function setupVylos(options: SetupOptions): void {
39
+ const { config, plugin, locations = [], events = [], actions = [], initLinks, skipMainMenu = false } = options;
40
+
41
+ const app = createApp(GameShell);
42
+ const pinia = createPinia();
43
+ app.use(pinia);
44
+
45
+ const gameStore: VylosGameStore = plugin?.gameStore
46
+ ? plugin.gameStore(pinia)
47
+ : useGameStateStore(pinia);
48
+ const engineState = useEngineStateStore(pinia);
49
+
50
+ const languageManager = config.languages.length > 1
51
+ ? createLanguageManager(config)
52
+ : null;
53
+ const resolveText = buildResolveText(languageManager, config);
54
+
55
+ const locationManager = new LocationManager();
56
+ locationManager.registerAll(locations);
57
+
58
+ if (locations.length > 0 && !locationManager.has(config.defaultLocation)) {
59
+ console.error(
60
+ `[Vylos] Default location "${config.defaultLocation}" is not registered. ` +
61
+ `Registered locations: [${locationManager.getAll().map(l => l.id).join(', ')}]. ` +
62
+ `Check your vylos.config.ts defaultLocation value.`
63
+ );
64
+ }
65
+
66
+ const actionManager = new ActionManager();
67
+ actionManager.registerAll(actions);
68
+
69
+ const callbacks = buildCallbacks(engineState, gameStore, locationManager, resolveText);
70
+ const engine = createEngine({ callbacks, projectId: config.id, plugin });
71
+
72
+ app.provide(ENGINE_INJECT_KEY, engine);
73
+ app.provide(CONFIG_INJECT_KEY, config);
74
+ app.mount('#app');
75
+
76
+ if (initLinks) {
77
+ initLinks(locationManager);
78
+ }
79
+
80
+ if (skipMainMenu) {
81
+ engineState.setPhase(EnginePhase.Running);
82
+ startGame();
83
+ } else {
84
+ engineState.setPhase(EnginePhase.MainMenu);
85
+
86
+ const stopWatch = watch(() => engineState.phase, (newPhase) => {
87
+ if (newPhase === EnginePhase.Running) {
88
+ stopWatch();
89
+ startGame();
90
+ }
91
+ });
92
+ }
93
+
94
+ function startGame(): void {
95
+ const startGameTime = config.startGameTime ?? 12;
96
+ gameStore.setState({
97
+ ...gameStore.getState(),
98
+ locationId: config.defaultLocation,
99
+ gameTime: startGameTime,
100
+ });
101
+
102
+ engineState.setLocation(config.defaultLocation);
103
+
104
+ const bg = locationManager.resolveBackground(config.defaultLocation, startGameTime);
105
+ if (bg) engineState.setBackground(bg);
106
+
107
+ const loopCallbacks: EngineLoopCallbacks = {
108
+ onTick(state) {
109
+ const locs = locationManager.getAccessibleFrom(state.locationId, state);
110
+ engineState.setLocations(locs.map(l => ({
111
+ id: l.id,
112
+ name: resolveText(l.name),
113
+ accessible: true,
114
+ })));
115
+
116
+ const acts = actionManager.getAvailable(state.locationId, state);
117
+ engineState.setActions(acts.map(a => ({
118
+ id: a.id,
119
+ label: resolveText(a.label),
120
+ locationId: a.locationId ?? '',
121
+ })));
122
+
123
+ engineState.setDrawableEvents(
124
+ engine.eventManager.getDrawableEvents(state, resolveText),
125
+ );
126
+
127
+ const bg = locationManager.resolveBackground(state.locationId, state.gameTime);
128
+ if (bg) engineState.setBackground(bg);
129
+ },
130
+ onAction(actionId, state) {
131
+ actionManager.execute(actionId, state);
132
+ },
133
+ };
134
+
135
+ engine.run(events, () => gameStore.getState(), loopCallbacks).catch(console.error);
136
+ }
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Helpers
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function createLanguageManager(config: VylosConfig): LanguageManager {
144
+ const lm = new LanguageManager();
145
+ lm.setLanguage(config.defaultLanguage ?? config.languages[0]);
146
+ return lm;
147
+ }
148
+
149
+ function buildResolveText(
150
+ languageManager: LanguageManager | null,
151
+ config: VylosConfig,
152
+ ): (entry: string | TextEntry) => string {
153
+ if (languageManager) {
154
+ return (entry) => languageManager.resolve(entry);
155
+ }
156
+ const lang = config.defaultLanguage ?? 'en';
157
+ return (entry) =>
158
+ typeof entry === 'string'
159
+ ? entry
160
+ : entry[lang] ?? Object.values(entry)[0] ?? '';
161
+ }
162
+
163
+ type EngineStateStore = ReturnType<typeof useEngineStateStore>;
164
+
165
+ function buildCallbacks(
166
+ engineState: EngineStateStore,
167
+ gameStore: VylosGameStore,
168
+ locationManager: LocationManager,
169
+ resolveText: (entry: string | TextEntry) => string,
170
+ ): EventRunnerCallbacks {
171
+ return {
172
+ onSay(text: string, speaker: VylosCharacter | null) {
173
+ engineState.setDialogue({ text, speaker, isNarration: !speaker });
174
+ },
175
+ onChoice(options) {
176
+ engineState.setChoices({ prompt: null, options });
177
+ },
178
+ onSetBackground(path) {
179
+ engineState.setBackground(path);
180
+ },
181
+ onSetForeground(path) {
182
+ engineState.setForeground(path);
183
+ },
184
+ onShowOverlay(componentId, props) {
185
+ engineState.setOverlay(componentId, props);
186
+ },
187
+ onHideOverlay() {
188
+ engineState.setOverlay(null);
189
+ },
190
+ onSetLocation(locationId) {
191
+ const state = gameStore.getState();
192
+ state.locationId = locationId;
193
+ engineState.setLocation(locationId);
194
+ const bg = locationManager.resolveBackground(locationId, state.gameTime);
195
+ if (bg) engineState.setBackground(bg);
196
+ },
197
+ onClear() {
198
+ engineState.setDialogue(null);
199
+ engineState.setChoices(null);
200
+ },
201
+ resolveText(entry) {
202
+ return resolveText(entry);
203
+ },
204
+ getState() {
205
+ return JSON.parse(JSON.stringify(gameStore.getState()));
206
+ },
207
+ setState(newState) {
208
+ gameStore.setState(newState);
209
+ },
210
+ };
211
+ }
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { LocationManager } from '../src/engine/managers/LocationManager';
3
+ import type { VylosLocation, VylosGameState } from '../src/engine/types';
4
+
5
+ function makeState(overrides: Partial<VylosGameState> = {}): VylosGameState {
6
+ return {
7
+ locationId: 'room_a',
8
+ gameTime: 12,
9
+ flags: {},
10
+ counters: {},
11
+ player: { id: 'player', name: 'Player' },
12
+ inventories: {},
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ const roomA: VylosLocation = {
18
+ id: 'room_a',
19
+ name: 'Room A',
20
+ backgrounds: [{ path: '/room_a.jpg' }],
21
+ };
22
+
23
+ const roomB: VylosLocation = {
24
+ id: 'room_b',
25
+ name: 'Room B',
26
+ backgrounds: [{ path: '/room_b.jpg' }],
27
+ };
28
+
29
+ const roomC: VylosLocation = {
30
+ id: 'room_c',
31
+ name: 'Room C',
32
+ backgrounds: [{ path: '/room_c.jpg' }],
33
+ };
34
+
35
+ const roomD: VylosLocation = {
36
+ id: 'room_d',
37
+ name: 'Room D',
38
+ backgrounds: [{ path: '/room_d.jpg' }],
39
+ };
40
+
41
+ describe('LocationManager.link()', () => {
42
+ let lm: LocationManager;
43
+
44
+ beforeEach(() => {
45
+ lm = new LocationManager();
46
+ lm.registerAll([roomA, roomB, roomC, roomD]);
47
+ });
48
+
49
+ it('creates directed links from one location to multiple targets', () => {
50
+ lm.link('room_a', ['room_b', 'room_c']);
51
+
52
+ const state = makeState();
53
+ const accessible = lm.getAccessibleFrom('room_a', state);
54
+ expect(accessible.map(l => l.id)).toEqual(['room_b', 'room_c']);
55
+ });
56
+
57
+ it('creates a single directed link with string shorthand', () => {
58
+ lm.link('room_a', 'room_b');
59
+
60
+ const state = makeState();
61
+ const accessible = lm.getAccessibleFrom('room_a', state);
62
+ expect(accessible.map(l => l.id)).toEqual(['room_b']);
63
+ });
64
+
65
+ it('creates a link with a condition', () => {
66
+ lm.link('room_a', ['room_b'], {
67
+ condition: (state) => state.flags['has_key'] === true,
68
+ });
69
+
70
+ const stateWithout = makeState();
71
+ expect(lm.getAccessibleFrom('room_a', stateWithout)).toHaveLength(0);
72
+
73
+ const stateWith = makeState({ flags: { has_key: true } });
74
+ expect(lm.getAccessibleFrom('room_a', stateWith).map(l => l.id)).toEqual(['room_b']);
75
+ });
76
+
77
+ it('is additive across multiple calls', () => {
78
+ lm.link('room_a', 'room_b');
79
+ lm.link('room_a', 'room_c');
80
+
81
+ const state = makeState();
82
+ const accessible = lm.getAccessibleFrom('room_a', state);
83
+ expect(accessible.map(l => l.id)).toEqual(['room_b', 'room_c']);
84
+ });
85
+
86
+ it('accumulates links alongside setLinks()', () => {
87
+ // link() first
88
+ lm.link('room_a', 'room_b');
89
+
90
+ // setLinks() replaces everything
91
+ lm.setLinks([{ from: 'room_a', to: 'room_c' }]);
92
+
93
+ const state = makeState();
94
+ // After setLinks, the link() entry is gone — only room_c remains
95
+ expect(lm.getAccessibleFrom('room_a', state).map(l => l.id)).toEqual(['room_c']);
96
+
97
+ // Now link() adds on top of the setLinks result
98
+ lm.link('room_a', 'room_d');
99
+ expect(lm.getAccessibleFrom('room_a', state).map(l => l.id)).toEqual(['room_c', 'room_d']);
100
+ });
101
+
102
+ it('link() after setLinks() accumulates on existing links', () => {
103
+ lm.setLinks([
104
+ { from: 'room_a', to: 'room_b' },
105
+ { from: 'room_b', to: 'room_a' },
106
+ ]);
107
+
108
+ lm.link('room_a', 'room_c');
109
+
110
+ const state = makeState();
111
+ const accessible = lm.getAccessibleFrom('room_a', state);
112
+ expect(accessible.map(l => l.id)).toEqual(['room_b', 'room_c']);
113
+ });
114
+
115
+ it('does not create reverse links (directed only)', () => {
116
+ lm.link('room_a', 'room_b');
117
+
118
+ const state = makeState({ locationId: 'room_b' });
119
+ const accessible = lm.getAccessibleFrom('room_b', state);
120
+ expect(accessible).toHaveLength(0);
121
+ });
122
+
123
+ it('handles mixed conditional and unconditional links', () => {
124
+ lm.link('room_a', 'room_b');
125
+ lm.link('room_a', ['room_c'], {
126
+ condition: (state) => state.gameTime >= 18,
127
+ });
128
+ lm.link('room_a', 'room_d');
129
+
130
+ const daytime = makeState({ gameTime: 12 });
131
+ expect(lm.getAccessibleFrom('room_a', daytime).map(l => l.id)).toEqual(['room_b', 'room_d']);
132
+
133
+ const nighttime = makeState({ gameTime: 20 });
134
+ expect(lm.getAccessibleFrom('room_a', nighttime).map(l => l.id)).toEqual([
135
+ 'room_b',
136
+ 'room_c',
137
+ 'room_d',
138
+ ]);
139
+ });
140
+
141
+ it('clear() removes links added via link()', () => {
142
+ lm.link('room_a', ['room_b', 'room_c']);
143
+ lm.clear();
144
+
145
+ const state = makeState();
146
+ // Locations are gone too, so getAccessibleFrom returns empty
147
+ expect(lm.getAccessibleFrom('room_a', state)).toHaveLength(0);
148
+ });
149
+ });
150
+
151
+ /**
152
+ * bootstrapVylos unit tests are intentionally omitted.
153
+ *
154
+ * bootstrapVylos() is a high-level orchestration function that:
155
+ * - Creates a Vue application with createApp(GameShell)
156
+ * - Sets up Pinia stores
157
+ * - Creates the engine via createEngine() (which uses tsyringe DI)
158
+ * - Mounts the app to #app
159
+ * - Sets up a phase watcher to start the game loop
160
+ *
161
+ * Testing it in isolation would require mocking nearly every dependency
162
+ * (Vue createApp, Pinia, GameShell component, createEngine, stores, etc.),
163
+ * which would produce brittle tests that test the mock wiring rather than
164
+ * real behavior. The function's correctness is better validated through:
165
+ *
166
+ * 1. Integration tests via the example projects (pnpm dev:basic, pnpm dev:romance)
167
+ * 2. Unit tests of the individual pieces it orchestrates (LocationManager, ActionManager,
168
+ * EventRunner, stores, etc.) which are already well-covered
169
+ * 3. The helper functions (buildResolveText, buildCallbacks) are private to the module
170
+ * and tested indirectly through their integration with the managers and stores
171
+ */