""" ui.state — ViewModel: pega game.Session con las pantallas. Es lo que vive en el gr.State. Tiene estado SOLO de UI (pantalla activa, crónica) y la PRESENTACIÓN (emojis de casilla, arte del rival). El flujo de pantallas (qué pantalla toca tras una ronda) se decide aquí a partir del estado del juego; las reglas viven en game.Session. """ from __future__ import annotations import copy from game import Session, RED, BLUE, DISPUTED, SPLIT from ui.assets import ART, COIN_PLAYER # Colores de ficha consistentes con el juego: jugador = azul, rival según quién sea # (Lesky = amarillo, monstruo = verde). El emoji se usa en la crónica/chat. PLAYER_EMOJI = "🔵" OPP_EMOJI = {"lesky": "🟡", "monstruo": "🟢"} def bump(vm: "ViewModel") -> "ViewModel": """Devuelve una copia superficial (id nuevo). Necesario porque @gr.render solo re-renderiza si el valor del gr.State cambia de identidad; mutar y devolver el mismo objeto NO dispara el re-render. Comparte session/log (mutaciones visibles). Incrementa vm.nav (contador de renders COMPARTIDO entre copias: es una lista, que el copy.copy comparte por referencia). Un handler que sintetiza voz tarda segundos; antes de su re-render TARDÍO mira este contador: si avanzó (otro handler navegó/ pintó mientras tanto), NO re-pinta -> no te devuelve a la pantalla vieja.""" vm.nav[0] += 1 return copy.copy(vm) class ViewModel: def __init__(self): self.session = Session() self.screen = "title" self.player_name = "Player" self.log: list[str] = [] self.chat_messages: list[tuple[str, str]] = [] # [(speaker, text), ...] self.round_taunt = "" self.accuse_result = "" # shown on round_over after an accusation self.accuse_caught = False # True when last accusation was correct self.reveal_step = 0 # paso de la secuencia de revelación de gnomos self.voice_url = "" # voz del enemigo a reproducir (url servible) — ui.tts self.voice_seq = 0 # +1 por frase -> el navegador la reproduce una sola vez self.pending_speech = "" # texto a sintetizar TRAS pintar la pantalla (no bloquea) self.nav = [0] # contador de renders COMPARTIDO (lista) — ver bump() self.voice_loading = False # True mientras se sintetiza voz -> bloquea botones de avanzar # ----- arte del rival actual ----- @property def art(self) -> dict: return ART[self.session.opponent.key] # ----- color/ficha del jugador y del rival ----- @property def player_emoji(self) -> str: return PLAYER_EMOJI @property def opp_emoji(self) -> str: return OPP_EMOJI.get(self.session.opponent.key, "🟡") @property def player_coin(self) -> str: return COIN_PLAYER @property def opp_coin(self) -> str: return self.art["coin"] # ----- crónica ----- def say(self, msg: str): self.log.append(msg) def chronicle(self, n: int = 6) -> str: return "
".join(self.log[-n:]) # ----- presentación de casilla (dato del juego -> texto/ficha) ----- def cell_label(self, r: int, c: int) -> str: """Texto del botón. Para casillas con dueño la FICHA (png) la pinta el CSS como fondo (ver cell_coin_class); aquí solo el valor numérico encima.""" s = self.session owner = s.cell_owner(r, c) val = f"{s.cell_value(r, c):g}" if owner in (RED, BLUE): return val # la moneda va de fondo if owner == DISPUTED: return f"⚔️ {val}" if owner == SPLIT: return f"➗ {val}" shown = val if s.cell_revealed(r, c) else "?" if s.cell_locked(r, c): return f"🚫 {shown}" return shown def cell_coin_class(self, r: int, c: int) -> str: """Clase CSS que pone la moneda de fondo: jugador (azul) o rival (su color).""" owner = self.session.cell_owner(r, c) if owner == RED: return "coin-player" if owner == BLUE: return "coin-opp" return "" def cell_clickable(self, r: int, c: int) -> bool: s = self.session return (self.screen == "board" and not s.round_over and s.chip_index < s.chips_per_round and s.cell_owner(r, c) is None and not s.cell_locked(r, c)) # ----- flujo de pantallas ----- def start(self, name: str): """'Listo': guarda el nombre y va a la tienda del duende (no al tablero).""" self.player_name = (name or "").strip() or "Player" self.session.set_player_name(self.player_name) self.screen = "shop" self.say(f"🌲 Welcome, {self.player_name}!") self.pending_speech = self.briefing_speech() # Lesky lee el briefing self.voice_loading = True # bloquea avanzar hasta tener la voz def to_board(self): self.screen = "board" async def on_cell_chips(self, r: int, c: int): if self.screen != "board" or self.session.round_over: return ev = await self.session.place_async(r, c) if not ev.get("ok"): return if ev["clash"]: self.say(f"⚡ Both at row {r+1} col {c+1}") elif ev["blue"] is None: self.say(f"{self.player_emoji} {self.player_name}: r{r+1}c{c+1} · " f"{self.opp_emoji} {self.session.opponent.name} skips (no legal cell)") else: b = ev["blue"] self.say(f"{self.player_emoji} {self.player_name}: r{r+1}c{c+1} · " f"{self.opp_emoji} {self.session.opponent.name}: r{b[0]+1}c{b[1]+1}") async def on_cell_finalize(self): import asyncio s = self.session sc = s.last_round_scores # committeados self.say(f"— Round {s.round_num} end: {self.player_emoji} {sc[RED]:g} · " f"{self.opp_emoji} {sc[BLUE]:g}") # Ejecutamos las llamadas al LLM (pulla + legislación) en paralelo tasks = [] async def fetch_taunt(): self.round_taunt = await s.round_over_taunt_async() tasks.append(fetch_taunt()) if not s.match_over and s.winner == BLUE: tasks.append(s.goblin_legislate_async()) await asyncio.gather(*tasks) announcement = "" if s.gs.scroll.laws: last_law = s.gs.scroll.laws[-1] if last_law.author == BLUE: announcement = last_law.announcement self.say(f"{self.opp_emoji} {s.opponent.name}: «{announcement}»") # Voz del enemigo: pulla + (si legisló) el anuncio de su ley, en una sola pista. # NO se sintetiza aquí (bloquearía el cambio de pantalla): se deja pendiente y # el handler la lanza tras pintar la pantalla. Ver board._cell. self.pending_speech = " ... ".join( p for p in (self.round_taunt, announcement) if p).strip() self.voice_loading = bool(self.pending_speech) # bloquea avanzar hasta tener la voz # Misión secreta completada (objetivo cumplido esta ronda) history = s.gs.round_history if history: last_round = history[-1] obj_data = last_round.get("obj", {}) if RED in obj_data and obj_data[RED][0] > 0: self.say(f"🎉 Secret Mission Completed! Gained +{obj_data[RED][0]} Cleverness!") if s.secret_mission: self.say(f"🎯 New Secret Mission: {s.secret_mission}") if s.match_over: self.screen = "enemy_clear" if s.player_won_match() else "gameover" else: self.screen = "round_over" def legislate(self, choice_index: int, repeal_index: int | None = None): self.session.legislate(choice_index, repeal_index) self.next_round() async def legislate_free(self, phrase: str, repeal_index: int | None = None) -> bool: ok, error, narration = await self.session.legislate_free_async(phrase, repeal_index) if not ok: self.say(f"❌ {error}") return False self.say(f"📜 Law enacted: «{narration}»") self.next_round() return True def accuse(self, law_index: int): caught, msg = self.session.accuse(law_index) self.accuse_result = msg self.accuse_caught = caught self.say(f"⚖️ {msg}") def next_round(self): self.accuse_result = "" self.accuse_caught = False self.session.next_round() self.screen = "board" self.say(f"— Round {self.session.round_num}/{self.session.max_rounds} · " f"{self.session.board_name}") def buy_scout(self) -> bool: opp_name = self.session.opponent.name if self.session.buy_scout(): self.say(f"🦊 Scouted {opp_name}'s secret mission!") return True self.say(f"❌ Failed to scout: not enough cleverness.") return False def buy_reveal_center(self) -> bool: if self.session.buy_reveal_center(): self.say(f"🦊 Revealed center value: {self.session.cell_value(1, 1)}") return True self.say(f"❌ Failed to reveal: not enough cleverness or center already revealed.") return False def buy_repeal_law(self, choice_text: str) -> bool: if not choice_text: return False try: parts = choice_text.split(".") idx = int(parts[0].strip()) - 1 if self.session.buy_repeal_law(idx): self.say(f"🦊 Repealed Law {idx + 1} from Scroll!") return True except Exception: pass self.say(f"❌ Failed to repeal: not enough cleverness or invalid law selection.") return False async def send_chat(self, message: str): message = message.strip() if not message: return self.chat_messages.append(("You", message)) reply = await self.session.chat_async(message) self.chat_messages.append((self.session.opponent.name, reply)) await self.speak(reply) # el enemigo responde con voz def chat_html(self, n: int = 8) -> str: recent = self.chat_messages[-n:] parts = [] for speaker, text in recent: cls = "chat-player" if speaker == "You" else "chat-lesky" parts.append(f"
{speaker}: {text}
") return "".join(parts) or "
Say something…
" def claim_treasure(self): # Hay otro rival -> presentación del jefe (boss_intro) antes de su tablero. # Si no, acabaste con el gigante: revélalo (4 gnomos) antes de la final. if self.session.next_opponent(): self.screen = "boss_intro" self.pending_speech = self.briefing_speech() # el jefe lee su briefing self.voice_loading = True else: self.reveal_step = 0 # arranca la secuencia de revelación self.screen = "monster_reveal" self.voice_loading = True # 1ª línea de gnomos def advance_reveal(self): self.reveal_step += 1 # ----- voz de los personajes (Chatterbox/Modal) ----- @staticmethod def _clean_speech(text: str) -> str: """Limpia para el TTS: quita HTML y normaliza espacios. NO recorta -> se lee TODO el texto entero (el estándar aguanta frases largas sin gibberish).""" import re t = re.sub(r"<[^>]+>", " ", text or "") return re.sub(r"\s+", " ", t).strip() async def speak(self, text: str, voice: str | None = None): """Sintetiza una voz y la marca para que el navegador la reproduzca (bajando la música). `voice` = clave de voz (lesky/monstruo/gnomos); por defecto el rival actual. Si el TTS falla, no pasa nada: sin voz, el juego sigue.""" from ui.tts import synthesize_async text = self._clean_speech(text) if not text: return url = await synthesize_async(text, voice or self.session.opponent.key) if url: self.voice_url = url self.voice_seq += 1 async def speak_guarded(self, text: str, voice: str | None = None) -> bool: """Como speak(), pero dice si el re-render POSTERIOR es seguro: True solo si nadie ha renderizado/navegado durante la síntesis (mismo contador nav). Evita que un re-pintado tardío te devuelva a una pantalla que ya dejaste atrás. Al terminar baja voice_loading -> re-habilita los botones de avanzar.""" token = self.nav[0] await self.speak(text, voice) self.voice_loading = False return self.nav[0] == token async def speak_pending(self) -> bool: """Sintetiza la frase pendiente (puesta al cambiar de pantalla) TRAS pintar. Devuelve True solo si el re-render es seguro (nadie navegó mientras tanto).""" if not self.pending_speech: self.voice_loading = False return False text, self.pending_speech = self.pending_speech, "" return await self.speak_guarded(text) def shop_briefing_html(self) -> str: """HTML del briefing del shop = resumen DETALLADO del juego + leyes + misión. FUENTE ÚNICA: shop.py lo pinta y briefing_speech() lo lee (sin HTML), así el pergamino y la voz dicen LO MISMO.""" s = self.session n, chips = s.grid_size, s.chips_per_round parts = [ f"Welcome to my shop, {self.player_name}! Hehe. Let me explain, then we duel.", (f"🎲 The board
A {n}×{n} grid. Every cell has a value — the " f"center is worth more, the edges less — and it reshuffles each round."), (f"🪙 Each round
We each place {chips} chips, one per turn, at the " f"same time (you don't see my pick). Higher value owns the cell; most " f"points wins the round. Same cell = a clash ⚔️ — nobody scores."), ("📜 Make a law
Win a round and you write a law in plain words " "(corners are worth double). It bends the board for both of us. " "The Scroll holds up to 3 laws — a 4th means repealing one."), ("🤥 I cheat
When I win I legislate too… and I may lie: announce one " "law, enact another. Accuse me after any round — each lie you expose makes " "whatever awaits you beyond this glade 7% less precise. Wrong guess costs you " "Cleverness 🦊 though."), ("🦊 Cleverness
Earned from secret missions. Spend it in the shop between " "matches for perks (scouting objectives, revealing cell values, or repealing scroll " "laws), or save it — each point earned adds +3 bonus points to your final score!"), ] laws = s.starting_laws() if laws: items = "".join(f"
  • {law}
  • " for law in laws) parts.append(f"📜 Active Forest Laws") if s.secret_mission: parts.append(f"🎯 Your Secret Mission
    {s.secret_mission}") return "

    ".join(parts) def briefing_speech(self) -> str: """Texto HABLADO del briefing. shop = exactamente lo del pergamino (limpiado de HTML/emojis para que el TTS lo lea bien). boss_intro = el diálogo del jefe.""" import re s = self.session if self.screen == "boss_intro": parts = [f"So, you slipped past Lesky. Impressive, for a morsel. " f"I am {s.opponent.name}, and this is my turf. The board is bigger " f"here, {s.grid_size} by {s.grid_size}. Win rounds, rewrite the law, " f"if you dare. Hehehe."] laws = s.starting_laws() if laws: parts.append("The forest laws in play. " + ". ".join(laws) + ".") if s.secret_mission: parts.append("Your secret mission: " + s.secret_mission) html = " ".join(parts) else: html = self.shop_briefing_html() # mismo contenido que el pergamino # Limpia para el TTS: tags -> espacio, × -> "by", quita emojis/símbolos raros. text = re.sub(r"<[^>]+>", " ", html) text = text.replace("×", " by ").replace("“", "").replace("”", "").replace("’", "'") text = re.sub(r"[\U0001F000-\U0001FAFF☀-➿️]", " ", text) # emojis return re.sub(r"\s+", " ", text).strip()