@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Igor Savin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# @game-infra/game-state-schemas
|
|
2
|
+
|
|
3
|
+
API contract for **play state**: valibot schemas plus [toad-contracts](https://github.com/kibertoad/toad-contracts) `defineApiContract` definitions for the sessions a player has in progress, the journal each one is rebuilt from, and the payload shapes freeform mode puts inside them. Builds on `@game-infra/api-schemas-core` for the shared success-response wrapper.
|
|
4
|
+
|
|
5
|
+
Published to npm. Also consumable as sibling source (`exports` points at `src/`).
|
|
6
|
+
|
|
7
|
+
## The line this package draws
|
|
8
|
+
|
|
9
|
+
`@game-infra/story-schemas` carries what an **author** writes: one world, read by every player, the same tomorrow as today. This package carries what a **player** produces: one run, belonging to them alone, rewritten every turn, and gone when they abandon it.
|
|
10
|
+
|
|
11
|
+
| | design content (`story-schemas`) | play state (this package) |
|
|
12
|
+
| ------------ | -------------------------------- | ------------------------- |
|
|
13
|
+
| Written by | an author, once | a player, every turn |
|
|
14
|
+
| Read by | every player of the game | one player |
|
|
15
|
+
| Lifetime | as long as the game | as long as the run |
|
|
16
|
+
| Deleted with | the world | the session |
|
|
17
|
+
| Service | `game-content-service` | `game-state-service` |
|
|
18
|
+
|
|
19
|
+
Keeping both in one resource meant one listing, one delete sweep and one retention policy for two things that share none of those — and it meant a design database growing without bound with other people's abandoned runs.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```jsonc
|
|
24
|
+
// pnpm sibling checkout
|
|
25
|
+
"@game-infra/game-state-schemas": "link:../../game-infra/packages/schemas/game-state-schemas"
|
|
26
|
+
// or from npm
|
|
27
|
+
"@game-infra/game-state-schemas": "^0.1.0"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`valibot` is a peer dependency (`^1.4.0`); the consumer provides it. `@game-infra/api-schemas-core` and `@game-infra/event-schemas` are runtime dependencies — the first for the success-response wrapper, the second for the `ActivationDTO` effect vocabulary a freeform turn shares with an authored story branch.
|
|
31
|
+
|
|
32
|
+
## Two shapes, seven endpoints
|
|
33
|
+
|
|
34
|
+
A **session** is the header of one playthrough: the world or campaign it plays (`scopeId`), what to call it in a picker, and an opaque payload for whatever else the game needs to resume. A **journal entry** is one recorded step of that session, numbered from zero, so folding the entries in order rebuilds the session's whole state.
|
|
35
|
+
|
|
36
|
+
| Contract | Method + path | What it is for |
|
|
37
|
+
| ----------------------------- | ------------------------------------------- | --------------------------------------------- |
|
|
38
|
+
| `gameSessionListContract` | `GET /games/:gameId/sessions` | the caller's runs, most recently played first |
|
|
39
|
+
| `gameSessionLoadContract` | `GET /games/:gameId/sessions/:sessionId` | one session header |
|
|
40
|
+
| `gameSessionSaveContract` | `PUT /games/:gameId/sessions/:sessionId` | begin a run, or rename one |
|
|
41
|
+
| `gameSessionDeleteContract` | `DELETE /games/:gameId/sessions/:sessionId` | drop a run and its whole journal |
|
|
42
|
+
| `gameJournalListContract` | `GET …/:sessionId/journal` | the entries to fold, in `sequence` order |
|
|
43
|
+
| `gameJournalAppendContract` | `PUT …/:sessionId/journal/:sequence` | record one step |
|
|
44
|
+
| `gameJournalTruncateContract` | `DELETE …/:sessionId/journal/:sequence` | correct a step, or undo back to one |
|
|
45
|
+
|
|
46
|
+
Every route is scoped to the authenticated caller: a session id is unique per player, not per game, so two players may both call their first run `slot-1` and neither can read the other's.
|
|
47
|
+
|
|
48
|
+
`sequence` is the ordering, and it is an integer — there is no sort key to mint and no zero-padded id to keep sortable as text. `SequenceParamSchema` validates the path segment in the contract, so no handler re-checks it.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { gameJournalAppendContract, gameSessionListContract } from "@game-infra/game-state-schemas";
|
|
54
|
+
import { mapApiContractToPath } from "@toad-contracts/valibot";
|
|
55
|
+
|
|
56
|
+
gameSessionListContract.pathResolver({ gameId: "g1" }); // '/games/g1/sessions'
|
|
57
|
+
mapApiContractToPath(gameJournalAppendContract);
|
|
58
|
+
// '/games/:gameId/sessions/:sessionId/journal/:sequence'
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Register on a Hono service with `@toad-contracts/hono` (`buildHonoRoute`) and call from a frontend with `@toad-contracts/frontend-http-client` (`sendByApiContract`) — both drive off the same contract objects.
|
|
62
|
+
|
|
63
|
+
Undo is a truncation, not a delete:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// drop turn 12 and everything after it — a journal is folded in order, so
|
|
67
|
+
// keeping the tail would replay effects whose cause is gone
|
|
68
|
+
await sendByApiContract(http, gameJournalTruncateContract, {
|
|
69
|
+
pathParams: { gameId: "g1", sessionId: "s1", sequence: "12" },
|
|
70
|
+
queryParams: { truncate: "following" },
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Freeform mode — a journal, not a tree
|
|
75
|
+
|
|
76
|
+
Story mode stores a tree of pregenerated beats addressed by the choices taken to reach them. Freeform play follows the player's own words, so it stores a linear journal instead: one session, one entry per turn. Replaying the journal rebuilds the whole run — play state, cast and memory — so nothing else is stored.
|
|
77
|
+
|
|
78
|
+
`FreeformTurnPayloadSchema` is what goes inside an entry's `data`: the player's input, the narration, the neurons the adjudicator judged relevant (`focus`), the check it rolled (`ResolvedCheckDTOSchema` — rolled once, recorded forever, never re-rolled on replay), effects in the same `ActivationDTO` vocabulary a story branch carries, the characters the turn introduced (`IntroducedEntityDTOSchema`, id and name guaranteed and the game's own definition passed through), the memories it formed and the consolidations it performed. `FreeformSessionPayloadSchema` is the session header's payload.
|
|
79
|
+
|
|
80
|
+
Memory is a synapse graph. Each `MemoryDTO` connects to **neurons** — `character:tallow`, `faction:ash-wardens`, or a domain such as `domain:war` (see `FREEFORM_DOMAINS`, and `neuronKey`/`parseNeuronKey` for the spelling) — and recall walks those connections rather than the whole journal. Over time small memories consolidate: a `ConsolidationDTO` names the entries a single summary supersedes, so five small grievances become "many past grievances" on replay exactly as they did in play.
|
|
81
|
+
|
|
82
|
+
None of these payload schemas is attached to an endpoint — the service stores `data` opaquely — but two clients reading the same session must agree on the shapes.
|
|
83
|
+
|
|
84
|
+
## Savegames
|
|
85
|
+
|
|
86
|
+
Slot snapshots are `game-state-service`'s other half, but their contracts predate this package and stay where they are, in `@game-infra/api-schemas-core`: `savegameSaveContract`, `savegameLoadContract`, `savegameListSlotsContract`, at `/users/:userId/games/:gameId/saves/:saveSlot`. A published shape does not move just to sit next to its neighbours.
|
|
87
|
+
|
|
88
|
+
A save is a snapshot a player can return to; a session is the run they are in the middle of. A game may use either, or both — freeform mode uses only the journal, because the journal _is_ the save.
|
|
89
|
+
|
|
90
|
+
## Extension points
|
|
91
|
+
|
|
92
|
+
- **`data`**: opaque to the contract on both shapes, so a game evolves its play vocabulary without a contract bump.
|
|
93
|
+
- **`scopeId`**: the world, campaign or scenario a session plays. A game with a single unnamed setting uses `ROOT_SESSION_SCOPE`.
|
|
94
|
+
- **Domain neurons**: `FREEFORM_DOMAINS` is a suggested set, not a closed one — a domain neuron is just a key.
|
|
95
|
+
- **Effect vocabulary**: `type` is free text in `ActivationDTOSchema`, so each game declares the set it evaluates and drops the rest.
|
|
96
|
+
|
|
97
|
+
## Dependencies
|
|
98
|
+
|
|
99
|
+
`@game-infra/api-schemas-core` (the success-response wrapper), `@game-infra/event-schemas` (the `ActivationDTO` effect vocabulary), `@toad-contracts/core`, `@toad-contracts/valibot`; peer `valibot`.
|
|
100
|
+
|
|
101
|
+
## Consumers
|
|
102
|
+
|
|
103
|
+
`services/game-state-service` (the Hono service registering these routes) and the play clients that call them — currently the Fluid Emerald game client.
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The caller's sessions for this game, most recently played first.
|
|
3
|
+
*
|
|
4
|
+
* This is the "continue playing" listing: which runs exist, what to call them,
|
|
5
|
+
* how long each one is, and when it was last touched.
|
|
6
|
+
*/
|
|
7
|
+
export declare const gameSessionListContract: {
|
|
8
|
+
readonly method: "get";
|
|
9
|
+
readonly requestPathParamsSchema: import("valibot").ObjectSchema<{
|
|
10
|
+
readonly gameId: import("valibot").StringSchema<undefined>;
|
|
11
|
+
}, undefined> & import("@toad-contracts/valibot").StandardObjectKeysV1<unknown, unknown>;
|
|
12
|
+
readonly requestQuerySchema: import("valibot").ObjectSchema<{
|
|
13
|
+
readonly scopeId: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
14
|
+
readonly includeData: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
15
|
+
}, undefined>;
|
|
16
|
+
readonly pathResolver: ({ gameId }: {
|
|
17
|
+
gameId: string;
|
|
18
|
+
}) => string;
|
|
19
|
+
readonly responsesByStatusCode: {
|
|
20
|
+
200: import("valibot").ObjectSchema<{
|
|
21
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
22
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
23
|
+
} & {
|
|
24
|
+
items: import("valibot").OptionalSchema<import("valibot").ArraySchema<import("valibot").ObjectSchema<{
|
|
25
|
+
readonly sessionId: import("valibot").StringSchema<undefined>;
|
|
26
|
+
readonly scopeId: import("valibot").StringSchema<undefined>;
|
|
27
|
+
readonly name: import("valibot").StringSchema<undefined>;
|
|
28
|
+
readonly data: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
29
|
+
readonly entryCount: import("valibot").OptionalSchema<import("valibot").NumberSchema<undefined>, undefined>;
|
|
30
|
+
readonly createdAt: import("valibot").StringSchema<undefined>;
|
|
31
|
+
readonly updatedAt: import("valibot").StringSchema<undefined>;
|
|
32
|
+
}, undefined>, undefined>, undefined>;
|
|
33
|
+
}, undefined>;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
/** One session header, or `item: undefined` when the caller has no such session. */
|
|
37
|
+
export declare const gameSessionLoadContract: {
|
|
38
|
+
readonly method: "get";
|
|
39
|
+
readonly requestPathParamsSchema: import("valibot").ObjectSchema<{
|
|
40
|
+
readonly gameId: import("valibot").StringSchema<undefined>;
|
|
41
|
+
readonly sessionId: import("valibot").StringSchema<undefined>;
|
|
42
|
+
}, undefined> & import("@toad-contracts/valibot").StandardObjectKeysV1<unknown, unknown>;
|
|
43
|
+
readonly pathResolver: ({ gameId, sessionId }: {
|
|
44
|
+
gameId: string;
|
|
45
|
+
sessionId: string;
|
|
46
|
+
}) => string;
|
|
47
|
+
readonly responsesByStatusCode: {
|
|
48
|
+
200: import("valibot").ObjectSchema<{
|
|
49
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
50
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
51
|
+
} & {
|
|
52
|
+
item: import("valibot").OptionalSchema<import("valibot").ObjectSchema<{
|
|
53
|
+
readonly sessionId: import("valibot").StringSchema<undefined>;
|
|
54
|
+
readonly scopeId: import("valibot").StringSchema<undefined>;
|
|
55
|
+
readonly name: import("valibot").StringSchema<undefined>;
|
|
56
|
+
readonly data: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
57
|
+
readonly entryCount: import("valibot").OptionalSchema<import("valibot").NumberSchema<undefined>, undefined>;
|
|
58
|
+
readonly createdAt: import("valibot").StringSchema<undefined>;
|
|
59
|
+
readonly updatedAt: import("valibot").StringSchema<undefined>;
|
|
60
|
+
}, undefined>, undefined>;
|
|
61
|
+
}, undefined>;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Create or replace a session header.
|
|
66
|
+
*
|
|
67
|
+
* The journal is untouched, so this is both "begin a run" and "rename one".
|
|
68
|
+
* It is a `put` because the client names the session: an id it minted is the
|
|
69
|
+
* same session whether or not the request that created it was retried.
|
|
70
|
+
*/
|
|
71
|
+
export declare const gameSessionSaveContract: {
|
|
72
|
+
readonly method: "put";
|
|
73
|
+
readonly requestPathParamsSchema: import("valibot").ObjectSchema<{
|
|
74
|
+
readonly gameId: import("valibot").StringSchema<undefined>;
|
|
75
|
+
readonly sessionId: import("valibot").StringSchema<undefined>;
|
|
76
|
+
}, undefined> & import("@toad-contracts/valibot").StandardObjectKeysV1<unknown, unknown>;
|
|
77
|
+
readonly pathResolver: ({ gameId, sessionId }: {
|
|
78
|
+
gameId: string;
|
|
79
|
+
sessionId: string;
|
|
80
|
+
}) => string;
|
|
81
|
+
readonly requestBodySchema: import("valibot").ObjectSchema<{
|
|
82
|
+
readonly scopeId: import("valibot").StringSchema<undefined>;
|
|
83
|
+
readonly name: import("valibot").StringSchema<undefined>;
|
|
84
|
+
readonly data: import("valibot").StringSchema<undefined>;
|
|
85
|
+
}, undefined>;
|
|
86
|
+
readonly responsesByStatusCode: {
|
|
87
|
+
200: import("valibot").ObjectSchema<{
|
|
88
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
89
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
90
|
+
} & {
|
|
91
|
+
sessionId: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
92
|
+
}, undefined>;
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Delete a session and its whole journal.
|
|
97
|
+
*
|
|
98
|
+
* There is no cascade option because there is no other shape it could take: a
|
|
99
|
+
* journal without its session is unreadable, and a session without its journal
|
|
100
|
+
* is a run that cannot be resumed.
|
|
101
|
+
*/
|
|
102
|
+
export declare const gameSessionDeleteContract: {
|
|
103
|
+
readonly method: "delete";
|
|
104
|
+
readonly requestPathParamsSchema: import("valibot").ObjectSchema<{
|
|
105
|
+
readonly gameId: import("valibot").StringSchema<undefined>;
|
|
106
|
+
readonly sessionId: import("valibot").StringSchema<undefined>;
|
|
107
|
+
}, undefined> & import("@toad-contracts/valibot").StandardObjectKeysV1<unknown, unknown>;
|
|
108
|
+
readonly pathResolver: ({ gameId, sessionId }: {
|
|
109
|
+
gameId: string;
|
|
110
|
+
sessionId: string;
|
|
111
|
+
}) => string;
|
|
112
|
+
readonly responsesByStatusCode: {
|
|
113
|
+
200: import("valibot").ObjectSchema<{
|
|
114
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
115
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
116
|
+
} & {
|
|
117
|
+
deletedCount: import("valibot").OptionalSchema<import("valibot").NumberSchema<undefined>, undefined>;
|
|
118
|
+
}, undefined>;
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* A session's journal in `sequence` order — the entries a client folds to
|
|
123
|
+
* rebuild the run.
|
|
124
|
+
*
|
|
125
|
+
* `fromSequence` returns the entries at or after that position, which is how a
|
|
126
|
+
* client that already holds the first hundred turns asks only for the rest.
|
|
127
|
+
*/
|
|
128
|
+
export declare const gameJournalListContract: {
|
|
129
|
+
readonly method: "get";
|
|
130
|
+
readonly requestPathParamsSchema: import("valibot").ObjectSchema<{
|
|
131
|
+
readonly gameId: import("valibot").StringSchema<undefined>;
|
|
132
|
+
readonly sessionId: import("valibot").StringSchema<undefined>;
|
|
133
|
+
}, undefined> & import("@toad-contracts/valibot").StandardObjectKeysV1<unknown, unknown>;
|
|
134
|
+
readonly requestQuerySchema: import("valibot").ObjectSchema<{
|
|
135
|
+
readonly fromSequence: import("valibot").OptionalSchema<import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").RegexAction<string, "sequence must be a non-negative integer">]>, undefined>;
|
|
136
|
+
readonly includeData: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
137
|
+
}, undefined>;
|
|
138
|
+
readonly pathResolver: ({ gameId, sessionId }: {
|
|
139
|
+
gameId: string;
|
|
140
|
+
sessionId: string;
|
|
141
|
+
}) => string;
|
|
142
|
+
readonly responsesByStatusCode: {
|
|
143
|
+
200: import("valibot").ObjectSchema<{
|
|
144
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
145
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
146
|
+
} & {
|
|
147
|
+
entries: import("valibot").OptionalSchema<import("valibot").ArraySchema<import("valibot").ObjectSchema<{
|
|
148
|
+
readonly sequence: import("valibot").NumberSchema<undefined>;
|
|
149
|
+
readonly name: import("valibot").StringSchema<undefined>;
|
|
150
|
+
readonly data: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
151
|
+
readonly createdAt: import("valibot").StringSchema<undefined>;
|
|
152
|
+
readonly updatedAt: import("valibot").StringSchema<undefined>;
|
|
153
|
+
}, undefined>, undefined>, undefined>;
|
|
154
|
+
}, undefined>;
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Write one journal entry at `sequence`.
|
|
159
|
+
*
|
|
160
|
+
* Writing a position that already exists replaces it, so re-recording a turn is
|
|
161
|
+
* the same call as recording it. The session's `updatedAt` moves with it, which
|
|
162
|
+
* is what keeps the "continue playing" listing in the order a player expects.
|
|
163
|
+
*/
|
|
164
|
+
export declare const gameJournalAppendContract: {
|
|
165
|
+
readonly method: "put";
|
|
166
|
+
readonly requestPathParamsSchema: import("valibot").ObjectSchema<{
|
|
167
|
+
readonly gameId: import("valibot").StringSchema<undefined>;
|
|
168
|
+
readonly sessionId: import("valibot").StringSchema<undefined>;
|
|
169
|
+
readonly sequence: import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").RegexAction<string, "sequence must be a non-negative integer">]>;
|
|
170
|
+
}, undefined> & import("@toad-contracts/valibot").StandardObjectKeysV1<unknown, unknown>;
|
|
171
|
+
readonly pathResolver: ({ gameId, sessionId, sequence }: {
|
|
172
|
+
gameId: string;
|
|
173
|
+
sessionId: string;
|
|
174
|
+
sequence: string;
|
|
175
|
+
}) => string;
|
|
176
|
+
readonly requestBodySchema: import("valibot").ObjectSchema<{
|
|
177
|
+
readonly name: import("valibot").StringSchema<undefined>;
|
|
178
|
+
readonly data: import("valibot").StringSchema<undefined>;
|
|
179
|
+
}, undefined>;
|
|
180
|
+
readonly responsesByStatusCode: {
|
|
181
|
+
200: import("valibot").ObjectSchema<{
|
|
182
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
183
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
184
|
+
} & {
|
|
185
|
+
sequence: import("valibot").OptionalSchema<import("valibot").NumberSchema<undefined>, undefined>;
|
|
186
|
+
}, undefined>;
|
|
187
|
+
};
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Drop a journal entry, and — with `?truncate=following` — everything after it.
|
|
191
|
+
*
|
|
192
|
+
* `following` is undo. A journal is folded in order, so dropping a step in the
|
|
193
|
+
* middle and keeping the tail would replay effects whose cause is gone; the
|
|
194
|
+
* default `entry` is for correcting the last step in place.
|
|
195
|
+
*/
|
|
196
|
+
export declare const gameJournalTruncateContract: {
|
|
197
|
+
readonly method: "delete";
|
|
198
|
+
readonly requestPathParamsSchema: import("valibot").ObjectSchema<{
|
|
199
|
+
readonly gameId: import("valibot").StringSchema<undefined>;
|
|
200
|
+
readonly sessionId: import("valibot").StringSchema<undefined>;
|
|
201
|
+
readonly sequence: import("valibot").SchemaWithPipe<readonly [import("valibot").StringSchema<undefined>, import("valibot").RegexAction<string, "sequence must be a non-negative integer">]>;
|
|
202
|
+
}, undefined> & import("@toad-contracts/valibot").StandardObjectKeysV1<unknown, unknown>;
|
|
203
|
+
readonly requestQuerySchema: import("valibot").ObjectSchema<{
|
|
204
|
+
readonly truncate: import("valibot").OptionalSchema<import("valibot").PicklistSchema<readonly ["entry", "following"], undefined>, undefined>;
|
|
205
|
+
}, undefined>;
|
|
206
|
+
readonly pathResolver: ({ gameId, sessionId, sequence }: {
|
|
207
|
+
gameId: string;
|
|
208
|
+
sessionId: string;
|
|
209
|
+
sequence: string;
|
|
210
|
+
}) => string;
|
|
211
|
+
readonly responsesByStatusCode: {
|
|
212
|
+
200: import("valibot").ObjectSchema<{
|
|
213
|
+
readonly success: import("valibot").BooleanSchema<undefined>;
|
|
214
|
+
readonly error: import("valibot").OptionalSchema<import("valibot").StringSchema<undefined>, undefined>;
|
|
215
|
+
} & {
|
|
216
|
+
deletedCount: import("valibot").OptionalSchema<import("valibot").NumberSchema<undefined>, undefined>;
|
|
217
|
+
}, undefined>;
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
//# sourceMappingURL=contracts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contracts.d.ts","sourceRoot":"","sources":["../src/contracts.ts"],"names":[],"mappings":"AA6EA;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;QAKT,GAAG;;;;;;;;;;;;;;;CAC5B,CAAC;AAEH,oFAAoF;AACpF,eAAO,MAAM,uBAAuB;;;;;;;;;;;QAIT,GAAG;;;;;;;;;;;;;;;CAC5B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;QAKT,GAAG;;;;;;;CAC5B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB;;;;;;;;;;;QAIX,GAAG;;;;;;;CAC5B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;QAKT,GAAG;;;;;;;;;;;;;CAC5B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;QAMX,GAAG;;;;;;;CAC5B,CAAC;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B;;;;;;;;;;;;;;;;QAMb,GAAG;;;;;;;CAC5B,CAAC"}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { defineApiContract, withObjectKeys } from "@toad-contracts/valibot";
|
|
2
|
+
import { object, optional, string } from "valibot";
|
|
3
|
+
import { GameJournalAppendRequestSchema, GameJournalAppendResponseSchema, GameJournalListResponseSchema, GameJournalTruncateResponseSchema, GameSessionDeleteResponseSchema, GameSessionListResponseSchema, GameSessionLoadResponseSchema, GameSessionSaveRequestSchema, GameSessionSaveResponseSchema, JournalTruncationSchema, SequenceParamSchema, } from "./sessionState.js";
|
|
4
|
+
/**
|
|
5
|
+
* Typed endpoint contracts for play state: the sessions a player has in
|
|
6
|
+
* progress, and the journal each one is rebuilt from.
|
|
7
|
+
*
|
|
8
|
+
* They live behind `/games/:gameId/sessions`, and `game-state-service`
|
|
9
|
+
* implements them. Savegames — slot snapshots of a finished or paused run —
|
|
10
|
+
* are the same service's other half, but their contracts predate this package
|
|
11
|
+
* and stay in `@game-infra/api-schemas-core` (`savegameSaveContract` and
|
|
12
|
+
* friends) so that already-published shape does not move.
|
|
13
|
+
*
|
|
14
|
+
* Every route is scoped to the authenticated caller. A session id is unique per
|
|
15
|
+
* player, not per game: two players may both call their first run `slot-1`
|
|
16
|
+
* without colliding, and neither can read the other's.
|
|
17
|
+
*
|
|
18
|
+
* Register them on a Hono service with `@toad-contracts/hono` (`buildHonoRoute`)
|
|
19
|
+
* and call them from a client with `@toad-contracts/frontend-http-client`
|
|
20
|
+
* (`sendByApiContract`) — both drive off these same objects.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { gameSessionListContract, gameJournalAppendContract } from '@game-infra/game-state-schemas'
|
|
25
|
+
* import { mapApiContractToPath } from '@toad-contracts/valibot'
|
|
26
|
+
*
|
|
27
|
+
* gameSessionListContract.pathResolver({ gameId: 'g1' }) // '/games/g1/sessions'
|
|
28
|
+
* gameJournalAppendContract.pathResolver({ gameId: 'g1', sessionId: 's1', sequence: '7' })
|
|
29
|
+
* // '/games/g1/sessions/s1/journal/7'
|
|
30
|
+
* mapApiContractToPath(gameJournalAppendContract)
|
|
31
|
+
* // '/games/:gameId/sessions/:sessionId/journal/:sequence'
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
const gameParams = withObjectKeys(object({ gameId: string() }));
|
|
35
|
+
const sessionParams = withObjectKeys(object({ gameId: string(), sessionId: string() }));
|
|
36
|
+
const entryParams = withObjectKeys(object({ gameId: string(), sessionId: string(), sequence: SequenceParamSchema }));
|
|
37
|
+
/**
|
|
38
|
+
* `scopeId` narrows to the sessions playing one world or campaign;
|
|
39
|
+
* `includeData=true` opts into the headers' payloads, which a picker does not
|
|
40
|
+
* need and a resume does.
|
|
41
|
+
*/
|
|
42
|
+
const sessionListQuery = object({
|
|
43
|
+
scopeId: optional(string()),
|
|
44
|
+
includeData: optional(string()),
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* `fromSequence` resumes a journal a client already holds part of, and
|
|
48
|
+
* `includeData=false` asks for the shape of the journal without its payloads —
|
|
49
|
+
* the inverse default from a session listing, because a journal is normally
|
|
50
|
+
* read to be replayed.
|
|
51
|
+
*/
|
|
52
|
+
const journalListQuery = object({
|
|
53
|
+
fromSequence: optional(SequenceParamSchema),
|
|
54
|
+
includeData: optional(string()),
|
|
55
|
+
});
|
|
56
|
+
/** `truncate` is `entry` (default) or `following`. */
|
|
57
|
+
const journalDeleteQuery = object({ truncate: optional(JournalTruncationSchema) });
|
|
58
|
+
/**
|
|
59
|
+
* The caller's sessions for this game, most recently played first.
|
|
60
|
+
*
|
|
61
|
+
* This is the "continue playing" listing: which runs exist, what to call them,
|
|
62
|
+
* how long each one is, and when it was last touched.
|
|
63
|
+
*/
|
|
64
|
+
export const gameSessionListContract = defineApiContract({
|
|
65
|
+
method: "get",
|
|
66
|
+
requestPathParamsSchema: gameParams,
|
|
67
|
+
requestQuerySchema: sessionListQuery,
|
|
68
|
+
pathResolver: ({ gameId }) => `/games/${gameId}/sessions`,
|
|
69
|
+
responsesByStatusCode: { 200: GameSessionListResponseSchema },
|
|
70
|
+
});
|
|
71
|
+
/** One session header, or `item: undefined` when the caller has no such session. */
|
|
72
|
+
export const gameSessionLoadContract = defineApiContract({
|
|
73
|
+
method: "get",
|
|
74
|
+
requestPathParamsSchema: sessionParams,
|
|
75
|
+
pathResolver: ({ gameId, sessionId }) => `/games/${gameId}/sessions/${sessionId}`,
|
|
76
|
+
responsesByStatusCode: { 200: GameSessionLoadResponseSchema },
|
|
77
|
+
});
|
|
78
|
+
/**
|
|
79
|
+
* Create or replace a session header.
|
|
80
|
+
*
|
|
81
|
+
* The journal is untouched, so this is both "begin a run" and "rename one".
|
|
82
|
+
* It is a `put` because the client names the session: an id it minted is the
|
|
83
|
+
* same session whether or not the request that created it was retried.
|
|
84
|
+
*/
|
|
85
|
+
export const gameSessionSaveContract = defineApiContract({
|
|
86
|
+
method: "put",
|
|
87
|
+
requestPathParamsSchema: sessionParams,
|
|
88
|
+
pathResolver: ({ gameId, sessionId }) => `/games/${gameId}/sessions/${sessionId}`,
|
|
89
|
+
requestBodySchema: GameSessionSaveRequestSchema,
|
|
90
|
+
responsesByStatusCode: { 200: GameSessionSaveResponseSchema },
|
|
91
|
+
});
|
|
92
|
+
/**
|
|
93
|
+
* Delete a session and its whole journal.
|
|
94
|
+
*
|
|
95
|
+
* There is no cascade option because there is no other shape it could take: a
|
|
96
|
+
* journal without its session is unreadable, and a session without its journal
|
|
97
|
+
* is a run that cannot be resumed.
|
|
98
|
+
*/
|
|
99
|
+
export const gameSessionDeleteContract = defineApiContract({
|
|
100
|
+
method: "delete",
|
|
101
|
+
requestPathParamsSchema: sessionParams,
|
|
102
|
+
pathResolver: ({ gameId, sessionId }) => `/games/${gameId}/sessions/${sessionId}`,
|
|
103
|
+
responsesByStatusCode: { 200: GameSessionDeleteResponseSchema },
|
|
104
|
+
});
|
|
105
|
+
/**
|
|
106
|
+
* A session's journal in `sequence` order — the entries a client folds to
|
|
107
|
+
* rebuild the run.
|
|
108
|
+
*
|
|
109
|
+
* `fromSequence` returns the entries at or after that position, which is how a
|
|
110
|
+
* client that already holds the first hundred turns asks only for the rest.
|
|
111
|
+
*/
|
|
112
|
+
export const gameJournalListContract = defineApiContract({
|
|
113
|
+
method: "get",
|
|
114
|
+
requestPathParamsSchema: sessionParams,
|
|
115
|
+
requestQuerySchema: journalListQuery,
|
|
116
|
+
pathResolver: ({ gameId, sessionId }) => `/games/${gameId}/sessions/${sessionId}/journal`,
|
|
117
|
+
responsesByStatusCode: { 200: GameJournalListResponseSchema },
|
|
118
|
+
});
|
|
119
|
+
/**
|
|
120
|
+
* Write one journal entry at `sequence`.
|
|
121
|
+
*
|
|
122
|
+
* Writing a position that already exists replaces it, so re-recording a turn is
|
|
123
|
+
* the same call as recording it. The session's `updatedAt` moves with it, which
|
|
124
|
+
* is what keeps the "continue playing" listing in the order a player expects.
|
|
125
|
+
*/
|
|
126
|
+
export const gameJournalAppendContract = defineApiContract({
|
|
127
|
+
method: "put",
|
|
128
|
+
requestPathParamsSchema: entryParams,
|
|
129
|
+
pathResolver: ({ gameId, sessionId, sequence }) => `/games/${gameId}/sessions/${sessionId}/journal/${sequence}`,
|
|
130
|
+
requestBodySchema: GameJournalAppendRequestSchema,
|
|
131
|
+
responsesByStatusCode: { 200: GameJournalAppendResponseSchema },
|
|
132
|
+
});
|
|
133
|
+
/**
|
|
134
|
+
* Drop a journal entry, and — with `?truncate=following` — everything after it.
|
|
135
|
+
*
|
|
136
|
+
* `following` is undo. A journal is folded in order, so dropping a step in the
|
|
137
|
+
* middle and keeping the tail would replay effects whose cause is gone; the
|
|
138
|
+
* default `entry` is for correcting the last step in place.
|
|
139
|
+
*/
|
|
140
|
+
export const gameJournalTruncateContract = defineApiContract({
|
|
141
|
+
method: "delete",
|
|
142
|
+
requestPathParamsSchema: entryParams,
|
|
143
|
+
requestQuerySchema: journalDeleteQuery,
|
|
144
|
+
pathResolver: ({ gameId, sessionId, sequence }) => `/games/${gameId}/sessions/${sessionId}/journal/${sequence}`,
|
|
145
|
+
responsesByStatusCode: { 200: GameJournalTruncateResponseSchema },
|
|
146
|
+
});
|
|
147
|
+
//# sourceMappingURL=contracts.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contracts.js","sourceRoot":"","sources":["../src/contracts.ts"],"names":[],"mappings":"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"}
|