From d04e5013fac8ef00894ce393e1c59ace51f05d16 Mon Sep 17 00:00:00 2001 From: Tim McCarthy Date: Fri, 12 Jun 2026 21:21:38 -0700 Subject: [PATCH] Streamline dead Pi-Rat rank voting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dead and awaiting-recruit Pi-Rats can no longer be nominated to rank up (they'd reset to Rank 1 anyway). submit_rank_vote rejects them, and the upkeep dropdown only lists living, in-play crewmates via the shared eligible_vote_nominees helper. When a voter has no eligible nominees (e.g. a lone survivor whose only crewmates are the previous Deep and a dead rat) they now skip voting entirely and can ready up directly, instead of deadlocking — which is why dead players were previously left nominable. The roster shows such players as "No vote". Co-Authored-By: Claude Fable 5 --- TODO.md | 4 --- frontend/src/components/UpkeepPhase.svelte | 32 +++++++++++++++------ src/pirats/crud_upkeep.py | 24 ++++++++++++++-- tests/test_game.py | 33 ++++++++++++++++++++++ 4 files changed, 78 insertions(+), 15 deletions(-) diff --git a/TODO.md b/TODO.md index 825512e..6f319ed 100644 --- a/TODO.md +++ b/TODO.md @@ -3,10 +3,6 @@ - [ ] **Player kicking.** Admins should be able to kick players out of the game so that it doesn't get stuck if a player vanishes. Admins can kick other admins. A kicked player's cards go to the discard. -## Minor Gameplay Polish - -- [ ] **Dead Pi-Rat rank voting streamlined**. Don't allow voting for dead Pi-Rats. If a player's only vote options are dead, then they skip voting entirely. - ## Words Words Words - [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing. diff --git a/frontend/src/components/UpkeepPhase.svelte b/frontend/src/components/UpkeepPhase.svelte index 477b4d1..bd6a1d5 100644 --- a/frontend/src/components/UpkeepPhase.svelte +++ b/frontend/src/components/UpkeepPhase.svelte @@ -14,6 +14,19 @@ // Helpers $: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : []; + // A nominee must be another living, in-play Pi-Rat: not the voter, not a + // previous-scene Deep, and not dead / awaiting-recruit (they'd reset to + // Rank 1 anyway). Mirrors crud_upkeep.is_eligible_nominee. + function eligibleNomineesFor(voter) { + return state.players.filter(p => + p.id !== voter.id && p.role !== 'deep' && !p.is_dead && !p.needs_reroll + ); + } + $: myNominees = eligibleNomineesFor(state.player); + $: hasVoted = !!state.votes.find(v => v.voter_player_id === state.player.id); + // With no eligible crewmates, the voter skips voting entirely (no deadlock). + $: mustVote = myNominees.length > 0; + // Drag & Drop State let keepCards = []; let discardCards = []; @@ -191,19 +204,21 @@

✔️ Voting complete! Results tallied.

- {:else if state.votes.find(v => v.voter_player_id === state.player.id)} + {:else if hasVoted}

✔️ Vote submitted! Waiting for other players to submit their nominations.

+ {:else if !mustVote} +
+

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

+
{:else}
@@ -218,9 +233,10 @@
{#each state.players as p} {@const voted = state.votes.some(v => v.voter_player_id === p.id)} -
+ {@const skips = eligibleNomineesFor(p).length === 0} +
{displayName(p)} - {voted ? 'Done' : 'Voting...'} + {voted ? 'Done' : skips ? 'No vote' : 'Voting...'}
{/each}
@@ -325,7 +341,7 @@
{/if} {:else} - {#if !state.votes.find(v => v.voter_player_id === state.player.id)} + {#if mustVote && !hasVoted}

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

{:else} diff --git a/src/pirats/crud_upkeep.py b/src/pirats/crud_upkeep.py index 4e24d35..471d025 100644 --- a/src/pirats/crud_upkeep.py +++ b/src/pirats/crud_upkeep.py @@ -10,10 +10,28 @@ from .crud_base import ( # --- Between Scenes Operations --- +def is_eligible_nominee(voter, nominee) -> bool: + """A rank-up nominee must be another living, in-play Pi-Rat. Excludes the + voter, previous-scene Deep players, and dead / awaiting-recruit players + (ranking them up is wasted — recruits reset to Rank 1).""" + return ( + nominee.id != voter.id + and nominee.role != "deep" + and not nominee.is_dead + and not nominee.needs_reroll + ) + +def eligible_vote_nominees(game: Game, voter) -> list: + """The crewmates `voter` may nominate to rank up. An empty list means the + voter has no valid pick and skips voting entirely (no deadlock).""" + return [p for p in game.players if is_eligible_nominee(voter, p)] + def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str) -> Tuple[bool, 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. + 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). """ voter = get_player(db, voter_id) nominee = get_player(db, nominated_id) @@ -23,8 +41,8 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str return False, "You cannot nominate yourself. Nice try." if nominee.role == "deep": return False, "Deep Players from the previous scene cannot be nominated." - # Dead/re-rolling players stay nominable on purpose: in small crews they can - # be a voter's only legal pick, and blocking them would deadlock the vote. + if nominee.is_dead or nominee.needs_reroll: + return False, "You can't nominate a Pi-Rat who's out of play. Pick a living crewmate." # Remove any existing vote by this voter for this game existing = db.exec( diff --git a/tests/test_game.py b/tests/test_game.py index 4923455..4be7a63 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -1048,6 +1048,39 @@ def test_vote_validation(session): ok, msg = crud.submit_rank_vote(session, game.id, p1.id, p2.id) assert ok + # Dead and awaiting-recruit Pi-Rats can't be nominated + p3.is_dead = True + session.add(p3) + session.commit() + ok, msg = crud.submit_rank_vote(session, game.id, p1.id, p3.id) + assert not ok + p3.is_dead = False + p3.needs_reroll = True + session.add(p3) + session.commit() + ok, msg = crud.submit_rank_vote(session, game.id, p1.id, p3.id) + assert not ok + +def test_vote_skips_when_only_dead_options(session): + # 3-player crew, one living Pi-Rat whose only crewmates are the previous + # Deep and a dead rat: they have no eligible nominee and skip voting. + game = crud.create_game(session) + deep = crud.add_player(session, game.id, "Deep") + living = crud.add_player(session, game.id, "Living") + dead = crud.add_player(session, game.id, "Dead") + deep.role = "deep" + living.role = "pirat" + dead.role = "pirat" + dead.is_dead = True + session.add_all([deep, living, dead]) + session.commit() + + # The living Pi-Rat has no one valid to nominate + assert crud.eligible_vote_nominees(game, living) == [] + # The Deep can still nominate the living Pi-Rat + nominees = crud.eligible_vote_nominees(game, deep) + assert [p.id for p in nominees] == [living.id] + def test_rank_up_draws_immediately(session): game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) # p2 is rank 1 (lowest -> max 2), p3 is rank 2 (highest -> max 4)