diff --git a/deploy/bots.py b/deploy/bots.py new file mode 100644 index 0000000..ba95392 --- /dev/null +++ b/deploy/bots.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""Spil et helt spil med bots mod en kørende server (røgtest). + +Kør i CT143 (eller lokalt), hvor værts-headeren stoles på fra 127.0.0.1: + /opt/wpp/venv/bin/python deploy/bots.py --base http://127.0.0.1:8000 --players 3 --audience 1 + +Opretter et rum som vært, joiner N spillere og M publikummer, og lader dem lyve, +gætte og like tilfældigt, indtil spillet er slut. Afslutter med kode 0, hvis alle +faser blev nået og slutstillingen blev vist. +""" + +import argparse +import asyncio +import http.cookiejar +import json +import random +import re +import sys +import urllib.parse +import urllib.request + +import websockets + +HOST_HEADERS = {"X-authentik-username": "bot-host", "X-authentik-groups": "Familie"} + + +def create_room(base: str) -> str: + jar = http.cookiejar.CookieJar() + opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) + req = urllib.request.Request(f"{base}/host/", headers=HOST_HEADERS) + html = opener.open(req).read().decode() + token = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', html).group(1) + data = urllib.parse.urlencode({"csrfmiddlewaretoken": token}).encode() + req = urllib.request.Request(f"{base}/host/new", data=data, headers={**HOST_HEADERS, "Referer": f"{base}/host/"}) + resp = opener.open(req) + return resp.geturl().rstrip("/").rsplit("/", 1)[-1] + + +async def host(ws_base, code, seen: list, done: asyncio.Event): + async with websockets.connect(f"{ws_base}/ws/host/{code}/", additional_headers=HOST_HEADERS) as ws: + async for raw in ws: + msg = json.loads(raw) + if msg["type"] != "state": + continue + st = msg["state"] + if not seen or seen[-1] != st["phase"]: + seen.append(st["phase"]) + extra = st.get("question", {}).get("prompt", "") if st["phase"] == "lie" else "" + print(f"[vært] {st['phase']:8} {extra}") + if st["phase"] == "final": + for r in st["final"]["ranking"]: + name = next(p["name"] for p in st["players"] if p["id"] == r["player_id"]) + print(f" {r['place']}. {name} {r['score']}") + for a in st["final"]["awards"]: + print(f" {a['title']}: {a['detail']}") + done.set() + return + + +async def player(ws_base, code, name, n_players, done: asyncio.Event, rng: random.Random): + async with websockets.connect(f"{ws_base}/ws/play/{code}/") as ws: + await ws.send(json.dumps({"action": "join", "name": name})) + acted = set() + while not done.is_set(): + try: + raw = await asyncio.wait_for(ws.recv(), timeout=1) + except asyncio.TimeoutError: + continue + msg = json.loads(raw) + if msg["type"] == "error": + print(f"[{name}] fejl: {msg['code']} {msg['message']}") + continue + if msg["type"] != "state": + continue + st, me = msg["state"], msg["state"]["me"] + key = (st["phase"], (st.get("round") or {}).get("number"), (st.get("round") or {}).get("question_number")) + await asyncio.sleep(rng.uniform(0.05, 0.4)) + if st["phase"] == "lobby" and me["vip"] and len(st["players"]) >= n_players and key not in acted: + acted.add(key) + await ws.send(json.dumps({"action": "start"})) + elif st["phase"] == "category" and me["is_chooser"] and key not in acted: + acted.add(key) + await ws.send(json.dumps({"action": "choose_category", "category_id": st["category"]["options"][0]["id"]})) + elif st["phase"] == "lie" and me["role"] == "player" and not me["lie"] and key not in acted: + acted.add(key) + if rng.random() < 0.3: + await ws.send(json.dumps({"action": "lie_for_me"})) + sug = json.loads(await ws.recv()) + text = sug.get("text") if sug.get("type") == "suggestion" else None + else: + text = None + await ws.send(json.dumps({"action": "submit_lie", "text": text or f"{name}s løgn {rng.randint(1, 999)}"})) + elif st["phase"] == "guess" and not me["guess"] and key not in acted: + acted.add(key) + answer = rng.choice(st["answers"]) + await ws.send(json.dumps({"action": "guess", "answer_id": answer["id"]})) + others = [a for a in st["answers"] if a["id"] != answer["id"] and a["id"] != me.get("own_answer_id")] + if others and rng.random() < 0.5: + await ws.send(json.dumps({"action": "like", "answer_id": rng.choice(others)["id"]})) + + +async def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--base", default="http://127.0.0.1:8000") + ap.add_argument("--players", type=int, default=3) + ap.add_argument("--audience", type=int, default=1) + ap.add_argument("--seed", type=int, default=None) + args = ap.parse_args() + rng = random.Random(args.seed) + code = create_room(args.base) + print(f"Rum {code}") + ws_base = args.base.replace("http", "ws", 1) + seen, done = [], asyncio.Event() + names = ["Asger", "Sus", "Jørn", "Mette", "Kaj", "Bodil", "Ib", "Lis"][: args.players] + tasks = [asyncio.create_task(host(ws_base, code, seen, done))] + await asyncio.sleep(0.2) + for n in names: + tasks.append(asyncio.create_task(player(ws_base, code, n, args.players, done, rng))) + await asyncio.sleep(0.1) + # Publikum joiner, når spillet er i gang (i lobbyen ville de blive spillere). + for _ in range(200): + if "category" in seen: + break + await asyncio.sleep(0.1) + for i in range(args.audience): + tasks.append(asyncio.create_task(player(ws_base, code, f"Publikum{i + 1}", args.players, done, rng))) + try: + await asyncio.wait_for(done.wait(), timeout=600) + except asyncio.TimeoutError: + print("Spillet blev ikke færdigt inden for 10 minutter", file=sys.stderr) + return 1 + for t in tasks: + t.cancel() + needed = {"lobby", "category", "lie", "guess", "reveal", "scores", "final"} + missing = needed - set(seen) + if missing: + print(f"Faser der aldrig blev nået: {missing}", file=sys.stderr) + return 1 + print("OK: helt spil gennemført") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/fupogfakta/data/questions_a.json b/fupogfakta/data/questions_a.json index 7838fdf..24796e0 100644 --- a/fupogfakta/data/questions_a.json +++ b/fupogfakta/data/questions_a.json @@ -1,81 +1,1166 @@ [ -{"category": "Dyreriget", "prompt": "En flok ugler kaldes på engelsk et _____.", "answer": "parliament", "alternatives": ["et parliament", "parlament"], "decoys": ["library", "choir", "cabinet"], "note": "\"A parliament of owls\" er den klassiske engelske gruppebetegnelse for ugler, inspireret af uglens ry for visdom."}, -{"category": "Dyreriget", "prompt": "Ottearmede blæksprutter har _____ hjerter.", "answer": "tre", "alternatives": ["3"], "decoys": ["to", "fire", "otte"], "note": "To gællehjerter pumper blodet gennem gællerne, og ét systemisk hjerte pumper det rundt i resten af kroppen."}, -{"category": "Dyreriget", "prompt": "Koalaer har _____, der næsten ikke kan skelnes fra menneskers.", "answer": "fingeraftryk", "alternatives": ["fingeraftrykkene"], "decoys": ["tænder", "øjenvipper", "stemmebånd"], "note": "Koalaens fingeraftryk ligner menneskers så meget, at selv eksperter kan have svært ved at se forskel (Henneberg, 1996)."}, -{"category": "Dyreriget", "prompt": "Dolkhalens blod er _____ og bruges til at teste medicin for bakterieforurening.", "answer": "blåt", "alternatives": ["blå", "lyseblåt"], "decoys": ["grønt", "gult", "sort"], "note": "Dolkhaler bruger kobberholdigt hæmocyanin i stedet for jernholdigt hæmoglobin. Deres blod giver LAL-testen for bakterietoksiner."}, -{"category": "Dyreriget", "prompt": "Vombatten er kendt for at lave _____ lorte.", "answer": "terningformede", "alternatives": ["firkantede", "kubiske", "terningeformede"], "decoys": ["spiralformede", "stjerneformede", "trekantede"], "note": "Vombattens tarm har bløde og stive partier, der former afføringen til små terninger. Forskningen fik Ig Nobel-prisen i 2019."}, -{"category": "Dyreriget", "prompt": "En giraf har _____ halshvirvler, præcis lige så mange som et menneske.", "answer": "syv", "alternatives": ["7"], "decoys": ["tolv", "fjorten", "toogtyve"], "note": "Næsten alle pattedyr har syv halshvirvler. Girafens er bare op til 25 cm lange hver."}, -{"category": "Dyreriget", "prompt": "Rensdyrs øjne skifter farve fra gyldne om sommeren til _____ om vinteren.", "answer": "blå", "alternatives": ["dybblå", "blålige"], "decoys": ["røde", "grønne", "hvide"], "note": "Det reflekterende lag bag nethinden ændrer sig i det arktiske vintermørke, så mere lys opfanges (Jeffery m.fl., 2013)."}, -{"category": "Dyreriget", "prompt": "Kolibrier er de eneste fugle, der kan flyve _____ i længere tid.", "answer": "baglæns", "alternatives": ["bagud", "bagud i luften"], "decoys": ["på hovedet", "under vand", "i søvne"], "note": "Kolibriens skulderled lader vingerne rotere, så den kan svæve stille og flyve baglæns."}, -{"category": "Dyreriget", "prompt": "Søstjerner kan skubbe deres _____ ud af kroppen for at fordøje byttet udenfor.", "answer": "mave", "alternatives": ["mavesæk", "maven"], "decoys": ["tunge", "lunge", "hjerne"], "note": "Søstjernen vrænger sin mavesæk ud gennem munden og ind i fx en musling, hvor føden fordøjes."}, -{"category": "Dyreriget", "prompt": "Hannen hos næbdyret har _____ på bagbenene.", "answer": "giftsporer", "alternatives": ["giftpigge", "giftige sporer", "giftige pigge", "sporer"], "decoys": ["sugekopper", "små vinger", "en ekstra tå"], "note": "Næbdyr-hannen er et af de få giftige pattedyr. Giften fra sporerne giver voldsomme smerter hos mennesker."}, -{"category": "Dyreriget", "prompt": "Den europæiske ål, også den fra danske åer, gyder i _____.", "answer": "Sargassohavet", "alternatives": ["sargassohavet", "Sargassohavet i Atlanterhavet"], "decoys": ["Middelhavet", "Østersøen", "Det Kaspiske Hav"], "note": "Danskeren Johannes Schmidt sporede i 1920'erne ålelarverne tilbage til Sargassohavet i Vestatlanten."}, -{"category": "Dyreriget", "prompt": "Narhvalens lange spyd er i virkeligheden en _____.", "answer": "tand", "alternatives": ["hjørnetand", "stødtand"], "decoys": ["næseknogle", "hornplade", "forstenet tunge"], "note": "Spyddet er en hjørnetand, der hos hannen vokser spiralsnoet ud gennem overlæben, typisk på venstre side."}, -{"category": "Dyreriget", "prompt": "Katte kan slet ikke smage _____.", "answer": "sødt", "alternatives": ["sødme", "sukker", "søde ting"], "decoys": ["salt", "surt", "bittert"], "note": "Genet for den ene halvdel af sød-receptoren er defekt hos katte, så de kan ikke registrere sødme."}, -{"category": "Dyreriget", "prompt": "Sommerfugle smager med deres _____.", "answer": "fødder", "alternatives": ["ben", "fod"], "decoys": ["vinger", "øjne", "ryg"], "note": "Smagsreceptorer på fødderne lader hunnen tjekke, om en plante egner sig til at lægge æg på."}, -{"category": "Dyreriget", "prompt": "Ifølge en dansk ledet undersøgelse fra 2016 kan grønlandshajen blive omkring _____ år gammel.", "answer": "400", "alternatives": ["fire hundrede", "392", "næsten 400"], "decoys": ["120", "180", "900"], "note": "Kulstof-14-datering af øjenlinser gav et estimat på 392 ± 120 år, hvilket gør den til verdens længstlevende kendte hvirveldyr."}, - -{"category": "Kroppen", "prompt": "Menneskekroppens mindste knogle sidder i _____.", "answer": "øret", "alternatives": ["mellemøret", "øre", "ørerne"], "decoys": ["lilletåen", "næsen", "lillefingeren"], "note": "Stigbøjlen i mellemøret er kun ca. 3 mm lang."}, -{"category": "Kroppen", "prompt": "_____ i øjet har ingen blodkar og får sin ilt direkte fra luften.", "answer": "hornhinden", "alternatives": ["hornhinde", "cornea", "Hornhinden"], "decoys": ["nethinden", "regnbuehinden", "synsnerven"], "note": "Hornhinden skal være helt gennemsigtig og har derfor ingen blodkar. Ilten optages fra tårefilmen og luften."}, -{"category": "Kroppen", "prompt": "Nyfødte har endnu ingen knæskaller af knogle, de består nemlig af _____.", "answer": "brusk", "alternatives": ["bruskvæv"], "decoys": ["fedt", "muskler", "væske"], "note": "Knæskallen begynder først at forbene sig i 3-6-årsalderen."}, -{"category": "Kroppen", "prompt": "Patienter kan være vågne under hjerneoperationer, fordi selve hjernevævet mangler _____.", "answer": "smertereceptorer", "alternatives": ["smertesensorer", "smertenerver", "nociceptorer", "smertefølere"], "decoys": ["blodkar", "nerveceller", "fedt"], "note": "Hjernen registrerer smerte i resten af kroppen, men hjernevævet selv har ingen smertereceptorer."}, -{"category": "Kroppen", "prompt": "Mavesækken undgår at fordøje sig selv ved hele tiden at danne et nyt lag _____.", "answer": "slim", "alternatives": ["slimhinde", "slimlag"], "decoys": ["fedt", "kalk", "hår"], "note": "Et tykt, basisk slimlag beskytter mavevæggen mod saltsyren og fordøjelsesenzymerne."}, -{"category": "Kroppen", "prompt": "Tandemalje er det _____ væv i menneskekroppen.", "answer": "hårdeste", "alternatives": ["stærkeste", "mest hårde"], "decoys": ["blødeste", "letteste", "mest elastiske"], "note": "Emaljen består af ca. 96 % mineral og er hårdere end knogle."}, -{"category": "Kroppen", "prompt": "Gåsehud er en rest fra dengang, vores forfædre havde _____.", "answer": "pels", "alternatives": ["mere pels", "mere hår", "kropsbehåring", "tæt pels"], "decoys": ["fjer", "skæl", "pigge"], "note": "Små muskler rejser hårene. Hos pelsdyr giver det isolering eller får dyret til at se større ud."}, -{"category": "Kroppen", "prompt": "Det lægelige navn for hikke er _____.", "answer": "singultus", "alternatives": ["singultation"], "decoys": ["diafragmose", "hiccupitis", "spasmus glottis"], "note": "Singultus kommer af latin og betyder noget i retning af et hulk eller en gisp."}, -{"category": "Kroppen", "prompt": "Babyer fødes med omkring 300 knogler, men en voksen har kun _____.", "answer": "206", "alternatives": ["to hundrede og seks"], "decoys": ["250", "180", "312"], "note": "Mange knogler vokser sammen gennem barndommen, fx i kraniet og bækkenet."}, -{"category": "Kroppen", "prompt": "Knæk i fingrene skyldes _____ i ledvæsken.", "answer": "gasbobler", "alternatives": ["bobler", "luftbobler", "gas", "en gasboble"], "decoys": ["kalkkrystaller", "små knoglesplinter", "størknet blod"], "note": "Når leddet trækkes fra hinanden, falder trykket, og der dannes en gasboble i ledvæsken (MR-studie, 2015)."}, -{"category": "Kroppen", "prompt": "Om ens ørevoks er tør eller våd, afgøres af ét enkelt _____.", "answer": "gen", "alternatives": ["genet", "arveanlæg"], "decoys": ["hormon", "vitamin", "mineral"], "note": "Varianter af genet ABCC11 bestemmer ørevoksens type. Tør voks er almindelig i Østasien (2006)."}, -{"category": "Kroppen", "prompt": "Hjernen udgør kun ca. 2 % af kropsvægten, men bruger omkring _____ af kroppens energi.", "answer": "20 procent", "alternatives": ["20 %", "20%", "en femtedel", "tyve procent"], "decoys": ["5 procent", "50 procent", "2 procent"], "note": "I hvile står hjernen for omkring en femtedel af kroppens energiforbrug."}, -{"category": "Kroppen", "prompt": "Den danske læge Niels Finsen fik Nobelprisen i 1903 for at behandle hudtuberkulose med _____.", "answer": "lys", "alternatives": ["lysbehandling", "koncentreret lys", "UV-lys"], "decoys": ["kulde", "radium", "honning"], "note": "Finsen brugte koncentreret lys mod lupus vulgaris og var Danmarks første nobelpristager i medicin."}, -{"category": "Kroppen", "prompt": "K-vitamin har sit bogstav fra ordet _____.", "answer": "koagulation", "alternatives": ["koagulering", "Koagulation"], "decoys": ["kalk", "kartoffel", "kost"], "note": "Danske Henrik Dam opdagede vitaminet og opkaldte det efter dets rolle i blodets størkning. Han fik Nobelprisen i 1943."}, -{"category": "Kroppen", "prompt": "Et voksent menneskehjerte slår omkring _____ gange i døgnet.", "answer": "100.000", "alternatives": ["100000", "hundrede tusind", "100 000"], "decoys": ["10.000", "30.000", "1 million"], "note": "Med en hvilepuls på 60-80 slag i minuttet giver det ca. 86.000-115.000 slag i døgnet."}, - -{"category": "Mad og drikke", "prompt": "Jordnødder er botanisk set ikke nødder, men _____.", "answer": "bælgfrugter", "alternatives": ["bælgplanter", "ærteplanter", "bælgfrugt"], "decoys": ["rodknolde", "kornsorter", "citrusfrugter"], "note": "Jordnødden er i familie med ærter og bønner og modner sine bælge under jorden."}, -{"category": "Mad og drikke", "prompt": "Kiwifrugten hed oprindeligt _____.", "answer": "kinesisk stikkelsbær", "alternatives": ["kinesiske stikkelsbær", "Chinese gooseberry"], "decoys": ["australsk kartoffel", "abeæble", "behåret blomme"], "note": "Frugten stammer fra Kina. New Zealandske eksportører omdøbte den til kiwifrugt omkring 1959."}, -{"category": "Mad og drikke", "prompt": "De første dyrkede gulerødder var ikke orange, men _____.", "answer": "lilla", "alternatives": ["gule", "lilla og gule", "violette"], "decoys": ["blå", "grønne", "lyserøde"], "note": "Tidlige gulerødder fra Centralasien var lilla eller gule. Den orange gulerod blev udviklet i Europa, især i Holland."}, -{"category": "Mad og drikke", "prompt": "Kaffebønner er egentlig _____ fra en bærlignende frugt.", "answer": "frø", "alternatives": ["kerner", "stenkerner", "frøene"], "decoys": ["rødder", "blomsterknopper", "tørrede bær"], "note": "Hvert kaffebær indeholder normalt to frø, som ristes til det, vi kalder kaffebønner."}, -{"category": "Mad og drikke", "prompt": "Den wasabi, man får til sushi i Europa, er oftest farvet _____.", "answer": "peberrod", "alternatives": ["peberrødder"], "decoys": ["ingefær", "rucola", "ærter"], "note": "Ægte wasabi er dyr og svær at dyrke, så de fleste tuber indeholder peberrod med grøn farve."}, -{"category": "Mad og drikke", "prompt": "Frisk ananas får gelatine-dessert til ikke at stivne, fordi et enzym i frugten nedbryder _____.", "answer": "protein", "alternatives": ["proteiner", "proteinet"], "decoys": ["sukker", "fedt", "stivelse"], "note": "Ananas indeholder bromelain, der klipper gelatinens proteinkæder over. Dåseananas er varmebehandlet og virker fint."}, -{"category": "Mad og drikke", "prompt": "En grøn peberfrugt er som regel bare en _____ rød peberfrugt.", "answer": "umoden", "alternatives": ["ikke moden", "uopmoden"], "decoys": ["syg", "tørret", "frossen"], "note": "De fleste peberfrugter skifter fra grøn til gul, orange eller rød, når de modner på planten."}, -{"category": "Mad og drikke", "prompt": "Ægte parmesan er ikke vegetarisk, fordi den laves med løbe fra maven af _____.", "answer": "kalve", "alternatives": ["kalv", "unge kalve", "kalvemaver"], "decoys": ["grise", "heste", "kaniner"], "note": "Reglerne for Parmigiano Reggiano kræver naturlig kalveløbe, et enzym fra kalvens mave."}, -{"category": "Mad og drikke", "prompt": "Cashewnødder sælges aldrig i skal, fordi skallen indeholder samme hudirriterende stof som _____.", "answer": "giftsumak", "alternatives": ["poison ivy", "giftsumakken"], "decoys": ["brændenælder", "bjørneklo", "chili"], "note": "Skallen indeholder urushiol, det samme stof som i giftsumak. Cashew og giftsumak er i samme plantefamilie."}, -{"category": "Mad og drikke", "prompt": "Carlsberg er opkaldt efter brygger J.C. Jacobsens _____.", "answer": "søn", "alternatives": ["søn Carl", "Carl"], "decoys": ["hest", "hund", "kone"], "note": "Bryggeriet fra 1847 blev opkaldt efter sønnen Carl Jacobsen plus \"bjerg\" for bakken i Valby."}, -{"category": "Mad og drikke", "prompt": "Sort te og grøn te er lavet af blade fra _____, forskellen ligger kun i forarbejdningen.", "answer": "samme plante", "alternatives": ["den samme plante", "samme busk", "den samme busk", "teplanten"], "decoys": ["to forskellige buske", "to forskellige træer", "hver sin palme"], "note": "Begge kommer fra Camellia sinensis. Sort te oxideres, grøn te varmebehandles hurtigt for at undgå det."}, -{"category": "Mad og drikke", "prompt": "Bananer er en lille smule _____ på grund af deres indhold af kalium.", "answer": "radioaktive", "alternatives": ["radioaktiv"], "decoys": ["magnetiske", "selvlysende", "giftige"], "note": "En lille del af naturligt kalium er den radioaktive isotop kalium-40. Strålingen er helt ufarlig."}, -{"category": "Mad og drikke", "prompt": "Safran er tørrede støvfang fra en _____.", "answer": "krokus", "alternatives": ["safrankrokus", "krokusblomst"], "decoys": ["tulipan", "lilje", "orkidé"], "note": "Hver blomst af Crocus sativus giver kun tre støvfang, som plukkes i hånden. Derfor er safran så dyrt."}, -{"category": "Mad og drikke", "prompt": "Vanilje kommer fra frugten af en _____.", "answer": "orkidé", "alternatives": ["orkide", "vaniljeorkidé", "orkidéart"], "decoys": ["palme", "lilje", "krokus"], "note": "Vaniljestangen er en frøkapsel fra slyngplanten Vanilla planifolia, som er en orkidé."}, -{"category": "Mad og drikke", "prompt": "_____ kan ikke mærke det brændende i chili og spreder derfor frøene.", "answer": "fugle", "alternatives": ["fuglene", "fugl"], "decoys": ["køer", "rotter", "heste"], "note": "Fuglenes receptor reagerer ikke på capsaicin. Pattedyr, der ville knuse frøene, skræmmes væk af styrken."}, - -{"category": "Rummet og naturen", "prompt": "Én omdrejning om Venus' egen akse tager længere tid end ét _____ på Venus.", "answer": "år", "alternatives": ["venusår", "Venus-år", "år på Venus"], "decoys": ["århundrede", "årti", "årtusinde"], "note": "Venus roterer på 243 jorddøgn, men kredser om Solen på kun 225 jorddøgn."}, -{"category": "Rummet og naturen", "prompt": "Månen bevæger sig hvert år ca. _____ længere væk fra Jorden.", "answer": "3,8 centimeter", "alternatives": ["3,8 cm", "4 centimeter", "4 cm", "knap 4 centimeter"], "decoys": ["3 millimeter", "40 centimeter", "1 meter"], "note": "Målt med laserstråler mod spejle, som Apollo-astronauterne efterlod på Månen."}, -{"category": "Rummet og naturen", "prompt": "Et lyn kan opvarme luften til ca. _____ gange Solens overfladetemperatur.", "answer": "fem", "alternatives": ["5"], "decoys": ["to", "hundrede", "tusind"], "note": "Lynkanalen kan nå op mod 30.000 °C, mens Solens overflade er ca. 5.500 °C."}, -{"category": "Rummet og naturen", "prompt": "Planeten Saturn har lavere massefylde end _____.", "answer": "vand", "alternatives": ["vand har", "almindeligt vand"], "decoys": ["luft", "helium", "vanddamp"], "note": "Saturns gennemsnitlige massefylde er ca. 0,69 g/cm³, mindre end vands 1 g/cm³."}, -{"category": "Rummet og naturen", "prompt": "Jorden er den eneste planet i solsystemet, der ikke er opkaldt efter en _____.", "answer": "gud", "alternatives": ["guddom", "gudinde", "gud eller gudinde", "mytologisk figur"], "decoys": ["farve", "stjerne", "romersk kejser"], "note": "De øvrige planeter har navne fra græsk eller romersk mytologi. Jorden har et gammelt germansk navn."}, -{"category": "Rummet og naturen", "prompt": "Den danske astronom Ole Rømer viste i 1676 ved hjælp af Jupiters måne Io, at _____ ikke bevæger sig uendelig hurtigt.", "answer": "lyset", "alternatives": ["lys"], "decoys": ["tyngdekraften", "magnetismen", "tiden"], "note": "Io's formørkelser kom for sent, når Jorden var langt fra Jupiter. Rømer forklarede det med lysets endelige hastighed."}, -{"category": "Rummet og naturen", "prompt": "Visse bambusarter kan vokse næsten _____ på ét døgn.", "answer": "en meter", "alternatives": ["1 meter", "91 centimeter", "90 centimeter"], "decoys": ["en millimeter", "en centimeter", "ti meter"], "note": "Nogle bambusarter er målt til at vokse op til 91 cm på 24 timer, blandt de hurtigste planter i verden."}, -{"category": "Rummet og naturen", "prompt": "Diamant og blyantens grafit består begge af rent _____.", "answer": "kulstof", "alternatives": ["carbon", "kul"], "decoys": ["silicium", "jern", "kvarts"], "note": "Forskellen ligger kun i, hvordan kulstofatomerne er bundet sammen."}, -{"category": "Rummet og naturen", "prompt": "Rav er forstenet _____.", "answer": "harpiks", "alternatives": ["træharpiks", "kåde", "harpiks fra træer"], "decoys": ["honning", "fiskeolie", "mos"], "note": "Det baltiske rav, der skyller op på danske strande, er omkring 40 millioner år gammel harpiks fra nåletræer."}, -{"category": "Rummet og naturen", "prompt": "Omkring halvdelen af Jordens ilt produceres af _____ i havet.", "answer": "plankton", "alternatives": ["planteplankton", "fytoplankton", "mikroalger", "alger"], "decoys": ["hvaler", "vandmænd", "fisk"], "note": "Mikroskopiske planteplankton står ifølge NOAA for ca. halvdelen af Jordens iltproduktion."}, -{"category": "Rummet og naturen", "prompt": "Toppen af Mount Everest består af kalksten med fossiler af _____.", "answer": "havdyr", "alternatives": ["dyr fra havet", "havdyr som trilobitter", "trilobitter", "søliljer"], "decoys": ["dinosaurer", "mammutter", "palmer"], "note": "Kalkstenen blev dannet på havbunden for ca. 450 millioner år siden og senere presset op af kontinentalpladerne."}, -{"category": "Rummet og naturen", "prompt": "Jordens indre kerne er omtrent lige så varm som _____ overflade.", "answer": "Solens", "alternatives": ["solens"], "decoys": ["Månens", "Mars'", "Venus'"], "note": "Den indre kerne er ca. 5.200-5.400 °C, næsten som Solens overflade på ca. 5.500 °C."}, -{"category": "Rummet og naturen", "prompt": "Ifølge en opgørelse fra 2015 findes der omkring 3.000 milliarder _____ på Jorden, flere end der er stjerner i Mælkevejen.", "answer": "træer", "alternatives": ["træ"], "decoys": ["fugle", "katte", "mennesker"], "note": "Studiet i Nature (Crowther m.fl., 2015) kom frem til ca. 3,04 billioner træer. Mælkevejen har 100-400 milliarder stjerner."}, -{"category": "Rummet og naturen", "prompt": "Målt på nedbør er Antarktis verdens største _____.", "answer": "ørken", "alternatives": ["ørkenområde", "polarørken"], "decoys": ["regnskov", "sump", "steppe"], "note": "En ørken defineres ud fra lidt nedbør, ikke varme. Indlandet på Antarktis får kun få centimeter om året."}, -{"category": "Rummet og naturen", "prompt": "Dværgplaneten Pluto blev i 1930 navngivet efter forslag fra en _____-årig skolepige.", "answer": "11", "alternatives": ["elleve"], "decoys": ["6", "8", "16"], "note": "Den 11-årige Venetia Burney fra Oxford foreslog navnet efter underverdenens romerske gud."}, - -{"category": "Opfindelser", "prompt": "Den danske kemiker S.P.L. Sørensen indførte pH-skalaen i 1909, mens han arbejdede for _____.", "answer": "Carlsberg", "alternatives": ["Carlsberg Laboratorium", "Carlsbergs laboratorium", "Carlsberg Laboratoriet", "Carlsberg Laboratoriet i Valby"], "decoys": ["Tuborg", "Mærsk", "Københavns Zoo"], "note": "Sørensen ledte kemiafdelingen på Carlsberg Laboratorium og havde brug for et præcist mål for surhed i bryggeprocessen."}, -{"category": "Opfindelser", "prompt": "Navnet LEGO kommer af de danske ord _____.", "answer": "leg godt", "alternatives": ["\"leg godt\"", "leg-godt"], "decoys": ["lær godt", "let og god", "lege og gøre"], "note": "Ole Kirk Christiansen fandt på navnet i 1934 ud fra \"leg godt\"."}, -{"category": "Opfindelser", "prompt": "H.C. Ørsted var i 1825 den første til at fremstille metallet _____.", "answer": "aluminium", "alternatives": ["aluminum"], "decoys": ["titan", "zink", "magnesium"], "note": "Ørsted fremstillede som den første (uren) aluminium. Wöhler forbedrede metoden få år senere."}, -{"category": "Opfindelser", "prompt": "Danske Valdemar Poulsen opfandt i 1898 den første maskine, der kunne optage lyd _____.", "answer": "magnetisk", "alternatives": ["magnetisk på ståltråd", "på ståltråd", "magnetiske"], "decoys": ["digitalt", "optisk", "i stereo"], "note": "Poulsens telegrafon optog lyd magnetisk på ståltråd og er forfader til båndoptageren og harddisken."}, -{"category": "Opfindelser", "prompt": "Bobleplast blev oprindeligt opfundet som _____.", "answer": "tapet", "alternatives": ["tapet med struktur", "vægtapet"], "decoys": ["badeforhæng", "legetøj", "regnfrakker"], "note": "Al Fielding og Marc Chavannes forsøgte i 1957 at lave et moderne strukturtapet. Det blev ikke en succes."}, -{"category": "Opfindelser", "prompt": "Mikrobølgeovnen blev opfundet, efter at radarudstyr havde smeltet en _____ i ingeniørens lomme.", "answer": "chokoladebar", "alternatives": ["slikbar", "chokolade", "slik", "chokoladeplade"], "decoys": ["smørklat", "stearinlys", "ostemad"], "note": "Percy Spencer opdagede i 1945 effekten ved en magnetron og fik patent på mikrobølgeovnen."}, -{"category": "Opfindelser", "prompt": "Limen på Post-it-sedler var egentlig et fejlslagent forsøg på at lave en _____ lim.", "answer": "superstærk", "alternatives": ["meget stærk", "ekstra stærk", "stærk"], "decoys": ["spiselig", "vandopløselig", "selvlysende"], "note": "Spencer Silver hos 3M ville i 1968 lave en superstærk lim, men fik en svag lim, der kunne genbruges."}, -{"category": "Opfindelser", "prompt": "Burrebåndet (velcro) blev opfundet, efter at opfinderen undrede sig over _____ i sin hunds pels.", "answer": "burrer", "alternatives": ["burresnerre", "burresnerrer", "borrer", "frøstande"], "decoys": ["fuglefjer", "edderkoppespind", "tang"], "note": "Schweizeren George de Mestral studerede burrernes små kroge under mikroskop efter en gåtur i 1941."}, -{"category": "Opfindelser", "prompt": "Den allerførste computermus fra 1964 var lavet af _____.", "answer": "træ", "alternatives": ["en træklods", "træklods"], "decoys": ["glas", "porcelæn", "kork"], "note": "Douglas Engelbarts prototype var en træklods med to metalhjul under."}, -{"category": "Opfindelser", "prompt": "Bluetooth er opkaldt efter den danske konge _____.", "answer": "Harald Blåtand", "alternatives": ["Harald", "Harald Bluetooth", "Harald 1."], "decoys": ["Gorm den Gamle", "Svend Tveskæg", "Knud den Store"], "note": "Harald Blåtand samlede Danmark og Norge, ligesom teknologien skulle samle enheder. Logoet er hans runeinitialer."}, -{"category": "Opfindelser", "prompt": "Coca-Cola blev i 1886 oprindeligt solgt som _____.", "answer": "medicin", "alternatives": ["nervemedicin", "patentmedicin", "hovedpinemedicin", "et lægemiddel", "lægemiddel", "tonic"], "decoys": ["hårvand", "rengøringsmiddel", "rottegift"], "note": "Apotekeren John Pemberton markedsførte drikken som et middel mod bl.a. hovedpine og nervøsitet."}, -{"category": "Opfindelser", "prompt": "Danske Karl Krøyer hævede i 1964 et sunket skib i Kuwait ved at fylde det med _____.", "answer": "isoporkugler", "alternatives": ["flamingokugler", "polystyrenkugler", "isopor", "flamingo", "plastkugler", "polystyren"], "decoys": ["bordtennisbolde", "badebolde", "balloner"], "note": "Omkring 27 millioner små polystyrenkugler blev pumpet ned i skibet, så det fik opdrift nok til at flyde."}, -{"category": "Opfindelser", "prompt": "Verdens første webcam blev sat op i 1991 for at holde øje med en _____.", "answer": "kaffekande", "alternatives": ["kaffemaskine", "kaffekolbe", "kaffekande på universitetet"], "decoys": ["fuglerede", "parkeringsplads", "fiskedam"], "note": "Forskere på Cambridge Universitet kunne fra skærmen se, om der var kaffe i kanden i Trojan Room."}, -{"category": "Opfindelser", "prompt": "Den første vare, der blev scannet med stregkode i et supermarked i 1974, var en pakke _____.", "answer": "tyggegummi", "alternatives": ["Wrigley's tyggegummi", "Juicy Fruit"], "decoys": ["cigaretter", "havregryn", "kaffe"], "note": "En pakke Wrigley's Juicy Fruit blev scannet i Marsh Supermarket i Ohio den 26. juni 1974."}, -{"category": "Opfindelser", "prompt": "H.C. Ørsted opdagede i 1820, at elektrisk strøm kan få en _____ til at bevæge sig.", "answer": "kompasnål", "alternatives": ["magnetnål", "kompas"], "decoys": ["pendul", "termometersøjle", "barometernål"], "note": "Opdagelsen af elektromagnetismen lagde grunden til elmotoren, generatoren og telegrafen."} -] + { + "category": "Dyreriget", + "prompt": "En flok ugler kaldes på engelsk et _____.", + "answer": "parliament", + "alternatives": [ + "et parliament", + "parlament" + ], + "decoys": [ + "library", + "choir", + "cabinet" + ], + "note": "\"A parliament of owls\" er den klassiske engelske gruppebetegnelse for ugler, inspireret af uglens ry for visdom." + }, + { + "category": "Dyreriget", + "prompt": "Ottearmede blæksprutter har _____ hjerter.", + "answer": "tre", + "alternatives": [ + "3" + ], + "decoys": [ + "to", + "fire", + "otte" + ], + "note": "To gællehjerter pumper blodet gennem gællerne, og ét systemisk hjerte pumper det rundt i resten af kroppen." + }, + { + "category": "Dyreriget", + "prompt": "Koalaer har _____, der næsten ikke kan skelnes fra menneskers.", + "answer": "fingeraftryk", + "alternatives": [ + "fingeraftrykkene" + ], + "decoys": [ + "tænder", + "øjenvipper", + "stemmebånd" + ], + "note": "Koalaens fingeraftryk ligner menneskers så meget, at selv eksperter kan have svært ved at se forskel (Henneberg, 1996)." + }, + { + "category": "Dyreriget", + "prompt": "Dolkhalens blod er _____ og bruges til at teste medicin for bakterieforurening.", + "answer": "blåt", + "alternatives": [ + "blå", + "lyseblåt" + ], + "decoys": [ + "grønt", + "gult", + "sort" + ], + "note": "Dolkhaler bruger kobberholdigt hæmocyanin i stedet for jernholdigt hæmoglobin. Deres blod giver LAL-testen for bakterietoksiner." + }, + { + "category": "Dyreriget", + "prompt": "Vombatten er kendt for at lave _____ lorte.", + "answer": "terningformede", + "alternatives": [ + "firkantede", + "kubiske", + "terningeformede" + ], + "decoys": [ + "spiralformede", + "stjerneformede", + "trekantede" + ], + "note": "Vombattens tarm har bløde og stive partier, der former afføringen til små terninger. Forskningen fik Ig Nobel-prisen i 2019." + }, + { + "category": "Dyreriget", + "prompt": "En giraf har _____ halshvirvler, præcis lige så mange som et menneske.", + "answer": "syv", + "alternatives": [ + "7" + ], + "decoys": [ + "tolv", + "fjorten", + "toogtyve" + ], + "note": "Næsten alle pattedyr har syv halshvirvler. Girafens er bare op til 25 cm lange hver." + }, + { + "category": "Dyreriget", + "prompt": "Rensdyrs øjne skifter farve fra gyldne om sommeren til _____ om vinteren.", + "answer": "blå", + "alternatives": [ + "dybblå", + "blålige" + ], + "decoys": [ + "røde", + "grønne", + "hvide" + ], + "note": "Det reflekterende lag bag nethinden ændrer sig i det arktiske vintermørke, så mere lys opfanges (Jeffery m.fl., 2013)." + }, + { + "category": "Dyreriget", + "prompt": "Kolibrier er de eneste fugle, der kan flyve _____ i længere tid.", + "answer": "baglæns", + "alternatives": [ + "bagud", + "bagud i luften" + ], + "decoys": [ + "på hovedet", + "under vand", + "i søvne" + ], + "note": "Kolibriens skulderled lader vingerne rotere, så den kan svæve stille og flyve baglæns." + }, + { + "category": "Dyreriget", + "prompt": "Søstjerner kan skubbe deres _____ ud af kroppen for at fordøje byttet udenfor.", + "answer": "mave", + "alternatives": [ + "mavesæk", + "maven" + ], + "decoys": [ + "tunge", + "lunge", + "hjerne" + ], + "note": "Søstjernen vrænger sin mavesæk ud gennem munden og ind i fx en musling, hvor føden fordøjes." + }, + { + "category": "Dyreriget", + "prompt": "Hannen hos næbdyret har _____ på bagbenene.", + "answer": "giftsporer", + "alternatives": [ + "giftpigge", + "giftige sporer", + "giftige pigge", + "sporer" + ], + "decoys": [ + "sugekopper", + "små vinger", + "en ekstra tå" + ], + "note": "Næbdyr-hannen er et af de få giftige pattedyr. Giften fra sporerne giver voldsomme smerter hos mennesker." + }, + { + "category": "Dyreriget", + "prompt": "Den europæiske ål, også den fra danske åer, gyder i _____.", + "answer": "Sargassohavet", + "alternatives": [ + "sargassohavet", + "Sargassohavet i Atlanterhavet" + ], + "decoys": [ + "Middelhavet", + "Østersøen", + "Det Kaspiske Hav" + ], + "note": "Danskeren Johannes Schmidt sporede i 1920'erne ålelarverne tilbage til Sargassohavet i Vestatlanten." + }, + { + "category": "Dyreriget", + "prompt": "Narhvalens lange spyd er i virkeligheden en _____.", + "answer": "tand", + "alternatives": [ + "hjørnetand", + "stødtand" + ], + "decoys": [ + "næseknogle", + "hornplade", + "forstenet tunge" + ], + "note": "Spyddet er en hjørnetand, der hos hannen vokser spiralsnoet ud gennem overlæben, typisk på venstre side." + }, + { + "category": "Dyreriget", + "prompt": "Katte kan slet ikke smage _____.", + "answer": "sødt", + "alternatives": [ + "sødme", + "sukker", + "søde ting" + ], + "decoys": [ + "salt", + "surt", + "bittert" + ], + "note": "Genet for den ene halvdel af sød-receptoren er defekt hos katte, så de kan ikke registrere sødme." + }, + { + "category": "Dyreriget", + "prompt": "Sommerfugle smager med deres _____.", + "answer": "fødder", + "alternatives": [ + "ben", + "fod" + ], + "decoys": [ + "vinger", + "øjne", + "ryg" + ], + "note": "Smagsreceptorer på fødderne lader hunnen tjekke, om en plante egner sig til at lægge æg på." + }, + { + "category": "Dyreriget", + "prompt": "Ifølge en dansk ledet undersøgelse fra 2016 kan grønlandshajen blive omkring _____ år gammel.", + "answer": "400", + "alternatives": [ + "fire hundrede", + "392", + "næsten 400" + ], + "decoys": [ + "120", + "180", + "900" + ], + "note": "Kulstof-14-datering af øjenlinser gav et estimat på 392 ± 120 år, hvilket gør den til verdens længstlevende kendte hvirveldyr." + }, + { + "category": "Kroppen", + "prompt": "Menneskekroppens mindste knogle sidder i _____.", + "answer": "øret", + "alternatives": [ + "mellemøret", + "øre", + "ørerne" + ], + "decoys": [ + "lilletåen", + "næsen", + "lillefingeren" + ], + "note": "Stigbøjlen i mellemøret er kun ca. 3 mm lang." + }, + { + "category": "Kroppen", + "prompt": "_____ i øjet har ingen blodkar og får sin ilt direkte fra luften.", + "answer": "hornhinden", + "alternatives": [ + "hornhinde", + "cornea", + "Hornhinden" + ], + "decoys": [ + "nethinden", + "regnbuehinden", + "synsnerven" + ], + "note": "Hornhinden skal være helt gennemsigtig og har derfor ingen blodkar. Ilten optages fra tårefilmen og luften." + }, + { + "category": "Kroppen", + "prompt": "Nyfødte har endnu ingen knæskaller af knogle, de består nemlig af _____.", + "answer": "brusk", + "alternatives": [ + "bruskvæv" + ], + "decoys": [ + "fedt", + "muskler", + "væske" + ], + "note": "Knæskallen begynder først at forbene sig i 3-6-årsalderen." + }, + { + "category": "Kroppen", + "prompt": "Patienter kan være vågne under hjerneoperationer, fordi selve hjernevævet mangler _____.", + "answer": "smertereceptorer", + "alternatives": [ + "smertesensorer", + "smertenerver", + "nociceptorer", + "smertefølere" + ], + "decoys": [ + "blodkar", + "nerveceller", + "fedt" + ], + "note": "Hjernen registrerer smerte i resten af kroppen, men hjernevævet selv har ingen smertereceptorer." + }, + { + "category": "Kroppen", + "prompt": "Mavesækken undgår at fordøje sig selv ved hele tiden at danne et nyt lag _____.", + "answer": "slim", + "alternatives": [ + "slimhinde", + "slimlag" + ], + "decoys": [ + "fedt", + "kalk", + "hår" + ], + "note": "Et tykt, basisk slimlag beskytter mavevæggen mod saltsyren og fordøjelsesenzymerne." + }, + { + "category": "Kroppen", + "prompt": "Tandemalje er det _____ væv i menneskekroppen.", + "answer": "hårdeste", + "alternatives": [ + "stærkeste", + "mest hårde" + ], + "decoys": [ + "blødeste", + "letteste", + "mest elastiske" + ], + "note": "Emaljen består af ca. 96 % mineral og er hårdere end knogle." + }, + { + "category": "Kroppen", + "prompt": "Gåsehud er en rest fra dengang, vores forfædre havde _____.", + "answer": "pels", + "alternatives": [ + "mere pels", + "mere hår", + "kropsbehåring", + "tæt pels" + ], + "decoys": [ + "fjer", + "skæl", + "pigge" + ], + "note": "Små muskler rejser hårene. Hos pelsdyr giver det isolering eller får dyret til at se større ud." + }, + { + "category": "Kroppen", + "prompt": "Det lægelige navn for hikke er _____.", + "answer": "singultus", + "alternatives": [ + "singultation" + ], + "decoys": [ + "diafragmose", + "hiccupitis", + "spasmus glottis" + ], + "note": "Singultus kommer af latin og betyder noget i retning af et hulk eller en gisp." + }, + { + "category": "Kroppen", + "prompt": "Babyer fødes med omkring 300 knogler, men en voksen har kun _____.", + "answer": "206", + "alternatives": [ + "to hundrede og seks" + ], + "decoys": [ + "250", + "180", + "312" + ], + "note": "Mange knogler vokser sammen gennem barndommen, fx i kraniet og bækkenet." + }, + { + "category": "Kroppen", + "prompt": "Knæk i fingrene skyldes _____ i ledvæsken.", + "answer": "gasbobler", + "alternatives": [ + "bobler", + "luftbobler", + "gas", + "en gasboble" + ], + "decoys": [ + "kalkkrystaller", + "små knoglesplinter", + "størknet blod" + ], + "note": "Når leddet trækkes fra hinanden, falder trykket, og der dannes en gasboble i ledvæsken (MR-studie, 2015)." + }, + { + "category": "Kroppen", + "prompt": "Om ens ørevoks er tør eller våd, afgøres af ét enkelt _____.", + "answer": "gen", + "alternatives": [ + "genet", + "arveanlæg" + ], + "decoys": [ + "hormon", + "vitamin", + "mineral" + ], + "note": "Varianter af genet ABCC11 bestemmer ørevoksens type. Tør voks er almindelig i Østasien (2006)." + }, + { + "category": "Kroppen", + "prompt": "Hjernen udgør kun ca. 2 % af kropsvægten, men bruger omkring _____ af kroppens energi.", + "answer": "20 procent", + "alternatives": [ + "20 %", + "20%", + "en femtedel", + "tyve procent" + ], + "decoys": [ + "5 procent", + "50 procent", + "2 procent" + ], + "note": "I hvile står hjernen for omkring en femtedel af kroppens energiforbrug." + }, + { + "category": "Kroppen", + "prompt": "Den danske læge Niels Finsen fik Nobelprisen i 1903 for at behandle hudtuberkulose med _____.", + "answer": "lys", + "alternatives": [ + "lysbehandling", + "koncentreret lys", + "UV-lys" + ], + "decoys": [ + "kulde", + "radium", + "honning" + ], + "note": "Finsen brugte koncentreret lys mod lupus vulgaris og var Danmarks første nobelpristager i medicin." + }, + { + "category": "Kroppen", + "prompt": "K-vitamin har sit bogstav fra ordet _____.", + "answer": "koagulation", + "alternatives": [ + "koagulering", + "Koagulation" + ], + "decoys": [ + "kalk", + "kartoffel", + "kost" + ], + "note": "Danske Henrik Dam opdagede vitaminet og opkaldte det efter dets rolle i blodets størkning. Han fik Nobelprisen i 1943." + }, + { + "category": "Kroppen", + "prompt": "Et voksent menneskehjerte slår omkring _____ gange i døgnet.", + "answer": "100.000", + "alternatives": [ + "100000", + "hundrede tusind", + "100 000" + ], + "decoys": [ + "10.000", + "30.000", + "1 million" + ], + "note": "Med en hvilepuls på 60-80 slag i minuttet giver det ca. 86.000-115.000 slag i døgnet." + }, + { + "category": "Mad og drikke", + "prompt": "Jordnødder er botanisk set ikke nødder, men _____.", + "answer": "bælgfrugter", + "alternatives": [ + "bælgplanter", + "ærteplanter", + "bælgfrugt" + ], + "decoys": [ + "rodknolde", + "kornsorter", + "citrusfrugter" + ], + "note": "Jordnødden er i familie med ærter og bønner og modner sine bælge under jorden." + }, + { + "category": "Mad og drikke", + "prompt": "Kiwifrugten hed oprindeligt _____.", + "answer": "kinesisk stikkelsbær", + "alternatives": [ + "kinesiske stikkelsbær", + "Chinese gooseberry" + ], + "decoys": [ + "australsk kartoffel", + "abeæble", + "behåret blomme" + ], + "note": "Frugten stammer fra Kina. New Zealandske eksportører omdøbte den til kiwifrugt omkring 1959." + }, + { + "category": "Mad og drikke", + "prompt": "De første dyrkede gulerødder var ikke orange, men _____.", + "answer": "lilla", + "alternatives": [ + "gule", + "lilla og gule", + "violette" + ], + "decoys": [ + "blå", + "grønne", + "lyserøde" + ], + "note": "Tidlige gulerødder fra Centralasien var lilla eller gule. Den orange gulerod blev udviklet i Europa, især i Holland." + }, + { + "category": "Mad og drikke", + "prompt": "Kaffebønner er egentlig _____ fra en bærlignende frugt.", + "answer": "frø", + "alternatives": [ + "kerner", + "stenkerner", + "frøene" + ], + "decoys": [ + "rødder", + "blomsterknopper", + "tørrede bær" + ], + "note": "Hvert kaffebær indeholder normalt to frø, som ristes til det, vi kalder kaffebønner." + }, + { + "category": "Mad og drikke", + "prompt": "Den grønne wasabi, man får til sushi i Europa, er oftest farvet _____.", + "answer": "peberrod", + "alternatives": [ + "peberrødder" + ], + "decoys": [ + "ingefær", + "rucola", + "ærter" + ], + "note": "Ægte wasabi er dyr og svær at dyrke, så de fleste tuber indeholder peberrod med grøn farve." + }, + { + "category": "Mad og drikke", + "prompt": "Frisk ananas får gelatine-dessert til ikke at stivne, fordi et enzym i frugten nedbryder _____.", + "answer": "protein", + "alternatives": [ + "proteiner", + "proteinet" + ], + "decoys": [ + "sukker", + "fedt", + "stivelse" + ], + "note": "Ananas indeholder bromelain, der klipper gelatinens proteinkæder over. Dåseananas er varmebehandlet og virker fint." + }, + { + "category": "Mad og drikke", + "prompt": "En grøn peberfrugt er som regel bare en _____ rød peberfrugt.", + "answer": "umoden", + "alternatives": [ + "ikke moden", + "uopmoden" + ], + "decoys": [ + "syg", + "tørret", + "frossen" + ], + "note": "De fleste peberfrugter skifter fra grøn til gul, orange eller rød, når de modner på planten." + }, + { + "category": "Mad og drikke", + "prompt": "Ægte parmesan er ikke vegetarisk, fordi den laves med løbe fra maven af _____.", + "answer": "kalve", + "alternatives": [ + "kalv", + "unge kalve", + "kalvemaver" + ], + "decoys": [ + "grise", + "heste", + "kaniner" + ], + "note": "Reglerne for Parmigiano Reggiano kræver naturlig kalveløbe, et enzym fra kalvens mave." + }, + { + "category": "Mad og drikke", + "prompt": "Cashewnødder sælges aldrig i skal, fordi skallen indeholder samme hudirriterende stof som _____.", + "answer": "giftsumak", + "alternatives": [ + "poison ivy", + "giftsumakken" + ], + "decoys": [ + "brændenælder", + "bjørneklo", + "chili" + ], + "note": "Skallen indeholder urushiol, det samme stof som i giftsumak. Cashew og giftsumak er i samme plantefamilie." + }, + { + "category": "Mad og drikke", + "prompt": "Carlsberg er opkaldt efter brygger J.C. Jacobsens _____.", + "answer": "søn", + "alternatives": [ + "søn Carl", + "Carl" + ], + "decoys": [ + "hest", + "hund", + "kone" + ], + "note": "Bryggeriet fra 1847 blev opkaldt efter sønnen Carl Jacobsen plus \"bjerg\" for bakken i Valby." + }, + { + "category": "Mad og drikke", + "prompt": "Sort te og grøn te er lavet af blade fra _____, forskellen ligger kun i forarbejdningen.", + "answer": "samme plante", + "alternatives": [ + "den samme plante", + "samme busk", + "den samme busk", + "teplanten" + ], + "decoys": [ + "to forskellige buske", + "to forskellige træer", + "hver sin palme" + ], + "note": "Begge kommer fra Camellia sinensis. Sort te oxideres, grøn te varmebehandles hurtigt for at undgå det." + }, + { + "category": "Mad og drikke", + "prompt": "Bananer er en lille smule _____ på grund af deres indhold af kalium.", + "answer": "radioaktive", + "alternatives": [ + "radioaktiv" + ], + "decoys": [ + "magnetiske", + "selvlysende", + "giftige" + ], + "note": "En lille del af naturligt kalium er den radioaktive isotop kalium-40. Strålingen er helt ufarlig." + }, + { + "category": "Mad og drikke", + "prompt": "Safran er tørrede støvfang fra en _____.", + "answer": "krokus", + "alternatives": [ + "safrankrokus", + "krokusblomst" + ], + "decoys": [ + "tulipan", + "lilje", + "orkidé" + ], + "note": "Hver blomst af Crocus sativus giver kun tre støvfang, som plukkes i hånden. Derfor er safran så dyrt." + }, + { + "category": "Mad og drikke", + "prompt": "Vanilje kommer fra frugten af en _____.", + "answer": "orkidé", + "alternatives": [ + "orkide", + "vaniljeorkidé", + "orkidéart" + ], + "decoys": [ + "palme", + "lilje", + "krokus" + ], + "note": "Vaniljestangen er en frøkapsel fra slyngplanten Vanilla planifolia, som er en orkidé." + }, + { + "category": "Mad og drikke", + "prompt": "_____ kan ikke mærke det brændende i chili og spreder derfor frøene.", + "answer": "fugle", + "alternatives": [ + "fuglene", + "fugl" + ], + "decoys": [ + "køer", + "rotter", + "heste" + ], + "note": "Fuglenes receptor reagerer ikke på capsaicin. Pattedyr, der ville knuse frøene, skræmmes væk af styrken." + }, + { + "category": "Rummet og naturen", + "prompt": "Én omdrejning om Venus' egen akse tager længere tid end ét _____ på Venus.", + "answer": "år", + "alternatives": [ + "venusår", + "Venus-år", + "år på Venus" + ], + "decoys": [ + "århundrede", + "årti", + "årtusinde" + ], + "note": "Venus roterer på 243 jorddøgn, men kredser om Solen på kun 225 jorddøgn." + }, + { + "category": "Rummet og naturen", + "prompt": "Månen bevæger sig hvert år ca. _____ længere væk fra Jorden.", + "answer": "3,8 centimeter", + "alternatives": [ + "3,8 cm", + "4 centimeter", + "4 cm", + "knap 4 centimeter" + ], + "decoys": [ + "3 millimeter", + "40 centimeter", + "1 meter" + ], + "note": "Målt med laserstråler mod spejle, som Apollo-astronauterne efterlod på Månen." + }, + { + "category": "Rummet og naturen", + "prompt": "Et lyn kan opvarme luften til ca. _____ gange Solens overfladetemperatur.", + "answer": "fem", + "alternatives": [ + "5" + ], + "decoys": [ + "to", + "hundrede", + "tusind" + ], + "note": "Lynkanalen kan nå op mod 30.000 °C, mens Solens overflade er ca. 5.500 °C." + }, + { + "category": "Rummet og naturen", + "prompt": "Planeten Saturn har lavere massefylde end _____.", + "answer": "vand", + "alternatives": [ + "vand har", + "almindeligt vand" + ], + "decoys": [ + "luft", + "helium", + "vanddamp" + ], + "note": "Saturns gennemsnitlige massefylde er ca. 0,69 g/cm³, mindre end vands 1 g/cm³." + }, + { + "category": "Rummet og naturen", + "prompt": "Jorden er den eneste planet i solsystemet, der ikke er opkaldt efter en _____.", + "answer": "gud", + "alternatives": [ + "guddom", + "gudinde", + "gud eller gudinde", + "mytologisk figur" + ], + "decoys": [ + "farve", + "stjerne", + "romersk kejser" + ], + "note": "De øvrige planeter har navne fra græsk eller romersk mytologi. Jorden har et gammelt germansk navn." + }, + { + "category": "Rummet og naturen", + "prompt": "Den danske astronom Ole Rømer viste i 1676 ved hjælp af Jupiters måne Io, at _____ ikke bevæger sig uendelig hurtigt.", + "answer": "lyset", + "alternatives": [ + "lys" + ], + "decoys": [ + "tyngdekraften", + "magnetismen", + "tiden" + ], + "note": "Io's formørkelser kom for sent, når Jorden var langt fra Jupiter. Rømer forklarede det med lysets endelige hastighed." + }, + { + "category": "Rummet og naturen", + "prompt": "Visse bambusarter kan vokse næsten _____ på ét døgn.", + "answer": "en meter", + "alternatives": [ + "1 meter", + "91 centimeter", + "90 centimeter" + ], + "decoys": [ + "en millimeter", + "en centimeter", + "ti meter" + ], + "note": "Nogle bambusarter er målt til at vokse op til 91 cm på 24 timer, blandt de hurtigste planter i verden." + }, + { + "category": "Rummet og naturen", + "prompt": "Diamant og blyantens grafit består begge af rent _____.", + "answer": "kulstof", + "alternatives": [ + "carbon", + "kul" + ], + "decoys": [ + "silicium", + "jern", + "kvarts" + ], + "note": "Forskellen ligger kun i, hvordan kulstofatomerne er bundet sammen." + }, + { + "category": "Rummet og naturen", + "prompt": "Rav er forstenet _____.", + "answer": "harpiks", + "alternatives": [ + "træharpiks", + "kåde", + "harpiks fra træer" + ], + "decoys": [ + "honning", + "fiskeolie", + "mos" + ], + "note": "Det baltiske rav, der skyller op på danske strande, er omkring 40 millioner år gammel harpiks fra nåletræer." + }, + { + "category": "Rummet og naturen", + "prompt": "Omkring halvdelen af Jordens ilt produceres af _____ i havet.", + "answer": "plankton", + "alternatives": [ + "planteplankton", + "fytoplankton", + "mikroalger", + "alger" + ], + "decoys": [ + "hvaler", + "vandmænd", + "fisk" + ], + "note": "Mikroskopiske planteplankton står ifølge NOAA for ca. halvdelen af Jordens iltproduktion." + }, + { + "category": "Rummet og naturen", + "prompt": "Toppen af Mount Everest består af kalksten med fossiler af _____.", + "answer": "havdyr", + "alternatives": [ + "dyr fra havet", + "havdyr som trilobitter", + "trilobitter", + "søliljer" + ], + "decoys": [ + "dinosaurer", + "mammutter", + "palmer" + ], + "note": "Kalkstenen blev dannet på havbunden for ca. 450 millioner år siden og senere presset op af kontinentalpladerne." + }, + { + "category": "Rummet og naturen", + "prompt": "Jordens indre kerne er omtrent lige så varm som _____ overflade.", + "answer": "Solens", + "alternatives": [ + "solens" + ], + "decoys": [ + "Månens", + "Mars'", + "Venus'" + ], + "note": "Den indre kerne er ca. 5.200-5.400 °C, næsten som Solens overflade på ca. 5.500 °C." + }, + { + "category": "Rummet og naturen", + "prompt": "Ifølge en opgørelse fra 2015 findes der omkring 3.000 milliarder _____ på Jorden, flere end der er stjerner i Mælkevejen.", + "answer": "træer", + "alternatives": [ + "træ" + ], + "decoys": [ + "fugle", + "katte", + "mennesker" + ], + "note": "Studiet i Nature (Crowther m.fl., 2015) kom frem til ca. 3,04 billioner træer. Mælkevejen har 100-400 milliarder stjerner." + }, + { + "category": "Rummet og naturen", + "prompt": "Målt på nedbør er Antarktis verdens største _____.", + "answer": "ørken", + "alternatives": [ + "ørkenområde", + "polarørken" + ], + "decoys": [ + "regnskov", + "sump", + "steppe" + ], + "note": "En ørken defineres ud fra lidt nedbør, ikke varme. Indlandet på Antarktis får kun få centimeter om året." + }, + { + "category": "Rummet og naturen", + "prompt": "Dværgplaneten Pluto blev i 1930 navngivet efter forslag fra en _____-årig skolepige.", + "answer": "11", + "alternatives": [ + "elleve" + ], + "decoys": [ + "6", + "8", + "16" + ], + "note": "Den 11-årige Venetia Burney fra Oxford foreslog navnet efter underverdenens romerske gud." + }, + { + "category": "Opfindelser", + "prompt": "Den danske kemiker S.P.L. Sørensen indførte pH-skalaen i 1909, mens han arbejdede for _____.", + "answer": "Carlsberg", + "alternatives": [ + "Carlsberg Laboratorium", + "Carlsbergs laboratorium", + "Carlsberg Laboratoriet", + "Carlsberg Laboratoriet i Valby" + ], + "decoys": [ + "Tuborg", + "Mærsk", + "Københavns Zoo" + ], + "note": "Sørensen ledte kemiafdelingen på Carlsberg Laboratorium og havde brug for et præcist mål for surhed i bryggeprocessen." + }, + { + "category": "Opfindelser", + "prompt": "Navnet LEGO kommer af de danske ord _____.", + "answer": "leg godt", + "alternatives": [ + "\"leg godt\"", + "leg-godt" + ], + "decoys": [ + "lær godt", + "let og god", + "lege og gøre" + ], + "note": "Ole Kirk Christiansen fandt på navnet i 1934 ud fra \"leg godt\"." + }, + { + "category": "Opfindelser", + "prompt": "H.C. Ørsted var i 1825 den første til at fremstille metallet _____.", + "answer": "aluminium", + "alternatives": [ + "aluminum" + ], + "decoys": [ + "titan", + "zink", + "magnesium" + ], + "note": "Ørsted fremstillede som den første (uren) aluminium. Wöhler forbedrede metoden få år senere." + }, + { + "category": "Opfindelser", + "prompt": "Danske Valdemar Poulsen opfandt i 1898 den første maskine, der kunne optage lyd _____.", + "answer": "magnetisk", + "alternatives": [ + "magnetisk på ståltråd", + "på ståltråd", + "magnetiske" + ], + "decoys": [ + "digitalt", + "optisk", + "i stereo" + ], + "note": "Poulsens telegrafon optog lyd magnetisk på ståltråd og er forfader til båndoptageren og harddisken." + }, + { + "category": "Opfindelser", + "prompt": "Bobleplast blev oprindeligt opfundet som _____.", + "answer": "tapet", + "alternatives": [ + "tapet med struktur", + "vægtapet" + ], + "decoys": [ + "badeforhæng", + "legetøj", + "regnfrakker" + ], + "note": "Al Fielding og Marc Chavannes forsøgte i 1957 at lave et moderne strukturtapet. Det blev ikke en succes." + }, + { + "category": "Opfindelser", + "prompt": "Mikrobølgeovnen blev opfundet, efter at radarudstyr havde smeltet en _____ i ingeniørens lomme.", + "answer": "chokoladebar", + "alternatives": [ + "slikbar", + "chokolade", + "slik", + "chokoladeplade" + ], + "decoys": [ + "smørklat", + "stearinlys", + "ostemad" + ], + "note": "Percy Spencer opdagede i 1945 effekten ved en magnetron og fik patent på mikrobølgeovnen." + }, + { + "category": "Opfindelser", + "prompt": "Limen på Post-it-sedler var egentlig et fejlslagent forsøg på at lave en _____ lim.", + "answer": "superstærk", + "alternatives": [ + "meget stærk", + "ekstra stærk", + "stærk" + ], + "decoys": [ + "spiselig", + "vandopløselig", + "selvlysende" + ], + "note": "Spencer Silver hos 3M ville i 1968 lave en superstærk lim, men fik en svag lim, der kunne genbruges." + }, + { + "category": "Opfindelser", + "prompt": "Burrebåndet (velcro) blev opfundet, efter at opfinderen undrede sig over _____ i sin hunds pels.", + "answer": "burrer", + "alternatives": [ + "burresnerre", + "burresnerrer", + "borrer", + "frøstande" + ], + "decoys": [ + "fuglefjer", + "edderkoppespind", + "tang" + ], + "note": "Schweizeren George de Mestral studerede burrernes små kroge under mikroskop efter en gåtur i 1941." + }, + { + "category": "Opfindelser", + "prompt": "Den allerførste computermus fra 1964 var lavet af _____.", + "answer": "træ", + "alternatives": [ + "en træklods", + "træklods" + ], + "decoys": [ + "glas", + "porcelæn", + "kork" + ], + "note": "Douglas Engelbarts prototype var en træklods med to metalhjul under." + }, + { + "category": "Opfindelser", + "prompt": "Bluetooth er opkaldt efter den danske konge _____.", + "answer": "Harald Blåtand", + "alternatives": [ + "Harald", + "Harald Bluetooth", + "Harald 1." + ], + "decoys": [ + "Gorm den Gamle", + "Svend Tveskæg", + "Knud den Store" + ], + "note": "Harald Blåtand samlede Danmark og Norge, ligesom teknologien skulle samle enheder. Logoet er hans runeinitialer." + }, + { + "category": "Opfindelser", + "prompt": "Coca-Cola blev i 1886 oprindeligt solgt som _____.", + "answer": "medicin", + "alternatives": [ + "nervemedicin", + "patentmedicin", + "hovedpinemedicin", + "et lægemiddel", + "lægemiddel", + "tonic" + ], + "decoys": [ + "hårvand", + "rengøringsmiddel", + "rottegift" + ], + "note": "Apotekeren John Pemberton markedsførte drikken som et middel mod bl.a. hovedpine og nervøsitet." + }, + { + "category": "Opfindelser", + "prompt": "Danske Karl Krøyer hævede i 1964 et sunket skib i Kuwait ved at fylde det med _____.", + "answer": "isoporkugler", + "alternatives": [ + "flamingokugler", + "polystyrenkugler", + "isopor", + "flamingo", + "plastkugler", + "polystyren" + ], + "decoys": [ + "bordtennisbolde", + "badebolde", + "balloner" + ], + "note": "Omkring 27 millioner små polystyrenkugler blev pumpet ned i skibet, så det fik opdrift nok til at flyde." + }, + { + "category": "Opfindelser", + "prompt": "Verdens første webcam blev sat op i 1991 for at holde øje med en _____.", + "answer": "kaffekande", + "alternatives": [ + "kaffemaskine", + "kaffekolbe", + "kaffekande på universitetet" + ], + "decoys": [ + "fuglerede", + "parkeringsplads", + "fiskedam" + ], + "note": "Forskere på Cambridge Universitet kunne fra skærmen se, om der var kaffe i kanden i Trojan Room." + }, + { + "category": "Opfindelser", + "prompt": "Den første vare, der blev scannet med stregkode i et supermarked i 1974, var en pakke _____.", + "answer": "tyggegummi", + "alternatives": [ + "Wrigley's tyggegummi", + "Juicy Fruit" + ], + "decoys": [ + "cigaretter", + "havregryn", + "kaffe" + ], + "note": "En pakke Wrigley's Juicy Fruit blev scannet i Marsh Supermarket i Ohio den 26. juni 1974." + }, + { + "category": "Opfindelser", + "prompt": "H.C. Ørsted opdagede i 1820, at elektrisk strøm kan få en _____ til at bevæge sig.", + "answer": "kompasnål", + "alternatives": [ + "magnetnål", + "kompas" + ], + "decoys": [ + "pendul", + "termometersøjle", + "barometernål" + ], + "note": "Opdagelsen af elektromagnetismen lagde grunden til elmotoren, generatoren og telegrafen." + } +] \ No newline at end of file diff --git a/fupogfakta/data/questions_b.json b/fupogfakta/data/questions_b.json index 170b66b..57863a9 100644 --- a/fupogfakta/data/questions_b.json +++ b/fupogfakta/data/questions_b.json @@ -1,20 +1,4 @@ [ - { - "category": "Danmark", - "prompt": "Navnet LEGO er dannet af de to danske ord _____.", - "answer": "leg godt", - "alternatives": [ - "leg godt", - "\"leg godt\"", - "leggodt" - ], - "decoys": [ - "lille gode", - "lad os lege", - "let og god" - ], - "note": "Ole Kirk Christiansen dannede i 1934 navnet LEGO af \"LEg GOdt\"." - }, { "category": "Danmark", "prompt": "Den Lille Havfrues krop er modelleret efter billedhuggerens _____.", @@ -160,23 +144,6 @@ ], "note": "Præsten Rasmus Malling-Hansens skrivekugle var en af de første kommercielle skrivemaskiner; Nietzsche brugte en i 1882." }, - { - "category": "Danmark", - "prompt": "Den trådløse teknologi Bluetooth er opkaldt efter _____.", - "answer": "Harald Blåtand", - "alternatives": [ - "harald blåtand", - "harald bluetooth", - "harald 1.", - "kong harald" - ], - "decoys": [ - "Gorm den Gamle", - "Svend Tveskæg", - "Knud den Store" - ], - "note": "Navnet hylder vikingekongen der samlede Danmark, ligesom teknologien skulle samle enheder; logoet er hans runeinitialer." - }, { "category": "Danmark", "prompt": "Dyrehavsbakken, der regnes for verdens ældste forlystelsespark, går tilbage til år _____.", @@ -1057,23 +1024,6 @@ ], "note": "Aleuterne krydser 180. længdegrad, så Alaskas yderste øer ligger på østlig længde." }, - { - "category": "Geografi", - "prompt": "Verdens største ørken er _____.", - "answer": "Antarktis", - "alternatives": [ - "antarktis", - "antarktika", - "sydpolen", - "antarktisk ørken" - ], - "decoys": [ - "Gobi", - "Arabiske Ørken", - "Kalahari" - ], - "note": "En ørken defineres ved lav nedbør; Antarktis er ca. 14 mio. km², større end Sahara." - }, { "category": "Geografi", "prompt": "Det sted i havet, der ligger længst fra land, kaldes Point _____.", diff --git a/party/static/party/common.js b/party/static/party/common.js new file mode 100644 index 0000000..e5337cb --- /dev/null +++ b/party/static/party/common.js @@ -0,0 +1,322 @@ +/* Fup og Fakta – fælles hjælpere (plain script, eksporterer window.WPP). */ +(function () { + 'use strict'; + var WPP = (window.WPP = window.WPP || {}); + + /* ---------- tekst ---------- */ + var ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; + WPP.esc = function (s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return ESC[c]; }); + }; + /* "Asger, Bo og Sus" – input skal allerede være escaped HTML */ + WPP.joinNames = function (arr) { + arr = (arr || []).filter(Boolean); + if (arr.length <= 1) return arr.join(''); + return arr.slice(0, -1).join(', ') + ' og ' + arr[arr.length - 1]; + }; + /* Spørgsmål med pænt stylet blank. fill = tekst der indsættes (sandheden) */ + WPP.promptHtml = function (prompt, fill) { + var html = WPP.esc(prompt); + var inner = fill ? '' + WPP.esc(fill) + '' : ''; + if (/_{3,}/.test(html)) return html.replace(/_{3,}/, inner); + return html; + }; + WPP.fmt = function (n) { + n = Math.round(Number(n) || 0); + return String(Math.abs(n)).replace(/\B(?=(\d{3})+(?!\d))/g, '.').replace(/^/, n < 0 ? '−' : ''); + }; + WPP.signed = function (n) { n = Number(n) || 0; return (n >= 0 ? '+' : '−') + WPP.fmt(Math.abs(n)); }; + WPP.playerMap = function (state) { + var m = {}; + ((state && state.players) || []).forEach(function (p) { m[p.id] = p; }); + return m; + }; + WPP.chip = function (p, extra) { + if (!p) return ''; + return '' + + '' + WPP.esc(p.name) + ''; + }; + WPP.roundTitle = function (round) { + if (!round) return ''; + var name = WPP.esc(round.name || ('Runde ' + (round.number || ''))); + var m = round.multiplier || 1; + if (m === 2) return name + ' – dobbelt point!'; + if (m === 3) return name + ' – tredobbelt point!'; + if (m > 3) return name + ' – ×' + m + ' point!'; + return name; + }; + + /* ---------- lokal lagring (kan kaste i privat-vindue) ---------- */ + WPP.ls = { + get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, + set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* ignorér */ } }, + del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* ignorér */ } } + }; + + /* ---------- server-ur ---------- */ + var offset = 0; + WPP.setServerTime = function (t) { if (typeof t === 'number' && isFinite(t)) offset = t - Date.now(); }; + WPP.now = function () { return Date.now() + offset; }; + WPP.secondsLeft = function (deadline) { + if (!deadline) return null; + return Math.max(0, Math.ceil((deadline - WPP.now()) / 1000)); + }; + var KNOWN = { category: 15000, lie: 60000, guess: 30000, scores: 10000 }; + var seenTotals = {}; + WPP.totalFor = function (phase, deadline) { + if (!deadline) return 0; + var left = deadline - WPP.now(); + if (KNOWN[phase]) return Math.max(KNOWN[phase], left); + var k = phase + ':' + deadline; + if (!seenTotals[k]) seenTotals[k] = Math.max(1000, left); + return seenTotals[k]; + }; + + /* Nedtællings-markup. Opdateres af den globale ticker. kind: 'pill' | 'ring' | 'bar' */ + WPP.timerHtml = function (state, kind) { + if (!state || !state.deadline) return ''; + var total = WPP.totalFor(state.phase, state.deadline); + var attrs = ' data-deadline="' + state.deadline + '" data-total="' + total + '"'; + var s = ''; /* udfyldes af tick() – holder markup stabil mellem renders */ + if (kind === 'ring') { + return '
' + + '' + s + '
'; + } + if (kind === 'bar') return '
'; + return '' + s + ''; + }; + var tickers = []; + WPP.onTick = function (fn) { tickers.push(fn); }; + function tick() { + var els = document.querySelectorAll('.js-timer'); + for (var i = 0; i < els.length; i++) { + var el = els[i]; + var dl = Number(el.getAttribute('data-deadline')); + var total = Number(el.getAttribute('data-total')) || 1; + var leftMs = Math.max(0, dl - WPP.now()); + var s = Math.ceil(leftMs / 1000); + var frac = Math.max(0, Math.min(1, leftMs / total)); + var num = el.querySelector('.num'); + if (num && num.textContent !== String(s)) num.textContent = s; + el.style.setProperty('--frac', frac.toFixed(4)); + el.classList.toggle('urgent', s <= 5); + el.classList.toggle('over', leftMs <= 0); + } + for (var j = 0; j < tickers.length; j++) { + try { tickers[j](); } catch (e) { if (window.console) console.error(e); } + } + } + setInterval(tick, 200); + WPP.tick = tick; + + /* ---------- DOM-patch: genbrug uændrede [data-k]-noder ---------- + Top-level-børn med data-k sammenlignes på deres genererede HTML; uændrede noder + bevares (fokus, inputtekst og animationer overlever). Noder med data-patch + patches rekursivt i stedet for at blive erstattet. */ + function syncAttrs(o, n) { + var i, a; + for (i = o.attributes.length - 1; i >= 0; i--) { + a = o.attributes[i].name; + if (!n.hasAttribute(a)) o.removeAttribute(a); + } + for (i = 0; i < n.attributes.length; i++) { + a = n.attributes[i]; + if (o.getAttribute(a.name) !== a.value) o.setAttribute(a.name, a.value); + } + } + function patchChildren(target, newParent) { + var old = {}, keep = new Set(), desired = []; + Array.prototype.forEach.call(target.children, function (el) { + var k = el.getAttribute('data-k'); + if (k && !old[k]) old[k] = el; + }); + Array.prototype.slice.call(newParent.children).forEach(function (n) { + var k = n.getAttribute('data-k'); + var o = k && old[k]; + if (o && !keep.has(o)) { + if (o._h === n._h) { desired.push(o); keep.add(o); return; } + if (n.hasAttribute('data-patch') && o.hasAttribute('data-patch') && o.tagName === n.tagName) { + syncAttrs(o, n); patchChildren(o, n); o._h = n._h; + desired.push(o); keep.add(o); return; + } + } + desired.push(n); + }); + Array.prototype.slice.call(target.childNodes).forEach(function (c) { if (!keep.has(c)) target.removeChild(c); }); + var cur = target.firstChild; + desired.forEach(function (n) { + if (cur === n) { cur = n.nextSibling; return; } + target.insertBefore(n, cur); + }); + } + WPP.patch = function (target, html) { + var tpl = document.createElement('template'); + tpl.innerHTML = html; + Array.prototype.forEach.call(tpl.content.querySelectorAll('[data-k]'), function (e) { e._h = e.outerHTML; }); + patchChildren(target, tpl.content); + tick(); + }; + + /* Skal: skærm + toasts + forbindelses-overlay */ + WPP.shell = function (root) { + if (root._shell) return root._shell; + root.classList.add('wpp-root'); + root.innerHTML = '
' + + '
Forbinder…
'; + root._shell = { + screen: root.querySelector('.screen'), + toasts: root.querySelector('.toasts') + }; + return root._shell; + }; + WPP.toast = function (root, msg, kind) { + var sh = WPP.shell(root); + var t = document.createElement('div'); + t.className = 'toast ' + (kind || 'err'); + t.textContent = msg; + sh.toasts.appendChild(t); + while (sh.toasts.children.length > 3) sh.toasts.removeChild(sh.toasts.firstChild); + setTimeout(function () { t.classList.add('out'); }, 3600); + setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 4100); + }; + + /* Tæller-animation for [data-count-from][data-count-to] (kun nye noder) */ + WPP.animateCounts = function (root) { + Array.prototype.forEach.call(root.querySelectorAll('[data-count-to]'), function (el) { + if (el._counted) return; + el._counted = true; + var from = Number(el.getAttribute('data-count-from')) || 0; + var to = Number(el.getAttribute('data-count-to')) || 0; + var delay = Number(el.getAttribute('data-count-delay')) || 0; + if (from === to) { el.textContent = WPP.fmt(to); return; } + el.textContent = WPP.fmt(from); + var dur = 1400, t0 = null; + function step(ts) { + if (!el.isConnected) return; + if (t0 === null) t0 = ts + delay; + var p = Math.max(0, Math.min(1, (ts - t0) / dur)); + var e = 1 - Math.pow(1 - p, 3); + el.textContent = WPP.fmt(from + (to - from) * e); + if (p < 1) requestAnimationFrame(step); + } + requestAnimationFrame(step); + }); + }; + + /* ---------- WebSocket med auto-reconnect ---------- */ + WPP.connect = function (path, opts) { + opts = opts || {}; + var url = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host + path; + var ws = null, closed = false, backoff = 500, retryTimer = null, downTimer = null; + var lastMsg = Date.now(), pingTimer = null; + var root = opts.root; + + function setDown(down) { + if (!root) return; + if (down) { + if (!downTimer) downTimer = setTimeout(function () { root.classList.add('offline'); }, 1000); + } else { + clearTimeout(downTimer); downTimer = null; + root.classList.remove('offline'); + } + } + function open() { + if (closed) return; + clearTimeout(retryTimer); retryTimer = null; + if (ws && (ws.readyState === 0 || ws.readyState === 1)) return; + setDown(true); + try { ws = new WebSocket(url); } catch (e) { schedule(); return; } + var me = ws; + ws.onopen = function () { + if (me !== ws) return; + backoff = 500; lastMsg = Date.now(); + setDown(false); + if (opts.onOpen) opts.onOpen(); + }; + ws.onmessage = function (ev) { + if (me !== ws) return; + lastMsg = Date.now(); + var msg; + try { msg = JSON.parse(ev.data); } catch (e) { return; } + if (msg && msg.type === 'state' && msg.state) WPP.setServerTime(msg.state.server_time); + if (opts.onMessage) opts.onMessage(msg); + }; + ws.onclose = function () { + if (me !== ws) return; + ws = null; + if (!closed) { setDown(true); schedule(); } + }; + ws.onerror = function () { /* onclose følger */ }; + } + function schedule() { + if (closed || retryTimer) return; + retryTimer = setTimeout(function () { retryTimer = null; open(); }, backoff); + backoff = Math.min(5000, backoff * 2); + } + function now() { + if (closed) return; + backoff = 500; + if (!ws || ws.readyState > 1) { clearTimeout(retryTimer); retryTimer = null; open(); } + } + function onVis() { if (document.visibilityState === 'visible') now(); } + document.addEventListener('visibilitychange', onVis); + window.addEventListener('online', now); + pingTimer = setInterval(function () { + if (!ws || ws.readyState !== 1) return; + if (Date.now() - lastMsg > 50000) { try { ws.close(); } catch (e) { /* */ } return; } + try { ws.send(JSON.stringify({ action: 'ping' })); } catch (e) { /* */ } + }, 20000); + open(); + + return { + send: function (obj) { + if (!ws || ws.readyState !== 1) return false; + try { ws.send(JSON.stringify(obj)); return true; } catch (e) { return false; } + }, + isOpen: function () { return !!ws && ws.readyState === 1; }, + close: function () { + closed = true; + clearTimeout(retryTimer); clearInterval(pingTimer); setDown(false); + document.removeEventListener('visibilitychange', onVis); + window.removeEventListener('online', now); + if (ws) { var w = ws; ws = null; try { w.close(); } catch (e) { /* */ } } + } + }; + }; + + /* ---------- lyd (WebAudio, syntetiseret) ---------- */ + var actx = null; + var sound = (WPP.sound = { + muted: WPP.ls.get('wpp:mute') === '1', + unlock: function () { + if (actx) { if (actx.state === 'suspended') actx.resume(); return; } + var AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return; + try { actx = new AC(); } catch (e) { actx = null; } + }, + setMuted: function (m) { sound.muted = !!m; WPP.ls.set('wpp:mute', m ? '1' : '0'); }, + tone: function (freq, start, dur, type, vol) { + if (!actx || sound.muted || actx.state !== 'running') return; + var t = actx.currentTime + (start || 0); + var o = actx.createOscillator(), g = actx.createGain(); + o.type = type || 'triangle'; + o.frequency.setValueAtTime(freq, t); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(vol || 0.18, t + 0.015); + g.gain.exponentialRampToValueAtTime(0.0001, t + dur); + o.connect(g); g.connect(actx.destination); + o.start(t); o.stop(t + dur + 0.05); + }, + play: function (kind) { + if (kind === 'phase') { sound.tone(523, 0, 0.15); sound.tone(784, 0.09, 0.25); } + else if (kind === 'tick') sound.tone(1200, 0, 0.06, 'square', 0.06); + else if (kind === 'stamp') { sound.tone(110, 0, 0.35, 'sawtooth', 0.22); sound.tone(82, 0.02, 0.4, 'sine', 0.3); } + else if (kind === 'truth') { [523, 659, 784, 1047].forEach(function (f, i) { sound.tone(f, i * 0.09, 0.35); }); } + else if (kind === 'pop') sound.tone(880, 0, 0.08, 'sine', 0.12); + } + }); + ['pointerdown', 'keydown', 'touchstart'].forEach(function (ev) { + window.addEventListener(ev, function () { sound.unlock(); }, { passive: true }); + }); +})(); diff --git a/party/static/party/dev-fixtures.js b/party/static/party/dev-fixtures.js new file mode 100644 index 0000000..632c099 --- /dev/null +++ b/party/static/party/dev-fixtures.js @@ -0,0 +1,166 @@ +/* Fixture-snapshots til dev.html (ingen server). window.WPPFixtures.build(phase, opts) */ +(function () { + 'use strict'; + var COLORS = ['#ff4d6d', '#3a86ff', '#ffbe0b', '#06d6a0', '#fb5607', '#8338ec', '#ff006e', '#00b4d8']; + var NAMES = ['Asger', 'Sus', 'Bo', 'Mette', 'Kasper', 'Line']; + var PHASES = ['join', 'lobby', 'category', 'lie', 'guess', 'reveal', 'scores', 'final']; + + function players(n, extra) { + var out = []; + for (var i = 0; i < n; i++) { + out.push({ id: 'p' + (i + 1), name: NAMES[i], color: COLORS[i], score: 0, connected: i !== 5, vip: i === 0, done: false }); + } + (extra || []).forEach(function (f, i) { if (out[i]) Object.assign(out[i], f); }); + return out; + } + function clone(o) { return JSON.parse(JSON.stringify(o)); } + function fakeQr() { + var s = ''; + var seed = 7; + function rnd() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; } + for (var y = 0; y < 29; y++) for (var x = 0; x < 29; x++) { + var finder = (x < 7 && y < 7) || (x > 21 && y < 7) || (x < 7 && y > 21); + if (finder) { + var fx = x > 21 ? x - 22 : x, fy = y > 21 ? y - 22 : y; + var on = fx === 0 || fx === 6 || fy === 0 || fy === 6 || (fx > 1 && fx < 5 && fy > 1 && fy < 5); + if (on) s += ''; + } else if (rnd() > 0.55) s += ''; + } + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(s + ''); + } + + function build(phase, opts) { + opts = opts || {}; + var now = Date.now(); + var base = { + code: 'KLMT', phase: phase === 'join' ? 'lobby' : phase, server_time: now, deadline: null, + join_url: 'https://party.weircon.dk/?code=KLMT', min_players: 2, max_players: 8, + round: { number: 1, name: 'Runde 1', multiplier: 1, question_number: 2, questions_in_round: 3 }, + players: players(6), audience_count: 3 + }; + var question = { category: 'Dyreriget', prompt: 'En flok _____ kaldes et parlament.' }; + var answers = [ + { id: 'a1', text: 'ugler' }, { id: 'a2', text: 'krager' }, { id: 'a3', text: 'papegøjer' }, + { id: 'a4', text: 'flamingoer' }, { id: 'a5', text: 'pingviner' }, { id: 'a6', text: 'måger & co' }, + { id: 'a7', text: 'skildpadder' } + ]; + var host, phones; + var me = function (o) { return Object.assign({ id: 'p3', role: 'player', vip: false, lie: null, guess: null, likes: [], is_chooser: false, last_delta: null }, o); }; + var aud = { id: 'x9', role: 'audience', vip: false, lie: null, guess: null, likes: [], is_chooser: false, last_delta: null }; + + switch (phase) { + case 'join': + host = Object.assign(base, { round: null, players: players(1), audience_count: 0 }); + phones = [ + { label: 'Ny spiller (tom formular)', join: { code: '', name: '' } }, + { label: 'Fra QR (?code=KLMT)', join: { code: 'KLMT', name: '' } }, + { label: 'VIP – for få spillere', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true }) }) } + ]; + break; + case 'lobby': + host = Object.assign(base, { round: null }); + phones = [ + { label: 'Spiller', state: Object.assign(clone(host), { me: me({}) }) }, + { label: 'VIP', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true }) }) }, + { label: 'Publikum', state: Object.assign(clone(host), { me: aud }) } + ]; + break; + case 'category': + host = Object.assign(base, { + deadline: now + 11000, + round: { number: 2, name: 'Runde 2', multiplier: 2, question_number: 1, questions_in_round: 3 }, + players: players(6, [{ score: 3500 }, { score: 1500 }, { score: 2000 }, { score: 500 }, { score: 0 }, { score: 1000 }]), + category: { chooser_id: 'p1', options: [{ id: 3, name: 'Dyreriget' }, { id: 7, name: 'Mærkelige love' }, { id: 9, name: 'Danmarkshistorie' }, { id: 12, name: 'Mad & drikke' }] } + }); + phones = [ + { label: 'Spiller', state: Object.assign(clone(host), { me: me({}) }) }, + { label: 'VIP = vælger', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, is_chooser: true }) }) }, + { label: 'Publikum', state: Object.assign(clone(host), { me: aud }) } + ]; + break; + case 'lie': + host = Object.assign(base, { + deadline: now + 42000, question: question, + players: players(6, [{ done: true }, {}, { done: true }, { done: true }]) + }); + phones = [ + { label: 'Spiller (skriver)', state: Object.assign(clone(host), { me: me({}) }) }, + { label: 'VIP (har løjet)', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, lie: 'krager' }) }) }, + { label: 'Publikum', state: Object.assign(clone(host), { me: aud }) } + ]; + break; + case 'guess': + host = Object.assign(base, { + deadline: now + 4000, question: question, answers: answers, + players: players(6, [{ done: true }, { done: true }, {}, { done: true }]) + }); + var pAns = answers.filter(function (a) { return a.id !== 'a5'; }); + var vAns = answers.filter(function (a) { return a.id !== 'a2'; }); + phones = [ + { label: 'Spiller (gætter)', state: Object.assign(clone(host), { answers: pAns, me: me({ lie: 'pingviner' }) }) }, + { label: 'VIP (har gættet)', state: Object.assign(clone(host), { answers: vAns, me: me({ id: 'p1', vip: true, lie: 'krager', guess: 'a1', likes: ['a3'] }) }) }, + { label: 'Publikum', state: Object.assign(clone(host), { me: aud }) } + ]; + break; + case 'reveal': + var stepMs = 4000; + var steps = [ + { answer_id: 'a2', text: 'krager', is_truth: false, house: false, authors: ['p1'], guessed_by: ['p3', 'p4'], audience_votes: 2, likes: 1, points: [{ player_id: 'p1', delta: 1000 }] }, + { answer_id: 'a3', text: 'papegøjer', is_truth: false, house: false, authors: ['p2', 'p5'], guessed_by: ['p6'], audience_votes: 0, likes: 0, points: [{ player_id: 'p2', delta: 500 }, { player_id: 'p5', delta: 500 }] }, + { answer_id: 'a6', text: 'måger & co', is_truth: false, house: true, authors: [], guessed_by: ['p5'], audience_votes: 1, likes: 2, points: [] }, + { answer_id: 'a1', text: 'ugler', is_truth: true, house: false, authors: [], guessed_by: ['p1', 'p2'], audience_votes: 0, likes: 0, points: [{ player_id: 'p1', delta: 1000 }, { player_id: 'p2', delta: 1000 }] } + ]; + var total = steps.length * stepMs + 3000; + var t = opts.t != null ? Number(opts.t) : stepMs * 0 + 2600; + host = Object.assign(base, { + deadline: now - t + total, question: question, answers: answers, + players: players(6, [{ score: 2000 }, { score: 1500 }, { score: 0 }, { score: 0 }, { score: 500 }, { score: 0 }]), + reveal: { steps: steps, step_ms: stepMs } + }); + phones = [ + { label: 'Spiller (0 point)', state: Object.assign(clone(host), { me: me({ lie: 'pingviner', guess: 'a2', last_delta: 0 }) }) }, + { label: 'VIP (+2.000)', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, lie: 'krager', guess: 'a1', likes: ['a3'], last_delta: 2000 }) }) }, + { label: 'Publikum', state: Object.assign(clone(host), { me: aud }) } + ]; + break; + case 'scores': + host = Object.assign(base, { + deadline: now + 8000, question: null, + players: players(6, [{ score: 4500 }, { score: 3000 }, { score: 1000 }, { score: 1500 }, { score: 2500 }, { score: 0 }]), + deltas: [{ player_id: 'p1', delta: 2000 }, { player_id: 'p2', delta: 1500 }, { player_id: 'p5', delta: 500 }, { player_id: 'p4', delta: 1000 }] + }); + phones = [ + { label: 'Spiller', state: Object.assign(clone(host), { me: me({ last_delta: 0 }) }) }, + { label: 'VIP', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true, last_delta: 2000 }) }) }, + { label: 'Publikum', state: Object.assign(clone(host), { me: aud }) } + ]; + break; + case 'final': + var ps = players(6, [{ score: 12500 }, { score: 9000 }, { score: 4000 }, { score: 7500 }, { score: 9000 }, { score: 1500 }]); + host = Object.assign(base, { + round: null, players: ps, + final: { + ranking: [ + { player_id: 'p1', score: 12500, place: 1 }, { player_id: 'p2', score: 9000, place: 2 }, { player_id: 'p5', score: 9000, place: 2 }, + { player_id: 'p4', score: 7500, place: 4 }, { player_id: 'p3', score: 4000, place: 5 }, { player_id: 'p6', score: 1500, place: 6 } + ], + awards: [ + { title: 'Største løgner', player_id: 'p1', detail: 'narrede 7 gange' }, + { title: 'Sandhedssiger', player_id: 'p4', detail: 'fandt sandheden 5 gange' }, + { title: 'Godtroende', player_id: 'p3', detail: 'faldt for 6 løgne' }, + { title: 'Publikumsfavorit', player_id: 'p2', detail: '9 hjerter' } + ] + } + }); + phones = [ + { label: 'Spiller (nr. 5)', state: Object.assign(clone(host), { me: me({}) }) }, + { label: 'VIP (vinder)', state: Object.assign(clone(host), { me: me({ id: 'p1', vip: true }) }) }, + { label: 'Publikum', state: Object.assign(clone(host), { me: aud }) } + ]; + break; + } + return { host: host, phones: phones }; + } + + window.WPPFixtures = { build: build, phases: PHASES, qr: fakeQr() }; +})(); diff --git a/party/static/party/dev.html b/party/static/party/dev.html new file mode 100644 index 0000000..fe17254 --- /dev/null +++ b/party/static/party/dev.html @@ -0,0 +1,96 @@ + + + + + + Fup og Fakta – dev-harness + + + + + +
+
+
Storskærm (1280×720)
+
+
+
+
+ + + + + + + + diff --git a/party/static/party/host.js b/party/static/party/host.js new file mode 100644 index 0000000..21f6a69 --- /dev/null +++ b/party/static/party/host.js @@ -0,0 +1,370 @@ +/* Fup og Fakta – storskærm. Eksporterer window.WPPHost = {renderHost, mount, revealProgress}. */ +(function () { + 'use strict'; + var WPP = window.WPP, esc = WPP.esc; + var TIMED = { category: 1, lie: 1, guess: 1, reveal: 1, scores: 1 }; + + function ui(root) { + return root._ui || (root._ui = { lastPhase: null, revealKey: null, revealSeen: {}, lastTick: null, state: null, ctx: null }); + } + + /* ---------- hjælpere ---------- */ + function joinHost(state) { + var u = state.join_url || ''; + var m = /^[a-z]+:\/\/([^/?#]+)/i.exec(u); + return m ? m[1] : location.host || 'party.weircon.dk'; + } + function chips(ids, players, cls) { + return ids.map(function (id) { return WPP.chip(players[id], cls || ''); }).join(''); + } + function avatar(p) { + return '' + esc((p.name || '?').charAt(0).toUpperCase()) + ''; + } + function sortedPlayers(state) { + return (state.players || []).slice().sort(function (a, b) { return (b.score || 0) - (a.score || 0); }); + } + + /* Reveal-tidslinje beregnes fra snapshot: start = deadline − (trin × step_ms + 3 s) */ + function revealProgress(state, u) { + var r = state.reveal || {}; + var steps = r.steps || []; + var ms = r.step_ms || 4000; + var total = steps.length * ms + 3000; + var start; + var key = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + ((state.question || {}).prompt || ''); + if (state.deadline) start = state.deadline - total; + else { + if (!u.revealSeen[key]) u.revealSeen[key] = WPP.now(); + start = u.revealSeen[key]; + } + var el = Math.max(0, WPP.now() - start); + var i = Math.floor(el / ms); + if (i >= steps.length) return { done: true, i: steps.length, n: steps.length, key: key + '|done' }; + var f = (el % ms) / ms; + var stage = f < 0.28 ? 0 : f < 0.55 ? 1 : 2; + return { done: false, i: i, stage: stage, n: steps.length, key: key + '|' + i + '.' + stage }; + } + + /* ---------- faste dele ---------- */ + function topBar(state) { + var r = state.round; + var mid = ''; + if (r && state.phase !== 'lobby' && state.phase !== 'final') { + mid = '' + WPP.roundTitle(r) + '' + + (r.questions_in_round > 1 && state.phase !== 'scores' + ? 'Spørgsmål ' + (r.question_number || 1) + ' af ' + r.questions_in_round + '' : ''); + } + return '
' + + '
Fup og Fakta
' + + '
' + mid + '
' + + (state.phase !== 'lobby' + ? '
' + esc(joinHost(state)) + ' ' + esc(state.code || '') + '' + + (state.audience_count ? '+' + state.audience_count + ' publikum' : '') + '
' + : '
') + + '
'; + } + + function footer(state, opts) { + opts = opts || {}; + var ps = state.players || []; + var chipsHtml = ''; + if (opts.doneChips) { + chipsHtml = ps.map(function (p) { + return '' + + '' + esc(p.name) + (p.done ? '' : '') + ''; + }).join(''); + } + var skip = TIMED[state.phase] ? '' : ''; + var mute = ''; + return ''; + } + + /* ---------- faser ---------- */ + function lobby(state, ctx) { + var ps = state.players || []; + var max = state.max_players || 8, min = state.min_players || 2; + var code = state.code || '????'; + var qr = ctx.qrSrc || ('/qr/' + encodeURIComponent(code) + '.svg'); + var tiles = code.split('').map(function (ch, i) { return '' + esc(ch) + ''; }).join(''); + var cards = ''; + for (var i = 0; i < max; i++) { + var p = ps[i]; + if (p) { + cards += '
' + avatar(p) + + '' + esc(p.name) + '' + + (p.vip ? 'VIP' : '') + + (p.connected === false ? 'afbrudt' : '') + + '
'; + } else { + cards += '
?Ledig plads
'; + } + } + var enough = ps.length >= min; + return '
' + + '

Gå ind på

' + esc(joinHost(state)) + '

og skriv koden

' + + '
' + tiles + '
' + + '
QR-kode til at deltage…eller scan her
' + + '
' + + '

Spillere ' + ps.length + '/' + max + '

' + + '
' + cards + '
' + + '
' + + '' + + '
' + (enough ? 'VIP kan også starte fra telefonen' : 'Der skal være mindst ' + min + ' spillere') + + (state.audience_count ? '
+ ' + state.audience_count + ' publikum' : '') + '
' + + '
'; + } + + function category(state) { + var cat = state.category || {}; + var players = WPP.playerMap(state); + var ch = players[cat.chooser_id]; + var r = state.round || {}; + return '
' + + '
' + WPP.roundTitle(r) + '
' + + (r.questions_in_round ? '
Spørgsmål ' + (r.question_number || 1) + ' af ' + r.questions_in_round + '
' : '') + '
' + + '
' + (ch ? WPP.chip(ch, 'xl') : 'Nogen') + ' vælger kategori…
' + + '
' + + (cat.options || []).map(function (o, i) { + return '
' + esc(o.name) + '
'; + }).join('') + '
' + + '
' + WPP.timerHtml(state, 'ring') + '
'; + } + + function questionBlock(state, cls, fill) { + var q = state.question || {}; + return '
' + + (q.category ? '
' + esc(q.category) + '
' : '') + + '
' + WPP.promptHtml(q.prompt || '', fill) + '
'; + } + + function lie(state) { + return questionBlock(state, 'lie') + + '

Find på en løgn på din telefon, der kan narre de andre!

' + + '
' + WPP.timerHtml(state, 'ring') + '
'; + } + + function guess(state) { + var ans = state.answers || []; + var cols = ans.length > 6 ? 3 : 2; + return questionBlock(state, 'small') + + '
' + + ans.map(function (a, i) { + return '
' + esc(a.text) + '
'; + }).join('') + '
' + + '

Hvad er sandheden? Gæt på din telefon!

' + + '
' + WPP.timerHtml(state, 'ring') + '
'; + } + + function reveal(state, u) { + var r = state.reveal || {}; + var steps = r.steps || []; + var players = WPP.playerMap(state); + var pr = revealProgress(state, u); + var truth = steps.filter(function (s) { return s.is_truth; })[0]; + var truthShown = truth && (pr.done || (steps[pr.i] === truth && pr.stage >= 2)); + var out = questionBlock(state, 'small rv', truthShown ? truth.text : ''); + if (!pr.done) { + var s = steps[pr.i]; + var who = '', stamp = ''; + if (pr.stage >= 1) { + var g = s.guessed_by || []; + who = '
' + (s.is_truth ? 'Gættede rigtigt:' : 'Faldt for den:') + '' + + (g.length ? chips(g, players, 'pop') : (s.is_truth ? 'Ingen…' : '')) + + (s.audience_votes ? '+' + s.audience_votes + ' fra publikum' : '') + '
'; + } + if (pr.stage >= 2) { + var pts = (s.points || []).map(function (p) { + return '' + WPP.chip(players[p.player_id]) + '' + WPP.signed(p.delta) + ''; + }).join(''); + var likes = s.likes ? '♥ ' + s.likes + '' : ''; + if (s.is_truth) { + stamp = '
SANDHEDEN
' + + (pts ? '
' + pts + '
' : '
Ingen fandt sandheden!
') + + (state.reveal && state.reveal.note ? '
' + esc(state.reveal.note) + '
' : '') + '
'; + } else if (s.house) { + stamp = '
HUSETS LØGN
Huset narrede jer!
' + likes + '
'; + } else { + var auth = (s.authors || []).map(function (id) { return players[id] ? esc(players[id].name) : '?'; }); + stamp = '
LØGN
af ' + WPP.joinNames(auth) + '
' + + (pts ? '
' + pts + '
' : '') + likes + '
'; + } + } + out += '
' + + '
' + esc(s.text) + '
' + who + stamp + '
'; + } else { + /* opsummering: point i spørgsmålet */ + var sum = {}; + steps.forEach(function (s) { (s.points || []).forEach(function (p) { sum[p.player_id] = (sum[p.player_id] || 0) + (p.delta || 0); }); }); + var ps = sortedPlayers(state); + out += '

Point i dette spørgsmål

    ' + ps.map(function (p, i) { + var d = sum[p.id] || 0; + return '
  1. ' + avatar(p) + '' + esc(p.name) + '' + + '' + (d ? WPP.signed(d) : '–') + '' + WPP.fmt(p.score) + '
  2. '; + }).join('') + '
'; + } + out += '
' + steps.map(function (s, i) { + return ''; + }).join('') + '
'; + return out; + } + + function scores(state) { + var ps = sortedPlayers(state); + var dmap = {}; + (state.deltas || []).forEach(function (d) { dmap[d.player_id] = d.delta || 0; }); + var max = Math.max.apply(null, ps.map(function (p) { return p.score || 0; }).concat([1])); + var r = state.round; + return '

Stillingen' + (r ? ' efter ' + esc(r.name || ('Runde ' + r.number)) : '') + '

' + + '
    ' + ps.map(function (p, i) { + var d = dmap[p.id] || 0; + return '
  1. ' + + '' + (i + 1) + '' + avatar(p) + '' + esc(p.name) + '' + + '' + + (d ? '' + WPP.signed(d) + '' : '') + + '' + + WPP.fmt(p.score) + '
  2. '; + }).join('') + '
' + + '
' + WPP.timerHtml(state, 'ring') + '
'; + } + + function finalView(state) { + var fin = state.final || {}; + var players = WPP.playerMap(state); + var rank = (fin.ranking || []).slice().sort(function (a, b) { return (a.place || 99) - (b.place || 99); }); + var top = rank.slice(0, 3); + var order = [top[1], top[0], top[2]]; + var pod = order.map(function (r, idx) { + if (!r) return '
'; + var p = players[r.player_id] || { name: '?', color: '#888' }; + var cls = ['second', 'first', 'third'][idx]; + return '
' + + (r.place === 1 ? '' : '') + avatar(p) + '
' + esc(p.name) + '
' + + '
' + WPP.fmt(r.score) + '
' + r.place + '
'; + }).join(''); + var rest = rank.slice(3).map(function (r) { + var p = players[r.player_id] || { name: '?' }; + return '
  • ' + r.place + '.' + esc(p.name) + '' + WPP.fmt(r.score) + '
  • '; + }).join(''); + var aw = (fin.awards || []).map(function (a, i) { + return '
  • ' + esc(a.title) + '' + WPP.chip(players[a.player_id]) + + (a.detail ? '' + esc(a.detail) + '' : '') + '
  • '; + }).join(''); + var w = top[0] && players[top[0].player_id]; + return '

    ' + (w ? esc(w.name) + ' vinder!' : 'Spillet er slut!') + '

    ' + + '
    ' + pod + '
    ' + + '
    ' + + (aw ? '

    Priser

    ' : '') + + (rest ? '
      ' + rest + '
    ' : '') + + '
    '; + } + + /* ---------- hændelser ---------- */ + function bind(root) { + if (root._bound) return; + root._bound = true; + root.addEventListener('click', function (e) { + var b = e.target.closest('[data-act]'); + if (!b || !root.contains(b) || b.disabled) return; + var u = ui(root), ctx = u.ctx || {}; + var a = b.getAttribute('data-act'); + WPP.sound.unlock(); + if (a === 'mute') { + WPP.sound.setMuted(!WPP.sound.muted); + if (u.state) renderHost(root, u.state, ctx); + return; + } + if (!ctx.send) return; + if (a === 'kick') { + var nm = b.getAttribute('data-name') || ''; + if (!ctx.harness && !window.confirm('Smid ' + nm + ' ud af spillet?')) return; + ctx.send({ action: 'kick', player_id: b.getAttribute('data-arg') }); + return; + } + if (a === 'start' || a === 'skip' || a === 'play_again') { + ctx.send({ action: a }); + b.disabled = true; + setTimeout(function () { b.disabled = false; }, 1500); + } + }); + } + + function hostTick(root) { + var u = ui(root), s = u.state; + if (!s || !root.isConnected) return; + if (s.phase === 'reveal') { + var pr = revealProgress(s, u); + if (pr.key !== u.revealKey) { + var prev = u.revealKey; + renderHost(root, s, u.ctx); + if (prev && !pr.done && pr.stage === 2) { + var st = (s.reveal.steps || [])[pr.i] || {}; + WPP.sound.play(st.is_truth ? 'truth' : 'stamp'); + } else if (prev && !pr.done && pr.stage === 0) WPP.sound.play('pop'); + } + } + if (s.deadline && (s.phase === 'lie' || s.phase === 'guess' || s.phase === 'category')) { + var left = WPP.secondsLeft(s.deadline); + if (left !== u.lastTick) { + u.lastTick = left; + if (left > 0 && left <= 5) WPP.sound.play('tick'); + } + } + } + + /* ---------- offentlig render ---------- */ + function renderHost(root, state, ctx) { + ctx = ctx || {}; + state = state || {}; + var u = ui(root); + u.state = state; u.ctx = ctx; + bind(root); + if (!root._ticking) { root._ticking = true; WPP.onTick(function () { hostTick(root); }); } + root.classList.add('host-root'); + var sh = WPP.shell(root); + var phase = state.phase || 'lobby'; + var main, foot = {}; + switch (phase) { + case 'lobby': main = lobby(state, ctx); break; + case 'category': main = category(state); break; + case 'lie': main = lie(state); foot.doneChips = true; break; + case 'guess': main = guess(state); foot.doneChips = true; break; + case 'reveal': main = reveal(state, u); u.revealKey = revealProgress(state, u).key; break; + case 'scores': main = scores(state); break; + case 'final': main = finalView(state); break; + default: main = '

    Øjeblik…

    '; + } + sh.screen.className = 'screen hs hs-' + phase; + WPP.patch(sh.screen, topBar(state) + '
    ' + main + '
    ' + footer(state, foot)); + WPP.animateCounts(sh.screen); + if (u.lastPhase !== phase) { + if (u.lastPhase && !ctx.harness) WPP.sound.play('phase'); + u.lastPhase = phase; + } + } + + function mount(root) { + var code = (root.getAttribute('data-code') || '').toUpperCase(); + var conn = null; + var ctx = { send: function (m) { return conn ? conn.send(m) : false; } }; + renderHost(root, { phase: 'lobby', code: code, players: [], audience_count: 0, min_players: 2, max_players: 8, join_url: location.origin + '/?code=' + code }, ctx); + conn = WPP.connect('/ws/host/' + encodeURIComponent(code) + '/', { + root: root, + onMessage: function (msg) { + if (msg.type === 'state' && msg.state) renderHost(root, msg.state, ctx); + else if (msg.type === 'error') WPP.toast(root, msg.message || 'Ups, noget gik galt.'); + } + }); + return ctx; + } + + window.WPPHost = { renderHost: renderHost, mount: mount, revealProgress: revealProgress }; + window.renderHost = renderHost; + + if (!window.WPP_HARNESS) { + var go = function () { var r = document.getElementById('host'); if (r) mount(r); }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', go); else go(); + } +})(); diff --git a/party/static/party/party.css b/party/static/party/party.css new file mode 100644 index 0000000..757d145 --- /dev/null +++ b/party/static/party/party.css @@ -0,0 +1,500 @@ +/* Fup og Fakta – party/game-show-tema. Ingen build-step. */ +:root { + --bg0: #12042b; + --bg1: #2b0a5c; + --bg2: #4a0f7a; + --ink: #ffffff; + --muted: rgba(255, 255, 255, .72); + --faint: rgba(255, 255, 255, .14); + --card: rgba(255, 255, 255, .09); + --card-b: rgba(255, 255, 255, .18); + --pink: #ff3d8b; + --pink-d: #b5185a; + --yellow: #ffd23f; + --yellow-d: #c99a00; + --cyan: #2de2e6; + --cyan-d: #109ea2; + --green: #3ddc84; + --green-d: #1f9b55; + --orange: #ff8c42; + --orange-d: #c25a14; + --violet: #a56bff; + --violet-d: #6c35c9; + --red: #ff4757; + --navy: #1d0846; + --font: "Fredoka", "Baloo 2", ui-rounded, "SF Pro Rounded", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; + color-scheme: dark; +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: var(--font); + color: var(--ink); + background: var(--bg0); + background-image: + radial-gradient(1200px 700px at 15% -10%, rgba(255, 61, 139, .35), transparent 60%), + radial-gradient(1000px 700px at 110% 10%, rgba(45, 226, 230, .22), transparent 60%), + radial-gradient(900px 900px at 50% 120%, rgba(255, 210, 63, .18), transparent 60%), + linear-gradient(160deg, var(--bg1), var(--bg0) 70%); + background-attachment: fixed; + min-height: 100vh; + min-height: 100dvh; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: transparent; +} +button, input { font-family: inherit; } +.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.muted { color: var(--muted); } +.center { text-align: center; } + +/* ---------- knapper ---------- */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: .4em; + min-height: 52px; padding: .7em 1.2em; + font: 600 1.15rem/1.15 var(--font); letter-spacing: .01em; + color: var(--navy); background: var(--yellow); + border: 0; border-radius: 18px; + box-shadow: 0 6px 0 var(--yellow-d), 0 10px 24px rgba(0, 0, 0, .35); + cursor: pointer; user-select: none; touch-action: manipulation; + transition: transform .08s, box-shadow .08s, filter .15s, opacity .15s; +} +.btn:active:not(:disabled) { transform: translateY(5px); box-shadow: 0 1px 0 var(--yellow-d), 0 4px 10px rgba(0, 0, 0, .3); } +.btn:disabled { opacity: .45; cursor: not-allowed; filter: grayscale(.4); } +.btn:focus-visible { outline: 3px solid #fff; outline-offset: 3px; } +.btn-xl { width: 100%; min-height: 64px; font-size: 1.4rem; } +.btn-go { background: var(--green); box-shadow: 0 6px 0 var(--green-d), 0 10px 24px rgba(0, 0, 0, .35); } +.btn-go:active:not(:disabled) { box-shadow: 0 1px 0 var(--green-d); } +.btn-alt { background: var(--cyan); box-shadow: 0 6px 0 var(--cyan-d), 0 10px 24px rgba(0, 0, 0, .3); } +.btn-alt:active:not(:disabled) { box-shadow: 0 1px 0 var(--cyan-d); } +.c0 { --c: var(--pink); --cd: var(--pink-d); } +.c1 { --c: var(--cyan); --cd: var(--cyan-d); } +.c2 { --c: var(--yellow); --cd: var(--yellow-d); } +.c3 { --c: var(--green); --cd: var(--green-d); } +.c4 { --c: var(--orange); --cd: var(--orange-d); } +.c5 { --c: var(--violet); --cd: var(--violet-d); } +.busy [data-act]:not([data-act="like"]), .busy button[type="submit"] { opacity: .55; pointer-events: none; } + +/* ---------- chips ---------- */ +.chip { + display: inline-flex; align-items: center; gap: .4em; + padding: .25em .75em .25em .35em; border-radius: 999px; + background: color-mix(in srgb, var(--pc, #888) 28%, rgba(0, 0, 0, .25)); + border: 2px solid var(--pc, #888); + font-weight: 600; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; +} +.chip .dot, .dot { width: .85em; height: .85em; border-radius: 50%; background: var(--pc, #888); flex: none; box-shadow: 0 0 0 2px rgba(255, 255, 255, .5); } +.chip.off { opacity: .45; border-style: dashed; } +.chip.aud { --pc: #b9b2d6; } + +/* ---------- toasts + forbindelse ---------- */ +.wpp-root { position: relative; } +.toasts { + position: fixed; left: 50%; bottom: max(20px, env(safe-area-inset-bottom)); transform: translateX(-50%); + display: flex; flex-direction: column; gap: 8px; z-index: 50; width: min(92%, 480px); pointer-events: none; +} +.toast { + background: #fff; color: var(--navy); font-weight: 600; font-size: 1.05rem; + padding: 14px 18px; border-radius: 16px; border-left: 8px solid var(--pink); + box-shadow: 0 12px 30px rgba(0, 0, 0, .45); animation: toast-in .25s ease-out; +} +.toast.info { border-left-color: var(--cyan); } +.toast.out { opacity: 0; transform: translateY(8px); transition: .4s; } +@keyframes toast-in { from { opacity: 0; transform: translateY(16px) scale(.96); } } +.conn { + display: none; position: fixed; inset: 0; z-index: 60; + background: rgba(18, 4, 43, .72); backdrop-filter: blur(3px); + align-items: center; justify-content: center; +} +.offline > .conn { display: flex; } +.conn-box { display: flex; align-items: center; gap: 14px; font-size: 1.4rem; font-weight: 600; background: var(--bg1); padding: 18px 26px; border-radius: 20px; border: 2px solid var(--card-b); } +.spinner { width: 26px; height: 26px; border-radius: 50%; border: 4px solid var(--faint); border-top-color: var(--yellow); animation: spin .8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ---------- nedtælling ---------- */ +.tpill { + display: inline-flex; align-items: center; justify-content: center; min-width: 54px; height: 40px; padding: 0 12px; + border-radius: 999px; background: var(--yellow); color: var(--navy); font-weight: 700; font-size: 1.25rem; + background-image: linear-gradient(90deg, rgba(255, 255, 255, .45) calc(var(--frac, 1) * 100%), transparent 0); + font-variant-numeric: tabular-nums; +} +.tpill.urgent { background-color: var(--red); color: #fff; animation: pulse .5s ease-in-out infinite alternate; } +.ring { position: relative; width: 100%; aspect-ratio: 1; } +.ring svg { width: 100%; height: 100%; transform: rotate(-90deg); display: block; } +.ring circle { fill: none; stroke-width: 9; } +.ring .bg { stroke: rgba(255, 255, 255, .15); fill: rgba(0, 0, 0, .25); } +.ring .fg { stroke: var(--yellow); stroke-linecap: round; stroke-dasharray: 100 100; stroke-dashoffset: calc(100 - var(--frac, 1) * 100); transition: stroke-dashoffset .2s linear, stroke .3s; } +.ring .num { position: absolute; inset: 0; display: grid; place-items: center; font-weight: 700; font-size: 2.6em; font-variant-numeric: tabular-nums; } +.ring.urgent .fg { stroke: var(--red); } +.ring.urgent .num { color: var(--red); animation: pulse .5s ease-in-out infinite alternate; } +.tbar { height: 8px; border-radius: 4px; background: var(--faint); overflow: hidden; } +.tbar i { display: block; height: 100%; width: calc(var(--frac, 1) * 100%); background: var(--yellow); transition: width .2s linear; } +@keyframes pulse { to { transform: scale(1.08); } } + +/* ---------- blank i spørgsmål ---------- */ +.blank { + display: inline-block; min-width: 3.2em; height: .95em; vertical-align: -0.08em; margin: 0 .12em; + border-bottom: .12em solid var(--yellow); border-radius: .15em .15em .05em .05em; + background: rgba(255, 210, 63, .16); +} +.blank.filled { + min-width: 0; height: auto; padding: 0 .25em; border: 0; border-radius: .2em; + background: var(--green); color: var(--navy); animation: pop .5s cubic-bezier(.3, 1.6, .5, 1); +} + +/* ===================================================================== + TELEFON + ===================================================================== */ +.ph { max-width: 540px; margin: 0 auto; padding: 0 16px calc(28px + env(safe-area-inset-bottom)); font-size: 18px; } +.ph-top { + position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 10px; + padding: calc(10px + env(safe-area-inset-top)) 0 10px; margin-bottom: 8px; + background: linear-gradient(var(--bg0) 70%, transparent); +} +.ph-top > [data-k="who"] { flex: 1; min-width: 0; display: flex; } +.ph-code { font-weight: 700; letter-spacing: .12em; padding: 6px 10px; border-radius: 10px; background: var(--card); border: 1px solid var(--card-b); font-size: .95rem; } +.ph-main { display: flex; flex-direction: column; gap: 16px; } +.ph h2 { margin: 0; font-size: 1.7rem; line-height: 1.1; } +.ph h3 { margin: 0 0 10px; font-size: 1.05rem; color: var(--muted); font-weight: 600; } +.ph p { margin: 0; } +.hint { color: var(--muted); font-size: .98rem; line-height: 1.35; } +.card, .qcard, .plist, .hearts, .picked, .myawards { + background: var(--card); border: 2px solid var(--card-b); border-radius: 22px; padding: 18px; +} +.big { text-align: center; padding: 30px 10px 10px; } +.big h2 { font-size: 2rem; } +.big p { margin-top: 8px; color: var(--muted); } +.big.pop h2 { animation: pop .5s cubic-bezier(.3, 1.6, .5, 1); } +.big.pulse h2 { animation: breathe 1.6s ease-in-out infinite; } +.big.look h2 { font-size: 2.4rem; color: var(--yellow); animation: wiggle 1.4s ease-in-out infinite; } +@keyframes pop { from { transform: scale(.6); opacity: 0; } } +@keyframes breathe { 50% { opacity: .65; } } +@keyframes wiggle { 0%, 100% { transform: rotate(-2deg); } 50% { transform: rotate(2deg) translateY(-3px); } } + +/* join */ +.ph-join { padding-top: 24px; } +.ph-join .ph-main, .ph-join { display: flex; flex-direction: column; gap: 18px; } +.logo { text-align: center; line-height: .9; padding: 14px 0 4px; transform: rotate(-3deg); } +.logo-fup, .logo-fakta { font-weight: 700; font-size: 3.6rem; letter-spacing: .02em; text-shadow: 0 5px 0 rgba(0, 0, 0, .35); } +.logo-fup { color: var(--pink); } +.logo-fakta { color: var(--yellow); } +.logo-og { font-size: 1.3rem; color: var(--cyan); font-weight: 600; margin: 2px 0; } +.join-form { display: flex; flex-direction: column; gap: 16px; } +.join-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; color: var(--muted); font-size: 1rem; } +.ph input[type="text"], .ph input:not([type]), .join-form input { + width: 100%; min-height: 56px; padding: 12px 16px; font-size: 1.3rem; font-weight: 600; + color: var(--navy); background: #fff; border: 3px solid transparent; border-radius: 16px; outline: none; +} +.ph input:focus { border-color: var(--yellow); box-shadow: 0 0 0 4px rgba(255, 210, 63, .3); } +.code-in { text-transform: uppercase; letter-spacing: .4em; text-align: center; font-size: 1.8rem !important; } +.join-err { background: var(--red); color: #fff; font-weight: 600; padding: 12px 16px; border-radius: 14px; text-align: center; } + +/* lobby */ +.hello { text-align: center; padding-top: 16px; } +.avatar { + width: 96px; height: 96px; margin: 0 auto 12px; border-radius: 50%; display: grid; place-items: center; + background: var(--pc); color: #fff; font-size: 3rem; font-weight: 700; text-shadow: 0 2px 0 rgba(0, 0, 0, .3); + box-shadow: 0 0 0 6px rgba(255, 255, 255, .2), 0 10px 30px color-mix(in srgb, var(--pc) 60%, transparent); + animation: pop .5s cubic-bezier(.3, 1.6, .5, 1); +} +.hello p { font-size: 1.3rem; color: var(--muted); margin-top: 4px; } +.chips { display: flex; flex-wrap: wrap; gap: 8px; } +.plist .muted { margin-top: 10px; } +.actions { display: flex; flex-direction: column; gap: 10px; margin-top: 6px; } + +/* kategori */ +.roundtag { align-self: center; font-weight: 700; color: var(--navy); background: var(--yellow); padding: 6px 16px; border-radius: 999px; font-size: 1rem; transform: rotate(-2deg); } +.ask { text-align: center; } +.opts { display: grid; gap: 14px; } +.btn-opt, .btn-ans { + width: 100%; min-height: 72px; font-size: 1.3rem; color: var(--navy); + background: var(--c); box-shadow: 0 6px 0 var(--cd), 0 10px 24px rgba(0, 0, 0, .3); + text-align: center; overflow-wrap: anywhere; +} +.btn-opt:active:not(:disabled), .btn-ans:active:not(:disabled) { box-shadow: 0 1px 0 var(--cd); } +.opts.ghost { opacity: .55; gap: 10px; } +.opt-ghost { padding: 14px; border-radius: 16px; border: 2px dashed var(--c); text-align: center; font-weight: 600; } + +/* løgn */ +.qcat { display: inline-block; font-size: .85rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--cyan); margin-bottom: 6px; } +.qprompt { font-size: 1.35rem; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; } +.lie-form { display: flex; flex-direction: column; gap: 14px; } +.lie-form input.flash { animation: flash .6s; } +@keyframes flash { 0% { background: var(--yellow); } } +.mylie { text-align: center; color: var(--muted); overflow-wrap: anywhere; } +.mylie b { color: #fff; } +.progress { text-align: center; font-size: .95rem; } + +/* gæt */ +.answers { display: flex; flex-direction: column; gap: 14px; } +.btn-ans { min-height: 64px; font-size: 1.25rem; } +.picked { text-align: center; } +.picked p { color: var(--muted); } +.pick { font-size: 1.6rem; font-weight: 700; margin-top: 6px; color: var(--yellow); overflow-wrap: anywhere; } +.hearts .hrow { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-top: 1px solid var(--faint); } +.hearts .hrow:first-of-type { border-top: 0; } +.hrow span { flex: 1; font-weight: 600; overflow-wrap: anywhere; } +.heart { + flex: none; width: 52px; height: 48px; border-radius: 14px; border: 2px solid var(--card-b); + background: rgba(0, 0, 0, .2); color: rgba(255, 255, 255, .45); font-size: 1.5rem; cursor: pointer; touch-action: manipulation; +} +.heart.on { color: #fff; background: var(--pink); border-color: var(--pink); animation: pop .35s cubic-bezier(.3, 1.6, .5, 1); } + +/* reveal/score */ +.delta { text-align: center; display: flex; flex-direction: column; align-items: center; } +.delta b { font-size: 3.4rem; line-height: 1; color: var(--muted); } +.delta.plus b { color: var(--green); animation: pop .6s cubic-bezier(.3, 1.6, .5, 1); } +.delta span { color: var(--muted); } +.place { text-align: center; padding: 10px 0; } +.pnum { font-size: 5rem; font-weight: 700; line-height: 1; color: var(--yellow); text-shadow: 0 6px 0 rgba(0, 0, 0, .3); animation: pop .6s cubic-bezier(.3, 1.6, .5, 1); } +.place .score { font-size: 1.5rem; font-weight: 700; margin-top: 6px; } +.place.win .pnum { color: var(--yellow); font-size: 6rem; } +.dchip { display: inline-block; margin-top: 8px; background: var(--green); color: var(--navy); font-weight: 700; padding: 4px 14px; border-radius: 999px; } +.mini-board { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.mini-board li { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--card); border-radius: 14px; } +.mini-board li.mine { outline: 3px solid var(--pc); } +.mini-board .n { flex: 1; font-weight: 600; overflow: hidden; text-overflow: ellipsis; } +.award { display: flex; flex-direction: column; padding: 6px 0; } +.award b { color: var(--yellow); } +.award span { color: var(--muted); font-size: .95rem; } + +/* ===================================================================== + STORSKÆRM – alt i em, skaleret fra containerens størrelse + ===================================================================== */ +.host-root { container-type: size; overflow: hidden; } +#host { width: 100vw; height: 100vh; height: 100dvh; } +.hs { + position: absolute; inset: 0; display: grid; grid-template-rows: auto 1fr auto; + font-size: min(1.25cqw, 2.222cqh); padding: .9em 1.6em 1em; overflow: hidden; + background: + repeating-conic-gradient(from 0deg at 50% 115%, rgba(255, 255, 255, .035) 0 6deg, transparent 6deg 14deg), + radial-gradient(80% 60% at 50% 110%, rgba(255, 210, 63, .18), transparent 70%); +} +.hs .toasts, .host-root .toasts { font-size: min(1.25cqw, 2.222cqh); } +.hs-top { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1em; min-height: 3em; } +.brand { font-weight: 700; font-size: 1.6em; transform: rotate(-2deg); white-space: nowrap; } +.b-fup { color: var(--pink); } .b-og { color: var(--cyan); font-size: .6em; } .b-fakta { color: var(--yellow); } +.rt { text-align: center; display: flex; flex-direction: column; align-items: center; } +.rt-name { font-weight: 700; font-size: 1.5em; background: var(--yellow); color: var(--navy); padding: .1em .8em; border-radius: 999px; transform: rotate(-1.5deg); white-space: nowrap; } +.rt-q { color: var(--muted); font-size: 1em; margin-top: .25em; } +.joininfo { justify-self: end; text-align: right; font-size: 1.1em; color: var(--muted); } +.joininfo b { color: #fff; letter-spacing: .12em; background: var(--card); padding: .1em .4em; border-radius: .3em; margin-left: .2em; } +.audn { display: block; font-size: .85em; margin-top: .2em; } +.hs-main { position: relative; min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1.2em; } +.hs-foot { display: flex; align-items: flex-end; justify-content: space-between; gap: 1em; min-height: 2.6em; } +.pchips { display: flex; flex-wrap: wrap; gap: .6em; } +.pchip { + display: inline-flex; align-items: center; gap: .45em; font-size: 1.25em; font-weight: 600; + padding: .3em .9em .3em .45em; border-radius: 999px; border: .12em solid rgba(255, 255, 255, .25); + background: rgba(0, 0, 0, .25); opacity: .55; transition: all .35s; +} +.pchip.done { opacity: 1; border-color: var(--pc); background: color-mix(in srgb, var(--pc) 45%, transparent); box-shadow: 0 0 1.2em color-mix(in srgb, var(--pc) 70%, transparent); animation: pop .4s cubic-bezier(.3, 1.6, .5, 1); } +.pchip.off { border-style: dashed; } +.pchip .ck { display: inline-block; width: .35em; height: .7em; margin: 0 .15em -.05em .1em; border: solid #fff; border-width: 0 .14em .14em 0; transform: rotate(45deg) translateY(-.15em); } +.ctl { display: flex; gap: .6em; flex: none; } +.hbtn { + font: 600 1em/1 var(--font); color: #fff; background: rgba(255, 255, 255, .12); border: .1em solid var(--card-b); + padding: .55em 1em; border-radius: 999px; cursor: pointer; opacity: .75; +} +.hbtn:hover { opacity: 1; background: rgba(255, 255, 255, .22); } +.hbtn.ghost { background: transparent; } +.hbtn:disabled { opacity: .3; } +.hs .btn { font-size: 1.4em; min-height: 0; padding: .6em 1.4em; border-radius: .7em; box-shadow: 0 .25em 0 var(--green-d), 0 .4em 1em rgba(0, 0, 0, .35); } +.hs .btn-xl { width: auto; font-size: 1.8em; } + +.hs-timer { position: absolute; top: 0; right: 0; width: 7.5em; } +.hs-timer.small { width: 5em; } +.hs-timer .ring .num { font-size: 2.6em; } +.hs-timer.small .ring .num { font-size: 1.7em; } + +/* lobby */ +.hs-lobby .hs-main { flex-direction: row; align-items: stretch; gap: 2.5em; } +.lobby-join { flex: 0 0 38%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1.2em; text-align: center; } +.lj-text p { margin: 0; } +.lj-go { font-size: 1.5em; color: var(--muted); } +.lj-url { font-size: 2.3em; font-weight: 700; color: var(--cyan); margin: .05em 0 .1em !important; white-space: nowrap; } +.ctiles { display: flex; gap: .35em; justify-content: center; margin-top: .4em; } +.ctile { + display: grid; place-items: center; width: 1.25em; height: 1.45em; font-size: 4.6em; font-weight: 700; line-height: 1; + color: var(--navy); background: var(--yellow); border-radius: .18em; + box-shadow: 0 .09em 0 var(--yellow-d), 0 .2em .4em rgba(0, 0, 0, .35); + animation: tile-in .6s cubic-bezier(.3, 1.6, .5, 1) both; animation-delay: calc(var(--i) * .1s); + transform: rotate(calc((var(--i) - 1.5) * 2deg)); +} +@keyframes tile-in { from { transform: translateY(-1em) rotate(-20deg); opacity: 0; } } +.qr { display: flex; flex-direction: column; align-items: center; gap: .4em; color: var(--muted); } +.qr img { width: 10em; height: 10em; background: #fff; padding: .5em; border-radius: .6em; box-shadow: 0 .5em 1.5em rgba(0, 0, 0, .4); } +.lobby-players { flex: 1; display: flex; flex-direction: column; gap: 1em; min-width: 0; justify-content: center; } +.lobby-players h2 { margin: 0; font-size: 2em; } +.lobby-players h2 span { color: var(--muted); font-weight: 500; } +.pgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: .9em; } +.pcard { + position: relative; display: flex; flex-direction: column; align-items: center; gap: .4em; padding: 1em .5em .9em; + border-radius: 1em; background: color-mix(in srgb, var(--pc) 22%, rgba(0, 0, 0, .25)); border: .15em solid var(--pc); + animation: pop .45s cubic-bezier(.3, 1.6, .5, 1); min-width: 0; +} +.pcard.empty { --pc: rgba(255, 255, 255, .2); border-style: dashed; background: transparent; animation: none; opacity: .6; } +.pcard.empty .av { background: transparent; border: .1em dashed rgba(255, 255, 255, .3); color: rgba(255, 255, 255, .35); box-shadow: none; } +.pcard.off { opacity: .5; } +.av { + display: grid; place-items: center; width: 2.4em; height: 2.4em; border-radius: 50%; flex: none; + background: var(--pc); color: #fff; font-weight: 700; font-size: 1.6em; text-shadow: 0 .06em 0 rgba(0, 0, 0, .3); + box-shadow: 0 0 0 .12em rgba(255, 255, 255, .25); +} +.pname { font-size: 1.4em; font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.vip { position: absolute; top: -.6em; left: 50%; transform: translateX(-50%) rotate(-4deg); background: var(--yellow); color: var(--navy); font-weight: 700; font-size: .9em; padding: .05em .6em; border-radius: .4em; } +.offl { font-size: .9em; color: var(--muted); } +.kick { + position: absolute; top: .3em; right: .3em; width: 1.5em; height: 1.5em; border-radius: 50%; border: 0; + background: rgba(0, 0, 0, .3); color: rgba(255, 255, 255, .6); font-size: 1em; line-height: 1; cursor: pointer; opacity: .35; +} +.pcard:hover .kick, .kick:focus-visible { opacity: 1; } +.kick:hover { background: var(--red); color: #fff; } +.lobby-start { display: flex; align-items: center; gap: 1.2em; margin-top: .4em; } +.ls-hint { color: var(--muted); font-size: 1.2em; line-height: 1.35; } +.ls-hint b { color: #fff; } + +/* kategori */ +.cat-banner { text-align: center; } +.cb-round { font-size: 3.6em; font-weight: 700; color: var(--yellow); text-shadow: 0 .08em 0 rgba(0, 0, 0, .35); animation: pop .6s cubic-bezier(.3, 1.6, .5, 1); line-height: 1.05; } +.cb-q { font-size: 1.4em; color: var(--muted); margin-top: .2em; } +.cat-who { font-size: 2em; display: flex; align-items: center; gap: .4em; } +.chip.xl { font-size: 1em; padding: .2em .8em .2em .3em; } +.cat-who > span { color: var(--muted); } +.cat-opts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1em; width: 100%; max-width: 70em; } +.cat-opt { + display: grid; place-items: center; text-align: center; min-height: 5em; padding: .6em; border-radius: .7em; + font-size: 1.7em; font-weight: 700; color: var(--navy); background: var(--c); box-shadow: 0 .2em 0 var(--cd); + animation: tile-in .5s cubic-bezier(.3, 1.6, .5, 1) both; animation-delay: calc(var(--i) * .08s + .2s); +} + +/* spørgsmål */ +.qbox { text-align: center; max-width: 52em; width: 100%; } +.qbox .qcat { font-size: 1.3em; padding: .2em .8em; border-radius: 999px; background: rgba(45, 226, 230, .15); border: .08em solid var(--cyan); margin-bottom: .5em; } +.qbox .qprompt { font-size: 3.4em; line-height: 1.18; font-weight: 700; text-wrap: balance; text-shadow: 0 .05em 0 rgba(0, 0, 0, .3); } +.qbox.lie { animation: pop .5s cubic-bezier(.3, 1.6, .5, 1); padding: 0 6em; max-width: none; } +.qbox.small .qprompt { font-size: 2.2em; } +.qbox.small { padding: 0 7em; max-width: none; } +.instr { font-size: 1.6em; color: var(--muted); margin: 0; } +.instr b { color: var(--yellow); } + +/* gæt */ +.ans-grid { display: grid; gap: .9em; width: 100%; max-width: 72em; } +.ans-grid.cols2 { grid-template-columns: repeat(2, 1fr); } +.ans-grid.cols3 { grid-template-columns: repeat(3, 1fr); } +.ans-tile { + display: grid; place-items: center; text-align: center; min-height: 2.6em; padding: .35em .6em; border-radius: .55em; + font-size: 1.9em; font-weight: 700; color: var(--navy); background: var(--c); box-shadow: 0 .14em 0 var(--cd), 0 .3em .6em rgba(0, 0, 0, .25); + overflow-wrap: anywhere; animation: tile-in .45s cubic-bezier(.3, 1.6, .5, 1) both; animation-delay: calc(var(--i) * .07s); +} + +/* reveal */ +.hs-reveal .hs-main { justify-content: flex-start; gap: 1.4em; padding-top: .4em; } +.rv-stage { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1em; width: 100%; position: relative; } +.rv-ans { + font-size: 4em; font-weight: 700; color: var(--navy); background: #fff; padding: .25em .9em; border-radius: .35em; + box-shadow: 0 .12em 0 #c9c1e6, 0 .3em .8em rgba(0, 0, 0, .4); animation: tile-in .55s cubic-bezier(.3, 1.6, .5, 1); + max-width: 90%; text-align: center; overflow-wrap: anywhere; +} +.is-truth .rv-ans { box-shadow: 0 .12em 0 var(--green-d), 0 0 1.5em rgba(61, 220, 132, .6); } +.rv-who { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: .6em; font-size: 1.5em; animation: rise .4s ease-out; } +.rv-who .lbl { color: var(--muted); } +.rv-who .chip.pop { animation: pop .4s cubic-bezier(.3, 1.6, .5, 1) both; } +.rv-who .audv { color: var(--cyan); font-weight: 600; } +.rv-who .none { color: var(--muted); font-style: italic; } +@keyframes rise { from { transform: translateY(.6em); opacity: 0; } } +.rv-res { display: flex; flex-direction: column; align-items: center; gap: .5em; margin-top: .6em; } +.rv-res .stamp { + font-size: 4.6em; font-weight: 700; letter-spacing: .08em; color: var(--red); border: .1em solid var(--red); border-radius: .2em; + padding: 0 .35em; line-height: 1.15; transform: rotate(-8deg); background: rgba(255, 71, 87, .1); + text-shadow: 0 0 .3em rgba(255, 71, 87, .5); animation: stamp .35s cubic-bezier(.2, 1.4, .5, 1) both; +} +.rv-res.house .stamp { color: var(--orange); border-color: var(--orange); background: rgba(255, 140, 66, .12); font-size: 3.6em; } +@keyframes stamp { from { transform: scale(3) rotate(-20deg); opacity: 0; } } +.rv-stage:has(.rv-res) .rv-who { opacity: .8; } +.rv-res .by { font-size: 1.8em; font-weight: 600; animation: rise .4s .25s both; } +.rv-res .pts { display: flex; flex-wrap: wrap; justify-content: center; gap: .8em; font-size: 1.5em; animation: rise .4s .4s both; } +.rv-res .pt { display: inline-flex; align-items: center; gap: .3em; } +.rv-res .pt b { color: var(--green); } +.rv-res .likes { font-size: 1.3em; color: var(--pink); font-weight: 700; animation: rise .4s .5s both; } +.truth-banner { + font-size: 4em; font-weight: 700; letter-spacing: .1em; color: var(--navy); padding: .05em .7em; border-radius: .25em; + background: linear-gradient(90deg, var(--green), var(--yellow), var(--green)); background-size: 200% 100%; + box-shadow: 0 0 1.4em rgba(255, 210, 63, .6); animation: pop .5s cubic-bezier(.3, 1.6, .5, 1) both, shimmer 2s linear infinite; +} +@keyframes shimmer { to { background-position: -200% 0; } } +.rv-dots { display: flex; gap: .5em; } +.rv-dots i { width: .9em; height: .9em; border-radius: 50%; background: var(--faint); } +.rv-dots i.past { background: var(--muted); } +.rv-dots i.now { background: var(--yellow); transform: scale(1.3); } +.rv-dots i.t { outline: .12em solid var(--green); } +.rv-sum { width: 100%; max-width: 44em; flex: 1; display: flex; flex-direction: column; justify-content: center; } +.rv-sum h2 { text-align: center; margin: 0 0 .5em; font-size: 2em; } +.rv-sum ol, .board { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .45em; } +.rv-sum li { display: flex; align-items: center; gap: .7em; font-size: 1.4em; background: var(--card); padding: .3em .7em; border-radius: .6em; animation: rise .35s both; animation-delay: calc(var(--i) * .06s); } +.rv-sum li .av { font-size: .8em; } +.rv-sum .n { flex: 1; font-weight: 600; } +.rv-sum .d { color: var(--muted); min-width: 4em; text-align: right; } +.rv-sum .d.plus { color: var(--green); font-weight: 700; } +.rv-sum li b { min-width: 4.5em; text-align: right; } + +/* stilling */ +.hs-scores .hs-main { justify-content: flex-start; padding-top: .5em; } +.sc-title { margin: 0; font-size: 3em; color: var(--yellow); text-shadow: 0 .08em 0 rgba(0, 0, 0, .35); animation: pop .5s cubic-bezier(.3, 1.6, .5, 1); } +.board { width: 100%; max-width: 60em; } +.board li { + display: grid; grid-template-columns: 1.6em auto 9em 1fr 4.5em 5em; align-items: center; gap: .7em; font-size: 1.5em; + padding: .35em .8em; border-radius: .7em; background: var(--card); border-left: .3em solid var(--pc); + animation: slide-in .5s cubic-bezier(.2, 1.2, .4, 1) both; animation-delay: calc(var(--i) * .12s); +} +.board li:first-child { background: color-mix(in srgb, var(--yellow) 18%, var(--card)); } +.board .rk { font-weight: 700; color: var(--muted); text-align: center; } +.board .av { font-size: .7em; } +.board .n { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.board .bar { height: .5em; background: rgba(255, 255, 255, .08); border-radius: .25em; overflow: hidden; } +.board .bar i { display: block; height: 100%; width: var(--w); background: var(--pc); border-radius: .25em; animation: grow 1.2s cubic-bezier(.2, .8, .3, 1) both; animation-delay: calc(var(--i) * .12s + .3s); } +.board .d { color: var(--green); font-weight: 700; text-align: right; animation: pop .4s both; animation-delay: calc(var(--i) * .12s + .5s); } +.board .sc { text-align: right; font-variant-numeric: tabular-nums; } +@keyframes slide-in { from { transform: translateX(-2em); opacity: 0; } } +@keyframes grow { from { width: 0; } } + +/* finale */ +.hs-final .hs-main { display: grid; grid-template-columns: 1.4fr 1fr; grid-template-rows: auto 1fr; gap: 1em 2.5em; align-items: end; justify-items: center; } +.fin-title { grid-column: 1 / -1; margin: 0; font-size: 3.4em; color: var(--yellow); text-shadow: 0 .08em 0 rgba(0, 0, 0, .35); animation: pop .6s cubic-bezier(.3, 1.6, .5, 1); } +.podium { display: flex; align-items: flex-end; justify-content: center; gap: 1em; width: 100%; height: 100%; } +.pod { flex: 1; max-width: 12em; display: flex; flex-direction: column; align-items: center; gap: .3em; animation: rise .7s both; } +.pod.second { animation-delay: .5s; } .pod.third { animation-delay: .2s; } .pod.first { animation-delay: .9s; } +.pod .av { font-size: 1.8em; } +.pod.first .av { font-size: 2.3em; box-shadow: 0 0 0 .1em var(--yellow), 0 0 1em rgba(255, 210, 63, .6); } +.pn { font-size: 1.5em; font-weight: 700; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ps { color: var(--muted); font-size: 1.2em; } +.crown { width: 4em; height: auto; fill: var(--yellow); filter: drop-shadow(0 .2em .4em rgba(255, 210, 63, .5)); animation: wiggle 1.4s ease-in-out infinite; } +.block { width: 100%; border-radius: .6em .6em 0 0; display: grid; place-items: start center; padding-top: .3em; background: linear-gradient(var(--pc), color-mix(in srgb, var(--pc) 40%, #000)); } +.block span { font-size: 3em; font-weight: 700; text-shadow: 0 .06em 0 rgba(0, 0, 0, .35); } +.pod.first .block { height: 9.5em; } .pod.second .block { height: 6.5em; } .pod.third .block { height: 4.5em; } +.pod.empty { visibility: hidden; } +.fin-side { align-self: stretch; width: 100%; display: flex; flex-direction: column; justify-content: center; gap: .7em; padding-bottom: .6em; } +.fin-side h2 { margin: 0; font-size: 2em; } +.awards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .6em; } +.awards li { display: flex; flex-wrap: wrap; align-items: center; gap: .2em .6em; font-size: 1.2em; background: var(--card); border: .08em solid var(--card-b); padding: .4em .8em; border-radius: .7em; animation: rise .5s both; animation-delay: calc(1.2s + var(--i) * .2s); } +.awards .at { font-weight: 700; color: var(--yellow); width: 100%; } +.awards .ad { color: var(--muted); } +.rest { margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: .3em 1.2em; font-size: 1.2em; color: var(--muted); } +.rest .rk { margin-right: .3em; } +.rest b { margin-left: .4em; color: #fff; font-weight: 600; } +.fin-side .btn { align-self: flex-start; margin-top: .3em; } + +/* host_new */ +.landing { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px; text-align: center; padding: 24px 16px; } +.landing h1 { margin: 0; font-size: clamp(2.2rem, 7vw, 5.5rem); line-height: 1; letter-spacing: .01em; text-shadow: 0 6px 0 rgba(0, 0, 0, .35); } +.landing .sub { font-size: clamp(1.6rem, 5vw, 3.6rem); font-weight: 700; transform: rotate(-3deg); } +.landing .sub .b-og { font-size: .55em; } +.landing .btn { font-size: clamp(1.3rem, 3vw, 2rem); padding: .7em 1.6em; } +.landing .who { color: var(--muted); } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } +} +.page-host { overflow: hidden; } + +/* forklaringen under sandheden */ +.rv-res.truth .note { margin: 18px auto 0; max-width: 1100px; font-size: clamp(18px, 1.6vw, 30px); line-height: 1.35; opacity: .85; font-weight: 500; } diff --git a/party/static/party/play.js b/party/static/party/play.js new file mode 100644 index 0000000..e9d4b5f --- /dev/null +++ b/party/static/party/play.js @@ -0,0 +1,442 @@ +/* Fup og Fakta – telefon. Eksporterer window.WPPPlay = {renderPlay, renderJoin, renderWaiting, mount}. */ +(function () { + 'use strict'; + var WPP = window.WPP, esc = WPP.esc; + + function ui(root) { + return root._ui || (root._ui = { busy: false, busyTimer: null, drafts: {}, lastPhase: null }); + } + + /* ---------- travlhed (forhindrer dobbelt-tryk) ---------- */ + function setBusy(root, on) { + var u = ui(root); + u.busy = !!on; + root.classList.toggle('busy', u.busy); + clearTimeout(u.busyTimer); + if (on) u.busyTimer = setTimeout(function () { setBusy(root, false); }, 6000); + } + function act(root, msg) { + var ctx = root._ctx || {}; + if (ui(root).busy) return; + if (!ctx.send) return; + var ok = ctx.send(msg); + if (ok !== false && !ctx.harness) setBusy(root, true); + } + + /* ---------- hændelser (delegeret, bindes én gang pr. rod) ---------- */ + function bind(root) { + if (root._bound) return; + root._bound = true; + root.addEventListener('click', function (e) { + var b = e.target.closest('[data-act]'); + if (!b || !root.contains(b) || b.disabled) return; + e.preventDefault(); + var a = b.getAttribute('data-act'), arg = b.getAttribute('data-arg'); + if (a === 'choose_category') act(root, { action: a, category_id: isNaN(Number(arg)) ? arg : Number(arg) }); + else if (a === 'guess' || a === 'like') { + if (a === 'like') { var c = root._ctx || {}; if (c.send) c.send({ action: 'like', answer_id: arg }); b.classList.toggle('on'); } + else act(root, { action: a, answer_id: arg }); + } + else if (a === 'lie_for_me') act(root, { action: a }); + else if (a === 'start' || a === 'play_again') act(root, { action: a }); + else if (a === 'forget') { if (root._ctx && root._ctx.onForget) root._ctx.onForget(); } + }); + root.addEventListener('input', function (e) { + var t = e.target; + if (t.id === 'wpp-lie') ui(root).drafts[t.getAttribute('data-q') || ''] = t.value; + if (t.name === 'code') { + var v = t.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 4); + if (v !== t.value) t.value = v; + } + }); + root.addEventListener('submit', function (e) { + var f = e.target; + e.preventDefault(); + if (f.classList.contains('lie-form')) { + var inp = f.querySelector('#wpp-lie'); + var text = (inp.value || '').trim(); + if (!text) { WPP.toast(root, 'Skriv lige en løgn først.'); inp.focus(); return; } + act(root, { action: 'submit_lie', text: text.slice(0, 40) }); + } else if (f.classList.contains('join-form')) { + var code = (f.code.value || '').toUpperCase().trim(); + var name = (f.name.value || '').trim(); + if (code.length !== 4) { WPP.toast(root, 'Koden er 4 bogstaver.'); f.code.focus(); return; } + if (!name) { WPP.toast(root, 'Skriv dit navn.'); f.name.focus(); return; } + if (root._ctx && root._ctx.onJoin) { + if (!root._ctx.harness) setBusy(root, true); + root._ctx.onJoin(code, name.slice(0, 12)); + } + } + }); + } + + /* ---------- skærme ---------- */ + function topBar(state, me, players) { + var p = me && players[me.id]; + var who; + if (p) who = WPP.chip(p, 'me'); + else if (me && me.role === 'audience') who = 'Publikum'; + else who = ''; + return '
    ' + + '' + esc(state.code || '') + '' + + '' + who + '' + + '' + WPP.timerHtml(state, 'pill') + '' + + '
    '; + } + function questionCard(state, k) { + var q = state.question || {}; + return '
    ' + + (q.category ? '
    ' + esc(q.category) + '
    ' : '') + + '
    ' + WPP.promptHtml(q.prompt || '') + '
    '; + } + function big(title, sub, k, cls) { + return '

    ' + title + '

    ' + + (sub ? '

    ' + sub + '

    ' : '') + '
    '; + } + + function screenLobby(state, me, players) { + var list = state.players || []; + var out = ''; + if (me.role === 'audience') { + out += big('Du er publikum!', 'Spillet er fyldt op eller allerede i gang. Du kan stadig gætte med og dele hjerter ud.', 'aud', 'pop'); + } else { + var p = players[me.id]; + out += '
    ' + + esc(((p && p.name) || '?').charAt(0).toUpperCase()) + '

    Du er med!

    ' + + '

    ' + (p ? esc(p.name) : '') + '

    '; + } + out += '

    Spillere (' + list.length + '/' + (state.max_players || 8) + ')

    ' + + list.map(function (q) { return WPP.chip(q, q.connected === false ? 'off' : ''); }).join('') + + '
    ' + (state.audience_count ? '

    + ' + state.audience_count + ' publikum

    ' : '') + '
    '; + var min = state.min_players || 2; + if (me.vip) { + var enough = list.length >= min; + out += '
    ' + + '' + + (enough ? '

    Du er VIP, så du bestemmer, hvornår vi går i gang.

    ' + : '

    Der skal være mindst ' + min + ' spillere. Få flere med!

    ') + '
    '; + } else { + var vip = list.filter(function (q) { return q.vip; })[0]; + out += '

    ' + (vip ? esc(vip.name) + ' starter spillet, når alle er klar.' : 'Venter på at spillet starter…') + '

    '; + } + return out; + } + + function screenCategory(state, me, players) { + var cat = state.category || {}; + var opts = cat.options || []; + var chooser = players[cat.chooser_id]; + var head = '
    ' + WPP.roundTitle(state.round) + '
    '; + if (me.is_chooser) { + return head + '

    Vælg en kategori!

    ' + + '
    ' + opts.map(function (o, i) { + return ''; + }).join('') + '
    '; + } + return head + big((chooser ? esc(chooser.name) : 'Nogen') + ' vælger kategori…', 'Hold dig klar til at lyve!', 'wait', 'pulse') + + '
    ' + opts.map(function (o, i) { + return '
    ' + esc(o.name) + '
    '; + }).join('') + '
    '; + } + + function screenLie(state, me, players, root) { + var q = state.question || {}; + var qk = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + (q.prompt || ''); + var out = questionCard(state, 'q:' + qk); + if (me.role === 'audience') return out + big('Spillerne finder på løgne…', 'Snart skal du gætte, hvad der er sandt.', 'aud', 'pulse'); + if (me.lie) { + out += big('Godt løjet!', 'Venter på de andre…', 'done', 'pop') + + '

    Din løgn: ' + esc(me.lie) + '

    '; + return out + progress(state); + } + out += '
    ' + + '' + + '' + + '' + + '' + + '
    '; + return out + progress(state); + } + function progress(state) { + var ps = (state.players || []); + var done = ps.filter(function (p) { return p.done; }).length; + return '

    ' + done + ' af ' + ps.length + ' er færdige

    '; + } + + function heartList(state, me, excludeIds, k) { + var ans = (state.answers || []).filter(function (a) { + if (excludeIds.indexOf(a.id) >= 0) return false; + if (me.own_answer_id && a.id === me.own_answer_id) return false; + if (state.reveal && (state.reveal.steps || []).some(function (st) { return st.answer_id === a.id && st.is_truth; })) return false; + if (me.lie && String(a.text).trim().toLowerCase() === String(me.lie).trim().toLowerCase()) return false; + return true; + }); + if (!ans.length) return ''; + var likes = me.likes || []; + return '

    Giv et hjerte til de bedste løgne

    ' + + ans.map(function (a) { + var on = likes.indexOf(a.id) >= 0; + return '
    ' + esc(a.text) + '
    '; + }).join('') + '
    '; + } + + function screenGuess(state, me) { + var q = state.question || {}; + var out = questionCard(state, 'gq:' + (q.prompt || '')); + var ans = state.answers || []; + if (me.guess) { + var picked = ans.filter(function (a) { return a.id === me.guess; })[0]; + out += '

    Du gættede på

    ' + esc(picked ? picked.text : '…') + '
    '; + out += heartList(state, me, [me.guess], 'hearts'); + out += '

    Venter på de andre…

    '; + return out; + } + out += '

    Hvad er sandheden?

    '; + out += '
    ' + + ans.map(function (a, i) { + return ''; + }).join('') + '
    '; + return out; + } + + function screenReveal(state, me) { + var out = big('Kig op på skærmen!', '', 'look', 'look'); + if (me.role === 'player' && me.last_delta != null) { + out += '
    ' + + (me.last_delta > 0 ? '' + WPP.signed(me.last_delta) + 'point til dig!' + : '0point denne gang') + '
    '; + } + out += heartList(state, me, [], 'hearts'); + return out; + } + + function rankOf(state, id) { + var ps = (state.players || []).slice().sort(function (a, b) { return (b.score || 0) - (a.score || 0); }); + for (var i = 0; i < ps.length; i++) { + if (ps[i].id === id) { + var place = 1; + while (place - 1 < i && (ps[place - 1].score || 0) > (ps[i].score || 0)) place++; + return { place: place, of: ps.length, p: ps[i] }; + } + } + return null; + } + + function screenScores(state, me) { + var out = '
    Stillingen
    '; + var r = me.role === 'player' && rankOf(state, me.id); + if (r) { + out += '
    ' + r.place + '.

    plads af ' + r.of + '

    ' + + '
    ' + WPP.fmt(r.p.score) + ' point
    ' + + (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); }); + out += '
      ' + ps.map(function (p) { + return '' + + esc(p.name) + '' + WPP.fmt(p.score) + ''; + }).join('') + '
    '; + out += '

    Kig op på skærmen!

    '; + return out; + } + + function screenFinal(state, me, players) { + var fin = state.final || {}; + var rank = fin.ranking || []; + var mine = rank.filter(function (r) { return r.player_id === me.id; })[0]; + var out = ''; + if (me.role === 'player' && mine) { + var title = mine.place === 1 ? 'Du vandt!' : 'Du blev nr. ' + mine.place + '!'; + out += '
    ' + + '
    ' + mine.place + '.

    ' + title + '

    ' + WPP.fmt(mine.score) + ' point
    '; + } else { + var w = rank[0] && players[rank[0].player_id]; + out += big('Spillet er slut!', w ? esc(w.name) + ' vandt med ' + WPP.fmt(rank[0].score) + ' point.' : '', 'over', 'pop'); + } + var aw = (fin.awards || []).filter(function (a) { return a.player_id === me.id; }); + if (aw.length) { + out += '

    Dine priser

    ' + aw.map(function (a) { + return '
    ' + esc(a.title) + '' + (a.detail ? '' + esc(a.detail) + '' : '') + '
    '; + }).join('') + '
    '; + } + if (me.vip) { + out += '
    '; + } else { + var vip = (state.players || []).filter(function (q) { return q.vip; })[0]; + out += '

    ' + (vip ? 'Venter på at ' + esc(vip.name) + ' starter et nyt spil…' : 'Tak for spillet!') + '

    '; + } + return out; + } + + /* ---------- offentlige render-funktioner ---------- */ + function renderPlay(root, state, ctx) { + ctx = ctx || {}; + root._ctx = ctx; + bind(root); + var sh = WPP.shell(root); + var u = ui(root); + if (!ctx.keepBusy) setBusy(root, false); + state = state || {}; + var me = state.me || { role: 'audience' }; + var players = WPP.playerMap(state); + var phase = state.phase || 'lobby'; + var body; + switch (phase) { + case 'lobby': body = screenLobby(state, me, players); break; + case 'category': body = screenCategory(state, me, players); break; + case 'lie': body = screenLie(state, me, players, root); break; + case 'guess': body = screenGuess(state, me, players); break; + case 'reveal': body = screenReveal(state, me, players); break; + case 'scores': body = screenScores(state, me, players); break; + case 'final': body = screenFinal(state, me, players); break; + default: body = big('Øjeblik…', '', 'unk'); + } + sh.screen.className = 'screen ph ph-' + phase; + WPP.patch(sh.screen, topBar(state, me, players) + '
    ' + body + '
    '); + + /* gendan kladde i løgne-feltet, hvis formularen er ny */ + var inp = sh.screen.querySelector('#wpp-lie'); + if (inp && !inp.value) { + var d = u.drafts[inp.getAttribute('data-q') || '']; + if (d) inp.value = d; + } + if (u.lastPhase !== phase) { + if (u.lastPhase && !ctx.harness) { + try { window.scrollTo(0, 0); } catch (e) { /* */ } + var needsAct = (phase === 'lie' && me.role === 'player') || phase === 'guess' || (phase === 'category' && me.is_chooser); + if (needsAct && navigator.vibrate) { try { navigator.vibrate(60); } catch (e) { /* */ } } + } + u.lastPhase = phase; + } + } + + function renderJoin(root, ctx, opts) { + ctx = ctx || {}; opts = opts || {}; + root._ctx = ctx; + bind(root); + setBusy(root, false); + var sh = WPP.shell(root); + sh.screen.className = 'screen ph ph-join'; + var code = opts.code || ''; + var name = opts.name != null ? opts.name : (WPP.ls.get('wpp:name') || ''); + WPP.patch(sh.screen, + '' + + '
    ' + + '' + + '' + + '' + + '

    Koden står på storskærmen.

    ' + + (opts.error ? '

    ' + esc(opts.error) + '

    ' : '')); + } + + function renderWaiting(root, text) { + var sh = WPP.shell(root); + sh.screen.className = 'screen ph ph-wait'; + WPP.patch(sh.screen, '

    ' + esc(text || 'Forbinder…') + '

    '); + } + + function setSuggestion(root, text) { + setBusy(root, false); + var inp = root.querySelector('#wpp-lie'); + if (!inp) return; + inp.value = String(text || '').slice(0, 40); + ui(root).drafts[inp.getAttribute('data-q') || ''] = inp.value; + inp.classList.remove('flash'); void inp.offsetWidth; inp.classList.add('flash'); + } + + /* ---------- live-forbindelse ---------- */ + function mount(root) { + var qs = new URLSearchParams(location.search); + var clean = function (s) { return String(s || '').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 4); }; + var prefill = clean(root.getAttribute('data-prefill') || qs.get('code')); + var code = null, conn = null, token = null, pendingName = null, joinedRole = null; + var ctx = { + send: function (m) { return conn ? conn.send(m) : false; }, + onJoin: function (c, name) { + WPP.ls.set('wpp:name', name); + pendingName = name; + if (conn && code === c) { + if (conn.send({ action: 'join', name: name })) return; + return; /* sendes ved onOpen */ + } + start(c, false); + }, + onForget: function () { forget(); showJoin(code); } + }; + + function forget() { + if (code) WPP.ls.del('wpp:' + code); + if (WPP.ls.get('wpp:last') === code) WPP.ls.del('wpp:last'); + token = null; + } + function showJoin(c, err) { + if (conn && !token) { /* behold forbindelse til samme kode */ } + renderJoin(root, ctx, { code: c || prefill || '', error: err }); + } + function start(c, resume) { + if (conn) { conn.close(); conn = null; } + code = c; + token = resume ? WPP.ls.get('wpp:' + c) : null; + conn = WPP.connect('/ws/play/' + c + '/', { + root: root, + onOpen: function () { + if (token) conn.send({ action: 'resume', token: token }); + else if (pendingName) conn.send({ action: 'join', name: pendingName }); + }, + onMessage: onMessage + }); + } + function onMessage(msg) { + switch (msg.type) { + case 'state': + if (!token) return; + renderPlay(root, msg.state, ctx); + break; + case 'joined': + token = msg.token; joinedRole = msg.role; pendingName = null; + WPP.ls.set('wpp:' + code, token); + WPP.ls.set('wpp:last', code); + renderWaiting(root, joinedRole === 'audience' ? 'Du er publikum!' : 'Du er med!'); + break; + case 'suggestion': + setSuggestion(root, msg.text); + break; + case 'error': + setBusy(root, false); + if (msg.code === 'unknown_token' || msg.code === 'unknown_room') { + var c = code; + forget(); + if (conn) { conn.close(); conn = null; } + pendingName = null; + showJoin(c, msg.code === 'unknown_room' ? (msg.message || 'Rummet findes ikke.') : ''); + } else if (!token && (msg.code === 'name_taken' || msg.code === 'name_invalid')) { + pendingName = null; + WPP.toast(root, msg.message || 'Det navn kan ikke bruges.'); + } else { + WPP.toast(root, msg.message || 'Ups, noget gik galt.'); + } + break; + } + } + + var last = WPP.ls.get('wpp:last'); + if (last && WPP.ls.get('wpp:' + last) && (!prefill || prefill === last)) { + renderWaiting(root, 'Forbinder…'); + start(last, true); + } else { + showJoin(prefill); + } + return ctx; + } + + window.WPPPlay = { renderPlay: renderPlay, renderJoin: renderJoin, renderWaiting: renderWaiting, setSuggestion: setSuggestion, mount: mount }; + window.renderPlay = renderPlay; + + if (!window.WPP_HARNESS) { + var go = function () { var r = document.getElementById('play'); if (r) mount(r); }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', go); else go(); + } +})(); diff --git a/party/templates/party/base.html b/party/templates/party/base.html new file mode 100644 index 0000000..9a4b220 --- /dev/null +++ b/party/templates/party/base.html @@ -0,0 +1,21 @@ +{% load static %} + + + + + + + + + {% block title %}Fup og Fakta{% endblock %} + + + + + {% block head %}{% endblock %} + + +{% block content %}{% endblock %} +{% block scripts %}{% endblock %} + + diff --git a/party/templates/party/host.html b/party/templates/party/host.html new file mode 100644 index 0000000..c00a0ca --- /dev/null +++ b/party/templates/party/host.html @@ -0,0 +1,11 @@ +{% extends "party/base.html" %} +{% load static %} +{% block title %}Fup og Fakta – {{ code }}{% endblock %} +{% block body_class %}page-host{% endblock %} +{% block content %} +
    +{% endblock %} +{% block scripts %} + + +{% endblock %} diff --git a/party/templates/party/host_new.html b/party/templates/party/host_new.html new file mode 100644 index 0000000..0b09c13 --- /dev/null +++ b/party/templates/party/host_new.html @@ -0,0 +1,12 @@ +{% extends "party/base.html" %} +{% block title %}Weirsøe Party Protocol{% endblock %} +{% block content %} +
    +

    Weirsøe Party Protocol

    +
    Fup og Fakta
    +
    {% csrf_token %} + +
    + {% if username %}

    Logget ind som {{ username }}

    {% endif %} +
    +{% endblock %} diff --git a/party/templates/party/play.html b/party/templates/party/play.html new file mode 100644 index 0000000..549ea23 --- /dev/null +++ b/party/templates/party/play.html @@ -0,0 +1,13 @@ +{% extends "party/base.html" %} +{% load static %} +{% block title %}Fup og Fakta{% endblock %} +{% block body_class %}page-play{% endblock %} +{% block content %} +
    + +
    +{% endblock %} +{% block scripts %} + + +{% endblock %} diff --git a/party/tests/test_views.py b/party/tests/test_views.py index 0d51e03..bc60c9a 100644 --- a/party/tests/test_views.py +++ b/party/tests/test_views.py @@ -33,3 +33,4 @@ class ViewTests(TestCase): r = self.client.get("/qr/KLMT.svg") self.assertEqual(r["Content-Type"], "image/svg+xml") self.assertIn(b" diff --git a/party/views.py b/party/views.py index bbad132..9f7983d 100644 --- a/party/views.py +++ b/party/views.py @@ -1,3 +1,4 @@ +import io import re import segno @@ -56,9 +57,10 @@ def host(request, code): def qr(request, code): if not CODE_RE.match(code): raise Http404 - svg = segno.make(f"{settings.PUBLIC_URL}/?code={code.upper()}", error="m").svg_inline( - scale=10, border=2, dark="#1b1035", light="#ffffff") - return HttpResponse(svg, content_type="image/svg+xml") + buf = io.BytesIO() + segno.make(f"{settings.PUBLIC_URL}/?code={code.upper()}", error="m").save( + buf, kind="svg", scale=10, border=2, dark="#1b1035", light="#ffffff") + return HttpResponse(buf.getvalue(), content_type="image/svg+xml") @require_GET diff --git a/shoot.js b/shoot.js new file mode 100644 index 0000000..82737ef --- /dev/null +++ b/shoot.js @@ -0,0 +1,31 @@ +const puppeteer = require('/opt/isms/node_modules/puppeteer'); +const base = 'file:///root/wppui/dev.html'; +const WAIT = 2200; +const jobs = []; +const phases = ['join','lobby','category','lie','guess','reveal','scores','final']; +for (const p of phases) { + jobs.push({name:`all-${p}`, url:`${base}?phase=${p}`, w:2620, h:930}); + jobs.push({name:`host-${p}`, url:`${base}?phase=${p}&only=host`, w:1920, h:1080}); +} +for (const t of [500, 1700, 3200, 11200, 15200, 17500]) { + jobs.push({name:`host-reveal-t${t}`, url:`${base}?phase=reveal&only=host&t=${t - WAIT}`, w:1920, h:1080}); +} +jobs.push({name:`phones360-lie`, url:`${base}?phase=lie&only=phones&pw=360`, w:1300, h:930}); +jobs.push({name:`phones360-guess`, url:`${base}?phase=guess&only=phones&pw=360`, w:1300, h:930}); +jobs.push({name:`host720-lobby`, url:`${base}?phase=lobby&only=host`, w:1280, h:720}); +const only = process.argv[2]; +(async () => { + const browser = await puppeteer.launch({args:['--no-sandbox']}); + const page = await browser.newPage(); + page.on('console', m => { if (m.type()==='error' || m.type()==='warning') console.log('CONSOLE', m.type(), m.text()); }); + page.on('pageerror', e => console.log('PAGEERROR', e.message)); + for (const j of jobs) { + if (only && !j.name.includes(only)) continue; + await page.setViewport({width:j.w, height:j.h}); + await page.goto(j.url, {waitUntil:'load', timeout: 15000}).catch(e=>console.log('goto', e.message)); + await new Promise(r=>setTimeout(r, WAIT)); + await page.screenshot({path:`/root/wppui/shots/${j.name}.png`}); + console.log('shot', j.name); + } + await browser.close(); +})();