Spionen: rum, speaker, skærme og register
- party/games.py: Spionen i menuen (4-10 spillere, 10-15 min, runder 3/4/5,
regler til/fra), så intro og skift af spil i rummet virker.
- party/spion.py: SpionRoom med telefonens handlinger (start, submit_clue,
vote, spy_guess, react, play_again), speakerens replikker pr. begivenhed
og et "ti sekunder" før stikordenes deadline. Reglernes oplæsning er
BaseRoom's.
- Speakeren (spionen/narrator/lines.json og generic.json) siger aldrig
ordet eller hvem der er spion før resultatet: dommen spørger kun, svaret
kommer med gættet eller resultatet. Replikker med navne eller ordet har
generiske afløsere, så spillet kører med WPP_TTS_LIVE=off. Replikken med
ordet ved resultatet forvarmes ikke (et klip pr. ord); uden live-stemme
siges den generiske ("Spionen og ordet står nu på skærmen").
- spion.js: telefonens rollekort (skjult, til man trykker, og lukker igen
ved hver ny fase), stikord, afstemning med store knapper, spionens gæt og
resultatet; storskærmens tema, stikordene et ad gangen, snakken med
nedtælling, stemmerne og stemplet "SPION!"/"Uskyldig!" i dommens sidste
sekunder, gættet og resultatet. Under dommen viser telefonen kun
"Stemmerne er talt". host.js: et spil kan melde sine egne tidsfaser
(spring over).
Test (test_spion): helt spil over WebSocket med fire telefoner, og skift
fra Tegn og Gæt til Spionen i samme rum; hver besked til hver telefon
læses for ordet og spionlisten. Speakeren: ordet siges aldrig før
resultatet, og alt uden navne eller ordet er forvarmet. Replikkerne er med
i sprogtesten.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ from django.conf import settings
|
||||
from django.templatetags.static import static
|
||||
|
||||
from fupogfakta import engine as fof_engine
|
||||
from spionen import engine as spion_engine
|
||||
from tegnoggaet import engine as tegn_engine
|
||||
|
||||
|
||||
@@ -93,6 +94,22 @@ async def _create_tegn(host_username: str, public_url: str, opts: dict, code: st
|
||||
return create_tegn_room(host_username, public_url, code=code, show_rules=opts["rules"] == "1")
|
||||
|
||||
|
||||
# ── Spionen ──────────────────────────────────────────────────────────────────
|
||||
def _spion_settings() -> list[dict]:
|
||||
return [{"name": "rounds", "label": "Runder", "default": str(spion_engine.ROUNDS),
|
||||
"options": [{"value": str(n), "label": f"{n} runder"} for n in spion_engine.ROUND_CHOICES]},
|
||||
{"name": "rules", "label": "Reglerne", "default": "1", "options": [
|
||||
{"value": "1", "label": "Vis reglerne først"},
|
||||
{"value": "0", "label": "Spring dem over"},
|
||||
]}]
|
||||
|
||||
|
||||
async def _create_spion(host_username: str, public_url: str, opts: dict, code: str | None = None):
|
||||
from .spion import create_spion_room
|
||||
return create_spion_room(host_username, public_url, code=code, show_rules=opts["rules"] == "1",
|
||||
rounds=int(opts["rounds"]))
|
||||
|
||||
|
||||
GAMES: dict[str, Game] = {g.key: g for g in (
|
||||
Game(key="fof", name="Fup og Fakta", tagline="Find på løgne, der lyder sande, og find sandheden blandt dem.",
|
||||
min_players=fof_engine.MIN_PLAYERS, max_players=fof_engine.MAX_PLAYERS, minutes="20-30",
|
||||
@@ -108,6 +125,13 @@ GAMES: dict[str, Game] = {g.key: g for g in (
|
||||
"Den næste ser kun tegningen og gætter, hvad den forestiller.",
|
||||
"Til sidst ser vi, hvad ordene blev til. Giv hjerter til de bedste."),
|
||||
icon="party/games/tegn.svg", scripts=("party/tegn.js",), create=_create_tegn, settings=_tegn_settings),
|
||||
Game(key="spion", name="Spionen", tagline="Alle kender det hemmelige ord. Undtagen spionen.",
|
||||
min_players=spion_engine.MIN_PLAYERS, max_players=spion_engine.MAX_PLAYERS, minutes="10-15",
|
||||
steps=("Alle får det samme hemmelige ord. Spionen får kun temaet.",
|
||||
"Alle skriver ét stikord, der viser, at de kender ordet.",
|
||||
"Snak sammen, og stem på den, I tror er spionen.",
|
||||
"Bliver spionen fanget, kan den stadig vinde ved at gætte ordet."),
|
||||
icon="party/games/spion.svg", scripts=("party/spion.js",), create=_create_spion, settings=_spion_settings),
|
||||
)}
|
||||
|
||||
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
"""Rum til Spionen.
|
||||
|
||||
Deler forbindelser, timer, udsendelse og speakerens kø med de andre spil (`rooms.BaseRoom`). Speakeren siger
|
||||
aldrig ordet eller hvem der er spion, før dommen og resultatet viser det på storskærmen: replikkerne til dommen
|
||||
spørger kun ("er det spionen?"), svaret kommer med gættet eller resultatet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from spionen.engine import MAX_PLAYERS, MIN_PLAYERS, ROUND_CHOICES, RULES, Game
|
||||
|
||||
from . import rooms, tts
|
||||
from .narrator import Narrator, generic_texts, join_names
|
||||
from .themes import spy_themes
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NARRATOR_DIR = Path(__file__).resolve().parents[1] / "spionen" / "narrator"
|
||||
LINES = json.loads((NARRATOR_DIR / "lines.json").read_text(encoding="utf-8"))
|
||||
GENERIC = json.loads((NARRATOR_DIR / "generic.json").read_text(encoding="utf-8")) # se party.narrator
|
||||
HURRY_BEFORE = 10
|
||||
# Replikker uden navne og uden ordet: dem kan tts_warm lave på forhånd (resten har en generisk afløser).
|
||||
FIXED_LINES = ("lobby_welcome", "round_first", "round_last", "clue_hurry", "clue_all_done", "clues_intro",
|
||||
"clues_missing", "talk_start", "vote_start", "vote_all_done", "verdict_tie", "verdict_none",
|
||||
"result_guessed", "result_missed", "result_timeout", "result_free", "play_again")
|
||||
AWARD_LINES = {"liar": "award_liar", "sharp": "award_sharp", "suspected": "award_suspected"}
|
||||
|
||||
|
||||
class SpionRoom(rooms.BaseRoom):
|
||||
kind = "spion"
|
||||
|
||||
def __init__(self, code: str, game: Game, host_username: str):
|
||||
super().__init__(code, game, host_username)
|
||||
self.narrator = Narrator(lines=LINES, generic=GENERIC)
|
||||
self._last_join_line = 0.0
|
||||
self._lobby_waiting_said = False
|
||||
|
||||
# ── handlinger ───────────────────────────────────────────────────────────
|
||||
async def play_action(self, conn, member_id: str, action: str, content: dict, now: float):
|
||||
g = self.game
|
||||
if action == "start":
|
||||
g.start(member_id, now)
|
||||
elif action == "submit_clue":
|
||||
g.submit_clue(member_id, str(content.get("text", "")), now)
|
||||
elif action == "vote":
|
||||
g.vote(member_id, str(content["player_id"]), now)
|
||||
elif action == "spy_guess":
|
||||
g.spy_guess(member_id, str(content.get("text", "")), now)
|
||||
elif action == "react":
|
||||
reaction = g.react(member_id, str(content.get("emoji", "")), now)
|
||||
if reaction:
|
||||
await self.forward_reaction(reaction)
|
||||
elif action == "play_again":
|
||||
g.play_again(member_id, now)
|
||||
else:
|
||||
await conn.send_error("bad_request", "Ukendt handling.")
|
||||
|
||||
# ── speakeren ────────────────────────────────────────────────────────────
|
||||
def host_opened(self):
|
||||
if tts.enabled() and not self._welcomed and self.game.phase == "lobby" and not self.game.players:
|
||||
self._welcomed = True
|
||||
self.say(self.narrator.line("lobby_welcome"))
|
||||
|
||||
def handle_events(self):
|
||||
events = self.game.drain_events()
|
||||
if not tts.enabled():
|
||||
return
|
||||
skip_start = any(e["type"] == "play_again" for e in events)
|
||||
for ev in events:
|
||||
t = ev["type"]
|
||||
if t == "game_start" and skip_start:
|
||||
continue
|
||||
if t == "rules":
|
||||
asyncio.get_running_loop().create_task(self._narrate_rules())
|
||||
continue
|
||||
if t == "join":
|
||||
now = time.time()
|
||||
if now - self._last_join_line < 2.5: # otte på én gang skal ikke alle kommenteres
|
||||
continue
|
||||
self._last_join_line = now
|
||||
self._maybe_schedule_lobby_waiting()
|
||||
for text in self.lines_for(ev):
|
||||
self.say(text)
|
||||
if t == "round_start":
|
||||
self._hurry()
|
||||
|
||||
def lines_for(self, ev: dict) -> list[str]:
|
||||
t, line = ev["type"], self.narrator.line
|
||||
if t == "join" and ev["role"] == "player" and self.game.phase == "lobby":
|
||||
return [line("lobby_join", navn=ev["name"])]
|
||||
if t == "game_start":
|
||||
return [line("game_start", antal_spillere=ev["players"])]
|
||||
if t == "round_start":
|
||||
head = (line("round_first") if ev["round"] == 1 else line("round_last") if ev["last"]
|
||||
else line("round_next", runde=ev["round"]))
|
||||
return [head, line("theme", tema=ev["theme"])]
|
||||
if t == "clues":
|
||||
return [line("clues_intro")] + ([line("clues_missing")] if ev["missing"] else [])
|
||||
if t in ("clue_all_done", "vote_all_done", "play_again"):
|
||||
return [line(t)]
|
||||
if t == "talk":
|
||||
return [line("talk_start")]
|
||||
if t == "vote":
|
||||
return [line("vote_start")]
|
||||
if t == "verdict":
|
||||
if ev["no_votes"]:
|
||||
return [line("verdict_none")]
|
||||
return [line("verdict_tie")] if ev["tie"] else [line("verdict_accused", navn=ev["accused"])]
|
||||
if t == "guess":
|
||||
return [line("guess_start", navn=ev["name"])]
|
||||
if t == "result":
|
||||
out = []
|
||||
if ev["accused_innocent"]:
|
||||
out.append(line("result_innocent", navn=self._accused_name()))
|
||||
elif ev["caught"]:
|
||||
g = self.game
|
||||
out.append(line("result_guessed" if ev["guessed"] else "result_missed" if g.guess else "result_timeout"))
|
||||
else:
|
||||
out.append(line("result_free"))
|
||||
out.append(line("result_reveal", spioner=join_names(ev["spies"]), ord=ev["word"]))
|
||||
return out
|
||||
if t == "final":
|
||||
out = [line("winner", vinder=ev["winner"], point=ev["points"])]
|
||||
return out + [line(AWARD_LINES[a["kind"]], navn=a["name"]) for a in ev["awards"]]
|
||||
return []
|
||||
|
||||
def _accused_name(self) -> str:
|
||||
p = self.game.players.get(self.game.accused or "")
|
||||
return p.name if p else ""
|
||||
|
||||
def _hurry(self):
|
||||
g = self.game
|
||||
ident = g.phase_key()
|
||||
delay = (g.deadline or 0) - HURRY_BEFORE - time.time()
|
||||
if delay <= 1:
|
||||
return
|
||||
|
||||
def fire():
|
||||
if g.phase_key() == ident and not g._all_done():
|
||||
self.say(self.narrator.line("clue_hurry"))
|
||||
self._side_timers.append(asyncio.get_running_loop().call_later(delay, fire))
|
||||
self._side_timers = [h for h in self._side_timers if not h.cancelled()][-20:]
|
||||
|
||||
def _maybe_schedule_lobby_waiting(self):
|
||||
g = self.game
|
||||
if self._lobby_waiting_said or g.phase != "lobby" or len(g.players) < MIN_PLAYERS:
|
||||
return
|
||||
self._lobby_waiting_said = True
|
||||
|
||||
def fire():
|
||||
vip = g.players.get(g.vip_id() or "")
|
||||
if g.phase == "lobby" and vip:
|
||||
self.say(self.narrator.line("lobby_waiting", vip=vip.name))
|
||||
self._side_timers.append(asyncio.get_running_loop().call_later(rooms.LOBBY_WAITING_AFTER, fire))
|
||||
|
||||
|
||||
def warm_texts(themes: list[dict] | None = None) -> list[str]:
|
||||
"""Spionens faste replikker præcis som rummet siger dem (standardstemmen): de faste, reglerne, temaerne,
|
||||
rundernes nummer og spillets start for 4-10 spillere. Replikker med navne eller ordet er ikke med; de har en
|
||||
generisk afløser (warm_generic)."""
|
||||
themes = spy_themes() if themes is None else themes
|
||||
n = Narrator(lines=LINES, generic=GENERIC)
|
||||
out = [t for ev in FIXED_LINES for t in n.variants(ev)]
|
||||
out += [r["say"] for r in RULES]
|
||||
out += [t for th in themes for t in n.variants("theme", tema=th["name"])]
|
||||
out += [t for k in range(2, max(ROUND_CHOICES)) for t in n.variants("round_next", runde=k)]
|
||||
out += [t for k in range(MIN_PLAYERS, MAX_PLAYERS + 1) for t in n.variants("game_start", antal_spillere=k)]
|
||||
return list(dict.fromkeys(out))
|
||||
|
||||
|
||||
def warm_generic() -> list[str]:
|
||||
return list(dict.fromkeys(t for t, _ in generic_texts(GENERIC, None)))
|
||||
|
||||
|
||||
def create_spion_room(host_username: str, public_url: str, code: str | None = None, show_rules: bool = True,
|
||||
rounds: int = 4) -> SpionRoom:
|
||||
"""code: et kørende rums kode = skift spil i det rum (se rooms.add_room)."""
|
||||
rooms.cleanup()
|
||||
code = code or rooms.new_code()
|
||||
game = Game(code, spy_themes(), join_url=f"{public_url}/?code={code}", rounds=rounds)
|
||||
if not show_rules:
|
||||
game.show_rules = False
|
||||
room = SpionRoom(code, game, host_username)
|
||||
rooms.add_room(room)
|
||||
log.info("Spion-rum %s oprettet af %s (%d runder)", code, host_username, game.rounds)
|
||||
return room
|
||||
|
||||
@@ -78,5 +78,56 @@ window.WPP_GAMES = [
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "spion",
|
||||
"name": "Spionen",
|
||||
"tagline": "Alle kender det hemmelige ord. Undtagen spionen.",
|
||||
"min_players": 4,
|
||||
"max_players": 10,
|
||||
"minutes": "10-15",
|
||||
"steps": [
|
||||
"Alle får det samme hemmelige ord. Spionen får kun temaet.",
|
||||
"Alle skriver ét stikord, der viser, at de kender ordet.",
|
||||
"Snak sammen, og stem på den, I tror er spionen.",
|
||||
"Bliver spionen fanget, kan den stadig vinde ved at gætte ordet."
|
||||
],
|
||||
"icon": "games/spion.svg",
|
||||
"settings": [
|
||||
{
|
||||
"name": "rounds",
|
||||
"label": "Runder",
|
||||
"default": "4",
|
||||
"options": [
|
||||
{
|
||||
"value": "3",
|
||||
"label": "3 runder"
|
||||
},
|
||||
{
|
||||
"value": "4",
|
||||
"label": "4 runder"
|
||||
},
|
||||
{
|
||||
"value": "5",
|
||||
"label": "5 runder"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "rules",
|
||||
"label": "Reglerne",
|
||||
"default": "1",
|
||||
"options": [
|
||||
{
|
||||
"value": "1",
|
||||
"label": "Vis reglerne først"
|
||||
},
|
||||
{
|
||||
"value": "0",
|
||||
"label": "Spring dem over"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="img" aria-label="Spionen">
|
||||
<path d="M20 54c4-22 18-34 40-34s36 12 40 34z" fill="#1b1035"/>
|
||||
<rect x="10" y="50" width="100" height="10" rx="5" fill="#1b1035"/>
|
||||
<rect x="28" y="44" width="64" height="6" fill="#ff4d6d"/>
|
||||
<circle cx="44" cy="78" r="13" fill="#4cc9f0" stroke="#1b1035" stroke-width="5"/>
|
||||
<circle cx="76" cy="78" r="13" fill="#4cc9f0" stroke="#1b1035" stroke-width="5"/>
|
||||
<path d="M57 78h6" stroke="#1b1035" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="M40 74l6-4M72 74l6-4" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
|
||||
<path d="M46 100c8 5 20 5 28 0" fill="none" stroke="#ffd166" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 741 B |
@@ -82,7 +82,8 @@
|
||||
return '<span class="pchip' + (p.done ? ' done' : '') + (p.connected === false ? ' off' : '') + '" data-k="pc:' + esc(p.id) + ':' + (p.done ? 1 : 0) + (p.connected === false ? 'x' : '') +
|
||||
'" style="--pc:' + esc(p.color || '#888') + '"><span class="dot"></span>' + esc(p.name) + (p.done ? '<i class="ck" aria-label="klar"></i>' : '') + '</span>';
|
||||
}).join('');
|
||||
var skip = TIMED[state.phase] ? '<button class="hbtn" data-act="skip" title="Afslut fasen nu">Spring over »</button>' : '';
|
||||
var G = WPP.gameUI(state.game);
|
||||
var skip = TIMED[state.phase] || (G && G.timed && G.timed[state.phase]) ? '<button class="hbtn" data-act="skip" title="Afslut fasen nu">Spring over »</button>' : '';
|
||||
/* lyden kræver ét klik på siden; indtil da en tydelig knap */
|
||||
var needClick = !WPP.sound.muted && !(ctx && ctx.harness) && (WPP.voice.blocked || !WPP.sound.ready());
|
||||
var mute = (needClick ? '<button class="hbtn voice-blocked" data-act="unblock">' + (WPP.voice.blocked ? 'Klik for at høre speakeren' : 'Klik for lyd') + '</button>' : '') +
|
||||
|
||||
@@ -1048,3 +1048,63 @@ button, input { font-family: inherit; }
|
||||
@media (max-width: 640px) { .mn-who { position: static; padding-bottom: 12px; } .mn-intro-top { flex-direction: column; text-align: center; } .mn-intro .mn-tag { text-align: center; } .mn-intro .mn-facts { justify-content: center; } }
|
||||
@media (prefers-reduced-motion: reduce) { .mn-card, .mn-intro, .mn-steps li { animation: none; } }
|
||||
.mn-note { margin: 0; padding: 10px 18px; border-radius: 14px; background: rgba(255, 210, 63, .14); color: var(--yellow); font-weight: 600; }
|
||||
|
||||
/* ---------- Spionen ---------- */
|
||||
.sp-card { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; width: 100%; min-height: 150px;
|
||||
margin: 12px 0; padding: 20px; border: 0; border-radius: 22px; font: inherit; color: var(--ink); cursor: pointer;
|
||||
background: repeating-linear-gradient(45deg, rgba(255, 255, 255, .06) 0 12px, rgba(255, 255, 255, .02) 12px 24px), var(--card);
|
||||
box-shadow: inset 0 0 0 3px var(--card-b); }
|
||||
.sp-card.open { background: var(--card); box-shadow: inset 0 0 0 3px var(--cyan); }
|
||||
.sp-card.open.spy { box-shadow: inset 0 0 0 3px var(--pink); background: rgba(255, 61, 139, .14); }
|
||||
.sp-cover { font-weight: 700; font-size: 1.2rem; color: var(--muted); }
|
||||
.sp-cover.small { font-size: .85rem; font-weight: 600; }
|
||||
.sp-role { font-weight: 700; font-size: 1.1rem; color: var(--muted); }
|
||||
.sp-role.spy { color: var(--pink); font-size: 1.6rem; }
|
||||
.sp-word { font-size: 2.4rem; font-weight: 700; color: var(--yellow); line-height: 1.05; text-align: center; word-break: break-word; }
|
||||
.sp-hint { color: var(--muted); font-size: .95rem; text-align: center; }
|
||||
.sp-mine { text-align: center; margin: 4px 0 8px; }
|
||||
.sp-form { display: flex; flex-direction: column; gap: 10px; }
|
||||
.sp-form input { font: inherit; font-size: 1.4rem; padding: 14px 16px; border-radius: 16px; border: 0; text-align: center; }
|
||||
.sp-votes { display: grid; gap: 10px; margin: 10px 0; }
|
||||
.sp-vote { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 2px 12px; text-align: left; padding: 14px 18px;
|
||||
background: var(--card); color: var(--ink); box-shadow: inset 0 0 0 2px var(--card-b); }
|
||||
.sp-vote .dot { grid-row: span 2; width: 18px; height: 18px; border-radius: 50%; background: var(--pc, #888); }
|
||||
.sp-vote b { font-size: 1.25rem; }
|
||||
.sp-vote small { color: var(--muted); }
|
||||
.sp-vote.chosen { background: var(--yellow); color: var(--navy); box-shadow: 0 4px 0 var(--yellow-d); }
|
||||
.sp-vote.chosen small { color: var(--navy); }
|
||||
.sp-clues { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin: 12px 0; }
|
||||
.sp-clue { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 18px; border-radius: 18px; background: var(--card);
|
||||
box-shadow: inset 0 0 0 2px var(--card-b); animation: mn-in .4s ease both; min-width: 7em; }
|
||||
.sp-clue b { font-size: 1.5em; }
|
||||
.sp-clue.none b { color: var(--muted); }
|
||||
.hs .sp-clue b { font-size: 2.2em; }
|
||||
.sp-theme { text-align: center; }
|
||||
.sp-theme p { margin: 0; color: var(--muted); font-size: 1.6em; font-weight: 600; }
|
||||
.sp-theme h1 { margin: .1em 0; font-size: 4em; line-height: 1; }
|
||||
.sp-ticon { font-size: 4em; }
|
||||
.sp-sub { text-align: center; font-size: 1.5em; color: var(--muted); }
|
||||
.sp-h { text-align: center; margin: .2em 0 .4em; font-size: 2.6em; }
|
||||
.sp-timer { display: flex; justify-content: center; }
|
||||
.sp-timer.big .ring { width: 9em; height: 9em; }
|
||||
.sp-bars { list-style: none; margin: 0 auto; padding: 0; display: grid; gap: 10px; width: min(900px, 92%); }
|
||||
.sp-bars li { display: grid; grid-template-columns: 9em 1fr 3em auto; align-items: center; gap: 14px; font-size: 1.4em; animation: mn-in .4s ease both;
|
||||
animation-delay: calc(var(--i) * 120ms); }
|
||||
.sp-bars .bar { height: 22px; border-radius: 11px; background: var(--faint); overflow: hidden; }
|
||||
.sp-bars .bar i { display: block; height: 100%; width: calc(var(--w) * 100%); background: var(--cyan); border-radius: 11px; }
|
||||
.sp-bars li.acc .bar i { background: var(--pink); }
|
||||
.sp-bars small { color: var(--muted); }
|
||||
.sp-stamp { margin: 24px auto 0; width: fit-content; padding: .3em 1em; font-size: 3.2em; font-weight: 700; border-radius: 18px; transform: rotate(-4deg); }
|
||||
.sp-stamp.wait { color: var(--muted); animation: pulse 1.2s ease-in-out infinite; }
|
||||
.sp-stamp.yes { color: #fff; background: var(--pink); animation: pop .5s ease both; }
|
||||
.sp-stamp.no { color: var(--navy); background: var(--cyan); animation: pop .5s ease both; }
|
||||
.sp-stamp.tie { color: var(--yellow); font-size: 2.2em; }
|
||||
.sp-reveal { text-align: center; }
|
||||
.sp-reveal p { margin: .4em 0 .1em; color: var(--muted); font-size: 1.4em; font-weight: 600; }
|
||||
.sp-spies { display: flex; gap: 10px; justify-content: center; font-size: 1.6em; }
|
||||
.sp-word.big { font-size: 4.4em; }
|
||||
.sp-guess { text-align: center; font-size: 1.5em; }
|
||||
.sp-guess.ok b { color: var(--green); } .sp-guess.bad b { color: var(--red); }
|
||||
.sp-deltas { list-style: none; display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; padding: 0; font-size: 1.3em; }
|
||||
.sp-deltas li { display: flex; gap: 8px; align-items: center; }
|
||||
.sp-deltas b { color: var(--green); }
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/* Spionen: storskærmens og telefonens skærme.
|
||||
Plain script; melder sig som window.WPPGameUI.spion. host.js og play.js kalder hertil, når state.game er "spion"
|
||||
(lobby, regler og slutstilling deles med de andre spil). Hemmeligheden: telefonen viser kun det, serveren har sendt
|
||||
til netop den (kortet), og under dommen viser telefonen intet om, hvorvidt den anklagede var spion. */
|
||||
(function () {
|
||||
'use strict';
|
||||
var WPP = window.WPP, esc = WPP.esc;
|
||||
|
||||
function send(root, msg) { var c = root._ctx || {}; return c.send ? c.send(msg) : false; }
|
||||
function busy(root, on) {
|
||||
root.classList.toggle('busy', !!on);
|
||||
clearTimeout(root._spBusy);
|
||||
if (on) root._spBusy = setTimeout(function () { root.classList.remove('busy'); }, 6000);
|
||||
}
|
||||
function sp(u) { return u.sp || (u.sp = { shown: null }); }
|
||||
function big(title, sub, k, cls) {
|
||||
return '<section class="big ' + (cls || '') + '" data-k="' + (k || 'big') + '"><h2>' + title + '</h2>' + (sub ? '<p>' + sub + '</p>' : '') + '</section>';
|
||||
}
|
||||
function themeTag(state) {
|
||||
var t = state.theme || {}, r = state.round || {};
|
||||
return '<div class="roundtag" data-k="tt:' + esc(t.name || '') + r.number + '">' + esc((t.icon ? t.icon + ' ' : '') + (t.name || 'Spionen')) +
|
||||
(r.number ? ' · runde ' + r.number + ' af ' + r.of : '') + '</div>';
|
||||
}
|
||||
function chipOf(players, id) { return WPP.chip(players[id] || { name: '?', color: '#888' }); }
|
||||
function doneCount(state) {
|
||||
var ps = (state.players || []).filter(function (p) { return p.in_round; });
|
||||
return '<p class="progress muted" data-k="prog">' + ps.filter(function (p) { return p.done; }).length + ' af ' + ps.length + ' er færdige</p>';
|
||||
}
|
||||
/* storskærmens afsløring af stikordene: intro, så ét ad gangen */
|
||||
function cluesShown(state) {
|
||||
var c = state.clues || {};
|
||||
if (!c.started_at) return 0;
|
||||
var el = WPP.now() - c.started_at - (c.intro_ms || 0);
|
||||
return el < 0 ? 0 : Math.min((c.items || []).length, Math.floor(el / (c.clue_ms || 2600)) + 1);
|
||||
}
|
||||
function clueTiles(state, players, n) {
|
||||
var items = (state.clues || {}).items || [];
|
||||
if (n != null) items = items.slice(0, n);
|
||||
return '<section class="sp-clues" data-k="cl:' + items.length + '">' + items.map(function (c, i) {
|
||||
return '<div class="sp-clue' + (c.text ? '' : ' none') + '" style="--i:' + i + '">' + chipOf(players, c.player_id) +
|
||||
'<b>' + (c.text ? esc(c.text) : '…') + '</b></div>';
|
||||
}).join('') + '</section>';
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
TELEFONEN
|
||||
===================================================================== */
|
||||
/* rollekortet: skjult, til man trykker; så sidemanden ikke ser det */
|
||||
function card(state, me, u) {
|
||||
var c = me.card;
|
||||
if (!c) return me.role === 'audience' ? big('Du er publikum', 'Du kender ikke ordet. Gæt med, og stem for sjov.', 'aud') : '';
|
||||
var key = state.code + ':' + (state.round || {}).number + ':' + state.phase; /* lukker igen ved hver ny fase */
|
||||
var open = sp(u).shown === key;
|
||||
var face = !open ? '<span class="sp-cover">Tryk for at se dit kort</span>'
|
||||
: c.spy ? '<span class="sp-role spy">🕵️ Du er spionen!</span><span class="sp-hint">Temaet er <b>' + esc(c.theme) + '</b>. Lad som om, du kender ordet.</span>'
|
||||
: '<span class="sp-role">Ordet er</span><b class="sp-word">' + esc(c.word) + '</b><span class="sp-hint">Tema: ' + esc(c.theme) + '</span>';
|
||||
return '<button type="button" class="sp-card' + (open ? ' open' : '') + (open && c.spy ? ' spy' : '') + '" data-sact="card" data-arg="' + esc(key) + '" data-k="card:' + esc(key) + (open ? 1 : 0) + '">' +
|
||||
face + (open ? '<span class="sp-cover small">Tryk for at skjule</span>' : '') + '</button>';
|
||||
}
|
||||
var play = {};
|
||||
play.clue = function (state, me, players, root, u) {
|
||||
var out = themeTag(state) + card(state, me, u);
|
||||
if (!me.card) return out;
|
||||
return out + (me.clue ? '<p class="sp-mine" data-k="mine:' + esc(me.clue) + '">Dit stikord: <b>' + esc(me.clue) + '</b> ✓</p>' : '') +
|
||||
'<form class="sp-form" data-sform="clue" data-k="form:clue:' + esc(state.code + (state.round || {}).number) + '" autocomplete="off">' +
|
||||
'<label for="sp-text" class="sr">Stikord</label>' +
|
||||
'<input id="sp-text" type="text" maxlength="20" enterkeyhint="send" autocomplete="off" autocorrect="off" spellcheck="false" placeholder="' + (me.clue ? 'Skift stikord…' : 'Ét ord…') + '">' +
|
||||
'<button type="submit" class="btn btn-go btn-xl">' + (me.clue ? 'Skift stikord' : 'Send stikord') + '</button></form>' + doneCount(state);
|
||||
};
|
||||
play.clues = function (state, me, players, root, u) {
|
||||
return themeTag(state) + big('Kig på skærmen', 'Stikordene kommer et ad gangen.', 'look', 'pulse') + card(state, me, u);
|
||||
};
|
||||
play.talk = function (state, me, players, root, u) {
|
||||
return themeTag(state) + '<h2 class="ask" data-k="ask">Snak sammen! Hvem lyver?</h2>' + clueTiles(state, players) + card(state, me, u);
|
||||
};
|
||||
play.vote = function (state, me, players, root, u) {
|
||||
var ps = (state.players || []).filter(function (p) { return p.in_round && p.id !== me.id; });
|
||||
var clues = {};
|
||||
((state.clues || {}).items || []).forEach(function (c) { clues[c.player_id] = c.text; });
|
||||
var title = me.role === 'audience' ? 'Hvem tror du, det er? (for sjov)' : 'Hvem er spionen?';
|
||||
return themeTag(state) + '<h2 class="ask" data-k="ask:' + esc(me.vote || '') + '">' + title + '</h2>' +
|
||||
'<section class="sp-votes" data-k="v:' + esc(me.vote || '') + '">' + ps.map(function (p) {
|
||||
var on = me.vote === p.id;
|
||||
return '<button type="button" class="btn sp-vote' + (on ? ' chosen' : '') + '" style="--pc:' + esc(p.color) + '" data-sact="vote" data-arg="' + esc(p.id) + '">' +
|
||||
'<span class="dot"></span><b>' + esc(p.name) + '</b><small>' + (clues[p.id] ? esc(clues[p.id]) : '…') + '</small></button>';
|
||||
}).join('') + '</section>' + (me.role === 'audience' ? '' : doneCount(state)) + card(state, me, u);
|
||||
};
|
||||
/* dommen: telefonen ved ikke, om det var spionen (det afsløres på skærmen) */
|
||||
play.verdict = function (state) {
|
||||
return themeTag(state) + big('Stemmerne er talt', 'Kig på skærmen.', 'look', 'pulse');
|
||||
};
|
||||
play.guess = function (state, me, players) {
|
||||
var v = state.votes || {};
|
||||
if (me.can_guess) {
|
||||
return themeTag(state) + '<h2 class="ask" data-k="ask">Du er afsløret! Gæt ordet</h2>' +
|
||||
'<p class="hint center" data-k="h">Temaet er <b>' + esc((state.theme || {}).name || '') + '</b>. Gætter du rigtigt, vinder du alligevel.</p>' +
|
||||
'<form class="sp-form" data-sform="guess" data-k="form:guess" autocomplete="off">' +
|
||||
'<label for="sp-text" class="sr">Dit gæt</label>' +
|
||||
'<input id="sp-text" type="text" maxlength="30" enterkeyhint="send" autocomplete="off" autocorrect="off" spellcheck="false" placeholder="Ordet er…">' +
|
||||
'<button type="submit" class="btn btn-go btn-xl">Gæt</button></form>';
|
||||
}
|
||||
return themeTag(state) + big('Spionen er fanget!', (players[v.accused] ? esc(players[v.accused].name) : 'Spionen') + ' gætter på ordet…', 'g', 'pulse');
|
||||
};
|
||||
play.result = function (state, me) {
|
||||
var r = state.result || {}, mine = (r.deltas || {})[me.id];
|
||||
var spy = (r.spies || []).indexOf(me.id) >= 0;
|
||||
var head = me.role === 'audience' ? 'Runden er slut' : spy ? (r.spy_won ? 'Du slap væk!' : 'Du blev fanget!') : (r.spy_won ? 'Spionen vandt' : 'I fangede spionen!');
|
||||
return themeTag(state) + big(head, 'Ordet var <b>' + esc(r.word || '') + '</b>' + (mine ? '<br>+' + WPP.fmt(mine) + ' point' : ''), 'res', 'pop');
|
||||
};
|
||||
function playStale(state, u) { return false; }
|
||||
function markPlay() { /* */ }
|
||||
function afterPlay(root) { bindPlay(root); }
|
||||
|
||||
function bindPlay(root) {
|
||||
if (root._spBound) return;
|
||||
root._spBound = true;
|
||||
root.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('[data-sact]');
|
||||
if (!b || !root.contains(b) || b.disabled) return;
|
||||
e.preventDefault();
|
||||
var u = root._ui || {}, a = b.getAttribute('data-sact'), arg = b.getAttribute('data-arg');
|
||||
if (a === 'card') {
|
||||
sp(u).shown = sp(u).shown === arg ? null : arg;
|
||||
if (u.state && window.WPPPlay) WPPPlay.renderPlay(root, u.state, root._ctx);
|
||||
} else if (a === 'vote') {
|
||||
send(root, { action: 'vote', player_id: arg });
|
||||
}
|
||||
});
|
||||
root.addEventListener('submit', function (e) {
|
||||
var f = e.target;
|
||||
if (!f.classList.contains('sp-form')) return;
|
||||
e.preventDefault();
|
||||
var inp = f.querySelector('#sp-text'), text = (inp.value || '').trim();
|
||||
if (!text) { WPP.toast(root, 'Skriv lige noget først.'); inp.focus(); return; }
|
||||
if (root.classList.contains('busy')) return;
|
||||
var kind = f.getAttribute('data-sform');
|
||||
if (send(root, kind === 'clue' ? { action: 'submit_clue', text: text.slice(0, 20) } : { action: 'spy_guess', text: text.slice(0, 30) }) !== false) {
|
||||
busy(root, true);
|
||||
inp.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
STORSKÆRMEN
|
||||
===================================================================== */
|
||||
var host = {};
|
||||
host.clue = function (state) {
|
||||
var t = state.theme || {};
|
||||
return '<section class="sp-theme" data-k="th:' + esc(t.name || '') + '"><span class="sp-ticon">' + esc(t.icon || '🕵️') + '</span>' +
|
||||
'<p>Temaet er</p><h1>' + esc(t.name || '') + '</h1></section>' +
|
||||
'<p class="sp-sub" data-k="sub:' + state.spies + '">Alle skriver ét stikord på telefonen. ' + (state.spies === 2 ? 'To af jer er spioner.' : 'En af jer er spionen.') + '</p>' +
|
||||
'<div class="sp-timer" data-k="t' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'ring') + '</div>';
|
||||
};
|
||||
host.clues = function (state) {
|
||||
var players = WPP.playerMap(state), n = cluesShown(state);
|
||||
return '<h1 class="sp-h" data-k="h">Stikordene</h1>' + clueTiles(state, players, n);
|
||||
};
|
||||
host.talk = function (state) {
|
||||
var players = WPP.playerMap(state);
|
||||
return '<h1 class="sp-h" data-k="h">Snak sammen! Hvem lyver?</h1>' + clueTiles(state, players) +
|
||||
'<div class="sp-timer big" data-k="t' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'ring') + '</div>';
|
||||
};
|
||||
host.vote = function (state) {
|
||||
var players = WPP.playerMap(state);
|
||||
return '<h1 class="sp-h" data-k="h">Stem på telefonen: hvem er spionen?</h1>' + clueTiles(state, players) +
|
||||
'<div class="sp-timer" data-k="t' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'ring') + '</div>';
|
||||
};
|
||||
/* dommen: stemmerne, den anklagede, og til sidst (de sidste 2,5 s) om det var spionen */
|
||||
var REVEAL_BEFORE_END = 2500;
|
||||
function verdictRevealed(state) { return !!state.deadline && WPP.now() >= state.deadline - REVEAL_BEFORE_END; }
|
||||
host.verdict = function (state) {
|
||||
var players = WPP.playerMap(state), v = state.votes || {};
|
||||
var max = Math.max.apply(null, (v.counts || []).map(function (c) { return c.votes; }).concat([1]));
|
||||
var aud = {};
|
||||
(v.audience || []).forEach(function (a) { aud[a.player_id] = a.votes; });
|
||||
var bars = (v.counts || []).slice().sort(function (a, b) { return b.votes - a.votes; }).map(function (c, i) {
|
||||
return '<li class="' + (c.player_id === v.accused ? 'acc' : '') + '" style="--i:' + i + ';--w:' + (c.votes / max) + '">' + chipOf(players, c.player_id) +
|
||||
'<span class="bar"><i></i></span><b>' + c.votes + '</b>' + (aud[c.player_id] ? '<small>+' + aud[c.player_id] + ' publikum</small>' : '') + '</li>';
|
||||
}).join('');
|
||||
var shown = verdictRevealed(state);
|
||||
var stamp = !v.accused ? '<div class="sp-stamp tie" data-k="st:tie">Ingen anklaget: spionen slipper!</div>'
|
||||
: !shown ? '<div class="sp-stamp wait" data-k="st:wait">Er det spionen?</div>'
|
||||
: '<div class="sp-stamp ' + (v.accused_is_spy ? 'yes' : 'no') + '" data-k="st:' + (v.accused_is_spy ? 'yes' : 'no') + '">' +
|
||||
(v.accused_is_spy ? 'SPION!' : 'Uskyldig!') + '</div>';
|
||||
return '<h1 class="sp-h" data-k="h:' + esc(v.accused || '') + '">' + (v.accused ? esc((players[v.accused] || {}).name || '') + ' er anklaget' : 'Stemmerne') + '</h1>' +
|
||||
'<ol class="sp-bars" data-k="bars">' + bars + '</ol>' + stamp;
|
||||
};
|
||||
host.guess = function (state) {
|
||||
var players = WPP.playerMap(state), v = state.votes || {};
|
||||
return '<section class="sp-theme" data-k="g"><span class="sp-ticon">🕵️</span><p>Spionen er fanget!</p><h1>' + esc((players[v.accused] || {}).name || '') + '</h1></section>' +
|
||||
'<p class="sp-sub" data-k="sub">En sidste chance: spionen gætter på ordet.</p>' +
|
||||
'<div class="sp-timer" data-k="t' + (state.deadline || 0) + '">' + WPP.timerHtml(state, 'ring') + '</div>';
|
||||
};
|
||||
host.result = function (state) {
|
||||
var players = WPP.playerMap(state), r = state.result || {};
|
||||
var spies = (r.spies || []).map(function (id) { return chipOf(players, id); }).join(' ');
|
||||
var g = r.guess ? '<p class="sp-guess ' + (r.guess.correct ? 'ok' : 'bad') + '" data-k="gs">Spionens gæt: <b>' + esc(r.guess.text) + '</b> ' + (r.guess.correct ? '✓' : '✗') + '</p>' : '';
|
||||
var deltas = Object.keys(r.deltas || {}).filter(function (id) { return r.deltas[id] > 0; }).map(function (id) {
|
||||
return '<li>' + chipOf(players, id) + '<b>+' + WPP.fmt(r.deltas[id]) + '</b></li>';
|
||||
}).join('');
|
||||
return '<h1 class="sp-h" data-k="h">' + (r.spy_won ? 'Spionen vandt runden!' : 'Spionen er fanget!') + '</h1>' +
|
||||
'<section class="sp-reveal" data-k="rv"><p>' + ((r.spies || []).length > 1 ? 'Spionerne var' : 'Spionen var') + '</p><div class="sp-spies">' + spies + '</div>' +
|
||||
'<p>Ordet var</p><div class="sp-word big">' + esc(r.word || '') + '</div>' + g + '</section>' +
|
||||
(deltas ? '<ul class="sp-deltas" data-k="d">' + deltas + '</ul>' : '');
|
||||
};
|
||||
/* afsløringen af stikord og dommens stempel skrider frem uden nye snapshots */
|
||||
function hostTick(root, u, s, renderHost) {
|
||||
var key = s.phase === 'clues' ? 'c' + cluesShown(s) : s.phase === 'verdict' ? 'v' + verdictRevealed(s) : '';
|
||||
if (key && u.spKey !== key) { u.spKey = key; renderHost(root, s, u.ctx); }
|
||||
}
|
||||
function afterHost() { /* */ }
|
||||
|
||||
window.WPPGameUI = window.WPPGameUI || {};
|
||||
window.WPPGameUI.spion = {
|
||||
play: play, host: host, afterPlay: afterPlay, afterHost: afterHost, playStale: playStale, markPlay: markPlay,
|
||||
hostTick: hostTick, doneChips: { clue: 1, vote: 1 }, timed: { clue: 1, clues: 1, talk: 1, vote: 1, verdict: 1, result: 1 }
|
||||
};
|
||||
})();
|
||||
@@ -20,7 +20,7 @@ HOST = {"headers": {"x-authentik-username": "agw"}, "REMOTE_ADDR": "127.0.0.1"}
|
||||
|
||||
class RegisterTests(TestCase):
|
||||
def test_every_game_is_complete(self):
|
||||
self.assertEqual(list(games.GAMES), ["fof", "tegn"])
|
||||
self.assertEqual(list(games.GAMES), ["fof", "tegn", "spion"])
|
||||
for g in games.GAMES.values():
|
||||
with self.subTest(game=g.key):
|
||||
self.assertTrue(g.name and g.tagline and g.minutes)
|
||||
|
||||
@@ -8,7 +8,8 @@ from django.test import SimpleTestCase
|
||||
from fupogfakta import engine as fof_engine
|
||||
from party import narrator as fof
|
||||
from party.narrator import Narrator
|
||||
from party import tegn
|
||||
from party import spion, tegn
|
||||
from spionen import engine as spion_engine
|
||||
from tegnoggaet import engine as tegn_engine
|
||||
|
||||
NUMBERS = ("to tre fire fem seks syv otte ni ti elleve tolv tretten fjorten femten seksten sytten atten nitten "
|
||||
@@ -20,11 +21,15 @@ SILENT = {"present_answer"}
|
||||
FEST_ONLY = {"award_thirstiest"} # tår findes kun i fest-spil
|
||||
|
||||
GAMES = {"fof": (fof.LINES, {**fof.GENERIC_COMMON, **fof.GENERIC}, fof_engine.MIN_PLAYERS, fof_engine.MAX_PLAYERS),
|
||||
"tegn": (tegn.LINES, {**fof.GENERIC_COMMON, **tegn.GENERIC}, tegn_engine.MIN_PLAYERS, tegn_engine.MAX_PLAYERS)}
|
||||
"tegn": (tegn.LINES, {**fof.GENERIC_COMMON, **tegn.GENERIC}, tegn_engine.MIN_PLAYERS, tegn_engine.MAX_PLAYERS),
|
||||
"spion": (spion.LINES, {**fof.GENERIC_COMMON, **spion.GENERIC}, spion_engine.MIN_PLAYERS,
|
||||
spion_engine.MAX_PLAYERS)}
|
||||
# Pladsholdere med et endeligt antal værdier i Spionen (runde-nummeret) og ordet, der først siges ved resultatet
|
||||
FINITE |= {"runde"}
|
||||
|
||||
|
||||
def all_generic() -> list[tuple[str, str]]:
|
||||
files = [fof.GENERIC_COMMON, fof.GENERIC, tegn.GENERIC]
|
||||
files = [fof.GENERIC_COMMON, fof.GENERIC, tegn.GENERIC, spion.GENERIC]
|
||||
return [(k, line) for f in files for k, v in f.items() if isinstance(v, list) for line in v]
|
||||
|
||||
|
||||
@@ -51,7 +56,7 @@ class GenericLinesTests(SimpleTestCase):
|
||||
texts = [line for _, line in all_generic()]
|
||||
dupes = sorted({t for t in texts if texts.count(t) > 1})
|
||||
self.assertEqual(dupes, [])
|
||||
specific = {v for lines in (fof.LINES, tegn.LINES) for vs in lines.values() for v in vs}
|
||||
specific = {v for lines in (fof.LINES, tegn.LINES, spion.LINES) for vs in lines.values() for v in vs}
|
||||
self.assertEqual(sorted(set(texts) & specific), [], "en generisk replik er en kopi af en almindelig")
|
||||
|
||||
def test_counts(self):
|
||||
@@ -112,3 +117,16 @@ class GenericLinesTests(SimpleTestCase):
|
||||
self.assertIn(f.generic("lie_progress_fast"), fof.GENERIC["lie_progress_fest"])
|
||||
self.assertIn(f.generic("award_thirstiest"), fof.GENERIC["award_thirstiest_fest"])
|
||||
self.assertIsNone(n.generic("lie_hurry")) # ingen navne: ingen generisk
|
||||
|
||||
|
||||
class SpionLinesTests(SimpleTestCase):
|
||||
def test_language_of_the_fixed_lines(self):
|
||||
for key, variants in spion.LINES.items():
|
||||
for line in variants:
|
||||
with self.subTest(key=key, line=line):
|
||||
self.assertNotRegex(line, r"[—–]| - ")
|
||||
for s in sentences(re.sub(r"\{\w+\}", "x", line)):
|
||||
self.assertNotIn(s.split()[0].strip(",!?.").lower(), NUMBERS, "tal først i en sætning")
|
||||
self.assertNotRegex(line, r"(^|[.!?]\s+)\{(ord|point|antal_spillere|runde)\}", "pladsholder først")
|
||||
for tag in re.findall(r"\[([^\]]*)\]", line):
|
||||
self.assertIn(tag, fof.AUDIO_TAGS)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Spionen over WebSocket: et helt spil, hemmeligheden i hver besked, skift fra Tegn og Gæt, og speakeren."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
from channels.testing.websocket import WebsocketCommunicator
|
||||
from django.test import SimpleTestCase, TransactionTestCase, override_settings
|
||||
|
||||
from party import rooms, tts
|
||||
from party.spion import SpionRoom, create_spion_room, warm_generic, warm_texts
|
||||
from party.tegn import create_tegn_room
|
||||
from party.tests.test_switch import HOST, drain
|
||||
from party.tests.test_ws import HOST_HEADERS, recv_until
|
||||
from party.themes import spy_themes
|
||||
from partyhub.asgi import application
|
||||
from spionen.engine import Game
|
||||
from tegnoggaet.engine import Game as TegnGame
|
||||
|
||||
|
||||
class Phone:
|
||||
"""En telefon, der gemmer alt, den får, så testen kan lede efter ordet i hver eneste besked."""
|
||||
|
||||
def __init__(self, code, name):
|
||||
self.comm = WebsocketCommunicator(application, f"/ws/play/{code}/")
|
||||
self.name, self.seen, self.state = name, [], None
|
||||
|
||||
async def join(self):
|
||||
await self.comm.connect()
|
||||
await self.comm.send_json_to({"action": "join", "name": self.name})
|
||||
self.joined = await recv_until(self.comm, "joined")
|
||||
self.id = self.joined["player_id"]
|
||||
|
||||
async def pump(self, wait=0.01):
|
||||
while not await self.comm.receive_nothing(timeout=wait):
|
||||
msg = await self.comm.receive_json_from()
|
||||
self.seen.append(msg)
|
||||
if msg["type"] == "state":
|
||||
self.state = msg["state"]
|
||||
|
||||
async def send(self, msg):
|
||||
await self.comm.send_json_to(msg)
|
||||
|
||||
|
||||
@override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None)
|
||||
class SpionWebSocketTests(TransactionTestCase):
|
||||
def setUp(self):
|
||||
rooms.all_rooms().clear()
|
||||
self.addCleanup(setattr, Game, "show_rules", Game.show_rules)
|
||||
Game.show_rules = False
|
||||
|
||||
async def host(self, code):
|
||||
comm = WebsocketCommunicator(application, f"/ws/host/{code}/", headers=HOST_HEADERS)
|
||||
comm.scope["client"] = ("127.0.0.1", 5000)
|
||||
self.assertTrue((await comm.connect())[0])
|
||||
return comm
|
||||
|
||||
async def play_rounds(self, code, host, phones):
|
||||
"""Spil runderne til podiet; spionen bliver fanget og gætter rigtigt hver anden gang."""
|
||||
secrets_seen = []
|
||||
for _ in range(400):
|
||||
g = rooms.get_room(code).game
|
||||
for p in phones:
|
||||
await p.pump()
|
||||
await drain(host)
|
||||
if g.phase == "final":
|
||||
return secrets_seen
|
||||
if g.phase == "clue" and not g.clues:
|
||||
secrets_seen.append((g.round, g.word, list(g.spies)))
|
||||
for i, p in enumerate(phones):
|
||||
await p.send({"action": "submit_clue", "text": f"ledtr{chr(97 + i)}{g.round}"})
|
||||
elif g.phase == "vote" and not g.votes:
|
||||
spy = g.spies[0]
|
||||
for p in phones:
|
||||
target = spy if p.id != spy else next(x.id for x in phones if x.id != spy)
|
||||
await p.send({"action": "vote", "player_id": target})
|
||||
elif g.phase == "guess" and not g.guess:
|
||||
spy = next(p for p in phones if p.id == g.accused)
|
||||
await spy.send({"action": "spy_guess", "text": g.word if g.round % 2 else "forkert"})
|
||||
elif g.phase in ("clues", "talk", "verdict", "result"):
|
||||
await host.send_json_to({"action": "skip"})
|
||||
await asyncio.sleep(0.02)
|
||||
self.fail(f"nåede aldrig podiet ({rooms.get_room(code).game.phase})")
|
||||
|
||||
def assert_secret(self, phones, secrets_seen):
|
||||
"""Ingen besked til en telefon røbede ordet til en spion eller nogen spion før resultatet."""
|
||||
for p in phones:
|
||||
current = None
|
||||
for msg in p.seen:
|
||||
if msg["type"] != "state":
|
||||
continue
|
||||
st = msg["state"]
|
||||
rnd = (st.get("round") or {}).get("number")
|
||||
word, spies = next(((w, s) for r, w, s in secrets_seen if r == rnd), (None, []))
|
||||
if st["phase"] in ("result", "final", "lobby") or not word:
|
||||
continue
|
||||
text = json.dumps(st, ensure_ascii=False).lower()
|
||||
self.assertNotIn('"spies": [', text, st["phase"])
|
||||
if p.id in spies:
|
||||
self.assertNotIn(word, text, (p.name, st["phase"]))
|
||||
self.assertTrue(st["me"]["card"]["spy"])
|
||||
else:
|
||||
self.assertEqual(st["me"]["card"]["word"], word)
|
||||
current = st
|
||||
self.assertIsNotNone(current)
|
||||
|
||||
async def test_whole_game_four_players(self):
|
||||
room = create_spion_room("agw", "http://testserver", rounds=3)
|
||||
host = await self.host(room.code)
|
||||
phones = [Phone(room.code, n) for n in ("Asger", "Sus", "Bo", "Mette")]
|
||||
for p in phones:
|
||||
await p.join()
|
||||
await phones[0].send({"action": "start"})
|
||||
secrets_seen = await self.play_rounds(room.code, host, phones)
|
||||
self.assertEqual(len(secrets_seen), 3)
|
||||
st = (await recv_until(host, "state", "final", limit=80))["state"] if room.game.phase != "final" else \
|
||||
room.game.snapshot(time.time(), host=True)
|
||||
self.assertEqual((st["game"], len(st["final"]["ranking"])), ("spion", 4))
|
||||
self.assertTrue(any(p.score for p in room.game.players.values()))
|
||||
self.assert_secret(phones, secrets_seen)
|
||||
for p in phones:
|
||||
await p.comm.disconnect()
|
||||
await host.disconnect()
|
||||
room.close()
|
||||
|
||||
async def test_switch_from_tegn_og_gaet_to_spionen(self):
|
||||
TegnGame.show_rules, keep = False, TegnGame.show_rules
|
||||
self.addCleanup(setattr, TegnGame, "show_rules", keep)
|
||||
tegn = create_tegn_room("agw", "http://testserver")
|
||||
code = tegn.code
|
||||
phones = [Phone(code, n) for n in ("Asger", "Sus", "Bo", "Mette", "Kasper")]
|
||||
for p in phones:
|
||||
await p.join()
|
||||
resp = await self.async_client.post("/host/new", {"game": "spion", "code": code, "rounds": "3", "rules": "0"},
|
||||
**HOST)
|
||||
self.assertEqual(resp["Location"], f"/host/{code}/")
|
||||
room = rooms.get_room(code)
|
||||
self.assertIsInstance(room, SpionRoom)
|
||||
self.assertEqual(room.game.rounds, 3)
|
||||
for p in phones:
|
||||
await p.pump(0.1)
|
||||
self.assertEqual((p.state["game"], p.state["phase"]), ("spion", "lobby"))
|
||||
p.id = p.state["me"]["id"] # nyt id i det nye spil, samme forbindelse
|
||||
host = await self.host(code)
|
||||
await phones[0].send({"action": "start"})
|
||||
secrets_seen = await self.play_rounds(code, host, phones)
|
||||
self.assertEqual(len(secrets_seen), 3)
|
||||
self.assert_secret(phones, secrets_seen)
|
||||
for p in phones:
|
||||
await p.comm.disconnect()
|
||||
await host.disconnect()
|
||||
room.close()
|
||||
|
||||
async def test_errors_reach_the_phone(self):
|
||||
room = create_spion_room("agw", "http://testserver")
|
||||
phones = [Phone(room.code, n) for n in ("Asger", "Sus", "Bo", "Mette")]
|
||||
for p in phones:
|
||||
await p.join()
|
||||
await phones[0].send({"action": "start"})
|
||||
await phones[0].pump(0.1)
|
||||
g = room.game
|
||||
agent = next(p for p in phones if p.id not in g.spies)
|
||||
await agent.send({"action": "submit_clue", "text": g.word})
|
||||
self.assertEqual((await recv_until(agent.comm, "error"))["code"], "clue_is_word")
|
||||
await agent.send({"action": "vote", "player_id": agent.id})
|
||||
self.assertEqual((await recv_until(agent.comm, "error"))["code"], "wrong_phase")
|
||||
for p in phones:
|
||||
await p.comm.disconnect()
|
||||
room.close()
|
||||
|
||||
|
||||
class SpionSpeakerTests(SimpleTestCase):
|
||||
def test_the_speaker_never_says_the_word_early_and_says_warmed_texts(self):
|
||||
"""Hele spil med en falsk stemme: før resultatet indeholder ingen replik ordet, og alt uden navne er forvarmet."""
|
||||
said = []
|
||||
|
||||
async def fake_synth(text, voice=None):
|
||||
said.append((str(text), g.phase, g.word))
|
||||
return {"url": "/tts/x.mp3", "ms": 10}
|
||||
|
||||
async def play(seed):
|
||||
nonlocal g
|
||||
g = Game("TEST", spy_themes(), rng=random.Random(seed), rounds=3)
|
||||
g.show_rules = seed % 2 == 0
|
||||
room = SpionRoom("TEST", g, "agw")
|
||||
room.narrator.rng = random.Random(seed)
|
||||
names = ["Qxa", "Zyb", "Wvc", "Ytu", "Rpq", "Mnb", "Lkj", "Hgf"][:4 + seed % 5]
|
||||
|
||||
async def step(action=None):
|
||||
if action:
|
||||
action()
|
||||
room.handle_events()
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
await step(room.host_opened)
|
||||
ps = [g.join(n) for n in names]
|
||||
await step()
|
||||
await step(lambda: g.start(None, time.time()))
|
||||
for _ in range(200):
|
||||
if g.phase == "final":
|
||||
break
|
||||
now = time.time()
|
||||
if g.phase == "clue":
|
||||
for i, p in enumerate(g.order):
|
||||
if p not in g.clues:
|
||||
g.submit_clue(p, f"ledtr{chr(97 + i)}", now)
|
||||
elif g.phase == "vote":
|
||||
spy = g.spies[0]
|
||||
for p in g.order:
|
||||
g.vote(p, spy if p != spy else next(x for x in g.order if x != spy), now)
|
||||
elif g.phase == "guess":
|
||||
g.spy_guess(g.accused, g.word if seed % 2 else "forkert", now)
|
||||
else:
|
||||
g.tick(now + 3600)
|
||||
await step()
|
||||
room.close()
|
||||
return names
|
||||
|
||||
g = None
|
||||
with mock.patch.object(tts, "enabled", return_value=True), mock.patch.object(tts, "synth", side_effect=fake_synth), \
|
||||
mock.patch("party.rooms.MAX_HOLD", 0):
|
||||
names = []
|
||||
for seed in range(8):
|
||||
names += asyncio.run(play(seed))
|
||||
self.assertGreater(len(said), 60)
|
||||
for text, phase, word in said:
|
||||
if phase not in ("result", "final") and word:
|
||||
self.assertNotIn(word, text.lower(), (phase, text))
|
||||
warm = set(warm_texts()) | set(warm_generic())
|
||||
plain = [t for t, _, w in said if not any(n.lower() in t.lower() for n in names) and not (w and w in t.lower())]
|
||||
self.assertEqual(sorted({t for t in plain if t not in warm}), [])
|
||||
+2
-1
@@ -603,7 +603,8 @@ class Game:
|
||||
"join_url": self.join_url,
|
||||
"min_players": MIN_PLAYERS,
|
||||
"max_players": MAX_PLAYERS,
|
||||
"round": {"number": self.round, "of": self.rounds} if in_round else None,
|
||||
"round": {"number": self.round, "of": self.rounds, "name": f"Runde {self.round} af {self.rounds}"}
|
||||
if in_round else None,
|
||||
"players": [
|
||||
{"id": p.id, "name": p.name, "color": p.color, "score": p.score, "connected": p.connected,
|
||||
"vip": p.id == vip, "done": p.id in done, "in_round": p.id in self.order}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"lobby_waiting": [
|
||||
"Alle agenter er på plads. VIP'en starter missionen.",
|
||||
"Vi er klar. Startknappen sidder på VIP'ens telefon.",
|
||||
"[sighs] Selv spionen keder sig. VIP, tryk start.",
|
||||
"Holdet er samlet. Nu mangler vi bare et tryk fra VIP'en.",
|
||||
"Missionen venter på VIP'en."
|
||||
],
|
||||
"verdict_accused": [
|
||||
"I har peget på en. Men er det spionen?",
|
||||
"[dramatic] Flertallet har talt. Lad os se, om I har ret.",
|
||||
"Den anklagede står på skærmen. Nu kommer sandheden.",
|
||||
"Det blev afgjort. Spændingen er ulidelig.",
|
||||
"En af jer er anklaget. Holder anklagen?"
|
||||
],
|
||||
"guess_start": [
|
||||
"[gasps] Det var spionen! Men der er en sidste chance. Gæt ordet.",
|
||||
"I fangede spionen! Nu kan kun et rigtigt gæt redde den.",
|
||||
"Rigtigt! Spionen er afsløret og får et sidste gæt.",
|
||||
"Spionen er fanget. Et gæt på ordet kan stadig vende det hele.",
|
||||
"Afsløret! Men spionen er ikke helt færdig endnu."
|
||||
],
|
||||
"result_innocent": [
|
||||
"Forkert! Den anklagede var uskyldig, og spionen gik fri.",
|
||||
"[laughs] Uskyldig! Den rigtige spion smiler nu.",
|
||||
"Det var ikke spionen. Undskyld og tak for indsatsen.",
|
||||
"Uskyldig hele vejen. Spionen slap væk under jeres næse.",
|
||||
"I tog fejl. Spionen er stadig blandt jer."
|
||||
],
|
||||
"result_reveal": [
|
||||
"Spionen og ordet står nu på skærmen.",
|
||||
"Her er sandheden. Kig på skærmen.",
|
||||
"[dramatic] Afsløringen er på skærmen. Gættede I rigtigt?",
|
||||
"Masken falder. Se selv, hvem det var.",
|
||||
"Nu kan alle se, hvem spionen var, og hvad ordet var."
|
||||
],
|
||||
"winner": [
|
||||
"Vi har en vinder! Mission fuldført.",
|
||||
"[excited] Tillykke til vinderen! Medaljen sendes diskret.",
|
||||
"Spillet er slut. Vinderen må gerne træde frem fra skyggerne nu.",
|
||||
"Tillykke! Flest point og fuld tillid. Næsten.",
|
||||
"Sejren er afgjort. Hemmeligholdelsen ophæves."
|
||||
],
|
||||
"award_liar": [
|
||||
"Prisen for bedste løgner! Jeg tror ikke på dig mere.",
|
||||
"[impressed] Aftenens bedste løgner. Pas på derhjemme.",
|
||||
"Bedste løgner. Ingen kunne se det komme.",
|
||||
"Prisen for bedste løgner går til en, I aldrig skal stole på.",
|
||||
"Bedste løgner. Pokerfjæset var perfekt."
|
||||
],
|
||||
"award_sharp": [
|
||||
"Skarpeste øje! Ingen spion slap forbi.",
|
||||
"Prisen for skarpeste øje. Den her agent ser alt.",
|
||||
"[impressed] Skarpeste øje. Spionerne havde ikke en chance.",
|
||||
"Ingen snyder den her. Skarpeste øje!",
|
||||
"Skarpeste øje går til en, der burde arbejde i efterretningstjenesten."
|
||||
],
|
||||
"award_suspected": [
|
||||
"Mest mistænkt i aften. Retfærdigt eller ej.",
|
||||
"[laughs] Prisen for mest mistænkt. Alle pegede igen og igen.",
|
||||
"Mest mistænkt. Det siger noget. Om noget.",
|
||||
"Prisen for flest stemmer går til en med et meget skyldigt ansigt.",
|
||||
"Mest mistænkt. Måske er det ansigtet. Måske er det sandheden."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
{
|
||||
"lobby_welcome": [
|
||||
"Velkommen til Spionen. Én af jer lyver i aften. Måske flere.",
|
||||
"[whispers] Velkommen. Tal stille, der er en spion iblandt os.",
|
||||
"Spionen er klar. Hold telefonen tæt ind til kroppen.",
|
||||
"Velkommen. Stol på ingen. Heller ikke på mig.",
|
||||
"[mischievously] Godaften, agenter. Og godaften, spion. Jeg ved bare ikke, hvem du er endnu.",
|
||||
"Telefonerne frem. Pokerfjæset på."
|
||||
],
|
||||
"lobby_join": [
|
||||
"Velkommen, {navn}. Du ser uskyldig ud. Det er mistænkeligt.",
|
||||
"{navn} er med. Agent eller spion? Det finder vi ud af.",
|
||||
"Hej {navn}. Jeg har allerede åbnet en sag på dig.",
|
||||
"{navn} er landet. Hold øje med den.",
|
||||
"[suspicious] Velkommen, {navn}. Hvor var du i går klokken otte?",
|
||||
"Plads til {navn}. Solbrillerne må gerne blive på."
|
||||
],
|
||||
"lobby_waiting": [
|
||||
"{vip}, vi er klar, når du er. Tryk start.",
|
||||
"Alle agenter er på plads, {vip}. Startknappen er din.",
|
||||
"{vip}, missionen venter på dig.",
|
||||
"[sighs] {vip}, selv spionen keder sig. Start spillet."
|
||||
],
|
||||
"game_start": [
|
||||
"Så kører vi med {antal_spillere} spillere. Mindst en af dem lyver.",
|
||||
"Missionen begynder. Vi er {antal_spillere}, og ingen er til at stole på.",
|
||||
"Okay, {antal_spillere} agenter. Eller næsten.",
|
||||
"Spillet starter, og {antal_spillere} par øjne kigger mistænksomt rundt."
|
||||
],
|
||||
"round_first": [
|
||||
"Første runde. Kig på telefonen, og lad være med at vise den til nogen.",
|
||||
"Vi begynder. Kig diskret på telefonen.",
|
||||
"[whispers] Første runde. Skærm hånden for telefonen.",
|
||||
"Første mission. Tjek dit kort, og gem det igen."
|
||||
],
|
||||
"round_next": [
|
||||
"Runde {runde}. Nye kort, ny spion.",
|
||||
"Så er det runde {runde}. Kig på telefonen igen.",
|
||||
"Videre til runde {runde}. Måske er det dig nu.",
|
||||
"Runde {runde}. Glem den gamle spion, der er en ny."
|
||||
],
|
||||
"round_last": [
|
||||
"Sidste runde! Nu gælder det.",
|
||||
"[dramatic] Den sidste mission. Kig på telefonen.",
|
||||
"Sidste runde. Der er stadig point at hente."
|
||||
],
|
||||
"theme": [
|
||||
"Temaet er {tema}.",
|
||||
"Vi er i temaet {tema}.",
|
||||
"Temaet er {tema}. Mere siger jeg ikke.",
|
||||
"[mischievously] Temaet er {tema}. Spionen ved ikke mere end det."
|
||||
],
|
||||
"clue_hurry": [
|
||||
"Kun ti sekunder til stikordene!",
|
||||
"Skriv nu det stikord.",
|
||||
"Uret tikker. Ét ord, tak.",
|
||||
"[excited] Sidste chance for et stikord!"
|
||||
],
|
||||
"clue_all_done": [
|
||||
"Alle har skrevet. Lad os se.",
|
||||
"Stikordene er klar. Hold fast.",
|
||||
"Så hurtigt? Nogen har vist en plan.",
|
||||
"Alle er færdige. Nu bliver det spændende."
|
||||
],
|
||||
"clues_intro": [
|
||||
"Her kommer stikordene. Et ad gangen.",
|
||||
"Lad os se, hvad I har skrevet.",
|
||||
"[curious] Stikordene. Kig godt efter, der gemmer sig en løgner.",
|
||||
"Hold øje med stikordene. Et af dem passer måske ikke helt."
|
||||
],
|
||||
"clues_missing": [
|
||||
"Nogen skrev ikke noget. Det er da mistænkeligt.",
|
||||
"Et tomt felt. Var det tid, der manglede, eller ord?",
|
||||
"[suspicious] Der mangler et stikord. Interessant."
|
||||
],
|
||||
"talk_start": [
|
||||
"I har et minut. Snak sammen. Hvem lyver?",
|
||||
"Nu må I snakke. Spørg hinanden ud.",
|
||||
"Et minut til forhør. Gå i gang.",
|
||||
"[excited] Snak løs! Hvem lyder, som om de gætter?",
|
||||
"Diskussionen er åben. Pas på, hvad I røber om ordet."
|
||||
],
|
||||
"vote_start": [
|
||||
"Tid til at stemme. Hvem er spionen?",
|
||||
"Stem på telefonen. Peg på den skyldige.",
|
||||
"Afstemningen er åben. Stol på maven.",
|
||||
"Nu skal I vælge. Hvem tror I, det er?"
|
||||
],
|
||||
"vote_all_done": [
|
||||
"Alle har stemt.",
|
||||
"Stemmerne er inde. Lad os se.",
|
||||
"Så er det afgjort. Eller er det?"
|
||||
],
|
||||
"verdict_accused": [
|
||||
"Flest stemmer fik {navn}. Men er det spionen?",
|
||||
"[dramatic] I peger på {navn}. Lad os se, om I har ret.",
|
||||
"{navn} er anklaget. Nu kommer sandheden.",
|
||||
"Flertallet har talt. {navn}, er du spionen?",
|
||||
"Det blev {navn}. Spændingen er ulidelig."
|
||||
],
|
||||
"verdict_tie": [
|
||||
"Lige mange stemmer. Så slipper spionen.",
|
||||
"Uafgjort! Det er spionens bedste nyhed i dag.",
|
||||
"[sighs] I kunne ikke blive enige. Spionen takker.",
|
||||
"Stemmerne er delt. Ingen bliver anklaget."
|
||||
],
|
||||
"verdict_none": [
|
||||
"Ingen stemte. Spionen kunne ikke have ønsket sig mere.",
|
||||
"[sighs] Ingen stemmer. Så går spionen fri."
|
||||
],
|
||||
"guess_start": [
|
||||
"[gasps] Det var spionen! {navn}, du har en sidste chance. Gæt ordet.",
|
||||
"I fangede spionen! Men {navn} kan stadig redde sig. Hvad er ordet?",
|
||||
"Rigtigt! Det er {navn}. Nu skal spionen gætte ordet.",
|
||||
"{navn} er afsløret. Et gæt på ordet kan stadig vende det hele."
|
||||
],
|
||||
"result_innocent": [
|
||||
"{navn} var uskyldig! Spionen gik fri.",
|
||||
"[laughs] Forkert! {navn} kendte ordet hele tiden.",
|
||||
"Det var ikke {navn}. Den rigtige spion smiler nu.",
|
||||
"Uskyldig! Undskyld, {navn}."
|
||||
],
|
||||
"result_guessed": [
|
||||
"Spionen gættede ordet! Sikke en redning.",
|
||||
"[impressed] Rigtigt gæt! Spionen vinder alligevel.",
|
||||
"Spionen havde fanget ordet. Godt spillet.",
|
||||
"Gættet er rigtigt. Spionen slipper med æren i behold."
|
||||
],
|
||||
"result_missed": [
|
||||
"Forkert gæt. Spionen er færdig.",
|
||||
"[laughs] Ikke engang tæt på. Agenterne vinder.",
|
||||
"Gættet holdt ikke. Godt fanget, agenter.",
|
||||
"Spionen gættede forkert. Missionen lykkedes."
|
||||
],
|
||||
"result_timeout": [
|
||||
"Tiden løb ud for spionen. Agenterne vinder.",
|
||||
"Intet gæt. Spionen er afsløret."
|
||||
],
|
||||
"result_free": [
|
||||
"Spionen slap væk.",
|
||||
"[mischievously] Spionen gik under radaren.",
|
||||
"Spionen klarede den. Agenterne må tage sig sammen."
|
||||
],
|
||||
"result_reveal": [
|
||||
"Spionen var {spioner}, og ordet var {ord}.",
|
||||
"Ordet var {ord}. Og spionen var {spioner}.",
|
||||
"Afsløring: {spioner} var spion, og ordet var {ord}."
|
||||
],
|
||||
"winner": [
|
||||
"Og vinderen er {vinder} med {point} point!",
|
||||
"[excited] {vinder} vinder med {point} point. Mission fuldført.",
|
||||
"Tillykke, {vinder}, med {point} point.",
|
||||
"{vinder} tager sejren med {point} point. Medaljen sendes diskret."
|
||||
],
|
||||
"award_liar": [
|
||||
"Prisen for bedste løgner går til {navn}.",
|
||||
"[impressed] {navn} løj bedst i aften.",
|
||||
"Bedste løgner er {navn}. Jeg tror ikke på dig mere.",
|
||||
"{navn} er aftenens bedste løgner. Pas på derhjemme."
|
||||
],
|
||||
"award_sharp": [
|
||||
"Skarpeste øje: {navn}.",
|
||||
"{navn} så tværs igennem spionerne.",
|
||||
"[impressed] Prisen for skarpeste øje går til {navn}.",
|
||||
"Ingen snyder {navn}. Skarpeste øje!"
|
||||
],
|
||||
"award_suspected": [
|
||||
"Mest mistænkt i aften: {navn}.",
|
||||
"[laughs] Alle pegede på {navn}. Igen og igen.",
|
||||
"Prisen for mest mistænkt går til {navn}. Retfærdigt eller ej.",
|
||||
"{navn} fik flest stemmer i aften. Det siger noget. Om noget."
|
||||
],
|
||||
"play_again": [
|
||||
"En gang til! Nye ord, nye spioner.",
|
||||
"Vi kører igen. Kig på telefonerne.",
|
||||
"[excited] Ny mission!",
|
||||
"Igen? Selvfølgelig. Spionen er klar."
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user