@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/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
|
+
}
|
package/src/protocol.js
ADDED
|
@@ -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
|
+
}
|