feat(gems): add multiplayer gem game with custom content packs
treefmt / nix fmt (pull_request) Successful in 6s
test ebook search / test-ebook-search (pull_request) Successful in 39s
pytest / pytest (pull_request) Successful in 40s
build_systems / build-brain (pull_request) Successful in 1m0s
build_systems / build-bob (pull_request) Successful in 1m2s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m33s
build_systems / build-jeeves (pull_request) Successful in 2m25s
test ebook search / test-ebook-search (push) Successful in 35s
build_systems / build-brain (push) Successful in 37s
build_systems / build-bob (push) Successful in 37s
build_systems / build-jeeves (push) Successful in 2m17s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 29s
build_systems / build-rhapsody-in-green (push) Successful in 49s
treefmt / nix fmt (pull_request) Successful in 6s
test ebook search / test-ebook-search (pull_request) Successful in 39s
pytest / pytest (pull_request) Successful in 40s
build_systems / build-brain (pull_request) Successful in 1m0s
build_systems / build-bob (pull_request) Successful in 1m2s
build_systems / build-rhapsody-in-green (pull_request) Successful in 1m33s
build_systems / build-jeeves (pull_request) Successful in 2m25s
test ebook search / test-ebook-search (push) Successful in 35s
build_systems / build-brain (push) Successful in 37s
build_systems / build-bob (push) Successful in 37s
build_systems / build-jeeves (push) Successful in 2m17s
treefmt / nix fmt (push) Successful in 5s
pytest / pytest (push) Successful in 29s
build_systems / build-rhapsody-in-green (push) Successful in 49s
- implement FastAPI and HTMX lobby and game interfaces - support up to four human and AI-controlled players - add configurable rules, victory conditions, and expansion modules - support validated JSON cards, patrons, objectives, and outposts
This commit was merged in pull request #43.
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from python.gems.config import GemsConfig
|
||||
from python.gems.main import create_app
|
||||
|
||||
|
||||
def make_client(tmp_path):
|
||||
app = create_app()
|
||||
app.state.config = GemsConfig(
|
||||
database_path=tmp_path / "gems.sqlite3",
|
||||
key_path=tmp_path / "instance.key",
|
||||
public_origin="http://testserver",
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_create_join_upload_and_reconnect(tmp_path, content_pack) -> None:
|
||||
with make_client(tmp_path) as host:
|
||||
response = host.post("/rooms", data={"name": "Host"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
assert response.cookies.get("gems_display_name") == "Host"
|
||||
code = response.headers["location"].rsplit("/", 1)[1]
|
||||
page = host.get(f"/rooms/{code}")
|
||||
assert code in page.text
|
||||
assert 'class="topbar room-topbar"' in page.text
|
||||
assert page.text.count("<header") == 1
|
||||
assert "No cards, artwork" not in page.text
|
||||
assert 'value="Host"' in host.get("/").text
|
||||
csrf = page.text.split('name="csrf" value="', 1)[1].split('"', 1)[0]
|
||||
upload = host.post(
|
||||
f"/rooms/{code}/pack",
|
||||
data={"csrf": csrf},
|
||||
files={"pack_file": ("pack.json", json.dumps(content_pack.model_dump(mode="json")), "application/json")},
|
||||
)
|
||||
assert upload.status_code == 200
|
||||
assert "Loaded and validated" in upload.text
|
||||
with make_client(tmp_path) as guest:
|
||||
joined = guest.post(f"/join/{code}", data={"name": "Guest"}, follow_redirects=False)
|
||||
assert joined.status_code == 303
|
||||
assert "Guest" in guest.get(f"/rooms/{code}").text
|
||||
|
||||
|
||||
def test_schema_and_security_headers(tmp_path) -> None:
|
||||
with make_client(tmp_path) as client:
|
||||
response = client.get("/schemas/content-pack-v1.json")
|
||||
assert response.status_code == 200
|
||||
assert "patrons" in response.json()["properties"]
|
||||
home = client.get("/")
|
||||
assert home.headers["x-frame-options"] == "DENY"
|
||||
assert "script-src 'self'" in home.headers["content-security-policy"]
|
||||
assert "style-src-attr 'unsafe-inline'" in home.headers["content-security-policy"]
|
||||
assert '"code":"422","swap":true' in home.text
|
||||
|
||||
|
||||
def test_solo_lobby_can_start_and_render_legal_actions(tmp_path, content_pack) -> None:
|
||||
with make_client(tmp_path) as client:
|
||||
created = client.post("/rooms", data={"name": "Solo"}, follow_redirects=False)
|
||||
code = created.headers["location"].rsplit("/", 1)[1]
|
||||
page = client.get(f"/rooms/{code}")
|
||||
csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1)
|
||||
assert "Pack required · Waiting for every human to ready up" in page.text
|
||||
assert '<button class="primary" disabled>Start game</button>' in page.text
|
||||
assert 'name="first_player_mode"' in page.text
|
||||
assert 'name="first_player_seat"' in page.text
|
||||
uploaded = client.post(
|
||||
f"/rooms/{code}/pack",
|
||||
data={"csrf": csrf},
|
||||
files={"pack_file": ("pack.json", json.dumps(content_pack.model_dump(mode="json")), "application/json")},
|
||||
)
|
||||
assert "Pack loaded · Waiting for every human to ready up" in uploaded.text
|
||||
assert '<button class="primary" disabled>Start game</button>' in uploaded.text
|
||||
ready = client.post(f"/rooms/{code}/ready", data={"csrf": csrf, "ready": "true"})
|
||||
assert ready.status_code == 200
|
||||
assert "Pack loaded · Players ready · Rules set" in ready.text
|
||||
assert '<button class="primary">Start game</button>' in ready.text
|
||||
started = client.post(f"/rooms/{code}/start", data={"csrf": csrf})
|
||||
assert started.status_code == 200
|
||||
assert all(marker in started.text for marker in ("Gem piles", "Solo", "History", "Patrons", "Patron One"))
|
||||
assert all(
|
||||
marker in started.text
|
||||
for marker in (
|
||||
'class="players-stack"',
|
||||
"Gems / Cards",
|
||||
"Click for card details",
|
||||
'class="supply right-supply panel compact"',
|
||||
'class="player-gem-dock"',
|
||||
'aria-label="Solo gems and cards"',
|
||||
'class="hand-total"',
|
||||
'class="gem-disc card-bonus-token"',
|
||||
)
|
||||
)
|
||||
assert "players-grid" not in started.text
|
||||
assert f"/rooms/{code}/take-gems" in started.text
|
||||
assert 'name="resources"' in started.text
|
||||
assert f"--gem-color:{content_pack.resources[0].color}" in started.text
|
||||
assert '<button class="primary" disabled>Purchase</button>' in started.text
|
||||
assert "<button>Reserve</button>" in started.text
|
||||
assert "command_json" in started.text
|
||||
|
||||
pair_resource = content_pack.resource_ids[0]
|
||||
paired = client.post(
|
||||
f"/rooms/{code}/take-gems",
|
||||
data={"csrf": csrf, "pair_resource": pair_resource},
|
||||
)
|
||||
assert paired.status_code == 200
|
||||
assert f'value="{pair_resource}" disabled>Take 2</button>' in paired.text
|
||||
|
||||
reserve_commands = re.findall(r'name="command_json" value="([^"]+)"><button>Reserve</button>', paired.text)
|
||||
reserve_command = next(
|
||||
item
|
||||
for item in reserve_commands
|
||||
if content_pack.card(json.loads(html.unescape(item))["payload"]["card_id"]).bonus_resource != "pearl"
|
||||
)
|
||||
reserved = client.post(
|
||||
f"/rooms/{code}/commands",
|
||||
data={"csrf": csrf, "command_json": html.unescape(reserve_command)},
|
||||
)
|
||||
assert reserved.status_code == 200
|
||||
assert "Your reserved cards" in reserved.text
|
||||
assert 'class="bottom-reserved"' in reserved.text
|
||||
assert ">Purchase</button>" in reserved.text
|
||||
reserved_label = re.search(r'<div class="reserved-card-face"><strong>([^<]+)</strong>', reserved.text).group(1)
|
||||
purchase_command = re.search(
|
||||
r'name="command_json" value="([^"]+)"><button class="primary">Purchase</button>', reserved.text
|
||||
).group(1)
|
||||
purchased = client.post(
|
||||
f"/rooms/{code}/commands",
|
||||
data={"csrf": csrf, "command_json": html.unescape(purchase_command)},
|
||||
)
|
||||
assert purchased.status_code == 200
|
||||
assert 'class="owned-card-inspector"' in purchased.text
|
||||
assert reserved_label in purchased.text
|
||||
|
||||
taken = client.post(
|
||||
f"/rooms/{code}/take-gems",
|
||||
data={"csrf": csrf, "resources": list(content_pack.resource_ids[:3])},
|
||||
)
|
||||
assert taken.status_code == 200
|
||||
assert "Gem piles" in taken.text
|
||||
|
||||
invalid = client.post(
|
||||
f"/rooms/{code}/take-gems",
|
||||
data={"csrf": csrf, "resources": list(content_pack.resource_ids[:2])},
|
||||
)
|
||||
assert invalid.status_code == 422
|
||||
assert "Choose three different available gems" in invalid.text
|
||||
|
||||
|
||||
def test_token_overflow_shows_and_processes_specific_discard_actions(tmp_path, content_pack) -> None:
|
||||
with make_client(tmp_path) as client:
|
||||
created = client.post("/rooms", data={"name": "Solo"}, follow_redirects=False)
|
||||
code = created.headers["location"].rsplit("/", 1)[1]
|
||||
page = client.get(f"/rooms/{code}")
|
||||
csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1)
|
||||
client.post(
|
||||
f"/rooms/{code}/pack",
|
||||
data={"csrf": csrf},
|
||||
files={"pack_file": ("pack.json", json.dumps(content_pack.model_dump(mode="json")), "application/json")},
|
||||
)
|
||||
client.post(
|
||||
f"/rooms/{code}/settings",
|
||||
data={
|
||||
"csrf": csrf,
|
||||
"token_limit": "1",
|
||||
"first_player_mode": "selected",
|
||||
"first_player_seat": "0",
|
||||
},
|
||||
)
|
||||
client.post(f"/rooms/{code}/ready", data={"csrf": csrf, "ready": "true"})
|
||||
client.post(f"/rooms/{code}/start", data={"csrf": csrf})
|
||||
|
||||
overflow = client.post(
|
||||
f"/rooms/{code}/take-gems",
|
||||
data={"csrf": csrf, "resources": list(content_pack.resource_ids[:3])},
|
||||
)
|
||||
assert overflow.status_code == 200
|
||||
assert "Resolve: discard tokens" in overflow.text
|
||||
assert f"/rooms/{code}/discard-tokens" in overflow.text
|
||||
assert "Return exactly 2 excess gems" in overflow.text
|
||||
returned_colors = content_pack.resource_ids[:2]
|
||||
discarded = client.post(
|
||||
f"/rooms/{code}/discard-tokens",
|
||||
data={
|
||||
"csrf": csrf,
|
||||
f"token_{returned_colors[0]}": "1",
|
||||
f"token_{returned_colors[1]}": "1",
|
||||
},
|
||||
)
|
||||
assert discarded.status_code == 200
|
||||
assert "Resolve: discard tokens" not in discarded.text
|
||||
assert "1 / 1" in discarded.text
|
||||
|
||||
|
||||
def test_cli_port_override_accepts_same_origin_mutations(tmp_path) -> None:
|
||||
app = create_app()
|
||||
app.state.config = GemsConfig(
|
||||
database_path=tmp_path / "gems.sqlite3",
|
||||
key_path=tmp_path / "instance.key",
|
||||
)
|
||||
with TestClient(app, base_url="http://127.0.0.1:8002") as client:
|
||||
created = client.post("/rooms", data={"name": "Host"}, follow_redirects=False)
|
||||
code = created.headers["location"].rsplit("/", 1)[1]
|
||||
page = client.get(f"/rooms/{code}")
|
||||
csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1)
|
||||
assert f"http://127.0.0.1:8002/join/{code}" in page.text
|
||||
|
||||
response = client.post(
|
||||
f"/rooms/{code}/seats/ai",
|
||||
data={"csrf": csrf, "difficulty": "medium"},
|
||||
headers={"Origin": "http://127.0.0.1:8002"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Medium AI" in response.text
|
||||
|
||||
|
||||
def test_cross_origin_mutation_is_rejected(tmp_path) -> None:
|
||||
with make_client(tmp_path) as client:
|
||||
created = client.post("/rooms", data={"name": "Host"}, follow_redirects=False)
|
||||
code = created.headers["location"].rsplit("/", 1)[1]
|
||||
page = client.get(f"/rooms/{code}")
|
||||
csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1)
|
||||
|
||||
response = client.post(
|
||||
f"/rooms/{code}/seats/ai",
|
||||
data={"csrf": csrf, "difficulty": "medium"},
|
||||
headers={"Origin": "https://attacker.example"},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert "Request origin was rejected" in response.text
|
||||
|
||||
|
||||
def test_invalid_content_pack_returns_visible_feedback(tmp_path) -> None:
|
||||
with make_client(tmp_path) as client:
|
||||
created = client.post("/rooms", data={"name": "Host"}, follow_redirects=False)
|
||||
code = created.headers["location"].rsplit("/", 1)[1]
|
||||
page = client.get(f"/rooms/{code}")
|
||||
csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1)
|
||||
assert "No content pack is loaded" in page.text
|
||||
|
||||
response = client.post(
|
||||
f"/rooms/{code}/pack",
|
||||
data={"csrf": csrf},
|
||||
files={"pack_file": ("broken.json", b"not json", "application/json")},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert 'role="alert"' in response.text
|
||||
assert "Invalid JSON" in response.text
|
||||
assert "No content pack is loaded" in response.text
|
||||
|
||||
|
||||
def test_host_can_play_again_with_same_table_setup(tmp_path, content_pack) -> None:
|
||||
with make_client(tmp_path) as client:
|
||||
created = client.post("/rooms", data={"name": "Host"}, follow_redirects=False)
|
||||
code = created.headers["location"].rsplit("/", 1)[1]
|
||||
page = client.get(f"/rooms/{code}")
|
||||
csrf = re.search(r'name="csrf" value="([^"]+)"', page.text).group(1)
|
||||
client.post(
|
||||
f"/rooms/{code}/pack",
|
||||
data={"csrf": csrf},
|
||||
files={"pack_file": ("pack.json", json.dumps(content_pack.model_dump(mode="json")), "application/json")},
|
||||
)
|
||||
client.post(f"/rooms/{code}/ready", data={"csrf": csrf, "ready": "true"})
|
||||
client.post(f"/rooms/{code}/start", data={"csrf": csrf})
|
||||
|
||||
room = client.app.state.repository.get_room(code)
|
||||
room.status = "finished"
|
||||
room.state.finished = True
|
||||
room.state.winners = [0]
|
||||
room.state.revision += 1
|
||||
room.revision = room.state.revision
|
||||
client.app.state.repository.save_state(room, "test-finish", 0, {"type": "test_finish"})
|
||||
|
||||
finished = client.get(f"/rooms/{code}")
|
||||
assert "Play again" in finished.text
|
||||
replayed = client.post(f"/rooms/{code}/play-again", data={"csrf": csrf})
|
||||
assert replayed.status_code == 200
|
||||
assert "Round 1" in replayed.text
|
||||
assert "Play again" not in replayed.text
|
||||
fresh_room = client.app.state.repository.get_room(code)
|
||||
assert fresh_room.status == "playing"
|
||||
assert not fresh_room.state.finished
|
||||
assert fresh_room.pack.metadata.id == content_pack.metadata.id
|
||||
Reference in New Issue
Block a user