Bug fixes

This commit is contained in:
2026-06-09 19:12:19 -07:00
parent 274ae3fb07
commit 8650ebe5b2
12 changed files with 1075 additions and 383 deletions

View File

@@ -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()

View File

@@ -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"<p class='alert alert-danger'>{msg}</p>", status_code=400)
@@ -603,7 +549,8 @@ def play_card_route(
f"<div class='play-notification' style='position:fixed;bottom:20px;right:20px;background:#152238;border:1px solid #d4af37;padding:15px;border-radius:8px;z-index:1000;' onclick='this.remove()'>"
f"<p>{color_prefix} {res['details']}{drew_text}</p>"
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
f"</div>"
f"</div>",
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"<p class='alert alert-danger'>{msg}</p>", status_code=400)
@@ -626,7 +570,8 @@ def play_joker_route(
f"<div class='play-notification' style='position:fixed;bottom:20px;right:20px;background:#152238;border:1px solid #d4af37;padding:15px;border-radius:8px;z-index:1000;' onclick='this.remove()'>"
f"<p>🃏 Play Joker: Discarded obstacle and drew a new replacement!</p>"
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
f"</div>"
f"</div>",
headers={"HX-Trigger": "hand-updated"}
)
# 13. Toggle Objective Completion Checklist

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -16,7 +16,7 @@
<div class="card glass-panel section-basic">
<h3>I. Basic Rat Records</h3>
{% 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 %}
<!-- Saved View -->
<div class="read-only-sheet">
<div class="record-line"><strong>Look:</strong> {{ player.avatar_look }}</div>
@@ -30,7 +30,10 @@
</div>
{% else %}
<!-- Form View -->
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/save-basic" hx-swap="none" class="creation-form">
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/save-basic"
hx-target="#game-view"
hx-swap="innerHTML"
class="creation-form">
<div class="form-group">
<label for="avatar_look">What does your Pi-Rat look like?</label>
<div class="input-row">

View File

@@ -3,13 +3,14 @@
<p class="section-desc">Answer questions that your crewmates have delegated to you.</p>
<div class="inbox-list">
<p class="empty-text text-center inbox-empty-message">No tasks in your inbox. Check back when other players delegate to you!</p>
{% if inbox_tasks %}
{% for task in inbox_tasks %}
{% set p = task.player %}
{% if task.type == 'like' %}
<div class="inbox-item glass-panel">
<div class="inbox-item glass-panel" id="inbox-item-like-{{ p.id }}">
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-delegate/{{ p.id }}/like"
hx-target="#inbox-card"
hx-target="#inbox-item-like-{{ p.id }}"
hx-swap="outerHTML">
<label>What do you <strong>LIKE</strong> about <strong>{{ p.name }}</strong>?</label>
<div class="input-row">
@@ -20,9 +21,9 @@
</form>
</div>
{% else %}
<div class="inbox-item glass-panel">
<div class="inbox-item glass-panel" id="inbox-item-hate-{{ p.id }}">
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-delegate/{{ p.id }}/hate"
hx-target="#inbox-card"
hx-target="#inbox-item-hate-{{ p.id }}"
hx-swap="outerHTML">
<label>What do you <strong>HATE</strong> about <strong>{{ p.name }}</strong>?</label>
<div class="input-row">
@@ -34,8 +35,6 @@
</div>
{% endif %}
{% endfor %}
{% else %}
<p class="empty-text text-center">No tasks in your inbox. Check back when other players delegate to you!</p>
{% endif %}
</div>
</div>

View File

