Objectives & Milestones Update

This commit is contained in:
2026-06-10 15:56:32 -07:00
parent 2ddcb86cc0
commit d7f8483a41
6 changed files with 254 additions and 59 deletions

View File

@@ -307,9 +307,19 @@ def play_card_on_obstacle(
return True, "Card played successfully.", res_dict return True, "Card played successfully.", res_dict
def toggle_objective(db: Session, player_id: str, obj_type: str, status: bool): def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, status: bool):
"""Toggles Personal or Crew objectives and adjusts Ranks accordingly.""" """Toggles Personal or Crew objectives and adjusts states/Ranks accordingly."""
player = get_player(db, player_id) if obj_type.startswith("crew_"):
game = get_game(db, game_id)
if not game: return
if obj_type == "crew_1": game.completed_crew_1 = status
elif obj_type == "crew_2": game.completed_crew_2 = status
elif obj_type == "crew_3": game.completed_crew_3 = status
db.add(game)
db.commit()
return
player = get_player(db, target_id)
if not player: if not player:
return return
@@ -324,14 +334,56 @@ def toggle_objective(db: Session, player_id: str, obj_type: str, status: bool):
elif obj_type == "personal_2": elif obj_type == "personal_2":
if status and not player.completed_personal_2: if status and not player.completed_personal_2:
rank_diff = 1 rank_diff = 1
player.needs_name = True
elif not status and player.completed_personal_2: elif not status and player.completed_personal_2:
rank_diff = -1 rank_diff = -1
player.needs_name = False
player.completed_personal_2 = status player.completed_personal_2 = status
elif obj_type == "personal_3": elif obj_type == "personal_3":
if status and not player.completed_personal_3:
player.needs_rank_3_bonus = True
player.is_dead = True
elif not status and player.completed_personal_3:
player.needs_rank_3_bonus = False
player.is_dead = False
player.completed_personal_3 = status player.completed_personal_3 = status
player.rank += rank_diff player.rank += rank_diff
player.rank = max(1, player.rank) player.rank = max(1, player.rank)
db.add(player) db.add(player)
db.commit() db.commit()
def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]:
obstacle = db.get(Obstacle, obstacle_id)
if not obstacle:
return False, "Obstacle not found."
played_list = json.loads(obstacle.played_cards)
if not played_list:
return False, "No cards to roll back."
last_play = played_list.pop()
card_code = last_play["card"]
player_id = last_play["player_id"]
# Update obstacle's value
if played_list:
prev_card_code = played_list[-1]["card"]
obstacle.current_value = cards.parse_card(prev_card_code)["numeric_value"]
else:
obstacle.current_value = cards.get_obstacle_info(obstacle.original_card)["initial_value"]
obstacle.played_cards = json.dumps(played_list)
db.add(obstacle)
# Return card to player's hand
player = get_player(db, player_id)
if player:
hand = get_player_hand(player)
hand.append(card_code)
set_player_hand(player, hand)
db.add(player)
db.commit()
return True, "Rolled back the last played card."

View File

@@ -18,9 +18,26 @@ def create_db_and_tables():
with Session(engine) as session: with Session(engine) as session:
try: try:
session.execute(text("ALTER TABLE game ADD COLUMN crew_name VARCHAR")) session.execute(text("ALTER TABLE game ADD COLUMN crew_name VARCHAR"))
session.commit()
except Exception: except Exception:
pass # Column already exists or table doesn't exist yet pass # Column already exists or table doesn't exist yet
columns = [
("game", "completed_crew_1", "BOOLEAN DEFAULT FALSE"),
("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"),
("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"),
("player", "needs_name", "BOOLEAN DEFAULT FALSE"),
("player", "needs_rank_3_bonus", "BOOLEAN DEFAULT FALSE"),
("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
]
for table, col, def_type in columns:
try:
session.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {def_type}"))
except Exception:
pass
session.commit()
def get_session(): def get_session():
with Session(engine) as session: with Session(engine) as session:

View File

@@ -10,6 +10,9 @@ class Game(SQLModel, table=True):
current_scene_number: int = Field(default=1) current_scene_number: int = Field(default=1)
extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers) extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers)
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...] deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]
completed_crew_1: bool = Field(default=False) # Steal a Ship
completed_crew_2: bool = Field(default=False) # Choose a Captain
completed_crew_3: bool = Field(default=False) # Commit Piracy
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)
@@ -50,6 +53,12 @@ class Player(SQLModel, table=True):
completed_personal_2: bool = Field(default=False) # Earn a Name completed_personal_2: bool = Field(default=False) # Earn a Name
completed_personal_3: bool = Field(default=False) # Die like a Pirate completed_personal_3: bool = Field(default=False) # Die like a Pirate
# States for Milestones & Death
needs_name: bool = Field(default=False)
needs_rank_3_bonus: bool = Field(default=False)
is_dead: bool = Field(default=False)
is_ghost: bool = Field(default=False)
# Relationships # Relationships
game: Optional[Game] = Relationship(back_populates="players") game: Optional[Game] = Relationship(back_populates="players")

