← Volver a proyectos

Proyecto 05 / Caso de estudio

Motor de juego · IA local · verificación

Unas reglas que no dependen de que la IA acierte.

Reglas de juego complejas, un motor determinista y un modelo de lenguaje que corre en el mismo ordenador y decide por los jugadores. Lo interesante no es que la máquina juegue bien: es que el motor no se rompe si el modelo falla, se desconecta o escribe una barbaridad. Aquí se ve cómo se diseña alrededor de una pieza en la que no puedes confiar.

3.959líneas de Python
24efectos de carta
100asaltos por perfil de prueba
0dependencias externas
RolDiseño de reglas, motor y verificación
EntornoWindows · Python 3.12 · Ollama local
TecnologíasPython, tkinter, urllib, unittest
01

El reto

Un juego de cartas con efectos encadenados, fases, apuestas y un castillo que se asalta. Las reglas caben en la cabeza de quien las diseña y en ningún otro sitio. La única forma de saber si son divertidas —y sobre todo si son consistentes— es jugarlas muchas veces, y nadie juega mil partidas para comprobar una frase del reglamento.

02

La solución

Un motor determinista que es dueño absoluto del estado, y un modelo de lenguaje al que se le pide una decisión en JSON y se le responde con un vocabulario cerrado. Cada efecto de carta está escrito como función del motor, no como texto que la IA tenga que interpretar. Si el modelo falla dos veces seguidas, se apaga para el resto de la partida y una heurística local termina el trabajo.

03

El aprendizaje

Que delegar en un modelo es delegar en algo que puede devolver cualquier cosa, y que la pregunta de diseño no es cómo conseguir que acierte sino qué pasa cuando se equivoca. Las ciento setenta y cuatro funciones del motor están bien; la parte que me hizo crecer es el normalizador de cuarenta líneas que rechaza lo que el juego no puede representar.

Arquitectura

El motor manda. La IA sugiere.

Un único fichero de tres mil cuatrocientas sesenta y siete líneas con la interfaz y las reglas, y un contrato estrecho entre el estado del juego y el modelo. Cero dependencias externas en tiempo de ejecución: solo la biblioteca estándar de Python.

El estado vive en el motor: cartas, jugadores, fichas, metas del castillo. Al modelo se le envía un retrato del estado en JSON —jugador, mano, fichas, lo que ha pasado— y devuelve una decisión. El motor comprueba si esa decisión es legal y, si lo es, la aplica. Nunca al revés: el modelo no toca el estado.

Cada efecto de carta es una función escrita a mano sobre el estado. El modelo no sabe cómo funciona el efecto de una carta: solo sabe que existe una opción con ese nombre. Toda la parte que se puede equivocar queda fuera de las reglas, y toda la parte de las reglas no depende de nadie.

El motor es determinista cuando no interviene la IA. Los dados salen de un generador con semilla, así que una partida es repetible: si algo se rompe en el asalto sesenta y tres, se vuelve a jugar el asalto sesenta y tres y se mira. Sin eso, depurar un juego por simulación es imposible.

Cinco textos de instrucciones: uno con las reglas del juego y cuatro perfiles estratégicos distintos —jugador de equipo, repartidor de equipo, jugador conservador, repartidor conservador—, porque el mismo modelo debe parecer un estilo de juego distinto según a quién represente.

estado del juego (motor, con semilla) → retrato en JSON · 4 prompts de rol → POST /api/generate · format="json" · temperatura 0,25 · sin streaming → json.loads estricto · si falla, recortar { … } → normalizar contra el vocabulario del juego → el motor valida la legalidad · aplica · siguiente jugador → 2 fallos seguidos ⇒ IA apagada, heurística local el resto de partida
  • Motor

      Python 3.12Clases Card, Scheme, Player24 efectos de cartaSemilla y dados reproducibles
  • Interfaz

      tkinterttkDiálogos de apuesta y simulaciónRegistro de eventos en pantalla
  • IA

      Ollama en localFormato JSON forzadoTemperatura 0,25Modelo y host configurablesEspera mínima de 3 s
  • Verificación

      unittestunittest.mockRespuestas HTTP simuladas492 líneas de prueba
  • Dependencias

      Solo biblioteca estándarjson, random, urllib, tkinterSin servicios en la red

El contrato de salida

Rechazar lo que el juego no puede representar.

Se pide JSON con el formato forzado, pero eso no impide que el modelo añada prosa alrededor, invente una recompensa que no existe o devuelva una acción que el reglamento no conoce.

La lectura es en dos tiempos. Primero json.loads estricto. Si falla, una segunda intentando recortar desde la primera llave de apertura hasta la última de cierre, que es como se sobrevive al modelo explicándose a sí mismo antes de contestar.

