Portada de «Baker’s Game sin duplicar FreeCell: una regla distinta sobre el mismo motor» — Arquitectura de Blupoli.

El problema parecía demasiado fácil, y eso era precisamente el riesgo

La Issue #309 pedía añadir Baker’s Game a Blupoli Cards tomando como referencia reglas públicas, no código ni diseño ajeno. A primera vista la implementación parecía casi mecánica: el reparto es el de FreeCell, hay ocho cascadas, cuatro celdas libres, cuatro fundaciones y toda la baraja está boca arriba. Si sólo mirábamos la forma del estado, copiar el módulo de FreeCell y cambiar un par de condiciones habría producido una versión jugable con rapidez.

Ese camino también habría creado dos motores que empezarían prácticamente iguales. Cada corrección futura de supermovimientos, fundaciones, capacidad, autocompletado o detección de bloqueo tendría que aplicarse dos veces. Lo peor no sería el volumen inicial de código, sino la divergencia silenciosa. Dos archivos copiados pueden parecer seguros durante una semana y convertirse después en dos interpretaciones distintas de una misma regla compartida.

La pregunta útil era otra: ¿Baker’s es un juego completamente nuevo o una especialización de una familia ya existente? La respuesta que encontramos en el código fue bastante clara. El reparto, las celdas libres, las fundaciones, los tipos de movimiento, el cálculo de capacidad y la condición de victoria coincidían. La diferencia esencial estaba en cómo se construye y se valida una secuencia dentro de una cascada.

Antes de tocar nada, revisamos la frontera real de Cards

Cards ya había pasado por una evolución importante desde su primera versión. El Devlog del segundo producto contaba una base más compacta. Después aparecieron módulos de dominio por juego, un solitaire-engine.js central, una sesión común, repositorios de persistencia, renderers compartidos, mapeo de movimientos y una suite creciente de tests unitarios y Playwright. Baker’s debía entrar en esa arquitectura, no en la versión histórica que todavía aparecía en artículos anteriores.

La revisión del repositorio mostró que FreeCell estaba aislado en apps/cards/src/domain/games/freecell.js y que el motor central registraba cada kind. La UI no conocía reglas concretas; traducía selecciones y drops a movimientos de dominio. La persistencia guardaba estados genéricos por tipo. El renderer de FreeCell ya sabía dibujar ocho cascadas, cuatro celdas y fundaciones. Ese reparto de responsabilidades hacía posible una reutilización mucho más limpia que copiar una pantalla.

También comprobamos el contrato arquitectónico documentado en docs/decisions/cards-solitaire-engine.md. La dirección era UI → sesión → dominio, con infraestructura para persistencia. Si Baker’s necesitaba que el renderer supiera cuándo dos palos coinciden, significaba que habíamos roto esa frontera. La regla debía vivir donde ya vivían las reglas de FreeCell: en Domain.

La solución mínima: un adaptador muy pequeño y una condición explícita

El nuevo archivo bakers.js es deliberadamente diminuto. Reexporta la máquina de estado de FreeCell: creación, aplicación de movimientos, movimientos legales, capacidad, bloqueo y victoria. Su función no es fingir que existe un motor independiente; es dar a solitaire-engine.js un módulo con identidad propia para el nuevo kind.

La especialización real ocurre dentro de freecell.js. Añadimos una función sameSuitBuild(state) que devuelve verdadero cuando state.kind es bakers. A partir de ahí, canCascade y movable eligen entre dos predicados. FreeCell continúa exigiendo secuencias descendentes de colores alternos; Baker’s exige secuencias descendentes del mismo palo.

No convertimos el módulo en una colección de if repartidos por cada operación. La diferencia se concentra en los puntos donde realmente importa la semántica del tableau. Mover una carta a una celda libre no cambia. Llevar una carta a una fundación no cambia. Calcular cuántas cartas caben en un supermovimiento no cambia. La victoria sigue siendo tener trece cartas en cada fundación. Compartir esas partes reduce superficie de error sin ocultar la regla diferencial.

Diagrama donde state.kind se bifurca hacia FreeCell y Baker’s y ambas rutas vuelven a una verificación común
Una identidad de juego distinta selecciona la regla de construcción; el resto de la familia conserva contratos comunes.

Por qué no creamos un parámetro de variante genérico

