| """
|
| 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
|
|
|
|
|
|
|
| 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]] = []
|
| self.round_taunt = ""
|
| self.accuse_result = ""
|
| self.accuse_caught = False
|
| self.reveal_step = 0
|
| self.voice_url = ""
|
| self.voice_seq = 0
|
| self.pending_speech = ""
|
| self.nav = [0]
|
| self.voice_loading = False
|
|
|
|
|
| @property
|
| def art(self) -> dict:
|
| return ART[self.session.opponent.key]
|
|
|
|
|
| @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"]
|
|
|
|
|
| def say(self, msg: str):
|
| self.log.append(msg)
|
|
|
| def chronicle(self, n: int = 6) -> str:
|
| return "<br>".join(self.log[-n:])
|
|
|
|
|
| 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
|
| 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))
|
|
|
|
|
| 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()
|
| self.voice_loading = True
|
|
|
| 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
|
| self.say(f"— Round {s.round_num} end: {self.player_emoji} {sc[RED]:g} · "
|
| f"{self.opp_emoji} {sc[BLUE]:g}")
|
|
|
|
|
| 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}»")
|
|
|
|
|
|
|
|
|
| self.pending_speech = " ... ".join(
|
| p for p in (self.round_taunt, announcement) if p).strip()
|
| self.voice_loading = bool(self.pending_speech)
|
|
|
|
|
| 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)
|
|
|
| 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"<div class='chat-msg {cls}'><b>{speaker}:</b> {text}</div>")
|
| return "".join(parts) or "<div class='chat-msg chat-empty'>Say something…</div>"
|
|
|
| def claim_treasure(self):
|
|
|
|
|
| if self.session.next_opponent():
|
| self.screen = "boss_intro"
|
| self.pending_speech = self.briefing_speech()
|
| self.voice_loading = True
|
| else:
|
| self.reveal_step = 0
|
| self.screen = "monster_reveal"
|
| self.voice_loading = True
|
|
|
| def advance_reveal(self):
|
| self.reveal_step += 1
|
|
|
|
|
| @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, <b>{self.player_name}</b>! Hehe. Let me explain, then we duel.",
|
| (f"<b>🎲 The board</b><br>A <b>{n}×{n}</b> grid. Every cell has a value — the "
|
| f"center is worth more, the edges less — and it <b>reshuffles each round</b>."),
|
| (f"<b>🪙 Each round</b><br>We each place <b>{chips} chips</b>, one per turn, at the "
|
| f"<b>same time</b> (you don't see my pick). Higher value <b>owns</b> the cell; most "
|
| f"points wins the round. Same cell = a <b>clash</b> ⚔️ — nobody scores."),
|
| ("<b>📜 Make a law</b><br>Win a round and you <b>write a law in plain words</b> "
|
| "(corners are worth double). It bends the board for <b>both</b> of us. "
|
| "The Scroll holds up to <b>3</b> laws — a 4th means repealing one."),
|
| ("<b>🤥 I cheat</b><br>When I win I legislate too… and I may <b>lie</b>: announce one "
|
| "law, enact another. <b>Accuse</b> me after any round — each lie you expose makes "
|
| "whatever awaits you beyond this glade <b>7% less precise</b>. Wrong guess costs you "
|
| "Cleverness 🦊 though."),
|
| ("<b>🦊 Cleverness</b><br>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 <b>+3 bonus points</b> to your final score!"),
|
| ]
|
| laws = s.starting_laws()
|
| if laws:
|
| items = "".join(f"<li>{law}</li>" for law in laws)
|
| parts.append(f"<b>📜 Active Forest Laws</b><ul>{items}</ul>")
|
| if s.secret_mission:
|
| parts.append(f"<b>🎯 Your Secret Mission</b><br>{s.secret_mission}")
|
| return "<br><br>".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()
|
|
|
| text = re.sub(r"<[^>]+>", " ", html)
|
| text = text.replace("×", " by ").replace("“", "").replace("”", "").replace("’", "'")
|
| text = re.sub(r"[\U0001F000-\U0001FAFF☀-➿️]", " ", text)
|
| return re.sub(r"\s+", " ", text).strip() |