Después viene lo importante: un normalizador que contrasta cada campo contra el vocabulario real del motor. La recompensa tiene que estar en la lista de recompensas; la acción de apuesta, entre cuatro valores posibles. Lo que no está en esa lista no existe y se convierte en la opción neutra. Un nombre inventado no lanza una excepción: degrada a ninguna y la partida sigue.

Y un techo en todo lo que entra del exterior: el nombre del beneficiario de un tiro se corta a ochenta caracteres. Un campo de texto libre que acaba en una consulta o en una ventana gráfica es una puerta abierta, y aquí el texto lo escribe un modelo que no controla nadie.

def normalize_ai_decision(self, decision): if not isinstance(decision, dict): decision = {} reward = str(decision.get("reward", "none")) valid = {"none", *(n for n, _c, _d in REWARDS)} if reward not in valid: reward = "none" # invento ⇒ opción neutra action = str(decision.get("poker_action", "check")).lower() if action not in {"check", "bet", "fold", "all_in"}: action = "check" return { "reward": reward, "take_shot": bool(decision.get("take_shot", False)), "shot_beneficiary": str( decision.get("shot_beneficiary", "self"))[:80], ... }

Tolerancia al fallo

Lo peor no es que la IA no responda: es que tarde.

El fallo evidente se captura. El que mata la simulación es el modelo que contesta a los veinte segundos en cada turno de una partida de cien asaltos y no la deja terminar nunca.

Hay un contador de fallos consecutivos que activa un cortocircuito permanente. Al segundo fallo seguido, el proveedor de IA se desactiva para el resto de esa simulación y todas las decisiones restantes las toma una heurística local, con un aviso en el registro para que nadie interprete después que estaba jugando el modelo.

El contador se reinicia con una sola respuesta válida. Una red inestable no apaga la IA de forma definitiva: solo durante esa ejecución, que es el único horizonte sobre el que se puede decidir.

Cada llamada lleva un tiempo de espera configurable con un suelo de tres segundos — el código escribe max(3, timeout) — para que un campo mal rellenado no produzca una espera indefinida. Nunca hay una espera sin límite, que es la única que no se recupera.

La heurística no es un parche vacío: reproduce una estrategia conservadora real, así que una partida terminada por heurística sigue siendo útil para validar reglas. Ese era el objetivo desde el principio: medir el juego, no medir el modelo.

except (OSError, urllib.error.URLError, json.JSONDecodeError, ValueError) as exc: self.ollama_consecutive_failures += 1 self.log_event(f"Ollama no respondio; uso heuristica. {exc}") if self.ollama_consecutive_failures >= 2: self.ollama_runtime_disabled = True self.log_event( "Ollama se desactiva para el resto de esta " "simulacion; se usara la heuristica sin mas esperas." ) return None

Verificación

Probar lógica que depende de un modelo que no siempre está.

Las pruebas corren sin Ollama instalado. Se sustituye la llamada HTTP por una respuesta falsa y se valida el contrato contra tres situaciones que se eligen a propósito.

Un doble de urllib.request.urlopen devuelve lo que a mí me interesa: un JSON válido, un texto sin estructura, un error de transporte. Con eso se comprueba el camino feliz y las dos formas de romperse, y el motor se prueba sin red ni modelo descargado.

Los perfiles de prueba juegan seis jugadores con nombre más un repartidor, con un máximo de cien asaltos y un resumen al final. Si una regla hace que nadie pueda ganar, el perfil no termina y el máximo lo detiene: un bucle infinito en un motor de juego es exactamente ese caso.

Y lo que falta

Con TEST_PROFILE_MAX_ROUNDS = 100, el coste de una regresión es alto y las pruebas actuales no cubren los veinticuatro efectos uno a uno contra estados fijos. Es una deuda real y acotada. La razón por la que cada efecto es ya una función separada es precisamente esa: se pueden probar sin reescribir nada del motor.

  • 3.467líneas de motor en un único fichero
  • 492líneas de pruebas automatizadas
  • 174funciones y 4 clases
  • 5textos de instrucciones: reglas y cuatro roles
  • 2fallos seguidos para desactivar la IA de una partida
  • 3scripts de punto de retorno con confirmación escrita

Lo que haría distinto

Separar el dominio de la interfaz. Ahora mismo el motor y la ventana de tkinter viven en el mismo fichero de tres mil cuatrocientas sesenta y siete líneas, y eso obliga a montar la aplicación para probar una regla. Lo siguiente es una tabla de pruebas por efecto de carta —estado de entrada, estado esperado— que corra sin abrir ventana. Y guardar los puntos de retorno en el historial versionado en lugar de en tres scripts de PowerShell que requieren escribir una palabra para restaurar: la confirmación a mano evita un desastre, pero el histórico no debería vivir fuera de Git.