@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/lobby.js ADDED
@@ -0,0 +1,244 @@
1
+ // Lobby: capa de descubrimiento + matchmaking sobre los canales del proxy.
2
+ // Crea/lista/une salas de un juego, hace partida rápida (quickMatch) filtrando
3
+ // por reputación, e invita contactos por pubkey (cola offline).
4
+
5
+ import { Emitter, normalizeSeats } from './util.js'
6
+ import { K, envelope, discoveryChannel } from './protocol.js'
7
+ import { Transport } from './transport.js'
8
+ import { Room, STATUS } from './room.js'
9
+ import { createRepGate, rankRooms } from './reputation.js'
10
+
11
+ export class Lobby extends Emitter {
12
+ constructor ({ transport, gameId, config }) {
13
+ super()
14
+ this.transport = transport
15
+ this.gameId = gameId
16
+ this.config = config
17
+ this.identity = config.identity || null
18
+ this.reputation = config.reputation || null
19
+ this.myPubkey = (this.identity && this.identity.me && this.identity.me.publickey) || null
20
+ this._gate = createRepGate(this.reputation, config.matchmaking || {})
21
+ this._rooms = new Set()
22
+ this._infoCollector = null
23
+ this._wire()
24
+ }
25
+
26
+ _wire () {
27
+ this.transport.subscribe(this.gameId, (from, env) => {
28
+ if (env.k === K.INFO) { if (this._infoCollector) this._infoCollector(from, env.d && env.d.summary) }
29
+ else if (env.k === K.INVITE) this.emit('invite', { from, ...(env.d || {}) })
30
+ })
31
+ const dc = discoveryChannel(this.gameId)
32
+ // Descubrimiento en vivo: requiere observar el canal (watch) para recibir
33
+ // estos eventos sin publicarnos como sala. Si el proxy no soporta watch,
34
+ // _watchDiscovery degrada a no-op y la app sigue con su polling de respaldo.
35
+ this.transport.on('channel_joined', (channel, token) => { if (channel === dc && token !== this.transport.token) this.emit('rooms-changed', { type: 'joined', token }) })
36
+ this.transport.on('channel_left', (channel, token) => { if (channel === dc) this.emit('rooms-changed', { type: 'left', token }) })
37
+ this.transport.on('peer_disconnected', (token, channel) => { if (!channel || channel === dc) this.emit('rooms-changed', { type: 'left', token }) })
38
+ this.transport.on('reconnect', () => this._watchDiscovery())
39
+ this._watchDiscovery()
40
+ }
41
+
42
+ // Observar el canal de descubrimiento para recibir altas/bajas de salas en vivo
43
+ // (proxy ≥ 0.6.2). Fire-and-forget: no bloquea ni rompe si no está soportado.
44
+ _watchDiscovery () {
45
+ try { this.transport.watch(discoveryChannel(this.gameId)) } catch (_) {}
46
+ }
47
+
48
+ // ── Crear / unir ────────────────────────────────────────────────
49
+
50
+ /** Crear una sala (sos host). */
51
+ async createRoom (opts = {}) {
52
+ await this.transport.connect()
53
+ const room = new Room({ transport: this.transport, gameId: this.gameId, roomId: this.transport.token, role: 'host', config: this._roomConfig(opts) })
54
+ this._track(room)
55
+ await room._startAsHost()
56
+ return room
57
+ }
58
+
59
+ /** Unirse a una sala existente por su roomId (== token del host). */
60
+ async joinRoom (roomId, opts = {}) {
61
+ await this.transport.connect()
62
+ const room = new Room({ transport: this.transport, gameId: this.gameId, roomId, role: 'guest', config: this._roomConfig(opts) })
63
+ this._track(room)
64
+ await room._joinAsGuest()
65
+ return room
66
+ }
67
+
68
+ // ── Descubrimiento ──────────────────────────────────────────────
69
+
70
+ /**
71
+ * Lista salas abiertas: enumera el canal de descubrimiento y pide un resumen
72
+ * (INFO) a cada host; enriquece y ordena por reputación/contactos.
73
+ * @param {object} [opts]
74
+ * @param {number} [opts.timeout=1500] ventana para recolectar respuestas INFO
75
+ * @param {boolean} [opts.enrich=true] añadir reputación + flag de contacto y ordenar
76
+ */
77
+ async listRooms (opts = {}) {
78
+ await this.transport.connect()
79
+ const timeout = opts.timeout || 1500
80
+ let tokens = []
81
+ try { tokens = await this.transport.list(discoveryChannel(this.gameId)) } catch (_) {}
82
+ const others = tokens.filter(t => t && t !== this.transport.token)
83
+ const summaries = await this._gatherInfo(others, timeout)
84
+ if (opts.enrich === false) return summaries
85
+ const contacts = await this._contactSet()
86
+ return rankRooms(summaries, { reputation: this.reputation, contacts, preferContacts: (this.config.matchmaking || {}).preferContacts !== false })
87
+ }
88
+
89
+ _gatherInfo (tokens, timeout) {
90
+ return new Promise((resolve) => {
91
+ const got = new Map()
92
+ let timer = null
93
+ const done = () => { if (timer) clearTimeout(timer); this._infoCollector = null; resolve([...got.values()]) }
94
+ if (!tokens.length) return done()
95
+ this._infoCollector = (from, summary) => { if (summary) got.set(from, summary); if (got.size >= tokens.length) done() }
96
+ for (const t of tokens) this._sendTo(t, K.INFO_REQUEST, {})
97
+ timer = setTimeout(done, timeout)
98
+ if (timer.unref) timer.unref()
99
+ })
100
+ }
101
+
102
+ // ── Matchmaking ─────────────────────────────────────────────────
103
+
104
+ /**
105
+ * Partida rápida: une la mejor sala compatible (con asiento libre, esperando y
106
+ * que pase el gate de reputación); si no hay, crea una y espera oponente.
107
+ * Devuelve el Room (mirá room.isHost). Para auto-sentarte dejá autoSeat=true.
108
+ */
109
+ async quickMatch (opts = {}) {
110
+ const rooms = await this.listRooms({ timeout: opts.timeout || 1500 })
111
+ for (const r of rooms) {
112
+ if (r.status !== STATUS.WAITING || !(r.openSeats > 0)) continue
113
+ const verdict = await this._gate(r.hostPubkey)
114
+ if (!verdict.ok) continue
115
+ const room = await this.joinRoom(r.roomId, opts)
116
+ if (opts.autoSeat !== false) this._autoSeat(room, opts.seat)
117
+ return room
118
+ }
119
+ const room = await this.createRoom(opts)
120
+ if (opts.autoSeat !== false) room.takeSeat(opts.seat)
121
+ return room
122
+ }
123
+
124
+ // Toma asiento; el host encola la intención hasta verificar (pendingSeat), así
125
+ // que con un envío basta. Reintenta acotado sólo si seguimos sin asiento.
126
+ _autoSeat (room, seat) {
127
+ if (room.mySeat) return
128
+ room.takeSeat(seat)
129
+ let tries = 0
130
+ const off = room.on('update', () => {
131
+ if (room.mySeat || tries >= 3) { off(); return }
132
+ tries++
133
+ room.takeSeat(seat)
134
+ })
135
+ }
136
+
137
+ // ── Invitaciones / contactos ────────────────────────────────────
138
+
139
+ /** Invitar a un contacto (por pubkey) a una sala. Usa la cola offline 24 h. */
140
+ inviteContact (pubkey, { roomId, name } = {}) {
141
+ const rid = roomId || this.transport.token
142
+ const env = envelope(this.gameId, rid, K.INVITE, { roomId: rid, name: name || null, from: this.myPubkey, fromName: (this.identity && this.identity.me && this.identity.me.nickname) || null })
143
+ this.transport.sendByPubkey(pubkey, env)
144
+ }
145
+
146
+ /** Contactos del vault (compartidos entre apps del ecosistema). */
147
+ async listContacts () {
148
+ if (!this.identity || !this.identity.listContacts) return []
149
+ try { return await this.identity.listContacts() } catch (_) { return [] }
150
+ }
151
+
152
+ async _contactSet () {
153
+ const list = await this.listContacts()
154
+ return new Set(list.map(c => c.publickey).filter(Boolean))
155
+ }
156
+
157
+ /** Reputación ponderada de una pubkey (para badges en la UI del lobby). */
158
+ async reputationOf (pubkey) {
159
+ if (!this.reputation) return null
160
+ try { return await this.reputation.reputationOf(pubkey) } catch (_) { return null }
161
+ }
162
+
163
+ // ── Limpieza ────────────────────────────────────────────────────
164
+
165
+ /** Salas activas creadas/unidas por este lobby. */
166
+ get rooms () { return [...this._rooms] }
167
+
168
+ async destroy () {
169
+ for (const room of this._rooms) { try { await room.leave() } catch (_) {} }
170
+ this._rooms.clear()
171
+ this.removeAllListeners()
172
+ }
173
+
174
+ // ── Internos ────────────────────────────────────────────────────
175
+
176
+ _track (room) { this._rooms.add(room); room.on('left', () => this._rooms.delete(room)) }
177
+
178
+ _roomConfig (opts) {
179
+ return {
180
+ seats: this.config.seats,
181
+ engineSpec: this.config.engineSpec || null,
182
+ allowSpectators: this.config.allowSpectators !== false,
183
+ maxSpectators: this.config.maxSpectators ?? 50,
184
+ start: this.config.start || 'ready',
185
+ onSeatVacated: this.config.onSeatVacated || 'pause',
186
+ onHostLost: this.config.onHostLost || 'end',
187
+ disconnectGraceMs: this.config.disconnectGraceMs ?? 45000,
188
+ requireVerify: this.config.requireVerify !== false,
189
+ gate: this._gate,
190
+ identity: this.identity,
191
+ reputation: this.reputation,
192
+ name: opts.name || null,
193
+ playerName: opts.playerName || this.config.playerName || null,
194
+ seed: opts.seed ?? this.config.seed,
195
+ // Indicador derivado a co-firmar al terminar (default ELO, scope=gameId).
196
+ resultIndicator: this.config.resultIndicator || 'elo',
197
+ resultScope: this.config.resultScope || this.gameId
198
+ }
199
+ }
200
+
201
+ _sendTo (token, kind, data) { this.transport.send(token, envelope(this.gameId, token, kind, data)) }
202
+ }
203
+
204
+ /**
205
+ * Punto de entrada principal: crea (si hace falta) el transporte, lo conecta e
206
+ * identifica con el vault, y devuelve un Lobby listo para el juego dado.
207
+ *
208
+ * @param {object} opts
209
+ * @param {string} opts.gameId identificador del juego (namespace de canales)
210
+ * @param {Array|object} opts.seats ['white','black'] o { min, max }
211
+ * @param {object} [opts.engine] spec del motor de turnos { initialState, reducer, view?, isOver? }
212
+ * @param {object} [opts.identity] instancia de Identity ya conectada (si no, se conecta sola)
213
+ * @param {object} [opts.reputation] instancia de createVaultReputation
214
+ * @param {object} [opts.proxy] cliente proxy ya creado (reuso de conexión)
215
+ * @param {string} [opts.url] URL del proxy
216
+ * @param {string} [opts.start='ready'] 'ready' | 'full' | 'manual'
217
+ * @param {string} [opts.onSeatVacated='pause'] 'pause' | 'forfeit' | 'fill'
218
+ * @param {object} [opts.matchmaking] { requireVouched, minReputation, preferContacts }
219
+ * @returns {Promise<Lobby>}
220
+ */
221
+ export async function createLobby (opts = {}) {
222
+ if (!opts.gameId) throw new Error('[lobby] createLobby: falta gameId')
223
+ const transport = opts.transport || new Transport({ proxy: opts.proxy, identity: opts.identity, url: opts.url })
224
+ await transport.connect()
225
+ const config = {
226
+ seats: normalizeSeats(opts.seats || { min: 2, max: 2 }),
227
+ engineSpec: opts.engine || null,
228
+ allowSpectators: opts.allowSpectators,
229
+ maxSpectators: opts.maxSpectators,
230
+ start: opts.start,
231
+ onSeatVacated: opts.onSeatVacated,
232
+ onHostLost: opts.onHostLost,
233
+ disconnectGraceMs: opts.disconnectGraceMs,
234
+ requireVerify: opts.requireVerify,
235
+ identity: transport.identity || opts.identity || null,
236
+ reputation: opts.reputation || null,
237
+ matchmaking: opts.matchmaking || {},
238
+ playerName: opts.playerName || null,
239
+ seed: opts.seed,
240
+ resultIndicator: opts.resultIndicator || 'elo',
241
+ resultScope: opts.resultScope || null
242
+ }
243
+ return new Lobby({ transport, gameId: opts.gameId, config })
244
+ }
@@ -0,0 +1,82 @@
1
+ // Protocolo de mensajes de lobby/room sobre el transporte Dotrino.
2
+ //
3
+ // Una sola conexión al proxy puede ser compartida por varias apps del
4
+ // ecosistema (messenger, varios juegos a la vez). Por eso TODO mensaje de esta
5
+ // librería viaja en un sobre namespaced que se puede demultiplexar por
6
+ // (gameId, roomId) y descartar lo ajeno sin ambigüedad.
7
+ //
8
+ // El proxy auto-parsea los strings JSON: send(obj) llega al receptor como
9
+ // objeto ya parseado en el callback 'message'. Aprovechamos eso enviando el
10
+ // sobre como objeto plano. Evitamos el campo `t` a nivel raíz porque el
11
+ // cliente lo reserva para señalización WebRTC (parsed.t === '__cc_rtc__').
12
+
13
+ /** Marca de versión del sobre. */
14
+ export const ENVELOPE_TAG = 1
15
+
16
+ /** Tipos de mensaje (campo `k` = kind). */
17
+ export const K = {
18
+ // ── guest → host ─────────────────────────────────────────────
19
+ HELLO: 'hello', // { pubkey?, name? } entrar + pedir estado
20
+ REQUEST_STATE: 'reqstate', // {} resync explícito
21
+ SEAT_TAKE: 'seat.take', // { seat }
22
+ SEAT_LEAVE: 'seat.leave', // {}
23
+ READY: 'ready', // { ready:boolean }
24
+ SPECTATE: 'spectate', // {}
25
+ ACTION: 'action', // { action } jugada de juego (al motor)
26
+ CHAT: 'chat', // { text }
27
+ RELAY: 'relay', // { data } mensaje opaco (room.send sin motor)
28
+ VERIFY_RESP: 'verify.resp', // { nonce, publickey, signature, encryptionPubkey? }
29
+ RECEIPT_SIGN: 'receipt.sign', // { receiptId, sig } segunda firma del recibo
30
+ RATING_QUERY: 'rep.query', // { queryId, subject }
31
+ RATING_REPLY: 'rep.reply', // { queryId, subject, mine, endorsements }
32
+ INFO_REQUEST: 'info.req', // {} discovery: pedir resumen de la sala
33
+ PING: 'ping', // { ts } heartbeat de presencia
34
+ INVITE: 'invite', // { roomId, name, from, fromName } invitación (sendByPubkey)
35
+ HOST_REKEY: 'host.rekey', // { oldRoomId, newRoomId, hostPubkey } el host reconectó con token nuevo
36
+
37
+ // ── host → guest(s) ──────────────────────────────────────────
38
+ STATE: 'state', // snapshot completo personalizado por asiento
39
+ EVENT: 'event', // { event, data } eventos laterales (chat, started, ended, rejected)
40
+ INFO: 'info', // { summary } respuesta de discovery
41
+ VERIFY_CHALLENGE: 'verify.challenge', // { nonce }
42
+ RECEIPT_OFFER: 'receipt.offer', // { receiptId, receipt } mitad a co-firmar
43
+ RESULT_OFFER: 'result.offer', // { resultId, data:{op:'result',gameId,a,b,winner,ts}, sigA } resultado a co-firmar
44
+ RESULT_SIGN: 'result.sign', // { resultId, sig } segunda firma del resultado (para ELO)
45
+ KICKED: 'kicked', // { reason }
46
+ PONG: 'pong' // { ts }
47
+ }
48
+
49
+ /** Canal de descubrimiento de salas de un juego (lista tokens de hosts). */
50
+ export const discoveryChannel = (gameId) => `cclobby/${gameId}`
51
+ /** Canal de presencia de una sala concreta (host + guests publican aquí). */
52
+ export const roomChannel = (gameId, roomId) => `ccroom/${gameId}/${roomId}`
53
+
54
+ /**
55
+ * Construye un sobre.
56
+ * @param {string} gameId
57
+ * @param {string} roomId
58
+ * @param {string} kind uno de K
59
+ * @param {any} data
60
+ * @param {number} [seq] número de secuencia autoritativo (host→guest)
61
+ */
62
+ export function envelope (gameId, roomId, kind, data, seq) {
63
+ const env = { __ccl: ENVELOPE_TAG, g: gameId, r: roomId, k: kind, d: data || {} }
64
+ if (typeof seq === 'number') env.s = seq
65
+ return env
66
+ }
67
+
68
+ /**
69
+ * ¿Es un mensaje de esta librería? El callback 'message' del proxy entrega el
70
+ * payload ya parseado cuando era JSON; aceptamos también un string JSON por las
71
+ * dudas (otros transportes / WebRTC).
72
+ * @returns {null | { g, r, k, d, s }}
73
+ */
74
+ export function parseEnvelope (payload) {
75
+ let obj = payload
76
+ if (typeof payload === 'string') {
77
+ try { obj = JSON.parse(payload) } catch (_) { return null }
78
+ }
79
+ if (!obj || typeof obj !== 'object' || obj.__ccl !== ENVELOPE_TAG) return null
80
+ if (typeof obj.k !== 'string') return null
81
+ return { g: obj.g, r: obj.r, k: obj.k, d: obj.d || {}, s: typeof obj.s === 'number' ? obj.s : null }
82
+ }
@@ -0,0 +1,105 @@
1
+ // Integración reputación + contactos para el lobby.
2
+ //
3
+ // Reusa @dotrino/reputation (createVaultReputation) y los
4
+ // contactos del vault (@dotrino/identity), inyectados por
5
+ // duck-typing. Tres usos:
6
+ // 1. Gate de admisión (filtrar salas / rechazar joiners por reputación).
7
+ // 2. Ranking de salas (priorizar contactos / mejor reputación).
8
+ // 3. Recibo de partida co-firmado → atestación txBound ("jugamos juntos").
9
+
10
+ import { samePubkey } from './util.js'
11
+
12
+ /**
13
+ * Crea una función de admisión `gate(pubkey) → { ok, reason?, rep? }`.
14
+ * Best-effort: si el servicio de reputación falla, NO bloquea (devuelve ok).
15
+ * @param {object|null} reputation instancia de createVaultReputation
16
+ * @param {object} [gate]
17
+ * @param {boolean} [gate.requireVouched] exigir aval de la red (trustedCount>0)
18
+ * @param {number} [gate.minReputation] score mínimo 0..1
19
+ */
20
+ export function createRepGate (reputation, gate = {}) {
21
+ const minRep = typeof gate.minReputation === 'number' ? gate.minReputation : null
22
+ const requireVouched = !!gate.requireVouched
23
+ const active = !!reputation && (minRep != null || requireVouched)
24
+ return async function passes (pubkey) {
25
+ if (!active || !pubkey) return { ok: true }
26
+ try {
27
+ const r = await reputation.reputationOf(pubkey)
28
+ if (requireVouched && !(r && r.trustedCount > 0)) return { ok: false, reason: 'not-vouched', rep: r }
29
+ if (minRep != null) {
30
+ const score = r && r.score != null ? r.score : 0
31
+ if (score < minRep) return { ok: false, reason: 'low-reputation', rep: r }
32
+ }
33
+ return { ok: true, rep: r }
34
+ } catch (_) {
35
+ return { ok: true } // no romper el matchmaking si reputation.dotrino.com no responde
36
+ }
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Enriquma resúmenes de sala con reputación y flag de contacto, y los ordena:
42
+ * primero salas con contactos, luego por score descendente.
43
+ * @param {Array} rooms [{ hostPubkey, ... }]
44
+ * @param {object} ctx { reputation, contacts: Set<pubkey>, preferContacts }
45
+ */
46
+ export async function rankRooms (rooms, { reputation, contacts, preferContacts = true } = {}) {
47
+ const enriched = await Promise.all(rooms.map(async (room) => {
48
+ let rep = null
49
+ if (reputation && room.hostPubkey) {
50
+ try { rep = await reputation.reputationOf(room.hostPubkey) } catch (_) {}
51
+ }
52
+ const isContact = !!(contacts && room.hostPubkey && [...contacts].some(c => samePubkey(c, room.hostPubkey)))
53
+ return { ...room, reputation: rep, hostScore: rep && rep.score != null ? rep.score : 0, isContact }
54
+ }))
55
+ enriched.sort((a, b) => {
56
+ if (preferContacts && a.isContact !== b.isContact) return a.isContact ? -1 : 1
57
+ return b.hostScore - a.hostScore
58
+ })
59
+ return enriched
60
+ }
61
+
62
+ // ── Recibo de partida co-firmado ────────────────────────────────────
63
+
64
+ /** Payload canónico que ambos jugadores firman (debe coincidir con el server). */
65
+ export function receiptPayload (a, b, ts) { return { op: 'receipt', a, b, ts } }
66
+
67
+ /** Firma mi mitad del recibo con el vault. Devuelve la firma base64. */
68
+ export async function signReceiptHalf (identity, a, b, ts) {
69
+ const signed = await identity.signData(receiptPayload(a, b, ts))
70
+ return typeof signed === 'string' ? signed : signed.signature
71
+ }
72
+
73
+ // ── Evento de indicador derivado co-firmado (p.ej. ELO) ────────────
74
+ /** Payload canónico del evento que ambas partes firman (debe coincidir con el
75
+ * server de reputation: {op:'event', indicator, scope, a, b, outcome, ts}). */
76
+ export function eventPayload (indicator, scope, a, b, outcome, ts) { return { op: 'event', indicator, scope, a, b, outcome, ts } }
77
+
78
+ /** Firma mi mitad del evento con el vault. */
79
+ export async function signEventHalf (identity, indicator, scope, a, b, outcome, ts) {
80
+ const signed = await identity.signData(eventPayload(indicator, scope, a, b, outcome, ts))
81
+ return typeof signed === 'string' ? signed : signed.signature
82
+ }
83
+
84
+ /**
85
+ * Promueve a un peer a contacto del vault (compartido entre apps del ecosistema)
86
+ * y, si se pasa rating, lo atesta en el registro (con recibo si está disponible).
87
+ */
88
+ export async function ratePlayer (identity, reputation, pubkey, valueOrIndicators, opts = {}) {
89
+ if (identity && opts.addContact !== false) {
90
+ try { await identity.addContact({ publickey: pubkey, nickname: opts.nickname, lastToken: opts.token }) } catch (_) {}
91
+ }
92
+ if (reputation && valueOrIndicators != null) {
93
+ try {
94
+ return await reputation.rate(pubkey, valueOrIndicators, { notes: opts.notes, receipt: opts.receipt })
95
+ } catch (e) {
96
+ // El server rechaza TODO el rating si el recibo es inválido. No perder el
97
+ // rating: reintentar sin recibo (queda con txBound:false).
98
+ if (opts.receipt) {
99
+ try { return await reputation.rate(pubkey, valueOrIndicators, { notes: opts.notes }) } catch (_) {}
100
+ }
101
+ throw e
102
+ }
103
+ }
104
+ return { ok: true, txBound: false }
105
+ }