p2p-lockstep-kit-session 0.1.2 → 0.1.4

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.
@@ -0,0 +1,1203 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
3
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
4
+
5
+ // session/commandBus.ts
6
+ var CommandBus = class {
7
+ constructor() {
8
+ __publicField(this, "handlers", {});
9
+ __publicField(this, "processingQueue", Promise.resolve());
10
+ }
11
+ emit(type, payload, from = "local") {
12
+ this.dispatch({ type, payload, from });
13
+ }
14
+ register(type, handler) {
15
+ this.handlers[type] = handler;
16
+ }
17
+ dispatch(message) {
18
+ this.processingQueue = this.processingQueue.then(async () => {
19
+ const handler = this.handlers[message.type];
20
+ if (handler) {
21
+ try {
22
+ await handler(message);
23
+ } catch (err) {
24
+ console.error(`[CommandBus] Error in ${message.type}:`, err);
25
+ }
26
+ }
27
+ });
28
+ }
29
+ };
30
+
31
+ // session/state/fsm.ts
32
+ var transitions = [
33
+ // Lobby readiness
34
+ { from: "idle", event: "READY", to: "ready" },
35
+ { from: "ready", event: "READY", to: "idle" },
36
+ { from: "idle", event: "REMOTE_READY", to: "could_start" },
37
+ { from: "could_start", event: "REMOTE_READY", to: "idle" },
38
+ { from: "ready", event: "REJECT", to: "idle" },
39
+ { from: "could_start", event: "REJECT", to: "idle" },
40
+ // Match start / turn assignment
41
+ { from: "ready", event: "REMOTE_START", to: "turn" },
42
+ { from: "ready", event: "REMOTE_START", to: "remote_turn" },
43
+ { from: "could_start", event: "START", to: "turn" },
44
+ { from: "could_start", event: "START", to: "remote_turn" },
45
+ // Turn swapping after moves
46
+ { from: "turn", event: "MOVE", to: "remote_turn" },
47
+ { from: "remote_turn", event: "REMOTE_MOVE", to: "turn" },
48
+ { from: "turn", event: "REJECT", to: "turn" },
49
+ { from: "remote_turn", event: "REJECT", to: "remote_turn" },
50
+ // Requests initiated by local player (undo/restart)
51
+ { from: "turn", event: "UNDO", to: "waiting_approval" },
52
+ { from: "remote_turn", event: "UNDO", to: "waiting_approval" },
53
+ { from: "turn", event: "RESTART", to: "waiting_approval" },
54
+ { from: "remote_turn", event: "RESTART", to: "waiting_approval" },
55
+ // Requests coming from remote (we need to approve)
56
+ { from: "turn", event: "REMOTE_UNDO", to: "approving" },
57
+ { from: "remote_turn", event: "REMOTE_UNDO", to: "approving" },
58
+ { from: "turn", event: "REMOTE_RESTART", to: "approving" },
59
+ { from: "remote_turn", event: "REMOTE_RESTART", to: "approving" },
60
+ // Approval outcomes when we were waiting
61
+ { from: "waiting_approval", event: "APPROVE", to: "turn" },
62
+ { from: "waiting_approval", event: "REJECT", to: "turn" },
63
+ { from: "waiting_approval", event: "REJECT", to: "remote_turn" },
64
+ // Approval outcomes when we were confirming
65
+ { from: "approving", event: "APPROVE", to: "remote_turn" },
66
+ { from: "approving", event: "REJECT", to: "remote_turn" },
67
+ { from: "approving", event: "REJECT", to: "turn" },
68
+ // Game end resets back to lobby idle
69
+ { from: "turn", event: "GAME_OVER", to: "idle" },
70
+ { from: "remote_turn", event: "GAME_OVER", to: "idle" },
71
+ // Rejoin/sync flows
72
+ { from: "turn", event: "SYNC", to: "syncing" },
73
+ { from: "remote_turn", event: "SYNC", to: "syncing" },
74
+ { from: "waiting_approval", event: "SYNC", to: "syncing" },
75
+ { from: "approving", event: "SYNC", to: "syncing" },
76
+ { from: "idle", event: "SYNC", to: "syncing" },
77
+ { from: "ready", event: "SYNC", to: "syncing" },
78
+ { from: "could_start", event: "SYNC", to: "syncing" },
79
+ { from: "syncing", event: "SYNC_COMPLETE", to: "turn" },
80
+ { from: "syncing", event: "SYNC_COMPLETE", to: "remote_turn" },
81
+ // Connection state
82
+ { from: "idle", event: "OFFLINE", to: "offline" },
83
+ { from: "ready", event: "OFFLINE", to: "offline" },
84
+ { from: "could_start", event: "OFFLINE", to: "offline" },
85
+ { from: "turn", event: "OFFLINE", to: "offline" },
86
+ { from: "remote_turn", event: "OFFLINE", to: "offline" },
87
+ { from: "waiting_approval", event: "OFFLINE", to: "offline" },
88
+ { from: "approving", event: "OFFLINE", to: "offline" },
89
+ { from: "syncing", event: "OFFLINE", to: "offline" },
90
+ { from: "offline", event: "ONLINE", to: "syncing" }
91
+ ];
92
+ var nextState = (state, event, to) => {
93
+ if (to) {
94
+ if (!!transitions.find(
95
+ (t) => t.from === state && t.event === event && t.to === to
96
+ )) {
97
+ return to;
98
+ } else {
99
+ return state;
100
+ }
101
+ } else {
102
+ const hit = transitions.find((t) => t.from === state && t.event === event);
103
+ return hit ? hit.to : state;
104
+ }
105
+ };
106
+ var hasNextState = (state, action, to) => {
107
+ if (to) {
108
+ return !!transitions.find(
109
+ (t) => t.from === state && t.event === action && t.to === to
110
+ );
111
+ }
112
+ return !!transitions.find((t) => t.from === state && t.event === action);
113
+ };
114
+ var SessionFsm = class {
115
+ constructor(state = "idle") {
116
+ __publicField(this, "state");
117
+ this.state = state;
118
+ }
119
+ getState() {
120
+ return this.state;
121
+ }
122
+ hasNextState(event, to) {
123
+ return hasNextState(this.state, event, to);
124
+ }
125
+ dispatch(action, to) {
126
+ this.state = nextState(this.state, action, to);
127
+ }
128
+ };
129
+
130
+ // session/observer/index.ts
131
+ var DefaultGamePlugin = class {
132
+ validateMove() {
133
+ return { valid: true };
134
+ }
135
+ checkWin() {
136
+ return null;
137
+ }
138
+ };
139
+ var StateObserverManager = class {
140
+ constructor() {
141
+ __publicField(this, "observers", /* @__PURE__ */ new Set());
142
+ }
143
+ subscribe(observer) {
144
+ this.observers.add(observer);
145
+ }
146
+ unsubscribe(observer) {
147
+ this.observers.delete(observer);
148
+ }
149
+ notifyStateChanged() {
150
+ for (const observer of this.observers) {
151
+ try {
152
+ observer.onStateChanged?.();
153
+ } catch (err) {
154
+ console.error("[StateObserver]", err);
155
+ }
156
+ }
157
+ }
158
+ notifyHistoryChanged() {
159
+ for (const observer of this.observers) {
160
+ try {
161
+ observer.onHistoryChanged?.();
162
+ } catch (err) {
163
+ console.error("[StateObserver]", err);
164
+ }
165
+ }
166
+ }
167
+ notifyGameReset() {
168
+ for (const observer of this.observers) {
169
+ try {
170
+ observer.onGameReset?.();
171
+ } catch (err) {
172
+ console.error("[StateObserver]", err);
173
+ }
174
+ }
175
+ }
176
+ };
177
+ var GameStateObserver = class {
178
+ constructor() {
179
+ __publicField(this, "observers", /* @__PURE__ */ new Set());
180
+ __publicField(this, "currentSnapshot", null);
181
+ }
182
+ subscribe(observer) {
183
+ this.observers.add(observer);
184
+ return () => {
185
+ this.observers.delete(observer);
186
+ };
187
+ }
188
+ unsubscribe(observer) {
189
+ this.observers.delete(observer);
190
+ }
191
+ notifyStateChange(snapshot) {
192
+ this.currentSnapshot = snapshot;
193
+ for (const observer of this.observers) {
194
+ try {
195
+ observer.onStateChange(snapshot);
196
+ } catch (err) {
197
+ console.error("[GameStateObserver]", err);
198
+ }
199
+ }
200
+ }
201
+ notifyGameEvent(event) {
202
+ event.timestamp = Date.now();
203
+ for (const observer of this.observers) {
204
+ try {
205
+ observer.onGameEvent(event);
206
+ } catch (err) {
207
+ console.error("[GameStateObserver]", err);
208
+ }
209
+ }
210
+ }
211
+ notifyConnectionChange(connected) {
212
+ for (const observer of this.observers) {
213
+ try {
214
+ observer.onConnectionChange?.(connected);
215
+ } catch (err) {
216
+ console.error("[GameStateObserver]", err);
217
+ }
218
+ }
219
+ }
220
+ notifyError(error) {
221
+ for (const observer of this.observers) {
222
+ try {
223
+ observer.onError?.(error);
224
+ } catch (err) {
225
+ console.error("[GameStateObserver]", err);
226
+ }
227
+ }
228
+ }
229
+ getSnapshot() {
230
+ return this.currentSnapshot;
231
+ }
232
+ getObserverCount() {
233
+ return this.observers.size;
234
+ }
235
+ };
236
+ function buildGameStateSnapshot(state, connected = false) {
237
+ return {
238
+ localState: state.getState("local"),
239
+ remoteState: state.getState("remote"),
240
+ turn: state.getTurnCount(),
241
+ history: state.getHistory(),
242
+ lastStart: state.getLastStart(),
243
+ pendingAction: state.getPendingAction(),
244
+ connected
245
+ };
246
+ }
247
+ var UINotificationAdapter = class {
248
+ constructor(stateRef, uiObserver, getConnected = () => false) {
249
+ __publicField(this, "stateRef", stateRef);
250
+ __publicField(this, "uiObserver", uiObserver);
251
+ __publicField(this, "getConnected", getConnected);
252
+ __publicField(this, "lastNotificationTime", 0);
253
+ __publicField(this, "notificationThrottleMs", 0);
254
+ }
255
+ onStateChanged() {
256
+ const now = Date.now();
257
+ if (this.lastNotificationTime + this.notificationThrottleMs > now) return;
258
+ this.lastNotificationTime = now;
259
+ const snapshot = buildGameStateSnapshot(this.stateRef, this.getConnected());
260
+ this.uiObserver.notifyStateChange(snapshot);
261
+ }
262
+ onHistoryChanged() {
263
+ }
264
+ onGameReset() {
265
+ }
266
+ emitEvent(event) {
267
+ this.uiObserver.notifyGameEvent(event);
268
+ }
269
+ };
270
+
271
+ // session/state/state.ts
272
+ var State = class {
273
+ constructor(id, remoteId) {
274
+ // will update map when multi-players (>=3)
275
+ __publicField(this, "local", new SessionFsm("idle"));
276
+ __publicField(this, "remote", new SessionFsm("idle"));
277
+ // for compare remote is same people or not
278
+ __publicField(this, "localId", null);
279
+ __publicField(this, "remoteId", null);
280
+ // store all actions
281
+ __publicField(this, "history", []);
282
+ // pending some state
283
+ __publicField(this, "pendingAction", null);
284
+ __publicField(this, "pendingUndoCount", null);
285
+ __publicField(this, "resumeTurn", null);
286
+ __publicField(this, "lastStart", null);
287
+ // Game plugin for rule validation and win checking
288
+ __publicField(this, "gamePlugin", new DefaultGamePlugin());
289
+ // Internal state observer for UI notifications
290
+ __publicField(this, "stateObserverManager", new StateObserverManager());
291
+ // ===== Helper Methods for Undo/Restart Request Handling =====
292
+ /**
293
+ * Save game state snapshot for undo/restart operations
294
+ */
295
+ __publicField(this, "gameSnapshot", null);
296
+ if (id) {
297
+ this.localId = id;
298
+ }
299
+ if (remoteId) {
300
+ this.remoteId = remoteId;
301
+ }
302
+ }
303
+ /**
304
+ * Register an internal observer (like plugin pattern)
305
+ * Use this to connect State mutations to UI updates
306
+ */
307
+ subscribeStateObserver(observer) {
308
+ this.stateObserverManager.subscribe(observer);
309
+ }
310
+ // ...existing code...
311
+ getId() {
312
+ return this.localId;
313
+ }
314
+ getremoteId() {
315
+ return this.remoteId;
316
+ }
317
+ setremoteId(id) {
318
+ this.remoteId = id;
319
+ }
320
+ getState(player) {
321
+ return this.getPlayerFsm(player).getState();
322
+ }
323
+ getTurnCount() {
324
+ return this.history.length + 1;
325
+ }
326
+ getHistory() {
327
+ return this.history.slice();
328
+ }
329
+ replaceHistory(entries) {
330
+ this.clearHistory();
331
+ entries.forEach((entry) => {
332
+ this.pushHistory({
333
+ turn: entry.turn,
334
+ player: entry.player,
335
+ move: entry.move
336
+ });
337
+ });
338
+ }
339
+ clearHistory() {
340
+ this.history.splice(0, this.history.length);
341
+ this.stateObserverManager.notifyHistoryChanged();
342
+ }
343
+ pushHistory(entry) {
344
+ this.history.push(entry);
345
+ this.stateObserverManager.notifyHistoryChanged();
346
+ }
347
+ popHistory() {
348
+ return this.history.pop() ?? null;
349
+ }
350
+ canAction(player, action) {
351
+ return this.getPlayerFsm(player).hasNextState(action);
352
+ }
353
+ /**
354
+ * Dispatch an action and automatically determine target state if unique
355
+ * Only use explicit 'to' parameter for ambiguous transitions (APPROVE, REJECT, etc.)
356
+ *
357
+ * For most actions (READY, MOVE, START, etc.), there's only one valid transition,
358
+ * so we automatically find and apply it.
359
+ */
360
+ dispatch(player, action, to) {
361
+ this.getPlayerFsm(player).dispatch(action, to);
362
+ this.stateObserverManager.notifyStateChanged();
363
+ }
364
+ setPendingAction(action) {
365
+ this.pendingAction = action;
366
+ }
367
+ getPendingAction() {
368
+ return this.pendingAction;
369
+ }
370
+ setPendingUndoCount(count) {
371
+ this.pendingUndoCount = count;
372
+ }
373
+ getPendingUndoCount() {
374
+ return this.pendingUndoCount;
375
+ }
376
+ setLastStart(player) {
377
+ this.lastStart = player;
378
+ }
379
+ getLastStart() {
380
+ return this.lastStart;
381
+ }
382
+ setResumeTurn(player) {
383
+ this.resumeTurn = player;
384
+ }
385
+ getResumeTurn() {
386
+ return this.resumeTurn;
387
+ }
388
+ getPlayerFsm(player) {
389
+ return player === "local" ? this.local : this.remote;
390
+ }
391
+ saveGameSnapshot(snapshot) {
392
+ this.gameSnapshot = snapshot;
393
+ }
394
+ getGameSnapshot() {
395
+ return this.gameSnapshot;
396
+ }
397
+ clearGameSnapshot() {
398
+ this.gameSnapshot = null;
399
+ }
400
+ /**
401
+ * Check if there's a pending action (undo/restart)
402
+ */
403
+ hasPendingAction() {
404
+ return this.pendingAction !== null;
405
+ }
406
+ /**
407
+ * Clear all pending states (called after approval/rejection)
408
+ */
409
+ clearPendingStates() {
410
+ this.pendingAction = null;
411
+ this.pendingUndoCount = null;
412
+ this.resumeTurn = null;
413
+ }
414
+ /**
415
+ * Initialize undo request with undo count and current turn holder
416
+ */
417
+ initializeUndoRequest(undoCount, resumeTurn) {
418
+ this.pendingAction = "undo";
419
+ this.pendingUndoCount = undoCount;
420
+ this.resumeTurn = resumeTurn;
421
+ }
422
+ /**
423
+ * Initialize restart request with resume turn
424
+ */
425
+ initializeRestartRequest(resumeTurn) {
426
+ this.pendingAction = "restart";
427
+ this.resumeTurn = resumeTurn;
428
+ }
429
+ /**
430
+ * Check if pending action is undo
431
+ */
432
+ isPendingUndo() {
433
+ return this.pendingAction === "undo";
434
+ }
435
+ /**
436
+ * Check if pending action is restart
437
+ */
438
+ isPendingRestart() {
439
+ return this.pendingAction === "restart";
440
+ }
441
+ /**
442
+ * Apply undo by popping history N times
443
+ */
444
+ applyUndo(count = 1) {
445
+ for (let i = 0; i < count; i++) {
446
+ this.popHistory();
447
+ }
448
+ }
449
+ /**
450
+ * Reset game state to initial (for restart)
451
+ */
452
+ resetGame() {
453
+ this.clearHistory();
454
+ this.local = new SessionFsm("idle");
455
+ this.remote = new SessionFsm("idle");
456
+ this.lastStart = null;
457
+ this.resumeTurn = null;
458
+ this.stateObserverManager.notifyGameReset();
459
+ }
460
+ /**
461
+ * Save start player for rejoin flow
462
+ */
463
+ recordStartPlayer(player) {
464
+ this.lastStart = player;
465
+ }
466
+ /**
467
+ * Get move to undo from history
468
+ */
469
+ getLastMove() {
470
+ return this.history.length > 0 ? this.history[this.history.length - 1] : null;
471
+ }
472
+ // ===== Specialized FSM Dispatch Methods =====
473
+ /**
474
+ * Dispatch APPROVE action with automatic target state resolution
475
+ * Multiple valid transitions exist - use state context to determine target
476
+ */
477
+ dispatchApprove() {
478
+ const localState = this.local.getState();
479
+ if (localState === "waiting_approval") {
480
+ this.local.dispatch("APPROVE", "turn");
481
+ this.remote.dispatch("APPROVE", "turn");
482
+ } else if (localState === "approving") {
483
+ this.local.dispatch("APPROVE", "remote_turn");
484
+ this.remote.dispatch("APPROVE", "remote_turn");
485
+ }
486
+ }
487
+ /**
488
+ * Dispatch REJECT action with automatic target state resolution
489
+ * Multiple valid transitions exist - use resumeTurn to determine who continues
490
+ */
491
+ dispatchReject() {
492
+ const localState = this.local.getState();
493
+ if (localState === "waiting_approval" || localState === "approving") {
494
+ const targetState = this.resumeTurn === "local" ? "turn" : "remote_turn";
495
+ this.local.dispatch("REJECT", targetState);
496
+ this.remote.dispatch("REJECT", targetState);
497
+ }
498
+ }
499
+ /**
500
+ * Dispatch START action with automatic target state resolution
501
+ * Determines who plays first based on starter parameter
502
+ */
503
+ dispatchStart(firstPlayer) {
504
+ if (firstPlayer === "local") {
505
+ this.local.dispatch("START", "turn");
506
+ this.remote.dispatch("START", "remote_turn");
507
+ this.lastStart = "local";
508
+ } else {
509
+ this.local.dispatch("START", "remote_turn");
510
+ this.remote.dispatch("START", "turn");
511
+ this.lastStart = "remote";
512
+ }
513
+ }
514
+ /**
515
+ * Dispatch SYNC_COMPLETE with automatic target state resolution
516
+ * Based on who should have the turn after sync
517
+ */
518
+ dispatchSyncComplete(nextPlayer) {
519
+ if (nextPlayer === "local") {
520
+ this.local.dispatch("SYNC_COMPLETE", "turn");
521
+ this.remote.dispatch("SYNC_COMPLETE", "remote_turn");
522
+ } else {
523
+ this.local.dispatch("SYNC_COMPLETE", "remote_turn");
524
+ this.remote.dispatch("SYNC_COMPLETE", "turn");
525
+ }
526
+ this.resumeTurn = nextPlayer;
527
+ }
528
+ // ===== Game Plugin Integration (Proxy Pattern) =====
529
+ /**
530
+ * Set the game plugin for rule validation and win checking
531
+ * @param plugin Implementation of IGamePlugin
532
+ */
533
+ setGamePlugin(plugin) {
534
+ this.gamePlugin = plugin;
535
+ if (plugin.initialize) {
536
+ plugin.initialize();
537
+ }
538
+ }
539
+ /**
540
+ * Get current game plugin
541
+ */
542
+ getGamePlugin() {
543
+ return this.gamePlugin;
544
+ }
545
+ /**
546
+ * Validate a move using the game plugin
547
+ * Called by move handler to check if move is legal
548
+ * @param move The move data to validate
549
+ * @returns Validation result with reason if invalid
550
+ */
551
+ validateMove(move2) {
552
+ const gameState = this.buildGameState();
553
+ return this.gamePlugin.validateMove(move2, gameState);
554
+ }
555
+ /**
556
+ * Check if game has ended (someone won)
557
+ * Called by move handler after move is applied
558
+ * @returns Winner (local/remote) or null if game continues
559
+ */
560
+ checkWin() {
561
+ const gameState = this.buildGameState();
562
+ return this.gamePlugin.checkWin(gameState, this.getHistory());
563
+ }
564
+ /**
565
+ * Cleanup when game ends (for plugin to reset internal state)
566
+ */
567
+ cleanupGame() {
568
+ if (this.gamePlugin.cleanup) {
569
+ this.gamePlugin.cleanup();
570
+ }
571
+ }
572
+ /**
573
+ * Build game state for plugin
574
+ * @private
575
+ */
576
+ buildGameState() {
577
+ return {
578
+ history: this.getHistory(),
579
+ localState: this.getState("local"),
580
+ remoteState: this.getState("remote"),
581
+ turn: this.getTurnCount(),
582
+ lastStart: this.getLastStart()
583
+ };
584
+ }
585
+ };
586
+
587
+ // session/net.ts
588
+ var NetClient = class {
589
+ constructor(client, bus, peerId) {
590
+ __publicField(this, "client", client);
591
+ __publicField(this, "bus", bus);
592
+ __publicField(this, "localPeerId");
593
+ __publicField(this, "remotePeerId");
594
+ __publicField(this, "isConnected", false);
595
+ __publicField(this, "connectionChangeListener", () => {
596
+ });
597
+ __publicField(this, "mediaStateListener", () => {
598
+ });
599
+ this.localPeerId = peerId ?? null;
600
+ this.remotePeerId = null;
601
+ this.client.onMessage((data) => {
602
+ const message = data;
603
+ if (!message || typeof message !== "object" || !message.type) {
604
+ return;
605
+ }
606
+ this.bus.emit(
607
+ message.type,
608
+ message,
609
+ "remote"
610
+ );
611
+ });
612
+ this.client.onStateChange((state) => {
613
+ const wasConnected = this.isConnected;
614
+ this.isConnected = state === "connected";
615
+ this.connectionChangeListener(this.isConnected);
616
+ if (this.isConnected && !wasConnected) {
617
+ this.bus.emit("ONLINE", void 0, "local");
618
+ } else if (!this.isConnected && wasConnected) {
619
+ this.bus.emit("OFFLINE", void 0, "local");
620
+ }
621
+ });
622
+ this.client.onRemoteStream((stream) => {
623
+ const active = !!stream && stream.getTracks().some((track) => track.readyState === "live");
624
+ this.mediaStateListener(active);
625
+ });
626
+ }
627
+ /**
628
+ * Send a message to the remote peer
629
+ * Drops message if not connected and logs warning
630
+ */
631
+ send(message) {
632
+ if (!this.isConnected) {
633
+ console.warn(
634
+ "[NetClient] Cannot send message: not connected",
635
+ message.type
636
+ );
637
+ return;
638
+ }
639
+ const enriched = {
640
+ ...message,
641
+ from: message.from ?? this.localPeerId ?? ""
642
+ };
643
+ this.client.send(JSON.stringify(enriched));
644
+ }
645
+ /**
646
+ * Update local and remote peer IDs
647
+ */
648
+ setPeerIds(ids) {
649
+ if (ids.local !== void 0) {
650
+ this.localPeerId = ids.local;
651
+ }
652
+ if (ids.remote !== void 0) {
653
+ this.remotePeerId = ids.remote;
654
+ }
655
+ }
656
+ /**
657
+ * Get current peer IDs
658
+ */
659
+ getPeerIds() {
660
+ return { local: this.localPeerId, remote: this.remotePeerId };
661
+ }
662
+ /**
663
+ * Check if currently connected to peer
664
+ */
665
+ getIsConnected() {
666
+ return this.isConnected;
667
+ }
668
+ /**
669
+ * Monitor connection state changes
670
+ * @param handler Called when connection state changes (true=connected, false=disconnected)
671
+ */
672
+ onConnectionChange(handler) {
673
+ this.connectionChangeListener = handler;
674
+ handler(this.isConnected);
675
+ }
676
+ /**
677
+ * Monitor remote media stream state changes
678
+ * @param handler Called when remote media becomes available or unavailable
679
+ */
680
+ onMediaStateChange(handler) {
681
+ this.mediaStateListener = handler;
682
+ }
683
+ };
684
+ var createNetClient = (client, bus, peerId) => new NetClient(client, bus, peerId);
685
+
686
+ // session/context.ts
687
+ var SessionContext = class {
688
+ constructor(state, bus, net, sid) {
689
+ __publicField(this, "state");
690
+ __publicField(this, "bus");
691
+ __publicField(this, "net");
692
+ __publicField(this, "sid");
693
+ this.state = state;
694
+ this.bus = bus;
695
+ this.net = net;
696
+ this.sid = sid;
697
+ }
698
+ getState() {
699
+ return this.state;
700
+ }
701
+ getBus() {
702
+ return this.bus;
703
+ }
704
+ getNet() {
705
+ return this.net;
706
+ }
707
+ getSid() {
708
+ return this.sid;
709
+ }
710
+ };
711
+ var instance = null;
712
+ var initializeContext = (state, bus, net, sid) => {
713
+ instance = new SessionContext(state, bus, net, sid);
714
+ };
715
+ var requireContext = () => {
716
+ if (!instance) {
717
+ throw new Error(
718
+ "[SessionContext] Not initialized. Call initializeContext() first."
719
+ );
720
+ }
721
+ return instance;
722
+ };
723
+ var getState = () => requireContext().getState();
724
+ var getBus = () => requireContext().getBus();
725
+ var getSid = () => requireContext().getSid();
726
+ var send = (message) => requireContext().getNet().send(message);
727
+
728
+ // session/handlers/ready.ts
729
+ var ready = (command) => {
730
+ const state = getState();
731
+ const bus = getBus();
732
+ const localSid = getSid();
733
+ if (command.from === "local") {
734
+ if (!state.canAction("local", "READY")) {
735
+ console.warn("[Ready] Cannot dispatch READY from current state", {
736
+ state: state.getState("local")
737
+ });
738
+ return;
739
+ }
740
+ state.dispatch("local", "READY");
741
+ state.dispatch("remote", "REMOTE_READY");
742
+ const message = {
743
+ type: "READY",
744
+ sid: localSid
745
+ };
746
+ send(message);
747
+ return;
748
+ }
749
+ const remoteSid = command.sid;
750
+ if (!remoteSid || localSid !== remoteSid) {
751
+ console.warn("[Ready] Session ID mismatch", {
752
+ local: localSid,
753
+ remote: remoteSid
754
+ });
755
+ bus.emit("REJECT", { reason: "sid-mismatch" }, "local");
756
+ return;
757
+ }
758
+ if (!state.canAction("remote", "READY")) {
759
+ console.warn("[Ready] Cannot dispatch READY for remote peer", {
760
+ state: state.getState("remote")
761
+ });
762
+ return;
763
+ }
764
+ state.dispatch("remote", "READY");
765
+ state.dispatch("local", "REMOTE_READY");
766
+ };
767
+
768
+ // session/handlers/start.ts
769
+ var getNextStarter = (lastStarter) => {
770
+ if (!lastStarter) {
771
+ return Math.random() < 0.5 ? "local" : "remote";
772
+ }
773
+ return lastStarter === "local" ? "remote" : "local";
774
+ };
775
+ var start = (command) => {
776
+ const state = getState();
777
+ if (command.from === "local") {
778
+ if (!state.canAction("local", "START")) {
779
+ console.warn("[Start] Cannot START from current state", {
780
+ state: state.getState("local")
781
+ });
782
+ return;
783
+ }
784
+ const nextStarter = getNextStarter(state.getLastStart());
785
+ state.dispatchStart(nextStarter);
786
+ send({
787
+ type: "START",
788
+ payload: { starter: nextStarter === "local" ? "sender" : "receiver" }
789
+ });
790
+ return;
791
+ }
792
+ const starterInfo = command.payload?.starter;
793
+ if (!starterInfo) {
794
+ console.warn("[Start] Invalid START message format", { payload: command });
795
+ return;
796
+ }
797
+ if (!state.canAction("local", "REMOTE_START")) {
798
+ console.warn("[Start] Cannot START from current state", {
799
+ state: state.getState("local")
800
+ });
801
+ return;
802
+ }
803
+ const starter = starterInfo === "sender" ? "local" : "remote";
804
+ state.dispatchStart(starter);
805
+ };
806
+
807
+ // session/handlers/move.ts
808
+ var move = (command) => {
809
+ const state = getState();
810
+ const bus = getBus();
811
+ const movePayload = command.payload;
812
+ if (command.from === "local") {
813
+ if (!state.canAction("local", "MOVE")) {
814
+ console.warn("[Move] Cannot MOVE from current state", {
815
+ state: state.getState("local")
816
+ });
817
+ return;
818
+ }
819
+ const validation2 = state.validateMove(movePayload);
820
+ if (!validation2.valid) {
821
+ console.warn("[Move] Move validation failed", {
822
+ reason: validation2.reason,
823
+ move: movePayload
824
+ });
825
+ return;
826
+ }
827
+ state.dispatch("local", "MOVE");
828
+ state.dispatch("remote", "REMOTE_MOVE");
829
+ const turn2 = state.getTurnCount();
830
+ state.pushHistory({
831
+ turn: turn2,
832
+ player: "local",
833
+ move: movePayload
834
+ });
835
+ const winner2 = state.checkWin();
836
+ if (winner2) {
837
+ console.log("[Move] Game over, winner:", winner2);
838
+ bus.emit("GAME_OVER", { winner: winner2, turn: turn2 }, "local");
839
+ send({
840
+ type: "GAME_OVER",
841
+ payload: { winner: winner2, turn: turn2 }
842
+ });
843
+ state.dispatch("local", "GAME_OVER");
844
+ state.dispatch("remote", "GAME_OVER");
845
+ state.cleanupGame();
846
+ return;
847
+ }
848
+ const message = {
849
+ type: "MOVE",
850
+ turn: turn2,
851
+ payload: movePayload
852
+ };
853
+ send(message);
854
+ return;
855
+ }
856
+ if (!state.canAction("remote", "MOVE")) {
857
+ console.warn("[Move] Cannot MOVE for remote player", {
858
+ state: state.getState("remote")
859
+ });
860
+ return;
861
+ }
862
+ const validation = state.validateMove(movePayload);
863
+ if (!validation.valid) {
864
+ console.warn("[Move] Remote move validation failed", {
865
+ reason: validation.reason,
866
+ move: movePayload
867
+ });
868
+ return;
869
+ }
870
+ state.dispatch("remote", "MOVE");
871
+ state.dispatch("local", "REMOTE_MOVE");
872
+ const turn = state.getTurnCount();
873
+ state.pushHistory({
874
+ turn,
875
+ player: "remote",
876
+ move: movePayload
877
+ });
878
+ const winner = state.checkWin();
879
+ if (winner) {
880
+ console.log("[Move] Game over, winner:", winner);
881
+ bus.emit("GAME_OVER", { winner, turn }, "local");
882
+ state.dispatch("local", "GAME_OVER");
883
+ state.dispatch("remote", "GAME_OVER");
884
+ state.cleanupGame();
885
+ }
886
+ };
887
+
888
+ // session/handlers/request.ts
889
+ var request = (command) => {
890
+ if (command.type !== "APPROVE" && command.type !== "REJECT") {
891
+ return;
892
+ }
893
+ const state = getState();
894
+ const action = state.getPendingAction();
895
+ if (!action) {
896
+ console.warn("[Request] No pending action", { commandType: command.type });
897
+ return;
898
+ }
899
+ const payload = command.payload;
900
+ if (payload?.action && payload.action !== action) {
901
+ console.warn("[Request] Action mismatch", { pending: action, payload: payload.action });
902
+ return;
903
+ }
904
+ if (command.from === "local") {
905
+ if (command.type === "APPROVE") {
906
+ if (!state.canAction("local", "APPROVE")) {
907
+ console.warn("[Request] Cannot APPROVE from current state");
908
+ return;
909
+ }
910
+ state.dispatchApprove();
911
+ if (action === "undo") {
912
+ state.applyUndo(state.getPendingUndoCount() ?? 1);
913
+ } else if (action === "restart") {
914
+ state.resetGame();
915
+ }
916
+ send({ type: "APPROVE", payload: { action } });
917
+ state.clearPendingStates();
918
+ return;
919
+ }
920
+ if (!state.canAction("local", "REJECT")) {
921
+ console.warn("[Request] Cannot REJECT from current state");
922
+ return;
923
+ }
924
+ state.dispatchReject();
925
+ send({
926
+ type: "REJECT",
927
+ payload: { action, reason: payload?.reason ?? "rejected" }
928
+ });
929
+ state.clearPendingStates();
930
+ return;
931
+ }
932
+ if (command.type === "APPROVE") {
933
+ if (!state.canAction("local", "APPROVE")) {
934
+ console.warn("[Request] Cannot APPROVE from current state (remote approved)");
935
+ return;
936
+ }
937
+ state.dispatchApprove();
938
+ if (action === "undo") {
939
+ state.applyUndo(state.getPendingUndoCount() ?? 1);
940
+ } else if (action === "restart") {
941
+ state.resetGame();
942
+ }
943
+ state.clearPendingStates();
944
+ return;
945
+ }
946
+ if (!state.canAction("local", "REJECT")) {
947
+ console.warn("[Request] Cannot REJECT from current state (remote rejected)");
948
+ state.clearPendingStates();
949
+ return;
950
+ }
951
+ state.dispatchReject();
952
+ state.clearPendingStates();
953
+ };
954
+
955
+ // session/handlers/sync.ts
956
+ var sync = (command) => {
957
+ const state = getState();
958
+ if (command.type === "SYNC_REQUEST") {
959
+ if (command.from === "local") {
960
+ if (!state.canAction("local", "SYNC")) {
961
+ console.warn("[Sync] Cannot SYNC from current state");
962
+ return;
963
+ }
964
+ state.dispatch("local", "SYNC", "syncing");
965
+ state.dispatch("remote", "SYNC", "syncing");
966
+ send({ type: "SYNC_REQUEST", from: "", payload: command.payload });
967
+ return;
968
+ }
969
+ const payload2 = {
970
+ history: state.getHistory(),
971
+ lastStart: state.getLastStart(),
972
+ turn: state.getState("local") === "turn" ? "local" : "remote",
973
+ resumeTurn: state.getResumeTurn()
974
+ // Send back the saved resume turn
975
+ };
976
+ send({ type: "SYNC_STATE", from: "", payload: payload2 });
977
+ return;
978
+ }
979
+ if (command.type !== "SYNC_STATE") {
980
+ return;
981
+ }
982
+ const payload = command.payload || {};
983
+ if (payload.history && payload.history.length > 0) {
984
+ state.replaceHistory(payload.history);
985
+ } else {
986
+ state.clearHistory();
987
+ }
988
+ if (payload.lastStart) {
989
+ state.setLastStart(payload.lastStart);
990
+ } else {
991
+ state.setLastStart(null);
992
+ }
993
+ let nextPlayer;
994
+ if (payload.resumeTurn) {
995
+ nextPlayer = payload.resumeTurn;
996
+ } else if (payload.turn) {
997
+ nextPlayer = payload.turn === "local" ? "local" : "remote";
998
+ } else {
999
+ nextPlayer = state.getState("local") === "turn" ? "local" : "remote";
1000
+ }
1001
+ console.log("[Sync] Restored game state", {
1002
+ historyLength: state.getHistory().length,
1003
+ lastStart: state.getLastStart(),
1004
+ nextTurnPlayer: nextPlayer
1005
+ });
1006
+ state.dispatchSyncComplete(nextPlayer);
1007
+ };
1008
+
1009
+ // session/handlers/undo.ts
1010
+ var undo = (command) => {
1011
+ if (command.type !== "UNDO") {
1012
+ return;
1013
+ }
1014
+ const state = getState();
1015
+ if (command.from === "local") {
1016
+ if (state.hasPendingAction()) {
1017
+ return;
1018
+ }
1019
+ if (!state.canAction("local", "UNDO")) {
1020
+ console.warn("[Undo] Cannot UNDO from current state");
1021
+ return;
1022
+ }
1023
+ const localState = state.getState("local");
1024
+ const undoCount = localState === "turn" ? 1 : 2;
1025
+ if (state.getHistory().length < undoCount) {
1026
+ console.warn("[Undo] Not enough history to undo", { count: undoCount });
1027
+ return;
1028
+ }
1029
+ state.initializeUndoRequest(undoCount, "local");
1030
+ state.dispatch("local", "UNDO");
1031
+ state.dispatch("remote", "REMOTE_UNDO");
1032
+ send({ type: "UNDO", payload: { count: undoCount } });
1033
+ return;
1034
+ }
1035
+ if (state.hasPendingAction()) {
1036
+ send({ type: "REJECT", payload: { action: "undo", reason: "busy" } });
1037
+ return;
1038
+ }
1039
+ if (!state.canAction("local", "REMOTE_UNDO")) {
1040
+ console.warn("[Undo] Cannot accept remote UNDO request");
1041
+ send({ type: "REJECT", payload: { action: "undo", reason: "invalid_state" } });
1042
+ return;
1043
+ }
1044
+ const payload = command.payload;
1045
+ const count = payload?.count === 2 ? 2 : 1;
1046
+ if (payload?.count && payload.count !== 1 && payload.count !== 2) {
1047
+ send({ type: "REJECT", payload: { action: "undo", reason: "invalid" } });
1048
+ return;
1049
+ }
1050
+ if (count === 2 && state.getHistory().length < 2) {
1051
+ send({ type: "REJECT", payload: { action: "undo", reason: "no_history" } });
1052
+ return;
1053
+ }
1054
+ const resumePlayer = state.getState("local") === "turn" ? "local" : "remote";
1055
+ state.initializeUndoRequest(count, resumePlayer);
1056
+ state.dispatch("local", "REMOTE_UNDO");
1057
+ state.dispatch("remote", "UNDO");
1058
+ };
1059
+
1060
+ // session/handlers/restart.ts
1061
+ var restart = (command) => {
1062
+ if (command.type !== "RESTART") {
1063
+ return;
1064
+ }
1065
+ const state = getState();
1066
+ if (command.from === "local") {
1067
+ if (state.hasPendingAction()) {
1068
+ return;
1069
+ }
1070
+ if (!state.canAction("local", "RESTART")) {
1071
+ console.warn("[Restart] Cannot RESTART from current state");
1072
+ return;
1073
+ }
1074
+ const resumePlayer2 = state.getState("local") === "turn" ? "local" : "remote";
1075
+ state.initializeRestartRequest(resumePlayer2);
1076
+ state.dispatch("local", "RESTART");
1077
+ state.dispatch("remote", "REMOTE_RESTART");
1078
+ send({ type: "RESTART" });
1079
+ return;
1080
+ }
1081
+ if (state.hasPendingAction()) {
1082
+ send({ type: "REJECT", payload: { action: "restart", reason: "busy" } });
1083
+ return;
1084
+ }
1085
+ if (!state.canAction("local", "REMOTE_RESTART")) {
1086
+ console.warn("[Restart] Cannot accept remote RESTART request");
1087
+ send({ type: "REJECT", payload: { action: "restart", reason: "invalid_state" } });
1088
+ return;
1089
+ }
1090
+ const resumePlayer = state.getState("local") === "turn" ? "local" : "remote";
1091
+ state.initializeRestartRequest(resumePlayer);
1092
+ state.dispatch("local", "REMOTE_RESTART");
1093
+ state.dispatch("remote", "RESTART");
1094
+ };
1095
+
1096
+ // session/handlers/offLine.ts
1097
+ var offline = (command) => {
1098
+ if (command.type !== "OFFLINE" && command.type !== "ONLINE") {
1099
+ return;
1100
+ }
1101
+ const state = getState();
1102
+ const bus = getBus();
1103
+ if (command.type === "OFFLINE") {
1104
+ if (!state.canAction("remote", "OFFLINE")) {
1105
+ console.warn("[Offline] Cannot transition to OFFLINE from current state");
1106
+ return;
1107
+ }
1108
+ const currentTurn = state.getState("local") === "turn" ? "local" : "remote";
1109
+ state.setResumeTurn(currentTurn);
1110
+ state.dispatch("remote", "OFFLINE", "offline");
1111
+ return;
1112
+ }
1113
+ if (!state.canAction("remote", "ONLINE")) {
1114
+ console.warn("[Offline] Cannot transition to ONLINE from current state");
1115
+ return;
1116
+ }
1117
+ state.dispatch("remote", "ONLINE", "syncing");
1118
+ bus.emit("SYNC_REQUEST", void 0, "local");
1119
+ };
1120
+
1121
+ // session/handlers/busRegister.ts
1122
+ var registerHandlers = (bus) => {
1123
+ bus.register("READY", ready);
1124
+ bus.register("START", start);
1125
+ bus.register("MOVE", move);
1126
+ bus.register("UNDO", undo);
1127
+ bus.register("RESTART", restart);
1128
+ bus.register("SYNC_REQUEST", sync);
1129
+ bus.register("SYNC_STATE", sync);
1130
+ bus.register("OFFLINE", offline);
1131
+ bus.register("ONLINE", offline);
1132
+ bus.register("APPROVE", request);
1133
+ bus.register("REJECT", request);
1134
+ };
1135
+
1136
+ // session/actions.ts
1137
+ var LocalActionsAPI = class {
1138
+ constructor(bus) {
1139
+ __publicField(this, "bus", bus);
1140
+ }
1141
+ ready() {
1142
+ this.bus.emit("READY");
1143
+ }
1144
+ start() {
1145
+ this.bus.emit("START");
1146
+ }
1147
+ move(data) {
1148
+ this.bus.emit("MOVE", data);
1149
+ }
1150
+ undo() {
1151
+ this.bus.emit("UNDO");
1152
+ }
1153
+ restart() {
1154
+ this.bus.emit("RESTART");
1155
+ }
1156
+ approve() {
1157
+ this.bus.emit("APPROVE");
1158
+ }
1159
+ reject() {
1160
+ this.bus.emit("REJECT");
1161
+ }
1162
+ rejoin(sid) {
1163
+ this.bus.emit("REJOIN", { sid });
1164
+ }
1165
+ };
1166
+
1167
+ // session/index.ts
1168
+ var createSession = (networkClient, sid) => {
1169
+ const bus = new CommandBus();
1170
+ const state = new State(null, null);
1171
+ const observer = new GameStateObserver();
1172
+ const net = createNetClient(networkClient, bus, null);
1173
+ const uiAdapter = new UINotificationAdapter(
1174
+ state,
1175
+ observer,
1176
+ () => net.getIsConnected()
1177
+ );
1178
+ state.subscribeStateObserver(uiAdapter);
1179
+ initializeContext(state, bus, net, sid);
1180
+ registerHandlers(bus);
1181
+ const actions = new LocalActionsAPI(bus);
1182
+ net.onConnectionChange((isConnected) => {
1183
+ observer.notifyConnectionChange(isConnected);
1184
+ observer.notifyStateChange(buildGameStateSnapshot(state, isConnected));
1185
+ });
1186
+ return {
1187
+ bus,
1188
+ state,
1189
+ observer,
1190
+ net,
1191
+ actions,
1192
+ send: net.send.bind(net)
1193
+ };
1194
+ };
1195
+ export {
1196
+ DefaultGamePlugin,
1197
+ GameStateObserver,
1198
+ StateObserverManager,
1199
+ UINotificationAdapter,
1200
+ buildGameStateSnapshot,
1201
+ createSession
1202
+ };
1203
+ //# sourceMappingURL=index.js.map