Decisiones  ›  Generalización del modelo

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).

importado 2026-08-05 · sesión claude.ai

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ónCoste hoyCoste con 5.000 juegos autorados
Valor puede ser expresiónpequeñomigración de todo
trigger es patrón, no enumpequeñomigración + semántica ambigua
modifiers[] separado de effects[]pequeñoreinterpretar efectos ya autorados
Efecto lleva subject/from/topequeñocatálogo fosilizado
Zona tiene parent (aunque siempre sea null)trivialcambio de esquema
Cola de pasos heterogéneatrivial
Motor que suspende y reanudacaroigual de caro
Resolutor de modificadorescaroigual de caro
Bots buenoscaroigual 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 decision en history
  • estado awaiting_decision en flow

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"):

  1. 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).
  2. 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.

  • slots con 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:

  1. 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.
  2. 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.
  3. 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:

  1. 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.
  1. 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.
  1. 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 estructuraAlcanceMotor
1Unión `valor \expresión` en atributos, costes, cantidades, copies, starts_atGrande — toca casi todos los esquemasExtender el evaluador de ending.when
2subject/from/to en el efecto; catálogo pasa a presetsMediaReescribir el ejecutor de efectos
3Spec de selección en params; pending_decisions + history de decisionesPequeña pero nuevaGrande con humanos; trivial si solo hay bots
4phase_queue → cola de objetos heterogéneos; scope simultáneo/secuencialMínima — el hueco ya existeBucle de turno
5modifiers[] en la carta + catálogo de propiedades + orden de capasMedia — el runtime ya tiene el huecoResolutor en cada lectura de valor
6trigger de enum a patrón de eventoPequeñaBus de eventos
7Ningunaaction_bindings ya está reservadoDejar de derivar bindings; leerlos
8parent + slots con relaciones en la zonaMedia, aisladaLegalidad posicional
rules_text / sim_model / sim_fidelity en la cartaPequeñaInforme 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?):

  1. ¿La partida termina?
  2. ¿Dura lo que el diseñador cree?
  3. ¿Hay una carta rota?
  4. ¿Hay cartas que nadie compra nunca?
  5. ¿La economía crece o se atasca?
  6. ¿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.

HerramientaEsquemaPuntos 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):

PrimitivaParámetrosVerbos que compone
movefrom (zona/s), to, qué cartas (selección o posición top/bottom/random), countdraw, discard, play, buy, mill, tutor, trash (mover a una zona fuera de juego), sacrificar, poner encima del mazo
adjust_valuedestino (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 · dadoya existe: la capa ƒx
choosekind (cards | player | zone | option), chooser, filter, count, auto/selecttargeting, descartes, modales, combos
shuffle / orderzona, asientosetup, reciclado
create / remove instancediseño → zona; instancia → fuera de juegotokens, discover, exilio
set_flagexhaust/ready, face up/down, status (flag + valor + duración)tapear, sigilo, aturdir
flowend phase, push steps, turno extracontrol 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%)

PiezaEstado
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 registradashecho
Valores como ƒx (JSON Logic) en cualquier campo numéricohecho — el "valor de origen" ya es autorable
Estado de carta: atributos efectivos, counters, agotamiento (exhausted), depleción → destrucción, eventos de combatehecho (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 MUDOpendiente — 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_tracker combate −N → daño al oponente. Ya se puede.
  • Por unidad (Hearthstone/MTG): elegir unidad propia no agotada → elegir objetivo → restar attack del origen a health del destino → daño de vuelta → agotar origen. La depleción ya mata y dispara on_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:

CapaQuién la tocaEjemplo (trash)
1. Primitivas (motor, cerradas, ~8)nadie las autora directamentemove
2. Presets de verbo (catálogo compartido: trash, draw, play, buy, attack, mill…)los enviamos nosotros; se amplían sin PHPsteps_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.

CambioEstructuraFormularioImport/ExportEvaluador / motor pendiente
1 · Valores como expresiónhecho — 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 efectohechosubject.who (self / opponent / each_opponent / all_players), to como zona destino; los sufijos viejos son presets con subject por defecto en el catálogohecho — 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 completadahecho — 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 estructurahecho — spec de selección en params (source:player, chooser, from, count, filter); runtime gana pending_decisions[], flow.awaiting_decision y entradas decision en historyhecho — specs en catálogo + sub-editor de selección por parámetro en el editor de cartashecho — 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éneahechoflow.step_queue (objetos {kind: phase|effect|decision}, strings compatibles); fase gana scope: all_players_sequential|simultaneoushecho — el campo scope se autora en Phases (Anatomy); pasos son runtime, no formulariohecho — saves viejos con phase_queue se leen; export emite ambas vistashecho — 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 cartahechomodifiers[] separado de effects: {active_while, property, target{who,filter}, operation, value}; catálogo cerrado de 5 propiedades (assets/data/modifiable-properties.json); values aceptan ƒxhecho — 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 eventohechotrigger acepta patrón {event, filtros}; 6 eventos (card_moved, tracker_adjusted, turn_started/ended, phase_started, action_performed); filtros relativos self/opponent/anyhecho — presets intactos + constructor de patrones por evento en el editor de cartashecho — normaliser preserva patrones y triggers desconocidos (A2 arreglado de raíz); validación con vocabulario cerrado de eventos y filtroshecho — 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ónhecho — 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 fasehecho — ya estaba en la whitelist; Dominion importa con bindings; amounts aceptan ƒxhecho — 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)reservadoparent: null en toda zone def desde setup; slots NO se reservafuera de alcancepassthroughdeclarado fuera de alcance — tableros/pirámides son otro producto; el techo está escrito en el doc
— · rules_text / sim_model / sim_fidelitypendientependiente — texto impreso editable por carta + selector de fidelidadpendienteInforme 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 verbohecho — recompila authored→steps en save e import; gap report validahecho — 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)hechoCardInstance.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 cartashecho — whitelist + allowlisthecho — 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 entidadpendiente — corregir scaffold a {id,label,color,description,style_prompt}, array abierto a Npendiente — UI limitada a 1 familiapendienteAceptar family en el filtro genérico; count_cards puede que ya filtre
9 · Sets + Condiciones nombradashechogame_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 cartashecho — whitelist + recompilación al importar + validación de referencias (zonas/trackers/sets/condiciones desconocidas al gap report); round-trip probado con dominion-verificationhecho — 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)hechoCardInstance.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ñohecho — 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 warningshecho — 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 vocabulariohecho — todo viaja en card_json / catálogos compartidoshecho — 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 siemprefase 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".