import { randomUUID , randomBytes } from 'node:crypto' ;
import { catalog , getGame } from './games/registry.mjs' ;
import { canCloseInactive , roomDeadlines } from '../shared/room-lifecycle.mjs' ;
import {
canChooseColor ,
paletteForRoom ,
assignLastColor ,
chooseDefaultColor ,
receivesLastColor ,
} from '../shared/player-colors.mjs' ;
export class Platform {
constructor ( store ) {
this . store = store ;
}
summary ( room ) {
return {
id : room . id ,
code : room . code ,
name : room . name ,
gameId : room . gameId ,
gameVersion : room . gameVersion ,
status : room . status ,
visibility : room . visibility ,
maxPlayers : room . maxPlayers ,
players : room . players . map ( ( p ) => ( {
userId : p . userId ,
nick : p . nick ,
seat : p . seat ,
ready : p . ready ,
outcome : p . outcome ,
placement : p . placement ,
colorId : p . colorId ,
avatar : p . avatar ,
withdrawnAt : p . withdrawnAt ,
} ) ) ,
createdAt : room . createdAt ,
startedAt : room . startedAt ,
lastGameActivityAt : room . lastGameActivityAt ,
deadlines : roomDeadlines ( room ) ,
finishedAt : room . finishedAt ,
result : room . result ,
version : room . version ,
} ;
}
async list ( userId ) {
return ( await this . store . listRooms ( userId ) )
. filter (
( r ) =>
r . status !== 'finished' &&
! r . players . some ( ( p ) => p . userId === userId && p . withdrawnAt ) ,
)
. sort ( ( a , b ) => b . createdAt . localeCompare ( a . createdAt ) )
. map ( ( r ) => this . summary ( r ) ) ;
}
async create ( user , input ) {
const game = getGame ( input . gameId ) ;
if (
input . maxPlayers < game . minPlayers ||
input . maxPlayers > game . maxPlayers
)
throw new Error ( 'Número de jugadores no válido.' ) ;
const rooms = await this . store . listRooms ( user . id ) ;
if (
rooms . filter ( ( r ) => r . createdBy === user . id && r . status === 'waiting' )
. length >= 5
)
throw new Error ( 'Ya tienes cinco salas abiertas.' ) ;
const now = new Date ( ) . toISOString ( ) ;
return this . store . createRoom ( {
id : randomUUID ( ) ,
code : randomBytes ( 6 ) . toString ( 'hex' ) . toUpperCase ( ) ,
name : input . name ,
gameId : game . id ,
gameVersion : game . version ,
maxPlayers : input . maxPlayers ,
visibility : input . visibility ,
createdBy : user . id ,
invitationMessage : input . invitationMessage ? ? '' ,
createdAt : now ,
updatedAt : now ,
lastGameActivityAt : now ,
status : 'waiting' ,
version : 0 ,
players : [
{
userId : user . id ,
nick : user . nick ,
seat : 0 ,
ready : false ,
avatar : user . avatar ,
} ,
] ,
state : null ,
result : null ,
events : [ ] ,
messages : [ ] ,
actions : { } ,
} ) ;
}
async join ( user , id ) {
const found = await this . store . room ( id ) ;
if ( ! found ) throw new Error ( 'No existe una sala con ese código.' ) ;
return this . store . updateRoom ( found . id , ( room ) => {
if ( room . players . some ( ( p ) => p . userId === user . id ) ) return ;
if ( room . status !== 'waiting' )
throw new Error ( 'La partida ya ha empezado.' ) ;
if ( room . players . length >= room . maxPlayers )
throw new Error ( 'La sala está completa.' ) ;
room . players . push ( {
userId : user . id ,
nick : user . nick ,
seat : room . players . length ,
ready : false ,
avatar : user . avatar ,
} ) ;
if ( canChooseColor ( room ) ) assignLastColor ( room ) ;
room . lastGameActivityAt = new Date ( ) . toISOString ( ) ;
room . version ++ ;
} ) ;
}
async invitation ( code , userId ) {
const room = await this . store . room ( code ) ;
if ( ! room ) throw new Error ( 'La invitación no existe.' ) ;
return {
code : room . code ,
name : room . name ,
definition : catalog . find ( ( g ) => g . id === room . gameId ) ,
host :
room . players . find ( ( p ) => p . userId === room . createdBy ) ? . nick ? ?
'Anfitrión' ,
message : room . invitationMessage ? ? '' ,
available :
room . status === 'waiting' && room . players . length < room . maxPlayers ,
isHost : room . createdBy === userId ,
alreadyJoined : room . players . some ( ( p ) => p . userId === userId ) ,
players : room . players . length ,
maxPlayers : room . maxPlayers ,
} ;
}
async personalizeInvite ( user , id , message ) {
return this . store . updateRoom ( id , ( room ) => {
if ( room . createdBy !== user . id )
throw new Error ( 'Solo el anfitrión puede modificar esta invitación.' ) ;
room . invitationMessage = message ;
} ) ;
}
async ready ( user , id , ready ) {
return this . store . updateRoom ( id , ( room ) => {
const player = room . players . find ( ( p ) => p . userId === user . id ) ;
if ( ! player || room . status !== 'waiting' )
throw new Error ( 'No puedes cambiar tu estado en esta sala.' ) ;
player . ready = ready ;
room . lastGameActivityAt = new Date ( ) . toISOString ( ) ;
if ( canChooseColor ( room ) ) {
if ( ready ) chooseDefaultColor ( room , player ) ;
assignLastColor ( room ) ;
}
room . version ++ ;
if (
room . players . length === room . maxPlayers &&
room . players . every ( ( p ) => p . ready )
) {
room . state = getGame ( room . gameId , room . gameVersion ) . create (
room . players . length ,
room . players . map ( ( p ) => p . colorId ) ,
) ;
room . status = 'playing' ;
room . startedAt = new Date ( ) . toISOString ( ) ;
}
} ) ;
}
async chooseColor ( user , id , colorId ) {
return this . store . updateRoom ( id , ( room ) => {
const player = room . players . find ( ( p ) => p . userId === user . id ) ;
if ( ! player || room . status !== 'waiting' || ! canChooseColor ( room ) )
throw new Error (
'Solo puedes elegir color en la sala de espera de este juego.' ,
) ;
if ( receivesLastColor ( room , player ) )
throw new Error ( 'El último jugador recibe un color automáticamente.' ) ;
if ( ! paletteForRoom ( room ) . some ( ( c ) => c . id === colorId ) )
throw new Error ( 'Ese color no está disponible en este juego.' ) ;
if (
room . players . some (
( p ) =>
p !== player &&
! receivesLastColor ( room , p ) &&
p . colorId === colorId ,
)
)
throw new Error (
'Otro jugador acaba de elegir ese color. Elige uno de los restantes.' ,
) ;
player . colorId = colorId ;
player . ready = false ;
room . lastGameActivityAt = new Date ( ) . toISOString ( ) ;
assignLastColor ( room ) ;
room . version ++ ;
} ) ;
}
finish ( room , result ) {
room . status = 'finished' ;
room . result = result ;
room . finishedAt = new Date ( ) . toISOString ( ) ;
for ( const p of room . players ) {
p . outcome =
result . winner === null
? 'draw'
: p . seat === result . winner
? 'win'
: 'loss' ;
p . placement = p . withdrawnAt
? ( p . placement ? ? 2 )
: result . winner === null || p . seat === result . winner
? 1
: 2 ;
}
}
closeWithoutResult ( room , reason , user = null ) {
room . status = 'finished' ;
room . result = { winner : null , reason , cancelled : true } ;
room . finishedAt = new Date ( ) . toISOString ( ) ;
room . version ++ ;
for ( const player of room . players ) {
delete player . outcome ;
delete player . placement ;
}
room . events . push ( {
sequence : room . events . length + 1 ,
actionId : randomUUID ( ) ,
actorId : user ? . id ? ? null ,
nick : user ? . nick ? ? 'Juegoland' ,
label : reason ,
type : 'close' ,
createdAt : room . finishedAt ,
} ) ;
}
async leave ( user , id , expectedVersion ) {
return this . store . updateRoom ( id , ( room ) => {
const player = room . players . find ( ( p ) => p . userId === user . id ) ;
if ( ! player ) throw new Error ( 'No participas en esta partida.' ) ;
if ( player . withdrawnAt || room . status === 'finished' ) return ;
if ( room . version !== expectedVersion )
throw new Error (
'La partida ha cambiado. Revisa su estado antes de salir.' ,
) ;
if ( room . status === 'waiting' ) {
if ( room . createdBy === user . id )
this . closeWithoutResult (
room ,
'Sala cancelada por el anfitrión' ,
user ,
) ;
else {
room . players = room . players . filter ( ( p ) => p !== player ) ;
room . players . forEach ( ( p , seat ) => {
p . seat = seat ;
p . ready = false ;
} ) ;
if ( canChooseColor ( room ) ) assignLastColor ( room ) ;
room . lastGameActivityAt = new Date ( ) . toISOString ( ) ;
room . version ++ ;
}
return ;
}
const remaining = room . players . filter (
( p ) => p !== player && ! p . withdrawnAt ,
) ;
player . withdrawnAt = new Date ( ) . toISOString ( ) ;
player . outcome = 'loss' ;
player . placement = remaining . length + 1 ;
if ( remaining . length === 1 )
this . finish ( room , {
winner : remaining [ 0 ] . seat ,
reason : 'rendición de los demás jugadores' ,
} ) ;
else
room . state = getGame ( room . gameId , room . gameVersion ) . withdraw (
room . state ,
player . seat ,
) ;
room . version ++ ;
room . lastGameActivityAt = player . withdrawnAt ;
room . events . push ( {
sequence : room . events . length + 1 ,
actionId : randomUUID ( ) ,
actorId : user . id ,
nick : user . nick ,
label : 'Abandona la partida' ,
type : 'leave' ,
createdAt : player . withdrawnAt ,
} ) ;
} ) ;
}
async closeInactive ( user , id , expectedVersion , now = Date . now ( ) ) {
return this . store . updateRoom ( id , ( room ) => {
const player = room . players . find ( ( p ) => p . userId === user . id ) ;
if ( ! player || player . withdrawnAt )
throw new Error ( 'No participas en esta partida.' ) ;
if ( room . version !== expectedVersion )
throw new Error (
'El otro jugador ha actuado. Revisa la partida antes de cerrarla.' ,
) ;
if ( ! canCloseInactive ( room , player . seat , now ) )
throw new Error (
'Puedes cerrar sin resultado cuando esperes a otro jugador durante 10 minutos sin jugadas.' ,
) ;
this . closeWithoutResult ( room , 'Cerrada por inactividad' , user ) ;
} ) ;
}
async expireInactiveRooms ( now = Date . now ( ) ) {
const closed = [ ] ;
for ( const snapshot of await this . store . activeRooms ( ) ) {
if ( roomDeadlines ( snapshot ) . autoCloseAt > now ) continue ;
let expired = false ;
await this . store . updateRoom ( snapshot . id , ( room ) => {
if (
room . status !== 'finished' &&
roomDeadlines ( room ) . autoCloseAt <= now
) {
this . closeWithoutResult (
room ,
'Cierre automático tras 24 horas sin actividad' ,
) ;
expired = true ;
}
} ) ;
if ( expired ) closed . push ( snapshot . id ) ;
}
return closed ;
}
async action ( user , input ) {
return this . store . updateRoom ( input . roomId , ( room ) => {
const player = room . players . find ( ( p ) => p . userId === user . id ) ;
if ( ! player ) throw new Error ( 'No participas en esta partida.' ) ;
if ( player . withdrawnAt ) throw new Error ( 'Has abandonado esta partida.' ) ;
const duplicate = room . actions [ input . actionId ] ;
if ( duplicate ) {
if (
duplicate . userId !== user . id ||
duplicate . action !== JSON . stringify ( input . action )
)
throw new Error ( 'El identificador de acción ya se ha utilizado.' ) ;
return ;
}
if ( room . status !== 'playing' )
throw new Error ( 'La partida no está en curso.' ) ;
if ( room . version !== input . expectedVersion )
throw new Error (
'El tablero se ha actualizado. Revisa la posición y vuelve a intentarlo.' ,
) ;
let applied ;
if ( input . action . type === 'resign' ) {
if ( room . players . length > 2 )
throw new Error (
'La rendición está disponible en partidas de dos jugadores.' ,
) ;
applied = {
state : room . state ,
label : 'Se rinde' ,
result : { winner : 1 - player . seat , reason : 'rendición' } ,
} ;
} else
applied = getGame ( room . gameId , room . gameVersion ) . apply (
room . state ,
input . action ,
player . seat ,
) ;
room . state = applied . state ;
room . lastGameActivityAt = new Date ( ) . toISOString ( ) ;
room . version ++ ;
room . actions [ input . actionId ] = {
userId : user . id ,
action : JSON . stringify ( input . action ) ,
} ;
room . events . push ( {
sequence : room . events . length + 1 ,
actionId : input . actionId ,
actorId : user . id ,
nick : user . nick ,
label : applied . label ,
sound : applied . result ? 'finish' : ( applied . sound ? ? input . action . type ) ,
type : input . action . type ,
animation :
applied . animation ? ?
( applied . result ? 'finish' : ( applied . sound ? ? input . action . type ) ) ,
action : input . action ,
createdAt : new Date ( ) . toISOString ( ) ,
} ) ;
if ( applied . result ) this . finish ( room , applied . result ) ;
} ) ;
}
async message ( user , id , text ) {
return this . store . updateRoom ( id , ( room ) => {
if ( ! room . players . some ( ( p ) => p . userId === user . id && ! p . withdrawnAt ) )
throw new Error ( 'No participas en esta sala.' ) ;
if ( room . messages . length >= 1000 )
throw new Error (
'Se ha alcanzado el límite de mensajes de esta partida.' ,
) ;
room . messages . push ( {
id : randomUUID ( ) ,
userId : user . id ,
nick : user . nick ,
text ,
createdAt : new Date ( ) . toISOString ( ) ,
} ) ;
} ) ;
}
async view ( id , userId ) {
const room = await this . store . room ( id ) ;
if ( ! room || ! room . players . some ( ( p ) => p . userId === userId ) )
throw new Error ( 'No tienes acceso a esta sala.' ) ;
const player = room . players . find ( ( p ) => p . userId === userId ) ;
const game = getGame ( room . gameId , room . gameVersion ) ;
return {
... this . summary ( room ) ,
createdBy : room . createdBy ,
invitationMessage :
room . createdBy === userId ? ( room . invitationMessage ? ? '' ) : undefined ,
seat : player . seat ,
state : room . state ? game . view ( room . state , player . seat ) : null ,
legalActions :
room . status === 'playing' && ! player . withdrawnAt
? game . legal ( room . state , player . seat )
: [ ] ,
events : room . events . map ( ( event ) => ( {
sequence : event . sequence ,
actionId : event . actionId ,
actorId : event . actorId ,
nick : event . nick ,
label : event . label ,
sound : event . sound ,
type : event . type ? ? event . action ? . type ,
animation : event . animation ? ? event . sound ? ? event . action ? . type ,
createdAt : event . createdAt ,
} ) ) ,
messages : room . messages ,
definition :
room . gameId === 'parchis' && room . gameVersion === '1.0.0'
? {
... catalog . find ( ( g ) => g . id === 'parchis' ) ,
version : '1.0.0' ,
variant : 'Partida antigua · reglas básicas' ,
rules : [
'Esta partida comenzó con el motor anterior y conserva su recorrido y reglas para que puedas terminarla. Las partidas nuevas utilizan la versión revisada.' ,
'Sale una ficha con 5. El 6 cuenta seis y permite repetir, sin penalización por tres seises ni obligación de abrir barrera.' ,
'Dos fichas del mismo color bloquean el paso. Capturar concede 20 pasos y llegar a meta, 10. Los premios imposibles se descartan automáticamente.' ,
'Gana quien lleva sus cuatro fichas a meta con avance exacto.' ,
] ,
}
: catalog . find ( ( g ) => g . id === room . gameId ) ,
} ;
}
async profile ( userId ) {
const [ rooms , active ] = await Promise . all ( [
this . store . history ( userId ) ,
this . store . listRooms ( userId ) ,
] ) ;
return {
pending : active
. filter (
( r ) =>
r . status !== 'finished' &&
r . players . some ( ( p ) => p . userId === userId && ! p . withdrawnAt ) ,
)
. sort ( ( a , b ) => b . createdAt . localeCompare ( a . createdAt ) )
. map ( ( r ) => ( {
... this . summary ( r ) ,
myTurn :
r . status === 'playing' &&
( r . state ? . phase === 'scoring' ||
r . state ? . turn ===
r . players . find ( ( p ) => p . userId === userId ) . seat ) ,
} ) ) ,
history : rooms
. sort ( ( a , b ) =>
(
b . finishedAt ? ?
b . players . find ( ( p ) => p . userId === userId ) . withdrawnAt
) . localeCompare (
a . finishedAt ? ?
a . players . find ( ( p ) => p . userId === userId ) . withdrawnAt ,
) ,
)
. map ( ( r ) => ( {
... this . summary ( r ) ,
myResult : r . players . find ( ( p ) => p . userId === userId ) ,
} ) ) ,
stats : catalog . map ( ( game ) => {
const results = rooms
. filter ( ( r ) => r . gameId === game . id && ! r . result ? . cancelled )
. map ( ( r ) => r . players . find ( ( p ) => p . userId === userId ) ) ;
return {
gameId : game . id ,
played : results . length ,
wins : results . filter ( ( p ) => p . outcome === 'win' ) . length ,
losses : results . filter ( ( p ) => p . outcome === 'loss' ) . length ,
draws : results . filter ( ( p ) => p . outcome === 'draw' ) . length ,
} ;
} ) ,
} ;
}
}