From 8650ebe5b2a68300c061d9192a2475f74fab154d Mon Sep 17 00:00:00 2001 From: Tim McCarthy Date: Tue, 9 Jun 2026 19:12:19 -0700 Subject: [PATCH] Bug fixes --- src/pirats/crud.py | 62 +-- src/pirats/main.py | 111 ++--- src/pirats/models.py | 19 +- src/pirats/static/css/style.css | 398 +++++++++++++++++- .../templates/character_creation_partial.html | 7 +- src/pirats/templates/inbox_snippet.html | 11 +- .../templates/scene_challenges_snippet.html | 63 --- .../templates/scene_obstacles_snippet.html | 91 +++- src/pirats/templates/scene_partial.html | 294 +++++++------ src/pirats/templates/scene_setup_partial.html | 10 +- src/pirats/templates/techniques_snippet.html | 288 +++++++++++-- tests/test_game.py | 104 ++++- 12 files changed, 1075 insertions(+), 383 deletions(-) delete mode 100644 src/pirats/templates/scene_challenges_snippet.html diff --git a/src/pirats/crud.py b/src/pirats/crud.py index f410b90..2377f96 100644 --- a/src/pirats/crud.py +++ b/src/pirats/crud.py @@ -2,7 +2,7 @@ import json import random from typing import List, Dict, Any, Optional, Tuple from sqlmodel import Session, select -from .models import Game, Player, Obstacle, Challenge, Vote +from .models import Game, Player, Obstacle, Vote from . import cards # --- Card and Hand Utilities --- @@ -30,13 +30,13 @@ def calculate_max_hand_size(player: Player, players_in_scene: List[Player]) -> i - Captain gets +1 If all players have the same Rank, they are all 'middle' and get 3 cards. """ - if player.role != "pirat": + if player.role == "deep": # Deep players still have hands (for when they transition), let's say they have size based on Rank # but don't participate in the Pi-Rat ranking calculations. # Deep player max hand size is 3 by default, or equal to Rank. Let's make it equal to Rank + 1. return player.rank + 1 - pi_rats = [p for p in players_in_scene if p.role == "pirat"] + pi_rats = [p for p in players_in_scene if p.role != "deep"] if not pi_rats: return 3 @@ -70,9 +70,9 @@ def calculate_max_hand_size(player: Player, players_in_scene: List[Player]) -> i def is_player_captain(player: Player, players_in_scene: List[Player]) -> bool: """Helper to check if a player is currently the captain in the scene.""" - if player.role != "pirat": + if player.role == "deep": return False - pi_rats = [p for p in players_in_scene if p.role == "pirat"] + pi_rats = [p for p in players_in_scene if p.role != "deep"] if not pi_rats: return False max_rank = max(p.rank for p in pi_rats) @@ -161,13 +161,19 @@ def get_player(db: Session, player_id: str) -> Optional[Player]: return db.get(Player, player_id) def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player: + game = db.get(Game, game_id) + role = None + if game and game.phase == "scene": + role = "pirat" + player = Player( game_id=game_id, name=name, is_creator=is_creator, rank=2, # default starting rank (will be set properly when starting character creation) hand_cards="[]", - is_ready=False + is_ready=False, + role=role ) db.add(player) db.commit() @@ -365,7 +371,13 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: players = game.players - # 1. Count roles + # 1. Count roles (default any unselected roles to 'pirat') + for p in players: + if p.role is None: + p.role = "pirat" + db.add(p) + db.commit() + pirats_count = sum(1 for p in players if p.role == "pirat") deeps_count = sum(1 for p in players if p.role == "deep") @@ -383,11 +395,9 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: 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 and challenges + # A. Reset obstacles for obs in game.obstacles: db.delete(obs) - for chal in game.challenges: - db.delete(chal) for vote in game.votes: db.delete(vote) db.commit() @@ -469,25 +479,11 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: # --- Scene Gameplay Operations --- -def create_challenge(db: Session, game_id: str, title: str, description: str, obstacle_ids: List[str]) -> Challenge: - challenge = Challenge( - game_id=game_id, - title=title, - description=description, - applied_obstacle_ids=json.dumps(obstacle_ids), - is_active=True - ) - db.add(challenge) - db.commit() - db.refresh(challenge) - return challenge - def play_card_on_obstacle( db: Session, player_id: str, obstacle_id: str, - card_code: str, - challenge_id: str + card_code: str ) -> Tuple[bool, str, Dict[str, Any]]: """ Plays a card from player's hand against an active obstacle. @@ -640,17 +636,6 @@ def play_card_on_obstacle( db.add(player) db.commit() - # 4. Deactivate the challenge (since a card was played against one of its obstacles) - # Note: If the challenge had multiple obstacles, this single play resolves it. - # In a more advanced UI we could track individual obstacle resolutions, but to keep the flow moving, - # resolving the active challenge is clean. Let's mark the challenge as resolved by this player. - challenge = db.get(Challenge, challenge_id) - if challenge: - challenge.is_active = False - challenge.resolved_by_player_id = player.id - db.add(challenge) - db.commit() - res_dict = { "success": success, "details": details, @@ -728,11 +713,6 @@ def end_scene_and_transition(db: Session, game_id: str): for p in game.players: p.is_ready = False db.add(p) - - # Remove active challenges - for chal in game.challenges: - db.delete(chal) - db.add(game) db.commit() diff --git a/src/pirats/main.py b/src/pirats/main.py index 77a2c0d..f08cb89 100644 --- a/src/pirats/main.py +++ b/src/pirats/main.py @@ -11,7 +11,7 @@ from pathlib import Path from . import crud from . import cards from .database import create_db_and_tables, get_session -from .models import Game, Player, Obstacle, Challenge, Vote +from .models import Game, Player, Obstacle, Vote app = FastAPI(title="Rats with Gats Remote Play") @@ -128,37 +128,20 @@ def scene_roster_snippet(request: Request, game_id: str, db: Session = Depends(g }) @app.get("/game/{game_id}/scene/obstacles", response_class=HTMLResponse) -def scene_obstacles_snippet(request: Request, game_id: str, player_rank: int, db: Session = Depends(get_session)): - game = crud.get_game(db, game_id) - if not game: - return HTMLResponse("") - return templates.TemplateResponse("scene_obstacles_snippet.html", { - "request": request, - "game": game, - "player_rank": player_rank, - "parse_card": cards.parse_card, - "json_loads": json.loads - }) - -@app.get("/game/{game_id}/scene/challenges", response_class=HTMLResponse) -def scene_challenges_snippet(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)): +def scene_obstacles_snippet(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)): game = crud.get_game(db, game_id) player = crud.get_player(db, player_id) if not game or not player: return HTMLResponse("") - - def get_obstacle_by_id(obs_id: str) -> Optional[Obstacle]: - return db.get(Obstacle, obs_id) - hand = crud.get_player_hand(player) - return templates.TemplateResponse("scene_challenges_snippet.html", { + return templates.TemplateResponse("scene_obstacles_snippet.html", { "request": request, "game": game, "player": player, "hand": hand, "parse_card": cards.parse_card, - "get_obstacle_by_id": get_obstacle_by_id, - "json_loads": json.loads + "json_loads": json.loads, + "is_captain": crud.is_player_captain }) @app.get("/game/{game_id}/between/roster", response_class=HTMLResponse) @@ -174,7 +157,13 @@ def between_scenes_roster_snippet(request: Request, game_id: str, db: Session = # --- HTMX View Polling Render Route --- @app.get("/game/{game_id}/player/{player_id}/view", response_class=HTMLResponse) -def get_game_phase_view(request: Request, game_id: str, player_id: str, db: Session = Depends(get_session)): +def get_game_phase_view( + request: Request, + game_id: str, + player_id: str, + edit: bool = False, + db: Session = Depends(get_session) +): game = crud.get_game(db, game_id) player = crud.get_player(db, player_id) if not game or not player: @@ -220,7 +209,8 @@ def get_game_phase_view(request: Request, game_id: str, player_id: str, db: Sess "is_captain": crud.is_player_captain, "json_loads": json.loads, "deck_count": len(crud.get_game_deck(game)), - "inbox_tasks": inbox_tasks + "inbox_tasks": inbox_tasks, + "edit_mode": edit } # Select template based on current game phase @@ -272,7 +262,7 @@ def player_save_basic_details( db.add(player) db.commit() - return HTMLResponse(status_code=204) + return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER) # 3. Edit basic character details (reset values to trigger form view) @app.post("/game/{game_id}/player/{player_id}/edit-basic") @@ -281,18 +271,8 @@ def player_edit_basic_details( player_id: str, db: Session = Depends(get_session) ): - player = crud.get_player(db, player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - - player.avatar_look = "" - player.avatar_smell = "" - player.first_words = "" - db.add(player) - db.commit() - - # Trigger HTMX reload - return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER) + # Trigger HTMX reload with edit mode active + return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view?edit=true", status_code=status.HTTP_303_SEE_OTHER) # Auto-assign unassigned delegated tasks (Like and Hate) randomly to crewmates @app.post("/game/{game_id}/player/{player_id}/delegate/auto") @@ -458,7 +438,6 @@ def player_get_delegation_status( # 5. Answer a delegated question for another player @app.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}") def player_submit_delegated_answer( - request: Request, game_id: str, player_id: str, # The answering player target_player_id: str, # The player whose sheet we are filling out @@ -467,28 +446,7 @@ def player_submit_delegated_answer( db: Session = Depends(get_session) ): crud.submit_delegated_answer(db, target_player_id, question_type, answer.strip(), player_id) - - # Re-fetch data to render the updated inbox snippet - game = crud.get_game(db, game_id) - player = crud.get_player(db, player_id) - if not game or not player: - raise HTTPException(status_code=404, detail="Not found") - - # Compute inbox tasks - inbox_tasks = [] - for p in game.players: - if p.id != player.id: - if p.other_like_from_player_id == player.id and not p.other_like: - 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"}) - - return templates.TemplateResponse("inbox_snippet.html", { - "request": request, - "game": game, - "player": player, - "inbox_tasks": inbox_tasks - }) + return HTMLResponse("") # 6. Submit 3 Secret Pirate Techniques @app.post("/game/{game_id}/player/{player_id}/submit-techniques") @@ -545,15 +503,16 @@ def player_assign_face_techniques( }) # 8. Set Role during Scene Setup -@app.post("/game/{game_id}/player/{player_id}/set-role") +@app.post("/game/{game_id}/player/{player_id}/set-role", response_class=HTMLResponse) def player_set_role( + request: Request, game_id: str, player_id: str, role: str, db: Session = Depends(get_session) ): crud.update_scene_role(db, player_id, role) - return HTMLResponse(status_code=204) + return get_game_phase_view(request, game_id, player_id, db=db) # 9. Start Scene (Verify Setup & Shuffle & Draw Obstacles) @app.post("/game/{game_id}/scene/start") @@ -569,29 +528,16 @@ def start_scene_route(request: Request, game_id: str, db: Session = Depends(get_ return HTMLResponse(status_code=204) -# 10. Deep creates a new challenge -@app.post("/game/{game_id}/challenge/create") -def create_challenge_route( - game_id: str, - title: str = Form(...), - description: str = Form(""), - obstacle_ids: List[str] = Form(...), - db: Session = Depends(get_session) -): - crud.create_challenge(db, game_id, title.strip(), description.strip(), obstacle_ids) - return HTMLResponse(status_code=204) - -# 11. Pi-Rat plays card against an obstacle in a challenge +# 11. Pi-Rat plays card against an obstacle @app.post("/game/{game_id}/player/{player_id}/play-card") def play_card_route( game_id: str, player_id: str, - challenge_id: str = Form(...), obstacle_id: str = Form(...), card_code: str = Form(...), db: Session = Depends(get_session) ): - ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code, challenge_id) + ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code) if not ok: return HTMLResponse(f"

