Leskys_Shop / game /engine.py
siigrid's picture
first push
9a097d5
Raw
History Blame Contribute Delete
45.4 kB
"""
Legisladores del Bosque — Motor del juego (FASE 1).
Árbitro honesto: mantiene el estado, valida efectos, aplica leyes y puntúa.
El LLM NUNCA toca nada de este módulo directamente: solo produce dicts de
efectos que aquí se validan con `validate_effect`.
Sin dependencias externas: Python puro.
"""
from __future__ import annotations
import random
import re
import copy
# ---------------------------------------------------------------------------
# Constantes del tablero
# ---------------------------------------------------------------------------
BASE_BOARD = [
[2, 1, 2],
[1, 3, 1],
[2, 1, 2],
]
RED = "red"
BLUE = "blue"
DISPUTED = "disputed" # casilla en disputa: no puntúa para nadie
SPLIT = "split" # casilla compartida: mitad de valor para cada uno
FICHAS_POR_RONDA = 3 # por defecto 3x3 (legacy); por partida se usa board_size
MAX_LEYES = 3
MAX_ROUNDS = 7 # la partida dura siempre exactamente 7 rondas
MAX_BOARD = 4 # lado máximo soportado (para validar zonas cell(r,c))
# ---------------------------------------------------------------------------
# Tableros aleatorios por ronda
# ---------------------------------------------------------------------------
# Cada preset: [[fila0], [fila1], [fila2]] con valores 0-5
BOARD_PRESETS = [
# Original: centro fuerte, esquinas medias (max=3, ×1)
[[2, 1, 2], [1, 3, 1], [2, 1, 2]],
# Diagonal alta (max=3, ×3)
[[3, 1, 1], [1, 3, 1], [1, 1, 3]],
# Anillo del Bosque: bordes fértiles (max=3, ×3 — tres bordes fuertes)
[[1, 3, 1], [3, 1, 2], [1, 3, 1]],
# Cuatro Torres: 3 esquinas fuertes (max=4, ×3)
[[4, 1, 4], [1, 1, 1], [4, 1, 1]],
# Fila superior vale mucho (max=3, ×3)
[[3, 3, 3], [2, 1, 2], [0, 1, 0]],
# Columna central valiosa (max=4, ×3)
[[1, 4, 1], [1, 4, 1], [1, 4, 1]],
# Tablero uniforme con centro rey (max=3, ×1)
[[2, 2, 2], [2, 3, 2], [2, 2, 2]],
# Vacío central (max=2, ×7)
[[2, 2, 2], [2, 0, 1], [2, 2, 2]],
# Encrucijada: bordes altos, centro muerto (max=4, ×3 bordes)
[[1, 4, 1], [4, 0, 3], [1, 4, 1]],
# Santuario: centro valiossísimo, esquinas medias (max=5, ×1)
[[3, 0, 3], [0, 5, 0], [3, 0, 2]],
# Tesoro Escondido: un solo gem en esquina (max=5, ×1)
[[0, 0, 5], [1, 2, 1], [0, 1, 0]],
# Bastión Oeste: columna izquierda dominante (max=5, ×1)
[[5, 1, 0], [4, 1, 0], [4, 2, 0]],
# Patchwork: terreno irregular — cada zona vale algo distinto (max=4, ×1)
[[4, 1, 2], [0, 3, 1], [2, 1, 3]],
# Campo de Batalla: esquinas superiores explosivas (max=5, ×1)
[[5, 2, 4], [1, 0, 1], [3, 1, 2]],
]
BOARD_NAMES = [
"The Great Oak",
"The Diagonal Path",
"The Forest Ring",
"The Three Towers",
"The North Ridge",
"The Middle Way",
"The Throne Glade",
"The Central Void",
"The Crossroads",
"The Ancient Shrine",
"The Hidden Hoard",
"The Western Bastion",
"The Patchwork Glen",
"The Battlefield",
]
# Presets 4x4 (el tablero del monstruo). Mismo juego, lado 4. Valores 0-5.
BOARD_PRESETS_4 = [
[[1, 1, 1, 1], [1, 3, 3, 1], [1, 3, 1, 1], [1, 1, 1, 1]], # bloque central
[[3, 1, 1, 1], [1, 3, 1, 1], [1, 1, 3, 1], [1, 1, 1, 1]], # diagonal
[[3, 1, 1, 3], [1, 1, 1, 1], [1, 1, 1, 1], [3, 1, 1, 1]], # esquinas
[[3, 3, 3, 1], [1, 1, 1, 1], [1, 2, 2, 1], [1, 1, 1, 1]], # muro norte
[[1, 3, 3, 1], [3, 1, 1, 1], [1, 1, 1, 3], [1, 3, 1, 1]], # anillo roto
[[1, 3, 1, 1], [1, 3, 1, 1], [1, 3, 1, 2], [1, 1, 1, 1]], # columna
]
BOARD_NAMES_4 = [
"The Sunken Vault",
"The Iron Diagonal",
"The Four Pillars",
"The High Wall",
"The Broken Ring",
"The Monolith",
]
# Sanity-check at import time: every preset must have an ODD count of max-value
# cells so there is always a tiebreaker cell worth fighting over.
for _b in BOARD_PRESETS + BOARD_PRESETS_4:
_flat = [v for row in _b for v in row]
_mx = max(_flat)
assert _flat.count(_mx) % 2 == 1, (
f"Board preset has an EVEN number of max-value ({_mx}) cells: {_b}"
)
def presets_for(n: int) -> tuple[list, list]:
"""(presets, names) para un tablero de lado n."""
if n == 4:
return BOARD_PRESETS_4, BOARD_NAMES_4
return BOARD_PRESETS, BOARD_NAMES
def random_board(rng: random.Random, n: int = 3) -> list[list[int]]:
"""Returns a random board from the presets for side n."""
return rng.choice(presets_for(n)[0])
# ---------------------------------------------------------------------------
# Secret Objectives
# ---------------------------------------------------------------------------
class SecretObjective:
"""A secret objective that awards cleverness proportional to how many times
the condition is met throughout the game."""
def __init__(self, key: str, description: str, hint: str):
self.key = key
self.description = description # full text (visible to owner)
self.hint = hint # vague hint (visible to rival)
def check(self, state: "GameState", owner: str) -> tuple[int, str]:
"""Evaluates how many times the condition is met in the current state.
Returns (count, explanation). count > 0 means it is met."""
raise NotImplementedError
def __repr__(self):
return f"SecretObjective({self.key!r})"
class _ObjEdgeControl(SecretObjective):
def __init__(self):
super().__init__(
"edge_control",
"Command the borders: place chips in edge cells (+1 cleverness per edge you own each round)",
"fixated on borders",
)
def check(self, state, owner):
edges = state.zone_cells("edges")
count = sum(1 for r, c in edges if state.occupancy[r][c] == owner)
return count, f"{count} edge(s) occupied"
class _ObjNoAdjacency(SecretObjective):
def __init__(self):
super().__init__(
"no_adjacency",
"Social distancing: end the round with none of your own chips adjacent to each other (+2 cleverness if completely separated)",
"demands personal space",
)
def check(self, state, owner):
pairs = state._adjacent_pairs(owner)
ok = pairs == 0
return (2 if ok else 0), ("no adjacent chips ✓" if ok else f"{pairs} adjacent pair(s) ✗")
class _ObjNoCenter(SecretObjective):
def __init__(self):
super().__init__(
"no_center",
"Shun the center: never place a chip there in a round (+1 cleverness if you avoid it)",
"avoids the center",
)
def check(self, state, owner):
ok = not any(state.occupancy[r][c] == owner for (r, c) in state.zone_cells("center"))
return (1 if ok else 0), ("center free ✓" if ok else "center occupied ✗")
class _ObjAdjacentTriple(SecretObjective):
def __init__(self):
super().__init__(
"adjacent_triple",
"Complete a full line of own chips (row, column, or diagonal) — +1 cleverness per line",
"seeks to align chips",
)
def check(self, state, owner):
count = sum(1 for line in state.lines()
if all(state.occupancy[r][c] == owner for r, c in line))
return count, f"{count} line(s) completed"
class _ObjHighValueCells(SecretObjective):
def __init__(self):
super().__init__(
"high_value",
"Be greedy: occupy cells with effective value ≥ 3 (+1 cleverness per such cell you own)",
"covets high-value cells",
)
def check(self, state, owner):
n = state.board_size
count = sum(
1 for r in range(n) for c in range(n)
if state.occupancy[r][c] == owner and state.effective_value(r, c) >= 3
)
return count, f"{count} cell(s) with value ≥ 3"
class _ObjNoClash(SecretObjective):
def __init__(self):
super().__init__(
"no_clash",
"Play it safe: end the round with zero clashes (+1 cleverness if no cell is disputed)",
"prefers peaceful play",
)
def check(self, state, owner):
n = state.board_size
clashes = sum(1 for r in range(n) for c in range(n) if state.occupancy[r][c] == DISPUTED)
ok = clashes == 0
return (1 if ok else 0), ("no clashes ✓" if ok else f"{clashes} clash(es) ✗")
class _ObjCornerControl(SecretObjective):
def __init__(self):
super().__init__(
"corner_control",
"Command the corners: place chips in the corner cells (+1 cleverness per corner you own each round)",
"fixated on corners",
)
def check(self, state, owner):
corners = state.zone_cells("corners")
count = sum(1 for r, c in corners if state.occupancy[r][c] == owner)
return count, f"{count}/4 corners occupied"
class _ObjWinByMargin(SecretObjective):
def __init__(self):
super().__init__(
"win_by_margin",
"Crush your opponent: win rounds by a large score margin (+1 cleverness per point of advantage, up to 4)",
"wants to dominate",
)
def check(self, state, owner):
scores = state.score_round()
rival = BLUE if owner == RED else RED
margin = max(0, scores[owner] - scores[rival])
count = min(int(margin), 4)
return count, f"advantage of {scores[owner]-scores[rival]:+g} pts → +{count} cleverness"
ALL_OBJECTIVES = [
_ObjEdgeControl(),
_ObjNoCenter(),
_ObjAdjacentTriple(),
_ObjHighValueCells(),
_ObjNoClash(),
_ObjCornerControl(),
_ObjWinByMargin(),
_ObjNoAdjacency(),
]
def assign_objectives(rng: random.Random) -> dict[str, SecretObjective]:
"""Asigna un objetivo secreto distinto a cada jugador al inicio de la partida."""
chosen = rng.sample(ALL_OBJECTIVES, 2)
return {RED: chosen[0], BLUE: chosen[1]}
def zones_for(n: int) -> dict:
"""Genera las zonas con nombre para un tablero de lado n.
Centro/medio = celda central (n impar) o bloque/franja 2 central (n par).
Para n=3 reproduce exactamente las zonas 3x3 originales."""
last = n - 1
cells = {(r, c) for r in range(n) for c in range(n)}
corners = {(0, 0), (0, last), (last, 0), (last, last)}
if n % 2 == 1:
m = n // 2
mid_rows, mid_cols = {m}, {m}
else:
m1, m2 = n // 2 - 1, n // 2
mid_rows, mid_cols = {m1, m2}, {m1, m2}
center = {(r, c) for r in mid_rows for c in mid_cols}
border = {(r, c) for (r, c) in cells if r in (0, last) or c in (0, last)}
return {
"center": center,
"corners": corners,
"edges": border - corners,
"row_top": {(0, c) for c in range(n)},
"row_mid": {(r, c) for r in mid_rows for c in range(n)},
"row_bot": {(last, c) for c in range(n)},
"col_left": {(r, 0) for r in range(n)},
"col_mid": {(r, c) for r in range(n) for c in mid_cols},
"col_right": {(r, last) for r in range(n)},
"all": cells,
}
_ZONE_CACHE: dict[int, dict] = {}
def _zones(n: int) -> dict:
if n not in _ZONE_CACHE:
_ZONE_CACHE[n] = zones_for(n)
return _ZONE_CACHE[n]
# Compat: zonas 3x3 a nivel de módulo (lo usaba código antiguo/tests).
ZONE_CELLS = zones_for(3)
ZONE_NAMES = set(ZONE_CELLS.keys())
OWNERS = {"red", "blue", "yellow", "any", "mine", "opponent"}
CLASH_MODES = {"disputed", "red_wins", "blue_wins", "split", "mine_wins", "opponent_wins"}
EFFECT_TYPES = {
"set_value", "multiply_value", "add_value",
"forbid_placement", "penalty_zone", "bonus_adjacency",
"clash_resolution",
}
_CELL_RE = re.compile(r"^cell\(\s*(\d+)\s*,\s*(\d+)\s*\)$")
_ZONE_ALIASES = {
"corner": "corners",
"edge": "edges",
"top": "row_top",
"mid": "row_mid",
"middle": "row_mid",
"bot": "row_bot",
"bottom": "row_bot",
"left": "col_left",
"right": "col_right",
"row_middle": "row_mid",
"row_bottom": "row_bot",
"col_middle": "col_mid",
"col_center": "col_mid",
"row_center": "row_mid",
"everywhere": "all",
"whole": "all",
"board": "all",
}
def zone_to_cells(zone: str, n: int = 3):
"""Devuelve el conjunto de casillas de una zona en un tablero de lado n,
o None si es inválida. Acepta zonas con nombre ("corners") o "cell(r,c)"
con r,c en 0..n-1. Normaliza alias comunes.
"""
if not isinstance(zone, str):
return None
zone = zone.strip().lower()
zone = _ZONE_ALIASES.get(zone, zone)
z = _zones(n)
if zone in z:
return z[zone]
m = _CELL_RE.match(zone)
if m:
r, c = int(m.group(1)), int(m.group(2))
if 0 <= r < n and 0 <= c < n:
return {(r, c)}
return None # celda fuera del tablero de lado n
return None
# ---------------------------------------------------------------------------
# Validación de efectos: el guardián contra alucinaciones del LLM
# ---------------------------------------------------------------------------
def _num(x):
"""Convierte a número si es posible (el LLM a veces manda strings)."""
if isinstance(x, bool):
return None
if isinstance(x, (int, float)):
return x
if isinstance(x, str):
try:
return float(x) if "." in x else int(x)
except ValueError:
return None
return None
def validate_effect(effect) -> tuple[bool, str]:
"""Valida un efecto. Devuelve (es_valido, motivo).
Rechaza tipos desconocidos, zonas/dueños fuera de vocabulario y números
fuera de rango. Cualquier "victoria directa" cae aquí porque no existe
ningún tipo de efecto que toque el marcador.
"""
if not isinstance(effect, dict):
return False, "el efecto no es un objeto"
etype = effect.get("type")
if etype not in EFFECT_TYPES:
return False, f"tipo de efecto desconocido: {etype!r}"
def check_zone():
cells = zone_to_cells(effect.get("zone", ""), MAX_BOARD)
if cells is None:
return f"zona inválida: {effect.get('zone')!r}"
return None
def check_owner():
owner = effect.get("owner")
if not isinstance(owner, str) or owner.strip().lower() not in OWNERS:
return f"dueño inválido: {owner!r}"
return None
if etype == "set_value":
err = check_zone()
if err:
return False, err
v = _num(effect.get("value"))
if v is None or not (0 <= v <= 9):
return False, f"value fuera de rango [0, 9]: {effect.get('value')!r}"
return True, "ok"
if etype == "multiply_value":
err = check_zone()
if err:
return False, err
f = _num(effect.get("factor"))
if f is None or not (0 <= f <= 3):
return False, f"factor fuera de rango [0, 3]: {effect.get('factor')!r}"
return True, "ok"
if etype == "add_value":
err = check_zone()
if err:
return False, err
a = _num(effect.get("amount"))
if a is None or not (-3 <= a <= 3):
return False, f"amount fuera de rango [-3, 3]: {effect.get('amount')!r}"
return True, "ok"
if etype == "forbid_placement":
err = check_zone() or check_owner()
if err:
return False, err
# Prohibir todo el tablero dejaría el juego injugable.
if effect["zone"].strip().lower() == "all":
return False, "no se puede prohibir todo el tablero"
return True, "ok"
if etype == "penalty_zone":
err = check_zone() or check_owner()
if err:
return False, err
a = _num(effect.get("amount"))
if a is None or not (-3 <= a <= 3):
return False, f"amount fuera de rango [-3, 3]: {effect.get('amount')!r}"
return True, "ok"
if etype == "bonus_adjacency":
err = check_owner()
if err:
return False, err
a = _num(effect.get("amount"))
if a is None or not (-3 <= a <= 3):
return False, f"amount fuera de rango [-3, 3]: {effect.get('amount')!r}"
return True, "ok"
if etype == "clash_resolution":
mode = effect.get("mode")
if not isinstance(mode, str) or mode.strip().lower() not in CLASH_MODES:
return False, f"modo de choque inválido: {mode!r}"
return True, "ok"
return False, "efecto no reconocido" # inalcanzable, por seguridad
def normalize_effect(effect: dict, legislator: str) -> dict:
"""Copia el efecto resolviendo dueños relativos (mine/opponent -> red/blue)
y normalizando strings. Llamar SOLO con efectos ya validados."""
eff = copy.deepcopy(effect)
eff["type"] = eff["type"].strip().lower()
if "zone" in eff:
eff["zone"] = eff["zone"].strip().lower()
if "mode" in eff:
mode = eff["mode"].strip().lower()
if mode == "mine_wins":
mode = f"{legislator}_wins"
elif mode == "opponent_wins":
rival = BLUE if legislator == RED else RED
mode = f"{rival}_wins"
eff["mode"] = mode
if "owner" in eff:
owner = eff["owner"].strip().lower()
if owner == "mine":
owner = legislator
elif owner == "opponent":
owner = BLUE if legislator == RED else RED
elif owner == "blue":
owner = RED # Blue represents the player (RED internally)
elif owner == "yellow":
owner = BLUE # Yellow represents the goblin (BLUE internally)
elif owner == "red":
owner = RED # Keep red for backward compatibility/tests
eff["owner"] = owner
for key in ("value", "factor", "amount"):
if key in eff:
eff[key] = _num(eff[key])
return eff
def describe_effect(eff: dict, opponent_name: str = "opponent") -> str:
"""Honest description in English of an effect (for the scroll and to unmask Lesky)."""
z = eff.get("zone", "?")
o = eff.get("owner", "?")
nombres = {RED: "you", BLUE: opponent_name, "any": "anyone"}
on = nombres.get(o, o)
t = eff["type"]
if t == "set_value":
return f"cells in '{z}' are worth {eff['value']}"
if t == "multiply_value":
return f"cells in '{z}' multiply their value by {eff['factor']}"
if t == "add_value":
return f"cells in '{z}' add {eff['amount']:+g} to their value"
if t == "forbid_placement":
return f"placement forbidden in '{z}' for {on}"
if t == "penalty_zone":
return f"{eff['amount']:+g} points per chip of {on} in '{z}'"
if t == "bonus_adjacency":
return f"{eff['amount']:+g} points for each pair of adjacent chips of {on}"
if t == "clash_resolution":
modos = {
"disputed": "clashing cells remain disputed (no one scores)",
"red_wins": "you win clashes",
"blue_wins": f"{opponent_name} wins clashes",
"split": "clash value is split in half",
}
return modos[eff["mode"]]
return str(eff)
def describe_law(law: "Law", opponent_name: str = "opponent") -> str:
"""Full description of a law (including double-edged if applicable).
Always uses announced_effects — Lesky may lie about any law, not only his own."""
parts = [describe_effect(e, opponent_name) for e in law.announced_effects if isinstance(e, dict) and "type" in e]
if len(parts) == 1:
return parts[0]
if len(parts) == 2:
return f"✨ {parts[0]} | ⚠️ {parts[1]}"
return " / ".join(parts)
class Law:
"""An active law: the real effect + what was announced (might differ if Lesky cheated).
Supports double-edged laws: effects is a list of 1 or 2 effects.
The first effect is usually beneficial to the author; the second, a penalty or
restriction that balances the law.
"""
def __init__(self, effects: list[dict], author: str, announcement: str = "",
announced_effects: list[dict] | None = None):
# Normalize to list
self.effects = effects if isinstance(effects, list) else [effects]
# Backward compatibility
self.effect = self.effects[0] if self.effects else {}
self.author = author # red / blue
self.announcement = announcement # shown narration
self.announced_effects = announced_effects if announced_effects is not None else list(self.effects)
# Backward compatibility
self.announced_effect = self.announced_effects[0] if self.announced_effects else self.effect
@property
def is_dual(self) -> bool:
return len(self.effects) > 1
@property
def is_lie(self) -> bool:
return self.effects != self.announced_effects
def __repr__(self):
return f"Law({[describe_effect(e) for e in self.effects]!r}, author={self.author})"
class Scroll:
"""El pergamino: lista de leyes activas, máximo MAX_LEYES."""
def __init__(self):
self.laws: list[Law] = []
def can_add(self) -> bool:
return len(self.laws) < MAX_LEYES
def add(self, law: Law, repeal_index: int | None = None):
"""Añade una ley. Si el pergamino está lleno hay que derogar una
(repeal_index). Lanza ValueError si no se puede."""
if not self.can_add():
if repeal_index is None or not (0 <= repeal_index < len(self.laws)):
raise ValueError(
f"pergamino lleno ({MAX_LEYES} leyes): hay que derogar una"
)
self.laws.pop(repeal_index)
self.laws.append(law)
def repeal(self, index: int) -> Law:
return self.laws.pop(index)
@property
def effects(self) -> list[dict]:
result = []
for law in self.laws:
result.extend(law.effects)
return result
# ---------------------------------------------------------------------------
# Estado del juego y puntuación
# ---------------------------------------------------------------------------
class GameState:
"""Todo el estado de la partida, separado de la I/O (clave para Gradio)."""
def __init__(self, rng: random.Random | None = None,
max_rounds: int = MAX_ROUNDS, difficulty: str = "normal",
board_size: int = 3, starting_laws: bool = True,
board: list[list[int]] | None = None):
self.rng = rng or random.Random()
self.max_rounds = max_rounds # largo del combate (campaña: por enemigo)
self.difficulty = difficulty # "easy" | "normal" | "hard"
self.board_size = board_size # lado del tablero (3 Lesky / 4 monstruo)
self.fichas_por_ronda = board_size # 3 fichas en 3x3, 4 en 4x4
self.scroll = Scroll()
self.round_wins = {RED: 0, BLUE: 0}
self.astucia = {RED: 0, BLUE: 0}
self.total_astucia_earned = {RED: 0, BLUE: 0}
self.total_points = {RED: 0.0, BLUE: 0.0} # acumulado a lo largo de la partida
self.round_history: list[dict] = [] # [{round, scores, winner, board_name}]
self.round_num = 0
self.occupancy = None
_presets, _names = presets_for(board_size)
self.board = _presets[0]
self.board_name = _names[0]
self.revealed: set[tuple[int, int]] = set()
self.objectives: dict[str, SecretObjective] = assign_objectives(self.rng)
self.obj_astucia_earned: dict[str, int] = {RED: 0, BLUE: 0} # astucia acumulada por objetivos
self.player_name = "Player"
self.scouted_opponent = False
self.lie_catches = 0 # # of times Lesky caught lying; reduces accuracy in last 2 rounds
if starting_laws:
self._enact_starting_laws()
self.start_round(board=board)
def draw_new_objective(self, owner: str):
"""Draws a new secret objective for the player from the unused pool."""
current_keys = {self.objectives[p].key for p in (RED, BLUE) if self.objectives.get(p)}
pool = [obj for obj in ALL_OBJECTIVES if obj.key not in current_keys]
if pool:
self.objectives[owner] = self.rng.choice(pool)
else:
self.objectives[owner] = None
def commit_round_scores(self, scores: dict) -> str | None:
"""Registra las puntuaciones de una ronda acabada, acumula totales
y otorga astucia por objetivos secretos esta ronda."""
winner = None
if scores[RED] > scores[BLUE]:
winner = RED
self.round_wins[RED] += 1
elif scores[BLUE] > scores[RED]:
winner = BLUE
self.round_wins[BLUE] += 1
self.total_points[RED] += scores[RED]
self.total_points[BLUE] += scores[BLUE]
# Evaluar objetivos esta ronda y acumular astucia
obj_this_round = {}
for owner in (RED, BLUE):
obj = self.objectives.get(owner)
if obj:
count, why = obj.check(self, owner)
if count > 0:
self.astucia[owner] += count
self.total_astucia_earned[owner] += count
self.obj_astucia_earned[owner] += count
self.draw_new_objective(owner)
obj_this_round[owner] = (count, why)
self.round_history.append({
"round": self.round_num,
"board": self.board_name,
"scores": dict(scores),
"winner": winner,
"obj": obj_this_round,
})
return winner
@property
def is_game_over(self) -> bool:
return self.round_num >= self.max_rounds
def final_winner(self) -> str | None:
"""Ganador de la partida por puntos totales + bonus de astucia (1 🦊 = +3 puntos)."""
r_pts = self.total_points[RED] + self.total_astucia_earned[RED] * 3
b_pts = self.total_points[BLUE] + self.total_astucia_earned[BLUE] * 3
if r_pts > b_pts:
return RED
if b_pts > r_pts:
return BLUE
# Desempate por monedero de astucia actual
if self.astucia[RED] > self.astucia[BLUE]:
return RED
if self.astucia[BLUE] > self.astucia[RED]:
return BLUE
return None # empate total
# ----- leyes iniciales del bosque -----
# Pool de posibles leyes iniciales del bosque (neutrales, sin dueño único).
# Se eligen 2 distintas al azar en cada partida.
_STARTING_LAW_POOL = [
({"type": "bonus_adjacency", "owner": "any", "amount": 1},
"Neighbors thrive together: +1 per pair of adjacent chips"),
({"type": "add_value", "zone": "corners", "amount": 1},
"Glade of Wisdom: corner cells +1"),
({"type": "add_value", "zone": "center", "amount": 1},
"The heart of the forest pulses with power: center cells +1"),
({"type": "penalty_zone", "zone": "corners", "owner": "any", "amount": -1},
"Ancient curse on the corners: -1 point for every chip placed there"),
({"type": "add_value", "zone": "edges", "amount": 1},
"The forest paths are fertile: edge cells +1"),
({"type": "multiply_value", "zone": "corners", "factor": 2},
"The old oaks stand tall: corner cells are worth double"),
({"type": "multiply_value", "zone": "center", "factor": 2},
"Echoes of the Heart: center cell value is doubled"),
({"type": "bonus_adjacency", "owner": "any", "amount": -1},
"The trees demand space: -1 for each pair of adjacent chips"),
({"type": "add_value", "zone": "edges", "amount": -1},
"The border is treacherous: edge cells -1"),
({"type": "set_value", "zone": "center", "value": 0},
"The heart of the forest is hollow: center cell is worth nothing"),
({"type": "penalty_zone", "zone": "edges", "owner": "any", "amount": -1},
"The forest's edge is dangerous: -1 for chips on the border"),
]
FOREST = "forest" # autor ficticio para las leyes del bosque
def _enact_starting_laws(self):
"""Elige 2 leyes iniciales del pool y las añade al pergamino sin autor jugador."""
chosen = self.rng.sample(self._STARTING_LAW_POOL, 2)
for effect, announcement in chosen:
law = Law(
[normalize_effect(effect, self.FOREST)],
author=self.FOREST,
announcement=announcement,
)
self.scroll.laws.append(law)
# ----- ronda -----
def start_round(self, board: list[list[int]] | None = None):
self.round_num += 1
n = self.board_size
# None = libre; RED/BLUE = ocupada; DISPUTED/SPLIT = choque resuelto
self.occupancy = [[None] * n for _ in range(n)]
self.revealed = set()
presets, names = presets_for(n) # pool del lado n (3x3 o 4x4)
if board is not None:
self.board = board
self.board_name = names[0]
else:
# Nuevo tablero aleatorio cada ronda
idx = self.rng.randrange(len(presets))
self.board = presets[idx]
self.board_name = names[idx]
# ----- zonas y líneas (según el lado del tablero) -----
def zone_cells(self, zone: str) -> set:
"""Casillas de una zona en ESTE tablero. set() si la zona no aplica."""
return zone_to_cells(zone, self.board_size) or set()
def lines(self) -> list[list[tuple[int, int]]]:
"""Todas las líneas ganadoras (filas, columnas, 2 diagonales) de lado n."""
n = self.board_size
rows = [[(r, c) for c in range(n)] for r in range(n)]
cols = [[(r, c) for r in range(n)] for c in range(n)]
diag1 = [(i, i) for i in range(n)]
diag2 = [(i, n - 1 - i) for i in range(n)]
return rows + cols + [diag1, diag2]
def free_cells(self) -> list[tuple[int, int]]:
n = self.board_size
return [(r, c) for r in range(n) for c in range(n)
if self.occupancy[r][c] is None]
# ----- leyes activas -----
def clash_mode(self) -> str:
"""Modo de choque vigente: la última ley clash_resolution manda."""
mode = "disputed"
for eff in self.scroll.effects:
if eff["type"] == "clash_resolution":
mode = eff["mode"]
return mode
def is_forbidden(self, owner: str, r: int, c: int) -> bool:
"""¿Alguna ley prohíbe a `owner` colocar en (r, c)?"""
for eff in self.scroll.effects:
if eff["type"] != "forbid_placement":
continue
if eff["owner"] not in (owner, "any"):
continue
if (r, c) in self.zone_cells(eff["zone"]):
return True
return False
def legal_cells(self, owner: str) -> list[tuple[int, int]]:
return [(r, c) for (r, c) in self.free_cells()
if not self.is_forbidden(owner, r, c)]
# ----- colocación simultánea -----
def place_both(self, red_cell: tuple[int, int], blue_cell: tuple[int, int] | None):
"""Coloca las fichas elegidas a la vez. Si chocan, se resuelve según
el modo de choque vigente. `blue_cell=None` = el duende pasa (sin jugada
legal): solo se coloca la roja."""
cells = [(RED, red_cell)] + ([(BLUE, blue_cell)] if blue_cell is not None else [])
for owner, cell in cells:
r, c = cell
if self.occupancy[r][c] is not None:
raise ValueError(f"casilla ocupada: {cell}")
if self.is_forbidden(owner, r, c):
raise ValueError(f"colocación prohibida para {owner}: {cell}")
if blue_cell is not None and red_cell == blue_cell:
r, c = red_cell
mode = self.clash_mode()
resolved = {
"disputed": DISPUTED,
"red_wins": RED,
"blue_wins": BLUE,
"split": SPLIT,
}[mode]
self.occupancy[r][c] = resolved
else:
self.occupancy[red_cell[0]][red_cell[1]] = RED
if blue_cell is not None:
self.occupancy[blue_cell[0]][blue_cell[1]] = BLUE
self.revealed.add(red_cell)
if blue_cell is not None:
self.revealed.add(blue_cell)
# ----- valores y puntuación -----
def effective_value(self, r: int, c: int) -> float:
"""Valor de una casilla tras aplicar las leyes de valor EN ORDEN."""
value = float(self.board[r][c])
for eff in self.scroll.effects:
if eff["type"] not in ("set_value", "multiply_value", "add_value"):
continue
if (r, c) not in self.zone_cells(eff["zone"]):
continue
if eff["type"] == "set_value":
value = float(eff["value"])
elif eff["type"] == "multiply_value":
value *= eff["factor"]
elif eff["type"] == "add_value":
value += eff["amount"]
return value
def perceived_value(self, r: int, c: int) -> float:
"""Valor de una casilla percibido por el duende (según la ronda y el Fog of War)."""
knows_real = False
if (r, c) in self.revealed:
knows_real = True
elif self.round_num >= 6 or self.difficulty == "hard":
knows_real = True
elif self.round_num >= 3 and (r, c) in self.zone_cells("center"):
knows_real = True
base = float(self.board[r][c]) if knows_real else 2.0
value = base
for eff in self.scroll.effects:
if eff["type"] not in ("set_value", "multiply_value", "add_value"):
continue
if (r, c) not in self.zone_cells(eff["zone"]):
continue
if eff["type"] == "set_value":
value = float(eff["value"])
elif eff["type"] == "multiply_value":
value *= eff["factor"]
elif eff["type"] == "add_value":
value += eff["amount"]
return value
def perceived_payoff(self, r: int, c: int) -> float:
"""Calcula el payoff total percibido por Lesky para colocar en (r, c)."""
# 1) Valor de la casilla
val = self.perceived_value(r, c)
# 2) Scroll Laws: Penalty Zone
for eff in self.scroll.effects:
if eff["type"] == "penalty_zone" and eff["owner"] in (BLUE, "any"):
if (r, c) in self.zone_cells(eff["zone"]):
val += float(eff["amount"])
# 3) Scroll Laws: Adjacency Bonus
n = self.board_size
for eff in self.scroll.effects:
if eff["type"] == "bonus_adjacency" and eff["owner"] in (BLUE, "any"):
adjacent_blue = 0
for dr, dc in ((0, 1), (0, -1), (1, 0), (-1, 0)):
nr, nc = r + dr, c + dc
if 0 <= nr < n and 0 <= nc < n:
if self.occupancy[nr][nc] == BLUE:
adjacent_blue += 1
val += float(eff["amount"]) * adjacent_blue
# 4) Secret Objective Weighting
obj = self.objectives.get(BLUE)
if obj:
if obj.key == "no_center" and (r, c) in self.zone_cells("center"):
val -= 5.0
elif obj.key == "corner_control" and (r, c) in self.zone_cells("corners"):
val += 1.5
elif obj.key == "edge_control" and (r, c) in self.zone_cells("edges"):
val += 1.5
elif obj.key == "no_adjacency":
n = self.board_size
adjacent_own = 0
for dr, dc in ((0, 1), (0, -1), (1, 0), (-1, 0)):
nr, nc = r + dr, c + dc
if 0 <= nr < n and 0 <= nc < n:
if self.occupancy[nr][nc] == BLUE:
adjacent_own += 1
if adjacent_own > 0:
val -= 3.0
elif obj.key == "high_value" and self.perceived_value(r, c) >= 3.0:
val += 1.0
elif obj.key == "adjacent_triple":
for line in self.lines():
if (r, c) in line:
others = [cell for cell in line if cell != (r, c)]
blue_count = sum(1 for nr, nc in others if self.occupancy[nr][nc] == BLUE)
red_count = sum(1 for nr, nc in others if self.occupancy[nr][nc] == RED)
need = len(line) - 1
if blue_count == need:
val += 3.0 # Completing a full line
elif blue_count >= 1 and red_count == 0:
val += 1.0 # Building towards a line
return val
def score_round(self) -> dict:
"""Puntúa la ronda actual con todas las leyes activas."""
scores = {RED: 0.0, BLUE: 0.0}
n = self.board_size
# 1) Valor de las casillas ocupadas
for r in range(n):
for c in range(n):
occ = self.occupancy[r][c]
if occ in (RED, BLUE):
scores[occ] += self.effective_value(r, c)
elif occ == SPLIT:
half = self.effective_value(r, c) / 2
scores[RED] += half
scores[BLUE] += half
# DISPUTED: nadie puntúa
# 2) Penalizaciones/bonus por zona
for eff in self.scroll.effects:
if eff["type"] != "penalty_zone":
continue
for (r, c) in self.zone_cells(eff["zone"]):
occ = self.occupancy[r][c]
if occ in (RED, BLUE) and eff["owner"] in (occ, "any"):
scores[occ] += eff["amount"]
# 3) Bonus por adyacencia (pares ortogonales del mismo dueño)
for eff in self.scroll.effects:
if eff["type"] != "bonus_adjacency":
continue
targets = [RED, BLUE] if eff["owner"] == "any" else [eff["owner"]]
for owner in targets:
pairs = self._adjacent_pairs(owner)
scores[owner] += eff["amount"] * pairs
return scores
def _adjacent_pairs(self, owner: str) -> int:
"""Cuenta pares de casillas ortogonalmente adyacentes de `owner`."""
pairs = 0
n = self.board_size
for r in range(n):
for c in range(n):
if self.occupancy[r][c] != owner:
continue
for dr, dc in ((0, 1), (1, 0)):
nr, nc = r + dr, c + dc
if nr < n and nc < n and self.occupancy[nr][nc] == owner:
pairs += 1
return pairs
def check_objectives(self) -> dict[str, tuple[int, str]]:
"""Evalúa los objetivos en el estado actual. Devuelve {owner: (count, why)}."""
return {
owner: obj.check(self, owner)
for owner, obj in self.objectives.items()
}
def award_objectives(self) -> dict[str, tuple[int, str, int]]:
"""Devuelve el resumen final de objetivos: {owner: (last_count, why, total_earned)}.
La astucia ya fue otorgada ronda a ronda en commit_round_scores."""
result = {}
for owner, obj in self.objectives.items():
count, why = obj.check(self, owner)
result[owner] = (count, why, self.obj_astucia_earned[owner])
return result
# ----- legislar -----
def enact(self, effect, author: str, announcement: str = "",
announced_effect=None,
repeal_index: int | None = None) -> Law:
"""Valida, normaliza y añade una ley al pergamino.
`effect` puede ser un dict (efecto único) o una lista de dicts
(ley de doble filo: beneficio + penalización).
Lanza ValueError si algún efecto es inválido o el pergamino está lleno.
"""
# Normalizar a lista
raw_effects = effect if isinstance(effect, list) else [effect]
real_effects = []
for e in raw_effects:
ok, reason = validate_effect(e)
if not ok:
raise ValueError(f"efecto inválido: {reason}")
real_effects.append(normalize_effect(e, author))
# announced_effects
if announced_effect is not None:
raw_announced = announced_effect if isinstance(announced_effect, list) else [announced_effect]
announced_effects = []
for ae in raw_announced:
ok2, _ = validate_effect(ae)
announced_effects.append(normalize_effect(ae, author) if ok2 else ae)
else:
announced_effects = None
# Auto-repeal conflicting effects before adding:
# clash_resolution → only one mode globally
# multiply_value / set_value + same zone → last one wins, repeal old
# forbid_placement + same zone + same owner → redundant, repeal old
def _conflicts(existing_e: dict, new_e: dict) -> bool:
t = new_e.get("type")
if t != existing_e.get("type"):
return False
if t == "clash_resolution":
return True
if t in ("multiply_value", "set_value"):
return new_e.get("zone") == existing_e.get("zone")
if t == "forbid_placement":
return (new_e.get("zone") == existing_e.get("zone")
and new_e.get("owner") == existing_e.get("owner"))
return False
for new_e in real_effects:
to_remove = [
idx for idx, existing_law in enumerate(self.scroll.laws)
if any(_conflicts(e, new_e) for e in existing_law.effects)
]
for idx in reversed(to_remove):
self.scroll.laws.pop(idx)
law = Law(real_effects, author, announcement, announced_effects)
self.scroll.add(law, repeal_index)
return law
# ---------------------------------------------------------------------------
# Rival simple (FASE 2): mini-algoritmo, NUNCA el LLM
# ---------------------------------------------------------------------------
def goblin_choose(state: GameState, rng: random.Random | None = None):
"""El duende elige casilla con precisión progresiva según la ronda actual."""
rng = rng or state.rng
legal = state.legal_cells(BLUE)
if not legal:
return None
# Shuffle to break ties randomly and avoid stable-sort top-left bias
rng.shuffle(legal)
ranked = sorted(legal, key=lambda rc: state.perceived_payoff(*rc), reverse=True)
# Cazas de mentira contra Lesky: se arrastran y bajan la precisión del Monstruo.
# Se define ANTES de las ramas (la rama "hard" la usa).
lie_catches = getattr(state, 'lie_catches', 0)
# Dificultad por enemigo (campaña): precisión plana. "normal" mantiene la
# curva por ronda original (lo que usan game.py / app_gradio.py).
diff = getattr(state, "difficulty", "normal")
if diff == "easy":
if rng.random() < 0.45:
return ranked[0]
return rng.choice(ranked)
if diff == "hard":
# Lie catches against Lesky carry over and reduce Monster accuracy by 7% each
accuracy = max(0.0, 0.95 - lie_catches * 0.07)
if rng.random() < accuracy or len(ranked) < 2:
return ranked[0]
return ranked[1]
round_num = state.round_num
if round_num <= 2:
# FASE FÁCIL: 40% mejor, 60% completamente al azar entre todas las legales
if rng.random() < 0.40:
return ranked[0]
return rng.choice(ranked)
elif round_num <= 5:
# FASE MEDIA: 70% mejor, 30% al azar entre las 3 mejores
if rng.random() < 0.70:
return ranked[0]
top = ranked[:3]
return rng.choice(top)
else:
# FASE DIFÍCIL: 95% mejor, 5% segunda mejor (si hay)
if rng.random() < 0.95 or len(ranked) < 2:
return ranked[0]
return ranked[1]