Advance after final rank vote and allow ballot changes

This commit is contained in:
2026-09-04 19:18:38 -07:00
parent 88f8781dce
commit 1837bf04e6
12 changed files with 132 additions and 68 deletions
+2
View File
@@ -24,6 +24,8 @@ When you run into a repeatable problem during testing (e.g. port assignment coll
- Before browser testing, check whether the default Vite port belongs to this project. If it is occupied by another app, start this frontend with `npm --prefix frontend run dev -- --host 127.0.0.1 --port 5174 --strictPort` and use that URL. Local server binds may require sandbox escalation. - Before browser testing, check whether the default Vite port belongs to this project. If it is occupied by another app, start this frontend with `npm --prefix frontend run dev -- --host 127.0.0.1 --port 5174 --strictPort` and use that URL. Local server binds may require sandbox escalation.
- In-memory SQLite tests that use FastAPI TestClient need `poolclass=StaticPool` as well as `check_same_thread=False`, so request threads share the database after commits. Otherwise they can fail with `no such table`.
## Versioning & changelog ## Versioning & changelog
The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`). The app shows its version number and a player-facing changelog in the ☰ menu → About. Both come from `frontend/src/lib/changelog.js` (rendered by `frontend/src/components/AboutModal.svelte`).
+1 -1
View File
@@ -15,7 +15,7 @@
- [x] Remove display of current deck size - [x] Remove display of current deck size
- [x] Voting status in the between-scenes phase should be visual, not text-based - [x] Voting status in the between-scenes phase should be visual, not text-based
- [x] Players should be able to see who they voted for - [x] Players should be able to see who they voted for
- [ ] The "ready" button between scenes is redundant. Allow players to vote, and allow them to change their vote, but once all players have voted, display the result and move on - [x] The "ready" button between scenes is redundant. Allow players to vote, and allow them to change their vote, but once all players have voted, display the result and move on
## Rules ## Rules
+1 -1
View File
@@ -1,7 +1,7 @@
/* --- Between-scenes upkeep --- */ /* --- Between-scenes upkeep --- */
.between-grid { .between-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: minmax(0, 1fr);
gap: 2rem; gap: 2rem;
margin: 2rem 0; margin: 2rem 0;
} }
+28 -39
View File
@@ -6,7 +6,7 @@
export let state; export let state;
let voting = false; let voting = false;
let readying = false; let voteError = "";
let confirming = false; let confirming = false;
let selectedVoteId = ''; let selectedVoteId = '';
@@ -134,28 +134,18 @@
async function submitVote() { async function submitVote() {
if (!selectedVoteId) return; if (!selectedVoteId) return;
voting = true; voting = true;
voteError = "";
try { try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-vote`, 'POST', { await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-vote`, 'POST', {
nominated_id: selectedVoteId nominated_id: selectedVoteId
}); });
} catch(e) { } catch(e) {
console.error(e); voteError = e.message;
} finally { } finally {
voting = false; voting = false;
} }
} }
async function setReady() {
readying = true;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/ready-next`, 'POST');
} catch(e) {
console.error(e);
} finally {
readying = false;
}
}
async function confirmRefresh() { async function confirmRefresh() {
confirming = true; confirming = true;
try { try {
@@ -173,7 +163,9 @@
<div class="between-scenes-view text-center"> <div class="between-scenes-view text-center">
<h2>Between Scenes · Scene {state.game.current_scene_number}</h2> <h2>Between Scenes · Scene {state.game.current_scene_number}</h2>
<p class="description">Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.</p> <p class="description">{state.game.phase === 'between_scenes'
? 'Vote for a crewmate to rank up. You can change your vote until everyone has voted.'
: 'Voting complete. The resting Deep refreshes their hand before the next scene.'}</p>
{#if state.player.is_dead && !state.player.is_ghost && !state.player.needs_reroll} {#if state.player.is_dead && !state.player.is_ghost && !state.player.needs_reroll}
<!-- Death Fate Panel --> <!-- Death Fate Panel -->
@@ -204,7 +196,9 @@
<!-- Ranks and Voting Card --> <!-- Ranks and Voting Card -->
<div class="card glass-panel voting-card"> <div class="card glass-panel voting-card">
<h3>Rank Up</h3> <h3>Rank Up</h3>
<p class="section-desc">Every player (including the Deep) nominates one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p> {#if state.game.phase === 'between_scenes'}
<p class="section-desc">Nominate another Pi-Rat who best showed their pirate spirit this scene. The Deep cannot receive votes.</p>
{/if}
{#if state.game.phase === 'deep_upkeep'} {#if state.game.phase === 'deep_upkeep'}
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
@@ -214,27 +208,31 @@
<p class="success-text">✔️ Voting complete! No one ranked up this time.</p> <p class="success-text">✔️ Voting complete! No one ranked up this time.</p>
{/if} {/if}
</div> </div>
{:else if hasVoted} {:else}
{#if hasVoted}
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
<p class="success-text">✓ Your vote: <strong>{votedNominee ? displayName(votedNominee) : "Unavailable crewmate"}</strong></p> <p class="success-text">✓ Your vote: <strong>{votedNominee ? displayName(votedNominee) : "Unavailable crewmate"}</strong></p>
</div> </div>
{:else if !mustVote} {/if}
{#if !mustVote}
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
<p class="info-text">No living crewmates left to nominate — you skip voting this round. Go ahead and ready up.</p> <p class="info-text">No eligible crewmates — voting skipped.</p>
</div> </div>
{:else} {:else}
<form on:submit|preventDefault={submitVote} class="vote-form inline-form"> <form on:submit|preventDefault={submitVote} class="vote-form inline-form">
<div class="form-group inline-group"> <div class="form-group inline-group">
<select class="select-field" bind:value={selectedVoteId} required> <select aria-label="Nominee" class="select-field" bind:value={selectedVoteId} required>
<option value="">Nominate crewmate...</option> <option value="">Nominate crewmate...</option>
{#each myNominees as p} {#each myNominees as p}
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option> <option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/each} {/each}
</select> </select>
<button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button> <button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId || selectedVoteId === myVote?.nominated_player_id}>{hasVoted ? "Change Vote" : "Vote"}</button>
</div> </div>
</form> </form>
{/if} {/if}
{#if voteError}<p class="alert alert-danger" role="alert">{voteError}</p>{/if}
{/if}
</div> </div>
@@ -320,7 +318,7 @@
{/if} {/if}
</div> </div>
<!-- Next Scene Ready Up --> <!-- Upkeep progress and end-story action -->
<div class="action-box margin-top"> <div class="action-box margin-top">
{#if state.game.phase === 'deep_upkeep'} {#if state.game.phase === 'deep_upkeep'}
{#if state.player.role === 'deep' && !state.player.is_ready} {#if state.player.role === 'deep' && !state.player.is_ready}
@@ -331,24 +329,6 @@
<div class="spinner-small"></div> <div class="spinner-small"></div>
</div> </div>
{/if} {/if}
{:else}
{#if mustVote && !hasVoted}
<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 state.player.is_ready}
<div class="waiting-box">
<p>Ready! Waiting for other players to ready up...</p>
<div class="spinner-small"></div>
</div>
{:else}
<button on:click={setReady}
disabled={readying}
class="btn btn-primary btn-large glow-effect">
Ready for Next Scene
</button>
{/if}
{/if}
{/if} {/if}
{#if state.player.is_admin && state.game.phase === 'between_scenes'} {#if state.player.is_admin && state.game.phase === 'between_scenes'}
@@ -360,3 +340,12 @@
</div> </div>
{/if} {/if}
</div> </div>
<style>
.vote-form { margin-top: 1rem; }
.vote-form button { white-space: nowrap; flex-shrink: 0; }
.vote-form select { min-width: 0; }
@media (max-width: 600px) {
.vote-form .inline-group { flex-direction: column; align-items: stretch; }
}
</style>
+2 -1
View File
@@ -6,10 +6,11 @@
// - Add a CHANGELOG entry only when a commit changes something players can // - Add a CHANGELOG entry only when a commit changes something players can
// see. Skip refactors, tests, and tooling. Keep wording player-facing. // see. Skip refactors, tests, and tooling. Keep wording player-facing.
export const VERSION = 30; export const VERSION = 31;
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }. // Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
export const CHANGELOG = [ export const CHANGELOG = [
{ version: 31, date: '2026-09-04', changes: ['Change your rank-up vote until everyone has voted. The final vote now reveals the result and advances automatically, without a Ready button.', 'The vote result stays visible through upkeep and next-scene setup.'] },
{ version: 30, date: '2026-09-04', changes: ['The crew roster shows voting progress with icons.', 'Your submitted vote now shows the crewmate you nominated.'] }, { version: 30, date: '2026-09-04', changes: ['The crew roster shows voting progress with icons.', 'Your submitted vote now shows the crewmate you nominated.'] },
{ version: 29, date: '2026-09-04', changes: ['Removed the remaining deck count from the table.'] }, { version: 29, date: '2026-09-04', changes: ['Removed the remaining deck count from the table.'] },
{ version: 28, date: '2026-09-04', changes: ['The Event Log toggle keeps the same size when opened or closed.', 'Dev Mode changes no longer clutter the Event Log.'] }, { version: 28, date: '2026-09-04', changes: ['The Event Log toggle keeps the same size when opened or closed.', 'Dev Mode changes no longer clutter the Event Log.'] },
+9
View File
@@ -229,6 +229,15 @@
<div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}> <div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}>
<CrewSidebar {state} /> <CrewSidebar {state} />
<main class="phase-main-column"> <main class="phase-main-column">
{#if ['scene_setup', 'recruit_creation'].includes(state.game.phase) && (state.game.last_rank_up_player_id || state.game.current_scene_number > 1)}
<div class="notice-banner" role="status">
{#if state.game.last_rank_up_player_id}
🏆 {displayName(state.players.find(p => p.id === state.game.last_rank_up_player_id))} won the rank-up vote!
{:else}
Voting complete. No one ranked up this time.
{/if}
</div>
{/if}
{#if state.game.phase === 'lobby'} {#if state.game.phase === 'lobby'}
<LobbyPhase {state} /> <LobbyPhase {state} />
{:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'} {:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'}
+2
View File
@@ -584,6 +584,8 @@ def choose_reroll(db: Session, player_id: str):
db.add(player) db.add(player)
db.commit() db.commit()
add_game_event(db, player.game_id, f"{player.name} will return as a fresh recruit! The crew will help create them after upkeep.", kind="join") add_game_event(db, player.game_id, f"{player.name} will return as a fresh recruit! The crew will help create them after upkeep.", kind="join")
from .crud_upkeep import recheck_phase_completion
recheck_phase_completion(db, get_game(db, player.game_id))
return True, "Queued for recruit creation." return True, "Queued for recruit creation."
def techniques_in_use(game: Game, exclude_player_id: str = None) -> set: def techniques_in_use(game: Game, exclude_player_id: str = None) -> set:
+1
View File
@@ -160,6 +160,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
db.add(p) db.add(p)
game.deck_cards = json.dumps(deck) game.deck_cards = json.dumps(deck)
game.last_rank_up_player_id = None
game.phase = "scene" game.phase = "scene"
db.add(game) db.add(game)
db.commit() db.commit()
+17 -6
View File
@@ -23,8 +23,11 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
Every Player (including last scene's Deep Players) nominates one *other* Pi-Rat Every Player (including last scene's Deep Players) nominates one *other* Pi-Rat
Player to Rank up. Deep Players from the previous scene cannot be nominated, Player to Rank up. Deep Players from the previous scene cannot be nominated,
nor can dead or awaiting-recruit Pi-Rats. A voter whose only options are nor can dead or awaiting-recruit Pi-Rats. A voter whose only options are
ineligible skips voting (the frontend lets them ready up without a vote). ineligible skips voting automatically.
""" """
game = get_game(db, game_id)
if not game or game.phase != "between_scenes":
return False, "Voting is closed."
voter = get_player(db, voter_id) voter = get_player(db, voter_id)
nominee = get_player(db, nominated_id) nominee = get_player(db, nominated_id)
if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id: if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id:
@@ -38,7 +41,6 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
).all() ).all()
for v in existing: for v in existing:
db.delete(v) db.delete(v)
db.commit()
vote = Vote( vote = Vote(
game_id=game_id, game_id=game_id,
@@ -47,6 +49,7 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
) )
db.add(vote) db.add(vote)
db.commit() db.commit()
maybe_transition_to_deep_upkeep(db, game)
return True, "Vote submitted." return True, "Vote submitted."
def unchallenged_pirats(game: Game): def unchallenged_pirats(game: Game):
@@ -85,6 +88,7 @@ def end_scene_and_transition(db: Session, game_id: str):
db.commit() db.commit()
add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene") add_game_event(db, game_id, f"Scene {game.current_scene_number} has ended! Transitioning to upkeep phase.", kind="scene")
maybe_transition_to_deep_upkeep(db, game)
return True, "Scene ended." return True, "Scene ended."
def process_between_scenes_votes(db: Session, game: Game): def process_between_scenes_votes(db: Session, game: Game):
@@ -128,7 +132,6 @@ def begin_next_scene_setup(db: Session, game: Game):
"""Advances to the next scene's setup: bump the scene counter and clear roles.""" """Advances to the next scene's setup: bump the scene counter and clear roles."""
game.current_scene_number += 1 game.current_scene_number += 1
game.phase = "scene_setup" game.phase = "scene_setup"
game.last_rank_up_player_id = None # Stale once we leave the post-voting screen
for p in game.players: for p in game.players:
p.role = None p.role = None
p.is_ready = False p.is_ready = False
@@ -147,9 +150,17 @@ def advance_after_upkeep(db: Session, game: Game):
begin_next_scene_setup(db, game) begin_next_scene_setup(db, game)
def maybe_transition_to_deep_upkeep(db: Session, game: Game): def maybe_transition_to_deep_upkeep(db: Session, game: Game):
"""In between_scenes, move on once every player has readied up. Safe to call """Advance after every eligible voter has voted and death choices are settled."""
after a roster change to unblock the phase.""" if game.phase != "between_scenes" or not game.players:
if game.players and all(p.is_ready for p in game.players): return
if any(p.is_dead and not p.is_ghost and not p.needs_reroll for p in game.players):
return
votes = db.exec(select(Vote).where(Vote.game_id == game.id)).all()
ballots = {v.voter_player_id: v.nominated_player_id for v in votes}
for player in game.players:
nominees = eligible_vote_nominees(game, player)
if nominees and ballots.get(player.id) not in {n.id for n in nominees}:
return
transition_to_deep_upkeep(db, game.id) transition_to_deep_upkeep(db, game.id)
def transition_to_deep_upkeep(db: Session, game_id: str): def transition_to_deep_upkeep(db: Session, game_id: str):
+1
View File
@@ -180,6 +180,7 @@ def become_ghost_route(
player.needs_reroll = False # in case they queued a re-roll first, ghosthood wins player.needs_reroll = False # in case they queued a re-roll first, ghosthood wins
db.add(player) db.add(player)
db.commit() db.commit()
crud.recheck_phase_completion(db, crud.get_game(db, player.game_id))
return {"status": "ok"} return {"status": "ok"}
# Deep ends the scene # Deep ends the scene
-18
View File
@@ -20,24 +20,6 @@ def submit_vote_route(
return JSONResponse({"error": msg}, status_code=400) return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"} return {"status": "ok"}
# Player Ready for Next Scene
@router.post("/game/{game_id}/player/{player_id}/ready-next")
def ready_next_route(game_id: str, player_id: str, db: Session = Depends(get_session)):
player = crud.get_player(db, player_id)
if not player:
raise HTTPException(status_code=404, detail="Player not found")
player.is_ready = True
db.add(player)
db.commit()
# Check if all players in the game are ready. If so, transition to deep upkeep!
game = crud.get_game(db, game_id)
if game:
crud.maybe_transition_to_deep_upkeep(db, game)
return {"status": "ok"}
# Confirm Hand Refresh for Deep player # Confirm Hand Refresh for Deep player
@router.post("/game/{game_id}/player/{player_id}/confirm-refresh") @router.post("/game/{game_id}/player/{player_id}/confirm-refresh")
def confirm_deep_refresh_route( def confirm_deep_refresh_route(
+67 -1
View File
@@ -1,6 +1,7 @@
import pytest import pytest
import json import json
from sqlmodel import SQLModel, create_engine, Session, select from sqlmodel import SQLModel, create_engine, Session, select
from sqlalchemy.pool import StaticPool
from pirats import cards from pirats import cards
from pirats import crud from pirats import crud
from pirats.models import Game, Obstacle, Player, Challenge, GameEvent, Checkpoint from pirats.models import Game, Obstacle, Player, Challenge, GameEvent, Checkpoint
@@ -8,7 +9,7 @@ from pirats.models import Game, Obstacle, Player, Challenge, GameEvent, Checkpoi
# In-memory database for testing # In-memory database for testing
@pytest.fixture(name="session") @pytest.fixture(name="session")
def session_fixture(): def session_fixture():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}) engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
with Session(engine) as session: with Session(engine) as session:
yield session yield session
@@ -1128,6 +1129,8 @@ def test_vote_validation(session):
p1 = crud.add_player(session, game.id, "P1") p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2") p2 = crud.add_player(session, game.id, "P2")
p3 = crud.add_player(session, game.id, "P3") p3 = crud.add_player(session, game.id, "P3")
game.phase = "between_scenes"
session.add(game)
p1.role = "deep" p1.role = "deep"
p2.role = "pirat" p2.role = "pirat"
p3.role = "pirat" p3.role = "pirat"
@@ -2585,3 +2588,66 @@ def test_request_body_size_limit_on_real_app():
client = TestClient(app) client = TestClient(app)
r = client.post("/api/game", content=b"x" * (DEFAULT_MAX_BODY_BYTES + 1)) r = client.post("/api/game", content=b"x" * (DEFAULT_MAX_BODY_BYTES + 1))
assert r.status_code == 413 assert r.status_code == 413
def test_votes_can_change_until_final_vote_then_close(session):
game, deep, (a, b) = make_scene_game(session, num_pirats=2)
crud.end_scene_and_transition(session, game.id)
assert crud.submit_rank_vote(session, game.id, deep.id, a.id)[0]
assert crud.submit_rank_vote(session, game.id, deep.id, b.id)[0]
session.refresh(game)
assert len(game.votes) == 1
assert game.votes[0].nominated_player_id == b.id
assert game.phase == "between_scenes"
assert crud.submit_rank_vote(session, game.id, a.id, b.id)[0]
old_rank = b.rank
assert crud.submit_rank_vote(session, game.id, b.id, a.id)[0]
session.refresh(game)
assert game.phase == "deep_upkeep"
assert game.last_rank_up_player_id == b.id
assert b.rank == old_rank + 1
assert not crud.submit_rank_vote(session, game.id, deep.id, a.id)[0]
crud.maybe_transition_to_deep_upkeep(session, game)
assert b.rank == old_rank + 1
def test_vote_skips_sole_nominee_and_advances_without_ready(session):
game, deep, (a,) = make_scene_game(session)
crud.end_scene_and_transition(session, game.id)
assert crud.eligible_vote_nominees(game, a) == []
assert crud.submit_rank_vote(session, game.id, deep.id, a.id)[0]
assert game.phase == "deep_upkeep"
assert game.last_rank_up_player_id == a.id
def test_voting_waits_for_death_choice_and_skips_empty_ballots(session):
game, deep, (a,) = make_scene_game(session)
a.is_dead = True
session.add(a)
session.commit()
crud.end_scene_and_transition(session, game.id)
assert game.phase == "between_scenes"
assert crud.choose_reroll(session, a.id)[0]
assert game.phase == "deep_upkeep"
assert game.last_rank_up_player_id is None
def test_vote_result_survives_skipping_deep_upkeep(session):
game, deep, (a,) = make_scene_game(session)
deep.previous_role = None
session.add(deep)
session.commit()
crud.end_scene_and_transition(session, game.id)
assert crud.submit_rank_vote(session, game.id, deep.id, a.id)[0]
assert game.phase == "scene_setup"
assert game.last_rank_up_player_id == a.id
def test_kick_last_pending_voter_completes_voting(session):
game, deep, (a, b) = make_scene_game(session, num_pirats=2)
crud.end_scene_and_transition(session, game.id)
assert crud.submit_rank_vote(session, game.id, deep.id, a.id)[0]
assert crud.submit_rank_vote(session, game.id, a.id, b.id)[0]
assert crud.kick_player(session, game, b)[0]
assert game.phase == "deep_upkeep"
assert game.last_rank_up_player_id == a.id