Spilmotoren udsender begivenheder, rummet vælger en replik, indsætter navne og talord og genererer lyden (cachet på disk ud fra tekst, stemme og model). Storskærmen afspiller replikkerne i rækkefølge. Afsløringen venter på lyden og får en længde pr. trin, så stemplet lander, mens speakeren taler. Uden nøgle eller ved fejl kører spillet som før, bare uden stemme. Spørgsmålene er gjort familievenlige, og de otte mest kluntede replikker er skrevet om. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
@@ -4,3 +4,4 @@ __pycache__/
|
||||
db.sqlite3
|
||||
staticfiles/
|
||||
.env
|
||||
tts-cache/
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
},
|
||||
{
|
||||
"category": "Dyreriget",
|
||||
"prompt": "Vombatten er kendt for at lave _____ lorte.",
|
||||
"prompt": "Vombatten er kendt for at lave _____ bæ.",
|
||||
"answer": "terningformede",
|
||||
"alternatives": [
|
||||
"firkantede",
|
||||
@@ -1142,7 +1142,7 @@
|
||||
"Juicy Fruit"
|
||||
],
|
||||
"decoys": [
|
||||
"cigaretter",
|
||||
"kiks",
|
||||
"havregryn",
|
||||
"kaffe"
|
||||
],
|
||||
|
||||
@@ -478,22 +478,6 @@
|
||||
],
|
||||
"note": "På Apollo 14 slog Shepard to golfbolde med et hjemmelavet jern."
|
||||
},
|
||||
{
|
||||
"category": "Sport",
|
||||
"prompt": "Under OL i Paris i 1900 blev der afholdt skydning efter levende _____.",
|
||||
"answer": "duer",
|
||||
"alternatives": [
|
||||
"duer",
|
||||
"due",
|
||||
"tamduer"
|
||||
],
|
||||
"decoys": [
|
||||
"kaniner",
|
||||
"fasaner",
|
||||
"høns"
|
||||
],
|
||||
"note": "Det var eneste gang, dyr bevidst blev dræbt ved legene; omkring 300 duer blev skudt."
|
||||
},
|
||||
{
|
||||
"category": "Sport",
|
||||
"prompt": "OL's grundlægger Pierre de Coubertin vandt selv en guldmedalje i 1912 i _____.",
|
||||
@@ -590,22 +574,6 @@
|
||||
],
|
||||
"note": "Badminton House i Gloucestershire tilhørte hertugen af Beaufort."
|
||||
},
|
||||
{
|
||||
"category": "Sport",
|
||||
"prompt": "Ved OL-maratonløbet i 1904 fik vinderen Thomas Hicks undervejs rottegiften _____ for at holde ham kørende.",
|
||||
"answer": "stryknin",
|
||||
"alternatives": [
|
||||
"stryknin",
|
||||
"strychnin",
|
||||
"strychnine"
|
||||
],
|
||||
"decoys": [
|
||||
"arsenik",
|
||||
"cyanid",
|
||||
"kviksølv"
|
||||
],
|
||||
"note": "Hans hjælpere gav ham stryknin blandet med æggehvide og brandy; han var tæt på at kollapse."
|
||||
},
|
||||
{
|
||||
"category": "Sport",
|
||||
"prompt": "Fred Lorz kom først i mål ved OL-maratonløbet i 1904, men havde kørt en stor del af ruten i _____.",
|
||||
@@ -735,7 +703,7 @@
|
||||
},
|
||||
{
|
||||
"category": "Historie",
|
||||
"prompt": "I Boston døde 21 mennesker i 1919, da en kæmpe tank med _____ bristede.",
|
||||
"prompt": "I Boston skyllede en kæmpe bølge af _____ gennem gaderne i 1919, da en tank bristede.",
|
||||
"answer": "melasse",
|
||||
"alternatives": [
|
||||
"melasse",
|
||||
@@ -743,9 +711,9 @@
|
||||
"sukkersirup"
|
||||
],
|
||||
"decoys": [
|
||||
"øl",
|
||||
"mælk",
|
||||
"whisky"
|
||||
"olie",
|
||||
"honning"
|
||||
],
|
||||
"note": "Den store melasseflod sendte en bølge af sirup gennem North End i Boston."
|
||||
},
|
||||
@@ -858,7 +826,7 @@
|
||||
"decoys": [
|
||||
"kaffe",
|
||||
"en appelsin",
|
||||
"whisky"
|
||||
"is"
|
||||
],
|
||||
"note": "Aldrin tog nadver med medbragt brød og vin, før han gik ud på Månen."
|
||||
},
|
||||
|
||||
+70
-5
@@ -112,7 +112,14 @@ class Game:
|
||||
self.likes: dict[str, set[str]] = {}
|
||||
self.reveal_steps: list[dict] = []
|
||||
self.final_result: dict | None = None
|
||||
self.reveal_started_at: float | None = None
|
||||
self.reveal_steps_ms: list[int] = []
|
||||
self.reveal_tail_ms = REVEAL_TAIL_MS
|
||||
self.reveal_note_audio: dict | None = None
|
||||
self.version = 0
|
||||
# Begivenheder til speakeren (party.narration). Motoren lægger dem her;
|
||||
# rummet tømmer listen efter hver handling.
|
||||
self.events: list[dict] = []
|
||||
|
||||
# ── hjælpere ────────────────────────────────────────────────────────────
|
||||
def _next_id(self, prefix: str) -> str:
|
||||
@@ -122,6 +129,18 @@ class Game:
|
||||
def _changed(self):
|
||||
self.version += 1
|
||||
|
||||
def _emit(self, kind: str, **data):
|
||||
self.events.append({"type": kind, **data})
|
||||
|
||||
def drain_events(self) -> list[dict]:
|
||||
events, self.events = self.events, []
|
||||
return events
|
||||
|
||||
def standings(self) -> tuple[Player | None, Player | None]:
|
||||
"""(førende, sidst) blandt spillerne."""
|
||||
ranked = sorted(self.players.values(), key=lambda p: (-p.score, p.joined_order))
|
||||
return (ranked[0], ranked[-1]) if ranked else (None, None)
|
||||
|
||||
def _member(self, member_id: str) -> Player | None:
|
||||
return self.players.get(member_id) or self.audience.get(member_id)
|
||||
|
||||
@@ -169,6 +188,7 @@ class Game:
|
||||
token=secrets.token_urlsafe(16), role="player" if as_player else "audience",
|
||||
connected=True, joined_order=self._counter)
|
||||
(self.players if as_player else self.audience)[p.id] = p
|
||||
self._emit("join", name=p.name, role=p.role)
|
||||
self._changed()
|
||||
return p
|
||||
|
||||
@@ -213,6 +233,7 @@ class Game:
|
||||
self._reset_scores()
|
||||
self.round_idx = 0
|
||||
self.q_idx = 0
|
||||
self._emit("game_start", players=len(self.players))
|
||||
self._begin_question(now)
|
||||
|
||||
def play_again(self, member_id: str | None, now: float) -> None:
|
||||
@@ -229,6 +250,7 @@ class Game:
|
||||
a.color = next((c for c in COLORS if c not in used), COLORS[0])
|
||||
self.players[a.id] = a
|
||||
self.phase = "lobby"
|
||||
self._emit("play_again")
|
||||
self.start(None, now)
|
||||
|
||||
def _to_lobby(self):
|
||||
@@ -257,6 +279,10 @@ class Game:
|
||||
if not cats:
|
||||
raise GameError("no_questions", "Der er ingen spørgsmål i banken.")
|
||||
self.round_deltas_reset_if_new_round()
|
||||
if self.q_idx == 0:
|
||||
leader, last = self.standings()
|
||||
self._emit("round_intro", round=self.round_idx, leader=leader.name if leader else "",
|
||||
last=last.name if last else "")
|
||||
if self.is_final_round:
|
||||
self._choose(self.rng.choice(list(cats)), now)
|
||||
return
|
||||
@@ -269,6 +295,7 @@ class Game:
|
||||
self.category_options = [{"id": cid, "name": name} for cid, name in picks]
|
||||
self.phase = "category"
|
||||
self.deadline = now + CATEGORY_SECONDS
|
||||
self._emit("category", chooser=chooser.name)
|
||||
self._changed()
|
||||
|
||||
def round_deltas_reset_if_new_round(self):
|
||||
@@ -298,8 +325,11 @@ class Game:
|
||||
self.reveal_steps = []
|
||||
for p in self.players.values():
|
||||
p.last_delta = None
|
||||
self.reveal_started_at = None
|
||||
self.reveal_note_audio = None
|
||||
self.phase = "lie"
|
||||
self.deadline = now + LIE_SECONDS
|
||||
self._emit("lie_start", category=self.question["category"], prompt=self.question["prompt"])
|
||||
self._changed()
|
||||
|
||||
def _truths(self) -> list[str]:
|
||||
@@ -324,6 +354,7 @@ class Game:
|
||||
if not text or len(text) > MAX_LIE or not normalize(text):
|
||||
raise GameError("lie_invalid", f"Løgnen skal være 1-{MAX_LIE} tegn.")
|
||||
if self._is_truth(text):
|
||||
self._emit("lie_was_truth")
|
||||
raise GameError("lie_is_truth", "Det er faktisk sandheden! Find på en løgn i stedet.")
|
||||
self.lies[member_id] = text
|
||||
self._changed()
|
||||
@@ -359,6 +390,7 @@ class Game:
|
||||
self.answers = answers
|
||||
self.phase = "guess"
|
||||
self.deadline = now + GUESS_SECONDS
|
||||
self._emit("guess_start")
|
||||
self._changed()
|
||||
|
||||
def _answer(self, answer_id: str) -> Answer:
|
||||
@@ -411,6 +443,7 @@ class Game:
|
||||
def _maybe_finish_early(self, now: float):
|
||||
active = self._active_players()
|
||||
if self.phase == "lie" and active and all(p.id in self.lies for p in active):
|
||||
self._emit("lie_all_done")
|
||||
self._start_guess(now)
|
||||
elif self.phase == "guess" and active and all(p.id in self.guesses for p in active):
|
||||
self._start_reveal(now)
|
||||
@@ -453,9 +486,25 @@ class Game:
|
||||
steps.sort(key=lambda s: (s["is_truth"], len(s["guessed_by"]) + s["audience_votes"]))
|
||||
self.reveal_steps = steps
|
||||
self.phase = "reveal"
|
||||
self.deadline = now + (len(steps) * REVEAL_STEP_MS + REVEAL_TAIL_MS) / 1000
|
||||
self.start_reveal_timeline(now)
|
||||
self._emit("reveal")
|
||||
self._changed()
|
||||
|
||||
def start_reveal_timeline(self, now: float, steps_ms: list[int] | None = None, tail_ms: int | None = None):
|
||||
"""Afsløringen kører trin for trin fra `now`. Speakeren kan give hvert trin sin egen længde."""
|
||||
self.reveal_steps_ms = list(steps_ms or [REVEAL_STEP_MS] * len(self.reveal_steps))
|
||||
self.reveal_tail_ms = REVEAL_TAIL_MS if tail_ms is None else tail_ms
|
||||
self.reveal_started_at = now
|
||||
self.deadline = now + (sum(self.reveal_steps_ms) + self.reveal_tail_ms) / 1000
|
||||
self._changed()
|
||||
|
||||
def hold_reveal(self, now: float, max_wait: float = 10.0):
|
||||
"""Vent med at starte afsløringen (speakeren laver lyd). Starter selv efter max_wait."""
|
||||
if self.phase == "reveal":
|
||||
self.reveal_started_at = None
|
||||
self.deadline = now + max_wait
|
||||
self._changed()
|
||||
|
||||
def _after_reveal(self, now: float):
|
||||
self.q_idx += 1
|
||||
if self.q_idx < self.round[2]:
|
||||
@@ -465,6 +514,8 @@ class Game:
|
||||
else:
|
||||
self.phase = "scores"
|
||||
self.deadline = now + SCORES_SECONDS
|
||||
leader, last = self.standings()
|
||||
self._emit("scores", leader=leader.name if leader else "", last=last.name if last else "")
|
||||
self._changed()
|
||||
|
||||
def _after_scores(self, now: float):
|
||||
@@ -484,12 +535,17 @@ class Game:
|
||||
def best(attr, title, detail):
|
||||
top = max(self.players.values(), key=lambda p: getattr(p, attr), default=None)
|
||||
if top and getattr(top, attr) > 0:
|
||||
awards.append({"title": title, "player_id": top.id, "detail": detail(getattr(top, attr))})
|
||||
awards.append({"title": title, "player_id": top.id, "detail": detail(getattr(top, attr)),
|
||||
"kind": attr, "count": getattr(top, attr)})
|
||||
|
||||
best("fooled", "Største løgner", lambda n: f"narrede andre {n} gang{'e' if n != 1 else ''}")
|
||||
best("correct", "Sandhedsvidne", lambda n: f"fandt sandheden {n} gang{'e' if n != 1 else ''}")
|
||||
best("likes", "Folkets favorit", lambda n: f"fik {n} like{'s' if n != 1 else ''}")
|
||||
self.final_result = {"ranking": places, "awards": awards}
|
||||
winner = ranking[0] if ranking else None
|
||||
self._emit("final", winner=winner.name if winner else "", points=winner.score if winner else 0,
|
||||
awards=[{"kind": a["kind"], "name": self.players[a["player_id"]].name, "count": a["count"]}
|
||||
for a in awards])
|
||||
self.phase = "final"
|
||||
self.deadline = None
|
||||
self._changed()
|
||||
@@ -508,13 +564,19 @@ class Game:
|
||||
|
||||
def _advance(self, now: float):
|
||||
if self.phase == "category":
|
||||
self._choose(self.rng.choice(self.category_options)["id"], now)
|
||||
pick = self.rng.choice(self.category_options)
|
||||
chooser = self.players.get(self.chooser_id)
|
||||
self._emit("category_timeout", chooser=chooser.name if chooser else "", category=pick["name"])
|
||||
self._choose(pick["id"], now)
|
||||
elif self.phase == "lie":
|
||||
self._start_guess(now)
|
||||
elif self.phase == "guess":
|
||||
self._start_reveal(now)
|
||||
elif self.phase == "reveal":
|
||||
self._after_reveal(now)
|
||||
if self.reveal_started_at is None:
|
||||
self.start_reveal_timeline(now) # lyden blev aldrig klar: kør uden
|
||||
else:
|
||||
self._after_reveal(now)
|
||||
elif self.phase == "scores":
|
||||
self._after_scores(now)
|
||||
|
||||
@@ -551,7 +613,10 @@ class Game:
|
||||
state["answers"] = [{"id": a.id, "text": a.text} for a in self.answers if hide not in a.authors]
|
||||
if self.phase == "reveal":
|
||||
state["reveal"] = {"steps": self.reveal_steps, "step_ms": REVEAL_STEP_MS,
|
||||
"note": self.question.get("note", "") if self.question else ""}
|
||||
"steps_ms": self.reveal_steps_ms, "tail_ms": self.reveal_tail_ms,
|
||||
"started_at": int(self.reveal_started_at * 1000) if self.reveal_started_at else None,
|
||||
"note": self.question.get("note", "") if self.question else "",
|
||||
"note_audio": self.reveal_note_audio}
|
||||
if self.phase == "scores":
|
||||
state["deltas"] = [{"player_id": p.id, "delta": p.round_delta} for p in self.players.values()]
|
||||
if self.phase == "final":
|
||||
|
||||
+227
-227
@@ -1,228 +1,228 @@
|
||||
{
|
||||
"lobby_welcome": [
|
||||
"Velkommen til Fup og Fakta. Find telefonen frem. Samvittigheden kan blive i lommen.",
|
||||
"Her må du lyve. Vi har endda lavet en pointtavle til det.",
|
||||
"Velkommen. Her bliver familiehistorier endelig vurderet efter, hvor godt de snyder.",
|
||||
"[excited] Fup og Fakta! Find telefonen frem. Jeg har allerede gjort sandheden nervøs.",
|
||||
"Et spil om sandhed og løgn. Altså en familiefest med lidt tydeligere regler."
|
||||
],
|
||||
"lobby_join": [
|
||||
"{navn} er med. Så har vi en mere at stole lidt for meget på.",
|
||||
"Velkommen, {navn}. Jeg stoler på dig. Det plejer at gå galt.",
|
||||
"Der kom {navn}. Endnu et navn til min kommende samling af bortforklaringer.",
|
||||
"{navn}, dejligt. Jeg skriver dig på listen over mistænkeligt hyggelige mennesker.",
|
||||
"[suspicious] {navn} er med. Den tavshed tager jeg som en tilståelse.",
|
||||
"Plads til {navn}. Telefonen er nu officielt et redskab til familieintriger.",
|
||||
"{navn}, du kom præcis i tide til at tage fejl sammen med os.",
|
||||
"[impressed] {navn} fandt ind. Første opgave løst. Desværre giver den ingen point.",
|
||||
"Nu er {navn} her. Så kan vi snart få sandheden nedstemt.",
|
||||
"{navn}, sæt dig godt. Dine forklaringer kommer til at stå på egne ben."
|
||||
],
|
||||
"lobby_waiting": [
|
||||
"{vip}, når du er klar, kan vi begynde. Jeg har løgne, der skal luftes.",
|
||||
"Hvis vi venter længere, {vip}, bliver mine løgne til gamle familiehistorier.",
|
||||
"{vip}, giv os et tegn. Helst startknappen. Røgsignaler virker dårligt i stuen.",
|
||||
"Jeg er klar, {vip}. Det siger jeg også før møder. Her mener jeg det.",
|
||||
"[whispers] {vip}, jeg har snart brugt min eneste replik om vejret."
|
||||
],
|
||||
"game_start": [
|
||||
"{antal_spillere} spillere og én sandhed. Det bliver en helt almindelig familiefest.",
|
||||
"{antal_spillere} spillere. Gæt sandheden, og nar de andre. Begge dele giver point.",
|
||||
"{antal_spillere} spillere. Tilliden i sofaen skal nu testes med helt frivillige løgne.",
|
||||
"[excited] Vi er i gang! {antal_spillere} spillere, samlet om noget mistænkeligt. Helt lovligt.",
|
||||
"{antal_spillere} spillere. Point for sandheden og gode løgne. Familiefortælleren får endelig lidt anerkendelse."
|
||||
],
|
||||
"round1_intro": [
|
||||
"Første runde. Almindelige point, ualmindeligt tvivlsomme forklaringer.",
|
||||
"Vi begynder med normale point. Se det som en høflig prøve på familiens tillid.",
|
||||
"Runde ét. Pointene er normale. Det er resten af det her sjældent.",
|
||||
"Første runde, normale point. Jeg starter med fuld tillid. Det plejer at være en kort fornøjelse.",
|
||||
"Vi starter stille med normale point. I får rigelig mulighed for at råbe senere."
|
||||
],
|
||||
"round2_intro": [
|
||||
"Nu giver vi dobbelt point. {foerende}, din føring får brug for en sikkerhedssele.",
|
||||
"Runde to, dobbelt point. Nu får selv en lille løgn lidt vokseværk.",
|
||||
"Vi fordobler pointene. {foerende} fører, men sofaen har endnu ikke kronet nogen.",
|
||||
"Dobbelt point! Nu er det dyrere at stole på familie og venner.",
|
||||
"[excited] Runde to giver dobbelt point. Jeg føler mig allerede dobbelt så vigtig."
|
||||
],
|
||||
"final_intro": [
|
||||
"Finale med tredobbelt point. {foerende} fører, men ingen har fået lov at holde takketale.",
|
||||
"{sidst}, nu giver vi tredobbelt point. Tid til at pynte på regnskabet.",
|
||||
"{foerende}, finalen giver tredobbelt point. Føringer tæller bedst, når de holder hele vejen.",
|
||||
"[dramatic] Tredobbelt point i finalen. Nu kan en mavefornemmelse flytte rundt på hele stillingen.",
|
||||
"Sidste runde. Tre gange pointene. Jeg har allerede øvet min overraskede stemme."
|
||||
],
|
||||
"category_choose": [
|
||||
"{navn}, vælg en kategori. Gerne noget, du kan lyve selvsikkert om.",
|
||||
"Valget er dit, {navn}. Hele sofaen får naturligvis lov at brokke sig bagefter.",
|
||||
"Hvad skal vi høre om, {navn}? Du har ordet. Jeg beholder opmærksomheden.",
|
||||
"{navn}, peg på en kategori. Her er mavefornemmelsen faktisk kvalificeret.",
|
||||
"{navn}, hvilken kategori? Du behøver kun vælge én. Gem resten til ønskesedlen."
|
||||
],
|
||||
"category_timeout": [
|
||||
"{navn} tog betænkningstid. Vi tog {kategori}. Så kom der da en beslutning.",
|
||||
"Det blev {kategori}. {navn}, stilhed er åbenbart også en måde at vælge på.",
|
||||
"[sighs] {navn}, tiden løb. Nu hedder kategorien {kategori}. Den kunne heldigvis beslutte sig.",
|
||||
"{kategori} er valgt. {navn}, vi kalder det delegering. Det lyder pænere.",
|
||||
"{navn}, universet har valgt {kategori}. Universet arbejder åbenbart hurtigere end vores familie."
|
||||
],
|
||||
"lie_start": [
|
||||
"Nu gælder det {kategori}. Skriv en løgn, der kunne få sit eget leksikonopslag.",
|
||||
"{kategori}. Skriv en løgn, der føles som noget, man har hørt før.",
|
||||
"Skriv en løgn om {kategori}. Giv den en parkeringsvagts selvtillid.",
|
||||
"{kategori} er emnet. Skriv noget opdigtet. Hunden har lovet at tage skylden.",
|
||||
"Opfind et svar om {kategori}. En lille detalje kan gøre løgnen stor.",
|
||||
"Vi skal digte om {kategori}. Gør gerne løgnen kedelig. Det virker overraskende godt.",
|
||||
"[mischievously] Nu handler det om {kategori}. Skriv en løgn, du kunne sige helt alvorligt.",
|
||||
"Skriv en løgn om {kategori}. Husets dårlige ry kan godt bruge hjælp.",
|
||||
"{kategori}. Opfind et svar, som selv familiens skeptiker kunne købe.",
|
||||
"{kategori}. Skriv en troværdig løgn. Du må gerne rette stavefejl i virkeligheden."
|
||||
],
|
||||
"lie_hurry": [
|
||||
"Ti sekunder. En lille løgn er bedre end en stor tænkepause.",
|
||||
"Ti sekunder tilbage. Afsted med løgnen. Den behøver kun overleve ét spørgsmål.",
|
||||
"Der er ti sekunder. Giv fantasien albuer, og send noget afsted.",
|
||||
"Ti sekunder! Selv en halvbagt løgn kan serveres med overbevisning.",
|
||||
"Kun ti sekunder. Din indre forfatter må godt springe korrekturen over."
|
||||
],
|
||||
"lie_all_done": [
|
||||
"Alle løgne er afleveret. Sikke vi kan arbejde, når sandheden bliver valgfri.",
|
||||
"Alle har skrevet. Jeg gemmer bunken til næste gang, nogen kalder sig ærlige.",
|
||||
"Nu har vi alle løgnene. Det er som et familiereferat, bare hurtigere.",
|
||||
"[impressed] Alle er færdige. Sikke et samarbejde om at gøre hinanden mistænksomme.",
|
||||
"Tak for løgnene. Det er en hilsen, jeg sjældent bruger hos bageren."
|
||||
],
|
||||
"lie_was_truth": [
|
||||
"Det var faktisk sandheden. Prøv igen, med lidt mindre ærlighed.",
|
||||
"Sandheden kom ind ad bagdøren. Send den ud igen, og skriv en løgn.",
|
||||
"[impressed] Nogen har skrevet sandheden. Så ved vi i det mindste, at den findes.",
|
||||
"Det svar var sandt. En sjælden tastefejl. Prøv med en løgn.",
|
||||
"Du ramte sandheden. Gem den slags talenter til gættefasen."
|
||||
],
|
||||
"guess_start": [
|
||||
"Find sandheden. Den har klædt sig ud som endnu en dårlig idé.",
|
||||
"Vælg det sande svar. Selvtillid er desværre en dårlig kildehenvisning.",
|
||||
"Nu skal I gætte sandheden. Tænk efter, før familieanekdoterne tager over.",
|
||||
"Hvilket svar er sandt? Lad maven bestemme. Den træffer jo allerede aftensmadsbeslutninger.",
|
||||
"Vælg sandheden. Mine løgne er også med. De er lavet med kærlighed.",
|
||||
"Find det rigtige svar. Virkeligheden har en ret løs opfattelse af, hvad der er rimeligt.",
|
||||
"Ét af svarene passer. Vælg det. Resten sælger vi på næste loppemarked.",
|
||||
"Vælg sandheden. Publikum gætter med. Nu kan endnu flere blande sig.",
|
||||
"Find sandheden, og giv gerne sjove løgne et hjerte. Også bedragere har følelser.",
|
||||
"[suspicious] Ét svar er sandt. Vælg med omhu. De andre har bare øvet sig."
|
||||
],
|
||||
"guess_hurry": [
|
||||
"Fem sekunder. Vælg et svar. Det hjælper sjældent at stirre vredt på telefonen.",
|
||||
"Fem sekunder tilbage. Din mavefornemmelse har nu sidste ord.",
|
||||
"Der er fem sekunder. Tvivlen får desværre ingen point.",
|
||||
"Fem sekunder! Vælg et svar, før din store overvejelse bliver en meget lille aflevering.",
|
||||
"Kun fem sekunder. Sandheden venter. Den er åbenbart bedre opdraget end mig."
|
||||
],
|
||||
"reveal_lie_fooled": [
|
||||
"{forfatter} opfandt {svar}. {ofre} købte den. Kvitteringen er desværre forsvundet.",
|
||||
"{ofre} faldt for {svar}. {forfatter}, din fantasi har nu fået selskab.",
|
||||
"Det var {forfatter}, der fandt på {svar}. {ofre} blev dagens forsøgskaniner.",
|
||||
"[chuckles] {svar} var en løgn fra {forfatter}. Tak for støtten, {ofre}.",
|
||||
"{forfatter} skrev {svar}, og {ofre} tog imod. Meget høfligt. Meget forkert.",
|
||||
"{svar} kom fra {forfatter}. {ofre} lod sig overbevise. Selvtilliden fik åbenbart ordet først.",
|
||||
"{ofre} troede på {svar}. {forfatter} har lige bevist, at en god historie kan springe køen over.",
|
||||
"{forfatter}, {svar} virkede på {ofre}. Den løgn slap godt fra middagsbordet.",
|
||||
"{svar}, sagde {forfatter}. Ja tak, sagde {ofre}. Og sådan fik fantasien sin belønning.",
|
||||
"{svar} var en løgn fra {forfatter}. {ofre}, det bliver mellem os og hele stuen."
|
||||
],
|
||||
"reveal_lie_fooled_many": [
|
||||
"{antal} troede på {svar}. {forfatter}, du har nok til at stifte en forening.",
|
||||
"{forfatter} fik {antal} til at vælge {svar}. Sofaen har dannet fælles front mod fakta.",
|
||||
"[impressed] {svar} narrede {antal}. {forfatter}, jeg bøjer mig. Mest for at gemme min jalousi.",
|
||||
"{antal} hoppede på {svar}. {forfatter}, du kan starte en bølge uden at rejse dig.",
|
||||
"{svar} narrede {antal}. {forfatter}, din fantasi har flere tilhængere end min madplan."
|
||||
],
|
||||
"reveal_lie_shared": [
|
||||
"{forfattere} skrev begge {svar}. To hjerner, én fælles omvej.",
|
||||
"{forfattere} leverede {svar}. To løgnere for én. Det kan tilbudsavisen lide.",
|
||||
"{forfattere} fandt på {svar}. Samme løgn. Fantasien må have en kopimaskine.",
|
||||
"{svar}, fra {forfattere}. En løgn med to underskrifter. Næsten klar til kommunen.",
|
||||
"[suspicious] {forfattere} skrev {svar}. Jeg siger bare, at telepati burde stå i reglerne."
|
||||
],
|
||||
"reveal_house_lie": [
|
||||
"{svar} var min løgn. Tak, {ofre}. Dejligt, at kunst bliver værdsat.",
|
||||
"[mischievously] {ofre} valgte {svar}. Husets løgn. Jeg modtager gerne stående bifald fra sofaen.",
|
||||
"Jeg opfandt {svar}. {ofre} troede på den. Det kalder jeg godt samarbejde.",
|
||||
"{ofre}, {svar} var min. Så ved vi, hvem der bestemmer over rygterne.",
|
||||
"{ofre} købte {svar}. Min opskrift. Tillid er åbenbart den hemmelige ingrediens.",
|
||||
"{svar} kom fra huset. {ofre}, den forklaring kan give næste middag ekstra samtalestof.",
|
||||
"{ofre} troede på {svar}. Jeg skrev den helt uden hjælp fra virkeligheden.",
|
||||
"Min løgn var {svar}. {ofre}, jeg kan mærke min dårlige samvittighed tage fri.",
|
||||
"{svar} snød {ofre}. Den var min. Jeg øver lige mit ydmyge nik.",
|
||||
"Huset opfandt {svar}. {ofre} faldt for den. Så sparede vi penge på specialeffekter."
|
||||
],
|
||||
"reveal_truth_found": [
|
||||
"Sandheden er {sandhed}. {navne} fandt den. Så fik virkeligheden lidt opbakning.",
|
||||
"{navne} valgte sandheden, {sandhed}. Gem lige øjeblikket til næste familiediskussion.",
|
||||
"[impressed] Svaret er {sandhed}. {navne} gennemskuede os. Irriterende godt arbejde.",
|
||||
"{sandhed}, det er sandheden. {navne} ramte rigtigt. Meget upraktisk for løgnerne.",
|
||||
"{sandhed} er svaret. {navne} har tilladelse til ét lille, bedrevidende nik.",
|
||||
"{navne} ramte rigtigt med {sandhed}. Nogen har åbenbart læst bagsiden af mælkekartonen.",
|
||||
"Sandheden var {sandhed}. Godt fanget, {navne}. Den viden fortjente sin egen fest.",
|
||||
"{navne} valgte {sandhed}. Korrekt. Skepsissen kan lige lægge benene op.",
|
||||
"{navne} fandt sandheden, {sandhed}. Mavefornemmelsen har lige fået en lønforhøjelse.",
|
||||
"Svaret er {sandhed}. Point til {navne}. Jeg vidste det selvfølgelig også. Næsten."
|
||||
],
|
||||
"reveal_truth_nobody": [
|
||||
"Svaret var {sandhed}. Ingen fandt det. Vi lader som om, det her var opvarmning.",
|
||||
"Sandheden var {sandhed}. Hele sofaen tog fejl. Sikke en sjælden form for enighed.",
|
||||
"[sighs] Sandheden var {sandhed}. Den stod lige der. Jeg giver virkeligheden et trøstende kram.",
|
||||
"Ingen valgte {sandhed}. Det var ellers sandheden. Den må arbejde lidt på sin troværdighed.",
|
||||
"Sandheden var {sandhed}. Ingen fandt den. Jeg havde ellers væddet min sidste småkage på jer."
|
||||
],
|
||||
"reveal_truth_everyone": [
|
||||
"Svaret var {sandhed}. Alle fandt det. Jeg får åbenbart brug for bedre løgne.",
|
||||
"Sandheden var {sandhed}. Enighed i sofaen. Nogen burde tage et billede.",
|
||||
"[impressed] Alle valgte {sandhed}. Korrekt. Det her bliver svært at forklare som held.",
|
||||
"{sandhed} var sandheden. Alle ramte rigtigt. Jeg havde ellers reserveret tid til en forklaring.",
|
||||
"Alle fandt sandheden, {sandhed}. Pointtavlen bliver lige så spændende som fælles opvask."
|
||||
],
|
||||
"scores_intro": [
|
||||
"{foerende} fører, og {sidst} ligger sidst. Pointtavlen har åbenbart fået holdninger.",
|
||||
"{foerende} fører. Jubel med omtanke. Der skal stadig være plads til alle i sofaen.",
|
||||
"{foerende} øverst, {sidst} nederst. Klager modtages på en serviet efter middagen.",
|
||||
"{foerende} ligger først, {sidst} sidst. Jeg kontrollerede tallene med et ansvarligt nik.",
|
||||
"Pointtavlen er klar. {foerende} fører. Dejligt med tal. De afbryder mig sjældent."
|
||||
],
|
||||
"winner": [
|
||||
"{vinder} vinder med {point} point. Det skal sofaen nok høre om igen.",
|
||||
"[cheering] Tillykke, {vinder}! {point} point. Æren er din. Fjernbetjeningen kræver stadig en forhandling.",
|
||||
"Sejren går til {vinder}, med {point} point. Takketalen må gerne indeholde noget sandt.",
|
||||
"Med {point} point vinder {vinder}. Min oplæring har tydeligvis været fuldstændig afgørende.",
|
||||
"{vinder} vinder med {point} point. Præmiebudgettet gik desværre til mit meget velfortjente honorar."
|
||||
],
|
||||
"award_biggest_liar": [
|
||||
"Største løgner, {navn}. {antal} vellykkede bedrag. Prisen overrækkes med et mistænksomt håndtryk.",
|
||||
"{navn} er største løgner, med {antal} vellykkede bedrag. Tillid kan åbenbart genbruges.",
|
||||
"{navn} narrede andre {antal} gange. Største løgner! Vi springer forklaringen over.",
|
||||
"[impressed] Største løgner er {navn}. {antal} gange blev nogen narret. Din fantasi har fået faste kunder.",
|
||||
"{antal} vellykkede bedrag. Prisen for største løgner går til {navn}. Pokalen er ægte. Tror jeg."
|
||||
],
|
||||
"award_truth_seeker": [
|
||||
"{navn} er sandhedsjæger med {antal} rigtige gæt. En meget irriterende person at diskutere med.",
|
||||
"Flest rigtige gæt, {navn}, med {antal}. Virkeligheden har åbenbart fået sin egen rådgiver.",
|
||||
"Sandhedsprisen går til {navn}. {antal} rigtige gæt. Endelig kom den mærkelige viden til nytte.",
|
||||
"{navn} fandt sandheden {antal} gange. Vi springer høfligt diskussionen om held over.",
|
||||
"{navn} er sandhedsjæger. {antal} rigtige gæt. Den titel har vi faktisk undersøgt, før vi fandt på den."
|
||||
],
|
||||
"award_most_liked": [
|
||||
"Hjerteprisen går til {navn}, med {antal}. Løgn med charme er åbenbart en disciplin.",
|
||||
"{antal} hjerter til {navn}. Sofaens yndling. Blomsterhandleren har lige mistet en kunde.",
|
||||
"{navn} får hjerteprisen med {antal}. Anbefalingen bliver mærkelig. Lyver virkelig underholdende.",
|
||||
"[cheering] Flest hjerter, {navn}, med {antal}. Vi har målt din morsomhed med tommelfingre.",
|
||||
"{navn} vandt {antal} hjerter. Sjældent har en bunke løgne fået så varm en modtagelse."
|
||||
],
|
||||
"play_again": [
|
||||
"En omgang mere! Vi nulstiller pointene. Mistanken gemmer familien selv.",
|
||||
"I vil spille igen. Det tager jeg som ros og et meget svagt ønske om sandhed.",
|
||||
"[excited] Så tager vi en til. Nye løgne, samme sofa. Den har efterhånden hørt lidt af hvert.",
|
||||
"Vi spiller igen. Jeg henter flere fakta. I kan bare genbruge jeres selvtillid.",
|
||||
"En ny omgang. Jeg er glad for tilliden. Især fordi jeg tydeligvis ikke har fortjent den."
|
||||
]
|
||||
}
|
||||
"lobby_welcome": [
|
||||
"Velkommen til Fup og Fakta. Find telefonen frem. Samvittigheden kan blive i lommen.",
|
||||
"Her må du lyve. Vi har endda lavet en pointtavle til det.",
|
||||
"Velkommen. Her bliver familiehistorier endelig vurderet efter, hvor godt de snyder.",
|
||||
"[excited] Fup og Fakta! Find telefonen frem. Jeg har allerede gjort sandheden nervøs.",
|
||||
"Et spil om sandhed og løgn. Altså en familiefest med lidt tydeligere regler."
|
||||
],
|
||||
"lobby_join": [
|
||||
"{navn} er med. Så har vi en mere at stole lidt for meget på.",
|
||||
"Velkommen, {navn}. Jeg stoler på dig. Det plejer at gå galt.",
|
||||
"Der kom {navn}. Endnu et navn til min kommende samling af bortforklaringer.",
|
||||
"{navn}, dejligt. Jeg skriver dig på listen over mistænkeligt hyggelige mennesker.",
|
||||
"[suspicious] {navn} er med. Den tavshed tager jeg som en tilståelse.",
|
||||
"Plads til {navn}. Telefonen er nu officielt et redskab til familieintriger.",
|
||||
"{navn}, du kom præcis i tide til at tage fejl sammen med os.",
|
||||
"[impressed] {navn} fandt ind. Første opgave løst. Desværre giver den ingen point.",
|
||||
"Nu er {navn} her. Så kan vi snart få sandheden nedstemt.",
|
||||
"[suspicious] {navn} er landet. Jeg holder øje med dig."
|
||||
],
|
||||
"lobby_waiting": [
|
||||
"{vip}, når du er klar, kan vi begynde. Jeg har løgne, der skal luftes.",
|
||||
"Hvis vi venter længere, {vip}, bliver mine løgne til gamle familiehistorier.",
|
||||
"{vip}, giv os et tegn. Helst startknappen. Røgsignaler virker dårligt i stuen.",
|
||||
"Jeg er klar, {vip}. Det siger jeg også før møder. Her mener jeg det.",
|
||||
"[whispers] {vip}, jeg har snart brugt min eneste replik om vejret."
|
||||
],
|
||||
"game_start": [
|
||||
"{antal_spillere} spillere og én sandhed. Det bliver en helt almindelig familiefest.",
|
||||
"{antal_spillere} spillere. Gæt sandheden, og nar de andre. Begge dele giver point.",
|
||||
"{antal_spillere} spillere. Tilliden i sofaen skal nu testes med helt frivillige løgne.",
|
||||
"[excited] Vi er i gang! {antal_spillere} spillere, samlet om noget mistænkeligt. Helt lovligt.",
|
||||
"{antal_spillere} spillere. Point for sandheden og gode løgne. Familiefortælleren får endelig lidt anerkendelse."
|
||||
],
|
||||
"round1_intro": [
|
||||
"Første runde. Almindelige point, ualmindeligt tvivlsomme forklaringer.",
|
||||
"Vi begynder med normale point. Se det som en høflig prøve på familiens tillid.",
|
||||
"Runde ét. Pointene er normale. Det er resten af det her sjældent.",
|
||||
"Første runde, normale point. Jeg starter med fuld tillid. Det plejer at være en kort fornøjelse.",
|
||||
"Vi starter stille med normale point. I får rigelig mulighed for at råbe senere."
|
||||
],
|
||||
"round2_intro": [
|
||||
"Nu giver vi dobbelt point. {foerende}, din føring får brug for en sikkerhedssele.",
|
||||
"Runde to, dobbelt point. Nu får selv en lille løgn lidt vokseværk.",
|
||||
"Vi fordobler pointene. {foerende} fører, men sofaen har endnu ikke kronet nogen.",
|
||||
"Dobbelt point! Nu er det dyrere at stole på familie og venner.",
|
||||
"[excited] Runde to giver dobbelt point. Jeg føler mig allerede dobbelt så vigtig."
|
||||
],
|
||||
"final_intro": [
|
||||
"Finale med tredobbelt point. {foerende} fører, men ingen har fået lov at holde takketale.",
|
||||
"{sidst}, nu giver vi tredobbelt point. Tid til at pynte på regnskabet.",
|
||||
"{foerende}, finalen giver tredobbelt point. Føringer tæller bedst, når de holder hele vejen.",
|
||||
"[dramatic] Tredobbelt point i finalen. Nu kan en mavefornemmelse flytte rundt på hele stillingen.",
|
||||
"Sidste runde. Tre gange pointene. Jeg har allerede øvet min overraskede stemme."
|
||||
],
|
||||
"category_choose": [
|
||||
"{navn}, vælg en kategori. Gerne noget, du kan lyve selvsikkert om.",
|
||||
"Valget er dit, {navn}. Hele sofaen får naturligvis lov at brokke sig bagefter.",
|
||||
"Hvad skal vi høre om, {navn}? Du har ordet. Jeg beholder opmærksomheden.",
|
||||
"{navn}, peg på en kategori. Her er mavefornemmelsen faktisk kvalificeret.",
|
||||
"{navn}, hvilken kategori? Du behøver kun vælge én. Gem resten til ønskesedlen."
|
||||
],
|
||||
"category_timeout": [
|
||||
"{navn} tog betænkningstid. Vi tog {kategori}. Så kom der da en beslutning.",
|
||||
"Det blev {kategori}. {navn}, stilhed er åbenbart også en måde at vælge på.",
|
||||
"[sighs] {navn}, tiden løb. Nu hedder kategorien {kategori}. Den kunne heldigvis beslutte sig.",
|
||||
"{kategori} er valgt. {navn}, vi kalder det delegering. Det lyder pænere.",
|
||||
"{navn}, universet har valgt {kategori}. Universet arbejder åbenbart hurtigere end vores familie."
|
||||
],
|
||||
"lie_start": [
|
||||
"Nu gælder det {kategori}. Skriv en løgn, der kunne få sit eget leksikonopslag.",
|
||||
"{kategori}. Skriv en løgn, der føles som noget, man har hørt før.",
|
||||
"Skriv en løgn om {kategori}. Giv den en parkeringsvagts selvtillid.",
|
||||
"{kategori} er emnet. Skriv noget opdigtet. Hunden har lovet at tage skylden.",
|
||||
"Opfind et svar om {kategori}. En lille detalje kan gøre løgnen stor.",
|
||||
"Vi skal digte om {kategori}. Gør gerne løgnen kedelig. Det virker overraskende godt.",
|
||||
"[mischievously] Nu handler det om {kategori}. Skriv en løgn, du kunne sige helt alvorligt.",
|
||||
"Skriv en løgn om {kategori}. Husets dårlige ry kan godt bruge hjælp.",
|
||||
"{kategori}. Opfind et svar, som selv familiens skeptiker kunne købe.",
|
||||
"{kategori}. Skriv en troværdig løgn. Du må gerne rette stavefejl i virkeligheden."
|
||||
],
|
||||
"lie_hurry": [
|
||||
"Ti sekunder. En lille løgn er bedre end en stor tænkepause.",
|
||||
"Ti sekunder tilbage. Afsted med løgnen. Den behøver kun overleve ét spørgsmål.",
|
||||
"Ti sekunder. Det behøver ikke være godt. Bare forkert.",
|
||||
"Ti sekunder! Selv en halvbagt løgn kan serveres med overbevisning.",
|
||||
"Kun ti sekunder. Din indre forfatter må godt springe korrekturen over."
|
||||
],
|
||||
"lie_all_done": [
|
||||
"Alle løgne er afleveret. Sikke vi kan arbejde, når sandheden bliver valgfri.",
|
||||
"Alle har skrevet. Jeg gemmer bunken til næste gang, nogen kalder sig ærlige.",
|
||||
"Nu har vi alle løgnene. Det er som et familiereferat, bare hurtigere.",
|
||||
"[impressed] Alle er færdige. Sikke et samarbejde om at gøre hinanden mistænksomme.",
|
||||
"Alle har løjet. Det gik hurtigere, end jeg havde frygtet."
|
||||
],
|
||||
"lie_was_truth": [
|
||||
"Det var faktisk sandheden. Prøv igen, med lidt mindre ærlighed.",
|
||||
"Sandheden kom ind ad bagdøren. Send den ud igen, og skriv en løgn.",
|
||||
"Nogen skrev sandheden. Det er snyd. Bare den omvendte slags.",
|
||||
"Det svar var sandt. En sjælden tastefejl. Prøv med en løgn.",
|
||||
"Du ramte sandheden. Gem den slags talenter til gættefasen."
|
||||
],
|
||||
"guess_start": [
|
||||
"Én af dem er sand. Held og lykke. I får brug for det.",
|
||||
"Vælg det sande svar. Selvtillid er desværre en dårlig kildehenvisning.",
|
||||
"Nu skal I gætte sandheden. Tænk efter, før familieanekdoterne tager over.",
|
||||
"Hvilket svar er sandt? Lad maven bestemme. Den træffer jo allerede aftensmadsbeslutninger.",
|
||||
"Vælg sandheden. Mine løgne er også med. De er lavet med kærlighed.",
|
||||
"Find det rigtige svar. Virkeligheden har en ret løs opfattelse af, hvad der er rimeligt.",
|
||||
"Ét af svarene passer. Vælg det. Resten sælger vi på næste loppemarked.",
|
||||
"Vælg sandheden. Publikum gætter med. Nu kan endnu flere blande sig.",
|
||||
"Find sandheden, og giv gerne sjove løgne et hjerte. Også bedragere har følelser.",
|
||||
"[suspicious] Ét svar er sandt. Vælg med omhu. De andre har bare øvet sig."
|
||||
],
|
||||
"guess_hurry": [
|
||||
"Fem sekunder. Vælg et svar. Det hjælper sjældent at stirre vredt på telefonen.",
|
||||
"Fem sekunder tilbage. Din mavefornemmelse har nu sidste ord.",
|
||||
"Der er fem sekunder. Tvivlen får desværre ingen point.",
|
||||
"Fem sekunder! Vælg et svar, før din store overvejelse bliver en meget lille aflevering.",
|
||||
"Kun fem sekunder. Sandheden venter. Den er åbenbart bedre opdraget end mig."
|
||||
],
|
||||
"reveal_lie_fooled": [
|
||||
"{forfatter} opfandt {svar}. {ofre} købte den. Kvitteringen er desværre forsvundet.",
|
||||
"{ofre} faldt for {svar}. {forfatter}, din fantasi har nu fået selskab.",
|
||||
"Det var {forfatter}, der fandt på {svar}. {ofre} hoppede lige i.",
|
||||
"[chuckles] {svar} var en løgn fra {forfatter}. Tak for støtten, {ofre}.",
|
||||
"{forfatter} skrev {svar}, og {ofre} tog imod. Meget høfligt. Meget forkert.",
|
||||
"{svar} kom fra {forfatter}. {ofre} lod sig overbevise. Selvtilliden fik åbenbart ordet først.",
|
||||
"{ofre} troede på {svar}. {forfatter} har lige bevist, at en god historie kan springe køen over.",
|
||||
"{forfatter}, {svar} virkede på {ofre}. Den løgn slap godt fra middagsbordet.",
|
||||
"{svar}, sagde {forfatter}. Ja tak, sagde {ofre}. Og sådan fik fantasien sin belønning.",
|
||||
"{svar} var en løgn fra {forfatter}. {ofre}, det bliver mellem os og hele stuen."
|
||||
],
|
||||
"reveal_lie_fooled_many": [
|
||||
"{antal} troede på {svar}. {forfatter}, du har nok til at stifte en forening.",
|
||||
"{forfatter} fik {antal} til at vælge {svar}. Sofaen har dannet fælles front mod fakta.",
|
||||
"[impressed] {svar} narrede {antal}. {forfatter}, jeg bøjer mig. Mest for at gemme min jalousi.",
|
||||
"{antal} hoppede på {svar}. {forfatter}, du kan starte en bølge uden at rejse dig.",
|
||||
"{svar} narrede {antal}. {forfatter}, din fantasi har flere tilhængere end min madplan."
|
||||
],
|
||||
"reveal_lie_shared": [
|
||||
"{forfattere} skrev begge {svar}. To hjerner, én fælles omvej.",
|
||||
"{forfattere} leverede {svar}. To løgnere for én. Det kan tilbudsavisen lide.",
|
||||
"{forfattere} fandt på {svar}. Samme løgn. Fantasien må have en kopimaskine.",
|
||||
"{svar}, fra {forfattere}. En løgn med to underskrifter. Næsten klar til kommunen.",
|
||||
"[suspicious] {forfattere} skrev {svar}. Jeg siger bare, at telepati burde stå i reglerne."
|
||||
],
|
||||
"reveal_house_lie": [
|
||||
"{svar} var min løgn. Tak, {ofre}. Dejligt, at kunst bliver værdsat.",
|
||||
"[mischievously] {ofre} valgte {svar}. Husets løgn. Jeg modtager gerne stående bifald fra sofaen.",
|
||||
"Jeg opfandt {svar}. {ofre} troede på den. Det kalder jeg godt samarbejde.",
|
||||
"{ofre}, {svar} var min. Så ved vi, hvem der bestemmer over rygterne.",
|
||||
"{ofre} købte {svar}. Min opskrift. Tillid er åbenbart den hemmelige ingrediens.",
|
||||
"{svar} kom fra huset. {ofre}, den forklaring kan give næste middag ekstra samtalestof.",
|
||||
"{ofre} troede på {svar}. Jeg skrev den helt uden hjælp fra virkeligheden.",
|
||||
"Min løgn var {svar}. {ofre}, jeg kan mærke min dårlige samvittighed tage fri.",
|
||||
"{svar} snød {ofre}. Den var min. Jeg øver lige mit ydmyge nik.",
|
||||
"Huset opfandt {svar}. {ofre} faldt for den. Så sparede vi penge på specialeffekter."
|
||||
],
|
||||
"reveal_truth_found": [
|
||||
"Sandheden er {sandhed}. {navne} fandt den. Så fik virkeligheden lidt opbakning.",
|
||||
"{navne} valgte sandheden, {sandhed}. Gem lige øjeblikket til næste familiediskussion.",
|
||||
"[impressed] Svaret er {sandhed}. {navne} gennemskuede os. Irriterende godt arbejde.",
|
||||
"{sandhed}, det er sandheden. {navne} ramte rigtigt. Meget upraktisk for løgnerne.",
|
||||
"{sandhed} er svaret. {navne} har tilladelse til ét lille, bedrevidende nik.",
|
||||
"{navne} ramte rigtigt med {sandhed}. Nogen har åbenbart læst bagsiden af mælkekartonen.",
|
||||
"Sandheden var {sandhed}. Godt fanget, {navne}. Den viden fortjente sin egen fest.",
|
||||
"{navne} valgte {sandhed}. Korrekt. Skepsissen kan lige lægge benene op.",
|
||||
"{navne} fandt sandheden, {sandhed}. Mavefornemmelsen har lige fået en lønforhøjelse.",
|
||||
"Svaret er {sandhed}. Point til {navne}. Jeg vidste det selvfølgelig også. Næsten."
|
||||
],
|
||||
"reveal_truth_nobody": [
|
||||
"Svaret var {sandhed}. Ingen fandt det. Vi lader som om, det her var opvarmning.",
|
||||
"Sandheden var {sandhed}. Hele sofaen tog fejl. Sikke en sjælden form for enighed.",
|
||||
"[sighs] Sandheden var {sandhed}. Den stod lige der. Jeg giver virkeligheden et trøstende kram.",
|
||||
"Ingen valgte {sandhed}. Det var ellers sandheden. Den må arbejde lidt på sin troværdighed.",
|
||||
"Sandheden var {sandhed}. Ingen fandt den. Jeg havde ellers væddet min sidste småkage på jer."
|
||||
],
|
||||
"reveal_truth_everyone": [
|
||||
"Svaret var {sandhed}. Alle fandt det. Jeg får åbenbart brug for bedre løgne.",
|
||||
"Sandheden var {sandhed}. Enighed i sofaen. Nogen burde tage et billede.",
|
||||
"[impressed] Alle valgte {sandhed}. Korrekt. Det her bliver svært at forklare som held.",
|
||||
"{sandhed} var sandheden. Alle ramte rigtigt. Jeg havde ellers reserveret tid til en forklaring.",
|
||||
"Alle fandt sandheden, {sandhed}. Pointtavlen bliver lige så spændende som fælles opvask."
|
||||
],
|
||||
"scores_intro": [
|
||||
"{foerende} fører, og {sidst} ligger sidst. Pointtavlen har åbenbart fået holdninger.",
|
||||
"{foerende} fører. Jubel med omtanke. Der skal stadig være plads til alle i sofaen.",
|
||||
"{foerende} øverst, {sidst} nederst. Klager modtages på en serviet efter middagen.",
|
||||
"{foerende} ligger først, {sidst} sidst. Jeg kontrollerede tallene med et ansvarligt nik.",
|
||||
"Pointtavlen er klar. {foerende} fører. Dejligt med tal. De afbryder mig sjældent."
|
||||
],
|
||||
"winner": [
|
||||
"{vinder} vinder med {point} point. Det skal sofaen nok høre om igen.",
|
||||
"[cheering] Tillykke, {vinder}! {point} point. Æren er din. Fjernbetjeningen kræver stadig en forhandling.",
|
||||
"Sejren går til {vinder}, med {point} point. Takketalen må gerne indeholde noget sandt.",
|
||||
"Med {point} point vinder {vinder}. Min oplæring har tydeligvis været fuldstændig afgørende.",
|
||||
"{vinder} vinder med {point} point. Præmiebudgettet gik desværre til mit meget velfortjente honorar."
|
||||
],
|
||||
"award_biggest_liar": [
|
||||
"Største løgner, {navn}. {antal} vellykkede bedrag. Prisen overrækkes med et mistænksomt håndtryk.",
|
||||
"{navn} narrede jer {antal} gange. Tjek lige jeres lommer.",
|
||||
"{navn} narrede andre {antal} gange. Største løgner! Vi springer forklaringen over.",
|
||||
"[impressed] Største løgner er {navn}. {antal} gange blev nogen narret. Din fantasi har fået faste kunder.",
|
||||
"{antal} vellykkede bedrag. Prisen for største løgner går til {navn}. Pokalen er ægte. Tror jeg."
|
||||
],
|
||||
"award_truth_seeker": [
|
||||
"{navn} er sandhedsjæger med {antal} rigtige gæt. En meget irriterende person at diskutere med.",
|
||||
"Flest rigtige gæt, {navn}, med {antal}. Virkeligheden har åbenbart fået sin egen rådgiver.",
|
||||
"Sandhedsprisen går til {navn}. {antal} rigtige gæt. Endelig kom den mærkelige viden til nytte.",
|
||||
"{navn} fandt sandheden {antal} gange. Vi springer høfligt diskussionen om held over.",
|
||||
"{navn} er sandhedsjæger. {antal} rigtige gæt. Den titel har vi faktisk undersøgt, før vi fandt på den."
|
||||
],
|
||||
"award_most_liked": [
|
||||
"Hjerteprisen går til {navn}, med {antal}. Løgn med charme er åbenbart en disciplin.",
|
||||
"{antal} hjerter til {navn}. Sofaens yndling. Blomsterhandleren har lige mistet en kunde.",
|
||||
"Hjerteprisen går til {navn}. Folkets yndlingsløgner. Sæt det ikke på dit CV.",
|
||||
"[cheering] Flest hjerter, {navn}, med {antal}. Vi har målt din morsomhed med tommelfingre.",
|
||||
"{navn} vandt {antal} hjerter. Sjældent har en bunke løgne fået så varm en modtagelse."
|
||||
],
|
||||
"play_again": [
|
||||
"En omgang mere! Vi nulstiller pointene. Mistanken gemmer familien selv.",
|
||||
"I vil spille igen. Det tager jeg som ros og et meget svagt ønske om sandhed.",
|
||||
"[excited] Så tager vi en til. Nye løgne, samme sofa. Den har efterhånden hørt lidt af hvert.",
|
||||
"Vi spiller igen. Jeg henter flere fakta. I kan bare genbruge jeres selvtillid.",
|
||||
"En ny omgang. Jeg er glad for tilliden. Især fordi jeg tydeligvis ikke har fortjent den."
|
||||
]
|
||||
}
|
||||
+6
-1
@@ -63,6 +63,8 @@ class _RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
await self.send_error("bad_request", "Ugyldig besked.")
|
||||
if self.room.game.version != version:
|
||||
self.room.changed()
|
||||
else:
|
||||
self.room.handle_events() # fx "det var sandheden", der afvises men kommenteres
|
||||
|
||||
async def handle(self, action, content, now):
|
||||
raise NotImplementedError
|
||||
@@ -76,10 +78,13 @@ class HostConsumer(_RoomConsumer):
|
||||
return scope_identity(self.scope) is not None
|
||||
|
||||
async def on_open(self):
|
||||
self.room.host_opened()
|
||||
await self.send_state()
|
||||
|
||||
async def send_state(self):
|
||||
await self.send_json({"type": "state", "state": self.room.game.snapshot(time.time(), host=True)})
|
||||
state = self.room.game.snapshot(time.time(), host=True)
|
||||
state["narration"] = self.room.narration
|
||||
await self.send_json({"type": "state", "state": state})
|
||||
|
||||
async def handle(self, action, content, now):
|
||||
g = self.room.game
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import re
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from party import tts
|
||||
from party.narrator import LINES
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Genererer speakerens faste replikker (dem uden pladsholdere) på forhånd, så første spil ikke venter."
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
if not tts.enabled():
|
||||
self.stdout.write("ElevenLabs er ikke sat op (ELEVENLABS_API_KEY/ELEVENLABS_VOICE_ID). Springer over.")
|
||||
return
|
||||
static = [line for variants in LINES.values() for line in variants if not re.search(r"\{\w+\}", line)]
|
||||
ok = sum(1 for line in static if tts.synth_sync(line))
|
||||
self.stdout.write(self.style.SUCCESS(f"{ok} af {len(static)} faste replikker ligger i cachen."))
|
||||
@@ -0,0 +1,127 @@
|
||||
"""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 speakable_prompt(prompt: str) -> str:
|
||||
return re.sub(r"_{3,}", "blank", prompt)
|
||||
|
||||
|
||||
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 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) -> str | None:
|
||||
picked = [names[p] for p in step["guessed_by"] if p in names]
|
||||
if step["audience_votes"]:
|
||||
picked.append("publikum")
|
||||
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))
|
||||
|
||||
# ── 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 == "lie_start":
|
||||
return [self.line("lie_start", kategori=ev["category"]), 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 []
|
||||
+136
@@ -15,10 +15,18 @@ import time
|
||||
|
||||
from fupogfakta.engine import Game
|
||||
|
||||
from . import tts
|
||||
from .narrator import Narrator
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CODE_LETTERS = "BCDFGHJKLMNPQRSTVWXZ"
|
||||
IDLE_TIMEOUT = 3 * 3600
|
||||
NARRATION_KEEP = 12 # så mange seneste replikker følger med i værtens snapshot
|
||||
LOBBY_WAITING_AFTER = 40 # sekunder i lobbyen med nok spillere, før speakeren rykker VIP'en
|
||||
REVEAL_AUDIO_WAIT = 8 # længst vi venter på lyd til afsløringen
|
||||
REVEAL_MIN_STEP_MS = 3500
|
||||
REVEAL_PAD_MS = 900 # luft efter hver replik i afsløringen
|
||||
|
||||
_rooms: dict[str, "Room"] = {}
|
||||
|
||||
@@ -32,6 +40,14 @@ class Room:
|
||||
self.last_activity = time.time()
|
||||
self._timer: asyncio.TimerHandle | None = None
|
||||
self._broadcast_pending = False
|
||||
self.narrator = Narrator()
|
||||
self.narration: list[dict] = [] # [{id, url, ms, at, text}] til værtens storskærm
|
||||
self._narr_seq = 0
|
||||
self._voice_queue: asyncio.Queue | None = None
|
||||
self._side_timers: list[asyncio.TimerHandle] = []
|
||||
self._welcomed = False
|
||||
self._last_join_line = 0.0
|
||||
self._lobby_waiting_said = False
|
||||
|
||||
def touch(self):
|
||||
self.last_activity = time.time()
|
||||
@@ -39,7 +55,11 @@ class Room:
|
||||
def changed(self):
|
||||
"""Kald efter hver ændring: planlæg timer og send nyt snapshot til alle."""
|
||||
self.touch()
|
||||
self.handle_events()
|
||||
self._schedule_timer()
|
||||
self.broadcast()
|
||||
|
||||
def broadcast(self):
|
||||
if not self._broadcast_pending:
|
||||
self._broadcast_pending = True
|
||||
asyncio.get_running_loop().create_task(self._broadcast())
|
||||
@@ -78,6 +98,122 @@ class Room:
|
||||
def close(self):
|
||||
if self._timer:
|
||||
self._timer.cancel()
|
||||
for h in self._side_timers:
|
||||
h.cancel()
|
||||
|
||||
# ── speakeren ────────────────────────────────────────────────────────────
|
||||
def handle_events(self):
|
||||
"""Tøm motorens begivenheder og lad speakeren reagere. Kaldes også efter afviste handlinger."""
|
||||
events = self.game.drain_events()
|
||||
if not tts.enabled():
|
||||
return
|
||||
skip_start = any(e["type"] == "play_again" for e in events)
|
||||
for ev in events:
|
||||
if ev["type"] == "game_start" and skip_start:
|
||||
continue
|
||||
if ev["type"] == "reveal":
|
||||
asyncio.get_running_loop().create_task(self._narrate_reveal())
|
||||
continue
|
||||
if ev["type"] == "join":
|
||||
now = time.time()
|
||||
if now - self._last_join_line < 2.5: # otte på én gang skal ikke alle kommenteres
|
||||
continue
|
||||
self._last_join_line = now
|
||||
self._maybe_schedule_lobby_waiting()
|
||||
for text in self.narrator.for_event(ev, self.game):
|
||||
self.say(text)
|
||||
if ev["type"] == "lie_start":
|
||||
self._at_deadline_minus(10, "lie", "lie_hurry")
|
||||
elif ev["type"] == "guess_start":
|
||||
self._at_deadline_minus(5, "guess", "guess_hurry")
|
||||
|
||||
def host_opened(self):
|
||||
if tts.enabled() and not self._welcomed and self.game.phase == "lobby" and not self.game.players:
|
||||
self._welcomed = True
|
||||
for text in self.narrator.for_event({"type": "host_open"}, self.game):
|
||||
self.say(text)
|
||||
|
||||
def say(self, text: str | None):
|
||||
"""Læg en replik i kø. Lyden genereres med det samme, men udgives i rækkefølge."""
|
||||
if not text:
|
||||
return
|
||||
if self._voice_queue is None:
|
||||
self._voice_queue = asyncio.Queue()
|
||||
asyncio.get_running_loop().create_task(self._voice_worker())
|
||||
task = asyncio.get_running_loop().create_task(tts.synth(text))
|
||||
self._voice_queue.put_nowait((text, task))
|
||||
|
||||
async def _voice_worker(self):
|
||||
while True:
|
||||
text, task = await self._voice_queue.get()
|
||||
audio = await task
|
||||
if audio:
|
||||
self._publish(text, audio)
|
||||
|
||||
def _publish(self, text: str, audio: dict):
|
||||
self._narr_seq += 1
|
||||
self.narration.append({"id": self._narr_seq, "url": audio["url"], "ms": audio["ms"],
|
||||
"at": int(time.time() * 1000), "text": text})
|
||||
del self.narration[:-NARRATION_KEEP]
|
||||
self.broadcast()
|
||||
|
||||
def _at_deadline_minus(self, seconds: int, phase: str, event: str):
|
||||
g = self.game
|
||||
if g.deadline is None:
|
||||
return
|
||||
ident = (g.round_idx, g.q_idx, phase)
|
||||
delay = g.deadline - seconds - time.time()
|
||||
if delay <= 1:
|
||||
return
|
||||
|
||||
def fire():
|
||||
if (g.round_idx, g.q_idx, g.phase) != ident:
|
||||
return
|
||||
if phase == "lie" and all(p.id in g.lies for p in g.players.values() if p.connected):
|
||||
return
|
||||
for text in self.narrator.for_event({"type": event}, g):
|
||||
self.say(text)
|
||||
self._side_timers.append(asyncio.get_running_loop().call_later(delay, fire))
|
||||
self._side_timers = [h for h in self._side_timers if not h.cancelled()][-20:]
|
||||
|
||||
def _maybe_schedule_lobby_waiting(self):
|
||||
g = self.game
|
||||
if self._lobby_waiting_said or g.phase != "lobby" or len(g.players) < 2:
|
||||
return
|
||||
self._lobby_waiting_said = True
|
||||
|
||||
def fire():
|
||||
if g.phase == "lobby" and len(g.players) >= 2:
|
||||
for text in self.narrator.for_event({"type": "lobby_waiting"}, g):
|
||||
self.say(text)
|
||||
self._side_timers.append(asyncio.get_running_loop().call_later(LOBBY_WAITING_AFTER, fire))
|
||||
|
||||
async def _narrate_reveal(self):
|
||||
"""Lav lyd til hvert trin, og lad afsløringens tempo følge replikkerne."""
|
||||
g = self.game
|
||||
ident = (g.round_idx, g.q_idx)
|
||||
g.hold_reveal(time.time(), max_wait=REVEAL_AUDIO_WAIT + 2)
|
||||
self.changed()
|
||||
names = {p.id: p.name for p in g.players.values()}
|
||||
texts = [self.narrator.reveal_step(step, names, len(g.players)) for step in g.reveal_steps]
|
||||
note = (g.question or {}).get("note", "")
|
||||
jobs = [tts.synth(t) if t else asyncio.sleep(0, None) for t in texts]
|
||||
jobs.append(tts.synth(note) if note else asyncio.sleep(0, None))
|
||||
try:
|
||||
results = await asyncio.wait_for(asyncio.gather(*jobs), timeout=REVEAL_AUDIO_WAIT)
|
||||
except asyncio.TimeoutError:
|
||||
results = [None] * len(jobs)
|
||||
if g.phase != "reveal" or (g.round_idx, g.q_idx) != ident or g.reveal_started_at is not None:
|
||||
return # værten sprang over, eller tiden løb ud
|
||||
steps_ms = []
|
||||
for step, text, audio in zip(g.reveal_steps, texts, results[:-1]):
|
||||
step["audio"] = {"url": audio["url"], "ms": audio["ms"], "text": text} if audio else None
|
||||
steps_ms.append(max(REVEAL_MIN_STEP_MS, (audio["ms"] + REVEAL_PAD_MS) if audio else 0))
|
||||
note_audio = results[-1]
|
||||
g.reveal_note_audio = {"url": note_audio["url"], "ms": note_audio["ms"]} if note_audio else None
|
||||
tail = 3000 + (note_audio["ms"] + REVEAL_PAD_MS if note_audio else 0)
|
||||
g.start_reveal_timeline(time.time(), steps_ms, tail)
|
||||
self.changed()
|
||||
|
||||
|
||||
def new_code() -> str:
|
||||
|
||||
@@ -317,6 +317,40 @@
|
||||
}
|
||||
});
|
||||
['pointerdown', 'keydown', 'touchstart'].forEach(function (ev) {
|
||||
window.addEventListener(ev, function () { sound.unlock(); }, { passive: true });
|
||||
window.addEventListener(ev, function () { sound.unlock(); voice.unblock(); }, { passive: true });
|
||||
});
|
||||
|
||||
/* ---------- speakeren (lydfiler fra serveren, én ad gangen) ---------- */
|
||||
var voice = (WPP.voice = {
|
||||
queue: [], current: null, blocked: false, onchange: null,
|
||||
enqueue: function (url) { if (!url || sound.muted) return; voice.queue.push(url); if (!voice.current) voice._next(); },
|
||||
now: function (urls) { voice.stop(); (urls || []).forEach(function (u) { if (u && !sound.muted) voice.queue.push(u); }); voice._next(); },
|
||||
stop: function () {
|
||||
voice.queue = [];
|
||||
if (voice.current) { try { voice.current.pause(); } catch (e) {} voice.current = null; }
|
||||
},
|
||||
unblock: function () {
|
||||
if (!voice.blocked) return;
|
||||
voice.blocked = false;
|
||||
if (voice.onchange) voice.onchange();
|
||||
if (!voice.current) voice._next();
|
||||
},
|
||||
_next: function () {
|
||||
var url = voice.queue.shift();
|
||||
if (!url) { voice.current = null; return; }
|
||||
var a = new Audio(url);
|
||||
voice.current = a;
|
||||
a.onended = a.onerror = function () { if (voice.current === a) voice._next(); };
|
||||
var p = a.play();
|
||||
if (p && p.catch) p.catch(function (e) {
|
||||
if (voice.current !== a) return;
|
||||
voice.current = null;
|
||||
if (e && e.name === 'NotAllowedError') { // browseren kræver et klik før lyd
|
||||
voice.queue.unshift(url);
|
||||
voice.blocked = true;
|
||||
if (voice.onchange) voice.onchange();
|
||||
} else voice._next();
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
+49
-11
@@ -24,24 +24,27 @@
|
||||
return (state.players || []).slice().sort(function (a, b) { return (b.score || 0) - (a.score || 0); });
|
||||
}
|
||||
|
||||
/* Reveal-tidslinje beregnes fra snapshot: start = deadline − (trin × step_ms + 3 s) */
|
||||
/* Reveal-tidslinje: serveren sender started_at og en længde pr. trin (speakerens replik styrer tempoet).
|
||||
started_at = null betyder, at serveren stadig laver lyd: vis en kort "trommehvirvel". */
|
||||
function revealProgress(state, u) {
|
||||
var r = state.reveal || {};
|
||||
var steps = r.steps || [];
|
||||
var ms = r.step_ms || 4000;
|
||||
var total = steps.length * ms + 3000;
|
||||
var start;
|
||||
var key = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + ((state.question || {}).prompt || '');
|
||||
if (state.deadline) start = state.deadline - total;
|
||||
if (state.reveal && r.started_at === null) return { pending: true, done: false, i: 0, stage: -1, n: steps.length, key: key + '|pending' };
|
||||
var durs = steps.map(function (s, i) { return (r.steps_ms && r.steps_ms[i]) || r.step_ms || 4000; });
|
||||
var total = durs.reduce(function (a, b) { return a + b; }, 0) + (r.tail_ms == null ? 3000 : r.tail_ms);
|
||||
var start;
|
||||
if (r.started_at) start = r.started_at;
|
||||
else if (state.deadline) start = state.deadline - total;
|
||||
else {
|
||||
if (!u.revealSeen[key]) u.revealSeen[key] = WPP.now();
|
||||
start = u.revealSeen[key];
|
||||
}
|
||||
var el = Math.max(0, WPP.now() - start);
|
||||
var i = Math.floor(el / ms);
|
||||
var el = Math.max(0, WPP.now() - start), i = 0;
|
||||
while (i < steps.length && el >= durs[i]) { el -= durs[i]; i++; }
|
||||
if (i >= steps.length) return { done: true, i: steps.length, n: steps.length, key: key + '|done' };
|
||||
var f = (el % ms) / ms;
|
||||
var stage = f < 0.28 ? 0 : f < 0.55 ? 1 : 2;
|
||||
/* svaret først, så hvem der valgte det, så stemplet (faste tider, så lange replikker ikke forsinker stemplet) */
|
||||
var stage = el < Math.min(1100, durs[i] * 0.28) ? 0 : el < Math.min(2300, durs[i] * 0.55) ? 1 : 2;
|
||||
return { done: false, i: i, stage: stage, n: steps.length, key: key + '|' + i + '.' + stage };
|
||||
}
|
||||
|
||||
@@ -75,11 +78,12 @@
|
||||
}).join('');
|
||||
}
|
||||
var skip = TIMED[state.phase] ? '<button class="hbtn" data-act="skip" title="Afslut fasen nu">Spring over »</button>' : '';
|
||||
var mute = '<button class="hbtn ghost" data-act="mute" aria-pressed="' + (WPP.sound.muted ? 'true' : 'false') + '">' +
|
||||
var mute = (WPP.voice.blocked ? '<button class="hbtn voice-blocked" data-act="unblock">Klik for at høre speakeren</button>' : '') +
|
||||
'<button class="hbtn ghost" data-act="mute" aria-pressed="' + (WPP.sound.muted ? 'true' : 'false') + '">' +
|
||||
(WPP.sound.muted ? 'Lyd fra' : 'Lyd til') + '</button>';
|
||||
return '<footer class="hs-foot" data-k="foot" data-patch>' +
|
||||
'<div class="pchips" data-k="chips:' + esc(chipsHtml) + '">' + chipsHtml + '</div>' +
|
||||
'<div class="ctl" data-k="ctl:' + state.phase + (WPP.sound.muted ? 1 : 0) + '">' + mute + skip + '</div></footer>';
|
||||
'<div class="ctl" data-k="ctl:' + state.phase + (WPP.sound.muted ? 1 : 0) + (WPP.voice.blocked ? 'b' : '') + '">' + mute + skip + '</div></footer>';
|
||||
}
|
||||
|
||||
/* ---------- faser ---------- */
|
||||
@@ -163,6 +167,10 @@
|
||||
var steps = r.steps || [];
|
||||
var players = WPP.playerMap(state);
|
||||
var pr = revealProgress(state, u);
|
||||
if (pr.pending) {
|
||||
return questionBlock(state, 'small rv', '') +
|
||||
'<section class="rv-wait" data-k="rvwait"><div class="drum">Lad os se, hvem der narrede hvem…</div></section>';
|
||||
}
|
||||
var truth = steps.filter(function (s) { return s.is_truth; })[0];
|
||||
var truthShown = truth && (pr.done || (steps[pr.i] === truth && pr.stage >= 2));
|
||||
var out = questionBlock(state, 'small rv', truthShown ? truth.text : '');
|
||||
@@ -273,9 +281,11 @@
|
||||
WPP.sound.unlock();
|
||||
if (a === 'mute') {
|
||||
WPP.sound.setMuted(!WPP.sound.muted);
|
||||
if (WPP.sound.muted) WPP.voice.stop();
|
||||
if (u.state) renderHost(root, u.state, ctx);
|
||||
return;
|
||||
}
|
||||
if (a === 'unblock') { WPP.voice.unblock(); if (u.state) renderHost(root, u.state, ctx); return; }
|
||||
if (!ctx.send) return;
|
||||
if (a === 'kick') {
|
||||
var nm = b.getAttribute('data-name') || '';
|
||||
@@ -296,6 +306,17 @@
|
||||
if (!s || !root.isConnected) return;
|
||||
if (s.phase === 'reveal') {
|
||||
var pr = revealProgress(s, u);
|
||||
/* speakeren: hvert trins replik starter med trinnet, forklaringen efter sandheden */
|
||||
if (!pr.done && !pr.pending && !(u.ctx && u.ctx.harness)) {
|
||||
var vkey = pr.key.split('|')[0] + '|' + pr.i;
|
||||
if (u.voiceStep !== vkey) {
|
||||
u.voiceStep = vkey;
|
||||
var stp = (s.reveal.steps || [])[pr.i] || {};
|
||||
var urls = [stp.audio && stp.audio.url];
|
||||
if (stp.is_truth && s.reveal.note_audio) urls.push(s.reveal.note_audio.url);
|
||||
if (urls[0] || urls[1]) WPP.voice.now(urls);
|
||||
}
|
||||
}
|
||||
if (pr.key !== u.revealKey) {
|
||||
var prev = u.revealKey;
|
||||
renderHost(root, s, u.ctx);
|
||||
@@ -314,6 +335,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Speakerens replikker uden for afsløringen: afspil nye i rækkefølge. Ved første snapshot
|
||||
(fx genindlæsning) afspilles kun det, der er sagt de sidste par sekunder. */
|
||||
function queueNarration(u, state, ctx) {
|
||||
var items = state.narration;
|
||||
if (!items || ctx.harness) return;
|
||||
if (u.narrLast == null) {
|
||||
var cutoff = (state.server_time || 0) - 4000;
|
||||
u.narrLast = 0;
|
||||
items.forEach(function (n) { if (n.at < cutoff) u.narrLast = Math.max(u.narrLast, n.id); });
|
||||
}
|
||||
items.forEach(function (n) {
|
||||
if (n.id > u.narrLast) { u.narrLast = n.id; WPP.voice.enqueue(n.url); }
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- offentlig render ---------- */
|
||||
function renderHost(root, state, ctx) {
|
||||
ctx = ctx || {};
|
||||
@@ -321,6 +357,7 @@
|
||||
var u = ui(root);
|
||||
u.state = state; u.ctx = ctx;
|
||||
bind(root);
|
||||
queueNarration(u, state, ctx);
|
||||
if (!root._ticking) { root._ticking = true; WPP.onTick(function () { hostTick(root); }); }
|
||||
root.classList.add('host-root');
|
||||
var sh = WPP.shell(root);
|
||||
@@ -350,6 +387,7 @@
|
||||
var conn = null;
|
||||
var ctx = { send: function (m) { return conn ? conn.send(m) : false; } };
|
||||
renderHost(root, { phase: 'lobby', code: code, players: [], audience_count: 0, min_players: 2, max_players: 8, join_url: location.origin + '/?code=' + code }, ctx);
|
||||
WPP.voice.onchange = function () { var u = ui(root); if (u.state) renderHost(root, u.state, u.ctx); };
|
||||
conn = WPP.connect('/ws/host/' + encodeURIComponent(code) + '/', {
|
||||
root: root,
|
||||
onMessage: function (msg) {
|
||||
|
||||
@@ -498,3 +498,9 @@ button, input { font-family: inherit; }
|
||||
|
||||
/* forklaringen under sandheden */
|
||||
.rv-res.truth .note { margin: 18px auto 0; max-width: 1100px; font-size: clamp(18px, 1.6vw, 30px); line-height: 1.35; opacity: .85; font-weight: 500; }
|
||||
|
||||
/* speakeren */
|
||||
.rv-wait { display: grid; place-items: center; min-height: 40vh; }
|
||||
.rv-wait .drum { font-size: clamp(28px, 3.2vw, 56px); font-weight: 800; opacity: .9; animation: drumpulse .5s ease-in-out infinite alternate; }
|
||||
@keyframes drumpulse { from { transform: scale(1); } to { transform: scale(1.04); } }
|
||||
.hbtn.voice-blocked { background: #ffd166; color: #1b1035; font-weight: 800; animation: drumpulse .7s ease-in-out infinite alternate; }
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import asyncio
|
||||
import random
|
||||
from unittest import mock
|
||||
|
||||
from channels.testing.websocket import WebsocketCommunicator
|
||||
from django.test import SimpleTestCase, TransactionTestCase, override_settings
|
||||
|
||||
from fupogfakta import engine
|
||||
from fupogfakta.engine import Game
|
||||
from party import rooms, tts
|
||||
from party.narrator import Narrator, join_names, number_words, safe, speakable_prompt
|
||||
from partyhub.asgi import application
|
||||
|
||||
from .test_ws import HOST_HEADERS, make_bank, recv_until
|
||||
|
||||
|
||||
class NarratorTests(SimpleTestCase):
|
||||
def test_number_words(self):
|
||||
cases = {0: "nul", 1: "én", 3: "tre", 21: "enogtyve", 47: "syvogfyrre", 100: "et hundrede",
|
||||
1500: "et tusind fem hundrede", 10500: "ti tusind fem hundrede", 2000: "to tusind"}
|
||||
for n, words in cases.items():
|
||||
self.assertEqual(number_words(n), words)
|
||||
|
||||
def test_names_and_safety(self):
|
||||
self.assertEqual(join_names(["A"]), "A")
|
||||
self.assertEqual(join_names(["A", "B", "C"]), "A, B og C")
|
||||
self.assertEqual(safe("Bo[laughs]"), "Bolaughs")
|
||||
self.assertEqual(speakable_prompt("En flok _____ kaldes."), "En flok blank kaldes.")
|
||||
|
||||
def test_lines_are_filled_and_vary(self):
|
||||
n = Narrator(random.Random(1))
|
||||
seen = {n.line("lobby_join", navn="Sus") for _ in range(6)}
|
||||
self.assertGreater(len(seen), 3)
|
||||
self.assertTrue(all("Sus" in s and "{" not in s for s in seen))
|
||||
self.assertNotIn("{", n.line("winner", vinder="Bo", point=10500))
|
||||
|
||||
def test_reveal_step_choice(self):
|
||||
n = Narrator(random.Random(2))
|
||||
names = {"p1": "Asger", "p2": "Sus", "p3": "Jørn"}
|
||||
lie = {"text": "katte", "is_truth": False, "house": False, "authors": ["p1"], "guessed_by": ["p2"], "audience_votes": 0}
|
||||
self.assertIn("Asger", n.reveal_step(lie, names, 3))
|
||||
house = dict(lie, authors=[], house=True)
|
||||
self.assertIn("katte", n.reveal_step(house, names, 3))
|
||||
nobody = {"text": "ugler", "is_truth": True, "house": False, "authors": [], "guessed_by": [], "audience_votes": 0}
|
||||
self.assertIn("ugler", n.reveal_step(nobody, names, 3))
|
||||
unpicked = dict(lie, guessed_by=[])
|
||||
self.assertIsNone(n.reveal_step(unpicked, names, 3))
|
||||
|
||||
|
||||
class EngineEventTests(SimpleTestCase):
|
||||
def test_events_and_reveal_hold(self):
|
||||
from fupogfakta.tests.test_engine import bank
|
||||
g = Game("TEST", bank(), rng=random.Random(3))
|
||||
a, b = g.join("A"), g.join("B")
|
||||
g.start(None, 0)
|
||||
types = [e["type"] for e in g.drain_events()]
|
||||
self.assertEqual(types[:4], ["join", "join", "game_start", "round_intro"])
|
||||
self.assertIn("category", types)
|
||||
g.tick(100) # kategorien vælges af tiden
|
||||
self.assertEqual([e["type"] for e in g.drain_events()], ["category_timeout", "lie_start"])
|
||||
g.submit_lie(a.id, "katte", 100)
|
||||
with self.assertRaises(engine.GameError):
|
||||
g.submit_lie(b.id, g.question["answer"], 100)
|
||||
self.assertEqual([e["type"] for e in g.drain_events()], ["lie_was_truth"])
|
||||
g.submit_lie(b.id, "hunde", 100)
|
||||
truth = next(x.id for x in g.answers if x.is_truth)
|
||||
g.guess(a.id, truth, 100)
|
||||
g.guess(b.id, truth, 100)
|
||||
self.assertEqual(g.phase, "reveal")
|
||||
g.hold_reveal(100, max_wait=5)
|
||||
self.assertIsNone(g.snapshot(100, host=True)["reveal"]["started_at"])
|
||||
g.tick(106) # lyden kom aldrig: afsløringen starter alligevel
|
||||
self.assertEqual(g.phase, "reveal")
|
||||
self.assertIsNotNone(g.reveal_started_at)
|
||||
g.start_reveal_timeline(106, [5000] * len(g.reveal_steps), 4000)
|
||||
self.assertAlmostEqual(g.deadline, 106 + (5 * len(g.reveal_steps) + 4))
|
||||
|
||||
|
||||
async def fake_synth(text):
|
||||
return {"url": f"/tts/{abs(hash(text)) % 10**32:032d}.mp3", "ms": 2000 + len(text) * 10}
|
||||
|
||||
|
||||
@override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None)
|
||||
class NarrationOverWebSocketTests(TransactionTestCase):
|
||||
def setUp(self):
|
||||
rooms.all_rooms().clear()
|
||||
make_bank()
|
||||
|
||||
async def test_host_gets_narration_and_reveal_audio(self):
|
||||
from asgiref.sync import sync_to_async
|
||||
from fupogfakta.models import load_game_questions
|
||||
room = rooms.create_room(await sync_to_async(load_game_questions)(), "agw", "http://testserver")
|
||||
with mock.patch.object(tts, "enabled", return_value=True), mock.patch.object(tts, "synth", fake_synth):
|
||||
host = WebsocketCommunicator(application, f"/ws/host/{room.code}/", headers=HOST_HEADERS)
|
||||
host.scope["client"] = ("127.0.0.1", 5000)
|
||||
await host.connect()
|
||||
phones = []
|
||||
for name in ["Asger", "Sus"]:
|
||||
c = WebsocketCommunicator(application, f"/ws/play/{room.code}/")
|
||||
await c.connect()
|
||||
await c.send_json_to({"action": "join", "name": name})
|
||||
await recv_until(c, "joined")
|
||||
phones.append(c)
|
||||
await phones[0].send_json_to({"action": "start"})
|
||||
st = (await recv_until(host, "state", "category"))["state"]
|
||||
chooser = phones[0] if st["category"]["chooser_id"] == "p1" else phones[1]
|
||||
await chooser.send_json_to({"action": "choose_category", "category_id": st["category"]["options"][0]["id"]})
|
||||
for i, c in enumerate(phones):
|
||||
await recv_until(c, "state", "lie")
|
||||
await c.send_json_to({"action": "submit_lie", "text": f"løgn {i}"})
|
||||
for c in phones:
|
||||
mine = (await recv_until(c, "state", "guess"))["state"]
|
||||
await c.send_json_to({"action": "guess", "answer_id": mine["answers"][0]["id"]})
|
||||
# Først venter afsløringen på lyden, så starter den med en længde pr. trin.
|
||||
for _ in range(30):
|
||||
st = (await recv_until(host, "state", "reveal"))["state"]
|
||||
if st["reveal"]["started_at"]:
|
||||
break
|
||||
r = st["reveal"]
|
||||
self.assertTrue(all(s.get("audio") for s in r["steps"]))
|
||||
self.assertEqual(len(r["steps_ms"]), len(r["steps"]))
|
||||
self.assertGreaterEqual(min(r["steps_ms"]), 3500)
|
||||
texts = [n["text"] for n in st["narration"]]
|
||||
self.assertTrue(any("Asger" in t or "Sus" in t for t in texts), texts) # join/kategori-replik
|
||||
self.assertTrue(any("blank" in t for t in texts), texts) # spørgsmålet blev læst op
|
||||
for c in phones:
|
||||
await c.disconnect()
|
||||
await host.disconnect()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Oplæsning med ElevenLabs, cachet på disk.
|
||||
|
||||
Samme tekst med samme stemme og model genereres kun én gang: filen hedder en
|
||||
hash af (model, stemme, tekst) og serveres fra /tts/<hash>.mp3. Fejler
|
||||
ElevenLabs, returneres None, og spillet fortsætter uden stemme.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
API = "https://api.elevenlabs.io/v1/text-to-speech/{voice}?output_format=mp3_44100_128"
|
||||
BITRATE = 128_000
|
||||
_inflight: dict[str, asyncio.Future] = {}
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(settings.ELEVENLABS_API_KEY and settings.ELEVENLABS_VOICE_ID)
|
||||
|
||||
|
||||
def _digest(text: str) -> str:
|
||||
raw = f"{settings.ELEVENLABS_MODEL}|{settings.ELEVENLABS_VOICE_ID}|{text}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
def cache_path(digest: str) -> Path:
|
||||
return Path(settings.TTS_CACHE_DIR) / f"{digest}.mp3"
|
||||
|
||||
|
||||
def _result(digest: str) -> dict:
|
||||
size = cache_path(digest).stat().st_size
|
||||
return {"url": f"/tts/{digest}.mp3", "ms": int(size * 8 * 1000 / BITRATE)}
|
||||
|
||||
|
||||
def _fetch(text: str, path: Path) -> None:
|
||||
body = json.dumps({"text": text, "model_id": settings.ELEVENLABS_MODEL, "language_code": "da"}).encode()
|
||||
req = urllib.request.Request(API.format(voice=settings.ELEVENLABS_VOICE_ID), data=body, headers={
|
||||
"xi-api-key": settings.ELEVENLABS_API_KEY, "Content-Type": "application/json", "Accept": "audio/mpeg"})
|
||||
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||
data = resp.read()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".part")
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
def synth_sync(text: str) -> dict | None:
|
||||
"""Til management-kommandoer: generér (eller find i cache) synkront."""
|
||||
if not enabled() or not text.strip():
|
||||
return None
|
||||
digest = _digest(text)
|
||||
if not cache_path(digest).exists():
|
||||
try:
|
||||
_fetch(text, cache_path(digest))
|
||||
except Exception as e: # noqa: BLE001 — stemmen må aldrig vælte spillet
|
||||
log.warning("TTS fejlede for %r: %s", text[:60], _describe(e))
|
||||
return None
|
||||
return _result(digest)
|
||||
|
||||
|
||||
async def synth(text: str) -> dict | None:
|
||||
if not enabled() or not text.strip():
|
||||
return None
|
||||
digest = _digest(text)
|
||||
if cache_path(digest).exists():
|
||||
return _result(digest)
|
||||
fut = _inflight.get(digest)
|
||||
if fut is None:
|
||||
fut = asyncio.get_running_loop().create_task(asyncio.to_thread(_fetch, text, cache_path(digest)))
|
||||
_inflight[digest] = fut
|
||||
fut.add_done_callback(lambda _f: _inflight.pop(digest, None))
|
||||
try:
|
||||
await asyncio.shield(fut)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("TTS fejlede for %r: %s", text[:60], _describe(e))
|
||||
return None
|
||||
return _result(digest)
|
||||
|
||||
|
||||
def _describe(e: Exception) -> str:
|
||||
if isinstance(e, urllib.error.HTTPError):
|
||||
try:
|
||||
return f"HTTP {e.code}: {e.read()[:200].decode('utf-8', 'replace')}"
|
||||
except Exception: # noqa: BLE001
|
||||
return f"HTTP {e.code}"
|
||||
return repr(e)
|
||||
@@ -10,5 +10,6 @@ urlpatterns = [
|
||||
path("host/new", views.host_new, name="host_new"),
|
||||
path("host/<str:code>/", views.host, name="host"),
|
||||
path("qr/<str:code>.svg", views.qr, name="qr"),
|
||||
path("tts/<str:digest>.mp3", views.tts_audio, name="tts"),
|
||||
path("healthz", views.healthz, name="healthz"),
|
||||
]
|
||||
|
||||
+12
-1
@@ -4,12 +4,13 @@ import re
|
||||
import segno
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.conf import settings
|
||||
from django.http import Http404, HttpResponse, HttpResponseForbidden
|
||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from fupogfakta.models import load_game_questions
|
||||
|
||||
from . import tts
|
||||
from .rooms import create_room, get_room
|
||||
|
||||
CODE_RE = re.compile(r"^[A-Za-z]{4}$")
|
||||
@@ -66,3 +67,13 @@ def qr(request, code):
|
||||
@require_GET
|
||||
def healthz(request):
|
||||
return HttpResponse("ok", content_type="text/plain")
|
||||
|
||||
|
||||
@require_GET
|
||||
def tts_audio(request, digest):
|
||||
path = tts.cache_path(digest)
|
||||
if not re.fullmatch(r"[0-9a-f]{32}", digest) or not path.exists():
|
||||
raise Http404
|
||||
resp = FileResponse(path.open("rb"), content_type="audio/mpeg")
|
||||
resp["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return resp
|
||||
|
||||
@@ -26,6 +26,12 @@ SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = PUBLIC_URL.startswith("https://")
|
||||
# Headerne stoles kun på, når forespørgslen kommer fra disse adresser.
|
||||
TRUSTED_PROXIES = [h for h in env("TRUSTED_PROXIES", "127.0.0.1").split(",") if h]
|
||||
HOST_ADMIN_GROUP = env("HOST_ADMIN_GROUP", "authentik Admins")
|
||||
# Speaker (ElevenLabs). Uden nøgle kører spillet uden stemme.
|
||||
ELEVENLABS_API_KEY = env("ELEVENLABS_API_KEY", "")
|
||||
ELEVENLABS_VOICE_ID = env("ELEVENLABS_VOICE_ID", "")
|
||||
ELEVENLABS_MODEL = env("ELEVENLABS_MODEL", "eleven_v4")
|
||||
TTS_CACHE_DIR = env("TTS_CACHE_DIR", str(BASE_DIR / "tts-cache"))
|
||||
|
||||
# Kun til lokal udvikling: lader som om denne bruger er logget ind som vært.
|
||||
DEV_HOST_USER = env("DEV_HOST_USER") if DEBUG else None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user