@@ -1,63 +0,0 @@
<div class="challenges-container" id="scene-challenges-container"
hx-get="/game/{{ game.id }}/scene/challenges?player_id={{ player.id }}"
hx-trigger="every 4s"
hx-swap="outerHTML">
{% for chal in game.challenges if chal.is_active %}
{% set applied_ids = json_loads(chal.applied_obstacle_ids) %}
<div class="challenge-item">
<h4>{{ chal.title }}</h4>
{% if chal.description %}
<p class="desc">{{ chal.description }}</p>
{% endif %}
<div class="applied-obstacles">
<h5>Applied Obstacles to beat:</h5>
{% for obs_id in applied_ids %}
{% set obs = get_obstacle_by_id(obs_id) %}
{% if obs %}
<div class="applied-obs-row glass-panel">
<div class="obs-info">
<span class="symbol suit-{{ obs.suit.lower() }}">{{ obs.symbol }}</span>
<strong>{{ obs.title }}</strong> (Difficulty:
{% if parse_card(obs.original_card).value in ["J", "Q", "K"] %}
Rank ({{ player.rank }})
{% else %}
{{ obs.current_value }}
{% endif %})
</div>
<!-- Play Card Form (Only for Pi-Rats) -->
{% if player.role == "pirat" %}
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/play-card"
hx-swap="none"
class="play-card-form inline-form">
<input type="hidden" name="challenge_id" value="{{ chal.id }}">
<input type="hidden" name="obstacle_id" value="{{ obs.id }}">
<select name="card_code" class="select-field select-small" required>
<option value="" disabled selected>Play card...</option>
{% for card in hand %}
{% set parsed = parse_card(card) %}
<option value="{{ card }}">
{{ parsed.display }}
{% if parsed.value == "J" %} - (Secret Tech: J)
{% elif parsed.value == "Q" %} - (Secret Tech: Q)
{% elif parsed.value == "K" %} - (Secret Tech: K)
{% endif %}
</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary btn-small">Fight</button>
</form>
{% else %}
<span class="sub-label italic">Waiting for Pi-Rats to resolve</span>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% else %}
<p class="empty-text text-center">No active challenges. Deep players should describe the scene and call for challenges!</p>
{% endfor %}
</div>

View File

@@ -1,8 +1,75 @@
<!-- TOP STATUS BAR: Scene Info and Captain -->
<div class="scene-status-banner glass-panel" style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(7, 11, 18, 0.6); border: 1px solid rgba(212, 175, 55, 0.2); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
<!-- Captain Badge -->
{% set ns = namespace(captain=None) %}
{% for p in game.players %}
{% if is_captain(p, game.players) %}
{% set ns.captain = p %}
{% endif %}
{% endfor %}
<div>
{% if ns.captain %}
<span class="captain-badge" style="background: rgba(212, 175, 55, 0.15); border: 1px solid var(--gold); color: var(--gold); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; font-weight: 700; font-family: var(--font-heading); display: inline-flex; align-items: center; gap: 0.5rem;">
🏴‍☠️ Captain: {{ ns.captain.name }} (Rank {{ ns.captain.rank }})
</span>
{% else %}
<span class="captain-badge" style="background: rgba(255, 255, 255, 0.05); border: 1px dashed rgba(255, 255, 255, 0.2); color: var(--text-muted); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; display: inline-flex; align-items: center; gap: 0.5rem;">
🏴‍☠️ Captain: None (No Pi-Rats)
</span>
{% endif %}
</div>
<!-- Active Scene Roster Mini-list -->
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-heading); margin-right: 0.25rem;">Active Roster:</span>
{% for p in game.players %}
{% if p.role == 'deep' %}
<span class="player-chip deep-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem;">
🌊 {{ p.name }} (Deep)
</span>
{% elif p.role == 'pirat' %}
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {% if is_captain(p, game.players) %}border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);{% endif %}">
🐀 {{ p.name }} (Rank {{ p.rank }}){% if is_captain(p, game.players) %} ⭐{% endif %}
</span>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% for obs in game.obstacles %}
<div class="obstacle-item suit-{{ obs.suit.lower() }}">
<div class="obstacle-meta">
<span class="suit-badge">{{ obs.symbol }} {{ obs.suit.upper() }}</span>
<span class="card-code">{{ obs.original_card }}</span>
{% 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) %}
<div class="obstacle-item suit-{{ obs.suit.lower() }}"
{% if player.role != "deep" %}
data-obstacle-id="{{ obs.id }}"
ondragover="dragOver(event)"
ondragenter="dragEnter(event)"
ondragleave="dragLeave(event)"
ondrop="dropCard(event)"
{% endif %}>
<div class="obstacle-card-wrapper" style="display: flex; justify-content: center; align-items: center;">
<div class="card-medium suit-{{ active_parsed.suit.lower() }} {% if active_parsed.is_joker %}joker-card{% endif %}" title="Active Card: {{ active_parsed.display }}">
<div class="card-corner top-left">
<span class="val">{{ active_parsed.value }}</span>
<span class="suit">{{ active_parsed.symbol }}</span>
</div>
<div class="card-center">
{% if active_parsed.is_joker %}
🃏
{% else %}
<span class="giant-symbol" style="font-size: 1.5rem;">{{ active_parsed.symbol }}</span>
{% endif %}
</div>
<div class="card-corner bottom-right">
<span class="val">{{ active_parsed.value }}</span>
<span class="suit">{{ active_parsed.symbol }}</span>
</div>
</div>
</div>
<div class="obstacle-details">
<h4>{{ obs.title }}</h4>
@@ -14,13 +81,27 @@
<span class="val-label">Current Difficulty</span>
<span class="val-number">
{% if parse_card(obs.original_card).value in ["J", "Q", "K"] %}
Rank ({{ player_rank }})
Rank ({{ player.rank }})
{% else %}
{{ obs.current_value }}
{% endif %}
</span>
</div>
<!-- Successes Tracker -->
{% 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 %}
<div class="obstacle-successes-display text-center">
<span class="val-label">Successes</span>
<span class="val-number" style="font-size: 1.5rem;">
{{ obs.success_count }} / {{ ns_non_deep.count }}
</span>
</div>
<!-- Played Cards Column -->
<div class="played-column">
<h5>Card History (Column)</h5>

