Fixes and updates
This commit is contained in:
@@ -141,13 +141,14 @@ def draw_cards_for_player(db: Session, game: Game, player: Player, count: int) -
|
||||
|
||||
# --- CRUD Operations ---
|
||||
|
||||
def create_game(db: Session) -> Game:
|
||||
def create_game(db: Session, crew_name: Optional[str] = None) -> Game:
|
||||
deck = cards.get_fresh_deck()
|
||||
game = Game(
|
||||
deck_cards=json.dumps(deck),
|
||||
phase="lobby",
|
||||
current_scene_number=1,
|
||||
extra_obstacles=0
|
||||
extra_obstacles=0,
|
||||
crew_name=crew_name
|
||||
)
|
||||
db.add(game)
|
||||
db.commit()
|
||||
@@ -371,13 +372,31 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
||||
|
||||
players = game.players
|
||||
|
||||
# 1. Count roles (default any unselected roles to 'pirat')
|
||||
# Default any unselected roles to 'pirat'
|
||||
for p in players:
|
||||
if p.role is None:
|
||||
p.role = "pirat"
|
||||
db.add(p)
|
||||
db.commit()
|
||||
|
||||
# 1. Check previous Deep players
|
||||
# "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene."
|
||||
# Note: Only enforce if this is scene 2+, and we have enough players to make a rotation.
|
||||
if game.current_scene_number > 1 and len(players) >= 3:
|
||||
for p in players:
|
||||
if p.previous_role == "deep" and p.role == "deep":
|
||||
return False, f"{p.name} was Deep in the last scene and must play a Pi-Rat in this scene."
|
||||
|
||||
# 2. Check single eligible Deep player
|
||||
# If only one player is eligible to play the Deep, they must play the Deep.
|
||||
if game.current_scene_number > 1:
|
||||
eligible_deeps = [p for p in players if p.previous_role != "deep"]
|
||||
if len(eligible_deeps) == 1:
|
||||
must_be_deep_player = eligible_deeps[0]
|
||||
if must_be_deep_player.role != "deep":
|
||||
return False, f"{must_be_deep_player.name} is the only player eligible to play the Deep and must play the Deep in this scene."
|
||||
|
||||
# 3. Check counts
|
||||
pirats_count = sum(1 for p in players if p.role == "pirat")
|
||||
deeps_count = sum(1 for p in players if p.role == "deep")
|
||||
|
||||
@@ -386,14 +405,6 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
||||
if deeps_count < 1:
|
||||
return False, "You need at least one Deep player."
|
||||
|
||||
# 2. Check previous Deep players
|
||||
# "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene."
|
||||
# Note: Only enforce if this is scene 2+, and we have enough players to make a rotation.
|
||||
if game.current_scene_number > 1 and len(players) >= 3:
|
||||
for p in players:
|
||||
if p.previous_role == "deep" and p.role == "deep":
|
||||
return False, f"{p.name} was Deep in the last scene and must play a Pi-Rat in this scene."
|
||||
|
||||
# Everything is valid! Start the scene.
|
||||
# A. Reset obstacles
|
||||
for obs in game.obstacles:
|
||||
@@ -747,45 +758,83 @@ def process_between_scenes_votes(db: Session, game: Game):
|
||||
db.delete(v)
|
||||
db.commit()
|
||||
|
||||
def redraw_deep_players_hands(db: Session, game: Game):
|
||||
"""
|
||||
Step 3 of Between Scenes: Deep players from the previous scene can discard and redraw.
|
||||
To make it simple in the UI, we'll let them click a button to redraw or automatically redraw
|
||||
their hands to their maximum size. Let's empty their hand, shuffle them into the deck,
|
||||
and draw back up to their new max hand size!
|
||||
"""
|
||||
for p in game.players:
|
||||
if p.previous_role == "deep":
|
||||
# Return hand to deck
|
||||
hand = get_player_hand(p)
|
||||
deck = get_game_deck(game)
|
||||
deck.extend(hand)
|
||||
random.shuffle(deck)
|
||||
set_game_deck(game, deck)
|
||||
set_player_hand(p, [])
|
||||
|
||||
# Recalculate max hand size and draw back up
|
||||
max_size = calculate_max_hand_size(p, game.players)
|
||||
draw_cards_for_player(db, game, p, max_size)
|
||||
|
||||
def proceed_to_next_scene_setup(db: Session, game_id: str):
|
||||
def transition_to_deep_upkeep(db: Session, game_id: str):
|
||||
game = get_game(db, game_id)
|
||||
if not game:
|
||||
return
|
||||
|
||||
# Process votes & redraw hands
|
||||
# Process votes & rank up winner
|
||||
process_between_scenes_votes(db, game)
|
||||
redraw_deep_players_hands(db, game)
|
||||
|
||||
# Increment scene number
|
||||
game.current_scene_number += 1
|
||||
game.phase = "scene_setup"
|
||||
|
||||
# Reset role options for everyone
|
||||
# Reset ready flags
|
||||
for p in game.players:
|
||||
p.is_ready = False
|
||||
db.add(p)
|
||||
|
||||
# Check if we have resting Deep players (previous_role was 'deep')
|
||||
has_resting_deep = any(p.previous_role == "deep" for p in game.players)
|
||||
|
||||
if has_resting_deep:
|
||||
game.phase = "deep_upkeep"
|
||||
else:
|
||||
# Fallback in case there were no Deep players
|
||||
# Just advance directly to scene_setup
|
||||
game.current_scene_number += 1
|
||||
game.phase = "scene_setup"
|
||||
for p in game.players:
|
||||
p.role = None
|
||||
p.is_ready = False
|
||||
db.add(p)
|
||||
|
||||
db.add(game)
|
||||
db.commit()
|
||||
|
||||
def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
|
||||
player = get_player(db, player_id)
|
||||
if not player:
|
||||
return
|
||||
game = get_game(db, player.game_id)
|
||||
if not game:
|
||||
return
|
||||
|
||||
# 1. Discard cards
|
||||
hand = get_player_hand(player)
|
||||
deck = get_game_deck(game)
|
||||
|
||||
for card in discard_cards:
|
||||
if card in hand:
|
||||
hand.remove(card)
|
||||
deck.append(card)
|
||||
|
||||
random.shuffle(deck)
|
||||
set_game_deck(game, deck)
|
||||
set_player_hand(player, hand)
|
||||
db.add(game)
|
||||
db.add(player)
|
||||
db.commit()
|
||||
|
||||
# 2. Draw cards back up to max size
|
||||
# Max size for deep player is Rank + 1
|
||||
max_size = player.rank + 1
|
||||
if len(hand) < max_size:
|
||||
diff = max_size - len(hand)
|
||||
draw_cards_for_player(db, game, player, diff)
|
||||
|
||||
# Mark as ready
|
||||
player.is_ready = True
|
||||
db.add(player)
|
||||
db.commit()
|
||||
|
||||
# Check if all resting deep players are ready
|
||||
resting_deep = [p for p in game.players if p.previous_role == "deep"]
|
||||
if all(p.is_ready for p in resting_deep):
|
||||
# All resting deep players have refreshed their hands!
|
||||
# Transition to scene_setup!
|
||||
game.current_scene_number += 1
|
||||
game.phase = "scene_setup"
|
||||
for p in game.players:
|
||||
p.is_ready = False
|
||||
p.role = None
|
||||
db.add(p)
|
||||
db.add(game)
|
||||
db.commit()
|
||||
|
||||
@@ -13,6 +13,14 @@ engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
|
||||
|
||||
def create_db_and_tables():
|
||||
SQLModel.metadata.create_all(engine)
|
||||
# Ensure crew_name column exists (migration for existing DBs)
|
||||
from sqlalchemy import text
|
||||
with Session(engine) as session:
|
||||
try:
|
||||
session.execute(text("ALTER TABLE game ADD COLUMN crew_name VARCHAR"))
|
||||
session.commit()
|
||||
except Exception:
|
||||
pass # Column already exists or table doesn't exist yet
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
|
||||
@@ -36,8 +36,11 @@ def home(request: Request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
@app.post("/game/create")
|
||||
def create_game_route(db: Session = Depends(get_session)):
|
||||
game = crud.create_game(db)
|
||||
def create_game_route(
|
||||
crew_name: str = Form(...),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
game = crud.create_game(db, crew_name=crew_name.strip())
|
||||
# Redirect creator to the join page with creator flag
|
||||
return RedirectResponse(url=f"/game/{game.id}/join?creator=true", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
@@ -195,6 +198,13 @@ def get_game_phase_view(
|
||||
if p.other_hate_from_player_id == player.id and not p.other_hate:
|
||||
inbox_tasks.append({"player": p, "type": "hate"})
|
||||
|
||||
# Freshly fetch votes to avoid cache stale
|
||||
from sqlmodel import select
|
||||
votes = db.exec(select(Vote).where(Vote.game_id == game_id)).all()
|
||||
|
||||
# Calculate eligible deep players
|
||||
eligible_deeps = [p for p in game.players if p.previous_role != "deep"]
|
||||
|
||||
context = {
|
||||
"request": request,
|
||||
"game": game,
|
||||
@@ -210,7 +220,9 @@ def get_game_phase_view(
|
||||
"json_loads": json.loads,
|
||||
"deck_count": len(crud.get_game_deck(game)),
|
||||
"inbox_tasks": inbox_tasks,
|
||||
"edit_mode": edit
|
||||
"votes": votes,
|
||||
"edit_mode": edit,
|
||||
"eligible_deeps": eligible_deeps
|
||||
}
|
||||
|
||||
# Select template based on current game phase
|
||||
@@ -224,6 +236,8 @@ def get_game_phase_view(
|
||||
return templates.TemplateResponse("scene_partial.html", context)
|
||||
elif game.phase == "between_scenes":
|
||||
return templates.TemplateResponse("between_scenes_partial.html", context)
|
||||
elif game.phase == "deep_upkeep":
|
||||
return templates.TemplateResponse("deep_upkeep_partial.html", context)
|
||||
|
||||
return HTMLResponse("<p>Unknown game state.</p>")
|
||||
|
||||
@@ -613,7 +627,7 @@ def submit_vote_route(
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
crud.submit_rank_vote(db, game_id, player_id, nominated_id)
|
||||
return HTMLResponse(status_code=204)
|
||||
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
# 16. Player Ready for Next Scene
|
||||
@app.post("/game/{game_id}/player/{player_id}/ready-next")
|
||||
@@ -626,12 +640,27 @@ def ready_next_route(game_id: str, player_id: str, db: Session = Depends(get_ses
|
||||
db.add(player)
|
||||
db.commit()
|
||||
|
||||
# Check if all players in the game are ready. If so, transition to next scene!
|
||||
# Check if all players in the game are ready. If so, transition to deep upkeep!
|
||||
game = crud.get_game(db, game_id)
|
||||
if game and all(p.is_ready for p in game.players):
|
||||
crud.proceed_to_next_scene_setup(db, game_id)
|
||||
crud.transition_to_deep_upkeep(db, game_id)
|
||||
|
||||
return HTMLResponse(status_code=204)
|
||||
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
# 17. Confirm Hand Refresh for Deep player
|
||||
@app.post("/game/{game_id}/player/{player_id}/confirm-refresh")
|
||||
def confirm_deep_refresh_route(
|
||||
game_id: str,
|
||||
player_id: str,
|
||||
discard_cards: str = Form("[]"),
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
try:
|
||||
discards = json.loads(discard_cards)
|
||||
except Exception:
|
||||
discards = []
|
||||
crud.confirm_deep_refresh(db, player_id, discards)
|
||||
return RedirectResponse(url=f"/game/{game_id}/player/{player_id}/view", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlmodel import Field, SQLModel, Relationship
|
||||
class Game(SQLModel, table=True):
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
crew_name: Optional[str] = Field(default=None)
|
||||
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes"
|
||||
current_scene_number: int = Field(default=1)
|
||||
extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers)
|
||||
|
||||
@@ -1729,3 +1729,46 @@ body {
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* --- Deep Upkeep Hand Refresh Drag & Drop --- */
|
||||
.upkeep-drag-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.upkeep-drag-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.upkeep-box {
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.upkeep-box h3 {
|
||||
margin-bottom: 1rem;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.upkeep-flex {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px dashed var(--glass-border);
|
||||
border-radius: 8px;
|
||||
align-content: flex-start;
|
||||
min-height: 300px;
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.upkeep-box[ondragover]:hover .upkeep-flex {
|
||||
border-color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Creator Admin Panel - Game #{{ game.id[:8] }}{% endblock %}
|
||||
{% block title %}Creator Admin Panel - {% if game.crew_name %}{{ game.crew_name }}{% else %}Game #{{ game.id[:8] }}{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="admin-panel-container glass-panel">
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
<h3>1. Pirate Ranking (Voting)</h3>
|
||||
<p class="section-desc">Nominate one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p>
|
||||
|
||||
{% set has_voted = False %}
|
||||
{% for v in game.votes %}
|
||||
{% set ns_vote = namespace(has_voted=False) %}
|
||||
{% for v in votes %}
|
||||
{% if v.voter_player_id == player.id %}
|
||||
{% set has_voted = True %}
|
||||
{% set ns_vote.has_voted = True %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if has_voted %}
|
||||
{% if ns_vote.has_voted %}
|
||||
<div class="voted-confirmation-box glass-panel">
|
||||
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-vote" hx-swap="none" class="vote-form inline-form">
|
||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/submit-vote" hx-target="#game-view" hx-swap="innerHTML" class="vote-form inline-form">
|
||||
<div class="form-group inline-group">
|
||||
<select name="nominated_id" class="select-field" required>
|
||||
<option value="" disabled selected>Nominate crewmate...</option>
|
||||
@@ -42,7 +42,7 @@
|
||||
hx-swap="innerHTML">
|
||||
{% for p in game.players %}
|
||||
{% set voted = False %}
|
||||
{% for v in game.votes if v.voter_player_id == p.id %}
|
||||
{% for v in votes if v.voter_player_id == p.id %}
|
||||
{% set voted = True %}
|
||||
{% endfor %}
|
||||
<div class="player-chip {% if voted %}voted-chip{% else %}not-voted-chip{% endif %}">
|
||||
@@ -79,10 +79,19 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Next Scene Ready Up -->
|
||||
<div class="action-box margin-top">
|
||||
<!-- Next Scene Ready Up -->
|
||||
<div class="action-box margin-top">
|
||||
{% set eligible_count = namespace(value=0) %}
|
||||
{% for other in game.players if other.id != player.id and other.previous_role != 'deep' %}
|
||||
{% set eligible_count.value = eligible_count.value + 1 %}
|
||||
{% endfor %}
|
||||
|
||||
{% if eligible_count.value > 0 and not ns_vote.has_voted %}
|
||||
<p class="info-text gold-text" style="margin-bottom:0.5rem;">⚠️ You must nominate a crewmate above before you can ready up.</p>
|
||||
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
|
||||
{% else %}
|
||||
{% if player.is_ready %}
|
||||
<div class="waiting-box">
|
||||
<p>Ready! Waiting for other players to ready up...</p>
|
||||
@@ -90,12 +99,14 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/ready-next"
|
||||
hx-swap="none"
|
||||
hx-target="#game-view"
|
||||
hx-swap="innerHTML"
|
||||
class="btn btn-primary btn-large glow-effect">
|
||||
Ready for Next Scene
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden phase-check trigger -->
|
||||
@@ -103,3 +114,5 @@
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="none"
|
||||
style="display:none;"></div>
|
||||
|
||||
{% include "header_status_snippet.html" %}
|
||||
|
||||
@@ -118,3 +118,5 @@
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="none"
|
||||
style="display:none;"></div>
|
||||
|
||||
{% include "header_status_snippet.html" %}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Game #{{ game.id[:8] }} - {% if player %}{{ player.name }}{% else %}Lobby{% endif %}{% endblock %}
|
||||
{% block title %}{% if game.crew_name %}{{ game.crew_name }}{% else %}Game #{{ game.id[:8] }}{% endif %} - {% if player %}{{ player.name }}{% else %}Lobby{% endif %}{% endblock %}
|
||||
|
||||
{% block header_extra %}
|
||||
<div class="header-status">
|
||||
<div id="header-status" class="header-status">
|
||||
{% if player %}
|
||||
<span class="player-pill"><span class="bullet"></span> {{ player.name }} (Rank {{ player.rank }})</span>
|
||||
{% endif %}
|
||||
|
||||
121
src/pirats/templates/deep_upkeep_partial.html
Normal file
121
src/pirats/templates/deep_upkeep_partial.html
Normal file
@@ -0,0 +1,121 @@
|
||||
{% if player.previous_role == "deep" %}
|
||||
<div class="deep-upkeep-view text-center">
|
||||
<h2>Resting Deep Upkeep</h2>
|
||||
<p class="description">Select which cards you want to keep and which you want to discard. Drag cards between the boxes, then click "Confirm Hand Refresh". You will draw back up to your maximum hand size of {{ player.rank + 1 }} cards.</p>
|
||||
|
||||
<div class="upkeep-drag-container">
|
||||
<!-- Keep Box -->
|
||||
<div class="card glass-panel upkeep-box" id="keep-box" ondragover="allowDrop(event)" ondrop="dropToKeep(event)">
|
||||
<h3>Keep (Current Hand)</h3>
|
||||
<div class="hand-flex upkeep-flex" id="keep-list">
|
||||
{% for card in hand %}
|
||||
{% set parsed = parse_card(card) %}
|
||||
<div class="card-large suit-{{ parsed.suit.lower() }} {% if parsed.is_joker %}joker-card{% endif %}"
|
||||
draggable="true"
|
||||
ondragstart="drag(event)"
|
||||
id="card-{{ card }}"
|
||||
data-card-code="{{ card }}">
|
||||
<div class="card-corner top-left">
|
||||
<span class="val">{{ parsed.value }}</span>
|
||||
<span class="suit">{{ parsed.symbol }}</span>
|
||||
</div>
|
||||
<div class="card-center">
|
||||
{% if parsed.is_joker %}
|
||||
🃏
|
||||
{% else %}
|
||||
<span class="giant-symbol">{{ parsed.symbol }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-corner bottom-right">
|
||||
<span class="val">{{ parsed.value }}</span>
|
||||
<span class="suit">{{ parsed.symbol }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discard Box -->
|
||||
<div class="card glass-panel upkeep-box" id="discard-box" ondragover="allowDrop(event)" ondrop="dropToDiscard(event)">
|
||||
<h3 style="color: var(--red-suit);">Discard</h3>
|
||||
<div class="hand-flex upkeep-flex" id="discard-list">
|
||||
<!-- Drop discarded cards here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-box margin-top">
|
||||
{% if player.is_ready %}
|
||||
<div class="waiting-box" style="justify-content: center;">
|
||||
<p>Upkeep confirmed! Waiting for other resting Deep players...</p>
|
||||
<div class="spinner-small"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<form id="confirm-refresh-form" hx-post="/game/{{ game.id }}/player/{{ player.id }}/confirm-refresh" hx-target="#game-view" hx-swap="innerHTML">
|
||||
<!-- Hidden input to submit which cards are discarded -->
|
||||
<input type="hidden" name="discard_cards" id="discard-input" value="[]">
|
||||
<button type="submit" class="btn btn-primary btn-large glow-effect">Confirm Hand Refresh</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function allowDrop(ev) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
function drag(ev) {
|
||||
ev.dataTransfer.setData("text/plain", ev.currentTarget.id);
|
||||
}
|
||||
|
||||
function dropToKeep(ev) {
|
||||
ev.preventDefault();
|
||||
var data = ev.dataTransfer.getData("text/plain");
|
||||
var cardEl = document.getElementById(data);
|
||||
if (cardEl) {
|
||||
document.getElementById("keep-list").appendChild(cardEl);
|
||||
updateInputs();
|
||||
}
|
||||
}
|
||||
|
||||
function dropToDiscard(ev) {
|
||||
ev.preventDefault();
|
||||
var data = ev.dataTransfer.getData("text/plain");
|
||||
var cardEl = document.getElementById(data);
|
||||
if (cardEl) {
|
||||
document.getElementById("discard-list").appendChild(cardEl);
|
||||
updateInputs();
|
||||
}
|
||||
}
|
||||
|
||||
function updateInputs() {
|
||||
var discardList = document.getElementById("discard-list");
|
||||
var discards = [];
|
||||
for (var i = 0; i < discardList.children.length; i++) {
|
||||
var code = discardList.children[i].getAttribute("data-card-code");
|
||||
if (code) {
|
||||
discards.push(code);
|
||||
}
|
||||
}
|
||||
document.getElementById("discard-input").value = JSON.stringify(discards);
|
||||
}
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="deep-upkeep-waiting text-center glass-panel" style="max-width:600px; margin:4rem auto; padding:3rem;">
|
||||
<h2>🌊 Resting Deep Upkeep</h2>
|
||||
<p class="description">The players who controlled the Deep in the previous scene are currently refreshing their hands.</p>
|
||||
<div class="waiting-box" style="justify-content: center; margin-top:2rem;">
|
||||
<p>Waiting for resting Deep players to confirm their hand refreshes...</p>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Hidden phase-check trigger -->
|
||||
<div hx-get="/game/{{ game.id }}/player/{{ player.id }}/phase-check?current={{ game.phase }}"
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="none"
|
||||
style="display:none;"></div>
|
||||
|
||||
{% include "header_status_snippet.html" %}
|
||||
6
src/pirats/templates/header_status_snippet.html
Normal file
6
src/pirats/templates/header_status_snippet.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<div id="header-status" hx-swap-oob="true" class="header-status">
|
||||
{% if player %}
|
||||
<span class="player-pill"><span class="bullet"></span> {{ player.name }} (Rank {{ player.rank }})</span>
|
||||
{% endif %}
|
||||
<span class="phase-pill">{{ game.phase.replace('_', ' ').title() }}</span>
|
||||
</div>
|
||||
@@ -12,8 +12,11 @@
|
||||
<div class="welcome-actions">
|
||||
<div class="action-card glass-panel text-center">
|
||||
<h3>Start a New Adventure</h3>
|
||||
<p>Generate a fresh game deck and take command as the crew creator.</p>
|
||||
<p>Generate a fresh game deck and name your crew of mathematical pirates.</p>
|
||||
<form action="/game/create" method="POST">
|
||||
<div class="form-group">
|
||||
<input type="text" name="crew_name" placeholder="Name your Crew (e.g. The Math-Gat Rats)" required class="input-field text-center">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-large glow-effect">Create Game</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="join-container glass-panel text-center">
|
||||
<h2>Join the Pi-Rat Crew!</h2>
|
||||
<h2>Join the {% if game.crew_name %}{{ game.crew_name }}{% else %}Pi-Rat{% endif %} Crew!</h2>
|
||||
<p>The mathematical transformation spell is about to cast. Enter your name below to stow away in the cargo hold.</p>
|
||||
|
||||
<form action="/game/{{ game.id }}/join" method="POST" class="join-form">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="lobby-view text-center">
|
||||
<h2>Ship's Hold (Lobby)</h2>
|
||||
<h2>{% if game.crew_name %}{{ game.crew_name }}{% else %}Ship's Hold{% endif %} (Lobby)</h2>
|
||||
<p class="description">Wait here as the mathematical mages gather stowaway rats for the spell. Once everyone is in, the captain will initiate the transformation.</p>
|
||||
|
||||
<div class="lobby-status-box glass-panel">
|
||||
@@ -64,3 +64,5 @@
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="none"
|
||||
style="display:none;"></div>
|
||||
|
||||
{% include "header_status_snippet.html" %}
|
||||
|
||||
@@ -294,6 +294,8 @@
|
||||
hx-swap="none"
|
||||
style="display:none;"></div>
|
||||
|
||||
{% include "header_status_snippet.html" %}
|
||||
|
||||
<script>
|
||||
window.dragStart = function(ev) {
|
||||
ev.dataTransfer.setData("text/plain", ev.currentTarget.getAttribute("data-card-code"));
|
||||
|
||||
@@ -25,3 +25,17 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roster-section margin-top">
|
||||
<h4>⏳ Yet to Choose</h4>
|
||||
<div class="player-chips list-chips">
|
||||
{% for p in game.players if not p.role %}
|
||||
<div class="player-chip undecided-chip">
|
||||
<span class="name">{{ p.name }}</span>
|
||||
<span class="sub-label">Rank {{ p.rank }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-text">Everyone has chosen!</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
{% if player.previous_role == "deep" %}
|
||||
{% set force_pirat = True %}
|
||||
{% set locked_role = "pirat" %}
|
||||
{% elif eligible_deeps|length == 1 %}
|
||||
{% set locked_role = "deep" %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
@@ -30,8 +32,10 @@
|
||||
<p class="info-text">
|
||||
{% if game.current_scene_number == 1 %}
|
||||
Based on starting Ranks, you are locked into:
|
||||
{% else %}
|
||||
{% elif player.previous_role == "deep" %}
|
||||
You were Deep in the previous scene! You are locked into:
|
||||
{% else %}
|
||||
You are the only player eligible to play the Deep! You are locked into:
|
||||
{% endif %}
|
||||
</p>
|
||||
<div class="role-badge large-badge {{ locked_role }}">
|
||||
@@ -66,33 +70,7 @@
|
||||
hx-get="/game/{{ game.id }}/scene/roster"
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="roster-section">
|
||||
<h4>🌊 The Deep (NPCs & Hazards)</h4>
|
||||
<div class="player-chips list-chips">
|
||||
{% for p in game.players if p.role == 'deep' %}
|
||||
<div class="player-chip deep-chip">
|
||||
<span class="name">{{ p.name }}</span>
|
||||
<span class="sub-label">Rank {{ p.rank }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-text">None selected</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roster-section margin-top">
|
||||
<h4>🐀 Pi-Rats (Crew Players)</h4>
|
||||
<div class="player-chips list-chips">
|
||||
{% for p in game.players if p.role == 'pirat' %}
|
||||
<div class="player-chip pirat-chip">
|
||||
<span class="name">{{ p.name }}</span>
|
||||
<span class="sub-label">Rank {{ p.rank }}{% if is_captain(p, game.players) %} (Captain) {% endif %}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-text">None selected</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% include "scene_roster_snippet.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,3 +95,5 @@
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="none"
|
||||
style="display:none;"></div>
|
||||
|
||||
{% include "header_status_snippet.html" %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% for p in game.players %}
|
||||
{% set voted = False %}
|
||||
{% for v in game.votes if v.voter_player_id == p.id %}
|
||||
{% for v in votes if v.voter_player_id == p.id %}
|
||||
{% set voted = True %}
|
||||
{% endfor %}
|
||||
<div class="player-chip {% if voted %}voted-chip{% else %}not-voted-chip{% endif %}">
|
||||
|
||||
@@ -318,3 +318,190 @@ def test_set_role_endpoint():
|
||||
assert player.role == "pirat"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_game_creation_with_crew_name(session):
|
||||
game = crud.create_game(session, crew_name="The Black Gats")
|
||||
assert game.id is not None
|
||||
assert game.crew_name == "The Black Gats"
|
||||
assert game.phase == "lobby"
|
||||
|
||||
def test_create_game_endpoint():
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import SQLModel, create_engine, Session, select
|
||||
from pirats.main import app
|
||||
from pirats import crud
|
||||
|
||||
# Setup in-memory DB
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
session = Session(engine)
|
||||
|
||||
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/create":
|
||||
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, base_url="http://testserver")
|
||||
|
||||
try:
|
||||
response = client.post("/game/create", data={"crew_name": "The Math Mutineers"})
|
||||
assert response.status_code in [200, 303] # starlette redirects can be 303
|
||||
|
||||
# Verify the database has the game with the crew name
|
||||
from pirats.models import Game
|
||||
games = session.exec(select(Game)).all()
|
||||
assert len(games) == 1
|
||||
assert games[0].crew_name == "The Math Mutineers"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_transition_to_deep_upkeep(session):
|
||||
game = crud.create_game(session)
|
||||
p1 = crud.add_player(session, game.id, "P1")
|
||||
p2 = crud.add_player(session, game.id, "P2")
|
||||
|
||||
p1.previous_role = "deep"
|
||||
p2.previous_role = "pirat"
|
||||
session.add_all([p1, p2])
|
||||
session.commit()
|
||||
|
||||
crud.transition_to_deep_upkeep(session, game.id)
|
||||
session.refresh(game)
|
||||
assert game.phase == "deep_upkeep"
|
||||
|
||||
def test_confirm_deep_refresh(session):
|
||||
game = crud.create_game(session)
|
||||
p1 = crud.add_player(session, game.id, "P1")
|
||||
p1.previous_role = "deep"
|
||||
p1.role = "deep"
|
||||
p1.rank = 2
|
||||
# Hand has 3 cards: ['2C', '3C', '4C']
|
||||
p1.hand_cards = json.dumps(["2C", "3C", "4C"])
|
||||
|
||||
# Rest of deck has some known cards
|
||||
game.deck_cards = json.dumps(["5H", "6H", "7H", "8H"])
|
||||
session.add_all([game, p1])
|
||||
session.commit()
|
||||
|
||||
# Stub shuffle to keep card order deterministic
|
||||
import random
|
||||
orig_shuffle = random.shuffle
|
||||
random.shuffle = lambda x: None
|
||||
|
||||
try:
|
||||
# Discard 2C and 3C
|
||||
crud.confirm_deep_refresh(session, p1.id, ["2C", "3C"])
|
||||
finally:
|
||||
random.shuffle = orig_shuffle
|
||||
|
||||
session.refresh(p1)
|
||||
session.refresh(game)
|
||||
|
||||
# Hand should not contain 2C and 3C, and should have drawn 5H and 6H
|
||||
hand = json.loads(p1.hand_cards)
|
||||
assert "2C" not in hand
|
||||
assert "3C" not in hand
|
||||
assert "4C" in hand
|
||||
assert "5H" in hand
|
||||
assert "6H" in hand
|
||||
assert len(hand) == 3
|
||||
|
||||
# Discarded cards should be at the end of the deck
|
||||
deck = json.loads(game.deck_cards)
|
||||
assert "2C" in deck
|
||||
assert "3C" in deck
|
||||
assert deck == ["7H", "8H", "2C", "3C"]
|
||||
|
||||
# Since P1 was the only resting deep player and is now ready, phase should have advanced to scene_setup!
|
||||
assert game.phase == "scene_setup"
|
||||
|
||||
def test_submit_vote_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
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
session = Session(engine)
|
||||
|
||||
# Create game and players
|
||||
game = crud.create_game(session)
|
||||
p1 = crud.add_player(session, game.id, "P1")
|
||||
p2 = crud.add_player(session, game.id, "P2")
|
||||
game.phase = "between_scenes"
|
||||
session.add(game)
|
||||
session.commit()
|
||||
|
||||
def get_session_override():
|
||||
yield session
|
||||
|
||||
from pirats.database import get_session
|
||||
app.dependency_overrides[get_session] = get_session_override
|
||||
client = TestClient(app)
|
||||
|
||||
try:
|
||||
response = client.post(f"/game/{game.id}/player/{p1.id}/submit-vote", data={"nominated_id": p2.id})
|
||||
# TestClient follows redirects by default
|
||||
assert response.status_code == 200
|
||||
assert "Vote submitted" in response.text
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
def test_single_eligible_deep_player(session):
|
||||
game = crud.create_game(session)
|
||||
p1 = crud.add_player(session, game.id, "P1")
|
||||
p2 = crud.add_player(session, game.id, "P2")
|
||||
p3 = crud.add_player(session, game.id, "P3")
|
||||
|
||||
# Scene number > 1
|
||||
game.current_scene_number = 2
|
||||
|
||||
# 2 players were Deep in the last scene, 1 was Pi-Rat
|
||||
p1.previous_role = "deep"
|
||||
p2.previous_role = "deep"
|
||||
p3.previous_role = "pirat"
|
||||
|
||||
# Set their current roles to something invalid first
|
||||
# P3 is the only one eligible to be Deep, but plays Pi-Rat. This should fail!
|
||||
p1.role = "pirat"
|
||||
p2.role = "pirat"
|
||||
p3.role = "pirat"
|
||||
|
||||
session.add_all([game, p1, p2, p3])
|
||||
session.commit()
|
||||
|
||||
success, msg = crud.confirm_scene_setup(session, game.id)
|
||||
assert not success
|
||||
assert "is the only player eligible to play the Deep" in msg
|
||||
|
||||
# Now set P3 to play Deep. This should succeed!
|
||||
p3.role = "deep"
|
||||
session.add(p3)
|
||||
session.commit()
|
||||
|
||||
success, msg = crud.confirm_scene_setup(session, game.id)
|
||||
assert success
|
||||
|
||||
|
||||
Reference in New Issue
Block a user