From 1837bf04e62b2e219bdf5e6dd0ce80ad95e41aaf Mon Sep 17 00:00:00 2001 From: Tim McCarthy Date: Fri, 4 Sep 2026 19:18:38 -0700 Subject: [PATCH] Advance after final rank vote and allow ballot changes --- AGENTS.md | 2 + TODO.md | 2 +- frontend/src/assets/css/upkeep.css | 2 +- frontend/src/components/UpkeepPhase.svelte | 67 +++++++++------------ frontend/src/lib/changelog.js | 3 +- frontend/src/pages/Dashboard.svelte | 9 +++ src/pirats/crud_character.py | 2 + src/pirats/crud_scene.py | 1 + src/pirats/crud_upkeep.py | 25 +++++--- src/pirats/routes_scene.py | 1 + src/pirats/routes_upkeep.py | 18 ------ tests/test_game.py | 68 +++++++++++++++++++++- 12 files changed, 132 insertions(+), 68 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1f627c7..21541d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). diff --git a/TODO.md b/TODO.md index 09ce290..d11edd2 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/frontend/src/assets/css/upkeep.css b/frontend/src/assets/css/upkeep.css index 9880808..cbe38ac 100644 --- a/frontend/src/assets/css/upkeep.css +++ b/frontend/src/assets/css/upkeep.css @@ -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; } diff --git a/frontend/src/components/UpkeepPhase.svelte b/frontend/src/components/UpkeepPhase.svelte index 0d24c5a..66e6545 100644 --- a/frontend/src/components/UpkeepPhase.svelte +++ b/frontend/src/components/UpkeepPhase.svelte @@ -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 @@

Between Scenes · Scene {state.game.current_scene_number}

-

Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.

+

{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.'}

{#if state.player.is_dead && !state.player.is_ghost && !state.player.needs_reroll} @@ -204,7 +196,9 @@

Rank Up

-

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.

+ {#if state.game.phase === 'between_scenes'} +

Nominate another Pi-Rat who best showed their pirate spirit this scene. The Deep cannot receive votes.

+ {/if} {#if state.game.phase === 'deep_upkeep'}
@@ -214,26 +208,30 @@

✔️ Voting complete! No one ranked up this time.

{/if}
- {:else if hasVoted} + {:else} + {#if hasVoted}

✓ Your vote: {votedNominee ? displayName(votedNominee) : "Unavailable crewmate"}

- {:else if !mustVote} + {/if} + {#if !mustVote}
-

No living crewmates left to nominate — you skip voting this round. Go ahead and ready up.

+

No eligible crewmates — voting skipped.

{:else}
- {#each myNominees as p} {/each} - +
+ {/if} + {#if voteError}{/if} {/if}
@@ -320,7 +318,7 @@ {/if}
- +
{#if state.game.phase === 'deep_upkeep'} {#if state.player.role === 'deep' && !state.player.is_ready} @@ -331,24 +329,6 @@
{/if} - {:else} - {#if mustVote && !hasVoted} -

⚠️ You must nominate a crewmate above before you can ready up.

- - {:else} - {#if state.player.is_ready} -
-

Ready! Waiting for other players to ready up...

-
-
- {:else} - - {/if} - {/if} {/if} {#if state.player.is_admin && state.game.phase === 'between_scenes'} @@ -360,3 +340,12 @@ {/if} + + diff --git a/frontend/src/lib/changelog.js b/frontend/src/lib/changelog.js index c0352eb..9fd8c2a 100644 --- a/frontend/src/lib/changelog.js +++ b/frontend/src/lib/changelog.js @@ -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.'] }, diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index aebd6bd..9654531 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -229,6 +229,15 @@
+ {#if ['scene_setup', 'recruit_creation'].includes(state.game.phase) && (state.game.last_rank_up_player_id || state.game.current_scene_number > 1)} +
+ {#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} +
+ {/if} {#if state.game.phase === 'lobby'} {:else if state.game.phase === 'character_creation' || state.game.phase === 'swap_techniques' || state.game.phase === 'assign_techniques'} diff --git a/src/pirats/crud_character.py b/src/pirats/crud_character.py index d455ce4..8d9c751 100644 --- a/src/pirats/crud_character.py +++ b/src/pirats/crud_character.py @@ -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: diff --git a/src/pirats/crud_scene.py b/src/pirats/crud_scene.py index e5d5a1c..f74aad4 100644 --- a/src/pirats/crud_scene.py +++ b/src/pirats/crud_scene.py @@ -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() diff --git a/src/pirats/crud_upkeep.py b/src/pirats/crud_upkeep.py index 8d28723..527e58a 100644 --- a/src/pirats/crud_upkeep.py +++ b/src/pirats/crud_upkeep.py @@ -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,10 +150,18 @@ 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): - transition_to_deep_upkeep(db, game.id) + """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): game = get_game(db, game_id) diff --git a/src/pirats/routes_scene.py b/src/pirats/routes_scene.py index 5cb9bcb..043e5ce 100644 --- a/src/pirats/routes_scene.py +++ b/src/pirats/routes_scene.py @@ -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 diff --git a/src/pirats/routes_upkeep.py b/src/pirats/routes_upkeep.py index b1d8be3..c50ae9b 100644 --- a/src/pirats/routes_upkeep.py +++ b/src/pirats/routes_upkeep.py @@ -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( diff --git a/tests/test_game.py b/tests/test_game.py index 2a9a854..2abb0e9 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -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