Files
weirsoe-party-protocol/party/narrator.py
T
agwandClaude Opus 5.5 8cba2e8071 tts_warm: forvarm de generiske replikker lige efter introen
De generiske replikker (fælles, Fup og Fakta, Tegn og Gæt og afsløringens
intro med første kæde) står i budget-køen efter menuens intro og før
Tegn og Gæts faste replikker; rækkefølgen bag dem er uændret. Fest-
replikkerne laves med fest-stemmen, som rummet siger dem. --only begrænser
til det valgte spil, og --dry-run viser dem på deres egen linje:

  generiske replikker: 310 klip, 16737 tegn (--only tegn: 153 klip, 8685 tegn)

Test: alt, et rum kan sige som generisk afløser, ligger i forvarmningen med
den stemme, rummet slår op med.

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

299 lines
16 KiB
Python

"""Speakeren: oversætter spillets begivenheder til replikker fra fupogfakta/narrator/lines.json."""
import json
import random
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LINES = json.loads((ROOT / "fupogfakta" / "narrator" / "lines.json").read_text(encoding="utf-8"))
# Generiske replikker: samme begivenheder uden navne og spillertekst, så de kan forvarmes. Speakeren siger dem,
# når den rigtige replik ikke kan laves (ingen live-stemme). Nøglen er begivenheden; "game_start_<antal>" for
# antal spillere; en tekst i stedet for en liste peger på en anden begivenheds replikker. Fælles for alle spil
# (party/generic_lines.json) plus spillets egne.
GENERIC_COMMON = json.loads((ROOT / "party" / "generic_lines.json").read_text(encoding="utf-8"))
GENERIC = json.loads((ROOT / "fupogfakta" / "narrator" / "generic.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 Line(str):
"""En replik, der kender sin generiske afløser: generic(accept) giver en udgave uden navne og spillertekst
(kun blandt dem, accept godtager, fx dem der ligger i lydcachen), eller None. Rummet spørger, når replikken
selv ikke kan laves."""
def generic(self, accept=None) -> str | None:
return None
def with_generic(text: str | None, generic) -> Line | None:
"""Giv en tekst (fx hjernens) en generisk afløser: generic(accept) -> str | None."""
if text is None:
return None
line = Line(text)
line.generic = generic
return line
def generic_texts(generic: dict, fest_voice: str | None) -> list[tuple[str, str | None]]:
"""Alle generiske replikker i en generic.json som (tekst, stemme), som rummet siger dem: fest-replikkerne
med fest-stemmen (eller standardstemmen, hvis der ikke er en), resten med standardstemmen."""
return [(t, fest_voice if k.endswith("_fest") else None)
for k, v in generic.items() if isinstance(v, list) for t in v]
def instead_of(text: str | None, fixed: Line | None) -> Line | None:
"""Hjernens replik i stedet for en fast: kan den ikke laves, siges den faste (hvis den er cachet), ellers den
faste replik's generiske udgave. Hjernens tekst caches aldrig, så uden live-stemme er det altid afløseren."""
def generic(accept=None):
if fixed and (accept is None or accept(fixed)):
return str(fixed)
return fixed.generic(accept) if isinstance(fixed, Line) else None
return with_generic(text, generic)
def one_of(text: str | None, choices: list[str], rng: random.Random) -> Line | None:
"""En replik (fx hjernens drilleri), hvis afløser er en af de faste choices."""
def generic(accept=None):
ok = [c for c in choices if accept is None or accept(c)]
return rng.choice(ok) if ok else None
return with_generic(text, generic)
class Narrator:
def __init__(self, rng: random.Random | None = None, fest: bool = False, lines: dict | None = None,
generic: 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.generic_lines = {**GENERIC_COMMON, **(GENERIC if lines is None else generic or {})} # ... og generic.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 _pick(self, key: str, variants: list[str], allowed: list[int] | None = None) -> int:
allowed = list(range(len(variants))) if allowed is None else allowed
recent = self._recent.setdefault(key, [])
fresh = [i for i in allowed if i not in recent] or allowed
i = self.rng.choice(fresh)
recent.append(i)
del recent[:-max(1, len(variants) // 2)] # undgå gentagelser i den seneste halvdel
return i
def line(self, event: str, **values) -> Line | None:
key = self._event(event)
variants = self.lines.get(key)
if not variants:
return None
return with_generic(self.fill(variants[self._pick(key, variants)], values),
lambda accept=None: self.generic(event, accept, **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), [])]
# ── generiske replikker ──────────────────────────────────────────────────
def _generic_key(self, event: str, values: dict) -> str | None:
if event == "game_start" and isinstance(values.get("antal_spillere"), int):
event = f"game_start_{values['antal_spillere']}"
v = self.generic_lines.get(event)
if isinstance(v, str): # henvisning til en anden begivenheds replikker
event = v
if self.fest and isinstance(self.generic_lines.get(event + "_fest"), list):
return event + "_fest"
return event if isinstance(self.generic_lines.get(event), list) else None
def generic(self, event: str, accept=None, **values) -> str | None:
"""En generisk udgave af begivenhedens replik (samme rotation som line()); accept(tekst) begrænser valget."""
key = self._generic_key(event, values)
variants = self.generic_lines.get(key, []) if key else []
allowed = [i for i, v in enumerate(variants) if accept is None or accept(v)]
return variants[self._pick("generic:" + key, variants, allowed)] if allowed else None
def generic_variants(self, event: str, **values) -> list[str]:
key = self._generic_key(event, values)
return list(self.generic_lines.get(key, [])) if key else []
# ── 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 []