View File

@@ -111,22 +111,86 @@ def play_joker_route(
def toggle_objective_route( def toggle_objective_route(
game_id: str, game_id: str,
player_id: str, player_id: str,
type: str, # "personal_1", "personal_2", "personal_3" type: str, # "personal_1", "personal_2", "personal_3", or "crew_1", etc.
db: Session = Depends(get_session)
):
if type.startswith("crew_"):
game = crud.get_game(db, game_id)
if not game: raise HTTPException(status_code=404)
current_status = False
if type == "crew_1": current_status = game.completed_crew_1
elif type == "crew_2": current_status = game.completed_crew_2
elif type == "crew_3": current_status = game.completed_crew_3
crud.toggle_objective(db, game_id, player_id, type, not current_status)
else:
player = crud.get_player(db, player_id)
if not player: raise HTTPException(status_code=404, detail="Player not found")
current_status = False
if type == "personal_1": current_status = player.completed_personal_1
elif type == "personal_2": current_status = player.completed_personal_2
elif type == "personal_3": current_status = player.completed_personal_3
crud.toggle_objective(db, game_id, player_id, type, not current_status)
return HTMLResponse(status_code=204)
# Deep rollbacks a card play
@router.post("/game/{game_id}/scene/rollback")
def rollback_card_play_route(
game_id: str,
obstacle_id: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg = crud.rollback_card_play(db, obstacle_id)
if not ok:
return HTMLResponse(f"<p class='alert alert-danger'>{msg}</p>", status_code=400)
return HTMLResponse(status_code=204, headers={"HX-Trigger": "hand-changed"})
# Pi-Rat sets a new name
@router.post("/game/{game_id}/player/{player_id}/set-name")
def set_name_route(
game_id: str,
player_id: str,
new_name: str = Form(...),
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
player = crud.get_player(db, player_id) player = crud.get_player(db, player_id)
if not player: if player and player.needs_name:
raise HTTPException(status_code=404, detail="Player not found") player.name = new_name
player.needs_name = False
current_status = False db.add(player)
if type == "personal_1": db.commit()
current_status = player.completed_personal_1 return HTMLResponse(status_code=204)
elif type == "personal_2":
current_status = player.completed_personal_2 # Pi-Rat bonus rank up for another player
elif type == "personal_3": @router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
current_status = player.completed_personal_3 def bonus_rank_up_route(
game_id: str,
crud.toggle_objective(db, player_id, type, not current_status) player_id: str,
target_player_id: str = Form(...),
db: Session = Depends(get_session)
):
player = crud.get_player(db, player_id)
target = crud.get_player(db, target_player_id)
if player and target and player.needs_rank_3_bonus:
target.rank += 1
player.needs_rank_3_bonus = False
db.add(target)
db.add(player)
db.commit()
return HTMLResponse(status_code=204)
# Pi-Rat becomes a ghost
@router.post("/game/{game_id}/player/{player_id}/become-ghost")
def become_ghost_route(
game_id: str,
player_id: str,
db: Session = Depends(get_session)
):
player = crud.get_player(db, player_id)
if player and player.is_dead:
player.is_ghost = True
db.add(player)
db.commit()
return HTMLResponse(status_code=204) return HTMLResponse(status_code=204)
# Deep ends the scene # Deep ends the scene

View File

@@ -112,11 +112,22 @@
<span class="suit">{{ obs.symbol }}</span> <span class="suit">{{ obs.symbol }}</span>
</div> </div>
{% for pc in column_cards %} {% for pc in column_cards %}
<div class="card-mini played-card rotated {% if pc.success %}success{% else %}failure{% endif %}" <div style="display:flex; align-items:center; gap:0.5rem; margin-bottom: 0.25rem;">
title="{{ pc.player_name }} played {{ pc.card }}: {{ pc.details }}"> <div class="card-mini played-card rotated {% if pc.success %}success{% else %}failure{% endif %}"
<span class="val">{{ pc.card[:-1] if not pc.card.startswith('Joker') else '🃏' }}</span> title="{{ pc.player_name }} played {{ pc.card }}: {{ pc.details }}">
<span class="suit">{{ parse_card(pc.card).symbol }}</span> <span class="val">{{ pc.card[:-1] if not pc.card.startswith('Joker') else '🃏' }}</span>
<span class="owner">{{ pc.player_name[:4] }}</span> <span class="suit">{{ parse_card(pc.card).symbol }}</span>
<span class="owner">{{ pc.player_name[:4] }}</span>
</div>
{% if loop.last and player.role == 'deep' %}
<button hx-post="/game/{{ game.id }}/scene/rollback"
hx-vals='{"obstacle_id": "{{ obs.id }}"}'
hx-swap="none"
title="Roll back this card play"
style="background: transparent; border: none; color: #ff6b6b; cursor: pointer; font-size: 1.2rem; padding: 0;">
↩️
</button>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -146,13 +146,46 @@
<!-- RIGHT COLUMN: Private Hand & Character Sheet --> <!-- RIGHT COLUMN: Private Hand & Character Sheet -->
<div class="private-column"> <div class="private-column">
<!-- DEEP CONTROLS: End Scene --> <!-- DEEP CONTROLS: End Scene & Objectives -->
{% if player.role == "deep" %} {% if player.role == "deep" %}
<div class="card glass-panel deep-panel-card"> <div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;">
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3> <h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">Crew Objectives</h4>
<div class="objectives-checklist">
<label class="checkbox-label">
<input type="checkbox" hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=crew_1" hx-trigger="click" hx-swap="none" {% if game.completed_crew_1 %}checked{% endif %}>
<span>Steal a Ship</span>
</label>
<label class="checkbox-label">
<input type="checkbox" hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=crew_2" hx-trigger="click" hx-swap="none" {% if game.completed_crew_2 %}checked{% endif %}>
<span>Choose a Captain</span>
</label>
<label class="checkbox-label">
<input type="checkbox" hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=crew_3" hx-trigger="click" hx-swap="none" {% if game.completed_crew_3 %}checked{% endif %}>
<span>Commit Piracy</span>
</label>
</div>
</div>
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">Pi-Rat Personal Objectives</h4>
<p class="info-text">You control completion. Only mark in sequence (1 -> 2 -> 3).</p>
{% for p in game.players if p.role == 'pirat' %}
<div style="background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 4px; margin-bottom: 0.5rem;">
<strong>{{ p.name }}</strong>
<div class="objectives-checklist" style="margin-top: 0.25rem;">
<label class="checkbox-label"><input type="checkbox" hx-post="/game/{{ game.id }}/player/{{ p.id }}/objective/toggle?type=personal_1" hx-trigger="click" hx-swap="none" {% if p.completed_personal_1 %}checked{% endif %}> <span>1. Gat</span></label>
<label class="checkbox-label"><input type="checkbox" hx-post="/game/{{ game.id }}/player/{{ p.id }}/objective/toggle?type=personal_2" hx-trigger="click" hx-swap="none" {% if p.completed_personal_2 %}checked{% endif %}> <span>2. Name</span></label>
<label class="checkbox-label"><input type="checkbox" hx-post="/game/{{ game.id }}/player/{{ p.id }}/objective/toggle?type=personal_3" hx-trigger="click" hx-swap="none" {% if p.completed_personal_3 %}checked{% endif %}> <span>3. Die/Retire</span></label>
</div>
</div>
{% endfor %}
</div>
<!-- End Scene Button --> <!-- End Scene Button -->
<div class="scene-upkeep-controls text-center" style="margin-top: 1rem;"> <div class="scene-upkeep-controls text-center" style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
<p class="info-text">Conclude the scene once players have made attempts or the obstacle list is depleted.</p> <p class="info-text">Conclude the scene once players have made attempts or the obstacle list is depleted.</p>
<button hx-post="/game/{{ game.id }}/scene/end" <button hx-post="/game/{{ game.id }}/scene/end"
hx-swap="none" hx-swap="none"
@@ -239,45 +272,54 @@
<div class="sheet-group margin-top"> <div class="sheet-group margin-top">
<h4>Objectives Tracker:</h4> <h4>Objectives Tracker:</h4>
<p class="info-text">Toggle your objectives as you complete them to earn Ranks and unlock privileges.</p> <p class="info-text">Deep players control these objectives. They unlock privileges when checked.</p>
<div class="objectives-checklist"> <div class="objectives-checklist">
<!-- Personal 1 --> <div style="margin-bottom: 0.5rem;">
<label class="checkbox-label"> <span>{% if player.completed_personal_1 %}✅{% else %}⬜{% endif %} 🔫 Personal 1: Get a Gat</span>
<input type="checkbox" <div class="footnote-desc">Privilege: +1 to Black cards. Tax: Can ask a Gatted player to do challenges.</div>
hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=personal_1"
hx-trigger="click"
hx-swap="none"
{% if player.completed_personal_1 %}checked{% endif %}>
<span>🔫 Personal 1: Get a Gat</span>
</label>
<div class="footnote-desc">
Privilege: +1 to Black cards (Clubs/Spades). Tax: Can ask a Gatted player to do challenges.
</div> </div>
<!-- Personal 2 --> <div style="margin-bottom: 0.5rem;">
<label class="checkbox-label margin-top-small"> <span>{% if player.completed_personal_2 %}✅{% else %}⬜{% endif %} 👑 Personal 2: Earn a Name</span>
<input type="checkbox" <div class="footnote-desc">Privilege: +1 to Red cards. Tax: Can ask a Named player to do challenges.</div>
hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=personal_2" {% if player.needs_name %}
hx-trigger="click" <form hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-name" hx-swap="none" style="margin-top: 0.5rem; display: flex; gap: 0.5rem;">
hx-swap="none" <input type="text" name="new_name" class="input-field" placeholder="Enter new proper name" required>
{% if player.completed_personal_2 %}checked{% endif %}> <button type="submit" class="btn btn-primary">Claim Name</button>
<span>👑 Personal 2: Earn a Name</span> </form>
</label> {% endif %}
<div class="footnote-desc">
Privilege: +1 to Red cards (Hearts/Diamonds). Tax: Can ask a Named player to do challenges.
</div> </div>
<!-- Personal 3 --> <div style="margin-bottom: 0.5rem;">
<label class="checkbox-label margin-top-small"> <span>{% if player.completed_personal_3 %}✅{% else %}⬜{% endif %} ☠️ Personal 3: Die Like a Pirate</span>
<input type="checkbox" <div class="footnote-desc">Bonus: Rank up another player.</div>
hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=personal_3"
hx-trigger="click" {% if player.needs_rank_3_bonus %}
hx-swap="none" <form hx-post="/game/{{ game.id }}/player/{{ player.id }}/bonus-rank-up" hx-swap="none" style="margin-top: 0.5rem; display: flex; flex-direction: column; gap: 0.5rem; background: rgba(212,175,55,0.1); padding: 0.5rem; border: 1px solid var(--gold); border-radius: 4px;">
{% if player.completed_personal_3 %}checked{% endif %}> <label>Choose a Pi-Rat to gain 1 Rank:</label>
<span>☠️ Personal 3: Die Like a Pirate (or Retire)</span> <select name="target_player_id" class="input-field" required>
</label> <option value="" disabled selected>Select player...</option>
<div class="footnote-desc"> {% for p in game.players if p.role == 'pirat' and p.id != player.id %}
Bonus: Rank up another player. <option value="{{ p.id }}">{{ p.name }} (Rank {{ p.rank }})</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary">Bestow Rank & Pass On</button>
</form>
{% endif %}
{% if player.is_dead and not player.is_ghost %}
<div style="margin-top: 0.5rem; background: rgba(255,100,100,0.1); padding: 0.5rem; border: 1px solid red; border-radius: 4px; text-align: center;">
<h4 style="color: red; margin:0 0 0.5rem 0;">You Died!</h4>
<p class="info-text">Your Pi-Rat's story has concluded. You may continue to watch over your crew as a ghost!</p>
<button hx-post="/game/{{ game.id }}/player/{{ player.id }}/become-ghost" hx-swap="none" class="btn btn-secondary">Become a Ghost</button>
</div>
{% endif %}
{% if player.is_ghost %}
<div style="margin-top: 0.5rem; text-align: center;">
<span style="display:inline-block; padding: 0.25rem 0.5rem; background: rgba(255,255,255,0.1); border-radius: 12px; font-weight: bold;">👻 Ghost Mode Active</span>
<p class="footnote-desc">You can still use your hand to help the living, but between scenes you may roll a new recruit.</p>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>