@couch-kit/host 1.2.7 → 1.3.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/README.md +5 -1
- package/lib/provider.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/provider.tsx +97 -21
package/README.md
CHANGED
|
@@ -73,12 +73,16 @@ Returns:
|
|
|
73
73
|
|
|
74
74
|
## System Actions
|
|
75
75
|
|
|
76
|
-
The host automatically dispatches internal system actions (`__PLAYER_JOINED__`, `__PLAYER_LEFT__`, `__HYDRATE__`) into `createGameReducer`, which handles them for you. **You do not need to handle these in your reducer.**
|
|
76
|
+
The host automatically dispatches internal system actions (`__PLAYER_JOINED__`, `__PLAYER_LEFT__`, `__PLAYER_RECONNECTED__`, `__PLAYER_REMOVED__`, `__HYDRATE__`) into `createGameReducer`, which handles them for you. **You do not need to handle these in your reducer.**
|
|
77
77
|
|
|
78
78
|
Player tracking (`state.players`) is managed automatically:
|
|
79
79
|
|
|
80
80
|
- When a player joins, they are added to `state.players` with `connected: true`.
|
|
81
81
|
- When a player disconnects, they are marked as `connected: false`.
|
|
82
|
+
- If a player reconnects from the same device/browser, they are automatically reassigned their previous player data via `__PLAYER_RECONNECTED__` (sets `connected: true`, preserves hand, score, etc.).
|
|
83
|
+
- If a disconnected player does not reconnect within the timeout window (default: 5 minutes), they are permanently removed from `state.players` via `__PLAYER_REMOVED__`.
|
|
84
|
+
|
|
85
|
+
Game logic that iterates over `state.players` should account for players being removed after the timeout.
|
|
82
86
|
|
|
83
87
|
To react to player events outside of state (e.g., logging, analytics), use the callback config options:
|
|
84
88
|
|
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;AAGf,OAAO,
|
|
1
|
+
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAGf,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,OAAO,EAGb,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACrE,YAAY,EAAE,CAAC,CAAC;IAChB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACpC,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,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,+CAA+C;IAC/C,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1D,wCAAwC;IACxC,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,yCAAyC;IACzC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;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;CAC3B;AA4CD;;;;;;;;;;;;;;;GAeG;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,qBAsTA;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,KAK/C,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC,CAC7C"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@couch-kit/host",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "React Native host for local multiplayer party games on Android TV — WebSocket server, state management, and static file serving",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"prepublishOnly": "bun run build"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@couch-kit/core": "0.
|
|
54
|
+
"@couch-kit/core": "0.5.0",
|
|
55
55
|
"buffer": "^6.0.3",
|
|
56
56
|
"js-sha1": "^0.7.0",
|
|
57
57
|
"react-native-nitro-http-server": "^1.5.4"
|
package/src/provider.tsx
CHANGED
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
DEFAULT_HTTP_PORT,
|
|
16
16
|
DEFAULT_WS_PORT_OFFSET,
|
|
17
17
|
createGameReducer,
|
|
18
|
+
derivePlayerId,
|
|
19
|
+
isValidSecret,
|
|
18
20
|
type IGameState,
|
|
19
21
|
type IAction,
|
|
20
22
|
type InternalAction,
|
|
@@ -130,12 +132,12 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
130
132
|
if (!wsServer.current) return;
|
|
131
133
|
|
|
132
134
|
const server = wsServer.current;
|
|
133
|
-
for (const [socketId] of pendingWelcome.current) {
|
|
135
|
+
for (const [socketId, playerId] of pendingWelcome.current) {
|
|
134
136
|
welcomedClients.current.add(socketId);
|
|
135
137
|
server.send(socketId, {
|
|
136
138
|
type: MessageTypes.WELCOME,
|
|
137
139
|
payload: {
|
|
138
|
-
playerId
|
|
140
|
+
playerId,
|
|
139
141
|
state,
|
|
140
142
|
serverTime: Date.now(),
|
|
141
143
|
},
|
|
@@ -162,9 +164,17 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
162
164
|
// 2. Start WebSocket Server (Convention: HTTP port + 2, avoids Metro on 8081)
|
|
163
165
|
const wsServer = useRef<GameWebSocketServer | null>(null);
|
|
164
166
|
|
|
165
|
-
// Track active sessions: secret ->
|
|
167
|
+
// Track active sessions: secret -> socketId
|
|
166
168
|
const sessions = useRef<Map<string, string>>(new Map());
|
|
167
169
|
|
|
170
|
+
// Reverse lookup: socketId -> secret (for disconnect resolution)
|
|
171
|
+
const reverseMap = useRef<Map<string, string>>(new Map());
|
|
172
|
+
|
|
173
|
+
// Stale player cleanup timers: playerId -> timer
|
|
174
|
+
const cleanupTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
|
175
|
+
new Map(),
|
|
176
|
+
);
|
|
177
|
+
|
|
168
178
|
// Track socket IDs that have received their WELCOME message
|
|
169
179
|
const welcomedClients = useRef<Set<string>>(new Set());
|
|
170
180
|
|
|
@@ -212,22 +222,51 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
212
222
|
case MessageTypes.JOIN: {
|
|
213
223
|
const { secret, ...payload } = message.payload;
|
|
214
224
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
225
|
+
// Validate secret format
|
|
226
|
+
if (!secret || !isValidSecret(secret)) {
|
|
227
|
+
server.send(socketId, {
|
|
228
|
+
type: MessageTypes.ERROR,
|
|
229
|
+
payload: {
|
|
230
|
+
code: "INVALID_SECRET",
|
|
231
|
+
message: "Invalid or missing session secret",
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
218
235
|
}
|
|
219
236
|
|
|
220
|
-
|
|
221
|
-
dispatch({
|
|
222
|
-
type: InternalActionTypes.PLAYER_JOINED,
|
|
223
|
-
payload: { id: socketId, ...payload },
|
|
224
|
-
} as InternalAction<S>);
|
|
237
|
+
const playerId = derivePlayerId(secret);
|
|
225
238
|
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
239
|
+
// Update session maps
|
|
240
|
+
sessions.current.set(secret, socketId);
|
|
241
|
+
reverseMap.current.set(socketId, secret);
|
|
229
242
|
|
|
230
|
-
|
|
243
|
+
// Cancel any pending cleanup timer for this player
|
|
244
|
+
const existingTimer = cleanupTimers.current.get(playerId);
|
|
245
|
+
if (existingTimer) {
|
|
246
|
+
clearTimeout(existingTimer);
|
|
247
|
+
cleanupTimers.current.delete(playerId);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Check if this is a returning player
|
|
251
|
+
const existingPlayer = stateRef.current.players[playerId];
|
|
252
|
+
if (existingPlayer) {
|
|
253
|
+
// Reconnection — restore existing player
|
|
254
|
+
dispatch({
|
|
255
|
+
type: InternalActionTypes.PLAYER_RECONNECTED,
|
|
256
|
+
payload: { playerId },
|
|
257
|
+
} as InternalAction<S>);
|
|
258
|
+
} else {
|
|
259
|
+
// New player
|
|
260
|
+
dispatch({
|
|
261
|
+
type: InternalActionTypes.PLAYER_JOINED,
|
|
262
|
+
payload: { id: playerId, ...payload },
|
|
263
|
+
} as InternalAction<S>);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Queue WELCOME
|
|
267
|
+
pendingWelcome.current.set(socketId, playerId);
|
|
268
|
+
|
|
269
|
+
configRef.current.onPlayerJoined?.(playerId, payload.name);
|
|
231
270
|
break;
|
|
232
271
|
}
|
|
233
272
|
|
|
@@ -238,7 +277,9 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
238
277
|
if (
|
|
239
278
|
actionPayload.type === InternalActionTypes.HYDRATE ||
|
|
240
279
|
actionPayload.type === InternalActionTypes.PLAYER_JOINED ||
|
|
241
|
-
actionPayload.type === InternalActionTypes.PLAYER_LEFT
|
|
280
|
+
actionPayload.type === InternalActionTypes.PLAYER_LEFT ||
|
|
281
|
+
actionPayload.type === InternalActionTypes.PLAYER_RECONNECTED ||
|
|
282
|
+
actionPayload.type === InternalActionTypes.PLAYER_REMOVED
|
|
242
283
|
) {
|
|
243
284
|
if (configRef.current.debug)
|
|
244
285
|
console.warn(
|
|
@@ -255,7 +296,12 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
255
296
|
});
|
|
256
297
|
return;
|
|
257
298
|
}
|
|
258
|
-
|
|
299
|
+
// Resolve playerId from socketId
|
|
300
|
+
const actionSecret = reverseMap.current.get(socketId);
|
|
301
|
+
const resolvedPlayerId = actionSecret
|
|
302
|
+
? derivePlayerId(actionSecret)
|
|
303
|
+
: undefined;
|
|
304
|
+
dispatch({ ...actionPayload, playerId: resolvedPlayerId });
|
|
259
305
|
break;
|
|
260
306
|
}
|
|
261
307
|
|
|
@@ -278,15 +324,41 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
278
324
|
|
|
279
325
|
welcomedClients.current.delete(socketId);
|
|
280
326
|
|
|
281
|
-
//
|
|
282
|
-
|
|
327
|
+
// Resolve socketId -> secret -> playerId
|
|
328
|
+
const secret = reverseMap.current.get(socketId);
|
|
329
|
+
reverseMap.current.delete(socketId);
|
|
330
|
+
|
|
331
|
+
if (!secret) return; // Unknown socket, nothing to do
|
|
332
|
+
|
|
333
|
+
const playerId = derivePlayerId(secret);
|
|
334
|
+
|
|
335
|
+
// RACE GUARD: Only mark as left if this socket is still the active one for this secret
|
|
336
|
+
if (sessions.current.get(secret) !== socketId) {
|
|
337
|
+
// Player already reconnected on a newer socket — skip
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
283
340
|
|
|
341
|
+
// Mark disconnected (don't remove from sessions — allow reconnect)
|
|
284
342
|
dispatch({
|
|
285
343
|
type: InternalActionTypes.PLAYER_LEFT,
|
|
286
|
-
payload: { playerId
|
|
344
|
+
payload: { playerId },
|
|
287
345
|
} as InternalAction<S>);
|
|
288
346
|
|
|
289
|
-
configRef.current.onPlayerLeft?.(
|
|
347
|
+
configRef.current.onPlayerLeft?.(playerId);
|
|
348
|
+
|
|
349
|
+
// Start stale player cleanup timer (5 minutes default)
|
|
350
|
+
const timer = setTimeout(
|
|
351
|
+
() => {
|
|
352
|
+
cleanupTimers.current.delete(playerId);
|
|
353
|
+
sessions.current.delete(secret);
|
|
354
|
+
dispatch({
|
|
355
|
+
type: InternalActionTypes.PLAYER_REMOVED,
|
|
356
|
+
payload: { playerId },
|
|
357
|
+
} as InternalAction<S>);
|
|
358
|
+
},
|
|
359
|
+
5 * 60 * 1000,
|
|
360
|
+
);
|
|
361
|
+
cleanupTimers.current.set(playerId, timer);
|
|
290
362
|
});
|
|
291
363
|
|
|
292
364
|
server.on("error", (error) => {
|
|
@@ -297,6 +369,10 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
297
369
|
|
|
298
370
|
return () => {
|
|
299
371
|
server.stop();
|
|
372
|
+
for (const timer of cleanupTimers.current.values()) {
|
|
373
|
+
clearTimeout(timer);
|
|
374
|
+
}
|
|
375
|
+
cleanupTimers.current.clear();
|
|
300
376
|
};
|
|
301
377
|
}, []); // Run once on mount
|
|
302
378
|
|