Award personal objectives through group votes
This commit is contained in:
@@ -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`.
|
- 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
|
## 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`).
|
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`).
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
- [x] Check against rules on correct quantity of of obstacles and obstacle refresh rules
|
- [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] 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
|
- [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
|
- [ ] Double check if PvP obstacles should allow card re-draw
|
||||||
|
|
||||||
## Unknown - need to clarify what these mean
|
## Unknown - need to clarify what these mean
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import Card from './Card.svelte';
|
import Card from './Card.svelte';
|
||||||
|
import ObjectiveVotes from './scene/ObjectiveVotes.svelte';
|
||||||
import ChallengePanel from './scene/ChallengePanel.svelte';
|
import ChallengePanel from './scene/ChallengePanel.svelte';
|
||||||
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
import ObstacleBoard from './scene/ObstacleBoard.svelte';
|
||||||
import CrewColumn from './scene/CrewColumn.svelte';
|
import CrewColumn from './scene/CrewColumn.svelte';
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
|
|
||||||
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
|
<!-- MIDDLE COLUMN: Your hand, the Challenge, the Obstacle list -->
|
||||||
<div class="table-column">
|
<div class="table-column">
|
||||||
|
<ObjectiveVotes {state} />
|
||||||
{#if !isDeep}
|
{#if !isDeep}
|
||||||
<div class="card glass-panel hand-card">
|
<div class="card glass-panel hand-card">
|
||||||
<span class="hand-label">🃏 Your Hand</span>
|
<span class="hand-label">🃏 Your Hand</span>
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
$: isSelf = target.id === viewer.id;
|
$: isSelf = target.id === viewer.id;
|
||||||
$: isDeep = viewer.role === 'deep';
|
$: isDeep = viewer.role === 'deep';
|
||||||
$: isCaptain = target.id === state.game.captain_player_id;
|
$: isCaptain = target.id === state.game.captain_player_id;
|
||||||
// The Deep manages captaincy and ticks personal objectives from a Pi-Rat's sheet
|
// Captaincy is managed by the Deep; objectives go to a group vote.
|
||||||
// (both moved here from the old Deep Control Panel).
|
|
||||||
$: canManageCaptain = isDeep && target.role === 'pirat' && !target.is_dead;
|
$: canManageCaptain = isDeep && target.role === 'pirat' && !target.is_dead;
|
||||||
$: canManageObjectives = isDeep && target.role === 'pirat';
|
$: canManageObjectives = !viewer.needs_reroll && target.role === 'pirat' && !target.is_dead && !target.needs_reroll;
|
||||||
|
$: pendingObjective = JSON.parse(state.game.objective_votes || '[]').some(p => p.target_id === target.id);
|
||||||
// The viewer throws one of their OWN cards in a duel.
|
// The viewer throws one of their OWN cards in a duel.
|
||||||
$: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : [];
|
$: hand = viewer.hand_cards ? JSON.parse(viewer.hand_cards) : [];
|
||||||
$: canDuel = !isSelf
|
$: canDuel = !isSelf
|
||||||
@@ -59,10 +59,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleObjective(type) {
|
async function proposeObjective(type) {
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/game/${state.game.id}/player/${target.id}/objective/toggle`, 'POST', { type });
|
await apiRequest(`/game/${state.game.id}/player/${viewer.id}/objective/propose`, 'POST', { type, target_id: target.id });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
}
|
}
|
||||||
@@ -190,21 +190,21 @@
|
|||||||
<div class="sheet-group">
|
<div class="sheet-group">
|
||||||
<h4>Personal Objectives</h4>
|
<h4>Personal Objectives</h4>
|
||||||
{#if canManageObjectives}
|
{#if canManageObjectives}
|
||||||
<p class="info-text" style="font-size: 0.8rem;">As the Deep, tick these off in sequence (1 → 2 → 3).</p>
|
<p class="info-text" style="font-size: 0.8rem;">Propose the next objective for a group vote. Your proposal counts as a Yes vote.</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="objectives-checklist">
|
<div class="objectives-checklist">
|
||||||
<label class="checkbox-label">
|
{#each ['Gat', 'Name', 'Die/Retire'] as label, i}
|
||||||
<input type="checkbox" checked={target.completed_personal_1} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_1')}>
|
{@const type = `personal_${i + 1}`}
|
||||||
<span>1. Gat</span>
|
{@const completed = target[`completed_${type}`]}
|
||||||
</label>
|
<div>
|
||||||
<label class="checkbox-label">
|
<span>{completed ? '✓' : '○'} {i + 1}. {label}</span>
|
||||||
<input type="checkbox" checked={target.completed_personal_2} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_2')}>
|
{#if canManageObjectives && !completed && (i === 0 || target[`completed_personal_${i}`])}
|
||||||
<span>2. Name</span>
|
<button class="btn btn-secondary" disabled={pendingObjective} on:click={() => proposeObjective(type)}>
|
||||||
</label>
|
{pendingObjective ? 'Vote pending' : 'Propose'}
|
||||||
<label class="checkbox-label">
|
</button>
|
||||||
<input type="checkbox" checked={target.completed_personal_3} disabled={!canManageObjectives} on:change={() => toggleObjective('personal_3')}>
|
{/if}
|
||||||
<span>3. Die/Retire</span>
|
</div>
|
||||||
</label>
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div> <!-- end sheet-side-column -->
|
</div> <!-- end sheet-side-column -->
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script>
|
||||||
|
import { apiRequest } from '../../lib/api';
|
||||||
|
export let state;
|
||||||
|
let error = '';
|
||||||
|
$: proposals = JSON.parse(state.game.objective_votes || '[]');
|
||||||
|
const labels = { personal_1: 'Gat', personal_2: 'Name', personal_3: 'Die/Retire' };
|
||||||
|
async function vote(proposal_id, approve) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/objective/vote`, 'POST', { proposal_id, approve });
|
||||||
|
} catch (e) { error = e.message; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}<div class="alert alert-danger">{error}</div>{/if}
|
||||||
|
{#each proposals as proposal (proposal.id)}
|
||||||
|
<div class="card glass-panel">
|
||||||
|
<h3>Objective Vote: {state.players.find(p => p.id === proposal.target_id)?.name} — {labels[proposal.type]}</h3>
|
||||||
|
<p class="info-text">Majority decides. The Captain breaks ties; a tie without a Captain does not award the objective. Votes close when decided or the scene ends.</p>
|
||||||
|
<p>{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</p>
|
||||||
|
{#if !state.player.needs_reroll}
|
||||||
|
<div class="challenge-actions">
|
||||||
|
<button class="btn btn-primary" aria-pressed={proposal.ballots[state.player.id] === true} disabled={proposal.ballots[state.player.id] === true} on:click={() => vote(proposal.id, true)}>Yes{proposal.ballots[state.player.id] === true ? ' ✓' : ''}</button>
|
||||||
|
<button class="btn btn-secondary" aria-pressed={proposal.ballots[state.player.id] === false} disabled={proposal.ballots[state.player.id] === false} on:click={() => vote(proposal.id, false)}>No{proposal.ballots[state.player.id] === false ? ' ✓' : ''}</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
@@ -6,10 +6,11 @@
|
|||||||
// - Add a CHANGELOG entry only when a commit changes something players can
|
// - Add a CHANGELOG entry only when a commit changes something players can
|
||||||
// see. Skip refactors, tests, and tooling. Keep wording player-facing.
|
// 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, ...] }.
|
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
|
||||||
export const CHANGELOG = [
|
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: 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: 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.'] },
|
{ 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.'] },
|
||||||
|
|||||||
@@ -421,3 +421,82 @@ def finish_game(db: Session, game_id: str):
|
|||||||
db.commit()
|
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")
|
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)
|
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."
|
||||||
|
|||||||
@@ -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."
|
f"(still waiting on: {names}). Clear the Obstacle List to end early."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
game.objective_votes = "[]"
|
||||||
game.phase = "between_scenes"
|
game.phase = "between_scenes"
|
||||||
|
|
||||||
# Clear ready flags for players
|
# Clear ready flags for players
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -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", ...]
|
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_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.
|
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_1: bool = Field(default=False) # Steal a Ship
|
||||||
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
||||||
completed_crew_3: bool = Field(default=False) # Commit Piracy
|
completed_crew_3: bool = Field(default=False) # Commit Piracy
|
||||||
|
|||||||
+20
-12
@@ -101,18 +101,8 @@ def toggle_objective_route(
|
|||||||
current_status = game.completed_crew_3
|
current_status = game.completed_crew_3
|
||||||
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
crud.toggle_objective(db, game_id, player_id, type, not current_status)
|
||||||
else:
|
else:
|
||||||
player = crud.get_player(db, player_id)
|
return JSONResponse({"error": "Personal objectives require a group vote."}, status_code=400)
|
||||||
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 {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Pi-Rat sets a new name
|
# 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)):
|
def finish_game_route(game_id: str, db: Session = Depends(get_session)):
|
||||||
crud.finish_game(db, game_id)
|
crud.finish_game(db, game_id)
|
||||||
return {"status": "ok"}
|
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"}
|
||||||
|
|||||||
@@ -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.request_tax(session, challenge.id, rat.id, helper.id)[0]
|
||||||
assert crud.respond_tax(session, challenge.id, helper.id, True)[0]
|
assert crud.respond_tax(session, challenge.id, helper.id, True)[0]
|
||||||
assert crud.play_challenge_card(session, helper.id, obs.id, "KH")[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()
|
||||||
|
|||||||
Reference in New Issue
Block a user