Files
weirsoe-party-protocol/party/narrator.py
T
agwandClaude Opus 5.5 45a146f548 tts_warm: forvarm også Tegn og Gæt (--only, --dry-run)
Tegn og Gæts faste replikker, reglerne, {tema}-replikkerne for alle
temaer og spillets start for 3-10 spillere laves først, med den
almindelige speaker; derefter Fup og Fakta som før. Teksterne kommer
fra samme Narrator-kode, som rummet bruger, så cache-nøglen er den
samme. --only fof|tegn vælger ét spil, og --dry-run tæller klip og
tegn uden at kalde ElevenLabs (også uden nøgle).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:19:38 +02:00

221 lines
12 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()
# Lyd-tags, som stemmen (eleven_v3/v4) udfører i stedet for at læse dem op. Andre [ord] fjernes,
# så en opfundet tag ikke bliver læst højt.
AUDIO_TAGS = {"laughs", "chuckles", "giggles", "sighs", "whispers", "excited", "mischievously", "sarcastic",
"suspicious", "impressed", "gasps", "dramatic", "cheering", "curious", "snorts", "pause", "disappointed"}
def keep_known_tags(text: str) -> str:
return re.sub(r"\[([^\]]*)\]", lambda m: m.group(0) if m.group(1).strip().lower() in AUDIO_TAGS else "", text)
def untag(text: str) -> str:
"""Til skærmen: replikken uden lyd-tags."""
return re.sub(r"\s{2,}", " ", re.sub(r"\[[^\]]*\]\s*", "", text or "")).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!",
"[laughs] Snydt igen!", "Den købte du råt!", "Nåå, troede du på den?", "[whispers] Jeg så det godt.",
"Lige i fælden!", "Bid på krogen!", "[chuckles] Hov, hov!", "Den var for nem!",
"Ups, der røg pointene.", "[mischievously] Tak for pointene!", "Godtroende!", "Sikke en naiv sjæl!",
"Narret!", "Fanget med fingrene i løgnen!", "[gasps] Den troede du på?", "Lidt for tillidsfuld!",
"Hjem og læs lektier!", "[sighs] Åh nej, ikke dig også.", "Den løgn sad lige i skabet!", "Bum! Fanget!"]
# Fest-spillets drillerier: samme idé, bare på zoomer, og der skal drikkes.
TEASES_FEST = ["Bro er cooked!", "[laughs] L, tag en tår!", "Ohio-moment! Skål!", "NPC-behavior!", "Aura minus tusind!",
"Du blev scammet, bestie!", "[gasps] Du troede på den? Delulu!", "Skibidi-snydt!", "Lowkey pinligt. Drik!",
"Det var ikke rizz, det var en løgn!", "[sarcastic] Big brain move. Not.", "Emotional damage! Skål!",
"Fanum tax på din tår!", "[chuckles] Mid gæt, fuld tår!", "Rip bozo. Drik!", "Ratio! Tag en tår!",
"Caught in 4K!", "[whispers] Sus. Meget sus.", "Den var for nem, bestie!", "It's giving godtroende!",
"Bussin' løgn, cooked gæt!", "[mischievously] Tak for tåren!", "No cap, du blev fooled!", "Main character? Nah, NPC!"]
class Narrator:
def __init__(self, rng: random.Random | None = None, fest: bool = False, lines: dict | None = None):
self.rng = rng or random.Random()
self.lines = LINES if lines is None else lines # et andet spil har sin egen lines.json
self.fest = fest # fest: replikken "<begivenhed>_fest" bruges, når den findes
self._recent: dict[str, list[int]] = {}
def _event(self, event: str) -> str:
return event + "_fest" if self.fest and self.lines.get(event + "_fest") else event
@staticmethod
def fill(variant: str, values: dict) -> str:
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), ""), variant))
def line(self, event: str, **values) -> str | None:
event = self._event(event)
variants = self.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
return self.fill(variants[i], values)
def variants(self, event: str, **values) -> list[str]:
"""Alle udgaver af replikken, som line() ville sige dem (til forvarmning af lyden)."""
return [self.fill(v, values) for v in self.lines.get(self._event(event), [])]
# ── 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 present_answer(self, svar: str, comment: str | None = None) -> str:
if comment:
comment = self.clean_ai(comment)
# hjernen gentager tit svaret ("Ugler. De ser kloge ud"); speakeren siger det allerede
m = re.match(r"^((?:\[[a-z ]+\]\s*)*)" + re.escape(safe(svar)) + r"\s*[.,:!?-]*\s*", comment, re.I)
if m:
comment = capitalize(m.group(1) + comment[m.end():])
return capitalize(f"{safe(svar)}. {comment}")
return self.line("present_answer", svar=svar)
@staticmethod
def clean_ai(text: str) -> str:
"""Hjernens replik: lyd-tags må gerne stå, men ingen pladsholdere eller markup."""
return capitalize(re.sub(r"\s{2,}", " ", keep_known_tags(re.sub(r"[{}<>]", "", text))).strip())
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 = keep_known_tags(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",
"sips": "award_thirstiest"}
for a in ev["awards"]:
out.append(self.line(keys[a["kind"]], navn=a["name"], antal=a["count"]))
return out
return []