@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 seyacat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,251 @@
1
+ # @dotrino/lobby
2
+
3
+ **Lobby + matchmaking reciclable para cualquier juego del ecosistema Dotrino.**
4
+
5
+ Resuelve, una sola vez y para todos los juegos, lo que el ajedrez, el chat y
6
+ compañía venían reimplementando a mano: **descubrir/crear/unir salas, asientos,
7
+ espectadores, autoridad de host (serverless), presencia, pausa/reconexión por
8
+ identidad, sincronización autoritativa con resync, verificación del oponente,
9
+ matchmaking filtrado por reputación y recibo de partida co-firmado.**
10
+
11
+ Está construido **sobre** los pilares compartidos — no reimplementa transporte ni
12
+ identidad:
13
+
14
+ - `@dotrino/proxy-client` — transporte (canales, `identify`,
15
+ `send`/`sendByPubkey`, WebRTC). **Una sola conexión**, reutilizable.
16
+ - `@dotrino/identity` — vault (firma, challenge/response,
17
+ **contactos**).
18
+ - `@dotrino/reputation` — web-of-trust (gate de admisión,
19
+ ranking, atestaciones `txBound`). *Opcional.*
20
+
21
+ Headless y framework-agnóstico (vanilla + EventEmitter). Sirve igual para Vue,
22
+ vanilla o nativo-vía-WebView.
23
+
24
+ ---
25
+
26
+ ## Idea central
27
+
28
+ El **host es uno de los jugadores** (no hay servidor central: *tu info, en tu
29
+ máquina*). La librería separa dos cosas:
30
+
31
+ 1. **Lobby/Room** — toda la fontanería de salas/asientos/presencia/sync.
32
+ 2. **Motor de turnos** *(opcional)* — el juego aporta **funciones puras**
33
+ (`initialState`, `reducer`, `view`, `isOver`) y la lib valida en el host,
34
+ sincroniza y hace resync. El ajedrez queda casi trivial; las cartas funcionan
35
+ gracias a `view` (estado oculto por asiento) y al azar determinista sembrado.
36
+
37
+ ---
38
+
39
+ ## Instalación
40
+
41
+ ```bash
42
+ npm i @dotrino/lobby \
43
+ @dotrino/proxy-client \
44
+ @dotrino/identity \
45
+ @dotrino/reputation
46
+ ```
47
+
48
+ ---
49
+
50
+ ## Quick start
51
+
52
+ ```js
53
+ import { createLobby } from '@dotrino/lobby'
54
+
55
+ // 1) Definí tu juego como funciones puras (ejemplo: "tres en raya")
56
+ const tictactoe = {
57
+ initialState: { board: Array(9).fill(null), turn: 'x' },
58
+ reducer (state, action, ctx) { // ctx = { seat, seats, rng, now }
59
+ const mark = ctx.seat === 'x' ? 'x' : 'o'
60
+ if (state.turn !== mark) throw new Error('no-es-tu-turno')
61
+ if (state.board[action.cell]) throw new Error('ocupada')
62
+ const board = state.board.slice()
63
+ board[action.cell] = mark
64
+ return { board, turn: mark === 'x' ? 'o' : 'x' }
65
+ },
66
+ isOver (state) {
67
+ const L = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]]
68
+ for (const [a,b,c] of L) {
69
+ const v = state.board[a]
70
+ if (v && v === state.board[b] && v === state.board[c]) return { winner: v, reason: 'line' }
71
+ }
72
+ if (state.board.every(Boolean)) return { winner: null, reason: 'draw' }
73
+ return null
74
+ }
75
+ }
76
+
77
+ // 2) Creá el lobby (conecta e identifica con el vault solo)
78
+ const lobby = await createLobby({
79
+ gameId: 'tictactoe',
80
+ seats: ['x', 'o'],
81
+ engine: tictactoe,
82
+ start: 'ready', // arranca cuando ambos asientos están "listos"
83
+ onSeatVacated: 'pause', // si alguien se va: pausa + reconexión por pubkey
84
+ matchmaking: { minReputation: 0.15, preferContacts: true }
85
+ })
86
+
87
+ // 3) Partida rápida
88
+ const room = await lobby.quickMatch({ autoSeat: true })
89
+
90
+ room.on('update', s => render(s)) // s.seats, s.status, s.spectators
91
+ room.on('state', game => renderBoard(game.board))// vista del juego para MI asiento
92
+ room.on('ended', r => alert(`Ganó ${r.winner}`))
93
+
94
+ // 4) Jugar
95
+ boardEl.onclick = cell => room.action({ cell })
96
+ ```
97
+
98
+ Crear/listar/unir manualmente:
99
+
100
+ ```js
101
+ const room = await lobby.createRoom({ name: 'Mi sala' }) // sos host
102
+ const rooms = await lobby.listRooms() // [{ roomId, name, openSeats, hostScore, isContact, ... }]
103
+ const room2 = await lobby.joinRoom(rooms[0].roomId) // sos guest
104
+ ```
105
+
106
+ ---
107
+
108
+ ## Asientos, espectadores y ciclo de vida
109
+
110
+ ```js
111
+ room.takeSeat('x') // tomar un asiento LIBRE (sin id → el primero libre)
112
+ room.setReady(true) // start:'ready' arranca cuando todos los ocupados están listos
113
+ room.leaveSeat() // volver a espectador
114
+ room.spectate() // mirar sin jugar (recibe el estado, no puede actuar)
115
+ room.mySeat // 'x' | 'o' | null
116
+ ```
117
+
118
+ - **Sólo se toman asientos libres.** Para cambiar, el ocupante deja el suyo primero.
119
+ - **Espectadores**: reciben todos los `state`/`event`; sus `action()` se rechazan.
120
+ Un espectador puede `takeSeat()` si hay un asiento libre (así "otro toma el
121
+ lugar"). Con `onSeatVacated:'fill'`, la cola de espectadores rellena automático.
122
+ - **Estados**: `waiting → playing → ended`, con `paused` intermedio.
123
+
124
+ ### ¿Qué pasa si un jugador se va / se cae?
125
+
126
+ Configurable con `onSeatVacated` (y el asiento va por **pubkey del vault**, no por
127
+ token efímero):
128
+
129
+ | Política | Comportamiento |
130
+ |---|---|
131
+ | `'pause'` *(default)* | La partida se pausa; el mismo jugador **recupera su asiento** al reconectar (dentro de `disconnectGraceMs`). Si vence el grace, el asiento se libera. |
132
+ | `'forfeit'` | Irse/caerse termina la partida a favor del resto (ranked). |
133
+ | `'fill'` | El asiento se libera y el primer espectador en cola lo toma; la partida sigue (drop-in/drop-out). |
134
+
135
+ ---
136
+
137
+ ## Motor de turnos (autoritativo)
138
+
139
+ El host instancia el motor; los guests sólo reflejan la **vista** que reciben.
140
+
141
+ ```js
142
+ const engine = {
143
+ initialState, // valor | (rng) => estado (rng determinista, sembrado)
144
+ reducer (state, action, ctx), // ctx = { seat, seats, rng, now }; throw Error(reason) para rechazar
145
+ view (state, seat), // opcional: proyección por asiento (info oculta). Default: estado completo
146
+ isOver (state) // opcional: { winner, reason } | null
147
+ }
148
+ ```
149
+
150
+ - **Cartas / info oculta**: usá `view(state, seat)` para devolver sólo lo que ese
151
+ asiento puede ver (su mano). Los espectadores reciben `view(state, null)`.
152
+ - **Azar (barajar, dados)**: usá `ctx.rng()` (y el `rng` de `initialState`). Es
153
+ determinista por semilla → reproducible y registrable en el recibo.
154
+ - **Sin motor**: si no pasás `engine`, la sala funciona en **modo relay**:
155
+ `room.send(data)` / `room.on('message')` y el juego sincroniza su propio estado.
156
+
157
+ ---
158
+
159
+ ## Reputación + contactos
160
+
161
+ Atraviesa todo el flujo (inyectá `reputation` para activarlo):
162
+
163
+ ```js
164
+ // Gate de admisión (bidireccional): el host rechaza joiners y el lobby filtra salas.
165
+ matchmaking: { requireVouched: true, minReputation: 0.2, preferContacts: true }
166
+
167
+ // Invitar a un contacto (cola offline 24 h):
168
+ lobby.inviteContact(pubkey, { roomId: room.roomId, name: 'Revancha?' })
169
+ lobby.on('invite', inv => console.log('te invitaron a', inv.roomId))
170
+
171
+ // Verificación anti-impersonación: al sentarse se hace challenge/response.
172
+ // (requireVerify: true por defecto)
173
+
174
+ // Post-partida: calificar y agregar a contactos, con recibo co-firmado → txBound.
175
+ await room.ratePlayer(oppPubkey, { confianza: 5, afinidad: 3 }, { notes: 'gg' })
176
+ ```
177
+
178
+ ### Recibo de partida co-firmado
179
+
180
+ Al terminar, el host y cada co-jugador **co-firman** un recibo
181
+ `{ a, b, ts, sigA, sigB }` (cada uno firma `{op:'receipt',a,b,ts}` con su vault).
182
+ `ratePlayer` lo adjunta automáticamente, produciendo una atestación **`txBound`**:
183
+ prueba verificable de que *esos dos realmente jugaron juntos* (anti-sybil).
184
+
185
+ ```js
186
+ room.on('receipt', ({ pubkey, receipt }) => { /* guardado para el rating */ })
187
+ room.matchReceipt(pubkey) // { a, b, ts, sigA, sigB } | null
188
+ ```
189
+
190
+ ---
191
+
192
+ ## Eventos de `Room`
193
+
194
+ | Evento | Payload | Cuándo |
195
+ |---|---|---|
196
+ | `update` | `RoomState` | cambió asientos/estado/espectadores |
197
+ | `state` | `game` | cambió la vista de juego para mi asiento |
198
+ | `started` | `{ ts }` | arrancó la partida |
199
+ | `ended` | `{ winner, reason }` | terminó |
200
+ | `event` | `{ event, data }` | evento lateral genérico |
201
+ | `chat` | `{ from, name, text, ts }` | mensaje de chat |
202
+ | `message` | `data` | relay opaco (modo sin motor) |
203
+ | `rejected` | `{ reason }` | tu acción fue rechazada |
204
+ | `kicked` | `{ reason }` | el host te expulsó (verify/reputación) |
205
+ | `closed` | `{ reason }` | el host cerró / se perdió |
206
+ | `receipt` | `{ pubkey, receipt }` | recibo co-firmado listo |
207
+ | `left` | — | saliste de la sala (`room.leave()`) |
208
+
209
+ ---
210
+
211
+ ## API
212
+
213
+ ```ts
214
+ createLobby(opts): Promise<Lobby>
215
+
216
+ Lobby.createRoom(opts?): Promise<Room>
217
+ Lobby.joinRoom(roomId, opts?): Promise<Room>
218
+ Lobby.listRooms(opts?): Promise<RoomSummary[]>
219
+ Lobby.quickMatch(opts?): Promise<Room>
220
+ Lobby.inviteContact(pubkey, { roomId, name }): void
221
+ Lobby.listContacts(): Promise<PeerInfo[]>
222
+ Lobby.reputationOf(pubkey): Promise<AggregateResult>
223
+
224
+ Room.takeSeat(seat?) / leaveSeat() / setReady(b) / spectate()
225
+ Room.action(a) / chat(text) / send(data) / start()
226
+ Room.ratePlayer(pubkey, indicators, opts?) / matchReceipt(pubkey)
227
+ Room.mySeat / status / seats / spectators / game / result / state
228
+ Room.leave()
229
+ ```
230
+
231
+ Ver tipos completos en [`src/index.d.ts`](./src/index.d.ts).
232
+
233
+ ---
234
+
235
+ ## Notas de diseño / límites de v1
236
+
237
+ - `roomId === token del host` (modelo probado por el ajedrez). Si el host
238
+ **reconecta**, su token cambia → la sala se considera nueva y los guests caen
239
+ (`onHostLost: 'end'`). La **migración de host** (elección determinista del
240
+ jugador de menor pubkey) está prevista como `onHostLost: 'migrate'` y queda como
241
+ extensión futura.
242
+ - El recibo co-firmado se genera para los pares **host ↔ cada jugador** (cubre 1v1
243
+ completo, p.ej. ajedrez). Recibos entre guests en mesas de N>2 quedan pendientes.
244
+ - Sincronización por **snapshot completo personalizado** por cambio (simple y
245
+ correcto para juegos por turnos). Deltas/patches son una optimización futura.
246
+
247
+ ## Tests
248
+
249
+ ```bash
250
+ npm test # node --test
251
+ ```
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@dotrino/lobby",
3
+ "version": "0.1.5",
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
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "module": "src/index.js",
8
+ "types": "src/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/index.d.ts",
12
+ "import": "./src/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "test": "node --test"
22
+ },
23
+ "keywords": [
24
+ "lobby",
25
+ "matchmaking",
26
+ "game",
27
+ "turn-based",
28
+ "p2p",
29
+ "webrtc",
30
+ "dotrino",
31
+ "web-of-trust"
32
+ ],
33
+ "peerDependencies": {
34
+ "@dotrino/proxy-client": ">=0.1.0",
35
+ "@dotrino/identity": ">=0.5.0",
36
+ "@dotrino/reputation": ">=0.3.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "@dotrino/reputation": { "optional": true }
40
+ },
41
+ "author": "seyacat",
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/imdotrino/dotrino-lobby.git"
46
+ }
47
+ }
package/src/engine.js ADDED
@@ -0,0 +1,96 @@
1
+ // Motor de turnos autoritativo (lado host).
2
+ //
3
+ // El juego aporta funciones puras y el motor se encarga de: mantener el estado
4
+ // autoritativo, validar/aplicar acciones, proyectar vistas por asiento (para
5
+ // info oculta como la mano de cartas), azar determinista sembrado y detección
6
+ // de fin de partida. Sólo se instancia en el host; los guests sólo guardan la
7
+ // vista que reciben.
8
+ //
9
+ // spec = {
10
+ // initialState: value | (rng) => state,
11
+ // reducer: (state, action, ctx) => newState // ctx = { seat, seats, rng, now }; throw para rechazar
12
+ // view?: (state, seat) => stateVisibleParaEseAsiento // default: estado completo
13
+ // isOver?: (state) => ({ winner, reason }) | falsy
14
+ // }
15
+
16
+ import { mulberry32, hashSeed, clone, clock } from './util.js'
17
+
18
+ export function createEngine (spec = {}) {
19
+ if (typeof spec.reducer !== 'function') {
20
+ throw new Error('[lobby] engine: spec.reducer es obligatorio')
21
+ }
22
+ const viewFn = typeof spec.view === 'function' ? spec.view : null
23
+ const isOverFn = typeof spec.isOver === 'function' ? spec.isOver : () => null
24
+
25
+ let state = null
26
+ let rng = null
27
+ let seed = null
28
+ let started = false
29
+
30
+ function makeInitial () {
31
+ const init = spec.initialState
32
+ if (typeof init === 'function') return init(rng)
33
+ if (init === undefined) return {}
34
+ return clone(init)
35
+ }
36
+
37
+ return {
38
+ /** ¿Ya arrancó la partida? */
39
+ get started () { return started },
40
+ /** Semilla usada (para recibos / reproducibilidad). */
41
+ get seed () { return seed },
42
+
43
+ /**
44
+ * Arranca (o reinicia) la partida con una semilla determinista.
45
+ * @param {number|string} [seedValue] si se omite, se deriva del reloj.
46
+ */
47
+ start (seedValue) {
48
+ seed = seedValue == null ? (clock.now() >>> 0) : seedValue
49
+ rng = mulberry32(typeof seed === 'number' ? seed : hashSeed(seed))
50
+ state = makeInitial()
51
+ started = true
52
+ return state
53
+ },
54
+
55
+ /**
56
+ * Aplica una acción de un asiento. Lanza Error(reason) si el reducer rechaza.
57
+ * @param {string} seat id del asiento que actúa
58
+ * @param {any} action
59
+ * @param {object} seatsSnapshot ocupación de asientos para contexto del reducer
60
+ * @returns {any} el nuevo estado autoritativo
61
+ */
62
+ apply (seat, action, seatsSnapshot) {
63
+ if (!started) throw new Error('game-not-started')
64
+ const ctx = { seat, seats: seatsSnapshot || {}, rng: rng || Math.random, now: clock.now() }
65
+ const next = spec.reducer(state, action, ctx)
66
+ if (next === undefined) throw new Error('reducer-returned-undefined')
67
+ state = next
68
+ return state
69
+ },
70
+
71
+ /** Estado autoritativo completo (host). */
72
+ getState () { return state },
73
+
74
+ /** Carga un estado completo (resync/persistencia). */
75
+ load (s) { state = s; started = true },
76
+
77
+ /** Proyección visible para un asiento (o null = espectador / vista pública).
78
+ * Una view() del juego que lanza no debe tumbar la difusión: degradamos a
79
+ * vista nula (segura respecto a info oculta) y logueamos. */
80
+ viewFor (seat) {
81
+ if (state == null) return null
82
+ if (!viewFn) return clone(state)
83
+ try { return viewFn(state, seat) }
84
+ catch (e) { console.warn('[lobby] view() lanzó para el asiento', seat, e); return null }
85
+ },
86
+
87
+ /** ¿Terminó? → { winner, reason } | null */
88
+ checkOver () {
89
+ if (state == null) return null
90
+ const r = isOverFn(state)
91
+ return r || null
92
+ },
93
+
94
+ reset () { state = null; rng = null; seed = null; started = false }
95
+ }
96
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,232 @@
1
+ // Tipos de @dotrino/lobby
2
+
3
+ export type Role = 'host' | 'guest'
4
+ export type RoomStatus = 'waiting' | 'playing' | 'paused' | 'ended'
5
+ export type SeatStatus = 'open' | 'occupied' | 'disconnected'
6
+ export type StartMode = 'ready' | 'full' | 'manual'
7
+ export type VacancyPolicy = 'pause' | 'forfeit' | 'fill'
8
+ export type HostLostPolicy = 'end' | 'migrate'
9
+
10
+ export const STATUS: { WAITING: 'waiting'; PLAYING: 'playing'; PAUSED: 'paused'; ENDED: 'ended' }
11
+ export const SEAT: { OPEN: 'open'; OCCUPIED: 'occupied'; DISCONNECTED: 'disconnected' }
12
+
13
+ /** Spec del motor de turnos autoritativo. El juego aporta funciones puras. */
14
+ export interface EngineSpec<S = any, A = any> {
15
+ /** Estado inicial (valor) o factoría que recibe el rng sembrado. */
16
+ initialState: S | ((rng: () => number) => S)
17
+ /** Aplica una acción. ctx = { seat, seats, rng, now }. Lanza Error(reason) para rechazar. */
18
+ reducer: (state: S, action: A, ctx: ReducerCtx) => S
19
+ /** Proyección visible para un asiento (o null = espectador). Default: estado completo. */
20
+ view?: (state: S, seat: string | null) => any
21
+ /** ¿Terminó? → { winner, reason } | falsy. */
22
+ isOver?: (state: S) => { winner: string | null; reason?: string } | null | undefined | false
23
+ }
24
+
25
+ export interface ReducerCtx {
26
+ seat: string
27
+ seats: Record<string, { pubkey: string | null; name: string | null; status: SeatStatus; occupied: boolean }>
28
+ rng: () => number
29
+ now: number
30
+ }
31
+
32
+ export interface SeatView {
33
+ id: string
34
+ pubkey: string | null
35
+ name: string | null
36
+ ready: boolean
37
+ status: SeatStatus
38
+ occupied: boolean
39
+ }
40
+
41
+ export interface SpectatorView { pubkey: string | null; name: string | null }
42
+
43
+ export interface RoomState {
44
+ roomId: string
45
+ gameId: string
46
+ name: string | null
47
+ hostPubkey: string | null
48
+ status: RoomStatus
49
+ seats: Record<string, SeatView>
50
+ spectators: SpectatorView[]
51
+ result: { winner: string | null; reason?: string } | null
52
+ game: any
53
+ version: number
54
+ }
55
+
56
+ export interface RoomSummary {
57
+ roomId: string
58
+ gameId: string
59
+ name: string | null
60
+ hostPubkey: string | null
61
+ status: RoomStatus
62
+ players: number
63
+ openSeats: number
64
+ max: number
65
+ spectators: number
66
+ seats: Array<{ id: string; status: SeatStatus; name: string | null }>
67
+ /** Añadidos por listRooms (enrich): reputación ponderada y flag de contacto. */
68
+ reputation?: any
69
+ hostScore?: number
70
+ isContact?: boolean
71
+ }
72
+
73
+ export interface Receipt { a: string; b: string; ts: number; sigA: string; sigB: string }
74
+
75
+ export interface MatchmakingConfig {
76
+ /** Sólo emparejar con avalados por tu red (trustedCount > 0). */
77
+ requireVouched?: boolean
78
+ /** Score mínimo 0..1 para admitir/emparejar. */
79
+ minReputation?: number
80
+ /** Priorizar salas de contactos al ordenar. Default true. */
81
+ preferContacts?: boolean
82
+ }
83
+
84
+ export interface CreateLobbyOptions {
85
+ /** Namespace del juego (canales). Obligatorio. */
86
+ gameId: string
87
+ /** Asientos: ['white','black'] o { min, max }. */
88
+ seats?: string[] | { min?: number; max?: number }
89
+ /** Motor de turnos opcional. Sin él, la sala funciona en modo relay. */
90
+ engine?: EngineSpec
91
+ /** Instancia de Identity ya conectada (si no, se conecta sola). */
92
+ identity?: any
93
+ /** Instancia de createVaultReputation (opcional). */
94
+ reputation?: any
95
+ /** Cliente proxy ya creado (reuso de conexión). */
96
+ proxy?: any
97
+ /** Transporte ya creado (avanzado / tests). */
98
+ transport?: Transport
99
+ /** URL del proxy. */
100
+ url?: string
101
+ start?: StartMode
102
+ onSeatVacated?: VacancyPolicy
103
+ onHostLost?: HostLostPolicy
104
+ allowSpectators?: boolean
105
+ maxSpectators?: number
106
+ /** Grace period (ms) para reclamar asiento tras caída. Default 45000. */
107
+ disconnectGraceMs?: number
108
+ /** Exigir challenge/response antes de sentar (anti-impersonación). Default true. */
109
+ requireVerify?: boolean
110
+ matchmaking?: MatchmakingConfig
111
+ /** Nombre a mostrar del jugador. */
112
+ playerName?: string
113
+ /** Semilla del motor (si se omite, la elige el host). */
114
+ seed?: number | string
115
+ }
116
+
117
+ export interface CreateRoomOptions { name?: string; playerName?: string; seed?: number | string }
118
+ export interface JoinRoomOptions { playerName?: string }
119
+ export interface QuickMatchOptions extends CreateRoomOptions, JoinRoomOptions {
120
+ /** Ventana (ms) para descubrir salas. */
121
+ timeout?: number
122
+ /** Asiento a tomar automáticamente. */
123
+ seat?: string
124
+ /** Auto-sentarse al unir/crear. Default true. */
125
+ autoSeat?: boolean
126
+ }
127
+
128
+ /** Emisor de eventos mínimo. */
129
+ export declare class Emitter {
130
+ on (event: string, handler: (...args: any[]) => void): () => void
131
+ once (event: string, handler: (...args: any[]) => void): () => void
132
+ off (event: string, handler: (...args: any[]) => void): void
133
+ emit (event: string, ...args: any[]): void
134
+ removeAllListeners (): void
135
+ }
136
+
137
+ /**
138
+ * Una sala. Eventos: 'update'(RoomState), 'state'(game), 'event'({event,data}),
139
+ * 'message'(relay), 'chat', 'started', 'ended'(result), 'closed', 'kicked',
140
+ * 'rejected', 'receipt'({pubkey,receipt}), 'left'.
141
+ */
142
+ export declare class Room extends Emitter {
143
+ readonly role: Role
144
+ readonly isHost: boolean
145
+ readonly roomId: string
146
+ readonly gameId: string
147
+ readonly status: RoomStatus
148
+ readonly result: { winner: string | null; reason?: string } | null
149
+ readonly seats: Record<string, SeatView>
150
+ readonly spectators: SpectatorView[]
151
+ readonly game: any
152
+ readonly version: number
153
+ readonly state: RoomState
154
+ readonly mySeat: string | null
155
+
156
+ takeSeat (seatId?: string): boolean
157
+ leaveSeat (): void
158
+ setReady (ready?: boolean): void
159
+ spectate (): void
160
+ action (action: any): void
161
+ chat (text: string): void
162
+ send (data: any): void
163
+ /** Host-only: arrancar/reiniciar manualmente. */
164
+ start (): boolean
165
+ /** Califica a un co-jugador (contacto + atestación, con recibo si existe). */
166
+ ratePlayer (pubkey: string, valueOrIndicators: number | Record<string, number>, opts?: { notes?: string; nickname?: string; addContact?: boolean; receipt?: Receipt }): Promise<{ ok: true; txBound: boolean }>
167
+ /** Recibo co-firmado con `pubkey` (o null). */
168
+ matchReceipt (pubkey: string): Receipt | null
169
+ leave (): Promise<void>
170
+ }
171
+
172
+ /**
173
+ * Lobby. Eventos: 'invite'({from, roomId, name, fromName}) cuando un contacto te
174
+ * invita; 'rooms-changed'({type:'joined'|'left', token}) cuando cambia el canal
175
+ * de descubrimiento (llamá listRooms para el detalle actualizado).
176
+ */
177
+ export declare class Lobby extends Emitter {
178
+ readonly gameId: string
179
+ readonly rooms: Room[]
180
+ createRoom (opts?: CreateRoomOptions): Promise<Room>
181
+ joinRoom (roomId: string, opts?: JoinRoomOptions): Promise<Room>
182
+ listRooms (opts?: { timeout?: number; enrich?: boolean }): Promise<RoomSummary[]>
183
+ quickMatch (opts?: QuickMatchOptions): Promise<Room>
184
+ inviteContact (pubkey: string, opts?: { roomId?: string; name?: string }): void
185
+ listContacts (): Promise<any[]>
186
+ reputationOf (pubkey: string): Promise<any>
187
+ destroy (): Promise<void>
188
+ }
189
+
190
+ export declare class Transport {
191
+ constructor (opts?: { proxy?: any; identity?: any; url?: string })
192
+ readonly token: string | null
193
+ readonly isReady: boolean
194
+ connect (): Promise<string | null>
195
+ subscribe (gameId: string, fn: (from: string, env: any, meta: any) => void): () => void
196
+ send (to: string | string[], env: any): void
197
+ sendByPubkey (pubkeys: string | string[], env: any): void
198
+ }
199
+
200
+ export function createLobby (opts: CreateLobbyOptions): Promise<Lobby>
201
+
202
+ export interface Engine {
203
+ readonly started: boolean
204
+ readonly seed: number | string | null
205
+ start (seed?: number | string): any
206
+ apply (seat: string, action: any, seatsSnapshot?: any): any
207
+ getState (): any
208
+ load (state: any): void
209
+ viewFor (seat: string | null): any
210
+ checkOver (): { winner: string | null; reason?: string } | null
211
+ reset (): void
212
+ }
213
+ export function createEngine (spec: EngineSpec): Engine
214
+
215
+ // Helpers de reputación / contactos
216
+ export function createRepGate (reputation: any, gate?: MatchmakingConfig): (pubkey: string) => Promise<{ ok: boolean; reason?: string; rep?: any }>
217
+ export function rankRooms (rooms: RoomSummary[], ctx?: { reputation?: any; contacts?: Set<string>; preferContacts?: boolean }): Promise<RoomSummary[]>
218
+ export function ratePlayer (identity: any, reputation: any, pubkey: string, valueOrIndicators: number | Record<string, number>, opts?: any): Promise<{ ok: true; txBound: boolean }>
219
+ export function receiptPayload (a: string, b: string, ts: number): { op: 'receipt'; a: string; b: string; ts: number }
220
+ export function signReceiptHalf (identity: any, a: string, b: string, ts: number): Promise<string>
221
+
222
+ // Protocolo / utilidades
223
+ export const K: Record<string, string>
224
+ export function discoveryChannel (gameId: string): string
225
+ export function roomChannel (gameId: string, roomId: string): string
226
+ export function envelope (gameId: string, roomId: string, kind: string, data?: any, seq?: number): any
227
+ export function parseEnvelope (payload: any): { g: string; r: string; k: string; d: any; s: number | null } | null
228
+ export function mulberry32 (seed: number): () => number
229
+ export function hashSeed (str: string): number
230
+ export function shuffle<T> (array: T[], rng: () => number): T[]
231
+ export function normalizeSeats (spec: string[] | { min?: number; max?: number }): { ids: string[]; min: number; max: number; named: boolean }
232
+ export function samePubkey (a: string, b: string): boolean
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // @dotrino/lobby
2
+ // Lobby + matchmaking reciclable para juegos del ecosistema Dotrino.
3
+ // Construido sobre los pilares compartidos: proxy (transporte/canales/identify),
4
+ // identity (vault/firma/contactos) y reputation (web-of-trust).
5
+
6
+ export { createLobby, Lobby } from './lobby.js'
7
+ export { Room, STATUS, SEAT } from './room.js'
8
+ export { createEngine } from './engine.js'
9
+ export { Transport } from './transport.js'
10
+ export {
11
+ createRepGate, rankRooms, ratePlayer,
12
+ receiptPayload, signReceiptHalf
13
+ } from './reputation.js'
14
+ export { K, discoveryChannel, roomChannel, envelope, parseEnvelope } from './protocol.js'
15
+ export { Emitter, mulberry32, hashSeed, shuffle, normalizeSeats, samePubkey } from './util.js'