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 %}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.
-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.
+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.
+ +Upkeep confirmed! Waiting for other resting Deep players...
+ +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...
+ +Generate a fresh game deck and take command as the crew creator.
+Generate a fresh game deck and name your crew of mathematical pirates.
The mathematical transformation spell is about to cast. Enter your name below to stow away in the cargo hold.