Bug fixes
This commit is contained in:
@@ -2,7 +2,7 @@ import json
|
|||||||
import random
|
import random
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from .models import Game, Player, Obstacle, Challenge, Vote
|
from .models import Game, Player, Obstacle, Vote
|
||||||
from . import cards
|
from . import cards
|
||||||
|
|
||||||
# --- Card and Hand Utilities ---
|
# --- Card and Hand Utilities ---
|
||||||
@@ -30,13 +30,13 @@ def calculate_max_hand_size(player: Player, players_in_scene: List[Player]) -> i
|
|||||||
- Captain gets +1
|
- Captain gets +1
|
||||||
If all players have the same Rank, they are all 'middle' and get 3 cards.
|
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
|
# 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.
|
# 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.
|
# 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
|
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:
|
if not pi_rats:
|
||||||
return 3
|
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:
|
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."""
|
"""Helper to check if a player is currently the captain in the scene."""
|
||||||
if player.role != "pirat":
|
if player.role == "deep":
|
||||||
return False
|
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:
|
if not pi_rats:
|
||||||
return False
|
return False
|
||||||
max_rank = max(p.rank for p in pi_rats)
|
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)
|
return db.get(Player, player_id)
|
||||||
|
|
||||||
def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player:
|
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(
|
player = Player(
|
||||||
game_id=game_id,
|
game_id=game_id,
|
||||||
name=name,
|
name=name,
|
||||||
is_creator=is_creator,
|
is_creator=is_creator,
|
||||||
rank=2, # default starting rank (will be set properly when starting character creation)
|
rank=2, # default starting rank (will be set properly when starting character creation)
|
||||||
hand_cards="[]",
|
hand_cards="[]",
|
||||||
is_ready=False
|
is_ready=False,
|
||||||
|
role=role
|
||||||
)
|
)
|
||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -365,7 +371,13 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
|
|
||||||
players = game.players
|
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")
|
pirats_count = sum(1 for p in players if p.role == "pirat")
|
||||||
deeps_count = sum(1 for p in players if p.role == "deep")
|
deeps_count = sum(1 for p in players if p.role == "deep")
|
||||||
|
|
||||||
@@ -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."
|
return False, f"{p.name} was Deep in the last scene and must play a Pi-Rat in this scene."
|
||||||
|
|
||||||
# Everything is valid! Start the scene.
|
# Everything is valid! Start the scene.
|
||||||
# A. Reset obstacles and challenges
|
# A. Reset obstacles
|
||||||
for obs in game.obstacles:
|
for obs in game.obstacles:
|
||||||
db.delete(obs)
|
db.delete(obs)
|
||||||
for chal in game.challenges:
|
|
||||||
db.delete(chal)
|
|
||||||
for vote in game.votes:
|
for vote in game.votes:
|
||||||
db.delete(vote)
|
db.delete(vote)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -469,25 +479,11 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
|
|
||||||
# --- Scene Gameplay Operations ---
|
# --- 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(
|
def play_card_on_obstacle(
|
||||||
db: Session,
|
db: Session,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
obstacle_id: str,
|
obstacle_id: str,
|
||||||
card_code: str,
|
card_code: str
|
||||||
challenge_id: str
|
|
||||||
) -> Tuple[bool, str, Dict[str, Any]]:
|
) -> Tuple[bool, str, Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Plays a card from player's hand against an active obstacle.
|
Plays a card from player's hand against an active obstacle.
|
||||||
@@ -640,17 +636,6 @@ def play_card_on_obstacle(
|
|||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
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 = {
|
res_dict = {
|
||||||
"success": success,
|
"success": success,
|
||||||
"details": details,
|
"details": details,
|
||||||
@@ -728,11 +713,6 @@ def end_scene_and_transition(db: Session, game_id: str):
|
|||||||
for p in game.players:
|
for p in game.players:
|
||||||
p.is_ready = False
|
p.is_ready = False
|
||||||
db.add(p)
|
db.add(p)
|
||||||
|
|
||||||
# Remove active challenges
|
|
||||||
for chal in game.challenges:
|
|
||||||
db.delete(chal)
|
|
||||||
|
|
||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
from . import crud
|
from . import crud
|
||||||
from . import cards
|
from . import cards
|
||||||
from .database import create_db_and_tables, get_session
|
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")
|
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)
|
@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)):
|
def scene_obstacles_snippet(request: Request, game_id: str, player_id: str, 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)):
|
|
||||||
game = crud.get_game(db, game_id)
|
game = crud.get_game(db, game_id)
|
||||||
player = crud.get_player(db, player_id)
|
player = crud.get_player(db, player_id)
|
||||||
if not game or not player:
|
if not game or not player:
|
||||||
return HTMLResponse("")
|
return HTMLResponse("")
|
||||||
|
|
||||||
def get_obstacle_by_id(obs_id: str) -> Optional[Obstacle]:
|
|
||||||
return db.get(Obstacle, obs_id)
|
|
||||||
|
|
||||||
hand = crud.get_player_hand(player)
|
hand = crud.get_player_hand(player)
|
||||||
return templates.TemplateResponse("scene_challenges_snippet.html", {
|
return templates.TemplateResponse("scene_obstacles_snippet.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"game": game,
|
"game": game,
|
||||||
"player": player,
|
"player": player,
|
||||||
"hand": hand,
|
"hand": hand,
|
||||||
"parse_card": cards.parse_card,
|
"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)
|
@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 ---
|
# --- HTMX View Polling Render Route ---
|
||||||
|
|
||||||
@app.get("/game/{game_id}/player/{player_id}/view", response_class=HTMLResponse)
|
@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)
|
game = crud.get_game(db, game_id)
|
||||||
player = crud.get_player(db, player_id)
|
player = crud.get_player(db, player_id)
|
||||||
if not game or not player:
|
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,
|
"is_captain": crud.is_player_captain,
|
||||||
"json_loads": json.loads,
|
"json_loads": json.loads,
|
||||||
"deck_count": len(crud.get_game_deck(game)),
|
"deck_count": len(crud.get_game_deck(game)),
|
||||||
"inbox_tasks": inbox_tasks
|
"inbox_tasks": inbox_tasks,
|
||||||
|
"edit_mode": edit
|
||||||
}
|
}
|
||||||
|
|
||||||
# Select template based on current game phase
|
# Select template based on current game phase
|
||||||
@@ -272,7 +262,7 @@ def player_save_basic_details(
|
|||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
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)
|
# 3. Edit basic character details (reset values to trigger form view)
|
||||||
@app.post("/game/{game_id}/player/{player_id}/edit-basic")
|
@app.post("/game/{game_id}/player/{player_id}/edit-basic")
|
||||||
@@ -281,18 +271,8 @@ def player_edit_basic_details(
|
|||||||
player_id: str,
|
player_id: str,
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
player = crud.get_player(db, player_id)
|
# Trigger HTMX reload with edit mode active
|
||||||
if not player:
|
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view?edit=true", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
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)
|
|
||||||
|
|
||||||
# Auto-assign unassigned delegated tasks (Like and Hate) randomly to crewmates
|
# Auto-assign unassigned delegated tasks (Like and Hate) randomly to crewmates
|
||||||
@app.post("/game/{game_id}/player/{player_id}/delegate/auto")
|
@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
|
# 5. Answer a delegated question for another player
|
||||||
@app.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
|
@app.post("/game/{game_id}/player/{player_id}/submit-delegate/{target_player_id}/{question_type}")
|
||||||
def player_submit_delegated_answer(
|
def player_submit_delegated_answer(
|
||||||
request: Request,
|
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str, # The answering player
|
player_id: str, # The answering player
|
||||||
target_player_id: str, # The player whose sheet we are filling out
|
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)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
crud.submit_delegated_answer(db, target_player_id, question_type, answer.strip(), player_id)
|
crud.submit_delegated_answer(db, target_player_id, question_type, answer.strip(), player_id)
|
||||||
|
return HTMLResponse("")
|
||||||
# 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
|
|
||||||
})
|
|
||||||
|
|
||||||
# 6. Submit 3 Secret Pirate Techniques
|
# 6. Submit 3 Secret Pirate Techniques
|
||||||
@app.post("/game/{game_id}/player/{player_id}/submit-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
|
# 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(
|
def player_set_role(
|
||||||
|
request: Request,
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
role: str,
|
role: str,
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
crud.update_scene_role(db, player_id, role)
|
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)
|
# 9. Start Scene (Verify Setup & Shuffle & Draw Obstacles)
|
||||||
@app.post("/game/{game_id}/scene/start")
|
@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)
|
return HTMLResponse(status_code=204)
|
||||||
|
|
||||||
# 10. Deep creates a new challenge
|
# 11. Pi-Rat plays card against an obstacle
|
||||||
@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
|
|
||||||
@app.post("/game/{game_id}/player/{player_id}/play-card")
|
@app.post("/game/{game_id}/player/{player_id}/play-card")
|
||||||
def play_card_route(
|
def play_card_route(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
player_id: str,
|
player_id: str,
|
||||||
challenge_id: str = Form(...),
|
|
||||||
obstacle_id: str = Form(...),
|
obstacle_id: str = Form(...),
|
||||||
card_code: str = Form(...),
|
card_code: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
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:
|
if not ok:
|
||||||
return HTMLResponse(f"<p class='alert alert-danger'>{msg}</p>", status_code=400)
|
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"<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"<p>{color_prefix} {res['details']}{drew_text}</p>"
|
||||||
f"<span style='font-size:0.8em;color:#aaa;'>Click to dismiss</span>"
|
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
|
# 12. Pi-Rat plays a Joker to replace an obstacle
|
||||||
@@ -615,10 +562,7 @@ def play_joker_route(
|
|||||||
obstacle_id: str = Form(...),
|
obstacle_id: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
db: Session = Depends(get_session)
|
||||||
):
|
):
|
||||||
# Retrieve any active challenge that contains this obstacle to use as a dummy,
|
ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code)
|
||||||
# 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, "")
|
|
||||||
if not ok:
|
if not ok:
|
||||||
return HTMLResponse(f"<p class='alert alert-danger'>{msg}</p>", status_code=400)
|
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"<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"<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"<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
|
# 13. Toggle Objective Completion Checklist
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ class Game(SQLModel, table=True):
|
|||||||
|
|
||||||
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
|
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
obstacles: List["Obstacle"] = 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)
|
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
|
|
||||||
class Player(SQLModel, table=True):
|
class Player(SQLModel, table=True):
|
||||||
@@ -65,16 +64,14 @@ class Obstacle(SQLModel, table=True):
|
|||||||
|
|
||||||
game: Optional[Game] = Relationship(back_populates="obstacles")
|
game: Optional[Game] = Relationship(back_populates="obstacles")
|
||||||
|
|
||||||
class Challenge(SQLModel, table=True):
|
@property
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
def success_count(self) -> int:
|
||||||
game_id: str = Field(foreign_key="game.id", index=True)
|
import json
|
||||||
title: str = Field(...)
|
try:
|
||||||
description: str = Field(default="")
|
cards_list = json.loads(self.played_cards)
|
||||||
applied_obstacle_ids: str = Field(default="[]") # JSON string of list of obstacle IDs
|
return sum(1 for c in cards_list if c.get("success") is True)
|
||||||
resolved_by_player_id: Optional[str] = Field(default=None) # Player ID if resolved (for logging/history)
|
except Exception:
|
||||||
is_active: bool = Field(default=True)
|
return 0
|
||||||
|
|
||||||
game: Optional[Game] = Relationship(back_populates="challenges")
|
|
||||||
|
|
||||||
class Vote(SQLModel, table=True):
|
class Vote(SQLModel, table=True):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||||
|
|||||||
@@ -578,6 +578,10 @@ body {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inbox-list:has(.inbox-item) .inbox-empty-message {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.inbox-item {
|
.inbox-item {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
@@ -763,12 +767,18 @@ body {
|
|||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
background: rgba(7, 11, 18, 0.4);
|
background: rgba(7, 11, 18, 0.4);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 0.8fr 2fr 1fr;
|
grid-template-columns: 0.8fr 2fr 1fr 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.obstacle-item {
|
||||||
|
grid-template-columns: 0.8fr 2fr 1.5fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.obstacle-item {
|
.obstacle-item {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -828,6 +838,21 @@ body {
|
|||||||
padding: 0.5rem;
|
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 {
|
.val-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -842,7 +867,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.played-column {
|
.played-column {
|
||||||
grid-column: span 3;
|
grid-column: span 4;
|
||||||
border-top: 1px solid rgba(255,255,255,0.05);
|
border-top: 1px solid rgba(255,255,255,0.05);
|
||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
@@ -1028,6 +1053,71 @@ body {
|
|||||||
margin-top: 1.5rem;
|
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 Widget (Large) */
|
||||||
.card-large {
|
.card-large {
|
||||||
width: 110px;
|
width: 110px;
|
||||||
@@ -1335,3 +1425,307 @@ body {
|
|||||||
.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; }
|
.select-small { padding: 0.5rem; font-size: 0.9rem; flex: 1; }
|
||||||
.select-xsmall { padding: 0.4rem; font-size: 0.85rem; }
|
.select-xsmall { padding: 0.4rem; font-size: 0.85rem; }
|
||||||
.btn-small { padding: 0.4rem 0.8rem; 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<div class="card glass-panel section-basic">
|
<div class="card glass-panel section-basic">
|
||||||
<h3>I. Basic Rat Records</h3>
|
<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 -->
|
<!-- Saved View -->
|
||||||
<div class="read-only-sheet">
|
<div class="read-only-sheet">
|
||||||
<div class="record-line"><strong>Look:</strong> {{ player.avatar_look }}</div>
|
<div class="record-line"><strong>Look:</strong> {{ player.avatar_look }}</div>
|
||||||
@@ -30,7 +30,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<!-- Form View -->
|
<!-- 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">
|
<div class="form-group">
|
||||||
<label for="avatar_look">What does your Pi-Rat look like?</label>
|
<label for="avatar_look">What does your Pi-Rat look like?</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
<p class="section-desc">Answer questions that your crewmates have delegated to you.</p>
|
<p class="section-desc">Answer questions that your crewmates have delegated to you.</p>
|
||||||
|
|
||||||
<div class="inbox-list">
|
<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 %}
|
{% if inbox_tasks %}
|
||||||
{% for task in inbox_tasks %}
|
{% for task in inbox_tasks %}
|
||||||
{% set p = task.player %}
|
{% set p = task.player %}
|
||||||
{% if task.type == 'like' %}
|
{% 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"
|
<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">
|
hx-swap="outerHTML">
|
||||||
<label>What do you <strong>LIKE</strong> about <strong>{{ p.name }}</strong>?</label>
|
<label>What do you <strong>LIKE</strong> about <strong>{{ p.name }}</strong>?</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
@@ -20,9 +21,9 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% 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"
|
<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">
|
hx-swap="outerHTML">
|
||||||
<label>What do you <strong>HATE</strong> about <strong>{{ p.name }}</strong>?</label>
|
<label>What do you <strong>HATE</strong> about <strong>{{ p.name }}</strong>?</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
@@ -34,8 +35,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
|
||||||
<p class="empty-text text-center">No tasks in your inbox. Check back when other players delegate to you!</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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 %}
|
{% for obs in game.obstacles %}
|
||||||
<div class="obstacle-item suit-{{ obs.suit.lower() }}">
|
{% set column_cards = json_loads(obs.played_cards) %}
|
||||||
<div class="obstacle-meta">
|
{% set active_card_code = column_cards[-1]["card"] if column_cards else obs.original_card %}
|
||||||
<span class="suit-badge">{{ obs.symbol }} {{ obs.suit.upper() }}</span>
|
{% set active_parsed = parse_card(active_card_code) %}
|
||||||
<span class="card-code">{{ obs.original_card }}</span>
|
<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>
|
||||||
<div class="obstacle-details">
|
<div class="obstacle-details">
|
||||||
<h4>{{ obs.title }}</h4>
|
<h4>{{ obs.title }}</h4>
|
||||||
@@ -14,13 +81,27 @@
|
|||||||
<span class="val-label">Current Difficulty</span>
|
<span class="val-label">Current Difficulty</span>
|
||||||
<span class="val-number">
|
<span class="val-number">
|
||||||
{% if parse_card(obs.original_card).value in ["J", "Q", "K"] %}
|
{% if parse_card(obs.original_card).value in ["J", "Q", "K"] %}
|
||||||
Rank ({{ player_rank }})
|
Rank ({{ player.rank }})
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ obs.current_value }}
|
{{ obs.current_value }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 -->
|
<!-- Played Cards Column -->
|
||||||
<div class="played-column">
|
<div class="played-column">
|
||||||
<h5>Card History (Column)</h5>
|
<h5>Card History (Column)</h5>
|
||||||
|
|||||||
@@ -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 -->
|
<!-- LEFT COLUMN: The Shared Table -->
|
||||||
<div class="table-column">
|
<div class="table-column">
|
||||||
|
|
||||||
@@ -10,14 +10,81 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="obstacles-container" id="scene-obstacles-container"
|
<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-trigger="every 2s"
|
||||||
hx-swap="innerHTML">
|
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 %}
|
{% for obs in game.obstacles %}
|
||||||
<div class="obstacle-item suit-{{ obs.suit.lower() }}">
|
{% set column_cards = json_loads(obs.played_cards) %}
|
||||||
<div class="obstacle-meta">
|
{% set active_card_code = column_cards[-1]["card"] if column_cards else obs.original_card %}
|
||||||
<span class="suit-badge">{{ obs.symbol }} {{ obs.suit.upper() }}</span>
|
{% set active_parsed = parse_card(active_card_code) %}
|
||||||
<span class="card-code">{{ obs.original_card }}</span>
|
<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>
|
||||||
<div class="obstacle-details">
|
<div class="obstacle-details">
|
||||||
<h4>{{ obs.title }}</h4>
|
<h4>{{ obs.title }}</h4>
|
||||||
@@ -36,6 +103,20 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 -->
|
<!-- Played Cards Column -->
|
||||||
<div class="played-column">
|
<div class="played-column">
|
||||||
<h5>Card History (Column)</h5>
|
<h5>Card History (Column)</h5>
|
||||||
@@ -61,135 +142,42 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Challenges List -->
|
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
||||||
<div class="card glass-panel challenges-card">
|
<div class="private-column">
|
||||||
<h3>⚔️ Active Challenges</h3>
|
<!-- DEEP CONTROLS: End Scene -->
|
||||||
|
|
||||||
<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 -->
|
|
||||||
{% if player.role == "deep" %}
|
{% if player.role == "deep" %}
|
||||||
<div class="card glass-panel deep-panel-card">
|
<div class="card glass-panel deep-panel-card">
|
||||||
<h3 class="deep-text">🌊 Deep Master Control Panel</h3>
|
<h3 class="deep-text text-center">🌊 Deep 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">
|
|
||||||
|
|
||||||
<!-- End Scene Button -->
|
<!-- End Scene Button -->
|
||||||
<div class="scene-upkeep-controls text-center">
|
<div class="scene-upkeep-controls text-center" style="margin-top: 1rem;">
|
||||||
<h4>3. End the Current Scene:</h4>
|
<p class="info-text">Conclude the scene once players have made attempts or the obstacle list is depleted.</p>
|
||||||
<p class="info-text">Deep players can conclude the scene once players have made attempts or the obstacle list is depleted.</p>
|
|
||||||
<button hx-post="/game/{{ game.id }}/scene/end"
|
<button hx-post="/game/{{ game.id }}/scene/end"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
class="btn btn-danger btn-large">
|
class="btn btn-danger btn-large btn-full glow-effect">
|
||||||
End Scene & Proceed to Ranking
|
End Scene & Proceed to Ranking
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
|
||||||
<div class="private-column">
|
|
||||||
|
|
||||||
<!-- Player Hand Card -->
|
<!-- Player Hand Card -->
|
||||||
<div class="card glass-panel hand-card">
|
<div class="card glass-panel hand-card">
|
||||||
<h3>🃏 Your Hand</h3>
|
<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">
|
<div class="hand-flex">
|
||||||
{% for card in hand %}
|
{% for card in hand %}
|
||||||
{% set parsed = parse_card(card) %}
|
{% 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">
|
<div class="card-corner top-left">
|
||||||
<span class="val">{{ parsed.value }}</span>
|
<span class="val">{{ parsed.value }}</span>
|
||||||
<span class="suit">{{ parsed.symbol }}</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>
|
<div class="tech-tag" title="Automatic success when played">Q: "{{ player.tech_queen }}"</div>
|
||||||
{% elif parsed.value == "K" %}
|
{% elif parsed.value == "K" %}
|
||||||
<div class="tech-tag" title="Automatic success when played">K: "{{ player.tech_king }}"</div>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -239,6 +214,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Player Character Sheet Card -->
|
<!-- Player Character Sheet Card -->
|
||||||
|
{% if player.role != "deep" %}
|
||||||
<div class="card glass-panel sheet-card">
|
<div class="card glass-panel sheet-card">
|
||||||
<h3>🐀 Your Character sheet</h3>
|
<h3>🐀 Your Character sheet</h3>
|
||||||
|
|
||||||
@@ -307,6 +283,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,3 +293,60 @@
|
|||||||
hx-trigger="every 2s"
|
hx-trigger="every 2s"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
style="display:none;"></div>
|
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>
|
||||||
|
|||||||
@@ -38,18 +38,20 @@
|
|||||||
{{ locked_role.upper() }}
|
{{ locked_role.upper() }}
|
||||||
</div>
|
</div>
|
||||||
<!-- Silently auto-select this role in the database using HTMX onload if not already set -->
|
<!-- 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>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="role-buttons">
|
<div class="role-buttons">
|
||||||
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-role?role=pirat"
|
<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 %}">
|
class="btn btn-large role-btn pirat-btn {% if player.role == 'pirat' %}active{% endif %}">
|
||||||
🐀 Play Pi-Rat
|
🐀 Play Pi-Rat
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-role?role=deep"
|
<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 %}">
|
class="btn btn-large role-btn deep-btn {% if player.role == 'deep' %}active{% endif %}">
|
||||||
🌊 Play the Deep
|
🌊 Play the Deep
|
||||||
</button>
|
</button>
|
||||||
@@ -105,7 +107,7 @@
|
|||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
hx-target="#game-view"
|
hx-target="#game-view"
|
||||||
class="btn btn-primary btn-large glow-effect">
|
class="btn btn-primary btn-large glow-effect">
|
||||||
Confirm Roles & Shufle Deck
|
Confirm Roles & Shuffle Deck
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,36 +64,274 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/assign-face-techniques"
|
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/assign-face-techniques"
|
||||||
hx-target="#techniques-card"
|
hx-target="#techniques-card"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML"
|
||||||
<div class="form-group">
|
id="face-tech-form"
|
||||||
<label for="tech_j">Jack (J) Secret Technique:</label>
|
class="face-tech-drag-form">
|
||||||
<select name="jack" id="tech_j" class="select-field" required>
|
|
||||||
<option value="" disabled selected>Choose technique...</option>
|
<!-- 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 %}
|
{% for t in swapped_techs %}
|
||||||
<option value="{{ t }}">{{ t }}</option>
|
<div class="draggable-tech-chip"
|
||||||
|
draggable="true"
|
||||||
|
data-tech="{{ t }}"
|
||||||
|
id="tech-chip-{{ loop.index0 }}">
|
||||||
|
<span class="drag-icon">⋮⋮</span> {{ t }}
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="tech_q">Queen (Q) Secret Technique:</label>
|
<!-- Visual Card Slots Grid -->
|
||||||
<select name="queen" id="tech_q" class="select-field" required>
|
<div class="face-cards-slots-grid">
|
||||||
<option value="" disabled selected>Choose technique...</option>
|
|
||||||
{% for t in swapped_techs %}
|
<!-- JACK SLOT -->
|
||||||
<option value="{{ t }}">{{ t }}</option>
|
<div class="face-card-slot-wrapper">
|
||||||
{% endfor %}
|
<div class="face-card-slot" data-slot="jack" id="slot-jack">
|
||||||
</select>
|
<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>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label for="tech_k">King (K) Secret Technique:</label>
|
<button type="submit" id="submit-assignments-btn" class="btn btn-primary btn-full glow-effect" disabled>
|
||||||
<select name="king" id="tech_k" class="select-field" required>
|
Confirm Assignments & Ready Up
|
||||||
<option value="" disabled selected>Choose technique...</option>
|
</button>
|
||||||
{% 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>
|
|
||||||
</form>
|
</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 %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import json
|
|||||||
from sqlmodel import SQLModel, create_engine, Session
|
from sqlmodel import SQLModel, create_engine, Session
|
||||||
from pirats import cards
|
from pirats import cards
|
||||||
from pirats import crud
|
from pirats import crud
|
||||||
from pirats.models import Game, Player, Obstacle, Challenge
|
from pirats.models import Game, Player, Obstacle
|
||||||
|
|
||||||
# In-memory database for testing
|
# In-memory database for testing
|
||||||
@pytest.fixture(name="session")
|
@pytest.fixture(name="session")
|
||||||
@@ -137,9 +137,6 @@ def test_scene_start_and_challenges(session):
|
|||||||
session.add(p2)
|
session.add(p2)
|
||||||
session.commit()
|
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.
|
# Play card: 10D (value 10) vs Obstacle. Let's make sure obstacle value is less than 10.
|
||||||
# Set obstacle value to 5
|
# Set obstacle value to 5
|
||||||
obs.current_value = 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"
|
orig_is_red = cards.parse_card(obs.original_card)["color"] == "red"
|
||||||
deck_before = len(crud.get_game_deck(game))
|
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 ok
|
||||||
assert res["success"]
|
assert res["success"]
|
||||||
assert "Success" in res["details"]
|
assert "Success" in res["details"]
|
||||||
@@ -190,10 +187,8 @@ def test_secret_technique_auto_success(session):
|
|||||||
session.add(obs)
|
session.add(obs)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
chal = crud.create_challenge(session, game.id, "C1", "D1", [obs.id])
|
|
||||||
|
|
||||||
# Play Jack. J is a face card -> auto success!
|
# 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 ok
|
||||||
assert res["success"]
|
assert res["success"]
|
||||||
assert res["is_technique"]
|
assert res["is_technique"]
|
||||||
@@ -225,10 +220,8 @@ def test_joker_play(session):
|
|||||||
session.add(obs)
|
session.add(obs)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
chal = crud.create_challenge(session, game.id, "C1", "D1", [obs.id])
|
|
||||||
|
|
||||||
# Play Joker
|
# 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 ok
|
||||||
assert res["is_joker"]
|
assert res["is_joker"]
|
||||||
|
|
||||||
@@ -236,3 +229,92 @@ def test_joker_play(session):
|
|||||||
session.refresh(game)
|
session.refresh(game)
|
||||||
assert len(game.obstacles) == 1
|
assert len(game.obstacles) == 1
|
||||||
assert game.obstacles[0].original_card != "10C"
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user