@game-infra/game-state-schemas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/contracts.d.ts +220 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +147 -0
- package/dist/contracts.js.map +1 -0
- package/dist/freeformPayloads.d.ts +368 -0
- package/dist/freeformPayloads.d.ts.map +1 -0
- package/dist/freeformPayloads.js +318 -0
- package/dist/freeformPayloads.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +45 -0
- package/dist/index.js.map +1 -0
- package/dist/sessionState.d.ts +252 -0
- package/dist/sessionState.d.ts.map +1 -0
- package/dist/sessionState.js +191 -0
- package/dist/sessionState.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { type InferOutput } from "valibot";
|
|
2
|
+
/**
|
|
3
|
+
* The transport for everything that changes because someone is *playing*: the
|
|
4
|
+
* sessions a player has in progress, and the journal each one is rebuilt from.
|
|
5
|
+
*
|
|
6
|
+
* This is the counterpart to `@game-infra/story-schemas`, and the split is the
|
|
7
|
+
* point. Design content is authored — one copy, shared by every player, the
|
|
8
|
+
* same tomorrow as today. Session state is transient and personal — one copy
|
|
9
|
+
* per player per playthrough, rewritten every turn, and meaningless to anyone
|
|
10
|
+
* else. Storing them in one resource meant one delete sweep, one listing and
|
|
11
|
+
* one retention policy for two things that share none of those.
|
|
12
|
+
*
|
|
13
|
+
* Two shapes carry all of it:
|
|
14
|
+
*
|
|
15
|
+
* - a **session** — the header of one playthrough: which world or campaign it
|
|
16
|
+
* plays, what to call it in a picker, and an opaque payload for whatever else
|
|
17
|
+
* the game needs to resume;
|
|
18
|
+
* - a **journal entry** — one recorded step of that session, numbered, so
|
|
19
|
+
* folding the entries in order rebuilds the session's whole state.
|
|
20
|
+
*
|
|
21
|
+
* As in design content, `data` is stored, indexed and returned but never parsed
|
|
22
|
+
* by the service, so a game's play vocabulary can move without a contract bump.
|
|
23
|
+
* The shapes that go *inside* it are typed too — see `freeformPayloads.ts` —
|
|
24
|
+
* but as schemas a client parses with, not as part of any endpoint.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* The scope of a session that plays nothing in particular. A session is
|
|
28
|
+
* normally scoped to the world or campaign it plays, but a game with a single
|
|
29
|
+
* unnamed setting has nothing to name, and a URL cannot carry an empty path
|
|
30
|
+
* segment, so unscoped sessions spell their scope `-`.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* import { ROOT_SESSION_SCOPE } from '@game-infra/game-state-schemas'
|
|
35
|
+
*
|
|
36
|
+
* const header = { scopeId: ROOT_SESSION_SCOPE, name: 'Slot 1', data: '{}' }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export declare const ROOT_SESSION_SCOPE = "-";
|
|
40
|
+
/**
|
|
41
|
+
* One playthrough in progress, as the service holds it.
|
|
42
|
+
*
|
|
43
|
+
* A session belongs to the caller who created it: every read and write is
|
|
44
|
+
* filtered by user id, so two players in the same game never see each other's
|
|
45
|
+
* sessions.
|
|
46
|
+
*
|
|
47
|
+
* `data` is present only when the request asked for it — a session picker needs
|
|
48
|
+
* titles and timestamps, not the premise of every abandoned run.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* import type { GameSessionRecord } from '@game-infra/game-state-schemas'
|
|
53
|
+
*
|
|
54
|
+
* const session: GameSessionRecord = {
|
|
55
|
+
* sessionId: 'ash-and-embers',
|
|
56
|
+
* scopeId: 'the-ash-reckoning',
|
|
57
|
+
* name: 'Ash and Embers',
|
|
58
|
+
* data: JSON.stringify({ title: 'Ash and Embers', premise: 'A drift-runner…' }),
|
|
59
|
+
* entryCount: 12,
|
|
60
|
+
* createdAt: '2026-08-29T09:00:00.000Z',
|
|
61
|
+
* updatedAt: '2026-08-29T11:42:00.000Z',
|
|
62
|
+
* }
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export declare const GameSessionRecordSchema: import("valibot").ObjectSchema<{
|
|
66
|
+
readonly sessionId: import("valibot").StringSchema<undefined>;
|
|
67
|
+
/** The world, campaign or scenario being played, or {@link ROOT_SESSION_SCOPE}. */
|
|
68
|
+
readonly scopeId: import("valibot").StringSchema<undefined>;
|
|
69
|
+
/** For display in a session picker, so a listing need not carry payloads. */
|
|
70
|
+
readonly name: import("valibot").StringSchema<undefined>;
|
|
71
|
+
readonly data: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
72
|
+
/** How many journal entries the session holds — its length, for a picker. */
|
|
73
|
+
readonly entryCount: import("valibot").OptionalSchema<import("valibot").NumberSchema<undefined>, undefined>;
|
|
74
|
+
readonly createdAt: import("valibot").StringSchema<undefined>;
|
|
75
|
+
readonly updatedAt: import("valibot").StringSchema<undefined>;
|
|
76
|
+
}, undefined>;
|
|
77
|
+
export type GameSessionRecord = InferOutput<typeof GameSessionRecordSchema>;
|
|
78
|
+
/**
|
|
79
|
+
* Create or replace a session header. Re-saving the same id keeps its
|
|
80
|
+
* `createdAt` and leaves the journal alone, so renaming a session is one call.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* import type { GameSessionSaveRequest } from '@game-infra/game-state-schemas'
|
|
85
|
+
*
|
|
86
|
+
* const request: GameSessionSaveRequest = {
|
|
87
|
+
* scopeId: 'the-ash-reckoning',
|
|
88
|
+
* name: 'Ash and Embers',
|
|
89
|
+
* data: JSON.stringify({ title: 'Ash and Embers', premise: 'A drift-runner…' }),
|
|
90
|
+
* }
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export declare const GameSessionSaveRequestSchema: import("valibot").ObjectSchema<{
|
|
94
|
+
readonly scopeId: import("valibot").StringSchema<undefined>;
|
|
95
|
+
readonly name: import("valibot").StringSchema<undefined>;
|
|
96
|
+
readonly data: import("valibot").StringSchema<undefined>;
|
|
97
|
+
}, undefined>;
|
|
98
|
+
export type GameSessionSaveRequest = InferOutput<typeof GameSessionSaveRequestSchema>;
|
|
99
|
+
/**
|
|
100
|
+
* One recorded step of a session.
|
|
101
|
+
*
|
|
102
|
+
* `sequence` is the entry's position in the journal, counted from zero, and it
|
|
103
|
+
* is the whole ordering: entries come back in numeric order with no sort key to
|
|
104
|
+
* mint and no zero-padded id to keep sortable as text.
|
|
105
|
+
*
|
|
106
|
+
* @example
|
|
107
|
+
* ```ts
|
|
108
|
+
* import type { GameJournalEntryRecord } from '@game-infra/game-state-schemas'
|
|
109
|
+
*
|
|
110
|
+
* const entry: GameJournalEntryRecord = {
|
|
111
|
+
* sequence: 7,
|
|
112
|
+
* name: 'The tunnel breathes out ash',
|
|
113
|
+
* data: JSON.stringify({ input: 'I follow the draught', narration: '…' }),
|
|
114
|
+
* createdAt: '2026-08-29T11:40:00.000Z',
|
|
115
|
+
* updatedAt: '2026-08-29T11:40:00.000Z',
|
|
116
|
+
* }
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
export declare const GameJournalEntryRecordSchema: import("valibot").ObjectSchema<{
|
|
120
|
+
readonly sequence: import("valibot").NumberSchema<undefined>;
|
|
121
|
+
/** For display in a history list — a turn title, a chapter heading. */
|
|
122
|
+
readonly name: import("valibot").StringSchema<undefined>;
|
|
123
|
+
readonly data: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
124
|
+
readonly createdAt: import("valibot").StringSchema<undefined>;
|
|
125
|
+
readonly updatedAt: import("valibot").StringSchema<undefined>;
|
|
126
|
+
}, undefined>;
|
|
127
|
+
export type GameJournalEntryRecord = InferOutput<typeof GameJournalEntryRecordSchema>;
|
|
128
|
+
/**
|
|
129
|
+
* Write one journal entry. Writing an existing `sequence` replaces it, which is
|
|
130
|
+
* how a turn is re-rolled or corrected without disturbing the ones after it.
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* import type { GameJournalAppendRequest } from '@game-infra/game-state-schemas'
|
|
135
|
+
*
|
|
136
|
+
* const request: GameJournalAppendRequest = {
|
|
137
|
+
* name: 'The tunnel breathes out ash',
|
|
138
|
+
* data: JSON.stringify({ input: 'I follow the draught', narration: '…' }),
|
|
139
|
+
* }
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
export declare const GameJournalAppendRequestSchema: import("valibot").ObjectSchema<{
|
|
143
|
+
readonly name: import("valibot").StringSchema<undefined>;
|
|
144
|
+
readonly data: import("valibot").StringSchema<undefined>;
|
|
145
|
+
}, undefined>;
|
|
146
|
+
export type GameJournalAppendRequest = InferOutput<typeof GameJournalAppendRequestSchema>;
|
|
147
|
+
/**
|
|
148
|
+
* How far a journal delete reaches.
|
|
149
|
+
*
|
|
150
|
+
* - `entry` — that entry alone, for correcting one step in place.
|
|
151
|
+
* - `following` — that entry and every entry after it. This is undo: a journal
|
|
152
|
+
* is folded in order, so keeping the tail after dropping a step in the middle
|
|
153
|
+
* would replay effects that no longer have a cause.
|
|
154
|
+
*/
|
|
155
|
+
export declare const JOURNAL_TRUNCATIONS: readonly ["entry", "following"];
|
|
156
|
+
export declare const JournalTruncationSchema: import("valibot").PicklistSchema<readonly ["entry", "following"], undefined>;
|
|
157
|
+
export type JournalTruncation = InferOutput<typeof JournalTruncationSchema>;
|
|
158
|
+
/**
|
|
159
|
+
* A journal position as it arrives in a path or query segment: digits, and
|
|
160
|
+
* short enough that no journal will ever reach it.
|
|
161
|
+
*
|
|
162
|
+
* Path segments are strings on the wire, so the shape is checked here — in the
|
|
163
|
+
* schema the contract references — rather than re-checked in every handler that
|
|
164
|
+
* reads one. Handlers take the number with `Number(...)`.
|
|
165
|
+
*/
|
|
166
|
+
export declare const SequenceParamSchema: import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").RegexAction<string, "sequence must be a non-negative integer">]>;
|
|
167
|
+
/** The caller's sessions for this game, most recently played first. */
|
|
168
|
+
export declare const GameSessionListResponseSchema: import("valibot").ObjectSchema<{
|
|
169
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
170
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
171
|
+
} & {
|
|
172
|
+
items: import("valibot").OptionalSchema<import("valibot").ArraySchema<import("valibot").ObjectSchema<{
|
|
173
|
+
readonly sessionId: import("valibot").StringSchema<undefined>;
|
|
174
|
+
/** The world, campaign or scenario being played, or {@link ROOT_SESSION_SCOPE}. */
|
|
175
|
+
readonly scopeId: import("valibot").StringSchema<undefined>;
|
|
176
|
+
/** For display in a session picker, so a listing need not carry payloads. */
|
|
177
|
+
readonly name: import("valibot").StringSchema<undefined>;
|
|
178
|
+
readonly data: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
179
|
+
/** How many journal entries the session holds — its length, for a picker. */
|
|
180
|
+
readonly entryCount: import("valibot").OptionalSchema<import("valibot").NumberSchema<undefined>, undefined>;
|
|
181
|
+
readonly createdAt: import("valibot").StringSchema<undefined>;
|
|
182
|
+
readonly updatedAt: import("valibot").StringSchema<undefined>;
|
|
183
|
+
}, undefined>, undefined>, undefined>;
|
|
184
|
+
}, undefined>;
|
|
185
|
+
export type GameSessionListResponse = InferOutput<typeof GameSessionListResponseSchema>;
|
|
186
|
+
/** One session header, absent when the caller has no session under that id. */
|
|
187
|
+
export declare const GameSessionLoadResponseSchema: import("valibot").ObjectSchema<{
|
|
188
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
189
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
190
|
+
} & {
|
|
191
|
+
item: import("valibot").OptionalSchema<import("valibot").ObjectSchema<{
|
|
192
|
+
readonly sessionId: import("valibot").StringSchema<undefined>;
|
|
193
|
+
/** The world, campaign or scenario being played, or {@link ROOT_SESSION_SCOPE}. */
|
|
194
|
+
readonly scopeId: import("valibot").StringSchema<undefined>;
|
|
195
|
+
/** For display in a session picker, so a listing need not carry payloads. */
|
|
196
|
+
readonly name: import("valibot").StringSchema<undefined>;
|
|
197
|
+
readonly data: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
198
|
+
/** How many journal entries the session holds — its length, for a picker. */
|
|
199
|
+
readonly entryCount: import("valibot").OptionalSchema<import("valibot").NumberSchema<undefined>, undefined>;
|
|
200
|
+
readonly createdAt: import("valibot").StringSchema<undefined>;
|
|
201
|
+
readonly updatedAt: import("valibot").StringSchema<undefined>;
|
|
202
|
+
}, undefined>, undefined>;
|
|
203
|
+
}, undefined>;
|
|
204
|
+
export type GameSessionLoadResponse = InferOutput<typeof GameSessionLoadResponseSchema>;
|
|
205
|
+
/** The saved session's id, echoed back. */
|
|
206
|
+
export declare const GameSessionSaveResponseSchema: import("valibot").ObjectSchema<{
|
|
207
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
208
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
209
|
+
} & {
|
|
210
|
+
sessionId: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
211
|
+
}, undefined>;
|
|
212
|
+
export type GameSessionSaveResponse = InferOutput<typeof GameSessionSaveResponseSchema>;
|
|
213
|
+
/** How many rows the delete removed — the session plus its journal entries. */
|
|
214
|
+
export declare const GameSessionDeleteResponseSchema: import("valibot").ObjectSchema<{
|
|
215
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
216
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
217
|
+
} & {
|
|
218
|
+
deletedCount: import("valibot").OptionalSchema<import("valibot").NumberSchema<undefined>, undefined>;
|
|
219
|
+
}, undefined>;
|
|
220
|
+
export type GameSessionDeleteResponse = InferOutput<typeof GameSessionDeleteResponseSchema>;
|
|
221
|
+
/** A session's journal, in `sequence` order. */
|
|
222
|
+
export declare const GameJournalListResponseSchema: import("valibot").ObjectSchema<{
|
|
223
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
224
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
225
|
+
} & {
|
|
226
|
+
entries: import("valibot").OptionalSchema<import("valibot").ArraySchema<import("valibot").ObjectSchema<{
|
|
227
|
+
readonly sequence: import("valibot").NumberSchema<undefined>;
|
|
228
|
+
/** For display in a history list — a turn title, a chapter heading. */
|
|
229
|
+
readonly name: import("valibot").StringSchema<undefined>;
|
|
230
|
+
readonly data: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
231
|
+
readonly createdAt: import("valibot").StringSchema<undefined>;
|
|
232
|
+
readonly updatedAt: import("valibot").StringSchema<undefined>;
|
|
233
|
+
}, undefined>, undefined>, undefined>;
|
|
234
|
+
}, undefined>;
|
|
235
|
+
export type GameJournalListResponse = InferOutput<typeof GameJournalListResponseSchema>;
|
|
236
|
+
/** The written entry's position, echoed back. */
|
|
237
|
+
export declare const GameJournalAppendResponseSchema: import("valibot").ObjectSchema<{
|
|
238
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
239
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
240
|
+
} & {
|
|
241
|
+
sequence: import("valibot").OptionalSchema<import("valibot").NumberSchema<undefined>, undefined>;
|
|
242
|
+
}, undefined>;
|
|
243
|
+
export type GameJournalAppendResponse = InferOutput<typeof GameJournalAppendResponseSchema>;
|
|
244
|
+
/** How many entries the truncation removed — 0 when nothing matched. */
|
|
245
|
+
export declare const GameJournalTruncateResponseSchema: import("valibot").ObjectSchema<{
|
|
246
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
247
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
248
|
+
} & {
|
|
249
|
+
deletedCount: import("valibot").OptionalSchema<import("valibot").NumberSchema<undefined>, undefined>;
|
|
250
|
+
}, undefined>;
|
|
251
|
+
export type GameJournalTruncateResponse = InferOutput<typeof GameJournalTruncateResponseSchema>;
|
|
252
|
+
//# sourceMappingURL=sessionState.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sessionState.d.ts","sourceRoot":"","sources":["../src/sessionState.ts"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,WAAW,EASjB,MAAM,SAAS,CAAC;AAEjB;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,kBAAkB,MAAM,CAAC;AAEtC;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,uBAAuB;;IAElC,mFAAmF;;IAEnF,6EAA6E;;;IAG7E,6EAA6E;;;;aAI7E,CAAC;AAEH,MAAM,MAAM,iBAAiB,GAAG,WAAW,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAE5E;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,4BAA4B;;;;aAIvC,CAAC;AAEH,MAAM,MAAM,sBAAsB,GAAG,WAAW,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAEtF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,4BAA4B;;IAEvC,uEAAuE;;;;;aAKvE,CAAC;AAEH,MAAM,MAAM,sBAAsB,GAAG,WAAW,CAAC,OAAO,4BAA4B,CAAC,CAAC;AAEtF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,8BAA8B;;;aAGzC,CAAC;AAEH,MAAM,MAAM,wBAAwB,GAAG,WAAW,CAAC,OAAO,8BAA8B,CAAC,CAAC;AAE1F;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,YAAI,OAAO,EAAE,WAAW,CAAU,CAAC;AAEnE,eAAO,MAAM,uBAAuB,8EAAgC,CAAC;AAErE,MAAM,MAAM,iBAAiB,GAAG,WAAW,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAE5E;;;;;;;GAOG;AACH,eAAO,MAAM,mBAAmB,0KAG/B,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,6BAA6B;;;;;;QApHxC,mFAAmF;;QAEnF,6EAA6E;;;QAG7E,6EAA6E;;;;;aAiH7E,CAAC;AAEH,MAAM,MAAM,uBAAuB,GAAG,WAAW,CAAC,OAAO,6BAA6B,CAAC,CAAC;AAExF,+EAA+E;AAC/E,eAAO,MAAM,6BAA6B;;;;;;QA3HxC,mFAAmF;;QAEnF,6EAA6E;;;QAG7E,6EAA6E;;;;;aAwH7E,CAAC;AAEH,MAAM,MAAM,uBAAuB,GAAG,WAAW,CAAC,OAAO,6BAA6B,CAAC,CAAC;AAExF,2CAA2C;AAC3C,eAAO,MAAM,6BAA6B;;;;;aAExC,CAAC;AAEH,MAAM,MAAM,uBAAuB,GAAG,WAAW,CAAC,OAAO,6BAA6B,CAAC,CAAC;AAExF,+EAA+E;AAC/E,eAAO,MAAM,+BAA+B;;;;;aAE1C,CAAC;AAEH,MAAM,MAAM,yBAAyB,GAAG,WAAW,CAAC,OAAO,+BAA+B,CAAC,CAAC;AAE5F,gDAAgD;AAChD,eAAO,MAAM,6BAA6B;;;;;;QAtFxC,uEAAuE;;;;;;aAwFvE,CAAC;AAEH,MAAM,MAAM,uBAAuB,GAAG,WAAW,CAAC,OAAO,6BAA6B,CAAC,CAAC;AAExF,iDAAiD;AACjD,eAAO,MAAM,+BAA+B;;;;;aAE1C,CAAC;AAEH,MAAM,MAAM,yBAAyB,GAAG,WAAW,CAAC,OAAO,+BAA+B,CAAC,CAAC;AAE5F,wEAAwE;AACxE,eAAO,MAAM,iCAAiC;;;;;aAE5C,CAAC;AAEH,MAAM,MAAM,2BAA2B,GAAG,WAAW,CAAC,OAAO,iCAAiC,CAAC,CAAC"}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { createSuccessResponseSchema } from "@game-infra/api-schemas-core";
|
|
2
|
+
import { array, number, object, optional, picklist, pipe, regex, string, } from "valibot";
|
|
3
|
+
/**
|
|
4
|
+
* The transport for everything that changes because someone is *playing*: the
|
|
5
|
+
* sessions a player has in progress, and the journal each one is rebuilt from.
|
|
6
|
+
*
|
|
7
|
+
* This is the counterpart to `@game-infra/story-schemas`, and the split is the
|
|
8
|
+
* point. Design content is authored — one copy, shared by every player, the
|
|
9
|
+
* same tomorrow as today. Session state is transient and personal — one copy
|
|
10
|
+
* per player per playthrough, rewritten every turn, and meaningless to anyone
|
|
11
|
+
* else. Storing them in one resource meant one delete sweep, one listing and
|
|
12
|
+
* one retention policy for two things that share none of those.
|
|
13
|
+
*
|
|
14
|
+
* Two shapes carry all of it:
|
|
15
|
+
*
|
|
16
|
+
* - a **session** — the header of one playthrough: which world or campaign it
|
|
17
|
+
* plays, what to call it in a picker, and an opaque payload for whatever else
|
|
18
|
+
* the game needs to resume;
|
|
19
|
+
* - a **journal entry** — one recorded step of that session, numbered, so
|
|
20
|
+
* folding the entries in order rebuilds the session's whole state.
|
|
21
|
+
*
|
|
22
|
+
* As in design content, `data` is stored, indexed and returned but never parsed
|
|
23
|
+
* by the service, so a game's play vocabulary can move without a contract bump.
|
|
24
|
+
* The shapes that go *inside* it are typed too — see `freeformPayloads.ts` —
|
|
25
|
+
* but as schemas a client parses with, not as part of any endpoint.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* The scope of a session that plays nothing in particular. A session is
|
|
29
|
+
* normally scoped to the world or campaign it plays, but a game with a single
|
|
30
|
+
* unnamed setting has nothing to name, and a URL cannot carry an empty path
|
|
31
|
+
* segment, so unscoped sessions spell their scope `-`.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* import { ROOT_SESSION_SCOPE } from '@game-infra/game-state-schemas'
|
|
36
|
+
*
|
|
37
|
+
* const header = { scopeId: ROOT_SESSION_SCOPE, name: 'Slot 1', data: '{}' }
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export const ROOT_SESSION_SCOPE = "-";
|
|
41
|
+
/**
|
|
42
|
+
* One playthrough in progress, as the service holds it.
|
|
43
|
+
*
|
|
44
|
+
* A session belongs to the caller who created it: every read and write is
|
|
45
|
+
* filtered by user id, so two players in the same game never see each other's
|
|
46
|
+
* sessions.
|
|
47
|
+
*
|
|
48
|
+
* `data` is present only when the request asked for it — a session picker needs
|
|
49
|
+
* titles and timestamps, not the premise of every abandoned run.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* import type { GameSessionRecord } from '@game-infra/game-state-schemas'
|
|
54
|
+
*
|
|
55
|
+
* const session: GameSessionRecord = {
|
|
56
|
+
* sessionId: 'ash-and-embers',
|
|
57
|
+
* scopeId: 'the-ash-reckoning',
|
|
58
|
+
* name: 'Ash and Embers',
|
|
59
|
+
* data: JSON.stringify({ title: 'Ash and Embers', premise: 'A drift-runner…' }),
|
|
60
|
+
* entryCount: 12,
|
|
61
|
+
* createdAt: '2026-08-29T09:00:00.000Z',
|
|
62
|
+
* updatedAt: '2026-08-29T11:42:00.000Z',
|
|
63
|
+
* }
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export const GameSessionRecordSchema = object({
|
|
67
|
+
sessionId: string(),
|
|
68
|
+
/** The world, campaign or scenario being played, or {@link ROOT_SESSION_SCOPE}. */
|
|
69
|
+
scopeId: string(),
|
|
70
|
+
/** For display in a session picker, so a listing need not carry payloads. */
|
|
71
|
+
name: string(),
|
|
72
|
+
data: optional(string()),
|
|
73
|
+
/** How many journal entries the session holds — its length, for a picker. */
|
|
74
|
+
entryCount: optional(number()),
|
|
75
|
+
createdAt: string(),
|
|
76
|
+
updatedAt: string(),
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* Create or replace a session header. Re-saving the same id keeps its
|
|
80
|
+
* `createdAt` and leaves the journal alone, so renaming a session is one call.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* import type { GameSessionSaveRequest } from '@game-infra/game-state-schemas'
|
|
85
|
+
*
|
|
86
|
+
* const request: GameSessionSaveRequest = {
|
|
87
|
+
* scopeId: 'the-ash-reckoning',
|
|
88
|
+
* name: 'Ash and Embers',
|
|
89
|
+
* data: JSON.stringify({ title: 'Ash and Embers', premise: 'A drift-runner…' }),
|
|
90
|
+
* }
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export const GameSessionSaveRequestSchema = object({
|
|
94
|
+
scopeId: string(),
|
|
95
|
+
name: string(),
|
|
96
|
+
data: string(),
|
|
97
|
+
});
|
|
98
|
+
/**
|
|
99
|
+
* One recorded step of a session.
|
|
100
|
+
*
|
|
101
|
+
* `sequence` is the entry's position in the journal, counted from zero, and it
|
|
102
|
+
* is the whole ordering: entries come back in numeric order with no sort key to
|
|
103
|
+
* mint and no zero-padded id to keep sortable as text.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* import type { GameJournalEntryRecord } from '@game-infra/game-state-schemas'
|
|
108
|
+
*
|
|
109
|
+
* const entry: GameJournalEntryRecord = {
|
|
110
|
+
* sequence: 7,
|
|
111
|
+
* name: 'The tunnel breathes out ash',
|
|
112
|
+
* data: JSON.stringify({ input: 'I follow the draught', narration: '…' }),
|
|
113
|
+
* createdAt: '2026-08-29T11:40:00.000Z',
|
|
114
|
+
* updatedAt: '2026-08-29T11:40:00.000Z',
|
|
115
|
+
* }
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export const GameJournalEntryRecordSchema = object({
|
|
119
|
+
sequence: number(),
|
|
120
|
+
/** For display in a history list — a turn title, a chapter heading. */
|
|
121
|
+
name: string(),
|
|
122
|
+
data: optional(string()),
|
|
123
|
+
createdAt: string(),
|
|
124
|
+
updatedAt: string(),
|
|
125
|
+
});
|
|
126
|
+
/**
|
|
127
|
+
* Write one journal entry. Writing an existing `sequence` replaces it, which is
|
|
128
|
+
* how a turn is re-rolled or corrected without disturbing the ones after it.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```ts
|
|
132
|
+
* import type { GameJournalAppendRequest } from '@game-infra/game-state-schemas'
|
|
133
|
+
*
|
|
134
|
+
* const request: GameJournalAppendRequest = {
|
|
135
|
+
* name: 'The tunnel breathes out ash',
|
|
136
|
+
* data: JSON.stringify({ input: 'I follow the draught', narration: '…' }),
|
|
137
|
+
* }
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export const GameJournalAppendRequestSchema = object({
|
|
141
|
+
name: string(),
|
|
142
|
+
data: string(),
|
|
143
|
+
});
|
|
144
|
+
/**
|
|
145
|
+
* How far a journal delete reaches.
|
|
146
|
+
*
|
|
147
|
+
* - `entry` — that entry alone, for correcting one step in place.
|
|
148
|
+
* - `following` — that entry and every entry after it. This is undo: a journal
|
|
149
|
+
* is folded in order, so keeping the tail after dropping a step in the middle
|
|
150
|
+
* would replay effects that no longer have a cause.
|
|
151
|
+
*/
|
|
152
|
+
export const JOURNAL_TRUNCATIONS = ["entry", "following"];
|
|
153
|
+
export const JournalTruncationSchema = picklist(JOURNAL_TRUNCATIONS);
|
|
154
|
+
/**
|
|
155
|
+
* A journal position as it arrives in a path or query segment: digits, and
|
|
156
|
+
* short enough that no journal will ever reach it.
|
|
157
|
+
*
|
|
158
|
+
* Path segments are strings on the wire, so the shape is checked here — in the
|
|
159
|
+
* schema the contract references — rather than re-checked in every handler that
|
|
160
|
+
* reads one. Handlers take the number with `Number(...)`.
|
|
161
|
+
*/
|
|
162
|
+
export const SequenceParamSchema = pipe(string(), regex(/^\d{1,9}$/, "sequence must be a non-negative integer"));
|
|
163
|
+
/** The caller's sessions for this game, most recently played first. */
|
|
164
|
+
export const GameSessionListResponseSchema = createSuccessResponseSchema({
|
|
165
|
+
items: optional(array(GameSessionRecordSchema)),
|
|
166
|
+
});
|
|
167
|
+
/** One session header, absent when the caller has no session under that id. */
|
|
168
|
+
export const GameSessionLoadResponseSchema = createSuccessResponseSchema({
|
|
169
|
+
item: optional(GameSessionRecordSchema),
|
|
170
|
+
});
|
|
171
|
+
/** The saved session's id, echoed back. */
|
|
172
|
+
export const GameSessionSaveResponseSchema = createSuccessResponseSchema({
|
|
173
|
+
sessionId: optional(string()),
|
|
174
|
+
});
|
|
175
|
+
/** How many rows the delete removed — the session plus its journal entries. */
|
|
176
|
+
export const GameSessionDeleteResponseSchema = createSuccessResponseSchema({
|
|
177
|
+
deletedCount: optional(number()),
|
|
178
|
+
});
|
|
179
|
+
/** A session's journal, in `sequence` order. */
|
|
180
|
+
export const GameJournalListResponseSchema = createSuccessResponseSchema({
|
|
181
|
+
entries: optional(array(GameJournalEntryRecordSchema)),
|
|
182
|
+
});
|
|
183
|
+
/** The written entry's position, echoed back. */
|
|
184
|
+
export const GameJournalAppendResponseSchema = createSuccessResponseSchema({
|
|
185
|
+
sequence: optional(number()),
|
|
186
|
+
});
|
|
187
|
+
/** How many entries the truncation removed — 0 when nothing matched. */
|
|
188
|
+
export const GameJournalTruncateResponseSchema = createSuccessResponseSchema({
|
|
189
|
+
deletedCount: optional(number()),
|
|
190
|
+
});
|
|
191
|
+
//# sourceMappingURL=sessionState.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sessionState.js","sourceRoot":"","sources":["../src/sessionState.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@game-infra/game-state-schemas",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Play-state API contract: the sessions a player has in progress, the journal each one is rebuilt from, and freeform mode's turn and memory payloads",
|
|
5
|
+
"homepage": "https://github.com/kibertoad/game-infra/tree/main/packages/schemas/game-state-schemas#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/kibertoad/game-infra/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/kibertoad/game-infra.git",
|
|
13
|
+
"directory": "packages/schemas/game-state-schemas"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@toad-contracts/core": "0.4.0",
|
|
31
|
+
"@toad-contracts/valibot": "0.5.0",
|
|
32
|
+
"@game-infra/api-schemas-core": "0.3.0",
|
|
33
|
+
"@game-infra/event-schemas": "1.1.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^26.1.2",
|
|
37
|
+
"rimraf": "^6.1.3",
|
|
38
|
+
"typescript": "^7.0.2",
|
|
39
|
+
"valibot": "^1.4.2",
|
|
40
|
+
"vitest": "^4.1.10"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"valibot": "^1.4.0"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "rimraf dist && tsc --project tsconfig.build.json",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"clean": "rimraf dist"
|
|
50
|
+
}
|
|
51
|
+
}
|