commit
04f1dff1c7
@ -0,0 +1,8 @@
|
||||
# Local development uses an atomic JSON file until PostgreSQL is configured.
|
||||
# Production requires DATABASE_URL and an explicit HTTPS origin.
|
||||
DATABASE_URL=
|
||||
APP_ORIGIN=http://localhost:4173
|
||||
## Production requires a private random PIN_PEPPER (at least 32 characters).
|
||||
HOST=127.0.0.1
|
||||
PORT=3001
|
||||
DATA_FILE=.data/development.json
|
||||
@ -0,0 +1,4 @@
|
||||
* text=auto
|
||||
*.sh text eol=lf
|
||||
*.service text eol=lf
|
||||
*.timer text eol=lf
|
||||
@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
.npm-cache/
|
||||
.svelte-kit/
|
||||
build/
|
||||
.data/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
coverage/
|
||||
*.log
|
||||
artifacts/
|
||||
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.npm-cache
|
||||
.svelte-kit
|
||||
.data
|
||||
build
|
||||
artifacts
|
||||
package-lock.json
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id uuid PRIMARY KEY,
|
||||
email text NOT NULL UNIQUE,
|
||||
nick text NOT NULL UNIQUE,
|
||||
data jsonb NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
hash text PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES users(id),
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS sessions_expiry ON sessions(expires_at);
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id uuid PRIMARY KEY,
|
||||
code text NOT NULL UNIQUE,
|
||||
game_id text NOT NULL,
|
||||
game_version text NOT NULL,
|
||||
visibility text NOT NULL CHECK (visibility IN ('public','private')),
|
||||
status text NOT NULL CHECK (status IN ('waiting','playing','finished')),
|
||||
created_at timestamptz NOT NULL,
|
||||
data jsonb NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS rooms_status ON rooms(status,created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS rooms_players ON rooms USING gin ((data->'players'));
|
||||
CREATE TABLE IF NOT EXISTS match_events (
|
||||
room_id uuid NOT NULL REFERENCES rooms(id),
|
||||
sequence integer NOT NULL,
|
||||
action_id text NOT NULL,
|
||||
actor_id uuid NOT NULL REFERENCES users(id),
|
||||
data jsonb NOT NULL,
|
||||
PRIMARY KEY(room_id,sequence),
|
||||
UNIQUE(room_id,action_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id uuid PRIMARY KEY,
|
||||
room_id uuid NOT NULL REFERENCES rooms(id),
|
||||
user_id uuid NOT NULL REFERENCES users(id),
|
||||
data jsonb NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS chat_room ON chat_messages(room_id);
|
||||
CREATE TABLE IF NOT EXISTS match_results (
|
||||
room_id uuid NOT NULL REFERENCES rooms(id),
|
||||
user_id uuid NOT NULL REFERENCES users(id),
|
||||
outcome text NOT NULL CHECK (outcome IN ('win','loss','draw')),
|
||||
placement integer NOT NULL CHECK (placement>0),
|
||||
reason text NOT NULL,
|
||||
PRIMARY KEY(room_id,user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS results_user ON match_results(user_id);
|
||||
@ -0,0 +1 @@
|
||||
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
|
||||
@ -0,0 +1,2 @@
|
||||
-- Automatic room expiry has no human actor.
|
||||
ALTER TABLE match_events ALTER COLUMN actor_id DROP NOT NULL;
|
||||
@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
release_id="${1:?Specify an existing checked release ID}"
|
||||
[[ "$release_id" =~ ^[0-9]{8}-[0-9]{2}$ ]] || { echo 'Invalid release ID'; exit 1; }
|
||||
release="/opt/mesa-abierta/releases/$release_id"
|
||||
[[ "$(realpath "$release")" == "$release" && -f "$release/build/handler.js" ]] || { echo 'Release has not been built'; exit 1; }
|
||||
cd "$release"
|
||||
config_backup="/var/backups/mesa-abierta/config-$release_id"
|
||||
install -d -m 0700 "$config_backup"
|
||||
if [[ -f /etc/mesa-abierta/game.env ]]; then cp -a /etc/mesa-abierta/game.env "$config_backup/"; fi
|
||||
for unit in mesa-game.service mesa-web.service mesa-backup.service mesa-backup.timer; do
|
||||
if [[ -f "/etc/systemd/system/$unit" ]]; then cp -a "/etc/systemd/system/$unit" "$config_backup/"; fi
|
||||
done
|
||||
previous="$(readlink -f /opt/mesa-abierta/current || true)"
|
||||
if [[ -n "$previous" && -d "$previous" ]]; then printf '%s\n' "$previous" > "$config_backup/previous-release"; fi
|
||||
python3 deploy/configure-production.py
|
||||
runuser -u mesa -- /usr/bin/node --env-file=/etc/mesa-abierta/backup.env --input-type=module -e "import {spawnSync} from 'node:child_process'; const result=spawnSync('/bin/sh',['deploy/backup.sh'],{stdio:'inherit',env:process.env}); process.exit(result.status ?? 1)"
|
||||
runuser -u mesa -- /usr/bin/node --env-file=/etc/mesa-abierta/game.env scripts/migrate.mjs
|
||||
for unit in mesa-game.service mesa-backup.service mesa-backup.timer; do
|
||||
install -m 0644 "deploy/$unit" "/etc/systemd/system/$unit"
|
||||
done
|
||||
if systemctl cat mesa-web.service >/dev/null 2>&1; then systemctl disable --now mesa-web.service; fi
|
||||
ln -sfn "$release" /opt/mesa-abierta/current.next
|
||||
mv -Tf /opt/mesa-abierta/current.next /opt/mesa-abierta/current
|
||||
systemctl daemon-reload
|
||||
systemctl enable mesa-game.service mesa-backup.timer
|
||||
systemctl restart mesa-game.service
|
||||
systemctl start mesa-backup.timer
|
||||
for attempt in $(seq 1 20); do
|
||||
if curl -4 --fail --silent --max-time 3 http://127.0.0.1:3000/api/health; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
curl -4 --fail --silent --max-time 10 http://127.0.0.1:3000/api/health
|
||||
curl -4 --fail --silent --max-time 10 http://127.0.0.1:3000/ > /dev/null
|
||||
curl -4 --fail --silent --max-time 10 http://127.0.0.1:3000/condiciones > /dev/null
|
||||
# The existing network proxy connects directly to Node. No VM proxy is needed.
|
||||
if systemctl cat nginx.service >/dev/null 2>&1; then systemctl disable --now nginx.service; fi
|
||||
systemctl start mesa-backup.service
|
||||
systemctl is-active mesa-game.service mesa-backup.timer postgresql
|
||||
printf '\nRelease active: %s - web, API and WebSockets at 192.168.18.155:3000\n' "$release_id"
|
||||
@ -0,0 +1,6 @@
|
||||
PGHOST=127.0.0.1
|
||||
PGPORT=5432
|
||||
PGDATABASE=mesa_abierta
|
||||
PGUSER=mesa_app
|
||||
PGPASSWORD=REPLACE_PASSWORD
|
||||
BACKUP_DIR=/var/backups/mesa-abierta
|
||||
@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
umask 077
|
||||
: "${BACKUP_DIR:?Configure BACKUP_DIR}"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
backup="$BACKUP_DIR/mesa-$(date -u +%Y%m%dT%H%M%SZ).dump"
|
||||
pg_dump --format=custom --file="$backup.tmp"
|
||||
mv "$backup.tmp" "$backup"
|
||||
pg_restore --list "$backup" >/dev/null
|
||||
printf 'Backup verificado: %s\n' "$backup"
|
||||
# Retention and off-VM replication are configured with the operator, not deleted here.
|
||||
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Native configuration. Existing database credentials are preserved."""
|
||||
import os
|
||||
import pathlib
|
||||
import grp
|
||||
import secrets
|
||||
import subprocess
|
||||
|
||||
if os.geteuid() != 0:
|
||||
raise SystemExit('Run as root on the target VM.')
|
||||
settings = pathlib.Path('/etc/mesa-abierta')
|
||||
files = [settings / name for name in ('game.env', 'web.env', 'backup.env')]
|
||||
if all(file.is_file() for file in files):
|
||||
app_file = settings / 'game.env'
|
||||
configuration = dict(line.split('=', 1) for line in app_file.read_text().splitlines()
|
||||
if line and not line.startswith('#') and '=' in line)
|
||||
configuration.update(HOST='0.0.0.0', PORT='3000',
|
||||
ORIGIN='https://juegoland.imaginacion.com',
|
||||
APP_ORIGIN='https://juegoland.imaginacion.com,http://192.168.18.155:3000',
|
||||
TRUST_PROXY='0')
|
||||
app_file.write_text(''.join(f'{key}={value}\n' for key, value in configuration.items()))
|
||||
os.chmod(app_file, 0o640)
|
||||
print('Single application port configured; existing credentials preserved.')
|
||||
raise SystemExit(0)
|
||||
if any(file.exists() for file in files):
|
||||
raise SystemExit('Partial configuration: inspect it before continuing.')
|
||||
|
||||
def psql(sql):
|
||||
return subprocess.run(['runuser', '-u', 'postgres', '--', 'psql', '-X', '-At', '-v', 'ON_ERROR_STOP=1'],
|
||||
input=sql, text=True, capture_output=True, cwd='/tmp', check=True).stdout.strip()
|
||||
|
||||
if psql("SELECT 1 FROM pg_roles WHERE rolname='mesa_app';") or psql("SELECT 1 FROM pg_database WHERE datname='mesa_abierta';"):
|
||||
raise SystemExit('Existing application database or role: refuse automatic credential replacement.')
|
||||
database_password = secrets.token_hex(32)
|
||||
pepper = secrets.token_hex(48)
|
||||
psql(f"CREATE ROLE mesa_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD '{database_password}';\nCREATE DATABASE mesa_abierta OWNER mesa_app;")
|
||||
settings.mkdir(mode=0o750, exist_ok=True)
|
||||
gid = grp.getgrnam('mesa').gr_gid
|
||||
os.chown(settings, 0, gid)
|
||||
os.chmod(settings, 0o750)
|
||||
environments = {
|
||||
'game.env': f'NODE_ENV=production\nHOST=0.0.0.0\nPORT=3000\nORIGIN=https://juegoland.imaginacion.com\nAPP_ORIGIN=https://juegoland.imaginacion.com,http://192.168.18.155:3000\nDATABASE_URL=postgresql://mesa_app:{database_password}@127.0.0.1:5432/mesa_abierta\nTRUST_PROXY=0\nPIN_PEPPER={pepper}\n',
|
||||
'web.env': 'NODE_ENV=production\nHOST=127.0.0.1\nPORT=3000\nPROTOCOL_HEADER=x-forwarded-proto\nHOST_HEADER=x-forwarded-host\n',
|
||||
'backup.env': f'PGHOST=127.0.0.1\nPGPORT=5432\nPGDATABASE=mesa_abierta\nPGUSER=mesa_app\nPGPASSWORD={database_password}\nBACKUP_DIR=/var/backups/mesa-abierta\n',
|
||||
}
|
||||
os.umask(0o077)
|
||||
for name, value in environments.items():
|
||||
with (settings / name).open('x', encoding='utf-8', newline='\n') as output:
|
||||
output.write(value)
|
||||
os.chown(settings / name, 0, gid)
|
||||
os.chmod(settings / name, 0o640)
|
||||
print('Dedicated database and environment created; secrets remain on this server.')
|
||||
@ -0,0 +1,39 @@
|
||||
param(
|
||||
[string]$Server = '192.168.18.155',
|
||||
[string]$Identity = "$env:USERPROFILE\.ssh\senzapaura_deploy",
|
||||
[Parameter(Mandatory=$true)][ValidatePattern('^\d{8}-\d{2}$')][string]$Release,
|
||||
[switch]$Activate
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$workspace = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$archive = Join-Path $workspace "artifacts\juegoland-$Release.tar.gz"
|
||||
$content = @('db','deploy','docs','scripts','server','shared','src','static','tests','.env.example','.gitattributes','.gitignore','.prettierignore','.prettierrc.json','package.json','package-lock.json','README.md','tsconfig.json','tsconfig.server.json','vite.config.ts')
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $workspace 'artifacts') | Out-Null
|
||||
& tar -czf $archive -C $workspace -- @content
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Packaging failed' }
|
||||
$sshOptions = @('-4','-i',$Identity,'-o','BatchMode=yes','-o','ConnectTimeout=10')
|
||||
& scp @sshOptions $archive "root@${Server}:/tmp/juegoland-$Release.tar.gz"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Transfer failed' }
|
||||
$prepare = @'
|
||||
set -e
|
||||
release=/opt/mesa-abierta/releases/RELEASE_ID
|
||||
test ! -e "$release"
|
||||
install -d -m 0755 -o mesa -g mesa "$release"
|
||||
tar -xzf /tmp/juegoland-RELEASE_ID.tar.gz -C "$release" --no-same-owner
|
||||
chown -R mesa:mesa "$release"
|
||||
cd "$release"
|
||||
local_address=$(ip -4 -o addr show scope global | awk '{split($4,a,"/");print a[1];exit}')
|
||||
test -n "$local_address"
|
||||
runuser -u mesa -- env HOME=/var/lib/mesa-abierta npm config set local-address "$local_address" --location=user
|
||||
runuser -u mesa -- env HOME=/var/lib/mesa-abierta NODE_OPTIONS=--dns-result-order=ipv4first npm ci --local-address="$local_address" --no-audit --no-fund
|
||||
runuser -u mesa -- npm run check
|
||||
runuser -u mesa -- npm test -- --maxWorkers=1
|
||||
runuser -u mesa -- npm run build
|
||||
'@
|
||||
$prepare = $prepare.Replace('RELEASE_ID',$Release).Replace("`r",'')
|
||||
& ssh @sshOptions "root@$Server" $prepare
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Remote release verification failed; no activation performed' }
|
||||
if ($Activate) {
|
||||
& ssh @sshOptions "root@$Server" "bash /opt/mesa-abierta/releases/$Release/deploy/activate-release.sh $Release"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Activation failed; inspect services and the saved previous release' }
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
# Example for the EXISTING network proxy. Do not install a proxy on the game VM.
|
||||
# Merge the map into your existing http block and the location into the existing
|
||||
# HTTPS server for juegoland.imaginacion.com. Keep your certificate settings.
|
||||
map $http_upgrade $juegoland_connection {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
# Inside your existing server block:
|
||||
location / {
|
||||
proxy_pass http://192.168.18.155:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $juegoland_connection;
|
||||
proxy_read_timeout 75s;
|
||||
proxy_send_timeout 75s;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
NODE_ENV=production
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
ORIGIN=https://juegoland.imaginacion.com
|
||||
APP_ORIGIN=https://juegoland.imaginacion.com,http://192.168.18.155:3000
|
||||
DATABASE_URL=postgresql://mesa_app:REPLACE_PASSWORD@127.0.0.1:5432/mesa_abierta
|
||||
# The existing network proxy forwards web, API and WebSocket to port 3000.
|
||||
TRUST_PROXY=0
|
||||
PIN_PEPPER=REPLACE_WITH_A_PRIVATE_RANDOM_SECRET_AT_LEAST_32_CHARACTERS
|
||||
@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=Juegoland - PostgreSQL backup
|
||||
After=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=mesa
|
||||
Group=mesa
|
||||
EnvironmentFile=/etc/mesa-abierta/backup.env
|
||||
ExecStart=/bin/sh /opt/mesa-abierta/current/deploy/backup.sh
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/backups/mesa-abierta
|
||||
UMask=0077
|
||||
@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Juegoland - Daily backup
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:15:00 Europe/Madrid
|
||||
Persistent=true
|
||||
RandomizedDelaySec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Juegoland - web, API, partidas y chat
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mesa
|
||||
Group=mesa
|
||||
WorkingDirectory=/opt/mesa-abierta/current
|
||||
EnvironmentFile=/etc/mesa-abierta/game.env
|
||||
ExecStart=/usr/bin/node server/production.mjs
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
TimeoutStopSec=30
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
[ "$(id -u)" = 0 ] || { echo 'Ejecutar como root'; exit 1; }
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get install -y ca-certificates curl gnupg git rsync xz-utils
|
||||
install -d -m 0755 /usr/share/keyrings
|
||||
curl -4 --fail --silent --show-error --retry 2 --connect-timeout 10 https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /tmp/mesa-nodesource.key
|
||||
gpg --batch --yes --dearmor -o /usr/share/keyrings/nodesource.gpg /tmp/mesa-nodesource.key
|
||||
gpg --show-keys --with-fingerprint /usr/share/keyrings/nodesource.gpg
|
||||
printf '%s\n' 'deb [arch=amd64 signed-by=/usr/share/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main' > /etc/apt/sources.list.d/mesa-nodesource.list
|
||||
apt-get update
|
||||
apt-get install -y nodejs
|
||||
node --version
|
||||
npm --version
|
||||
getent passwd mesa >/dev/null || adduser --system --group --home /var/lib/mesa-abierta mesa
|
||||
install -d -m 0755 /opt/mesa-abierta/releases
|
||||
install -d -m 0750 -o root -g mesa /etc/mesa-abierta
|
||||
install -d -m 0700 -o mesa -g mesa /var/backups/mesa-abierta
|
||||
@ -0,0 +1,280 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile, writeFile, unlink } from 'node:fs/promises';
|
||||
import { Pool } from 'pg';
|
||||
import { io } from 'socket.io-client';
|
||||
import { terms } from '../shared/terms.mjs';
|
||||
|
||||
// Isolated synthetic QA identities. Never reads/imports a real browser session.
|
||||
const base = process.env.SMOKE_BASE || 'http://192.168.18.155:3000';
|
||||
const origin = process.env.SMOKE_ORIGIN || 'https://juegoland.imaginacion.com';
|
||||
const statePath = '/var/lib/mesa-abierta/production-smoke.json';
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const sockets = [];
|
||||
async function request(path, cookie = '', data) {
|
||||
const response = await fetch(`${base}/api${path}`, {
|
||||
method: data === undefined ? 'GET' : 'POST',
|
||||
headers: {
|
||||
host: new URL(origin).host,
|
||||
origin,
|
||||
cookie,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: data === undefined ? undefined : JSON.stringify(data),
|
||||
});
|
||||
return { response, body: await response.json() };
|
||||
}
|
||||
const cookieOf = (response) => response.headers.get('set-cookie').split(';')[0];
|
||||
async function client(cookie) {
|
||||
const socket = io(base, {
|
||||
forceNew: true,
|
||||
reconnection: false,
|
||||
transports: ['websocket'],
|
||||
extraHeaders: { host: new URL(origin).host, origin, cookie },
|
||||
});
|
||||
sockets.push(socket);
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('connect_error', reject);
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
async function emit(socket, name, value) {
|
||||
const result = await socket.timeout(5000).emitWithAck(name, value);
|
||||
assert.equal(result.ok, true, result.error);
|
||||
return result.room;
|
||||
}
|
||||
let fixture;
|
||||
async function cleanup() {
|
||||
if (!fixture) return;
|
||||
const connection = await pool.connect();
|
||||
try {
|
||||
await connection.query('BEGIN');
|
||||
for (const table of ['match_events', 'chat_messages', 'match_results'])
|
||||
await connection.query(
|
||||
`DELETE FROM ${table} WHERE room_id = ANY($1::uuid[])`,
|
||||
[fixture.rooms],
|
||||
);
|
||||
await connection.query('DELETE FROM rooms WHERE id = ANY($1::uuid[])', [
|
||||
fixture.rooms,
|
||||
]);
|
||||
await connection.query(
|
||||
'DELETE FROM sessions WHERE user_id = ANY($1::uuid[])',
|
||||
[fixture.users.map((p) => p.id)],
|
||||
);
|
||||
await connection.query('DELETE FROM users WHERE id = ANY($1::uuid[])', [
|
||||
fixture.users.map((p) => p.id),
|
||||
]);
|
||||
await connection.query('COMMIT');
|
||||
await unlink(statePath).catch((e) => {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
});
|
||||
} catch (e) {
|
||||
await connection.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (process.argv[2] === 'verify-restart') {
|
||||
fixture = JSON.parse(await readFile(statePath, 'utf8'));
|
||||
const result = await request(
|
||||
`/rooms/${fixture.chess}`,
|
||||
fixture.users[0].cookie,
|
||||
);
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(result.body.room.status, 'finished');
|
||||
assert.equal(result.body.room.players[0].outcome, 'win');
|
||||
assert.equal(
|
||||
result.body.room.messages[0].text,
|
||||
'Mensaje de la prueba de despliegue',
|
||||
);
|
||||
const profile = (await request('/profile', fixture.users[0].cookie)).body;
|
||||
assert.ok(profile.history.some((room) => room.id === fixture.chess));
|
||||
assert.ok(
|
||||
profile.history.some(
|
||||
(room) => room.id === fixture.inactive && room.result.cancelled,
|
||||
),
|
||||
);
|
||||
assert.equal(profile.pending.length, 0);
|
||||
assert.ok(
|
||||
profile.history.some(
|
||||
(room) => room.id === fixture.expired && room.result.cancelled,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await pool.query(
|
||||
'SELECT count(*)::int AS n FROM match_events WHERE room_id=$1 AND actor_id IS NULL',
|
||||
[fixture.expired],
|
||||
)
|
||||
).rows[0].n,
|
||||
1,
|
||||
);
|
||||
const socket = await client(fixture.users[0].cookie);
|
||||
await emit(socket, 'room:subscribe', { roomId: fixture.chess });
|
||||
console.log(
|
||||
'OK: sesiones, aceptación, historial, mensajes y reconexión persistidos tras reiniciar servicios.',
|
||||
);
|
||||
await cleanup();
|
||||
console.log(
|
||||
'OK: registros de prueba eliminados; no se han tocado datos ajenos a la prueba.',
|
||||
);
|
||||
} else {
|
||||
const page = await fetch(base + '/');
|
||||
assert.equal(page.status, 200);
|
||||
const html = await page.text();
|
||||
assert.match(html, /Tu gente\. Tus juegos\./);
|
||||
assert.match(html, /Antes de jugar/);
|
||||
assert.doesNotMatch(html, /terms-gate|Leer las condiciones completas/);
|
||||
assert.equal((await request('/health')).body.storage, 'postgresql');
|
||||
assert.equal(
|
||||
(await request('/auth/guest', '', { name: 'Blocked test' })).response
|
||||
.status,
|
||||
428,
|
||||
);
|
||||
const acceptance = await request('/terms/accept', '', {
|
||||
accepted: true,
|
||||
version: terms.version,
|
||||
});
|
||||
assert.equal(acceptance.response.status, 200);
|
||||
assert.match(
|
||||
acceptance.response.headers.get('set-cookie'),
|
||||
/HttpOnly; SameSite=Lax/,
|
||||
);
|
||||
assert.equal(
|
||||
acceptance.response.headers.get('set-cookie').includes('; Secure'),
|
||||
origin.startsWith('https://'),
|
||||
);
|
||||
const consentCookie = cookieOf(acceptance.response);
|
||||
fixture = { users: [], rooms: [] };
|
||||
for (const name of ['QAprodUno', 'QAprodDos']) {
|
||||
const created = await request('/auth/guest', consentCookie, { name });
|
||||
assert.equal(created.response.status, 201);
|
||||
fixture.users.push({
|
||||
id: created.body.user.id,
|
||||
cookie: `${cookieOf(created.response)}; ${consentCookie}`,
|
||||
});
|
||||
}
|
||||
const [one, two] = fixture.users;
|
||||
const a = await client(one.cookie),
|
||||
b = await client(two.cookie);
|
||||
let room = (
|
||||
await request('/rooms', one.cookie, {
|
||||
gameId: 'ajedrez',
|
||||
name: 'QA deployment chess',
|
||||
maxPlayers: 2,
|
||||
visibility: 'private',
|
||||
})
|
||||
).body.room;
|
||||
fixture.chess = room.id;
|
||||
fixture.rooms.push(room.id);
|
||||
assert.equal(
|
||||
(await request('/rooms/join', two.cookie, { code: room.code })).response
|
||||
.status,
|
||||
200,
|
||||
);
|
||||
await emit(a, 'room:subscribe', { roomId: room.id });
|
||||
await emit(b, 'room:subscribe', { roomId: room.id });
|
||||
await emit(a, 'room:ready', { roomId: room.id, ready: true });
|
||||
room = await emit(b, 'room:ready', { roomId: room.id, ready: true });
|
||||
room = await emit(a, 'game:action', {
|
||||
roomId: room.id,
|
||||
actionId: randomUUID(),
|
||||
expectedVersion: room.version,
|
||||
action: { type: 'move', from: 'e2', to: 'e4' },
|
||||
});
|
||||
assert.equal(room.state.turn, 1);
|
||||
await emit(a, 'chat:send', {
|
||||
roomId: room.id,
|
||||
text: 'Mensaje de la prueba de despliegue',
|
||||
});
|
||||
room = await emit(b, 'game:action', {
|
||||
roomId: room.id,
|
||||
actionId: randomUUID(),
|
||||
expectedVersion: room.version,
|
||||
action: { type: 'resign' },
|
||||
});
|
||||
assert.equal(room.status, 'finished');
|
||||
assert.equal(room.players[0].outcome, 'win');
|
||||
assert.equal(
|
||||
(
|
||||
await pool.query(
|
||||
'SELECT count(*)::int AS n FROM match_results WHERE room_id=$1',
|
||||
[room.id],
|
||||
)
|
||||
).rows[0].n,
|
||||
2,
|
||||
);
|
||||
let inactive = (
|
||||
await request('/rooms', one.cookie, {
|
||||
gameId: 'oca',
|
||||
name: 'QA inactivity closure',
|
||||
maxPlayers: 2,
|
||||
visibility: 'private',
|
||||
})
|
||||
).body.room;
|
||||
fixture.inactive = inactive.id;
|
||||
fixture.rooms.push(inactive.id);
|
||||
await request('/rooms/join', two.cookie, { code: inactive.code });
|
||||
await emit(a, 'room:subscribe', { roomId: inactive.id });
|
||||
await emit(b, 'room:subscribe', { roomId: inactive.id });
|
||||
await emit(a, 'room:ready', { roomId: inactive.id, ready: true });
|
||||
inactive = await emit(b, 'room:ready', {
|
||||
roomId: inactive.id,
|
||||
ready: true,
|
||||
});
|
||||
await pool.query(
|
||||
"UPDATE rooms SET data=jsonb_set(data,'{lastGameActivityAt}',to_jsonb($2::text)) WHERE id=$1",
|
||||
[inactive.id, new Date(Date.now() - 660000).toISOString()],
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/rooms/${inactive.id}/close-inactive`, two.cookie, {
|
||||
expectedVersion: inactive.version,
|
||||
})
|
||||
).response.status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await pool.query(
|
||||
'SELECT count(*)::int AS n FROM match_results WHERE room_id=$1',
|
||||
[inactive.id],
|
||||
)
|
||||
).rows[0].n,
|
||||
0,
|
||||
);
|
||||
const expired = (
|
||||
await request('/rooms', one.cookie, {
|
||||
gameId: 'oca',
|
||||
name: 'QA automatic expiry',
|
||||
maxPlayers: 2,
|
||||
visibility: 'private',
|
||||
})
|
||||
).body.room;
|
||||
fixture.expired = expired.id;
|
||||
fixture.rooms.push(expired.id);
|
||||
await pool.query(
|
||||
"UPDATE rooms SET data=jsonb_set(data,'{lastGameActivityAt}',to_jsonb($2::text)) WHERE id=$1",
|
||||
[expired.id, new Date(Date.now() - 25 * 3600000).toISOString()],
|
||||
);
|
||||
await writeFile(statePath, JSON.stringify(fixture), {
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
console.log(
|
||||
`OK: web, API y WebSocket en puerto 3000, aceptación desde ${origin}, dos jugadores, chat, turno, victoria, PostgreSQL y cierre sin resultado.`,
|
||||
);
|
||||
console.log(
|
||||
'Reinicia los servicios y ejecuta verify-restart para comprobar persistencia y limpiar estas identidades sintéticas.',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
} finally {
|
||||
sockets.forEach((socket) => socket.disconnect());
|
||||
await pool.end();
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
NODE_ENV=production
|
||||
HOST=127.0.0.1
|
||||
PORT=3000
|
||||
PROTOCOL_HEADER=x-forwarded-proto
|
||||
HOST_HEADER=x-forwarded-host
|
||||
@ -0,0 +1,15 @@
|
||||
# Aviso de entrada
|
||||
|
||||
El popup breve aparece sobre el catálogo al entrar, sin esperar a la API de sesión.
|
||||
Identifica el servicio personal de Juan V. Navarro Leal, su estado en desarrollo y el uso bajo responsabilidad del jugador.
|
||||
No hay página ni enlace de condiciones completas. La antigua ruta /condiciones redirige al catálogo o a su regreso local válido.
|
||||
|
||||
La casilla empieza vacía. Aceptar guarda una cookie firmada durante un año. Ahora no cierra el aviso y deja consultar el catálogo; intentar jugar vuelve a abrirlo.
|
||||
La invitación conserva su URL y se abre después de aceptar, evitando apilar diálogos.
|
||||
|
||||
El servidor valida versión, HMAC, fecha y caducidad. La pista SSR de una cookie actual solo evita un destello del popup; no autoriza operaciones.
|
||||
La consulta pública del catálogo y de salas públicas no exige aceptación. Las operaciones de juego sí la requieren.
|
||||
HTTPS usa cookies Secure. El origen local HTTP explícitamente configurado admite cookies en la LAN.
|
||||
La revisión de sesión no sobrescribe una aceptación realizada mientras esa consulta estaba en curso.
|
||||
|
||||
Las pruebas de integración usan identidades sintéticas. No se acepta el aviso por el usuario real durante la comprobación visual.
|
||||
@ -0,0 +1,26 @@
|
||||
# Dirección visual de Juegoland
|
||||
|
||||
Revisión con el skill `frontend-design`, instalado desde [Anthropic](https://github.com/anthropics/skills/tree/main/skills/frontend-design).
|
||||
|
||||
La identidad parte del logotipo multicolor y de los materiales de los juegos: madera en Go y ajedrez, fichas de colores en parchís, ocas y recorrido en la oca. El tablero es el centro de la partida; los controles explican únicamente la acción disponible. La interfaz que lo rodea es sobria para que esos elementos conserven protagonismo.
|
||||
|
||||
Paleta base: azul de acción `#0759d9`, rojo de ficha `#ed121b`, amarillo de ficha `#ffc800`, verde de ficha `#15932a`, superficie clara `#ffffff`, texto oscuro `#202c42`. Las variantes de tema conservan colores semánticos de turno, jugadores y peligro con contraste adecuado a cada superficie.
|
||||
|
||||
Tipografía: Manrope para títulos y texto de interfaz. Su forma geométrica acompaña las letras redondeadas del logotipo, con pesos distintos para separar jerarquías. Escala 12, 14, 16, 20, 24 y 28 px; en pantallas grandes aumenta un escalón. Texto normal a 400, etiquetas a 600, títulos a 750. Interlineado de lectura 1,5. Espaciado 4, 8, 12, 16, 24 y 32 px; controles táctiles de al menos 44 px.
|
||||
|
||||
Disposición: título y turno comparten línea base en escritorio; en móvil se apilan sobre una misma guía izquierda. Tablero y controles forman un grupo centrado; en móvil las acciones van inmediatamente después del tablero. El menú es un panel de tamaño ajustado a su contenido. Chat y reglas se descubren con un botón.
|
||||
|
||||
```text
|
||||
Escritorio Móvil
|
||||
menú logo ajustes menú logo ajustes
|
||||
título turno acciones título acciones
|
||||
turno
|
||||
tablero | controles tablero
|
||||
controles
|
||||
```
|
||||
|
||||
Crítica de la propuesta anterior: los encabezados decorativos en mayúsculas añadían información que no ayudaba a jugar, los estilos heredados alteraban tamaños dentro de los diálogos y el logotipo aparecía sobre una placa blanca. Esta revisión elimina esos encabezados en los diálogos y el menú de partida, usa una única familia de interfaz y coloca el logotipo transparente directamente sobre la superficie.
|
||||
|
||||
Las animaciones muestran lo que cambia después de una acción: agitar, lanzar, desplazar una ficha, colocar o retirar una piedra. La preferencia de movimiento reducido se respeta. Los sonidos se identifican por juego y evento, y se pueden escuchar por separado desde sus ajustes.
|
||||
|
||||
Invitaciones: título de 24 px, nombre de partida de 18 px, datos secundarios de 14 px y mensaje, explicación y botones de 16 px. La ilustración ocupa un cuadrado de 96 px en escritorio y 80 px en móvil. El encabezado no lleva una etiqueta decorativa sobre el título.
|
||||
@ -0,0 +1,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.
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "mesa-abierta",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#lib/*": "./src/lib/*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev.mjs",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && tsc --project tsconfig.server.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"start": "node --env-file-if-exists=.env server/production.mjs",
|
||||
"db:migrate": "node --env-file-if-exists=.env scripts/migrate.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"chess.js": "^1.4.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"pg": "^8.16.3",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^6.0.0",
|
||||
"@sveltejs/kit": "^3.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/pg": "^8.15.5",
|
||||
"prettier": "^3.9.9",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "^4.7.6",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.2",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import 'dotenv/config';
|
||||
|
||||
const children = [
|
||||
spawn(process.execPath, ['--watch', 'server/index.mjs'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NODE_ENV: 'development', PORT: '3001' },
|
||||
}),
|
||||
spawn(process.execPath, ['node_modules/vite/bin/vite.js'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NODE_ENV: 'development' },
|
||||
}),
|
||||
];
|
||||
let stopping = false;
|
||||
function stop(code = 0) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
for (const child of children) child.kill();
|
||||
process.exitCode = code;
|
||||
}
|
||||
for (const child of children) child.on('exit', (code) => stop(code ?? 1));
|
||||
process.on('SIGINT', () => stop());
|
||||
process.on('SIGTERM', () => stop());
|
||||
@ -0,0 +1,350 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
// Original procedural foley: materials, rhythm and envelopes differ by action.
|
||||
// Versioned URLs keep browsers from reusing the previous, similar-sounding pack.
|
||||
const rate = 44100;
|
||||
const directory = new URL('../static/sounds/v2/', import.meta.url);
|
||||
await mkdir(directory, { recursive: true });
|
||||
let seed = 1781,
|
||||
low = 0;
|
||||
function noise() {
|
||||
seed = (1664525 * seed + 1013904223) >>> 0;
|
||||
return seed / 2147483648 - 1;
|
||||
}
|
||||
function filtered() {
|
||||
const n = noise();
|
||||
low += 0.12 * (n - low);
|
||||
return { low, high: n - low };
|
||||
}
|
||||
const wave = (f, t) => Math.sin(2 * Math.PI * f * t);
|
||||
function hit(t, start, frequencies, decay = 60, strength = 1) {
|
||||
const age = t - start;
|
||||
if (age < 0) return 0;
|
||||
const body = frequencies.reduce(
|
||||
(sum, f, i) =>
|
||||
sum + (wave(f, age) * Math.exp(-age * decay * (1 + i * 0.7))) / (i + 1),
|
||||
0,
|
||||
);
|
||||
return strength * (body * 0.55 + noise() * Math.exp(-age * 700) * 0.45);
|
||||
}
|
||||
function pluck(t, start, f, damping = 6) {
|
||||
const age = t - start;
|
||||
if (age < 0) return 0;
|
||||
return (
|
||||
(wave(f, age) +
|
||||
0.35 * wave(f * 2, age) * Math.exp(-age * 8) +
|
||||
0.14 * wave(f * 3, age)) *
|
||||
Math.exp(-age * damping)
|
||||
);
|
||||
}
|
||||
const cupImpacts = Array.from({ length: 19 }, (_, i) => ({
|
||||
start: i * 0.028 + ((i * 7) % 5) * 0.002,
|
||||
pitch: 480 + ((i * 317) % 1900),
|
||||
gain: 0.35 + (i % 4) * 0.13,
|
||||
}));
|
||||
const diceImpacts = [
|
||||
{ t: 0, f: 180, g: 1 },
|
||||
{ t: 0.13, f: 780, g: 0.85 },
|
||||
{ t: 0.27, f: 1140, g: 0.7 },
|
||||
{ t: 0.42, f: 920, g: 0.55 },
|
||||
{ t: 0.59, f: 1340, g: 0.4 },
|
||||
{ t: 0.72, f: 1040, g: 0.25 },
|
||||
];
|
||||
const effects = {
|
||||
'chess-move': [
|
||||
0.19,
|
||||
(t) =>
|
||||
hit(t, 0, [165, 390, 940], 42) + filtered().low * Math.exp(-t * 70) * 0.5,
|
||||
],
|
||||
'chess-capture': [
|
||||
0.36,
|
||||
(t) =>
|
||||
hit(t, 0, [480, 970], 85, 0.65) +
|
||||
hit(t, 0.09, [125, 290, 690], 26) +
|
||||
filtered().low * Math.exp(-Math.abs(t - 0.12) * 35) * 0.3,
|
||||
],
|
||||
'checkers-move': [0.12, (t) => hit(t, 0, [1180, 2870, 4160], 85)],
|
||||
'checkers-capture': [
|
||||
0.43,
|
||||
(t) =>
|
||||
[0, 0.12, 0.24].reduce(
|
||||
(s, at, i) =>
|
||||
s + hit(t, at, [1320 - i * 120, 3080 - i * 230], 65, 1 - i * 0.18),
|
||||
0,
|
||||
),
|
||||
],
|
||||
'go-place': [
|
||||
0.18,
|
||||
(t) =>
|
||||
hit(t, 0, [2850, 4790, 7100], 58, 0.75) +
|
||||
hit(t, 0.013, [690, 1530], 120, 0.25),
|
||||
],
|
||||
'go-capture': [
|
||||
0.31,
|
||||
(t) =>
|
||||
[0, 0.034, 0.071, 0.12, 0.16].reduce(
|
||||
(s, at, i) =>
|
||||
s + hit(t, at, [2530 + i * 277, 4360 + i * 140], 95, 0.65),
|
||||
0,
|
||||
),
|
||||
],
|
||||
'parchis-move': [
|
||||
0.18,
|
||||
(t) => hit(t, 0, [720, 1640], 78) + hit(t, 0.03, [330, 1390], 90, 0.4),
|
||||
],
|
||||
'parchis-capture': [
|
||||
0.42,
|
||||
(t) =>
|
||||
filtered().high * Math.sin(Math.PI * Math.min(1, t / 0.28)) ** 2 * 0.4 +
|
||||
hit(t, 0.25, [370, 1280], 50),
|
||||
],
|
||||
'oca-move': [
|
||||
0.41,
|
||||
(t) =>
|
||||
[0, 0.09, 0.18, 0.27].reduce(
|
||||
(s, at, i) => s + hit(t, at, [290 + i * 80, 850 + i * 150], 60, 0.6),
|
||||
0,
|
||||
),
|
||||
],
|
||||
'oca-bonus': [
|
||||
0.72,
|
||||
(t) =>
|
||||
[0, 0.26].reduce((sum, start) => {
|
||||
const a = t - start;
|
||||
if (a < 0 || a > 0.23) return sum;
|
||||
const env = Math.sin((Math.PI * a) / 0.23) ** 2;
|
||||
return (
|
||||
sum +
|
||||
env *
|
||||
(wave(195 + 40 * a, a) * 0.32 +
|
||||
wave(850 + 400 * a, a) * 0.2 +
|
||||
wave(1420, a) * 0.1 +
|
||||
filtered().low * 0.4)
|
||||
);
|
||||
}, 0),
|
||||
],
|
||||
'oca-penalty': [
|
||||
0.8,
|
||||
(t) =>
|
||||
(wave(220 * Math.exp(-t * 2.4), t) * 0.6 +
|
||||
wave(440 * Math.exp(-t * 2.4), t) * 0.2) *
|
||||
Math.exp(-t * 5),
|
||||
],
|
||||
'cup-shake': [
|
||||
0.55,
|
||||
(t) =>
|
||||
cupImpacts.reduce(
|
||||
(s, impact) =>
|
||||
s +
|
||||
hit(
|
||||
t,
|
||||
impact.start,
|
||||
[impact.pitch, impact.pitch * 1.64],
|
||||
145,
|
||||
impact.gain,
|
||||
),
|
||||
0,
|
||||
),
|
||||
],
|
||||
'dice-roll': [
|
||||
0.94,
|
||||
(t) =>
|
||||
diceImpacts.reduce(
|
||||
(s, impact) =>
|
||||
s +
|
||||
hit(
|
||||
t,
|
||||
impact.t,
|
||||
[impact.f, impact.f * 2.17, impact.f * 3.6],
|
||||
impact.t ? 70 : 24,
|
||||
impact.g,
|
||||
),
|
||||
0,
|
||||
),
|
||||
],
|
||||
'card-play': [
|
||||
0.29,
|
||||
(t) => {
|
||||
const n = filtered();
|
||||
return (
|
||||
n.high * Math.sin(Math.PI * Math.min(1, t / 0.24)) ** 1.5 * 0.6 +
|
||||
hit(t, 0.205, [210, 950], 140, 0.35)
|
||||
);
|
||||
},
|
||||
],
|
||||
'card-collect': [
|
||||
0.65,
|
||||
(t) => {
|
||||
const n = filtered();
|
||||
return (
|
||||
n.high *
|
||||
(Math.exp(-(((t - 0.12) / 0.05) ** 2)) * 0.45 +
|
||||
Math.exp(-(((t - 0.36) / 0.1) ** 2)) * 0.6) +
|
||||
hit(t, 0.5, [190, 650], 100, 0.3)
|
||||
);
|
||||
},
|
||||
],
|
||||
'tute-card': [
|
||||
0.22,
|
||||
(t) =>
|
||||
filtered().high * Math.exp(-t * 17) * Math.min(1, t / 0.012) * 0.55 +
|
||||
hit(t, 0.025, [330, 1800], 140, 0.3),
|
||||
],
|
||||
'brisca-card': [
|
||||
0.38,
|
||||
(t) =>
|
||||
filtered().high * Math.sin((Math.PI * t) / 0.38) ** 2 * 0.55 +
|
||||
hit(t, 0.26, [140, 720], 90, 0.5),
|
||||
],
|
||||
'coin-buy': [
|
||||
1.1,
|
||||
(t) =>
|
||||
[1170, 2635, 4190, 6030].reduce(
|
||||
(s, f, i) => s + (wave(f, t) * Math.exp(-t * (5 + i * 3))) / (i + 1),
|
||||
0,
|
||||
),
|
||||
],
|
||||
'coin-payment': [
|
||||
0.8,
|
||||
(t) =>
|
||||
[0, 0.16, 0.28].reduce(
|
||||
(s, at, i) => s + hit(t, at, [1380 + i * 190, 3230 + i * 117], 18, 0.6),
|
||||
0,
|
||||
),
|
||||
],
|
||||
build: [
|
||||
0.46,
|
||||
(t) => hit(t, 0, [105, 290], 32) + hit(t, 0.18, [290, 780], 55, 0.7),
|
||||
],
|
||||
'mystery-suggest': [
|
||||
0.82,
|
||||
(t) => {
|
||||
const n = filtered();
|
||||
return (
|
||||
(wave(146.83, t) * 0.28 + wave(155.56, t) * 0.22 + n.low * 0.22) *
|
||||
Math.sin((Math.PI * t) / 0.82) ** 2
|
||||
);
|
||||
},
|
||||
],
|
||||
'mystery-accuse': [
|
||||
1.0,
|
||||
(t) =>
|
||||
pluck(t, 0, 196, 5) * 0.35 +
|
||||
pluck(t, 0.12, 233.08, 5) * 0.3 +
|
||||
pluck(t, 0.28, 277.18, 6) * 0.3,
|
||||
],
|
||||
'risk-reinforce': [
|
||||
0.24,
|
||||
(t) => hit(t, 0, [125, 650], 55) + hit(t, 0.065, [270, 1200], 100, 0.5),
|
||||
],
|
||||
'risk-attack': [
|
||||
0.78,
|
||||
(t) =>
|
||||
[0, 0.2, 0.42].reduce(
|
||||
(s, at, i) =>
|
||||
s +
|
||||
hit(
|
||||
t,
|
||||
at,
|
||||
[72 + i * 17, 151 + i * 37, 630],
|
||||
15 + i * 8,
|
||||
0.9 - i * 0.12,
|
||||
),
|
||||
0,
|
||||
) +
|
||||
filtered().low * Math.exp(-t * 5) * 0.45,
|
||||
],
|
||||
check: [
|
||||
0.66,
|
||||
(t) => pluck(t, 0, 880, 12) * 0.55 + pluck(t, 0.28, 1174.66, 12) * 0.5,
|
||||
],
|
||||
promotion: [
|
||||
1.15,
|
||||
(t) =>
|
||||
[523.25, 659.25, 783.99, 1046.5].reduce(
|
||||
(s, f, i) => s + pluck(t, i * 0.09, f, 5) * 0.25,
|
||||
0,
|
||||
),
|
||||
],
|
||||
goal: [
|
||||
1.2,
|
||||
(t) =>
|
||||
[659.25, 783.99, 987.77, 1318.51].reduce(
|
||||
(s, f, i) => s + pluck(t, i * 0.12, f, 5) * 0.3,
|
||||
0,
|
||||
),
|
||||
],
|
||||
turn: [
|
||||
0.16,
|
||||
(t) =>
|
||||
filtered().low * Math.exp(-t * 45) * 0.45 +
|
||||
hit(t, 0, [240, 690], 130, 0.3),
|
||||
],
|
||||
start: [
|
||||
1.05,
|
||||
(t) =>
|
||||
[329.63, 440, 554.37].reduce(
|
||||
(s, f, i) => s + pluck(t, 0.055 * i, f, 5) * 0.3,
|
||||
0,
|
||||
),
|
||||
],
|
||||
win: [
|
||||
2.1,
|
||||
(t) =>
|
||||
[392, 523.25, 659.25, 783.99].reduce(
|
||||
(s, f, i) => s + pluck(t, i * 0.18, f, 3.5) * 0.32,
|
||||
0,
|
||||
) +
|
||||
[523.25, 659.25, 783.99].reduce(
|
||||
(s, f) => s + pluck(t, 0.8, f, 4) * 0.17,
|
||||
0,
|
||||
),
|
||||
],
|
||||
loss: [
|
||||
1.1,
|
||||
(t) =>
|
||||
[293.66, 349.23, 440].reduce(
|
||||
(s, f, i) => s + pluck(t, i * 0.1, f, 5) * 0.25,
|
||||
0,
|
||||
),
|
||||
],
|
||||
draw: [
|
||||
0.9,
|
||||
(t) => pluck(t, 0, 440, 6) * 0.35 + pluck(t, 0.25, 523.25, 7) * 0.3,
|
||||
],
|
||||
};
|
||||
for (const [name, [duration, sample]] of Object.entries(effects)) {
|
||||
seed = 1781;
|
||||
low = 0;
|
||||
const count = Math.ceil(rate * duration),
|
||||
samples = new Float64Array(count);
|
||||
let peak = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = i / rate,
|
||||
fade = Math.max(0, Math.min(1, t / 0.0015, (duration - t) / 0.025));
|
||||
samples[i] = sample(t) * fade;
|
||||
peak = Math.max(peak, Math.abs(samples[i]));
|
||||
}
|
||||
const buffer = Buffer.alloc(44 + count * 2);
|
||||
buffer.write('RIFF');
|
||||
buffer.writeUInt32LE(buffer.length - 8, 4);
|
||||
buffer.write('WAVEfmt ', 8);
|
||||
buffer.writeUInt32LE(16, 16);
|
||||
buffer.writeUInt16LE(1, 20);
|
||||
buffer.writeUInt16LE(1, 22);
|
||||
buffer.writeUInt32LE(rate, 24);
|
||||
buffer.writeUInt32LE(rate * 2, 28);
|
||||
buffer.writeUInt16LE(2, 32);
|
||||
buffer.writeUInt16LE(16, 34);
|
||||
buffer.write('data', 36);
|
||||
buffer.writeUInt32LE(count * 2, 40);
|
||||
for (let i = 0; i < count; i++)
|
||||
buffer.writeInt16LE(
|
||||
Math.round((samples[i] / Math.max(peak, 0.01)) * 25000),
|
||||
44 + i * 2,
|
||||
);
|
||||
await writeFile(new URL(`${name}.wav`, directory), buffer);
|
||||
}
|
||||
console.log(
|
||||
`${Object.keys(effects).length} efectos originales con materiales y ritmos diferentes, a 44,1 kHz.`,
|
||||
);
|
||||
@ -0,0 +1,40 @@
|
||||
import 'dotenv/config';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
if (!process.env.DATABASE_URL)
|
||||
throw new Error('Configura DATABASE_URL antes de ejecutar las migraciones.');
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('SELECT pg_advisory_xact_lock(188413091)');
|
||||
await client.query(
|
||||
'CREATE TABLE IF NOT EXISTS schema_migrations(name text PRIMARY KEY,applied_at timestamptz NOT NULL DEFAULT now())',
|
||||
);
|
||||
const applied = new Set(
|
||||
(await client.query('SELECT name FROM schema_migrations')).rows.map(
|
||||
(r) => r.name,
|
||||
),
|
||||
);
|
||||
const files = (await readdir(new URL('../db/', import.meta.url)))
|
||||
.filter((f) => f.endsWith('.sql'))
|
||||
.sort();
|
||||
for (const name of files)
|
||||
if (!applied.has(name)) {
|
||||
await client.query(
|
||||
await readFile(new URL(`../db/${name}`, import.meta.url), 'utf8'),
|
||||
);
|
||||
await client.query('INSERT INTO schema_migrations(name) VALUES($1)', [
|
||||
name,
|
||||
]);
|
||||
console.log(`Migración aplicada: ${name}`);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import {
|
||||
createHash,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
scrypt as scryptCallback,
|
||||
timingSafeEqual,
|
||||
} from 'node:crypto';
|
||||
import { promisify } from 'node:util';
|
||||
import { avatarReference } from './avatar.mjs';
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
export const hashToken = (token) =>
|
||||
createHash('sha256').update(token).digest('hex');
|
||||
export const publicUser = (user) =>
|
||||
user
|
||||
? {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
nick: user.nick,
|
||||
createdAt: user.createdAt,
|
||||
guest: Boolean(user.guest),
|
||||
avatar: avatarReference(user),
|
||||
}
|
||||
: null;
|
||||
export async function hashPassword(password) {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const hash = await scrypt(password, salt, 64);
|
||||
return `${salt}:${hash.toString('hex')}`;
|
||||
}
|
||||
export async function verifyPassword(password, encoded) {
|
||||
const [salt, stored] = encoded.split(':');
|
||||
const expected = Buffer.from(stored, 'hex');
|
||||
const actual = await scrypt(password, salt, 64);
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
}
|
||||
export const hashPin = (pin, pepper) =>
|
||||
hashPassword(createHmac('sha256', pepper).update(pin).digest('hex'));
|
||||
export const verifyPin = (pin, encoded, pepper) =>
|
||||
verifyPassword(
|
||||
createHmac('sha256', pepper).update(pin).digest('hex'),
|
||||
encoded,
|
||||
);
|
||||
export function cookieToken(header = '') {
|
||||
const token = header
|
||||
.split(';')
|
||||
.map((v) => v.trim())
|
||||
.find((v) => v.startsWith('mesa_session='))
|
||||
?.slice(13);
|
||||
return token && /^[a-f0-9]{64}$/.test(token) ? token : null;
|
||||
}
|
||||
export async function authenticate(store, token) {
|
||||
if (!token) return null;
|
||||
const session = await store.session(hashToken(token));
|
||||
return session ? publicUser(await store.userById(session.userId)) : null;
|
||||
}
|
||||
export async function newSession(store, userId) {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
|
||||
await store.createSession({ hash: hashToken(token), userId, expiresAt });
|
||||
return token;
|
||||
}
|
||||
export function sessionCookie(token, production) {
|
||||
return `mesa_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${token ? 2592000 : 0}${production ? '; Secure' : ''}`;
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { avatarPresets } from '../shared/avatars.mjs';
|
||||
export function avatarReference(user) {
|
||||
if (user?.avatar?.preset)
|
||||
return avatarPresets.find((p) => p.id === user.avatar.preset)?.src;
|
||||
if (user?.avatar?.image)
|
||||
return `/api/avatars/${user.id}?v=${user.avatar.revision}`;
|
||||
}
|
||||
export function validateAvatar(input) {
|
||||
if ('preset' in input) {
|
||||
if (input.preset === null) return null;
|
||||
if (!avatarPresets.some((p) => p.id === input.preset))
|
||||
throw Error('Elige un avatar de la lista.');
|
||||
return { preset: input.preset };
|
||||
}
|
||||
if (
|
||||
typeof input.image !== 'string' ||
|
||||
!/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(input.image) ||
|
||||
input.image.length > 90000
|
||||
)
|
||||
throw Error('La imagen del avatar no es válida.');
|
||||
const bytes = Buffer.from(input.image.slice(22), 'base64');
|
||||
if (
|
||||
bytes.length < 45 ||
|
||||
bytes.length > 65536 ||
|
||||
bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' ||
|
||||
bytes.toString('ascii', 12, 16) !== 'IHDR' ||
|
||||
bytes.readUInt32BE(8) !== 13
|
||||
)
|
||||
throw Error('El avatar debe ser una imagen PNG.');
|
||||
const width = bytes.readUInt32BE(16),
|
||||
height = bytes.readUInt32BE(20);
|
||||
if (width < 1 || height < 1 || width > 256 || height > 256)
|
||||
throw Error('La imagen del avatar debe medir como máximo 256 píxeles.');
|
||||
return {
|
||||
image: input.image,
|
||||
revision: createHash('sha256').update(bytes).digest('hex').slice(0, 16),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
import { Chess } from 'chess.js';
|
||||
|
||||
function engine(state) {
|
||||
const game = new Chess();
|
||||
for (const move of state.moves) game.move(move);
|
||||
return game;
|
||||
}
|
||||
|
||||
export const chess = {
|
||||
id: 'ajedrez',
|
||||
version: '1.0.0',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
/** @returns {{ moves: string[], fen: string, turn: number }} */
|
||||
create() {
|
||||
return { moves: [], fen: new Chess().fen(), turn: 0 };
|
||||
},
|
||||
legal(state, seat) {
|
||||
if (seat !== state.turn) return [];
|
||||
return engine(state)
|
||||
.moves({ verbose: true })
|
||||
.map((m) => ({
|
||||
type: 'move',
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
...(m.promotion ? { promotion: m.promotion } : {}),
|
||||
}));
|
||||
},
|
||||
apply(state, action, seat) {
|
||||
if (seat !== state.turn || action.type !== 'move')
|
||||
throw new Error('No puedes hacer esa jugada.');
|
||||
const game = engine(state);
|
||||
const move = game.move({
|
||||
from: action.from,
|
||||
to: action.to,
|
||||
...(action.promotion ? { promotion: action.promotion } : {}),
|
||||
});
|
||||
if (!move) throw new Error('Movimiento ilegal.');
|
||||
const next = {
|
||||
moves: [...state.moves, move.san],
|
||||
fen: game.fen(),
|
||||
turn: game.turn() === 'w' ? 0 : 1,
|
||||
};
|
||||
let result = null;
|
||||
if (game.isCheckmate()) result = { winner: seat, reason: 'jaque mate' };
|
||||
else if (game.isDraw())
|
||||
result = {
|
||||
winner: null,
|
||||
reason: game.isStalemate() ? 'rey ahogado' : 'tablas por las reglas',
|
||||
};
|
||||
return {
|
||||
state: next,
|
||||
label: move.san,
|
||||
result,
|
||||
sound: result
|
||||
? 'finish'
|
||||
: game.isCheck()
|
||||
? 'check'
|
||||
: move.promotion
|
||||
? 'promotion'
|
||||
: move.captured
|
||||
? 'capture'
|
||||
: 'move',
|
||||
};
|
||||
},
|
||||
view(state) {
|
||||
const game = engine(state);
|
||||
return {
|
||||
...state,
|
||||
board: game.board(),
|
||||
check: game.isCheck(),
|
||||
pgn: game.pgn(),
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,188 @@
|
||||
const size = 9;
|
||||
const neighbors = (i) =>
|
||||
[
|
||||
i % size ? i - 1 : -1,
|
||||
i % size < size - 1 ? i + 1 : -1,
|
||||
i >= size ? i - size : -1,
|
||||
i < size * (size - 1) ? i + size : -1,
|
||||
].filter((v) => v >= 0);
|
||||
function group(board, start) {
|
||||
const stones = new Set([start]),
|
||||
liberties = new Set(),
|
||||
queue = [start],
|
||||
color = board[start];
|
||||
for (const i of queue)
|
||||
for (const n of neighbors(i)) {
|
||||
if (board[n] === 0) liberties.add(n);
|
||||
else if (board[n] === color && !stones.has(n)) {
|
||||
stones.add(n);
|
||||
queue.push(n);
|
||||
}
|
||||
}
|
||||
return { stones: [...stones], liberties };
|
||||
}
|
||||
function place(state, to, seat) {
|
||||
if (!Number.isInteger(to) || to < 0 || to >= size * size || state.board[to])
|
||||
throw new Error('Intersección ocupada o no válida.');
|
||||
const board = [...state.board],
|
||||
color = seat + 1;
|
||||
board[to] = color;
|
||||
let captured = 0;
|
||||
for (const n of neighbors(to))
|
||||
if (board[n] === 3 - color) {
|
||||
const enemy = group(board, n);
|
||||
if (!enemy.liberties.size) {
|
||||
for (const i of enemy.stones) board[i] = 0;
|
||||
captured += enemy.stones.length;
|
||||
}
|
||||
}
|
||||
if (!group(board, to).liberties.size)
|
||||
throw new Error('No puedes hacer una jugada suicida.');
|
||||
if (state.history.includes(board.join('')))
|
||||
throw new Error('Superko: no puedes repetir una posición anterior.');
|
||||
return { board, captured };
|
||||
}
|
||||
function score(state) {
|
||||
const board = state.board.map((v, i) => (state.dead.includes(i) ? 0 : v)),
|
||||
seen = new Set(),
|
||||
scores = [0, 6.5];
|
||||
for (let i = 0; i < board.length; i++) {
|
||||
if (board[i]) {
|
||||
scores[board[i] - 1]++;
|
||||
continue;
|
||||
}
|
||||
if (seen.has(i)) continue;
|
||||
const empty = [i],
|
||||
borders = new Set();
|
||||
seen.add(i);
|
||||
for (const p of empty)
|
||||
for (const n of neighbors(p))
|
||||
if (board[n]) borders.add(board[n]);
|
||||
else if (!seen.has(n)) {
|
||||
seen.add(n);
|
||||
empty.push(n);
|
||||
}
|
||||
if (borders.size === 1) scores[[...borders][0] - 1] += empty.length;
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
export const go = {
|
||||
id: 'go',
|
||||
version: '1.0.0',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 2,
|
||||
create() {
|
||||
const board = Array(size * size).fill(0);
|
||||
return {
|
||||
board,
|
||||
turn: 0,
|
||||
phase: 'play',
|
||||
passes: 0,
|
||||
history: [board.join('')],
|
||||
captures: [0, 0],
|
||||
dead: [],
|
||||
accepted: [false, false],
|
||||
scores: null,
|
||||
};
|
||||
},
|
||||
legal(state, seat) {
|
||||
if (state.phase === 'scoring')
|
||||
return [
|
||||
...state.board.flatMap((v, to) => (v ? [{ type: 'mark', to }] : [])),
|
||||
{ type: 'accept-score' },
|
||||
{ type: 'resume' },
|
||||
];
|
||||
if (state.phase !== 'play' || state.turn !== seat) return [];
|
||||
return [
|
||||
...state.board.flatMap((v, to) => {
|
||||
if (v) return [];
|
||||
try {
|
||||
place(state, to, seat);
|
||||
return [{ type: 'move', to }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
{ type: 'pass' },
|
||||
];
|
||||
},
|
||||
apply(state, action, seat) {
|
||||
if (
|
||||
!this.legal(state, seat).some(
|
||||
(a) =>
|
||||
a.type === action.type && (a.to === undefined || a.to === action.to),
|
||||
)
|
||||
)
|
||||
throw new Error('Esa acción no está disponible.');
|
||||
const next = structuredClone(state);
|
||||
let label = '',
|
||||
sound = 'move',
|
||||
result = null;
|
||||
if (action.type === 'move') {
|
||||
const move = place(state, action.to, seat);
|
||||
next.board = move.board;
|
||||
next.history.push(next.board.join(''));
|
||||
next.captures[seat] += move.captured;
|
||||
next.turn = 1 - seat;
|
||||
next.passes = 0;
|
||||
label = `Piedra en ${'ABCDEFGHJ'[action.to % size]}${size - Math.floor(action.to / size)}${move.captured ? ` · captura ${move.captured}` : ''}`;
|
||||
sound = move.captured ? 'capture' : 'move';
|
||||
}
|
||||
if (action.type === 'pass') {
|
||||
next.turn = 1 - seat;
|
||||
next.passes++;
|
||||
if (next.passes === 2) next.phase = 'scoring';
|
||||
label =
|
||||
next.phase === 'scoring'
|
||||
? 'Segundo pase: acordad las piedras muertas y el resultado'
|
||||
: 'Pasa';
|
||||
sound = 'pass';
|
||||
}
|
||||
if (action.type === 'mark') {
|
||||
const stones = group(next.board, action.to).stones,
|
||||
marked = next.dead.includes(action.to);
|
||||
next.dead = marked
|
||||
? next.dead.filter((i) => !stones.includes(i))
|
||||
: [...new Set([...next.dead, ...stones])];
|
||||
next.accepted = [false, false];
|
||||
label = marked ? 'Restaura un grupo vivo' : 'Marca un grupo muerto';
|
||||
sound = 'move';
|
||||
}
|
||||
if (action.type === 'resume') {
|
||||
next.phase = 'play';
|
||||
next.passes = 0;
|
||||
next.dead = [];
|
||||
next.accepted = [false, false];
|
||||
next.turn = seat;
|
||||
label = 'Reanuda el juego';
|
||||
sound = 'pass';
|
||||
}
|
||||
if (action.type === 'accept-score') {
|
||||
next.accepted[seat] = true;
|
||||
label = 'Acepta la propuesta de puntuación';
|
||||
sound = 'pass';
|
||||
if (next.accepted.every(Boolean)) {
|
||||
next.scores = score(next);
|
||||
next.phase = 'finished';
|
||||
result = {
|
||||
winner: next.scores[0] > next.scores[1] ? 0 : 1,
|
||||
reason: `área: negras ${next.scores[0]}, blancas ${next.scores[1]} (komi 6,5)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { state: next, label, sound, result };
|
||||
},
|
||||
view(state) {
|
||||
return {
|
||||
turn: state.turn,
|
||||
grid: state.board,
|
||||
size,
|
||||
phase: state.phase,
|
||||
captures: state.captures,
|
||||
dead: state.dead,
|
||||
accepted: state.accepted,
|
||||
scores: state.scores ?? score(state),
|
||||
komi: 6.5,
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,118 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { geese } from '../../shared/oca-board.mjs';
|
||||
export { geese };
|
||||
function advanceTurn(state, seat) {
|
||||
let candidate = seat;
|
||||
// Each skipped seat consumes one turn automatically; a full circuit may still have penalties.
|
||||
for (let n = 0; n < state.positions.length * 4; n++) {
|
||||
candidate = (candidate + 1) % state.positions.length;
|
||||
if (state.withdrawn?.includes(candidate)) continue;
|
||||
if (state.skips[candidate] > 0) {
|
||||
state.skips[candidate]--;
|
||||
continue;
|
||||
}
|
||||
state.turn = candidate;
|
||||
return;
|
||||
}
|
||||
throw new Error('Estado de turnos no válido.');
|
||||
}
|
||||
export const oca = {
|
||||
withdraw(state, seat) {
|
||||
const next = structuredClone(state);
|
||||
next.withdrawn = [...(next.withdrawn ?? []), seat];
|
||||
next.positions[seat] = 0;
|
||||
next.skips[seat] = 0;
|
||||
if (next.turn === seat) advanceTurn(next, seat);
|
||||
return next;
|
||||
},
|
||||
id: 'oca',
|
||||
version: '1.0.0',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 4,
|
||||
create(players = 4) {
|
||||
return {
|
||||
positions: Array(players).fill(0),
|
||||
skips: Array(players).fill(0),
|
||||
turn: 0,
|
||||
rolled: null,
|
||||
lastPath: [],
|
||||
lastPlayer: null,
|
||||
note: 'Llegad al jardín de la casilla 63.',
|
||||
};
|
||||
},
|
||||
legal(state, seat) {
|
||||
return seat === state.turn ? [{ type: 'roll' }] : [];
|
||||
},
|
||||
apply(state, action, seat, rng = () => randomInt(1, 7)) {
|
||||
if (seat !== state.turn || action.type !== 'roll')
|
||||
throw new Error('No es tu turno.');
|
||||
const die = rng();
|
||||
if (!Number.isInteger(die) || die < 1 || die > 6)
|
||||
throw new Error('Dado no válido.');
|
||||
const next = structuredClone(state),
|
||||
start = state.positions[seat];
|
||||
let target = start + die,
|
||||
repeat = false,
|
||||
sound = 'roll';
|
||||
next.rolled = die;
|
||||
next.lastPlayer = seat;
|
||||
next.lastEffect = null;
|
||||
next.lastPath = Array.from({ length: die }, (_, i) =>
|
||||
start + i + 1 > 63 ? 126 - start - i - 1 : start + i + 1,
|
||||
);
|
||||
if (target > 63) target = 126 - target;
|
||||
let label = `Dado: ${die} · casilla ${target}`;
|
||||
if (target === 63) {
|
||||
next.positions[seat] = 63;
|
||||
next.lastEffect = 'goal';
|
||||
next.note = '¡Has llegado al jardín!';
|
||||
return {
|
||||
state: next,
|
||||
label: `${label} · llega al jardín`,
|
||||
sound,
|
||||
result: { winner: seat, reason: 'llega exactamente a la casilla 63' },
|
||||
};
|
||||
}
|
||||
if (geese.includes(target)) {
|
||||
target = geese[geese.indexOf(target) + 1] ?? target;
|
||||
repeat = true;
|
||||
label += ` · de oca a oca: ${target}`;
|
||||
} else if (target === 6 || target === 12) {
|
||||
target = target === 6 ? 12 : 6;
|
||||
repeat = true;
|
||||
label += ` · puente a ${target}`;
|
||||
} else if (target === 26 || target === 53) {
|
||||
target = target === 26 ? 53 : 26;
|
||||
repeat = true;
|
||||
label += ` · de dado a dado: ${target}`;
|
||||
} else if (target === 19) {
|
||||
next.skips[seat] = 1;
|
||||
label += ' · posada: descansa un turno';
|
||||
} else if (target === 31) {
|
||||
next.skips[seat] = 3;
|
||||
label += ' · pozo: descansa tres turnos';
|
||||
} else if (target === 42) {
|
||||
target = 30;
|
||||
label += ' · laberinto: vuelve a 30';
|
||||
} else if (target === 52) {
|
||||
next.skips[seat] = 2;
|
||||
label += ' · cárcel: descansa dos turnos';
|
||||
} else if (target === 58) {
|
||||
target = 0;
|
||||
label += ' · vuelve al inicio';
|
||||
}
|
||||
next.lastEffect = repeat
|
||||
? 'bonus'
|
||||
: next.skips[seat] > 0 || target < next.lastPath.at(-1)
|
||||
? 'penalty'
|
||||
: null;
|
||||
next.positions[seat] = target;
|
||||
next.lastPath.push(target);
|
||||
next.note = label;
|
||||
if (!repeat) advanceTurn(next, seat);
|
||||
return { state: next, label, sound, result: null };
|
||||
},
|
||||
view(state) {
|
||||
return { ...state, geese };
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,256 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { parchisColorOrder } from '../../shared/player-colors.mjs';
|
||||
import { nextPlayingSeat, withdrawParchis } from './turns.mjs';
|
||||
/** @typedef {{starts?:number[],pieces:number[][],turn:number,die:number|null,rolled:number|null,bonus:boolean,extraTurn:boolean,sixes:number,lastMoved:number|null,arrivals:number[][],clock:number}} ParchisState */
|
||||
|
||||
export const starts = [4, 21, 38, 55];
|
||||
const OUTER = 65,
|
||||
GOAL = 72;
|
||||
const safe = new Set([
|
||||
0, 4, 11, 16, 17, 21, 28, 33, 34, 38, 45, 50, 51, 55, 62, 67,
|
||||
]);
|
||||
const square = (state, seat, progress) =>
|
||||
((state.starts ?? starts)[seat] + progress) % 68;
|
||||
const occupants = (state, cell) =>
|
||||
state.pieces.flatMap((pieces, seat) =>
|
||||
pieces.flatMap((p, piece) =>
|
||||
p >= 0 && p < OUTER && square(state, seat, p) === cell
|
||||
? [{ seat, piece }]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const barrier = (group) =>
|
||||
group.length === 2 && group[0].seat === group[1].seat;
|
||||
function canMove(state, seat, piece) {
|
||||
const p = state.pieces[seat][piece],
|
||||
distance = state.die;
|
||||
if (
|
||||
!distance ||
|
||||
p === GOAL ||
|
||||
(p === -1 && (distance !== 5 || state.bonus)) ||
|
||||
p + distance > GOAL
|
||||
)
|
||||
return false;
|
||||
const target = p === -1 ? 0 : p + distance;
|
||||
if (p !== -1)
|
||||
for (let step = p + 1; step <= target; step++) {
|
||||
if (step < OUTER && barrier(occupants(state, square(state, seat, step))))
|
||||
return false;
|
||||
if (
|
||||
step >= OUTER &&
|
||||
step < GOAL &&
|
||||
state.pieces[seat].filter((v) => v === step).length >= 2
|
||||
)
|
||||
return false;
|
||||
}
|
||||
if (target < OUTER) {
|
||||
const group = occupants(state, square(state, seat, target));
|
||||
if (p === -1 && group.length === 2 && group.some((v) => v.seat !== seat))
|
||||
return true;
|
||||
if (group.length >= 2) return false;
|
||||
} else if (
|
||||
target < GOAL &&
|
||||
state.pieces[seat].filter((v) => v === target).length >= 2
|
||||
)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
function moves(state, seat) {
|
||||
let choices = state.pieces[seat].flatMap((_, piece) =>
|
||||
canMove(state, seat, piece) ? [{ type: 'move', piece }] : [],
|
||||
);
|
||||
if (
|
||||
!state.bonus &&
|
||||
state.die === 5 &&
|
||||
choices.some((a) => state.pieces[seat][a.piece] === -1)
|
||||
)
|
||||
return choices.filter((a) => state.pieces[seat][a.piece] === -1);
|
||||
if (!state.bonus && state.rolled === 6) {
|
||||
const opening = choices.filter((a) => {
|
||||
const p = state.pieces[seat][a.piece];
|
||||
return (
|
||||
p >= 0 &&
|
||||
p < GOAL &&
|
||||
(p < OUTER
|
||||
? barrier(occupants(state, square(state, seat, p)))
|
||||
: state.pieces[seat].filter((v) => v === p).length === 2)
|
||||
);
|
||||
});
|
||||
if (opening.length) choices = opening;
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
function endTurn(state, seat) {
|
||||
state.die = null;
|
||||
state.bonus = false;
|
||||
if (!state.extraTurn) {
|
||||
state.turn = nextPlayingSeat(state, seat);
|
||||
state.sixes = 0;
|
||||
state.lastMoved = null;
|
||||
}
|
||||
state.extraTurn = false;
|
||||
}
|
||||
function settle(state, seat) {
|
||||
if (state.die !== null && !moves(state, seat).length) {
|
||||
const bonus = state.bonus;
|
||||
endTurn(state, seat);
|
||||
return bonus ? ' · premio sin movimiento posible' : ' · sin movimientos';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
export const parchis = {
|
||||
withdraw: withdrawParchis,
|
||||
id: 'parchis',
|
||||
version: '2.0.0',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 4,
|
||||
/** @returns {ParchisState} */
|
||||
create(players = 4, colors = []) {
|
||||
const selected = Array.from(
|
||||
{ length: players },
|
||||
(_, seat) =>
|
||||
starts[
|
||||
parchisColorOrder.indexOf(colors[seat] ?? parchisColorOrder[seat])
|
||||
],
|
||||
);
|
||||
if (
|
||||
selected.some((s) => s === undefined) ||
|
||||
new Set(selected).size !== players
|
||||
)
|
||||
throw new Error('Los colores del parchís deben ser distintos.');
|
||||
return {
|
||||
starts: selected,
|
||||
pieces: Array.from({ length: players }, () => [-1, -1, -1, -1]),
|
||||
turn: 0,
|
||||
die: null,
|
||||
rolled: null,
|
||||
bonus: false,
|
||||
extraTurn: false,
|
||||
sixes: 0,
|
||||
lastMoved: null,
|
||||
arrivals: Array.from({ length: players }, () => [0, 0, 0, 0]),
|
||||
clock: 0,
|
||||
};
|
||||
},
|
||||
/** @returns {{type:string,piece?:number}[]} */
|
||||
legal(state, seat) {
|
||||
if (seat !== state.turn) return [];
|
||||
if (state.die === null) return [{ type: 'roll' }];
|
||||
const choices = moves(state, seat);
|
||||
return choices.length ? choices : [{ type: 'pass' }];
|
||||
},
|
||||
apply(state, action, seat, rng = () => randomInt(1, 7)) {
|
||||
if (
|
||||
!this.legal(state, seat).some(
|
||||
(a) =>
|
||||
a.type === action.type &&
|
||||
(a.type !== 'move' || a.piece === action.piece),
|
||||
)
|
||||
)
|
||||
throw new Error('Esa acción no está disponible.');
|
||||
const next = structuredClone(state);
|
||||
if (action.type === 'roll') {
|
||||
const die = rng();
|
||||
if (!Number.isInteger(die) || die < 1 || die > 6)
|
||||
throw new Error('Dado no válido.');
|
||||
next.rolled = die;
|
||||
next.sixes = die === 6 ? (state.sixes ?? 0) + 1 : 0;
|
||||
if (next.sixes === 3) {
|
||||
const last = next.lastMoved;
|
||||
if (
|
||||
last !== null &&
|
||||
last !== undefined &&
|
||||
next.pieces[seat][last] >= 0 &&
|
||||
next.pieces[seat][last] < OUTER
|
||||
)
|
||||
next.pieces[seat][last] = -1;
|
||||
next.extraTurn = false;
|
||||
endTurn(next, seat);
|
||||
return {
|
||||
state: next,
|
||||
label:
|
||||
'Tercer seis: termina el turno; la última ficha del recorrido vuelve a casa',
|
||||
result: null,
|
||||
sound: 'pass',
|
||||
};
|
||||
}
|
||||
next.die = die === 6 && !next.pieces[seat].includes(-1) ? 7 : die;
|
||||
next.extraTurn = die === 6;
|
||||
const skipped = settle(next, seat);
|
||||
return {
|
||||
state: next,
|
||||
label: `Dado: ${die}${next.die === 7 ? ' · avanza 7' : ''}${skipped}`,
|
||||
result: null,
|
||||
sound: 'roll',
|
||||
};
|
||||
}
|
||||
if (action.type === 'pass') {
|
||||
endTurn(next, seat);
|
||||
return {
|
||||
state: next,
|
||||
label: 'Sin movimientos: turno resuelto',
|
||||
result: null,
|
||||
sound: 'pass',
|
||||
};
|
||||
}
|
||||
const piece = action.piece,
|
||||
p = state.pieces[seat][piece],
|
||||
target = p === -1 ? 0 : p + state.die;
|
||||
let reward = null,
|
||||
label = `Ficha ${piece + 1}: ${p === -1 ? 'sale de casa' : `avanza ${state.die}`}`;
|
||||
const group =
|
||||
target < OUTER ? occupants(state, square(state, seat, target)) : [];
|
||||
let victims = group.filter((v) => v.seat !== seat);
|
||||
if (p === -1 && group.length === 2)
|
||||
victims = victims
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(state.arrivals?.[b.seat]?.[b.piece] ?? 0) -
|
||||
(state.arrivals?.[a.seat]?.[a.piece] ?? 0),
|
||||
)
|
||||
.slice(0, 1);
|
||||
else if (target >= OUTER || safe.has(square(state, seat, target)))
|
||||
victims = [];
|
||||
for (const victim of victims) {
|
||||
next.pieces[victim.seat][victim.piece] = -1;
|
||||
reward = 20;
|
||||
label += ' · captura';
|
||||
}
|
||||
next.pieces[seat][piece] = target;
|
||||
next.lastMoved = piece;
|
||||
next.clock = (next.clock ?? 0) + 1;
|
||||
next.arrivals[seat][piece] = next.clock;
|
||||
if (target === GOAL) {
|
||||
reward = 10;
|
||||
label += ' · llega a meta';
|
||||
}
|
||||
if (next.pieces[seat].every((v) => v === GOAL))
|
||||
return {
|
||||
state: next,
|
||||
label,
|
||||
result: { winner: seat, reason: 'cuatro fichas en meta' },
|
||||
sound: 'finish',
|
||||
};
|
||||
if (reward !== null) {
|
||||
next.die = reward;
|
||||
next.bonus = true;
|
||||
label += ` · premio de ${reward}`;
|
||||
label += settle(next, seat);
|
||||
} else endTurn(next, seat);
|
||||
return {
|
||||
state: next,
|
||||
label,
|
||||
result: null,
|
||||
sound: reward === 20 ? 'capture' : reward === 10 ? 'goal' : 'move',
|
||||
};
|
||||
},
|
||||
view(state) {
|
||||
return {
|
||||
...state,
|
||||
starts: state.starts ?? starts,
|
||||
safe: [...safe],
|
||||
goal: GOAL,
|
||||
outerLength: OUTER,
|
||||
};
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,165 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { nextPlayingSeat, withdrawParchis } from './turns.mjs';
|
||||
|
||||
export const starts = [4, 21, 38, 55];
|
||||
const safe = new Set([4, 11, 16, 21, 28, 33, 38, 45, 50, 55, 62, 67]);
|
||||
const square = (seat, progress) => (starts[seat] + progress) % 68;
|
||||
const occupants = (state, cell) =>
|
||||
state.pieces.flatMap((pieces, seat) =>
|
||||
pieces.flatMap((p, piece) =>
|
||||
p >= 0 && p < 64 && square(seat, p) === cell ? [{ seat, piece }] : [],
|
||||
),
|
||||
);
|
||||
|
||||
function canMove(state, seat, piece) {
|
||||
const p = state.pieces[seat][piece];
|
||||
const distance = state.die;
|
||||
if (
|
||||
!distance ||
|
||||
p === 71 ||
|
||||
(p === -1 && distance !== 5) ||
|
||||
p + distance > 71
|
||||
)
|
||||
return false;
|
||||
const target = p === -1 ? 0 : p + distance;
|
||||
if (p !== -1)
|
||||
for (let step = p + 1; step <= Math.min(target, 63); step++) {
|
||||
const group = occupants(state, square(seat, step));
|
||||
if (group.length >= 2 && group[0].seat === group[1].seat) return false;
|
||||
}
|
||||
if (target < 64) {
|
||||
const group = occupants(state, square(seat, target));
|
||||
if (group.length >= 2) return false;
|
||||
if (
|
||||
group.length &&
|
||||
group[0].seat !== seat &&
|
||||
safe.has(square(seat, target))
|
||||
)
|
||||
return false;
|
||||
} else if (target < 71 && state.pieces[seat].includes(target)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export const parchis = {
|
||||
withdraw: withdrawParchis,
|
||||
id: 'parchis',
|
||||
version: '1.0.0',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 4,
|
||||
/** @returns {{pieces: number[][], turn: number, die: number|null, rolled: number|null, bonus: boolean, extraTurn: boolean}} */
|
||||
create(players = 4) {
|
||||
return {
|
||||
pieces: Array.from({ length: players }, () => [-1, -1, -1, -1]),
|
||||
turn: 0,
|
||||
die: null,
|
||||
rolled: null,
|
||||
bonus: false,
|
||||
extraTurn: false,
|
||||
};
|
||||
},
|
||||
/** @returns {{type: string, piece?: number}[]} */
|
||||
legal(state, seat) {
|
||||
if (seat !== state.turn) return [];
|
||||
if (state.die === null) return [{ type: 'roll' }];
|
||||
let moves = state.pieces[seat].flatMap((_, piece) =>
|
||||
canMove(state, seat, piece) ? [{ type: 'move', piece }] : [],
|
||||
);
|
||||
// In this explicit basic variant, leaving home on a five is compulsory.
|
||||
if (
|
||||
!state.bonus &&
|
||||
state.die === 5 &&
|
||||
moves.some((m) => state.pieces[seat][m.piece] === -1)
|
||||
)
|
||||
moves = moves.filter((m) => state.pieces[seat][m.piece] === -1);
|
||||
return moves.length ? moves : [{ type: 'pass' }];
|
||||
},
|
||||
apply(state, action, seat, rng = () => randomInt(1, 7)) {
|
||||
if (
|
||||
!this.legal(state, seat).some(
|
||||
(a) =>
|
||||
a.type === action.type &&
|
||||
(a.type !== 'move' || a.piece === action.piece),
|
||||
)
|
||||
)
|
||||
throw new Error('Esa acción no está disponible.');
|
||||
const next = structuredClone(state);
|
||||
if (action.type === 'roll') {
|
||||
const die = rng();
|
||||
if (!Number.isInteger(die) || die < 1 || die > 6)
|
||||
throw new Error('Dado no válido.');
|
||||
next.die = die;
|
||||
next.rolled = die;
|
||||
next.extraTurn = die === 6;
|
||||
if (this.legal(next, seat).every((a) => a.type === 'pass')) {
|
||||
next.die = null;
|
||||
next.turn = next.extraTurn ? seat : nextPlayingSeat(next, seat);
|
||||
next.extraTurn = false;
|
||||
}
|
||||
return {
|
||||
state: next,
|
||||
label: `Dado: ${die}`,
|
||||
result: null,
|
||||
sound: 'roll',
|
||||
};
|
||||
}
|
||||
let reward = null;
|
||||
let label = 'Sin movimientos: pasa el turno';
|
||||
if (action.type === 'move') {
|
||||
const piece = action.piece;
|
||||
const p = state.pieces[seat][piece];
|
||||
const target = p === -1 ? 0 : p + state.die;
|
||||
next.pieces[seat][piece] = target;
|
||||
label = `Ficha ${piece + 1}: ${p === -1 ? 'sale de casa' : `avanza ${state.die}`}`;
|
||||
if (target < 64 && !safe.has(square(seat, target))) {
|
||||
for (const other of occupants(next, square(seat, target)))
|
||||
if (other.seat !== seat) {
|
||||
next.pieces[other.seat][other.piece] = -1;
|
||||
reward = 20;
|
||||
label += ' · captura';
|
||||
}
|
||||
}
|
||||
if (target === 71) {
|
||||
reward = 10;
|
||||
label += ' · llega a meta';
|
||||
}
|
||||
if (next.pieces[seat].every((p) => p === 71))
|
||||
return {
|
||||
state: next,
|
||||
label,
|
||||
result: { winner: seat, reason: 'cuatro fichas en meta' },
|
||||
sound: 'finish',
|
||||
};
|
||||
}
|
||||
if (reward !== null) {
|
||||
next.die = reward;
|
||||
next.bonus = true;
|
||||
if (this.legal(next, seat).every((a) => a.type === 'pass')) {
|
||||
next.die = null;
|
||||
next.bonus = false;
|
||||
next.turn = next.extraTurn ? seat : nextPlayingSeat(next, seat);
|
||||
next.extraTurn = false;
|
||||
}
|
||||
} else {
|
||||
next.die = null;
|
||||
next.bonus = false;
|
||||
next.turn = state.extraTurn ? seat : nextPlayingSeat(next, seat);
|
||||
next.extraTurn = false;
|
||||
}
|
||||
return {
|
||||
state: next,
|
||||
label,
|
||||
result: null,
|
||||
sound:
|
||||
action.type === 'pass'
|
||||
? 'pass'
|
||||
: reward === 20
|
||||
? 'capture'
|
||||
: reward === 10
|
||||
? 'goal'
|
||||
: 'move',
|
||||
};
|
||||
},
|
||||
view(state) {
|
||||
return { ...state, starts, safe: [...safe] };
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import { chess } from './chess.mjs';
|
||||
import { checkers } from './checkers.mjs';
|
||||
import { parchis } from './parchis-classic.mjs';
|
||||
import { parchis as legacyParchis } from './parchis.mjs';
|
||||
import { go } from './go.mjs';
|
||||
import { oca } from './oca.mjs';
|
||||
|
||||
// Trusted modules are deployed with the application. No uploaded code is executed.
|
||||
export const games = new Map(
|
||||
[chess, checkers, parchis, go, oca].map((game) => [game.id, game]),
|
||||
);
|
||||
export { catalog } from '../../shared/catalog.mjs';
|
||||
|
||||
export function getGame(id, version) {
|
||||
// Existing checkers positions use the same state and receive the requested fix.
|
||||
if (id === 'damas' && version === '1.0.0') return checkers;
|
||||
if (id === 'parchis' && version === legacyParchis.version)
|
||||
return legacyParchis;
|
||||
const game = games.get(id);
|
||||
if (!game || (version && game.version !== version))
|
||||
throw new Error('La versión de este juego no está disponible.');
|
||||
return game;
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
export function nextPlayingSeat(state, seat) {
|
||||
const count = state.pieces?.length ?? state.positions.length;
|
||||
for (let offset = 1; offset <= count; offset++) {
|
||||
const next = (seat + offset) % count;
|
||||
if (!state.withdrawn?.includes(next)) return next;
|
||||
}
|
||||
throw new Error('No quedan jugadores activos.');
|
||||
}
|
||||
|
||||
export function withdrawParchis(state, seat) {
|
||||
const next = structuredClone(state);
|
||||
next.withdrawn = [...(next.withdrawn ?? []), seat];
|
||||
next.pieces[seat] = [-1, -1, -1, -1];
|
||||
if (next.turn === seat) {
|
||||
next.turn = nextPlayingSeat(next, seat);
|
||||
next.die = null;
|
||||
next.bonus = false;
|
||||
next.extraTurn = false;
|
||||
next.sixes = 0;
|
||||
next.lastMoved = null;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import 'dotenv/config';
|
||||
import { createStore } from './store.mjs';
|
||||
import { createService } from './service.mjs';
|
||||
|
||||
const store = await createStore();
|
||||
const service = createService(store);
|
||||
const host = process.env.HOST || '127.0.0.1';
|
||||
const port = Number(process.env.PORT || 3001);
|
||||
service.server.listen(port, host, () =>
|
||||
console.log(
|
||||
`Juegoland · API y partidas en http://${host}:${port} · ${process.env.DATABASE_URL ? 'PostgreSQL' : 'almacenamiento local de desarrollo'}`,
|
||||
),
|
||||
);
|
||||
let stopping = false;
|
||||
async function stop() {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
await service.close();
|
||||
await store.close();
|
||||
}
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
@ -0,0 +1,504 @@
|
||||
import { randomUUID, randomBytes } from 'node:crypto';
|
||||
import { catalog, getGame } from './games/registry.mjs';
|
||||
import { canCloseInactive, roomDeadlines } from '../shared/room-lifecycle.mjs';
|
||||
import {
|
||||
canChooseColor,
|
||||
paletteForRoom,
|
||||
assignLastColor,
|
||||
chooseDefaultColor,
|
||||
} from '../shared/player-colors.mjs';
|
||||
|
||||
export class Platform {
|
||||
constructor(store) {
|
||||
this.store = store;
|
||||
}
|
||||
summary(room) {
|
||||
return {
|
||||
id: room.id,
|
||||
code: room.code,
|
||||
name: room.name,
|
||||
gameId: room.gameId,
|
||||
gameVersion: room.gameVersion,
|
||||
status: room.status,
|
||||
visibility: room.visibility,
|
||||
maxPlayers: room.maxPlayers,
|
||||
players: room.players.map((p) => ({
|
||||
userId: p.userId,
|
||||
nick: p.nick,
|
||||
seat: p.seat,
|
||||
ready: p.ready,
|
||||
outcome: p.outcome,
|
||||
placement: p.placement,
|
||||
colorId: p.colorId,
|
||||
avatar: p.avatar,
|
||||
withdrawnAt: p.withdrawnAt,
|
||||
})),
|
||||
createdAt: room.createdAt,
|
||||
startedAt: room.startedAt,
|
||||
lastGameActivityAt: room.lastGameActivityAt,
|
||||
deadlines: roomDeadlines(room),
|
||||
finishedAt: room.finishedAt,
|
||||
result: room.result,
|
||||
version: room.version,
|
||||
};
|
||||
}
|
||||
async list(userId) {
|
||||
return (await this.store.listRooms(userId))
|
||||
.filter(
|
||||
(r) =>
|
||||
r.status !== 'finished' &&
|
||||
!r.players.some((p) => p.userId === userId && p.withdrawnAt),
|
||||
)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.map((r) => this.summary(r));
|
||||
}
|
||||
async create(user, input) {
|
||||
const game = getGame(input.gameId);
|
||||
if (
|
||||
input.maxPlayers < game.minPlayers ||
|
||||
input.maxPlayers > game.maxPlayers
|
||||
)
|
||||
throw new Error('Número de jugadores no válido.');
|
||||
const rooms = await this.store.listRooms(user.id);
|
||||
if (
|
||||
rooms.filter((r) => r.createdBy === user.id && r.status === 'waiting')
|
||||
.length >= 5
|
||||
)
|
||||
throw new Error('Ya tienes cinco salas abiertas.');
|
||||
const now = new Date().toISOString();
|
||||
return this.store.createRoom({
|
||||
id: randomUUID(),
|
||||
code: randomBytes(6).toString('hex').toUpperCase(),
|
||||
name: input.name,
|
||||
gameId: game.id,
|
||||
gameVersion: game.version,
|
||||
maxPlayers: input.maxPlayers,
|
||||
visibility: input.visibility,
|
||||
createdBy: user.id,
|
||||
invitationMessage: input.invitationMessage ?? '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastGameActivityAt: now,
|
||||
status: 'waiting',
|
||||
version: 0,
|
||||
players: [
|
||||
{
|
||||
userId: user.id,
|
||||
nick: user.nick,
|
||||
seat: 0,
|
||||
ready: false,
|
||||
avatar: user.avatar,
|
||||
},
|
||||
],
|
||||
state: null,
|
||||
result: null,
|
||||
events: [],
|
||||
messages: [],
|
||||
actions: {},
|
||||
});
|
||||
}
|
||||
async join(user, id) {
|
||||
const found = await this.store.room(id);
|
||||
if (!found) throw new Error('No existe una sala con ese código.');
|
||||
return this.store.updateRoom(found.id, (room) => {
|
||||
if (room.players.some((p) => p.userId === user.id)) return;
|
||||
if (room.status !== 'waiting')
|
||||
throw new Error('La partida ya ha empezado.');
|
||||
if (room.players.length >= room.maxPlayers)
|
||||
throw new Error('La sala está completa.');
|
||||
room.players.push({
|
||||
userId: user.id,
|
||||
nick: user.nick,
|
||||
seat: room.players.length,
|
||||
ready: false,
|
||||
avatar: user.avatar,
|
||||
});
|
||||
if (canChooseColor(room)) assignLastColor(room);
|
||||
room.lastGameActivityAt = new Date().toISOString();
|
||||
room.version++;
|
||||
});
|
||||
}
|
||||
async invitation(code, userId) {
|
||||
const room = await this.store.room(code);
|
||||
if (!room) throw new Error('La invitación no existe.');
|
||||
return {
|
||||
code: room.code,
|
||||
name: room.name,
|
||||
definition: catalog.find((g) => g.id === room.gameId),
|
||||
host:
|
||||
room.players.find((p) => p.userId === room.createdBy)?.nick ??
|
||||
'Anfitrión',
|
||||
message: room.invitationMessage ?? '',
|
||||
available:
|
||||
room.status === 'waiting' && room.players.length < room.maxPlayers,
|
||||
isHost: room.createdBy === userId,
|
||||
alreadyJoined: room.players.some((p) => p.userId === userId),
|
||||
players: room.players.length,
|
||||
maxPlayers: room.maxPlayers,
|
||||
};
|
||||
}
|
||||
async personalizeInvite(user, id, message) {
|
||||
return this.store.updateRoom(id, (room) => {
|
||||
if (room.createdBy !== user.id)
|
||||
throw new Error('Solo el anfitrión puede modificar esta invitación.');
|
||||
room.invitationMessage = message;
|
||||
});
|
||||
}
|
||||
async ready(user, id, ready) {
|
||||
return this.store.updateRoom(id, (room) => {
|
||||
const player = room.players.find((p) => p.userId === user.id);
|
||||
if (!player || room.status !== 'waiting')
|
||||
throw new Error('No puedes cambiar tu estado en esta sala.');
|
||||
player.ready = ready;
|
||||
room.lastGameActivityAt = new Date().toISOString();
|
||||
if (canChooseColor(room)) {
|
||||
if (ready) chooseDefaultColor(room, player);
|
||||
assignLastColor(room);
|
||||
}
|
||||
room.version++;
|
||||
if (
|
||||
room.players.length === room.maxPlayers &&
|
||||
room.players.every((p) => p.ready)
|
||||
) {
|
||||
room.state = getGame(room.gameId, room.gameVersion).create(
|
||||
room.players.length,
|
||||
room.players.map((p) => p.colorId),
|
||||
);
|
||||
room.status = 'playing';
|
||||
room.startedAt = new Date().toISOString();
|
||||
}
|
||||
});
|
||||
}
|
||||
async chooseColor(user, id, colorId) {
|
||||
return this.store.updateRoom(id, (room) => {
|
||||
const player = room.players.find((p) => p.userId === user.id);
|
||||
if (!player || room.status !== 'waiting' || !canChooseColor(room))
|
||||
throw new Error(
|
||||
'Solo puedes elegir color en la sala de espera de este juego.',
|
||||
);
|
||||
if (player.seat === room.maxPlayers - 1)
|
||||
throw new Error('El último jugador recibe un color automáticamente.');
|
||||
if (!paletteForRoom(room).some((c) => c.id === colorId))
|
||||
throw new Error('Ese color no está disponible en este juego.');
|
||||
if (
|
||||
room.players.some(
|
||||
(p) =>
|
||||
p !== player &&
|
||||
p.seat !== room.maxPlayers - 1 &&
|
||||
p.colorId === colorId,
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
'Otro jugador acaba de elegir ese color. Elige uno de los restantes.',
|
||||
);
|
||||
player.colorId = colorId;
|
||||
player.ready = false;
|
||||
room.lastGameActivityAt = new Date().toISOString();
|
||||
assignLastColor(room);
|
||||
room.version++;
|
||||
});
|
||||
}
|
||||
finish(room, result) {
|
||||
room.status = 'finished';
|
||||
room.result = result;
|
||||
room.finishedAt = new Date().toISOString();
|
||||
for (const p of room.players) {
|
||||
p.outcome =
|
||||
result.winner === null
|
||||
? 'draw'
|
||||
: p.seat === result.winner
|
||||
? 'win'
|
||||
: 'loss';
|
||||
p.placement = p.withdrawnAt
|
||||
? (p.placement ?? 2)
|
||||
: result.winner === null || p.seat === result.winner
|
||||
? 1
|
||||
: 2;
|
||||
}
|
||||
}
|
||||
closeWithoutResult(room, reason, user = null) {
|
||||
room.status = 'finished';
|
||||
room.result = { winner: null, reason, cancelled: true };
|
||||
room.finishedAt = new Date().toISOString();
|
||||
room.version++;
|
||||
for (const player of room.players) {
|
||||
delete player.outcome;
|
||||
delete player.placement;
|
||||
}
|
||||
room.events.push({
|
||||
sequence: room.events.length + 1,
|
||||
actionId: randomUUID(),
|
||||
actorId: user?.id ?? null,
|
||||
nick: user?.nick ?? 'Juegoland',
|
||||
label: reason,
|
||||
type: 'close',
|
||||
createdAt: room.finishedAt,
|
||||
});
|
||||
}
|
||||
async leave(user, id, expectedVersion) {
|
||||
return this.store.updateRoom(id, (room) => {
|
||||
const player = room.players.find((p) => p.userId === user.id);
|
||||
if (!player) throw new Error('No participas en esta partida.');
|
||||
if (player.withdrawnAt || room.status === 'finished') return;
|
||||
if (room.version !== expectedVersion)
|
||||
throw new Error(
|
||||
'La partida ha cambiado. Revisa su estado antes de salir.',
|
||||
);
|
||||
if (room.status === 'waiting') {
|
||||
if (room.createdBy === user.id)
|
||||
this.closeWithoutResult(
|
||||
room,
|
||||
'Sala cancelada por el anfitrión',
|
||||
user,
|
||||
);
|
||||
else {
|
||||
room.players = room.players.filter((p) => p !== player);
|
||||
room.players.forEach((p, seat) => {
|
||||
p.seat = seat;
|
||||
p.ready = false;
|
||||
});
|
||||
if (canChooseColor(room)) assignLastColor(room);
|
||||
room.lastGameActivityAt = new Date().toISOString();
|
||||
room.version++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const remaining = room.players.filter(
|
||||
(p) => p !== player && !p.withdrawnAt,
|
||||
);
|
||||
player.withdrawnAt = new Date().toISOString();
|
||||
player.outcome = 'loss';
|
||||
player.placement = remaining.length + 1;
|
||||
if (remaining.length === 1)
|
||||
this.finish(room, {
|
||||
winner: remaining[0].seat,
|
||||
reason: 'rendición de los demás jugadores',
|
||||
});
|
||||
else
|
||||
room.state = getGame(room.gameId, room.gameVersion).withdraw(
|
||||
room.state,
|
||||
player.seat,
|
||||
);
|
||||
room.version++;
|
||||
room.lastGameActivityAt = player.withdrawnAt;
|
||||
room.events.push({
|
||||
sequence: room.events.length + 1,
|
||||
actionId: randomUUID(),
|
||||
actorId: user.id,
|
||||
nick: user.nick,
|
||||
label: 'Abandona la partida',
|
||||
type: 'leave',
|
||||
createdAt: player.withdrawnAt,
|
||||
});
|
||||
});
|
||||
}
|
||||
async closeInactive(user, id, expectedVersion, now = Date.now()) {
|
||||
return this.store.updateRoom(id, (room) => {
|
||||
const player = room.players.find((p) => p.userId === user.id);
|
||||
if (!player || player.withdrawnAt)
|
||||
throw new Error('No participas en esta partida.');
|
||||
if (room.version !== expectedVersion)
|
||||
throw new Error(
|
||||
'El otro jugador ha actuado. Revisa la partida antes de cerrarla.',
|
||||
);
|
||||
if (!canCloseInactive(room, player.seat, now))
|
||||
throw new Error(
|
||||
'Puedes cerrar sin resultado cuando esperes a otro jugador durante 10 minutos sin jugadas.',
|
||||
);
|
||||
this.closeWithoutResult(room, 'Cerrada por inactividad', user);
|
||||
});
|
||||
}
|
||||
async expireInactiveRooms(now = Date.now()) {
|
||||
const closed = [];
|
||||
for (const snapshot of await this.store.activeRooms()) {
|
||||
if (roomDeadlines(snapshot).autoCloseAt > now) continue;
|
||||
let expired = false;
|
||||
await this.store.updateRoom(snapshot.id, (room) => {
|
||||
if (
|
||||
room.status !== 'finished' &&
|
||||
roomDeadlines(room).autoCloseAt <= now
|
||||
) {
|
||||
this.closeWithoutResult(
|
||||
room,
|
||||
'Cierre automático tras 24 horas sin actividad',
|
||||
);
|
||||
expired = true;
|
||||
}
|
||||
});
|
||||
if (expired) closed.push(snapshot.id);
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
async action(user, input) {
|
||||
return this.store.updateRoom(input.roomId, (room) => {
|
||||
const player = room.players.find((p) => p.userId === user.id);
|
||||
if (!player) throw new Error('No participas en esta partida.');
|
||||
if (player.withdrawnAt) throw new Error('Has abandonado esta partida.');
|
||||
const duplicate = room.actions[input.actionId];
|
||||
if (duplicate) {
|
||||
if (
|
||||
duplicate.userId !== user.id ||
|
||||
duplicate.action !== JSON.stringify(input.action)
|
||||
)
|
||||
throw new Error('El identificador de acción ya se ha utilizado.');
|
||||
return;
|
||||
}
|
||||
if (room.status !== 'playing')
|
||||
throw new Error('La partida no está en curso.');
|
||||
if (room.version !== input.expectedVersion)
|
||||
throw new Error(
|
||||
'El tablero se ha actualizado. Revisa la posición y vuelve a intentarlo.',
|
||||
);
|
||||
let applied;
|
||||
if (input.action.type === 'resign') {
|
||||
if (room.players.length > 2)
|
||||
throw new Error(
|
||||
'La rendición está disponible en partidas de dos jugadores.',
|
||||
);
|
||||
applied = {
|
||||
state: room.state,
|
||||
label: 'Se rinde',
|
||||
result: { winner: 1 - player.seat, reason: 'rendición' },
|
||||
};
|
||||
} else
|
||||
applied = getGame(room.gameId, room.gameVersion).apply(
|
||||
room.state,
|
||||
input.action,
|
||||
player.seat,
|
||||
);
|
||||
room.state = applied.state;
|
||||
room.lastGameActivityAt = new Date().toISOString();
|
||||
room.version++;
|
||||
room.actions[input.actionId] = {
|
||||
userId: user.id,
|
||||
action: JSON.stringify(input.action),
|
||||
};
|
||||
room.events.push({
|
||||
sequence: room.events.length + 1,
|
||||
actionId: input.actionId,
|
||||
actorId: user.id,
|
||||
nick: user.nick,
|
||||
label: applied.label,
|
||||
sound: applied.result ? 'finish' : (applied.sound ?? input.action.type),
|
||||
type: input.action.type,
|
||||
animation:
|
||||
applied.animation ??
|
||||
(applied.result ? 'finish' : (applied.sound ?? input.action.type)),
|
||||
action: input.action,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
if (applied.result) this.finish(room, applied.result);
|
||||
});
|
||||
}
|
||||
async message(user, id, text) {
|
||||
return this.store.updateRoom(id, (room) => {
|
||||
if (!room.players.some((p) => p.userId === user.id && !p.withdrawnAt))
|
||||
throw new Error('No participas en esta sala.');
|
||||
if (room.messages.length >= 1000)
|
||||
throw new Error(
|
||||
'Se ha alcanzado el límite de mensajes de esta partida.',
|
||||
);
|
||||
room.messages.push({
|
||||
id: randomUUID(),
|
||||
userId: user.id,
|
||||
nick: user.nick,
|
||||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
});
|
||||
}
|
||||
async view(id, userId) {
|
||||
const room = await this.store.room(id);
|
||||
if (!room || !room.players.some((p) => p.userId === userId))
|
||||
throw new Error('No tienes acceso a esta sala.');
|
||||
const player = room.players.find((p) => p.userId === userId);
|
||||
const game = getGame(room.gameId, room.gameVersion);
|
||||
return {
|
||||
...this.summary(room),
|
||||
createdBy: room.createdBy,
|
||||
invitationMessage:
|
||||
room.createdBy === userId ? (room.invitationMessage ?? '') : undefined,
|
||||
seat: player.seat,
|
||||
state: room.state ? game.view(room.state, player.seat) : null,
|
||||
legalActions:
|
||||
room.status === 'playing' && !player.withdrawnAt
|
||||
? game.legal(room.state, player.seat)
|
||||
: [],
|
||||
events: room.events.map((event) => ({
|
||||
sequence: event.sequence,
|
||||
actionId: event.actionId,
|
||||
actorId: event.actorId,
|
||||
nick: event.nick,
|
||||
label: event.label,
|
||||
sound: event.sound,
|
||||
type: event.type ?? event.action?.type,
|
||||
animation: event.animation ?? event.sound ?? event.action?.type,
|
||||
createdAt: event.createdAt,
|
||||
})),
|
||||
messages: room.messages,
|
||||
definition:
|
||||
room.gameId === 'parchis' && room.gameVersion === '1.0.0'
|
||||
? {
|
||||
...catalog.find((g) => g.id === 'parchis'),
|
||||
version: '1.0.0',
|
||||
variant: 'Partida antigua · reglas básicas',
|
||||
rules: [
|
||||
'Esta partida comenzó con el motor anterior y conserva su recorrido y reglas para que puedas terminarla. Las partidas nuevas utilizan la versión revisada.',
|
||||
'Sale una ficha con 5. El 6 cuenta seis y permite repetir, sin penalización por tres seises ni obligación de abrir barrera.',
|
||||
'Dos fichas del mismo color bloquean el paso. Capturar concede 20 pasos y llegar a meta, 10. Los premios imposibles se descartan automáticamente.',
|
||||
'Gana quien lleva sus cuatro fichas a meta con avance exacto.',
|
||||
],
|
||||
}
|
||||
: catalog.find((g) => g.id === room.gameId),
|
||||
};
|
||||
}
|
||||
async profile(userId) {
|
||||
const [rooms, active] = await Promise.all([
|
||||
this.store.history(userId),
|
||||
this.store.listRooms(userId),
|
||||
]);
|
||||
return {
|
||||
pending: active
|
||||
.filter(
|
||||
(r) =>
|
||||
r.status !== 'finished' &&
|
||||
r.players.some((p) => p.userId === userId && !p.withdrawnAt),
|
||||
)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.map((r) => ({
|
||||
...this.summary(r),
|
||||
myTurn:
|
||||
r.status === 'playing' &&
|
||||
(r.state?.phase === 'scoring' ||
|
||||
r.state?.turn ===
|
||||
r.players.find((p) => p.userId === userId).seat),
|
||||
})),
|
||||
history: rooms
|
||||
.sort((a, b) =>
|
||||
(
|
||||
b.finishedAt ??
|
||||
b.players.find((p) => p.userId === userId).withdrawnAt
|
||||
).localeCompare(
|
||||
a.finishedAt ??
|
||||
a.players.find((p) => p.userId === userId).withdrawnAt,
|
||||
),
|
||||
)
|
||||
.map((r) => ({
|
||||
...this.summary(r),
|
||||
myResult: r.players.find((p) => p.userId === userId),
|
||||
})),
|
||||
stats: catalog.map((game) => {
|
||||
const results = rooms
|
||||
.filter((r) => r.gameId === game.id && !r.result?.cancelled)
|
||||
.map((r) => r.players.find((p) => p.userId === userId));
|
||||
return {
|
||||
gameId: game.id,
|
||||
played: results.length,
|
||||
wins: results.filter((p) => p.outcome === 'win').length,
|
||||
losses: results.filter((p) => p.outcome === 'loss').length,
|
||||
draws: results.filter((p) => p.outcome === 'draw').length,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
import 'dotenv/config';
|
||||
import { handler } from '../build/handler.js';
|
||||
import { createStore } from './store.mjs';
|
||||
import { createService } from './service.mjs';
|
||||
|
||||
const store = await createStore();
|
||||
const service = createService(store, process.env, handler);
|
||||
const host = process.env.HOST || '0.0.0.0';
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
service.server.listen(port, host, () =>
|
||||
console.log(`Juegoland · web, API y WebSockets en ${host}:${port}`),
|
||||
);
|
||||
let stopping = false;
|
||||
async function stop() {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
await service.close();
|
||||
await store.close();
|
||||
}
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
@ -0,0 +1,334 @@
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const empty = () => ({ users: [], sessions: [], rooms: [] });
|
||||
|
||||
export class FileStore {
|
||||
constructor(path) {
|
||||
this.path = resolve(path);
|
||||
this.queue = Promise.resolve();
|
||||
}
|
||||
async init() {
|
||||
await mkdir(dirname(this.path), { recursive: true });
|
||||
try {
|
||||
this.data = JSON.parse(await readFile(this.path, 'utf8'));
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
this.data = empty();
|
||||
await this.persist(this.data);
|
||||
}
|
||||
}
|
||||
async persist(data) {
|
||||
const temp = `${this.path}.tmp`;
|
||||
await writeFile(temp, JSON.stringify(data), { mode: 0o600 });
|
||||
await rename(temp, this.path);
|
||||
}
|
||||
async mutate(fn) {
|
||||
const operation = this.queue.then(async () => {
|
||||
const draft = structuredClone(this.data);
|
||||
const result = await fn(draft);
|
||||
await this.persist(draft);
|
||||
this.data = draft;
|
||||
return structuredClone(result);
|
||||
});
|
||||
this.queue = operation.catch(() => {});
|
||||
return operation;
|
||||
}
|
||||
async userByEmail(email) {
|
||||
return structuredClone(
|
||||
this.data.users.find((u) => u.email === email) ?? null,
|
||||
);
|
||||
}
|
||||
async userById(id) {
|
||||
return structuredClone(this.data.users.find((u) => u.id === id) ?? null);
|
||||
}
|
||||
async createUser(user) {
|
||||
return this.mutate((d) => {
|
||||
if (
|
||||
d.users.some(
|
||||
(u) =>
|
||||
(user.email && u.email === user.email) ||
|
||||
u.nick.toLowerCase() === user.nick.toLowerCase(),
|
||||
)
|
||||
)
|
||||
throw new Error('El correo o nick ya están registrados.');
|
||||
d.users.push(user);
|
||||
return user;
|
||||
});
|
||||
}
|
||||
async createSession(session) {
|
||||
return this.mutate((d) => {
|
||||
d.sessions = d.sessions.filter((s) => s.expiresAt > Date.now());
|
||||
d.sessions.push(session);
|
||||
return session;
|
||||
});
|
||||
}
|
||||
async updateUser(id, fn) {
|
||||
return this.mutate(async (d) => {
|
||||
const user = d.users.find((u) => u.id === id);
|
||||
if (!user) throw new Error('Cuenta no encontrada.');
|
||||
await fn(user);
|
||||
if (
|
||||
d.users.some(
|
||||
(u) =>
|
||||
u.id !== id &&
|
||||
((user.email && u.email === user.email) ||
|
||||
u.nick.toLowerCase() === user.nick.toLowerCase()),
|
||||
)
|
||||
)
|
||||
throw new Error('El correo o nick ya están registrados.');
|
||||
return user;
|
||||
});
|
||||
}
|
||||
async deleteUserSessions(id) {
|
||||
return this.mutate((d) => {
|
||||
d.sessions = d.sessions.filter((s) => s.userId !== id);
|
||||
});
|
||||
}
|
||||
async session(hash) {
|
||||
return structuredClone(
|
||||
this.data.sessions.find(
|
||||
(s) => s.hash === hash && s.expiresAt > Date.now(),
|
||||
) ?? null,
|
||||
);
|
||||
}
|
||||
async deleteSession(hash) {
|
||||
return this.mutate((d) => {
|
||||
d.sessions = d.sessions.filter((s) => s.hash !== hash);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
async listRooms(userId) {
|
||||
return structuredClone(
|
||||
this.data.rooms.filter(
|
||||
(r) =>
|
||||
r.visibility === 'public' ||
|
||||
r.players.some((p) => p.userId === userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
async room(id) {
|
||||
return structuredClone(
|
||||
this.data.rooms.find((r) => r.id === id || r.code === id.toUpperCase()) ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
async activeRooms() {
|
||||
return structuredClone(
|
||||
this.data.rooms.filter((r) => r.status !== 'finished'),
|
||||
);
|
||||
}
|
||||
async createRoom(room) {
|
||||
return this.mutate((d) => {
|
||||
d.rooms.push(room);
|
||||
return room;
|
||||
});
|
||||
}
|
||||
async updateRoom(id, fn) {
|
||||
return this.mutate(async (d) => {
|
||||
const room = d.rooms.find((r) => r.id === id);
|
||||
if (!room) throw new Error('Sala no encontrada.');
|
||||
await fn(room);
|
||||
return room;
|
||||
});
|
||||
}
|
||||
async history(userId) {
|
||||
return structuredClone(
|
||||
this.data.rooms.filter((r) =>
|
||||
r.players.some(
|
||||
(p) =>
|
||||
p.userId === userId && (r.status === 'finished' || p.withdrawnAt),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
async close() {
|
||||
await this.queue;
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresStore {
|
||||
constructor(url) {
|
||||
this.pool = new Pool({ connectionString: url, max: 10 });
|
||||
}
|
||||
async init() {
|
||||
await this.pool.query('SELECT id FROM rooms LIMIT 1');
|
||||
}
|
||||
async userByEmail(email) {
|
||||
const r = await this.pool.query('SELECT data FROM users WHERE email=$1', [
|
||||
email,
|
||||
]);
|
||||
return r.rows[0]?.data ?? null;
|
||||
}
|
||||
async userById(id) {
|
||||
const r = await this.pool.query('SELECT data FROM users WHERE id=$1', [id]);
|
||||
return r.rows[0]?.data ?? null;
|
||||
}
|
||||
async createUser(user) {
|
||||
try {
|
||||
await this.pool.query(
|
||||
'INSERT INTO users(id,email,nick,data) VALUES($1,$2,$3,$4)',
|
||||
[user.id, user.email, user.nick.toLowerCase(), user],
|
||||
);
|
||||
return user;
|
||||
} catch (e) {
|
||||
if (e.code === '23505')
|
||||
throw new Error('El correo o nick ya están registrados.');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
async createSession(session) {
|
||||
await this.pool.query(
|
||||
'INSERT INTO sessions(hash,user_id,expires_at) VALUES($1,$2,$3)',
|
||||
[session.hash, session.userId, new Date(session.expiresAt)],
|
||||
);
|
||||
return session;
|
||||
}
|
||||
async updateUser(id, fn) {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await client.query(
|
||||
'SELECT data FROM users WHERE id=$1 FOR UPDATE',
|
||||
[id],
|
||||
);
|
||||
const user = result.rows[0]?.data;
|
||||
if (!user) throw new Error('Cuenta no encontrada.');
|
||||
await fn(user);
|
||||
await client.query(
|
||||
'UPDATE users SET email=$2,nick=$3,data=$4 WHERE id=$1',
|
||||
[id, user.email, user.nick.toLowerCase(), user],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
return user;
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
if (e.code === '23505')
|
||||
throw new Error('El correo o nick ya están registrados.');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
async deleteUserSessions(id) {
|
||||
await this.pool.query('DELETE FROM sessions WHERE user_id=$1', [id]);
|
||||
}
|
||||
async session(hash) {
|
||||
const r = await this.pool.query(
|
||||
'SELECT hash,user_id AS "userId",expires_at FROM sessions WHERE hash=$1 AND expires_at>now()',
|
||||
[hash],
|
||||
);
|
||||
return r.rows[0]
|
||||
? { ...r.rows[0], expiresAt: +r.rows[0].expires_at }
|
||||
: null;
|
||||
}
|
||||
async deleteSession(hash) {
|
||||
await this.pool.query('DELETE FROM sessions WHERE hash=$1', [hash]);
|
||||
}
|
||||
async listRooms(userId) {
|
||||
const r = await this.pool.query(
|
||||
"SELECT data FROM rooms WHERE visibility='public' OR data->'players' @> $1::jsonb ORDER BY created_at DESC",
|
||||
[JSON.stringify([{ userId }])],
|
||||
);
|
||||
return r.rows.map((r) => r.data);
|
||||
}
|
||||
async activeRooms() {
|
||||
const r = await this.pool.query(
|
||||
"SELECT data FROM rooms WHERE status IN ('waiting','playing')",
|
||||
);
|
||||
return r.rows.map((r) => r.data);
|
||||
}
|
||||
async room(id) {
|
||||
const r = await this.pool.query(
|
||||
'SELECT data FROM rooms WHERE id::text=$1 OR code=$2',
|
||||
[id, id.toUpperCase()],
|
||||
);
|
||||
return r.rows[0]?.data ?? null;
|
||||
}
|
||||
async createRoom(room) {
|
||||
await this.pool.query(
|
||||
'INSERT INTO rooms(id,code,game_id,game_version,visibility,status,created_at,data) VALUES($1,$2,$3,$4,$5,$6,$7,$8)',
|
||||
[
|
||||
room.id,
|
||||
room.code,
|
||||
room.gameId,
|
||||
room.gameVersion,
|
||||
room.visibility,
|
||||
room.status,
|
||||
room.createdAt,
|
||||
room,
|
||||
],
|
||||
);
|
||||
return room;
|
||||
}
|
||||
async updateRoom(id, fn) {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const row = await client.query(
|
||||
'SELECT data FROM rooms WHERE id=$1 FOR UPDATE',
|
||||
[id],
|
||||
);
|
||||
if (!row.rows[0]) throw new Error('Sala no encontrada.');
|
||||
const room = row.rows[0].data;
|
||||
const eventCount = room.events.length,
|
||||
messageCount = room.messages.length;
|
||||
await fn(room);
|
||||
await client.query('UPDATE rooms SET status=$2,data=$3 WHERE id=$1', [
|
||||
id,
|
||||
room.status,
|
||||
room,
|
||||
]);
|
||||
for (const event of room.events.slice(eventCount))
|
||||
await client.query(
|
||||
'INSERT INTO match_events(room_id,sequence,action_id,actor_id,data) VALUES($1,$2,$3,$4,$5)',
|
||||
[id, event.sequence, event.actionId, event.actorId, event],
|
||||
);
|
||||
for (const msg of room.messages.slice(messageCount))
|
||||
await client.query(
|
||||
'INSERT INTO chat_messages(id,room_id,user_id,data) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING',
|
||||
[msg.id, id, msg.userId, msg],
|
||||
);
|
||||
if (room.result && !room.result.cancelled)
|
||||
for (const player of room.players)
|
||||
await client.query(
|
||||
'INSERT INTO match_results(room_id,user_id,outcome,placement,reason) VALUES($1,$2,$3,$4,$5) ON CONFLICT(room_id,user_id) DO NOTHING',
|
||||
[
|
||||
id,
|
||||
player.userId,
|
||||
player.outcome,
|
||||
player.placement,
|
||||
room.result.reason,
|
||||
],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
return room;
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
async history(userId) {
|
||||
const r = await this.pool.query(
|
||||
"SELECT data FROM rooms WHERE EXISTS (SELECT 1 FROM jsonb_array_elements(data->'players') p WHERE p->>'userId'=$1 AND (status='finished' OR p->>'withdrawnAt' IS NOT NULL)) ORDER BY created_at DESC",
|
||||
[userId],
|
||||
);
|
||||
return r.rows.map((r) => r.data);
|
||||
}
|
||||
async close() {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function createStore(env = process.env) {
|
||||
if (env.NODE_ENV === 'production' && !env.DATABASE_URL)
|
||||
throw new Error('Producción requiere DATABASE_URL.');
|
||||
const store = env.DATABASE_URL
|
||||
? new PostgresStore(env.DATABASE_URL)
|
||||
: new FileStore(env.DATA_FILE || '.data/development.json');
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { terms } from '../shared/terms.mjs';
|
||||
|
||||
export const TERMS_COOKIE = 'juegoland_terms';
|
||||
export const TERMS_MAX_AGE = 365 * 24 * 60 * 60;
|
||||
const signature = (payload, secret) =>
|
||||
createHmac('sha256', secret)
|
||||
.update(`juegoland-terms:${payload}`)
|
||||
.digest('hex');
|
||||
|
||||
export function termsCookie(secret, production, now = Date.now()) {
|
||||
const payload = `${terms.version}.${Math.floor(now / 1000)}`;
|
||||
return `${TERMS_COOKIE}=${payload}.${signature(payload, secret)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${TERMS_MAX_AGE}${production ? '; Secure' : ''}`;
|
||||
}
|
||||
export function clearTermsCookie(production) {
|
||||
return `${TERMS_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${production ? '; Secure' : ''}`;
|
||||
}
|
||||
export function termsAcceptance(header = '', secret, now = Date.now()) {
|
||||
const rejected = {
|
||||
accepted: false,
|
||||
version: terms.version,
|
||||
acceptedAt: null,
|
||||
expiresAt: null,
|
||||
};
|
||||
const value = header
|
||||
.split(';')
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`${TERMS_COOKIE}=`))
|
||||
?.slice(TERMS_COOKIE.length + 1);
|
||||
const match = value?.match(
|
||||
/^(\d{4}-\d{2}-\d{2}\.\d+)\.(\d{10})\.([a-f0-9]{64})$/,
|
||||
);
|
||||
if (!match || match[1] !== terms.version) return rejected;
|
||||
const issuedAt = Number(match[2]) * 1000;
|
||||
if (issuedAt > now || now >= issuedAt + TERMS_MAX_AGE * 1000) return rejected;
|
||||
const expected = Buffer.from(
|
||||
signature(`${match[1]}.${match[2]}`, secret),
|
||||
'hex',
|
||||
);
|
||||
if (!timingSafeEqual(expected, Buffer.from(match[3], 'hex'))) return rejected;
|
||||
return {
|
||||
accepted: true,
|
||||
version: terms.version,
|
||||
acceptedAt: new Date(issuedAt).toISOString(),
|
||||
expiresAt: new Date(issuedAt + TERMS_MAX_AGE * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
const animation = (kind, duration = 350) => ({
|
||||
kind,
|
||||
duration,
|
||||
easing: 'ease-out',
|
||||
});
|
||||
const finish = {
|
||||
start: animation('reveal', 500),
|
||||
win: animation('celebrate', 900),
|
||||
loss: animation('fade', 400),
|
||||
draw: animation('reveal', 400),
|
||||
finish: animation('celebrate', 900),
|
||||
};
|
||||
export const gameAnimations = {
|
||||
ajedrez: {
|
||||
...finish,
|
||||
move: animation('slide', 300),
|
||||
capture: animation('capture', 350),
|
||||
check: animation('pulse', 650),
|
||||
promotion: animation('reveal', 450),
|
||||
},
|
||||
damas: {
|
||||
...finish,
|
||||
move: animation('slide', 320),
|
||||
capture: animation('jump', 400),
|
||||
promotion: animation('reveal', 450),
|
||||
},
|
||||
parchis: {
|
||||
...finish,
|
||||
roll: animation('cup', 800),
|
||||
shake: animation('shake', 180),
|
||||
move: animation('path', 600),
|
||||
capture: animation('capture', 600),
|
||||
goal: animation('celebrate', 700),
|
||||
pass: animation('fade', 250),
|
||||
},
|
||||
go: {
|
||||
...finish,
|
||||
move: animation('place', 240),
|
||||
capture: animation('capture', 400),
|
||||
pass: animation('fade', 200),
|
||||
mark: animation('pulse', 300),
|
||||
'accept-score': animation('reveal', 300),
|
||||
},
|
||||
oca: {
|
||||
...finish,
|
||||
roll: animation('cup', 800),
|
||||
shake: animation('shake', 180),
|
||||
move: animation('path', 700),
|
||||
bonus: animation('jump', 650),
|
||||
penalty: animation('shake', 450),
|
||||
goal: animation('celebrate', 700),
|
||||
},
|
||||
monopoli: {
|
||||
...finish,
|
||||
roll: animation('cup', 800),
|
||||
move: animation('path', 650),
|
||||
buy: animation('reveal', 400),
|
||||
payment: animation('transfer', 450),
|
||||
build: animation('place', 300),
|
||||
},
|
||||
cluedo: {
|
||||
...finish,
|
||||
roll: animation('cup', 800),
|
||||
move: animation('slide', 500),
|
||||
suggestion: animation('pulse', 500),
|
||||
reveal: animation('flip', 450),
|
||||
accusation: animation('reveal', 650),
|
||||
},
|
||||
risk: {
|
||||
...finish,
|
||||
reinforce: animation('place', 250),
|
||||
attack: animation('shake', 500),
|
||||
conquest: animation('reveal', 650),
|
||||
move: animation('transfer', 450),
|
||||
},
|
||||
tute: {
|
||||
...finish,
|
||||
play: animation('flip', 350),
|
||||
trick: animation('transfer', 500),
|
||||
sing: animation('reveal', 600),
|
||||
},
|
||||
brisca: {
|
||||
...finish,
|
||||
play: animation('flip', 350),
|
||||
trick: animation('transfer', 500),
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
export const avatarPresets = [
|
||||
{ id: 'caballo', name: 'Caballo', src: '/avatars/caballo.svg' },
|
||||
{ id: 'dado', name: 'Dado', src: '/avatars/dado.svg' },
|
||||
{ id: 'oca', name: 'Oca', src: '/avatars/oca.svg' },
|
||||
{ id: 'gato', name: 'Gato', src: '/avatars/gato.svg' },
|
||||
{ id: 'zorro', name: 'Zorro', src: '/avatars/zorro.svg' },
|
||||
{ id: 'robot', name: 'Robot', src: '/avatars/robot.svg' },
|
||||
{ id: 'estrella', name: 'Estrella', src: '/avatars/estrella.svg' },
|
||||
{ id: 'torre', name: 'Torre', src: '/avatars/torre.svg' },
|
||||
];
|
||||
@ -0,0 +1,9 @@
|
||||
export const brand = {
|
||||
name: 'Juegoland',
|
||||
invitationOrigin: 'https://juegoland.imaginacion.com',
|
||||
logo: '/branding/juegoland-logo-transparent.png',
|
||||
tagline: 'Juegos para compartir',
|
||||
hero: 'La próxima ronda empieza contigo.',
|
||||
introduction: 'Elige un juego, invita a tu gente y disfruta del rato juntos.',
|
||||
welcome: 'Siempre hay sitio para uno más.',
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
import { brand } from './brand.mjs';
|
||||
|
||||
/** Use the public HTTPS address in production and the current origin for local previews. */
|
||||
export function invitationUrl(code, currentOrigin, production) {
|
||||
const url = new URL('/', production ? brand.invitationOrigin : currentOrigin);
|
||||
url.searchParams.set('invite', code);
|
||||
return url.href;
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
export const playerColors = [
|
||||
{ id: 'amarillo', label: 'Amarillo', hex: '#b88925' },
|
||||
{ id: 'azul', label: 'Azul', hex: '#4e84a6' },
|
||||
{ id: 'rojo', label: 'Rojo', hex: '#b95f56' },
|
||||
{ id: 'verde', label: 'Verde', hex: '#568264' },
|
||||
{ id: 'negro', label: 'Negro', hex: '#30343b' },
|
||||
{ id: 'naranja', label: 'Naranja', hex: '#c96b24' },
|
||||
{ id: 'violeta', label: 'Violeta', hex: '#8654b8' },
|
||||
{ id: 'gris', label: 'Gris', hex: '#7d8794' },
|
||||
];
|
||||
export const parchisColorOrder = playerColors.slice(0, 4).map((c) => c.id);
|
||||
export const canChooseColor = (room) =>
|
||||
room.gameId === 'oca' ||
|
||||
(room.gameId === 'parchis' && room.gameVersion !== '1.0.0');
|
||||
export const paletteForRoom = (room) =>
|
||||
playerColors.slice(0, room.maxPlayers > 4 ? 8 : 4);
|
||||
export const colorForPlayer = (player) =>
|
||||
playerColors.find((c) => c.id === player?.colorId) ??
|
||||
playerColors[player?.seat ?? 0] ??
|
||||
playerColors[0];
|
||||
export const playerColorClass = (player) =>
|
||||
`player-color-${playerColors.indexOf(colorForPlayer(player))}`;
|
||||
export function assignLastColor(room) {
|
||||
const last = room.players.find((p) => p.seat === room.maxPlayers - 1);
|
||||
if (!last) return;
|
||||
const others = room.players.filter((p) => p !== last);
|
||||
if (others.length === room.maxPlayers - 1 && others.every((p) => p.colorId))
|
||||
last.colorId = paletteForRoom(room).find(
|
||||
(c) => !others.some((p) => p.colorId === c.id),
|
||||
).id;
|
||||
else delete last.colorId;
|
||||
}
|
||||
export function chooseDefaultColor(room, player) {
|
||||
if (player.colorId || player.seat === room.maxPlayers - 1) return;
|
||||
const free = paletteForRoom(room).filter(
|
||||
(c) =>
|
||||
!room.players.some(
|
||||
(p) =>
|
||||
p !== player && p.seat !== room.maxPlayers - 1 && p.colorId === c.id,
|
||||
),
|
||||
);
|
||||
player.colorId = (
|
||||
free.find((c) => c.id === playerColors[player.seat]?.id) ?? free[0]
|
||||
).id;
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
export const inactivityPolicy = {
|
||||
closeAfter: 10 * 60_000,
|
||||
expireAfter: 24 * 60 * 60_000,
|
||||
};
|
||||
|
||||
export function roomActivityAt(room) {
|
||||
return Date.parse(
|
||||
room.lastGameActivityAt ??
|
||||
room.events?.at(-1)?.createdAt ??
|
||||
room.startedAt ??
|
||||
room.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
export function roomDeadlines(room) {
|
||||
const activity = roomActivityAt(room);
|
||||
return {
|
||||
inactiveCloseAt: activity + inactivityPolicy.closeAfter,
|
||||
autoCloseAt: activity + inactivityPolicy.expireAfter,
|
||||
};
|
||||
}
|
||||
|
||||
export function canCloseInactive(room, seat, now = Date.now()) {
|
||||
if (
|
||||
room.status !== 'playing' ||
|
||||
!room.players[seat] ||
|
||||
room.players[seat].withdrawnAt
|
||||
)
|
||||
return false;
|
||||
const waiting =
|
||||
room.state?.phase === 'scoring'
|
||||
? room.state.accepted?.[seat] &&
|
||||
room.state.accepted.some((accepted, i) => i !== seat && !accepted)
|
||||
: room.state?.turn !== seat;
|
||||
return Boolean(waiting && now >= roomDeadlines(room).inactiveCloseAt);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
// Only a new, recent, authoritative transition can trigger an effect.
|
||||
// Initial loads, duplicate acknowledgements and old replayed events stay silent.
|
||||
export function roomSound(previous, next, now = Date.now()) {
|
||||
if (!previous || previous.id !== next.id) return null;
|
||||
if (next.result?.cancelled || next.events?.at(-1)?.type === 'leave')
|
||||
return null;
|
||||
if (previous.status === 'waiting' && next.status === 'playing')
|
||||
return 'start';
|
||||
const last = next.events?.at(-1);
|
||||
if (!last || last.sequence <= (previous.events?.at(-1)?.sequence ?? 0))
|
||||
return null;
|
||||
const age = now - Date.parse(last.createdAt);
|
||||
if (age < -5000 || age > 5000) return null;
|
||||
if (next.status === 'finished' && previous.status !== 'finished') {
|
||||
const outcome = next.players.find((p) => p.seat === next.seat)?.outcome;
|
||||
return outcome === 'win' ? 'win' : outcome === 'loss' ? 'loss' : 'draw';
|
||||
}
|
||||
return last.sound ?? 'move';
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
// A game's manifest maps semantic event IDs to local audio assets.
|
||||
// Rules return the event ID; clients decide when and at what volume to play it.
|
||||
const effect = (name, volume = 0.8) => ({
|
||||
src: `/sounds/v2/${name}.wav`,
|
||||
volume,
|
||||
});
|
||||
const endings = {
|
||||
start: effect('start', 0.65),
|
||||
win: effect('win', 0.65),
|
||||
loss: effect('loss', 0.55),
|
||||
draw: effect('draw', 0.55),
|
||||
finish: effect('draw', 0.55),
|
||||
};
|
||||
export const gameSounds = {
|
||||
ajedrez: {
|
||||
...endings,
|
||||
move: effect('chess-move'),
|
||||
capture: effect('chess-capture'),
|
||||
check: effect('check', 0.6),
|
||||
promotion: effect('promotion', 0.65),
|
||||
},
|
||||
damas: {
|
||||
...endings,
|
||||
move: effect('checkers-move'),
|
||||
capture: effect('checkers-capture'),
|
||||
promotion: effect('promotion', 0.65),
|
||||
},
|
||||
parchis: {
|
||||
...endings,
|
||||
roll: { ...effect('dice-roll'), announceNumber: true, language: 'es-ES' },
|
||||
shake: effect('cup-shake', 0.6),
|
||||
move: effect('parchis-move'),
|
||||
capture: effect('parchis-capture'),
|
||||
goal: effect('goal', 0.65),
|
||||
pass: effect('turn', 0.45),
|
||||
},
|
||||
go: {
|
||||
...endings,
|
||||
move: effect('go-place'),
|
||||
capture: effect('go-capture'),
|
||||
pass: effect('turn', 0.45),
|
||||
},
|
||||
oca: {
|
||||
...endings,
|
||||
roll: { ...effect('dice-roll'), announceNumber: true, language: 'es-ES' },
|
||||
shake: effect('cup-shake', 0.6),
|
||||
move: effect('oca-move'),
|
||||
goal: effect('goal', 0.65),
|
||||
bonus: effect('oca-bonus', 0.65),
|
||||
penalty: effect('oca-penalty', 0.6),
|
||||
},
|
||||
monopoli: {
|
||||
...endings,
|
||||
roll: effect('dice-roll'),
|
||||
move: effect('parchis-move'),
|
||||
buy: effect('coin-buy'),
|
||||
payment: effect('coin-payment'),
|
||||
build: effect('build'),
|
||||
},
|
||||
cluedo: {
|
||||
...endings,
|
||||
roll: effect('dice-roll'),
|
||||
move: effect('parchis-move'),
|
||||
suggestion: effect('mystery-suggest', 0.5),
|
||||
reveal: effect('card-play'),
|
||||
accusation: effect('mystery-accuse', 0.6),
|
||||
},
|
||||
risk: {
|
||||
...endings,
|
||||
reinforce: effect('risk-reinforce'),
|
||||
attack: effect('risk-attack'),
|
||||
conquest: effect('promotion', 0.65),
|
||||
move: effect('risk-reinforce'),
|
||||
},
|
||||
tute: {
|
||||
...endings,
|
||||
play: effect('tute-card'),
|
||||
trick: effect('card-collect'),
|
||||
sing: effect('promotion', 0.65),
|
||||
},
|
||||
brisca: {
|
||||
...endings,
|
||||
play: effect('brisca-card'),
|
||||
trick: effect('card-collect'),
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
export function termsReturnPath(value, origin) {
|
||||
if (typeof value !== 'string' || !value.startsWith('/')) return '/';
|
||||
try {
|
||||
const url = new URL(value, origin);
|
||||
return url.origin === origin ? url.pathname + url.search + url.hash : '/';
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
}
|
||||
|
||||
// Presentation hint only. The API independently validates the signed cookie.
|
||||
export function hasCurrentTermsCookie(value = '', now = Date.now()) {
|
||||
const match = value.match(
|
||||
/^(\d{4}-\d{2}-\d{2}\.\d+)\.(\d{10})\.[a-f0-9]{64}$/,
|
||||
);
|
||||
if (!match || match[1] !== terms.version) return false;
|
||||
const issuedAt = Number(match[2]) * 1000;
|
||||
return issuedAt <= now && now < issuedAt + 365 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
export const terms = {
|
||||
version: '2026-10-04.2',
|
||||
author: 'Juan V. Navarro Leal',
|
||||
introduction:
|
||||
'Juegoland es un servicio desarrollado para uso personal por Juan V. Navarro Leal. Está en desarrollo y puede contener errores.',
|
||||
notice:
|
||||
'Su autor no se responsabiliza de la información compartida, pérdidas de datos ni daños o trastornos derivados del uso del servicio.',
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#f7f6f2" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,570 @@
|
||||
.brand {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
margin: 0 0 38px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.brand-logo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 897 / 214;
|
||||
object-fit: cover;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
.brand small {
|
||||
color: var(--secondary);
|
||||
font:
|
||||
500 10px 'DM Sans',
|
||||
sans-serif;
|
||||
letter-spacing: 0.3px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.mobile-logo {
|
||||
display: none;
|
||||
}
|
||||
.sidebar,
|
||||
.game-card,
|
||||
.hero-note,
|
||||
.join-code,
|
||||
.lobby-table,
|
||||
.continue-card,
|
||||
.modal,
|
||||
.auth-layout,
|
||||
.stat-card,
|
||||
.help-card,
|
||||
.room-panel,
|
||||
.parchis-board,
|
||||
.promotion,
|
||||
.room-code {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.topbar {
|
||||
background: var(--background);
|
||||
}
|
||||
.sidebar-invite,
|
||||
.filter-tabs,
|
||||
.difficulty,
|
||||
.lobby-table-head,
|
||||
.chat-form,
|
||||
.variant-note,
|
||||
.parchis-home,
|
||||
.promotion button {
|
||||
background: var(--soft);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.hero,
|
||||
.auth-art,
|
||||
.profile-header,
|
||||
.help-intro,
|
||||
.waiting-card,
|
||||
.result-banner {
|
||||
background: var(--hero);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.sidebar nav a.active,
|
||||
.sidebar nav a:hover,
|
||||
.turn-indicator {
|
||||
background: var(--soft);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.hero h1 {
|
||||
color: var(--text);
|
||||
}
|
||||
.hero h1 em,
|
||||
.hero-note > span,
|
||||
.game-play,
|
||||
.lobby-join,
|
||||
.bottom-note > a,
|
||||
.sidebar-invite a,
|
||||
.waiting-symbol,
|
||||
.room-code,
|
||||
.hero-star {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.hero-loop {
|
||||
border-color: var(--border);
|
||||
}
|
||||
.sidebar-label,
|
||||
.eyebrow,
|
||||
.sidebar nav a:not(.active),
|
||||
.sidebar-invite p,
|
||||
.sidebar-bottom > p,
|
||||
.user-card small,
|
||||
.logout,
|
||||
.header-search,
|
||||
.header-search input::placeholder,
|
||||
.connection,
|
||||
.hero-eyebrow,
|
||||
.hero-copy > p,
|
||||
.hero-caption,
|
||||
.hero-note small,
|
||||
.welcome-strip,
|
||||
.live-count,
|
||||
.filter-tabs button,
|
||||
.game-card-body > p,
|
||||
.difficulty,
|
||||
.game-meta,
|
||||
.card-bottom > a,
|
||||
.join-code,
|
||||
.join-code input::placeholder,
|
||||
.lobby-table-head,
|
||||
.lobby-game small,
|
||||
.player-stack small,
|
||||
.status-badge,
|
||||
.empty-lobby p,
|
||||
.empty-panel,
|
||||
.bottom-note > span,
|
||||
.bottom-note strong,
|
||||
.bottom-note p,
|
||||
.continue-card small,
|
||||
.site-footer,
|
||||
.muted,
|
||||
.modal label,
|
||||
.auth-art p,
|
||||
.auth-form > p,
|
||||
.auth-tabs button,
|
||||
.auth-note,
|
||||
.page-heading p,
|
||||
.profile-header p,
|
||||
.stat-card span,
|
||||
.stat-card small,
|
||||
.history-item small,
|
||||
.help-card ol,
|
||||
.help-intro p,
|
||||
.room-page-heading p,
|
||||
.breadcrumb,
|
||||
.player-banner small,
|
||||
.board-note,
|
||||
.chat-empty,
|
||||
.chat-message time,
|
||||
.room-rules,
|
||||
.event-list,
|
||||
.event-list span,
|
||||
.event-list small,
|
||||
.waiting-card > p,
|
||||
.waiting-player small,
|
||||
.result-banner p,
|
||||
.parchis-controls p,
|
||||
.parchis-home strong,
|
||||
.home-pieces small {
|
||||
color: var(--secondary);
|
||||
}
|
||||
.header-search input,
|
||||
.modal input,
|
||||
.modal select,
|
||||
.modal textarea,
|
||||
.auth-form input,
|
||||
.chat-form input {
|
||||
color: var(--text);
|
||||
}
|
||||
input:not([type='range']),
|
||||
select,
|
||||
textarea {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--secondary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.button.secondary,
|
||||
.icon-button:hover,
|
||||
.filter-tabs button.chosen {
|
||||
background: var(--surface);
|
||||
border-color: var(--border);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.button.primary,
|
||||
.chat-form button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.button.danger,
|
||||
.form-error {
|
||||
color: var(--danger-text);
|
||||
background: var(--danger-bg);
|
||||
border-color: var(--danger-text);
|
||||
}
|
||||
.card-bottom,
|
||||
.lobby-table-head,
|
||||
.lobby-row,
|
||||
.event-list > div {
|
||||
border-color: var(--border);
|
||||
}
|
||||
.stat-card strong {
|
||||
color: var(--text);
|
||||
}
|
||||
.stat-card:nth-child(2) strong,
|
||||
.result-tag.win {
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.result-tag {
|
||||
color: var(--secondary);
|
||||
background: var(--soft);
|
||||
}
|
||||
.result-tag.loss {
|
||||
color: var(--danger-text);
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
.avatar,
|
||||
.mini-avatar {
|
||||
background: var(--soft);
|
||||
color: var(--accent-text);
|
||||
border-color: var(--surface);
|
||||
}
|
||||
.settings-trigger {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
color: var(--secondary);
|
||||
transition:
|
||||
background 0.15s,
|
||||
transform 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
.settings-trigger:hover {
|
||||
color: var(--accent-text);
|
||||
background: var(--soft);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.settings-popover {
|
||||
position: fixed;
|
||||
inset: 68px 28px auto auto;
|
||||
margin: 0;
|
||||
width: 310px;
|
||||
max-width: calc(100vw - 24px);
|
||||
padding: 22px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
color: var(--text);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 16px 54px var(--shadow);
|
||||
}
|
||||
.settings-popover:popover-open {
|
||||
animation: panel-enter 0.18s ease-out;
|
||||
}
|
||||
.settings-popover > strong {
|
||||
display: block;
|
||||
font:
|
||||
750 18px 'Manrope',
|
||||
sans-serif;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.settings-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--secondary);
|
||||
margin: 18px 0 9px;
|
||||
}
|
||||
.theme-options,
|
||||
.palette-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 7px;
|
||||
}
|
||||
.theme-options button,
|
||||
.palette-options button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px 4px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 11px;
|
||||
background: var(--surface);
|
||||
color: var(--secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
.theme-options button.chosen,
|
||||
.palette-options button.chosen {
|
||||
background: var(--soft);
|
||||
border-color: var(--accent-text);
|
||||
color: var(--accent-text);
|
||||
box-shadow: 0 0 0 1px var(--accent-text);
|
||||
}
|
||||
.palette-options i {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 2px #fff6;
|
||||
}
|
||||
.sound-toggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--accent-text);
|
||||
background: var(--soft);
|
||||
font-size: 13px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.sound-popover label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.sound-popover input[type='range'] {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 16px 0 22px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.sound-popover p {
|
||||
font-size: 12px;
|
||||
margin-top: 12px;
|
||||
color: var(--secondary);
|
||||
}
|
||||
.sound-popover .speech-option {
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin: 0 0 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.speech-option input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.modal.dice-overlay {
|
||||
width: 560px;
|
||||
max-width: calc(100vw - 24px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 28px;
|
||||
padding: 38px 30px 28px;
|
||||
text-align: center;
|
||||
background: radial-gradient(
|
||||
ellipse at 50% 36%,
|
||||
var(--hero),
|
||||
var(--surface) 70%
|
||||
);
|
||||
}
|
||||
.dice-overlay::backdrop {
|
||||
background: #081126b8;
|
||||
backdrop-filter: blur(7px);
|
||||
}
|
||||
.dice-overlay h2 {
|
||||
font-size: 24px;
|
||||
max-width: 360px;
|
||||
margin: auto;
|
||||
}
|
||||
.text-button {
|
||||
background: transparent;
|
||||
color: var(--accent-text);
|
||||
border: 0;
|
||||
font-size: 12px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.pin-input {
|
||||
font-size: 23px;
|
||||
letter-spacing: 12px;
|
||||
}
|
||||
.guest-notice {
|
||||
padding: 12px;
|
||||
background: var(--soft);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.modal .button.full + .button.full {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.player-color-0 {
|
||||
--player-ink: #775800;
|
||||
--player-soft: #fff3ce;
|
||||
--player-solid: #b88925;
|
||||
}
|
||||
.player-color-1 {
|
||||
--player-ink: #165ba1;
|
||||
--player-soft: #e6f1ff;
|
||||
--player-solid: #397bba;
|
||||
}
|
||||
.player-color-2 {
|
||||
--player-ink: #aa3938;
|
||||
--player-soft: #ffecea;
|
||||
--player-solid: #b95f56;
|
||||
}
|
||||
.player-color-3 {
|
||||
--player-ink: #217146;
|
||||
--player-soft: #e6f5eb;
|
||||
--player-solid: #568264;
|
||||
}
|
||||
.player-color-4 {
|
||||
--player-ink: #30343b;
|
||||
--player-soft: #e8eaed;
|
||||
--player-solid: #30343b;
|
||||
}
|
||||
.player-color-5 {
|
||||
--player-ink: #985023;
|
||||
--player-soft: #fff0e1;
|
||||
--player-solid: #ad702f;
|
||||
}
|
||||
.player-color-6 {
|
||||
--player-ink: #703cad;
|
||||
--player-soft: #f2eaff;
|
||||
--player-solid: #8654b8;
|
||||
}
|
||||
.player-color-7 {
|
||||
--player-ink: #536070;
|
||||
--player-soft: #eaf0f4;
|
||||
--player-solid: #7d8794;
|
||||
}
|
||||
[data-theme='dark'] .player-color-0 {
|
||||
--player-ink: #f3cd67;
|
||||
--player-soft: #393326;
|
||||
}
|
||||
[data-theme='dark'] .player-color-1 {
|
||||
--player-ink: #95c6ff;
|
||||
--player-soft: #243650;
|
||||
}
|
||||
[data-theme='dark'] .player-color-2 {
|
||||
--player-ink: #ffb4ad;
|
||||
--player-soft: #402d36;
|
||||
}
|
||||
[data-theme='dark'] .player-color-3 {
|
||||
--player-ink: #92ddb1;
|
||||
--player-soft: #203d35;
|
||||
}
|
||||
[data-theme='dark'] .player-color-4 {
|
||||
--player-ink: #e1e4e9;
|
||||
--player-soft: #30343b;
|
||||
}
|
||||
[data-theme='dark'] .player-color-5 {
|
||||
--player-ink: #ffc290;
|
||||
--player-soft: #3b3029;
|
||||
}
|
||||
[data-theme='dark'] .player-color-6 {
|
||||
--player-ink: #d9b8ff;
|
||||
--player-soft: #352a49;
|
||||
}
|
||||
[data-theme='dark'] .player-color-7 {
|
||||
--player-ink: #c1ccd6;
|
||||
--player-soft: #303c48;
|
||||
}
|
||||
.avatar[class*='player-color'],
|
||||
.mini-avatar[class*='player-color'] {
|
||||
color: var(--player-ink);
|
||||
background: var(--player-soft);
|
||||
}
|
||||
.chat-message strong {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--player-ink);
|
||||
font-size: 11px;
|
||||
}
|
||||
.chat-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 23px;
|
||||
width: 23px;
|
||||
border-radius: 8px;
|
||||
background: var(--player-soft);
|
||||
color: var(--player-ink);
|
||||
font-size: 11px;
|
||||
}
|
||||
.chat-message p,
|
||||
.chat-message.mine p {
|
||||
color: var(--text);
|
||||
background: var(--player-soft);
|
||||
border-inline-start: 3px solid var(--player-solid);
|
||||
font-size: 13px;
|
||||
}
|
||||
.chat-message time {
|
||||
font-size: 10px;
|
||||
}
|
||||
.chat-message {
|
||||
animation: panel-enter 0.2s ease-out;
|
||||
}
|
||||
.parchis-controls {
|
||||
background: var(--soft);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.parchis-home {
|
||||
border-color: var(--border);
|
||||
}
|
||||
.home-pieces .parchis-token:not(:disabled) {
|
||||
outline: 2px solid var(--accent-text);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
@keyframes panel-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (max-width: 1000px) and (min-width: 501px) {
|
||||
.brand {
|
||||
width: 64px;
|
||||
align-items: center;
|
||||
margin: 0 auto 38px;
|
||||
}
|
||||
.brand-logo {
|
||||
min-width: 64px;
|
||||
}
|
||||
.brand small {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.sidebar {
|
||||
background: var(--surface);
|
||||
}
|
||||
.mobile-logo {
|
||||
display: block;
|
||||
width: 155px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar .header-search {
|
||||
display: none;
|
||||
}
|
||||
.topbar {
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.header-right {
|
||||
gap: 8px;
|
||||
}
|
||||
.settings-popover {
|
||||
inset: 65px 12px auto auto;
|
||||
}
|
||||
.header-login {
|
||||
font-size: 11px;
|
||||
gap: 4px;
|
||||
}
|
||||
.settings-trigger {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,16 @@
|
||||
import type { Room } from './types.js';
|
||||
|
||||
/** Ignore initial history, own messages and duplicate state acknowledgements. */
|
||||
export function incomingChat(
|
||||
previous: Room | null,
|
||||
next: Room,
|
||||
userId: string,
|
||||
) {
|
||||
if (!previous || previous.id !== next.id) return null;
|
||||
const known = new Set(previous.messages?.map((message) => message.id));
|
||||
return (
|
||||
next.messages
|
||||
?.filter((message) => message.userId !== userId && !known.has(message.id))
|
||||
.at(-1) ?? null
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import type { User } from './types';
|
||||
import { catalog } from '../../shared/catalog.mjs';
|
||||
import type { Definition } from './types';
|
||||
import { terms } from '../../shared/terms.mjs';
|
||||
|
||||
export const consent = $state({
|
||||
accepted: false,
|
||||
version: terms.version,
|
||||
acceptedAt: null as string | null,
|
||||
expiresAt: null as string | null,
|
||||
reviewOpen: false,
|
||||
dismissed: false,
|
||||
});
|
||||
|
||||
export const definitions = catalog as Definition[];
|
||||
export const session = $state({
|
||||
user: null as User | null,
|
||||
loading: true,
|
||||
connected: false,
|
||||
search: '',
|
||||
});
|
||||
let socket: Socket | null = null;
|
||||
let consentRevision = 0;
|
||||
export function getSocket() {
|
||||
if (!socket) {
|
||||
socket = io({
|
||||
autoConnect: false,
|
||||
withCredentials: true,
|
||||
transports: ['websocket'],
|
||||
});
|
||||
socket.on('connect', () => {
|
||||
session.connected = true;
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
session.connected = false;
|
||||
});
|
||||
socket.on('connect_error', () => {
|
||||
session.connected = false;
|
||||
});
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
export async function api<T>(path: string, input?: unknown): Promise<T> {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method: input === undefined ? 'GET' : 'POST',
|
||||
credentials: 'same-origin',
|
||||
signal: AbortSignal.timeout(10000),
|
||||
headers: input === undefined ? {} : { 'content-type': 'application/json' },
|
||||
body: input === undefined ? undefined : JSON.stringify(input),
|
||||
});
|
||||
let result;
|
||||
try {
|
||||
result = await response.json();
|
||||
} catch {
|
||||
throw new Error('No se pudo conectar con el servidor.');
|
||||
}
|
||||
if (response.status === 428 && result.code === 'TERMS_REQUIRED') {
|
||||
consent.accepted = false;
|
||||
consent.dismissed = false;
|
||||
consent.reviewOpen = true;
|
||||
socket?.disconnect();
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new Error(result.error || 'No se pudo completar la operación.');
|
||||
return result;
|
||||
}
|
||||
export async function initialize() {
|
||||
const requestRevision = consentRevision;
|
||||
try {
|
||||
const result = await api<{
|
||||
user: User | null;
|
||||
terms: {
|
||||
accepted: boolean;
|
||||
version: string;
|
||||
acceptedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
}>('/session');
|
||||
session.user = result.user;
|
||||
if (requestRevision === consentRevision)
|
||||
Object.assign(consent, result.terms);
|
||||
if (session.user && consent.accepted) getSocket().connect();
|
||||
} finally {
|
||||
session.loading = false;
|
||||
}
|
||||
}
|
||||
export function signedIn(user: User) {
|
||||
session.user = user;
|
||||
if (consent.accepted) getSocket().connect();
|
||||
}
|
||||
export async function acceptTerms() {
|
||||
const result = await api<{
|
||||
terms: Pick<
|
||||
typeof consent,
|
||||
'accepted' | 'version' | 'acceptedAt' | 'expiresAt'
|
||||
>;
|
||||
}>('/terms/accept', { accepted: true, version: terms.version });
|
||||
consentRevision++;
|
||||
Object.assign(consent, result.terms);
|
||||
consent.reviewOpen = false;
|
||||
if (session.user) getSocket().connect();
|
||||
}
|
||||
export async function revokeTerms() {
|
||||
const result = await api<{
|
||||
terms: Pick<
|
||||
typeof consent,
|
||||
'accepted' | 'version' | 'acceptedAt' | 'expiresAt'
|
||||
>;
|
||||
}>('/terms/revoke', {});
|
||||
Object.assign(consent, result.terms);
|
||||
consent.reviewOpen = false;
|
||||
socket?.disconnect();
|
||||
}
|
||||
export async function signOut() {
|
||||
await api('/auth/logout', {});
|
||||
session.user = null;
|
||||
getSocket().disconnect();
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
person,
|
||||
}: { person?: { nick?: string; name?: string; avatar?: string } | null } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
{#if person?.avatar}<img
|
||||
class="avatar-image"
|
||||
src={person.avatar}
|
||||
alt=""
|
||||
width="80"
|
||||
height="80"
|
||||
/>{:else}{(person?.nick ?? person?.name ?? '?')
|
||||
.slice(0, 2)
|
||||
.toUpperCase()}{/if}
|
||||
|
||||
<style>
|
||||
.avatar-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { session, api } from '../client.svelte';
|
||||
import type { User } from '../types';
|
||||
import { avatarPresets } from '../../../shared/avatars.mjs';
|
||||
import Dialog from './Dialog.svelte';
|
||||
import AvatarImage from './AvatarImage.svelte';
|
||||
let open = $state(false),
|
||||
preview = $state<string | undefined>(),
|
||||
preset = $state<string | null>(null),
|
||||
image = $state<string | null>(null),
|
||||
pending = $state(false),
|
||||
error = $state('');
|
||||
let fileInput = $state<HTMLInputElement>();
|
||||
function edit() {
|
||||
preview = session.user?.avatar;
|
||||
preset = avatarPresets.find((p) => p.src === preview)?.id ?? null;
|
||||
image = null;
|
||||
error = '';
|
||||
open = true;
|
||||
}
|
||||
function choose(id: string | null) {
|
||||
preset = id;
|
||||
image = null;
|
||||
preview = avatarPresets.find((p) => p.id === id)?.src;
|
||||
error = '';
|
||||
}
|
||||
async function upload(file: File | undefined) {
|
||||
if (!file) return;
|
||||
error = '';
|
||||
if (
|
||||
!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) ||
|
||||
file.size > 8 * 1024 * 1024
|
||||
) {
|
||||
error = 'Elige una imagen PNG, JPG o WebP de hasta 8 MB.';
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw Error('No se puede preparar esta imagen.');
|
||||
const side = Math.min(bitmap.width, bitmap.height);
|
||||
context.drawImage(
|
||||
bitmap,
|
||||
(bitmap.width - side) / 2,
|
||||
(bitmap.height - side) / 2,
|
||||
side,
|
||||
side,
|
||||
0,
|
||||
0,
|
||||
128,
|
||||
128,
|
||||
);
|
||||
bitmap.close();
|
||||
image = canvas.toDataURL('image/png');
|
||||
preset = null;
|
||||
preview = image;
|
||||
} catch {
|
||||
error = 'No se puede abrir esa imagen. Prueba con otra.';
|
||||
} finally {
|
||||
pending = false;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
}
|
||||
async function save() {
|
||||
if (preview === session.user?.avatar) {
|
||||
open = false;
|
||||
return;
|
||||
}
|
||||
pending = true;
|
||||
error = '';
|
||||
try {
|
||||
const result = await api<{ user: User }>(
|
||||
'/profile/avatar',
|
||||
image ? { image } : { preset },
|
||||
);
|
||||
session.user = result.user;
|
||||
open = false;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="button secondary" onclick={edit}>Cambiar avatar</button>
|
||||
{#if open}<Dialog
|
||||
label="avatar-title"
|
||||
onclose={() => (open = false)}
|
||||
className="avatar-editor"
|
||||
>
|
||||
<h2 id="avatar-title">Tu avatar</h2>
|
||||
<p class="avatar-copy">
|
||||
Elige uno o sube una imagen. Se mostrará en tus partidas y en el chat.
|
||||
</p>
|
||||
<span class="avatar avatar-preview"
|
||||
><AvatarImage person={{ ...session.user, avatar: preview }} /></span
|
||||
>
|
||||
<div class="avatar-options" aria-label="Avatares disponibles">
|
||||
{#each avatarPresets as option}
|
||||
<button
|
||||
class:selected={preview === option.src}
|
||||
aria-label={`Elegir avatar: ${option.name}`}
|
||||
aria-pressed={preview === option.src}
|
||||
disabled={pending}
|
||||
onclick={() => choose(option.id)}
|
||||
><img src={option.src} alt="" width="64" height="64" /></button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="avatar-tools">
|
||||
<button
|
||||
class="button secondary"
|
||||
disabled={pending}
|
||||
onclick={() => fileInput?.click()}>Subir una imagen</button
|
||||
><button
|
||||
class="button secondary"
|
||||
disabled={pending}
|
||||
onclick={() => choose(null)}>Usar mis iniciales</button
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
style="display:none"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
aria-label="Imagen para tu avatar"
|
||||
onchange={(e) => upload(e.currentTarget.files?.[0])}
|
||||
/>
|
||||
<p class="avatar-hint">
|
||||
PNG, JPG o WebP, hasta 8 MB. La imagen se recorta al centro.
|
||||
</p>
|
||||
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
|
||||
<div class="form-row">
|
||||
<button
|
||||
class="button secondary"
|
||||
disabled={pending}
|
||||
onclick={() => (open = false)}>Cancelar</button
|
||||
><button class="button primary" disabled={pending} onclick={save}
|
||||
>{pending ? 'Guardando…' : 'Guardar avatar'}</button
|
||||
>
|
||||
</div>
|
||||
</Dialog>{/if}
|
||||
|
||||
<style>
|
||||
.avatar-copy {
|
||||
font-size: var(--type-secondary);
|
||||
color: var(--secondary);
|
||||
margin: 12px 0 20px;
|
||||
}
|
||||
.avatar-preview {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 20px;
|
||||
font-size: 28px;
|
||||
}
|
||||
.avatar-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.avatar-options button {
|
||||
border: 2px solid transparent;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
border-radius: 16px;
|
||||
padding: 4px;
|
||||
min-height: 64px;
|
||||
}
|
||||
.avatar-options button.selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--soft);
|
||||
}
|
||||
.avatar-options img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
max-width: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.avatar-tools {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.avatar-tools .button {
|
||||
font-size: var(--type-secondary);
|
||||
padding: 10px;
|
||||
}
|
||||
.avatar-hint {
|
||||
font-size: var(--type-detail);
|
||||
color: var(--secondary);
|
||||
margin: 12px 0 20px;
|
||||
}
|
||||
input[type='file'] {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 380px) {
|
||||
.avatar-options {
|
||||
gap: 8px;
|
||||
}
|
||||
.avatar-options img {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import type { Action, Room } from '../types';
|
||||
import ParchisMap from './ParchisMap.svelte';
|
||||
import Dice from './Dice.svelte';
|
||||
import { diceFeedback } from '../dice-feedback';
|
||||
import GoBoard from './GoBoard.svelte';
|
||||
import OcaBoard from './OcaBoard.svelte';
|
||||
import AvatarImage from './AvatarImage.svelte';
|
||||
import {
|
||||
colorForPlayer,
|
||||
playerColorClass,
|
||||
} from '../../../shared/player-colors.mjs';
|
||||
let {
|
||||
room,
|
||||
disabled = false,
|
||||
onaction,
|
||||
}: {
|
||||
room: Room;
|
||||
disabled?: boolean;
|
||||
onaction: (action: Action) => void;
|
||||
} = $props();
|
||||
let selected = $state<string | number | null>(null),
|
||||
promotion = $state<Action[]>([]);
|
||||
const chessSymbols: Record<string, string> = {
|
||||
k: '♚',
|
||||
q: '♛',
|
||||
r: '♜',
|
||||
b: '♝',
|
||||
n: '♞',
|
||||
p: '♟',
|
||||
};
|
||||
const pieceNames: Record<string, string> = {
|
||||
k: 'rey',
|
||||
q: 'dama',
|
||||
r: 'torre',
|
||||
b: 'alfil',
|
||||
n: 'caballo',
|
||||
p: 'peón',
|
||||
};
|
||||
const legal = $derived(room.legalActions ?? []);
|
||||
const active = $derived(
|
||||
room.gameId === 'damas' &&
|
||||
room.status === 'playing' &&
|
||||
room.state &&
|
||||
room.state.turn === room.seat &&
|
||||
typeof room.state.chain === 'number'
|
||||
? room.state.chain
|
||||
: selected,
|
||||
);
|
||||
const feedback = $derived(diceFeedback(room));
|
||||
const lastRoll = $derived(
|
||||
room.events?.filter((e) => e.type === 'roll').at(-1)?.sequence ?? 0,
|
||||
);
|
||||
const positions = $derived(
|
||||
Array.from({ length: 64 }, (_, i) => (room.seat === 1 ? 63 - i : i)),
|
||||
);
|
||||
const cells = $derived.by(() => {
|
||||
if (room.gameId === 'ajedrez')
|
||||
return (
|
||||
(
|
||||
room.state?.board as ({
|
||||
square: string;
|
||||
type: string;
|
||||
color: string;
|
||||
} | null)[][]
|
||||
)?.flat() ?? []
|
||||
);
|
||||
return (room.state?.board as number[]) ?? [];
|
||||
});
|
||||
const moves = $derived(legal.filter((a) => a.from === active));
|
||||
$effect(() => {
|
||||
room.version;
|
||||
selected = null;
|
||||
promotion = [];
|
||||
});
|
||||
function cellId(i: number) {
|
||||
return room.gameId === 'ajedrez'
|
||||
? `${'abcdefgh'[i % 8]}${8 - Math.floor(i / 8)}`
|
||||
: i;
|
||||
}
|
||||
function select(i: number) {
|
||||
if (disabled) return;
|
||||
const id = cellId(i);
|
||||
const options = moves.filter((a) => a.to === id);
|
||||
if (options.length > 1) {
|
||||
promotion = options;
|
||||
return;
|
||||
}
|
||||
if (options.length === 1) {
|
||||
onaction(options[0]);
|
||||
if (room.gameId !== 'damas') selected = null;
|
||||
return;
|
||||
}
|
||||
selected = legal.some((a) => a.from === id) ? id : null;
|
||||
}
|
||||
function describe(i: number) {
|
||||
const piece = cells[i];
|
||||
if (room.gameId === 'ajedrez') {
|
||||
const p = piece as { type: string; color: string } | null;
|
||||
return `${cellId(i)}, ${p ? `${pieceNames[p.type]} ${p.color === 'w' ? 'blanco' : 'negro'}` : 'vacía'}`;
|
||||
}
|
||||
const p = piece as number;
|
||||
return `Casilla ${i + 1}, ${p ? `${p < 0 ? 'dama' : 'ficha'} del jugador ${Math.abs(p)}` : 'vacía'}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if room.gameId === 'parchis'}
|
||||
<div class="parchis-board">
|
||||
<div class="board-surface"><ParchisMap {room} {disabled} {onaction} /></div>
|
||||
<div class="parchis-controls">
|
||||
<Dice
|
||||
value={room.state?.rolled}
|
||||
rollId={lastRoll}
|
||||
{feedback}
|
||||
canRoll={legal.some((a) => a.type === 'roll')}
|
||||
{disabled}
|
||||
definition={room.definition}
|
||||
onroll={() => onaction({ type: 'roll' })}
|
||||
/>
|
||||
<p class="dice-instruction" aria-live="polite">
|
||||
{#if lastRoll}<span class="last-roll-caption">{feedback.title}.</span
|
||||
>{/if}{feedback.next}
|
||||
</p>
|
||||
<div class="table-players">
|
||||
{#each room.players as player}<div
|
||||
class="table-player {playerColorClass(player)}"
|
||||
class:playing={room.status === 'playing' &&
|
||||
room.state?.turn === player.seat}
|
||||
>
|
||||
<span class="mini-avatar"><AvatarImage person={player} /></span>
|
||||
<strong
|
||||
>{player.nick}{player.seat === room.seat ? ' · tú' : ''}</strong
|
||||
>
|
||||
<small
|
||||
>{#if player.withdrawnAt}Se ha retirado{:else}{room.state?.pieces?.[
|
||||
player.seat
|
||||
]?.filter((p) => p === (room.state?.goal ?? 71)).length ?? 0}/4
|
||||
en meta{/if}</small
|
||||
>
|
||||
</div>{/each}
|
||||
</div>
|
||||
{#if legal.some((a) => a.type === 'move')}<div class="piece-shortcuts">
|
||||
<span>Fichas disponibles</span
|
||||
>{#each legal.filter((a) => a.type === 'move') as move}<button
|
||||
class="parchis-token"
|
||||
{disabled}
|
||||
style:--piece-color={colorForPlayer(
|
||||
room.players.find((p) => p.seat === room.seat),
|
||||
).hex}
|
||||
onclick={() => onaction(move)}
|
||||
aria-label={`Mover ficha ${move.piece! + 1}`}
|
||||
>{move.piece! + 1}</button
|
||||
>{/each}
|
||||
</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if room.gameId === 'go'}
|
||||
<GoBoard {room} {disabled} {onaction} />
|
||||
{:else if room.gameId === 'oca'}
|
||||
<OcaBoard {room} {disabled} {onaction} />
|
||||
{:else}
|
||||
<div class="board-container">
|
||||
<div class="board-grid" aria-label={`Tablero de ${room.definition?.name}`}>
|
||||
{#each positions as i, visual}<button
|
||||
class="board-square"
|
||||
class:dark={(Math.floor(i / 8) + (i % 8)) % 2 === 1}
|
||||
class:selected={active === cellId(i)}
|
||||
class:target={moves.some((a) => a.to === cellId(i))}
|
||||
class:occupied={!!cells[i]}
|
||||
disabled={disabled ||
|
||||
(!legal.some((a) => a.from === cellId(i)) &&
|
||||
!moves.some((a) => a.to === cellId(i)))}
|
||||
aria-label={describe(i)}
|
||||
aria-pressed={active === cellId(i)}
|
||||
onclick={() => select(i)}
|
||||
>{#if visual % 8 === 0}<span class="coordinate"
|
||||
>{8 - Math.floor(i / 8)}</span
|
||||
>{/if}{#if visual >= 56}<span class="coordinate file"
|
||||
>{'abcdefgh'[i % 8]}</span
|
||||
>{/if}{#if room.gameId === 'ajedrez'}{@const piece = cells[i] as {
|
||||
type: string;
|
||||
color: string;
|
||||
} | null}{#if piece}<span
|
||||
class="chess-piece"
|
||||
class:white={piece.color === 'w'}
|
||||
aria-hidden="true">{chessSymbols[piece.type]}</span
|
||||
>{/if}{:else}{@const piece = cells[i] as number}{#if piece}<span
|
||||
class="checker-piece"
|
||||
class:light={Math.abs(piece) === 1}
|
||||
aria-hidden="true">{piece < 0 ? '♔' : ''}</span
|
||||
>{/if}{/if}</button
|
||||
>{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if promotion.length}<div class="promotion">
|
||||
<p>Elige la pieza para la promoción:</p>
|
||||
{#each promotion as action}<button
|
||||
{disabled}
|
||||
onclick={() => {
|
||||
onaction(action);
|
||||
promotion = [];
|
||||
}}>{pieceNames[action.promotion!]}</button
|
||||
>{/each}
|
||||
</div>{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { brand } from '../../../shared/brand.mjs';
|
||||
</script>
|
||||
<img src={brand.logo} alt={brand.name} width="897" height="214" class="brand-logo" />
|
||||
@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import Icon from './Icon.svelte';
|
||||
let {
|
||||
info,
|
||||
x = 50,
|
||||
y = 50,
|
||||
pinned = false,
|
||||
onclose,
|
||||
}: {
|
||||
info: { title: string; kind: string; description: string };
|
||||
x?: number;
|
||||
y?: number;
|
||||
pinned?: boolean;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="cell-info"
|
||||
class:danger={info.kind === 'danger'}
|
||||
class:advantage={info.kind === 'advantage'}
|
||||
style={`--cell-x:${x}%;${y < 50 ? `top:calc(${y}% + 32px)` : `bottom:calc(${100 - y}% + 32px)`}`}
|
||||
role={pinned ? 'region' : 'tooltip'}
|
||||
aria-label="Información de la casilla"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
>{info.kind === 'danger'
|
||||
? 'Peligro'
|
||||
: info.kind === 'advantage'
|
||||
? 'Ventaja'
|
||||
: 'En el recorrido'}</span
|
||||
>
|
||||
<h3>{info.title}</h3>
|
||||
</div>
|
||||
{#if pinned}<button
|
||||
class="icon-button"
|
||||
aria-label="Cerrar información de la casilla"
|
||||
onclick={onclose}><Icon name="close" size={18} /></button
|
||||
>{/if}
|
||||
<p>{info.description}</p>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.cell-info {
|
||||
position: absolute;
|
||||
left: clamp(8px, calc(var(--cell-x) - 140px), calc(100% - 288px));
|
||||
width: 280px;
|
||||
max-width: calc(100% - 16px);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-top: 3px solid var(--accent);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 30px var(--shadow);
|
||||
padding: 16px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.cell-info.danger {
|
||||
border-top-color: #c3403a;
|
||||
}
|
||||
.cell-info.advantage {
|
||||
border-top-color: #308052;
|
||||
}
|
||||
.cell-info > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.cell-info span {
|
||||
font-size: var(--type-detail);
|
||||
color: var(--secondary);
|
||||
}
|
||||
.cell-info.danger span {
|
||||
color: var(--danger);
|
||||
}
|
||||
.cell-info.advantage span {
|
||||
color: var(--player-ink, #308052);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--type-body);
|
||||
line-height: 1.4;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
p {
|
||||
width: 100%;
|
||||
font-size: var(--type-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
color: var(--secondary);
|
||||
}
|
||||
.icon-button {
|
||||
flex-shrink: 0;
|
||||
margin: -8px -8px 0 0;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, definitions } from '../client.svelte';
|
||||
import type { Room } from '../types';
|
||||
import Icon from './Icon.svelte';
|
||||
import Dialog from './Dialog.svelte';
|
||||
let {
|
||||
gameId = 'ajedrez',
|
||||
onclose,
|
||||
}: { gameId?: string; onclose: () => void } = $props();
|
||||
let selected = $state('ajedrez'),
|
||||
name = $state(''),
|
||||
invitationMessage = $state(''),
|
||||
visibility = $state('public'),
|
||||
players = $state(2),
|
||||
pending = $state(false),
|
||||
error = $state('');
|
||||
onMount(() => {
|
||||
selected = gameId;
|
||||
});
|
||||
const game = $derived(definitions.find((g) => g.id === selected)!);
|
||||
async function create(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
pending = true;
|
||||
error = '';
|
||||
try {
|
||||
const { room } = await api<{ room: Room }>('/rooms', {
|
||||
gameId: selected,
|
||||
name: name.trim() || `Una partida de ${game.name.toLowerCase()}`,
|
||||
maxPlayers: Math.min(players, game.maxPlayers),
|
||||
visibility,
|
||||
invitationMessage,
|
||||
});
|
||||
onclose();
|
||||
await goto(`/salas/${room.id}`);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog label="create-title" {onclose}>
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2 id="create-title">Crea tu próxima partida</h2>
|
||||
</div>
|
||||
<button class="icon-button" aria-label="Cerrar" onclick={onclose}
|
||||
><Icon name="close" /></button
|
||||
>
|
||||
</div>
|
||||
<form onsubmit={create}>
|
||||
<label
|
||||
>Elige un juego<select
|
||||
bind:value={selected}
|
||||
onchange={() => (players = 2)}
|
||||
>{#each definitions as definition}<option value={definition.id}
|
||||
>{definition.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<div class="variant-note">
|
||||
<Icon name="help" size={17} /><span>{game.variant}</span>
|
||||
</div>
|
||||
<label
|
||||
>Nombre de la mesa<input
|
||||
bind:value={name}
|
||||
maxlength="60"
|
||||
placeholder={`Una partida de ${game.name.toLowerCase()}`}
|
||||
/></label
|
||||
>
|
||||
<div class="form-row">
|
||||
<label
|
||||
>Jugadores<select bind:value={players}
|
||||
>{#each Array(game.maxPlayers - 1) as _, i}<option value={i + 2}
|
||||
>{i + 2} jugadores</option
|
||||
>{/each}</select
|
||||
></label
|
||||
><label
|
||||
>Visibilidad<select bind:value={visibility}
|
||||
><option value="public">Pública</option><option value="private"
|
||||
>Privada · por código</option
|
||||
></select
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
<p class="muted small">
|
||||
La partida comienza cuando todas las plazas estén ocupadas y los jugadores
|
||||
indiquen que están listos.
|
||||
</p>
|
||||
<label>Un mensaje para tus invitados<textarea bind:value={invitationMessage} maxlength="300" rows="3" placeholder="¿Nos echamos una partida? Te espero en la mesa."></textarea></label>
|
||||
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
|
||||
<button class="button primary full" disabled={pending}
|
||||
>{pending ? 'Preparando la mesa…' : 'Crear mesa'}<Icon
|
||||
name="arrow"
|
||||
size={18}
|
||||
/></button
|
||||
>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
let {
|
||||
label,
|
||||
onclose,
|
||||
children,
|
||||
className='',
|
||||
}: { label: string; onclose: () => void; children: Snippet;className?:string } = $props();
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
onMount(() => {
|
||||
dialog?.showModal();
|
||||
const heading=dialog?.querySelector<HTMLElement>('h2');
|
||||
heading?.setAttribute('tabindex','-1');
|
||||
heading?.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
class="modal {className}"
|
||||
bind:this={dialog}
|
||||
aria-labelledby={label}
|
||||
oncancel={onclose}
|
||||
>
|
||||
{@render children()}
|
||||
</dialog>
|
||||
@ -0,0 +1,591 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import type { Definition } from '../types';
|
||||
import Dialog from './Dialog.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import { unlockSound, playSound, announceDice } from '../sound.svelte';
|
||||
import type { diceFeedback } from '../dice-feedback';
|
||||
let {
|
||||
value = null,
|
||||
rollId = '',
|
||||
canRoll = false,
|
||||
disabled = false,
|
||||
onroll,
|
||||
definition,
|
||||
feedback,
|
||||
onresultclose,
|
||||
}: {
|
||||
value?: number | null;
|
||||
rollId?: string | number;
|
||||
canRoll?: boolean;
|
||||
disabled?: boolean;
|
||||
onroll: () => void;
|
||||
definition?: Definition;
|
||||
feedback: ReturnType<typeof diceFeedback>;
|
||||
onresultclose?: (rollId: string | number) => void;
|
||||
} = $props();
|
||||
let rolling = $state(false),
|
||||
previous: string | number | undefined,
|
||||
timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let overlay = $state(false),
|
||||
phase = $state<'ready' | 'shaking' | 'waiting' | 'dropping' | 'result'>(
|
||||
'ready',
|
||||
),
|
||||
slow = $state(false);
|
||||
let shakeTimer: ReturnType<typeof setInterval> | undefined,
|
||||
waitTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let notice = $state(false),
|
||||
noticeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const duration = $derived(definition?.animations?.roll?.duration ?? 800);
|
||||
const dots = [
|
||||
[4],
|
||||
[0, 8],
|
||||
[0, 4, 8],
|
||||
[0, 2, 6, 8],
|
||||
[0, 2, 4, 6, 8],
|
||||
[0, 2, 3, 5, 6, 8],
|
||||
];
|
||||
const angles = [
|
||||
[0, 0],
|
||||
[0, -90],
|
||||
[-90, 0],
|
||||
[90, 0],
|
||||
[0, 90],
|
||||
[0, 180],
|
||||
];
|
||||
const rotation = $derived(angles[(value ?? 1) - 1]);
|
||||
let resultValue = $state<number | null>(null),
|
||||
resultFeedback = $state(untrack(() => feedback)),
|
||||
resultRollId: string | number = '';
|
||||
function reveal() {
|
||||
if (
|
||||
feedback.createdAt &&
|
||||
Date.now() - Date.parse(feedback.createdAt) > 5000
|
||||
)
|
||||
return;
|
||||
// A later rival throw must not dismiss a result the player is still reading.
|
||||
if (
|
||||
overlay &&
|
||||
!feedback.own &&
|
||||
(phase === 'dropping' || phase === 'result')
|
||||
)
|
||||
return;
|
||||
clearInterval(shakeTimer);
|
||||
clearTimeout(timer);
|
||||
clearTimeout(waitTimer);
|
||||
clearTimeout(noticeTimer);
|
||||
rolling = true;
|
||||
overlay = feedback.own;
|
||||
if (feedback.own) {
|
||||
resultValue = value;
|
||||
resultFeedback = feedback;
|
||||
resultRollId = rollId;
|
||||
}
|
||||
notice = !feedback.own;
|
||||
phase = 'dropping';
|
||||
slow = false;
|
||||
const revealedValue = value;
|
||||
timer = setTimeout(() => {
|
||||
rolling = false;
|
||||
phase = 'result';
|
||||
if (revealedValue) announceDice(definition, revealedValue);
|
||||
}, duration);
|
||||
if (notice)
|
||||
noticeTimer = setTimeout(() => (notice = false), duration + 3500);
|
||||
}
|
||||
$effect(() => {
|
||||
const id = rollId;
|
||||
if (previous !== undefined && previous !== id) untrack(reveal);
|
||||
previous = id;
|
||||
});
|
||||
function shake() {
|
||||
if (phase !== 'ready' || !canRoll || disabled) return;
|
||||
phase = 'shaking';
|
||||
void unlockSound().then(() => playSound(definition, 'shake'));
|
||||
shakeTimer = setInterval(() => void playSound(definition, 'shake'), 550);
|
||||
}
|
||||
function release() {
|
||||
if (phase !== 'shaking') return;
|
||||
if (!canRoll || disabled) {
|
||||
cancelShake();
|
||||
return;
|
||||
}
|
||||
clearInterval(shakeTimer);
|
||||
phase = 'waiting';
|
||||
slow = false;
|
||||
onroll();
|
||||
waitTimer = setTimeout(() => (slow = true), 8000);
|
||||
}
|
||||
function close() {
|
||||
const finishedResult =
|
||||
overlay && (phase === 'dropping' || phase === 'result');
|
||||
clearInterval(shakeTimer);
|
||||
clearTimeout(timer);
|
||||
clearTimeout(waitTimer);
|
||||
overlay = false;
|
||||
rolling = false;
|
||||
phase = 'ready';
|
||||
if (finishedResult) onresultclose?.(resultRollId);
|
||||
}
|
||||
function cancelShake() {
|
||||
clearInterval(shakeTimer);
|
||||
if (phase === 'shaking') phase = 'ready';
|
||||
}
|
||||
onDestroy(() => {
|
||||
clearInterval(shakeTimer);
|
||||
clearTimeout(timer);
|
||||
clearTimeout(waitTimer);
|
||||
clearTimeout(noticeTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet figure(large = false)}
|
||||
{@const shownValue = large ? resultValue : value}
|
||||
{@const shownRotation = large ? angles[(shownValue ?? 1) - 1] : rotation}
|
||||
<div
|
||||
class="dice-stage"
|
||||
class:large
|
||||
class:rolling
|
||||
style:--roll-duration={`${duration}ms`}
|
||||
style:--rx={`${shownRotation[0]}deg`}
|
||||
style:--ry={`${shownRotation[1]}deg`}
|
||||
role="img"
|
||||
aria-label={rolling
|
||||
? 'Lanzando el dado'
|
||||
: shownValue
|
||||
? `Resultado del dado: ${shownValue}`
|
||||
: 'Dado listo para lanzar'}
|
||||
>
|
||||
<div class="dice-cube">
|
||||
{#each dots as face, i}<div class="dice-face face-{i + 1}">
|
||||
{#each Array(9) as _, dot}<i class:visible={face.includes(dot)}
|
||||
></i>{/each}
|
||||
</div>{/each}
|
||||
</div>
|
||||
<span class="dice-shadow"></span>
|
||||
</div>
|
||||
{/snippet}
|
||||
<div class="dice-widget">
|
||||
<div class="dice-result">
|
||||
{@render figure()}<small
|
||||
>{value ? feedback.title : 'Sin tiradas todavía'}</small
|
||||
>
|
||||
</div>
|
||||
{#if canRoll}<button
|
||||
class="button primary"
|
||||
disabled={disabled || rolling}
|
||||
onclick={() => {
|
||||
phase = 'ready';
|
||||
overlay = true;
|
||||
}}>Lanzar el dado</button
|
||||
>{/if}
|
||||
</div>
|
||||
{#if overlay}<Dialog
|
||||
label="dice-title"
|
||||
onclose={close}
|
||||
className="dice-overlay"
|
||||
>
|
||||
<button
|
||||
class="icon-button close-dice"
|
||||
aria-label="Cerrar tirada"
|
||||
onclick={close}><Icon name="close" /></button
|
||||
>
|
||||
<h2 id="dice-title">
|
||||
{phase === 'ready'
|
||||
? 'Lanza el dado'
|
||||
: phase === 'shaking'
|
||||
? 'Agita… y suelta'
|
||||
: phase === 'result'
|
||||
? resultFeedback.title
|
||||
: 'Tu dado está en el aire'}
|
||||
</h2>
|
||||
<div
|
||||
class="throw-scene"
|
||||
style:--roll-duration={`${duration}ms`}
|
||||
style:--shake-duration={`${definition?.animations?.shake?.duration ?? 180}ms`}
|
||||
class:shaking={phase === 'shaking'}
|
||||
class:dropping={phase === 'dropping'}
|
||||
>
|
||||
{#if phase === 'ready' || phase === 'shaking' || phase === 'waiting'}<div
|
||||
class="dice-cup"
|
||||
>
|
||||
<span></span><i></i><b>✦</b>
|
||||
</div>
|
||||
<span class="cup-shadow"></span>
|
||||
{:else}{@render figure(true)}{/if}
|
||||
</div>
|
||||
<div class="dice-announcement" aria-live="polite">
|
||||
{#if phase === 'result'}<strong>{resultValue}</strong>
|
||||
<p>
|
||||
{onresultclose
|
||||
? 'Cierra el dado para ver avanzar tu ficha.'
|
||||
: resultFeedback.next}
|
||||
</p>{:else if phase === 'waiting'}<p>
|
||||
{slow
|
||||
? 'La confirmación está tardando. Puedes cerrar y comprobar la conexión.'
|
||||
: 'Confirmando tu tirada…'}
|
||||
</p>{:else if phase === 'ready' || phase === 'shaking'}<p>
|
||||
Mantén pulsado para agitar el cubilete.<br />Suéltalo para lanzar el
|
||||
dado.
|
||||
</p>{/if}
|
||||
</div>
|
||||
{#if phase === 'ready' || phase === 'shaking'}<button
|
||||
class="button primary shake-button"
|
||||
onpointerdown={(e) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
shake();
|
||||
}}
|
||||
onpointerup={release}
|
||||
onpointercancel={cancelShake}
|
||||
onkeydown={(e) => {
|
||||
if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) {
|
||||
e.preventDefault();
|
||||
shake();
|
||||
}
|
||||
}}
|
||||
onkeyup={(e) => {
|
||||
if (e.key === ' ' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
release();
|
||||
}
|
||||
}}
|
||||
onblur={cancelShake}
|
||||
>{phase === 'shaking'
|
||||
? 'Suelta para lanzar'
|
||||
: 'Agitar cubilete'}</button
|
||||
>
|
||||
{:else if phase === 'result'}<button
|
||||
class="button secondary"
|
||||
onclick={close}>Volver al tablero<Icon name="arrow" size={17} /></button
|
||||
>{/if}
|
||||
</Dialog>{/if}
|
||||
{#if notice}<aside class="dice-notice" role="status" aria-live="polite">
|
||||
{@render figure()}
|
||||
<div>
|
||||
<strong>{feedback.title}</strong>
|
||||
<p>{feedback.next}</p>
|
||||
</div>
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="Cerrar aviso de tirada"
|
||||
onclick={() => (notice = false)}><Icon name="close" size={16} /></button
|
||||
>
|
||||
</aside>{/if}
|
||||
|
||||
<style>
|
||||
.dice-widget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
.dice-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.dice-result small {
|
||||
color: var(--secondary);
|
||||
font-size: var(--type-secondary, 14px);
|
||||
line-height: 1.4;
|
||||
max-width: 160px;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dice-stage {
|
||||
width: 82px;
|
||||
height: 90px;
|
||||
position: relative;
|
||||
perspective: 500px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.dice-cube {
|
||||
width: var(--edge, 52px);
|
||||
height: var(--edge, 52px);
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transform: rotateX(-58deg) rotateY(-22deg) rotateX(calc(90deg + var(--rx)))
|
||||
rotateY(var(--ry));
|
||||
transition: transform 0.25s ease-out;
|
||||
z-index: 1;
|
||||
}
|
||||
.dice-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
padding: 9px;
|
||||
gap: 3px;
|
||||
background: #fffdf7;
|
||||
border: 1px solid #cfd7e3;
|
||||
border-radius: 9px;
|
||||
box-shadow: inset 0 0 10px #8b9bb425;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
i {
|
||||
border-radius: 50%;
|
||||
background: #1c2c47;
|
||||
opacity: 0;
|
||||
box-shadow: inset 0 1px 2px #0005;
|
||||
}
|
||||
i.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
.face-1 {
|
||||
transform: translateZ(var(--depth, 26px));
|
||||
}
|
||||
.face-2 {
|
||||
transform: rotateY(90deg) translateZ(var(--depth, 26px));
|
||||
}
|
||||
.face-3 {
|
||||
transform: rotateX(90deg) translateZ(var(--depth, 26px));
|
||||
}
|
||||
.face-4 {
|
||||
transform: rotateX(-90deg) translateZ(var(--depth, 26px));
|
||||
}
|
||||
.face-5 {
|
||||
transform: rotateY(-90deg) translateZ(var(--depth, 26px));
|
||||
}
|
||||
.face-6 {
|
||||
transform: rotateY(180deg) translateZ(var(--depth, 26px));
|
||||
}
|
||||
.dice-shadow {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
width: 65px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #10274a24;
|
||||
filter: blur(3px);
|
||||
}
|
||||
.rolling .dice-cube {
|
||||
animation: tumble var(--roll-duration, 800ms) ease-out;
|
||||
}
|
||||
.rolling .dice-shadow {
|
||||
animation: shadow var(--roll-duration, 800ms) ease-out;
|
||||
}
|
||||
@keyframes tumble {
|
||||
0% {
|
||||
transform: translateY(0) rotateX(0) rotateY(0);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-27px) rotateX(170deg) rotateY(100deg);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-12px) rotateX(410deg) rotateY(280deg);
|
||||
}
|
||||
85% {
|
||||
transform: translateY(4px) rotateX(730deg) rotateY(520deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotateX(-58deg) rotateY(-22deg)
|
||||
rotateX(calc(90deg + var(--rx))) rotateY(var(--ry));
|
||||
}
|
||||
}
|
||||
@keyframes shadow {
|
||||
25% {
|
||||
transform: scale(0.65);
|
||||
opacity: 0.5;
|
||||
}
|
||||
85% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.large {
|
||||
--edge: 104px;
|
||||
--depth: 52px;
|
||||
width: 200px;
|
||||
height: 210px;
|
||||
}
|
||||
.large .dice-face {
|
||||
padding: 19px;
|
||||
gap: 7px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.large .dice-shadow {
|
||||
width: 132px;
|
||||
height: 22px;
|
||||
bottom: 12px;
|
||||
}
|
||||
.throw-scene {
|
||||
height: 245px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
margin: 15px auto 0;
|
||||
perspective: 600px;
|
||||
}
|
||||
.dice-cup {
|
||||
width: 135px;
|
||||
height: 158px;
|
||||
background: linear-gradient(100deg, #0a47a8, #2372e5 50%, #0d4cab);
|
||||
border-radius: 20px 20px 35px 35px;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
inset -10px 0 22px #002b6d55,
|
||||
6px 14px 24px #16326330;
|
||||
transform: rotate(-12deg);
|
||||
}
|
||||
.dice-cup > span {
|
||||
position: absolute;
|
||||
top: -13px;
|
||||
left: 0;
|
||||
width: 135px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: #143e77;
|
||||
border: 8px solid #4c8ced;
|
||||
box-shadow: inset 0 8px 10px #0005;
|
||||
}
|
||||
.dice-cup i {
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
inset: 40px 18px 20px;
|
||||
border: 2px solid #ffffff25;
|
||||
border-radius: 15px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.dice-cup b {
|
||||
position: absolute;
|
||||
inset: 52px 0 0;
|
||||
color: #ffce36;
|
||||
font-size: 48px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.cup-shadow {
|
||||
position: absolute;
|
||||
bottom: 27px;
|
||||
width: 145px;
|
||||
height: 23px;
|
||||
border-radius: 50%;
|
||||
background: #10274a22;
|
||||
filter: blur(6px);
|
||||
z-index: -1;
|
||||
}
|
||||
.shaking .dice-cup {
|
||||
animation: shake-cup var(--shake-duration, 180ms) ease-in-out infinite
|
||||
alternate;
|
||||
}
|
||||
.dropping .large {
|
||||
animation: drop-die var(--roll-duration, 800ms) ease-out;
|
||||
}
|
||||
.dice-announcement {
|
||||
min-height: 95px;
|
||||
}
|
||||
.dice-announcement > strong {
|
||||
display: block;
|
||||
font:
|
||||
800 52px 'Manrope',
|
||||
sans-serif;
|
||||
color: var(--accent-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.dice-announcement p {
|
||||
font-size: 14px;
|
||||
color: var(--secondary);
|
||||
}
|
||||
.shake-button {
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
min-width: 210px;
|
||||
}
|
||||
.close-dice {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
top: 16px;
|
||||
}
|
||||
.dice-notice {
|
||||
position: fixed;
|
||||
right: 22px;
|
||||
bottom: 22px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 390px;
|
||||
max-width: calc(100vw - 24px);
|
||||
padding: 12px 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: 0 12px 50px var(--shadow);
|
||||
animation: notice-enter 0.2s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dice-notice > div {
|
||||
min-width: 0;
|
||||
}
|
||||
.dice-notice strong {
|
||||
font-size: var(--type-body, 16px);
|
||||
}
|
||||
.dice-notice p {
|
||||
font-size: var(--type-secondary, 14px);
|
||||
line-height: 1.5;
|
||||
margin-top: 5px;
|
||||
color: var(--secondary);
|
||||
}
|
||||
.dice-notice button {
|
||||
pointer-events: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dice-notice .dice-stage {
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
}
|
||||
@keyframes notice-enter {
|
||||
from {
|
||||
transform: translateY(12px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes shake-cup {
|
||||
from {
|
||||
transform: translate(-15px, -4px) rotate(-24deg);
|
||||
}
|
||||
to {
|
||||
transform: translate(15px, -12px) rotate(12deg);
|
||||
}
|
||||
}
|
||||
@keyframes drop-die {
|
||||
0% {
|
||||
transform: translateY(-100px) scale(0.4);
|
||||
opacity: 0.2;
|
||||
}
|
||||
65% {
|
||||
transform: translateY(12px) scale(1.08);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rolling .dice-cube,
|
||||
.rolling .dice-shadow {
|
||||
animation: none;
|
||||
}
|
||||
.dice-cube {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.dice-widget {
|
||||
gap: 12px;
|
||||
}
|
||||
.dice-result small {
|
||||
max-width: 90px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
let { game, large = false }: { game: string; large?: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<div class:large class="game-art {game}" aria-hidden="true">
|
||||
{#if game === 'ajedrez'}
|
||||
<div class="mini-board">
|
||||
{#each Array(16) as _, i}<span
|
||||
class:dark={(Math.floor(i / 4) + (i % 4)) % 2 === 1}
|
||||
>{#if i === 5}<b class="white-piece">♘</b>{:else if i === 10}<b>♟</b
|
||||
>{:else if i === 2}<b>♜</b>{/if}</span
|
||||
>{/each}
|
||||
</div>
|
||||
<span class="loose-piece">♔</span>
|
||||
{:else if game === 'damas'}
|
||||
<div class="mini-board">
|
||||
{#each Array(16) as _, i}<span
|
||||
class:dark={(Math.floor(i / 4) + (i % 4)) % 2 === 1}
|
||||
>{#if [3, 6, 9].includes(i)}<i class="checker light"
|
||||
></i>{:else if [1, 14].includes(i)}<i class="checker"
|
||||
></i>{/if}</span
|
||||
>{/each}
|
||||
</div>
|
||||
<i class="loose-checker"></i>
|
||||
{:else if game === 'go'}
|
||||
<svg class="mini-go" viewBox="0 0 180 180"
|
||||
><rect
|
||||
width="180"
|
||||
height="180"
|
||||
rx="12"
|
||||
fill="#e4bd7c"
|
||||
/>{#each Array(5) as _, n}<path
|
||||
d={`M20 ${20 + n * 35}H160 M${20 + n * 35} 20V160`}
|
||||
stroke="#866334"
|
||||
stroke-width="1.2"
|
||||
/>{/each}{#each [[55, 55, 1], [90, 55, 2], [55, 90, 2], [90, 90, 1], [125, 125, 1]] as stone}<circle
|
||||
cx={stone[0] + 2}
|
||||
cy={stone[1] + 3}
|
||||
r="15"
|
||||
fill="#55371133"
|
||||
/><circle
|
||||
cx={stone[0]}
|
||||
cy={stone[1]}
|
||||
r="15"
|
||||
fill={stone[2] === 1 ? '#293039' : '#f9fafb'}
|
||||
stroke={stone[2] === 1 ? '#10171e' : '#b6b4b0'}
|
||||
/>{/each}</svg
|
||||
>
|
||||
{:else if game === 'oca'}
|
||||
<svg class="mini-oca" viewBox="0 0 180 180"
|
||||
><rect
|
||||
width="180"
|
||||
height="180"
|
||||
rx="12"
|
||||
fill="#f8f1df"
|
||||
/>{#each Array(16) as _, n}<rect
|
||||
x={(n % 4) * 42 + 7}
|
||||
y={Math.floor(n / 4) * 42 + 7}
|
||||
width="39"
|
||||
height="39"
|
||||
rx="5"
|
||||
fill={n % 4 === 0 ? '#e6efca' : '#f2d7aa'}
|
||||
stroke="#c8bd9b"
|
||||
/><text
|
||||
x={(n % 4) * 42 + 16}
|
||||
y={Math.floor(n / 4) * 42 + 23}
|
||||
font-size="10"
|
||||
fill="#806d47">{n + 1}</text
|
||||
>{/each}<path
|
||||
d="M62 104C54 79 83 71 96 84L97 56C98 35 121 33 126 46L125 61L139 67L123 72L121 99C119 127 67 132 62 104Z"
|
||||
fill="#fffef5"
|
||||
stroke="#659551"
|
||||
stroke-width="3"
|
||||
/><circle cx="118" cy="49" r="3" fill="#354829" /><path
|
||||
d="M73 91Q78 113 98 102"
|
||||
stroke="#8cac78"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
/></svg
|
||||
>
|
||||
{:else}
|
||||
<div class="parchis-art">
|
||||
<div class="home yellow"><i></i><i></i><i></i><i></i></div>
|
||||
<div class="home blue"><i></i><i></i><i></i><i></i></div>
|
||||
<div class="home red"><i></i><i></i><i></i><i></i></div>
|
||||
<div class="home green"><i></i><i></i><i></i><i></i></div>
|
||||
<div class="center-diamond"></div>
|
||||
</div>
|
||||
<div class="die"><i></i><i></i><i></i><i></i><i></i></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.game-art {
|
||||
height: 190px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
background: #e8eee6;
|
||||
}
|
||||
.game-art::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(#223f3012 0.8px, transparent 0.8px);
|
||||
background-size: 10px 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.game-art.damas {
|
||||
background: #f0e6d6;
|
||||
}
|
||||
.game-art.parchis {
|
||||
background: #e9e5f2;
|
||||
}
|
||||
.game-art.go {
|
||||
background: #ede4d5;
|
||||
}
|
||||
.game-art.oca {
|
||||
background: #e5efdb;
|
||||
}
|
||||
.mini-go,
|
||||
.mini-oca {
|
||||
width: 145px;
|
||||
height: 145px;
|
||||
transform: rotate(-12deg);
|
||||
box-shadow:
|
||||
5px 10px #c8bd9c,
|
||||
9px 20px 25px #51452c26;
|
||||
z-index: 1;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.mini-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
width: 142px;
|
||||
height: 142px;
|
||||
transform: rotate(-13deg) skew(-5deg, 3deg);
|
||||
border: 7px solid #ece3d0;
|
||||
border-radius: 5px;
|
||||
box-shadow:
|
||||
8px 15px 0 #d5cbb9,
|
||||
12px 28px 30px #29442b26;
|
||||
z-index: 1;
|
||||
}
|
||||
.mini-board span {
|
||||
background: #e7eadc;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
position: relative;
|
||||
}
|
||||
.mini-board span.dark {
|
||||
background: #748e75;
|
||||
}
|
||||
.mini-board b {
|
||||
font-size: 42px;
|
||||
font-family: Georgia, serif;
|
||||
font-weight: normal;
|
||||
line-height: 1;
|
||||
color: #273a2d;
|
||||
filter: drop-shadow(2px 3px 1px #0003);
|
||||
}
|
||||
.mini-board b.white-piece {
|
||||
color: #fffef6;
|
||||
text-shadow: 1px 1px #85977c;
|
||||
}
|
||||
.loose-piece {
|
||||
font-family: Georgia, serif;
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 22%;
|
||||
font-size: 84px;
|
||||
color: #fffef5;
|
||||
text-shadow: 1px 1px #8e9f83;
|
||||
filter: drop-shadow(4px 7px 2px #273b3230);
|
||||
transform: rotate(8deg);
|
||||
z-index: 2;
|
||||
}
|
||||
.damas .mini-board span {
|
||||
background: #f5e7cd;
|
||||
}
|
||||
.damas .mini-board span.dark {
|
||||
background: #9c7852;
|
||||
}
|
||||
.checker {
|
||||
width: 29px;
|
||||
height: 29px;
|
||||
background: #3b332d;
|
||||
border-radius: 50%;
|
||||
border: 3px double #897261;
|
||||
box-shadow: 0 4px 0 #28241f;
|
||||
display: block;
|
||||
}
|
||||
.checker.light {
|
||||
background: #d77b57;
|
||||
border-color: #eeab80;
|
||||
box-shadow: 0 4px 0 #975337;
|
||||
}
|
||||
.loose-checker {
|
||||
position: absolute;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
bottom: 27px;
|
||||
right: 21%;
|
||||
background: #d77b57;
|
||||
border-radius: 50%;
|
||||
border: 5px double #eeab80;
|
||||
box-shadow:
|
||||
0 7px 0 #975337,
|
||||
4px 15px 15px #6b432b35;
|
||||
z-index: 2;
|
||||
}
|
||||
.parchis-art {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
width: 145px;
|
||||
height: 145px;
|
||||
padding: 12px;
|
||||
background: #f9f7f0;
|
||||
transform: rotate(-12deg);
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
5px 10px 0 #d2cddd,
|
||||
9px 20px 25px #51457225;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.home {
|
||||
border-radius: 5px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
background: var(--color);
|
||||
}
|
||||
.home i {
|
||||
border-radius: 50%;
|
||||
background: #ffffffb5;
|
||||
box-shadow: 1px 2px #0002;
|
||||
}
|
||||
.yellow {
|
||||
--color: #e4bd57;
|
||||
}
|
||||
.blue {
|
||||
--color: #6c9cbe;
|
||||
}
|
||||
.red {
|
||||
--color: #c96b67;
|
||||
}
|
||||
.green {
|
||||
--color: #6b9c7b;
|
||||
}
|
||||
.center-diamond {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #f9f7f0;
|
||||
transform: rotate(45deg);
|
||||
position: absolute;
|
||||
left: 62px;
|
||||
top: 62px;
|
||||
border: 1px solid #e4ded8;
|
||||
}
|
||||
.die {
|
||||
position: absolute;
|
||||
right: 21%;
|
||||
bottom: 21px;
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
background: #fffdf7;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 3px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
2px 4px #c9c1d3,
|
||||
4px 10px 14px #40344d22;
|
||||
transform: rotate(12deg);
|
||||
z-index: 2;
|
||||
}
|
||||
.die i {
|
||||
background: #615177;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.die i:nth-child(2) {
|
||||
grid-column: 3;
|
||||
}
|
||||
.die i:nth-child(3) {
|
||||
grid-column: 2;
|
||||
}
|
||||
.die i:nth-child(4) {
|
||||
grid-column: 1;
|
||||
}
|
||||
.die i:nth-child(5) {
|
||||
grid-column: 3;
|
||||
}
|
||||
.large {
|
||||
height: 100%;
|
||||
background: transparent !important;
|
||||
}
|
||||
.large .mini-board {
|
||||
width: 224px;
|
||||
height: 224px;
|
||||
border-width: 10px;
|
||||
}
|
||||
.large .mini-board b {
|
||||
font-size: 65px;
|
||||
}
|
||||
.large .loose-piece {
|
||||
font-size: 125px;
|
||||
bottom: 4px;
|
||||
right: 8%;
|
||||
}
|
||||
.large::after {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.game-art {
|
||||
height: 165px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, signedIn } from '../client.svelte';
|
||||
import type { User } from '../types';
|
||||
import Dialog from './Dialog.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
let {onclose,onready}:{onclose:()=>void;onready:()=>void}=$props();
|
||||
let name=$state(''), pending=$state(false), error=$state('');
|
||||
async function enter(event:SubmitEvent){
|
||||
event.preventDefault();pending=true;error='';
|
||||
try{const {user}=await api<{user:User}>('/auth/guest',{name});signedIn(user);onready();}
|
||||
catch(e){error=(e as Error).message;}finally{pending=false;}
|
||||
}
|
||||
</script>
|
||||
<Dialog label="guest-title" {onclose}>
|
||||
<div class="modal-head"><div><span class="eyebrow">JUEGA EN COMPAÑÍA</span><h2 id="guest-title">¿Cómo te llamas?</h2></div><button class="icon-button" aria-label="Cerrar" onclick={onclose}><Icon name="close"/></button></div>
|
||||
<p class="muted small">Puedes jugar como invitado. Tu acceso quedará en este navegador y podrás registrar esta cuenta después.</p>
|
||||
<form onsubmit={enter}><label>Nombre al jugar<input bind:value={name} required minlength="2" maxlength="30" autocomplete="nickname" placeholder="Tu nombre"/></label>{#if error}<p class="form-error" role="alert">{error}</p>{/if}<button class="button primary full" disabled={pending}>{pending?'Preparando tu sitio…':'Entrar como invitado'}<Icon name="arrow" size={17}/></button></form>
|
||||
<button class="text-button" onclick={()=>{onclose();goto('/cuenta');}}>Prefiero registrarme</button>
|
||||
</Dialog>
|
||||
@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
let { name, size = 20 }: { name: string; size?: number } = $props();
|
||||
const paths: Record<string, string> = {
|
||||
menu: 'M4 6h16 M4 12h16 M4 18h16',
|
||||
refresh:
|
||||
'M20 7v-4l-3 3a8 8 0 0 0-13 6 M4 17v4l3-3a8 8 0 0 0 13-6 M20 3h-4 M4 21h4',
|
||||
chat: 'M21 11a8 8 0 0 1-8 8H6l-4 3V11a8 8 0 0 1 8-8h3a8 8 0 0 1 8 8z M7 10h9 M7 14h6',
|
||||
'chat-new':
|
||||
'M21 11a8 8 0 0 1-8 8H6l-4 3V11a8 8 0 0 1 8-8h3 M7 10h7 M7 14h6 M22 5a3 3 0 1 1-6 0 3 3 0 0 1 6 0z',
|
||||
expand: 'M8 3H3v5 M16 3h5v5 M3 16v5h5 M21 16v5h-5',
|
||||
grid: 'M3 3h7v7H3z M14 3h7v7h-7z M3 14h7v7H3z M14 14h7v7h-7z',
|
||||
trophy:
|
||||
'M8 3h8v8a4 4 0 0 1-8 0V3z M8 5H4v3a4 4 0 0 0 4 4 M16 5h4v3a4 4 0 0 1-4 4 M12 15v5 M8 21h8',
|
||||
user: 'M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0z M4 21v-2a8 8 0 0 1 16 0v2',
|
||||
help: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z M9 8a3 3 0 0 1 6 0c0 2-3 2-3 5 M12 17h.01',
|
||||
search: 'M20 20l-5-5 M17 10a7 7 0 1 1-14 0 7 7 0 0 1 14 0z',
|
||||
plus: 'M12 5v14 M5 12h14',
|
||||
arrow: 'M5 12h14 M13 6l6 6-6 6',
|
||||
clock: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20z M12 6v6l4 2',
|
||||
people:
|
||||
'M15 8a4 4 0 1 1-8 0 4 4 0 0 1 8 0z M2 21v-2a7 7 0 0 1 14 0v2 M17 4a4 4 0 0 1 0 8 M18 15a6 6 0 0 1 4 6',
|
||||
chevron: 'M9 5l7 7-7 7',
|
||||
close: 'M6 6l12 12 M18 6L6 18',
|
||||
lock: 'M5 10h14v11H5z M8 10V7a4 4 0 0 1 8 0v3',
|
||||
send: 'M22 2L9 15 M22 2l-7 20-6-7-7-6 20-7z',
|
||||
logout: 'M9 4H4v16h5 M10 12h11 M17 8l4 4-4 4',
|
||||
copy: 'M9 9h12v12H9z M5 15H3V3h12v2',
|
||||
check: 'M5 12l4 4L19 6',
|
||||
flag: 'M5 21V3 M5 3h13l-3 5 3 5H5',
|
||||
spark: 'M12 2l3 7 7 3-7 3-3 7-3-7-7-3 7-3z',
|
||||
volume: 'M3 9h4l5-4v14l-5-4H3z M16 8a5 5 0 0 1 0 8 M19 5a9 9 0 0 1 0 14',
|
||||
mute: 'M3 9h4l5-4v14l-5-4H3z M17 9l5 6 M22 9l-5 6',
|
||||
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M12 2v2 M12 20v2 M2 12h2 M20 12h2 M5 5l1.5 1.5 M17.5 17.5L19 19 M5 19l1.5-1.5 M17.5 6.5L19 5',
|
||||
moon: 'M20.8 13a9 9 0 1 1-9.8-9.8A7 7 0 0 0 20.8 13z',
|
||||
monitor: 'M3 4h18v13H3z M12 17v4 M8 21h8',
|
||||
};
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"><path d={paths[name] || paths.grid} /></svg
|
||||
>
|
||||
@ -0,0 +1,239 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, session, signedIn } from '../client.svelte';
|
||||
import type { User, Room, Definition } from '../types';
|
||||
import Dialog from './Dialog.svelte';
|
||||
import GameArt from './GameArt.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
import { invitationUrl } from '../../../shared/invitations.mjs';
|
||||
let { code, onclose }: { code: string; onclose: () => void } = $props();
|
||||
type Invite = {
|
||||
code: string;
|
||||
name: string;
|
||||
host: string;
|
||||
message: string;
|
||||
definition: Definition;
|
||||
available: boolean;
|
||||
isHost: boolean;
|
||||
alreadyJoined: boolean;
|
||||
players: number;
|
||||
maxPlayers: number;
|
||||
};
|
||||
let invitation = $state<Invite | null>(null),
|
||||
loading = $state(true),
|
||||
error = $state(''),
|
||||
step = $state('offer'),
|
||||
name = $state(''),
|
||||
pending = $state(false),
|
||||
copied = $state(false);
|
||||
onMount(() => {
|
||||
api<{ invitation: Invite }>(`/invites/${encodeURIComponent(code)}`)
|
||||
.then((result) => {
|
||||
invitation = result.invitation;
|
||||
})
|
||||
.catch((e) => (error = e.message))
|
||||
.finally(() => (loading = false));
|
||||
});
|
||||
async function enter(guest = false) {
|
||||
pending = true;
|
||||
error = '';
|
||||
try {
|
||||
if (guest) {
|
||||
const { user } = await api<{ user: User }>('/auth/guest', {
|
||||
name,
|
||||
code,
|
||||
});
|
||||
signedIn(user);
|
||||
}
|
||||
const { room } = await api<{ room: Room }>('/rooms/join', { code });
|
||||
onclose();
|
||||
await goto(`/salas/${room.id}`);
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
async function share() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(
|
||||
invitationUrl(code, location.origin, import.meta.env.PROD),
|
||||
);
|
||||
copied = true;
|
||||
} catch {
|
||||
error = `Comparte este código: ${code}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog label="invite-title" className="invitation-modal" {onclose}>
|
||||
<div class="modal-head">
|
||||
<h2 id="invite-title">
|
||||
{invitation?.isHost
|
||||
? 'Tu invitación está lista'
|
||||
: 'Te han reservado un sitio'}
|
||||
</h2>
|
||||
<button class="icon-button" aria-label="Cerrar invitación" onclick={onclose}
|
||||
><Icon name="close" /></button
|
||||
>
|
||||
</div>
|
||||
{#if loading}<p class="muted">Abriendo la invitación…</p>{:else if invitation}
|
||||
<div class="invitation-game">
|
||||
<div class="invitation-art">
|
||||
<GameArt game={invitation.definition.id} />
|
||||
</div>
|
||||
<div class="invitation-details">
|
||||
<strong>{invitation.name}</strong><span
|
||||
>{invitation.definition.name} · {invitation.players}/{invitation.maxPlayers}
|
||||
jugadores</span
|
||||
><span>Te invita {invitation.host}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if invitation.message}<blockquote class="invitation-message">
|
||||
{invitation.message}
|
||||
</blockquote>{/if}
|
||||
{#if invitation.isHost}<p class="invitation-note">
|
||||
Comparte este enlace con quien quieras invitar a la partida.
|
||||
</p>
|
||||
<button class="button primary full" onclick={share}
|
||||
><Icon name={copied ? 'check' : 'copy'} size={18} />{copied
|
||||
? 'Enlace copiado'
|
||||
: 'Copiar invitación'}</button
|
||||
>
|
||||
{:else if !invitation.available && !invitation.alreadyJoined}<p
|
||||
class="form-error"
|
||||
>
|
||||
Esta partida ya ha empezado o no quedan plazas.
|
||||
</p>
|
||||
{:else if step === 'offer'}<div class="form-row">
|
||||
<button class="button secondary" onclick={onclose}>Ahora no</button
|
||||
><button class="button primary" onclick={() => (step = 'identity')}
|
||||
>Aceptar invitación<Icon name="arrow" size={17} /></button
|
||||
>
|
||||
</div>
|
||||
{:else if session.user}<p class="muted small">
|
||||
Entrarás como {session.user.nick}{session.user.guest
|
||||
? ' · invitado'
|
||||
: ''}.
|
||||
</p>
|
||||
<button
|
||||
class="button primary full"
|
||||
disabled={pending}
|
||||
onclick={() => enter()}
|
||||
>{pending ? 'Entrando…' : 'Entrar en la partida'}</button
|
||||
>
|
||||
{:else if step === 'identity'}<h3>Elige cómo entrar</h3>
|
||||
<p class="muted small">
|
||||
El registro es opcional. Puedes crear una cuenta después.
|
||||
</p>
|
||||
<button class="button primary full" onclick={() => (step = 'guest')}
|
||||
>Entrar como invitado</button
|
||||
><a
|
||||
class="button secondary full"
|
||||
href={`/cuenta?invite=${encodeURIComponent(code)}`}
|
||||
>Crear una cuenta o iniciar sesión</a
|
||||
>
|
||||
{:else}<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
enter(true);
|
||||
}}
|
||||
>
|
||||
<label
|
||||
>Tu nombre al jugar<input
|
||||
required
|
||||
minlength="2"
|
||||
maxlength="30"
|
||||
bind:value={name}
|
||||
autocomplete="nickname"
|
||||
placeholder="¿Cómo te llamas?"
|
||||
/></label
|
||||
><button class="button primary full" disabled={pending}
|
||||
>{pending ? 'Entrando…' : 'Sentarme a jugar'}</button
|
||||
>
|
||||
</form>{/if}
|
||||
{/if}
|
||||
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
.modal-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 44px;
|
||||
align-items: start;
|
||||
gap: 16px;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
h2 {
|
||||
margin: 6px 0 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.invitation-game {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
.invitation-art {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.invitation-art :global(.game-art) {
|
||||
width: 190px;
|
||||
height: 190px;
|
||||
transform: scale(0.505263);
|
||||
transform-origin: top left;
|
||||
}
|
||||
.invitation-details {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.invitation-details strong {
|
||||
font-size: 18px;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.invitation-details span {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--secondary);
|
||||
}
|
||||
.invitation-message {
|
||||
margin: 0 0 24px;
|
||||
padding: 16px;
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 12px 12px 0;
|
||||
background: var(--soft);
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.invitation-note {
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--secondary);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.invitation-game {
|
||||
grid-template-columns: 80px minmax(0, 1fr);
|
||||
}
|
||||
.invitation-art {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.invitation-art :global(.game-art) {
|
||||
transform: scale(0.421053);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import type { Room, Action } from '../types';
|
||||
import Dice from './Dice.svelte';
|
||||
import { diceFeedback } from '../dice-feedback';
|
||||
import { playSound } from '../sound.svelte';
|
||||
import { OcaPlayback } from '../oca-playback';
|
||||
import {
|
||||
colorForPlayer,
|
||||
playerColorClass,
|
||||
} from '../../../shared/player-colors.mjs';
|
||||
import AvatarImage from './AvatarImage.svelte';
|
||||
import CellInfo from './CellInfo.svelte';
|
||||
import { ocaBoard, ocaCell } from '../../../shared/oca-board.mjs';
|
||||
let {
|
||||
room,
|
||||
disabled = false,
|
||||
onaction,
|
||||
}: {
|
||||
room: Room;
|
||||
disabled?: boolean;
|
||||
onaction: (action: Action) => void;
|
||||
} = $props();
|
||||
const lastRoll = $derived(
|
||||
room.events?.filter((e) => e.type === 'roll').at(-1)?.sequence ?? 0,
|
||||
);
|
||||
const feedback = $derived(diceFeedback(room));
|
||||
let displayed = $state<number[]>([]),
|
||||
busy = $state(false),
|
||||
hop = $state(0),
|
||||
hopPlayer = $state<number | null>(null),
|
||||
stepDuration = $state(160);
|
||||
let arrival = $state<string | null>(null);
|
||||
let arrivalPlayer = $state<number | null>(null);
|
||||
const playback = new OcaPlayback({
|
||||
frame: (positions, player, duration) => {
|
||||
displayed = positions;
|
||||
hopPlayer = player;
|
||||
stepDuration = duration;
|
||||
hop++;
|
||||
},
|
||||
busy: (value) => (busy = value),
|
||||
sound: (definition, event) => void playSound(definition, event),
|
||||
arrival: (player, effect) => {
|
||||
arrivalPlayer = player;
|
||||
arrival = effect;
|
||||
hopPlayer = null;
|
||||
},
|
||||
reducedMotion: () =>
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
});
|
||||
const artwork = $derived(room.definition?.board ?? ocaBoard);
|
||||
const viewport = $derived(artwork.viewport ?? ocaBoard.viewport);
|
||||
let hovered = $state<number | null>(null),
|
||||
selected = $state<number | null>(null);
|
||||
const inspected = $derived(selected ?? hovered);
|
||||
function position(n: number) {
|
||||
return artwork.centers[n];
|
||||
}
|
||||
function tile(n: number) {
|
||||
const [x, y] = position(n),
|
||||
width = n === 63 ? 248 : n < 3 ? 108 : 120,
|
||||
height = n === 63 ? 264 : 126;
|
||||
return { x: x - width / 2, y: y - height / 2, width, height };
|
||||
}
|
||||
function dismiss() {
|
||||
selected = null;
|
||||
hovered = null;
|
||||
}
|
||||
$effect(() => {
|
||||
room.id;
|
||||
lastRoll;
|
||||
untrack(() => playback.receive(room));
|
||||
});
|
||||
onDestroy(() => playback.dispose());
|
||||
</script>
|
||||
|
||||
<div class="table-game oca-table">
|
||||
<div class="board-surface illustrated-board">
|
||||
<svg
|
||||
class="game-map oca-map"
|
||||
viewBox={`${viewport.x} ${viewport.y} ${viewport.width} ${viewport.height}`}
|
||||
role="group"
|
||||
aria-label="Tablero de la oca: recorrido en espiral hasta el jardín de la casilla 63"
|
||||
>
|
||||
<defs
|
||||
><clipPath id="oca-cells-clip"
|
||||
>{#each artwork.centers as _, n}{@const rect = tile(n)}<rect
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
rx="8"
|
||||
/>{/each}</clipPath
|
||||
></defs
|
||||
>
|
||||
<image
|
||||
href={artwork.image}
|
||||
width={artwork.width}
|
||||
height={artwork.height}
|
||||
clip-path="url(#oca-cells-clip)"
|
||||
/>
|
||||
{#each artwork.centers as _, n}{@const rect = tile(n)}{@const info =
|
||||
ocaCell(n)}
|
||||
<g
|
||||
class="cell-hotspot"
|
||||
class:active={inspected === n}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={`${info.title}: ${info.description}`}
|
||||
aria-expanded={selected === n}
|
||||
onpointerenter={(e) => {
|
||||
if (e.pointerType === 'mouse') hovered = n;
|
||||
}}
|
||||
onpointerleave={() => (hovered = null)}
|
||||
onfocus={() => (hovered = n)}
|
||||
onblur={() => (hovered = null)}
|
||||
onclick={() => (selected = selected === n ? null : n)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selected = selected === n ? null : n;
|
||||
} else if (e.key === 'Escape') dismiss();
|
||||
}}
|
||||
>
|
||||
<rect
|
||||
class="cell-hit"
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
rx="10"
|
||||
/>
|
||||
</g>
|
||||
{/each}
|
||||
{#if inspected !== null}{@const rect = tile(inspected)}
|
||||
<g
|
||||
class="cell-enlargement"
|
||||
transform={`translate(${rect.x + rect.width / 2} ${rect.y + rect.height / 2}) scale(1.12) translate(${-rect.x - rect.width / 2} ${-rect.y - rect.height / 2})`}
|
||||
>
|
||||
<svg
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
viewBox={`${rect.x} ${rect.y} ${rect.width} ${rect.height}`}
|
||||
><image
|
||||
href={artwork.image}
|
||||
width={artwork.width}
|
||||
height={artwork.height}
|
||||
/></svg
|
||||
>
|
||||
<rect
|
||||
x={rect.x}
|
||||
y={rect.y}
|
||||
width={rect.width}
|
||||
height={rect.height}
|
||||
rx="10"
|
||||
fill="none"
|
||||
/>
|
||||
</g>
|
||||
{/if}
|
||||
{#each displayed as cell, seat (seat)}{@const point = position(cell)}
|
||||
<g
|
||||
animate:flip={{ duration: stepDuration, easing: (t) => t }}
|
||||
data-player={seat}
|
||||
data-cell={cell}
|
||||
style:display={room.players[seat]?.withdrawnAt ? 'none' : undefined}
|
||||
>
|
||||
{#key seat === hopPlayer ? hop : 0}
|
||||
<g
|
||||
class="token-body"
|
||||
class:hopping={seat === hopPlayer && stepDuration > 0}
|
||||
class:bonus={seat === arrivalPlayer && arrival === 'bonus'}
|
||||
class:penalty={seat === arrivalPlayer && arrival === 'penalty'}
|
||||
class:goal={seat === arrivalPlayer && arrival === 'goal'}
|
||||
style={`--hop-duration:${stepDuration}ms;--arrival-duration:${room.definition?.animations?.[arrival ?? 'move']?.duration ?? 450}ms`}
|
||||
>
|
||||
<circle
|
||||
cx={point[0] - 24 + (seat % 2) * 48}
|
||||
cy={point[1] + 10 + Math.floor(seat / 2) * 48}
|
||||
r="23"
|
||||
fill={colorForPlayer(room.players[seat]).hex}
|
||||
stroke="#fffaf0"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<text
|
||||
x={point[0] - 24 + (seat % 2) * 48}
|
||||
y={point[1] + 18 + Math.floor(seat / 2) * 48}
|
||||
text-anchor="middle"
|
||||
font-size="23"
|
||||
font-weight="700"
|
||||
fill="white">{seat + 1}</text
|
||||
>
|
||||
</g>
|
||||
{/key}
|
||||
</g>
|
||||
{/each}
|
||||
</svg>
|
||||
{#if inspected !== null}<CellInfo
|
||||
info={ocaCell(inspected)}
|
||||
x={((position(inspected)[0] - viewport.x) / viewport.width) * 100}
|
||||
y={((position(inspected)[1] - viewport.y) / viewport.height) * 100}
|
||||
pinned={selected !== null}
|
||||
onclose={dismiss}
|
||||
/>{/if}
|
||||
</div>
|
||||
<div class="game-controls oca-controls">
|
||||
<Dice
|
||||
value={room.state?.rolled}
|
||||
rollId={lastRoll}
|
||||
{feedback}
|
||||
definition={room.definition}
|
||||
disabled={disabled || busy}
|
||||
canRoll={room.legalActions?.some((a) => a.type === 'roll')}
|
||||
onroll={() => onaction({ type: 'roll' })}
|
||||
onresultclose={(id) => playback.release(Number(id))}
|
||||
/>
|
||||
<p class="oca-instruction">
|
||||
{#if room.state?.rolled}<span class="last-roll-caption"
|
||||
>{feedback.title}</span
|
||||
>{/if}{feedback.next}
|
||||
</p>
|
||||
<p class="oca-note">{room.state?.note}</p>
|
||||
<div class="table-players">
|
||||
{#each room.players as player}<div
|
||||
class="table-player {playerColorClass(player)}"
|
||||
>
|
||||
<span class="mini-avatar"><AvatarImage person={player} /></span
|
||||
><strong
|
||||
>{player.nick}{player.seat === room.seat ? ' · tú' : ''}</strong
|
||||
><small
|
||||
>{#if player.withdrawnAt}Se ha retirado{:else}Casilla {room.state
|
||||
?.positions?.[player.seat] ?? 0}{room.state?.skips?.[
|
||||
player.seat
|
||||
]
|
||||
? ` · ${room.state.skips[player.seat]} descansos`
|
||||
: ''}{/if}</small
|
||||
>
|
||||
</div>{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.illustrated-board {
|
||||
position: relative;
|
||||
}
|
||||
.oca-map {
|
||||
overflow: visible;
|
||||
}
|
||||
.cell-hit {
|
||||
fill: transparent;
|
||||
stroke: transparent;
|
||||
stroke-width: 3;
|
||||
}
|
||||
.cell-hotspot {
|
||||
cursor: help;
|
||||
outline: none;
|
||||
}
|
||||
.cell-hotspot:focus-visible .cell-hit,
|
||||
.cell-hotspot.active .cell-hit {
|
||||
stroke: var(--accent);
|
||||
}
|
||||
.cell-enlargement {
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 8px 10px #27365250);
|
||||
}
|
||||
.cell-enlargement > rect {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 4;
|
||||
}
|
||||
@media (hover: none), (prefers-reduced-motion: reduce) {
|
||||
.cell-enlargement {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
.token-body {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
.hopping {
|
||||
animation: cell-hop var(--hop-duration) ease-in-out;
|
||||
}
|
||||
@keyframes cell-hop {
|
||||
50% {
|
||||
transform: translateY(-30px) scale(1.08);
|
||||
}
|
||||
}
|
||||
.bonus,
|
||||
.goal {
|
||||
animation: arrive-jump var(--arrival-duration) ease-out;
|
||||
}
|
||||
.penalty {
|
||||
animation: arrive-shake var(--arrival-duration) ease-out;
|
||||
}
|
||||
@keyframes arrive-jump {
|
||||
30% {
|
||||
transform: translateY(-12px) scale(1.15);
|
||||
}
|
||||
65% {
|
||||
transform: translateY(0) scale(0.9);
|
||||
}
|
||||
}
|
||||
@keyframes arrive-shake {
|
||||
25%,
|
||||
75% {
|
||||
transform: translateX(-7px);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(7px);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.token-body {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,434 @@
|
||||
<script lang="ts">
|
||||
import { flip } from 'svelte/animate';
|
||||
import type { Room, Action } from '../types';
|
||||
import CellInfo from './CellInfo.svelte';
|
||||
let {
|
||||
room,
|
||||
disabled = false,
|
||||
onaction,
|
||||
}: {
|
||||
room: Room;
|
||||
disabled?: boolean;
|
||||
onaction: (action: Action) => void;
|
||||
} = $props();
|
||||
const goal = $derived(room.state?.goal ?? 71),
|
||||
outer = $derived(room.state?.outerLength ?? 64);
|
||||
const colors = ['#b88925', '#4e84a6', '#b95f56', '#568264'];
|
||||
const houseStarts = [4, 21, 38, 55];
|
||||
function houseForSeat(seat: number) {
|
||||
return houseStarts.indexOf(room.state?.starts?.[seat] ?? houseStarts[seat]);
|
||||
}
|
||||
const homes = [
|
||||
[11, 11],
|
||||
[0, 11],
|
||||
[0, 0],
|
||||
[11, 0],
|
||||
];
|
||||
const route: number[][] = [];
|
||||
for (let x = 11; x < 19; x++) route.push([x, 8]);
|
||||
route.push([18, 9]);
|
||||
for (let x = 18; x > 10; x--) route.push([x, 10]);
|
||||
for (let y = 11; y < 19; y++) route.push([10, y]);
|
||||
route.push([9, 18]);
|
||||
for (let y = 18; y > 10; y--) route.push([8, y]);
|
||||
for (let x = 7; x >= 0; x--) route.push([x, 10]);
|
||||
route.push([0, 9]);
|
||||
for (let x = 0; x < 8; x++) route.push([x, 8]);
|
||||
for (let y = 7; y >= 0; y--) route.push([8, y]);
|
||||
route.push([9, 0]);
|
||||
for (let y = 0; y < 8; y++) route.push([10, y]);
|
||||
const track = Array.from({ length: 68 }, (_, i) => route[(i + 8) % 68]);
|
||||
let hovered = $state<string | null>(null),
|
||||
selected = $state<string | null>(null);
|
||||
const inspected = $derived(selected ?? hovered);
|
||||
function describeCell(id: string) {
|
||||
const [type, first, second] = id.split('-'),
|
||||
index = Number(first);
|
||||
if (type === 'goal')
|
||||
return {
|
||||
point: [285, 285],
|
||||
info: {
|
||||
title: 'Meta',
|
||||
kind: 'advantage',
|
||||
description:
|
||||
'Debes llegar con el número exacto. Cada ficha que entra da 10 pasos de premio. Ganas al llevar las cuatro.',
|
||||
},
|
||||
};
|
||||
if (type === 'lane') {
|
||||
const cell = lane(index, Number(second));
|
||||
return {
|
||||
point: [cell[0] * 30 + 15, cell[1] * 30 + 15],
|
||||
info: {
|
||||
title: `Pasillo ${['amarillo', 'azul', 'rojo', 'verde'][index]} · ${Number(second) + 1}`,
|
||||
kind: 'advantage',
|
||||
description:
|
||||
'Solo entran las fichas de este color. Estás protegido de las capturas; necesitas la cuenta exacta para llegar a meta.',
|
||||
},
|
||||
};
|
||||
}
|
||||
const safe = room.state?.safe?.includes(index),
|
||||
exit = houseStarts.includes(index),
|
||||
cell = track[index];
|
||||
return {
|
||||
point: [cell[0] * 30 + 15, cell[1] * 30 + 15],
|
||||
info: {
|
||||
title: `Casilla ${index + 1}${exit ? ' · Salida' : safe ? ' · Seguro' : ''}`,
|
||||
kind: safe ? 'advantage' : 'danger',
|
||||
description: exit
|
||||
? 'Sales de casa con un 5. Si la salida está ocupada por dos fichas, al salir puedes capturar al último rival que llegó.'
|
||||
: safe
|
||||
? 'Aquí no pueden capturarte. Pueden compartirla dos fichas; dos del mismo color forman una barrera.'
|
||||
: 'Si un rival llega a esta casilla, captura tu ficha y la devuelve a casa; recibe 20 pasos de premio.',
|
||||
},
|
||||
};
|
||||
}
|
||||
function dismiss() {
|
||||
hovered = null;
|
||||
selected = null;
|
||||
}
|
||||
function keyInfo(e: KeyboardEvent, id: string) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selected = selected === id ? null : id;
|
||||
} else if (e.key === 'Escape') dismiss();
|
||||
}
|
||||
function lane(seat: number, step: number) {
|
||||
const edge = outer === 65 ? 17 : 18,
|
||||
begin = outer === 65 ? 1 : 0;
|
||||
return [
|
||||
[edge - step, 9],
|
||||
[9, edge - step],
|
||||
[begin + step, 9],
|
||||
[9, begin + step],
|
||||
][seat];
|
||||
}
|
||||
function point(seat: number, piece: number, progress: number) {
|
||||
const house = houseForSeat(seat);
|
||||
if (progress === -1)
|
||||
return [
|
||||
homes[house][0] * 30 + 85 + (piece % 2) * 70,
|
||||
homes[house][1] * 30 + 85 + Math.floor(piece / 2) * 70,
|
||||
];
|
||||
if (progress === goal)
|
||||
return [
|
||||
[315, 285],
|
||||
[285, 315],
|
||||
[255, 285],
|
||||
[285, 255],
|
||||
][house].map(
|
||||
(v, i) => v + (i === 0 ? piece % 2 : Math.floor(piece / 2)) * 8 - 4,
|
||||
);
|
||||
const cell =
|
||||
progress >= outer
|
||||
? lane(house, progress - outer)
|
||||
: track[((room.state?.starts?.[seat] ?? 0) + progress) % 68];
|
||||
const others = room.state?.pieces?.[seat] ?? [];
|
||||
const shared = others.filter((p) => p === progress).length > 1;
|
||||
return [
|
||||
cell[0] * 30 +
|
||||
15 +
|
||||
(shared ? (others.indexOf(progress) === piece ? -5 : 5) : 0),
|
||||
cell[1] * 30 + 15,
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="parchis-inspection">
|
||||
<svg
|
||||
class="parchis-map"
|
||||
viewBox="-8 -8 586 586"
|
||||
role="group"
|
||||
aria-label="Tablero de parchís: cuatro casas y un recorrido de 68 casillas. Puedes seleccionar las fichas resaltadas."
|
||||
>
|
||||
<rect
|
||||
x="-6"
|
||||
y="-6"
|
||||
width="582"
|
||||
height="582"
|
||||
rx="18"
|
||||
fill="#f5f1e7"
|
||||
stroke="#ded8c9"
|
||||
stroke-width="2"
|
||||
/>
|
||||
{#each homes as home, seat}
|
||||
{@const owner = room.players.find((p) => houseForSeat(p.seat) === seat)}
|
||||
<g opacity={owner ? 1 : 0.45}>
|
||||
<rect
|
||||
x={home[0] * 30 + 8}
|
||||
y={home[1] * 30 + 8}
|
||||
width="224"
|
||||
height="224"
|
||||
rx="25"
|
||||
fill={colors[seat]}
|
||||
fill-opacity="0.16"
|
||||
/>
|
||||
<rect
|
||||
x={home[0] * 30 + 45}
|
||||
y={home[1] * 30 + 45}
|
||||
width="150"
|
||||
height="150"
|
||||
rx="35"
|
||||
fill="#fffdf7"
|
||||
stroke={colors[seat]}
|
||||
stroke-opacity="0.4"
|
||||
stroke-width="2"
|
||||
/>
|
||||
{#each Array(4) as _, i}<circle
|
||||
cx={home[0] * 30 + 85 + (i % 2) * 70}
|
||||
cy={home[1] * 30 + 85 + Math.floor(i / 2) * 70}
|
||||
r="22"
|
||||
fill={colors[seat]}
|
||||
fill-opacity="0.11"
|
||||
/>{/each}
|
||||
<text
|
||||
x={home[0] * 30 + 120}
|
||||
y={home[1] * 30 + 216}
|
||||
text-anchor="middle"
|
||||
fill={colors[seat]}
|
||||
font-size="13"
|
||||
font-weight="700">{owner?.nick.slice(0, 20) ?? 'Casa libre'}</text
|
||||
>
|
||||
</g>
|
||||
{/each}
|
||||
{#each track as cell, i}
|
||||
{@const seat = houseStarts.indexOf(i)}
|
||||
{@const safe = room.state?.safe?.includes(i)}
|
||||
{@const id = `track-${i}`}
|
||||
<g
|
||||
class="cell-hotspot"
|
||||
class:active={inspected === id}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={describeCell(id).info.title}
|
||||
aria-expanded={selected === id}
|
||||
onpointerenter={(e) => {
|
||||
if (e.pointerType === 'mouse') hovered = id;
|
||||
}}
|
||||
onpointerleave={() => (hovered = null)}
|
||||
onfocus={() => (hovered = id)}
|
||||
onblur={() => (hovered = null)}
|
||||
onclick={() => (selected = selected === id ? null : id)}
|
||||
onkeydown={(e) => keyInfo(e, id)}
|
||||
>
|
||||
<rect
|
||||
x={cell[0] * 30 + 1}
|
||||
y={cell[1] * 30 + 1}
|
||||
width="28"
|
||||
height="28"
|
||||
rx="3"
|
||||
fill={seat >= 0 ? colors[seat] : safe ? '#e1e8d8' : '#fffdf7'}
|
||||
stroke="#c8c9b9"
|
||||
stroke-width="0.8"
|
||||
/>
|
||||
<text
|
||||
x={cell[0] * 30 + 15}
|
||||
y={cell[1] * 30 + 19}
|
||||
text-anchor="middle"
|
||||
fill={seat >= 0 ? '#fff' : '#5c6855'}
|
||||
font-size="9"
|
||||
font-weight="600">{i + 1}</text
|
||||
>
|
||||
</g>
|
||||
{/each}
|
||||
{#each colors as color, seat}
|
||||
{#each Array(7) as _, step}{@const cell = lane(
|
||||
seat,
|
||||
step,
|
||||
)}{@const id = `lane-${seat}-${step}`}<g
|
||||
class="cell-hotspot"
|
||||
class:active={inspected === id}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={describeCell(id).info.title}
|
||||
aria-expanded={selected === id}
|
||||
onpointerenter={(e) => {
|
||||
if (e.pointerType === 'mouse') hovered = id;
|
||||
}}
|
||||
onpointerleave={() => (hovered = null)}
|
||||
onfocus={() => (hovered = id)}
|
||||
onblur={() => (hovered = null)}
|
||||
onclick={() => (selected = selected === id ? null : id)}
|
||||
onkeydown={(e) => keyInfo(e, id)}
|
||||
><rect
|
||||
x={cell[0] * 30 + 1}
|
||||
y={cell[1] * 30 + 1}
|
||||
width="28"
|
||||
height="28"
|
||||
rx="3"
|
||||
fill={color}
|
||||
fill-opacity="0.28"
|
||||
stroke={color}
|
||||
stroke-opacity="0.5"
|
||||
stroke-width="0.8"
|
||||
/></g
|
||||
>{/each}
|
||||
{/each}
|
||||
<path d="M240 240L285 285L330 240Z" fill={colors[3]} fill-opacity="0.8" />
|
||||
<path d="M330 240L285 285L330 330Z" fill={colors[0]} fill-opacity="0.8" />
|
||||
<path d="M330 330L285 285L240 330Z" fill={colors[1]} fill-opacity="0.8" />
|
||||
<path d="M240 330L285 285L240 240Z" fill={colors[2]} fill-opacity="0.8" />
|
||||
<g
|
||||
class="cell-hotspot"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Meta del parchís"
|
||||
onpointerenter={(e) => {
|
||||
if (e.pointerType === 'mouse') hovered = 'goal';
|
||||
}}
|
||||
onpointerleave={() => (hovered = null)}
|
||||
onfocus={() => (hovered = 'goal')}
|
||||
onblur={() => (hovered = null)}
|
||||
onclick={() => (selected = selected === 'goal' ? null : 'goal')}
|
||||
onkeydown={(e) => keyInfo(e, 'goal')}
|
||||
><circle cx="285" cy="285" r="13" fill="#fffdf7" />
|
||||
<text x="285" y="290" text-anchor="middle" fill="#6b7957" font-size="17"
|
||||
>✳</text
|
||||
>
|
||||
</g>
|
||||
{#each room.state?.pieces ?? [] as pieces, seat}
|
||||
{@const color = colors[houseForSeat(seat)]}
|
||||
{#each pieces as progress, piece (piece)}{@const pos = point(
|
||||
seat,
|
||||
piece,
|
||||
progress,
|
||||
)}
|
||||
{@const playable =
|
||||
!disabled &&
|
||||
seat === room.seat &&
|
||||
room.legalActions?.some(
|
||||
(a) => a.type === 'move' && a.piece === piece,
|
||||
)}
|
||||
<g
|
||||
animate:flip={{
|
||||
duration: room.definition?.animations?.move?.duration ?? 450,
|
||||
}}
|
||||
role="button"
|
||||
tabindex={playable ? 0 : -1}
|
||||
aria-disabled={!playable}
|
||||
aria-label={`Mover ficha ${piece + 1} de ${room.players[seat].nick}`}
|
||||
style:display={room.players[seat]?.withdrawnAt ? 'none' : undefined}
|
||||
class:playable
|
||||
onclick={() => {
|
||||
if (playable) onaction({ type: 'move', piece });
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
if (playable && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onaction({ type: 'move', piece });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title
|
||||
>Ficha {piece + 1} de {room.players[seat].nick}: {progress === -1
|
||||
? 'en casa'
|
||||
: progress === goal
|
||||
? 'en meta'
|
||||
: progress >= outer
|
||||
? 'pasillo final'
|
||||
: `casilla ${(((room.state?.starts?.[seat] ?? 0) + progress) % 68) + 1}`}</title
|
||||
>
|
||||
{#if playable}<circle
|
||||
cx={pos[0]}
|
||||
cy={pos[1]}
|
||||
r={progress === -1 ? 25 : 17}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
stroke-width="3"
|
||||
class="piece-halo"
|
||||
/>{/if}
|
||||
<circle
|
||||
cx={pos[0] + 1}
|
||||
cy={pos[1] + 2}
|
||||
r={progress === -1 ? 19 : 12}
|
||||
fill="#253324"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
<circle
|
||||
cx={pos[0]}
|
||||
cy={pos[1]}
|
||||
r={progress === -1 ? 19 : 12}
|
||||
fill={color}
|
||||
stroke="#fffdf7"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle
|
||||
cx={pos[0]}
|
||||
cy={pos[1]}
|
||||
r={progress === -1 ? 14 : 8}
|
||||
fill="none"
|
||||
stroke="#fffdf7"
|
||||
stroke-opacity="0.4"
|
||||
/>
|
||||
<text
|
||||
x={pos[0]}
|
||||
y={pos[1] + 4}
|
||||
text-anchor="middle"
|
||||
fill="#fff"
|
||||
font-size={progress === -1 ? 15 : 11}
|
||||
font-weight="700">{progress === goal ? '✓' : piece + 1}</text
|
||||
>
|
||||
</g>
|
||||
{/each}
|
||||
{/each}
|
||||
</svg>
|
||||
{#if inspected}{@const detail = describeCell(inspected)}<CellInfo
|
||||
info={detail.info}
|
||||
x={((detail.point[0] + 8) / 586) * 100}
|
||||
y={((detail.point[1] + 8) / 586) * 100}
|
||||
pinned={selected !== null}
|
||||
onclose={dismiss}
|
||||
/>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.parchis-inspection {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.cell-hotspot {
|
||||
cursor: help;
|
||||
outline: none;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
.cell-hotspot text {
|
||||
pointer-events: none;
|
||||
}
|
||||
.cell-hotspot:focus-visible rect,
|
||||
.cell-hotspot.active rect {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 2;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.cell-hotspot:hover {
|
||||
transform: scale(1.18);
|
||||
filter: drop-shadow(0 2px 2px #27365240);
|
||||
}
|
||||
}
|
||||
g.playable {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
g.playable:focus-visible .piece-halo {
|
||||
stroke: #13284a;
|
||||
stroke-width: 5;
|
||||
}
|
||||
.piece-halo {
|
||||
animation: piece-pulse 1.3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes piece-pulse {
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cell-hotspot:hover {
|
||||
transform: none;
|
||||
}
|
||||
.piece-halo {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import type { Profile } from '../types';
|
||||
import { definitions } from '../client.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
let {
|
||||
rooms,
|
||||
currentId = '',
|
||||
}: { rooms: Profile['pending']; currentId?: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="pending-games">
|
||||
{#if !rooms.length}<p class="pending-empty">
|
||||
No tienes partidas pendientes. Puedes crear una desde el catálogo.
|
||||
</p>
|
||||
{:else}<div class="pending-games-list">
|
||||
{#each rooms as game (game.id)}
|
||||
<a
|
||||
class="pending-game"
|
||||
class:current={game.id === currentId}
|
||||
href={`/salas/${game.id}`}
|
||||
aria-current={game.id === currentId ? 'page' : undefined}
|
||||
>
|
||||
<span
|
||||
><strong>{game.name}</strong><small
|
||||
>{definitions.find((d) => d.id === game.gameId)?.name ??
|
||||
game.gameId}</small
|
||||
><span class="pending-status" class:your-turn={game.myTurn}
|
||||
>{game.status === 'waiting'
|
||||
? 'Sala de espera'
|
||||
: game.myTurn
|
||||
? 'Tu turno'
|
||||
: 'Partida en curso'}{game.id === currentId
|
||||
? ' · Estás aquí'
|
||||
: ''}</span
|
||||
></span
|
||||
>
|
||||
<Icon name="chevron" size={18} />
|
||||
</a>
|
||||
{/each}
|
||||
</div>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pending-games {
|
||||
container-type: inline-size;
|
||||
}
|
||||
.pending-games-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.pending-game {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.pending-game:hover,
|
||||
.pending-game.current {
|
||||
border-color: var(--accent);
|
||||
background: var(--soft);
|
||||
}
|
||||
.pending-game > span {
|
||||
min-width: 0;
|
||||
}
|
||||
.pending-game strong {
|
||||
display: block;
|
||||
font-size: var(--type-body);
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pending-game small {
|
||||
display: block;
|
||||
font-size: var(--type-secondary);
|
||||
color: var(--secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.pending-status {
|
||||
display: block;
|
||||
font-size: var(--type-detail);
|
||||
color: var(--secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.pending-status.your-turn {
|
||||
color: var(--accent-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
.pending-empty {
|
||||
font-size: var(--type-secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
@container (min-width:640px) {
|
||||
.pending-games-list {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
import type { Room } from '../types';
|
||||
import {
|
||||
paletteForRoom,
|
||||
colorForPlayer,
|
||||
} from '../../../shared/player-colors.mjs';
|
||||
let {
|
||||
room,
|
||||
disabled = false,
|
||||
onchoose,
|
||||
}: {
|
||||
room: Room;
|
||||
disabled?: boolean;
|
||||
onchoose: (colorId: string) => void;
|
||||
} = $props();
|
||||
const me = $derived(room.players.find((p) => p.seat === room.seat));
|
||||
const automatic = $derived(room.seat === room.maxPlayers - 1);
|
||||
</script>
|
||||
|
||||
<section class="color-chooser" aria-labelledby="piece-color-title">
|
||||
<h3 id="piece-color-title">Color de tus fichas</h3>
|
||||
{#if automatic}<div class="automatic-color">
|
||||
{#if me?.colorId}<i style:background={colorForPlayer(me).hex}></i><strong
|
||||
>{colorForPlayer(me).label}</strong
|
||||
>{/if}
|
||||
<p>
|
||||
{me?.colorId
|
||||
? 'Tu color se asigna automáticamente al ser el último jugador.'
|
||||
: 'Tu color se asignará cuando los demás elijan.'}
|
||||
</p>
|
||||
</div>
|
||||
{:else}<div class="color-options">
|
||||
{#each paletteForRoom(room) as color}
|
||||
{@const owner = room.players.find(
|
||||
(p) =>
|
||||
p.seat !== room.seat &&
|
||||
p.seat !== room.maxPlayers - 1 &&
|
||||
p.colorId === color.id,
|
||||
)}
|
||||
<button
|
||||
class:chosen={me?.colorId === color.id}
|
||||
disabled={disabled || !!owner}
|
||||
aria-pressed={me?.colorId === color.id}
|
||||
title={owner ? `Elegido por ${owner.nick}` : color.label}
|
||||
onclick={() => onchoose(color.id)}
|
||||
><i style:background={color.hex}></i><span>{color.label}</span
|
||||
></button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="color-hint">
|
||||
Los colores elegidos por otras personas quedan reservados.
|
||||
</p>{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.color-chooser {
|
||||
margin: 24px 0;
|
||||
text-align: left;
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--type-body);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.color-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.color-options button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
min-height: 48px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: var(--type-secondary);
|
||||
}
|
||||
.color-options button.chosen {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
.color-options button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
i {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset 0 0 0 2px #ffffff70;
|
||||
}
|
||||
.color-hint,
|
||||
.automatic-color p {
|
||||
font-size: var(--type-secondary);
|
||||
color: var(--secondary);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.automatic-color {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.automatic-color p {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@media (max-width: 650px) {
|
||||
.color-options {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '../client.svelte';
|
||||
import type { Room } from '../types';
|
||||
import {
|
||||
canCloseInactive,
|
||||
roomDeadlines,
|
||||
} from '../../../shared/room-lifecycle.mjs';
|
||||
import Dialog from './Dialog.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
let { room, onrefresh }: { room: Room; onrefresh: () => void } = $props();
|
||||
let open = $state(false),
|
||||
pending = $state(false),
|
||||
error = $state(''),
|
||||
now = $state(Date.now());
|
||||
const me = $derived(room.players.find((p) => p.seat === room.seat));
|
||||
const host = $derived(room.createdBy === me?.userId);
|
||||
const deadlines = $derived(roomDeadlines(room));
|
||||
const eligible = $derived(canCloseInactive(room, room.seat ?? -1, now));
|
||||
const minutes = $derived(
|
||||
Math.max(0, Math.ceil((deadlines.inactiveCloseAt - now) / 60_000)),
|
||||
);
|
||||
const waitingForOther = $derived(
|
||||
room.state?.phase === 'scoring'
|
||||
? Boolean(
|
||||
room.state.accepted?.[room.seat ?? -1] &&
|
||||
room.state.accepted.some(
|
||||
(accepted, i) => i !== room.seat && !accepted,
|
||||
),
|
||||
)
|
||||
: room.state?.turn !== room.seat,
|
||||
);
|
||||
$effect(() => {
|
||||
if (room.status === 'finished' || me?.withdrawnAt) open = false;
|
||||
});
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => (now = Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
async function leave(mode: 'leave' | 'close-inactive') {
|
||||
pending = true;
|
||||
error = '';
|
||||
try {
|
||||
await api(`/rooms/${room.id}/${mode}`, { expectedVersion: room.version });
|
||||
open = false;
|
||||
await goto('/perfil');
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
onrefresh();
|
||||
} finally {
|
||||
pending = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if room.status !== 'finished' && !me?.withdrawnAt}
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="Salir de la partida"
|
||||
title="Salir de la partida"
|
||||
onclick={() => {
|
||||
error = '';
|
||||
open = true;
|
||||
}}><Icon name="logout" size={19} /></button
|
||||
>
|
||||
{/if}
|
||||
{#if open}
|
||||
<Dialog
|
||||
label="exit-title"
|
||||
className="exit-modal"
|
||||
onclose={() => {
|
||||
if (!pending) open = false;
|
||||
}}
|
||||
>
|
||||
<div class="modal-head">
|
||||
<h2 id="exit-title">Salir de la partida</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="Cerrar opciones de salida"
|
||||
disabled={pending}
|
||||
onclick={() => (open = false)}><Icon name="close" /></button
|
||||
>
|
||||
</div>
|
||||
<section class="exit-choice">
|
||||
<h3>
|
||||
{room.status === 'waiting'
|
||||
? host
|
||||
? 'Cancelar esta sala'
|
||||
: 'Dejar esta sala'
|
||||
: 'Abandonar la partida'}
|
||||
</h3>
|
||||
<p>
|
||||
{room.status === 'waiting'
|
||||
? host
|
||||
? 'Cerrarás la sala para todos. No se guardarán victorias ni derrotas.'
|
||||
: 'Tu plaza quedará libre. El resto podrá seguir preparando la partida.'
|
||||
: room.players.filter((p) => !p.withdrawnAt).length === 2
|
||||
? 'Te rendirás y el otro jugador ganará. Tu derrota se guardará en el historial.'
|
||||
: 'Te retirarás con una derrota. Tus fichas saldrán del tablero y los demás podrán continuar.'}
|
||||
</p>
|
||||
<button
|
||||
class="button danger full"
|
||||
disabled={pending}
|
||||
onclick={() => leave('leave')}
|
||||
>{pending
|
||||
? 'Guardando…'
|
||||
: room.status === 'waiting'
|
||||
? host
|
||||
? 'Cancelar sala y salir'
|
||||
: 'Dejar sala'
|
||||
: 'Abandonar y salir'}</button
|
||||
>
|
||||
</section>
|
||||
{#if room.status === 'playing'}
|
||||
<section class="exit-choice inactivity-choice">
|
||||
<h3>El otro jugador no responde</h3>
|
||||
<p>
|
||||
Cierra la partida sin resultado después de 10 minutos esperando una
|
||||
jugada de otro jugador.
|
||||
</p>
|
||||
<p class="exit-status" role="status">
|
||||
{eligible
|
||||
? 'Ya puedes cerrarla por inactividad.'
|
||||
: waitingForOther
|
||||
? `Disponible en ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'} sin nuevas jugadas.`
|
||||
: 'Ahora te corresponde jugar.'}
|
||||
</p>
|
||||
<button
|
||||
class="button secondary full"
|
||||
disabled={pending || !eligible}
|
||||
onclick={() => leave('close-inactive')}>Cerrar por inactividad</button
|
||||
>
|
||||
</section>
|
||||
{/if}
|
||||
<p class="expiry-note">
|
||||
<Icon name="clock" size={16} />Las salas se cierran automáticamente tras
|
||||
24 horas sin actividad de juego.
|
||||
</p>
|
||||
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
|
||||
</Dialog>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
h2 {
|
||||
margin: 6px 0 0;
|
||||
font-size: 24px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.exit-choice {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.exit-choice p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
color: var(--secondary);
|
||||
}
|
||||
.inactivity-choice {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.exit-choice .exit-status {
|
||||
font-size: 14px;
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.expiry-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 24px 0 0;
|
||||
color: var(--secondary);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.expiry-note :global(svg) {
|
||||
flex: 0 0 16px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { sound, saveSound, unlockSound, playSound } from '../sound.svelte';
|
||||
import { definitions } from '../client.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
const game = $derived(
|
||||
definitions.find((g) => g.id === sound.previewGame) ?? definitions[0],
|
||||
);
|
||||
const names: Record<string, string> = {
|
||||
move: 'Mover',
|
||||
capture: 'Capturar',
|
||||
roll: 'Dado',
|
||||
shake: 'Cubilete',
|
||||
check: 'Jaque',
|
||||
promotion: 'Coronar',
|
||||
goal: 'Meta',
|
||||
pass: 'Turno',
|
||||
bonus: 'Oca',
|
||||
penalty: 'Penalización',
|
||||
play: 'Carta',
|
||||
trick: 'Baza',
|
||||
win: 'Victoria',
|
||||
};
|
||||
const samples = $derived(
|
||||
Object.keys(names).filter((key) => game?.sounds?.[key]),
|
||||
);
|
||||
function toggle() {
|
||||
sound.enabled = !sound.enabled;
|
||||
saveSound();
|
||||
if (sound.enabled) void unlockSound();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sound-control">
|
||||
<button
|
||||
class="settings-trigger"
|
||||
popovertarget="audio-settings"
|
||||
aria-label="Configurar sonidos"
|
||||
title={sound.enabled ? 'Sonidos activados' : 'Sonidos silenciados'}
|
||||
><Icon
|
||||
name={sound.enabled && sound.volume > 0 ? 'volume' : 'mute'}
|
||||
size={19}
|
||||
/></button
|
||||
>
|
||||
<div
|
||||
id="audio-settings"
|
||||
popover
|
||||
class="settings-popover sound-popover"
|
||||
role="dialog"
|
||||
aria-label="Sonidos de Juegoland"
|
||||
>
|
||||
<strong>Sonidos de juego</strong>
|
||||
<button class="sound-toggle" aria-pressed={sound.enabled} onclick={toggle}
|
||||
><Icon name={sound.enabled ? 'volume' : 'mute'} size={17} />{sound.enabled
|
||||
? 'Activados · silenciar'
|
||||
: 'Silenciados · activar'}</button
|
||||
>
|
||||
<label for="effect-volume"
|
||||
>Volumen <span>{Math.round(sound.volume * 100)} %</span></label
|
||||
>
|
||||
<input
|
||||
id="effect-volume"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
bind:value={sound.volume}
|
||||
oninput={saveSound}
|
||||
/>
|
||||
<label class="speech-option"
|
||||
><input
|
||||
type="checkbox"
|
||||
bind:checked={sound.announcements}
|
||||
onchange={saveSound}
|
||||
/>Leer el número del dado en voz alta</label
|
||||
>
|
||||
<label class="audio-preview-label" for="audio-game"
|
||||
>Escucha los efectos</label
|
||||
><select id="audio-game" bind:value={sound.previewGame}
|
||||
>{#each definitions as definition}<option value={definition.id}
|
||||
>{definition.name}</option
|
||||
>{/each}</select
|
||||
>
|
||||
<div class="audio-samples">
|
||||
{#each samples as sample}<button
|
||||
disabled={!sound.enabled || sound.volume === 0}
|
||||
onclick={async () => {
|
||||
await unlockSound();
|
||||
await playSound(game, sample);
|
||||
}}><Icon name="volume" size={16} />{names[sample]}</button
|
||||
>{/each}
|
||||
</div>
|
||||
{#if sound.error}<p role="status">{sound.error}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { acceptTerms, consent, session } from '#lib/client.svelte.ts';
|
||||
import { terms } from '../../../shared/terms.mjs';
|
||||
import Dialog from './Dialog.svelte';
|
||||
let { hasTermsCookie = false }: { hasTermsCookie?: boolean } = $props();
|
||||
let checked = $state(false),
|
||||
busy = $state(false),
|
||||
error = $state('');
|
||||
const visible = $derived(
|
||||
consent.reviewOpen ||
|
||||
(!consent.accepted &&
|
||||
!consent.dismissed &&
|
||||
(!session.loading || !hasTermsCookie)),
|
||||
);
|
||||
async function accept() {
|
||||
if (!checked || busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
await acceptTerms();
|
||||
checked = false;
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
function close() {
|
||||
if (busy) return;
|
||||
consent.reviewOpen = false;
|
||||
consent.dismissed = true;
|
||||
checked = false;
|
||||
error = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<Dialog label="terms-title" onclose={close} className="terms-consent">
|
||||
<h2 id="terms-title">Antes de jugar</h2>
|
||||
<p class="terms-author">{terms.introduction}</p>
|
||||
<p>{terms.notice}</p>
|
||||
<label class="terms-check">
|
||||
<input type="checkbox" bind:checked disabled={busy} />
|
||||
<span
|
||||
>Entiendo que es un servicio en desarrollo y acepto usarlo bajo mi
|
||||
responsabilidad.</span
|
||||
>
|
||||
</label>
|
||||
<p class="terms-cookie">
|
||||
Una cookie recordará tu aceptación durante un año.
|
||||
</p>
|
||||
<div class="terms-actions">
|
||||
<button class="button secondary" disabled={busy} onclick={close}
|
||||
>Ahora no</button
|
||||
>
|
||||
<button
|
||||
class="button primary"
|
||||
disabled={!checked || busy}
|
||||
onclick={accept}
|
||||
>
|
||||
{busy ? 'Guardando…' : 'Aceptar y entrar'}
|
||||
</button>
|
||||
</div>
|
||||
{#if error}<p class="form-error" role="alert">{error}</p>{/if}
|
||||
</Dialog>
|
||||
{/if}
|
||||
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { appearance, saveAppearance, type Mode, type Palette } from '../theme.svelte';
|
||||
import Icon from './Icon.svelte';
|
||||
const modes:{id:Mode;label:string;icon:string}[]=[{id:'light',label:'Claro',icon:'sun'},{id:'dark',label:'Oscuro',icon:'moon'},{id:'system',label:'Sistema',icon:'monitor'}];
|
||||
const palettes:{id:Palette;label:string;color:string}[]=[{id:'blue',label:'Azul',color:'#4263df'},{id:'forest',label:'Bosque',color:'#247761'},{id:'violet',label:'Violeta',color:'#8756ca'}];
|
||||
</script>
|
||||
<div class="theme-control">
|
||||
<button class="settings-trigger" popovertarget="appearance-settings" aria-label="Cambiar apariencia" title="Apariencia"><Icon name={appearance.mode==='dark'?'moon':'sun'} size={19}/></button>
|
||||
<div id="appearance-settings" popover class="settings-popover theme-popover" role="dialog" aria-label="Apariencia de Juegoland">
|
||||
<strong>A tu manera</strong>
|
||||
<span class="settings-label">Aspecto</span>
|
||||
<div class="theme-options">{#each modes as mode}<button class:chosen={appearance.mode===mode.id} aria-pressed={appearance.mode===mode.id} onclick={()=>{appearance.mode=mode.id;saveAppearance();}}><Icon name={mode.icon} size={17}/>{mode.label}</button>{/each}</div>
|
||||
<span class="settings-label">Color de la plataforma</span>
|
||||
<div class="palette-options">{#each palettes as palette}<button class:chosen={appearance.palette===palette.id} aria-label={`Paleta ${palette.label}`} aria-pressed={appearance.palette===palette.id} onclick={()=>{appearance.palette=palette.id;saveAppearance();}}><i style:background={palette.color}></i>{palette.label}</button>{/each}</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,39 @@
|
||||
import type { Room } from './types.js';
|
||||
|
||||
/** Ownership comes from the confirmed event, even when the roll changes the turn. */
|
||||
export function diceFeedback(room: Room) {
|
||||
const event = room.events?.filter((entry) => entry.type === 'roll').at(-1);
|
||||
const player = event?.actorId
|
||||
? room.players.find((entry) => entry.userId === event.actorId)
|
||||
: room.players.find((entry) => entry.nick === event?.nick);
|
||||
const own = player !== undefined && player.seat === room.seat;
|
||||
const myTurn = room.status === 'playing' && room.state?.turn === room.seat;
|
||||
const current = room.players[room.state?.turn ?? 0];
|
||||
let next = 'Partida terminada.';
|
||||
if (room.status === 'playing') {
|
||||
if (room.state?.die) {
|
||||
const advance = `${room.state.die} ${room.state.die === 1 ? 'casilla' : 'casillas'}`;
|
||||
next = myTurn
|
||||
? `Elige una ficha resaltada para avanzar ${advance}.`
|
||||
: `${current.nick} debe elegir una ficha para avanzar ${advance}.`;
|
||||
} else {
|
||||
next = myTurn
|
||||
? 'Es tu turno. Puedes lanzar el dado.'
|
||||
: `Ahora juega ${current.nick}. Espera su tirada.`;
|
||||
}
|
||||
}
|
||||
const penalty = event?.label.startsWith('Tercer seis')
|
||||
? own
|
||||
? 'Tercer seis: tu turno ha terminado. '
|
||||
: `Tercer seis: termina el turno de ${player?.nick}. `
|
||||
: '';
|
||||
return {
|
||||
own,
|
||||
actor: player?.nick ?? event?.nick ?? 'Otro jugador',
|
||||
title: own
|
||||
? `Has sacado un ${room.state?.rolled}`
|
||||
: `${player?.nick ?? event?.nick ?? 'Otro jugador'} ha sacado un ${room.state?.rolled}`,
|
||||
next: penalty + next,
|
||||
createdAt: event?.createdAt,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
import type { Room, Definition } from './types.js';
|
||||
|
||||
interface Throw {
|
||||
sequence: number;
|
||||
player: number;
|
||||
path: number[];
|
||||
released: boolean;
|
||||
effect: string | null;
|
||||
definition?: Definition;
|
||||
}
|
||||
/** Keeps authoritative results queued until their dice presentation has finished. */
|
||||
export class OcaPlayback {
|
||||
private previous: number | undefined;
|
||||
private roomId = '';
|
||||
private queue: Throw[] = [];
|
||||
private positions: number[] = [];
|
||||
private timers: ReturnType<typeof setTimeout>[] = [];
|
||||
private playing = false;
|
||||
constructor(
|
||||
private callbacks: {
|
||||
frame: (
|
||||
positions: number[],
|
||||
player: number | null,
|
||||
duration: number,
|
||||
) => void;
|
||||
busy: (value: boolean) => void;
|
||||
sound: (definition: Definition | undefined, event: string) => void;
|
||||
arrival: (player: number, effect: string | null) => void;
|
||||
reducedMotion: () => boolean;
|
||||
},
|
||||
) {}
|
||||
receive(room: Room) {
|
||||
const event = room.events?.filter((e) => e.type === 'roll').at(-1);
|
||||
const sequence = event?.sequence ?? 0;
|
||||
if (this.previous === undefined || this.roomId !== room.id) {
|
||||
this.dispose();
|
||||
this.roomId = room.id;
|
||||
this.previous = sequence;
|
||||
this.positions = [...(room.state?.positions ?? [])];
|
||||
this.callbacks.frame(this.positions, null, 0);
|
||||
return;
|
||||
}
|
||||
if (sequence <= this.previous) return;
|
||||
this.previous = sequence;
|
||||
if (
|
||||
!room.state?.lastPath?.length ||
|
||||
!event ||
|
||||
Date.now() - Date.parse(event.createdAt) > 5000
|
||||
) {
|
||||
if (!this.queue.length) {
|
||||
this.positions = [...(room.state?.positions ?? [])];
|
||||
this.callbacks.frame(this.positions, null, 0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const own =
|
||||
event.actorId === room.players.find((p) => p.seat === room.seat)?.userId;
|
||||
const player = room.state.lastPlayer ?? 0;
|
||||
const entry: Throw = {
|
||||
sequence,
|
||||
player,
|
||||
path: [...room.state.lastPath],
|
||||
released: false,
|
||||
effect: room.state.lastEffect ?? null,
|
||||
definition: room.definition,
|
||||
};
|
||||
this.queue.push(entry);
|
||||
this.callbacks.busy(true);
|
||||
if (!own)
|
||||
this.timers.push(
|
||||
setTimeout(
|
||||
() => this.release(sequence),
|
||||
room.definition?.animations?.roll?.duration ?? 800,
|
||||
),
|
||||
);
|
||||
}
|
||||
release(sequence: number) {
|
||||
const entry = this.queue.find((e) => e.sequence === sequence);
|
||||
if (entry) entry.released = true;
|
||||
this.pump();
|
||||
}
|
||||
private pump() {
|
||||
const entry = this.queue[0];
|
||||
if (this.playing || !entry?.released) return;
|
||||
this.playing = true;
|
||||
this.callbacks.arrival(entry.player, null);
|
||||
const path = entry.path.filter(
|
||||
(n, i, a) => n !== (i ? a[i - 1] : this.positions[entry.player]),
|
||||
);
|
||||
const reduced = this.callbacks.reducedMotion();
|
||||
const stepDuration = reduced
|
||||
? 0
|
||||
: Math.max(
|
||||
160,
|
||||
Math.min(
|
||||
260,
|
||||
(entry.definition?.animations?.move?.duration ?? 700) /
|
||||
Math.max(path.length, 1),
|
||||
),
|
||||
);
|
||||
if (path.length && !reduced) this.callbacks.sound(entry.definition, 'move');
|
||||
path.forEach((cell, index) =>
|
||||
this.timers.push(
|
||||
setTimeout(() => {
|
||||
this.positions = this.positions.map((p, seat) =>
|
||||
seat === entry.player ? cell : p,
|
||||
);
|
||||
this.callbacks.frame([...this.positions], entry.player, stepDuration);
|
||||
}, index * stepDuration),
|
||||
),
|
||||
);
|
||||
this.timers.push(
|
||||
setTimeout(() => {
|
||||
this.callbacks.arrival(entry.player, entry.effect);
|
||||
if (entry.effect) this.callbacks.sound(entry.definition, entry.effect);
|
||||
this.queue.shift();
|
||||
this.playing = false;
|
||||
this.callbacks.busy(this.queue.length > 0);
|
||||
this.pump();
|
||||
}, path.length * stepDuration),
|
||||
);
|
||||
}
|
||||
dispose() {
|
||||
this.timers.forEach(clearTimeout);
|
||||
this.timers = [];
|
||||
this.queue = [];
|
||||
this.playing = false;
|
||||
this.callbacks.busy(false);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
import type { Definition, Room } from './types';
|
||||
import { roomSound } from '../../shared/sound-event.mjs';
|
||||
|
||||
export const sound = $state({
|
||||
enabled: true,
|
||||
volume: 0.45,
|
||||
ready: false,
|
||||
error: '',
|
||||
announcements: false,
|
||||
previewGame: 'ajedrez',
|
||||
});
|
||||
let context: AudioContext | null = null;
|
||||
let active: AudioBufferSourceNode | null = null;
|
||||
const buffers = new Map<string, Promise<AudioBuffer>>();
|
||||
let generation = 0;
|
||||
|
||||
export async function unlockSound() {
|
||||
if (!sound.enabled) return;
|
||||
try {
|
||||
context ??= new AudioContext();
|
||||
if (context.state === 'suspended') await context.resume();
|
||||
sound.ready = context.state === 'running';
|
||||
} catch {
|
||||
sound.ready = false;
|
||||
}
|
||||
}
|
||||
export function installSound() {
|
||||
try {
|
||||
const saved = JSON.parse(
|
||||
localStorage.getItem('juegoland.sound') ??
|
||||
localStorage.getItem('mesa.sound') ??
|
||||
'null',
|
||||
);
|
||||
if (typeof saved?.announcements === 'boolean')
|
||||
sound.announcements = saved.announcements;
|
||||
if (typeof saved?.enabled === 'boolean') sound.enabled = saved.enabled;
|
||||
if (typeof saved?.volume === 'number' && Number.isFinite(saved.volume))
|
||||
sound.volume = Math.max(0, Math.min(1, saved.volume));
|
||||
} catch {
|
||||
/* Storage may be unavailable; use defaults. */
|
||||
}
|
||||
const unlock = () => {
|
||||
void unlockSound();
|
||||
};
|
||||
window.addEventListener('pointerdown', unlock, { passive: true });
|
||||
window.addEventListener('keydown', unlock);
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', unlock);
|
||||
window.removeEventListener('keydown', unlock);
|
||||
active?.stop();
|
||||
};
|
||||
}
|
||||
export function saveSound() {
|
||||
generation++;
|
||||
sound.error = '';
|
||||
if (!sound.enabled || sound.volume === 0) {
|
||||
active?.stop();
|
||||
active = null;
|
||||
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'juegoland.sound',
|
||||
JSON.stringify({
|
||||
enabled: sound.enabled,
|
||||
volume: sound.volume,
|
||||
announcements: sound.announcements,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* Preferences remain active in this tab. */
|
||||
}
|
||||
}
|
||||
export async function playSound(
|
||||
definition: Definition | undefined,
|
||||
event: string,
|
||||
) {
|
||||
const effect = definition?.sounds?.[event];
|
||||
if (
|
||||
!effect ||
|
||||
!sound.enabled ||
|
||||
sound.volume === 0 ||
|
||||
!context ||
|
||||
context.state !== 'running'
|
||||
)
|
||||
return;
|
||||
const url = new URL(effect.src, location.origin);
|
||||
if (url.origin !== location.origin) return;
|
||||
const requested = Date.now(),
|
||||
requestGeneration = generation;
|
||||
try {
|
||||
let buffer = buffers.get(url.href);
|
||||
if (!buffer) {
|
||||
buffer = fetch(url.href).then(async (response) => {
|
||||
if (!response.ok) throw new Error('Audio no disponible');
|
||||
return context!.decodeAudioData(await response.arrayBuffer());
|
||||
});
|
||||
buffers.set(url.href, buffer);
|
||||
buffer.catch(() => buffers.delete(url.href));
|
||||
}
|
||||
const decoded = await buffer;
|
||||
if (
|
||||
generation !== requestGeneration ||
|
||||
!sound.enabled ||
|
||||
!context ||
|
||||
context.state !== 'running' ||
|
||||
Date.now() - requested > 2000
|
||||
)
|
||||
return;
|
||||
active?.stop();
|
||||
const source = context.createBufferSource(),
|
||||
gain = context.createGain();
|
||||
source.buffer = decoded;
|
||||
gain.gain.value = sound.volume * Math.max(0, Math.min(1, effect.volume));
|
||||
source.connect(gain);
|
||||
gain.connect(context.destination);
|
||||
active = source;
|
||||
source.onended = () => {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
if (active === source) active = null;
|
||||
};
|
||||
source.start();
|
||||
sound.error = '';
|
||||
} catch {
|
||||
sound.error = 'Este sonido no está disponible.';
|
||||
}
|
||||
}
|
||||
export function playRoomSound(previous: Room | null, next: Room) {
|
||||
const event = roomSound(previous, next);
|
||||
if (event) void playSound(next.definition, event);
|
||||
}
|
||||
export function announceDice(
|
||||
definition: Definition | undefined,
|
||||
value: number,
|
||||
) {
|
||||
if (
|
||||
!sound.enabled ||
|
||||
!sound.announcements ||
|
||||
!sound.volume ||
|
||||
!definition?.sounds.roll?.announceNumber ||
|
||||
!('speechSynthesis' in window)
|
||||
)
|
||||
return;
|
||||
const utterance = new SpeechSynthesisUtterance(String(value));
|
||||
utterance.lang = definition.sounds.roll.language ?? 'es-ES';
|
||||
utterance.volume = sound.volume;
|
||||
utterance.rate = 0.9;
|
||||
window.speechSynthesis.cancel();
|
||||
window.speechSynthesis.speak(utterance);
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
export type Mode = 'light' | 'dark' | 'system';
|
||||
export type Palette = 'blue' | 'forest' | 'violet';
|
||||
export const appearance = $state<{mode:Mode;palette:Palette}>({mode:'system',palette:'blue'});
|
||||
let media: MediaQueryList | null = null;
|
||||
function apply() {
|
||||
document.documentElement.dataset.theme = appearance.mode==='system' ? media?.matches ? 'dark' : 'light' : appearance.mode;
|
||||
document.documentElement.dataset.palette = appearance.palette;
|
||||
}
|
||||
export function saveAppearance() {
|
||||
apply();
|
||||
try { localStorage.setItem('juegoland.appearance',JSON.stringify(appearance)); } catch { /* Use this session's preferences. */ }
|
||||
}
|
||||
export function installAppearance() {
|
||||
media = matchMedia('(prefers-color-scheme: dark)');
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem('juegoland.appearance')??localStorage.getItem('enronda.appearance')??'null');
|
||||
if(['light','dark','system'].includes(saved?.mode))appearance.mode=saved.mode;
|
||||
if(['blue','forest','violet'].includes(saved?.palette))appearance.palette=saved.palette;
|
||||
} catch { /* Defaults remain available. */ }
|
||||
apply();media.addEventListener('change',apply);
|
||||
return ()=>media?.removeEventListener('change',apply);
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
export interface User {
|
||||
avatar?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
nick: string;
|
||||
createdAt: string;
|
||||
guest: boolean;
|
||||
}
|
||||
export interface Definition {
|
||||
board?: {
|
||||
image: string;
|
||||
width: number;
|
||||
height: number;
|
||||
viewport?: { x: number; y: number; width: number; height: number };
|
||||
centers: number[][];
|
||||
};
|
||||
id: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
category: string;
|
||||
variant: string;
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
duration: string;
|
||||
difficulty: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
version: string;
|
||||
rules: string[];
|
||||
sounds: Record<
|
||||
string,
|
||||
{ src: string; volume: number; announceNumber?: boolean; language?: string }
|
||||
>;
|
||||
animations: Record<
|
||||
string,
|
||||
{ kind: string; duration: number; easing: string }
|
||||
>;
|
||||
}
|
||||
export interface Player {
|
||||
avatar?: string;
|
||||
colorId?: string;
|
||||
userId: string;
|
||||
nick: string;
|
||||
seat: number;
|
||||
ready: boolean;
|
||||
connected?: boolean;
|
||||
withdrawnAt?: string;
|
||||
outcome?: 'win' | 'loss' | 'draw';
|
||||
placement?: number;
|
||||
}
|
||||
export interface Action {
|
||||
type: string;
|
||||
from?: string | number;
|
||||
to?: string | number;
|
||||
promotion?: string;
|
||||
piece?: number;
|
||||
}
|
||||
export interface GameState {
|
||||
turn: number;
|
||||
chain?: number | null;
|
||||
board?:
|
||||
({ square: string; type: string; color: string } | null)[][] | number[];
|
||||
check?: boolean;
|
||||
pgn?: string;
|
||||
pieces?: number[][];
|
||||
die?: number | null;
|
||||
rolled?: number | null;
|
||||
starts?: number[];
|
||||
safe?: number[];
|
||||
bonus?: boolean;
|
||||
goal?: number;
|
||||
outerLength?: number;
|
||||
sixes?: number;
|
||||
grid?: number[];
|
||||
size?: number;
|
||||
phase?: string;
|
||||
dead?: number[];
|
||||
accepted?: boolean[];
|
||||
scores?: number[];
|
||||
positions?: number[];
|
||||
skips?: number[];
|
||||
note?: string;
|
||||
captures?: number[];
|
||||
komi?: number;
|
||||
lastPath?: number[];
|
||||
lastPlayer?: number | null;
|
||||
lastEffect?: string | null;
|
||||
geese?: number[];
|
||||
}
|
||||
export interface Room {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
gameId: string;
|
||||
gameVersion: string;
|
||||
status: 'waiting' | 'playing' | 'finished';
|
||||
visibility: string;
|
||||
maxPlayers: number;
|
||||
players: Player[];
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
lastGameActivityAt?: string;
|
||||
deadlines?: { inactiveCloseAt: number; autoCloseAt: number };
|
||||
finishedAt?: string;
|
||||
result?: { winner: number | null; reason: string; cancelled?: boolean };
|
||||
version: number;
|
||||
createdBy?: string;
|
||||
invitationMessage?: string;
|
||||
seat?: number;
|
||||
state?: GameState;
|
||||
legalActions?: Action[];
|
||||
events?: {
|
||||
sequence: number;
|
||||
nick: string;
|
||||
label: string;
|
||||
createdAt: string;
|
||||
sound?: string;
|
||||
type?: string;
|
||||
actorId?: string;
|
||||
animation?: string;
|
||||
}[];
|
||||
messages?: {
|
||||
id: string;
|
||||
userId: string;
|
||||
nick: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
}[];
|
||||
definition?: Definition;
|
||||
}
|
||||
export interface Stats {
|
||||
gameId: string;
|
||||
played: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
draws: number;
|
||||
}
|
||||
export interface Profile {
|
||||
pending: (Room & { myTurn: boolean })[];
|
||||
stats: Stats[];
|
||||
history: (Room & { myResult: Player })[];
|
||||
}
|
||||
@ -0,0 +1,601 @@
|
||||
/* The table has its own viewport; platform navigation opens on demand. */
|
||||
.match-shell {
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.match-shell .main-shell {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.match-shell .topbar {
|
||||
height: 52px;
|
||||
flex: 0 0 52px;
|
||||
padding: 0 18px;
|
||||
gap: 14px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.match-shell .mobile-logo {
|
||||
display: block;
|
||||
width: 128px;
|
||||
margin-right: auto;
|
||||
}
|
||||
.match-shell .header-right {
|
||||
gap: 12px;
|
||||
}
|
||||
.match-shell .header-right .small-avatar {
|
||||
display: none;
|
||||
}
|
||||
.match-shell main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.match-shell .site-footer {
|
||||
display: none;
|
||||
}
|
||||
.match-shell .sidebar:not(:popover-open) {
|
||||
display: none;
|
||||
}
|
||||
.match-shell .sidebar:popover-open {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
inset: 60px auto 12px 12px;
|
||||
margin: 0;
|
||||
height: auto;
|
||||
width: 260px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 16px 54px var(--shadow);
|
||||
z-index: 50;
|
||||
overflow: auto;
|
||||
animation: panel-enter 0.18s ease-out;
|
||||
}
|
||||
.match-shell .sidebar .brand {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin: 0 0 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.match-shell .sidebar .brand-logo {
|
||||
width: 100%;
|
||||
}
|
||||
.match-shell .sidebar .brand small,
|
||||
.match-shell .sidebar-label,
|
||||
.match-shell .sidebar nav a span,
|
||||
.match-shell .sidebar-invite,
|
||||
.match-shell .sidebar-bottom {
|
||||
display: block;
|
||||
}
|
||||
.match-shell .sidebar nav {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.match-shell .sidebar nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
padding: 14px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.match-shell .sidebar-bottom {
|
||||
margin-top: auto;
|
||||
padding-top: 18px;
|
||||
}
|
||||
.match-shell .sidebar-invite {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.match-shell .settings-popover {
|
||||
top: 58px;
|
||||
}
|
||||
.match-room {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: radial-gradient(
|
||||
ellipse at 50% 40%,
|
||||
var(--soft),
|
||||
var(--background) 75%
|
||||
);
|
||||
}
|
||||
.match-room .room-page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 0;
|
||||
padding: 10px 20px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.room-page-heading > div:first-child {
|
||||
min-width: 0;
|
||||
}
|
||||
.match-room .room-page-heading .eyebrow {
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.match-room .room-page-heading h1 {
|
||||
font-size: 17px;
|
||||
letter-spacing: -0.4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 36vw;
|
||||
}
|
||||
.match-turn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 12px;
|
||||
background: var(--player-soft);
|
||||
color: var(--player-ink);
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.match-turn i,
|
||||
.panel-players i {
|
||||
display: block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--player-solid);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.room-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.match-room .room-tools .button {
|
||||
padding: 9px 12px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.room-tools .icon-button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
.panel-trigger b {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.match-room .room-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
position: relative;
|
||||
padding: 12px 18px 8px;
|
||||
transition: padding-right 0.18s ease-out;
|
||||
}
|
||||
.match-room .play-area {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
container-type: size;
|
||||
}
|
||||
.match-room .parchis-board {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 210px;
|
||||
gap: 24px;
|
||||
}
|
||||
.board-surface {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
container-type: size;
|
||||
}
|
||||
.match-room .parchis-map {
|
||||
width: min(100cqw, 100cqh);
|
||||
height: min(100cqw, 100cqh);
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
filter: drop-shadow(0 10px 18px var(--shadow));
|
||||
}
|
||||
.match-room .parchis-controls {
|
||||
margin: 0;
|
||||
padding: 20px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
box-shadow: 0 4px 24px var(--shadow);
|
||||
}
|
||||
.match-room .parchis-controls .dice-widget {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.match-room .parchis-controls p {
|
||||
font-size: 12px;
|
||||
flex: none;
|
||||
}
|
||||
.table-players {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.table-player {
|
||||
display: grid;
|
||||
grid-template-columns: 25px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.table-player.playing {
|
||||
opacity: 1;
|
||||
}
|
||||
.table-player .mini-avatar {
|
||||
grid-row: span 2;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
font-size: 10px;
|
||||
background: var(--player-soft);
|
||||
color: var(--player-ink);
|
||||
}
|
||||
.table-player strong {
|
||||
font-size: 11px;
|
||||
color: var(--player-ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.table-player small {
|
||||
font-size: 10px;
|
||||
color: var(--secondary);
|
||||
}
|
||||
.piece-shortcuts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.piece-shortcuts > span {
|
||||
width: 100%;
|
||||
font-size: 10px;
|
||||
color: var(--secondary);
|
||||
}
|
||||
.piece-shortcuts .parchis-token {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.match-room .board-note {
|
||||
font-size: 11px;
|
||||
margin: 8px 0 0;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.match-room .board-note > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
.match-room .parchis-board + .board-note {
|
||||
display: none;
|
||||
}
|
||||
.match-room .board-container {
|
||||
padding: 8px;
|
||||
margin: auto;
|
||||
max-width: none;
|
||||
width: min(100cqw, calc(100cqh - 34px));
|
||||
aspect-ratio: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.match-room .board-grid {
|
||||
height: 100%;
|
||||
grid-template-rows: repeat(8, minmax(0, 1fr));
|
||||
}
|
||||
.match-room .chess-piece {
|
||||
font-size: clamp(16px, 8cqmin, 80px);
|
||||
}
|
||||
.match-room .board-square {
|
||||
aspect-ratio: auto;
|
||||
}
|
||||
.match-room .promotion {
|
||||
position: absolute;
|
||||
bottom: 15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10;
|
||||
}
|
||||
.match-room .room-panel:not(:popover-open) {
|
||||
display: none;
|
||||
}
|
||||
.match-room .room-panel:popover-open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 310px;
|
||||
max-width: calc(100vw - 24px);
|
||||
position: fixed;
|
||||
inset: 124px 18px 14px auto;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
z-index: 20;
|
||||
background: var(--surface);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 6px 30px var(--shadow);
|
||||
animation: panel-enter 0.18s ease-out;
|
||||
}
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 8px;
|
||||
}
|
||||
.panel-heading strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
.panel-players {
|
||||
display: flex;
|
||||
gap: 8px 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 16px 10px;
|
||||
}
|
||||
.panel-players span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--player-ink);
|
||||
font-size: 10px;
|
||||
}
|
||||
.match-room .room-tabs {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.match-room .chat-messages,
|
||||
.match-room .room-rules,
|
||||
.match-room .event-list {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
.match-room .chat-form {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.match-room .waiting-card {
|
||||
height: 100%;
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
.match-room .result-banner {
|
||||
padding: 12px 20px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.match-room .result-banner .empty-symbol {
|
||||
display: none;
|
||||
}
|
||||
.match-room .result-banner h2 {
|
||||
font-size: 15px;
|
||||
}
|
||||
.match-room .result-banner p {
|
||||
font-size: 12px;
|
||||
}
|
||||
.match-room .result-banner .button {
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
.match-room .form-error {
|
||||
margin: 8px 18px 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (min-width: 850px) {
|
||||
.match-room .room-layout:has(.room-panel:popover-open) {
|
||||
padding-right: 346px;
|
||||
}
|
||||
.match-room .room-layout:has(.room-panel:popover-open) .parchis-board {
|
||||
grid-template-columns: minmax(0, 1fr) 180px;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 850px) and (max-width: 1199px) {
|
||||
.match-room .room-layout:has(.room-panel:popover-open) .parchis-board {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
}
|
||||
.match-room .room-layout:has(.room-panel:popover-open) .parchis-controls {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 180px minmax(0, 1fr);
|
||||
gap: 6px 14px;
|
||||
}
|
||||
.match-room
|
||||
.room-layout:has(.room-panel:popover-open)
|
||||
.parchis-controls
|
||||
> .eyebrow,
|
||||
.match-room .room-layout:has(.room-panel:popover-open) .table-players {
|
||||
display: none;
|
||||
}
|
||||
.match-room .room-layout:has(.room-panel:popover-open) .dice-widget {
|
||||
flex-direction: row;
|
||||
grid-row: span 2;
|
||||
}
|
||||
}
|
||||
@media (max-width: 800px) and (orientation: portrait) {
|
||||
.match-room .parchis-board {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
.match-room .parchis-controls {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 6px 12px;
|
||||
align-self: stretch;
|
||||
}
|
||||
.match-room .parchis-controls > .eyebrow {
|
||||
display: none;
|
||||
}
|
||||
.match-room .parchis-controls .dice-widget {
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
grid-row: span 2;
|
||||
}
|
||||
.match-room .parchis-controls .dice-widget .dice-stage {
|
||||
width: 54px;
|
||||
height: 65px;
|
||||
--edge: 36px;
|
||||
--depth: 18px;
|
||||
}
|
||||
.match-room .parchis-controls .dice-widget .dice-face {
|
||||
padding: 6px;
|
||||
gap: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.match-room .parchis-controls .button {
|
||||
font-size: 11px;
|
||||
padding: 10px;
|
||||
}
|
||||
.match-room .parchis-controls .dice-instruction {
|
||||
font-size: 11px;
|
||||
align-self: center;
|
||||
}
|
||||
.match-room .table-players {
|
||||
display: none;
|
||||
}
|
||||
.match-room .piece-shortcuts {
|
||||
gap: 6px;
|
||||
}
|
||||
.match-room .piece-shortcuts > span {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@media (max-width: 650px) {
|
||||
.match-shell .topbar {
|
||||
padding: 0 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
.match-shell .header-right {
|
||||
gap: 8px;
|
||||
}
|
||||
.match-shell .connection {
|
||||
font-size: 0;
|
||||
gap: 0;
|
||||
}
|
||||
.match-shell .mobile-logo {
|
||||
width: 114px;
|
||||
}
|
||||
.match-room .room-page-heading {
|
||||
padding: 8px 12px;
|
||||
gap: 6px 8px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
.match-room .room-page-heading > div:first-child {
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
}
|
||||
.match-room .room-tools {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
.match-room .room-page-heading h1 {
|
||||
font-size: 14px;
|
||||
max-width: 45vw;
|
||||
}
|
||||
.match-turn {
|
||||
padding: 5px 0;
|
||||
font-size: 11px;
|
||||
grid-row: 2;
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
.room-tools {
|
||||
gap: 4px;
|
||||
}
|
||||
.room-tools .fullscreen-trigger {
|
||||
display: none;
|
||||
}
|
||||
.match-room .panel-trigger {
|
||||
padding: 8px;
|
||||
}
|
||||
.panel-trigger > span {
|
||||
display: none;
|
||||
}
|
||||
.match-room .room-layout {
|
||||
padding: 10px 8px 8px;
|
||||
}
|
||||
.match-room .room-panel:popover-open {
|
||||
inset: 142px 8px 8px auto;
|
||||
width: 340px;
|
||||
}
|
||||
.match-room .board-note {
|
||||
font-size: 10px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
@media (orientation: landscape) and (max-height: 520px) {
|
||||
.match-shell .topbar {
|
||||
height: 44px;
|
||||
flex-basis: 44px;
|
||||
}
|
||||
.match-room .room-page-heading {
|
||||
padding: 5px 12px;
|
||||
}
|
||||
.match-room .parchis-board {
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr) 180px;
|
||||
}
|
||||
.match-room .parchis-controls {
|
||||
padding: 10px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
.match-room .table-players {
|
||||
display: none;
|
||||
}
|
||||
.match-room .parchis-controls .dice-widget {
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
.match-room .parchis-controls .dice-widget .dice-stage {
|
||||
--edge: 36px;
|
||||
--depth: 18px;
|
||||
width: 48px;
|
||||
height: 60px;
|
||||
}
|
||||
.match-room .parchis-controls .button {
|
||||
padding: 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<svelte:head><title>No encontramos esta mesa · Juegoland</title></svelte:head
|
||||
>
|
||||
<div class="empty-panel">
|
||||
<span class="empty-symbol">♧</span>
|
||||
<h1>{page.error?.status ?? 404}</h1>
|
||||
<h2>{page.error?.message ?? 'No encontramos esta página.'}</h2>
|
||||
<p>Vuelve al catálogo para encontrar tu próxima partida.</p>
|
||||
<a href="/" class="button primary">Volver al catálogo</a>
|
||||
</div>
|
||||
@ -0,0 +1,9 @@
|
||||
import { hasCurrentTermsCookie } from '../../shared/terms.mjs';
|
||||
|
||||
export function load({ cookies, setHeaders }) {
|
||||
setHeaders({ 'cache-control': 'private, no-store' });
|
||||
// This avoids a popup flash for returning visitors. It never authorizes play.
|
||||
return {
|
||||
hasTermsCookie: hasCurrentTermsCookie(cookies.get('juegoland_terms') ?? ''),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { initialize, session, signOut } from '#lib/client.svelte.ts';
|
||||
import Icon from '#lib/components/Icon.svelte';
|
||||
import SoundControls from '#lib/components/SoundControls.svelte';
|
||||
import { installSound } from '#lib/sound.svelte.ts';
|
||||
import { installAppearance } from '#lib/theme.svelte.ts';
|
||||
import ThemeControls from '#lib/components/ThemeControls.svelte';
|
||||
import BrandMark from '#lib/components/BrandMark.svelte';
|
||||
import AvatarImage from '#lib/components/AvatarImage.svelte';
|
||||
import { brand } from '../../shared/brand.mjs';
|
||||
import '../app.css';
|
||||
import '../appearance.css';
|
||||
import '../match.css';
|
||||
import '../design.css';
|
||||
import '../terms.css';
|
||||
import TermsConsent from '#lib/components/TermsConsent.svelte';
|
||||
import { terms } from '../../shared/terms.mjs';
|
||||
let { children, data } = $props();
|
||||
let startupError = $state('');
|
||||
const inRoom = $derived(page.url.pathname.startsWith('/salas/'));
|
||||
onMount(() => {
|
||||
const stopSound = installSound();
|
||||
const stopAppearance = installAppearance();
|
||||
initialize().catch(() => {
|
||||
startupError =
|
||||
'No se pudo conectar. Comprueba que el servidor está en marcha.';
|
||||
});
|
||||
return () => {
|
||||
stopSound();
|
||||
stopAppearance();
|
||||
};
|
||||
});
|
||||
async function logout() {
|
||||
try {
|
||||
await signOut();
|
||||
await goto('/');
|
||||
} catch {
|
||||
startupError = 'No se pudo cerrar la sesión. Inténtalo de nuevo.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="es_ES" />
|
||||
<meta property="og:site_name" content="Juegoland" />
|
||||
<meta property="og:title" content="Juegoland · ¿Echamos una partida?" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Juegos de mesa online para jugar con tu gente. Entra como invitado, crea una mesa e invita a tus amigos."
|
||||
/>
|
||||
<meta property="og:url" content={brand.invitationOrigin + '/'} />
|
||||
<meta
|
||||
property="og:image"
|
||||
content={brand.invitationOrigin + '/branding/juegoland-share-v1.png'}
|
||||
/>
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta
|
||||
property="og:image:alt"
|
||||
content="Juegoland. ¿Echamos una partida? Ajedrez, damas, parchís, go y la oca para jugar en compañía."
|
||||
/>
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Juegoland · ¿Echamos una partida?" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Entra, invita y juega en compañía. Ajedrez, damas, parchís, go y la oca."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content={brand.invitationOrigin + '/branding/juegoland-share-v1.png'}
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="app-shell" class:match-shell={inRoom}>
|
||||
<aside
|
||||
class="sidebar"
|
||||
id="platform-menu"
|
||||
popover={inRoom ? 'auto' : undefined}
|
||||
>
|
||||
{#if inRoom}<button
|
||||
class="settings-trigger close-platform-menu"
|
||||
aria-label="Cerrar menú principal"
|
||||
popovertarget="platform-menu"
|
||||
popovertargetaction="hide"><Icon name="close" /></button
|
||||
>{/if}
|
||||
<a class="brand" href="/"><BrandMark /><small>{brand.tagline}</small></a>
|
||||
{#if !inRoom}<div class="sidebar-label">Tu próxima ronda</div>{/if}
|
||||
<nav aria-label="Navegación principal">
|
||||
<a href="/" class:active={page.url.pathname === '/'}
|
||||
><Icon name="grid" /><span>Descubrir juegos</span></a
|
||||
>
|
||||
<a href="/perfil" class:active={page.url.pathname === '/perfil'}
|
||||
><Icon name="trophy" /><span>Mis partidas</span></a
|
||||
>
|
||||
<a href="/ayuda" class:active={page.url.pathname === '/ayuda'}
|
||||
><Icon name="help" /><span>Reglas y ayuda</span></a
|
||||
>
|
||||
</nav>
|
||||
<div class="sidebar-invite">
|
||||
<span class="invite-ornament">♧</span><strong>Mejor en compañía.</strong>
|
||||
<p>Crea una mesa y comparte el código con tus amigos.</p>
|
||||
<a href="/ayuda">Así funciona <Icon name="arrow" size={15} /></a>
|
||||
</div>
|
||||
<div class="sidebar-bottom">
|
||||
{#if session.user}<a class="user-card" href="/perfil"
|
||||
><span class="avatar"><AvatarImage person={session.user} /></span
|
||||
><span
|
||||
><strong>{session.user.nick}</strong><small
|
||||
>Tu perfil de jugador</small
|
||||
></span
|
||||
></a
|
||||
><button class="logout" onclick={logout}
|
||||
><Icon name="logout" size={16} />Cerrar sesión</button
|
||||
>{:else}<a class="button primary full" href="/cuenta"
|
||||
>Únete a la mesa <Icon name="arrow" size={16} /></a
|
||||
>
|
||||
<p>Un lugar para jugar y volver.</p>{/if}
|
||||
</div>
|
||||
</aside>
|
||||
<div class="main-shell">
|
||||
<header class="topbar">
|
||||
{#if inRoom}<button
|
||||
class="settings-trigger menu-trigger"
|
||||
popovertarget="platform-menu"
|
||||
aria-label="Abrir menú principal"
|
||||
title="Menú principal"><Icon name="menu" /></button
|
||||
>{/if}
|
||||
<a class="mobile-logo" href="/" aria-label={brand.name}><BrandMark /></a>
|
||||
{#if !inRoom}<div class="header-search">
|
||||
<Icon name="search" size={18} /><input
|
||||
aria-label="Buscar un juego"
|
||||
placeholder="¿A qué te apetece jugar?"
|
||||
bind:value={session.search}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && page.url.pathname !== '/') goto('/');
|
||||
}}
|
||||
/><span class="search-hint">⌕</span>
|
||||
</div>{/if}
|
||||
<div class="header-right">
|
||||
<ThemeControls />
|
||||
<SoundControls />
|
||||
<span class="connection"
|
||||
><i class:online={session.connected}></i>{session.user
|
||||
? session.connected
|
||||
? 'Conectado'
|
||||
: 'Reconectando…'
|
||||
: 'Vamos a jugar'}</span
|
||||
>{#if session.user}<a
|
||||
class="avatar small-avatar"
|
||||
href="/perfil"
|
||||
aria-label="Mi perfil"><AvatarImage person={session.user} /></a
|
||||
>{:else}<a class="header-login" href="/cuenta"
|
||||
>Entrar <Icon name="arrow" size={16} /></a
|
||||
>{/if}
|
||||
</div>
|
||||
</header>
|
||||
{#if startupError}<div class="service-alert" role="alert">
|
||||
{startupError}<button onclick={() => location.reload()}
|
||||
>Reintentar</button
|
||||
>
|
||||
</div>{/if}
|
||||
<main>{@render children()}</main>
|
||||
<footer class="site-footer">
|
||||
<span class="footer-author"
|
||||
>Servicio personal en desarrollo de {terms.author}</span
|
||||
><a href="/ayuda">Reglas y ayuda</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TermsConsent hasTermsCookie={data.hasTermsCookie} />
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue