Conclusiones sobre la generalización del modelo
Resultado de evaluar juegos reales (Dominion, Race for the Galaxy, Shards of Infinity) contra el modelo actual: ocho cambios, separados en estructura (ahora, completos) y motor (por fases). Fuente: conclusiones-modelo.md (verbatim).
Documento de trabajo. Resume el análisis hecho a partir de reference-structures.html, findings-game-probes.html y las probes de Dominion / Race for the Galaxy.
Contexto del producto: creador de juegos de cartas de mesa para imprimir. La partida real se juega en vivo; el simulador existe para que el usuario pueda testear el diseño antes de imprimir. Posible expansión futura a app jugable y/o TCG digital.
0 · Postura general
Compromiso total con el formato, pragmatismo con el motor.
Los cambios de forma (esquema) son casi imposibles de revertir una vez haya juegos autorados por usuarios: obligan a migrar contenido escrito por gente que ya no está. Los cambios de ejecución (motor) se pueden rehacer cuantas veces haga falta sin tocar los datos.
Por tanto: hacer los cambios de esquema ahora y completos, aunque el motor solo consuma una fracción. Es lo que ya se hizo con players[].modifiers, action_bindings y card_families — andamio vacío. La intuición era correcta; falta completar el conjunto.
| Decisión | Coste hoy | Coste con 5.000 juegos autorados |
|---|---|---|
| Valor puede ser expresión | pequeño | migración de todo |
trigger es patrón, no enum | pequeño | migración + semántica ambigua |
modifiers[] separado de effects[] | pequeño | reinterpretar efectos ya autorados |
Efecto lleva subject/from/to | pequeño | catálogo fosilizado |
Zona tiene parent (aunque siempre sea null) | trivial | cambio de esquema |
| Cola de pasos heterogénea | trivial | — |
| Motor que suspende y reanuda | caro | igual de caro |
| Resolutor de modificadores | caro | igual de caro |
| Bots buenos | caro | igual de caro |
Regla sobre el andamio: un campo reservado necesita esquema y semántica escritos, aunque nadie lo lea. Reservar el nombre con la forma equivocada es peor que no reservarlo (caso actual de card_families).
1 · Valores como expresión
Problema: todo número es un literal autorado. Gardens ("1 PV por cada 10 cartas de tu mazo") no cabe: en dominion.json acabó como un effect_key con trigger: on_play, que es un error de categoría — la puntuación no es un efecto y no ocurre al jugar la carta.
Cambio: tipo unión valor | expresión en atributos, costes, cantidades de parámetros, copies, starts_at, umbrales.
"attributes": { "vp": 1 }
"attributes": { "vp": { "floor_div": [ { "count_cards": {} }, 10 ] } }
Desbloquea: G12 (score-from-cards), G4 (Gardens), trackers derivados, costes computados, cantidades de setup escaladas por número de jugadores (Province 8/12, Curse 10/20/30).
Motor: extender el evaluador que ya existe para ending.when.
Aviso: la validación cambia de naturaleza. validateCardAgainstGame() pasa de comprobar rangos a comprobar tipos y existencia de referencias en expresiones. Es un componente nuevo, no una extensión — contarlo en el esfuerzo.
Es innegociable: sin esto los juegos terminan 0–0 y el simulador no puede decir quién gana.
2 · Selector de sujeto en el efecto
Problema: el "a quién" está soldado dentro del nombre del efecto (_to_self, _to_opponent). 5 verbos × 6 alcances = 30 entradas de catálogo. Crece multiplicando.
Matiz importante: el parámetro que falta no es la zona, es de qué jugador es esa zona. draw_to_self y draw_to_opponent no se diferencian en la zona.
Cambio: bloque de destino en el efecto de la carta.
{
"effect_key": "move_cards",
"trigger": "on_play",
"parameters": { "amount": 1 },
"subject": { "who": "each_opponent" },
"from": "draw_pile",
"to": "hand"
}
El catálogo sobrevive como presets que se materializan en esta forma — mismo patrón que ya se usa con los subtipos de carta.
Terminología: las cartas tienen effects, no actions. actions son los verbos de juego que el bot elige en una fase. Son dos catálogos distintos; esto consolida el de efectos.
Desbloquea: G2 (Council Room: "cada rival roba 1" = verbo + selector). No basta para Militia — eso necesita el punto 3.
3 · La decisión como estructura
Problema: el motor ejecuta los efectos de principio a fin sin poder preguntar nada a nadie. "Cada rival descarta hasta quedarse con 3" es imposible, no por el targeting sino porque no hay forma de detenerse a que alguien elija.
Ya existe media pieza: params[].source: design | player en el catálogo de efectos. Falta que player describa qué se elige y quién elige.
Cambio en la carta — la spec de selección:
"parameters": {
"cards": {
"source": "player",
"chooser": "subject",
"from": "hand",
"count": { "expr": "size_of(hand) - 3" },
"filter": null,
"optional": false
}
}
Cinco campos cubren: descartar hasta N, pagar eligiendo cartas, elegir objetivo, robar y quedarse con M, cartas modales, elecciones del reparto inicial.
Cambio en el runtime:
pending_decisions[]en el Game object- entradas de tipo
decisionenhistory - estado
awaiting_decisionenflow
Rebaja por el contexto de producto: si el simulador es solo bots, una decisión es una llamada a la política del bot en línea — no hace falta suspender ni reanudar nada. Se implementa la estructura (barata) y se aplaza el motor (caro). Mantener el diseño abierto para un futuro modo "jugar contra bots" con humano.
Crítico: hoy el replay es determinista solo con seed. En cuanto haya decisiones, depende también del log de decisiones. Si no se guarda, se pierde la reproducibilidad que ya se tiene. Con un TCG competitivo detrás, ese historial deja de ser comodidad de depuración y pasa a ser el mecanismo de resolución de disputas.
4 · El flujo como cola mutable de pasos
Problema: el turno es una lista fija de fases que se ejecuta entera. Nada puede insertar nada.
Dos correcciones a la formulación inicial ("crear la pila al inicio del turno"):
- No basta con crearla al inicio del turno — tiene que ser modificable durante la resolución (una carta puede empujar un paso a mitad de un efecto).
- La cola no es de fases, es de pasos heterogéneos.
Hoy flow.phase_queue: ["cleanup"] son strings. Pasa a objetos:
"step_queue": [
{ "kind": "phase", "id": "cleanup" },
{ "kind": "effect", "ref": "eff_3", "source_card": "c12" },
{ "kind": "decision", "ref": "d17" }
]
Del lado de la definición: scope de fase gana all_players_sequential y all_players_simultaneous. Hoy solo hay per_player, que implícitamente significa "el jugador activo".
Unificar el setup aquí: deja de ser un compilador aparte y pasa a ser los primeros pasos de la cola. Una máquina en vez de dos, y hereda gratis las decisiones del punto 3.
Cambio de estructura mínimo — el hueco ya existe. Casi todo es motor.
5 · Modificadores (efectos permanentes)
La clave conceptual: un permanente no es un efecto. Un efecto es un verbo que ocurre en un instante y cambia el estado. Un permanente es una regla vigente que no cambia nada — altera cómo se lee el estado. Meterlos en el mismo array es lo que bloquea.
Cambio en la carta — array separado:
{
"effects": [ ... ],
"modifiers": [
{
"active_while": { "in_zone": "in_play", "owner": "self" },
"property": "action_cost",
"target": { "who": "controller", "cards": "*" },
"operation": "add",
"value": -1
}
]
}
Cuatro conceptos: cuándo está activo, qué propiedad toca, sobre quién, cómo la altera.
El runtime ya tiene el hueco: players[].modifiers y card_instances[].modifiers existen vacíos. Ahí registra el motor los modificadores activos al entrar la carta en zona, y los retira al salir.
La parte cara, y es de estructura: hay que escribir el catálogo de propiedades modificables. Lista cerrada y explícita: coste de compra, coste de jugar, cantidad robada, valor de atributo, legalidad de una acción, límite de mano, usos por turno. Cada propiedad es un sitio donde el motor deja de leer el número crudo. Empezar con cinco o seis; si crece sin control el motor se vuelve inauditable.
Decidir desde el día uno el orden de capas: sumas → multiplicaciones → reemplazos, y las prohibiciones ganan a todo. Si no, dos cartas juntas dan resultados distintos según cuál se jugó antes, y son bugs irreproducibles.
Es irrenunciable para el simulador: el desequilibrio vive en las combinaciones. Un simulador que ignora los permanentes dirá que todo está balanceado justo en los juegos donde no lo está.
6 · Triggers como patrón de evento
Problema: trigger es un enum cerrado (on_play, on_destroy, at_start_of_turn). No se puede escribir "cuando un rival te obligue a descartar". Peor: un trigger desconocido se reescribe en silencio a on_play (hallazgo A2 de Ascension).
Cambio:
"trigger": { "event": "card_moved", "to_zone": "discard", "owner": "self" }
Los valores actuales sobreviven como presets (on_play = "carta movida a in_play por su dueño").
Regla de higiene: patrón desconocido avisa, nunca se reescribe en silencio.
Analogía WordPress (correcta): el punto 6 son las actions (ocurre X, ejecuta esto), el punto 5 son los filters (un valor pasa por aquí, devuélvelo modificado). Misma división, por eso van juntos en el motor.
Tres diferencias con WP:
- El orden debe ser determinista (capas por tipo de operación, no prioridades ad hoc).
- Todo es dato declarativo, no código — lo tiene que poder emitir un formulario y una IA.
- Se guardan las suscripciones activas en el runtime, no las definiciones.
Bonus: junto con el punto 4, una reacción o interrupción es empujar una ventana de respuesta a la cola. El techo declarado (G3, resolution stack) deja de ser una reescritura.
7 · Acciones compuestas — corregido
Corrección: la presuposición de género no está en las zonas (que las define el usuario). Está en bindings. El juego se autora abierto y en el setup se colapsa a roles fijos que el motor sabe leer:
"market": "trade_row", "life_tracker": "authority", "attack_cost_tracker": "combat", "currency_source": "catalog_default:buy_from_market"
Un juego sin vida, sin mercado, o con dos vías de adquirir la misma carta, pierde información en ese colapso. El catálogo lo refuerza: buy_from_market trae default_cost_tracker y requires: { shared_zone_present: true }.
La estructura ya lo soporta: game_meta['action_bindings'] está reservado y vacío. En vez de que el motor deduzca los roles, el juego los declara por acción:
"action_bindings": {
"buy_from_market": {
"source_zones": ["copper_pile", "silver_pile"],
"cost_tracker": "coins",
"destination": "discard",
"uses_per_turn": { "tracker": "buys" }
}
}
Cero estructura nueva. Es motor + poblar un campo existente. Cierra G9, G10, G11 y da soporte a la Rules page (P7).
Irrenunciable para el simulador: si las acciones son gratis e ilimitadas, la duración y la economía salen mal por construcción (mazos finales de 100 cartas en la probe de Dominion).
8 · Zonas anidadas y posicionales
Cambio:
{
"id": "pyramid",
"parent": null,
"slots": [
{ "id": "r1c1", "face": "down", "covered_by": ["r2c1", "r2c2"] }
]
}
parent(mesa / jugador / instancia de carta) → contenedores anidados: fichas o
cartas colocadas encima de otra carta concreta.
slotscon relaciones → pirámide de 7 Wonders Duel (covered_by) o tablero
(adjacent_to). Mismo mecanismo.
Visibilidad: sacarla del enum de la zona y convertirla en relación observador→objeto evaluable. Cubre casos como "ves las manos ajenas pero no la tuya".
Lo que NO cubre de 7WD: costes dependientes de lo que tenga el rival y victoria por colección de símbolos — ambos caen en el punto 1 (expresiones), no en éste.
Tier aislado. Se puede dejar el último. Si el producto se mantiene en juegos de cartas puros, el parent (trivial) vale la pena y los slots son opcionales.
Nota · Aproximación de cartas no modelables
Este es el cambio estructural que hace viable todo lo demás, y no es ninguno de los ocho.
Separar lo impreso de lo simulado:
{
"name": "Bazar",
"rules_text": "Descarta una carta. Si es un tesoro, roba dos.",
"sim_model": { },
"sim_fidelity": "exact | approximate | unmodelled"
}
Hoy el esquema asume que el efecto estructurado es la carta, y el texto se deriva del render del catálogo. Invertir la relación: el texto es la carta, el modelo es una lectura de la carta.
Tres consecuencias:
- El modelo deja de bloquear la autoría. El usuario siempre puede imprimir lo que quiera. Nunca se le dice "esa carta no se puede crear" — veneno en un producto creativo.
- Se puede aproximar a propósito. "Esta carta es difícil de modelar; para simular, trátala como roba 1." La aproximación pasa a ser una decisión de diseño explícita en vez de un fallo silencioso.
- La cobertura se vuelve una métrica de producto. El informe de simulación abre con: "He entendido 47 de 52 cartas. Estas 5 se han tratado como sin efecto. Los resultados de estrategias que dependan de ellas son poco fiables."
Sin esto, cada agujero del modelo es un muro. Con esto, cada agujero es una nota al pie.
Y es lo que permite medir: sustituir el objetivo "modelar casi todo" (que no dice nunca cuándo parar) por "% de cartas autoradas que se simulan sin aproximar". Con datos reales de usuarios, que autoran cosas que ninguna probe contiene.
Importante: la aproximación es una válvula de escape, no una arquitectura. Solo es segura porque existe el modelo fiel debajo.
Nota · Familias
Confirmado: familia = tag clasificatorio, cero motor de simulación. No dispara ni modifica nada. Es un adjetivo que otras cosas leen — igual que type, que el motor no ejecuta pero las zonas filtran con accepts_types.
Tres puntos a resolver:
- Formato abierto a N, UI limitada a 1. El campo en la carta ya es array (
families: []). La restricción "v1 max 1" tiene sentido para el estilo de imagen, pero se queda corta en cuanto la familia es target de efectos (cartas de dos bandos, temática + facción). Dejar el formato abierto y limitar en el formulario; la primera familia manda para la imagen. Gratis ahora, migración después.
- Entidad, no string suelto. Si lleva estilo de imagen, es un objeto autorado a nivel de juego:
{ id, label, color, description, style_prompt }. La carta guarda el id. El scaffold actual (family_1/enabled/name) es una forma distinta y peor que la forma "intended" del propio D6. Corregirlo ahora que no cuesta nada.
- La familia es un criterio del filtro genérico, no un target especial. Modelarla como caso particular obliga a escribir lo mismo tres veces:
"filter": { "family": "gremio_chatarrero" }
"filter": { "type": "unit", "cost": { "<=": 4 } }
"filter": { "behaviour": "Permanent", "owner": "self" }
Mismo mecanismo que "elige 3 cartas de tu mano" (punto 3) y "gana una carta de coste ≤ 4". Del lado de contar, count_cards ya filtra por campos de carta — es posible que familias como condición ya funcione hoy en cuanto las familias existan de verdad.
S1 se descompone en: definir la entidad + UI (pequeño, y desbloquea el estilo de imagen, que es valor de producto inmediato aunque el motor no la mire) · aceptar family en el filtro genérico (mínimo) · dar contexto a la IA generadora para asignar familias con coherencia temática (aquí está el trabajo real).
Resumen: estructura vs motor
| # | Cambio de estructura | Alcance | Motor | |
|---|---|---|---|---|
| 1 | Unión `valor \ | expresión` en atributos, costes, cantidades, copies, starts_at | Grande — toca casi todos los esquemas | Extender el evaluador de ending.when |
| 2 | subject/from/to en el efecto; catálogo pasa a presets | Media | Reescribir el ejecutor de efectos | |
| 3 | Spec de selección en params; pending_decisions + history de decisiones | Pequeña pero nueva | Grande con humanos; trivial si solo hay bots | |
| 4 | phase_queue → cola de objetos heterogéneos; scope simultáneo/secuencial | Mínima — el hueco ya existe | Bucle de turno | |
| 5 | modifiers[] en la carta + catálogo de propiedades + orden de capas | Media — el runtime ya tiene el hueco | Resolutor en cada lectura de valor | |
| 6 | trigger de enum a patrón de evento | Pequeña | Bus de eventos | |
| 7 | Ninguna — action_bindings ya está reservado | — | Dejar de derivar bindings; leerlos | |
| 8 | parent + slots con relaciones en la zona | Media, aislada | Legalidad posicional | |
| — | rules_text / sim_model / sim_fidelity en la carta | Pequeña | Informe de cobertura |
Orden sugerido: 1 primero (es el que más esquemas toca; hacerlo antes de que haya juegos autorados encima). Luego 2 y 6 juntos — son el mismo refactor del catálogo de efectos, uno por el lado del destino y otro por el del disparo. Luego 3 y 4. El 7 cuando convenga, porque la estructura ya está. El 5 y el 8 al final, cada uno es un tier propio.
Los tres irrenunciables para que el número que ve el usuario signifique algo: 1 (expresiones), 5 (modificadores) y 7 (costes y límites de uso reales).
Pendiente de decidir
Versionado de cartas. Si el futuro incluye app o TCG con propiedad digital, "Bazar v1.2" tiene que ser una entidad distinta de "Bazar v1.0", los errata no pueden reescribir en silencio lo que alguien poseía, y un mazo debe apuntar a versiones concretas. Hoy las cartas viven en filas de game_cards sin versión. Añadir versionado después de que exista propiedad es una pesadilla; ahora es una columna.
Niveles de autoría. Formalizar tres, y guardar el nivel en el dato: preset ("Roba 3", ~90%) → preset parametrizado ("Roba 1 por cada X", ~9%) → expresión libre (~1%, con aviso de validación más débil). La IA generadora emite solo presets, nunca expresiones libres.
Calidad de los bots. Probablemente el mayor retorno por hora de todo el proyecto, y no es una feature de modelo. Un simulador vale lo que valga el jugador que lo juega; un bot codicioso dice lo mismo de un juego bien diseñado que de uno roto. Hacen falta varias políticas (codicioso, económico, agresivo, aleatorio — comparar contra el aleatorio es el mejor detector de juegos donde las decisiones no importan) y muchas semillas, para pasar de "terminó en 41 turnos" a "termina entre 28 y 46, y el que empieza gana el 58%".
Techo declarado
Fuera de alcance, ahora y siempre: tiempo real, negociación entre jugadores, destreza física y azar externo al mazo. No por dificultad, sino porque no son juegos de cartas por turnos. Acotar "casi todo" es lo que hace que la promesa sea cumplible.
Métricas que el simulador debe responder (y criterio para decidir si algo merece modelarse: ¿mueve una de estas seis?):
- ¿La partida termina?
- ¿Dura lo que el diseñador cree?
- ¿Hay una carta rota?
- ¿Hay cartas que nadie compra nunca?
- ¿La economía crece o se atasca?
- ¿Gana siempre el que empieza?
Sets + Condiciones nombradas construido 2026-08-06
Dos herramientas propuestas por Ivan (2026-08-06). El estudio del TcgEngine (U1) validó el diseño; construido el mismo día en los cuatro niveles. Prueba viva: el combo del tesoro en dominion-verification.json — dispara en 20/20 partidas greedy (174 activaciones), cada agarre registrado como decisión auto_set.
| Herramienta | Esquema | Puntos de cableado |
|---|---|---|
| Sets — consultas de cartas nombradas y reutilizables | game_meta[card_sets]: {id, label, zone(s), filter, count{min[,max]}, same?} — la misma forma que un paso choose_cards sin la elección, a propósito (compila sobre la maquinaria existente) |
como TEST ("¿tengo el set?") vía condición has_set; como SELECTOR en pasos de acción: {"do": "choose_cards", "set": "id", ...} con slots pre-rellenados, y modo auto: true (agarra el set sin elección — "esta acción coge este set de esta zona") |
| Condiciones — lógica nombrada, autorable, referenciable | game_meta[conditions]: patrón dual-key ƒx — {id, label, authored: {preset|free, params}, expr: compilado}. Presets: has_set · tracker ≥ N · zone empty · JSON libre. Referencia: {"named": "id"} en cualquier sitio que acepte JSON Logic, resuelta por un op nuevo del evaluador (lookup en runtime = fuente única, editar propaga) |
Acciones ganan requires: (NUEVO — hoy la legalidad es solo estructural; el combo "si tengo 3 así, puedo ejecutar esto" necesita exactamente esto) · Efectos ganan condition: (cierra el hallazgo SI1 de Shards — efectos condicionales) · when: de pasos, endings y active_while de modificadores aceptan named gratis |
Formularios: dos secciones pequeñas en Step 2 (Sets: widgets zona+filtro+count ya existentes; Condiciones: recipe picker estilo win conditions) + select "Requires" en el form de acciones + select de condición por fila de efecto en el editor de cartas. Prueba prevista: acción combo real en el verification kingdom ("si tienes 3 tesoros del mismo tipo, sacrifícalos y gana 6 monedas") autorada entera con las dos herramientas — y la métrica de frecuencia de interacción ("¿mi combo ocurre?") se vuelve medible. Tamaño: ~2 días. Ritmo acordado: contrato primero (ambos esquemas + lista de cableado en la referencia), checkpoint de revisión, luego build.
Planificado (aprobado 2026-08-06): Estrategias de bot generadas por IA
Propuesta de Ivan: UNA llamada IA antes de las simulaciones — nunca en runtime.
game_meta[bot_strategies]: { id: { label, description (texto humano,
editable), strategy: { card_values (→ ai_value), tracker_weights, priorities[] (when: JSON Logic /
condiciones nombradas → buy/play/custom/activate), fallback: lookahead }, source: ai|user|tweaked } }.
Tres caminos: (1) generar — export del juego → {description, strategy}; (2) tweak — el usuario edita
reglas en el form ("prioriza comprar X si está disponible" = fila de prioridad arriba del todo);
(3) regenerar desde la description editada — el patrón dual-key otra vez: la description es la fuente
humana, la strategy se recompila. Runner: bot=strategy:<id> con asignación POR ASIENTO
(matchups asimétricos — rompe la simetría del self-play). El report ya guarda bot por fila →
torneo de estrategias gratis = detector de líneas degeneradas. Raíles: lenguaje cerrado (condiciones +
referencias a cartas/acciones reales), warn-never-block, fallback lookahead, determinista tras la
llamada. Tamaño: ~1.5 días (bot seguidor + validación + form + endpoint de generación).
Planificado (aprobado 2026-08-07): Verb Forge — primitivas de acción y verbos definibles
Idea de Ivan: hoy las acciones autorables (action_definitions) mueven CARTAS entre
zonas y ajustan trackers, pero los verbos que tocan VALORES de carta (atacar = restar un valor a otro valor)
siguen siendo código. Verb Forge generaliza eso: un definidor donde attack, buy, play y
cualquier verbo nuevo se componen de las mismas primitivas, y el autor elige el valor de origen, quién puede
actuar, qué puede ser objetivo y qué pasa al resolver.
Enfoque acordado (Ivan, 2026-08-07): primero las PRIMITIVAS, luego todo se compone
"Draw ya es un subcaso de move: de aquí a allá." Correcto — y cambia el orden del trabajo: no es "añadir 4 pasos que faltan" sino declarar el juego completo de primitivas y reconstruir los verbos internos como composiciones sobre ellas. Inventario propuesto (8 mutadores + el tipo de argumento numérico que ya existe):
| Primitiva | Parámetros | Verbos que compone |
|---|---|---|
| move | from (zona/s), to, qué cartas (selección o posición top/bottom/random), count | draw, discard, play, buy, mill, tutor, trash (mover a una zona fuera de juego), sacrificar, poner encima del mazo |
| adjust_value | destino (tracker de jugador | atributo/counter de carta), cantidad (cualquier fuente de valor), op (add/sub/set) | ganar recurso, pagar coste, daño, curación, buff/debuff, atacar |
| fuente de valor (tipo de argumento, no mutador) | literal · ƒx · tracker · atributo de carta · conteo de cartas · dado | ya existe: la capa ƒx |
| choose | kind (cards | player | zone | option), chooser, filter, count, auto/select | targeting, descartes, modales, combos |
| shuffle / order | zona, asiento | setup, reciclado |
| create / remove instance | diseño → zona; instancia → fuera de juego | tokens, discover, exilio |
| set_flag | exhaust/ready, face up/down, status (flag + valor + duración) | tapear, sigilo, aturdir |
| flow | end phase, push steps, turno extra | control de fases |
Dos hallazgos que salen del ejercicio: (1) trash no necesita verbo
propio — es move del PROPIO origen a una zona fuera de juego; lo que faltaba era mover la
carta fuente, un parámetro, no una mecánica (las 18 cartas de scrap de Star Realms se bloqueaban por ahí).
(2) La única diferencia real de draw (rebarajar el descarte al agotarse) es una
propiedad de la ZONA, no del verbo.
Dos límites honestos: las primitivas describen la EJECUCIÓN, pero un verbo
necesita además su offer spec (qué ofrece el menú / cómo se enumera la legalidad) — también dato, o
la mesa de juego y los bots quedan ciegos; y los bots pierden semántica si todo verbo es un move+adjust
anónimo, así que los verbos conservan category (attack/acquire/gain) como pista de datos.
Camino iterativo acordado: (1) contrato de las 8 primitivas; (2) implementar
las que faltan en el vocabulario de pasos (adjust_value sobre atributos, choose target, mover el origen);
(3) reexpresar UN built-in — attack_opponent — como composición y demostrar con el version-diff
que el perfil de Starforge no se mueve; (4) migrar el resto de uno en uno, cada uno con su diff; (5) el
catálogo pasa a ser presets renombrables. Nada se rompe en ningún paso: el verbo viejo sigue vivo hasta que
su reemplazo demuestre ser equivalente.
Por qué es viable (ya tenemos ~80%)
| Pieza | Estado |
|---|---|
Composición por pasos + catálogo de presets con slots (action-presets.json) | hecho — el composer de acciones de usuario |
Legalidad autorable: costes, usos por turno, filtros de fase, requires: (condiciones nombradas) | hecho — bindings + Sets/Conditions |
| Selecciones con chooser/count/filter/select y decisiones registradas | hecho |
| Valores como ƒx (JSON Logic) en cualquier campo numérico | hecho — el "valor de origen" ya es autorable |
Estado de carta: atributos efectivos, counters, agotamiento (exhausted), depleción → destrucción, eventos de combate | hecho (U2/U3/U4) |
Primitivas que faltan: choose_target (carta o jugador, con reglas de targeteo), transfer_value / apply_damage (restar valor-origen a atributo/tracker-destino), exhaust_card / ready_card, gates de origen ("no agotada", "puede atacar") | pendiente — el corazón de Verb Forge |
Des-hardcodear comportamientos: hoy el motor busca literalmente 'Permanent' y 'Outpost'; un comportamiento propio ("Guardian") es autorable pero MUDO | pendiente — pasar a flags de catálogo/bindings |
Escotilla de código: handler apuntando a una función nombrada del catálogo | ☐ a revisar — ya estaba abierto en el contrato de acciones |
La prueba de que funciona
Con esas primitivas, los DOS modelos de combate se autoran sin código nuevo, desde el mismo definidor:
- Star Realms (pool):
adjust_trackercombate −N → daño al oponente. Ya se puede. - Por unidad (Hearthstone/MTG): elegir unidad propia no agotada → elegir objetivo → restar
attackdel origen ahealthdel destino → daño de vuelta → agotar origen. La depleción ya mata y disparaon_destroy; los eventos U4 ya se emiten.
Primer cliente concreto (deuda ya registrada): attack_with_card, el "mareo de invocación"
(agotar al jugar) y el des-hardcodeo de Permanent/Outpost. Hoy el atributo attack
de las unidades de Starforge es DATO INERTE: se autora y se muestra, pero ningún verbo lo lee.
Tres capas: primitivas → presets de verbo → instancias por juego
Aclaración de Ivan: trash SÍ es un verbo — predefinido y visible — sólo que su significado se configura por juego ("mover ¿a dónde?"), y eso puede autocompletarlo la IA. La arquitectura queda en tres capas, que es exactamente como ya funcionan las acciones de usuario, extendido a TODOS los verbos:
| Capa | Quién la toca | Ejemplo (trash) |
|---|---|---|
| 1. Primitivas (motor, cerradas, ~8) | nadie las autora directamente | move |
| 2. Presets de verbo (catálogo compartido: trash, draw, play, buy, attack, mill…) | los enviamos nosotros; se amplían sin PHP | steps_template: [ move(cards: $source, to: "@{destination}") ] + offer spec |
3. Instancia por juego (game_meta[action_definitions]) | el diseñador, en Anatomy · Acciones | {id: "scrap", label: "Scrap", authored: {preset: "trash", params: {destination: "scrap_heap"}}} |
Patrón dual-key otra vez: authored para el formulario, steps
compilados para el motor, recompilado al guardar. La PANTALLA ya existe a medias (el picker de presets +
relleno de slots con el que se autoró Jaipur); el cambio es que los verbos internos dejan de ser una lista
especial y aparecen en la misma pantalla — habilitar, configurar y RENOMBRAR cualquier verbo en un sitio.
Autorrelleno por IA: mismo patrón que el generador de estrategias (una
llamada, worker en background). Dadas las zonas y trackers del juego propone los slots — "trash: no tienes
zona fuera de juego; crea scrap_heap (compartida, oculta) y apunta ahí", "draw: de
draw_pile a hand, rebarajando discard al agotarse". El diseñador
revisa; nada se inventa en silencio.
Cada preset lleva DOS cosas: la receta (steps) y su offer spec (cómo se enumera qué es legal ahora: play_card ofrece la mano, trash ofrece lo descartable). Si sólo hacemos recetas, la mesa de juego y los bots no saben qué ofrecer.
El nombre del verbo es DATO (refinamiento de Ivan, 2026-08-07)
Hoy actions-default.json es un MANIFIESTO, no una definición: declara id, label,
target_spec, roles requeridos y trackers por defecto — pero la conducta ("gasta el pool,
resta a la vida") vive como PROSA en description y como un case del switch de
engineApply. Consecuencia: los bindings ya permiten elegir QUÉ trackers usa "atacar"
(Starforge: combat→authority), pero ni la receta ni el NOMBRE son autorables — todos los juegos muestran
"Attack opponent". Las acciones de usuario sí tienen nombre propio (Jaipur: "Vender un set"), así que la
asimetría es doble: los verbos internos son opacos y anónimos.
Verb Forge lo corrige: un verbo es {id, label, authored:{preset,params}, steps[]} como
cualquier acción de usuario. "Atacar" pasa a ser una instancia de preset renombrable — Raid,
Bombardear, Denunciar — y lo que significa (restar de qué a qué, quién puede ser
objetivo) es autorado, no heredado. Los juegos que quieran el comportamiento clásico simplemente activan
el preset sin abrirlo.
Sobre el "snippet/DSL"
Viable con un matiz de seguridad que conviene fijar ahora: código arbitrario del usuario ejecutándose en el
servidor es RCE en cuanto haya más de un autor. La escalera segura, de menor a mayor poder:
(1) presets con slots — el 90%; (2) composición de primitivas — el definidor; (3) ƒx/JSON Logic para cualquier
valor o condición — ya es un DSL, cerrado y evaluable; (4) handler a una función PHP del catálogo
(la escotilla revisable, no código del usuario). Un DSL propio ejecutable llegaría, si llega, como intérprete
cerrado — nunca eval.
Tamaño estimado: primitivas + validación + editor ~2-3 días; des-hardcodeo ~½ día. Ritmo acordado: contrato primero, checkpoint, luego build.
Tracker · estructura → formulario → motor
Regla de trabajo (2026-08-05): todo cambio de modelo se hace en
cuatro niveles a la vez: la estructura de datos, el formulario que permite
autorarla (funcional, sin pulir UX — pero tiene que poder reproducir la estructura), el
import/export (el formato deckcraft-game/1 tiene que hacer
round-trip de la estructura, o las probes de juegos futuros no podrán expresarla), y una
fila aquí registrando qué le falta al evaluador/motor para procesarla. Nada de campos que
solo existen vía import JSON, y nada de deuda de motor invisible.
Trampas conocidas del nivel import/export: la whitelist
DC_IMPORT_META_KEYS (inc-game-import.php) descarta en silencio claves nuevas de
game_meta, y los normalisers de carta (normaliseCardEffects,
normaliseCardClassification) coercionan o eliminan campos desconocidos. Ambos se
extienden en cada cambio.
| Cambio | Estructura | Formulario | Import/Export | Evaluador / motor pendiente |
|---|---|---|---|---|
| 1 · Valores como expresión | hecho — unión {expr, authored} en starts_at/min/max, attributes.*, copies (inc-expr-value.php) | hecho — widget ƒx compartido (js/expr-widget.js) en Trackers·starts_at/min/max, Setup·starting_hand_size y Zones·auto_refill.to, con preview 2p-4p. attributes/copies/cost de carta ahora ƒx-autorables en el editor de cartas (editCard.php) | hecho — round-trip verbatim probado con Dominion (Province 8@2p/12@3p+ por expresión) | hecho — ops player_count/floor_div/by_player_count/count_cards multi-zona; resolución en setup, regen, cost.* (legal actions), draw_n_cards, hand size y refill target. Umbrales de ending ya eran JSON Logic. Resto de deuda menor: type-check de referencias cuando card-model se carga sin el evaluador |
2 · Selector subject/from/to en el efecto | hecho — subject.who (self / opponent / each_opponent / all_players), to como zona destino; los sufijos viejos son presets con subject por defecto en el catálogo | hecho — presets vía vocabulario + bloque subject/from/to por carta en el editor de cartas (editCard.php) | hecho — Dominion: Council Room y Militia validan e importan; primera sim a 3 jugadores completada | hecho — ejecutor por-sujeto (engineResolveSubjects); subject desconocido = NADIE + aviso, nunca self. Pendiente: from en draw (zonas por rol), "chosen" llega con el punto 3, Militia aproximada (descarte aleatorio) hasta el 3 |
| 3 · Decisión como estructura | hecho — spec de selección en params (source:player, chooser, from, count, filter); runtime gana pending_decisions[], flow.awaiting_decision y entradas decision en history | hecho — specs en catálogo + sub-editor de selección por parámetro en el editor de cartas | hecho — round-trip + validación (chooser/count/filter) | hecho (solo bots) — la política del sujeto elige en línea: greedy conserva sus mejores cartas, random usa el RNG de la partida (replay bit-idéntico); la elección queda en history. Militia deja de ser aproximación. Pendiente: suspensión real solo si juega un humano; draw-and-keep y targeting elegido cuando un juego los fuerce |
| 4 · Cola de pasos heterogénea | hecho — flow.step_queue (objetos {kind: phase|effect|decision}, strings compatibles); fase gana scope: all_players_sequential|simultaneous | hecho — el campo scope se autora en Phases (Anatomy); pasos son runtime, no formulario | hecho — saves viejos con phase_queue se leen; export emite ambas vistas | hecho — bucle consume pasos heterogéneos; enginePushSteps() inserta en mitad de la resolución; fases automáticas all-players (simultáneo = secuencial con bots, el secreto es gratis). Pendiente: unificar triggers preset sobre la cola, A4 (end_of_round), fases player_driven all-players (necesita bucle de bots) |
| 5 · Modificadores en la carta | hecho — modifiers[] separado de effects: {active_while, property, target{who,filter}, operation, value}; catálogo cerrado de 5 propiedades (assets/data/modifiable-properties.json); values aceptan ƒx | hecho — editor de modifiers[] por carta en editCard.php (propiedad/operación/target/filtros/ƒx) | hecho — verification kingdom importa limpio con Merchant Guildhall (Bridge-style) | hecho — resolutor engineModifiedValue con orden fijo (suma → mult → replace; forbid gana); 5 read sites: buy_cost, play_cost, draw_amount, damage_dealt, action_legality; no-op garantizado sin modificadores. Pendiente: attribute:* (scoring/evaluator hook), duraciones temporales ("hasta tu próximo turno") |
| 6 · Trigger como patrón de evento | hecho — trigger acepta patrón {event, filtros}; 6 eventos (card_moved, tracker_adjusted, turn_started/ended, phase_started, action_performed); filtros relativos self/opponent/any | hecho — presets intactos + constructor de patrones por evento en el editor de cartas | hecho — normaliser preserva patrones y triggers desconocidos (A2 arreglado de raíz); validación con vocabulario cerrado de eventos y filtros | hecho — bus de eventos con emisiones en primitivas/apply/turnos/fases; suscriptores = cartas en in_play; cadenas con tope de profundidad 8; no-op garantizado sin patrones. Pendiente: presets aún van por vía directa (unificar sobre el bus con el punto 4); reacciones/interrupts esperan la cola (4) y decisiones (3) |
7 · action_bindings por acción | hecho — forma fijada: play_costs[{tracker,amount,card_types?}], uses_per_turn{tracker}, phase_types{fase:[tipos]}; sin binding = libre e ilimitado (cero migración) | hecho — editor de bindings por acción habilitada en Anatomy·Actions (Rules page v1): coste, límite de usos, filtro por fase | hecho — ya estaba en la whitelist; Dominion importa con bindings; amounts aceptan ƒx | hecho — legal_actions/apply los leen y gastan; cierra G9/G10/G11. Dominion: de mazos de 100 cartas a ~77 turnos con estructura real de turno; los 7 turn_limit restantes son calidad de bot, no modelo |
8 · Zonas anidadas (parent / slots) | reservado — parent: null en toda zone def desde setup; slots NO se reserva | fuera de alcance | passthrough | declarado fuera de alcance — tableros/pirámides son otro producto; el techo está escrito en el doc |
— · rules_text / sim_model / sim_fidelity | pendiente | pendiente — texto impreso editable por carta + selector de fidelidad | pendiente | Informe de cobertura ("N de M cartas simuladas sin aproximar") en el report |
| — · Acciones definibles por el usuario (punto 7 completado de verdad) | hecho — catálogo shipped assets/data/action-presets.json (6 presets con slots) + game_meta[action_definitions] (instancias {authored, steps} o composiciones raw); vocabulario cerrado de 6 pasos; escape hatch handler (checkbox revisado) | hecho — Anatomy·Actions: selector de preset + slot-filler (zonas/trackers/filtros/mapas) + composer raw tras "Advanced"; enable/bindings como cualquier verbo | hecho — recompila authored→steps en save e import; gap report valida | hecho — intérprete completo (selecciones agrupadas por same-field, when sobre $ctx, mapas de zona por campo, feasibility en legal actions, uses_per_turn compone). PROBADO: Jaipur entero — el primer no-deck-builder — corre con 4 acciones custom (take/sweep/sell con 3 bonus tiers/trade N-a-N), finales reales por count_zones_where y puntos por G12 |
| — · Estado de carta (atributos en runtime) | hecho — CardInstance.counters por fin escrito; game_meta[attribute_rules] {min, max, on_deplete.move_to}; mecánica adjust_card_attribute con targeting de cartas; evento card_attribute_adjusted; propiedad de modificador attribute:* | hecho — Anatomy · 5 "Attribute rules"; el efecto/modificador se autora en el editor de cartas | hecho — whitelist + allowlist | hecho — lectura efectiva (base ƒx + counters + auras, clamp), depleción = transacción de derrota generalizada (cierra la mitad de A1/P1/S2: unidades con moral/vida propia, bases destruibles). Combate dirigido: acción attack_card (matar cuesta los puntos restantes del objetivo), puerta Outpost (bloquea la cara), greedy caza el objetivo más valioso / sacrifica el menos valioso según el rol (intent give/take). Starforge importa limpio con unidades destructibles |
| — · Familias como entidad | pendiente — corregir scaffold a {id,label,color,description,style_prompt}, array abierto a N | pendiente — UI limitada a 1 familia | pendiente | Aceptar family en el filtro genérico; count_cards puede que ya filtre |
| 9 · Sets + Condiciones nombradas | hecho — game_meta[card_sets] (consulta con from/filter/count/same) y game_meta[conditions] (dual-key ƒx: authored + expr recompilado al guardar; inc-conditions.php). Referencia {"named": id} resuelta por lookup en runtime (editar propaga; tope de profundidad 8; id desconocido = warn + false, nunca silencioso) | hecho — Step 2 § Card sets y § Conditions (recipe picker con los 7 presets), select Requires por binding en Anatomy · Actions, select de condición por fila de efecto en el editor de cartas | hecho — whitelist + recompilación al importar + validación de referencias (zonas/trackers/sets/condiciones desconocidas al gap report); round-trip probado con dominion-verification | hecho — ops has_set (agrupación same-field sobre la maquinaria de choose_cards) y named; gate requires: en legal actions + apply (built-ins Y custom); condition: por efecto con el subject ligado (skip registrado en history); pasos choose_cards con set:/auto: (agarre determinista); active_while.when en modificadores; endings y when: evalúan named. tests/test-conditions.php: 59 checks |
| 10 · Statuses (U2) | hecho — CardInstance.statuses[] {behaviour, value, duration}; comportamientos efectivos = impresos + statuses en TODAS las puertas y filtros (Outpost por status bloquea la cara); regla de restack (valor suma, duración más larga gana); expiran tras turn_ended (duración = fines de turno) | hecho — mecánicas Apply/Remove Status en el vocabulario de efectos; efectos apply_status_to_cards/remove_status_from_cards autorables en el editor de cartas (selección chooser/count/filter estándar) | hecho — los efectos viajan en card_json como siempre; catálogos compartidos (effects.json, mechanics-effect.json) | hecho — primApplyStatus/primRemoveStatus, engineCardEffectiveDef en 8 sitios sensibles a behaviours, tick en engineEndPhase, historia {type: status}. tests/test-statuses-activated.php |
| 11 · Habilidades activadas (U3 / S2) | hecho — trigger activated + activation: {cost: {tracker: n}, exhaust} en efectos de carta; CardInstance.exhausted, untap al inicio del turno del dueño | hecho — editor de cartas: campos de activación al elegir el trigger; verbo activate_ability en el catálogo de acciones (habilitar + fases + bindings como cualquier verbo) | hecho — round-trip vía card_json; starforge-rivals.json importa con CERO warnings | hecho — oferta en legal actions (untapped + coste pagable + condition), pago/exhaust/ejecución en apply, greedy activa tras jugar cartas; requires/forbid/condition componen. Prueba viva: Salvage Outpost "Activate: +2 Trade" dispara en 17/20 partidas. Cierra S2 |
| 12 · Lote U4–U9 (eventos de combate, choose-one, selectores, event-card, tokens, dados) | hecho — eventos attack_declared/resolved (target_kind card|player) y card_killed en el bus; mecánicas choose_one (options = bundles de efectos), create_card (tokens en runtime, por nombre de diseño, ids deterministas genN) y roll_dice (registro _last_roll); specs de selección ganan select: first|random|highest:attr|lowest:attr y {source: "event_card"} (el efecto golpea la carta del evento) | hecho — pattern builder recibe los 3 eventos vía dcTriggerFilterMap; editor de cartas: selector de target source (player choice | event card), campo auto-select, textarea JSON para options; catálogos Create Card / Roll Dice / Choose One en el vocabulario | hecho — todo viaja en card_json / catálogos compartidos | hecho — op rolled_value; validador: select vocabulary + event_card solo con pattern triggers. tests/test-u4-u9.php (22 checks); suite 19 archivos verde |
| 13 · Verb Forge — fases B/C/D (primitivas + presets de verbo + pantalla) | hecho — pasos primitivos: choose (kind cards|player, of: opponent selecciona en las zonas del rival), adjust_value (trackers y atributos, ops add/sub/set, fuentes de valor con take: "all" para drenar pools y {attribute, of: "$ref"}), set_flag, shuffle_zone, fire_triggers (actúa como el DUEÑO de la carta), move con to_of: owner; mecánica move_self (la carta se mueve a sí misma — trash real). 6 presets de verbo en el catálogo: trash, spend_pool_at_player (attack como dato), attack_with_card (combate por unidad, U14 CERRADO), destroy_target_card, draw, mill; las instancias heredan category (pista para bots; greedy prioriza customs de ataque) | hecho — pantalla de Verbos en Anatomy · Acciones: lista unificada con badge de categoría, chips de FASE por verbo (built-ins incluidos, escriben allowed_actions desde aquí), botón Steps con la composición compilada en solo-lectura, renombrado por juego probado ("Banish" ejecuta la receta trash) | hecho — mismos catálogos compartidos; instancias en action_definitions como siempre | fase E pendiente — migrar los built-ins juego a juego con el version diff como prueba. Hallazgo registrado: reemplazar el ataque de Starforge requiere la integración del MENÚ (candidatos con cantidades computadas: "mata esta base por su vida restante") — exactamente el offer-spec del contrato; equivalencia matemática ya probada a nivel test (raid == attack_opponent). tests: test-verb-primitives (19) + test-verb-presets (20); suite 24 archivos verde en 8.0/8.1/8.2 |
Estados: pendiente = decidido, sin construir · hecho · aplazado. Esta tabla se mantiene viva: cada cambio que se construya actualiza su fila, y ningún campo se considera terminado hasta que estructura y formulario están y la columna de motor dice qué falta o "nada".