@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/LICENSE +21 -0
- package/README.md +251 -0
- package/package.json +47 -0
- package/src/engine.js +96 -0
- package/src/index.d.ts +232 -0
- package/src/index.js +15 -0
- package/src/lobby.js +244 -0
- package/src/protocol.js +82 -0
- package/src/reputation.js +105 -0
- package/src/room.js +926 -0
- package/src/transport.js +156 -0
- package/src/util.js +127 -0
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
|
+
}
|