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.
- 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
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] Voting status in the between-scenes phase should be visual, not text-based
- [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
+1 -1
View File
@@ -1,7 +1,7 @@
/* --- Between-scenes upkeep --- */
.between-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: minmax(0, 1fr);
gap: 2rem;
margin: 2rem 0;
}
+28 -39
View File
@@ -6,7 +6,7 @@
export let state;
let voting = false;
let readying = false;
let voteError = "";
let confirming = false;
let selectedVoteId = '';
@@ -134,28 +134,18 @@
async function submitVote() {
if (!selectedVoteId) return;
voting = true;
voteError = "";
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-vote`, 'POST', {
nominated_id: selectedVoteId
});
} catch(e) {
console.error(e);
voteError = e.message;
} finally {
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() {
confirming = true;
try {
@@ -173,7 +163,9 @@
<div class="between-scenes-view text-center">
<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}
<!-- Death Fate Panel -->
@@ -204,7 +196,9 @@
<!-- Ranks and Voting Card -->
<div class="card glass-panel voting-card">
<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'}
<div class="voted-confirmation-box glass-panel">
@@ -214,27 +208,31 @@
<p class="success-text">✔️ Voting complete! No one ranked up this time.</p>
{/if}
</div>
{:else if hasVoted}
{:else}
{#if hasVoted}
<div class="voted-confirmation-box glass-panel">
<p class="success-text">✓ Your vote: <strong>{votedNominee ? displayName(votedNominee) : "Unavailable crewmate"}</strong></p>
</div>
{:else if !mustVote}
{/if}
{#if !mustVote}
<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>
{:else}
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
<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>
{#each myNominees as p}
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/each}
</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>
</form>
{/if}
{#if voteError}<p class="alert alert-danger" role="alert">{voteError}</p>{/if}
{/if}
</div>
@@ -320,7 +318,7 @@
{/if}
</div>
<!-- Next Scene Ready Up -->
<!-- Upkeep progress and end-story action -->
<div class="action-box margin-top">
{#if state.game.phase === 'deep_upkeep'}
{#if state.player.role === 'deep' && !state.player.is_ready}
@@ -331,24 +329,6 @@
<div class="spinner-small"></div>
</div>
{/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 state.player.is_admin && state.game.phase === 'between_scenes'}
@@ -360,3 +340,12 @@
</div>
{/if}
</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
// 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, ...] }.
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: 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.'] },
+9
View File
@@ -229,6 +229,15 @@
<div class="phase-view-layout" class:log-collapsed={!phaseLogOpen}>
<CrewSidebar {state} />
<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'}
<LobbyPhase {state} />
{: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.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")
from .crud_upkeep import recheck_phase_completion
recheck_phase_completion(db, get_game(db, player.game_id))
return True, "Queued for recruit creation."
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)
game.deck_cards = json.dumps(deck)
game.last_rank_up_player_id = None
game.phase = "scene"
db.add(game)
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
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
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)
nominee = get_player(db, nominated_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()
for v in existing:
db.delete(v)
db.commit()
vote = Vote(
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.commit()
maybe_transition_to_deep_upkeep(db, game)
return True, "Vote submitted."
def unchallenged_pirats(game: Game):
@@ -85,6 +88,7 @@ def end_scene_and_transition(db: Session, game_id: str):
db.commit()
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."
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."""
game.current_scene_number += 1
game.phase = "scene_setup"
game.last_rank_up_player_id = None # Stale once we leave the post-voting screen
for p in game.players:
p.role = None
p.is_ready = False
@@ -147,9 +150,17 @@ def advance_after_upkeep(db: Session, game: Game):
begin_next_scene_setup(db, 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
after a roster change to unblock the phase."""
if game.players and all(p.is_ready for p in game.players):
"""Advance after every eligible voter has voted and death choices are settled."""
if game.phase != "between_scenes" or not 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)
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
db.add(player)
db.commit()
crud.recheck_phase_completion(db, crud.get_game(db, player.game_id))
return {"status": "ok"}
# Deep ends the scene
-18
View File
@@ -20,24 +20,6 @@ def submit_vote_route(
return JSONResponse({"error": msg}, status_code=400)
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
@router.post("/game/{game_id}/player/{player_id}/confirm-refresh")
def confirm_deep_refresh_route(
+67 -1
View File
@@ -1,6 +1,7 @@
import pytest
import json
from sqlmodel import SQLModel, create_engine, Session, select
from sqlalchemy.pool import StaticPool
from pirats import cards
from pirats import crud
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
@pytest.fixture(name="session")
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)
with Session(engine) as session:
yield session
@@ -1128,6 +1129,8 @@ def test_vote_validation(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")
game.phase = "between_scenes"
session.add(game)
p1.role = "deep"
p2.role = "pirat"
p3.role = "pirat"
@@ -2585,3 +2588,66 @@ def test_request_body_size_limit_on_real_app():
client = TestClient(app)
r = client.post("/api/game", content=b"x" * (DEFAULT_MAX_BODY_BYTES + 1))
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