Implementa Juegoland con juegos online y despliegue nativo

main
dev 4 days ago
commit 04f1dff1c7

@ -0,0 +1,8 @@
# Local development uses an atomic JSON file until PostgreSQL is configured.
# Production requires DATABASE_URL and an explicit HTTPS origin.
DATABASE_URL=
APP_ORIGIN=http://localhost:4173
## Production requires a private random PIN_PEPPER (at least 32 characters).
HOST=127.0.0.1
PORT=3001
DATA_FILE=.data/development.json

4
.gitattributes vendored

@ -0,0 +1,4 @@
* text=auto
*.sh text eol=lf
*.service text eol=lf
*.timer text eol=lf

11
.gitignore vendored

@ -0,0 +1,11 @@
node_modules/
.npm-cache/
.svelte-kit/
build/
.data/
.env
.env.*
!.env.example
coverage/
*.log
artifacts/

@ -0,0 +1,7 @@
node_modules
.npm-cache
.svelte-kit
.data
build
artifacts
package-lock.json

@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

@ -0,0 +1,77 @@
# Juegoland
Primera versión de una plataforma de juegos online en español. Svelte 5 + SvelteKit 3, Node.js, Socket.IO y PostgreSQL. Despliegue nativo con systemd detrás del proxy HTTPS de la red.
## Qué funciona
- Registro con correo, nombre y nick; PIN de cuatro cifras protegido con scrypt y secreto del servidor; sesiones opacas en cookies HttpOnly.
- Catálogo, búsqueda y filtros; salas públicas y privadas por invitación; estado «listo».
- Ajedrez, damas de recorrido largo, parchís, Go 9×9 y la oca, con las variantes descritas en la ayuda.
- Validación de reglas, dados y resultados en el servidor; acciones idempotentes y control de versión.
- Chat de sala, consulta de reglas, registro de jugadas y reconexión al estado persistido.
- Perfil e historial con victoria, derrota, empate y posición.
- Interfaz adaptada a móvil, temas claro/oscuro, avatar, color de fichas, sonidos y animaciones de cada juego.
- Salida de partidas y cierres sin resultado por inactividad; pendientes actualizadas en tiempo real.
- Servicio personal en desarrollo de Juan V. Navarro Leal: popup breve de entrada y aceptación recordada con una cookie firmada.
## Desarrollo local
Requiere Node.js 24 o superior. No necesita una base de datos para la primera prueba.
```sh
npm ci
npm run dev
```
Abre `http://localhost:4173`. Este puerto evita un conflicto local detectado con 5173. Vite encamina `/api` y `/socket.io` al servidor Node en `127.0.0.1:3001`.
En desarrollo sin `DATABASE_URL`, las cuentas y partidas se guardan en `.data/development.json`. Es un almacén local de **un solo proceso**, para desarrollo; no debe usarse en producción ni como almacenamiento compartido. Está excluido de Git. La aplicación se niega a arrancar en producción sin PostgreSQL.
Para jugar, abre dos navegadores o perfiles separados, entra con dos identidades invitadas o cuentas, acepta el aviso de entrada y entra en la misma sala mediante su código. Todos los jugadores deben marcar «Estoy listo». Los nombres de duración del catálogo son estimaciones, no límites de tiempo.
## PostgreSQL
Configura `.env` a partir de `.env.example` y añade `DATABASE_URL` apuntando a una base dedicada. Después:
```sh
npm run db:migrate
npm run dev
```
Las migraciones se ejecutan en una transacción y con bloqueo para evitar dos migraciones simultáneas. Las mutaciones de partidas utilizan `SELECT ... FOR UPDATE`; el estado, los eventos y los resultados se confirman juntos. Los datos del almacén local no se importan automáticamente a PostgreSQL.
La estructura relacional incluye usuarios, sesiones, salas/partidas, eventos, mensajes y resultados. El estado específico de cada juego y la instantánea recuperable de la sala se guardan en JSONB. Las estadísticas se calculan a partir de partidas terminadas.
## Verificación
```sh
npm run check
npm test
npm run build
```
Las pruebas cubren reglas, capturas encadenadas, promoción en damas, finales, azar, barreras, privacidad de salas, concurrencia, acciones repetidas, recuperación del almacenamiento y el flujo real HTTP/WebSocket entre dos clientes. Estas pruebas usan el almacén local. La conexión, persistencia tras reinicio y restauración de PostgreSQL se han comprobado en la VM.
## Arquitectura y nuevos juegos
- `src/routes`: catálogo, cuenta, perfil, ayuda y sala.
- `src/lib/components`: ilustraciones y componentes de interfaz.
- `shared/catalog.mjs`: definiciones y reglas visibles para el usuario.
- `server/games`: módulos de reglas, independientes de Svelte y del transporte.
- `server/platform.mjs`: participantes, salas, acciones, chat y resultados.
- `server/store.mjs`: implementaciones local y PostgreSQL.
- `server/service.mjs`: API, sesiones, origen, límites y WebSockets.
- `db`: migraciones versionadas.
- `deploy`: configuración nativa para Debian.
El contrato de un juego es `id`, `version`, `minPlayers`, `maxPlayers`, `create`, `legal`, `apply` y `view`. `apply` devuelve `{ state, label, result }`; `result` contiene ganador y motivo o es `null`. Para añadir un juego, registra un módulo de confianza en `registry.mjs`, añade su manifiesto al catálogo y su componente de tablero. No se ejecuta código subido por usuarios. Un juego con información oculta debe filtrar su estado en `view` antes de enviarlo. Las partidas guardan la versión de reglas y fallan explícitamente si deja de estar disponible.
Se ha elegido un contrato pequeño de reglas con Socket.IO para esta primera versión: evita adaptar cuentas, resultados y variantes al modelo de un motor externo. boardgame.io y Colyseus quedan como alternativas a evaluar si aparecen fases simultáneas, bots o necesidades de escalado. Un único servidor de partidas es la topología inicial; antes de levantar varias instancias habrá que coordinar presencia y publicaciones entre ellas.
## Límites de esta primera versión
Modalidades casuales, sin reloj, ranking, torneos, bots, espectadores ni penalización por desconexión. Ajedrez declara automáticamente tablas por triple repetición y 50 movimientos. Damas permite damas de recorrido largo, captura obligatoria y saltos encadenados. Parchís utiliza una variante básica explícita, no todas las reglas de competición. El chat de consulta es la ayuda de reglas; no hay asistente de IA ni servicio de soporte conectado.
Antes de abrir registro público quedan verificación y recuperación por correo (requieren proveedor SMTP), moderación/bloqueo/reportes, gestión de cuentas y políticas de conservación de datos. La versión está instalada en la VM con PostgreSQL y systemd. El acceso público requiere conectar DNS y el proxy HTTPS a juegoland.imaginacion.com; consulta deploy/server-status.md.
La guía de instalación está en [deploy/README.md](deploy/README.md). La dirección visual y el prompt para otra herramienta están en [docs/design.md](docs/design.md).