{msg}

", status_code=400) @@ -603,7 +549,8 @@ def play_card_route( f"
" f"

{color_prefix} {res['details']}{drew_text}

" f"Click to dismiss" - f"
" + f"", + headers={"HX-Trigger": "hand-updated"} ) # 12. Pi-Rat plays a Joker to replace an obstacle @@ -615,10 +562,7 @@ def play_joker_route( obstacle_id: str = Form(...), db: Session = Depends(get_session) ): - # Retrieve any active challenge that contains this obstacle to use as a dummy, - # or let play_card_on_obstacle handle challenge_id=None. - # Wait, we can pass challenge_id = "" and check in play_card if it exists. - ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code, "") + ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code) if not ok: return HTMLResponse(f"

{msg}

", status_code=400) @@ -626,7 +570,8 @@ def play_joker_route( f"
" f"

🃏 Play Joker: Discarded obstacle and drew a new replacement!

" f"Click to dismiss" - f"
" + f"", + headers={"HX-Trigger": "hand-updated"} ) # 13. Toggle Objective Completion Checklist diff --git a/src/pirats/models.py b/src/pirats/models.py index 7db8e6b..e20ff1a 100644 --- a/src/pirats/models.py +++ b/src/pirats/models.py @@ -12,7 +12,6 @@ class Game(SQLModel, table=True): players: List["Player"] = Relationship(back_populates="game", cascade_delete=True) obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True) - challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True) votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True) class Player(SQLModel, table=True): @@ -65,16 +64,14 @@ class Obstacle(SQLModel, table=True): game: Optional[Game] = Relationship(back_populates="obstacles") -class Challenge(SQLModel, table=True): - id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) - game_id: str = Field(foreign_key="game.id", index=True) - title: str = Field(...) - description: str = Field(default="") - applied_obstacle_ids: str = Field(default="[]") # JSON string of list of obstacle IDs - resolved_by_player_id: Optional[str] = Field(default=None) # Player ID if resolved (for logging/history) - is_active: bool = Field(default=True) - - game: Optional[Game] = Relationship(back_populates="challenges") + @property + def success_count(self) -> int: + import json + try: + cards_list = json.loads(self.played_cards) + return sum(1 for c in cards_list if c.get("success") is True) + except Exception: + return 0 class Vote(SQLModel, table=True): id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) diff --git a/src/pirats/static/css/style.css b/src/pirats/static/css/style.css index 65ae6df..1d210ae 100644 --- a/src/pirats/static/css/style.css +++ b/src/pirats/static/css/style.css @@ -578,6 +578,10 @@ body { gap: 0.5rem; } +.inbox-list:has(.inbox-item) .inbox-empty-message { + display: none; +} + .inbox-item { padding: 1rem; margin-bottom: 0.75rem; @@ -763,12 +767,18 @@ body { padding: 1.25rem; background: rgba(7, 11, 18, 0.4); display: grid; - grid-template-columns: 0.8fr 2fr 1fr; + grid-template-columns: 0.8fr 2fr 1fr 1fr; gap: 1rem; position: relative; overflow: hidden; } +@media (max-width: 768px) { + .obstacle-item { + grid-template-columns: 0.8fr 2fr 1.5fr; + } +} + @media (max-width: 600px) { .obstacle-item { grid-template-columns: 1fr; @@ -828,6 +838,21 @@ body { padding: 0.5rem; } +.obstacle-successes-display { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: rgba(0, 255, 135, 0.04); + border: 1px dashed rgba(0, 255, 135, 0.25); + border-radius: 6px; + padding: 0.5rem; +} + +.obstacle-successes-display .val-number { + color: var(--neon-emerald); +} + .val-label { font-size: 0.75rem; color: var(--text-muted); @@ -842,7 +867,7 @@ body { } .played-column { - grid-column: span 3; + grid-column: span 4; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 0.75rem; margin-top: 0.5rem; @@ -1028,6 +1053,71 @@ body { margin-top: 1.5rem; } +/* Card Widget (Medium) */ +.card-medium { + width: 75px; + height: 112px; + border-radius: 6px; + border: 1.5px solid var(--glass-border); + background: linear-gradient(135deg, var(--bg-ocean-light) 0%, var(--bg-dark) 100%); + box-shadow: 0 3px 10px rgba(0,0,0,0.4); + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 0.3rem; + position: relative; + font-size: 0.85rem; +} + +.card-medium .card-corner { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1; +} + +.card-medium .card-corner .val { + font-weight: 900; + font-size: 0.9rem; +} + +.card-medium .card-corner .suit { + font-size: 0.75rem; +} + +.card-medium.suit-c, .card-medium.suit-s { + border-color: rgba(226, 232, 240, 0.15); +} + +.card-medium.suit-h, .card-medium.suit-d { + border-color: rgba(255, 42, 95, 0.15); +} + +.card-medium.joker-card { + border-color: var(--gold); + background: linear-gradient(135deg, #1d1b10 0%, var(--bg-dark) 100%); +} + +.card-medium.suit-c .val, .card-medium.suit-c .suit, +.card-medium.suit-s .val, .card-medium.suit-s .suit { + color: var(--black-suit); +} + +.card-medium.suit-h .val, .card-medium.suit-h .suit, +.card-medium.suit-d .val, .card-medium.suit-d .suit { + color: var(--red-suit); +} + +.card-medium .card-center { + font-size: 1.8rem; + text-align: center; + align-self: center; + margin: auto 0; +} + +.card-medium.suit-c .card-center, .card-medium.suit-s .card-center { color: var(--black-suit); } +.card-medium.suit-h .card-center, .card-medium.suit-d .card-center { color: var(--red-suit); } + /* Card Widget (Large) */ .card-large { width: 110px; @@ -1335,3 +1425,307 @@ body { .select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; } .select-xsmall { padding: 0.4rem; font-size: 0.85rem; } .btn-small { padding: 0.4rem 0.8rem; font-size: 0.85rem; } + +/* --- Visual & Tactile Technique Assignment --- */ +.face-tech-drag-form { + margin-top: 1.5rem; +} + +.available-techniques-container { + background: rgba(255, 255, 255, 0.02); + border: 1px dashed var(--glass-border); + border-radius: 12px; + padding: 1.25rem; + margin-bottom: 2rem; + box-shadow: inset 0 0 15px rgba(0,0,0,0.2); +} + +.available-techniques-container h4 { + font-family: var(--font-heading); + color: var(--gold); + font-size: 1.1rem; + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.instruction-help { + font-size: 0.8rem; + color: var(--text-muted); + margin-bottom: 1rem; +} + +.techniques-drag-pool { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: center; + min-height: 50px; + align-items: center; +} + +.draggable-tech-chip { + background: linear-gradient(135deg, rgba(22, 36, 59, 0.9) 0%, rgba(13, 23, 38, 0.9) 100%); + border: 1px solid var(--gold); + color: var(--text-primary); + padding: 0.6rem 1rem; + border-radius: 8px; + cursor: grab; + user-select: none; + font-family: var(--font-body); + font-size: 0.95rem; + font-weight: 500; + transition: var(--transition-smooth); + box-shadow: 0 4px 10px rgba(0,0,0,0.3), 0 0 5px rgba(212, 175, 55, 0.1); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.draggable-tech-chip:hover { + transform: translateY(-2px); + border-color: var(--neon-cyan); + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.4), 0 0 10px rgba(0, 242, 254, 0.3); +} + +.draggable-tech-chip:active { + cursor: grabbing; +} + +.draggable-tech-chip.dragging { + opacity: 0.4; + border-style: dashed; +} + +.draggable-tech-chip.selected { + border-color: var(--neon-cyan); + box-shadow: 0 0 15px rgba(0, 242, 254, 0.5); + background: rgba(0, 242, 254, 0.1); +} + +.draggable-tech-chip.assigned-hidden { + opacity: 0.2; + pointer-events: none; + cursor: not-allowed; + border-color: var(--text-muted); +} + +.drag-icon { + color: var(--gold); + font-size: 0.8rem; + opacity: 0.7; +} + +/* Slots Grid */ +.face-cards-slots-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-bottom: 2rem; +} + +@media (max-width: 768px) { + .face-cards-slots-grid { + grid-template-columns: 1fr; + } +} + +.face-card-slot-wrapper { + display: flex; + justify-content: center; +} + +.face-card-slot { + position: relative; + width: 100%; + max-width: 220px; + aspect-ratio: 2 / 3; + min-height: 280px; + background: linear-gradient(135deg, rgba(13, 23, 38, 0.8) 0%, rgba(7, 11, 18, 0.9) 100%); + border: 2px dashed rgba(212, 175, 55, 0.3); + border-radius: 12px; + padding: 1rem; + display: flex; + flex-direction: column; + justify-content: space-between; + transition: var(--transition-smooth); + overflow: hidden; + cursor: pointer; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4); +} + +.face-card-slot:hover { + border-color: rgba(212, 175, 55, 0.7); + box-shadow: 0 12px 30px rgba(212, 175, 55, 0.15); + transform: translateY(-4px); +} + +.face-card-slot.drag-over { + border-color: var(--neon-cyan); + border-style: solid; + background: rgba(0, 242, 254, 0.05); + box-shadow: 0 0 20px rgba(0, 242, 254, 0.2); + transform: scale(1.02); +} + +.face-card-slot.has-assignment { + border-style: solid; + border-color: var(--gold); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5), 0 0 15px rgba(212, 175, 55, 0.15); +} + +.card-bg-letter { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-family: var(--font-heading); + font-size: 8rem; + font-weight: 900; + color: rgba(255, 255, 255, 0.025); + line-height: 1; + pointer-events: none; + user-select: none; + transition: var(--transition-smooth); +} + +.face-card-slot.has-assignment .card-bg-letter { + color: rgba(212, 175, 55, 0.04); +} + +.card-slot-header, .card-slot-footer { + display: flex; + justify-content: space-between; + align-items: center; + font-family: var(--font-heading); + font-weight: 700; + font-size: 1.1rem; + color: var(--text-muted); + pointer-events: none; + user-select: none; +} + +.face-card-slot.has-assignment .card-slot-header, +.face-card-slot.has-assignment .card-slot-footer { + color: var(--gold); +} + +.card-slot-body { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + z-index: 2; + padding: 0.5rem 0; +} + +.card-title { + font-family: var(--font-heading); + font-size: 1.2rem; + font-weight: 700; + color: var(--text-primary); + margin-bottom: 0.75rem; + pointer-events: none; +} + +.face-card-slot.has-assignment .card-title { + color: var(--gold); +} + +.drop-zone-placeholder { + font-size: 0.8rem; + color: var(--text-muted); + border: 1px dashed rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.2); + border-radius: 6px; + padding: 0.5rem 0.75rem; + transition: var(--transition-smooth); + pointer-events: none; +} + +.face-card-slot:hover .drop-zone-placeholder { + color: var(--text-primary); + border-color: rgba(255, 255, 255, 0.2); +} + +.assigned-tech-content { + width: 100%; + display: none; +} + +.face-card-slot.has-assignment .drop-zone-placeholder { + display: none; +} + +.face-card-slot.has-assignment .assigned-tech-content { + display: block; +} + +.assigned-tech-chip { + background: rgba(212, 175, 55, 0.08); + border: 1px solid var(--gold); + color: var(--text-primary); + padding: 0.5rem; + border-radius: 8px; + font-size: 0.85rem; + position: relative; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); + word-break: break-word; + animation: fadeIn 0.3s ease; +} + +.remove-tech-btn { + position: absolute; + top: -8px; + right: -8px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #c0392b; + border: 1px solid #e74c3c; + color: white; + font-size: 10px; + font-weight: 900; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0,0,0,0.5); + transition: var(--transition-smooth); +} + +.remove-tech-btn:hover { + background: #e74c3c; + transform: scale(1.1); +} + +@keyframes fadeIn { + from { opacity: 0; transform: scale(0.9); } + to { opacity: 1; transform: scale(1); } +} + +/* Drag and Drop Interface Styles */ +.card-large[draggable="true"] { + cursor: grab; +} + +.card-large[draggable="true"]:active { + cursor: grabbing; +} + +.card-large.dragging { + opacity: 0.4; + border-style: dashed; + transform: scale(0.95); +} + +.obstacle-item.drag-over { + border-color: var(--gold) !important; + background: rgba(212, 175, 55, 0.1) !important; + box-shadow: 0 0 20px rgba(212, 175, 55, 0.2) !important; + transform: translateY(-2px) scale(1.01); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + diff --git a/src/pirats/templates/character_creation_partial.html b/src/pirats/templates/character_creation_partial.html index 6eae02d..7adc939 100644 --- a/src/pirats/templates/character_creation_partial.html +++ b/src/pirats/templates/character_creation_partial.html @@ -16,7 +16,7 @@

