@diffenderfer-games/hub 2.0.0-next.5 → 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.
Files changed (170) hide show
  1. package/CHANGELOG.md +54 -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 +1804 -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 +101 -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 +1860 -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 +9 -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 +68 -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 +76 -0
  101. package/src/avatar/face.ts +187 -0
  102. package/src/avatar/geom.ts +168 -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 +258 -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 +56 -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 +269 -238
  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
package/src/avatar.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `@diffenderfer-games/hub/avatar`: draw players' avatars in any game.
3
+ *
4
+ * ```ts
5
+ * import { ready, render, animator } from '@diffenderfer-games/hub/avatar';
6
+ *
7
+ * await ready(); // loads the art once
8
+ * el.innerHTML = render.svg(seat.look, { size: 48 });
9
+ * const anim = animator(seat.look, { seed: seat.userId });
10
+ * anim.express('happy');
11
+ * render.canvas(ctx, seat.look, { x, y, size: 64, pose: anim.pose(now) });
12
+ * ```
13
+ *
14
+ * The art loads lazily (`ready()`); before it does, renders draw the
15
+ * silhouette. Zero dependencies; the Pixi and three.js adapters take the
16
+ * library as an argument (`/avatar/pixi`, `/avatar/three`). Docs: "Avatars"
17
+ * in docs/hub.md.
18
+ *
19
+ * @packageDocumentation
20
+ */
21
+ import { renderBitmap, renderDataUrl } from './avatar/outputs/bitmap';
22
+ import { renderCanvas, renderCanvasAt } from './avatar/outputs/canvas';
23
+ import { renderElement } from './avatar/outputs/element';
24
+ import { renderSvg } from './avatar/outputs/svg';
25
+
26
+ export {
27
+ ACCESSORY_SLOTS, CATALOG, CATALOG_REVISION, DEFAULT_GEN, LOOK_CODE_PREFIX, PALETTES,
28
+ canonicalLook, decodeLook, defaultLook, distinctLooks, encodeLook, isLookCode, isValidLook, lookProblem,
29
+ normaliseHex, partById, partName, partsOf, randomLook, resolveColour, sameLook, seededRng,
30
+ type AccessorySlot, type AvatarLook, type CatalogEntry, type DecodedLook, type LookAccessory, type LookColour,
31
+ type LookPart, type LookProblem, type PaletteName, type PartCategory, type Preset, type RandomLookOptions, type Rng,
32
+ } from '@diffenderfer-games/hub-contract/avatar';
33
+ export { ready, onArtReady } from './avatar/art-store';
34
+ export { animator, Animator, type AnimatorOptions, type At, type Feeling } from './avatar/animator/animator';
35
+ export { REACTIONS, type Reaction } from './avatar/animator/reactions';
36
+ export { EXPRESSIONS, EXPRESSION_NAMES, expressionFromEmote, isExpression, mix, type BrowParams, type Expression, type FaceExtra, type FaceParams, type MouthParams } from './avatar/face';
37
+ export { facingFrom, layout, lookToward } from './avatar/layout';
38
+ export { avatarEmoji, describe } from './avatar/describe';
39
+ export { defineAvatarElement } from './avatar/outputs/custom-element';
40
+ export type { AvatarElement } from './avatar/outputs/element';
41
+ export type { CanvasPlacement } from './avatar/outputs/canvas';
42
+ export { overlays, type ActivityIcon, type Overlay, type ReactionGlyph } from './avatar/overlays';
43
+ export { overlaySvg, paintOverlay } from './avatar/outputs/overlay-svg';
44
+ export { catalog } from './avatar/catalog-view';
45
+ export { composeAvatar, HEAD_Y, type Composed } from './avatar/render/compose';
46
+ export { paintNodes, type Ctx2D } from './avatar/outputs/canvas-paint';
47
+ export { sceneToSvg } from './avatar/outputs/svg-markup';
48
+ export { toCode, toLook } from './avatar/look-like';
49
+ export { contrast, deltaE, lightness } from './avatar/colour';
50
+ export type { SceneNode } from './avatar/scene';
51
+ export type { Anchor, BodyPose, Facing, Frame, LookDir, LookLike, Point, Pose, RenderOptions, StatusProp, View } from './avatar/render/types';
52
+ export { AvatarAtlas, createAtlas, type AtlasFrame, type AtlasOptions } from './avatar/atlas';
53
+
54
+ /** Every output: SVG markup, a live element, Canvas 2D, a bitmap or a data URL. */
55
+ export const render = {
56
+ svg: renderSvg,
57
+ element: renderElement,
58
+ canvas: renderCanvas,
59
+ canvasAt: renderCanvasAt,
60
+ bitmap: renderBitmap,
61
+ dataUrl: renderDataUrl,
62
+ };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The device's avatar cache (avatars.md §5.6), so faces render offline:
3
+ * your own look (`hub:avatar:me`, read synchronously at startup) and an LRU
4
+ * of other players' codes (`hub:avatar:cache`, 200 entries).
5
+ */
6
+ import { isLookCode } from '@diffenderfer-games/hub-contract/avatar';
7
+ import { isRecord, optionalNumber, optionalString, parseJsonText } from '../storage/json-guards';
8
+ import type { KeyValueStore } from '../storage/key-value';
9
+
10
+ /** Your own look as cached. */
11
+ export interface CachedMine {
12
+ readonly userId: number;
13
+ readonly code: string;
14
+ readonly isDefault: boolean;
15
+ readonly updatedAt: number;
16
+ }
17
+
18
+ const MINE_KEY = 'hub:avatar:me';
19
+ const OTHERS_KEY = 'hub:avatar:cache';
20
+ const OTHERS_MAX = 200;
21
+
22
+ /** The device cache over a key-value store. */
23
+ export class AvatarCache {
24
+ private readonly store: KeyValueStore;
25
+ private others: Map<number, string> | null = null;
26
+
27
+ constructor(store: KeyValueStore) {
28
+ this.store = store;
29
+ }
30
+
31
+ /** Your cached look, or `null` (never cached, or another player's on this device). */
32
+ readMine(userId: number | null): CachedMine | null {
33
+ const parsed = parseJsonText(this.store.read(MINE_KEY) ?? '');
34
+ if (!isRecord(parsed)) {
35
+ return null;
36
+ }
37
+ const id = optionalNumber(parsed.userId);
38
+ const code = optionalString(parsed.code);
39
+ if (id === undefined || code === undefined || !isLookCode(code) || (userId !== null && id !== userId)) {
40
+ return null;
41
+ }
42
+ return { userId: id, code, isDefault: parsed.isDefault === true, updatedAt: optionalNumber(parsed.updatedAt) ?? 0 };
43
+ }
44
+
45
+ /** Remember your look. */
46
+ writeMine(mine: CachedMine): void {
47
+ this.store.write(MINE_KEY, JSON.stringify(mine));
48
+ this.remember(mine.userId, mine.code);
49
+ }
50
+
51
+ /** A player's cached code, or `undefined`. */
52
+ codeOf(userId: number): string | undefined {
53
+ return this.loadOthers().get(userId);
54
+ }
55
+
56
+ /** Remember a player's code (most recent last; the oldest drop past 200). */
57
+ remember(userId: number, code: string): void {
58
+ if (code === '') {
59
+ return;
60
+ }
61
+ const others = this.loadOthers();
62
+ if (others.get(userId) === code) {
63
+ return;
64
+ }
65
+ others.delete(userId);
66
+ others.set(userId, code);
67
+ while (others.size > OTHERS_MAX) {
68
+ const oldest = others.keys().next().value;
69
+ if (oldest === undefined) {
70
+ break;
71
+ }
72
+ others.delete(oldest);
73
+ }
74
+ this.store.write(OTHERS_KEY, JSON.stringify([...others]));
75
+ }
76
+
77
+ private loadOthers(): Map<number, string> {
78
+ if (this.others !== null) {
79
+ return this.others;
80
+ }
81
+ const others = new Map<number, string>();
82
+ const parsed = parseJsonText(this.store.read(OTHERS_KEY) ?? '[]');
83
+ if (Array.isArray(parsed)) {
84
+ for (const entry of parsed) {
85
+ const id = Array.isArray(entry) ? optionalNumber(entry[0]) : undefined;
86
+ const code = Array.isArray(entry) ? optionalString(entry[1]) : undefined;
87
+ if (id !== undefined && code !== undefined && isLookCode(code)) {
88
+ others.set(id, code);
89
+ }
90
+ }
91
+ }
92
+ this.others = others;
93
+ return others;
94
+ }
95
+ }
@@ -0,0 +1,34 @@
1
+ /** Building {@link AvatarRef}s from codes, payloads and seeds. */
2
+ import { decodeLook, defaultLook, encodeLook } from '@diffenderfer-games/hub-contract/avatar';
3
+ import type { AvatarRef } from './avatar-types';
4
+
5
+ /** A ref for a code (`''` = hidden). */
6
+ export function refOf(code: string, isDefault = false): AvatarRef {
7
+ if (code === '') {
8
+ return { code: '', look: null, isDefault: false, hidden: true };
9
+ }
10
+ return { code, look: decodeLook(code)?.look ?? null, isDefault, hidden: false };
11
+ }
12
+
13
+ /** A seeded default look as a ref (bots, previews, local pass-and-play seats). */
14
+ export function botRef(seed: string | number): AvatarRef {
15
+ const look = defaultLook(seed);
16
+ return { code: encodeLook(look), look, isDefault: true, hidden: false };
17
+ }
18
+
19
+ /** Anything that carries a player's look: a seat, a `PlayerLite`, a racer, a ticket user. */
20
+ export interface CarriesLook {
21
+ readonly look?: string;
22
+ readonly userId?: number;
23
+ readonly id?: number;
24
+ readonly uid?: number;
25
+ }
26
+
27
+ /** The ref a payload carries; an old hub's payload (no `look`) falls back to the id's default. */
28
+ export function refFrom(carrier: CarriesLook): AvatarRef {
29
+ if (carrier.look !== undefined) {
30
+ return refOf(carrier.look);
31
+ }
32
+ const id = carrier.userId ?? carrier.id ?? carrier.uid;
33
+ return id === undefined ? refOf('') : botRef(id);
34
+ }
@@ -0,0 +1,43 @@
1
+ /** The shapes `hub.avatar` works with (avatars.md §6). */
2
+ import type { AvatarLook } from '@diffenderfer-games/hub-contract/avatar';
3
+
4
+ /**
5
+ * A player's avatar as games hold it: the code (pass it to any renderer),
6
+ * the decoded look, whether it is their generated default, and whether the
7
+ * player is hidden from you (draw the silhouette).
8
+ */
9
+ export interface AvatarRef {
10
+ readonly code: string;
11
+ /** `null` when hidden or the code is malformed (renderers draw the silhouette). */
12
+ readonly look: AvatarLook | null;
13
+ /** They never designed one: this is their generated default. */
14
+ readonly isDefault: boolean;
15
+ /** Hidden where the username is hidden (§2.8). */
16
+ readonly hidden: boolean;
17
+ }
18
+
19
+ /** A player's avatar changed (`hub.avatar.on('change')`). */
20
+ export interface AvatarChange {
21
+ readonly userId: number;
22
+ readonly ref: AvatarRef;
23
+ }
24
+
25
+ /** Options for the designer. */
26
+ export interface OpenDesignerOptions {
27
+ /** Why it opened (`'game'`, `'world'`, …), for metrics only. */
28
+ readonly reason?: string;
29
+ }
30
+
31
+ /** What the designer reports when it closes. */
32
+ export interface DesignerResult {
33
+ /** The player saved a new look. */
34
+ readonly saved: boolean;
35
+ }
36
+
37
+ /** What the social runtime adds for avatars (feature version 5). */
38
+ export interface RuntimeAvatarApi {
39
+ /** Open "Make your avatar" over the page; resolves when it closes. */
40
+ openDesigner(options?: OpenDesignerOptions): Promise<DesignerResult>;
41
+ /** Live `ev:avatar` changes (yours, friends', your room's). */
42
+ on(event: 'change', listener: (change: { readonly userId: number; readonly code: string }) => void): () => void;
43
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * `hub.avatar` (avatars.md §6): players' looks without the renderer. Codes
3
+ * arrive in payloads (seats, `PlayerLite`, racers), so `from()` needs no
4
+ * request; `of()` and `many()` batch and cache the rest; your own look is
5
+ * cached on the device so it works offline. `load()` brings in the renderer
6
+ * (`@diffenderfer-games/hub/avatar`) and its art when a game wants to draw.
7
+ *
8
+ * It follows the page's account (the menu's account events, the runtime's
9
+ * socket hello, this client's sign-ins): when your account appears or
10
+ * changes, or the hello says your look is not the one cached, your look is
11
+ * fetched again and `change` fires. Each hello also re-checks the players
12
+ * this page asked about, so a change sent while the socket was down is not
13
+ * missed.
14
+ */
15
+ import type { MyAvatar } from '@diffenderfer-games/hub-contract';
16
+ import { encodeLook, type AvatarLook } from '@diffenderfer-games/hub-contract/avatar';
17
+ import type * as AvatarEntry from '../../avatar';
18
+ import { pageBridge } from '../../realtime/bridge';
19
+ import { createRuntimeOpener } from '../../realtime/runtime-opener';
20
+ import type { AccountListener, PageUser } from '../page-account';
21
+ import { AvatarCache } from './avatar-cache';
22
+ import { botRef, refFrom, refOf, type CarriesLook } from './avatar-ref';
23
+ import type { AvatarChange, AvatarRef, DesignerResult, OpenDesignerOptions } from './avatar-types';
24
+ import { createPageStore } from '../storage/key-value';
25
+
26
+ /** What `hub.avatar` needs from its client. */
27
+ export interface AvatarHost {
28
+ /** The signed-in user's id, or `null` (`hub.userId()`). */
29
+ readonly currentUserId: () => number | null;
30
+ /** Hear every report of who is signed in (the page's account). */
31
+ readonly onAccount: (listener: AccountListener) => () => void;
32
+ /** `GET /avatar/me`; rejects when offline. */
33
+ readonly fetchMine: () => Promise<MyAvatar>;
34
+ /** `POST /avatar/batch`. */
35
+ readonly fetchCodes: (ids: readonly number[]) => Promise<Record<string, string>>;
36
+ /** Queue a save of your look (sent now when online, replayed later when not). */
37
+ readonly queueSave: (code: string, savedAt: number) => Promise<void>;
38
+ }
39
+
40
+ /** The renderer module `load()` resolves with. */
41
+ export type AvatarModule = typeof AvatarEntry;
42
+
43
+ type ChangeListener = (change: AvatarChange) => void;
44
+
45
+ const BATCH_MAX = 100;
46
+ const openWithRuntime = createRuntimeOpener(pageBridge);
47
+
48
+ /** The look `mine()` gives before the account exists (a new guest's is being made). */
49
+ const NO_ACCOUNT_YET = botRef('you');
50
+
51
+ /** `hub.avatar`. */
52
+ export class HubAvatar {
53
+ private readonly host: AvatarHost;
54
+ private readonly cache = new AvatarCache(createPageStore());
55
+ private readonly listeners = new Set<ChangeListener>();
56
+ private pending = new Map<number, ((code: string) => void)[]>();
57
+ private flushQueued = false;
58
+ private detachRuntime: (() => void) | null = null;
59
+ /** The account the last report named. */
60
+ private lastUserId: number | null;
61
+ /** The code of your look the page was last given (`mine`, `mineSync`, `change`). */
62
+ private mineShown: string | null = null;
63
+ /** Players this page asked about or was handed (re-checked on each socket hello). */
64
+ private readonly seen = new Set<number>();
65
+
66
+ constructor(host: AvatarHost) {
67
+ this.host = host;
68
+ this.lastUserId = host.currentUserId();
69
+ host.onAccount((user, source) => {
70
+ this.onAccount(user, source === 'session');
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Your look (your default if you never designed one). Works offline from
76
+ * the device cache. Before your account exists (a new guest's is made just
77
+ * after the game starts) it resolves at once with a default look, and
78
+ * `change` brings yours when the account is there.
79
+ */
80
+ async mine(): Promise<AvatarRef> {
81
+ const userId = this.host.currentUserId();
82
+ if (userId === null) {
83
+ return this.showMine(NO_ACCOUNT_YET);
84
+ }
85
+ try {
86
+ const mine = await this.host.fetchMine();
87
+ return this.noteMine(userId, mine);
88
+ } catch (failure) {
89
+ const cached = this.mineSync();
90
+ if (cached !== null) {
91
+ return cached;
92
+ }
93
+ throw failure;
94
+ }
95
+ }
96
+
97
+ /** Your cached look, or your default; `null` only while nobody is signed in. */
98
+ mineSync(): AvatarRef | null {
99
+ const userId = this.host.currentUserId();
100
+ if (userId === null) {
101
+ return null;
102
+ }
103
+ const cached = this.cache.readMine(userId);
104
+ return this.showMine(cached === null ? botRef(userId) : refOf(cached.code, cached.isDefault));
105
+ }
106
+
107
+ /** Any player's look: from the cache, else batched with other requests in the same tick. */
108
+ of(userId: number): Promise<AvatarRef> {
109
+ this.seen.add(userId);
110
+ const cached = this.cache.codeOf(userId);
111
+ if (cached !== undefined) {
112
+ return Promise.resolve(refOf(cached));
113
+ }
114
+ return new Promise((resolve) => {
115
+ const waiting = this.pending.get(userId) ?? [];
116
+ waiting.push((code) => {
117
+ resolve(code === '' ? botRef(userId) : refOf(code));
118
+ });
119
+ this.pending.set(userId, waiting);
120
+ this.scheduleFlush();
121
+ });
122
+ }
123
+
124
+ /** Many players' looks at once. */
125
+ async many(userIds: readonly number[]): Promise<Map<number, AvatarRef>> {
126
+ const refs = await Promise.all(userIds.map(async (id) => [id, await this.of(id)] as const));
127
+ return new Map(refs);
128
+ }
129
+
130
+ /** The look a payload already carries (a `RoomSeat`, `PlayerLite`, racer or ticket user). No request. */
131
+ from(carrier: CarriesLook): AvatarRef {
132
+ const ref = refFrom(carrier);
133
+ const id = carrier.userId ?? carrier.id ?? carrier.uid;
134
+ if (id !== undefined && carrier.look !== undefined) {
135
+ this.seen.add(id);
136
+ this.cache.remember(id, carrier.look);
137
+ }
138
+ return ref;
139
+ }
140
+
141
+ /** A seeded default for bots, previews and local pass-and-play seats: `bot('local:2')`. */
142
+ bot(seed: string | number): AvatarRef {
143
+ return botRef(seed);
144
+ }
145
+
146
+ /** Save your look (designer code paths; queued offline, latest wins). */
147
+ async save(look: AvatarLook): Promise<AvatarRef> {
148
+ const code = encodeLook(look);
149
+ const savedAt = Date.now();
150
+ const userId = this.host.currentUserId();
151
+ if (userId !== null) {
152
+ this.noteMine(userId, { code, isDefault: false, updatedAt: savedAt });
153
+ }
154
+ await this.host.queueSave(code, savedAt);
155
+ return refOf(code);
156
+ }
157
+
158
+ /**
159
+ * Hear about look changes (yours, friends', your room's, and yours when
160
+ * your account appears). Returns the unsubscribe function. Wait for this
161
+ * rather than polling `mine()`.
162
+ */
163
+ on(_event: 'change', listener: ChangeListener): () => void {
164
+ this.listeners.add(listener);
165
+ this.attachRuntime();
166
+ return () => {
167
+ this.listeners.delete(listener);
168
+ };
169
+ }
170
+
171
+ /** Open "Make your avatar" over the game; resolves when it closes. */
172
+ openDesigner(options: OpenDesignerOptions = {}): Promise<DesignerResult> {
173
+ return new Promise((resolve) => {
174
+ openWithRuntime((realtime) => {
175
+ if (realtime.avatar === undefined) {
176
+ resolve({ saved: false });
177
+ return;
178
+ }
179
+ void realtime.avatar.openDesigner(options).then(resolve, () => {
180
+ resolve({ saved: false });
181
+ });
182
+ });
183
+ });
184
+ }
185
+
186
+ /** Loads the renderer and its art; resolves when rendering is synchronous. */
187
+ async load(): Promise<AvatarModule> {
188
+ const avatar = await import('../../avatar');
189
+ await avatar.ready();
190
+ return avatar;
191
+ }
192
+
193
+ /** Remember the ref the page was given for your look. */
194
+ private showMine(ref: AvatarRef): AvatarRef {
195
+ this.mineShown = ref.code;
196
+ return ref;
197
+ }
198
+
199
+ /** Cache your look; `change` fires when it differs from the one the page last had. */
200
+ private noteMine(userId: number, mine: Pick<MyAvatar, 'code' | 'isDefault' | 'updatedAt'>): AvatarRef {
201
+ const before = this.mineShown ?? this.cache.readMine(userId)?.code ?? null;
202
+ this.cache.writeMine({ userId, code: mine.code, isDefault: mine.isDefault, updatedAt: mine.updatedAt });
203
+ const ref = this.showMine(refOf(mine.code, mine.isDefault));
204
+ if (mine.code !== before) {
205
+ this.emit({ userId, ref });
206
+ }
207
+ return ref;
208
+ }
209
+
210
+ /** Someone said who is signed in: fetch your look when the account is new or its look isn't the cached one. */
211
+ private onAccount(user: PageUser | null, isHello: boolean): void {
212
+ const userId = user === null || user.id === 0 ? null : user.id;
213
+ const isNewAccount = userId !== this.lastUserId;
214
+ this.lastUserId = userId;
215
+ if (user === null || userId === null) {
216
+ return;
217
+ }
218
+ if (isNewAccount || (user.look !== null && user.look !== this.cache.readMine(userId)?.code)) {
219
+ void this.refreshMine(userId);
220
+ }
221
+ if (isHello) {
222
+ void this.recheckSeen();
223
+ }
224
+ }
225
+
226
+ private async refreshMine(userId: number): Promise<void> {
227
+ const mine = await this.host.fetchMine().catch(() => null);
228
+ if (mine !== null && this.host.currentUserId() === userId) {
229
+ this.noteMine(userId, mine);
230
+ }
231
+ }
232
+
233
+ /** The socket (re)connected: a change sent while it was down never arrived, so ask again. */
234
+ private async recheckSeen(): Promise<void> {
235
+ if (this.listeners.size === 0 || this.seen.size === 0) {
236
+ return;
237
+ }
238
+ const codes = await this.fetchCodes([...this.seen]);
239
+ for (const [userId, code] of codes) {
240
+ if (code !== '' && code !== this.cache.codeOf(userId)) {
241
+ this.cache.remember(userId, code);
242
+ this.emit({ userId, ref: refOf(code) });
243
+ }
244
+ }
245
+ }
246
+
247
+ private emit(change: AvatarChange): void {
248
+ for (const listener of this.listeners) {
249
+ listener(change);
250
+ }
251
+ }
252
+
253
+ private attachRuntime(): void {
254
+ if (this.detachRuntime !== null) {
255
+ return;
256
+ }
257
+ this.detachRuntime = pageBridge.subscribe((realtime) => realtime.avatar?.on('change', ({ userId, code }) => {
258
+ if (userId === this.host.currentUserId()) {
259
+ this.noteMine(userId, { code, isDefault: false, updatedAt: Date.now() });
260
+ return;
261
+ }
262
+ this.cache.remember(userId, code);
263
+ this.emit({ userId, ref: refOf(code) });
264
+ }) ?? (() => undefined));
265
+ }
266
+
267
+ private scheduleFlush(): void {
268
+ if (this.flushQueued) {
269
+ return;
270
+ }
271
+ this.flushQueued = true;
272
+ queueMicrotask(() => {
273
+ this.flushQueued = false;
274
+ void this.flush();
275
+ });
276
+ }
277
+
278
+ private async flush(): Promise<void> {
279
+ const batch = this.pending;
280
+ this.pending = new Map();
281
+ const codes = await this.fetchCodes([...batch.keys()]);
282
+ for (const [id, waiting] of batch) {
283
+ const code = codes.get(id) ?? '';
284
+ if (code !== '') {
285
+ this.cache.remember(id, code);
286
+ }
287
+ for (const resolve of waiting) {
288
+ resolve(code);
289
+ }
290
+ }
291
+ }
292
+
293
+ /** Players' codes, in batches; offline or failing, the missing ones are left out (they get their default). */
294
+ private async fetchCodes(ids: readonly number[]): Promise<Map<number, string>> {
295
+ const codes = new Map<number, string>();
296
+ for (let i = 0; i < ids.length; i += BATCH_MAX) {
297
+ const slice = ids.slice(i, i + BATCH_MAX);
298
+ const answered = await this.host.fetchCodes(slice).catch((): Record<string, string> => ({}));
299
+ for (const id of slice) {
300
+ const code = answered[String(id)];
301
+ if (code !== undefined) {
302
+ codes.set(id, code);
303
+ }
304
+ }
305
+ }
306
+ return codes;
307
+ }
308
+ }
@@ -12,6 +12,7 @@ import { LocalStore } from './offline/local-store';
12
12
  import { OfflineApi } from './offline/offline-api';
13
13
  import { Outbox, type OfflineOp } from './offline/outbox';
14
14
  import { LocalResumeStore } from './resume/local-resume';
15
+ import { LocalDailyStore } from './saved/local-saved';
15
16
  import { absoluteApiBase, detectApiBase, detectSlug, emitWindowEvent, isBrowserPage } from './page';
16
17
  import { createRestClient, type RestClient } from './rest/rest-client';
17
18
  import { createPageStore } from './storage/key-value';
@@ -65,6 +66,8 @@ export class HubConnection {
65
66
  protected readonly outbox: Outbox;
66
67
  /** The device copies of every game's resume slot (`hub.resume`). */
67
68
  protected readonly resumeStore: LocalResumeStore;
69
+ /** The device copies of daily challenges in progress (every game's). */
70
+ protected readonly dailyStore: LocalDailyStore;
68
71
  private readonly offlineMode: NonNullable<HubOptions['offline']>;
69
72
  private isReachable = true;
70
73
  private runningSync: Promise<SyncResult> | null = null;
@@ -90,6 +93,7 @@ export class HubConnection {
90
93
  const pageStore = createPageStore();
91
94
  this.store = new LocalStore(this.slug ?? 'app', pageStore);
92
95
  this.resumeStore = new LocalResumeStore(pageStore);
96
+ this.dailyStore = new LocalDailyStore(pageStore);
93
97
  this.outbox = new Outbox({
94
98
  slug: this.slug ?? 'app',
95
99
  store: pageStore,
@@ -108,9 +112,13 @@ export class HubConnection {
108
112
  registerHubKinds(this.outbox, {
109
113
  game: () => this.gameFields(),
110
114
  onDailyComplete: (payload, completion) => {
115
+ if (this.slug !== null) {
116
+ this.dailyStore.remove(this.slug, payload.day);
117
+ }
111
118
  emitWindowEvent('hub:daily-complete', { day: payload.day, count: completion.count, rank: completion.rank, meta: payload.meta });
112
119
  },
113
120
  resume: this.resumeStore,
121
+ dailies: this.dailyStore,
114
122
  cloudSaves: this.store,
115
123
  });
116
124
  this.importLegacyQueue();
@@ -41,6 +41,8 @@ export interface HubErrorDetails {
41
41
  readonly retryAfterMs?: number | undefined;
42
42
  /** When a suspension or mute ends (epoch ms). */
43
43
  readonly until?: number | undefined;
44
+ /** An online game's rules refused a move (`illegal`): the rules' own short code. */
45
+ readonly rule?: string | undefined;
44
46
  }
45
47
 
46
48
  /**
@@ -62,6 +64,8 @@ export class HubError extends Error {
62
64
  readonly retryAfterMs: number | undefined;
63
65
  /** When a suspension or mute ends (epoch ms). */
64
66
  readonly until: number | undefined;
67
+ /** An online game's rules refused a move (`code` is `illegal`): the rules' own short code, such as `cell_taken`. */
68
+ readonly rule: string | undefined;
65
69
 
66
70
  /** An error with a player-safe `message`, the HTTP `status` (if any) and the body's extras. */
67
71
  constructor(message: string, status?: number, details: HubErrorDetails = {}) {
@@ -73,6 +77,7 @@ export class HubError extends Error {
73
77
  this.hint = details.hint;
74
78
  this.retryAfterMs = details.retryAfterMs;
75
79
  this.until = details.until;
80
+ this.rule = details.rule;
76
81
  }
77
82
 
78
83
  /** `true` when the failure was a network or transport error, not an HTTP status. */
@@ -122,12 +127,13 @@ export function readErrorFields(body: unknown): ErrorBodyFields {
122
127
  hint: optionalString(body.hint),
123
128
  retryAfterMs: optionalNumber(body.retryAfterMs),
124
129
  until: optionalNumber(body.until),
130
+ rule: optionalString(body.rule),
125
131
  };
126
132
  }
127
133
 
128
134
  /** The right error class for a failure: a 422 or `content_rejected` is a {@link ContentRejectedError}. */
129
135
  export function errorFor(message: string, status: number | undefined, details: HubErrorDetails): HubError {
130
- return status === 422 || details.code === 'content_rejected'
136
+ return (status === 422 && details.code !== 'illegal') || details.code === 'content_rejected'
131
137
  ? new ContentRejectedError(message, status, details)
132
138
  : new HubError(message, status, details);
133
139
  }