Fable will fix it!

This commit is contained in:
2026-06-11 15:57:08 -07:00
parent 2af98a3eeb
commit 443acd8400
23 changed files with 2151 additions and 557 deletions

View File

@@ -1,6 +1,7 @@
<script>
import { onMount } from 'svelte';
import { apiRequest } from '../lib/api';
import { getSuggestion } from '../lib/suggestions';
import { getSuggestion, getSuggestions } from '../lib/suggestions';
export let state;
@@ -13,7 +14,6 @@
};
let savingBasic = false;
let delegating = false;
async function saveBasicDetails() {
savingBasic = true;
@@ -26,16 +26,17 @@
}
}
async function autoDelegate() {
delegating = true;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/delegate/auto`, 'POST');
} catch(e) {
console.error(e);
} finally {
delegating = false;
// Like/Hate tasks are assigned automatically when character creation starts.
// This is a fallback for games that entered the phase before assignments existed.
onMount(async () => {
if (!delegateComplete && state.players.length >= 2) {
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/delegate/auto`, 'POST');
} catch(e) {
console.error(e);
}
}
}
});
// A helper to get player name
function getPlayerName(id) {
@@ -47,15 +48,17 @@
// Techniques
let tech1 = '', tech2 = '', tech3 = '';
let savingTechs = false;
let techError = '';
async function submitTechniques() {
savingTechs = true;
techError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
tech1, tech2, tech3
});
} catch(e) {
console.error(e);
techError = e.message;
} finally {
savingTechs = false;
}
@@ -82,11 +85,16 @@
}
}
// Submit delegated answers
let answerLike = '', answerHate = '';
async function submitDelegatedAnswer(type, targetId, answer) {
// Submit delegated answers — one draft per (player, question) so editing one
// task never bleeds into another.
let inboxAnswers = {};
async function submitDelegatedAnswer(type, targetId) {
const answer = (inboxAnswers[`${targetId}:${type}`] || '').trim();
if (!answer) return;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-delegate/${targetId}/${type}`, 'POST', { answer });
delete inboxAnswers[`${targetId}:${type}`];
inboxAnswers = inboxAnswers;
} catch(e) {
console.error(e);
}
@@ -107,42 +115,40 @@
async function autoFillAll() {
autoFilling = true;
try {
// 1. Basic details
// 1. Basic details — drawn from the same pools as the Suggest buttons
if (!basicComplete) {
basicDetails = {
avatar_look: 'A scruffy rat',
avatar_smell: 'Like old cheese',
first_words: 'Give me the loot!',
good_at_math: 'No'
avatar_look: getSuggestion('look'),
avatar_smell: getSuggestion('smell'),
first_words: getSuggestion('first_words'),
good_at_math: getSuggestion('good_at_math')
};
await saveBasicDetails();
}
// 2. Delegate
if (!delegateComplete && state.players.length >= 2) {
await autoDelegate();
}
// 3. Answer questions (might need a quick refresh to get the questions, but we can do our best with current state)
// Wait, state needs to be re-fetched to get the assigned questions. We can just fill what's currently in inbox.
// 2. Answer pending inbox questions with pool suggestions
for (const p of state.players) {
if (p.other_like_from_player_id === state.player.id && !p.other_like) {
await submitDelegatedAnswer('like', p.id, 'They are sneaky');
inboxAnswers[`${p.id}:like`] = getSuggestion('like');
await submitDelegatedAnswer('like', p.id);
}
if (p.other_hate_from_player_id === state.player.id && !p.other_hate) {
await submitDelegatedAnswer('hate', p.id, 'They snore loud');
inboxAnswers[`${p.id}:hate`] = getSuggestion('hate');
await submitDelegatedAnswer('hate', p.id);
}
}
// 4. Techniques
// 3. Techniques — 3 distinct pool picks; retry if another player
// grabbed the same name first (the backend rejects collisions)
if (createdTechniques.length === 0) {
tech1 = 'Pocket Sand';
tech2 = 'Tail Whip';
tech3 = 'Cheese Decoy';
await submitTechniques();
for (let attempt = 0; attempt < 5; attempt++) {
[tech1, tech2, tech3] = getSuggestions('techniques', 3);
await submitTechniques();
if (!techError) break;
}
}
// 5. Face Techniques - we need the swapped ones. We can't do this synchronously without waiting for the server
// 4. Face Techniques - we need the swapped ones. We can't do this synchronously without waiting for the server
// to process the state, because swapped_techniques comes from the server once everyone is done.
if (!techniquesComplete && swappedTechniques.length === 3) {
jackTech = swappedTechniques[0];
@@ -235,15 +241,8 @@
<!-- SECTION 2: Delegated Questions (Like/Hate) -->
<div class="card glass-panel section-delegations">
<div class="card-header-flex" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem; border-bottom:1px solid rgba(212,175,55,0.2); padding-bottom:0.5rem;">
<h3 style="border:none; margin:0; padding:0; color:var(--gold); font-family:var(--font-heading);">II. Crew Delegations {delegateComplete ? '✅' : '❌'}</h3>
{#if !delegateComplete}
<button on:click={autoDelegate} disabled={delegating || state.players.length < 2} class="btn btn-secondary btn-small">
🎲 Auto-Assign Tasks
</button>
{/if}
</div>
<p class="section-desc">You must let other players decide what they like and hate about your character.</p>
<h3>II. Crew Delegations {delegateComplete ? '✅' : '❌'}</h3>
<p class="section-desc">Other players decide what their rats like and hate about your character. Crewmates have been randomly assigned to answer for you.</p>
<div class="delegation-list">
{#if delegateComplete}
@@ -262,7 +261,7 @@
<div class="author-label"> {getPlayerName(state.player.other_hate_from_player_id)}</div>
</div>
{:else}
<p class="mb-4 text-sm text-gray-400">Randomly assign crewmates to write your relationships by clicking "Auto-Assign Tasks".</p>
<p class="mb-4 text-sm text-gray-400">Assigning crewmates to write your relationships...</p>
{/if}
</div>
</div>
@@ -277,8 +276,9 @@
<div class="inbox-item">
<label>What do you LIKE about {p.name}?</label>
<div class="input-row">
<input type="text" class="input-field" bind:value={answerLike} placeholder="e.g. They always share their cheese"/>
<button class="btn btn-primary" on:click={() => submitDelegatedAnswer('like', p.id, answerLike)}>Submit</button>
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button>
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', p.id)}>Submit</button>
</div>
</div>
{/if}
@@ -286,8 +286,9 @@
<div class="inbox-item">
<label>What do you HATE about {p.name}?</label>
<div class="input-row">
<input type="text" class="input-field" bind:value={answerHate} placeholder="e.g. They snore too loudly"/>
<button class="btn btn-primary" on:click={() => submitDelegatedAnswer('hate', p.id, answerHate)}>Submit</button>
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button>
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', p.id)}>Submit</button>
</div>
</div>
{/if}
@@ -304,26 +305,29 @@
{#if createdTechniques.length === 0}
<p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p>
{#if techError}
<div class="alert alert-danger">{techError}</div>
{/if}
<form on:submit|preventDefault={submitTechniques} id="tech-form">
<div class="form-group">
<label for="tech1">Technique #1:</label>
<div class="input-row">
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech1 = getSuggestion('techniques')}>Suggest</button>
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech1 = getSuggestion('techniques', [tech2, tech3])}>Suggest</button>
</div>
</div>
<div class="form-group">
<label for="tech2">Technique #2:</label>
<div class="input-row">
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech2 = getSuggestion('techniques')}>Suggest</button>
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech2 = getSuggestion('techniques', [tech1, tech3])}>Suggest</button>
</div>
</div>
<div class="form-group">
<label for="tech3">Technique #3:</label>
<div class="input-row">
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech3 = getSuggestion('techniques')}>Suggest</button>
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech3 = getSuggestion('techniques', [tech1, tech2])}>Suggest</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>

View File

@@ -0,0 +1,50 @@
<script>
export let state;
$: pirats = state.players;
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
</script>
<div class="dashboard-container" style="max-width: 800px; margin: 0 auto; padding: 2rem;">
<div class="card glass-panel" style="text-align: center; padding: 2rem;">
<h1 style="font-family: var(--font-heading); color: var(--gold);">🎉 The Story Has Ended!</h1>
<p class="section-desc" style="font-size: 1.1rem;">
The Pi-Rats of {state.game.crew_name || 'the crew'} party til they pass out in a pile.
Tell one last tale of how your Pi-Rat is remembered!
</p>
<div class="sheet-group" style="text-align: left; margin-top: 2rem;">
<h3 style="color: var(--gold);">Crew Objectives</h3>
<div class="objectives-checklist">
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_1} disabled> <span>Steal a Ship</span></label>
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_2} disabled> <span>Choose a Captain</span></label>
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_3} disabled> <span>Commit Piracy</span></label>
</div>
{#if crewDone}
<p class="success-text">A complete pirate legend! ⭐</p>
{:else}
<p class="info-text">Some deeds were left undone... a tale for the next crew.</p>
{/if}
</div>
<div class="sheet-group" style="text-align: left; margin-top: 1.5rem;">
<h3 style="color: var(--gold);">The Crew</h3>
{#each pirats as p}
<div style="background: rgba(0,0,0,0.2); padding: 0.75rem; border-radius: 6px; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<span>
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if}
<strong>{p.name}</strong> (Rank {p.rank})
{#if p.id === state.game.captain_player_id}<span style="color: var(--gold);"> ⭐ Captain</span>{/if}
</span>
<span style="font-size: 0.85rem; color: var(--text-muted);">
{p.completed_personal_1 ? '🔫 Gat' : '— no gat'} ·
{p.completed_personal_2 ? '📛 Named' : '— nameless'} ·
{p.completed_personal_3 ? '⚰️ Story complete' : '⛵ Still sailing'}
</span>
</div>
{/each}
</div>
<p class="info-text" style="margin-top: 2rem;">Want more? Get the full game "The Magical Land of Yeld" at YeldStuff.com!</p>
</div>
</div>

View File

@@ -8,18 +8,32 @@
// Helpers
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: isCaptain = state.players.reduce((max, p) => p.rank > max.rank ? p : max, state.players[0])?.id === state.player.id;
$: captain = state.players.find(p => p.id === state.game.captain_player_id) || null;
$: isCaptain = state.game.captain_player_id === state.player.id;
$: challenges = state.challenges || [];
$: openChallenges = challenges.filter(c => c.status === 'open');
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
$: myOpenChallenge = openChallenges.find(c => c.acting_player_id === state.player.id) || null;
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
$: scenePirats = state.players.filter(p => p.role === 'pirat');
function playerName(id) {
return state.players.find(p => p.id === id)?.name || '???';
}
function isJoker(code) {
return !!code && code.startsWith('Joker');
}
async function playCard(obstacleId, cardCode) {
playingCard = true;
error = '';
try {
const res = await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
obstacle_id: obstacleId,
card_code: cardCode
});
// Removed alert
} catch(e) {
error = e.message;
} finally {
@@ -68,6 +82,125 @@
}
}
// --- Challenge controls (Deep) ---
let challengeTargetId = '';
let challengeStakes = '';
let selectedObstacles = {};
async function createChallenge() {
error = '';
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/create`, 'POST', {
target_player_id: challengeTargetId,
obstacle_ids: JSON.stringify(ids),
stakes: challengeStakes
});
challengeTargetId = '';
challengeStakes = '';
selectedObstacles = {};
} catch(e) {
error = e.message;
}
}
async function resolveChallenge(challengeId) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/resolve`, 'POST');
} catch(e) {
error = e.message;
}
}
async function cancelChallenge(challengeId) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/cancel`, 'POST');
} catch(e) {
error = e.message;
}
}
// --- Captain controls (Deep / current Captain) ---
let captainSelectId = '';
async function setCaptain() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-captain`, 'POST', {
target_player_id: captainSelectId
});
captainSelectId = '';
} catch(e) {
error = e.message;
}
}
// --- Gat/Name Tax ---
let taxTargetId = '';
function taxType(player) {
if (!player.completed_personal_1) return 'Gat';
if (!player.completed_personal_2) return 'Name';
return null;
}
function eligibleTaxTargets(challenge) {
const me = state.player;
const type = taxType(me);
if (!type) return [];
return scenePirats.filter(p =>
p.id !== me.id && !p.is_dead &&
(type === 'Gat' ? p.completed_personal_1 : p.completed_personal_2)
);
}
async function requestTax(challengeId) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/request`, 'POST', {
target_player_id: taxTargetId
});
taxTargetId = '';
} catch(e) {
error = e.message;
}
}
async function respondTax(challengeId, accept) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/respond`, 'POST', {
accept: accept ? 'true' : 'false'
});
} catch(e) {
error = e.message;
}
}
// --- Pi-Rat vs Pi-Rat duels ---
let pvpOpponentId = '';
let pvpCard = '';
async function createPvp() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', {
defender_id: pvpOpponentId,
card_code: pvpCard
});
pvpOpponentId = '';
pvpCard = '';
} catch(e) {
error = e.message;
}
}
async function pvpDefend(challengeId, cardCode) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/pvp/defend`, 'POST', {
card_code: cardCode
});
} catch(e) {
error = e.message;
}
}
let showEventLog = false;
let newNameInput = '';
@@ -85,18 +218,9 @@
}
// Parsing card helpers
function getCardValue(code) {
if (!code) return 0;
if (code === 'JOKER') return 0;
let rank = code.slice(0, -1);
if (['J', 'Q', 'K'].includes(rank)) return 10;
if (rank === 'A') return 1;
return parseInt(rank);
}
function getCardDisplay(code) {
if (!code) return "";
if (code === 'JOKER') return "🃏 JOKER";
if (isJoker(code)) return "🃏 JOKER";
let rank = code.slice(0, -1);
let suit = code.slice(-1);
let suitEmoji = { 'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️' }[suit] || suit;
@@ -121,13 +245,13 @@
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
<!-- Captain Badge -->
<div>
{#if isCaptain}
{#if captain}
<span class="captain-badge" style="background: rgba(212, 175, 55, 0.15); border: 1px solid var(--gold); color: var(--gold); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; font-weight: 700; font-family: var(--font-heading); display: inline-flex; align-items: center; gap: 0.5rem;">
🏴‍☠️ Captain: {state.players.find(p => p.id === state.player.id)?.name} (Rank {state.players.find(p => p.id === state.player.id)?.rank})
🏴‍☠️ Captain: {captain.name} (Rank {captain.rank}){isCaptain ? ' — You!' : ''}
</span>
{:else}
<span class="captain-badge" style="background: rgba(255, 255, 255, 0.05); border: 1px dashed rgba(255, 255, 255, 0.2); color: var(--text-muted); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; display: inline-flex; align-items: center; gap: 0.5rem;">
🏴‍☠️ Captain: {state.players.reduce((max, p) => p.rank > max.rank ? p : max, state.players[0])?.name || 'None'}
🏴‍☠️ Captain: None{state.game.completed_crew_1 ? '' : ' (steal a ship first!)'}
</span>
{/if}
</div>
@@ -141,8 +265,8 @@
🌊 {p.name} (Deep)
</span>
{:else if p.role === 'pirat'}
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {p.is_ghost ? 'border-color: #7890a8; color: #a0b0c0; background: rgba(120, 150, 180, 0.1);' : ''} {p.is_dead ? 'border-color: var(--red-suit); color: var(--red-suit); background: rgba(255, 42, 95, 0.1);' : ''} {p.id === state.players.reduce((max, p2) => p2.rank > max.rank ? p2 : max, state.players[0])?.id && !p.is_ghost && !p.is_dead ? 'border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);' : ''}">
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {p.name} (Rank {p.rank}){p.id === state.players.reduce((max, p2) => p2.rank > max.rank ? p2 : max, state.players[0])?.id && !p.is_ghost && !p.is_dead ? ' ⭐' : ''}
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {p.is_ghost ? 'border-color: #7890a8; color: #a0b0c0; background: rgba(120, 150, 180, 0.1);' : ''} {p.is_dead ? 'border-color: var(--red-suit); color: var(--red-suit); background: rgba(255, 42, 95, 0.1);' : ''} {p.id === state.game.captain_player_id ? 'border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);' : ''}">
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {p.name} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''}
</span>
{/if}
{/each}
@@ -154,12 +278,98 @@
<div class="alert alert-danger">{error}</div>
{/if}
<!-- OPEN CHALLENGES -->
{#each openChallenges as ch}
{@const chPlays = JSON.parse(ch.plays || '[]')}
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
<div class="glass-panel" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red-suit, #ff2a5f); border-radius: 8px; background: rgba(255, 42, 95, 0.06);">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h4 style="margin: 0; font-family: var(--font-heading);">
{#if ch.challenge_type === 'pvp'}
⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}
{:else}
⚔️ The Deep challenges {playerName(ch.target_player_id)}!
{/if}
</h4>
{#if state.player.role === 'deep' && ch.challenge_type === 'deep'}
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" on:click={() => resolveChallenge(ch.id)} disabled={ch.tax_state === 'requested'}>Resolve</button>
<button class="btn btn-secondary" on:click={() => cancelChallenge(ch.id)}>Withdraw</button>
</div>
{/if}
</div>
{#if ch.stakes}
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
{/if}
{#if ch.challenge_type === 'pvp'}
<p class="info-text" style="margin: 0.5rem 0 0 0;">
Temporary Obstacle: <strong>{getCardDisplay(ch.temp_card)}</strong>{playerName(ch.acting_player_id)} must answer it!
</p>
{:else}
<p class="info-text" style="margin: 0.5rem 0 0 0;">
Applied Obstacles: {chObstacleIds.map(oid => state.obstacles.find(o => o.id === oid)?.title || '(discarded)').join(', ') || 'None left'}
— attempted by <strong>{playerName(ch.acting_player_id)}</strong>{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}.
Other Pi-Rats may assist (assistants don't draw back).
</p>
{/if}
{#if chPlays.length > 0}
<p class="info-text" style="margin: 0.25rem 0 0 0; font-size: 0.85rem;">
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
</p>
{/if}
<!-- Tax: pending request aimed at me -->
{#if ch.tax_state === 'requested'}
{#if myTaxRequest && myTaxRequest.id === ch.id}
<div style="margin-top: 0.75rem; padding: 0.75rem; border: 1px dashed var(--gold); border-radius: 6px;">
<p style="margin: 0 0 0.5rem 0;"><strong>{playerName(ch.target_player_id)}</strong> calls a <strong>{ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax</strong> on you: take this Challenge for them!</p>
<p class="info-text" style="font-size: 0.85rem;">Accept: you get one random card from their hand and attempt the Challenge. Refuse: you hand over your {ch.tax_type === 'gat' ? 'Gat' : 'Name'} — they keep it if they succeed!</p>
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary" on:click={() => respondTax(ch.id, true)}>Accept</button>
<button class="btn btn-danger" on:click={() => respondTax(ch.id, false)}>Refuse</button>
</div>
</div>
{:else}
<p class="info-text gold-text" style="margin-top: 0.5rem;">⏳ Waiting for {playerName(ch.tax_target_id)} to answer the {ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax...</p>
{/if}
{/if}
<!-- Tax: option for the challenged player -->
{#if ch.challenge_type === 'deep' && ch.acting_player_id === state.player.id && !ch.tax_state && !state.player.tax_banned && taxType(state.player) && eligibleTaxTargets(ch).length > 0}
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
<span class="info-text" style="font-size: 0.9rem;">No {taxType(state.player)}? Make it someone else's problem:</span>
<select class="select-field" bind:value={taxTargetId} style="max-width: 200px;">
<option value="">Pick a crewmate...</option>
{#each eligibleTaxTargets(ch) as p}
<option value={p.id}>{p.name}</option>
{/each}
</select>
<button class="btn btn-secondary" on:click={() => requestTax(ch.id)} disabled={!taxTargetId}>Call {taxType(state.player)} Tax</button>
</div>
{/if}
<!-- PvP: defender picks a card -->
{#if myPvpDefense && myPvpDefense.id === ch.id}
<div style="margin-top: 0.75rem;">
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
{#each hand.filter(c => !isJoker(c)) as card}
<button class="btn btn-secondary" on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
{/each}
</div>
</div>
{/if}
</div>
{/each}
{#each state.obstacles as obs}
{@const column_cards = JSON.parse(obs.played_cards || '[]')}
{@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card}
{@const success_count = column_cards.filter(c => c.success).length}
{@const is_completed = success_count >= state.players.filter(p => p.role !== 'deep').length}
{@const is_completed = success_count >= state.players.length}
{@const in_challenge = challengedObstacleIds.has(obs.id)}
<div class="obstacle-item suit-{obs.suit.toLowerCase()} {is_completed ? 'completed' : ''}"
style={in_challenge ? 'box-shadow: 0 0 0 2px var(--red-suit, #ff2a5f);' : ''}
data-obstacle-id={obs.id}
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
@@ -170,33 +380,29 @@
e.currentTarget.classList.remove("drag-over");
const cardCode = e.dataTransfer.getData('text/plain');
if (cardCode) {
if (cardCode === 'JOKER') playJoker(obs.id, cardCode);
if (isJoker(cardCode)) playJoker(obs.id, cardCode);
else playCard(obs.id, cardCode);
}
}}>
<div class="obstacle-card-wrapper" style="display: flex; justify-content: center; align-items: center;">
<div class="card-medium suit-{active_card_code.slice(-1).toLowerCase()} {active_card_code === 'JOKER' ? 'joker-card' : ''}" title="Active Card: {getCardDisplay(active_card_code)}">
<div class="card-medium suit-{active_card_code.slice(-1).toLowerCase()}" title="Active Card: {getCardDisplay(active_card_code)}">
<div class="card-corner top-left">
<span class="val">{active_card_code === 'JOKER' ? '🃏' : active_card_code.slice(0, -1)}</span>
<span class="suit">{active_card_code === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
<span class="val">{active_card_code.slice(0, -1)}</span>
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
</div>
<div class="card-center">
{#if active_card_code === 'JOKER'}
🃏
{:else}
<span class="giant-symbol" style="font-size: 1.5rem;">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
{/if}
<span class="giant-symbol" style="font-size: 1.5rem;">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
</div>
<div class="card-corner bottom-right">
<span class="val">{active_card_code === 'JOKER' ? '🃏' : active_card_code.slice(0, -1)}</span>
<span class="suit">{active_card_code === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
<span class="val">{active_card_code.slice(0, -1)}</span>
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
</div>
</div>
</div>
<div class="obstacle-details">
<h4>{obs.title}</h4>
<h4>{obs.title} {#if in_challenge}<span style="color: var(--red-suit, #ff2a5f); font-size: 0.8rem;">⚔️ In Challenge</span>{/if}</h4>
<p class="desc">{obs.description}</p>
</div>
@@ -204,8 +410,8 @@
<div class="obstacle-value-display text-center">
<span class="val-label">Current Difficulty</span>
<span class="val-number">
{#if ['J', 'Q', 'K'].includes(obs.original_card.slice(0, -1))}
Rank ({state.player.rank})
{#if ['J', 'Q', 'K'].includes((column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card).slice(0, -1))}
Challenged Rat's Rank
{:else}
{obs.current_value}
{/if}
@@ -216,7 +422,7 @@
<div class="obstacle-successes-display text-center">
<span class="val-label">Successes</span>
<span class="val-number" style="font-size: 1.5rem;">
{success_count} / {state.players.filter(p => p.role !== 'deep').length}
{success_count} / {state.players.length}
</span>
</div>
@@ -231,8 +437,8 @@
{#each column_cards as pc}
<div class="card-mini played-card rotated {pc.success ? 'success' : 'failure'}"
title="{pc.player_name} played {getCardDisplay(pc.card)}">
<span class="val">{pc.card === 'JOKER' ? '🃏' : pc.card.slice(0, -1)}</span>
<span class="suit">{pc.card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]}</span>
<span class="val">{pc.card.slice(0, -1)}</span>
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]}</span>
<span class="owner">{pc.player_name.slice(0,4)}</span>
</div>
{/each}
@@ -249,19 +455,58 @@
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
{/each}
<!-- Event Log Removed from inline layout -->
</div>
</div>
</div>
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
<div class="private-column">
<!-- DEEP CONTROLS: End Scene & Objectives -->
<!-- DEEP CONTROLS: Challenges, End Scene & Objectives -->
{#if state.player.role === "deep"}
<div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;">
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
<!-- Call a Challenge -->
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">⚔️ Call a Challenge</h4>
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Challenge which Pi-Rat?</option>
{#each scenePirats.filter(p => !p.is_dead) as p}
<option value={p.id}>{p.name} (Rank {p.rank})</option>
{/each}
</select>
<div style="margin-bottom: 0.5rem;">
{#each state.obstacles as obs}
{@const taken = challengedObstacleIds.has(obs.id)}
<label class="checkbox-label" style="{taken ? 'opacity: 0.5;' : ''}">
<input type="checkbox" bind:checked={selectedObstacles[obs.id]} disabled={taken}>
<span>{obs.title}{taken ? ' (in a challenge)' : ''}</span>
</label>
{/each}
</div>
<input type="text" class="form-control" placeholder="Stakes (optional): what happens on success/failure?" bind:value={challengeStakes} style="width: 100%; margin-bottom: 0.5rem;">
<button class="btn btn-primary btn-full" on:click={createChallenge}
disabled={!challengeTargetId || !Object.values(selectedObstacles).some(v => v)}>
Call Challenge
</button>
</div>
<!-- Captain Assignment -->
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">🏴‍☠️ Captaincy</h4>
<p class="info-text" style="font-size: 0.85rem;">The Captain holds power until they step down, lose a duel, die, or the ship is lost. Reassign after a leadership duel or resignation.</p>
<div style="display: flex; gap: 0.5rem;">
<select class="select-field" bind:value={captainSelectId} style="flex: 1;">
<option value="">No Captain</option>
{#each scenePirats.filter(p => !p.is_dead) as p}
<option value={p.id}>{p.name}</option>
{/each}
</select>
<button class="btn btn-secondary" on:click={setCaptain}>Set</button>
</div>
</div>
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">Crew Objectives</h4>
<div class="objectives-checklist">
@@ -297,7 +542,7 @@
<!-- End Scene Button -->
<div class="scene-upkeep-controls text-center" style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
<p class="info-text">Conclude the scene once players have made attempts or the obstacle list is depleted.</p>
<p class="info-text">End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.</p>
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
End Scene & Proceed to Ranking
</button>
@@ -308,11 +553,11 @@
<!-- Player Hand Card -->
<div class="card glass-panel hand-card">
<h3>🃏 Your Hand</h3>
<p class="section-desc">Keep your cards secret! {#if state.player.role !== "deep"}Drag a card onto an active obstacle to play it.{/if}</p>
<p class="section-desc">Keep your cards secret! {#if state.player.role !== "deep"}When the Deep challenges you (or you assist a crewmate), drag a card onto an applied obstacle to play it. Jokers can hit any obstacle, any time.{/if}</p>
<div class="hand-flex">
{#each hand as card}
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card === 'JOKER' ? 'joker-card' : ''}"
<div class="card-large suit-{isJoker(card) ? 'joker' : card.slice(-1).toLowerCase()} {isJoker(card) ? 'joker-card' : ''}"
draggable={state.player.role !== "deep"}
on:dragstart={(e) => {
e.dataTransfer.setData('text/plain', card);
@@ -322,12 +567,12 @@
e.currentTarget.classList.remove("dragging");
}}>
<div class="card-corner top-left">
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
<div class="card-center">
{#if card === 'JOKER'}
{#if isJoker(card)}
🃏
{:else}
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
@@ -335,18 +580,18 @@
</div>
<div class="card-tech-overlay">
{#if card.slice(0, -1) === "J"}
{#if !isJoker(card) && card.slice(0, -1) === "J"}
<div class="tech-tag" title="Automatic success when played">J: "{state.player.tech_jack}"</div>
{:else if card.slice(0, -1) === "Q"}
{:else if !isJoker(card) && card.slice(0, -1) === "Q"}
<div class="tech-tag" title="Automatic success when played">Q: "{state.player.tech_queen}"</div>
{:else if card.slice(0, -1) === "K"}
{:else if !isJoker(card) && card.slice(0, -1) === "K"}
<div class="tech-tag" title="Automatic success when played">K: "{state.player.tech_king}"</div>
{/if}
</div>
<div class="card-corner bottom-right">
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
</div>
{:else}
@@ -386,6 +631,12 @@
</div>
{/if}
{#if state.player.tax_banned}
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.08); border: 1px dashed var(--red-suit); padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
<p class="info-text" style="margin: 0; font-size: 0.9rem;">🚫 You failed a refused Tax — no more Gat/Name Taxes for you this scene.</p>
</div>
{/if}
<div class="sheet-group">
<h4>Description:</h4>
<p><strong>Look:</strong> {state.player.avatar_look}</p>
@@ -402,6 +653,27 @@
</ul>
</div>
<!-- Duel another Pi-Rat -->
{#if !state.player.is_dead && scenePirats.filter(p => p.id !== state.player.id && !p.is_dead).length > 0}
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">⚔️ Duel a Pi-Rat</h4>
<p class="info-text" style="font-size: 0.85rem;">Challenge a crewmate (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>
<select class="select-field" bind:value={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Challenge whom?</option>
{#each scenePirats.filter(p => p.id !== state.player.id && !p.is_dead) as p}
<option value={p.id}>{p.name} (Rank {p.rank})</option>
{/each}
</select>
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
<option value="">Throw which card?</option>
{#each hand.filter(c => !isJoker(c)) as card}
<option value={card}>{getCardDisplay(card)}</option>
{/each}
</select>
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpOpponentId || !pvpCard}>Throw Down!</button>
</div>
{/if}
<div class="sheet-group margin-top">
<h4 style="color: var(--gold);">Crew Objectives</h4>
<div class="objectives-checklist">

View File

@@ -72,14 +72,14 @@
<div class="role-restrictions mt-4">
{#if !allowedRoles.includes('pirat')}
{#if state.game.current_scene_number === 1}
<p class="text-red-400 text-sm italic">You are the highest ranked player, so you must play The Deep for the first scene!</p>
<p class="text-red-400 text-sm italic">You start at Rank 3, so you must play The Deep for the first scene!</p>
{:else}
<p class="text-red-400 text-sm italic">You are the only eligible player for The Deep this scene!</p>
{/if}
{/if}
{#if !allowedRoles.includes('deep')}
{#if state.game.current_scene_number === 1}
<p class="text-red-400 text-sm italic">Only the highest ranked player(s) can play The Deep in the first scene!</p>
<p class="text-red-400 text-sm italic">You start at Rank 1, so you must play your Pi-Rat for the first scene!</p>
{:else}
<p class="text-red-400 text-sm italic">You played The Deep last scene, so you must play a Pi-Rat!</p>
{/if}

View File

@@ -36,6 +36,15 @@
}
}
async function finishGame() {
if (!confirm('End the story for everyone? Best saved for when every player has played the Deep, each Pi-Rat has completed a Personal Objective, and all 3 Crew Objectives are done.')) return;
try {
await apiRequest(`/game/${state.game.id}/finish`, 'POST');
} catch(e) {
console.error(e);
}
}
async function submitReRoll() {
if (!reRollDetails.avatar_look.trim() ||
!reRollDetails.avatar_smell.trim() ||
@@ -115,7 +124,16 @@
}
}
$: maxHandSize = state.player.rank + 1;
// Mirrors the backend hand size table: highest rank -> 4, lowest -> 2, middle -> 3, Captain +1.
// Deep players are ranked against all players.
$: maxHandSize = (() => {
const ranks = state.players.map(p => p.rank);
let base;
if (state.player.rank >= Math.max(...ranks)) base = 4;
else if (state.player.rank <= Math.min(...ranks)) base = 2;
else base = 3;
return base + (state.game.captain_player_id === state.player.id ? 1 : 0);
})();
$: isOverLimit = keepCards.length > maxHandSize;
async function submitVote() {
@@ -180,7 +198,7 @@
</button>
</div>
<p class="info-text" style="margin-top: 1.5rem; font-style: italic; color: var(--text-muted);">
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh with a randomized starting Rank between 1 and 3.
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh at Rank 1 with surprise Secret Techniques and a name based on their smell.
</p>
{:else}
<div class="creation-form-wrapper" style="text-align: left; background: rgba(0,0,0,0.2); padding: 1.5rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.05);">
@@ -234,7 +252,7 @@
<!-- Ranks and Voting Card -->
<div class="card glass-panel voting-card">
<h3>1. Pirate Ranking (Voting)</h3>
<p class="section-desc">Nominate one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p>
<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 === 'deep_upkeep'}
<div class="voted-confirmation-box glass-panel">
@@ -244,10 +262,6 @@
<div class="voted-confirmation-box glass-panel">
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
</div>
{:else if state.player.role === 'deep'}
<div class="voted-confirmation-box glass-panel">
<p class="info-text">The Deep does not vote.</p>
</div>
{:else}
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
<div class="form-group inline-group">
@@ -270,7 +284,7 @@
<h4>Nomination Progress:</h4>
<div class="player-chips list-chips" id="voting-roster">
{#each state.players as p}
{@const voted = state.votes.some(v => v.voter_player_id === p.id) || p.role === 'deep'}
{@const voted = state.votes.some(v => v.voter_player_id === p.id)}
<div class="player-chip {voted ? 'voted-chip' : 'not-voted-chip'}">
<span class="name">{p.name}</span>
<span class="status-badge">{voted ? 'Done' : 'Voting...'}</span>
@@ -305,26 +319,26 @@
<h4 class="gold-text">Hand (Keep)</h4>
<div class="upkeep-flex">
{#each keepCards as card}
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card === 'JOKER' ? 'joker-card' : ''}"
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}"
draggable="true"
on:dragstart={(e) => handleDragStart(e, card)}
on:click={() => moveToDiscard(card)}
style="cursor: pointer;"
title="Click or drag to discard">
<div class="card-corner top-left">
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
<div class="card-center">
{#if card === 'JOKER'}
{#if card.startsWith('Joker')}
🃏
{:else}
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
{/if}
</div>
<div class="card-corner bottom-right">
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
</div>
{:else}
@@ -340,26 +354,26 @@
<h4 class="gold-text">Discard Pile</h4>
<div class="upkeep-flex">
{#each discardCards as card}
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card === 'JOKER' ? 'joker-card' : ''}"
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}"
draggable="true"
on:dragstart={(e) => handleDragStart(e, card)}
on:click={() => moveToKeep(card)}
style="cursor: pointer;"
title="Click or drag to keep">
<div class="card-corner top-left">
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
<div class="card-center">
{#if card === 'JOKER'}
{#if card.startsWith('Joker')}
🃏
{:else}
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
{/if}
</div>
<div class="card-corner bottom-right">
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
</div>
</div>
{:else}
@@ -414,7 +428,7 @@
</div>
{/if}
{:else}
{#if state.player.role === 'pirat' && !state.votes.find(v => v.voter_player_id === state.player.id)}
{#if !state.votes.find(v => v.voter_player_id === state.player.id)}
<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}
@@ -432,6 +446,13 @@
{/if}
{/if}
{/if}
{#if state.player.is_creator && state.game.phase === 'between_scenes'}
<div style="margin-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
<p class="info-text" style="font-size: 0.85rem;">All good stories end. When the crew agrees the legend is complete:</p>
<button on:click={finishGame} class="btn btn-danger">🏴‍☠️ End the Story</button>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -1416,8 +1416,22 @@ const SUGGESTIONS = {
]
};
export function getSuggestion(type) {
const arr = SUGGESTIONS[type];
export function getSuggestion(type, exclude = []) {
let arr = SUGGESTIONS[type];
if (!arr) return "";
const taken = new Set(exclude.map(s => s.trim().toLowerCase()));
const available = arr.filter(s => !taken.has(s.trim().toLowerCase()));
if (available.length > 0) arr = available;
return arr[Math.floor(Math.random() * arr.length)];
}
// Draws `count` distinct suggestions from a pool.
export function getSuggestions(type, count) {
const picked = [];
for (let i = 0; i < count; i++) {
const s = getSuggestion(type, picked);
if (!s) break;
picked.push(s);
}
return picked;
}

View File

@@ -7,6 +7,7 @@
import SceneSetupPhase from '../components/SceneSetupPhase.svelte';
import ScenePhase from '../components/ScenePhase.svelte';
import UpkeepPhase from '../components/UpkeepPhase.svelte';
import GameOverPhase from '../components/GameOverPhase.svelte';
export let params = {};
let gameId = params.id;
@@ -55,6 +56,8 @@
<ScenePhase {state} />
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
<UpkeepPhase {state} />
{:else if state.game.phase === 'ended'}
<GameOverPhase {state} />
{:else}
<div class="card p-4">Unknown phase: {state.game.phase}</div>
{/if}

View File

@@ -74,8 +74,10 @@ OBSTACLES_DATA = {
}
}
# 20 funny Secret Pirate Technique suggestions for when players need ideas
TECHNIQUE_SUGGESTIONS = [
# Secret Pirate Technique suggestions for when players need ideas: 20 handcrafted
# names plus the same "{math word} {move} of {target}" combinations the frontend
# suggestion pool (frontend/src/lib/suggestions.js) is built from.
_HANDCRAFTED_TECHNIQUES = [
"I missed on purpose",
"Carlos will figure it out",
"Never trust a hatless captain",
@@ -98,6 +100,26 @@ TECHNIQUE_SUGGESTIONS = [
"Sudden pirate flip"
]
_TECHNIQUE_MATH_WORDS = [
"Geometric", "Parabolic", "Fibonacci", "Trigonometric", "Prime Number",
"Logarithmic", "Hypotenuse", "Algebraic", "Binary", "Vector"
]
_TECHNIQUE_MOVES = [
"Drift", "Leap", "Flurry", "Slash", "Parry",
"Slam", "Dodge", "Vortex", "Shield", "Barrage"
]
_TECHNIQUE_TARGETS = [
"the Cheese Reef", "the Stormy Sea", "Euler", "the Abacus", "the Gat",
"Carlos", "the Deep", "Gauss", "the Coordinate Plane", "Shrapnel"
]
TECHNIQUE_SUGGESTIONS = _HANDCRAFTED_TECHNIQUES + [
f"{math_word} {move} of {target}"
for math_word in _TECHNIQUE_MATH_WORDS
for move in _TECHNIQUE_MOVES
for target in _TECHNIQUE_TARGETS
]
def get_fresh_deck() -> List[str]:
"""Generates a shuffled deck of 54 cards (52 standard cards + 2 Jokers)."""
deck = []

View File

@@ -2,4 +2,5 @@
from .crud_base import *
from .crud_character import *
from .crud_scene import *
from .crud_challenge import *
from .crud_upkeep import *

View File

@@ -19,66 +19,83 @@ def get_game_deck(game: Game) -> List[str]:
def set_game_deck(game: Game, deck: List[str]):
game.deck_cards = json.dumps(deck)
def calculate_max_hand_size(player: Player, players_in_scene: List[Player]) -> int:
def calculate_max_hand_size(player: Player, players_in_scene: List[Player], captain_id: Optional[str] = None) -> int:
"""
Calculates a player's maximum hand size based on their Rank relative to others in the scene,
and Captain privileges (+1).
Calculates a player's maximum hand size based on their Rank relative to others,
plus Captain privileges (+1, regardless of Rank).
Rank Hand sizes:
- Highest Rank Pi-Rat(s): max 4 cards
- Middle Rank Pi-Rat(s): max 3 cards
- Lowest Rank Pi-Rat(s): max 2 cards
- Captain gets +1
If all players have the same Rank, they are all 'middle' and get 3 cards.
If everyone shares a Rank, they are all 'highest' and get 4 cards.
Pi-Rats are ranked against the Pi-Rats in the scene; Deep players (whose hand size
only matters for the between-scenes redraw) are ranked against all players.
"""
if player.role == "deep":
# Deep players still have hands (for when they transition), let's say they have size based on Rank
# but don't participate in the Pi-Rat ranking calculations.
# Deep player max hand size is 3 by default, or equal to Rank. Let's make it equal to Rank + 1.
return player.rank + 1
pi_rats = [p for p in players_in_scene if p.role != "deep"]
if not pi_rats:
return 3
ranks = [p.rank for p in pi_rats]
max_rank = max(ranks)
min_rank = min(ranks)
# Base size calculation
if len(set(ranks)) == 1:
# All have the same rank
base_size = 3
pool = players_in_scene
else:
if player.rank == max_rank:
base_size = 4
elif player.rank == min_rank:
base_size = 2
else:
base_size = 3
pool = [p for p in players_in_scene if p.role != "deep"]
if not pool:
pool = [player]
# Captain check (highest rank Pi-Rat is de facto Captain)
# The rulebook: 'Initially, the highest-Ranked Pi-Rat becomes the de facto Captain.
# Captain Privileges: The Captain has their Hand size increased by 1, regardless of their Rank.'
# If there's a tie for highest rank, we'll designate the first one in alphabetical/ID order.
highest_ranked_pirats = [p for p in pi_rats if p.rank == max_rank]
highest_ranked_pirats.sort(key=lambda p: p.id)
is_captain = len(highest_ranked_pirats) > 0 and highest_ranked_pirats[0].id == player.id
ranks = [p.rank for p in pool]
if player.rank >= max(ranks):
base_size = 4
elif player.rank <= min(ranks):
base_size = 2
else:
base_size = 3
if is_captain:
if captain_id is not None and player.id == captain_id:
return base_size + 1
return base_size
def is_player_captain(player: Player, players_in_scene: List[Player]) -> bool:
"""Helper to check if a player is currently the captain in the scene."""
if player.role == "deep":
return False
pi_rats = [p for p in players_in_scene if p.role != "deep"]
if not pi_rats:
return False
max_rank = max(p.rank for p in pi_rats)
highest_ranked = [p for p in pi_rats if p.rank == max_rank]
highest_ranked.sort(key=lambda p: p.id)
return len(highest_ranked) > 0 and highest_ranked[0].id == player.id
def is_player_captain(player: Player, game: Game) -> bool:
"""The Captain is a persistent position tracked on the Game (set once the crew steals a ship)."""
return game.captain_player_id is not None and game.captain_player_id == player.id
def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict:
"""Snapshot every player's max hand size, for detecting increases after a Rank/Captain change."""
return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players}
def apply_hand_increases(db: Session, game: Game, old_maxes: dict):
"""
'Anytime your Hand size increases, you immediately draw a card.'
Compares current max hand sizes against a snapshot and draws the difference
for any player whose max increased. (Decreases never force a discard.)
"""
for p in game.players:
new_max = calculate_max_hand_size(p, game.players, game.captain_player_id)
old_max = old_maxes.get(p.id, new_max)
if new_max > old_max:
draw_cards_for_player(db, game, p, new_max - old_max)
def change_player_rank(db: Session, game: Game, player: Player, delta: int):
"""Changes a player's Rank and grants immediate draws for any resulting hand size increases."""
old_maxes = capture_hand_maxes(game.players, game.captain_player_id)
player.rank = max(1, player.rank + delta)
db.add(player)
db.commit()
apply_hand_increases(db, game, old_maxes)
def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str = ""):
"""Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain."""
if game.captain_player_id == player_id:
return
old_maxes = capture_hand_maxes(game.players, game.captain_player_id)
game.captain_player_id = player_id
db.add(game)
db.commit()
apply_hand_increases(db, game, old_maxes)
if player_id:
new_captain = db.get(Player, player_id)
if new_captain:
msg = f"{new_captain.name} is now the Captain!"
if reason:
msg += f" ({reason})"
add_game_event(db, game.id, msg)
elif reason:
add_game_event(db, game.id, f"The Captain's position is vacant. ({reason})")
def reshuffle_discard_pile(db: Session, game: Game):
"""
@@ -91,13 +108,13 @@ def reshuffle_discard_pile(db: Session, game: Game):
all_hand_cards.update(get_player_hand(player))
# 2. Get active cards on the table
# We must keep the original_card of active obstacles, and the top card in their played_cards list
# Obstacle cards and their whole played-card columns stay on the table until the Obstacle is discarded
active_table_cards = set()
for obs in game.obstacles:
active_table_cards.add(obs.original_card)
played = json.loads(obs.played_cards)
if played:
active_table_cards.add(played[-1]["card"])
for entry in played:
active_table_cards.add(entry["card"])
# 3. The full deck of 54 cards
full_deck = []

View File

@@ -0,0 +1,505 @@
import json
import random
from typing import Tuple, Dict, Any, List, Optional
from sqlmodel import Session
from .models import Game, Player, Obstacle, Challenge
from . import cards
from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand,
draw_cards_for_player, add_game_event, change_player_rank
)
from .crud_scene import resolve_card_against_obstacle
# --- Helpers ---
def get_challenge(db: Session, challenge_id: str) -> Optional[Challenge]:
return db.get(Challenge, challenge_id)
def find_open_challenge_for_obstacle(game: Game, obstacle_id: str) -> Optional[Challenge]:
for challenge in game.challenges:
if challenge.status == "open" and obstacle_id in json.loads(challenge.obstacle_ids):
return challenge
return None
def _apply_captain_tax(db: Session, game: Game, acting_player: Player):
"""Captains lose 1 Rank each time they personally fail a Challenge."""
if game.captain_player_id == acting_player.id and acting_player.rank > 1:
change_player_rank(db, game, acting_player, -1)
add_game_event(db, game.id, f"Captain Tax! {acting_player.name} failed a Challenge and drops to Rank {acting_player.rank}.")
# --- Deep Challenges ---
def create_challenge(
db: Session,
game_id: str,
deep_player_id: str,
target_player_id: str,
obstacle_ids: List[str],
stakes: str = ""
) -> Tuple[bool, str]:
"""A Deep Player calls for a Challenge, applying one or more Obstacles from the list to a Pi-Rat."""
game = get_game(db, game_id)
deep_player = get_player(db, deep_player_id)
target = get_player(db, target_player_id)
if not game or not deep_player or not target:
return False, "Game entities not found."
if deep_player.role != "deep":
return False, "Only Deep Players can call for Challenges."
if target.role == "deep":
return False, "The Deep cannot challenge itself. Pick a Pi-Rat!"
if target.is_dead:
return False, f"{target.name} is dead. The Deep has no quarrel with the deceased."
if not obstacle_ids:
return False, "Apply at least one Obstacle to the Challenge."
valid_ids = {o.id for o in game.obstacles}
for oid in obstacle_ids:
if oid not in valid_ids:
return False, "Obstacle not found."
existing = find_open_challenge_for_obstacle(game, oid)
if existing:
return False, "One of those Obstacles is already part of an open Challenge."
for challenge in game.challenges:
if challenge.status == "open" and challenge.target_player_id == target_player_id:
return False, f"{target.name} is already facing an open Challenge. Resolve it first."
challenge = Challenge(
game_id=game.id,
challenge_type="deep",
target_player_id=target.id,
acting_player_id=target.id,
challenger_player_id=deep_player.id,
obstacle_ids=json.dumps(obstacle_ids),
stakes=stakes.strip(),
)
db.add(challenge)
db.commit()
obstacle_titles = ", ".join(f"'{o.title}'" for o in game.obstacles if o.id in obstacle_ids)
msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
if stakes.strip():
msg += f" Stakes: {stakes.strip()}"
add_game_event(db, game.id, msg)
return True, "Challenge called!"
def play_challenge_card(
db: Session,
player_id: str,
obstacle_id: str,
card_code: str
) -> Tuple[bool, str, Dict[str, Any]]:
"""
Plays a card from a Pi-Rat's hand against an Obstacle that is part of an open
Challenge. The challenged Pi-Rat (or whoever takes it over via a Gat/Name Tax)
draws back on suit-color matches; assisting Pi-Rats play but never draw.
"""
player = get_player(db, player_id)
obstacle = db.get(Obstacle, obstacle_id)
if not player or not obstacle:
return False, "Game entities not found.", {}
game = get_game(db, player.game_id)
if not game:
return False, "Game not found.", {}
if player.role == "deep":
return False, "The Deep applies Obstacles; it does not play cards against them.", {}
challenge = find_open_challenge_for_obstacle(game, obstacle_id)
if not challenge:
return False, "This Obstacle is not part of an open Challenge. The Deep must call a Challenge first.", {}
if challenge.tax_state == "requested":
return False, "A Gat/Name Tax is pending. Wait for an answer before playing cards.", {}
# An Obstacle beaten as many times as there are players is spent
played_list = json.loads(obstacle.played_cards)
current_successes = sum(1 for c in played_list if c.get("success") is True)
if current_successes >= len(game.players):
return False, "This obstacle is already completed.", {}
hand = get_player_hand(player)
if card_code not in hand:
return False, "Card not in hand.", {}
if cards.parse_card(card_code)["is_joker"]:
return False, "Jokers discard Obstacles outright; play it directly on the Obstacle instead.", {}
hand.remove(card_code)
set_player_hand(player, hand)
db.add(player)
acting = get_player(db, challenge.acting_player_id) or player
result = resolve_card_against_obstacle(db, game, player, obstacle, card_code, acting_rank=acting.rank)
# Record the play on the Challenge
plays = json.loads(challenge.plays)
plays.append({
"obstacle_id": obstacle_id,
"card": card_code,
"player_id": player.id,
"player_name": player.name,
"success": result["success"],
"details": result["details"]
})
challenge.plays = json.dumps(plays)
db.add(challenge)
db.commit()
# Step 6: Draw back on suit-color match — only the attempting Pi-Rat draws.
drew_card = None
if player.id == challenge.acting_player_id:
if cards.match_suit_color(card_code, obstacle.original_card):
drawn = draw_cards_for_player(db, game, player, 1)
if drawn:
drew_card = drawn[0]
result["details"] += " (Drew a card back due to matching suit colors!)"
else:
result["details"] += f" (Assisting {acting.name} — assistants don't draw back.)"
role_note = "" if player.id == challenge.acting_player_id else " (assist)"
add_game_event(db, game.id, f"{player.name} played {cards.parse_card(card_code)['display']} on '{obstacle.title}'{role_note}. {result['details']}")
result["drew_card"] = drew_card
result["is_joker"] = False
return True, "Card played successfully.", result
def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple[bool, str]:
"""
The Deep resolves the Challenge: it succeeds if at least one played card beat an
Obstacle. Applies the Captain Tax and settles any refused Gat/Name Tax.
"""
challenge = get_challenge(db, challenge_id)
resolver = get_player(db, resolver_id)
if not challenge or not resolver:
return False, "Challenge not found."
if challenge.status != "open":
return False, "This Challenge is already resolved."
if resolver.role != "deep":
return False, "Only Deep Players resolve Challenges."
if challenge.tax_state == "requested":
return False, "A Gat/Name Tax is pending. Wait for an answer before resolving."
game = get_game(db, challenge.game_id)
acting = get_player(db, challenge.acting_player_id)
target = get_player(db, challenge.target_player_id)
if not game or not acting or not target:
return False, "Game entities not found."
plays = json.loads(challenge.plays)
success = any(p.get("success") for p in plays)
challenge.status = "succeeded" if success else "failed"
db.add(challenge)
db.commit()
outcome = "succeeded" if success else "failed"
add_game_event(db, game.id, f"The Challenge against {target.name} {outcome}!")
# Settle a refused Gat/Name Tax: keep the prize on success, return it on failure.
if challenge.tax_state == "refused" and challenge.tax_target_id:
refuser = get_player(db, challenge.tax_target_id)
thing = "Gat" if challenge.tax_type == "gat" else "Name"
if refuser:
if success:
add_game_event(db, game.id, f"{acting.name} succeeded and keeps {refuser.name}'s {thing}! {refuser.name} must find a new one.")
else:
if challenge.tax_type == "gat":
acting.completed_personal_1 = False
refuser.completed_personal_1 = True
else:
acting.completed_personal_2 = False
acting.needs_name = False
refuser.completed_personal_2 = True
acting.tax_banned = True
db.add(refuser)
db.add(acting)
db.commit()
change_player_rank(db, game, acting, -1)
add_game_event(db, game.id, f"{acting.name} failed and returns the {thing} to {refuser.name}. No more Gat/Name Taxes for {acting.name} this scene!")
if not success:
_apply_captain_tax(db, game, acting)
return True, f"Challenge {outcome}."
def cancel_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple[bool, str]:
"""The Deep withdraws an open Challenge (e.g. called by mistake). Played cards stay in the columns."""
challenge = get_challenge(db, challenge_id)
resolver = get_player(db, resolver_id)
if not challenge or not resolver:
return False, "Challenge not found."
if challenge.status != "open":
return False, "This Challenge is already resolved."
if resolver.role != "deep":
return False, "Only Deep Players can withdraw Challenges."
game = get_game(db, challenge.game_id)
target = get_player(db, challenge.target_player_id)
db.delete(challenge)
db.commit()
if game and target:
add_game_event(db, game.id, f"The Deep withdrew the Challenge against {target.name}.")
return True, "Challenge withdrawn."
# --- Gat Tax & Name Tax ---
def request_tax(db: Session, challenge_id: str, requester_id: str, tax_target_id: str) -> Tuple[bool, str]:
"""
A Gatless Pi-Rat may ask a Gatted Pi-Rat (or a Gatted-but-Nameless Pi-Rat may ask
a Named one) to attempt their Challenge for them.
"""
challenge = get_challenge(db, challenge_id)
requester = get_player(db, requester_id)
target = get_player(db, tax_target_id)
if not challenge or not requester or not target:
return False, "Game entities not found."
if challenge.status != "open" or challenge.challenge_type != "deep":
return False, "That Challenge cannot be taxed."
if challenge.acting_player_id != requester.id:
return False, "Only the challenged Pi-Rat can call a Gat/Name Tax."
if challenge.tax_state is not None:
return False, "A Tax has already been called on this Challenge."
if requester.tax_banned:
return False, "You failed a refused Tax this scene — no more Gat/Name Taxes for you!"
if target.id == requester.id or target.role == "deep" or target.is_dead:
return False, "Pick another living Pi-Rat in the scene."
if not requester.completed_personal_1:
if not target.completed_personal_1:
return False, f"{target.name} has no Gat either!"
tax_type = "gat"
elif not requester.completed_personal_2:
if not target.completed_personal_2:
return False, f"{target.name} has no Name either!"
tax_type = "name"
else:
return False, "You already have a Gat and a Name. Face your own Challenges!"
challenge.tax_type = tax_type
challenge.tax_target_id = target.id
challenge.tax_state = "requested"
db.add(challenge)
db.commit()
game = get_game(db, challenge.game_id)
thing = "Gat" if tax_type == "gat" else "Name"
add_game_event(db, game.id, f"{requester.name} calls a {thing} Tax on {target.name}: 'Take this Challenge for me!'")
return True, f"{thing} Tax called on {target.name}."
def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool) -> Tuple[bool, str]:
"""
Accept: the requester hands over one random card and the responder attempts the
Challenge, sharing successes and failures.
Refuse: the responder hands over their Gat/Name (the requester immediately
completes that Objective) and the requester attempts the Challenge — winner keeps it.
"""
challenge = get_challenge(db, challenge_id)
responder = get_player(db, responder_id)
if not challenge or not responder:
return False, "Game entities not found."
if challenge.status != "open" or challenge.tax_state != "requested":
return False, "There is no pending Tax on this Challenge."
if challenge.tax_target_id != responder.id:
return False, "This Tax was not called on you."
requester = get_player(db, challenge.acting_player_id)
game = get_game(db, challenge.game_id)
if not requester or not game:
return False, "Game entities not found."
thing = "Gat" if challenge.tax_type == "gat" else "Name"
if accept:
# One random card from the requester's hand, chosen without looking
requester_hand = get_player_hand(requester)
card_note = ""
if requester_hand:
card = random.choice(requester_hand)
requester_hand.remove(card)
set_player_hand(requester, requester_hand)
responder_hand = get_player_hand(responder)
responder_hand.append(card)
set_player_hand(responder, responder_hand)
db.add(requester)
db.add(responder)
card_note = " One random card changed paws."
challenge.acting_player_id = responder.id
challenge.tax_state = "accepted"
db.add(challenge)
db.commit()
add_game_event(db, game.id, f"{responder.name} accepted the {thing} Tax and takes over the Challenge!{card_note}")
return True, "Tax accepted."
# Refused: hand over the Gat/Name. The requester attempts the Challenge themselves;
# the transfer is settled (kept or returned) when the Challenge resolves.
if challenge.tax_type == "gat":
requester.completed_personal_1 = True
responder.completed_personal_1 = False
else:
requester.completed_personal_2 = True
requester.needs_name = True
responder.completed_personal_2 = False
challenge.tax_state = "refused"
db.add(requester)
db.add(responder)
db.add(challenge)
db.commit()
change_player_rank(db, game, requester, 1)
add_game_event(db, game.id, f"{responder.name} refused the {thing} Tax and must hand over their {thing}! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!")
return True, "Tax refused. The prize is on the line!"
# --- Pi-Rat vs Pi-Rat Challenges ---
def create_pvp_challenge(
db: Session,
game_id: str,
challenger_id: str,
defender_id: str,
card_code: str
) -> Tuple[bool, str]:
"""
A Pi-Rat challenges another: the challenger plays one card face up as a temporary
Obstacle (narrating based on suit). The defender plays a card against it.
"""
game = get_game(db, game_id)
challenger = get_player(db, challenger_id)
defender = get_player(db, defender_id)
if not game or not challenger or not defender:
return False, "Game entities not found."
if challenger.role == "deep" or defender.role == "deep":
return False, "Only Pi-Rats can duel each other."
if challenger.id == defender.id:
return False, "You cannot challenge yourself. (Well, you can, but not with cards.)"
if defender.is_dead:
return False, f"{defender.name} is dead. Let them rest."
for challenge in game.challenges:
if challenge.status == "open" and defender.id in (challenge.target_player_id, challenge.acting_player_id):
return False, f"{defender.name} is already facing an open Challenge."
hand = get_player_hand(challenger)
if card_code not in hand:
return False, "Card not in hand."
if cards.parse_card(card_code)["is_joker"]:
return False, "A Joker cannot serve as a temporary Obstacle."
hand.remove(card_code)
set_player_hand(challenger, hand)
db.add(challenger)
challenge = Challenge(
game_id=game.id,
challenge_type="pvp",
target_player_id=defender.id,
acting_player_id=defender.id,
challenger_player_id=challenger.id,
temp_card=card_code,
)
db.add(challenge)
db.commit()
parsed = cards.parse_card(card_code)
narration = cards.SUITS[parsed["suit"]]["narration"]
add_game_event(db, game.id, f"{challenger.name} challenges {defender.name}, playing {parsed['display']} as a temporary Obstacle — {narration}!")
return True, "Duel called!"
def play_pvp_defense(db: Session, challenge_id: str, defender_id: str, card_code: str) -> Tuple[bool, str, Dict[str, Any]]:
"""
The defender plays a card against the temporary Obstacle. Resolved immediately;
both cards are discarded and only the defender draws back on a suit-color match.
"""
challenge = get_challenge(db, challenge_id)
defender = get_player(db, defender_id)
if not challenge or not defender:
return False, "Game entities not found.", {}
if challenge.status != "open" or challenge.challenge_type != "pvp":
return False, "This duel is already settled.", {}
if challenge.acting_player_id != defender.id:
return False, "You are not the one being challenged.", {}
game = get_game(db, challenge.game_id)
challenger = get_player(db, challenge.challenger_player_id)
if not game or not challenger:
return False, "Game entities not found.", {}
hand = get_player_hand(defender)
if card_code not in hand:
return False, "Card not in hand.", {}
played_parsed = cards.parse_card(card_code)
if played_parsed["is_joker"]:
return False, "Jokers discard Obstacles from the list; they can't parry a duel.", {}
hand.remove(card_code)
set_player_hand(defender, hand)
db.add(defender)
temp_parsed = cards.parse_card(challenge.temp_card)
success = False
details = ""
is_technique = False
tech_name = ""
if played_parsed["value"] in ["J", "Q", "K"]:
success = True
is_technique = True
tech_name = {
"J": defender.tech_jack or "Jack Secret Technique",
"Q": defender.tech_queen or "Queen Secret Technique",
"K": defender.tech_king or "King Secret Technique",
}[played_parsed["value"]]
details = f"Used Secret Pirate Technique: '{tech_name}'!"
else:
# A face card acting as an Obstacle is worth the challenged Pi-Rat's Rank
if temp_parsed["value"] in ["J", "Q", "K"]:
target_value = defender.rank
obs_detail = f"Rank {defender.rank} (Face Card Obstacle)"
else:
target_value = temp_parsed["numeric_value"]
obs_detail = str(target_value)
privilege_bonus = 0
if defender.completed_personal_1 and played_parsed["suit"] in ["C", "S"]:
privilege_bonus = 1
if defender.completed_personal_2 and played_parsed["suit"] in ["H", "D"]:
privilege_bonus = 1
final_value = played_parsed["numeric_value"] + privilege_bonus
if final_value > target_value:
success = True
details = f"Success! Played {played_parsed['display']} ({final_value}) vs {temp_parsed['display']} ({obs_detail})."
else:
details = f"Failure! Played {played_parsed['display']} ({final_value}) vs {temp_parsed['display']} ({obs_detail})."
plays = json.loads(challenge.plays)
plays.append({
"obstacle_id": None,
"card": card_code,
"player_id": defender.id,
"player_name": defender.name,
"success": success,
"details": details
})
challenge.plays = json.dumps(plays)
challenge.status = "succeeded" if success else "failed"
db.add(challenge)
db.commit()
drew_card = None
if cards.match_suit_color(card_code, challenge.temp_card):
drawn = draw_cards_for_player(db, game, defender, 1)
if drawn:
drew_card = drawn[0]
details += " (Drew a card back due to matching suit colors!)"
outcome = "wins" if success else "loses"
add_game_event(db, game.id, f"{defender.name} defends against {challenger.name} and {outcome}! {details}")
if not success:
_apply_captain_tax(db, game, defender)
return True, "Duel resolved.", {
"success": success,
"details": details,
"is_technique": is_technique,
"tech_name": tech_name,
"drew_card": drew_card,
"is_joker": False
}

View File

@@ -6,6 +6,27 @@ from .crud_base import get_player, get_game, add_game_event
# --- Character Creation Operations ---
def auto_delegate_all(db: Session, game: Game):
"""
Assigns the Like/Hate delegated questions for every player at once.
Uses a shuffled cycle so each player answers exactly one Like and one Hate,
never their own, and (with 3+ players) from two different crewmates.
"""
players = list(game.players)
if len(players) < 2:
return
random.shuffle(players)
n = len(players)
for i, p in enumerate(players):
if not p.other_like_from_player_id:
p.other_like_from_player_id = players[(i + 1) % n].id
p.other_like = ""
if not p.other_hate_from_player_id:
p.other_hate_from_player_id = players[(i - 1) % n].id
p.other_hate = ""
db.add(p)
db.commit()
def delegate_question(db: Session, player_id: str, question_type: str, target_player_id: str):
player = get_player(db, player_id)
if not player:
@@ -31,17 +52,40 @@ def submit_delegated_answer(db: Session, target_player_id: str, question_type: s
db.commit()
def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3: str):
"""
Saves a player's 3 techniques. Returns an error string if any of them collide
(case-insensitively) with each other or with techniques other players already
submitted, so the player can fix it immediately instead of the whole pool
being reset after everyone submits.
"""
player = get_player(db, player_id)
if not player:
return
player.created_techniques = json.dumps([tech1, tech2, tech3])
return None
techs = [tech1, tech2, tech3]
cleaned = [t.strip().lower() for t in techs]
if len(set(cleaned)) < 3:
return "Your 3 techniques must all be different."
game = get_game(db, player.game_id)
if game:
taken = set()
for p in game.players:
if p.id == player.id:
continue
for t in json.loads(p.created_techniques):
taken.add(t.strip().lower())
clashes = [techs[i] for i, c in enumerate(cleaned) if c in taken]
if clashes:
return f"Already taken by a crewmate: {', '.join(clashes)}. Pick something more original!"
player.created_techniques = json.dumps(techs)
db.add(player)
db.commit()
# Check if all players have submitted techniques. If so, trigger the swap!
game = get_game(db, player.game_id)
if not game:
return
return None
all_submitted = True
for p in game.players:
@@ -52,6 +96,7 @@ def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3
if all_submitted:
trigger_technique_swap(db, game)
return None
def trigger_technique_swap(db: Session, game: Game):
"""
@@ -214,8 +259,8 @@ def roll_new_character(
set_game_deck(game, deck)
set_player_hand(player, [])
# 2. Reset properties & randomize starting rank between 1 and 3
player.rank = random.randint(1, 3)
# 2. Reset properties. New recruits always start at Rank 1.
player.rank = 1
player.is_ready = False
player.completed_personal_1 = False
player.completed_personal_2 = False
@@ -224,6 +269,11 @@ def roll_new_character(
player.needs_rank_3_bonus = False
player.is_dead = False
player.is_ghost = False
player.tax_banned = False
# A dead Captain's position does not pass to their replacement recruit
if game.captain_player_id == player.id:
game.captain_player_id = None
# Save player-provided details
player.avatar_look = avatar_look.strip()
@@ -231,15 +281,38 @@ def roll_new_character(
player.first_words = first_words.strip()
player.good_at_math = good_at_math.strip()
# Scent/Name is "Recruit [Smell]"
# Scent/Name is "Recruit [Smell]" — strip lead-ins like "smells like" or
# "faintly of" (the suggestion pool uses these) so the name reads naturally.
clean_smell = avatar_smell.strip()
if clean_smell.lower().startswith("like "):
clean_smell = clean_smell[5:]
lead_ins = [
"suspiciously smelling of", "pleasantly smelling of", "reminiscent of",
"smells like", "a mix of", "like a mix of", "strongly of", "faintly of",
"like", "of",
]
stripped = True
while stripped:
stripped = False
for lead in lead_ins:
if clean_smell.lower().startswith(lead + " "):
clean_smell = clean_smell[len(lead) + 1:]
stripped = True
clean_smell = clean_smell[:1].upper() + clean_smell[1:]
player.name = f"Recruit {clean_smell}"
# 3. Randomly assign 3 new secret techniques from cards.TECHNIQUE_SUGGESTIONS
# 3. Randomly assign 3 new secret techniques, avoiding any technique already
# in play on another player's sheet (collisions make for boring recruits).
from .cards import TECHNIQUE_SUGGESTIONS
chosen_techs = random.sample(TECHNIQUE_SUGGESTIONS, 3)
in_use = set()
for p in game.players:
if p.id == player.id:
continue
for t in [p.tech_jack, p.tech_queen, p.tech_king] + json.loads(p.created_techniques) + json.loads(p.swapped_techniques):
if t:
in_use.add(t.strip().lower())
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use]
if len(available) < 3:
available = TECHNIQUE_SUGGESTIONS
chosen_techs = random.sample(available, 3)
player.created_techniques = json.dumps(chosen_techs)
player.swapped_techniques = json.dumps(chosen_techs)
player.tech_jack = chosen_techs[0]
@@ -271,7 +344,7 @@ def roll_new_character(
db.commit()
# 5. Draw cards up to new hand size based on new Rank
max_size = calculate_max_hand_size(player, game.players)
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
draw_cards_for_player(db, game, player, max_size)
add_game_event(db, game.id, f"{player.name} has joined the crew as a new Rank {player.rank} recruit!")

View File

@@ -1,13 +1,14 @@
import json
import random
from typing import Tuple, Dict, Any
from typing import Tuple, Dict, Any, Optional
from sqlmodel import Session
from .models import Game, Player, Obstacle
from .models import Game, Player, Obstacle, Challenge
from . import cards
from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand,
get_game_deck, set_game_deck, calculate_max_hand_size, draw_cards_for_player,
add_game_event
add_game_event, reshuffle_discard_pile, capture_hand_maxes, apply_hand_increases,
change_player_rank, set_captain
)
# --- Scene Setup Operations ---
@@ -24,8 +25,10 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
"""
Validates that:
1. At least 1 Pi-Rat and 1 Deep player are selected.
2. Any player who was Deep in the previous scene must play Pi-Rat in this scene.
If valid, starts the scene: shuffles deck, draws obstacles, draws starting hands, resets ready flags.
2. First scene: the Rank 3 player must play the Deep, the Rank 1 player must play their Pi-Rat.
3. Any player who was Deep in the previous scene must play Pi-Rat in this scene.
If valid, starts the scene: shuffles discards into the deck, tops up the Obstacle List
(unbeaten Obstacles persist across scenes), and deals starting hands in the first scene.
"""
game = get_game(db, game_id)
if not game:
@@ -41,17 +44,14 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
db.commit()
if game.current_scene_number == 1:
max_rank = max((p.rank for p in players), default=1)
highest_ranked_players = [p for p in players if p.rank == max_rank]
for p in players:
if p.role == "deep" and p not in highest_ranked_players:
return False, f"Only the highest ranked player(s) (Rank {max_rank}) can play The Deep in the first scene."
if len(highest_ranked_players) == 1:
must_be_deep = highest_ranked_players[0]
if must_be_deep.role != "deep":
return False, f"{must_be_deep.name} is the highest ranked player and must play The Deep in the first scene."
# The Rank 3 starter must play the Deep; the Rank 1 starter must play their Pi-Rat.
# Rank 2 players may choose either role.
if len(players) >= 2:
for p in players:
if p.rank >= 3 and p.role != "deep":
return False, f"{p.name} starts at Rank 3 and must play The Deep in the first scene."
if p.rank <= 1 and p.role != "pirat":
return False, f"{p.name} starts at Rank 1 and must play their Pi-Rat in the first scene."
# 1. Check previous Deep players
# "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene."
@@ -80,50 +80,42 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
return False, "You need at least one Deep player."
# Everything is valid! Start the scene.
# A. Reset obstacles
for obs in game.obstacles:
db.delete(obs)
# A. Clear votes and stale challenges. Obstacles persist across scenes.
for vote in game.votes:
db.delete(vote)
for challenge in game.challenges:
db.delete(challenge)
db.commit()
# B. Set previous_role to current role, reset is_ready
# B. Set previous_role to current role, reset per-scene flags
for p in players:
p.previous_role = p.role
p.is_ready = False
p.tax_banned = False
db.add(p)
db.commit()
# C. Shuffle remaining deck (cards not in player hands)
all_hand_cards = set()
for p in players:
all_hand_cards.update(get_player_hand(p))
# C. Return discards to the deck and shuffle (cards in hands or on the table stay where they are)
reshuffle_discard_pile(db, game)
deck = get_game_deck(game)
full_deck = []
for suit in cards.SUITS.keys():
for val in cards.VALUES:
full_deck.append(f"{val}{suit}")
full_deck.extend(["Joker1", "Joker2"])
deck = [c for c in full_deck if c not in all_hand_cards]
random.shuffle(deck)
# D. Draw Obstacles: At least D + 1 obstacles, plus any extra_obstacles from Jokers
num_obstacles_to_draw = deeps_count + 1 + game.extra_obstacles
# Reset extra_obstacles for the next scene
game.extra_obstacles = 0
# Draw obstacles, handling Jokers
drawn_obstacles = []
while len(drawn_obstacles) < num_obstacles_to_draw:
# D. Top up the Obstacle List until it holds at least Deep Players + 1 Obstacles,
# plus the permanent increase from Jokers previously drawn as Obstacles.
required_obstacles = deeps_count + 1 + game.extra_obstacles
db.refresh(game)
active_count = len(game.obstacles)
drawn_count = 0
while active_count + drawn_count < required_obstacles:
if not deck:
break
card = deck.pop(0)
parsed = cards.parse_card(card)
if parsed["is_joker"]:
# Joker drawn as Obstacle:
# Immediately discard Jokers when they are drawn this way, and increase the total obstacles
# Joker drawn as an Obstacle: discard it and permanently increase the
# number of Obstacles required for following scenes by 1.
game.extra_obstacles += 1
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.")
continue
info = cards.get_obstacle_info(card)
@@ -137,20 +129,16 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
played_cards="[]"
)
db.add(obstacle)
drawn_obstacles.append(obstacle)
drawn_count += 1
# E. Top up player hands to their maximum size
for p in players:
max_size = calculate_max_hand_size(p, players)
current_hand = get_player_hand(p)
if len(current_hand) < max_size:
diff = max_size - len(current_hand)
# Draw diff cards
for _ in range(diff):
if not deck:
break
card = deck.pop(0)
current_hand.append(card)
# E. Deal starting hands in the first scene. In later scenes hands carry over;
# players only draw via suit-color matches, hand size increases, and the Deep's upkeep redraw.
if game.current_scene_number == 1:
for p in players:
max_size = calculate_max_hand_size(p, players, game.captain_player_id)
current_hand = get_player_hand(p)
while len(current_hand) < max_size and deck:
current_hand.append(deck.pop(0))
p.hand_cards = json.dumps(current_hand)
db.add(p)
@@ -159,7 +147,8 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
db.add(game)
db.commit()
add_game_event(db, game.id, f"Scene {game.current_scene_number} has started! {num_obstacles_to_draw} obstacles drawn.")
total_obstacles = active_count + drawn_count
add_game_event(db, game.id, f"Scene {game.current_scene_number} has started! {drawn_count} new obstacle(s) drawn ({total_obstacles} active).")
return True, "Scene started successfully!"
@@ -173,15 +162,12 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
allowed = ["pirat", "deep"]
if game.current_scene_number == 1:
max_rank = max((p.rank for p in players), default=1)
highest_ranked_players = [p for p in players if p.rank == max_rank]
if player not in highest_ranked_players:
if "deep" in allowed:
allowed.remove("deep")
elif len(highest_ranked_players) == 1:
if "pirat" in allowed:
allowed.remove("pirat")
# Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either.
if len(players) >= 2:
if player.rank >= 3:
allowed = ["deep"]
elif player.rank <= 1:
allowed = ["pirat"]
elif game.current_scene_number > 1:
# Rule 1: Previous Deep must play Pi-Rat (if >= 3 players)
if len(players) >= 3 and player.previous_role == "deep":
@@ -198,53 +184,29 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
# --- Scene Gameplay Operations ---
def play_card_on_obstacle(
def resolve_card_against_obstacle(
db: Session,
player_id: str,
obstacle_id: str,
card_code: str
) -> Tuple[bool, str, Dict[str, Any]]:
game: Game,
player: Player,
obstacle: Obstacle,
card_code: str,
acting_rank: int
) -> Dict[str, Any]:
"""
Plays a card from player's hand against an active obstacle.
Updates the obstacle value, checks matching suit color to draw back,
and returns resolution results.
Resolves one (non-Joker) card from a player against an Obstacle:
determines success (Secret Pirate Techniques, Gat/Name privileges, face-card
Obstacle values), appends the card to the Obstacle's column, and updates
the Obstacle's current value. Does not touch hands or draws.
"""
player = get_player(db, player_id)
obstacle = db.get(Obstacle, obstacle_id)
game = get_game(db, player.game_id)
if not player or not obstacle or not game:
return False, "Game entities not found.", {}
hand = get_player_hand(player)
if card_code not in hand:
return False, "Card not in hand.", {}
# Remove from hand
hand.remove(card_code)
set_player_hand(player, hand)
# Parse card and obstacle
played_parsed = cards.parse_card(card_code)
orig_obs_parsed = cards.parse_card(obstacle.original_card)
# Check if already completed
played_list = json.loads(obstacle.played_cards)
current_successes = sum(1 for c in played_list if c.get("success") is True)
required_successes = sum(1 for p in game.players if p.role != "deep")
if current_successes >= required_successes:
return False, "This obstacle is already completed.", {}
# 1. Determine Success/Failure
success = False
details = ""
is_technique = False
tech_name = ""
# Value rules:
# A. Face Cards (Jack, Queen, King) played by Pi-Rat are Secret Pirate Techniques: Automatic Success!
if played_parsed["value"] in ["J", "Q", "K"] and player.role == "pirat":
# Face Cards (Jack, Queen, King) played by a Pi-Rat trigger a Secret Pirate Technique: automatic success!
if played_parsed["value"] in ["J", "Q", "K"] and player.role != "deep":
success = True
is_technique = True
if played_parsed["value"] == "J":
@@ -254,30 +216,25 @@ def play_card_on_obstacle(
elif played_parsed["value"] == "K":
tech_name = player.tech_king or "King Secret Technique"
details = f"Used Secret Pirate Technique: '{tech_name}'!"
# B. Jokers played by Pi-Rat:
elif played_parsed["is_joker"]:
success = True
details = "Played a Joker! This obstacle is discarded, and a new one is drawn."
else:
# Regular card play vs Obstacle value.
# Regular card play vs Obstacle value. The last card in the column (or the
# original Obstacle card) determines the value; face cards count as the
# Rank of the challenged Pi-Rat.
obs_val_card = cards.parse_card(obstacle.original_card)
played_on_obs = json.loads(obstacle.played_cards)
if played_on_obs:
obs_val_card = cards.parse_card(played_on_obs[-1]["card"])
if obs_val_card["value"] in ["J", "Q", "K"]:
target_value = player.rank
obs_detail = f"Rank {player.rank} (Face Card Obstacle)"
target_value = acting_rank
obs_detail = f"Rank {acting_rank} (Face Card Obstacle)"
else:
target_value = obstacle.current_value
obs_detail = str(target_value)
# What is the played card's value?
played_val = played_parsed["numeric_value"]
# Apply privileges:
# Gat privilege: Black cards +1. Name privilege: Red cards +1.
privilege_bonus = 0
if player.completed_personal_1 and played_parsed["suit"] in ["C", "S"]:
privilege_bonus = 1
@@ -286,7 +243,6 @@ def play_card_on_obstacle(
final_played_value = played_val + privilege_bonus
# Compare
if final_played_value > target_value:
success = True
details = f"Success! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}."
@@ -294,17 +250,7 @@ def play_card_on_obstacle(
success = False
details = f"Failure! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}."
# 2. Card Draw (Step 6 of Resolving Challenges)
drew_card = None
if not played_parsed["is_joker"]:
if played_parsed["color"] == orig_obs_parsed["color"]:
# Match! Draw 1 card
drawn = draw_cards_for_player(db, game, player, 1)
if drawn:
drew_card = drawn[0]
details += " (Drew a card back due to matching suit colors!)"
# 3. Update Obstacle Table & Played Card list
# Place the card in the Obstacle's column; it becomes the Obstacle's new value.
played_list = json.loads(obstacle.played_cards)
played_list.append({
"card": card_code,
@@ -314,90 +260,125 @@ def play_card_on_obstacle(
"details": details
})
obstacle.played_cards = json.dumps(played_list)
# The last played card (if not Joker) becomes the obstacle's new value
if not played_parsed["is_joker"]:
obstacle.current_value = played_parsed["numeric_value"]
db.add(obstacle)
else:
# It's a Joker! We replace the obstacle.
db.delete(obstacle)
db.commit()
# Draw replacement obstacle from deck
deck = get_game_deck(game)
new_obs = None
while deck:
new_card = deck.pop(0)
new_parsed = cards.parse_card(new_card)
if new_parsed["is_joker"]:
game.extra_obstacles += 1
continue
info = cards.get_obstacle_info(new_card)
new_obs = Obstacle(
game_id=game.id,
original_card=new_card,
suit=info["suit"],
title=info["title"],
description=info["description"],
current_value=info["initial_value"],
played_cards="[]"
)
db.add(new_obs)
break
game.deck_cards = json.dumps(deck)
db.add(game)
db.commit()
db.add(player)
obstacle.current_value = played_parsed["numeric_value"]
db.add(obstacle)
db.commit()
event_msg = f"{player.name} played {played_parsed['display']} on '{obstacle.title}'. {details}"
add_game_event(db, game.id, event_msg)
res_dict = {
return {
"success": success,
"details": details,
"is_technique": is_technique,
"tech_name": tech_name,
"drew_card": drew_card,
"is_joker": played_parsed["is_joker"]
"tech_name": tech_name
}
return True, "Card played successfully.", res_dict
def evaluate_hand_sizes(db: Session, game_id: str):
def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) -> Tuple[bool, str]:
"""
Recalculates max hand size for all players in the game.
If a player's current hand size is less than their max hand size,
draws cards to fill it up immediately.
Pirate Luck! A Joker may be played at any time to discard one Obstacle (and its
column) from the list entirely and draw a new one. The Joker is then discarded.
"""
game = get_game(db, game_id)
player = get_player(db, player_id)
obstacle = db.get(Obstacle, obstacle_id)
if not player or not obstacle:
return False, "Game entities not found."
game = get_game(db, player.game_id)
if not game:
return
return False, "Game not found."
for player in game.players:
max_size = calculate_max_hand_size(player, game.players)
current_hand = get_player_hand(player)
if len(current_hand) < max_size:
draw_cards_for_player(db, game, player, max_size - len(current_hand))
parsed = cards.parse_card(card_code)
if not parsed["is_joker"]:
return False, "That card is not a Joker."
hand = get_player_hand(player)
if card_code not in hand:
return False, "Card not in hand."
hand.remove(card_code)
set_player_hand(player, hand)
db.add(player)
obstacle_title = obstacle.title
# Remove the obstacle from any open challenge it was applied to
for challenge in game.challenges:
if challenge.status != "open":
continue
obstacle_ids = json.loads(challenge.obstacle_ids)
if obstacle_id in obstacle_ids:
obstacle_ids.remove(obstacle_id)
challenge.obstacle_ids = json.dumps(obstacle_ids)
db.add(challenge)
db.delete(obstacle)
db.commit()
# Draw a replacement Obstacle from the deck
deck = get_game_deck(game)
new_title = None
while deck:
new_card = deck.pop(0)
new_parsed = cards.parse_card(new_card)
if new_parsed["is_joker"]:
game.extra_obstacles += 1
add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.")
continue
info = cards.get_obstacle_info(new_card)
new_obs = Obstacle(
game_id=game.id,
original_card=new_card,
suit=info["suit"],
title=info["title"],
description=info["description"],
current_value=info["initial_value"],
played_cards="[]"
)
db.add(new_obs)
new_title = info["title"]
break
game.deck_cards = json.dumps(deck)
db.add(game)
db.commit()
msg = f"{player.name} played a Joker! '{obstacle_title}' is discarded"
msg += f" and replaced by '{new_title}'." if new_title else " (no replacement left in the deck)."
add_game_event(db, game.id, msg)
return True, msg
def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, status: bool):
"""Toggles Personal or Crew objectives and adjusts states/Ranks accordingly."""
"""Toggles Personal or Crew objectives and adjusts states/Ranks/Captaincy accordingly."""
if obj_type.startswith("crew_"):
game = get_game(db, game_id)
if not game: return
if obj_type == "crew_1": game.completed_crew_1 = status
elif obj_type == "crew_2": game.completed_crew_2 = status
elif obj_type == "crew_3": game.completed_crew_3 = status
db.add(game)
db.commit()
if obj_type == "crew_1":
game.completed_crew_1 = status
db.add(game)
db.commit()
if status and not game.captain_player_id:
# "Once the crew controls a ship, the highest-Ranked Pi-Rat becomes the de facto Captain."
pi_rats = [p for p in game.players if p.role != "deep" and not p.is_dead and not p.is_ghost]
if pi_rats:
max_rank = max(p.rank for p in pi_rats)
candidates = [p for p in pi_rats if p.rank == max_rank]
set_captain(db, game, random.choice(candidates).id, "Highest-ranked Pi-Rat aboard the stolen ship")
elif not status and game.captain_player_id:
# The ship is lost: the Captain's position goes with it.
set_captain(db, game, None, "The ship was lost")
elif obj_type == "crew_2":
game.completed_crew_2 = status
db.add(game)
db.commit()
elif obj_type == "crew_3":
game.completed_crew_3 = status
db.add(game)
db.commit()
return
player = get_player(db, target_id)
if not player:
return
game = get_game(db, game_id)
if not game:
return
rank_diff = 0
if obj_type == "personal_1":
@@ -425,13 +406,15 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
player.is_dead = False
player.completed_personal_3 = status
player.rank += rank_diff
player.rank = max(1, player.rank)
db.add(player)
db.commit()
if rank_diff != 0:
evaluate_hand_sizes(db, game_id)
change_player_rank(db, game, player, rank_diff)
# The Captain remains in power until they give up the position, are defeated, or die.
if obj_type == "personal_3" and status and game.captain_player_id == player.id:
set_captain(db, game, None, f"{player.name}'s story arc is complete")
obj_name = obj_type.replace('_', ' ').title()
status_text = "completed" if status else "unmarked"
@@ -460,9 +443,23 @@ def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]:
obstacle.played_cards = json.dumps(played_list)
db.add(obstacle)
# Return card to player's hand
# Remove the matching play from any open challenge that applied this obstacle
player = get_player(db, player_id)
if player:
game = get_game(db, player.game_id)
if game:
for challenge in game.challenges:
if challenge.status != "open":
continue
plays = json.loads(challenge.plays)
for i in range(len(plays) - 1, -1, -1):
p = plays[i]
if p.get("obstacle_id") == obstacle_id and p.get("card") == card_code and p.get("player_id") == player_id:
plays.pop(i)
challenge.plays = json.dumps(plays)
db.add(challenge)
break
# Return card to player's hand
hand = get_player_hand(player)
hand.append(card_code)
set_player_hand(player, hand)
@@ -477,3 +474,13 @@ def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.")
db.delete(obstacle)
db.commit()
def finish_game(db: Session, game_id: str):
"""Ends the story. The wrap-up screen shows how the crew measured up."""
game = get_game(db, game_id)
if not game:
return
game.phase = "ended"
db.add(game)
db.commit()
add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. 🎉")

View File

@@ -1,16 +1,30 @@
import json
import random
from typing import List
from typing import List, Tuple
from sqlmodel import Session, select
from .models import Game, Player, Vote
from .crud_base import (
get_player, get_game, get_player_hand, set_player_hand,
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event
get_game_deck, set_game_deck, draw_cards_for_player, add_game_event,
calculate_max_hand_size, change_player_rank
)
# --- Between Scenes Operations ---
def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: 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
Player to Rank up. Deep Players from the previous scene cannot be nominated.
"""
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:
return False, "Player not found."
if voter_id == nominated_id:
return False, "You cannot nominate yourself. Nice try."
if nominee.role == "deep":
return False, "Deep Players from the previous scene cannot be nominated."
# Remove any existing vote by this voter for this game
existing = db.exec(
select(Vote).where(Vote.game_id == game_id, Vote.voter_player_id == voter_id)
@@ -26,6 +40,7 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
)
db.add(vote)
db.commit()
return True, "Vote submitted."
def end_scene_and_transition(db: Session, game_id: str):
"""Moves game phase to between_scenes."""
@@ -66,8 +81,7 @@ def process_between_scenes_votes(db: Session, game: Game):
winner_id = random.choice(winners)
winner = get_player(db, winner_id)
if winner:
winner.rank += 1
db.add(winner)
change_player_rank(db, game, winner, 1)
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.")
# Clear all votes
@@ -132,9 +146,8 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
db.add(player)
db.commit()
# 2. Draw cards back up to max size
# Max size for deep player is Rank + 1
max_size = player.rank + 1
# 2. Draw cards back up to max size (based on Rank, per the hand size table)
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
if len(hand) < max_size:
diff = max_size - len(hand)
draw_cards_for_player(db, game, player, diff)

View File

@@ -25,10 +25,12 @@ def create_db_and_tables():
("game", "completed_crew_1", "BOOLEAN DEFAULT FALSE"),
("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"),
("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"),
("game", "captain_player_id", "VARCHAR"),
("player", "needs_name", "BOOLEAN DEFAULT FALSE"),
("player", "needs_rank_3_bonus", "BOOLEAN DEFAULT FALSE"),
("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
("player", "tax_banned", "BOOLEAN DEFAULT FALSE"),
]
for table, col, def_type in columns:

View File

@@ -60,6 +60,7 @@ def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_sessi
"player": player.model_dump(),
"players": [p.model_dump() for p in game.players],
"obstacles": [o.model_dump() for o in game.obstacles],
"challenges": [c.model_dump() for c in game.challenges],
"votes": [v.model_dump() for v in game.votes],
"events": [e.model_dump() for e in events],
}
@@ -68,12 +69,14 @@ def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_sessi
from .routes_lobby import router as lobby_router
from .routes_character import router as character_router
from .routes_scene import router as scene_router
from .routes_challenge import router as challenge_router
from .routes_upkeep import router as upkeep_router
from .routes_admin import router as admin_router
api.include_router(lobby_router)
api.include_router(character_router)
api.include_router(scene_router)
api.include_router(challenge_router)
api.include_router(upkeep_router)
api.include_router(admin_router)

View File

@@ -7,18 +7,20 @@ class Game(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
crew_name: Optional[str] = Field(default=None)
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep"
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "ended"
current_scene_number: int = Field(default=1)
extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers)
extra_obstacles: int = Field(default=0) # Permanent increase to required obstacles (from Jokers drawn as Obstacles)
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]
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
captain_player_id: Optional[str] = Field(default=None) # Persistent captaincy; set once the crew controls a ship
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True)
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True)
challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True)
class Player(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
@@ -60,6 +62,7 @@ class Player(SQLModel, table=True):
needs_rank_3_bonus: bool = Field(default=False)
is_dead: bool = Field(default=False)
is_ghost: bool = Field(default=False)
tax_banned: bool = Field(default=False) # Failed a refused Gat/Name Tax; cannot initiate one again this scene
# Relationships
game: Optional[Game] = Relationship(back_populates="players")
@@ -85,6 +88,26 @@ class Obstacle(SQLModel, table=True):
except Exception:
return 0
class Challenge(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
game_id: str = Field(foreign_key="game.id", index=True)
challenge_type: str = Field(default="deep") # "deep" (called by Deep Players) or "pvp" (Pi-Rat vs Pi-Rat)
target_player_id: str = Field(...) # The originally challenged Pi-Rat
acting_player_id: str = Field(...) # Who attempts it (changes when a Gat/Name Tax is accepted)
challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it
obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges)
temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle
stakes: str = Field(default="")
plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}]
status: str = Field(default="open") # "open", "succeeded", "failed"
# Gat/Name Tax state
tax_type: Optional[str] = Field(default=None) # "gat" or "name"
tax_target_id: Optional[str] = Field(default=None) # The player asked to take over the challenge
tax_state: Optional[str] = Field(default=None) # "requested", "accepted", "refused"
game: Optional[Game] = Relationship(back_populates="challenges")
class Vote(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
game_id: str = Field(foreign_key="game.id", index=True)

View File

@@ -0,0 +1,110 @@
import json
from fastapi import APIRouter, Form, Depends
from fastapi.responses import JSONResponse
from sqlmodel import Session
from .database import get_session
from . import crud
router = APIRouter()
# Deep calls a Challenge: applies one or more Obstacles to a Pi-Rat
@router.post("/game/{game_id}/player/{player_id}/challenge/create")
def create_challenge_route(
game_id: str,
player_id: str,
target_player_id: str = Form(...),
obstacle_ids: str = Form(...), # JSON list of obstacle ids
stakes: str = Form(""),
db: Session = Depends(get_session)
):
try:
ids = json.loads(obstacle_ids)
assert isinstance(ids, list)
except Exception:
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids, stakes)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Deep resolves an open Challenge
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/resolve")
def resolve_challenge_route(
game_id: str,
player_id: str,
challenge_id: str,
db: Session = Depends(get_session)
):
ok, msg = crud.resolve_challenge(db, challenge_id, player_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok", "message": msg}
# Deep withdraws an open Challenge
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/cancel")
def cancel_challenge_route(
game_id: str,
player_id: str,
challenge_id: str,
db: Session = Depends(get_session)
):
ok, msg = crud.cancel_challenge(db, challenge_id, player_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Challenged Pi-Rat calls a Gat/Name Tax on another Pi-Rat
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/tax/request")
def request_tax_route(
game_id: str,
player_id: str,
challenge_id: str,
target_player_id: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg = crud.request_tax(db, challenge_id, player_id, target_player_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok", "message": msg}
# Taxed Pi-Rat accepts or refuses
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/tax/respond")
def respond_tax_route(
game_id: str,
player_id: str,
challenge_id: str,
accept: str = Form(...), # "true" or "false"
db: Session = Depends(get_session)
):
ok, msg = crud.respond_tax(db, challenge_id, player_id, accept.lower() == "true")
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok", "message": msg}
# Pi-Rat challenges another Pi-Rat, playing a card as a temporary Obstacle
@router.post("/game/{game_id}/player/{player_id}/challenge/pvp/create")
def create_pvp_route(
game_id: str,
player_id: str,
defender_id: str = Form(...),
card_code: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg = crud.create_pvp_challenge(db, game_id, player_id, defender_id, card_code)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Defender plays a card against the temporary Obstacle
@router.post("/game/{game_id}/player/{player_id}/challenge/{challenge_id}/pvp/defend")
def pvp_defend_route(
game_id: str,
player_id: str,
challenge_id: str,
card_code: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg, res = crud.play_pvp_defense(db, challenge_id, player_id, card_code)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok", "details": res["details"], "success": res["success"], "drew_card": res["drew_card"]}

View File

@@ -25,7 +25,7 @@ def player_save_basic_details(
player.avatar_look = avatar_look.strip()
player.avatar_smell = avatar_smell.strip()
player.first_words = first_words.strip()
player.good_at_math = good_at_math
player.good_at_math = good_at_math.strip()
db.add(player)
db.commit()
@@ -125,7 +125,9 @@ def player_submit_techniques(
tech3: str = Form(...),
db: Session = Depends(get_session)
):
crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip())
error = crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip())
if error:
return JSONResponse({"error": error}, status_code=400)
return {"status": "ok"}
# Assign Swapped Techniques to Face Cards (Jack, Queen, King)

View File

@@ -14,6 +14,8 @@ def lobby_start_character_creation(game_id: str, db: Session = Depends(get_sessi
game.phase = "character_creation"
db.add(game)
db.commit()
# The Like/Hate questions involve no player decision, so assign them up front.
crud.auto_delegate_all(db, game)
crud.add_game_event(db, game.id, "Phase changed: Character Creation")
return {"status": "ok"}

View File

@@ -35,7 +35,7 @@ def start_scene_route(game_id: str, db: Session = Depends(get_session)):
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Pi-Rat plays card against an obstacle
# Pi-Rat plays a card against an obstacle that is part of an open Challenge
@router.post("/game/{game_id}/player/{player_id}/play-card")
def play_card_route(
game_id: str,
@@ -44,7 +44,7 @@ def play_card_route(
card_code: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code)
ok, msg, res = crud.play_challenge_card(db, player_id, obstacle_id, card_code)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {
@@ -54,7 +54,7 @@ def play_card_route(
"success": res["success"]
}
# Pi-Rat plays a Joker to replace an obstacle
# Pi-Rat plays a Joker to replace an obstacle (playable at any time)
@router.post("/game/{game_id}/player/{player_id}/play-joker")
def play_joker_route(
game_id: str,
@@ -63,7 +63,7 @@ def play_joker_route(
obstacle_id: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code)
ok, msg = crud.play_joker(db, player_id, obstacle_id, card_code)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok", "message": "Played Joker: Discarded obstacle and drew a new replacement!"}
@@ -133,13 +133,13 @@ def bonus_rank_up_route(
):
player = crud.get_player(db, player_id)
target = crud.get_player(db, target_player_id)
if player and target and player.needs_rank_3_bonus:
target.rank += 1
game = crud.get_game(db, game_id)
if player and target and game and player.needs_rank_3_bonus:
player.needs_rank_3_bonus = False
db.add(target)
db.add(player)
db.commit()
crud.evaluate_hand_sizes(db, game_id)
crud.change_player_rank(db, game, target, 1)
crud.add_game_event(db, game_id, f"{player.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.")
return {"status": "ok"}
# Pi-Rat becomes a ghost
@@ -172,3 +172,31 @@ def clear_obstacle_route(
):
crud.clear_completed_obstacle(db, game_id, obstacle_id)
return {"status": "ok"}
# Deep (or the Captain stepping down) sets or clears the Captain
@router.post("/game/{game_id}/player/{player_id}/set-captain")
def set_captain_route(
game_id: str,
player_id: str,
target_player_id: str = Form(""), # empty string clears the position
db: Session = Depends(get_session)
):
game = crud.get_game(db, game_id)
player = crud.get_player(db, player_id)
if not game or not player:
raise HTTPException(status_code=404, detail="Game or Player not found")
if player.role != "deep" and game.captain_player_id != player.id:
return JSONResponse({"error": "Only the Deep or the current Captain can reassign the Captaincy."}, status_code=403)
new_captain_id = target_player_id.strip() or None
if new_captain_id:
target = crud.get_player(db, new_captain_id)
if not target or target.role == "deep" or target.is_dead:
return JSONResponse({"error": "The Captain must be a living Pi-Rat in the crew."}, status_code=400)
crud.set_captain(db, game, new_captain_id, f"Decided by {player.name}")
return {"status": "ok"}
# End the story (wrap-up screen)
@router.post("/game/{game_id}/finish")
def finish_game_route(game_id: str, db: Session = Depends(get_session)):
crud.finish_game(db, game_id)
return {"status": "ok"}

View File

@@ -15,7 +15,9 @@ def submit_vote_route(
nominated_id: str = Form(...),
db: Session = Depends(get_session)
):
crud.submit_rank_vote(db, game_id, player_id, nominated_id)
ok, msg = crud.submit_rank_vote(db, game_id, player_id, nominated_id)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Player Ready for Next Scene

View File

@@ -104,51 +104,65 @@ def test_character_creation_and_swap(session):
assert "deep" in roles
assert "pirat" in roles
def test_scene_start_and_challenges(session):
def make_scene_game(session, num_pirats=1):
"""Helper: a game in scene phase with 1 Deep player and num_pirats Pi-Rats."""
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
p2 = crud.add_player(session, game.id, "P2")
# Bypass char creation for quick scene test
p1.rank = 3
p1.role = "deep"
p1.previous_role = "deep"
p2.rank = 1
p2.role = "pirat"
p2.previous_role = "pirat"
session.add_all([p1, p2])
deep = crud.add_player(session, game.id, "Deep1", is_creator=True)
deep.rank = 3
deep.role = "deep"
deep.previous_role = "deep"
session.add(deep)
pirats = []
for i in range(num_pirats):
p = crud.add_player(session, game.id, f"Rat{i+1}")
p.rank = 1 if i == 0 else 2
p.role = "pirat"
p.previous_role = "pirat"
session.add(p)
pirats.append(p)
session.commit()
# Confirm setup (1 Deep, 1 Pi-Rat)
success, msg = crud.confirm_scene_setup(session, game.id)
assert success
# Stub shuffle so the rebuilt deck keeps its construction order (Jokers last)
# and no Joker can be drawn as an Obstacle, which would bump extra_obstacles
import random
orig_shuffle = random.shuffle
random.shuffle = lambda x: None
try:
success, msg = crud.confirm_scene_setup(session, game.id)
finally:
random.shuffle = orig_shuffle
assert success, msg
session.refresh(game)
return game, deep, pirats
def test_scene_start_and_challenges(session):
game, deep, (p2,) = make_scene_game(session)
assert game.phase == "scene"
assert len(game.obstacles) == 2 # Deeps (1) + 1 = 2 obstacles
obs_list = game.obstacles
obs = obs_list[0]
obs = game.obstacles[0]
# Give p2 a known hand for testing
p2.hand_cards = json.dumps(["10D", "2H", "JS", "Joker1"])
session.add(p2)
session.commit()
# Play card: 10D (value 10) vs Obstacle. Let's make sure obstacle value is less than 10.
# Set obstacle value to 5
obs.current_value = 5
session.add(obs)
session.add_all([p2, obs])
session.commit()
# Play 10D (red) on obstacle. Let's check original obstacle color.
# If original obstacle was red, we expect a card draw.
orig_is_red = cards.parse_card(obs.original_card)["color"] == "red"
deck_before = len(crud.get_game_deck(game))
# Playing a card without an open Challenge must be rejected
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "10D")
assert not ok
assert "Challenge" in msg
ok, msg, res = crud.play_card_on_obstacle(session, p2.id, obs.id, "10D")
# The Deep calls a Challenge applying the obstacle
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="The cheese is on the line")
assert ok, msg
session.refresh(game)
challenge = game.challenges[0]
assert challenge.status == "open"
assert challenge.acting_player_id == p2.id
orig_is_red = cards.parse_card(obs.original_card)["color"] == "red"
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "10D")
assert ok
assert res["success"]
assert "Success" in res["details"]
@@ -168,27 +182,48 @@ def test_scene_start_and_challenges(session):
assert len(hand) == 3
assert res["drew_card"] is None
def test_secret_technique_auto_success(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p1.role = "pirat"
p1.rank = 2
p1.tech_jack = "Pocket Sand"
p1.hand_cards = json.dumps(["JS"])
session.add(p1)
# The Deep resolves the challenge: at least one success -> succeeded
ok, msg = crud.resolve_challenge(session, challenge.id, deep.id)
assert ok
session.refresh(challenge)
assert challenge.status == "succeeded"
obs = Obstacle(
game_id=game.id,
original_card="10C",
suit="C",
title="Knights",
current_value=10
)
session.add(obs)
def test_assistant_does_not_draw(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
obs = game.obstacles[0]
obs.current_value = 5
# Helper card guaranteed to match the obstacle's color
helper_card = "9D" if cards.parse_card(obs.original_card)["color"] == "red" else "9S"
p3.hand_cards = json.dumps([helper_card])
session.add_all([obs, p3])
session.commit()
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id])
assert ok
session.refresh(game)
challenge = game.challenges[0]
# p3 assists: plays a color-matching card but must NOT draw back
ok, msg, res = crud.play_challenge_card(session, p3.id, obs.id, helper_card)
assert ok
assert res["success"]
assert res["drew_card"] is None
session.refresh(p3)
assert crud.get_player_hand(p3) == []
def test_secret_technique_auto_success(session):
game, deep, (p1,) = make_scene_game(session)
obs = game.obstacles[0]
p1.tech_jack = "Pocket Sand"
p1.hand_cards = json.dumps(["JS"])
obs.current_value = 10
session.add_all([p1, obs])
session.commit()
ok, msg = crud.create_challenge(session, game.id, deep.id, p1.id, [obs.id])
assert ok
# Play Jack. J is a face card -> auto success!
ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "JS")
ok, msg, res = crud.play_challenge_card(session, p1.id, obs.id, "JS")
assert ok
assert res["success"]
assert res["is_technique"]
@@ -220,15 +255,17 @@ def test_joker_play(session):
session.add(obs)
session.commit()
# Play Joker
ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "Joker1")
# Play Joker: discards the obstacle and draws a replacement
ok, msg = crud.play_joker(session, p1.id, obs.id, "Joker1")
assert ok
assert res["is_joker"]
# Verify obstacle was deleted and replaced
session.refresh(game)
assert len(game.obstacles) == 1
assert game.obstacles[0].original_card != "10C"
# Joker left the player's hand
session.refresh(p1)
assert crud.get_player_hand(p1) == []
def test_obstacle_success_count(session):
game = crud.create_game(session)
@@ -249,23 +286,77 @@ def test_obstacle_success_count(session):
assert obs.success_count == 2
def test_non_deep_player_treatment(session):
def test_captaincy_and_hand_sizes(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "Captain Barnaby")
p2 = crud.add_player(session, game.id, "Crewmate Pip")
p3 = crud.add_player(session, game.id, "Lowly Larry")
# p1 is deep, p2 is None (non-deep)
p1.role = "deep"
p2.role = None
session.add_all([p1, p2])
p1.rank = 2
p2.role = "pirat"
p2.rank = 2
p3.role = "pirat"
p3.rank = 1
session.add_all([p1, p2, p3])
session.commit()
# Check captain status: p2 should be captain because p2 is the only non-deep player
assert crud.is_player_captain(p2, game.players)
assert not crud.is_player_captain(p1, game.players)
# No captain until the crew controls a ship
assert game.captain_player_id is None
assert not crud.is_player_captain(p2, game)
# Check hand size: p2 should participate in hand size calculations and get Captain privileges (+1)
assert crud.calculate_max_hand_size(p2, game.players) == 4
# Highest pirat (p2) -> 4 cards, lowest (p3) -> 2 cards
assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 4
assert crud.calculate_max_hand_size(p3, game.players, game.captain_player_id) == 2
# If everyone shares a Rank, they are all 'highest' -> 4 cards
p3.rank = 2
session.add(p3)
session.commit()
assert crud.calculate_max_hand_size(p3, game.players, game.captain_player_id) == 4
# Completing Crew Objective 1 (Steal a Ship) makes the highest-ranked Pi-Rat the Captain
crud.toggle_objective(session, game.id, p1.id, "crew_1", True)
session.refresh(game)
assert game.captain_player_id in (p2.id, p3.id)
captain = crud.get_player(session, game.captain_player_id)
assert crud.is_player_captain(captain, game)
# Captain privilege: +1 hand size
assert crud.calculate_max_hand_size(captain, game.players, game.captain_player_id) == 5
# Losing the ship vacates the Captaincy
crud.toggle_objective(session, game.id, p1.id, "crew_1", False)
session.refresh(game)
assert game.captain_player_id is None
def test_captain_tax_on_failed_challenge(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
# p3 (rank 2) becomes captain
crud.set_captain(session, game, p3.id)
session.refresh(game)
assert game.captain_player_id == p3.id
start_rank = p3.rank
obs = game.obstacles[0]
obs.current_value = 13
p3.hand_cards = json.dumps(["2C"])
session.add_all([obs, p3])
session.commit()
ok, msg = crud.create_challenge(session, game.id, deep.id, p3.id, [obs.id])
assert ok
session.refresh(game)
challenge = [c for c in game.challenges if c.status == "open"][0]
ok, msg, res = crud.play_challenge_card(session, p3.id, obs.id, "2C")
assert ok
assert not res["success"]
ok, msg = crud.resolve_challenge(session, challenge.id, deep.id)
assert ok
session.refresh(p3)
# Captain Tax: lost a rank for personally failing a Challenge
assert p3.rank == start_rank - 1
def test_set_role_endpoint():
from fastapi.testclient import TestClient
@@ -390,20 +481,22 @@ def test_confirm_deep_refresh(session):
session.refresh(p1)
session.refresh(game)
# Hand should not contain 2C and 3C, and should have drawn 5H and 6H
# Hand should not contain 2C and 3C, and should have drawn back up to max
# hand size (4: the only player counts as highest rank)
hand = json.loads(p1.hand_cards)
assert "2C" not in hand
assert "3C" not in hand
assert "4C" in hand
assert "5H" in hand
assert "6H" in hand
assert len(hand) == 3
assert "7H" in hand
assert len(hand) == 4
# Discarded cards should be at the end of the deck
deck = json.loads(game.deck_cards)
assert "2C" in deck
assert "3C" in deck
assert deck == ["7H", "8H", "2C", "3C"]
assert deck == ["8H", "2C", "3C"]
# Since P1 was the only resting deep player and is now ready, phase should have advanced to scene_setup!
assert game.phase == "scene_setup"
@@ -564,8 +657,8 @@ def test_roll_new_character(session):
assert p1.first_words == "No fear!"
assert p1.good_at_math == "Yes"
# Verify rank randomized between 1-3
assert p1.rank in [1, 2, 3]
# New recruits always start at Rank 1
assert p1.rank == 1
# Verify name is "Recruit Spiced Rum"
assert p1.name == "Recruit Spiced Rum"
@@ -588,4 +681,331 @@ def test_roll_new_character(session):
assert "3D" in deck or "3D" in hand
assert "4H" in deck or "4H" in hand
def test_gat_tax_accept(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
obs = game.obstacles[0]
# p2 is gatless; p3 has a gat
p3.completed_personal_1 = True
p2.hand_cards = json.dumps(["2C", "3C"])
p3.hand_cards = json.dumps(["10C"])
session.add_all([p2, p3])
session.commit()
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id])
assert ok
session.refresh(game)
challenge = game.challenges[0]
ok, msg = crud.request_tax(session, challenge.id, p2.id, p3.id)
assert ok, msg
session.refresh(challenge)
assert challenge.tax_state == "requested"
assert challenge.tax_type == "gat"
# Plays are blocked while the tax is pending
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "2C")
assert not ok
ok, msg = crud.respond_tax(session, challenge.id, p3.id, accept=True)
assert ok
session.refresh(challenge)
session.refresh(p2)
session.refresh(p3)
# The gatted pi-rat takes over and received one random card from the requester
assert challenge.acting_player_id == p3.id
assert len(crud.get_player_hand(p2)) == 1
assert len(crud.get_player_hand(p3)) == 2
def test_gat_tax_refuse_and_fail(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
obs = game.obstacles[0]
obs.current_value = 13
p3.completed_personal_1 = True
p2.hand_cards = json.dumps(["2C"])
session.add_all([obs, p2, p3])
session.commit()
p2_start_rank = p2.rank
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id])
assert ok
session.refresh(game)
challenge = game.challenges[0]
ok, msg = crud.request_tax(session, challenge.id, p2.id, p3.id)
assert ok
ok, msg = crud.respond_tax(session, challenge.id, p3.id, accept=False)
assert ok
session.refresh(p2)
session.refresh(p3)
# Refusal: the gat changes paws immediately, requester completes Objective 1 (+1 Rank)
assert p2.completed_personal_1
assert not p3.completed_personal_1
assert p2.rank == p2_start_rank + 1
# The requester attempts the challenge themselves... and fails
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "2C")
assert ok
assert not res["success"]
ok, msg = crud.resolve_challenge(session, challenge.id, deep.id)
assert ok
session.refresh(p2)
session.refresh(p3)
# Failure: the gat goes back, the rank reverts, and no more taxes this scene
assert not p2.completed_personal_1
assert p3.completed_personal_1
assert p2.rank == p2_start_rank
assert p2.tax_banned
def test_pvp_challenge(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
p2.hand_cards = json.dumps(["7C"])
p3.hand_cards = json.dumps(["9C"])
session.add_all([p2, p3])
session.commit()
ok, msg = crud.create_pvp_challenge(session, game.id, p2.id, p3.id, "7C")
assert ok, msg
session.refresh(p2)
assert crud.get_player_hand(p2) == [] # temp obstacle card left the hand
session.refresh(game)
duel = [c for c in game.challenges if c.challenge_type == "pvp"][0]
assert duel.temp_card == "7C"
# Defender beats 7 with 9; matching color (both black) -> draws a card back
ok, msg, res = crud.play_pvp_defense(session, duel.id, p3.id, "9C")
assert ok
assert res["success"]
assert res["drew_card"] is not None
session.refresh(duel)
assert duel.status == "succeeded"
def test_obstacles_persist_across_scenes(session):
game, deep, (p2,) = make_scene_game(session)
assert len(game.obstacles) == 2
surviving_ids = {o.id for o in game.obstacles}
# End the scene, vote, and set up scene 2 with swapped roles
crud.end_scene_and_transition(session, game.id)
crud.transition_to_deep_upkeep(session, game.id)
crud.confirm_deep_refresh(session, deep.id, [])
session.refresh(game)
assert game.phase == "scene_setup"
assert game.current_scene_number == 2
deep.role = "pirat"
p2.role = "deep"
session.add_all([deep, p2])
session.commit()
success, msg = crud.confirm_scene_setup(session, game.id)
assert success, msg
session.refresh(game)
# Unbeaten obstacles remain; the list is already at Deep(1)+1=2, so nothing new is drawn
assert {o.id for o in game.obstacles} == surviving_ids
def test_joker_obstacle_increase_is_permanent(session):
game = crud.create_game(session)
deep = crud.add_player(session, game.id, "Deep1")
deep.rank = 3
deep.role = "deep"
p2 = crud.add_player(session, game.id, "Rat1")
p2.rank = 1
p2.role = "pirat"
game.extra_obstacles = 1 # a Joker was drawn as an Obstacle earlier
session.add_all([game, deep, p2])
session.commit()
# Stub shuffle so the rebuilt deck keeps its construction order (Jokers last)
# and no Joker can be drawn as an Obstacle here
import random
orig_shuffle = random.shuffle
random.shuffle = lambda x: None
try:
success, msg = crud.confirm_scene_setup(session, game.id)
finally:
random.shuffle = orig_shuffle
assert success, msg
session.refresh(game)
# Deep(1) + 1 + permanent increase(1) = 3 obstacles
assert len(game.obstacles) == 3
# The increase is permanent, not consumed
assert game.extra_obstacles == 1
def test_first_scene_role_choice(session):
game = crud.create_game(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")
p1.rank = 3
p2.rank = 1
p3.rank = 2
session.add_all([p1, p2, p3])
session.commit()
# Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either
assert crud.get_allowed_roles(session, game.id, p1.id) == ["deep"]
assert crud.get_allowed_roles(session, game.id, p2.id) == ["pirat"]
assert set(crud.get_allowed_roles(session, game.id, p3.id)) == {"pirat", "deep"}
# A Rank 2 player choosing Deep alongside the Rank 3 player is legal
p1.role = "deep"
p2.role = "pirat"
p3.role = "deep"
session.add_all([p1, p2, p3])
session.commit()
success, msg = crud.confirm_scene_setup(session, game.id)
assert success, msg
def test_vote_validation(session):
game = crud.create_game(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")
p1.role = "deep"
p2.role = "pirat"
p3.role = "pirat"
session.add_all([p1, p2, p3])
session.commit()
# Self-nomination is rejected
ok, msg = crud.submit_rank_vote(session, game.id, p2.id, p2.id)
assert not ok
# Nominating a previous-scene Deep player is rejected
ok, msg = crud.submit_rank_vote(session, game.id, p2.id, p1.id)
assert not ok
# But the Deep player may vote
ok, msg = crud.submit_rank_vote(session, game.id, p1.id, p2.id)
assert ok
def test_rank_up_draws_immediately(session):
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)
hand_before = len(crud.get_player_hand(p2))
crud.toggle_objective(session, game.id, p2.id, "personal_1", True)
session.refresh(p2)
assert p2.rank == 2
# p2 jumped from lowest (2 cards) to highest-tied (4 cards): draws 2 immediately
assert len(crud.get_player_hand(p2)) == hand_before + 2
def test_auto_delegate_all(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
p2 = crud.add_player(session, game.id, "P2")
p3 = crud.add_player(session, game.id, "P3")
session.refresh(game)
crud.auto_delegate_all(session, game)
players = [p1, p2, p3]
for p in players:
session.refresh(p)
assert p.other_like_from_player_id and p.other_like_from_player_id != p.id
assert p.other_hate_from_player_id and p.other_hate_from_player_id != p.id
# With 3+ players, like and hate go to different crewmates
assert p.other_like_from_player_id != p.other_hate_from_player_id
# Workload is even: each player answers exactly one like and one hate
like_answerers = sorted(p.other_like_from_player_id for p in players)
hate_answerers = sorted(p.other_hate_from_player_id for p in players)
assert like_answerers == sorted(p.id for p in players)
assert hate_answerers == sorted(p.id for p in players)
def test_auto_delegate_all_two_players(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
p2 = crud.add_player(session, game.id, "P2")
session.refresh(game)
crud.auto_delegate_all(session, game)
session.refresh(p1)
session.refresh(p2)
assert p1.other_like_from_player_id == p2.id
assert p1.other_hate_from_player_id == p2.id
assert p2.other_like_from_player_id == p1.id
assert p2.other_hate_from_player_id == p1.id
def test_submit_techniques_rejects_collisions(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
p2 = crud.add_player(session, game.id, "P2")
# Duplicates within a single submission are rejected
err = crud.submit_techniques(session, p1.id, "Tail Whip", "tail whip", "Cheese Decoy")
assert err is not None
session.refresh(p1)
assert json.loads(p1.created_techniques) == []
# Valid submission goes through
err = crud.submit_techniques(session, p1.id, "Tail Whip", "Pocket Sand", "Cheese Decoy")
assert err is None
# A second player colliding (case-insensitively) with the pool is rejected
err = crud.submit_techniques(session, p2.id, "POCKET SAND", "Bilge Bomb", "Rat King Roar")
assert err is not None
session.refresh(p2)
assert json.loads(p2.created_techniques) == []
# And a unique set is accepted, triggering the swap
err = crud.submit_techniques(session, p2.id, "Plank Pirouette", "Bilge Bomb", "Rat King Roar")
assert err is None
session.refresh(game)
assert game.phase == "swap_techniques"
def test_roll_new_character_smell_name_normalization(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2")
p1.is_dead = True
session.add(p1)
session.commit()
crud.roll_new_character(
session,
game.id,
p1.id,
avatar_look="Shiny eyes",
avatar_smell="suspiciously smelling of ink and sour lemons",
first_words="No fear!",
good_at_math="Yes"
)
session.refresh(p1)
assert p1.name == "Recruit Ink and sour lemons"
def test_roll_new_character_avoids_technique_collisions(session):
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2")
# Give the surviving player techniques straight from the suggestion pool
p2.tech_jack = cards.TECHNIQUE_SUGGESTIONS[0]
p2.tech_queen = cards.TECHNIQUE_SUGGESTIONS[1]
p2.tech_king = cards.TECHNIQUE_SUGGESTIONS[2]
p1.is_dead = True
session.add_all([p1, p2])
session.commit()
# Shrink the pool so a collision would be guaranteed without the exclusion logic
import pirats.crud_character # noqa: F401 (module uses cards.TECHNIQUE_SUGGESTIONS)
original = cards.TECHNIQUE_SUGGESTIONS
cards.TECHNIQUE_SUGGESTIONS = original[:6]
try:
crud.roll_new_character(
session, game.id, p1.id,
avatar_look="Shiny eyes", avatar_smell="Spiced Rum",
first_words="No fear!", good_at_math="Yes"
)
finally:
cards.TECHNIQUE_SUGGESTIONS = original
session.refresh(p1)
recruit_techs = {p1.tech_jack, p1.tech_queen, p1.tech_king}
assert recruit_techs == set(original[3:6])