@dotrino/lobby 0.2.0 → 0.4.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/lobby",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Lobby + matchmaking reciclable para juegos del ecosistema Dotrino: salas, asientos, espectadores, motor de turnos autoritativo, reputación y contactos sobre el proxy/identity/reputation compartidos",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -31,8 +31,8 @@
31
31
  "web-of-trust"
32
32
  ],
33
33
  "peerDependencies": {
34
- "@dotrino/proxy-client": ">=0.1.0",
35
34
  "@dotrino/identity": ">=0.5.0",
35
+ "@dotrino/proxy-client": "^0.10.0",
36
36
  "@dotrino/reputation": ">=0.3.0"
37
37
  },
38
38
  "peerDependenciesMeta": {
package/src/index.js CHANGED
@@ -11,5 +11,5 @@ export {
11
11
  createRepGate, rankRooms, ratePlayer,
12
12
  receiptPayload, signReceiptHalf
13
13
  } from './reputation.js'
14
- export { K, discoveryChannel, roomChannel, setLobbyHomeNode, envelope, parseEnvelope } from './protocol.js'
14
+ export { K, discoveryChannel, discoveryChannels, roomChannel, envelope, parseEnvelope } from './protocol.js'
15
15
  export { Emitter, mulberry32, hashSeed, shuffle, normalizeSeats, samePubkey } from './util.js'
package/src/lobby.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // por reputación, e invita contactos por pubkey (cola offline).
4
4
 
5
5
  import { Emitter, normalizeSeats } from './util.js'
6
- import { K, envelope, discoveryChannel } from './protocol.js'
6
+ import { K, envelope, discoveryChannel, discoveryChannels } from './protocol.js'
7
7
  import { Transport } from './transport.js'
8
8
  import { Room, STATUS } from './room.js'
9
9
  import { createRepGate, rankRooms } from './reputation.js'
@@ -28,21 +28,44 @@ export class Lobby extends Emitter {
28
28
  if (env.k === K.INFO) { if (this._infoCollector) this._infoCollector(from, env.d && env.d.summary) }
29
29
  else if (env.k === K.INVITE) this.emit('invite', { from, ...(env.d || {}) })
30
30
  })
31
- const dc = discoveryChannel(this.gameId)
31
+ // Hay un canal de descubrimiento POR NODO (ver protocol.js): el
32
+ // descubrimiento no tiene dueño natural, así que en vez de designar un nodo
33
+ // árbitro se publica en el propio y se lee de todos.
34
+ const esDescubrimiento = (channel) => this._discoveryChannels().includes(channel)
32
35
  // Descubrimiento en vivo: requiere observar el canal (watch) para recibir
33
36
  // estos eventos sin publicarnos como sala. Si el proxy no soporta watch,
34
37
  // _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('channel_joined', (channel, token) => { if (esDescubrimiento(channel) && token !== this.transport.token) this.emit('rooms-changed', { type: 'joined', token }) })
39
+ this.transport.on('channel_left', (channel, token) => { if (esDescubrimiento(channel)) this.emit('rooms-changed', { type: 'left', token }) })
40
+ this.transport.on('peer_disconnected', (token, channel) => { if (!channel || esDescubrimiento(channel)) this.emit('rooms-changed', { type: 'left', token }) })
38
41
  this.transport.on('reconnect', () => this._watchDiscovery())
39
42
  this._watchDiscovery()
40
43
  }
41
44
 
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.
45
+ /** Los nodos que conoce el proxio al que estamos conectados (él incluido). */
46
+ _knownNodes () {
47
+ const t = this.transport
48
+ return (t && (t.knownNodes || (t.client && t.client.knownNodes))) || []
49
+ }
50
+
51
+ /** Un canal de descubrimiento por nodo conocido. */
52
+ _discoveryChannels () {
53
+ return discoveryChannels(this.gameId, this._knownNodes())
54
+ }
55
+
56
+ /** El canal donde ANUNCIAMOS: el del proxio al que estamos conectados. */
57
+ _myDiscoveryChannel () {
58
+ const t = this.transport
59
+ return discoveryChannel(this.gameId, (t && (t.node || (t.client && t.client.node))) || null)
60
+ }
61
+
62
+ // Observar los canales de descubrimiento para recibir altas/bajas de salas en
63
+ // vivo (proxy ≥ 0.6.2). Fire-and-forget: no bloquea ni rompe si no está
64
+ // soportado. Son N canales, uno por nodo conocido — hoy dos.
44
65
  _watchDiscovery () {
45
- try { this.transport.watch(discoveryChannel(this.gameId)) } catch (_) {}
66
+ for (const ch of this._discoveryChannels()) {
67
+ try { this.transport.watch(ch) } catch (_) {}
68
+ }
46
69
  }
47
70
 
48
71
  // ── Crear / unir ────────────────────────────────────────────────
