Fixes and updates

This commit is contained in:
2026-06-09 23:29:05 -07:00
parent 41731459a7
commit 2d57973cd8
19 changed files with 612 additions and 152 deletions

View File

@@ -141,13 +141,14 @@ def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -
# --- CRUD Operations --- # --- CRUD Operations ---
def create_game(db: Session) -> Game: def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
deck = cards.get_fresh_deck() deck = cards.get_fresh_deck()
game = Game( game = Game(
deck_cards=json.dumps(deck), deck_cards=json.dumps(deck),
phase="lobby", phase="lobby",
current_scene_number=1, current_scene_number=1,
extra_obstacles=0 extra_obstacles=0,
crew_name=crew_name
) )
db.add(game) db.add(game)
db.commit() db.commit()
@@ -371,13 +372,31 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
players = game.players players = game.players
# 1. Count roles (default any unselected roles to 'pirat') # Default any unselected roles to 'pirat'
for p in players: for p in players:
if p.role is None: if p.role is None:
p.role = "pirat" p.role = "pirat"
db.add(p) db.add(p)
db.commit() db.commit()
# 1. Check previous Deep players
# "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene."
# Note: Only enforce if this is scene 2+, and we have enough players to make a rotation.
if game.current_scene_number > 1 and len(players) >= 3:
for p in players:
if p.previous_role == "deep" and p.role == "deep":
return False, f"{p.name} was Deep in the last scene and must play a Pi-Rat in this scene."
# 2. Check single eligible Deep player
# If only one player is eligible to play the Deep, they must play the Deep.
if game.current_scene_number > 1:
eligible_deeps = [p for p in players if p.previous_role != "deep"]
if len(eligible_deeps) == 1:
must_be_deep_player = eligible_deeps[0]
if must_be_deep_player.role != "deep":
return False, f"{must_be_deep_player.name} is the only player eligible to play the Deep and must play the Deep in this scene."
# 3. Check counts
pirats_count = sum(1 for p in players if p.role == "pirat") pirats_count = sum(1 for p in players if p.role == "pirat")
deeps_count = sum(1 for p in players if p.role == "deep") deeps_count = sum(1 for p in players if p.role == "deep")
@@ -386,14 +405,6 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
if deeps_count < 1: if deeps_count < 1:
return False, "You need at least one Deep player." return False, "You need at least one Deep player."
# 2. Check previous Deep players
# "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene."
# Note: Only enforce if this is scene 2+, and we have enough players to make a rotation.
if game.current_scene_number > 1 and len(players) >= 3:
for p in players:
if p.previous_role == "deep" and p.role == "deep":
return False, f"{p.name} was Deep in the last scene and must play a Pi-Rat in this scene."
# Everything is valid! Start the scene. # Everything is valid! Start the scene.
# A. Reset obstacles # A. Reset obstacles
for obs in game.obstacles: for obs in game.obstacles:
@@ -747,45 +758,83 @@ def process_between_scenes_votes(db: Session, game: Game):
db.delete(v) db.delete(v)
db.commit() db.commit()
def redraw_deep_players_hands(db: Session, game: Game): def transition_to_deep_upkeep(db: Session, game_id: str):
"""
Step 3 of Between Scenes: Deep players from the previous scene can discard and redraw.
To make it simple in the UI, we'll let them click a button to redraw or automatically redraw
their hands to their maximum size. Let's empty their hand, shuffle them into the deck,
and draw back up to their new max hand size!
"""
for p in game.players:
if p.previous_role == "deep":
# Return hand to deck
hand = get_player_hand(p)
deck = get_game_deck(game)
deck.extend(hand)
random.shuffle(deck)
set_game_deck(game, deck)
set_player_hand(p, [])
# Recalculate max hand size and draw back up
max_size = calculate_max_hand_size(p, game.players)
draw_cards_for_player(db, game, p, max_size)
def proceed_to_next_scene_setup(db: Session, game_id: str):
game = get_game(db, game_id) game = get_game(db, game_id)
if not game: if not game:
return return
# Process votes & redraw hands # Process votes & rank up winner
process_between_scenes_votes(db, game) process_between_scenes_votes(db, game)
redraw_deep_players_hands(db, game)
# Increment scene number # Reset ready flags
game.current_scene_number += 1
game.phase = "scene_setup"
# Reset role options for everyone
for p in game.players: for p in game.players:
p.is_ready = False p.is_ready = False
db.add(p)
# Check if we have resting Deep players (previous_role was 'deep')
has_resting_deep = any(p.previous_role == "deep" for p in game.players)
if has_resting_deep:
game.phase = "deep_upkeep"
else:
# Fallback in case there were no Deep players
# Just advance directly to scene_setup
game.current_scene_number += 1
game.phase = "scene_setup"
for p in game.players:
p.role = None p.role = None
p.is_ready = False
db.add(p) db.add(p)
db.add(game) db.add(game)
db.commit() db.commit()
def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
player = get_player(db, player_id)
if not player:
return
game = get_game(db, player.game_id)
if not game:
return
# 1. Discard cards
hand = get_player_hand(player)
deck = get_game_deck(game)
for card in discard_cards:
if card in hand:
hand.remove(card)
deck.append(card)
random.shuffle(deck)
set_game_deck(game, deck)
set_player_hand(player, hand)
db.add(game)
db.add(player)
db.commit()
# 2. Draw cards back up to max size
# Max size for deep player is Rank + 1
max_size = player.rank + 1
if len(hand) < max_size:
diff = max_size - len(hand)
draw_cards_for_player(db, game, player, diff)
# Mark as ready
player.is_ready = True
db.add(player)
db.commit()
# Check if all resting deep players are ready
resting_deep = [p for p in game.players if p.previous_role == "deep"]
if all(p.is_ready for p in resting_deep):
# All resting deep players have refreshed their hands!
# Transition to scene_setup!
game.current_scene_number += 1
game.phase = "scene_setup"
for p in game.players:
p.is_ready = False
p.role = None
db.add(p)
db.add(game)
db.commit()

