@vibevibes/runtime 0.2.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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Tick Engine — server-side tick loop for experiences with netcode: "tick".
3
+ *
4
+ * Runs at a fixed tick rate, executing tool calls from the tick context.
5
+ * The tick engine provides the timing infrastructure; experiences define
6
+ * what happens each tick via their tool handlers.
7
+ */
8
+
9
+ import type { ToolCtx, ToolEvent } from "@vibevibes/sdk";
10
+ import type { EventEmitter } from "events";
11
+
12
+ // ── Interfaces ────────────────────────────────────────────────────────────────
13
+
14
+ /** ToolEvent extended with observation field (computed server-side after tool execution). */
15
+ interface TickToolEvent extends ToolEvent {
16
+ observation?: Record<string, unknown>;
17
+ }
18
+
19
+ /** Minimal Room interface — what the tick engine needs from a room. */
20
+ export interface TickRoom {
21
+ readonly id: string;
22
+ readonly experienceId: string;
23
+ readonly config: Record<string, unknown>;
24
+ sharedState: Record<string, unknown>;
25
+ participantList(): string[];
26
+ broadcastToAll(message: Record<string, unknown>): void;
27
+ broadcastStateUpdate?(extra: { changedBy: string; tick?: unknown }, forceFullState?: boolean): void;
28
+ appendEvent(event: TickToolEvent): void;
29
+ enqueueExecution<T>(fn: () => Promise<T>): Promise<T>;
30
+ }
31
+
32
+ /** Minimal experience interface — what the tick engine needs. */
33
+ export interface TickExperience {
34
+ module: {
35
+ manifest: { id: string };
36
+ tools: Array<{
37
+ name: string;
38
+ input_schema?: { parse?: (input: unknown) => unknown };
39
+ handler: (ctx: ToolCtx, input: unknown) => Promise<unknown>;
40
+ }>;
41
+ observe?: (state: Record<string, unknown>, event: TickToolEvent, actorId: string) => Record<string, unknown>;
42
+ };
43
+ }
44
+
45
+ // ── Tick Engine ───────────────────────────────────────────────────────────────
46
+
47
+ const TICK_ACTOR_ID = "_tick-engine";
48
+ const TICK_OWNER = "_system";
49
+
50
+ export class TickEngine {
51
+ private room: TickRoom;
52
+ private experience: TickExperience;
53
+ private roomEvents: EventEmitter;
54
+ private interval: ReturnType<typeof setTimeout> | null = null;
55
+ private tickCount = 0;
56
+ private tickRateMs: number;
57
+ private startedAt: number = 0;
58
+ private _running = false;
59
+ private _stopped = false;
60
+ private _stateSetThisTick = false;
61
+
62
+ constructor(
63
+ room: TickRoom,
64
+ experience: TickExperience,
65
+ roomEvents: EventEmitter,
66
+ tickRateMs: number = 50,
67
+ ) {
68
+ this.room = room;
69
+ this.experience = experience;
70
+ this.roomEvents = roomEvents;
71
+ this.tickRateMs = tickRateMs;
72
+ }
73
+
74
+ /** Start the tick loop. Uses self-correcting setTimeout to prevent timing drift. */
75
+ start(): void {
76
+ if (this.interval) return;
77
+ this._stopped = false;
78
+ this.tickCount = 0;
79
+ this.startedAt = Date.now();
80
+ this.scheduleNext();
81
+ }
82
+
83
+ /** Schedule the next tick with drift correction. */
84
+ private scheduleNext(): void {
85
+ if (this._stopped) return;
86
+ const expected = this.startedAt + (this.tickCount + 1) * this.tickRateMs;
87
+ const delay = Math.max(0, expected - Date.now());
88
+ this.interval = setTimeout(() => {
89
+ this.tick().then(() => this.scheduleNext(), () => this.scheduleNext());
90
+ }, delay);
91
+ }
92
+
93
+ /** Stop the tick loop. */
94
+ stop(): void {
95
+ this._stopped = true;
96
+ this._running = false;
97
+ if (this.interval) {
98
+ clearTimeout(this.interval);
99
+ this.interval = null;
100
+ }
101
+ }
102
+
103
+ /** Mark dirty — no-op in simplified engine, kept for API compat. */
104
+ markDirty(): void {}
105
+
106
+ /** Get current tick status. */
107
+ getStatus(): {
108
+ enabled: boolean;
109
+ tickRateMs: number;
110
+ tickCount: number;
111
+ } {
112
+ return {
113
+ enabled: this.interval !== null,
114
+ tickRateMs: this.tickRateMs,
115
+ tickCount: this.tickCount,
116
+ };
117
+ }
118
+
119
+ /** Execute one tick cycle. */
120
+ private async tick(): Promise<void> {
121
+ if (this._running) return;
122
+ this._running = true;
123
+ try {
124
+ this.tickCount++;
125
+ } finally {
126
+ this._running = false;
127
+ }
128
+ }
129
+
130
+ /** Execute a tool call internally (no HTTP round-trip). */
131
+ async executeTool(
132
+ toolName: string,
133
+ input: Record<string, unknown>,
134
+ callerActorId?: string,
135
+ expiredFlag?: { value: boolean },
136
+ ): Promise<{ output?: unknown; error?: string }> {
137
+ const tool = this.experience.module.tools.find((t) => t.name === toolName);
138
+ if (!tool) {
139
+ return { error: `Tool '${toolName}' not found` };
140
+ }
141
+
142
+ const actorId = callerActorId || TICK_ACTOR_ID;
143
+ const owner = callerActorId || TICK_OWNER;
144
+
145
+ try {
146
+ let validatedInput: Record<string, unknown> = input as Record<string, unknown>;
147
+ if (tool.input_schema?.parse) {
148
+ validatedInput = tool.input_schema.parse(input) as Record<string, unknown>;
149
+ }
150
+
151
+ const self = this;
152
+ const ctxBase = {
153
+ roomId: this.room.id,
154
+ actorId,
155
+ owner,
156
+ state: null as unknown as Record<string, unknown>,
157
+ setState: (newState: Record<string, unknown>) => {
158
+ if (expiredFlag?.value) return;
159
+ self.room.sharedState = newState;
160
+ self._stateSetThisTick = true;
161
+ },
162
+ timestamp: Date.now(),
163
+ memory: {},
164
+ setMemory: () => {},
165
+ roomConfig: this.room.config || {},
166
+ };
167
+ Object.defineProperty(ctxBase, 'state', {
168
+ get() { return self.room.sharedState; },
169
+ enumerable: true,
170
+ configurable: true,
171
+ });
172
+ const ctx = ctxBase as ToolCtx;
173
+
174
+ const output = await tool.handler(ctx, validatedInput);
175
+
176
+ const event: TickToolEvent = {
177
+ id: `tick-${this.tickCount}-${toolName}-${Math.random().toString(36).slice(2, 6)}`,
178
+ ts: Date.now(),
179
+ actorId: TICK_ACTOR_ID,
180
+ owner: TICK_OWNER,
181
+ tool: toolName,
182
+ input: validatedInput,
183
+ output,
184
+ };
185
+
186
+ if (this.experience.module.observe) {
187
+ try {
188
+ event.observation = this.experience.module.observe(this.room.sharedState, event, actorId);
189
+ } catch {
190
+ // Don't fail tick if observe throws
191
+ }
192
+ }
193
+
194
+ this.room.appendEvent(event);
195
+ this.roomEvents.emit(`room:${this.room.id}`);
196
+
197
+ return { output };
198
+ } catch (err: unknown) {
199
+ const errorMsg = err instanceof Error ? err.message : String(err);
200
+
201
+ const event: TickToolEvent = {
202
+ id: `tick-${this.tickCount}-${toolName}-${Math.random().toString(36).slice(2, 6)}`,
203
+ ts: Date.now(),
204
+ actorId: TICK_ACTOR_ID,
205
+ owner: TICK_OWNER,
206
+ tool: toolName,
207
+ input,
208
+ error: errorMsg,
209
+ };
210
+ this.room.appendEvent(event);
211
+ this.roomEvents.emit(`room:${this.room.id}`);
212
+
213
+ return { error: errorMsg };
214
+ }
215
+ }
216
+ }