""" 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]