View File

@@ -13,6 +13,14 @@ engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
def create_db_and_tables(): def create_db_and_tables():
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
# Ensure crew_name column exists (migration for existing DBs)
from sqlalchemy import text
with Session(engine) as session:
try:
session.execute(text("ALTER TABLE game ADD COLUMN crew_name VARCHAR"))
session.commit()
except Exception:
pass # Column already exists or table doesn't exist yet
def get_session(): def get_session():
with Session(engine) as session: with Session(engine) as session:

View File

@@ -36,8 +36,11 @@ def home(request: Request):
return templates.TemplateResponse("index.html", {"request": request}) return templates.TemplateResponse("index.html", {"request": request})
@app.post("/game/create") @app.post("/game/create")
def create_game_route(db: Session = Depends(get_session)): def create_game_route(
game = crud.create_game(db) crew_name: str = Form(...),
db: Session = Depends(get_session)
):
game = crud.create_game(db, crew_name=crew_name.strip())
# Redirect creator to the join page with creator flag # Redirect creator to the join page with creator flag
return RedirectResponse(url=f"/game/{game.id}/join?creator=true", status_code=status.HTTP_303_SEE_OTHER) return RedirectResponse(url=f"/game/{game.id}/join?creator=true", status_code=status.HTTP_303_SEE_OTHER)
@@ -195,6 +198,13 @@ def get_game_phase_view(
if p.other_hate_from_player_id == player.id and not p.other_hate: if p.other_hate_from_player_id == player.id and not p.other_hate:
inbox_tasks.append({"player": p, "type": "hate"}) inbox_tasks.append({"player": p, "type": "hate"})
# Freshly fetch votes to avoid cache stale
from sqlmodel import select
votes = db.exec(select(Vote).where(Vote.game_id == game_id)).all()
# Calculate eligible deep players
eligible_deeps = [p for p in game.players if p.previous_role != "deep"]
context = { context = {
"request": request, "request": request,
"game": game, "game": game,
@@ -210,7 +220,9 @@ def get_game_phase_view(
"json_loads": json.loads, "json_loads": json.loads,
"deck_count": len(crud.get_game_deck(game)), "deck_count": len(crud.get_game_deck(game)),
"inbox_tasks": inbox_tasks, "inbox_tasks": inbox_tasks,
"edit_mode": edit "votes": votes,
"edit_mode": edit,
"eligible_deeps": eligible_deeps
} }
# Select template based on current game phase # Select template based on current game phase
@@ -224,6 +236,8 @@ def get_game_phase_view(
return templates.TemplateResponse("scene_partial.html", context) return templates.TemplateResponse("scene_partial.html", context)
elif game.phase == "between_scenes": elif game.phase == "between_scenes":
return templates.TemplateResponse("between_scenes_partial.html", context) return templates.TemplateResponse("between_scenes_partial.html", context)
elif game.phase == "deep_upkeep":
return templates.TemplateResponse("deep_upkeep_partial.html", context)
return HTMLResponse("<p>Unknown game state.</p>") return HTMLResponse("<p>Unknown game state.</p>")
@@ -613,7 +627,7 @@ def submit_vote_route(
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
crud.submit_rank_vote(db, game_id, player_id, nominated_id) crud.submit_rank_vote(db, game_id, player_id, nominated_id)
return HTMLResponse(status_code=204) return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
# 16. Player Ready for Next Scene # 16. Player Ready for Next Scene
@app.post("/game/{game_id}/player/{player_id}/ready-next") @app.post("/game/{game_id}/player/{player_id}/ready-next")
@@ -626,12 +640,27 @@ def ready_next_route(game_id: str, player_id: str, db: Session = Depends(get_ses
db.add(player) db.add(player)
db.commit() db.commit()
# Check if all players in the game are ready. If so, transition to next scene! # Check if all players in the game are ready. If so, transition to deep upkeep!
game = crud.get_game(db, game_id) game = crud.get_game(db, game_id)
if game and all(p.is_ready for p in game.players): if game and all(p.is_ready for p in game.players):
crud.proceed_to_next_scene_setup(db, game_id) crud.transition_to_deep_upkeep(db, game_id)
return HTMLResponse(status_code=204) return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
# 17. Confirm Hand Refresh for Deep player
@app.post("/game/{game_id}/player/{player_id}/confirm-refresh")
def confirm_deep_refresh_route(
game_id: str,
player_id: str,
discard_cards: str = Form("[]"),
db: Session = Depends(get_session)
):
try:
discards = json.loads(discard_cards)
except Exception:
discards = []
crud.confirm_deep_refresh(db, player_id, discards)
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
def main(): def main():
import argparse import argparse

View File

@@ -5,6 +5,7 @@ from sqlmodel import Field, SQLModel, Relationship
class Game(SQLModel, table=True): class Game(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4())) admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
crew_name: Optional[str] = Field(default=None)
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes" phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes"
current_scene_number: int = Field(default=1) current_scene_number: int = Field(default=1)
extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers) extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers)

View File

@@ -1729,3 +1729,46 @@ body {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
} }
/* --- Deep Upkeep Hand Refresh Drag & Drop --- */
.upkeep-drag-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin: 2rem 0;
}
@media (max-width: 768px) {
.upkeep-drag-container {
grid-template-columns: 1fr;
}
}
.upkeep-box {
min-height: 400px;
display: flex;
flex-direction: column;
}
.upkeep-box h3 {
margin-bottom: 1rem;
font-family: var(--font-heading);
}
.upkeep-flex {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.3);
border: 1px dashed var(--glass-border);
border-radius: 8px;
align-content: flex-start;
min-height: 300px;
transition: var(--transition-smooth);
}
.upkeep-box[ondragover]:hover .upkeep-flex {
border-color: var(--neon-cyan);
}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Creator Admin Panel - Game #{{ game.id[:8] }}{% endblock %} {% block title %}Creator Admin Panel - {% if game.crew_name %}{{ game.crew_name }}{% else %}Game #{{ game.id[:8] }}{% endif %}{% endblock %}
{% block content %} {% block content %}
<div class="admin-panel-container glass-panel"> <div class="admin-panel-container glass-panel">

View File

@@ -8,19 +8,19 @@
<h3>1. Pirate Ranking (Voting)</h3> <h3>1. Pirate Ranking (Voting)</h3>
<p class="section-desc">Nominate one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p> <p class="section-desc">Nominate one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p>
{% set has_voted = False %} {% set ns_vote = namespace(has_voted=False) %}
{% for v in game.votes %} {% for v in votes %}
{% if v.voter_player_id == player.id %} {% if v.voter_player_id == player.id %}
{% set has_voted = True %} {% set ns_vote.has_voted = True %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if has_voted %} {% if ns_vote.has_voted %}
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p> <p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
</div> </div>
{% else %} {% else %}
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-vote" hx-swap="none" class="vote-form inline-form"> <form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-vote" hx-target="#game-view" hx-swap="innerHTML" class="vote-form inline-form">
<div class="form-group inline-group"> <div class="form-group inline-group">
<select name="nominated_id" class="select-field" required> <select name="nominated_id" class="select-field" required>
<option value="" disabled selected>Nominate crewmate...</option> <option value="" disabled selected>Nominate crewmate...</option>
@@ -42,7 +42,7 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
{% for p in game.players %} {% for p in game.players %}
{% set voted = False %} {% set voted = False %}
{% for v in game.votes if v.voter_player_id == p.id %} {% for v in votes if v.voter_player_id == p.id %}
{% set voted = True %} {% set voted = True %}
{% endfor %} {% endfor %}
<div class="player-chip {% if voted %}voted-chip{% else %}not-voted-chip{% endif %}"> <div class="player-chip {% if voted %}voted-chip{% else %}not-voted-chip{% endif %}">
@@ -79,10 +79,19 @@
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
<!-- Next Scene Ready Up --> <!-- Next Scene Ready Up -->
<div class="action-box margin-top"> <div class="action-box margin-top">
{% set eligible_count = namespace(value=0) %}
{% for other in game.players if other.id != player.id and other.previous_role != 'deep' %}
{% set eligible_count.value = eligible_count.value + 1 %}
{% endfor %}
{% if eligible_count.value > 0 and not ns_vote.has_voted %}
<p class="info-text gold-text" style="margin-bottom:0.5rem;">⚠️ You must nominate a crewmate above before you can ready up.</p>
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
{% else %}
{% if player.is_ready %} {% if player.is_ready %}
<div class="waiting-box"> <div class="waiting-box">
<p>Ready! Waiting for other players to ready up...</p> <p>Ready! Waiting for other players to ready up...</p>
@@ -90,12 +99,14 @@
</div> </div>
{% else %} {% else %}
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/ready-next" <button hx-post="/game/{{ game.id }}/player/{{ player.id }}/ready-next"
hx-swap="none" hx-target="#game-view"
hx-swap="innerHTML"
class="btn btn-primary btn-large glow-effect"> class="btn btn-primary btn-large glow-effect">
Ready for Next Scene Ready for Next Scene
</button> </button>
{% endif %} {% endif %}
</div> {% endif %}
</div>
</div> </div>
<!-- Hidden phase-check trigger --> <!-- Hidden phase-check trigger -->
@@ -103,3 +114,5 @@
hx-trigger="every 2s" hx-trigger="every 2s"
hx-swap="none" hx-swap="none"
style="display:none;"></div> style="display:none;"></div>
{% include "header_status_snippet.html" %}