View File

@@ -1,4 +1,4 @@
<div class="scene-view-layout">
<div class="scene-view-layout" id="scene-layout-container" data-game-id="{{ game.id }}" data-player-id="{{ player.id }}">
<!-- LEFT COLUMN: The Shared Table -->
<div class="table-column">
@@ -10,14 +10,81 @@
</div>
<div class="obstacles-container" id="scene-obstacles-container"
hx-get="/game/{{ game.id }}/scene/obstacles?player_rank={{ player.rank }}"
hx-get="/game/{{ game.id }}/scene/obstacles?player_id={{ player.id }}"
hx-trigger="every 2s"
hx-swap="innerHTML">
<!-- TOP STATUS BAR: Scene Info and Captain -->
<div class="scene-status-banner glass-panel" style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(7, 11, 18, 0.6); border: 1px solid rgba(212, 175, 55, 0.2); border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
<!-- Captain Badge -->
{% set ns = namespace(captain=None) %}
{% for p in game.players %}
{% if is_captain(p, game.players) %}
{% set ns.captain = p %}
{% endif %}
{% endfor %}
<div>
{% if ns.captain %}
<span class="captain-badge" style="background: rgba(212, 175, 55, 0.15); border: 1px solid var(--gold); color: var(--gold); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; font-weight: 700; font-family: var(--font-heading); display: inline-flex; align-items: center; gap: 0.5rem;">
🏴‍☠️ Captain: {{ ns.captain.name }} (Rank {{ ns.captain.rank }})
</span>
{% else %}
<span class="captain-badge" style="background: rgba(255, 255, 255, 0.05); border: 1px dashed rgba(255, 255, 255, 0.2); color: var(--text-muted); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; display: inline-flex; align-items: center; gap: 0.5rem;">
🏴‍☠️ Captain: None (No Pi-Rats)
</span>
{% endif %}
</div>
<!-- Active Scene Roster Mini-list -->
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-heading); margin-right: 0.25rem;">Active Roster:</span>
{% for p in game.players %}
{% if p.role == 'deep' %}
<span class="player-chip deep-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem;">
🌊 {{ p.name }} (Deep)
</span>
{% elif p.role == 'pirat' %}
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {% if is_captain(p, game.players) %}border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);{% endif %}">
🐀 {{ p.name }} (Rank {{ p.rank }}){% if is_captain(p, game.players) %} ⭐{% endif %}
</span>
{% endif %}
{% endfor %}
</div>
</div>
</div>
{% for obs in game.obstacles %}
<div class="obstacle-item suit-{{ obs.suit.lower() }}">
<div class="obstacle-meta">
<span class="suit-badge">{{ obs.symbol }} {{ obs.suit.upper() }}</span>
<span class="card-code">{{ obs.original_card }}</span>
{% 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) %}
<div class="obstacle-item suit-{{ obs.suit.lower() }}"
{% if player.role != "deep" %}
data-obstacle-id="{{ obs.id }}"
ondragover="dragOver(event)"
ondragenter="dragEnter(event)"
ondragleave="dragLeave(event)"
ondrop="dropCard(event)"
{% endif %}>
<div class="obstacle-card-wrapper" style="display: flex; justify-content: center; align-items: center;">
<div class="card-medium suit-{{ active_parsed.suit.lower() }} {% if active_parsed.is_joker %}joker-card{% endif %}" title="Active Card: {{ active_parsed.display }}">
<div class="card-corner top-left">
<span class="val">{{ active_parsed.value }}</span>
<span class="suit">{{ active_parsed.symbol }}</span>
</div>
<div class="card-center">
{% if active_parsed.is_joker %}
🃏
{% else %}
<span class="giant-symbol" style="font-size: 1.5rem;">{{ active_parsed.symbol }}</span>
{% endif %}
</div>
<div class="card-corner bottom-right">
<span class="val">{{ active_parsed.value }}</span>
<span class="suit">{{ active_parsed.symbol }}</span>
</div>
</div>
</div>
<div class="obstacle-details">
<h4>{{ obs.title }}</h4>
@@ -36,6 +103,20 @@
</span>
</div>
<!-- Successes Tracker -->
{% 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 %}
<div class="obstacle-successes-display text-center">
<span class="val-label">Successes</span>
<span class="val-number" style="font-size: 1.5rem;">
{{ obs.success_count }} / {{ ns_non_deep.count }}
</span>
</div>
<!-- Played Cards Column -->
<div class="played-column">
<h5>Card History (Column)</h5>
@@ -61,135 +142,42 @@
{% endfor %}
</div>
</div>
<!-- Challenges List -->
<div class="card glass-panel challenges-card">
<h3>⚔️ Active Challenges</h3>
<div class="challenges-container" id="scene-challenges-container"
hx-get="/game/{{ game.id }}/scene/challenges?player_id={{ player.id }}"
hx-trigger="every 4s"
hx-swap="outerHTML">
{% for chal in game.challenges if chal.is_active %}
{% set applied_ids = json_loads(chal.applied_obstacle_ids) %}
<div class="challenge-item">
<h4>{{ chal.title }}</h4>
{% if chal.description %}
<p class="desc">{{ chal.description }}</p>
{% endif %}
<div class="applied-obstacles">
<h5>Applied Obstacles to beat (Success on any, but failures carry complications!):</h5>
{% for obs_id in applied_ids %}
{% set obs = get_obstacle_by_id(obs_id) %}
{% if obs %}
<div class="applied-obs-row glass-panel">
<div class="obs-info">
<span class="symbol suit-{{ obs.suit.lower() }}">{{ obs.symbol }}</span>
<strong>{{ obs.title }}</strong> (Difficulty:
{% if parse_card(obs.original_card).value in ["J", "Q", "K"] %}
Rank ({{ player.rank }})
{% else %}
{{ obs.current_value }}
{% endif %})
</div>
<!-- Play Card Form (Only for Pi-Rats) -->
{% if player.role == "pirat" %}
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/play-card"
hx-swap="none"
class="play-card-form inline-form">
<input type="hidden" name="challenge_id" value="{{ chal.id }}">
<input type="hidden" name="obstacle_id" value="{{ obs.id }}">
<select name="card_code" class="select-field select-small" required>
<option value="" disabled selected>Play card...</option>
{% for card in hand %}
{% set parsed = parse_card(card) %}
<option value="{{ card }}">
{{ parsed.display }}
{% if parsed.value == "J" %} - (Secret Tech: J)
{% elif parsed.value == "Q" %} - (Secret Tech: Q)
{% elif parsed.value == "K" %} - (Secret Tech: K)
{% endif %}
</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary btn-small">Fight</button>
</form>
{% else %}
<span class="sub-label italic">Waiting for Pi-Rats to resolve</span>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% else %}
<p class="empty-text text-center">No active challenges. Deep players should describe the scene and call for challenges!</p>
{% endfor %}
</div>
</div>
<!-- DEEP CONTROLS: Create Challenges and End Scene -->
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
<div class="private-column">
<!-- DEEP CONTROLS: End Scene -->
{% if player.role == "deep" %}
<div class="card glass-panel deep-panel-card">
<h3 class="deep-text">🌊 Deep Master Control Panel</h3>
<!-- Challenge Creator -->
<form hx-post="/game/{{ game.id }}/challenge/create" hx-swap="none" class="challenge-creator-form">
<h4>1. Call for a New Challenge:</h4>
<div class="form-group">
<label for="chal-title">Challenge Title:</label>
<input type="text" id="chal-title" name="title" placeholder="e.g. Steal the cell keys from the sleeping sailor" required class="input-field">
</div>
<div class="form-group">
<label for="chal-desc">Narration Context (Optional):</label>
<input type="text" id="chal-desc" name="description" placeholder="e.g. You need to sneak past without creaking the floorboard." class="input-field">
</div>
<div class="form-group">
<label>2. Select Applied Obstacles (Must select at least 1):</label>
<div class="obstacles-checkboxes">
{% for obs in game.obstacles %}
<label class="checkbox-label">
<input type="checkbox" name="obstacle_ids" value="{{ obs.id }}">
<span>{{ obs.symbol }} {{ obs.title }} (Difficulty: {{ obs.current_value }})</span>
</label>
{% endfor %}
</div>
</div>
<button type="submit" class="btn btn-deep btn-full glow-effect">Spawn Challenge</button>
</form>
<hr class="deep-divider">
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
<!-- End Scene Button -->
<div class="scene-upkeep-controls text-center">
<h4>3. End the Current Scene:</h4>
<p class="info-text">Deep players can conclude the scene once players have made attempts or the obstacle list is depleted.</p>
<div class="scene-upkeep-controls text-center" style="margin-top: 1rem;">
<p class="info-text">Conclude the scene once players have made attempts or the obstacle list is depleted.</p>
<button hx-post="/game/{{ game.id }}/scene/end"
hx-swap="none"
class="btn btn-danger btn-large">
class="btn btn-danger btn-large btn-full glow-effect">
End Scene & Proceed to Ranking
</button>
</div>
</div>
{% endif %}
</div>
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
<div class="private-column">
<!-- Player Hand Card -->
<div class="card glass-panel hand-card">
<h3>🃏 Your Hand</h3>
<p class="section-desc">Keep your cards secret! Max hand size: {{ max_hand_size }} cards. {% if is_captain(player, game.players) %} (Includes +1 Captain bonus) {% endif %}</p>
<p class="section-desc">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 %}</p>
<div class="hand-flex">
{% for card in hand %}
{% set parsed = parse_card(card) %}
<div class="card-large suit-{{ parsed.suit.lower() }} {% if parsed.is_joker %}joker-card{% endif %}">
<div class="card-large suit-{{ parsed.suit.lower() }} {% if parsed.is_joker %}joker-card{% endif %}"
{% if player.role != "deep" %}
draggable="true"
ondragstart="dragStart(event)"
ondragend="dragEnd(event)"
data-card-code="{{ card }}"
{% endif %}>
<div class="card-corner top-left">
<span class="val">{{ parsed.value }}</span>
<span class="suit">{{ parsed.symbol }}</span>
@@ -211,19 +199,6 @@
<div class="tech-tag" title="Automatic success when played">Q: "{{ player.tech_queen }}"</div>
{% elif parsed.value == "K" %}
<div class="tech-tag" title="Automatic success when played">K: "{{ player.tech_king }}"</div>
{% elif parsed.is_joker %}
<div class="joker-action">
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/play-joker" hx-swap="none" class="inline-form">
<input type="hidden" name="card_code" value="{{ card }}">
<select name="obstacle_id" class="select-field select-xsmall" required>
<option value="" disabled selected>Discard Obstacle...</option>
{% for obs in game.obstacles %}
<option value="{{ obs.id }}">{{ obs.title }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-small btn-primary">Play</button>
</form>
</div>
{% endif %}
</div>
@@ -239,6 +214,7 @@
</div>
<!-- Player Character Sheet Card -->
{% if player.role != "deep" %}
<div class="card glass-panel sheet-card">
<h3>🐀 Your Character sheet</h3>
@@ -307,6 +283,7 @@
</div>
</div>
</div>
{% endif %}
</div>
</div>
@@ -316,3 +293,60 @@
hx-trigger="every 2s"
hx-swap="none"
style="display:none;"></div>
<script>
window.dragStart = function(ev) {
ev.dataTransfer.setData("text/plain", ev.currentTarget.getAttribute("data-card-code"));
ev.currentTarget.classList.add("dragging");
};
window.dragEnd = function(ev) {
ev.currentTarget.classList.remove("dragging");
};
window.dragOver = function(ev) {
ev.preventDefault();
};
window.dragEnter = function(ev) {
ev.preventDefault();
const item = ev.currentTarget.closest(".obstacle-item");
if (item) {
item.classList.add("drag-over");
}
};
window.dragLeave = function(ev) {
const item = ev.currentTarget.closest(".obstacle-item");
if (item) {
item.classList.remove("drag-over");
}
};
window.dropCard = function(ev) {
ev.preventDefault();
const item = ev.currentTarget.closest(".obstacle-item");
if (item) {
item.classList.remove("drag-over");
const cardCode = ev.dataTransfer.getData("text/plain");
const obstacleId = item.getAttribute("data-obstacle-id");
if (cardCode && obstacleId) {
const container = document.getElementById("scene-layout-container");
if (container) {
const gameId = container.getAttribute("data-game-id");
const playerId = container.getAttribute("data-player-id");
const isJoker = cardCode.startsWith("Joker");
const url = isJoker
? `/game/${gameId}/player/${playerId}/play-joker`
: `/game/${gameId}/player/${playerId}/play-card`;
htmx.ajax('POST', url, {
values: { obstacle_id: obstacleId, card_code: cardCode },
swap: 'none'
});
}
}
}
};
</script>

View File

@@ -38,18 +38,20 @@
{{ locked_role.upper() }}
</div>
<!-- Silently auto-select this role in the database using HTMX onload if not already set -->
<div hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-role?role={{ locked_role }}" hx-trigger="load" style="display:none;"></div>
<div hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-role?role={{ locked_role }}" hx-trigger="load" hx-swap="none" style="display:none;"></div>
</div>
{% else %}
<div class="role-buttons">
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-role?role=pirat"
hx-swap="none"
hx-target="#game-view"
onclick="const p=this.closest('.role-buttons'); if(p) p.querySelectorAll('.role-btn').forEach(b=>b.classList.remove('active')); this.classList.add('active');"
class="btn btn-large role-btn pirat-btn {% if player.role == 'pirat' %}active{% endif %}">
🐀 Play Pi-Rat
</button>
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-role?role=deep"
hx-swap="none"
hx-target="#game-view"
onclick="const p=this.closest('.role-buttons'); if(p) p.querySelectorAll('.role-btn').forEach(b=>b.classList.remove('active')); this.classList.add('active');"
class="btn btn-large role-btn deep-btn {% if player.role == 'deep' %}active{% endif %}">
🌊 Play the Deep
</button>
@@ -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
</button>
</div>
</div>

View File

@@ -64,36 +64,274 @@
{% else %}
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/assign-face-techniques"
hx-target="#techniques-card"
hx-swap="outerHTML">
<div class="form-group">
<label for="tech_j">Jack (J) Secret Technique:</label>
<select name="jack" id="tech_j" class="select-field" required>
<option value="" disabled selected>Choose technique...</option>
hx-swap="outerHTML"
id="face-tech-form"
class="face-tech-drag-form">
<!-- Hidden inputs to submit assigned values -->
<input type="hidden" name="jack" id="input-jack" value="">
<input type="hidden" name="queen" id="input-queen" value="">
<input type="hidden" name="king" id="input-king" value="">
<!-- Available Techniques Pool -->
<div class="available-techniques-container">
<h4>Available Swapped Techniques</h4>
<p class="instruction-help">Drag a technique onto a card slot below, or click/tap a technique and then click/tap a slot to assign it.</p>
<div class="techniques-drag-pool" id="tech-pool">
{% for t in swapped_techs %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
<div class="draggable-tech-chip"
draggable="true"
data-tech="{{ t }}"
id="tech-chip-{{ loop.index0 }}">
<span class="drag-icon">⋮⋮</span> {{ t }}
</div>
<div class="form-group">
<label for="tech_q">Queen (Q) Secret Technique:</label>
<select name="queen" id="tech_q" class="select-field" required>
<option value="" disabled selected>Choose technique...</option>
{% for t in swapped_techs %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label for="tech_k">King (K) Secret Technique:</label>
<select name="king" id="tech_k" class="select-field" required>
<option value="" disabled selected>Choose technique...</option>
{% for t in swapped_techs %}
<option value="{{ t }}">{{ t }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary btn-full glow-effect">Confirm Assignments & Ready Up</button>
<!-- Visual Card Slots Grid -->
<div class="face-cards-slots-grid">
<!-- JACK SLOT -->
<div class="face-card-slot-wrapper">
<div class="face-card-slot" data-slot="jack" id="slot-jack">
<div class="card-bg-letter">J</div>
<div class="card-slot-header">
<span class="rank">J</span>
<span class="suit-icon"></span>
</div>
<div class="card-slot-body">
<div class="card-title">Jack</div>
<div class="drop-zone-placeholder">Drag or Click to Assign</div>
<div class="assigned-tech-content"></div>
</div>
<div class="card-slot-footer">
<span class="suit-icon"></span>
<span class="rank">J</span>
</div>
</div>
</div>
<!-- QUEEN SLOT -->
<div class="face-card-slot-wrapper">
<div class="face-card-slot" data-slot="queen" id="slot-queen">
<div class="card-bg-letter">Q</div>
<div class="card-slot-header">
<span class="rank">Q</span>
<span class="suit-icon"></span>
</div>
<div class="card-slot-body">
<div class="card-title">Queen</div>
<div class="drop-zone-placeholder">Drag or Click to Assign</div>
<div class="assigned-tech-content"></div>
</div>
<div class="card-slot-footer">
<span class="suit-icon"></span>
<span class="rank">Q</span>
</div>
</div>
</div>
<!-- KING SLOT -->
<div class="face-card-slot-wrapper">
<div class="face-card-slot" data-slot="king" id="slot-king">
<div class="card-bg-letter">K</div>
<div class="card-slot-header">
<span class="rank">K</span>
<span class="suit-icon"></span>
</div>
<div class="card-slot-body">
<div class="card-title">King</div>
<div class="drop-zone-placeholder">Drag or Click to Assign</div>
<div class="assigned-tech-content"></div>
</div>
<div class="card-slot-footer">
<span class="suit-icon"></span>
<span class="rank">K</span>
</div>
</div>
</div>
</div>
<button type="submit" id="submit-assignments-btn" class="btn btn-primary btn-full glow-effect" disabled>
Confirm Assignments & Ready Up
</button>
</form>
<script>
(function() {
const form = document.getElementById('face-tech-form');
if (!form) return;
const chips = form.querySelectorAll('.draggable-tech-chip');
const slots = form.querySelectorAll('.face-card-slot');
const inputs = {
jack: form.querySelector('#input-jack'),
queen: form.querySelector('#input-queen'),
king: form.querySelector('#input-king')
};
const submitBtn = form.querySelector('#submit-assignments-btn');
let selectedChip = null;
// Helper: update form validity and submit button state
function updateSubmitButton() {
const jVal = inputs.jack.value;
const qVal = inputs.queen.value;
const kVal = inputs.king.value;
const allAssigned = jVal && qVal && kVal;
const uniqueValues = new Set([jVal, qVal, kVal]).size === 3;
submitBtn.disabled = !(allAssigned && uniqueValues);
}
// Helper: assign a technique to a slot
function assignTech(techText, slotName, chipId) {
// Unassign from any other slot first
for (const key in inputs) {
if (inputs[key].value === techText) {
unassignSlot(key);
}
}
// Unassign current content in this slot
unassignSlot(slotName);
// Set input value
inputs[slotName].value = techText;
// Update slot UI
const slotEl = form.querySelector(`#slot-${slotName}`);
slotEl.classList.add('has-assignment');
const contentContainer = slotEl.querySelector('.assigned-tech-content');
contentContainer.innerHTML = `
<div class="assigned-tech-chip">
${techText}
<span class="remove-tech-btn" onclick="event.stopPropagation(); window.unassignTech('${slotName}')">×</span>
</div>
`;
// Mark chip as assigned in pool
const chipEl = form.querySelector(`#${chipId}`);
if (chipEl) {
chipEl.classList.add('assigned-hidden');
chipEl.classList.remove('selected');
}
if (selectedChip) {
selectedChip.classList.remove('selected');
selectedChip = null;
}
updateSubmitButton();
}
// Helper: unassign a slot
function unassignSlot(slotName) {
const input = inputs[slotName];
const prevValue = input.value;
if (!prevValue) return;
input.value = '';
const slotEl = form.querySelector(`#slot-${slotName}`);
slotEl.classList.remove('has-assignment');
const contentContainer = slotEl.querySelector('.assigned-tech-content');
contentContainer.innerHTML = '';
// Return chip to pool
const chipsList = form.querySelectorAll('.draggable-tech-chip');
chipsList.forEach(chip => {
if (chip.getAttribute('data-tech') === prevValue) {
chip.classList.remove('assigned-hidden');
}
});
updateSubmitButton();
}
window.unassignTech = function(slotName) {
unassignSlot(slotName);
};
// Setup drag start / end on chips
chips.forEach(chip => {
chip.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', chip.getAttribute('data-tech'));
e.dataTransfer.setData('chip-id', chip.id);
chip.classList.add('dragging');
});
chip.addEventListener('dragend', () => {
chip.classList.remove('dragging');
});
chip.addEventListener('click', (e) => {
e.stopPropagation();
if (chip.classList.contains('assigned-hidden')) return;
if (selectedChip) {
selectedChip.classList.remove('selected');
}
if (selectedChip === chip) {
selectedChip = null;
} else {
selectedChip = chip;
chip.classList.add('selected');
}
});
});
// Setup drop zone on card slots
slots.forEach(slot => {
const slotName = slot.getAttribute('data-slot');
slot.addEventListener('dragover', (e) => {
e.preventDefault();
if (!slot.classList.contains('has-assignment')) {
slot.classList.add('drag-over');
}
});
slot.addEventListener('dragleave', () => {
slot.classList.remove('drag-over');
});
slot.addEventListener('drop', (e) => {
e.preventDefault();
slot.classList.remove('drag-over');
const techText = e.dataTransfer.getData('text/plain');
const chipId = e.dataTransfer.getData('chip-id');
if (techText && chipId) {
assignTech(techText, slotName, chipId);
}
});
slot.addEventListener('click', () => {
if (selectedChip) {
const techText = selectedChip.getAttribute('data-tech');
const chipId = selectedChip.id;
assignTech(techText, slotName, chipId);
}
});
});
// Clear selection when clicking backdrop card area
const cardContainer = document.getElementById('techniques-card');
if (cardContainer) {
cardContainer.addEventListener('click', (e) => {
if (selectedChip && !e.target.closest('.draggable-tech-chip')) {
selectedChip.classList.remove('selected');
selectedChip = null;
}
});
}
})();
</script>
{% endif %}
{% endif %}
</div>

View File

@@ -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()