@couch-kit/host 2.0.1 → 2.1.1
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/lib/action-recorder.d.ts +35 -4
- package/lib/action-recorder.d.ts.map +1 -1
- package/lib/provider.d.ts +9 -1
- package/lib/provider.d.ts.map +1 -1
- package/lib/websocket.d.ts +5 -0
- package/lib/websocket.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/action-recorder.ts +64 -8
- package/src/provider.tsx +20 -4
- package/src/websocket.ts +23 -0
package/lib/action-recorder.d.ts
CHANGED
|
@@ -24,20 +24,51 @@ export interface ActionRecorderControls<S extends IGameState = IGameState, A ext
|
|
|
24
24
|
isRecording: boolean;
|
|
25
25
|
/** Number of actions recorded so far */
|
|
26
26
|
recordedCount: number;
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Start recording actions, capturing `currentState` as the initial state.
|
|
29
|
+
* With a {@link ActionRecorderOptions.source}, `currentState` may be omitted
|
|
30
|
+
* and the source's current state is used.
|
|
31
|
+
*/
|
|
32
|
+
startRecording: (currentState?: S, metadata?: Record<string, unknown>) => void;
|
|
29
33
|
/** Stop recording and return the recording */
|
|
30
34
|
stopRecording: () => ActionRecording<S, A> | null;
|
|
31
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Record a single action. Only needed without a
|
|
37
|
+
* {@link ActionRecorderOptions.source}, which records every action itself.
|
|
38
|
+
*/
|
|
32
39
|
recordAction: (action: A) => void;
|
|
33
40
|
/** Export the current recording as JSON string */
|
|
34
41
|
exportRecording: () => string | null;
|
|
35
42
|
/** Discard the current recording */
|
|
36
43
|
discardRecording: () => void;
|
|
37
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Where a recorder reads actions from on its own. `useGameHost()` returns one.
|
|
47
|
+
*/
|
|
48
|
+
export interface ActionRecorderSource<S extends IGameState = IGameState> {
|
|
49
|
+
getState: () => S;
|
|
50
|
+
subscribeActions: (listener: (action: IAction) => void) => () => void;
|
|
51
|
+
}
|
|
52
|
+
export interface ActionRecorderOptions<S extends IGameState = IGameState> {
|
|
53
|
+
/**
|
|
54
|
+
* Records every action the host reduces — including players' actions and
|
|
55
|
+
* join/leave lifecycle actions, which never pass through the host's own
|
|
56
|
+
* `dispatch`. Without it, only actions passed to `recordAction` are
|
|
57
|
+
* recorded, which on a host means only the host's own dispatches.
|
|
58
|
+
*
|
|
59
|
+
* Recordings made this way contain internal actions (`__PLAYER_JOINED__`
|
|
60
|
+
* and friends); `replayActions` handles them.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* const host = useGameHost<GameState, GameAction>();
|
|
64
|
+
* const recorder = useActionRecorder({ source: host });
|
|
65
|
+
* recorder.startRecording();
|
|
66
|
+
*/
|
|
67
|
+
source?: ActionRecorderSource<S>;
|
|
68
|
+
}
|
|
38
69
|
/**
|
|
39
70
|
* Hook that enables recording of game actions for later replay.
|
|
40
71
|
* Used on the host side to capture game sessions.
|
|
41
72
|
*/
|
|
42
|
-
export declare function useActionRecorder<S extends IGameState = IGameState, A extends IAction = IAction>(): ActionRecorderControls<S, A>;
|
|
73
|
+
export declare function useActionRecorder<S extends IGameState = IGameState, A extends IAction = IAction>(options?: ActionRecorderOptions<S>): ActionRecorderControls<S, A>;
|
|
43
74
|
//# sourceMappingURL=action-recorder.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"action-recorder.d.ts","sourceRoot":"","sources":["../src/action-recorder.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE3D;;GAEG;AACH,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,OAAO,GAAG,OAAO;IACzD,MAAM,EAAE,CAAC,CAAC;IACV,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe,CAC9B,CAAC,SAAS,UAAU,GAAG,UAAU,EACjC,CAAC,SAAS,OAAO,GAAG,OAAO;IAE3B,YAAY,EAAE,CAAC,CAAC;IAChB,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,EAAE,CAAC;IAC7B,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,sBAAsB,CACrC,CAAC,SAAS,UAAU,GAAG,UAAU,EACjC,CAAC,SAAS,OAAO,GAAG,OAAO;IAE3B,4CAA4C;IAC5C,WAAW,EAAE,OAAO,CAAC;IACrB,wCAAwC;IACxC,aAAa,EAAE,MAAM,CAAC;IACtB
|
|
1
|
+
{"version":3,"file":"action-recorder.d.ts","sourceRoot":"","sources":["../src/action-recorder.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE3D;;GAEG;AACH,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,OAAO,GAAG,OAAO;IACzD,MAAM,EAAE,CAAC,CAAC;IACV,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;GAEG;AACH,MAAM,WAAW,eAAe,CAC9B,CAAC,SAAS,UAAU,GAAG,UAAU,EACjC,CAAC,SAAS,OAAO,GAAG,OAAO;IAE3B,YAAY,EAAE,CAAC,CAAC;IAChB,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,EAAE,CAAC;IAC7B,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,sBAAsB,CACrC,CAAC,SAAS,UAAU,GAAG,UAAU,EACjC,CAAC,SAAS,OAAO,GAAG,OAAO;IAE3B,4CAA4C;IAC5C,WAAW,EAAE,OAAO,CAAC;IACrB,wCAAwC;IACxC,aAAa,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,cAAc,EAAE,CACd,YAAY,CAAC,EAAE,CAAC,EAChB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC/B,IAAI,CAAC;IACV,8CAA8C;IAC9C,aAAa,EAAE,MAAM,eAAe,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI,CAAC;IAClD;;;OAGG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAClC,kDAAkD;IAClD,eAAe,EAAE,MAAM,MAAM,GAAG,IAAI,CAAC;IACrC,oCAAoC;IACpC,gBAAgB,EAAE,MAAM,IAAI,CAAC;CAC9B;AAED;;GAEG;AACH,MAAM,WAAW,oBAAoB,CAAC,CAAC,SAAS,UAAU,GAAG,UAAU;IACrE,QAAQ,EAAE,MAAM,CAAC,CAAC;IAClB,gBAAgB,EAAE,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,KAAK,MAAM,IAAI,CAAC;CACvE;AAED,MAAM,WAAW,qBAAqB,CAAC,CAAC,SAAS,UAAU,GAAG,UAAU;IACtE;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,oBAAoB,CAAC,CAAC,CAAC,CAAC;CAClC;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,CAAC,SAAS,UAAU,GAAG,UAAU,EACjC,CAAC,SAAS,OAAO,GAAG,OAAO,EAC3B,OAAO,GAAE,qBAAqB,CAAC,CAAC,CAAM,GAAG,sBAAsB,CAAC,CAAC,EAAE,CAAC,CAAC,CA8EtE"}
|
package/lib/provider.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { type IAction, type IGameState } from "@couch-kit/core";
|
|
3
|
-
import { type GameHostRuntimeConfig } from "@couch-kit/runtime";
|
|
3
|
+
import { type GameHostRuntimeConfig, type RuntimeActionListener } from "@couch-kit/runtime";
|
|
4
4
|
export interface GameHostConfig<S extends IGameState, A extends IAction> extends GameHostRuntimeConfig<S, A> {
|
|
5
5
|
port?: number;
|
|
6
6
|
wsPort?: number;
|
|
@@ -18,6 +18,14 @@ interface GameHostContextValue<S extends IGameState, A extends IAction> {
|
|
|
18
18
|
dispatch: (action: A) => void;
|
|
19
19
|
serverUrl: string | null;
|
|
20
20
|
serverError: Error | null;
|
|
21
|
+
/** Reads canonical state now, without waiting for a re-render. */
|
|
22
|
+
getState: () => S;
|
|
23
|
+
/**
|
|
24
|
+
* Subscribes to every action the host reduces — its own dispatches, every
|
|
25
|
+
* player's actions, and player join/leave lifecycle actions. Pass the whole
|
|
26
|
+
* context to `useActionRecorder({ source })` to record complete sessions.
|
|
27
|
+
*/
|
|
28
|
+
subscribeActions: (listener: RuntimeActionListener<S, A>) => () => void;
|
|
21
29
|
}
|
|
22
30
|
/**
|
|
23
31
|
* React Native adapter for the transport-neutral authoritative game runtime.
|
package/lib/provider.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AACf,OAAO,EAGL,KAAK,OAAO,EACZ,KAAK,UAAU,EAChB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,oBAAoB,CAAC;AAI5B,MAAM,WAAW,cAAc,CAC7B,CAAC,SAAS,UAAU,EACpB,CAAC,SAAS,OAAO,CACjB,SAAQ,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,UAAU,oBAAoB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACpE,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,KAAK,GAAG,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AACf,OAAO,EAGL,KAAK,OAAO,EACZ,KAAK,UAAU,EAChB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAEL,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,EAC3B,MAAM,oBAAoB,CAAC;AAI5B,MAAM,WAAW,cAAc,CAC7B,CAAC,SAAS,UAAU,EACpB,CAAC,SAAS,OAAO,CACjB,SAAQ,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,UAAU,oBAAoB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACpE,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,KAAK,GAAG,IAAI,CAAC;IAC1B,kEAAkE;IAClE,QAAQ,EAAE,MAAM,CAAC,CAAC;IAClB;;;;OAIG;IACH,gBAAgB,EAAE,CAAC,QAAQ,EAAE,qBAAqB,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,MAAM,IAAI,CAAC;CACzE;AAOD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EAAE,EACxE,QAAQ,EACR,MAAM,GACP,EAAE;IACD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,MAAM,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;CAC9B,qBAmIA;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,KAK/C,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC,CAC7C"}
|
package/lib/websocket.d.ts
CHANGED
|
@@ -63,6 +63,11 @@ export declare class GameWebSocketServer extends EventEmitter<WebSocketServerEve
|
|
|
63
63
|
* Wraps each send in try/catch so a single failed send doesn't skip remaining clients.
|
|
64
64
|
*/
|
|
65
65
|
broadcast(data: unknown, excludeId?: string): void;
|
|
66
|
+
/**
|
|
67
|
+
* Send the same data to each of `socketIds`, serializing it once.
|
|
68
|
+
* Unknown IDs are skipped; a failed send doesn't skip the remaining clients.
|
|
69
|
+
*/
|
|
70
|
+
multicast(socketIds: Iterable<string>, data: unknown): void;
|
|
66
71
|
/** Returns the number of currently connected clients. */
|
|
67
72
|
get clientCount(): number;
|
|
68
73
|
}
|
package/lib/websocket.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAO/C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8CAA8C;AAC9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC9C,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC1B,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAC;AAOF,qBAAa,mBAAoB,SAAQ,YAAY,CAAC,qBAAqB,CAAC;IAC1E,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,OAAO,CAA2C;IAC1D,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,KAAK,CAAU;IACvB,OAAO,CAAC,eAAe,CAAS;IAEhC,YAAY,MAAM,EAAE,eAAe,EASlC;IAED,OAAO,CAAC,GAAG;IAME,KAAK,kBAkGjB;IAEY,IAAI,kBAkBhB;IAED;;;OAGG;IACI,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,QAoB1C;IAED;;;OAGG;IACI,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM,QAkBjD;IAED,yDAAyD;IACzD,IAAW,WAAW,IAAI,MAAM,CAE/B;CACF"}
|
|
1
|
+
{"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAO/C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8CAA8C;AAC9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC9C,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC1B,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAC;AAOF,qBAAa,mBAAoB,SAAQ,YAAY,CAAC,qBAAqB,CAAC;IAC1E,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,OAAO,CAA2C;IAC1D,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,KAAK,CAAU;IACvB,OAAO,CAAC,eAAe,CAAS;IAEhC,YAAY,MAAM,EAAE,eAAe,EASlC;IAED,OAAO,CAAC,GAAG;IAME,KAAK,kBAkGjB;IAEY,IAAI,kBAkBhB;IAED;;;OAGG;IACI,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,QAoB1C;IAED;;;OAGG;IACI,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM,QAkBjD;IAED;;;OAGG;IACI,SAAS,CAAC,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,IAAI,EAAE,OAAO,QAiB1D;IAED,yDAAyD;IACzD,IAAW,WAAW,IAAI,MAAM,CAE/B;CACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@couch-kit/host",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"provenance": true
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"prepublishOnly": "bun run build"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@couch-kit/core": "0.10.
|
|
61
|
-
"@couch-kit/runtime": "0.
|
|
60
|
+
"@couch-kit/core": "0.10.1",
|
|
61
|
+
"@couch-kit/runtime": "0.6.0",
|
|
62
62
|
"react-native-nitro-http-server": "^1.9.2"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
package/src/action-recorder.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import type { IAction, IGameState } from "@couch-kit/core";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -34,11 +34,21 @@ export interface ActionRecorderControls<
|
|
|
34
34
|
isRecording: boolean;
|
|
35
35
|
/** Number of actions recorded so far */
|
|
36
36
|
recordedCount: number;
|
|
37
|
-
/**
|
|
38
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Start recording actions, capturing `currentState` as the initial state.
|
|
39
|
+
* With a {@link ActionRecorderOptions.source}, `currentState` may be omitted
|
|
40
|
+
* and the source's current state is used.
|
|
41
|
+
*/
|
|
42
|
+
startRecording: (
|
|
43
|
+
currentState?: S,
|
|
44
|
+
metadata?: Record<string, unknown>,
|
|
45
|
+
) => void;
|
|
39
46
|
/** Stop recording and return the recording */
|
|
40
47
|
stopRecording: () => ActionRecording<S, A> | null;
|
|
41
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Record a single action. Only needed without a
|
|
50
|
+
* {@link ActionRecorderOptions.source}, which records every action itself.
|
|
51
|
+
*/
|
|
42
52
|
recordAction: (action: A) => void;
|
|
43
53
|
/** Export the current recording as JSON string */
|
|
44
54
|
exportRecording: () => string | null;
|
|
@@ -46,6 +56,32 @@ export interface ActionRecorderControls<
|
|
|
46
56
|
discardRecording: () => void;
|
|
47
57
|
}
|
|
48
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Where a recorder reads actions from on its own. `useGameHost()` returns one.
|
|
61
|
+
*/
|
|
62
|
+
export interface ActionRecorderSource<S extends IGameState = IGameState> {
|
|
63
|
+
getState: () => S;
|
|
64
|
+
subscribeActions: (listener: (action: IAction) => void) => () => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ActionRecorderOptions<S extends IGameState = IGameState> {
|
|
68
|
+
/**
|
|
69
|
+
* Records every action the host reduces — including players' actions and
|
|
70
|
+
* join/leave lifecycle actions, which never pass through the host's own
|
|
71
|
+
* `dispatch`. Without it, only actions passed to `recordAction` are
|
|
72
|
+
* recorded, which on a host means only the host's own dispatches.
|
|
73
|
+
*
|
|
74
|
+
* Recordings made this way contain internal actions (`__PLAYER_JOINED__`
|
|
75
|
+
* and friends); `replayActions` handles them.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* const host = useGameHost<GameState, GameAction>();
|
|
79
|
+
* const recorder = useActionRecorder({ source: host });
|
|
80
|
+
* recorder.startRecording();
|
|
81
|
+
*/
|
|
82
|
+
source?: ActionRecorderSource<S>;
|
|
83
|
+
}
|
|
84
|
+
|
|
49
85
|
/**
|
|
50
86
|
* Hook that enables recording of game actions for later replay.
|
|
51
87
|
* Used on the host side to capture game sessions.
|
|
@@ -53,15 +89,24 @@ export interface ActionRecorderControls<
|
|
|
53
89
|
export function useActionRecorder<
|
|
54
90
|
S extends IGameState = IGameState,
|
|
55
91
|
A extends IAction = IAction,
|
|
56
|
-
>(): ActionRecorderControls<S, A> {
|
|
92
|
+
>(options: ActionRecorderOptions<S> = {}): ActionRecorderControls<S, A> {
|
|
93
|
+
const { source } = options;
|
|
57
94
|
const [isRecording, setIsRecording] = useState(false);
|
|
58
95
|
const [recordedCount, setRecordedCount] = useState(0);
|
|
59
96
|
const recordingRef = useRef<ActionRecording<S, A> | null>(null);
|
|
97
|
+
const sourceRef = useRef(source);
|
|
98
|
+
sourceRef.current = source;
|
|
60
99
|
|
|
61
100
|
const startRecording = useCallback(
|
|
62
|
-
(currentState
|
|
101
|
+
(currentState?: S, metadata?: Record<string, unknown>) => {
|
|
102
|
+
const initialState = currentState ?? sourceRef.current?.getState();
|
|
103
|
+
if (initialState === undefined) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
"startRecording needs the current state when no source is configured",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
63
108
|
recordingRef.current = {
|
|
64
|
-
initialState
|
|
109
|
+
initialState,
|
|
65
110
|
actions: [],
|
|
66
111
|
startTimestamp: Date.now(),
|
|
67
112
|
metadata,
|
|
@@ -82,7 +127,12 @@ export function useActionRecorder<
|
|
|
82
127
|
}, []);
|
|
83
128
|
|
|
84
129
|
const recordAction = useCallback((action: A) => {
|
|
85
|
-
|
|
130
|
+
// A stopped recording keeps its actions but takes no new ones.
|
|
131
|
+
if (
|
|
132
|
+
!recordingRef.current ||
|
|
133
|
+
recordingRef.current.endTimestamp !== undefined
|
|
134
|
+
)
|
|
135
|
+
return;
|
|
86
136
|
|
|
87
137
|
recordingRef.current.actions.push({
|
|
88
138
|
action,
|
|
@@ -91,6 +141,12 @@ export function useActionRecorder<
|
|
|
91
141
|
setRecordedCount((c) => c + 1);
|
|
92
142
|
}, []);
|
|
93
143
|
|
|
144
|
+
const subscribeActions = source?.subscribeActions;
|
|
145
|
+
useEffect(() => {
|
|
146
|
+
if (!subscribeActions) return;
|
|
147
|
+
return subscribeActions((action) => recordAction(action as A));
|
|
148
|
+
}, [subscribeActions, recordAction]);
|
|
149
|
+
|
|
94
150
|
const exportRecording = useCallback((): string | null => {
|
|
95
151
|
if (!recordingRef.current) return null;
|
|
96
152
|
return JSON.stringify(recordingRef.current, null, 2);
|
package/src/provider.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import {
|
|
17
17
|
GameHostRuntime,
|
|
18
18
|
type GameHostRuntimeConfig,
|
|
19
|
+
type RuntimeActionListener,
|
|
19
20
|
} from "@couch-kit/runtime";
|
|
20
21
|
import { useStaticServer } from "./server";
|
|
21
22
|
import { GameWebSocketServer } from "./websocket";
|
|
@@ -41,6 +42,14 @@ interface GameHostContextValue<S extends IGameState, A extends IAction> {
|
|
|
41
42
|
dispatch: (action: A) => void;
|
|
42
43
|
serverUrl: string | null;
|
|
43
44
|
serverError: Error | null;
|
|
45
|
+
/** Reads canonical state now, without waiting for a re-render. */
|
|
46
|
+
getState: () => S;
|
|
47
|
+
/**
|
|
48
|
+
* Subscribes to every action the host reduces — its own dispatches, every
|
|
49
|
+
* player's actions, and player join/leave lifecycle actions. Pass the whole
|
|
50
|
+
* context to `useActionRecorder({ source })` to record complete sessions.
|
|
51
|
+
*/
|
|
52
|
+
subscribeActions: (listener: RuntimeActionListener<S, A>) => () => void;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -139,8 +148,8 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
139
148
|
send: (connectionId, message) => {
|
|
140
149
|
server.send(connectionId, message);
|
|
141
150
|
},
|
|
142
|
-
broadcast: (message) => {
|
|
143
|
-
server.
|
|
151
|
+
broadcast: (message, recipients) => {
|
|
152
|
+
server.multicast(recipients, message);
|
|
144
153
|
},
|
|
145
154
|
});
|
|
146
155
|
|
|
@@ -175,8 +184,15 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
175
184
|
);
|
|
176
185
|
|
|
177
186
|
const contextValue = useMemo(
|
|
178
|
-
() => ({
|
|
179
|
-
|
|
187
|
+
() => ({
|
|
188
|
+
state,
|
|
189
|
+
dispatch,
|
|
190
|
+
serverUrl,
|
|
191
|
+
serverError,
|
|
192
|
+
getState: runtime.getState,
|
|
193
|
+
subscribeActions: runtime.subscribeActions,
|
|
194
|
+
}),
|
|
195
|
+
[state, dispatch, serverUrl, serverError, runtime],
|
|
180
196
|
);
|
|
181
197
|
|
|
182
198
|
return (
|
package/src/websocket.ts
CHANGED
|
@@ -256,6 +256,29 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
|
|
|
256
256
|
}
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Send the same data to each of `socketIds`, serializing it once.
|
|
261
|
+
* Unknown IDs are skipped; a failed send doesn't skip the remaining clients.
|
|
262
|
+
*/
|
|
263
|
+
public multicast(socketIds: Iterable<string>, data: unknown) {
|
|
264
|
+
try {
|
|
265
|
+
const message = JSON.stringify(data);
|
|
266
|
+
for (const id of socketIds) {
|
|
267
|
+
const client = this.clients.get(id);
|
|
268
|
+
if (!client) continue;
|
|
269
|
+
client.ws.send(message).catch((error: Error) => {
|
|
270
|
+
this.log(`[WebSocket] Failed to send to ${id}:`, error);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
this.log(`[WebSocket] Failed to serialize multicast message:`, error);
|
|
275
|
+
this.emit(
|
|
276
|
+
"error",
|
|
277
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
259
282
|
/** Returns the number of currently connected clients. */
|
|
260
283
|
public get clientCount(): number {
|
|
261
284
|
return this.clients.size;
|