Fable will fix it!
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { apiRequest } from '../lib/api';
|
import { apiRequest } from '../lib/api';
|
||||||
import { getSuggestion } from '../lib/suggestions';
|
import { getSuggestion, getSuggestions } from '../lib/suggestions';
|
||||||
|
|
||||||
export let state;
|
export let state;
|
||||||
|
|
||||||
@@ -13,7 +14,6 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let savingBasic = false;
|
let savingBasic = false;
|
||||||
let delegating = false;
|
|
||||||
|
|
||||||
async function saveBasicDetails() {
|
async function saveBasicDetails() {
|
||||||
savingBasic = true;
|
savingBasic = true;
|
||||||
@@ -26,16 +26,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function autoDelegate() {
|
// Like/Hate tasks are assigned automatically when character creation starts.
|
||||||
delegating = true;
|
// This is a fallback for games that entered the phase before assignments existed.
|
||||||
try {
|
onMount(async () => {
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/delegate/auto`, 'POST');
|
if (!delegateComplete && state.players.length >= 2) {
|
||||||
} catch(e) {
|
try {
|
||||||
console.error(e);
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/delegate/auto`, 'POST');
|
||||||
} finally {
|
} catch(e) {
|
||||||
delegating = false;
|
console.error(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// A helper to get player name
|
// A helper to get player name
|
||||||
function getPlayerName(id) {
|
function getPlayerName(id) {
|
||||||
@@ -47,15 +48,17 @@
|
|||||||
// Techniques
|
// Techniques
|
||||||
let tech1 = '', tech2 = '', tech3 = '';
|
let tech1 = '', tech2 = '', tech3 = '';
|
||||||
let savingTechs = false;
|
let savingTechs = false;
|
||||||
|
let techError = '';
|
||||||
|
|
||||||
async function submitTechniques() {
|
async function submitTechniques() {
|
||||||
savingTechs = true;
|
savingTechs = true;
|
||||||
|
techError = '';
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
||||||
tech1, tech2, tech3
|
tech1, tech2, tech3
|
||||||
});
|
});
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error(e);
|
techError = e.message;
|
||||||
} finally {
|
} finally {
|
||||||
savingTechs = false;
|
savingTechs = false;
|
||||||
}
|
}
|
||||||
@@ -82,11 +85,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit delegated answers
|
// Submit delegated answers — one draft per (player, question) so editing one
|
||||||
let answerLike = '', answerHate = '';
|
// task never bleeds into another.
|
||||||
async function submitDelegatedAnswer(type, targetId, answer) {
|
let inboxAnswers = {};
|
||||||
|
async function submitDelegatedAnswer(type, targetId) {
|
||||||
|
const answer = (inboxAnswers[`${targetId}:${type}`] || '').trim();
|
||||||
|
if (!answer) return;
|
||||||
try {
|
try {
|
||||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-delegate/${targetId}/${type}`, 'POST', { answer });
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-delegate/${targetId}/${type}`, 'POST', { answer });
|
||||||
|
delete inboxAnswers[`${targetId}:${type}`];
|
||||||
|
inboxAnswers = inboxAnswers;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
@@ -107,42 +115,40 @@
|
|||||||
async function autoFillAll() {
|
async function autoFillAll() {
|
||||||
autoFilling = true;
|
autoFilling = true;
|
||||||
try {
|
try {
|
||||||
// 1. Basic details
|
// 1. Basic details — drawn from the same pools as the Suggest buttons
|
||||||
if (!basicComplete) {
|
if (!basicComplete) {
|
||||||
basicDetails = {
|
basicDetails = {
|
||||||
avatar_look: 'A scruffy rat',
|
avatar_look: getSuggestion('look'),
|
||||||
avatar_smell: 'Like old cheese',
|
avatar_smell: getSuggestion('smell'),
|
||||||
first_words: 'Give me the loot!',
|
first_words: getSuggestion('first_words'),
|
||||||
good_at_math: 'No'
|
good_at_math: getSuggestion('good_at_math')
|
||||||
};
|
};
|
||||||
await saveBasicDetails();
|
await saveBasicDetails();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Delegate
|
// 2. Answer pending inbox questions with pool suggestions
|
||||||
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.
|
|
||||||
for (const p of state.players) {
|
for (const p of state.players) {
|
||||||
if (p.other_like_from_player_id === state.player.id && !p.other_like) {
|
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) {
|
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) {
|
if (createdTechniques.length === 0) {
|
||||||
tech1 = 'Pocket Sand';
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
tech2 = 'Tail Whip';
|
[tech1, tech2, tech3] = getSuggestions('techniques', 3);
|
||||||
tech3 = 'Cheese Decoy';
|
await submitTechniques();
|
||||||
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.
|
// to process the state, because swapped_techniques comes from the server once everyone is done.
|
||||||
if (!techniquesComplete && swappedTechniques.length === 3) {
|
if (!techniquesComplete && swappedTechniques.length === 3) {
|
||||||
jackTech = swappedTechniques[0];
|
jackTech = swappedTechniques[0];
|
||||||
@@ -235,15 +241,8 @@
|
|||||||
|
|
||||||
<!-- SECTION 2: Delegated Questions (Like/Hate) -->
|
<!-- SECTION 2: Delegated Questions (Like/Hate) -->
|
||||||
<div class="card glass-panel section-delegations">
|
<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>II. Crew Delegations {delegateComplete ? '✅' : '❌'}</h3>
|
||||||
<h3 style="border:none; margin:0; padding:0; color:var(--gold); font-family:var(--font-heading);">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>
|
||||||
{#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>
|
|
||||||
|
|
||||||
<div class="delegation-list">
|
<div class="delegation-list">
|
||||||
{#if delegateComplete}
|
{#if delegateComplete}
|
||||||
@@ -262,7 +261,7 @@
|
|||||||
<div class="author-label">— {getPlayerName(state.player.other_hate_from_player_id)}</div>
|
<div class="author-label">— {getPlayerName(state.player.other_hate_from_player_id)}</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,8 +276,9 @@
|
|||||||
<div class="inbox-item">
|
<div class="inbox-item">
|
||||||
<label>What do you LIKE about {p.name}?</label>
|
<label>What do you LIKE about {p.name}?</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input type="text" class="input-field" bind:value={answerLike} placeholder="e.g. They always share their cheese"/>
|
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/>
|
||||||
<button class="btn btn-primary" on:click={() => submitDelegatedAnswer('like', p.id, answerLike)}>Submit</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -286,8 +286,9 @@
|
|||||||
<div class="inbox-item">
|
<div class="inbox-item">
|
||||||
<label>What do you HATE about {p.name}?</label>
|
<label>What do you HATE about {p.name}?</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input type="text" class="input-field" bind:value={answerHate} placeholder="e.g. They snore too loudly"/>
|
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/>
|
||||||
<button class="btn btn-primary" on:click={() => submitDelegatedAnswer('hate', p.id, answerHate)}>Submit</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -304,26 +305,29 @@
|
|||||||
|
|
||||||
{#if createdTechniques.length === 0}
|
{#if createdTechniques.length === 0}
|
||||||
<p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p>
|
<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">
|
<form on:submit|preventDefault={submitTechniques} id="tech-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="tech1">Technique #1:</label>
|
<label for="tech1">Technique #1:</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
|
<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>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="tech2">Technique #2:</label>
|
<label for="tech2">Technique #2:</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
|
<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>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="tech3">Technique #3:</label>
|
<label for="tech3">Technique #3:</label>
|
||||||
<div class="input-row">
|
<div class="input-row">
|
||||||
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
|
<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
|
// Helpers
|
||||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
$: 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) {
|
async function playCard(obstacleId, cardCode) {
|
||||||
playingCard = true;
|
playingCard = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
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,
|
obstacle_id: obstacleId,
|
||||||
card_code: cardCode
|
card_code: cardCode
|
||||||
});
|
});
|
||||||
// Removed alert
|
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
error = e.message;
|
error = e.message;
|
||||||
} finally {
|
} 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 showEventLog = false;
|
||||||
|
|
||||||
let newNameInput = '';
|
let newNameInput = '';
|
||||||
@@ -85,18 +218,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parsing card helpers
|
// 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) {
|
function getCardDisplay(code) {
|
||||||
if (!code) return "";
|
if (!code) return "";
|
||||||
if (code === 'JOKER') return "🃏 JOKER";
|
if (isJoker(code)) return "🃏 JOKER";
|
||||||
let rank = code.slice(0, -1);
|
let rank = code.slice(0, -1);
|
||||||
let suit = code.slice(-1);
|
let suit = code.slice(-1);
|
||||||
let suitEmoji = { 'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️' }[suit] || suit;
|
let suitEmoji = { 'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️' }[suit] || suit;
|
||||||
@@ -121,13 +245,13 @@
|
|||||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
||||||
<!-- Captain Badge -->
|
<!-- Captain Badge -->
|
||||||
<div>
|
<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;">
|
<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>
|
</span>
|
||||||
{:else}
|
{: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;">
|
<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>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -141,8 +265,8 @@
|
|||||||
🌊 {p.name} (Deep)
|
🌊 {p.name} (Deep)
|
||||||
</span>
|
</span>
|
||||||
{:else if p.role === 'pirat'}
|
{: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);' : ''}">
|
<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.players.reduce((max, p2) => p2.rank > max.rank ? p2 : max, state.players[0])?.id && !p.is_ghost && !p.is_dead ? ' ⭐' : ''}
|
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {p.name} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -154,12 +278,98 @@
|
|||||||
<div class="alert alert-danger">{error}</div>
|
<div class="alert alert-danger">{error}</div>
|
||||||
{/if}
|
{/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}
|
{#each state.obstacles as obs}
|
||||||
{@const column_cards = JSON.parse(obs.played_cards || '[]')}
|
{@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 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 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' : ''}"
|
<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}
|
data-obstacle-id={obs.id}
|
||||||
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
|
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
|
||||||
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
|
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
|
||||||
@@ -170,33 +380,29 @@
|
|||||||
e.currentTarget.classList.remove("drag-over");
|
e.currentTarget.classList.remove("drag-over");
|
||||||
const cardCode = e.dataTransfer.getData('text/plain');
|
const cardCode = e.dataTransfer.getData('text/plain');
|
||||||
if (cardCode) {
|
if (cardCode) {
|
||||||
if (cardCode === 'JOKER') playJoker(obs.id, cardCode);
|
if (isJoker(cardCode)) playJoker(obs.id, cardCode);
|
||||||
else playCard(obs.id, cardCode);
|
else playCard(obs.id, cardCode);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<div class="obstacle-card-wrapper" style="display: flex; justify-content: center; align-items: center;">
|
<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">
|
<div class="card-corner top-left">
|
||||||
<span class="val">{active_card_code === 'JOKER' ? '🃏' : active_card_code.slice(0, -1)}</span>
|
<span class="val">{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="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-center">
|
<div class="card-center">
|
||||||
{#if active_card_code === 'JOKER'}
|
<span class="giant-symbol" style="font-size: 1.5rem;">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||||
🃏
|
|
||||||
{:else}
|
|
||||||
<span class="giant-symbol" style="font-size: 1.5rem;">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-corner bottom-right">
|
<div class="card-corner bottom-right">
|
||||||
<span class="val">{active_card_code === 'JOKER' ? '🃏' : active_card_code.slice(0, -1)}</span>
|
<span class="val">{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="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="obstacle-details">
|
<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>
|
<p class="desc">{obs.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -204,8 +410,8 @@
|
|||||||
<div class="obstacle-value-display text-center">
|
<div class="obstacle-value-display text-center">
|
||||||
<span class="val-label">Current Difficulty</span>
|
<span class="val-label">Current Difficulty</span>
|
||||||
<span class="val-number">
|
<span class="val-number">
|
||||||
{#if ['J', 'Q', 'K'].includes(obs.original_card.slice(0, -1))}
|
{#if ['J', 'Q', 'K'].includes((column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card).slice(0, -1))}
|
||||||
Rank ({state.player.rank})
|
Challenged Rat's Rank
|
||||||
{:else}
|
{:else}
|
||||||
{obs.current_value}
|
{obs.current_value}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -216,7 +422,7 @@
|
|||||||
<div class="obstacle-successes-display text-center">
|
<div class="obstacle-successes-display text-center">
|
||||||
<span class="val-label">Successes</span>
|
<span class="val-label">Successes</span>
|
||||||
<span class="val-number" style="font-size: 1.5rem;">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,8 +437,8 @@
|
|||||||
{#each column_cards as pc}
|
{#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)}">
|
title="{pc.player_name} played {getCardDisplay(pc.card)}">
|
||||||
<span class="val">{pc.card === 'JOKER' ? '🃏' : pc.card.slice(0, -1)}</span>
|
<span class="val">{pc.card.slice(0, -1)}</span>
|
||||||
<span class="suit">{pc.card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]}</span>
|
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]}</span>
|
||||||
<span class="owner">{pc.player_name.slice(0,4)}</span>
|
<span class="owner">{pc.player_name.slice(0,4)}</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -249,19 +455,58 @@
|
|||||||
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<!-- Event Log Removed from inline layout -->
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
||||||
<div class="private-column">
|
<div class="private-column">
|
||||||
<!-- DEEP CONTROLS: End Scene & Objectives -->
|
<!-- DEEP CONTROLS: Challenges, End Scene & Objectives -->
|
||||||
{#if state.player.role === "deep"}
|
{#if state.player.role === "deep"}
|
||||||
<div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;">
|
<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>
|
<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">
|
<div class="sheet-group margin-top">
|
||||||
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
||||||
<div class="objectives-checklist">
|
<div class="objectives-checklist">
|
||||||
@@ -297,7 +542,7 @@
|
|||||||
|
|
||||||
<!-- End Scene Button -->
|
<!-- 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;">
|
<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">
|
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
|
||||||
End Scene & Proceed to Ranking
|
End Scene & Proceed to Ranking
|
||||||
</button>
|
</button>
|
||||||
@@ -308,11 +553,11 @@
|
|||||||
<!-- Player Hand Card -->
|
<!-- Player Hand Card -->
|
||||||
<div class="card glass-panel hand-card">
|
<div class="card glass-panel hand-card">
|
||||||
<h3>🃏 Your Hand</h3>
|
<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">
|
<div class="hand-flex">
|
||||||
{#each hand as card}
|
{#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"}
|
draggable={state.player.role !== "deep"}
|
||||||
on:dragstart={(e) => {
|
on:dragstart={(e) => {
|
||||||
e.dataTransfer.setData('text/plain', card);
|
e.dataTransfer.setData('text/plain', card);
|
||||||
@@ -322,12 +567,12 @@
|
|||||||
e.currentTarget.classList.remove("dragging");
|
e.currentTarget.classList.remove("dragging");
|
||||||
}}>
|
}}>
|
||||||
<div class="card-corner top-left">
|
<div class="card-corner top-left">
|
||||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
|
||||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-center">
|
<div class="card-center">
|
||||||
{#if card === 'JOKER'}
|
{#if isJoker(card)}
|
||||||
🃏
|
🃏
|
||||||
{:else}
|
{:else}
|
||||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
@@ -335,18 +580,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-tech-overlay">
|
<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>
|
<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>
|
<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>
|
<div class="tech-tag" title="Automatic success when played">K: "{state.player.tech_king}"</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-corner bottom-right">
|
<div class="card-corner bottom-right">
|
||||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
|
||||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -386,6 +631,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="sheet-group">
|
||||||
<h4>Description:</h4>
|
<h4>Description:</h4>
|
||||||
<p><strong>Look:</strong> {state.player.avatar_look}</p>
|
<p><strong>Look:</strong> {state.player.avatar_look}</p>
|
||||||
@@ -402,6 +653,27 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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">
|
<div class="sheet-group margin-top">
|
||||||
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
||||||
<div class="objectives-checklist">
|
<div class="objectives-checklist">
|
||||||
|
|||||||
@@ -72,14 +72,14 @@
|
|||||||
<div class="role-restrictions mt-4">
|
<div class="role-restrictions mt-4">
|
||||||
{#if !allowedRoles.includes('pirat')}
|
{#if !allowedRoles.includes('pirat')}
|
||||||
{#if state.game.current_scene_number === 1}
|
{#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}
|
{:else}
|
||||||
<p class="text-red-400 text-sm italic">You are the only eligible player for The Deep this scene!</p>
|
<p class="text-red-400 text-sm italic">You are the only eligible player for The Deep this scene!</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if !allowedRoles.includes('deep')}
|
{#if !allowedRoles.includes('deep')}
|
||||||
{#if state.game.current_scene_number === 1}
|
{#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}
|
{:else}
|
||||||
<p class="text-red-400 text-sm italic">You played The Deep last scene, so you must play a Pi-Rat!</p>
|
<p class="text-red-400 text-sm italic">You played The Deep last scene, so you must play a Pi-Rat!</p>
|
||||||
{/if}
|
{/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() {
|
async function submitReRoll() {
|
||||||
if (!reRollDetails.avatar_look.trim() ||
|
if (!reRollDetails.avatar_look.trim() ||
|
||||||
!reRollDetails.avatar_smell.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;
|
$: isOverLimit = keepCards.length > maxHandSize;
|
||||||
|
|
||||||
async function submitVote() {
|
async function submitVote() {
|
||||||
@@ -180,7 +198,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="info-text" style="margin-top: 1.5rem; font-style: italic; color: var(--text-muted);">
|
<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>
|
</p>
|
||||||
{:else}
|
{: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);">
|
<div class="creation-form-wrapper" style="text-align: left; background: rgba(0,0,0,0.2); padding: 1.5rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.05);">
|
||||||
@@ -234,7 +252,7 @@
|
|||||||
<!-- Ranks and Voting Card -->
|
<!-- Ranks and Voting Card -->
|
||||||
<div class="card glass-panel voting-card">
|
<div class="card glass-panel voting-card">
|
||||||
<h3>1. Pirate Ranking (Voting)</h3>
|
<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'}
|
{#if state.game.phase === 'deep_upkeep'}
|
||||||
<div class="voted-confirmation-box glass-panel">
|
<div class="voted-confirmation-box glass-panel">
|
||||||
@@ -244,10 +262,6 @@
|
|||||||
<div class="voted-confirmation-box glass-panel">
|
<div class="voted-confirmation-box glass-panel">
|
||||||
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
|
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
|
||||||
</div>
|
</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}
|
{:else}
|
||||||
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
|
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
|
||||||
<div class="form-group inline-group">
|
<div class="form-group inline-group">
|
||||||
@@ -270,7 +284,7 @@
|
|||||||
<h4>Nomination Progress:</h4>
|
<h4>Nomination Progress:</h4>
|
||||||
<div class="player-chips list-chips" id="voting-roster">
|
<div class="player-chips list-chips" id="voting-roster">
|
||||||
{#each state.players as p}
|
{#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'}">
|
<div class="player-chip {voted ? 'voted-chip' : 'not-voted-chip'}">
|
||||||
<span class="name">{p.name}</span>
|
<span class="name">{p.name}</span>
|
||||||
<span class="status-badge">{voted ? 'Done' : 'Voting...'}</span>
|
<span class="status-badge">{voted ? 'Done' : 'Voting...'}</span>
|
||||||
@@ -305,26 +319,26 @@
|
|||||||
<h4 class="gold-text">Hand (Keep)</h4>
|
<h4 class="gold-text">Hand (Keep)</h4>
|
||||||
<div class="upkeep-flex">
|
<div class="upkeep-flex">
|
||||||
{#each keepCards as card}
|
{#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"
|
draggable="true"
|
||||||
on:dragstart={(e) => handleDragStart(e, card)}
|
on:dragstart={(e) => handleDragStart(e, card)}
|
||||||
on:click={() => moveToDiscard(card)}
|
on:click={() => moveToDiscard(card)}
|
||||||
style="cursor: pointer;"
|
style="cursor: pointer;"
|
||||||
title="Click or drag to discard">
|
title="Click or drag to discard">
|
||||||
<div class="card-corner top-left">
|
<div class="card-corner top-left">
|
||||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
||||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-center">
|
<div class="card-center">
|
||||||
{#if card === 'JOKER'}
|
{#if card.startsWith('Joker')}
|
||||||
🃏
|
🃏
|
||||||
{:else}
|
{:else}
|
||||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-corner bottom-right">
|
<div class="card-corner bottom-right">
|
||||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
||||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -340,26 +354,26 @@
|
|||||||
<h4 class="gold-text">Discard Pile</h4>
|
<h4 class="gold-text">Discard Pile</h4>
|
||||||
<div class="upkeep-flex">
|
<div class="upkeep-flex">
|
||||||
{#each discardCards as card}
|
{#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"
|
draggable="true"
|
||||||
on:dragstart={(e) => handleDragStart(e, card)}
|
on:dragstart={(e) => handleDragStart(e, card)}
|
||||||
on:click={() => moveToKeep(card)}
|
on:click={() => moveToKeep(card)}
|
||||||
style="cursor: pointer;"
|
style="cursor: pointer;"
|
||||||
title="Click or drag to keep">
|
title="Click or drag to keep">
|
||||||
<div class="card-corner top-left">
|
<div class="card-corner top-left">
|
||||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
||||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-center">
|
<div class="card-center">
|
||||||
{#if card === 'JOKER'}
|
{#if card.startsWith('Joker')}
|
||||||
🃏
|
🃏
|
||||||
{:else}
|
{:else}
|
||||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-corner bottom-right">
|
<div class="card-corner bottom-right">
|
||||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
||||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -414,7 +428,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{: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>
|
<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>
|
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -432,6 +446,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1416,8 +1416,22 @@ const SUGGESTIONS = {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSuggestion(type) {
|
export function getSuggestion(type, exclude = []) {
|
||||||
const arr = SUGGESTIONS[type];
|
let arr = SUGGESTIONS[type];
|
||||||
if (!arr) return "";
|
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)];
|
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 SceneSetupPhase from '../components/SceneSetupPhase.svelte';
|
||||||
import ScenePhase from '../components/ScenePhase.svelte';
|
import ScenePhase from '../components/ScenePhase.svelte';
|
||||||
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
||||||
|
import GameOverPhase from '../components/GameOverPhase.svelte';
|
||||||
|
|
||||||
export let params = {};
|
export let params = {};
|
||||||
let gameId = params.id;
|
let gameId = params.id;
|
||||||
@@ -55,6 +56,8 @@
|
|||||||
<ScenePhase {state} />
|
<ScenePhase {state} />
|
||||||
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
|
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
|
||||||
<UpkeepPhase {state} />
|
<UpkeepPhase {state} />
|
||||||
|
{:else if state.game.phase === 'ended'}
|
||||||
|
<GameOverPhase {state} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card p-4">Unknown phase: {state.game.phase}</div>
|
<div class="card p-4">Unknown phase: {state.game.phase}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -74,8 +74,10 @@ OBSTACLES_DATA = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 20 funny Secret Pirate Technique suggestions for when players need ideas
|
# Secret Pirate Technique suggestions for when players need ideas: 20 handcrafted
|
||||||
TECHNIQUE_SUGGESTIONS = [
|
# 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",
|
"I missed on purpose",
|
||||||
"Carlos will figure it out",
|
"Carlos will figure it out",
|
||||||
"Never trust a hatless captain",
|
"Never trust a hatless captain",
|
||||||
@@ -98,6 +100,26 @@ TECHNIQUE_SUGGESTIONS = [
|
|||||||
"Sudden pirate flip"
|
"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]:
|
def get_fresh_deck() -> List[str]:
|
||||||
"""Generates a shuffled deck of 54 cards (52 standard cards + 2 Jokers)."""
|
"""Generates a shuffled deck of 54 cards (52 standard cards + 2 Jokers)."""
|
||||||
deck = []
|
deck = []
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
from .crud_base import *
|
from .crud_base import *
|
||||||
from .crud_character import *
|
from .crud_character import *
|
||||||
from .crud_scene import *
|
from .crud_scene import *
|
||||||
|
from .crud_challenge import *
|
||||||
from .crud_upkeep 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]):
|
def set_game_deck(game: Game, deck: List[str]):
|
||||||
game.deck_cards = json.dumps(deck)
|
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,
|
Calculates a player's maximum hand size based on their Rank relative to others,
|
||||||
and Captain privileges (+1).
|
plus Captain privileges (+1, regardless of Rank).
|
||||||
Rank Hand sizes:
|
Rank Hand sizes:
|
||||||
- Highest Rank Pi-Rat(s): max 4 cards
|
- Highest Rank Pi-Rat(s): max 4 cards
|
||||||
- Middle Rank Pi-Rat(s): max 3 cards
|
- Middle Rank Pi-Rat(s): max 3 cards
|
||||||
- Lowest Rank Pi-Rat(s): max 2 cards
|
- Lowest Rank Pi-Rat(s): max 2 cards
|
||||||
- Captain gets +1
|
If everyone shares a Rank, they are all 'highest' and get 4 cards.
|
||||||
If all players have the same Rank, they are all 'middle' and get 3 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":
|
if player.role == "deep":
|
||||||
# Deep players still have hands (for when they transition), let's say they have size based on Rank
|
pool = players_in_scene
|
||||||
# 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
|
|
||||||
else:
|
else:
|
||||||
if player.rank == max_rank:
|
pool = [p for p in players_in_scene if p.role != "deep"]
|
||||||
base_size = 4
|
if not pool:
|
||||||
elif player.rank == min_rank:
|
pool = [player]
|
||||||
base_size = 2
|
|
||||||
else:
|
|
||||||
base_size = 3
|
|
||||||
|
|
||||||
# Captain check (highest rank Pi-Rat is de facto Captain)
|
ranks = [p.rank for p in pool]
|
||||||
# The rulebook: 'Initially, the highest-Ranked Pi-Rat becomes the de facto Captain.
|
if player.rank >= max(ranks):
|
||||||
# Captain Privileges: The Captain has their Hand size increased by 1, regardless of their Rank.'
|
base_size = 4
|
||||||
# If there's a tie for highest rank, we'll designate the first one in alphabetical/ID order.
|
elif player.rank <= min(ranks):
|
||||||
highest_ranked_pirats = [p for p in pi_rats if p.rank == max_rank]
|
base_size = 2
|
||||||
highest_ranked_pirats.sort(key=lambda p: p.id)
|
else:
|
||||||
is_captain = len(highest_ranked_pirats) > 0 and highest_ranked_pirats[0].id == player.id
|
base_size = 3
|
||||||
|
|
||||||
if is_captain:
|
if captain_id is not None and player.id == captain_id:
|
||||||
return base_size + 1
|
return base_size + 1
|
||||||
return base_size
|
return base_size
|
||||||
|
|
||||||
def is_player_captain(player: Player, players_in_scene: List[Player]) -> bool:
|
def is_player_captain(player: Player, game: Game) -> bool:
|
||||||
"""Helper to check if a player is currently the captain in the scene."""
|
"""The Captain is a persistent position tracked on the Game (set once the crew steals a ship)."""
|
||||||
if player.role == "deep":
|
return game.captain_player_id is not None and game.captain_player_id == player.id
|
||||||
return False
|
|
||||||
pi_rats = [p for p in players_in_scene if p.role != "deep"]
|
def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict:
|
||||||
if not pi_rats:
|
"""Snapshot every player's max hand size, for detecting increases after a Rank/Captain change."""
|
||||||
return False
|
return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players}
|
||||||
max_rank = max(p.rank for p in pi_rats)
|
|
||||||
highest_ranked = [p for p in pi_rats if p.rank == max_rank]
|
def apply_hand_increases(db: Session, game: Game, old_maxes: dict):
|
||||||
highest_ranked.sort(key=lambda p: p.id)
|
"""
|
||||||
return len(highest_ranked) > 0 and highest_ranked[0].id == player.id
|
'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):
|
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))
|
all_hand_cards.update(get_player_hand(player))
|
||||||
|
|
||||||
# 2. Get active cards on the table
|
# 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()
|
active_table_cards = set()
|
||||||
for obs in game.obstacles:
|
for obs in game.obstacles:
|
||||||
active_table_cards.add(obs.original_card)
|
active_table_cards.add(obs.original_card)
|
||||||
played = json.loads(obs.played_cards)
|
played = json.loads(obs.played_cards)
|
||||||
if played:
|
for entry in played:
|
||||||
active_table_cards.add(played[-1]["card"])
|
active_table_cards.add(entry["card"])
|
||||||
|
|
||||||
# 3. The full deck of 54 cards
|
# 3. The full deck of 54 cards
|
||||||
full_deck = []
|
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 ---
|
# --- 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):
|
def delegate_question(db: Session, player_id: str, question_type: str, target_player_id: str):
|
||||||
player = get_player(db, player_id)
|
player = get_player(db, player_id)
|
||||||
if not player:
|
if not player:
|
||||||
@@ -31,17 +52,40 @@ def submit_delegated_answer(db: Session, target_player_id: str, question_type: s
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3: str):
|
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)
|
player = get_player(db, player_id)
|
||||||
if not player:
|
if not player:
|
||||||
return
|
return None
|
||||||
player.created_techniques = json.dumps([tech1, tech2, tech3])
|
|
||||||
|
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.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Check if all players have submitted techniques. If so, trigger the swap!
|
# Check if all players have submitted techniques. If so, trigger the swap!
|
||||||
game = get_game(db, player.game_id)
|
|
||||||
if not game:
|
if not game:
|
||||||
return
|
return None
|
||||||
|
|
||||||
all_submitted = True
|
all_submitted = True
|
||||||
for p in game.players:
|
for p in game.players:
|
||||||
@@ -52,6 +96,7 @@ def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3
|
|||||||
|
|
||||||
if all_submitted:
|
if all_submitted:
|
||||||
trigger_technique_swap(db, game)
|
trigger_technique_swap(db, game)
|
||||||
|
return None
|
||||||
|
|
||||||
def trigger_technique_swap(db: Session, game: Game):
|
def trigger_technique_swap(db: Session, game: Game):
|
||||||
"""
|
"""
|
||||||
@@ -214,8 +259,8 @@ def roll_new_character(
|
|||||||
set_game_deck(game, deck)
|
set_game_deck(game, deck)
|
||||||
set_player_hand(player, [])
|
set_player_hand(player, [])
|
||||||
|
|
||||||
# 2. Reset properties & randomize starting rank between 1 and 3
|
# 2. Reset properties. New recruits always start at Rank 1.
|
||||||
player.rank = random.randint(1, 3)
|
player.rank = 1
|
||||||
player.is_ready = False
|
player.is_ready = False
|
||||||
player.completed_personal_1 = False
|
player.completed_personal_1 = False
|
||||||
player.completed_personal_2 = False
|
player.completed_personal_2 = False
|
||||||
@@ -224,6 +269,11 @@ def roll_new_character(
|
|||||||
player.needs_rank_3_bonus = False
|
player.needs_rank_3_bonus = False
|
||||||
player.is_dead = False
|
player.is_dead = False
|
||||||
player.is_ghost = 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
|
# Save player-provided details
|
||||||
player.avatar_look = avatar_look.strip()
|
player.avatar_look = avatar_look.strip()
|
||||||
@@ -231,15 +281,38 @@ def roll_new_character(
|
|||||||
player.first_words = first_words.strip()
|
player.first_words = first_words.strip()
|
||||||
player.good_at_math = good_at_math.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()
|
clean_smell = avatar_smell.strip()
|
||||||
if clean_smell.lower().startswith("like "):
|
lead_ins = [
|
||||||
clean_smell = clean_smell[5:]
|
"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}"
|
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
|
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.created_techniques = json.dumps(chosen_techs)
|
||||||
player.swapped_techniques = json.dumps(chosen_techs)
|
player.swapped_techniques = json.dumps(chosen_techs)
|
||||||
player.tech_jack = chosen_techs[0]
|
player.tech_jack = chosen_techs[0]
|
||||||
@@ -271,7 +344,7 @@ def roll_new_character(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 5. Draw cards up to new hand size based on new Rank
|
# 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)
|
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!")
|
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 json
|
||||||
import random
|
import random
|
||||||
from typing import Tuple, Dict, Any
|
from typing import Tuple, Dict, Any, Optional
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
from .models import Game, Player, Obstacle
|
from .models import Game, Player, Obstacle, Challenge
|
||||||
from . import cards
|
from . import cards
|
||||||
from .crud_base import (
|
from .crud_base import (
|
||||||
get_player, get_game, get_player_hand, set_player_hand,
|
get_player, get_game, get_player_hand, set_player_hand,
|
||||||
get_game_deck, set_game_deck, calculate_max_hand_size, draw_cards_for_player,
|
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 ---
|
# --- Scene Setup Operations ---
|
||||||
@@ -24,8 +25,10 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
"""
|
"""
|
||||||
Validates that:
|
Validates that:
|
||||||
1. At least 1 Pi-Rat and 1 Deep player are selected.
|
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.
|
2. First scene: the Rank 3 player must play the Deep, the Rank 1 player must play their Pi-Rat.
|
||||||
If valid, starts the scene: shuffles deck, draws obstacles, draws starting hands, resets ready flags.
|
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)
|
game = get_game(db, game_id)
|
||||||
if not game:
|
if not game:
|
||||||
@@ -41,17 +44,14 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
if game.current_scene_number == 1:
|
if game.current_scene_number == 1:
|
||||||
max_rank = max((p.rank for p in players), default=1)
|
# The Rank 3 starter must play the Deep; the Rank 1 starter must play their Pi-Rat.
|
||||||
highest_ranked_players = [p for p in players if p.rank == max_rank]
|
# Rank 2 players may choose either role.
|
||||||
|
if len(players) >= 2:
|
||||||
for p in players:
|
for p in players:
|
||||||
if p.role == "deep" and p not in highest_ranked_players:
|
if p.rank >= 3 and p.role != "deep":
|
||||||
return False, f"Only the highest ranked player(s) (Rank {max_rank}) can play The Deep in the first scene."
|
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":
|
||||||
if len(highest_ranked_players) == 1:
|
return False, f"{p.name} starts at Rank 1 and must play their Pi-Rat in the first scene."
|
||||||
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."
|
|
||||||
|
|
||||||
# 1. Check previous Deep players
|
# 1. Check previous Deep players
|
||||||
# "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene."
|
# "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene."
|
||||||
@@ -80,50 +80,42 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
return False, "You need at least one Deep player."
|
return False, "You need at least one Deep player."
|
||||||
|
|
||||||
# Everything is valid! Start the scene.
|
# Everything is valid! Start the scene.
|
||||||
# A. Reset obstacles
|
# A. Clear votes and stale challenges. Obstacles persist across scenes.
|
||||||
for obs in game.obstacles:
|
|
||||||
db.delete(obs)
|
|
||||||
for vote in game.votes:
|
for vote in game.votes:
|
||||||
db.delete(vote)
|
db.delete(vote)
|
||||||
|
for challenge in game.challenges:
|
||||||
|
db.delete(challenge)
|
||||||
db.commit()
|
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:
|
for p in players:
|
||||||
p.previous_role = p.role
|
p.previous_role = p.role
|
||||||
p.is_ready = False
|
p.is_ready = False
|
||||||
|
p.tax_banned = False
|
||||||
db.add(p)
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
# C. Shuffle remaining deck (cards not in player hands)
|
# C. Return discards to the deck and shuffle (cards in hands or on the table stay where they are)
|
||||||
all_hand_cards = set()
|
reshuffle_discard_pile(db, game)
|
||||||
for p in players:
|
deck = get_game_deck(game)
|
||||||
all_hand_cards.update(get_player_hand(p))
|
|
||||||
|
|
||||||
full_deck = []
|
# D. Top up the Obstacle List until it holds at least Deep Players + 1 Obstacles,
|
||||||
for suit in cards.SUITS.keys():
|
# plus the permanent increase from Jokers previously drawn as Obstacles.
|
||||||
for val in cards.VALUES:
|
required_obstacles = deeps_count + 1 + game.extra_obstacles
|
||||||
full_deck.append(f"{val}{suit}")
|
db.refresh(game)
|
||||||
full_deck.extend(["Joker1", "Joker2"])
|
active_count = len(game.obstacles)
|
||||||
|
drawn_count = 0
|
||||||
deck = [c for c in full_deck if c not in all_hand_cards]
|
while active_count + drawn_count < required_obstacles:
|
||||||
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:
|
|
||||||
if not deck:
|
if not deck:
|
||||||
break
|
break
|
||||||
card = deck.pop(0)
|
card = deck.pop(0)
|
||||||
parsed = cards.parse_card(card)
|
parsed = cards.parse_card(card)
|
||||||
|
|
||||||
if parsed["is_joker"]:
|
if parsed["is_joker"]:
|
||||||
# Joker drawn as Obstacle:
|
# Joker drawn as an Obstacle: discard it and permanently increase the
|
||||||
# Immediately discard Jokers when they are drawn this way, and increase the total obstacles
|
# number of Obstacles required for following scenes by 1.
|
||||||
game.extra_obstacles += 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
|
continue
|
||||||
|
|
||||||
info = cards.get_obstacle_info(card)
|
info = cards.get_obstacle_info(card)
|
||||||
@@ -137,20 +129,16 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
played_cards="[]"
|
played_cards="[]"
|
||||||
)
|
)
|
||||||
db.add(obstacle)
|
db.add(obstacle)
|
||||||
drawn_obstacles.append(obstacle)
|
drawn_count += 1
|
||||||
|
|
||||||
# E. Top up player hands to their maximum size
|
# E. Deal starting hands in the first scene. In later scenes hands carry over;
|
||||||
for p in players:
|
# players only draw via suit-color matches, hand size increases, and the Deep's upkeep redraw.
|
||||||
max_size = calculate_max_hand_size(p, players)
|
if game.current_scene_number == 1:
|
||||||
current_hand = get_player_hand(p)
|
for p in players:
|
||||||
if len(current_hand) < max_size:
|
max_size = calculate_max_hand_size(p, players, game.captain_player_id)
|
||||||
diff = max_size - len(current_hand)
|
current_hand = get_player_hand(p)
|
||||||
# Draw diff cards
|
while len(current_hand) < max_size and deck:
|
||||||
for _ in range(diff):
|
current_hand.append(deck.pop(0))
|
||||||
if not deck:
|
|
||||||
break
|
|
||||||
card = deck.pop(0)
|
|
||||||
current_hand.append(card)
|
|
||||||
p.hand_cards = json.dumps(current_hand)
|
p.hand_cards = json.dumps(current_hand)
|
||||||
db.add(p)
|
db.add(p)
|
||||||
|
|
||||||
@@ -159,7 +147,8 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
|
|||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
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!"
|
return True, "Scene started successfully!"
|
||||||
|
|
||||||
@@ -173,15 +162,12 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
|
|||||||
allowed = ["pirat", "deep"]
|
allowed = ["pirat", "deep"]
|
||||||
|
|
||||||
if game.current_scene_number == 1:
|
if game.current_scene_number == 1:
|
||||||
max_rank = max((p.rank for p in players), default=1)
|
# Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either.
|
||||||
highest_ranked_players = [p for p in players if p.rank == max_rank]
|
if len(players) >= 2:
|
||||||
|
if player.rank >= 3:
|
||||||
if player not in highest_ranked_players:
|
allowed = ["deep"]
|
||||||
if "deep" in allowed:
|
elif player.rank <= 1:
|
||||||
allowed.remove("deep")
|
allowed = ["pirat"]
|
||||||
elif len(highest_ranked_players) == 1:
|
|
||||||
if "pirat" in allowed:
|
|
||||||
allowed.remove("pirat")
|
|
||||||
elif game.current_scene_number > 1:
|
elif game.current_scene_number > 1:
|
||||||
# Rule 1: Previous Deep must play Pi-Rat (if >= 3 players)
|
# Rule 1: Previous Deep must play Pi-Rat (if >= 3 players)
|
||||||
if len(players) >= 3 and player.previous_role == "deep":
|
if len(players) >= 3 and player.previous_role == "deep":
|
||||||
@@ -198,53 +184,29 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
|
|||||||
|
|
||||||
# --- Scene Gameplay Operations ---
|
# --- Scene Gameplay Operations ---
|
||||||
|
|
||||||
def play_card_on_obstacle(
|
def resolve_card_against_obstacle(
|
||||||
db: Session,
|
db: Session,
|
||||||
player_id: str,
|
game: Game,
|
||||||
obstacle_id: str,
|
player: Player,
|
||||||
card_code: str
|
obstacle: Obstacle,
|
||||||
) -> Tuple[bool, str, Dict[str, Any]]:
|
card_code: str,
|
||||||
|
acting_rank: int
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Plays a card from player's hand against an active obstacle.
|
Resolves one (non-Joker) card from a player against an Obstacle:
|
||||||
Updates the obstacle value, checks matching suit color to draw back,
|
determines success (Secret Pirate Techniques, Gat/Name privileges, face-card
|
||||||
and returns resolution results.
|
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)
|
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
|
success = False
|
||||||
details = ""
|
details = ""
|
||||||
is_technique = False
|
is_technique = False
|
||||||
tech_name = ""
|
tech_name = ""
|
||||||
|
|
||||||
# Value rules:
|
# Face Cards (Jack, Queen, King) played by a Pi-Rat trigger a Secret Pirate Technique: automatic success!
|
||||||
# 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 != "deep":
|
||||||
if played_parsed["value"] in ["J", "Q", "K"] and player.role == "pirat":
|
|
||||||
success = True
|
success = True
|
||||||
is_technique = True
|
is_technique = True
|
||||||
if played_parsed["value"] == "J":
|
if played_parsed["value"] == "J":
|
||||||
@@ -254,30 +216,25 @@ def play_card_on_obstacle(
|
|||||||
elif played_parsed["value"] == "K":
|
elif played_parsed["value"] == "K":
|
||||||
tech_name = player.tech_king or "King Secret Technique"
|
tech_name = player.tech_king or "King Secret Technique"
|
||||||
details = f"Used Secret Pirate Technique: '{tech_name}'!"
|
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:
|
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)
|
obs_val_card = cards.parse_card(obstacle.original_card)
|
||||||
played_on_obs = json.loads(obstacle.played_cards)
|
played_on_obs = json.loads(obstacle.played_cards)
|
||||||
if played_on_obs:
|
if played_on_obs:
|
||||||
obs_val_card = cards.parse_card(played_on_obs[-1]["card"])
|
obs_val_card = cards.parse_card(played_on_obs[-1]["card"])
|
||||||
|
|
||||||
if obs_val_card["value"] in ["J", "Q", "K"]:
|
if obs_val_card["value"] in ["J", "Q", "K"]:
|
||||||
target_value = player.rank
|
target_value = acting_rank
|
||||||
obs_detail = f"Rank {player.rank} (Face Card Obstacle)"
|
obs_detail = f"Rank {acting_rank} (Face Card Obstacle)"
|
||||||
else:
|
else:
|
||||||
target_value = obstacle.current_value
|
target_value = obstacle.current_value
|
||||||
obs_detail = str(target_value)
|
obs_detail = str(target_value)
|
||||||
|
|
||||||
# What is the played card's value?
|
|
||||||
played_val = played_parsed["numeric_value"]
|
played_val = played_parsed["numeric_value"]
|
||||||
|
|
||||||
# Apply privileges:
|
# Gat privilege: Black cards +1. Name privilege: Red cards +1.
|
||||||
privilege_bonus = 0
|
privilege_bonus = 0
|
||||||
if player.completed_personal_1 and played_parsed["suit"] in ["C", "S"]:
|
if player.completed_personal_1 and played_parsed["suit"] in ["C", "S"]:
|
||||||
privilege_bonus = 1
|
privilege_bonus = 1
|
||||||
@@ -286,7 +243,6 @@ def play_card_on_obstacle(
|
|||||||
|
|
||||||
final_played_value = played_val + privilege_bonus
|
final_played_value = played_val + privilege_bonus
|
||||||
|
|
||||||
# Compare
|
|
||||||
if final_played_value > target_value:
|
if final_played_value > target_value:
|
||||||
success = True
|
success = True
|
||||||
details = f"Success! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}."
|
details = f"Success! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}."
|
||||||
@@ -294,17 +250,7 @@ def play_card_on_obstacle(
|
|||||||
success = False
|
success = False
|
||||||
details = f"Failure! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}."
|
details = f"Failure! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}."
|
||||||
|
|
||||||
# 2. Card Draw (Step 6 of Resolving Challenges)
|
# Place the card in the Obstacle's column; it becomes the Obstacle's new value.
|
||||||
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
|
|
||||||
played_list = json.loads(obstacle.played_cards)
|
played_list = json.loads(obstacle.played_cards)
|
||||||
played_list.append({
|
played_list.append({
|
||||||
"card": card_code,
|
"card": card_code,
|
||||||
@@ -314,90 +260,125 @@ def play_card_on_obstacle(
|
|||||||
"details": details
|
"details": details
|
||||||
})
|
})
|
||||||
obstacle.played_cards = json.dumps(played_list)
|
obstacle.played_cards = json.dumps(played_list)
|
||||||
|
obstacle.current_value = played_parsed["numeric_value"]
|
||||||
# The last played card (if not Joker) becomes the obstacle's new value
|
db.add(obstacle)
|
||||||
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)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
event_msg = f"{player.name} played {played_parsed['display']} on '{obstacle.title}'. {details}"
|
return {
|
||||||
add_game_event(db, game.id, event_msg)
|
|
||||||
|
|
||||||
res_dict = {
|
|
||||||
"success": success,
|
"success": success,
|
||||||
"details": details,
|
"details": details,
|
||||||
"is_technique": is_technique,
|
"is_technique": is_technique,
|
||||||
"tech_name": tech_name,
|
"tech_name": tech_name
|
||||||
"drew_card": drew_card,
|
|
||||||
"is_joker": played_parsed["is_joker"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return True, "Card played successfully.", res_dict
|
def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) -> Tuple[bool, str]:
|
||||||
|
|
||||||
def evaluate_hand_sizes(db: Session, game_id: str):
|
|
||||||
"""
|
"""
|
||||||
Recalculates max hand size for all players in the game.
|
Pirate Luck! A Joker may be played at any time to discard one Obstacle (and its
|
||||||
If a player's current hand size is less than their max hand size,
|
column) from the list entirely and draw a new one. The Joker is then discarded.
|
||||||
draws cards to fill it up immediately.
|
|
||||||
"""
|
"""
|
||||||
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:
|
if not game:
|
||||||
return
|
return False, "Game not found."
|
||||||
|
|
||||||
for player in game.players:
|
parsed = cards.parse_card(card_code)
|
||||||
max_size = calculate_max_hand_size(player, game.players)
|
if not parsed["is_joker"]:
|
||||||
current_hand = get_player_hand(player)
|
return False, "That card is not a Joker."
|
||||||
if len(current_hand) < max_size:
|
|
||||||
draw_cards_for_player(db, game, player, max_size - len(current_hand))
|
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):
|
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_"):
|
if obj_type.startswith("crew_"):
|
||||||
game = get_game(db, game_id)
|
game = get_game(db, game_id)
|
||||||
if not game: return
|
if not game: return
|
||||||
if obj_type == "crew_1": game.completed_crew_1 = status
|
if obj_type == "crew_1":
|
||||||
elif obj_type == "crew_2": game.completed_crew_2 = status
|
game.completed_crew_1 = status
|
||||||
elif obj_type == "crew_3": game.completed_crew_3 = status
|
db.add(game)
|
||||||
db.add(game)
|
db.commit()
|
||||||
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
|
return
|
||||||
|
|
||||||
player = get_player(db, target_id)
|
player = get_player(db, target_id)
|
||||||
if not player:
|
if not player:
|
||||||
return
|
return
|
||||||
|
game = get_game(db, game_id)
|
||||||
|
if not game:
|
||||||
|
return
|
||||||
|
|
||||||
rank_diff = 0
|
rank_diff = 0
|
||||||
if obj_type == "personal_1":
|
if obj_type == "personal_1":
|
||||||
@@ -425,13 +406,15 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
|
|||||||
player.is_dead = False
|
player.is_dead = False
|
||||||
player.completed_personal_3 = status
|
player.completed_personal_3 = status
|
||||||
|
|
||||||
player.rank += rank_diff
|
|
||||||
player.rank = max(1, player.rank)
|
|
||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
if rank_diff != 0:
|
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()
|
obj_name = obj_type.replace('_', ' ').title()
|
||||||
status_text = "completed" if status else "unmarked"
|
status_text = "completed" if status else "unmarked"
|
||||||
@@ -460,9 +443,23 @@ def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]:
|
|||||||
obstacle.played_cards = json.dumps(played_list)
|
obstacle.played_cards = json.dumps(played_list)
|
||||||
db.add(obstacle)
|
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)
|
player = get_player(db, player_id)
|
||||||
if player:
|
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 = get_player_hand(player)
|
||||||
hand.append(card_code)
|
hand.append(card_code)
|
||||||
set_player_hand(player, hand)
|
set_player_hand(player, hand)
|
||||||
@@ -477,3 +474,13 @@ def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str):
|
|||||||
add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.")
|
add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.")
|
||||||
db.delete(obstacle)
|
db.delete(obstacle)
|
||||||
db.commit()
|
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 json
|
||||||
import random
|
import random
|
||||||
from typing import List
|
from typing import List, Tuple
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from .models import Game, Player, Vote
|
from .models import Game, Player, Vote
|
||||||
from .crud_base import (
|
from .crud_base import (
|
||||||
get_player, get_game, get_player_hand, set_player_hand,
|
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 ---
|
# --- 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
|
# Remove any existing vote by this voter for this game
|
||||||
existing = db.exec(
|
existing = db.exec(
|
||||||
select(Vote).where(Vote.game_id == game_id, Vote.voter_player_id == voter_id)
|
select(Vote).where(Vote.game_id == game_id, Vote.voter_player_id == voter_id)
|
||||||
@@ -26,6 +40,7 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
|
|||||||
)
|
)
|
||||||
db.add(vote)
|
db.add(vote)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
return True, "Vote submitted."
|
||||||
|
|
||||||
def end_scene_and_transition(db: Session, game_id: str):
|
def end_scene_and_transition(db: Session, game_id: str):
|
||||||
"""Moves game phase to between_scenes."""
|
"""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_id = random.choice(winners)
|
||||||
winner = get_player(db, winner_id)
|
winner = get_player(db, winner_id)
|
||||||
if winner:
|
if winner:
|
||||||
winner.rank += 1
|
change_player_rank(db, game, winner, 1)
|
||||||
db.add(winner)
|
|
||||||
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.")
|
add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.")
|
||||||
|
|
||||||
# Clear all votes
|
# Clear all votes
|
||||||
@@ -132,9 +146,8 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
|
|||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# 2. Draw cards back up to max size
|
# 2. Draw cards back up to max size (based on Rank, per the hand size table)
|
||||||
# Max size for deep player is Rank + 1
|
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
|
||||||
max_size = player.rank + 1
|
|
||||||
if len(hand) < max_size:
|
if len(hand) < max_size:
|
||||||
diff = max_size - len(hand)
|
diff = max_size - len(hand)
|
||||||
draw_cards_for_player(db, game, player, diff)
|
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_1", "BOOLEAN DEFAULT FALSE"),
|
||||||
("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"),
|
("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"),
|
||||||
("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"),
|
("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
("game", "captain_player_id", "VARCHAR"),
|
||||||
("player", "needs_name", "BOOLEAN DEFAULT FALSE"),
|
("player", "needs_name", "BOOLEAN DEFAULT FALSE"),
|
||||||
("player", "needs_rank_3_bonus", "BOOLEAN DEFAULT FALSE"),
|
("player", "needs_rank_3_bonus", "BOOLEAN DEFAULT FALSE"),
|
||||||
("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
|
("player", "is_dead", "BOOLEAN DEFAULT FALSE"),
|
||||||
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
|
("player", "is_ghost", "BOOLEAN DEFAULT FALSE"),
|
||||||
|
("player", "tax_banned", "BOOLEAN DEFAULT FALSE"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for table, col, def_type in columns:
|
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(),
|
"player": player.model_dump(),
|
||||||
"players": [p.model_dump() for p in game.players],
|
"players": [p.model_dump() for p in game.players],
|
||||||
"obstacles": [o.model_dump() for o in game.obstacles],
|
"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],
|
"votes": [v.model_dump() for v in game.votes],
|
||||||
"events": [e.model_dump() for e in events],
|
"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_lobby import router as lobby_router
|
||||||
from .routes_character import router as character_router
|
from .routes_character import router as character_router
|
||||||
from .routes_scene import router as scene_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_upkeep import router as upkeep_router
|
||||||
from .routes_admin import router as admin_router
|
from .routes_admin import router as admin_router
|
||||||
|
|
||||||
api.include_router(lobby_router)
|
api.include_router(lobby_router)
|
||||||
api.include_router(character_router)
|
api.include_router(character_router)
|
||||||
api.include_router(scene_router)
|
api.include_router(scene_router)
|
||||||
|
api.include_router(challenge_router)
|
||||||
api.include_router(upkeep_router)
|
api.include_router(upkeep_router)
|
||||||
api.include_router(admin_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)
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||||
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
crew_name: Optional[str] = Field(default=None)
|
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)
|
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", ...]
|
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_1: bool = Field(default=False) # Steal a Ship
|
||||||
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
completed_crew_2: bool = Field(default=False) # Choose a Captain
|
||||||
completed_crew_3: bool = Field(default=False) # Commit Piracy
|
completed_crew_3: bool = Field(default=False) # Commit Piracy
|
||||||
|
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)
|
players: List["Player"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
obstacles: List["Obstacle"] = 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)
|
votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True)
|
||||||
events: List["GameEvent"] = 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):
|
class Player(SQLModel, table=True):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=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)
|
needs_rank_3_bonus: bool = Field(default=False)
|
||||||
is_dead: bool = Field(default=False)
|
is_dead: bool = Field(default=False)
|
||||||
is_ghost: 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
|
# Relationships
|
||||||
game: Optional[Game] = Relationship(back_populates="players")
|
game: Optional[Game] = Relationship(back_populates="players")
|
||||||
@@ -85,6 +88,26 @@ class Obstacle(SQLModel, table=True):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return 0
|
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):
|
class Vote(SQLModel, table=True):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
|
||||||
game_id: str = Field(foreign_key="game.id", index=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_look = avatar_look.strip()
|
||||||
player.avatar_smell = avatar_smell.strip()
|
player.avatar_smell = avatar_smell.strip()
|
||||||
player.first_words = first_words.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.add(player)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -125,7 +125,9 @@ def player_submit_techniques(
|
|||||||
tech3: str = Form(...),
|
tech3: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
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"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Assign Swapped Techniques to Face Cards (Jack, Queen, King)
|
# 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"
|
game.phase = "character_creation"
|
||||||
db.add(game)
|
db.add(game)
|
||||||
db.commit()
|
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")
|
crud.add_game_event(db, game.id, "Phase changed: Character Creation")
|
||||||
return {"status": "ok"}
|
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 JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok"}
|
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")
|
@router.post("/game/{game_id}/player/{player_id}/play-card")
|
||||||
def play_card_route(
|
def play_card_route(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
@@ -44,7 +44,7 @@ def play_card_route(
|
|||||||
card_code: str = Form(...),
|
card_code: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
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:
|
if not ok:
|
||||||
return JSONResponse({"error": msg}, status_code=400)
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {
|
return {
|
||||||
@@ -54,7 +54,7 @@ def play_card_route(
|
|||||||
"success": res["success"]
|
"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")
|
@router.post("/game/{game_id}/player/{player_id}/play-joker")
|
||||||
def play_joker_route(
|
def play_joker_route(
|
||||||
game_id: str,
|
game_id: str,
|
||||||
@@ -63,7 +63,7 @@ def play_joker_route(
|
|||||||
obstacle_id: str = Form(...),
|
obstacle_id: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
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:
|
if not ok:
|
||||||
return JSONResponse({"error": msg}, status_code=400)
|
return JSONResponse({"error": msg}, status_code=400)
|
||||||
return {"status": "ok", "message": "Played Joker: Discarded obstacle and drew a new replacement!"}
|
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)
|
player = crud.get_player(db, player_id)
|
||||||
target = crud.get_player(db, target_player_id)
|
target = crud.get_player(db, target_player_id)
|
||||||
if player and target and player.needs_rank_3_bonus:
|
game = crud.get_game(db, game_id)
|
||||||
target.rank += 1
|
if player and target and game and player.needs_rank_3_bonus:
|
||||||
player.needs_rank_3_bonus = False
|
player.needs_rank_3_bonus = False
|
||||||
db.add(target)
|
|
||||||
db.add(player)
|
db.add(player)
|
||||||
db.commit()
|
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"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Pi-Rat becomes a ghost
|
# Pi-Rat becomes a ghost
|
||||||
@@ -172,3 +172,31 @@ def clear_obstacle_route(
|
|||||||
):
|
):
|
||||||
crud.clear_completed_obstacle(db, game_id, obstacle_id)
|
crud.clear_completed_obstacle(db, game_id, obstacle_id)
|
||||||
return {"status": "ok"}
|
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(...),
|
nominated_id: str = Form(...),
|
||||||
db: Session = Depends(get_session)
|
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"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
# Player Ready for Next Scene
|
# Player Ready for Next Scene
|
||||||
|
|||||||
@@ -104,51 +104,65 @@ def test_character_creation_and_swap(session):
|
|||||||
assert "deep" in roles
|
assert "deep" in roles
|
||||||
assert "pirat" 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)
|
game = crud.create_game(session)
|
||||||
p1 = crud.add_player(session, game.id, "P1", is_creator=True)
|
deep = crud.add_player(session, game.id, "Deep1", is_creator=True)
|
||||||
p2 = crud.add_player(session, game.id, "P2")
|
deep.rank = 3
|
||||||
|
deep.role = "deep"
|
||||||
# Bypass char creation for quick scene test
|
deep.previous_role = "deep"
|
||||||
p1.rank = 3
|
session.add(deep)
|
||||||
p1.role = "deep"
|
pirats = []
|
||||||
p1.previous_role = "deep"
|
for i in range(num_pirats):
|
||||||
|
p = crud.add_player(session, game.id, f"Rat{i+1}")
|
||||||
p2.rank = 1
|
p.rank = 1 if i == 0 else 2
|
||||||
p2.role = "pirat"
|
p.role = "pirat"
|
||||||
p2.previous_role = "pirat"
|
p.previous_role = "pirat"
|
||||||
|
session.add(p)
|
||||||
session.add_all([p1, p2])
|
pirats.append(p)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
# Stub shuffle so the rebuilt deck keeps its construction order (Jokers last)
|
||||||
# Confirm setup (1 Deep, 1 Pi-Rat)
|
# and no Joker can be drawn as an Obstacle, which would bump extra_obstacles
|
||||||
success, msg = crud.confirm_scene_setup(session, game.id)
|
import random
|
||||||
assert success
|
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)
|
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 game.phase == "scene"
|
||||||
assert len(game.obstacles) == 2 # Deeps (1) + 1 = 2 obstacles
|
assert len(game.obstacles) == 2 # Deeps (1) + 1 = 2 obstacles
|
||||||
|
|
||||||
obs_list = game.obstacles
|
obs = game.obstacles[0]
|
||||||
obs = obs_list[0]
|
|
||||||
|
|
||||||
# Give p2 a known hand for testing
|
# Give p2 a known hand for testing
|
||||||
p2.hand_cards = json.dumps(["10D", "2H", "JS", "Joker1"])
|
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
|
obs.current_value = 5
|
||||||
session.add(obs)
|
session.add_all([p2, obs])
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
# Play 10D (red) on obstacle. Let's check original obstacle color.
|
# Playing a card without an open Challenge must be rejected
|
||||||
# If original obstacle was red, we expect a card draw.
|
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "10D")
|
||||||
orig_is_red = cards.parse_card(obs.original_card)["color"] == "red"
|
assert not ok
|
||||||
deck_before = len(crud.get_game_deck(game))
|
assert "Challenge" in msg
|
||||||
|
|
||||||
ok, msg, res = crud.play_card_on_obstacle(session, p2.id, obs.id, "10D")
|
# The Deep calls a Challenge applying the obstacle
|
||||||
|
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="The cheese is on the line")
|
||||||
|
assert ok, msg
|
||||||
|
session.refresh(game)
|
||||||
|
challenge = game.challenges[0]
|
||||||
|
assert challenge.status == "open"
|
||||||
|
assert challenge.acting_player_id == p2.id
|
||||||
|
|
||||||
|
orig_is_red = cards.parse_card(obs.original_card)["color"] == "red"
|
||||||
|
|
||||||
|
ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "10D")
|
||||||
assert ok
|
assert ok
|
||||||
assert res["success"]
|
assert res["success"]
|
||||||
assert "Success" in res["details"]
|
assert "Success" in res["details"]
|
||||||
@@ -168,27 +182,48 @@ def test_scene_start_and_challenges(session):
|
|||||||
assert len(hand) == 3
|
assert len(hand) == 3
|
||||||
assert res["drew_card"] is None
|
assert res["drew_card"] is None
|
||||||
|
|
||||||
def test_secret_technique_auto_success(session):
|
# The Deep resolves the challenge: at least one success -> succeeded
|
||||||
game = crud.create_game(session)
|
ok, msg = crud.resolve_challenge(session, challenge.id, deep.id)
|
||||||
p1 = crud.add_player(session, game.id, "P1")
|
assert ok
|
||||||
p1.role = "pirat"
|
session.refresh(challenge)
|
||||||
p1.rank = 2
|
assert challenge.status == "succeeded"
|
||||||
p1.tech_jack = "Pocket Sand"
|
|
||||||
p1.hand_cards = json.dumps(["JS"])
|
|
||||||
session.add(p1)
|
|
||||||
|
|
||||||
obs = Obstacle(
|
def test_assistant_does_not_draw(session):
|
||||||
game_id=game.id,
|
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
|
||||||
original_card="10C",
|
obs = game.obstacles[0]
|
||||||
suit="C",
|
obs.current_value = 5
|
||||||
title="Knights",
|
# Helper card guaranteed to match the obstacle's color
|
||||||
current_value=10
|
helper_card = "9D" if cards.parse_card(obs.original_card)["color"] == "red" else "9S"
|
||||||
)
|
p3.hand_cards = json.dumps([helper_card])
|
||||||
session.add(obs)
|
session.add_all([obs, p3])
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id])
|
||||||
|
assert ok
|
||||||
|
session.refresh(game)
|
||||||
|
challenge = game.challenges[0]
|
||||||
|
|
||||||
|
# p3 assists: plays a color-matching card but must NOT draw back
|
||||||
|
ok, msg, res = crud.play_challenge_card(session, p3.id, obs.id, helper_card)
|
||||||
|
assert ok
|
||||||
|
assert res["success"]
|
||||||
|
assert res["drew_card"] is None
|
||||||
|
session.refresh(p3)
|
||||||
|
assert crud.get_player_hand(p3) == []
|
||||||
|
|
||||||
|
def test_secret_technique_auto_success(session):
|
||||||
|
game, deep, (p1,) = make_scene_game(session)
|
||||||
|
obs = game.obstacles[0]
|
||||||
|
p1.tech_jack = "Pocket Sand"
|
||||||
|
p1.hand_cards = json.dumps(["JS"])
|
||||||
|
obs.current_value = 10
|
||||||
|
session.add_all([p1, obs])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
ok, msg = crud.create_challenge(session, game.id, deep.id, p1.id, [obs.id])
|
||||||
|
assert ok
|
||||||
# Play Jack. J is a face card -> auto success!
|
# 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 ok
|
||||||
assert res["success"]
|
assert res["success"]
|
||||||
assert res["is_technique"]
|
assert res["is_technique"]
|
||||||
@@ -220,15 +255,17 @@ def test_joker_play(session):
|
|||||||
session.add(obs)
|
session.add(obs)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
# Play Joker
|
# Play Joker: discards the obstacle and draws a replacement
|
||||||
ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "Joker1")
|
ok, msg = crud.play_joker(session, p1.id, obs.id, "Joker1")
|
||||||
assert ok
|
assert ok
|
||||||
assert res["is_joker"]
|
|
||||||
|
|
||||||
# Verify obstacle was deleted and replaced
|
# Verify obstacle was deleted and replaced
|
||||||
session.refresh(game)
|
session.refresh(game)
|
||||||
assert len(game.obstacles) == 1
|
assert len(game.obstacles) == 1
|
||||||
assert game.obstacles[0].original_card != "10C"
|
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):
|
def test_obstacle_success_count(session):
|
||||||
game = crud.create_game(session)
|
game = crud.create_game(session)
|
||||||
@@ -249,23 +286,77 @@ def test_obstacle_success_count(session):
|
|||||||
|
|
||||||
assert obs.success_count == 2
|
assert obs.success_count == 2
|
||||||
|
|
||||||
def test_non_deep_player_treatment(session):
|
def test_captaincy_and_hand_sizes(session):
|
||||||
game = crud.create_game(session)
|
game = crud.create_game(session)
|
||||||
p1 = crud.add_player(session, game.id, "Captain Barnaby")
|
p1 = crud.add_player(session, game.id, "Captain Barnaby")
|
||||||
p2 = crud.add_player(session, game.id, "Crewmate Pip")
|
p2 = crud.add_player(session, game.id, "Crewmate Pip")
|
||||||
|
p3 = crud.add_player(session, game.id, "Lowly Larry")
|
||||||
|
|
||||||
# p1 is deep, p2 is None (non-deep)
|
|
||||||
p1.role = "deep"
|
p1.role = "deep"
|
||||||
p2.role = None
|
p1.rank = 2
|
||||||
session.add_all([p1, p2])
|
p2.role = "pirat"
|
||||||
|
p2.rank = 2
|
||||||
|
p3.role = "pirat"
|
||||||
|
p3.rank = 1
|
||||||
|
session.add_all([p1, p2, p3])
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
# Check captain status: p2 should be captain because p2 is the only non-deep player
|
# No captain until the crew controls a ship
|
||||||
assert crud.is_player_captain(p2, game.players)
|
assert game.captain_player_id is None
|
||||||
assert not crud.is_player_captain(p1, game.players)
|
assert not crud.is_player_captain(p2, game)
|
||||||
|
|
||||||
# Check hand size: p2 should participate in hand size calculations and get Captain privileges (+1)
|
# Highest pirat (p2) -> 4 cards, lowest (p3) -> 2 cards
|
||||||
assert crud.calculate_max_hand_size(p2, game.players) == 4
|
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():
|
def test_set_role_endpoint():
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -390,20 +481,22 @@ def test_confirm_deep_refresh(session):
|
|||||||
session.refresh(p1)
|
session.refresh(p1)
|
||||||
session.refresh(game)
|
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)
|
hand = json.loads(p1.hand_cards)
|
||||||
assert "2C" not in hand
|
assert "2C" not in hand
|
||||||
assert "3C" not in hand
|
assert "3C" not in hand
|
||||||
assert "4C" in hand
|
assert "4C" in hand
|
||||||
assert "5H" in hand
|
assert "5H" in hand
|
||||||
assert "6H" 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
|
# Discarded cards should be at the end of the deck
|
||||||
deck = json.loads(game.deck_cards)
|
deck = json.loads(game.deck_cards)
|
||||||
assert "2C" in deck
|
assert "2C" in deck
|
||||||
assert "3C" 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!
|
# Since P1 was the only resting deep player and is now ready, phase should have advanced to scene_setup!
|
||||||
assert game.phase == "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.first_words == "No fear!"
|
||||||
assert p1.good_at_math == "Yes"
|
assert p1.good_at_math == "Yes"
|
||||||
|
|
||||||
# Verify rank randomized between 1-3
|
# New recruits always start at Rank 1
|
||||||
assert p1.rank in [1, 2, 3]
|
assert p1.rank == 1
|
||||||
|
|
||||||
# Verify name is "Recruit Spiced Rum"
|
# Verify name is "Recruit Spiced Rum"
|
||||||
assert p1.name == "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 "3D" in deck or "3D" in hand
|
||||||
assert "4H" in deck or "4H" 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