You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

506 lines
17 KiB

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,
};
}),
};
}
}

Powered by TurnKey Linux.