From 2d57973cd859b52c64292e49f49484503d9cc749 Mon Sep 17 00:00:00 2001 From: Tim McCarthy Date: Tue, 9 Jun 2026 23:29:05 -0700 Subject: [PATCH] Fixes and updates --- src/pirats/crud.py | 131 ++++++++---- src/pirats/database.py | 8 + src/pirats/main.py | 43 +++- src/pirats/models.py | 1 + src/pirats/static/css/style.css | 43 ++++ src/pirats/templates/admin.html | 2 +- .../templates/between_scenes_partial.html | 151 +++++++------- .../templates/character_creation_partial.html | 2 + src/pirats/templates/dashboard.html | 4 +- src/pirats/templates/deep_upkeep_partial.html | 121 ++++++++++++ .../templates/header_status_snippet.html | 6 + src/pirats/templates/index.html | 5 +- src/pirats/templates/join_form.html | 2 +- src/pirats/templates/lobby_partial.html | 4 +- src/pirats/templates/scene_partial.html | 2 + .../templates/scene_roster_snippet.html | 14 ++ src/pirats/templates/scene_setup_partial.html | 36 +--- .../templates/voting_roster_snippet.html | 2 +- tests/test_game.py | 187 ++++++++++++++++++ 19 files changed, 612 insertions(+), 152 deletions(-) create mode 100644 src/pirats/templates/deep_upkeep_partial.html create mode 100644 src/pirats/templates/header_status_snippet.html diff --git a/src/pirats/crud.py b/src/pirats/crud.py index 2377f96..26c473c 100644 --- a/src/pirats/crud.py +++ b/src/pirats/crud.py @@ -141,13 +141,14 @@ def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) - # --- CRUD Operations --- -def create_game(db: Session) -> Game: +def create_game(db: Session, crew_name: Optional[str] = None) -> Game: deck = cards.get_fresh_deck() game = Game( deck_cards=json.dumps(deck), phase="lobby", current_scene_number=1, - extra_obstacles=0 + extra_obstacles=0, + crew_name=crew_name ) db.add(game) db.commit() @@ -371,13 +372,31 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: players = game.players - # 1. Count roles (default any unselected roles to 'pirat') + # Default any unselected roles to 'pirat' for p in players: if p.role is None: p.role = "pirat" db.add(p) 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") deeps_count = sum(1 for p in players if p.role == "deep") @@ -385,14 +404,6 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: return False, "You need at least one Pi-Rat player to play." if deeps_count < 1: 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. # A. Reset obstacles @@ -747,45 +758,83 @@ def process_between_scenes_votes(db: Session, game: Game): db.delete(v) db.commit() -def redraw_deep_players_hands(db: Session, game: Game): - """ - 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): +def transition_to_deep_upkeep(db: Session, game_id: str): game = get_game(db, game_id) if not game: return - # Process votes & redraw hands + # Process votes & rank up winner process_between_scenes_votes(db, game) - redraw_deep_players_hands(db, game) - # Increment scene number - game.current_scene_number += 1 - game.phase = "scene_setup" - - # Reset role options for everyone + # Reset ready flags for p in game.players: p.is_ready = False - p.role = None 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.is_ready = False + db.add(p) + db.add(game) 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() diff --git a/src/pirats/database.py b/src/pirats/database.py index cf58669..5e42602 100644 --- a/src/pirats/database.py +++ b/src/pirats/database.py @@ -13,6 +13,14 @@ engine = create_engine(sqlite_url, connect_args={"check_same_thread": False}) def create_db_and_tables(): 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(): with Session(engine) as session: diff --git a/src/pirats/main.py b/src/pirats/main.py index 3eefc4e..cdd116c 100644 --- a/src/pirats/main.py +++ b/src/pirats/main.py @@ -36,8 +36,11 @@ def home(request: Request): return templates.TemplateResponse("index.html", {"request": request}) @app.post("/game/create") -def create_game_route(db: Session = Depends(get_session)): - game = crud.create_game(db) +def create_game_route( + 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 return RedirectResponse(url=f"/game/{game.id}/join?creator=true", status_code=status.HTTP_303_SEE_OTHER) @@ -194,6 +197,13 @@ def get_game_phase_view( inbox_tasks.append({"player": p, "type": "like"}) if p.other_hate_from_player_id == player.id and not p.other_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 = { "request": request, @@ -210,7 +220,9 @@ def get_game_phase_view( "json_loads": json.loads, "deck_count": len(crud.get_game_deck(game)), "inbox_tasks": inbox_tasks, - "edit_mode": edit + "votes": votes, + "edit_mode": edit, + "eligible_deeps": eligible_deeps } # Select template based on current game phase @@ -224,6 +236,8 @@ def get_game_phase_view( return templates.TemplateResponse("scene_partial.html", context) elif game.phase == "between_scenes": return templates.TemplateResponse("between_scenes_partial.html", context) + elif game.phase == "deep_upkeep": + return templates.TemplateResponse("deep_upkeep_partial.html", context) return HTMLResponse("

