@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/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'
|