CI / test (push) Failing after 39s
Hjernen (party/brain) skriver kommentarer til løgnene, dømmer løgne (sandheden eller upassende), personlige drillerier og en slut-anmeldelse, alt i baggrunden med faste replikker som fallback. Udbyderen vælges med BRAIN_PROVIDER: anthropic (Claude), openai (OpenAI-kompatibel, fx Codex) eller ollama (fx Gemma). Rummets mikrofon er eksperimentel: storskærmen sender korte lydstumper til talegenkendelse (STT_PROVIDER), og kun et kort resumé af latter og udsagn huskes til hjernen. Musikken er nu lavet med en musikmodel. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
164 lines
8.0 KiB
Python
164 lines
8.0 KiB
Python
"""Speakeren: oversætter spillets begivenheder til replikker fra fupogfakta/narrator/lines.json."""
|
|
|
|
import json
|
|
import random
|
|
import re
|
|
from pathlib import Path
|
|
|
|
LINES = json.loads((Path(__file__).resolve().parents[1] / "fupogfakta" / "narrator" / "lines.json")
|
|
.read_text(encoding="utf-8"))
|
|
|
|
ONES = ["nul", "én", "to", "tre", "fire", "fem", "seks", "syv", "otte", "ni", "ti", "elleve", "tolv",
|
|
"tretten", "fjorten", "femten", "seksten", "sytten", "atten", "nitten"]
|
|
TENS = {20: "tyve", 30: "tredive", 40: "fyrre", 50: "halvtreds", 60: "tres", 70: "halvfjerds", 80: "firs", 90: "halvfems"}
|
|
|
|
|
|
def number_words(n: int) -> str:
|
|
"""Danske talord, så oplæsningen ikke snubler over cifre og tusindtalspunktummer."""
|
|
n = int(n)
|
|
if n < 0:
|
|
return "minus " + number_words(-n)
|
|
if n < 20:
|
|
return ONES[n]
|
|
if n < 100:
|
|
tens, one = divmod(n, 10)
|
|
return TENS[tens * 10] if not one else f"{'en' if one == 1 else ONES[one]}og{TENS[tens * 10]}"
|
|
if n < 1000:
|
|
h, rest = divmod(n, 100)
|
|
head = "et hundrede" if h == 1 else f"{ONES[h]} hundrede"
|
|
return head if not rest else f"{head} og {number_words(rest)}"
|
|
if n < 1_000_000:
|
|
t, rest = divmod(n, 1000)
|
|
head = "et tusind" if t == 1 else f"{number_words(t)} tusind"
|
|
if not rest:
|
|
return head
|
|
return f"{head} {'og ' if rest < 100 else ''}{number_words(rest)}"
|
|
return str(n)
|
|
|
|
|
|
def join_names(names: list[str]) -> str:
|
|
names = [n for n in names if n]
|
|
if len(names) <= 1:
|
|
return "".join(names)
|
|
return ", ".join(names[:-1]) + " og " + names[-1]
|
|
|
|
|
|
def safe(text: str) -> str:
|
|
"""Spillertekst (navne, løgne) må ikke kunne sende lyd-tags til ElevenLabs."""
|
|
return re.sub(r"[\[\]{}<>]", "", str(text)).strip()
|
|
|
|
|
|
def capitalize(line: str) -> str:
|
|
"""Stort begyndelsesbogstav, også når replikken starter med et indsat svar ("kongevand, det er …")."""
|
|
m = re.match(r"^((?:\[[a-z ]+\]\s*)*)(\w)", line)
|
|
return line if not m else line[:m.start(2)] + m.group(2).upper() + line[m.end(2):]
|
|
|
|
|
|
def speakable_prompt(prompt: str) -> str:
|
|
return capitalize(re.sub(r"_{3,}", "blank", prompt))
|
|
|
|
|
|
# Drillerier på telefonen: kort tekst på skærmen og (hvis der er stemme) samme replik læst op.
|
|
TEASES = ["Buh!", "Hov hov, der røg du i!", "Avs, avs, avs!", "Snydt!", "Hihi, den var god!",
|
|
"Klassisk dig!", "Ups!", "Ha! Fanget!"]
|
|
|
|
|
|
class Narrator:
|
|
def __init__(self, rng: random.Random | None = None):
|
|
self.rng = rng or random.Random()
|
|
self._recent: dict[str, list[int]] = {}
|
|
|
|
def line(self, event: str, **values) -> str | None:
|
|
variants = LINES.get(event)
|
|
if not variants:
|
|
return None
|
|
recent = self._recent.setdefault(event, [])
|
|
fresh = [i for i in range(len(variants)) if i not in recent] or list(range(len(variants)))
|
|
i = self.rng.choice(fresh)
|
|
recent.append(i)
|
|
del recent[:-max(1, len(variants) // 2)] # undgå gentagelser i den seneste halvdel
|
|
filled = {k: (number_words(v) if isinstance(v, int) else safe(v)) for k, v in values.items()}
|
|
return capitalize(re.sub(r"\{(\w+)\}", lambda m: filled.get(m.group(1), ""), variants[i]))
|
|
|
|
# ── afsløringen ──────────────────────────────────────────────────────────
|
|
def reveal_step(self, step: dict, names: dict[str, str], n_players: int, comment: str | None = None) -> str | None:
|
|
picked = [names[p] for p in step["guessed_by"] if p in names]
|
|
if step["audience_votes"]:
|
|
picked.append("publikum")
|
|
if comment:
|
|
return self._reveal_with_comment(step, names, picked, n_players, comment)
|
|
if step["is_truth"]:
|
|
if not step["guessed_by"]:
|
|
return self.line("reveal_truth_nobody", sandhed=step["text"])
|
|
if n_players >= 2 and len(step["guessed_by"]) >= n_players:
|
|
return self.line("reveal_truth_everyone", sandhed=step["text"])
|
|
return self.line("reveal_truth_found", sandhed=step["text"], navne=join_names(picked))
|
|
if not picked:
|
|
return None
|
|
if step["house"]:
|
|
return self.line("reveal_house_lie", svar=step["text"], ofre=join_names(picked))
|
|
authors = [names[a] for a in step["authors"] if a in names]
|
|
if len(authors) >= 2:
|
|
return self.line("reveal_lie_shared", svar=step["text"], forfattere=join_names(authors))
|
|
if len(step["guessed_by"]) >= 3:
|
|
return self.line("reveal_lie_fooled_many", svar=step["text"], forfatter=join_names(authors),
|
|
antal=len(step["guessed_by"]))
|
|
return self.line("reveal_lie_fooled", svar=step["text"], forfatter=join_names(authors),
|
|
ofre=join_names(picked))
|
|
|
|
def _reveal_with_comment(self, step, names, picked, n_players, comment) -> str | None:
|
|
"""Hjernen har skrevet pointen; her lægges fakta (hvem skrev, hvem faldt) foran."""
|
|
comment = re.sub(r"[{}<>]", "", comment).strip()
|
|
svar = safe(step["text"])
|
|
if step["is_truth"]:
|
|
if not step["guessed_by"]:
|
|
head = f"Sandheden er {svar}, og ingen fandt den."
|
|
elif n_players >= 2 and len(step["guessed_by"]) >= n_players:
|
|
head = f"Alle fandt sandheden, {svar}."
|
|
else:
|
|
head = f"Sandheden er {svar}. {join_names(picked)} fandt den."
|
|
return capitalize(f"{head} {comment}")
|
|
if not picked:
|
|
return None
|
|
if step["house"]:
|
|
return capitalize(f"{svar}. Husets løgn! {join_names(picked)} faldt for den. {comment}")
|
|
authors = join_names([names[a] for a in step["authors"] if a in names])
|
|
if len(step["authors"]) >= 2:
|
|
return capitalize(f"{svar}. Det skrev både {authors}, og {join_names(picked)} hoppede i. {comment}")
|
|
return capitalize(f"{svar}. Det skrev {authors}, og {join_names(picked)} hoppede i. {comment}")
|
|
|
|
# ── alt andet ────────────────────────────────────────────────────────────
|
|
def for_event(self, ev: dict, game) -> list[str]:
|
|
t = ev["type"]
|
|
vip = game.players.get(game.vip_id()) if game.vip_id() else None
|
|
if t == "host_open":
|
|
return [self.line("lobby_welcome")]
|
|
if t == "join" and ev["role"] == "player" and game.phase == "lobby":
|
|
return [self.line("lobby_join", navn=ev["name"])]
|
|
if t == "lobby_waiting" and vip:
|
|
return [self.line("lobby_waiting", vip=vip.name)]
|
|
if t == "game_start":
|
|
return [self.line("game_start", antal_spillere=ev["players"])]
|
|
if t == "round_intro":
|
|
key = ["round1_intro", "round2_intro", "final_intro"][min(ev["round"], 2)]
|
|
return [self.line(key, foerende=ev["leader"], sidst=ev["last"])]
|
|
if t == "category":
|
|
return [self.line("category_choose", navn=ev["chooser"])]
|
|
if t == "category_timeout":
|
|
return [self.line("category_timeout", navn=ev["chooser"], kategori=ev["category"])]
|
|
if t == "category_intro":
|
|
return [self.line("lie_start", kategori=ev["category"])]
|
|
if t == "lie_start":
|
|
return [speakable_prompt(ev["prompt"])]
|
|
if t in ("lie_hurry", "lie_all_done", "lie_was_truth", "guess_start", "guess_hurry", "play_again"):
|
|
return [self.line(t)]
|
|
if t == "scores":
|
|
return [self.line("scores_intro", foerende=ev["leader"], sidst=ev["last"])]
|
|
if t == "final":
|
|
out = [self.line("winner", vinder=ev["winner"], point=ev["points"])]
|
|
keys = {"fooled": "award_biggest_liar", "correct": "award_truth_seeker", "likes": "award_most_liked"}
|
|
for a in ev["awards"]:
|
|
out.append(self.line(keys[a["kind"]], navn=a["name"], antal=a["count"]))
|
|
return out
|
|
return []
|