@@ -77,8 +100,13 @@ export class Lobby extends Emitter {
77
100
  async listRooms (opts = {}) {
78
101
  await this.transport.connect()
79
102
  const timeout = opts.timeout || 1500
80
- let tokens = []
81
- try { tokens = await this.transport.list(discoveryChannel(this.gameId)) } catch (_) {}
103
+ // Se pregunta en CADA nodo y se mezcla. Un nodo que no conteste no rompe la
104
+ // lista: se ven las salas de los demás, que es justo lo que se gana al no
105
+ // tener un nodo árbitro.
106
+ const listas = await Promise.all(this._discoveryChannels().map(
107
+ (ch) => this.transport.list(ch).catch(() => [])
108
+ ))
109
+ const tokens = [...new Set(listas.flat())]
82
110
  const others = tokens.filter(t => t && t !== this.transport.token)
83
111
  const summaries = await this._gatherInfo(others, timeout)
84
112
  if (opts.enrich === false) return summaries
package/src/protocol.js CHANGED
@@ -49,28 +49,56 @@ export const K = {
49
49
  /**
50
50
  * NODO DUEÑO DE LOS CANALES.
51
51
  *
52
- * Un canal del proxio puede llevar delante el prefijo del nodo que lo hospeda
53
- * (`P1/loquesea`). Ese nodo guarda la membresía y los demás le pasan las
54
- * operaciones. Sin prefijo, cada proxio tiene su propia copia del canal — que es
55
- * lo que hacía que dos personas en proxios distintos NO se vieran en la misma
56
- * lista de salas, aunque los mensajes entre ellas sí cruzaran.
52
+ * Un canal del proxio lleva delante el id del nodo que lo hospeda
53
+ * (`<id de 12>/loquesea`). Ese nodo guarda la membresía y los demás le pasan las
54
+ * operaciones. Sin ese prefijo, cada proxio tiene su propia copia del canal.
57
55
  *
58
- * El **canal de descubrimiento** es uno solo para todo el ecosistema, así que
59
- * tiene que vivir en un nodo fijo: si cada quien lo publicara en el suyo,
60
- * habría tantas listas de salas como proxios. Se puede cambiar con
61
- * `setLobbyHomeNode()` para una malla propia.
56
+ * Los dos canales del lobby resuelven su dueño de forma distinta, y la razón es
57
+ * que uno tiene dueño natural y el otro no:
58
+ *
59
+ * · LA SALA lo tiene: vive en el proxio de quien la abrió, y eso se LEE del
60
+ * propio `roomId` (que es la instancia del host y lleva su id delante). Nadie
61
+ * lo declara ni lo acuerda.
62
+ *
63
+ * · EL DESCUBRIMIENTO no: es un nombre global del ecosistema y no lo crea
64
+ * nadie en particular. Poner el id del primero que llegue no sirve — el
65
+ * segundo host, en otro proxio, crearía otro canal y volverían a ser dos
66
+ * listas.
67
+ *
68
+ * Para el descubrimiento se eligió NO designar un árbitro: **cada proxio tiene su
69
+ * lista y quien busca pregunta en todos**. Se publica en uno, se lee de varios.
70
+ * Así no hay punto único de fallo (si un nodo no contesta, se ven las salas de
71
+ * los demás) ni nadie que decida dónde vive la lista del ecosistema. El costo es
72
+ * una consulta por nodo, y lo paga el CLIENTE —no el servidor— así que no
73
+ * reaparece el fan-out en el proxio, que ya habíamos descartado.
62
74
  */
63
- let LOBBY_HOME_NODE = 'P1'
64
-
65
- /** Fija en qué proxio vive el canal de descubrimiento de salas. */
66
- export function setLobbyHomeNode (prefix) {
67
- LOBBY_HOME_NODE = /^[1-9A-Z]{2}$/.test(String(prefix || '')) ? prefix : null
68
- }
75
+ const NODE_ID_LEN = 12
76
+ // Comprueba la FORMA, no el alfabeto exacto, y es a propósito: el alfabeto lo
77
+ // decide el proxio (que excluye los caracteres confundibles) y esta librería no
78
+ // tiene por qué llevar una copia que se desincronice. Un id con un símbolo que
79
+ // el proxio no emite simplemente no va a coincidir con ningún nodo.
80
+ const isNodeId = (s) => new RegExp(`^[1-9A-Z]{${NODE_ID_LEN}}$`).test(String(s || ''))
69
81
 
70
82
  const withNode = (prefix, name) => (prefix ? `${prefix}/${name}` : name)
71
83
 
72
- /** Canal de descubrimiento de salas de un juego (lista instancias de hosts). */
73
- export const discoveryChannel = (gameId) => withNode(LOBBY_HOME_NODE, `cclobby/${gameId}`)
84
+ /**
85
+ * Canal de descubrimiento de un juego EN UN NODO concreto.
86
+ * Sin `nodeId` (proxio sin identidad, o dev) devuelve el nombre pelado, que el
87
+ * servidor trata como canal local — el comportamiento de siempre.
88
+ */
89
+ export const discoveryChannel = (gameId, nodeId) =>
90
+ withNode(isNodeId(nodeId) ? nodeId : null, `cclobby/${gameId}`)
91
+
92
+ /**
93
+ * Todos los canales de descubrimiento a consultar: el del propio proxio y el de
94
+ * cada nodo que conozca. Se deduplica y se deja el propio PRIMERO, porque es el
95
+ * que va a contestar más rápido y el que se usa para publicar.
96
+ */
97
+ export function discoveryChannels (gameId, nodeIds = []) {
98
+ const ids = nodeIds.filter((n, i, a) => isNodeId(n) && a.indexOf(n) === i)
99
+ if (!ids.length) return [`cclobby/${gameId}`]
100
+ return ids.map((id) => discoveryChannel(gameId, id))
101
+ }
74
102
 
75
103
  /**
76
104
  * Canal de presencia de una sala concreta (host + guests publican aquí).
@@ -81,8 +109,8 @@ export const discoveryChannel = (gameId) => withNode(LOBBY_HOME_NODE, `cclobby/$
81
109
  * declararlo ni acordarlo.
82
110
  */
83
111
  export const roomChannel = (gameId, roomId) => {
84
- const prefix = /^[1-9A-Z]{2}/.test(String(roomId || '')) ? String(roomId).slice(0, 2) : null
85
- return withNode(prefix, `ccroom/${gameId}/${roomId}`)
112
+ const id = String(roomId || '').slice(0, NODE_ID_LEN)
113
+ return withNode(isNodeId(id) ? id : null, `ccroom/${gameId}/${roomId}`)
86
114
  }
87
115
 
88
116
  /**
package/src/room.js CHANGED
@@ -114,20 +114,30 @@ export class Room extends Emitter {
114
114
  this._members.set(this.transport.token, { pubkey: this.myPubkey, name: this.myName, verified: true, lastSeen: clock.now() })
115
115
  this._wire()
116
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 (_) {}
117
+ try { await this.transport.publish(this._myDiscoveryChannel(), { roomName: this.name, gameType: this.gameId }) } catch (_) {}
118
118
  try { await this.transport.publish(roomChannel(this.gameId, this.roomId)) } catch (_) {}
119
119
  this._startHeartbeat()
120
120
  this._afterStateChange()
121
121
  return this
122
122
  }
123
123
 
124
+ /**
125
+ * El canal de descubrimiento donde ANUNCIAMOS esta sala: el del proxio al que
126
+ * estamos conectados. Se publica en uno solo (el propio) y quien busca lee de
127
+ * todos — así el descubrimiento no necesita un nodo árbitro. Ver protocol.js.
128
+ */
129
+ _myDiscoveryChannel () {
130
+ const t = this.transport
131
+ return discoveryChannel(this.gameId, (t && (t.node || (t.client && t.client.node))) || null)
132
+ }
133
+
124
134
  // Re-publica la sala periódicamente: las entradas de canal del proxy expiran
125
135
  // (~20 min), así que sin esto una sala desaparecería del descubrimiento en
126
136
  // partidas largas. Reemplaza el _republish casero que hacían los bots.
127
137
  _startHeartbeat () {
128
138
  if (this._heartbeat) clearInterval(this._heartbeat)
129
139
  this._heartbeat = setInterval(() => {
130
- this.transport.publish(discoveryChannel(this.gameId), { roomName: this.name, gameType: this.gameId }).catch(() => {})
140
+ this.transport.publish(this._myDiscoveryChannel(), { roomName: this.name, gameType: this.gameId }).catch(() => {})
131
141
  this.transport.publish(roomChannel(this.gameId, this.roomId)).catch(() => {})
132
142
  }, 10 * 60 * 1000)
133
143
  if (this._heartbeat.unref) this._heartbeat.unref()
@@ -792,7 +802,7 @@ export class Room extends Emitter {
792
802
  }, 20000)
793
803
  if (this._rekeyTimer.unref) this._rekeyTimer.unref()
794
804
  }
795
- this.transport.publish(discoveryChannel(this.gameId), { roomName: this.name, gameType: this.gameId }).catch(() => {})
805
+ this.transport.publish(this._myDiscoveryChannel(), { roomName: this.name, gameType: this.gameId }).catch(() => {})
796
806
  this.transport.publish(roomChannel(this.gameId, this.roomId)).catch(() => {})
797
807
  this._afterStateChange() // re-difundir estado a los miembros vivos
798
808
  } else {
package/src/transport.js CHANGED
@@ -46,6 +46,10 @@ export class Transport extends Emitter {
46
46
  }
47
47
 
48
48
  get token () { return this.proxy ? this.proxy.token : null }
49
+ /** Id del proxio al que estamos conectados (12 chars, derivado de su llave). */
50
+ get node () { return this.proxy ? (this.proxy.node || null) : null }
51
+ /** Ese proxio + los que conoce. Es a quiénes se les pregunta por salas. */
52
+ get knownNodes () { return (this.proxy && this.proxy.knownNodes) || [] }
49
53
  get isReady () { return this._ready && !!this.token }
50
54
 
51
55
  /** Conecta (si hace falta) e identifica con el vault. Idempotente. */