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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +43 -4
  2. package/dist/auto.d.ts +3 -3
  3. package/dist/auto.js +2 -2
  4. package/dist/avatar-pixi.d.ts +19 -1
  5. package/dist/avatar-pixi.d.ts.map +1 -1
  6. package/dist/avatar-pixi.js +37 -18
  7. package/dist/avatar-pixi.js.map +1 -1
  8. package/dist/avatar-svg.d.ts +1 -1
  9. package/dist/avatar-svg.js +1 -1
  10. package/dist/avatar-three.d.ts +1 -1
  11. package/dist/avatar.d.ts +3 -3
  12. package/dist/avatar.js +20 -7
  13. package/dist/avatar.js.map +1 -1
  14. package/dist/doctor/cli.mjs +58 -4
  15. package/dist/doctor/cli.mjs.map +1 -1
  16. package/dist/index.d.ts +3 -3
  17. package/dist/index.js +2 -2
  18. package/dist/shared/animator.d.ts +1 -1
  19. package/dist/shared/art.js +1 -1
  20. package/dist/shared/atlas.d.ts +5 -1
  21. package/dist/shared/atlas.d.ts.map +1 -1
  22. package/dist/shared/avatar.d.ts +10 -4
  23. package/dist/shared/avatar.d.ts.map +1 -1
  24. package/dist/shared/bridge.js +2 -2
  25. package/dist/shared/bridge.js.map +1 -1
  26. package/dist/shared/client-version.gen.js +1 -1
  27. package/dist/shared/daily.js +1 -1
  28. package/dist/shared/describe.d.ts +1 -1
  29. package/dist/shared/describe.d.ts.map +1 -1
  30. package/dist/shared/describe.js +1 -1
  31. package/dist/shared/index.d.ts +88 -11
  32. package/dist/shared/index.d.ts.map +1 -1
  33. package/dist/shared/live-canvas.js +1 -1
  34. package/dist/shared/overlays.js +1 -1
  35. package/dist/shared/overlays.js.map +1 -1
  36. package/dist/shared/pose-key.js +2 -2
  37. package/dist/shared/pose-key.js.map +1 -1
  38. package/dist/shared/social.d.ts +19 -4
  39. package/dist/shared/social.d.ts.map +1 -1
  40. package/dist/shared/src.js +178 -28
  41. package/dist/shared/src.js.map +1 -1
  42. package/dist/shared/svg-markup.js +430 -21
  43. package/dist/shared/svg-markup.js.map +1 -1
  44. package/dist/shared/svg.js +2 -2
  45. package/dist/shared/svg.js.map +1 -1
  46. package/dist/shared/types.d.ts +9 -1
  47. package/dist/shared/types.d.ts.map +1 -1
  48. package/dist/social.d.ts +2 -2
  49. package/dist/turns-rules.d.ts +2 -0
  50. package/dist/turns-rules.js +1 -0
  51. package/dist/turns.d.ts +23 -1
  52. package/dist/turns.d.ts.map +1 -1
  53. package/dist/turns.js +79 -13
  54. package/dist/turns.js.map +1 -1
  55. package/package.json +8 -2
  56. package/src/avatar/adapters/pixi-crowd.ts +45 -18
  57. package/src/avatar/atlas.ts +16 -6
  58. package/src/avatar/layout.ts +1 -1
  59. package/src/avatar/outputs/canvas.ts +2 -2
  60. package/src/avatar/outputs/svg.ts +2 -2
  61. package/src/avatar/render/body.ts +2 -2
  62. package/src/avatar/render/compose.ts +25 -8
  63. package/src/avatar/render/frame.ts +15 -2
  64. package/src/avatar/render/types.ts +9 -0
  65. package/src/avatar/render/walker-parts.ts +121 -0
  66. package/src/avatar/render/walker.ts +199 -0
  67. package/src/avatar.ts +2 -1
  68. package/src/core/rest/routes.gen.ts +1 -0
  69. package/src/core/turns/hub-turns.ts +25 -4
  70. package/src/core/turns/turn-game.ts +143 -21
  71. package/src/core/turns/your-turn-buzz.ts +69 -0
  72. package/src/core/version/client-version.gen.ts +1 -1
  73. package/src/doctor/checks.ts +2 -2
  74. package/src/doctor/cli.ts +5 -2
  75. package/src/doctor/world-check.ts +61 -0
  76. package/src/index.ts +2 -1
  77. package/src/realtime/runtime-api.ts +5 -2
  78. package/src/realtime/types.ts +1 -0
  79. package/src/realtime/world-types.ts +20 -0
  80. package/src/turns-kit/check-rules.ts +116 -17
  81. package/src/turns-rules.ts +12 -0
  82. package/src/turns.ts +4 -3
