@diffenderfer-games/hub 2.0.0-next.7 → 2.0.0-next.8
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 +24 -4
- package/dist/auto.d.ts +2 -2
- package/dist/auto.js +2 -2
- package/dist/doctor/cli.mjs +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.js +1 -1
- package/dist/shared/index.d.ts +87 -10
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/src.js +172 -28
- package/dist/shared/src.js.map +1 -1
- package/dist/turns-rules.d.ts +2 -0
- package/dist/turns-rules.js +1 -0
- package/dist/turns.d.ts +23 -1
- package/dist/turns.d.ts.map +1 -1
- package/dist/turns.js +79 -13
- package/dist/turns.js.map +1 -1
- package/package.json +7 -2
- package/src/core/turns/hub-turns.ts +25 -4
- package/src/core/turns/turn-game.ts +143 -21
- package/src/core/turns/your-turn-buzz.ts +69 -0
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/index.ts +68 -67
- package/src/turns-kit/check-rules.ts +116 -17
- package/src/turns-rules.ts +12 -0
- package/src/turns.ts +4 -3
package/dist/turns.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { c as parseJsonText } from "./shared/json-guards.js";
|
|
2
|
-
import { createTurnRng,
|
|
2
|
+
import { createTurnRng, defineTurnRules, turnRngStateFromSeed } from "./turns-rules.js";
|
|
3
|
+
import { createTurnRng as createTurnRng$1, turnRngStateFromSeed as turnRngStateFromSeed$1 } from "@diffenderfer-games/hub-contract/turns-rules";
|
|
3
4
|
//#region src/turns-kit/check-rules.ts
|
|
4
5
|
/**
|
|
5
6
|
* The rules conformance kit (docs/multiplayer.md "Turn games: testing"):
|
|
@@ -7,12 +8,18 @@ import { createTurnRng, createTurnRng as createTurnRng$1, defineTurnRules, turnR
|
|
|
7
8
|
* module the way the hub does and throws on the first broken promise:
|
|
8
9
|
*
|
|
9
10
|
* - every game reaches an outcome within `maxMoves`, and `toAct` is never
|
|
10
|
-
* empty before it;
|
|
11
|
+
* empty before it; every seat in `toAct` gets to move (not only the
|
|
12
|
+
* first), and so do the seats in `mayAct` (out of turn: a jump-in, a
|
|
13
|
+
* catch) whose `autoReact` (or `options.reactMove`) has a move;
|
|
14
|
+
* - `toAct` and `mayAct` only name seats that exist;
|
|
11
15
|
* - the state survives a JSON round trip after every move, and the same seed
|
|
12
16
|
* plays the same game (no `Math.random`, no hidden globals);
|
|
13
|
-
* - `autoMove` (when the rules have
|
|
17
|
+
* - `autoMove` and `autoReact` (when the rules have them) are always legal;
|
|
14
18
|
* - no seat's `view` or `eventView` shows another seat's secrets: markers
|
|
15
|
-
* planted with `options.plant` never appear in anyone else's view
|
|
19
|
+
* planted with `options.plant` never appear in anyone else's view until
|
|
20
|
+
* the rules reveal them (`options.revealed`: a card played face up);
|
|
21
|
+
* - with `options.shuffleHidden` (the "twin table"): shuffling everything a
|
|
22
|
+
* seat may not see never changes that seat's view;
|
|
16
23
|
* - stored fixtures from older versions `migrate` to a state that views;
|
|
17
24
|
* - views, events and moves stay within the hub's size caps.
|
|
18
25
|
*
|
|
@@ -45,43 +52,99 @@ function pickMove(rules, options, state, seat, rng) {
|
|
|
45
52
|
if (rules.autoMove === void 0) return fail("the rules have no autoMove: pass options.randomMove");
|
|
46
53
|
return rules.autoMove(state, seat, rng);
|
|
47
54
|
}
|
|
55
|
+
/** An out-of-turn move for `seat`: the check's own chooser, else the rules' autoReact, else none. */
|
|
56
|
+
function pickReaction(rules, options, state, seat, rng) {
|
|
57
|
+
if (options.reactMove !== void 0) return options.reactMove(state, seat, rng);
|
|
58
|
+
return rules.autoReact?.(state, seat, rng) ?? null;
|
|
59
|
+
}
|
|
60
|
+
/** Fail unless every seat in `list` exists. */
|
|
61
|
+
function checkSeats(list, seats, what, seed) {
|
|
62
|
+
if (list.some((seat) => !Number.isInteger(seat) || seat < 0 || seat >= seats)) fail(`${what} named a seat that doesn't exist: [${list.join(", ")}] (seed ${String(seed)})`);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The next move of a game: half the time a seat in `mayAct` that has an
|
|
66
|
+
* out-of-turn move, else a seat in `toAct` (any of them, not only the
|
|
67
|
+
* first). The kit's own `pick` RNG chooses, so the game's RNG sees only
|
|
68
|
+
* the rules' own draws.
|
|
69
|
+
*/
|
|
70
|
+
function nextMove(rules, options, state, toAct, rng, pick) {
|
|
71
|
+
const optional = (rules.mayAct?.(state) ?? []).filter((seat) => !toAct.includes(seat));
|
|
72
|
+
if (optional.length > 0 && pick.int(2) === 0) {
|
|
73
|
+
const seat = pick.pick(optional);
|
|
74
|
+
const move = pickReaction(rules, options, state, seat, rng);
|
|
75
|
+
if (move !== null) return {
|
|
76
|
+
seat,
|
|
77
|
+
move,
|
|
78
|
+
by: options.reactMove === void 0 ? "autoReact" : "reactMove"
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const seat = toAct.length === 0 ? void 0 : pick.pick(toAct);
|
|
82
|
+
return seat === void 0 ? null : {
|
|
83
|
+
seat,
|
|
84
|
+
move: pickMove(rules, options, state, seat, rng),
|
|
85
|
+
by: options.randomMove === void 0 ? "autoMove" : "randomMove"
|
|
86
|
+
};
|
|
87
|
+
}
|
|
48
88
|
/** Play one seeded game; answers its moves and a trace for the determinism check. */
|
|
49
89
|
function playGame(rules, seats, seed, options) {
|
|
50
90
|
const rng = createTurnRng$1(turnRngStateFromSeed$1(seed));
|
|
91
|
+
const pick = createTurnRng$1(turnRngStateFromSeed$1(seed ^ 1540483477));
|
|
92
|
+
const twinRng = createTurnRng$1(turnRngStateFromSeed$1(seed ^ 668265263));
|
|
51
93
|
const marker = `MARKER-${String(seed)}-`;
|
|
94
|
+
const markerOf = (owner) => `${marker}${String(owner)}`;
|
|
52
95
|
let state = rules.setup({
|
|
53
96
|
seats,
|
|
54
97
|
options: options.setupOptions ?? null,
|
|
55
98
|
mode: options.mode ?? null,
|
|
56
99
|
rng
|
|
57
100
|
});
|
|
58
|
-
if (options.plant !== void 0) for (let seat = 0; seat < seats; seat++) state = options.plant(state, seat,
|
|
101
|
+
if (options.plant !== void 0) for (let seat = 0; seat < seats; seat++) state = options.plant(state, seat, markerOf(seat));
|
|
59
102
|
const maxMoves = options.maxMoves ?? 2e3;
|
|
60
103
|
const trace = [];
|
|
104
|
+
let outOfTurn = 0;
|
|
105
|
+
/** The twin table: each seat's view is the same after what it can't see is shuffled. */
|
|
106
|
+
const checkTwin = () => {
|
|
107
|
+
if (options.shuffleHidden === void 0) return;
|
|
108
|
+
for (let viewer = 0; viewer < seats; viewer++) {
|
|
109
|
+
const real = JSON.stringify(rules.view(state, viewer));
|
|
110
|
+
if (JSON.stringify(rules.view(roundTrip(options.shuffleHidden(roundTrip(state), viewer, twinRng)), viewer)) !== real) fail(`seat ${String(viewer)}'s view changed when what it can't see was shuffled: it shows something hidden (seed ${String(seed)})`);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
/** The seats whose planted marker is still secret (the rules haven't revealed it). */
|
|
114
|
+
const secret = new Set(options.plant === void 0 ? [] : Array.from({ length: seats }, (_, seat) => seat));
|
|
61
115
|
const checkViews = (events) => {
|
|
116
|
+
for (const owner of secret) if (options.revealed?.(state, markerOf(owner)) === true) secret.delete(owner);
|
|
62
117
|
for (let viewer = 0; viewer < seats; viewer++) {
|
|
63
118
|
const seen = JSON.stringify(rules.view(state, viewer)) + JSON.stringify(events.map((event) => rules.eventView?.(event, viewer) ?? null));
|
|
64
|
-
for (
|
|
119
|
+
for (const owner of secret) if (owner !== viewer && seen.includes(markerOf(owner))) fail(`seat ${String(viewer)} can see seat ${String(owner)}'s secret (seed ${String(seed)})`);
|
|
65
120
|
}
|
|
66
|
-
|
|
121
|
+
const shown = JSON.stringify(rules.view(state, null));
|
|
122
|
+
for (const owner of secret) if (shown.includes(markerOf(owner))) fail(`the public view shows seat ${String(owner)}'s secret (seed ${String(seed)})`);
|
|
123
|
+
checkTwin();
|
|
67
124
|
};
|
|
68
125
|
checkViews([]);
|
|
69
126
|
for (let moves = 0; moves < maxMoves; moves++) {
|
|
70
127
|
if (rules.outcome(state) !== null) return {
|
|
71
128
|
moves,
|
|
129
|
+
outOfTurn,
|
|
72
130
|
trace: trace.join("|")
|
|
73
131
|
};
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
132
|
+
const toAct = rules.toAct(state);
|
|
133
|
+
checkSeats(toAct, seats, "toAct", seed);
|
|
134
|
+
checkSeats(rules.mayAct?.(state) ?? [], seats, "mayAct", seed);
|
|
135
|
+
if (toAct.length === 0) fail(`toAct is empty but there is no outcome yet (seed ${String(seed)}, move ${String(moves)})`);
|
|
136
|
+
const next = nextMove(rules, options, state, toAct, rng, pick);
|
|
137
|
+
if (next === null) return fail(`no seat could move (seed ${String(seed)}, move ${String(moves)})`);
|
|
138
|
+
const { seat, move } = next;
|
|
139
|
+
outOfTurn += toAct.includes(seat) ? 0 : 1;
|
|
77
140
|
if (bytesOf(JSON.stringify(move)) > MOVE_MAX_BYTES) fail(`a move is over ${String(MOVE_MAX_BYTES)} bytes`);
|
|
78
141
|
const result = rules.apply(roundTrip(state), seat, roundTrip(move), rng);
|
|
79
|
-
if ("error" in result) fail(`${
|
|
142
|
+
if ("error" in result) fail(`${next.by} made an illegal move for seat ${String(seat)}: ${result.error} (seed ${String(seed)}, move ${String(moves)})`);
|
|
80
143
|
state = roundTrip(result.state);
|
|
81
144
|
if (bytesOf(JSON.stringify(state)) > STATE_MAX_BYTES) fail(`the state is over ${String(STATE_MAX_BYTES)} bytes`);
|
|
82
145
|
checkViews(result.events ?? []);
|
|
83
146
|
rules.progress(state, seat);
|
|
84
|
-
trace.push(JSON.stringify(move));
|
|
147
|
+
trace.push(`${String(seat)}:${JSON.stringify(move)}`);
|
|
85
148
|
}
|
|
86
149
|
return fail(`a game did not end within ${String(maxMoves)} moves (seed ${String(seed)})`);
|
|
87
150
|
}
|
|
@@ -91,6 +154,7 @@ function checkTurnRules(rules, options = {}) {
|
|
|
91
154
|
let games = 0;
|
|
92
155
|
let moves = 0;
|
|
93
156
|
let longestGame = 0;
|
|
157
|
+
let outOfTurn = 0;
|
|
94
158
|
for (const seats of options.seats ?? [2]) for (let game = 0; game < (options.games ?? 25); game++) {
|
|
95
159
|
const seed = seats * 100003 + game;
|
|
96
160
|
const played = playGame(rules, seats, seed, options);
|
|
@@ -98,6 +162,7 @@ function checkTurnRules(rules, options = {}) {
|
|
|
98
162
|
games += 1;
|
|
99
163
|
moves += played.moves;
|
|
100
164
|
longestGame = Math.max(longestGame, played.moves);
|
|
165
|
+
outOfTurn += played.outOfTurn;
|
|
101
166
|
}
|
|
102
167
|
for (const fixture of options.fixtures ?? []) if (fixture.version < rules.version) {
|
|
103
168
|
if (rules.migrate === void 0) fail(`a version ${String(fixture.version)} fixture needs rules.migrate`);
|
|
@@ -108,7 +173,8 @@ function checkTurnRules(rules, options = {}) {
|
|
|
108
173
|
return {
|
|
109
174
|
games,
|
|
110
175
|
moves,
|
|
111
|
-
longestGame
|
|
176
|
+
longestGame,
|
|
177
|
+
outOfTurn
|
|
112
178
|
};
|
|
113
179
|
}
|
|
114
180
|
//#endregion
|
package/dist/turns.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"turns.js","names":["createTurnRng","turnRngStateFromSeed"],"sources":["../src/turns-kit/check-rules.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"turns.js","names":["createTurnRng","turnRngStateFromSeed"],"sources":["../src/turns-kit/check-rules.ts"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diffenderfer-games/hub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.8",
|
|
4
4
|
"description": "The diffenderfer.games hub client for games: accounts, saves, stats, leaderboards, inventory, daily challenges, the input system and the social/multiplayer forwarders, typed from the hub contract.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -64,6 +64,11 @@
|
|
|
64
64
|
"types": "./dist/turns.d.ts",
|
|
65
65
|
"default": "./dist/turns.js"
|
|
66
66
|
},
|
|
67
|
+
"./turns-rules": {
|
|
68
|
+
"source": "./src/turns-rules.ts",
|
|
69
|
+
"types": "./dist/turns-rules.d.ts",
|
|
70
|
+
"default": "./dist/turns-rules.js"
|
|
71
|
+
},
|
|
67
72
|
"./types": {
|
|
68
73
|
"source": "./src/types.ts",
|
|
69
74
|
"types": "./dist/types.d.ts",
|
|
@@ -106,7 +111,7 @@
|
|
|
106
111
|
},
|
|
107
112
|
"dependencies": {
|
|
108
113
|
"@ayepi/core": "0.4.1",
|
|
109
|
-
"@diffenderfer-games/hub-contract": "2.0.0-next.
|
|
114
|
+
"@diffenderfer-games/hub-contract": "2.0.0-next.8"
|
|
110
115
|
},
|
|
111
116
|
"publishConfig": {
|
|
112
117
|
"access": "public"
|
|
@@ -17,7 +17,8 @@ import type { CreateTurnGame, OpenTurnGame, TurnDeadline } from '@diffenderfer-g
|
|
|
17
17
|
import { toStoredJson, type GamePayload } from '../game-data';
|
|
18
18
|
import { isBrowserPage } from '../page';
|
|
19
19
|
import type { SavedEntry } from '../saved/saved-types';
|
|
20
|
-
import { TurnGame, type TurnGameHost } from './turn-game';
|
|
20
|
+
import { TurnGame, type TurnGameHost, type TurnGameOptions } from './turn-game';
|
|
21
|
+
import { readYourTurnBuzz, saveYourTurnBuzz } from './your-turn-buzz';
|
|
21
22
|
|
|
22
23
|
/** The query parameter an invite link carries (`/<slug>/?hub_turn_link=<key>`); the client strips it. */
|
|
23
24
|
export const TURN_LINK_PARAM = 'hub_turn_link';
|
|
@@ -89,11 +90,31 @@ function isGameId(idOrKey: string): boolean {
|
|
|
89
90
|
/** `hub.turns`. */
|
|
90
91
|
export class HubTurns {
|
|
91
92
|
private readonly host: TurnsHost;
|
|
93
|
+
private isBuzzOn = true;
|
|
94
|
+
private readonly gameOptions: TurnGameOptions = { isBuzzOn: () => this.isBuzzOn };
|
|
92
95
|
|
|
93
96
|
constructor(host: TurnsHost) {
|
|
94
97
|
this.host = host;
|
|
95
98
|
}
|
|
96
99
|
|
|
100
|
+
/**
|
|
101
|
+
* This page's "your turn" buzz (a short vibration when the player becomes
|
|
102
|
+
* due in an open game, see `TurnGame.setYourTurnBuzz`): pass `false` if
|
|
103
|
+
* your game gives its own signal. On by default.
|
|
104
|
+
*/
|
|
105
|
+
setYourTurnBuzz(isOn: boolean): void {
|
|
106
|
+
this.isBuzzOn = isOn;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The player's own "Buzz when it's my turn" switch on this device (the menu's Settings show it too). */
|
|
110
|
+
get playerYourTurnBuzz(): boolean {
|
|
111
|
+
return readYourTurnBuzz();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
set playerYourTurnBuzz(isOn: boolean) {
|
|
115
|
+
saveYourTurnBuzz(isOn);
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
/** The invite link key this page was opened with (`?hub_turn_link=`), taken out of the address bar; `null` when none. Synchronous. */
|
|
98
119
|
get link(): string | null {
|
|
99
120
|
return linkOnPage();
|
|
@@ -114,18 +135,18 @@ export class HubTurns {
|
|
|
114
135
|
...(options.deadline === undefined ? {} : { deadline: options.deadline }),
|
|
115
136
|
...(options.autoStart === undefined ? {} : { autoStart: options.autoStart }),
|
|
116
137
|
};
|
|
117
|
-
return new TurnGame<View, Move, Event>(this.host, await this.host.create(body));
|
|
138
|
+
return new TurnGame<View, Move, Event>(this.host, await this.host.create(body), this.gameOptions);
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
/** Take a seat: by the game's id (an invite, an open seat) or by an invite link's key. */
|
|
121
142
|
async join<View = GamePayload, Move extends GamePayload = GamePayload, Event = GamePayload>(idOrLinkKey: string): Promise<TurnGame<View, Move, Event>> {
|
|
122
143
|
const joined = isGameId(idOrLinkKey) ? await this.host.join(idOrLinkKey) : await this.host.joinByLink(idOrLinkKey);
|
|
123
|
-
return new TurnGame<View, Move, Event>(this.host, joined);
|
|
144
|
+
return new TurnGame<View, Move, Event>(this.host, joined, this.gameOptions);
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
/** Open a game the player sits in (from `hub.saved.opened`, the Continue list, an invite). */
|
|
127
148
|
async open<View = GamePayload, Move extends GamePayload = GamePayload, Event = GamePayload>(id: string): Promise<TurnGame<View, Move, Event>> {
|
|
128
|
-
return new TurnGame<View, Move, Event>(this.host, await this.host.get(id));
|
|
149
|
+
return new TurnGame<View, Move, Event>(this.host, await this.host.get(id), this.gameOptions);
|
|
129
150
|
}
|
|
130
151
|
|
|
131
152
|
/** This game's online games the player sits in (the same rows as `hub.saved.list({ kinds: ['turns'] })`). */
|
|
@@ -9,13 +9,18 @@
|
|
|
9
9
|
* change, `on('ended')` at the end, `on('chat')` for chat lines. Moves made
|
|
10
10
|
* while the page was away are replayed from `history` when it comes back
|
|
11
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, a
|
|
13
|
+
* 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.
|
|
12
16
|
*/
|
|
13
|
-
import type { Json, TurnChatLine, TurnGameView, TurnSeat, TurnSummary } from '@diffenderfer-games/hub-contract';
|
|
17
|
+
import type { Json, TurnChatLine, TurnGameView, TurnSeat, TurnSummary, TurnTournament } from '@diffenderfer-games/hub-contract';
|
|
14
18
|
import { HubError } from '../errors';
|
|
15
19
|
import { toGameData, type GamePayload } from '../game-data';
|
|
16
20
|
import { isBrowserPage } from '../page';
|
|
17
21
|
import { isJson, parseJsonText } from '../storage/json-guards';
|
|
18
22
|
import { readField } from '../../realtime/cross-bundle';
|
|
23
|
+
import { buzzYourTurn, isNewlyDue } from './your-turn-buzz';
|
|
19
24
|
|
|
20
25
|
/** The window event the social runtime fires for each `ev:turn`. */
|
|
21
26
|
export const TURN_EVENT = 'hub:turn';
|
|
@@ -29,6 +34,12 @@ export const TURN_CHAT_EVENT = 'hub:turn-chat';
|
|
|
29
34
|
/** How often a page without the live runtime looks for moves, ms. */
|
|
30
35
|
export const TURN_POLL_MS = 20_000;
|
|
31
36
|
|
|
37
|
+
/** How long a visible page waiting on another player's move 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
|
+
|
|
32
43
|
/** A game's end: the winning seats, and optionally places and scores by seat. */
|
|
33
44
|
export type TurnOutcome = NonNullable<TurnGameView['outcome']>;
|
|
34
45
|
|
|
@@ -72,6 +83,12 @@ export interface TurnGameHost {
|
|
|
72
83
|
|
|
73
84
|
type Listener<Payload> = (payload: Payload) => void;
|
|
74
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
|
+
|
|
75
92
|
/** One online game. `View`, `Move` and `Ev` (its events) are the game's own JSON types from its rules. */
|
|
76
93
|
export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload, Ev = GamePayload> {
|
|
77
94
|
readonly id: string;
|
|
@@ -85,9 +102,15 @@ export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload
|
|
|
85
102
|
private catchingUp: Promise<void> | null = null;
|
|
86
103
|
/** The move count this page's own move in flight will make (its live echo is not another player's move). */
|
|
87
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;
|
|
88
110
|
|
|
89
|
-
constructor(host: TurnGameHost, initial: TurnGameView) {
|
|
111
|
+
constructor(host: TurnGameHost, initial: TurnGameView, options: TurnGameOptions = {}) {
|
|
90
112
|
this.host = host;
|
|
113
|
+
this.options = options;
|
|
91
114
|
this.id = initial.id;
|
|
92
115
|
this.state = initial;
|
|
93
116
|
host.opened(this);
|
|
@@ -129,25 +152,58 @@ export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload
|
|
|
129
152
|
return this.state.link === null ? null : new URL(this.state.link, location.origin).href;
|
|
130
153
|
}
|
|
131
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
|
+
|
|
132
177
|
/** Creator: start the game (empty seats go to the computer when the game allows). */
|
|
133
178
|
async start(): Promise<void> {
|
|
134
179
|
this.update(await this.host.start(this.id));
|
|
135
180
|
}
|
|
136
181
|
|
|
137
182
|
/**
|
|
138
|
-
* Make a move
|
|
139
|
-
*
|
|
140
|
-
*
|
|
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.
|
|
141
191
|
*/
|
|
142
192
|
async move(move: Move): Promise<{ seq: number; view: View; events: Ev[] }> {
|
|
143
193
|
this.ownSeq = this.state.summary.seq + 1;
|
|
144
194
|
try {
|
|
145
195
|
const answer = await this.host.move(this.id, this.state.summary.seq, toPayload(move));
|
|
146
|
-
|
|
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
|
+
}
|
|
147
200
|
return { seq: answer.game.summary.seq, view: this.view, events: answer.events.map((event) => toGameData<Ev>(event)) };
|
|
148
201
|
} catch (failure) {
|
|
149
202
|
if (failure instanceof HubError && failure.code === 'stale') {
|
|
150
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();
|
|
151
207
|
}
|
|
152
208
|
throw failure;
|
|
153
209
|
} finally {
|
|
@@ -179,7 +235,7 @@ export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload
|
|
|
179
235
|
|
|
180
236
|
/** A finished game: a new one with the same friends (they get an invite). */
|
|
181
237
|
async rematch(): Promise<TurnGame<View, Move, Ev>> {
|
|
182
|
-
return new TurnGame<View, Move, Ev>(this.host, await this.host.rematch(this.id));
|
|
238
|
+
return new TurnGame<View, Move, Ev>(this.host, await this.host.rematch(this.id), this.options);
|
|
183
239
|
}
|
|
184
240
|
|
|
185
241
|
/** Creator, before the start: add an invite link (`link: true`, then read `link`) or list the open seats for anyone (`public`). */
|
|
@@ -192,7 +248,40 @@ export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload
|
|
|
192
248
|
this.update(await this.host.invite(this.id, userIds));
|
|
193
249
|
}
|
|
194
250
|
|
|
195
|
-
/**
|
|
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
|
+
*/
|
|
196
285
|
async letComputerPlay(): Promise<void> {
|
|
197
286
|
this.update(await this.host.autoPlay(this.id));
|
|
198
287
|
}
|
|
@@ -202,18 +291,26 @@ export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload
|
|
|
202
291
|
return this.host.chat(this.id, send);
|
|
203
292
|
}
|
|
204
293
|
|
|
205
|
-
/**
|
|
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
|
+
*/
|
|
206
300
|
async refresh(): Promise<void> {
|
|
207
|
-
const before = this.state.summary.seq;
|
|
208
301
|
const fresh = await this.host.get(this.id);
|
|
209
|
-
|
|
210
|
-
|
|
302
|
+
const seen = this.state.summary.seq;
|
|
303
|
+
if (fresh.summary.seq <= seen) {
|
|
211
304
|
this.update(fresh);
|
|
212
|
-
|
|
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) {
|
|
213
312
|
this.emit('move', { seq: move.seq, seat: move.seat, view: this.view, events: move.events.map((event) => toGameData<Ev>(event)) });
|
|
214
313
|
}
|
|
215
|
-
} else {
|
|
216
|
-
this.update(fresh);
|
|
217
314
|
}
|
|
218
315
|
}
|
|
219
316
|
|
|
@@ -238,12 +335,16 @@ export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload
|
|
|
238
335
|
this.host.closed(this);
|
|
239
336
|
}
|
|
240
337
|
|
|
241
|
-
/** Take a fresh answer from the hub; fire `turn` and `ended` when they changed. */
|
|
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. */
|
|
242
339
|
private update(next: TurnGameView): void {
|
|
340
|
+
this.heardAt = Date.now();
|
|
243
341
|
const previous = this.state;
|
|
342
|
+
if (next.summary.seq < previous.summary.seq) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
244
345
|
this.state = next;
|
|
245
346
|
const isChanged = previous.summary.seq !== next.summary.seq || previous.summary.phase !== next.summary.phase || !sameSeats(previous.summary, next.summary)
|
|
246
|
-
|| previous.chatMode !== next.chatMode || previous.link !== next.link;
|
|
347
|
+
|| previous.summary.handOverAt !== next.summary.handOverAt || previous.chatMode !== next.chatMode || previous.link !== next.link;
|
|
247
348
|
if (isChanged) {
|
|
248
349
|
this.emit('turn', next.summary);
|
|
249
350
|
}
|
|
@@ -259,8 +360,15 @@ export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload
|
|
|
259
360
|
}
|
|
260
361
|
}
|
|
261
362
|
|
|
363
|
+
/** Whether it is another player's move now (the page is waiting on someone). */
|
|
364
|
+
private isWaitingOnOthers(): boolean {
|
|
365
|
+
const { summary } = this.state;
|
|
366
|
+
return summary.phase === 'playing' && !summary.isMyTurn;
|
|
367
|
+
}
|
|
368
|
+
|
|
262
369
|
/** A live `hub:turn` for this game: a move (or a missed run of them), or another change. */
|
|
263
370
|
private onLive(detail: object): void {
|
|
371
|
+
this.heardAt = Date.now();
|
|
264
372
|
const seq = readField(detail, 'seq');
|
|
265
373
|
const view = readField(detail, 'view');
|
|
266
374
|
const summary = readField(detail, 'summary');
|
|
@@ -271,6 +379,10 @@ export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload
|
|
|
271
379
|
void this.catchUp();
|
|
272
380
|
return;
|
|
273
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
|
+
}
|
|
274
386
|
const seat = readField(detail, 'seat');
|
|
275
387
|
const events = readField(detail, 'events');
|
|
276
388
|
const outcome = readField(detail, 'outcome');
|
|
@@ -279,7 +391,11 @@ export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload
|
|
|
279
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.
|
|
280
392
|
void this.catchUp();
|
|
281
393
|
}
|
|
394
|
+
const before = this.state.summary;
|
|
282
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
|
+
}
|
|
283
399
|
if (isMove) {
|
|
284
400
|
this.emit('move', { seq, seat, view: this.view, events: Array.isArray(events) ? events.map((event) => toGameData<Ev>(toPayloadJson(event))) : [] });
|
|
285
401
|
}
|
|
@@ -327,10 +443,16 @@ export class TurnGame<View = GamePayload, Move extends GamePayload = GamePayload
|
|
|
327
443
|
window.addEventListener(TURN_RESYNC_EVENT, onResync);
|
|
328
444
|
document.addEventListener('visibilitychange', onVisible);
|
|
329
445
|
const poll = setInterval(() => {
|
|
330
|
-
if (
|
|
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 else's move.
|
|
451
|
+
if (this.host.isLive() ? this.isWaitingOnOthers() && quietMs >= TURN_QUIET_MS : quietMs >= TURN_POLL_MS) {
|
|
452
|
+
this.heardAt = Date.now();
|
|
331
453
|
void this.catchUp();
|
|
332
454
|
}
|
|
333
|
-
},
|
|
455
|
+
}, TURN_CHECK_MS);
|
|
334
456
|
this.stopListening.push(
|
|
335
457
|
() => { window.removeEventListener(TURN_EVENT, onTurn); },
|
|
336
458
|
() => { window.removeEventListener(TURN_CHAT_EVENT, onChat); },
|
|
@@ -354,9 +476,9 @@ function sameSeating(a: TurnSummary, b: TurnSummary): boolean {
|
|
|
354
476
|
return eachSeat(a, b, (seat, other) => seat.status === other.status && seat.userId === other.userId);
|
|
355
477
|
}
|
|
356
478
|
|
|
357
|
-
/** The same seating, and
|
|
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). */
|
|
358
480
|
function sameSeats(a: TurnSummary, b: TurnSummary): boolean {
|
|
359
|
-
return sameSeating(a, b) && eachSeat(a, b, (seat, other) => seat.look === other.look);
|
|
481
|
+
return sameSeating(a, b) && eachSeat(a, b, (seat, other) => seat.look === other.look && seat.connected === other.connected);
|
|
360
482
|
}
|
|
361
483
|
|
|
362
484
|
/** A game's move as the JSON the hub takes. */
|
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by tools/hub-client/generate.ts. Do not edit; run `npm run gen:hub-client`.
|
|
2
2
|
/** This client's package version, sent as `x-hub-client` on REST calls and `client` in `hello`. */
|
|
3
|
-
export const HUB_CLIENT_VERSION = '2.0.0-next.
|
|
3
|
+
export const HUB_CLIENT_VERSION = '2.0.0-next.8';
|