| """
|
| 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
|
|
|
|
|
|
|
|
|
|
|
| BASE_BOARD = [
|
| [2, 1, 2],
|
| [1, 3, 1],
|
| [2, 1, 2],
|
| ]
|
|
|
| RED = "red"
|
| BLUE = "blue"
|
| DISPUTED = "disputed"
|
| SPLIT = "split"
|
|
|
| FICHAS_POR_RONDA = 3
|
| MAX_LEYES = 3
|
| MAX_ROUNDS = 7
|
| MAX_BOARD = 4
|
|
|
|
|
|
|
|
|
|
|
|
|
| BOARD_PRESETS = [
|
|
|
| [[2, 1, 2], [1, 3, 1], [2, 1, 2]],
|
|
|
| [[3, 1, 1], [1, 3, 1], [1, 1, 3]],
|
|
|
| [[1, 3, 1], [3, 1, 2], [1, 3, 1]],
|
|
|
| [[4, 1, 4], [1, 1, 1], [4, 1, 1]],
|
|
|
| [[3, 3, 3], [2, 1, 2], [0, 1, 0]],
|
|
|
| [[1, 4, 1], [1, 4, 1], [1, 4, 1]],
|
|
|
| [[2, 2, 2], [2, 3, 2], [2, 2, 2]],
|
|
|
| [[2, 2, 2], [2, 0, 1], [2, 2, 2]],
|
|
|
| [[1, 4, 1], [4, 0, 3], [1, 4, 1]],
|
|
|
| [[3, 0, 3], [0, 5, 0], [3, 0, 2]],
|
|
|
| [[0, 0, 5], [1, 2, 1], [0, 1, 0]],
|
|
|
| [[5, 1, 0], [4, 1, 0], [4, 2, 0]],
|
|
|
| [[4, 1, 2], [0, 3, 1], [2, 1, 3]],
|
|
|
| [[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",
|
| ]
|
|
|
|
|
| BOARD_PRESETS_4 = [
|
| [[1, 1, 1, 1], [1, 3, 3, 1], [1, 3, 1, 1], [1, 1, 1, 1]],
|
| [[3, 1, 1, 1], [1, 3, 1, 1], [1, 1, 3, 1], [1, 1, 1, 1]],
|
| [[3, 1, 1, 3], [1, 1, 1, 1], [1, 1, 1, 1], [3, 1, 1, 1]],
|
| [[3, 3, 3, 1], [1, 1, 1, 1], [1, 2, 2, 1], [1, 1, 1, 1]],
|
| [[1, 3, 3, 1], [3, 1, 1, 1], [1, 1, 1, 3], [1, 3, 1, 1]],
|
| [[1, 3, 1, 1], [1, 3, 1, 1], [1, 3, 1, 2], [1, 1, 1, 1]],
|
| ]
|
|
|
| BOARD_NAMES_4 = [
|
| "The Sunken Vault",
|
| "The Iron Diagonal",
|
| "The Four Pillars",
|
| "The High Wall",
|
| "The Broken Ring",
|
| "The Monolith",
|
| ]
|
|
|
|
|
|
|
| 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])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| self.hint = hint
|
|
|
| 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]
|
|
|
|
|
|
|
| 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
|
| return None
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
| 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"
|
|
|
|
|
| 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
|
| elif owner == "yellow":
|
| owner = BLUE
|
| elif owner == "red":
|
| owner = RED
|
| 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):
|
|
|
| self.effects = effects if isinstance(effects, list) else [effects]
|
|
|
| self.effect = self.effects[0] if self.effects else {}
|
| self.author = author
|
| self.announcement = announcement
|
| self.announced_effects = announced_effects if announced_effects is not None else list(self.effects)
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| self.difficulty = difficulty
|
| self.board_size = board_size
|
| self.fichas_por_ronda = board_size
|
| 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}
|
| self.round_history: list[dict] = []
|
| 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}
|
| self.player_name = "Player"
|
| self.scouted_opponent = False
|
| self.lie_catches = 0
|
| 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]
|
|
|
|
|
| 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
|
|
|
| if self.astucia[RED] > self.astucia[BLUE]:
|
| return RED
|
| if self.astucia[BLUE] > self.astucia[RED]:
|
| return BLUE
|
| return None
|
|
|
|
|
|
|
|
|
|
|
| _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"
|
|
|
| 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)
|
|
|
|
|
|
|
| def start_round(self, board: list[list[int]] | None = None):
|
| self.round_num += 1
|
| n = self.board_size
|
|
|
| self.occupancy = [[None] * n for _ in range(n)]
|
| self.revealed = set()
|
| presets, names = presets_for(n)
|
| if board is not None:
|
| self.board = board
|
| self.board_name = names[0]
|
| else:
|
|
|
| idx = self.rng.randrange(len(presets))
|
| self.board = presets[idx]
|
| self.board_name = names[idx]
|
|
|
|
|
|
|
| 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]
|
|
|
|
|
|
|
| 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)]
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
| 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)."""
|
|
|
| val = self.perceived_value(r, c)
|
|
|
|
|
| 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"])
|
|
|
|
|
| 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
|
|
|
|
|
| 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
|
| elif blue_count >= 1 and red_count == 0:
|
| val += 1.0
|
| 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
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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"]
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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.
|
| """
|
|
|
| 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))
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
| rng.shuffle(legal)
|
| ranked = sorted(legal, key=lambda rc: state.perceived_payoff(*rc), reverse=True)
|
|
|
|
|
|
|
| lie_catches = getattr(state, 'lie_catches', 0)
|
|
|
|
|
|
|
| diff = getattr(state, "difficulty", "normal")
|
| if diff == "easy":
|
| if rng.random() < 0.45:
|
| return ranked[0]
|
| return rng.choice(ranked)
|
| if diff == "hard":
|
|
|
| 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:
|
|
|
| if rng.random() < 0.40:
|
| return ranked[0]
|
| return rng.choice(ranked)
|
| elif round_num <= 5:
|
|
|
| if rng.random() < 0.70:
|
| return ranked[0]
|
| top = ranked[:3]
|
| return rng.choice(top)
|
| else:
|
|
|
| if rng.random() < 0.95 or len(ranked) < 2:
|
| return ranked[0]
|
| return ranked[1]
|
|
|