Fix classic Parchis board and color selection; add handoff

main
dev 4 days ago
parent 6c9b1d2fb3
commit 4a00402db0

@ -1,13 +1,13 @@
# Servidor de Juegoland
Versión 20261004-11. Web, API y WebSockets en mesa-game, 0.0.0.0:3000.
Versión 20261004-12. Web, API y WebSockets en mesa-game, 0.0.0.0:3000.
El proxy Nginx existente de la red apunta a http://192.168.18.155:3000.
Nginx y el antiguo servicio mesa-web están deshabilitados en la VM.
Acceso local: http://192.168.18.155:3000.
Dominio público: https://juegoland.imaginacion.com.
78 pruebas y compilación Linux verificadas. Dos clientes reales han comprobado por el dominio público la aceptación, cookies, WebSocket, chat, movimiento, turno y resultado. Las identidades sintéticas se eliminaron después de la prueba.
81 pruebas y compilación Linux verificadas. En una comprobación anterior, dos clientes reales verificaron por el dominio público la aceptación, cookies, WebSocket, chat, movimiento, turno y resultado. Las identidades sintéticas se eliminaron después de esa prueba.
El popup breve llega abierto en el HTML del servidor y pasa a modal nativo al inicializar JavaScript. Declara display, opacity y visibility en línea para evitar que una regla CSS de ocultación deje un modal invisible bloqueando la página. Se comprobó a 565×958 con reglas de ocultación !important y sin JavaScript en el navegador integrado. La comprobación directa en Edge sigue pendiente. El formulario puede aceptar y guardar la cookie sin JavaScript; conserva la invitación o ruta de entrada. «No acepto» es un enlace a https://imaginacion.com/ y Escape vuelve a la misma web. No hay página de condiciones completas. Open Graph y PNG de 1200×630 publicados para compartir.
El despliegue conserva los archivos inmutables del cliente anterior para que las pestañas abiertas puedan seguir cargando sus componentes.
Damas mantiene la ficha seleccionada durante capturas encadenadas y permite recorrido diagonal largo de las damas. Las partidas existentes siguen disponibles.
@ -16,4 +16,6 @@ La interfaz consume `src/tokens.css`: tipografía proporcional de 16 a 18 px, es
El chat aprovecha primero el margen derecho libre. A 1907×955, los cinco juegos conservan exactamente la posición y tamaño del tablero al abrirlo; en parchís y oca también se conserva el ancho de los controles. Se retiró la regla que convertía el tablero en una columna elástica y estrechaba los controles a 180 px. Se verificaron además 390×844, 1024×900, 1440×900, 3818×1886 y 932×430, sin desbordamiento horizontal. En pantalla horizontal de poca altura, tablero y acciones permanecen en columnas; el apilado del parchís se reserva a tabletas con altura suficiente.
El parchís usa la distribución clásica del tablero de referencia, colores vivos, números orientados por brazo y avatar en el centro de cada casa. Solo las cuatro salidas muestran popover. En la oca lo muestran únicamente las casillas especiales. En mesas de dos jugadores el segundo puede elegir entre los tres colores libres; la asignación automática se reserva al último color de una paleta completa de cuatro u ocho jugadores. La geometría y la selección tienen pruebas de regresión.
PostgreSQL permanece en loopback, con base y rol dedicados. Credenciales y datos existentes se conservan. APT y npm operan por IPv4. Copias diarias activas a las 03:15 Europe/Madrid.

