
PROYECTO SAMUEL — 3 en Raya con Q-Learning
Línea THEMIS — Ética, Alfabetización Mediática y Pensamiento Crítico con IA Local
IES Monterroso | Puerto:
:8072| Archivo:app_3enraya_rl.py
📌 1. Ficha Técnica
| Parámetro | Detalle |
| Nombre del Proyecto | PROYECTO SAMUEL (Aprendizaje por Refuerzo / Q-Learning Tabular) |
| Epónimo | Arthur Samuel (1901–1990), científico que demostró en 1959 que un ordenador podía aprender a jugar a las damas jugando contra sí mismo sin que un humano le programara las reglas estratégicas. |
| Puerto de Servicio | http://localhost:8072 / [http://127.0.0.1:8072](http://127.0.0.1:8072) |
| Stack Técnico | Python 3.10+, FastAPI, Uvicorn, HTML5/JS ES6 (Single-File Architecture). |
| Motor de IA (RL) | Q-Learning puro en Python (Sin LLMs, sin PyTorch, sin redes neuronales preentrenadas). |
| Estructura de Datos | Diccionario Python en memoria (Q-Table): (Estado_Tablero, Casilla_Elegida) ➔ Valor_Q. |
| Destinatarios | Alumnado de 1º a 4º de ESO y Bachillerato (Tecnología, Matemáticas e IA). |
🏛️ 2. ¿Por qué Arthur Samuel?
En los años 50, la mayoría de los científicos creían que para que una máquina jugara bien al ajedrez o a las damas, un experto humano debía programar manualmente miles de reglas (“si el rival ocupa el centro, haz X”).
Arthur Samuel demostró que había un camino infinitamente más potente: la Tabula Rasa y el ensayo-error. Dejó que la máquina jugara miles de partidas contra sí misma, anotando qué decisiones acababan en victoria y cuáles en derrota. En pocas horas, la máquina superó al propio Samuel, que era un jugador medio.
🎯 3. Propósito Pedagógico y Filosofía THEMIS
Este proyecto rompe un sesgo cognitivo clave que tiene el alumnado actual: “Toda la Inteligencia Artificial es un modelo de lenguaje (LLM) o un chatbot”.
Principios clave para el aula:
- Desmontando la Caja Negra: El alumnado no interactúa con una red neuronal de miles de millones de parámetros inaccesibles. La “mente” del agente es literalmente un mapa de números visibles (la Tabla Q) que suben cuando gana y bajan cuando pierde.
- Sin Reglas Programadas: El script en Python no sabe jugar al 3 en raya. Solo conoce las reglas físicas (dónde se puede poner ficha y cuándo acaba la partida). Todas las estrategias (ocupar el centro, hacer tenazas, bloquear al rival) las descubre el agente por sí solo.
- Exploración vs. Explotación ($\epsilon$-greedy): Permite explicar de forma intuitiva por qué al principio el agente comete fallos absurdos (explora alternativas al azar) y cómo, al acumular experiencia, pasa a usar su conocimiento optimizado (explota lo aprendido).
⚙️ 4. Arquitectura de Datos y Flujo Técnico
Plaintext
[ TABULA RASA (Tabla Q Vacía) ]
│
▼
┌──────────────────────────────────────────┐
│ Bucle de Auto-Entrenamiento (Self-Play) │
│ 1. Agente juega contra sí mismo │
│ 2. Asigna Premio (+1) o Castigo (-1) │
│ 3. Actualiza la Fórmula de Q-Learning │
└────────────────────┬─────────────────────┘
│ (Miles de partidas en segundos)
▼
[ TABLA Q ENTRENADA (Mapa de Calor) ]
│
▼
[ Partida Humano vs. IA ] ──► (Elige la casilla con mayor valor Q en directo)
La Fórmula de Actualización (Ecuación de Bellman simplificada):
$$Q(s, a) \leftarrow Q(s, a) + \alpha \cdot \left[ r + \gamma \cdot \max_{a’} Q(s’, a’) – Q(s, a) \right]$$
📂 5. Estructura de Archivos
Un único archivo autosuficiente (Single-File Application):
Plaintext
proyecto_samuel/
└── app_3enraya_rl.py # Servidor FastAPI + Agente Q-Learning + Frontend HTML/CSS/JS
🚀 6. Guía de Ejecución
- Lanzar el Servidor SAMUEL (No requiere LM Studio):
PowerShelluvicorn app_3enraya_rl:app --host 0.0.0.0 --port 8072 --reload - Abrir en el Navegador:Navegar a
http://localhost:8072o desde los equipos del aula ([http://192.168.](http://192.168.)X.X:8072).

