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

- 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:
2026-07-22 20:47:03 -04:00
parent 247e951a27
commit add7a6a848
41 changed files with 4161 additions and 1 deletions
@@ -0,0 +1,211 @@
{% if error %}<p class="alert floating" role="alert">{{ error }}</p>{% endif %}
{% if room.status == "lobby" %}
<div class="lobby-grid">
<section class="panel">
<div class="section-heading"><h2>Seats</h2><span>{{ members|length }}/4</span></div>
<ol class="seat-list">
{% for seat in members %}
<li>
<span class="seat-number">{{ seat.seat + 1 }}</span>
<span><strong>{{ seat.name }}</strong><small>{{ seat.difficulty|capitalize if seat.controller == 'ai' else ('Ready' if seat.ready else 'Not ready') }}{% if seat.is_host %} · Host{% endif %}</small></span>
{% if member.is_host and not seat.is_host %}
<form hx-post="/rooms/{{ room.code }}/seats/{{ seat.seat }}/make-host" hx-target="#room-state" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}"><button class="quiet">Make host</button>
</form>
<form hx-post="/rooms/{{ room.code }}/seats/{{ seat.seat }}/remove" hx-target="#room-state" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}"><button class="icon-button" aria-label="Remove {{ seat.name }}">×</button>
</form>
{% endif %}
</li>
{% endfor %}
</ol>
{% if member.is_host and members|length < 4 %}
<form class="inline-form" hx-post="/rooms/{{ room.code }}/seats/ai" hx-target="#room-state" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<select name="difficulty" aria-label="AI difficulty"><option value="easy">Easy AI</option><option value="medium" selected>Medium AI</option><option value="hard">Hard AI</option></select>
<button>Add bot</button>
</form>
{% endif %}
{% if member.controller == 'human' %}
<form hx-post="/rooms/{{ room.code }}/ready" hx-target="#room-state" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}"><input type="hidden" name="ready" value="{{ 'false' if member.ready else 'true' }}">
<button class="{{ 'quiet' if member.ready else 'primary' }}">{{ 'Not ready' if member.ready else 'Ready up' }}</button>
</form>
{% endif %}
</section>
<section class="panel">
<div class="section-heading"><h2>Content pack</h2>{% if room.pack %}<span class="success">Loaded and validated</span>{% else %}<span class="danger">Not loaded</span>{% endif %}</div>
{% if room.pack %}<p><strong>{{ room.pack.metadata.name }}</strong> <span class="muted">v{{ room.pack.metadata.version }}</span></p>{% else %}<p class="pack-status" role="status">No content pack is loaded. The game cannot start until the host uploads a valid JSON pack.</p>{% endif %}
{% if member.is_host %}
<form hx-post="/rooms/{{ room.code }}/pack" hx-target="#room-state" hx-swap="innerHTML" hx-encoding="multipart/form-data">
<input type="hidden" name="csrf" value="{{ csrf }}"><label>JSON file<input type="file" name="pack_file" accept="application/json,.json" required></label>
<button>Validate and use pack</button>
</form>
{% endif %}
<p class="fine-print"><a href="/schemas/content-pack-v1.json">JSON Schema</a>{% if room.pack %} · <a href="/rooms/{{ room.code }}/pack">Download active pack</a>{% endif %}</p>
</section>
<section class="panel rules-panel">
<div class="section-heading"><h2>Table rules</h2><span>Frozen on start</span></div>
{% if member.is_host %}<div class="preset-row">
{% for key, label in [('classic','Classic'),('objectives','Objective race'),('objectives_outposts','Objective + outpost'),('eastern_fortifications','Eastern + fortification'),('all','All modules')] %}
<form hx-post="/rooms/{{ room.code }}/settings/preset" hx-target="#room-state" hx-swap="innerHTML"><input type="hidden" name="csrf" value="{{ csrf }}"><input type="hidden" name="preset" value="{{ key }}"><button class="quiet">{{ label }}</button></form>
{% endfor %}
</div>{% endif %}
<form hx-post="/rooms/{{ room.code }}/settings" hx-target="#room-state" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<fieldset {% if not member.is_host %}disabled{% endif %}>
<div class="settings-grid">
<label>Target score<input type="number" name="target_score" min="1" max="999" value="{{ room.settings.target_score }}"></label>
<label>Victory<select name="victory_condition"><option value="score" {% if room.settings.victory_condition == 'score' %}selected{% endif %}>Score</option><option value="objective" {% if room.settings.victory_condition == 'objective' %}selected{% endif %}>Objective</option><option value="either" {% if room.settings.victory_condition == 'either' %}selected{% endif %}>Either</option><option value="both" {% if room.settings.victory_condition == 'both' %}selected{% endif %}>Both</option></select></label>
<label>First player<select name="first_player_mode"><option value="random" {% if room.settings.first_player_mode == 'random' %}selected{% endif %}>Random</option><option value="selected" {% if room.settings.first_player_mode == 'selected' %}selected{% endif %}>Choose player</option></select></label>
<label>Chosen player<select name="first_player_seat" aria-label="Chosen first player">{% for seat in members %}<option value="{{ loop.index0 }}" {% if room.settings.first_player_seat == loop.index0 %}selected{% endif %}>{{ seat.name }}</option>{% endfor %}</select><small>Used when “Choose player” is selected.</small></label>
<label>Token limit<input type="number" name="token_limit" min="1" max="99" value="{{ room.settings.token_limit }}"></label>
<label>Reserve limit<input type="number" name="reserve_limit" min="0" max="20" value="{{ room.settings.reserve_limit }}"></label>
<label>Base market/tier<input type="number" name="base_market_size" min="1" max="10" value="{{ room.settings.base_market_size }}"></label>
<label>Eastern market/tier<input type="number" name="eastern_market_size" min="1" max="10" value="{{ room.settings.eastern_market_size }}"></label>
<label>Objectives shown<input type="number" name="objective_count" min="1" max="10" value="{{ room.settings.objective_count }}"></label>
<label>Fortifications/player<input type="number" name="fortifications_per_player" min="1" max="10" value="{{ room.settings.fortifications_per_player }}"></label>
</div>
<h3>Modules</h3>
<div class="toggle-grid">
{% for key, label in [('objectives','Objectives'),('outposts','Outposts'),('eastern_decks','Eastern decks'),('fortifications','Fortifications')] %}
<label class="toggle"><input type="checkbox" name="{{ key }}" {% if room.settings.modules[key] %}checked{% endif %}><span>{{ label }}</span></label>
{% endfor %}
</div>
<details><summary>Advanced module interactions</summary><div class="toggle-grid advanced">
{% for key, value in room.settings.interactions %}<label class="toggle"><input type="checkbox" name="{{ key }}" {% if value %}checked{% endif %}><span>{{ key|replace('_',' ')|capitalize }}</span></label>{% endfor %}
</div></details>
</fieldset>
{% if member.is_host %}<button>Save rules</button>{% endif %}
</form>
</section>
</div>
{% if member.is_host %}
<form class="start-bar" hx-post="/rooms/{{ room.code }}/start" hx-target="#room-state" hx-swap="innerHTML"><input type="hidden" name="csrf" value="{{ csrf }}"><span>{{ 'Pack loaded' if room.pack else 'Pack required' }} · {{ 'Players ready' if all_humans_ready else 'Waiting for every human to ready up' }} · Rules set</span><button class="primary"{% if not can_start %} disabled{% endif %}>Start game</button></form>
{% endif %}
{% else %}
<section class="status-strip">
<span>Round {{ state.round_number }}</span><strong>{% if room.status == 'finished' %}Game complete · {% for seat in state.winners %}{{ state.players[seat].name }}{% if not loop.last %}, {% endif %}{% endfor %}{% else %}{{ state.players[state.current_seat].name }}s turn{% endif %}</strong><span>Target {{ room.settings.target_score }}</span><details class="history-inline"><summary>History</summary><div>{% for entry in state.log[-12:]|reverse %}<p>{{ entry }}</p>{% endfor %}</div></details>{% if room.status == 'finished' %}{% if member.is_host %}<form hx-post="/rooms/{{ room.code }}/play-again" hx-target="#room-state" hx-swap="innerHTML"><input type="hidden" name="csrf" value="{{ csrf }}"><button class="primary">Play again</button></form>{% else %}<span class="muted">Waiting for the host</span>{% endif %}{% endif %}
</section>
<div class="game-layout">
<aside class="board-sidebar">
<section class="players-stack" aria-label="Players">
{% for player in state.players %}
<details class="sidebar-player panel compact{% if player.seat == state.current_seat %} active{% endif %}">
<summary class="sidebar-player-summary">
<div class="sidebar-player-heading"><strong>{{ player.name }}</strong><span>{% if member_by_seat[player.seat].controller == 'ai' %}Bot · {% endif %}{{ scores[player.seat] }}◆</span></div>
<div class="count-legend"><span>Color</span><span>Gems / Cards</span></div>
<div class="player-color-counts">
{% for resource_id in normal_resource_ids %}{% set resource = resources[resource_id] %}
<span class="player-color-count" style="--gem-color:{{ resource.color }}" title="{{ resource.label }}: {{ player.tokens[resource_id] }} gems, {{ bonus_counts[player.seat].get(resource_id, 0) }} cards"><b>{{ resource.symbol }}</b><span>{{ player.tokens[resource_id] }} / {{ bonus_counts[player.seat].get(resource_id, 0) }}</span></span>
{% endfor %}
{% set wild = pack.wild_resource %}<span class="player-color-count" style="--gem-color:{{ wild.color }}" title="{{ wild.label }}: {{ player.tokens[wild.id] }} gems"><b>{{ wild.symbol }}</b><span>{{ player.tokens[wild.id] }} / 0</span></span>
</div>
<small class="player-detail-hint">Click for card details · {{ player.cards|length }} owned · {{ player.reserved|length }} reserved</small>
</summary>
<div class="sidebar-player-details">
{% if player.cards %}<div class="owned-card-grid">
{% for owned in player.cards %}{% set card = cards[owned.card_id] %}{% set bonus_id = owned.copied_resource or card.bonus_resource %}
<details class="owned-card-inspector" {% if bonus_id %}style="--gem-color:{{ resources[bonus_id].color }};--gem-ink:{{ resources[bonus_id].ink_color }}"{% endif %}>
<summary><span class="owned-card-bonus">{{ resources[bonus_id].symbol if bonus_id else '◇' }}</span><strong>{{ card.label }}</strong><span>{{ card.points }}◆</span></summary>
<div class="owned-card-detail"><p>Cost {% for resource_id, value in card.cost.items() %}<span class="resource-cost" style="--gem-color:{{ resources[resource_id].color }}">{{ resources[resource_id].symbol }} {{ value }}</span>{% else %}<span>Free</span>{% endfor %}</p><p>Effect: {{ card.effect.kind|replace('_',' ') }}{% if owned.copied_resource %} · copied {{ resources[owned.copied_resource].label }}{% endif %}</p></div>
</details>
{% endfor %}
</div>{% else %}<p class="muted">No owned cards yet.</p>{% endif %}
{% if player.patrons %}<div class="claimed-patrons"><strong>Claimed patrons</strong>{% for patron_id in player.patrons %}<span>{{ patrons[patron_id].label }} · {{ patrons[patron_id].points }}◆</span>{% endfor %}</div>{% endif %}
{% if player.seat == member.seat and player.reserved %}
<div class="reserved-cards"><h3>Your reserved cards</h3>
{% for card_id in player.reserved %}{% set card = cards[card_id] %}{% set purchase = card_actions.get(card_id, {}).get('purchase') %}
<div class="reserved-card{% if purchase %} actionable{% endif %}"><div class="reserved-card-face"><strong>{{ card.label }}</strong><span>{{ card.points }}◆ · {% for resource_id, value in card.cost.items() %}{{ resources[resource_id].symbol }} {{ value }} {% endfor %}</span></div><div class="card-actions">{% if purchase %}<form hx-post="/rooms/{{ room.code }}/commands" hx-target="#room-state" hx-swap="innerHTML"><input type="hidden" name="csrf" value="{{ csrf }}"><input type="hidden" name="command_json" value="{{ purchase|e }}"><button class="primary">Purchase</button></form>{% else %}<button class="primary" disabled>Purchase</button>{% endif %}<button disabled>Reserved</button></div></div>
{% endfor %}
</div>
{% endif %}
{% if member.is_host and player.seat in offline_seats and player.seat != member.seat and room.status == 'playing' %}<form hx-post="/rooms/{{ room.code }}/seats/{{ player.seat }}/replace-with-ai" hx-target="#room-state" hx-swap="innerHTML"><input type="hidden" name="csrf" value="{{ csrf }}"><input type="hidden" name="difficulty" value="medium"><button>Replace with AI</button></form>{% endif %}
</div>
</details>
{% endfor %}
</section>
<section class="supply right-supply panel compact">
<div class="patrons-section">
<div class="section-heading"><h2>Patrons</h2><span>{{ state.available_patrons|length }} available</span></div>
<div class="patron-list">
{% for patron_id in state.available_patrons %}{% set patron = patrons[patron_id] %}
<article class="patron-card"><strong>{{ patron.points }}◆</strong><span><b>{{ patron.label }}</b><small>{% for requirement in patron.requirements %}{% if requirement.resource %}<span class="requirement-chip resource-cost" style="--gem-color:{{ resources[requirement.resource].color }}">{{ resources[requirement.resource].symbol }} {{ requirement.count }}</span>{% else %}<span class="requirement-chip">Any {{ requirement.count }}</span>{% endif %}{% endfor %}</small></span></article>
{% else %}<p class="muted">No unclaimed patrons.</p>
{% endfor %}
</div>
</div>
<div class="section-heading"><h2>Gem piles</h2><span>Supply</span></div>
{% if member.seat == state.current_seat and not state.pending and room.status == 'playing' %}
<form class="gem-picker" hx-post="/rooms/{{ room.code }}/take-gems" hx-target="#room-state" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<p class="fine-print">Check 3 different gems, or use Take 2 beside one pile.</p>
{% for resource_id in normal_resource_ids %}{% set resource = resources[resource_id] %}{% set amount = state.supply[resource_id] %}
<div class="gem-pile{% if not amount %} empty{% endif %}" style="--gem-color:{{ resource.color }};--gem-ink:{{ resource.ink_color }}">
<input id="gem-{{ resource_id }}" type="checkbox" name="resources" value="{{ resource_id }}" {% if not amount %}disabled{% endif %}>
<label class="gem-choice" for="gem-{{ resource_id }}"><span class="gem-disc{% if resource.ink_color == '#111111' %} light-gem{% endif %}">{{ resource.symbol }}</span><span><strong>{{ resource.label }}</strong><small>{{ amount }} available</small></span></label>
<button class="take-pair" type="submit" name="pair_resource" value="{{ resource_id }}" {% if resource_id not in double_resource_ids %}disabled{% endif %}>Take 2</button>
</div>
{% endfor %}
<button class="primary take-checked">Take checked gems</button>
</form>
{% else %}
<div class="gem-picker readonly">
{% for resource_id in normal_resource_ids %}{% set resource = resources[resource_id] %}
<div class="gem-pile" style="--gem-color:{{ resource.color }};--gem-ink:{{ resource.ink_color }}"><span class="gem-disc{% if resource.ink_color == '#111111' %} light-gem{% endif %}">{{ resource.symbol }}</span><span><strong>{{ resource.label }}</strong><small>{{ state.supply[resource_id] }} available</small></span></div>
{% endfor %}
</div>
{% endif %}
{% set wild = pack.wild_resource %}
<div class="wild-pile" style="--gem-color:{{ wild.color }};--gem-ink:{{ wild.ink_color }}"><span class="gem-disc{% if wild.ink_color == '#111111' %} light-gem{% endif %}">{{ wild.symbol }}</span><span><strong>{{ wild.label }}</strong><small>{{ state.supply[wild.id] }} available · gained by reserving</small></span></div>
</section>
</aside>
<section class="market">
{% for key, market in state.markets.items() %}
<div class="market-row">
<div class="tier-label"><strong>{{ key|replace(':',' · Tier ') }}</strong><span>{{ state.decks[key] }} left</span>{% if deck_reserve_actions.get(key) %}<form hx-post="/rooms/{{ room.code }}/commands" hx-target="#room-state" hx-swap="innerHTML"><input type="hidden" name="csrf" value="{{ csrf }}"><input type="hidden" name="command_json" value="{{ deck_reserve_actions[key]|e }}"><button class="quiet compact-button">Reserve top card</button></form>{% else %}<button class="quiet compact-button" disabled>Reserve top card</button>{% endif %}</div>
<div class="card-row">
{% for card_id in market %}{% set card = cards[card_id] %}{% set available = card_actions.get(card_id, {}) %}
<article class="game-card{% if available %} actionable{% endif %}">
<div class="card-face"><div class="card-top"><span>{{ card.points }}◆</span>{% if card.bonus_resource %}<span class="gem-disc card-bonus-token{% if resources[card.bonus_resource].ink_color == '#111111' %} light-gem{% endif %}" style="--gem-color:{{ resources[card.bonus_resource].color }};--gem-ink:{{ resources[card.bonus_resource].ink_color }}" role="img" aria-label="{{ resources[card.bonus_resource].label }} bonus" title="{{ resources[card.bonus_resource].label }} bonus">{{ resources[card.bonus_resource].symbol }}</span>{% else %}<span></span>{% endif %}</div><h3>{{ card.label }}</h3><p>Tier {{ card.tier }} · {{ card.effect.kind|replace('_',' ') }}</p><div class="cost">{% for resource_id, value in card.cost.items() %}<span class="resource-cost" style="--gem-color:{{ resources[resource_id].color }}">{{ resources[resource_id].symbol }} {{ value }}</span>{% endfor %}</div>{% if state.fortifications.get(card_id) %}<small>Fortified</small>{% endif %}</div>
<div class="card-actions">
{% if available.get('purchase') %}<form hx-post="/rooms/{{ room.code }}/commands" hx-target="#room-state" hx-swap="innerHTML"><input type="hidden" name="csrf" value="{{ csrf }}"><input type="hidden" name="command_json" value="{{ available['purchase']|e }}"><button class="primary">Purchase</button></form>{% else %}<button class="primary" disabled>Purchase</button>{% endif %}
{% if available.get('reserve') %}<form hx-post="/rooms/{{ room.code }}/commands" hx-target="#room-state" hx-swap="innerHTML"><input type="hidden" name="csrf" value="{{ csrf }}"><input type="hidden" name="command_json" value="{{ available['reserve']|e }}"><button>Reserve</button></form>{% else %}<button disabled>Reserve</button>{% endif %}
</div>
</article>
{% endfor %}
</div>
</div>
{% endfor %}
</section>
</div>
{% set viewing_player = state.players[member.seat] %}
<section class="player-gem-dock" aria-label="{{ viewing_player.name }} gems and cards">
<strong>{{ viewing_player.name }}</strong><span class="hand-total"><strong>{{ viewing_player.tokens.values()|sum }} / {{ room.settings.token_limit }}</strong><small>Gems</small></span><span class="hand-legend">Gems / Cards</span>
<div class="player-gem-hand">
{% for resource_id in normal_resource_ids %}{% set resource = resources[resource_id] %}<span class="hand-gem{% if resource.ink_color == '#111111' %} light-gem{% endif %}" style="--gem-color:{{ resource.color }};--gem-ink:{{ resource.ink_color }}" title="{{ resource.label }}: {{ viewing_player.tokens[resource_id] }} gems, {{ bonus_counts[viewing_player.seat].get(resource_id, 0) }} cards"><b>{{ resource.symbol }}</b><span><strong>{{ viewing_player.tokens[resource_id] }}</strong><i>/</i><strong>{{ bonus_counts[viewing_player.seat].get(resource_id, 0) }}</strong></span></span>{% endfor %}
{% set wild = pack.wild_resource %}<span class="hand-gem wild{% if wild.ink_color == '#111111' %} light-gem{% endif %}" style="--gem-color:{{ wild.color }};--gem-ink:{{ wild.ink_color }}" title="{{ wild.label }}: {{ viewing_player.tokens[wild.id] }} gems, 0 cards"><b>{{ wild.symbol }}</b><span><strong>{{ viewing_player.tokens[wild.id] }}</strong><i>/</i><strong>0</strong></span></span>
</div>
{% if viewing_player.reserved %}<div class="bottom-reserved"><span class="hand-legend">Reserved</span>{% for card_id in viewing_player.reserved %}{% set card = cards[card_id] %}{% set purchase = card_actions.get(card_id, {}).get('purchase') %}<div class="bottom-reserved-card"><span><strong>{{ card.label }}</strong><small>{{ card.points }}◆ · {% for resource_id, value in card.cost.items() %}{{ resources[resource_id].symbol }}{{ value }} {% endfor %}</small></span>{% if purchase %}<form hx-post="/rooms/{{ room.code }}/commands" hx-target="#room-state" hx-swap="innerHTML"><input type="hidden" name="csrf" value="{{ csrf }}"><input type="hidden" name="command_json" value="{{ purchase|e }}"><button class="primary">Purchase</button></form>{% else %}<button class="primary" disabled>Purchase</button>{% endif %}</div>{% endfor %}</div>{% endif %}
</section>
{% if room.status != 'finished' and member.seat == state.current_seat and state.pending %}
<section class="action-dock"><div><p class="eyebrow">Your move</p><strong>Resolve: {{ state.pending.kind|replace('_',' ') }}</strong>{% if state.pending.kind == 'discard_tokens' %}<small>Return exactly {{ state.pending.amount }} excess gem{{ '' if state.pending.amount == 1 else 's' }}.</small>{% endif %}</div>
{% if state.pending.kind == 'discard_tokens' %}
<form class="discard-picker" hx-post="/rooms/{{ room.code }}/discard-tokens" hx-target="#room-state" hx-swap="innerHTML">
<input type="hidden" name="csrf" value="{{ csrf }}">
<div class="discard-colors">{% for resource_id in all_resource_ids %}{% set resource = resources[resource_id] %}<label class="discard-color" style="--gem-color:{{ resource.color }};--gem-ink:{{ resource.ink_color }}"><span class="gem-disc{% if resource.ink_color == '#111111' %} light-gem{% endif %}">{{ resource.symbol }}</span><span>{{ resource.label }}<small>{{ viewing_player.tokens[resource_id] }} held</small></span><input type="number" name="token_{{ resource_id }}" min="0" max="{{ viewing_player.tokens[resource_id] }}" value="0" aria-label="{{ resource.label }} gems to return"></label>{% endfor %}</div>
<button class="primary">Return {{ state.pending.amount }} gem{{ '' if state.pending.amount == 1 else 's' }}</button>
</form>
{% else %}
<div class="actions">{% for label, command_json in actions %}<form hx-post="/rooms/{{ room.code }}/commands" hx-target="#room-state" hx-swap="innerHTML"><input type="hidden" name="csrf" value="{{ csrf }}"><input type="hidden" name="command_json" value="{{ command_json|e }}"><button>{{ label }}</button></form>{% else %}<span class="muted">No legal action available.</span>{% endfor %}</div>
{% endif %}
</section>
{% endif %}
{% endif %}