Unknown game state.

") @@ -613,7 +627,7 @@ def submit_vote_route( db: Session = Depends(get_session) ): 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 @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.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) 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(): import argparse diff --git a/src/pirats/models.py b/src/pirats/models.py index e20ff1a..6732c65 100644 --- a/src/pirats/models.py +++ b/src/pirats/models.py @@ -5,6 +5,7 @@ from sqlmodel import Field, SQLModel, Relationship class Game(SQLModel, table=True): id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) 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" current_scene_number: int = Field(default=1) extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers) diff --git a/src/pirats/static/css/style.css b/src/pirats/static/css/style.css index 1d210ae..b880915 100644 --- a/src/pirats/static/css/style.css +++ b/src/pirats/static/css/style.css @@ -1729,3 +1729,46 @@ body { 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); +} + diff --git a/src/pirats/templates/admin.html b/src/pirats/templates/admin.html index 6720186..3d4454e 100644 --- a/src/pirats/templates/admin.html +++ b/src/pirats/templates/admin.html @@ -1,6 +1,6 @@ {% 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 %}
diff --git a/src/pirats/templates/between_scenes_partial.html b/src/pirats/templates/between_scenes_partial.html index 6071b29..ed0a577 100644 --- a/src/pirats/templates/between_scenes_partial.html +++ b/src/pirats/templates/between_scenes_partial.html @@ -8,81 +8,90 @@

1. Pirate Ranking (Voting)

Nominate one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.

- {% set has_voted = False %} - {% for v in game.votes %} - {% if v.voter_player_id == player.id %} - {% set has_voted = True %} - {% endif %} - {% endfor %} - - {% if has_voted %} -
-

✔️ Vote submitted! Waiting for other players to submit their nominations.

-
- {% else %} -
-
- - -
-
+ {% set ns_vote = namespace(has_voted=False) %} + {% for v in votes %} + {% if v.voter_player_id == player.id %} + {% set ns_vote.has_voted = True %} {% endif %} - - -
-

Nomination Progress:

-
- {% for p in game.players %} - {% set voted = False %} - {% for v in game.votes if v.voter_player_id == p.id %} - {% set voted = True %} - {% endfor %} -
- {{ p.name }} - {% if voted %}Voted{% else %}Voting...{% endif %} -
- {% endfor %} -
-
-
+ {% endfor %} - -
-

2. Resting Deep Upkeep

- - {% if player.previous_role == "deep" %} -
-

You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size (Rank {{ player.rank }} maximum: {{ max_hand_size }} cards).

-

This is handled automatically when transitioning, but you must click 'Ready' below to proceed.

+ {% if ns_vote.has_voted %} +
+

✔️ Vote submitted! Waiting for other players to submit their nominations.

+
+ {% else %} +
+
+ +
- {% else %} -

Your Pi-Rat was active. Your hand cards carry over to the next scene.

- {% endif %} - -
-

Crew Rank Ledger:

-
    - {% for p in game.players %} -
  • - {{ p.name }}: Rank {{ p.rank }} - {% if p.previous_role == 'deep' %}Deep{% else %}Pi-Rat{% endif %} -
  • + + {% endif %} + + +
    +

    Nomination Progress:

    +
    + {% for p in game.players %} + {% set voted = False %} + {% for v in votes if v.voter_player_id == p.id %} + {% set voted = True %} {% endfor %} -
+
+ {{ p.name }} + {% if voted %}Voted{% else %}Voting...{% endif %} +
+ {% endfor %}
- -
+ +
+

2. Resting Deep Upkeep

+ + {% if player.previous_role == "deep" %} +
+

You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size (Rank {{ player.rank }} maximum: {{ max_hand_size }} cards).

+

This is handled automatically when transitioning, but you must click 'Ready' below to proceed.

+
+ {% else %} +

Your Pi-Rat was active. Your hand cards carry over to the next scene.

+ {% endif %} + +
+

Crew Rank Ledger:

+
    + {% for p in game.players %} +
  • + {{ p.name }}: Rank {{ p.rank }} + {% if p.previous_role == 'deep' %}Deep{% else %}Pi-Rat{% endif %} +
  • + {% endfor %} +
+
+
+
+ + +
+ {% 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 %} +

⚠️ You must nominate a crewmate above before you can ready up.

+ + {% else %} {% if player.is_ready %}

Ready! Waiting for other players to ready up...