I. Basic Rat Records

- {% if player.avatar_look and player.avatar_smell and player.first_words %} + {% if player.avatar_look and player.avatar_smell and player.first_words and not edit_mode %}
Look: {{ player.avatar_look }}
@@ -30,7 +30,10 @@
{% else %} -
+
diff --git a/src/pirats/templates/inbox_snippet.html b/src/pirats/templates/inbox_snippet.html index e5be800..51545fc 100644 --- a/src/pirats/templates/inbox_snippet.html +++ b/src/pirats/templates/inbox_snippet.html @@ -3,13 +3,14 @@

Answer questions that your crewmates have delegated to you.

+

No tasks in your inbox. Check back when other players delegate to you!

{% if inbox_tasks %} {% for task in inbox_tasks %} {% set p = task.player %} {% if task.type == 'like' %} -
+
@@ -20,9 +21,9 @@
{% else %} -
+
@@ -34,8 +35,6 @@
{% endif %} {% endfor %} - {% else %} -

No tasks in your inbox. Check back when other players delegate to you!

{% endif %}
diff --git a/src/pirats/templates/scene_challenges_snippet.html b/src/pirats/templates/scene_challenges_snippet.html deleted file mode 100644 index 3945742..0000000 --- a/src/pirats/templates/scene_challenges_snippet.html +++ /dev/null @@ -1,63 +0,0 @@ -
- {% for chal in game.challenges if chal.is_active %} - {% set applied_ids = json_loads(chal.applied_obstacle_ids) %} -
-

