castle-web-sdk 0.4.46 → 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 +79 -2
- package/dist/avatar.d.ts +6 -2
- package/dist/avatar.js +12 -4
- package/dist/castle.d.ts +1 -1
- package/dist/commands.d.ts +14 -0
- package/dist/hub.d.ts +6 -2
- package/dist/hub.js +13 -0
- package/dist/multiplayer.js +17 -1
- package/dist/save.js +2 -1
- package/dist/server/hub.d.ts +87 -0
- package/dist/server/hub.js +70 -0
- package/dist/server/platformHandle.d.ts +31 -1
- package/dist/server/storage.d.ts +13 -0
- package/dist/server/storage.js +46 -0
- package/dist/server/wrapper.d.ts +36 -5
- package/dist/server/wrapper.js +83 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -151,6 +151,23 @@ export default {
|
|
|
151
151
|
support the [atomic save API](#atomic-saves). Each scope has the same
|
|
152
152
|
`get`, `set`, `remove`, `increment` and `list` as `Store`.
|
|
153
153
|
|
|
154
|
+
`session.storage.town(userId)` lets a door deck's server read and write one
|
|
155
|
+
signed-in player's rows in its source town's deck scope, for things such as
|
|
156
|
+
hats bought in town. It has `get`, `set`, `remove` and `increment`; quota counts
|
|
157
|
+
against the town. Every key is one of the town's per-player namespaces
|
|
158
|
+
(`tokens`, `spent`, `own`, `hat`, `chirp`) followed by the user id: for user
|
|
159
|
+
`7`, `own:7:tophat` and `hat:7` work; `own:tophat:7` and `quay:7` do not. The
|
|
160
|
+
rest of the town's rows are its own. The player must be live in this session
|
|
161
|
+
(a player who joined a few seconds ago can briefly get
|
|
162
|
+
`CAULDRON_HUB_NOT_IN_SESSION`, so retry later rather than giving up); in a house, only the owner's rows are
|
|
163
|
+
accessible. Calls reject with `CAULDRON_HUB_NO_TOWN` without a source town
|
|
164
|
+
(including a town calling it), `CAULDRON_HUB_NOT_IN_SESSION` for an absent
|
|
165
|
+
player, or `CAULDRON_HUB_NOT_OWNER` for someone other than the house's owner.
|
|
166
|
+
Invalid key segments reject with `CAULDRON_STORAGE_INVALID_ARGUMENT`.
|
|
167
|
+
During dev serve, keys live under `town/` in the dev deck's own deck scope,
|
|
168
|
+
so testing a door never changes a real town. An unpublished deck still needs
|
|
169
|
+
a published deck id for storage.
|
|
170
|
+
|
|
154
171
|
Two differences follow from a session server not being a player:
|
|
155
172
|
|
|
156
173
|
- **It owns `deck` scope.** Once a deck has a published server, clients read
|
|
@@ -173,6 +190,10 @@ kit's contract). `save-deck` publishes the block's titles and result shapes with
|
|
|
173
190
|
the server bundle, and Castle pins them to every session of the place. Those
|
|
174
191
|
sessions have `session.hub`; every other session has `session.hub === null`.
|
|
175
192
|
|
|
193
|
+
`session.hub.owner` is the owner's user id string on an owner door instance,
|
|
194
|
+
passed by the platform. It is absent on every other hub session, including the
|
|
195
|
+
dev serve.
|
|
196
|
+
|
|
176
197
|
- `session.hub.place`, `.title` and `.result` are the place and the result shape
|
|
177
198
|
as published (`result` is `null` for a place that reports none, such as a town).
|
|
178
199
|
- `session.hub.arrival(playerId)` is how a player came in when Castle placed them
|
|
@@ -199,6 +220,43 @@ sessions have `session.hub`; every other session has `session.hub === null`.
|
|
|
199
220
|
id the kit draws, `color` `'#rrggbb'`, `rarity` 0-9), or clears it with `null`;
|
|
200
221
|
the latest wins. Castle takes it only from a curated hub place. Resolves
|
|
201
222
|
`{ wearable }`; `readResults` returns it as `players[userId].wearable`.
|
|
223
|
+
- `session.hub.unlock(playerId, itemId)` gives a signed-in player one of the
|
|
224
|
+
deck's declared earned items (castle.json `hub.unlocks`: `{ id, class, shape,
|
|
225
|
+
name, deed, color }`, at most 8, `class` one of `badge`, `pin`, `belt`, `sash`,
|
|
226
|
+
`held`). v1.70 also permits up to 6 home items separately: `furniture` or
|
|
227
|
+
`deed`, with `piece` in place of `shape` (the same lowercase id format).
|
|
228
|
+
Each id is unique across both lists. Castle takes it only from a curated hub
|
|
229
|
+
place and only for an id the saved deck declares; a first wearable unlock
|
|
230
|
+
is also shown. Resolves
|
|
231
|
+
`{ unlocked: true, isNew, earned }`, where `earned` is what the player now
|
|
232
|
+
shows (`{ deckId, id, class, shape, name, deed, color }`) or `null`, including
|
|
233
|
+
for home grants. A home grant never changes what the avatar shows; a home
|
|
234
|
+
item appears only in the owned list returned by `readUnlocks`.
|
|
235
|
+
`readResults` returns the shown one as `players[userId].earned`, absent when
|
|
236
|
+
none.
|
|
237
|
+
- `session.hub.wearEarned(playerId, { deckId, id } | null)` chooses which owned
|
|
238
|
+
earned item a player shows, or none. Castle takes it only from the town.
|
|
239
|
+
Home items are refused with `CAULDRON_HUB_EARNED_NOT_WEARABLE`.
|
|
240
|
+
Resolves `{ earned }`.
|
|
241
|
+
- `session.hub.readUnlocks(userIds)` reads the earned items players here own:
|
|
242
|
+
`{ players: { [userId]: { owned: [...] } } }`, including home items with
|
|
243
|
+
their `class` and `piece`. `MultiplayerHubEarnedItem` and
|
|
244
|
+
`MultiplayerHubEarnedClass` describe wearable items only;
|
|
245
|
+
`MultiplayerHubHomeItem` and `MultiplayerHubHomeClass` describe home items.
|
|
246
|
+
Only the owned list uses the union of both item types; unlock/wear receipts
|
|
247
|
+
and arrival results keep the wearable-only type.
|
|
248
|
+
- These three reject with `CAULDRON_HUB_UNAVAILABLE` on a runtime without them.
|
|
249
|
+
- `session.hub.friendsAmong(playerId, userIds)` resolves the subset of candidate
|
|
250
|
+
user ids that are this connected player's friends (`Promise<string[]>`).
|
|
251
|
+
The platform checks mutual follows, age, blocks, the hub gate and the player's
|
|
252
|
+
recent client friends ask. It uses `hubFriendsAmong` or the older runtime's
|
|
253
|
+
storage broker and rejects on platform errors. The hub-game kit wraps it with
|
|
254
|
+
decimal-id filtering, a 100-candidate cap, caching and a silent `[]` fallback.
|
|
255
|
+
- `session.hub.relationsAmong(playerId, userIds)` makes the same single host read
|
|
256
|
+
and resolves `{ friends, follows, followedBy }`, each limited to asked string
|
|
257
|
+
ids. Missing lists are empty on older hosts. Mutual friends keep the chat age
|
|
258
|
+
and block rules; follows in either direction require no block, with no age rule.
|
|
259
|
+
These lists belong to the viewer only and must never go to another client.
|
|
202
260
|
- `session.hub.hiddenPair(playerIdA, playerIdB)` is true when a block hides these
|
|
203
261
|
two players from each other (either one blocked the other). Never send either
|
|
204
262
|
one the other's pose, avatar or name. The block lists themselves never reach
|
|
@@ -895,7 +953,7 @@ itself (ones Castle runs across decks), on the serve only.
|
|
|
895
953
|
|
|
896
954
|
The player's platform avatar: one per account, worn in every hub place
|
|
897
955
|
(cauldron-game-server `docs/HUB.md`, decision 4). In v1 it is an index into the
|
|
898
|
-
hub-game kit's character templates
|
|
956
|
+
hub-game kit's character templates, with an optional colour choice. The
|
|
899
957
|
hub-game kit is the caller: it dresses the player and offers the choice in its
|
|
900
958
|
picker. Other players' avatars are not read here; a place's server learns them
|
|
901
959
|
from ghost.
|
|
@@ -907,7 +965,7 @@ every account has (`chosen: false`). Anonymous accounts have one too.
|
|
|
907
965
|
`{ status: "unavailable", avatar: null }` for a signed-out visitor; a host from
|
|
908
966
|
before avatars rejects with `UNKNOWN_COMMAND`. Either way the deck picks its own.
|
|
909
967
|
|
|
910
|
-
### `Avatar.set(avatar): Promise<AvatarResult>`
|
|
968
|
+
### `Avatar.set(avatar, options?: { color?: number }): Promise<AvatarResult>`
|
|
911
969
|
|
|
912
970
|
Makes `avatar` the account's from now on, everywhere. Only a curated hub place
|
|
913
971
|
(a deck an enabled hub door leads into or out of) may: anywhere else, and for a
|
|
@@ -915,6 +973,15 @@ signed-out visitor, the answer is `unavailable` and nothing changes. Ghost
|
|
|
915
973
|
refuses an index outside its template set (`INVALID_ARGUMENT`) and more than 30
|
|
916
974
|
choices a minute (`RATE_LIMIT_EXCEEDED`).
|
|
917
975
|
|
|
976
|
+
A host that supports avatar colours includes `color` in `AvatarResult`; the
|
|
977
|
+
field is absent from older hosts. `Avatar.colors === true` marks an SDK whose
|
|
978
|
+
`Avatar.set` carries `{ color }`; the hub-game kit requires both the host field
|
|
979
|
+
and this marker to offer platform colours. Only after that support is known, pass
|
|
980
|
+
`{ color }` to choose a non-negative integer colour for the current avatar.
|
|
981
|
+
The host accepts 0–255; ghost holds the offered range. Changing character
|
|
982
|
+
without options clears its colour. Omitting colour sends the existing command
|
|
983
|
+
and mutation unchanged. The read query will ask for colour once ghost serves it.
|
|
984
|
+
|
|
918
985
|
## Pass
|
|
919
986
|
|
|
920
987
|
A pass is something a creator sells to players for Castle bricks (the
|
|
@@ -1625,3 +1692,13 @@ Common codes:
|
|
|
1625
1692
|
that lacks it.
|
|
1626
1693
|
- `CAULDRON_STORAGE_STALE_GENERATION` — the handle belongs to an older reset
|
|
1627
1694
|
generation; reopen explicitly rather than replaying work into the new one.
|
|
1695
|
+
|
|
1696
|
+
### Your games: hub hearts
|
|
1697
|
+
|
|
1698
|
+
`Hub.hearts()` returns `{ status: 'ok', hearts: string[] }` for the signed-in
|
|
1699
|
+
account. `Hub.setHearts(doorIds)` replaces that list with at most eight distinct
|
|
1700
|
+
door ids, oldest first. The host supplies the current deck id; only curated hub
|
|
1701
|
+
places may save hearts. Guests and unavailable hosts return
|
|
1702
|
+
`{ status: 'unavailable', hearts: [] }`; older hosts throw `UNKNOWN_COMMAND`.
|
|
1703
|
+
Invalid lists throw `INVALID_ARGUMENT`, and other host failures use `CastleError`.
|
|
1704
|
+
The dev serve keeps hearts per account in memory until it stops.
|
package/dist/avatar.d.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { AvatarResult } from "./commands";
|
|
2
2
|
export type { AvatarResult } from "./commands";
|
|
3
3
|
export interface CastleAvatarApi {
|
|
4
|
+
/** Present only in an SDK whose Avatar.set carries { color }; the hub-game kit offers colours only when it is here. */
|
|
5
|
+
readonly colors?: true;
|
|
4
6
|
/**
|
|
5
7
|
* The player's avatar: their choice, or the default every account has.
|
|
6
8
|
* `unavailable` for a signed-out visitor, or on a host without avatars.
|
|
7
9
|
*/
|
|
8
10
|
get(): Promise<AvatarResult>;
|
|
9
|
-
/** Choose the avatar
|
|
10
|
-
set(avatar: number
|
|
11
|
+
/** Choose the avatar, and optionally its colour, worn everywhere. Requires a host with colours for options.color. */
|
|
12
|
+
set(avatar: number, options?: {
|
|
13
|
+
color?: number;
|
|
14
|
+
}): Promise<AvatarResult>;
|
|
11
15
|
}
|
|
12
16
|
export declare const Avatar: CastleAvatarApi;
|
package/dist/avatar.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
// Avatar — the player's platform avatar (cauldron-game-server docs/HUB.md,
|
|
2
2
|
// decision 4): one per account, worn in every hub place. v1 is an index into the
|
|
3
|
-
// hub-game kit's character templates
|
|
3
|
+
// hub-game kit's character templates, with optional colour choices.
|
|
4
4
|
//
|
|
5
5
|
// The hub-game kit is the one caller: it reads the avatar to dress the player
|
|
6
6
|
// and offers the choice in its picker. Other players' avatars are not read
|
|
7
7
|
// here; a place's server learns them from ghost.
|
|
8
8
|
import { CastleError } from "./errors";
|
|
9
9
|
import { hostRequest } from "./transport";
|
|
10
|
-
export const Avatar = { get, set };
|
|
10
|
+
export const Avatar = { get, set, colors: true };
|
|
11
11
|
async function get() {
|
|
12
12
|
return hostRequest("avatar.get", {});
|
|
13
13
|
}
|
|
14
|
-
async function set(avatar) {
|
|
14
|
+
async function set(avatar, options) {
|
|
15
15
|
if (typeof avatar !== "number" || !Number.isInteger(avatar) || avatar < 0) {
|
|
16
16
|
throw new CastleError({
|
|
17
17
|
code: "INVALID_ARGUMENT",
|
|
@@ -19,5 +19,13 @@ async function set(avatar) {
|
|
|
19
19
|
operation: "Avatar.set",
|
|
20
20
|
});
|
|
21
21
|
}
|
|
22
|
-
|
|
22
|
+
const color = options?.color;
|
|
23
|
+
if (color !== undefined && (!Number.isInteger(color) || color < 0)) {
|
|
24
|
+
throw new CastleError({
|
|
25
|
+
code: "INVALID_ARGUMENT",
|
|
26
|
+
message: "Avatar.set requires a non-negative integer colour.",
|
|
27
|
+
operation: "Avatar.set",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return hostRequest("avatar.set", color === undefined ? { avatar } : { avatar, color });
|
|
23
31
|
}
|
package/dist/castle.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export { deckPrefixOf, IMPORTS_DIR, IMPORTS_PREFIX, isImportedFile, resolveDeckF
|
|
|
13
13
|
export { Haptics } from "./haptics";
|
|
14
14
|
export type { CastleHapticsApi, HapticsResult, HapticsStatus, HapticStyle } from "./haptics";
|
|
15
15
|
export { Hub } from "./hub";
|
|
16
|
-
export type { CastleHubApi, HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubGoParams, HubGoResult, HubHandoffResult, HubHandoffStatus, HubJumpResult, HubPlacementKind, HubReportResult, HubSayResult, } from "./hub";
|
|
16
|
+
export type { CastleHubApi, HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubHeartsResult, HubGoParams, HubGoResult, HubHandoffResult, HubHandoffStatus, HubJumpResult, HubPlacementKind, HubReportResult, HubSayResult, } from "./hub";
|
|
17
17
|
export { Leaderboard } from "./leaderboard";
|
|
18
18
|
export type { LeaderboardData, LeaderboardEntry, LeaderboardOptions, LeaderboardScope, LeaderboardShowOptions, LeaderboardShowResult, LeaderboardShowStatus, LeaderboardSort, } from "./leaderboard";
|
|
19
19
|
export { Lifecycle } from "./lifecycle";
|
package/dist/commands.d.ts
CHANGED
|
@@ -158,6 +158,11 @@ export type HubFriendsResult = {
|
|
|
158
158
|
status: "unavailable";
|
|
159
159
|
friends: null;
|
|
160
160
|
};
|
|
161
|
+
/** Account-wide hub door hearts, in oldest-first order (at most eight). */
|
|
162
|
+
export interface HubHeartsResult {
|
|
163
|
+
status: "ok" | "unavailable";
|
|
164
|
+
hearts: string[];
|
|
165
|
+
}
|
|
161
166
|
/** Hub.chat: only the messages after a cursor an earlier answer returned. */
|
|
162
167
|
export interface HubChatParams {
|
|
163
168
|
after?: string;
|
|
@@ -508,6 +513,7 @@ export interface CommandParams {
|
|
|
508
513
|
"avatar.get": Record<string, never>;
|
|
509
514
|
"avatar.set": {
|
|
510
515
|
avatar: number;
|
|
516
|
+
color?: number;
|
|
511
517
|
};
|
|
512
518
|
"pass.offer": {
|
|
513
519
|
passId: string;
|
|
@@ -530,6 +536,10 @@ export interface CommandParams {
|
|
|
530
536
|
"hub.back": Record<string, never>;
|
|
531
537
|
"hub.doors": Record<string, never>;
|
|
532
538
|
"hub.friends": Record<string, never>;
|
|
539
|
+
"hub.hearts": Record<string, never>;
|
|
540
|
+
"hub.setHearts": {
|
|
541
|
+
doorIds: string[];
|
|
542
|
+
};
|
|
533
543
|
"hub.jump": HubJumpParams;
|
|
534
544
|
"hub.report": HubReportParams;
|
|
535
545
|
"hub.chat": HubChatParams;
|
|
@@ -700,6 +710,8 @@ export interface CommandResult {
|
|
|
700
710
|
"hub.back": HubBackResult;
|
|
701
711
|
"hub.doors": HubDoorsResult;
|
|
702
712
|
"hub.friends": HubFriendsResult;
|
|
713
|
+
"hub.hearts": HubHeartsResult;
|
|
714
|
+
"hub.setHearts": HubHeartsResult;
|
|
703
715
|
"hub.jump": HubJumpResult;
|
|
704
716
|
"hub.report": HubReportResult;
|
|
705
717
|
"hub.chat": HubChatResult;
|
|
@@ -775,6 +787,8 @@ export interface UserPayload {
|
|
|
775
787
|
color: string | null;
|
|
776
788
|
}
|
|
777
789
|
export interface AvatarResult {
|
|
790
|
+
/** Absent from a host before avatar colours. */
|
|
791
|
+
color?: number;
|
|
778
792
|
status: "ok" | "unavailable";
|
|
779
793
|
avatar: number | null;
|
|
780
794
|
/** False while the player wears the default ghost gives every account. */
|
package/dist/hub.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { HubBackResult, HubCancelResult, HubChatResult, HubDoorsResult, HubFriendsResult, HubGoResult, HubJumpResult, HubReportResult, HubSayResult } from "./commands";
|
|
2
|
-
export type { HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubGoParams, HubGoResult, HubHandoffResult, HubHandoffStatus, HubJumpResult, HubPlacementKind, HubReportResult, HubSayResult, } from "./commands";
|
|
1
|
+
import type { HubBackResult, HubCancelResult, HubChatResult, HubDoorsResult, HubFriendsResult, HubHeartsResult, HubGoResult, HubJumpResult, HubReportResult, HubSayResult } from "./commands";
|
|
2
|
+
export type { HubBackResult, HubCancelResult, HubCancelStatus, HubChatMessage, HubChatResult, HubDoor, HubDoorsResult, HubFriend, HubFriendsResult, HubHeartsResult, HubGoParams, HubGoResult, HubHandoffResult, HubHandoffStatus, HubJumpResult, HubPlacementKind, HubReportResult, HubSayResult, } from "./commands";
|
|
3
3
|
/**
|
|
4
4
|
* Doors between hub places. Every hub-kit deck is a place; ghost's door table
|
|
5
5
|
* is the allowlist, and the host supplies real authentication and the current
|
|
@@ -35,6 +35,10 @@ export interface CastleHubApi {
|
|
|
35
35
|
* on hosts without hub support.
|
|
36
36
|
*/
|
|
37
37
|
friends(): Promise<HubFriendsResult>;
|
|
38
|
+
/** Read this signed-in account's hub door hearts. */
|
|
39
|
+
hearts(): Promise<HubHeartsResult>;
|
|
40
|
+
/** Replace the hearts, oldest first, at most eight distinct door ids. */
|
|
41
|
+
setHearts(doorIds: string[]): Promise<HubHeartsResult>;
|
|
38
42
|
/**
|
|
39
43
|
* Acquire the layer of this place a friend is in. The destination is this
|
|
40
44
|
* same place in another session; join it exactly like a door's
|
package/dist/hub.js
CHANGED
|
@@ -5,6 +5,8 @@ export const Hub = {
|
|
|
5
5
|
back,
|
|
6
6
|
doors,
|
|
7
7
|
friends,
|
|
8
|
+
hearts,
|
|
9
|
+
setHearts,
|
|
8
10
|
jump,
|
|
9
11
|
report,
|
|
10
12
|
chat,
|
|
@@ -32,6 +34,17 @@ async function doors() {
|
|
|
32
34
|
async function friends() {
|
|
33
35
|
return hostRequest("hub.friends", {});
|
|
34
36
|
}
|
|
37
|
+
async function hearts() {
|
|
38
|
+
return hostRequest("hub.hearts", {});
|
|
39
|
+
}
|
|
40
|
+
async function setHearts(doorIds) {
|
|
41
|
+
if (!Array.isArray(doorIds) || doorIds.length > 8 ||
|
|
42
|
+
doorIds.some((id) => typeof id !== "string" || !id.length || id.length > 64) ||
|
|
43
|
+
new Set(doorIds).size !== doorIds.length) {
|
|
44
|
+
throw new CastleError({ code: "INVALID_ARGUMENT", message: "Hub.setHearts requires at most eight distinct door ids.", operation: "Hub.setHearts" });
|
|
45
|
+
}
|
|
46
|
+
return hostRequest("hub.setHearts", { doorIds });
|
|
47
|
+
}
|
|
35
48
|
async function jump(userId) {
|
|
36
49
|
if (typeof userId !== "string" || userId.length === 0) {
|
|
37
50
|
throw new CastleError({
|
package/dist/multiplayer.js
CHANGED
|
@@ -7,6 +7,13 @@ const HELLO_TIMEOUT_MS = 10_000;
|
|
|
7
7
|
const RECONNECT_BUDGET_MS = 30_000;
|
|
8
8
|
const RECONNECT_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
|
|
9
9
|
const INVALID_DEV_RECONNECT_CLOSE_CODE = 4003;
|
|
10
|
+
// The play proxy closes a handshake it refuses with a reason (cauldron-game-server
|
|
11
|
+
// shared/src/constants.ts). The host that owns the session rejecting the credential is
|
|
12
|
+
// final. "Session ended" is ghost's routing verdict, which a host drain or a ghost
|
|
13
|
+
// error can also produce, so it takes two in a row before the session is given up on.
|
|
14
|
+
const PROXY_CREDENTIAL_REJECTED_CLOSE_CODE = 4401;
|
|
15
|
+
const PROXY_SESSION_ENDED_CLOSE_CODE = 4404;
|
|
16
|
+
const SESSION_ENDED_CONFIRMATIONS = 2;
|
|
10
17
|
// The host may spend the full 15-second grant window waiting for author admission,
|
|
11
18
|
// then up to 10 seconds on the fenced control-plane request. Keep the client above
|
|
12
19
|
// that complete lifecycle so it cannot discard a valid durable receipt.
|
|
@@ -623,6 +630,7 @@ class MultiplayerConnectionImpl {
|
|
|
623
630
|
async recover() {
|
|
624
631
|
const startedAt = Date.now();
|
|
625
632
|
let attempt = 0;
|
|
633
|
+
let sessionEndedCloses = 0;
|
|
626
634
|
while (Date.now() - startedAt < RECONNECT_BUDGET_MS) {
|
|
627
635
|
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
628
636
|
attempt += 1;
|
|
@@ -633,7 +641,15 @@ class MultiplayerConnectionImpl {
|
|
|
633
641
|
return;
|
|
634
642
|
}
|
|
635
643
|
catch (error) {
|
|
636
|
-
|
|
644
|
+
const code = error instanceof SocketClosedError ? error.code : null;
|
|
645
|
+
if (code === 1008 || code === PROXY_CREDENTIAL_REJECTED_CLOSE_CODE)
|
|
646
|
+
break;
|
|
647
|
+
// Retrying a session that is gone only spends the budget; ask for a fresh join.
|
|
648
|
+
// Any other failure in between means the two refusals were not in a row.
|
|
649
|
+
sessionEndedCloses = code === PROXY_SESSION_ENDED_CLOSE_CODE
|
|
650
|
+
? sessionEndedCloses + 1
|
|
651
|
+
: 0;
|
|
652
|
+
if (sessionEndedCloses >= SESSION_ENDED_CONFIRMATIONS)
|
|
637
653
|
break;
|
|
638
654
|
}
|
|
639
655
|
}
|
package/dist/save.js
CHANGED
|
@@ -60,7 +60,8 @@ const MAX_SUBJECT_BYTES = 256;
|
|
|
60
60
|
const MAX_VALUE_BYTES = 256 * 1024;
|
|
61
61
|
const encoder = new TextEncoder();
|
|
62
62
|
const byteLength = (text) => encoder.encode(text).length;
|
|
63
|
-
|
|
63
|
+
// Unicode mode consumes valid pairs together; avoid lookbehind for Safari < 16.4.
|
|
64
|
+
const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
|
|
64
65
|
function invalid(message) {
|
|
65
66
|
throw new SaveError({
|
|
66
67
|
code: "CAULDRON_STORAGE_INVALID_ARGUMENT",
|
package/dist/server/hub.d.ts
CHANGED
|
@@ -64,6 +64,52 @@ export interface MultiplayerHubWearReceipt {
|
|
|
64
64
|
/** What the player now wears; null when cleared. */
|
|
65
65
|
wearable: MultiplayerHubWearable | null;
|
|
66
66
|
}
|
|
67
|
+
/** The classes of earned item a hub game may give (contract v1.53). */
|
|
68
|
+
export type MultiplayerHubEarnedClass = "badge" | "pin" | "belt" | "sash" | "held";
|
|
69
|
+
/** The home classes a hub game may give (contract v1.70/v1.73), never worn. */
|
|
70
|
+
export type MultiplayerHubHomeClass = "furniture" | "deed" | "pet";
|
|
71
|
+
/**
|
|
72
|
+
* An earned item: something a hub game gave a player for a stated deed, declared
|
|
73
|
+
* in that game's castle.json `hub.unlocks` and kept by Castle per account. It has
|
|
74
|
+
* no rarity. `deckId` is the game that declared it; `id` and `shape` match
|
|
75
|
+
* /^[a-z][a-z0-9-]{0,23}$/, `color` is '#rrggbb'.
|
|
76
|
+
*/
|
|
77
|
+
export interface MultiplayerHubEarnedItem {
|
|
78
|
+
deckId: string;
|
|
79
|
+
id: string;
|
|
80
|
+
class: MultiplayerHubEarnedClass;
|
|
81
|
+
shape: string;
|
|
82
|
+
name: string;
|
|
83
|
+
deed: string;
|
|
84
|
+
color: string;
|
|
85
|
+
}
|
|
86
|
+
/** A furniture piece, area deed or adopted pet owned for the house, never shown on an avatar. */
|
|
87
|
+
export interface MultiplayerHubHomeItem {
|
|
88
|
+
deckId: string;
|
|
89
|
+
id: string;
|
|
90
|
+
class: MultiplayerHubHomeClass;
|
|
91
|
+
piece: string;
|
|
92
|
+
name: string;
|
|
93
|
+
deed: string;
|
|
94
|
+
color: string;
|
|
95
|
+
}
|
|
96
|
+
export interface MultiplayerHubUnlockReceipt {
|
|
97
|
+
unlocked: true;
|
|
98
|
+
/** True when this call gave the player the item for the first time. */
|
|
99
|
+
isNew: boolean;
|
|
100
|
+
/** What the player now shows in the earned slot (never a home item); null when none. */
|
|
101
|
+
earned: MultiplayerHubEarnedItem | null;
|
|
102
|
+
}
|
|
103
|
+
export interface MultiplayerHubWearEarnedReceipt {
|
|
104
|
+
/** What the player now shows in the earned slot; null when cleared. */
|
|
105
|
+
earned: MultiplayerHubEarnedItem | null;
|
|
106
|
+
}
|
|
107
|
+
export interface MultiplayerHubUnlocksRead {
|
|
108
|
+
/** Each player's owned wearable and home items, by user id. */
|
|
109
|
+
players: Record<string, {
|
|
110
|
+
owned: (MultiplayerHubEarnedItem | MultiplayerHubHomeItem)[];
|
|
111
|
+
}>;
|
|
112
|
+
}
|
|
67
113
|
export interface MultiplayerHubPadUpdateInput {
|
|
68
114
|
/** A pad door of this place, from ghost's door table. */
|
|
69
115
|
doorId: string;
|
|
@@ -91,8 +137,12 @@ export interface MultiplayerHubPlayerResults {
|
|
|
91
137
|
records: MultiplayerHubRecord[];
|
|
92
138
|
/** The player's platform avatar, chosen or default; null when ghost has none to say. */
|
|
93
139
|
avatar: number | null;
|
|
140
|
+
/** Account-wide door hearts, oldest first; absent on older ghosts. */
|
|
141
|
+
hearts?: string[];
|
|
94
142
|
/** The player's wearable; null when none. Absent from a ghost older than wearables. */
|
|
95
143
|
wearable?: MultiplayerHubWearable | null;
|
|
144
|
+
/** The earned item the player shows (contract v1.53); absent when none. */
|
|
145
|
+
earned?: MultiplayerHubEarnedItem;
|
|
96
146
|
}
|
|
97
147
|
export interface MultiplayerHubBoard {
|
|
98
148
|
toDeckId: string;
|
|
@@ -134,6 +184,8 @@ export interface MultiplayerHubResultsRead {
|
|
|
134
184
|
* and for a deck that declares no hub block.
|
|
135
185
|
*/
|
|
136
186
|
export interface MultiplayerServerHub {
|
|
187
|
+
/** The owner user id, only in an owner door instance. */
|
|
188
|
+
readonly owner?: string;
|
|
137
189
|
readonly place: string | null;
|
|
138
190
|
readonly title: string | null;
|
|
139
191
|
readonly result: MultiplayerHubResultShape | null;
|
|
@@ -156,6 +208,41 @@ export interface MultiplayerServerHub {
|
|
|
156
208
|
* without it).
|
|
157
209
|
*/
|
|
158
210
|
wear(playerId: string, wearable: MultiplayerHubWearable | null): Promise<MultiplayerHubWearReceipt>;
|
|
211
|
+
/**
|
|
212
|
+
* Gives a signed-in player one of this deck's declared earned items (castle.json
|
|
213
|
+
* `hub.unlocks`), by its id. Castle takes it only from a curated hub place, for a
|
|
214
|
+
* declared id; a first wearable unlock is also shown. Home items are never worn.
|
|
215
|
+
* Rejects with a `CAULDRON_HUB_*` code
|
|
216
|
+
* (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
|
|
217
|
+
*/
|
|
218
|
+
unlock(playerId: string, itemId: string): Promise<MultiplayerHubUnlockReceipt>;
|
|
219
|
+
/**
|
|
220
|
+
* Chooses which owned earned item a signed-in player shows, or none with null.
|
|
221
|
+
* Castle takes it only from the town; home items are refused.
|
|
222
|
+
* Rejects with a `CAULDRON_HUB_*` code
|
|
223
|
+
* (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
|
|
224
|
+
*/
|
|
225
|
+
wearEarned(playerId: string, earned: {
|
|
226
|
+
deckId: string;
|
|
227
|
+
id: string;
|
|
228
|
+
} | null): Promise<MultiplayerHubWearEarnedReceipt>;
|
|
229
|
+
/**
|
|
230
|
+
* The earned items players here own, by user id. Rejects with a `CAULDRON_HUB_*`
|
|
231
|
+
* code (`CAULDRON_HUB_UNAVAILABLE` on a runtime without it).
|
|
232
|
+
*/
|
|
233
|
+
readUnlocks(userIds: readonly string[]): Promise<MultiplayerHubUnlocksRead>;
|
|
234
|
+
/** The connected player's friends among these candidates; platform refusals reject. */
|
|
235
|
+
friendsAmong(playerId: string, userIds: readonly string[]): Promise<string[]>;
|
|
236
|
+
/**
|
|
237
|
+
* The connected player's mutual friends and follows in either direction among
|
|
238
|
+
* these candidates, in one host read. Missing lists are empty; refusals reject.
|
|
239
|
+
* These relations belong to this player only; never send them to another client.
|
|
240
|
+
*/
|
|
241
|
+
relationsAmong(playerId: string, userIds: readonly string[]): Promise<{
|
|
242
|
+
friends: string[];
|
|
243
|
+
follows: string[];
|
|
244
|
+
followedBy: string[];
|
|
245
|
+
}>;
|
|
159
246
|
/**
|
|
160
247
|
* Whether a block hides these two players from each other: either one blocked
|
|
161
248
|
* the other, in either direction. Symmetric. The block lists themselves stay
|
package/dist/server/hub.js
CHANGED
|
@@ -39,6 +39,13 @@ function hubHiddenUserIds(raw) {
|
|
|
39
39
|
}
|
|
40
40
|
return ids.size > 0 ? ids : null;
|
|
41
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
|
+
}
|
|
42
49
|
export function createServerHub(platformHandle, arrivals, hiddenPair = () => false) {
|
|
43
50
|
const pinned = platformHandle.hub;
|
|
44
51
|
if (!pinned ||
|
|
@@ -69,12 +76,41 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
|
|
|
69
76
|
place: typeof pinned.place === "string" ? pinned.place : null,
|
|
70
77
|
title: typeof pinned.title === "string" ? pinned.title : null,
|
|
71
78
|
result: pinned.result ?? null,
|
|
79
|
+
...(typeof pinned.owner === "string" && pinned.owner.length > 0 ? { owner: pinned.owner } : {}),
|
|
72
80
|
arrival(playerId) {
|
|
73
81
|
return arrivals.get(playerId) ?? null;
|
|
74
82
|
},
|
|
75
83
|
hiddenPair(playerIdA, playerIdB) {
|
|
76
84
|
return hiddenPair(playerIdA, playerIdB);
|
|
77
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
|
+
},
|
|
78
114
|
async reportResult(playerId, result) {
|
|
79
115
|
const { place, outcome, score, stats } = result ?? {};
|
|
80
116
|
return (await call("hubResult", {
|
|
@@ -121,5 +157,39 @@ export function createServerHub(platformHandle, arrivals, hiddenPair = () => fal
|
|
|
121
157
|
wearable: (receipt.wearable ?? null),
|
|
122
158
|
};
|
|
123
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
|
+
},
|
|
124
194
|
});
|
|
125
195
|
}
|
|
@@ -53,6 +53,7 @@ export interface PlatformHandle {
|
|
|
53
53
|
on(event: "worldCharacterTurn", callback: (request: PlatformWorldCharacterTurn) => unknown): void;
|
|
54
54
|
on(event: "worldCharacterCommand", callback: (request: PlatformWorldCharacterCommand) => unknown): void;
|
|
55
55
|
on(event: "worldCharacterRead", callback: (request: PlatformWorldCharacterRead) => unknown): void;
|
|
56
|
+
on(event: "worldCharacterTool", callback: (request: PlatformWorldCharacterTool) => unknown): void;
|
|
56
57
|
/**
|
|
57
58
|
* Deck storage. Strings in and strings out, like the rest of this handle, so a
|
|
58
59
|
* runtime other than the Node shim can implement it without knowing what storage is.
|
|
@@ -68,12 +69,18 @@ export interface PlatformHandle {
|
|
|
68
69
|
/** Persistent-world checkpoint broker. The host stamps the hidden world binding. */
|
|
69
70
|
worldCheckpointRead?(): Promise<string>;
|
|
70
71
|
worldCheckpointCommit?(argsJson: string): Promise<string>;
|
|
71
|
-
|
|
72
|
+
/** `tools` is the turn's place tools, passed only when non-empty. */
|
|
73
|
+
worldCharacterSend?(grantId: string, tools?: readonly PlatformPlaceTool[]): Promise<string>;
|
|
72
74
|
worldCharacterCommand?(grantId: string): Promise<string>;
|
|
73
75
|
worldCharacterRead?(grantId: string): Promise<string>;
|
|
74
76
|
worldCharacterRuntimeCreate?(argsJson: string): Promise<string>;
|
|
75
77
|
worldCharacterRuntimeList?(): Promise<string>;
|
|
76
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>;
|
|
77
84
|
/**
|
|
78
85
|
* The hub place this session runs, as ghost pinned it from the published
|
|
79
86
|
* castle.json `hub` block. Present only when the host services the hub ops
|
|
@@ -83,8 +90,16 @@ export interface PlatformHandle {
|
|
|
83
90
|
hubResult?(argsJson: string): Promise<string>;
|
|
84
91
|
hubPadUpdate?(argsJson: string): Promise<string>;
|
|
85
92
|
hubResultsRead?(argsJson: string): Promise<string>;
|
|
93
|
+
/** Friends among candidates (hub contract v1.36); older images use storage. */
|
|
94
|
+
hubFriendsAmong?(argsJson: string): Promise<string>;
|
|
86
95
|
/** Absent on a runtime older than wearables (contract v1.4). */
|
|
87
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>;
|
|
88
103
|
readonly config: PlatformSessionConfig;
|
|
89
104
|
}
|
|
90
105
|
export interface PlatformHubResultShape {
|
|
@@ -98,6 +113,7 @@ export interface PlatformHubResultShape {
|
|
|
98
113
|
stats: Record<string, "number" | "string">;
|
|
99
114
|
}
|
|
100
115
|
export interface PlatformHubPlace {
|
|
116
|
+
owner?: string;
|
|
101
117
|
place?: string;
|
|
102
118
|
title?: string | null;
|
|
103
119
|
result?: PlatformHubResultShape | null;
|
|
@@ -124,6 +140,20 @@ export interface PlatformWorldCharacterRead {
|
|
|
124
140
|
};
|
|
125
141
|
query: unknown;
|
|
126
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
|
+
}
|
|
127
157
|
export interface PlatformWorldCharacterTurn {
|
|
128
158
|
grantId: string;
|
|
129
159
|
requestId: string;
|
package/dist/server/storage.d.ts
CHANGED
|
@@ -23,6 +23,12 @@ export interface ServerStorageScope extends SaveBucketApi {
|
|
|
23
23
|
increment(key: string, delta?: number): Promise<number>;
|
|
24
24
|
list<T extends Json = Json>(options?: ServerStorageListOptions): Promise<ServerStorageListPage<T>>;
|
|
25
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
|
+
}
|
|
26
32
|
export interface ServerStorageBoardScore {
|
|
27
33
|
subject: string;
|
|
28
34
|
score: number;
|
|
@@ -263,6 +269,13 @@ export interface ServerStorage extends SaveApi {
|
|
|
263
269
|
readonly deck: ServerStorageScope;
|
|
264
270
|
/** One player's public data. Readable by anyone; writable by them or by this server. */
|
|
265
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;
|
|
266
279
|
board(name: string): ServerStorageBoard;
|
|
267
280
|
/** Opens one player's experimental, generation-bound game namespace. */
|
|
268
281
|
playthrough(userId: string): Promise<ServerPlaythrough>;
|
package/dist/server/storage.js
CHANGED
|
@@ -91,6 +91,9 @@ export function createServerStorage(platformHandle) {
|
|
|
91
91
|
}
|
|
92
92
|
return createScope(call, "USER", userId, saveApi);
|
|
93
93
|
},
|
|
94
|
+
town(userId) {
|
|
95
|
+
return createTownStorage(call, userId);
|
|
96
|
+
},
|
|
94
97
|
board(name) {
|
|
95
98
|
return createBoard(call, name);
|
|
96
99
|
},
|
|
@@ -243,6 +246,49 @@ function createPlaythrough(call, subject, generation) {
|
|
|
243
246
|
},
|
|
244
247
|
});
|
|
245
248
|
}
|
|
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
|
+
}
|
|
246
292
|
function createScope(call, scope, subject, saveApi) {
|
|
247
293
|
return {
|
|
248
294
|
...createSaveBucket(saveApi, scope === "DECK" ? "deck" : "user", subject ?? undefined),
|
package/dist/server/wrapper.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MultiplayerSessionMode, PlatformHandle, PlayerIdentity } from "./platformHandle";
|
|
1
|
+
import type { MultiplayerSessionMode, PlatformHandle, PlatformPlaceTool, PlayerIdentity } from "./platformHandle";
|
|
2
2
|
import type { Json } from "../types";
|
|
3
3
|
import type { MultiplayerWorldCharacterCommand, MultiplayerWorldCharacterCommandResult, MultiplayerWorldCharacterCommandResultValue } from "../multiplayer";
|
|
4
4
|
import { type ServerStorage } from "./storage";
|
|
@@ -8,8 +8,8 @@ export { createSaveCoordinator } from "../saveCoordinator";
|
|
|
8
8
|
export type { CoordinatedChange, CoordinatedSave, SaveCoordinator, SaveStatus, } from "../saveCoordinator";
|
|
9
9
|
export { SaveError } from "../save";
|
|
10
10
|
export type { SaveApi, SaveBucketApi, SaveBucketRead, SaveChange, SaveCheck, SaveClaimed, SaveClaimInput, SaveErrorEnvelope, SaveInitial, SaveInitializeInput, SaveOpened, SaveOperation, SaveReadTarget, SaveReceipt, SaveRevision, SaveRows, SaveScope, SaveSelection, SaveSnapshot, SaveTarget, SaveTransactionInput, SaveWriter, } from "../save";
|
|
11
|
-
export type { MultiplayerHubArrival, MultiplayerHubBoard, MultiplayerHubOutcome, MultiplayerHubPadUpdateInput, MultiplayerHubPadUpdateResult, MultiplayerHubPlayerResults, MultiplayerHubRecord, MultiplayerHubReportReceipt, MultiplayerHubResultInput, MultiplayerHubResultRow, MultiplayerHubResultShape, MultiplayerHubResultsRead, MultiplayerHubResultsReadInput, MultiplayerHubWearable, MultiplayerHubWearReceipt, MultiplayerServerHub, } from "./hub";
|
|
12
|
-
export type { CharacterActionNotice, CharacterGameStateOperation, CharacterGameStateResult, CharacterKnowledgeDeliveryOperation, CharacterKnowledgeDeliveryResult, CharacterKnowledgeSource, CompleteCharacterBeatInput, DeliverCharacterKnowledgeInput, RecordCharacterEventInput, SetCharacterGameStateInput, ShareCharacterClaimInput, ServerStorage, ServerStorageBoard, ServerStorageBoardScore, ServerStorageListEntry, ServerStorageListOptions, ServerStorageListPage, ServerPlaythrough, ServerPlaythroughCharacters, ServerCharacterHistoryOptions, ServerStorageScope, } from "./storage";
|
|
11
|
+
export type { MultiplayerHubArrival, MultiplayerHubBoard, MultiplayerHubOutcome, MultiplayerHubPadUpdateInput, MultiplayerHubPadUpdateResult, MultiplayerHubPlayerResults, MultiplayerHubRecord, MultiplayerHubReportReceipt, MultiplayerHubResultInput, MultiplayerHubResultRow, MultiplayerHubResultShape, MultiplayerHubResultsRead, MultiplayerHubResultsReadInput, MultiplayerHubWearable, MultiplayerHubWearReceipt, MultiplayerHubEarnedClass, MultiplayerHubEarnedItem, MultiplayerHubHomeClass, MultiplayerHubHomeItem, MultiplayerHubUnlockReceipt, MultiplayerHubUnlocksRead, MultiplayerHubWearEarnedReceipt, MultiplayerServerHub, } from "./hub";
|
|
12
|
+
export type { CharacterActionNotice, CharacterGameStateOperation, CharacterGameStateResult, CharacterKnowledgeDeliveryOperation, CharacterKnowledgeDeliveryResult, CharacterKnowledgeSource, CompleteCharacterBeatInput, DeliverCharacterKnowledgeInput, RecordCharacterEventInput, SetCharacterGameStateInput, ShareCharacterClaimInput, ServerStorage, ServerStorageBoard, ServerStorageBoardScore, ServerStorageListEntry, ServerStorageListOptions, ServerStorageListPage, ServerPlaythrough, ServerPlaythroughCharacters, ServerCharacterHistoryOptions, ServerStorageScope, ServerTownStorage, } from "./storage";
|
|
13
13
|
export interface MultiplayerServerPlayer extends PlayerIdentity {
|
|
14
14
|
playerId: string;
|
|
15
15
|
readonly participantId: string;
|
|
@@ -268,11 +268,35 @@ export interface MultiplayerWorldCharacterTurn {
|
|
|
268
268
|
readonly players: readonly MultiplayerServerPlayer[];
|
|
269
269
|
readonly characterIds: readonly string[];
|
|
270
270
|
};
|
|
271
|
-
/**
|
|
272
|
-
|
|
271
|
+
/**
|
|
272
|
+
* Durably admits the exact host-authorized request. No arguments can broaden it:
|
|
273
|
+
* `tools` only declares lookups the character may make of this place during the
|
|
274
|
+
* turn (answered by `onWorldCharacterTool`), and is dropped on a host that does
|
|
275
|
+
* not serve place tools.
|
|
276
|
+
*/
|
|
277
|
+
send(options?: MultiplayerWorldCharacterSendOptions): Promise<MultiplayerWorldCharacterAdmission>;
|
|
273
278
|
/** Explicitly rejects the request before admission. */
|
|
274
279
|
reject(code?: string): void;
|
|
275
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* A lookup a place offers its character for one turn. The host drops an invalid
|
|
283
|
+
* list whole (the turn proceeds without tools): at most 8, a unique `name` matching
|
|
284
|
+
* `^[A-Za-z][A-Za-z0-9_]{0,31}$` other than `perform_action` or `capture_claim`,
|
|
285
|
+
* a 1..300 character `description`, and a JSON-object `input_schema` of at most
|
|
286
|
+
* 1 KiB, with the whole list at most 6 KiB.
|
|
287
|
+
*/
|
|
288
|
+
export type MultiplayerPlaceTool = PlatformPlaceTool;
|
|
289
|
+
export interface MultiplayerWorldCharacterSendOptions {
|
|
290
|
+
tools?: readonly MultiplayerPlaceTool[];
|
|
291
|
+
}
|
|
292
|
+
/** One place-tool use by a character during a turn this place admitted. */
|
|
293
|
+
export interface MultiplayerWorldCharacterToolRequest {
|
|
294
|
+
readonly operationId: string;
|
|
295
|
+
readonly characterId: string;
|
|
296
|
+
readonly playerId: string;
|
|
297
|
+
readonly name: string;
|
|
298
|
+
readonly input: Readonly<Record<string, unknown>>;
|
|
299
|
+
}
|
|
276
300
|
type CallbackResult = unknown;
|
|
277
301
|
export interface MultiplayerServerCallbacks {
|
|
278
302
|
onStart?: (session: MultiplayerServerSession) => CallbackResult;
|
|
@@ -284,6 +308,13 @@ export interface MultiplayerServerCallbacks {
|
|
|
284
308
|
onWorldCharacterTurn?: (session: MultiplayerServerSession, turn: MultiplayerWorldCharacterTurn) => CallbackResult;
|
|
285
309
|
onWorldCharacterCommand?: (session: MultiplayerServerSession, request: MultiplayerWorldCharacterCommandRequest) => CallbackResult;
|
|
286
310
|
onWorldCharacterRead?: (session: MultiplayerServerSession, request: MultiplayerWorldCharacterReadRequest) => CallbackResult;
|
|
311
|
+
/**
|
|
312
|
+
* Answers a place-tool use declared through `turn.send({ tools })`. Return a
|
|
313
|
+
* string or a JSON value (stringified); a result over 1 KiB becomes
|
|
314
|
+
* `{"error":"result too large"}` and a throw `{"error":"tool failed"}`. The host
|
|
315
|
+
* waits 1.5 s, then the character answers without it.
|
|
316
|
+
*/
|
|
317
|
+
onWorldCharacterTool?: (request: MultiplayerWorldCharacterToolRequest, session: MultiplayerServerSession) => unknown;
|
|
287
318
|
onTick?: (session: MultiplayerServerSession) => CallbackResult;
|
|
288
319
|
onReplaced?: (session: MultiplayerServerSession) => CallbackResult;
|
|
289
320
|
/**
|
package/dist/server/wrapper.js
CHANGED
|
@@ -69,18 +69,15 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
69
69
|
players: Object.freeze(request.audience.players.map((player) => Object.freeze(joinedPlayer(player.playerId, player)))),
|
|
70
70
|
characterIds: Object.freeze([...request.audience.characterIds]),
|
|
71
71
|
}),
|
|
72
|
-
async send() {
|
|
72
|
+
async send(options) {
|
|
73
73
|
if (settled)
|
|
74
74
|
throw new TypeError("This shared character request is already settled.");
|
|
75
75
|
settled = true;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
rawReceipt = await platformHandle.worldCharacterSend(request.grantId);
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
76
|
+
const rawReceipt = await sendWorldCharacterTurn(platformHandle, request.grantId, options)
|
|
77
|
+
.catch((error) => {
|
|
81
78
|
brokerFailure.rejection = { error };
|
|
82
79
|
throw error;
|
|
83
|
-
}
|
|
80
|
+
});
|
|
84
81
|
return parseWorldCharacterAdmission(rawReceipt, request.operationId);
|
|
85
82
|
},
|
|
86
83
|
reject(code) {
|
|
@@ -103,6 +100,7 @@ async function bootSession(platformHandle, callbacks) {
|
|
|
103
100
|
}
|
|
104
101
|
});
|
|
105
102
|
}
|
|
103
|
+
listenForWorldCharacterTools(platformHandle, callbacks, session);
|
|
106
104
|
if (callbacks.onWorldCharacterCommand &&
|
|
107
105
|
hasWorldCharacterPlatform(platformHandle, "worldCharacterCommand")) {
|
|
108
106
|
platformHandle.on("worldCharacterCommand", async (raw) => {
|
|
@@ -1081,12 +1079,13 @@ function assertRuntimeCharacterCapabilities(value) {
|
|
|
1081
1079
|
}
|
|
1082
1080
|
return Object.freeze({ actionIds: Object.freeze([...normalized].sort()) });
|
|
1083
1081
|
}
|
|
1082
|
+
const LONE_SURROGATE = /[\uD800-\uDFFF]/u;
|
|
1084
1083
|
function assertRuntimeCharacterText(value, field, maxCharacters, maxBytes) {
|
|
1085
1084
|
if (typeof value !== "string" ||
|
|
1086
1085
|
value.length === 0 ||
|
|
1087
1086
|
value.length > maxCharacters ||
|
|
1088
1087
|
value.includes("\0") ||
|
|
1089
|
-
|
|
1088
|
+
LONE_SURROGATE.test(value) ||
|
|
1090
1089
|
new TextEncoder().encode(value).byteLength > maxBytes) {
|
|
1091
1090
|
throw new TypeError(`session.world.characters.create: ${field} must be valid non-empty text of at most ${maxCharacters} characters.`);
|
|
1092
1091
|
}
|
|
@@ -1190,6 +1189,82 @@ function assertPlatformWorldCharacterRead(value) {
|
|
|
1190
1189
|
}
|
|
1191
1190
|
return value;
|
|
1192
1191
|
}
|
|
1192
|
+
// Tools ride only to a host that can answer them (an older host refuses a send
|
|
1193
|
+
// carrying anything beyond the grant); otherwise this is exactly the tool-less send.
|
|
1194
|
+
function sendWorldCharacterTurn(platformHandle, grantId, options) {
|
|
1195
|
+
const tools = options?.tools;
|
|
1196
|
+
return Array.isArray(tools) && tools.length > 0 &&
|
|
1197
|
+
typeof platformHandle.worldCharacterToolResult === "function"
|
|
1198
|
+
? platformHandle.worldCharacterSend(grantId, tools)
|
|
1199
|
+
: platformHandle.worldCharacterSend(grantId);
|
|
1200
|
+
}
|
|
1201
|
+
// Only a handler on a host that answers place tools listens; without one the
|
|
1202
|
+
// host times the lookup out and the character answers without it.
|
|
1203
|
+
function listenForWorldCharacterTools(platformHandle, callbacks, session) {
|
|
1204
|
+
const handler = callbacks.onWorldCharacterTool;
|
|
1205
|
+
const answer = platformHandle.worldCharacterToolResult?.bind(platformHandle);
|
|
1206
|
+
if (!handler || !answer)
|
|
1207
|
+
return;
|
|
1208
|
+
try {
|
|
1209
|
+
platformHandle.on("worldCharacterTool", async (raw) => {
|
|
1210
|
+
const call = platformWorldCharacterTool(raw);
|
|
1211
|
+
if (!call)
|
|
1212
|
+
return;
|
|
1213
|
+
const result = await worldCharacterToolResult(handler, call.request, session);
|
|
1214
|
+
try {
|
|
1215
|
+
await answer(call.requestId, result);
|
|
1216
|
+
}
|
|
1217
|
+
catch {
|
|
1218
|
+
// The host has stopped waiting (or refused); the character answers without it.
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
catch {
|
|
1223
|
+
// Older shims may reject event names they do not recognize.
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
const MAX_WORLD_CHARACTER_TOOL_RESULT_BYTES = 1024;
|
|
1227
|
+
const WORLD_CHARACTER_TOOL_TOO_LARGE = '{"error":"result too large"}';
|
|
1228
|
+
const WORLD_CHARACTER_TOOL_FAILED = '{"error":"tool failed"}';
|
|
1229
|
+
function platformWorldCharacterTool(value) {
|
|
1230
|
+
if (!value ||
|
|
1231
|
+
typeof value !== "object" ||
|
|
1232
|
+
typeof value.requestId !== "string" ||
|
|
1233
|
+
typeof value.operationId !== "string" ||
|
|
1234
|
+
typeof value.characterId !== "string" ||
|
|
1235
|
+
typeof value.playerId !== "string" ||
|
|
1236
|
+
typeof value.name !== "string" ||
|
|
1237
|
+
!value.input ||
|
|
1238
|
+
typeof value.input !== "object" ||
|
|
1239
|
+
Array.isArray(value.input)) {
|
|
1240
|
+
return null;
|
|
1241
|
+
}
|
|
1242
|
+
return {
|
|
1243
|
+
requestId: value.requestId,
|
|
1244
|
+
request: Object.freeze({
|
|
1245
|
+
operationId: value.operationId,
|
|
1246
|
+
characterId: value.characterId,
|
|
1247
|
+
playerId: value.playerId,
|
|
1248
|
+
name: value.name,
|
|
1249
|
+
input: deepFreezeJsonObject(value.input),
|
|
1250
|
+
}),
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
async function worldCharacterToolResult(handler, request, session) {
|
|
1254
|
+
let result;
|
|
1255
|
+
try {
|
|
1256
|
+
const value = await handler(request, session);
|
|
1257
|
+
result = typeof value === "string" ? value : JSON.stringify(value);
|
|
1258
|
+
}
|
|
1259
|
+
catch {
|
|
1260
|
+
return WORLD_CHARACTER_TOOL_FAILED;
|
|
1261
|
+
}
|
|
1262
|
+
if (typeof result !== "string")
|
|
1263
|
+
return WORLD_CHARACTER_TOOL_FAILED;
|
|
1264
|
+
return new TextEncoder().encode(result).byteLength > MAX_WORLD_CHARACTER_TOOL_RESULT_BYTES
|
|
1265
|
+
? WORLD_CHARACTER_TOOL_TOO_LARGE
|
|
1266
|
+
: result;
|
|
1267
|
+
}
|
|
1193
1268
|
function hasWorldCharacterPlatform(platformHandle, operation) {
|
|
1194
1269
|
return (typeof platformHandle[operation] === "function" &&
|
|
1195
1270
|
typeof platformHandle.worldCharacterReject === "function");
|