View File

@@ -118,3 +118,5 @@
hx-trigger="every 2s" hx-trigger="every 2s"
hx-swap="none" hx-swap="none"
style="display:none;"></div> style="display:none;"></div>
{% include "header_status_snippet.html" %}

View File

@@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Game #{{ game.id[:8] }} - {% if player %}{{ player.name }}{% else %}Lobby{% endif %}{% endblock %} {% block title %}{% if game.crew_name %}{{ game.crew_name }}{% else %}Game #{{ game.id[:8] }}{% endif %} - {% if player %}{{ player.name }}{% else %}Lobby{% endif %}{% endblock %}
{% block header_extra %} {% block header_extra %}
<div class="header-status"> <div id="header-status" class="header-status">
{% if player %} {% if player %}
<span class="player-pill"><span class="bullet"></span> {{ player.name }} (Rank {{ player.rank }})</span> <span class="player-pill"><span class="bullet"></span> {{ player.name }} (Rank {{ player.rank }})</span>
{% endif %} {% endif %}

View File

@@ -0,0 +1,121 @@
{% if player.previous_role == "deep" %}
<div class="deep-upkeep-view text-center">
<h2>Resting Deep Upkeep</h2>
<p class="description">Select which cards you want to keep and which you want to discard. Drag cards between the boxes, then click "Confirm Hand Refresh". You will draw back up to your maximum hand size of {{ player.rank + 1 }} cards.</p>
<div class="upkeep-drag-container">
<!-- Keep Box -->
<div class="card glass-panel upkeep-box" id="keep-box" ondragover="allowDrop(event)" ondrop="dropToKeep(event)">
<h3>Keep (Current Hand)</h3>
<div class="hand-flex upkeep-flex" id="keep-list">
{% for card in hand %}
{% set parsed = parse_card(card) %}
<div class="card-large suit-{{ parsed.suit.lower() }} {% if parsed.is_joker %}joker-card{% endif %}"
draggable="true"
ondragstart="drag(event)"
id="card-{{ card }}"
data-card-code="{{ card }}">
<div class="card-corner top-left">
<span class="val">{{ parsed.value }}</span>
<span class="suit">{{ parsed.symbol }}</span>
</div>
<div class="card-center">
{% if parsed.is_joker %}
🃏
{% else %}
<span class="giant-symbol">{{ parsed.symbol }}</span>
{% endif %}
</div>
<div class="card-corner bottom-right">
<span class="val">{{ parsed.value }}</span>
<span class="suit">{{ parsed.symbol }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Discard Box -->
<div class="card glass-panel upkeep-box" id="discard-box" ondragover="allowDrop(event)" ondrop="dropToDiscard(event)">
<h3 style="color: var(--red-suit);">Discard</h3>
<div class="hand-flex upkeep-flex" id="discard-list">
<!-- Drop discarded cards here -->
</div>
</div>
</div>
<div class="action-box margin-top">
{% if player.is_ready %}
<div class="waiting-box" style="justify-content: center;">
<p>Upkeep confirmed! Waiting for other resting Deep players...</p>
<div class="spinner-small"></div>
</div>
{% else %}
<form id="confirm-refresh-form" hx-post="/game/{{ game.id }}/player/{{ player.id }}/confirm-refresh" hx-target="#game-view" hx-swap="innerHTML">
<!-- Hidden input to submit which cards are discarded -->
<input type="hidden" name="discard_cards" id="discard-input" value="[]">
<button type="submit" class="btn btn-primary btn-large glow-effect">Confirm Hand Refresh</button>
</form>
{% endif %}
</div>
</div>
<script>
function allowDrop(ev) {
ev.preventDefault();
}
function drag(ev) {
ev.dataTransfer.setData("text/plain", ev.currentTarget.id);
}
function dropToKeep(ev) {
ev.preventDefault();
var data = ev.dataTransfer.getData("text/plain");
var cardEl = document.getElementById(data);
if (cardEl) {
document.getElementById("keep-list").appendChild(cardEl);
updateInputs();
}
}
function dropToDiscard(ev) {
ev.preventDefault();
var data = ev.dataTransfer.getData("text/plain");
var cardEl = document.getElementById(data);
if (cardEl) {
document.getElementById("discard-list").appendChild(cardEl);
updateInputs();
}
}
function updateInputs() {
var discardList = document.getElementById("discard-list");
var discards = [];
for (var i = 0; i < discardList.children.length; i++) {
var code = discardList.children[i].getAttribute("data-card-code");
if (code) {
discards.push(code);
}
}
document.getElementById("discard-input").value = JSON.stringify(discards);
}
</script>
{% else %}
<div class="deep-upkeep-waiting text-center glass-panel" style="max-width:600px; margin:4rem auto; padding:3rem;">
<h2>🌊 Resting Deep Upkeep</h2>
<p class="description">The players who controlled the Deep in the previous scene are currently refreshing their hands.</p>
<div class="waiting-box" style="justify-content: center; margin-top:2rem;">
<p>Waiting for resting Deep players to confirm their hand refreshes...</p>
<div class="spinner"></div>
</div>
</div>
{% endif %}
<!-- Hidden phase-check trigger -->
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}"
hx-trigger="every 2s"
hx-swap="none"
style="display:none;"></div>
{% include "header_status_snippet.html" %}

