Leskys_Shop / ui /state.py
siigrid's picture
final version
9993711
Raw
History Blame Contribute Delete
17.2 kB
"""
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 "<br>".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"<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):
# 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, <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() # 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()