Otra opción habría sido tratar Baker’s como una variante de FreeCell, igual que Klondike usa opciones de robo o Spider puede tener varios palos. Técnicamente era posible añadir algo como buildMode: sameSuit al objeto de variante. No lo hicimos porque, desde el punto de vista del producto, Baker’s es una entrada propia del catálogo, con ruta, copy, estadísticas y reglas propias.

El kind ya es la identidad que atraviesa motor, persistencia, rutas, catálogo y analytics locales. Usarlo para seleccionar una regla de dominio mantiene alineada esa identidad. Una variante debería describir opciones dentro del mismo juego; aquí necesitábamos un juego distinto que comparte una familia de implementación.

Esa distinción también evita URLs o partidas ambiguas. Un estado guardado como freecell no debería reaparecer con reglas de Baker’s porque una preferencia cambió. Un estado bakers se restaura siempre con construcción por palo. La reutilización de código no obliga a mezclar identidad de producto.

El reparto compartido no necesitaba saber el nombre del juego

Durante la revisión apareció una comprobación importante: la función create de FreeCell devuelve las cascadas, celdas y fundaciones, pero no fija por su cuenta kind: freecell. Es createGame quien añade la identidad externa. Esa separación era fundamental para que el adaptador de Baker’s funcionara sin envolver o modificar el reparto.

Si freecell.create() hubiese escrito un tipo fijo dentro del estado, reexportarla desde bakers.js habría sido un bug: el juego se habría creado con identidad FreeCell y la condición sameSuitBuild nunca se activaría. Revisar ese detalle antes de asumir comportamiento fue una aplicación directa del principio del proyecto: el repositorio es la fuente de verdad, no nuestra memoria sobre cómo debería estar construido.

El contrato actual resulta más limpio: el módulo crea la parte específica del tablero y el motor central añade versión, kind, semilla, variante, contador de movimientos y estado de victoria. Baker’s puede compartir creación porque la identidad pertenece a una capa superior.

La capacidad de supermovimientos se conserva exactamente

Una parte tentadora de “simplificar” Baker’s habría sido permitir mover cualquier secuencia correcta del mismo palo como un bloque, independientemente de las celdas disponibles. Eso habría hecho la interacción más cómoda, pero habría cambiado el juego. FreeCell y Baker’s utilizan las celdas y cascadas vacías como almacenamiento temporal implícito, y el tamaño de un movimiento múltiple depende de ese espacio.

El método moveCapacity ya calculaba esa capacidad a partir del número de celdas libres y cascadas vacías, descontando la cascada de destino si también está vacía. No había ninguna razón de dominio para duplicarlo. Baker’s reutiliza exactamente ese cálculo. Sólo cambia qué secuencia puede considerarse ordenada antes de comprobar si su longitud cabe.

Esto es un ejemplo de reutilización semántica, no sólo sintáctica. Compartimos la función porque la regla es la misma. Si mañana una variante de cartas tuviera otra definición de capacidad, no deberíamos forzarla dentro de este método sólo para mantener un archivo menos.

La regla de cascada vacía tenía que ser una decisión explícita

Baker’s aparece en distintas implementaciones con matices alrededor de las columnas vacías. Para Blupoli elegimos la variante clásica abierta en la que una cascada vacía acepta cualquier carta o secuencia legal, sujeta a la capacidad normal de supermovimiento. La elección quedó escrita tanto en la documentación SDD como en el copy de reglas y los tests.

La implementación resulta casi invisible porque canCascade ya considera válido un destino vacío. Eso no significa que la decisión no importe. Una línea de código heredada puede ser correcta por casualidad o correcta por contrato. Documentarla convierte ese comportamiento en parte intencional del juego y permite que una futura refactorización sepa qué debe preservar.

También evitamos introducir una opción “rey solamente” sin que el producto la necesitara. Más variantes significan más combinaciones de estado, copy, tests y UX. Preferimos una versión definida y verificable antes que un selector de reglas cuya única ventaja fuese demostrar flexibilidad.

Integrar el motor era sólo la mitad del trabajo

Añadir bakers a GAME_KINDS y al mapa de módulos permite crear partidas, pero una aplicación de Cards atraviesa más capas. El autocompletado de fundaciones necesitaba reconocer Baker’s como miembro de la familia. El move mapper debía producir movimientos de cascada, celda y fundación. El renderer debía usar ocho columnas. La lógica de selección tenía que validar celdas libres y cascadas de la misma forma que FreeCell.

