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