🏫 7. Dinámica Sugerida para una Sesión de Clase (50 min)
- Fase 1: Retar a la IA “Recién Nacida” (10 min): El alumnado juega contra el agente con 0 partidas de entrenamiento. La IA comete errores garrafales y es muy fácil de vencer.
- Fase 2: El Salto de Aprendizaje (10 min): Pulsan el botón “Entrenar 5.000 partidas” (se ejecuta en 0,2 segundos). El alumnado observa cómo la Q-Table se llena de miles de estados analizados.
- Fase 3: El Desafío Imbatible (15 min): Vuelven a jugar contra la IA. Descubren que ahora es casi imposible ganarle (como máximo se consiguen empates).
- Fase 4: Inspección Radiográfica de la Mente (15 min): Al pasar el ratón por el tablero de juego, la app muestra el mapa de calor con las puntuaciones Q de cada casilla disponible. El alumnado comprende exactamente por qué la IA toma una decisión sin necesidad de usar un modelo de lenguaje.
🐍 Código Completo de app_3enraya_rl.py (Puerto :8072)
Python
import random
import time
from typing import Dict, List, Optional
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
app = FastAPI(title="Proyecto SAMUEL - 3 en Raya con Q-Learning (THEMIS)")
# Combinaciones ganadoras en un tablero de 9 casillas (0 a 8)
WIN_COMBOS = [
(0,1,2), (3,4,5), (6,7,8), # Filas
(0,3,6), (1,4,7), (2,5,8), # Columnas
(0,4,8), (2,4,6) # Diagonales
]
def check_winner(board: str) -> Optional[str]:
"""Retorna 'X', 'O', 'D' (Empate/Draw) o None (Si la partida continúa)."""
for a, b, c in WIN_COMBOS:
if board[a] != ' ' and board[a] == board[b] == board[c]:
return board[a]
if ' ' not in board:
return 'D'
return None
class QLearningAgent:
def __init__(self, alpha=0.2, gamma=0.9, epsilon=1.0, min_epsilon=0.01, decay=0.9997):
self.q_table: Dict[str, Dict[int, float]] = {} # estado -> {accion: valor_q}
self.alpha = alpha # Tasa de aprendizaje
self.gamma = gamma # Factor de descuento (visión a futuro)
self.epsilon = epsilon # Exploración vs Explotación
self.min_epsilon = min_epsilon
self.decay = decay
self.total_episodes = 0
self.stats_history = []
def reset_memory(self):
self.q_table.clear()
self.epsilon = 1.0
self.total_episodes = 0
self.stats_history.clear()
def get_q(self, state: str, action: int) -> float:
return self.q_table.get(state, {}).get(action, 0.0)
def set_q(self, state: str, action: int, value: float):
if state not in self.q_table:
self.q_table[state] = {}
self.q_table[state][action] = round(value, 4)
def get_legal_actions(self, state: str) -> List[int]:
return [i for i, ch in enumerate(state) if ch == ' ']
def choose_action(self, state: str, legal_actions: List[int], greedy: bool = False) -> int:
if not legal_actions:
return -1
# Exploración al azar (si no es modo codicioso)
if not greedy and random.random() < self.epsilon:
return random.choice(legal_actions)
# Explotación: Elegir la acción con mayor valor Q
q_vals = [self.get_q(state, a) for a in legal_actions]
max_q = max(q_vals)
best_actions = [a for a, q in zip(legal_actions, q_vals) if q == max_q]
return random.choice(best_actions)
def update_q(self, state: str, action: int, reward: float, next_state: str, next_legal_actions: List[int], done: bool):
current_q = self.get_q(state, action)
if done:
max_next_q = 0.0
else:
max_next_q = max([self.get_q(next_state, a) for a in next_legal_actions]) if next_legal_actions else 0.0
new_q = current_q + self.alpha * (reward + (self.gamma * max_next_q) - current_q)
self.set_q(state, action, new_q)
def train_self_play(self, num_episodes: int):
start_time = time.time()
x_wins = 0
o_wins = 0
draws = 0
for ep in range(num_episodes):
board = " " * 9
history_x = []
history_o = []
current_player = 'X'
while True:
legal = self.get_legal_actions(board)
if not legal:
break
action = self.choose_action(board, legal, greedy=False)
if current_player == 'X':
history_x.append((board, action))
board = board[:action] + 'X' + board[action+1:]
else:
history_o.append((board, action))
board = board[:action] + 'O' + board[action+1:]
winner = check_winner(board)
if winner is not None:
if winner == 'X':
rx, ro = 1.0, -1.0
x_wins += 1
elif winner == 'O':
rx, ro = -1.0, 1.0
o_wins += 1
else:
rx, ro = 0.3, 0.3
draws += 1
# Retropropagación de recompensas para X
for idx in reversed(range(len(history_x))):
s, a = history_x[idx]
if idx == len(history_x) - 1:
self.update_q(s, a, rx, "", [], done=True)
else:
next_s, _ = history_x[idx+1]
self.update_q(s, a, 0.0, next_s, self.get_legal_actions(next_s), done=False)
# Retropropagación de recompensas para O
for idx in reversed(range(len(history_o))):
s, a = history_o[idx]
if idx == len(history_o) - 1:
self.update_q(s, a, ro, "", [], done=True)
else:
next_s, _ = history_o[idx+1]
self.update_q(s, a, 0.0, next_s, self.get_legal_actions(next_s), done=False)
break
current_player = 'O' if current_player == 'X' else 'X'
self.epsilon = max(self.min_epsilon, self.epsilon * self.decay)
self.total_episodes += num_episodes
elapsed = round(time.time() - start_time, 3)
return {
"trained_now": num_episodes,
"total_episodes": self.total_episodes,
"time_seconds": elapsed,
"q_table_size": len(self.q_table),
"current_epsilon": round(self.epsilon, 4)
}
# Instancia global del agente Q-Learning
agent = QLearningAgent()
class TrainRequest(BaseModel):
episodes: int = 5000
class AIMoveRequest(BaseModel):
board: str # Estado actual del tablero (9 caracteres)
HTML_CONTENT = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PROYECTO SAMUEL - Q-Learning 3 en Raya</title>
<style>
:root {
--bg-page: #f8fafc;
--nav-bg: #0f172a;
--primary: #2563eb;
--primary-hover: #1d4ed8;
--accent-green: #059669;
--accent-amber: #d97706;
--border-color: #cbd5e1;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-page); color: #0f172a; line-height: 1.5; }
header {
background: var(--nav-bg);
color: white;
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
}
.header-left { display: flex; align-items: center; gap: 12px; }
.brand-logo { background: #60a5fa; color: #0f172a; font-weight: 900; padding: 6px 10px; border-radius: 6px; font-size: 18px; }
.header-titles h1 { font-size: 18px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
.pill { padding: 2px 8px; border-radius: 4px; font-weight: 600; font-size: 11px; text-transform: uppercase; }
.pill-blue { background: var(--primary); color: white; }
.pill-amber { background: var(--accent-amber); color: white; }
.container { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
.banner {
background: linear-gradient(135deg, #0f172a, #1e293b);
color: white;
border-radius: 12px;
padding: 20px 24px;
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
border-left: 6px solid #60a5fa;
}
.banner-text h2 { font-size: 12px; color: #60a5fa; text-transform: uppercase; font-weight: 800; letter-spacing: 0.5px; }
.banner-text p { font-size: 18px; font-weight: 700; margin-top: 2px; }
.metrics-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
.metric-card { background: white; border: 1px solid var(--border-color); border-radius: 10px; padding: 16px; text-align: center; }
.metric-value { font-size: 26px; font-weight: 900; color: var(--primary); }
.metric-label { font-size: 12px; font-weight: 700; color: #64748b; text-transform: uppercase; margin-top: 2px; }
.grid-main { display: grid; grid-template-columns: 380px 1fr; gap: 24px; }
.card { background: white; border-radius: 12px; border: 1px solid var(--border-color); padding: 20px; }
.card-title { font-size: 14px; font-weight: 800; text-transform: uppercase; color: #475569; margin-bottom: 14px; display: flex; align-items: center; gap: 8px; }
/* SELECTOR DE TURNO */
.select-group { margin-bottom: 16px; }
.select-group label { display: block; font-size: 12px; font-weight: 700; color: #475569; text-transform: uppercase; margin-bottom: 6px; }
.select-control { width: 100%; padding: 10px; border-radius: 8px; border: 1px solid var(--border-color); font-weight: 700; font-size: 14px; background: #f8fafc; }
/* TABLERO INTERACTIVO */
.board-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; max-width: 300px; margin: 0 auto 16px auto; }
.cell {
aspect-ratio: 1;
background: #f1f5f9;
border: 2px solid var(--border-color);
border-radius: 10px;
font-size: 36px;
font-weight: 900;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
transition: all 0.15s ease;
position: relative;
}
.cell:hover { background: #e2e8f0; border-color: var(--primary); }
.cell.x { color: #dc2626; }
.cell.o { color: #2563eb; }
.q-subtag { font-size: 10px; font-weight: 700; color: #64748b; margin-top: -4px; }
.btn { width: 100%; padding: 10px 14px; border: none; border-radius: 8px; font-size: 14px; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: background 0.2s; margin-bottom: 8px; }
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-success { background: var(--accent-green); color: white; }
.btn-success:hover { background: #047857; }
.btn-danger { background: #ef4444; color: white; }
.btn-danger:hover { background: #dc2626; }
.status-box { background: #eff6ff; border: 1px solid #bfdbfe; padding: 12px; border-radius: 8px; font-weight: 700; color: #1e40af; text-align: center; margin-bottom: 16px; }
.q-heatmap { background: #fafafa; border: 1px solid var(--border-color); border-radius: 8px; padding: 14px; }
.q-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px dashed #e2e8f0; font-size: 13px; }
.q-row:last-child { border-bottom: none; }
.q-score { font-family: monospace; font-weight: 800; padding: 2px 6px; border-radius: 4px; }
.q-positive { background: #dcfce7; color: #15803d; }
.q-negative { background: #fee2e2; color: #b91c1c; }
.pedagogical-box { background: #f0fdf4; border: 1px solid #bbf7d0; border-left: 4px solid var(--accent-green); padding: 14px; border-radius: 8px; margin-top: 20px; font-size: 13px; color: #166534; }
</style>
</head>
<body>
<header>
<div class="header-left">
<div class="brand-logo">SAMUEL</div>
<div class="header-titles">
<h1>PROYECTO SAMUEL <span class="pill pill-amber">PUERTO :8072</span></h1>
</div>
</div>
<span class="pill pill-blue">IES MONTERROSO — LÍNEA THEMIS</span>
</header>
<div class="container">
<div class="banner">
<div class="banner-text">
<h2>3 EN RAYA CON APRENDIZAJE POR REFUERZO (Q-LEARNING)</h2>
<p>Una IA que aprende desde cero (Tabula Rasa) sin reglas preprogramadas ni modelos de lenguaje</p>
</div>
<div style="font-size:36px;">🎮</div>
</div>
<!-- METRICAS DE ENTRENAMIENTO -->
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-value" id="mEpisodes">0</div>
<div class="metric-label">Partidas Simuladas</div>
</div>
<div class="metric-card">
<div class="metric-value" id="mStates">0</div>
<div class="metric-label">Estados en Q-Table</div>
</div>
<div class="metric-card">
<div class="metric-value" id="mEpsilon">100%</div>
<div class="metric-label">Exploración Azar (ε)</div>
</div>
<div class="metric-card">
<div class="metric-value" id="mStatus">Tabula Rasa</div>
<div class="metric-label">Estado de la IA</div>
</div>
</div>
<div class="grid-main">
<!-- PANEL IZQUIERDO: ENTRENAMIENTO Y TABLERO -->
<div class="card">
<div class="card-title">⚡ 1. Centro de Entrenamiento</div>
<button class="btn btn-primary" onclick="trainAgent(1000)">⚡ Entrenar +1.000 Partidas</button>
<button class="btn btn-success" onclick="trainAgent(10000)">⚡ Entrenar +10.000 Partidas</button>
<button class="btn btn-danger" onclick="resetAgent()">🔄 Reiniciar IA a Cero (Tabula Rasa)</button>
<div class="card-title" style="margin-top:24px;">⚔️ 2. Configurar y Jugar</div>
<div class="select-group">
<label for="starterSelect">¿Quién empieza la partida?</label>
<select id="starterSelect" class="select-control" onchange="resetGame()">
<option value="human">👤 Humano empieza (Humano = X, IA = O)</option>
<option value="ai">🤖 IA empieza (IA = X, Humano = O)</option>
<option value="alt">🔄 Alternar automáticamente cada partida</option>
</select>
</div>
<div class="status-box" id="gameStatus">Tu turno</div>
<div class="board-grid" id="board"></div>
<button class="btn btn-primary" style="background:#475569;" onclick="resetGame()">🔁 Reiniciar Partida</button>
</div>
<!-- PANEL DERECHO: RADIOGRAFÍA DE LA Q-TABLE -->
<div class="card">
<div class="card-title">🧠 3. Radiografía de la Mente de la IA (Valores Q)</div>
<p style="font-size:13px; color:#64748b; margin-bottom:12px;">
Valores $Q(s, a)$ aprendidos para el estado actual. La IA elegirá siempre la casilla disponible con la puntuación más alta.
</p>
<div class="q-heatmap" id="qHeatmap">
<div style="font-size:13px; color:#94a3b8; text-align:center;">Haz un movimiento o deja que empiece la IA para ver la evaluación...</div>
</div>
<div class="pedagogical-box">
🧐 <strong>LECCIÓN TÁCTICA Y PEDAGÓGICA (THEMIS):</strong><br>
• <strong>Si empieza la IA (X):</strong> Cuando está entrenada, ocupará inmediatamente el centro o las esquinas. Castigará cualquier error táctico del humano.<br>
• <strong>Si empieza el Humano (X):</strong> Con estrategia perfecta se puede forzar el empate. La IA entrenada jugará como defensora perfecta con <strong>O</strong>.
</div>
</div>
</div>
</div>
<script>
const WIN_COMBOS = [
[0,1,2], [3,4,5], [6,7,8],
[0,3,6], [1,4,7], [2,5,8],
[0,4,8], [2,4,6]
];
let currentBoard = [" ", " ", " ", " ", " ", " ", " ", " ", " "];
let gameOver = false;
let humanSymbol = 'X';
let aiSymbol = 'O';
let lastStarter = 'human'; // Para el modo alternar
function checkWinnerJS(board) {
for (let [a,b,c] of WIN_COMBOS) {
if (board[a] !== ' ' && board[a] === board[b] && board[a] === board[c]) {
return board[a];
}
}
if (!board.includes(' ')) return 'D';
return null;
}
function renderBoard(qValues = {}) {
const boardEl = document.getElementById('board');
boardEl.innerHTML = '';
currentBoard.forEach((cell, idx) => {
const cellEl = document.createElement('div');
cellEl.className = `cell ${cell.toLowerCase()}`;
let qText = '';
if (cell === ' ' && qValues.hasOwnProperty(idx)) {
const qVal = qValues[idx];
qText = `<span class="q-subtag">Q:${qVal.toFixed(2)}</span>`;
}
cellEl.innerHTML = `<span>${cell === ' ' ? '' : cell}</span>${qText}`;
cellEl.onclick = () => handleHumanClick(idx);
boardEl.appendChild(cellEl);
});
}
async function requestAIMove() {
if (gameOver) return;
document.getElementById('gameStatus').innerText = `🤖 Pensando turno de la IA (${aiSymbol})...`;
try {
const res = await fetch('/v1/ai_move', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ board: currentBoard.join('') })
});
const data = await res.json();
currentBoard = data.updated_board.split('');
gameOver = data.game_over;
renderBoard(data.current_q_values);
renderQHeatmap(data.current_q_values, data.ai_chosen_action);
if (data.game_over) {
handleGameOver(data.winner);
} else {
document.getElementById('gameStatus').innerText = `Tu turno (Juegas con ${humanSymbol})`;
}
} catch(e) {
console.error(e);
}
}
async function handleHumanClick(index) {
if (gameOver || currentBoard[index] !== ' ') return;
// Poner la ficha del humano
currentBoard[index] = humanSymbol;
renderBoard();
// Verificar si el humano ganó o empató en este movimiento
const localWinner = checkWinnerJS(currentBoard);
if (localWinner !== null) {
gameOver = true;
handleGameOver(localWinner);
return;
}
// Si la partida sigue, turno de la IA
await requestAIMove();
}
function handleGameOver(winner) {
if (winner === humanSymbol) {
document.getElementById('gameStatus').innerText = '🎉 ¡Has ganado! (La IA aún comete errores)';
} else if (winner === aiSymbol) {
document.getElementById('gameStatus').innerText = '🤖 La IA ha ganado la partida.';
} else if (winner === 'D') {
document.getElementById('gameStatus').innerText = '🤝 Empate (Partida Perfecta).';
}
}
async function trainAgent(episodes) {
document.getElementById('gameStatus').innerText = '🤖 Entrenando partidas en segundo plano...';
try {
const res = await fetch('/v1/train', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ episodes: episodes })
});
const data = await res.json();
document.getElementById('mEpisodes').innerText = data.total_episodes.toLocaleString();
document.getElementById('mStates').innerText = data.q_table_size.toLocaleString();
document.getElementById('mEpsilon').innerText = Math.round(data.current_epsilon * 100) + '%';
if (data.total_episodes > 10000) {
document.getElementById('mStatus').innerText = 'Imbatible 🏆';
} else if (data.total_episodes > 0) {
document.getElementById('mStatus').innerText = 'Aprendiendo 📈';
}
document.getElementById('gameStatus').innerText = `¡Entrenadas +${episodes} partidas en ${data.time_seconds}s!`;
resetGame();
} catch(e) {
alert('Error al entrenar al agente.');
}
}
async function resetAgent() {
await fetch('/v1/reset', { method: 'POST' });
document.getElementById('mEpisodes').innerText = '0';
document.getElementById('mStates').innerText = '0';
document.getElementById('mEpsilon').innerText = '100%';
document.getElementById('mStatus').innerText = 'Tabula Rasa';
document.getElementById('gameStatus').innerText = 'IA reiniciada a cero.';
resetGame();
}
function renderQHeatmap(qValues, chosenAction) {
const container = document.getElementById('qHeatmap');
container.innerHTML = '';
const keys = Object.keys(qValues);
if (keys.length === 0) {
container.innerHTML = '<div style="font-size:13px; color:#94a3b8; text-align:center;">No hay datos Q aprendidos para esta posición.</div>';
return;
}
keys.forEach(actionIdx => {
const qVal = qValues[actionIdx];
const isChosen = parseInt(actionIdx) === chosenAction;
const scoreClass = qVal >= 0 ? 'q-positive' : 'q-negative';
container.innerHTML += `
<div class="q-row" style="${isChosen ? 'background:#e0f2fe; font-weight:bold; padding:6px;' : ''}">
<span>Casilla #${actionIdx} ${isChosen ? '👈 (Elección IA)' : ''}</span>
<span class="q-score ${scoreClass}">Q = ${qVal.toFixed(4)}</span>
</div>
`;
});
}
function resetGame() {
currentBoard = [" ", " ", " ", " ", " ", " ", " ", " ", " "];
gameOver = false;
const mode = document.getElementById('starterSelect').value;
let whoStarts = mode;
if (mode === 'alt') {
whoStarts = (lastStarter === 'human') ? 'ai' : 'human';
lastStarter = whoStarts;
}
if (whoStarts === 'human') {
humanSymbol = 'X';
aiSymbol = 'O';
document.getElementById('gameStatus').innerText = `Tu turno (Juegas con ${humanSymbol})`;
document.getElementById('qHeatmap').innerHTML = '<div style="font-size:13px; color:#94a3b8; text-align:center;">Haz un movimiento para ver la evaluación de la IA...</div>';
renderBoard();
} else {
humanSymbol = 'O';
aiSymbol = 'X';
renderBoard();
requestAIMove();
}
}
// Inicializar al cargar
resetGame();
</script>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
async def serve_index():
return HTML_CONTENT
@app.post("/v1/train")
async def train(req: TrainRequest):
stats = agent.train_self_play(req.episodes)
return JSONResponse(stats)
@app.post("/v1/reset")
async def reset():
agent.reset_memory()
return JSONResponse({"status": "reset_successful"})
@app.post("/v1/ai_move")
async def ai_move(req: AIMoveRequest):
board = req.board
winner = check_winner(board)
if winner is not None:
return JSONResponse({
"updated_board": board,
"game_over": True,
"winner": winner,
"ai_chosen_action": -1,
"current_q_values": {}
})
# Determina automáticamente el turno según el número de piezas en el tablero
x_count = board.count('X')
o_count = board.count('O')
current_turn = 'X' if x_count == o_count else 'O'
legal_actions = agent.get_legal_actions(board)
q_values = {a: agent.get_q(board, a) for a in legal_actions}
# Elegir la mejor acción aprendida (modo greedy)
ai_action = agent.choose_action(board, legal_actions, greedy=True)
updated_board = board
if ai_action != -1:
updated_board = board[:ai_action] + current_turn + board[ai_action+1:]
final_winner = check_winner(updated_board)
return JSONResponse({
"updated_board": updated_board,
"game_over": final_winner is not None,
"winner": final_winner,
"ai_chosen_action": ai_action,
"current_q_values": q_values
})
🏷️ Metadatos de la Entrada
🔗



