castle-web-sdk 0.4.31 → 0.4.33
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 +25 -0
- package/dist/commands.d.ts +16 -0
- package/dist/devUnavailableNotice.d.ts +1 -0
- package/dist/devUnavailableNotice.js +36 -0
- package/dist/passes.js +2 -34
- package/dist/portal.d.ts +3 -2
- package/dist/portal.js +22 -0
- package/dist/server/storage.d.ts +9 -0
- package/dist/server/storage.js +75 -11
- package/dist/snapshots.js +2 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -591,6 +591,31 @@ link.addEventListener("pointerenter", () => {
|
|
|
591
591
|
});
|
|
592
592
|
```
|
|
593
593
|
|
|
594
|
+
### `Portal.info(deckId): Promise<PortalInfoResult>`
|
|
595
|
+
|
|
596
|
+
Where a portal leads: the destination deck's title, creator and cover, for a
|
|
597
|
+
sign or label on the portal. A deck can't reach Castle's API or load remote
|
|
598
|
+
images itself, so this is how it shows another deck's details. It needs a
|
|
599
|
+
Castle login, which the app and a signed-in website visitor have; the cloud
|
|
600
|
+
editor's preview and a signed-out visitor get `unavailable`, so keep your own
|
|
601
|
+
label as the fallback. `PortalInfoResult` has a `status`:
|
|
602
|
+
|
|
603
|
+
- `'ok'` — also has `deckId`, `title`, `creator` (`{ userId, username }` or
|
|
604
|
+
`null`), and `coverUrl`, a `data:` url of the deck's cover or `null`.
|
|
605
|
+
- `'broken'` — the portal wouldn't work for this player: the deck doesn't
|
|
606
|
+
exist, has portals turned off, or isn't visible to them.
|
|
607
|
+
- `'unavailable'` — the host couldn't look it up.
|
|
608
|
+
|
|
609
|
+
```js
|
|
610
|
+
const info = await Portal.info(doorway.deckId);
|
|
611
|
+
if (info.status === "ok") {
|
|
612
|
+
sign.textContent = `${info.title} by @${info.creator?.username ?? "?"}`;
|
|
613
|
+
if (info.coverUrl) sign.style.backgroundImage = `url(${info.coverUrl})`;
|
|
614
|
+
}
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Answers are kept per deck for the session, so asking again is cheap.
|
|
618
|
+
|
|
594
619
|
## Haptics
|
|
595
620
|
|
|
596
621
|
`Haptics` plays a short device vibration ("buzz") for tactile feedback —
|
package/dist/commands.d.ts
CHANGED
|
@@ -57,6 +57,18 @@ export interface PortalOpenResult {
|
|
|
57
57
|
status: PortalOpenStatus;
|
|
58
58
|
}
|
|
59
59
|
export type PortalPrefetchStatus = "prefetching" | "rejected" | "unavailable";
|
|
60
|
+
export type PortalInfoResult = {
|
|
61
|
+
status: "ok";
|
|
62
|
+
deckId: string;
|
|
63
|
+
title: string;
|
|
64
|
+
creator: {
|
|
65
|
+
userId: string;
|
|
66
|
+
username: string;
|
|
67
|
+
} | null;
|
|
68
|
+
coverUrl: string | null;
|
|
69
|
+
} | {
|
|
70
|
+
status: "broken" | "unavailable";
|
|
71
|
+
};
|
|
60
72
|
export interface PortalPrefetchResult {
|
|
61
73
|
status: PortalPrefetchStatus;
|
|
62
74
|
}
|
|
@@ -186,6 +198,9 @@ export interface CommandParams {
|
|
|
186
198
|
"portal.prefetch": {
|
|
187
199
|
targetDeckId: string;
|
|
188
200
|
};
|
|
201
|
+
"portal.info": {
|
|
202
|
+
targetDeckId: string;
|
|
203
|
+
};
|
|
189
204
|
"haptics.play": {
|
|
190
205
|
style: HapticStyle;
|
|
191
206
|
};
|
|
@@ -306,6 +321,7 @@ export interface CommandResult {
|
|
|
306
321
|
"snapshot.offer": SnapshotOfferResult;
|
|
307
322
|
"portal.open": PortalOpenResult;
|
|
308
323
|
"portal.prefetch": PortalPrefetchResult;
|
|
324
|
+
"portal.info": PortalInfoResult;
|
|
309
325
|
"haptics.play": HapticsResult;
|
|
310
326
|
"multiplayer.getSession": MultiplayerGetSessionResult;
|
|
311
327
|
"characters.open": CharacterAdmission;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function createDevUnavailableNotice(message: string): () => void;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function createDevUnavailableNotice(message) {
|
|
2
|
+
let noticeEl = null;
|
|
3
|
+
let noticeTimer = null;
|
|
4
|
+
return () => {
|
|
5
|
+
if (typeof document === "undefined")
|
|
6
|
+
return;
|
|
7
|
+
if (!noticeEl) {
|
|
8
|
+
noticeEl = document.createElement("div");
|
|
9
|
+
noticeEl.textContent = message;
|
|
10
|
+
noticeEl.style.cssText = [
|
|
11
|
+
"position:fixed",
|
|
12
|
+
"left:50%",
|
|
13
|
+
"bottom:24px",
|
|
14
|
+
"transform:translateX(-50%)",
|
|
15
|
+
"max-width:80vw",
|
|
16
|
+
"padding:10px 16px",
|
|
17
|
+
"border-radius:8px",
|
|
18
|
+
"background:rgba(0,0,0,0.82)",
|
|
19
|
+
"color:#fff",
|
|
20
|
+
"font:500 13px/1.4 system-ui,sans-serif",
|
|
21
|
+
"text-align:center",
|
|
22
|
+
"z-index:2147483647",
|
|
23
|
+
"pointer-events:none",
|
|
24
|
+
"transition:opacity 0.3s ease",
|
|
25
|
+
].join(";");
|
|
26
|
+
document.body.appendChild(noticeEl);
|
|
27
|
+
}
|
|
28
|
+
noticeEl.style.opacity = "1";
|
|
29
|
+
if (noticeTimer)
|
|
30
|
+
clearTimeout(noticeTimer);
|
|
31
|
+
noticeTimer = setTimeout(() => {
|
|
32
|
+
if (noticeEl)
|
|
33
|
+
noticeEl.style.opacity = "0";
|
|
34
|
+
}, 3200);
|
|
35
|
+
};
|
|
36
|
+
}
|
package/dist/passes.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// notice itself (kept deliberately tiny), returns `unavailable`
|
|
10
10
|
// There is no capability check for the deck to make — every platform returns a
|
|
11
11
|
// PassOfferResult, so a single code path handles them all.
|
|
12
|
+
import { createDevUnavailableNotice } from "./devUnavailableNotice";
|
|
12
13
|
import { CastleError } from "./errors";
|
|
13
14
|
import { getCommandChannel, hostRequest } from "./transport";
|
|
14
15
|
export const Pass = {
|
|
@@ -46,39 +47,6 @@ async function offer(passId) {
|
|
|
46
47
|
}
|
|
47
48
|
return result;
|
|
48
49
|
}
|
|
49
|
-
let devNoticeEl = null;
|
|
50
|
-
let devNoticeTimer = null;
|
|
51
50
|
// Minimal, dependency-free toast. Dev-only affordance — not the place for a
|
|
52
51
|
// designed purchase UI.
|
|
53
|
-
|
|
54
|
-
if (typeof document === "undefined")
|
|
55
|
-
return;
|
|
56
|
-
if (!devNoticeEl) {
|
|
57
|
-
devNoticeEl = document.createElement("div");
|
|
58
|
-
devNoticeEl.textContent = "Passes can only be purchased in the Castle app.";
|
|
59
|
-
devNoticeEl.style.cssText = [
|
|
60
|
-
"position:fixed",
|
|
61
|
-
"left:50%",
|
|
62
|
-
"bottom:24px",
|
|
63
|
-
"transform:translateX(-50%)",
|
|
64
|
-
"max-width:80vw",
|
|
65
|
-
"padding:10px 16px",
|
|
66
|
-
"border-radius:8px",
|
|
67
|
-
"background:rgba(0,0,0,0.82)",
|
|
68
|
-
"color:#fff",
|
|
69
|
-
"font:500 13px/1.4 system-ui,sans-serif",
|
|
70
|
-
"text-align:center",
|
|
71
|
-
"z-index:2147483647",
|
|
72
|
-
"pointer-events:none",
|
|
73
|
-
"transition:opacity 0.3s ease",
|
|
74
|
-
].join(";");
|
|
75
|
-
document.body.appendChild(devNoticeEl);
|
|
76
|
-
}
|
|
77
|
-
devNoticeEl.style.opacity = "1";
|
|
78
|
-
if (devNoticeTimer)
|
|
79
|
-
clearTimeout(devNoticeTimer);
|
|
80
|
-
devNoticeTimer = setTimeout(() => {
|
|
81
|
-
if (devNoticeEl)
|
|
82
|
-
devNoticeEl.style.opacity = "0";
|
|
83
|
-
}, 3200);
|
|
84
|
-
}
|
|
52
|
+
const showDevUnavailableNotice = createDevUnavailableNotice("Passes can only be purchased in the Castle app.");
|
package/dist/portal.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { PortalOpenResult, PortalPrefetchResult } from "./commands";
|
|
2
|
-
export type { PortalOpenResult, PortalOpenStatus, PortalPrefetchResult, PortalPrefetchStatus, } from "./commands";
|
|
1
|
+
import type { PortalInfoResult, PortalOpenResult, PortalPrefetchResult } from "./commands";
|
|
2
|
+
export type { PortalInfoResult, PortalOpenResult, PortalOpenStatus, PortalPrefetchResult, PortalPrefetchStatus, } from "./commands";
|
|
3
3
|
export interface CastlePortalApi {
|
|
4
4
|
open(deckId: string): Promise<PortalOpenResult>;
|
|
5
5
|
prefetch(deckId: string): Promise<PortalPrefetchResult>;
|
|
6
|
+
info(deckId: string): Promise<PortalInfoResult>;
|
|
6
7
|
}
|
|
7
8
|
export declare const Portal: CastlePortalApi;
|
package/dist/portal.js
CHANGED
|
@@ -7,11 +7,16 @@
|
|
|
7
7
|
// Navigation is effectively fire-and-forget: once the host accepts it, the
|
|
8
8
|
// source deck is torn down as the feed moves to the target, so a deck should
|
|
9
9
|
// not rely on code running after a successful `open`.
|
|
10
|
+
//
|
|
11
|
+
// `info` is a plain read of where a portal leads: the target's title, creator
|
|
12
|
+
// and cover, for a sign on the portal. It works on every host that has a
|
|
13
|
+
// Castle login.
|
|
10
14
|
import { CastleError } from "./errors";
|
|
11
15
|
import { hostRequest } from "./transport";
|
|
12
16
|
export const Portal = {
|
|
13
17
|
open,
|
|
14
18
|
prefetch,
|
|
19
|
+
info,
|
|
15
20
|
};
|
|
16
21
|
async function open(deckId) {
|
|
17
22
|
if (typeof deckId !== "string" || deckId.length === 0) {
|
|
@@ -33,3 +38,20 @@ async function prefetch(deckId) {
|
|
|
33
38
|
}
|
|
34
39
|
return hostRequest("portal.prefetch", { targetDeckId: deckId });
|
|
35
40
|
}
|
|
41
|
+
async function info(deckId) {
|
|
42
|
+
if (typeof deckId !== "string" || deckId.length === 0) {
|
|
43
|
+
throw new CastleError({
|
|
44
|
+
code: "INVALID_ARGUMENT",
|
|
45
|
+
message: "Portal.info requires a deckId.",
|
|
46
|
+
operation: "Portal.info",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
return await hostRequest("portal.info", { targetDeckId: deckId });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// No login, an old host, or a failed lookup all read the same to a deck:
|
|
54
|
+
// there is nothing to show, so it keeps whatever label it had.
|
|
55
|
+
return { status: "unavailable" };
|
|
56
|
+
}
|
|
57
|
+
}
|
package/dist/server/storage.d.ts
CHANGED
|
@@ -63,6 +63,15 @@ export interface SetCharacterGameStateInput {
|
|
|
63
63
|
key: string;
|
|
64
64
|
value: string | number | boolean;
|
|
65
65
|
}>;
|
|
66
|
+
objectChanges?: CharacterObjectChange[];
|
|
67
|
+
}
|
|
68
|
+
export interface CharacterObjectChange {
|
|
69
|
+
objectId: string;
|
|
70
|
+
holderId?: string;
|
|
71
|
+
stateChanges?: Array<{
|
|
72
|
+
key: string;
|
|
73
|
+
value: string | number | boolean;
|
|
74
|
+
}>;
|
|
66
75
|
}
|
|
67
76
|
export interface ShareCharacterClaimInput {
|
|
68
77
|
operationId: string;
|
package/dist/server/storage.js
CHANGED
|
@@ -94,11 +94,15 @@ function createPlaythrough(call, subject, generation) {
|
|
|
94
94
|
});
|
|
95
95
|
},
|
|
96
96
|
async setGameState(input) {
|
|
97
|
+
const objectChanges = input?.objectChanges === undefined
|
|
98
|
+
? undefined
|
|
99
|
+
: assertObjectChanges(input.objectChanges);
|
|
97
100
|
return call("characterSetGameState", {
|
|
98
101
|
subject, generation,
|
|
99
102
|
operationId: assertOperationId(input?.operationId),
|
|
100
103
|
expectedRevision: assertStateRevision(input?.expectedRevision),
|
|
101
|
-
changes: assertStateChanges(input?.changes),
|
|
104
|
+
changes: assertStateChanges(input?.changes, objectChanges !== undefined),
|
|
105
|
+
...(objectChanges === undefined ? {} : { objectChanges }),
|
|
102
106
|
});
|
|
103
107
|
},
|
|
104
108
|
async shareClaim(input) {
|
|
@@ -398,24 +402,84 @@ function assertStateRevision(value) {
|
|
|
398
402
|
}
|
|
399
403
|
return value;
|
|
400
404
|
}
|
|
401
|
-
function assertStateChanges(value) {
|
|
402
|
-
if (!Array.isArray(value) || value.length
|
|
403
|
-
throw characterArgumentError(
|
|
405
|
+
function assertStateChanges(value, hasObjectChanges = false) {
|
|
406
|
+
if (!Array.isArray(value) || value.length > 32 || (!hasObjectChanges && value.length < 1)) {
|
|
407
|
+
throw characterArgumentError(hasObjectChanges
|
|
408
|
+
? "changes must contain at most 32 fields."
|
|
409
|
+
: "changes must contain one to 32 fields.");
|
|
410
|
+
}
|
|
411
|
+
const seen = new Set();
|
|
412
|
+
return value.map((change) => assertStateChange(change, seen, "state change"));
|
|
413
|
+
}
|
|
414
|
+
function assertObjectChanges(value) {
|
|
415
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 16) {
|
|
416
|
+
throw characterArgumentError("objectChanges must contain one to 16 objects.");
|
|
417
|
+
}
|
|
418
|
+
const seenObjects = new Set();
|
|
419
|
+
return value.map((change) => {
|
|
420
|
+
if (!change || typeof change !== "object" || Array.isArray(change)) {
|
|
421
|
+
throw characterArgumentError("object change must name an object and a holder or state change.");
|
|
422
|
+
}
|
|
423
|
+
const candidate = change;
|
|
424
|
+
if (Object.keys(candidate).some((key) => key !== "objectId" && key !== "holderId" && key !== "stateChanges")) {
|
|
425
|
+
throw characterArgumentError("object change has an unknown field.");
|
|
426
|
+
}
|
|
427
|
+
const objectId = assertMechanicsKey(candidate.objectId, "objectChanges.objectId");
|
|
428
|
+
if (seenObjects.has(objectId)) {
|
|
429
|
+
throw characterArgumentError("objectChanges contains a duplicate object.");
|
|
430
|
+
}
|
|
431
|
+
seenObjects.add(objectId);
|
|
432
|
+
const hasHolder = candidate.holderId !== undefined;
|
|
433
|
+
const holderId = hasHolder
|
|
434
|
+
? assertNarrativeId(candidate.holderId, "objectChanges.holderId")
|
|
435
|
+
: undefined;
|
|
436
|
+
const stateChanges = candidate.stateChanges === undefined
|
|
437
|
+
? undefined
|
|
438
|
+
: assertObjectStateChanges(candidate.stateChanges);
|
|
439
|
+
if (!hasHolder && !stateChanges?.length) {
|
|
440
|
+
throw characterArgumentError("object change must include holderId or stateChanges.");
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
objectId,
|
|
444
|
+
...(hasHolder ? { holderId } : {}),
|
|
445
|
+
...(stateChanges === undefined ? {} : { stateChanges }),
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
function assertObjectStateChanges(value) {
|
|
450
|
+
if (!Array.isArray(value) || value.length > 8) {
|
|
451
|
+
throw characterArgumentError("object stateChanges must contain at most eight fields.");
|
|
404
452
|
}
|
|
405
453
|
const seen = new Set();
|
|
406
454
|
return value.map((change) => {
|
|
407
455
|
if (!change || typeof change !== "object" || Array.isArray(change)) {
|
|
408
|
-
throw characterArgumentError("state change must contain a key and scalar value.");
|
|
456
|
+
throw characterArgumentError("object state change must contain a key and scalar value.");
|
|
409
457
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
key === "constructor" || key === "prototype" || seen.has(key) || !assertScalar(field)) {
|
|
413
|
-
throw characterArgumentError("state change has an invalid or duplicate field.");
|
|
458
|
+
if (Object.keys(change).some((key) => key !== "key" && key !== "value")) {
|
|
459
|
+
throw characterArgumentError("object state change has an unknown field.");
|
|
414
460
|
}
|
|
415
|
-
seen
|
|
416
|
-
return { key, value: field };
|
|
461
|
+
return assertStateChange(change, seen, "object state change");
|
|
417
462
|
});
|
|
418
463
|
}
|
|
464
|
+
function assertStateChange(change, seen, label) {
|
|
465
|
+
if (!change || typeof change !== "object" || Array.isArray(change)) {
|
|
466
|
+
throw characterArgumentError(`${label} must contain a key and scalar value.`);
|
|
467
|
+
}
|
|
468
|
+
const { key, value } = change;
|
|
469
|
+
if (typeof key !== "string" || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) ||
|
|
470
|
+
key === "constructor" || key === "prototype" || seen.has(key) || !assertScalar(value)) {
|
|
471
|
+
throw characterArgumentError(`${label} has an invalid or duplicate field.`);
|
|
472
|
+
}
|
|
473
|
+
seen.add(key);
|
|
474
|
+
return { key, value };
|
|
475
|
+
}
|
|
476
|
+
function assertMechanicsKey(value, field) {
|
|
477
|
+
if (typeof value !== "string" || !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(value) ||
|
|
478
|
+
value === "constructor" || value === "prototype") {
|
|
479
|
+
throw characterArgumentError(`${field} must be a safe key of at most 64 characters.`);
|
|
480
|
+
}
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
419
483
|
function assertHistoryLimit(value) {
|
|
420
484
|
if (value === undefined || value === null)
|
|
421
485
|
return null;
|
package/dist/snapshots.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// - mobile app : the collect card over the captured frame, then the confirm sheet
|
|
10
10
|
// - web player : "collect this snapshot in the app", returns `unavailable`
|
|
11
11
|
// - dev CLI : a small notice, returns `unavailable`
|
|
12
|
+
import { createDevUnavailableNotice } from "./devUnavailableNotice";
|
|
12
13
|
import { CastleError } from "./errors";
|
|
13
14
|
import { getCommandChannel, hostRequest } from "./transport";
|
|
14
15
|
export const Snapshot = {
|
|
@@ -89,40 +90,4 @@ function nextPaintedFrame() {
|
|
|
89
90
|
});
|
|
90
91
|
});
|
|
91
92
|
}
|
|
92
|
-
|
|
93
|
-
let devNoticeTimer = null;
|
|
94
|
-
function showDevUnavailableNotice() {
|
|
95
|
-
if (typeof document === "undefined") {
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
if (!devNoticeEl) {
|
|
99
|
-
devNoticeEl = document.createElement("div");
|
|
100
|
-
devNoticeEl.textContent = "Snapshots can only be collected in the Castle app.";
|
|
101
|
-
devNoticeEl.style.cssText = [
|
|
102
|
-
"position:fixed",
|
|
103
|
-
"left:50%",
|
|
104
|
-
"bottom:24px",
|
|
105
|
-
"transform:translateX(-50%)",
|
|
106
|
-
"max-width:80vw",
|
|
107
|
-
"padding:10px 16px",
|
|
108
|
-
"border-radius:8px",
|
|
109
|
-
"background:rgba(0,0,0,0.82)",
|
|
110
|
-
"color:#fff",
|
|
111
|
-
"font:500 13px/1.4 system-ui,sans-serif",
|
|
112
|
-
"text-align:center",
|
|
113
|
-
"z-index:2147483647",
|
|
114
|
-
"pointer-events:none",
|
|
115
|
-
"transition:opacity 0.3s ease",
|
|
116
|
-
].join(";");
|
|
117
|
-
document.body.appendChild(devNoticeEl);
|
|
118
|
-
}
|
|
119
|
-
devNoticeEl.style.opacity = "1";
|
|
120
|
-
if (devNoticeTimer) {
|
|
121
|
-
clearTimeout(devNoticeTimer);
|
|
122
|
-
}
|
|
123
|
-
devNoticeTimer = setTimeout(() => {
|
|
124
|
-
if (devNoticeEl) {
|
|
125
|
-
devNoticeEl.style.opacity = "0";
|
|
126
|
-
}
|
|
127
|
-
}, 3200);
|
|
128
|
-
}
|
|
93
|
+
const showDevUnavailableNotice = createDevUnavailableNotice("Snapshots can only be collected in the Castle app.");
|