@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,145 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { EventManager } from '../../src/engine/managers/EventManager';
|
|
3
|
+
import type { VylosEvent, VylosAPI, BaseGameState } from '../../src/engine/types';
|
|
4
|
+
import { EventStatus } from '../../src/engine/types';
|
|
5
|
+
|
|
6
|
+
function makeState(overrides: Partial<BaseGameState> = {}): BaseGameState {
|
|
7
|
+
return {
|
|
8
|
+
locationId: 'cafe',
|
|
9
|
+
gameTime: 8,
|
|
10
|
+
flags: {},
|
|
11
|
+
counters: {},
|
|
12
|
+
player: { name: 'Alice' },
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeEvent(id: string, overrides: Partial<VylosEvent> = {}): VylosEvent {
|
|
18
|
+
return {
|
|
19
|
+
id,
|
|
20
|
+
async execute(_engine: VylosAPI) { /* noop */ },
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('EventManager', () => {
|
|
26
|
+
let em: EventManager;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
em = new EventManager();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('registers events', () => {
|
|
33
|
+
em.register(makeEvent('ev1'));
|
|
34
|
+
em.register(makeEvent('ev2'));
|
|
35
|
+
expect(em.get('ev1')).toBeDefined();
|
|
36
|
+
expect(em.get('ev2')).toBeDefined();
|
|
37
|
+
expect(em.get('ev3')).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('events start as NotReady', () => {
|
|
41
|
+
em.register(makeEvent('ev1'));
|
|
42
|
+
expect(em.getStatus('ev1')).toBe(EventStatus.NotReady);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('evaluate()', () => {
|
|
46
|
+
it('unlocks events with no conditions', () => {
|
|
47
|
+
em.register(makeEvent('ev1'));
|
|
48
|
+
const unlocked = em.evaluate(makeState());
|
|
49
|
+
expect(unlocked).toHaveLength(1);
|
|
50
|
+
expect(unlocked[0].id).toBe('ev1');
|
|
51
|
+
expect(em.getStatus('ev1')).toBe(EventStatus.Unlocked);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('unlocks events when conditions are met', () => {
|
|
55
|
+
em.register(makeEvent('ev1', {
|
|
56
|
+
conditions: (state) => state.flags['intro_done'] === true,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
expect(em.evaluate(makeState())).toHaveLength(0);
|
|
60
|
+
expect(em.getStatus('ev1')).toBe(EventStatus.NotReady);
|
|
61
|
+
|
|
62
|
+
const unlocked = em.evaluate(makeState({ flags: { intro_done: true } }));
|
|
63
|
+
expect(unlocked).toHaveLength(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('calls unlocked() callback when transitioning', () => {
|
|
67
|
+
const unlockedFn = vi.fn();
|
|
68
|
+
em.register(makeEvent('ev1', { unlocked: unlockedFn }));
|
|
69
|
+
em.evaluate(makeState());
|
|
70
|
+
expect(unlockedFn).toHaveBeenCalledOnce();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not re-evaluate already unlocked events', () => {
|
|
74
|
+
em.register(makeEvent('ev1'));
|
|
75
|
+
em.evaluate(makeState());
|
|
76
|
+
const second = em.evaluate(makeState());
|
|
77
|
+
expect(second).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('getNextUnlocked returns first unlocked event', () => {
|
|
82
|
+
em.register(makeEvent('ev1', {
|
|
83
|
+
conditions: () => false,
|
|
84
|
+
}));
|
|
85
|
+
em.register(makeEvent('ev2'));
|
|
86
|
+
em.evaluate(makeState());
|
|
87
|
+
|
|
88
|
+
const next = em.getNextUnlocked(makeState());
|
|
89
|
+
expect(next?.id).toBe('ev2');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('lifecycle: NotReady → Unlocked → Running → Locked', () => {
|
|
93
|
+
const lockedFn = vi.fn();
|
|
94
|
+
em.register(makeEvent('ev1', { locked: lockedFn }));
|
|
95
|
+
|
|
96
|
+
expect(em.getStatus('ev1')).toBe(EventStatus.NotReady);
|
|
97
|
+
|
|
98
|
+
em.evaluate(makeState());
|
|
99
|
+
expect(em.getStatus('ev1')).toBe(EventStatus.Unlocked);
|
|
100
|
+
|
|
101
|
+
em.setRunning('ev1');
|
|
102
|
+
expect(em.getStatus('ev1')).toBe(EventStatus.Running);
|
|
103
|
+
|
|
104
|
+
em.setLocked('ev1', makeState());
|
|
105
|
+
expect(em.getStatus('ev1')).toBe(EventStatus.Locked);
|
|
106
|
+
expect(lockedFn).toHaveBeenCalledOnce();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('getByLocation filters events', () => {
|
|
110
|
+
em.register(makeEvent('ev1', { locationId: 'cafe' }));
|
|
111
|
+
em.register(makeEvent('ev2', { locationId: 'bedroom' }));
|
|
112
|
+
em.register(makeEvent('ev3', { locationId: 'cafe' }));
|
|
113
|
+
|
|
114
|
+
const cafeEvents = em.getByLocation('cafe');
|
|
115
|
+
expect(cafeEvents).toHaveLength(2);
|
|
116
|
+
expect(cafeEvents.map(e => e.id)).toEqual(['ev1', 'ev3']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('getLockedIds / restoreLockedIds', () => {
|
|
120
|
+
em.register(makeEvent('ev1'));
|
|
121
|
+
em.register(makeEvent('ev2'));
|
|
122
|
+
em.register(makeEvent('ev3'));
|
|
123
|
+
|
|
124
|
+
em.evaluate(makeState());
|
|
125
|
+
em.setLocked('ev1', makeState());
|
|
126
|
+
em.setLocked('ev3', makeState());
|
|
127
|
+
|
|
128
|
+
const ids = em.getLockedIds();
|
|
129
|
+
expect(ids).toEqual(['ev1', 'ev3']);
|
|
130
|
+
|
|
131
|
+
em.resetAll();
|
|
132
|
+
em.restoreLockedIds(['ev1', 'ev3']);
|
|
133
|
+
expect(em.getStatus('ev1')).toBe(EventStatus.Locked);
|
|
134
|
+
expect(em.getStatus('ev2')).toBe(EventStatus.NotReady);
|
|
135
|
+
expect(em.getStatus('ev3')).toBe(EventStatus.Locked);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('reset puts event back to NotReady', () => {
|
|
139
|
+
em.register(makeEvent('ev1'));
|
|
140
|
+
em.evaluate(makeState());
|
|
141
|
+
expect(em.getStatus('ev1')).toBe(EventStatus.Unlocked);
|
|
142
|
+
em.reset('ev1');
|
|
143
|
+
expect(em.getStatus('ev1')).toBe(EventStatus.NotReady);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { EventRunner, type EventRunnerCallbacks } from '../../src/engine/core/EventRunner';
|
|
3
|
+
import type { VylosEvent, VylosAPI, BaseGameState } from '../../src/engine/types';
|
|
4
|
+
import { JumpSignal } from '../../src/engine/errors/JumpSignal';
|
|
5
|
+
import { EventEndError } from '../../src/engine/errors/EventEndError';
|
|
6
|
+
|
|
7
|
+
function makeState(overrides: Partial<BaseGameState> = {}): BaseGameState {
|
|
8
|
+
return {
|
|
9
|
+
locationId: 'cafe',
|
|
10
|
+
gameTime: 8,
|
|
11
|
+
flags: {},
|
|
12
|
+
counters: {},
|
|
13
|
+
player: { name: 'Alice' },
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeCallbacks(state?: BaseGameState): EventRunnerCallbacks & { state: BaseGameState } {
|
|
19
|
+
const s = state ?? makeState();
|
|
20
|
+
return {
|
|
21
|
+
state: s,
|
|
22
|
+
onSay: vi.fn(),
|
|
23
|
+
onChoice: vi.fn(),
|
|
24
|
+
onSetBackground: vi.fn(),
|
|
25
|
+
onSetForeground: vi.fn(),
|
|
26
|
+
onShowOverlay: vi.fn(),
|
|
27
|
+
onHideOverlay: vi.fn(),
|
|
28
|
+
onSetLocation: vi.fn(),
|
|
29
|
+
onClear: vi.fn(),
|
|
30
|
+
resolveText: vi.fn((text: unknown) => typeof text === 'string' ? text : 'resolved'),
|
|
31
|
+
getState: vi.fn(() => s),
|
|
32
|
+
setState: vi.fn((newState: BaseGameState) => { Object.assign(s, newState); }),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('EventRunner', () => {
|
|
37
|
+
let callbacks: ReturnType<typeof makeCallbacks>;
|
|
38
|
+
let runner: EventRunner;
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
callbacks = makeCallbacks();
|
|
42
|
+
runner = new EventRunner(callbacks);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('say()', () => {
|
|
46
|
+
it('displays dialogue and waits for continue', async () => {
|
|
47
|
+
const event: VylosEvent = {
|
|
48
|
+
id: 'test-say',
|
|
49
|
+
async execute(engine: VylosAPI) {
|
|
50
|
+
await engine.say('Hello world');
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Start execution (will pause at say)
|
|
55
|
+
const promise = runner.executeEvent(event);
|
|
56
|
+
|
|
57
|
+
// Wait a tick for the say to be called
|
|
58
|
+
await vi.waitFor(() => {
|
|
59
|
+
expect(callbacks.onSay).toHaveBeenCalledWith('Hello world', null);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Simulate player clicking continue
|
|
63
|
+
runner.resolveWait();
|
|
64
|
+
|
|
65
|
+
await promise;
|
|
66
|
+
expect(callbacks.onClear).toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('resolves speaker text', async () => {
|
|
70
|
+
const event: VylosEvent = {
|
|
71
|
+
id: 'test-speaker',
|
|
72
|
+
async execute(engine: VylosAPI) {
|
|
73
|
+
await engine.say('Hi!', { from: 'Barista' });
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const promise = runner.executeEvent(event);
|
|
78
|
+
|
|
79
|
+
await vi.waitFor(() => {
|
|
80
|
+
expect(callbacks.onSay).toHaveBeenCalledWith('Hi!', 'Barista');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
runner.resolveWait();
|
|
84
|
+
await promise;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('interpolates variables', async () => {
|
|
88
|
+
callbacks.resolveText = vi.fn((t: unknown) => typeof t === 'string' ? t : 'resolved');
|
|
89
|
+
|
|
90
|
+
const event: VylosEvent = {
|
|
91
|
+
id: 'test-vars',
|
|
92
|
+
async execute(engine: VylosAPI) {
|
|
93
|
+
await engine.say('Hello {name}!', { variables: { name: 'Bob' } });
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const promise = runner.executeEvent(event);
|
|
98
|
+
|
|
99
|
+
await vi.waitFor(() => {
|
|
100
|
+
expect(callbacks.onSay).toHaveBeenCalledWith('Hello Bob!', null);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
runner.resolveWait();
|
|
104
|
+
await promise;
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('choice()', () => {
|
|
109
|
+
it('shows choices and returns selected value', async () => {
|
|
110
|
+
let result = '';
|
|
111
|
+
const event: VylosEvent = {
|
|
112
|
+
id: 'test-choice',
|
|
113
|
+
async execute(engine: VylosAPI) {
|
|
114
|
+
result = await engine.choice([
|
|
115
|
+
{ text: 'Coffee', value: 'coffee' },
|
|
116
|
+
{ text: 'Tea', value: 'tea' },
|
|
117
|
+
]);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const promise = runner.executeEvent(event);
|
|
122
|
+
|
|
123
|
+
await vi.waitFor(() => {
|
|
124
|
+
expect(callbacks.onChoice).toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Simulate player choosing "tea"
|
|
128
|
+
runner.resolveWait('tea');
|
|
129
|
+
await promise;
|
|
130
|
+
|
|
131
|
+
expect(result).toBe('tea');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('filters choices by condition', async () => {
|
|
135
|
+
const event: VylosEvent = {
|
|
136
|
+
id: 'test-choice-filter',
|
|
137
|
+
async execute(engine: VylosAPI) {
|
|
138
|
+
await engine.choice([
|
|
139
|
+
{ text: 'Coffee', value: 'coffee' },
|
|
140
|
+
{ text: 'Secret', value: 'secret', condition: () => false },
|
|
141
|
+
]);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const promise = runner.executeEvent(event);
|
|
146
|
+
|
|
147
|
+
await vi.waitFor(() => {
|
|
148
|
+
expect(callbacks.onChoice).toHaveBeenCalled();
|
|
149
|
+
const options = callbacks.onChoice.mock.calls[0][0];
|
|
150
|
+
expect(options).toHaveLength(1);
|
|
151
|
+
expect(options[0].value).toBe('coffee');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
runner.resolveWait('coffee');
|
|
155
|
+
await promise;
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('inline choice branching', () => {
|
|
160
|
+
it('supports if/else based on choice result', async () => {
|
|
161
|
+
const spoken: string[] = [];
|
|
162
|
+
callbacks.onSay = vi.fn((_text: string) => { spoken.push(_text); });
|
|
163
|
+
|
|
164
|
+
const event: VylosEvent = {
|
|
165
|
+
id: 'test-branch',
|
|
166
|
+
async execute(engine: VylosAPI) {
|
|
167
|
+
await engine.say('Welcome!');
|
|
168
|
+
const pick = await engine.choice([
|
|
169
|
+
{ text: 'Coffee', value: 'coffee' },
|
|
170
|
+
{ text: 'Tea', value: 'tea' },
|
|
171
|
+
]);
|
|
172
|
+
if (pick === 'coffee') {
|
|
173
|
+
await engine.say('Good choice!');
|
|
174
|
+
} else {
|
|
175
|
+
await engine.say('Coming right up!');
|
|
176
|
+
}
|
|
177
|
+
await engine.say('Enjoy.');
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const promise = runner.executeEvent(event);
|
|
182
|
+
|
|
183
|
+
// Step 1: "Welcome!"
|
|
184
|
+
await vi.waitFor(() => expect(spoken).toContain('Welcome!'));
|
|
185
|
+
runner.resolveWait();
|
|
186
|
+
|
|
187
|
+
// Step 2: Choice
|
|
188
|
+
await vi.waitFor(() => expect(callbacks.onChoice).toHaveBeenCalled());
|
|
189
|
+
runner.resolveWait('coffee');
|
|
190
|
+
|
|
191
|
+
// Step 3: "Good choice!" (not "Coming right up!")
|
|
192
|
+
await vi.waitFor(() => expect(spoken).toContain('Good choice!'));
|
|
193
|
+
runner.resolveWait();
|
|
194
|
+
|
|
195
|
+
// Step 4: "Enjoy."
|
|
196
|
+
await vi.waitFor(() => expect(spoken).toContain('Enjoy.'));
|
|
197
|
+
runner.resolveWait();
|
|
198
|
+
|
|
199
|
+
await promise;
|
|
200
|
+
|
|
201
|
+
expect(spoken).toEqual(['Welcome!', 'Good choice!', 'Enjoy.']);
|
|
202
|
+
expect(spoken).not.toContain('Coming right up!');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('jump()', () => {
|
|
207
|
+
it('throws JumpSignal', async () => {
|
|
208
|
+
const event: VylosEvent = {
|
|
209
|
+
id: 'test-jump',
|
|
210
|
+
async execute(engine: VylosAPI) {
|
|
211
|
+
engine.jump('other-event');
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
await expect(runner.executeEvent(event)).rejects.toThrow(JumpSignal);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('carries target event ID', async () => {
|
|
219
|
+
const event: VylosEvent = {
|
|
220
|
+
id: 'test-jump-id',
|
|
221
|
+
async execute(engine: VylosAPI) {
|
|
222
|
+
engine.jump('intro');
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
await runner.executeEvent(event);
|
|
228
|
+
} catch (e) {
|
|
229
|
+
expect(e).toBeInstanceOf(JumpSignal);
|
|
230
|
+
expect((e as JumpSignal).targetEventId).toBe('intro');
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('end()', () => {
|
|
236
|
+
it('ends event cleanly', async () => {
|
|
237
|
+
const spoken: string[] = [];
|
|
238
|
+
callbacks.onSay = vi.fn((t: string) => spoken.push(t));
|
|
239
|
+
|
|
240
|
+
const event: VylosEvent = {
|
|
241
|
+
id: 'test-end',
|
|
242
|
+
async execute(engine: VylosAPI) {
|
|
243
|
+
await engine.say('Before end');
|
|
244
|
+
engine.end();
|
|
245
|
+
// This should never execute
|
|
246
|
+
await engine.say('After end');
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const promise = runner.executeEvent(event);
|
|
251
|
+
|
|
252
|
+
await vi.waitFor(() => expect(spoken).toContain('Before end'));
|
|
253
|
+
runner.resolveWait();
|
|
254
|
+
|
|
255
|
+
await promise;
|
|
256
|
+
expect(spoken).not.toContain('After end');
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('setBackground / setForeground', () => {
|
|
261
|
+
it('calls callbacks synchronously', async () => {
|
|
262
|
+
const event: VylosEvent = {
|
|
263
|
+
id: 'test-bg',
|
|
264
|
+
async execute(engine: VylosAPI) {
|
|
265
|
+
engine.setBackground('/bg.jpg');
|
|
266
|
+
engine.setForeground('/fg.png');
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
await runner.executeEvent(event);
|
|
271
|
+
expect(callbacks.onSetBackground).toHaveBeenCalledWith('/bg.jpg');
|
|
272
|
+
expect(callbacks.onSetForeground).toHaveBeenCalledWith('/fg.png');
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('checkpoints', () => {
|
|
277
|
+
it('captures checkpoint for each say', async () => {
|
|
278
|
+
const event: VylosEvent = {
|
|
279
|
+
id: 'test-cp',
|
|
280
|
+
async execute(engine: VylosAPI) {
|
|
281
|
+
await engine.say('Line 1');
|
|
282
|
+
await engine.say('Line 2');
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const promise = runner.executeEvent(event);
|
|
287
|
+
|
|
288
|
+
await vi.waitFor(() => expect(callbacks.onSay).toHaveBeenCalled());
|
|
289
|
+
runner.resolveWait();
|
|
290
|
+
|
|
291
|
+
await vi.waitFor(() => expect(callbacks.onSay).toHaveBeenCalledTimes(2));
|
|
292
|
+
runner.resolveWait();
|
|
293
|
+
|
|
294
|
+
await promise;
|
|
295
|
+
expect(runner.checkpoints.count).toBe(2);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('captures choice result in checkpoint', async () => {
|
|
299
|
+
const event: VylosEvent = {
|
|
300
|
+
id: 'test-cp-choice',
|
|
301
|
+
async execute(engine: VylosAPI) {
|
|
302
|
+
await engine.choice([
|
|
303
|
+
{ text: 'A', value: 'a' },
|
|
304
|
+
{ text: 'B', value: 'b' },
|
|
305
|
+
]);
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const promise = runner.executeEvent(event);
|
|
310
|
+
await vi.waitFor(() => expect(callbacks.onChoice).toHaveBeenCalled());
|
|
311
|
+
runner.resolveWait('b');
|
|
312
|
+
await promise;
|
|
313
|
+
|
|
314
|
+
const all = runner.checkpoints.getAll();
|
|
315
|
+
expect(all[0].choiceResult).toBe('b');
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { HistoryManager } from '../../src/engine/managers/HistoryManager';
|
|
3
|
+
import type { Checkpoint } from '../../src/engine/types';
|
|
4
|
+
import { CheckpointType } from '../../src/engine/types';
|
|
5
|
+
|
|
6
|
+
function makeCheckpoints(count: number): Checkpoint[] {
|
|
7
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
8
|
+
step: i,
|
|
9
|
+
gameState: {
|
|
10
|
+
locationId: 'cafe',
|
|
11
|
+
gameTime: 8 + i,
|
|
12
|
+
flags: {},
|
|
13
|
+
counters: {},
|
|
14
|
+
player: { name: 'Alice' },
|
|
15
|
+
},
|
|
16
|
+
type: CheckpointType.Say,
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('HistoryManager', () => {
|
|
21
|
+
let hm: HistoryManager;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
hm = new HistoryManager();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('starts empty', () => {
|
|
28
|
+
expect(hm.count).toBe(0);
|
|
29
|
+
expect(hm.canGoBack).toBe(false);
|
|
30
|
+
expect(hm.canGoForward).toBe(false);
|
|
31
|
+
expect(hm.current).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('pushes entries and tracks current', () => {
|
|
35
|
+
hm.push('event-1', makeCheckpoints(2));
|
|
36
|
+
expect(hm.count).toBe(1);
|
|
37
|
+
expect(hm.current?.eventId).toBe('event-1');
|
|
38
|
+
|
|
39
|
+
hm.push('event-2', makeCheckpoints(3));
|
|
40
|
+
expect(hm.count).toBe(2);
|
|
41
|
+
expect(hm.current?.eventId).toBe('event-2');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('goes back and forward', () => {
|
|
45
|
+
hm.push('event-1', makeCheckpoints(2));
|
|
46
|
+
hm.push('event-2', makeCheckpoints(3));
|
|
47
|
+
hm.push('event-3', makeCheckpoints(1));
|
|
48
|
+
|
|
49
|
+
expect(hm.canGoBack).toBe(true);
|
|
50
|
+
expect(hm.canGoForward).toBe(false);
|
|
51
|
+
|
|
52
|
+
const back1 = hm.goBack();
|
|
53
|
+
expect(back1?.eventId).toBe('event-2');
|
|
54
|
+
expect(hm.canGoBack).toBe(true);
|
|
55
|
+
expect(hm.canGoForward).toBe(true);
|
|
56
|
+
|
|
57
|
+
const back2 = hm.goBack();
|
|
58
|
+
expect(back2?.eventId).toBe('event-1');
|
|
59
|
+
expect(hm.canGoBack).toBe(false);
|
|
60
|
+
|
|
61
|
+
const fwd1 = hm.goForward();
|
|
62
|
+
expect(fwd1?.eventId).toBe('event-2');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('trims forward history on push after goBack', () => {
|
|
66
|
+
hm.push('event-1', makeCheckpoints(1));
|
|
67
|
+
hm.push('event-2', makeCheckpoints(1));
|
|
68
|
+
hm.push('event-3', makeCheckpoints(1));
|
|
69
|
+
|
|
70
|
+
hm.goBack(); // at event-2
|
|
71
|
+
hm.push('event-4', makeCheckpoints(1));
|
|
72
|
+
|
|
73
|
+
expect(hm.count).toBe(3); // event-1, event-2, event-4
|
|
74
|
+
expect(hm.current?.eventId).toBe('event-4');
|
|
75
|
+
expect(hm.canGoForward).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('goBack returns undefined when at start', () => {
|
|
79
|
+
hm.push('event-1', makeCheckpoints(1));
|
|
80
|
+
expect(hm.goBack()).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('goForward returns undefined when at end', () => {
|
|
84
|
+
hm.push('event-1', makeCheckpoints(1));
|
|
85
|
+
expect(hm.goForward()).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('clear removes all history', () => {
|
|
89
|
+
hm.push('event-1', makeCheckpoints(2));
|
|
90
|
+
hm.push('event-2', makeCheckpoints(3));
|
|
91
|
+
hm.clear();
|
|
92
|
+
expect(hm.count).toBe(0);
|
|
93
|
+
expect(hm.canGoBack).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('getAll returns deep clones', () => {
|
|
97
|
+
hm.push('event-1', makeCheckpoints(2));
|
|
98
|
+
const all = hm.getAll();
|
|
99
|
+
all[0].eventId = 'hacked';
|
|
100
|
+
expect(hm.current?.eventId).toBe('event-1');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('restore loads from save data', () => {
|
|
104
|
+
const entries = [
|
|
105
|
+
{ eventId: 'ev1', checkpoints: makeCheckpoints(2) },
|
|
106
|
+
{ eventId: 'ev2', checkpoints: makeCheckpoints(1) },
|
|
107
|
+
];
|
|
108
|
+
hm.restore(entries, 1);
|
|
109
|
+
expect(hm.count).toBe(2);
|
|
110
|
+
expect(hm.current?.eventId).toBe('ev2');
|
|
111
|
+
expect(hm.canGoBack).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { LocationManager } from '../../src/engine/managers/LocationManager';
|
|
3
|
+
import type { VylosLocation, LocationLink, BaseGameState } from '../../src/engine/types';
|
|
4
|
+
|
|
5
|
+
function makeState(overrides: Partial<BaseGameState> = {}): BaseGameState {
|
|
6
|
+
return {
|
|
7
|
+
locationId: 'bedroom',
|
|
8
|
+
gameTime: 12,
|
|
9
|
+
flags: {},
|
|
10
|
+
counters: {},
|
|
11
|
+
player: { name: 'Alice' },
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const bedroom: VylosLocation = {
|
|
17
|
+
id: 'bedroom',
|
|
18
|
+
name: 'Bedroom',
|
|
19
|
+
backgrounds: [
|
|
20
|
+
{ path: '/bedroom_day.jpg', timeRange: [6, 18] },
|
|
21
|
+
{ path: '/bedroom_night.jpg', timeRange: [18, 6] },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const hallway: VylosLocation = {
|
|
26
|
+
id: 'hallway',
|
|
27
|
+
name: 'Hallway',
|
|
28
|
+
backgrounds: [{ path: '/hallway.jpg' }],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const cafe: VylosLocation = {
|
|
32
|
+
id: 'cafe',
|
|
33
|
+
name: { en: 'Cafe', fr: 'Café' },
|
|
34
|
+
backgrounds: [{ path: '/cafe.jpg' }],
|
|
35
|
+
accessible: (state) => state.flags['has_key'] === true,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('LocationManager', () => {
|
|
39
|
+
let lm: LocationManager;
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
lm = new LocationManager();
|
|
43
|
+
lm.registerAll([bedroom, hallway, cafe]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('registers and retrieves locations', () => {
|
|
47
|
+
expect(lm.get('bedroom')).toBeDefined();
|
|
48
|
+
expect(lm.get('hallway')).toBeDefined();
|
|
49
|
+
expect(lm.get('cafe')).toBeDefined();
|
|
50
|
+
expect(lm.get('nonexistent')).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('has() checks existence', () => {
|
|
54
|
+
expect(lm.has('bedroom')).toBe(true);
|
|
55
|
+
expect(lm.has('nope')).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('getAll returns all locations', () => {
|
|
59
|
+
expect(lm.getAll()).toHaveLength(3);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('getAccessibleFrom', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
lm.setLinks([
|
|
65
|
+
{ from: 'bedroom', to: 'hallway' },
|
|
66
|
+
{ from: 'hallway', to: 'bedroom' },
|
|
67
|
+
{ from: 'hallway', to: 'cafe' },
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns linked accessible locations', () => {
|
|
72
|
+
const state = makeState({ flags: { has_key: true } });
|
|
73
|
+
const accessible = lm.getAccessibleFrom('hallway', state);
|
|
74
|
+
expect(accessible.map(l => l.id)).toContain('bedroom');
|
|
75
|
+
expect(accessible.map(l => l.id)).toContain('cafe');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('excludes inaccessible locations', () => {
|
|
79
|
+
const state = makeState(); // no has_key flag
|
|
80
|
+
const accessible = lm.getAccessibleFrom('hallway', state);
|
|
81
|
+
expect(accessible.map(l => l.id)).toContain('bedroom');
|
|
82
|
+
expect(accessible.map(l => l.id)).not.toContain('cafe');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('only returns locations linked from source', () => {
|
|
86
|
+
const state = makeState({ flags: { has_key: true } });
|
|
87
|
+
const accessible = lm.getAccessibleFrom('bedroom', state);
|
|
88
|
+
expect(accessible.map(l => l.id)).toEqual(['hallway']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('supports conditional links', () => {
|
|
92
|
+
lm.setLinks([
|
|
93
|
+
{ from: 'bedroom', to: 'hallway' },
|
|
94
|
+
{
|
|
95
|
+
from: 'bedroom',
|
|
96
|
+
to: 'cafe',
|
|
97
|
+
condition: (state) => state.flags['secret_path'] === true,
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
const stateWithout = makeState({ flags: { has_key: true } });
|
|
102
|
+
expect(lm.getAccessibleFrom('bedroom', stateWithout).map(l => l.id)).toEqual(['hallway']);
|
|
103
|
+
|
|
104
|
+
const stateWith = makeState({ flags: { has_key: true, secret_path: true } });
|
|
105
|
+
expect(lm.getAccessibleFrom('bedroom', stateWith).map(l => l.id)).toEqual(['hallway', 'cafe']);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('resolveBackground', () => {
|
|
110
|
+
it('resolves time-based background', () => {
|
|
111
|
+
expect(lm.resolveBackground('bedroom', 12)).toBe('/bedroom_day.jpg');
|
|
112
|
+
expect(lm.resolveBackground('bedroom', 22)).toBe('/bedroom_night.jpg');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('resolves default background', () => {
|
|
116
|
+
expect(lm.resolveBackground('hallway', 12)).toBe('/hallway.jpg');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('returns null for unknown location', () => {
|
|
120
|
+
expect(lm.resolveBackground('nope', 12)).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('clear removes everything', () => {
|
|
125
|
+
lm.clear();
|
|
126
|
+
expect(lm.getAll()).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
});
|