@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/transport.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Transporte de lobby: una sola conexión al proxy Dotrino, reutilizable por
|
|
2
|
+
// todas las salas/juegos de la app. Encapsula connect + identify (firmado por el
|
|
3
|
+
// vault), canales, envío y el demux de mensajes namespaced por gameId.
|
|
4
|
+
//
|
|
5
|
+
// NO abre una conexión nueva si ya hay una: usa el singleton del proxy-client,
|
|
6
|
+
// igual que el messenger. La identidad de red coincide con la de firma (identify
|
|
7
|
+
// con sobre firmado por id.signData), lo que habilita la cola offline.
|
|
8
|
+
|
|
9
|
+
import { Emitter, clock } from './util.js'
|
|
10
|
+
import { parseEnvelope } from './protocol.js'
|
|
11
|
+
|
|
12
|
+
let _defaultGetClient = null
|
|
13
|
+
let _defaultIdentityConnect = null
|
|
14
|
+
|
|
15
|
+
// Carga perezosa de los paquetes del ecosistema (peer deps). Permite testear con
|
|
16
|
+
// transportes inyectados sin requerir los paquetes instalados.
|
|
17
|
+
async function loadProxyFactory () {
|
|
18
|
+
if (_defaultGetClient) return _defaultGetClient
|
|
19
|
+
const mod = await import('@dotrino/proxy-client')
|
|
20
|
+
_defaultGetClient = mod.getWebSocketProxyClient
|
|
21
|
+
return _defaultGetClient
|
|
22
|
+
}
|
|
23
|
+
async function loadIdentity () {
|
|
24
|
+
if (_defaultIdentityConnect) return _defaultIdentityConnect
|
|
25
|
+
const mod = await import('@dotrino/identity')
|
|
26
|
+
_defaultIdentityConnect = mod.Identity.connect.bind(mod.Identity)
|
|
27
|
+
return _defaultIdentityConnect
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class Transport extends Emitter {
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {object} [opts.proxy] cliente proxy ya creado (si no, se usa el singleton)
|
|
34
|
+
* @param {object} [opts.identity] instancia de Identity ya conectada (para identify/firmas)
|
|
35
|
+
* @param {string} [opts.url] URL del proxy
|
|
36
|
+
*/
|
|
37
|
+
constructor (opts = {}) {
|
|
38
|
+
super()
|
|
39
|
+
this.proxy = opts.proxy || null
|
|
40
|
+
this.identity = opts.identity || null
|
|
41
|
+
this.url = opts.url || null
|
|
42
|
+
this._ready = false
|
|
43
|
+
this._wired = false
|
|
44
|
+
this._connecting = null
|
|
45
|
+
this._subs = new Map() // gameId → Set<fn(from, env, meta)>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get token () { return this.proxy ? this.proxy.token : null }
|
|
49
|
+
get isReady () { return this._ready && !!this.token }
|
|
50
|
+
|
|
51
|
+
/** Conecta (si hace falta) e identifica con el vault. Idempotente. */
|
|
52
|
+
async connect () {
|
|
53
|
+
if (this.isReady) return this.token
|
|
54
|
+
if (this._connecting) return this._connecting
|
|
55
|
+
this._connecting = (async () => {
|
|
56
|
+
if (!this.proxy) {
|
|
57
|
+
const getClient = await loadProxyFactory()
|
|
58
|
+
this.proxy = getClient(this.url ? { url: this.url } : undefined)
|
|
59
|
+
}
|
|
60
|
+
if (this.url && this.proxy.updateConfig) this.proxy.updateConfig({ url: this.url })
|
|
61
|
+
if (!this.identity) {
|
|
62
|
+
try { const connect = await loadIdentity(); this.identity = await connect() } catch (_) { this.identity = null }
|
|
63
|
+
}
|
|
64
|
+
this._wire()
|
|
65
|
+
await this.proxy.connect()
|
|
66
|
+
await this._identify()
|
|
67
|
+
this._ready = true
|
|
68
|
+
this.emit('ready', this.token)
|
|
69
|
+
return this.token
|
|
70
|
+
})()
|
|
71
|
+
try { return await this._connecting } finally { this._connecting = null }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_wire () {
|
|
75
|
+
if (this._wired) return
|
|
76
|
+
this._wired = true
|
|
77
|
+
|
|
78
|
+
// Reconexión: el proxy reasigna token; reidentificamos y avisamos para que
|
|
79
|
+
// las salas vuelvan a anunciarse / re-saludar.
|
|
80
|
+
let firstToken = false
|
|
81
|
+
this.proxy.on('token', async () => {
|
|
82
|
+
if (!firstToken) { firstToken = true; return } // el primer token lo maneja connect()
|
|
83
|
+
try { await this._identify() } catch (e) { console.warn('[lobby] re-identify falló:', e) }
|
|
84
|
+
this.emit('reconnect', this.token)
|
|
85
|
+
this.emit('ready', this.token)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
this.proxy.on('message', (from, payload, meta) => {
|
|
89
|
+
const env = parseEnvelope(payload)
|
|
90
|
+
if (!env) return
|
|
91
|
+
const subs = this._subs.get(env.g)
|
|
92
|
+
if (!subs || !subs.size) return
|
|
93
|
+
for (const fn of [...subs]) {
|
|
94
|
+
try { fn(from, env, meta) } catch (e) { console.error('[lobby] sub handler error:', e) }
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
// Eventos de presencia (no namespaced; las salas filtran por canal).
|
|
99
|
+
this.proxy.on('peer_disconnected', (token, channel) => this.emit('peer_disconnected', token, channel || null))
|
|
100
|
+
this.proxy.on('channel_joined', (channel, token) => this.emit('channel_joined', channel, token))
|
|
101
|
+
this.proxy.on('channel_left', (channel, token) => this.emit('channel_left', channel, token))
|
|
102
|
+
this.proxy.on('disconnect', (d) => this.emit('disconnect', d))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async _identify () {
|
|
106
|
+
if (!this.identity || !this.proxy || !this.proxy.token) return
|
|
107
|
+
const me = this.identity.me
|
|
108
|
+
const publickey = me && me.publickey
|
|
109
|
+
if (!publickey) return
|
|
110
|
+
const data = { op: 'identify', publickey, token: this.proxy.token, ts: clock.now() }
|
|
111
|
+
const signed = await this.identity.signData(data)
|
|
112
|
+
const signature = typeof signed === 'string' ? signed : signed.signature
|
|
113
|
+
await this.proxy.identify({ data, signature })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Demultiplexor: registra un handler para un gameId. Devuelve desuscriptor. */
|
|
117
|
+
subscribe (gameId, fn) {
|
|
118
|
+
let set = this._subs.get(gameId)
|
|
119
|
+
if (!set) { set = new Set(); this._subs.set(gameId, set) }
|
|
120
|
+
set.add(fn)
|
|
121
|
+
return () => { const s = this._subs.get(gameId); if (s) s.delete(fn) }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Envío directo por token (intenta WebRTC, cae a proxy). */
|
|
125
|
+
send (to, env) { this.proxy.send(to, env) }
|
|
126
|
+
|
|
127
|
+
/** Envío por pubkey estable (cola offline 24 h). Para invitaciones a contactos. */
|
|
128
|
+
sendByPubkey (pubkeys, env) { this.proxy.sendByPubkey(pubkeys, env) }
|
|
129
|
+
|
|
130
|
+
// ── Canales ────────────────────────────────────────────────────
|
|
131
|
+
publish (channel, extra) { return this.proxy.publish(channel, extra) }
|
|
132
|
+
unpublish (channel) { return this.proxy.unpublish(channel) }
|
|
133
|
+
list (channel) { return this.proxy.list(channel) }
|
|
134
|
+
listChannels (options) { return this.proxy.listChannels(options) }
|
|
135
|
+
channelCount (channel) { return this.proxy.channelCount(channel) }
|
|
136
|
+
|
|
137
|
+
// Observación read-only de un canal (proxy ≥ 0.6.2). Degrada elegante: si el
|
|
138
|
+
// cliente/proxy no lo soporta o no responde, resuelve sin colgar (la app cae a
|
|
139
|
+
// polling). Nunca bloquea ni rechaza hacia arriba.
|
|
140
|
+
watch (channel) {
|
|
141
|
+
if (!this.proxy || typeof this.proxy.watch !== 'function') return Promise.resolve(null)
|
|
142
|
+
return Promise.race([
|
|
143
|
+
this.proxy.watch(channel).catch(() => null),
|
|
144
|
+
new Promise(res => { const t = setTimeout(() => res(null), 4000); if (t.unref) t.unref() })
|
|
145
|
+
])
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
unwatch (channel) {
|
|
149
|
+
if (!this.proxy || typeof this.proxy.unwatch !== 'function') return Promise.resolve(null)
|
|
150
|
+
return this.proxy.unwatch(channel).catch(() => null)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── WebRTC (opcional, para partidas de baja latencia) ─────────
|
|
154
|
+
connectWebRTC (token) { return this.proxy.connectWebRTC(token) }
|
|
155
|
+
isWebRTCOpen (token) { return this.proxy.isWebRTCOpen(token) }
|
|
156
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Utilidades internas de @dotrino/lobby.
|
|
2
|
+
// Sin dependencias: EventEmitter mínimo, RNG determinista sembrado,
|
|
3
|
+
// normalización de asientos y helpers varios.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* EventEmitter mínimo (navegador + node). on() devuelve un desuscriptor.
|
|
7
|
+
* Los errores en un handler no rompen al resto de handlers.
|
|
8
|
+
*/
|
|
9
|
+
export class Emitter {
|
|
10
|
+
constructor () { this._h = new Map() }
|
|
11
|
+
|
|
12
|
+
on (event, handler) {
|
|
13
|
+
if (typeof handler !== 'function') return () => {}
|
|
14
|
+
let set = this._h.get(event)
|
|
15
|
+
if (!set) { set = new Set(); this._h.set(event, set) }
|
|
16
|
+
set.add(handler)
|
|
17
|
+
return () => this.off(event, handler)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
once (event, handler) {
|
|
21
|
+
const off = this.on(event, (...args) => { off(); handler(...args) })
|
|
22
|
+
return off
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
off (event, handler) {
|
|
26
|
+
const set = this._h.get(event)
|
|
27
|
+
if (set) set.delete(handler)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
emit (event, ...args) {
|
|
31
|
+
const set = this._h.get(event)
|
|
32
|
+
if (!set) return
|
|
33
|
+
for (const h of [...set]) {
|
|
34
|
+
try { h(...args) } catch (e) { console.error(`[lobby] handler error on "${event}":`, e) }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
removeAllListeners () { this._h.clear() }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Reloj inyectable (los tests pueden sustituirlo). */
|
|
42
|
+
export const clock = { now: () => Date.now() }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* PRNG determinista (mulberry32). Devuelve floats en [0,1).
|
|
46
|
+
* Misma semilla ⇒ misma secuencia, en cualquier máquina. Esto permite que el
|
|
47
|
+
* host genere azar (barajar/dados) de forma reproducible y verificable.
|
|
48
|
+
* @param {number} seed entero de 32 bits
|
|
49
|
+
*/
|
|
50
|
+
export function mulberry32 (seed) {
|
|
51
|
+
let a = seed >>> 0
|
|
52
|
+
return function rng () {
|
|
53
|
+
a |= 0; a = (a + 0x6D2B79F5) | 0
|
|
54
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
55
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
56
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Hash de string → entero de 32 bits (FNV-1a). Para derivar semillas de seeds string. */
|
|
61
|
+
export function hashSeed (str) {
|
|
62
|
+
let h = 0x811c9dc5
|
|
63
|
+
const s = String(str)
|
|
64
|
+
for (let i = 0; i < s.length; i++) {
|
|
65
|
+
h ^= s.charCodeAt(i)
|
|
66
|
+
h = Math.imul(h, 0x01000193)
|
|
67
|
+
}
|
|
68
|
+
return h >>> 0
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Baraja Fisher–Yates in-place usando un rng() dado (no muta el original si se pasa copia). */
|
|
72
|
+
export function shuffle (array, rng) {
|
|
73
|
+
for (let i = array.length - 1; i > 0; i--) {
|
|
74
|
+
const j = Math.floor(rng() * (i + 1))
|
|
75
|
+
const tmp = array[i]; array[i] = array[j]; array[j] = tmp
|
|
76
|
+
}
|
|
77
|
+
return array
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Clon profundo (structuredClone si existe; si no, JSON). */
|
|
81
|
+
export function clone (value) {
|
|
82
|
+
if (value == null) return value
|
|
83
|
+
try {
|
|
84
|
+
if (typeof structuredClone === 'function') return structuredClone(value)
|
|
85
|
+
} catch (_) { /* fall through */ }
|
|
86
|
+
return JSON.parse(JSON.stringify(value))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Normaliza la especificación de asientos a una forma canónica.
|
|
91
|
+
* - Array de ids → asientos nombrados (p.ej. ['white','black']).
|
|
92
|
+
* - { min, max } → genera ids 's1'..'sN'.
|
|
93
|
+
* @returns {{ ids: string[], min: number, max: number, named: boolean }}
|
|
94
|
+
*/
|
|
95
|
+
export function normalizeSeats (spec) {
|
|
96
|
+
if (Array.isArray(spec)) {
|
|
97
|
+
if (!spec.length) throw new Error('[lobby] seats: el array de asientos no puede estar vacío')
|
|
98
|
+
const ids = spec.map(String)
|
|
99
|
+
return { ids, min: ids.length, max: ids.length, named: true }
|
|
100
|
+
}
|
|
101
|
+
const max = Math.max(1, (spec && (spec.max ?? spec.min)) || 2)
|
|
102
|
+
const min = Math.max(1, Math.min(max, (spec && spec.min) || max))
|
|
103
|
+
const ids = Array.from({ length: max }, (_, i) => 's' + (i + 1))
|
|
104
|
+
return { ids, min, max, named: false }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Igualdad de pubkeys tolerante a diferencias de formato JWK (orden de claves). */
|
|
108
|
+
export function samePubkey (a, b) {
|
|
109
|
+
if (!a || !b) return false
|
|
110
|
+
if (a === b) return true
|
|
111
|
+
try {
|
|
112
|
+
const pa = typeof a === 'string' ? JSON.parse(a) : a
|
|
113
|
+
const pb = typeof b === 'string' ? JSON.parse(b) : b
|
|
114
|
+
return pa && pb && pa.x === pb.x && pa.y === pb.y && pa.crv === pb.crv
|
|
115
|
+
} catch (_) { return false }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Promesa que resuelve tras `ms`. */
|
|
119
|
+
export function delay (ms) { return new Promise(r => setTimeout(r, ms)) }
|
|
120
|
+
|
|
121
|
+
/** Id corto aleatorio (no criptográfico) para correlación de mensajes. */
|
|
122
|
+
export function shortId () {
|
|
123
|
+
try {
|
|
124
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID().slice(0, 8)
|
|
125
|
+
} catch (_) {}
|
|
126
|
+
return Math.floor(Math.random() * 0xffffffff).toString(36)
|
|
127
|
+
}
|