View File

@@ -0,0 +1,6 @@
<div id="header-status" hx-swap-oob="true" class="header-status">
{% if player %}
<span class="player-pill"><span class="bullet"></span> {{ player.name }} (Rank {{ player.rank }})</span>
{% endif %}
<span class="phase-pill">{{ game.phase.replace('_', ' ').title() }}</span>
</div>

View File

@@ -12,8 +12,11 @@
<div class="welcome-actions"> <div class="welcome-actions">
<div class="action-card glass-panel text-center"> <div class="action-card glass-panel text-center">
<h3>Start a New Adventure</h3> <h3>Start a New Adventure</h3>
<p>Generate a fresh game deck and take command as the crew creator.</p> <p>Generate a fresh game deck and name your crew of mathematical pirates.</p>
<form action="/game/create" method="POST"> <form action="/game/create" method="POST">
<div class="form-group">
<input type="text" name="crew_name" placeholder="Name your Crew (e.g. The Math-Gat Rats)" required class="input-field text-center">
</div>
<button type="submit" class="btn btn-primary btn-large glow-effect">Create Game</button> <button type="submit" class="btn btn-primary btn-large glow-effect">Create Game</button>
</form> </form>
</div> </div>

View File

@@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="join-container glass-panel text-center"> <div class="join-container glass-panel text-center">
<h2>Join the Pi-Rat Crew!</h2> <h2>Join the {% if game.crew_name %}{{ game.crew_name }}{% else %}Pi-Rat{% endif %} Crew!</h2>
<p>The mathematical transformation spell is about to cast. Enter your name below to stow away in the cargo hold.</p> <p>The mathematical transformation spell is about to cast. Enter your name below to stow away in the cargo hold.</p>
<form action="/game/{{ game.id }}/join" method="POST" class="join-form"> <form action="/game/{{ game.id }}/join" method="POST" class="join-form">

