Files
dotfiles/python/gems/templates/partials/room_state.html
T
Richie add7a6a848
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
feat(gems): add multiplayer gem game with custom content packs
- 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
2026-07-22 20:47:03 -04:00

212 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% 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 %}