commit 04f1dff1c79678359b9abca88698e15e54a4225d Author: dev Date: Sun Oct 4 02:01:03 2026 +0200 Implementa Juegoland con juegos online y despliegue nativo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3b6d356 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..01c789d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto +*.sh text eol=lf +*.service text eol=lf +*.timer text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58d3f8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +.npm-cache/ +.svelte-kit/ +build/ +.data/ +.env +.env.* +!.env.example +coverage/ +*.log +artifacts/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..df86173 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +.npm-cache +.svelte-kit +.data +build +artifacts +package-lock.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..478c90f --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..086fd63 --- /dev/null +++ b/README.md @@ -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). diff --git a/db/001_initial.sql b/db/001_initial.sql new file mode 100644 index 0000000..761f989 --- /dev/null +++ b/db/001_initial.sql @@ -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); diff --git a/db/002_guest_accounts.sql b/db/002_guest_accounts.sql new file mode 100644 index 0000000..9aa2656 --- /dev/null +++ b/db/002_guest_accounts.sql @@ -0,0 +1 @@ +ALTER TABLE users ALTER COLUMN email DROP NOT NULL; diff --git a/db/003_system_events.sql b/db/003_system_events.sql new file mode 100644 index 0000000..61877ac --- /dev/null +++ b/db/003_system_events.sql @@ -0,0 +1,2 @@ +-- Automatic room expiry has no human actor. +ALTER TABLE match_events ALTER COLUMN actor_id DROP NOT NULL; diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..6b2747a --- /dev/null +++ b/deploy/README.md @@ -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-. +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. diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh new file mode 100644 index 0000000..a768902 --- /dev/null +++ b/deploy/activate-release.sh @@ -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" diff --git a/deploy/backup.env.example b/deploy/backup.env.example new file mode 100644 index 0000000..732a943 --- /dev/null +++ b/deploy/backup.env.example @@ -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 diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100644 index 0000000..e9a14c0 --- /dev/null +++ b/deploy/backup.sh @@ -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. diff --git a/deploy/configure-production.py b/deploy/configure-production.py new file mode 100644 index 0000000..17add07 --- /dev/null +++ b/deploy/configure-production.py @@ -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.') diff --git a/deploy/deploy-native.ps1 b/deploy/deploy-native.ps1 new file mode 100644 index 0000000..bb3cae8 --- /dev/null +++ b/deploy/deploy-native.ps1 @@ -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' } +} diff --git a/deploy/external-nginx.conf.example b/deploy/external-nginx.conf.example new file mode 100644 index 0000000..51fdd04 --- /dev/null +++ b/deploy/external-nginx.conf.example @@ -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; +} diff --git a/deploy/game.env.example b/deploy/game.env.example new file mode 100644 index 0000000..4f4e824 --- /dev/null +++ b/deploy/game.env.example @@ -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 diff --git a/deploy/mesa-backup.service b/deploy/mesa-backup.service new file mode 100644 index 0000000..fc8eef9 --- /dev/null +++ b/deploy/mesa-backup.service @@ -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 diff --git a/deploy/mesa-backup.timer b/deploy/mesa-backup.timer new file mode 100644 index 0000000..abcdc13 --- /dev/null +++ b/deploy/mesa-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Juegoland - Daily backup + +[Timer] +OnCalendar=*-*-* 03:15:00 Europe/Madrid +Persistent=true +RandomizedDelaySec=300 + +[Install] +WantedBy=timers.target diff --git a/deploy/mesa-game.service b/deploy/mesa-game.service new file mode 100644 index 0000000..c4a2aeb --- /dev/null +++ b/deploy/mesa-game.service @@ -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 diff --git a/deploy/provision-base.sh b/deploy/provision-base.sh new file mode 100644 index 0000000..89081c1 --- /dev/null +++ b/deploy/provision-base.sh @@ -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 diff --git a/deploy/server-status.md b/deploy/server-status.md new file mode 100644 index 0000000..7056be4 --- /dev/null +++ b/deploy/server-status.md @@ -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. diff --git a/deploy/smoke-production.mjs b/deploy/smoke-production.mjs new file mode 100644 index 0000000..f73e08e --- /dev/null +++ b/deploy/smoke-production.mjs @@ -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(); +} diff --git a/deploy/web.env.example b/deploy/web.env.example new file mode 100644 index 0000000..bf760aa --- /dev/null +++ b/deploy/web.env.example @@ -0,0 +1,5 @@ +NODE_ENV=production +HOST=127.0.0.1 +PORT=3000 +PROTOCOL_HEADER=x-forwarded-proto +HOST_HEADER=x-forwarded-host diff --git a/docs/conditions-acceptance.md b/docs/conditions-acceptance.md new file mode 100644 index 0000000..3281de7 --- /dev/null +++ b/docs/conditions-acceptance.md @@ -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. diff --git a/docs/design-direction.md b/docs/design-direction.md new file mode 100644 index 0000000..cb3a549 --- /dev/null +++ b/docs/design-direction.md @@ -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. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..5d235d0 --- /dev/null +++ b/docs/design.md @@ -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. diff --git a/docs/frontend-design-prompt.md b/docs/frontend-design-prompt.md new file mode 100644 index 0000000..8f8e997 --- /dev/null +++ b/docs/frontend-design-prompt.md @@ -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. diff --git a/docs/logo-transparency.md b/docs/logo-transparency.md new file mode 100644 index 0000000..6d7ac7d --- /dev/null +++ b/docs/logo-transparency.md @@ -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. diff --git a/docs/player-experience.md b/docs/player-experience.md new file mode 100644 index 0000000..ff68e46 --- /dev/null +++ b/docs/player-experience.md @@ -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. diff --git a/docs/room-layout.md b/docs/room-layout.md new file mode 100644 index 0000000..8d020c1 --- /dev/null +++ b/docs/room-layout.md @@ -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. diff --git a/docs/typography.md b/docs/typography.md new file mode 100644 index 0000000..7531445 --- /dev/null +++ b/docs/typography.md @@ -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. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8ece67a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2026 @@ +{ + "name": "mesa-abierta", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mesa-abierta", + "version": "0.1.0", + "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" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-6.0.0.tgz", + "integrity": "sha512-s7muM4JmJ75OZ8o0G4VRhrEKdi8twuEm3t2o/qQsdlm12hKaX7S/CuUBuw3n983wB/IFBSpzG2ugX0xdGzbKwg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^3.0.0-next.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-3.0.0.tgz", + "integrity": "sha512-dmP6gjz41DAT7erONGW8FdTWjmsmOJ/SaWVEsuEc1MXDcpH6YV+Pt98biZFPuinH4LldJPmScxDqiHwCDLRCrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1", + "@standard-schema/spec": "^1.1.0", + "cookie": "^2.0.1", + "devalue": "^5.9.4", + "esm-env": "^1.2.2", + "magic-string": "^1.2.3", + "mrmime": "^2.0.1", + "sirv": "^3.0.2" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=22.17" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "svelte": "^5.57.1", + "typescript": "^6.0.0", + "vite": "^8.0.12" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz", + "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chess.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/chess.js/-/chess.js-1.4.0.tgz", + "integrity": "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw==", + "license": "BSD-2-Clause" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/engine.io": { + "version": "6.6.11", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz", + "integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.7.tgz", + "integrity": "sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-4.1.1.tgz", + "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^5.0.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/socket.io": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.4.tgz", + "integrity": "sha512-9ZV9zg8vN8hO3Y25DhR7nUkJ1VDnBhq5O/JrXhFT9s6+F0gqytSASTHVfCFc2mJXaTfARroErplXlzFWwETZqQ==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.4.tgz", + "integrity": "sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.57.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", + "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/tinybench": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz", + "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", + "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9673dc0 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..ac7d5a4 --- /dev/null +++ b/scripts/dev.mjs @@ -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()); diff --git a/scripts/generate-sounds.mjs b/scripts/generate-sounds.mjs new file mode 100644 index 0000000..00e1943 --- /dev/null +++ b/scripts/generate-sounds.mjs @@ -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.`, +); diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs new file mode 100644 index 0000000..5d7219b --- /dev/null +++ b/scripts/migrate.mjs @@ -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(); +} diff --git a/server/auth.mjs b/server/auth.mjs new file mode 100644 index 0000000..581e566 --- /dev/null +++ b/server/auth.mjs @@ -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' : ''}`; +} diff --git a/server/avatar.mjs b/server/avatar.mjs new file mode 100644 index 0000000..31041a1 --- /dev/null +++ b/server/avatar.mjs @@ -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), + }; +} diff --git a/server/games/checkers.mjs b/server/games/checkers.mjs new file mode 100644 index 0000000..925d070 --- /dev/null +++ b/server/games/checkers.mjs @@ -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; + }, +}; diff --git a/server/games/chess.mjs b/server/games/chess.mjs new file mode 100644 index 0000000..16e79f3 --- /dev/null +++ b/server/games/chess.mjs @@ -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(), + }; + }, +}; diff --git a/server/games/go.mjs b/server/games/go.mjs new file mode 100644 index 0000000..c81c4a2 --- /dev/null +++ b/server/games/go.mjs @@ -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, + }; + }, +}; diff --git a/server/games/oca.mjs b/server/games/oca.mjs new file mode 100644 index 0000000..ea52603 --- /dev/null +++ b/server/games/oca.mjs @@ -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 }; + }, +}; diff --git a/server/games/parchis-classic.mjs b/server/games/parchis-classic.mjs new file mode 100644 index 0000000..c98123b --- /dev/null +++ b/server/games/parchis-classic.mjs @@ -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, + }; + }, +}; diff --git a/server/games/parchis.mjs b/server/games/parchis.mjs new file mode 100644 index 0000000..62e21c7 --- /dev/null +++ b/server/games/parchis.mjs @@ -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] }; + }, +}; diff --git a/server/games/registry.mjs b/server/games/registry.mjs new file mode 100644 index 0000000..292b250 --- /dev/null +++ b/server/games/registry.mjs @@ -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; +} diff --git a/server/games/turns.mjs b/server/games/turns.mjs new file mode 100644 index 0000000..809d568 --- /dev/null +++ b/server/games/turns.mjs @@ -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; +} diff --git a/server/index.mjs b/server/index.mjs new file mode 100644 index 0000000..f31901a --- /dev/null +++ b/server/index.mjs @@ -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); diff --git a/server/platform.mjs b/server/platform.mjs new file mode 100644 index 0000000..849cc39 --- /dev/null +++ b/server/platform.mjs @@ -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, + }; + }), + }; + } +} diff --git a/server/production.mjs b/server/production.mjs new file mode 100644 index 0000000..120fb75 --- /dev/null +++ b/server/production.mjs @@ -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); diff --git a/server/service.mjs b/server/service.mjs new file mode 100644 index 0000000..792a8fe --- /dev/null +++ b/server/service.mjs @@ -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)); + }, + }; +} diff --git a/server/store.mjs b/server/store.mjs new file mode 100644 index 0000000..757ab26 --- /dev/null +++ b/server/store.mjs @@ -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; +} diff --git a/server/terms.mjs b/server/terms.mjs new file mode 100644 index 0000000..f781ae4 --- /dev/null +++ b/server/terms.mjs @@ -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(), + }; +} diff --git a/shared/animations.mjs b/shared/animations.mjs new file mode 100644 index 0000000..082e8c5 --- /dev/null +++ b/shared/animations.mjs @@ -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), + }, +}; diff --git a/shared/avatars.mjs b/shared/avatars.mjs new file mode 100644 index 0000000..c504b43 --- /dev/null +++ b/shared/avatars.mjs @@ -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' }, +]; diff --git a/shared/brand.mjs b/shared/brand.mjs new file mode 100644 index 0000000..1d68461 --- /dev/null +++ b/shared/brand.mjs @@ -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.', +}; diff --git a/shared/catalog.mjs b/shared/catalog.mjs new file mode 100644 index 0000000..95bbfcb --- /dev/null +++ b/shared/catalog.mjs @@ -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 } : {}), +})); diff --git a/shared/invitations.mjs b/shared/invitations.mjs new file mode 100644 index 0000000..bbdfdc8 --- /dev/null +++ b/shared/invitations.mjs @@ -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; +} diff --git a/shared/oca-board.mjs b/shared/oca-board.mjs new file mode 100644 index 0000000..161e61b --- /dev/null +++ b/shared/oca-board.mjs @@ -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; +} diff --git a/shared/player-colors.mjs b/shared/player-colors.mjs new file mode 100644 index 0000000..477bfd6 --- /dev/null +++ b/shared/player-colors.mjs @@ -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; +} diff --git a/shared/room-lifecycle.mjs b/shared/room-lifecycle.mjs new file mode 100644 index 0000000..73e77dd --- /dev/null +++ b/shared/room-lifecycle.mjs @@ -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); +} diff --git a/shared/sound-event.mjs b/shared/sound-event.mjs new file mode 100644 index 0000000..793b80d --- /dev/null +++ b/shared/sound-event.mjs @@ -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'; +} diff --git a/shared/sounds.mjs b/shared/sounds.mjs new file mode 100644 index 0000000..ad2aab5 --- /dev/null +++ b/shared/sounds.mjs @@ -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'), + }, +}; diff --git a/shared/terms.mjs b/shared/terms.mjs new file mode 100644 index 0000000..1b0f6e2 --- /dev/null +++ b/shared/terms.mjs @@ -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.', +}; diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..5a793d6 --- /dev/null +++ b/src/app.css @@ -0,0 +1,2473 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;450;500;550;600;650;700&family=Manrope:wght@400;500;600;650;700;750;800&display=swap'); +/* Theme tokens also serve as the integration point for future palettes. */ +:root { + color-scheme: light; + --text: #202c41; + --background: #f5f7fc; + --surface: #ffffff; + --soft: #edf2fa; + --hero: #e8efff; + --accent: #0759d9; + --accent-hover: #0046b8; + --accent-text: #0759c4; + --secondary: #58677e; + --border: #dde4f0; + --danger-bg: #fff0ec; + --danger-text: #a32a27; + --shadow: #10274a1c; +} +:root[data-palette='forest'] { --accent:#14744f; --accent-hover:#0c5c3d; --accent-text:#116942; --hero:#e4f2e9; } +:root[data-palette='violet'] { --accent:#7742bf; --accent-hover:#62329f; --accent-text:#6836af; --hero:#f0e9fc; } +:root[data-theme='dark'] { + color-scheme: dark; + --text:#e8edf7; --background:#101827; --surface:#192438; --soft:#223149; --hero:#1c3052; + --accent:#397aee; --accent-hover:#2b69d3; --accent-text:#9bbeff; --secondary:#adbbd0; --border:#34445d; + --danger-bg:#442b32; --danger-text:#ffb5a9; --shadow:#0006; +} +:root[data-theme='dark'][data-palette='forest'] { --accent:#177f5a; --accent-hover:#106e4b; --accent-text:#7adab1; --hero:#183a30; } +:root[data-theme='dark'][data-palette='violet'] { --accent:#8554d1; --accent-hover:#7744c5; --accent-text:#d2b4ff; --hero:#30244c; } +:root { + font-family: 'DM Sans', system-ui, sans-serif; + color: var(--text); + background: var(--background); + font-synthesis: none; + --green: var(--accent); + --green-dark: var(--accent-hover); + --muted: var(--secondary); + --line: var(--border); + --paper: var(--surface); + --terracotta: #bc7255; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input, +select, +textarea { + font: inherit; +} +button, +a, +input, +select, +textarea { + -webkit-tap-highlight-color: transparent; +} +button, +a { + touch-action: manipulation; +} +button { + cursor: pointer; +} +button:disabled { + cursor: not-allowed; + opacity: 0.5; +} +a { + color: inherit; + text-decoration: none; +} +button { + color: inherit; +} +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 3px solid #b88f59; + outline-offset: 4px; +} +button svg, +a svg { + flex-shrink: 0; +} +h1, +h2, +h3, +p { + margin: 0; +} +h1, +h2, +h3 { + font-family: 'Manrope', 'DM Sans', sans-serif; +} +h2 { + font-size: 23px; + letter-spacing: -0.8px; + font-weight: 750; +} +h3 { + font-size: 19px; + letter-spacing: -0.45px; +} +p { + line-height: 1.65; +} +.muted { + color: var(--muted); +} +.small { + font-size: 13px; +} +.eyebrow { + font-size: 10px; + letter-spacing: 1.8px; + font-weight: 700; + color: #93998c; + display: block; + margin-bottom: 9px; +} +.accent-dot { + color: var(--terracotta); +} +.app-shell { + display: flex; + min-height: 100vh; +} +.sidebar { + width: 230px; + flex-shrink: 0; + background: #fffefa; + border-right: 1px solid var(--line); + padding: 35px 22px 20px; + position: fixed; + inset: 0 auto 0 0; + display: flex; + flex-direction: column; + z-index: 20; +} +.brand { + display: flex; + align-items: center; + gap: 11px; + margin: 0 7px 55px; + font-family: 'Manrope', sans-serif; + font-size: 23px; + font-weight: 800; + letter-spacing: -1px; + line-height: 1.04; +} +.brand-mark { + width: 42px; + height: 45px; + border-radius: 13px; + background: var(--green); + color: #fffefa; + font-size: 35px; + display: grid; + place-items: center; + position: relative; + letter-spacing: -2px; + padding-bottom: 7px; +} +.brand-mark > span { + font-size: 15px; + position: absolute; + right: 7px; + bottom: 3px; + line-height: 1; +} +.brand-line { + display: block; +} +.brand-dot { + color: var(--terracotta); +} +.sidebar-label { + font-size: 9px; + color: #a0a396; + letter-spacing: 1.3px; + font-weight: 700; + margin: 0 13px 15px; +} +.sidebar nav { + display: grid; + gap: 7px; +} +.sidebar nav a { + display: flex; + align-items: center; + gap: 12px; + padding: 13px 14px; + border-radius: 8px; + font-size: 13px; + color: #82877c; + font-weight: 550; + transition: + background 0.15s, + color 0.15s; +} +.sidebar nav a.active { + background: #eaf0e7; + color: var(--green); + font-weight: 700; +} +.sidebar nav a:hover { + background: #f0f2eb; + color: var(--green); +} +.sidebar-invite { + border: 1px solid #e7e5d7; + background: #f5f4ed; + border-radius: 11px; + padding: 18px; + margin: 42px 3px 20px; + position: relative; + overflow: hidden; +} +.invite-ornament { + font-family: Georgia, serif; + color: #a1ac96; + font-size: 40px; + display: block; + margin-bottom: 8px; + line-height: 1; +} +.sidebar-invite strong { + font-size: 13px; +} +.sidebar-invite p { + font-size: 11px; + color: #929587; + margin: 7px 0 12px; +} +.sidebar-invite a { + display: flex; + align-items: center; + gap: 6px; + color: #657858; + font-size: 11px; + font-weight: 700; +} +.sidebar-bottom { + margin-top: auto; + padding-top: 30px; +} +.sidebar-bottom > p { + text-align: center; + color: #9a9e91; + font-size: 10px; + margin-top: 10px; +} +.user-card { + display: flex; + align-items: center; + gap: 10px; + border-top: 1px solid var(--line); + padding: 17px 3px 10px; +} +.avatar { + width: 39px; + height: 39px; + border-radius: 50%; + display: grid; + place-items: center; + background: #ebd9b9; + color: #816845; + font-size: 12px; + font-weight: 700; + flex-shrink: 0; +} +.user-card strong { + font-size: 12px; + display: block; +} +.user-card small { + font-size: 10px; + color: #8a9083; + display: block; + margin-top: 3px; +} +.logout { + border: 0; + background: none; + display: flex; + align-items: center; + gap: 7px; + margin-left: 49px; + color: #979c91; + font-size: 10px; + padding: 4px 0; +} +.main-shell { + margin-left: 230px; + width: calc(100% - 230px); +} +.topbar { + height: 83px; + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 46px; + gap: 24px; + background: #f7f6f2; +} +.header-search { + display: flex; + align-items: center; + gap: 10px; + color: #9ca296; + max-width: 370px; + width: 100%; +} +.header-search input { + background: transparent; + border: 0; + padding: 9px 0; + color: #536151; + width: 100%; + font-size: 12px; + outline-offset: 0; +} +.header-search input::placeholder { + color: #949b8e; +} +.search-hint { + border: 1px solid #dcdfd5; + border-radius: 5px; + width: 24px; + height: 24px; + font-size: 16px; + text-align: center; +} +.header-right { + display: flex; + align-items: center; + gap: 22px; + white-space: nowrap; +} +.connection { + font-size: 11px; + color: #929989; + display: flex; + align-items: center; + gap: 7px; +} +.connection i { + height: 6px; + width: 6px; + border-radius: 50%; + background: #c0c5b7; +} +.connection i.online { + background: #77a078; +} +.small-avatar { + height: 33px; + width: 33px; + font-size: 10px; +} +.header-login { + display: flex; + align-items: center; + gap: 9px; + font-size: 12px; + font-weight: 700; + color: var(--green); +} +main { + max-width: 1440px; + margin: auto; + padding: 34px 46px 40px; + min-height: calc(100vh - 153px); +} +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + font-size: 12px; + font-weight: 650; + padding: 13px 19px; + border-radius: 7px; + border: 1px solid transparent; + transition: + background 0.15s, + transform 0.15s; +} +.button:hover:not(:disabled) { + transform: translateY(-1px); +} +.button.primary { + background: var(--green); + color: #fffefa; + box-shadow: 0 4px 8px #254d2f09; +} +.button.primary:hover:not(:disabled) { + background: var(--green-dark); +} +.button.secondary { + background: var(--paper); + border-color: #dfe3d7; + color: var(--green); +} +.button.danger { + background: #f6e8e2; + border-color: #eccfc3; + color: #a55840; +} +.full { + width: 100%; +} +.icon-button { + border: 0; + background: none; + padding: 8px; + display: grid; + place-items: center; + border-radius: 5px; +} +.icon-button:hover { + background: #f1f2eb; +} +.hero { + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 13px; + background: #eeefe6; + min-height: 319px; + padding: 36px 40px; + position: relative; + overflow: hidden; + border: 1px solid #e5e8dc; +} +.hero-copy { + z-index: 2; + max-width: 64%; +} +.hero-eyebrow { + font-size: 9px; + letter-spacing: 2px; + font-weight: 700; + color: #70806a; + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 17px; +} +.hero-eyebrow > span { + width: 5px; + height: 5px; + border-radius: 50%; + background: #92a37f; +} +.hero h1 { + font-size: 44px; + line-height: 1.13; + letter-spacing: -2px; + color: #253c2c; + font-weight: 750; + margin-bottom: 17px; +} +.hero h1 em { + font-family: Georgia, serif; + font-weight: normal; + color: #708967; +} +.hero-copy > p { + font-size: 12px; + color: #858e7c; + line-height: 1.8; + margin-bottom: 21px; +} +.hero-caption { + font-size: var(--type-secondary); + color: #909886; + display: block; + margin-top: 12px; +} +.hero-visual { + width: 310px; + height: 260px; + position: relative; + flex-shrink: 0; + align-self: center; + margin-right: 2px; +} +.hero-board { + position: absolute; + inset: 5px 0 0; +} +.hero-note { + position: absolute; + bottom: 0; + left: 3px; + z-index: 3; + display: flex; + align-items: center; + gap: 12px; + padding: 13px 18px; + border-radius: 9px; + background: #fffefa; + box-shadow: 0 6px 25px #40513c12; + transform: rotate(3deg); +} +.hero-note > span { + font-family: Georgia, serif; + font-size: 28px; + color: #7d9173; +} +.hero-note strong { + display: block; + font-size: 11px; +} +.hero-note small { + display: block; + font-size: 9px; + color: #9ca191; + margin-top: 3px; +} +.hero-star { + position: absolute; + right: -8px; + top: 8px; + color: #a0b394; + font-size: 40px; +} +.hero-loop { + position: absolute; + border: 1px solid #d7decb; + border-radius: 50%; + width: 300px; + height: 300px; + left: -5px; + top: -20px; +} +.loop-two { + width: 380px; + height: 380px; + left: -45px; + top: -60px; +} +.welcome-strip { + display: flex; + align-items: center; + gap: 11px; + font-size: var(--type-body); + color: #929989; + padding: 21px 3px 29px; +} +.welcome-spark { + color: #b39469; + display: grid; + place-items: center; +} +.live-count { + margin-left: auto; + font-size: var(--type-secondary); + color: #809078; + white-space: nowrap; + display: flex; + gap: 7px; + align-items: center; +} +.live-count i { + width: 5px; + height: 5px; + background: #7b9e72; + border-radius: 50%; +} +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 18px; + margin-bottom: 22px; +} +.filter-tabs { + display: flex; + gap: 4px; + background: #efefe8; + border-radius: 7px; + padding: 4px; +} +.filter-tabs button { + border: 0; + background: none; + font-size: 10px; + padding: 7px 12px; + border-radius: 5px; + color: #909685; + font-weight: 550; +} +.filter-tabs button.chosen { + background: #fffefa; + box-shadow: 0 1px 4px #00000008; + color: #486641; +} +.game-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; +} +.game-card { + border-radius: 11px; + border: 1px solid #e4e6dd; + background: var(--paper); + overflow: hidden; + transition: + transform 0.2s, + box-shadow 0.2s; +} +.game-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 28px #263b2410; +} +.art-wrap { + position: relative; +} +.game-category { + position: absolute; + top: 15px; + left: 15px; + border: 1px solid #ffffff90; + background: #ffffffa1; + backdrop-filter: blur(5px); + padding: 5px 9px; + border-radius: 5px; + font-size: var(--type-detail); + color: #77816e; + z-index: 3; +} +.game-card-body { + padding: 19px 19px 0; +} +.game-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} +.game-title h3 { + font-size: var(--type-subtitle); + font-weight: 750; +} +.difficulty { + font-size: var(--type-detail); + color: #97a08e; + background: #f0f2ea; + border-radius: 4px; + padding: 4px 6px; +} +.game-card-body > p { + font-size: 11px; + color: #8c9584; + margin-top: 6px; +} +.game-meta { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 9px; + color: #959d8d; + margin: 17px 0; +} +.game-meta span { + display: flex; + align-items: center; + gap: 5px; +} +.card-bottom { + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid #eceee5; + padding: 14px 0; +} +.game-play { + display: flex; + gap: 8px; + align-items: center; + background: none; + border: 0; + font-size: 11px; + color: var(--green); + font-weight: 700; + padding: 0; +} +.card-bottom > a { + color: #b1b8a8; + padding: 2px; +} +.lobby-section, +.continue-section { + margin-top: 37px; +} +.lobby-section .section-header { + align-items: flex-end; +} +.join-code { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid #e0e3d8; + background: #fffefa; + border-radius: 7px; + padding: 8px 11px; + color: #929b87; +} +.join-code input { + width: 133px; + min-width: 0; + border: 0; + background: none; + outline-offset: 1px; + font-size: var(--type-body); +} +.join-code input::placeholder { + color: #a1a693; +} +.join-code button { + border: 0; + background: none; + color: var(--green); + padding: 0; + display: grid; + place-items: center; +} +.lobby-table { + background: #fffefa; + border: 1px solid #e4e6dd; + border-radius: 10px; + overflow: hidden; +} +.lobby-table-head, +.lobby-row { + display: grid; + grid-template-columns: 2fr 1fr 1.2fr 0.6fr; + align-items: center; + gap: 15px; + padding: 14px 22px; +} +.lobby-table-head { + font-size: var(--type-secondary); + letter-spacing: 1.1px; + color: #a0a895; + border-bottom: 1px solid #eceee4; + background: #fbfcf7; +} +.lobby-row { + padding-top: 17px; + padding-bottom: 17px; + border-bottom: 1px solid #eff0e8; +} +.lobby-row:last-child { + border-bottom: 0; +} +.lobby-game { + display: flex; + align-items: center; + gap: 11px; + min-width: 0; +} +.room-symbol { + font-family: Georgia, serif; + font-size: 27px; + background: #edf1e7; + width: 40px; + height: 40px; + border-radius: 8px; + display: grid; + place-items: center; + color: #768a68; + flex-shrink: 0; +} +.room-symbol.damas { + background: #f5eddf; + color: #a58b65; +} +.room-symbol.parchis { + background: #eeeaf4; + color: #9481a5; +} +.lobby-game strong { + display: block; + font-size: var(--type-body); + font-weight: 600; + overflow-wrap: anywhere; +} +.lobby-game small { + display: block; + font-size: var(--type-secondary); + color: #a0a693; + margin-top: 5px; +} +.player-stack { + display: flex; + align-items: center; +} +.mini-avatar { + width: 25px; + height: 25px; + border: 2px solid #fffefa; + border-radius: 50%; + display: grid; + place-items: center; + background: #e6dac4; + color: #8c7957; + font-size: 9px; + margin-left: -6px; +} +.mini-avatar:first-child { + margin-left: 0; +} +.mini-avatar:nth-child(2) { + background: #d6e3d2; + color: #738970; +} +.player-stack small { + font-size: var(--type-secondary); + color: #a1a891; + margin-left: 7px; +} +.status-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: var(--type-secondary); + color: #8b9a7e; +} +.status-badge i { + width: 5px; + height: 5px; + border-radius: 50%; + background: #95ab84; +} +.lobby-join { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + font-size: var(--type-body); + color: #526e48; + background: none; + border: 0; + font-weight: 600; + white-space: nowrap; +} +.empty-lobby { + text-align: center; + padding: 32px 20px 36px; +} +.empty-lobby h3 { + font-size: var(--type-subtitle); + margin-bottom: 7px; +} +.empty-lobby p { + font-size: var(--type-body); + color: #a0a590; + margin-bottom: 17px; +} +.empty-symbol { + font-size: 33px; + color: #a4b197; + margin-bottom: 11px; +} +.empty-panel { + text-align: center; + padding: 40px; + border: 1px dashed #d6dece; + border-radius: 10px; + color: #889579; +} +.empty-panel h3 { + margin: 10px; +} +.empty-panel p { + margin-bottom: 15px; + font-size: 13px; +} +.bottom-note { + display: flex; + align-items: center; + gap: 17px; + padding: 25px 0 0; + margin-top: 27px; + border-top: 1px solid var(--line); +} +.bottom-note > span { + font-family: Georgia, serif; + font-size: 41px; + color: #abaf99; +} +.bottom-note strong { + font-size: var(--type-body); + color: #76836a; +} +.bottom-note p { + font-size: var(--type-secondary); + color: #9ba28e; + margin-top: 4px; +} +.bottom-note > a { + margin-left: auto; + display: flex; + gap: 8px; + align-items: center; + font-size: var(--type-body); + color: #7d8e6e; + white-space: nowrap; +} +.continue-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} +.continue-card { + display: flex; + align-items: center; + gap: 12px; + background: #fffefa; + border: 1px solid #e5e8da; + border-radius: 9px; + padding: 14px; +} +.continue-card strong { + display: block; + font-size: var(--type-body); +} +.continue-card small { + display: block; + font-size: var(--type-secondary); + color: #8d9781; + margin-top: 5px; +} +.continue-card > svg { + margin-left: auto; + color: var(--green); +} +.site-footer { + max-width: 1440px; + margin: auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 21px 46px 25px; + font-size: var(--type-secondary); + color: #a7ad9c; + border-top: 1px solid var(--line); +} +.site-footer a { + display: flex; + align-items: center; + gap: 7px; +} +.service-alert { + padding: 12px 45px; + background: #fff0d9; + color: #9a6937; + font-size: 12px; + display: flex; + justify-content: space-between; +} +.service-alert button { + background: none; + border: 0; + text-decoration: underline; +} +.modal-backdrop { + position: fixed; + inset: 0; + background: #263a2d55; + backdrop-filter: blur(4px); + z-index: 50; + display: grid; + place-items: center; + padding: 20px; +} +.modal { + background: #fffefa; + border-radius: 14px; + padding: 29px; + max-width: 460px; + width: 100%; + box-shadow: 0 25px 90px #26362b40; + max-height: 90vh; + overflow: auto; +} +.modal-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; + gap: 12px; +} +.modal h2 { + font-size: 22px; +} +form label { + font-size: 11px; + color: #78846d; + font-weight: 600; + display: grid; + gap: 7px; + margin-bottom: 17px; +} +form input, +form select, +form textarea { + border: 1px solid #dfe5d5; + border-radius: 7px; + background: #fffefa; + padding: 12px 13px; + color: #354932; + min-width: 0; + width: 100%; + font-size: 12px; +} +form input::placeholder { + color: #a3ad98; +} +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 15px; +} +.variant-note { + display: flex; + align-items: center; + gap: 7px; + background: #f0f3e9; + padding: 10px; + border-radius: 7px; + color: #7f916c; + font-size: 11px; + margin: -5px 0 18px; +} +.form-error { + color: #af6347; + background: #f9ede5; + border-radius: 7px; + padding: 11px 14px; + font-size: 12px; + margin: 15px 0; +} +.modal .full { + margin-top: 20px; +} +.auth-layout { + display: grid; + grid-template-columns: 1fr 1fr; + max-width: 900px; + margin: 28px auto; + background: #fffefa; + border: 1px solid var(--line); + border-radius: 16px; + overflow: hidden; +} +.auth-art { + background: #edf0e5; + padding: 45px 35px; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 580px; +} +.auth-art h1 { + font-size: 34px; + letter-spacing: -1.4px; + line-height: 1.15; + margin: 20px 0 14px; +} +.auth-art p { + font-size: 12px; + color: #87977a; +} +.auth-art .art-box { + height: 220px; + margin-top: 25px; +} +.auth-form { + padding: 38px 35px; +} +.auth-form h2 { + font-size: 25px; + margin: 22px 0 7px; +} +.auth-form > p { + font-size: 12px; + color: #9ba58f; + margin-bottom: 22px; +} +.auth-tabs { + display: flex; + border-bottom: 1px solid var(--line); + gap: 20px; +} +.auth-tabs button { + background: none; + border: 0; + border-bottom: 2px solid transparent; + padding: 0 0 12px; + font-size: 12px; + color: #a0a88e; +} +.auth-tabs button.chosen { + border-color: var(--green); + color: var(--green); + font-weight: 700; +} +.auth-note { + font-size: 10px; + color: #98a489; + margin-top: 15px; + text-align: center; +} +.page-heading { + margin-bottom: 30px; +} +.page-heading h1 { + font-size: 33px; + letter-spacing: -1.2px; +} +.page-heading p { + font-size: 13px; + color: #919d83; + margin-top: 9px; +} +.profile-header { + display: flex; + align-items: center; + gap: 20px; + background: #edf0e5; + border: 1px solid #e3e8da; + border-radius: 13px; + padding: 26px 30px; + margin-bottom: 24px; +} +.profile-header .avatar { + width: 68px; + height: 68px; + font-size: 22px; +} +.profile-header h1 { + font-size: 28px; + letter-spacing: -1px; +} +.profile-header p { + font-size: 12px; + color: #8d9b7d; + margin-top: 6px; +} +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 15px; + margin-bottom: 32px; +} +.stat-card { + border: 1px solid var(--line); + background: #fffefa; + border-radius: 10px; + padding: 21px; +} +.stat-card span { + font-size: 11px; + color: #95a187; +} +.stat-card strong { + font-family: 'Manrope', sans-serif; + font-size: 31px; + display: block; + margin: 9px 0; + color: #3f5b34; +} +.stat-card small { + font-size: 10px; + color: #a3ae95; +} +.history-item { + padding: 19px 22px; + border-bottom: 1px solid var(--line); + display: flex; + align-items: center; + gap: 15px; +} +.history-item:last-child { + border-bottom: 0; +} +.history-item strong { + font-size: 12px; +} +.history-item small { + display: block; + font-size: 10px; + color: #9aa58a; + margin-top: 5px; +} +.result-tag { + margin-left: auto; + font-size: 11px; + padding: 6px 10px; + border-radius: 5px; + background: #f0f1e8; + color: #909b7e; +} +.result-tag.win { + background: #e6f0df; + color: #5c854a; +} +.result-tag.loss { + background: #f5e8df; + color: #b0805b; +} +.help-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} +.help-card { + background: #fffefa; + border: 1px solid var(--line); + border-radius: 11px; + padding: 25px; +} +.help-card h2 { + margin-bottom: 8px; + font-size: 22px; +} +.help-card .variant-note { + margin: 13px 0; +} +.help-card ol { + padding-left: 20px; + font-size: 12px; + color: #8a987c; + line-height: 1.8; +} +.help-card li { + padding: 5px 0; +} +.help-intro { + background: #eaf0e3; + border-radius: 12px; + padding: 25px 28px; + margin-bottom: 25px; +} +.help-intro h2 { + font-size: 23px; + margin-bottom: 9px; +} +.help-intro p { + font-size: 13px; + color: #869779; + max-width: 720px; +} +.room-page-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + margin-bottom: 25px; +} +.room-page-heading h1 { + font-size: 28px; + letter-spacing: -1px; +} +.room-page-heading p { + font-size: 11px; + color: #9aa68a; + margin-top: 5px; +} +.breadcrumb { + display: flex; + align-items: center; + gap: 7px; + font-size: 11px; + color: #99a48c; + margin-bottom: 20px; +} +.room-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 285px; + gap: 24px; + align-items: start; +} +.play-area { + min-width: 0; +} +.player-banner { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 0; +} +.player-banner strong { + font-size: 12px; +} +.player-banner small { + font-size: 10px; + color: #98a888; + display: block; + margin-top: 3px; +} +.player-banner .avatar { + width: 34px; + height: 34px; + font-size: 10px; +} +.turn-indicator { + font-size: 10px; + margin-left: auto; + border-radius: 5px; + background: #e8f0df; + color: #7b9864; + padding: 6px 10px; +} +.board-container { + background: #ece8dc; + padding: 14px; + border-radius: 12px; + border: 1px solid #dddcca; + max-width: 570px; + margin: auto; +} +.board-grid { + display: grid; + grid-template-columns: repeat(8, 1fr); + border: 1px solid #acb797; + box-shadow: 0 3px 8px #35462b19; +} +.board-square { + aspect-ratio: 1; + border: 0; + border-radius: 0; + position: relative; + background: #f1eddf; + display: grid; + place-items: center; + padding: 0; + opacity: 1 !important; + overflow: hidden; +} +.board-square.dark { + background: #7f9871; +} +.board-square.selected { + background: #d4bb68; +} +.board-square.target::after { + content: ''; + width: 19%; + height: 19%; + background: #294b35a0; + border-radius: 50%; + position: absolute; + z-index: 3; +} +.board-square.target.occupied::after { + width: 85%; + height: 85%; + background: none; + border: 4px solid #234732aa; +} +.chess-piece { + font-family: 'Segoe UI Symbol', 'DejaVu Sans', serif; + font-size: clamp(28px, 4vw, 60px); + color: #283e2c; + line-height: 1.1; + z-index: 2; + filter: drop-shadow(1px 2px 1px #0003); +} +.chess-piece.white { + color: #fffef5; + text-shadow: + 1px 0 #70856a, + -1px 0 #70856a, + 0 1px #70856a, + 0 -1px #70856a; +} +.coordinate { + position: absolute; + font-size: 8px; + color: #8c9b7a; + font-weight: 600; + left: 4px; + top: 3px; +} +.dark .coordinate { + color: #dbe7d0; +} +.coordinate.file { + top: auto; + left: auto; + right: 4px; + bottom: 3px; +} +.checker-piece { + width: 67%; + height: 67%; + border-radius: 50%; + background: #3b4033; + border: 4px double #90947b; + box-shadow: 0 4px #292f25; + z-index: 2; + display: grid; + place-items: center; + color: #dce2c5; + font-size: 23px; +} +.checker-piece.light { + background: #d18b62; + border-color: #f0bb8d; + box-shadow: 0 4px #a96a44; +} +.board-note { + display: flex; + justify-content: space-between; + gap: 10px; + color: #97a48a; + font-size: 10px; + margin: 14px 0; +} +.room-panel { + background: #fffefa; + border: 1px solid var(--line); + border-radius: 11px; + overflow: hidden; +} +.room-tabs { + display: flex; + border-bottom: 1px solid var(--line); + padding: 0 16px; +} +.room-tabs button { + border: 0; + background: none; + flex: 1; + font-size: 11px; + color: #a0ac91; + padding: 15px 0; + border-bottom: 2px solid transparent; +} +.room-tabs button.active { + color: var(--green); + border-color: var(--green); + font-weight: 600; +} +.chat-messages { + height: 310px; + overflow: auto; + overscroll-behavior: contain; + padding: 18px 16px; + display: flex; + flex-direction: column; + gap: 13px; +} +.chat-message { + max-width: 95%; + align-self: flex-start; +} +.chat-message.mine { + align-self: flex-end; +} +.chat-message strong { + font-size: 9px; + color: #8c9e78; + margin-bottom: 4px; + display: block; +} +.chat-message p { + font-size: 11px; + color: #6e835a; + white-space: pre-wrap; + overflow-wrap: anywhere; + background: #f0f3e9; + padding: 9px 11px; + border-radius: 0 8px 8px 8px; + line-height: 1.5; +} +.chat-message.mine p { + background: #e4ecdc; + border-radius: 8px 0 8px 8px; +} +.chat-message time { + font-size: 8px; + color: #b0bba0; + display: block; + text-align: right; + margin-top: 4px; +} +.chat-empty { + text-align: center; + font-size: 11px; + color: #a4b096; + margin: auto; +} +.chat-form { + display: flex; + gap: 6px; + padding: 13px; + border-top: 1px solid var(--line); + background: #fbfcf7; +} +.chat-form input { + font-size: 11px; + padding: 9px; + flex: 1; + min-width: 0; +} +.chat-form button { + border: 0; + background: var(--green); + border-radius: 6px; + color: white; + display: grid; + place-items: center; + padding: 9px; +} +.room-rules { + padding: 18px; + font-size: 11px; + color: #91a07e; + line-height: 1.8; + min-height: 310px; +} +.room-rules li { + margin-bottom: 10px; +} +.room-rules ol { + padding-left: 17px; +} +.event-list { + padding: 15px; + max-height: 370px; + overflow: auto; +} +.event-list > div { + display: flex; + gap: 9px; + padding: 9px 0; + border-bottom: 1px solid #f0f2e9; + font-size: 11px; + color: #8b9b78; +} +.event-list span { + color: #a7b397; + width: 20px; +} +.event-list small { + margin-left: auto; + font-size: 9px; + color: #a9b89a; +} +.waiting-card { + background: #edf0e5; + border: 1px solid #e1e7d7; + border-radius: 13px; + text-align: center; + padding: 40px 25px; +} +.waiting-card h2 { + font-size: 25px; + margin: 15px 0 9px; +} +.waiting-card > p { + font-size: 12px; + color: #91a17e; + margin-bottom: 25px; +} +.waiting-symbol { + font-family: Georgia, serif; + font-size: 45px; + color: #6d8a58; +} +.waiting-players { + display: flex; + justify-content: center; + gap: 25px; + margin: 25px 0 30px; + flex-wrap: wrap; +} +.waiting-player { + display: grid; + justify-items: center; + gap: 8px; + width: 84px; +} +.waiting-player .avatar { + width: 52px; + height: 52px; + font-size: 16px; +} +.waiting-player strong { + font-size: 11px; + overflow-wrap: anywhere; +} +.waiting-player small { + font-size: 9px; + color: #9bab87; +} +.avatar.empty { + background: none; + border: 1px dashed #bdc9ab; + color: #a2b78b; +} +.room-code { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + background: #fffefa; + border: 1px dashed #ccd8b8; + border-radius: 7px; + padding: 12px; + margin: 0 auto 25px; + max-width: 270px; + font-size: 15px; + letter-spacing: 2px; + color: #78935e; +} +.room-code button { + background: none; + border: 0; + color: #97aa80; +} +.result-banner { + padding: 23px; + background: #e7eedb; + border: 1px solid #ccd8b5; + border-radius: 10px; + text-align: center; + margin-bottom: 20px; +} +.result-banner h2 { + font-size: 23px; + margin-bottom: 7px; +} +.result-banner p { + font-size: 12px; + color: #90a078; + margin-bottom: 15px; +} +.parchis-board { + background: #fffefa; + border: 1px solid var(--line); + border-radius: 13px; + padding: 20px; + position: relative; +} +.parchis-map { + display: block; + width: 100%; + height: auto; +} +.parchis-controls { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 0; +} +.parchis-controls p { + flex: 1; + font-size: 13px; + color: #5c6855; + line-height: 1.5; +} +.parchis-controls .big-die { + font-size: 42px; + line-height: 1; + color: #5c7451; +} +.parchis-controls .button { + font-size: 12px; + padding: 12px 14px; +} +@media (max-width: 600px) { + .parchis-controls { + flex-wrap: wrap; + gap: 8px; + } + .parchis-controls .button { + margin-left: auto; + } +} +.parchis-track { + width: 100%; + aspect-ratio: 1; + position: relative; + border-radius: 50%; + background: radial-gradient(circle, #f5f5ed 0 56%, transparent 57%); + max-width: 550px; + margin: auto; +} +.track-cell { + position: absolute; + width: 5.5%; + height: 5.5%; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid #dfe7d2; + border-radius: 3px; + background: #f9faf5; + color: #b1bd9d; + font-size: 6px; +} +.track-cell.safe { + background: #e6eddc; + color: #83966b; +} +.parchis-token { + background: var(--piece-color); + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid #fff9; + box-shadow: 0 2px #0002; + display: grid; + place-items: center; + font-size: 8px; + color: white; + padding: 0; + position: relative; + z-index: 2; +} +.track-cell .parchis-token { + width: 70%; + height: 70%; + font-size: 7px; +} +.parchis-center { + position: absolute; + inset: 28%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} +.big-die { + font-family: Georgia, serif; + font-size: 54px; + color: #667e50; + line-height: 1; +} +.parchis-center p { + font-size: 11px; + color: #9baa86; + margin: 10px 0; +} +.parchis-center .button { + padding: 10px; + font-size: 10px; +} +.parchis-homes { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 13px; + margin-top: 18px; +} +.parchis-home { + border: 1px solid #e4e9da; + background: #fafbf6; + border-radius: 8px; + padding: 11px; +} +.parchis-home strong { + font-size: 12px; + color: #5c6855; + display: block; + margin-bottom: 9px; +} +.home-pieces { + display: flex; + align-items: center; + gap: 9px; + flex-wrap: wrap; +} +.home-pieces .parchis-token { + width: 44px; + height: 44px; + min-width: 44px; + font-size: 16px; +} +.home-pieces small { + font-size: 12px; + color: #5c6855; +} +@media (max-width: 600px) { + .parchis-homes { + grid-template-columns: 1fr; + } +} +.promotion { + background: #fffefa; + border: 1px solid #d6dfca; + padding: 18px; + border-radius: 9px; + margin: 10px 0; +} +.promotion p { + font-size: 12px; + margin-bottom: 10px; +} +.promotion button { + border: 1px solid #d6dfca; + background: #edf1e7; + padding: 8px 15px; + border-radius: 5px; + margin-right: 8px; +} +.mobile-brand { + display: none; +} +@media (min-width: 1550px) { + main { + padding-top: 42px; + } + .hero { + min-height: 350px; + } + .hero h1 { + font-size: 50px; + } + .hero-visual { + margin-right: 40px; + } +} +@media (max-width: 1200px) { + .sidebar { + width: 205px; + padding: 30px 16px; + } + .main-shell { + margin-left: 205px; + width: calc(100% - 205px); + } + main { + padding: 28px 28px; + } + .topbar { + padding: 0 28px; + } + .hero { + padding: 30px; + min-height: 290px; + } + .hero h1 { + font-size: 38px; + } + .hero-visual { + width: 245px; + } + .hero-copy > p { + font-size: 11px; + } + .game-grid { + gap: 14px; + } + .game-card-body { + padding: 16px 14px 0; + } + .game-meta { + font-size: 8px; + } + .game-title h3 { + font-size: var(--type-subtitle); + } + .difficulty { + font-size: var(--type-detail); + } + .section-header h2 { + font-size: var(--type-section); + } + .room-layout { + grid-template-columns: minmax(0, 1fr) 250px; + gap: 18px; + } + .site-footer { + padding-left: 28px; + padding-right: 28px; + } +} +@media (max-width: 1000px) { + .sidebar { + width: 76px; + padding: 30px 12px; + } + .brand { + margin: 0 3px 40px; + } + .brand > span:not(.brand-mark) { + display: none; + } + .brand-mark { + width: 42px; + flex-shrink: 0; + } + .sidebar-label, + .sidebar nav a span, + .sidebar-invite, + .sidebar-bottom { + display: none; + } + .sidebar nav a { + justify-content: center; + padding: 14px; + } + .main-shell { + margin-left: 76px; + width: calc(100% - 76px); + } + .hero-copy { + max-width: 63%; + } + .hero h1 { + font-size: 39px; + } + .hero-visual { + width: 270px; + } + .room-layout { + grid-template-columns: minmax(0, 1fr) 265px; + } + .stats-grid { + gap: 10px; + } + .stat-card { + padding: 17px; + } + .auth-layout { + max-width: 760px; + } + .auth-form { + padding: 30px 25px; + } +} +@media (max-width: 760px) { + .topbar { + height: 67px; + padding: 0 23px; + } + .connection { + display: none; + } + main { + padding: 25px 23px; + } + .hero { + padding: 28px; + min-height: 300px; + } + .hero h1 { + font-size: 34px; + } + .hero-copy { + max-width: 70%; + } + .hero-visual { + width: 175px; + height: 220px; + margin-right: -30px; + opacity: 0.85; + } + .hero-board { + transform: scale(0.78); + transform-origin: 50% 50%; + } + .hero-note { + display: none; + } + .hero-star { + font-size: 27px; + } + .hero-loop { + width: 220px; + height: 220px; + left: -10px; + top: 0; + } + .loop-two { + width: 280px; + height: 280px; + left: -40px; + top: -30px; + } + .section-header { + align-items: flex-start; + flex-wrap: wrap; + gap: 13px; + } + .game-grid { + grid-template-columns: 1fr; + gap: 17px; + } + .game-card { + display: grid; + grid-template-columns: 180px 1fr; + } + .game-card-body { + padding: 18px 20px 0; + } + .art-wrap { + height: 100%; + } + .art-wrap .game-art { + height: 100%; + } + .game-title h3 { + font-size: var(--type-subtitle); + } + .game-meta { + font-size: 10px; + } + .card-bottom { + padding: 14px 0; + } + .lobby-table-head, + .lobby-row { + grid-template-columns: 1fr auto; + gap: 12px; + padding: 15px; + } + .lobby-table-head span:nth-child(2), + .lobby-table-head span:nth-child(3), + .lobby-row .player-stack, + .lobby-row .status-badge { + display: none; + } + .lobby-table-head span:nth-child(4) { + display: none; + } + .lobby-table-head::after { + content: 'ENTRAR'; + text-align: right; + } + .welcome-strip { + font-size: var(--type-body); + padding-top: 18px; + padding-bottom: 27px; + gap: 8px; + } + .welcome-strip > span:nth-child(2) { + max-width: 70%; + } + .live-count { + font-size: var(--type-secondary); + } + .continue-grid { + grid-template-columns: 1fr; + } + .bottom-note > a { + display: none; + } + .site-footer > span:nth-child(2) { + display: none; + } + .room-layout { + grid-template-columns: 1fr; + } + .room-panel { + max-width: 570px; + width: 100%; + margin: auto; + } + .chat-messages { + height: 230px; + } + .room-rules { + min-height: 0; + } + .room-page-heading h1 { + font-size: 24px; + } + .chess-piece { + font-size: clamp(28px, 7vw, 58px); + } + .auth-layout { + grid-template-columns: 1fr; + margin: 0; + } + .auth-art { + min-height: 0; + padding: 27px; + } + .auth-art h1 { + font-size: 27px; + margin: 12px 0; + } + .auth-art .art-box { + display: none; + } + .auth-form { + padding: 28px; + } + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + .help-grid { + grid-template-columns: 1fr; + } + .profile-header { + padding: 23px; + } + .board-container { + padding: 10px; + } +} +@media (max-width: 500px) { + .sidebar { + position: fixed; + top: auto; + bottom: 0; + width: 100%; + height: 64px; + padding: 0; + background: #fffefaf5; + backdrop-filter: blur(12px); + border-right: 0; + border-top: 1px solid var(--line); + } + .brand { + display: none; + } + .sidebar nav { + display: flex; + justify-content: space-around; + height: 100%; + align-items: center; + gap: 0; + } + .sidebar nav a { + width: 30%; + padding: 8px; + display: flex; + flex-direction: column; + gap: 3px; + border-radius: 6px; + } + .sidebar nav a span { + display: block; + font-size: 8px; + } + .sidebar nav a svg { + width: 18px; + height: 18px; + } + .main-shell { + margin-left: 0; + width: 100%; + padding-bottom: 64px; + } + .topbar { + padding: 0 19px; + height: 62px; + } + .header-search { + max-width: 220px; + } + .header-search input { + font-size: 11px; + } + .header-search .search-hint { + display: none; + } + .header-right { + gap: 0; + } + .header-login { + font-size: 11px; + } + .header-search input::placeholder { + font-size: 10px; + } + main { + padding: 20px 19px 26px; + } + .hero { + padding: 26px 22px; + min-height: 300px; + } + .hero-copy { + max-width: 100%; + } + .hero h1 { + font-size: 35px; + position: relative; + z-index: 2; + } + .hero-copy > p { + font-size: 10px; + max-width: 210px; + } + .hero-visual { + position: absolute; + right: -70px; + top: 85px; + width: 195px; + opacity: 0.24; + pointer-events: none; + } + .hero-eyebrow { + font-size: 8px; + letter-spacing: 1.3px; + } + .hero-caption { + font-size: var(--type-secondary); + } + .hero .button { + font-size: 11px; + } + .welcome-strip > span:nth-child(2) { + max-width: 62%; + font-size: var(--type-body); + } + .welcome-spark { + display: none; + } + .section-header h2 { + font-size: var(--type-section); + } + .game-card { + grid-template-columns: 130px 1fr; + } + .game-card-body { + padding: 15px 13px 0; + } + .game-title h3 { + font-size: var(--type-subtitle); + } + .difficulty { + display: none; + } + .game-card-body > p { + font-size: 10px; + } + .game-meta { + flex-wrap: wrap; + justify-content: flex-start; + font-size: 8px; + gap: 7px; + margin: 13px 0; + } + .game-play { + font-size: 10px; + } + .game-category { + left: 9px; + top: 10px; + font-size: var(--type-detail); + padding: 4px 6px; + } + .game-card .art-wrap { + min-height: 175px; + } + .join-code { + width: 100%; + } + .join-code input { + width: 100%; + font-size: var(--type-body); + } + .empty-lobby p { + font-size: var(--type-body); + } + .empty-lobby h3 { + font-size: var(--type-subtitle); + } + .bottom-note > span { + font-size: 32px; + } + .bottom-note strong { + font-size: var(--type-body); + } + .bottom-note p { + font-size: var(--type-secondary); + } + .site-footer { + padding: 20px; + font-size: var(--type-secondary); + } + .form-row { + gap: 10px; + } + .modal { + padding: 23px; + } + .modal h2 { + font-size: 20px; + } + .room-page-heading { + align-items: flex-start; + flex-direction: column; + } + .room-page-heading .button { + padding: 9px 12px; + } + .board-container { + padding: 8px; + border-radius: 8px; + } + .coordinate { + font-size: 7px; + left: 2px; + top: 2px; + } + .checker-piece { + border-width: 3px; + box-shadow: 0 2px #292f25; + } + .board-note { + font-size: 9px; + } + .waiting-card { + padding: 25px 15px; + } + .waiting-card h2 { + font-size: 22px; + } + .waiting-players { + gap: 14px; + } + .waiting-player { + width: 65px; + } + .profile-header { + gap: 15px; + padding: 20px; + } + .profile-header h1 { + font-size: 24px; + } + .profile-header .avatar { + width: 52px; + height: 52px; + font-size: 17px; + } + .history-item { + padding: 15px; + gap: 10px; + } + .history-item .room-symbol { + width: 33px; + height: 33px; + font-size: 24px; + } + .history-item strong { + font-size: 11px; + } + .history-item small { + font-size: 9px; + } + .result-tag { + font-size: 9px; + padding: 5px 7px; + } + .parchis-board { + padding: 10px; + } + .parchis-center .button { + padding: 8px; + font-size: 9px; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition: none !important; + animation: none !important; + } +} +dialog.modal { + border: 0; + color: inherit; + margin: auto; +} +dialog.modal::backdrop { + background: #263a2d55; + backdrop-filter: blur(4px); +} + +.hero-copy > p { + font-size: 13px; + color: #65735d; +} +.hero-caption { + font-size: var(--type-secondary); + color: #6d795f; +} +.welcome-strip { + color: #66755b; + font-size: var(--type-body); +} +.eyebrow { + color: #647358; +} +.game-card-body > p { + color: #68755f; + font-size: 12px; +} +.game-meta { + color: #67755c; + font-size: 10px; +} +.game-play { + font-size: 12px; +} +.sidebar nav a:not(.active) { + color: #64705c; +} +.muted, +.page-heading p { + color: #67755d; +} +.game-card-body .difficulty { + color: #64725c; +} +.help-card ol, +.help-intro p, +.room-rules { + color: #657557; +} +.chat-empty, +.empty-lobby p { + color: #69765e; +} +.hero-eyebrow { + color: #627356; +} +.auth-form > p, +.auth-art p { + color: #67785b; +} +.live-count, +.connection { + color: #68795c; +} +.welcome-strip .live-count { + font-size: var(--type-secondary); +} +@media (max-width: 500px) { + .hero-copy > p { + font-size: 12px; + max-width: 235px; + } + .game-meta { + font-size: 10px; + } + .game-card-body > p { + font-size: 11px; + } + .game-play { + font-size: 11px; + } + .sidebar nav a span { + font-size: 10px; + } + .welcome-strip > span:nth-child(2) { + font-size: var(--type-body); + } +} diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..3ecd684 --- /dev/null +++ b/src/app.html @@ -0,0 +1,13 @@ + + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/appearance.css b/src/appearance.css new file mode 100644 index 0000000..54fdbe7 --- /dev/null +++ b/src/appearance.css @@ -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; + } +} diff --git a/src/design.css b/src/design.css new file mode 100644 index 0000000..b1b3369 --- /dev/null +++ b/src/design.css @@ -0,0 +1,1116 @@ +/* One type and spacing scale for navigation, forms and game controls. */ +:root { + --font-ui: 'Manrope', system-ui, sans-serif; + --font-title: var(--font-ui); + --type-detail: 12px; + --type-secondary: 14px; + --type-body: 16px; + --type-subtitle: 20px; + --type-section: 24px; + --type-heading: 28px; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + font-size: var(--type-body); +} +body { + font: 400 var(--type-body)/1.5 var(--font-ui); +} +h1, +h2, +h3 { + font-family: var(--font-title); + line-height: 1.25; + letter-spacing: -0.025em; +} +h2, +.modal h2 { + font-size: var(--type-section); + font-weight: 750; +} +h3 { + font-size: var(--type-subtitle); +} +.small, +.muted.small, +.auth-note, +.modal .auth-note { + font-size: var(--type-secondary); + line-height: 1.55; +} +.eyebrow, +.sidebar-label { + font-size: var(--type-detail); + line-height: 1.4; + letter-spacing: 0.1em; +} +.button { + min-height: 44px; + font-size: var(--type-secondary); + line-height: 1.4; + padding: 10px 16px; + border-radius: 12px; + font-weight: 650; +} +.icon-button, +.settings-trigger { + width: 44px; + height: 44px; + min-width: 44px; + min-height: 44px; + display: inline-grid; + place-items: center; + padding: 0; + border-radius: 12px; +} +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} +.modal h2:focus { + outline: none; +} +.modal { + width: calc(100vw - 32px); + max-width: 520px; + padding: 28px; + border-radius: 24px; + border: 1px solid var(--border); + box-shadow: 0 24px 80px var(--shadow); + max-height: calc(100dvh - 32px); +} +.modal-head { + display: flex; + align-items: flex-start; + gap: 16px; + margin-bottom: 20px; +} +.modal-head > div { + min-width: 0; + flex: 1; +} +.modal-head .icon-button { + flex-shrink: 0; +} +.modal .eyebrow { + margin: 0 0 8px; +} +.modal > p { + font-size: var(--type-body); + line-height: 1.55; +} +.modal label, +form label { + display: flex; + flex-direction: column; + gap: 8px; + align-items: stretch; + margin: 0 0 20px; + font-size: var(--type-secondary); + font-weight: 600; + color: var(--text); +} +.modal input:not([type='checkbox']):not([type='range']), +.modal select, +.modal textarea, +form input:not([type='checkbox']):not([type='range']), +form select, +form textarea { + display: block; + width: 100%; + min-width: 0; + min-height: 48px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + color: var(--text); + font-size: var(--type-body); + line-height: 1.5; + font-weight: 400; +} +.modal textarea, +form textarea { + resize: vertical; + min-height: 112px; +} +.modal input[readonly] { + background: var(--soft); + text-overflow: ellipsis; +} +.modal .form-row { + gap: 12px; +} +.modal .form-row .button { + width: 100%; +} +.modal .auth-note { + margin: 16px 0 0; + overflow-wrap: anywhere; +} +.share-form { + display: flex; + flex-direction: column; + margin-top: 24px; +} +.share-form .button.full { + margin-top: 0; + width: 100%; +} +.modal .button.full { + font-size: var(--type-body); +} +.match-room .dice-overlay .button { + min-height: 48px; + font-size: var(--type-body); + padding: 12px 18px; +} +.match-room .dice-overlay .dice-announcement p { + font-size: var(--type-body); + line-height: 1.5; +} +.settings-popover { + font-size: var(--type-secondary); + padding: 24px; + border-radius: 20px; +} +.sound-popover { + width: 360px; + max-height: calc(100dvh - 80px); + overflow: auto; +} +.sound-popover .audio-preview-label { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + margin-top: 20px; + font-size: 14px; +} +.sound-popover select { + min-height: 44px; + width: 100%; + border: 1px solid var(--border); + border-radius: 10px; + padding: 8px 12px; + margin-top: 8px; + font-size: 16px; + background: var(--surface); + color: var(--text); +} +.audio-samples { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 12px; +} +.audio-samples button { + min-height: 44px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--soft); + color: var(--accent-text); + font-size: 14px; +} +.settings-label, +.sound-popover label, +.sound-popover p { + font-size: var(--type-secondary); + line-height: 1.5; +} +.theme-options button, +.palette-options button { + min-height: 76px; + font-size: var(--type-secondary); +} +.sidebar nav a { + font-size: var(--type-body); +} +.user-card strong, +.sidebar-invite strong { + font-size: var(--type-body); +} +.user-card small, +.sidebar-invite p, +.sidebar-invite a, +.logout { + font-size: var(--type-secondary); + line-height: 1.5; +} +.game-meta, +.lobby-game small, +.filter-tabs button, +.help-card ol, +.history-item small { + font-size: var(--type-secondary); +} +.game-card-body > p, +.game-play, +.welcome-strip, +.join-code input, +.empty-lobby p, +.lobby-join, +.lobby-game strong, +.continue-card strong, +.bottom-note strong, +.bottom-note > a { + font-size: var(--type-body); + line-height: 1.5; +} +.lobby-game strong, +.continue-card strong { + font-weight: 700; +} +.lobby-table-head, +.hero-caption, +.live-count, +.welcome-strip .live-count, +.player-stack small, +.status-badge, +.continue-card small, +.bottom-note p, +.site-footer { + font-size: var(--type-secondary); + line-height: 1.5; +} +.section-header h2 { + font-size: var(--type-section); + line-height: 1.25; +} +.game-title h3 { + font-size: var(--type-subtitle); + line-height: 1.3; +} +.game-category, +.difficulty, +.game-card-body .difficulty { + font-size: var(--type-detail); + line-height: 1.4; +} +.empty-lobby h3 { + font-size: var(--type-subtitle); +} +.lobby-table-head { + letter-spacing: 0; + font-weight: 600; +} +.lobby-game small, +.continue-card small { + margin-top: 4px; +} +.lobby-game, +.continue-card { + gap: 16px; +} +.lobby-game > span:last-child, +.continue-card > div { + min-width: 0; +} +.continue-card { + min-height: 88px; + padding: 16px 20px; +} +.continue-card strong { + overflow-wrap: anywhere; +} +.lobby-row { + min-height: 96px; +} +.lobby-join { + min-height: 44px; +} +.lobby-row .mini-avatar { + width: 32px; + height: 32px; + font-size: var(--type-secondary); +} +.site-footer { + flex-wrap: wrap; + gap: 16px 24px; +} +.hero-copy > p, +.help-intro p, +.auth-form > p { + font-size: var(--type-body); +} +.match-shell .main-shell { + padding-bottom: 0; +} +.match-shell .topbar { + height: 60px; + flex-basis: 60px; +} +.match-shell .mobile-logo { + width: 156px; +} +.match-shell .header-right .connection { + font-size: var(--type-detail); +} +.match-shell .sidebar:popover-open { + inset: 68px auto auto 12px; + width: min(320px, calc(100vw - 24px)); + height: auto; + max-height: calc(100dvh - 80px); + padding: 24px; + gap: 0; +} +.match-shell .sidebar .brand { + padding-right: 48px; + margin-bottom: 24px; +} +.close-platform-menu { + position: absolute; + top: 20px; + right: 16px; +} +.match-shell .sidebar-label { + margin: 0 12px 12px; + font-size: var(--type-detail); +} +.match-shell .sidebar nav { + height: auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; +} +.match-shell .sidebar nav a { + width: 100%; + padding: 12px; + min-height: 48px; + font-size: var(--type-body); + gap: 12px; +} +.match-shell .sidebar nav a span { + font-size: inherit; + line-height: 1.5; + white-space: nowrap; +} +.match-shell .sidebar nav a svg { + width: 22px; + height: 22px; +} +.match-shell .sidebar-invite { + display: none; +} +.match-shell .sidebar-bottom { + margin-top: 20px; + padding-top: 0; +} +.match-shell .sidebar .brand small { + font-size: var(--type-secondary); +} +.match-shell .sidebar .user-card { + padding: 16px 0; +} +.match-shell .sidebar .user-card > span:last-child { + min-width: 0; +} +.match-shell .sidebar .user-card strong { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.match-shell .sidebar .logout { + margin: 0; + min-height: 44px; + padding: 8px 0; +} +.match-room .room-page-heading { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + gap: 24px; +} +.match-room .room-page-heading > div:first-child { + display: block; + min-width: 0; +} +.match-room .room-page-heading .eyebrow { + font-size: var(--type-detail); + line-height: 1.4; + margin: 0 0 4px; +} +.title-turn-row { + display: flex; + align-items: baseline; + gap: 16px; + min-width: 0; +} +.match-room .room-page-heading h1 { + font-size: var(--type-subtitle); + line-height: 1.25; + max-width: none; + margin: 0; +} +.match-room .match-turn { + font-size: var(--type-secondary); + line-height: 1.5; + padding: 6px 12px; + width: auto; + flex-shrink: 0; +} +.match-room .room-tools { + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.match-room .room-tools .icon-button { + width: 44px; + height: 44px; +} +.match-room .room-tools > .button { + min-height: 44px; + padding: 10px 14px; + font-size: var(--type-secondary); +} +.panel-trigger.has-unread { + color: var(--accent-text); + border-color: var(--accent); + background: var(--soft); +} +.panel-trigger { + position: relative; +} +.panel-trigger.has-unread b { + display: grid; + place-items: center; + min-width: 20px; + min-height: 20px; + font-size: var(--type-detail); + line-height: 1; +} +.chat-notice { + position: fixed; + top: 146px; + right: 16px; + width: 380px; + max-width: calc(100vw - 24px); + display: flex; + align-items: flex-start; + padding: 12px; + gap: 8px; + border: 1px solid var(--border); + border-left: 4px solid var(--player-solid); + border-radius: 16px; + background: var(--surface); + box-shadow: 0 12px 40px var(--shadow); + z-index: 35; + animation: panel-enter 0.18s ease-out; +} +.chat-notice-open { + display: flex; + align-items: flex-start; + gap: 12px; + flex: 1; + min-width: 0; + border: 0; + background: transparent; + padding: 4px; + text-align: left; + color: var(--text); +} +.chat-notice-open > span:last-child { + min-width: 0; +} +.chat-notice-icon { + flex-shrink: 0; + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 12px; + background: var(--player-soft); + color: var(--player-ink); +} +.chat-notice strong { + display: block; + font-size: var(--type-secondary); + color: var(--player-ink); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.chat-notice-text { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + font-size: var(--type-secondary); + line-height: 1.5; + margin-top: 4px; + overflow-wrap: anywhere; +} +.chat-notice small { + display: block; + font-size: var(--type-detail); + margin-top: 8px; + color: var(--accent-text); +} +.chat-notice > .icon-button { + flex-shrink: 0; +} +.match-room .room-layout { + padding: 16px; +} +.match-room .parchis-board, +.match-room .table-game { + --control-width: clamp(220px, 23cqmin, 360px); + --table-gap: clamp(16px, 2cqmin, 32px); + --board-size: min( + 100cqh, + calc(100cqw - var(--control-width) - var(--table-gap)) + ); + display: grid; + flex: 1; + min-height: 0; + grid-template-columns: minmax(0, var(--board-size)) var(--control-width); + grid-template-rows: minmax(0, 1fr); + gap: var(--table-gap); + justify-content: center; +} +.match-room .board-surface { + align-self: center; + height: var(--board-size); + width: 100%; +} +.match-room .game-map { + display: block; + width: min(100cqw, 100cqh); + height: min(100cqw, 100cqh); + max-width: 100%; + max-height: 100%; + filter: drop-shadow(0 8px 18px var(--shadow)); +} +.match-room .parchis-controls, +.match-room .game-controls { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + border: 1px solid var(--border); + border-radius: 20px; + background: var(--surface); + align-self: center; + box-shadow: 0 6px 24px var(--shadow); + min-width: 0; +} +.match-room .parchis-controls > .eyebrow, +.game-controls > .eyebrow { + font-size: var(--type-detail); + margin: 0; + letter-spacing: 0.08em; +} +.match-room .parchis-controls .dice-instruction, +.game-controls > p { + font-size: var(--type-body); + line-height: 1.5; +} +.last-roll-caption { + display: none; +} +.match-room .parchis-controls .dice-widget, +.game-controls .dice-widget { + flex-direction: column; + gap: 12px; +} +.match-room .parchis-controls .dice-widget .button, +.game-controls .dice-widget .button { + min-height: 44px; + font-size: var(--type-secondary); + padding: 10px 14px; +} +.dice-widget .dice-result small { + font-size: var(--type-secondary); + line-height: 1.45; + max-width: 220px; +} +.match-room .table-player strong { + font-size: var(--type-secondary); +} +.match-room .table-player small, +.piece-shortcuts > span { + font-size: var(--type-detail); +} +.match-room .piece-shortcuts .parchis-token { + width: 44px; + height: 44px; + font-size: 16px; +} +.match-room .waiting-card { + flex: 1; + min-height: 0; + width: 100%; + max-width: 680px; + margin: 0 auto; + height: auto; + padding: 24px; + text-align: left; + background: var(--surface); + border-radius: 20px; +} +.match-room .waiting-card h2 { + font-size: var(--type-subtitle); + margin: 0 0 8px; +} +.match-room .waiting-card > p { + font-size: var(--type-secondary); + margin: 0 0 16px; +} +.match-room .waiting-card .room-code { + margin: 0; + font-size: var(--type-body); + padding: 8px 12px; + display: flex; + align-items: center; + justify-content: space-between; +} +.match-room .waiting-card .room-code button { + width: 44px; + height: 44px; +} +.match-room .waiting-players { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin: 20px 0; +} +.match-room .waiting-player { + width: auto; + min-width: 0; + display: grid; + grid-template-columns: 40px minmax(0, 1fr); + column-gap: 10px; + row-gap: 2px; + justify-items: start; + align-items: center; +} +.match-room .waiting-player .avatar { + grid-row: 1/3; + width: 40px; + height: 40px; + font-size: var(--type-secondary); +} +.match-room .waiting-player strong { + font-size: var(--type-secondary); + width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} +.match-room .waiting-player small { + font-size: var(--type-detail); +} +.match-room .waiting-card > .button { + width: 100%; +} +.match-room .room-panel:popover-open { + top: 144px; +} +.panel-heading strong { + font-size: var(--type-body); +} +.panel-players span, +.chat-message time { + font-size: var(--type-detail); +} +.match-room .room-tabs button { + font-size: var(--type-secondary); + min-height: 48px; +} +.chat-message strong { + font-size: var(--type-secondary); +} +.chat-message p { + font-size: var(--type-body); + line-height: 1.5; +} +.chat-form input { + font-size: var(--type-body); + min-height: 44px; +} +.chat-form button { + width: 44px; + min-height: 44px; +} +.room-rules, +.event-list { + font-size: var(--type-secondary); + line-height: 1.55; +} +.panel-pending { + flex: 1; + min-height: 0; + overflow: auto; + padding: 16px; +} +.pending-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; +} +.pending-heading strong { + font-size: var(--type-body); +} +.panel-pending > .button { + width: 100%; + margin-top: 16px; +} +.profile-pending { + margin: 32px 0; +} +.profile-header .button { + margin-top: 16px; +} +.profile-header > div { + min-width: 0; +} +.profile-header h1 { + overflow-wrap: anywhere; +} +.avatar, +.mini-avatar, +.chat-avatar { + overflow: hidden; + flex-shrink: 0; +} +.chat-avatar { + border-radius: 50%; +} +.avatar[class*='player-color'], +.table-player .mini-avatar, +.chat-message .chat-avatar { + box-shadow: 0 0 0 2px var(--player-solid); +} +.match-room .board-container { + width: min(100cqw, calc(100cqh - 34px), 1100px); +} +.table-game + .board-note { + display: none; +} +.go-scores small, +.game-controls .muted { + font-size: var(--type-secondary); +} +.game-controls .button { + font-size: var(--type-secondary); +} +.go-scores strong { + font-size: var(--type-body); +} +.oca-note { + color: var(--secondary); + font-size: var(--type-secondary); +} +@media (min-width: 850px) and (max-width: 1199px) { + .match-room .room-layout:has(.room-panel:popover-open) .parchis-board { + --board-size: min(100cqw, calc(100cqh - 150px)); + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, var(--board-size)) auto; + align-content: center; + } + .match-room .room-layout:has(.room-panel:popover-open) .parchis-controls { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + padding: 12px; + gap: 8px 16px; + } + .match-room .room-layout:has(.room-panel:popover-open) .dice-widget { + flex-direction: row; + gap: 12px; + } +} +@media (max-width: 800px) and (orientation: portrait) { + .match-room .parchis-board, + .match-room .table-game { + --board-size: min(100cqw, calc(100cqh - 184px)); + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, var(--board-size)) auto; + align-content: start; + gap: 12px; + } + .match-room .board-surface { + align-self: start; + } + .match-room .parchis-controls { + display: grid; + grid-template-columns: minmax(132px, 44%) minmax(0, 1fr); + padding: 14px; + gap: 8px 12px; + align-self: start; + width: 100%; + } + .match-room .parchis-controls > .eyebrow { + display: none; + } + .match-room .parchis-controls .dice-widget { + display: flex; + flex-direction: column; + gap: 8px; + grid-row: auto; + } + .match-room .parchis-controls .dice-widget .dice-stage { + width: 60px; + height: 64px; + --edge: 36px; + --depth: 18px; + } + .dice-widget .dice-result small { + font-size: var(--type-detail); + max-width: 140px; + } + .match-room .parchis-controls .dice-instruction { + font-size: var(--type-secondary); + align-self: center; + } + .match-room .table-players { + display: none; + } + .match-room .parchis-controls .dice-widget:has(> button) .dice-result { + display: none; + } + .match-room .parchis-controls .dice-result small { + display: none; + } + .match-room .parchis-controls:not(:has(.dice-widget > button)) { + grid-template-columns: 64px minmax(0, 1fr); + } + .match-room .last-roll-caption { + display: block; + font-size: var(--type-detail); + margin-bottom: 4px; + color: var(--secondary); + } + .match-room .parchis-controls .dice-widget { + align-self: center; + } + .match-room .piece-shortcuts { + grid-column: 1 / -1; + flex-wrap: nowrap; + justify-content: center; + } + .match-room .piece-shortcuts > span { + display: none; + } + .match-room .game-controls { + width: 100%; + padding: 14px; + gap: 8px; + } + .match-room .go-table { + --board-size: min(100cqw, calc(100cqh - 212px)); + } + .match-room .go-table.scoring { + --board-size: min(100cqw, calc(100cqh - 268px)); + } + .match-room .go-table .game-controls > .eyebrow { + display: none; + } + .match-room .go-table .game-controls > p { + font-size: var(--type-secondary); + } + .match-room .go-scores { + flex-direction: row; + justify-content: space-between; + } + .match-room .oca-controls { + display: grid; + grid-template-columns: 44% minmax(0, 1fr); + } + .oca-controls > .eyebrow { + display: none; + } + .match-room .oca-controls .dice-widget { + grid-row: auto; + align-self: center; + } + .match-room .oca-controls .dice-widget:has(> button) .dice-result { + display: none; + } + .match-room .oca-controls .dice-stage { + width: 60px; + height: 64px; + --edge: 36px; + --depth: 18px; + } + .match-room .oca-controls .dice-stage .dice-face { + padding: 6px; + gap: 2px; + border-radius: 6px; + } + .match-room .oca-controls .dice-result small { + display: none; + } + .match-room .oca-controls:not(:has(.dice-widget > button)) { + grid-template-columns: 64px minmax(0, 1fr); + } + .match-room .oca-controls .oca-instruction { + font-size: var(--type-secondary); + align-self: center; + } + .match-room .oca-controls .oca-note { + grid-column: 1/-1; + font-size: var(--type-detail); + } +} +@media (max-width: 650px) { + .match-shell .topbar { + padding: 0 12px; + gap: 8px; + } + .match-shell .mobile-logo { + width: 136px; + } + .match-shell .header-right .connection { + display: none; + } + .match-shell .header-right { + gap: 8px; + } + .match-room .room-page-heading { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px; + } + .match-room .title-turn-row { + align-items: flex-start; + flex-direction: column; + gap: 6px; + } + .match-room .room-page-heading h1 { + font-size: 18px; + max-width: none; + white-space: normal; + overflow-wrap: anywhere; + } + .match-room .match-turn { + padding: 0; + font-size: var(--type-secondary); + white-space: normal; + background: none; + } + .match-room .room-tools { + gap: 4px; + margin-left: 0; + } + .match-room .panel-trigger { + width: 44px; + padding: 0; + } + .match-room .panel-trigger.has-unread b { + position: absolute; + top: -4px; + right: -4px; + } + .chat-notice { + top: 166px; + right: 12px; + } + .match-room .room-layout { + padding: 8px 12px 12px; + } + .match-room .room-panel:popover-open { + inset: 180px 8px 8px auto; + } + dialog.modal:not(.dice-overlay) { + inset: auto 8px 8px; + width: calc(100% - 16px); + margin: 0 auto; + padding: 24px; + max-height: calc(100dvh - 24px); + border-radius: 24px; + } + .modal h2 { + font-size: 24px; + } + .modal .form-row { + grid-template-columns: 1fr; + } + .modal .button { + font-size: var(--type-body); + } + .settings-popover { + top: 68px; + } +} +@media (max-width: 370px) { + .match-shell .mobile-logo { + width: 112px; + } + .match-room .room-tools { + flex-wrap: wrap; + max-width: 92px; + } +} +@media (min-width: 1800px) and (min-height: 900px) { + :root { + --type-detail: 14px; + --type-secondary: 16px; + --type-body: 18px; + --type-subtitle: 24px; + --type-section: 28px; + --type-heading: 32px; + } + .match-room .parchis-board, + .match-room .table-game { + --control-width: clamp(280px, 24cqmin, 400px); + } +} +@media (orientation: landscape) and (max-height: 520px) { + .match-shell .topbar { + height: 52px; + flex-basis: 52px; + } + .match-room .room-page-heading { + padding: 6px 12px; + } + .match-room .room-page-heading .eyebrow { + display: none; + } + .match-room .room-page-heading h1 { + font-size: 18px; + } + .match-room .parchis-board, + .match-room .table-game { + --control-width: 220px; + } + .match-room .parchis-controls, + .match-room .game-controls { + padding: 12px; + gap: 8px; + max-height: 100%; + overflow: auto; + } + .match-room .parchis-controls > .eyebrow, + .match-room .table-players { + display: none; + } + .match-room .parchis-controls .dice-widget { + flex-direction: row; + gap: 8px; + } + .dice-widget .dice-result small { + font-size: var(--type-detail); + } + .match-room .parchis-controls .dice-instruction { + font-size: var(--type-secondary); + } +} diff --git a/src/lib/chat-feedback.ts b/src/lib/chat-feedback.ts new file mode 100644 index 0000000..3b68d13 --- /dev/null +++ b/src/lib/chat-feedback.ts @@ -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 + ); +} diff --git a/src/lib/client.svelte.ts b/src/lib/client.svelte.ts new file mode 100644 index 0000000..df5b191 --- /dev/null +++ b/src/lib/client.svelte.ts @@ -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(path: string, input?: unknown): Promise { + 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(); +} diff --git a/src/lib/components/AvatarImage.svelte b/src/lib/components/AvatarImage.svelte new file mode 100644 index 0000000..808ea61 --- /dev/null +++ b/src/lib/components/AvatarImage.svelte @@ -0,0 +1,26 @@ + + +{#if person?.avatar}{:else}{(person?.nick ?? person?.name ?? '?') + .slice(0, 2) + .toUpperCase()}{/if} + + diff --git a/src/lib/components/AvatarPicker.svelte b/src/lib/components/AvatarPicker.svelte new file mode 100644 index 0000000..c9f922d --- /dev/null +++ b/src/lib/components/AvatarPicker.svelte @@ -0,0 +1,212 @@ + + + +{#if open} (open = false)} + className="avatar-editor" + > +

Tu avatar

+

+ Elige uno o sube una imagen. Se mostrará en tus partidas y en el chat. +

+ +
+ {#each avatarPresets as option} + + {/each} +
+
+ +
+ upload(e.currentTarget.files?.[0])} + /> +

+ PNG, JPG o WebP, hasta 8 MB. La imagen se recorta al centro. +

+ {#if error}{/if} +
+ +
+
{/if} + + diff --git a/src/lib/components/Board.svelte b/src/lib/components/Board.svelte new file mode 100644 index 0000000..84488aa --- /dev/null +++ b/src/lib/components/Board.svelte @@ -0,0 +1,205 @@ + + +{#if room.gameId === 'parchis'} +
+
+
+ a.type === 'roll')} + {disabled} + definition={room.definition} + onroll={() => onaction({ type: 'roll' })} + /> +

+ {#if lastRoll}{feedback.title}.{/if}{feedback.next} +

+
+ {#each room.players as player}
+ + {player.nick}{player.seat === room.seat ? ' · tú' : ''} + {#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} +
{/each} +
+ {#if legal.some((a) => a.type === 'move')}
+ Fichas disponibles{#each legal.filter((a) => a.type === 'move') as move}{/each} +
{/if} +
+
+{:else if room.gameId === 'go'} + +{:else if room.gameId === 'oca'} + +{:else} +
+
+ {#each positions as i, visual}{/each} +
+
+ {#if promotion.length}
+

Elige la pieza para la promoción:

+ {#each promotion as action}{/each} +
{/if} +{/if} diff --git a/src/lib/components/BrandMark.svelte b/src/lib/components/BrandMark.svelte new file mode 100644 index 0000000..d3c2518 --- /dev/null +++ b/src/lib/components/BrandMark.svelte @@ -0,0 +1,4 @@ + + diff --git a/src/lib/components/CellInfo.svelte b/src/lib/components/CellInfo.svelte new file mode 100644 index 0000000..66a38fa --- /dev/null +++ b/src/lib/components/CellInfo.svelte @@ -0,0 +1,100 @@ + + + + + diff --git a/src/lib/components/CreateRoom.svelte b/src/lib/components/CreateRoom.svelte new file mode 100644 index 0000000..08bbc98 --- /dev/null +++ b/src/lib/components/CreateRoom.svelte @@ -0,0 +1,103 @@ + + + + +
+ +
+ {game.variant} +
+ +
+ +
+

+ La partida comienza cuando todas las plazas estén ocupadas y los jugadores + indiquen que están listos. +

+ + {#if error}{/if} + +
+
+ diff --git a/src/lib/components/Dialog.svelte b/src/lib/components/Dialog.svelte new file mode 100644 index 0000000..fef20be --- /dev/null +++ b/src/lib/components/Dialog.svelte @@ -0,0 +1,25 @@ + + + + {@render children()} + diff --git a/src/lib/components/Dice.svelte b/src/lib/components/Dice.svelte new file mode 100644 index 0000000..87f6e71 --- /dev/null +++ b/src/lib/components/Dice.svelte @@ -0,0 +1,591 @@ + + +{#snippet figure(large = false)} + {@const shownValue = large ? resultValue : value} + {@const shownRotation = large ? angles[(shownValue ?? 1) - 1] : rotation} + +{/snippet} +
+
+ {@render figure()}{value ? feedback.title : 'Sin tiradas todavía'} +
+ {#if canRoll}{/if} +
+{#if overlay} + +

+ {phase === 'ready' + ? 'Lanza el dado' + : phase === 'shaking' + ? 'Agita… y suelta' + : phase === 'result' + ? resultFeedback.title + : 'Tu dado está en el aire'} +

+
+ {#if phase === 'ready' || phase === 'shaking' || phase === 'waiting'}
+ ✦ +
+ + {:else}{@render figure(true)}{/if} +
+
+ {#if phase === 'result'}{resultValue} +

+ {onresultclose + ? 'Cierra el dado para ver avanzar tu ficha.' + : resultFeedback.next} +

{:else if phase === 'waiting'}

+ {slow + ? 'La confirmación está tardando. Puedes cerrar y comprobar la conexión.' + : 'Confirmando tu tirada…'} +

{:else if phase === 'ready' || phase === 'shaking'}

+ Mantén pulsado para agitar el cubilete.
Suéltalo para lanzar el + dado. +

{/if} +
+ {#if phase === 'ready' || phase === 'shaking'} + {:else if phase === 'result'}{/if} +
{/if} +{#if notice}{/if} + + diff --git a/src/lib/components/GameArt.svelte b/src/lib/components/GameArt.svelte new file mode 100644 index 0000000..c8655e7 --- /dev/null +++ b/src/lib/components/GameArt.svelte @@ -0,0 +1,323 @@ + + + + + diff --git a/src/lib/components/GoBoard.svelte b/src/lib/components/GoBoard.svelte new file mode 100644 index 0000000..68e82b6 --- /dev/null +++ b/src/lib/components/GoBoard.svelte @@ -0,0 +1,244 @@ + + +
+
+ + + + {#each Array(9) as _, n} + + {'ABCDEFGHJ'[n]} + {9 - n} + {/each} + {#each [[2, 2], [2, 6], [4, 4], [6, 2], [6, 6]] as [x, y]}{/each} + {#each points as point (point.index)} + + + + + {/each} + {#each grid as color, to} + {@const allowed = !disabled && legal.some((a) => a.to === to)} + play(to)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + play(to); + } + }} + > + + {#if room.state?.dead?.includes(to)}×{/if} + + {/each} + +
+
+ {scoring + ? 'RECUENTO' + : room.status === 'finished' + ? 'RESULTADO' + : room.state?.turn === room.seat + ? 'TU JUGADA' + : 'TURNO DEL RIVAL'} +

+ {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}.`} +

+
+ {#each room.players as player}
+ {player.nick}{player.seat === room.seat ? ' · tú' : ''}{scoring || room.status === 'finished' + ? `${room.state?.scores?.[player.seat] ?? 0} puntos` + : `${room.state?.captures?.[player.seat] ?? 0} capturas`}{room + .state?.accepted?.[player.seat] + ? ' · ✓ aceptado' + : ''} +
{/each} +
+ Blancas: komi de 6,5 puntos. + {#if legal.some((a) => a.type === 'pass')}{/if} + {#if scoring && room.status === 'playing'}{/if} +
+
+ + diff --git a/src/lib/components/GuestAccess.svelte b/src/lib/components/GuestAccess.svelte new file mode 100644 index 0000000..63db8ac --- /dev/null +++ b/src/lib/components/GuestAccess.svelte @@ -0,0 +1,20 @@ + + + +

Puedes jugar como invitado. Tu acceso quedará en este navegador y podrás registrar esta cuenta después.

+
{#if error}{/if}
+ +
diff --git a/src/lib/components/Icon.svelte b/src/lib/components/Icon.svelte new file mode 100644 index 0000000..6774bc8 --- /dev/null +++ b/src/lib/components/Icon.svelte @@ -0,0 +1,49 @@ + + + diff --git a/src/lib/components/Invitation.svelte b/src/lib/components/Invitation.svelte new file mode 100644 index 0000000..7f8d960 --- /dev/null +++ b/src/lib/components/Invitation.svelte @@ -0,0 +1,239 @@ + + + + + {#if loading}

Abriendo la invitación…

{:else if invitation} +
+
+ +
+
+ {invitation.name}{invitation.definition.name} · {invitation.players}/{invitation.maxPlayers} + jugadoresTe invita {invitation.host} +
+
+ {#if invitation.message}
+ {invitation.message} +
{/if} + {#if invitation.isHost}

+ Comparte este enlace con quien quieras invitar a la partida. +

+ + {:else if !invitation.available && !invitation.alreadyJoined}

+ Esta partida ya ha empezado o no quedan plazas. +

+ {:else if step === 'offer'}
+ +
+ {:else if session.user}

+ Entrarás como {session.user.nick}{session.user.guest + ? ' · invitado' + : ''}. +

+ + {:else if step === 'identity'}

Elige cómo entrar

+

+ El registro es opcional. Puedes crear una cuenta después. +

+ Crear una cuenta o iniciar sesión + {:else}
{ + e.preventDefault(); + enter(true); + }} + > + +
{/if} + {/if} + {#if error}{/if} +
+ + diff --git a/src/lib/components/OcaBoard.svelte b/src/lib/components/OcaBoard.svelte new file mode 100644 index 0000000..d38b41f --- /dev/null +++ b/src/lib/components/OcaBoard.svelte @@ -0,0 +1,320 @@ + + +
+
+ + {#each artwork.centers as _, n}{@const rect = tile(n)}{/each} + + {#each artwork.centers as _, n}{@const rect = tile(n)}{@const info = + ocaCell(n)} + { + 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(); + }} + > + + + {/each} + {#if inspected !== null}{@const rect = tile(inspected)} + + + + + {/if} + {#each displayed as cell, seat (seat)}{@const point = position(cell)} + t }} + data-player={seat} + data-cell={cell} + style:display={room.players[seat]?.withdrawnAt ? 'none' : undefined} + > + {#key seat === hopPlayer ? hop : 0} + 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`} + > + + {seat + 1} + + {/key} + + {/each} + + {#if inspected !== null}{/if} +
+
+ a.type === 'roll')} + onroll={() => onaction({ type: 'roll' })} + onresultclose={(id) => playback.release(Number(id))} + /> +

+ {#if room.state?.rolled}{feedback.title}{/if}{feedback.next} +

+

{room.state?.note}

+
+ {#each room.players as player}
+ {player.nick}{player.seat === room.seat ? ' · tú' : ''}{#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} +
{/each} +
+
+
+ + diff --git a/src/lib/components/ParchisMap.svelte b/src/lib/components/ParchisMap.svelte new file mode 100644 index 0000000..a6d0f80 --- /dev/null +++ b/src/lib/components/ParchisMap.svelte @@ -0,0 +1,434 @@ + + +
+ + + {#each homes as home, seat} + {@const owner = room.players.find((p) => houseForSeat(p.seat) === seat)} + + + + {#each Array(4) as _, i}{/each} + {owner?.nick.slice(0, 20) ?? 'Casa libre'} + + {/each} + {#each track as cell, i} + {@const seat = houseStarts.indexOf(i)} + {@const safe = room.state?.safe?.includes(i)} + {@const id = `track-${i}`} + { + 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)} + > + = 0 ? colors[seat] : safe ? '#e1e8d8' : '#fffdf7'} + stroke="#c8c9b9" + stroke-width="0.8" + /> + = 0 ? '#fff' : '#5c6855'} + font-size="9" + font-weight="600">{i + 1} + + {/each} + {#each colors as color, seat} + {#each Array(7) as _, step}{@const cell = lane( + seat, + step, + )}{@const id = `lane-${seat}-${step}`} { + 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)} + >{/each} + {/each} + + + + + { + 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')} + > + ✳ + + {#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, + )} + { + if (playable) onaction({ type: 'move', piece }); + }} + onkeydown={(e) => { + if (playable && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + onaction({ type: 'move', piece }); + } + }} + > + 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}`} + {#if playable}{/if} + + + + {progress === goal ? '✓' : piece + 1} + + {/each} + {/each} + + {#if inspected}{@const detail = describeCell(inspected)}{/if} +
+ + diff --git a/src/lib/components/PendingGames.svelte b/src/lib/components/PendingGames.svelte new file mode 100644 index 0000000..8fc2ca6 --- /dev/null +++ b/src/lib/components/PendingGames.svelte @@ -0,0 +1,102 @@ + + + + + diff --git a/src/lib/components/PlayerColorChooser.svelte b/src/lib/components/PlayerColorChooser.svelte new file mode 100644 index 0000000..4446713 --- /dev/null +++ b/src/lib/components/PlayerColorChooser.svelte @@ -0,0 +1,120 @@ + + +
+

Color de tus fichas

+ {#if automatic}
+ {#if me?.colorId}{colorForPlayer(me).label}{/if} +

+ {me?.colorId + ? 'Tu color se asigna automáticamente al ser el último jugador.' + : 'Tu color se asignará cuando los demás elijan.'} +

+
+ {:else}
+ {#each paletteForRoom(room) as color} + {@const owner = room.players.find( + (p) => + p.seat !== room.seat && + p.seat !== room.maxPlayers - 1 && + p.colorId === color.id, + )} + + {/each} +
+

+ Los colores elegidos por otras personas quedan reservados. +

{/if} +
+ + diff --git a/src/lib/components/RoomExit.svelte b/src/lib/components/RoomExit.svelte new file mode 100644 index 0000000..2b99b9d --- /dev/null +++ b/src/lib/components/RoomExit.svelte @@ -0,0 +1,194 @@ + + +{#if room.status !== 'finished' && !me?.withdrawnAt} + +{/if} +{#if open} + { + if (!pending) open = false; + }} + > + +
+

+ {room.status === 'waiting' + ? host + ? 'Cancelar esta sala' + : 'Dejar esta sala' + : 'Abandonar la partida'} +

+

+ {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.'} +

+ +
+ {#if room.status === 'playing'} +
+

El otro jugador no responde

+

+ Cierra la partida sin resultado después de 10 minutos esperando una + jugada de otro jugador. +

+

+ {eligible + ? 'Ya puedes cerrarla por inactividad.' + : waitingForOther + ? `Disponible en ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'} sin nuevas jugadas.` + : 'Ahora te corresponde jugar.'} +

+ +
+ {/if} +

+ Las salas se cierran automáticamente tras + 24 horas sin actividad de juego. +

+ {#if error}{/if} +
+{/if} + + diff --git a/src/lib/components/SoundControls.svelte b/src/lib/components/SoundControls.svelte new file mode 100644 index 0000000..7abc20d --- /dev/null +++ b/src/lib/components/SoundControls.svelte @@ -0,0 +1,94 @@ + + +
+ + +
diff --git a/src/lib/components/TermsConsent.svelte b/src/lib/components/TermsConsent.svelte new file mode 100644 index 0000000..d29d595 --- /dev/null +++ b/src/lib/components/TermsConsent.svelte @@ -0,0 +1,66 @@ + + +{#if visible} + +

Antes de jugar

+

{terms.introduction}

+

{terms.notice}

+ + +
+ + +
+ {#if error}{/if} +
+{/if} diff --git a/src/lib/components/ThemeControls.svelte b/src/lib/components/ThemeControls.svelte new file mode 100644 index 0000000..ce74afb --- /dev/null +++ b/src/lib/components/ThemeControls.svelte @@ -0,0 +1,16 @@ + +
+ + +
diff --git a/src/lib/dice-feedback.ts b/src/lib/dice-feedback.ts new file mode 100644 index 0000000..da9c416 --- /dev/null +++ b/src/lib/dice-feedback.ts @@ -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, + }; +} diff --git a/src/lib/oca-playback.ts b/src/lib/oca-playback.ts new file mode 100644 index 0000000..4a47fc8 --- /dev/null +++ b/src/lib/oca-playback.ts @@ -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[] = []; + 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); + } +} diff --git a/src/lib/sound.svelte.ts b/src/lib/sound.svelte.ts new file mode 100644 index 0000000..9be39fb --- /dev/null +++ b/src/lib/sound.svelte.ts @@ -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>(); +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); +} diff --git a/src/lib/theme.svelte.ts b/src/lib/theme.svelte.ts new file mode 100644 index 0000000..7a02859 --- /dev/null +++ b/src/lib/theme.svelte.ts @@ -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); +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..402b162 --- /dev/null +++ b/src/lib/types.ts @@ -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 })[]; +} diff --git a/src/match.css b/src/match.css new file mode 100644 index 0000000..c07e64c --- /dev/null +++ b/src/match.css @@ -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; + } +} diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..981cc90 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,13 @@ + + +No encontramos esta mesa · Juegoland +
+ ♧ +

{page.error?.status ?? 404}

+

{page.error?.message ?? 'No encontramos esta página.'}

+

Vuelve al catálogo para encontrar tu próxima partida.

+ Volver al catálogo +
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..c6b768c --- /dev/null +++ b/src/routes/+layout.server.ts @@ -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') ?? ''), + }; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..c42c1ed --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ {#if inRoom}{/if} + + {#if !inRoom}{/if} +
+ + + {session.user + ? session.connected + ? 'Conectado' + : 'Reconectando…' + : 'Vamos a jugar'}{#if session.user}{:else}{/if} +
+
+ {#if startupError}{/if} +
{@render children()}
+ +
+
+ + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..7348299 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,330 @@ + + +Juegoland · Tu próxima partida empieza aquí +
+
+
+
{brand.tagline.toUpperCase()}
+

Tu gente. Tus juegos.
¿Echamos una partida?

+

+ {brand.introduction}
Entra como invitado o + con tu cuenta. Tú eliges. +

+ Sin descargas. Con muchas ganas de jugar. +
+
+ +
+
+ ♟ +
+ Un tablero.Muchas historias por jugar. +
+
+ ✳ +
+
+
+ {session.user + ? `Hola, ${session.user.nick}. Tu sitio en la mesa te está esperando.` + : 'Una buena partida empieza con un «¿jugamos?».'}{loading + ? 'Buscando mesas…' + : `${open.length} ${open.length === 1 ? 'mesa abierta' : 'mesas abiertas'}`} +
+
+
+
+

Un clásico para cada momento.

+
+
+ {#each ['Todos', 'Estrategia', 'Familia'] as item}{/each} +
+
+
+ {#each filtered as game}
+
+ {game.category} +
+
+
+

{game.name}

+ {game.difficulty} +
+

{game.subtitle}

+
+ {game.minPlayers === + game.maxPlayers + ? game.minPlayers + : `${game.minPlayers}–${game.maxPlayers}`} jugadores{game.duration} +
+
+ +
+
+
{/each} +
+ {#if !filtered.length}
+ +

No encontramos ese juego

+

Prueba con ajedrez, damas o parchís.

+ +
{/if} +
+ {#if mine.length}
+
+
+

Tus partidas pendientes

+
+
+ +
{/if} +
+
+
+

Mesas abiertas

+
+
{ + e.preventDefault(); + if (code.trim()) join(code.trim()); + }} + > + + +
+ {#if error}{/if} +
+
+ Partida / JuegoJugadoresEstado +
+ {#if loading}
+ Preparando las mesas… +
{:else if !open.length}
+
♧
+

La primera mesa puede ser la tuya

+

+ Crea una partida e invita a alguien. Nosotros ponemos el tablero. +

+ +
{:else}{#each open as room}
+
+ {room.gameId === 'ajedrez' + ? '♞' + : room.gameId === 'damas' + ? '◉' + : '⚄'}{room.name}{definitions.find((g) => g.id === room.gameId)?.name} · {room + .players[0]?.nick} +
+
+ {#each room.players as player}{player.nick.slice(0, 1).toUpperCase()}{/each}{room.players.length}/{room.maxPlayers} +
+ {room.players.length === room.maxPlayers + ? 'Completa' + : 'Buscando jugadores'} +
{/each}{/if} +
+
+
+ ♔ +
+ Juega a tu ritmo. Vuelve cuando quieras. +

+ Tu historial guarda cada victoria, cada empate y cada buena partida. +

+
+ Mi historial +
+
+{#if modal} (modal = false)} + />{/if} +{#if guestAccess} (guestAccess = false)} + onready={() => { + guestAccess = false; + modal = true; + }} + />{/if} +{#if invitationCode} { + invitationCode = ''; + if (page.url.searchParams.has('invite')) replaceState('/', page.state); + }} + />{/if} diff --git a/src/routes/ayuda/+page.svelte b/src/routes/ayuda/+page.svelte new file mode 100644 index 0000000..b71d40a --- /dev/null +++ b/src/routes/ayuda/+page.svelte @@ -0,0 +1,56 @@ + + +Reglas y ayuda · Juegoland +
+ UN MOMENTO ANTES DE JUGAR +

Las reglas, sobre la mesa.

+

Estas son las variantes que se juegan en Juegoland.

+
+
+

Elige un juego. Invita. A jugar.

+

+ Crea una cuenta, abre una mesa pública o privada y comparte su código. + Cuando se llenen las plazas, todos los jugadores deben pulsar «Estoy listo». + Si pierdes la conexión, vuelve a la sala: el servidor conserva la partida. +

+
+
+ {#each definitions as game}
+ {game.category.toUpperCase()} +

{game.name}

+
+ {game.variant} +
+
    + {#each game.rules as rule}
  1. {rule}
  2. {/each} +
+ Encontrar una mesa +
{/each} +
+ PREGUNTAS FRECUENTES +

Sobre tus partidas

+
    +
  1. + El chat de cada sala es privado para sus participantes. Puedes + consultarlo también al volver a la partida. +
  2. +
  3. + Estas modalidades son casuales y sin reloj. Desconectarte no produce una + derrota automática. +
  4. +
  5. + El servidor valida cada movimiento, los dados y el resultado. Si tu + tablero cambia mientras eliges una jugada, tendrás que revisarla. +
  6. +
  7. + El historial distingue victorias, empates y derrotas. En el parchís + básico el primero gana y los demás comparten el segundo puesto. +
  8. +
+
+
diff --git a/src/routes/condiciones/+page.server.ts b/src/routes/condiciones/+page.server.ts new file mode 100644 index 0000000..7d6a813 --- /dev/null +++ b/src/routes/condiciones/+page.server.ts @@ -0,0 +1,7 @@ +import { redirect } from '@sveltejs/kit'; +import { termsReturnPath } from '../../../shared/terms.mjs'; + +// Preserve old bookmarked links after removing the conditions page. +export function load({ url }) { + redirect(307, termsReturnPath(url.searchParams.get('returnTo'), url.origin)); +} diff --git a/src/routes/cuenta/+page.svelte b/src/routes/cuenta/+page.svelte new file mode 100644 index 0000000..b934ed8 --- /dev/null +++ b/src/routes/cuenta/+page.svelte @@ -0,0 +1,142 @@ + + +Tu sitio en la mesa · Juegoland +{#if session.user && !session.user.guest}
+

Ya tienes sitio en la mesa

+

Has iniciado sesión como {session.user.nick}.

+ Descubrir juegos +
{:else}
+
+ BIENVENIDO A JUEGOLAND +

Un buen juego.
Una buena compañía.

+

Juega sin registrarte. Crea tu cuenta cuando quieras guardar tu acceso y volver a por la revancha.

+
+
+
+
+ +
+ {#if session.user?.guest}

Estás jugando como invitado. Al registrarte conservarás tus partidas.

{/if} +

+ {mode === 'register' + ? 'Tu sitio está reservado.' + : mode==='migrate-pin' ? 'Tu cuenta, ahora con PIN.' : 'Qué bien verte de nuevo.'} +

+

+ {mode === 'register' + ? 'Solo falta saber cómo te llamas.' + : 'Entra y continúa tu próxima partida.'} +

+
+ {#if mode === 'register'}
+ +
{/if}{#if mode==='migrate-pin'}{/if}{#if error}{/if} +
+

+ Tu correo es privado. Los demás jugadores ven tu nick. +

+ +
+
{/if} diff --git a/src/routes/perfil/+page.svelte b/src/routes/perfil/+page.svelte new file mode 100644 index 0000000..b1a4b87 --- /dev/null +++ b/src/routes/perfil/+page.svelte @@ -0,0 +1,158 @@ + + +Mis partidas · Juegoland +{#if session.loading}
+ Cargando tu perfil… +
{:else if !session.user}
+ +

Tu historia empieza con una partida

+

Crea una cuenta para guardar tus resultados y volver a tus mesas.

+ Crear mi cuenta +
{:else}
+ +
+

{session.user.nick}

+

+ {session.user.name} · Jugando desde {new Date( + session.user.createdAt, + ).toLocaleDateString('es-ES', { month: 'long', year: 'numeric' })} +

+ +
+
+
+ {#each [{ label: 'Partidas jugadas', value: played, note: 'Cada partida cuenta' }, { label: 'Victorias', value: wins, note: 'Una buena jugada' }, { label: 'Empates', value: draws, note: 'A la altura del rival' }, { label: 'Derrotas', value: losses, note: 'La revancha te espera' }] as stat}
+ {stat.label}{stat.value}{stat.note} +
{/each} +
+ {#if error}{/if} +
+
+

Tus partidas pendientes

+
+ {#if !profile}

+ Cargando tus partidas… +

{:else}{/if} +
+
+
+

Tu historial

+
+ Volver a jugar +
+ {/if} diff --git a/src/routes/salas/[id]/+page.svelte b/src/routes/salas/[id]/+page.svelte new file mode 100644 index 0000000..1a743fe --- /dev/null +++ b/src/routes/salas/[id]/+page.svelte @@ -0,0 +1,672 @@ + + +{room?.name ?? 'Tu mesa'} · Juegoland + (fullScreen = !!document.fullscreenElement)} +/> +
+ {#if session.loading || (loading && session.user)}
+ Preparando tu mesa… +
{:else if !session.user}
+

Tu mesa te espera

+

Inicia sesión para acceder a tus partidas.

+ Entrar +
{:else if !room}
+

No hemos podido abrir la mesa

+

{error}

+ +
{:else}
+
+ {room.definition?.name} +
+

{room.name}

+ {#if room.status === 'playing'}{room.state?.phase === 'scoring' + ? 'Recuento compartido' + : isTurn + ? 'Tu turno' + : `Juega ${turnPlayer?.nick}`}{room.state?.check + ? ' · jaque' + : ''}{/if} +
+
+
+ + + + +
+
+ {#if error}{/if} + {#if room.status === 'finished'}
+ ♔ +

+ {room.result?.cancelled + ? 'Partida cerrada' + : me?.outcome === 'win' + ? 'Una buena partida. Una gran victoria.' + : me?.outcome === 'draw' + ? 'Una partida a la altura.' + : 'La revancha te espera.'} +

+

+ {room.result?.cancelled + ? 'Sin resultado' + : room.result?.winner === null + ? 'Empate' + : `${room.players[room.result!.winner!].nick} gana`} · {room + .result?.reason} +

+ Volver a jugar Ver mi historial +
{/if} + {#if room.status === 'waiting' || room.state}
+
+ {#if room.status === 'waiting'}
+

Preparad la partida

+

+ Comparte el enlace o el código. Cuando estéis todos listos, + empezamos. +

+
+ {room.code} +
+
+ {#each Array(room.maxPlayers) as _, seat}{@const player = + room.players[seat]} +
+ {#if player}{:else}+{/if}{player?.nick ?? 'Un sitio libre'}{player + ? player.ready + ? '✓ Listo para jugar' + : player.colorId + ? colorForPlayer(player).label + : 'Preparándose' + : 'Esperando jugador'} +
{/each} +
+ {#if canChooseColor(room)} + send('room:color', { roomId: room!.id, colorId })} + />{/if} + +
{:else} + +
+ {room.status === 'finished' + ? 'Resultado guardado en tu historial' + : pending + ? 'Confirmando tu jugada…' + : isTurn + ? room.gameId === 'parchis' + ? 'Lanza el dado o elige una ficha disponible.' + : 'Selecciona una pieza y una casilla resaltada.' + : 'Espera la jugada del otro jugador.'}Sin reloj +
+ {/if} +
+ +
{/if}{/if} +
+{#if chatNotice}{/if} +{#if invitationOpen && room} (invitationOpen = false)} + > + +

+ Comparte este enlace con otra persona. Verá tu invitación y podrá entrar + como invitado o con su cuenta. +

+ +

También puedes compartir el código {room.code}.

+
{/if} diff --git a/src/terms.css b/src/terms.css new file mode 100644 index 0000000..17f69df --- /dev/null +++ b/src/terms.css @@ -0,0 +1,106 @@ +.terms-consent { + width: min(600px, calc(100vw - 32px)); + max-width: 600px; + padding: 32px; +} +.terms-consent h2 { + margin: 0 0 20px; + font-size: 24px; +} +.terms-consent p { + font-size: 16px; + line-height: 1.55; + margin: 0 0 16px; + color: var(--muted); +} +.terms-consent .terms-author { + color: var(--text); +} +.terms-read { + display: inline-block; + font-size: 16px; + font-weight: 650; + color: var(--green); + margin: 0 0 20px; + text-decoration: underline; + text-underline-offset: 3px; +} +.terms-consent .terms-check { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; + margin: 0 0 16px; + color: var(--text); + font-size: 16px; + line-height: 1.5; + font-weight: 600; +} +.terms-check input { + flex: 0 0 22px; + width: 22px; + height: 22px; + margin: 2px 0 0; + padding: 0; + accent-color: var(--green); +} +.terms-consent .terms-cookie { + font-size: 14px; + line-height: 1.5; +} +.terms-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 24px; +} +.terms-actions .button { + font-size: 16px; +} +.terms-page { + width: min(100%, 760px); + margin: 0 auto; + padding: 40px 32px; +} +.terms-page h1 { + font-size: 28px; + margin: 0 0 24px; +} +.terms-page h2 { + font-size: 20px; + margin: 32px 0 12px; +} +.terms-page p { + font-size: 16px; + line-height: 1.65; + margin-bottom: 16px; + color: var(--muted); +} +.terms-page .terms-introduction { + color: var(--text); +} +.terms-page .terms-version { + font-size: 14px; + margin-top: 32px; +} +.terms-page .button { + margin: 0 8px 12px 0; + font-size: 16px; +} +.site-footer .footer-author { + max-width: 42ch; +} +@media (max-width: 600px) { + .terms-consent { + padding: 24px; + } + .terms-page { + padding: 24px 20px; + } + .terms-actions { + flex-direction: column-reverse; + } + .terms-actions .button { + width: 100%; + } +} diff --git a/static/avatars/caballo.svg b/static/avatars/caballo.svg new file mode 100644 index 0000000..d896494 --- /dev/null +++ b/static/avatars/caballo.svg @@ -0,0 +1 @@ + diff --git a/static/avatars/dado.svg b/static/avatars/dado.svg new file mode 100644 index 0000000..a681e31 --- /dev/null +++ b/static/avatars/dado.svg @@ -0,0 +1 @@ + diff --git a/static/avatars/estrella.svg b/static/avatars/estrella.svg new file mode 100644 index 0000000..15eb26c --- /dev/null +++ b/static/avatars/estrella.svg @@ -0,0 +1 @@ + diff --git a/static/avatars/gato.svg b/static/avatars/gato.svg new file mode 100644 index 0000000..310c4e6 --- /dev/null +++ b/static/avatars/gato.svg @@ -0,0 +1 @@ + diff --git a/static/avatars/oca.svg b/static/avatars/oca.svg new file mode 100644 index 0000000..db72e28 --- /dev/null +++ b/static/avatars/oca.svg @@ -0,0 +1 @@ + diff --git a/static/avatars/robot.svg b/static/avatars/robot.svg new file mode 100644 index 0000000..664a29a --- /dev/null +++ b/static/avatars/robot.svg @@ -0,0 +1 @@ + diff --git a/static/avatars/torre.svg b/static/avatars/torre.svg new file mode 100644 index 0000000..bfc95e2 --- /dev/null +++ b/static/avatars/torre.svg @@ -0,0 +1 @@ + diff --git a/static/avatars/zorro.svg b/static/avatars/zorro.svg new file mode 100644 index 0000000..7351e4e --- /dev/null +++ b/static/avatars/zorro.svg @@ -0,0 +1 @@ + diff --git a/static/boards/oca-spiral.png b/static/boards/oca-spiral.png new file mode 100644 index 0000000..0e97aa6 Binary files /dev/null and b/static/boards/oca-spiral.png differ diff --git a/static/branding/juegoland-logo-transparent.png b/static/branding/juegoland-logo-transparent.png new file mode 100644 index 0000000..ec17cd2 Binary files /dev/null and b/static/branding/juegoland-logo-transparent.png differ diff --git a/static/branding/juegoland-logo.png b/static/branding/juegoland-logo.png new file mode 100644 index 0000000..d855a55 Binary files /dev/null and b/static/branding/juegoland-logo.png differ diff --git a/static/branding/juegoland-share-v1.png b/static/branding/juegoland-share-v1.png new file mode 100644 index 0000000..edf7557 Binary files /dev/null and b/static/branding/juegoland-share-v1.png differ diff --git a/static/branding/juegoland-share-v1.svg b/static/branding/juegoland-share-v1.svg new file mode 100644 index 0000000..f39452f --- /dev/null +++ b/static/branding/juegoland-share-v1.svg @@ -0,0 +1,21 @@ + + + + + +¿Echamos +una partida? +Ajedrez, damas, parchís, go y la oca. +Entra, invita y juega en compañía. +juegoland.imaginacion.com + + + + + + + + + + + \ No newline at end of file diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..ccdcb1a --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/static/sounds/capture.wav b/static/sounds/capture.wav new file mode 100644 index 0000000..ed5bdb0 Binary files /dev/null and b/static/sounds/capture.wav differ diff --git a/static/sounds/card.wav b/static/sounds/card.wav new file mode 100644 index 0000000..0e65133 Binary files /dev/null and b/static/sounds/card.wav differ diff --git a/static/sounds/check.wav b/static/sounds/check.wav new file mode 100644 index 0000000..2f20ec3 Binary files /dev/null and b/static/sounds/check.wav differ diff --git a/static/sounds/coin.wav b/static/sounds/coin.wav new file mode 100644 index 0000000..90cc63f Binary files /dev/null and b/static/sounds/coin.wav differ diff --git a/static/sounds/dice.wav b/static/sounds/dice.wav new file mode 100644 index 0000000..40c05ec Binary files /dev/null and b/static/sounds/dice.wav differ diff --git a/static/sounds/draw.wav b/static/sounds/draw.wav new file mode 100644 index 0000000..460bca4 Binary files /dev/null and b/static/sounds/draw.wav differ diff --git a/static/sounds/loss.wav b/static/sounds/loss.wav new file mode 100644 index 0000000..24983ea Binary files /dev/null and b/static/sounds/loss.wav differ diff --git a/static/sounds/piece.wav b/static/sounds/piece.wav new file mode 100644 index 0000000..ea35482 Binary files /dev/null and b/static/sounds/piece.wav differ diff --git a/static/sounds/promotion.wav b/static/sounds/promotion.wav new file mode 100644 index 0000000..30d789f Binary files /dev/null and b/static/sounds/promotion.wav differ diff --git a/static/sounds/start.wav b/static/sounds/start.wav new file mode 100644 index 0000000..b0b6b53 Binary files /dev/null and b/static/sounds/start.wav differ diff --git a/static/sounds/stone.wav b/static/sounds/stone.wav new file mode 100644 index 0000000..75c25b0 Binary files /dev/null and b/static/sounds/stone.wav differ diff --git a/static/sounds/turn.wav b/static/sounds/turn.wav new file mode 100644 index 0000000..8ca064c Binary files /dev/null and b/static/sounds/turn.wav differ diff --git a/static/sounds/v2/brisca-card.wav b/static/sounds/v2/brisca-card.wav new file mode 100644 index 0000000..2f86457 Binary files /dev/null and b/static/sounds/v2/brisca-card.wav differ diff --git a/static/sounds/v2/build.wav b/static/sounds/v2/build.wav new file mode 100644 index 0000000..e037b90 Binary files /dev/null and b/static/sounds/v2/build.wav differ diff --git a/static/sounds/v2/card-collect.wav b/static/sounds/v2/card-collect.wav new file mode 100644 index 0000000..e75bfa2 Binary files /dev/null and b/static/sounds/v2/card-collect.wav differ diff --git a/static/sounds/v2/card-play.wav b/static/sounds/v2/card-play.wav new file mode 100644 index 0000000..fec3bfb Binary files /dev/null and b/static/sounds/v2/card-play.wav differ diff --git a/static/sounds/v2/check.wav b/static/sounds/v2/check.wav new file mode 100644 index 0000000..c5281ea Binary files /dev/null and b/static/sounds/v2/check.wav differ diff --git a/static/sounds/v2/checkers-capture.wav b/static/sounds/v2/checkers-capture.wav new file mode 100644 index 0000000..7036718 Binary files /dev/null and b/static/sounds/v2/checkers-capture.wav differ diff --git a/static/sounds/v2/checkers-move.wav b/static/sounds/v2/checkers-move.wav new file mode 100644 index 0000000..fbaa46f Binary files /dev/null and b/static/sounds/v2/checkers-move.wav differ diff --git a/static/sounds/v2/chess-capture.wav b/static/sounds/v2/chess-capture.wav new file mode 100644 index 0000000..1614f47 Binary files /dev/null and b/static/sounds/v2/chess-capture.wav differ diff --git a/static/sounds/v2/chess-move.wav b/static/sounds/v2/chess-move.wav new file mode 100644 index 0000000..c575162 Binary files /dev/null and b/static/sounds/v2/chess-move.wav differ diff --git a/static/sounds/v2/coin-buy.wav b/static/sounds/v2/coin-buy.wav new file mode 100644 index 0000000..c32f193 Binary files /dev/null and b/static/sounds/v2/coin-buy.wav differ diff --git a/static/sounds/v2/coin-payment.wav b/static/sounds/v2/coin-payment.wav new file mode 100644 index 0000000..f55e547 Binary files /dev/null and b/static/sounds/v2/coin-payment.wav differ diff --git a/static/sounds/v2/cup-shake.wav b/static/sounds/v2/cup-shake.wav new file mode 100644 index 0000000..8f01c9e Binary files /dev/null and b/static/sounds/v2/cup-shake.wav differ diff --git a/static/sounds/v2/dice-roll.wav b/static/sounds/v2/dice-roll.wav new file mode 100644 index 0000000..abcde43 Binary files /dev/null and b/static/sounds/v2/dice-roll.wav differ diff --git a/static/sounds/v2/draw.wav b/static/sounds/v2/draw.wav new file mode 100644 index 0000000..c99837e Binary files /dev/null and b/static/sounds/v2/draw.wav differ diff --git a/static/sounds/v2/go-capture.wav b/static/sounds/v2/go-capture.wav new file mode 100644 index 0000000..d2e8d45 Binary files /dev/null and b/static/sounds/v2/go-capture.wav differ diff --git a/static/sounds/v2/go-place.wav b/static/sounds/v2/go-place.wav new file mode 100644 index 0000000..c6c6293 Binary files /dev/null and b/static/sounds/v2/go-place.wav differ diff --git a/static/sounds/v2/goal.wav b/static/sounds/v2/goal.wav new file mode 100644 index 0000000..1be249f Binary files /dev/null and b/static/sounds/v2/goal.wav differ diff --git a/static/sounds/v2/loss.wav b/static/sounds/v2/loss.wav new file mode 100644 index 0000000..b0796ff Binary files /dev/null and b/static/sounds/v2/loss.wav differ diff --git a/static/sounds/v2/mystery-accuse.wav b/static/sounds/v2/mystery-accuse.wav new file mode 100644 index 0000000..9644197 Binary files /dev/null and b/static/sounds/v2/mystery-accuse.wav differ diff --git a/static/sounds/v2/mystery-suggest.wav b/static/sounds/v2/mystery-suggest.wav new file mode 100644 index 0000000..62ae6f6 Binary files /dev/null and b/static/sounds/v2/mystery-suggest.wav differ diff --git a/static/sounds/v2/oca-bonus.wav b/static/sounds/v2/oca-bonus.wav new file mode 100644 index 0000000..8ce38ff Binary files /dev/null and b/static/sounds/v2/oca-bonus.wav differ diff --git a/static/sounds/v2/oca-move.wav b/static/sounds/v2/oca-move.wav new file mode 100644 index 0000000..7005e9b Binary files /dev/null and b/static/sounds/v2/oca-move.wav differ diff --git a/static/sounds/v2/oca-penalty.wav b/static/sounds/v2/oca-penalty.wav new file mode 100644 index 0000000..13d2f5d Binary files /dev/null and b/static/sounds/v2/oca-penalty.wav differ diff --git a/static/sounds/v2/parchis-capture.wav b/static/sounds/v2/parchis-capture.wav new file mode 100644 index 0000000..7dbab95 Binary files /dev/null and b/static/sounds/v2/parchis-capture.wav differ diff --git a/static/sounds/v2/parchis-move.wav b/static/sounds/v2/parchis-move.wav new file mode 100644 index 0000000..bd88828 Binary files /dev/null and b/static/sounds/v2/parchis-move.wav differ diff --git a/static/sounds/v2/promotion.wav b/static/sounds/v2/promotion.wav new file mode 100644 index 0000000..ef180bb Binary files /dev/null and b/static/sounds/v2/promotion.wav differ diff --git a/static/sounds/v2/risk-attack.wav b/static/sounds/v2/risk-attack.wav new file mode 100644 index 0000000..f3604b2 Binary files /dev/null and b/static/sounds/v2/risk-attack.wav differ diff --git a/static/sounds/v2/risk-reinforce.wav b/static/sounds/v2/risk-reinforce.wav new file mode 100644 index 0000000..f28808c Binary files /dev/null and b/static/sounds/v2/risk-reinforce.wav differ diff --git a/static/sounds/v2/start.wav b/static/sounds/v2/start.wav new file mode 100644 index 0000000..968c475 Binary files /dev/null and b/static/sounds/v2/start.wav differ diff --git a/static/sounds/v2/turn.wav b/static/sounds/v2/turn.wav new file mode 100644 index 0000000..cfcd0d9 Binary files /dev/null and b/static/sounds/v2/turn.wav differ diff --git a/static/sounds/v2/tute-card.wav b/static/sounds/v2/tute-card.wav new file mode 100644 index 0000000..ce74dc7 Binary files /dev/null and b/static/sounds/v2/tute-card.wav differ diff --git a/static/sounds/v2/win.wav b/static/sounds/v2/win.wav new file mode 100644 index 0000000..133844d Binary files /dev/null and b/static/sounds/v2/win.wav differ diff --git a/static/sounds/win.wav b/static/sounds/win.wav new file mode 100644 index 0000000..e45e590 Binary files /dev/null and b/static/sounds/win.wav differ diff --git a/static/sounds/wood.wav b/static/sounds/wood.wav new file mode 100644 index 0000000..36c61d2 Binary files /dev/null and b/static/sounds/wood.wav differ diff --git a/tests/audio-assets.test.ts b/tests/audio-assets.test.ts new file mode 100644 index 0000000..637473e --- /dev/null +++ b/tests/audio-assets.test.ts @@ -0,0 +1,46 @@ +import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { expect, it } from 'vitest'; +import { gameSounds } from '../shared/sounds.mjs'; + +it('todos los efectos declarados existen, son WAV válidos y contienen audio', async () => { + const paths = new Set( + Object.values(gameSounds).flatMap((game) => + Object.values(game).map((effect) => effect.src), + ), + ); + for (const path of paths) { + const bytes = await readFile(new URL(`../static${path}`, import.meta.url)); + expect(bytes.toString('ascii', 0, 4)).toBe('RIFF'); + expect(bytes.toString('ascii', 8, 12)).toBe('WAVE'); + const rate = bytes.readUInt32LE(24), + length = bytes.readUInt32LE(40); + expect(length).toBe(bytes.length - 44); + expect(length / (rate * 2)).toBeGreaterThanOrEqual(0.1); + let energy = 0; + for (let i = 44; i < bytes.length; i += 2) + energy += bytes.readInt16LE(i) ** 2; + expect(energy / (length / 2)).toBeGreaterThan(10000); + } +}); +it('los movimientos de los cinco juegos y las dos acciones del cubilete tienen audios distintos', async () => { + const effects = [ + gameSounds.ajedrez.move, + gameSounds.damas.move, + gameSounds.go.move, + gameSounds.parchis.move, + gameSounds.oca.move, + gameSounds.parchis.shake, + gameSounds.parchis.roll, + ]; + const hashes = await Promise.all( + effects.map(async (effect) => + createHash('sha256') + .update( + await readFile(new URL(`../static${effect.src}`, import.meta.url)), + ) + .digest('hex'), + ), + ); + expect(new Set(hashes).size).toBe(effects.length); +}); diff --git a/tests/avatar.test.ts b/tests/avatar.test.ts new file mode 100644 index 0000000..52997be --- /dev/null +++ b/tests/avatar.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { validateAvatar, avatarReference } from '../server/avatar.mjs'; +import { publicUser } from '../server/auth.mjs'; +const png = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=='; +describe('avatares personales', () => { + it('acepta los avatares locales y permite volver a las iniciales', () => { + expect(validateAvatar({ preset: 'oca' })).toEqual({ preset: 'oca' }); + expect(validateAvatar({ preset: null })).toBeNull(); + expect(() => + validateAvatar({ preset: 'https://unknown/avatar.svg' }), + ).toThrow(); + expect(avatarReference({ avatar: { preset: 'gato' } })).toBe( + '/avatars/gato.svg', + ); + }); + it('guarda la imagen y devuelve solo su referencia pública, sin datos privados', () => { + const avatar = validateAvatar({ image: png }); + const user = publicUser({ + id: 'test', + nick: 'Test', + password: 'private', + email: 'secret', + avatar, + }); + expect(user?.avatar).toMatch(/^\/api\/avatars\/test\?v=[a-f0-9]{16}$/); + expect(JSON.stringify(user)).not.toContain('base64'); + expect(user).not.toHaveProperty('password'); + expect(user).not.toHaveProperty('email'); + }); + it('rechaza SVG, contenido falso, imágenes excesivas y dimensiones grandes', () => { + for (const image of [ + 'data:image/svg+xml;base64,AAAA', + 'data:image/png;base64,AAAA', + png + 'A'.repeat(90000), + ]) + expect(() => validateAvatar({ image })).toThrow(); + const bytes = Buffer.from(png.slice(22), 'base64'); + bytes.writeUInt32BE(4096, 16); + expect(() => + validateAvatar({ + image: 'data:image/png;base64,' + bytes.toString('base64'), + }), + ).toThrow('256'); + }); +}); diff --git a/tests/chat-feedback.test.ts b/tests/chat-feedback.test.ts new file mode 100644 index 0000000..c276163 --- /dev/null +++ b/tests/chat-feedback.test.ts @@ -0,0 +1,23 @@ +import { expect, it } from 'vitest'; +import { incomingChat } from '../src/lib/chat-feedback.js'; +import type { Room } from '../src/lib/types.js'; + +const message={id:'one',userId:'ana',nick:'Ana',text:'¿Jugamos?',createdAt:'2026-10-03T20:00:00Z'}; +const room={id:'table',messages:[]} as unknown as Room; +it('la carga inicial del historial no genera notificaciones',()=>{ + expect(incomingChat(null,{...room,messages:[message]},'luis')).toBeNull(); + expect(incomingChat({...room,id:'other'},{...room,messages:[message]},'luis')).toBeNull(); +}); +it('los mensajes propios no generan avisos de otro jugador',()=>{ + expect(incomingChat(room,{...room,messages:[message]},'ana')).toBeNull(); +}); +it('una confirmación duplicada no vuelve a avisar del mismo mensaje',()=>{ + const next={...room,messages:[message]}; + expect(incomingChat(room,next,'luis')).toEqual(message); + expect(incomingChat(next,next,'luis')).toBeNull(); +}); +it('si llegan varios mensajes muestra el último nuevo de otra persona',()=>{ + const latest={...message,id:'two',text:'Te espero en la mesa'}; + const mine={...message,id:'three',userId:'luis',text:'Voy'}; + expect(incomingChat(room,{...room,messages:[message,latest,mine]},'luis')).toEqual(latest); +}); diff --git a/tests/dice-feedback.test.ts b/tests/dice-feedback.test.ts new file mode 100644 index 0000000..e38f326 --- /dev/null +++ b/tests/dice-feedback.test.ts @@ -0,0 +1,83 @@ +import { expect, it } from 'vitest'; +import { diceFeedback } from '../src/lib/dice-feedback.js'; +import type { Room } from '../src/lib/types.js'; + +const room: Room = { + id: 'table', + code: 'code', + name: 'Parchís', + gameId: 'parchis', + gameVersion: '2.0.0', + status: 'playing', + visibility: 'private', + maxPlayers: 2, + version: 2, + createdAt: '', + seat: 0, + players: [ + { userId: 'ana', nick: 'Ana', seat: 0, ready: true }, + { userId: 'luis', nick: 'Luis', seat: 1, ready: true }, + ], + state: { turn: 0, rolled: 6, die: 7 }, + events: [ + { + sequence: 1, + type: 'roll', + actorId: 'ana', + nick: 'Ana', + label: 'Dado: 6 · avanza 7', + createdAt: '', + }, + ], + legalActions: [{ type: 'move', piece: 0 }], +}; + +it('el jugador que tira recibe la distancia real y la ficha que debe elegir', () => { + expect(diceFeedback(room)).toMatchObject({ + own: true, + title: 'Has sacado un 6', + next: 'Elige una ficha resaltada para avanzar 7 casillas.', + }); +}); +it('los demás ven quién ha tirado y quién debe mover, sin instrucciones propias', () => { + expect(diceFeedback({ ...room, seat: 1, legalActions: [] })).toMatchObject({ + own: false, + title: 'Ana ha sacado un 6', + next: 'Ana debe elegir una ficha para avanzar 7 casillas.', + }); +}); +it('el cambio automático de turno no convierte una tirada ajena en una tirada propia', () => { + const next = { + ...room, + seat: 1, + state: { turn: 1, rolled: 3, die: null }, + legalActions: [{ type: 'roll' }], + }; + expect(diceFeedback(next)).toMatchObject({ + own: false, + title: 'Ana ha sacado un 3', + next: 'Es tu turno. Puedes lanzar el dado.', + }); + expect(diceFeedback({ ...next, seat: 0 })).toMatchObject({ + own: true, + next: 'Ahora juega Luis. Espera su tirada.', + }); +}); +it('un tercer seis anuncia el fin del turno y no promete otra tirada', () => { + const next = { + ...room, + state: { turn: 1, rolled: 6, die: null }, + events: [{ ...room.events![0], label: 'Tercer seis: termina el turno' }], + }; + expect(diceFeedback(next).next).toBe( + 'Tercer seis: tu turno ha terminado. Ahora juega Luis. Espera su tirada.', + ); +}); +it('los apodos repetidos no cambian la identidad del jugador que tira', () => { + const next = { + ...room, + players: [room.players[0], { ...room.players[1], nick: 'Ana' }], + events: [{ ...room.events![0], actorId: 'luis' }], + }; + expect(diceFeedback(next).own).toBe(false); +}); diff --git a/tests/games.test.ts b/tests/games.test.ts new file mode 100644 index 0000000..9037b01 --- /dev/null +++ b/tests/games.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest'; +import { chess } from '../server/games/chess.mjs'; +import { checkers } from '../server/games/checkers.mjs'; +import { getGame } from '../server/games/registry.mjs'; +import { parchis } from '../server/games/parchis.mjs'; + +describe('ajedrez: reglas en el servidor', () => { + it('rechaza movimientos ilegales y jugar fuera de turno', () => { + expect(() => + chess.apply(chess.create(), { type: 'move', from: 'e2', to: 'e5' }, 0), + ).toThrow(); + expect(chess.legal(chess.create(), 1)).toEqual([]); + expect(() => + chess.apply(chess.create(), { type: 'move', from: 'e7', to: 'e5' }, 1), + ).toThrow(); + }); + it('detecta el mate y preserva el historial para reconstruir la partida', () => { + let state = chess.create(); + let result; + for (const [from, to, seat] of [ + ['f2', 'f3', 0], + ['e7', 'e5', 1], + ['g2', 'g4', 0], + ['d8', 'h4', 1], + ] as const) { + const move = chess.apply(state, { type: 'move', from, to }, seat); + state = move.state; + result = move.result; + } + expect(result).toEqual({ winner: 1, reason: 'jaque mate' }); + expect(chess.view(state).pgn).toContain('Qh4#'); + }); + it('conserva la repetición triple al reconstruir desde movimientos', () => { + let state = chess.create(); + let result; + for (let round = 0; round < 2; round++) + for (const [from, to, seat] of [ + ['g1', 'f3', 0], + ['g8', 'f6', 1], + ['f3', 'g1', 0], + ['f6', 'g8', 1], + ] as const) { + const applied = chess.apply(state, { type: 'move', from, to }, seat); + state = applied.state; + result = applied.result; + } + expect(result?.winner).toBeNull(); + }); +}); + +describe('damas con recorrido largo', () => { + it('aplica las correcciones también a las partidas ya creadas', () => { + expect(getGame('damas', '1.0.0')).toBe(checkers); + expect(getGame('damas', '2.0.0')).toBe(checkers); + }); + const position = () => ({ + board: Array(64).fill(0), + turn: 0, + chain: null, + quietMoves: 0, + }); + it('fuerza capturas y encadena saltos con la misma pieza', () => { + const state = position(); + state.board[42] = 1; + state.board[33] = 2; + state.board[17] = 2; + state.board[46] = 1; + expect(checkers.legal(state, 0)).toEqual([ + { type: 'move', from: 42, to: 24, capture: 33 }, + ]); + const first = checkers.apply(state, { type: 'move', from: 42, to: 24 }, 0); + expect(first.state.turn).toBe(0); + expect(first.state.chain).toBe(24); + expect(() => + checkers.apply(first.state, { type: 'move', from: 46, to: 37 }, 0), + ).toThrow(); + const next = checkers.apply( + first.state, + { type: 'move', from: 24, to: 10 }, + 0, + ); + expect(next.state.board[17]).toBe(0); + expect(next.result?.winner).toBe(0); + }); + it('la dama recorre diagonales completas y una ficha propia bloquea el paso', () => { + const state = position(); + state.board[42] = -1; + expect(checkers.legal(state, 0).map((m) => m.to)).toEqual( + expect.arrayContaining([33, 24, 35, 28, 21, 14, 7, 49, 56, 51, 60]), + ); + state.board[35] = 1; + expect( + checkers + .legal(state, 0) + .filter((m) => m.from === 42) + .map((m) => m.to), + ).not.toEqual(expect.arrayContaining([28])); + expect( + checkers.legal(state, 0).some((m) => m.from === 42 && m.to === 7), + ).toBe(false); + }); + it('la dama captura a distancia y conserva la pieza en una segunda captura', () => { + const state = position(); + state.board[42] = -1; + state.board[35] = 2; + state.board[19] = 2; + state.board[5] = 2; + expect( + checkers + .legal(state, 0) + .filter((m) => m.capture === 35) + .map((m) => m.to), + ).toEqual([28, 21, 14, 7]); + const first = checkers.apply(state, { type: 'move', from: 42, to: 28 }, 0); + expect(first.state.chain).toBe(28); + expect(first.state.turn).toBe(0); + expect(checkers.legal(first.state, 0)).toEqual([ + { type: 'move', from: 28, to: 10, capture: 19 }, + { type: 'move', from: 28, to: 1, capture: 19 }, + ]); + const second = checkers.apply( + first.state, + { type: 'move', from: 28, to: 1 }, + 0, + ); + expect(second.state.board[35]).toBe(0); + expect(second.state.board[19]).toBe(0); + expect(second.state.board[1]).toBe(-1); + expect(second.state.chain).toBeNull(); + expect(second.state.turn).toBe(1); + }); + it('no permite saltar dos adversarios en un solo salto', () => { + const state = position(); + state.board[42] = -1; + state.board[35] = 2; + state.board[28] = 2; + expect( + checkers + .legal(state, 0) + .some((m) => m.to === 21 || m.capture !== undefined), + ).toBe(false); + }); + it('termina la captura al coronar y permite que las damas retrocedan', () => { + const state = position(); + state.board[17] = 1; + state.board[10] = 2; + state.board[12] = 2; + const result = checkers.apply(state, { type: 'move', from: 17, to: 3 }, 0); + expect(result.state.board[3]).toBe(-1); + expect(result.state.chain).toBeNull(); + expect(result.state.turn).toBe(1); + expect( + checkers.legal({ ...result.state, turn: 0 }, 0).some((m) => m.to > 3), + ).toBe(true); + }); + it('declara tablas por inactividad y rechaza entradas arbitrarias', () => { + const state = position(); + state.board[42] = -1; + state.board[5] = -2; + state.quietMoves = 79; + expect( + checkers.apply(state, { type: 'move', from: 42, to: 33 }, 0).result + ?.winner, + ).toBeNull(); + expect(() => + checkers.apply(state, { type: 'move', from: -1, to: 100 }, 0), + ).toThrow(); + }); +}); + +describe('parchís básico', () => { + it('el dado procede del servidor y salir con un cinco es obligatorio', () => { + const state = parchis.create(2); + const rolled = parchis.apply(state, { type: 'roll', die: 6 }, 0, () => 5); + expect(rolled.state.die).toBe(5); + rolled.state.pieces[0][1] = 10; + expect(parchis.legal(rolled.state, 0).every((a) => a.piece !== 1)).toBe( + true, + ); + expect( + parchis.apply(rolled.state, { type: 'move', piece: 0 }, 0).state + .pieces[0][0], + ).toBe(0); + expect(() => parchis.apply(state, { type: 'roll' }, 1, () => 5)).toThrow(); + }); + it('bloquea el paso a través de barreras', () => { + const state = parchis.create(2); + state.pieces[0] = [0, 2, 2, -1]; + state.die = 4; + expect(parchis.legal(state, 0).some((a) => a.piece === 0)).toBe(false); + }); + it('captura fuera de un seguro y concede 20 pasos', () => { + const state = parchis.create(2); + state.pieces[0][0] = 20; + state.pieces[1][0] = 5; + state.die = 2; + const result = parchis.apply(state, { type: 'move', piece: 0 }, 0); + expect(result.state.pieces[1][0]).toBe(-1); + expect(result.state.die).toBe(20); + expect(result.state.bonus).toBe(true); + }); + it('protege los seguros y exige entrada exacta a meta', () => { + const state = parchis.create(2); + state.pieces[0][0] = 15; + state.pieces[1][0] = 0; + state.die = 2; + expect(parchis.legal(state, 0).some((a) => a.piece === 0)).toBe(false); + state.pieces[0][0] = 70; + state.die = 2; + expect(parchis.legal(state, 0)).toEqual([{ type: 'pass' }]); + }); + it('acaba al llevar las cuatro fichas a meta y conserva el turno extra', () => { + const state = parchis.create(2); + state.pieces[0] = [71, 71, 71, 70]; + state.die = 1; + expect(parchis.apply(state, { type: 'move', piece: 3 }, 0).result).toEqual({ + winner: 0, + reason: 'cuatro fichas en meta', + }); + const roll = parchis.apply(parchis.create(2), { type: 'roll' }, 0, () => 6); + expect(roll.state.turn).toBe(0); + expect(roll.state.die).toBeNull(); + }); +}); diff --git a/tests/go-oca.test.ts b/tests/go-oca.test.ts new file mode 100644 index 0000000..e3a1799 --- /dev/null +++ b/tests/go-oca.test.ts @@ -0,0 +1,64 @@ +import { expect, it } from 'vitest'; +import { go } from '../server/games/go.mjs'; +import { oca } from '../server/games/oca.mjs'; + +it('Go captura grupos sin libertades y prohíbe el suicidio', () => { + const state = go.create(); + state.board[10] = 2; + for (const n of [1, 9, 19]) state.board[n] = 1; + const next = go.apply(state, { type: 'move', to: 11 }, 0).state; + expect(next.board[10]).toBe(0); + expect(next.captures).toEqual([1, 0]); + expect(() => go.apply(next, { type: 'move', to: 10 }, 1)).toThrow(); +}); +it('Go no permite repetir una posición anterior', () => { + const state = go.create(), + next = [...state.board]; + next[40] = 1; + state.history.push(next.join('')); + expect(go.legal(state, 0).some((a) => a.type === 'move' && a.to === 40)).toBe( + false, + ); +}); +it('Go necesita dos pases y el acuerdo de ambos para terminar', () => { + let state = go.apply(go.create(), { type: 'pass' }, 0).state; + state = go.apply(state, { type: 'pass' }, 1).state; + expect(state.phase).toBe('scoring'); + const first = go.apply(state, { type: 'accept-score' }, 0); + expect(first.result).toBeNull(); + const second = go.apply(first.state, { type: 'accept-score' }, 1); + expect(second.result?.winner).toBe(1); + expect(second.state.scores).toEqual([0, 6.5]); +}); +it('la oca resuelve el movimiento, la repetición y los descansos en el servidor', () => { + const first = oca.apply(oca.create(2), { type: 'roll' }, 0, () => 5); + expect(first.state.positions[0]).toBe(9); + expect(first.state.turn).toBe(0); + expect(first.state.lastEffect).toBe('bonus'); + const state = oca.create(2); + state.positions = [18, 1]; + const inn = oca.apply(state, { type: 'roll' }, 0, () => 1).state; + expect(inn.skips[0]).toBe(1); + expect(inn.turn).toBe(1); + expect(inn.lastEffect).toBe('penalty'); + const next = oca.apply(inn, { type: 'roll' }, 1, () => 1).state; + expect(next.turn).toBe(1); + expect(next.skips[0]).toBe(0); +}); +it('la oca rebota si se pasa y exige el número exacto para ganar', () => { + const state = oca.create(2); + state.positions[0] = 61; + expect( + oca.apply(state, { type: 'roll' }, 0, () => 4).state.positions[0], + ).toBe(61); + expect(oca.apply(state, { type: 'roll' }, 0, () => 2).result?.winner).toBe(0); +}); +it('la última oca conserva la posición y su mensaje no anuncia una llegada a meta', () => { + const state = oca.create(2); + state.positions[0] = 58; + const result = oca.apply(state, { type: 'roll' }, 0, () => 1); + expect(result.state.positions[0]).toBe(59); + expect(result.state.turn).toBe(0); + expect(result.label).toContain('oca: 59'); + expect(result.result).toBeNull(); +}); diff --git a/tests/identity.test.ts b/tests/identity.test.ts new file mode 100644 index 0000000..54c9ca3 --- /dev/null +++ b/tests/identity.test.ts @@ -0,0 +1,209 @@ +import { acceptedCookie } from './terms-fixture.js'; +import { it, expect } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { FileStore } from '../server/store.mjs'; +import { createService } from '../server/service.mjs'; +import { hashPassword } from '../server/auth.mjs'; + +it('invitación pública limitada, invitado opcional, registro conservando identidad y bloqueo del PIN', async () => { + const dir = await mkdtemp(join(tmpdir(), 'juegoland-identity-')), + store = new FileStore(join(dir, 'store.json')); + await store.init(); + const service = createService(store, { + NODE_ENV: 'test', + APP_ORIGIN: 'http://localhost:4173', + }); + await new Promise((resolve) => + service.server.listen(0, '127.0.0.1', resolve), + ); + const address = service.server.address(); + if (!address || typeof address === 'string') throw new Error('Sin puerto'); + const base = `http://127.0.0.1:${address.port}/api`; + const request = (path: string, data?: unknown, cookie = '') => + fetch(base + path, { + method: data === undefined ? 'GET' : 'POST', + headers: { + origin: 'http://localhost:4173', + cookie: acceptedCookie(cookie), + 'content-type': 'application/json', + }, + body: data === undefined ? undefined : JSON.stringify(data), + }); + const cookieOf = (r: Response) => r.headers.get('set-cookie')!.split(';')[0]; + try { + const hostResponse = await request('/auth/guest', { name: 'Anfitrión' }), + host = (await hostResponse.json()).user, + hostCookie = cookieOf(hostResponse); + const created = await request( + '/rooms', + { + gameId: 'parchis', + name: 'Una ronda juntos', + visibility: 'private', + maxPlayers: 2, + invitationMessage: 'Te espero para jugar.', + }, + hostCookie, + ), + room = (await created.json()).room; + const own = ( + await ( + await request(`/invites/${room.code}`, undefined, hostCookie) + ).json() + ).invitation; + expect(own.isHost).toBe(true); + expect(own.alreadyJoined).toBe(true); + expect(own.players).toBe(1); + const invite = (await (await request(`/invites/${room.code}`)).json()) + .invitation; + expect(invite.message).toBe('Te espero para jugar.'); + expect(invite.host).toBe(host.nick); + expect(invite).not.toHaveProperty('state'); + expect(invite).not.toHaveProperty('createdBy'); + expect(invite.isHost).toBe(false); + const guestResponse = await request('/auth/guest', { + name: 'Invitado', + code: room.code, + }), + guest = (await guestResponse.json()).user, + guestCookie = cookieOf(guestResponse); + expect(guestResponse.status).toBe(201); + expect(guest.guest).toBe(true); + expect(guest).not.toHaveProperty('pinHash'); + expect( + (await request('/rooms/join', { code: room.code }, guestCookie)).status, + ).toBe(200); + expect( + ( + await request( + `/rooms/${room.id}/invitation`, + { message: 'Otro mensaje' }, + guestCookie, + ) + ).status, + ).toBe(400); + expect( + ( + await request( + `/rooms/${room.id}/invitation`, + { message: '¡Vamos a jugar!' }, + hostCookie, + ) + ).status, + ).toBe(200); + const response = await request( + '/auth/register', + { + name: 'Invitado registrado', + nick: 'jugador_registrado', + email: 'guest@example.test', + pin: '0248', + }, + guestCookie, + ); + expect(response.status).toBe(201); + const registered = (await response.json()).user; + expect(registered.id).toBe(guest.id); + expect(registered.guest).toBe(false); + expect(registered.createdAt).toBe(guest.createdAt); + expect( + (await store.room(room.id)).players.some( + (p: { userId: string }) => p.userId === registered.id, + ), + ).toBe(true); + const account = await store.userByEmail('guest@example.test'); + expect(account.pinHash).toBeTruthy(); + expect(account).not.toHaveProperty('pin'); + expect(account).not.toHaveProperty('password'); + expect( + ( + await request('/auth/login', { + email: 'guest@example.test', + pin: '0248', + }) + ).status, + ).toBe(200); + for (let i = 0; i < 5; i++) + expect( + ( + await request('/auth/login', { + email: 'guest@example.test', + pin: '9999', + }) + ).status, + ).toBe(401); + expect( + ( + await request('/auth/login', { + email: 'guest@example.test', + pin: '0248', + }) + ).status, + ).toBe(429); + const reopened = new FileStore(join(dir, 'store.json')); + await reopened.init(); + expect( + (await reopened.userByEmail('guest@example.test')).pinFailures + .lockedUntil, + ).toBeGreaterThan(Date.now()); + await reopened.close(); + } finally { + await service.close(); + await store.close(); + await rm(dir, { recursive: true, force: true }); + } +}, 20000); + +it('migra la contraseña anterior una sola vez sin cambiar el jugador y revoca sus sesiones', async () => { + const dir = await mkdtemp(join(tmpdir(), 'juegoland-pin-')), + store = new FileStore(join(dir, 'store.json')); + await store.init(); + const id = randomUUID(); + await store.createUser({ + id, + email: 'legacy@example.test', + name: 'Anterior', + nick: 'anterior', + createdAt: new Date().toISOString(), + password: await hashPassword('clave-de-prueba-anterior'), + }); + const service = createService(store, { + NODE_ENV: 'test', + APP_ORIGIN: 'http://localhost:4173', + }); + await new Promise((resolve) => + service.server.listen(0, '127.0.0.1', resolve), + ); + const address = service.server.address(); + if (!address || typeof address === 'string') throw new Error('Sin puerto'); + const migrate = () => + fetch(`http://127.0.0.1:${address.port}/api/auth/migrate-pin`, { + method: 'POST', + headers: { + origin: 'http://localhost:4173', + cookie: acceptedCookie(), + 'content-type': 'application/json', + }, + body: JSON.stringify({ + email: 'legacy@example.test', + password: 'clave-de-prueba-anterior', + pin: '0248', + }), + }); + try { + const response = await migrate(); + expect(response.status).toBe(200); + expect((await response.json()).user.id).toBe(id); + expect(await store.userByEmail('legacy@example.test')).not.toHaveProperty( + 'password', + ); + expect((await migrate()).status).toBe(401); + } finally { + await service.close(); + await store.close(); + await rm(dir, { recursive: true, force: true }); + } +}, 20000); diff --git a/tests/invitations.test.ts b/tests/invitations.test.ts new file mode 100644 index 0000000..fd48931 --- /dev/null +++ b/tests/invitations.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest'; +import { invitationUrl } from '../shared/invitations.mjs'; + +it('shares the public HTTPS address in production even behind an internal proxy', () => { + expect(invitationUrl('E4DAC4D795E6', 'http://192.168.18.155', true)).toBe( + 'https://juegoland.imaginacion.com/?invite=E4DAC4D795E6', + ); +}); + +it('keeps invitations local in development and encodes the invitation parameter', () => { + expect(invitationUrl('a&b', 'http://localhost:4173', false)).toBe( + 'http://localhost:4173/?invite=a%26b', + ); +}); diff --git a/tests/oca-cells.test.ts b/tests/oca-cells.test.ts new file mode 100644 index 0000000..2dbab06 --- /dev/null +++ b/tests/oca-cells.test.ts @@ -0,0 +1,26 @@ +import { it, expect } from 'vitest'; +import { oca } from '../server/games/oca.mjs'; +import { ocaCell, ocaBoard } from '../shared/oca-board.mjs'; +it('la información de peligros y premios corresponde a la acción real', () => { + for (const [cell, skips] of [ + [19, 1], + [31, 3], + [52, 2], + ]) { + const state = oca.create(2); + state.positions[0] = cell - 1; + const next = oca.apply(state, { type: 'roll' }, 0, () => 1).state; + expect(next.skips[0]).toBe(skips); + expect(ocaCell(cell).kind).toBe('danger'); + } + const state = oca.create(2); + state.positions[0] = 4; + const next = oca.apply(state, { type: 'roll' }, 0, () => 1).state; + expect(next.positions[0]).toBe(9); + expect(next.turn).toBe(0); + expect(ocaCell(5).description).toContain('9'); + expect(ocaCell(5).kind).toBe('advantage'); + expect(ocaBoard.centers).toHaveLength(64); + expect(new Set(ocaBoard.centers.map((p) => p.join(','))).size).toBe(64); + expect(ocaBoard.centers[63][0]).toBeGreaterThan(ocaBoard.centers[62][0]); +}); diff --git a/tests/oca-playback.test.ts b/tests/oca-playback.test.ts new file mode 100644 index 0000000..c1fddd2 --- /dev/null +++ b/tests/oca-playback.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { OcaPlayback } from '../src/lib/oca-playback.js'; +import type { Room } from '../src/lib/types.js'; +import { paletteForRoom } from '../shared/player-colors.mjs'; +afterEach(() => vi.useRealTimers()); +function fixture() { + vi.useFakeTimers(); + const frame = vi.fn(), + busy = vi.fn(), + sound = vi.fn(), + arrival = vi.fn(); + const playback = new OcaPlayback({ + frame, + busy, + sound, + arrival, + reducedMotion: () => false, + }); + const room = { + id: 'oca', + seat: 0, + players: [ + { seat: 0, userId: 'one' }, + { seat: 1, userId: 'two' }, + ], + state: { positions: [0, 0] }, + events: [], + } as unknown as Room; + playback.receive(room); + frame.mockClear(); + const roll = (sequence: number, player = 0) => + ({ + ...room, + state: { + ...room.state, + lastPlayer: player, + positions: player ? [0, 3] : [3, 0], + lastPath: [1, 2, 3, 3], + lastEffect: null, + }, + events: [ + { + sequence, + type: 'roll', + actorId: player ? 'two' : 'one', + createdAt: new Date().toISOString(), + }, + ], + }) as Room; + return { playback, frame, busy, sound, arrival, roll }; +} +describe('presentación de las tiradas de la oca', () => { + it('espera al cierre del resultado y recorre cada casilla sin repetir la llegada', () => { + const { playback, frame, sound, roll, busy } = fixture(); + playback.receive(roll(1)); + vi.advanceTimersByTime(60000); + expect(frame).not.toHaveBeenCalled(); + expect(sound).not.toHaveBeenCalled(); + playback.release(1); + vi.runAllTimers(); + expect(frame.mock.calls.map((c) => c[0])).toEqual([ + [1, 0], + [2, 0], + [3, 0], + ]); + expect(busy).toHaveBeenLastCalledWith(false); + playback.dispose(); + }); + it('conserva una tirada rival que llega antes de cerrar el dado propio', () => { + const { playback, frame, roll } = fixture(); + playback.receive(roll(1)); + playback.receive(roll(2, 1)); + vi.advanceTimersByTime(1000); + expect(frame).not.toHaveBeenCalled(); + playback.release(1); + vi.runAllTimers(); + expect(frame.mock.calls.map((c) => c[0])).toEqual([ + [1, 0], + [2, 0], + [3, 0], + [3, 1], + [3, 2], + [3, 3], + ]); + playback.dispose(); + }); + it('presenta la tirada rival después del dado y cancela el recorrido al salir', () => { + const { playback, frame, roll } = fixture(); + playback.receive(roll(1, 1)); + vi.advanceTimersByTime(799); + expect(frame).not.toHaveBeenCalled(); + vi.advanceTimersByTime(2); + expect(frame).toHaveBeenCalledOnce(); + playback.dispose(); + vi.runAllTimers(); + expect(frame).toHaveBeenCalledOnce(); + }); + it('prepara ocho colores únicos para juegos con más participantes', () => { + expect(paletteForRoom({ maxPlayers: 4 })).toHaveLength(4); + expect(paletteForRoom({ maxPlayers: 8 }).map((c) => c.id)).toEqual([ + 'amarillo', + 'azul', + 'rojo', + 'verde', + 'negro', + 'naranja', + 'violeta', + 'gris', + ]); + }); +}); diff --git a/tests/parchis.test.ts b/tests/parchis.test.ts new file mode 100644 index 0000000..1c87227 --- /dev/null +++ b/tests/parchis.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from 'vitest'; +import { parchis } from '../server/games/parchis-classic.mjs'; +describe('parchís individual de un dado', () => { + it('usa la salida elegida al calcular capturas y conserva partidas antiguas', () => { + const state = parchis.create(2, ['verde', 'rojo']); + expect(parchis.view(state).starts).toEqual([55, 38]); + state.pieces[0][0] = 0; + state.pieces[1][0] = 18; + state.die = 1; + const moved = parchis.apply(state, { type: 'move', piece: 0 }, 0).state; + expect(moved.pieces[1][0]).toBe(-1); + expect(moved.die).toBe(20); + const old = parchis.create(2); + delete old.starts; + expect(parchis.view(old).starts).toEqual([4, 21, 38, 55]); + expect(() => parchis.create(2, ['verde', 'verde'])).toThrow('distintos'); + }); + it('resuelve tiradas sin movimientos automáticamente y permite actuar al siguiente jugador', () => { + const next = parchis.apply( + parchis.create(4), + { type: 'roll' }, + 0, + () => 3, + ).state; + expect(next.turn).toBe(1); + expect(next.die).toBeNull(); + expect(parchis.legal(next, 0)).toEqual([]); + expect(parchis.legal(next, 1)).toEqual([{ type: 'roll' }]); + const moved = parchis.apply( + parchis.apply(next, { type: 'roll' }, 1, () => 5).state, + { type: 'move', piece: 0 }, + 1, + ).state; + expect(moved.turn).toBe(2); + expect(moved.pieces[1][0]).toBe(0); + }); + it('conserva el seis sin movimiento y penaliza el tercero consecutivo', () => { + let state = parchis.create(2); + for (let n = 0; n < 2; n++) { + state = parchis.apply(state, { type: 'roll' }, 0, () => 6).state; + expect(state.turn).toBe(0); + expect(state.die).toBeNull(); + } + state = parchis.apply(state, { type: 'roll' }, 0, () => 6).state; + expect(state.turn).toBe(1); + state = parchis.create(2); + state.pieces[0][0] = 8; + for (let n = 0; n < 2; n++) { + state = parchis.apply(state, { type: 'roll' }, 0, () => 6).state; + state = parchis.apply(state, { type: 'move', piece: 0 }, 0).state; + } + state = parchis.apply(state, { type: 'roll' }, 0, () => 6).state; + expect(state.pieces[0][0]).toBe(-1); + expect(state.turn).toBe(1); + }); + it('no devuelve a casa la última ficha si está en el pasillo final', () => { + const state = parchis.create(2); + state.pieces[0][0] = 67; + state.sixes = 2; + state.lastMoved = 0; + expect( + parchis.apply(state, { type: 'roll' }, 0, () => 6).state.pieces[0][0], + ).toBe(67); + }); + it('cuenta siete sin fichas en casa y obliga a abrir barrera', () => { + const state = parchis.create(2); + state.pieces[0] = [0, 0, 20, 72]; + const next = parchis.apply(state, { type: 'roll' }, 0, () => 6).state; + expect(next.die).toBe(7); + expect(parchis.legal(next, 0).map((a) => a.piece)).toEqual([0, 1]); + }); + it('comparte un seguro con un rival y atraviesa dos colores distintos', () => { + const state = parchis.create(3); + state.pieces[0][0] = 15; + state.pieces[1][0] = 0; + state.die = 2; + const shared = parchis.apply(state, { type: 'move', piece: 0 }, 0).state; + expect(shared.pieces[1][0]).toBe(0); + expect(shared.pieces[0][0]).toBe(17); + state.pieces[0][0] = 16; + state.pieces[2][0] = 51; + state.die = 3; + expect(parchis.legal(state, 0).some((a) => a.piece === 0)).toBe(true); + }); + it('captura al último rival en una salida llena y concede el premio', () => { + const state = parchis.create(3); + state.pieces[1][0] = 51; + state.pieces[2][0] = 34; + state.arrivals[1][0] = 1; + state.arrivals[2][0] = 2; + state.die = 5; + const next = parchis.apply(state, { type: 'move', piece: 0 }, 0).state; + expect(next.pieces[2][0]).toBe(-1); + expect(next.pieces[1][0]).toBe(51); + expect(next.die).toBe(20); + expect(next.turn).toBe(0); + }); + it('descarta premios imposibles sin pedir pasar turno y conserva una repetición por seis', () => { + const state = parchis.create(2); + state.pieces[0] = [71, 72, 72, -1]; + state.die = 1; + const next = parchis.apply(state, { type: 'move', piece: 0 }, 0).state; + expect(next.turn).toBe(1); + expect(next.die).toBeNull(); + expect(next.bonus).toBe(false); + state.extraTurn = true; + expect(parchis.apply(state, { type: 'move', piece: 0 }, 0).state.turn).toBe( + 0, + ); + }); + it('termina solo con entrada exacta a meta y mantiene el último seguro antes del pasillo', () => { + const state = parchis.create(2); + state.pieces[0] = [72, 72, 72, 71]; + state.die = 2; + expect(parchis.legal(state, 0)).toEqual([{ type: 'pass' }]); + state.die = 1; + expect(parchis.apply(state, { type: 'move', piece: 3 }, 0).result).toEqual({ + winner: 0, + reason: 'cuatro fichas en meta', + }); + state.pieces[0] = [63, 72, 72, 72]; + state.die = 1; + const next = parchis.apply(state, { type: 'move', piece: 0 }, 0).state; + expect(next.pieces[0][0]).toBe(64); + expect(parchis.view(next).outerLength).toBe(65); + expect(parchis.view(next).safe).toHaveLength(16); + }); +}); diff --git a/tests/platform.test.ts b/tests/platform.test.ts new file mode 100644 index 0000000..0e74d12 --- /dev/null +++ b/tests/platform.test.ts @@ -0,0 +1,214 @@ +import { beforeEach, afterEach, describe, it, expect } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { FileStore } from '../server/store.mjs'; +import { Platform } from '../server/platform.mjs'; +import { hashPassword, verifyPassword, cookieToken } from '../server/auth.mjs'; +import type { Player, Room, Profile } from '../src/lib/types.js'; + +let directory: string, store: FileStore, platform: Platform; +const one = { id: randomUUID(), nick: 'JugadorUno' }, + two = { id: randomUUID(), nick: 'JugadorDos' }, + outsider = { id: randomUUID(), nick: 'OtroJugador' }; +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'mesa-test-')); + store = new FileStore(join(directory, 'store.json')); + await store.init(); + platform = new Platform(store); +}); +afterEach(async () => { + await store.close(); + await rm(directory, { recursive: true, force: true }); +}); +const create = () => + platform.create(one, { + gameId: 'ajedrez', + name: 'Mesa de prueba', + maxPlayers: 2, + visibility: 'private', + }); +async function start() { + const room = await create(); + await platform.join(two, room.code); + await platform.ready(one, room.id, true); + await platform.ready(two, room.id, true); + return platform.view(room.id, one.id); +} + +describe('persistencia y permisos', () => { + it('reserva colores de forma atómica y asigna el restante al cuarto jugador', async () => { + const fourth = { id: randomUUID(), nick: 'Cuarto' }; + const room = await platform.create(one, { + gameId: 'parchis', + name: 'Colores', + maxPlayers: 4, + visibility: 'private', + }); + await platform.join(two, room.code); + await platform.join(outsider, room.code); + await platform.join(fourth, room.code); + const choices = await Promise.allSettled([ + platform.chooseColor(one, room.id, 'rojo'), + platform.chooseColor(two, room.id, 'rojo'), + ]); + expect(choices.filter((c) => c.status === 'fulfilled')).toHaveLength(1); + const first = (await store.room(room.id)).players.find( + (p: Player) => p.colorId === 'rojo', + ); + const other = first.userId === one.id ? two : one; + await platform.chooseColor(other, room.id, 'azul'); + await platform.chooseColor(outsider, room.id, 'verde'); + expect((await store.room(room.id)).players[3].colorId).toBe('amarillo'); + await expect( + platform.chooseColor(fourth, room.id, 'amarillo'), + ).rejects.toThrow('último'); + await expect(platform.chooseColor(one, room.id, 'negro')).rejects.toThrow( + 'disponible', + ); + for (const user of [one, two, outsider, fourth]) + await platform.ready(user, room.id, true); + const view = (await platform.view(room.id, one.id)) as Room; + const exits: Record = { + rojo: 38, + azul: 21, + verde: 55, + amarillo: 4, + }; + expect(view.status).toBe('playing'); + expect(view.state?.starts).toEqual( + view.players.map((p) => exits[p.colorId!]), + ); + await expect( + platform.chooseColor(one, room.id, 'amarillo'), + ).rejects.toThrow('sala de espera'); + }); + it('recalcula el color automático al entrar el último y conserva una elección al reiniciar', async () => { + const room = await platform.create(one, { + gameId: 'oca', + name: 'Colores Oca', + maxPlayers: 2, + visibility: 'private', + }); + await platform.chooseColor(one, room.id, 'verde'); + await platform.join(two, room.code); + expect((await store.room(room.id)).players[1].colorId).toBe('amarillo'); + await platform.chooseColor(one, room.id, 'amarillo'); + expect((await store.room(room.id)).players[1].colorId).toBe('azul'); + const recovered = new FileStore(join(directory, 'store.json')); + await recovered.init(); + expect( + (await new Platform(recovered).view(room.id, one.id)).players[0].colorId, + ).toBe('amarillo'); + await recovered.close(); + await platform.ready(two, room.id, true); + await platform.ready(one, room.id, true); + expect((await platform.view(room.id, one.id)).status).toBe('playing'); + }); + it('no expone salas privadas, correos ni credenciales', async () => { + const room = await create(); + expect(await platform.list(outsider.id)).toEqual([]); + await expect(platform.view(room.id, outsider.id)).rejects.toThrow(); + await expect(platform.message(outsider, room.id, 'hola')).rejects.toThrow(); + expect(await platform.view(room.id, one.id)).not.toHaveProperty('actions'); + }); + it('serializa entradas concurrentes y respeta el límite de plazas', async () => { + const room = await create(); + const joins = await Promise.allSettled([ + platform.join(two, room.code), + platform.join(outsider, room.code), + ]); + expect(joins.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + expect((await store.room(room.id))?.players).toHaveLength(2); + }); + it('la jugada repetida se aplica una sola vez; otra versión se rechaza', async () => { + const room = await start(); + const action = { + roomId: room.id, + actionId: randomUUID(), + expectedVersion: room.version, + action: { type: 'move', from: 'e2', to: 'e4' }, + }; + await Promise.all([ + platform.action(one, action), + platform.action(one, action), + ]); + expect((await store.room(room.id))?.events).toHaveLength(1); + await expect( + platform.action(two, { + ...action, + actionId: randomUUID(), + action: { type: 'move', from: 'e7', to: 'e5' }, + }), + ).rejects.toThrow('actualizado'); + await expect(platform.action(two, action)).rejects.toThrow('identificador'); + }); + it('recupera una partida después de reiniciar el almacenamiento', async () => { + const room = await start(); + await platform.action(one, { + roomId: room.id, + actionId: randomUUID(), + expectedVersion: room.version, + action: { type: 'move', from: 'e2', to: 'e4' }, + }); + const recovered = new FileStore(join(directory, 'store.json')); + await recovered.init(); + const view = await new Platform(recovered).view(room.id, two.id); + expect(view.state?.turn).toBe(1); + expect(view.events).toHaveLength(1); + await recovered.close(); + }); + it('registra el resultado y las estadísticas una sola vez', async () => { + const room = await start(); + const input = { + roomId: room.id, + actionId: randomUUID(), + expectedVersion: room.version, + action: { type: 'resign' }, + }; + await platform.action(one, input); + await platform.action(one, input); + const first = await platform.profile(one.id), + second = await platform.profile(two.id); + expect(first.stats[0].losses).toBe(1); + expect(second.stats[0].wins).toBe(1); + expect(first.history[0].myResult.placement).toBe(2); + }); + it('el perfil incluye solo partidas propias pendientes y retira las terminadas', async () => { + const waiting = await create(), + playing = await start(); + await platform.create(outsider, { + gameId: 'ajedrez', + name: 'Mesa pública ajena', + maxPlayers: 2, + visibility: 'public', + }); + const pending: Profile['pending'] = (await platform.profile(one.id)) + .pending; + expect(pending.map((r) => r.id).sort()).toEqual( + [waiting.id, playing.id].sort(), + ); + expect(pending.find((r) => r.id === playing.id)?.myTurn).toBe(true); + expect( + (await platform.profile(two.id)).pending.map((r: Room) => r.id), + ).toEqual([playing.id]); + await platform.action(one, { + roomId: playing.id, + actionId: randomUUID(), + expectedVersion: playing.version, + action: { type: 'resign' }, + }); + expect( + (await platform.profile(one.id)).pending.map((r: Room) => r.id), + ).toEqual([waiting.id]); + }); + it('guarda y verifica contraseñas con sal, y valida cookies', async () => { + const encoded = await hashPassword('contraseña-de-prueba'); + expect(encoded).not.toContain('contraseña'); + expect(await verifyPassword('contraseña-de-prueba', encoded)).toBe(true); + expect(await verifyPassword('incorrecta', encoded)).toBe(false); + expect(cookieToken('mesa_session=' + 'a'.repeat(64))).toBe('a'.repeat(64)); + expect(cookieToken('mesa_session=incorrecto')).toBeNull(); + }); +}); diff --git a/tests/room-lifecycle-service.test.ts b/tests/room-lifecycle-service.test.ts new file mode 100644 index 0000000..19a0b2a --- /dev/null +++ b/tests/room-lifecycle-service.test.ts @@ -0,0 +1,154 @@ +import { acceptedCookie } from './terms-fixture.js'; +import { it, expect } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { io, type Socket } from 'socket.io-client'; +import { FileStore } from '../server/store.mjs'; +import { createService } from '../server/service.mjs'; +import type { Room } from '../src/lib/types.js'; + +it('HTTP exits and inactivity closure notify real sockets and update both profiles', async () => { + const directory = await mkdtemp(join(tmpdir(), 'juegoland-exit-service-')); + const store = new FileStore(join(directory, 'store.json')); + await store.init(); + const origin = 'http://localhost:4173'; + const service = createService(store, { + APP_ORIGIN: origin, + NODE_ENV: 'test', + }); + await new Promise((resolve) => + service.server.listen(0, '127.0.0.1', resolve), + ); + const address = service.server.address(); + if (!address || typeof address === 'string') throw new Error('Sin dirección'); + const base = `http://127.0.0.1:${address.port}`; + const sockets: Socket[] = []; + const request = (path: string, cookie = '', data?: unknown) => + fetch(base + '/api' + path, { + method: data === undefined ? 'GET' : 'POST', + headers: { + cookie: acceptedCookie(cookie), + origin, + 'content-type': 'application/json', + }, + body: data === undefined ? undefined : JSON.stringify(data), + }); + async function guest(name: string) { + const response = await request('/auth/guest', '', { name }); + return { + user: (await response.json()).user, + cookie: response.headers.get('set-cookie')!.split(';')[0], + }; + } + async function socketFor(cookie: string, roomId: string) { + const socket = io(base, { + forceNew: true, + transports: ['websocket'], + extraHeaders: { cookie: acceptedCookie(cookie), origin }, + }); + sockets.push(socket); + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('connect_error', reject); + }); + const response = await socket + .timeout(4000) + .emitWithAck('room:subscribe', { roomId }); + expect(response.ok).toBe(true); + return socket; + } + try { + const a = await guest('Anfitrión'), + b = await guest('Invitado'), + outsider = await guest('Fuera'); + const room = ( + await ( + await request('/rooms', a.cookie, { + gameId: 'oca', + name: 'Sala para salir', + visibility: 'private', + maxPlayers: 2, + }) + ).json() + ).room; + await request('/rooms/join', b.cookie, { code: room.code }); + const one = await socketFor(a.cookie, room.id), + two = await socketFor(b.cookie, room.id); + const removed = new Promise<{ roomId: string }>((resolve) => + two.once('room:removed', resolve), + ); + const before = await service.platform.view(room.id, b.user.id); + expect( + ( + await request(`/rooms/${room.id}/leave`, b.cookie, { + expectedVersion: before.version, + }) + ).status, + ).toBe(200); + expect((await removed).roomId).toBe(room.id); + expect( + (await (await request('/profile', b.cookie)).json()).pending, + ).toHaveLength(0); + expect((await request(`/rooms/${room.id}`, b.cookie)).status).toBe(400); + await request('/rooms/join', b.cookie, { code: room.code }); + await two.timeout(4000).emitWithAck('room:subscribe', { roomId: room.id }); + await service.platform.ready(a.user, room.id, true); + await service.platform.ready(b.user, room.id, true); + const playing = await service.platform.view(room.id, b.user.id); + expect( + ( + await request(`/rooms/${room.id}/close-inactive`, b.cookie, { + expectedVersion: playing.version, + }) + ).status, + ).toBe(400); + await store.updateRoom( + room.id, + (stored: any) => + (stored.lastGameActivityAt = new Date( + Date.now() - 11 * 60_000, + ).toISOString()), + ); + expect( + ( + await request(`/rooms/${room.id}/close-inactive`, outsider.cookie, { + expectedVersion: playing.version, + }) + ).status, + ).toBe(400); + const finished = (socket: Socket) => + new Promise((resolve) => { + socket.on('room:state', function listen(state: Room) { + if (state.status === 'finished') { + socket.off('room:state', listen); + resolve(state); + } + }); + }); + const notices = Promise.all([finished(one), finished(two)]); + expect( + ( + await request(`/rooms/${room.id}/close-inactive`, b.cookie, { + expectedVersion: playing.version, + }) + ).status, + ).toBe(200); + expect((await notices).every((state) => state.result?.cancelled)).toBe( + true, + ); + for (const player of [a, b]) { + const profile = await (await request('/profile', player.cookie)).json(); + expect(profile.pending).toHaveLength(0); + expect(profile.history[0].result.cancelled).toBe(true); + expect( + profile.stats.every((s: { played: number }) => s.played === 0), + ).toBe(true); + } + } finally { + for (const socket of sockets) socket.disconnect(); + await service.close(); + await store.close(); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/tests/room-lifecycle.test.ts b/tests/room-lifecycle.test.ts new file mode 100644 index 0000000..ac96ecc --- /dev/null +++ b/tests/room-lifecycle.test.ts @@ -0,0 +1,209 @@ +import { beforeEach, afterEach, it, expect } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { FileStore } from '../server/store.mjs'; +import { Platform } from '../server/platform.mjs'; +import { getGame } from '../server/games/registry.mjs'; +import { + inactivityPolicy, + canCloseInactive, +} from '../shared/room-lifecycle.mjs'; + +let directory: string, store: FileStore, platform: Platform; +const players = Array.from({ length: 4 }, (_, i) => ({ + id: randomUUID(), + nick: `Jugador${i}`, +})); +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'juegoland-lifecycle-')); + store = new FileStore(join(directory, 'store.json')); + await store.init(); + platform = new Platform(store); +}); +afterEach(async () => { + await store.close(); + await rm(directory, { recursive: true, force: true }); +}); +async function create(count = 2, gameId = 'oca') { + const room = await platform.create(players[0], { + gameId, + maxPlayers: count, + name: 'Partida de prueba', + visibility: 'private', + }); + for (const player of players.slice(1, count)) + await platform.join(player, room.code); + return room.id; +} +async function start(count = 2, gameId = 'oca') { + const id = await create(count, gameId); + for (const player of players.slice(0, count)) + await platform.ready(player, id, true); + return id; +} + +it('leaving a waiting room frees a seat, resets readiness and lets someone else join', async () => { + const id = await create(4, 'parchis'); + await platform.ready(players[0], id, true); + const before = await platform.view(id, players[1].id); + await platform.leave(players[1], id, before.version); + const room = await store.room(id); + expect(room.players.map((p: { seat: number }) => p.seat)).toEqual([0, 1, 2]); + expect(room.players.every((p: { ready: boolean }) => !p.ready)).toBe(true); + expect((await platform.profile(players[1].id)).pending).toHaveLength(0); + await expect(platform.view(id, players[1].id)).rejects.toThrow('acceso'); + await platform.join(players[1], room.code); + expect((await platform.view(id, players[1].id)).seat).toBe(3); +}); + +it('the host can cancel a waiting room without producing a scored result', async () => { + const id = await create(); + const room = await platform.view(id, players[0].id); + await platform.leave(players[0], id, room.version); + expect((await store.room(id)).result.cancelled).toBe(true); + expect((await platform.invitation(room.code, players[1].id)).available).toBe( + false, + ); + const profile = await platform.profile(players[1].id); + expect(profile.pending).toHaveLength(0); + expect(profile.history).toHaveLength(1); + expect( + profile.stats.find((s: { gameId: string }) => s.gameId === 'oca')?.played, + ).toBe(0); +}); + +it('abandoning a multiplayer game removes pieces, advances the turn and blocks further actions', async () => { + const id = await start(4, 'parchis'); + await store.updateRoom(id, (r: any) => { + r.state.die = 5; + r.state.pieces[0] = [10, 10, -1, -1]; + }); + const before = await platform.view(id, players[0].id); + await platform.leave(players[0], id, before.version); + const after = await platform.view(id, players[1].id); + expect(after.status).toBe('playing'); + expect(after.state.turn).toBe(1); + expect(after.state.pieces[0]).toEqual([-1, -1, -1, -1]); + expect(after.state.die).toBeNull(); + expect((await platform.profile(players[0].id)).pending).toHaveLength(0); + expect(await platform.list(players[0].id)).toHaveLength(0); + expect( + (await platform.profile(players[0].id)).history[0].myResult.placement, + ).toBe(4); + await expect( + platform.action(players[0], { + roomId: id, + actionId: randomUUID(), + expectedVersion: after.version, + action: { type: 'roll' }, + }), + ).rejects.toThrow('abandonado'); + await platform.leave(players[0], id, before.version); // retry must not remove another seat + expect((await store.room(id)).version).toBe(after.version); +}); + +it('all dice engines skip withdrawn players, including the legacy Parchis variant', () => { + for (const [gameId, version] of [ + ['oca', '1.0.0'], + ['parchis', '1.0.0'], + ['parchis', '2.0.0'], + ]) { + const game = getGame(gameId, version); + if (!('withdraw' in game)) + throw new Error('El juego necesita gestionar retiradas'); + const state = game.withdraw(game.create(4), 1); + const next = game.apply(state, { type: 'roll' }, 0, () => 1).state; + expect(next.turn).toBe(2); + } +}); + +it('the last remaining player wins voluntary abandonment while earlier placements are preserved', async () => { + const id = await start(4); + for (const player of players.slice(0, 3)) { + const room = await store.room(id); + await platform.leave(player, id, room.version); + } + const room = await store.room(id); + expect(room.status).toBe('finished'); + expect(room.result.winner).toBe(3); + expect(room.players.map((p: { placement: number }) => p.placement)).toEqual([ + 4, 3, 2, 1, + ]); +}); + +it('inactivity closure is server-authorized, does not count chat and produces no draw or victory', async () => { + const id = await start(); + const room = await store.room(id); + const later = + Date.parse(room.lastGameActivityAt) + inactivityPolicy.closeAfter; + await expect( + platform.closeInactive(players[1], id, room.version, later - 1), + ).rejects.toThrow('10 minutos'); + await expect( + platform.closeInactive(players[0], id, room.version, later), + ).rejects.toThrow('10 minutos'); + await expect( + platform.closeInactive({ id: randomUUID() }, id, room.version, later), + ).rejects.toThrow('participas'); + await platform.message(players[0], id, 'Un mensaje no es una jugada'); + await platform.closeInactive(players[1], id, room.version, later); + const profile = await platform.profile(players[1].id); + expect(profile.pending).toHaveLength(0); + expect(profile.history[0].result.cancelled).toBe(true); + expect( + profile.stats.find((s: { gameId: string }) => s.gameId === 'oca'), + ).toMatchObject({ played: 0, wins: 0, losses: 0, draws: 0 }); +}); + +it('a concurrent valid move prevents closing a stale board for inactivity', async () => { + const id = await start(); + const room = await store.room(id); + await platform.action(players[0], { + roomId: id, + actionId: randomUUID(), + expectedVersion: room.version, + action: { type: 'roll' }, + }); + await expect( + platform.closeInactive( + players[1], + id, + room.version, + Date.parse(room.lastGameActivityAt) + inactivityPolicy.closeAfter, + ), + ).rejects.toThrow('actuado'); + expect((await store.room(id)).status).toBe('playing'); +}); + +it('expiry covers private waiting and playing rooms and rechecks activity under the store lock', async () => { + const waiting = await create(), + playing = await start(); + const timestamp = Date.parse((await store.room(playing)).lastGameActivityAt); + const expired = timestamp + inactivityPolicy.expireAfter + 1000; + const list = store.activeRooms.bind(store); + store.activeRooms = async () => { + const snapshots = await list(); + await store.updateRoom( + playing, + (r: any) => (r.lastGameActivityAt = new Date(expired).toISOString()), + ); + return snapshots; + }; + expect(await platform.expireInactiveRooms(expired)).toEqual([waiting]); + expect((await store.room(playing)).status).toBe('playing'); + expect((await store.room(waiting)).result.cancelled).toBe(true); +}); + +it('Go scoring waits only after your acceptance and rejects spectators', () => { + const room = { + status: 'playing', + createdAt: new Date(0).toISOString(), + players: [{}, {}], + state: { phase: 'scoring', accepted: [true, false] }, + }; + expect(canCloseInactive(room, 0, inactivityPolicy.closeAfter)).toBe(true); + expect(canCloseInactive(room, 1, inactivityPolicy.closeAfter)).toBe(false); + expect(canCloseInactive(room, -1, inactivityPolicy.closeAfter)).toBe(false); +}); diff --git a/tests/service.test.ts b/tests/service.test.ts new file mode 100644 index 0000000..ed08fc0 --- /dev/null +++ b/tests/service.test.ts @@ -0,0 +1,248 @@ +import { acceptedCookie } from './terms-fixture.js'; +import { it, expect } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { io, type Socket } from 'socket.io-client'; +import { FileStore } from '../server/store.mjs'; +import { createService } from '../server/service.mjs'; +import type { Room } from '../src/lib/types.js'; + +it('dos clientes reales: registro, sala, chat, jugadas, reconexión y resultado', async () => { + const dir = await mkdtemp(join(tmpdir(), 'mesa-socket-')); + const store = new FileStore(join(dir, 'store.json')); + await store.init(); + const origin = 'http://localhost:4173', + service = createService(store, { APP_ORIGIN: origin, NODE_ENV: 'test' }); + await new Promise((resolve) => + service.server.listen(0, '127.0.0.1', resolve), + ); + const address = service.server.address(); + if (!address || typeof address === 'string') throw new Error('Sin dirección'); + const base = `http://127.0.0.1:${address.port}`, + sockets: Socket[] = []; + async function request( + path: string, + data?: unknown, + cookie = '', + customOrigin = origin, + ) { + return fetch(base + '/api' + path, { + method: data === undefined ? 'GET' : 'POST', + headers: { + origin: customOrigin, + 'content-type': 'application/json', + cookie: acceptedCookie(cookie), + }, + body: data === undefined ? undefined : JSON.stringify(data), + }); + } + async function player(n: number) { + const response = await request('/auth/register', { + name: `Prueba ${n}`, + nick: `prueba_${n}`, + email: `prueba${n}@example.test`, + pin: '3581', + }); + expect(response.status).toBe(201); + const cookie = response.headers.get('set-cookie')!.split(';')[0]; + const socket = io(base, { + transports: ['websocket'], + extraHeaders: { cookie: acceptedCookie(cookie), origin }, + forceNew: true, + }); + sockets.push(socket); + await new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('connect_error', reject); + }); + return { cookie, socket }; + } + const emit = (socket: Socket, event: string, input: unknown) => + new Promise<{ ok: boolean; room: Room; error?: string }>( + (resolve, reject) => + socket + .timeout(4000) + .emit( + event, + input, + ( + e: Error | null, + result: { ok: boolean; room: Room; error?: string }, + ) => (e ? reject(e) : resolve(result)), + ), + ); + try { + const a = await player(1), + b = await player(2); + expect( + (await request('/rooms', {}, a.cookie, 'https://other.example')).status, + ).toBe(403); + const response = await request( + '/rooms', + { + gameId: 'ajedrez', + name: 'Partida de integración', + maxPlayers: 2, + visibility: 'private', + }, + a.cookie, + ); + let room = (await response.json()).room as Room; + expect((await request('/rooms')).status).toBe(200); + expect((await (await request('/rooms')).json()).rooms).toHaveLength(0); + await request('/rooms/join', { code: room.code }, b.cookie); + await emit(a.socket, 'room:subscribe', { roomId: room.id }); + await emit(b.socket, 'room:subscribe', { roomId: room.id }); + await emit(a.socket, 'room:ready', { roomId: room.id, ready: true }); + room = ( + await emit(b.socket, 'room:ready', { roomId: room.id, ready: true }) + ).room; + expect(room.status).toBe('playing'); + const preset = await request( + '/profile/avatar', + { preset: 'gato' }, + a.cookie, + ); + expect(preset.status).toBe(200); + expect((await preset.json()).user.avatar).toBe('/avatars/gato.svg'); + expect( + (await (await request(`/rooms/${room.id}`, undefined, b.cookie)).json()) + .room.players[0].avatar, + ).toBe('/avatars/gato.svg'); + expect((await request('/profile/avatar', { preset: 'torre' })).status).toBe( + 401, + ); + expect( + ( + await request( + '/profile/avatar', + { image: 'data:image/svg+xml;base64,AAAA' }, + a.cookie, + ) + ).status, + ).toBe(400); + const png = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=='; + const uploaded = await request('/profile/avatar', { image: png }, a.cookie); + expect(uploaded.status).toBe(200); + const avatarUrl = (await uploaded.json()).user.avatar; + const served = await request(avatarUrl.slice(4), undefined, b.cookie); + expect(served.headers.get('content-type')).toBe('image/png'); + expect(Buffer.from(await served.arrayBuffer()).toString('base64')).toBe( + png.slice(22), + ); + expect( + (await emit(b.socket, 'room:subscribe', { roomId: room.id })).room + .players[0].avatar, + ).toBe(avatarUrl); + expect( + (await request('/profile/avatar', { preset: null }, a.cookie)).status, + ).toBe(200); + const chat = await emit(a.socket, 'chat:send', { + roomId: room.id, + text: 'Buena partida