Spaces:
Sleeping
Sleeping
Download game/engine.py from build-small-hackathon/Leskys_Shop: direct link, hf CLI and curl.
- Browser
- Download file 45.4 kB
-
https://huggingface.co/spaces/build-small-hackathon/Leskys_Shop/resolve/main/game/engine.py
- Command line
-
hf download hf://spaces/build-small-hackathon/Leskys_Shop/game/engine.py
-
curl -L -o engine.py https://huggingface.co/spaces/build-small-hackathon/Leskys_Shop/resolve/main/game/engine.py
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 | |
| def is_dual(self) -> bool: | |
| return len(self.effects) > 1 | |
| 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) | |
| 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 | |
| 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] | |