@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.
Files changed (75) hide show
  1. package/package.json +34 -0
  2. package/src/components/app/EngineView.vue +40 -0
  3. package/src/components/app/GameShell.vue +265 -0
  4. package/src/components/app/LoadingScreen.vue +10 -0
  5. package/src/components/app/MainMenu.vue +243 -0
  6. package/src/components/core/BackgroundLayer.vue +27 -0
  7. package/src/components/core/ChoicePanel.vue +115 -0
  8. package/src/components/core/CustomOverlay.vue +27 -0
  9. package/src/components/core/DialogueBox.vue +144 -0
  10. package/src/components/core/DrawableOverlay.vue +109 -0
  11. package/src/components/core/ForegroundLayer.vue +31 -0
  12. package/src/components/menu/ActionOverlay.vue +126 -0
  13. package/src/components/menu/LocationOverlay.vue +136 -0
  14. package/src/components/menu/PauseMenu.vue +196 -0
  15. package/src/components/menu/SaveLoadMenu.vue +377 -0
  16. package/src/components/menu/SettingsMenu.vue +111 -0
  17. package/src/components/menu/TopBar.vue +65 -0
  18. package/src/composables/useConfig.ts +4 -0
  19. package/src/composables/useEngine.ts +16 -0
  20. package/src/composables/useGameState.ts +9 -0
  21. package/src/composables/useLanguage.ts +31 -0
  22. package/src/engine/core/CheckpointManager.ts +122 -0
  23. package/src/engine/core/Engine.ts +272 -0
  24. package/src/engine/core/EngineFactory.ts +102 -0
  25. package/src/engine/core/EventRunner.ts +488 -0
  26. package/src/engine/errors/EventEndError.ts +7 -0
  27. package/src/engine/errors/InterruptSignal.ts +10 -0
  28. package/src/engine/errors/JumpSignal.ts +10 -0
  29. package/src/engine/errors/StateValidationError.ts +13 -0
  30. package/src/engine/managers/ActionManager.ts +62 -0
  31. package/src/engine/managers/EventManager.ts +166 -0
  32. package/src/engine/managers/HistoryManager.ts +84 -0
  33. package/src/engine/managers/InputManager.ts +117 -0
  34. package/src/engine/managers/LanguageManager.ts +51 -0
  35. package/src/engine/managers/LocationManager.ts +76 -0
  36. package/src/engine/managers/NavigationManager.ts +75 -0
  37. package/src/engine/managers/SaveManager.ts +86 -0
  38. package/src/engine/managers/SettingsManager.ts +70 -0
  39. package/src/engine/managers/WaitManager.ts +47 -0
  40. package/src/engine/schemas/baseGameState.schema.ts +19 -0
  41. package/src/engine/schemas/checkpoint.schema.ts +11 -0
  42. package/src/engine/schemas/engineState.schema.ts +59 -0
  43. package/src/engine/schemas/location.schema.ts +21 -0
  44. package/src/engine/storage/VylosStorage.ts +131 -0
  45. package/src/engine/types/actions.ts +20 -0
  46. package/src/engine/types/checkpoint.ts +31 -0
  47. package/src/engine/types/config.ts +9 -0
  48. package/src/engine/types/dialogue.ts +15 -0
  49. package/src/engine/types/engine.ts +85 -0
  50. package/src/engine/types/events.ts +117 -0
  51. package/src/engine/types/game-state.ts +15 -0
  52. package/src/engine/types/index.ts +10 -0
  53. package/src/engine/types/locations.ts +32 -0
  54. package/src/engine/types/plugin.ts +11 -0
  55. package/src/engine/types/save.ts +40 -0
  56. package/src/engine/utils/TimeHelper.ts +39 -0
  57. package/src/engine/utils/logger.ts +43 -0
  58. package/src/env.d.ts +17 -0
  59. package/src/index.ts +74 -0
  60. package/src/stores/engineState.ts +127 -0
  61. package/src/stores/gameState.ts +49 -0
  62. package/tests/engine/ActionManager.test.ts +94 -0
  63. package/tests/engine/CheckpointManager.test.ts +136 -0
  64. package/tests/engine/EventManager.test.ts +145 -0
  65. package/tests/engine/EventRunner.test.ts +318 -0
  66. package/tests/engine/HistoryManager.test.ts +113 -0
  67. package/tests/engine/LocationManager.test.ts +128 -0
  68. package/tests/engine/schemas.test.ts +250 -0
  69. package/tests/engine/utils.test.ts +75 -0
  70. package/tests/integration/game-loop.test.ts +201 -0
  71. package/tests/safety/event-validation.test.ts +102 -0
  72. package/tests/safety/state-schema.test.ts +96 -0
  73. package/tests/setup.ts +2 -0
  74. package/tsconfig.json +14 -0
  75. package/vitest.config.ts +16 -0