En lugar de crear ramas nuevas completas, ampliamos las listas donde ya existía una familia explícita. Allí donde el código decía “si el juego es FreeCell”, revisamos si la condición semántica era realmente “si el juego usa el modelo de cascadas y celdas de FreeCell”. Cuando la respuesta era sí, la condición pasó a incluir bakers. Cuando la respuesta era no, no tocamos nada.

Este tipo de revisión parece menor, pero evita una forma habitual de deuda: copiar un renderer y descubrir meses después que una mejora de accesibilidad o tamaño de cartas llegó a uno y no al otro. La misma superficie visual sirve a ambos porque la geometría es realmente compartida.

Doble clic, drag, toque y teclado tenían que seguir siendo coherentes

Cards no puede considerar implementado un juego únicamente porque el motor acepte movimientos. La interacción compartida admite selección por toque, drag, doble clic o doble toque hacia fundación y atajos de teclado para acciones comunes. Baker’s necesitaba entrar en esos caminos sin recibir una capa especial de eventos.

El move mapper fue un punto importante. Las dos ramas que antes comprobaban state.kind === freecell pasaron a aceptar la pareja freecell/bakers. Así, un drop desde cascada a cascada, desde celda a cascada o hacia fundación produce los mismos tipos de movimiento. La legalidad definitiva sigue en Domain.

Esta separación es la razón por la que la UI no necesita saber si un siete de corazones puede caer sobre un ocho de picas. Construye la intención “mover esta secuencia a esa cascada” y el motor decide. Si mañana cambiamos una regla, el listener de pointer no debería tener que enterarse.

El E2E reveló un detalle de selección que merecía ser respetado

La prueba de navegador para Baker’s construye un estado pequeño y controlado: una secuencia 7♠–6♠, un 8♠ válido y un 8♥ inválido. Primero selecciona la secuencia e intenta soltarla sobre el ocho de corazones. El contador de movimientos debe permanecer en cero. Después vuelve a seleccionar el origen y prueba el ocho de picas; el movimiento debe realizarse y la carta inferior aparece en la nueva posición.

La necesidad de volver a seleccionar el origen no es ruido del test. Cuando un drop inválido termina en un elemento seleccionable, la UI actualiza la selección a ese destino. La primera versión de la prueba asumía que el origen seguiría seleccionado y habría fallado por una expectativa incorrecta sobre interacción, no por las reglas de Baker’s.

Ajustar el E2E para reflejar el comportamiento real fue mejor que modificar la UI sólo para satisfacer la prueba. Si queremos cambiar esa semántica de selección en el futuro, será una decisión de UX para toda la familia, no una excepción añadida al nuevo juego.

El primer CI falló por el test, no por el juego

La primera ejecución de Quality and Firebase Preview asociada a la PR #312 llegó a 273 pruebas superadas y una fallida. El caso nuevo “Baker’s Game builds same-suit sequences and keeps classic empty cascades open” lanzaba ReferenceError: getLegalMoves is not defined. Habíamos escrito correctamente la prueba, pero olvidado importar la función desde solitaire-engine.js.

El fallo ocurrió antes de las fases de Cards preview y E2E, así que el workflow las saltó. Eso es exactamente lo que queremos de un gate: no seguir publicando una preview cuando la suite de fuente no está verde. El arreglo fue pequeño —añadir el import que faltaba—, pero la tarea no podía darse por terminada mientras CI siguiera rojo.

También fue una buena advertencia contra interpretar “fallo trivial” como “fallo ignorable”. Si el proceso permite saltarse un rojo porque creemos entenderlo, deja de ser un proceso verificable. Corregimos el test y dejamos que el workflow volviera a demostrar el estado real de la rama.

Mientras validábamos, Montana entró primero en main

Baker’s no era la única ampliación de Cards en curso. Montana Solitaire, PR #310, partía de la misma base y llegó a main antes. Eso convirtió la PR #312 en una rama divergida: ambos cambios tocaban el registro de juegos, copy, traducciones, builder, tests y E2E. Un merge mecánico podía perder Montana o dejar contadores incoherentes.

