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 @@
|
||||
"""Tests for the Gems application."""
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Synthetic content builders; no playable pack is bundled with the app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from python.gems.domain.models import (
|
||||
AlternateCost,
|
||||
CardDefinition,
|
||||
CardEffect,
|
||||
CardEffectKind,
|
||||
ContentPack,
|
||||
ObjectiveDefinition,
|
||||
OutpostDefinition,
|
||||
OutpostPower,
|
||||
PackMetadata,
|
||||
PatronDefinition,
|
||||
Requirement,
|
||||
RequirementKind,
|
||||
ResourceDefinition,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def content_pack() -> ContentPack:
|
||||
resources = [
|
||||
ResourceDefinition(id=name, label=name.title(), symbol=str(index + 1), color=f"#{index + 2}{index + 2}4455")
|
||||
for index, name in enumerate(("pearl", "wave", "leaf", "flame", "stone"))
|
||||
]
|
||||
cards = []
|
||||
for deck in ("base", "eastern"):
|
||||
for tier in (1, 2, 3):
|
||||
for index, resource in enumerate(resources):
|
||||
effect = CardEffect()
|
||||
if deck == "eastern" and tier == 1 and index == 0:
|
||||
effect = CardEffect(kind=CardEffectKind.VIRTUAL_WILD, amount=2)
|
||||
if deck == "eastern" and tier == 1 and index == 1:
|
||||
effect = CardEffect(kind=CardEffectKind.COPY_BONUS)
|
||||
if deck == "eastern" and tier == 2 and index == 0:
|
||||
effect = CardEffect(kind=CardEffectKind.COPY_AND_CLAIM, target_tier=1)
|
||||
if deck == "eastern" and tier == 2 and index == 1:
|
||||
effect = CardEffect(kind=CardEffectKind.MULTI_BONUS, amount=2)
|
||||
if deck == "eastern" and tier == 3 and index == 0:
|
||||
effect = CardEffect(kind=CardEffectKind.CLAIM_FREE, target_tier=2)
|
||||
alternate = (
|
||||
AlternateCost(discard_resource="stone", count=2)
|
||||
if deck == "eastern" and tier == 3 and index == 1
|
||||
else None
|
||||
)
|
||||
cards.append(
|
||||
CardDefinition(
|
||||
id=f"{deck}_{tier}_{index}",
|
||||
label=f"{deck.title()} {tier}-{index}",
|
||||
deck=deck,
|
||||
tier=tier,
|
||||
points=tier - 1,
|
||||
bonus_resource=resource.id,
|
||||
cost={resources[(index + 1) % 5].id: tier},
|
||||
effect=effect,
|
||||
alternate_cost=alternate,
|
||||
)
|
||||
)
|
||||
requirement = Requirement(id="need_pearl", kind=RequirementKind.COLOR, resource="pearl", count=1)
|
||||
return ContentPack(
|
||||
schema_version=1,
|
||||
metadata=PackMetadata(id="synthetic", name="Synthetic Tests", version="1"),
|
||||
resources=resources,
|
||||
wild_resource=ResourceDefinition(id="wild", label="Wild", symbol="*", color="#aaaaaa"),
|
||||
cards=cards,
|
||||
patrons=[PatronDefinition(id="patron_one", label="Patron One", points=3, requirements=[requirement])],
|
||||
objectives=[ObjectiveDefinition(id="goal_one", label="Goal One", minimum_score=0, requirements=[requirement])],
|
||||
outposts=[
|
||||
OutpostDefinition(id=f"post_{power.value}", label=power.value, requirements=[requirement], power=power)
|
||||
for power in OutpostPower
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from python.gems.ai import choose_ai_command
|
||||
from python.gems.domain.engine import RuleError, apply_command, bonuses, new_game, public_state, score
|
||||
from python.gems.domain.legal_actions import command, legal_commands
|
||||
from python.gems.domain.models import GameSettings, OwnedCard
|
||||
|
||||
|
||||
def test_setup_and_hidden_information(content_pack) -> None:
|
||||
state = new_game("ABCDEFGH", ["A", "B", "C", "D"], content_pack, GameSettings(), seed=9)
|
||||
assert state.supply["pearl"] == 7
|
||||
assert all(len(market) == 4 for market in state.markets.values())
|
||||
state.players[1].reserved.append("base_1_0")
|
||||
view = public_state(state, 0)
|
||||
assert view["players"][1]["reserved"] == [None]
|
||||
assert isinstance(view["decks"]["base:1"], int)
|
||||
|
||||
|
||||
def test_distinct_take_requires_three_when_available(content_pack) -> None:
|
||||
settings = GameSettings(first_player_mode="selected", first_player_seat=0)
|
||||
state = new_game("ABCDEFGH", ["A", "B"], content_pack, settings, seed=1)
|
||||
bad = command(state, "take_distinct", {"resources": ["pearl", "wave"]})
|
||||
with pytest.raises(RuleError, match="exactly three"):
|
||||
apply_command(state, bad, content_pack, settings, actor_seat=0)
|
||||
good = command(state, "take_distinct", {"resources": ["pearl", "wave", "leaf"]})
|
||||
state = apply_command(state, good, content_pack, settings, actor_seat=0)
|
||||
assert state.players[0].tokens["pearl"] == 1
|
||||
assert state.current_seat == 1
|
||||
|
||||
|
||||
def test_purchase_uses_discount_and_returns_payment(content_pack) -> None:
|
||||
settings = GameSettings(first_player_mode="selected", first_player_seat=0)
|
||||
state = new_game("ABCDEFGH", ["A", "B"], content_pack, settings, seed=2)
|
||||
card_id = state.markets["base:1"][0]
|
||||
card = content_pack.card(card_id)
|
||||
resource, cost = next(iter(card.cost.items()))
|
||||
state.players[0].tokens[resource] = cost
|
||||
state.supply[resource] -= cost
|
||||
buy = command(state, "purchase", {"card_id": card_id, "payment": {resource: cost}})
|
||||
state = apply_command(state, buy, content_pack, settings, actor_seat=0)
|
||||
assert any(item.card_id == card_id for item in state.players[0].cards)
|
||||
assert state.supply[resource] >= cost
|
||||
assert bonuses(state.players[0], content_pack)[card.bonus_resource] == 1
|
||||
|
||||
|
||||
def test_reserving_visible_card_refills_market(content_pack) -> None:
|
||||
settings = GameSettings(first_player_mode="selected", first_player_seat=0)
|
||||
state = new_game("ABCDEFGH", ["A", "B"], content_pack, settings, seed=12)
|
||||
card_id = state.markets["base:1"][0]
|
||||
deck_size = len(state.decks["base:1"])
|
||||
|
||||
state = apply_command(state, command(state, "reserve", {"card_id": card_id}), content_pack, settings, actor_seat=0)
|
||||
|
||||
assert card_id in state.players[0].reserved
|
||||
assert card_id not in state.markets["base:1"]
|
||||
assert len(state.markets["base:1"]) == settings.base_market_size
|
||||
assert len(state.decks["base:1"]) == deck_size - 1
|
||||
|
||||
|
||||
def test_score_counts_cards_patrons_and_scoring_outpost(content_pack) -> None:
|
||||
state = new_game("ABCDEFGH", ["A"], content_pack, GameSettings(), seed=3)
|
||||
player = state.players[0]
|
||||
player.cards.append(OwnedCard(card_id="base_3_0"))
|
||||
player.patrons.append("patron_one")
|
||||
player.outposts.append("post_points_per_outpost")
|
||||
assert score(player, content_pack) == 6
|
||||
|
||||
|
||||
def test_each_ai_level_returns_a_legal_command(content_pack) -> None:
|
||||
settings = GameSettings(first_player_mode="selected", first_player_seat=0)
|
||||
state = new_game("ABCDEFGH", ["A", "B"], content_pack, settings, seed=4)
|
||||
legal = legal_commands(state, content_pack, settings, 0)
|
||||
legal_shapes = {(item.type, str(item.payload)) for item in legal}
|
||||
for difficulty in ("easy", "medium", "hard"):
|
||||
chosen = choose_ai_command(state, content_pack, settings, 0, difficulty)
|
||||
assert (chosen.type, str(chosen.payload)) in legal_shapes
|
||||
|
||||
|
||||
def test_first_player_can_be_selected_or_random(content_pack) -> None:
|
||||
selected = GameSettings(first_player_mode="selected", first_player_seat=2)
|
||||
selected_state = new_game("ABCDEFGH", ["A", "B", "C"], content_pack, selected, seed=8)
|
||||
assert selected_state.first_seat == 2
|
||||
assert selected_state.current_seat == 2
|
||||
|
||||
random_settings = GameSettings(first_player_mode="random")
|
||||
first = new_game("ABCDEFGH", ["A", "B", "C"], content_pack, random_settings, seed=8)
|
||||
repeated = new_game("ABCDEFGH", ["A", "B", "C"], content_pack, random_settings, seed=8)
|
||||
assert first.first_seat == repeated.first_seat
|
||||
assert 0 <= first.first_seat < 3
|
||||
|
||||
|
||||
def test_excess_tokens_can_be_discarded_and_returned_to_supply(content_pack) -> None:
|
||||
settings = GameSettings(token_limit=1, first_player_mode="selected", first_player_seat=0)
|
||||
state = new_game("ABCDEFGH", ["A", "B"], content_pack, settings, seed=6)
|
||||
colors = list(content_pack.resource_ids[:3])
|
||||
|
||||
state = apply_command(
|
||||
state,
|
||||
command(state, "take_distinct", {"resources": colors}),
|
||||
content_pack,
|
||||
settings,
|
||||
actor_seat=0,
|
||||
)
|
||||
assert state.pending is not None
|
||||
assert state.pending.kind == "discard_tokens"
|
||||
assert state.pending.amount == 2
|
||||
|
||||
discard = legal_commands(state, content_pack, settings, 0)[0]
|
||||
returned = sum(discard.payload["tokens"].values())
|
||||
state = apply_command(state, discard, content_pack, settings, actor_seat=0)
|
||||
|
||||
assert returned == 2
|
||||
assert sum(state.players[0].tokens.values()) == 1
|
||||
assert state.pending is None
|
||||
assert state.current_seat == 1
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from python.gems.content import ContentPackError, content_pack_schema, parse_content_pack
|
||||
|
||||
|
||||
def test_schema_uses_neutral_patrons_name() -> None:
|
||||
assert "patrons" in content_pack_schema()["properties"]
|
||||
assert "governors" not in content_pack_schema()["properties"]
|
||||
|
||||
|
||||
def test_governors_is_accepted_as_input_alias(content_pack) -> None:
|
||||
data = content_pack.model_dump(mode="json")
|
||||
data["governors"] = data.pop("patrons")
|
||||
parsed = parse_content_pack(json.dumps(data))
|
||||
assert parsed.pack.patrons[0].id == "patron_one"
|
||||
|
||||
|
||||
def test_both_patron_names_are_rejected(content_pack) -> None:
|
||||
data = content_pack.model_dump(mode="json")
|
||||
data["governors"] = data["patrons"]
|
||||
with pytest.raises(ContentPackError, match="use patrons or governors"):
|
||||
parse_content_pack(json.dumps(data))
|
||||
|
||||
|
||||
def test_standard_symbols_are_normalized_in_canonical_pack_data(content_pack) -> None:
|
||||
data = content_pack.model_dump(mode="json")
|
||||
data["resources"][0].update(label="Onyx", symbol="B")
|
||||
data["wild_resource"].update(label="Gold", symbol="Au")
|
||||
|
||||
parsed = parse_content_pack(json.dumps(data))
|
||||
canonical = json.loads(parsed.canonical_json)
|
||||
|
||||
assert canonical["resources"][0]["symbol"] == "O"
|
||||
assert canonical["wild_resource"]["symbol"] == "G"
|
||||
|
||||
|
||||
def test_unknown_resource_reference_is_rejected(content_pack) -> None:
|
||||
data = content_pack.model_dump(mode="json")
|
||||
data["cards"][0]["cost"] = {"missing": 1}
|
||||
with pytest.raises(ContentPackError, match="unknown cost resource"):
|
||||
parse_content_pack(json.dumps(data))
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from python.gems.domain.engine import bonuses, new_game
|
||||
from python.gems.domain.models import GameSettings, Modules, OwnedCard, Requirement, RequirementKind, ResourceDefinition
|
||||
from python.gems.domain.requirements import requirements_met
|
||||
|
||||
|
||||
def test_any_color_requirements_can_be_distinct() -> None:
|
||||
requirements = [
|
||||
Requirement(id="first", kind=RequirementKind.ANY_COLOR, count=2),
|
||||
Requirement(id="second", kind=RequirementKind.ANY_COLOR, count=2, distinct_from=["first"]),
|
||||
]
|
||||
assert requirements_met(requirements, {"pearl": 2, "wave": 2})
|
||||
assert not requirements_met(requirements, {"pearl": 3, "wave": 1})
|
||||
|
||||
|
||||
def test_light_resource_colors_use_dark_token_lettering() -> None:
|
||||
light = ResourceDefinition(id="light", label="Light", symbol="W", color="#f4f2ed")
|
||||
gold = ResourceDefinition(id="gold", label="Gold", symbol="Au", color="#d9a928")
|
||||
dark = ResourceDefinition(id="dark", label="Dark", symbol="B", color="#151719")
|
||||
assert light.ink_color == "#111111"
|
||||
assert gold.ink_color == "#ffffff"
|
||||
assert dark.ink_color == "#ffffff"
|
||||
|
||||
|
||||
def test_standard_resources_store_conventional_symbols() -> None:
|
||||
"""Standard gem labels normalize legacy abbreviations in pack data."""
|
||||
standard = {
|
||||
"Onyx": "O",
|
||||
"Sapphire": "S",
|
||||
"Emerald": "E",
|
||||
"Ruby": "R",
|
||||
"Diamond": "D",
|
||||
"Gold": "G",
|
||||
}
|
||||
|
||||
for label, expected in standard.items():
|
||||
resource = ResourceDefinition(id=label.casefold(), label=label, symbol="?", color="#334455")
|
||||
assert resource.symbol == expected
|
||||
|
||||
custom = ResourceDefinition(id="pearl", label="Pearl", symbol="P", color="#334455")
|
||||
assert custom.symbol == "P"
|
||||
|
||||
|
||||
def test_all_modules_deal_their_content(content_pack) -> None:
|
||||
settings = GameSettings(modules=Modules(objectives=True, outposts=True, eastern_decks=True, fortifications=True))
|
||||
state = new_game("ABCDEFGH", ["A", "B"], content_pack, settings, seed=5)
|
||||
assert "eastern:1" in state.markets
|
||||
assert state.available_objectives == ["goal_one"]
|
||||
assert not state.available_patrons
|
||||
assert state.players[0].fortifications_available == 3
|
||||
|
||||
|
||||
def test_multi_bonus_counts_twice_but_is_one_card(content_pack) -> None:
|
||||
settings = GameSettings(modules=Modules(eastern_decks=True))
|
||||
state = new_game("ABCDEFGH", ["A"], content_pack, settings, seed=5)
|
||||
player = state.players[0]
|
||||
player.cards.append(OwnedCard(card_id="eastern_2_1"))
|
||||
assert bonuses(player, content_pack)[content_pack.card("eastern_2_1").bonus_resource] == 2
|
||||
assert len(player.cards) == 1
|
||||
@@ -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