@@ -0,0 +1,250 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { baseGameStateSchema, extendGameStateSchema } from '../../src/engine/schemas/baseGameState.schema';
3
+ import { engineStateSchema, engineSettingsSchema } from '../../src/engine/schemas/engineState.schema';
4
+ import { checkpointSchema } from '../../src/engine/schemas/checkpoint.schema';
5
+ import { locationSchema, locationLinkSchema } from '../../src/engine/schemas/location.schema';
6
+ import { z } from 'zod';
7
+
8
+ describe('baseGameStateSchema', () => {
9
+ const validState = {
10
+ locationId: 'bedroom',
11
+ gameTime: 8,
12
+ flags: { intro_done: true },
13
+ counters: { coffee_count: 2 },
14
+ player: { name: 'Alice' },
15
+ };
16
+
17
+ it('validates a correct base game state', () => {
18
+ const result = baseGameStateSchema.safeParse(validState);
19
+ expect(result.success).toBe(true);
20
+ });
21
+
22
+ it('rejects empty locationId', () => {
23
+ const result = baseGameStateSchema.safeParse({ ...validState, locationId: '' });
24
+ expect(result.success).toBe(false);
25
+ });
26
+
27
+ it('rejects negative gameTime', () => {
28
+ const result = baseGameStateSchema.safeParse({ ...validState, gameTime: -1 });
29
+ expect(result.success).toBe(false);
30
+ });
31
+
32
+ it('rejects empty player name', () => {
33
+ const result = baseGameStateSchema.safeParse({
34
+ ...validState,
35
+ player: { name: '' },
36
+ });
37
+ expect(result.success).toBe(false);
38
+ });
39
+
40
+ it('rejects missing fields', () => {
41
+ const result = baseGameStateSchema.safeParse({ locationId: 'test' });
42
+ expect(result.success).toBe(false);
43
+ });
44
+ });
45
+
46
+ describe('extendGameStateSchema', () => {
47
+ it('extends base schema with custom fields', () => {
48
+ const extended = extendGameStateSchema({
49
+ reputation: z.number(),
50
+ inventory: z.array(z.string()),
51
+ });
52
+
53
+ const result = extended.safeParse({
54
+ locationId: 'cafe',
55
+ gameTime: 14,
56
+ flags: {},
57
+ counters: {},
58
+ player: { name: 'Bob' },
59
+ reputation: 50,
60
+ inventory: ['key', 'map'],
61
+ });
62
+ expect(result.success).toBe(true);
63
+ });
64
+
65
+ it('rejects extended state missing custom fields', () => {
66
+ const extended = extendGameStateSchema({
67
+ reputation: z.number(),
68
+ });
69
+
70
+ const result = extended.safeParse({
71
+ locationId: 'cafe',
72
+ gameTime: 14,
73
+ flags: {},
74
+ counters: {},
75
+ player: { name: 'Bob' },
76
+ // missing reputation
77
+ });
78
+ expect(result.success).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe('engineStateSchema', () => {
83
+ const validEngineState = {
84
+ phase: 'created',
85
+ background: null,
86
+ foreground: null,
87
+ dialogue: null,
88
+ choices: null,
89
+ currentLocationId: null,
90
+ availableActions: [],
91
+ availableLocations: [],
92
+ menuOpen: null,
93
+ skipMode: false,
94
+ autoMode: false,
95
+ };
96
+
97
+ it('validates initial engine state', () => {
98
+ const result = engineStateSchema.safeParse(validEngineState);
99
+ expect(result.success).toBe(true);
100
+ });
101
+
102
+ it('validates engine state with dialogue', () => {
103
+ const result = engineStateSchema.safeParse({
104
+ ...validEngineState,
105
+ phase: 'running',
106
+ dialogue: { text: 'Hello', speaker: 'Alice', isNarration: false },
107
+ });
108
+ expect(result.success).toBe(true);
109
+ });
110
+
111
+ it('validates engine state with choices', () => {
112
+ const result = engineStateSchema.safeParse({
113
+ ...validEngineState,
114
+ phase: 'running',
115
+ choices: {
116
+ prompt: null,
117
+ options: [
118
+ { text: 'Yes', value: 'yes' },
119
+ { text: 'No', value: 'no' },
120
+ ],
121
+ },
122
+ });
123
+ expect(result.success).toBe(true);
124
+ });
125
+
126
+ it('rejects invalid phase', () => {
127
+ const result = engineStateSchema.safeParse({
128
+ ...validEngineState,
129
+ phase: 'invalid',
130
+ });
131
+ expect(result.success).toBe(false);
132
+ });
133
+ });
134
+
135
+ describe('engineSettingsSchema', () => {
136
+ it('validates default settings', () => {
137
+ const result = engineSettingsSchema.safeParse({
138
+ textSpeed: 0.5,
139
+ autoSpeed: 0.5,
140
+ volume: { master: 1, music: 0.8, sfx: 0.8, voice: 1 },
141
+ language: 'en',
142
+ fullscreen: false,
143
+ });
144
+ expect(result.success).toBe(true);
145
+ });
146
+
147
+ it('rejects volume out of range', () => {
148
+ const result = engineSettingsSchema.safeParse({
149
+ textSpeed: 0.5,
150
+ autoSpeed: 0.5,
151
+ volume: { master: 1.5, music: 0.8, sfx: 0.8, voice: 1 },
152
+ language: 'en',
153
+ fullscreen: false,
154
+ });
155
+ expect(result.success).toBe(false);
156
+ });
157
+ });
158
+
159
+ describe('checkpointSchema', () => {
160
+ it('validates a say checkpoint', () => {
161
+ const result = checkpointSchema.safeParse({
162
+ step: 0,
163
+ gameState: {
164
+ locationId: 'cafe',
165
+ gameTime: 14,
166
+ flags: {},
167
+ counters: {},
168
+ player: { name: 'Alice' },
169
+ },
170
+ type: 'say',
171
+ });
172
+ expect(result.success).toBe(true);
173
+ });
174
+
175
+ it('validates a choice checkpoint with result', () => {
176
+ const result = checkpointSchema.safeParse({
177
+ step: 1,
178
+ gameState: {
179
+ locationId: 'cafe',
180
+ gameTime: 14,
181
+ flags: {},
182
+ counters: {},
183
+ player: { name: 'Alice' },
184
+ },
185
+ type: 'choice',
186
+ choiceResult: 'coffee',
187
+ });
188
+ expect(result.success).toBe(true);
189
+ });
190
+
191
+ it('rejects negative step number', () => {
192
+ const result = checkpointSchema.safeParse({
193
+ step: -1,
194
+ gameState: {
195
+ locationId: 'cafe',
196
+ gameTime: 14,
197
+ flags: {},
198
+ counters: {},
199
+ player: { name: 'Alice' },
200
+ },
201
+ type: 'say',
202
+ });
203
+ expect(result.success).toBe(false);
204
+ });
205
+ });
206
+
207
+ describe('locationSchema', () => {
208
+ it('validates a location with backgrounds', () => {
209
+ const result = locationSchema.safeParse({
210
+ id: 'cafe',
211
+ name: 'The Cafe',
212
+ backgrounds: [
213
+ { path: '/assets/cafe_day.jpg', timeRange: [6, 18] },
214
+ { path: '/assets/cafe_night.jpg', timeRange: [18, 6] },
215
+ { path: '/assets/cafe_default.jpg' },
216
+ ],
217
+ });
218
+ expect(result.success).toBe(true);
219
+ });
220
+
221
+ it('validates a location with i18n name', () => {
222
+ const result = locationSchema.safeParse({
223
+ id: 'cafe',
224
+ name: { en: 'The Cafe', fr: 'Le Café' },
225
+ backgrounds: [{ path: '/assets/cafe.jpg' }],
226
+ });
227
+ expect(result.success).toBe(true);
228
+ });
229
+
230
+ it('rejects empty location id', () => {
231
+ const result = locationSchema.safeParse({
232
+ id: '',
233
+ name: 'Test',
234
+ backgrounds: [],
235
+ });
236
+ expect(result.success).toBe(false);
237
+ });
238
+ });
239
+
240
+ describe('locationLinkSchema', () => {
241
+ it('validates a link', () => {
242
+ const result = locationLinkSchema.safeParse({ from: 'bedroom', to: 'hallway' });
243
+ expect(result.success).toBe(true);
244
+ });
245
+
246
+ it('rejects empty from', () => {
247
+ const result = locationLinkSchema.safeParse({ from: '', to: 'hallway' });
248
+ expect(result.success).toBe(false);
249
+ });
250
+ });
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { resolveBackground, formatGameTime, interpolate } from '../../src/engine/utils/TimeHelper';
3
+
4
+ describe('resolveBackground', () => {
5
+ const backgrounds = [
6
+ { path: '/day.jpg', timeRange: [6, 18] as [number, number] },
7
+ { path: '/night.jpg', timeRange: [18, 6] as [number, number] },
8
+ { path: '/default.jpg' },
9
+ ];
10
+
11
+ it('returns daytime background during day', () => {
12
+ expect(resolveBackground(backgrounds, 12)).toBe('/day.jpg');
13
+ });
14
+
15
+ it('returns night background at night', () => {
16
+ expect(resolveBackground(backgrounds, 22)).toBe('/night.jpg');
17
+ });
18
+
19
+ it('returns night background at midnight', () => {
20
+ expect(resolveBackground(backgrounds, 0)).toBe('/night.jpg');
21
+ });
22
+
23
+ it('returns default when no time match', () => {
24
+ const bgs = [{ path: '/default.jpg' }];
25
+ expect(resolveBackground(bgs, 12)).toBe('/default.jpg');
26
+ });
27
+
28
+ it('returns null for empty array', () => {
29
+ expect(resolveBackground([], 12)).toBeNull();
30
+ });
31
+
32
+ it('wraps time past 24 hours', () => {
33
+ expect(resolveBackground(backgrounds, 36)).toBe('/day.jpg'); // 36 % 24 = 12
34
+ });
35
+ });
36
+
37
+ describe('formatGameTime', () => {
38
+ it('formats morning time', () => {
39
+ expect(formatGameTime(8)).toBe('08:00');
40
+ });
41
+
42
+ it('formats afternoon time with minutes', () => {
43
+ expect(formatGameTime(14.5)).toBe('14:30');
44
+ });
45
+
46
+ it('formats midnight', () => {
47
+ expect(formatGameTime(0)).toBe('00:00');
48
+ });
49
+
50
+ it('wraps past 24', () => {
51
+ expect(formatGameTime(25.5)).toBe('01:30');
52
+ });
53
+ });
54
+
55
+ describe('interpolate', () => {
56
+ it('replaces single variable', () => {
57
+ expect(interpolate('Hello {name}!', { name: 'Alice' })).toBe('Hello Alice!');
58
+ });
59
+
60
+ it('replaces multiple variables', () => {
61
+ expect(interpolate('{a} and {b}', { a: 'X', b: 'Y' })).toBe('X and Y');
62
+ });
63
+
64
+ it('leaves unknown variables untouched', () => {
65
+ expect(interpolate('Hello {name}!', {})).toBe('Hello {name}!');
66
+ });
67
+
68
+ it('handles numeric values', () => {
69
+ expect(interpolate('Count: {n}', { n: 42 })).toBe('Count: 42');
70
+ });
71
+
72
+ it('handles text without variables', () => {
73
+ expect(interpolate('No vars here', { name: 'Alice' })).toBe('No vars here');
74
+ });
75
+ });
@@ -0,0 +1,201 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { EventRunner, type EventRunnerCallbacks } from '../../src/engine/core/EventRunner';
3
+ import { EventManager } from '../../src/engine/managers/EventManager';
4
+ import { HistoryManager } from '../../src/engine/managers/HistoryManager';
5
+ import { NavigationManager } from '../../src/engine/managers/NavigationManager';
6
+ import { SaveManager } from '../../src/engine/managers/SaveManager';
7
+ import { SettingsManager } from '../../src/engine/managers/SettingsManager';
8
+ import { VylosStorage } from '../../src/engine/storage/VylosStorage';
9
+ import { Engine } from '../../src/engine/core/Engine';
10
+ import type { VylosAPI, VylosEvent, BaseGameState } from '../../src/engine/types';
11
+ import { createEngine, clearComponentOverrides, getComponentOverride } from '../../src/engine/core/EngineFactory';
12
+ import type { VylosPlugin } from '../../src/engine/types';
13
+ import { defineComponent } from 'vue';
14
+
15
+ function makeState(overrides: Partial<BaseGameState> = {}): BaseGameState {
16
+ return {
17
+ locationId: 'cafe',
18
+ gameTime: 8,
19
+ flags: {},
20
+ counters: {},
21
+ player: { name: 'Alice' },
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ function makeCallbacks(state?: BaseGameState): EventRunnerCallbacks & { state: BaseGameState } {
27
+ const s = state ?? makeState();
28
+ return {
29
+ state: s,
30
+ onSay: vi.fn(),
31
+ onChoice: vi.fn(),
32
+ onSetBackground: vi.fn(),
33
+ onSetForeground: vi.fn(),
34
+ onShowOverlay: vi.fn(),
35
+ onHideOverlay: vi.fn(),
36
+ onSetLocation: vi.fn(),
37
+ onClear: vi.fn(),
38
+ resolveText: vi.fn((text: unknown) => typeof text === 'string' ? text : 'resolved'),
39
+ getState: vi.fn(() => s),
40
+ setState: vi.fn((newState: BaseGameState) => { Object.assign(s, newState); }),
41
+ };
42
+ }
43
+
44
+ describe('Game loop integration', () => {
45
+ it('executes an event, locks it, and records history', async () => {
46
+ const callbacks = makeCallbacks();
47
+ const eventRunner = new EventRunner(callbacks);
48
+ const eventManager = new EventManager();
49
+ const historyManager = new HistoryManager();
50
+ const navigationManager = new NavigationManager();
51
+ const storage = new VylosStorage('test-loop-1');
52
+ await storage.open();
53
+ const saveManager = new SaveManager(storage);
54
+ const settingsManager = new SettingsManager(storage);
55
+
56
+ const engine = new Engine({
57
+ eventManager,
58
+ historyManager,
59
+ navigationManager,
60
+ eventRunner,
61
+ saveManager,
62
+ settingsManager,
63
+ });
64
+
65
+ const event: VylosEvent = {
66
+ id: 'intro',
67
+ async execute(api: VylosAPI) {
68
+ await api.say('Hello');
69
+ await api.say('World');
70
+ },
71
+ };
72
+
73
+ // Start engine in background
74
+ const runPromise = engine.run([event], callbacks.getState);
75
+
76
+ // Wait for first say
77
+ await vi.waitFor(() => expect(callbacks.onSay).toHaveBeenCalledWith('Hello', null));
78
+ eventRunner.resolveWait();
79
+
80
+ // Wait for second say
81
+ await vi.waitFor(() => expect(callbacks.onSay).toHaveBeenCalledWith('World', null));
82
+ eventRunner.resolveWait();
83
+
84
+ // Event complete — now we need to handle navigation wait
85
+ // Give engine a tick to process completion
86
+ await new Promise(r => setTimeout(r, 50));
87
+
88
+ // Stop engine
89
+ engine.stop();
90
+ await runPromise;
91
+
92
+ // Verify event was locked
93
+ expect(eventManager.getStatus('intro')).toBe('locked');
94
+ // Verify history recorded
95
+ expect(historyManager.count).toBe(1);
96
+ expect(historyManager.current?.eventId).toBe('intro');
97
+ });
98
+
99
+ it('handles jump between events', async () => {
100
+ const callbacks = makeCallbacks();
101
+ const eventRunner = new EventRunner(callbacks);
102
+ const eventManager = new EventManager();
103
+ const historyManager = new HistoryManager();
104
+ const navigationManager = new NavigationManager();
105
+ const storage = new VylosStorage('test-loop-2');
106
+ await storage.open();
107
+ const saveManager = new SaveManager(storage);
108
+ const settingsManager = new SettingsManager(storage);
109
+
110
+ const engine = new Engine({
111
+ eventManager,
112
+ historyManager,
113
+ navigationManager,
114
+ eventRunner,
115
+ saveManager,
116
+ settingsManager,
117
+ });
118
+
119
+ const spoken: string[] = [];
120
+ callbacks.onSay = vi.fn((text: string) => spoken.push(text));
121
+
122
+ const events: VylosEvent[] = [
123
+ {
124
+ id: 'first',
125
+ async execute(api: VylosAPI) {
126
+ await api.say('In first event');
127
+ api.jump('second');
128
+ },
129
+ },
130
+ {
131
+ id: 'second',
132
+ async execute(api: VylosAPI) {
133
+ await api.say('In second event');
134
+ },
135
+ },
136
+ ];
137
+
138
+ const runPromise = engine.run(events, callbacks.getState);
139
+
140
+ // First event: "In first event"
141
+ await vi.waitFor(() => expect(spoken).toContain('In first event'));
142
+ eventRunner.resolveWait();
143
+
144
+ // After jump, second event: "In second event"
145
+ await vi.waitFor(() => expect(spoken).toContain('In second event'));
146
+ eventRunner.resolveWait();
147
+
148
+ await new Promise(r => setTimeout(r, 50));
149
+ engine.stop();
150
+ await runPromise;
151
+
152
+ expect(spoken).toEqual(['In first event', 'In second event']);
153
+ expect(historyManager.count).toBe(2);
154
+ });
155
+ });
156
+
157
+ describe('DI / Plugin system', () => {
158
+ beforeEach(() => {
159
+ clearComponentOverrides();
160
+ });
161
+
162
+ it('createEngine produces a working engine', () => {
163
+ const callbacks = makeCallbacks();
164
+ const engine = createEngine({ callbacks });
165
+ expect(engine).toBeDefined();
166
+ expect(engine.eventManager).toBeInstanceOf(EventManager);
167
+ expect(engine.historyManager).toBeInstanceOf(HistoryManager);
168
+ expect(engine.navigationManager).toBeInstanceOf(NavigationManager);
169
+ });
170
+
171
+ it('plugin can override a manager', () => {
172
+ class CustomEventManager extends EventManager {
173
+ readonly isCustom = true;
174
+ }
175
+
176
+ const plugin: VylosPlugin = {
177
+ setup(container) {
178
+ container.register('EventManager', { useClass: CustomEventManager });
179
+ },
180
+ };
181
+
182
+ const callbacks = makeCallbacks();
183
+ const engine = createEngine({ callbacks, plugin });
184
+ expect((engine.eventManager as CustomEventManager).isCustom).toBe(true);
185
+ });
186
+
187
+ it('plugin can register component overrides', () => {
188
+ const CustomTopBar = defineComponent({ template: '<div>Custom</div>' });
189
+
190
+ const plugin: VylosPlugin = {
191
+ components: {
192
+ TopBar: CustomTopBar,
193
+ },
194
+ };
195
+
196
+ const callbacks = makeCallbacks();
197
+ createEngine({ callbacks, plugin });
198
+ expect(getComponentOverride('TopBar')).toBe(CustomTopBar);
199
+ expect(getComponentOverride('NonExistent')).toBeUndefined();
200
+ });
201
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { baseGameStateSchema, extendGameStateSchema } from '../../src/engine/schemas/baseGameState.schema';
3
+ import { StateValidationError } from '../../src/engine/errors/StateValidationError';
4
+ import { z } from 'zod';
5
+
6
+ /**
7
+ * Tests for the Zod validation gate that runs after event execution.
8
+ * The engine validates state after each event to prevent corruption.
9
+ */
10
+ describe('Event validation gate', () => {
11
+ function validateState(schema: z.ZodSchema, state: unknown): { valid: boolean; error?: StateValidationError } {
12
+ const result = schema.safeParse(state);
13
+ if (result.success) return { valid: true };
14
+ return { valid: false, error: new StateValidationError(result.error) };
15
+ }
16
+
17
+ it('accepts valid state after event execution', () => {
18
+ const state = {
19
+ locationId: 'cafe',
20
+ gameTime: 14,
21
+ flags: { intro_done: true },
22
+ counters: { coffee: 1 },
23
+ player: { name: 'Alice' },
24
+ };
25
+
26
+ const { valid } = validateState(baseGameStateSchema, state);
27
+ expect(valid).toBe(true);
28
+ });
29
+
30
+ it('rejects state with corrupted locationId', () => {
31
+ const state = {
32
+ locationId: '',
33
+ gameTime: 14,
34
+ flags: {},
35
+ counters: {},
36
+ player: { name: 'Alice' },
37
+ };
38
+
39
+ const { valid, error } = validateState(baseGameStateSchema, state);
40
+ expect(valid).toBe(false);
41
+ expect(error?.message).toContain('locationId');
42
+ });
43
+
44
+ it('rejects state with wrong type for flags', () => {
45
+ const state = {
46
+ locationId: 'cafe',
47
+ gameTime: 14,
48
+ flags: 'not-an-object',
49
+ counters: {},
50
+ player: { name: 'Alice' },
51
+ };
52
+
53
+ const { valid } = validateState(baseGameStateSchema, state);
54
+ expect(valid).toBe(false);
55
+ });
56
+
57
+ it('validates extended project schema', () => {
58
+ const projectSchema = extendGameStateSchema({
59
+ reputation: z.number().min(0).max(100),
60
+ inventory: z.array(z.string()),
61
+ npcRelations: z.record(z.string(), z.number().min(-100).max(100)),
62
+ });
63
+
64
+ // Simulates an event that correctly modified state
65
+ const goodState = {
66
+ locationId: 'cafe',
67
+ gameTime: 14,
68
+ flags: { intro_done: true },
69
+ counters: {},
70
+ player: { name: 'Alice' },
71
+ reputation: 50,
72
+ inventory: ['map', 'key'],
73
+ npcRelations: { barista: 10, landlord: -5 },
74
+ };
75
+ expect(validateState(projectSchema, goodState).valid).toBe(true);
76
+
77
+ // Simulates a buggy event that set reputation to 150
78
+ const badState = { ...goodState, reputation: 150 };
79
+ const result = validateState(projectSchema, badState);
80
+ expect(result.valid).toBe(false);
81
+ expect(result.error?.message).toContain('reputation');
82
+ });
83
+
84
+ it('rejects prototype pollution attempts', () => {
85
+ const state = {
86
+ locationId: 'cafe',
87
+ gameTime: 14,
88
+ flags: {},
89
+ counters: {},
90
+ player: { name: 'Alice' },
91
+ __proto__: { isAdmin: true },
92
+ };
93
+
94
+ // Zod parses with structuredClone behavior, __proto__ not a valid field
95
+ const result = baseGameStateSchema.safeParse(state);
96
+ // Should either succeed (ignoring __proto__) or fail — both are safe
97
+ // The important thing is that isAdmin is NOT in the result
98
+ if (result.success) {
99
+ expect((result.data as Record<string, unknown>)['isAdmin']).toBeUndefined();
100
+ }
101
+ });
102
+ });