La solución no fue elegir “nuestros” archivos frente a “los suyos”. Recolocamos Baker’s sobre el estado actual de main y reaplicamos la feature conservando Montana. Allí donde antes el copy decía once juegos, la integración debía reflejar el catálogo resultante. Las listas de juegos de test debían contener ambos slugs. Los símbolos del builder, el motor y las rutas debían conservar los dos módulos.

Ese episodio explica por qué los cambios de catálogo necesitan algo más que resolver marcadores de conflicto. Dos ramas pueden fusionar sintácticamente y aun así producir una verdad semántica incorrecta, por ejemplo un hero que promete once juegos mientras el objeto de copy contiene doce. La revisión del diff tiene que mirar el producto final.

Durante el cierre ocurrió una segunda carrera: Russian Solitaire (#311) también llegó a main. Repetimos la misma disciplina y Baker’s pasó a ser el decimotercer juego. El contador, las listas de E2E y el registro del motor se reconciliaron otra vez desde el estado publicado, preservando Montana, Russian y el rediseño de cartas que había entrado entre medias.

Incluso la maniobra de rebase tuvo una consecuencia visible

Para limpiar la divergencia, la rama de Baker’s se movió temporalmente al mismo commit que main antes de reaplicar los cambios. Durante ese instante GitHub detectó una PR sin diferencias y cerró #312 automáticamente. No fue pérdida de código, pero sí una consecuencia operativa que había que entender antes de continuar.

Los commits reaplicados siguieron existiendo en la rama. La solución consistía en terminar la reconciliación, comprobar el nuevo head y reabrir la PR con un diff real. Este detalle no aporta ninguna regla a Baker’s, pero sí una lección de flujo: una operación técnicamente válida sobre refs puede cambiar el estado de colaboración alrededor de ellas.

Por eso el cierre de la tarea incluye Issue, rama, PR y Actions, no sólo archivos. El repositorio puede contener la implementación correcta mientras la PR está cerrada accidentalmente o apunta a una base desactualizada. El estado de entrega forma parte del trabajo.

El catálogo y el SEO también son código de producto

Baker’s añadió copy de reglas y descripción en inglés, español, italiano, portugués, francés y alemán. Las rutas de Cards se generan desde GAME_COPY, así que registrar el nuevo slug produce sus páginas localizadas, canonical, hreflang, sitemap y tarjetas de catálogo. El builder también necesita un símbolo visual y descripciones globales coherentes.

Este trabajo puede parecer periférico comparado con canCascade, pero una feature que sólo existe en el motor no está publicada. El usuario necesita descubrirla, leer las reglas en el idioma de la interfaz y llegar a una URL estable. La arquitectura de Cards permite que gran parte de esa integración sea declarativa, pero sigue siendo responsabilidad de la PR aportar datos correctos.

El artículo del Blog sobre cómo cambia la estrategia frente a FreeCell completa la capa pública. El juego explica sus reglas brevemente; el Blog puede detenerse en la experiencia; este Devlog conserva la historia técnica. Son tres superficies con objetivos distintos que se enlazan entre sí.

Los tests se diseñaron alrededor de la diferencia, no del parecido

Un test que sólo comprobara que Baker’s reparte cincuenta y dos cartas habría demostrado la parte que ya sabíamos que compartía con FreeCell. La cobertura importante tenía que capturar la frontera: una secuencia 7♠–6♠ puede moverse sobre 8♠; la misma secuencia no puede moverse sobre 8♥; una cascada vacía sigue siendo destino válido; una secuencia 7♠–6♥ no se vuelve móvil sólo porque sus colores alternen.

También incluimos Baker’s en el caso de autocompletado cercano a victoria para asegurar que el motor común puede terminar fundaciones sin confundir el tipo de juego. Los tests de catálogo y build obligan a que el slug aparezca en el conjunto publicado. Playwright lo recorre en desktop y en un viewport táctil de 390 píxeles para comprobar que la integración no rompe el layout compartido.

Ese enfoque sigue una regla útil: cuanto más código se comparte, más deben concentrarse los tests nuevos en la diferencia de comportamiento. Repetir toda la suite de FreeCell con otro nombre aporta poco; fijar la regla que podría perderse en un refactor aporta mucho.

La documentación SDD quedó pequeña a propósito

El plan de Baker’s describe objetivo, decisiones y pasos. Las tareas separan familia FreeCell, motor, UX, catálogo y verificación. No intentamos convertir los documentos en una segunda especificación del código. La intención del SDD ligero es que obligue a decidir antes de implementar y deje un mapa revisable, no que duplique cada función.

Una decisión sí merecía quedar escrita: las cascadas vacías aceptan cualquier carta o secuencia legal. Otra: Baker’s comparte la familia FreeCell en vez de duplicarla. Esos dos puntos son suficientemente importantes para explicar el diseño y suficientemente estables para sobrevivir a cambios de nombres internos.

Después de la integración con Montana, actualizamos además el contexto del catálogo. La documentación no puede conservar “undécimo juego” si main ya ha cambiado. Mantenerla sincronizada evita que una persona futura reconstruya una secuencia histórica incorrecta a partir de un documento que parecía canónico.

Qué evitamos construir

No creamos un “motor universal de solitarios” capaz de expresar cualquier regla mediante JSON. No añadimos una jerarquía de clases para FreeCell y Baker’s. No introdujimos flags genéricos para cada posible diferencia de tableau. No duplicamos el renderer. Y no añadimos una capa de compatibilidad para una arquitectura antigua que ya no existe.

La solución elegida es más modesta: una familia compartida con una condición de dominio bien nombrada. Puede parecer menos ambiciosa, pero se adapta mejor a la evidencia actual. Si más juegos futuros comparten el mismo modelo con diferencias adicionales, tendremos datos para decidir si extraer una estrategia más formal. Hoy no necesitamos inventarla.

Evitar abstracción prematura también reduce el coste de lectura. Quien abre freecell.js puede ver de inmediato qué cambia para Baker’s. No tiene que seguir una cadena de factories y políticas para descubrir que la única diferencia relevante es “alternating colors versus same suit”.

La verificación final importa más que la elegancia del diff

Una implementación puede parecer limpia en revisión y aun así fallar por un import, una ruta no generada o un selector de E2E. El proceso de Blupoli considera GitHub Actions el gate principal cuando ya cubre tests, build y navegador. No repetimos manualmente lo que CI valida de forma fiable sólo para obtener una sensación adicional de seguridad.

La revisión manual se reserva para lo que la automatización no juzga bien: que la variante elegida esté descrita con claridad, que la integración no pise un juego que entró en paralelo, que el diff no contenga código muerto y que la relación entre FreeCell y Baker’s siga siendo comprensible. Esa combinación de checks automáticos y lectura semántica es más útil que ejecutar la misma suite dos veces en lugares diferentes.

La tarea sólo se considera cerrada cuando la PR vuelve a estar basada en main, los checks relevantes pasan, el merge ocurre y main contiene los archivos esperados. Publicar el juego sin esa cadena completa habría contradicho la razón por la que construimos una plataforma verificable.

La lección: compartir comportamiento, no identidad

Baker’s Game dejó una frontera que probablemente reutilizaremos. Dos juegos pueden compartir reparto, estructura, tipos de movimiento, UI, persistencia y cálculo de capacidad sin convertirse en una sola entrada de catálogo. La identidad pertenece al producto; la implementación puede compartir todo aquello cuya semántica sea realmente igual.

La clave es no utilizar “DRY” como objetivo aislado. Si hubiéramos obligado a compartir una regla que no coincide, habríamos creado un motor elegante y un juego incorrecto. Si hubiéramos copiado todo para mantener pureza de identidad, habríamos creado mantenimiento duplicado. El punto útil está entre ambos: compartir contratos estables y especializar el comportamiento que define la variante.

El resultado para el jugador es Baker’s Game, una mesa que se siente integrada en Cards pero toma decisiones distintas de FreeCell. El resultado para el código es más interesante: un juego nuevo añadió una regla, no un segundo motor completo. Esa es la clase de crecimiento que queríamos demostrar cuando empezamos a convertir Cards en una colección capaz de ampliarse sin multiplicar deuda.

Lecturas relacionadas

La capa de producto que hizo posible separar Cards se cuenta en De un producto a dos. El enfoque de verificación está desarrollado en De juegos a plataforma verificable y el trabajo de localización y gates en i18n en seis idiomas y gates de calidad. Para la perspectiva de juego, la pareja natural de este texto es Baker’s Game: cuando FreeCell empieza a pensar por palos.