castle-web-sdk 0.4.45 → 0.4.47
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/README.md +526 -53
- package/dist/agent.js +4 -4
- package/dist/avatar.d.ts +16 -0
- package/dist/avatar.js +31 -0
- package/dist/castle.d.ts +10 -1
- package/dist/castle.js +5 -0
- package/dist/commands.d.ts +237 -0
- package/dist/commands.js +16 -0
- package/dist/context.d.ts +6 -0
- package/dist/experiments.d.ts +15 -0
- package/dist/experiments.js +83 -0
- package/dist/hub.d.ts +89 -0
- package/dist/hub.js +120 -0
- package/dist/multiplayer.d.ts +86 -6
- package/dist/multiplayer.js +226 -9
- package/dist/save.d.ts +197 -0
- package/dist/save.js +526 -0
- package/dist/saveCoordinator.d.ts +39 -0
- package/dist/saveCoordinator.js +252 -0
- package/dist/server/hub.d.ts +256 -0
- package/dist/server/hub.js +195 -0
- package/dist/server/platformHandle.d.ts +80 -3
- package/dist/server/storage.d.ts +71 -4
- package/dist/server/storage.js +71 -5
- package/dist/server/wrapper.d.ts +123 -9
- package/dist/server/wrapper.js +478 -38
- package/dist/store.d.ts +3 -2
- package/dist/store.js +5 -0
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +13 -1
- package/package.json +1 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { CastleError } from "../errors";
|
|
2
|
+
const DOOR_ID = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
3
|
+
export function hubArrival(raw) {
|
|
4
|
+
if (!raw || typeof raw !== "object")
|
|
5
|
+
return null;
|
|
6
|
+
const { fromDeckId, doorId } = raw;
|
|
7
|
+
if (typeof fromDeckId !== "string" || fromDeckId.length === 0 || fromDeckId.length > 64) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
fromDeckId,
|
|
12
|
+
doorId: typeof doorId === "string" && DOOR_ID.test(doorId) ? doorId : null,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
// The co-present users a block hides from a joining hub player, as the host
|
|
16
|
+
// sends them on the join (`player.hiddenUserIds`): decimal user ids, or null
|
|
17
|
+
// when there are none or the list is malformed.
|
|
18
|
+
const HIDDEN_USER_ID = /^\d{1,20}$/;
|
|
19
|
+
const HIDDEN_USER_IDS_MAX = 1000;
|
|
20
|
+
export function recordHiddenUserIds(hidden, playerId, raw) {
|
|
21
|
+
const ids = hubHiddenUserIds(raw);
|
|
22
|
+
if (ids) {
|
|
23
|
+
hidden.set(playerId, ids);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
hidden.delete(playerId);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function hubHiddenUserIds(raw) {
|
|
30
|
+
if (!raw || typeof raw !== "object")
|
|
31
|
+
return null;
|
|
32
|
+
const list = raw.hiddenUserIds;
|
|
33
|
+
if (!Array.isArray(list) || list.length === 0)
|
|
34
|
+
return null;
|
|
35
|
+
const ids = new Set();
|
|
36
|
+
for (const id of list.slice(0, HIDDEN_USER_IDS_MAX)) {
|
|
37
|
+
if (typeof id === "string" && HIDDEN_USER_ID.test(id))
|
|
38
|
+
ids.add(id);
|
|
39
|
+
}
|
|
40
|
+
return ids.size > 0 ? ids : null;
|
|
41
|
+
}
|
|
42
|
+
function unavailable(op, what) {
|
|
43
|
+
return new CastleError({
|
|
44
|
+
code: "CAULDRON_HUB_UNAVAILABLE",
|
|
45
|
+
message: `This host's runtime cannot ${what}.`,
|
|
46
|
+
operation: `session.hub.${op}`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function createServerHub(platformHandle, arrivals, hiddenPair = () => false) {
|
|
50
|
+
const pinned = platformHandle.hub;
|
|
51
|
+
if (!pinned ||
|
|
52
|
+
typeof platformHandle.hubResult !== "function" ||
|
|
53
|
+
typeof platformHandle.hubPadUpdate !== "function" ||
|
|
54
|
+
typeof platformHandle.hubResultsRead !== "function") {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const call = async (op, args) => {
|
|
58
|
+
const raw = await platformHandle[op](JSON.stringify(args));
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(raw);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
parsed = null;
|
|
65
|
+
}
|
|
66
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
67
|
+
throw new CastleError({
|
|
68
|
+
code: "CAULDRON_HUB_INVALID_RESPONSE",
|
|
69
|
+
message: `The platform returned an invalid ${op} response.`,
|
|
70
|
+
operation: `session.hub.${op}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return parsed;
|
|
74
|
+
};
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
place: typeof pinned.place === "string" ? pinned.place : null,
|
|
77
|
+
title: typeof pinned.title === "string" ? pinned.title : null,
|
|
78
|
+
result: pinned.result ?? null,
|
|
79
|
+
...(typeof pinned.owner === "string" && pinned.owner.length > 0 ? { owner: pinned.owner } : {}),
|
|
80
|
+
arrival(playerId) {
|
|
81
|
+
return arrivals.get(playerId) ?? null;
|
|
82
|
+
},
|
|
83
|
+
hiddenPair(playerIdA, playerIdB) {
|
|
84
|
+
return hiddenPair(playerIdA, playerIdB);
|
|
85
|
+
},
|
|
86
|
+
async friendsAmong(playerId, userIds) {
|
|
87
|
+
const json = JSON.stringify({ playerId, userIds: [...userIds] });
|
|
88
|
+
const raw = typeof platformHandle.hubFriendsAmong === "function"
|
|
89
|
+
? await platformHandle.hubFriendsAmong(json)
|
|
90
|
+
: await platformHandle.storage?.("hubFriendsAmong", json);
|
|
91
|
+
const answer = JSON.parse(raw ?? "null");
|
|
92
|
+
return Array.isArray(answer?.userIds) ? answer.userIds : [];
|
|
93
|
+
},
|
|
94
|
+
async relationsAmong(playerId, userIds) {
|
|
95
|
+
const asked = new Set(userIds);
|
|
96
|
+
const json = JSON.stringify({ playerId, userIds: [...userIds] });
|
|
97
|
+
let raw;
|
|
98
|
+
if (typeof platformHandle.hubFriendsAmong === "function") {
|
|
99
|
+
raw = await platformHandle.hubFriendsAmong(json);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
raw = await platformHandle.storage?.("hubFriendsAmong", json);
|
|
103
|
+
}
|
|
104
|
+
const answer = JSON.parse(raw ?? "null");
|
|
105
|
+
const list = (value) => Array.isArray(value)
|
|
106
|
+
? value.filter((id) => typeof id === "string" && asked.has(id))
|
|
107
|
+
: [];
|
|
108
|
+
return {
|
|
109
|
+
friends: list(answer?.userIds),
|
|
110
|
+
follows: list(answer?.follows),
|
|
111
|
+
followedBy: list(answer?.followedBy),
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
async reportResult(playerId, result) {
|
|
115
|
+
const { place, outcome, score, stats } = result ?? {};
|
|
116
|
+
return (await call("hubResult", {
|
|
117
|
+
playerId,
|
|
118
|
+
...(place === undefined ? {} : { place }),
|
|
119
|
+
outcome,
|
|
120
|
+
...(score === undefined || score === null ? {} : { score }),
|
|
121
|
+
...(stats === undefined ? {} : { stats }),
|
|
122
|
+
}));
|
|
123
|
+
},
|
|
124
|
+
async updatePad(input) {
|
|
125
|
+
return (await call("hubPadUpdate", {
|
|
126
|
+
doorId: input?.doorId,
|
|
127
|
+
padId: input?.padId,
|
|
128
|
+
memberUserIds: [...(input?.memberUserIds ?? [])],
|
|
129
|
+
...(input?.fire === true ? { fire: true } : {}),
|
|
130
|
+
}));
|
|
131
|
+
},
|
|
132
|
+
async readResults(input = {}) {
|
|
133
|
+
const read = await call("hubResultsRead", {
|
|
134
|
+
...(input.userIds ? { userIds: [...input.userIds] } : {}),
|
|
135
|
+
...(input.doorIds ? { doorIds: [...input.doorIds] } : {}),
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
players: (read.players ?? {}),
|
|
139
|
+
boards: (read.boards ?? {}),
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
async wear(playerId, wearable) {
|
|
143
|
+
if (typeof platformHandle.hubWear !== "function") {
|
|
144
|
+
throw new CastleError({
|
|
145
|
+
code: "CAULDRON_HUB_UNAVAILABLE",
|
|
146
|
+
message: "This host's runtime cannot change wearables.",
|
|
147
|
+
operation: "session.hub.hubWear",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const receipt = await call("hubWear", {
|
|
151
|
+
playerId,
|
|
152
|
+
wearable: wearable === null || wearable === undefined
|
|
153
|
+
? null
|
|
154
|
+
: { kind: wearable.kind, color: wearable.color, rarity: wearable.rarity },
|
|
155
|
+
});
|
|
156
|
+
return {
|
|
157
|
+
wearable: (receipt.wearable ?? null),
|
|
158
|
+
};
|
|
159
|
+
},
|
|
160
|
+
async unlock(playerId, itemId) {
|
|
161
|
+
if (typeof platformHandle.hubUnlock !== "function") {
|
|
162
|
+
throw unavailable("hubUnlock", "give earned items");
|
|
163
|
+
}
|
|
164
|
+
const receipt = await call("hubUnlock", { playerId, itemId });
|
|
165
|
+
return {
|
|
166
|
+
unlocked: true,
|
|
167
|
+
isNew: receipt.isNew === true,
|
|
168
|
+
earned: (receipt.earned ?? null),
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
async wearEarned(playerId, earned) {
|
|
172
|
+
if (typeof platformHandle.hubWearEarned !== "function") {
|
|
173
|
+
throw unavailable("hubWearEarned", "choose earned items");
|
|
174
|
+
}
|
|
175
|
+
const receipt = await call("hubWearEarned", {
|
|
176
|
+
playerId,
|
|
177
|
+
earned: earned === null || earned === undefined
|
|
178
|
+
? null
|
|
179
|
+
: { deckId: earned.deckId, id: earned.id },
|
|
180
|
+
});
|
|
181
|
+
return {
|
|
182
|
+
earned: (receipt.earned ?? null),
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
async readUnlocks(userIds) {
|
|
186
|
+
if (typeof platformHandle.hubUnlocksRead !== "function") {
|
|
187
|
+
throw unavailable("hubUnlocksRead", "read earned items");
|
|
188
|
+
}
|
|
189
|
+
const read = await call("hubUnlocksRead", { userIds: [...(userIds ?? [])] });
|
|
190
|
+
return {
|
|
191
|
+
players: (read.players ?? {}),
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Json } from "../types";
|
|
2
|
+
import type { MultiplayerWorldCharacterCommand } from "../multiplayer";
|
|
2
3
|
export type MultiplayerSessionMode = "named" | "party" | "public";
|
|
3
4
|
export interface PlayerIdentity {
|
|
4
5
|
userId: string;
|
|
@@ -13,6 +14,16 @@ export interface PlatformSessionConfig {
|
|
|
13
14
|
readonly tickRate: number;
|
|
14
15
|
readonly maxPlayers: number;
|
|
15
16
|
readonly world?: PlatformWorldSnapshot;
|
|
17
|
+
/**
|
|
18
|
+
* True when the host brokers a hub place's shared characters into this
|
|
19
|
+
* session without a world (a layer of the place). Absent on hosts that do not.
|
|
20
|
+
*/
|
|
21
|
+
readonly placeCharacters?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The place this session was started for, stamped by a host that placed it
|
|
24
|
+
* through a door (the hub's door table). Absent on hosts that do not.
|
|
25
|
+
*/
|
|
26
|
+
readonly place?: string;
|
|
16
27
|
}
|
|
17
28
|
export interface PlatformWorldCheckpoint {
|
|
18
29
|
version: number;
|
|
@@ -21,6 +32,11 @@ export interface PlatformWorldCheckpoint {
|
|
|
21
32
|
export interface PlatformWorldSnapshot {
|
|
22
33
|
revision: string;
|
|
23
34
|
checkpoint: PlatformWorldCheckpoint | null;
|
|
35
|
+
/** Selected destination timing policy, stamped by hosts that support catch-up. */
|
|
36
|
+
worldTime?: PlatformWorldTimeConfig;
|
|
37
|
+
}
|
|
38
|
+
export interface PlatformWorldTimeConfig {
|
|
39
|
+
advance: "wallClock";
|
|
24
40
|
}
|
|
25
41
|
export interface PlatformHandle {
|
|
26
42
|
send(connId: string, text: string): void;
|
|
@@ -28,7 +44,7 @@ export interface PlatformHandle {
|
|
|
28
44
|
sendBinary?(connId: string, bytes: Uint8Array | ArrayBuffer): void;
|
|
29
45
|
broadcastBinary?(bytes: Uint8Array | ArrayBuffer): void;
|
|
30
46
|
disconnect(connId: string, code: string | number): void;
|
|
31
|
-
on(event: "join", callback: (connId: string, player: PlayerIdentity) => unknown): void;
|
|
47
|
+
on(event: "join", callback: (connId: string, player: PlayerIdentity, arrival?: PlatformHubArrival, experimentVariants?: Record<string, string>) => unknown): void;
|
|
32
48
|
on(event: "leave", callback: (connId: string) => unknown): void;
|
|
33
49
|
on(event: "message", callback: (connId: string, text: string) => unknown): void;
|
|
34
50
|
on(event: "binaryMessage", callback: (connId: string, bytes: Uint8Array) => unknown): void;
|
|
@@ -37,6 +53,7 @@ export interface PlatformHandle {
|
|
|
37
53
|
on(event: "worldCharacterTurn", callback: (request: PlatformWorldCharacterTurn) => unknown): void;
|
|
38
54
|
on(event: "worldCharacterCommand", callback: (request: PlatformWorldCharacterCommand) => unknown): void;
|
|
39
55
|
on(event: "worldCharacterRead", callback: (request: PlatformWorldCharacterRead) => unknown): void;
|
|
56
|
+
on(event: "worldCharacterTool", callback: (request: PlatformWorldCharacterTool) => unknown): void;
|
|
40
57
|
/**
|
|
41
58
|
* Deck storage. Strings in and strings out, like the rest of this handle, so a
|
|
42
59
|
* runtime other than the Node shim can implement it without knowing what storage is.
|
|
@@ -52,14 +69,60 @@ export interface PlatformHandle {
|
|
|
52
69
|
/** Persistent-world checkpoint broker. The host stamps the hidden world binding. */
|
|
53
70
|
worldCheckpointRead?(): Promise<string>;
|
|
54
71
|
worldCheckpointCommit?(argsJson: string): Promise<string>;
|
|
55
|
-
|
|
72
|
+
/** `tools` is the turn's place tools, passed only when non-empty. */
|
|
73
|
+
worldCharacterSend?(grantId: string, tools?: readonly PlatformPlaceTool[]): Promise<string>;
|
|
56
74
|
worldCharacterCommand?(grantId: string): Promise<string>;
|
|
57
75
|
worldCharacterRead?(grantId: string): Promise<string>;
|
|
58
76
|
worldCharacterRuntimeCreate?(argsJson: string): Promise<string>;
|
|
59
77
|
worldCharacterRuntimeList?(): Promise<string>;
|
|
60
78
|
worldCharacterReject?(grantId: string, code?: string): void;
|
|
79
|
+
/**
|
|
80
|
+
* Answers a `worldCharacterTool` event with at most 1 KiB of UTF-8. Present only
|
|
81
|
+
* when the host serves place tools, which is also what lets a send declare them.
|
|
82
|
+
*/
|
|
83
|
+
worldCharacterToolResult?(requestId: string, result: string): Promise<string>;
|
|
84
|
+
/**
|
|
85
|
+
* The hub place this session runs, as ghost pinned it from the published
|
|
86
|
+
* castle.json `hub` block. Present only when the host services the hub ops
|
|
87
|
+
* below, so a deck without a hub block never sees any of them.
|
|
88
|
+
*/
|
|
89
|
+
readonly hub?: Readonly<PlatformHubPlace>;
|
|
90
|
+
hubResult?(argsJson: string): Promise<string>;
|
|
91
|
+
hubPadUpdate?(argsJson: string): Promise<string>;
|
|
92
|
+
hubResultsRead?(argsJson: string): Promise<string>;
|
|
93
|
+
/** Friends among candidates (hub contract v1.36); older images use storage. */
|
|
94
|
+
hubFriendsAmong?(argsJson: string): Promise<string>;
|
|
95
|
+
/** Absent on a runtime older than wearables (contract v1.4). */
|
|
96
|
+
hubWear?(argsJson: string): Promise<string>;
|
|
97
|
+
/** Absent on a runtime older than earned items (contract v1.53). */
|
|
98
|
+
hubUnlock?(argsJson: string): Promise<string>;
|
|
99
|
+
/** Absent on a runtime older than earned items (contract v1.53). */
|
|
100
|
+
hubWearEarned?(argsJson: string): Promise<string>;
|
|
101
|
+
/** Absent on a runtime older than earned items (contract v1.53). */
|
|
102
|
+
hubUnlocksRead?(argsJson: string): Promise<string>;
|
|
61
103
|
readonly config: PlatformSessionConfig;
|
|
62
104
|
}
|
|
105
|
+
export interface PlatformHubResultShape {
|
|
106
|
+
outcomes: ("win" | "lose" | "draw")[];
|
|
107
|
+
score: {
|
|
108
|
+
unit: "ms" | "s" | "points";
|
|
109
|
+
better: "higher" | "lower";
|
|
110
|
+
min: number;
|
|
111
|
+
max: number;
|
|
112
|
+
} | null;
|
|
113
|
+
stats: Record<string, "number" | "string">;
|
|
114
|
+
}
|
|
115
|
+
export interface PlatformHubPlace {
|
|
116
|
+
owner?: string;
|
|
117
|
+
place?: string;
|
|
118
|
+
title?: string | null;
|
|
119
|
+
result?: PlatformHubResultShape | null;
|
|
120
|
+
}
|
|
121
|
+
/** Where a player came in, when ghost placed them through a hub door. Ghost's word, not a client's. */
|
|
122
|
+
export interface PlatformHubArrival {
|
|
123
|
+
fromDeckId: string;
|
|
124
|
+
doorId?: string;
|
|
125
|
+
}
|
|
63
126
|
export interface PlatformWorldCharacterCommand {
|
|
64
127
|
grantId: string;
|
|
65
128
|
requestId: string;
|
|
@@ -67,7 +130,7 @@ export interface PlatformWorldCharacterCommand {
|
|
|
67
130
|
speaker: PlayerIdentity & {
|
|
68
131
|
playerId: string;
|
|
69
132
|
};
|
|
70
|
-
command:
|
|
133
|
+
command: MultiplayerWorldCharacterCommand;
|
|
71
134
|
}
|
|
72
135
|
export interface PlatformWorldCharacterRead {
|
|
73
136
|
grantId: string;
|
|
@@ -77,6 +140,20 @@ export interface PlatformWorldCharacterRead {
|
|
|
77
140
|
};
|
|
78
141
|
query: unknown;
|
|
79
142
|
}
|
|
143
|
+
/** A lookup a place declares for one character turn. */
|
|
144
|
+
export interface PlatformPlaceTool {
|
|
145
|
+
name: string;
|
|
146
|
+
description: string;
|
|
147
|
+
input_schema: Record<string, unknown>;
|
|
148
|
+
}
|
|
149
|
+
export interface PlatformWorldCharacterTool {
|
|
150
|
+
requestId: string;
|
|
151
|
+
operationId: string;
|
|
152
|
+
characterId: string;
|
|
153
|
+
playerId: string;
|
|
154
|
+
name: string;
|
|
155
|
+
input: Record<string, unknown>;
|
|
156
|
+
}
|
|
80
157
|
export interface PlatformWorldCharacterTurn {
|
|
81
158
|
grantId: string;
|
|
82
159
|
requestId: string;
|
package/dist/server/storage.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Json } from "../types";
|
|
2
2
|
import type { CharacterHistory, CharacterOperation } from "../commands";
|
|
3
3
|
import type { PlatformHandle } from "./platformHandle";
|
|
4
|
+
import { type SaveApi, type SaveBucketApi } from "../save";
|
|
4
5
|
export interface ServerStorageListOptions {
|
|
5
6
|
prefix?: string;
|
|
6
7
|
limit?: number;
|
|
@@ -15,13 +16,19 @@ export interface ServerStorageListPage<T extends Json = Json> {
|
|
|
15
16
|
/** Null means there is nothing left. A short page alone does not mean that. */
|
|
16
17
|
cursor: string | null;
|
|
17
18
|
}
|
|
18
|
-
export interface ServerStorageScope {
|
|
19
|
+
export interface ServerStorageScope extends SaveBucketApi {
|
|
19
20
|
get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
|
|
20
21
|
set(values: Record<string, Json>): Promise<void>;
|
|
21
22
|
remove(keys: string[]): Promise<void>;
|
|
22
23
|
increment(key: string, delta?: number): Promise<number>;
|
|
23
24
|
list<T extends Json = Json>(options?: ServerStorageListOptions): Promise<ServerStorageListPage<T>>;
|
|
24
25
|
}
|
|
26
|
+
export interface ServerTownStorage {
|
|
27
|
+
get<T extends Json = Json>(keys: string[]): Promise<Record<string, T>>;
|
|
28
|
+
set(values: Record<string, Json>): Promise<void>;
|
|
29
|
+
remove(keys: string[]): Promise<void>;
|
|
30
|
+
increment(key: string, delta?: number): Promise<number>;
|
|
31
|
+
}
|
|
25
32
|
export interface ServerStorageBoardScore {
|
|
26
33
|
subject: string;
|
|
27
34
|
score: number;
|
|
@@ -95,6 +102,59 @@ export interface DeliverCharacterKnowledgeInput {
|
|
|
95
102
|
claimIds: string[];
|
|
96
103
|
source: CharacterKnowledgeSource;
|
|
97
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* A character acting on its own after a command woke it (autonomous character
|
|
107
|
+
* actions, bundles with `characterActions`). `declined` covers choosing
|
|
108
|
+
* nothing, too little confidence for its temperament, and an action no longer
|
|
109
|
+
* eligible when it committed; `failed` is a decision-model error.
|
|
110
|
+
*/
|
|
111
|
+
export type CharacterActionNotice = {
|
|
112
|
+
characterId: string;
|
|
113
|
+
/** `id` is the event occurrence, routine, or promise id. */
|
|
114
|
+
trigger: {
|
|
115
|
+
type: "witnessed" | "routine" | "promise_due";
|
|
116
|
+
id: string;
|
|
117
|
+
};
|
|
118
|
+
outcome: "acted" | "declined" | "failed";
|
|
119
|
+
/** When acted. */
|
|
120
|
+
actionId?: string;
|
|
121
|
+
/** When acted with a parameterized action's variant. */
|
|
122
|
+
variantId?: string;
|
|
123
|
+
/** The action's world event, when it has one. */
|
|
124
|
+
eventOccurrenceId?: string;
|
|
125
|
+
/** The decision model's probability for its choice. */
|
|
126
|
+
probability?: number;
|
|
127
|
+
};
|
|
128
|
+
export type CharacterGameStateResult = {
|
|
129
|
+
revision: number;
|
|
130
|
+
state: {
|
|
131
|
+
[key: string]: string | number | boolean;
|
|
132
|
+
};
|
|
133
|
+
promiseOutcomes: Array<{
|
|
134
|
+
id: string;
|
|
135
|
+
status: "fulfilled" | "broken";
|
|
136
|
+
due: number;
|
|
137
|
+
resolvedAtGameTime: number;
|
|
138
|
+
}>;
|
|
139
|
+
/** At most 8, omitted when no character acted, declined, or failed. */
|
|
140
|
+
characterActions?: CharacterActionNotice[];
|
|
141
|
+
};
|
|
142
|
+
export type CharacterGameStateOperation = Omit<CharacterOperation, "result"> & {
|
|
143
|
+
result: CharacterGameStateResult | null;
|
|
144
|
+
};
|
|
145
|
+
export type CharacterKnowledgeDeliveryResult = {
|
|
146
|
+
deliveryId: string;
|
|
147
|
+
recipientCharacterId: string;
|
|
148
|
+
eventOccurrenceId: string;
|
|
149
|
+
claimIds: string[];
|
|
150
|
+
source: CharacterKnowledgeSource;
|
|
151
|
+
deliveredAt: string;
|
|
152
|
+
/** At most 8, omitted when no character acted, declined, or failed. */
|
|
153
|
+
characterActions?: CharacterActionNotice[];
|
|
154
|
+
};
|
|
155
|
+
export type CharacterKnowledgeDeliveryOperation = Omit<CharacterOperation, "result"> & {
|
|
156
|
+
result: CharacterKnowledgeDeliveryResult | null;
|
|
157
|
+
};
|
|
98
158
|
export interface CompleteCharacterBeatInput {
|
|
99
159
|
operationId: string;
|
|
100
160
|
characterId: string;
|
|
@@ -189,10 +249,10 @@ export interface ServerPlaythroughCharacters {
|
|
|
189
249
|
/** Queues a funded turn whose admitted listeners are fixed at enqueue time. */
|
|
190
250
|
send(input: SendCharacterInput): Promise<CharacterOperation>;
|
|
191
251
|
recordEvent(input: RecordCharacterEventInput): Promise<CharacterOperation>;
|
|
192
|
-
setGameState(input: SetCharacterGameStateInput): Promise<
|
|
252
|
+
setGameState(input: SetCharacterGameStateInput): Promise<CharacterGameStateOperation>;
|
|
193
253
|
shareClaim(input: ShareCharacterClaimInput): Promise<CharacterOperation>;
|
|
194
254
|
inspect(): Promise<Json>;
|
|
195
|
-
deliverKnowledge(input: DeliverCharacterKnowledgeInput): Promise<
|
|
255
|
+
deliverKnowledge(input: DeliverCharacterKnowledgeInput): Promise<CharacterKnowledgeDeliveryOperation>;
|
|
196
256
|
completeBeat(input: CompleteCharacterBeatInput): Promise<CharacterOperation>;
|
|
197
257
|
evaluateInformation(input: EvaluateCharacterInformationInput): Promise<CharacterInformationEvaluationOperation>;
|
|
198
258
|
evaluateBeats(input: EvaluateCharacterBeatsInput): Promise<CharacterBeatEvaluationOperation>;
|
|
@@ -204,11 +264,18 @@ export interface ServerCharacterHistoryOptions {
|
|
|
204
264
|
limit?: number;
|
|
205
265
|
cursor?: string | null;
|
|
206
266
|
}
|
|
207
|
-
export interface ServerStorage {
|
|
267
|
+
export interface ServerStorage extends SaveApi {
|
|
208
268
|
/** Shared by everyone playing the deck, and owned by this server. */
|
|
209
269
|
readonly deck: ServerStorageScope;
|
|
210
270
|
/** One player's public data. Readable by anyone; writable by them or by this server. */
|
|
211
271
|
user(userId: string): ServerStorageScope;
|
|
272
|
+
/**
|
|
273
|
+
* The door deck's source town's deck-scope rows, for one player in this session.
|
|
274
|
+
* Only a door deck's server may call it; a town gets CAULDRON_HUB_NO_TOWN.
|
|
275
|
+
* In an owner house, only the owner's rows are accessible. Quota counts against
|
|
276
|
+
* the town. Each key must be tokens|spent|own|hat|chirp, then userId, as its first two segments.
|
|
277
|
+
*/
|
|
278
|
+
town(userId: string): ServerTownStorage;
|
|
212
279
|
board(name: string): ServerStorageBoard;
|
|
213
280
|
/** Opens one player's experimental, generation-bound game namespace. */
|
|
214
281
|
playthrough(userId: string): Promise<ServerPlaythrough>;
|
package/dist/server/storage.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { chunked } from "../chunk";
|
|
2
2
|
import { decodeStorageValue, encodeStorageValue, storageError, } from "../storageJson";
|
|
3
|
+
import { createSaveApi, createSaveBucket, preflightSaveRequest, SaveError, } from "../save";
|
|
3
4
|
// Deck storage for an author's session server.
|
|
4
5
|
//
|
|
5
6
|
// The same store the deck's clients use, reached over the platform channel instead of
|
|
@@ -56,8 +57,26 @@ export function createServerStorage(platformHandle) {
|
|
|
56
57
|
const result = await platformHandle.storage(op, JSON.stringify(args));
|
|
57
58
|
return JSON.parse(result);
|
|
58
59
|
};
|
|
60
|
+
// The host forwards these args to the platform's storage route unchanged,
|
|
61
|
+
// which takes a save's request as `input` and a lookup as `operationId`.
|
|
62
|
+
const saveApi = createSaveApi(async (verb, input) => {
|
|
63
|
+
if (typeof platformHandle.storage !== "function" ||
|
|
64
|
+
platformHandle.storageEnabled === false) {
|
|
65
|
+
throw new SaveError({
|
|
66
|
+
code: "CAULDRON_STORAGE_UNAVAILABLE",
|
|
67
|
+
message: "Storage is not available to this session.",
|
|
68
|
+
operationId: input.operationId,
|
|
69
|
+
retryable: false,
|
|
70
|
+
committed: false,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const args = verb === "operation" ? input : { input };
|
|
74
|
+
preflightSaveRequest(verb, args, MAX_REQUEST_BYTES);
|
|
75
|
+
return call(verb, args);
|
|
76
|
+
}, true);
|
|
59
77
|
return {
|
|
60
|
-
|
|
78
|
+
...saveApi,
|
|
79
|
+
deck: createScope(call, "DECK", null, saveApi),
|
|
61
80
|
user(userId) {
|
|
62
81
|
// A bad id poisons the scope rather than throwing here, so the failure arrives
|
|
63
82
|
// the same way every other failure does -- as a rejection the author can await.
|
|
@@ -65,12 +84,15 @@ export function createServerStorage(platformHandle) {
|
|
|
65
84
|
// resolved by the platform to the deck's creator, quietly writing the wrong
|
|
66
85
|
// player's bucket, so nothing is sent either.
|
|
67
86
|
if (typeof userId !== "string" || userId.length === 0) {
|
|
68
|
-
return createScope(rejectMissingSubject, "USER", null);
|
|
87
|
+
return createScope(rejectMissingSubject, "USER", null, saveApi);
|
|
69
88
|
}
|
|
70
89
|
if (utf8Length(userId) > MAX_SUBJECT_BYTES) {
|
|
71
|
-
return createScope(rejectOversizeSubject, "USER",
|
|
90
|
+
return createScope(rejectOversizeSubject, "USER", userId, saveApi);
|
|
72
91
|
}
|
|
73
|
-
return createScope(call, "USER", userId);
|
|
92
|
+
return createScope(call, "USER", userId, saveApi);
|
|
93
|
+
},
|
|
94
|
+
town(userId) {
|
|
95
|
+
return createTownStorage(call, userId);
|
|
74
96
|
},
|
|
75
97
|
board(name) {
|
|
76
98
|
return createBoard(call, name);
|
|
@@ -224,8 +246,52 @@ function createPlaythrough(call, subject, generation) {
|
|
|
224
246
|
},
|
|
225
247
|
});
|
|
226
248
|
}
|
|
227
|
-
|
|
249
|
+
/** The town's per-player namespaces a door deck may reach; ghost holds the same list. */
|
|
250
|
+
const TOWN_PLAYER_NAMESPACES = ["tokens", "spent", "own", "hat", "chirp"];
|
|
251
|
+
function createTownStorage(call, subject) {
|
|
252
|
+
const args = { scope: "DECK", subject, place: "town" };
|
|
253
|
+
// Validate in the async verbs, so a bad subject poisons the handle without
|
|
254
|
+
// throwing when the author creates it. Validate every key before any chunk lands.
|
|
255
|
+
const keysFor = (keys, operation) => {
|
|
256
|
+
assertUserId(subject, operation);
|
|
257
|
+
const checked = assertKeys(keys, operation);
|
|
258
|
+
for (const key of checked) {
|
|
259
|
+
const segments = key.split(":");
|
|
260
|
+
if (!TOWN_PLAYER_NAMESPACES.includes(segments[0]) || segments[1] !== subject) {
|
|
261
|
+
throw storageError("CAULDRON_STORAGE_INVALID_ARGUMENT", `Town storage key ${JSON.stringify(key)} must be ${TOWN_PLAYER_NAMESPACES.join("|")}:${subject}[:...].`, operation);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return checked;
|
|
265
|
+
};
|
|
266
|
+
return {
|
|
267
|
+
async get(keys) {
|
|
268
|
+
const entries = [];
|
|
269
|
+
for (const chunk of chunked(keysFor(keys, "session.storage.town.get"), MAX_KEYS_PER_CALL)) {
|
|
270
|
+
entries.push(...await call("get", { ...args, keys: chunk }));
|
|
271
|
+
}
|
|
272
|
+
return decodeEntries(entries, "session.storage.town.get");
|
|
273
|
+
},
|
|
274
|
+
async set(values) {
|
|
275
|
+
keysFor(Object.keys(values), "session.storage.town.set");
|
|
276
|
+
const entries = encodeEntries(values, "session.storage.town.set");
|
|
277
|
+
for (const chunk of batched(entries, "session.storage.town.set")) {
|
|
278
|
+
await call("set", { ...args, entries: chunk });
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
async remove(keys) {
|
|
282
|
+
for (const chunk of chunked(keysFor(keys, "session.storage.town.remove"), MAX_KEYS_PER_CALL)) {
|
|
283
|
+
await call("delete", { ...args, keys: chunk });
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
async increment(key, delta = 1) {
|
|
287
|
+
keysFor([key], "session.storage.town.increment");
|
|
288
|
+
return Number(await call("increment", { ...args, key, delta }));
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function createScope(call, scope, subject, saveApi) {
|
|
228
293
|
return {
|
|
294
|
+
...createSaveBucket(saveApi, scope === "DECK" ? "deck" : "user", subject ?? undefined),
|
|
229
295
|
async get(keys) {
|
|
230
296
|
// A bare array, not a wrapper: the platform hands back exactly what the
|
|
231
297
|
// storage verb returns, and `get` returns its entries directly.
|