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 # 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. 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. Nginx y el antiguo servicio mesa-web están deshabilitados en la VM.
Acceso local: http://192.168.18.155:3000. Acceso local: http://192.168.18.155:3000.
Dominio público: https://juegoland.imaginacion.com. 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 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. 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. 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 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. 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, paletteForRoom,
assignLastColor, assignLastColor,
chooseDefaultColor, chooseDefaultColor,
receivesLastColor,
} from '../shared/player-colors.mjs'; } from '../shared/player-colors.mjs';
export class Platform { export class Platform {
@ -176,7 +177,7 @@ export class Platform {
throw new Error( throw new Error(
'Solo puedes elegir color en la sala de espera de este juego.', '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.'); throw new Error('El último jugador recibe un color automáticamente.');
if (!paletteForRoom(room).some((c) => c.id === colorId)) if (!paletteForRoom(room).some((c) => c.id === colorId))
throw new Error('Ese color no está disponible en este juego.'); throw new Error('Ese color no está disponible en este juego.');
@ -184,7 +185,7 @@ export class Platform {
room.players.some( room.players.some(
(p) => (p) =>
p !== player && p !== player &&
p.seat !== room.maxPlayers - 1 && !receivesLastColor(room, p) &&
p.colorId === colorId, 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 = [ export const playerColors = [
{ id: 'amarillo', label: 'Amarillo', hex: '#b88925' }, { id: 'amarillo', label: 'Amarillo', hex: '#ffde00' },
{ id: 'azul', label: 'Azul', hex: '#4e84a6' }, { id: 'azul', label: 'Azul', hex: '#00aeef' },
{ id: 'rojo', label: 'Rojo', hex: '#b95f56' }, { id: 'rojo', label: 'Rojo', hex: '#ff2438' },
{ id: 'verde', label: 'Verde', hex: '#568264' }, { id: 'verde', label: 'Verde', hex: '#28c800' },
{ id: 'negro', label: 'Negro', hex: '#30343b' }, { id: 'negro', label: 'Negro', hex: '#30343b' },
{ id: 'naranja', label: 'Naranja', hex: '#c96b24' }, { id: 'naranja', label: 'Naranja', hex: '#ff861c' },
{ id: 'violeta', label: 'Violeta', hex: '#8654b8' }, { id: 'violeta', label: 'Violeta', hex: '#9855ef' },
{ id: 'gris', label: 'Gris', hex: '#7d8794' }, { id: 'gris', label: 'Gris', hex: '#7d8794' },
]; ];
export const parchisColorOrder = playerColors.slice(0, 4).map((c) => c.id); 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'); (room.gameId === 'parchis' && room.gameVersion !== '1.0.0');
export const paletteForRoom = (room) => export const paletteForRoom = (room) =>
playerColors.slice(0, room.maxPlayers > 4 ? 8 : 4); 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) => export const colorForPlayer = (player) =>
playerColors.find((c) => c.id === player?.colorId) ?? playerColors.find((c) => c.id === player?.colorId) ??
playerColors[player?.seat ?? 0] ?? playerColors[player?.seat ?? 0] ??
@ -22,7 +25,7 @@ export const playerColorClass = (player) =>
`player-color-${playerColors.indexOf(colorForPlayer(player))}`; `player-color-${playerColors.indexOf(colorForPlayer(player))}`;
export function assignLastColor(room) { export function assignLastColor(room) {
const last = room.players.find((p) => p.seat === room.maxPlayers - 1); 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); const others = room.players.filter((p) => p !== last);
if (others.length === room.maxPlayers - 1 && others.every((p) => p.colorId)) if (others.length === room.maxPlayers - 1 && others.every((p) => p.colorId))
last.colorId = paletteForRoom(room).find( last.colorId = paletteForRoom(room).find(
@ -31,12 +34,12 @@ export function assignLastColor(room) {
else delete last.colorId; else delete last.colorId;
} }
export function chooseDefaultColor(room, player) { 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( const free = paletteForRoom(room).filter(
(c) => (c) =>
!room.players.some( !room.players.some(
(p) => (p) =>
p !== player && p.seat !== room.maxPlayers - 1 && p.colorId === c.id, p !== player && !receivesLastColor(room, p) && p.colorId === c.id,
), ),
); );
player.colorId = ( player.colorId = (

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

@ -103,36 +103,38 @@
/> />
{#each artwork.centers as _, n}{@const rect = tile(n)}{@const info = {#each artwork.centers as _, n}{@const rect = tile(n)}{@const info =
ocaCell(n)} ocaCell(n)}
<g {#if info.kind !== 'normal'}
class="cell-hotspot" <g
class:active={inspected === n} class="cell-hotspot"
role="button" class:active={inspected === n}
tabindex="0" role="button"
aria-label={`${info.title}: ${info.description}`} tabindex="0"
aria-expanded={selected === n} aria-label={`${info.title}: ${info.description}`}
onpointerenter={(e) => { aria-expanded={selected === n}
if (e.pointerType === 'mouse') hovered = n; onpointerenter={(e) => {
}} if (e.pointerType === 'mouse') hovered = n;
onpointerleave={() => (hovered = null)} }}
onfocus={() => (hovered = n)} onpointerleave={() => (hovered = null)}
onblur={() => (hovered = null)} onfocus={() => (hovered = n)}
onclick={() => (selected = selected === n ? null : n)} onblur={() => (hovered = null)}
onkeydown={(e) => { onclick={() => (selected = selected === n ? null : n)}
if (e.key === 'Enter' || e.key === ' ') { onkeydown={(e) => {
e.preventDefault(); if (e.key === 'Enter' || e.key === ' ') {
selected = selected === n ? null : n; e.preventDefault();
} else if (e.key === 'Escape') dismiss(); selected = selected === n ? null : n;
}} } else if (e.key === 'Escape') dismiss();
> }}
<rect >
class="cell-hit" <rect
x={rect.x} class="cell-hit"
y={rect.y} x={rect.x}
width={rect.width} y={rect.y}
height={rect.height} width={rect.width}
rx="10" height={rect.height}
/> rx="10"
</g> />
</g>
{/if}
{/each} {/each}
{#if inspected !== null}{@const rect = tile(inspected)} {#if inspected !== null}{@const rect = tile(inspected)}
<g <g
@ -191,7 +193,11 @@
text-anchor="middle" text-anchor="middle"
font-size="23" font-size="23"
font-weight="700" 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> </g>
{/key} {/key}

@ -2,6 +2,14 @@
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import type { Room, Action } from '../types'; import type { Room, Action } from '../types';
import CellInfo from './CellInfo.svelte'; import CellInfo from './CellInfo.svelte';
import { playerColors } from '../../../shared/player-colors.mjs';
import {
parchisTrack,
parchisStarts,
parchisHomes,
parchisLane,
parchisHomePoint,
} from '../../../shared/parchis-board.mjs';
let { let {
room, room,
disabled = false, disabled = false,
@ -11,287 +19,259 @@
disabled?: boolean; disabled?: boolean;
onaction: (action: Action) => void; onaction: (action: Action) => void;
} = $props(); } = $props();
const colors = playerColors.slice(0, 4);
const houseLabels = ['amarilla', 'azul', 'roja', 'verde'];
const goal = $derived(room.state?.goal ?? 71), const goal = $derived(room.state?.goal ?? 71),
outer = $derived(room.state?.outerLength ?? 64); outer = $derived(room.state?.outerLength ?? 64);
const colors = ['#b88925', '#4e84a6', '#b95f56', '#568264']; let hovered = $state<number | null>(null),
const houseStarts = [4, 21, 38, 55]; selected = $state<number | null>(null);
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); const inspected = $derived(selected ?? hovered);
function describeCell(id: string) { function houseForSeat(seat: number) {
const [type, first, second] = id.split('-'), return parchisStarts.indexOf(
index = Number(first); room.state?.starts?.[seat] ?? parchisStarts[seat],
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() { function dismiss() {
hovered = null; hovered = null;
selected = null; selected = null;
} }
function keyInfo(e: KeyboardEvent, id: string) { function keyInfo(e: KeyboardEvent, index: number) {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); e.preventDefault();
selected = selected === id ? null : id; selected = selected === index ? null : index;
} else if (e.key === 'Escape') dismiss(); } 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) { function point(seat: number, piece: number, progress: number) {
const house = houseForSeat(seat); const house = houseForSeat(seat);
if (progress === -1) if (progress === -1) return parchisHomePoint(house, piece);
return [
homes[house][0] * 30 + 85 + (piece % 2) * 70,
homes[house][1] * 30 + 85 + Math.floor(piece / 2) * 70,
];
if (progress === goal) if (progress === goal)
return [ return [
[315, 285], [300, 350],
[285, 315], [350, 300],
[255, 285], [300, 250],
[285, 255], [250, 300],
][house].map( ][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 = const cell =
progress >= outer progress >= outer
? lane(house, progress - outer) ? parchisLane(house, progress - outer, outer)
: track[((room.state?.starts?.[seat] ?? 0) + progress) % 68]; : parchisTrack[
((room.state?.starts?.[seat] ?? parchisStarts[seat]) + progress) %
68
];
const others = room.state?.pieces?.[seat] ?? []; const others = room.state?.pieces?.[seat] ?? [];
const shared = others.filter((p) => p === progress).length > 1; const shared = others.filter((p) => p === progress).length > 1;
const offset = shared ? (others.indexOf(progress) === piece ? -11 : 11) : 0;
return [ return [
cell[0] * 30 + cell.center[0] + (cell.width > cell.height ? offset : 0),
15 + cell.center[1] + (cell.height > cell.width ? offset : 0),
(shared ? (others.indexOf(progress) === piece ? -5 : 5) : 0),
cell[1] * 30 + 15,
]; ];
} }
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> </script>
<div class="parchis-inspection"> <div class="parchis-inspection">
<svg <svg
class="parchis-map" class="parchis-map"
viewBox="-8 -8 586 586" viewBox="-8 -8 616 616"
role="group" role="group"
aria-label="Tablero de parchís: cuatro casas y un recorrido de 68 casillas. Puedes seleccionar las fichas resaltadas." aria-label="Tablero de parchís: cuatro casas y un recorrido de 68 casillas. Puedes seleccionar las fichas resaltadas."
> >
<rect <rect
x="-6" x="-6"
y="-6" y="-6"
width="582" width="612"
height="582" height="612"
rx="18" rx="14"
fill="#f5f1e7" fill="#ffffff"
stroke="#ded8c9" stroke="#293447"
stroke-width="2" stroke-width="3"
/> />
{#each homes as home, seat} {#each parchisHomes as home, house}
{@const owner = room.players.find((p) => houseForSeat(p.seat) === seat)} {@const owner = room.players.find((p) => houseForSeat(p.seat) === house)}
<g opacity={owner ? 1 : 0.45}> {@const color = colors[house]}
<rect <g
x={home[0] * 30 + 8} class="player-home"
y={home[1] * 30 + 8} data-color={color.id}
width="224" aria-label={`Casa ${houseLabels[house]}: ${owner?.nick ?? 'libre'}`}
height="224" >
rx="25" <circle
fill={colors[seat]} cx={home[0] + 100}
fill-opacity="0.16" cy={home[1] + 100}
r="88"
fill={color.hex}
stroke="#293447"
stroke-width="1.5"
/> />
<rect <circle
x={home[0] * 30 + 45} cx={home[0] + 100}
y={home[1] * 30 + 45} cy={home[1] + 100}
width="150" r="68"
height="150" fill="none"
rx="35" stroke="#ffffff"
fill="#fffdf7" stroke-width="1.5"
stroke={colors[seat]}
stroke-opacity="0.4"
stroke-width="2"
/> />
{#each Array(4) as _, i}<circle {#each Array(4) as _, piece}{@const spot = parchisHomePoint(
cx={home[0] * 30 + 85 + (i % 2) * 70} house,
cy={home[1] * 30 + 85 + Math.floor(i / 2) * 70} piece,
r="22" )}
fill={colors[seat]} <circle
fill-opacity="0.11" cx={spot[0]}
/>{/each} 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})`}
/>
{: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 <text
x={home[0] * 30 + 120} x={home[0] + 100}
y={home[1] * 30 + 216} y={home[1] + 197}
text-anchor="middle" text-anchor="middle"
fill={colors[seat]} font-size="10"
font-size="13" font-weight="800"
font-weight="700">{owner?.nick.slice(0, 20) ?? 'Casa libre'}</text fill="#202c41">{owner?.nick.slice(0, 20) ?? 'Casa libre'}</text
> >
</g> </g>
{/each} {/each}
{#each track as cell, i} {#each colors as color, house}
{@const seat = houseStarts.indexOf(i)} {#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 safe = room.state?.safe?.includes(i)}
{@const id = `track-${i}`} {@const cap = [16, 33, 50, 67].includes(i)}
<g {#snippet trackDrawing()}
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 <rect
x={cell[0] * 30 + 1} x={cell.x}
y={cell[1] * 30 + 1} y={cell.y}
width="28" width={cell.width}
height="28" height={cell.height}
rx="3" fill={house >= 0 ? colors[house].hex : '#ffffff'}
fill={seat >= 0 ? colors[seat] : safe ? '#e1e8d8' : '#fffdf7'} stroke="#293447"
stroke="#c8c9b9"
stroke-width="0.8" 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 <text
x={cell[0] * 30 + 15} x={cap ? cell.center[0] : cell.label[0]}
y={cell[1] * 30 + 19} 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" text-anchor="middle"
fill={seat >= 0 ? '#fff' : '#5c6855'} font-size={cap ? 10 : 12}
font-size="9" font-weight="700"
font-weight="600">{i + 1}</text fill="#202c41">{i + 1}</text
> >
</g> {/snippet}
{/each} {#if house >= 0}
{#each colors as color, seat} <g
{#each Array(7) as _, step}{@const cell = lane(
seat,
step,
)}{@const id = `lane-${seat}-${step}`}<g
class="cell-hotspot" class="cell-hotspot"
class:active={inspected === id} class:active={inspected === i}
role="button" role="button"
tabindex="0" tabindex="0"
aria-label={describeCell(id).info.title} aria-label={exitInfo(i).title}
aria-expanded={selected === id} aria-expanded={selected === i}
onpointerenter={(e) => { onpointerenter={(e) => {
if (e.pointerType === 'mouse') hovered = id; if (e.pointerType === 'mouse') hovered = i;
}} }}
onpointerleave={() => (hovered = null)} onpointerleave={() => (hovered = null)}
onfocus={() => (hovered = id)} onfocus={() => (hovered = i)}
onblur={() => (hovered = null)} onblur={() => (hovered = null)}
onclick={() => (selected = selected === id ? null : id)} onclick={() => (selected = selected === i ? null : i)}
onkeydown={(e) => keyInfo(e, id)} onkeydown={(e) => keyInfo(e, i)}
><rect data-cell={i + 1}
x={cell[0] * 30 + 1} >
y={cell[1] * 30 + 1} {@render trackDrawing()}
width="28" </g>
height="28" {:else}<g data-cell={i + 1}>{@render trackDrawing()}</g>{/if}
rx="3"
fill={color}
fill-opacity="0.28"
stroke={color}
stroke-opacity="0.5"
stroke-width="0.8"
/></g
>{/each}
{/each} {/each}
<path d="M240 240L285 285L330 240Z" fill={colors[3]} fill-opacity="0.8" /> <path
<path d="M330 240L285 285L330 330Z" fill={colors[0]} fill-opacity="0.8" /> d="M200 200L300 300L400 200Z"
<path d="M330 330L285 285L240 330Z" fill={colors[1]} fill-opacity="0.8" /> fill={colors[2].hex}
<path d="M240 330L285 285L240 240Z" fill={colors[2]} fill-opacity="0.8" /> stroke="#293447"
<g stroke-width="1.2"
class="cell-hotspot" />
role="button" <path
tabindex="0" d="M400 200L300 300L400 400Z"
aria-label="Meta del parchís" fill={colors[1].hex}
onpointerenter={(e) => { stroke="#293447"
if (e.pointerType === 'mouse') hovered = 'goal'; stroke-width="1.2"
}} />
onpointerleave={() => (hovered = null)} <path
onfocus={() => (hovered = 'goal')} d="M400 400L300 300L200 400Z"
onblur={() => (hovered = null)} fill={colors[0].hex}
onclick={() => (selected = selected === 'goal' ? null : 'goal')} stroke="#293447"
onkeydown={(e) => keyInfo(e, 'goal')} stroke-width="1.2"
><circle cx="285" cy="285" r="13" fill="#fffdf7" /> />
<text x="285" y="290" text-anchor="middle" fill="#6b7957" font-size="17" <path
>✳</text d="M200 400L300 300L200 200Z"
> fill={colors[3].hex}
</g> stroke="#293447"
stroke-width="1.2"
/>
{#each room.state?.pieces ?? [] as pieces, seat} {#each room.state?.pieces ?? [] as pieces, seat}
{@const color = colors[houseForSeat(seat)]} {@const color = colors[houseForSeat(seat)]}
{#each pieces as progress, piece (piece)}{@const pos = point( {#each pieces as progress, piece (piece)}
seat, {@const pos = point(seat, piece, progress)}
piece,
progress,
)}
{@const playable = {@const playable =
!disabled && !disabled &&
seat === room.seat && seat === room.seat &&
@ -325,59 +305,62 @@
? 'en meta' ? 'en meta'
: progress >= outer : progress >= outer
? 'pasillo final' ? '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 {#if playable}<circle
cx={pos[0]} cx={pos[0]}
cy={pos[1]} cy={pos[1]}
r={progress === -1 ? 25 : 17} r={progress === -1 ? 23 : 13}
fill="none" fill="none"
stroke={color} stroke="#202c41"
stroke-width="3" stroke-width="2.5"
class="piece-halo" class="piece-halo"
/>{/if} />{/if}
<circle <circle
cx={pos[0] + 1} cx={pos[0] + 1}
cy={pos[1] + 2} cy={pos[1] + 2}
r={progress === -1 ? 19 : 12} r={progress === -1 ? 17 : 10}
fill="#253324" fill="#202c41"
fill-opacity="0.2" fill-opacity="0.25"
/> />
<circle <circle
cx={pos[0]} cx={pos[0]}
cy={pos[1]} cy={pos[1]}
r={progress === -1 ? 19 : 12} r={progress === -1 ? 17 : 10}
fill={color} fill={color.hex}
stroke="#fffdf7" stroke="#ffffff"
stroke-width="2" stroke-width="2"
/> />
<circle <circle
cx={pos[0]} cx={pos[0]}
cy={pos[1]} cy={pos[1]}
r={progress === -1 ? 14 : 8} r={progress === -1 ? 13 : 7}
fill="none" fill="none"
stroke="#fffdf7" stroke="#202c41"
stroke-opacity="0.4" stroke-opacity="0.45"
stroke-width="0.8"
/> />
<text <text
x={pos[0]} x={pos[0]}
y={pos[1] + 4} y={pos[1] + 4}
text-anchor="middle" text-anchor="middle"
fill="#fff" fill="#202c41"
font-size={progress === -1 ? 15 : 11} font-size={progress === -1 ? 13 : 10}
font-weight="700">{progress === goal ? '✓' : piece + 1}</text font-weight="800">{progress === goal ? '✓' : piece + 1}</text
> >
</g> </g>
{/each} {/each}
{/each} {/each}
</svg> </svg>
{#if inspected}{@const detail = describeCell(inspected)}<CellInfo {#if inspected !== null}{@const cell = parchisTrack[inspected]}
info={detail.info} <CellInfo
x={((detail.point[0] + 8) / 586) * 100} info={exitInfo(inspected)}
y={((detail.point[1] + 8) / 586) * 100} x={((cell.center[0] + 8) / 616) * 100}
y={((cell.center[1] + 8) / 616) * 100}
pinned={selected !== null} pinned={selected !== null}
onclose={dismiss} onclose={dismiss}
/>{/if} />
{/if}
</div> </div>
<style> <style>
@ -402,8 +385,8 @@
} }
@media (hover: hover) { @media (hover: hover) {
.cell-hotspot:hover { .cell-hotspot:hover {
transform: scale(1.18); transform: scale(1.1);
filter: drop-shadow(0 2px 2px #27365240); filter: drop-shadow(var(--shadow-board));
} }
} }
g.playable { g.playable {

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

@ -173,25 +173,29 @@
/* Player identities remain distinct in either theme. */ /* Player identities remain distinct in either theme. */
:root {
--piece-number: #202c41;
}
.player-color-0 { .player-color-0 {
--player-ink: #775800; --player-ink: #775800;
--player-soft: #fff3ce; --player-soft: #fff3ce;
--player-solid: #b88925; --player-solid: #ffde00;
} }
.player-color-1 { .player-color-1 {
--player-ink: #165ba1; --player-ink: #165ba1;
--player-soft: #e6f1ff; --player-soft: #e6f1ff;
--player-solid: #397bba; --player-solid: #00aeef;
} }
.player-color-2 { .player-color-2 {
--player-ink: #aa3938; --player-ink: #aa3938;
--player-soft: #ffecea; --player-soft: #ffecea;
--player-solid: #b95f56; --player-solid: #ff2438;
} }
.player-color-3 { .player-color-3 {
--player-ink: #217146; --player-ink: #217146;
--player-soft: #e6f5eb; --player-soft: #e6f5eb;
--player-solid: #568264; --player-solid: #28c800;
} }
.player-color-4 { .player-color-4 {
--player-ink: #30343b; --player-ink: #30343b;
@ -201,12 +205,12 @@
.player-color-5 { .player-color-5 {
--player-ink: #985023; --player-ink: #985023;
--player-soft: #fff0e1; --player-soft: #fff0e1;
--player-solid: #ad702f; --player-solid: #ff861c;
} }
.player-color-6 { .player-color-6 {
--player-ink: #703cad; --player-ink: #703cad;
--player-soft: #f2eaff; --player-soft: #f2eaff;
--player-solid: #8654b8; --player-solid: #9855ef;
} }
.player-color-7 { .player-color-7 {
--player-ink: #536070; --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'), platform.chooseColor(one, room.id, 'amarillo'),
).rejects.toThrow('sala de espera'); ).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, { const room = await platform.create(one, {
gameId: 'oca', gameId: 'oca',
name: 'Colores Oca', name: 'Colores Oca',
@ -93,19 +93,45 @@ describe('persistencia y permisos', () => {
}); });
await platform.chooseColor(one, room.id, 'verde'); await platform.chooseColor(one, room.id, 'verde');
await platform.join(two, room.code); 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'); 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')); const recovered = new FileStore(join(directory, 'store.json'));
await recovered.init(); await recovered.init();
expect( expect(
(await new Platform(recovered).view(room.id, one.id)).players[0].colorId, (await new Platform(recovered).view(room.id, one.id)).players[0].colorId,
).toBe('amarillo'); ).toBe('azul');
await recovered.close(); await recovered.close();
await platform.ready(two, room.id, true); await platform.ready(two, room.id, true);
await platform.ready(one, room.id, true); await platform.ready(one, room.id, true);
expect((await platform.view(room.id, one.id)).status).toBe('playing'); 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 () => { it('no expone salas privadas, correos ni credenciales', async () => {
const room = await create(); const room = await create();
expect(await platform.list(outsider.id)).toEqual([]); expect(await platform.list(outsider.id)).toEqual([]);

Loading…
Cancel
Save

Powered by TurnKey Linux.