@ -0,0 +1,49 @@
CREATE TABLE IF NOT EXISTS users (
id uuid PRIMARY KEY,
email text NOT NULL UNIQUE,
nick text NOT NULL UNIQUE,
data jsonb NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
hash text PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id),
expires_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS rooms (
id uuid PRIMARY KEY,
code text NOT NULL UNIQUE,
game_id text NOT NULL,
game_version text NOT NULL,
visibility text NOT NULL CHECK (visibility IN ('public','private')),
status text NOT NULL CHECK (status IN ('waiting','playing','finished')),
created_at timestamptz NOT NULL,
data jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS rooms_status ON rooms(status,created_at DESC);
CREATE INDEX IF NOT EXISTS rooms_players ON rooms USING gin ((data->'players'));
CREATE TABLE IF NOT EXISTS match_events (
room_id uuid NOT NULL REFERENCES rooms(id),
sequence integer NOT NULL,
action_id text NOT NULL,
actor_id uuid NOT NULL REFERENCES users(id),
data jsonb NOT NULL,
PRIMARY KEY(room_id,sequence),
UNIQUE(room_id,action_id)
);
CREATE TABLE IF NOT EXISTS chat_messages (
id uuid PRIMARY KEY,
room_id uuid NOT NULL REFERENCES rooms(id),
user_id uuid NOT NULL REFERENCES users(id),
data jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS chat_room ON chat_messages(room_id);
CREATE TABLE IF NOT EXISTS match_results (
room_id uuid NOT NULL REFERENCES rooms(id),
user_id uuid NOT NULL REFERENCES users(id),
outcome text NOT NULL CHECK (outcome IN ('win','loss','draw')),
placement integer NOT NULL CHECK (placement>0),
reason text NOT NULL,
PRIMARY KEY(room_id,user_id)
);
CREATE INDEX IF NOT EXISTS results_user ON match_results(user_id);

@ -0,0 +1 @@
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;

@ -0,0 +1,2 @@
-- Automatic room expiry has no human actor.
ALTER TABLE match_events ALTER COLUMN actor_id DROP NOT NULL;

@ -0,0 +1,68 @@
# Juegoland: despliegue nativo
Debian 12 en 192.168.18.155, Node.js 24, PostgreSQL 15 y systemd, sin contenedores.
## Acceso
Un único proceso ejecuta SvelteKit, API y Socket.IO en **0.0.0.0:3000**.
Acceso local: **http://192.168.18.155:3000**.
Tu proxy Nginx existente debe apuntar a **http://192.168.18.155:3000**, conservando las rutas y Upgrade/Connection para WebSocket.
El origen público es **https://juegoland.imaginacion.com**.
No hay proxy adicional en la VM: Nginx y el antiguo servicio mesa-web quedan deshabilitados.
El ejemplo deploy/external-nginx.conf.example pertenece al proxy de la red; no se instala en esta VM.
Las cookies son Secure por el dominio HTTPS. Solo el origen HTTP local explícito permite cookies para pruebas en la LAN.
PostgreSQL continúa en 127.0.0.1:5432. La configuración no confía en encabezados de IP enviados por clientes.
## Actualizar
Desde PowerShell:
```powershell
./deploy/deploy-native.ps1 -Release 20261004-04 -Activate
```
Usa una versión nueva en cada actualización. El script transfiere solo fuentes y recursos; no incluye datos locales ni secretos.
Comprueba tipos, pruebas y compilación en Linux antes de activar.
La activación guarda configuración y copia de la base antes de migrar, conserva credenciales, cambia current y reinicia mesa-game.
APT fuerza IPv4. npm usa la dirección IPv4 local persistida para el usuario mesa.
## Servicios y datos
Servicio mesa-game: Node server/production.mjs, usuario mesa.
Base mesa_abierta y rol mesa_app sin privilegios administrativos.
Secretos de aplicación y copias en /etc/mesa-abierta/game.env y backup.env, 0640 root:mesa. No se imprimen ni se copian al repositorio.
Las partidas y usuarios de desarrollo no se importan.
```sh
systemctl is-active mesa-game postgresql mesa-backup.timer
curl -4 --fail http://127.0.0.1:3000/api/health
journalctl -u mesa-game --since '10 minutes ago'
```
## Prueba de producción
El script usa exclusivamente identidades y partidas sintéticas. Comprueba catálogo, aceptación, cookies, dos clientes WebSocket, chat, turno, resultado e inactividad.
Después del reinicio comprueba persistencia y elimina solo sus propios registros.
```sh
cd /opt/mesa-abierta/current
runuser -u mesa -- node --env-file=/etc/mesa-abierta/game.env deploy/smoke-production.mjs
systemctl restart mesa-game
runuser -u mesa -- node --env-file=/etc/mesa-abierta/game.env deploy/smoke-production.mjs verify-restart
```
Para comprobar también el acceso local, añade SMOKE_ORIGIN=http://192.168.18.155:3000 mediante env antes de node en ambas ejecuciones.
## Copias y recuperación
mesa-backup.timer: copia diaria a las 03:15 Europe/Madrid, con hasta cinco minutos de variación.
Los archivos pg_dump están en /var/backups/mesa-abierta y se comprueban con pg_restore.
Las copias permanecen en la VM; no se borran automáticamente.
Versiones previas y configuración se conservan en releases y config-<version>.
Para volver de una versión con servidor combinado a otra anterior con el mismo servidor, restaura current y reinicia mesa-game.
Para volver a versiones 01–03, restaura también game.env y las unidades guardadas: utilizaban dos servicios y loopback.
Cambiar current no revierte migraciones de datos.

@ -0,0 +1,41 @@
#!/bin/bash
set -euo pipefail
umask 077
release_id="${1:?Specify an existing checked release ID}"
[[ "$release_id" =~ ^[0-9]{8}-[0-9]{2}$ ]] || { echo 'Invalid release ID'; exit 1; }
release="/opt/mesa-abierta/releases/$release_id"
[[ "$(realpath "$release")" == "$release" && -f "$release/build/handler.js" ]] || { echo 'Release has not been built'; exit 1; }
cd "$release"
config_backup="/var/backups/mesa-abierta/config-$release_id"
install -d -m 0700 "$config_backup"
if [[ -f /etc/mesa-abierta/game.env ]]; then cp -a /etc/mesa-abierta/game.env "$config_backup/"; fi
for unit in mesa-game.service mesa-web.service mesa-backup.service mesa-backup.timer; do
if [[ -f "/etc/systemd/system/$unit" ]]; then cp -a "/etc/systemd/system/$unit" "$config_backup/"; fi
done
previous="$(readlink -f /opt/mesa-abierta/current || true)"
if [[ -n "$previous" && -d "$previous" ]]; then printf '%s\n' "$previous" > "$config_backup/previous-release"; fi
python3 deploy/configure-production.py
runuser -u mesa -- /usr/bin/node --env-file=/etc/mesa-abierta/backup.env --input-type=module -e "import {spawnSync} from 'node:child_process'; const result=spawnSync('/bin/sh',['deploy/backup.sh'],{stdio:'inherit',env:process.env}); process.exit(result.status ?? 1)"
runuser -u mesa -- /usr/bin/node --env-file=/etc/mesa-abierta/game.env scripts/migrate.mjs
for unit in mesa-game.service mesa-backup.service mesa-backup.timer; do
install -m 0644 "deploy/$unit" "/etc/systemd/system/$unit"
done
if systemctl cat mesa-web.service >/dev/null 2>&1; then systemctl disable --now mesa-web.service; fi
ln -sfn "$release" /opt/mesa-abierta/current.next
mv -Tf /opt/mesa-abierta/current.next /opt/mesa-abierta/current
systemctl daemon-reload
systemctl enable mesa-game.service mesa-backup.timer
systemctl restart mesa-game.service
systemctl start mesa-backup.timer
for attempt in $(seq 1 20); do
if curl -4 --fail --silent --max-time 3 http://127.0.0.1:3000/api/health; then break; fi
sleep 1
done
curl -4 --fail --silent --max-time 10 http://127.0.0.1:3000/api/health
curl -4 --fail --silent --max-time 10 http://127.0.0.1:3000/ > /dev/null
curl -4 --fail --silent --max-time 10 http://127.0.0.1:3000/condiciones > /dev/null
# The existing network proxy connects directly to Node. No VM proxy is needed.
if systemctl cat nginx.service >/dev/null 2>&1; then systemctl disable --now nginx.service; fi
systemctl start mesa-backup.service
systemctl is-active mesa-game.service mesa-backup.timer postgresql
printf '\nRelease active: %s - web, API and WebSockets at 192.168.18.155:3000\n' "$release_id"

@ -0,0 +1,6 @@
PGHOST=127.0.0.1
PGPORT=5432
PGDATABASE=mesa_abierta
PGUSER=mesa_app
PGPASSWORD=REPLACE_PASSWORD
BACKUP_DIR=/var/backups/mesa-abierta

@ -0,0 +1,11 @@
#!/bin/sh
set -eu
umask 077
: "${BACKUP_DIR:?Configure BACKUP_DIR}"
mkdir -p "$BACKUP_DIR"
backup="$BACKUP_DIR/mesa-$(date -u +%Y%m%dT%H%M%SZ).dump"
pg_dump --format=custom --file="$backup.tmp"
mv "$backup.tmp" "$backup"
pg_restore --list "$backup" >/dev/null
printf 'Backup verificado: %s\n' "$backup"
# Retention and off-VM replication are configured with the operator, not deleted here.

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Native configuration. Existing database credentials are preserved."""
import os
import pathlib
import grp
import secrets
import subprocess
if os.geteuid() != 0:
raise SystemExit('Run as root on the target VM.')
settings = pathlib.Path('/etc/mesa-abierta')
files = [settings / name for name in ('game.env', 'web.env', 'backup.env')]
if all(file.is_file() for file in files):
app_file = settings / 'game.env'
configuration = dict(line.split('=', 1) for line in app_file.read_text().splitlines()
if line and not line.startswith('#') and '=' in line)
configuration.update(HOST='0.0.0.0', PORT='3000',
ORIGIN='https://juegoland.imaginacion.com',
APP_ORIGIN='https://juegoland.imaginacion.com,http://192.168.18.155:3000',
TRUST_PROXY='0')
app_file.write_text(''.join(f'{key}={value}\n' for key, value in configuration.items()))
os.chmod(app_file, 0o640)
print('Single application port configured; existing credentials preserved.')
raise SystemExit(0)
if any(file.exists() for file in files):
raise SystemExit('Partial configuration: inspect it before continuing.')
def psql(sql):
return subprocess.run(['runuser', '-u', 'postgres', '--', 'psql', '-X', '-At', '-v', 'ON_ERROR_STOP=1'],
input=sql, text=True, capture_output=True, cwd='/tmp', check=True).stdout.strip()
if psql("SELECT 1 FROM pg_roles WHERE rolname='mesa_app';") or psql("SELECT 1 FROM pg_database WHERE datname='mesa_abierta';"):
raise SystemExit('Existing application database or role: refuse automatic credential replacement.')
database_password = secrets.token_hex(32)
pepper = secrets.token_hex(48)
psql(f"CREATE ROLE mesa_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD '{database_password}';\nCREATE DATABASE mesa_abierta OWNER mesa_app;")
settings.mkdir(mode=0o750, exist_ok=True)
gid = grp.getgrnam('mesa').gr_gid
os.chown(settings, 0, gid)
os.chmod(settings, 0o750)
environments = {
'game.env': f'NODE_ENV=production\nHOST=0.0.0.0\nPORT=3000\nORIGIN=https://juegoland.imaginacion.com\nAPP_ORIGIN=https://juegoland.imaginacion.com,http://192.168.18.155:3000\nDATABASE_URL=postgresql://mesa_app:{database_password}@127.0.0.1:5432/mesa_abierta\nTRUST_PROXY=0\nPIN_PEPPER={pepper}\n',
'web.env': 'NODE_ENV=production\nHOST=127.0.0.1\nPORT=3000\nPROTOCOL_HEADER=x-forwarded-proto\nHOST_HEADER=x-forwarded-host\n',
'backup.env': f'PGHOST=127.0.0.1\nPGPORT=5432\nPGDATABASE=mesa_abierta\nPGUSER=mesa_app\nPGPASSWORD={database_password}\nBACKUP_DIR=/var/backups/mesa-abierta\n',
}
os.umask(0o077)
for name, value in environments.items():
with (settings / name).open('x', encoding='utf-8', newline='\n') as output:
output.write(value)
os.chown(settings / name, 0, gid)
os.chmod(settings / name, 0o640)
print('Dedicated database and environment created; secrets remain on this server.')

@ -0,0 +1,39 @@
param(
[string]$Server = '192.168.18.155',
[string]$Identity = "$env:USERPROFILE\.ssh\senzapaura_deploy",
[Parameter(Mandatory=$true)][ValidatePattern('^\d{8}-\d{2}$')][string]$Release,
[switch]$Activate
)
$ErrorActionPreference = 'Stop'
$workspace = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$archive = Join-Path $workspace "artifacts\juegoland-$Release.tar.gz"
$content = @('db','deploy','docs','scripts','server','shared','src','static','tests','.env.example','.gitattributes','.gitignore','.prettierignore','.prettierrc.json','package.json','package-lock.json','README.md','tsconfig.json','tsconfig.server.json','vite.config.ts')
New-Item -ItemType Directory -Force -Path (Join-Path $workspace 'artifacts') | Out-Null
& tar -czf $archive -C $workspace -- @content
if ($LASTEXITCODE -ne 0) { throw 'Packaging failed' }
$sshOptions = @('-4','-i',$Identity,'-o','BatchMode=yes','-o','ConnectTimeout=10')
& scp @sshOptions $archive "root@${Server}:/tmp/juegoland-$Release.tar.gz"
if ($LASTEXITCODE -ne 0) { throw 'Transfer failed' }
$prepare = @'
set -e
release=/opt/mesa-abierta/releases/RELEASE_ID
test ! -e "$release"
install -d -m 0755 -o mesa -g mesa "$release"
tar -xzf /tmp/juegoland-RELEASE_ID.tar.gz -C "$release" --no-same-owner
chown -R mesa:mesa "$release"
cd "$release"
local_address=$(ip -4 -o addr show scope global | awk '{split($4,a,"/");print a[1];exit}')
test -n "$local_address"
runuser -u mesa -- env HOME=/var/lib/mesa-abierta npm config set local-address "$local_address" --location=user
runuser -u mesa -- env HOME=/var/lib/mesa-abierta NODE_OPTIONS=--dns-result-order=ipv4first npm ci --local-address="$local_address" --no-audit --no-fund
runuser -u mesa -- npm run check
runuser -u mesa -- npm test -- --maxWorkers=1
runuser -u mesa -- npm run build
'@
$prepare = $prepare.Replace('RELEASE_ID',$Release).Replace("`r",'')
& ssh @sshOptions "root@$Server" $prepare
if ($LASTEXITCODE -ne 0) { throw 'Remote release verification failed; no activation performed' }
if ($Activate) {
& ssh @sshOptions "root@$Server" "bash /opt/mesa-abierta/releases/$Release/deploy/activate-release.sh $Release"
if ($LASTEXITCODE -ne 0) { throw 'Activation failed; inspect services and the saved previous release' }
}

@ -0,0 +1,19 @@
# Example for the EXISTING network proxy. Do not install a proxy on the game VM.
# Merge the map into your existing http block and the location into the existing
# HTTPS server for juegoland.imaginacion.com. Keep your certificate settings.
map $http_upgrade $juegoland_connection {
default upgrade;
'' close;
}
# Inside your existing server block:
location / {
proxy_pass http://192.168.18.155:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $juegoland_connection;
proxy_read_timeout 75s;
proxy_send_timeout 75s;
}

@ -0,0 +1,9 @@
NODE_ENV=production
HOST=0.0.0.0
PORT=3000
ORIGIN=https://juegoland.imaginacion.com
APP_ORIGIN=https://juegoland.imaginacion.com,http://192.168.18.155:3000
DATABASE_URL=postgresql://mesa_app:REPLACE_PASSWORD@127.0.0.1:5432/mesa_abierta
# The existing network proxy forwards web, API and WebSocket to port 3000.
TRUST_PROXY=0
PIN_PEPPER=REPLACE_WITH_A_PRIVATE_RANDOM_SECRET_AT_LEAST_32_CHARACTERS

@ -0,0 +1,16 @@
[Unit]
Description=Juegoland - PostgreSQL backup
After=postgresql.service
[Service]
Type=oneshot
User=mesa
Group=mesa
EnvironmentFile=/etc/mesa-abierta/backup.env
ExecStart=/bin/sh /opt/mesa-abierta/current/deploy/backup.sh
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/backups/mesa-abierta
UMask=0077

@ -0,0 +1,10 @@
[Unit]
Description=Juegoland - Daily backup
[Timer]
OnCalendar=*-*-* 03:15:00 Europe/Madrid
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target

@ -0,0 +1,23 @@
[Unit]
Description=Juegoland - web, API, partidas y chat
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=mesa
Group=mesa
WorkingDirectory=/opt/mesa-abierta/current
EnvironmentFile=/etc/mesa-abierta/game.env
ExecStart=/usr/bin/node server/production.mjs
Restart=on-failure
RestartSec=3
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
UMask=0077
[Install]
WantedBy=multi-user.target

@ -0,0 +1,18 @@
#!/bin/bash
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'Ejecutar como root'; exit 1; }
export DEBIAN_FRONTEND=noninteractive
apt-get install -y ca-certificates curl gnupg git rsync xz-utils
install -d -m 0755 /usr/share/keyrings
curl -4 --fail --silent --show-error --retry 2 --connect-timeout 10 https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/mesa-nodesource.key
gpg --batch --yes --dearmor -o /usr/share/keyrings/nodesource.gpg /tmp/mesa-nodesource.key
gpg --show-keys --with-fingerprint /usr/share/keyrings/nodesource.gpg
printf '%s\n' 'deb [arch=amd64 signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main' > /etc/apt/sources.list.d/mesa-nodesource.list
apt-get update
apt-get install -y nodejs
node --version
npm --version
getent passwd mesa >/dev/null || adduser --system --group --home /var/lib/mesa-abierta mesa
install -d -m 0755 /opt/mesa-abierta/releases
install -d -m 0750 -o root -g mesa /etc/mesa-abierta
install -d -m 0700 -o mesa -g mesa /var/backups/mesa-abierta

@ -0,0 +1,14 @@
# Servidor de Juegoland
Versión 20261004-06. Web, API y WebSockets en mesa-game, 0.0.0.0:3000.
El proxy Nginx existente de la red apunta a http://192.168.18.155:3000.
Nginx y el antiguo servicio mesa-web están deshabilitados en la VM.
Acceso local: http://192.168.18.155:3000.
Dominio público: https://juegoland.imaginacion.com.
78 pruebas y compilación Linux verificadas. Dos clientes reales han comprobado por el dominio público la aceptación, cookies, WebSocket, chat, movimiento, turno y resultado. Las identidades sintéticas se eliminaron después de la prueba.
El popup breve se muestra sobre el catálogo; no hay página de condiciones completas. Open Graph y PNG de 1200×630 publicados para compartir.
Damas mantiene la ficha seleccionada durante capturas encadenadas y permite recorrido diagonal largo de las damas. Las partidas existentes siguen disponibles.
PostgreSQL permanece en loopback, con base y rol dedicados. Credenciales y datos existentes se conservan. APT y npm operan por IPv4. Copias diarias activas a las 03:15 Europe/Madrid.

@ -0,0 +1,280 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { readFile, writeFile, unlink } from 'node:fs/promises';
import { Pool } from 'pg';
import { io } from 'socket.io-client';
import { terms } from '../shared/terms.mjs';
// Isolated synthetic QA identities. Never reads/imports a real browser session.
const base = process.env.SMOKE_BASE || 'http://192.168.18.155:3000';
const origin = process.env.SMOKE_ORIGIN || 'https://juegoland.imaginacion.com';
const statePath = '/var/lib/mesa-abierta/production-smoke.json';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const sockets = [];
async function request(path, cookie = '', data) {
const response = await fetch(`${base}/api${path}`, {
method: data === undefined ? 'GET' : 'POST',
headers: {
host: new URL(origin).host,
origin,
cookie,
'content-type': 'application/json',
},
body: data === undefined ? undefined : JSON.stringify(data),
});
return { response, body: await response.json() };
}
const cookieOf = (response) => response.headers.get('set-cookie').split(';')[0];
async function client(cookie) {
const socket = io(base, {
forceNew: true,
reconnection: false,
transports: ['websocket'],
extraHeaders: { host: new URL(origin).host, origin, cookie },
});
sockets.push(socket);
await new Promise((resolve, reject) => {
socket.once('connect', resolve);
socket.once('connect_error', reject);
});
return socket;
}
async function emit(socket, name, value) {
const result = await socket.timeout(5000).emitWithAck(name, value);
assert.equal(result.ok, true, result.error);
return result.room;
}
let fixture;
async function cleanup() {
if (!fixture) return;
const connection = await pool.connect();
try {
await connection.query('BEGIN');
for (const table of ['match_events', 'chat_messages', 'match_results'])
await connection.query(
`DELETE FROM ${table} WHERE room_id = ANY($1::uuid[])`,
[fixture.rooms],
);
await connection.query('DELETE FROM rooms WHERE id = ANY($1::uuid[])', [
fixture.rooms,
]);
await connection.query(
'DELETE FROM sessions WHERE user_id = ANY($1::uuid[])',
[fixture.users.map((p) => p.id)],
);
await connection.query('DELETE FROM users WHERE id = ANY($1::uuid[])', [
fixture.users.map((p) => p.id),
]);
await connection.query('COMMIT');
await unlink(statePath).catch((e) => {
if (e.code !== 'ENOENT') throw e;
});
} catch (e) {
await connection.query('ROLLBACK');
throw e;
} finally {
connection.release();
}
}
try {
if (process.argv[2] === 'verify-restart') {
fixture = JSON.parse(await readFile(statePath, 'utf8'));
const result = await request(
`/rooms/${fixture.chess}`,
fixture.users[0].cookie,
);
assert.equal(result.response.status, 200);
assert.equal(result.body.room.status, 'finished');
assert.equal(result.body.room.players[0].outcome, 'win');
assert.equal(
result.body.room.messages[0].text,
'Mensaje de la prueba de despliegue',
);
const profile = (await request('/profile', fixture.users[0].cookie)).body;
assert.ok(profile.history.some((room) => room.id === fixture.chess));
assert.ok(
profile.history.some(
(room) => room.id === fixture.inactive && room.result.cancelled,
),
);
assert.equal(profile.pending.length, 0);
assert.ok(
profile.history.some(
(room) => room.id === fixture.expired && room.result.cancelled,
),
);
assert.equal(
(
await pool.query(
'SELECT count(*)::int AS n FROM match_events WHERE room_id=$1 AND actor_id IS NULL',
[fixture.expired],
)
).rows[0].n,
1,
);
const socket = await client(fixture.users[0].cookie);
await emit(socket, 'room:subscribe', { roomId: fixture.chess });
console.log(
'OK: sesiones, aceptación, historial, mensajes y reconexión persistidos tras reiniciar servicios.',
);
await cleanup();
console.log(
'OK: registros de prueba eliminados; no se han tocado datos ajenos a la prueba.',
);
} else {
const page = await fetch(base + '/');
assert.equal(page.status, 200);
const html = await page.text();
assert.match(html, /Tu gente\. Tus juegos\./);
assert.match(html, /Antes de jugar/);
assert.doesNotMatch(html, /terms-gate|Leer las condiciones completas/);
assert.equal((await request('/health')).body.storage, 'postgresql');
assert.equal(
(await request('/auth/guest', '', { name: 'Blocked test' })).response
.status,
428,
);
const acceptance = await request('/terms/accept', '', {
accepted: true,
version: terms.version,
});
assert.equal(acceptance.response.status, 200);
assert.match(
acceptance.response.headers.get('set-cookie'),
/HttpOnly; SameSite=Lax/,
);
assert.equal(
acceptance.response.headers.get('set-cookie').includes('; Secure'),
origin.startsWith('https://'),
);
const consentCookie = cookieOf(acceptance.response);
fixture = { users: [], rooms: [] };
for (const name of ['QAprodUno', 'QAprodDos']) {
const created = await request('/auth/guest', consentCookie, { name });
assert.equal(created.response.status, 201);
fixture.users.push({
id: created.body.user.id,
cookie: `${cookieOf(created.response)}; ${consentCookie}`,
});
}
const [one, two] = fixture.users;
const a = await client(one.cookie),
b = await client(two.cookie);
let room = (
await request('/rooms', one.cookie, {
gameId: 'ajedrez',
name: 'QA deployment chess',
maxPlayers: 2,
visibility: 'private',
})
).body.room;
fixture.chess = room.id;
fixture.rooms.push(room.id);
assert.equal(
(await request('/rooms/join', two.cookie, { code: room.code })).response
.status,
200,
);
await emit(a, 'room:subscribe', { roomId: room.id });
await emit(b, 'room:subscribe', { roomId: room.id });
await emit(a, 'room:ready', { roomId: room.id, ready: true });
room = await emit(b, 'room:ready', { roomId: room.id, ready: true });
room = await emit(a, 'game:action', {
roomId: room.id,
actionId: randomUUID(),
expectedVersion: room.version,
action: { type: 'move', from: 'e2', to: 'e4' },
});
assert.equal(room.state.turn, 1);
await emit(a, 'chat:send', {
roomId: room.id,
text: 'Mensaje de la prueba de despliegue',
});
room = await emit(b, 'game:action', {
roomId: room.id,
actionId: randomUUID(),
expectedVersion: room.version,
action: { type: 'resign' },
});
assert.equal(room.status, 'finished');
assert.equal(room.players[0].outcome, 'win');
assert.equal(
(
await pool.query(
'SELECT count(*)::int AS n FROM match_results WHERE room_id=$1',
[room.id],
)
).rows[0].n,
2,
);
let inactive = (
await request('/rooms', one.cookie, {
gameId: 'oca',
name: 'QA inactivity closure',
maxPlayers: 2,
visibility: 'private',
})
).body.room;
fixture.inactive = inactive.id;
fixture.rooms.push(inactive.id);
await request('/rooms/join', two.cookie, { code: inactive.code });
await emit(a, 'room:subscribe', { roomId: inactive.id });
await emit(b, 'room:subscribe', { roomId: inactive.id });
await emit(a, 'room:ready', { roomId: inactive.id, ready: true });
inactive = await emit(b, 'room:ready', {
roomId: inactive.id,
ready: true,
});
await pool.query(
"UPDATE rooms SET data=jsonb_set(data,'{lastGameActivityAt}',to_jsonb($2::text)) WHERE id=$1",
[inactive.id, new Date(Date.now() - 660000).toISOString()],
);
assert.equal(
(
await request(`/rooms/${inactive.id}/close-inactive`, two.cookie, {
expectedVersion: inactive.version,
})
).response.status,
200,
);
assert.equal(
(
await pool.query(
'SELECT count(*)::int AS n FROM match_results WHERE room_id=$1',
[inactive.id],
)
).rows[0].n,
0,
);
const expired = (
await request('/rooms', one.cookie, {
gameId: 'oca',
name: 'QA automatic expiry',
maxPlayers: 2,
visibility: 'private',
})
).body.room;
fixture.expired = expired.id;
fixture.rooms.push(expired.id);
await pool.query(
"UPDATE rooms SET data=jsonb_set(data,'{lastGameActivityAt}',to_jsonb($2::text)) WHERE id=$1",
[expired.id, new Date(Date.now() - 25 * 3600000).toISOString()],
);
await writeFile(statePath, JSON.stringify(fixture), {
mode: 0o600,
flag: 'wx',
});
console.log(
`OK: web, API y WebSocket en puerto 3000, aceptación desde ${origin}, dos jugadores, chat, turno, victoria, PostgreSQL y cierre sin resultado.`,
);
console.log(
'Reinicia los servicios y ejecuta verify-restart para comprobar persistencia y limpiar estas identidades sintéticas.',
);
}
} catch (error) {
await cleanup();
throw error;
} finally {
sockets.forEach((socket) => socket.disconnect());
await pool.end();
}

@ -0,0 +1,5 @@
NODE_ENV=production
HOST=127.0.0.1
PORT=3000
PROTOCOL_HEADER=x-forwarded-proto
HOST_HEADER=x-forwarded-host

@ -0,0 +1,15 @@
# Aviso de entrada
El popup breve aparece sobre el catálogo al entrar, sin esperar a la API de sesión.
Identifica el servicio personal de Juan V. Navarro Leal, su estado en desarrollo y el uso bajo responsabilidad del jugador.
No hay página ni enlace de condiciones completas. La antigua ruta /condiciones redirige al catálogo o a su regreso local válido.
La casilla empieza vacía. Aceptar guarda una cookie firmada durante un año. Ahora no cierra el aviso y deja consultar el catálogo; intentar jugar vuelve a abrirlo.
La invitación conserva su URL y se abre después de aceptar, evitando apilar diálogos.
El servidor valida versión, HMAC, fecha y caducidad. La pista SSR de una cookie actual solo evita un destello del popup; no autoriza operaciones.
La consulta pública del catálogo y de salas públicas no exige aceptación. Las operaciones de juego sí la requieren.
HTTPS usa cookies Secure. El origen local HTTP explícitamente configurado admite cookies en la LAN.
La revisión de sesión no sobrescribe una aceptación realizada mientras esa consulta estaba en curso.
Las pruebas de integración usan identidades sintéticas. No se acepta el aviso por el usuario real durante la comprobación visual.

@ -0,0 +1,26 @@
# Dirección visual de Juegoland
Revisión con el skill `frontend-design`, instalado desde [Anthropic](https://github.com/anthropics/skills/tree/main/skills/frontend-design).
La identidad parte del logotipo multicolor y de los materiales de los juegos: madera en Go y ajedrez, fichas de colores en parchís, ocas y recorrido en la oca. El tablero es el centro de la partida; los controles explican únicamente la acción disponible. La interfaz que lo rodea es sobria para que esos elementos conserven protagonismo.
Paleta base: azul de acción `#0759d9`, rojo de ficha `#ed121b`, amarillo de ficha `#ffc800`, verde de ficha `#15932a`, superficie clara `#ffffff`, texto oscuro `#202c42`. Las variantes de tema conservan colores semánticos de turno, jugadores y peligro con contraste adecuado a cada superficie.
Tipografía: Manrope para títulos y texto de interfaz. Su forma geométrica acompaña las letras redondeadas del logotipo, con pesos distintos para separar jerarquías. Escala 12, 14, 16, 20, 24 y 28 px; en pantallas grandes aumenta un escalón. Texto normal a 400, etiquetas a 600, títulos a 750. Interlineado de lectura 1,5. Espaciado 4, 8, 12, 16, 24 y 32 px; controles táctiles de al menos 44 px.
Disposición: título y turno comparten línea base en escritorio; en móvil se apilan sobre una misma guía izquierda. Tablero y controles forman un grupo centrado; en móvil las acciones van inmediatamente después del tablero. El menú es un panel de tamaño ajustado a su contenido. Chat y reglas se descubren con un botón.
```text
Escritorio Móvil
menú logo ajustes menú logo ajustes
título turno acciones título acciones
turno
tablero | controles tablero
controles
```
Crítica de la propuesta anterior: los encabezados decorativos en mayúsculas añadían información que no ayudaba a jugar, los estilos heredados alteraban tamaños dentro de los diálogos y el logotipo aparecía sobre una placa blanca. Esta revisión elimina esos encabezados en los diálogos y el menú de partida, usa una única familia de interfaz y coloca el logotipo transparente directamente sobre la superficie.
Las animaciones muestran lo que cambia después de una acción: agitar, lanzar, desplazar una ficha, colocar o retirar una piedra. La preferencia de movimiento reducido se respeta. Los sonidos se identifican por juego y evento, y se pueden escuchar por separado desde sus ajustes.
Invitaciones: título de 24 px, nombre de partida de 18 px, datos secundarios de 14 px y mensaje, explicación y botones de 16 px. La ilustración ocupa un cuadrado de 96 px en escritorio y 80 px en móvil. El encabezado no lleva una etiqueta decorativa sobre el título.

@ -0,0 +1,11 @@
# Dirección visual de Mesa Abierta
Club de juegos contemporáneo: marfil, verde bosque, acentos terracota, titulares con presencia editorial y pequeños tableros ilustrados. Evitar aspecto de panel administrativo y animaciones que distraigan de la partida. El tablero y las acciones disponibles son protagonistas; la interfaz comunica con claridad turno, conexión y confirmación.
La implementación usa componentes Svelte y dibujos en HTML/CSS. Las tipografías DM Sans y Manrope tienen alternativas del sistema y se cargan desde Google Fonts; antes de publicación pueden alojarse localmente. Cada ilustración usa una paleta propia: verde para ajedrez, arena para damas y lavanda para parchís.
## Prompt para una IA de diseño
Diseña «Mesa Abierta», una plataforma de juegos de mesa online en español con ajedrez, damas y parchís. Busca una estética de club de juegos contemporáneo: fondo marfil, verde bosque, acentos terracota, tipografía editorial y tableros ilustrados con profundidad sutil. Debe resultar atractiva, cálida y fácil de usar. Diseña catálogo, sala de espera, partida con chat, perfil con historial y registro. El tablero debe ser protagonista; en móvil, los controles deben ser táctiles y el chat ir en una pestaña. Incluye estados vacíos, carga, errores y reconexión, con buen contraste y foco de teclado visible. Utiliza texto real en español y evita datos ficticios que parezcan actividad real. Entrega pantallas de escritorio y móvil, componentes reutilizables, paleta, tipografía y espaciado para implementar en Svelte 5.
Referencia funcional: cuentas con correo privado y nick público; salas públicas/privadas; partidas de 2–4 jugadores; acciones disponibles validadas por el servidor; chat de sala y consulta de reglas; historial con victoria, derrota, empate y posición. No incluyas suscripciones, apuestas, torneos ni rankings como funciones ya disponibles.

@ -0,0 +1,15 @@
# Prompt para diseñar Juegoland en una aplicación de diseño con IA
Diseña una plataforma social de juegos de mesa llamada Juegoland. Usa su logotipo original adjunto y respeta sus cuatro colores. El resultado se implementará en Svelte 5 y SvelteKit. Prioriza una experiencia de juego clara, atractiva y cómoda para personas de distintas edades.
Entrega pantallas de catálogo, sala de espera, partida de parchís, partida de ajedrez, invitación y acceso como invitado. Incluye los estados de turno propio, turno ajeno, reconexión y resultado. Diseña variantes a 390 × 844, 600 × 1500, 1440 × 900 y 1920 × 1080, en modos claro y oscuro.
Define un sistema explícito de componentes, tipografía, espaciado y colores. Texto de lectura de 16 px; información secundaria de 14 px; metadatos de 12 px; subtítulos de 20 px y títulos de 24–28 px. Botones y controles táctiles de al menos 44 px. Usa espaciado de 4, 8, 12, 16, 24 y 32 px. Presenta los tamaños reales y sus reglas de adaptación; no reduzcas toda la interfaz para encajarla.
En una partida, muestra el tablero completo dentro del alto y ancho disponibles. Agrupa tablero y controles para que no queden separados por grandes espacios en pantallas anchas. En móvil sitúa el tablero debajo de una cabecera compacta y las acciones inmediatamente debajo. Menú plegable y chat accesible con un botón. Alinea el título y el indicador del turno por su línea de texto; cuando no quepan, apílalos con la misma alineación izquierda.
Diseña el menú como un panel compacto con enlaces de una sola línea. Las invitaciones usan campos de ancho completo, cada etiqueta sobre su campo, un mensaje del anfitrión y un enlace que se puede copiar. El acceso mediante invitación ofrece entrar como invitado o registrarse. Registro con nombre, correo, apodo y PIN de cuatro dígitos. El registro es opcional.
El cubilete se agita manteniendo un botón pulsado y se lanza al soltarlo. La cámara muestra el dado desde arriba y el resultado está en la cara superior. El jugador que tira recibe su resultado; los demás reciben un aviso temporal con su nombre y el siguiente turno, sin un botón para avanzar la partida. Las animaciones y los sonidos se definen por juego y respetan la preferencia de movimiento reducido.
Entrega un prototipo navegable, los componentes y tokens, y especificaciones que permitan reproducir la propuesta sin improvisar márgenes, fuentes ni comportamientos.

@ -0,0 +1,9 @@
# Logotipo transparente
Activo utilizado por la plataforma: `static/branding/juegoland-logo-transparent.png`. El original se conserva en `static/branding/juegoland-logo.png`.
Editado con la herramienta integrada de imagen, con `transparent_background: true`, usando el original como referencia. Comprobado como PNG con canal alfa; el fondo tiene alfa cero. El CSS de `.brand-logo` usa fondo transparente y encuadra la imagen en la proporción del logotipo original.
Prompt utilizado:
> Use case: background-extraction. Asset type: existing Juegoland website logo. Input image 1 is the exact edit target. Remove ONLY the white and very light gray rectangular background and its background gradient, leaving actual transparent alpha outside the logo and inside the counters of letters. Preserve the existing logo precisely: the red pawn, blue knight, yellow die with dark pips, green board, all nine letters J U E G O L A N D with their exact shapes, positions, proportions and original blue/red/yellow/green colors. Preserve small white highlights and thin white outlines that belong to the game pieces. Do not redesign, redraw, re-letter, crop artwork, add shadow, change spacing or add text. Keep the existing very wide composition, approximately 897:214 aspect ratio, with minimal transparent padding. This is a background removal edit, for placement directly on both light and dark UI surfaces.

@ -0,0 +1,21 @@
# Experiencia en las partidas
El tablero de la oca conserva la imagen original del usuario en `static/boards/oca-spiral.png`. El encuadre se ajusta al recorrido y una máscara muestra solo las casillas: oculta las flores exteriores y elimina sus márgenes, manteniendo intactas las ilustraciones y los números. Las posiciones de cada casilla, incluido el jardín central 63, están en `shared/oca-board.mjs` y se incluyen en la definición del juego. Se superponen las fichas y las zonas de consulta; la ampliación recorta visualmente una zona del mismo activo, sin duplicar imágenes.
La casilla se consulta con el ratón, el teclado o un toque. El aviso distingue peligros, ventajas y casillas normales. Los efectos descritos corresponden a la variante que aplica el servidor: posada de un turno, pozo de tres, cárcel de dos, retorno desde la calavera y llegada exacta al jardín. Parchís comparte el componente de consulta para salidas, seguros, recorrido y pasillos.
La tirada de la oca se guarda en el servidor al lanzar. Su animación espera a cerrar el resultado del dado propio; entonces visita cada casilla del recorrido con un salto. Las tiradas rivales esperan a la presentación del dado y se encolan si hay una animación pendiente. Se respeta la preferencia de movimiento reducido.
En la sala de espera de la oca y del parchís actual, los colores elegidos quedan reservados mediante la actualización atómica de la sala. El último jugador recibe el restante automáticamente. El parchís relaciona cada color con su casa, salida y pasillo. Los cuatro colores habituales están disponibles; negro, naranja, violeta y gris están preparados para definiciones que admitan más de cuatro jugadores. Los juegos actuales conservan sus límites de participantes.
El chat cerrado muestra un aviso temporal y mantiene un indicador de mensajes no leídos. Abrir la pestaña Chat marca los mensajes como leídos. Las partidas pendientes propias se consultan en la pestaña Partidas del panel y en el perfil; ambas listas reaccionan a cambios de salas y ofrecen enlaces para retomarlas.
El avatar se cambia desde el perfil, también como invitado. Se puede elegir uno de ocho dibujos SVG locales, recuperar las iniciales o subir PNG, JPG y WebP de hasta 8 MB. El navegador recorta al centro y convierte la imagen a PNG de 128 × 128. El servidor valida el formato, las dimensiones y el tamaño, almacena la imagen en el usuario y sirve solo su referencia en los datos públicos. El cambio se difunde a las salas sin alterar la versión de la jugada. Se conserva al convertir el invitado en una cuenta registrada.
La dirección visual y la escala tipográfica se describen en `docs/design-direction.md`; el proceso del logotipo transparente y su prompt, en `docs/logo-transparency.md`.
El botón «Salir de la partida» permite dejar una sala de espera (libera la plaza) o cancelarla como anfitrión. Durante el juego, abandonar guarda una derrota y retira las fichas; si quedan varios jugadores, se saltan los turnos del jugador retirado. Si solo queda uno, gana por rendición. Las retiradas desaparecen de pendientes y se consultan en el historial, aunque los demás sigan jugando.
Después de 10 minutos sin jugadas, quien espera a otro jugador puede cerrar la partida por inactividad. El recuento de Go permite hacerlo a quien ya haya aceptado la puntuación y espere la aceptación rival. El servidor comprueba el plazo, la participación y la versión bajo el bloqueo de la sala; un movimiento concurrente impide cerrar una posición antigua. El chat y los cambios de avatar no reinician el plazo. Un cierre por inactividad o la cancelación de una sala se muestran como «Sin resultado» y no se suman a las estadísticas de victorias, derrotas ni empates.
El servicio revisa cada minuto todas las salas, incluidas las privadas, y cierra las que llevan 24 horas sin actividad de juego o preparación. También las revisa al arrancar, por lo que la limpieza se recupera tras reiniciar. Los registros se conservan en el historial. En PostgreSQL debe aplicarse la migración `003_system_events.sql` antes del despliegue para admitir eventos automáticos sin actor humano.

@ -0,0 +1,15 @@
# Disposición de las partidas
La sala usa el alto disponible de la ventana (`100dvh`). El tablero mantiene su proporción y se ajusta tanto al ancho como al alto. La navegación del catálogo y su pie quedan accesibles desde el menú, sin ocupar una columna permanente durante la partida.
El turno se muestra junto al título. Las acciones del juego quedan junto al tablero en escritorio y debajo en móvil. Las fichas de parchís se pueden pulsar directamente; los botones de fichas disponibles ofrecen un acceso más cómodo en pantallas pequeñas.
Tablero y controles forman un grupo centrado, con separación de 16–32 px. En móvil, la cabecera y el tablero empiezan arriba y las acciones quedan inmediatamente después, sin espacios vacíos entre ellos. El título y el indicador comparten línea base en escritorio y guía izquierda al apilarse en móvil. El menú ajusta su alto al contenido y conserva sus enlaces en una línea. Las invitaciones apilan etiquetas y campos de ancho completo. La escala y la dirección visual están descritas en `design-direction.md`.
Chat, jugadas y reglas comparten un panel plegable. En escritorio el tablero se reajusta al abrirlo. En ventanas estrechas el panel se superpone y se cierra con su botón, Escape o un clic fuera. No bloquea las acciones del tablero. Los mensajes nuevos muestran un contador en el botón del panel.
La tirada se identifica por el autor del evento confirmado, no por quién tiene el turno después. El autor usa el cubilete y puede cerrar su resultado con «Volver al tablero». Los demás ven el autor y el siguiente paso en un aviso temporal, sin confirmaciones de juego. Los turnos siguen las reglas del servidor y avanzan automáticamente cuando corresponde. Un seis no promete repetir cuando la tercera tirada termina el turno.
Referencias consultadas: [partida pública de Lichess](https://lichess.org/tv), con tablero central e información en los laterales, y [guía de interfaces de Board Game Arena](https://en.doc.boardgamearena.com/images/5/57/Guidelines_UX_new_compressed.pdf), especialmente disposición central, acciones contextuales, adaptación al móvil y avisos que no interrumpan la partida. La distribución aplicada a Juegoland es una adaptación de esos criterios.
Comprobaciones: salas privadas de prueba con dos clientes por WebSocket; tirada propia y ajena; movimiento y cambio de turno; menú, chat e invitación del anfitrión; ventanas de 1440 × 900, 390 × 844, 390 × 640, 600 × 1500 y 844 × 390, más medición de la distribución a 3818 × 1886. Logo transparente revisado sobre superficies claras y oscuras. Las pruebas de mensajes cubren también cambio automático tras una tirada sin movimientos, avance de siete casillas y tercer seis.

@ -0,0 +1,18 @@
# Escala tipográfica de Juegoland
Se aplica la guía frontend-design con una familia Manrope. Los recursos de marca conservan sus colores azul, rojo, verde y amarillo. La jerarquía tipográfica depende de la función del texto, no del componente que lo contiene.
| Función | Tamaño habitual | Pantallas grandes (desde 1800×900) |
| --- | --- | --- |
| Título de página | 28 px | 32 px |
| Título de sección | 24 px | 28 px |
| Nombre del juego | 20 px | 24 px |
| Nombre de partida, párrafos y acciones | 16 px | 18 px |
| Estado, participantes, datos secundarios y pie | 14 px | 16 px |
| Etiquetas breves | 12 px | 14 px |
La tipografía no se reduce en móvil. Se reorganizan las columnas, los controles y los espacios. El tablero tiene su propia escala geométrica para numeración y fichas.
Los tokens están en src/design.css. Se han corregido también las reglas antiguas de src/app.css para nombres, estados, pendientes, contadores, etiquetas, campo de código y pie. La clase inexistente lobby-name no se utiliza: el título real es lobby-game strong.
La revisión visual usa los mismos CSS y el mismo marcado de los elementos afectados con datos ficticios, en un documento estático bajo artifacts (no se incluye en el despliegue). Se ha medido la escala calculada en navegador y comprobado el desbordamiento en móvil. El aviso de condiciones se ha comprobado en la aplicación real, sin aceptar en nombre del usuario.

2026
package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,43 @@
{
"name": "mesa-abierta",
"version": "0.1.0",
"private": true,
"type": "module",
"imports": {
"#lib/*": "./src/lib/*"
},
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"dev": "node scripts/dev.mjs",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && tsc --project tsconfig.server.json --noEmit",
"test": "vitest run",
"start": "node --env-file-if-exists=.env server/production.mjs",
"db:migrate": "node --env-file-if-exists=.env scripts/migrate.mjs"
},
"dependencies": {
"chess.js": "^1.4.0",
"dotenv": "^17.2.3",
"pg": "^8.16.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@sveltejs/adapter-node": "^6.0.0",
"@sveltejs/kit": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@types/node": "^24.0.0",
"@types/pg": "^8.15.5",
"prettier": "^3.9.9",
"prettier-plugin-svelte": "^4.1.1",
"svelte": "^5.57.1",
"svelte-check": "^4.7.6",
"typescript": "^6.0.3",
"vite": "^8.3.2",
"vitest": "^5.0.3"
}
}

@ -0,0 +1,23 @@
import { spawn } from 'node:child_process';
import 'dotenv/config';
const children = [
spawn(process.execPath, ['--watch', 'server/index.mjs'], {
stdio: 'inherit',
env: { ...process.env, NODE_ENV: 'development', PORT: '3001' },
}),
spawn(process.execPath, ['node_modules/vite/bin/vite.js'], {
stdio: 'inherit',
env: { ...process.env, NODE_ENV: 'development' },
}),
];
let stopping = false;
function stop(code = 0) {
if (stopping) return;
stopping = true;
for (const child of children) child.kill();
process.exitCode = code;
}
for (const child of children) child.on('exit', (code) => stop(code ?? 1));
process.on('SIGINT', () => stop());
process.on('SIGTERM', () => stop());

@ -0,0 +1,350 @@
import { mkdir, writeFile } from 'node:fs/promises';
// Original procedural foley: materials, rhythm and envelopes differ by action.
// Versioned URLs keep browsers from reusing the previous, similar-sounding pack.
const rate = 44100;
const directory = new URL('../static/sounds/v2/', import.meta.url);
await mkdir(directory, { recursive: true });
let seed = 1781,
low = 0;
function noise() {
seed = (1664525 * seed + 1013904223) >>> 0;
return seed / 2147483648 - 1;
}
function filtered() {
const n = noise();
low += 0.12 * (n - low);
return { low, high: n - low };
}
const wave = (f, t) => Math.sin(2 * Math.PI * f * t);
function hit(t, start, frequencies, decay = 60, strength = 1) {
const age = t - start;
if (age < 0) return 0;
const body = frequencies.reduce(
(sum, f, i) =>
sum + (wave(f, age) * Math.exp(-age * decay * (1 + i * 0.7))) / (i + 1),
0,
);
return strength * (body * 0.55 + noise() * Math.exp(-age * 700) * 0.45);
}
function pluck(t, start, f, damping = 6) {
const age = t - start;
if (age < 0) return 0;
return (
(wave(f, age) +
0.35 * wave(f * 2, age) * Math.exp(-age * 8) +
0.14 * wave(f * 3, age)) *
Math.exp(-age * damping)
);
}
const cupImpacts = Array.from({ length: 19 }, (_, i) => ({
start: i * 0.028 + ((i * 7) % 5) * 0.002,
pitch: 480 + ((i * 317) % 1900),
gain: 0.35 + (i % 4) * 0.13,
}));
const diceImpacts = [
{ t: 0, f: 180, g: 1 },
{ t: 0.13, f: 780, g: 0.85 },
{ t: 0.27, f: 1140, g: 0.7 },
{ t: 0.42, f: 920, g: 0.55 },
{ t: 0.59, f: 1340, g: 0.4 },
{ t: 0.72, f: 1040, g: 0.25 },
];
const effects = {
'chess-move': [
0.19,
(t) =>
hit(t, 0, [165, 390, 940], 42) + filtered().low * Math.exp(-t * 70) * 0.5,
],
'chess-capture': [
0.36,
(t) =>
hit(t, 0, [480, 970], 85, 0.65) +
hit(t, 0.09, [125, 290, 690], 26) +
filtered().low * Math.exp(-Math.abs(t - 0.12) * 35) * 0.3,
],
'checkers-move': [0.12, (t) => hit(t, 0, [1180, 2870, 4160], 85)],
'checkers-capture': [
0.43,
(t) =>
[0, 0.12, 0.24].reduce(
(s, at, i) =>
s + hit(t, at, [1320 - i * 120, 3080 - i * 230], 65, 1 - i * 0.18),
0,
),
],
'go-place': [
0.18,
(t) =>
hit(t, 0, [2850, 4790, 7100], 58, 0.75) +
hit(t, 0.013, [690, 1530], 120, 0.25),
],
'go-capture': [
0.31,
(t) =>
[0, 0.034, 0.071, 0.12, 0.16].reduce(
(s, at, i) =>
s + hit(t, at, [2530 + i * 277, 4360 + i * 140], 95, 0.65),
0,
),
],
'parchis-move': [
0.18,
(t) => hit(t, 0, [720, 1640], 78) + hit(t, 0.03, [330, 1390], 90, 0.4),
],
'parchis-capture': [
0.42,
(t) =>
filtered().high * Math.sin(Math.PI * Math.min(1, t / 0.28)) ** 2 * 0.4 +
hit(t, 0.25, [370, 1280], 50),
],
'oca-move': [
0.41,
(t) =>
[0, 0.09, 0.18, 0.27].reduce(
(s, at, i) => s + hit(t, at, [290 + i * 80, 850 + i * 150], 60, 0.6),
0,
),
],
'oca-bonus': [
0.72,
(t) =>
[0, 0.26].reduce((sum, start) => {
const a = t - start;
if (a < 0 || a > 0.23) return sum;
const env = Math.sin((Math.PI * a) / 0.23) ** 2;
return (
sum +
env *
(wave(195 + 40 * a, a) * 0.32 +
wave(850 + 400 * a, a) * 0.2 +
wave(1420, a) * 0.1 +
filtered().low * 0.4)
);
}, 0),
],
'oca-penalty': [
0.8,
(t) =>
(wave(220 * Math.exp(-t * 2.4), t) * 0.6 +
wave(440 * Math.exp(-t * 2.4), t) * 0.2) *
Math.exp(-t * 5),
],
'cup-shake': [
0.55,
(t) =>
cupImpacts.reduce(
(s, impact) =>
s +
hit(
t,
impact.start,
[impact.pitch, impact.pitch * 1.64],
145,
impact.gain,
),
0,
),
],
'dice-roll': [
0.94,
(t) =>
diceImpacts.reduce(
(s, impact) =>
s +
hit(
t,
impact.t,
[impact.f, impact.f * 2.17, impact.f * 3.6],
impact.t ? 70 : 24,
impact.g,
),
0,
),
],
'card-play': [
0.29,
(t) => {
const n = filtered();
return (
n.high * Math.sin(Math.PI * Math.min(1, t / 0.24)) ** 1.5 * 0.6 +
hit(t, 0.205, [210, 950], 140, 0.35)
);
},
],
'card-collect': [
0.65,
(t) => {
const n = filtered();
return (
n.high *
(Math.exp(-(((t - 0.12) / 0.05) ** 2)) * 0.45 +
Math.exp(-(((t - 0.36) / 0.1) ** 2)) * 0.6) +
hit(t, 0.5, [190, 650], 100, 0.3)
);
},
],
'tute-card': [
0.22,
(t) =>
filtered().high * Math.exp(-t * 17) * Math.min(1, t / 0.012) * 0.55 +
hit(t, 0.025, [330, 1800], 140, 0.3),
],
'brisca-card': [
0.38,
(t) =>
filtered().high * Math.sin((Math.PI * t) / 0.38) ** 2 * 0.55 +
hit(t, 0.26, [140, 720], 90, 0.5),
],
'coin-buy': [
1.1,
(t) =>
[1170, 2635, 4190, 6030].reduce(
(s, f, i) => s + (wave(f, t) * Math.exp(-t * (5 + i * 3))) / (i + 1),
0,
),
],
'coin-payment': [
0.8,
(t) =>
[0, 0.16, 0.28].reduce(
(s, at, i) => s + hit(t, at, [1380 + i * 190, 3230 + i * 117], 18, 0.6),
0,
),
],
build: [
0.46,
(t) => hit(t, 0, [105, 290], 32) + hit(t, 0.18, [290, 780], 55, 0.7),
],
'mystery-suggest': [
0.82,
(t) => {
const n = filtered();
return (
(wave(146.83, t) * 0.28 + wave(155.56, t) * 0.22 + n.low * 0.22) *
Math.sin((Math.PI * t) / 0.82) ** 2
);
},
],
'mystery-accuse': [
1.0,
(t) =>
pluck(t, 0, 196, 5) * 0.35 +
pluck(t, 0.12, 233.08, 5) * 0.3 +
pluck(t, 0.28, 277.18, 6) * 0.3,
],
'risk-reinforce': [
0.24,
(t) => hit(t, 0, [125, 650], 55) + hit(t, 0.065, [270, 1200], 100, 0.5),
],
'risk-attack': [
0.78,
(t) =>
[0, 0.2, 0.42].reduce(
(s, at, i) =>
s +
hit(
t,
at,
[72 + i * 17, 151 + i * 37, 630],
15 + i * 8,
0.9 - i * 0.12,
),
0,
) +
filtered().low * Math.exp(-t * 5) * 0.45,
],
check: [
0.66,
(t) => pluck(t, 0, 880, 12) * 0.55 + pluck(t, 0.28, 1174.66, 12) * 0.5,
],
promotion: [
1.15,
(t) =>
[523.25, 659.25, 783.99, 1046.5].reduce(
(s, f, i) => s + pluck(t, i * 0.09, f, 5) * 0.25,
0,
),
],
goal: [
1.2,
(t) =>
[659.25, 783.99, 987.77, 1318.51].reduce(
(s, f, i) => s + pluck(t, i * 0.12, f, 5) * 0.3,
0,
),
],
turn: [
0.16,
(t) =>
filtered().low * Math.exp(-t * 45) * 0.45 +
hit(t, 0, [240, 690], 130, 0.3),
],
start: [
1.05,
(t) =>
[329.63, 440, 554.37].reduce(
(s, f, i) => s + pluck(t, 0.055 * i, f, 5) * 0.3,
0,
),
],
win: [
2.1,
(t) =>
[392, 523.25, 659.25, 783.99].reduce(
(s, f, i) => s + pluck(t, i * 0.18, f, 3.5) * 0.32,
0,
) +
[523.25, 659.25, 783.99].reduce(
(s, f) => s + pluck(t, 0.8, f, 4) * 0.17,
0,
),
],
loss: [
1.1,
(t) =>
[293.66, 349.23, 440].reduce(
(s, f, i) => s + pluck(t, i * 0.1, f, 5) * 0.25,
0,
),
],
draw: [
0.9,
(t) => pluck(t, 0, 440, 6) * 0.35 + pluck(t, 0.25, 523.25, 7) * 0.3,
],
};
for (const [name, [duration, sample]] of Object.entries(effects)) {
seed = 1781;
low = 0;
const count = Math.ceil(rate * duration),
samples = new Float64Array(count);
let peak = 0;
for (let i = 0; i < count; i++) {
const t = i / rate,
fade = Math.max(0, Math.min(1, t / 0.0015, (duration - t) / 0.025));
samples[i] = sample(t) * fade;
peak = Math.max(peak, Math.abs(samples[i]));
}
const buffer = Buffer.alloc(44 + count * 2);
buffer.write('RIFF');
buffer.writeUInt32LE(buffer.length - 8, 4);
buffer.write('WAVEfmt ', 8);
buffer.writeUInt32LE(16, 16);
buffer.writeUInt16LE(1, 20);
buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(rate, 24);
buffer.writeUInt32LE(rate * 2, 28);
buffer.writeUInt16LE(2, 32);
buffer.writeUInt16LE(16, 34);
buffer.write('data', 36);
buffer.writeUInt32LE(count * 2, 40);
for (let i = 0; i < count; i++)
buffer.writeInt16LE(
Math.round((samples[i] / Math.max(peak, 0.01)) * 25000),
44 + i * 2,
);
await writeFile(new URL(`${name}.wav`, directory), buffer);
}
console.log(
`${Object.keys(effects).length} efectos originales con materiales y ritmos diferentes, a 44,1 kHz.`,
);

@ -0,0 +1,40 @@
import 'dotenv/config';
import { readFile, readdir } from 'node:fs/promises';
import { Pool } from 'pg';
if (!process.env.DATABASE_URL)
throw new Error('Configura DATABASE_URL antes de ejecutar las migraciones.');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('SELECT pg_advisory_xact_lock(188413091)');
await client.query(
'CREATE TABLE IF NOT EXISTS schema_migrations(name text PRIMARY KEY,applied_at timestamptz NOT NULL DEFAULT now())',
);
const applied = new Set(
(await client.query('SELECT name FROM schema_migrations')).rows.map(
(r) => r.name,
),
);
const files = (await readdir(new URL('../db/', import.meta.url)))
.filter((f) => f.endsWith('.sql'))
.sort();
for (const name of files)
if (!applied.has(name)) {
await client.query(
await readFile(new URL(`../db/${name}`, import.meta.url), 'utf8'),
);
await client.query('INSERT INTO schema_migrations(name) VALUES($1)', [
name,
]);
console.log(`Migración aplicada: ${name}`);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
await pool.end();
}

@ -0,0 +1,64 @@
import {
createHash,
createHmac,
randomBytes,
scrypt as scryptCallback,
timingSafeEqual,
} from 'node:crypto';
import { promisify } from 'node:util';
import { avatarReference } from './avatar.mjs';
const scrypt = promisify(scryptCallback);
export const hashToken = (token) =>
createHash('sha256').update(token).digest('hex');
export const publicUser = (user) =>
user
? {
id: user.id,
name: user.name,
nick: user.nick,
createdAt: user.createdAt,
guest: Boolean(user.guest),
avatar: avatarReference(user),
}
: null;
export async function hashPassword(password) {
const salt = randomBytes(16).toString('hex');
const hash = await scrypt(password, salt, 64);
return `${salt}:${hash.toString('hex')}`;
}
export async function verifyPassword(password, encoded) {
const [salt, stored] = encoded.split(':');
const expected = Buffer.from(stored, 'hex');
const actual = await scrypt(password, salt, 64);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
export const hashPin = (pin, pepper) =>
hashPassword(createHmac('sha256', pepper).update(pin).digest('hex'));
export const verifyPin = (pin, encoded, pepper) =>
verifyPassword(
createHmac('sha256', pepper).update(pin).digest('hex'),
encoded,
);
export function cookieToken(header = '') {
const token = header
.split(';')
.map((v) => v.trim())
.find((v) => v.startsWith('mesa_session='))
?.slice(13);
return token && /^[a-f0-9]{64}$/.test(token) ? token : null;
}
export async function authenticate(store, token) {
if (!token) return null;
const session = await store.session(hashToken(token));
return session ? publicUser(await store.userById(session.userId)) : null;
}
export async function newSession(store, userId) {
const token = randomBytes(32).toString('hex');
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
await store.createSession({ hash: hashToken(token), userId, expiresAt });
return token;
}
export function sessionCookie(token, production) {
return `mesa_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${token ? 2592000 : 0}${production ? '; Secure' : ''}`;
}

@ -0,0 +1,39 @@
import { createHash } from 'node:crypto';
import { avatarPresets } from '../shared/avatars.mjs';
export function avatarReference(user) {
if (user?.avatar?.preset)
return avatarPresets.find((p) => p.id === user.avatar.preset)?.src;
if (user?.avatar?.image)
return `/api/avatars/${user.id}?v=${user.avatar.revision}`;
}
export function validateAvatar(input) {
if ('preset' in input) {
if (input.preset === null) return null;
if (!avatarPresets.some((p) => p.id === input.preset))
throw Error('Elige un avatar de la lista.');
return { preset: input.preset };
}
if (
typeof input.image !== 'string' ||
!/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(input.image) ||
input.image.length > 90000
)
throw Error('La imagen del avatar no es válida.');
const bytes = Buffer.from(input.image.slice(22), 'base64');
if (
bytes.length < 45 ||
bytes.length > 65536 ||
bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' ||
bytes.toString('ascii', 12, 16) !== 'IHDR' ||
bytes.readUInt32BE(8) !== 13
)
throw Error('El avatar debe ser una imagen PNG.');
const width = bytes.readUInt32BE(16),
height = bytes.readUInt32BE(20);
if (width < 1 || height < 1 || width > 256 || height > 256)
throw Error('La imagen del avatar debe medir como máximo 256 píxeles.');
return {
image: input.image,
revision: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
};
}

@ -0,0 +1,131 @@
const inside = (r, c) => r >= 0 && r < 8 && c >= 0 && c < 8;
const owner = (piece) => Math.abs(piece) - 1;
function movesFor(board, from, capturesOnly = false) {
const piece = board[from];
if (!piece) return [];
const r = Math.floor(from / 8),
c = from % 8;
const rows = piece < 0 ? [-1, 1] : owner(piece) === 0 ? [-1] : [1];
const moves = [];
if (piece < 0) {
for (const dr of rows)
for (const dc of [-1, 1]) {
let captured;
for (let distance = 1; ; distance++) {
const nr = r + distance * dr,
nc = c + distance * dc;
if (!inside(nr, nc)) break;
const to = nr * 8 + nc;
if (board[to]) {
if (captured !== undefined || owner(board[to]) === owner(piece))
break;
captured = to;
} else if (captured !== undefined) {
moves.push({ type: 'move', from, to, capture: captured });
} else if (!capturesOnly) {
moves.push({ type: 'move', from, to });
}
}
}
return moves;
}
for (const dr of rows)
for (const dc of [-1, 1]) {
const nr = r + dr,
nc = c + dc;
if (!inside(nr, nc)) continue;
const to = nr * 8 + nc;
if (!capturesOnly && !board[to]) moves.push({ type: 'move', from, to });
const jr = r + 2 * dr,
jc = c + 2 * dc;
if (
board[to] &&
owner(board[to]) !== owner(piece) &&
inside(jr, jc) &&
!board[jr * 8 + jc]
) {
moves.push({ type: 'move', from, to: jr * 8 + jc, capture: to });
}
}
return moves;
}
export const checkers = {
id: 'damas',
version: '2.0.0',
minPlayers: 2,
maxPlayers: 2,
create() {
return {
board: Array.from({ length: 64 }, (_, i) => {
const r = Math.floor(i / 8),
c = i % 8;
return (r + c) % 2 === 0 ? 0 : r < 3 ? 2 : r > 4 ? 1 : 0;
}),
turn: 0,
chain: null,
quietMoves: 0,
};
},
/** @returns {{type: string, from: number, to: number, capture?: number}[]} */
legal(state, seat) {
if (seat !== state.turn) return [];
if (state.chain !== null) return movesFor(state.board, state.chain, true);
const moves = state.board.flatMap((piece, i) =>
piece && owner(piece) === seat ? movesFor(state.board, i) : [],
);
const captures = moves.filter((m) => m.capture !== undefined);
return captures.length ? captures : moves;
},
apply(state, action, seat) {
const legal = this.legal(state, seat).find(
(m) =>
m.type === action.type && m.from === action.from && m.to === action.to,
);
if (!legal)
throw new Error(
'Movimiento ilegal. Recuerda que la captura es obligatoria.',
);
const board = [...state.board];
const piece = board[legal.from];
board[legal.from] = 0;
board[legal.to] = piece;
if (legal.capture !== undefined) board[legal.capture] = 0;
const crowned = piece > 0 && (seat === 0 ? legal.to < 8 : legal.to >= 56);
if (crowned) board[legal.to] = -piece;
const chain =
legal.capture !== undefined &&
!crowned &&
movesFor(board, legal.to, true).length
? legal.to
: null;
const next = {
board,
turn: chain !== null ? seat : 1 - seat,
chain,
quietMoves:
legal.capture !== undefined || crowned ? 0 : state.quietMoves + 1,
};
let result = null;
if (chain === null && !this.legal(next, next.turn).length)
result = { winner: seat, reason: 'sin movimientos disponibles' };
else if (next.quietMoves >= 80)
result = { winner: null, reason: '40 turnos sin captura ni coronación' };
return {
state: next,
label: `${legal.from + 1}${legal.capture !== undefined ? ' × ' : ' → '}${legal.to + 1}`,
result,
sound: result
? 'finish'
: crowned
? 'promotion'
: legal.capture !== undefined
? 'capture'
: 'move',
};
},
view(state) {
return state;
},
};

@ -0,0 +1,75 @@
import { Chess } from 'chess.js';
function engine(state) {
const game = new Chess();
for (const move of state.moves) game.move(move);
return game;
}
export const chess = {
id: 'ajedrez',
version: '1.0.0',
minPlayers: 2,
maxPlayers: 2,
/** @returns {{ moves: string[], fen: string, turn: number }} */
create() {
return { moves: [], fen: new Chess().fen(), turn: 0 };
},
legal(state, seat) {
if (seat !== state.turn) return [];
return engine(state)
.moves({ verbose: true })
.map((m) => ({
type: 'move',
from: m.from,
to: m.to,
...(m.promotion ? { promotion: m.promotion } : {}),
}));
},
apply(state, action, seat) {
if (seat !== state.turn || action.type !== 'move')
throw new Error('No puedes hacer esa jugada.');
const game = engine(state);
const move = game.move({
from: action.from,
to: action.to,
...(action.promotion ? { promotion: action.promotion } : {}),
});
if (!move) throw new Error('Movimiento ilegal.');
const next = {
moves: [...state.moves, move.san],
fen: game.fen(),
turn: game.turn() === 'w' ? 0 : 1,
};
let result = null;
if (game.isCheckmate()) result = { winner: seat, reason: 'jaque mate' };
else if (game.isDraw())
result = {
winner: null,
reason: game.isStalemate() ? 'rey ahogado' : 'tablas por las reglas',
};
return {
state: next,
label: move.san,
result,
sound: result
? 'finish'
: game.isCheck()
? 'check'
: move.promotion
? 'promotion'
: move.captured
? 'capture'
: 'move',
};
},
view(state) {
const game = engine(state);
return {
...state,
board: game.board(),
check: game.isCheck(),
pgn: game.pgn(),
};
},
};

@ -0,0 +1,188 @@
const size = 9;
const neighbors = (i) =>
[
i % size ? i - 1 : -1,
i % size < size - 1 ? i + 1 : -1,
i >= size ? i - size : -1,
i < size * (size - 1) ? i + size : -1,
].filter((v) => v >= 0);
function group(board, start) {
const stones = new Set([start]),
liberties = new Set(),
queue = [start],
color = board[start];
for (const i of queue)
for (const n of neighbors(i)) {
if (board[n] === 0) liberties.add(n);
else if (board[n] === color && !stones.has(n)) {
stones.add(n);
queue.push(n);
}
}
return { stones: [...stones], liberties };
}
function place(state, to, seat) {
if (!Number.isInteger(to) || to < 0 || to >= size * size || state.board[to])
throw new Error('Intersección ocupada o no válida.');
const board = [...state.board],
color = seat + 1;
board[to] = color;
let captured = 0;
for (const n of neighbors(to))
if (board[n] === 3 - color) {
const enemy = group(board, n);
if (!enemy.liberties.size) {
for (const i of enemy.stones) board[i] = 0;
captured += enemy.stones.length;
}
}
if (!group(board, to).liberties.size)
throw new Error('No puedes hacer una jugada suicida.');
if (state.history.includes(board.join('')))
throw new Error('Superko: no puedes repetir una posición anterior.');
return { board, captured };
}
function score(state) {
const board = state.board.map((v, i) => (state.dead.includes(i) ? 0 : v)),
seen = new Set(),
scores = [0, 6.5];
for (let i = 0; i < board.length; i++) {
if (board[i]) {
scores[board[i] - 1]++;
continue;
}
if (seen.has(i)) continue;
const empty = [i],
borders = new Set();
seen.add(i);
for (const p of empty)
for (const n of neighbors(p))
if (board[n]) borders.add(board[n]);
else if (!seen.has(n)) {
seen.add(n);
empty.push(n);
}
if (borders.size === 1) scores[[...borders][0] - 1] += empty.length;
}
return scores;
}
export const go = {
id: 'go',
version: '1.0.0',
minPlayers: 2,
maxPlayers: 2,
create() {
const board = Array(size * size).fill(0);
return {
board,
turn: 0,
phase: 'play',
passes: 0,
history: [board.join('')],
captures: [0, 0],
dead: [],
accepted: [false, false],
scores: null,
};
},
legal(state, seat) {
if (state.phase === 'scoring')
return [
...state.board.flatMap((v, to) => (v ? [{ type: 'mark', to }] : [])),
{ type: 'accept-score' },
{ type: 'resume' },
];
if (state.phase !== 'play' || state.turn !== seat) return [];
return [
...state.board.flatMap((v, to) => {
if (v) return [];
try {
place(state, to, seat);
return [{ type: 'move', to }];
} catch {
return [];
}
}),
{ type: 'pass' },
];
},
apply(state, action, seat) {
if (
!this.legal(state, seat).some(
(a) =>
a.type === action.type && (a.to === undefined || a.to === action.to),
)
)
throw new Error('Esa acción no está disponible.');
const next = structuredClone(state);
let label = '',
sound = 'move',
result = null;
if (action.type === 'move') {
const move = place(state, action.to, seat);
next.board = move.board;
next.history.push(next.board.join(''));
next.captures[seat] += move.captured;
next.turn = 1 - seat;
next.passes = 0;
label = `Piedra en ${'ABCDEFGHJ'[action.to % size]}${size - Math.floor(action.to / size)}${move.captured ? ` · captura ${move.captured}` : ''}`;
sound = move.captured ? 'capture' : 'move';
}
if (action.type === 'pass') {
next.turn = 1 - seat;
next.passes++;
if (next.passes === 2) next.phase = 'scoring';
label =
next.phase === 'scoring'
? 'Segundo pase: acordad las piedras muertas y el resultado'
: 'Pasa';
sound = 'pass';
}
if (action.type === 'mark') {
const stones = group(next.board, action.to).stones,
marked = next.dead.includes(action.to);
next.dead = marked
? next.dead.filter((i) => !stones.includes(i))
: [...new Set([...next.dead, ...stones])];
next.accepted = [false, false];
label = marked ? 'Restaura un grupo vivo' : 'Marca un grupo muerto';
sound = 'move';
}
if (action.type === 'resume') {
next.phase = 'play';
next.passes = 0;
next.dead = [];
next.accepted = [false, false];
next.turn = seat;
label = 'Reanuda el juego';
sound = 'pass';
}
if (action.type === 'accept-score') {
next.accepted[seat] = true;
label = 'Acepta la propuesta de puntuación';
sound = 'pass';
if (next.accepted.every(Boolean)) {
next.scores = score(next);
next.phase = 'finished';
result = {
winner: next.scores[0] > next.scores[1] ? 0 : 1,
reason: `área: negras ${next.scores[0]}, blancas ${next.scores[1]} (komi 6,5)`,
};
}
}
return { state: next, label, sound, result };
},
view(state) {
return {
turn: state.turn,
grid: state.board,
size,
phase: state.phase,
captures: state.captures,
dead: state.dead,
accepted: state.accepted,
scores: state.scores ?? score(state),
komi: 6.5,
};
},
};

@ -0,0 +1,118 @@
import { randomInt } from 'node:crypto';
import { geese } from '../../shared/oca-board.mjs';
export { geese };
function advanceTurn(state, seat) {
let candidate = seat;
// Each skipped seat consumes one turn automatically; a full circuit may still have penalties.
for (let n = 0; n < state.positions.length * 4; n++) {
candidate = (candidate + 1) % state.positions.length;
if (state.withdrawn?.includes(candidate)) continue;
if (state.skips[candidate] > 0) {
state.skips[candidate]--;
continue;
}
state.turn = candidate;
return;
}
throw new Error('Estado de turnos no válido.');
}
export const oca = {
withdraw(state, seat) {
const next = structuredClone(state);
next.withdrawn = [...(next.withdrawn ?? []), seat];
next.positions[seat] = 0;
next.skips[seat] = 0;
if (next.turn === seat) advanceTurn(next, seat);
return next;
},
id: 'oca',
version: '1.0.0',
minPlayers: 2,
maxPlayers: 4,
create(players = 4) {
return {
positions: Array(players).fill(0),
skips: Array(players).fill(0),
turn: 0,
rolled: null,
lastPath: [],
lastPlayer: null,
note: 'Llegad al jardín de la casilla 63.',
};
},
legal(state, seat) {
return seat === state.turn ? [{ type: 'roll' }] : [];
},
apply(state, action, seat, rng = () => randomInt(1, 7)) {
if (seat !== state.turn || action.type !== 'roll')
throw new Error('No es tu turno.');
const die = rng();
if (!Number.isInteger(die) || die < 1 || die > 6)
throw new Error('Dado no válido.');
const next = structuredClone(state),
start = state.positions[seat];
let target = start + die,
repeat = false,
sound = 'roll';
next.rolled = die;
next.lastPlayer = seat;
next.lastEffect = null;
next.lastPath = Array.from({ length: die }, (_, i) =>
start + i + 1 > 63 ? 126 - start - i - 1 : start + i + 1,
);
if (target > 63) target = 126 - target;
let label = `Dado: ${die} · casilla ${target}`;
if (target === 63) {
next.positions[seat] = 63;
next.lastEffect = 'goal';
next.note = '¡Has llegado al jardín!';
return {
state: next,
label: `${label} · llega al jardín`,
sound,
result: { winner: seat, reason: 'llega exactamente a la casilla 63' },
};
}
if (geese.includes(target)) {
target = geese[geese.indexOf(target) + 1] ?? target;
repeat = true;
label += ` · de oca a oca: ${target}`;
} else if (target === 6 || target === 12) {
target = target === 6 ? 12 : 6;
repeat = true;
label += ` · puente a ${target}`;
} else if (target === 26 || target === 53) {
target = target === 26 ? 53 : 26;
repeat = true;
label += ` · de dado a dado: ${target}`;
} else if (target === 19) {
next.skips[seat] = 1;
label += ' · posada: descansa un turno';
} else if (target === 31) {
next.skips[seat] = 3;
label += ' · pozo: descansa tres turnos';
} else if (target === 42) {
target = 30;
label += ' · laberinto: vuelve a 30';
} else if (target === 52) {
next.skips[seat] = 2;
label += ' · cárcel: descansa dos turnos';
} else if (target === 58) {
target = 0;
label += ' · vuelve al inicio';
}
next.lastEffect = repeat
? 'bonus'
: next.skips[seat] > 0 || target < next.lastPath.at(-1)
? 'penalty'
: null;
next.positions[seat] = target;
next.lastPath.push(target);
next.note = label;
if (!repeat) advanceTurn(next, seat);
return { state: next, label, sound, result: null };
},
view(state) {
return { ...state, geese };
},
};

@ -0,0 +1,256 @@
import { randomInt } from 'node:crypto';
import { parchisColorOrder } from '../../shared/player-colors.mjs';
import { nextPlayingSeat, withdrawParchis } from './turns.mjs';
/** @typedef {{starts?:number[],pieces:number[][],turn:number,die:number|null,rolled:number|null,bonus:boolean,extraTurn:boolean,sixes:number,lastMoved:number|null,arrivals:number[][],clock:number}} ParchisState */
export const starts = [4, 21, 38, 55];
const OUTER = 65,
GOAL = 72;
const safe = new Set([
0, 4, 11, 16, 17, 21, 28, 33, 34, 38, 45, 50, 51, 55, 62, 67,
]);
const square = (state, seat, progress) =>
((state.starts ?? starts)[seat] + progress) % 68;
const occupants = (state, cell) =>
state.pieces.flatMap((pieces, seat) =>
pieces.flatMap((p, piece) =>
p >= 0 && p < OUTER && square(state, seat, p) === cell
? [{ seat, piece }]
: [],
),
);
const barrier = (group) =>
group.length === 2 && group[0].seat === group[1].seat;
function canMove(state, seat, piece) {
const p = state.pieces[seat][piece],
distance = state.die;
if (
!distance ||
p === GOAL ||
(p === -1 && (distance !== 5 || state.bonus)) ||
p + distance > GOAL
)
return false;
const target = p === -1 ? 0 : p + distance;
if (p !== -1)
for (let step = p + 1; step <= target; step++) {
if (step < OUTER && barrier(occupants(state, square(state, seat, step))))
return false;
if (
step >= OUTER &&
step < GOAL &&
state.pieces[seat].filter((v) => v === step).length >= 2
)
return false;
}
if (target < OUTER) {
const group = occupants(state, square(state, seat, target));
if (p === -1 && group.length === 2 && group.some((v) => v.seat !== seat))
return true;
if (group.length >= 2) return false;
} else if (
target < GOAL &&
state.pieces[seat].filter((v) => v === target).length >= 2
)
return false;
return true;
}
function moves(state, seat) {
let choices = state.pieces[seat].flatMap((_, piece) =>
canMove(state, seat, piece) ? [{ type: 'move', piece }] : [],
);
if (
!state.bonus &&
state.die === 5 &&
choices.some((a) => state.pieces[seat][a.piece] === -1)
)
return choices.filter((a) => state.pieces[seat][a.piece] === -1);
if (!state.bonus && state.rolled === 6) {
const opening = choices.filter((a) => {
const p = state.pieces[seat][a.piece];
return (
p >= 0 &&
p < GOAL &&
(p < OUTER
? barrier(occupants(state, square(state, seat, p)))
: state.pieces[seat].filter((v) => v === p).length === 2)
);
});
if (opening.length) choices = opening;
}
return choices;
}
function endTurn(state, seat) {
state.die = null;
state.bonus = false;
if (!state.extraTurn) {
state.turn = nextPlayingSeat(state, seat);
state.sixes = 0;
state.lastMoved = null;
}
state.extraTurn = false;
}
function settle(state, seat) {
if (state.die !== null && !moves(state, seat).length) {
const bonus = state.bonus;
endTurn(state, seat);
return bonus ? ' · premio sin movimiento posible' : ' · sin movimientos';
}
return '';
}
export const parchis = {
withdraw: withdrawParchis,
id: 'parchis',
version: '2.0.0',
minPlayers: 2,
maxPlayers: 4,
/** @returns {ParchisState} */
create(players = 4, colors = []) {
const selected = Array.from(
{ length: players },
(_, seat) =>
starts[
parchisColorOrder.indexOf(colors[seat] ?? parchisColorOrder[seat])
],
);
if (
selected.some((s) => s === undefined) ||
new Set(selected).size !== players
)
throw new Error('Los colores del parchís deben ser distintos.');
return {
starts: selected,
pieces: Array.from({ length: players }, () => [-1, -1, -1, -1]),
turn: 0,
die: null,
rolled: null,
bonus: false,
extraTurn: false,
sixes: 0,
lastMoved: null,
arrivals: Array.from({ length: players }, () => [0, 0, 0, 0]),
clock: 0,
};
},
/** @returns {{type:string,piece?:number}[]} */
legal(state, seat) {
if (seat !== state.turn) return [];
if (state.die === null) return [{ type: 'roll' }];
const choices = moves(state, seat);
return choices.length ? choices : [{ type: 'pass' }];
},
apply(state, action, seat, rng = () => randomInt(1, 7)) {
if (
!this.legal(state, seat).some(
(a) =>
a.type === action.type &&
(a.type !== 'move' || a.piece === action.piece),
)
)
throw new Error('Esa acción no está disponible.');
const next = structuredClone(state);
if (action.type === 'roll') {
const die = rng();
if (!Number.isInteger(die) || die < 1 || die > 6)
throw new Error('Dado no válido.');
next.rolled = die;
next.sixes = die === 6 ? (state.sixes ?? 0) + 1 : 0;
if (next.sixes === 3) {
const last = next.lastMoved;
if (
last !== null &&
last !== undefined &&
next.pieces[seat][last] >= 0 &&
next.pieces[seat][last] < OUTER
)
next.pieces[seat][last] = -1;
next.extraTurn = false;
endTurn(next, seat);
return {
state: next,
label:
'Tercer seis: termina el turno; la última ficha del recorrido vuelve a casa',
result: null,
sound: 'pass',
};
}
next.die = die === 6 && !next.pieces[seat].includes(-1) ? 7 : die;
next.extraTurn = die === 6;
const skipped = settle(next, seat);
return {
state: next,
label: `Dado: ${die}${next.die === 7 ? ' · avanza 7' : ''}${skipped}`,
result: null,
sound: 'roll',
};
}
if (action.type === 'pass') {
endTurn(next, seat);
return {
state: next,
label: 'Sin movimientos: turno resuelto',
result: null,
sound: 'pass',
};
}
const piece = action.piece,
p = state.pieces[seat][piece],
target = p === -1 ? 0 : p + state.die;
let reward = null,
label = `Ficha ${piece + 1}: ${p === -1 ? 'sale de casa' : `avanza ${state.die}`}`;
const group =
target < OUTER ? occupants(state, square(state, seat, target)) : [];
let victims = group.filter((v) => v.seat !== seat);
if (p === -1 && group.length === 2)
victims = victims
.sort(
(a, b) =>
(state.arrivals?.[b.seat]?.[b.piece] ?? 0) -
(state.arrivals?.[a.seat]?.[a.piece] ?? 0),
)
.slice(0, 1);
else if (target >= OUTER || safe.has(square(state, seat, target)))
victims = [];
for (const victim of victims) {
next.pieces[victim.seat][victim.piece] = -1;
reward = 20;
label += ' · captura';
}
next.pieces[seat][piece] = target;
next.lastMoved = piece;
next.clock = (next.clock ?? 0) + 1;
next.arrivals[seat][piece] = next.clock;
if (target === GOAL) {
reward = 10;
label += ' · llega a meta';
}
if (next.pieces[seat].every((v) => v === GOAL))
return {
state: next,
label,
result: { winner: seat, reason: 'cuatro fichas en meta' },
sound: 'finish',
};
if (reward !== null) {
next.die = reward;
next.bonus = true;
label += ` · premio de ${reward}`;
label += settle(next, seat);
} else endTurn(next, seat);
return {
state: next,
label,
result: null,
sound: reward === 20 ? 'capture' : reward === 10 ? 'goal' : 'move',
};
},
view(state) {
return {
...state,
starts: state.starts ?? starts,
safe: [...safe],
goal: GOAL,
outerLength: OUTER,
};
},
};

@ -0,0 +1,165 @@
import { randomInt } from 'node:crypto';
import { nextPlayingSeat, withdrawParchis } from './turns.mjs';
export const starts = [4, 21, 38, 55];
const safe = new Set([4, 11, 16, 21, 28, 33, 38, 45, 50, 55, 62, 67]);
const square = (seat, progress) => (starts[seat] + progress) % 68;
const occupants = (state, cell) =>
state.pieces.flatMap((pieces, seat) =>
pieces.flatMap((p, piece) =>
p >= 0 && p < 64 && square(seat, p) === cell ? [{ seat, piece }] : [],
),
);
function canMove(state, seat, piece) {
const p = state.pieces[seat][piece];
const distance = state.die;
if (
!distance ||
p === 71 ||
(p === -1 && distance !== 5) ||
p + distance > 71
)
return false;
const target = p === -1 ? 0 : p + distance;
if (p !== -1)
for (let step = p + 1; step <= Math.min(target, 63); step++) {
const group = occupants(state, square(seat, step));
if (group.length >= 2 && group[0].seat === group[1].seat) return false;
}
if (target < 64) {
const group = occupants(state, square(seat, target));
if (group.length >= 2) return false;
if (
group.length &&
group[0].seat !== seat &&
safe.has(square(seat, target))
)
return false;
} else if (target < 71 && state.pieces[seat].includes(target)) return false;
return true;
}
export const parchis = {
withdraw: withdrawParchis,
id: 'parchis',
version: '1.0.0',
minPlayers: 2,
maxPlayers: 4,
/** @returns {{pieces: number[][], turn: number, die: number|null, rolled: number|null, bonus: boolean, extraTurn: boolean}} */
create(players = 4) {
return {
pieces: Array.from({ length: players }, () => [-1, -1, -1, -1]),
turn: 0,
die: null,
rolled: null,
bonus: false,
extraTurn: false,
};
},
/** @returns {{type: string, piece?: number}[]} */
legal(state, seat) {
if (seat !== state.turn) return [];
if (state.die === null) return [{ type: 'roll' }];
let moves = state.pieces[seat].flatMap((_, piece) =>
canMove(state, seat, piece) ? [{ type: 'move', piece }] : [],
);
// In this explicit basic variant, leaving home on a five is compulsory.
if (
!state.bonus &&
state.die === 5 &&
moves.some((m) => state.pieces[seat][m.piece] === -1)
)
moves = moves.filter((m) => state.pieces[seat][m.piece] === -1);
return moves.length ? moves : [{ type: 'pass' }];
},
apply(state, action, seat, rng = () => randomInt(1, 7)) {
if (
!this.legal(state, seat).some(
(a) =>
a.type === action.type &&
(a.type !== 'move' || a.piece === action.piece),
)
)
throw new Error('Esa acción no está disponible.');
const next = structuredClone(state);
if (action.type === 'roll') {
const die = rng();
if (!Number.isInteger(die) || die < 1 || die > 6)
throw new Error('Dado no válido.');
next.die = die;
next.rolled = die;
next.extraTurn = die === 6;
if (this.legal(next, seat).every((a) => a.type === 'pass')) {
next.die = null;
next.turn = next.extraTurn ? seat : nextPlayingSeat(next, seat);
next.extraTurn = false;
}
return {
state: next,
label: `Dado: ${die}`,
result: null,
sound: 'roll',
};
}
let reward = null;
let label = 'Sin movimientos: pasa el turno';
if (action.type === 'move') {
const piece = action.piece;
const p = state.pieces[seat][piece];
const target = p === -1 ? 0 : p + state.die;
next.pieces[seat][piece] = target;
label = `Ficha ${piece + 1}: ${p === -1 ? 'sale de casa' : `avanza ${state.die}`}`;
if (target < 64 && !safe.has(square(seat, target))) {
for (const other of occupants(next, square(seat, target)))
if (other.seat !== seat) {
next.pieces[other.seat][other.piece] = -1;
reward = 20;
label += ' · captura';
}
}
if (target === 71) {
reward = 10;
label += ' · llega a meta';
}
if (next.pieces[seat].every((p) => p === 71))
return {
state: next,
label,
result: { winner: seat, reason: 'cuatro fichas en meta' },
sound: 'finish',
};
}
if (reward !== null) {
next.die = reward;
next.bonus = true;
if (this.legal(next, seat).every((a) => a.type === 'pass')) {
next.die = null;
next.bonus = false;
next.turn = next.extraTurn ? seat : nextPlayingSeat(next, seat);
next.extraTurn = false;
}
} else {
next.die = null;
next.bonus = false;
next.turn = state.extraTurn ? seat : nextPlayingSeat(next, seat);
next.extraTurn = false;
}
return {
state: next,
label,
result: null,
sound:
action.type === 'pass'
? 'pass'
: reward === 20
? 'capture'
: reward === 10
? 'goal'
: 'move',
};
},
view(state) {
return { ...state, starts, safe: [...safe] };
},
};

@ -0,0 +1,23 @@
import { chess } from './chess.mjs';
import { checkers } from './checkers.mjs';
import { parchis } from './parchis-classic.mjs';
import { parchis as legacyParchis } from './parchis.mjs';
import { go } from './go.mjs';
import { oca } from './oca.mjs';
// Trusted modules are deployed with the application. No uploaded code is executed.
export const games = new Map(
[chess, checkers, parchis, go, oca].map((game) => [game.id, game]),
);
export { catalog } from '../../shared/catalog.mjs';
export function getGame(id, version) {
// Existing checkers positions use the same state and receive the requested fix.
if (id === 'damas' && version === '1.0.0') return checkers;
if (id === 'parchis' && version === legacyParchis.version)
return legacyParchis;
const game = games.get(id);
if (!game || (version && game.version !== version))
throw new Error('La versión de este juego no está disponible.');
return game;
}

@ -0,0 +1,23 @@
export function nextPlayingSeat(state, seat) {
const count = state.pieces?.length ?? state.positions.length;
for (let offset = 1; offset <= count; offset++) {
const next = (seat + offset) % count;
if (!state.withdrawn?.includes(next)) return next;
}
throw new Error('No quedan jugadores activos.');
}
export function withdrawParchis(state, seat) {
const next = structuredClone(state);
next.withdrawn = [...(next.withdrawn ?? []), seat];
next.pieces[seat] = [-1, -1, -1, -1];
if (next.turn === seat) {
next.turn = nextPlayingSeat(next, seat);
next.die = null;
next.bonus = false;
next.extraTurn = false;
next.sixes = 0;
next.lastMoved = null;
}
return next;
}

@ -0,0 +1,22 @@
import 'dotenv/config';
import { createStore } from './store.mjs';
import { createService } from './service.mjs';
const store = await createStore();
const service = createService(store);
const host = process.env.HOST || '127.0.0.1';
const port = Number(process.env.PORT || 3001);
service.server.listen(port, host, () =>
console.log(
`Juegoland · API y partidas en http://${host}:${port} · ${process.env.DATABASE_URL ? 'PostgreSQL' : 'almacenamiento local de desarrollo'}`,
),
);
let stopping = false;
async function stop() {
if (stopping) return;
stopping = true;
await service.close();
await store.close();
}
process.on('SIGINT', stop);
process.on('SIGTERM', stop);

@ -0,0 +1,504 @@
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,
} 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 (player.seat === room.maxPlayers - 1)
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 &&
p.seat !== room.maxPlayers - 1 &&
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,
};
}),
};
}
}

@ -0,0 +1,21 @@
import 'dotenv/config';
import { handler } from '../build/handler.js';
import { createStore } from './store.mjs';
import { createService } from './service.mjs';
const store = await createStore();
const service = createService(store, process.env, handler);
const host = process.env.HOST || '0.0.0.0';
const port = Number(process.env.PORT || 3000);
service.server.listen(port, host, () =>
console.log(`Juegoland · web, API y WebSockets en ${host}:${port}`),
);
let stopping = false;
async function stop() {
if (stopping) return;
stopping = true;
await service.close();
await store.close();
}
process.on('SIGINT', stop);
process.on('SIGTERM', stop);

@ -0,0 +1,728 @@
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import { Server } from 'socket.io';
import { z } from 'zod';
import {
authenticate,
cookieToken,
hashPin,
hashToken,
newSession,
publicUser,
sessionCookie,
verifyPassword,
verifyPin,
} from './auth.mjs';
import { Platform } from './platform.mjs';
import { catalog } from './games/registry.mjs';
import { validateAvatar } from './avatar.mjs';
import { terms } from '../shared/terms.mjs';
import { termsAcceptance, termsCookie, clearTermsCookie } from './terms.mjs';
const registerSchema = z.object({
email: z
.email()
.max(254)
.transform((v) => v.toLowerCase().trim()),
name: z.string().trim().min(2).max(60),
nick: z
.string()
.trim()
.regex(/^[a-zA-Z0-9_]{3,20}$/),
pin: z.string().regex(/^\d{4}$/),
});
const loginSchema = z.object({
email: z
.email()
.max(254)
.transform((v) => v.toLowerCase().trim()),
pin: z.string().regex(/^\d{4}$/),
});
const createSchema = z.object({
gameId: z.string().regex(/^[a-z][a-z0-9-]{1,30}$/),
name: z.string().trim().min(3).max(60),
visibility: z.enum(['public', 'private']),
maxPlayers: z.number().int().min(2).max(4),
invitationMessage: z.string().trim().max(300).default(''),
});
const idSchema = z.string().uuid();
// The transport validates the envelope; each trusted game validates its actions.
const actionSchema = z.object({
roomId: idSchema,
actionId: z.string().uuid(),
expectedVersion: z.number().int().nonnegative(),
action: z
.object({ type: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/) })
.catchall(z.unknown()),
});
class RateLimit {
constructor() {
this.buckets = new Map();
}
check(key, limit, duration) {
const now = Date.now();
if (this.buckets.size > 10000)
for (const [id, b] of this.buckets)
if (b.until < now) this.buckets.delete(id);
const bucket = this.buckets.get(key);
if (!bucket || bucket.until < now) {
this.buckets.set(key, { count: 1, until: now + duration });
return;
}
if (++bucket.count > limit)
throw new Error('Demasiadas solicitudes. Espera un momento.');
}
}
async function body(req, maxSize = 16384) {
if (!req.headers['content-type']?.startsWith('application/json'))
throw new Error('Se necesita contenido JSON.');
let size = 0;
const chunks = [];
for await (const chunk of req) {
size += chunk.length;
if (size > maxSize) throw new Error('Solicitud demasiado grande.');
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw new Error('Solicitud JSON no válida.');
}
}
/** @param {import('node:http').RequestListener | null} [webHandler] */
export function createService(store, env = process.env, webHandler = null) {
const platform = new Platform(store);
const limits = new RateLimit();
const production = env.NODE_ENV === 'production';
if (production && (!env.PIN_PEPPER || env.PIN_PEPPER.length < 32))
throw new Error(
'Producción requiere PIN_PEPPER de al menos 32 caracteres.',
);
const pepper = env.PIN_PEPPER || 'enronda-development-only-pin-pepper';
if (production && (!env.APP_ORIGIN || !env.APP_ORIGIN.startsWith('https://')))
throw new Error('Producción requiere APP_ORIGIN HTTPS.');
const origins = new Set(
(env.APP_ORIGIN || 'http://localhost:4173')
.split(',')
.map((s) => new URL(s.trim()).origin),
);
if (!production) {
origins.add('http://127.0.0.1:4173');
origins.add('http://localhost:4173');
}
const server = createServer();
const io = new Server(server, {
maxHttpBufferSize: 16384,
cors: { origin: [...origins], credentials: true },
allowRequest: (req, cb) => cb(null, origins.has(req.headers.origin ?? '')),
});
const withPresence = (room) => ({
...room,
players: room.players.map((p) => ({
...p,
connected: [...io.sockets.sockets.values()].some(
(s) => s.data.user.id === p.userId && s.rooms.has(room.id),
),
})),
});
async function broadcast(roomId) {
const storedRoom = await store.room(roomId);
const sockets = [...io.sockets.sockets.values()].filter((s) =>
s.rooms.has(roomId),
);
await Promise.all(
sockets.map(async (socket) => {
const user = await authenticate(store, socket.data.token);
if (
!user ||
!termsAcceptance(socket.request.headers.cookie, pepper).accepted
) {
socket.disconnect(true);
return;
}
if (!storedRoom?.players.some((p) => p.userId === user.id)) {
socket.emit('room:removed', { roomId });
await socket.leave(roomId);
return;
}
socket.emit(
'room:state',
withPresence(await platform.view(roomId, user.id)),
);
}),
);
}
const changed = () => io.emit('lobby:changed');
let sweeping = false;
async function sweep() {
if (sweeping) return;
sweeping = true;
try {
const closed = await platform.expireInactiveRooms();
for (const roomId of closed) await broadcast(roomId);
if (closed.length) changed();
} catch (error) {
console.error(
'No se pudo revisar la inactividad de las partidas:',
error.message,
);
} finally {
sweeping = false;
}
}
let sweepPromise = Promise.resolve();
const scheduleSweep = () => {
sweepPromise = sweep();
};
const expiryTimer = setInterval(scheduleSweep, 60_000);
expiryTimer.unref();
scheduleSweep();
io.use(async (socket, next) => {
try {
if (!termsAcceptance(socket.request.headers.cookie, pepper).accepted)
return next(new Error('Acepta las condiciones de uso para conectar.'));
const token = cookieToken(socket.request.headers.cookie);
const user = await authenticate(store, token);
if (!user) return next(new Error('Inicia sesión para conectar.'));
socket.data = { user, token };
next();
} catch {
next(new Error('No se pudo validar la sesión.'));
}
});
io.on('connection', (socket) => {
socket.on('room:unsubscribe', (input) => {
if (input && typeof input.roomId === 'string') {
socket.leave(input.roomId);
broadcast(input.roomId).catch(console.error);
}
});
function event(name, schema, handler) {
socket.on(name, async (raw, ack) => {
if (typeof ack !== 'function') return;
try {
if (
!termsAcceptance(socket.request.headers.cookie, pepper).accepted
) {
socket.disconnect(true);
throw new Error('Acepta las condiciones de uso para continuar.');
}
limits.check(`ws:${socket.data.user.id}`, 120, 60000);
const user = await authenticate(store, socket.data.token);
if (!user) {
socket.disconnect(true);
throw new Error('La sesión ha caducado.');
}
const input = schema.parse(raw);
const roomId = await handler(user, input);
const room = withPresence(await platform.view(roomId, user.id));
ack({ ok: true, room });
await broadcast(roomId);
changed();
} catch (e) {
const internal = e.code || /database|connect|query/i.test(e.message);
if (internal) console.error('Error del servicio:', e.message);
ack({
ok: false,
error:
e instanceof z.ZodError
? 'Solicitud no válida.'
: internal
? 'No se pudo completar la operación.'
: e.message,
});
}
});
}
event(
'room:subscribe',
z.object({ roomId: idSchema }),
async (user, input) => {
await platform.view(input.roomId, user.id);
await socket.join(input.roomId);
return input.roomId;
},
);
event(
'room:ready',
z.object({ roomId: idSchema, ready: z.boolean() }),
async (user, input) => {
await platform.ready(user, input.roomId, input.ready);
return input.roomId;
},
);
event(
'room:color',
z.object({
roomId: idSchema,
colorId: z.enum([
'amarillo',
'azul',
'rojo',
'verde',
'negro',
'naranja',
'violeta',
'gris',
]),
}),
async (user, input) => {
await platform.chooseColor(user, input.roomId, input.colorId);
return input.roomId;
},
);
event('game:action', actionSchema, async (user, input) => {
await platform.action(user, input);
return input.roomId;
});
event(
'chat:send',
z.object({ roomId: idSchema, text: z.string().trim().min(1).max(500) }),
async (user, input) => {
limits.check(`chat:${user.id}`, 15, 10000);
await platform.message(user, input.roomId, input.text);
return input.roomId;
},
);
socket.on('disconnecting', () => {
const rooms = [...socket.rooms].filter((r) => r !== socket.id);
setTimeout(
() => rooms.forEach((id) => broadcast(id).catch(console.error)),
0,
);
});
});
server.on('request', async (req, res) => {
if (req.url?.startsWith('/socket.io')) return;
const send = (status, value, cookie) => {
const headers = {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
};
if (cookie) headers['set-cookie'] = cookie;
res.writeHead(status, headers);
res.end(JSON.stringify(value));
};
try {
const url = new URL(req.url ?? '/', 'http://localhost');
const path = url.pathname;
if (!path.startsWith('/api/')) {
if (webHandler) {
await webHandler(req, res);
return;
}
send(404, { error: 'Ruta no encontrada.' });
return;
}
const ip =
env.TRUST_PROXY === '1'
? String(req.headers['x-forwarded-for'] || req.socket.remoteAddress)
.split(',')[0]
.trim()
: req.socket.remoteAddress;
limits.check(`http:${ip}`, 240, 60000);
if (req.method !== 'GET' && !origins.has(req.headers.origin ?? '')) {
send(403, { error: 'Origen de la solicitud no permitido.' });
return;
}
// Only explicitly allowed LAN HTTP origins use non-Secure cookies.
// Public HTTPS origins keep Secure even behind an HTTP reverse proxy.
const secureCookies =
production && !String(req.headers.origin ?? '').startsWith('http://');
const token = cookieToken(req.headers.cookie);
const user = await authenticate(store, token);
const acceptance = termsAcceptance(req.headers.cookie, pepper);
const acceptanceRecord = acceptance.accepted
? { version: acceptance.version, acceptedAt: acceptance.acceptedAt }
: null;
if (path === '/api/health' && req.method === 'GET') {
await store.listRooms(null);
send(200, {
status: 'ok',
storage: env.DATABASE_URL ? 'postgresql' : 'local',
});
return;
}
if (path === '/api/catalog' && req.method === 'GET') {
send(200, { games: catalog });
return;
}
if (path === '/api/session' && req.method === 'GET') {
send(200, { user, terms: acceptance });
return;
}
if (
path === '/api/rooms' &&
req.method === 'GET' &&
!acceptance.accepted
) {
send(200, { rooms: await platform.list(null) });
return;
}
if (path === '/api/terms/accept' && req.method === 'POST') {
limits.check(`terms:${ip}`, 20, 60000);
z.object({
accepted: z.literal(true),
version: z.literal(terms.version),
}).parse(await body(req));
const cookie = termsCookie(pepper, secureCookies);
const accepted = termsAcceptance(cookie, pepper);
if (user)
await store.updateUser(user.id, (stored) => {
stored.termsAcceptance = {
version: accepted.version,
acceptedAt: accepted.acceptedAt,
};
});
send(200, { terms: accepted }, cookie);
return;
}
if (path === '/api/terms/revoke' && req.method === 'POST') {
if (user)
await store.updateUser(user.id, (stored) => {
delete stored.termsAcceptance;
});
for (const socket of io.sockets.sockets.values())
if (token && socket.data.token === token) socket.disconnect(true);
send(
200,
{
terms: {
accepted: false,
version: terms.version,
acceptedAt: null,
expiresAt: null,
},
},
clearTermsCookie(secureCookies),
);
return;
}
const publicRequest =
(path.startsWith('/api/invites/') && req.method === 'GET') ||
(path === '/api/auth/logout' && req.method === 'POST');
if (!acceptance.accepted && !publicRequest) {
send(428, {
code: 'TERMS_REQUIRED',
error: 'Acepta las condiciones de uso para continuar.',
});
return;
}
if (path === '/api/auth/register' && req.method === 'POST') {
limits.check(`auth:${ip}`, 10, 15 * 60000);
const input = registerSchema.parse(await body(req));
const { pin, ...identity } = input;
const account = {
...identity,
pinHash: await hashPin(pin, pepper),
termsAcceptance: acceptanceRecord,
guest: false,
id: randomUUID(),
createdAt: new Date().toISOString(),
};
if (user?.guest) {
account.id = user.id;
await store.updateUser(user.id, (stored) => {
account.createdAt = stored.createdAt;
account.avatar = stored.avatar;
Object.assign(stored, account);
delete stored.password;
});
} else await store.createUser(account);
const token = await newSession(store, account.id);
send(
201,
{ user: publicUser(account) },
sessionCookie(token, secureCookies),
);
return;
}
if (path === '/api/auth/login' && req.method === 'POST') {
limits.check(`auth:${ip}`, 10, 15 * 60000);
const input = loginSchema.parse(await body(req));
const account = await store.userByEmail(input.email);
if (account?.pinFailures?.lockedUntil > Date.now()) {
send(429, {
error:
'Demasiados intentos. Espera 15 minutos para volver a probar.',
});
return;
}
if (account?.password && !account.pinHash) {
send(409, {
error:
'Esta cuenta aún utiliza contraseña. Activa su PIN conservando tus partidas.',
});
return;
}
const valid = await verifyPin(
input.pin,
account?.pinHash ??
'00000000000000000000000000000000:' + '00'.repeat(64),
pepper,
);
if (!account || !valid) {
if (account)
await store.updateUser(account.id, (stored) => {
const failures = stored.pinFailures;
const count =
failures?.since > Date.now() - 900000 ? failures.count + 1 : 1;
stored.pinFailures = {
count,
since: count === 1 ? Date.now() : failures.since,
lockedUntil: count >= 5 ? Date.now() + 900000 : 0,
};
});
send(401, { error: 'Correo o PIN incorrectos.' });
return;
}
await store.updateUser(account.id, (stored) => {
delete stored.pinFailures;
stored.termsAcceptance = acceptanceRecord;
});
const token = await newSession(store, account.id);
send(
200,
{ user: publicUser(account) },
sessionCookie(token, secureCookies),
);
return;
}
if (path === '/api/auth/migrate-pin' && req.method === 'POST') {
limits.check(`auth:${ip}`, 10, 900000);
const input = z
.object({
email: z.email().transform((v) => v.toLowerCase().trim()),
password: z.string().min(1).max(128),
pin: z.string().regex(/^\d{4}$/),
})
.parse(await body(req));
const account = await store.userByEmail(input.email);
if (
!account?.password ||
!(await verifyPassword(input.password, account.password))
) {
send(401, { error: 'Correo o contraseña anterior incorrectos.' });
return;
}
const pinHash = await hashPin(input.pin, pepper);
const updated = await store.updateUser(account.id, (stored) => {
stored.pinHash = pinHash;
delete stored.password;
delete stored.pinFailures;
stored.termsAcceptance = acceptanceRecord;
});
await store.deleteUserSessions(account.id);
for (const socket of io.sockets.sockets.values())
if (socket.data.user.id === account.id) socket.disconnect(true);
const session = await newSession(store, account.id);
send(
200,
{ user: publicUser(updated) },
sessionCookie(session, secureCookies),
);
return;
}
if (path === '/api/auth/guest' && req.method === 'POST') {
limits.check(`guest:${ip}`, 15, 900000);
const input = z
.object({
name: z.string().trim().min(2).max(30),
code: z
.string()
.trim()
.regex(/^[a-fA-F0-9]{12}$/)
.optional(),
})
.parse(await body(req));
if (input.code) {
const invitation = await platform.invitation(input.code);
if (!invitation.available)
throw new Error('Esta invitación ya no tiene plazas disponibles.');
}
if (user) {
send(200, { user });
return;
}
const id = randomUUID(),
prefix =
input.name
.normalize('NFKD')
.replace(/[^a-zA-Z0-9]/g, '')
.slice(0, 10) || 'Invitado';
const account = {
id,
name: input.name,
nick: `${prefix}_${id.slice(0, 6)}`,
email: null,
guest: true,
termsAcceptance: acceptanceRecord,
createdAt: new Date().toISOString(),
};
await store.createUser(account);
const guestToken = await newSession(store, id);
send(
201,
{ user: publicUser(account) },
sessionCookie(guestToken, secureCookies),
);
return;
}
if (path === '/api/auth/logout' && req.method === 'POST') {
if (token) {
await store.deleteSession(hashToken(token));
for (const socket of io.sockets.sockets.values())
if (socket.data.token === token) socket.disconnect(true);
}
send(200, { ok: true }, sessionCookie('', secureCookies));
return;
}
if (path === '/api/rooms' && req.method === 'GET') {
send(200, { rooms: await platform.list(user?.id) });
return;
}
if (path.startsWith('/api/invites/') && req.method === 'GET') {
const code = z
.string()
.regex(/^[a-fA-F0-9]{12}$/)
.parse(path.slice(13));
send(200, { invitation: await platform.invitation(code, user?.id) });
return;
}
if (!user) {
send(401, { error: 'Inicia sesión para continuar.' });
return;
}
if (path === '/api/rooms' && req.method === 'POST') {
const room = await platform.create(
user,
createSchema.parse(await body(req)),
);
changed();
send(201, { room: await platform.view(room.id, user.id) });
return;
}
if (path === '/api/rooms/join' && req.method === 'POST') {
const input = z
.object({ code: z.string().trim().min(6).max(40) })
.parse(await body(req));
const room = await platform.join(user, input.code);
await broadcast(room.id);
changed();
send(200, { room: await platform.view(room.id, user.id) });
return;
}
if (
/^\/api\/rooms\/[^/]+\/(leave|close-inactive)$/.test(path) &&
req.method === 'POST'
) {
const id = idSchema.parse(path.split('/')[3]);
const input = z
.object({ expectedVersion: z.number().int().nonnegative() })
.parse(await body(req));
if (path.endsWith('/leave'))
await platform.leave(user, id, input.expectedVersion);
else await platform.closeInactive(user, id, input.expectedVersion);
await broadcast(id);
changed();
send(200, { ok: true });
return;
}
if (
path.match(/^\/api\/rooms\/[^/]+\/invitation$/) &&
req.method === 'POST'
) {
const id = idSchema.parse(path.split('/')[3]);
const input = z
.object({ message: z.string().trim().max(300) })
.parse(await body(req));
await platform.personalizeInvite(user, id, input.message);
send(200, { room: await platform.view(id, user.id) });
return;
}
if (path.startsWith('/api/rooms/') && req.method === 'GET') {
const id = idSchema.parse(path.slice(11));
send(200, { room: await platform.view(id, user.id) });
return;
}
if (path === '/api/profile' && req.method === 'GET') {
send(200, await platform.profile(user.id));
return;
}
if (path === '/api/profile/avatar' && req.method === 'POST') {
limits.check(`avatar:${user.id}`, 20, 60000);
const input = z
.union([
z.object({ preset: z.string().max(30).nullable() }),
z.object({ image: z.string().max(90000) }),
])
.parse(await body(req, 95000));
const avatar = validateAvatar(input);
const updated = publicUser(
await store.updateUser(user.id, (stored) => {
stored.avatar = avatar;
}),
);
const rooms = (await store.listRooms(user.id)).filter((r) =>
r.players.some((p) => p.userId === user.id),
);
for (const room of rooms) {
await store.updateRoom(room.id, (stored) => {
stored.players.find((p) => p.userId === user.id).avatar =
updated.avatar;
});
await broadcast(room.id);
}
changed();
send(200, { user: updated });
return;
}
if (
/^\/api\/avatars\/[a-f0-9-]{36}$/.test(path) &&
req.method === 'GET'
) {
const account = await store.userById(path.split('/').at(-1));
if (!account?.avatar?.image) {
send(404, { error: 'Avatar no encontrado.' });
return;
}
res.writeHead(200, {
'content-type': 'image/png',
'cache-control': 'private, max-age=3600',
'x-content-type-options': 'nosniff',
});
res.end(Buffer.from(account.avatar.image.slice(22), 'base64'));
return;
}
send(404, { error: 'Ruta no encontrada.' });
} catch (e) {
if (e instanceof z.ZodError) {
send(400, {
error: req.url?.startsWith('/api/terms/')
? 'Debes aceptar expresamente la versión vigente de las condiciones.'
: 'Revisa los datos: correo válido, nick de 3–20 letras/números y PIN de cuatro cifras.',
fields: e.issues.map((i) => i.path.join('.')),
});
} else if (e.code || /database|connect|query/i.test(e.message)) {
console.error('Error del servicio:', e.message);
send(500, { error: 'No se pudo completar la operación.' });
} else send(400, { error: e.message });
}
});
return {
server,
io,
platform,
close: async () => {
clearInterval(expiryTimer);
await sweepPromise;
return new Promise((resolve) => io.close(resolve));
},
};
}

@ -0,0 +1,334 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { Pool } from 'pg';
const empty = () => ({ users: [], sessions: [], rooms: [] });
export class FileStore {
constructor(path) {
this.path = resolve(path);
this.queue = Promise.resolve();
}
async init() {
await mkdir(dirname(this.path), { recursive: true });
try {
this.data = JSON.parse(await readFile(this.path, 'utf8'));
} catch (e) {
if (e.code !== 'ENOENT') throw e;
this.data = empty();
await this.persist(this.data);
}
}
async persist(data) {
const temp = `${this.path}.tmp`;
await writeFile(temp, JSON.stringify(data), { mode: 0o600 });
await rename(temp, this.path);
}
async mutate(fn) {
const operation = this.queue.then(async () => {
const draft = structuredClone(this.data);
const result = await fn(draft);
await this.persist(draft);
this.data = draft;
return structuredClone(result);
});
this.queue = operation.catch(() => {});
return operation;
}
async userByEmail(email) {
return structuredClone(
this.data.users.find((u) => u.email === email) ?? null,
);
}
async userById(id) {
return structuredClone(this.data.users.find((u) => u.id === id) ?? null);
}
async createUser(user) {
return this.mutate((d) => {
if (
d.users.some(
(u) =>
(user.email && u.email === user.email) ||
u.nick.toLowerCase() === user.nick.toLowerCase(),
)
)
throw new Error('El correo o nick ya están registrados.');
d.users.push(user);
return user;
});
}
async createSession(session) {
return this.mutate((d) => {
d.sessions = d.sessions.filter((s) => s.expiresAt > Date.now());
d.sessions.push(session);
return session;
});
}
async updateUser(id, fn) {
return this.mutate(async (d) => {
const user = d.users.find((u) => u.id === id);
if (!user) throw new Error('Cuenta no encontrada.');
await fn(user);
if (
d.users.some(
(u) =>
u.id !== id &&
((user.email && u.email === user.email) ||
u.nick.toLowerCase() === user.nick.toLowerCase()),
)
)
throw new Error('El correo o nick ya están registrados.');
return user;
});
}
async deleteUserSessions(id) {
return this.mutate((d) => {
d.sessions = d.sessions.filter((s) => s.userId !== id);
});
}
async session(hash) {
return structuredClone(
this.data.sessions.find(
(s) => s.hash === hash && s.expiresAt > Date.now(),
) ?? null,
);
}
async deleteSession(hash) {
return this.mutate((d) => {
d.sessions = d.sessions.filter((s) => s.hash !== hash);
return null;
});
}
async listRooms(userId) {
return structuredClone(
this.data.rooms.filter(
(r) =>
r.visibility === 'public' ||
r.players.some((p) => p.userId === userId),
),
);
}
async room(id) {
return structuredClone(
this.data.rooms.find((r) => r.id === id || r.code === id.toUpperCase()) ??
null,
);
}
async activeRooms() {
return structuredClone(
this.data.rooms.filter((r) => r.status !== 'finished'),
);
}
async createRoom(room) {
return this.mutate((d) => {
d.rooms.push(room);
return room;
});
}
async updateRoom(id, fn) {
return this.mutate(async (d) => {
const room = d.rooms.find((r) => r.id === id);
if (!room) throw new Error('Sala no encontrada.');
await fn(room);
return room;
});
}
async history(userId) {
return structuredClone(
this.data.rooms.filter((r) =>
r.players.some(
(p) =>
p.userId === userId && (r.status === 'finished' || p.withdrawnAt),
),
),
);
}
async close() {
await this.queue;
}
}
export class PostgresStore {
constructor(url) {
this.pool = new Pool({ connectionString: url, max: 10 });
}
async init() {
await this.pool.query('SELECT id FROM rooms LIMIT 1');
}
async userByEmail(email) {
const r = await this.pool.query('SELECT data FROM users WHERE email=$1', [
email,
]);
return r.rows[0]?.data ?? null;
}
async userById(id) {
const r = await this.pool.query('SELECT data FROM users WHERE id=$1', [id]);
return r.rows[0]?.data ?? null;
}
async createUser(user) {
try {
await this.pool.query(
'INSERT INTO users(id,email,nick,data) VALUES($1,$2,$3,$4)',
[user.id, user.email, user.nick.toLowerCase(), user],
);
return user;
} catch (e) {
if (e.code === '23505')
throw new Error('El correo o nick ya están registrados.');
throw e;
}
}
async createSession(session) {
await this.pool.query(
'INSERT INTO sessions(hash,user_id,expires_at) VALUES($1,$2,$3)',
[session.hash, session.userId, new Date(session.expiresAt)],
);
return session;
}
async updateUser(id, fn) {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(
'SELECT data FROM users WHERE id=$1 FOR UPDATE',
[id],
);
const user = result.rows[0]?.data;
if (!user) throw new Error('Cuenta no encontrada.');
await fn(user);
await client.query(
'UPDATE users SET email=$2,nick=$3,data=$4 WHERE id=$1',
[id, user.email, user.nick.toLowerCase(), user],
);
await client.query('COMMIT');
return user;
} catch (e) {
await client.query('ROLLBACK');
if (e.code === '23505')
throw new Error('El correo o nick ya están registrados.');
throw e;
} finally {
client.release();
}
}
async deleteUserSessions(id) {
await this.pool.query('DELETE FROM sessions WHERE user_id=$1', [id]);
}
async session(hash) {
const r = await this.pool.query(
'SELECT hash,user_id AS "userId",expires_at FROM sessions WHERE hash=$1 AND expires_at>now()',
[hash],
);
return r.rows[0]
? { ...r.rows[0], expiresAt: +r.rows[0].expires_at }
: null;
}
async deleteSession(hash) {
await this.pool.query('DELETE FROM sessions WHERE hash=$1', [hash]);
}
async listRooms(userId) {
const r = await this.pool.query(
"SELECT data FROM rooms WHERE visibility='public' OR data->'players' @> $1::jsonb ORDER BY created_at DESC",
[JSON.stringify([{ userId }])],
);
return r.rows.map((r) => r.data);
}
async activeRooms() {
const r = await this.pool.query(
"SELECT data FROM rooms WHERE status IN ('waiting','playing')",
);
return r.rows.map((r) => r.data);
}
async room(id) {
const r = await this.pool.query(
'SELECT data FROM rooms WHERE id::text=$1 OR code=$2',
[id, id.toUpperCase()],
);
return r.rows[0]?.data ?? null;
}
async createRoom(room) {
await this.pool.query(
'INSERT INTO rooms(id,code,game_id,game_version,visibility,status,created_at,data) VALUES($1,$2,$3,$4,$5,$6,$7,$8)',
[
room.id,
room.code,
room.gameId,
room.gameVersion,
room.visibility,
room.status,
room.createdAt,
room,
],
);
return room;
}
async updateRoom(id, fn) {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const row = await client.query(
'SELECT data FROM rooms WHERE id=$1 FOR UPDATE',
[id],
);
if (!row.rows[0]) throw new Error('Sala no encontrada.');
const room = row.rows[0].data;
const eventCount = room.events.length,
messageCount = room.messages.length;
await fn(room);
await client.query('UPDATE rooms SET status=$2,data=$3 WHERE id=$1', [
id,
room.status,
room,
]);
for (const event of room.events.slice(eventCount))
await client.query(
'INSERT INTO match_events(room_id,sequence,action_id,actor_id,data) VALUES($1,$2,$3,$4,$5)',
[id, event.sequence, event.actionId, event.actorId, event],
);
for (const msg of room.messages.slice(messageCount))
await client.query(
'INSERT INTO chat_messages(id,room_id,user_id,data) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING',
[msg.id, id, msg.userId, msg],
);
if (room.result && !room.result.cancelled)
for (const player of room.players)
await client.query(
'INSERT INTO match_results(room_id,user_id,outcome,placement,reason) VALUES($1,$2,$3,$4,$5) ON CONFLICT(room_id,user_id) DO NOTHING',
[
id,
player.userId,
player.outcome,
player.placement,
room.result.reason,
],
);
await client.query('COMMIT');
return room;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
async history(userId) {
const r = await this.pool.query(
"SELECT data FROM rooms WHERE EXISTS (SELECT 1 FROM jsonb_array_elements(data->'players') p WHERE p->>'userId'=$1 AND (status='finished' OR p->>'withdrawnAt' IS NOT NULL)) ORDER BY created_at DESC",
[userId],
);
return r.rows.map((r) => r.data);
}
async close() {
await this.pool.end();
}
}
export async function createStore(env = process.env) {
if (env.NODE_ENV === 'production' && !env.DATABASE_URL)
throw new Error('Producción requiere DATABASE_URL.');
const store = env.DATABASE_URL
? new PostgresStore(env.DATABASE_URL)
: new FileStore(env.DATA_FILE || '.data/development.json');
await store.init();
return store;
}

@ -0,0 +1,47 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { terms } from '../shared/terms.mjs';
export const TERMS_COOKIE = 'juegoland_terms';
export const TERMS_MAX_AGE = 365 * 24 * 60 * 60;
const signature = (payload, secret) =>
createHmac('sha256', secret)
.update(`juegoland-terms:${payload}`)
.digest('hex');
export function termsCookie(secret, production, now = Date.now()) {
const payload = `${terms.version}.${Math.floor(now / 1000)}`;
return `${TERMS_COOKIE}=${payload}.${signature(payload, secret)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${TERMS_MAX_AGE}${production ? '; Secure' : ''}`;
}
export function clearTermsCookie(production) {
return `${TERMS_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${production ? '; Secure' : ''}`;
}
export function termsAcceptance(header = '', secret, now = Date.now()) {
const rejected = {
accepted: false,
version: terms.version,
acceptedAt: null,
expiresAt: null,
};
const value = header
.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(`${TERMS_COOKIE}=`))
?.slice(TERMS_COOKIE.length + 1);
const match = value?.match(
/^(\d{4}-\d{2}-\d{2}\.\d+)\.(\d{10})\.([a-f0-9]{64})$/,
);
if (!match || match[1] !== terms.version) return rejected;
const issuedAt = Number(match[2]) * 1000;
if (issuedAt > now || now >= issuedAt + TERMS_MAX_AGE * 1000) return rejected;
const expected = Buffer.from(
signature(`${match[1]}.${match[2]}`, secret),
'hex',
);
if (!timingSafeEqual(expected, Buffer.from(match[3], 'hex'))) return rejected;
return {
accepted: true,
version: terms.version,
acceptedAt: new Date(issuedAt).toISOString(),
expiresAt: new Date(issuedAt + TERMS_MAX_AGE * 1000).toISOString(),
};
}

@ -0,0 +1,87 @@
const animation = (kind, duration = 350) => ({
kind,
duration,
easing: 'ease-out',
});
const finish = {
start: animation('reveal', 500),
win: animation('celebrate', 900),
loss: animation('fade', 400),
draw: animation('reveal', 400),
finish: animation('celebrate', 900),
};
export const gameAnimations = {
ajedrez: {
...finish,
move: animation('slide', 300),
capture: animation('capture', 350),
check: animation('pulse', 650),
promotion: animation('reveal', 450),
},
damas: {
...finish,
move: animation('slide', 320),
capture: animation('jump', 400),
promotion: animation('reveal', 450),
},
parchis: {
...finish,
roll: animation('cup', 800),
shake: animation('shake', 180),
move: animation('path', 600),
capture: animation('capture', 600),
goal: animation('celebrate', 700),
pass: animation('fade', 250),
},
go: {
...finish,
move: animation('place', 240),
capture: animation('capture', 400),
pass: animation('fade', 200),
mark: animation('pulse', 300),
'accept-score': animation('reveal', 300),
},
oca: {
...finish,
roll: animation('cup', 800),
shake: animation('shake', 180),
move: animation('path', 700),
bonus: animation('jump', 650),
penalty: animation('shake', 450),
goal: animation('celebrate', 700),
},
monopoli: {
...finish,
roll: animation('cup', 800),
move: animation('path', 650),
buy: animation('reveal', 400),
payment: animation('transfer', 450),
build: animation('place', 300),
},
cluedo: {
...finish,
roll: animation('cup', 800),
move: animation('slide', 500),
suggestion: animation('pulse', 500),
reveal: animation('flip', 450),
accusation: animation('reveal', 650),
},
risk: {
...finish,
reinforce: animation('place', 250),
attack: animation('shake', 500),
conquest: animation('reveal', 650),
move: animation('transfer', 450),
},
tute: {
...finish,
play: animation('flip', 350),
trick: animation('transfer', 500),
sing: animation('reveal', 600),
},
brisca: {
...finish,
play: animation('flip', 350),
trick: animation('transfer', 500),
},
};

@ -0,0 +1,10 @@
export const avatarPresets = [
{ id: 'caballo', name: 'Caballo', src: '/avatars/caballo.svg' },
{ id: 'dado', name: 'Dado', src: '/avatars/dado.svg' },
{ id: 'oca', name: 'Oca', src: '/avatars/oca.svg' },
{ id: 'gato', name: 'Gato', src: '/avatars/gato.svg' },
{ id: 'zorro', name: 'Zorro', src: '/avatars/zorro.svg' },
{ id: 'robot', name: 'Robot', src: '/avatars/robot.svg' },
{ id: 'estrella', name: 'Estrella', src: '/avatars/estrella.svg' },
{ id: 'torre', name: 'Torre', src: '/avatars/torre.svg' },
];

@ -0,0 +1,9 @@
export const brand = {
name: 'Juegoland',
invitationOrigin: 'https://juegoland.imaginacion.com',
logo: '/branding/juegoland-logo-transparent.png',
tagline: 'Juegos para compartir',
hero: 'La próxima ronda empieza contigo.',
introduction: 'Elige un juego, invita a tu gente y disfruta del rato juntos.',
welcome: 'Siempre hay sitio para uno más.',
};

@ -0,0 +1,111 @@
import { gameSounds } from './sounds.mjs';
import { ocaBoard } from './oca-board.mjs';
import { gameAnimations } from './animations.mjs';
export const catalog = [
{
id: 'ajedrez',
name: 'Ajedrez',
subtitle: 'Cada movimiento cuenta.',
category: 'Estrategia',
variant: 'Clásico · sin reloj',
minPlayers: 2,
maxPlayers: 2,
duration: '15–40 min',
difficulty: 'Estratégico',
color: 'sage',
icon: 'chess',
version: '1.0.0',
rules: [
'Las blancas empiezan. Selecciona una pieza y su destino; los movimientos legales se resaltan.',
'Incluye enroque, captura al paso y elección de promoción.',
'La partida termina por jaque mate, rendición o tablas. En esta versión, repetición triple y 50 movimientos producen tablas automáticas.',
'Esta primera modalidad no tiene reloj. Puedes volver a la partida tras desconectarte.',
],
},
{
id: 'damas',
name: 'Damas',
subtitle: 'Una captura cambia todo.',
category: 'Estrategia',
variant: 'Damas de recorrido largo · tablero 8 × 8',
minPlayers: 2,
maxPlayers: 2,
duration: '10–25 min',
difficulty: 'Accesible',
color: 'sand',
icon: 'checkers',
version: '2.0.0',
rules: [
'12 fichas por jugador sobre las casillas oscuras.',
'Las fichas normales avanzan y capturan hacia delante. Capturar es obligatorio; debes completar los saltos encadenados.',
'Al coronar termina el turno. Una dama recorre cualquier número de casillas libres en diagonal, hacia delante o atrás. Puede capturar una pieza a distancia y elegir una casilla libre más allá; no puede saltar otras piezas.',
'Gana quien deja al rival sin fichas o movimientos. Se declaran tablas tras 40 turnos por jugador sin captura ni coronación.',
],
},
{
id: 'parchis',
name: 'Parchís',
subtitle: 'La suerte también se juega.',
category: 'Familia',
variant: 'Individual · un dado · 4 fichas por jugador',
minPlayers: 2,
maxPlayers: 4,
duration: '20–45 min',
difficulty: 'Familiar',
color: 'lilac',
icon: 'dice',
version: '2.0.0',
rules: [
'Todas las fichas empiezan en casa. Sale una con 5; si la salida es posible, es obligatoria. Cada jugador controla un color.',
'Un 6 permite repetir. Cuenta 7 cuando no quedan fichas en casa. El tercer 6 termina el turno y devuelve la última ficha movida a casa, salvo que esté en el pasillo final o en meta.',
'Dos fichas del mismo color forman una barrera que nadie puede atravesar. Al sacar 6 debes abrir una barrera si tienes un movimiento para hacerlo.',
'Los 16 seguros admiten dos fichas, incluso de colores distintos. Salir con 5 a una salida completa captura al último rival que llegó.',
'Capturar concede 20 pasos; llegar a meta, 10. El premio se mueve con una ficha y debe gastarse completo. Si no es posible, se descarta automáticamente.',
'Entra en meta con el número exacto. Gana quien lleva sus cuatro fichas. Tras mover o cuando no puedas hacerlo, el turno avanza automáticamente; un 6 conserva la repetición.',
],
},
{
id: 'go',
name: 'Go',
subtitle: 'Construye territorio, piedra a piedra.',
category: 'Estrategia',
variant: 'Área · tablero 9 × 9 · komi 6,5',
minPlayers: 2,
maxPlayers: 2,
duration: '15–35 min',
difficulty: 'Estratégico',
color: 'sand',
icon: 'go',
version: '1.0.0',
rules: [
'Las negras comienzan. Coloca una piedra en una intersección vacía. Los grupos sin libertades se capturan; no se permite suicidio ni repetir una posición anterior (superko).',
'Dos pases seguidos abren el recuento. Marcad los grupos muertos y aceptad ambos la propuesta, o reanudad la partida si hay desacuerdo.',
'Se cuentan piedras vivas y territorio rodeado por un único color. Las blancas reciben 6,5 puntos de compensación. Sin reloj ni hándicap.',
],
},
{
id: 'oca',
name: 'La Oca',
subtitle: 'De oca a oca… y a por la revancha.',
category: 'Familia',
variant: 'Un dado · 63 casillas · pozo de tres turnos',
minPlayers: 2,
maxPlayers: 4,
duration: '10–25 min',
difficulty: 'Familiar',
color: 'sage',
icon: 'goose',
version: '1.0.0',
rules: [
'Cada jugador tiene una ficha. El dado y el movimiento se resuelven en el servidor; los turnos y descansos avanzan automáticamente.',
'Una oca te lleva a la siguiente y repites. Desde la última oca (59) conservas la casilla y repites; la meta exige una tirada exacta. Los puentes 6 y 12 se conectan; los dados 26 y 53 también.',
'Posada 19: descansa un turno. Pozo 31: tres turnos. Laberinto 42: vuelve a 30. Cárcel 52: dos turnos. Calavera 58: vuelve al inicio.',
'Gana quien llega exactamente a 63. Si te pasas, rebota hacia atrás. Esta modalidad usa un dado y una espera fija en el pozo.',
],
},
].map((definition) => ({
...definition,
sounds: gameSounds[definition.id],
animations: gameAnimations[definition.id],
...(definition.id === 'oca' ? { board: ocaBoard } : {}),
}));

@ -0,0 +1,8 @@
import { brand } from './brand.mjs';
/** Use the public HTTPS address in production and the current origin for local previews. */
export function invitationUrl(code, currentOrigin, production) {
const url = new URL('/', production ? brand.invitationOrigin : currentOrigin);
url.searchParams.set('invite', code);
return url.href;
}

@ -0,0 +1,150 @@
export const geese = [5, 9, 14, 18, 23, 27, 32, 36, 41, 45, 50, 54, 59];
// Coordinates in the author's 1254 × 1254 artwork. Each index is its game square.
export const ocaCenters = [
[80, 811],
[80, 945],
[80, 1083],
[199, 1083],
[325, 1083],
[450, 1083],
[574, 1083],
[696, 1083],
[821, 1083],
[953, 1083],
[1085, 1083],
[1085, 945],
[1085, 811],
[1085, 673],
[1085, 537],
[1085, 406],
[1085, 272],
[1085, 136],
[956, 136],
[821, 136],
[688, 136],
[563, 136],
[438, 136],
[313, 136],
[187, 136],
[187, 272],
[187, 406],
[187, 537],
[187, 673],
[199, 811],
[199, 945],
[324, 945],
[450, 945],
[574, 945],
[696, 945],
[821, 945],
[953, 945],
[953, 811],
[953, 673],
[953, 537],
[953, 406],
[956, 272],
[821, 272],
[688, 272],
[563, 272],
[438, 272],
[313, 272],
[313, 406],
[313, 537],
[313, 673],
[325, 811],
[452, 811],
[574, 811],
[695, 811],
[819, 811],
[821, 673],
[821, 537],
[821, 406],
[688, 406],
[563, 406],
[438, 406],
[438, 537],
[438, 673],
[628, 607],
];
export const ocaBoard = {
image: '/boards/oca-spiral.png',
width: 1254,
height: 1254,
viewport: { x: 24, y: 72, width: 1124, height: 1078 },
centers: ocaCenters,
};
export function ocaCell(n) {
const info = {
title: n === 0 ? 'Inicio' : `Casilla ${n}`,
kind: 'normal',
description:
'Aquí no hay premio ni penalización. En tu turno avanzarás lo que indique el dado.',
};
if (geese.includes(n))
return {
...info,
title: `Casilla ${n} · Oca`,
kind: 'advantage',
description:
n === 59
? 'Vuelves a lanzar el dado. Esta es la última oca antes del jardín.'
: `De oca a oca: saltas a la casilla ${geese[geese.indexOf(n) + 1]} y vuelves a lanzar.`,
};
const specials = {
0: [
'Inicio',
'normal',
'Aquí comienza la partida. Lanza el dado para entrar en el recorrido.',
],
6: [
'Puente',
'advantage',
'Saltas al puente de la casilla 12 y vuelves a lanzar.',
],
12: [
'Puente',
'advantage',
'Vuelves al puente de la casilla 6 y lanzas otra vez.',
],
19: [
'Posada',
'danger',
'Descansas un turno. El siguiente jugador continúa la partida.',
],
26: [
'Dados',
'advantage',
'Saltas a la casilla 53 y vuelves a lanzar el dado.',
],
31: [
'Pozo',
'danger',
'Pierdes tres turnos antes de poder volver a lanzar.',
],
42: [
'Laberinto',
'danger',
'Retrocedes a la casilla 30. Termina tu turno.',
],
52: [
'Cárcel',
'danger',
'Pierdes dos turnos antes de poder volver a lanzar.',
],
53: ['Dados', 'advantage', 'Vuelves a la casilla 26 y lanzas otra vez.'],
58: ['Calavera', 'danger', 'Vuelves al inicio y termina tu turno.'],
63: [
'El jardín · Meta',
'advantage',
'Ganas al llegar con una tirada exacta. Si te pasas de 63, retrocedes las casillas sobrantes.',
],
};
const special = specials[n];
return special
? {
title: n ? `Casilla ${n} · ${special[0]}` : special[0],
kind: special[1],
description: special[2],
}
: info;
}

@ -0,0 +1,45 @@
export const playerColors = [
{ id: 'amarillo', label: 'Amarillo', hex: '#b88925' },
{ id: 'azul', label: 'Azul', hex: '#4e84a6' },
{ id: 'rojo', label: 'Rojo', hex: '#b95f56' },
{ id: 'verde', label: 'Verde', hex: '#568264' },
{ id: 'negro', label: 'Negro', hex: '#30343b' },
{ id: 'naranja', label: 'Naranja', hex: '#c96b24' },
{ id: 'violeta', label: 'Violeta', hex: '#8654b8' },
{ id: 'gris', label: 'Gris', hex: '#7d8794' },
];
export const parchisColorOrder = playerColors.slice(0, 4).map((c) => c.id);
export const canChooseColor = (room) =>
room.gameId === 'oca' ||
(room.gameId === 'parchis' && room.gameVersion !== '1.0.0');
export const paletteForRoom = (room) =>
playerColors.slice(0, room.maxPlayers > 4 ? 8 : 4);
export const colorForPlayer = (player) =>
playerColors.find((c) => c.id === player?.colorId) ??
playerColors[player?.seat ?? 0] ??
playerColors[0];
export const playerColorClass = (player) =>
`player-color-${playerColors.indexOf(colorForPlayer(player))}`;
export function assignLastColor(room) {
const last = room.players.find((p) => p.seat === room.maxPlayers - 1);
if (!last) return;
const others = room.players.filter((p) => p !== last);
if (others.length === room.maxPlayers - 1 && others.every((p) => p.colorId))
last.colorId = paletteForRoom(room).find(
(c) => !others.some((p) => p.colorId === c.id),
).id;
else delete last.colorId;
}
export function chooseDefaultColor(room, player) {
if (player.colorId || player.seat === room.maxPlayers - 1) return;
const free = paletteForRoom(room).filter(
(c) =>
!room.players.some(
(p) =>
p !== player && p.seat !== room.maxPlayers - 1 && p.colorId === c.id,
),
);
player.colorId = (
free.find((c) => c.id === playerColors[player.seat]?.id) ?? free[0]
).id;
}

@ -0,0 +1,36 @@
export const inactivityPolicy = {
closeAfter: 10 * 60_000,
expireAfter: 24 * 60 * 60_000,
};
export function roomActivityAt(room) {
return Date.parse(
room.lastGameActivityAt ??
room.events?.at(-1)?.createdAt ??
room.startedAt ??
room.createdAt,
);
}
export function roomDeadlines(room) {
const activity = roomActivityAt(room);
return {
inactiveCloseAt: activity + inactivityPolicy.closeAfter,
autoCloseAt: activity + inactivityPolicy.expireAfter,
};
}
export function canCloseInactive(room, seat, now = Date.now()) {
if (
room.status !== 'playing' ||
!room.players[seat] ||
room.players[seat].withdrawnAt
)
return false;
const waiting =
room.state?.phase === 'scoring'
? room.state.accepted?.[seat] &&
room.state.accepted.some((accepted, i) => i !== seat && !accepted)
: room.state?.turn !== seat;
return Boolean(waiting && now >= roomDeadlines(room).inactiveCloseAt);
}

@ -0,0 +1,19 @@
// Only a new, recent, authoritative transition can trigger an effect.
// Initial loads, duplicate acknowledgements and old replayed events stay silent.
export function roomSound(previous, next, now = Date.now()) {
if (!previous || previous.id !== next.id) return null;
if (next.result?.cancelled || next.events?.at(-1)?.type === 'leave')
return null;
if (previous.status === 'waiting' && next.status === 'playing')
return 'start';
const last = next.events?.at(-1);
if (!last || last.sequence <= (previous.events?.at(-1)?.sequence ?? 0))
return null;
const age = now - Date.parse(last.createdAt);
if (age < -5000 || age > 5000) return null;
if (next.status === 'finished' && previous.status !== 'finished') {
const outcome = next.players.find((p) => p.seat === next.seat)?.outcome;
return outcome === 'win' ? 'win' : outcome === 'loss' ? 'loss' : 'draw';
}
return last.sound ?? 'move';
}

@ -0,0 +1,86 @@
// A game's manifest maps semantic event IDs to local audio assets.
// Rules return the event ID; clients decide when and at what volume to play it.
const effect = (name, volume = 0.8) => ({
src: `/sounds/v2/${name}.wav`,
volume,
});
const endings = {
start: effect('start', 0.65),
win: effect('win', 0.65),
loss: effect('loss', 0.55),
draw: effect('draw', 0.55),
finish: effect('draw', 0.55),
};
export const gameSounds = {
ajedrez: {
...endings,
move: effect('chess-move'),
capture: effect('chess-capture'),
check: effect('check', 0.6),
promotion: effect('promotion', 0.65),
},
damas: {
...endings,
move: effect('checkers-move'),
capture: effect('checkers-capture'),
promotion: effect('promotion', 0.65),
},
parchis: {
...endings,
roll: { ...effect('dice-roll'), announceNumber: true, language: 'es-ES' },
shake: effect('cup-shake', 0.6),
move: effect('parchis-move'),
capture: effect('parchis-capture'),
goal: effect('goal', 0.65),
pass: effect('turn', 0.45),
},
go: {
...endings,
move: effect('go-place'),
capture: effect('go-capture'),
pass: effect('turn', 0.45),
},
oca: {
...endings,
roll: { ...effect('dice-roll'), announceNumber: true, language: 'es-ES' },
shake: effect('cup-shake', 0.6),
move: effect('oca-move'),
goal: effect('goal', 0.65),
bonus: effect('oca-bonus', 0.65),
penalty: effect('oca-penalty', 0.6),
},
monopoli: {
...endings,
roll: effect('dice-roll'),
move: effect('parchis-move'),
buy: effect('coin-buy'),
payment: effect('coin-payment'),
build: effect('build'),
},
cluedo: {
...endings,
roll: effect('dice-roll'),
move: effect('parchis-move'),
suggestion: effect('mystery-suggest', 0.5),
reveal: effect('card-play'),
accusation: effect('mystery-accuse', 0.6),
},
risk: {
...endings,
reinforce: effect('risk-reinforce'),
attack: effect('risk-attack'),
conquest: effect('promotion', 0.65),
move: effect('risk-reinforce'),
},
tute: {
...endings,
play: effect('tute-card'),
trick: effect('card-collect'),
sing: effect('promotion', 0.65),
},
brisca: {
...endings,
play: effect('brisca-card'),
trick: effect('card-collect'),
},
};

@ -0,0 +1,28 @@
export function termsReturnPath(value, origin) {
if (typeof value !== 'string' || !value.startsWith('/')) return '/';
try {
const url = new URL(value, origin);
return url.origin === origin ? url.pathname + url.search + url.hash : '/';
} catch {
return '/';
}
}
// Presentation hint only. The API independently validates the signed cookie.
export function hasCurrentTermsCookie(value = '', now = Date.now()) {
const match = value.match(
/^(\d{4}-\d{2}-\d{2}\.\d+)\.(\d{10})\.[a-f0-9]{64}$/,
);
if (!match || match[1] !== terms.version) return false;
const issuedAt = Number(match[2]) * 1000;
return issuedAt <= now && now < issuedAt + 365 * 24 * 60 * 60 * 1000;
}
export const terms = {
version: '2026-10-04.2',
author: 'Juan V. Navarro Leal',
introduction:
'Juegoland es un servicio desarrollado para uso personal por Juan V. Navarro Leal. Está en desarrollo y puede contener errores.',
notice:
'Su autor no se responsabiliza de la información compartida, pérdidas de datos ni daños o trastornos derivados del uso del servicio.',
};

File diff suppressed because it is too large Load Diff

@ -0,0 +1,13 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#f7f6f2" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

@ -0,0 +1,570 @@
.brand {
flex-direction: column;
align-items: flex-start;
gap: 9px;
margin: 0 0 38px;
letter-spacing: 0;
}
.brand-logo {
display: block;
width: 100%;
height: auto;
aspect-ratio: 897 / 214;
object-fit: cover;
background: transparent;
border-radius: 0;
}
.brand small {
color: var(--secondary);
font:
500 10px 'DM Sans',
sans-serif;
letter-spacing: 0.3px;
margin-left: 6px;
}
.mobile-logo {
display: none;
}
.sidebar,
.game-card,
.hero-note,
.join-code,
.lobby-table,
.continue-card,
.modal,
.auth-layout,
.stat-card,
.help-card,
.room-panel,
.parchis-board,
.promotion,
.room-code {
background: var(--surface);
border-color: var(--border);
}
.topbar {
background: var(--background);
}
.sidebar-invite,
.filter-tabs,
.difficulty,
.lobby-table-head,
.chat-form,
.variant-note,
.parchis-home,
.promotion button {
background: var(--soft);
border-color: var(--border);
}
.hero,
.auth-art,
.profile-header,
.help-intro,
.waiting-card,
.result-banner {
background: var(--hero);
border-color: var(--border);
}
.sidebar nav a.active,
.sidebar nav a:hover,
.turn-indicator {
background: var(--soft);
color: var(--accent-text);
}
.hero h1 {
color: var(--text);
}
.hero h1 em,
.hero-note > span,
.game-play,
.lobby-join,
.bottom-note > a,
.sidebar-invite a,
.waiting-symbol,
.room-code,
.hero-star {
color: var(--accent-text);
}
.hero-loop {
border-color: var(--border);
}
.sidebar-label,
.eyebrow,
.sidebar nav a:not(.active),
.sidebar-invite p,
.sidebar-bottom > p,
.user-card small,
.logout,
.header-search,
.header-search input::placeholder,
.connection,
.hero-eyebrow,
.hero-copy > p,
.hero-caption,
.hero-note small,
.welcome-strip,
.live-count,
.filter-tabs button,
.game-card-body > p,
.difficulty,
.game-meta,
.card-bottom > a,
.join-code,
.join-code input::placeholder,
.lobby-table-head,
.lobby-game small,
.player-stack small,
.status-badge,
.empty-lobby p,
.empty-panel,
.bottom-note > span,
.bottom-note strong,
.bottom-note p,
.continue-card small,
.site-footer,
.muted,
.modal label,
.auth-art p,
.auth-form > p,
.auth-tabs button,
.auth-note,
.page-heading p,
.profile-header p,
.stat-card span,
.stat-card small,
.history-item small,
.help-card ol,
.help-intro p,
.room-page-heading p,
.breadcrumb,
.player-banner small,
.board-note,
.chat-empty,
.chat-message time,
.room-rules,
.event-list,
.event-list span,
.event-list small,
.waiting-card > p,
.waiting-player small,
.result-banner p,
.parchis-controls p,
.parchis-home strong,
.home-pieces small {
color: var(--secondary);
}
.header-search input,
.modal input,
.modal select,
.modal textarea,
.auth-form input,
.chat-form input {
color: var(--text);
}
input:not([type='range']),
select,
textarea {
background: var(--surface);
border-color: var(--border);
color: var(--text);
}
input::placeholder,
textarea::placeholder {
color: var(--secondary);
opacity: 0.8;
}
.button.secondary,
.icon-button:hover,
.filter-tabs button.chosen {
background: var(--surface);
border-color: var(--border);
color: var(--accent-text);
}
.button.primary,
.chat-form button {
background: var(--accent);
color: #fff;
}
.button.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.button.danger,
.form-error {
color: var(--danger-text);
background: var(--danger-bg);
border-color: var(--danger-text);
}
.card-bottom,
.lobby-table-head,
.lobby-row,
.event-list > div {
border-color: var(--border);
}
.stat-card strong {
color: var(--text);
}
.stat-card:nth-child(2) strong,
.result-tag.win {
color: var(--accent-text);
}
.result-tag {
color: var(--secondary);
background: var(--soft);
}
.result-tag.loss {
color: var(--danger-text);
background: var(--danger-bg);
}
.avatar,
.mini-avatar {
background: var(--soft);
color: var(--accent-text);
border-color: var(--surface);
}
.settings-trigger {
display: grid;
place-items: center;
width: 38px;
height: 38px;
padding: 0;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
color: var(--secondary);
transition:
background 0.15s,
transform 0.15s,
color 0.15s;
}
.settings-trigger:hover {
color: var(--accent-text);
background: var(--soft);
transform: translateY(-1px);
}
.settings-popover {
position: fixed;
inset: 68px 28px auto auto;
margin: 0;
width: 310px;
max-width: calc(100vw - 24px);
padding: 22px;
border: 1px solid var(--border);
border-radius: 18px;
color: var(--text);
background: var(--surface);
box-shadow: 0 16px 54px var(--shadow);
}
.settings-popover:popover-open {
animation: panel-enter 0.18s ease-out;
}
.settings-popover > strong {
display: block;
font:
750 18px 'Manrope',
sans-serif;
margin-bottom: 20px;
}
.settings-label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--secondary);
margin: 18px 0 9px;
}
.theme-options,
.palette-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 7px;
}
.theme-options button,
.palette-options button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 4px;
min-width: 0;
border: 1px solid var(--border);
border-radius: 11px;
background: var(--surface);
color: var(--secondary);
font-size: 11px;
}
.theme-options button.chosen,
.palette-options button.chosen {
background: var(--soft);
border-color: var(--accent-text);
color: var(--accent-text);
box-shadow: 0 0 0 1px var(--accent-text);
}
.palette-options i {
width: 22px;
height: 22px;
border-radius: 50%;
box-shadow: inset 0 0 0 2px #fff6;
}
.sound-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border: 1px solid var(--border);
border-radius: 10px;
color: var(--accent-text);
background: var(--soft);
font-size: 13px;
margin-bottom: 20px;
}
.sound-popover label {
display: flex;
justify-content: space-between;
color: var(--secondary);
font-size: 12px;
font-weight: 500;
}
.sound-popover input[type='range'] {
width: 100%;
padding: 0;
margin: 16px 0 22px;
accent-color: var(--accent);
}
.sound-popover p {
font-size: 12px;
margin-top: 12px;
color: var(--secondary);
}
.sound-popover .speech-option {
justify-content: flex-start;
align-items: center;
gap: 9px;
margin: 0 0 20px;
line-height: 1.5;
}
.speech-option input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--accent);
}
.modal.dice-overlay {
width: 560px;
max-width: calc(100vw - 24px);
border: 1px solid var(--border);
border-radius: 28px;
padding: 38px 30px 28px;
text-align: center;
background: radial-gradient(
ellipse at 50% 36%,
var(--hero),
var(--surface) 70%
);
}
.dice-overlay::backdrop {
background: #081126b8;
backdrop-filter: blur(7px);
}
.dice-overlay h2 {
font-size: 24px;
max-width: 360px;
margin: auto;
}
.text-button {
background: transparent;
color: var(--accent-text);
border: 0;
font-size: 12px;
padding: 10px 0;
}
.pin-input {
font-size: 23px;
letter-spacing: 12px;
}
.guest-notice {
padding: 12px;
background: var(--soft);
border-radius: 10px;
margin-bottom: 20px;
font-size: 12px;
}
.modal .button.full + .button.full {
margin-top: 10px;
}
.player-color-0 {
--player-ink: #775800;
--player-soft: #fff3ce;
--player-solid: #b88925;
}
.player-color-1 {
--player-ink: #165ba1;
--player-soft: #e6f1ff;
--player-solid: #397bba;
}
.player-color-2 {
--player-ink: #aa3938;
--player-soft: #ffecea;
--player-solid: #b95f56;
}
.player-color-3 {
--player-ink: #217146;
--player-soft: #e6f5eb;
--player-solid: #568264;
}
.player-color-4 {
--player-ink: #30343b;
--player-soft: #e8eaed;
--player-solid: #30343b;
}
.player-color-5 {
--player-ink: #985023;
--player-soft: #fff0e1;
--player-solid: #ad702f;
}
.player-color-6 {
--player-ink: #703cad;
--player-soft: #f2eaff;
--player-solid: #8654b8;
}
.player-color-7 {
--player-ink: #536070;
--player-soft: #eaf0f4;
--player-solid: #7d8794;
}
[data-theme='dark'] .player-color-0 {
--player-ink: #f3cd67;
--player-soft: #393326;
}
[data-theme='dark'] .player-color-1 {
--player-ink: #95c6ff;
--player-soft: #243650;
}
[data-theme='dark'] .player-color-2 {
--player-ink: #ffb4ad;
--player-soft: #402d36;
}
[data-theme='dark'] .player-color-3 {
--player-ink: #92ddb1;
--player-soft: #203d35;
}
[data-theme='dark'] .player-color-4 {
--player-ink: #e1e4e9;
--player-soft: #30343b;
}
[data-theme='dark'] .player-color-5 {
--player-ink: #ffc290;
--player-soft: #3b3029;
}
[data-theme='dark'] .player-color-6 {
--player-ink: #d9b8ff;
--player-soft: #352a49;
}
[data-theme='dark'] .player-color-7 {
--player-ink: #c1ccd6;
--player-soft: #303c48;
}
.avatar[class*='player-color'],
.mini-avatar[class*='player-color'] {
color: var(--player-ink);
background: var(--player-soft);
}
.chat-message strong {
display: flex;
align-items: center;
gap: 7px;
color: var(--player-ink);
font-size: 11px;
}
.chat-avatar {
display: grid;
place-items: center;
height: 23px;
width: 23px;
border-radius: 8px;
background: var(--player-soft);
color: var(--player-ink);
font-size: 11px;
}
.chat-message p,
.chat-message.mine p {
color: var(--text);
background: var(--player-soft);
border-inline-start: 3px solid var(--player-solid);
font-size: 13px;
}
.chat-message time {
font-size: 10px;
}
.chat-message {
animation: panel-enter 0.2s ease-out;
}
.parchis-controls {
background: var(--soft);
border-radius: 16px;
padding: 18px;
margin: 16px 0;
}
.parchis-home {
border-color: var(--border);
}
.home-pieces .parchis-token:not(:disabled) {
outline: 2px solid var(--accent-text);
outline-offset: 3px;
}
@keyframes panel-enter {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 1000px) and (min-width: 501px) {
.brand {
width: 64px;
align-items: center;
margin: 0 auto 38px;
}
.brand-logo {
min-width: 64px;
}
.brand small {
display: none;
}
}
@media (max-width: 500px) {
.sidebar {
background: var(--surface);
}
.mobile-logo {
display: block;
width: 155px;
flex-shrink: 0;
}
.topbar .header-search {
display: none;
}
.topbar {
justify-content: space-between;
gap: 10px;
padding: 0 16px;
}
.header-right {
gap: 8px;
}
.settings-popover {
inset: 65px 12px auto auto;
}
.header-login {
font-size: 11px;
gap: 4px;
}
.settings-trigger {
width: 34px;
height: 34px;
border-radius: 10px;
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,16 @@
import type { Room } from './types.js';
/** Ignore initial history, own messages and duplicate state acknowledgements. */
export function incomingChat(
previous: Room | null,
next: Room,
userId: string,
) {
if (!previous || previous.id !== next.id) return null;
const known = new Set(previous.messages?.map((message) => message.id));
return (
next.messages
?.filter((message) => message.userId !== userId && !known.has(message.id))
.at(-1) ?? null
);
}

@ -0,0 +1,119 @@
import { io, type Socket } from 'socket.io-client';
import type { User } from './types';
import { catalog } from '../../shared/catalog.mjs';
import type { Definition } from './types';
import { terms } from '../../shared/terms.mjs';
export const consent = $state({
accepted: false,
version: terms.version,
acceptedAt: null as string | null,
expiresAt: null as string | null,
reviewOpen: false,
dismissed: false,
});
export const definitions = catalog as Definition[];
export const session = $state({
user: null as User | null,
loading: true,
connected: false,
search: '',
});
let socket: Socket | null = null;
let consentRevision = 0;
export function getSocket() {
if (!socket) {
socket = io({
autoConnect: false,
withCredentials: true,
transports: ['websocket'],
});
socket.on('connect', () => {
session.connected = true;
});
socket.on('disconnect', () => {
session.connected = false;
});
socket.on('connect_error', () => {
session.connected = false;
});
}
return socket;
}
export async function api<T>(path: string, input?: unknown): Promise<T> {
const response = await fetch(`/api${path}`, {
method: input === undefined ? 'GET' : 'POST',
credentials: 'same-origin',
signal: AbortSignal.timeout(10000),
headers: input === undefined ? {} : { 'content-type': 'application/json' },
body: input === undefined ? undefined : JSON.stringify(input),
});
let result;
try {
result = await response.json();
} catch {
throw new Error('No se pudo conectar con el servidor.');
}
if (response.status === 428 && result.code === 'TERMS_REQUIRED') {
consent.accepted = false;
consent.dismissed = false;
consent.reviewOpen = true;
socket?.disconnect();
}
if (!response.ok)
throw new Error(result.error || 'No se pudo completar la operación.');
return result;
}
export async function initialize() {
const requestRevision = consentRevision;
try {
const result = await api<{
user: User | null;
terms: {
accepted: boolean;
version: string;
acceptedAt: string | null;
expiresAt: string | null;
};
}>('/session');
session.user = result.user;
if (requestRevision === consentRevision)
Object.assign(consent, result.terms);
if (session.user && consent.accepted) getSocket().connect();
} finally {
session.loading = false;
}
}
export function signedIn(user: User) {
session.user = user;
if (consent.accepted) getSocket().connect();
}
export async function acceptTerms() {
const result = await api<{
terms: Pick<
typeof consent,
'accepted' | 'version' | 'acceptedAt' | 'expiresAt'
>;
}>('/terms/accept', { accepted: true, version: terms.version });
consentRevision++;
Object.assign(consent, result.terms);
consent.reviewOpen = false;
if (session.user) getSocket().connect();
}
export async function revokeTerms() {
const result = await api<{
terms: Pick<
typeof consent,
'accepted' | 'version' | 'acceptedAt' | 'expiresAt'
>;
}>('/terms/revoke', {});
Object.assign(consent, result.terms);
consent.reviewOpen = false;
socket?.disconnect();
}
export async function signOut() {
await api('/auth/logout', {});
session.user = null;
getSocket().disconnect();
}

@ -0,0 +1,26 @@
<script lang="ts">
let {
person,
}: { person?: { nick?: string; name?: string; avatar?: string } | null } =
$props();
</script>
{#if person?.avatar}<img
class="avatar-image"
src={person.avatar}
alt=""
width="80"
height="80"
/>{:else}{(person?.nick ?? person?.name ?? '?')
.slice(0, 2)
.toUpperCase()}{/if}
<style>
.avatar-image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
</style>

@ -0,0 +1,212 @@
<script lang="ts">
import { session, api } from '../client.svelte';
import type { User } from '../types';
import { avatarPresets } from '../../../shared/avatars.mjs';
import Dialog from './Dialog.svelte';
import AvatarImage from './AvatarImage.svelte';
let open = $state(false),
preview = $state<string | undefined>(),
preset = $state<string | null>(null),
image = $state<string | null>(null),
pending = $state(false),
error = $state('');
let fileInput = $state<HTMLInputElement>();
function edit() {
preview = session.user?.avatar;
preset = avatarPresets.find((p) => p.src === preview)?.id ?? null;
image = null;
error = '';
open = true;
}
function choose(id: string | null) {
preset = id;
image = null;
preview = avatarPresets.find((p) => p.id === id)?.src;
error = '';
}
async function upload(file: File | undefined) {
if (!file) return;
error = '';
if (
!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) ||
file.size > 8 * 1024 * 1024
) {
error = 'Elige una imagen PNG, JPG o WebP de hasta 8 MB.';
return;
}
pending = true;
try {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext('2d');
if (!context) throw Error('No se puede preparar esta imagen.');
const side = Math.min(bitmap.width, bitmap.height);
context.drawImage(
bitmap,
(bitmap.width - side) / 2,
(bitmap.height - side) / 2,
side,
side,
0,
0,
128,
128,
);
bitmap.close();
image = canvas.toDataURL('image/png');
preset = null;
preview = image;
} catch {
error = 'No se puede abrir esa imagen. Prueba con otra.';
} finally {
pending = false;
if (fileInput) fileInput.value = '';
}
}
async function save() {
if (preview === session.user?.avatar) {
open = false;
return;
}
pending = true;
error = '';
try {
const result = await api<{ user: User }>(
'/profile/avatar',
image ? { image } : { preset },
);
session.user = result.user;
open = false;
} catch (e) {
error = (e as Error).message;
} finally {
pending = false;
}
}
</script>
<button class="button secondary" onclick={edit}>Cambiar avatar</button>
{#if open}<Dialog
label="avatar-title"
onclose={() => (open = false)}
className="avatar-editor"
>
<h2 id="avatar-title">Tu avatar</h2>
<p class="avatar-copy">
Elige uno o sube una imagen. Se mostrará en tus partidas y en el chat.
</p>
<span class="avatar avatar-preview"
><AvatarImage person={{ ...session.user, avatar: preview }} /></span
>
<div class="avatar-options" aria-label="Avatares disponibles">
{#each avatarPresets as option}
<button
class:selected={preview === option.src}
aria-label={`Elegir avatar: ${option.name}`}
aria-pressed={preview === option.src}
disabled={pending}
onclick={() => choose(option.id)}
><img src={option.src} alt="" width="64" height="64" /></button
>
{/each}
</div>
<div class="avatar-tools">
<button
class="button secondary"
disabled={pending}
onclick={() => fileInput?.click()}>Subir una imagen</button
><button
class="button secondary"
disabled={pending}
onclick={() => choose(null)}>Usar mis iniciales</button
>
</div>
<input
bind:this={fileInput}
type="file"
style="display:none"
accept="image/png,image/jpeg,image/webp"
aria-label="Imagen para tu avatar"
onchange={(e) => upload(e.currentTarget.files?.[0])}
/>
<p class="avatar-hint">
PNG, JPG o WebP, hasta 8 MB. La imagen se recorta al centro.
</p>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<div class="form-row">
<button
class="button secondary"
disabled={pending}
onclick={() => (open = false)}>Cancelar</button
><button class="button primary" disabled={pending} onclick={save}
>{pending ? 'Guardando…' : 'Guardar avatar'}</button
>
</div>
</Dialog>{/if}
<style>
.avatar-copy {
font-size: var(--type-secondary);
color: var(--secondary);
margin: 12px 0 20px;
}
.avatar-preview {
width: 80px;
height: 80px;
margin: 0 auto 20px;
font-size: 28px;
}
.avatar-options {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.avatar-options button {
border: 2px solid transparent;
display: grid;
place-items: center;
background: transparent;
border-radius: 16px;
padding: 4px;
min-height: 64px;
}
.avatar-options button.selected {
border-color: var(--accent);
background: var(--soft);
}
.avatar-options img {
width: 64px;
height: 64px;
max-width: 100%;
border-radius: 50%;
}
.avatar-tools {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.avatar-tools .button {
font-size: var(--type-secondary);
padding: 10px;
}
.avatar-hint {
font-size: var(--type-detail);
color: var(--secondary);
margin: 12px 0 20px;
}
input[type='file'] {
display: none;
}
@media (max-width: 380px) {
.avatar-options {
gap: 8px;
}
.avatar-options img {
width: 52px;
height: 52px;
}
}
</style>

@ -0,0 +1,205 @@
<script lang="ts">
import type { Action, Room } from '../types';
import ParchisMap from './ParchisMap.svelte';
import Dice from './Dice.svelte';
import { diceFeedback } from '../dice-feedback';
import GoBoard from './GoBoard.svelte';
import OcaBoard from './OcaBoard.svelte';
import AvatarImage from './AvatarImage.svelte';
import {
colorForPlayer,
playerColorClass,
} from '../../../shared/player-colors.mjs';
let {
room,
disabled = false,
onaction,
}: {
room: Room;
disabled?: boolean;
onaction: (action: Action) => void;
} = $props();
let selected = $state<string | number | null>(null),
promotion = $state<Action[]>([]);
const chessSymbols: Record<string, string> = {
k: '♚',
q: '♛',
r: '♜',
b: '♝',
n: '♞',
p: '♟',
};
const pieceNames: Record<string, string> = {
k: 'rey',
q: 'dama',
r: 'torre',
b: 'alfil',
n: 'caballo',
p: 'peón',
};
const legal = $derived(room.legalActions ?? []);
const active = $derived(
room.gameId === 'damas' &&
room.status === 'playing' &&
room.state &&
room.state.turn === room.seat &&
typeof room.state.chain === 'number'
? room.state.chain
: selected,
);
const feedback = $derived(diceFeedback(room));
const lastRoll = $derived(
room.events?.filter((e) => e.type === 'roll').at(-1)?.sequence ?? 0,
);
const positions = $derived(
Array.from({ length: 64 }, (_, i) => (room.seat === 1 ? 63 - i : i)),
);
const cells = $derived.by(() => {
if (room.gameId === 'ajedrez')
return (
(
room.state?.board as ({
square: string;
type: string;
color: string;
} | null)[][]
)?.flat() ?? []
);
return (room.state?.board as number[]) ?? [];
});
const moves = $derived(legal.filter((a) => a.from === active));
$effect(() => {
room.version;
selected = null;
promotion = [];
});
function cellId(i: number) {
return room.gameId === 'ajedrez'
? `${'abcdefgh'[i % 8]}${8 - Math.floor(i / 8)}`
: i;
}
function select(i: number) {
if (disabled) return;
const id = cellId(i);
const options = moves.filter((a) => a.to === id);
if (options.length > 1) {
promotion = options;
return;
}
if (options.length === 1) {
onaction(options[0]);
if (room.gameId !== 'damas') selected = null;
return;
}
selected = legal.some((a) => a.from === id) ? id : null;
}
function describe(i: number) {
const piece = cells[i];
if (room.gameId === 'ajedrez') {
const p = piece as { type: string; color: string } | null;
return `${cellId(i)}, ${p ? `${pieceNames[p.type]} ${p.color === 'w' ? 'blanco' : 'negro'}` : 'vacía'}`;
}
const p = piece as number;
return `Casilla ${i + 1}, ${p ? `${p < 0 ? 'dama' : 'ficha'} del jugador ${Math.abs(p)}` : 'vacía'}`;
}
</script>
{#if room.gameId === 'parchis'}
<div class="parchis-board">
<div class="board-surface"><ParchisMap {room} {disabled} {onaction} /></div>
<div class="parchis-controls">
<Dice
value={room.state?.rolled}
rollId={lastRoll}
{feedback}
canRoll={legal.some((a) => a.type === 'roll')}
{disabled}
definition={room.definition}
onroll={() => onaction({ type: 'roll' })}
/>
<p class="dice-instruction" aria-live="polite">
{#if lastRoll}<span class="last-roll-caption">{feedback.title}.</span
>{/if}{feedback.next}
</p>
<div class="table-players">
{#each room.players as player}<div
class="table-player {playerColorClass(player)}"
class:playing={room.status === 'playing' &&
room.state?.turn === player.seat}
>
<span class="mini-avatar"><AvatarImage person={player} /></span>
<strong
>{player.nick}{player.seat === room.seat ? ' · tú' : ''}</strong
>
<small
>{#if player.withdrawnAt}Se ha retirado{:else}{room.state?.pieces?.[
player.seat
]?.filter((p) => p === (room.state?.goal ?? 71)).length ?? 0}/4
en meta{/if}</small
>
</div>{/each}
</div>
{#if legal.some((a) => a.type === 'move')}<div class="piece-shortcuts">
<span>Fichas disponibles</span
>{#each legal.filter((a) => a.type === 'move') as move}<button
class="parchis-token"
{disabled}
style:--piece-color={colorForPlayer(
room.players.find((p) => p.seat === room.seat),
).hex}
onclick={() => onaction(move)}
aria-label={`Mover ficha ${move.piece! + 1}`}
>{move.piece! + 1}</button
>{/each}
</div>{/if}
</div>
</div>
{:else if room.gameId === 'go'}
<GoBoard {room} {disabled} {onaction} />
{:else if room.gameId === 'oca'}
<OcaBoard {room} {disabled} {onaction} />
{:else}
<div class="board-container">
<div class="board-grid" aria-label={`Tablero de ${room.definition?.name}`}>
{#each positions as i, visual}<button
class="board-square"
class:dark={(Math.floor(i / 8) + (i % 8)) % 2 === 1}
class:selected={active === cellId(i)}
class:target={moves.some((a) => a.to === cellId(i))}
class:occupied={!!cells[i]}
disabled={disabled ||
(!legal.some((a) => a.from === cellId(i)) &&
!moves.some((a) => a.to === cellId(i)))}
aria-label={describe(i)}
aria-pressed={active === cellId(i)}
onclick={() => select(i)}
>{#if visual % 8 === 0}<span class="coordinate"
>{8 - Math.floor(i / 8)}</span
>{/if}{#if visual >= 56}<span class="coordinate file"
>{'abcdefgh'[i % 8]}</span
>{/if}{#if room.gameId === 'ajedrez'}{@const piece = cells[i] as {
type: string;
color: string;
} | null}{#if piece}<span
class="chess-piece"
class:white={piece.color === 'w'}
aria-hidden="true">{chessSymbols[piece.type]}</span
>{/if}{:else}{@const piece = cells[i] as number}{#if piece}<span
class="checker-piece"
class:light={Math.abs(piece) === 1}
aria-hidden="true">{piece < 0 ? '♔' : ''}</span
>{/if}{/if}</button
>{/each}
</div>
</div>
{#if promotion.length}<div class="promotion">
<p>Elige la pieza para la promoción:</p>
{#each promotion as action}<button
{disabled}
onclick={() => {
onaction(action);
promotion = [];
}}>{pieceNames[action.promotion!]}</button
>{/each}
</div>{/if}
{/if}

@ -0,0 +1,4 @@
<script lang="ts">
import { brand } from '../../../shared/brand.mjs';
</script>
<img src={brand.logo} alt={brand.name} width="897" height="214" class="brand-logo" />

@ -0,0 +1,100 @@
<script lang="ts">
import Icon from './Icon.svelte';
let {
info,
x = 50,
y = 50,
pinned = false,
onclose,
}: {
info: { title: string; kind: string; description: string };
x?: number;
y?: number;
pinned?: boolean;
onclose: () => void;
} = $props();
</script>
<aside
class="cell-info"
class:danger={info.kind === 'danger'}
class:advantage={info.kind === 'advantage'}
style={`--cell-x:${x}%;${y < 50 ? `top:calc(${y}% + 32px)` : `bottom:calc(${100 - y}% + 32px)`}`}
role={pinned ? 'region' : 'tooltip'}
aria-label="Información de la casilla"
>
<div>
<span
>{info.kind === 'danger'
? 'Peligro'
: info.kind === 'advantage'
? 'Ventaja'
: 'En el recorrido'}</span
>
<h3>{info.title}</h3>
</div>
{#if pinned}<button
class="icon-button"
aria-label="Cerrar información de la casilla"
onclick={onclose}><Icon name="close" size={18} /></button
>{/if}
<p>{info.description}</p>
</aside>
<style>
.cell-info {
position: absolute;
left: clamp(8px, calc(var(--cell-x) - 140px), calc(100% - 288px));
width: 280px;
max-width: calc(100% - 16px);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-top: 3px solid var(--accent);
border-radius: 14px;
box-shadow: 0 8px 30px var(--shadow);
padding: 16px;
z-index: 5;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
pointer-events: auto;
}
.cell-info.danger {
border-top-color: #c3403a;
}
.cell-info.advantage {
border-top-color: #308052;
}
.cell-info > div {
flex: 1;
min-width: 0;
}
.cell-info span {
font-size: var(--type-detail);
color: var(--secondary);
}
.cell-info.danger span {
color: var(--danger);
}
.cell-info.advantage span {
color: var(--player-ink, #308052);
}
h3 {
font-size: var(--type-body);
line-height: 1.4;
margin: 4px 0 0;
}
p {
width: 100%;
font-size: var(--type-secondary);
line-height: 1.5;
margin: 0;
color: var(--secondary);
}
.icon-button {
flex-shrink: 0;
margin: -8px -8px 0 0;
}
</style>

@ -0,0 +1,103 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api, definitions } from '../client.svelte';
import type { Room } from '../types';
import Icon from './Icon.svelte';
import Dialog from './Dialog.svelte';
let {
gameId = 'ajedrez',
onclose,
}: { gameId?: string; onclose: () => void } = $props();
let selected = $state('ajedrez'),
name = $state(''),
invitationMessage = $state(''),
visibility = $state('public'),
players = $state(2),
pending = $state(false),
error = $state('');
onMount(() => {
selected = gameId;
});
const game = $derived(definitions.find((g) => g.id === selected)!);
async function create(event: SubmitEvent) {
event.preventDefault();
pending = true;
error = '';
try {
const { room } = await api<{ room: Room }>('/rooms', {
gameId: selected,
name: name.trim() || `Una partida de ${game.name.toLowerCase()}`,
maxPlayers: Math.min(players, game.maxPlayers),
visibility,
invitationMessage,
});
onclose();
await goto(`/salas/${room.id}`);
} catch (e) {
error = (e as Error).message;
} finally {
pending = false;
}
}
</script>
<Dialog label="create-title" {onclose}>
<div class="modal-head">
<div>
<h2 id="create-title">Crea tu próxima partida</h2>
</div>
<button class="icon-button" aria-label="Cerrar" onclick={onclose}
><Icon name="close" /></button
>
</div>
<form onsubmit={create}>
<label
>Elige un juego<select
bind:value={selected}
onchange={() => (players = 2)}
>{#each definitions as definition}<option value={definition.id}
>{definition.name}</option
>{/each}</select
></label
>
<div class="variant-note">
<Icon name="help" size={17} /><span>{game.variant}</span>
</div>
<label
>Nombre de la mesa<input
bind:value={name}
maxlength="60"
placeholder={`Una partida de ${game.name.toLowerCase()}`}
/></label
>
<div class="form-row">
<label
>Jugadores<select bind:value={players}
>{#each Array(game.maxPlayers - 1) as _, i}<option value={i + 2}
>{i + 2} jugadores</option
>{/each}</select
></label
><label
>Visibilidad<select bind:value={visibility}
><option value="public">Pública</option><option value="private"
>Privada · por código</option
></select
></label
>
</div>
<p class="muted small">
La partida comienza cuando todas las plazas estén ocupadas y los jugadores
indiquen que están listos.
</p>
<label>Un mensaje para tus invitados<textarea bind:value={invitationMessage} maxlength="300" rows="3" placeholder="¿Nos echamos una partida? Te espero en la mesa."></textarea></label>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
<button class="button primary full" disabled={pending}
>{pending ? 'Preparando la mesa…' : 'Crear mesa'}<Icon
name="arrow"
size={18}
/></button
>
</form>
</Dialog>

@ -0,0 +1,25 @@
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
let {
label,
onclose,
children,
className='',
}: { label: string; onclose: () => void; children: Snippet;className?:string } = $props();
let dialog = $state<HTMLDialogElement>();
onMount(() => {
dialog?.showModal();
const heading=dialog?.querySelector<HTMLElement>('h2');
heading?.setAttribute('tabindex','-1');
heading?.focus();
});
</script>
<dialog
class="modal {className}"
bind:this={dialog}
aria-labelledby={label}
oncancel={onclose}
>
{@render children()}
</dialog>

@ -0,0 +1,591 @@
<script lang="ts">
import { onDestroy, untrack } from 'svelte';
import type { Definition } from '../types';
import Dialog from './Dialog.svelte';
import Icon from './Icon.svelte';
import { unlockSound, playSound, announceDice } from '../sound.svelte';
import type { diceFeedback } from '../dice-feedback';
let {
value = null,
rollId = '',
canRoll = false,
disabled = false,
onroll,
definition,
feedback,
onresultclose,
}: {
value?: number | null;
rollId?: string | number;
canRoll?: boolean;
disabled?: boolean;
onroll: () => void;
definition?: Definition;
feedback: ReturnType<typeof diceFeedback>;
onresultclose?: (rollId: string | number) => void;
} = $props();
let rolling = $state(false),
previous: string | number | undefined,
timer: ReturnType<typeof setTimeout> | undefined;
let overlay = $state(false),
phase = $state<'ready' | 'shaking' | 'waiting' | 'dropping' | 'result'>(
'ready',
),
slow = $state(false);
let shakeTimer: ReturnType<typeof setInterval> | undefined,
waitTimer: ReturnType<typeof setTimeout> | undefined;
let notice = $state(false),
noticeTimer: ReturnType<typeof setTimeout> | undefined;
const duration = $derived(definition?.animations?.roll?.duration ?? 800);
const dots = [
[4],
[0, 8],
[0, 4, 8],
[0, 2, 6, 8],
[0, 2, 4, 6, 8],
[0, 2, 3, 5, 6, 8],
];
const angles = [
[0, 0],
[0, -90],
[-90, 0],
[90, 0],
[0, 90],
[0, 180],
];
const rotation = $derived(angles[(value ?? 1) - 1]);
let resultValue = $state<number | null>(null),
resultFeedback = $state(untrack(() => feedback)),
resultRollId: string | number = '';
function reveal() {
if (
feedback.createdAt &&
Date.now() - Date.parse(feedback.createdAt) > 5000
)
return;
// A later rival throw must not dismiss a result the player is still reading.
if (
overlay &&
!feedback.own &&
(phase === 'dropping' || phase === 'result')
)
return;
clearInterval(shakeTimer);
clearTimeout(timer);
clearTimeout(waitTimer);
clearTimeout(noticeTimer);
rolling = true;
overlay = feedback.own;
if (feedback.own) {
resultValue = value;
resultFeedback = feedback;
resultRollId = rollId;
}
notice = !feedback.own;
phase = 'dropping';
slow = false;
const revealedValue = value;
timer = setTimeout(() => {
rolling = false;
phase = 'result';
if (revealedValue) announceDice(definition, revealedValue);
}, duration);
if (notice)
noticeTimer = setTimeout(() => (notice = false), duration + 3500);
}
$effect(() => {
const id = rollId;
if (previous !== undefined && previous !== id) untrack(reveal);
previous = id;
});
function shake() {
if (phase !== 'ready' || !canRoll || disabled) return;
phase = 'shaking';
void unlockSound().then(() => playSound(definition, 'shake'));
shakeTimer = setInterval(() => void playSound(definition, 'shake'), 550);
}
function release() {
if (phase !== 'shaking') return;
if (!canRoll || disabled) {
cancelShake();
return;
}
clearInterval(shakeTimer);
phase = 'waiting';
slow = false;
onroll();
waitTimer = setTimeout(() => (slow = true), 8000);
}
function close() {
const finishedResult =
overlay && (phase === 'dropping' || phase === 'result');
clearInterval(shakeTimer);
clearTimeout(timer);
clearTimeout(waitTimer);
overlay = false;
rolling = false;
phase = 'ready';
if (finishedResult) onresultclose?.(resultRollId);
}
function cancelShake() {
clearInterval(shakeTimer);
if (phase === 'shaking') phase = 'ready';
}
onDestroy(() => {
clearInterval(shakeTimer);
clearTimeout(timer);
clearTimeout(waitTimer);
clearTimeout(noticeTimer);
});
</script>
{#snippet figure(large = false)}
{@const shownValue = large ? resultValue : value}
{@const shownRotation = large ? angles[(shownValue ?? 1) - 1] : rotation}
<div
class="dice-stage"
class:large
class:rolling
style:--roll-duration={`${duration}ms`}
style:--rx={`${shownRotation[0]}deg`}
style:--ry={`${shownRotation[1]}deg`}
role="img"
aria-label={rolling
? 'Lanzando el dado'
: shownValue
? `Resultado del dado: ${shownValue}`
: 'Dado listo para lanzar'}
>
<div class="dice-cube">
{#each dots as face, i}<div class="dice-face face-{i + 1}">
{#each Array(9) as _, dot}<i class:visible={face.includes(dot)}
></i>{/each}
</div>{/each}
</div>
<span class="dice-shadow"></span>
</div>
{/snippet}
<div class="dice-widget">
<div class="dice-result">
{@render figure()}<small
>{value ? feedback.title : 'Sin tiradas todavía'}</small
>
</div>
{#if canRoll}<button
class="button primary"
disabled={disabled || rolling}
onclick={() => {
phase = 'ready';
overlay = true;
}}>Lanzar el dado</button
>{/if}
</div>
{#if overlay}<Dialog
label="dice-title"
onclose={close}
className="dice-overlay"
>
<button
class="icon-button close-dice"
aria-label="Cerrar tirada"
onclick={close}><Icon name="close" /></button
>
<h2 id="dice-title">
{phase === 'ready'
? 'Lanza el dado'
: phase === 'shaking'
? 'Agita… y suelta'
: phase === 'result'
? resultFeedback.title
: 'Tu dado está en el aire'}
</h2>
<div
class="throw-scene"
style:--roll-duration={`${duration}ms`}
style:--shake-duration={`${definition?.animations?.shake?.duration ?? 180}ms`}
class:shaking={phase === 'shaking'}
class:dropping={phase === 'dropping'}
>
{#if phase === 'ready' || phase === 'shaking' || phase === 'waiting'}<div
class="dice-cup"
>
<span></span><i></i><b>✦</b>
</div>
<span class="cup-shadow"></span>
{:else}{@render figure(true)}{/if}
</div>
<div class="dice-announcement" aria-live="polite">
{#if phase === 'result'}<strong>{resultValue}</strong>
<p>
{onresultclose
? 'Cierra el dado para ver avanzar tu ficha.'
: resultFeedback.next}
</p>{:else if phase === 'waiting'}<p>
{slow
? 'La confirmación está tardando. Puedes cerrar y comprobar la conexión.'
: 'Confirmando tu tirada…'}
</p>{:else if phase === 'ready' || phase === 'shaking'}<p>
Mantén pulsado para agitar el cubilete.<br />Suéltalo para lanzar el
dado.
</p>{/if}
</div>
{#if phase === 'ready' || phase === 'shaking'}<button
class="button primary shake-button"
onpointerdown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
shake();
}}
onpointerup={release}
onpointercancel={cancelShake}
onkeydown={(e) => {
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) {
e.preventDefault();
shake();
}
}}
onkeyup={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
release();
}
}}
onblur={cancelShake}
>{phase === 'shaking'
? 'Suelta para lanzar'
: 'Agitar cubilete'}</button
>
{:else if phase === 'result'}<button
class="button secondary"
onclick={close}>Volver al tablero<Icon name="arrow" size={17} /></button
>{/if}
</Dialog>{/if}
{#if notice}<aside class="dice-notice" role="status" aria-live="polite">
{@render figure()}
<div>
<strong>{feedback.title}</strong>
<p>{feedback.next}</p>
</div>
<button
class="icon-button"
aria-label="Cerrar aviso de tirada"
onclick={() => (notice = false)}><Icon name="close" size={16} /></button
>
</aside>{/if}
<style>
.dice-widget {
display: flex;
align-items: center;
gap: 24px;
}
.dice-result {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.dice-result small {
color: var(--secondary);
font-size: var(--type-secondary, 14px);
line-height: 1.4;
max-width: 160px;
text-align: center;
overflow-wrap: anywhere;
}
.dice-stage {
width: 82px;
height: 90px;
position: relative;
perspective: 500px;
display: grid;
place-items: center;
}
.dice-cube {
width: var(--edge, 52px);
height: var(--edge, 52px);
position: relative;
transform-style: preserve-3d;
transform: rotateX(-58deg) rotateY(-22deg) rotateX(calc(90deg + var(--rx)))
rotateY(var(--ry));
transition: transform 0.25s ease-out;
z-index: 1;
}
.dice-face {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
padding: 9px;
gap: 3px;
background: #fffdf7;
border: 1px solid #cfd7e3;
border-radius: 9px;
box-shadow: inset 0 0 10px #8b9bb425;
backface-visibility: hidden;
}
i {
border-radius: 50%;
background: #1c2c47;
opacity: 0;
box-shadow: inset 0 1px 2px #0005;
}
i.visible {
opacity: 1;
}
.face-1 {
transform: translateZ(var(--depth, 26px));
}
.face-2 {
transform: rotateY(90deg) translateZ(var(--depth, 26px));
}
.face-3 {
transform: rotateX(90deg) translateZ(var(--depth, 26px));
}
.face-4 {
transform: rotateX(-90deg) translateZ(var(--depth, 26px));
}
.face-5 {
transform: rotateY(-90deg) translateZ(var(--depth, 26px));
}
.face-6 {
transform: rotateY(180deg) translateZ(var(--depth, 26px));
}
.dice-shadow {
position: absolute;
bottom: 4px;
width: 65px;
height: 12px;
border-radius: 50%;
background: #10274a24;
filter: blur(3px);
}
.rolling .dice-cube {
animation: tumble var(--roll-duration, 800ms) ease-out;
}
.rolling .dice-shadow {
animation: shadow var(--roll-duration, 800ms) ease-out;
}
@keyframes tumble {
0% {
transform: translateY(0) rotateX(0) rotateY(0);
}
25% {
transform: translateY(-27px) rotateX(170deg) rotateY(100deg);
}
60% {
transform: translateY(-12px) rotateX(410deg) rotateY(280deg);
}
85% {
transform: translateY(4px) rotateX(730deg) rotateY(520deg);
}
100% {
transform: rotateX(-58deg) rotateY(-22deg)
rotateX(calc(90deg + var(--rx))) rotateY(var(--ry));
}
}
@keyframes shadow {
25% {
transform: scale(0.65);
opacity: 0.5;
}
85% {
transform: scale(1.15);
}
100% {
transform: scale(1);
}
}
.large {
--edge: 104px;
--depth: 52px;
width: 200px;
height: 210px;
}
.large .dice-face {
padding: 19px;
gap: 7px;
border-radius: 15px;
}
.large .dice-shadow {
width: 132px;
height: 22px;
bottom: 12px;
}
.throw-scene {
height: 245px;
display: grid;
place-items: center;
position: relative;
margin: 15px auto 0;
perspective: 600px;
}
.dice-cup {
width: 135px;
height: 158px;
background: linear-gradient(100deg, #0a47a8, #2372e5 50%, #0d4cab);
border-radius: 20px 20px 35px 35px;
position: relative;
box-shadow:
inset -10px 0 22px #002b6d55,
6px 14px 24px #16326330;
transform: rotate(-12deg);
}
.dice-cup > span {
position: absolute;
top: -13px;
left: 0;
width: 135px;
height: 38px;
border-radius: 50%;
background: #143e77;
border: 8px solid #4c8ced;
box-shadow: inset 0 8px 10px #0005;
}
.dice-cup i {
opacity: 1;
position: absolute;
inset: 40px 18px 20px;
border: 2px solid #ffffff25;
border-radius: 15px;
background: transparent;
box-shadow: none;
}
.dice-cup b {
position: absolute;
inset: 52px 0 0;
color: #ffce36;
font-size: 48px;
line-height: 1;
text-align: center;
}
.cup-shadow {
position: absolute;
bottom: 27px;
width: 145px;
height: 23px;
border-radius: 50%;
background: #10274a22;
filter: blur(6px);
z-index: -1;
}
.shaking .dice-cup {
animation: shake-cup var(--shake-duration, 180ms) ease-in-out infinite
alternate;
}
.dropping .large {
animation: drop-die var(--roll-duration, 800ms) ease-out;
}
.dice-announcement {
min-height: 95px;
}
.dice-announcement > strong {
display: block;
font:
800 52px 'Manrope',
sans-serif;
color: var(--accent-text);
line-height: 1.2;
}
.dice-announcement p {
font-size: 14px;
color: var(--secondary);
}
.shake-button {
touch-action: none;
user-select: none;
min-width: 210px;
}
.close-dice {
position: absolute;
right: 18px;
top: 16px;
}
.dice-notice {
position: fixed;
right: 22px;
bottom: 22px;
z-index: 30;
display: flex;
align-items: center;
gap: 14px;
width: 390px;
max-width: calc(100vw - 24px);
padding: 12px 18px;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface);
color: var(--text);
box-shadow: 0 12px 50px var(--shadow);
animation: notice-enter 0.2s ease-out;
pointer-events: none;
}
.dice-notice > div {
min-width: 0;
}
.dice-notice strong {
font-size: var(--type-body, 16px);
}
.dice-notice p {
font-size: var(--type-secondary, 14px);
line-height: 1.5;
margin-top: 5px;
color: var(--secondary);
}
.dice-notice button {
pointer-events: auto;
flex-shrink: 0;
}
.dice-notice .dice-stage {
flex-shrink: 0;
width: 64px;
}
@keyframes notice-enter {
from {
transform: translateY(12px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes shake-cup {
from {
transform: translate(-15px, -4px) rotate(-24deg);
}
to {
transform: translate(15px, -12px) rotate(12deg);
}
}
@keyframes drop-die {
0% {
transform: translateY(-100px) scale(0.4);
opacity: 0.2;
}
65% {
transform: translateY(12px) scale(1.08);
}
100% {
transform: translateY(0) scale(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.rolling .dice-cube,
.rolling .dice-shadow {
animation: none;
}
.dice-cube {
transition: none;
}
}
@media (max-width: 500px) {
.dice-widget {
gap: 12px;
}
.dice-result small {
max-width: 90px;
}
}
</style>

@ -0,0 +1,323 @@
<script lang="ts">
let { game, large = false }: { game: string; large?: boolean } = $props();
</script>
<div class:large class="game-art {game}" aria-hidden="true">
{#if game === 'ajedrez'}
<div class="mini-board">
{#each Array(16) as _, i}<span
class:dark={(Math.floor(i / 4) + (i % 4)) % 2 === 1}
>{#if i === 5}<b class="white-piece">♘</b>{:else if i === 10}<b>♟</b
>{:else if i === 2}<b>♜</b>{/if}</span
>{/each}
</div>
<span class="loose-piece">♔</span>
{:else if game === 'damas'}
<div class="mini-board">
{#each Array(16) as _, i}<span
class:dark={(Math.floor(i / 4) + (i % 4)) % 2 === 1}
>{#if [3, 6, 9].includes(i)}<i class="checker light"
></i>{:else if [1, 14].includes(i)}<i class="checker"
></i>{/if}</span
>{/each}
</div>
<i class="loose-checker"></i>
{:else if game === 'go'}
<svg class="mini-go" viewBox="0 0 180 180"
><rect
width="180"
height="180"
rx="12"
fill="#e4bd7c"
/>{#each Array(5) as _, n}<path
d={`M20 ${20 + n * 35}H160 M${20 + n * 35} 20V160`}
stroke="#866334"
stroke-width="1.2"
/>{/each}{#each [[55, 55, 1], [90, 55, 2], [55, 90, 2], [90, 90, 1], [125, 125, 1]] as stone}<circle
cx={stone[0] + 2}
cy={stone[1] + 3}
r="15"
fill="#55371133"
/><circle
cx={stone[0]}
cy={stone[1]}
r="15"
fill={stone[2] === 1 ? '#293039' : '#f9fafb'}
stroke={stone[2] === 1 ? '#10171e' : '#b6b4b0'}
/>{/each}</svg
>
{:else if game === 'oca'}
<svg class="mini-oca" viewBox="0 0 180 180"
><rect
width="180"
height="180"
rx="12"
fill="#f8f1df"
/>{#each Array(16) as _, n}<rect
x={(n % 4) * 42 + 7}
y={Math.floor(n / 4) * 42 + 7}
width="39"
height="39"
rx="5"
fill={n % 4 === 0 ? '#e6efca' : '#f2d7aa'}
stroke="#c8bd9b"
/><text
x={(n % 4) * 42 + 16}
y={Math.floor(n / 4) * 42 + 23}
font-size="10"
fill="#806d47">{n + 1}</text
>{/each}<path
d="M62 104C54 79 83 71 96 84L97 56C98 35 121 33 126 46L125 61L139 67L123 72L121 99C119 127 67 132 62 104Z"
fill="#fffef5"
stroke="#659551"
stroke-width="3"
/><circle cx="118" cy="49" r="3" fill="#354829" /><path
d="M73 91Q78 113 98 102"
stroke="#8cac78"
stroke-width="3"
fill="none"
/></svg
>
{:else}
<div class="parchis-art">
<div class="home yellow"><i></i><i></i><i></i><i></i></div>
<div class="home blue"><i></i><i></i><i></i><i></i></div>
<div class="home red"><i></i><i></i><i></i><i></i></div>
<div class="home green"><i></i><i></i><i></i><i></i></div>
<div class="center-diamond"></div>
</div>
<div class="die"><i></i><i></i><i></i><i></i><i></i></div>
{/if}
</div>
<style>
.game-art {
height: 190px;
position: relative;
display: grid;
place-items: center;
overflow: hidden;
background: #e8eee6;
}
.game-art::after {
content: '';
position: absolute;
inset: 0;
background-image: radial-gradient(#223f3012 0.8px, transparent 0.8px);
background-size: 10px 10px;
pointer-events: none;
}
.game-art.damas {
background: #f0e6d6;
}
.game-art.parchis {
background: #e9e5f2;
}
.game-art.go {
background: #ede4d5;
}
.game-art.oca {
background: #e5efdb;
}
.mini-go,
.mini-oca {
width: 145px;
height: 145px;
transform: rotate(-12deg);
box-shadow:
5px 10px #c8bd9c,
9px 20px 25px #51452c26;
z-index: 1;
border-radius: 12px;
}
.mini-board {
display: grid;
grid-template-columns: repeat(4, 1fr);
width: 142px;
height: 142px;
transform: rotate(-13deg) skew(-5deg, 3deg);
border: 7px solid #ece3d0;
border-radius: 5px;
box-shadow:
8px 15px 0 #d5cbb9,
12px 28px 30px #29442b26;
z-index: 1;
}
.mini-board span {
background: #e7eadc;
display: grid;
place-items: center;
position: relative;
}
.mini-board span.dark {
background: #748e75;
}
.mini-board b {
font-size: 42px;
font-family: Georgia, serif;
font-weight: normal;
line-height: 1;
color: #273a2d;
filter: drop-shadow(2px 3px 1px #0003);
}
.mini-board b.white-piece {
color: #fffef6;
text-shadow: 1px 1px #85977c;
}
.loose-piece {
font-family: Georgia, serif;
position: absolute;
bottom: 8px;
right: 22%;
font-size: 84px;
color: #fffef5;
text-shadow: 1px 1px #8e9f83;
filter: drop-shadow(4px 7px 2px #273b3230);
transform: rotate(8deg);
z-index: 2;
}
.damas .mini-board span {
background: #f5e7cd;
}
.damas .mini-board span.dark {
background: #9c7852;
}
.checker {
width: 29px;
height: 29px;
background: #3b332d;
border-radius: 50%;
border: 3px double #897261;
box-shadow: 0 4px 0 #28241f;
display: block;
}
.checker.light {
background: #d77b57;
border-color: #eeab80;
box-shadow: 0 4px 0 #975337;
}
.loose-checker {
position: absolute;
width: 48px;
height: 48px;
bottom: 27px;
right: 21%;
background: #d77b57;
border-radius: 50%;
border: 5px double #eeab80;
box-shadow:
0 7px 0 #975337,
4px 15px 15px #6b432b35;
z-index: 2;
}
.parchis-art {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
width: 145px;
height: 145px;
padding: 12px;
background: #f9f7f0;
transform: rotate(-12deg);
border-radius: 8px;
box-shadow:
5px 10px 0 #d2cddd,
9px 20px 25px #51457225;
position: relative;
z-index: 1;
}
.home {
border-radius: 5px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 8px;
background: var(--color);
}
.home i {
border-radius: 50%;
background: #ffffffb5;
box-shadow: 1px 2px #0002;
}
.yellow {
--color: #e4bd57;
}
.blue {
--color: #6c9cbe;
}
.red {
--color: #c96b67;
}
.green {
--color: #6b9c7b;
}
.center-diamond {
width: 20px;
height: 20px;
background: #f9f7f0;
transform: rotate(45deg);
position: absolute;
left: 62px;
top: 62px;
border: 1px solid #e4ded8;
}
.die {
position: absolute;
right: 21%;
bottom: 21px;
width: 45px;
height: 45px;
background: #fffdf7;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
padding: 8px;
border-radius: 8px;
box-shadow:
2px 4px #c9c1d3,
4px 10px 14px #40344d22;
transform: rotate(12deg);
z-index: 2;
}
.die i {
background: #615177;
border-radius: 50%;
}
.die i:nth-child(2) {
grid-column: 3;
}
.die i:nth-child(3) {
grid-column: 2;
}
.die i:nth-child(4) {
grid-column: 1;
}
.die i:nth-child(5) {
grid-column: 3;
}
.large {
height: 100%;
background: transparent !important;
}
.large .mini-board {
width: 224px;
height: 224px;
border-width: 10px;
}
.large .mini-board b {
font-size: 65px;
}
.large .loose-piece {
font-size: 125px;
bottom: 4px;
right: 8%;
}
.large::after {
display: none;
}
@media (max-width: 600px) {
.game-art {
height: 165px;
}
}
</style>

@ -0,0 +1,244 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import type { Room, Action } from '../types';
let {
room,
disabled = false,
onaction,
}: {
room: Room;
disabled?: boolean;
onaction: (action: Action) => void;
} = $props();
const grid = $derived(room.state?.grid ?? []);
const scoring = $derived(room.state?.phase === 'scoring');
const points = $derived(
grid.map((color, index) => ({ color, index })).filter((p) => p.color),
);
const legal = $derived(room.legalActions ?? []);
function play(to: number) {
const action = legal.find((a) => a.to === to);
if (action && !disabled) onaction(action);
}
</script>
<div class="table-game go-table" class:scoring>
<div class="board-surface">
<svg
class="game-map go-map"
viewBox="0 0 560 560"
role="group"
aria-label="Tablero de Go, nueve por nueve intersecciones"
>
<defs
><radialGradient id="black-stone"
><stop stop-color="#62636a" /><stop
offset="1"
stop-color="#16191d"
/></radialGradient
><radialGradient id="white-stone"
><stop stop-color="#fff" /><stop
offset="1"
stop-color="#d5d8de"
/></radialGradient
></defs
>
<rect
x="4"
y="4"
width="552"
height="552"
rx="20"
fill="#e5be7e"
stroke="#ba8b49"
stroke-width="3"
/>
{#each Array(9) as _, n}
<path
d={`M40 ${40 + n * 60}H520 M${40 + n * 60} 40V520`}
fill="none"
stroke="#624c2f"
stroke-width="1.4"
/>
<text
x={40 + n * 60}
y="548"
text-anchor="middle"
font-size="13"
fill="#614626">{'ABCDEFGHJ'[n]}</text
>
<text
x="20"
y={45 + n * 60}
text-anchor="middle"
font-size="13"
fill="#614626">{9 - n}</text
>
{/each}
{#each [[2, 2], [2, 6], [4, 4], [6, 2], [6, 6]] as [x, y]}<circle
cx={40 + x * 60}
cy={40 + y * 60}
r="4"
fill="#624c2f"
/>{/each}
{#each points as point (point.index)}
<g
transition:fade={{
duration: room.definition?.animations?.move?.duration ?? 240,
}}
opacity={room.state?.dead?.includes(point.index) ? 0.3 : 1}
>
<circle
cx={42 + (point.index % 9) * 60}
cy={43 + Math.floor(point.index / 9) * 60}
r="26"
fill="#513b2740"
/>
<circle
cx={40 + (point.index % 9) * 60}
cy={40 + Math.floor(point.index / 9) * 60}
r="26"
fill={point.color === 1 ? 'url(#black-stone)' : 'url(#white-stone)'}
stroke={point.color === 1 ? '#181b20' : '#abb0b8'}
stroke-width="1"
/>
</g>
{/each}
{#each grid as color, to}
{@const allowed = !disabled && legal.some((a) => a.to === to)}
<g
role="button"
tabindex={allowed ? 0 : -1}
aria-disabled={!allowed}
aria-label={`${'ABCDEFGHJ'[to % 9]}${9 - Math.floor(to / 9)}, ${color ? (color === 1 ? 'piedra negra' : 'piedra blanca') : 'vacía'}${scoring && color ? ', marcar grupo muerto o vivo' : ''}`}
class:allowed
onclick={() => play(to)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
play(to);
}
}}
>
<circle
cx={40 + (to % 9) * 60}
cy={40 + Math.floor(to / 9) * 60}
r="29"
fill="transparent"
class="intersection"
/>
{#if room.state?.dead?.includes(to)}<text
x={40 + (to % 9) * 60}
y={47 + Math.floor(to / 9) * 60}
text-anchor="middle"
font-size="24"
fill="#ac2525"
pointer-events="none">×</text
>{/if}
</g>
{/each}
</svg>
</div>
<div class="game-controls">
<span class="eyebrow"
>{scoring
? 'RECUENTO'
: room.status === 'finished'
? 'RESULTADO'
: room.state?.turn === room.seat
? 'TU JUGADA'
: 'TURNO DEL RIVAL'}</span
>
<p>
{scoring
? 'Marcáis los grupos muertos y aceptáis ambos el resultado. Podéis reanudar si hay desacuerdo.'
: room.status === 'finished'
? 'Partida terminada.'
: room.state?.turn === room.seat
? 'Coloca una piedra en una intersección vacía o pasa.'
: `Espera la piedra de ${room.players[room.state?.turn ?? 0].nick}.`}
</p>
<div class="go-scores">
{#each room.players as player}<div>
<i class:white={player.seat === 1}></i><strong title={player.nick}
>{player.nick}{player.seat === room.seat ? ' · tú' : ''}</strong
><small
>{scoring || room.status === 'finished'
? `${room.state?.scores?.[player.seat] ?? 0} puntos`
: `${room.state?.captures?.[player.seat] ?? 0} capturas`}{room
.state?.accepted?.[player.seat]
? ' · ✓ aceptado'
: ''}</small
>
</div>{/each}
</div>
<small class="muted">Blancas: komi de 6,5 puntos.</small>
{#if legal.some((a) => a.type === 'pass')}<button
class="button secondary"
{disabled}
onclick={() => onaction({ type: 'pass' })}>Pasar</button
>{/if}
{#if scoring && room.status === 'playing'}<button
class="button primary"
disabled={disabled || room.state?.accepted?.[room.seat ?? 0]}
onclick={() => onaction({ type: 'accept-score' })}
>{room.state?.accepted?.[room.seat ?? 0]
? 'Esperando aceptación'
: 'Aceptar puntuación'}</button
><button
class="button secondary"
{disabled}
onclick={() => onaction({ type: 'resume' })}>Reanudar partida</button
>{/if}
</div>
</div>
<style>
.allowed {
cursor: pointer;
outline: none;
}
.go-map g[role='button'] { outline:none; }
.allowed:hover .intersection,
.allowed:focus-visible .intersection {
fill: #ffffff33;
stroke: #0759d9;
stroke-width: 3;
}
.go-scores {
display: flex;
flex-direction: column;
gap: 14px;
}
.go-scores > div {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
gap: 3px 8px;
align-items: center;
}
.go-scores i {
width: 20px;
height: 20px;
border-radius: 50%;
background: #20252e;
border: 1px solid #858b96;
grid-row: span 2;
}
.go-scores i.white {
background: #f5f6f8;
}
.go-scores strong {
font-size: var(--type-secondary,14px);
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.go-scores small {
font-size: var(--type-detail,12px);
color: var(--secondary);
}
@media(max-width:800px) and (orientation:portrait) {
.go-scores { flex-direction:row; gap:12px; }
.go-scores>div { flex:1; min-width:0; }
}
</style>

@ -0,0 +1,20 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { api, signedIn } from '../client.svelte';
import type { User } from '../types';
import Dialog from './Dialog.svelte';
import Icon from './Icon.svelte';
let {onclose,onready}:{onclose:()=>void;onready:()=>void}=$props();
let name=$state(''), pending=$state(false), error=$state('');
async function enter(event:SubmitEvent){
event.preventDefault();pending=true;error='';
try{const {user}=await api<{user:User}>('/auth/guest',{name});signedIn(user);onready();}
catch(e){error=(e as Error).message;}finally{pending=false;}
}
</script>
<Dialog label="guest-title" {onclose}>
<div class="modal-head"><div><span class="eyebrow">JUEGA EN COMPAÑÍA</span><h2 id="guest-title">¿Cómo te llamas?</h2></div><button class="icon-button" aria-label="Cerrar" onclick={onclose}><Icon name="close"/></button></div>
<p class="muted small">Puedes jugar como invitado. Tu acceso quedará en este navegador y podrás registrar esta cuenta después.</p>
<form onsubmit={enter}><label>Nombre al jugar<input bind:value={name} required minlength="2" maxlength="30" autocomplete="nickname" placeholder="Tu nombre"/></label>{#if error}<p class="form-error" role="alert">{error}</p>{/if}<button class="button primary full" disabled={pending}>{pending?'Preparando tu sitio…':'Entrar como invitado'}<Icon name="arrow" size={17}/></button></form>
<button class="text-button" onclick={()=>{onclose();goto('/cuenta');}}>Prefiero registrarme</button>
</Dialog>

@ -0,0 +1,49 @@
<script lang="ts">
let { name, size = 20 }: { name: string; size?: number } = $props();
const paths: Record<string, string> = {
menu: 'M4 6h16 M4 12h16 M4 18h16',
refresh:
'M20 7v-4l-3 3a8 8 0 0 0-13 6 M4 17v4l3-3a8 8 0 0 0 13-6 M20 3h-4 M4 21h4',
chat: 'M21 11a8 8 0 0 1-8 8H6l-4 3V11a8 8 0 0 1 8-8h3a8 8 0 0 1 8 8z M7 10h9 M7 14h6',
'chat-new':
'M21 11a8 8 0 0 1-8 8H6l-4 3V11a8 8 0 0 1 8-8h3 M7 10h7 M7 14h6 M22 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0z',
expand: 'M8 3H3v5 M16 3h5v5 M3 16v5h5 M21 16v5h-5',
grid: 'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',
trophy:
'M8 3h8v8a4 4 0 0 1-8 0V3z M8 5H4v3a4 4 0 0 0 4 4 M16 5h4v3a4 4 0 0 1-4 4 M12 15v5 M8 21h8',
user: 'M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0z M4 21v-2a8 8 0 0 1 16 0v2',
help: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z M9 8a3 3 0 0 1 6 0c0 2-3 2-3 5 M12 17h.01',
search: 'M20 20l-5-5 M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0z',
plus: 'M12 5v14 M5 12h14',
arrow: 'M5 12h14 M13 6l6 6-6 6',
clock: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z M12 6v6l4 2',
people:
'M15 8a4 4 0 1 1-8 0 4 4 0 0 1 8 0z M2 21v-2a7 7 0 0 1 14 0v2 M17 4a4 4 0 0 1 0 8 M18 15a6 6 0 0 1 4 6',
chevron: 'M9 5l7 7-7 7',
close: 'M6 6l12 12 M18 6L6 18',
lock: 'M5 10h14v11H5z M8 10V7a4 4 0 0 1 8 0v3',
send: 'M22 2L9 15 M22 2l-7 20-6-7-7-6 20-7z',
logout: 'M9 4H4v16h5 M10 12h11 M17 8l4 4-4 4',
copy: 'M9 9h12v12H9z M5 15H3V3h12v2',
check: 'M5 12l4 4L19 6',
flag: 'M5 21V3 M5 3h13l-3 5 3 5H5',
spark: 'M12 2l3 7 7 3-7 3-3 7-3-7-7-3 7-3z',
volume: 'M3 9h4l5-4v14l-5-4H3z M16 8a5 5 0 0 1 0 8 M19 5a9 9 0 0 1 0 14',
mute: 'M3 9h4l5-4v14l-5-4H3z M17 9l5 6 M22 9l-5 6',
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M12 2v2 M12 20v2 M2 12h2 M20 12h2 M5 5l1.5 1.5 M17.5 17.5L19 19 M5 19l1.5-1.5 M17.5 6.5L19 5',
moon: 'M20.8 13a9 9 0 1 1-9.8-9.8A7 7 0 0 0 20.8 13z',
monitor: 'M3 4h18v13H3z M12 17v4 M8 21h8',
};
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"><path d={paths[name] || paths.grid} /></svg
>

@ -0,0 +1,239 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api, session, signedIn } from '../client.svelte';
import type { User, Room, Definition } from '../types';
import Dialog from './Dialog.svelte';
import GameArt from './GameArt.svelte';
import Icon from './Icon.svelte';
import { invitationUrl } from '../../../shared/invitations.mjs';
let { code, onclose }: { code: string; onclose: () => void } = $props();
type Invite = {
code: string;
name: string;
host: string;
message: string;
definition: Definition;
available: boolean;
isHost: boolean;
alreadyJoined: boolean;
players: number;
maxPlayers: number;
};
let invitation = $state<Invite | null>(null),
loading = $state(true),
error = $state(''),
step = $state('offer'),
name = $state(''),
pending = $state(false),
copied = $state(false);
onMount(() => {
api<{ invitation: Invite }>(`/invites/${encodeURIComponent(code)}`)
.then((result) => {
invitation = result.invitation;
})
.catch((e) => (error = e.message))
.finally(() => (loading = false));
});
async function enter(guest = false) {
pending = true;
error = '';
try {
if (guest) {
const { user } = await api<{ user: User }>('/auth/guest', {
name,
code,
});
signedIn(user);
}
const { room } = await api<{ room: Room }>('/rooms/join', { code });
onclose();
await goto(`/salas/${room.id}`);
} catch (e) {
error = (e as Error).message;
} finally {
pending = false;
}
}
async function share() {
try {
await navigator.clipboard.writeText(
invitationUrl(code, location.origin, import.meta.env.PROD),
);
copied = true;
} catch {
error = `Comparte este código: ${code}`;
}
}
</script>
<Dialog label="invite-title" className="invitation-modal" {onclose}>
<div class="modal-head">
<h2 id="invite-title">
{invitation?.isHost
? 'Tu invitación está lista'
: 'Te han reservado un sitio'}
</h2>
<button class="icon-button" aria-label="Cerrar invitación" onclick={onclose}
><Icon name="close" /></button
>
</div>
{#if loading}<p class="muted">Abriendo la invitación…</p>{:else if invitation}
<div class="invitation-game">
<div class="invitation-art">
<GameArt game={invitation.definition.id} />
</div>
<div class="invitation-details">
<strong>{invitation.name}</strong><span
>{invitation.definition.name} · {invitation.players}/{invitation.maxPlayers}
jugadores</span
><span>Te invita {invitation.host}</span>
</div>
</div>
{#if invitation.message}<blockquote class="invitation-message">
{invitation.message}
</blockquote>{/if}
{#if invitation.isHost}<p class="invitation-note">
Comparte este enlace con quien quieras invitar a la partida.
</p>
<button class="button primary full" onclick={share}
><Icon name={copied ? 'check' : 'copy'} size={18} />{copied
? 'Enlace copiado'
: 'Copiar invitación'}</button
>
{:else if !invitation.available && !invitation.alreadyJoined}<p
class="form-error"
>
Esta partida ya ha empezado o no quedan plazas.
</p>
{:else if step === 'offer'}<div class="form-row">
<button class="button secondary" onclick={onclose}>Ahora no</button
><button class="button primary" onclick={() => (step = 'identity')}
>Aceptar invitación<Icon name="arrow" size={17} /></button
>
</div>
{:else if session.user}<p class="muted small">
Entrarás como {session.user.nick}{session.user.guest
? ' · invitado'
: ''}.
</p>
<button
class="button primary full"
disabled={pending}
onclick={() => enter()}
>{pending ? 'Entrando…' : 'Entrar en la partida'}</button
>
{:else if step === 'identity'}<h3>Elige cómo entrar</h3>
<p class="muted small">
El registro es opcional. Puedes crear una cuenta después.
</p>
<button class="button primary full" onclick={() => (step = 'guest')}
>Entrar como invitado</button
><a
class="button secondary full"
href={`/cuenta?invite=${encodeURIComponent(code)}`}
>Crear una cuenta o iniciar sesión</a
>
{:else}<form
onsubmit={(e) => {
e.preventDefault();
enter(true);
}}
>
<label
>Tu nombre al jugar<input
required
minlength="2"
maxlength="30"
bind:value={name}
autocomplete="nickname"
placeholder="¿Cómo te llamas?"
/></label
><button class="button primary full" disabled={pending}
>{pending ? 'Entrando…' : 'Sentarme a jugar'}</button
>
</form>{/if}
{/if}
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
</Dialog>
<style>
.modal-head {
display: grid;
grid-template-columns: minmax(0, 1fr) 44px;
align-items: start;
gap: 16px;
margin: 0 0 24px;
}
h2 {
margin: 6px 0 0;
font-size: 24px;
line-height: 1.25;
letter-spacing: -0.5px;
}
.invitation-game {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: 16px;
align-items: center;
margin: 0 0 24px;
}
.invitation-art {
width: 96px;
height: 96px;
border-radius: 16px;
overflow: hidden;
}
.invitation-art :global(.game-art) {
width: 190px;
height: 190px;
transform: scale(0.505263);
transform-origin: top left;
}
.invitation-details {
display: grid;
gap: 4px;
min-width: 0;
}
.invitation-details strong {
font-size: 18px;
line-height: 1.4;
overflow-wrap: anywhere;
}
.invitation-details span {
font-size: 14px;
line-height: 1.5;
color: var(--secondary);
}
.invitation-message {
margin: 0 0 24px;
padding: 16px;
border-left: 3px solid var(--accent);
border-radius: 0 12px 12px 0;
background: var(--soft);
color: var(--text);
font-size: 16px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.invitation-note {
margin: 0 0 16px;
font-size: 16px;
line-height: 1.5;
color: var(--secondary);
}
@media (max-width: 600px) {
.invitation-game {
grid-template-columns: 80px minmax(0, 1fr);
}
.invitation-art {
width: 80px;
height: 80px;
border-radius: 12px;
}
.invitation-art :global(.game-art) {
transform: scale(0.421053);
}
}
</style>

@ -0,0 +1,320 @@
<script lang="ts">
import { onDestroy, untrack } from 'svelte';
import { flip } from 'svelte/animate';
import type { Room, Action } from '../types';
import Dice from './Dice.svelte';
import { diceFeedback } from '../dice-feedback';
import { playSound } from '../sound.svelte';
import { OcaPlayback } from '../oca-playback';
import {
colorForPlayer,
playerColorClass,
} from '../../../shared/player-colors.mjs';
import AvatarImage from './AvatarImage.svelte';
import CellInfo from './CellInfo.svelte';
import { ocaBoard, ocaCell } from '../../../shared/oca-board.mjs';
let {
room,
disabled = false,
onaction,
}: {
room: Room;
disabled?: boolean;
onaction: (action: Action) => void;
} = $props();
const lastRoll = $derived(
room.events?.filter((e) => e.type === 'roll').at(-1)?.sequence ?? 0,
);
const feedback = $derived(diceFeedback(room));
let displayed = $state<number[]>([]),
busy = $state(false),
hop = $state(0),
hopPlayer = $state<number | null>(null),
stepDuration = $state(160);
let arrival = $state<string | null>(null);
let arrivalPlayer = $state<number | null>(null);
const playback = new OcaPlayback({
frame: (positions, player, duration) => {
displayed = positions;
hopPlayer = player;
stepDuration = duration;
hop++;
},
busy: (value) => (busy = value),
sound: (definition, event) => void playSound(definition, event),
arrival: (player, effect) => {
arrivalPlayer = player;
arrival = effect;
hopPlayer = null;
},
reducedMotion: () =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
});
const artwork = $derived(room.definition?.board ?? ocaBoard);
const viewport = $derived(artwork.viewport ?? ocaBoard.viewport);
let hovered = $state<number | null>(null),
selected = $state<number | null>(null);
const inspected = $derived(selected ?? hovered);
function position(n: number) {
return artwork.centers[n];
}
function tile(n: number) {
const [x, y] = position(n),
width = n === 63 ? 248 : n < 3 ? 108 : 120,
height = n === 63 ? 264 : 126;
return { x: x - width / 2, y: y - height / 2, width, height };
}
function dismiss() {
selected = null;
hovered = null;
}
$effect(() => {
room.id;
lastRoll;
untrack(() => playback.receive(room));
});
onDestroy(() => playback.dispose());
</script>
<div class="table-game oca-table">
<div class="board-surface illustrated-board">
<svg
class="game-map oca-map"
viewBox={`${viewport.x} ${viewport.y} ${viewport.width} ${viewport.height}`}
role="group"
aria-label="Tablero de la oca: recorrido en espiral hasta el jardín de la casilla 63"
>
<defs
><clipPath id="oca-cells-clip"
>{#each artwork.centers as _, n}{@const rect = tile(n)}<rect
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
rx="8"
/>{/each}</clipPath
></defs
>
<image
href={artwork.image}
width={artwork.width}
height={artwork.height}
clip-path="url(#oca-cells-clip)"
/>
{#each artwork.centers as _, n}{@const rect = tile(n)}{@const info =
ocaCell(n)}
<g
class="cell-hotspot"
class:active={inspected === n}
role="button"
tabindex="0"
aria-label={`${info.title}: ${info.description}`}
aria-expanded={selected === n}
onpointerenter={(e) => {
if (e.pointerType === 'mouse') hovered = n;
}}
onpointerleave={() => (hovered = null)}
onfocus={() => (hovered = n)}
onblur={() => (hovered = null)}
onclick={() => (selected = selected === n ? null : n)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selected = selected === n ? null : n;
} else if (e.key === 'Escape') dismiss();
}}
>
<rect
class="cell-hit"
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
rx="10"
/>
</g>
{/each}
{#if inspected !== null}{@const rect = tile(inspected)}
<g
class="cell-enlargement"
transform={`translate(${rect.x + rect.width / 2} ${rect.y + rect.height / 2}) scale(1.12) translate(${-rect.x - rect.width / 2} ${-rect.y - rect.height / 2})`}
>
<svg
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
viewBox={`${rect.x} ${rect.y} ${rect.width} ${rect.height}`}
><image
href={artwork.image}
width={artwork.width}
height={artwork.height}
/></svg
>
<rect
x={rect.x}
y={rect.y}
width={rect.width}
height={rect.height}
rx="10"
fill="none"
/>
</g>
{/if}
{#each displayed as cell, seat (seat)}{@const point = position(cell)}
<g
animate:flip={{ duration: stepDuration, easing: (t) => t }}
data-player={seat}
data-cell={cell}
style:display={room.players[seat]?.withdrawnAt ? 'none' : undefined}
>
{#key seat === hopPlayer ? hop : 0}
<g
class="token-body"
class:hopping={seat === hopPlayer && stepDuration > 0}
class:bonus={seat === arrivalPlayer && arrival === 'bonus'}
class:penalty={seat === arrivalPlayer && arrival === 'penalty'}
class:goal={seat === arrivalPlayer && arrival === 'goal'}
style={`--hop-duration:${stepDuration}ms;--arrival-duration:${room.definition?.animations?.[arrival ?? 'move']?.duration ?? 450}ms`}
>
<circle
cx={point[0] - 24 + (seat % 2) * 48}
cy={point[1] + 10 + Math.floor(seat / 2) * 48}
r="23"
fill={colorForPlayer(room.players[seat]).hex}
stroke="#fffaf0"
stroke-width="4"
/>
<text
x={point[0] - 24 + (seat % 2) * 48}
y={point[1] + 18 + Math.floor(seat / 2) * 48}
text-anchor="middle"
font-size="23"
font-weight="700"
fill="white">{seat + 1}</text
>
</g>
{/key}
</g>
{/each}
</svg>
{#if inspected !== null}<CellInfo
info={ocaCell(inspected)}
x={((position(inspected)[0] - viewport.x) / viewport.width) * 100}
y={((position(inspected)[1] - viewport.y) / viewport.height) * 100}
pinned={selected !== null}
onclose={dismiss}
/>{/if}
</div>
<div class="game-controls oca-controls">
<Dice
value={room.state?.rolled}
rollId={lastRoll}
{feedback}
definition={room.definition}
disabled={disabled || busy}
canRoll={room.legalActions?.some((a) => a.type === 'roll')}
onroll={() => onaction({ type: 'roll' })}
onresultclose={(id) => playback.release(Number(id))}
/>
<p class="oca-instruction">
{#if room.state?.rolled}<span class="last-roll-caption"
>{feedback.title}</span
>{/if}{feedback.next}
</p>
<p class="oca-note">{room.state?.note}</p>
<div class="table-players">
{#each room.players as player}<div
class="table-player {playerColorClass(player)}"
>
<span class="mini-avatar"><AvatarImage person={player} /></span
><strong
>{player.nick}{player.seat === room.seat ? ' · tú' : ''}</strong
><small
>{#if player.withdrawnAt}Se ha retirado{:else}Casilla {room.state
?.positions?.[player.seat] ?? 0}{room.state?.skips?.[
player.seat
]
? ` · ${room.state.skips[player.seat]} descansos`
: ''}{/if}</small
>
</div>{/each}
</div>
</div>
</div>
<style>
.illustrated-board {
position: relative;
}
.oca-map {
overflow: visible;
}
.cell-hit {
fill: transparent;
stroke: transparent;
stroke-width: 3;
}
.cell-hotspot {
cursor: help;
outline: none;
}
.cell-hotspot:focus-visible .cell-hit,
.cell-hotspot.active .cell-hit {
stroke: var(--accent);
}
.cell-enlargement {
pointer-events: none;
filter: drop-shadow(0 8px 10px #27365250);
}
.cell-enlargement > rect {
stroke: var(--accent);
stroke-width: 4;
}
@media (hover: none), (prefers-reduced-motion: reduce) {
.cell-enlargement {
transform: none;
}
}
.token-body {
transform-box: fill-box;
transform-origin: center;
}
.hopping {
animation: cell-hop var(--hop-duration) ease-in-out;
}
@keyframes cell-hop {
50% {
transform: translateY(-30px) scale(1.08);
}
}
.bonus,
.goal {
animation: arrive-jump var(--arrival-duration) ease-out;
}
.penalty {
animation: arrive-shake var(--arrival-duration) ease-out;
}
@keyframes arrive-jump {
30% {
transform: translateY(-12px) scale(1.15);
}
65% {
transform: translateY(0) scale(0.9);
}
}
@keyframes arrive-shake {
25%,
75% {
transform: translateX(-7px);
}
50% {
transform: translateX(7px);
}
}
@media (prefers-reduced-motion: reduce) {
.token-body {
animation: none;
}
}
</style>

@ -0,0 +1,434 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import type { Room, Action } from '../types';
import CellInfo from './CellInfo.svelte';
let {
room,
disabled = false,
onaction,
}: {
room: Room;
disabled?: boolean;
onaction: (action: Action) => void;
} = $props();
const goal = $derived(room.state?.goal ?? 71),
outer = $derived(room.state?.outerLength ?? 64);
const colors = ['#b88925', '#4e84a6', '#b95f56', '#568264'];
const houseStarts = [4, 21, 38, 55];
function houseForSeat(seat: number) {
return houseStarts.indexOf(room.state?.starts?.[seat] ?? houseStarts[seat]);
}
const homes = [
[11, 11],
[0, 11],
[0, 0],
[11, 0],
];
const route: number[][] = [];
for (let x = 11; x < 19; x++) route.push([x, 8]);
route.push([18, 9]);
for (let x = 18; x > 10; x--) route.push([x, 10]);
for (let y = 11; y < 19; y++) route.push([10, y]);
route.push([9, 18]);
for (let y = 18; y > 10; y--) route.push([8, y]);
for (let x = 7; x >= 0; x--) route.push([x, 10]);
route.push([0, 9]);
for (let x = 0; x < 8; x++) route.push([x, 8]);
for (let y = 7; y >= 0; y--) route.push([8, y]);
route.push([9, 0]);
for (let y = 0; y < 8; y++) route.push([10, y]);
const track = Array.from({ length: 68 }, (_, i) => route[(i + 8) % 68]);
let hovered = $state<string | null>(null),
selected = $state<string | null>(null);
const inspected = $derived(selected ?? hovered);
function describeCell(id: string) {
const [type, first, second] = id.split('-'),
index = Number(first);
if (type === 'goal')
return {
point: [285, 285],
info: {
title: 'Meta',
kind: 'advantage',
description:
'Debes llegar con el número exacto. Cada ficha que entra da 10 pasos de premio. Ganas al llevar las cuatro.',
},
};
if (type === 'lane') {
const cell = lane(index, Number(second));
return {
point: [cell[0] * 30 + 15, cell[1] * 30 + 15],
info: {
title: `Pasillo ${['amarillo', 'azul', 'rojo', 'verde'][index]} · ${Number(second) + 1}`,
kind: 'advantage',
description:
'Solo entran las fichas de este color. Estás protegido de las capturas; necesitas la cuenta exacta para llegar a meta.',
},
};
}
const safe = room.state?.safe?.includes(index),
exit = houseStarts.includes(index),
cell = track[index];
return {
point: [cell[0] * 30 + 15, cell[1] * 30 + 15],
info: {
title: `Casilla ${index + 1}${exit ? ' · Salida' : safe ? ' · Seguro' : ''}`,
kind: safe ? 'advantage' : 'danger',
description: exit
? 'Sales de casa con un 5. Si la salida está ocupada por dos fichas, al salir puedes capturar al último rival que llegó.'
: safe
? 'Aquí no pueden capturarte. Pueden compartirla dos fichas; dos del mismo color forman una barrera.'
: 'Si un rival llega a esta casilla, captura tu ficha y la devuelve a casa; recibe 20 pasos de premio.',
},
};
}
function dismiss() {
hovered = null;
selected = null;
}
function keyInfo(e: KeyboardEvent, id: string) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selected = selected === id ? null : id;
} else if (e.key === 'Escape') dismiss();
}
function lane(seat: number, step: number) {
const edge = outer === 65 ? 17 : 18,
begin = outer === 65 ? 1 : 0;
return [
[edge - step, 9],
[9, edge - step],
[begin + step, 9],
[9, begin + step],
][seat];
}
function point(seat: number, piece: number, progress: number) {
const house = houseForSeat(seat);
if (progress === -1)
return [
homes[house][0] * 30 + 85 + (piece % 2) * 70,
homes[house][1] * 30 + 85 + Math.floor(piece / 2) * 70,
];
if (progress === goal)
return [
[315, 285],
[285, 315],
[255, 285],
[285, 255],
][house].map(
(v, i) => v + (i === 0 ? piece % 2 : Math.floor(piece / 2)) * 8 - 4,
);
const cell =
progress >= outer
? lane(house, progress - outer)
: track[((room.state?.starts?.[seat] ?? 0) + progress) % 68];
const others = room.state?.pieces?.[seat] ?? [];
const shared = others.filter((p) => p === progress).length > 1;
return [
cell[0] * 30 +
15 +
(shared ? (others.indexOf(progress) === piece ? -5 : 5) : 0),
cell[1] * 30 + 15,
];
}
</script>
<div class="parchis-inspection">
<svg
class="parchis-map"
viewBox="-8 -8 586 586"
role="group"
aria-label="Tablero de parchís: cuatro casas y un recorrido de 68 casillas. Puedes seleccionar las fichas resaltadas."
>
<rect
x="-6"
y="-6"
width="582"
height="582"
rx="18"
fill="#f5f1e7"
stroke="#ded8c9"
stroke-width="2"
/>
{#each homes as home, seat}
{@const owner = room.players.find((p) => houseForSeat(p.seat) === seat)}
<g opacity={owner ? 1 : 0.45}>
<rect
x={home[0] * 30 + 8}
y={home[1] * 30 + 8}
width="224"
height="224"
rx="25"
fill={colors[seat]}
fill-opacity="0.16"
/>
<rect
x={home[0] * 30 + 45}
y={home[1] * 30 + 45}
width="150"
height="150"
rx="35"
fill="#fffdf7"
stroke={colors[seat]}
stroke-opacity="0.4"
stroke-width="2"
/>
{#each Array(4) as _, i}<circle
cx={home[0] * 30 + 85 + (i % 2) * 70}
cy={home[1] * 30 + 85 + Math.floor(i / 2) * 70}
r="22"
fill={colors[seat]}
fill-opacity="0.11"
/>{/each}
<text
x={home[0] * 30 + 120}
y={home[1] * 30 + 216}
text-anchor="middle"
fill={colors[seat]}
font-size="13"
font-weight="700">{owner?.nick.slice(0, 20) ?? 'Casa libre'}</text
>
</g>
{/each}
{#each track as cell, i}
{@const seat = houseStarts.indexOf(i)}
{@const safe = room.state?.safe?.includes(i)}
{@const id = `track-${i}`}
<g
class="cell-hotspot"
class:active={inspected === id}
role="button"
tabindex="0"
aria-label={describeCell(id).info.title}
aria-expanded={selected === id}
onpointerenter={(e) => {
if (e.pointerType === 'mouse') hovered = id;
}}
onpointerleave={() => (hovered = null)}
onfocus={() => (hovered = id)}
onblur={() => (hovered = null)}
onclick={() => (selected = selected === id ? null : id)}
onkeydown={(e) => keyInfo(e, id)}
>
<rect
x={cell[0] * 30 + 1}
y={cell[1] * 30 + 1}
width="28"
height="28"
rx="3"
fill={seat >= 0 ? colors[seat] : safe ? '#e1e8d8' : '#fffdf7'}
stroke="#c8c9b9"
stroke-width="0.8"
/>
<text
x={cell[0] * 30 + 15}
y={cell[1] * 30 + 19}
text-anchor="middle"
fill={seat >= 0 ? '#fff' : '#5c6855'}
font-size="9"
font-weight="600">{i + 1}</text
>
</g>
{/each}
{#each colors as color, seat}
{#each Array(7) as _, step}{@const cell = lane(
seat,
step,
)}{@const id = `lane-${seat}-${step}`}<g
class="cell-hotspot"
class:active={inspected === id}
role="button"
tabindex="0"
aria-label={describeCell(id).info.title}
aria-expanded={selected === id}
onpointerenter={(e) => {
if (e.pointerType === 'mouse') hovered = id;
}}
onpointerleave={() => (hovered = null)}
onfocus={() => (hovered = id)}
onblur={() => (hovered = null)}
onclick={() => (selected = selected === id ? null : id)}
onkeydown={(e) => keyInfo(e, id)}
><rect
x={cell[0] * 30 + 1}
y={cell[1] * 30 + 1}
width="28"
height="28"
rx="3"
fill={color}
fill-opacity="0.28"
stroke={color}
stroke-opacity="0.5"
stroke-width="0.8"
/></g
>{/each}
{/each}
<path d="M240 240L285 285L330 240Z" fill={colors[3]} fill-opacity="0.8" />
<path d="M330 240L285 285L330 330Z" fill={colors[0]} fill-opacity="0.8" />
<path d="M330 330L285 285L240 330Z" fill={colors[1]} fill-opacity="0.8" />
<path d="M240 330L285 285L240 240Z" fill={colors[2]} fill-opacity="0.8" />
<g
class="cell-hotspot"
role="button"
tabindex="0"
aria-label="Meta del parchís"
onpointerenter={(e) => {
if (e.pointerType === 'mouse') hovered = 'goal';
}}
onpointerleave={() => (hovered = null)}
onfocus={() => (hovered = 'goal')}
onblur={() => (hovered = null)}
onclick={() => (selected = selected === 'goal' ? null : 'goal')}
onkeydown={(e) => keyInfo(e, 'goal')}
><circle cx="285" cy="285" r="13" fill="#fffdf7" />
<text x="285" y="290" text-anchor="middle" fill="#6b7957" font-size="17"
>✳</text
>
</g>
{#each room.state?.pieces ?? [] as pieces, seat}
{@const color = colors[houseForSeat(seat)]}
{#each pieces as progress, piece (piece)}{@const pos = point(
seat,
piece,
progress,
)}
{@const playable =
!disabled &&
seat === room.seat &&
room.legalActions?.some(
(a) => a.type === 'move' && a.piece === piece,
)}
<g
animate:flip={{
duration: room.definition?.animations?.move?.duration ?? 450,
}}
role="button"
tabindex={playable ? 0 : -1}
aria-disabled={!playable}
aria-label={`Mover ficha ${piece + 1} de ${room.players[seat].nick}`}
style:display={room.players[seat]?.withdrawnAt ? 'none' : undefined}
class:playable
onclick={() => {
if (playable) onaction({ type: 'move', piece });
}}
onkeydown={(e) => {
if (playable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onaction({ type: 'move', piece });
}
}}
>
<title
>Ficha {piece + 1} de {room.players[seat].nick}: {progress === -1
? 'en casa'
: progress === goal
? 'en meta'
: progress >= outer
? 'pasillo final'
: `casilla ${(((room.state?.starts?.[seat] ?? 0) + progress) % 68) + 1}`}</title
>
{#if playable}<circle
cx={pos[0]}
cy={pos[1]}
r={progress === -1 ? 25 : 17}
fill="none"
stroke={color}
stroke-width="3"
class="piece-halo"
/>{/if}
<circle
cx={pos[0] + 1}
cy={pos[1] + 2}
r={progress === -1 ? 19 : 12}
fill="#253324"
fill-opacity="0.2"
/>
<circle
cx={pos[0]}
cy={pos[1]}
r={progress === -1 ? 19 : 12}
fill={color}
stroke="#fffdf7"
stroke-width="2"
/>
<circle
cx={pos[0]}
cy={pos[1]}
r={progress === -1 ? 14 : 8}
fill="none"
stroke="#fffdf7"
stroke-opacity="0.4"
/>
<text
x={pos[0]}
y={pos[1] + 4}
text-anchor="middle"
fill="#fff"
font-size={progress === -1 ? 15 : 11}
font-weight="700">{progress === goal ? '✓' : piece + 1}</text
>
</g>
{/each}
{/each}
</svg>
{#if inspected}{@const detail = describeCell(inspected)}<CellInfo
info={detail.info}
x={((detail.point[0] + 8) / 586) * 100}
y={((detail.point[1] + 8) / 586) * 100}
pinned={selected !== null}
onclose={dismiss}
/>{/if}
</div>
<style>
.parchis-inspection {
width: 100%;
height: 100%;
position: relative;
}
.cell-hotspot {
cursor: help;
outline: none;
transform-box: fill-box;
transform-origin: center;
}
.cell-hotspot text {
pointer-events: none;
}
.cell-hotspot:focus-visible rect,
.cell-hotspot.active rect {
stroke: var(--accent);
stroke-width: 2;
}
@media (hover: hover) {
.cell-hotspot:hover {
transform: scale(1.18);
filter: drop-shadow(0 2px 2px #27365240);
}
}
g.playable {
cursor: pointer;
outline: none;
}
g.playable:focus-visible .piece-halo {
stroke: #13284a;
stroke-width: 5;
}
.piece-halo {
animation: piece-pulse 1.3s ease-in-out infinite;
}
@keyframes piece-pulse {
50% {
opacity: 0.5;
stroke-width: 1.5;
}
}
@media (prefers-reduced-motion: reduce) {
.cell-hotspot:hover {
transform: none;
}
.piece-halo {
animation: none;
}
}
</style>

@ -0,0 +1,102 @@
<script lang="ts">
import type { Profile } from '../types';
import { definitions } from '../client.svelte';
import Icon from './Icon.svelte';
let {
rooms,
currentId = '',
}: { rooms: Profile['pending']; currentId?: string } = $props();
</script>
<div class="pending-games">
{#if !rooms.length}<p class="pending-empty">
No tienes partidas pendientes. Puedes crear una desde el catálogo.
</p>
{:else}<div class="pending-games-list">
{#each rooms as game (game.id)}
<a
class="pending-game"
class:current={game.id === currentId}
href={`/salas/${game.id}`}
aria-current={game.id === currentId ? 'page' : undefined}
>
<span
><strong>{game.name}</strong><small
>{definitions.find((d) => d.id === game.gameId)?.name ??
game.gameId}</small
><span class="pending-status" class:your-turn={game.myTurn}
>{game.status === 'waiting'
? 'Sala de espera'
: game.myTurn
? 'Tu turno'
: 'Partida en curso'}{game.id === currentId
? ' · Estás aquí'
: ''}</span
></span
>
<Icon name="chevron" size={18} />
</a>
{/each}
</div>{/if}
</div>
<style>
.pending-games {
container-type: inline-size;
}
.pending-games-list {
display: grid;
gap: 8px;
}
.pending-game {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text);
text-decoration: none;
}
.pending-game:hover,
.pending-game.current {
border-color: var(--accent);
background: var(--soft);
}
.pending-game > span {
min-width: 0;
}
.pending-game strong {
display: block;
font-size: var(--type-body);
line-height: 1.4;
overflow-wrap: anywhere;
}
.pending-game small {
display: block;
font-size: var(--type-secondary);
color: var(--secondary);
margin-top: 6px;
}
.pending-status {
display: block;
font-size: var(--type-detail);
color: var(--secondary);
margin-top: 6px;
}
.pending-status.your-turn {
color: var(--accent-text);
font-weight: 700;
}
.pending-empty {
font-size: var(--type-secondary);
color: var(--secondary);
}
@container (min-width:640px) {
.pending-games-list {
grid-template-columns: 1fr 1fr;
}
}
</style>

@ -0,0 +1,120 @@
<script lang="ts">
import type { Room } from '../types';
import {
paletteForRoom,
colorForPlayer,
} from '../../../shared/player-colors.mjs';
let {
room,
disabled = false,
onchoose,
}: {
room: Room;
disabled?: boolean;
onchoose: (colorId: string) => void;
} = $props();
const me = $derived(room.players.find((p) => p.seat === room.seat));
const automatic = $derived(room.seat === room.maxPlayers - 1);
</script>
<section class="color-chooser" aria-labelledby="piece-color-title">
<h3 id="piece-color-title">Color de tus fichas</h3>
{#if automatic}<div class="automatic-color">
{#if me?.colorId}<i style:background={colorForPlayer(me).hex}></i><strong
>{colorForPlayer(me).label}</strong
>{/if}
<p>
{me?.colorId
? 'Tu color se asigna automáticamente al ser el último jugador.'
: 'Tu color se asignará cuando los demás elijan.'}
</p>
</div>
{:else}<div class="color-options">
{#each paletteForRoom(room) as color}
{@const owner = room.players.find(
(p) =>
p.seat !== room.seat &&
p.seat !== room.maxPlayers - 1 &&
p.colorId === color.id,
)}
<button
class:chosen={me?.colorId === color.id}
disabled={disabled || !!owner}
aria-pressed={me?.colorId === color.id}
title={owner ? `Elegido por ${owner.nick}` : color.label}
onclick={() => onchoose(color.id)}
><i style:background={color.hex}></i><span>{color.label}</span
></button
>
{/each}
</div>
<p class="color-hint">
Los colores elegidos por otras personas quedan reservados.
</p>{/if}
</section>
<style>
.color-chooser {
margin: 24px 0;
text-align: left;
}
h3 {
font-size: var(--type-body);
margin-bottom: 12px;
}
.color-options {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.color-options button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
min-height: 48px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
color: var(--text);
font-size: var(--type-secondary);
}
.color-options button.chosen {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.color-options button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
i {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: inset 0 0 0 2px #ffffff70;
}
.color-hint,
.automatic-color p {
font-size: var(--type-secondary);
color: var(--secondary);
margin-top: 12px;
}
.automatic-color {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.automatic-color p {
width: 100%;
margin-top: 4px;
}
@media (max-width: 650px) {
.color-options {
grid-template-columns: 1fr 1fr;
}
}
</style>

@ -0,0 +1,194 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { api } from '../client.svelte';
import type { Room } from '../types';
import {
canCloseInactive,
roomDeadlines,
} from '../../../shared/room-lifecycle.mjs';
import Dialog from './Dialog.svelte';
import Icon from './Icon.svelte';
let { room, onrefresh }: { room: Room; onrefresh: () => void } = $props();
let open = $state(false),
pending = $state(false),
error = $state(''),
now = $state(Date.now());
const me = $derived(room.players.find((p) => p.seat === room.seat));
const host = $derived(room.createdBy === me?.userId);
const deadlines = $derived(roomDeadlines(room));
const eligible = $derived(canCloseInactive(room, room.seat ?? -1, now));
const minutes = $derived(
Math.max(0, Math.ceil((deadlines.inactiveCloseAt - now) / 60_000)),
);
const waitingForOther = $derived(
room.state?.phase === 'scoring'
? Boolean(
room.state.accepted?.[room.seat ?? -1] &&
room.state.accepted.some(
(accepted, i) => i !== room.seat && !accepted,
),
)
: room.state?.turn !== room.seat,
);
$effect(() => {
if (room.status === 'finished' || me?.withdrawnAt) open = false;
});
onMount(() => {
const timer = setInterval(() => (now = Date.now()), 1000);
return () => clearInterval(timer);
});
async function leave(mode: 'leave' | 'close-inactive') {
pending = true;
error = '';
try {
await api(`/rooms/${room.id}/${mode}`, { expectedVersion: room.version });
open = false;
await goto('/perfil');
} catch (e) {
error = (e as Error).message;
onrefresh();
} finally {
pending = false;
}
}
</script>
{#if room.status !== 'finished' && !me?.withdrawnAt}
<button
class="icon-button"
aria-label="Salir de la partida"
title="Salir de la partida"
onclick={() => {
error = '';
open = true;
}}><Icon name="logout" size={19} /></button
>
{/if}
{#if open}
<Dialog
label="exit-title"
className="exit-modal"
onclose={() => {
if (!pending) open = false;
}}
>
<div class="modal-head">
<h2 id="exit-title">Salir de la partida</h2>
<button
class="icon-button"
aria-label="Cerrar opciones de salida"
disabled={pending}
onclick={() => (open = false)}><Icon name="close" /></button
>
</div>
<section class="exit-choice">
<h3>
{room.status === 'waiting'
? host
? 'Cancelar esta sala'
: 'Dejar esta sala'
: 'Abandonar la partida'}
</h3>
<p>
{room.status === 'waiting'
? host
? 'Cerrarás la sala para todos. No se guardarán victorias ni derrotas.'
: 'Tu plaza quedará libre. El resto podrá seguir preparando la partida.'
: room.players.filter((p) => !p.withdrawnAt).length === 2
? 'Te rendirás y el otro jugador ganará. Tu derrota se guardará en el historial.'
: 'Te retirarás con una derrota. Tus fichas saldrán del tablero y los demás podrán continuar.'}
</p>
<button
class="button danger full"
disabled={pending}
onclick={() => leave('leave')}
>{pending
? 'Guardando…'
: room.status === 'waiting'
? host
? 'Cancelar sala y salir'
: 'Dejar sala'
: 'Abandonar y salir'}</button
>
</section>
{#if room.status === 'playing'}
<section class="exit-choice inactivity-choice">
<h3>El otro jugador no responde</h3>
<p>
Cierra la partida sin resultado después de 10 minutos esperando una
jugada de otro jugador.
</p>
<p class="exit-status" role="status">
{eligible
? 'Ya puedes cerrarla por inactividad.'
: waitingForOther
? `Disponible en ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'} sin nuevas jugadas.`
: 'Ahora te corresponde jugar.'}
</p>
<button
class="button secondary full"
disabled={pending || !eligible}
onclick={() => leave('close-inactive')}>Cerrar por inactividad</button
>
</section>
{/if}
<p class="expiry-note">
<Icon name="clock" size={16} />Las salas se cierran automáticamente tras
24 horas sin actividad de juego.
</p>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
</Dialog>
{/if}
<style>
.modal-head {
display: flex;
align-items: flex-start;
gap: 16px;
justify-content: space-between;
margin: 0 0 24px;
}
h2 {
margin: 6px 0 0;
font-size: 24px;
line-height: 1.25;
}
h3 {
margin: 0;
font-size: 18px;
line-height: 1.4;
}
.exit-choice {
display: grid;
gap: 12px;
}
.exit-choice p {
margin: 0;
font-size: 16px;
line-height: 1.5;
color: var(--secondary);
}
.inactivity-choice {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid var(--border);
}
.exit-choice .exit-status {
font-size: 14px;
color: var(--accent-text);
}
.expiry-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 24px 0 0;
color: var(--secondary);
font-size: 14px;
line-height: 1.5;
}
.expiry-note :global(svg) {
flex: 0 0 16px;
margin-top: 3px;
}
</style>

@ -0,0 +1,94 @@
<script lang="ts">
import { sound, saveSound, unlockSound, playSound } from '../sound.svelte';
import { definitions } from '../client.svelte';
import Icon from './Icon.svelte';
const game = $derived(
definitions.find((g) => g.id === sound.previewGame) ?? definitions[0],
);
const names: Record<string, string> = {
move: 'Mover',
capture: 'Capturar',
roll: 'Dado',
shake: 'Cubilete',
check: 'Jaque',
promotion: 'Coronar',
goal: 'Meta',
pass: 'Turno',
bonus: 'Oca',
penalty: 'Penalización',
play: 'Carta',
trick: 'Baza',
win: 'Victoria',
};
const samples = $derived(
Object.keys(names).filter((key) => game?.sounds?.[key]),
);
function toggle() {
sound.enabled = !sound.enabled;
saveSound();
if (sound.enabled) void unlockSound();
}
</script>
<div class="sound-control">
<button
class="settings-trigger"
popovertarget="audio-settings"
aria-label="Configurar sonidos"
title={sound.enabled ? 'Sonidos activados' : 'Sonidos silenciados'}
><Icon
name={sound.enabled && sound.volume > 0 ? 'volume' : 'mute'}
size={19}
/></button
>
<div
id="audio-settings"
popover
class="settings-popover sound-popover"
role="dialog"
aria-label="Sonidos de Juegoland"
>
<strong>Sonidos de juego</strong>
<button class="sound-toggle" aria-pressed={sound.enabled} onclick={toggle}
><Icon name={sound.enabled ? 'volume' : 'mute'} size={17} />{sound.enabled
? 'Activados · silenciar'
: 'Silenciados · activar'}</button
>
<label for="effect-volume"
>Volumen <span>{Math.round(sound.volume * 100)} %</span></label
>
<input
id="effect-volume"
type="range"
min="0"
max="1"
step="0.05"
bind:value={sound.volume}
oninput={saveSound}
/>
<label class="speech-option"
><input
type="checkbox"
bind:checked={sound.announcements}
onchange={saveSound}
/>Leer el número del dado en voz alta</label
>
<label class="audio-preview-label" for="audio-game"
>Escucha los efectos</label
><select id="audio-game" bind:value={sound.previewGame}
>{#each definitions as definition}<option value={definition.id}
>{definition.name}</option
>{/each}</select
>
<div class="audio-samples">
{#each samples as sample}<button
disabled={!sound.enabled || sound.volume === 0}
onclick={async () => {
await unlockSound();
await playSound(game, sample);
}}><Icon name="volume" size={16} />{names[sample]}</button
>{/each}
</div>
{#if sound.error}<p role="status">{sound.error}</p>{/if}
</div>
</div>

@ -0,0 +1,66 @@
<script lang="ts">
import { acceptTerms, consent, session } from '#lib/client.svelte.ts';
import { terms } from '../../../shared/terms.mjs';
import Dialog from './Dialog.svelte';
let { hasTermsCookie = false }: { hasTermsCookie?: boolean } = $props();
let checked = $state(false),
busy = $state(false),
error = $state('');
const visible = $derived(
consent.reviewOpen ||
(!consent.accepted &&
!consent.dismissed &&
(!session.loading || !hasTermsCookie)),
);
async function accept() {
if (!checked || busy) return;
busy = true;
error = '';
try {
await acceptTerms();
checked = false;
} catch (e) {
error = (e as Error).message;
} finally {
busy = false;
}
}
function close() {
if (busy) return;
consent.reviewOpen = false;
consent.dismissed = true;
checked = false;
error = '';
}
</script>
{#if visible}
<Dialog label="terms-title" onclose={close} className="terms-consent">
<h2 id="terms-title">Antes de jugar</h2>
<p class="terms-author">{terms.introduction}</p>
<p>{terms.notice}</p>
<label class="terms-check">
<input type="checkbox" bind:checked disabled={busy} />
<span
>Entiendo que es un servicio en desarrollo y acepto usarlo bajo mi
responsabilidad.</span
>
</label>
<p class="terms-cookie">
Una cookie recordará tu aceptación durante un año.
</p>
<div class="terms-actions">
<button class="button secondary" disabled={busy} onclick={close}
>Ahora no</button
>
<button
class="button primary"
disabled={!checked || busy}
onclick={accept}
>
{busy ? 'Guardando…' : 'Aceptar y entrar'}
</button>
</div>
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
</Dialog>
{/if}

@ -0,0 +1,16 @@
<script lang="ts">
import { appearance, saveAppearance, type Mode, type Palette } from '../theme.svelte';
import Icon from './Icon.svelte';
const modes:{id:Mode;label:string;icon:string}[]=[{id:'light',label:'Claro',icon:'sun'},{id:'dark',label:'Oscuro',icon:'moon'},{id:'system',label:'Sistema',icon:'monitor'}];
const palettes:{id:Palette;label:string;color:string}[]=[{id:'blue',label:'Azul',color:'#4263df'},{id:'forest',label:'Bosque',color:'#247761'},{id:'violet',label:'Violeta',color:'#8756ca'}];
</script>
<div class="theme-control">
<button class="settings-trigger" popovertarget="appearance-settings" aria-label="Cambiar apariencia" title="Apariencia"><Icon name={appearance.mode==='dark'?'moon':'sun'} size={19}/></button>
<div id="appearance-settings" popover class="settings-popover theme-popover" role="dialog" aria-label="Apariencia de Juegoland">
<strong>A tu manera</strong>
<span class="settings-label">Aspecto</span>
<div class="theme-options">{#each modes as mode}<button class:chosen={appearance.mode===mode.id} aria-pressed={appearance.mode===mode.id} onclick={()=>{appearance.mode=mode.id;saveAppearance();}}><Icon name={mode.icon} size={17}/>{mode.label}</button>{/each}</div>
<span class="settings-label">Color de la plataforma</span>
<div class="palette-options">{#each palettes as palette}<button class:chosen={appearance.palette===palette.id} aria-label={`Paleta ${palette.label}`} aria-pressed={appearance.palette===palette.id} onclick={()=>{appearance.palette=palette.id;saveAppearance();}}><i style:background={palette.color}></i>{palette.label}</button>{/each}</div>
</div>
</div>

@ -0,0 +1,39 @@
import type { Room } from './types.js';
/** Ownership comes from the confirmed event, even when the roll changes the turn. */
export function diceFeedback(room: Room) {
const event = room.events?.filter((entry) => entry.type === 'roll').at(-1);
const player = event?.actorId
? room.players.find((entry) => entry.userId === event.actorId)
: room.players.find((entry) => entry.nick === event?.nick);
const own = player !== undefined && player.seat === room.seat;
const myTurn = room.status === 'playing' && room.state?.turn === room.seat;
const current = room.players[room.state?.turn ?? 0];
let next = 'Partida terminada.';
if (room.status === 'playing') {
if (room.state?.die) {
const advance = `${room.state.die} ${room.state.die === 1 ? 'casilla' : 'casillas'}`;
next = myTurn
? `Elige una ficha resaltada para avanzar ${advance}.`
: `${current.nick} debe elegir una ficha para avanzar ${advance}.`;
} else {
next = myTurn
? 'Es tu turno. Puedes lanzar el dado.'
: `Ahora juega ${current.nick}. Espera su tirada.`;
}
}
const penalty = event?.label.startsWith('Tercer seis')
? own
? 'Tercer seis: tu turno ha terminado. '
: `Tercer seis: termina el turno de ${player?.nick}. `
: '';
return {
own,
actor: player?.nick ?? event?.nick ?? 'Otro jugador',
title: own
? `Has sacado un ${room.state?.rolled}`
: `${player?.nick ?? event?.nick ?? 'Otro jugador'} ha sacado un ${room.state?.rolled}`,
next: penalty + next,
createdAt: event?.createdAt,
};
}

@ -0,0 +1,130 @@
import type { Room, Definition } from './types.js';
interface Throw {
sequence: number;
player: number;
path: number[];
released: boolean;
effect: string | null;
definition?: Definition;
}
/** Keeps authoritative results queued until their dice presentation has finished. */
export class OcaPlayback {
private previous: number | undefined;
private roomId = '';
private queue: Throw[] = [];
private positions: number[] = [];
private timers: ReturnType<typeof setTimeout>[] = [];
private playing = false;
constructor(
private callbacks: {
frame: (
positions: number[],
player: number | null,
duration: number,
) => void;
busy: (value: boolean) => void;
sound: (definition: Definition | undefined, event: string) => void;
arrival: (player: number, effect: string | null) => void;
reducedMotion: () => boolean;
},
) {}
receive(room: Room) {
const event = room.events?.filter((e) => e.type === 'roll').at(-1);
const sequence = event?.sequence ?? 0;
if (this.previous === undefined || this.roomId !== room.id) {
this.dispose();
this.roomId = room.id;
this.previous = sequence;
this.positions = [...(room.state?.positions ?? [])];
this.callbacks.frame(this.positions, null, 0);
return;
}
if (sequence <= this.previous) return;
this.previous = sequence;
if (
!room.state?.lastPath?.length ||
!event ||
Date.now() - Date.parse(event.createdAt) > 5000
) {
if (!this.queue.length) {
this.positions = [...(room.state?.positions ?? [])];
this.callbacks.frame(this.positions, null, 0);
}
return;
}
const own =
event.actorId === room.players.find((p) => p.seat === room.seat)?.userId;
const player = room.state.lastPlayer ?? 0;
const entry: Throw = {
sequence,
player,
path: [...room.state.lastPath],
released: false,
effect: room.state.lastEffect ?? null,
definition: room.definition,
};
this.queue.push(entry);
this.callbacks.busy(true);
if (!own)
this.timers.push(
setTimeout(
() => this.release(sequence),
room.definition?.animations?.roll?.duration ?? 800,
),
);
}
release(sequence: number) {
const entry = this.queue.find((e) => e.sequence === sequence);
if (entry) entry.released = true;
this.pump();
}
private pump() {
const entry = this.queue[0];
if (this.playing || !entry?.released) return;
this.playing = true;
this.callbacks.arrival(entry.player, null);
const path = entry.path.filter(
(n, i, a) => n !== (i ? a[i - 1] : this.positions[entry.player]),
);
const reduced = this.callbacks.reducedMotion();
const stepDuration = reduced
? 0
: Math.max(
160,
Math.min(
260,
(entry.definition?.animations?.move?.duration ?? 700) /
Math.max(path.length, 1),
),
);
if (path.length && !reduced) this.callbacks.sound(entry.definition, 'move');
path.forEach((cell, index) =>
this.timers.push(
setTimeout(() => {
this.positions = this.positions.map((p, seat) =>
seat === entry.player ? cell : p,
);
this.callbacks.frame([...this.positions], entry.player, stepDuration);
}, index * stepDuration),
),
);
this.timers.push(
setTimeout(() => {
this.callbacks.arrival(entry.player, entry.effect);
if (entry.effect) this.callbacks.sound(entry.definition, entry.effect);
this.queue.shift();
this.playing = false;
this.callbacks.busy(this.queue.length > 0);
this.pump();
}, path.length * stepDuration),
);
}
dispose() {
this.timers.forEach(clearTimeout);
this.timers = [];
this.queue = [];
this.playing = false;
this.callbacks.busy(false);
}
}

@ -0,0 +1,151 @@
import type { Definition, Room } from './types';
import { roomSound } from '../../shared/sound-event.mjs';
export const sound = $state({
enabled: true,
volume: 0.45,
ready: false,
error: '',
announcements: false,
previewGame: 'ajedrez',
});
let context: AudioContext | null = null;
let active: AudioBufferSourceNode | null = null;
const buffers = new Map<string, Promise<AudioBuffer>>();
let generation = 0;
export async function unlockSound() {
if (!sound.enabled) return;
try {
context ??= new AudioContext();
if (context.state === 'suspended') await context.resume();
sound.ready = context.state === 'running';
} catch {
sound.ready = false;
}
}
export function installSound() {
try {
const saved = JSON.parse(
localStorage.getItem('juegoland.sound') ??
localStorage.getItem('mesa.sound') ??
'null',
);
if (typeof saved?.announcements === 'boolean')
sound.announcements = saved.announcements;
if (typeof saved?.enabled === 'boolean') sound.enabled = saved.enabled;
if (typeof saved?.volume === 'number' && Number.isFinite(saved.volume))
sound.volume = Math.max(0, Math.min(1, saved.volume));
} catch {
/* Storage may be unavailable; use defaults. */
}
const unlock = () => {
void unlockSound();
};
window.addEventListener('pointerdown', unlock, { passive: true });
window.addEventListener('keydown', unlock);
return () => {
window.removeEventListener('pointerdown', unlock);
window.removeEventListener('keydown', unlock);
active?.stop();
};
}
export function saveSound() {
generation++;
sound.error = '';
if (!sound.enabled || sound.volume === 0) {
active?.stop();
active = null;
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
}
try {
localStorage.setItem(
'juegoland.sound',
JSON.stringify({
enabled: sound.enabled,
volume: sound.volume,
announcements: sound.announcements,
}),
);
} catch {
/* Preferences remain active in this tab. */
}
}
export async function playSound(
definition: Definition | undefined,
event: string,
) {
const effect = definition?.sounds?.[event];
if (
!effect ||
!sound.enabled ||
sound.volume === 0 ||
!context ||
context.state !== 'running'
)
return;
const url = new URL(effect.src, location.origin);
if (url.origin !== location.origin) return;
const requested = Date.now(),
requestGeneration = generation;
try {
let buffer = buffers.get(url.href);
if (!buffer) {
buffer = fetch(url.href).then(async (response) => {
if (!response.ok) throw new Error('Audio no disponible');
return context!.decodeAudioData(await response.arrayBuffer());
});
buffers.set(url.href, buffer);
buffer.catch(() => buffers.delete(url.href));
}
const decoded = await buffer;
if (
generation !== requestGeneration ||
!sound.enabled ||
!context ||
context.state !== 'running' ||
Date.now() - requested > 2000
)
return;
active?.stop();
const source = context.createBufferSource(),
gain = context.createGain();
source.buffer = decoded;
gain.gain.value = sound.volume * Math.max(0, Math.min(1, effect.volume));
source.connect(gain);
gain.connect(context.destination);
active = source;
source.onended = () => {
source.disconnect();
gain.disconnect();
if (active === source) active = null;
};
source.start();
sound.error = '';
} catch {
sound.error = 'Este sonido no está disponible.';
}
}
export function playRoomSound(previous: Room | null, next: Room) {
const event = roomSound(previous, next);
if (event) void playSound(next.definition, event);
}
export function announceDice(
definition: Definition | undefined,
value: number,
) {
if (
!sound.enabled ||
!sound.announcements ||
!sound.volume ||
!definition?.sounds.roll?.announceNumber ||
!('speechSynthesis' in window)
)
return;
const utterance = new SpeechSynthesisUtterance(String(value));
utterance.lang = definition.sounds.roll.language ?? 'es-ES';
utterance.volume = sound.volume;
utterance.rate = 0.9;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utterance);
}

@ -0,0 +1,22 @@
export type Mode = 'light' | 'dark' | 'system';
export type Palette = 'blue' | 'forest' | 'violet';
export const appearance = $state<{mode:Mode;palette:Palette}>({mode:'system',palette:'blue'});
let media: MediaQueryList | null = null;
function apply() {
document.documentElement.dataset.theme = appearance.mode==='system' ? media?.matches ? 'dark' : 'light' : appearance.mode;
document.documentElement.dataset.palette = appearance.palette;
}
export function saveAppearance() {
apply();
try { localStorage.setItem('juegoland.appearance',JSON.stringify(appearance)); } catch { /* Use this session's preferences. */ }
}
export function installAppearance() {
media = matchMedia('(prefers-color-scheme: dark)');
try {
const saved = JSON.parse(localStorage.getItem('juegoland.appearance')??localStorage.getItem('enronda.appearance')??'null');
if(['light','dark','system'].includes(saved?.mode))appearance.mode=saved.mode;
if(['blue','forest','violet'].includes(saved?.palette))appearance.palette=saved.palette;
} catch { /* Defaults remain available. */ }
apply();media.addEventListener('change',apply);
return ()=>media?.removeEventListener('change',apply);
}

@ -0,0 +1,142 @@
export interface User {
avatar?: string;
id: string;
name: string;
nick: string;
createdAt: string;
guest: boolean;
}
export interface Definition {
board?: {
image: string;
width: number;
height: number;
viewport?: { x: number; y: number; width: number; height: number };
centers: number[][];
};
id: string;
name: string;
subtitle: string;
category: string;
variant: string;
minPlayers: number;
maxPlayers: number;
duration: string;
difficulty: string;
color: string;
icon: string;
version: string;
rules: string[];
sounds: Record<
string,
{ src: string; volume: number; announceNumber?: boolean; language?: string }
>;
animations: Record<
string,
{ kind: string; duration: number; easing: string }
>;
}
export interface Player {
avatar?: string;
colorId?: string;
userId: string;
nick: string;
seat: number;
ready: boolean;
connected?: boolean;
withdrawnAt?: string;
outcome?: 'win' | 'loss' | 'draw';
placement?: number;
}
export interface Action {
type: string;
from?: string | number;
to?: string | number;
promotion?: string;
piece?: number;
}
export interface GameState {
turn: number;
chain?: number | null;
board?:
({ square: string; type: string; color: string } | null)[][] | number[];
check?: boolean;
pgn?: string;
pieces?: number[][];
die?: number | null;
rolled?: number | null;
starts?: number[];
safe?: number[];
bonus?: boolean;
goal?: number;
outerLength?: number;
sixes?: number;
grid?: number[];
size?: number;
phase?: string;
dead?: number[];
accepted?: boolean[];
scores?: number[];
positions?: number[];
skips?: number[];
note?: string;
captures?: number[];
komi?: number;
lastPath?: number[];
lastPlayer?: number | null;
lastEffect?: string | null;
geese?: number[];
}
export interface Room {
id: string;
code: string;
name: string;
gameId: string;
gameVersion: string;
status: 'waiting' | 'playing' | 'finished';
visibility: string;
maxPlayers: number;
players: Player[];
createdAt: string;
startedAt?: string;
lastGameActivityAt?: string;
deadlines?: { inactiveCloseAt: number; autoCloseAt: number };
finishedAt?: string;
result?: { winner: number | null; reason: string; cancelled?: boolean };
version: number;
createdBy?: string;
invitationMessage?: string;
seat?: number;
state?: GameState;
legalActions?: Action[];
events?: {
sequence: number;
nick: string;
label: string;
createdAt: string;
sound?: string;
type?: string;
actorId?: string;
animation?: string;
}[];
messages?: {
id: string;
userId: string;
nick: string;
text: string;
createdAt: string;
}[];
definition?: Definition;
}
export interface Stats {
gameId: string;
played: number;
wins: number;
losses: number;
draws: number;
}
export interface Profile {
pending: (Room & { myTurn: boolean })[];
stats: Stats[];
history: (Room & { myResult: Player })[];
}

@ -0,0 +1,601 @@
/* The table has its own viewport; platform navigation opens on demand. */
.match-shell {
height: 100dvh;
overflow: hidden;
}
.match-shell .main-shell {
margin: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.match-shell .topbar {
height: 52px;
flex: 0 0 52px;
padding: 0 18px;
gap: 14px;
background: var(--surface);
}
.match-shell .mobile-logo {
display: block;
width: 128px;
margin-right: auto;
}
.match-shell .header-right {
gap: 12px;
}
.match-shell .header-right .small-avatar {
display: none;
}
.match-shell main {
flex: 1;
min-height: 0;
width: 100%;
max-width: none;
margin: 0;
padding: 0;
overflow: hidden;
}
.match-shell .site-footer {
display: none;
}
.match-shell .sidebar:not(:popover-open) {
display: none;
}
.match-shell .sidebar:popover-open {
display: flex;
position: fixed;
inset: 60px auto 12px 12px;
margin: 0;
height: auto;
width: 260px;
padding: 24px;
border: 1px solid var(--border);
border-radius: 18px;
box-shadow: 0 16px 54px var(--shadow);
z-index: 50;
overflow: auto;
animation: panel-enter 0.18s ease-out;
}
.match-shell .sidebar .brand {
display: flex;
width: 100%;
margin: 0 0 24px;
align-items: flex-start;
}
.match-shell .sidebar .brand-logo {
width: 100%;
}
.match-shell .sidebar .brand small,
.match-shell .sidebar-label,
.match-shell .sidebar nav a span,
.match-shell .sidebar-invite,
.match-shell .sidebar-bottom {
display: block;
}
.match-shell .sidebar nav {
display: block;
width: 100%;
}
.match-shell .sidebar nav a {
display: flex;
align-items: center;
justify-content: flex-start;
flex-direction: row;
gap: 10px;
padding: 14px 12px;
font-size: 13px;
}
.match-shell .sidebar-bottom {
margin-top: auto;
padding-top: 18px;
}
.match-shell .sidebar-invite {
margin-top: 18px;
}
.match-shell .settings-popover {
top: 58px;
}
.match-room {
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
background: radial-gradient(
ellipse at 50% 40%,
var(--soft),
var(--background) 75%
);
}
.match-room .room-page-heading {
display: flex;
align-items: center;
gap: 16px;
margin: 0;
padding: 10px 20px;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
}
.room-page-heading > div:first-child {
min-width: 0;
}
.match-room .room-page-heading .eyebrow {
font-size: 9px;
line-height: 1;
letter-spacing: 1px;
}
.match-room .room-page-heading h1 {
font-size: 17px;
letter-spacing: -0.4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 36vw;
}
.match-turn {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 12px;
background: var(--player-soft);
color: var(--player-ink);
border-radius: 20px;
font-size: 12px;
font-weight: 650;
white-space: nowrap;
}
.match-turn i,
.panel-players i {
display: block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--player-solid);
flex-shrink: 0;
}
.room-tools {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.match-room .room-tools .button {
padding: 9px 12px;
margin: 0;
font-size: 12px;
}
.room-tools .icon-button {
width: 34px;
height: 34px;
display: grid;
place-items: center;
padding: 0;
}
.panel-trigger b {
background: var(--accent);
color: white;
border-radius: 20px;
font-size: 10px;
padding: 2px 6px;
}
.match-room .room-layout {
flex: 1;
min-height: 0;
display: block;
position: relative;
padding: 12px 18px 8px;
transition: padding-right 0.18s ease-out;
}
.match-room .play-area {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
container-type: size;
}
.match-room .parchis-board {
flex: 1;
min-height: 0;
padding: 0;
border: 0;
background: transparent;
display: grid;
grid-template-columns: minmax(0, 1fr) 210px;
gap: 24px;
}
.board-surface {
min-width: 0;
min-height: 0;
display: grid;
place-items: center;
container-type: size;
}
.match-room .parchis-map {
width: min(100cqw, 100cqh);
height: min(100cqw, 100cqh);
max-width: 100%;
max-height: 100%;
filter: drop-shadow(0 10px 18px var(--shadow));
}
.match-room .parchis-controls {
margin: 0;
padding: 20px 16px;
border: 1px solid var(--border);
border-radius: 20px;
align-self: center;
display: flex;
align-items: stretch;
flex-direction: column;
gap: 14px;
box-shadow: 0 4px 24px var(--shadow);
}
.match-room .parchis-controls .dice-widget {
flex-direction: column;
gap: 10px;
}
.match-room .parchis-controls p {
font-size: 12px;
flex: none;
}
.table-players {
display: flex;
flex-direction: column;
gap: 10px;
border-top: 1px solid var(--border);
padding-top: 14px;
}
.table-player {
display: grid;
grid-template-columns: 25px minmax(0, 1fr);
align-items: center;
column-gap: 8px;
opacity: 0.7;
}
.table-player.playing {
opacity: 1;
}
.table-player .mini-avatar {
grid-row: span 2;
width: 25px;
height: 25px;
margin: 0;
border: 0;
font-size: 10px;
background: var(--player-soft);
color: var(--player-ink);
}
.table-player strong {
font-size: 11px;
color: var(--player-ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.table-player small {
font-size: 10px;
color: var(--secondary);
}
.piece-shortcuts {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.piece-shortcuts > span {
width: 100%;
font-size: 10px;
color: var(--secondary);
}
.piece-shortcuts .parchis-token {
width: 32px;
height: 32px;
font-size: 13px;
}
.match-room .board-note {
font-size: 11px;
margin: 8px 0 0;
height: 20px;
flex-shrink: 0;
justify-content: center;
}
.match-room .board-note > span:last-child {
display: none;
}
.match-room .parchis-board + .board-note {
display: none;
}
.match-room .board-container {
padding: 8px;
margin: auto;
max-width: none;
width: min(100cqw, calc(100cqh - 34px));
aspect-ratio: 1;
flex-shrink: 0;
}
.match-room .board-grid {
height: 100%;
grid-template-rows: repeat(8, minmax(0, 1fr));
}
.match-room .chess-piece {
font-size: clamp(16px, 8cqmin, 80px);
}
.match-room .board-square {
aspect-ratio: auto;
}
.match-room .promotion {
position: absolute;
bottom: 15px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
}
.match-room .room-panel:not(:popover-open) {
display: none;
}
.match-room .room-panel:popover-open {
display: flex;
flex-direction: column;
width: 310px;
max-width: calc(100vw - 24px);
position: fixed;
inset: 124px 18px 14px auto;
height: auto;
margin: 0;
z-index: 20;
background: var(--surface);
border-radius: 18px;
box-shadow: 0 6px 30px var(--shadow);
animation: panel-enter 0.18s ease-out;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 8px;
}
.panel-heading strong {
font-size: 14px;
}
.panel-players {
display: flex;
gap: 8px 12px;
flex-wrap: wrap;
padding: 0 16px 10px;
}
.panel-players span {
display: flex;
align-items: center;
gap: 5px;
color: var(--player-ink);
font-size: 10px;
}
.match-room .room-tabs {
flex-shrink: 0;
}
.match-room .chat-messages,
.match-room .room-rules,
.match-room .event-list {
height: auto;
min-height: 0;
flex: 1;
overflow: auto;
}
.match-room .chat-form {
flex-shrink: 0;
}
.match-room .waiting-card {
height: 100%;
max-width: 800px;
margin: auto;
overflow: auto;
padding: 24px;
}
.match-room .result-banner {
padding: 12px 20px;
margin: 0;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.match-room .result-banner .empty-symbol {
display: none;
}
.match-room .result-banner h2 {
font-size: 15px;
}
.match-room .result-banner p {
font-size: 12px;
}
.match-room .result-banner .button {
padding: 8px 12px;
margin: 0;
}
.match-room .form-error {
margin: 8px 18px 0;
flex-shrink: 0;
}
@media (min-width: 850px) {
.match-room .room-layout:has(.room-panel:popover-open) {
padding-right: 346px;
}
.match-room .room-layout:has(.room-panel:popover-open) .parchis-board {
grid-template-columns: minmax(0, 1fr) 180px;
gap: 12px;
}
}
@media (min-width: 850px) and (max-width: 1199px) {
.match-room .room-layout:has(.room-panel:popover-open) .parchis-board {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto;
}
.match-room .room-layout:has(.room-panel:popover-open) .parchis-controls {
width: 100%;
padding: 10px 14px;
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 6px 14px;
}
.match-room
.room-layout:has(.room-panel:popover-open)
.parchis-controls
> .eyebrow,
.match-room .room-layout:has(.room-panel:popover-open) .table-players {
display: none;
}
.match-room .room-layout:has(.room-panel:popover-open) .dice-widget {
flex-direction: row;
grid-row: span 2;
}
}
@media (max-width: 800px) and (orientation: portrait) {
.match-room .parchis-board {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto;
gap: 10px;
}
.match-room .parchis-controls {
width: 100%;
padding: 10px 12px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px 12px;
align-self: stretch;
}
.match-room .parchis-controls > .eyebrow {
display: none;
}
.match-room .parchis-controls .dice-widget {
flex-direction: row;
gap: 8px;
grid-row: span 2;
}
.match-room .parchis-controls .dice-widget .dice-stage {
width: 54px;
height: 65px;
--edge: 36px;
--depth: 18px;
}
.match-room .parchis-controls .dice-widget .dice-face {
padding: 6px;
gap: 2px;
border-radius: 6px;
}
.match-room .parchis-controls .button {
font-size: 11px;
padding: 10px;
}
.match-room .parchis-controls .dice-instruction {
font-size: 11px;
align-self: center;
}
.match-room .table-players {
display: none;
}
.match-room .piece-shortcuts {
gap: 6px;
}
.match-room .piece-shortcuts > span {
width: auto;
}
}
@media (max-width: 650px) {
.match-shell .topbar {
padding: 0 12px;
gap: 10px;
}
.match-shell .header-right {
gap: 8px;
}
.match-shell .connection {
font-size: 0;
gap: 0;
}
.match-shell .mobile-logo {
width: 114px;
}
.match-room .room-page-heading {
padding: 8px 12px;
gap: 6px 8px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
}
.match-room .room-page-heading > div:first-child {
grid-row: 1;
grid-column: 1;
}
.match-room .room-tools {
grid-row: 1;
grid-column: 2;
}
.match-room .room-page-heading h1 {
font-size: 14px;
max-width: 45vw;
}
.match-turn {
padding: 5px 0;
font-size: 11px;
grid-row: 2;
grid-column: 1 / -1;
width: 100%;
background: transparent;
}
.room-tools {
gap: 4px;
}
.room-tools .fullscreen-trigger {
display: none;
}
.match-room .panel-trigger {
padding: 8px;
}
.panel-trigger > span {
display: none;
}
.match-room .room-layout {
padding: 10px 8px 8px;
}
.match-room .room-panel:popover-open {
inset: 142px 8px 8px auto;
width: 340px;
}
.match-room .board-note {
font-size: 10px;
height: auto;
}
}
@media (orientation: landscape) and (max-height: 520px) {
.match-shell .topbar {
height: 44px;
flex-basis: 44px;
}
.match-room .room-page-heading {
padding: 5px 12px;
}
.match-room .parchis-board {
gap: 12px;
grid-template-columns: minmax(0, 1fr) 180px;
}
.match-room .parchis-controls {
padding: 10px 12px;
gap: 8px;
}
.match-room .table-players {
display: none;
}
.match-room .parchis-controls .dice-widget {
flex-direction: row;
gap: 8px;
}
.match-room .parchis-controls .dice-widget .dice-stage {
--edge: 36px;
--depth: 18px;
width: 48px;
height: 60px;
}
.match-room .parchis-controls .button {
padding: 8px;
font-size: 10px;
}
}

@ -0,0 +1,13 @@
<script lang="ts">
import { page } from '$app/state';
</script>
<svelte:head><title>No encontramos esta mesa · Juegoland</title></svelte:head
>
<div class="empty-panel">
<span class="empty-symbol">♧</span>
<h1>{page.error?.status ?? 404}</h1>
<h2>{page.error?.message ?? 'No encontramos esta página.'}</h2>
<p>Vuelve al catálogo para encontrar tu próxima partida.</p>
<a href="/" class="button primary">Volver al catálogo</a>
</div>

@ -0,0 +1,9 @@
import { hasCurrentTermsCookie } from '../../shared/terms.mjs';
export function load({ cookies, setHeaders }) {
setHeaders({ 'cache-control': 'private, no-store' });
// This avoids a popup flash for returning visitors. It never authorizes play.
return {
hasTermsCookie: hasCurrentTermsCookie(cookies.get('juegoland_terms') ?? ''),
};
}

@ -0,0 +1,176 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { initialize, session, signOut } from '#lib/client.svelte.ts';
import Icon from '#lib/components/Icon.svelte';
import SoundControls from '#lib/components/SoundControls.svelte';
import { installSound } from '#lib/sound.svelte.ts';
import { installAppearance } from '#lib/theme.svelte.ts';
import ThemeControls from '#lib/components/ThemeControls.svelte';
import BrandMark from '#lib/components/BrandMark.svelte';
import AvatarImage from '#lib/components/AvatarImage.svelte';
import { brand } from '../../shared/brand.mjs';
import '../app.css';
import '../appearance.css';
import '../match.css';
import '../design.css';
import '../terms.css';
import TermsConsent from '#lib/components/TermsConsent.svelte';
import { terms } from '../../shared/terms.mjs';
let { children, data } = $props();
let startupError = $state('');
const inRoom = $derived(page.url.pathname.startsWith('/salas/'));
onMount(() => {
const stopSound = installSound();
const stopAppearance = installAppearance();
initialize().catch(() => {
startupError =
'No se pudo conectar. Comprueba que el servidor está en marcha.';
});
return () => {
stopSound();
stopAppearance();
};
});
async function logout() {
try {
await signOut();
await goto('/');
} catch {
startupError = 'No se pudo cerrar la sesión. Inténtalo de nuevo.';
}
}
</script>
<svelte:head>
<meta property="og:type" content="website" />
<meta property="og:locale" content="es_ES" />
<meta property="og:site_name" content="Juegoland" />
<meta property="og:title" content="Juegoland · ¿Echamos una partida?" />
<meta
property="og:description"
content="Juegos de mesa online para jugar con tu gente. Entra como invitado, crea una mesa e invita a tus amigos."
/>
<meta property="og:url" content={brand.invitationOrigin + '/'} />
<meta
property="og:image"
content={brand.invitationOrigin + '/branding/juegoland-share-v1.png'}
/>
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:type" content="image/png" />
<meta
property="og:image:alt"
content="Juegoland. ¿Echamos una partida? Ajedrez, damas, parchís, go y la oca para jugar en compañía."
/>
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Juegoland · ¿Echamos una partida?" />
<meta
name="twitter:description"
content="Entra, invita y juega en compañía. Ajedrez, damas, parchís, go y la oca."
/>
<meta
name="twitter:image"
content={brand.invitationOrigin + '/branding/juegoland-share-v1.png'}
/>
</svelte:head>
<div class="app-shell" class:match-shell={inRoom}>
<aside
class="sidebar"
id="platform-menu"
popover={inRoom ? 'auto' : undefined}
>
{#if inRoom}<button
class="settings-trigger close-platform-menu"
aria-label="Cerrar menú principal"
popovertarget="platform-menu"
popovertargetaction="hide"><Icon name="close" /></button
>{/if}
<a class="brand" href="/"><BrandMark /><small>{brand.tagline}</small></a>
{#if !inRoom}<div class="sidebar-label">Tu próxima ronda</div>{/if}
<nav aria-label="Navegación principal">
<a href="/" class:active={page.url.pathname === '/'}
><Icon name="grid" /><span>Descubrir juegos</span></a
>
<a href="/perfil" class:active={page.url.pathname === '/perfil'}
><Icon name="trophy" /><span>Mis partidas</span></a
>
<a href="/ayuda" class:active={page.url.pathname === '/ayuda'}
><Icon name="help" /><span>Reglas y ayuda</span></a
>
</nav>
<div class="sidebar-invite">
<span class="invite-ornament">♧</span><strong>Mejor en compañía.</strong>
<p>Crea una mesa y comparte el código con tus amigos.</p>
<a href="/ayuda">Así funciona <Icon name="arrow" size={15} /></a>
</div>
<div class="sidebar-bottom">
{#if session.user}<a class="user-card" href="/perfil"
><span class="avatar"><AvatarImage person={session.user} /></span
><span
><strong>{session.user.nick}</strong><small
>Tu perfil de jugador</small
></span
></a
><button class="logout" onclick={logout}
><Icon name="logout" size={16} />Cerrar sesión</button
>{:else}<a class="button primary full" href="/cuenta"
>Únete a la mesa <Icon name="arrow" size={16} /></a
>
<p>Un lugar para jugar y volver.</p>{/if}
</div>
</aside>
<div class="main-shell">
<header class="topbar">
{#if inRoom}<button
class="settings-trigger menu-trigger"
popovertarget="platform-menu"
aria-label="Abrir menú principal"
title="Menú principal"><Icon name="menu" /></button
>{/if}
<a class="mobile-logo" href="/" aria-label={brand.name}><BrandMark /></a>
{#if !inRoom}<div class="header-search">
<Icon name="search" size={18} /><input
aria-label="Buscar un juego"
placeholder="¿A qué te apetece jugar?"
bind:value={session.search}
onkeydown={(e) => {
if (e.key === 'Enter' && page.url.pathname !== '/') goto('/');
}}
/><span class="search-hint">⌕</span>
</div>{/if}
<div class="header-right">
<ThemeControls />
<SoundControls />
<span class="connection"
><i class:online={session.connected}></i>{session.user
? session.connected
? 'Conectado'
: 'Reconectando…'
: 'Vamos a jugar'}</span
>{#if session.user}<a
class="avatar small-avatar"
href="/perfil"
aria-label="Mi perfil"><AvatarImage person={session.user} /></a
>{:else}<a class="header-login" href="/cuenta"
>Entrar <Icon name="arrow" size={16} /></a
>{/if}
</div>
</header>
{#if startupError}<div class="service-alert" role="alert">
{startupError}<button onclick={() => location.reload()}
>Reintentar</button
>
</div>{/if}
<main>{@render children()}</main>
<footer class="site-footer">
<span class="footer-author"
>Servicio personal en desarrollo de {terms.author}</span
><a href="/ayuda">Reglas y ayuda</a>
</footer>
</div>
</div>
<TermsConsent hasTermsCookie={data.hasTermsCookie} />

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.