{{ chal.title }}

- {% if chal.description %} -

{{ chal.description }}

- {% endif %} - -
-
Applied Obstacles to beat:
- {% for obs_id in applied_ids %} - {% set obs = get_obstacle_by_id(obs_id) %} - {% if obs %} -
-
- {{ obs.symbol }} - {{ obs.title }} (Difficulty: - {% if parse_card(obs.original_card).value in ["J", "Q", "K"] %} - Rank ({{ player.rank }}) - {% else %} - {{ obs.current_value }} - {% endif %}) -
- - - {% if player.role == "pirat" %} - - - - - - - - {% else %} - Waiting for Pi-Rats to resolve - {% endif %} -
- {% endif %} - {% endfor %} -
-
- {% else %} -

No active challenges. Deep players should describe the scene and call for challenges!

- {% endfor %} -
diff --git a/src/pirats/templates/scene_obstacles_snippet.html b/src/pirats/templates/scene_obstacles_snippet.html index 7317437..8131911 100644 --- a/src/pirats/templates/scene_obstacles_snippet.html +++ b/src/pirats/templates/scene_obstacles_snippet.html @@ -1,8 +1,75 @@ + +
+
+ + {% set ns = namespace(captain=None) %} + {% for p in game.players %} + {% if is_captain(p, game.players) %} + {% set ns.captain = p %} + {% endif %} + {% endfor %} +
+ {% if ns.captain %} + + 🏴‍☠️ Captain: {{ ns.captain.name }} (Rank {{ ns.captain.rank }}) + + {% else %} + + 🏴‍☠️ Captain: None (No Pi-Rats) + + {% endif %} +
+ + +
+ Active Roster: + {% for p in game.players %} + {% if p.role == 'deep' %} + + 🌊 {{ p.name }} (Deep) + + {% elif p.role == 'pirat' %} + + 🐀 {{ p.name }} (Rank {{ p.rank }}){% if is_captain(p, game.players) %} ⭐{% endif %} + + {% endif %} + {% endfor %} +
+
+
+ {% for obs in game.obstacles %} -
-
- {{ obs.symbol }} {{ obs.suit.upper() }} - {{ obs.original_card }} + {% set column_cards = json_loads(obs.played_cards) %} + {% set active_card_code = column_cards[-1]["card"] if column_cards else obs.original_card %} + {% set active_parsed = parse_card(active_card_code) %} +
+
+
+
+ {{ active_parsed.value }} + {{ active_parsed.symbol }} +
+ +
+ {% if active_parsed.is_joker %} + 🃏 + {% else %} + {{ active_parsed.symbol }} + {% endif %} +
+ +
+ {{ active_parsed.value }} + {{ active_parsed.symbol }} +
+

