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