@diffenderfer-games/hub 2.0.0-next.4 → 2.0.0-next.6
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/CHANGELOG.md +54 -11
- package/README.md +3 -6
- package/dist/auto.d.ts +5 -5
- package/dist/auto.js +2 -2
- package/dist/avatar-pixi.d.ts +147 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +280 -0
- package/dist/avatar-pixi.js.map +1 -0
- package/dist/avatar-svg.d.ts +4 -0
- package/dist/avatar-svg.js +5 -0
- package/dist/avatar-three.d.ts +69 -0
- package/dist/avatar-three.d.ts.map +1 -0
- package/dist/avatar-three.js +49 -0
- package/dist/avatar-three.js.map +1 -0
- package/dist/avatar.d.ts +7 -0
- package/dist/avatar.js +487 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/input.d.ts +2 -2
- package/dist/shared/animator.d.ts +104 -0
- package/dist/shared/animator.d.ts.map +1 -0
- package/dist/shared/art.js +1804 -0
- package/dist/shared/art.js.map +1 -0
- package/dist/shared/atlas.d.ts +134 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +117 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +13 -5
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +129 -18
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +120 -26
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/defineProperty.js +39 -0
- package/dist/shared/describe.d.ts +32 -0
- package/dist/shared/describe.d.ts.map +1 -0
- package/dist/shared/describe.js +101 -0
- package/dist/shared/describe.js.map +1 -0
- package/dist/shared/globals.js +1 -39
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +537 -17
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +31 -13
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +40 -2
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/json-guards.js +48 -0
- package/dist/shared/json-guards.js.map +1 -0
- package/dist/shared/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- package/dist/shared/mp.js +1 -1
- package/dist/shared/overlays.js +800 -0
- package/dist/shared/overlays.js.map +1 -0
- package/dist/shared/page-stack.js +2 -1
- package/dist/shared/page-stack.js.map +1 -1
- package/dist/shared/pose-key.js +249 -0
- package/dist/shared/pose-key.js.map +1 -0
- package/dist/shared/race.js +1 -1
- package/dist/shared/rooms.js +1 -1
- package/dist/shared/scene.d.ts +90 -0
- package/dist/shared/scene.d.ts.map +1 -0
- package/dist/shared/social.d.ts +55 -4
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/src.js +2217 -199
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +1860 -0
- package/dist/shared/svg-markup.js.map +1 -0
- package/dist/shared/svg.js +27 -0
- package/dist/shared/svg.js.map +1 -0
- package/dist/shared/types.d.ts +166 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/social.d.ts +2 -2
- package/dist/social.js +2 -2
- package/dist/turns.d.ts +39 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +117 -0
- package/dist/turns.js.map +1 -0
- package/package.json +33 -3
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +302 -0
- package/src/avatar/adapters/pixi-types.ts +84 -0
- package/src/avatar/adapters/three.ts +126 -0
- package/src/avatar/animator/animator.ts +285 -0
- package/src/avatar/animator/reactions.ts +44 -0
- package/src/avatar/animator/schedule.ts +73 -0
- package/src/avatar/art/beard.ts +49 -0
- package/src/avatar/art/glasses.ts +9 -0
- package/src/avatar/art/hair.ts +254 -0
- package/src/avatar/art/headwear.ts +75 -0
- package/src/avatar/art/index.ts +21 -0
- package/src/avatar/art/shape.ts +68 -0
- package/src/avatar/art-store.ts +36 -0
- package/src/avatar/atlas.ts +236 -0
- package/src/avatar/catalog-view.ts +14 -0
- package/src/avatar/colour.ts +109 -0
- package/src/avatar/describe.ts +76 -0
- package/src/avatar/face.ts +187 -0
- package/src/avatar/geom.ts +168 -0
- package/src/avatar/layout.ts +47 -0
- package/src/avatar/look-like.ts +52 -0
- package/src/avatar/outputs/bitmap.ts +75 -0
- package/src/avatar/outputs/canvas-paint.ts +177 -0
- package/src/avatar/outputs/canvas.ts +62 -0
- package/src/avatar/outputs/custom-element.ts +140 -0
- package/src/avatar/outputs/element.ts +58 -0
- package/src/avatar/outputs/live-canvas.ts +57 -0
- package/src/avatar/outputs/overlay-svg.ts +22 -0
- package/src/avatar/outputs/pose-key.ts +30 -0
- package/src/avatar/outputs/svg-markup.ts +113 -0
- package/src/avatar/outputs/svg.ts +41 -0
- package/src/avatar/overlays.ts +128 -0
- package/src/avatar/render/body.ts +116 -0
- package/src/avatar/render/colours.ts +81 -0
- package/src/avatar/render/compose.ts +258 -0
- package/src/avatar/render/context.ts +117 -0
- package/src/avatar/render/eyes.ts +78 -0
- package/src/avatar/render/eyewear.ts +56 -0
- package/src/avatar/render/frame.ts +60 -0
- package/src/avatar/render/head.ts +81 -0
- package/src/avatar/render/mouth.ts +86 -0
- package/src/avatar/render/props.ts +91 -0
- package/src/avatar/render/types.ts +84 -0
- package/src/avatar/scene.ts +119 -0
- package/src/avatar-pixi.ts +20 -0
- package/src/avatar-svg.ts +21 -0
- package/src/avatar-three.ts +18 -0
- package/src/avatar.ts +62 -0
- package/src/core/avatar/avatar-cache.ts +95 -0
- package/src/core/avatar/avatar-ref.ts +34 -0
- package/src/core/avatar/avatar-types.ts +43 -0
- package/src/core/avatar/hub-avatar.ts +308 -0
- package/src/core/connection.ts +9 -0
- package/src/core/errors.ts +15 -2
- package/src/core/hub-progress.ts +269 -202
- package/src/core/hub.ts +174 -6
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/index.ts +14 -3
- package/src/core/offline/hub-kinds/progress-kinds.ts +21 -4
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/offline/local-store.ts +46 -1
- package/src/core/page-account.ts +121 -0
- package/src/core/rest/routes.gen.ts +31 -1
- package/src/core/resume/hub-resume.ts +39 -5
- package/src/core/resume/resume-types.ts +2 -2
- package/src/core/saved/daily-saves.ts +196 -0
- package/src/core/saved/daily-seed.ts +16 -0
- package/src/core/saved/hub-saved.ts +221 -0
- package/src/core/saved/local-saved.ts +122 -0
- package/src/core/saved/saved-list.ts +129 -0
- package/src/core/saved/saved-open.ts +104 -0
- package/src/core/saved/saved-order.ts +49 -0
- package/src/core/saved/saved-types.ts +24 -0
- package/src/core/turns/hub-turns.ts +140 -0
- package/src/core/turns/turn-game.ts +388 -0
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/daily/daily-system.ts +112 -13
- package/src/daily/page-daily.ts +2 -2
- package/src/daily/types.ts +46 -9
- package/src/doctor/hub-version-fetch.ts +43 -43
- package/src/index.ts +11 -1
- package/src/input/input-system.ts +10 -1
- package/src/input/navigator.ts +11 -0
- package/src/input.ts +1 -0
- package/src/overlay/overlay.ts +25 -1
- package/src/realtime/runtime-api.ts +14 -3
- package/src/social.ts +1 -0
- package/src/turns-kit/check-rules.ts +182 -0
- package/src/turns.ts +13 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hub.turns`: online, turn-based games the hub runs from the game's own
|
|
3
|
+
* rules module (`game.turns`), played over minutes or days. Make one
|
|
4
|
+
* (inviting friends, with a link, or with open seats anyone may take),
|
|
5
|
+
* join one, open one the player sits in, and list them. Players see "an
|
|
6
|
+
* online game", never "a turn game".
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const opened = hub.saved.opened;
|
|
10
|
+
* const game = opened?.kind === 'turns' ? await hub.turns.open(opened.id)
|
|
11
|
+
* : hub.turns.link !== null ? await hub.turns.join(hub.turns.link)
|
|
12
|
+
* : null;
|
|
13
|
+
* game?.on('move', ({ view, events }) => animate(events, view));
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
import type { CreateTurnGame, OpenTurnGame, TurnDeadline } from '@diffenderfer-games/hub-contract';
|
|
17
|
+
import { toStoredJson, type GamePayload } from '../game-data';
|
|
18
|
+
import { isBrowserPage } from '../page';
|
|
19
|
+
import type { SavedEntry } from '../saved/saved-types';
|
|
20
|
+
import { TurnGame, type TurnGameHost } from './turn-game';
|
|
21
|
+
|
|
22
|
+
/** The query parameter an invite link carries (`/<slug>/?hub_turn_link=<key>`); the client strips it. */
|
|
23
|
+
export const TURN_LINK_PARAM = 'hub_turn_link';
|
|
24
|
+
|
|
25
|
+
/** Where the answer is kept for the page. */
|
|
26
|
+
const PAGE_FLAG = '__HUB_TURN_LINK__';
|
|
27
|
+
|
|
28
|
+
/** How to make a game. */
|
|
29
|
+
export interface TurnCreateOptions {
|
|
30
|
+
readonly mode?: string;
|
|
31
|
+
/** The game's own setup choices (at most 2 KB of JSON). */
|
|
32
|
+
readonly options?: GamePayload;
|
|
33
|
+
/** Friends to invite (their seats are held). */
|
|
34
|
+
readonly invite?: readonly number[];
|
|
35
|
+
/** Make a link others can take a seat with (`game.link`). */
|
|
36
|
+
readonly link?: boolean;
|
|
37
|
+
/** List the open seats in Play online for anyone to take. */
|
|
38
|
+
readonly public?: boolean;
|
|
39
|
+
/** Seats in all (within `game.turns.players`). */
|
|
40
|
+
readonly seats?: number;
|
|
41
|
+
/** Let the computer fill seats nobody took (default true, when the game has bots). */
|
|
42
|
+
readonly bots?: boolean;
|
|
43
|
+
/** How long a turn may wait before the others may let the computer play it (default none). */
|
|
44
|
+
readonly deadline?: TurnDeadline;
|
|
45
|
+
/** Start by itself once every seat is taken (the creator needn't come back). */
|
|
46
|
+
readonly autoStart?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What `hub.turns` needs from its client. */
|
|
50
|
+
export interface TurnsHost extends TurnGameHost {
|
|
51
|
+
readonly slug: string | null;
|
|
52
|
+
create(body: CreateTurnGame): ReturnType<TurnGameHost['get']>;
|
|
53
|
+
join(id: string): ReturnType<TurnGameHost['get']>;
|
|
54
|
+
joinByLink(key: string): ReturnType<TurnGameHost['get']>;
|
|
55
|
+
listOpen(game: string | null): Promise<OpenTurnGame[]>;
|
|
56
|
+
list(): Promise<SavedEntry[]>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Read and strip `hub_turn_link`, once per page. */
|
|
60
|
+
function linkOnPage(): string | null {
|
|
61
|
+
if (!isBrowserPage()) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (Reflect.has(window, PAGE_FLAG)) {
|
|
65
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a window field another bundle may have set
|
|
66
|
+
const recorded: unknown = Reflect.get(window, PAGE_FLAG);
|
|
67
|
+
return typeof recorded === 'string' ? recorded : null;
|
|
68
|
+
}
|
|
69
|
+
let key: string | null = null;
|
|
70
|
+
try {
|
|
71
|
+
const url = new URL(location.href);
|
|
72
|
+
key = url.searchParams.get(TURN_LINK_PARAM);
|
|
73
|
+
if (key !== null) {
|
|
74
|
+
url.searchParams.delete(TURN_LINK_PARAM);
|
|
75
|
+
history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// A sandboxed page keeps the parameter; the answer is still recorded.
|
|
79
|
+
}
|
|
80
|
+
Reflect.set(window, PAGE_FLAG, key);
|
|
81
|
+
return key;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether `idOrKey` is a game id (`sg_…`) rather than a link key. */
|
|
85
|
+
function isGameId(idOrKey: string): boolean {
|
|
86
|
+
return idOrKey.startsWith('sg_');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** `hub.turns`. */
|
|
90
|
+
export class HubTurns {
|
|
91
|
+
private readonly host: TurnsHost;
|
|
92
|
+
|
|
93
|
+
constructor(host: TurnsHost) {
|
|
94
|
+
this.host = host;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The invite link key this page was opened with (`?hub_turn_link=`), taken out of the address bar; `null` when none. Synchronous. */
|
|
98
|
+
get link(): string | null {
|
|
99
|
+
return linkOnPage();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Make a game in this page's game (its lobby): the player takes the first seat. */
|
|
103
|
+
async create<View = GamePayload, Move extends GamePayload = GamePayload, Event = GamePayload>(options: TurnCreateOptions = {}): Promise<TurnGame<View, Move, Event>> {
|
|
104
|
+
const slug = this.host.slug ?? '';
|
|
105
|
+
const body: CreateTurnGame = {
|
|
106
|
+
game: slug,
|
|
107
|
+
...(options.mode === undefined ? {} : { mode: options.mode }),
|
|
108
|
+
...(options.options === undefined ? {} : { options: toStoredJson(options.options) }),
|
|
109
|
+
...(options.invite === undefined ? {} : { invite: [...options.invite] }),
|
|
110
|
+
...(options.link === undefined ? {} : { link: options.link }),
|
|
111
|
+
...(options.public === undefined ? {} : { public: options.public }),
|
|
112
|
+
...(options.seats === undefined ? {} : { seats: options.seats }),
|
|
113
|
+
...(options.bots === undefined ? {} : { bots: options.bots }),
|
|
114
|
+
...(options.deadline === undefined ? {} : { deadline: options.deadline }),
|
|
115
|
+
...(options.autoStart === undefined ? {} : { autoStart: options.autoStart }),
|
|
116
|
+
};
|
|
117
|
+
return new TurnGame<View, Move, Event>(this.host, await this.host.create(body));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Take a seat: by the game's id (an invite, an open seat) or by an invite link's key. */
|
|
121
|
+
async join<View = GamePayload, Move extends GamePayload = GamePayload, Event = GamePayload>(idOrLinkKey: string): Promise<TurnGame<View, Move, Event>> {
|
|
122
|
+
const joined = isGameId(idOrLinkKey) ? await this.host.join(idOrLinkKey) : await this.host.joinByLink(idOrLinkKey);
|
|
123
|
+
return new TurnGame<View, Move, Event>(this.host, joined);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Open a game the player sits in (from `hub.saved.opened`, the Continue list, an invite). */
|
|
127
|
+
async open<View = GamePayload, Move extends GamePayload = GamePayload, Event = GamePayload>(id: string): Promise<TurnGame<View, Move, Event>> {
|
|
128
|
+
return new TurnGame<View, Move, Event>(this.host, await this.host.get(id));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** This game's online games the player sits in (the same rows as `hub.saved.list({ kinds: ['turns'] })`). */
|
|
132
|
+
list(): Promise<SavedEntry[]> {
|
|
133
|
+
return this.host.list();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Open-seat games of this game anyone may join (none across a block). */
|
|
137
|
+
openLobbies(): Promise<OpenTurnGame[]> {
|
|
138
|
+
return this.host.listOpen(this.host.slug);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One online game, as a seated player's page holds it (`hub.turns.open`,
|
|
3
|
+
* `create`, `join`). The hub runs the game's rules: the page only ever has
|
|
4
|
+
* its own `view` and sends moves.
|
|
5
|
+
*
|
|
6
|
+
* Live changes arrive as the social runtime's `hub:turn` window events
|
|
7
|
+
* (from `/_ws`): `on('move')` for each move (with its events as this seat may
|
|
8
|
+
* see them, to animate), `on('turn')` whenever whose move it is or the seats
|
|
9
|
+
* change, `on('ended')` at the end, `on('chat')` for chat lines. Moves made
|
|
10
|
+
* while the page was away are replayed from `history` when it comes back
|
|
11
|
+
* (and, without the runtime, every so often while the page is visible).
|
|
12
|
+
*/
|
|
13
|
+
import type { Json, TurnChatLine, TurnGameView, TurnSeat, TurnSummary } from '@diffenderfer-games/hub-contract';
|
|
14
|
+
import { HubError } from '../errors';
|
|
15
|
+
import { toGameData, type GamePayload } from '../game-data';
|
|
16
|
+
import { isBrowserPage } from '../page';
|
|
17
|
+
import { isJson, parseJsonText } from '../storage/json-guards';
|
|
18
|
+
import { readField } from '../../realtime/cross-bundle';
|
|
19
|
+
|
|
20
|
+
/** The window event the social runtime fires for each `ev:turn`. */
|
|
21
|
+
export const TURN_EVENT = 'hub:turn';
|
|
22
|
+
|
|
23
|
+
/** The runtime's live connection (re)opened: anything sent while it was down is fetched again. */
|
|
24
|
+
export const TURN_RESYNC_EVENT = 'hub:turn-resync';
|
|
25
|
+
|
|
26
|
+
/** The window event the social runtime fires for each `ev:turn.chat`. */
|
|
27
|
+
export const TURN_CHAT_EVENT = 'hub:turn-chat';
|
|
28
|
+
|
|
29
|
+
/** How often a page without the live runtime looks for moves, ms. */
|
|
30
|
+
export const TURN_POLL_MS = 20_000;
|
|
31
|
+
|
|
32
|
+
/** A game's end: the winning seats, and optionally places and scores by seat. */
|
|
33
|
+
export type TurnOutcome = NonNullable<TurnGameView['outcome']>;
|
|
34
|
+
|
|
35
|
+
/** One move as this seat saw it. */
|
|
36
|
+
export interface TurnMoveEvent<View, Ev> {
|
|
37
|
+
readonly seq: number;
|
|
38
|
+
readonly seat: number;
|
|
39
|
+
/** The view after the move (for a replayed move, the view now). */
|
|
40
|
+
readonly view: View;
|
|
41
|
+
readonly events: readonly Ev[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What a game's listeners hear. */
|
|
45
|
+
export interface TurnGameEvents<View, Ev> {
|
|
46
|
+
move: TurnMoveEvent<View, Ev>;
|
|
47
|
+
turn: TurnSummary;
|
|
48
|
+
ended: { readonly outcome: TurnOutcome | null; readonly view: View };
|
|
49
|
+
chat: TurnChatLine;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What a game needs from its client: the REST calls, by name. */
|
|
53
|
+
export interface TurnGameHost {
|
|
54
|
+
get(id: string): Promise<TurnGameView>;
|
|
55
|
+
start(id: string): Promise<TurnGameView>;
|
|
56
|
+
move(id: string, seq: number, move: Json): Promise<{ game: TurnGameView; events: Json[] }>;
|
|
57
|
+
history(id: string, since: number): Promise<{ seq: number; seat: number; events: Json[] }[]>;
|
|
58
|
+
leave(id: string): Promise<void>;
|
|
59
|
+
nudge(id: string): Promise<void>;
|
|
60
|
+
setNotify(id: string, mode: 'on' | 'off'): Promise<void>;
|
|
61
|
+
rematch(id: string): Promise<TurnGameView>;
|
|
62
|
+
invite(id: string, userIds: readonly number[]): Promise<TurnGameView>;
|
|
63
|
+
share(id: string, share: { readonly link?: boolean; readonly public?: boolean }): Promise<TurnGameView>;
|
|
64
|
+
autoPlay(id: string): Promise<TurnGameView>;
|
|
65
|
+
chat(id: string, send: { readonly text?: string; readonly quick?: string }): Promise<TurnChatLine>;
|
|
66
|
+
/** The page has a game open (saves of a run are off while it does). */
|
|
67
|
+
opened(game: object): void;
|
|
68
|
+
closed(game: object): void;
|
|
69
|
+
/** Whether the live runtime is on the page (else the game polls). */
|
|
70
|
+
isLive(): boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Listener<Payload> = (payload: Payload) => void;
|
|
74
|
+
|
|
75
|
+
/** One online game. `View`, `Move` and `Ev` (its events) are the game's own JSON types from its rules. */
|
|
76
|
+
export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload, Ev = GamePayload> {
|
|
77
|
+
readonly id: string;
|
|
78
|
+
private state: TurnGameView;
|
|
79
|
+
private readonly host: TurnGameHost;
|
|
80
|
+
private readonly listeners: { [K in keyof TurnGameEvents<View, Ev>]: Set<Listener<TurnGameEvents<View, Ev>[K]>> } = {
|
|
81
|
+
move: new Set(), turn: new Set(), ended: new Set(), chat: new Set(),
|
|
82
|
+
};
|
|
83
|
+
private readonly stopListening: (() => void)[] = [];
|
|
84
|
+
private isClosed = false;
|
|
85
|
+
private catchingUp: Promise<void> | null = null;
|
|
86
|
+
/** The move count this page's own move in flight will make (its live echo is not another player's move). */
|
|
87
|
+
private ownSeq: number | null = null;
|
|
88
|
+
|
|
89
|
+
constructor(host: TurnGameHost, initial: TurnGameView) {
|
|
90
|
+
this.host = host;
|
|
91
|
+
this.id = initial.id;
|
|
92
|
+
this.state = initial;
|
|
93
|
+
host.opened(this);
|
|
94
|
+
this.listen();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Whose move it is, the seats and the deadline. */
|
|
98
|
+
get summary(): TurnSummary {
|
|
99
|
+
return this.state.summary;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** This seat's view, from the game's rules (`null` before the game starts). */
|
|
103
|
+
get view(): View {
|
|
104
|
+
return toGameData<View>(this.state.view);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** This player's seat, or `null` (they left). */
|
|
108
|
+
get mySeat(): number | null {
|
|
109
|
+
return this.state.mySeat;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The end, once the game is over. */
|
|
113
|
+
get outcome(): TurnOutcome | null {
|
|
114
|
+
return this.state.outcome;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The last chat lines, oldest first. */
|
|
118
|
+
get chat(): readonly TurnChatLine[] {
|
|
119
|
+
return this.state.chat;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `on`: friends may type (moderated); `quick`: quick-chat and emotes only; `off`. */
|
|
123
|
+
get chatMode(): TurnGameView['chatMode'] {
|
|
124
|
+
return this.state.chatMode;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The link others can take a seat with (the creator's, while seats are open), else `null`. */
|
|
128
|
+
get link(): string | null {
|
|
129
|
+
return this.state.link === null ? null : new URL(this.state.link, location.origin).href;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Creator: start the game (empty seats go to the computer when the game allows). */
|
|
133
|
+
async start(): Promise<void> {
|
|
134
|
+
this.update(await this.host.start(this.id));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Make a move. Rejects with a `HubError`: 403 `not_your_turn`, 409
|
|
139
|
+
* `stale` (the game moved on: the view is refreshed first), 422 `illegal`
|
|
140
|
+
* with the rules' own code in `rule`.
|
|
141
|
+
*/
|
|
142
|
+
async move(move: Move): Promise<{ seq: number; view: View; events: Ev[] }> {
|
|
143
|
+
this.ownSeq = this.state.summary.seq + 1;
|
|
144
|
+
try {
|
|
145
|
+
const answer = await this.host.move(this.id, this.state.summary.seq, toPayload(move));
|
|
146
|
+
this.update(answer.game);
|
|
147
|
+
return { seq: answer.game.summary.seq, view: this.view, events: answer.events.map((event) => toGameData<Ev>(event)) };
|
|
148
|
+
} catch (failure) {
|
|
149
|
+
if (failure instanceof HubError && failure.code === 'stale') {
|
|
150
|
+
await this.refresh();
|
|
151
|
+
}
|
|
152
|
+
throw failure;
|
|
153
|
+
} finally {
|
|
154
|
+
this.ownSeq = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The moves after `sinceSeq`, with their events as this seat may see them ("what happened while you were away"). */
|
|
159
|
+
async history(sinceSeq: number): Promise<{ seq: number; seat: number; events: Ev[] }[]> {
|
|
160
|
+
const moves = await this.host.history(this.id, sinceSeq);
|
|
161
|
+
return moves.map((move) => ({ seq: move.seq, seat: move.seat, events: move.events.map((event) => toGameData<Ev>(event)) }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Leave: the computer plays the seat (or, without bots, the others win). The player can't come back. */
|
|
165
|
+
async leave(): Promise<void> {
|
|
166
|
+
await this.host.leave(this.id);
|
|
167
|
+
this.close();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Remind whoever must move (once a day). */
|
|
171
|
+
nudge(): Promise<void> {
|
|
172
|
+
return this.host.nudge(this.id);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** This game's "your turn" alerts. */
|
|
176
|
+
setNotify(mode: 'on' | 'off'): Promise<void> {
|
|
177
|
+
return this.host.setNotify(this.id, mode);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A finished game: a new one with the same friends (they get an invite). */
|
|
181
|
+
async rematch(): Promise<TurnGame<View, Move, Ev>> {
|
|
182
|
+
return new TurnGame<View, Move, Ev>(this.host, await this.host.rematch(this.id));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Creator, before the start: add an invite link (`link: true`, then read `link`) or list the open seats for anyone (`public`). */
|
|
186
|
+
async share(share: { readonly link?: boolean; readonly public?: boolean }): Promise<void> {
|
|
187
|
+
this.update(await this.host.share(this.id, share));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Invite friends to free seats (before the start). */
|
|
191
|
+
async invite(userIds: readonly number[]): Promise<void> {
|
|
192
|
+
this.update(await this.host.invite(this.id, userIds));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The turn's deadline passed (or its player is suspended): let the computer play it. */
|
|
196
|
+
async letComputerPlay(): Promise<void> {
|
|
197
|
+
this.update(await this.host.autoPlay(this.id));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Say something: `{ text }` between friends (moderated), `{ quick }` (a quick-chat phrase id) with anyone. */
|
|
201
|
+
say(send: { readonly text?: string; readonly quick?: string }): Promise<TurnChatLine> {
|
|
202
|
+
return this.host.chat(this.id, send);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Fetch the game again, and replay moves missed since the last one this page saw. */
|
|
206
|
+
async refresh(): Promise<void> {
|
|
207
|
+
const before = this.state.summary.seq;
|
|
208
|
+
const fresh = await this.host.get(this.id);
|
|
209
|
+
if (fresh.summary.seq > before) {
|
|
210
|
+
const missed = await this.host.history(this.id, before);
|
|
211
|
+
this.update(fresh);
|
|
212
|
+
for (const move of missed) {
|
|
213
|
+
this.emit('move', { seq: move.seq, seat: move.seat, view: this.view, events: move.events.map((event) => toGameData<Ev>(event)) });
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
this.update(fresh);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Hear moves, turn changes, the end and chat; returns a function that stops listening. */
|
|
221
|
+
on<K extends keyof TurnGameEvents<View, Ev>>(eventName: K, listener: Listener<TurnGameEvents<View, Ev>[K]>): () => void {
|
|
222
|
+
const set: Set<Listener<TurnGameEvents<View, Ev>[K]>> = this.listeners[eventName];
|
|
223
|
+
set.add(listener);
|
|
224
|
+
return () => {
|
|
225
|
+
set.delete(listener);
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Stop live updates for this page (the game itself goes on). */
|
|
230
|
+
close(): void {
|
|
231
|
+
if (this.isClosed) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.isClosed = true;
|
|
235
|
+
for (const stop of this.stopListening) {
|
|
236
|
+
stop();
|
|
237
|
+
}
|
|
238
|
+
this.host.closed(this);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Take a fresh answer from the hub; fire `turn` and `ended` when they changed. */
|
|
242
|
+
private update(next: TurnGameView): void {
|
|
243
|
+
const previous = this.state;
|
|
244
|
+
this.state = next;
|
|
245
|
+
const isChanged = previous.summary.seq !== next.summary.seq || previous.summary.phase !== next.summary.phase || !sameSeats(previous.summary, next.summary)
|
|
246
|
+
|| previous.chatMode !== next.chatMode || previous.link !== next.link;
|
|
247
|
+
if (isChanged) {
|
|
248
|
+
this.emit('turn', next.summary);
|
|
249
|
+
}
|
|
250
|
+
if (previous.summary.phase !== 'ended' && next.summary.phase === 'ended') {
|
|
251
|
+
this.emit('ended', { outcome: next.outcome, view: this.view });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private emit<K extends keyof TurnGameEvents<View, Ev>>(eventName: K, payload: TurnGameEvents<View, Ev>[K]): void {
|
|
256
|
+
const set: Set<Listener<TurnGameEvents<View, Ev>[K]>> = this.listeners[eventName];
|
|
257
|
+
for (const listener of set) {
|
|
258
|
+
listener(payload);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A live `hub:turn` for this game: a move (or a missed run of them), or another change. */
|
|
263
|
+
private onLive(detail: object): void {
|
|
264
|
+
const seq = readField(detail, 'seq');
|
|
265
|
+
const view = readField(detail, 'view');
|
|
266
|
+
const summary = readField(detail, 'summary');
|
|
267
|
+
if (typeof seq !== 'number' || !isSummary(summary) || view === undefined) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (seq > this.state.summary.seq + 1) {
|
|
271
|
+
void this.catchUp();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const seat = readField(detail, 'seat');
|
|
275
|
+
const events = readField(detail, 'events');
|
|
276
|
+
const outcome = readField(detail, 'outcome');
|
|
277
|
+
const isMove = typeof seat === 'number' && seq === this.state.summary.seq + 1 && seq !== this.ownSeq;
|
|
278
|
+
if (summary.phase !== this.state.summary.phase || !sameSeating(this.state.summary, summary)) {
|
|
279
|
+
// Who sits where changed (a join, the start, a leave): the chat mode and link may have too, and the live event doesn't carry them.
|
|
280
|
+
void this.catchUp();
|
|
281
|
+
}
|
|
282
|
+
this.update({ ...this.state, summary, view: toPayloadJson(view), outcome: isOutcome(outcome) ? outcome : this.state.outcome });
|
|
283
|
+
if (isMove) {
|
|
284
|
+
this.emit('move', { seq, seat, view: this.view, events: Array.isArray(events) ? events.map((event) => toGameData<Ev>(toPayloadJson(event))) : [] });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Replay what was missed (one catch-up at a time). */
|
|
289
|
+
private catchUp(): Promise<void> {
|
|
290
|
+
this.catchingUp ??= this.refresh().catch(() => undefined).finally(() => {
|
|
291
|
+
this.catchingUp = null;
|
|
292
|
+
});
|
|
293
|
+
return this.catchingUp;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private listen(): void {
|
|
297
|
+
if (!isBrowserPage()) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const onTurn = (event: Event): void => {
|
|
301
|
+
if (!(event instanceof CustomEvent) || readField(event.detail, 'id') !== this.id) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail, read field by field
|
|
305
|
+
const detail: unknown = event.detail;
|
|
306
|
+
if (typeof detail === 'object' && detail !== null) {
|
|
307
|
+
this.onLive(detail);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
const onChat = (event: Event): void => {
|
|
311
|
+
if (event instanceof CustomEvent && readField(event.detail, 'id') === this.id) {
|
|
312
|
+
const line = readField(event.detail, 'line');
|
|
313
|
+
if (isChatLine(line)) {
|
|
314
|
+
this.state = { ...this.state, chat: [...this.state.chat, line].slice(-50) };
|
|
315
|
+
this.emit('chat', line);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
const onVisible = (): void => {
|
|
320
|
+
if (document.visibilityState === 'visible') {
|
|
321
|
+
void this.catchUp();
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const onResync = (): void => { void this.catchUp(); };
|
|
325
|
+
window.addEventListener(TURN_EVENT, onTurn);
|
|
326
|
+
window.addEventListener(TURN_CHAT_EVENT, onChat);
|
|
327
|
+
window.addEventListener(TURN_RESYNC_EVENT, onResync);
|
|
328
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
329
|
+
const poll = setInterval(() => {
|
|
330
|
+
if (!this.host.isLive() && document.visibilityState === 'visible') {
|
|
331
|
+
void this.catchUp();
|
|
332
|
+
}
|
|
333
|
+
}, TURN_POLL_MS);
|
|
334
|
+
this.stopListening.push(
|
|
335
|
+
() => { window.removeEventListener(TURN_EVENT, onTurn); },
|
|
336
|
+
() => { window.removeEventListener(TURN_CHAT_EVENT, onChat); },
|
|
337
|
+
() => { window.removeEventListener(TURN_RESYNC_EVENT, onResync); },
|
|
338
|
+
() => { document.removeEventListener('visibilitychange', onVisible); },
|
|
339
|
+
() => { clearInterval(poll); },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Every seat matches its counterpart by `same`. */
|
|
345
|
+
function eachSeat(a: TurnSummary, b: TurnSummary, same: (seat: TurnSeat, other: TurnSeat) => boolean): boolean {
|
|
346
|
+
return a.seats.length === b.seats.length && a.seats.every((seat, index) => {
|
|
347
|
+
const other = b.seats[index];
|
|
348
|
+
return other !== undefined && same(seat, other);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** The same players in the same seats with the same statuses (who sits where). */
|
|
353
|
+
function sameSeating(a: TurnSummary, b: TurnSummary): boolean {
|
|
354
|
+
return eachSeat(a, b, (seat, other) => seat.status === other.status && seat.userId === other.userId);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** The same seating, and the same looks (a player's new avatar is a change: `turn` fires). */
|
|
358
|
+
function sameSeats(a: TurnSummary, b: TurnSummary): boolean {
|
|
359
|
+
return sameSeating(a, b) && eachSeat(a, b, (seat, other) => seat.look === other.look);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** A game's move as the JSON the hub takes. */
|
|
363
|
+
function toPayload(move: GamePayload): Json {
|
|
364
|
+
return toPayloadJson(move);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Any JSON-safe value as `Json` (a JSON round trip). */
|
|
368
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a game's own move, or a value from another bundle's event
|
|
369
|
+
function toPayloadJson(value: unknown): Json {
|
|
370
|
+
const parsed = parseJsonText(JSON.stringify(value ?? null));
|
|
371
|
+
return isJson(parsed) ? parsed : null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Shape checks for a live event's parts (it comes from another bundle). */
|
|
375
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail
|
|
376
|
+
function isSummary(value: unknown): value is TurnSummary {
|
|
377
|
+
return typeof readField(value, 'phase') === 'string' && Array.isArray(readField(value, 'seats')) && Array.isArray(readField(value, 'toAct'));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail
|
|
381
|
+
function isOutcome(value: unknown): value is TurnOutcome | null {
|
|
382
|
+
return value === null || Array.isArray(readField(value, 'winners'));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail
|
|
386
|
+
function isChatLine(value: unknown): value is TurnChatLine {
|
|
387
|
+
return typeof readField(value, 'text') === 'string' && typeof readField(value, 'from') === 'number';
|
|
388
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
// Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client
|
|
1
|
+
// Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client`.
|
|
2
2
|
/** This client's package version, sent as `x-hub-client` on REST calls and `client` in `hello`. */
|
|
3
|
-
export const HUB_CLIENT_VERSION = '2.0.0-next.
|
|
3
|
+
export const HUB_CLIENT_VERSION = '2.0.0-next.6';
|