{{ obs.title }}

@@ -14,13 +81,27 @@ Current Difficulty {% if parse_card(obs.original_card).value in ["J", "Q", "K"] %} - Rank ({{ player_rank }}) + Rank ({{ player.rank }}) {% else %} {{ obs.current_value }} {% endif %}
+ + {% set ns_non_deep = namespace(count=0) %} + {% for p in game.players %} + {% if p.role != "deep" %} + {% set ns_non_deep.count = ns_non_deep.count + 1 %} + {% endif %} + {% endfor %} +
+ Successes + + {{ obs.success_count }} / {{ ns_non_deep.count }} + +
+
Card History (Column)
diff --git a/src/pirats/templates/scene_partial.html b/src/pirats/templates/scene_partial.html index 294d72d..46ecbfe 100644 --- a/src/pirats/templates/scene_partial.html +++ b/src/pirats/templates/scene_partial.html @@ -1,4 +1,4 @@ -
+
@@ -10,14 +10,81 @@
+ +
+
+ + {% set ns = namespace(captain=None) %} + {% for p in game.players %} + {% if is_captain(p, game.players) %} + {% set ns.captain = p %} + {% endif %} + {% endfor %} +
+ {% if ns.captain %} + + 🏴‍☠️ Captain: {{ ns.captain.name }} (Rank {{ ns.captain.rank }}) + + {% else %} + + 🏴‍☠️ Captain: None (No Pi-Rats) + + {% endif %} +
+ + +
+ Active Roster: + {% for p in game.players %} + {% if p.role == 'deep' %} + + 🌊 {{ p.name }} (Deep) + + {% elif p.role == 'pirat' %} + + 🐀 {{ p.name }} (Rank {{ p.rank }}){% if is_captain(p, game.players) %} ⭐{% endif %} + + {% endif %} + {% endfor %} +
+
+
+ {% for obs in game.obstacles %} -
-
- {{ obs.symbol }} {{ obs.suit.upper() }} - {{ obs.original_card }} + {% set column_cards = json_loads(obs.played_cards) %} + {% set active_card_code = column_cards[-1]["card"] if column_cards else obs.original_card %} + {% set active_parsed = parse_card(active_card_code) %} +
+
+
+
+ {{ active_parsed.value }} + {{ active_parsed.symbol }} +
+ +
+ {% if active_parsed.is_joker %} + 🃏 + {% else %} + {{ active_parsed.symbol }} + {% endif %} +
+ +
+ {{ active_parsed.value }} + {{ active_parsed.symbol }} +
+

