Ny spørgsmålspakke "fest" (aldrig med i "Alle"): 43 faktatjekkede spørgsmål om dansk pop i to kategorier, Fest: retro og Fest: nu. Et fest-spil tæller tår ved siden af point: narret giver en tår, ingen fandt sandheden giver alle en tår, gange rundens faktor (finalen er en shot). Afsløringen, opsummeringen, stillingen og telefonen viser tårene, og der er en pris til aftenens tørstigste. Speakeren bruger zoomer-replikker (<begivenhed>_fest), egne regler og FEST_VOICE_ID til alt. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
+4
-3
@@ -24,13 +24,13 @@ import websockets
|
||||
HOST_HEADERS = {"X-authentik-username": "bot-host", "X-authentik-groups": "Familie"}
|
||||
|
||||
|
||||
def create_room(base: str) -> str:
|
||||
def create_room(base: str, pack: str = "alle") -> str:
|
||||
# CSRF-cookien er Secure i drift; over http til localhost sender vi den selv tilbage.
|
||||
resp = urllib.request.urlopen(urllib.request.Request(f"{base}/host/", headers=HOST_HEADERS))
|
||||
html = resp.read().decode()
|
||||
cookie = re.search(r"csrftoken=([^;]+)", resp.headers.get("Set-Cookie", "")).group(1)
|
||||
token = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', html).group(1)
|
||||
data = urllib.parse.urlencode({"csrfmiddlewaretoken": token}).encode()
|
||||
data = urllib.parse.urlencode({"csrfmiddlewaretoken": token, "pack": pack}).encode()
|
||||
|
||||
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *a, **k):
|
||||
@@ -134,12 +134,13 @@ async def main():
|
||||
ap.add_argument("--players", type=int, default=3)
|
||||
ap.add_argument("--audience", type=int, default=1)
|
||||
ap.add_argument("--seed", type=int, default=None)
|
||||
ap.add_argument("--pack", default="alle", help="spørgsmålspakke (alle, familie, nørd, arbejde, fest)")
|
||||
ap.add_argument("--code", help="join et eksisterende rum i stedet for at oprette et (du er selv vært/VIP)")
|
||||
args = ap.parse_args()
|
||||
rng = random.Random(args.seed)
|
||||
if args.code:
|
||||
return await join_existing(args, rng)
|
||||
code = create_room(args.base)
|
||||
code = create_room(args.base, args.pack)
|
||||
print(f"Rum {code}")
|
||||
ws_base = args.base.replace("http", "ws", 1)
|
||||
seen, done = [], asyncio.Event()
|
||||
|
||||
@@ -184,3 +184,14 @@ Rækkefølge: `category → intro → lie → guess → reveal (→ scores)`; i
|
||||
- "Lyv for mig": bruger man forslaget uændret, får man kun halve point for at narre andre (forfatteren står i `assisted`).
|
||||
- Publikum: forfatteren får `round(500 × multiplikator × andel af publikum narret)`.
|
||||
- Spil igen: samme spillere, point nulstilles.
|
||||
|
||||
## Fest-spil (pakken "fest", 18+)
|
||||
|
||||
Oprettes med `pack=fest` på `/host/new`. Snapshot har altid `fest` (bool). I et fest-spil desuden:
|
||||
|
||||
- `players[].sips`: tår i alt.
|
||||
- `reveal.steps[].sips`: `[{player_id, n}]`; `n` = rundens gange (×1/×2/×3, finalen vises som shots). `everyone_drinks: true` på sandhedens trin, når ingen fandt den.
|
||||
- `reveal.sip_deltas`: `[{player_id, n}]` for spørgsmålet (opsummeringen tæller tår op i stedet for point).
|
||||
- `me.sips`, `me.last_sips` (sidstnævnte kun i `reveal`).
|
||||
- `final.awards` får `kind: "sips"` ("Tørstigste").
|
||||
- Reglerne (`rules.items`) er fest-udgaven, og speakeren bruger `<begivenhed>_fest`-replikker og `FEST_VOICE_ID`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+52
-7
@@ -66,6 +66,20 @@ RULES = [
|
||||
]
|
||||
RULES_STEP_MS = 5000
|
||||
|
||||
# Fest (18+): samme spil, men det handler om tår. Narret = drik; ingen fandt sandheden = alle drikker.
|
||||
# Antal tårer følger rundens gange (×1/×2/×3), og finalen kaldes en shot.
|
||||
RULES_FEST = [
|
||||
{"icon": "🤥", "title": "Lyv som en influencer", "body": "Et sandt faktum om dansk pop mangler et ord. Skriv en løgn, der lyder sand.",
|
||||
"say": "Yo, bestie, velkommen til festen. I får noget ægte dansk pop-sladder, men der mangler et ord. "
|
||||
"Jeres job? At lyve så hårdt, at de andre hopper lige i det. Ingen cap."},
|
||||
{"icon": "🥃", "title": "Narret? Drik!", "body": "Falder du for en løgn, tager du en tår. Point er sekundært.",
|
||||
"say": "Og nu kommer det vigtige. Falder du for en løgn, så tager du en tår. Det er hele pointen. Point er lowkey ligegyldige."},
|
||||
{"icon": "🍻", "title": "Ingen fandt sandheden?", "body": "Så drikker alle. Sådan er reglerne.",
|
||||
"say": "Finder ingen af jer sandheden, så drikker alle sammen. Det er bare sådan, det er."},
|
||||
{"icon": "🔥", "title": "Det eskalerer", "body": "Runde 2 er to tårer. Finalen er en shot.",
|
||||
"say": "Runde to er to tårer, og finalen er en hel shot. Drik med omtanke, og drik vand imellem. Lad os gå!"},
|
||||
]
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Kategorinavn til filnavn: "Mad og drikke" -> "mad_og_drikke" (bruges til kategoriens lyd)."""
|
||||
@@ -119,6 +133,8 @@ class Player:
|
||||
fooled: int = 0 # antal gange andre valgte spillerens løgn
|
||||
correct: int = 0
|
||||
likes: int = 0
|
||||
sips: int = 0 # fest: tår i alt
|
||||
last_sips: int | None = None
|
||||
round_delta: int = 0
|
||||
last_delta: int | None = None
|
||||
|
||||
@@ -135,8 +151,10 @@ class Answer:
|
||||
class Game:
|
||||
show_rules = True # reglerne før rummets første spil (testene slår dem fra)
|
||||
|
||||
def __init__(self, code: str, questions: list[dict], join_url: str = "", rng: random.Random | None = None):
|
||||
def __init__(self, code: str, questions: list[dict], join_url: str = "", rng: random.Random | None = None,
|
||||
fest: bool = False):
|
||||
self.code = code
|
||||
self.fest = fest
|
||||
self.join_url = join_url
|
||||
self.rng = rng or random.Random()
|
||||
self.questions = list(questions)
|
||||
@@ -326,8 +344,12 @@ class Game:
|
||||
return
|
||||
self._begin_question(now)
|
||||
|
||||
@property
|
||||
def rules(self) -> list[dict]:
|
||||
return RULES_FEST if self.fest else RULES
|
||||
|
||||
def start_rules_timeline(self, now: float, steps_ms: list[int] | None = None):
|
||||
self.rules_steps_ms = list(steps_ms or [RULES_STEP_MS] * len(RULES))
|
||||
self.rules_steps_ms = list(steps_ms or [RULES_STEP_MS] * len(self.rules))
|
||||
self.rules_started_at = now
|
||||
self.deadline = now + sum(self.rules_steps_ms) / 1000 + 1
|
||||
self._changed()
|
||||
@@ -362,8 +384,8 @@ class Game:
|
||||
|
||||
def _reset_scores(self):
|
||||
for p in self.players.values():
|
||||
p.score = p.fooled = p.correct = p.likes = p.round_delta = 0
|
||||
p.last_delta = None
|
||||
p.score = p.fooled = p.correct = p.likes = p.round_delta = p.sips = 0
|
||||
p.last_delta = p.last_sips = None
|
||||
self.final_result = None
|
||||
|
||||
def _available_categories(self) -> dict:
|
||||
@@ -448,6 +470,7 @@ class Game:
|
||||
self.reveal_steps = []
|
||||
for p in self.players.values():
|
||||
p.last_delta = None
|
||||
p.last_sips = None
|
||||
self.reveal_started_at = None
|
||||
self.reveal_note_audio = None
|
||||
self.phase = "intro"
|
||||
@@ -673,6 +696,14 @@ class Game:
|
||||
p.last_delta = (p.last_delta or 0) + delta
|
||||
step["points"].append({"player_id": pid, "delta": delta})
|
||||
|
||||
def _drink(self, pid: str, n: int, step: dict):
|
||||
p = self.players.get(pid)
|
||||
if not p or n <= 0:
|
||||
return
|
||||
p.sips += n
|
||||
p.last_sips = (p.last_sips or 0) + n
|
||||
step["sips"].append({"player_id": pid, "n": n})
|
||||
|
||||
def _start_reveal(self, now: float):
|
||||
m = self.multiplier
|
||||
audience_total = len(self.audience_votes)
|
||||
@@ -684,13 +715,20 @@ class Game:
|
||||
continue
|
||||
step = {"answer_id": a.id, "text": a.text, "is_truth": a.is_truth, "house": a.house,
|
||||
"authors": list(a.authors), "guessed_by": picked_by, "audience_votes": votes,
|
||||
"likes": len(self.likes.get(a.id, ())), "points": [],
|
||||
"likes": len(self.likes.get(a.id, ())), "points": [], "sips": [],
|
||||
"assisted": [x for x in a.authors if x in self.assisted]}
|
||||
if a.is_truth:
|
||||
for pid in picked_by:
|
||||
self.players[pid].correct += 1
|
||||
self._award(pid, POINTS_CORRECT * m, step)
|
||||
if self.fest and not picked_by: # ingen fandt sandheden: alle drikker
|
||||
step["everyone_drinks"] = True
|
||||
for pid in self.players:
|
||||
self._drink(pid, m, step)
|
||||
else:
|
||||
if self.fest:
|
||||
for pid in picked_by:
|
||||
self._drink(pid, m, step)
|
||||
for author in a.authors:
|
||||
if author in self.players:
|
||||
self.players[author].fooled += len(picked_by)
|
||||
@@ -757,6 +795,8 @@ class Game:
|
||||
awards.append({"title": title, "player_id": top.id, "detail": detail(getattr(top, attr)),
|
||||
"kind": attr, "count": getattr(top, attr)})
|
||||
|
||||
if self.fest:
|
||||
best("sips", "Tørstigste", lambda n: f"{n} tår i aften")
|
||||
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 ''}")
|
||||
@@ -831,10 +871,11 @@ class Game:
|
||||
"round": None,
|
||||
"players": [
|
||||
{"id": p.id, "name": p.name, "color": p.color, "score": p.score, "connected": p.connected,
|
||||
"vip": p.id == vip, "done": p.id in done_set}
|
||||
"vip": p.id == vip, "done": p.id in done_set, **({"sips": p.sips} if self.fest else {})}
|
||||
for p in self.players.values()
|
||||
],
|
||||
"audience_count": len(self.audience),
|
||||
"fest": self.fest,
|
||||
}
|
||||
if self.phase not in ("lobby", "final"):
|
||||
name, mult, count, _read = self.round
|
||||
@@ -850,7 +891,7 @@ class Game:
|
||||
state["question"] = {"category": self.question["category"], "prompt": self.question["prompt"],
|
||||
"slug": slugify(self.question["category"])}
|
||||
if self.phase == "rules":
|
||||
state["rules"] = {"items": [{k: r[k] for k in ("icon", "title", "body")} for r in RULES],
|
||||
state["rules"] = {"items": [{k: r[k] for k in ("icon", "title", "body")} for r in self.rules],
|
||||
"started_at": int(self.rules_started_at * 1000) if self.rules_started_at else None,
|
||||
"steps_ms": self.rules_steps_ms, "audio": self.rules_audio}
|
||||
if self.phase == "present":
|
||||
@@ -879,6 +920,8 @@ class Game:
|
||||
"summary": self.summary,
|
||||
# point i dette spørgsmål pr. spiller (inkl. hjerter), til optællingen
|
||||
"deltas": [{"player_id": p.id, "delta": p.last_delta or 0} for p in self.players.values()],
|
||||
**({"sip_deltas": [{"player_id": p.id, "n": p.last_sips or 0} for p in self.players.values()]}
|
||||
if self.fest else {}),
|
||||
"step_ms": REVEAL_STEP_MS,
|
||||
"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,
|
||||
@@ -904,6 +947,8 @@ class Game:
|
||||
"likes": sorted(aid for aid, voters in self.likes.items() if viewer.id in voters),
|
||||
"is_chooser": self.phase == "category" and viewer.id == self.chooser_id,
|
||||
"last_delta": viewer.last_delta if self.phase in ("reveal", "scores") else None,
|
||||
**({"last_sips": viewer.last_sips if self.phase == "reveal" else None, "sips": viewer.sips}
|
||||
if self.fest else {}),
|
||||
"score": viewer.score,
|
||||
}
|
||||
return state
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
# Pakker: en kategori kan høre til flere. "arbejde" er aldrig med i "alle" og vælges kun med vilje.
|
||||
PACKS = ["familie", "nørd", "arbejde"]
|
||||
PRESETS = {"alle": ["familie", "nørd"], "familie": ["familie"], "nørd": ["nørd"], "arbejde": ["arbejde"]}
|
||||
# Pakker: en kategori kan høre til flere. "arbejde" og "fest" er aldrig med i "alle" og vælges kun med vilje.
|
||||
# "fest" (18+) er også en spilleform: tår frem for point, og speakeren er FEST_VOICE_ID.
|
||||
PACKS = ["familie", "nørd", "arbejde", "fest"]
|
||||
PRESETS = {"alle": ["familie", "nørd"], "familie": ["familie"], "nørd": ["nørd"], "arbejde": ["arbejde"],
|
||||
"fest": ["fest"]}
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
|
||||
@@ -419,5 +419,138 @@
|
||||
"{navn} har afleveret. Ser selvsikker ud. Det er et dårligt tegn.",
|
||||
"{navn} er inde. Nu mangler vi bare resten af familien.",
|
||||
"[whispers] {navn} er færdig. Og jeg tror, den er lumsk."
|
||||
],
|
||||
"lobby_welcome_fest": [
|
||||
"[excited] Yo yo yo, velkommen til festen! Telefonen frem, glasset fyldt, hjernen må gerne blive hjemme.",
|
||||
"Hvad så, besties? Det her er Fup og Fakta, festudgaven. Jeres lever har ikke sagt ja, men vi kører alligevel.",
|
||||
"[laughs] Velkommen til Ohio. Her lyver vi, vi sladrer, og vi drikker. Scan koden, bestie."
|
||||
],
|
||||
"lobby_join_fest": [
|
||||
"{navn} er joinet. Main character energy, ingen tvivl.",
|
||||
"{navn} er her! Lowkey den mest tørstige i rummet.",
|
||||
"[gasps] {navn} er landet. Glasset klar? Godt.",
|
||||
"{navn} har sluttet sig til festen. Aura plus tusind.",
|
||||
"Velkommen, {navn}. Det her ender med en tår, det lover jeg."
|
||||
],
|
||||
"lobby_waiting_fest": [
|
||||
"{vip}, tryk start. Vi bliver ikke yngre, og øllen bliver ikke koldere.",
|
||||
"[sighs] {vip}, festen venter på dig. Start nu, bestie."
|
||||
],
|
||||
"game_start_fest": [
|
||||
"{antal_spillere} spillere, en masse sladder og alt for mange tår. Lad os gå!",
|
||||
"[excited] {antal_spillere} spillere er klar. Det her bliver ikke familievenligt. Okay, lidt alligevel."
|
||||
],
|
||||
"round1_intro_fest": [
|
||||
"Runde et. Én tår, hver gang I bliver narret. Det er bare opvarmning, bestie.",
|
||||
"Første runde. Små tår. Store løgne. Let's go."
|
||||
],
|
||||
"round2_intro_fest": [
|
||||
"Runde to! Nu er det to tår, når I bliver snydt. {foerende} fører, men det redder dig ikke.",
|
||||
"[mischievously] Runde to. Dobbelt op på tårene. Det her er jeres comeback-arc."
|
||||
],
|
||||
"final_intro_fest": [
|
||||
"Finalen! Bliver du narret nu, så er det en hel shot. No cap.",
|
||||
"[dramatic] Det er finalen. Shot-runden. {foerende} fører, men det betyder intet nu."
|
||||
],
|
||||
"category_choose_fest": [
|
||||
"{navn}, vælg en kategori. Gør det slay.",
|
||||
"{navn}, du har magten. Vælg klogt, eller i det mindste sjovt."
|
||||
],
|
||||
"category_timeout_fest": [
|
||||
"{navn} var for langsom, så det bliver {kategori}. Det er giving Ohio.",
|
||||
"Ingen valgte, så jeg tog {kategori}. {navn}, red flag."
|
||||
],
|
||||
"lie_start_fest": [
|
||||
"{kategori}. Skriv en løgn så god, at Se og Hør ville trykke den.",
|
||||
"Nu er det {kategori}. Lyv som en influencer med en rabatkode.",
|
||||
"{kategori}, bestie. Find på noget, der lyder som ægte sladder."
|
||||
],
|
||||
"lie_hurry_fest": [
|
||||
"Tiden løber, besties. Skriv noget, hvad som helst!",
|
||||
"[excited] Ti sekunder! Lyv nu, tænk bagefter."
|
||||
],
|
||||
"lie_all_done_fest": [
|
||||
"Alle har løjet. Jeg er så stolt af jer. Og lidt bange.",
|
||||
"Løgnene er inde. Det her bliver kaos, for real"
|
||||
],
|
||||
"guess_start_fest": [
|
||||
"Find sandheden. Ellers drikker du, simple as.",
|
||||
"Én af dem er ægte. Resten er ren delulu. Vælg!"
|
||||
],
|
||||
"guess_hurry_fest": [
|
||||
"Vælg nu! Glasset venter.",
|
||||
"[laughs] Hurtigt, ellers tager jeg det som et ja til en tår."
|
||||
],
|
||||
"reveal_lie_fooled_fest": [
|
||||
"{ofre} faldt for {svar}. Det var {forfatter}, der løj. {ofre}, tag en tår, bestie.",
|
||||
"[laughs] {svar}! Opfundet af {forfatter}. {ofre}, drik. Det er bare reglerne.",
|
||||
"{forfatter} skrev {svar}, og {ofre} købte den råt. Skål, din lille red flag.",
|
||||
"{ofre} troede på {svar}. Det er så cringe. {forfatter} fik dig. Drik!",
|
||||
"[mischievously] {svar} var en løgn fra {forfatter}. {ofre}, glasset op. Nu."
|
||||
],
|
||||
"reveal_lie_fooled_many_fest": [
|
||||
"[gasps] {antal} personer faldt for {svar}! {forfatter} har rizz. Alle, der faldt, drikker.",
|
||||
"{svar} narrede {antal} af jer. {forfatter} er main character. I andre, drik!"
|
||||
],
|
||||
"reveal_lie_shared_fest": [
|
||||
"{svar} blev skrevet af både {forfattere}. Same brain cell. Ofrene drikker.",
|
||||
"[laughs] {forfattere} skrev begge {svar}. Det er giving tvillinger. Alle der faldt, drik!"
|
||||
],
|
||||
"reveal_house_lie_fest": [
|
||||
"{svar} var min løgn. {ofre}, tag en tår for huset. Skål!",
|
||||
"[laughs] {ofre} faldt for husets løgn, {svar}. Drik, bestie, jeg dømmer ikke. Jo, lidt."
|
||||
],
|
||||
"reveal_truth_found_fest": [
|
||||
"Sandheden var {sandhed}. {navne} fandt den. Ingen tår til dem. Slay.",
|
||||
"{sandhed}! Det er ægte. {navne} har aura i dag."
|
||||
],
|
||||
"reveal_truth_nobody_fest": [
|
||||
"Sandheden var {sandhed}, og ingen fandt den. Alle drikker! Skål!",
|
||||
"[laughs] {sandhed}. Ingen af jer vidste det. Det er en tår til hele rummet.",
|
||||
"Ingen fandt {sandhed}. Det er kollektiv Ohio. Alle drikker, nu."
|
||||
],
|
||||
"reveal_truth_everyone_fest": [
|
||||
"Alle fandt {sandhed}. Boring, men okay. Ingen drikker... denne gang.",
|
||||
"{sandhed}. Det vidste alle. Jeg skal skrive sværere løgne, det er på mig."
|
||||
],
|
||||
"scores_intro_fest": [
|
||||
"{foerende} fører på point, men hvem fører på tår? Det er det, der tæller.",
|
||||
"{sidst} ligger sidst. Lowkey mere fuld end alle andre, tror jeg."
|
||||
],
|
||||
"winner_fest": [
|
||||
"{vinder} vinder med {point} point. Men den rigtige vinder er dem, der drak mest.",
|
||||
"[cheering] {vinder} vinder! {point} point. Skål for dig, bestie."
|
||||
],
|
||||
"award_biggest_liar_fest": [
|
||||
"Største løgner: {navn}. {antal} folk faldt. Det er straight up villain-arc.",
|
||||
"{navn} narrede jer {antal} gange. Det er straight up skurke-aura."
|
||||
],
|
||||
"award_truth_seeker_fest": [
|
||||
"Sandhedsvidnet er {navn}. {antal} rigtige. Lowkey ædru, sus.",
|
||||
"{navn} fandt sandheden {antal} gange. Den ædru i gruppen, respekt."
|
||||
],
|
||||
"award_most_liked_fest": [
|
||||
"{navn} fik {antal} hjerter. Folkets bestie.",
|
||||
"[cheering] {navn} fik {antal} hjerter. Aftenens main character."
|
||||
],
|
||||
"award_thirstiest": [
|
||||
"Aftenens tørstigste: {navn} med {antal} tår. Drik et glas vand, bestie. Seriøst.",
|
||||
"[laughs] {navn} tog {antal} tår. Det er ikke en pris, det er en livsstil."
|
||||
],
|
||||
"play_again_fest": [
|
||||
"En runde mere? Leveren siger nej, men vi siger ja.",
|
||||
"Igen igen! Fyld op, besties."
|
||||
],
|
||||
"present_intro_fest": [
|
||||
"Okay, her er svarene. Lyt godt efter, én af dem er ægte sladder.",
|
||||
"Jeg læser dem op. Den der lyder mest unhinged er måske sandheden."
|
||||
],
|
||||
"summary_cleanup_fest": [
|
||||
"Dem her troede ingen på. Bye bye.",
|
||||
"[laughs] De her løgne flopper hårdt. Væk med dem."
|
||||
],
|
||||
"summary_points_fest": [
|
||||
"Og så til tårene. Og okay, pointene.",
|
||||
"Lad os se, hvem der skal drikke. Og hvem der fik point, hvis I gider."
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,3 +321,43 @@ class SanitizeTests(GameTestCase):
|
||||
with self.assertRaises(GameError) as e:
|
||||
self.g.join("🎉🎉")
|
||||
self.assertEqual(e.exception.code, "name_invalid")
|
||||
|
||||
|
||||
class FestTests(GameTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.g = Game("TEST", bank(), join_url="http://x/?code=TEST", rng=random.Random(4), fest=True)
|
||||
self.a, self.b, self.c = self.join("A", "B", "C")
|
||||
self.g.start(None, self.now)
|
||||
self.through_category()
|
||||
|
||||
def test_fooled_players_drink(self):
|
||||
for p, lie in [(self.a, "katte"), (self.b, "hunde"), (self.c, "heste")]:
|
||||
self.g.submit_lie(p.id, lie, self.now)
|
||||
by_text = {a.text: a.id for a in self.g.answers}
|
||||
self.g.guess(self.a.id, self.truth_id(), self.now)
|
||||
self.g.guess(self.b.id, by_text["katte"], self.now)
|
||||
self.g.guess(self.c.id, by_text["katte"], self.now)
|
||||
self.assertEqual((self.a.sips, self.b.sips, self.c.sips), (0, 1, 1))
|
||||
self.assertEqual(self.a.score, 2000) # pointene findes stadig
|
||||
snap = self.g.snapshot(self.now, viewer=self.b)
|
||||
self.assertTrue(snap["fest"])
|
||||
self.assertEqual(snap["me"]["last_sips"], 1)
|
||||
self.assertEqual({d["player_id"]: d["n"] for d in snap["reveal"]["sip_deltas"]}[self.b.id], 1)
|
||||
|
||||
def test_nobody_found_truth_everyone_drinks(self):
|
||||
for p, lie in [(self.a, "katte"), (self.b, "hunde"), (self.c, "heste")]:
|
||||
self.g.submit_lie(p.id, lie, self.now)
|
||||
by_text = {a.text: a.id for a in self.g.answers}
|
||||
self.g.guess(self.a.id, by_text["hunde"], self.now)
|
||||
self.g.guess(self.b.id, by_text["katte"], self.now)
|
||||
self.g.guess(self.c.id, by_text["katte"], self.now)
|
||||
truth = next(s for s in self.g.reveal_steps if s["is_truth"])
|
||||
self.assertTrue(truth["everyone_drinks"])
|
||||
self.assertEqual((self.a.sips, self.b.sips, self.c.sips), (2, 2, 2))
|
||||
|
||||
def test_fest_rules_and_no_sips_in_normal_games(self):
|
||||
self.assertIs(self.g.rules, engine.RULES_FEST)
|
||||
normal = Game("N", bank())
|
||||
self.assertIs(normal.rules, engine.RULES)
|
||||
self.assertFalse(normal.snapshot(self.now)["fest"])
|
||||
|
||||
@@ -23,8 +23,11 @@ EVENTS = {
|
||||
"winner": {"vinder", "point"}, "award_biggest_liar": {"navn", "antal"},
|
||||
"award_truth_seeker": {"navn", "antal"}, "award_most_liked": {"navn", "antal"}, "play_again": set(),
|
||||
"present_intro": set(), "present_answer": {"svar"}, "summary_cleanup": set(), "summary_points": set(),
|
||||
"summary_favorite": {"svar"},
|
||||
"summary_favorite": {"svar"}, "award_thirstiest": {"navn", "antal"},
|
||||
}
|
||||
# Fest-udgaven: "<begivenhed>_fest" har samme pladsholdere som begivenheden, men behøver færre varianter.
|
||||
EVENTS.update({f"{e}_fest": ph for e, ph in list(EVENTS.items())})
|
||||
MIN_VARIANTS = {e: 2 for e in EVENTS if e.endswith("_fest") or e == "award_thirstiest"}
|
||||
from party.narrator import AUDIO_TAGS as TAGS # samme liste som hjernen og filtret bruger
|
||||
|
||||
|
||||
@@ -33,9 +36,11 @@ class NarratorLinesTests(SimpleTestCase):
|
||||
self.lines = json.loads(LINES.read_text(encoding="utf-8"))
|
||||
|
||||
def test_all_events_present_with_variants(self):
|
||||
self.assertEqual(set(self.lines), set(EVENTS))
|
||||
base = {e for e in EVENTS if not e.endswith("_fest")}
|
||||
self.assertEqual(set(self.lines) & base, base)
|
||||
self.assertLessEqual(set(self.lines), set(EVENTS))
|
||||
for event, variants in self.lines.items():
|
||||
self.assertGreaterEqual(len(variants), 5, event)
|
||||
self.assertGreaterEqual(len(variants), MIN_VARIANTS.get(event, 5), event)
|
||||
self.assertEqual(len(variants), len(set(variants)), f"dubletter i {event}")
|
||||
|
||||
def test_placeholders_tags_and_style(self):
|
||||
|
||||
@@ -2,8 +2,10 @@ import re
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from fupogfakta.engine import RULES
|
||||
from fupogfakta.models import Question
|
||||
from django.conf import settings
|
||||
|
||||
from fupogfakta.engine import RULES, RULES_FEST
|
||||
from fupogfakta.models import Category, Question
|
||||
from party import tts
|
||||
from party.narrator import LINES, TEASES, Narrator, speakable_prompt
|
||||
|
||||
@@ -31,11 +33,20 @@ class Command(BaseCommand):
|
||||
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)]
|
||||
static = [line for k, variants in LINES.items() if not k.endswith("_fest")
|
||||
for line in variants if not re.search(r"\{\w+\}", line)]
|
||||
static += TEASES
|
||||
static += [r["say"] for r in RULES]
|
||||
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."))
|
||||
fest = settings.FEST_VOICE_ID or None
|
||||
if fest:
|
||||
# fest-speakeren siger "<begivenhed>_fest", og den almindelige replik, hvor der ikke er en fest-udgave
|
||||
keys = [k for k in LINES if k.endswith("_fest") or k + "_fest" not in LINES]
|
||||
fest_static = [line for k in keys for line in LINES[k] if not re.search(r"\{\w+\}", line)]
|
||||
fest_static += TEASES + [r["say"] for r in RULES_FEST]
|
||||
ok = sum(1 for line in fest_static if tts.synth_sync(line, fest))
|
||||
self.stdout.write(self.style.SUCCESS(f"{ok} af {len(fest_static)} faste fest-replikker ligger i cachen."))
|
||||
if budget <= 0:
|
||||
return
|
||||
left = tts.credits_left()
|
||||
@@ -50,12 +61,17 @@ class Command(BaseCommand):
|
||||
narrator = Narrator()
|
||||
questions = [] # (tekst, stemme); None = speakeren
|
||||
for q in Question.objects.filter(active=True).select_related("category").order_by("times_used", "id"):
|
||||
voice = q.category.voice_id or None
|
||||
in_fest = fest and "fest" in (q.category.packs or [])
|
||||
voice = q.category.voice_id or (fest if in_fest else None)
|
||||
questions += [(speakable_prompt(q.prompt), voice)] + ([(q.note, voice)] if q.note else [])
|
||||
# oplæsningen i runde 2/finalen: sandheden og husets løgne med deres forudskrevne kommentar
|
||||
questions += [(narrator.present_answer(svar, comment), None) for svar, comment in (q.pitch or {}).items() if comment]
|
||||
categories = sorted(set(Question.objects.filter(active=True).values_list("category__name", flat=True)))
|
||||
per_category = [(t, None) for t in _expand_categories(LINES.get("lie_start", []), categories)]
|
||||
questions += [(narrator.present_answer(svar, comment), fest if in_fest else None)
|
||||
for svar, comment in (q.pitch or {}).items() if comment]
|
||||
cats = {c.name: c for c in Category.objects.filter(questions__active=True).distinct()}
|
||||
normal = sorted(n for n, c in cats.items() if not (fest and "fest" in (c.packs or [])))
|
||||
festive = sorted(n for n in cats if n not in normal)
|
||||
per_category = [(t, None) for t in _expand_categories(LINES.get("lie_start", []), normal)]
|
||||
per_category += [(t, fest) for t in _expand_categories(LINES.get("lie_start_fest", []), festive)]
|
||||
queue = [t for pair in zip(questions, per_category) for t in pair]
|
||||
queue += questions[len(per_category):] + per_category[len(questions):]
|
||||
|
||||
|
||||
+6
-2
@@ -84,11 +84,14 @@ TEASES = ["Buh!", "Hov hov, der røg du i!", "Avs, avs, avs!", "Snydt!", "Hihi,
|
||||
|
||||
|
||||
class Narrator:
|
||||
def __init__(self, rng: random.Random | None = None):
|
||||
def __init__(self, rng: random.Random | None = None, fest: bool = False):
|
||||
self.rng = rng or random.Random()
|
||||
self.fest = fest # fest: replikken "<begivenhed>_fest" bruges, når den findes
|
||||
self._recent: dict[str, list[int]] = {}
|
||||
|
||||
def line(self, event: str, **values) -> str | None:
|
||||
if self.fest and LINES.get(event + "_fest"):
|
||||
event += "_fest"
|
||||
variants = LINES.get(event)
|
||||
if not variants:
|
||||
return None
|
||||
@@ -191,7 +194,8 @@ class Narrator:
|
||||
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"}
|
||||
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
|
||||
|
||||
+20
-15
@@ -62,7 +62,9 @@ class Room:
|
||||
self.last_activity = time.time()
|
||||
self._timer: asyncio.TimerHandle | None = None
|
||||
self._broadcast_pending = False
|
||||
self.narrator = Narrator()
|
||||
self.narrator = Narrator(fest=game.fest)
|
||||
# Fest-spil har sin egen speaker til alt; ellers bruges kategoriens/standardstemmen
|
||||
self.voice = (settings.FEST_VOICE_ID or None) if game.fest else None
|
||||
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
|
||||
@@ -200,7 +202,7 @@ class Room:
|
||||
self._at_deadline_minus(10, "lie", "lie_hurry")
|
||||
note = (self.game.question or {}).get("note")
|
||||
if note: # forklaringen kendes allerede nu; lav den, mens der løjes
|
||||
asyncio.get_running_loop().create_task(tts.synth(note, voice))
|
||||
asyncio.get_running_loop().create_task(self._synth(note, voice))
|
||||
elif ev["type"] == "guess_start":
|
||||
self._at_deadline_minus(5, "guess", "guess_hurry")
|
||||
|
||||
@@ -386,6 +388,9 @@ class Room:
|
||||
def _question_voice(self) -> str | None:
|
||||
return (self.game.question or {}).get("voice")
|
||||
|
||||
def _synth(self, text: str, voice: str | None = None):
|
||||
return tts.synth(text, voice or self.voice)
|
||||
|
||||
def say(self, text: str | None, blocking: bool = True, voice: str | None = None):
|
||||
"""Læg en replik i kø. Lyden genereres med det samme, men udgives i rækkefølge.
|
||||
blocking=False: en sidebemærkning, som ikke holder skærmen tilbage (fx "det var sandheden")."""
|
||||
@@ -398,7 +403,7 @@ class Room:
|
||||
started = time.time() if blocking else None
|
||||
if blocking:
|
||||
self._pending.setdefault(key, []).append(started)
|
||||
task = asyncio.get_running_loop().create_task(tts.synth(text, voice))
|
||||
task = asyncio.get_running_loop().create_task(self._synth(text, voice))
|
||||
self._voice_queue.put_nowait((text, task, key, started))
|
||||
self._update_hold()
|
||||
|
||||
@@ -483,10 +488,10 @@ class Room:
|
||||
unpicked = [a for a in g.answers if not a.is_truth and a.id not in picked]
|
||||
cleanup_text = self.narrator.line("summary_cleanup") if unpicked else None
|
||||
points_text = self.narrator.line("summary_points")
|
||||
jobs = [tts.synth(t) if t else asyncio.sleep(0, None) for t in texts]
|
||||
jobs.append(tts.synth(cleanup_text) if cleanup_text else asyncio.sleep(0, None))
|
||||
jobs.append(tts.synth(points_text))
|
||||
jobs.append(tts.synth(note, self._question_voice()) if note else asyncio.sleep(0, None))
|
||||
jobs = [self._synth(t) if t else asyncio.sleep(0, None) for t in texts]
|
||||
jobs.append(self._synth(cleanup_text) if cleanup_text else asyncio.sleep(0, None))
|
||||
jobs.append(self._synth(points_text))
|
||||
jobs.append(self._synth(note, self._question_voice()) if note else asyncio.sleep(0, None))
|
||||
try:
|
||||
results = await asyncio.wait_for(asyncio.gather(*jobs), timeout=REVEAL_AUDIO_WAIT)
|
||||
except asyncio.TimeoutError:
|
||||
@@ -553,7 +558,7 @@ class Room:
|
||||
intro = self.narrator.line("present_intro")
|
||||
texts = [self.narrator.present_answer(a.text, comments.get(a.id)) for a in g.answers]
|
||||
try:
|
||||
results = await asyncio.wait_for(asyncio.gather(tts.synth(intro), *[tts.synth(t) for t in texts]),
|
||||
results = await asyncio.wait_for(asyncio.gather(self._synth(intro), *[self._synth(t) for t in texts]),
|
||||
timeout=REVEAL_AUDIO_WAIT)
|
||||
except asyncio.TimeoutError:
|
||||
results = [None] * (len(texts) + 1)
|
||||
@@ -575,17 +580,17 @@ class Room:
|
||||
g.hold_rules(time.time(), max_wait=REVEAL_AUDIO_WAIT + 2 + max(0.0, self.speech_end - time.time()))
|
||||
self.changed()
|
||||
try:
|
||||
results = await asyncio.wait_for(asyncio.gather(*[tts.synth(r["say"]) for r in engine.RULES]),
|
||||
results = await asyncio.wait_for(asyncio.gather(*[self._synth(r["say"]) for r in g.rules]),
|
||||
timeout=REVEAL_AUDIO_WAIT)
|
||||
except asyncio.TimeoutError:
|
||||
results = [None] * len(engine.RULES)
|
||||
results = [None] * len(g.rules)
|
||||
wait = min(self.speech_end - time.time(), MAX_HOLD)
|
||||
if wait > 0: # "lad spillet begynde" tales færdig først
|
||||
await asyncio.sleep(wait)
|
||||
if g.phase != "rules" or g.rules_started_at is not None:
|
||||
return # værten sprang over
|
||||
g.rules_audio = [{"url": a["url"], "ms": a["ms"], "text": r["say"]} if a else None
|
||||
for a, r in zip(results, engine.RULES)]
|
||||
for a, r in zip(results, g.rules)]
|
||||
g.start_rules_timeline(time.time(), [max(2500, a["ms"] + 600) if a else engine.RULES_STEP_MS for a in results])
|
||||
self.changed()
|
||||
|
||||
@@ -614,7 +619,7 @@ class Room:
|
||||
self._tease_texts = {pid: self.narrator.clean_ai(t) for pid, t in
|
||||
(await self.brain.teases(victims, list(self.memory))).items()}
|
||||
for text in self._tease_texts.values():
|
||||
asyncio.get_running_loop().create_task(tts.synth(text))
|
||||
asyncio.get_running_loop().create_task(self._synth(text))
|
||||
|
||||
# ── drillerier på telefonen ──────────────────────────────────────────────
|
||||
def _schedule_teases(self):
|
||||
@@ -641,7 +646,7 @@ class Room:
|
||||
asyncio.get_running_loop().create_task(self._send_tease(pid, text))
|
||||
|
||||
async def _send_tease(self, pid: str, text: str):
|
||||
audio = await tts.synth(text) # forudgenereret af tts_warm, så det er straks
|
||||
audio = await self._synth(text) # forudgenereret af tts_warm, så det er straks
|
||||
msg = {"type": "tease", "text": untag(text), "image": self.narrator.rng.choice(tease_images()),
|
||||
"sound": audio["url"] if audio else None}
|
||||
for conn in list(self.connections):
|
||||
@@ -660,10 +665,10 @@ def new_code() -> str:
|
||||
raise RuntimeError("Ingen ledige rumkoder")
|
||||
|
||||
|
||||
def create_room(questions: list[dict], host_username: str, public_url: str) -> Room:
|
||||
def create_room(questions: list[dict], host_username: str, public_url: str, fest: bool = False) -> Room:
|
||||
cleanup()
|
||||
code = new_code()
|
||||
game = Game(code, questions, join_url=f"{public_url}/?code={code}")
|
||||
game = Game(code, questions, join_url=f"{public_url}/?code={code}", fest=fest)
|
||||
room = Room(code, game, host_username)
|
||||
_rooms[code] = room
|
||||
log.info("Rum %s oprettet af %s med %d spørgsmål", code, host_username, len(questions))
|
||||
|
||||
@@ -262,8 +262,28 @@
|
||||
];
|
||||
break;
|
||||
}
|
||||
if (opts.fest) festify(host, phones);
|
||||
return { host: host, phones: phones };
|
||||
}
|
||||
|
||||
/* ?fest=1: samme skærme i fest-udgaven (tår pr. spiller) */
|
||||
function festify(host, phones) {
|
||||
var sips = { p1: 1, p2: 3, p3: 6, p4: 4, p5: 2, p6: 5 };
|
||||
[host].concat(phones.map(function (ph) { return ph.state; })).forEach(function (st) {
|
||||
st.fest = true;
|
||||
(st.players || []).forEach(function (p) { p.sips = sips[p.id] || 0; });
|
||||
if (st.reveal) {
|
||||
st.reveal.steps.forEach(function (s) {
|
||||
s.sips = s.is_truth ? [] : s.guessed_by.map(function (id) { return { player_id: id, n: 1 }; });
|
||||
});
|
||||
st.reveal.sip_deltas = [{ player_id: 'p3', n: 1 }, { player_id: 'p4', n: 1 }, { player_id: 'p5', n: 1 }, { player_id: 'p6', n: 1 }];
|
||||
}
|
||||
if (st.me && st.me.role === 'player') {
|
||||
st.me.sips = sips[st.me.id] || 0;
|
||||
st.me.last_sips = st.me.id === 'p1' ? 0 : 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.WPPFixtures = { build: build, phases: PHASES, qr: fakeQr(), tease: TEASE };
|
||||
})();
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
var cur = null, hostCtx = null;
|
||||
function draw() {
|
||||
var fx = cur = WPPFixtures.build(phase, { t: t });
|
||||
var fx = cur = WPPFixtures.build(phase, { t: t, fest: qs.get("fest") === "1" });
|
||||
var hostRoot = document.getElementById('hostroot');
|
||||
hostCtx = { harness: true, sound: qs.get('sound') === '1', send: log, qrSrc: WPPFixtures.qr };
|
||||
WPPHost.renderHost(hostRoot, fx.host, hostCtx);
|
||||
|
||||
@@ -235,6 +235,19 @@
|
||||
'<div class="hs-timer" data-k="tm:' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'ring') + '</div>';
|
||||
}
|
||||
|
||||
/* fest: "+2 tår" i runde 1-2, "1 shot" i finalen (×3) */
|
||||
function sipText(state, n) {
|
||||
var m = (state.round && state.round.multiplier) || 1;
|
||||
return m >= 3 && n % 3 === 0 ? (n / 3) + ' shot' : n + ' tår';
|
||||
}
|
||||
function sipsHtml(state, s, players) {
|
||||
if (!state.fest || !(s.sips || []).length) return '';
|
||||
return '<div class="sips">' + (s.everyone_drinks ? '<span class="all">🍻 ALLE DRIKKER!</span>' : '') +
|
||||
(s.sips || []).map(function (x) {
|
||||
return '<span class="sp">' + WPP.chip(players[x.player_id]) + '<b>🥃 ' + esc(sipText(state, x.n)) + '</b></span>';
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
function reveal(state, u) {
|
||||
var r = state.reveal || {};
|
||||
var steps = r.steps || [];
|
||||
@@ -262,16 +275,16 @@
|
||||
}).join('');
|
||||
if (s.is_truth) {
|
||||
stamp = '<div class="rv-res truth" data-k="s"><div class="truth-banner">SANDHEDEN</div>' +
|
||||
(pts ? '<div class="pts">' + pts + '</div>' : '<div class="by">Ingen fandt sandheden!</div>') +
|
||||
(pts ? '<div class="pts">' + pts + '</div>' : '<div class="by">Ingen fandt sandheden!</div>') + sipsHtml(state, s, players) +
|
||||
(r.note ? '<div class="note">' + esc(r.note) + '</div>' : '') + '</div>';
|
||||
} else if (s.house) {
|
||||
stamp = '<div class="rv-res house" data-k="s"><div class="stamp">HUSETS LØGN</div><div class="by">Huset narrede jer!</div></div>';
|
||||
stamp = '<div class="rv-res house" data-k="s"><div class="stamp">HUSETS LØGN</div><div class="by">Huset narrede jer!</div>' + sipsHtml(state, s, players) + '</div>';
|
||||
} else {
|
||||
var auth = (s.authors || []).map(function (id) {
|
||||
return players[id] ? esc(players[id].name) + ((s.assisted || []).indexOf(id) >= 0 ? ' <small>(Lyv for mig)</small>' : '') : '?';
|
||||
});
|
||||
stamp = '<div class="rv-res lie" data-k="s"><div class="stamp">LØGN</div><div class="by">af ' + WPP.joinNames(auth) + '</div>' +
|
||||
(pts ? '<div class="pts">' + pts + '</div>' : '') + '</div>';
|
||||
(pts ? '<div class="pts">' + pts + '</div>' : '') + sipsHtml(state, s, players) + '</div>';
|
||||
}
|
||||
}
|
||||
/* hjerterne er live: mærket har sin egen nøgle, så kun det skiftes (og popper) */
|
||||
@@ -297,7 +310,15 @@
|
||||
|
||||
var dmap = {};
|
||||
(r.deltas || []).forEach(function (d) { dmap[d.player_id] = d.delta || 0; });
|
||||
var ps = (state.players || []).slice().sort(function (a, b) {
|
||||
var fest = state.fest && r.sip_deltas;
|
||||
var players0 = state.players || [];
|
||||
if (fest) {
|
||||
/* fest: opsummeringen tæller tår op i stedet for point */
|
||||
dmap = {};
|
||||
r.sip_deltas.forEach(function (d) { dmap[d.player_id] = d.n || 0; });
|
||||
players0 = players0.map(function (p) { return Object.assign({}, p, { score: p.sips || 0 }); });
|
||||
}
|
||||
var ps = players0.slice().sort(function (a, b) {
|
||||
return (dmap[b.id] || 0) - (dmap[a.id] || 0) || (b.score || 0) - (a.score || 0);
|
||||
});
|
||||
var live = pr.tail >= plan.pointsAt;
|
||||
@@ -306,7 +327,7 @@
|
||||
return '<section class="rv-sum" data-k="sum" data-patch>' +
|
||||
'<div class="sum-l" data-k="l" data-patch><h2 data-k="h">Alle svarene</h2>' +
|
||||
'<div class="sum-tiles cols' + cols + '" data-k="tiles:' + cols + '" data-patch>' + tiles + '</div></div>' +
|
||||
'<div class="sum-r" data-k="r" data-patch><h2 data-k="h">Point i spørgsmålet</h2>' +
|
||||
'<div class="sum-r" data-k="r" data-patch><h2 data-k="h">' + (fest ? '🥃 Tår (i alt)' : 'Point i spørgsmålet') + '</h2>' +
|
||||
'<ol class="sum-pts' + (live ? ' live' : '') + '" data-k="pts" data-patch>' + rows + '</ol></div></section>';
|
||||
}
|
||||
/* boomAt = ms inde i halen hvor svaret eksploderer (kun ikke-valgte); tail = hvor langt vi er nu */
|
||||
@@ -354,7 +375,8 @@
|
||||
var d = dmap[p.id] || 0;
|
||||
return '<li style="--pc:' + esc(p.color || '#888') + ';--i:' + i + ';--mv:' + (before.indexOf(p) - i) + ';--w:' + Math.max(4, Math.round(100 * (p.score || 0) / max)) + '%"' +
|
||||
sfxAttr('pop_in', i * 120, 'sc:' + p.id) + '>' +
|
||||
'<span class="rk">' + (i + 1) + '</span>' + avatar(p) + '<span class="n">' + esc(p.name) + '</span>' +
|
||||
'<span class="rk">' + (i + 1) + '</span>' + avatar(p) + '<span class="n">' + esc(p.name) +
|
||||
(state.fest ? ' <small class="sipb">🥃 ' + (p.sips || 0) + '</small>' : '') + '</span>' +
|
||||
'<span class="bar"><i></i></span>' +
|
||||
(d ? '<span class="d">' + WPP.signed(d) + '</span>' : '<span class="d zero"></span>') +
|
||||
'<b class="sc" data-count-from="' + ((p.score || 0) - d) + '" data-count-to="' + (p.score || 0) + '" data-count-delay="' + (300 + i * 120) + '">' +
|
||||
|
||||
@@ -912,3 +912,19 @@ button, input { font-family: inherit; }
|
||||
.more-packs summary::-webkit-details-marker { display: none; }
|
||||
.more-packs[open] summary { margin-bottom: 8px; }
|
||||
.pack-hint { margin: 8px 0 0; font-size: .9rem; color: var(--muted); }
|
||||
|
||||
/* fest: tår */
|
||||
.rv-res .sips { display: flex; flex-wrap: wrap; justify-content: center; gap: .6em; font-size: 1.35em; margin-top: .4em; animation: rise .4s .7s both; }
|
||||
.rv-res .sips .sp { display: inline-flex; align-items: center; gap: .3em; }
|
||||
.rv-res .sips b { color: var(--yellow); }
|
||||
.rv-res .sips .all { width: 100%; text-align: center; font-weight: 800; color: var(--yellow); font-size: 1.2em; animation: pop .6s .7s cubic-bezier(.3, 1.6, .5, 1) both; }
|
||||
.sipb { color: var(--yellow); font-weight: 600; margin-left: .3em; }
|
||||
.drink { text-align: center; display: flex; flex-direction: column; align-items: center; margin-top: 12px; }
|
||||
.drink b { font-size: 3.4rem; line-height: 1; }
|
||||
.drink span { color: var(--muted); font-size: 1.2rem; }
|
||||
.drink.go span { color: var(--yellow); font-weight: 800; font-size: 1.8rem; }
|
||||
.drink.go b { animation: pop .6s cubic-bezier(.3, 1.6, .5, 1); }
|
||||
|
||||
.delta.small b { font-size: 1.6rem; }
|
||||
.drink { margin: 8px 0 12px; }
|
||||
.drink.go b { font-size: 4.4rem; }
|
||||
|
||||
@@ -297,9 +297,15 @@
|
||||
function screenReveal(state, me, players, u) {
|
||||
var pr = WPP.revealProgress(state, u);
|
||||
var out = big('Kig op på skærmen!', '', 'look', 'look');
|
||||
/* egne point først, når storskærmen er nået til opsummeringen */
|
||||
/* egne point (og i fest: tår, som fylder mest) først, når storskærmen er nået til opsummeringen */
|
||||
if (pr.done && state.fest && me.role === 'player' && me.last_sips != null) {
|
||||
var m = (state.round && state.round.multiplier) || 1, n = me.last_sips;
|
||||
var what = m >= 3 && n % 3 === 0 ? (n / 3 === 1 ? 'en shot' : (n / 3) + ' shots') : (n === 1 ? 'en tår' : n + ' tår');
|
||||
out += '<section class="drink' + (n ? ' go' : '') + '" data-k="drink:' + n + '">' +
|
||||
(n ? '<b>🥃</b><span>Tag ' + what + '!</span>' : '<b>😎</b><span>Du slap. Ingen tår.</span>') + '</section>';
|
||||
}
|
||||
if (pr.done && me.role === 'player' && me.last_delta != null) {
|
||||
out += '<section class="delta' + (me.last_delta > 0 ? ' plus' : '') + '" data-k="delta:' + me.last_delta + '">' +
|
||||
out += '<section class="delta' + (me.last_delta > 0 ? ' plus' : '') + (state.fest ? ' small' : '') + '" data-k="delta:' + me.last_delta + '">' +
|
||||
(me.last_delta > 0 ? '<b>' + WPP.signed(me.last_delta) + '</b><span>point til dig!</span>'
|
||||
: '<b>0</b><span>point denne gang</span>') + '</section>';
|
||||
}
|
||||
@@ -325,6 +331,7 @@
|
||||
if (r) {
|
||||
out += '<section class="place" data-k="place"><div class="pnum">' + r.place + '.</div><p>plads af ' + r.of + '</p>' +
|
||||
'<div class="score">' + WPP.fmt(r.p.score) + ' point</div>' +
|
||||
(state.fest ? '<div class="score">🥃 ' + (me.sips || 0) + ' tår i aften</div>' : '') +
|
||||
(me.last_delta ? '<div class="dchip">' + WPP.signed(me.last_delta) + ' i runden</div>' : '') + '</section>';
|
||||
}
|
||||
var ps = (state.players || []).slice().sort(function (a, b) { return (b.score || 0) - (a.score || 0); });
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<summary>Flere valg…</summary>
|
||||
<label><input type="radio" name="pack" value="arbejde"><span>{{ work_pack_label }}</span></label>
|
||||
<p class="pack-hint">Arbejdsspørgsmål: e-faktura, IT-sikkerhed og kategorier med pakken "arbejde".</p>
|
||||
<label><input type="radio" name="pack" value="fest"><span>Fest 🥃 18+</span></label>
|
||||
<p class="pack-hint">Dansk pop-sladder med zoomer-speaker. Narret = tag en tår, finalen er en shot.</p>
|
||||
</details>
|
||||
</fieldset>
|
||||
<button type="submit" class="btn btn-go">Start et nyt spil</button>
|
||||
|
||||
+3
-2
@@ -41,10 +41,11 @@ async def host_new(request):
|
||||
ident = request.host_identity
|
||||
if not ident:
|
||||
return _forbidden()
|
||||
questions = await sync_to_async(load_game_questions)(request.POST.get("pack", "alle"))
|
||||
pack = request.POST.get("pack", "alle")
|
||||
questions = await sync_to_async(load_game_questions)(pack)
|
||||
if not questions:
|
||||
return HttpResponse("Der er ingen spørgsmål i banken. Kør 'manage.py load_questions'.", status=503)
|
||||
room = create_room(questions, ident.username, settings.PUBLIC_URL)
|
||||
room = create_room(questions, ident.username, settings.PUBLIC_URL, fest=pack == "fest")
|
||||
return redirect("party:host", code=room.code)
|
||||
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ HOST_ADMIN_GROUP = env("HOST_ADMIN_GROUP", "authentik Admins")
|
||||
ELEVENLABS_API_KEY = env("ELEVENLABS_API_KEY", "")
|
||||
ELEVENLABS_VOICE_ID = env("ELEVENLABS_VOICE_ID", "")
|
||||
ELEVENLABS_MODEL = env("ELEVENLABS_MODEL", "eleven_v4")
|
||||
FEST_VOICE_ID = env("FEST_VOICE_ID", "") # speakeren i fest-spil (tom = den almindelige speaker)
|
||||
TTS_CACHE_DIR = env("TTS_CACHE_DIR", str(BASE_DIR / "tts-cache"))
|
||||
TTS_STABILITY = float(env("TTS_STABILITY", "0.3")) # lavere = mere udsving i stemmen
|
||||
TTS_STYLE = float(env("TTS_STYLE", "0.7")) # højere = mere udtryk
|
||||
|
||||
Reference in New Issue
Block a user