@couch-kit/core 0.4.0 → 0.5.0
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 +23 -0
- package/README.md +2 -0
- package/dist/index.js +35 -1
- package/lib/constants.d.ts +12 -0
- package/lib/constants.d.ts.map +1 -1
- package/lib/protocol.d.ts +2 -1
- package/lib/protocol.d.ts.map +1 -1
- package/lib/types.d.ts +20 -1
- package/lib/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +19 -0
- package/src/protocol.ts +2 -2
- package/src/types.ts +41 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# @couch-kit/core
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Fix player state not restored on page refresh
|
|
8
|
+
|
|
9
|
+
Players now maintain their identity across page refreshes and network reconnections. The library uses a stable player ID derived from a persistent session secret (stored in localStorage) instead of the ephemeral WebSocket socket ID.
|
|
10
|
+
|
|
11
|
+
**New features:**
|
|
12
|
+
- `__PLAYER_RECONNECTED__` internal action: dispatched when a returning player reconnects, preserving all game data (hand, score, turn, etc.)
|
|
13
|
+
- `__PLAYER_REMOVED__` internal action: dispatched when a disconnected player times out (default: 5 minutes), permanently removing them from state
|
|
14
|
+
- Race-safe disconnect handling: prevents marking a player as disconnected if they've already reconnected on a new socket
|
|
15
|
+
- Secret validation: malformed session secrets are rejected at the JOIN boundary
|
|
16
|
+
|
|
17
|
+
**Breaking changes:**
|
|
18
|
+
- `state.players` keys are now stable derived player IDs instead of ephemeral socket IDs
|
|
19
|
+
- `playerId` returned by `useGameClient()` and sent in `WELCOME` messages is now a stable identifier that persists across reconnections
|
|
20
|
+
- `secret` field in `JOIN` payload is now required (was optional)
|
|
21
|
+
|
|
22
|
+
**Security:**
|
|
23
|
+
- Raw session secret is never stored on `IPlayer` or broadcast to clients
|
|
24
|
+
- Only the derived public player ID is shared in game state
|
|
25
|
+
|
|
3
26
|
## 0.4.0
|
|
4
27
|
|
|
5
28
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -21,6 +21,8 @@ A higher-order reducer that wraps your game reducer with automatic handling of i
|
|
|
21
21
|
- `__HYDRATE__` -- Replaces state wholesale (used for server-to-client state sync).
|
|
22
22
|
- `__PLAYER_JOINED__` -- Adds a player to `state.players`.
|
|
23
23
|
- `__PLAYER_LEFT__` -- Marks a player as disconnected in `state.players`.
|
|
24
|
+
- `__PLAYER_RECONNECTED__` -- Dispatched when a returning player reconnects with a valid session. Sets `connected: true` and preserves all existing player data.
|
|
25
|
+
- `__PLAYER_REMOVED__` -- Dispatched when a disconnected player times out (default: 5 minutes). Permanently removes the player from `state.players`.
|
|
24
26
|
|
|
25
27
|
**You do not need to call this yourself.** Both `GameHostProvider` and `useGameClient` wrap your reducer automatically. Just write a plain reducer that handles your own action types:
|
|
26
28
|
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
var InternalActionTypes = {
|
|
3
3
|
HYDRATE: "__HYDRATE__",
|
|
4
4
|
PLAYER_JOINED: "__PLAYER_JOINED__",
|
|
5
|
-
PLAYER_LEFT: "__PLAYER_LEFT__"
|
|
5
|
+
PLAYER_LEFT: "__PLAYER_LEFT__",
|
|
6
|
+
PLAYER_RECONNECTED: "__PLAYER_RECONNECTED__",
|
|
7
|
+
PLAYER_REMOVED: "__PLAYER_REMOVED__"
|
|
6
8
|
};
|
|
7
9
|
function createGameReducer(reducer) {
|
|
8
10
|
return (state, action) => {
|
|
@@ -38,6 +40,29 @@ function createGameReducer(reducer) {
|
|
|
38
40
|
}
|
|
39
41
|
};
|
|
40
42
|
}
|
|
43
|
+
case InternalActionTypes.PLAYER_RECONNECTED: {
|
|
44
|
+
const { playerId } = action.payload;
|
|
45
|
+
const player = state.players[playerId];
|
|
46
|
+
if (!player)
|
|
47
|
+
return state;
|
|
48
|
+
return {
|
|
49
|
+
...state,
|
|
50
|
+
players: {
|
|
51
|
+
...state.players,
|
|
52
|
+
[playerId]: { ...player, connected: true }
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
case InternalActionTypes.PLAYER_REMOVED: {
|
|
57
|
+
const { playerId } = action.payload;
|
|
58
|
+
if (!state.players[playerId])
|
|
59
|
+
return state;
|
|
60
|
+
const { [playerId]: _, ...remainingPlayers } = state.players;
|
|
61
|
+
return {
|
|
62
|
+
...state,
|
|
63
|
+
players: remainingPlayers
|
|
64
|
+
};
|
|
65
|
+
}
|
|
41
66
|
default:
|
|
42
67
|
return reducer(state, action);
|
|
43
68
|
}
|
|
@@ -79,6 +104,13 @@ function generateId() {
|
|
|
79
104
|
const b = Math.random().toString(36).substring(2, 10);
|
|
80
105
|
return a + b;
|
|
81
106
|
}
|
|
107
|
+
function isValidSecret(secret) {
|
|
108
|
+
const hex = secret.replace(/-/g, "");
|
|
109
|
+
return hex.length >= 32 && /^[0-9a-f]+$/i.test(hex);
|
|
110
|
+
}
|
|
111
|
+
function derivePlayerId(secret) {
|
|
112
|
+
return secret.replace(/-/g, "").slice(0, 16);
|
|
113
|
+
}
|
|
82
114
|
function toErrorMessage(error) {
|
|
83
115
|
if (error instanceof Error)
|
|
84
116
|
return error.message;
|
|
@@ -86,7 +118,9 @@ function toErrorMessage(error) {
|
|
|
86
118
|
}
|
|
87
119
|
export {
|
|
88
120
|
toErrorMessage,
|
|
121
|
+
isValidSecret,
|
|
89
122
|
generateId,
|
|
123
|
+
derivePlayerId,
|
|
90
124
|
createGameReducer,
|
|
91
125
|
MessageTypes,
|
|
92
126
|
MAX_PENDING_PINGS,
|
package/lib/constants.d.ts
CHANGED
|
@@ -26,6 +26,18 @@ export declare const KEEPALIVE_TIMEOUT = 10000;
|
|
|
26
26
|
* falling back to `crypto.getRandomValues()` for older environments.
|
|
27
27
|
*/
|
|
28
28
|
export declare function generateId(): string;
|
|
29
|
+
/**
|
|
30
|
+
* Validates that a string looks like a UUID (with or without dashes, 32+ hex chars).
|
|
31
|
+
*/
|
|
32
|
+
export declare function isValidSecret(secret: string): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Derives a stable, public player ID from a secret UUID.
|
|
35
|
+
*
|
|
36
|
+
* Strips dashes and takes the first 16 hex characters. This is NOT a
|
|
37
|
+
* cryptographic hash — it simply avoids exposing the raw secret in
|
|
38
|
+
* state that gets broadcast to all clients.
|
|
39
|
+
*/
|
|
40
|
+
export declare function derivePlayerId(secret: string): string;
|
|
29
41
|
/**
|
|
30
42
|
* Safely extract an error message from an unknown caught value.
|
|
31
43
|
* In JavaScript, anything can be thrown -- this normalizes it.
|
package/lib/constants.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,wDAAwD;AAExD,oDAAoD;AACpD,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC,4EAA4E;AAC5E,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC,2DAA2D;AAC3D,eAAO,MAAM,cAAc,QAAc,CAAC;AAE1C,4DAA4D;AAC5D,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,oEAAoE;AACpE,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC,4CAA4C;AAC5C,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C,0DAA0D;AAC1D,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,gDAAgD;AAChD,eAAO,MAAM,kBAAkB,QAAQ,CAAC;AAExC,gEAAgE;AAChE,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC;;;;;GAKG;AACH,wBAAgB,UAAU,IAAI,MAAM,CAwBnC;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAGrD"}
|
|
1
|
+
{"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,wDAAwD;AAExD,oDAAoD;AACpD,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC,4EAA4E;AAC5E,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC,2DAA2D;AAC3D,eAAO,MAAM,cAAc,QAAc,CAAC;AAE1C,4DAA4D;AAC5D,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAErC,oEAAoE;AACpE,eAAO,MAAM,kBAAkB,OAAO,CAAC;AAEvC,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC,4CAA4C;AAC5C,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C,0DAA0D;AAC1D,eAAO,MAAM,iBAAiB,KAAK,CAAC;AAEpC,gDAAgD;AAChD,eAAO,MAAM,kBAAkB,QAAQ,CAAC;AAExC,gEAAgE;AAChE,eAAO,MAAM,iBAAiB,QAAQ,CAAC;AAEvC;;;;;GAKG;AACH,wBAAgB,UAAU,IAAI,MAAM,CAwBnC;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAGrD;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAErD;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAGrD"}
|
package/lib/protocol.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type ClientMessage = {
|
|
|
3
3
|
payload: {
|
|
4
4
|
name: string;
|
|
5
5
|
avatar?: string;
|
|
6
|
-
secret
|
|
6
|
+
secret: string;
|
|
7
7
|
};
|
|
8
8
|
} | {
|
|
9
9
|
type: "ACTION";
|
|
@@ -45,6 +45,7 @@ export type HostMessage = {
|
|
|
45
45
|
} | {
|
|
46
46
|
type: "RECONNECTED";
|
|
47
47
|
payload: {
|
|
48
|
+
playerId: string;
|
|
48
49
|
state: unknown;
|
|
49
50
|
};
|
|
50
51
|
} | {
|
package/lib/protocol.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AACA,MAAM,MAAM,aAAa,GACrB;IACE,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,
|
|
1
|
+
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AACA,MAAM,MAAM,aAAa,GACrB;IACE,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CAC5D,GACD;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GAChE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAC5D;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,OAAO,EAAE,IAAI,CAAA;CAAE,CAAC;AAG7C,MAAM,MAAM,WAAW,GACnB;IACE,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;CACnE,GACD;IACE,IAAI,EAAE,cAAc,CAAC;IACrB,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;CACrE,GACD;IACE,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAC;CACpE,GACD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GACtE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAElE,eAAO,MAAM,YAAY;;;;;;;;;;CAaf,CAAC"}
|
package/lib/types.d.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface IAction {
|
|
|
36
36
|
/**
|
|
37
37
|
* Internal actions managed automatically by `createGameReducer`.
|
|
38
38
|
* These are dispatched by the framework -- consumers do not need to handle them.
|
|
39
|
+
*
|
|
40
|
+
* - `__HYDRATE__` -- Replaces state wholesale (server-to-client sync).
|
|
41
|
+
* - `__PLAYER_JOINED__` -- Adds a player to `state.players`.
|
|
42
|
+
* - `__PLAYER_LEFT__` -- Marks a player as disconnected (`connected: false`).
|
|
43
|
+
* - `__PLAYER_RECONNECTED__` -- Restores a returning player (`connected: true`), preserving existing data.
|
|
44
|
+
* - `__PLAYER_REMOVED__` -- Permanently removes a timed-out disconnected player from `state.players`.
|
|
39
45
|
*/
|
|
40
46
|
export type InternalAction<S extends IGameState = IGameState> = {
|
|
41
47
|
type: "__HYDRATE__";
|
|
@@ -46,19 +52,30 @@ export type InternalAction<S extends IGameState = IGameState> = {
|
|
|
46
52
|
id: string;
|
|
47
53
|
name: string;
|
|
48
54
|
avatar?: string;
|
|
49
|
-
secret?: string;
|
|
50
55
|
};
|
|
51
56
|
} | {
|
|
52
57
|
type: "__PLAYER_LEFT__";
|
|
53
58
|
payload: {
|
|
54
59
|
playerId: string;
|
|
55
60
|
};
|
|
61
|
+
} | {
|
|
62
|
+
type: "__PLAYER_RECONNECTED__";
|
|
63
|
+
payload: {
|
|
64
|
+
playerId: string;
|
|
65
|
+
};
|
|
66
|
+
} | {
|
|
67
|
+
type: "__PLAYER_REMOVED__";
|
|
68
|
+
payload: {
|
|
69
|
+
playerId: string;
|
|
70
|
+
};
|
|
56
71
|
};
|
|
57
72
|
/** Well-known internal action type strings. */
|
|
58
73
|
export declare const InternalActionTypes: {
|
|
59
74
|
readonly HYDRATE: "__HYDRATE__";
|
|
60
75
|
readonly PLAYER_JOINED: "__PLAYER_JOINED__";
|
|
61
76
|
readonly PLAYER_LEFT: "__PLAYER_LEFT__";
|
|
77
|
+
readonly PLAYER_RECONNECTED: "__PLAYER_RECONNECTED__";
|
|
78
|
+
readonly PLAYER_REMOVED: "__PLAYER_REMOVED__";
|
|
62
79
|
};
|
|
63
80
|
/**
|
|
64
81
|
* Type alias for a game reducer function.
|
|
@@ -73,6 +90,8 @@ export type GameReducer<S extends IGameState, A extends IAction> = (state: S, ac
|
|
|
73
90
|
* - `__HYDRATE__` -- Replaces state wholesale (used for server→client state sync).
|
|
74
91
|
* - `__PLAYER_JOINED__` -- Adds a player to `state.players`.
|
|
75
92
|
* - `__PLAYER_LEFT__` -- Marks a player as disconnected in `state.players`.
|
|
93
|
+
* - `__PLAYER_RECONNECTED__` -- Restores a returning player's connection status, preserving all existing data.
|
|
94
|
+
* - `__PLAYER_REMOVED__` -- Permanently removes a timed-out disconnected player from `state.players`.
|
|
76
95
|
*
|
|
77
96
|
* The wrapped reducer accepts both `A` (user actions) and `InternalAction<S>`.
|
|
78
97
|
* User reducers only need to handle their own action types.
|
package/lib/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,OAAO,CAAC;IAChB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;GAKG;AACH,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED;;;;;GAKG;AACH,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;;;GASG;AACH,MAAM,MAAM,cAAc,CAAC,CAAC,SAAS,UAAU,GAAG,UAAU,IACxD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,CAAC,CAAA;CAAE,GACnC;IACE,IAAI,EAAE,mBAAmB,CAAC;IAC1B,OAAO,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACxD,GACD;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GAC1D;IAAE,IAAI,EAAE,wBAAwB,CAAC;IAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,GACjE;IAAE,IAAI,EAAE,oBAAoB,CAAC;IAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAElE,+CAA+C;AAC/C,eAAO,MAAM,mBAAmB;;;;;;CAMtB,CAAC;AAEX;;;;;GAKG;AACH,MAAM,MAAM,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,IAAI,CACjE,KAAK,EAAE,CAAC,EACR,MAAM,EAAE,CAAC,KACN,CAAC,CAAC;AAEP;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EACvE,OAAO,EAAE,WAAW,CAAC,CAAC,EAAE,CAAC,CAAC,GACzB,WAAW,CAAC,CAAC,EAAE,CAAC,GAAG,cAAc,CAAC,CAAC,CAAC,CAAC,CAuEvC"}
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -62,6 +62,25 @@ export function generateId(): string {
|
|
|
62
62
|
return a + b;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Validates that a string looks like a UUID (with or without dashes, 32+ hex chars).
|
|
67
|
+
*/
|
|
68
|
+
export function isValidSecret(secret: string): boolean {
|
|
69
|
+
const hex = secret.replace(/-/g, "");
|
|
70
|
+
return hex.length >= 32 && /^[0-9a-f]+$/i.test(hex);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Derives a stable, public player ID from a secret UUID.
|
|
75
|
+
*
|
|
76
|
+
* Strips dashes and takes the first 16 hex characters. This is NOT a
|
|
77
|
+
* cryptographic hash — it simply avoids exposing the raw secret in
|
|
78
|
+
* state that gets broadcast to all clients.
|
|
79
|
+
*/
|
|
80
|
+
export function derivePlayerId(secret: string): string {
|
|
81
|
+
return secret.replace(/-/g, "").slice(0, 16);
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
/**
|
|
66
85
|
* Safely extract an error message from an unknown caught value.
|
|
67
86
|
* In JavaScript, anything can be thrown -- this normalizes it.
|
package/src/protocol.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export type ClientMessage =
|
|
3
3
|
| {
|
|
4
4
|
type: "JOIN";
|
|
5
|
-
payload: { name: string; avatar?: string; secret
|
|
5
|
+
payload: { name: string; avatar?: string; secret: string };
|
|
6
6
|
}
|
|
7
7
|
| { type: "ACTION"; payload: { type: string; payload?: unknown } }
|
|
8
8
|
| { type: "PING"; payload: { id: string; timestamp: number } }
|
|
@@ -22,7 +22,7 @@ export type HostMessage =
|
|
|
22
22
|
type: "PONG";
|
|
23
23
|
payload: { id: string; origTimestamp: number; serverTime: number };
|
|
24
24
|
}
|
|
25
|
-
| { type: "RECONNECTED"; payload: { state: unknown } }
|
|
25
|
+
| { type: "RECONNECTED"; payload: { playerId: string; state: unknown } }
|
|
26
26
|
| { type: "ERROR"; payload: { code: string; message: string } };
|
|
27
27
|
|
|
28
28
|
export const MessageTypes = {
|
package/src/types.ts
CHANGED
|
@@ -39,20 +39,30 @@ export interface IAction {
|
|
|
39
39
|
/**
|
|
40
40
|
* Internal actions managed automatically by `createGameReducer`.
|
|
41
41
|
* These are dispatched by the framework -- consumers do not need to handle them.
|
|
42
|
+
*
|
|
43
|
+
* - `__HYDRATE__` -- Replaces state wholesale (server-to-client sync).
|
|
44
|
+
* - `__PLAYER_JOINED__` -- Adds a player to `state.players`.
|
|
45
|
+
* - `__PLAYER_LEFT__` -- Marks a player as disconnected (`connected: false`).
|
|
46
|
+
* - `__PLAYER_RECONNECTED__` -- Restores a returning player (`connected: true`), preserving existing data.
|
|
47
|
+
* - `__PLAYER_REMOVED__` -- Permanently removes a timed-out disconnected player from `state.players`.
|
|
42
48
|
*/
|
|
43
49
|
export type InternalAction<S extends IGameState = IGameState> =
|
|
44
50
|
| { type: "__HYDRATE__"; payload: S }
|
|
45
51
|
| {
|
|
46
52
|
type: "__PLAYER_JOINED__";
|
|
47
|
-
payload: { id: string; name: string; avatar?: string
|
|
53
|
+
payload: { id: string; name: string; avatar?: string };
|
|
48
54
|
}
|
|
49
|
-
| { type: "__PLAYER_LEFT__"; payload: { playerId: string } }
|
|
55
|
+
| { type: "__PLAYER_LEFT__"; payload: { playerId: string } }
|
|
56
|
+
| { type: "__PLAYER_RECONNECTED__"; payload: { playerId: string } }
|
|
57
|
+
| { type: "__PLAYER_REMOVED__"; payload: { playerId: string } };
|
|
50
58
|
|
|
51
59
|
/** Well-known internal action type strings. */
|
|
52
60
|
export const InternalActionTypes = {
|
|
53
61
|
HYDRATE: "__HYDRATE__",
|
|
54
62
|
PLAYER_JOINED: "__PLAYER_JOINED__",
|
|
55
63
|
PLAYER_LEFT: "__PLAYER_LEFT__",
|
|
64
|
+
PLAYER_RECONNECTED: "__PLAYER_RECONNECTED__",
|
|
65
|
+
PLAYER_REMOVED: "__PLAYER_REMOVED__",
|
|
56
66
|
} as const;
|
|
57
67
|
|
|
58
68
|
/**
|
|
@@ -72,6 +82,8 @@ export type GameReducer<S extends IGameState, A extends IAction> = (
|
|
|
72
82
|
* - `__HYDRATE__` -- Replaces state wholesale (used for server→client state sync).
|
|
73
83
|
* - `__PLAYER_JOINED__` -- Adds a player to `state.players`.
|
|
74
84
|
* - `__PLAYER_LEFT__` -- Marks a player as disconnected in `state.players`.
|
|
85
|
+
* - `__PLAYER_RECONNECTED__` -- Restores a returning player's connection status, preserving all existing data.
|
|
86
|
+
* - `__PLAYER_REMOVED__` -- Permanently removes a timed-out disconnected player from `state.players`.
|
|
75
87
|
*
|
|
76
88
|
* The wrapped reducer accepts both `A` (user actions) and `InternalAction<S>`.
|
|
77
89
|
* User reducers only need to handle their own action types.
|
|
@@ -118,6 +130,33 @@ export function createGameReducer<S extends IGameState, A extends IAction>(
|
|
|
118
130
|
};
|
|
119
131
|
}
|
|
120
132
|
|
|
133
|
+
case InternalActionTypes.PLAYER_RECONNECTED: {
|
|
134
|
+
const { playerId } = (
|
|
135
|
+
action as InternalAction<S> & { type: "__PLAYER_RECONNECTED__" }
|
|
136
|
+
).payload;
|
|
137
|
+
const player = state.players[playerId];
|
|
138
|
+
if (!player) return state;
|
|
139
|
+
return {
|
|
140
|
+
...state,
|
|
141
|
+
players: {
|
|
142
|
+
...state.players,
|
|
143
|
+
[playerId]: { ...player, connected: true },
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case InternalActionTypes.PLAYER_REMOVED: {
|
|
149
|
+
const { playerId } = (
|
|
150
|
+
action as InternalAction<S> & { type: "__PLAYER_REMOVED__" }
|
|
151
|
+
).payload;
|
|
152
|
+
if (!state.players[playerId]) return state;
|
|
153
|
+
const { [playerId]: _, ...remainingPlayers } = state.players;
|
|
154
|
+
return {
|
|
155
|
+
...state,
|
|
156
|
+
players: remainingPlayers,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
121
160
|
default:
|
|
122
161
|
return reducer(state, action as A);
|
|
123
162
|
}
|