From a40243ed236ac203dc7595d5c1787d325c195a7b Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 18 Oct 2026 10:20:40 +0200 Subject: [PATCH] =?UTF-8?q?Fest:=2018+-pakke=20med=20pop-sladder,=20t?= =?UTF-8?q?=C3=A5r=20og=20egen=20speaker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (_fest), egne regler og FEST_VOICE_ID til alt. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu --- deploy/bots.py | 7 +- docs/PROTOCOL.md | 11 + fupogfakta/data/questions_d_fest.json | 1161 +++++++++++++++++++++++++ fupogfakta/engine.py | 59 +- fupogfakta/models.py | 8 +- fupogfakta/narrator/lines.json | 135 ++- fupogfakta/tests/test_engine.py | 40 + fupogfakta/tests/test_narrator.py | 11 +- party/management/commands/tts_warm.py | 30 +- party/narrator.py | 8 +- party/rooms.py | 35 +- party/static/party/dev-fixtures.js | 20 + party/static/party/dev.html | 2 +- party/static/party/host.js | 34 +- party/static/party/party.css | 16 + party/static/party/play.js | 11 +- party/templates/party/host_new.html | 2 + party/views.py | 5 +- partyhub/settings.py | 1 + 19 files changed, 1544 insertions(+), 52 deletions(-) create mode 100644 fupogfakta/data/questions_d_fest.json diff --git a/deploy/bots.py b/deploy/bots.py index f98207b..9b72c18 100644 --- a/deploy/bots.py +++ b/deploy/bots.py @@ -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() diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 05129d3..71c2fe8 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -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 `_fest`-replikker og `FEST_VOICE_ID`. diff --git a/fupogfakta/data/questions_d_fest.json b/fupogfakta/data/questions_d_fest.json new file mode 100644 index 0000000..b754e8f --- /dev/null +++ b/fupogfakta/data/questions_d_fest.json @@ -0,0 +1,1161 @@ +[ + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Før Aqua slog igennem med Barbie Girl, hed gruppen _____ og udgav en cover af Itsy Bitsy Spider. Lowkey cursed origin story.", + "answer": "Joyspeed", + "alternatives": [ + "joyspeed", + "joy speed" + ], + "decoys": [ + "Bubblegum", + "Splashdance", + "Candyrain", + "Seastar" + ], + "note": "No cap, Aqua hed Joyspeed før de blev Aqua, og singlen Itsy Bitsy Spider kom på hitlisten i Sverige. It's giving børnehave-rave for real.", + "source": "https://da.wikipedia.org/wiki/Aqua_(band)", + "pitch": { + "Joyspeed": "Lyder som en energidrik fra 1989, og det er en kompliment.", + "Bubblegum": "[chuckles] Tyggegummipop før det var cool, bestie.", + "Splashdance": "Et bandnavn der danser, før musikken starter.", + "Candyrain": "[excited] Regnvejr af slik, peak 90'er-æstetik.", + "Seastar": "Havtema før Aqua? Det er bare logisk, no cap." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Da Mattel sagsøgte Aquas pladeselskab over Barbie Girl, sluttede den amerikanske dommer afgørelsen med ordene: \"The parties are advised to _____.\"", + "answer": "chill", + "alternatives": [ + "chill", + "to chill" + ], + "decoys": [ + "dance", + "grow up", + "make up", + "shut up" + ], + "note": "Dommer Alex Kozinski ate totalt: \"The parties are advised to chill.\" Main character energy i en amerikansk retssal, og Mattel brugte senere selv sangen i reklamer.", + "source": "https://en.wikipedia.org/wiki/Mattel,_Inc._v._MCA_Records,_Inc.", + "pitch": { + "chill": "[laughs] En dommer med rizz? Det ville være peak.", + "dance": "Det er en Aqua-sag, selvfølgelig skal de danse.", + "grow up": "Lyder som en træt far til to plastikdukker.", + "make up": "[mischievously] Barbie og Ken-energi, kys og bliv venner.", + "shut up": "Hård dommer-energi, ingen pjat i retten." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Whigfield, kvinden bag 90'er-bangeren Saturday Night, er dansk og hedder i virkeligheden _____ Carlson.", + "answer": "Sannie", + "alternatives": [ + "sannie", + "sannie charlotte" + ], + "decoys": [ + "Mette", + "Linnea", + "Tanja", + "Bettina" + ], + "note": "Whigfield er Sannie Charlotte Carlson fra Skælskør, bestie. Saturday Night gik nr. 1 i UK, og i MGP 2018 stillede hun op som bare Sannie.", + "source": "https://en.wikipedia.org/wiki/Whigfield", + "pitch": { + "Sannie": "Et navn så sjældent, at det må være sandt.", + "Mette": "Den mest danske 90'er-pige, no cap.", + "Linnea": "Lyder lidt svensk, og Carlson hjælper jo.", + "Tanja": "[sarcastic] Tanja fra Skælskør, it's giving lokal stjerne.", + "Bettina": "Bettina Carlson? Det er giving eurodance-dronning." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Andrea Fuentealba Valbak skiftede i 2005 navn til Medina efter at have spurgt en _____ til råds.", + "answer": "numerolog", + "alternatives": [ + "numerolog", + "numerologist", + "numerologen" + ], + "decoys": [ + "clairvoyant", + "astrolog", + "tarotlægger", + "spåkone" + ], + "note": "Hun konsulterede en numerolog og blev til Medina Danielle Oona Valbak. Lidt delulu måske, men Kun for mig blev årets bedst sælgende single i 2009, så hun ate.", + "source": "https://da.wikipedia.org/wiki/Medina_(sanger)", + "pitch": { + "numerolog": "[whispers] Tallene lyver aldrig, bestie.", + "clairvoyant": "Hun så navnet i en vision, lowkey creepy.", + "astrolog": "Stjernetegnet bestemte det, obviously.", + "tarotlægger": "[mischievously] Kortene blev lagt, og Medina kom op.", + "spåkone": "Spåkone med krystalkugle, peak 2005-vibe." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Lukas Forchhammer fra Lukas Graham spillede som barn rollen som _____ i de første tre film om Krummerne.", + "answer": "Grunk", + "alternatives": [ + "grunk" + ], + "decoys": [ + "Krumme", + "Pjevs", + "Snusser", + "Lillebror" + ], + "note": "Lukas var Grunk i de tre første Krummerne-film, og senere lagde han dansk stemme til Andy i Toy Story 2 og 3. Childhood unlocked, no cap.", + "source": "https://en.wikipedia.org/wiki/Lukas_Forchhammer", + "pitch": { + "Grunk": "[chuckles] Det lyder som en lyd, ikke et navn, og det passer.", + "Krumme": "Hovedrollen, selvfølgelig, main character fra start.", + "Pjevs": "Et navn der skriger 90'er-familiefilm.", + "Snusser": "Sød lille rolle, giving barnestjerne.", + "Lillebror": "Den klassiske lillebror-rolle, bestie." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Brødrene Olsens Eurovision-vinder Fly on the Wings of Love hed oprindeligt på dansk \"_____\".", + "answer": "Smuk som et stjerneskud", + "alternatives": [ + "smuk som et stjerneskud" + ], + "decoys": [ + "Flyv på kærlighedens vinger", + "Højt over skyerne", + "Vinger af guld", + "Du er min engel" + ], + "note": "Den danske titel var Smuk som et stjerneskud, og Jørgen var 50 år, da de vandt i 2000. Boomer-rizz på absolut max, de slayede Stockholm.", + "source": "https://en.wikipedia.org/wiki/Olsen_Brothers", + "pitch": { + "Smuk som et stjerneskud": "[sighs] Ren poesi, onkel-romantik i topform.", + "Flyv på kærlighedens vinger": "Den direkte oversættelse, it's giving Google Translate.", + "Højt over skyerne": "Lyder som noget, man synger i en flyver.", + "Vinger af guld": "Guld og vinger, Eurovision-bingo, no cap.", + "Du er min engel": "[excited] Peak dansk schlager, jeg græder." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Før solokarrieren og Engel rappede Rasmus Seebach sammen med storebror Nicolai i hiphopgruppen _____.", + "answer": "G-Bach", + "alternatives": [ + "g-bach", + "gbach", + "g bach" + ], + "decoys": [ + "Seebach Boyz", + "Bach & Co.", + "Tommys Drenge", + "Brødrene Beat" + ], + "note": "Rasmus og Nicolai var hiphopgruppen G-Bach og udgav albummet Skakmat i 1999. Engel-Rasmus som rapper er lowkey Ohio, men det er sandt.", + "source": "https://da.wikipedia.org/wiki/Rasmus_Seebach", + "pitch": { + "G-Bach": "[laughs] Klassisk musik møder gangsta-rap, det er giving.", + "Seebach Boyz": "Boyband-navn med z, peak 90'er cringe.", + "Bach & Co.": "Et firmanavn mere end et band, men okay.", + "Tommys Drenge": "[sarcastic] Far Tommy ville være så stolt.", + "Brødrene Beat": "To brødre og en beat, no cap logisk." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Christophers karriere tog fart i 2011, da hans YouTube-cover af Robyns \"_____\" gik viralt.", + "answer": "Call Your Girlfriend", + "alternatives": [ + "call your girlfriend" + ], + "decoys": [ + "Dancing on My Own", + "With Every Heartbeat", + "Show Me Love", + "Be Mine!" + ], + "note": "Christopher og en ven lavede et cover af Robyns Call Your Girlfriend, som eksploderede på YouTube og fik ham hen til EMI. Main character arc via webcam, bestie.", + "source": "https://da.wikipedia.org/wiki/Christopher_(sanger)", + "pitch": { + "Call Your Girlfriend": "[mischievously] En lidt sus titel for en teenager, hehe.", + "Dancing on My Own": "[sighs] Den mest gråd-i-klubben-sang ever.", + "With Every Heartbeat": "Hjerteslag og strygere, det er giving viral.", + "Show Me Love": "Klassisk 90'er-Robyn, ren nostalgi.", + "Be Mine!": "Kort og catchy, perfekt til YouTube." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Før de blev Nik & Jay, stillede drengene fra Værløse op til rapkonkurrencer under navnet _____.", + "answer": "Bagmændene", + "alternatives": [ + "bagmændene", + "bagmaendene" + ], + "decoys": [ + "Forstadsdrengene", + "Rimbanditterne", + "Værløse Vandaler", + "Lækkerdrengene" + ], + "note": "De hed Bagmændene i 1998-99 og rebrandede til Nik & Jay i 2002. Debutalbummet blev det bedst sælgende danske rap/R&B-debutalbum nogensinde, slay.", + "source": "https://da.wikipedia.org/wiki/Nik_%26_Jay", + "pitch": { + "Bagmændene": "[whispers] Lyder sus, men det er bare rap.", + "Forstadsdrengene": "Forstads-energi, det er giving Værløse.", + "Rimbanditterne": "Rim og røverhistorier, no cap.", + "Værløse Vandaler": "[laughs] Alliteration-rizz på max.", + "Lækkerdrengene": "Lækker før Lækker, ret logisk faktisk." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Som blot 16-årig startede Burhan G sin karriere som korsanger for _____.", + "answer": "Outlandish", + "alternatives": [ + "outlandish" + ], + "decoys": [ + "Nik & Jay", + "Jokeren", + "Aqua", + "Infernal" + ], + "note": "Burhan G sang kor for Outlandish i 1999, og før det var han solosopran i Københavns Drengekor. Glow up arc, no cap.", + "source": "https://da.wikipedia.org/wiki/Burhan_G", + "pitch": { + "Outlandish": "Brøndby-forbindelse, det giver bare mening.", + "Nik & Jay": "[chuckles] R&B-drengen med rap-drengene, logisk.", + "Jokeren": "Han kunne ramme de lyse toner, for real.", + "Aqua": "[excited] Barbie Girl med kor? Ja tak.", + "Infernal": "Dancepop-kor, det er giving 90'er." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Alphabeat fra Silkeborg hed oprindeligt _____, men skiftede navn for ikke at blive forvekslet med et tysk band.", + "answer": "Sodastar", + "alternatives": [ + "sodastar", + "soda star" + ], + "decoys": [ + "Glitterbox", + "Silkebeat", + "Popsicle", + "Starfizz" + ], + "note": "De hed Sodastar fra 2003, men en tysk navnebror fik dem til at skifte i 2006. It's giving sodavand, men Alphabeat ate med Fascination.", + "source": "https://da.wikipedia.org/wiki/Alphabeat", + "pitch": { + "Sodastar": "[excited] Brus og stjerner, det er så dem.", + "Glitterbox": "Glimmer i kassen, peak indiepop.", + "Silkebeat": "[chuckles] Silkeborg-rizz i bandnavnet.", + "Popsicle": "Ispind-energi, sommer hele året.", + "Starfizz": "Fizz og stjerner, giving sodavand." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Outlandish-rapperen Lenny Martinez kom som 14-årig til Brøndby Strand fra _____.", + "answer": "Honduras", + "alternatives": [ + "honduras" + ], + "decoys": [ + "Chile", + "Colombia", + "Cuba", + "Peru" + ], + "note": "Lenny er født i Honduras og flyttede til Brøndby Strand i 1991. Outlandish var Honduras, Marokko og Pakistan i én crew, bestie, multikulti slay.", + "source": "https://da.wikipedia.org/wiki/Outlandish", + "pitch": { + "Honduras": "Mellemamerika-vibes, det giver mening.", + "Chile": "Mange chilenere kom til Danmark, logisk.", + "Colombia": "[mischievously] Latin-flow, det er giving Colombia.", + "Cuba": "Salsa og rap, no cap.", + "Peru": "Andes-energi i Brøndby, why not." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Som blot 10-årig startede Joey Moe sit første boyband, som fik det ydmyge navn _____.", + "answer": "Nothing Special", + "alternatives": [ + "nothing special" + ], + "decoys": [ + "Not Ready Yet", + "Just Kidding", + "Almost Famous", + "No Big Deal" + ], + "note": "Han dannede boybandet Nothing Special i 1996 med Theis Keller, der senere kom i Fu:el. Navnet er lowkey humble, men så kom Yo-Yo.", + "source": "https://en.wikipedia.org/wiki/Joey_Moe", + "pitch": { + "Nothing Special": "[laughs] Det mest ydmyge navn nogensinde.", + "Not Ready Yet": "Ærligt navn for en 10-årig, no cap.", + "Just Kidding": "[chuckles] Boyband med selvironi, slay.", + "Almost Famous": "Manifesting før det var en trend.", + "No Big Deal": "Chill navn for chill drenge." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "MGP-vinderen Basim har skrevet sange for boybandet Generations from Exile Tribe, der gik nummer ét i _____.", + "answer": "Japan", + "alternatives": [ + "japan" + ], + "decoys": [ + "Sydkorea", + "Tyrkiet", + "Brasilien", + "Filippinerne" + ], + "note": "Basim skrev til det japanske boyband Generations from Exile Tribe, nr. 1 i Japan i 2016. Fra Cliche Love Song til J-pop, it's giving global rizz.", + "source": "https://da.wikipedia.org/wiki/Basim", + "pitch": { + "Japan": "[excited] J-pop-energi, det giver bare mening.", + "Sydkorea": "K-pop-boyband, obviously, bestie.", + "Tyrkiet": "Boyband-kultur er kæmpe dér, no cap.", + "Brasilien": "[chuckles] Samba-boyband? Lowkey iconic.", + "Filippinerne": "Popmarked i vild vækst, for real." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Ida Corr vandt som 11-årig det allerførste børne-MGP sammen med bandet _____.", + "answer": "Årgang 77", + "alternatives": [ + "årgang 77", + "årgang '77", + "aargang 77", + "argang 77" + ], + "decoys": [ + "Klasse 5B", + "Generation 88", + "Skolegården", + "Nørrebro Kids" + ], + "note": "Ida Corr vandt det første børne-MGP som 11-årig med bandet Årgang 77. Senere sang hun kor for Gnags og Sanne, slay.", + "source": "https://en.wikipedia.org/wiki/Ida_Corr", + "pitch": { + "Årgang 77": "Retro-navn med tal, peak 80'er-vibe.", + "Klasse 5B": "[chuckles] Klasse-band, det er giving skolekoncert.", + "Generation 88": "Futuristisk navn for børn, lowkey cool.", + "Skolegården": "Frikvarter-energi, peak børne-MGP.", + "Nørrebro Kids": "[excited] Storby-kids med swag, no cap." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Rasmus Nøhr fik sit gennembrud med en duet med Ida Corr, der hedder \"_____\".", + "answer": "Det glade pizzabud", + "alternatives": [ + "det glade pizzabud", + "glade pizzabud" + ], + "decoys": [ + "Den sure buschauffør", + "Kærlighed på dåse", + "Fredag på Vesterbro", + "Den sidste kebab" + ], + "note": "Gennembruddet var Det glade pizzabud med Ida Corr. En kærlighedssang om et pizzabud er skibidi-levering, men den virkede for real.", + "source": "https://en.wikipedia.org/wiki/Rasmus_N%C3%B8hr", + "pitch": { + "Det glade pizzabud": "[laughs] Ingen ville finde på det, derfor er det sandt.", + "Den sure buschauffør": "Hverdagspoesi, det er så Nøhr.", + "Kærlighed på dåse": "[sighs] Romantik fra Netto-hylden, bestie.", + "Fredag på Vesterbro": "Vesterbro-vibes, det er giving dansk pop.", + "Den sidste kebab": "Natlig snack-romantik, no cap." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Folk tror, Saybia betyder \"say\" plus \"phobia\", men bandet har sagt, at navnet faktisk kom fra logoet på et _____.", + "answer": "bækken", + "alternatives": [ + "bækken", + "bækkenet", + "sabian-bækken", + "sabian bækken", + "cymbal" + ], + "decoys": [ + "ølkrus", + "keyboard", + "pladecover", + "fodboldtrøje" + ], + "note": "Bandet fra Nyborg har sagt, at navnet er inspireret af logoet på et Sabian-bækken. Trommeslager-rizz, no cap, og debutalbummet gik direkte ind som nr. 1.", + "source": "https://da.wikipedia.org/wiki/Saybia", + "pitch": { + "bækken": "[whispers] Trommeslagerens hævn, bestie.", + "ølkrus": "Øvelokale-kultur, det giver mening.", + "keyboard": "Synth-æra, it's giving 2002.", + "pladecover": "Inspireret af et andet band? Lowkey sus.", + "fodboldtrøje": "[chuckles] Nyborg-fodbold, det er giving lokal." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Suspekt kaldte deres album fra 2017, inspireret af Orgi-E's rejse i Brasilien, for \"_____\".", + "answer": "100% Jesus", + "alternatives": [ + "100% jesus", + "100 procent jesus", + "100 % jesus" + ], + "decoys": [ + "Tropisk Tabu", + "Samba Suspekt", + "Amen Albertslund", + "Himmelfart" + ], + "note": "Albummet hed 100% Jesus og var inspireret af Orgi-E's tur til Brasilien. Gangsta-rap med kristus-tema er lowkey Ohio, men det slog.", + "source": "https://da.wikipedia.org/wiki/Suspekt", + "pitch": { + "100% Jesus": "[laughs] Ingen bandnavn-ironi, bare fuld tro.", + "Tropisk Tabu": "Tabu Records-nik, det er giving.", + "Samba Suspekt": "[mischievously] Albertslund møder Rio, no cap.", + "Amen Albertslund": "Hellig forstad, peak Suspekt-humor.", + "Himmelfart": "Spirituelt rap-arc, bestie." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Cut'N'Moves cover af KC and the Sunshine Bands Give It Up lå nummer ét i fire uger i _____.", + "answer": "Australien", + "alternatives": [ + "australien", + "australia" + ], + "decoys": [ + "Canada", + "Sydafrika", + "Mexico", + "Island" + ], + "note": "Give It Up gik nr. 1 i Australien i fire uger i 1994 og fem uger i Danmark. Danske 90'er-dance bangers rejste langt, slay.", + "source": "https://en.wikipedia.org/wiki/Cut_%27N%27_Move", + "pitch": { + "Australien": "[excited] Down under elsker eurodance, no cap.", + "Canada": "Næsten amerikansk, det giver mening.", + "Sydafrika": "Sommerland på den anden halvkugle, slay.", + "Mexico": "[chuckles] Fiesta-energi, it's giving dance.", + "Island": "Små lande elsker dansk pop, for real." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "I 2002 sang Malene Mortensen Tell Me Who You Are ved Eurovision i Tallinn og gav Danmark noget helt nyt: _____.", + "answer": "sidstepladsen", + "alternatives": [ + "sidstepladsen", + "sidste plads", + "sidsteplads", + "sidste pladsen" + ], + "decoys": [ + "nul point", + "en diskvalifikation", + "en delt sejr", + "en buh-orkan" + ], + "note": "Danmarks første sidsteplads nogensinde, nr. 24 af 24 med 7 point. Ick fra Europa, men Malene var stadig main character.", + "source": "https://en.wikipedia.org/wiki/Denmark_in_the_Eurovision_Song_Contest_2002", + "pitch": { + "sidstepladsen": "[sighs] Historisk på den forkerte måde, bestie.", + "nul point": "[gasps] Den mest ikoniske Eurovision-skam.", + "en diskvalifikation": "Reglerne er stramme, det er giving drama.", + "en delt sejr": "Fair for alle, lowkey wholesome.", + "en buh-orkan": "Publikum var ikke fans, no cap." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Før Me & My og Dub-I-Dub optrådte søstrene Pernille og Susanne Georgi i slut 80'erne som _____.", + "answer": "SuPer Sisters", + "alternatives": [ + "super sisters", + "supersisters" + ], + "decoys": [ + "Georgi Girls", + "Sweet Sisters", + "Double Trouble", + "Pop Søstrene" + ], + "note": "Søstrene hed SuPer Sisters og udgav albums i 1989 og 1990. Senere var Me & My årets bedst sælgende kunstner i Japan, slay.", + "source": "https://da.wikipedia.org/wiki/Me_%26_My", + "pitch": { + "SuPer Sisters": "[excited] Superhelte-søskende, it's giving.", + "Georgi Girls": "Efternavnet som brand, smart move.", + "Sweet Sisters": "[chuckles] Slik-pop-navn, peak 80'er.", + "Double Trouble": "Søskende-kaos, det er bare logisk.", + "Pop Søstrene": "Hjemmestrikket og dansk, no cap." + } + }, + { + "category": "Fest: retro", + "packs": [ + "fest" + ], + "prompt": "Før From Paris to Berlin var Infernal kendt for at proppe _____ ind i dancehits som Kalinka og Highland Fling.", + "answer": "sækkepiber", + "alternatives": [ + "sækkepiber", + "sækkepibe", + "bagpipes" + ], + "decoys": [ + "panfløjter", + "harmonikaer", + "kobjælder", + "blokfløjter" + ], + "note": "Infernal var berømt for sækkepiber i deres tidlige hits som Kalinka og Highland Fling. Skotsk rave i København, lowkey iconic.", + "source": "https://da.wikipedia.org/wiki/Infernal", + "pitch": { + "sækkepiber": "[laughs] Skotsk dance-energi, ingen kan stoppe det.", + "panfløjter": "Panfløjte-trance, peak 90'er.", + "harmonikaer": "Kalinka kræver harmonika, no cap.", + "kobjælder": "[mischievously] Kobjælder i klubben, it's giving.", + "blokfløjter": "Skolemusik-rave, lowkey nostalgisk." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Før han blev Gilli og fik hele Danmark til at stå 'Tidligt op', hed rapperen fra Rødovre i virkeligheden _____ Rosenberg Larsson.", + "answer": "Kian", + "alternatives": [ + "kian" + ], + "decoys": [ + "Mikkel", + "Sami", + "Elias", + "Noah" + ], + "note": "Fr for real, Gilli hedder Kian Rosenberg Larsson på papiret. Kian fra Rødovre, it's giving folkeskoleklasse, ikke rapgud, men aura'en er stadig enorm.", + "source": "https://en.wikipedia.org/wiki/Gilli_(rapper)", + "pitch": { + "Kian": "Kian fra Rødovre lyder bare som en dreng med rizz, no cap.", + "Mikkel": "[chuckles] Mikkel Rosenberg? Lowkey en revisor med bars.", + "Sami": "Sami passer perfekt til en Rødovre-legende, trust.", + "Elias": "Elias er giving soft boy før kæden kom på.", + "Noah": "Noah er det mest 2010'er-navn ever, slay." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Gillis debutalbum 'Kiko' fra 2019 satte dansk rekord, fordi _____ af albummets 16 numre røg ind på singlehitlisten.", + "answer": "13", + "alternatives": [ + "tretten" + ], + "decoys": [ + "11", + "14", + "10", + "15" + ], + "note": "13 ud af 16 numre på hitlisten, bestie. Det er rekord, og Gilli ate hele hitlisten op som en buffet. Main character energy.", + "source": "https://en.wikipedia.org/wiki/Gilli_(rapper)", + "pitch": { + "13": "Uheldigt tal? Ikke for Gilli, han ejer det.", + "11": "11 lyder realistisk, resten var skits, ikke?", + "14": "[excited] 14 numre, det er straight up dominans.", + "10": "Et rundt tal, it's giving rekordbog.", + "15": "15 af 16? Lowkey kun intro'en floppede." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Bandnavnet Lukas Graham er ikke opfundet af et pladeselskab. Det er bare forsangerens fornavn plus hans _____.", + "answer": "mellemnavn", + "alternatives": [ + "mellemnavnet", + "middle name" + ], + "decoys": [ + "mors efternavn", + "kattenavn", + "gadenavn", + "konfirmationsnavn" + ], + "note": "Han hedder Lukas Graham Forchhammer, så bandet er bare hans fornavn plus mellemnavn. Laziest branding ever, men det ate.", + "source": "https://da.wikipedia.org/wiki/Lukas_Forchhammer", + "pitch": { + "mellemnavn": "Mellemnavnet, minimal indsats, maksimal aura.", + "mors efternavn": "Mors efternavn, en sød lille hilsen, for real.", + "kattenavn": "[laughs] Katten hed Graham, det er lowkey iconic.", + "gadenavn": "Gaden han voksede op på, it's giving storytelling.", + "konfirmationsnavn": "Et konfirmationsnavn er mega sus, men hvorfor ikke." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Lukas Grahams album efter debuten har ikke rigtige titler. Fans kender dem bare på deres _____.", + "answer": "farve", + "alternatives": [ + "farver", + "farverne", + "farven" + ], + "decoys": [ + "årstal", + "bogstav", + "dyr", + "måned" + ], + "note": "Blue album, Purple album, Pink album. De hedder bare noget efter farven på coveret, it's giving Pantone-katalog, men slay.", + "source": "https://da.wikipedia.org/wiki/Lukas_Forchhammer", + "pitch": { + "farve": "Farven på coveret, så kan selv din onkel finde det.", + "årstal": "Årstal, så du aldrig glemmer hvornår du var trist.", + "bogstav": "Et bogstav pr. album, lowkey alfabet-core.", + "dyr": "[mischievously] Et dyr pr. album, rent zoo-brainrot.", + "måned": "Opkaldt efter udgivelsesmåneden, it's giving kalender." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Leonora, der vandt Melodi Grand Prix 2019 med 'Love Is Forever', var før musikken dansk juniormester i _____.", + "answer": "kunstskøjteløb", + "alternatives": [ + "kunstskøjteløber", + "skøjteløb", + "skøjteløber" + ], + "decoys": [ + "dressurridning", + "gymnastik", + "badminton", + "synkronsvømning" + ], + "note": "Leonora var dansk juniormester i kunstskøjteløb og var med til junior-VM i 2016. Hun gled bogstaveligt ind på Eurovision-scenen, slay.", + "source": "https://en.wikipedia.org/wiki/Leonora_(singer)", + "pitch": { + "kunstskøjteløb": "Piruetter før popsange, det er giving main character.", + "dressurridning": "[chuckles] Hestepige til Grand Prix, lowkey oplagt.", + "gymnastik": "Turnering og spagat, den holdning kom et sted fra.", + "badminton": "Badminton er så dansk at det næsten er sandt.", + "synkronsvømning": "Synkronsvømning forklarer det smil, no cap." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Før MØ blev verdensstjerne med 'Lean On', var hun med i en feministisk punkduo fra Odense, der hed _____.", + "answer": "MOR", + "alternatives": [ + "mor" + ], + "decoys": [ + "SØSTER", + "Raseri", + "Hekse", + "Bunker" + ], + "note": "MØ var halvdelen af punkduoen MOR fra 2007 til 2012. Fra MOR til MØ, hun mistede bare et R undervejs, iconic for real", + "source": "https://en.wikipedia.org/wiki/M%C3%98", + "pitch": { + "MOR": "Fra MOR til MØ, bare tab et bogstav, slay.", + "SØSTER": "SØSTER lyder som feministisk punk, no cap.", + "Raseri": "[excited] Raseri er giving DIY-kælder i Odense.", + "Hekse": "Hekse er peak 2007-punk, lowkey oplagt.", + "Bunker": "Bunker, fordi koncerterne var i en kælder, trust." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Tobias Rahims nummer et-hit fra sommeren 2022 hedder 'Flyvende _____'.", + "answer": "Faduma", + "alternatives": [ + "faduma" + ], + "decoys": [ + "Fernando", + "Farida", + "Flamingo", + "Fakir" + ], + "note": "'Flyvende Faduma' gik direkte ind som nummer et i august 2022. Hele Danmark sang med på en titel ingen kunne forklare, pure aura.", + "source": "https://en.wikipedia.org/wiki/N%C3%A5r_sj%C3%A6len_kaster_op", + "pitch": { + "Faduma": "Faduma, et navn der svæver, it's giving sommer.", + "Fernando": "[chuckles] Fernando lyder som en ABBA-reference, lowkey.", + "Farida": "Farida, det rimer jo på hele sommeren, no cap.", + "Flamingo": "En flyvende flamingo er peak Tobias-brainrot.", + "Fakir": "Fakir på flyvende tæppe, det giver så meget mening." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Før 'Stor mand' var Tobias Rahim halvdelen af reggaetonduoen Camilo & _____.", + "answer": "Grande", + "alternatives": [ + "grande" + ], + "decoys": [ + "Chico", + "Loco", + "Fuego", + "Diablo" + ], + "note": "Tobias Rahim kaldte sig Grande i reggaetonduoen Camilo & Grande, der debuterede i 2012 med 'Go'e vibes'. Grande før stor mand, ate.", + "source": "https://en.wikipedia.org/wiki/Tobias_Rahim_discography", + "pitch": { + "Grande": "Grande, før han blev stor mand, full circle.", + "Chico": "Chico er det mest reggaeton-agtige ever, for real.", + "Loco": "[laughs] Loco passer jo perfekt til Tobias' energi.", + "Fuego": "Fuego er giving sommerhit på Strøget.", + "Diablo": "Diablo lyder lowkey som en red flag, men cool." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Inden han blev popstjerne, studerede Tobias Rahim sociologi i byen Cali i _____.", + "answer": "Colombia", + "alternatives": [ + "columbia", + "colombia" + ], + "decoys": [ + "Mexico", + "Peru", + "Ecuador", + "Venezuela" + ], + "note": "Tobias Rahim læste sociologi i Cali i Colombia. Så ja, reggaeton-fasen var research, bestie, ikke bare vibes.", + "source": "https://da.wikipedia.org/wiki/Tobias_Rahim", + "pitch": { + "Colombia": "Colombia forklarer reggaeton-fasen, no cap.", + "Mexico": "Mexico er giving taco-fuelet sociologi.", + "Peru": "[mischievously] Peru, mellem lamaer og pensum.", + "Ecuador": "Ecuador, lige på ækvator, max sommerhit-energi.", + "Venezuela": "Venezuela lyder lowkey som en sangtitel fra ham." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Gulddrengs hemmelige identitet blev afsløret, da _____ fra en udvekslingstur skrev hans rigtige navn på Facebook.", + "answer": "værtsfaren", + "alternatives": [ + "værtsfar", + "hans værtsfar", + "faren i værtsfamilien" + ], + "decoys": [ + "fodboldtræneren", + "dansklæreren", + "frisøren", + "naboen" + ], + "note": "Malte Ebert gemte sig bag solbriller, men faren fra hans udvekslingsværtsfamilie skrev navnet på Facebook. Boomer-leak, ikke Ohio-detektiv.", + "source": "https://en.wikipedia.org/wiki/Gulddreng", + "pitch": { + "værtsfaren": "En stolt værtsfar på Facebook, peak boomer-energi.", + "fodboldtræneren": "Træneren genkendte hans løbestil, lowkey sus.", + "dansklæreren": "[chuckles] Dansklæreren genkendte de stavefejl, trust.", + "frisøren": "Frisøren kender altid sandheden, no cap.", + "naboen": "Naboen har set ham uden solbriller, det er ick." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Malte Ebert har forklaret, at han opfandt figuren Gulddreng som en reaktion på _____.", + "answer": "dårlig popmusik", + "alternatives": [ + "dårlig pop", + "dårlig musik" + ], + "decoys": [ + "dansk vejr", + "Instagram-filtre", + "kedelig rap", + "X Factor" + ], + "note": "Gulddreng var lavet som en reaktion på dårlig popmusik. Og så fik han syv nummer et-hits i træk. Satire der ate, delulu er solulu.", + "source": "https://en.wikipedia.org/wiki/Gulddreng", + "pitch": { + "dårlig popmusik": "Han lavede dårlig pop for at drille, og den slog.", + "dansk vejr": "Solbriller hele året, det er protest mod vejret.", + "Instagram-filtre": "[sarcastic] Guldfilter på alt, very deep.", + "kedelig rap": "Kedelig rap fik endelig en modstander, for real", + "X Factor": "Et anti-talentshow-statement, lowkey legit." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Da Fyr og Flamme sang 'Øve os på hinanden' i 2021, var det første gang siden _____, at Danmark stillede op til Eurovision med en sang helt på dansk.", + "answer": "1997", + "alternatives": [ + "97" + ], + "decoys": [ + "1989", + "2000", + "1993", + "2005" + ], + "note": "Fyr og Flamme var de første til at synge helt på dansk ved Eurovision siden 1997. De gik ikke videre, men vibe'n var 10 ud af 10.", + "source": "https://en.wikipedia.org/wiki/Denmark_in_the_Eurovision_Song_Contest_2021", + "pitch": { + "1997": "Halvfemserne var sidste gang, det føles rigtigt.", + "1989": "[gasps] 1989? Så var det Birthe Kjær-æraen, no cap.", + "2000": "Olsen Brothers' år, det lyder logisk, ikke?", + "1993": "1993 er giving gammelt Grand Prix-arkiv.", + "2005": "2005, lowkey sidste gang dansk var cool." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Rasmussen, vikingen fra Grand Prix 2018, er i fritiden forsanger i coverbandet Hair Metal _____.", + "answer": "Heröes", + "alternatives": [ + "heroes", + "heröes" + ], + "decoys": [ + "Legends", + "Zombies", + "Daddies", + "Vikings" + ], + "note": "Rasmussen fronter coverbandet Hair Metal Heröes med Bon Jovi og Def Leppard. Vikingeskæg plus hårspray, ultimativ aura.", + "source": "https://en.wikipedia.org/wiki/Rasmussen_(singer)", + "pitch": { + "Heröes": "Med metal-umlaut, selvfølgelig, slay.", + "Legends": "Legends, fordi beskedenhed er en red flag.", + "Zombies": "[laughs] Zombies passer til de der hårspray-år.", + "Daddies": "Daddies er lowkey det mest ærlige navn.", + "Vikings": "Vikings, han kunne bare ikke slippe skægget." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "I Netflix-filmen 'A Beautiful Life' spiller Christopher Elliott, en ung _____, der bliver opdaget af en stor musikproducer.", + "answer": "fisker", + "alternatives": [ + "fiskeren" + ], + "decoys": [ + "tømrer", + "bager", + "postbud", + "bartender" + ], + "note": "Christopher spiller fiskeren Elliott, der bliver opdaget for sin stemme. Netflix-debut og verdenshit, fisk og rizz på samme tid.", + "source": "https://en.wikipedia.org/wiki/A_Beautiful_Life_(2023_film)", + "pitch": { + "fisker": "Han trak ikke kun fisk op, også fans, no cap.", + "tømrer": "En tømrer med sixpack, det er giving Netflix.", + "bager": "[chuckles] Bager om dagen, popstjerne om natten.", + "postbud": "Postbud der synger ved hver dør, iconic.", + "bartender": "Bartender med guitar er peak romcom-core." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Før han slog igennem som rapper, havde Benjamin Hav job som administrativ medarbejder i et _____.", + "answer": "jobcenter", + "alternatives": [ + "et jobcenter" + ], + "decoys": [ + "skattecenter", + "borgerservice", + "rejsebureau", + "pengeinstitut" + ], + "note": "Benjamin Hav arbejdede i et jobcenter og senere i et callcenter, før rappen tog over. Fra CV-tjek til GAFFA-priser, glow up for real", + "source": "https://da.wikipedia.org/wiki/Benjamin_Hav", + "pitch": { + "jobcenter": "Han hjalp andre i job og fandt sit eget, ate.", + "skattecenter": "Skattecenter forklarer de tørre punchlines, lowkey.", + "borgerservice": "[sighs] Tag et nummer, det er giving pas-fornyelse.", + "rejsebureau": "Rejsebureau, derfor drømmer han så meget.", + "pengeinstitut": "Pengeinstitut, han kendte til guld før kæden." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Benjamin Hav har et nummer med den mundvandsdrivende titel 'Cannelloni & _____'.", + "answer": "Kød", + "alternatives": [ + "kød", + "kod" + ], + "decoys": [ + "Kærlighed", + "Kaos", + "Ketchup", + "Kammerater" + ], + "note": "Sangen hedder bogstaveligt 'Cannelloni & Kød'. Ingen kunstfærdig metafor, bare aftensmad. Det er så dumt at det er genialt, no cap.", + "source": "https://da.wikipedia.org/wiki/Benjamin_Hav", + "pitch": { + "Kød": "Bare aftensmaden, ærligt og sultent.", + "Kærlighed": "Pasta og kærlighed, lowkey romcom-titel.", + "Kaos": "[mischievously] Cannelloni og kaos, hans livsmotto.", + "Ketchup": "Ketchup på cannelloni er en kæmpe ick, men sandt?", + "Kammerater": "Mad med vennerne, wholesome core." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "I december 2024 gik Benjamin Hav og Lukas Graham direkte ind som nummer et med sangen 'Du ligner din _____'.", + "answer": "mor", + "alternatives": [ + "mor" + ], + "decoys": [ + "far", + "bror", + "nabo", + "moster" + ], + "note": "'Du ligner din mor' røg ind som nummer et og blev dobbelt platin. Den værste og bedste kompliment i ét, ægte roast-energi.", + "source": "https://da.wikipedia.org/wiki/Benjamin_Hav", + "pitch": { + "mor": "Det mest danske diss ever, for real.", + "far": "Ligner din far, det er peak julefrokost.", + "bror": "[chuckles] Ligner din bror, lowkey sus kompliment.", + "nabo": "Ligner din nabo, nu bliver det skandale.", + "moster": "Ligner din moster, det er giving familiefest." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Lang tid før 'Stor mand' blev Andreas Odbjerg nummer to i anden sæson af _____.", + "answer": "The Voice", + "alternatives": [ + "voice", + "the voice danmark" + ], + "decoys": [ + "Popstars", + "Danmark har talent", + "Stjerne for en aften", + "Vild med dans" + ], + "note": "Andreas Odbjerg blev nummer to i The Voice i 2012 og fik pladekontrakt. Ti år senere skrev han landets største sang, comeback-arc.", + "source": "https://da.wikipedia.org/wiki/Andreas_Odbjerg", + "pitch": { + "The Voice": "Dommerne vendte stolen og han vendte karrieren.", + "Popstars": "Popstars er giving 00'er-boyband, no cap.", + "Danmark har talent": "[excited] Han sang for Danmarks talentfulde hunde.", + "Stjerne for en aften": "Stjerne for en aften, han ville have flere.", + "Vild med dans": "Vild med dans, rytmen skulle jo komme et sted fra." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Ericka Jane fik sin pladekontrakt, efter at hendes YouTube-cover af Drakes '_____' gik viralt.", + "answer": "Marvin's Room", + "alternatives": [ + "marvins room", + "marvin's room" + ], + "decoys": [ + "Take Care", + "Hotline Bling", + "Headlines", + "Find Your Love" + ], + "note": "Ericka Jane røg ud af X Factor i bootcamp, men hendes cover af 'Marvin's Room' gik viralt og gav pladekontrakt. Rejection til rizz.", + "source": "https://da.wikipedia.org/wiki/Ericka_Jane", + "pitch": { + "Marvin's Room": "Den mest emo Drake-sang, perfekt til YouTube.", + "Take Care": "Take Care er giving soft era, no cap.", + "Hotline Bling": "[laughs] Hotline Bling på akustisk, lowkey legendarisk.", + "Headlines": "Headlines, og hun kom selv i dem.", + "Find Your Love": "Find Your Love, hun fandt et pladeselskab." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Reiley, der vandt Melodi Grand Prix 2023 med over 10 millioner TikTok-følgere i ryggen, blev den første Eurovision-deltager nogensinde fra _____.", + "answer": "Færøerne", + "alternatives": [ + "faroe islands", + "færøerne" + ], + "decoys": [ + "Bornholm", + "Grønland", + "Læsø", + "Ærø" + ], + "note": "Reiley fra Tórshavn blev den første færing ved Eurovision. TikTok-cover-drengen gik fra telefonen til Liverpool, main character for real", + "source": "https://en.wikipedia.org/wiki/Denmark_in_the_Eurovision_Song_Contest_2023", + "pitch": { + "Færøerne": "Fårene stemte, og TikTok gjorde resten.", + "Bornholm": "Bornholm, endelig får solskinsøen noget aura.", + "Grønland": "[gasps] Grønland, det ville være mega historisk.", + "Læsø": "Læsø med tusind indbyggere og ti millioner følgere.", + "Ærø": "Ærø, den mest hyggelige rizz i Danmark." + } + }, + { + "category": "Fest: nu", + "packs": [ + "fest" + ], + "prompt": "Ben og Tan, der vandt Grand Prix 2020 med 'Yes', havde året før været _____ i X Factor.", + "answer": "konkurrenter", + "alternatives": [ + "modstandere", + "rivaler" + ], + "decoys": [ + "dommere", + "backupdansere", + "statister", + "værter" + ], + "note": "Ben blev nummer to som solist og Tan var i gruppen Echo i samme X Factor. Rivaler blev en duo, men Eurovision blev aflyst pga. corona. Ick.", + "source": "https://en.wikipedia.org/wiki/Ben_and_Tan", + "pitch": { + "konkurrenter": "Fra fjender til duo, enemies to lovers-core.", + "dommere": "[sarcastic] Teenagedommere, helt normalt, no cap.", + "backupdansere": "Backupdansere der stjal showet, slay.", + "statister": "Statister i publikum, lowkey en origin story.", + "værter": "Værter med mikrofon, de øvede bare." + } + } +] diff --git a/fupogfakta/engine.py b/fupogfakta/engine.py index d64ede1..e2c2e60 100644 --- a/fupogfakta/engine.py +++ b/fupogfakta/engine.py @@ -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 diff --git a/fupogfakta/models.py b/fupogfakta/models.py index 481a8ac..8c56624 100644 --- a/fupogfakta/models.py +++ b/fupogfakta/models.py @@ -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): diff --git a/fupogfakta/narrator/lines.json b/fupogfakta/narrator/lines.json index 6bb4326..abedd5b 100644 --- a/fupogfakta/narrator/lines.json +++ b/fupogfakta/narrator/lines.json @@ -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." ] -} \ No newline at end of file +} diff --git a/fupogfakta/tests/test_engine.py b/fupogfakta/tests/test_engine.py index 579ded0..a441d37 100644 --- a/fupogfakta/tests/test_engine.py +++ b/fupogfakta/tests/test_engine.py @@ -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"]) diff --git a/fupogfakta/tests/test_narrator.py b/fupogfakta/tests/test_narrator.py index 8a8a7e6..7fb2aa2 100644 --- a/fupogfakta/tests/test_narrator.py +++ b/fupogfakta/tests/test_narrator.py @@ -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: "_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): diff --git a/party/management/commands/tts_warm.py b/party/management/commands/tts_warm.py index 3148055..66bdca5 100644 --- a/party/management/commands/tts_warm.py +++ b/party/management/commands/tts_warm.py @@ -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 "_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):] diff --git a/party/narrator.py b/party/narrator.py index f691fb4..590b209 100644 --- a/party/narrator.py +++ b/party/narrator.py @@ -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 "_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 diff --git a/party/rooms.py b/party/rooms.py index 6dfbc42..316d6a1 100644 --- a/party/rooms.py +++ b/party/rooms.py @@ -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)) diff --git a/party/static/party/dev-fixtures.js b/party/static/party/dev-fixtures.js index bff6f37..0a1a48a 100644 --- a/party/static/party/dev-fixtures.js +++ b/party/static/party/dev-fixtures.js @@ -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 }; })(); diff --git a/party/static/party/dev.html b/party/static/party/dev.html index b5b93c2..c221fe0 100644 --- a/party/static/party/dev.html +++ b/party/static/party/dev.html @@ -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); diff --git a/party/static/party/host.js b/party/static/party/host.js index 3e617d8..0748da7 100644 --- a/party/static/party/host.js +++ b/party/static/party/host.js @@ -235,6 +235,19 @@ '
' + WPP.timerHtml(state, 'ring') + '
'; } + /* 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 '
' + (s.everyone_drinks ? '🍻 ALLE DRIKKER!' : '') + + (s.sips || []).map(function (x) { + return '' + WPP.chip(players[x.player_id]) + '🥃 ' + esc(sipText(state, x.n)) + ''; + }).join('') + '
'; + } + function reveal(state, u) { var r = state.reveal || {}; var steps = r.steps || []; @@ -262,16 +275,16 @@ }).join(''); if (s.is_truth) { stamp = '
SANDHEDEN
' + - (pts ? '
' + pts + '
' : '
Ingen fandt sandheden!
') + + (pts ? '
' + pts + '
' : '
Ingen fandt sandheden!
') + sipsHtml(state, s, players) + (r.note ? '
' + esc(r.note) + '
' : '') + '
'; } else if (s.house) { - stamp = '
HUSETS LØGN
Huset narrede jer!
'; + stamp = '
HUSETS LØGN
Huset narrede jer!
' + sipsHtml(state, s, players) + '
'; } else { var auth = (s.authors || []).map(function (id) { return players[id] ? esc(players[id].name) + ((s.assisted || []).indexOf(id) >= 0 ? ' (Lyv for mig)' : '') : '?'; }); stamp = '
LØGN
af ' + WPP.joinNames(auth) + '
' + - (pts ? '
' + pts + '
' : '') + '
'; + (pts ? '
' + pts + '
' : '') + sipsHtml(state, s, players) + ''; } } /* 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 '
' + '

Alle svarene

' + '
' + tiles + '
' + - '

Point i spørgsmålet

' + + '

' + (fest ? '🥃 Tår (i alt)' : 'Point i spørgsmålet') + '

' + '
    ' + rows + '
'; } /* 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 '
  • ' + - '' + (i + 1) + '' + avatar(p) + '' + esc(p.name) + '' + + '' + (i + 1) + '' + avatar(p) + '' + esc(p.name) + + (state.fest ? ' 🥃 ' + (p.sips || 0) + '' : '') + '' + '' + (d ? '' + WPP.signed(d) + '' : '') + '' + diff --git a/party/static/party/party.css b/party/static/party/party.css index 25b9d42..3e3ae5e 100644 --- a/party/static/party/party.css +++ b/party/static/party/party.css @@ -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; } diff --git a/party/static/party/play.js b/party/static/party/play.js index 1501700..965bff9 100644 --- a/party/static/party/play.js +++ b/party/static/party/play.js @@ -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 += '
    ' + + (n ? '🥃Tag ' + what + '!' : '😎Du slap. Ingen tår.') + '
    '; + } if (pr.done && me.role === 'player' && me.last_delta != null) { - out += '
    ' + + out += '
    ' + (me.last_delta > 0 ? '' + WPP.signed(me.last_delta) + 'point til dig!' : '0point denne gang') + '
    '; } @@ -325,6 +331,7 @@ if (r) { out += '
    ' + r.place + '.

    plads af ' + r.of + '

    ' + '
    ' + WPP.fmt(r.p.score) + ' point
    ' + + (state.fest ? '
    🥃 ' + (me.sips || 0) + ' tår i aften
    ' : '') + (me.last_delta ? '
    ' + WPP.signed(me.last_delta) + ' i runden
    ' : '') + '
    '; } var ps = (state.players || []).slice().sort(function (a, b) { return (b.score || 0) - (a.score || 0); }); diff --git a/party/templates/party/host_new.html b/party/templates/party/host_new.html index 019b654..6460407 100644 --- a/party/templates/party/host_new.html +++ b/party/templates/party/host_new.html @@ -14,6 +14,8 @@ Flere valg…

    Arbejdsspørgsmål: e-faktura, IT-sikkerhed og kategorier med pakken "arbejde".

    + +

    Dansk pop-sladder med zoomer-speaker. Narret = tag en tår, finalen er en shot.

    diff --git a/party/views.py b/party/views.py index 06f31ce..fb158f0 100644 --- a/party/views.py +++ b/party/views.py @@ -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) diff --git a/partyhub/settings.py b/partyhub/settings.py index de6d9d4..5e572d3 100644 --- a/partyhub/settings.py +++ b/partyhub/settings.py @@ -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