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