@diffenderfer-games/hub 2.0.0-next.0 → 2.0.0-next.10
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 +163 -0
- package/LICENSE +21 -21
- package/README.md +203 -139
- package/dist/auto.d.ts +8 -8
- package/dist/auto.js +3 -3
- package/dist/avatar-pixi.d.ts +165 -0
- package/dist/avatar-pixi.d.ts.map +1 -0
- package/dist/avatar-pixi.js +299 -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 +500 -0
- package/dist/avatar.js.map +1 -0
- package/dist/daily.d.ts +2 -2
- package/dist/doctor/cli.mjs +119 -28
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.js +3 -3
- package/dist/input.d.ts +2 -2
- package/dist/input.js +2 -2
- package/dist/mp.d.ts +3 -3
- package/dist/race.d.ts +1 -1
- package/dist/rooms.d.ts +3 -3
- package/dist/rooms.js +1 -1
- package/dist/shared/animator.d.ts +104 -0
- package/dist/shared/animator.d.ts.map +1 -0
- package/dist/shared/art.js +1946 -0
- package/dist/shared/art.js.map +1 -0
- package/dist/shared/atlas.d.ts +138 -0
- package/dist/shared/atlas.d.ts.map +1 -0
- package/dist/shared/avatar.d.ts +123 -0
- package/dist/shared/avatar.d.ts.map +1 -0
- package/dist/shared/bridge.js +18 -4
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +145 -19
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +138 -25
- 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 +109 -0
- package/dist/shared/describe.js.map +1 -0
- package/dist/shared/globals.js +9 -42
- package/dist/shared/globals.js.map +1 -1
- package/dist/shared/index.d.ts +1122 -46
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +74 -14
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +179 -25
- 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/latest-state.js +1 -1
- package/dist/shared/live-canvas.js +62 -0
- package/dist/shared/live-canvas.js.map +1 -0
- package/dist/shared/mp.js +10 -2
- package/dist/shared/mp.js.map +1 -1
- package/dist/shared/multiplayer-types.d.ts +15 -2
- package/dist/shared/multiplayer-types.d.ts.map +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-types.d.ts +1 -1
- package/dist/shared/race.js +1 -1
- package/dist/shared/room-types.d.ts +28 -3
- package/dist/shared/room-types.d.ts.map +1 -1
- package/dist/shared/rooms.d.ts.map +1 -1
- package/dist/shared/rooms.js +23 -4
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.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 +90 -9
- package/dist/shared/social.d.ts.map +1 -1
- package/dist/shared/social.js +6 -1
- package/dist/shared/social.js.map +1 -1
- package/dist/shared/src.js +4563 -667
- package/dist/shared/src.js.map +1 -1
- package/dist/shared/svg-markup.js +2354 -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 +174 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/wire-types.d.ts +6 -2
- package/dist/shared/wire-types.d.ts.map +1 -1
- package/dist/social.d.ts +5 -5
- package/dist/social.js +2 -2
- package/dist/turns-rules.d.ts +2 -0
- package/dist/turns-rules.js +1 -0
- package/dist/turns.d.ts +61 -0
- package/dist/turns.d.ts.map +1 -0
- package/dist/turns.js +183 -0
- package/dist/turns.js.map +1 -0
- package/package.json +125 -89
- package/src/auto.ts +39 -39
- package/src/avatar/adapters/pixi-avatar.ts +73 -0
- package/src/avatar/adapters/pixi-crowd.ts +329 -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 +67 -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 +87 -0
- package/src/avatar/art-store.ts +36 -0
- package/src/avatar/atlas.ts +246 -0
- package/src/avatar/catalog-view.ts +14 -0
- package/src/avatar/colour.ts +109 -0
- package/src/avatar/describe.ts +79 -0
- package/src/avatar/face.ts +187 -0
- package/src/avatar/geom.ts +173 -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 +320 -0
- package/src/avatar/render/context.ts +117 -0
- package/src/avatar/render/eyes.ts +78 -0
- package/src/avatar/render/eyewear.ts +118 -0
- package/src/avatar/render/frame.ts +73 -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 +93 -0
- package/src/avatar/render/walker-parts.ts +121 -0
- package/src/avatar/render/walker.ts +199 -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 +63 -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 +408 -299
- package/src/core/errors.ts +160 -147
- package/src/core/game-data.ts +40 -38
- package/src/core/hub-economy.ts +216 -179
- package/src/core/hub-progress.ts +269 -205
- package/src/core/hub.ts +352 -131
- package/src/core/menu-control.ts +61 -61
- package/src/core/offline/cloud-saves.ts +62 -0
- package/src/core/offline/events.ts +50 -50
- package/src/core/offline/favorites.ts +21 -21
- package/src/core/offline/hub-kinds/avatar-kind.ts +42 -0
- package/src/core/offline/hub-kinds/daily-kind.ts +68 -0
- package/src/core/offline/hub-kinds/economy-kinds.ts +86 -0
- package/src/core/offline/hub-kinds/game-fields.ts +2 -0
- package/src/core/offline/hub-kinds/index.ts +52 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +165 -0
- package/src/core/offline/hub-kinds/resume-kind.ts +108 -0
- package/src/core/offline/hub-kinds/saved-kind.ts +93 -0
- package/src/core/offline/kind-adapters.ts +48 -0
- package/src/core/offline/local-inventory.ts +64 -0
- package/src/core/offline/local-store.ts +304 -261
- package/src/core/offline/offline-api.ts +65 -0
- package/src/core/offline/outbox-entry.ts +59 -0
- package/src/core/offline/outbox-kinds.ts +78 -0
- package/src/core/offline/outbox-merge.ts +39 -0
- package/src/core/offline/outbox-replayer.ts +141 -0
- package/src/core/offline/outbox-store.ts +85 -0
- package/src/core/offline/outbox.ts +232 -0
- package/src/core/offline/queue.ts +126 -171
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/page-account.ts +121 -0
- package/src/core/page.ts +87 -74
- package/src/core/rest/endpoint-types.ts +22 -22
- package/src/core/rest/rest-client.ts +163 -152
- package/src/core/rest/routes.gen.ts +144 -88
- package/src/core/resume/hub-resume.ts +286 -0
- package/src/core/resume/local-resume.ts +130 -0
- package/src/core/resume/resume-list.ts +65 -0
- package/src/core/resume/resume-request.ts +52 -0
- package/src/core/resume/resume-types.ts +64 -0
- 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/storage/json-guards.ts +80 -74
- package/src/core/storage/key-value.ts +66 -66
- package/src/core/ticket.ts +43 -43
- package/src/core/turns/hub-turns.ts +161 -0
- package/src/core/turns/turn-game.ts +510 -0
- package/src/core/turns/your-turn-buzz.ts +69 -0
- package/src/core/types.ts +134 -134
- package/src/core/version/client-version.gen.ts +2 -2
- package/src/core/version/hub-version.ts +62 -62
- package/src/core/version/semver.ts +97 -97
- package/src/daily/daily-system.ts +276 -161
- package/src/daily/page-daily.ts +36 -36
- package/src/daily/types.ts +105 -67
- package/src/daily.ts +10 -10
- package/src/doctor/checks.ts +86 -76
- package/src/doctor/cli.ts +101 -106
- package/src/doctor/hub-version-fetch.ts +43 -0
- package/src/doctor/tsconfig.json +12 -12
- package/src/doctor/world-check.ts +61 -0
- package/src/globals.ts +115 -110
- package/src/index.ts +68 -50
- package/src/input/device-state.ts +146 -146
- package/src/input/edges.ts +55 -55
- package/src/input/focus-ring.ts +13 -0
- package/src/input/gamepad.ts +61 -61
- package/src/input/input-events.ts +32 -32
- package/src/input/input-registry.ts +171 -164
- package/src/input/input-system.ts +406 -393
- package/src/input/legacy-controls.ts +64 -64
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +200 -191
- package/src/input/navigator.ts +182 -171
- package/src/input/on-screen-controls.ts +101 -101
- package/src/input/page-control.ts +43 -43
- package/src/input/page-input.ts +193 -193
- package/src/input/player-remaps.ts +77 -77
- package/src/input/source-values.ts +116 -116
- package/src/input/spatial.ts +47 -47
- package/src/input/types.ts +331 -331
- package/src/input/zero-config.ts +164 -177
- package/src/input.ts +26 -22
- package/src/mp.ts +10 -10
- package/src/overlay/controls-panel.ts +163 -163
- package/src/overlay/dom.ts +46 -46
- package/src/overlay/layout-clusters.ts +162 -162
- package/src/overlay/layout-geometry.ts +136 -136
- package/src/overlay/layout.ts +203 -203
- package/src/overlay/listener-scope.ts +23 -23
- package/src/overlay/on-screen-keyboard.ts +137 -137
- package/src/overlay/overlay.ts +234 -208
- package/src/overlay/styles.ts +13 -12
- package/src/overlay/virtual-controls.ts +262 -262
- package/src/race.ts +10 -10
- package/src/realtime/bridge.ts +126 -121
- package/src/realtime/cross-bundle.ts +29 -29
- package/src/realtime/fallback-notice.ts +48 -48
- package/src/realtime/hub-link.ts +65 -65
- package/src/realtime/latest-state.ts +33 -33
- package/src/realtime/multiplayer-forwarder.ts +40 -28
- package/src/realtime/multiplayer-types.ts +66 -52
- package/src/realtime/page-runtime.ts +60 -60
- package/src/realtime/race-forwarder.ts +59 -59
- package/src/realtime/race-types.ts +132 -132
- package/src/realtime/room-types.ts +151 -125
- package/src/realtime/rooms-forwarder.ts +104 -79
- package/src/realtime/runtime-api.ts +74 -49
- package/src/realtime/runtime-opener.ts +61 -61
- package/src/realtime/social-forwarder.ts +77 -72
- package/src/realtime/social-types.ts +86 -78
- package/src/realtime/types.ts +54 -53
- package/src/realtime/wire-types.ts +79 -72
- package/src/realtime/world-types.ts +20 -0
- package/src/rooms.ts +10 -10
- package/src/social.ts +19 -18
- package/src/turns-kit/check-rules.ts +281 -0
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +14 -0
- package/src/types.ts +13 -13
- package/src/uistack/page-stack.ts +45 -45
- package/src/uistack/play-suspension.ts +82 -82
- package/src/uistack/types.ts +76 -76
- package/src/uistack/ui-stack.ts +144 -144
|
@@ -0,0 +1,510 @@
|
|
|
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
|
+
* A safety net: while the page is visible and it is someone else's move (or
|
|
13
|
+
* a lobby), a page that has heard nothing for {@link TURN_QUIET_MS} fetches the game
|
|
14
|
+
* again (a lost live event never leaves it stuck), so games need no
|
|
15
|
+
* polling of their own.
|
|
16
|
+
*/
|
|
17
|
+
import type { Json, TurnChatLine, TurnGameView, TurnSeat, TurnSummary, TurnTournament } from '@diffenderfer-games/hub-contract';
|
|
18
|
+
import { HubError } from '../errors';
|
|
19
|
+
import { toGameData, type GamePayload } from '../game-data';
|
|
20
|
+
import { isBrowserPage } from '../page';
|
|
21
|
+
import { isJson, parseJsonText } from '../storage/json-guards';
|
|
22
|
+
import { readField } from '../../realtime/cross-bundle';
|
|
23
|
+
import { buzzYourTurn, isNewlyDue } from './your-turn-buzz';
|
|
24
|
+
|
|
25
|
+
/** The window event the social runtime fires for each `ev:turn`. */
|
|
26
|
+
export const TURN_EVENT = 'hub:turn';
|
|
27
|
+
|
|
28
|
+
/** The runtime's live connection (re)opened: anything sent while it was down is fetched again. */
|
|
29
|
+
export const TURN_RESYNC_EVENT = 'hub:turn-resync';
|
|
30
|
+
|
|
31
|
+
/** The window event the social runtime fires for each `ev:turn.chat`. */
|
|
32
|
+
export const TURN_CHAT_EVENT = 'hub:turn-chat';
|
|
33
|
+
|
|
34
|
+
/** How often a page without the live runtime looks for moves, ms. */
|
|
35
|
+
export const TURN_POLL_MS = 20_000;
|
|
36
|
+
|
|
37
|
+
/** How long a visible page waiting on another player's move (or in a lobby) may hear nothing before it fetches the game again, ms. */
|
|
38
|
+
export const TURN_QUIET_MS = 15_000;
|
|
39
|
+
|
|
40
|
+
/** How often the page checks whether it should fetch, ms. */
|
|
41
|
+
const TURN_CHECK_MS = 5_000;
|
|
42
|
+
|
|
43
|
+
/** A game's end: the winning seats, and optionally places and scores by seat. */
|
|
44
|
+
export type TurnOutcome = NonNullable<TurnGameView['outcome']>;
|
|
45
|
+
|
|
46
|
+
/** One move as this seat saw it. */
|
|
47
|
+
export interface TurnMoveEvent<View, Ev> {
|
|
48
|
+
readonly seq: number;
|
|
49
|
+
readonly seat: number;
|
|
50
|
+
/** The view after the move (for a replayed move, the view now). */
|
|
51
|
+
readonly view: View;
|
|
52
|
+
readonly events: readonly Ev[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What a game's listeners hear. */
|
|
56
|
+
export interface TurnGameEvents<View, Ev> {
|
|
57
|
+
move: TurnMoveEvent<View, Ev>;
|
|
58
|
+
turn: TurnSummary;
|
|
59
|
+
ended: { readonly outcome: TurnOutcome | null; readonly view: View };
|
|
60
|
+
chat: TurnChatLine;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What a game needs from its client: the REST calls, by name. */
|
|
64
|
+
export interface TurnGameHost {
|
|
65
|
+
get(id: string): Promise<TurnGameView>;
|
|
66
|
+
start(id: string): Promise<TurnGameView>;
|
|
67
|
+
move(id: string, seq: number, move: Json): Promise<{ game: TurnGameView; events: Json[] }>;
|
|
68
|
+
history(id: string, since: number): Promise<{ seq: number; seat: number; events: Json[] }[]>;
|
|
69
|
+
leave(id: string): Promise<void>;
|
|
70
|
+
nudge(id: string): Promise<void>;
|
|
71
|
+
setNotify(id: string, mode: 'on' | 'off'): Promise<void>;
|
|
72
|
+
rematch(id: string): Promise<TurnGameView>;
|
|
73
|
+
invite(id: string, userIds: readonly number[]): Promise<TurnGameView>;
|
|
74
|
+
share(id: string, share: { readonly link?: boolean; readonly public?: boolean }): Promise<TurnGameView>;
|
|
75
|
+
autoPlay(id: string): Promise<TurnGameView>;
|
|
76
|
+
chat(id: string, send: { readonly text?: string; readonly quick?: string }): Promise<TurnChatLine>;
|
|
77
|
+
/** The page has a game open (saves of a run are off while it does). */
|
|
78
|
+
opened(game: object): void;
|
|
79
|
+
closed(game: object): void;
|
|
80
|
+
/** Whether the live runtime is on the page (else the game polls). */
|
|
81
|
+
isLive(): boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Listener<Payload> = (payload: Payload) => void;
|
|
85
|
+
|
|
86
|
+
/** How `hub.turns` sets a game up. */
|
|
87
|
+
export interface TurnGameOptions {
|
|
88
|
+
/** Whether this page wants the "your turn" buzz (`hub.turns.setYourTurnBuzz`); read at each buzz. Default on. */
|
|
89
|
+
readonly isBuzzOn?: () => boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One online game. `View`, `Move` and `Ev` (its events) are the game's own JSON types from its rules. */
|
|
93
|
+
export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload, Ev = GamePayload> {
|
|
94
|
+
readonly id: string;
|
|
95
|
+
private state: TurnGameView;
|
|
96
|
+
private readonly host: TurnGameHost;
|
|
97
|
+
private readonly listeners: { [K in keyof TurnGameEvents<View, Ev>]: Set<Listener<TurnGameEvents<View, Ev>[K]>> } = {
|
|
98
|
+
move: new Set(), turn: new Set(), ended: new Set(), chat: new Set(),
|
|
99
|
+
};
|
|
100
|
+
private readonly stopListening: (() => void)[] = [];
|
|
101
|
+
private isClosed = false;
|
|
102
|
+
private catchingUp: Promise<void> | null = null;
|
|
103
|
+
/** The move count this page's own move in flight will make (its live echo is not another player's move). */
|
|
104
|
+
private ownSeq: number | null = null;
|
|
105
|
+
/** When the page last heard from the hub about this game (a live event or an answer), epoch ms. */
|
|
106
|
+
private heardAt = Date.now();
|
|
107
|
+
private readonly options: TurnGameOptions;
|
|
108
|
+
/** This game's own "your turn" buzz switch (`setYourTurnBuzz`). */
|
|
109
|
+
private isBuzzOn = true;
|
|
110
|
+
|
|
111
|
+
constructor(host: TurnGameHost, initial: TurnGameView, options: TurnGameOptions = {}) {
|
|
112
|
+
this.host = host;
|
|
113
|
+
this.options = options;
|
|
114
|
+
this.id = initial.id;
|
|
115
|
+
this.state = initial;
|
|
116
|
+
host.opened(this);
|
|
117
|
+
this.listen();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Whose move it is, the seats and the deadline. */
|
|
121
|
+
get summary(): TurnSummary {
|
|
122
|
+
return this.state.summary;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** This seat's view, from the game's rules (`null` before the game starts). */
|
|
126
|
+
get view(): View {
|
|
127
|
+
return toGameData<View>(this.state.view);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** This player's seat, or `null` (they left). */
|
|
131
|
+
get mySeat(): number | null {
|
|
132
|
+
return this.state.mySeat;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The end, once the game is over. */
|
|
136
|
+
get outcome(): TurnOutcome | null {
|
|
137
|
+
return this.state.outcome;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The last chat lines, oldest first. */
|
|
141
|
+
get chat(): readonly TurnChatLine[] {
|
|
142
|
+
return this.state.chat;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `on`: friends may type (moderated); `quick`: quick-chat and emotes only; `off`. */
|
|
146
|
+
get chatMode(): TurnGameView['chatMode'] {
|
|
147
|
+
return this.state.chatMode;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The link others can take a seat with (the creator's, while seats are open), else `null`. */
|
|
151
|
+
get link(): string | null {
|
|
152
|
+
return this.state.link === null ? null : new URL(this.state.link, location.origin).href;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether this player may move now without it being their turn (the rules' `mayAct`: a jump-in, calling or catching "UNO"): offer that action. */
|
|
156
|
+
get mayActNow(): boolean {
|
|
157
|
+
const { mySeat } = this.state;
|
|
158
|
+
return mySeat !== null && this.state.summary.phase === 'playing' && (this.state.summary.mayAct ?? []).includes(mySeat);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The tournament match this game is, or `null` (show it if you like: "Saturday Cup: your match"). */
|
|
162
|
+
get tournament(): TurnTournament | null {
|
|
163
|
+
return this.state.tournament ?? null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The "your turn" buzz for this game: a short vibration when this player
|
|
168
|
+
* becomes due on a live update while the page is visible (never for the
|
|
169
|
+
* next step of their own turn, a move out of turn, a game just opened or a
|
|
170
|
+
* catch-up; never when the player turned it off or asks for less motion;
|
|
171
|
+
* silently nothing where the device can't vibrate). On by default.
|
|
172
|
+
*/
|
|
173
|
+
setYourTurnBuzz(isOn: boolean): void {
|
|
174
|
+
this.isBuzzOn = isOn;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Creator: start the game (empty seats go to the computer when the game allows). */
|
|
178
|
+
async start(): Promise<void> {
|
|
179
|
+
this.update(await this.host.start(this.id));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Make a move: when it is your turn (`summary.isMyTurn`), or out of turn
|
|
184
|
+
* when the rules allow it (`mayActNow`). Rejects with a `HubError`: 403
|
|
185
|
+
* `not_your_turn`, 409 `stale` (the game moved on, or another player's
|
|
186
|
+
* move got there first: the view is refreshed first), 422 `illegal` with
|
|
187
|
+
* the rules' own code in `rule`, 500 `rules_error` or 503 `rules_busy`
|
|
188
|
+
* (the move was not made; the game is fetched again). The answer's `view` is the newest this
|
|
189
|
+
* page has: moves that arrived live while the answer was on its way (the
|
|
190
|
+
* computer's, another player's) are never rolled back.
|
|
191
|
+
*/
|
|
192
|
+
async move(move: Move): Promise<{ seq: number; view: View; events: Ev[] }> {
|
|
193
|
+
this.ownSeq = this.state.summary.seq + 1;
|
|
194
|
+
try {
|
|
195
|
+
const answer = await this.host.move(this.id, this.state.summary.seq, toPayload(move));
|
|
196
|
+
// A late answer: live events already brought a newer state, which stays.
|
|
197
|
+
if (answer.game.summary.seq >= this.state.summary.seq) {
|
|
198
|
+
this.update(answer.game);
|
|
199
|
+
}
|
|
200
|
+
return { seq: answer.game.summary.seq, view: this.view, events: answer.events.map((event) => toGameData<Ev>(event)) };
|
|
201
|
+
} catch (failure) {
|
|
202
|
+
if (failure instanceof HubError && failure.code === 'stale') {
|
|
203
|
+
await this.refresh();
|
|
204
|
+
} else if (failure instanceof HubError && (failure.code === 'rules_error' || failure.code === 'rules_busy')) {
|
|
205
|
+
// The move was not made (the hub writes nothing when its rules fail), but the page may be behind: fetch the truth.
|
|
206
|
+
await this.catchUp();
|
|
207
|
+
}
|
|
208
|
+
throw failure;
|
|
209
|
+
} finally {
|
|
210
|
+
this.ownSeq = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The moves after `sinceSeq`, with their events as this seat may see them ("what happened while you were away"). */
|
|
215
|
+
async history(sinceSeq: number): Promise<{ seq: number; seat: number; events: Ev[] }[]> {
|
|
216
|
+
const moves = await this.host.history(this.id, sinceSeq);
|
|
217
|
+
return moves.map((move) => ({ seq: move.seq, seat: move.seat, events: move.events.map((event) => toGameData<Ev>(event)) }));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Leave: the computer plays the seat (or, without bots, the others win). The player can't come back. */
|
|
221
|
+
async leave(): Promise<void> {
|
|
222
|
+
await this.host.leave(this.id);
|
|
223
|
+
this.close();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Remind whoever must move (once a day). */
|
|
227
|
+
nudge(): Promise<void> {
|
|
228
|
+
return this.host.nudge(this.id);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** This game's "your turn" alerts. */
|
|
232
|
+
setNotify(mode: 'on' | 'off'): Promise<void> {
|
|
233
|
+
return this.host.setNotify(this.id, mode);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A finished game: a new one with the same friends (they get an invite). */
|
|
237
|
+
async rematch(): Promise<TurnGame<View, Move, Ev>> {
|
|
238
|
+
return new TurnGame<View, Move, Ev>(this.host, await this.host.rematch(this.id), this.options);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Creator, before the start: add an invite link (`link: true`, then read `link`) or list the open seats for anyone (`public`). */
|
|
242
|
+
async share(share: { readonly link?: boolean; readonly public?: boolean }): Promise<void> {
|
|
243
|
+
this.update(await this.host.share(this.id, share));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Invite friends to free seats (before the start). */
|
|
247
|
+
async invite(userIds: readonly number[]): Promise<void> {
|
|
248
|
+
this.update(await this.host.invite(this.id, userIds));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Whether "Let the computer play for <name>" may work now: another
|
|
253
|
+
* player is due, the hub's hand-over wait (`summary.handOverAt`) has
|
|
254
|
+
* passed and a due player isn't connected (`TurnSeat.connected` false
|
|
255
|
+
* or unknown), or the turn's `deadline` passed. `false` in a
|
|
256
|
+
* tournament match, and from a hub too old to say. Re-check it on each
|
|
257
|
+
* `turn` event (a player coming or going fires one) and when
|
|
258
|
+
* `handOverAt` comes.
|
|
259
|
+
*/
|
|
260
|
+
canLetComputerPlay(now: number = Date.now()): boolean {
|
|
261
|
+
const { summary, mySeat } = this.state;
|
|
262
|
+
if (summary.phase !== 'playing' || mySeat === null || this.state.tournament !== undefined) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
const due = summary.seats.filter((seat) => summary.toAct.includes(seat.seat) && seat.seat !== mySeat && seat.status === 'joined');
|
|
266
|
+
if (due.length === 0) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
if (summary.deadline !== null && summary.deadline <= now) {
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
const { handOverAt } = summary;
|
|
273
|
+
return handOverAt !== undefined && handOverAt !== null && handOverAt <= now && due.some((seat) => seat.connected !== true);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Let the computer play for whoever is due: a turn whose deadline passed
|
|
278
|
+
* (or whose player is suspended) is played once; otherwise, when that
|
|
279
|
+
* player has been due a while (2 minutes) and isn't connected at all, the
|
|
280
|
+
* computer plays their seat for the rest of the game (it shows as `bot`,
|
|
281
|
+
* named with who chose it) until they open the game again. Rejects with
|
|
282
|
+
* 409 `deadline_not_passed` when neither (it never says whether they are
|
|
283
|
+
* online), and 409 `not_supported` in a tournament match.
|
|
284
|
+
*/
|
|
285
|
+
async letComputerPlay(): Promise<void> {
|
|
286
|
+
this.update(await this.host.autoPlay(this.id));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Say something: `{ text }` between friends (moderated), `{ quick }` (a quick-chat phrase id) with anyone. */
|
|
290
|
+
say(send: { readonly text?: string; readonly quick?: string }): Promise<TurnChatLine> {
|
|
291
|
+
return this.host.chat(this.id, send);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Fetch the game again, and replay moves missed since the last one this
|
|
296
|
+
* page saw. An answer older than what the page has by then (live moves
|
|
297
|
+
* arrived while it was on its way) is dropped, never rolled back to, and
|
|
298
|
+
* a move that arrived live is never replayed twice.
|
|
299
|
+
*/
|
|
300
|
+
async refresh(): Promise<void> {
|
|
301
|
+
const fresh = await this.host.get(this.id);
|
|
302
|
+
const seen = this.state.summary.seq;
|
|
303
|
+
if (fresh.summary.seq <= seen) {
|
|
304
|
+
this.update(fresh);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
const missed = await this.host.history(this.id, seen);
|
|
308
|
+
const since = this.state.summary.seq;
|
|
309
|
+
this.update(fresh);
|
|
310
|
+
for (const move of missed) {
|
|
311
|
+
if (move.seq > since && move.seq <= fresh.summary.seq) {
|
|
312
|
+
this.emit('move', { seq: move.seq, seat: move.seat, view: this.view, events: move.events.map((event) => toGameData<Ev>(event)) });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Hear moves, turn changes, the end and chat; returns a function that stops listening. */
|
|
318
|
+
on<K extends keyof TurnGameEvents<View, Ev>>(eventName: K, listener: Listener<TurnGameEvents<View, Ev>[K]>): () => void {
|
|
319
|
+
const set: Set<Listener<TurnGameEvents<View, Ev>[K]>> = this.listeners[eventName];
|
|
320
|
+
set.add(listener);
|
|
321
|
+
return () => {
|
|
322
|
+
set.delete(listener);
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Stop live updates for this page (the game itself goes on). */
|
|
327
|
+
close(): void {
|
|
328
|
+
if (this.isClosed) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
this.isClosed = true;
|
|
332
|
+
for (const stop of this.stopListening) {
|
|
333
|
+
stop();
|
|
334
|
+
}
|
|
335
|
+
this.host.closed(this);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Take a fresh answer from the hub (one older than what the page has is dropped: no rollback); fire `turn` and `ended` when they changed. */
|
|
339
|
+
private update(next: TurnGameView): void {
|
|
340
|
+
this.heardAt = Date.now();
|
|
341
|
+
const previous = this.state;
|
|
342
|
+
if (next.summary.seq < previous.summary.seq) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
this.state = next;
|
|
346
|
+
const isChanged = previous.summary.seq !== next.summary.seq || previous.summary.phase !== next.summary.phase || !sameSeats(previous.summary, next.summary)
|
|
347
|
+
|| previous.summary.handOverAt !== next.summary.handOverAt || previous.chatMode !== next.chatMode || previous.link !== next.link;
|
|
348
|
+
if (isChanged) {
|
|
349
|
+
this.emit('turn', next.summary);
|
|
350
|
+
}
|
|
351
|
+
if (previous.summary.phase !== 'ended' && next.summary.phase === 'ended') {
|
|
352
|
+
this.emit('ended', { outcome: next.outcome, view: this.view });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private emit<K extends keyof TurnGameEvents<View, Ev>>(eventName: K, payload: TurnGameEvents<View, Ev>[K]): void {
|
|
357
|
+
const set: Set<Listener<TurnGameEvents<View, Ev>[K]>> = this.listeners[eventName];
|
|
358
|
+
for (const listener of set) {
|
|
359
|
+
listener(payload);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Whether the page is waiting on someone: another player's move, or a lobby. */
|
|
364
|
+
private isWaitingOnOthers(): boolean {
|
|
365
|
+
const { summary } = this.state;
|
|
366
|
+
return summary.phase === 'lobby' || (summary.phase === 'playing' && !summary.isMyTurn);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** A live `hub:turn` for this game: a move (or a missed run of them), or another change. */
|
|
370
|
+
private onLive(detail: object): void {
|
|
371
|
+
this.heardAt = Date.now();
|
|
372
|
+
const seq = readField(detail, 'seq');
|
|
373
|
+
const view = readField(detail, 'view');
|
|
374
|
+
const summary = readField(detail, 'summary');
|
|
375
|
+
if (typeof seq !== 'number' || !isSummary(summary) || view === undefined) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (seq > this.state.summary.seq + 1) {
|
|
379
|
+
void this.catchUp();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (seq < this.state.summary.seq) {
|
|
383
|
+
// Older than what the page has (a late event): nothing to roll back to.
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const seat = readField(detail, 'seat');
|
|
387
|
+
const events = readField(detail, 'events');
|
|
388
|
+
const outcome = readField(detail, 'outcome');
|
|
389
|
+
const isMove = typeof seat === 'number' && seq === this.state.summary.seq + 1 && seq !== this.ownSeq;
|
|
390
|
+
if (summary.phase !== this.state.summary.phase || !sameSeating(this.state.summary, summary)) {
|
|
391
|
+
// 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.
|
|
392
|
+
void this.catchUp();
|
|
393
|
+
}
|
|
394
|
+
const before = this.state.summary;
|
|
395
|
+
this.update({ ...this.state, summary, view: toPayloadJson(view), outcome: isOutcome(outcome) ? outcome : this.state.outcome });
|
|
396
|
+
if (this.isBuzzOn && (this.options.isBuzzOn?.() ?? true) && isNewlyDue(before, this.state.summary)) {
|
|
397
|
+
buzzYourTurn();
|
|
398
|
+
}
|
|
399
|
+
if (isMove) {
|
|
400
|
+
this.emit('move', { seq, seat, view: this.view, events: Array.isArray(events) ? events.map((event) => toGameData<Ev>(toPayloadJson(event))) : [] });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Replay what was missed (one catch-up at a time). */
|
|
405
|
+
private catchUp(): Promise<void> {
|
|
406
|
+
this.catchingUp ??= this.refresh().catch(() => undefined).finally(() => {
|
|
407
|
+
this.catchingUp = null;
|
|
408
|
+
});
|
|
409
|
+
return this.catchingUp;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private listen(): void {
|
|
413
|
+
if (!isBrowserPage()) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const onTurn = (event: Event): void => {
|
|
417
|
+
if (!(event instanceof CustomEvent) || readField(event.detail, 'id') !== this.id) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail, read field by field
|
|
421
|
+
const detail: unknown = event.detail;
|
|
422
|
+
if (typeof detail === 'object' && detail !== null) {
|
|
423
|
+
this.onLive(detail);
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
const onChat = (event: Event): void => {
|
|
427
|
+
if (event instanceof CustomEvent && readField(event.detail, 'id') === this.id) {
|
|
428
|
+
const line = readField(event.detail, 'line');
|
|
429
|
+
if (isChatLine(line)) {
|
|
430
|
+
this.state = { ...this.state, chat: [...this.state.chat, line].slice(-50) };
|
|
431
|
+
this.emit('chat', line);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
const onVisible = (): void => {
|
|
436
|
+
if (document.visibilityState === 'visible') {
|
|
437
|
+
void this.catchUp();
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
const onResync = (): void => { void this.catchUp(); };
|
|
441
|
+
window.addEventListener(TURN_EVENT, onTurn);
|
|
442
|
+
window.addEventListener(TURN_CHAT_EVENT, onChat);
|
|
443
|
+
window.addEventListener(TURN_RESYNC_EVENT, onResync);
|
|
444
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
445
|
+
const poll = setInterval(() => {
|
|
446
|
+
if (document.visibilityState !== 'visible' || this.catchingUp !== null) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const quietMs = Date.now() - this.heardAt;
|
|
450
|
+
// Without the live runtime: poll. With it: the safety net, while waiting on someone.
|
|
451
|
+
if (this.host.isLive() ? this.isWaitingOnOthers() && quietMs >= TURN_QUIET_MS : quietMs >= TURN_POLL_MS) {
|
|
452
|
+
this.heardAt = Date.now();
|
|
453
|
+
void this.catchUp();
|
|
454
|
+
}
|
|
455
|
+
}, TURN_CHECK_MS);
|
|
456
|
+
this.stopListening.push(
|
|
457
|
+
() => { window.removeEventListener(TURN_EVENT, onTurn); },
|
|
458
|
+
() => { window.removeEventListener(TURN_CHAT_EVENT, onChat); },
|
|
459
|
+
() => { window.removeEventListener(TURN_RESYNC_EVENT, onResync); },
|
|
460
|
+
() => { document.removeEventListener('visibilitychange', onVisible); },
|
|
461
|
+
() => { clearInterval(poll); },
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Every seat matches its counterpart by `same`. */
|
|
467
|
+
function eachSeat(a: TurnSummary, b: TurnSummary, same: (seat: TurnSeat, other: TurnSeat) => boolean): boolean {
|
|
468
|
+
return a.seats.length === b.seats.length && a.seats.every((seat, index) => {
|
|
469
|
+
const other = b.seats[index];
|
|
470
|
+
return other !== undefined && same(seat, other);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The same players in the same seats with the same statuses (who sits where). */
|
|
475
|
+
function sameSeating(a: TurnSummary, b: TurnSummary): boolean {
|
|
476
|
+
return eachSeat(a, b, (seat, other) => seat.status === other.status && seat.userId === other.userId);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** The same seating, looks and who is connected (a player's new avatar, or a player coming or going, is a change: `turn` fires). */
|
|
480
|
+
function sameSeats(a: TurnSummary, b: TurnSummary): boolean {
|
|
481
|
+
return sameSeating(a, b) && eachSeat(a, b, (seat, other) => seat.look === other.look && seat.connected === other.connected);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** A game's move as the JSON the hub takes. */
|
|
485
|
+
function toPayload(move: GamePayload): Json {
|
|
486
|
+
return toPayloadJson(move);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Any JSON-safe value as `Json` (a JSON round trip). */
|
|
490
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: a game's own move, or a value from another bundle's event
|
|
491
|
+
function toPayloadJson(value: unknown): Json {
|
|
492
|
+
const parsed = parseJsonText(JSON.stringify(value ?? null));
|
|
493
|
+
return isJson(parsed) ? parsed : null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Shape checks for a live event's parts (it comes from another bundle). */
|
|
497
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail
|
|
498
|
+
function isSummary(value: unknown): value is TurnSummary {
|
|
499
|
+
return typeof readField(value, 'phase') === 'string' && Array.isArray(readField(value, 'seats')) && Array.isArray(readField(value, 'toAct'));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail
|
|
503
|
+
function isOutcome(value: unknown): value is TurnOutcome | null {
|
|
504
|
+
return value === null || Array.isArray(readField(value, 'winners'));
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: another bundle's event detail
|
|
508
|
+
function isChatLine(value: unknown): value is TurnChatLine {
|
|
509
|
+
return typeof readField(value, 'text') === 'string' && typeof readField(value, 'from') === 'number';
|
|
510
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Your turn" buzz: a short vibration when this player becomes due in an
|
|
3
|
+
* online game whose page they have open and visible (a phone held while
|
|
4
|
+
* waiting). Every online game gets it from `TurnGame`, with no game code.
|
|
5
|
+
*
|
|
6
|
+
* It buzzes only when the player **becomes** due on a live update: not for
|
|
7
|
+
* the next step of their own turn, not for a move they may make out of turn
|
|
8
|
+
* (`mayAct`), not for a game they just opened or a catch-up after being
|
|
9
|
+
* away, and not in a game they muted. It stays quiet when the player turned
|
|
10
|
+
* it off (the menu's Settings, stored on this device), when the page asks
|
|
11
|
+
* for less motion (the hub's `data-hub-motion="reduce"` or the system's
|
|
12
|
+
* reduced motion), and where `navigator.vibrate` doesn't exist (iOS Safari).
|
|
13
|
+
* A page can turn it off for itself (`hub.turns.setYourTurnBuzz(false)`) or
|
|
14
|
+
* for one game (`TurnGame.setYourTurnBuzz(false)`).
|
|
15
|
+
*/
|
|
16
|
+
import type { TurnSummary } from '@diffenderfer-games/hub-contract';
|
|
17
|
+
|
|
18
|
+
/** The pattern: two short pulses, ms. */
|
|
19
|
+
export const YOUR_TURN_BUZZ: readonly number[] = [60, 40, 60];
|
|
20
|
+
|
|
21
|
+
/** Where this device keeps the player's choice (`off` turns it off). */
|
|
22
|
+
export const YOUR_TURN_BUZZ_KEY = 'dg:your-turn-buzz';
|
|
23
|
+
|
|
24
|
+
/** Whether going from `previous` to `next` makes this player newly due (the moment to buzz). */
|
|
25
|
+
export function isNewlyDue(previous: Pick<TurnSummary, 'phase' | 'isMyTurn'>, next: Pick<TurnSummary, 'phase' | 'isMyTurn' | 'isMuted'>): boolean {
|
|
26
|
+
const wasDue = previous.phase === 'playing' && previous.isMyTurn;
|
|
27
|
+
return next.phase === 'playing' && next.isMyTurn && !wasDue && !next.isMuted;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The player's own switch on this device (on unless they turned it off). */
|
|
31
|
+
export function readYourTurnBuzz(): boolean {
|
|
32
|
+
try {
|
|
33
|
+
return localStorage.getItem(YOUR_TURN_BUZZ_KEY) !== 'off';
|
|
34
|
+
} catch {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Save the player's switch on this device. */
|
|
40
|
+
export function saveYourTurnBuzz(isOn: boolean): void {
|
|
41
|
+
try {
|
|
42
|
+
localStorage.setItem(YOUR_TURN_BUZZ_KEY, isOn ? 'on' : 'off');
|
|
43
|
+
} catch {
|
|
44
|
+
// No storage: it stays on.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Whether the page asks for less motion (the hub's setting, else the system's). */
|
|
49
|
+
function prefersLessMotion(): boolean {
|
|
50
|
+
if (typeof document !== 'undefined' && document.documentElement.dataset.hubMotion === 'reduce') {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Buzz now, when this page is visible and nothing says not to; fails silently. Answers whether it buzzed. */
|
|
57
|
+
export function buzzYourTurn(): boolean {
|
|
58
|
+
if (typeof document === 'undefined' || document.visibilityState !== 'visible' || !readYourTurnBuzz() || prefersLessMotion()) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return navigator.vibrate([...YOUR_TURN_BUZZ]);
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|