@@ -0,0 +1,199 @@
1
+ /**
2
+ * The world walker (`figure: 'walker'`): a small full-body character for
3
+ * maps and crowds, with legs, arms, a shirt, trousers and shoes, and the
4
+ * head drawn smaller on top ({@link WALKER_HEAD_SCALE}). It takes the blob's
5
+ * pose names: `walk` and `run` swing the legs and arms along the facing,
6
+ * `sit` folds the legs over a seat, `idle` breathes, `wave` and `cheer` lift
7
+ * the arms.
8
+ *
9
+ * Standing, the origin is the feet. Sitting (`sit`, `play`), the origin is
10
+ * the seat: the hips rest on it and the feet hang {@link WALKER_SEAT_DROP}
11
+ * units below, on the floor in front of it.
12
+ */
13
+ import { ellipse, type SceneNode } from '../scene';
14
+ import type { BodyLayout } from './body';
15
+ import type { Colours } from './colours';
16
+ import type { BodyPose } from './types';
17
+ import { hand, hips, limb, neck, outfitFor, shirt, shoe, type Outfit, type Vec } from './walker-parts';
18
+
19
+ /** The head's scale on a walker (the blob draws it at 1). */
20
+ export const WALKER_HEAD_SCALE = 0.58;
21
+
22
+ /** How far below the seat a sitter's feet reach (rig units). */
23
+ export const WALKER_SEAT_DROP = 26;
24
+
25
+ /** Heights of the hips, shoulders and head centre above the origin. */
26
+ interface Heights {
27
+ readonly hip: number;
28
+ readonly shoulder: number;
29
+ readonly head: number;
30
+ }
31
+
32
+ const STANDING: Heights = { hip: -35, shoulder: -72, head: -103 };
33
+ const SEATED: Heights = { hip: -3, shoulder: -40, head: -71 };
34
+ const LEG = 32;
35
+ const UPPER_ARM = 13;
36
+ const FOREARM = 12;
37
+ const LEG_W = 10.5;
38
+ const SLEEVE_W = 10;
39
+ const ARM_W = 7.5;
40
+ const DEG = Math.PI / 180;
41
+
42
+ /** A walker's layout: the blob's fields plus where the head goes and its scale. */
43
+ export interface WalkerLayout extends BodyLayout {
44
+ readonly headY: number;
45
+ readonly headScale: number;
46
+ }
47
+
48
+ /** One side of the body: −1 is the character's right, +1 its left. */
49
+ type Side = -1 | 1;
50
+
51
+ /** The view of a facing: how far it is turned, which way is forward on screen. */
52
+ interface View {
53
+ /** sin of the turn: 0 front or back, ~1 side on. */
54
+ readonly side: number;
55
+ /** cos of the turn: 1 front, −1 back. */
56
+ readonly front: number;
57
+ /** Forward on the floor, in screen units per unit (y is foreshortened). */
58
+ readonly forward: Vec;
59
+ readonly isBack: boolean;
60
+ /** The torso's width factor. */
61
+ readonly width: number;
62
+ }
63
+
64
+ function viewOf(angleDeg: number): View {
65
+ const side = Math.sin(angleDeg * DEG);
66
+ const front = Math.cos(angleDeg * DEG);
67
+ return { side, front, forward: [side, front * 0.45], isBack: angleDeg > 90, width: 1 - 0.36 * Math.abs(side) };
68
+ }
69
+
70
+ /** A body side's screen x for a lateral distance (sides overlap when turned). */
71
+ const lateralX = (v: View, sideOf: Side, distance: number): number => sideOf * distance * v.front;
72
+
73
+ /** How near the viewer a side is (> 0: drawn in front when turned). */
74
+ const nearness = (v: View, sideOf: Side): number => -sideOf * v.side;
75
+
76
+ const add = (a: Vec, b: Vec, k = 1): Vec => [a[0] + b[0] * k, a[1] + b[1] * k];
77
+
78
+ /** What each limb does in a pose. */
79
+ interface Limbs {
80
+ /** Foot (standing) or knee-then-foot (sitting) per side. */
81
+ readonly leg: (sideOf: Side) => readonly [Vec, Vec, Vec];
82
+ readonly arm: (sideOf: Side) => readonly [Vec, Vec, Vec];
83
+ }
84
+
85
+ function standingLimbs(v: View, h: Heights, pose: BodyPose, phase: number, bob: number): Limbs {
86
+ const isRun = pose === 'run';
87
+ const isWalking = pose === 'walk' || isRun || pose === 'dance';
88
+ const stride = isRun ? 38 : 27;
89
+ const swingOf = (sideOf: Side): number => (isWalking ? Math.sin((phase + (sideOf === 1 ? 0 : 0.5)) * Math.PI * 2) : 0);
90
+ return {
91
+ leg: (sideOf) => {
92
+ const hip: Vec = [lateralX(v, sideOf, 6), h.hip + bob];
93
+ const swing = swingOf(sideOf);
94
+ const lift = isWalking ? Math.max(0, Math.cos((phase + (sideOf === 1 ? 0 : 0.5)) * Math.PI * 2)) * (isRun ? 7 : 4.5) : 0;
95
+ const reach = Math.sin(stride * DEG) * LEG * swing;
96
+ const foot: Vec = [hip[0] + v.forward[0] * reach, -bob * 0.2 + v.forward[1] * reach - lift];
97
+ const knee: Vec = [(hip[0] + foot[0]) / 2 + v.forward[0] * lift * 0.9, (hip[1] + foot[1]) / 2 + v.forward[1] * lift * 0.9];
98
+ return [hip, knee, foot];
99
+ },
100
+ arm: (sideOf) => {
101
+ const shoulder: Vec = [lateralX(v, sideOf, 15.5 * v.width) + v.forward[0] * 1.5, h.shoulder + 4 + bob];
102
+ const out = sideOf * v.front * 2.2;
103
+ if (pose === 'cheer' || (pose === 'wave' && sideOf === -1)) {
104
+ const wave = pose === 'wave' ? Math.sin(phase * Math.PI * 2) * 4 : 0;
105
+ const elbow: Vec = [shoulder[0] + out * 3, shoulder[1] - 10];
106
+ return [shoulder, elbow, [elbow[0] + out * 1.5 + wave, elbow[1] - 12]];
107
+ }
108
+ const swing = isWalking ? -swingOf(sideOf) * (isRun ? 1.3 : 1) : Math.sin(phase * Math.PI * 2) * 0.06;
109
+ const reach = Math.sin(24 * DEG) * swing;
110
+ const elbow: Vec = [shoulder[0] + out + v.forward[0] * UPPER_ARM * reach * 0.6, shoulder[1] + UPPER_ARM + v.forward[1] * UPPER_ARM * reach];
111
+ const wrist: Vec = [elbow[0] + out * 0.4 + v.forward[0] * FOREARM * reach * 1.6, elbow[1] + FOREARM - Math.abs(reach) * 3 + v.forward[1] * FOREARM * reach];
112
+ return [shoulder, elbow, wrist];
113
+ },
114
+ };
115
+ }
116
+
117
+ function seatedLimbs(v: View, h: Heights, pose: BodyPose): Limbs {
118
+ const thigh: Vec = [v.forward[0] * 15, v.forward[1] * 16];
119
+ return {
120
+ leg: (sideOf) => {
121
+ const hip: Vec = [lateralX(v, sideOf, 6), h.hip];
122
+ const knee = add(hip, thigh);
123
+ return [hip, knee, [knee[0] + v.forward[0] * 3, WALKER_SEAT_DROP - 2]];
124
+ },
125
+ arm: (sideOf) => {
126
+ const shoulder: Vec = [lateralX(v, sideOf, 15.5 * v.width) + v.forward[0] * 1.5, h.shoulder + 4];
127
+ const elbow: Vec = [shoulder[0] + sideOf * v.front * 2 + v.forward[0] * 3, shoulder[1] + UPPER_ARM];
128
+ const lap = add([lateralX(v, sideOf, pose === 'play' ? 4 : 7), h.hip - 4], thigh, pose === 'play' ? 0.9 : 0.6);
129
+ return [shoulder, elbow, lap];
130
+ },
131
+ };
132
+ }
133
+
134
+ /** The walker's drawing for a pose, at a facing angle (0 = S, 180 = N; west facings are mirrored by the caller). */
135
+ export function walkerLayout(c: Colours, pose: BodyPose, angleDeg: number, phase: number, outfit: Outfit = outfitFor(c)): WalkerLayout {
136
+ const v = viewOf(angleDeg);
137
+ const isSitting = pose === 'sit' || pose === 'play';
138
+ const h = isSitting ? SEATED : STANDING;
139
+ const cycle = Math.sin(phase * Math.PI * 2);
140
+ const isWalking = pose === 'walk' || pose === 'run' || pose === 'dance';
141
+ const hop = pose === 'jump' || pose === 'cheer' ? Math.abs(Math.sin(phase * Math.PI)) * (pose === 'jump' ? 16 : 8) : 0;
142
+ const bob = isWalking ? -2.2 * (1 - Math.abs(cycle)) : pose === 'idle' ? cycle * 0.6 : 0;
143
+ const limbs = isSitting ? seatedLimbs(v, h, pose) : standingLimbs(v, h, pose, phase, bob);
144
+ const o = outfit;
145
+ const [farSide, nearSide]: readonly [Side, Side] = nearness(v, 1) > nearness(v, -1) ? [-1, 1] : [1, -1];
146
+ const isTurned = Math.abs(v.side) > 0.3;
147
+ const lean = v.side * (isSitting ? 0.6 : 1);
148
+ const armNodes = (sideOf: Side): SceneNode[] => {
149
+ const [shoulder, elbow, wrist] = limbs.arm(sideOf);
150
+ return [...limb([shoulder, elbow, wrist], ARM_W, c.skin, c.skinInk), ...limb([shoulder, add(shoulder, [elbow[0] - shoulder[0], elbow[1] - shoulder[1]], 0.62)], SLEEVE_W, c.body, c.bodyInk), hand(c, wrist)];
151
+ };
152
+ const legNodes = (sideOf: Side): SceneNode[] => {
153
+ const points = limbs.leg(sideOf);
154
+ const foot = points[2];
155
+ return [...limb(points, LEG_W, o.trousers, o.trousersInk), shoe(foot, v.forward, o)];
156
+ };
157
+ const torso = shirt(c, v.width, h.shoulder + (isWalking ? bob : 0), h.hip + 3 + (isWalking ? bob : 0), lean, v.isBack);
158
+ // Seated with our back to us, the arms reach forward and the legs are under the seat: both hide behind the back.
159
+ const isTucked = isSitting && v.isBack;
160
+ const behind: SceneNode[] = isTucked ? [...armNodes(farSide), ...armNodes(nearSide)] : isTurned ? armNodes(farSide) : [];
161
+ // Sitting facing us, the thighs come toward the viewer over the shirt's hem.
162
+ const isLegsFirst = v.isBack || !isSitting;
163
+ const legs = isTucked ? [] : [...legNodes(farSide), ...legNodes(nearSide)];
164
+ const body: SceneNode[] = [
165
+ ...(isLegsFirst ? legs : []),
166
+ hips(o, v.width, h.hip + (isWalking ? bob : 0)),
167
+ neck(c, h.shoulder + bob, v.forward[0] * 2),
168
+ ...torso,
169
+ ...(isLegsFirst ? [] : legs),
170
+ ...(isTucked ? [] : isTurned ? armNodes(nearSide) : [...armNodes(-1), ...armNodes(1)]),
171
+ ];
172
+ const prop = add([0, h.hip - 12], v.forward, 8);
173
+ return {
174
+ behind, body, front: [],
175
+ headDx: v.forward[0] * 1.5, headDy: 0, headY: h.head + bob * 1.1, headScale: WALKER_HEAD_SCALE,
176
+ tilt: isWalking ? v.side * 2 : 0, lift: hop, prop: { x: prop[0], y: prop[1] },
177
+ };
178
+ }
179
+
180
+ const GREY = '#6f7787';
181
+ const GREY_INK = '#5c6474';
182
+
183
+ /** Every colour slot in the silhouette grey. */
184
+ const GREY_COLOURS: Colours = {
185
+ skin: GREY, skinShade: GREY, skinLight: GREY, skinInk: GREY_INK, eyeRim: GREY, hair: GREY, hairShade: GREY, hairLight: GREY,
186
+ hairInk: GREY_INK, hairBuzz: GREY, brow: GREY, beard: GREY, beardShade: GREY, beardInk: GREY_INK, stubble: GREY, eye: GREY,
187
+ eyeDark: GREY, body: GREY, bodyShade: GREY, bodyLight: GREY, bodyInk: GREY_INK,
188
+ };
189
+
190
+ /** The grey walker shown before the art loads (or for a hidden player): its nodes, head centre and top. */
191
+ export function walkerSilhouette(): { readonly nodes: readonly SceneNode[]; readonly headY: number; readonly top: number } {
192
+ const b = walkerLayout(GREY_COLOURS, 'idle', 0, 0, { trousers: GREY, trousersInk: GREY_INK, shoes: GREY, shoesInk: GREY_INK });
193
+ const r = 46 * WALKER_HEAD_SCALE;
194
+ return {
195
+ nodes: [...b.behind, ...b.body, ellipse(0, b.headY, r, r * 1.05, { fill: GREY })],
196
+ headY: b.headY,
197
+ top: b.headY - r - 4,
198
+ };
199
+ }
package/src/avatar.ts CHANGED
@@ -43,12 +43,13 @@ export { overlays, type ActivityIcon, type Overlay, type ReactionGlyph } from '.
43
43
  export { overlaySvg, paintOverlay } from './avatar/outputs/overlay-svg';
