@dotrino/lobby 0.1.5

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/src/room.js ADDED
@@ -0,0 +1,926 @@
1
+ // Room: una partida concreta con autoridad de host (serverless: el host es uno
2
+ // de los jugadores). Encapsula TODO lo que cada juego del ecosistema venía
3
+ // reimplementando a mano: asientos, espectadores, presencia, pausa/reconexión
4
+ // por pubkey, sincronización autoritativa con resync, verificación de identidad
5
+ // del oponente y recibo de partida co-firmado.
6
+ //
7
+ // Un mismo objeto Room sirve para host y guest (branch por this.role). El host
8
+ // muta el estado autoritativo y lo difunde; el guest envía intenciones y refleja
9
+ // lo que llega.
10
+
11
+ import { Emitter, clock, samePubkey } from './util.js'
12
+ import { K, envelope, roomChannel, discoveryChannel } from './protocol.js'
13
+ import { createEngine } from './engine.js'
14
+ import { signReceiptHalf, signEventHalf, eventPayload, ratePlayer as repRatePlayer } from './reputation.js'
15
+
16
+ export const STATUS = { WAITING: 'waiting', PLAYING: 'playing', PAUSED: 'paused', ENDED: 'ended' }
17
+ export const SEAT = { OPEN: 'open', OCCUPIED: 'occupied', DISCONNECTED: 'disconnected' }
18
+
19
+ export class Room extends Emitter {
20
+ constructor ({ transport, gameId, roomId, role, config }) {
21
+ super()
22
+ this.transport = transport
23
+ this.gameId = gameId
24
+ this.roomId = roomId // host: == transport.token ; guest: token del host
25
+ this.role = role // 'host' | 'guest'
26
+ this.config = config
27
+ this.name = config.name || null
28
+
29
+ this.identity = config.identity || null
30
+ this.reputation = config.reputation || null
31
+ this.myPubkey = (this.identity && this.identity.me && this.identity.me.publickey) || null
32
+ this.myName = config.playerName || (this.identity && this.identity.me && this.identity.me.nickname) || null
33
+
34
+ // Estado público unificado (lo que ven los getters). El host lo deriva de su
35
+ // estado autoritativo; el guest lo recibe por STATE.
36
+ this._public = this._emptyPublic()
37
+
38
+ // Estado autoritativo del host (campos de trabajo).
39
+ this.engine = config.engineSpec ? createEngine(config.engineSpec) : null
40
+ this.hostPubkey = role === 'host' ? this.myPubkey : null
41
+ this._seats = {} // id → { id, pubkey, token, name, ready, status, disconnectAt }
42
+ this._members = new Map() // token → { pubkey, name, verified, nonce, lastSeen }
43
+ this._status = STATUS.WAITING
44
+ this._result = null
45
+ this._seq = 0
46
+ this._graceTimers = new Map() // seatId → timeout
47
+ this._receipts = new Map() // peerPubkey → { a,b,ts,sigA,sigB }
48
+ this._pendingReceiptSig = new Map() // receiptId → mitad pendiente
49
+ this._pendingResults = new Map() // resultId → resultado a co-firmar (para ELO)
50
+
51
+ // Roomids que el host acepta en el campo `r` (incluye el viejo durante una
52
+ // ventana tras reconectar, mientras los guests se re-claven).
53
+ this._acceptedRoomIds = new Set([roomId])
54
+ this._rekeyTimer = null
55
+ this._heartbeat = null
56
+
57
+ // Guest
58
+ this._hostToken = role === 'guest' ? roomId : null // a quién le mando (cambia si el host reconecta)
59
+ this._lastSeq = -1
60
+ this._joinTimer = null
61
+ this._joinAttempts = 0
62
+ this._hostLostTimer = null
63
+
64
+ this._unsub = []
65
+ if (role === 'host') this._initSeats()
66
+ }
67
+
68
+ // ── Getters públicos ───────────────────────────────────────────
69
+ get isHost () { return this.role === 'host' }
70
+ get status () { return this._public.status }
71
+ get result () { return this._public.result }
72
+ get seats () { return this._public.seats }
73
+ get spectators () { return this._public.spectators }
74
+ get game () { return this._public.game }
75
+ get version () { return this._public.version }
76
+ get state () { return this._public }
77
+ get mySeat () {
78
+ // Host: resuelve por su token (siempre lo tiene, con o sin identidad).
79
+ if (this.isHost) {
80
+ const byToken = this._seatIdByToken(this.transport.token)
81
+ if (byToken) return byToken
82
+ }
83
+ const seats = this._public.seats || {}
84
+ if (this.myPubkey) {
85
+ for (const id of Object.keys(seats)) {
86
+ if (seats[id] && samePubkey(seats[id].pubkey, this.myPubkey)) return id
87
+ }
88
+ }
89
+ // Sin identidad: el host nos marca el asiento propio en el snapshot personalizado.
90
+ return this._public.mySeatId || null
91
+ }
92
+
93
+ _emptyPublic () {
94
+ return { roomId: this.roomId, gameId: this.gameId, name: this.name, hostPubkey: null, status: STATUS.WAITING, seats: {}, spectators: [], result: null, game: null, version: 0 }
95
+ }
96
+
97
+ // ════════════════════════════════════════════════════════════════
98
+ // ARRANQUE
99
+ // ════════════════════════════════════════════════════════════════
100
+
101
+ async _startAsHost () {
102
+ // Config inválida: requireVerify sin vault dejaría la sala sin poder sentar a
103
+ // nadie. Degradar conscientemente en vez de morir en silencio.
104
+ if (this.config.requireVerify && !this.identity) {
105
+ console.warn('[lobby] requireVerify pedido pero no hay identidad: se desactiva la verificación')
106
+ this.config = { ...this.config, requireVerify: false }
107
+ }
108
+ this.roomId = this.transport.token
109
+ this._acceptedRoomIds = new Set([this.transport.token])
110
+ this.hostPubkey = this.myPubkey
111
+ this._public.roomId = this.roomId
112
+ this._public.hostPubkey = this.hostPubkey
113
+ // El host es un miembro más (puede sentarse o ser espectador).
114
+ this._members.set(this.transport.token, { pubkey: this.myPubkey, name: this.myName, verified: true, lastSeen: clock.now() })
115
+ this._wire()
116
+ // Anunciarse: canal de descubrimiento + canal de presencia de la sala.
117
+ try { await this.transport.publish(discoveryChannel(this.gameId), { roomName: this.name, gameType: this.gameId }) } catch (_) {}
118
+ try { await this.transport.publish(roomChannel(this.gameId, this.roomId)) } catch (_) {}
119
+ this._startHeartbeat()
120
+ this._afterStateChange()
121
+ return this
122
+ }
123
+
124
+ // Re-publica la sala periódicamente: las entradas de canal del proxy expiran
125
+ // (~20 min), así que sin esto una sala desaparecería del descubrimiento en
126
+ // partidas largas. Reemplaza el _republish casero que hacían los bots.
127
+ _startHeartbeat () {
128
+ if (this._heartbeat) clearInterval(this._heartbeat)
129
+ this._heartbeat = setInterval(() => {
130
+ this.transport.publish(discoveryChannel(this.gameId), { roomName: this.name, gameType: this.gameId }).catch(() => {})
131
+ this.transport.publish(roomChannel(this.gameId, this.roomId)).catch(() => {})
132
+ }, 10 * 60 * 1000)
133
+ if (this._heartbeat.unref) this._heartbeat.unref()
134
+ }
135
+
136
+ async _joinAsGuest () {
137
+ this._public.roomId = this.roomId
138
+ this._wire()
139
+ try { await this.transport.publish(roomChannel(this.gameId, this.roomId)) } catch (_) {}
140
+ this._sendHelloWithRetry()
141
+ return this
142
+ }
143
+
144
+ _wire () {
145
+ const off1 = this.transport.subscribe(this.gameId, (from, env, meta) => {
146
+ if (this.role === 'host') {
147
+ // INFO_REQUEST es de descubrimiento (pre-join); el resto debe traer un
148
+ // roomId aceptado (el actual o el viejo durante la ventana de re-clave).
149
+ if (env.k !== K.INFO_REQUEST && !this._acceptedRoomIds.has(env.r)) return
150
+ this._onHostMessage(from, env, meta)
151
+ } else {
152
+ // HOST_REKEY puede venir del token nuevo del host; se valida por pubkey.
153
+ if (env.k !== K.HOST_REKEY && env.r !== this.roomId) return
154
+ this._onGuestMessage(from, env, meta)
155
+ }
156
+ })
157
+ // rc se recalcula dinámicamente: this.roomId puede cambiar si el host reconecta.
158
+ const off2 = this.transport.on('peer_disconnected', (token, channel) => {
159
+ if (channel && channel !== roomChannel(this.gameId, this.roomId)) return
160
+ this._onPeerGone(token)
161
+ })
162
+ const off3 = this.transport.on('channel_left', (channel, token) => {
163
+ if (channel === roomChannel(this.gameId, this.roomId)) this._onPeerGone(token)
164
+ })
165
+ const off4 = this.transport.on('reconnect', () => this._onReconnect())
166
+ this._unsub.push(off1, off2, off3, off4)
167
+ }
168
+
169
+ // ════════════════════════════════════════════════════════════════
170
+ // API PÚBLICA (host y guest)
171
+ // ════════════════════════════════════════════════════════════════
172
+
173
+ /** Tomar un asiento libre (si se omite, el primero disponible). */
174
+ takeSeat (seatId) {
175
+ if (this.isHost) {
176
+ const id = seatId || this._firstOpenSeat()
177
+ if (!id) return false
178
+ if (this._occupySeat(id, this.transport.token, this.myPubkey, this.myName)) this._afterSeatChange()
179
+ return true
180
+ }
181
+ this._sendHost(K.SEAT_TAKE, { seat: seatId || null })
182
+ return true
183
+ }
184
+
185
+ /** Dejar el asiento (paso a espectador). */
186
+ leaveSeat () {
187
+ if (this.isHost) { if (this._vacateByToken(this.transport.token, { voluntary: true })) this._afterSeatChange() }
188
+ else this._sendHost(K.SEAT_LEAVE, {})
189
+ }
190
+
191
+ /** Marcar/desmarcar "listo". */
192
+ setReady (ready = true) {
193
+ if (this.isHost) { if (this._setReady(this.transport.token, !!ready)) this._afterSeatChange() }
194
+ else this._sendHost(K.READY, { ready: !!ready })
195
+ }
196
+
197
+ /** Volverse espectador explícitamente. */
198
+ spectate () { this.leaveSeat() }
199
+
200
+ /** Enviar una acción de juego al motor autoritativo. */
201
+ action (action) {
202
+ if (this.isHost) this._applyAction(this.transport.token, action)
203
+ else this._sendHost(K.ACTION, { action })
204
+ }
205
+
206
+ /** Mensaje de chat (difundido por el host a toda la sala). */
207
+ chat (text) {
208
+ if (this.isHost) this._broadcastEvent('chat', { from: this.myPubkey, name: this.myName, text: String(text || '').slice(0, 500), ts: clock.now() })
209
+ else this._sendHost(K.CHAT, { text })
210
+ }
211
+
212
+ /** Relay opaco (juegos sin motor que sincronizan su propio estado). */
213
+ send (data) {
214
+ if (this.isHost) this._broadcastEvent('message', { from: this.myPubkey, data })
215
+ else this._sendHost(K.RELAY, { data })
216
+ }
217
+
218
+ /** Host-only: arrancar manualmente (start:'manual') o reiniciar. */
219
+ start () {
220
+ if (!this.isHost) return false
221
+ this._startGame()
222
+ return true
223
+ }
224
+
225
+ /**
226
+ * Calificar a un co-jugador: lo agrega a contactos del vault y atesta en el
227
+ * registro, adjuntando el recibo de partida co-firmado si existe (→ txBound).
228
+ */
229
+ ratePlayer (pubkey, valueOrIndicators, opts = {}) {
230
+ const receipt = this._receipts.get(pubkey)
231
+ const token = this._tokenByPubkey(pubkey)
232
+ return repRatePlayer(this.identity, this.reputation, pubkey, valueOrIndicators, {
233
+ ...opts, receipt: receipt && receipt.sigB ? receipt : opts.receipt, token
234
+ })
235
+ }
236
+
237
+ /** Recibo co-firmado entre yo y `pubkey` (o null si no se completó). */
238
+ matchReceipt (pubkey) {
239
+ const r = this._receipts.get(pubkey)
240
+ return r && r.sigA && r.sigB ? r : null
241
+ }
242
+
243
+ /** Salir de la sala y liberar recursos. */
244
+ async leave () {
245
+ for (const off of this._unsub) { try { off() } catch (_) {} }
246
+ this._unsub = []
247
+ for (const t of this._graceTimers.values()) clearTimeout(t)
248
+ this._graceTimers.clear()
249
+ if (this._joinTimer) clearTimeout(this._joinTimer)
250
+ if (this._rekeyTimer) clearTimeout(this._rekeyTimer)
251
+ if (this._hostLostTimer) clearTimeout(this._hostLostTimer)
252
+ if (this._heartbeat) clearInterval(this._heartbeat)
253
+ if (this.isHost) {
254
+ this._broadcastEvent('closed', { reason: 'host-left' })
255
+ try { await this.transport.unpublish(discoveryChannel(this.gameId)) } catch (_) {}
256
+ }
257
+ try { await this.transport.unpublish(roomChannel(this.gameId, this.roomId)) } catch (_) {}
258
+ this.emit('left')
259
+ this.removeAllListeners()
260
+ }
261
+
262
+ // ════════════════════════════════════════════════════════════════
263
+ // HOST: manejo de mensajes de guests
264
+ // ════════════════════════════════════════════════════════════════
265
+
266
+ _onHostMessage (from, env) {
267
+ const d = env.d || {}
268
+ switch (env.k) {
269
+ case K.HELLO: this._hostHello(from, d); break
270
+ case K.REQUEST_STATE: this._sendStateTo(from); break
271
+ case K.INFO_REQUEST: this._sendInfoTo(from); break
272
+ case K.VERIFY_RESP: this._hostVerifyResp(from, d); break
273
+ case K.SEAT_TAKE: this._hostSeatTake(from, d); break
274
+ case K.SEAT_LEAVE: if (this._vacateByToken(from, { voluntary: true })) this._afterSeatChange(); break
275
+ case K.READY: if (this._setReady(from, !!d.ready)) this._afterSeatChange(); break
276
+ case K.SPECTATE: if (this._vacateByToken(from, { voluntary: true })) this._afterSeatChange(); break
277
+ case K.ACTION: this._applyAction(from, d.action); break
278
+ case K.CHAT: { const m = this._members.get(from); this._broadcastEvent('chat', { from: m && m.pubkey, name: m && m.name, text: String(d.text || '').slice(0, 500), ts: clock.now() }); break }
279
+ case K.RELAY: { const m = this._members.get(from); this._broadcastEvent('message', { from: m && m.pubkey, data: d.data }); break }
280
+ case K.RECEIPT_SIGN: this._hostReceiptSign(from, d); break
281
+ case K.RESULT_SIGN: this._hostResultSign(from, d); break
282
+ case K.PING: this._touch(from); this._sendTo(from, K.PONG, { ts: d.ts }); break
283
+ default: break
284
+ }
285
+ }
286
+
287
+ _hostSeatTake (from, d) {
288
+ const m = this._members.get(from)
289
+ if (!m) return
290
+ // Si todavía no se verificó, encolar la intención y aplicarla tras verificar
291
+ // (evita la carrera HELLO→SEAT_TAKE en quickMatch/auto-seat).
292
+ if (this.config.requireVerify && !m.verified) { m.pendingSeat = d.seat || true; return }
293
+ const id = d.seat || this._firstOpenSeat()
294
+ if (this._occupySeat(id, from, m.pubkey, m.name)) this._afterSeatChange()
295
+ }
296
+
297
+ _hostHello (from, d) {
298
+ const claimedPubkey = d.pubkey || null
299
+ const name = (d.name && String(d.name).slice(0, 40)) || null
300
+ // Mutar en sitio para preservar campos en vuelo (verified, nonce, pendingSeat)
301
+ // ante reintentos de HELLO.
302
+ const m = this._members.get(from) || { verified: !this.config.requireVerify }
303
+ m.pubkey = claimedPubkey
304
+ if (name) m.name = name
305
+ m.lastSeen = clock.now()
306
+ this._members.set(from, m)
307
+ // Verificación de identidad antes de admitir/sentar (anti-impersonación).
308
+ if (this.config.requireVerify && this.identity && claimedPubkey) {
309
+ this._sendVerifyChallenge(from)
310
+ } else {
311
+ this._admit(from)
312
+ }
313
+ // Siempre mandamos el estado actual para que el guest pinte el lobby.
314
+ this._sendStateTo(from)
315
+ }
316
+
317
+ async _sendVerifyChallenge (from) {
318
+ try {
319
+ const { nonce } = await this.identity.makeChallenge()
320
+ const m = this._members.get(from); if (m) m.nonce = nonce
321
+ this._sendTo(from, K.VERIFY_CHALLENGE, { nonce })
322
+ } catch (_) {
323
+ // No admitir sin verificar cuando la política lo exige: expulsar para que
324
+ // el guest pueda reintentar un HELLO fresco.
325
+ this._sendTo(from, K.KICKED, { reason: 'verify-unavailable' })
326
+ this._members.delete(from)
327
+ }
328
+ }
329
+
330
+ async _hostVerifyResp (from, d) {
331
+ if (!this.identity) return
332
+ try {
333
+ const res = await this.identity.verifyResponse(d)
334
+ if (!res || !res.ok) { this._sendTo(from, K.KICKED, { reason: 'verify-failed' }); this._members.delete(from); return }
335
+ const m = this._members.get(from) || {}
336
+ m.verified = true
337
+ m.pubkey = res.publickey
338
+ this._members.set(from, m)
339
+ await this._admit(from)
340
+ } catch (_) {}
341
+ }
342
+
343
+ async _admit (from) {
344
+ const m = this._members.get(from)
345
+ if (!m) return
346
+ // Gate de reputación (host-side): rechazar desconocidos / baja reputación.
347
+ if (this._gate && m.pubkey) {
348
+ const verdict = await this._gate(m.pubkey)
349
+ if (!verdict.ok) { this._sendTo(from, K.KICKED, { reason: verdict.reason || 'reputation' }); this._members.delete(from); return }
350
+ }
351
+ // Reclamo de asiento por pubkey (reconexión dentro del grace period).
352
+ const reclaimed = this._reclaimSeat(from, m.pubkey)
353
+ if (!reclaimed) this._ensureSpectator(from)
354
+ // Intención de asiento encolada antes de verificar (auto-seat / quickMatch).
355
+ if (!reclaimed && m.pendingSeat) {
356
+ const sid = m.pendingSeat === true ? this._firstOpenSeat() : m.pendingSeat
357
+ m.pendingSeat = null
358
+ if (sid) this._occupySeat(sid, from, m.pubkey, m.name)
359
+ }
360
+ this._afterSeatChange()
361
+ }
362
+
363
+ _hostReceiptSign (from, d) {
364
+ const rec = this._pendingReceiptSig.get(d.receiptId)
365
+ if (!rec || from !== rec.token) return // sólo el destinatario del recibo puede co-firmarlo
366
+ const full = { a: rec.a, b: rec.b, ts: rec.ts, sigA: rec.sigA, sigB: d.sig }
367
+ this._receipts.set(rec.peerPubkey, full)
368
+ this._pendingReceiptSig.delete(d.receiptId)
369
+ this.emit('receipt', { pubkey: rec.peerPubkey, receipt: full })
370
+ }
371
+
372
+ // ── Mutadores de asientos (host) ───────────────────────────────
373
+
374
+ _initSeats () {
375
+ for (const id of this.config.seats.ids) {
376
+ this._seats[id] = { id, pubkey: null, token: null, name: null, ready: false, status: SEAT.OPEN, disconnectAt: null }
377
+ }
378
+ }
379
+
380
+ _firstOpenSeat () {
381
+ for (const id of this.config.seats.ids) if (this._seats[id].status === SEAT.OPEN) return id
382
+ return null
383
+ }
384
+
385
+ _seatIdByToken (token) {
386
+ for (const id of this.config.seats.ids) if (this._seats[id].token === token) return id
387
+ return null
388
+ }
389
+
390
+ _tokenByPubkey (pubkey) {
391
+ for (const [t, m] of this._members) if (m.pubkey && m.pubkey === pubkey) return t
392
+ return null
393
+ }
394
+
395
+ _memberOk (from) {
396
+ const m = this._members.get(from)
397
+ if (!m) return false
398
+ if (this.config.requireVerify && !m.verified) return false
399
+ return true
400
+ }
401
+
402
+ _occupySeat (seatId, token, pubkey, name) {
403
+ if (!seatId || !this._seats[seatId]) return false
404
+ const seat = this._seats[seatId]
405
+ if (seat.status === SEAT.OCCUPIED && seat.token !== token) return false // sólo asientos libres
406
+ const prev = this._seatIdByToken(token)
407
+ if (prev && prev !== seatId) this._clearSeat(prev) // movida de asiento
408
+ this._ensureMember(token, pubkey, name)
409
+ seat.token = token; seat.pubkey = pubkey || seat.pubkey; seat.name = name || seat.name || 'Jugador'
410
+ seat.status = SEAT.OCCUPIED; seat.ready = false; seat.disconnectAt = null
411
+ this._clearGrace(seatId)
412
+ return true
413
+ }
414
+
415
+ _setReady (token, ready) {
416
+ const id = this._seatIdByToken(token)
417
+ if (!id) return false
418
+ this._seats[id].ready = ready
419
+ return true
420
+ }
421
+
422
+ _vacateByToken (token, { voluntary } = {}) {
423
+ const id = this._seatIdByToken(token)
424
+ if (!id) return false
425
+ this._clearSeat(id)
426
+ this._ensureSpectator(token)
427
+ if (voluntary) this._applyVacancyPolicy(id, token) // excluir al que se va del fill
428
+ return true
429
+ }
430
+
431
+ _clearSeat (id) {
432
+ const s = this._seats[id]
433
+ s.token = null; s.pubkey = null; s.name = null; s.ready = false; s.status = SEAT.OPEN; s.disconnectAt = null
434
+ this._clearGrace(id)
435
+ }
436
+
437
+ _reclaimSeat (token, pubkey) {
438
+ if (!pubkey) return false
439
+ for (const id of this.config.seats.ids) {
440
+ const s = this._seats[id]
441
+ if (s.status === SEAT.DISCONNECTED && s.pubkey === pubkey) {
442
+ s.token = token; s.status = SEAT.OCCUPIED; s.disconnectAt = null
443
+ this._clearGrace(id)
444
+ if (this._status === STATUS.PAUSED && !this._hasDisconnectedSeat()) this._status = STATUS.PLAYING
445
+ this.emit('event', { event: 'reconnected', data: { seat: id, pubkey } })
446
+ return true
447
+ }
448
+ }
449
+ return false
450
+ }
451
+
452
+ _ensureMember (token, pubkey, name) {
453
+ const m = this._members.get(token) || { verified: !this.config.requireVerify }
454
+ if (pubkey) m.pubkey = pubkey
455
+ if (name) m.name = name
456
+ m.lastSeen = clock.now()
457
+ this._members.set(token, m)
458
+ }
459
+
460
+ _ensureSpectator (token) {
461
+ if (!this.config.allowSpectators) return
462
+ if (this._seatIdByToken(token)) return
463
+ this._ensureMember(token)
464
+ }
465
+
466
+ _touch (from) { const m = this._members.get(from); if (m) m.lastSeen = clock.now() }
467
+
468
+ // ── Política de asiento vacante / desconexión ──────────────────
469
+
470
+ _applyVacancyPolicy (seatId, excludeToken = null) {
471
+ const policy = this.config.onSeatVacated
472
+ if (this._status !== STATUS.PLAYING && this._status !== STATUS.PAUSED) return
473
+ if (policy === 'forfeit') {
474
+ this._endGame({ winner: this._otherSeatsWinner(seatId), reason: 'forfeit' })
475
+ } else if (policy === 'fill') {
476
+ if (!this._fillFromSpectators(seatId, excludeToken) && this._occupiedCount() < this.config.seats.min) this._status = STATUS.PAUSED
477
+ } else { // 'pause'
478
+ if (this._occupiedCount() < this.config.seats.min) this._status = STATUS.PAUSED
479
+ }
480
+ }
481
+
482
+ _onPeerGone (token) {
483
+ if (this.role === 'guest') {
484
+ if (token === this._hostToken) this._onHostLost()
485
+ return
486
+ }
487
+ const id = this._seatIdByToken(token)
488
+ this._members.delete(token)
489
+ if (id) {
490
+ const policy = this.config.onSeatVacated
491
+ if (policy === 'forfeit' && (this._status === STATUS.PLAYING || this._status === STATUS.PAUSED)) {
492
+ this._endGame({ winner: this._otherSeatsWinner(id), reason: 'forfeit' })
493
+ return
494
+ }
495
+ const s = this._seats[id]
496
+ s.status = SEAT.DISCONNECTED; s.token = null; s.disconnectAt = clock.now()
497
+ if (this._status === STATUS.PLAYING && policy !== 'fill' && this._occupiedCount() < this.config.seats.min) this._status = STATUS.PAUSED
498
+ if (policy === 'fill') this._fillFromSpectators(id) // el miembro caído ya fue borrado de _members
499
+ if (this._seats[id].status === SEAT.DISCONNECTED) this._startGrace(id) // no arrancar grace si ya se rellenó
500
+ }
501
+ this._afterStateChange()
502
+ }
503
+
504
+ _startGrace (seatId) {
505
+ this._clearGrace(seatId)
506
+ const ms = this.config.disconnectGraceMs
507
+ const at = this._seats[seatId].disconnectAt
508
+ const t = setTimeout(() => {
509
+ const s = this._seats[seatId]
510
+ if (s.status === SEAT.DISCONNECTED && s.disconnectAt === at) {
511
+ this._clearSeat(seatId)
512
+ this.emit('event', { event: 'seat-expired', data: { seat: seatId } })
513
+ this._afterStateChange()
514
+ }
515
+ }, ms)
516
+ if (t.unref) t.unref()
517
+ this._graceTimers.set(seatId, t)
518
+ }
519
+
520
+ _clearGrace (seatId) {
521
+ const t = this._graceTimers.get(seatId)
522
+ if (t) { clearTimeout(t); this._graceTimers.delete(seatId) }
523
+ }
524
+
525
+ _fillFromSpectators (seatId, excludeToken = null) {
526
+ for (const [token, m] of this._members) {
527
+ if (token === excludeToken) continue // no re-sentar al que se acaba de ir
528
+ if (this._seatIdByToken(token)) continue
529
+ if (token === this.transport.token) continue
530
+ if (this.config.requireVerify && !m.verified) continue
531
+ if (this._occupySeat(seatId, token, m.pubkey, m.name)) return true
532
+ }
533
+ return false
534
+ }
535
+
536
+ // ── Inicio / fin de partida ────────────────────────────────────
537
+
538
+ _checkAutoStart () {
539
+ if (this._status === STATUS.PAUSED) {
540
+ if (this._occupiedCount() >= this.config.seats.min && !this._hasDisconnectedSeat()) this._status = STATUS.PLAYING
541
+ return
542
+ }
543
+ if (this._status !== STATUS.WAITING) return
544
+ const mode = this.config.start
545
+ if (mode === 'manual') return
546
+ const occupied = this._occupiedCount()
547
+ if (occupied < this.config.seats.min) return
548
+ if (mode === 'ready' && !this._allOccupiedReady()) return
549
+ this._startGame()
550
+ }
551
+
552
+ _startGame () {
553
+ this._status = STATUS.PLAYING
554
+ this._result = null
555
+ if (this.engine) this.engine.start(this.config.seed)
556
+ this._afterStateChange()
557
+ this._broadcastEvent('started', { ts: clock.now() })
558
+ }
559
+
560
+ _endGame (result) {
561
+ this._status = STATUS.ENDED
562
+ this._result = result || { winner: null, reason: 'ended' }
563
+ this._afterStateChange()
564
+ this._broadcastEvent('ended', this._result)
565
+ this.emit('ended', this._result)
566
+ this._offerReceipts()
567
+ this._offerResults()
568
+ }
569
+
570
+ _applyAction (token, action) {
571
+ if (this._status !== STATUS.PLAYING) { this._sendTo(token, K.EVENT, { event: 'rejected', data: { reason: 'not-playing' } }); return }
572
+ const seat = this._seatIdByToken(token)
573
+ if (!seat) { this._sendTo(token, K.EVENT, { event: 'rejected', data: { reason: 'not-seated' } }); return }
574
+ if (!this.engine) { this._broadcastEvent('action', { seat, action }); return } // relay autoritativo
575
+ try {
576
+ this.engine.apply(seat, action, this._seatsSnapshot())
577
+ } catch (e) {
578
+ this._sendTo(token, K.EVENT, { event: 'rejected', data: { reason: (e && e.message) || 'invalid' } })
579
+ return
580
+ }
581
+ const over = this.engine.checkOver()
582
+ if (over) { this._endGame(over); return }
583
+ this._afterStateChange()
584
+ }
585
+
586
+ // ── Recibos co-firmados (host inicia con cada co-jugador) ───────
587
+
588
+ async _offerReceipts () {
589
+ if (!this.identity || !this.myPubkey) return
590
+ const ids = this.config.seats.ids
591
+ for (let i = 0; i < ids.length; i++) {
592
+ const s = this._seats[ids[i]]
593
+ if (!s.pubkey || samePubkey(s.pubkey, this.myPubkey) || !s.token) continue
594
+ try {
595
+ const a = this.myPubkey, b = s.pubkey
596
+ const ts = clock.now() + i // ts distinto por par → receiptId no adivinable entre peers
597
+ const sigA = await signReceiptHalf(this.identity, a, b, ts)
598
+ const receiptId = `${ts}:${ids[i]}`
599
+ this._pendingReceiptSig.set(receiptId, { a, b, ts, sigA, peerPubkey: b, token: s.token })
600
+ this._sendTo(s.token, K.RECEIPT_OFFER, { receiptId, receipt: { a, b, ts, sigA } })
601
+ } catch (_) {}
602
+ }
603
+ }
604
+
605
+ // ── Evento de indicador derivado co-firmado (p.ej. ELO): el host ofrece el
606
+ // resultado (outcome) a co-firmar con cada co-jugador ──
607
+ async _offerResults () {
608
+ if (!this.identity || !this.myPubkey) return
609
+ const indicator = this.config.resultIndicator || 'elo'
610
+ const scope = this.config.resultScope || this.gameId
611
+ const winnerSeat = this._result && this._result.winner // id de asiento ganador o null
612
+ const winnerPub = winnerSeat ? (this._seats[winnerSeat] && this._seats[winnerSeat].pubkey) : null
613
+ const ids = this.config.seats.ids
614
+ for (let i = 0; i < ids.length; i++) {
615
+ const s = this._seats[ids[i]]
616
+ if (!s.pubkey || samePubkey(s.pubkey, this.myPubkey) || !s.token) continue
617
+ try {
618
+ const a = this.myPubkey, b = s.pubkey
619
+ const outcome = winnerPub ? (samePubkey(winnerPub, a) ? 'a' : (samePubkey(winnerPub, b) ? 'b' : 'draw')) : 'draw'
620
+ const ts = clock.now() + i
621
+ const data = eventPayload(indicator, scope, a, b, outcome, ts)
622
+ const sigA = await signEventHalf(this.identity, indicator, scope, a, b, outcome, ts)
623
+ const resultId = `res:${ts}:${ids[i]}`
624
+ this._pendingResults.set(resultId, { data, sigA, token: s.token })
625
+ this._sendTo(s.token, K.RESULT_OFFER, { resultId, data, sigA })
626
+ } catch (_) {}
627
+ }
628
+ }
629
+
630
+ _hostResultSign (from, d) {
631
+ const rec = this._pendingResults.get(d.resultId)
632
+ if (!rec || from !== rec.token) return // sólo el destinatario co-firma su resultado
633
+ this._pendingResults.delete(d.resultId)
634
+ const coSigned = { data: rec.data, sigA: rec.sigA, sigB: d.sig }
635
+ this.emit('result', { indicator: rec.data.indicator, scope: rec.data.scope, a: rec.data.a, b: rec.data.b, outcome: rec.data.outcome, coSigned })
636
+ }
637
+
638
+ // Ganador relativo (a/b/draw) según lo que YO vi (anti-trampa del guest).
639
+ _relativeWinner (a, b, result) {
640
+ const seat = result && result.winner
641
+ const pub = seat ? (this._public.seats && this._public.seats[seat] && this._public.seats[seat].pubkey) : null
642
+ if (!pub) return 'draw'
643
+ if (samePubkey(pub, a)) return 'a'
644
+ if (samePubkey(pub, b)) return 'b'
645
+ return 'draw'
646
+ }
647
+
648
+ async _guestResultOffer (d) {
649
+ if (!this.identity || !this.myPubkey || !d.data) return
650
+ const { indicator, scope, a, b, outcome, ts } = d.data
651
+ if (!samePubkey(a, this.myPubkey) && !samePubkey(b, this.myPubkey)) return
652
+ const other = samePubkey(a, this.myPubkey) ? b : a
653
+ if (!samePubkey(other, this._public.hostPubkey)) return
654
+ // Anti-trampa: sólo co-firmo si el resultado del offer coincide con el que YO vi.
655
+ if (this._relativeWinner(a, b, this._public.result) !== outcome) return
656
+ try {
657
+ const sigB = await signEventHalf(this.identity, indicator, scope, a, b, outcome, ts)
658
+ this._sendHost(K.RESULT_SIGN, { resultId: d.resultId, sig: sigB })
659
+ this.emit('result', { indicator, scope, a, b, outcome, coSigned: { data: d.data, sigA: d.sigA, sigB } })
660
+ } catch (_) {}
661
+ }
662
+
663
+ // ════════════════════════════════════════════════════════════════
664
+ // GUEST: manejo de mensajes del host
665
+ // ════════════════════════════════════════════════════════════════
666
+
667
+ _onGuestMessage (from, env) {
668
+ const d = env.d || {}
669
+ if (env.k === K.HOST_REKEY) { this._guestRekey(from, d); return } // viene del token nuevo
670
+ if (from !== this._hostToken) return // sólo confiamos en el host
671
+ switch (env.k) {
672
+ case K.STATE: this._guestState(d, env.s); break
673
+ case K.EVENT: this._guestEvent(d); break
674
+ case K.VERIFY_CHALLENGE: this._guestVerify(d); break
675
+ case K.RECEIPT_OFFER: this._guestReceiptOffer(d); break
676
+ case K.RESULT_OFFER: this._guestResultOffer(d); break
677
+ case K.KICKED: this.emit('kicked', { reason: d.reason }); break
678
+ case K.PONG: break
679
+ default: break
680
+ }
681
+ }
682
+
683
+ _guestState (data, seq) {
684
+ if (typeof seq === 'number') {
685
+ if (seq <= this._lastSeq) return // estado viejo / fuera de orden
686
+ this._lastSeq = seq
687
+ }
688
+ this._clearHostLost() // recibimos estado: el host está vivo
689
+ if (this._joinTimer) { clearTimeout(this._joinTimer); this._joinTimer = null }
690
+ this._public = { ...data, version: typeof seq === 'number' ? seq : (this._public.version || 0) }
691
+ this.emit('update', this._public)
692
+ this.emit('state', this._public.game)
693
+ }
694
+
695
+ _guestEvent (d) {
696
+ const ev = d.event
697
+ this.emit('event', d)
698
+ if (ev === 'chat') this.emit('chat', d.data)
699
+ else if (ev === 'message') this.emit('message', d.data)
700
+ else if (ev === 'action') this.emit('action', d.data)
701
+ else if (ev === 'ended') { this._public = { ...this._public, status: STATUS.ENDED, result: d.data }; this.emit('ended', d.data) }
702
+ else if (ev === 'closed') this.emit('closed', d.data)
703
+ else if (ev === 'rejected') this.emit('rejected', d.data)
704
+ else if (ev === 'started') this.emit('started', d.data)
705
+ else if (ev === 'reconnected' || ev === 'seat-expired') { /* el STATE que sigue refleja el cambio */ }
706
+ }
707
+
708
+ async _guestVerify (d) {
709
+ if (!this.identity || !d.nonce) return
710
+ try {
711
+ const resp = await this.identity.signChallenge(d.nonce)
712
+ this._sendHost(K.VERIFY_RESP, resp)
713
+ } catch (_) {}
714
+ }
715
+
716
+ async _guestReceiptOffer (d) {
717
+ if (!this.identity || !this.myPubkey || !d.receipt) return
718
+ const { a, b, ts } = d.receipt
719
+ // Debo ser uno de los extremos del par...
720
+ if (!samePubkey(a, this.myPubkey) && !samePubkey(b, this.myPubkey)) return
721
+ const peer = samePubkey(a, this.myPubkey) ? b : a
722
+ // ...y la contraparte tiene que ser el host real (pubkey conocido por el STATE).
723
+ if (!samePubkey(peer, this._public.hostPubkey)) return
724
+ try {
725
+ const sigB = await signReceiptHalf(this.identity, a, b, ts)
726
+ const full = { a, b, ts, sigA: d.receipt.sigA, sigB }
727
+ this._receipts.set(peer, full)
728
+ this._sendHost(K.RECEIPT_SIGN, { receiptId: d.receiptId, sig: sigB })
729
+ this.emit('receipt', { pubkey: peer, receipt: full })
730
+ } catch (_) {}
731
+ }
732
+
733
+ _onHostLost () {
734
+ // No declarar la sala muerta de inmediato: el host puede estar reconectando
735
+ // (cambia de token y avisa por HOST_REKEY). Esperamos un grace; si vuelve,
736
+ // _guestRekey/_guestState cancelan el timer.
737
+ if (this._hostLostTimer) return
738
+ const ms = this.config.disconnectGraceMs || 45000
739
+ this.emit('event', { event: 'host-disconnected', data: {} })
740
+ this._hostLostTimer = setTimeout(() => {
741
+ this._hostLostTimer = null
742
+ const policy = this.config.onHostLost || 'end'
743
+ if (policy === 'end') {
744
+ this._public = { ...this._public, status: STATUS.ENDED }
745
+ this.emit('closed', { reason: 'host-lost' })
746
+ }
747
+ // 'migrate' (elección determinista de nuevo host) queda como extensión futura.
748
+ }, ms)
749
+ if (this._hostLostTimer.unref) this._hostLostTimer.unref()
750
+ }
751
+
752
+ _clearHostLost () { if (this._hostLostTimer) { clearTimeout(this._hostLostTimer); this._hostLostTimer = null } }
753
+
754
+ _sendHelloWithRetry () {
755
+ const send = () => {
756
+ this._sendHost(K.HELLO, { pubkey: this.myPubkey, name: this.myName })
757
+ this._joinAttempts++
758
+ if (this._lastSeq < 0 && this._joinAttempts < 5) {
759
+ this._joinTimer = setTimeout(send, 1200)
760
+ if (this._joinTimer.unref) this._joinTimer.unref()
761
+ }
762
+ }
763
+ send()
764
+ }
765
+
766
+ _onReconnect () {
767
+ if (this.role === 'host') {
768
+ const oldRoomId = this.roomId
769
+ const newToken = this.transport.token
770
+ if (newToken && newToken !== oldRoomId) {
771
+ this._acceptedRoomIds.add(newToken)
772
+ this.roomId = newToken
773
+ this._public.roomId = newToken
774
+ // Re-clave del self-member y del asiento propio (estaban con el token viejo).
775
+ const self = this._members.get(oldRoomId)
776
+ if (self) { this._members.delete(oldRoomId); this._members.set(newToken, self) }
777
+ for (const id of this.config.seats.ids) {
778
+ const s = this._seats[id]
779
+ if (s.pubkey && samePubkey(s.pubkey, this.myPubkey)) { s.token = newToken; break }
780
+ }
781
+ // Avisar a los miembros por pubkey (sobrevive al cambio de token; cola offline).
782
+ for (const m of this._members.values()) {
783
+ if (m.pubkey && !samePubkey(m.pubkey, this.myPubkey)) {
784
+ try { this.transport.sendByPubkey(m.pubkey, this._env(K.HOST_REKEY, { oldRoomId, newRoomId: newToken, hostPubkey: this.myPubkey })) } catch (_) {}
785
+ }
786
+ }
787
+ // Dejar de aceptar el roomId viejo tras una ventana de transición.
788
+ if (this._rekeyTimer) clearTimeout(this._rekeyTimer)
789
+ this._rekeyTimer = setTimeout(() => {
790
+ this._acceptedRoomIds.delete(oldRoomId)
791
+ this.transport.unpublish(roomChannel(this.gameId, oldRoomId)).catch(() => {})
792
+ }, 20000)
793
+ if (this._rekeyTimer.unref) this._rekeyTimer.unref()
794
+ }
795
+ this.transport.publish(discoveryChannel(this.gameId), { roomName: this.name, gameType: this.gameId }).catch(() => {})
796
+ this.transport.publish(roomChannel(this.gameId, this.roomId)).catch(() => {})
797
+ this._afterStateChange() // re-difundir estado a los miembros vivos
798
+ } else {
799
+ if (this._joinTimer) { clearTimeout(this._joinTimer); this._joinTimer = null }
800
+ this.transport.publish(roomChannel(this.gameId, this.roomId)).catch(() => {})
801
+ this._lastSeq = -1; this._joinAttempts = 0
802
+ this._sendHelloWithRetry()
803
+ }
804
+ }
805
+
806
+ _guestRekey (from, d) {
807
+ if (!d || !d.newRoomId) return
808
+ // Validar que el aviso viene del host real (por pubkey conocido del STATE).
809
+ if (this._public.hostPubkey && d.hostPubkey && !samePubkey(d.hostPubkey, this._public.hostPubkey)) return
810
+ this._clearHostLost() // el host volvió
811
+ if (this.roomId === d.newRoomId) return
812
+ this.roomId = d.newRoomId
813
+ this._hostToken = d.newRoomId
814
+ this._public.roomId = d.newRoomId
815
+ this.transport.publish(roomChannel(this.gameId, this.roomId)).catch(() => {})
816
+ this._lastSeq = -1; this._joinAttempts = 0
817
+ this._sendHelloWithRetry()
818
+ }
819
+
820
+ // ════════════════════════════════════════════════════════════════
821
+ // DIFUSIÓN / SNAPSHOTS (host)
822
+ // ════════════════════════════════════════════════════════════════
823
+
824
+ _afterSeatChange () { this._checkAutoStart(); this._afterStateChange() }
825
+
826
+ _afterStateChange () {
827
+ this._seq++
828
+ for (const token of this._recipients()) {
829
+ try { this._sendStateTo(token) } catch (e) { console.warn('[lobby] STATE falló para', token, e) }
830
+ }
831
+ try { this._refreshLocal() } catch (e) { console.warn('[lobby] refresh local falló', e) }
832
+ }
833
+
834
+ _refreshLocal () {
835
+ const seat = this._seatIdByToken(this.transport.token)
836
+ this._public = { ...this._snapshot(seat), version: this._seq }
837
+ this.emit('update', this._public)
838
+ this.emit('state', this._public.game)
839
+ }
840
+
841
+ _recipients () {
842
+ const set = new Set()
843
+ for (const token of this._members.keys()) if (token && token !== this.transport.token) set.add(token)
844
+ return [...set]
845
+ }
846
+
847
+ _sendStateTo (token) {
848
+ const seat = this._seatIdByToken(token)
849
+ this._sendTo(token, K.STATE, this._snapshot(seat), this._seq)
850
+ }
851
+
852
+ _sendInfoTo (token) { this._sendTo(token, K.INFO, { summary: this._summary() }) }
853
+
854
+ _summary () {
855
+ const seatsArr = this.config.seats.ids.map(id => ({ id, status: this._seats[id].status, name: this._seats[id].name }))
856
+ const open = seatsArr.filter(s => s.status === SEAT.OPEN).length
857
+ return {
858
+ roomId: this.roomId, gameId: this.gameId, name: this.name, hostPubkey: this.hostPubkey,
859
+ hostName: this.myName, status: this._status, players: this._occupiedCount(),
860
+ seats: seatsArr, openSeats: open, max: this.config.seats.max, spectators: this._spectatorCount()
861
+ }
862
+ }
863
+
864
+ _snapshot (seat) {
865
+ return {
866
+ roomId: this.roomId, gameId: this.gameId, name: this.name, hostPubkey: this.hostPubkey,
867
+ status: this._status, seats: this._publicSeats(), spectators: this._publicSpectators(),
868
+ result: this._result, game: this.engine ? this.engine.viewFor(seat) : null,
869
+ mySeatId: seat || null // personalizado por destinatario: permite mySeat sin pubkey
870
+ }
871
+ }
872
+
873
+ _publicSeats () {
874
+ const out = {}
875
+ for (const id of this.config.seats.ids) {
876
+ const s = this._seats[id]
877
+ out[id] = { id, pubkey: s.pubkey, name: s.name, ready: s.ready, status: s.status, occupied: s.status === SEAT.OCCUPIED }
878
+ }
879
+ return out
880
+ }
881
+
882
+ _publicSpectators () {
883
+ const out = []
884
+ for (const [token, m] of this._members) {
885
+ if (this._seatIdByToken(token)) continue
886
+ out.push({ pubkey: m.pubkey, name: m.name })
887
+ }
888
+ return out
889
+ }
890
+
891
+ _seatsSnapshot () {
892
+ const out = {}
893
+ for (const id of this.config.seats.ids) {
894
+ const s = this._seats[id]
895
+ out[id] = { pubkey: s.pubkey, name: s.name, status: s.status, occupied: s.status === SEAT.OCCUPIED }
896
+ }
897
+ return out
898
+ }
899
+
900
+ _broadcastEvent (event, data) {
901
+ if (this.role !== 'host') return
902
+ for (const token of this._recipients()) this._sendTo(token, K.EVENT, { event, data })
903
+ this.emit('event', { event, data })
904
+ if (event === 'chat') this.emit('chat', data)
905
+ else if (event === 'message') this.emit('message', data)
906
+ else if (event === 'action') this.emit('action', data)
907
+ else if (event === 'started') this.emit('started', data)
908
+ else if (event === 'closed') this.emit('closed', data)
909
+ }
910
+
911
+ // ── Cuentas auxiliares ─────────────────────────────────────────
912
+ _occupiedCount () { let n = 0; for (const id of this.config.seats.ids) if (this._seats[id].status === SEAT.OCCUPIED) n++; return n }
913
+ _allOccupiedReady () { for (const id of this.config.seats.ids) { const s = this._seats[id]; if (s.status === SEAT.OCCUPIED && !s.ready) return false } return true }
914
+ _hasDisconnectedSeat () { for (const id of this.config.seats.ids) if (this._seats[id].status === SEAT.DISCONNECTED) return true; return false }
915
+ _spectatorCount () { let n = 0; for (const token of this._members.keys()) if (!this._seatIdByToken(token)) n++; return n }
916
+ _otherSeatsWinner (excludeId) {
917
+ const others = this.config.seats.ids.filter(id => id !== excludeId && this._seats[id].status === SEAT.OCCUPIED)
918
+ return others.length === 1 ? others[0] : null
919
+ }
920
+
921
+ // ── Envío ──────────────────────────────────────────────────────
922
+ get _gate () { return this.config.gate || null }
923
+ _env (kind, data, seq) { return envelope(this.gameId, this.roomId, kind, data, seq) }
924
+ _sendTo (token, kind, data, seq) { try { this.transport.send(token, this._env(kind, data, seq)) } catch (e) { console.warn('[lobby] send failed:', e) } }
925
+ _sendHost (kind, data) { this._sendTo(this._hostToken || this.roomId, kind, data) }
926
+ }