castle-web-sdk 0.4.5 → 0.4.7

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/src/errors.ts DELETED
@@ -1,32 +0,0 @@
1
- export interface GraphqlErrorPayload {
2
- message?: string;
3
- extensions?: Record<string, unknown>;
4
- path?: Array<string | number>;
5
- }
6
-
7
- interface CastleErrorInput {
8
- code: string;
9
- message: string;
10
- operation?: string;
11
- status?: number;
12
- extensions?: Record<string, unknown>;
13
- errors?: GraphqlErrorPayload[];
14
- }
15
-
16
- export class CastleError extends Error {
17
- code: string;
18
- operation?: string;
19
- status?: number;
20
- extensions?: Record<string, unknown>;
21
- errors?: GraphqlErrorPayload[];
22
-
23
- constructor(input: CastleErrorInput) {
24
- super(input.message);
25
- this.name = "CastleError";
26
- this.code = input.code;
27
- this.operation = input.operation;
28
- this.status = input.status;
29
- this.extensions = input.extensions;
30
- this.errors = input.errors;
31
- }
32
- }
@@ -1,348 +0,0 @@
1
- import {
2
- type RawLeaderboard,
3
- type RawLeaderboardEntry,
4
- } from "./commands";
5
- import { isEdit } from "./context";
6
- import { CastleError } from "./errors";
7
- import { hostRequest } from "./transport";
8
-
9
- export type LeaderboardSort = "high" | "low";
10
- export type LeaderboardScope = string;
11
-
12
- export interface LeaderboardOptions {
13
- scope?: LeaderboardScope | null;
14
- }
15
-
16
- export interface LeaderboardEntry {
17
- place: number;
18
- value: number;
19
- username: string;
20
- userId?: string;
21
- }
22
-
23
- export interface LeaderboardData {
24
- list: LeaderboardEntry[];
25
- playerRank?: number;
26
- playerValue?: number;
27
- }
28
-
29
- interface PendingLeaderboardWrite {
30
- variable: string;
31
- scope: string | null;
32
- highScore: number;
33
- lowScore: number;
34
- isHighDirty: boolean;
35
- isLowDirty: boolean;
36
- }
37
-
38
- interface LeaderboardWriteJob {
39
- record: PendingLeaderboardWrite;
40
- type: "high" | "low" | "both";
41
- score: number;
42
- }
43
-
44
- const LEADERBOARD_FLUSH_INTERVAL_MS = 5000;
45
-
46
- // Host stamps deckId, so one deck session = one set of leaderboards; key by
47
- // variable+scope only and keep the best high/low score per key until flush.
48
- const leaderboardWrites = new Map<string, PendingLeaderboardWrite>();
49
- let leaderboardFlushTimer: ReturnType<typeof setTimeout> | null = null;
50
- let leaderboardFlushPromise: Promise<void> | null = null;
51
- let leaderboardUnloadHooked = false;
52
-
53
- export const Leaderboard = {
54
- write(
55
- variable: string,
56
- score: number,
57
- options: LeaderboardOptions = {},
58
- ): void {
59
- writeLeaderboard(variable, score, options);
60
- },
61
-
62
- fetch(
63
- variable: string,
64
- type: LeaderboardSort,
65
- options: LeaderboardOptions = {},
66
- ): Promise<LeaderboardData> {
67
- return fetchLeaderboardData(variable, type, options);
68
- },
69
- } as const;
70
-
71
- function writeLeaderboard(
72
- variable: string,
73
- score: number,
74
- options: LeaderboardOptions,
75
- ): void {
76
- if (isEdit()) return;
77
- try {
78
- bufferLeaderboardWrite(variable, score, options);
79
- } catch (error) {
80
- reportLeaderboardError(error);
81
- }
82
- }
83
-
84
- async function fetchLeaderboardData(
85
- variable: string,
86
- type: LeaderboardSort,
87
- options: LeaderboardOptions,
88
- ): Promise<LeaderboardData> {
89
- assertLeaderboardVariable(variable, "Leaderboard.fetch");
90
- assertLeaderboardType(type, "Leaderboard.fetch");
91
- const scope = leaderboardScope(options);
92
- // If the deck has written a score for this variable+scope this session, send
93
- // it so the host writes-and-reads via leaderboardV2 and the player's own
94
- // score shows up immediately (mirrors getLeaderboard in
95
- // core/src/leaderboards.cpp — presence of a buffered score, not dirtiness,
96
- // gates the write-through). Otherwise a plain read of the settled board.
97
- const record = leaderboardWrites.get(leaderboardWriteKey(variable, scope));
98
- const score = record
99
- ? type === "high"
100
- ? record.highScore
101
- : record.lowScore
102
- : null;
103
- const { leaderboard, currentUserId } = await hostRequest("leaderboard.fetch", {
104
- variable,
105
- type,
106
- scope,
107
- ...(score === null ? {} : { score }),
108
- });
109
- if (record && score !== null) {
110
- clearLeaderboardDirtyAfterFetch(record, type, score);
111
- }
112
- return normalizeLeaderboard(leaderboard, currentUserId);
113
- }
114
-
115
- // After a write-through fetch, clear the dirty flag for the side we just sent
116
- // (so the periodic flush won't re-send it via saveVariableToLeaderboard). If
117
- // the other side's buffered value matches what we sent (the common single-score
118
- // case where high == low), clear it too. The equality guards skip clearing if a
119
- // concurrent write bumped the buffered score while the fetch was in flight —
120
- // that newer score still needs flushing. Mirrors leaderboards.cpp.
121
- function clearLeaderboardDirtyAfterFetch(
122
- record: PendingLeaderboardWrite,
123
- type: LeaderboardSort,
124
- sentScore: number,
125
- ): void {
126
- if (type === "high") {
127
- if (record.highScore === sentScore) {
128
- record.isHighDirty = false;
129
- if (record.lowScore === sentScore) record.isLowDirty = false;
130
- }
131
- } else {
132
- if (record.lowScore === sentScore) {
133
- record.isLowDirty = false;
134
- if (record.highScore === sentScore) record.isHighDirty = false;
135
- }
136
- }
137
- }
138
-
139
- function bufferLeaderboardWrite(
140
- variable: string,
141
- score: number,
142
- options: LeaderboardOptions,
143
- ): void {
144
- assertLeaderboardVariable(variable, "Leaderboard.write");
145
- assertLeaderboardScore(score, "Leaderboard.write");
146
- const scope = leaderboardScope(options);
147
- const key = leaderboardWriteKey(variable, scope);
148
- const record = leaderboardWrites.get(key);
149
- if (record) {
150
- updatePendingLeaderboardWrite(record, score);
151
- } else {
152
- leaderboardWrites.set(key, newPendingLeaderboardWrite(variable, scope, score));
153
- }
154
- ensureLeaderboardUnloadFlush();
155
- scheduleLeaderboardFlush();
156
- }
157
-
158
- function newPendingLeaderboardWrite(
159
- variable: string,
160
- scope: string | null,
161
- score: number,
162
- ): PendingLeaderboardWrite {
163
- return {
164
- variable,
165
- scope,
166
- highScore: score,
167
- lowScore: score,
168
- isHighDirty: true,
169
- isLowDirty: true,
170
- };
171
- }
172
-
173
- function updatePendingLeaderboardWrite(
174
- record: PendingLeaderboardWrite,
175
- score: number,
176
- ): void {
177
- if (score > record.highScore) {
178
- record.highScore = score;
179
- record.isHighDirty = true;
180
- }
181
- if (score < record.lowScore) {
182
- record.lowScore = score;
183
- record.isLowDirty = true;
184
- }
185
- }
186
-
187
- function scheduleLeaderboardFlush(): void {
188
- if (leaderboardFlushTimer) return;
189
- leaderboardFlushTimer = setTimeout(() => {
190
- leaderboardFlushTimer = null;
191
- void flushLeaderboardWrites().catch(reportLeaderboardError);
192
- }, LEADERBOARD_FLUSH_INTERVAL_MS);
193
- }
194
-
195
- function ensureLeaderboardUnloadFlush(): void {
196
- if (leaderboardUnloadHooked || typeof window === "undefined") return;
197
- leaderboardUnloadHooked = true;
198
- window.addEventListener("pagehide", () => {
199
- void flushLeaderboardWrites().catch(reportLeaderboardError);
200
- });
201
- }
202
-
203
- async function flushLeaderboardWrites(): Promise<void> {
204
- if (leaderboardFlushPromise) return leaderboardFlushPromise;
205
- leaderboardFlushPromise = flushLeaderboardWritesOnce().finally(() => {
206
- leaderboardFlushPromise = null;
207
- if (hasDirtyLeaderboardWrites()) scheduleLeaderboardFlush();
208
- });
209
- return leaderboardFlushPromise;
210
- }
211
-
212
- async function flushLeaderboardWritesOnce(): Promise<void> {
213
- for (const job of leaderboardWriteJobs()) {
214
- await saveLeaderboardScore(job.record, job.score);
215
- markLeaderboardWriteClean(job);
216
- }
217
- }
218
-
219
- function leaderboardWriteJobs(): LeaderboardWriteJob[] {
220
- const jobs: LeaderboardWriteJob[] = [];
221
- for (const record of leaderboardWrites.values()) {
222
- if (
223
- record.isHighDirty &&
224
- record.isLowDirty &&
225
- record.highScore === record.lowScore
226
- ) {
227
- jobs.push({ record, type: "both", score: record.highScore });
228
- } else {
229
- if (record.isHighDirty)
230
- jobs.push({ record, type: "high", score: record.highScore });
231
- if (record.isLowDirty)
232
- jobs.push({ record, type: "low", score: record.lowScore });
233
- }
234
- }
235
- return jobs;
236
- }
237
-
238
- function markLeaderboardWriteClean(job: LeaderboardWriteJob): void {
239
- const { record, score, type } = job;
240
- if ((type === "high" || type === "both") && record.highScore === score) {
241
- record.isHighDirty = false;
242
- }
243
- if ((type === "low" || type === "both") && record.lowScore === score) {
244
- record.isLowDirty = false;
245
- }
246
- }
247
-
248
- function hasDirtyLeaderboardWrites(): boolean {
249
- for (const record of leaderboardWrites.values()) {
250
- if (record.isHighDirty || record.isLowDirty) return true;
251
- }
252
- return false;
253
- }
254
-
255
- async function saveLeaderboardScore(
256
- record: PendingLeaderboardWrite,
257
- score: number,
258
- ): Promise<void> {
259
- await hostRequest("leaderboard.save", {
260
- variable: record.variable,
261
- score,
262
- scope: record.scope,
263
- });
264
- }
265
-
266
- function normalizeLeaderboard(
267
- leaderboard: RawLeaderboard,
268
- userId: string | null,
269
- ): LeaderboardData {
270
- const list = (leaderboard.list ?? []).map(normalizeLeaderboardEntry);
271
- const playerRank = playerRankFromList(list, userId);
272
- const playerValue = scoreNumber(leaderboard.yourScore?.score);
273
- return {
274
- list,
275
- ...(playerRank === undefined ? {} : { playerRank }),
276
- ...(playerValue === undefined ? {} : { playerValue }),
277
- };
278
- }
279
-
280
- function normalizeLeaderboardEntry(
281
- entry: RawLeaderboardEntry,
282
- ): LeaderboardEntry {
283
- const userId = entry.user?.userId ?? undefined;
284
- return {
285
- place: scoreNumber(entry.place) ?? 0,
286
- value: scoreNumber(entry.score) ?? 0,
287
- username: entry.user?.username ?? "",
288
- ...(userId ? { userId } : {}),
289
- };
290
- }
291
-
292
- function playerRankFromList(
293
- list: LeaderboardEntry[],
294
- userId: string | null,
295
- ): number | undefined {
296
- return userId
297
- ? list.find((entry) => entry.userId === userId)?.place
298
- : undefined;
299
- }
300
-
301
- function scoreNumber(
302
- value: string | number | null | undefined,
303
- ): number | undefined {
304
- if (typeof value === "number")
305
- return Number.isFinite(value) ? value : undefined;
306
- if (typeof value !== "string") return undefined;
307
- const parsed = Number.parseFloat(value);
308
- return Number.isFinite(parsed) ? parsed : undefined;
309
- }
310
-
311
- function leaderboardWriteKey(variable: string, scope: string | null): string {
312
- return `${variable}${scope ? `::${scope}` : ""}`;
313
- }
314
-
315
- function leaderboardScope(options: LeaderboardOptions): string | null {
316
- return options.scope ?? null;
317
- }
318
-
319
- function assertLeaderboardVariable(variable: string, operation: string): void {
320
- if (variable.trim().length > 0) return;
321
- throw new CastleError({
322
- code: "INVALID_LEADERBOARD_VARIABLE",
323
- message: "Leaderboard variable must be a non-empty string.",
324
- operation,
325
- });
326
- }
327
-
328
- function assertLeaderboardScore(score: number, operation: string): void {
329
- if (Number.isFinite(score)) return;
330
- throw new CastleError({
331
- code: "INVALID_LEADERBOARD_SCORE",
332
- message: "Leaderboard score must be a finite number.",
333
- operation,
334
- });
335
- }
336
-
337
- function assertLeaderboardType(type: LeaderboardSort, operation: string): void {
338
- if (type === "high" || type === "low") return;
339
- throw new CastleError({
340
- code: "INVALID_LEADERBOARD_TYPE",
341
- message: "Leaderboard type must be high or low.",
342
- operation,
343
- });
344
- }
345
-
346
- function reportLeaderboardError(error: unknown): void {
347
- console.warn("Castle leaderboard request failed", error);
348
- }
package/src/passes.ts DELETED
@@ -1,95 +0,0 @@
1
- // Pass — a deck-facing capability for selling a creator "pass" to the player.
2
- //
3
- // The deck stays capability-AGNOSTIC: it just offers a pass to the player and
4
- // gets back one normalized outcome regardless of platform. The host decides what
5
- // UI to show and whether a real transaction can happen:
6
- // - mobile app : renders the native bricks purchase sheet over the deck
7
- // - web player : shows an "open in the app" upsell, returns `unavailable`
8
- // - dev CLI : no host UI surface, so the SDK shows a minimal in-page
9
- // notice itself (kept deliberately tiny), returns `unavailable`
10
- // There is no capability check for the deck to make — every platform returns a
11
- // PassOfferResult, so a single code path handles them all.
12
-
13
- import type { PassOfferResult, PassOfferStatus } from "./commands";
14
- import { CastleError } from "./errors";
15
- import { getCommandChannel, hostRequest } from "./transport";
16
-
17
- export type { PassOfferResult, PassOfferStatus } from "./commands";
18
-
19
- export interface CastlePassApi {
20
- has(passId: string): Promise<boolean>;
21
- offer(passId: string): Promise<PassOfferResult>;
22
- }
23
-
24
- export const Pass: CastlePassApi = {
25
- has,
26
- offer,
27
- };
28
-
29
- // Pure read — does the current player already own this pass? No UI, every
30
- // platform answers it the same way (a GraphQL query). Use it to gate content or
31
- // to decide whether to bother calling `offer`.
32
- async function has(passId: string): Promise<boolean> {
33
- if (typeof passId !== "string" || passId.length === 0) {
34
- throw new CastleError({
35
- code: "INVALID_ARGUMENT",
36
- message: "Pass.has requires a passId.",
37
- operation: "Pass.has",
38
- });
39
- }
40
- const { hasPass } = await hostRequest("pass.has", { passId });
41
- return hasPass;
42
- }
43
-
44
- async function offer(passId: string): Promise<PassOfferResult> {
45
- if (typeof passId !== "string" || passId.length === 0) {
46
- throw new CastleError({
47
- code: "INVALID_ARGUMENT",
48
- message: "Pass.offer requires a passId.",
49
- operation: "Pass.offer",
50
- });
51
- }
52
- const result = await hostRequest("pass.offer", { passId });
53
- // On the dev server there's no host chrome to explain why nothing happened,
54
- // so surface a small built-in notice. The mobile/web hosts render their own
55
- // UI, so the SDK stays silent there.
56
- if (result.status === "unavailable" && getCommandChannel() === "local") {
57
- showDevUnavailableNotice();
58
- }
59
- return result;
60
- }
61
-
62
- let devNoticeEl: HTMLDivElement | null = null;
63
- let devNoticeTimer: ReturnType<typeof setTimeout> | null = null;
64
-
65
- // Minimal, dependency-free toast. Dev-only affordance — not the place for a
66
- // designed purchase UI.
67
- function showDevUnavailableNotice(): void {
68
- if (typeof document === "undefined") return;
69
- if (!devNoticeEl) {
70
- devNoticeEl = document.createElement("div");
71
- devNoticeEl.textContent = "Passes can only be purchased in the Castle app.";
72
- devNoticeEl.style.cssText = [
73
- "position:fixed",
74
- "left:50%",
75
- "bottom:24px",
76
- "transform:translateX(-50%)",
77
- "max-width:80vw",
78
- "padding:10px 16px",
79
- "border-radius:8px",
80
- "background:rgba(0,0,0,0.82)",
81
- "color:#fff",
82
- "font:500 13px/1.4 system-ui,sans-serif",
83
- "text-align:center",
84
- "z-index:2147483647",
85
- "pointer-events:none",
86
- "transition:opacity 0.3s ease",
87
- ].join(";");
88
- document.body.appendChild(devNoticeEl);
89
- }
90
- devNoticeEl.style.opacity = "1";
91
- if (devNoticeTimer) clearTimeout(devNoticeTimer);
92
- devNoticeTimer = setTimeout(() => {
93
- if (devNoticeEl) devNoticeEl.style.opacity = "0";
94
- }, 3200);
95
- }