@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 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,
@@ -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.
@@ -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?: string;
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
  } | {
@@ -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,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7D,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,KAAK,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GACpD;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"}
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.
@@ -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;;;GAGG;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,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACzE,GACD;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,OAAO,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAA;CAAE,CAAC;AAE/D,+CAA+C;AAC/C,eAAO,MAAM,mBAAmB;;;;CAItB,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;;;;;;;;;GASG;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,CA4CvC"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@couch-kit/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared types, protocol, and game reducer for Couch Kit party games",
5
5
  "license": "MIT",
6
6
  "repository": {
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?: string };
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; secret?: 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
  }