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:
agw
2026-10-20 21:59:54 +02:00
co-authored by Claude Opus 5.5
parent ccb925b89a
commit d5d60ad75d
13 changed files with 1061 additions and 7 deletions
+24
View File
@@ -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
View File
@@ -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
+51
View File
@@ -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"
}
]
}
]
}
];
+10
View File
@@ -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

+2 -1
View File
@@ -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>' : '') +
+60
View File
@@ -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); }
+219
View File
@@ -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 }
};
})();
+1 -1
View File
@@ -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)
+22 -4
View File
@@ -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)
+233
View File
@@ -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
View File
@@ -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}
+65
View File
@@ -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."
]
}
+179
View File
@@ -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."
]
}