Objectives & Milestones Update
This commit is contained in:
@@ -307,9 +307,19 @@ def play_card_on_obstacle(
|
||||
|
||||
return True, "Card played successfully.", res_dict
|
||||
|
||||
def toggle_objective(db: Session, player_id: str, obj_type: str, status: bool):
|
||||
"""Toggles Personal or Crew objectives and adjusts Ranks accordingly."""
|
||||
player = get_player(db, player_id)
|
||||
def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, status: bool):
|
||||
"""Toggles Personal or Crew objectives and adjusts states/Ranks accordingly."""
|
||||
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:
|
||||
return
|
||||
|
||||
@@ -324,14 +334,56 @@ def toggle_objective(db: Session, player_id: str, obj_type: str, status: bool):
|
||||
elif obj_type == "personal_2":
|
||||
if status and not player.completed_personal_2:
|
||||
rank_diff = 1
|
||||
player.needs_name = True
|
||||
elif not status and player.completed_personal_2:
|
||||
rank_diff = -1
|
||||
player.needs_name = False
|
||||
player.completed_personal_2 = status
|
||||
|
||||
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.rank += rank_diff
|
||||
player.rank = max(1, player.rank)
|
||||
db.add(player)
|
||||
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."
|
||||
|
||||
@@ -18,10 +18,27 @@ def create_db_and_tables():
|
||||
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
|
||||
|
||||
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():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
@@ -10,6 +10,9 @@ class Game(SQLModel, table=True):
|
||||
current_scene_number: int = Field(default=1)
|
||||
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", ...]
|
||||
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)
|
||||
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_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
|
||||
game: Optional[Game] = Relationship(back_populates="players")
|
||||
|
||||
|
||||
@@ -111,22 +111,86 @@ def play_joker_route(
|
||||
def toggle_objective_route(
|
||||
game_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)
|
||||
):
|
||||
player = crud.get_player(db, player_id)
|
||||
if not player:
|
||||
raise HTTPException(status_code=404, detail="Player not found")
|
||||
if player and player.needs_name:
|
||||
player.name = new_name
|
||||
player.needs_name = False
|
||||
db.add(player)
|
||||
db.commit()
|
||||
return HTMLResponse(status_code=204)
|
||||
|
||||
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
|
||||
# Pi-Rat bonus rank up for another player
|
||||
@router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
|
||||
def bonus_rank_up_route(
|
||||
game_id: str,
|
||||
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)
|
||||
|
||||
crud.toggle_objective(db, player_id, type, not current_status)
|
||||
# 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)
|
||||
|
||||
# Deep ends the scene
|
||||
|
||||
@@ -112,11 +112,22 @@
|
||||
<span class="suit">{{ obs.symbol }}</span>
|
||||
</div>
|
||||
{% for pc in column_cards %}
|
||||
<div class="card-mini played-card rotated {% if pc.success %}success{% else %}failure{% endif %}"
|
||||
title="{{ pc.player_name }} played {{ pc.card }}: {{ pc.details }}">
|
||||
<span class="val">{{ pc.card[:-1] if not pc.card.startswith('Joker') else '🃏' }}</span>
|
||||
<span class="suit">{{ parse_card(pc.card).symbol }}</span>
|
||||
<span class="owner">{{ pc.player_name[:4] }}</span>
|
||||
<div style="display:flex; align-items:center; gap:0.5rem; margin-bottom: 0.25rem;">
|
||||
<div class="card-mini played-card rotated {% if pc.success %}success{% else %}failure{% endif %}"
|
||||
title="{{ pc.player_name }} played {{ pc.card }}: {{ pc.details }}">
|
||||
<span class="val">{{ pc.card[:-1] if not pc.card.startswith('Joker') else '🃏' }}</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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -146,13 +146,46 @@
|
||||
|
||||
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
||||
<div class="private-column">
|
||||
<!-- DEEP CONTROLS: End Scene -->
|
||||
<!-- DEEP CONTROLS: End Scene & Objectives -->
|
||||
{% 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>
|
||||
|
||||
<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 -->
|
||||
<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>
|
||||
<button hx-post="/game/{{ game.id }}/scene/end"
|
||||
hx-swap="none"
|
||||
@@ -239,45 +272,54 @@
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<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">
|
||||
<!-- Personal 1 -->
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox"
|
||||
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 style="margin-bottom: 0.5rem;">
|
||||
<span>{% if player.completed_personal_1 %}✅{% else %}⬜{% endif %} 🔫 Personal 1: Get a Gat</span>
|
||||
<div class="footnote-desc">Privilege: +1 to Black cards. Tax: Can ask a Gatted player to do challenges.</div>
|
||||
</div>
|
||||
|
||||
<!-- Personal 2 -->
|
||||
<label class="checkbox-label margin-top-small">
|
||||
<input type="checkbox"
|
||||
hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=personal_2"
|
||||
hx-trigger="click"
|
||||
hx-swap="none"
|
||||
{% if player.completed_personal_2 %}checked{% endif %}>
|
||||
<span>👑 Personal 2: Earn a Name</span>
|
||||
</label>
|
||||
<div class="footnote-desc">
|
||||
Privilege: +1 to Red cards (Hearts/Diamonds). Tax: Can ask a Named player to do challenges.
|
||||
<div style="margin-bottom: 0.5rem;">
|
||||
<span>{% if player.completed_personal_2 %}✅{% else %}⬜{% endif %} 👑 Personal 2: Earn a Name</span>
|
||||
<div class="footnote-desc">Privilege: +1 to Red cards. Tax: Can ask a Named player to do challenges.</div>
|
||||
{% if player.needs_name %}
|
||||
<form hx-post="/game/{{ game.id }}/player/{{ player.id }}/set-name" hx-swap="none" style="margin-top: 0.5rem; display: flex; gap: 0.5rem;">
|
||||
<input type="text" name="new_name" class="input-field" placeholder="Enter new proper name" required>
|
||||
<button type="submit" class="btn btn-primary">Claim Name</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Personal 3 -->
|
||||
<label class="checkbox-label margin-top-small">
|
||||
<input type="checkbox"
|
||||
hx-post="/game/{{ game.id }}/player/{{ player.id }}/objective/toggle?type=personal_3"
|
||||
hx-trigger="click"
|
||||
hx-swap="none"
|
||||
{% if player.completed_personal_3 %}checked{% endif %}>
|
||||
<span>☠️ Personal 3: Die Like a Pirate (or Retire)</span>
|
||||
</label>
|
||||
<div class="footnote-desc">
|
||||
Bonus: Rank up another player.
|
||||
<div style="margin-bottom: 0.5rem;">
|
||||
<span>{% if player.completed_personal_3 %}✅{% else %}⬜{% endif %} ☠️ Personal 3: Die Like a Pirate</span>
|
||||
<div class="footnote-desc">Bonus: Rank up another player.</div>
|
||||
|
||||
{% if player.needs_rank_3_bonus %}
|
||||
<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;">
|
||||
<label>Choose a Pi-Rat to gain 1 Rank:</label>
|
||||
<select name="target_player_id" class="input-field" required>
|
||||
<option value="" disabled selected>Select player...</option>
|
||||
{% for p in game.players if p.role == 'pirat' and p.id != player.id %}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user