{{ obs.title }}

@@ -36,6 +103,20 @@
+ + {% set ns_non_deep = namespace(count=0) %} + {% for p in game.players %} + {% if p.role != "deep" %} + {% set ns_non_deep.count = ns_non_deep.count + 1 %} + {% endif %} + {% endfor %} +
+ Successes + + {{ obs.success_count }} / {{ ns_non_deep.count }} + +
+
Card History (Column)
@@ -61,135 +142,42 @@ {% endfor %}
- - -
-

⚔️ Active Challenges

- -
- {% for chal in game.challenges if chal.is_active %} - {% set applied_ids = json_loads(chal.applied_obstacle_ids) %} -
-

{{ chal.title }}

- {% if chal.description %} -

{{ chal.description }}

- {% endif %} - -
-
Applied Obstacles to beat (Success on any, but failures carry complications!):
- {% for obs_id in applied_ids %} - {% set obs = get_obstacle_by_id(obs_id) %} - {% if obs %} -
-
- {{ obs.symbol }} - {{ obs.title }} (Difficulty: - {% if parse_card(obs.original_card).value in ["J", "Q", "K"] %} - Rank ({{ player.rank }}) - {% else %} - {{ obs.current_value }} - {% endif %}) -
- - - {% if player.role == "pirat" %} -
- - - - - -
- {% else %} - Waiting for Pi-Rats to resolve - {% endif %} -
- {% endif %} - {% endfor %} -
-
- {% else %} -

