Streamline dead Pi-Rat rank voting

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 21:21:38 -07:00
parent 345ef4088f
commit d04e5013fa
4 changed files with 78 additions and 15 deletions

View File

@@ -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. - [ ] **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 ## 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. - [ ] **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.

View File

@@ -14,6 +14,19 @@
// Helpers // Helpers
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : []; $: 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 // Drag & Drop State
let keepCards = []; let keepCards = [];
let discardCards = []; let discardCards = [];
@@ -191,19 +204,21 @@
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
<p class="success-text">✔️ Voting complete! Results tallied.</p> <p class="success-text">✔️ Voting complete! Results tallied.</p>
</div> </div>
{:else if state.votes.find(v => v.voter_player_id === state.player.id)} {:else if hasVoted}
<div class="voted-confirmation-box glass-panel"> <div class="voted-confirmation-box glass-panel">
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p> <p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
</div> </div>
{:else 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>
</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 class="select-field" bind:value={selectedVoteId} required>
<option value="">Nominate crewmate...</option> <option value="">Nominate crewmate...</option>
{#each state.players as p} {#each myNominees as p}
{#if p.id !== state.player.id && p.role !== 'deep'} <option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
{/if}
{/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}>Cast Nomination</button>
@@ -218,9 +233,10 @@
<div class="player-chips list-chips" id="voting-roster"> <div class="player-chips list-chips" id="voting-roster">
{#each state.players as p} {#each state.players as p}
{@const voted = state.votes.some(v => v.voter_player_id === p.id)} {@const voted = state.votes.some(v => v.voter_player_id === p.id)}
<div class="player-chip {voted ? 'voted-chip' : 'not-voted-chip'}"> {@const skips = eligibleNomineesFor(p).length === 0}
<div class="player-chip {voted || skips ? 'voted-chip' : 'not-voted-chip'}">
<span class="name">{displayName(p)}</span> <span class="name">{displayName(p)}</span>
<span class="status-badge">{voted ? 'Done' : 'Voting...'}</span> <span class="status-badge">{voted ? 'Done' : skips ? 'No vote' : 'Voting...'}</span>
</div> </div>
{/each} {/each}
</div> </div>
@@ -325,7 +341,7 @@
</div> </div>
{/if} {/if}
{:else} {:else}
{#if !state.votes.find(v => v.voter_player_id === state.player.id)} {#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> <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> <button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
{:else} {:else}

View File

@@ -10,10 +10,28 @@ from .crud_base import (
# --- Between Scenes Operations --- # --- 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]: 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 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) voter = get_player(db, voter_id)
nominee = get_player(db, nominated_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." return False, "You cannot nominate yourself. Nice try."
if nominee.role == "deep": if nominee.role == "deep":
return False, "Deep Players from the previous scene cannot be nominated." return False, "Deep Players from the previous scene cannot be nominated."
# Dead/re-rolling players stay nominable on purpose: in small crews they can if nominee.is_dead or nominee.needs_reroll:
# be a voter's only legal pick, and blocking them would deadlock the vote. 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 # Remove any existing vote by this voter for this game
existing = db.exec( existing = db.exec(

View File

@@ -1048,6 +1048,39 @@ def test_vote_validation(session):
ok, msg = crud.submit_rank_vote(session, game.id, p1.id, p2.id) ok, msg = crud.submit_rank_vote(session, game.id, p1.id, p2.id)
assert ok 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): def test_rank_up_draws_immediately(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) 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) # p2 is rank 1 (lowest -> max 2), p3 is rank 2 (highest -> max 4)