44
44
  export { catalog } from './avatar/catalog-view';
45
45
  export { composeAvatar, HEAD_Y, type Composed } from './avatar/render/compose';
46
+ export { WALKER_HEAD_SCALE, WALKER_SEAT_DROP } from './avatar/render/walker';
46
47
  export { paintNodes, type Ctx2D } from './avatar/outputs/canvas-paint';
47
48
  export { sceneToSvg } from './avatar/outputs/svg-markup';
48
49
  export { toCode, toLook } from './avatar/look-like';
49
50
  export { contrast, deltaE, lightness } from './avatar/colour';
50
51
  export type { SceneNode } from './avatar/scene';
51
- export type { Anchor, BodyPose, Facing, Frame, LookDir, LookLike, Point, Pose, RenderOptions, StatusProp, View } from './avatar/render/types';
52
+ export type { Anchor, BodyPose, Facing, Figure, Frame, LookDir, LookLike, Point, Pose, RenderOptions, StatusProp, View } from './avatar/render/types';
52
53
  export { AvatarAtlas, createAtlas, type AtlasFrame, type AtlasOptions } from './avatar/atlas';
53
54
 
54
55
  /** Every output: SVG markup, a live element, Canvas 2D, a bitmap or a data URL. */
@@ -139,6 +139,7 @@ export const ROUTES = {
139
139
  getTurnGameHistory: { method: 'GET', path: '/turns/:id/history', query: ['since'], offline: null },
140
140
  listOpenTurnGames: { method: 'GET', path: '/open-turns', query: ['game'], offline: null },
141
141
  listMyTurnGames: { method: 'GET', path: '/turns', query: ['game'], offline: null },
142
+ getWorldMap: { method: 'GET', path: '/world/maps/:key', query: ['rev'], offline: null },
142
143
  } as const;
143
144
 
144
145
  /** The request header that carries an offline operation key (the contract's `OFFLINE_OP_HEADER`). */
@@ -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. Rejects with a `HubError`: 403 `not_your_turn`, 409
139
- * `stale` (the game moved on: the view is refreshed first), 422 `illegal`
140
- * with the rules' own code in `rule`.
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
- this.update(answer.game);
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
- /** The turn's deadline passed (or its player is suspended): let the computer play it. */
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
- /** Fetch the game again, and replay moves missed since the last one this page saw. */
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
- if (fresh.summary.seq > before) {
210
- const missed = await this.host.history(this.id, before);
302
+ const seen = this.state.summary.seq;
303
+ if (fresh.summary.seq <= seen) {
211
304
  this.update(fresh);
212
- for (const move of missed) {
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 (!this.host.isLive() && document.visibilityState === 'visible') {
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
- }, TURN_POLL_MS);
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 the same looks (a player's new avatar is a change: `turn` fires). */
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.7';
3
+ export const HUB_CLIENT_VERSION = '2.0.0-next.9';