@diffenderfer-games/hub 2.0.0-next.5 → 2.0.0-next.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/CHANGELOG.md +65 -19
  2. package/README.md +3 -6
  3. package/dist/auto.d.ts +5 -5
  4. package/dist/auto.js +2 -2
  5. package/dist/avatar-pixi.d.ts +147 -0
  6. package/dist/avatar-pixi.d.ts.map +1 -0
  7. package/dist/avatar-pixi.js +280 -0
  8. package/dist/avatar-pixi.js.map +1 -0
  9. package/dist/avatar-svg.d.ts +4 -0
  10. package/dist/avatar-svg.js +5 -0
  11. package/dist/avatar-three.d.ts +69 -0
  12. package/dist/avatar-three.d.ts.map +1 -0
  13. package/dist/avatar-three.js +49 -0
  14. package/dist/avatar-three.js.map +1 -0
  15. package/dist/avatar.d.ts +7 -0
  16. package/dist/avatar.js +487 -0
  17. package/dist/avatar.js.map +1 -0
  18. package/dist/daily.d.ts +2 -2
  19. package/dist/doctor/cli.mjs +1 -1
  20. package/dist/index.d.ts +5 -5
  21. package/dist/index.js +2 -2
  22. package/dist/input.d.ts +2 -2
  23. package/dist/shared/animator.d.ts +104 -0
  24. package/dist/shared/animator.d.ts.map +1 -0
  25. package/dist/shared/art.js +1946 -0
  26. package/dist/shared/art.js.map +1 -0
  27. package/dist/shared/atlas.d.ts +134 -0
  28. package/dist/shared/atlas.d.ts.map +1 -0
  29. package/dist/shared/avatar.d.ts +117 -0
  30. package/dist/shared/avatar.d.ts.map +1 -0
  31. package/dist/shared/bridge.js +13 -5
  32. package/dist/shared/bridge.js.map +1 -1
  33. package/dist/shared/client-version.gen.js +1 -1
  34. package/dist/shared/daily.d.ts +129 -18
  35. package/dist/shared/daily.d.ts.map +1 -1
  36. package/dist/shared/daily.js +120 -26
  37. package/dist/shared/daily.js.map +1 -1
  38. package/dist/shared/defineProperty.js +39 -0
  39. package/dist/shared/describe.d.ts +32 -0
  40. package/dist/shared/describe.d.ts.map +1 -0
  41. package/dist/shared/describe.js +109 -0
  42. package/dist/shared/describe.js.map +1 -0
  43. package/dist/shared/globals.js +1 -39
  44. package/dist/shared/globals.js.map +1 -1
  45. package/dist/shared/index.d.ts +503 -14
  46. package/dist/shared/index.d.ts.map +1 -1
  47. package/dist/shared/input.d.ts +31 -13
  48. package/dist/shared/input.d.ts.map +1 -1
  49. package/dist/shared/input.js +40 -2
  50. package/dist/shared/input.js.map +1 -1
  51. package/dist/shared/json-guards.js +48 -0
  52. package/dist/shared/json-guards.js.map +1 -0
  53. package/dist/shared/live-canvas.js +62 -0
  54. package/dist/shared/live-canvas.js.map +1 -0
  55. package/dist/shared/mp.js +1 -1
  56. package/dist/shared/overlays.js +800 -0
  57. package/dist/shared/overlays.js.map +1 -0
  58. package/dist/shared/page-stack.js +2 -1
  59. package/dist/shared/page-stack.js.map +1 -1
  60. package/dist/shared/pose-key.js +249 -0
  61. package/dist/shared/pose-key.js.map +1 -0
  62. package/dist/shared/race.js +1 -1
  63. package/dist/shared/rooms.js +1 -1
  64. package/dist/shared/scene.d.ts +90 -0
  65. package/dist/shared/scene.d.ts.map +1 -0
  66. package/dist/shared/social.d.ts +55 -4
  67. package/dist/shared/social.d.ts.map +1 -1
  68. package/dist/shared/src.js +2020 -144
  69. package/dist/shared/src.js.map +1 -1
  70. package/dist/shared/svg-markup.js +1945 -0
  71. package/dist/shared/svg-markup.js.map +1 -0
  72. package/dist/shared/svg.js +27 -0
  73. package/dist/shared/svg.js.map +1 -0
  74. package/dist/shared/types.d.ts +166 -0
  75. package/dist/shared/types.d.ts.map +1 -0
  76. package/dist/social.d.ts +2 -2
  77. package/dist/social.js +2 -2
  78. package/dist/turns.d.ts +39 -0
  79. package/dist/turns.d.ts.map +1 -0
  80. package/dist/turns.js +117 -0
  81. package/dist/turns.js.map +1 -0
  82. package/package.json +33 -3
  83. package/src/avatar/adapters/pixi-avatar.ts +73 -0
  84. package/src/avatar/adapters/pixi-crowd.ts +302 -0
  85. package/src/avatar/adapters/pixi-types.ts +84 -0
  86. package/src/avatar/adapters/three.ts +126 -0
  87. package/src/avatar/animator/animator.ts +285 -0
  88. package/src/avatar/animator/reactions.ts +44 -0
  89. package/src/avatar/animator/schedule.ts +73 -0
  90. package/src/avatar/art/beard.ts +49 -0
  91. package/src/avatar/art/glasses.ts +67 -0
  92. package/src/avatar/art/hair.ts +254 -0
  93. package/src/avatar/art/headwear.ts +75 -0
  94. package/src/avatar/art/index.ts +21 -0
  95. package/src/avatar/art/shape.ts +87 -0
  96. package/src/avatar/art-store.ts +36 -0
  97. package/src/avatar/atlas.ts +236 -0
  98. package/src/avatar/catalog-view.ts +14 -0
  99. package/src/avatar/colour.ts +109 -0
  100. package/src/avatar/describe.ts +79 -0
  101. package/src/avatar/face.ts +187 -0
  102. package/src/avatar/geom.ts +173 -0
  103. package/src/avatar/layout.ts +47 -0
  104. package/src/avatar/look-like.ts +52 -0
  105. package/src/avatar/outputs/bitmap.ts +75 -0
  106. package/src/avatar/outputs/canvas-paint.ts +177 -0
  107. package/src/avatar/outputs/canvas.ts +62 -0
  108. package/src/avatar/outputs/custom-element.ts +140 -0
  109. package/src/avatar/outputs/element.ts +58 -0
  110. package/src/avatar/outputs/live-canvas.ts +57 -0
  111. package/src/avatar/outputs/overlay-svg.ts +22 -0
  112. package/src/avatar/outputs/pose-key.ts +30 -0
  113. package/src/avatar/outputs/svg-markup.ts +113 -0
  114. package/src/avatar/outputs/svg.ts +41 -0
  115. package/src/avatar/overlays.ts +128 -0
  116. package/src/avatar/render/body.ts +116 -0
  117. package/src/avatar/render/colours.ts +81 -0
  118. package/src/avatar/render/compose.ts +303 -0
  119. package/src/avatar/render/context.ts +117 -0
  120. package/src/avatar/render/eyes.ts +78 -0
  121. package/src/avatar/render/eyewear.ts +118 -0
  122. package/src/avatar/render/frame.ts +60 -0
  123. package/src/avatar/render/head.ts +81 -0
  124. package/src/avatar/render/mouth.ts +86 -0
  125. package/src/avatar/render/props.ts +91 -0
  126. package/src/avatar/render/types.ts +84 -0
  127. package/src/avatar/scene.ts +119 -0
  128. package/src/avatar-pixi.ts +20 -0
  129. package/src/avatar-svg.ts +21 -0
  130. package/src/avatar-three.ts +18 -0
  131. package/src/avatar.ts +62 -0
  132. package/src/core/avatar/avatar-cache.ts +95 -0
  133. package/src/core/avatar/avatar-ref.ts +34 -0
  134. package/src/core/avatar/avatar-types.ts +43 -0
  135. package/src/core/avatar/hub-avatar.ts +308 -0
  136. package/src/core/connection.ts +8 -0
  137. package/src/core/errors.ts +7 -1
  138. package/src/core/hub-progress.ts +39 -8
  139. package/src/core/hub.ts +174 -6
  140. package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
  141. package/src/core/offline/hub-kinds/index.ts +9 -0
  142. package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
  143. package/src/core/page-account.ts +121 -0
  144. package/src/core/rest/routes.gen.ts +31 -1
  145. package/src/core/resume/hub-resume.ts +39 -5
  146. package/src/core/resume/resume-types.ts +2 -2
  147. package/src/core/saved/daily-saves.ts +196 -0
  148. package/src/core/saved/daily-seed.ts +16 -0
  149. package/src/core/saved/hub-saved.ts +221 -0
  150. package/src/core/saved/local-saved.ts +122 -0
  151. package/src/core/saved/saved-list.ts +129 -0
  152. package/src/core/saved/saved-open.ts +104 -0
  153. package/src/core/saved/saved-order.ts +49 -0
  154. package/src/core/saved/saved-types.ts +24 -0
  155. package/src/core/turns/hub-turns.ts +140 -0
  156. package/src/core/turns/turn-game.ts +388 -0
  157. package/src/core/version/client-version.gen.ts +2 -2
  158. package/src/daily/daily-system.ts +112 -13
  159. package/src/daily/page-daily.ts +2 -2
  160. package/src/daily/types.ts +46 -9
  161. package/src/doctor/hub-version-fetch.ts +43 -43
  162. package/src/index.ts +10 -1
  163. package/src/input/input-system.ts +10 -1
  164. package/src/input/navigator.ts +11 -0
  165. package/src/input.ts +1 -0
  166. package/src/overlay/overlay.ts +25 -1
  167. package/src/realtime/runtime-api.ts +14 -3
  168. package/src/social.ts +1 -0
  169. package/src/turns-kit/check-rules.ts +182 -0
  170. package/src/turns.ts +13 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * A player's games in progress: the hub's list merged with this device's