@ -0,0 +1,71 @@
# Continuación de Juegoland — 4 de octubre de 2026
El usuario ha pedido terminar los cambios actuales, dejar este handoff, hacer commit y push y cerrar por hoy. Los componentes de cartas y los juegos nuevos quedan para la siguiente sesión.
## Trabajo cerrado
- El parchís sigue la distribución del tablero de referencia: rojo arriba a la izquierda, azul arriba a la derecha, verde abajo a la izquierda y amarillo abajo a la derecha. Las 68 casillas, sus números, las salidas 5/22/39/56 y los pasillos se dibujan con la geometría clásica. Los índices del motor se conservan, incluidos los de partidas antiguas.
- Solo las cuatro casillas de salida del parchís tienen popover. En la oca lo conservan las casillas especiales; las normales y el inicio no lo tienen.
- Los colores de las fichas son vivos y el amarillo es amarillo. La paleta compartida y los tokens visuales tienen los mismos colores sólidos; los textos del chat conservan tonos legibles para claro y oscuro.
- El centro de cada casa del parchís muestra el avatar del jugador. Si no hay avatar, aparecen sus iniciales; las casas libres mantienen un símbolo neutro.
- En mesas de dos o tres jugadores, cada jugador puede elegir entre todos los colores libres. Solo se asigna automáticamente el último color cuando se agota la paleta: cuatro jugadores con cuatro colores u ocho con ocho. Un color ocupado se rechaza y cambiar de color desmarca «listo».
- El arreglo anterior del chat se conserva: utiliza primero el margen derecho libre y no desplaza el tablero mientras haya espacio.
## Código de referencia
- `shared/parchis-board.mjs`: geometría de casillas, números, casas, salidas y pasillos.
- `src/lib/components/ParchisMap.svelte`: tablero, avatares, fichas, animaciones y popovers de salida.
- `src/lib/components/OcaBoard.svelte`: interacción solo en casillas especiales y contraste de números de fichas.
- `shared/player-colors.mjs`: paleta y función `receivesLastColor`.
- `server/platform.mjs` y `src/lib/components/PlayerColorChooser.svelte`: asignación y elección de colores.
- `src/tokens.css` y `src/app.css`: sistema visual. Consultar `docs/design-system.md`, `docs/typography.md` y `docs/room-layout.md` antes de tocar estilos.
- `tests/parchis-board.test.ts` y `tests/platform.test.ts`: posición de casillas y selección de colores, además de las pruebas existentes.
## Verificación y despliegue
La suite contiene 81 pruebas. Ejecutar `npm run design:check`, `npm run check`, `npm test -- --maxWorkers=1` y `npm run build` al modificar el proyecto. La revisión visual de este cambio utiliza dos identidades sintéticas y un servidor aislado en `127.0.0.1:4185`; sus archivos y capturas están en `artifacts/`, ignorado por Git. No reiniciar los servidores de otras sesiones de Codex.
El estado del despliegue se registra en `deploy/server-status.md`. Al cierre de esta sesión quedó activada la versión `20261004-12`, con 81 pruebas aprobadas en Linux y respuesta HTTP 200 del endpoint público de salud. Comprobar allí y con `readlink /opt/mesa-abierta/current` la versión vigente antes de continuar.
- Proyecto local: `G:\dev\svelte\games`, rama `main`.
- Repositorio: `https://g.activething.com/go/Juegoland.git`.
- VM: `192.168.18.155`.
- Servicio único: `mesa-game`, escucha en `0.0.0.0:3000` para web, API y WebSockets.
- Dominio vigente: `https://juegoland.imaginacion.com/`. Usar este dominio en invitaciones; las menciones antiguas a games/juegolang ya no son la dirección vigente.
- El proxy Nginx existente de la red apunta a `http://192.168.18.155:3000`. No instalar otro proxy ni Docker Compose en la VM. `mesa-web` y Nginx de la VM están deshabilitados.
- PostgreSQL y las credenciales actuales se conservan. No copiar secretos al repositorio ni imprimirlos.
- SSH por IPv4 con la clave local `$env:USERPROFILE\.ssh\senzapaura_deploy`. APT y npm deben usar IPv4.
- Despliegue nativo: `deploy/deploy-native.ps1 -Release <identificador nuevo> -Activate`. Verifica, hace copia de seguridad, migra y activa la versión; no reutilizar identificadores existentes.
## Siguiente sesión: componentes de cartas
El usuario preparará imágenes de la baraja española y de póquer. Todavía no se han recibido ni se han creado estos componentes. Preparar una base reutilizable para carta, reverso, mano, mazo, descarte y bazas/mesa. Separar datos, imágenes, reglas e interacción; cada juego define sus acciones, animaciones y sonidos.
Prever baraja española de 40/48 cartas y póquer de 52 con comodines opcionales. Identificar cartas por palo y valor mediante un manifiesto de recursos. Mantener manos legibles en móvil, selección accesible y acciones legales claras. No sustituir las imágenes que traerá el usuario por otra baraja.
El servidor debe autorizar las acciones y ocultar las manos ajenas. Revisar `Platform.view` y la proyección `game.view(state, seat)` antes de implementar estados privados. Esta misma base sirve para dados ocultos y tableros privados de barcos.
## Juegos solicitados para incorporar poco a poco
Cartas: brisca, tute de dos jugadores, la 31 y cinquillo. Otras posibilidades mencionadas para valorar después: escoba, chinchón, tute por parejas, pocha y mus.
Otros juegos solicitados:
- Serpientes y escaleras: el usuario aportará las imágenes del tablero, como hizo con la oca.
- Guerra de barcos / hundir la flota: tableros privados de cada jugador.
- El ahorcado.
- Tres en raya.
- Conecta cuatro.
- Mancala: concretar la variante antes de implementar reglas.
- Shut the Box: concretar dados y puntuación.
- El mentiroso de dados y otros juegos de dados: concretar variantes y dados ocultos.
No están implementados ni deben presentarse como disponibles en el catálogo. Incorporarlos uno a uno con motor, interfaz, reglas, sonidos, animaciones y validación completos.
## Preferencias que hay que respetar
Usar el skill `C:\Users\dev\.codex\skills\frontend-design\SKILL.md`. Conservar el logo transparente y la identidad de Juegoland, los tokens de tipografía y espaciado y las opciones de tema claro/oscuro. Revisar capturas en escritorio y móvil: tamaños, alineaciones y proporciones son una prioridad expresa del usuario.
Mantener PIN de cuatro cifras, registro opcional, invitaciones con mensaje del anfitrión, avatares editables, notificación de mensajes con el chat cerrado y partidas pendientes. Sonidos y animaciones deben diferenciarse por juego y acción. El popup de entrada debe ser breve; «No acepto» y Escape llevan a `https://imaginacion.com/`. No volver a añadir páginas legales extensas ni un aviso en lugar del popup.
Este handoff no programa tareas para mañana ni abre otra sesión.

