Advance after final rank vote and allow ballot changes
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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,26 +208,30 @@
|
||||
<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>
|
||||
|
||||
@@ -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.'] },
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user