@memizy/host-sdk 0.0.0-stage → 1.0.0-rc.5
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 +31 -2
- package/dist/frame.cjs +1 -0
- package/dist/frame.js +77 -0
- package/dist/index.cjs +1 -0
- package/dist/index.js +10 -0
- package/dist/learning.cjs +1 -0
- package/dist/learning.js +43 -0
- package/dist/plugin.cjs +1 -0
- package/dist/plugin.js +52 -0
- package/dist/relay/host.cjs +1 -0
- package/dist/relay/host.js +370 -0
- package/dist/relay/messages.cjs +1 -0
- package/dist/relay/messages.js +22 -0
- package/dist/relay/player.cjs +1 -0
- package/dist/relay/player.js +274 -0
- package/dist/relay/socket.cjs +1 -0
- package/dist/relay/socket.js +74 -0
- package/dist/session.cjs +1 -0
- package/dist/session.js +530 -0
- package/dist/src/frame.d.ts +38 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/learning.d.ts +14 -0
- package/dist/src/plugin.d.ts +51 -0
- package/dist/src/relay/host.d.ts +94 -0
- package/dist/src/relay/messages.d.ts +97 -0
- package/dist/src/relay/player.d.ts +98 -0
- package/dist/src/relay/socket.d.ts +34 -0
- package/dist/src/session.d.ts +233 -0
- package/dist/src/storage.d.ts +24 -0
- package/dist/storage.cjs +1 -0
- package/dist/storage.js +37 -0
- package/package.json +59 -4
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { CreateRoomResponse, RelayErrorCode, RelayPlayer } from '@memizy/protocol';
|
|
2
|
+
import { LocalSession, PluginEndpoint } from '../session';
|
|
3
|
+
import { RelayStatus } from './socket';
|
|
4
|
+
import { RemoteLobbyState } from './messages';
|
|
5
|
+
export type RelayHostEvent = {
|
|
6
|
+
type: 'status';
|
|
7
|
+
status: RelayStatus;
|
|
8
|
+
} | {
|
|
9
|
+
type: 'players';
|
|
10
|
+
players: RelayPlayer[];
|
|
11
|
+
} | {
|
|
12
|
+
type: 'error';
|
|
13
|
+
code: RelayErrorCode;
|
|
14
|
+
message: string;
|
|
15
|
+
} | {
|
|
16
|
+
type: 'closed';
|
|
17
|
+
reason: string;
|
|
18
|
+
};
|
|
19
|
+
export interface RelayHostOptions {
|
|
20
|
+
/** Server base URL, e.g. `https://mp.memizy.com`. */
|
|
21
|
+
serverUrl: string;
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
WebSocket?: typeof WebSocket;
|
|
24
|
+
/** How long a player device may take to load the game (default 20 s). */
|
|
25
|
+
mountTimeoutMs?: number;
|
|
26
|
+
/** Timeout of one call to a remote plugin (default 15 s). */
|
|
27
|
+
callTimeoutMs?: number;
|
|
28
|
+
}
|
|
29
|
+
export declare class RelayHost {
|
|
30
|
+
readonly pin: string;
|
|
31
|
+
readonly hostToken: string;
|
|
32
|
+
players: RelayPlayer[];
|
|
33
|
+
/** The lobby/game state shown by the player apps. */
|
|
34
|
+
state: RemoteLobbyState;
|
|
35
|
+
private readonly options;
|
|
36
|
+
private readonly socket;
|
|
37
|
+
private readonly listeners;
|
|
38
|
+
private readonly channels;
|
|
39
|
+
private bundleVersion;
|
|
40
|
+
private genCounter;
|
|
41
|
+
private callCounter;
|
|
42
|
+
private session;
|
|
43
|
+
private hosting;
|
|
44
|
+
private readonly hostingWaiters;
|
|
45
|
+
private stopSession;
|
|
46
|
+
private constructor();
|
|
47
|
+
/** Creates a new room on the server. */
|
|
48
|
+
static create(options: RelayHostOptions): Promise<RelayHost>;
|
|
49
|
+
/** Re-attaches to an existing room (e.g. after a page reload). */
|
|
50
|
+
static resume(options: RelayHostOptions, room: CreateRoomResponse): RelayHost;
|
|
51
|
+
/** Resolves once the server confirmed the room and sent the current players. */
|
|
52
|
+
whenHosting(): Promise<void>;
|
|
53
|
+
get status(): RelayStatus;
|
|
54
|
+
on(listener: (event: RelayHostEvent) => void): () => void;
|
|
55
|
+
private emit;
|
|
56
|
+
/** Uploads the plugin and the set prepared for it (`LocalSession.prepared.set`). */
|
|
57
|
+
uploadBundle(pluginHtml: string, set: unknown): Promise<number>;
|
|
58
|
+
/** Allows or stops new players joining. */
|
|
59
|
+
setOpen(open: boolean): void;
|
|
60
|
+
kick(playerId: string): void;
|
|
61
|
+
/** Closes the room for everyone. */
|
|
62
|
+
close(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Leaves the room without closing it (the page is going away). The room and its
|
|
65
|
+
* players stay on the server, so the host can come back (reload) or take it over.
|
|
66
|
+
*/
|
|
67
|
+
disconnect(): void;
|
|
68
|
+
/** Updates the lobby/game state shown by the player apps. */
|
|
69
|
+
setState(update: Partial<RemoteLobbyState>): void;
|
|
70
|
+
/**
|
|
71
|
+
* Connects the session's remote players (all roster players that are relay
|
|
72
|
+
* players) and keeps the session in sync with the room: presence, late joins,
|
|
73
|
+
* reconnects, renames and kicks.
|
|
74
|
+
*/
|
|
75
|
+
attach(session: LocalSession): void;
|
|
76
|
+
/** Disconnects from the session; player devices return to the lobby. */
|
|
77
|
+
detach(): void;
|
|
78
|
+
private onSessionEvent;
|
|
79
|
+
private connectRemote;
|
|
80
|
+
/** A session endpoint whose plugin instance runs on the player's device. */
|
|
81
|
+
endpointFor(playerId: string): PluginEndpoint;
|
|
82
|
+
private sendMount;
|
|
83
|
+
private remotePlugin;
|
|
84
|
+
private callRemote;
|
|
85
|
+
private channel;
|
|
86
|
+
private resetChannel;
|
|
87
|
+
/** Calls to a device that went away are dropped (resolved), like messages. */
|
|
88
|
+
private settleCalls;
|
|
89
|
+
private onServer;
|
|
90
|
+
private onPresence;
|
|
91
|
+
private onPlayer;
|
|
92
|
+
private sendTo;
|
|
93
|
+
private broadcast;
|
|
94
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Player, SessionResult } from '@memizy/protocol';
|
|
2
|
+
export type RemotePhase = 'lobby' | 'loading' | 'countdown' | 'running' | 'ended';
|
|
3
|
+
export interface RemoteLobbyState {
|
|
4
|
+
phase: RemotePhase;
|
|
5
|
+
appName: string | null;
|
|
6
|
+
players: Player[];
|
|
7
|
+
/** Seconds left while counting down. */
|
|
8
|
+
countdown?: number;
|
|
9
|
+
/** Whether the authority is reachable (false = "waiting for the host"). */
|
|
10
|
+
authorityConnected: boolean;
|
|
11
|
+
result?: SessionResult;
|
|
12
|
+
}
|
|
13
|
+
export type SerializedError = {
|
|
14
|
+
message: string;
|
|
15
|
+
};
|
|
16
|
+
export type HostToPlayer =
|
|
17
|
+
/** Lobby / game state for the player app's own screens. */
|
|
18
|
+
{
|
|
19
|
+
k: 'state';
|
|
20
|
+
state: RemoteLobbyState;
|
|
21
|
+
}
|
|
22
|
+
/** Create (or recreate) the plugin instance; download the bundle if its version changed. */
|
|
23
|
+
| {
|
|
24
|
+
k: 'mount';
|
|
25
|
+
gen: number;
|
|
26
|
+
bundle: number;
|
|
27
|
+
locale: string;
|
|
28
|
+
}
|
|
29
|
+
/** Remove the plugin instance. */
|
|
30
|
+
| {
|
|
31
|
+
k: 'unmount';
|
|
32
|
+
}
|
|
33
|
+
/** A PluginApi call. */
|
|
34
|
+
| {
|
|
35
|
+
k: 'call';
|
|
36
|
+
id: number;
|
|
37
|
+
gen: number;
|
|
38
|
+
method: string;
|
|
39
|
+
args: unknown[];
|
|
40
|
+
}
|
|
41
|
+
/** Result of a HostApi call. `hostNow` comes with `hello` (clock sync). */
|
|
42
|
+
| {
|
|
43
|
+
k: 'result';
|
|
44
|
+
id: number;
|
|
45
|
+
ok: true;
|
|
46
|
+
value: unknown;
|
|
47
|
+
hostNow?: number;
|
|
48
|
+
} | {
|
|
49
|
+
k: 'result';
|
|
50
|
+
id: number;
|
|
51
|
+
ok: false;
|
|
52
|
+
error: SerializedError;
|
|
53
|
+
};
|
|
54
|
+
export type PlayerToHost =
|
|
55
|
+
/** After (re)joining: the generation of the plugin instance still alive (0 = none). */
|
|
56
|
+
{
|
|
57
|
+
k: 'hello';
|
|
58
|
+
gen: number;
|
|
59
|
+
}
|
|
60
|
+
/** The plugin instance `gen` connected (Penpal handshake done). */
|
|
61
|
+
| {
|
|
62
|
+
k: 'mounted';
|
|
63
|
+
gen: number;
|
|
64
|
+
} | {
|
|
65
|
+
k: 'mount-failed';
|
|
66
|
+
gen: number;
|
|
67
|
+
error: SerializedError;
|
|
68
|
+
}
|
|
69
|
+
/** A HostApi call of instance `gen`. */
|
|
70
|
+
| {
|
|
71
|
+
k: 'call';
|
|
72
|
+
id: number;
|
|
73
|
+
gen: number;
|
|
74
|
+
method: string;
|
|
75
|
+
args: unknown[];
|
|
76
|
+
}
|
|
77
|
+
/** Result of a PluginApi call. */
|
|
78
|
+
| {
|
|
79
|
+
k: 'result';
|
|
80
|
+
id: number;
|
|
81
|
+
ok: true;
|
|
82
|
+
value: unknown;
|
|
83
|
+
} | {
|
|
84
|
+
k: 'result';
|
|
85
|
+
id: number;
|
|
86
|
+
ok: false;
|
|
87
|
+
error: SerializedError;
|
|
88
|
+
};
|
|
89
|
+
export declare function serializeError(error: unknown): SerializedError;
|
|
90
|
+
/** Blobs (getAsset) are not JSON: they travel as base64. */
|
|
91
|
+
export interface SerializedBlob {
|
|
92
|
+
$blob: string;
|
|
93
|
+
type: string;
|
|
94
|
+
}
|
|
95
|
+
export declare function blobToJson(blob: Blob): Promise<SerializedBlob>;
|
|
96
|
+
export declare function jsonToBlob(value: SerializedBlob): Blob;
|
|
97
|
+
export declare function isSerializedBlob(value: unknown): value is SerializedBlob;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { HostApi, Theme, PluginApi, RelayErrorCode } from '@memizy/protocol';
|
|
2
|
+
import { RelayStatus } from './socket';
|
|
3
|
+
import { RemoteLobbyState } from './messages';
|
|
4
|
+
export type RelayPlayerEvent = {
|
|
5
|
+
type: 'status';
|
|
6
|
+
status: RelayStatus;
|
|
7
|
+
} | {
|
|
8
|
+
type: 'joined';
|
|
9
|
+
playerId: string;
|
|
10
|
+
token: string;
|
|
11
|
+
name: string;
|
|
12
|
+
} | {
|
|
13
|
+
type: 'state';
|
|
14
|
+
state: RemoteLobbyState;
|
|
15
|
+
} | {
|
|
16
|
+
type: 'host';
|
|
17
|
+
connected: boolean;
|
|
18
|
+
}
|
|
19
|
+
/** The plugin instance was created (`true`) or removed (`false`). */
|
|
20
|
+
| {
|
|
21
|
+
type: 'frame';
|
|
22
|
+
active: boolean;
|
|
23
|
+
} | {
|
|
24
|
+
type: 'error';
|
|
25
|
+
code: RelayErrorCode | 'MOUNT_FAILED';
|
|
26
|
+
message: string;
|
|
27
|
+
fatal: boolean;
|
|
28
|
+
} | {
|
|
29
|
+
type: 'closed';
|
|
30
|
+
reason: 'host' | 'expired' | 'kicked';
|
|
31
|
+
};
|
|
32
|
+
export interface RelayPlayerOptions {
|
|
33
|
+
serverUrl: string;
|
|
34
|
+
pin: string;
|
|
35
|
+
name: string;
|
|
36
|
+
/** Token from an earlier `joined` event: rejoin as the same player. */
|
|
37
|
+
token?: string;
|
|
38
|
+
/** Where the plugin iframe goes (called when the game starts). */
|
|
39
|
+
container: () => HTMLElement | null;
|
|
40
|
+
fetch?: typeof fetch;
|
|
41
|
+
WebSocket?: typeof WebSocket;
|
|
42
|
+
callTimeoutMs?: number;
|
|
43
|
+
/** Adjusts the downloaded plugin HTML before it runs (e.g. points the SDK import to a local build). */
|
|
44
|
+
transformHtml?: (html: string) => string;
|
|
45
|
+
/**
|
|
46
|
+
* Language and theme of this device. They replace the host's values, so every
|
|
47
|
+
* player sees the game in the language and theme of their own app.
|
|
48
|
+
*/
|
|
49
|
+
config?: () => Partial<Theme>;
|
|
50
|
+
/** Creates the plugin instance (default: a sandboxed iframe; tests connect the SDK directly). */
|
|
51
|
+
createFrame?: FrameFactory;
|
|
52
|
+
}
|
|
53
|
+
export type FrameFactory = (html: string, title: string, hostApi: HostApi, doc: Document) => RemoteFrame;
|
|
54
|
+
export interface RemoteFrame {
|
|
55
|
+
readonly iframe: HTMLElement;
|
|
56
|
+
readonly plugin: Promise<PluginApi>;
|
|
57
|
+
destroy(): void;
|
|
58
|
+
}
|
|
59
|
+
export declare class RelayPlayer {
|
|
60
|
+
playerId: string | null;
|
|
61
|
+
token: string | null;
|
|
62
|
+
name: string;
|
|
63
|
+
state: RemoteLobbyState | null;
|
|
64
|
+
hostConnected: boolean;
|
|
65
|
+
private readonly options;
|
|
66
|
+
private readonly socket;
|
|
67
|
+
private readonly listeners;
|
|
68
|
+
private bundle;
|
|
69
|
+
private frame;
|
|
70
|
+
private plugin;
|
|
71
|
+
private gen;
|
|
72
|
+
private callCounter;
|
|
73
|
+
private readonly pending;
|
|
74
|
+
/** Host clock minus local clock (ms). */
|
|
75
|
+
private skewMs;
|
|
76
|
+
/** The config the host sent last (before this device's overrides). */
|
|
77
|
+
private hostConfig;
|
|
78
|
+
private mountQueue;
|
|
79
|
+
constructor(options: RelayPlayerOptions);
|
|
80
|
+
get status(): RelayStatus;
|
|
81
|
+
on(listener: (event: RelayPlayerEvent) => void): () => void;
|
|
82
|
+
private emit;
|
|
83
|
+
/** Call when this device's language or theme changed (see `config`). */
|
|
84
|
+
updateConfig(): void;
|
|
85
|
+
private localConfig;
|
|
86
|
+
rename(name: string): void;
|
|
87
|
+
/** Reconnect right away (e.g. the page became visible again). */
|
|
88
|
+
reconnectNow(): void;
|
|
89
|
+
/** Leaves (the token stays valid for a rejoin until the room closes). */
|
|
90
|
+
leave(): void;
|
|
91
|
+
private onServer;
|
|
92
|
+
private up;
|
|
93
|
+
private onHost;
|
|
94
|
+
private ensureBundle;
|
|
95
|
+
private mount;
|
|
96
|
+
private hostApiFor;
|
|
97
|
+
private destroyFrame;
|
|
98
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { RelayClientMessage, RelayServerMessage } from '@memizy/protocol';
|
|
2
|
+
/** `replaced`: the same host or player connected from another tab or device; this one stops. */
|
|
3
|
+
export type RelayStatus = 'connecting' | 'online' | 'offline' | 'closed' | 'replaced';
|
|
4
|
+
export interface RelaySocketOptions {
|
|
5
|
+
/** `ws(s)://…/ws` */
|
|
6
|
+
url: string;
|
|
7
|
+
/** The attach message sent after every (re)connect. */
|
|
8
|
+
attach: () => RelayClientMessage;
|
|
9
|
+
WebSocket?: typeof WebSocket;
|
|
10
|
+
}
|
|
11
|
+
export declare class RelaySocket {
|
|
12
|
+
status: RelayStatus;
|
|
13
|
+
private readonly options;
|
|
14
|
+
private ws;
|
|
15
|
+
private retry;
|
|
16
|
+
private retryTimer;
|
|
17
|
+
private pingTimer;
|
|
18
|
+
private stopped;
|
|
19
|
+
private readonly messageListeners;
|
|
20
|
+
private readonly statusListeners;
|
|
21
|
+
constructor(options: RelaySocketOptions);
|
|
22
|
+
onMessage(listener: (msg: RelayServerMessage) => void): () => void;
|
|
23
|
+
onStatus(listener: (status: RelayStatus) => void): () => void;
|
|
24
|
+
/** Sends when online; returns `false` when the message was dropped. */
|
|
25
|
+
send(msg: RelayClientMessage): boolean;
|
|
26
|
+
/** Stops for good (no reconnect). */
|
|
27
|
+
close(): void;
|
|
28
|
+
/** Reconnects right away (e.g. when the page becomes visible again). */
|
|
29
|
+
reconnectNow(): void;
|
|
30
|
+
private setStatus;
|
|
31
|
+
private open;
|
|
32
|
+
}
|
|
33
|
+
/** `http(s)://server` → `ws(s)://server/ws` */
|
|
34
|
+
export declare function relayWebSocketUrl(serverUrl: string): string;
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { AnswerRecord, HostApi, HostAs, Player, PluginApi, PluginRuntime, PluginView, SessionEndReason, SessionResult, Theme, SessionClock } from '@memizy/protocol';
|
|
2
|
+
import { OQSEFile, ProgressRecord } from '@memizy/oqse';
|
|
3
|
+
import { LearningAlgorithm } from './learning';
|
|
4
|
+
import { HostStorage } from './storage';
|
|
5
|
+
import { LoadedPlugin, PreparedSet } from './plugin';
|
|
6
|
+
export interface SessionPlayer {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
isHost?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface SessionConfig {
|
|
12
|
+
plugin: LoadedPlugin;
|
|
13
|
+
/** The study set, loaded with `loadOQSEFile`. */
|
|
14
|
+
set: OQSEFile;
|
|
15
|
+
mode: 'solo' | 'multiplayer';
|
|
16
|
+
/** Multiplayer only: how the host takes part. */
|
|
17
|
+
hostAs?: HostAs;
|
|
18
|
+
/** Solo: exactly one player (default: a player called "Player"). Multiplayer: the roster. */
|
|
19
|
+
players?: SessionPlayer[];
|
|
20
|
+
/** Setting values (defaults are applied; invalid values throw). */
|
|
21
|
+
settings?: Record<string, unknown>;
|
|
22
|
+
storage?: HostStorage;
|
|
23
|
+
algorithm?: LearningAlgorithm;
|
|
24
|
+
/** Loader for assets that are not absolute URLs (e.g. files of an `.oqse` package). */
|
|
25
|
+
loadAsset?: (key: string, itemId: string | undefined) => Promise<Blob>;
|
|
26
|
+
/** Maps a player to the key of their stored progress and data (default: the player ID). */
|
|
27
|
+
userKeyFor?: (playerId: string) => string;
|
|
28
|
+
config?: Partial<Theme>;
|
|
29
|
+
sessionId?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Seed of the display order of options, pairs… (SPEC 4.4; default: the session id).
|
|
32
|
+
* Pass the seed used for the relay bundle so all devices agree; `null` keeps the set's order.
|
|
33
|
+
*/
|
|
34
|
+
shuffleSeed?: string | null;
|
|
35
|
+
host?: {
|
|
36
|
+
name: string;
|
|
37
|
+
version: string;
|
|
38
|
+
};
|
|
39
|
+
/** Max wait for all instances to be ready before the countdown (default 15 s). */
|
|
40
|
+
readyTimeoutMs?: number;
|
|
41
|
+
/** Countdown before the game starts in multiplayer (default 3 s; 0 = none). */
|
|
42
|
+
countdownMs?: number;
|
|
43
|
+
/**
|
|
44
|
+
* The session was already running (e.g. the host page was reloaded; pass the
|
|
45
|
+
* same `sessionId` and a persistent `storage`). The authority resumes from its
|
|
46
|
+
* last snapshot; without a snapshot the game starts again right away.
|
|
47
|
+
*/
|
|
48
|
+
resume?: boolean;
|
|
49
|
+
/** When resuming: the pauses so far (from the `paused` events), so timers stay right. */
|
|
50
|
+
clock?: {
|
|
51
|
+
pausedMs?: number;
|
|
52
|
+
pausedAt?: number | null;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Memizy services this host offers (SPEC 5.3), by name. A plugin may call those it
|
|
56
|
+
* declares in `services`; payloads and results are JSON. Throw a `ProtocolError` to
|
|
57
|
+
* reject a call with a specific code.
|
|
58
|
+
*/
|
|
59
|
+
services?: Record<string, ServiceHandler>;
|
|
60
|
+
}
|
|
61
|
+
/** Handles one service call. `playerId` is null on the board. */
|
|
62
|
+
export type ServiceHandler = (payload: unknown, context: {
|
|
63
|
+
playerId: string | null;
|
|
64
|
+
address: string;
|
|
65
|
+
pluginId: string;
|
|
66
|
+
setId: string;
|
|
67
|
+
}) => Promise<unknown>;
|
|
68
|
+
export type SessionEvent = {
|
|
69
|
+
type: 'ready';
|
|
70
|
+
address: string;
|
|
71
|
+
} | {
|
|
72
|
+
type: 'countdown';
|
|
73
|
+
secondsLeft: number;
|
|
74
|
+
} | {
|
|
75
|
+
type: 'started';
|
|
76
|
+
} | {
|
|
77
|
+
type: 'settings';
|
|
78
|
+
values: Record<string, unknown>;
|
|
79
|
+
valid: boolean;
|
|
80
|
+
message?: string;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A recorded answer. `record` is the updated learning progress (null for a generated item,
|
|
84
|
+
* which is not repeated); `skills` / `tags` come from the item (for statistics).
|
|
85
|
+
*/
|
|
86
|
+
| {
|
|
87
|
+
type: 'answer';
|
|
88
|
+
playerId: string;
|
|
89
|
+
record: ProgressRecord | null;
|
|
90
|
+
answer: AnswerRecord;
|
|
91
|
+
generated: boolean;
|
|
92
|
+
skills: string[];
|
|
93
|
+
tags: string[];
|
|
94
|
+
} | {
|
|
95
|
+
type: 'ended';
|
|
96
|
+
result: SessionResult;
|
|
97
|
+
} | {
|
|
98
|
+
type: 'players';
|
|
99
|
+
players: Player[];
|
|
100
|
+
} | {
|
|
101
|
+
type: 'authority';
|
|
102
|
+
connected: boolean;
|
|
103
|
+
} | {
|
|
104
|
+
type: 'traffic';
|
|
105
|
+
from: string;
|
|
106
|
+
to: string;
|
|
107
|
+
bytes: number;
|
|
108
|
+
data: unknown;
|
|
109
|
+
} | {
|
|
110
|
+
type: 'rejected';
|
|
111
|
+
address: string;
|
|
112
|
+
method: string;
|
|
113
|
+
code: string;
|
|
114
|
+
message: string;
|
|
115
|
+
} | {
|
|
116
|
+
type: 'pluginError';
|
|
117
|
+
address: string;
|
|
118
|
+
code: string;
|
|
119
|
+
message: string;
|
|
120
|
+
context?: Record<string, unknown>;
|
|
121
|
+
} | {
|
|
122
|
+
type: 'resize';
|
|
123
|
+
address: string;
|
|
124
|
+
height: number | 'auto';
|
|
125
|
+
}
|
|
126
|
+
/** The host paused or resumed the game (store `clock` to resume a reloaded host). */
|
|
127
|
+
| {
|
|
128
|
+
type: 'paused';
|
|
129
|
+
paused: boolean;
|
|
130
|
+
clock: {
|
|
131
|
+
pausedMs: number;
|
|
132
|
+
pausedAt: number | null;
|
|
133
|
+
};
|
|
134
|
+
} | {
|
|
135
|
+
type: 'exit';
|
|
136
|
+
address: string;
|
|
137
|
+
};
|
|
138
|
+
/** Creates the plugin side of an instance for a given host API (iframe + Penpal, or a direct connection in tests). */
|
|
139
|
+
export type PluginEndpoint = (hostApi: HostApi) => PluginApi | Promise<PluginApi>;
|
|
140
|
+
export interface SessionInstance {
|
|
141
|
+
readonly address: string;
|
|
142
|
+
readonly view: PluginView;
|
|
143
|
+
readonly hostApi: HostApi;
|
|
144
|
+
}
|
|
145
|
+
export declare const SETTINGS_ADDRESS = "settings";
|
|
146
|
+
export declare class LocalSession {
|
|
147
|
+
readonly id: string;
|
|
148
|
+
readonly plugin: LoadedPlugin;
|
|
149
|
+
readonly runtime: PluginRuntime;
|
|
150
|
+
readonly mode: 'solo' | 'multiplayer';
|
|
151
|
+
readonly hostAs: HostAs | null;
|
|
152
|
+
readonly authority: string;
|
|
153
|
+
readonly prepared: PreparedSet;
|
|
154
|
+
players: Player[];
|
|
155
|
+
settings: Record<string, unknown>;
|
|
156
|
+
settingsValid: boolean;
|
|
157
|
+
config: Theme;
|
|
158
|
+
started: boolean;
|
|
159
|
+
ended: boolean;
|
|
160
|
+
/** Pauses of the game (game time = session time without them). */
|
|
161
|
+
private pauseState;
|
|
162
|
+
private readonly options;
|
|
163
|
+
private readonly storage;
|
|
164
|
+
private readonly algorithm;
|
|
165
|
+
private readonly instances;
|
|
166
|
+
private readonly listeners;
|
|
167
|
+
private authorityConnected;
|
|
168
|
+
private startPromise;
|
|
169
|
+
private resumeStartPending;
|
|
170
|
+
private pendingSnapshot;
|
|
171
|
+
private snapshotTimer;
|
|
172
|
+
private readonly pendingData;
|
|
173
|
+
constructor(config: SessionConfig);
|
|
174
|
+
on(listener: (event: SessionEvent) => void): () => void;
|
|
175
|
+
private emit;
|
|
176
|
+
/** The default view of an address in this session. */
|
|
177
|
+
viewOf(address: string): PluginView;
|
|
178
|
+
/** Addresses that have a game instance in this session (board and players). */
|
|
179
|
+
addresses(): string[];
|
|
180
|
+
/**
|
|
181
|
+
* Connects a plugin instance at `address` (a player ID, `"board"`, or `"settings"`
|
|
182
|
+
* for the lobby settings screen). An existing instance at the address is replaced.
|
|
183
|
+
*/
|
|
184
|
+
connect(address: string, endpoint: PluginEndpoint, options?: {
|
|
185
|
+
onDispose?: () => void;
|
|
186
|
+
}): Promise<SessionInstance>;
|
|
187
|
+
/** Recreates the instance at `address` (like a browser reload or a reconnect). */
|
|
188
|
+
reload(address: string): Promise<SessionInstance>;
|
|
189
|
+
/** Removes the instance at `address` (the player stays in the roster). */
|
|
190
|
+
disconnect(address: string): void;
|
|
191
|
+
private disposeInstance;
|
|
192
|
+
getInstance(address: string): SessionInstance | undefined;
|
|
193
|
+
/**
|
|
194
|
+
* Multiplayer: waits until all connected game instances are ready (or the
|
|
195
|
+
* ready timeout passes), runs the countdown and starts the game on the
|
|
196
|
+
* authority (SPEC 3.3). Solo games start automatically when ready.
|
|
197
|
+
*/
|
|
198
|
+
start(): Promise<void>;
|
|
199
|
+
private runStart;
|
|
200
|
+
addPlayer(player: SessionPlayer): void;
|
|
201
|
+
removePlayer(id: string): void;
|
|
202
|
+
renamePlayer(id: string, name: string): void;
|
|
203
|
+
/**
|
|
204
|
+
* Simulates a connection loss / recovery of an instance. Messages to and from a
|
|
205
|
+
* disconnected instance are dropped; for the authority, the others are told
|
|
206
|
+
* that the authority is unavailable (SPEC 6.1).
|
|
207
|
+
*/
|
|
208
|
+
setConnected(address: string, connected: boolean): void;
|
|
209
|
+
/** Services the plugin declares and this host offers (SPEC 5.3). */
|
|
210
|
+
get services(): string[];
|
|
211
|
+
/** Whether the game is paused (the clock stands still on every device). */
|
|
212
|
+
get paused(): boolean;
|
|
213
|
+
/** The session clock as sent to the instances (host time; the relay adds each device's skew). */
|
|
214
|
+
get clock(): SessionClock;
|
|
215
|
+
/** Pauses the game: timers and countdowns stop, players cannot act (the SDK shows "Paused"). */
|
|
216
|
+
pause(): void;
|
|
217
|
+
/** Continues a paused game. */
|
|
218
|
+
resume(): void;
|
|
219
|
+
private broadcastClock;
|
|
220
|
+
setConfig(config: Partial<Theme>): void;
|
|
221
|
+
/** Ends the session for all instances (they stay mounted until the app removes them). */
|
|
222
|
+
end(reason?: SessionEndReason): Promise<void>;
|
|
223
|
+
private createHostApi;
|
|
224
|
+
private buildInit;
|
|
225
|
+
private userKey;
|
|
226
|
+
private deliver;
|
|
227
|
+
private broadcastPlayers;
|
|
228
|
+
private setAuthorityConnected;
|
|
229
|
+
private callPlugin;
|
|
230
|
+
private flushSnapshot;
|
|
231
|
+
private pendingDataValue;
|
|
232
|
+
private flushData;
|
|
233
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ProgressRecord } from '@memizy/oqse';
|
|
2
|
+
import { DataScope } from '@memizy/protocol';
|
|
3
|
+
export interface HostStorage {
|
|
4
|
+
loadProgress(userKey: string, setId: string): Promise<Record<string, ProgressRecord>>;
|
|
5
|
+
/** Upserts the given records (other records stay). */
|
|
6
|
+
saveProgress(userKey: string, setId: string, records: Record<string, ProgressRecord>): Promise<void>;
|
|
7
|
+
/** `setId` is ignored for scope `plugin`. Returns `null` when nothing is saved. */
|
|
8
|
+
loadData(userKey: string, pluginId: string, scope: DataScope, setId: string): Promise<unknown>;
|
|
9
|
+
saveData(userKey: string, pluginId: string, scope: DataScope, setId: string, value: unknown): Promise<void>;
|
|
10
|
+
loadSnapshot(sessionId: string): Promise<unknown | null>;
|
|
11
|
+
saveSnapshot(sessionId: string, snapshot: unknown): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
/** In-memory storage (values are copied, like a real store would). */
|
|
14
|
+
export declare class MemoryStorage implements HostStorage {
|
|
15
|
+
readonly progress: Map<string, Record<string, ProgressRecord>>;
|
|
16
|
+
readonly data: Map<string, unknown>;
|
|
17
|
+
readonly snapshots: Map<string, unknown>;
|
|
18
|
+
loadProgress(userKey: string, setId: string): Promise<Record<string, ProgressRecord>>;
|
|
19
|
+
saveProgress(userKey: string, setId: string, records: Record<string, ProgressRecord>): Promise<void>;
|
|
20
|
+
loadData(userKey: string, pluginId: string, scope: DataScope, setId: string): Promise<{} | null>;
|
|
21
|
+
saveData(userKey: string, pluginId: string, scope: DataScope, setId: string, value: unknown): Promise<void>;
|
|
22
|
+
loadSnapshot(sessionId: string): Promise<{} | null>;
|
|
23
|
+
saveSnapshot(sessionId: string, snapshot: unknown): Promise<void>;
|
|
24
|
+
}
|
package/dist/storage.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var e=(e,t,n,r)=>JSON.stringify([e,t,n,n===`set`?r:null]),t=class{progress=new Map;data=new Map;snapshots=new Map;async loadProgress(e,t){return structuredClone(this.progress.get(`${e}|${t}`)??{})}async saveProgress(e,t,n){let r=`${e}|${t}`;this.progress.set(r,{...this.progress.get(r)??{},...structuredClone(n)})}async loadData(t,n,r,i){let a=this.data.get(e(t,n,r,i));return a===void 0?null:structuredClone(a)}async saveData(t,n,r,i,a){this.data.set(e(t,n,r,i),structuredClone(a))}async loadSnapshot(e){let t=this.snapshots.get(e);return t===void 0?null:structuredClone(t)}async saveSnapshot(e,t){this.snapshots.set(e,structuredClone(t))}};exports.MemoryStorage=t;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/storage.ts
|
|
2
|
+
var e = (e, t, n, r) => JSON.stringify([
|
|
3
|
+
e,
|
|
4
|
+
t,
|
|
5
|
+
n,
|
|
6
|
+
n === "set" ? r : null
|
|
7
|
+
]), t = class {
|
|
8
|
+
progress = /* @__PURE__ */ new Map();
|
|
9
|
+
data = /* @__PURE__ */ new Map();
|
|
10
|
+
snapshots = /* @__PURE__ */ new Map();
|
|
11
|
+
async loadProgress(e, t) {
|
|
12
|
+
return structuredClone(this.progress.get(`${e}|${t}`) ?? {});
|
|
13
|
+
}
|
|
14
|
+
async saveProgress(e, t, n) {
|
|
15
|
+
let r = `${e}|${t}`;
|
|
16
|
+
this.progress.set(r, {
|
|
17
|
+
...this.progress.get(r) ?? {},
|
|
18
|
+
...structuredClone(n)
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async loadData(t, n, r, i) {
|
|
22
|
+
let a = this.data.get(e(t, n, r, i));
|
|
23
|
+
return a === void 0 ? null : structuredClone(a);
|
|
24
|
+
}
|
|
25
|
+
async saveData(t, n, r, i, a) {
|
|
26
|
+
this.data.set(e(t, n, r, i), structuredClone(a));
|
|
27
|
+
}
|
|
28
|
+
async loadSnapshot(e) {
|
|
29
|
+
let t = this.snapshots.get(e);
|
|
30
|
+
return t === void 0 ? null : structuredClone(t);
|
|
31
|
+
}
|
|
32
|
+
async saveSnapshot(e, t) {
|
|
33
|
+
this.snapshots.set(e, structuredClone(t));
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
//#endregion
|
|
37
|
+
export { t as MemoryStorage };
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@memizy/host-sdk",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0-rc.5",
|
|
4
|
+
"description": "Run Memizy plugins: sandboxed iframes, protocol validation, sessions, storage hooks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/src/index.d.ts",
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/src/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js",
|
|
14
|
+
"require": "./dist/index.cjs"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc --noEmit && vite build",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@memizy/oqse": "^1.0.0-rc.5",
|
|
29
|
+
"@memizy/protocol": "^1.0.0-rc.5",
|
|
30
|
+
"penpal": "^8.0.1"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@memizy/plugin-sdk": "^1.0.0-rc.5",
|
|
34
|
+
"@types/node": "^25.6.0",
|
|
35
|
+
"@types/ws": "^8",
|
|
36
|
+
"jsdom": "^30.1.2",
|
|
37
|
+
"typescript": "~6.0.2",
|
|
38
|
+
"vite": "^8.0.9",
|
|
39
|
+
"vite-plugin-dts": "^4.5.4",
|
|
40
|
+
"vitest": "^4.1.4",
|
|
41
|
+
"ws": "^8"
|
|
42
|
+
},
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"author": "Memizy Team",
|
|
45
|
+
"keywords": [
|
|
46
|
+
"memizy",
|
|
47
|
+
"plugin",
|
|
48
|
+
"host",
|
|
49
|
+
"sdk",
|
|
50
|
+
"multiplayer"
|
|
51
|
+
],
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/memizy/memizy.git",
|
|
55
|
+
"directory": "packages/host-sdk"
|
|
56
|
+
},
|
|
57
|
+
"bugs": {
|
|
58
|
+
"url": "https://github.com/memizy/memizy/issues"
|
|
59
|
+
},
|
|
60
|
+
"homepage": "https://github.com/memizy/memizy/tree/main/packages/host-sdk#readme"
|
|
61
|
+
}
|