@arcadible/sdk 1.5.1 → 1.5.2-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bus/protocol.js +6 -1
- package/dist/client.d.ts +8 -0
- package/dist/client.js +4 -1
- package/dist/host.d.ts +7 -1
- package/dist/host.js +13 -0
- package/dist/index.d.ts +2 -0
- package/dist/players/client.d.ts +24 -0
- package/dist/players/client.js +76 -0
- package/dist/players/host.d.ts +14 -0
- package/dist/players/host.js +50 -0
- package/dist/players/rules.d.ts +37 -0
- package/dist/players/rules.js +83 -0
- package/dist/players/server.d.ts +7 -0
- package/dist/players/server.js +57 -0
- package/dist/saves/backend/indexeddb.js +83 -3
- package/dist/saves/backend/memory.js +7 -0
- package/dist/saves/backend/types.d.ts +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/bus/protocol.js
CHANGED
|
@@ -157,7 +157,7 @@ export function utf8Size(text) {
|
|
|
157
157
|
}
|
|
158
158
|
// [function]
|
|
159
159
|
// A rough size of a message as the browser copies it: strings by their
|
|
160
|
-
// UTF-8 bytes, and a few bytes for anything else. It only has to be close
|
|
160
|
+
// UTF-8 bytes, a Blob by its own, and a few bytes for anything else. It only has to be close
|
|
161
161
|
// enough to refuse a message far over the limit, so it stops counting once
|
|
162
162
|
// past `limit`, and counts an object it has seen (a cycle) once.
|
|
163
163
|
export function messageSize(value, limit = Number.POSITIVE_INFINITY) {
|
|
@@ -172,6 +172,11 @@ export function messageSize(value, limit = Number.POSITIVE_INFINITY) {
|
|
|
172
172
|
size += 8;
|
|
173
173
|
else if (seen.has(item))
|
|
174
174
|
size += 8;
|
|
175
|
+
else if (typeof Blob !== "undefined" && item instanceof Blob) {
|
|
176
|
+
// a picture: its bytes are copied with it.
|
|
177
|
+
seen.add(item);
|
|
178
|
+
size += item.size + 8;
|
|
179
|
+
}
|
|
175
180
|
else {
|
|
176
181
|
seen.add(item);
|
|
177
182
|
size += 2;
|
package/dist/client.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Build } from "./bus/protocol.js";
|
|
2
2
|
import { type Context } from "./context/client.js";
|
|
3
3
|
import { type System } from "./system/client.js";
|
|
4
|
+
import { type Players } from "./players/client.js";
|
|
4
5
|
import type { Backend } from "./saves/backend/types.js";
|
|
5
6
|
import { type Saves } from "./saves/client.js";
|
|
6
7
|
export type Ready = {
|
|
@@ -39,6 +40,13 @@ export interface Arcadible {
|
|
|
39
40
|
* alone, and `undefined` before.
|
|
40
41
|
*/
|
|
41
42
|
readonly context: Context | null | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Who's playing: `primary`, the player the game runs for, fixed for the
|
|
45
|
+
* run, and `pictures()`, theirs. `primary` is set by the time `ready`
|
|
46
|
+
* settles; `null` where no page says, such as standing alone, and
|
|
47
|
+
* `undefined` before.
|
|
48
|
+
*/
|
|
49
|
+
readonly players: Players;
|
|
42
50
|
/**
|
|
43
51
|
* Starts the game's initialization: call it as your code starts, before
|
|
44
52
|
* anything it awaits, and Arcadible shows the game's loading screen until
|
package/dist/client.js
CHANGED
|
@@ -8,6 +8,7 @@ import { createLifecycle } from "./lifecycle/client.js";
|
|
|
8
8
|
import { createPause } from "./lifecycle/pause.js";
|
|
9
9
|
import { createContextClient } from "./context/client.js";
|
|
10
10
|
import { createSystemClient } from "./system/client.js";
|
|
11
|
+
import { createPlayersClient } from "./players/client.js";
|
|
11
12
|
import { indexedDBBackend } from "./saves/backend/indexeddb.js";
|
|
12
13
|
import { createSaves } from "./saves/client.js";
|
|
13
14
|
import { serve } from "./host.js";
|
|
@@ -40,10 +41,11 @@ export function createClient(options = {}) {
|
|
|
40
41
|
});
|
|
41
42
|
const system = createSystemClient(bus);
|
|
42
43
|
const context = createContextClient(bus);
|
|
44
|
+
const players = createPlayersClient(bus);
|
|
43
45
|
const lifecycle = createLifecycle(bus, win);
|
|
44
46
|
const pause = createPause(bus);
|
|
45
47
|
// the ones `ready` waits on, or tells: lifecycle and pause do neither.
|
|
46
|
-
const parts = [saves, system, context];
|
|
48
|
+
const parts = [saves, system, context, players];
|
|
47
49
|
let settled = null;
|
|
48
50
|
// ready once welcomed, and each module's first messages have arrived;
|
|
49
51
|
// then each is told, so its facts are known, or null.
|
|
@@ -69,6 +71,7 @@ export function createClient(options = {}) {
|
|
|
69
71
|
get context() {
|
|
70
72
|
return context.value;
|
|
71
73
|
},
|
|
74
|
+
players: players.players,
|
|
72
75
|
initStart() {
|
|
73
76
|
lifecycle.start();
|
|
74
77
|
return ready;
|
package/dist/host.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { type Holds } from "./lifecycle/holds.js";
|
|
|
5
5
|
import { type SystemState } from "./system/rules.js";
|
|
6
6
|
import { type System } from "./system/server.js";
|
|
7
7
|
import { type LifecycleHost } from "./lifecycle/server.js";
|
|
8
|
+
import { type SignedIn } from "./players/host.js";
|
|
9
|
+
import { type PlayersHost } from "./players/server.js";
|
|
8
10
|
import type { Backend } from "./saves/backend/types.js";
|
|
9
11
|
import type { Where } from "./saves/rules.js";
|
|
10
12
|
export type ServeOptions = {
|
|
@@ -18,6 +20,7 @@ export type ServeOptions = {
|
|
|
18
20
|
holds?: Holds;
|
|
19
21
|
system?: System;
|
|
20
22
|
context?: ContextState;
|
|
23
|
+
players?: PlayersHost;
|
|
21
24
|
};
|
|
22
25
|
export type Host = {
|
|
23
26
|
close(): void;
|
|
@@ -25,7 +28,9 @@ export type Host = {
|
|
|
25
28
|
leave(timeout?: number): Promise<void>;
|
|
26
29
|
setSystem(state: Partial<SystemState>): void;
|
|
27
30
|
};
|
|
28
|
-
type HostOptions = Omit<ServeOptions, "holds" | "system" | "context"> & {
|
|
31
|
+
type HostOptions = Omit<ServeOptions, "holds" | "system" | "context" | "players"> & {
|
|
32
|
+
player?: SignedIn | null;
|
|
33
|
+
fetch?: (url: string) => Promise<Response>;
|
|
29
34
|
language?: string;
|
|
30
35
|
launchedAt?: string;
|
|
31
36
|
frame: HTMLIFrameElement;
|
|
@@ -37,6 +42,7 @@ export declare function serve(options: ServeOptions): Server;
|
|
|
37
42
|
export declare function createHost(options: HostOptions): Host;
|
|
38
43
|
export type { Server } from "./bus/server.js";
|
|
39
44
|
export { createHolds, type Holds } from "./lifecycle/holds.js";
|
|
45
|
+
export type { SignedIn } from "./players/host.js";
|
|
40
46
|
export { createSystem, type System } from "./system/server.js";
|
|
41
47
|
export { indexedDBBackend } from "./saves/backend/indexeddb.js";
|
|
42
48
|
export { memoryBackend } from "./saves/backend/memory.js";
|
package/dist/host.js
CHANGED
|
@@ -9,6 +9,8 @@ import { createHolds } from "./lifecycle/holds.js";
|
|
|
9
9
|
import { browserLanguage } from "./system/rules.js";
|
|
10
10
|
import { createSystem } from "./system/server.js";
|
|
11
11
|
import { lifecycleServer } from "./lifecycle/server.js";
|
|
12
|
+
import { playersHost } from "./players/host.js";
|
|
13
|
+
import { playersServer } from "./players/server.js";
|
|
12
14
|
import { savesServer } from "./saves/server.js";
|
|
13
15
|
// [function]
|
|
14
16
|
// Answers the bus for one game in one channel, on any number of ports.
|
|
@@ -22,6 +24,7 @@ export function serve(options) {
|
|
|
22
24
|
...(options.holds ? [options.holds.module] : []),
|
|
23
25
|
...(options.system ? [options.system.module] : []),
|
|
24
26
|
...(options.context ? [contextServer(options.context)] : []),
|
|
27
|
+
...(options.players ? [playersServer(options.players)] : []),
|
|
25
28
|
savesServer({
|
|
26
29
|
backend,
|
|
27
30
|
now: options.now ?? (() => new Date()),
|
|
@@ -64,6 +67,16 @@ export function createHost(options) {
|
|
|
64
67
|
...options,
|
|
65
68
|
holds,
|
|
66
69
|
system,
|
|
70
|
+
...(options.player === undefined
|
|
71
|
+
? {}
|
|
72
|
+
: {
|
|
73
|
+
players: playersHost({
|
|
74
|
+
backend: options.backend,
|
|
75
|
+
fetch: options.fetch ?? ((url) => win.fetch(url)),
|
|
76
|
+
game: options.game,
|
|
77
|
+
player: options.player,
|
|
78
|
+
}),
|
|
79
|
+
}),
|
|
67
80
|
context: {
|
|
68
81
|
// Arcadible's time when given, else the device's: never a time the
|
|
69
82
|
// game would refuse.
|
package/dist/index.d.ts
CHANGED
|
@@ -6,4 +6,6 @@ export type { Arcadible, Ready } from "./client.js";
|
|
|
6
6
|
export type { Saves } from "./saves/client.js";
|
|
7
7
|
export type { System } from "./system/client.js";
|
|
8
8
|
export type { Context } from "./context/client.js";
|
|
9
|
+
export type { Players } from "./players/client.js";
|
|
10
|
+
export type { Player } from "./players/rules.js";
|
|
9
11
|
export type { SaveInfo } from "./saves/rules.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Bus } from "../bus/client.js";
|
|
2
|
+
import { type Player } from "./rules.js";
|
|
3
|
+
/** Who's playing. */
|
|
4
|
+
export interface Players {
|
|
5
|
+
/**
|
|
6
|
+
* The player the game runs for, fixed for the run: their id in this game,
|
|
7
|
+
* whether they're a guest, and their name. Set by the time `ready`
|
|
8
|
+
* settles; `null` where no page says, such as standing alone, and
|
|
9
|
+
* `undefined` before.
|
|
10
|
+
*/
|
|
11
|
+
readonly primary: Player | null | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Each player's picture, 256 × 256, as WebP, in the order given: a Blob,
|
|
14
|
+
* or `null` for a player with none (a guest always). Takes players or
|
|
15
|
+
* their ids, up to 50. Rejects with `unavailable` when the pictures can't
|
|
16
|
+
* be reached, as offline.
|
|
17
|
+
*/
|
|
18
|
+
pictures(players: readonly (Player | string)[]): Promise<(Blob | null)[]>;
|
|
19
|
+
}
|
|
20
|
+
export type PlayersClient = {
|
|
21
|
+
players: Players;
|
|
22
|
+
settle(): void;
|
|
23
|
+
};
|
|
24
|
+
export declare function createPlayersClient(bus: Bus): PlayersClient;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The game's side of `players`: `arcadible.players`, who's playing. Its
|
|
2
|
+
// `primary` is a fact, fixed for the run: `undefined` before `ready`, and
|
|
3
|
+
// `null` where no page says (standing alone, or an older page). Pictures
|
|
4
|
+
// are asked for when a game draws them.
|
|
5
|
+
import { ArcadibleError, Codes, isObject, log } from "../bus/protocol.js";
|
|
6
|
+
import { fact } from "../facts.js";
|
|
7
|
+
import { isId, isPicture, MAX_PICTURES, Names, PICTURES_PER_REQUEST, stateOf, } from "./rules.js";
|
|
8
|
+
// [function]
|
|
9
|
+
// an answer the game can use: a list as long as what it asked for.
|
|
10
|
+
function isAnswer(length) {
|
|
11
|
+
return (value) => Array.isArray(value) && value.length === length;
|
|
12
|
+
}
|
|
13
|
+
// [function]
|
|
14
|
+
// the ids a game asked for, checked before anything is sent.
|
|
15
|
+
function idsOf(players) {
|
|
16
|
+
if (!Array.isArray(players)) {
|
|
17
|
+
throw new ArcadibleError(Codes.Invalid, "players.pictures takes an array of players, or of their ids.");
|
|
18
|
+
}
|
|
19
|
+
if (players.length > MAX_PICTURES) {
|
|
20
|
+
throw new ArcadibleError(Codes.TooMany, `players.pictures takes up to ${MAX_PICTURES} players at once, not ${players.length}.`);
|
|
21
|
+
}
|
|
22
|
+
return players.map((entry, index) => {
|
|
23
|
+
const id = isObject(entry) ? entry["id"] : entry;
|
|
24
|
+
if (!isId(id)) {
|
|
25
|
+
throw new ArcadibleError(Codes.Invalid, `players.pictures takes players, or their ids: entry ${index} is neither.`);
|
|
26
|
+
}
|
|
27
|
+
return id;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
// [function]
|
|
31
|
+
export function createPlayersClient(bus) {
|
|
32
|
+
const primary = fact(bus, Names.State, {
|
|
33
|
+
fixed: true,
|
|
34
|
+
parse(data) {
|
|
35
|
+
const state = stateOf(data);
|
|
36
|
+
return state ? Object.freeze({ ...state.primary }) : null;
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const ask = async function (ids) {
|
|
40
|
+
let answer;
|
|
41
|
+
try {
|
|
42
|
+
answer = await bus.request(Names.Pictures, { ids }, isAnswer(ids.length));
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
// no page to ask (standing alone), or one from before pictures: no
|
|
46
|
+
// one has a picture there.
|
|
47
|
+
if (err instanceof ArcadibleError && err.code === Codes.Unsupported) {
|
|
48
|
+
return ids.map(() => null);
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
return answer.map((entry, index) => {
|
|
53
|
+
if (entry === null)
|
|
54
|
+
return null;
|
|
55
|
+
if (isPicture(entry))
|
|
56
|
+
return entry;
|
|
57
|
+
log("warn", `ignored a malformed picture for player ${ids[index]}.`);
|
|
58
|
+
return null;
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
const players = {
|
|
62
|
+
get primary() {
|
|
63
|
+
return primary.value;
|
|
64
|
+
},
|
|
65
|
+
async pictures(list) {
|
|
66
|
+
const ids = idsOf(list);
|
|
67
|
+
// a few at a time, so every answer fits a message.
|
|
68
|
+
const pictures = [];
|
|
69
|
+
for (let at = 0; at < ids.length; at += PICTURES_PER_REQUEST) {
|
|
70
|
+
pictures.push(...(await ask(ids.slice(at, at + PICTURES_PER_REQUEST))));
|
|
71
|
+
}
|
|
72
|
+
return pictures;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
return { players, settle: () => primary.settle() };
|
|
76
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Backend } from "../saves/backend/types.js";
|
|
2
|
+
import type { PlayersHost } from "./server.js";
|
|
3
|
+
export type SignedIn = {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string | null;
|
|
6
|
+
picture?: string | null;
|
|
7
|
+
};
|
|
8
|
+
export type PlayersHostOptions = {
|
|
9
|
+
player: SignedIn | null;
|
|
10
|
+
backend: Backend;
|
|
11
|
+
game: string;
|
|
12
|
+
fetch: (url: string) => Promise<Response>;
|
|
13
|
+
};
|
|
14
|
+
export declare function playersHost(options: PlayersHostOptions): PlayersHost;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Who a play page's game runs for: the player signed in, from the page's
|
|
2
|
+
// own lookup, or the device's guest in this game.
|
|
3
|
+
// [import]
|
|
4
|
+
import { log } from "../bus/protocol.js";
|
|
5
|
+
import { isId } from "./rules.js";
|
|
6
|
+
// [function]
|
|
7
|
+
// Only the primary's picture is found, fetched from its address when a
|
|
8
|
+
// game first asks and kept for the run; no one else has one yet.
|
|
9
|
+
export function playersHost(options) {
|
|
10
|
+
const signedIn = options.player;
|
|
11
|
+
if (signedIn && !isId(signedIn.id)) {
|
|
12
|
+
log("error", `the player's id isn't a UUID: ${JSON.stringify(signedIn.id)}.`);
|
|
13
|
+
}
|
|
14
|
+
let primary = null;
|
|
15
|
+
const address = signedIn?.picture ?? null;
|
|
16
|
+
let picture = null;
|
|
17
|
+
const fetchPicture = async function (url) {
|
|
18
|
+
// a fetch that fails rejects: the pictures can't be reached.
|
|
19
|
+
const response = await options.fetch(url);
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
log("warn", `the player's picture answered ${response.status}.`);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
return response.blob();
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
primary() {
|
|
28
|
+
primary ??= signedIn
|
|
29
|
+
? Promise.resolve({
|
|
30
|
+
id: signedIn.id,
|
|
31
|
+
guest: false,
|
|
32
|
+
name: signedIn.name,
|
|
33
|
+
})
|
|
34
|
+
: options.backend
|
|
35
|
+
.guest(options.game)
|
|
36
|
+
.then((id) => ({ id, guest: true, name: null }));
|
|
37
|
+
return primary;
|
|
38
|
+
},
|
|
39
|
+
async picture(id) {
|
|
40
|
+
if (!signedIn || !address || id !== signedIn.id)
|
|
41
|
+
return null;
|
|
42
|
+
picture ??= fetchPicture(address).catch((err) => {
|
|
43
|
+
// not kept, so the next ask tries again.
|
|
44
|
+
picture = null;
|
|
45
|
+
throw err;
|
|
46
|
+
});
|
|
47
|
+
return picture;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare const Names: {
|
|
2
|
+
readonly State: "players.state";
|
|
3
|
+
readonly Pictures: "players.pictures";
|
|
4
|
+
};
|
|
5
|
+
/** A player, as a game sees one. */
|
|
6
|
+
export interface Player {
|
|
7
|
+
/** This player in this game: a UUID, the same every run, different in every game. */
|
|
8
|
+
readonly id: string;
|
|
9
|
+
/** Signed out: playing on this device, with no account. */
|
|
10
|
+
readonly guest: boolean;
|
|
11
|
+
/** The profile's name, as players see it; null for a guest, or with none. */
|
|
12
|
+
readonly name: string | null;
|
|
13
|
+
}
|
|
14
|
+
export type PlayersState = {
|
|
15
|
+
primary: Player;
|
|
16
|
+
};
|
|
17
|
+
export declare const MAX_PICTURES = 50;
|
|
18
|
+
export declare const PICTURES_PER_REQUEST = 10;
|
|
19
|
+
export declare const MAX_PICTURE: number;
|
|
20
|
+
export declare const PICTURE_TYPE = "image/webp";
|
|
21
|
+
/** Whether a value is a player's id: a UUID, in lowercase. */
|
|
22
|
+
export declare function isId(value: unknown): value is string;
|
|
23
|
+
/**
|
|
24
|
+
* A new id, for a guest: a random UUID. `crypto.randomUUID` isn't on
|
|
25
|
+
* plain-http pages, as a development play page is, so it's built from
|
|
26
|
+
* `getRandomValues`, which is.
|
|
27
|
+
*/
|
|
28
|
+
export declare function newId(): string;
|
|
29
|
+
/**
|
|
30
|
+
* A player as the game keeps one: only fields it knows, each checked. A
|
|
31
|
+
* guest has no name; a name that's blank is none.
|
|
32
|
+
*/
|
|
33
|
+
export declare function playerOf(value: unknown): Player | null;
|
|
34
|
+
/** The state as the game keeps it, or null. */
|
|
35
|
+
export declare function stateOf(value: unknown): PlayersState | null;
|
|
36
|
+
/** Whether a value is a picture as either end hands one over. */
|
|
37
|
+
export declare function isPicture(value: unknown): value is Blob;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `players` on the bus, shared by both ends: who's playing, fixed for the
|
|
2
|
+
// run, and their pictures, on demand.
|
|
3
|
+
// [import]
|
|
4
|
+
import { isObject } from "../bus/protocol.js";
|
|
5
|
+
// [declare]
|
|
6
|
+
export const Names = {
|
|
7
|
+
// who's playing, `{ primary }`: an event, sent once, as the module opens.
|
|
8
|
+
State: "players.state",
|
|
9
|
+
// pictures, a request with `{ ids }`, answered with a Blob or null each.
|
|
10
|
+
Pictures: "players.pictures",
|
|
11
|
+
};
|
|
12
|
+
// The most pictures one call asks for.
|
|
13
|
+
export const MAX_PICTURES = 50;
|
|
14
|
+
// How many go in one request, and the largest picture the page hands over,
|
|
15
|
+
// so an answer always fits a message: ten of the largest are under
|
|
16
|
+
// `MAX_MESSAGE`. A 256 × 256 WebP is 15 to 20 KB.
|
|
17
|
+
export const PICTURES_PER_REQUEST = 10;
|
|
18
|
+
export const MAX_PICTURE = 96 * 1024;
|
|
19
|
+
export const PICTURE_TYPE = "image/webp";
|
|
20
|
+
// The longest name kept: a profile's is far shorter.
|
|
21
|
+
const MAX_NAME = 200;
|
|
22
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
23
|
+
// [function]
|
|
24
|
+
/** Whether a value is a player's id: a UUID, in lowercase. */
|
|
25
|
+
export function isId(value) {
|
|
26
|
+
return typeof value === "string" && UUID.test(value);
|
|
27
|
+
}
|
|
28
|
+
// [function]
|
|
29
|
+
/**
|
|
30
|
+
* A new id, for a guest: a random UUID. `crypto.randomUUID` isn't on
|
|
31
|
+
* plain-http pages, as a development play page is, so it's built from
|
|
32
|
+
* `getRandomValues`, which is.
|
|
33
|
+
*/
|
|
34
|
+
export function newId() {
|
|
35
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
36
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
37
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
38
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0"));
|
|
39
|
+
return [
|
|
40
|
+
hex.slice(0, 4),
|
|
41
|
+
hex.slice(4, 6),
|
|
42
|
+
hex.slice(6, 8),
|
|
43
|
+
hex.slice(8, 10),
|
|
44
|
+
hex.slice(10, 16),
|
|
45
|
+
]
|
|
46
|
+
.map((part) => part.join(""))
|
|
47
|
+
.join("-");
|
|
48
|
+
}
|
|
49
|
+
// [function]
|
|
50
|
+
/**
|
|
51
|
+
* A player as the game keeps one: only fields it knows, each checked. A
|
|
52
|
+
* guest has no name; a name that's blank is none.
|
|
53
|
+
*/
|
|
54
|
+
export function playerOf(value) {
|
|
55
|
+
if (!isObject(value))
|
|
56
|
+
return null;
|
|
57
|
+
const { id, guest, name } = value;
|
|
58
|
+
if (!isId(id) || typeof guest !== "boolean")
|
|
59
|
+
return null;
|
|
60
|
+
if (name !== null && typeof name !== "string")
|
|
61
|
+
return null;
|
|
62
|
+
if (typeof name === "string" && name.length > MAX_NAME)
|
|
63
|
+
return null;
|
|
64
|
+
const kept = guest || name === null || name.trim() === "" ? null : name;
|
|
65
|
+
return { id, guest, name: kept };
|
|
66
|
+
}
|
|
67
|
+
// [function]
|
|
68
|
+
/** The state as the game keeps it, or null. */
|
|
69
|
+
export function stateOf(value) {
|
|
70
|
+
if (!isObject(value))
|
|
71
|
+
return null;
|
|
72
|
+
const primary = playerOf(value["primary"]);
|
|
73
|
+
return primary ? { primary } : null;
|
|
74
|
+
}
|
|
75
|
+
// [function]
|
|
76
|
+
/** Whether a value is a picture as either end hands one over. */
|
|
77
|
+
export function isPicture(value) {
|
|
78
|
+
return (typeof Blob !== "undefined" &&
|
|
79
|
+
value instanceof Blob &&
|
|
80
|
+
value.type === PICTURE_TYPE &&
|
|
81
|
+
value.size > 0 &&
|
|
82
|
+
value.size <= MAX_PICTURE);
|
|
83
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Module } from "../bus/server.js";
|
|
2
|
+
import { type Player } from "./rules.js";
|
|
3
|
+
export type PlayersHost = {
|
|
4
|
+
primary(): Promise<Player>;
|
|
5
|
+
picture(id: string): Promise<Blob | null>;
|
|
6
|
+
};
|
|
7
|
+
export declare function playersServer(host: PlayersHost): () => Module;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The page's side of `players`: who's playing, sent once as the game
|
|
2
|
+
// connects, and their pictures, fetched when a game asks. It never changes
|
|
3
|
+
// during the run: the page ends the run when the player does.
|
|
4
|
+
// [import]
|
|
5
|
+
import { ArcadibleError, Codes, log } from "../bus/protocol.js";
|
|
6
|
+
import { isId, isPicture, Names, PICTURES_PER_REQUEST, playerOf, } from "./rules.js";
|
|
7
|
+
// [function]
|
|
8
|
+
function idsOf(params) {
|
|
9
|
+
const ids = params["ids"];
|
|
10
|
+
if (!Array.isArray(ids) ||
|
|
11
|
+
ids.length > PICTURES_PER_REQUEST ||
|
|
12
|
+
!ids.every(isId)) {
|
|
13
|
+
throw new ArcadibleError(Codes.Invalid, `players.pictures takes up to ${PICTURES_PER_REQUEST} ids.`);
|
|
14
|
+
}
|
|
15
|
+
return ids;
|
|
16
|
+
}
|
|
17
|
+
// [function]
|
|
18
|
+
export function playersServer(host) {
|
|
19
|
+
const pictures = async function (params) {
|
|
20
|
+
const ids = idsOf(params);
|
|
21
|
+
const found = await Promise.allSettled(ids.map((id) => host.picture(id)));
|
|
22
|
+
// one that fails among many is null, said in the console; when none
|
|
23
|
+
// can be reached, the game is told, and can try again.
|
|
24
|
+
const failed = found.filter((one) => one.status === "rejected");
|
|
25
|
+
if (ids.length > 0 && failed.length === ids.length) {
|
|
26
|
+
throw new ArcadibleError(Codes.Unavailable, "Pictures can't be reached right now. Try again.");
|
|
27
|
+
}
|
|
28
|
+
return found.map((one, index) => {
|
|
29
|
+
if (one.status === "rejected") {
|
|
30
|
+
log("warn", `player ${ids[index]}'s picture couldn't be fetched.`);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
if (one.value === null || isPicture(one.value))
|
|
34
|
+
return one.value;
|
|
35
|
+
log("warn", `player ${ids[index]}'s picture isn't one a game takes.`);
|
|
36
|
+
return null;
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
return () => ({
|
|
40
|
+
handlers: {
|
|
41
|
+
// beside the queue: it waits on the network, not on storage.
|
|
42
|
+
[Names.Pictures]: { run: pictures, parallel: true },
|
|
43
|
+
},
|
|
44
|
+
// first on each channel, so the game has it by `ready`.
|
|
45
|
+
async open(send) {
|
|
46
|
+
try {
|
|
47
|
+
const primary = playerOf(await host.primary());
|
|
48
|
+
if (!primary)
|
|
49
|
+
throw new Error("The page's player isn't one.");
|
|
50
|
+
send(Names.State, { primary });
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
log("warn", "the game isn't told who's playing.", err);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// [import]
|
|
2
2
|
import { ArcadibleError, Codes, log } from "../../bus/protocol.js";
|
|
3
|
+
import { isId, newId } from "../../players/rules.js";
|
|
3
4
|
import { listenersByKey } from "./listeners.js";
|
|
4
5
|
import { memoryBackend } from "./memory.js";
|
|
5
6
|
// [declare]
|
|
@@ -10,9 +11,12 @@ const GAMES = "games";
|
|
|
10
11
|
const BY_SCOPE = "scope";
|
|
11
12
|
// tells the page's other tabs about each change.
|
|
12
13
|
const CHANNEL = "arcadible-saves";
|
|
13
|
-
//
|
|
14
|
-
//
|
|
14
|
+
// a guest's saves; a signed-in player's sit beside them, under their id in
|
|
15
|
+
// the game, so each player's are their own on a device they share.
|
|
15
16
|
const GUEST = "guest";
|
|
17
|
+
// the guest's id in a game is kept in the game's row for no channel, since
|
|
18
|
+
// it's the same in every one: no real channel is empty.
|
|
19
|
+
const NO_CHANNEL = "";
|
|
16
20
|
// after a failed reopen, how long calls fail at once before it's tried again,
|
|
17
21
|
// so a game saving every frame doesn't try, and fail, every frame.
|
|
18
22
|
const REOPEN_WAIT = 1000;
|
|
@@ -161,6 +165,59 @@ export function indexedDBBackend(options = {}) {
|
|
|
161
165
|
return again ? op(again) : otherwise();
|
|
162
166
|
}
|
|
163
167
|
};
|
|
168
|
+
// A signed-in player with no saves of a game on this device takes the
|
|
169
|
+
// guest's: what they played before signing in is theirs, and the guest
|
|
170
|
+
// starts again. With saves of their own (or once they've had any, a
|
|
171
|
+
// revision), theirs are used and the guest's stay the guest's. One
|
|
172
|
+
// transaction, so two tabs can't both take them; once for each game and
|
|
173
|
+
// channel, before anything reads or writes them.
|
|
174
|
+
const taken = new Map();
|
|
175
|
+
const take = async function (db, scope) {
|
|
176
|
+
const tx = db.transaction([SAVES, GAMES], "readwrite");
|
|
177
|
+
const saves = tx.objectStore(SAVES);
|
|
178
|
+
const games = tx.objectStore(GAMES);
|
|
179
|
+
const own = [player, scope.game, scope.channel];
|
|
180
|
+
const guest = [GUEST, scope.game, scope.channel];
|
|
181
|
+
const [mine, row, theirs, guestRow] = await Promise.all([
|
|
182
|
+
settle(saves.index(BY_SCOPE).count(IDBKeyRange.only(own))),
|
|
183
|
+
settle(games.get(own)),
|
|
184
|
+
settle(saves.index(BY_SCOPE).getAll(IDBKeyRange.only(guest))),
|
|
185
|
+
settle(games.get(guest)),
|
|
186
|
+
]);
|
|
187
|
+
const had = (row?.revision ?? 0) > 0;
|
|
188
|
+
if (mine === 0 && !had && theirs.length > 0) {
|
|
189
|
+
for (const stored of theirs) {
|
|
190
|
+
saves.put({ ...stored, player });
|
|
191
|
+
saves.delete([...guest, stored.name]);
|
|
192
|
+
}
|
|
193
|
+
// the player's counter carries on from the guest's, so each save's
|
|
194
|
+
// revision still orders it.
|
|
195
|
+
games.put({
|
|
196
|
+
...(row ?? {
|
|
197
|
+
player,
|
|
198
|
+
game: scope.game,
|
|
199
|
+
channel: scope.channel,
|
|
200
|
+
}),
|
|
201
|
+
revision: guestRow?.revision ?? 0,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
await finish(tx);
|
|
205
|
+
};
|
|
206
|
+
const mine = function (db, scope) {
|
|
207
|
+
if (player === GUEST)
|
|
208
|
+
return Promise.resolve();
|
|
209
|
+
const key = keyOf(scope);
|
|
210
|
+
let done = taken.get(key);
|
|
211
|
+
if (!done) {
|
|
212
|
+
done = take(db, scope).catch((err) => {
|
|
213
|
+
// not kept, so the next call tries again.
|
|
214
|
+
taken.delete(key);
|
|
215
|
+
throw err;
|
|
216
|
+
});
|
|
217
|
+
taken.set(key, done);
|
|
218
|
+
}
|
|
219
|
+
return done;
|
|
220
|
+
};
|
|
164
221
|
database().catch(() => undefined);
|
|
165
222
|
const broadcast = typeof BroadcastChannel === "undefined"
|
|
166
223
|
? null
|
|
@@ -220,6 +277,7 @@ export function indexedDBBackend(options = {}) {
|
|
|
220
277
|
status: () => status,
|
|
221
278
|
list(scope) {
|
|
222
279
|
return run(async (db) => {
|
|
280
|
+
await mine(db, scope);
|
|
223
281
|
const tx = db.transaction(SAVES, "readonly");
|
|
224
282
|
const all = await settle(tx.objectStore(SAVES).index(BY_SCOPE).getAll(rangeOf(scope)));
|
|
225
283
|
return all.map(toRecord);
|
|
@@ -227,6 +285,7 @@ export function indexedDBBackend(options = {}) {
|
|
|
227
285
|
},
|
|
228
286
|
get(scope, name) {
|
|
229
287
|
return run(async (db) => {
|
|
288
|
+
await mine(db, scope);
|
|
230
289
|
const tx = db.transaction(SAVES, "readonly");
|
|
231
290
|
const stored = await settle(tx
|
|
232
291
|
.objectStore(SAVES)
|
|
@@ -235,13 +294,17 @@ export function indexedDBBackend(options = {}) {
|
|
|
235
294
|
}, () => fallback.get(scope, name));
|
|
236
295
|
},
|
|
237
296
|
async change(scope, name, decide, source) {
|
|
238
|
-
const result = await run(
|
|
297
|
+
const result = await run(async (db) => {
|
|
298
|
+
await mine(db, scope);
|
|
299
|
+
return change(db, scope, name, decide);
|
|
300
|
+
}, () => fallback.change(scope, name, decide, source));
|
|
239
301
|
if (result)
|
|
240
302
|
tell(scope, result, source);
|
|
241
303
|
return result;
|
|
242
304
|
},
|
|
243
305
|
touch(scope, at) {
|
|
244
306
|
return run(async (db) => {
|
|
307
|
+
await mine(db, scope);
|
|
245
308
|
const tx = db.transaction(GAMES, "readwrite");
|
|
246
309
|
const games = tx.objectStore(GAMES);
|
|
247
310
|
const key = [player, scope.game, scope.channel];
|
|
@@ -253,6 +316,23 @@ export function indexedDBBackend(options = {}) {
|
|
|
253
316
|
return finish(tx);
|
|
254
317
|
}, () => fallback.touch(scope, at));
|
|
255
318
|
},
|
|
319
|
+
guest(game) {
|
|
320
|
+
return run(async (db) => {
|
|
321
|
+
// read and made in one transaction, so two tabs agree on it.
|
|
322
|
+
const tx = db.transaction(GAMES, "readwrite");
|
|
323
|
+
const games = tx.objectStore(GAMES);
|
|
324
|
+
const key = [GUEST, game, NO_CHANNEL];
|
|
325
|
+
const row = (await settle(games.get(key)));
|
|
326
|
+
if (row && isId(row.id)) {
|
|
327
|
+
await finish(tx);
|
|
328
|
+
return row.id;
|
|
329
|
+
}
|
|
330
|
+
const id = newId();
|
|
331
|
+
games.put({ player: GUEST, game, channel: NO_CHANNEL, id });
|
|
332
|
+
await finish(tx);
|
|
333
|
+
return id;
|
|
334
|
+
}, () => fallback.guest(game));
|
|
335
|
+
},
|
|
256
336
|
subscribe(scope, listener) {
|
|
257
337
|
return listeners.add(keyOf(scope), listener);
|
|
258
338
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// [import]
|
|
2
|
+
import { newId } from "../../players/rules.js";
|
|
2
3
|
import { listenersByKey } from "./listeners.js";
|
|
3
4
|
// [function]
|
|
4
5
|
function keyOf(scope) {
|
|
@@ -11,6 +12,7 @@ function keyOf(scope) {
|
|
|
11
12
|
// device's with `device`.
|
|
12
13
|
export function memoryBackend({ status = "memory", } = {}) {
|
|
13
14
|
const games = new Map();
|
|
15
|
+
const guests = new Map();
|
|
14
16
|
const listeners = listenersByKey();
|
|
15
17
|
const gameOf = function (scope) {
|
|
16
18
|
const key = keyOf(scope);
|
|
@@ -46,6 +48,11 @@ export function memoryBackend({ status = "memory", } = {}) {
|
|
|
46
48
|
touch: async (scope, at) => {
|
|
47
49
|
gameOf(scope).usedAt = at;
|
|
48
50
|
},
|
|
51
|
+
guest: async (game) => {
|
|
52
|
+
const id = guests.get(game) ?? newId();
|
|
53
|
+
guests.set(game, id);
|
|
54
|
+
return id;
|
|
55
|
+
},
|
|
49
56
|
subscribe: (scope, listener) => listeners.add(keyOf(scope), listener),
|
|
50
57
|
used: (scope) => games.get(keyOf(scope))?.usedAt,
|
|
51
58
|
};
|
|
@@ -23,5 +23,6 @@ export interface Backend {
|
|
|
23
23
|
get(scope: Scope, name: string): Promise<SaveRecord | null>;
|
|
24
24
|
change(scope: Scope, name: string, decide: (saves: SaveRecord[]) => NewRecord | null, source: string): Promise<Change | null>;
|
|
25
25
|
touch(scope: Scope, at: string): Promise<void>;
|
|
26
|
+
guest(game: string): Promise<string>;
|
|
26
27
|
subscribe(scope: Scope, listener: (change: Change, source: string) => void): () => void;
|
|
27
28
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "1.5.
|
|
1
|
+
export declare const VERSION = "1.5.2-next.0";
|
package/dist/version.js
CHANGED
package/package.json
CHANGED