diff --git a/party/rooms.py b/party/rooms.py
index c27ebfc..eb6ac07 100644
--- a/party/rooms.py
+++ b/party/rooms.py
@@ -158,6 +158,28 @@ class BaseRoom:
if self._voice_task:
self._voice_task.cancel()
+ def take_over(self, old: "BaseRoom"):
+ """Skift af spil i samme rum: spillerne kommer med i det nye spils lobby med samme navn og token, så
+ telefonerne følger med uden at joine igen. Er der flere end det nye spils max, bliver resten publikum
+ (spillerne først, i den rækkefølge de kom). Forbindelserne flyttes, så ingen peger på det gamle rum."""
+ g, ids = self.game, {}
+ members = sorted(old.game.players.values(), key=lambda p: p.joined_order) + \
+ sorted(old.game.audience.values(), key=lambda p: p.joined_order)
+ for m in members:
+ try:
+ p = g.join(m.name)
+ except engine.GameError: # navnet duer ikke i det nye spil: telefonen må joine igen
+ log.warning("Rum %s: %s kom ikke med i det nye spil", self.code, m.name)
+ continue
+ p.token, p.connected = m.token, m.connected
+ ids[m.id] = p.id
+ g.drain_events() # ingen velkomst til hver enkelt: de var her i forvejen
+ for conn in old.connections:
+ conn.room = self
+ if getattr(conn, "member_id", None):
+ conn.member_id = ids.get(conn.member_id, "-") # "-": findes ikke, så telefonen beder om at joine
+ self.connections, old.connections = old.connections, set()
+
def handle_events(self):
self.game.drain_events()
@@ -732,6 +754,7 @@ def new_code() -> str:
def create_room(questions: list[dict], host_username: str, public_url: str, fest: bool = False,
code: str | None = None) -> Room:
+ """code: et kørende rums kode = skift spil i det rum (se add_room)."""
cleanup()
code = code or new_code()
game = Game(code, questions, join_url=f"{public_url}/?code={code}", fest=fest)
@@ -742,7 +765,13 @@ def create_room(questions: list[dict], host_username: str, public_url: str, fest
def add_room(room: BaseRoom):
+ """Læg rummet i kodelisten. Kører der allerede et rum med koden, er det et skift af spil: det nye rum
+ overtager spillerne og forbindelserne, og det gamle lukkes."""
+ old = _rooms.get(room.code)
_rooms[room.code] = room
+ if old is not None and old is not room:
+ room.take_over(old)
+ old.close()
def get_room(code: str) -> Room | None:
diff --git a/party/static/party/host.js b/party/static/party/host.js
index dab240b..9494f9a 100644
--- a/party/static/party/host.js
+++ b/party/static/party/host.js
@@ -414,7 +414,8 @@
'' +
(aw ? 'Priser ' : '') +
(rest ? '' + rest + ' ' : '') +
- 'Spil igen ';
+ '
';
}
/* ---------- effekter: et lag over skærmen, som patch ikke rører ---------- */
diff --git a/party/static/party/menu.js b/party/static/party/menu.js
index 237f173..5d1e8bf 100644
--- a/party/static/party/menu.js
+++ b/party/static/party/menu.js
@@ -11,8 +11,10 @@
return '👥 ' + players + ' spillere ⏱ ca. ' + esc(g.minutes) + ' min ';
}
- function menuScreen(games) {
- return 'Weirsøe Party Protocol Vælg et spil
' +
+ function menuScreen(games, sw) {
+ var sub = sw ? 'Rum ' + esc(sw.code) + ' : ' + (sw.players === 1 ? 'én venter' : sw.players + ' venter') + '. Vælg næste spil'
+ : 'Vælg et spil';
+ return 'Weirsøe Party Protocol ' + sub + '
' +
'' + games.map(function (g, i) {
return '' +
' ' +
@@ -22,7 +24,7 @@
}).join('') + ' ';
}
- function introScreen(g, values) {
+ function introScreen(g, values, sw) {
var sets = (g.settings || []).map(function (s) {
var cur = values[s.name] != null ? values[s.name] : s.default;
var hint = s.options.filter(function (o) { return o.value === cur; })[0];
@@ -37,19 +39,20 @@
'' +
'
' + WPP.brandHtml(g.name) + ' ' + esc(g.tagline) + '
' +
'
' + facts(g) + '
' +
+ (sw && sw.players > g.max_players ? '' + g.max_players + ' kan spille med, resten er publikum.
' : '') +
'' + g.steps.map(function (s, i) { return '' + esc(s) + ' '; }).join('') + ' ' +
(sets ? '' + sets + '
' : '') +
'Tilbage ' +
'Start ' + esc(g.name) + '
';
}
- /* view = {screen: 'menu'} eller {screen: 'intro', key, values} */
+ /* view = {screen: 'menu'} eller {screen: 'intro', key, values}; view.sw = {code, players} ved skift af spil */
function render(root, view) {
var games = WPP.games, g = view.screen === 'intro' && WPP.game(view.key);
var sh = WPP.shell(root);
sh.screen.className = 'screen mn mn-' + (g ? 'intro' : 'menu');
WPP.patch(sh.screen, '' +
- (g ? introScreen(g, view.values || {}) : menuScreen(games)) + ' ');
+ (g ? introScreen(g, view.values || {}, view.sw) : menuScreen(games, view.sw)) + '');
}
/* pilene: nærmeste valgbare element i den retning (målt fra midten), så både kort i et gitter og knapper
@@ -78,6 +81,7 @@
function mount(root) {
var form = document.getElementById('mn-form');
+ var sw = root.getAttribute('data-code') ? { code: root.getAttribute('data-code'), players: Number(root.getAttribute('data-players')) || 0 } : null;
var view = { screen: 'menu' };
function focusFirst(sel) {
@@ -95,6 +99,7 @@
function show(next, push) {
var from = view;
view = next;
+ view.sw = sw;
render(root, view);
if (view.screen === 'intro') {
if (push) try { history.pushState({ intro: view.key }, '', '#' + view.key); } catch (e) { /* */ }
diff --git a/party/static/party/party.css b/party/static/party/party.css
index c53808e..5e3f2f9 100644
--- a/party/static/party/party.css
+++ b/party/static/party/party.css
@@ -500,6 +500,9 @@ button, input { font-family: inherit; }
.rest .rk { margin-right: .3em; }
.rest b { margin-left: .4em; color: #fff; font-weight: 600; }
.fin-side .btn { align-self: flex-start; margin-top: .3em; }
+.fin-again { display: flex; gap: .6em; flex-wrap: wrap; align-items: center; }
+.fin-again .btn { margin-top: .3em; }
+.fin-other { background: var(--card); color: var(--ink); box-shadow: inset 0 0 0 2px var(--card-b); text-decoration: none; font-size: 1.2em; }
/* host_new */
.landing { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px; text-align: center; padding: 24px 16px; }
@@ -1044,3 +1047,4 @@ button, input { font-family: inherit; }
@keyframes mn-in { from { opacity: 0; transform: translateY(14px); } }
@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; }
diff --git a/party/tegn.py b/party/tegn.py
index a6807d0..6ec4536 100644
--- a/party/tegn.py
+++ b/party/tegn.py
@@ -271,6 +271,7 @@ def warm_texts(themes: list[dict] | None = None) -> list[str]:
def create_tegn_room(host_username: str, public_url: str, code: str | None = None,
show_rules: bool = True) -> TegnRoom:
+ """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, load_themes(), join_url=f"{public_url}/?code={code}")
diff --git a/party/templates/party/host_new.html b/party/templates/party/host_new.html
index c65c6b8..1ded7ff 100644
--- a/party/templates/party/host_new.html
+++ b/party/templates/party/host_new.html
@@ -2,14 +2,14 @@
{% load static %}
{% block body_class %}page-menu{% endblock %}
{% block content %}
-
+
{# menu.js sender denne formular med spillet og dets indstillinger #}
-
+
Weirsøe Party Protocol
{# hvert spil er sin egen knap: Enter/OK på en fokuseret radio sender formularen med den afkrydsede, ikke den fokuserede #}
-
diff --git a/party/tests/test_switch.py b/party/tests/test_switch.py
new file mode 100644
index 0000000..10098c3
--- /dev/null
+++ b/party/tests/test_switch.py
@@ -0,0 +1,183 @@
+"""Skift spil i samme rum: Fup og Fakta til podiet, "Vælg andet spil", Tegn og Gæt med de samme telefoner."""
+
+import asyncio
+
+from channels.testing.websocket import WebsocketCommunicator
+from django.test import TransactionTestCase, override_settings
+
+from fupogfakta.engine import Game as FofGame
+from partyhub.asgi import application
+from party import rooms
+from party.rooms import Room
+from party.tegn import TegnRoom, create_tegn_room
+from party.tests.test_tegn import FAST, STROKE, recv_task
+from party.tests.test_ws import HOST_HEADERS, make_bank, recv_until
+from tegnoggaet import engine as tegn_engine
+from tegnoggaet.engine import Game as TegnGame
+
+HOST = {"headers": {"x-authentik-username": "agw"}, "REMOTE_ADDR": "127.0.0.1"}
+FOF_WAIT = {"rules", "intro", "present", "reveal", "scores"} # faser, hvor kun tiden (eller værten) rykker
+
+
+async def drain(comm, wait=0.01):
+ """Tøm køen; den seneste tilstand gemmes i comm.last_state."""
+ while not await comm.receive_nothing(timeout=wait):
+ msg = await comm.receive_json_from()
+ if msg["type"] == "state":
+ comm.last_state = msg["state"]
+
+
+async def last_state(comm):
+ await drain(comm, wait=0.1)
+ return comm.last_state
+
+
+@override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None)
+class SwitchGameTests(TransactionTestCase):
+ def setUp(self):
+ rooms.all_rooms().clear()
+ for cls in (FofGame, TegnGame):
+ self.addCleanup(setattr, cls, "show_rules", cls.show_rules)
+ cls.show_rules = False
+ for k, v in FAST.items():
+ self.addCleanup(setattr, tegn_engine, k, getattr(tegn_engine, k))
+ setattr(tegn_engine, k, v)
+
+ 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_fof(self, code, host, phones):
+ """Telefonerne spiller hver handling over WebSocket; værten springer ventefaserne over."""
+ acted = set()
+ for _ in range(2000):
+ room = rooms.get_room(code)
+ g = room.game
+ if g.phase == "final":
+ return
+ key = g.phase_key()
+ if g.phase == "category":
+ c, _ = next(p for p in phones if p[1]["player_id"] == g.chooser_id)
+ if (key, "cat") not in acted:
+ acted.add((key, "cat"))
+ await c.send_json_to({"action": "choose_category", "category_id": g.category_options[0]["id"]})
+ elif g.phase == "lie":
+ for i, (c, j) in enumerate(phones):
+ if (key, i) not in acted:
+ acted.add((key, i))
+ await c.send_json_to({"action": "submit_lie", "text": f"løgn {i} {key}"[:30]})
+ elif g.phase == "guess":
+ truth = next(a for a in g.answers if a.is_truth)
+ for i, (c, j) in enumerate(phones):
+ if (key, i) not in acted:
+ acted.add((key, i))
+ await c.send_json_to({"action": "guess", "answer_id": truth.id})
+ elif g.phase in FOF_WAIT and (key, "skip") not in acted:
+ acted.add((key, "skip"))
+ await host.send_json_to({"action": "skip"})
+ await asyncio.sleep(0.02)
+ for c, _ in phones:
+ await drain(c)
+ await drain(host)
+ self.fail(f"Fup og Fakta nåede aldrig podiet (fase {rooms.get_room(code).game.phase})")
+
+ async def test_fof_then_tegn_in_the_same_room(self):
+ await asyncio.to_thread(make_bank)
+ resp = await self.async_client.post("/host/new", {"game": "fof"}, **HOST)
+ code = resp["Location"].rstrip("/").rsplit("/", 1)[-1]
+ fof_room = rooms.get_room(code)
+ self.assertIsInstance(fof_room, Room)
+ host = await self._host(code)
+ phones = []
+ for name in ["Asger", "Sus", "Jørn"]:
+ c = WebsocketCommunicator(application, f"/ws/play/{code}/")
+ await c.connect()
+ await c.send_json_to({"action": "join", "name": name})
+ phones.append((c, await recv_until(c, "joined")))
+ await phones[0][0].send_json_to({"action": "start"})
+ await self._play_fof(code, host, phones)
+ st = await last_state(phones[1][0])
+ self.assertEqual((st["game"], st["phase"]), ("fof", "final"))
+ self.assertEqual(len(st["final"]["ranking"]), 3)
+ self.assertEqual(len(fof_room.game.used_questions), 7) # hele spillet: 3 + 3 + finalen
+ self.assertTrue(all(p.score > 0 for p in fof_room.game.players.values())) # alle fandt sandheden
+
+ # Værten vælger et andet spil: menuen kender rummet, og Start sender koden med
+ await host.disconnect() # storskærmen går til menuen
+ menu = await self.async_client.get(f"/host/?code={code}", **HOST)
+ self.assertEqual(menu.context["switch"], {"code": code, "players": 3})
+ self.assertContains(menu, f'name="code" value="{code}"')
+ resp = await self.async_client.post("/host/new", {"game": "tegn", "code": code, "rules": "0"}, **HOST)
+ self.assertEqual(resp["Location"], f"/host/{code}/")
+ room = rooms.get_room(code)
+ self.assertIsInstance(room, TegnRoom)
+ self.assertEqual(fof_room.connections, set()) # det gamle rum har ingen forbindelser tilbage
+ self.assertIsNone(fof_room._timer)
+
+ # Telefonerne får det nye spils lobby uden at joine igen: samme navne, samme VIP, samme token
+ ids = {}
+ for c, j in phones:
+ st = await recv_until(c, "state", "lobby")
+ st = st["state"]
+ self.assertEqual(st["game"], "tegn")
+ self.assertEqual([p["name"] for p in st["players"]], ["Asger", "Sus", "Jørn"])
+ self.assertEqual(room.game._member(st["me"]["id"]).token, j["token"])
+ ids[j["player_id"]] = st["me"]["id"]
+ self.assertEqual(room.game.vip_id(), ids[phones[0][1]["player_id"]])
+ self.assertTrue(all(p.score == 0 for p in room.game.players.values()))
+
+ # ... og spiller Tegn og Gæt til podiet på de samme forbindelser
+ host = await self._host(code)
+ await phones[0][0].send_json_to({"action": "start"})
+ st = (await recv_until(host, "state", "theme"))["state"]
+ await host.send_json_to({"action": "choose_theme", "theme_id": st["theme"]["options"][0]["id"]})
+ for i, (c, _) in enumerate(phones):
+ await recv_task(c, "word")
+ await c.send_json_to({"action": "submit_word", "text": f"ord {i}"})
+ for c, _ in phones:
+ await recv_task(c, "draw")
+ await c.send_json_to({"action": "submit_drawing", "strokes": [STROKE]})
+ for c, _ in phones:
+ await recv_task(c, "guess")
+ await c.send_json_to({"action": "submit_guess", "text": "en hest"})
+ st = (await recv_until(host, "state", "final", limit=80))["state"]
+ self.assertEqual((st["game"], len(st["final"]["ranking"])), ("tegn", 3))
+
+ # En telefon, der genindlæser, kommer ind med sit gamle token
+ c0, j0 = phones[0]
+ await c0.disconnect()
+ again = WebsocketCommunicator(application, f"/ws/play/{code}/")
+ await again.connect()
+ await again.send_json_to({"action": "resume", "token": j0["token"]})
+ self.assertEqual((await recv_until(again, "joined"))["player_id"], ids[j0["player_id"]])
+ for c, _ in phones[1:]:
+ await c.disconnect()
+ await again.disconnect()
+ await host.disconnect()
+ room.close()
+
+ async def test_players_over_the_new_max_become_audience(self):
+ tegn = create_tegn_room("agw", "http://testserver")
+ names = [f"Spiller{i}" for i in range(10)]
+ members = [tegn.game.join(n) for n in names] + [tegn.game.join("Tilskuer")]
+ self.assertEqual((len(tegn.game.players), len(tegn.game.audience)), (10, 1))
+ phone = WebsocketCommunicator(application, f"/ws/play/{tegn.code}/")
+ await phone.connect()
+ await phone.send_json_to({"action": "resume", "token": members[9].token}) # tiende spiller
+ await recv_until(phone, "joined")
+ await recv_until(phone, "state", "lobby")
+
+ room = rooms.create_room([{"category": "K", "prompt": "Hvad _____?", "answer": "x"}], "agw",
+ "http://testserver", code=tegn.code)
+ room.changed()
+ g = room.game
+ self.assertEqual([p.name for p in g.players.values()], names[:8]) # fof: højst 8, i den rækkefølge de kom
+ self.assertEqual([p.name for p in g.audience.values()], names[8:] + ["Tilskuer"])
+ self.assertEqual({p.token for p in list(g.players.values()) + list(g.audience.values())},
+ {m.token for m in members})
+ st = (await recv_until(phone, "state", "lobby"))["state"]
+ self.assertEqual((st["game"], st["me"]["role"], st["me"]["name"]), ("fof", "audience", "Spiller9"))
+ await phone.disconnect()
+ room.close()
diff --git a/party/views.py b/party/views.py
index 41dad14..4d69152 100644
--- a/party/views.py
+++ b/party/views.py
@@ -37,7 +37,11 @@ def play(request):
def host_new_page(request):
if not request.host_identity:
return _forbidden()
- return render(request, "party/host_new.html", {"username": request.host_identity.username, **_game_page(None)})
+ # ?code=ABCD: "Vælg andet spil" fra podiet; det næste spil starter i samme rum med de samme spillere
+ room = get_room(request.GET.get("code", ""))
+ switch = {"code": room.code, "players": len(room.game.players) + len(room.game.audience)} if room else None
+ return render(request, "party/host_new.html", {"username": request.host_identity.username, "switch": switch,
+ **_game_page(None)})
@require_GET
@@ -59,10 +63,14 @@ async def host_new(request):
game = games.GAMES.get(request.POST.get("game") or next(iter(games.GAMES)))
if not game:
return HttpResponse("Det spil findes ikke.", status=400)
+ running = get_room(request.POST.get("code", "")) # skift spil i et kørende rum (se rooms.add_room)
try:
- room = await game.create(ident.username, settings.PUBLIC_URL, game.clean_settings(request.POST))
+ room = await game.create(ident.username, settings.PUBLIC_URL, game.clean_settings(request.POST),
+ code=running.code if running else None)
except games.Unavailable as e:
return HttpResponse(str(e), status=503)
+ if running:
+ room.changed() # telefonerne får det nye spils lobby med det samme
return redirect("party:host", code=room.code)