@vylos/core 0.6.1 → 0.7.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.6.1",
3
+ "version": "0.7.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/DevOpsBenjamin/Vylos"
@@ -139,6 +139,7 @@ async function handleSlot(slot: number): Promise<void> {
139
139
  engineState.historyBrowsing = false;
140
140
  engineState.setDialogue(null);
141
141
  engineState.setChoices(null);
142
+ engineState.setForeground(null);
142
143
  engineState.closeMenu();
143
144
  }
144
145
  }
@@ -138,18 +138,23 @@ export class Engine {
138
138
  * Restores game state, history, event lock state, and sets up mid-event resume if needed.
139
139
  */
140
140
  loadSave(saveData: SaveSlot, setState: (state: VylosGameState) => void): void {
141
+ logger.info('Loading save...');
142
+
141
143
  // Restore game state
142
144
  setState(JSON.parse(JSON.stringify(saveData.gameState)));
145
+ logger.debug(`Restored game state (location: ${saveData.gameState.locationId})`);
143
146
 
144
147
  // Restore event lock state
145
148
  this.eventManager.resetAll();
146
149
  if (saveData.lockedEventIds) {
147
150
  this.eventManager.restoreLockedIds(saveData.lockedEventIds);
151
+ logger.debug(`Restored ${saveData.lockedEventIds.length} locked events`);
148
152
  }
149
153
 
150
154
  // Restore history
151
155
  if (saveData.history) {
152
156
  this.historyManager.restore(saveData.history, saveData.historyIndex ?? -1);
157
+ logger.debug(`Restored history (${saveData.history.length} entries, index ${saveData.historyIndex ?? -1})`);
153
158
  } else {
154
159
  this.historyManager.clear();
155
160
  }
@@ -161,12 +166,14 @@ export class Engine {
161
166
  checkpoints: saveData.checkpoints,
162
167
  initialState: saveData.initialState,
163
168
  };
169
+ logger.debug(`Pending resume: ${saveData.eventId} (${saveData.checkpoints.length} checkpoints)`);
164
170
  }
165
171
 
166
172
  // Interrupt current execution so the loop picks up the new state
167
173
  this.loadInterrupted = true;
168
174
  this.eventRunner.interrupt('load');
169
175
  this.navigationManager.cancel();
176
+ logger.info('Save loaded');
170
177
  }
171
178
 
172
179
  /** Execute a single event, handling jumps */