View File

@@ -1,5 +1,5 @@
<div class="lobby-view text-center"> <div class="lobby-view text-center">
<h2>Ship's Hold (Lobby)</h2> <h2>{% if game.crew_name %}{{ game.crew_name }}{% else %}Ship's Hold{% endif %} (Lobby)</h2>
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p> <p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
<div class="lobby-status-box glass-panel"> <div class="lobby-status-box glass-panel">
@@ -64,3 +64,5 @@
hx-trigger="every 2s" hx-trigger="every 2s"
hx-swap="none" hx-swap="none"
style="display:none;"></div> style="display:none;"></div>
{% include "header_status_snippet.html" %}

View File

@@ -294,6 +294,8 @@
hx-swap="none" hx-swap="none"
style="display:none;"></div> style="display:none;"></div>
{% include "header_status_snippet.html" %}
<script> <script>
window.dragStart = function(ev) { window.dragStart = function(ev) {
ev.dataTransfer.setData("text/plain", ev.currentTarget.getAttribute("data-card-code")); ev.dataTransfer.setData("text/plain", ev.currentTarget.getAttribute("data-card-code"));

View File

@@ -25,3 +25,17 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="roster-section margin-top">
<h4>⏳ Yet to Choose</h4>
<div class="player-chips list-chips">
{% for p in game.players if not p.role %}
<div class="player-chip undecided-chip">
<span class="name">{{ p.name }}</span>
<span class="sub-label">Rank {{ p.rank }}</span>
</div>
{% else %}
<p class="empty-text">Everyone has chosen!</p>
{% endfor %}
</div>
</div>

View File

@@ -22,6 +22,8 @@
{% if player.previous_role == "deep" %} {% if player.previous_role == "deep" %}
{% set force_pirat = True %} {% set force_pirat = True %}
{% set locked_role = "pirat" %} {% set locked_role = "pirat" %}
{% elif eligible_deeps|length == 1 %}
{% set locked_role = "deep" %}
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -30,8 +32,10 @@
<p class="info-text"> <p class="info-text">
{% if game.current_scene_number == 1 %} {% if game.current_scene_number == 1 %}
Based on starting Ranks, you are locked into: Based on starting Ranks, you are locked into:
{% else %} {% elif player.previous_role == "deep" %}
You were Deep in the previous scene! You are locked into: You were Deep in the previous scene! You are locked into:
{% else %}
You are the only player eligible to play the Deep! You are locked into:
{% endif %} {% endif %}
</p> </p>
<div class="role-badge large-badge {{ locked_role }}"> <div class="role-badge large-badge {{ locked_role }}">
@@ -66,33 +70,7 @@
hx-get="/game/{{ game.id }}/scene/roster" hx-get="/game/{{ game.id }}/scene/roster"
hx-trigger="every 2s" hx-trigger="every 2s"
hx-swap="innerHTML"> hx-swap="innerHTML">
<div class="roster-section"> {% include "scene_roster_snippet.html" %}
<h4>🌊 The Deep (NPCs & Hazards)</h4>
<div class="player-chips list-chips">
{% for p in game.players if p.role == 'deep' %}
<div class="player-chip deep-chip">
<span class="name">{{ p.name }}</span>
<span class="sub-label">Rank {{ p.rank }}</span>
</div>
{% else %}
<p class="empty-text">None selected</p>
{% endfor %}
</div>
</div>
<div class="roster-section margin-top">
<h4>🐀 Pi-Rats (Crew Players)</h4>
<div class="player-chips list-chips">
{% for p in game.players if p.role == 'pirat' %}
<div class="player-chip pirat-chip">
<span class="name">{{ p.name }}</span>
<span class="sub-label">Rank {{ p.rank }}{% if is_captain(p, game.players) %} (Captain) {% endif %}</span>
</div>
{% else %}
<p class="empty-text">None selected</p>
{% endfor %}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -117,3 +95,5 @@
hx-trigger="every 2s" hx-trigger="every 2s"
hx-swap="none" hx-swap="none"
style="display:none;"></div> style="display:none;"></div>
{% include "header_status_snippet.html" %}

View File

@@ -1,6 +1,6 @@
{% for p in game.players %} {% for p in game.players %}
{% set voted = False %} {% set voted = False %}
{% for v in game.votes if v.voter_player_id == p.id %} {% for v in votes if v.voter_player_id == p.id %}
{% set voted = True %} {% set voted = True %}
{% endfor %} {% endfor %}
<div class="player-chip {% if voted %}voted-chip{% else %}not-voted-chip{% endif %}"> <div class="player-chip {% if voted %}voted-chip{% else %}not-voted-chip{% endif %}">

View File

@@ -318,3 +318,190 @@ def test_set_role_endpoint():
assert player.role == "pirat" assert player.role == "pirat"
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
def test_game_creation_with_crew_name(session):
game = crud.create_game(session, crew_name="The Black Gats")
assert game.id is not None
assert game.crew_name == "The Black Gats"
assert game.phase == "lobby"
def test_create_game_endpoint():
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel, create_engine, Session, select
from pirats.main import app
from pirats import crud
# Setup in-memory DB
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
def get_session_override():
yield session
# Dynamically find the get_session function object used by the route
get_session_func = None
for route in app.routes:
if route.path == "/game/create":
for dep in route.dependant.dependencies:
if dep.name == "db":
get_session_func = dep.call
break
if get_session_func:
break
assert get_session_func is not None, "Could not find get_session dependency in route"
app.dependency_overrides[get_session_func] = get_session_override
client = TestClient(app, base_url="http://testserver")
try:
response = client.post("/game/create", data={"crew_name": "The Math Mutineers"})
assert response.status_code in [200, 303] # starlette redirects can be 303
# Verify the database has the game with the crew name
from pirats.models import Game
games = session.exec(select(Game)).all()
assert len(games) == 1
assert games[0].crew_name == "The Math Mutineers"
finally:
app.dependency_overrides.clear()
def test_transition_to_deep_upkeep(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2")
p1.previous_role = "deep"
p2.previous_role = "pirat"
session.add_all([p1, p2])
session.commit()
crud.transition_to_deep_upkeep(session, game.id)
session.refresh(game)
assert game.phase == "deep_upkeep"
def test_confirm_deep_refresh(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p1.previous_role = "deep"
p1.role = "deep"
p1.rank = 2
# Hand has 3 cards: ['2C', '3C', '4C']
p1.hand_cards = json.dumps(["2C", "3C", "4C"])
# Rest of deck has some known cards
game.deck_cards = json.dumps(["5H", "6H", "7H", "8H"])
session.add_all([game, p1])
session.commit()
# Stub shuffle to keep card order deterministic
import random
orig_shuffle = random.shuffle
random.shuffle = lambda x: None
try:
# Discard 2C and 3C
crud.confirm_deep_refresh(session, p1.id, ["2C", "3C"])
finally:
random.shuffle = orig_shuffle
session.refresh(p1)
session.refresh(game)
# Hand should not contain 2C and 3C, and should have drawn 5H and 6H
hand = json.loads(p1.hand_cards)
assert "2C" not in hand
assert "3C" not in hand
assert "4C" in hand
assert "5H" in hand
assert "6H" in hand
assert len(hand) == 3
# Discarded cards should be at the end of the deck
deck = json.loads(game.deck_cards)
assert "2C" in deck
assert "3C" in deck
assert deck == ["7H", "8H", "2C", "3C"]
# Since P1 was the only resting deep player and is now ready, phase should have advanced to scene_setup!
assert game.phase == "scene_setup"
def test_submit_vote_endpoint():
from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlmodel import SQLModel, create_engine, Session
from pirats.main import app
from pirats import crud
# Setup in-memory DB
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
# Create game and players
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2")
game.phase = "between_scenes"
session.add(game)
session.commit()
def get_session_override():
yield session
from pirats.database import get_session
app.dependency_overrides[get_session] = get_session_override
client = TestClient(app)
try:
response = client.post(f"/game/{game.id}/player/{p1.id}/submit-vote", data={"nominated_id": p2.id})
# TestClient follows redirects by default
assert response.status_code == 200
assert "Vote submitted" in response.text
finally:
app.dependency_overrides.clear()
def test_single_eligible_deep_player(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2")
p3 = crud.add_player(session, game.id, "P3")
# Scene number > 1
game.current_scene_number = 2
# 2 players were Deep in the last scene, 1 was Pi-Rat
p1.previous_role = "deep"
p2.previous_role = "deep"
p3.previous_role = "pirat"
# Set their current roles to something invalid first
# P3 is the only one eligible to be Deep, but plays Pi-Rat. This should fail!
p1.role = "pirat"
p2.role = "pirat"
p3.role = "pirat"
session.add_all([game, p1, p2, p3])
session.commit()
success, msg = crud.confirm_scene_setup(session, game.id)
assert not success
assert "is the only player eligible to play the Deep" in msg
# Now set P3 to play Deep. This should succeed!
p3.role = "deep"
session.add(p3)
session.commit()
success, msg = crud.confirm_scene_setup(session, game.id)
assert success