@@ -90,12 +99,14 @@
{% else %} {% endif %} -
+ {% endif %} + @@ -103,3 +114,5 @@ hx-trigger="every 2s" hx-swap="none" style="display:none;"> + +{% include "header_status_snippet.html" %} diff --git a/src/pirats/templates/character_creation_partial.html b/src/pirats/templates/character_creation_partial.html index 7adc939..643128a 100644 --- a/src/pirats/templates/character_creation_partial.html +++ b/src/pirats/templates/character_creation_partial.html @@ -118,3 +118,5 @@ hx-trigger="every 2s" hx-swap="none" style="display:none;"> + +{% include "header_status_snippet.html" %} diff --git a/src/pirats/templates/dashboard.html b/src/pirats/templates/dashboard.html index 9a66a4f..c2fc4e0 100644 --- a/src/pirats/templates/dashboard.html +++ b/src/pirats/templates/dashboard.html @@ -1,9 +1,9 @@ {% 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 %} -
+
{% if player %} {{ player.name }} (Rank {{ player.rank }}) {% endif %} diff --git a/src/pirats/templates/deep_upkeep_partial.html b/src/pirats/templates/deep_upkeep_partial.html new file mode 100644 index 0000000..8919a27 --- /dev/null +++ b/src/pirats/templates/deep_upkeep_partial.html @@ -0,0 +1,121 @@ +{% if player.previous_role == "deep" %} +
+

Resting Deep Upkeep

+

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.

+ +
+ +
+

Keep (Current Hand)

+
+ {% for card in hand %} + {% set parsed = parse_card(card) %} +
+
+ {{ parsed.value }} + {{ parsed.symbol }} +
+
+ {% if parsed.is_joker %} + 🃏 + {% else %} + {{ parsed.symbol }} + {% endif %} +
+
+ {{ parsed.value }} + {{ parsed.symbol }} +
+
+ {% endfor %} +
+
+ + +
+

Discard

+
+ +
+
+
+ +
+ {% if player.is_ready %} +
+

Upkeep confirmed! Waiting for other resting Deep players...

+
+
+ {% else %} +
+ + + +
+ {% endif %} +
+
+ + +{% else %} +
+

🌊 Resting Deep Upkeep

+

The players who controlled the Deep in the previous scene are currently refreshing their hands.

+
+

Waiting for resting Deep players to confirm their hand refreshes...

+
+
+
+{% endif %} + + +
+ +{% include "header_status_snippet.html" %} diff --git a/src/pirats/templates/header_status_snippet.html b/src/pirats/templates/header_status_snippet.html new file mode 100644 index 0000000..a221a26 --- /dev/null +++ b/src/pirats/templates/header_status_snippet.html @@ -0,0 +1,6 @@ +
+ {% if player %} + {{ player.name }} (Rank {{ player.rank }}) + {% endif %} + {{ game.phase.replace('_', ' ').title() }} +
diff --git a/src/pirats/templates/index.html b/src/pirats/templates/index.html index 09bc421..94ed4f4 100644 --- a/src/pirats/templates/index.html +++ b/src/pirats/templates/index.html @@ -12,8 +12,11 @@

Start a New Adventure

-

Generate a fresh game deck and take command as the crew creator.

+

Generate a fresh game deck and name your crew of mathematical pirates.

+
+ +
diff --git a/src/pirats/templates/join_form.html b/src/pirats/templates/join_form.html index ee369b6..925da29 100644 --- a/src/pirats/templates/join_form.html +++ b/src/pirats/templates/join_form.html @@ -4,7 +4,7 @@ {% block content %}
-

Join the Pi-Rat Crew!

+

Join the {% if game.crew_name %}{{ game.crew_name }}{% else %}Pi-Rat{% endif %} Crew!

The mathematical transformation spell is about to cast. Enter your name below to stow away in the cargo hold.

diff --git a/src/pirats/templates/lobby_partial.html b/src/pirats/templates/lobby_partial.html index 2942803..4015f49 100644 --- a/src/pirats/templates/lobby_partial.html +++ b/src/pirats/templates/lobby_partial.html @@ -1,5 +1,5 @@
-

Ship's Hold (Lobby)

+

{% if game.crew_name %}{{ game.crew_name }}{% else %}Ship's Hold{% endif %} (Lobby)

Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.

@@ -64,3 +64,5 @@ hx-trigger="every 2s" hx-swap="none" style="display:none;">
+ +{% include "header_status_snippet.html" %} diff --git a/src/pirats/templates/scene_partial.html b/src/pirats/templates/scene_partial.html index 6fe7854..ca070e7 100644 --- a/src/pirats/templates/scene_partial.html +++ b/src/pirats/templates/scene_partial.html @@ -294,6 +294,8 @@ hx-swap="none" style="display:none;">
+{% include "header_status_snippet.html" %} +