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:
4
TODO.md
4
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.
|
- [ ] **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.
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user