diff --git a/AGENTS.md b/AGENTS.md index 21541d6..6305adc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,8 @@ When you run into a repeatable problem during testing (e.g. port assignment coll - 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`. +- Rollback tests must fetch Player/Obstacle/Challenge/Vote rows again by ID after `apply_game_state`; it deletes and recreates those rows, so old ORM instances cannot be refreshed. + ## 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 ebb4c61..13bc241 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,7 @@ - [x] Check against rules on correct quantity of of obstacles and obstacle refresh rules - [x] Check against the rules on pi-rat hand size and conditions for drawing more cards - [x] Assisting other pi-rats should only be available if there are multiple obstacles in the current challenge -- [ ] Checking off personal objectives (gat/name/death) should be handled by group vote rather than adjudication by the deep +- [x] Checking off personal objectives (gat/name/death) should be handled by group vote rather than adjudication by the deep - [ ] Double check if PvP obstacles should allow card re-draw ## Unknown - need to clarify what these mean diff --git a/frontend/src/components/ScenePhase.svelte b/frontend/src/components/ScenePhase.svelte index 6395bf6..5d8081a 100644 --- a/frontend/src/components/ScenePhase.svelte +++ b/frontend/src/components/ScenePhase.svelte @@ -1,5 +1,6 @@ + +{#if error}
{error}
{/if} +{#each proposals as proposal (proposal.id)} +
+

Objective Vote: {state.players.find(p => p.id === proposal.target_id)?.name} โ€” {labels[proposal.type]}

+

Majority decides. The Captain breaks ties; a tie without a Captain does not award the objective. Votes close when decided or the scene ends.

+

{Object.values(proposal.ballots).filter(Boolean).length} Yes ยท {Object.values(proposal.ballots).filter(v => !v).length} No ยท {state.players.filter(p => !p.needs_reroll && !(p.id in proposal.ballots)).length} waiting

+ {#if !state.player.needs_reroll} +
+ + +
+ {/if} +
+{/each} diff --git a/frontend/src/lib/changelog.js b/frontend/src/lib/changelog.js index b102391..d35abcb 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 = 34; +export const VERSION = 35; // Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }. export const CHANGELOG = [ + { version: 35, date: '2026-09-04', changes: ['Personal objectives now require a group vote. Propose the next objective from a character sheet, then vote Yes or No at the table. A majority approves; the Captain breaks tied votes.'] }, { version: 34, date: '2026-09-04', changes: ['Assistance is available only when a Challenge has multiple active Obstacles. Gat and Name Tax takeovers still work against a single Obstacle.'] }, { version: 33, date: '2026-09-04', changes: ['Hand sizes compare ranks across the crew, including Pi-Rats whose players are the Deep.', 'Discarded cards return to the deck at scene setup. Only the previous Deep can refresh their hand during upkeep, once per scene.'] }, { version: 32, date: '2026-09-04', changes: ['Obstacles are automatically discarded with their columns once they reach one success per player. Unfinished obstacles carry over between scenes.'] }, diff --git a/src/pirats/crud_scene.py b/src/pirats/crud_scene.py index 72cd963..b4fa4a0 100644 --- a/src/pirats/crud_scene.py +++ b/src/pirats/crud_scene.py @@ -421,3 +421,82 @@ def finish_game(db: Session, game_id: str): db.commit() add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. ๐ŸŽ‰", kind="victory") logger.info("Game %s ended after %s scene(s)", game_id, game.current_scene_number) + + +PERSONAL_OBJECTIVES = ("personal_1", "personal_2", "personal_3") + + +def _can_award_objective(target, obj_type): + return (target and target.role == "pirat" and not target.needs_reroll + and not target.is_dead and obj_type in PERSONAL_OBJECTIVES + and not getattr(target, "completed_" + obj_type) + and all(getattr(target, "completed_" + previous) + for previous in PERSONAL_OBJECTIVES[:PERSONAL_OBJECTIVES.index(obj_type)])) + + +def propose_personal_objective(db, game_id, proposer_id, target_id, obj_type): + import uuid + game = get_game(db, game_id) + proposer = get_player(db, proposer_id) + target = get_player(db, target_id) + if not game or game.phase != "scene": + return False, "Propose objectives during a scene." + if not proposer or proposer.game_id != game_id or proposer.needs_reroll: + return False, "Only this game's participating players may propose objectives." + if not target or target.game_id != game_id or not _can_award_objective(target, obj_type): + return False, "Propose the next unfinished objective for a living Pi-Rat in this scene." + proposals = json.loads(game.objective_votes) + if any(p["target_id"] == target_id for p in proposals): + return False, "This Pi-Rat already has an objective vote pending." + proposal = {"id": str(uuid.uuid4()), "target_id": target_id, "type": obj_type, "ballots": {}} + proposals.append(proposal) + game.objective_votes = json.dumps(proposals) + db.add(game) + db.commit() + add_game_event(db, game_id, f"{proposer.name} proposed {target.name}'s {obj_type.replace('_', ' ')} for a group vote.", kind="objective") + return vote_personal_objective(db, game_id, proposer_id, proposal["id"], True) + + +def vote_personal_objective(db, game_id, voter_id, proposal_id, approve): + game = get_game(db, game_id) + voter = get_player(db, voter_id) + if not game or game.phase != "scene": + return False, "Objective voting is closed." + if not voter or voter.game_id != game_id or voter.needs_reroll: + return False, "Only this game's participating players may vote." + proposals = json.loads(game.objective_votes) + proposal = next((p for p in proposals if p["id"] == proposal_id), None) + if not proposal: + return False, "That objective vote is no longer pending." + target = get_player(db, proposal["target_id"]) + if not _can_award_objective(target, proposal["type"]): + proposals.remove(proposal) + game.objective_votes = json.dumps(proposals) + db.add(game) + db.commit() + return False, "That objective is no longer eligible." + eligible = {p.id for p in game.players if not p.needs_reroll} + ballots = {pid: value for pid, value in proposal["ballots"].items() if pid in eligible} + ballots[voter_id] = approve + proposal["ballots"] = ballots + yes = sum(ballots.values()) + no = len(ballots) - yes + outcome = None + if yes > len(eligible) / 2: + outcome = True + elif no > len(eligible) / 2: + outcome = False + elif len(ballots) == len(eligible): + # The Captain breaks a tie; without a Captain, the objective is not awarded. + outcome = ballots.get(game.captain_player_id, False) + if outcome is not None: + proposals.remove(proposal) + game.objective_votes = json.dumps(proposals) + db.add(game) + db.commit() + if outcome is True: + toggle_objective(db, game_id, target.id, proposal["type"], True) + if outcome is not None: + result = "approved" if outcome else "did not approve" + add_game_event(db, game_id, f"The group {result} {target.name}'s {proposal['type'].replace('_', ' ')} ({yes} yes, {no} no).", kind="objective") + return True, "Vote recorded." diff --git a/src/pirats/crud_upkeep.py b/src/pirats/crud_upkeep.py index e744f4a..1f63fc5 100644 --- a/src/pirats/crud_upkeep.py +++ b/src/pirats/crud_upkeep.py @@ -78,6 +78,7 @@ def end_scene_and_transition(db: Session, game_id: str): f"(still waiting on: {names}). Clear the Obstacle List to end early." ) + game.objective_votes = "[]" game.phase = "between_scenes" # Clear ready flags for players diff --git a/src/pirats/migrations/versions/9c04e3bec3b5_add_personal_objective_group_votes.py b/src/pirats/migrations/versions/9c04e3bec3b5_add_personal_objective_group_votes.py new file mode 100644 index 0000000..439d87e --- /dev/null +++ b/src/pirats/migrations/versions/9c04e3bec3b5_add_personal_objective_group_votes.py @@ -0,0 +1,32 @@ +"""add personal objective group votes + +Revision ID: 9c04e3bec3b5 +Revises: 6ea41638edfc +Create Date: 2026-09-04 19:24:05.292837 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision = '9c04e3bec3b5' +down_revision = '6ea41638edfc' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('game', schema=None) as batch_op: + batch_op.add_column(sa.Column('objective_votes', sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default="[]")) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('game', schema=None) as batch_op: + batch_op.drop_column('objective_votes') + + # ### end Alembic commands ### diff --git a/src/pirats/models.py b/src/pirats/models.py index 08f6790..29f83f0 100644 --- a/src/pirats/models.py +++ b/src/pirats/models.py @@ -22,6 +22,7 @@ class Game(SQLModel, table=True): deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...] rollback_timeline_version: int = Field(default=0) # Bumped whenever a rollback truncates events; lets the frontend reset its accumulated log. NEVER snapshotted (see crud_rollback.SNAPSHOT_EXCLUDE) rollback_head_checkpoint_id: Optional[int] = Field(default=None) # When set, the game is viewing a rolled-back state at this checkpoint; events after it are the greyed, still-undoable future. None = live at the latest. NEVER snapshotted. + objective_votes: str = Field(default="[]") # Pending personal-objective proposals and ballots completed_crew_1: bool = Field(default=False) # Steal a Ship completed_crew_2: bool = Field(default=False) # Choose a Captain completed_crew_3: bool = Field(default=False) # Commit Piracy diff --git a/src/pirats/routes_scene.py b/src/pirats/routes_scene.py index 043e5ce..8bc7719 100644 --- a/src/pirats/routes_scene.py +++ b/src/pirats/routes_scene.py @@ -101,18 +101,8 @@ def toggle_objective_route( current_status = game.completed_crew_3 crud.toggle_objective(db, game_id, player_id, type, not current_status) else: - player = crud.get_player(db, player_id) - if not player: - raise HTTPException(status_code=404, detail="Player not found") - current_status = False - if type == "personal_1": - current_status = player.completed_personal_1 - elif type == "personal_2": - current_status = player.completed_personal_2 - elif type == "personal_3": - current_status = player.completed_personal_3 - crud.toggle_objective(db, game_id, player_id, type, not current_status) - + return JSONResponse({"error": "Personal objectives require a group vote."}, status_code=400) + return {"status": "ok"} # Pi-Rat sets a new name @@ -228,3 +218,21 @@ def set_captain_route( def finish_game_route(game_id: str, db: Session = Depends(get_session)): crud.finish_game(db, game_id) return {"status": "ok"} + + +@router.post("/game/{game_id}/player/{player_id}/objective/propose") +def propose_objective_route(game_id: str, player_id: str, target_id: str = Form(...), + type: str = Form(...), db: Session = Depends(get_session)): + ok, msg = crud.propose_personal_objective(db, game_id, player_id, target_id, type) + if not ok: + return JSONResponse({"error": msg}, status_code=400) + return {"status": "ok"} + + +@router.post("/game/{game_id}/player/{player_id}/objective/vote") +def vote_objective_route(game_id: str, player_id: str, proposal_id: str = Form(...), + approve: bool = Form(...), db: Session = Depends(get_session)): + ok, msg = crud.vote_personal_objective(db, game_id, player_id, proposal_id, approve) + if not ok: + return JSONResponse({"error": msg}, status_code=400) + return {"status": "ok"} diff --git a/tests/test_game.py b/tests/test_game.py index 86a44dc..be633be 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -2742,3 +2742,96 @@ def test_single_obstacle_rejects_assistance_but_allows_tax_actor(session): assert crud.request_tax(session, challenge.id, rat.id, helper.id)[0] assert crud.respond_tax(session, challenge.id, helper.id, True)[0] assert crud.play_challenge_card(session, helper.id, obs.id, "KH")[0] + + +def test_objective_vote_majority_awards_once_and_preserves_rollback(session): + game, deep, (rat, helper) = make_scene_game(session, num_pirats=2) + rank = rat.rank + assert not crud.propose_personal_objective(session, game.id, deep.id, rat.id, 'personal_2')[0] + assert crud.propose_personal_objective(session, game.id, deep.id, rat.id, 'personal_1')[0] + proposal = json.loads(game.objective_votes)[0] + assert not rat.completed_personal_1 + assert not crud.propose_personal_objective(session, game.id, helper.id, rat.id, 'personal_1')[0] + snapshot = crud.serialize_game_state(game) + assert crud.vote_personal_objective(session, game.id, helper.id, proposal['id'], True)[0] + assert rat.completed_personal_1 and rat.needs_gat_description + assert rat.rank == rank + 1 + assert json.loads(game.objective_votes) == [] + assert not crud.vote_personal_objective(session, game.id, helper.id, proposal['id'], True)[0] + assert rat.rank == rank + 1 + rat_id = rat.id + crud.apply_game_state(session, game, snapshot) + rat = session.get(Player, rat_id) + session.refresh(game) + assert not rat.completed_personal_1 + assert json.loads(game.objective_votes)[0]['id'] == proposal['id'] + + +@pytest.mark.parametrize('captain_vote, awarded', [(True, True), (False, False), (None, False)]) +def test_objective_vote_ties(session, captain_vote, awarded): + game, deep, (rat, helper, captain) = make_scene_game(session, num_pirats=3) + if captain_vote is not None: + game.captain_player_id = captain.id + session.commit() + assert crud.propose_personal_objective(session, game.id, deep.id, rat.id, 'personal_1')[0] + pid = json.loads(game.objective_votes)[0]['id'] + assert crud.vote_personal_objective(session, game.id, rat.id, pid, False)[0] + assert crud.vote_personal_objective(session, game.id, helper.id, pid, not bool(captain_vote))[0] + assert crud.vote_personal_objective(session, game.id, captain.id, pid, bool(captain_vote))[0] + assert rat.completed_personal_1 == awarded + assert json.loads(game.objective_votes) == [] + + +def test_objective_votes_reject_foreign_players_and_close_at_scene_end(session): + game, deep, (rat, helper) = make_scene_game(session, num_pirats=2) + other = crud.create_game(session) + foreign = crud.add_player(session, other.id, 'Outsider') + assert not crud.propose_personal_objective(session, game.id, foreign.id, rat.id, 'personal_1')[0] + assert crud.propose_personal_objective(session, game.id, deep.id, rat.id, 'personal_1')[0] + pid = json.loads(game.objective_votes)[0]['id'] + assert not crud.vote_personal_objective(session, game.id, foreign.id, pid, True)[0] + assert crud.end_scene_and_transition(session, game.id)[0] + assert json.loads(game.objective_votes) == [] + assert not crud.vote_personal_objective(session, game.id, rat.id, pid, True)[0] + + +def test_objective_vote_rejection_and_death_effects(session): + game, deep, (rat, helper) = make_scene_game(session, num_pirats=2) + assert crud.propose_personal_objective(session, game.id, deep.id, rat.id, 'personal_1')[0] + pid = json.loads(game.objective_votes)[0]['id'] + assert crud.vote_personal_objective(session, game.id, rat.id, pid, False)[0] + assert crud.vote_personal_objective(session, game.id, helper.id, pid, False)[0] + assert not rat.completed_personal_1 + rat.completed_personal_1 = rat.completed_personal_2 = True + game.captain_player_id = rat.id + session.commit() + assert crud.propose_personal_objective(session, game.id, helper.id, rat.id, 'personal_3')[0] + pid = json.loads(game.objective_votes)[0]['id'] + assert crud.vote_personal_objective(session, game.id, rat.id, pid, True)[0] + assert rat.is_dead and rat.needs_rank_3_bonus + assert game.captain_player_id is None + + +def test_personal_objective_api_requires_vote(session): + from fastapi.testclient import TestClient + from pirats.main import app + from pirats.database import get_session + game, deep, (rat, helper) = make_scene_game(session, num_pirats=2) + def override(): + yield session + app.dependency_overrides[get_session] = override + try: + client = TestClient(app) + path = f'/api/game/{game.id}/player' + response = client.post(f'{path}/{rat.id}/objective/toggle', data={'type': 'personal_1'}) + assert response.status_code == 400 + assert not rat.completed_personal_1 + response = client.post(f'{path}/{deep.id}/objective/propose', data={'target_id': rat.id, 'type': 'personal_1'}) + assert response.status_code == 200 + pid = json.loads(game.objective_votes)[0]['id'] + response = client.post(f'{path}/{helper.id}/objective/vote', data={'proposal_id': pid, 'approve': 'true'}) + assert response.status_code == 200 + session.refresh(rat) + assert rat.completed_personal_1 + finally: + app.dependency_overrides.clear()