3
+ * copies of runs (`hub.resume`) and daily challenges (`hub.daily.save`), so
4
+ * one saved offline (or just before the tab closed) shows at once, and one
5
+ * cleared or discarded offline disappears at once. Pure.
6
+ */
7
+ import { compareSaved } from './saved-order';
8
+ import type { LocalResume, LocalRun } from '../resume/local-resume';
9
+ import type { LocalDaily } from './local-saved';
10
+ import type { SavedEntry } from './saved-types';
11
+
12
+ /** The id a run saved only on this device goes by until the hub has it. */
13
+ export function localRunId(slug: string): string {
14
+ return `local:${slug}:run`;
15
+ }
16
+
17
+ /** The id a daily started only on this device goes by until the hub has it. */
18
+ export function localDailyId(slug: string, day: string): string {
19
+ return `local:${slug}:${day}`;
20
+ }
21
+
22
+ /** A device run as an entry, with the game's catalog fields from the hub's entry when it has one. */
23
+ function runEntry(slug: string, run: LocalRun, known: SavedEntry | undefined): SavedEntry {
24
+ return {
25
+ id: known?.id ?? localRunId(slug),
26
+ game: slug,
27
+ gameTitle: known?.gameTitle ?? run.gameTitle,
28
+ path: known?.path ?? `/${slug}`,
29
+ hasImage: known?.hasImage ?? false,
30
+ kind: 'run',
31
+ status: 'active',
32
+ title: run.title,
33
+ progress: { label: run.label, fraction: run.fraction },
34
+ version: run.version,
35
+ createdAt: known?.createdAt ?? run.at,
36
+ updatedAt: run.at,
37
+ finishedAt: null,
38
+ isLocalOnly: !run.isSynced,
39
+ };
40
+ }
41
+
42
+ /** One game's run: the hub's, the device's newer copy, or none (cleared on the device). */
43
+ function mergeRun(slug: string, hub: SavedEntry | undefined, copy: LocalResume | undefined, isHubKnown: boolean): SavedEntry | null {
44
+ if (copy === undefined) {
45
+ return hub ?? null;
46
+ }
47
+ if (copy.status === 'cleared') {
48
+ return hub !== undefined && hub.updatedAt > copy.at ? hub : null;
49
+ }
50
+ if (hub !== undefined && hub.updatedAt >= copy.at) {
51
+ return hub;
52
+ }
53
+ // The hub hasn't got this run: show it while it waits to be sent, or when the hub can't be asked.
54
+ return !copy.isSynced || !isHubKnown || hub !== undefined ? runEntry(slug, copy, hub) : null;
55
+ }
56
+
57
+ /** A device daily as an entry. */
58
+ function dailyEntry(daily: LocalDaily, known: SavedEntry | undefined): SavedEntry {
59
+ return {
60
+ id: daily.id,
61
+ game: daily.game,
62
+ gameTitle: known?.gameTitle ?? daily.gameTitle,
63
+ path: known?.path ?? `/${daily.game}`,
64
+ hasImage: known?.hasImage ?? false,
65
+ kind: 'daily',
66
+ status: 'active',
67
+ title: daily.title,
68
+ progress: { label: daily.label, fraction: daily.fraction },
69
+ version: daily.version,
70
+ createdAt: known?.createdAt ?? daily.at,
71
+ updatedAt: daily.at,
72
+ finishedAt: null,
73
+ daily: { day: daily.day, seed: daily.seed },
74
+ isLocalOnly: !daily.isSynced,
75
+ };
76
+ }
77
+
78
+ /** What the device holds, for one player. */
79
+ export interface DeviceCopies {
80
+ /** Run copies by slug (`hub.resume`). */
81
+ readonly runs: ReadonlyMap<string, LocalResume>;
82
+ /** Daily copies (`hub.daily`), any game. */
83
+ readonly dailies: readonly LocalDaily[];
84
+ }
85
+
86
+ /** One daily: the hub's entry, the device's newer copy, or none (discarded or finished on the device). */
87
+ function mergeDaily(hub: SavedEntry | undefined, copy: LocalDaily | undefined, isHubKnown: boolean): SavedEntry | null {
88
+ if (copy === undefined) {
89
+ return hub ?? null;
90
+ }
91
+ if (copy.status !== 'active') {
92
+ return hub !== undefined && hub.updatedAt > copy.at ? hub : null;
93
+ }
94
+ if (hub !== undefined && hub.updatedAt >= copy.at) {
95
+ return hub;
96
+ }
97
+ return !copy.isSynced || !isHubKnown || hub !== undefined ? dailyEntry(copy, hub) : null;
98
+ }
99
+
100
+ /** The key that pairs a hub daily with a device copy: its game and day. */
101
+ const dailyKey = (game: string, day: string): string => `${game}#${day}`;
102
+
103
+ /**
104
+ * The entries to show, in the shared list order. `hub` is the hub's list,
105
+ * or `undefined` when it couldn't be reached; `copies` are the device's
106
+ * (already only the player's); `game` limits the device copies to one game.
107
+ */
108
+ export function mergeSavedLists(hub: readonly SavedEntry[] | undefined, copies: DeviceCopies, game: string | null): SavedEntry[] {
109
+ const isHubKnown = hub !== undefined;
110
+ const fromHub = hub ?? [];
111
+ const runs = new Map(fromHub.filter((entry) => entry.kind === 'run').map((entry) => [entry.game, entry] as const));
112
+ const dailies = new Map(fromHub.flatMap((entry) => (entry.kind === 'daily' && entry.daily !== undefined ? [[dailyKey(entry.game, entry.daily.day), entry] as const] : [])));
113
+ const runCopies = new Map([...copies.runs].filter(([slug]) => game === null || slug === game));
114
+ const dailyCopies = new Map(copies.dailies.filter((copy) => game === null || copy.game === game).map((copy) => [dailyKey(copy.game, copy.day), copy] as const));
115
+ const merged: SavedEntry[] = fromHub.filter((entry) => entry.kind === 'turns');
116
+ for (const slug of new Set([...runs.keys(), ...runCopies.keys()])) {
117
+ const entry = mergeRun(slug, runs.get(slug), runCopies.get(slug), isHubKnown);
118
+ if (entry !== null) {
119
+ merged.push(entry);
120
+ }
121
+ }
122
+ for (const key of new Set([...dailies.keys(), ...dailyCopies.keys()])) {
123
+ const entry = mergeDaily(dailies.get(key), dailyCopies.get(key), isHubKnown);
124
+ if (entry !== null) {
125
+ merged.push(entry);
126
+ }
127
+ }
128
+ return merged.sort(compareSaved);
129
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Opening one saved game in its game page: the address the hub's lists use
3
+ * (`/<slug>/?hub_open=<id>&hub_kind=<kind>`, plus `hub_resume=1` for a run
4
+ * and `daily=<day>` for a daily, so games that only know `hub.resume` or
5
+ * `hub.daily` restore it too), and reading it back on arrival. The first
6
+ * client copy on the page takes the parameters out of the address bar (a
7
+ * reload or a shared link doesn't open it again) and records the answer on
8
+ * `window`, so every copy (a game's bundled client, the host's
9
+ * `/_hub/hub.js`) answers the same.
10
+ *
11
+ * Picking a game of THIS page from the hub menu fires
12
+ * {@link SAVED_OPEN_EVENT} on `window`: a client with an `onOpen` handler
13
+ * cancels it and switches in place; otherwise the menu loads the address.
14
+ */
15
+ import type { SavedGame, SavedKind } from '@diffenderfer-games/hub-contract';
16
+ import { readField } from '../../realtime/cross-bundle';
17
+ import { isBrowserPage } from '../page';
18
+ import { RESUME_REQUEST_PARAM } from '../resume/resume-request';
19
+
20
+ /** The instance id parameter (the contract's `SAVED_OPEN_PARAM`). */
21
+ export const SAVED_OPEN_PARAM = 'hub_open';
22
+
23
+ /** The instance kind parameter (the contract's `SAVED_KIND_PARAM`). */
24
+ export const SAVED_KIND_PARAM = 'hub_kind';
25
+
26
+ /** The window event the hub menu fires to open an instance of this page's game (cancelable). */
27
+ export const SAVED_OPEN_EVENT = 'hub:saved-open';
28
+
29
+ /** The window event the social runtime fires for each `ev:saved` (detail: the `SavedGame`). */
30
+ export const SAVED_CHANGE_EVENT = 'hub:saved';
31
+
32
+ /** Where the answer is kept for the page. */
33
+ const PAGE_FLAG = '__HUB_SAVED_OPENED__';
34
+
35
+ /** The instance a page was opened for. */
36
+ export interface SavedOpened {
37
+ readonly id: string;
38
+ readonly kind: SavedKind;
39
+ }
40
+
41
+ const KINDS: readonly SavedKind[] = ['daily', 'run', 'turns'];
42
+
43
+ /** `text` as a kind, or `null`. */
44
+ function asKind(text: string | null): SavedKind | null {
45
+ return KINDS.find((kind) => kind === text) ?? null;
46
+ }
47
+
48
+ /** The address that opens `game` in its page. */
49
+ export function savedGameUrl(game: Pick<SavedGame, 'id' | 'kind' | 'path' | 'daily'>): string {
50
+ const params = new URLSearchParams();
51
+ if (game.kind === 'run') {
52
+ params.set(RESUME_REQUEST_PARAM, '1');
53
+ }
54
+ if (game.kind === 'daily' && game.daily !== undefined) {
55
+ params.set('daily', game.daily.day);
56
+ }
57
+ params.set(SAVED_OPEN_PARAM, game.id);
58
+ params.set(SAVED_KIND_PARAM, game.kind);
59
+ return `${game.path.replace(/\/+$/, '')}/?${params.toString()}`;
60
+ }
61
+
62
+ /** Read and strip `hub_open` / `hub_kind`; `null` when absent or malformed. */
63
+ function takeParams(): SavedOpened | null {
64
+ try {
65
+ const url = new URL(location.href);
66
+ const id = url.searchParams.get(SAVED_OPEN_PARAM);
67
+ const kind = asKind(url.searchParams.get(SAVED_KIND_PARAM));
68
+ if (!url.searchParams.has(SAVED_OPEN_PARAM) && !url.searchParams.has(SAVED_KIND_PARAM)) {
69
+ return null;
70
+ }
71
+ url.searchParams.delete(SAVED_OPEN_PARAM);
72
+ url.searchParams.delete(SAVED_KIND_PARAM);
73
+ try {
74
+ history.replaceState(history.state, '', `${url.pathname}${url.search}${url.hash}`);
75
+ } catch {
76
+ // A sandboxed page that refuses replaceState keeps them; the answer is still recorded.
77
+ }
78
+ return id === null || id === '' || kind === null ? null : { id, kind };
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /** A recorded answer read back. */
85
+ // eslint-disable-next-line no-restricted-syntax -- trust boundary: a window global another bundle may have set
86
+ function readRecorded(value: unknown): SavedOpened | null {
87
+ const id = readField(value, 'id');
88
+ const kind = readField(value, 'kind');
89
+ const savedKind = asKind(typeof kind === 'string' ? kind : null);
90
+ return typeof id === 'string' && savedKind !== null ? { id, kind: savedKind } : null;
91
+ }
92
+
93
+ /** The instance this page was opened for, or `null` (synchronous, so a game can route before it draws). */
94
+ export function savedOpenedOnPage(): SavedOpened | null {
95
+ if (!isBrowserPage()) {
96
+ return null;
97
+ }
98
+ if (Reflect.has(window, PAGE_FLAG)) {
99
+ return readRecorded(Reflect.get(window, PAGE_FLAG));
100
+ }
101
+ const opened = takeParams();
102
+ Reflect.set(window, PAGE_FLAG, opened);
103
+ return opened;
104
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The order and badge counts of saved games, the same rules as the
3
+ * contract's `compareSaved` and `countSaved` (the hub's `GET /saved` and
4
+ * every list agree; the browser client carries no contract code, so this
5
+ * copy is kept in step by a test). Pure.
6
+ */
7
+ import type { SavedCounts } from '@diffenderfer-games/hub-contract';
8
+ import type { SavedEntry } from './saved-types';
9
+
10
+ /** The fields the order and counts read. */
11
+ type Listed = Pick<SavedEntry, 'id' | 'kind' | 'updatedAt' | 'turns'>;
12
+
13
+ /** The player must move in this online game now. */
14
+ export function isYourTurn(entry: Pick<SavedEntry, 'turns'>): boolean {
15
+ return entry.turns?.isMyTurn === true && entry.turns.phase === 'playing';
16
+ }
17
+
18
+ /** Online games waiting on the player first (oldest turn first), then the newest. */
19
+ export function compareSaved(a: Listed, b: Listed): number {
20
+ const isATurn = isYourTurn(a);
21
+ const isBTurn = isYourTurn(b);
22
+ if (isATurn !== isBTurn) {
23
+ return isATurn ? -1 : 1;
24
+ }
25
+ if (isATurn && a.turns !== undefined && b.turns !== undefined && a.turns.turnStartedAt !== b.turns.turnStartedAt) {
26
+ return a.turns.turnStartedAt - b.turns.turnStartedAt;
27
+ }
28
+ if (a.updatedAt !== b.updatedAt) {
29
+ return b.updatedAt - a.updatedAt;
30
+ }
31
+ return a.id < b.id ? 1 : -1;
32
+ }
33
+
34
+ /** The badge counts of a list of games in progress. */
35
+ export function countSaved(entries: readonly Listed[]): SavedCounts {
36
+ const counts = { yourTurn: 0, dailies: 0, runs: 0, waiting: 0, total: entries.length };
37
+ for (const entry of entries) {
38
+ if (entry.kind === 'daily') {
39
+ counts.dailies += 1;
40
+ } else if (entry.kind === 'run') {
41
+ counts.runs += 1;
42
+ } else if (isYourTurn(entry)) {
43
+ counts.yourTurn += 1;
44
+ } else {
45
+ counts.waiting += 1;
46
+ }
47
+ }
48
+ return counts;
49
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `hub.saved`'s public types: one game in progress as the lists show it,
3
+ * the badge counts, and what `opened` says about the page.
4
+ */
5
+ import type { SavedCounts, SavedDaily, SavedGame, SavedKind, SavedProgress, SavedStatus, TurnSummary } from '@diffenderfer-games/hub-contract';
6
+
7
+ export type { SavedCounts, SavedDaily, SavedKind, SavedProgress, SavedStatus, TurnSummary };
8
+ export type { SavedOpened } from './saved-open';
9
+
10
+ /**
11
+ * One game in progress: a daily challenge, a single-player run or an online
12
+ * game. Its `id` is the hub's, or `local:…` for one saved only on this
13
+ * device so far.
14
+ */
15
+ export interface SavedEntry extends Omit<SavedGame, 'id'> {
16
+ readonly id: string;
17
+ /** Saved on this device and not yet sent to the hub (offline, or the tab closed first). */
18
+ readonly isLocalOnly?: boolean;
19
+ }
20
+
21
+ /** Which kinds to list. */
22
+ export interface SavedListOptions {
23
+ readonly kinds?: readonly SavedKind[];
24
+ }
@@ -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
+ }