@ -6,6 +6,7 @@ import {
paletteForRoom,
assignLastColor,
chooseDefaultColor,
receivesLastColor,
} from '../shared/player-colors.mjs';
export class Platform {
@ -176,7 +177,7 @@ export class Platform {
throw new Error(
'Solo puedes elegir color en la sala de espera de este juego.',
);
if (player.seat === room.maxPlayers - 1)
if (receivesLastColor(room, player))
throw new Error('El último jugador recibe un color automáticamente.');
if (!paletteForRoom(room).some((c) => c.id === colorId))
throw new Error('Ese color no está disponible en este juego.');
@ -184,7 +185,7 @@ export class Platform {
room.players.some(
(p) =>
p !== player &&
p.seat !== room.maxPlayers - 1 &&
!receivesLastColor(room, p) &&
p.colorId === colorId,
)
)

@ -0,0 +1,54 @@
// Classic board: three equal sectors on each axis, eight rectangular cells per arm.
export const parchisStarts = [4, 21, 38, 55];
export const parchisHomes = [
[400, 400],
[400, 0],
[0, 0],
[0, 400],
];
const laneWidth = 200 / 3;
function cell(x, y, width, height, angle) {
const center = [x + width / 2, y + height / 2];
const label = [...center];
if (height === 25 && x !== 200 + laneWidth)
label[0] = x === 200 ? x + width - 11 : x + 11;
if (width === 25 && y !== 200 + laneWidth)
label[1] = y === 200 ? y + height - 11 : y + 11;
return { x, y, width, height, angle, center, label };
}
const track = [];
for (let i = 7; i >= 0; i--)
track.push(cell(200 + 2 * laneWidth, 400 + i * 25, laneWidth, 25, 0));
for (let i = 0; i < 8; i++)
track.push(cell(400 + i * 25, 200 + 2 * laneWidth, 25, laneWidth, 90));
track.push(cell(575, 200 + laneWidth, 25, laneWidth, 90));
for (let i = 7; i >= 0; i--)
track.push(cell(400 + i * 25, 200, 25, laneWidth, 90));
for (let i = 7; i >= 0; i--)
track.push(cell(200 + 2 * laneWidth, i * 25, laneWidth, 25, 180));
track.push(cell(200 + laneWidth, 0, laneWidth, 25, 180));
for (let i = 0; i < 8; i++) track.push(cell(200, i * 25, laneWidth, 25, 180));
for (let i = 7; i >= 0; i--) track.push(cell(i * 25, 200, 25, laneWidth, 270));
track.push(cell(0, 200 + laneWidth, 25, laneWidth, 270));
for (let i = 0; i < 8; i++)
track.push(cell(i * 25, 200 + 2 * laneWidth, 25, laneWidth, 270));
for (let i = 0; i < 8; i++)
track.push(cell(200, 400 + i * 25, laneWidth, 25, 0));
track.push(cell(200 + laneWidth, 575, laneWidth, 25, 0));
export const parchisTrack = track;
export function parchisLane(house, step, outer = 65) {
const edge = outer === 65 ? 6 : 7;
const begin = outer === 65 ? 1 : 0;
return [
() => cell(200 + laneWidth, 400 + (edge - step) * 25, laneWidth, 25, 0),
() => cell(400 + (edge - step) * 25, 200 + laneWidth, 25, laneWidth, 90),
() => cell(200 + laneWidth, (begin + step) * 25, laneWidth, 25, 180),
() => cell((begin + step) * 25, 200 + laneWidth, 25, laneWidth, 270),
][house]();
}
export function parchisHomePoint(house, piece) {
return [
parchisHomes[house][0] + 57 + (piece % 2) * 86,
parchisHomes[house][1] + 57 + Math.floor(piece / 2) * 86,
];
}

@ -1,11 +1,11 @@
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: 'amarillo', label: 'Amarillo', hex: '#ffde00' },
{ id: 'azul', label: 'Azul', hex: '#00aeef' },
{ id: 'rojo', label: 'Rojo', hex: '#ff2438' },
{ id: 'verde', label: 'Verde', hex: '#28c800' },
{ id: 'negro', label: 'Negro', hex: '#30343b' },
{ id: 'naranja', label: 'Naranja', hex: '#c96b24' },
{ id: 'violeta', label: 'Violeta', hex: '#8654b8' },
{ id: 'naranja', label: 'Naranja', hex: '#ff861c' },
{ id: 'violeta', label: 'Violeta', hex: '#9855ef' },
{ id: 'gris', label: 'Gris', hex: '#7d8794' },
];
export const parchisColorOrder = playerColors.slice(0, 4).map((c) => c.id);
@ -14,6 +14,9 @@ export const canChooseColor = (room) =>
(room.gameId === 'parchis' && room.gameVersion !== '1.0.0');
export const paletteForRoom = (room) =>
playerColors.slice(0, room.maxPlayers > 4 ? 8 : 4);
export const receivesLastColor = (room, player) =>
room.maxPlayers === paletteForRoom(room).length &&
player?.seat === room.maxPlayers - 1;
export const colorForPlayer = (player) =>
playerColors.find((c) => c.id === player?.colorId) ??
playerColors[player?.seat ?? 0] ??
@ -22,7 +25,7 @@ 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;
if (!receivesLastColor(room, 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(
@ -31,12 +34,12 @@ export function assignLastColor(room) {
else delete last.colorId;
}
export function chooseDefaultColor(room, player) {
if (player.colorId || player.seat === room.maxPlayers - 1) return;
if (player.colorId || receivesLastColor(room, player)) return;
const free = paletteForRoom(room).filter(
(c) =>
!room.players.some(
(p) =>
p !== player && p.seat !== room.maxPlayers - 1 && p.colorId === c.id,
p !== player && !receivesLastColor(room, p) && p.colorId === c.id,
),
);
player.colorId = (

@ -1420,7 +1420,7 @@ form input::placeholder {
display: grid;
place-items: center;
font-size: 8px;
color: white;
color: var(--piece-number);
padding: 0;
position: relative;
z-index: 2;

@ -103,6 +103,7 @@
/>
{#each artwork.centers as _, n}{@const rect = tile(n)}{@const info =
ocaCell(n)}
{#if info.kind !== 'normal'}
<g
class="cell-hotspot"
class:active={inspected === n}
@ -133,6 +134,7 @@
rx="10"
/>
</g>
{/if}
{/each}
{#if inspected !== null}{@const rect = tile(inspected)}
<g
@ -191,7 +193,11 @@
text-anchor="middle"
font-size="23"
font-weight="700"
fill="white">{seat + 1}</text
fill={['amarillo', 'azul', 'verde', 'naranja'].includes(
colorForPlayer(room.players[seat]).id,
)
? '#202c41'
: '#ffffff'}>{seat + 1}</text
>
</g>
{/key}

@ -2,6 +2,14 @@
import { flip } from 'svelte/animate';
import type { Room, Action } from '../types';
import CellInfo from './CellInfo.svelte';
import { playerColors } from '../../../shared/player-colors.mjs';
import {
parchisTrack,
parchisStarts,
parchisHomes,
parchisLane,
parchisHomePoint,
} from '../../../shared/parchis-board.mjs';
let {
room,
disabled = false,
@ -11,287 +19,259 @@
disabled?: boolean;
onaction: (action: Action) => void;
} = $props();
const colors = playerColors.slice(0, 4);
const houseLabels = ['amarilla', 'azul', 'roja', 'verde'];
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);
let hovered = $state<number | null>(null),
selected = $state<number | 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 houseForSeat(seat: number) {
return parchisStarts.indexOf(
room.state?.starts?.[seat] ?? parchisStarts[seat],
);
}
function dismiss() {
hovered = null;
selected = null;
}
function keyInfo(e: KeyboardEvent, id: string) {
function keyInfo(e: KeyboardEvent, index: number) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selected = selected === id ? null : id;
selected = selected === index ? null : index;
} 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 === -1) return parchisHomePoint(house, piece);
if (progress === goal)
return [
[315, 285],
[285, 315],
[255, 285],
[285, 255],
[300, 350],
[350, 300],
[300, 250],
[250, 300],
][house].map(
(v, i) => v + (i === 0 ? piece % 2 : Math.floor(piece / 2)) * 8 - 4,
(v, i) => v + (i === 0 ? piece % 2 : Math.floor(piece / 2)) * 18 - 9,
);
const cell =
progress >= outer
? lane(house, progress - outer)
: track[((room.state?.starts?.[seat] ?? 0) + progress) % 68];
? parchisLane(house, progress - outer, outer)
: parchisTrack[
((room.state?.starts?.[seat] ?? parchisStarts[seat]) + progress) %
68
];
const others = room.state?.pieces?.[seat] ?? [];
const shared = others.filter((p) => p === progress).length > 1;
const offset = shared ? (others.indexOf(progress) === piece ? -11 : 11) : 0;
return [
cell[0] * 30 +
15 +
(shared ? (others.indexOf(progress) === piece ? -5 : 5) : 0),
cell[1] * 30 + 15,
cell.center[0] + (cell.width > cell.height ? offset : 0),
cell.center[1] + (cell.height > cell.width ? offset : 0),
];
}
function exitInfo(index: number) {
return {
title: `Casilla ${index + 1} · Salida ${houseLabels[parchisStarts.indexOf(index)]}`,
kind: 'advantage',
description:
'Sales de casa con un 5. Esta casilla es un seguro. Si dos fichas ocupan la salida, al salir puedes capturar al último rival que llegó.',
};
}
</script>
<div class="parchis-inspection">
<svg
class="parchis-map"
viewBox="-8 -8 586 586"
viewBox="-8 -8 616 616"
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"
width="612"
height="612"
rx="14"
fill="#ffffff"
stroke="#293447"
stroke-width="3"
/>
{#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"
{#each parchisHomes as home, house}
{@const owner = room.players.find((p) => houseForSeat(p.seat) === house)}
{@const color = colors[house]}
<g
class="player-home"
data-color={color.id}
aria-label={`Casa ${houseLabels[house]}: ${owner?.nick ?? 'libre'}`}
>
<circle
cx={home[0] + 100}
cy={home[1] + 100}
r="88"
fill={color.hex}
stroke="#293447"
stroke-width="1.5"
/>
<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"
<circle
cx={home[0] + 100}
cy={home[1] + 100}
r="68"
fill="none"
stroke="#ffffff"
stroke-width="1.5"
/>
{#each Array(4) as _, piece}{@const spot = parchisHomePoint(
house,
piece,
)}
<circle
cx={spot[0]}
cy={spot[1]}
r="23"
fill="#ffffff"
fill-opacity="0.3"
/>
{/each}
<circle
cx={home[0] + 100}
cy={home[1] + 100}
r="26"
fill="#ffffff"
stroke="#ffffff"
stroke-width="3"
/>
{#if owner?.avatar}
<defs
><clipPath id={`home-avatar-${room.id}-${house}`}
><circle cx={home[0] + 100} cy={home[1] + 100} r="24" /></clipPath
></defs
>
<image
class="home-avatar"
href={owner.avatar}
x={home[0] + 76}
y={home[1] + 76}
width="48"
height="48"
preserveAspectRatio="xMidYMid slice"
clip-path={`url(#home-avatar-${room.id}-${house})`}
/>
{#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}
{:else}
<text
class="home-avatar"
x={home[0] + 100}
y={home[1] + 106}
text-anchor="middle"
font-size="18"
font-weight="800"
fill="#202c41"
>{owner ? owner.nick.slice(0, 2).toUpperCase() : '✳'}</text
>
{/if}
<text
x={home[0] * 30 + 120}
y={home[1] * 30 + 216}
x={home[0] + 100}
y={home[1] + 197}
text-anchor="middle"
fill={colors[seat]}
font-size="13"
font-weight="700">{owner?.nick.slice(0, 20) ?? 'Casa libre'}</text
font-size="10"
font-weight="800"
fill="#202c41">{owner?.nick.slice(0, 20) ?? 'Casa libre'}</text
>
</g>
{/each}
{#each track as cell, i}
{@const seat = houseStarts.indexOf(i)}
{#each colors as color, house}
{#each Array(7) as _, step}{@const cell = parchisLane(house, step)}
<rect
x={cell.x}
y={cell.y}
width={cell.width}
height={cell.height}
fill={color.hex}
stroke="#293447"
stroke-width="0.8"
/>
{/each}
{/each}
{#each parchisTrack as cell, i}
{@const house = parchisStarts.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)}
>
{@const cap = [16, 33, 50, 67].includes(i)}
{#snippet trackDrawing()}
<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"
x={cell.x}
y={cell.y}
width={cell.width}
height={cell.height}
fill={house >= 0 ? colors[house].hex : '#ffffff'}
stroke="#293447"
stroke-width="0.8"
/>
{#if safe}<circle
cx={cell.center[0]}
cy={cell.center[1]}
r="9"
fill={house >= 0 ? 'none' : '#dce3ec'}
stroke="#293447"
stroke-width="1.2"
/>{/if}
<text
x={cell[0] * 30 + 15}
y={cell[1] * 30 + 19}
x={cap ? cell.center[0] : cell.label[0]}
y={(cap ? cell.center[1] : cell.label[1]) + 4}
transform={`rotate(${cell.angle} ${cap ? cell.center[0] : cell.label[0]} ${cap ? cell.center[1] : cell.label[1]})`}
text-anchor="middle"
fill={seat >= 0 ? '#fff' : '#5c6855'}
font-size="9"
font-weight="600">{i + 1}</text
font-size={cap ? 10 : 12}
font-weight="700"
fill="#202c41">{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" />
{/snippet}
{#if house >= 0}
<g
class="cell-hotspot"
class:active={inspected === i}
role="button"
tabindex="0"
aria-label="Meta del parchís"
aria-label={exitInfo(i).title}
aria-expanded={selected === i}
onpointerenter={(e) => {
if (e.pointerType === 'mouse') hovered = 'goal';
if (e.pointerType === 'mouse') hovered = i;
}}
onpointerleave={() => (hovered = null)}
onfocus={() => (hovered = 'goal')}
onfocus={() => (hovered = i)}
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
onclick={() => (selected = selected === i ? null : i)}
onkeydown={(e) => keyInfo(e, i)}
data-cell={i + 1}
>
{@render trackDrawing()}
</g>
{:else}<g data-cell={i + 1}>{@render trackDrawing()}</g>{/if}
{/each}
<path
d="M200 200L300 300L400 200Z"
fill={colors[2].hex}
stroke="#293447"
stroke-width="1.2"
/>
<path
d="M400 200L300 300L400 400Z"
fill={colors[1].hex}
stroke="#293447"
stroke-width="1.2"
/>
<path
d="M400 400L300 300L200 400Z"
fill={colors[0].hex}
stroke="#293447"
stroke-width="1.2"
/>
<path
d="M200 400L300 300L200 200Z"
fill={colors[3].hex}
stroke="#293447"
stroke-width="1.2"
/>
{#each room.state?.pieces ?? [] as pieces, seat}
{@const color = colors[houseForSeat(seat)]}
{#each pieces as progress, piece (piece)}{@const pos = point(
seat,
piece,
progress,
)}
{#each pieces as progress, piece (piece)}
{@const pos = point(seat, piece, progress)}
{@const playable =
!disabled &&
seat === room.seat &&
@ -325,59 +305,62 @@
? 'en meta'
: progress >= outer
? 'pasillo final'
: `casilla ${(((room.state?.starts?.[seat] ?? 0) + progress) % 68) + 1}`}</title
: `casilla ${(((room.state?.starts?.[seat] ?? parchisStarts[seat]) + progress) % 68) + 1}`}</title
>
{#if playable}<circle
cx={pos[0]}
cy={pos[1]}
r={progress === -1 ? 25 : 17}
r={progress === -1 ? 23 : 13}
fill="none"
stroke={color}
stroke-width="3"
stroke="#202c41"
stroke-width="2.5"
class="piece-halo"
/>{/if}
<circle
cx={pos[0] + 1}
cy={pos[1] + 2}
r={progress === -1 ? 19 : 12}
fill="#253324"
fill-opacity="0.2"
r={progress === -1 ? 17 : 10}
fill="#202c41"
fill-opacity="0.25"
/>
<circle
cx={pos[0]}
cy={pos[1]}
r={progress === -1 ? 19 : 12}
fill={color}
stroke="#fffdf7"
r={progress === -1 ? 17 : 10}
fill={color.hex}
stroke="#ffffff"
stroke-width="2"
/>
<circle
cx={pos[0]}
cy={pos[1]}
r={progress === -1 ? 14 : 8}
r={progress === -1 ? 13 : 7}
fill="none"
stroke="#fffdf7"
stroke-opacity="0.4"
stroke="#202c41"
stroke-opacity="0.45"
stroke-width="0.8"
/>
<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
fill="#202c41"
font-size={progress === -1 ? 13 : 10}
font-weight="800">{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}
{#if inspected !== null}{@const cell = parchisTrack[inspected]}
<CellInfo
info={exitInfo(inspected)}
x={((cell.center[0] + 8) / 616) * 100}
y={((cell.center[1] + 8) / 616) * 100}
pinned={selected !== null}
onclose={dismiss}
/>{/if}
/>
{/if}
</div>
<style>
@ -402,8 +385,8 @@
}
@media (hover: hover) {
.cell-hotspot:hover {
transform: scale(1.18);
filter: drop-shadow(0 2px 2px #27365240);
transform: scale(1.1);
filter: drop-shadow(var(--shadow-board));
}
}
g.playable {

@ -3,6 +3,7 @@
import {
paletteForRoom,
colorForPlayer,
receivesLastColor,
} from '../../../shared/player-colors.mjs';
let {
room,
@ -14,7 +15,7 @@
onchoose: (colorId: string) => void;
} = $props();
const me = $derived(room.players.find((p) => p.seat === room.seat));
const automatic = $derived(room.seat === room.maxPlayers - 1);
const automatic = $derived(receivesLastColor(room, me));
</script>
<section class="color-chooser" aria-labelledby="piece-color-title">
@ -25,8 +26,8 @@
>{/if}
<p>
{me?.colorId
? 'Tu color se asigna automáticamente al ser el último jugador.'
: 'Tu color se asignará cuando los demás elijan.'}
? 'Recibes el único color que queda libre.'
: 'Recibirás el color restante cuando los demás elijan.'}
</p>
</div>
{:else}<div class="color-options">
@ -34,7 +35,7 @@
{@const owner = room.players.find(
(p) =>
p.seat !== room.seat &&
p.seat !== room.maxPlayers - 1 &&
!receivesLastColor(room, p) &&
p.colorId === color.id,
)}
<button

@ -173,25 +173,29 @@
/* Player identities remain distinct in either theme. */
:root {
--piece-number: #202c41;
}
.player-color-0 {
--player-ink: #775800;
--player-soft: #fff3ce;
--player-solid: #b88925;
--player-solid: #ffde00;
}
.player-color-1 {
--player-ink: #165ba1;
--player-soft: #e6f1ff;
--player-solid: #397bba;
--player-solid: #00aeef;
}
.player-color-2 {
--player-ink: #aa3938;
--player-soft: #ffecea;
--player-solid: #b95f56;
--player-solid: #ff2438;
}
.player-color-3 {
--player-ink: #217146;
--player-soft: #e6f5eb;
--player-solid: #568264;
--player-solid: #28c800;
}
.player-color-4 {
--player-ink: #30343b;
@ -201,12 +205,12 @@
.player-color-5 {
--player-ink: #985023;
--player-soft: #fff0e1;
--player-solid: #ad702f;
--player-solid: #ff861c;
}
.player-color-6 {
--player-ink: #703cad;
--player-soft: #f2eaff;
--player-solid: #8654b8;
--player-solid: #9855ef;
}
.player-color-7 {
--player-ink: #536070;

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import {
parchisTrack,
parchisStarts,
parchisHomes,
parchisLane,
} from '../shared/parchis-board.mjs';
describe('tablero clásico de la referencia', () => {
it('sitúa los cuatro extremos y salidas en el brazo correcto', () => {
// Coordinates read from the requested reference, normalized to a 600px board.
const expected = [
[1, 1100 / 3, 587.5],
[5, 1100 / 3, 487.5],
[17, 587.5, 300],
[22, 487.5, 700 / 3],
[34, 300, 12.5],
[39, 700 / 3, 112.5],
[51, 12.5, 300],
[56, 112.5, 1100 / 3],
[68, 300, 587.5],
];
for (const [number, x, y] of expected) {
const center = parchisTrack[number - 1].center;
expect(center[0]).toBeCloseTo(x);
expect(center[1]).toBeCloseTo(y);
}
expect(parchisTrack).toHaveLength(68);
expect(new Set(parchisTrack.map((c) => c.center.join(','))).size).toBe(68);
});
it('conecta el color de cada salida con su casa y su pasillo hacia el centro', () => {
expect(parchisStarts.map((i) => i + 1)).toEqual([5, 22, 39, 56]);
expect(parchisHomes).toEqual([
[400, 400],
[400, 0],
[0, 0],
[0, 400],
]);
expect([0, 1, 2, 3].map((house) => parchisLane(house, 0).center)).toEqual([
[300, 562.5],
[562.5, 300],
[300, 37.5],
[37.5, 300],
]);
expect([0, 1, 2, 3].map((house) => parchisLane(house, 6).center)).toEqual([
[300, 412.5],
[412.5, 300],
[300, 187.5],
[187.5, 300],
]);
});
});

@ -84,7 +84,7 @@ describe('persistencia y permisos', () => {
platform.chooseColor(one, room.id, 'amarillo'),
).rejects.toThrow('sala de espera');
});
it('recalcula el color automático al entrar el último y conserva una elección al reiniciar', async () => {
it('permite elegir al segundo de dos jugadores y conserva las elecciones al reiniciar', async () => {
const room = await platform.create(one, {
gameId: 'oca',
name: 'Colores Oca',
@ -93,19 +93,45 @@ describe('persistencia y permisos', () => {
});
await platform.chooseColor(one, room.id, 'verde');
await platform.join(two, room.code);
expect((await store.room(room.id)).players[1].colorId).toBeUndefined();
await platform.chooseColor(two, room.id, 'amarillo');
await expect(
platform.chooseColor(one, room.id, 'amarillo'),
).rejects.toThrow('restantes');
await platform.chooseColor(one, room.id, 'azul');
expect((await store.room(room.id)).players[1].colorId).toBe('amarillo');
await platform.chooseColor(one, room.id, 'amarillo');
expect((await store.room(room.id)).players[1].colorId).toBe('azul');
const recovered = new FileStore(join(directory, 'store.json'));
await recovered.init();
expect(
(await new Platform(recovered).view(room.id, one.id)).players[0].colorId,
).toBe('amarillo');
).toBe('azul');
await recovered.close();
await platform.ready(two, room.id, true);
await platform.ready(one, room.id, true);
expect((await platform.view(room.id, one.id)).status).toBe('playing');
});
it('el segundo jugador de parchís elige entre los tres colores libres', async () => {
const room = await platform.create(one, {
gameId: 'parchis',
name: 'Dos colores',
maxPlayers: 2,
visibility: 'private',
});
await platform.chooseColor(one, room.id, 'rojo');
await platform.join(two, room.code);
await expect(platform.chooseColor(two, room.id, 'rojo')).rejects.toThrow(
'restantes',
);
for (const color of ['amarillo', 'azul', 'verde']) {
await platform.chooseColor(two, room.id, color);
expect((await store.room(room.id)).players[1].colorId).toBe(color);
}
await platform.ready(one, room.id, true);
await platform.ready(two, room.id, true);
expect((await platform.view(room.id, two.id)).state?.starts).toEqual([
38, 55,
]);
});
it('no expone salas privadas, correos ni credenciales', async () => {
const room = await create();
expect(await platform.list(outsider.id)).toEqual([]);

Loading…
Cancel
Save

Powered by TurnKey Linux.