Fable will fix it!
This commit is contained in:
@@ -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,16 +241,9 @@
|
||||
|
||||
<!-- 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}
|
||||
<div class="delegation-box glass-panel">
|
||||
@@ -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>
|
||||
|
||||
50
frontend/src/components/GameOverPhase.svelte
Normal file
50
frontend/src/components/GameOverPhase.svelte
Normal 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>
|
||||
@@ -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;
|
||||
@@ -107,31 +231,31 @@
|
||||
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
||||
<!-- LEFT COLUMN: The Shared Table -->
|
||||
<div class="table-column">
|
||||
|
||||
|
||||
<!-- Game Deck and Obstacles Roster -->
|
||||
<div class="card glass-panel obstacle-list-card">
|
||||
<div class="card-header">
|
||||
<h3>🌊 The Obstacle List</h3>
|
||||
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="obstacles-container" id="scene-obstacles-container">
|
||||
<!-- TOP STATUS BAR: Scene Info and Captain -->
|
||||
<div class="scene-status-banner glass-panel" style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(7, 11, 18, 0.6); border: 1px solid rgba(212, 175, 55, 0.2); border-radius: 8px;">
|
||||
<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>
|
||||
|
||||
|
||||
<!-- Active Scene Roster Mini-list -->
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-heading); margin-right: 0.25rem;">Active Roster:</span>
|
||||
@@ -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,56 +380,52 @@
|
||||
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>
|
||||
|
||||
|
||||
<!-- Value Display -->
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Successes Tracker -->
|
||||
<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>
|
||||
|
||||
|
||||
<!-- Played Cards Column -->
|
||||
<div class="played-column">
|
||||
<h5>Card History (Column)</h5>
|
||||
@@ -229,16 +435,16 @@
|
||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[obs.original_card.slice(-1)]}</span>
|
||||
</div>
|
||||
{#each column_cards as pc}
|
||||
<div class="card-mini played-card rotated {pc.success ? 'success' : 'failure'}"
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{#if is_completed && state.player.role === 'deep'}
|
||||
<div class="text-center" style="margin-top: 1rem;">
|
||||
<button class="btn btn-success" on:click={() => clearObstacle(obs.id)} style="width: 100%; border: 1px solid rgba(255,255,255,0.2);">Clear Obstacle</button>
|
||||
@@ -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">
|
||||
@@ -294,10 +539,10 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 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,31 +567,31 @@
|
||||
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>
|
||||
{/if}
|
||||
</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}
|
||||
@@ -354,12 +599,12 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Player Character Sheet Card -->
|
||||
{#if state.player.role !== "deep"}
|
||||
<div class="card glass-panel sheet-card">
|
||||
<h3>🐀 {state.player.name}'s Character Sheet</h3>
|
||||
|
||||
|
||||
<div class="character-sheet-details">
|
||||
{#if state.player.is_dead}
|
||||
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.1); border: 1px dashed var(--red-suit); padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
||||
@@ -386,13 +631,19 @@
|
||||
</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>
|
||||
<p><strong>Smell:</strong> {state.player.avatar_smell}</p>
|
||||
<p><strong>First Words:</strong> "{state.player.first_words}"</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
||||
<ul class="techniques-list-sheet">
|
||||
@@ -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">
|
||||
@@ -448,7 +720,7 @@
|
||||
<button class="toggle-log-btn" on:click={() => showEventLog = !showEventLog}>
|
||||
{showEventLog ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||
</button>
|
||||
|
||||
|
||||
{#if showEventLog}
|
||||
<div class="log-content font-mono text-sm space-y-2 p-2">
|
||||
{#each state.events as event}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,8 +252,8 @@
|
||||
<!-- 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">
|
||||
<p class="success-text">✔️ Voting complete! Results tallied.</p>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
from .crud_base import *
|
||||
from .crud_character import *
|
||||
from .crud_scene import *
|
||||
from .crud_challenge import *
|
||||
from .crud_upkeep import *
|
||||
|
||||
@@ -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
|
||||
|
||||
# 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
|
||||
|
||||
if is_captain:
|
||||
pool = [p for p in players_in_scene if p.role != "deep"]
|
||||
if not pool:
|
||||
pool = [player]
|
||||
|
||||
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 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 = []
|
||||
|
||||
505
src/pirats/crud_challenge.py
Normal file
505
src/pirats/crud_challenge.py
Normal 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
|
||||
}
|
||||
@@ -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,27 +52,51 @@ 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:
|
||||
techs = json.loads(p.created_techniques)
|
||||
if len(techs) < 3 or not all(techs):
|
||||
all_submitted = False
|
||||
break
|
||||
|
||||
|
||||
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!")
|
||||
|
||||
@@ -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,15 +25,17 @@ 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:
|
||||
return False, "Game not found."
|
||||
|
||||
|
||||
players = game.players
|
||||
|
||||
|
||||
# Default any unselected roles to 'pirat'
|
||||
for p in players:
|
||||
if p.role is None:
|
||||
@@ -41,18 +44,15 @@ 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."
|
||||
# Note: Only enforce if this is scene 2+, and we have enough players to make a rotation.
|
||||
@@ -73,59 +73,51 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
||||
# 3. Check counts
|
||||
pirats_count = sum(1 for p in players if p.role == "pirat")
|
||||
deeps_count = sum(1 for p in players if p.role == "deep")
|
||||
|
||||
|
||||
if pirats_count < 1:
|
||||
return False, "You need at least one Pi-Rat player to play."
|
||||
if deeps_count < 1:
|
||||
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)
|
||||
|
||||
# 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))
|
||||
|
||||
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:
|
||||
db.commit()
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
obstacle = Obstacle(
|
||||
game_id=game.id,
|
||||
@@ -137,30 +129,27 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
||||
played_cards="[]"
|
||||
)
|
||||
db.add(obstacle)
|
||||
drawn_obstacles.append(obstacle)
|
||||
|
||||
# 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)
|
||||
drawn_count += 1
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
game.deck_cards = json.dumps(deck)
|
||||
game.phase = "scene"
|
||||
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!"
|
||||
|
||||
def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
|
||||
@@ -168,83 +157,56 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
|
||||
player = get_player(db, player_id)
|
||||
if not game or not player:
|
||||
return []
|
||||
|
||||
|
||||
players = game.players
|
||||
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":
|
||||
if "deep" in allowed:
|
||||
allowed.remove("deep")
|
||||
|
||||
|
||||
# Rule 2: If only 1 eligible Deep, they MUST play Deep
|
||||
eligible_deeps = [p for p in players if p.previous_role != "deep"]
|
||||
if len(eligible_deeps) == 1 and eligible_deeps[0].id == player_id:
|
||||
if "pirat" in allowed:
|
||||
allowed.remove("pirat")
|
||||
|
||||
|
||||
return allowed
|
||||
|
||||
# --- Scene Gameplay Operations ---
|
||||
|
||||
def play_card_on_obstacle(
|
||||
db: Session,
|
||||
player_id: str,
|
||||
obstacle_id: str,
|
||||
card_code: str
|
||||
) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
def resolve_card_against_obstacle(
|
||||
db: Session,
|
||||
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,57 +216,41 @@ 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
|
||||
if player.completed_personal_2 and played_parsed["suit"] in ["H", "D"]:
|
||||
privilege_bonus = 1
|
||||
|
||||
|
||||
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}."
|
||||
else:
|
||||
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,91 +260,126 @@ 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
|
||||
|
||||
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))
|
||||
return False, "Game not found."
|
||||
|
||||
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":
|
||||
if status and not player.completed_personal_1:
|
||||
@@ -406,7 +387,7 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
||||
elif not status and player.completed_personal_1:
|
||||
rank_diff = -1
|
||||
player.completed_personal_1 = status
|
||||
|
||||
|
||||
elif obj_type == "personal_2":
|
||||
if status and not player.completed_personal_2:
|
||||
rank_diff = 1
|
||||
@@ -415,7 +396,7 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
||||
rank_diff = -1
|
||||
player.needs_name = False
|
||||
player.completed_personal_2 = status
|
||||
|
||||
|
||||
elif obj_type == "personal_3":
|
||||
if status and not player.completed_personal_3:
|
||||
player.needs_rank_3_bonus = True
|
||||
@@ -424,15 +405,17 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
||||
player.needs_rank_3_bonus = False
|
||||
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"
|
||||
add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.")
|
||||
@@ -441,33 +424,47 @@ def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]:
|
||||
obstacle = db.get(Obstacle, obstacle_id)
|
||||
if not obstacle:
|
||||
return False, "Obstacle not found."
|
||||
|
||||
|
||||
played_list = json.loads(obstacle.played_cards)
|
||||
if not played_list:
|
||||
return False, "No cards to roll back."
|
||||
|
||||
|
||||
last_play = played_list.pop()
|
||||
card_code = last_play["card"]
|
||||
player_id = last_play["player_id"]
|
||||
|
||||
|
||||
# Update obstacle's value
|
||||
if played_list:
|
||||
prev_card_code = played_list[-1]["card"]
|
||||
obstacle.current_value = cards.parse_card(prev_card_code)["numeric_value"]
|
||||
else:
|
||||
obstacle.current_value = cards.get_obstacle_info(obstacle.original_card)["initial_value"]
|
||||
|
||||
|
||||
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)
|
||||
db.add(player)
|
||||
|
||||
|
||||
db.commit()
|
||||
return True, "Rolled back the last played card."
|
||||
|
||||
@@ -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. 🎉")
|
||||
|
||||
@@ -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)
|
||||
@@ -18,7 +32,7 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
|
||||
for v in existing:
|
||||
db.delete(v)
|
||||
db.commit()
|
||||
|
||||
|
||||
vote = Vote(
|
||||
game_id=game_id,
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
110
src/pirats/routes_challenge.py
Normal file
110
src/pirats/routes_challenge.py
Normal 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"]}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -104,60 +104,74 @@ 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.
|
||||
|
||||
# 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
|
||||
|
||||
# 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"
|
||||
deck_before = len(crud.get_game_deck(game))
|
||||
|
||||
ok, msg, res = crud.play_card_on_obstacle(session, p2.id, obs.id, "10D")
|
||||
|
||||
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "10D")
|
||||
assert ok
|
||||
assert res["success"]
|
||||
assert "Success" in res["details"]
|
||||
|
||||
|
||||
session.refresh(p2)
|
||||
session.refresh(obs)
|
||||
# Obstacle current value should update to 10
|
||||
assert obs.current_value == 10
|
||||
|
||||
|
||||
# Check if hand size matches card draw rule
|
||||
# 10D was played (-1 card). If colors matched, drew 1 back (+1 card).
|
||||
hand = crud.get_player_hand(p2)
|
||||
@@ -168,27 +182,48 @@ def test_scene_start_and_challenges(session):
|
||||
assert len(hand) == 3
|
||||
assert res["drew_card"] is None
|
||||
|
||||
# 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"
|
||||
|
||||
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 = crud.create_game(session)
|
||||
p1 = crud.add_player(session, game.id, "P1")
|
||||
p1.role = "pirat"
|
||||
p1.rank = 2
|
||||
game, deep, (p1,) = make_scene_game(session)
|
||||
obs = game.obstacles[0]
|
||||
p1.tech_jack = "Pocket Sand"
|
||||
p1.hand_cards = json.dumps(["JS"])
|
||||
session.add(p1)
|
||||
|
||||
obs = Obstacle(
|
||||
game_id=game.id,
|
||||
original_card="10C",
|
||||
suit="C",
|
||||
title="Knights",
|
||||
current_value=10
|
||||
)
|
||||
session.add(obs)
|
||||
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"]
|
||||
@@ -204,12 +239,12 @@ def test_joker_play(session):
|
||||
deck.remove("10C")
|
||||
game.deck_cards = json.dumps(deck)
|
||||
session.add(game)
|
||||
|
||||
|
||||
p1 = crud.add_player(session, game.id, "P1")
|
||||
p1.role = "pirat"
|
||||
p1.hand_cards = json.dumps(["Joker1"])
|
||||
session.add(p1)
|
||||
|
||||
|
||||
obs = Obstacle(
|
||||
game_id=game.id,
|
||||
original_card="10C",
|
||||
@@ -219,16 +254,18 @@ 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")
|
||||
|
||||
# p1 is deep, p2 is None (non-deep)
|
||||
p3 = crud.add_player(session, game.id, "Lowly Larry")
|
||||
|
||||
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)
|
||||
|
||||
# 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
|
||||
|
||||
# No captain until the crew controls a ship
|
||||
assert game.captain_player_id is None
|
||||
assert not crud.is_player_captain(p2, game)
|
||||
|
||||
# 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
|
||||
@@ -389,22 +480,24 @@ 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])
|
||||
|
||||
Reference in New Issue
Block a user