No active challenges. Deep players should describe the scene and call for challenges!

- {% endfor %} -
-
- - +
+ + +
+ {% if player.role == "deep" %}
-

🌊 Deep Master Control Panel

- - -
-

1. Call for a New Challenge:

-
- - -
-
- - -
- -
- -
- {% for obs in game.obstacles %} - - {% endfor %} -
-
- -
- -
+

🌊 Deep Control Panel

-
-

3. End the Current Scene:

-

Deep players can conclude the scene once players have made attempts or the obstacle list is depleted.

+
+

Conclude the scene once players have made attempts or the obstacle list is depleted.

{% endif %} -
- - -
- +

🃏 Your Hand

-

Keep your cards secret! Max hand size: {{ max_hand_size }} cards. {% if is_captain(player, game.players) %} (Includes +1 Captain bonus) {% endif %}

+

Keep your cards secret! {% if player.role != "deep" %}Drag a card onto an active obstacle to play it.{% else %}Max hand size: {{ max_hand_size }} cards.{% endif %}

{% for card in hand %} {% set parsed = parse_card(card) %} -
+
{{ parsed.value }} {{ parsed.symbol }} @@ -211,19 +199,6 @@
Q: "{{ player.tech_queen }}"
{% elif parsed.value == "K" %}
K: "{{ player.tech_king }}"
- {% elif parsed.is_joker %} -
-
- - - -
-
{% endif %}
@@ -239,6 +214,7 @@
+ {% if player.role != "deep" %}

🐀 Your Character sheet

@@ -307,6 +283,7 @@
+ {% endif %}
@@ -316,3 +293,60 @@ hx-trigger="every 2s" hx-swap="none" style="display:none;">
+ + diff --git a/src/pirats/templates/scene_setup_partial.html b/src/pirats/templates/scene_setup_partial.html index 6fd32ec..ae24e26 100644 --- a/src/pirats/templates/scene_setup_partial.html +++ b/src/pirats/templates/scene_setup_partial.html @@ -38,18 +38,20 @@ {{ locked_role.upper() }}
-
+
{% else %}
@@ -105,7 +107,7 @@ hx-swap="innerHTML" hx-target="#game-view" class="btn btn-primary btn-large glow-effect"> - Confirm Roles & Shufle Deck + Confirm Roles & Shuffle Deck
diff --git a/src/pirats/templates/techniques_snippet.html b/src/pirats/templates/techniques_snippet.html index 95cefee..4a41a18 100644 --- a/src/pirats/templates/techniques_snippet.html +++ b/src/pirats/templates/techniques_snippet.html @@ -64,36 +64,274 @@ {% else %}
-
- - + + + + +
+

