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>
299 lines
16 KiB
Python
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 []
|