Files
weirsoe-party-protocol/party/narrator.py
T
agw d149aa323e
CI / test (push) Failing after 39s
Udbyder-agnostisk spilhjerne, rummets mikrofon og rigtig musik
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
2026-10-01 21:59:50 +02:00

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 []