@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,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules conformance kit (docs/multiplayer.md "Turn games: testing"):
|
|
3
|
+
* `checkTurnRules(rules, options)` plays many seeded games through a rules
|
|
4
|
+
* module the way the hub does and throws on the first broken promise:
|
|
5
|
+
*
|
|
6
|
+
* - every game reaches an outcome within `maxMoves`, and `toAct` is never
|
|
7
|
+
* empty before it; every seat in `toAct` gets to move (not only the
|
|
8
|
+
* first), and so do the seats in `mayAct` (out of turn: a jump-in, a
|
|
9
|
+
* catch) whose `autoReact` (or `options.reactMove`) has a move;
|
|
10
|
+
* - `toAct` and `mayAct` only name seats that exist;
|
|
11
|
+
* - the state survives a JSON round trip after every move, and the same seed
|
|
12
|
+
* plays the same game (no `Math.random`, no hidden globals);
|
|
13
|
+
* - `autoMove` and `autoReact` (when the rules have them) are always legal;
|
|
14
|
+
* - no seat's `view` or `eventView` shows another seat's secrets: markers
|
|
15
|
+
* planted with `options.plant` never appear in anyone else's view until
|
|
16
|
+
* the rules reveal them (`options.revealed`: a card played face up);
|
|
17
|
+
* - with `options.shuffleHidden` (the "twin table"): shuffling everything a
|
|
18
|
+
* seat may not see never changes that seat's view;
|
|
19
|
+
* - stored fixtures from older versions `migrate` to a state that views;
|
|
20
|
+
* - views, events and moves stay within the hub's size caps.
|
|
21
|
+
*
|
|
22
|
+
* It needs no hub: run it in a game's own unit tests (vitest, node:test).
|
|
23
|
+
*/
|
|
24
|
+
import { createTurnRng, turnRngStateFromSeed, type TurnRng, type TurnRules } from '@diffenderfer-games/hub-contract/turns-rules';
|
|
25
|
+
import { parseJsonText } from '../core/storage/json-guards';
|
|
26
|
+
|
|
27
|
+
/** The hub's caps. */
|
|
28
|
+
const STATE_MAX_BYTES = 64 * 1024;
|
|
29
|
+
const MOVE_MAX_BYTES = 2 * 1024;
|
|
30
|
+
|
|
31
|
+
/** How to check a rules module. */
|
|
32
|
+
export interface TurnRulesCheckOptions<State, Move, Options = never> {
|
|
33
|
+
/** Seat counts to play (default: 2). */
|
|
34
|
+
readonly seats?: readonly number[];
|
|
35
|
+
/** Games per seat count (default: 25). */
|
|
36
|
+
readonly games?: number;
|
|
37
|
+
/** A game that runs longer than this many moves fails (default: 2000). */
|
|
38
|
+
readonly maxMoves?: number;
|
|
39
|
+
/** Options and mode to set games up with. */
|
|
40
|
+
readonly setupOptions?: Options;
|
|
41
|
+
readonly mode?: string | null;
|
|
42
|
+
/** A legal move for `seat` (default: the rules' own `autoMove`). At least one of the two is needed. */
|
|
43
|
+
readonly randomMove?: (state: State, seat: number, rng: TurnRng) => Move;
|
|
44
|
+
/** A legal out-of-turn move for `seat` while it is in `mayAct`, or `null` to pass (default: the rules' own `autoReact`). */
|
|
45
|
+
readonly reactMove?: (state: State, seat: number, rng: TurnRng) => Move | null;
|
|
46
|
+
/**
|
|
47
|
+
* Put `marker` somewhere only `seat` may see (a card in their hand, their
|
|
48
|
+
* secret goal) and return the state; the kit then checks no other seat's
|
|
49
|
+
* view or events ever contain it. Leave it out for a game with no hidden
|
|
50
|
+
* information.
|
|
51
|
+
*/
|
|
52
|
+
readonly plant?: (state: State, seat: number, marker: string) => State;
|
|
53
|
+
/**
|
|
54
|
+
* Whether a planted `marker` is now public by the rules (the card was
|
|
55
|
+
* played or discarded face up, a secret was shown at the end): from then
|
|
56
|
+
* on the kit stops checking it in that game, so a card that is rightly
|
|
57
|
+
* revealed isn't a leak. Leave it out when planted markers never become
|
|
58
|
+
* public.
|
|
59
|
+
*/
|
|
60
|
+
readonly revealed?: (state: State, marker: string) => boolean;
|
|
61
|
+
/**
|
|
62
|
+
* The "twin table": a copy of `state` with everything `seat` may not see
|
|
63
|
+
* shuffled or redealt (the deck's order, the other hands' cards, keeping
|
|
64
|
+
* their counts), drawing from `rng`. After every move the kit checks
|
|
65
|
+
* that each seat's `view` of the twin is exactly its view of the real
|
|
66
|
+
* state: a view that changes depends on something that seat must not
|
|
67
|
+
* know. Catches leaks `plant` can't (a count, an order, a derived hint).
|
|
68
|
+
*/
|
|
69
|
+
readonly shuffleHidden?: (state: State, seat: number, rng: TurnRng) => State;
|
|
70
|
+
/** States saved by older rules versions, which `migrate` must bring up to date. */
|
|
71
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: states stored by older rules versions
|
|
72
|
+
readonly fixtures?: readonly { readonly version: number; readonly state: unknown }[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** What the kit played. */
|
|
76
|
+
export interface TurnRulesReport {
|
|
77
|
+
readonly games: number;
|
|
78
|
+
readonly moves: number;
|
|
79
|
+
readonly longestGame: number;
|
|
80
|
+
/** Moves made out of turn (by a seat in `mayAct`, not `toAct`). */
|
|
81
|
+
readonly outOfTurn: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The UTF-8 bytes of a JSON text. */
|
|
85
|
+
function bytesOf(json: string): number {
|
|
86
|
+
return new TextEncoder().encode(json).length;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Throw a kit failure. */
|
|
90
|
+
function fail(message: string): never {
|
|
91
|
+
throw new Error(`checkTurnRules: ${message}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A JSON copy (what the hub stores and reads back). */
|
|
95
|
+
function roundTrip<T>(value: T): T {
|
|
96
|
+
const copy = parseJsonText(JSON.stringify(value));
|
|
97
|
+
if (copy === null && value !== null) {
|
|
98
|
+
fail('a state or move is not JSON');
|
|
99
|
+
}
|
|
100
|
+
return isGamesOwn(copy, value) ? copy : fail('a state or move is not JSON');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Always true: JSON a game's rules made is read back as the game's own type (the game vouches for it, as `toGameData` does). */
|
|
104
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the game's own JSON, round-tripped
|
|
105
|
+
function isGamesOwn<T>(_value: unknown, _like: T): _value is T {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A move for `seat`: the check's own chooser, else the rules' autoMove. */
|
|
110
|
+
function pickMove<State, Move, View, Event, Options>(rules: TurnRules<State, Move, View, Event, Options>, options: TurnRulesCheckOptions<State, Move, Options>, state: State, seat: number, rng: TurnRng): Move {
|
|
111
|
+
if (options.randomMove !== undefined) {
|
|
112
|
+
return options.randomMove(state, seat, rng);
|
|
113
|
+
}
|
|
114
|
+
if (rules.autoMove === undefined) {
|
|
115
|
+
return fail('the rules have no autoMove: pass options.randomMove');
|
|
116
|
+
}
|
|
117
|
+
return rules.autoMove(state, seat, rng);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** An out-of-turn move for `seat`: the check's own chooser, else the rules' autoReact, else none. */
|
|
121
|
+
function pickReaction<State, Move, View, Event, Options>(rules: TurnRules<State, Move, View, Event, Options>, options: TurnRulesCheckOptions<State, Move, Options>, state: State, seat: number, rng: TurnRng): Move | null {
|
|
122
|
+
if (options.reactMove !== undefined) {
|
|
123
|
+
return options.reactMove(state, seat, rng);
|
|
124
|
+
}
|
|
125
|
+
return rules.autoReact?.(state, seat, rng) ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Fail unless every seat in `list` exists. */
|
|
129
|
+
function checkSeats(list: readonly number[], seats: number, what: string, seed: number): void {
|
|
130
|
+
if (list.some((seat) => !Number.isInteger(seat) || seat < 0 || seat >= seats)) {
|
|
131
|
+
fail(`${what} named a seat that doesn't exist: [${list.join(', ')}] (seed ${String(seed)})`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The next move of a game: half the time a seat in `mayAct` that has an
|
|
137
|
+
* out-of-turn move, else a seat in `toAct` (any of them, not only the
|
|
138
|
+
* first). The kit's own `pick` RNG chooses, so the game's RNG sees only
|
|
139
|
+
* the rules' own draws.
|
|
140
|
+
*/
|
|
141
|
+
function nextMove<State, Move, View, Event, Options>(
|
|
142
|
+
rules: TurnRules<State, Move, View, Event, Options>, options: TurnRulesCheckOptions<State, Move, Options>, state: State, toAct: readonly number[], rng: TurnRng, pick: TurnRng,
|
|
143
|
+
): { seat: number; move: Move; by: string } | null {
|
|
144
|
+
const optional = (rules.mayAct?.(state) ?? []).filter((seat) => !toAct.includes(seat));
|
|
145
|
+
if (optional.length > 0 && pick.int(2) === 0) {
|
|
146
|
+
const seat = pick.pick(optional);
|
|
147
|
+
const move = pickReaction(rules, options, state, seat, rng);
|
|
148
|
+
if (move !== null) {
|
|
149
|
+
return { seat, move, by: options.reactMove === undefined ? 'autoReact' : 'reactMove' };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const seat = toAct.length === 0 ? undefined : pick.pick(toAct);
|
|
153
|
+
return seat === undefined ? null : { seat, move: pickMove(rules, options, state, seat, rng), by: options.randomMove === undefined ? 'autoMove' : 'randomMove' };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Play one seeded game; answers its moves and a trace for the determinism check. */
|
|
157
|
+
function playGame<State, Move, View, Event, Options>(
|
|
158
|
+
rules: TurnRules<State, Move, View, Event, Options>, seats: number, seed: number, options: TurnRulesCheckOptions<State, Move, Options>,
|
|
159
|
+
): { moves: number; outOfTurn: number; trace: string } {
|
|
160
|
+
const rng = createTurnRng(turnRngStateFromSeed(seed));
|
|
161
|
+
const pick = createTurnRng(turnRngStateFromSeed(seed ^ 0x5bd1e995));
|
|
162
|
+
const twinRng = createTurnRng(turnRngStateFromSeed(seed ^ 0x27d4eb2f));
|
|
163
|
+
const marker = `MARKER-${String(seed)}-`;
|
|
164
|
+
const markerOf = (owner: number): string => `${marker}${String(owner)}`;
|
|
165
|
+
let state = rules.setup({ seats, options: options.setupOptions ?? null, mode: options.mode ?? null, rng });
|
|
166
|
+
if (options.plant !== undefined) {
|
|
167
|
+
for (let seat = 0; seat < seats; seat++) {
|
|
168
|
+
state = options.plant(state, seat, markerOf(seat));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const maxMoves = options.maxMoves ?? 2000;
|
|
172
|
+
const trace: string[] = [];
|
|
173
|
+
let outOfTurn = 0;
|
|
174
|
+
/** The twin table: each seat's view is the same after what it can't see is shuffled. */
|
|
175
|
+
const checkTwin = (): void => {
|
|
176
|
+
if (options.shuffleHidden === undefined) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
for (let viewer = 0; viewer < seats; viewer++) {
|
|
180
|
+
const real = JSON.stringify(rules.view(state, viewer));
|
|
181
|
+
const twin = JSON.stringify(rules.view(roundTrip(options.shuffleHidden(roundTrip(state), viewer, twinRng)), viewer));
|
|
182
|
+
if (twin !== real) {
|
|
183
|
+
fail(`seat ${String(viewer)}'s view changed when what it can't see was shuffled: it shows something hidden (seed ${String(seed)})`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
/** The seats whose planted marker is still secret (the rules haven't revealed it). */
|
|
188
|
+
const secret = new Set(options.plant === undefined ? [] : Array.from({ length: seats }, (_, seat) => seat));
|
|
189
|
+
const checkViews = (events: readonly Event[]): void => {
|
|
190
|
+
for (const owner of secret) {
|
|
191
|
+
if (options.revealed?.(state, markerOf(owner)) === true) {
|
|
192
|
+
secret.delete(owner);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (let viewer = 0; viewer < seats; viewer++) {
|
|
196
|
+
const seen = JSON.stringify(rules.view(state, viewer)) + JSON.stringify(events.map((event) => rules.eventView?.(event, viewer) ?? null));
|
|
197
|
+
for (const owner of secret) {
|
|
198
|
+
if (owner !== viewer && seen.includes(markerOf(owner))) {
|
|
199
|
+
fail(`seat ${String(viewer)} can see seat ${String(owner)}'s secret (seed ${String(seed)})`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const shown = JSON.stringify(rules.view(state, null));
|
|
204
|
+
for (const owner of secret) {
|
|
205
|
+
if (shown.includes(markerOf(owner))) {
|
|
206
|
+
fail(`the public view shows seat ${String(owner)}'s secret (seed ${String(seed)})`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
checkTwin();
|
|
210
|
+
};
|
|
211
|
+
checkViews([]);
|
|
212
|
+
for (let moves = 0; moves < maxMoves; moves++) {
|
|
213
|
+
if (rules.outcome(state) !== null) {
|
|
214
|
+
return { moves, outOfTurn, trace: trace.join('|') };
|
|
215
|
+
}
|
|
216
|
+
const toAct = rules.toAct(state);
|
|
217
|
+
checkSeats(toAct, seats, 'toAct', seed);
|
|
218
|
+
checkSeats(rules.mayAct?.(state) ?? [], seats, 'mayAct', seed);
|
|
219
|
+
if (toAct.length === 0) {
|
|
220
|
+
fail(`toAct is empty but there is no outcome yet (seed ${String(seed)}, move ${String(moves)})`);
|
|
221
|
+
}
|
|
222
|
+
const next = nextMove(rules, options, state, toAct, rng, pick);
|
|
223
|
+
if (next === null) {
|
|
224
|
+
return fail(`no seat could move (seed ${String(seed)}, move ${String(moves)})`);
|
|
225
|
+
}
|
|
226
|
+
const { seat, move } = next;
|
|
227
|
+
outOfTurn += toAct.includes(seat) ? 0 : 1;
|
|
228
|
+
if (bytesOf(JSON.stringify(move)) > MOVE_MAX_BYTES) {
|
|
229
|
+
fail(`a move is over ${String(MOVE_MAX_BYTES)} bytes`);
|
|
230
|
+
}
|
|
231
|
+
const result = rules.apply(roundTrip(state), seat, roundTrip(move), rng);
|
|
232
|
+
if ('error' in result) {
|
|
233
|
+
fail(`${next.by} made an illegal move for seat ${String(seat)}: ${result.error} (seed ${String(seed)}, move ${String(moves)})`);
|
|
234
|
+
}
|
|
235
|
+
state = roundTrip(result.state);
|
|
236
|
+
if (bytesOf(JSON.stringify(state)) > STATE_MAX_BYTES) {
|
|
237
|
+
fail(`the state is over ${String(STATE_MAX_BYTES)} bytes`);
|
|
238
|
+
}
|
|
239
|
+
checkViews(result.events ?? []);
|
|
240
|
+
rules.progress(state, seat);
|
|
241
|
+
trace.push(`${String(seat)}:${JSON.stringify(move)}`);
|
|
242
|
+
}
|
|
243
|
+
return fail(`a game did not end within ${String(maxMoves)} moves (seed ${String(seed)})`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Check a rules module (see the file comment); throws on the first failure. */
|
|
247
|
+
export function checkTurnRules<State, Move, View, Event, Options>(
|
|
248
|
+
rules: TurnRules<State, Move, View, Event, Options>, options: TurnRulesCheckOptions<State, Move, Options> = {},
|
|
249
|
+
): TurnRulesReport {
|
|
250
|
+
if (!Number.isInteger(rules.version) || rules.version < 0) {
|
|
251
|
+
fail('rules.version must be a whole number');
|
|
252
|
+
}
|
|
253
|
+
let games = 0;
|
|
254
|
+
let moves = 0;
|
|
255
|
+
let longestGame = 0;
|
|
256
|
+
let outOfTurn = 0;
|
|
257
|
+
for (const seats of options.seats ?? [2]) {
|
|
258
|
+
for (let game = 0; game < (options.games ?? 25); game++) {
|
|
259
|
+
const seed = seats * 100_003 + game;
|
|
260
|
+
const played = playGame(rules, seats, seed, options);
|
|
261
|
+
if (playGame(rules, seats, seed, options).trace !== played.trace) {
|
|
262
|
+
fail(`the same seed played a different game (seed ${String(seed)}): use the rng you are given, never Math.random`);
|
|
263
|
+
}
|
|
264
|
+
games += 1;
|
|
265
|
+
moves += played.moves;
|
|
266
|
+
longestGame = Math.max(longestGame, played.moves);
|
|
267
|
+
outOfTurn += played.outOfTurn;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const fixture of options.fixtures ?? []) {
|
|
271
|
+
if (fixture.version < rules.version) {
|
|
272
|
+
if (rules.migrate === undefined) {
|
|
273
|
+
fail(`a version ${String(fixture.version)} fixture needs rules.migrate`);
|
|
274
|
+
}
|
|
275
|
+
const migrated = rules.migrate(roundTrip(fixture.state), fixture.version);
|
|
276
|
+
rules.view(migrated, null);
|
|
277
|
+
rules.toAct(migrated);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { games, moves, longestGame, outOfTurn };
|
|
281
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@diffenderfer-games/hub/turns-rules`: what an online turn game's rules
|
|
3
|
+
* module imports (docs/multiplayer.md §16): `defineTurnRules` to type the
|
|
4
|
+
* rules object, the rules contract's types, and the seeded RNG the hub
|
|
5
|
+
* hands the rules. Nothing else comes with it (no zod, no client), so a
|
|
6
|
+
* rules bundle that imports it stays a few KB. The conformance kit for
|
|
7
|
+
* tests is `@diffenderfer-games/hub/turns`.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
export { createTurnRng, defineTurnRules, turnRngStateFromSeed } from '@diffenderfer-games/hub-contract/turns-rules';
|
|
12
|
+
export type { TurnApplyResult, TurnOutcome, TurnProgress, TurnRng, TurnRngState, TurnRules, TurnSetupContext } from '@diffenderfer-games/hub-contract/turns-rules';
|
package/src/turns.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@diffenderfer-games/hub/turns`: writing and testing an online turn
|
|
3
|
+
* game's rules module (docs/multiplayer.md "Turn games"). The types of the
|
|
4
|
+
* rules contract, `defineTurnRules` to type a rules object, the seeded RNG
|
|
5
|
+
* the hub hands the rules (to replay a game in tests), and the conformance
|
|
6
|
+
* kit, `checkTurnRules`, for the game's own CI. The page-side API is
|
|
7
|
+
* `hub.turns` on the main entry. A rules module itself imports
|
|
8
|
+
* `@diffenderfer-games/hub/turns-rules` (the same `defineTurnRules` and
|
|
9
|
+
* types, nothing else), so its bundle stays a few KB.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
export * from './turns-rules';
|
|
14
|
+
export { checkTurnRules, type TurnRulesCheckOptions, type TurnRulesReport } from './turns-kit/check-rules';
|
package/src/types.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `@diffenderfer-games/hub/types`: a type-only re-export of every contract
|
|
3
|
-
* type a game may use (request and reply shapes, entities, error bodies and
|
|
4
|
-
* codes, socket ops and events, the `game.*` manifest), so a game can name a
|
|
5
|
-
* hub type without depending on the contract package itself. Importing it
|
|
6
|
-
* adds nothing to a bundle.
|
|
7
|
-
*
|
|
8
|
-
* The client's own types (`User`, `Save<T>`, `HubStatus`, ...) come from the
|
|
9
|
-
* main entry.
|
|
10
|
-
*
|
|
11
|
-
* @packageDocumentation
|
|
12
|
-
*/
|
|
13
|
-
export type * from '@diffenderfer-games/hub-contract';
|
|
1
|
+
/**
|
|
2
|
+
* `@diffenderfer-games/hub/types`: a type-only re-export of every contract
|
|
3
|
+
* type a game may use (request and reply shapes, entities, error bodies and
|
|
4
|
+
* codes, socket ops and events, the `game.*` manifest), so a game can name a
|
|
5
|
+
* hub type without depending on the contract package itself. Importing it
|
|
6
|
+
* adds nothing to a bundle.
|
|
7
|
+
*
|
|
8
|
+
* The client's own types (`User`, `Save<T>`, `HubStatus`, ...) come from the
|
|
9
|
+
* main entry.
|
|
10
|
+
*
|
|
11
|
+
* @packageDocumentation
|
|
12
|
+
*/
|
|
13
|
+
export type * from '@diffenderfer-games/hub-contract';
|
|
@@ -1,45 +1,45 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The overlay stack is one per page, on `window.__HUB_UI__`, because several
|
|
3
|
-
* bundles share it: the host's `/_hub/hub.js`, a game's bundled client and
|
|
4
|
-
* the social runtime. It has its own handover rule (older than plan §4.4's
|
|
5
|
-
* registry, and kept for v1 copies): a newer implementation replaces an older
|
|
6
|
-
* one only while it is idle, adopting its listeners, so open entries are
|
|
7
|
-
* never orphaned.
|
|
8
|
-
*/
|
|
9
|
-
import { hasMethods, readField } from '../realtime/cross-bundle';
|
|
10
|
-
import type { UiStack } from './types';
|
|
11
|
-
import { UI_STACK_VERSION, UiStackImpl } from './ui-stack';
|
|
12
|
-
|
|
13
|
-
declare global {
|
|
14
|
-
interface Window {
|
|
15
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: set by any bundle on the page, including v1 copies
|
|
16
|
-
__HUB_UI__?: unknown;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Whether `candidate` is some bundle's overlay stack. */
|
|
21
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the stack may come from another bundle
|
|
22
|
-
export function isUiStack(candidate: unknown): candidate is UiStack {
|
|
23
|
-
return typeof readField(candidate, 'version') === 'number' && hasMethods(candidate, ['push', 'on']);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
let pagelessStack: UiStack | null = null;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The page-wide overlay stack, created on first use. Outside a browser a
|
|
30
|
-
* module-local instance is returned.
|
|
31
|
-
*/
|
|
32
|
-
export function getUiStack(): UiStack {
|
|
33
|
-
if (typeof window === 'undefined') {
|
|
34
|
-
pagelessStack ??= new UiStackImpl();
|
|
35
|
-
return pagelessStack;
|
|
36
|
-
}
|
|
37
|
-
const existing = window.__HUB_UI__;
|
|
38
|
-
if (isUiStack(existing) && (existing.version >= UI_STACK_VERSION || existing.isOpen)) {
|
|
39
|
-
return existing;
|
|
40
|
-
}
|
|
41
|
-
const handoff = isUiStack(existing) && hasMethods(existing, ['handoff']) ? existing.handoff() : undefined;
|
|
42
|
-
const stack = new UiStackImpl(handoff);
|
|
43
|
-
window.__HUB_UI__ = stack;
|
|
44
|
-
return stack;
|
|
45
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The overlay stack is one per page, on `window.__HUB_UI__`, because several
|
|
3
|
+
* bundles share it: the host's `/_hub/hub.js`, a game's bundled client and
|
|
4
|
+
* the social runtime. It has its own handover rule (older than plan §4.4's
|
|
5
|
+
* registry, and kept for v1 copies): a newer implementation replaces an older
|
|
6
|
+
* one only while it is idle, adopting its listeners, so open entries are
|
|
7
|
+
* never orphaned.
|
|
8
|
+
*/
|
|
9
|
+
import { hasMethods, readField } from '../realtime/cross-bundle';
|
|
10
|
+
import type { UiStack } from './types';
|
|
11
|
+
import { UI_STACK_VERSION, UiStackImpl } from './ui-stack';
|
|
12
|
+
|
|
13
|
+
declare global {
|
|
14
|
+
interface Window {
|
|
15
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: set by any bundle on the page, including v1 copies
|
|
16
|
+
__HUB_UI__?: unknown;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Whether `candidate` is some bundle's overlay stack. */
|
|
21
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the stack may come from another bundle
|
|
22
|
+
export function isUiStack(candidate: unknown): candidate is UiStack {
|
|
23
|
+
return typeof readField(candidate, 'version') === 'number' && hasMethods(candidate, ['push', 'on']);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let pagelessStack: UiStack | null = null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The page-wide overlay stack, created on first use. Outside a browser a
|
|
30
|
+
* module-local instance is returned.
|
|
31
|
+
*/
|
|
32
|
+
export function getUiStack(): UiStack {
|
|
33
|
+
if (typeof window === 'undefined') {
|
|
34
|
+
pagelessStack ??= new UiStackImpl();
|
|
35
|
+
return pagelessStack;
|
|
36
|
+
}
|
|
37
|
+
const existing = window.__HUB_UI__;
|
|
38
|
+
if (isUiStack(existing) && (existing.version >= UI_STACK_VERSION || existing.isOpen)) {
|
|
39
|
+
return existing;
|
|
40
|
+
}
|
|
41
|
+
const handoff = isUiStack(existing) && hasMethods(existing, ['handoff']) ? existing.handoff() : undefined;
|
|
42
|
+
const stack = new UiStackImpl(handoff);
|
|
43
|
+
window.__HUB_UI__ = stack;
|
|
44
|
+
return stack;
|
|
45
|
+
}
|
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* While hub UI covers the game, its `play` input group is held disabled (so a
|
|
3
|
-
* paddle doesn't move while the player types), even for games that ignore
|
|
4
|
-
* the overlay events. The game's own enable/disable calls for `play` are
|
|
5
|
-
* recorded instead of applied, and the last one wins on release, so a game
|
|
6
|
-
* that pauses itself on `open` stays paused afterwards.
|
|
7
|
-
*/
|
|
8
|
-
import { hasMethods } from '../realtime/cross-bundle';
|
|
9
|
-
|
|
10
|
-
/** The input group the stack suspends. */
|
|
11
|
-
export const PLAY_GROUP = 'play';
|
|
12
|
-
|
|
13
|
-
/** The slice of the page's input system the stack needs (any bundle's). */
|
|
14
|
-
export interface GroupSwitch {
|
|
15
|
-
/** Whether a group is on. */
|
|
16
|
-
isEnabled(group: string): boolean;
|
|
17
|
-
/** Turn a group on. */
|
|
18
|
-
enable(group: string): void;
|
|
19
|
-
/** Turn a group off. */
|
|
20
|
-
disable(group: string): void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Whether `candidate` can switch input groups. */
|
|
24
|
-
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the page's input system may come from another bundle
|
|
25
|
-
export function isGroupSwitch(candidate: unknown): candidate is GroupSwitch {
|
|
26
|
-
return hasMethods(candidate, ['isEnabled', 'enable', 'disable']);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** The intercepted method names. */
|
|
30
|
-
const SWITCH_METHODS = ['enable', 'disable'] as const;
|
|
31
|
-
|
|
32
|
-
/** Holds `play` disabled for one overlay visit. */
|
|
33
|
-
export class PlaySuspension {
|
|
34
|
-
private readonly input: GroupSwitch;
|
|
35
|
-
private shouldPlayAfter: boolean;
|
|
36
|
-
/** The own-property descriptors replaced (`undefined` = the method lived on the prototype). */
|
|
37
|
-
private readonly savedMethods: Record<(typeof SWITCH_METHODS)[number], PropertyDescriptor | undefined>;
|
|
38
|
-
|
|
39
|
-
constructor(input: GroupSwitch) {
|
|
40
|
-
this.input = input;
|
|
41
|
-
this.shouldPlayAfter = input.isEnabled(PLAY_GROUP);
|
|
42
|
-
if (this.shouldPlayAfter) {
|
|
43
|
-
input.disable(PLAY_GROUP);
|
|
44
|
-
}
|
|
45
|
-
this.savedMethods = {
|
|
46
|
-
enable: Object.getOwnPropertyDescriptor(input, 'enable'),
|
|
47
|
-
disable: Object.getOwnPropertyDescriptor(input, 'disable'),
|
|
48
|
-
};
|
|
49
|
-
const enableGroup = input.enable.bind(input);
|
|
50
|
-
const disableGroup = input.disable.bind(input);
|
|
51
|
-
// Own-property overrides shadow the real methods until release().
|
|
52
|
-
input.enable = (group: string): void => {
|
|
53
|
-
if (group === PLAY_GROUP) {
|
|
54
|
-
this.shouldPlayAfter = true;
|
|
55
|
-
} else {
|
|
56
|
-
enableGroup(group);
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
input.disable = (group: string): void => {
|
|
60
|
-
if (group === PLAY_GROUP) {
|
|
61
|
-
this.shouldPlayAfter = false;
|
|
62
|
-
} else {
|
|
63
|
-
disableGroup(group);
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Remove the interception and apply the state the game last asked for. */
|
|
69
|
-
release(): void {
|
|
70
|
-
for (const method of SWITCH_METHODS) {
|
|
71
|
-
const saved = this.savedMethods[method];
|
|
72
|
-
if (saved === undefined) {
|
|
73
|
-
Reflect.deleteProperty(this.input, method);
|
|
74
|
-
} else {
|
|
75
|
-
Object.defineProperty(this.input, method, saved);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (this.shouldPlayAfter) {
|
|
79
|
-
this.input.enable(PLAY_GROUP);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* While hub UI covers the game, its `play` input group is held disabled (so a
|
|
3
|
+
* paddle doesn't move while the player types), even for games that ignore
|
|
4
|
+
* the overlay events. The game's own enable/disable calls for `play` are
|
|
5
|
+
* recorded instead of applied, and the last one wins on release, so a game
|
|
6
|
+
* that pauses itself on `open` stays paused afterwards.
|
|
7
|
+
*/
|
|
8
|
+
import { hasMethods } from '../realtime/cross-bundle';
|
|
9
|
+
|
|
10
|
+
/** The input group the stack suspends. */
|
|
11
|
+
export const PLAY_GROUP = 'play';
|
|
12
|
+
|
|
13
|
+
/** The slice of the page's input system the stack needs (any bundle's). */
|
|
14
|
+
export interface GroupSwitch {
|
|
15
|
+
/** Whether a group is on. */
|
|
16
|
+
isEnabled(group: string): boolean;
|
|
17
|
+
/** Turn a group on. */
|
|
18
|
+
enable(group: string): void;
|
|
19
|
+
/** Turn a group off. */
|
|
20
|
+
disable(group: string): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether `candidate` can switch input groups. */
|
|
24
|
+
// eslint-disable-next-line no-restricted-syntax -- trust boundary: the page's input system may come from another bundle
|
|
25
|
+
export function isGroupSwitch(candidate: unknown): candidate is GroupSwitch {
|
|
26
|
+
return hasMethods(candidate, ['isEnabled', 'enable', 'disable']);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The intercepted method names. */
|
|
30
|
+
const SWITCH_METHODS = ['enable', 'disable'] as const;
|
|
31
|
+
|
|
32
|
+
/** Holds `play` disabled for one overlay visit. */
|
|
33
|
+
export class PlaySuspension {
|
|
34
|
+
private readonly input: GroupSwitch;
|
|
35
|
+
private shouldPlayAfter: boolean;
|
|
36
|
+
/** The own-property descriptors replaced (`undefined` = the method lived on the prototype). */
|
|
37
|
+
private readonly savedMethods: Record<(typeof SWITCH_METHODS)[number], PropertyDescriptor | undefined>;
|
|
38
|
+
|
|
39
|
+
constructor(input: GroupSwitch) {
|
|
40
|
+
this.input = input;
|
|
41
|
+
this.shouldPlayAfter = input.isEnabled(PLAY_GROUP);
|
|
42
|
+
if (this.shouldPlayAfter) {
|
|
43
|
+
input.disable(PLAY_GROUP);
|
|
44
|
+
}
|
|
45
|
+
this.savedMethods = {
|
|
46
|
+
enable: Object.getOwnPropertyDescriptor(input, 'enable'),
|
|
47
|
+
disable: Object.getOwnPropertyDescriptor(input, 'disable'),
|
|
48
|
+
};
|
|
49
|
+
const enableGroup = input.enable.bind(input);
|
|
50
|
+
const disableGroup = input.disable.bind(input);
|
|
51
|
+
// Own-property overrides shadow the real methods until release().
|
|
52
|
+
input.enable = (group: string): void => {
|
|
53
|
+
if (group === PLAY_GROUP) {
|
|
54
|
+
this.shouldPlayAfter = true;
|
|
55
|
+
} else {
|
|
56
|
+
enableGroup(group);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
input.disable = (group: string): void => {
|
|
60
|
+
if (group === PLAY_GROUP) {
|
|
61
|
+
this.shouldPlayAfter = false;
|
|
62
|
+
} else {
|
|
63
|
+
disableGroup(group);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Remove the interception and apply the state the game last asked for. */
|
|
69
|
+
release(): void {
|
|
70
|
+
for (const method of SWITCH_METHODS) {
|
|
71
|
+
const saved = this.savedMethods[method];
|
|
72
|
+
if (saved === undefined) {
|
|
73
|
+
Reflect.deleteProperty(this.input, method);
|
|
74
|
+
} else {
|
|
75
|
+
Object.defineProperty(this.input, method, saved);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (this.shouldPlayAfter) {
|
|
79
|
+
this.input.enable(PLAY_GROUP);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|