@diffenderfer-games/hub 2.0.0-next.1 → 2.0.0-next.3
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 +40 -6
- package/README.md +25 -10
- package/dist/auto.d.ts +7 -7
- package/dist/auto.js +3 -3
- package/dist/doctor/cli.mjs +14 -7
- package/dist/doctor/cli.mjs.map +1 -1
- package/dist/index.d.ts +7 -7
- 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/shared/bridge.js +9 -3
- package/dist/shared/bridge.js.map +1 -1
- package/dist/shared/client-version.gen.js +1 -1
- package/dist/shared/daily.d.ts +10 -4
- package/dist/shared/daily.d.ts.map +1 -1
- package/dist/shared/daily.js +12 -2
- package/dist/shared/daily.js.map +1 -1
- package/dist/shared/index.d.ts +315 -36
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/input.d.ts +47 -5
- package/dist/shared/input.d.ts.map +1 -1
- package/dist/shared/input.js +139 -23
- package/dist/shared/input.js.map +1 -1
- package/dist/shared/latest-state.js +1 -1
- 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/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 +10 -2
- package/dist/shared/rooms.js.map +1 -1
- package/dist/shared/runtime-opener.js +1 -1
- package/dist/shared/social.d.ts +23 -8
- 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 +1588 -388
- package/dist/shared/src.js.map +1 -1
- 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 +1 -1
- package/package.json +2 -2
- package/src/core/connection.ts +187 -92
- package/src/core/game-data.ts +11 -9
- package/src/core/hub-economy.ts +47 -10
- package/src/core/hub-progress.ts +13 -16
- package/src/core/hub.ts +4 -2
- 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 +35 -0
- package/src/core/offline/hub-kinds/legacy-ops.ts +37 -0
- package/src/core/offline/hub-kinds/progress-kinds.ts +148 -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 +20 -22
- 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 +4 -49
- package/src/core/offline/replay-lease.ts +87 -0
- package/src/core/offline/replay-policy.ts +52 -0
- package/src/core/rest/rest-client.ts +14 -3
- package/src/core/rest/routes.gen.ts +108 -87
- package/src/core/storage/json-guards.ts +6 -0
- package/src/core/version/client-version.gen.ts +1 -1
- package/src/daily/daily-system.ts +8 -1
- package/src/daily/types.ts +3 -2
- package/src/doctor/checks.ts +10 -2
- package/src/index.ts +6 -1
- package/src/input/focus-ring.ts +13 -0
- package/src/input/input-registry.ts +9 -2
- package/src/input/input-system.ts +4 -0
- package/src/input/modality.ts +122 -0
- package/src/input/nav-state.ts +13 -4
- package/src/input/zero-config.ts +1 -14
- package/src/input.ts +3 -0
- package/src/mp.ts +1 -1
- package/src/overlay/overlay.ts +5 -3
- package/src/overlay/styles.ts +4 -3
- package/src/realtime/bridge.ts +5 -0
- package/src/realtime/multiplayer-forwarder.ts +13 -1
- package/src/realtime/multiplayer-types.ts +15 -1
- package/src/realtime/room-types.ts +28 -2
- package/src/realtime/rooms-forwarder.ts +13 -1
- package/src/realtime/runtime-api.ts +19 -8
- package/src/realtime/social-forwarder.ts +5 -0
- package/src/realtime/social-types.ts +8 -0
- package/src/realtime/wire-types.ts +7 -0
- package/src/rooms.ts +1 -1
|
@@ -13,6 +13,8 @@ import { RepeatingEdges, RisingEdges } from './edges';
|
|
|
13
13
|
import { isAnyPadButtonPressed, PAD_BUTTON } from './gamepad';
|
|
14
14
|
import { InputEventHub } from './input-events';
|
|
15
15
|
import { InputRegistry } from './input-registry';
|
|
16
|
+
import { trackInputModality } from './modality';
|
|
17
|
+
import type { ModalityTracker } from './modality';
|
|
16
18
|
import { parseLegacyControls } from './legacy-controls';
|
|
17
19
|
import type { LegacyControls } from './legacy-controls';
|
|
18
20
|
import { Navigator } from './navigator';
|
|
@@ -56,6 +58,7 @@ export class InputSystem implements ControlsPanelHost, PageInputControl {
|
|
|
56
58
|
private activeSource: InputSource = 'keyboard';
|
|
57
59
|
private isPageOwner: boolean;
|
|
58
60
|
private pageOwner: PageInputControl | null = null;
|
|
61
|
+
private readonly modality: ModalityTracker | null = typeof document === 'undefined' ? null : trackInputModality();
|
|
59
62
|
|
|
60
63
|
constructor(host: InputHost, options: InputSystemOptions = {}) {
|
|
61
64
|
this.host = host;
|
|
@@ -352,6 +355,7 @@ export class InputSystem implements ControlsPanelHost, PageInputControl {
|
|
|
352
355
|
this.controls.isGamepadConnected = isConnected;
|
|
353
356
|
if (hasActivity) {
|
|
354
357
|
this.setSource('gamepad');
|
|
358
|
+
this.modality?.set('keys');
|
|
355
359
|
}
|
|
356
360
|
if (this.isPageOwner) {
|
|
357
361
|
this.driveFrame();
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which kind of input the player used last, published as `data-hub-input` on
|
|
3
|
+
* `<html>` so focus styles show only to keyboard and gamepad players: a tap or
|
|
4
|
+
* click never leaves a ring behind, and touch devices show none at all. The
|
|
5
|
+
* attribute is the shared state, so every bundle's copy of the client agrees.
|
|
6
|
+
*/
|
|
7
|
+
import { isTypingTarget } from './device-state';
|
|
8
|
+
import { FOCUS_RING_SHADOW } from './focus-ring';
|
|
9
|
+
|
|
10
|
+
/** `keys`: keyboard or gamepad. `pointer`: mouse or pen. `touch`: a finger. */
|
|
11
|
+
export type InputModality = 'keys' | 'pointer' | 'touch';
|
|
12
|
+
|
|
13
|
+
/** The `<html>` attribute that holds the modality. */
|
|
14
|
+
export const MODALITY_ATTRIBUTE = 'data-hub-input';
|
|
15
|
+
|
|
16
|
+
const STYLE_ID = 'hub-focus-style';
|
|
17
|
+
const MODIFIER_KEYS = new Set(['Shift', 'Control', 'Alt', 'Meta', 'AltGraph', 'CapsLock']);
|
|
18
|
+
const MODALITIES: readonly InputModality[] = ['keys', 'pointer', 'touch'];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The page focus styles: the gamepad highlight (`gp-focus`) for keyboard and
|
|
22
|
+
* gamepad players only, and no browser focus ring on buttons after a tap.
|
|
23
|
+
*/
|
|
24
|
+
const PAGE_FOCUS_CSS = `html[${MODALITY_ATTRIBUTE}="keys"] .gp-focus{outline:none !important;box-shadow:${FOCUS_RING_SHADOW} !important;}
|
|
25
|
+
html[${MODALITY_ATTRIBUTE}="touch"] :is(button,a,summary,[role="button"],[tabindex]):focus{outline:none;}`;
|
|
26
|
+
|
|
27
|
+
/** The modality a pointer event's `pointerType` implies. */
|
|
28
|
+
export function modalityOfPointer(pointerType: string): InputModality {
|
|
29
|
+
return pointerType === 'touch' ? 'touch' : 'pointer';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The modality a key press implies, or `null` when it says nothing (a lone modifier, typing in a field). */
|
|
33
|
+
export function modalityOfKey(key: string, isTyping: boolean): InputModality | null {
|
|
34
|
+
if (MODIFIER_KEYS.has(key)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
// Typing (an on-screen keyboard fires keydown too) is not navigating; Tab always is.
|
|
38
|
+
return isTyping && key !== 'Tab' ? null : 'keys';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where the modality is stored: `<html>`, or a fake in tests. */
|
|
42
|
+
export interface ModalityRoot {
|
|
43
|
+
getAttribute(name: string): string | null;
|
|
44
|
+
setAttribute(name: string, value: string): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Reads and writes the page's modality attribute. */
|
|
48
|
+
export class ModalityTracker {
|
|
49
|
+
private readonly root: ModalityRoot;
|
|
50
|
+
|
|
51
|
+
/** Start at `initial` unless another copy already set the attribute. */
|
|
52
|
+
constructor(root: ModalityRoot, initial: InputModality) {
|
|
53
|
+
this.root = root;
|
|
54
|
+
if (this.readAttribute() === null) {
|
|
55
|
+
this.set(initial);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The last modality used. */
|
|
60
|
+
get current(): InputModality {
|
|
61
|
+
return this.readAttribute() ?? 'pointer';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether focus should show: the last input was a keyboard or gamepad. */
|
|
65
|
+
get showsFocus(): boolean {
|
|
66
|
+
return this.current === 'keys';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Record the modality the player just used. */
|
|
70
|
+
set(modality: InputModality): void {
|
|
71
|
+
if (this.readAttribute() !== modality) {
|
|
72
|
+
this.root.setAttribute(MODALITY_ATTRIBUTE, modality);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A key went down (`isTyping`: in a text field). */
|
|
77
|
+
noteKey(key: string, isTyping: boolean): void {
|
|
78
|
+
const modality = modalityOfKey(key, isTyping);
|
|
79
|
+
if (modality !== null) {
|
|
80
|
+
this.set(modality);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A pointer went down. */
|
|
85
|
+
notePointer(pointerType: string): void {
|
|
86
|
+
this.set(modalityOfPointer(pointerType));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private readAttribute(): InputModality | null {
|
|
90
|
+
const value = this.root.getAttribute(MODALITY_ATTRIBUTE);
|
|
91
|
+
return MODALITIES.find((modality) => modality === value) ?? null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let pageTracker: ModalityTracker | null = null;
|
|
96
|
+
|
|
97
|
+
/** Track the page's modality (once per bundle) and add the focus styles (once per page). */
|
|
98
|
+
export function trackInputModality(): ModalityTracker {
|
|
99
|
+
if (pageTracker !== null) {
|
|
100
|
+
return pageTracker;
|
|
101
|
+
}
|
|
102
|
+
const root = document.documentElement;
|
|
103
|
+
const isTouchDevice = typeof matchMedia === 'function' && matchMedia('(hover: none) and (pointer: coarse)').matches;
|
|
104
|
+
const isFirstCopy = root.getAttribute(MODALITY_ATTRIBUTE) === null;
|
|
105
|
+
const tracker = new ModalityTracker(root, isTouchDevice ? 'touch' : 'pointer');
|
|
106
|
+
pageTracker = tracker;
|
|
107
|
+
if (isFirstCopy) {
|
|
108
|
+
document.addEventListener('keydown', (event) => {
|
|
109
|
+
tracker.noteKey(event.key, isTypingTarget(event.target));
|
|
110
|
+
}, true);
|
|
111
|
+
document.addEventListener('pointerdown', (event) => {
|
|
112
|
+
tracker.notePointer(event.pointerType);
|
|
113
|
+
}, true);
|
|
114
|
+
}
|
|
115
|
+
if (document.getElementById(STYLE_ID) === null) {
|
|
116
|
+
const style = document.createElement('style');
|
|
117
|
+
style.id = STYLE_ID;
|
|
118
|
+
style.textContent = PAGE_FOCUS_CSS;
|
|
119
|
+
document.head.appendChild(style);
|
|
120
|
+
}
|
|
121
|
+
return tracker;
|
|
122
|
+
}
|
package/src/input/nav-state.ts
CHANGED
|
@@ -9,8 +9,8 @@ import type { NavBlurEvent, NavElement, NavElementKind, NavFocusEvent, NavTarget
|
|
|
9
9
|
|
|
10
10
|
/** What a navigable list draws with (the overlay). */
|
|
11
11
|
export interface NavHighlighter {
|
|
12
|
-
/** Ring the highlighted element. */
|
|
13
|
-
showHighlight(bounds: Rect): void;
|
|
12
|
+
/** Ring the highlighted element, following its corner radius (a CSS `border-radius`, or empty). */
|
|
13
|
+
showHighlight(bounds: Rect, radius: string): void;
|
|
14
14
|
/** Remove the ring. */
|
|
15
15
|
hideHighlight(): void;
|
|
16
16
|
/** Open the on-screen keyboard for a text field. */
|
|
@@ -50,6 +50,11 @@ function boundsOf(element: NavElement): Rect {
|
|
|
50
50
|
return element.el === undefined ? { x: 0, y: 0, width: 0, height: 0 } : rectOf(element.el);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** A navigable element's CSS corner radius, so the ring can follow it (empty for a non-DOM element). */
|
|
54
|
+
function radiusOf(element: NavElement): string {
|
|
55
|
+
return element.el === undefined ? '' : getComputedStyle(element.el).borderRadius;
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
/** The text field a navigable element edits, if it is one. */
|
|
54
59
|
function textFieldOf(element: NavElement): HTMLInputElement | HTMLTextAreaElement | null {
|
|
55
60
|
return element.el instanceof HTMLInputElement || element.el instanceof HTMLTextAreaElement ? element.el : null;
|
|
@@ -148,16 +153,20 @@ export class NavState {
|
|
|
148
153
|
this.position(highlighter);
|
|
149
154
|
}
|
|
150
155
|
|
|
151
|
-
/** Move the ring to the highlighted element (or hide it). */
|
|
156
|
+
/** Move the ring to the highlighted element (or hide it), following keyboard focus (Tab) within the list. */
|
|
152
157
|
position(highlighter: NavHighlighter | null): void {
|
|
153
158
|
if (highlighter === null) {
|
|
154
159
|
return;
|
|
155
160
|
}
|
|
161
|
+
const focused = this.elements.findIndex((element) => element.el !== undefined && element.el === document.activeElement);
|
|
162
|
+
if (focused >= 0) {
|
|
163
|
+
this.index = focused;
|
|
164
|
+
}
|
|
156
165
|
const current = this.elements[this.index];
|
|
157
166
|
if (current === undefined) {
|
|
158
167
|
highlighter.hideHighlight();
|
|
159
168
|
} else {
|
|
160
|
-
highlighter.showHighlight(boundsOf(current));
|
|
169
|
+
highlighter.showHighlight(boundsOf(current), radiusOf(current));
|
|
161
170
|
}
|
|
162
171
|
}
|
|
163
172
|
|
package/src/input/zero-config.ts
CHANGED
|
@@ -14,9 +14,8 @@ import { isTypingTarget } from './device-state';
|
|
|
14
14
|
|
|
15
15
|
/** Synthetic keys stay pressed past half deflection. */
|
|
16
16
|
const SYNTH_DEADZONE = 0.5;
|
|
17
|
+
/** The catalog tile highlight; its style (keyboard and gamepad players only) is in `modality.ts`. */
|
|
17
18
|
const FOCUS_CLASS = 'gp-focus';
|
|
18
|
-
const FOCUS_STYLE_ID = 'gp-focus-style';
|
|
19
|
-
const FOCUS_CSS = '.gp-focus{outline:3px solid #ff5f3b !important;outline-offset:2px;border-radius:4px;}';
|
|
20
19
|
|
|
21
20
|
/** `KeyboardEvent.code` → its `key` and legacy `keyCode`. */
|
|
22
21
|
const KEY_FOR_CODE = new Map<string, readonly [string, number]>([
|
|
@@ -51,17 +50,6 @@ function dispatchKey(type: 'keydown' | 'keyup', code: string): void {
|
|
|
51
50
|
target.dispatchEvent(event);
|
|
52
51
|
}
|
|
53
52
|
|
|
54
|
-
/** Add the highlight style to the page once. */
|
|
55
|
-
function ensureFocusStyle(): void {
|
|
56
|
-
if (document.getElementById(FOCUS_STYLE_ID) !== null) {
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
const style = document.createElement('style');
|
|
60
|
-
style.id = FOCUS_STYLE_ID;
|
|
61
|
-
style.textContent = FOCUS_CSS;
|
|
62
|
-
document.head.appendChild(style);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
53
|
/** The keys each pad holds down, by pad index. */
|
|
66
54
|
const heldCodes = new Map<number, Set<string>>();
|
|
67
55
|
|
|
@@ -168,7 +156,6 @@ export function runZeroConfigFrame(frame: ZeroConfigFrame): void {
|
|
|
168
156
|
if (frame.gamepads.length === 0) {
|
|
169
157
|
return;
|
|
170
158
|
}
|
|
171
|
-
ensureFocusStyle();
|
|
172
159
|
if (frame.slug === null) {
|
|
173
160
|
navigateCatalog(frame);
|
|
174
161
|
} else {
|
package/src/input.ts
CHANGED
|
@@ -9,6 +9,9 @@ export { InputSystem } from './input/input-system';
|
|
|
9
9
|
export type { InputSystemOptions } from './input/input-system';
|
|
10
10
|
export { getInput, INPUT_SINGLETON_API, isInputSystem, parseInputOverrides } from './input/page-input';
|
|
11
11
|
export type { InputOptions } from './input/page-input';
|
|
12
|
+
export { FOCUS_RING_SHADOW } from './input/focus-ring';
|
|
13
|
+
export { MODALITY_ATTRIBUTE, trackInputModality } from './input/modality';
|
|
14
|
+
export type { InputModality } from './input/modality';
|
|
12
15
|
export { isPageInputControl } from './input/page-control';
|
|
13
16
|
export type { PageInputControl } from './input/page-control';
|
|
14
17
|
export type * from './input/types';
|
package/src/mp.ts
CHANGED
|
@@ -6,5 +6,5 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { multiplayerForwarder as mp } from './realtime/multiplayer-forwarder';
|
|
8
8
|
export type * from './realtime/multiplayer-types';
|
|
9
|
-
export type { InviteCreated } from './realtime/wire-types';
|
|
9
|
+
export type { InviteCreated, InviteLink } from './realtime/wire-types';
|
|
10
10
|
export type { Invite, JoinInfo, Launch, LaunchTicket, Party } from '@diffenderfer-games/hub-contract';
|
package/src/overlay/overlay.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { NavHighlighter } from '../input/nav-state';
|
|
8
8
|
import type { InputConfig, InputOverrides, Rect, VirtualDef } from '../input/types';
|
|
9
|
+
import { trackInputModality } from '../input/modality';
|
|
9
10
|
import { readField } from '../realtime/cross-bundle';
|
|
10
11
|
import { ControlsPanel } from './controls-panel';
|
|
11
12
|
import type { ControlsPanelHost } from './controls-panel';
|
|
@@ -160,9 +161,10 @@ export class Overlay implements NavHighlighter {
|
|
|
160
161
|
}
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
/** Ring the highlighted element. */
|
|
164
|
-
showHighlight(bounds: Rect): void {
|
|
165
|
-
this.ring.hidden =
|
|
164
|
+
/** Ring the highlighted element, for keyboard and gamepad players only (never after a tap or click). */
|
|
165
|
+
showHighlight(bounds: Rect, radius: string): void {
|
|
166
|
+
this.ring.hidden = !trackInputModality().showsFocus;
|
|
167
|
+
this.ring.style.borderRadius = radius;
|
|
166
168
|
this.ring.style.left = `${String(bounds.x)}px`;
|
|
167
169
|
this.ring.style.top = `${String(bounds.y)}px`;
|
|
168
170
|
this.ring.style.width = `${String(bounds.width)}px`;
|
package/src/overlay/styles.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The overlay's styles (inside its shadow root, so games can't restyle it).
|
|
3
3
|
*/
|
|
4
|
+
import { FOCUS_RING_SHADOW } from '../input/focus-ring';
|
|
4
5
|
|
|
5
6
|
/** The hub accent colour. */
|
|
6
7
|
export const ACCENT_COLOR = '#ff5f3b';
|
|
@@ -28,8 +29,8 @@ export const OVERLAY_CSS = `
|
|
|
28
29
|
:host(.editing) .edit-bar .btn { margin: 0; }
|
|
29
30
|
.safe-probe { position: fixed; left: 0; top: 0; width: 0; height: 0; visibility: hidden; pointer-events: none;
|
|
30
31
|
padding: env(safe-area-inset-top, 0px) env(safe-area-inset-right, 0px) env(safe-area-inset-bottom, 0px) env(safe-area-inset-left, 0px); }
|
|
31
|
-
.ring { position: fixed; pointer-events: none; z-index: 2147483200;
|
|
32
|
-
|
|
32
|
+
.ring { position: fixed; pointer-events: none; z-index: 2147483200; box-shadow: ${FOCUS_RING_SHADOW};
|
|
33
|
+
transition: left .08s, top .08s, width .08s, height .08s; }
|
|
33
34
|
.ring[hidden] { display: none; }
|
|
34
35
|
.kb { position: fixed; left: 0; right: 0; bottom: 0; z-index: 2147483400; background: #0d0f14ee; padding: 10px;
|
|
35
36
|
pointer-events: auto; border-top: 1px solid #2a3142; }
|
|
@@ -38,7 +39,7 @@ export const OVERLAY_CSS = `
|
|
|
38
39
|
.kb .k { flex: 1; min-width: 0; max-width: 64px; height: 46px; border-radius: 8px; border: 1px solid #2a3142;
|
|
39
40
|
background: #11141c; color: #e8e6df; font: 16px monospace; cursor: pointer; }
|
|
40
41
|
.kb .k.space { max-width: 280px; } .kb .k.wide { max-width: 90px; }
|
|
41
|
-
.kb .k.kfocus {
|
|
42
|
+
.kb .k.kfocus { box-shadow: ${FOCUS_RING_SHADOW}; }
|
|
42
43
|
.panel { position: fixed; inset: 0; z-index: 2147483500; display: grid; place-items: center; background: rgba(0,0,0,.6); pointer-events: auto;
|
|
43
44
|
font-family: 'Space Mono', ui-monospace, monospace; }
|
|
44
45
|
.panel-card { background: #0d0f14; color: #e8e6df; border: 1px solid #2a3142; border-radius: 12px; padding: 18px;
|
package/src/realtime/bridge.ts
CHANGED
|
@@ -117,5 +117,10 @@ export async function rethrowAsHubError<Result>(pending: Promise<Result>): Promi
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** The rejection for a feature the page's social runtime is too old to have (version 3 additions). */
|
|
121
|
+
export function outdatedRuntime(feature: string): Error {
|
|
122
|
+
return hubLink().createOfflineError(`${feature} needs the newest hub. Reload the page to update.`);
|
|
123
|
+
}
|
|
124
|
+
|
|
120
125
|
/** The page's one bridge (per client bundle). */
|
|
121
126
|
export const pageBridge = new RealtimeBridge();
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* forwarded to the social runtime.
|
|
4
4
|
*/
|
|
5
5
|
import { getUiStack } from '../uistack/page-stack';
|
|
6
|
-
import { pageBridge } from './bridge';
|
|
6
|
+
import { outdatedRuntime, pageBridge } from './bridge';
|
|
7
7
|
import { hubLink } from './hub-link';
|
|
8
8
|
import { latestState } from './latest-state';
|
|
9
9
|
import type { HubMpApi } from './multiplayer-types';
|
|
@@ -25,4 +25,16 @@ export const multiplayerForwarder: HubMpApi = {
|
|
|
25
25
|
ticket: () => hubLink().getGameServerTicket(),
|
|
26
26
|
setJoinInfo: (partyId, joinInfo) => pageBridge.call((realtime) => realtime.mp.setJoinInfo(partyId, joinInfo)),
|
|
27
27
|
party: () => pageBridge.current()?.mp.party() ?? null,
|
|
28
|
+
inviteLink: () => pageBridge.call((realtime) => {
|
|
29
|
+
if (realtime.mp.inviteLink === undefined) {
|
|
30
|
+
throw outdatedRuntime('Invite links');
|
|
31
|
+
}
|
|
32
|
+
return realtime.mp.inviteLink();
|
|
33
|
+
}),
|
|
34
|
+
revokeInviteLink: (token) => pageBridge.call((realtime) => {
|
|
35
|
+
if (realtime.mp.revokeInviteLink === undefined) {
|
|
36
|
+
throw outdatedRuntime('Invite links');
|
|
37
|
+
}
|
|
38
|
+
return realtime.mp.revokeInviteLink(token);
|
|
39
|
+
}),
|
|
28
40
|
};
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* launches, the leave guard and own-server tickets.
|
|
4
4
|
*/
|
|
5
5
|
import type { Invite, JoinInfo, Launch, Party } from '@diffenderfer-games/hub-contract';
|
|
6
|
+
import type { InviteLink } from './wire-types';
|
|
6
7
|
|
|
7
8
|
/** `hub.mp.onJoinInfo` payload: the party leader published where to connect. */
|
|
8
9
|
export interface JoinInfoEvent {
|
|
@@ -30,8 +31,21 @@ export interface BusyOptions {
|
|
|
30
31
|
label?: string;
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/** What feature version 3 added to `hub.mp`: invite links. */
|
|
35
|
+
export interface InviteLinkMethods {
|
|
36
|
+
/**
|
|
37
|
+
* A shareable link to the room I'm in (my live one when it exists): anyone
|
|
38
|
+
* who opens it joins while a seat is free, otherwise watches. It expires
|
|
39
|
+
* after 24 h. Claimed accounts only; rejects with `HubError`
|
|
40
|
+
* (`not_in_room`, `too_many_links`, `rate_limited`) or `OfflineError`.
|
|
41
|
+
*/
|
|
42
|
+
inviteLink(): Promise<InviteLink>;
|
|
43
|
+
/** Revoke a link I made, or one into a room I host. */
|
|
44
|
+
revokeInviteLink(token: string): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
/** `hub.mp`: the multiplayer lifecycle (docs/multiplayer.md §7). */
|
|
34
|
-
export interface HubMpApi {
|
|
48
|
+
export interface HubMpApi extends InviteLinkMethods {
|
|
35
49
|
/** Invite, join and watch arrivals. Call at boot; launches that arrived earlier are buffered. */
|
|
36
50
|
onLaunch(listener: (launch: Launch) => void): () => void;
|
|
37
51
|
/** The party leader published join info (e.g. a room code). */
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* multiplayer for static games, relayed (never refereed) by the hub.
|
|
4
4
|
*/
|
|
5
5
|
import type { Json, RoomInfo } from '@diffenderfer-games/hub-contract';
|
|
6
|
-
import type { RoomChatLine, RoomMessage } from './wire-types';
|
|
6
|
+
import type { RoomChatLine, RoomMessage, RoomSeat, RoomSeatChange } from './wire-types';
|
|
7
7
|
|
|
8
8
|
/** The shared room state (a JSON object, at most 32 KB). */
|
|
9
9
|
export type RoomState = Record<string, Json>;
|
|
@@ -20,6 +20,13 @@ export interface RoomCreateOptions {
|
|
|
20
20
|
maxPlayers?: number;
|
|
21
21
|
/** `host` (default): guests' unaddressed sends reach the host only. `relay`: everyone hears every send. */
|
|
22
22
|
model?: 'host' | 'relay';
|
|
23
|
+
/**
|
|
24
|
+
* When the host role moves on mid-game: `grace` (default) once the host's
|
|
25
|
+
* seat is freed (left, or 3 minutes away), so a host holding hidden state
|
|
26
|
+
* can come back; `drop` as soon as the host drops, for games whose truth is
|
|
27
|
+
* all in `setState`.
|
|
28
|
+
*/
|
|
29
|
+
hostMigration?: 'grace' | 'drop';
|
|
23
30
|
/** Seat this party in the room. */
|
|
24
31
|
partyId?: string;
|
|
25
32
|
}
|
|
@@ -52,6 +59,13 @@ export interface RoomEvents {
|
|
|
52
59
|
ended: { room: RoomInfo; results?: Json };
|
|
53
60
|
/** A player left mid-game (or their reconnect grace ran out). */
|
|
54
61
|
abandoned: { userId: number; reason?: string };
|
|
62
|
+
/**
|
|
63
|
+
* A seat changed: `away` (dropped; held until `graceEndsAt`, so hand it to
|
|
64
|
+
* your computer player), `back` (reclaimed: give control back) or `gone`
|
|
65
|
+
* (freed: left, kicked or the grace ran out). The game never ends on its
|
|
66
|
+
* own because of this; `info.hostId` may have moved to another player.
|
|
67
|
+
*/
|
|
68
|
+
seat: RoomSeatChange;
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
/** Who I am in a room. */
|
|
@@ -84,6 +98,8 @@ export interface HubRoom {
|
|
|
84
98
|
readonly state: RoomState;
|
|
85
99
|
/** Who I am in this room. */
|
|
86
100
|
readonly me: RoomSelf;
|
|
101
|
+
/** The seats as last reported; `connected: false` while a seat is away. */
|
|
102
|
+
seats(): RoomSeat[];
|
|
87
103
|
/** Subscribe to a room event. Returns the unsubscribe function. */
|
|
88
104
|
on<Name extends keyof RoomEvents>(event: Name, listener: (payload: RoomEvents[Name]) => void): () => void;
|
|
89
105
|
/** Mark my seat ready (or not). */
|
|
@@ -114,8 +130,18 @@ export interface RoomListOptions {
|
|
|
114
130
|
mode?: string;
|
|
115
131
|
}
|
|
116
132
|
|
|
133
|
+
/** What feature version 3 added to `hub.rooms`. */
|
|
134
|
+
export interface RoomReclaimMethods {
|
|
135
|
+
/**
|
|
136
|
+
* Take back the seat I still hold in this game (after a reload, from
|
|
137
|
+
* another tab): the room, or `null` when I hold none. The hub also does
|
|
138
|
+
* this on its own at boot, as a `join` launch with `via: 'rejoin'`.
|
|
139
|
+
*/
|
|
140
|
+
reclaim(): Promise<HubRoom | null>;
|
|
141
|
+
}
|
|
142
|
+
|
|
117
143
|
/** `hub.rooms`: hub relay rooms (static games, no server). */
|
|
118
|
-
export interface HubRoomsApi {
|
|
144
|
+
export interface HubRoomsApi extends RoomReclaimMethods {
|
|
119
145
|
/** Create a room and take its first seat. */
|
|
120
146
|
create(options?: RoomCreateOptions): Promise<HubRoom>;
|
|
121
147
|
/** Join a room by its 5-character code or its id. */
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* wrapped so its rejections use the core client's error classes.
|
|
4
4
|
*/
|
|
5
5
|
import type { Json, RoomInfo } from '@diffenderfer-games/hub-contract';
|
|
6
|
-
import { pageBridge, rethrowAsHubError } from './bridge';
|
|
6
|
+
import { outdatedRuntime, pageBridge, rethrowAsHubError } from './bridge';
|
|
7
7
|
import type { HubRoom, HubRoomsApi, RoomEvents, RoomSelf, RoomSendOptions, RoomState, RoomStateOptions } from './room-types';
|
|
8
|
+
import type { RoomSeat } from './wire-types';
|
|
8
9
|
|
|
9
10
|
/** A runtime room whose rejections are re-created as the core client's errors. */
|
|
10
11
|
class ForwardedRoom implements HubRoom {
|
|
@@ -26,6 +27,10 @@ class ForwardedRoom implements HubRoom {
|
|
|
26
27
|
return this.room.me;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
seats(): RoomSeat[] {
|
|
31
|
+
return this.room.info.seats;
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
on<Name extends keyof RoomEvents>(event: Name, listener: (payload: RoomEvents[Name]) => void): () => void {
|
|
30
35
|
return this.room.on(event, listener);
|
|
31
36
|
}
|
|
@@ -76,4 +81,11 @@ export const roomsForwarder: HubRoomsApi = {
|
|
|
76
81
|
create: (options) => pageBridge.call(async (realtime) => new ForwardedRoom(await realtime.rooms.create(options))),
|
|
77
82
|
join: (codeOrId, options) => pageBridge.call(async (realtime) => new ForwardedRoom(await realtime.rooms.join(codeOrId, options))),
|
|
78
83
|
list: (options) => pageBridge.call((realtime) => realtime.rooms.list(options)),
|
|
84
|
+
reclaim: () => pageBridge.call(async (realtime) => {
|
|
85
|
+
if (realtime.rooms.reclaim === undefined) {
|
|
86
|
+
throw outdatedRuntime('Taking back a seat');
|
|
87
|
+
}
|
|
88
|
+
const held = await realtime.rooms.reclaim();
|
|
89
|
+
return held === null ? null : new ForwardedRoom(held);
|
|
90
|
+
}),
|
|
79
91
|
};
|
|
@@ -3,16 +3,27 @@
|
|
|
3
3
|
* the page, and that `hub.presence`, `hub.social`, `hub.mp`, `hub.rooms`,
|
|
4
4
|
* `hub.notify` and `hub.race` forward to.
|
|
5
5
|
*/
|
|
6
|
-
import type { HubMpApi } from './multiplayer-types';
|
|
6
|
+
import type { HubMpApi, InviteLinkMethods } from './multiplayer-types';
|
|
7
7
|
import type { HubRaceApi } from './race-types';
|
|
8
|
-
import type { HubRoomsApi } from './room-types';
|
|
9
|
-
import type { HubNotifyApi, HubPresenceApi, HubSocialApi } from './social-types';
|
|
8
|
+
import type { HubRoomsApi, RoomReclaimMethods } from './room-types';
|
|
9
|
+
import type { HubNotifyApi, HubPresenceApi, HubSocialApi, PlayOnlineMethods } from './social-types';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* The realtime feature version the runtime reports in {@link RealtimeApi.version}.
|
|
13
|
-
* 2 added races;
|
|
13
|
+
* 2 added races; 3 added invite links and taking back a held seat; 4 added
|
|
14
|
+
* "Play online" (tournaments). A client feature-detects newer additions with
|
|
15
|
+
* it (or by the optional method).
|
|
14
16
|
*/
|
|
15
|
-
export const REALTIME_FEATURE_VERSION =
|
|
17
|
+
export const REALTIME_FEATURE_VERSION = 4;
|
|
18
|
+
|
|
19
|
+
/** `hub.social` as a runtime installs it: version 4's `openPlayOnline` is missing on older runtimes. */
|
|
20
|
+
export type RuntimeSocialApi = Omit<HubSocialApi, keyof PlayOnlineMethods> & Partial<PlayOnlineMethods>;
|
|
21
|
+
|
|
22
|
+
/** `hub.mp` as a runtime installs it: version 3's invite-link methods are missing on older runtimes. */
|
|
23
|
+
export type RuntimeMpApi = Omit<HubMpApi, keyof InviteLinkMethods> & Partial<InviteLinkMethods>;
|
|
24
|
+
|
|
25
|
+
/** `hub.rooms` as a runtime installs it: version 3's `reclaim` is missing on older runtimes. */
|
|
26
|
+
export type RuntimeRoomsApi = Omit<HubRoomsApi, keyof RoomReclaimMethods> & Partial<RoomReclaimMethods>;
|
|
16
27
|
|
|
17
28
|
/**
|
|
18
29
|
* The cross-bundle shape number of the `rt` page singleton (plan §4.4). The
|
|
@@ -37,11 +48,11 @@ export interface RealtimeApi {
|
|
|
37
48
|
/** Presence reporting. */
|
|
38
49
|
presence: HubPresenceApi;
|
|
39
50
|
/** Friends and the social dialogs. */
|
|
40
|
-
social:
|
|
51
|
+
social: RuntimeSocialApi;
|
|
41
52
|
/** Invites, launches, the leave guard and tickets. */
|
|
42
|
-
mp:
|
|
53
|
+
mp: RuntimeMpApi;
|
|
43
54
|
/** Hub relay rooms. */
|
|
44
|
-
rooms:
|
|
55
|
+
rooms: RuntimeRoomsApi;
|
|
45
56
|
/** Toast behaviour. */
|
|
46
57
|
notify: HubNotifyApi;
|
|
47
58
|
/** Races (feature version 2 and later). */
|
|
@@ -65,6 +65,11 @@ export const socialForwarder: HubSocialApi = {
|
|
|
65
65
|
realtime.social.openNotify(options);
|
|
66
66
|
});
|
|
67
67
|
},
|
|
68
|
+
openPlayOnline: () => {
|
|
69
|
+
openWithRuntime((realtime) => {
|
|
70
|
+
realtime.social.openPlayOnline?.();
|
|
71
|
+
});
|
|
72
|
+
},
|
|
68
73
|
friends: () => pageBridge.call((realtime) => realtime.social.friends()),
|
|
69
74
|
counts: () => pageBridge.current()?.social.counts() ?? { ...NO_COUNTS },
|
|
70
75
|
chatMode: (userIds) => pageBridge.current()?.social.chatMode(userIds) ?? 'off',
|
|
@@ -61,6 +61,11 @@ export interface HubSocialApi {
|
|
|
61
61
|
openSuggest(): void;
|
|
62
62
|
/** Open notification settings, optionally focused on a game's lobby bell. */
|
|
63
63
|
openNotify(options?: { game?: string }): void;
|
|
64
|
+
/**
|
|
65
|
+
* Open "Play online": games waiting for a player, invites, tournaments and
|
|
66
|
+
* the quick 1v1 (feature version 4; a no-op on older runtimes).
|
|
67
|
+
*/
|
|
68
|
+
openPlayOnline(): void;
|
|
64
69
|
/** Friends with live presence. Rejects with `OfflineError` without the runtime. */
|
|
65
70
|
friends(): Promise<FriendEntry[]>;
|
|
66
71
|
/** Live counts; all zero until the runtime is up. */
|
|
@@ -71,6 +76,9 @@ export interface HubSocialApi {
|
|
|
71
76
|
on<Name extends keyof SocialEvents>(event: Name, listener: (payload: SocialEvents[Name]) => void): () => void;
|
|
72
77
|
}
|
|
73
78
|
|
|
79
|
+
/** What feature version 4 added to `hub.social`. */
|
|
80
|
+
export type PlayOnlineMethods = Pick<HubSocialApi, 'openPlayOnline'>;
|
|
81
|
+
|
|
74
82
|
/** `hub.notify`: toast behaviour during intense play. */
|
|
75
83
|
export interface HubNotifyApi {
|
|
76
84
|
/** Quiet: toasts collapse into the menu badge; invites still show as a small pill. */
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
OpAck,
|
|
10
10
|
ops,
|
|
11
11
|
InviteCreated as InviteCreatedSchema,
|
|
12
|
+
InviteLink as InviteLinkSchema,
|
|
12
13
|
RaceHistory as RaceHistorySchema,
|
|
13
14
|
RaceResultPlayer as RaceResultPlayerSchema,
|
|
14
15
|
RaceRecordPlayer as RaceRecordPlayerSchema,
|
|
@@ -51,6 +52,12 @@ export type RoomMessage = ServerEvents['room.msg'];
|
|
|
51
52
|
/** A shared-state change (`ev:room.state`); patches are shallow-merged unless `replace`. */
|
|
52
53
|
export type RoomStateChange = ServerEvents['room.state'];
|
|
53
54
|
|
|
55
|
+
/** A seat went away, came back or is gone (`ev:room.seat`). */
|
|
56
|
+
export type RoomSeatChange = ServerEvents['room.seat'];
|
|
57
|
+
|
|
58
|
+
/** A shareable invite link into a room (`POST /mp/links`). */
|
|
59
|
+
export type InviteLink = SchemaOutput<typeof InviteLinkSchema>;
|
|
60
|
+
|
|
54
61
|
/** A moderated room chat line (`ev:room.chat`). */
|
|
55
62
|
export type RoomChatLine = ServerEvents['room.chat'];
|
|
56
63
|
|
package/src/rooms.ts
CHANGED
|
@@ -6,5 +6,5 @@
|
|
|
6
6
|
*/
|
|
7
7
|
export { roomsForwarder as rooms } from './realtime/rooms-forwarder';
|
|
8
8
|
export type * from './realtime/room-types';
|
|
9
|
-
export type { RoomChatLine, RoomMessage, RoomSeat, RoomStateChange } from './realtime/wire-types';
|
|
9
|
+
export type { RoomChatLine, RoomMessage, RoomSeat, RoomSeatChange, RoomStateChange } from './realtime/wire-types';
|
|
10
10
|
export type { RoomInfo } from '@diffenderfer-games/hub-contract';
|