Available Swapped Techniques

+

Drag a technique onto a card slot below, or click/tap a technique and then click/tap a slot to assign it.

+
{% for t in swapped_techs %} - +
+ ⋮⋮ {{ t }} +
{% endfor %} - +
-
- - + + +
+ + +
+
+
J
+
+ J + +
+
+
Jack
+
Drag or Click to Assign
+
+
+ +
+
+ + +
+
+
Q
+
+ Q + +
+
+
Queen
+
Drag or Click to Assign
+
+
+ +
+
+ + +
+
+
K
+
+ K + +
+
+
King
+
Drag or Click to Assign
+
+
+ +
+
+
-
- - -
- + + + + {% endif %} {% endif %}
diff --git a/tests/test_game.py b/tests/test_game.py index 3d6a47a..403c8ab 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -3,7 +3,7 @@ import json from sqlmodel import SQLModel, create_engine, Session from pirats import cards from pirats import crud -from pirats.models import Game, Player, Obstacle, Challenge +from pirats.models import Game, Player, Obstacle # In-memory database for testing @pytest.fixture(name="session") @@ -137,9 +137,6 @@ def test_scene_start_and_challenges(session): session.add(p2) session.commit() - # Create challenge - chal = crud.create_challenge(session, game.id, "Challenge 1", "Sneak past", [obs.id]) - # Play card: 10D (value 10) vs Obstacle. Let's make sure obstacle value is less than 10. # Set obstacle value to 5 obs.current_value = 5 @@ -151,7 +148,7 @@ def test_scene_start_and_challenges(session): orig_is_red = cards.parse_card(obs.original_card)["color"] == "red" deck_before = len(crud.get_game_deck(game)) - ok, msg, res = crud.play_card_on_obstacle(session, p2.id, obs.id, "10D", chal.id) + ok, msg, res = crud.play_card_on_obstacle(session, p2.id, obs.id, "10D") assert ok assert res["success"] assert "Success" in res["details"] @@ -190,10 +187,8 @@ def test_secret_technique_auto_success(session): session.add(obs) session.commit() - chal = crud.create_challenge(session, game.id, "C1", "D1", [obs.id]) - # Play Jack. J is a face card -> auto success! - ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "JS", chal.id) + ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "JS") assert ok assert res["success"] assert res["is_technique"] @@ -225,10 +220,8 @@ def test_joker_play(session): session.add(obs) session.commit() - chal = crud.create_challenge(session, game.id, "C1", "D1", [obs.id]) - # Play Joker - ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "Joker1", chal.id) + ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "Joker1") assert ok assert res["is_joker"] @@ -236,3 +229,92 @@ def test_joker_play(session): session.refresh(game) assert len(game.obstacles) == 1 assert game.obstacles[0].original_card != "10C" + +def test_obstacle_success_count(session): + game = crud.create_game(session) + obs = Obstacle( + game_id=game.id, + original_card="10C", + suit="C", + title="Knights", + current_value=10, + played_cards=json.dumps([ + {"card": "9H", "player_id": "p1", "player_name": "P1", "success": True}, + {"card": "8S", "player_id": "p2", "player_name": "P2", "success": False}, + {"card": "AH", "player_id": "p1", "player_name": "P1", "success": True} + ]) + ) + session.add(obs) + session.commit() + + assert obs.success_count == 2 + +def test_non_deep_player_treatment(session): + game = crud.create_game(session) + p1 = crud.add_player(session, game.id, "Captain Barnaby") + p2 = crud.add_player(session, game.id, "Crewmate Pip") + + # p1 is deep, p2 is None (non-deep) + p1.role = "deep" + p2.role = None + session.add_all([p1, p2]) + session.commit() + + # Check captain status: p2 should be captain because p2 is the only non-deep player + assert crud.is_player_captain(p2, game.players) + assert not crud.is_player_captain(p1, game.players) + + # Check hand size: p2 should participate in hand size calculations and get Captain privileges (+1) + assert crud.calculate_max_hand_size(p2, game.players) == 4 + +def test_set_role_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 with StaticPool to share connection across threads + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + session = Session(engine) + + # Create game and player + game = crud.create_game(session) + player = crud.add_player(session, game.id, "TestPirate") + game.phase = "scene_setup" + session.add(game) + session.commit() + + 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/{game_id}/player/{player_id}/set-role": + 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) + + try: + response = client.post(f"/game/{game.id}/player/{player.id}/set-role?role=pirat") + assert response.status_code == 200 + # The response should contain the rendered scene setup template + assert "Play Pi-Rat" in response.text + # The database should be updated + session.refresh(player) + assert player.role == "pirat" + finally: + app.dependency_overrides.clear()