@aghents/pulse 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.
- package/dist/index.d.ts +313 -0
- package/dist/index.js +610 -0
- package/package.json +35 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { Result } from 'neverthrow';
|
|
2
|
+
import { Database } from '@aghents/identity';
|
|
3
|
+
import { SupabaseClient } from '@supabase/supabase-js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
type SeatKind = "human" | "persona";
|
|
7
|
+
type ActorMeta = {
|
|
8
|
+
actorKind: "human";
|
|
9
|
+
} | {
|
|
10
|
+
actorKind: "system";
|
|
11
|
+
} | {
|
|
12
|
+
actorKind: "persona";
|
|
13
|
+
personaId: string;
|
|
14
|
+
tier: "heuristic" | "llm";
|
|
15
|
+
model: string;
|
|
16
|
+
};
|
|
17
|
+
interface PulseGame<State, Event, View> {
|
|
18
|
+
key: string;
|
|
19
|
+
create(input: {
|
|
20
|
+
gameId: string;
|
|
21
|
+
seed: string;
|
|
22
|
+
config: unknown;
|
|
23
|
+
seats: {
|
|
24
|
+
seatId: string;
|
|
25
|
+
kind: SeatKind;
|
|
26
|
+
}[];
|
|
27
|
+
}): State;
|
|
28
|
+
advance(state: State, event: Event): {
|
|
29
|
+
ok: true;
|
|
30
|
+
state: State;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
error: {
|
|
34
|
+
code: string;
|
|
35
|
+
message: string;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
view(state: State, seatId: string): View;
|
|
39
|
+
clockEvents: {
|
|
40
|
+
lock: Event;
|
|
41
|
+
resolve: Event;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Whether `advance` would treat this event as a clock transition. Games that dispatch on a
|
|
45
|
+
* field (Vouch on `type`) must implement it: `{ type: "lock", extra: true }` is not deep-equal
|
|
46
|
+
* to `clockEvents.lock` yet runs the clock. Absent, submit falls back to deep equality.
|
|
47
|
+
*/
|
|
48
|
+
isClockEvent?(event: Event): boolean;
|
|
49
|
+
phaseOf(state: State): "open" | "lock" | "complete";
|
|
50
|
+
pulseOf(state: State): number;
|
|
51
|
+
seqOf(state: State): number;
|
|
52
|
+
}
|
|
53
|
+
declare function definePulseGame<S, E, V>(g: PulseGame<S, E, V>): PulseGame<S, E, V>;
|
|
54
|
+
|
|
55
|
+
type Timescale = "quick" | "daily";
|
|
56
|
+
/**
|
|
57
|
+
* Pulse windows: a pulse is one open window followed by one lock window. Every window must be
|
|
58
|
+
* >= the scheduler cadence (Vercel Cron, once a minute) plus the llm worst case (gateway
|
|
59
|
+
* MIN_WINDOW_MS = 100s): the clock is only advanced by a tick, a persona is due no later than
|
|
60
|
+
* windowEnd - (cadence [+ llm worst case]), and a window shorter than that could pass with no
|
|
61
|
+
* tick able to act inside it. gateway's scheduler.test pins every timescale against it.
|
|
62
|
+
*/
|
|
63
|
+
declare const SCHEDULER_CADENCE_MS = 60000;
|
|
64
|
+
declare const TIMESCALES: Record<Timescale, {
|
|
65
|
+
openMs: number;
|
|
66
|
+
lockMs: number;
|
|
67
|
+
}>;
|
|
68
|
+
declare function pulseTimes(startedAtMs: number, timescale: Timescale, pulse: number): {
|
|
69
|
+
openAtMs: number;
|
|
70
|
+
lockAtMs: number;
|
|
71
|
+
resolveAtMs: number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type PulseError = {
|
|
75
|
+
code: string;
|
|
76
|
+
message: string;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* `conflict`: a (gameId, seq) collision, the loser of a race. `llm_budget_exceeded`: the game has
|
|
80
|
+
* spent its LLM_CALL_BUDGET llm calls (the scheduler skips the persona). `room_full`: the room's
|
|
81
|
+
* insert trigger (0002) refused the member for capacity, the loser of a join race. `room_closed`:
|
|
82
|
+
* the same trigger's status path only (0005): the room is no longer open. Everything else is
|
|
83
|
+
* `store`.
|
|
84
|
+
*/
|
|
85
|
+
type StoreError = {
|
|
86
|
+
code: "conflict" | "llm_budget_exceeded" | "room_full" | "room_closed" | "store";
|
|
87
|
+
message: string;
|
|
88
|
+
};
|
|
89
|
+
/** Mirrors the cap inside pulse.spend_llm_call (0002_pulse.sql). */
|
|
90
|
+
declare const LLM_CALL_BUDGET = 200;
|
|
91
|
+
type RoomSettings = {
|
|
92
|
+
seats: number;
|
|
93
|
+
timescale: Timescale;
|
|
94
|
+
};
|
|
95
|
+
type RoomStatus = "open" | "playing" | "complete";
|
|
96
|
+
interface RoomRecord {
|
|
97
|
+
roomId: string;
|
|
98
|
+
gameKey: string;
|
|
99
|
+
inviteCode: string;
|
|
100
|
+
hostUserId: string;
|
|
101
|
+
settings: RoomSettings;
|
|
102
|
+
status: RoomStatus;
|
|
103
|
+
}
|
|
104
|
+
interface RoomMember {
|
|
105
|
+
roomId: string;
|
|
106
|
+
userId: string;
|
|
107
|
+
displayName: string;
|
|
108
|
+
}
|
|
109
|
+
interface GameSeatRecord {
|
|
110
|
+
seatId: string;
|
|
111
|
+
kind: SeatKind;
|
|
112
|
+
userId: string | null;
|
|
113
|
+
personaProfileId: string | null;
|
|
114
|
+
personaTier: "heuristic" | "llm" | null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* SERVER-SIDE ONLY: holds seat secrets (kind, persona, user mapping). Clients only ever get a
|
|
118
|
+
* View through getView. `seats` come back in canonical order (lexicographic by seatId): games
|
|
119
|
+
* assign roles by folding seats in that order, so any other order would silently reassign them.
|
|
120
|
+
*/
|
|
121
|
+
interface GameRecord {
|
|
122
|
+
gameId: string;
|
|
123
|
+
roomId: string;
|
|
124
|
+
gameKey: string;
|
|
125
|
+
seed: string;
|
|
126
|
+
config: unknown;
|
|
127
|
+
timescale: Timescale;
|
|
128
|
+
startedAtMs: number;
|
|
129
|
+
status: GameStatus;
|
|
130
|
+
seats: GameSeatRecord[];
|
|
131
|
+
}
|
|
132
|
+
type GameStatus = "playing" | "complete";
|
|
133
|
+
interface StoredEvent<E> {
|
|
134
|
+
seq: number;
|
|
135
|
+
event: E;
|
|
136
|
+
actor: ActorMeta;
|
|
137
|
+
}
|
|
138
|
+
interface GameStore<E> {
|
|
139
|
+
createRoom(room: RoomRecord): Promise<Result<void, StoreError>>;
|
|
140
|
+
/** Compensation only (rooms.createRoom undoes a room whose host could not be seated). */
|
|
141
|
+
deleteRoom(roomId: string): Promise<Result<void, StoreError>>;
|
|
142
|
+
getRoom(roomId: string): Promise<Result<RoomRecord | null, StoreError>>;
|
|
143
|
+
getRoomByCode(inviteCode: string): Promise<Result<RoomRecord | null, StoreError>>;
|
|
144
|
+
setRoomStatus(roomId: string, status: RoomStatus): Promise<Result<void, StoreError>>;
|
|
145
|
+
addMember(member: RoomMember): Promise<Result<void, StoreError>>;
|
|
146
|
+
listMembers(roomId: string): Promise<Result<RoomMember[], StoreError>>;
|
|
147
|
+
createGame(game: GameRecord): Promise<Result<void, StoreError>>;
|
|
148
|
+
getGame(gameId: string): Promise<Result<GameRecord | null, StoreError>>;
|
|
149
|
+
getLatestGameByRoom(roomId: string): Promise<Result<GameRecord | null, StoreError>>;
|
|
150
|
+
setGameStatus(gameId: string, status: GameStatus): Promise<Result<void, StoreError>>;
|
|
151
|
+
listEvents(gameId: string): Promise<Result<StoredEvent<E>[], StoreError>>;
|
|
152
|
+
/** err `conflict` when (gameId, seq) already exists (the optimistic-concurrency signal). */
|
|
153
|
+
appendEvent(gameId: string, stored: StoredEvent<E>): Promise<Result<void, StoreError>>;
|
|
154
|
+
/**
|
|
155
|
+
* Spend one of the game's LLM_CALL_BUDGET llm calls, atomically; ok(calls so far), or err
|
|
156
|
+
* `llm_budget_exceeded` with the counter untouched. The scheduler spends before each llm-tier
|
|
157
|
+
* decision, so the budget counts calls made, not events produced.
|
|
158
|
+
*/
|
|
159
|
+
spendLlmCall(gameId: string): Promise<Result<number, StoreError>>;
|
|
160
|
+
/**
|
|
161
|
+
* Take the game's tick lease for ttlMs: ok(true) when taken, ok(false) when another pass holds
|
|
162
|
+
* an unexpired one. An expired lease is reacquirable, so a pass that died never wedges a game.
|
|
163
|
+
*/
|
|
164
|
+
acquireTickLease(gameId: string, ttlMs: number): Promise<Result<boolean, StoreError>>;
|
|
165
|
+
releaseTickLease(gameId: string): Promise<Result<void, StoreError>>;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
declare function foldState<S, E, V>(game: PulseGame<S, E, V>, record: GameRecord, events: StoredEvent<E>[]): Result<S, PulseError>;
|
|
169
|
+
/**
|
|
170
|
+
* Advance every due phase transition for a game up to nowMs. Lazy scheduler: callers invoke this
|
|
171
|
+
* before any read or write; there is no cron. Clock events are appended one at a time through the
|
|
172
|
+
* store's optimistic concurrency; a conflict means another caller already appended that
|
|
173
|
+
* transition, so the log is re-read and the loop continues from where they left it.
|
|
174
|
+
*/
|
|
175
|
+
declare function catchUp<S, E, V>(game: PulseGame<S, E, V>, store: GameStore<E>, gameId: string, nowMs: number): Promise<Result<S | null, PulseError>>;
|
|
176
|
+
|
|
177
|
+
declare function submit<S, E, V>(game: PulseGame<S, E, V>, store: GameStore<E>, input: {
|
|
178
|
+
gameId: string;
|
|
179
|
+
seatId: string;
|
|
180
|
+
event: E;
|
|
181
|
+
nowMs: number;
|
|
182
|
+
actor: ActorMeta;
|
|
183
|
+
}): Promise<Result<V, PulseError>>;
|
|
184
|
+
type ViewResult<V> = {
|
|
185
|
+
view: V;
|
|
186
|
+
deadline: {
|
|
187
|
+
phase: "open" | "lock";
|
|
188
|
+
atMs: number;
|
|
189
|
+
} | null;
|
|
190
|
+
};
|
|
191
|
+
declare function getView<S, E, V>(game: PulseGame<S, E, V>, store: GameStore<E>, input: {
|
|
192
|
+
gameId: string;
|
|
193
|
+
seatId: string;
|
|
194
|
+
nowMs: number;
|
|
195
|
+
}): Promise<Result<ViewResult<V>, PulseError>>;
|
|
196
|
+
/**
|
|
197
|
+
* The view of one event snapshot, for a caller that has already caught the clock up and holds
|
|
198
|
+
* the log it wants to derive other facts from (the hub's view route reads `lastActor` off the
|
|
199
|
+
* same array, so view and actor can never disagree).
|
|
200
|
+
*/
|
|
201
|
+
declare function viewFromEvents<S, E, V>(game: PulseGame<S, E, V>, record: GameRecord, events: StoredEvent<E>[], seatId: string): Result<ViewResult<V>, PulseError>;
|
|
202
|
+
|
|
203
|
+
declare function createRng(seed: string): () => number;
|
|
204
|
+
|
|
205
|
+
declare function inviteCode(rng: () => number, length?: number): string;
|
|
206
|
+
declare function createRoom<E>(store: GameStore<E>, input: {
|
|
207
|
+
roomId: string;
|
|
208
|
+
gameKey: string;
|
|
209
|
+
hostUserId: string;
|
|
210
|
+
hostDisplayName: string;
|
|
211
|
+
settings: RoomSettings;
|
|
212
|
+
rng: () => number;
|
|
213
|
+
}): Promise<Result<RoomRecord, PulseError>>;
|
|
214
|
+
declare function joinRoom<E>(store: GameStore<E>, input: {
|
|
215
|
+
inviteCode: string;
|
|
216
|
+
userId: string;
|
|
217
|
+
displayName: string;
|
|
218
|
+
}): Promise<Result<RoomRecord, PulseError>>;
|
|
219
|
+
|
|
220
|
+
declare class MemoryStore<E> implements GameStore<E> {
|
|
221
|
+
private readonly nowMs;
|
|
222
|
+
/** The lease clock; injected because the store never reads the wall clock on its own. */
|
|
223
|
+
constructor(nowMs?: () => number);
|
|
224
|
+
private leases;
|
|
225
|
+
private rooms;
|
|
226
|
+
private members;
|
|
227
|
+
private games;
|
|
228
|
+
private gameOrder;
|
|
229
|
+
private events;
|
|
230
|
+
private llmCalls;
|
|
231
|
+
createRoom(room: RoomRecord): Promise<Result<void, StoreError>>;
|
|
232
|
+
deleteRoom(roomId: string): Promise<Result<void, StoreError>>;
|
|
233
|
+
getRoom(roomId: string): Promise<Result<RoomRecord | null, StoreError>>;
|
|
234
|
+
getRoomByCode(inviteCode: string): Promise<Result<RoomRecord | null, StoreError>>;
|
|
235
|
+
setRoomStatus(roomId: string, status: RoomStatus): Promise<Result<void, StoreError>>;
|
|
236
|
+
addMember(member: RoomMember): Promise<Result<void, StoreError>>;
|
|
237
|
+
listMembers(roomId: string): Promise<Result<RoomMember[], StoreError>>;
|
|
238
|
+
createGame(game: GameRecord): Promise<Result<void, StoreError>>;
|
|
239
|
+
getGame(gameId: string): Promise<Result<GameRecord | null, StoreError>>;
|
|
240
|
+
setGameStatus(gameId: string, status: GameStatus): Promise<Result<void, StoreError>>;
|
|
241
|
+
getLatestGameByRoom(roomId: string): Promise<Result<GameRecord | null, StoreError>>;
|
|
242
|
+
listEvents(gameId: string): Promise<Result<StoredEvent<E>[], StoreError>>;
|
|
243
|
+
appendEvent(gameId: string, stored: StoredEvent<E>): Promise<Result<void, StoreError>>;
|
|
244
|
+
acquireTickLease(gameId: string, ttlMs: number): Promise<Result<boolean, StoreError>>;
|
|
245
|
+
releaseTickLease(gameId: string): Promise<Result<void, StoreError>>;
|
|
246
|
+
spendLlmCall(gameId: string): Promise<Result<number, StoreError>>;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The ONLY module in @aghents/pulse that touches the network. Maps the GameStore contract onto the
|
|
251
|
+
* pulse.* tables of 0002_pulse.sql through a service-role client; clients never reach these tables.
|
|
252
|
+
*/
|
|
253
|
+
|
|
254
|
+
declare const envSchema: z.ZodObject<{
|
|
255
|
+
SUPABASE_URL: z.ZodString;
|
|
256
|
+
SUPABASE_SERVICE_ROLE_KEY: z.ZodString;
|
|
257
|
+
}, "strip", z.ZodTypeAny, {
|
|
258
|
+
SUPABASE_URL: string;
|
|
259
|
+
SUPABASE_SERVICE_ROLE_KEY: string;
|
|
260
|
+
}, {
|
|
261
|
+
SUPABASE_URL: string;
|
|
262
|
+
SUPABASE_SERVICE_ROLE_KEY: string;
|
|
263
|
+
}>;
|
|
264
|
+
type ServiceEnv = z.infer<typeof envSchema>;
|
|
265
|
+
declare function readServiceEnv(raw: Record<string, string | undefined>): ServiceEnv;
|
|
266
|
+
type Json = Database["pulse"]["Tables"]["game_events"]["Row"]["event"];
|
|
267
|
+
type AppendEventArgs = {
|
|
268
|
+
p_game_id: string;
|
|
269
|
+
p_seq: number;
|
|
270
|
+
p_event: Json;
|
|
271
|
+
p_actor_kind: string;
|
|
272
|
+
p_persona_id: string | null;
|
|
273
|
+
p_tier: string | null;
|
|
274
|
+
p_model: string | null;
|
|
275
|
+
};
|
|
276
|
+
type PulseDatabase = Omit<Database, "pulse"> & {
|
|
277
|
+
pulse: Omit<Database["pulse"], "Functions"> & {
|
|
278
|
+
Functions: Omit<Database["pulse"]["Functions"], "append_event"> & {
|
|
279
|
+
append_event: {
|
|
280
|
+
Args: AppendEventArgs;
|
|
281
|
+
Returns: undefined;
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
type PulseServiceClient = SupabaseClient<PulseDatabase>;
|
|
287
|
+
declare function createServiceClient(env: ServiceEnv): PulseServiceClient;
|
|
288
|
+
declare class SupabaseStore<E> implements GameStore<E> {
|
|
289
|
+
private readonly db;
|
|
290
|
+
private readonly pageSize;
|
|
291
|
+
constructor(db: PulseServiceClient, pageSize?: number);
|
|
292
|
+
private get pulse();
|
|
293
|
+
createRoom(room: RoomRecord): Promise<Result<void, StoreError>>;
|
|
294
|
+
deleteRoom(roomId: string): Promise<Result<void, StoreError>>;
|
|
295
|
+
private roomWhere;
|
|
296
|
+
getRoom(roomId: string): Promise<Result<RoomRecord | null, StoreError>>;
|
|
297
|
+
getRoomByCode(inviteCode: string): Promise<Result<RoomRecord | null, StoreError>>;
|
|
298
|
+
setRoomStatus(roomId: string, status: RoomStatus): Promise<Result<void, StoreError>>;
|
|
299
|
+
addMember(member: RoomMember): Promise<Result<void, StoreError>>;
|
|
300
|
+
listMembers(roomId: string): Promise<Result<RoomMember[], StoreError>>;
|
|
301
|
+
createGame(game: GameRecord): Promise<Result<void, StoreError>>;
|
|
302
|
+
private gameFromRow;
|
|
303
|
+
getGame(gameId: string): Promise<Result<GameRecord | null, StoreError>>;
|
|
304
|
+
setGameStatus(gameId: string, status: GameStatus): Promise<Result<void, StoreError>>;
|
|
305
|
+
getLatestGameByRoom(roomId: string): Promise<Result<GameRecord | null, StoreError>>;
|
|
306
|
+
listEvents(gameId: string): Promise<Result<StoredEvent<E>[], StoreError>>;
|
|
307
|
+
appendEvent(gameId: string, stored: StoredEvent<E>): Promise<Result<void, StoreError>>;
|
|
308
|
+
acquireTickLease(gameId: string, ttlMs: number): Promise<Result<boolean, StoreError>>;
|
|
309
|
+
releaseTickLease(gameId: string): Promise<Result<void, StoreError>>;
|
|
310
|
+
spendLlmCall(gameId: string): Promise<Result<number, StoreError>>;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export { type ActorMeta, type GameRecord, type GameSeatRecord, type GameStatus, type GameStore, LLM_CALL_BUDGET, MemoryStore, type PulseError, type PulseGame, type PulseServiceClient, type RoomMember, type RoomRecord, type RoomSettings, type RoomStatus, SCHEDULER_CADENCE_MS, type SeatKind, type ServiceEnv, type StoreError, type StoredEvent, SupabaseStore, TIMESCALES, type Timescale, type ViewResult, catchUp, createRng, createRoom, createServiceClient, definePulseGame, foldState, getView, inviteCode, joinRoom, pulseTimes, readServiceEnv, submit, viewFromEvents };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
// src/catchup.ts
|
|
2
|
+
import { err, ok } from "neverthrow";
|
|
3
|
+
|
|
4
|
+
// src/store.ts
|
|
5
|
+
var LLM_CALL_BUDGET = 200;
|
|
6
|
+
var bySeatId = (seats) => [...seats].sort((a, b) => a.seatId < b.seatId ? -1 : a.seatId > b.seatId ? 1 : 0);
|
|
7
|
+
|
|
8
|
+
// src/timetable.ts
|
|
9
|
+
var SCHEDULER_CADENCE_MS = 6e4;
|
|
10
|
+
var TIMESCALES = {
|
|
11
|
+
quick: { openMs: 12e4, lockMs: 12e4 },
|
|
12
|
+
daily: { openMs: 828e5, lockMs: 36e5 }
|
|
13
|
+
};
|
|
14
|
+
function pulseTimes(startedAtMs, timescale2, pulse) {
|
|
15
|
+
const { openMs, lockMs } = TIMESCALES[timescale2];
|
|
16
|
+
const openAtMs = startedAtMs + (pulse - 1) * (openMs + lockMs);
|
|
17
|
+
return { openAtMs, lockAtMs: openAtMs + openMs, resolveAtMs: openAtMs + openMs + lockMs };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/catchup.ts
|
|
21
|
+
var SYSTEM = { actorKind: "system" };
|
|
22
|
+
function guarded(what, fn) {
|
|
23
|
+
try {
|
|
24
|
+
return ok(fn());
|
|
25
|
+
} catch (e) {
|
|
26
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
27
|
+
return err({ code: "corrupt_log", message: `${what} threw: ${message}` });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function foldState(game, record, events) {
|
|
31
|
+
const created = guarded(
|
|
32
|
+
"create",
|
|
33
|
+
() => game.create({
|
|
34
|
+
gameId: record.gameId,
|
|
35
|
+
seed: record.seed,
|
|
36
|
+
config: record.config,
|
|
37
|
+
seats: bySeatId(record.seats).map((s) => ({ seatId: s.seatId, kind: s.kind }))
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
if (created.isErr()) return err(created.error);
|
|
41
|
+
let state = created.value;
|
|
42
|
+
for (const e of events) {
|
|
43
|
+
const advanced = guarded(`seq ${e.seq}`, () => game.advance(state, e.event));
|
|
44
|
+
if (advanced.isErr()) return err(advanced.error);
|
|
45
|
+
const r = advanced.value;
|
|
46
|
+
if (!r.ok) {
|
|
47
|
+
return err({ code: "corrupt_log", message: `seq ${e.seq}: ${r.error.code}` });
|
|
48
|
+
}
|
|
49
|
+
if (e.seq !== game.seqOf(r.state)) {
|
|
50
|
+
return err({
|
|
51
|
+
code: "corrupt_log",
|
|
52
|
+
message: `seq mismatch: expected ${game.seqOf(r.state)}, stored ${e.seq}`
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
state = r.state;
|
|
56
|
+
}
|
|
57
|
+
return ok(state);
|
|
58
|
+
}
|
|
59
|
+
async function catchUp(game, store, gameId, nowMs) {
|
|
60
|
+
const found = await store.getGame(gameId);
|
|
61
|
+
if (found.isErr()) return err(found.error);
|
|
62
|
+
const record = found.value;
|
|
63
|
+
if (!record) return ok(null);
|
|
64
|
+
const fold = async () => (await store.listEvents(gameId)).andThen((events) => foldState(game, record, events));
|
|
65
|
+
let folded = await fold();
|
|
66
|
+
if (folded.isErr()) return err(folded.error);
|
|
67
|
+
let state = folded.value;
|
|
68
|
+
while (game.phaseOf(state) !== "complete") {
|
|
69
|
+
const phase = game.phaseOf(state);
|
|
70
|
+
const t = pulseTimes(record.startedAtMs, record.timescale, game.pulseOf(state));
|
|
71
|
+
const dueAt = phase === "open" ? t.lockAtMs : t.resolveAtMs;
|
|
72
|
+
if (nowMs < dueAt) break;
|
|
73
|
+
const event = phase === "open" ? game.clockEvents.lock : game.clockEvents.resolve;
|
|
74
|
+
const advanced = guarded(
|
|
75
|
+
phase === "open" ? "lock" : "resolve",
|
|
76
|
+
() => game.advance(state, event)
|
|
77
|
+
);
|
|
78
|
+
if (advanced.isErr()) return err(advanced.error);
|
|
79
|
+
const r = advanced.value;
|
|
80
|
+
if (!r.ok)
|
|
81
|
+
return err({ code: r.error.code, message: `clock event refused: ${r.error.message}` });
|
|
82
|
+
const appended = await store.appendEvent(gameId, {
|
|
83
|
+
seq: game.seqOf(r.state),
|
|
84
|
+
event,
|
|
85
|
+
actor: SYSTEM
|
|
86
|
+
});
|
|
87
|
+
if (appended.isOk()) {
|
|
88
|
+
state = r.state;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (appended.error.code !== "conflict") return err(appended.error);
|
|
92
|
+
folded = await fold();
|
|
93
|
+
if (folded.isErr()) return err(folded.error);
|
|
94
|
+
if (game.seqOf(folded.value) <= game.seqOf(state)) {
|
|
95
|
+
return err({
|
|
96
|
+
code: "conflict",
|
|
97
|
+
message: `seq ${game.seqOf(r.state)} collided but the log did not advance`
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
state = folded.value;
|
|
101
|
+
}
|
|
102
|
+
if (game.phaseOf(state) === "complete" && record.status !== "complete") {
|
|
103
|
+
const marked = await store.setGameStatus(gameId, "complete");
|
|
104
|
+
if (marked.isErr()) return err(marked.error);
|
|
105
|
+
}
|
|
106
|
+
return ok(state);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/game.ts
|
|
110
|
+
function definePulseGame(g) {
|
|
111
|
+
return g;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/play.ts
|
|
115
|
+
import { isDeepStrictEqual } from "util";
|
|
116
|
+
import { err as err2, ok as ok2 } from "neverthrow";
|
|
117
|
+
async function seated(game, store, input) {
|
|
118
|
+
const record = await store.getGame(input.gameId);
|
|
119
|
+
if (record.isErr()) return err2(record.error);
|
|
120
|
+
if (!record.value) return err2({ code: "game_not_found", message: "game not found" });
|
|
121
|
+
const seat = record.value.seats.find((s) => s.seatId === input.seatId);
|
|
122
|
+
if (!seat) return err2({ code: "not_in_game", message: `no seat ${input.seatId} in this game` });
|
|
123
|
+
const state = await catchUp(game, store, input.gameId, input.nowMs);
|
|
124
|
+
if (state.isErr()) return err2(state.error);
|
|
125
|
+
if (!state.value) return err2({ code: "game_not_found", message: "game not found" });
|
|
126
|
+
return ok2({ record: record.value, seat, state: state.value });
|
|
127
|
+
}
|
|
128
|
+
var actorMatches = (seat, actor) => seat.kind === "human" ? actor.actorKind === "human" : actor.actorKind === "persona" && actor.personaId === seat.personaProfileId;
|
|
129
|
+
async function submit(game, store, input) {
|
|
130
|
+
const { lock, resolve } = game.clockEvents;
|
|
131
|
+
const isClock = game.isClockEvent ?? ((e) => isDeepStrictEqual(e, lock) || isDeepStrictEqual(e, resolve));
|
|
132
|
+
if (isClock(input.event)) {
|
|
133
|
+
return err2({ code: "not_allowed", message: "clock events are appended by the scheduler" });
|
|
134
|
+
}
|
|
135
|
+
const cur = await seated(game, store, input);
|
|
136
|
+
if (cur.isErr()) return err2(cur.error);
|
|
137
|
+
if (!actorMatches(cur.value.seat, input.actor)) {
|
|
138
|
+
return err2({ code: "invalid_actor", message: `actor does not match seat ${input.seatId}` });
|
|
139
|
+
}
|
|
140
|
+
if (game.phaseOf(cur.value.state) === "complete") {
|
|
141
|
+
return err2({ code: "phase_closed", message: "game is complete" });
|
|
142
|
+
}
|
|
143
|
+
const advanced = guarded("advance", () => game.advance(cur.value.state, input.event));
|
|
144
|
+
if (advanced.isErr()) return err2(advanced.error);
|
|
145
|
+
const r = advanced.value;
|
|
146
|
+
if (!r.ok) return err2(r.error);
|
|
147
|
+
const appended = await store.appendEvent(input.gameId, {
|
|
148
|
+
seq: game.seqOf(r.state),
|
|
149
|
+
event: input.event,
|
|
150
|
+
actor: input.actor
|
|
151
|
+
});
|
|
152
|
+
if (appended.isErr()) return err2(appended.error);
|
|
153
|
+
return guarded("view", () => game.view(r.state, input.seatId));
|
|
154
|
+
}
|
|
155
|
+
function viewOf(game, record, state, seatId) {
|
|
156
|
+
const phase = game.phaseOf(state);
|
|
157
|
+
const t = pulseTimes(record.startedAtMs, record.timescale, game.pulseOf(state));
|
|
158
|
+
const deadline = phase === "open" ? { phase, atMs: t.lockAtMs } : phase === "lock" ? { phase, atMs: t.resolveAtMs } : null;
|
|
159
|
+
return guarded("view", () => game.view(state, seatId)).map((view) => ({ view, deadline }));
|
|
160
|
+
}
|
|
161
|
+
async function getView(game, store, input) {
|
|
162
|
+
const cur = await seated(game, store, input);
|
|
163
|
+
if (cur.isErr()) return err2(cur.error);
|
|
164
|
+
return viewOf(game, cur.value.record, cur.value.state, input.seatId);
|
|
165
|
+
}
|
|
166
|
+
function viewFromEvents(game, record, events, seatId) {
|
|
167
|
+
return foldState(game, record, events).andThen((state) => viewOf(game, record, state, seatId));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/rng.ts
|
|
171
|
+
function hashSeed(seed) {
|
|
172
|
+
let h = 1779033703 ^ seed.length;
|
|
173
|
+
for (let i = 0; i < seed.length; i++) {
|
|
174
|
+
h = Math.imul(h ^ seed.charCodeAt(i), 3432918353);
|
|
175
|
+
h = h << 13 | h >>> 19;
|
|
176
|
+
}
|
|
177
|
+
return h >>> 0;
|
|
178
|
+
}
|
|
179
|
+
function createRng(seed) {
|
|
180
|
+
let a = hashSeed(seed);
|
|
181
|
+
return () => {
|
|
182
|
+
a = a + 1831565813 | 0;
|
|
183
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
184
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
185
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/rooms.ts
|
|
190
|
+
import { err as err3, ok as ok3 } from "neverthrow";
|
|
191
|
+
var CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
192
|
+
function inviteCode(rng, length = 6) {
|
|
193
|
+
let code = "";
|
|
194
|
+
for (let i = 0; i < length; i++) code += CODE_ALPHABET[Math.floor(rng() * CODE_ALPHABET.length)];
|
|
195
|
+
return code;
|
|
196
|
+
}
|
|
197
|
+
async function createRoom(store, input) {
|
|
198
|
+
const room = {
|
|
199
|
+
roomId: input.roomId,
|
|
200
|
+
gameKey: input.gameKey,
|
|
201
|
+
inviteCode: inviteCode(input.rng),
|
|
202
|
+
hostUserId: input.hostUserId,
|
|
203
|
+
settings: input.settings,
|
|
204
|
+
status: "open"
|
|
205
|
+
};
|
|
206
|
+
const created = await store.createRoom(room);
|
|
207
|
+
if (created.isErr()) return err3(created.error);
|
|
208
|
+
const added = await store.addMember({
|
|
209
|
+
roomId: input.roomId,
|
|
210
|
+
userId: input.hostUserId,
|
|
211
|
+
displayName: input.hostDisplayName
|
|
212
|
+
});
|
|
213
|
+
if (added.isOk()) return ok3(room);
|
|
214
|
+
const undone = await store.deleteRoom(input.roomId);
|
|
215
|
+
if (undone.isErr()) {
|
|
216
|
+
return err3({
|
|
217
|
+
code: added.error.code,
|
|
218
|
+
message: `${added.error.message}; undo failed: ${undone.error.message}`
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return err3(added.error);
|
|
222
|
+
}
|
|
223
|
+
async function joinRoom(store, input) {
|
|
224
|
+
const found = await store.getRoomByCode(input.inviteCode);
|
|
225
|
+
if (found.isErr()) return err3(found.error);
|
|
226
|
+
const room = found.value;
|
|
227
|
+
if (!room) return err3({ code: "room_not_found", message: "room not found" });
|
|
228
|
+
if (room.status !== "open") return err3({ code: "room_closed", message: "room is not open" });
|
|
229
|
+
const members = await store.listMembers(room.roomId);
|
|
230
|
+
if (members.isErr()) return err3(members.error);
|
|
231
|
+
if (members.value.some((m) => m.userId === input.userId)) return ok3(room);
|
|
232
|
+
if (members.value.length >= room.settings.seats) {
|
|
233
|
+
return err3({ code: "room_full", message: "room is full" });
|
|
234
|
+
}
|
|
235
|
+
const added = await store.addMember({
|
|
236
|
+
roomId: room.roomId,
|
|
237
|
+
userId: input.userId,
|
|
238
|
+
displayName: input.displayName
|
|
239
|
+
});
|
|
240
|
+
if (added.isErr()) return err3(added.error);
|
|
241
|
+
return ok3(room);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// src/store.memory.ts
|
|
245
|
+
import { err as err4, ok as ok4 } from "neverthrow";
|
|
246
|
+
var MemoryStore = class {
|
|
247
|
+
/** The lease clock; injected because the store never reads the wall clock on its own. */
|
|
248
|
+
constructor(nowMs = () => 0) {
|
|
249
|
+
this.nowMs = nowMs;
|
|
250
|
+
}
|
|
251
|
+
nowMs;
|
|
252
|
+
leases = /* @__PURE__ */ new Map();
|
|
253
|
+
rooms = /* @__PURE__ */ new Map();
|
|
254
|
+
members = /* @__PURE__ */ new Map();
|
|
255
|
+
games = /* @__PURE__ */ new Map();
|
|
256
|
+
gameOrder = [];
|
|
257
|
+
events = /* @__PURE__ */ new Map();
|
|
258
|
+
llmCalls = /* @__PURE__ */ new Map();
|
|
259
|
+
async createRoom(room) {
|
|
260
|
+
this.rooms.set(room.roomId, structuredClone(room));
|
|
261
|
+
return ok4(void 0);
|
|
262
|
+
}
|
|
263
|
+
async deleteRoom(roomId) {
|
|
264
|
+
this.rooms.delete(roomId);
|
|
265
|
+
this.members.delete(roomId);
|
|
266
|
+
return ok4(void 0);
|
|
267
|
+
}
|
|
268
|
+
async getRoom(roomId) {
|
|
269
|
+
return ok4(structuredClone(this.rooms.get(roomId) ?? null));
|
|
270
|
+
}
|
|
271
|
+
async getRoomByCode(inviteCode2) {
|
|
272
|
+
for (const r of this.rooms.values()) {
|
|
273
|
+
if (r.inviteCode === inviteCode2) return ok4(structuredClone(r));
|
|
274
|
+
}
|
|
275
|
+
return ok4(null);
|
|
276
|
+
}
|
|
277
|
+
async setRoomStatus(roomId, status) {
|
|
278
|
+
const r = this.rooms.get(roomId);
|
|
279
|
+
if (r) this.rooms.set(roomId, { ...r, status });
|
|
280
|
+
return ok4(void 0);
|
|
281
|
+
}
|
|
282
|
+
async addMember(member) {
|
|
283
|
+
const list = this.members.get(member.roomId) ?? [];
|
|
284
|
+
if (!list.some((m) => m.userId === member.userId)) list.push({ ...member });
|
|
285
|
+
this.members.set(member.roomId, list);
|
|
286
|
+
return ok4(void 0);
|
|
287
|
+
}
|
|
288
|
+
async listMembers(roomId) {
|
|
289
|
+
return ok4(structuredClone(this.members.get(roomId) ?? []));
|
|
290
|
+
}
|
|
291
|
+
async createGame(game) {
|
|
292
|
+
const personas = game.seats.flatMap((s) => s.personaProfileId ? [s.personaProfileId] : []);
|
|
293
|
+
if (new Set(personas).size !== personas.length) {
|
|
294
|
+
return err4({
|
|
295
|
+
code: "store",
|
|
296
|
+
message: "game_seats_persona_once: a persona sits once per game"
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
this.games.set(game.gameId, structuredClone({ ...game, seats: bySeatId(game.seats) }));
|
|
300
|
+
this.gameOrder.push(game.gameId);
|
|
301
|
+
return ok4(void 0);
|
|
302
|
+
}
|
|
303
|
+
async getGame(gameId) {
|
|
304
|
+
return ok4(structuredClone(this.games.get(gameId) ?? null));
|
|
305
|
+
}
|
|
306
|
+
async setGameStatus(gameId, status) {
|
|
307
|
+
const g = this.games.get(gameId);
|
|
308
|
+
if (g) this.games.set(gameId, { ...g, status });
|
|
309
|
+
return ok4(void 0);
|
|
310
|
+
}
|
|
311
|
+
async getLatestGameByRoom(roomId) {
|
|
312
|
+
for (let i = this.gameOrder.length - 1; i >= 0; i--) {
|
|
313
|
+
const g = this.games.get(this.gameOrder[i] ?? "");
|
|
314
|
+
if (g && g.roomId === roomId) return ok4(structuredClone(g));
|
|
315
|
+
}
|
|
316
|
+
return ok4(null);
|
|
317
|
+
}
|
|
318
|
+
async listEvents(gameId) {
|
|
319
|
+
return ok4(structuredClone(this.events.get(gameId) ?? []).sort((a, b) => a.seq - b.seq));
|
|
320
|
+
}
|
|
321
|
+
async appendEvent(gameId, stored) {
|
|
322
|
+
const list = this.events.get(gameId) ?? [];
|
|
323
|
+
if (list.some((e) => e.seq === stored.seq)) {
|
|
324
|
+
return err4({ code: "conflict", message: `duplicate seq ${stored.seq} for ${gameId}` });
|
|
325
|
+
}
|
|
326
|
+
list.push(structuredClone(stored));
|
|
327
|
+
this.events.set(gameId, list);
|
|
328
|
+
return ok4(void 0);
|
|
329
|
+
}
|
|
330
|
+
async acquireTickLease(gameId, ttlMs) {
|
|
331
|
+
if (!this.games.has(gameId)) return err4({ code: "store", message: `no game ${gameId}` });
|
|
332
|
+
const now = this.nowMs();
|
|
333
|
+
const until = this.leases.get(gameId);
|
|
334
|
+
if (until !== void 0 && until >= now) return ok4(false);
|
|
335
|
+
this.leases.set(gameId, now + ttlMs);
|
|
336
|
+
return ok4(true);
|
|
337
|
+
}
|
|
338
|
+
async releaseTickLease(gameId) {
|
|
339
|
+
this.leases.delete(gameId);
|
|
340
|
+
return ok4(void 0);
|
|
341
|
+
}
|
|
342
|
+
async spendLlmCall(gameId) {
|
|
343
|
+
if (!this.games.has(gameId)) return err4({ code: "store", message: `no game ${gameId}` });
|
|
344
|
+
const calls = (this.llmCalls.get(gameId) ?? 0) + 1;
|
|
345
|
+
if (calls > LLM_CALL_BUDGET) {
|
|
346
|
+
return err4({ code: "llm_budget_exceeded", message: "llm_budget_exceeded" });
|
|
347
|
+
}
|
|
348
|
+
this.llmCalls.set(gameId, calls);
|
|
349
|
+
return ok4(calls);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// src/store.supabase.ts
|
|
354
|
+
import { createClient } from "@supabase/supabase-js";
|
|
355
|
+
import { err as err5, ok as ok5 } from "neverthrow";
|
|
356
|
+
import { z } from "zod";
|
|
357
|
+
var envSchema = z.object({
|
|
358
|
+
SUPABASE_URL: z.string().url(),
|
|
359
|
+
SUPABASE_SERVICE_ROLE_KEY: z.string().min(20)
|
|
360
|
+
});
|
|
361
|
+
function readServiceEnv(raw) {
|
|
362
|
+
return envSchema.parse(raw);
|
|
363
|
+
}
|
|
364
|
+
function createServiceClient(env) {
|
|
365
|
+
return createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
|
|
366
|
+
auth: { autoRefreshToken: false, persistSession: false }
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
var timescale = z.enum(["quick", "daily"]);
|
|
370
|
+
var roomRow = z.object({
|
|
371
|
+
id: z.string(),
|
|
372
|
+
game_key: z.string(),
|
|
373
|
+
invite_code: z.string(),
|
|
374
|
+
host_user_id: z.string(),
|
|
375
|
+
settings: z.object({ seats: z.number().int(), timescale }),
|
|
376
|
+
status: z.enum(["open", "playing", "complete"])
|
|
377
|
+
});
|
|
378
|
+
var seatRow = z.object({
|
|
379
|
+
seat_id: z.string(),
|
|
380
|
+
kind: z.enum(["human", "persona"]),
|
|
381
|
+
user_id: z.string().nullable(),
|
|
382
|
+
persona_profile_id: z.string().nullable(),
|
|
383
|
+
persona_tier: z.enum(["heuristic", "llm"]).nullable()
|
|
384
|
+
});
|
|
385
|
+
var gameRow = z.object({
|
|
386
|
+
id: z.string(),
|
|
387
|
+
room_id: z.string(),
|
|
388
|
+
game_key: z.string(),
|
|
389
|
+
seed: z.string(),
|
|
390
|
+
config: z.unknown(),
|
|
391
|
+
timescale,
|
|
392
|
+
started_at_ms: z.number(),
|
|
393
|
+
status: z.enum(["playing", "complete"])
|
|
394
|
+
});
|
|
395
|
+
var actorRow = z.discriminatedUnion("actor_kind", [
|
|
396
|
+
z.object({ actor_kind: z.literal("human") }),
|
|
397
|
+
z.object({ actor_kind: z.literal("system") }),
|
|
398
|
+
z.object({
|
|
399
|
+
actor_kind: z.literal("persona"),
|
|
400
|
+
persona_id: z.string(),
|
|
401
|
+
tier: z.enum(["heuristic", "llm"]),
|
|
402
|
+
model: z.string()
|
|
403
|
+
})
|
|
404
|
+
]);
|
|
405
|
+
var eventRow = z.object({ seq: z.number().int(), event: z.unknown() }).and(actorRow);
|
|
406
|
+
var toActor = (a) => a.actor_kind === "persona" ? { actorKind: "persona", personaId: a.persona_id, tier: a.tier, model: a.model } : { actorKind: a.actor_kind };
|
|
407
|
+
var fromActor = (a) => a.actorKind === "persona" ? { p_actor_kind: a.actorKind, p_persona_id: a.personaId, p_tier: a.tier, p_model: a.model } : { p_actor_kind: a.actorKind, p_persona_id: null, p_tier: null, p_model: null };
|
|
408
|
+
var parse = (schema, value) => {
|
|
409
|
+
const r = schema.safeParse(value);
|
|
410
|
+
return r.success ? ok5(r.data) : err5({ code: "store", message: r.error.message });
|
|
411
|
+
};
|
|
412
|
+
var dbErr = (e) => ({ code: "store", message: e.message });
|
|
413
|
+
var toRoom = (r) => ({
|
|
414
|
+
roomId: r.id,
|
|
415
|
+
gameKey: r.game_key,
|
|
416
|
+
inviteCode: r.invite_code,
|
|
417
|
+
hostUserId: r.host_user_id,
|
|
418
|
+
settings: r.settings,
|
|
419
|
+
status: r.status
|
|
420
|
+
});
|
|
421
|
+
var asJson = (v) => v;
|
|
422
|
+
var SupabaseStore = class {
|
|
423
|
+
// pageSize mirrors the Data API's max_rows (supabase/config.toml); smaller only in tests.
|
|
424
|
+
constructor(db, pageSize = 1e3) {
|
|
425
|
+
this.db = db;
|
|
426
|
+
this.pageSize = pageSize;
|
|
427
|
+
}
|
|
428
|
+
db;
|
|
429
|
+
pageSize;
|
|
430
|
+
get pulse() {
|
|
431
|
+
return this.db.schema("pulse");
|
|
432
|
+
}
|
|
433
|
+
async createRoom(room) {
|
|
434
|
+
const { error } = await this.pulse.from("rooms").insert({
|
|
435
|
+
id: room.roomId,
|
|
436
|
+
game_key: room.gameKey,
|
|
437
|
+
invite_code: room.inviteCode,
|
|
438
|
+
host_user_id: room.hostUserId,
|
|
439
|
+
settings: room.settings,
|
|
440
|
+
status: room.status
|
|
441
|
+
});
|
|
442
|
+
return error ? err5(dbErr(error)) : ok5(void 0);
|
|
443
|
+
}
|
|
444
|
+
async deleteRoom(roomId) {
|
|
445
|
+
const { error } = await this.pulse.from("rooms").delete().eq("id", roomId);
|
|
446
|
+
return error ? err5(dbErr(error)) : ok5(void 0);
|
|
447
|
+
}
|
|
448
|
+
async roomWhere(column, value) {
|
|
449
|
+
const { data, error } = await this.pulse.from("rooms").select().eq(column, value).maybeSingle();
|
|
450
|
+
if (error) return err5(dbErr(error));
|
|
451
|
+
return data ? parse(roomRow, data).map(toRoom) : ok5(null);
|
|
452
|
+
}
|
|
453
|
+
getRoom(roomId) {
|
|
454
|
+
return this.roomWhere("id", roomId);
|
|
455
|
+
}
|
|
456
|
+
getRoomByCode(inviteCode2) {
|
|
457
|
+
return this.roomWhere("invite_code", inviteCode2);
|
|
458
|
+
}
|
|
459
|
+
async setRoomStatus(roomId, status) {
|
|
460
|
+
const { error } = await this.pulse.from("rooms").update({ status }).eq("id", roomId);
|
|
461
|
+
return error ? err5(dbErr(error)) : ok5(void 0);
|
|
462
|
+
}
|
|
463
|
+
async addMember(member) {
|
|
464
|
+
const { error } = await this.pulse.from("room_members").upsert(
|
|
465
|
+
{ room_id: member.roomId, user_id: member.userId, display_name: member.displayName },
|
|
466
|
+
{ onConflict: "room_id,user_id" }
|
|
467
|
+
);
|
|
468
|
+
if (!error) return ok5(void 0);
|
|
469
|
+
if (error.code === "23514") {
|
|
470
|
+
for (const code of ["room_full", "room_closed"]) {
|
|
471
|
+
if (error.message.includes(code)) return err5({ code, message: error.message });
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return err5(dbErr(error));
|
|
475
|
+
}
|
|
476
|
+
async listMembers(roomId) {
|
|
477
|
+
const { data, error } = await this.pulse.from("room_members").select().eq("room_id", roomId).order("joined_at", { ascending: true });
|
|
478
|
+
if (error) return err5(dbErr(error));
|
|
479
|
+
return ok5(
|
|
480
|
+
data.map((r) => ({ roomId: r.room_id, userId: r.user_id, displayName: r.display_name }))
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
async createGame(game) {
|
|
484
|
+
const { error } = await this.pulse.from("games").insert({
|
|
485
|
+
id: game.gameId,
|
|
486
|
+
room_id: game.roomId,
|
|
487
|
+
game_key: game.gameKey,
|
|
488
|
+
seed: game.seed,
|
|
489
|
+
config: asJson(game.config),
|
|
490
|
+
timescale: game.timescale,
|
|
491
|
+
started_at_ms: game.startedAtMs,
|
|
492
|
+
status: game.status
|
|
493
|
+
});
|
|
494
|
+
if (error) return err5(dbErr(error));
|
|
495
|
+
const { error: seatErr } = await this.pulse.from("game_seats").insert(
|
|
496
|
+
game.seats.map((s) => ({
|
|
497
|
+
game_id: game.gameId,
|
|
498
|
+
seat_id: s.seatId,
|
|
499
|
+
kind: s.kind,
|
|
500
|
+
user_id: s.userId,
|
|
501
|
+
persona_profile_id: s.personaProfileId,
|
|
502
|
+
persona_tier: s.personaTier
|
|
503
|
+
}))
|
|
504
|
+
);
|
|
505
|
+
if (!seatErr) return ok5(void 0);
|
|
506
|
+
const { error: undo } = await this.pulse.from("games").delete().eq("id", game.gameId);
|
|
507
|
+
return err5(
|
|
508
|
+
dbErr(undo ? { message: `${seatErr.message}; undo failed: ${undo.message}` } : seatErr)
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
async gameFromRow(raw) {
|
|
512
|
+
const row = parse(gameRow, raw);
|
|
513
|
+
if (row.isErr()) return err5(row.error);
|
|
514
|
+
const { data, error } = await this.pulse.from("game_seats").select().eq("game_id", row.value.id).order("seat_id", { ascending: true });
|
|
515
|
+
if (error) return err5(dbErr(error));
|
|
516
|
+
return parse(z.array(seatRow), data).map((seats) => ({
|
|
517
|
+
gameId: row.value.id,
|
|
518
|
+
roomId: row.value.room_id,
|
|
519
|
+
gameKey: row.value.game_key,
|
|
520
|
+
seed: row.value.seed,
|
|
521
|
+
config: row.value.config,
|
|
522
|
+
timescale: row.value.timescale,
|
|
523
|
+
startedAtMs: row.value.started_at_ms,
|
|
524
|
+
status: row.value.status,
|
|
525
|
+
seats: seats.map((s) => ({
|
|
526
|
+
seatId: s.seat_id,
|
|
527
|
+
kind: s.kind,
|
|
528
|
+
userId: s.user_id,
|
|
529
|
+
personaProfileId: s.persona_profile_id,
|
|
530
|
+
personaTier: s.persona_tier
|
|
531
|
+
}))
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
async getGame(gameId) {
|
|
535
|
+
const { data, error } = await this.pulse.from("games").select().eq("id", gameId).maybeSingle();
|
|
536
|
+
if (error) return err5(dbErr(error));
|
|
537
|
+
return data ? this.gameFromRow(data) : ok5(null);
|
|
538
|
+
}
|
|
539
|
+
async setGameStatus(gameId, status) {
|
|
540
|
+
const { error } = await this.pulse.from("games").update({ status }).eq("id", gameId);
|
|
541
|
+
return error ? err5(dbErr(error)) : ok5(void 0);
|
|
542
|
+
}
|
|
543
|
+
async getLatestGameByRoom(roomId) {
|
|
544
|
+
const { data, error } = await this.pulse.from("games").select().eq("room_id", roomId).order("created_at", { ascending: false }).limit(1).maybeSingle();
|
|
545
|
+
if (error) return err5(dbErr(error));
|
|
546
|
+
return data ? this.gameFromRow(data) : ok5(null);
|
|
547
|
+
}
|
|
548
|
+
async listEvents(gameId) {
|
|
549
|
+
const data = [];
|
|
550
|
+
for (let from = 0; ; from += this.pageSize) {
|
|
551
|
+
const page = await this.pulse.from("game_events").select().eq("game_id", gameId).order("seq", { ascending: true }).range(from, from + this.pageSize - 1);
|
|
552
|
+
if (page.error) return err5(dbErr(page.error));
|
|
553
|
+
data.push(...page.data);
|
|
554
|
+
if (page.data.length < this.pageSize) break;
|
|
555
|
+
}
|
|
556
|
+
return parse(z.array(eventRow), data).map(
|
|
557
|
+
(rows) => rows.map((r) => ({ seq: r.seq, event: r.event, actor: toActor(r) }))
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
async appendEvent(gameId, stored) {
|
|
561
|
+
const { error } = await this.pulse.rpc("append_event", {
|
|
562
|
+
p_game_id: gameId,
|
|
563
|
+
p_seq: stored.seq,
|
|
564
|
+
p_event: asJson(stored.event),
|
|
565
|
+
...fromActor(stored.actor)
|
|
566
|
+
});
|
|
567
|
+
if (!error) return ok5(void 0);
|
|
568
|
+
if (error.code === "23505") return err5({ code: "conflict", message: error.message });
|
|
569
|
+
return err5(dbErr(error));
|
|
570
|
+
}
|
|
571
|
+
async acquireTickLease(gameId, ttlMs) {
|
|
572
|
+
const { data, error } = await this.pulse.rpc("acquire_tick_lease", {
|
|
573
|
+
p_game_id: gameId,
|
|
574
|
+
p_ttl_ms: ttlMs
|
|
575
|
+
});
|
|
576
|
+
return error ? err5(dbErr(error)) : ok5(data);
|
|
577
|
+
}
|
|
578
|
+
async releaseTickLease(gameId) {
|
|
579
|
+
const { error } = await this.pulse.rpc("release_tick_lease", { p_game_id: gameId });
|
|
580
|
+
return error ? err5(dbErr(error)) : ok5(void 0);
|
|
581
|
+
}
|
|
582
|
+
async spendLlmCall(gameId) {
|
|
583
|
+
const { data, error } = await this.pulse.rpc("spend_llm_call", { p_game_id: gameId });
|
|
584
|
+
if (!error) return ok5(data);
|
|
585
|
+
if (error.code === "23514" && error.message.includes("llm_budget_exceeded")) {
|
|
586
|
+
return err5({ code: "llm_budget_exceeded", message: error.message });
|
|
587
|
+
}
|
|
588
|
+
return err5(dbErr(error));
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
export {
|
|
592
|
+
LLM_CALL_BUDGET,
|
|
593
|
+
MemoryStore,
|
|
594
|
+
SCHEDULER_CADENCE_MS,
|
|
595
|
+
SupabaseStore,
|
|
596
|
+
TIMESCALES,
|
|
597
|
+
catchUp,
|
|
598
|
+
createRng,
|
|
599
|
+
createRoom,
|
|
600
|
+
createServiceClient,
|
|
601
|
+
definePulseGame,
|
|
602
|
+
foldState,
|
|
603
|
+
getView,
|
|
604
|
+
inviteCode,
|
|
605
|
+
joinRoom,
|
|
606
|
+
pulseTimes,
|
|
607
|
+
readServiceEnv,
|
|
608
|
+
submit,
|
|
609
|
+
viewFromEvents
|
|
610
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aghents/pulse",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@supabase/supabase-js": "^2.110.2",
|
|
22
|
+
"neverthrow": "^8.1.1",
|
|
23
|
+
"zod": "^3.24.1",
|
|
24
|
+
"@aghents/identity": "0.2.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"typescript": "^5.9.3",
|
|
28
|
+
"vitest": "^2.1.9"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
32
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
33
|
+
"test": "vitest run"
|
|
34
|
+
}
|
|
35
|
+
}
|