@@ -183,18 +190,18 @@ export class Engine {
183
190
  // Skip lock/push if interrupted by a load
184
191
  if (this.loadInterrupted) return;
185
192
 
186
- // Event completed successfully
193
+ // Event completed — check locked() to decide fate
187
194
  const state = getState();
188
- this.eventManager.setLocked(currentEvent.id, state);
195
+ this.finishEvent(currentEvent, state);
189
196
  this.historyManager.push(currentEvent.id, this.eventRunner.checkpoints.getAll());
190
197
  currentEvent = undefined;
191
198
  } catch (error) {
192
199
  if (this.loadInterrupted) return;
193
200
 
194
201
  if (error instanceof JumpSignal) {
195
- // Lock current event, find jump target
202
+ // Finish current event, find jump target
196
203
  const state = getState();
197
- this.eventManager.setLocked(currentEvent.id, state);
204
+ this.finishEvent(currentEvent, state);
198
205
  this.historyManager.push(currentEvent.id, this.eventRunner.checkpoints.getAll());
199
206
 
200
207
  const target = this.eventManager.get(error.targetEventId);
@@ -234,14 +241,14 @@ export class Engine {
234
241
  if (this.loadInterrupted) return;
235
242
 
236
243
  const state = getState();
237
- this.eventManager.setLocked(resume.eventId, state);
244
+ this.finishEvent(event, state);
238
245
  this.historyManager.push(resume.eventId, this.eventRunner.checkpoints.getAll());
239
246
  } catch (error) {
240
247
  if (this.loadInterrupted) return;
241
248
 
242
249
  if (error instanceof JumpSignal) {
243
250
  const state = getState();
244
- this.eventManager.setLocked(resume.eventId, state);
251
+ this.finishEvent(event, state);
245
252
  this.historyManager.push(resume.eventId, this.eventRunner.checkpoints.getAll());
246
253
 
247
254
  const target = this.eventManager.get(error.targetEventId);
@@ -255,6 +262,15 @@ export class Engine {
255
262
  }
256
263
  }
257
264
 
265
+ /** After event execution: lock permanently or return to Ready */
266
+ private finishEvent(event: VylosEvent, state: VylosGameState): void {
267
+ if (event.locked?.(state) === true) {
268
+ this.eventManager.setLocked(event.id);
269
+ } else {
270
+ this.eventManager.setReady(event.id);
271
+ }
272
+ }
273
+
258
274
  private handleBack(): void {
259
275
  const entry = this.historyManager.goBack();
260
276
  if (entry) {
@@ -10,11 +10,11 @@ interface EventEntry {
10
10
  /**
11
11
  * Manages event lifecycle: registration, condition evaluation, status transitions.
12
12
  *
13
- * Event lifecycle: NotReady → Unlocked → Running → Locked
14
- * - NotReady: conditions not yet met
15
- * - Unlocked: conditions met, ready to execute
13
+ * Event lifecycle: NotReady → Ready → Running → Ready (or Locked)
14
+ * - NotReady: unlocked() gate not yet passed
15
+ * - Ready: available — conditions() checked each tick
16
16
  * - Running: currently executing
17
- * - Locked: completed (won't trigger again)
17
+ * - Locked: permanently done (locked() returned true)
18
18
  */
19
19
  export class EventManager {
20
20
  private events = new Map<string, EventEntry>();
@@ -45,46 +45,44 @@ export class EventManager {
45
45
  return this.events.get(id)?.status;
46
46
  }
47
47
 
48
- /** Evaluate conditions and update statuses. Returns newly unlocked events. */
48
+ /** Evaluate unlocked() gate for NotReady events. Returns newly ready events. */
49
49
  evaluate(state: VylosGameState): VylosEvent[] {
50
- const unlocked: VylosEvent[] = [];
50
+ const newlyReady: VylosEvent[] = [];
51
51
 
52
52
  for (const [id, entry] of this.events) {
53
53
  if (entry.status !== EventStatus.NotReady) continue;
54
54
 
55
- // Skip events bound to a different location
56
- if (entry.event.locationId && entry.event.locationId !== state.locationId) continue;
55
+ // unlocked() gate: if not defined or returns !== false → Ready
56
+ if (entry.event.unlocked && entry.event.unlocked(state) === false) continue;
57
57
 
58
- const conditionsMet = !entry.event.conditions || entry.event.conditions(state);
59
- if (conditionsMet) {
60
- entry.status = EventStatus.Unlocked;
61
- entry.event.unlocked?.(state);
62
- unlocked.push(entry.event);
63
- logger.debug(`Event unlocked: ${id}`);
64
- }
58
+ entry.status = EventStatus.Ready;
59
+ newlyReady.push(entry.event);
60
+ logger.debug(`Event ready: ${id}`);
65
61
  }
66
62
 
67
- return unlocked;
63
+ return newlyReady;
68
64
  }
69
65
 
70
- /** Get the next unlocked event matching the current location (first registered wins). Skips drawable events. */
66
+ /** Get the next ready event matching the current location whose conditions are met. Skips drawable events. */
71
67
  getNextUnlocked(state: VylosGameState): VylosEvent | undefined {
72
68
  for (const entry of this.events.values()) {
73
- if (entry.status !== EventStatus.Unlocked) continue;
69
+ if (entry.status !== EventStatus.Ready) continue;
74
70
  if (entry.event.draw) continue; // Drawable events don't auto-trigger
75
71
  if (entry.event.locationId && entry.event.locationId !== state.locationId) continue;
72
+ if (entry.event.conditions && !entry.event.conditions(state)) continue;
76
73
  return entry.event;
77
74
  }
78
75
  return undefined;
79
76
  }
80
77
 
81
- /** Get all unlocked drawable events at the current location */
78
+ /** Get all ready drawable events at the current location whose conditions are met */
82
79
  getDrawableEvents(state: VylosGameState, resolveText?: (text: string | Record<string, string>) => string): DrawableEventEntry[] {
83
80
  const result: DrawableEventEntry[] = [];
84
81
  for (const entry of this.events.values()) {
85
- if (entry.status !== EventStatus.Unlocked) continue;
82
+ if (entry.status !== EventStatus.Ready) continue;
86
83
  if (!entry.event.draw) continue;
87
84
  if (entry.event.locationId && entry.event.locationId !== state.locationId) continue;
85
+ if (entry.event.conditions && !entry.event.conditions(state)) continue;
88
86
  const draw = entry.event.draw;
89
87
  const label = typeof draw.label === 'string'
90
88
  ? draw.label
@@ -107,16 +105,23 @@ export class EventManager {
107
105
  }
108
106
  }
109
107
 
110
- /** Mark event as locked (completed) */
111
- setLocked(id: string, state: VylosGameState): void {
108
+ /** Mark event as locked (permanently done) */
109
+ setLocked(id: string): void {
112
110
  const entry = this.events.get(id);
113
111
  if (entry) {
114
112
  entry.status = EventStatus.Locked;
115
- entry.event.locked?.(state);
116
113
  logger.debug(`Event locked: ${id}`);
117
114
  }
118
115
  }
119
116
 
117
+ /** Mark event as ready (skip unlocked() gate) */
118
+ setReady(id: string): void {
119
+ const entry = this.events.get(id);
120
+ if (entry) {
121
+ entry.status = EventStatus.Ready;
122
+ }
123
+ }
124
+
120
125
  /** Reset an event back to NotReady */
121
126
  reset(id: string): void {
122
127
  const entry = this.events.get(id);
@@ -109,11 +109,11 @@ export interface VylosEvent<TState extends VylosGameState = VylosGameState> {
109
109
  /** Whether this event should trigger — checked each game loop tick */
110
110
  conditions?(state: TState): boolean;
111
111
 
112
- /** Called when event transitions from NotReady Unlocked */
113
- unlocked?(state: TState): void;
112
+ /** Gate from NotReady Ready. Return false to stay NotReady, anything else = Ready. */
113
+ unlocked?(state: TState): boolean;
114
114
 
115
- /** Called when event completes (Unlocked Locked) */
116
- locked?(state: TState): void;
115
+ /** Called after execution. Return true to permanently lock, anything else = stays Ready. */
116
+ locked?(state: TState): boolean;
117
117
 
118
118
  /** The event's narrative logic */
119
119
  execute(engine: VylosAPI, state: TState): Promise<void>;
@@ -121,12 +121,12 @@ export interface VylosEvent<TState extends VylosGameState = VylosGameState> {
121
121
 
122
122
  /** Event lifecycle status */
123
123
  export enum EventStatus {
124
- /** Conditions not yet met */
124
+ /** unlocked() gate not yet passed */
125
125
  NotReady = 'not_ready',
126
- /** Conditions met, ready to execute */
127
- Unlocked = 'unlocked',
126
+ /** Ready to execute when conditions met */
127
+ Ready = 'ready',
128
128
  /** Currently executing */
129
129
  Running = 'running',
130
- /** Completed */
130
+ /** Permanently locked (locked() returned true) */
131
131
  Locked = 'locked',
132
132
  }
@@ -43,68 +43,121 @@ describe('EventManager', () => {
43
43
  expect(em.getStatus('ev1')).toBe(EventStatus.NotReady);
44
44
  });
45
45
 
46
- describe('evaluate()', () => {
47
- it('unlocks events with no conditions', () => {
46
+ describe('evaluate() — unlocked() gate', () => {
47
+ it('moves events with no unlocked() to Ready', () => {
48
48
  em.register(makeEvent('ev1'));
49
- const unlocked = em.evaluate(makeState());
50
- expect(unlocked).toHaveLength(1);
51
- expect(unlocked[0].id).toBe('ev1');
52
- expect(em.getStatus('ev1')).toBe(EventStatus.Unlocked);
49
+ const ready = em.evaluate(makeState());
50
+ expect(ready).toHaveLength(1);
51
+ expect(ready[0].id).toBe('ev1');
52
+ expect(em.getStatus('ev1')).toBe(EventStatus.Ready);
53
53
  });
54
54
 
55
- it('unlocks events when conditions are met', () => {
55
+ it('moves events when unlocked() returns true', () => {
56
56
  em.register(makeEvent('ev1', {
57
- conditions: (state) => state.flags['intro_done'] === true,
57
+ unlocked: () => true,
58
+ }));
59
+ const ready = em.evaluate(makeState());
60
+ expect(ready).toHaveLength(1);
61
+ expect(em.getStatus('ev1')).toBe(EventStatus.Ready);
62
+ });
63
+
64
+ it('keeps events NotReady when unlocked() returns false', () => {
65
+ em.register(makeEvent('ev1', {
66
+ unlocked: (state) => state.flags['intro_done'] === true,
58
67
  }));
59
68
 
60
69
  expect(em.evaluate(makeState())).toHaveLength(0);
61
70
  expect(em.getStatus('ev1')).toBe(EventStatus.NotReady);
62
71
 
63
- const unlocked = em.evaluate(makeState({ flags: { intro_done: true } }));
64
- expect(unlocked).toHaveLength(1);
65
- });
66
-
67
- it('calls unlocked() callback when transitioning', () => {
68
- const unlockedFn = vi.fn();
69
- em.register(makeEvent('ev1', { unlocked: unlockedFn }));
70
- em.evaluate(makeState());
71
- expect(unlockedFn).toHaveBeenCalledOnce();
72
+ const ready = em.evaluate(makeState({ flags: { intro_done: true } }));
73
+ expect(ready).toHaveLength(1);
74
+ expect(em.getStatus('ev1')).toBe(EventStatus.Ready);
72
75
  });
73
76
 
74
- it('does not re-evaluate already unlocked events', () => {
77
+ it('does not re-evaluate already Ready events', () => {
75
78
  em.register(makeEvent('ev1'));
76
79
  em.evaluate(makeState());
77
80
  const second = em.evaluate(makeState());
78
81
  expect(second).toHaveLength(0);
79
82
  });
83
+
84
+ it('does not check conditions — only unlocked() gate', () => {
85
+ em.register(makeEvent('ev1', {
86
+ conditions: () => false,
87
+ }));
88
+ // evaluate() ignores conditions — event goes Ready anyway
89
+ const ready = em.evaluate(makeState());
90
+ expect(ready).toHaveLength(1);
91
+ expect(em.getStatus('ev1')).toBe(EventStatus.Ready);
92
+ });
80
93
  });
81
94
 
82
- it('getNextUnlocked returns first unlocked event', () => {
83
- em.register(makeEvent('ev1', {
84
- conditions: () => false,
85
- }));
86
- em.register(makeEvent('ev2'));
87
- em.evaluate(makeState());
95
+ describe('getNextUnlocked() conditions checked at query time', () => {
96
+ it('returns first Ready event with no conditions', () => {
97
+ em.register(makeEvent('ev1'));
98
+ em.evaluate(makeState());
99
+ const next = em.getNextUnlocked(makeState());
100
+ expect(next?.id).toBe('ev1');
101
+ });
102
+
103
+ it('skips Ready event when conditions return false', () => {
104
+ em.register(makeEvent('ev1', {
105
+ conditions: () => false,
106
+ }));
107
+ em.register(makeEvent('ev2'));
108
+ em.evaluate(makeState());
109
+
110
+ const next = em.getNextUnlocked(makeState());
111
+ expect(next?.id).toBe('ev2');
112
+ });
113
+
114
+ it('returns Ready event when conditions return true', () => {
115
+ em.register(makeEvent('ev1', {
116
+ conditions: (state) => state.flags['go'] === true,
117
+ }));
118
+ em.evaluate(makeState());
88
119
 
89
- const next = em.getNextUnlocked(makeState());
90
- expect(next?.id).toBe('ev2');
120
+ expect(em.getNextUnlocked(makeState())).toBeUndefined();
121
+ expect(em.getNextUnlocked(makeState({ flags: { go: true } }))?.id).toBe('ev1');
122
+ });
91
123
  });
92
124
 
93
- it('lifecycle: NotReady → Unlocked → Running → Locked', () => {
94
- const lockedFn = vi.fn();
95
- em.register(makeEvent('ev1', { locked: lockedFn }));
125
+ describe('lifecycle: NotReady → Ready → Running → Ready/Locked', () => {
126
+ it('repeatable event: Ready → Running → Ready', () => {
127
+ em.register(makeEvent('ev1'));
96
128
 
97
- expect(em.getStatus('ev1')).toBe(EventStatus.NotReady);
129
+ expect(em.getStatus('ev1')).toBe(EventStatus.NotReady);
98
130
 
99
- em.evaluate(makeState());
100
- expect(em.getStatus('ev1')).toBe(EventStatus.Unlocked);
131
+ em.evaluate(makeState());
132
+ expect(em.getStatus('ev1')).toBe(EventStatus.Ready);
101
133
 
102
- em.setRunning('ev1');
103
- expect(em.getStatus('ev1')).toBe(EventStatus.Running);
134
+ em.setRunning('ev1');
135
+ expect(em.getStatus('ev1')).toBe(EventStatus.Running);
104
136
 
105
- em.setLocked('ev1', makeState());
106
- expect(em.getStatus('ev1')).toBe(EventStatus.Locked);
107
- expect(lockedFn).toHaveBeenCalledOnce();
137
+ em.setReady('ev1');
138
+ expect(em.getStatus('ev1')).toBe(EventStatus.Ready);
139
+ });
140
+
141
+ it('one-shot event: Ready → Running → Locked', () => {
142
+ em.register(makeEvent('ev1'));
143
+
144
+ em.evaluate(makeState());
145
+ em.setRunning('ev1');
146
+ em.setLocked('ev1');
147
+ expect(em.getStatus('ev1')).toBe(EventStatus.Locked);
148
+ });
149
+
150
+ it('setLocked is a pure status setter (no callback)', () => {
151
+ const lockedFn = vi.fn();
152
+ em.register(makeEvent('ev1', { locked: lockedFn }));
153
+
154
+ em.evaluate(makeState());
155
+ em.setRunning('ev1');
156
+ em.setLocked('ev1');
157
+ expect(em.getStatus('ev1')).toBe(EventStatus.Locked);
158
+ // locked() is NOT called by setLocked — Engine calls it explicitly
159
+ expect(lockedFn).not.toHaveBeenCalled();
160
+ });
108
161
  });
109
162
 
110
163
  it('getByLocation filters events', () => {
@@ -123,8 +176,8 @@ describe('EventManager', () => {
123
176
  em.register(makeEvent('ev3'));
124
177
 
125
178
  em.evaluate(makeState());
126
- em.setLocked('ev1', makeState());
127
- em.setLocked('ev3', makeState());
179
+ em.setLocked('ev1');
180
+ em.setLocked('ev3');
128
181
 
129
182
  const ids = em.getLockedIds();
130
183
  expect(ids).toEqual(['ev1', 'ev3']);
@@ -139,8 +192,21 @@ describe('EventManager', () => {
139
192
  it('reset puts event back to NotReady', () => {
140
193
  em.register(makeEvent('ev1'));
141
194
  em.evaluate(makeState());
142
- expect(em.getStatus('ev1')).toBe(EventStatus.Unlocked);
195
+ expect(em.getStatus('ev1')).toBe(EventStatus.Ready);
143
196
  em.reset('ev1');
144
197
  expect(em.getStatus('ev1')).toBe(EventStatus.NotReady);
145
198
  });
199
+
200
+ it('setReady puts event directly to Ready (skips gate)', () => {
201
+ em.register(makeEvent('ev1', {
202
+ unlocked: () => false,
203
+ }));
204
+ // Gate blocks it
205
+ em.evaluate(makeState());
206
+ expect(em.getStatus('ev1')).toBe(EventStatus.NotReady);
207
+
208
+ // setReady bypasses gate
209
+ em.setReady('ev1');
210
+ expect(em.getStatus('ev1')).toBe(EventStatus.Ready);
211
+ });
146
212
  });
@@ -69,6 +69,7 @@ describe('Game loop integration', () => {
69
69
 
70
70
  const event: VylosEvent = {
71
71
  id: 'intro',
72
+ locked: () => true,
72
73
  async execute(api: VylosAPI) {
73
74
  await api.say('Hello');
74
75
  await api.say('World');
@@ -130,6 +131,7 @@ describe('Game loop integration', () => {
130
131
  const events: VylosEvent[] = [
131
132
  {
132
133
  id: 'first',
134
+ locked: () => true,
133
135
  async execute(api: VylosAPI) {
134
136
  await api.say('In first event');
135
137
  api.jump('second');
@@ -137,6 +139,7 @@ describe('Game loop integration', () => {
137
139
  },
138
140
  {
139
141
  id: 'second',
142
+ locked: () => true,
140
143
  async execute(api: VylosAPI) {
141
144
  await api.say('In second event');
142
145
  },