diff --git a/frontend/src/components/CharacterCreationPhase.svelte b/frontend/src/components/CharacterCreationPhase.svelte index bc34773..172d1ac 100644 --- a/frontend/src/components/CharacterCreationPhase.svelte +++ b/frontend/src/components/CharacterCreationPhase.svelte @@ -1,6 +1,7 @@ + +
+
+

🎉 The Story Has Ended!

+

+ 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! +

+ +
+

Crew Objectives

+
+ + + +
+ {#if crewDone} +

A complete pirate legend! ⭐

+ {:else} +

Some deeds were left undone... a tale for the next crew.

+ {/if} +
+ +
+

The Crew

+ {#each pirats as p} +
+ + {#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} + {p.name} (Rank {p.rank}) + {#if p.id === state.game.captain_player_id} ⭐ Captain{/if} + + + {p.completed_personal_1 ? '🔫 Gat' : '— no gat'} · + {p.completed_personal_2 ? '📛 Named' : '— nameless'} · + {p.completed_personal_3 ? '⚰️ Story complete' : '⛵ Still sailing'} + +
+ {/each} +
+ +

Want more? Get the full game "The Magical Land of Yeld" at YeldStuff.com!

+
+
diff --git a/frontend/src/components/ScenePhase.svelte b/frontend/src/components/ScenePhase.svelte index 702f491..40f59d2 100644 --- a/frontend/src/components/ScenePhase.svelte +++ b/frontend/src/components/ScenePhase.svelte @@ -8,18 +8,32 @@ // Helpers $: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : []; - $: isCaptain = state.players.reduce((max, p) => p.rank > max.rank ? p : max, state.players[0])?.id === state.player.id; + $: captain = state.players.find(p => p.id === state.game.captain_player_id) || null; + $: isCaptain = state.game.captain_player_id === state.player.id; + $: challenges = state.challenges || []; + $: openChallenges = challenges.filter(c => c.status === 'open'); + $: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]'))); + $: myOpenChallenge = openChallenges.find(c => c.acting_player_id === state.player.id) || null; + $: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null; + $: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null; + $: scenePirats = state.players.filter(p => p.role === 'pirat'); + + function playerName(id) { + return state.players.find(p => p.id === id)?.name || '???'; + } + + function isJoker(code) { + return !!code && code.startsWith('Joker'); + } async function playCard(obstacleId, cardCode) { playingCard = true; error = ''; try { - const res = await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', { + await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', { obstacle_id: obstacleId, card_code: cardCode }); - // Removed alert - } catch(e) { error = e.message; } finally { @@ -68,6 +82,125 @@ } } + // --- Challenge controls (Deep) --- + let challengeTargetId = ''; + let challengeStakes = ''; + let selectedObstacles = {}; + + async function createChallenge() { + error = ''; + const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]); + try { + await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/create`, 'POST', { + target_player_id: challengeTargetId, + obstacle_ids: JSON.stringify(ids), + stakes: challengeStakes + }); + challengeTargetId = ''; + challengeStakes = ''; + selectedObstacles = {}; + } catch(e) { + error = e.message; + } + } + + async function resolveChallenge(challengeId) { + error = ''; + try { + await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/resolve`, 'POST'); + } catch(e) { + error = e.message; + } + } + + async function cancelChallenge(challengeId) { + error = ''; + try { + await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/cancel`, 'POST'); + } catch(e) { + error = e.message; + } + } + + // --- Captain controls (Deep / current Captain) --- + let captainSelectId = ''; + async function setCaptain() { + error = ''; + try { + await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-captain`, 'POST', { + target_player_id: captainSelectId + }); + captainSelectId = ''; + } catch(e) { + error = e.message; + } + } + + // --- Gat/Name Tax --- + let taxTargetId = ''; + function taxType(player) { + if (!player.completed_personal_1) return 'Gat'; + if (!player.completed_personal_2) return 'Name'; + return null; + } + function eligibleTaxTargets(challenge) { + const me = state.player; + const type = taxType(me); + if (!type) return []; + return scenePirats.filter(p => + p.id !== me.id && !p.is_dead && + (type === 'Gat' ? p.completed_personal_1 : p.completed_personal_2) + ); + } + async function requestTax(challengeId) { + error = ''; + try { + await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/request`, 'POST', { + target_player_id: taxTargetId + }); + taxTargetId = ''; + } catch(e) { + error = e.message; + } + } + async function respondTax(challengeId, accept) { + error = ''; + try { + await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/respond`, 'POST', { + accept: accept ? 'true' : 'false' + }); + } catch(e) { + error = e.message; + } + } + + // --- Pi-Rat vs Pi-Rat duels --- + let pvpOpponentId = ''; + let pvpCard = ''; + async function createPvp() { + error = ''; + try { + await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', { + defender_id: pvpOpponentId, + card_code: pvpCard + }); + pvpOpponentId = ''; + pvpCard = ''; + } catch(e) { + error = e.message; + } + } + async function pvpDefend(challengeId, cardCode) { + error = ''; + try { + await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/pvp/defend`, 'POST', { + card_code: cardCode + }); + } catch(e) { + error = e.message; + } + } + let showEventLog = false; let newNameInput = ''; @@ -85,18 +218,9 @@ } // Parsing card helpers - function getCardValue(code) { - if (!code) return 0; - if (code === 'JOKER') return 0; - let rank = code.slice(0, -1); - if (['J', 'Q', 'K'].includes(rank)) return 10; - if (rank === 'A') return 1; - return parseInt(rank); - } - function getCardDisplay(code) { if (!code) return ""; - if (code === 'JOKER') return "🃏 JOKER"; + if (isJoker(code)) return "🃏 JOKER"; let rank = code.slice(0, -1); let suit = code.slice(-1); let suitEmoji = { 'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️' }[suit] || suit; @@ -107,31 +231,31 @@
- +

🌊 The Obstacle List

🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left
- +
- {#if isCaptain} + {#if captain} - 🏴‍☠️ 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!' : ''} {:else} - 🏴‍☠️ 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!)'} {/if}
- +
Active Roster: @@ -141,8 +265,8 @@ 🌊 {p.name} (Deep) {:else if p.role === 'pirat'} - - {#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 ? ' ⭐' : ''} {/if} {/each} @@ -154,12 +278,98 @@
{error}
{/if} + + {#each openChallenges as ch} + {@const chPlays = JSON.parse(ch.plays || '[]')} + {@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')} +
+
+

+ {#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} +

+ {#if state.player.role === 'deep' && ch.challenge_type === 'deep'} +
+ + +
+ {/if} +
+ {#if ch.stakes} +

Stakes: {ch.stakes}

+ {/if} + {#if ch.challenge_type === 'pvp'} +

+ Temporary Obstacle: {getCardDisplay(ch.temp_card)} — {playerName(ch.acting_player_id)} must answer it! +

+ {:else} +

+ Applied Obstacles: {chObstacleIds.map(oid => state.obstacles.find(o => o.id === oid)?.title || '(discarded)').join(', ') || 'None left'} + — attempted by {playerName(ch.acting_player_id)}{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). +

+ {/if} + {#if chPlays.length > 0} +

+ Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')} +

+ {/if} + + + {#if ch.tax_state === 'requested'} + {#if myTaxRequest && myTaxRequest.id === ch.id} +
+

{playerName(ch.target_player_id)} calls a {ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax on you: take this Challenge for them!

+

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!

+
+ + +
+
+ {:else} +

⏳ Waiting for {playerName(ch.tax_target_id)} to answer the {ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax...

+ {/if} + {/if} + + + {#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} +
+ No {taxType(state.player)}? Make it someone else's problem: + + +
+ {/if} + + + {#if myPvpDefense && myPvpDefense.id === ch.id} +
+

Defend yourself! Pick a card:

+
+ {#each hand.filter(c => !isJoker(c)) as card} + + {/each} +
+
+ {/if} +
+ {/each} + {#each state.obstacles as obs} {@const column_cards = JSON.parse(obs.played_cards || '[]')} {@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card} {@const success_count = column_cards.filter(c => c.success).length} - {@const is_completed = success_count >= state.players.filter(p => p.role !== 'deep').length} + {@const is_completed = success_count >= state.players.length} + {@const in_challenge = challengedObstacleIds.has(obs.id)}
{ if (!is_completed) e.preventDefault(); }} on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }} @@ -170,56 +380,52 @@ e.currentTarget.classList.remove("drag-over"); const cardCode = e.dataTransfer.getData('text/plain'); if (cardCode) { - if (cardCode === 'JOKER') playJoker(obs.id, cardCode); + if (isJoker(cardCode)) playJoker(obs.id, cardCode); else playCard(obs.id, cardCode); } }}>
-
+
- {active_card_code === 'JOKER' ? '🃏' : active_card_code.slice(0, -1)} - {active_card_code === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]} + {active_card_code.slice(0, -1)} + {{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}
- +
- {#if active_card_code === 'JOKER'} - 🃏 - {:else} - {{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]} - {/if} + {{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}
- +
- {active_card_code === 'JOKER' ? '🃏' : active_card_code.slice(0, -1)} - {active_card_code === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]} + {active_card_code.slice(0, -1)} + {{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}
-

{obs.title}

+

{obs.title} {#if in_challenge}⚔️ In Challenge{/if}

{obs.description}

- +
Current Difficulty - {#if ['J', 'Q', 'K'].includes(obs.original_card.slice(0, -1))} - Rank ({state.player.rank}) + {#if ['J', 'Q', 'K'].includes((column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card).slice(0, -1))} + Challenged Rat's Rank {:else} {obs.current_value} {/if}
- +
Successes - {success_count} / {state.players.filter(p => p.role !== 'deep').length} + {success_count} / {state.players.length}
- +
Card History (Column)
@@ -229,16 +435,16 @@ {{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[obs.original_card.slice(-1)]}
{#each column_cards as pc} -
- {pc.card === 'JOKER' ? '🃏' : pc.card.slice(0, -1)} - {pc.card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]} + {pc.card.slice(0, -1)} + {{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]} {pc.player_name.slice(0,4)}
{/each}
- + {#if is_completed && state.player.role === 'deep'}
@@ -249,19 +455,58 @@

No active obstacles. Deep players can end the scene!

{/each} - -
- +
- + {#if state.player.role === "deep"}

🌊 Deep Control Panel

- + + +
+

⚔️ Call a Challenge

+

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.

+ +
+ {#each state.obstacles as obs} + {@const taken = challengedObstacleIds.has(obs.id)} + + {/each} +
+ + +
+ + +
+

🏴‍☠️ Captaincy

+

The Captain holds power until they step down, lose a duel, die, or the ship is lost. Reassign after a leadership duel or resignation.

+
+ + +
+
+

Crew Objectives

@@ -294,10 +539,10 @@
{/each}
- +
-

Conclude the scene once players have made attempts or the obstacle list is depleted.

+

End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.

@@ -308,11 +553,11 @@

🃏 Your Hand

-

Keep your cards secret! {#if state.player.role !== "deep"}Drag a card onto an active obstacle to play it.{/if}

- +

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}

+
{#each hand as card} -
{ e.dataTransfer.setData('text/plain', card); @@ -322,31 +567,31 @@ e.currentTarget.classList.remove("dragging"); }}>
- {card === 'JOKER' ? '🃏' : card.slice(0, -1)} - {card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]} + {isJoker(card) ? '🃏' : card.slice(0, -1)} + {isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}
- +
- {#if card === 'JOKER'} + {#if isJoker(card)} 🃏 {:else} {{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]} {/if}
- +
- {#if card.slice(0, -1) === "J"} + {#if !isJoker(card) && card.slice(0, -1) === "J"}
J: "{state.player.tech_jack}"
- {:else if card.slice(0, -1) === "Q"} + {:else if !isJoker(card) && card.slice(0, -1) === "Q"}
Q: "{state.player.tech_queen}"
- {:else if card.slice(0, -1) === "K"} + {:else if !isJoker(card) && card.slice(0, -1) === "K"}
K: "{state.player.tech_king}"
{/if}
- +
- {card === 'JOKER' ? '🃏' : card.slice(0, -1)} - {card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]} + {isJoker(card) ? '🃏' : card.slice(0, -1)} + {isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}
{:else} @@ -354,12 +599,12 @@ {/each}
- + {#if state.player.role !== "deep"}

🐀 {state.player.name}'s Character Sheet

- +
{#if state.player.is_dead}
@@ -386,13 +631,19 @@
{/if} + {#if state.player.tax_banned} +
+

🚫 You failed a refused Tax — no more Gat/Name Taxes for you this scene.

+
+ {/if} +

Description:

Look: {state.player.avatar_look}

Smell: {state.player.avatar_smell}

First Words: "{state.player.first_words}"

- +

Assigned Secret Techniques (J/Q/K):

    @@ -402,6 +653,27 @@
+ + {#if !state.player.is_dead && scenePirats.filter(p => p.id !== state.player.id && !p.is_dead).length > 0} +
+

⚔️ Duel a Pi-Rat

+

Challenge a crewmate (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.

+ + + +
+ {/if} +

Crew Objectives

@@ -448,7 +720,7 @@ - + {#if showEventLog}
{#each state.events as event} diff --git a/frontend/src/components/SceneSetupPhase.svelte b/frontend/src/components/SceneSetupPhase.svelte index 74b1739..9370a31 100644 --- a/frontend/src/components/SceneSetupPhase.svelte +++ b/frontend/src/components/SceneSetupPhase.svelte @@ -72,14 +72,14 @@
{#if !allowedRoles.includes('pirat')} {#if state.game.current_scene_number === 1} -

You are the highest ranked player, so you must play The Deep for the first scene!

+

You start at Rank 3, so you must play The Deep for the first scene!

{:else}

You are the only eligible player for The Deep this scene!

{/if} {/if} {#if !allowedRoles.includes('deep')} {#if state.game.current_scene_number === 1} -

Only the highest ranked player(s) can play The Deep in the first scene!

+

You start at Rank 1, so you must play your Pi-Rat for the first scene!

{:else}

You played The Deep last scene, so you must play a Pi-Rat!

{/if} diff --git a/frontend/src/components/UpkeepPhase.svelte b/frontend/src/components/UpkeepPhase.svelte index e41bc80..4e999bd 100644 --- a/frontend/src/components/UpkeepPhase.svelte +++ b/frontend/src/components/UpkeepPhase.svelte @@ -36,6 +36,15 @@ } } + async function finishGame() { + if (!confirm('End the story for everyone? Best saved for when every player has played the Deep, each Pi-Rat has completed a Personal Objective, and all 3 Crew Objectives are done.')) return; + try { + await apiRequest(`/game/${state.game.id}/finish`, 'POST'); + } catch(e) { + console.error(e); + } + } + async function submitReRoll() { if (!reRollDetails.avatar_look.trim() || !reRollDetails.avatar_smell.trim() || @@ -115,7 +124,16 @@ } } - $: maxHandSize = state.player.rank + 1; + // Mirrors the backend hand size table: highest rank -> 4, lowest -> 2, middle -> 3, Captain +1. + // Deep players are ranked against all players. + $: maxHandSize = (() => { + const ranks = state.players.map(p => p.rank); + let base; + if (state.player.rank >= Math.max(...ranks)) base = 4; + else if (state.player.rank <= Math.min(...ranks)) base = 2; + else base = 3; + return base + (state.game.captain_player_id === state.player.id ? 1 : 0); + })(); $: isOverLimit = keepCards.length > maxHandSize; async function submitVote() { @@ -180,7 +198,7 @@

- 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.

{:else}
@@ -234,8 +252,8 @@

1. Pirate Ranking (Voting)

-

Nominate one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.

- +

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.

+ {#if state.game.phase === 'deep_upkeep'}

✔️ Voting complete! Results tallied.

@@ -244,10 +262,6 @@

✔️ Vote submitted! Waiting for other players to submit their nominations.

- {:else if state.player.role === 'deep'} -
-

The Deep does not vote.

-
{:else}
@@ -270,7 +284,7 @@

Nomination Progress:

{#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)}
{p.name} {voted ? 'Done' : 'Voting...'} @@ -305,26 +319,26 @@

Hand (Keep)

{#each keepCards as card} -
handleDragStart(e, card)} on:click={() => moveToDiscard(card)} style="cursor: pointer;" title="Click or drag to discard">
- {card === 'JOKER' ? '🃏' : card.slice(0, -1)} - {card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]} + {card.startsWith('Joker') ? '🃏' : card.slice(0, -1)} + {card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}
- {#if card === 'JOKER'} + {#if card.startsWith('Joker')} 🃏 {:else} {{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]} {/if}
- {card === 'JOKER' ? '🃏' : card.slice(0, -1)} - {card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]} + {card.startsWith('Joker') ? '🃏' : card.slice(0, -1)} + {card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}
{:else} @@ -340,26 +354,26 @@

Discard Pile

{#each discardCards as card} -
handleDragStart(e, card)} on:click={() => moveToKeep(card)} style="cursor: pointer;" title="Click or drag to keep">
- {card === 'JOKER' ? '🃏' : card.slice(0, -1)} - {card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]} + {card.startsWith('Joker') ? '🃏' : card.slice(0, -1)} + {card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}
- {#if card === 'JOKER'} + {#if card.startsWith('Joker')} 🃏 {:else} {{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]} {/if}
- {card === 'JOKER' ? '🃏' : card.slice(0, -1)} - {card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]} + {card.startsWith('Joker') ? '🃏' : card.slice(0, -1)} + {card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}
{:else} @@ -414,7 +428,7 @@
{/if} {:else} - {#if state.player.role === 'pirat' && !state.votes.find(v => v.voter_player_id === state.player.id)} + {#if !state.votes.find(v => v.voter_player_id === state.player.id)}

⚠️ You must nominate a crewmate above before you can ready up.

{:else} @@ -432,6 +446,13 @@ {/if} {/if} {/if} + + {#if state.player.is_creator && state.game.phase === 'between_scenes'} +
+

All good stories end. When the crew agrees the legend is complete:

+ +
+ {/if}
{/if}
diff --git a/frontend/src/lib/suggestions.js b/frontend/src/lib/suggestions.js index 5eac957..f6c71e1 100644 --- a/frontend/src/lib/suggestions.js +++ b/frontend/src/lib/suggestions.js @@ -1416,8 +1416,22 @@ const SUGGESTIONS = { ] }; -export function getSuggestion(type) { - const arr = SUGGESTIONS[type]; +export function getSuggestion(type, exclude = []) { + let arr = SUGGESTIONS[type]; if (!arr) return ""; + const taken = new Set(exclude.map(s => s.trim().toLowerCase())); + const available = arr.filter(s => !taken.has(s.trim().toLowerCase())); + if (available.length > 0) arr = available; return arr[Math.floor(Math.random() * arr.length)]; } + +// Draws `count` distinct suggestions from a pool. +export function getSuggestions(type, count) { + const picked = []; + for (let i = 0; i < count; i++) { + const s = getSuggestion(type, picked); + if (!s) break; + picked.push(s); + } + return picked; +} diff --git a/frontend/src/pages/Dashboard.svelte b/frontend/src/pages/Dashboard.svelte index 775a1f4..f321824 100644 --- a/frontend/src/pages/Dashboard.svelte +++ b/frontend/src/pages/Dashboard.svelte @@ -7,6 +7,7 @@ import SceneSetupPhase from '../components/SceneSetupPhase.svelte'; import ScenePhase from '../components/ScenePhase.svelte'; import UpkeepPhase from '../components/UpkeepPhase.svelte'; + import GameOverPhase from '../components/GameOverPhase.svelte'; export let params = {}; let gameId = params.id; @@ -55,6 +56,8 @@ {:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'} + {:else if state.game.phase === 'ended'} + {:else}
Unknown phase: {state.game.phase}
{/if} diff --git a/src/pirats/cards.py b/src/pirats/cards.py index b7694d8..ea72a24 100644 --- a/src/pirats/cards.py +++ b/src/pirats/cards.py @@ -74,8 +74,10 @@ OBSTACLES_DATA = { } } -# 20 funny Secret Pirate Technique suggestions for when players need ideas -TECHNIQUE_SUGGESTIONS = [ +# Secret Pirate Technique suggestions for when players need ideas: 20 handcrafted +# names plus the same "{math word} {move} of {target}" combinations the frontend +# suggestion pool (frontend/src/lib/suggestions.js) is built from. +_HANDCRAFTED_TECHNIQUES = [ "I missed on purpose", "Carlos will figure it out", "Never trust a hatless captain", @@ -98,6 +100,26 @@ TECHNIQUE_SUGGESTIONS = [ "Sudden pirate flip" ] +_TECHNIQUE_MATH_WORDS = [ + "Geometric", "Parabolic", "Fibonacci", "Trigonometric", "Prime Number", + "Logarithmic", "Hypotenuse", "Algebraic", "Binary", "Vector" +] +_TECHNIQUE_MOVES = [ + "Drift", "Leap", "Flurry", "Slash", "Parry", + "Slam", "Dodge", "Vortex", "Shield", "Barrage" +] +_TECHNIQUE_TARGETS = [ + "the Cheese Reef", "the Stormy Sea", "Euler", "the Abacus", "the Gat", + "Carlos", "the Deep", "Gauss", "the Coordinate Plane", "Shrapnel" +] + +TECHNIQUE_SUGGESTIONS = _HANDCRAFTED_TECHNIQUES + [ + f"{math_word} {move} of {target}" + for math_word in _TECHNIQUE_MATH_WORDS + for move in _TECHNIQUE_MOVES + for target in _TECHNIQUE_TARGETS +] + def get_fresh_deck() -> List[str]: """Generates a shuffled deck of 54 cards (52 standard cards + 2 Jokers).""" deck = [] diff --git a/src/pirats/crud.py b/src/pirats/crud.py index d4318b9..182da85 100644 --- a/src/pirats/crud.py +++ b/src/pirats/crud.py @@ -2,4 +2,5 @@ from .crud_base import * from .crud_character import * from .crud_scene import * +from .crud_challenge import * from .crud_upkeep import * diff --git a/src/pirats/crud_base.py b/src/pirats/crud_base.py index 50536b3..b1f4cb6 100644 --- a/src/pirats/crud_base.py +++ b/src/pirats/crud_base.py @@ -19,66 +19,83 @@ def get_game_deck(game: Game) -> List[str]: def set_game_deck(game: Game, deck: List[str]): game.deck_cards = json.dumps(deck) -def calculate_max_hand_size(player: Player, players_in_scene: List[Player]) -> int: +def calculate_max_hand_size(player: Player, players_in_scene: List[Player], captain_id: Optional[str] = None) -> int: """ - Calculates a player's maximum hand size based on their Rank relative to others in the scene, - and Captain privileges (+1). + Calculates a player's maximum hand size based on their Rank relative to others, + plus Captain privileges (+1, regardless of Rank). Rank Hand sizes: - Highest Rank Pi-Rat(s): max 4 cards - Middle Rank Pi-Rat(s): max 3 cards - Lowest Rank Pi-Rat(s): max 2 cards - - Captain gets +1 - If all players have the same Rank, they are all 'middle' and get 3 cards. + If everyone shares a Rank, they are all 'highest' and get 4 cards. + Pi-Rats are ranked against the Pi-Rats in the scene; Deep players (whose hand size + only matters for the between-scenes redraw) are ranked against all players. """ if player.role == "deep": - # Deep players still have hands (for when they transition), let's say they have size based on Rank - # but don't participate in the Pi-Rat ranking calculations. - # Deep player max hand size is 3 by default, or equal to Rank. Let's make it equal to Rank + 1. - return player.rank + 1 - - pi_rats = [p for p in players_in_scene if p.role != "deep"] - if not pi_rats: - return 3 - - ranks = [p.rank for p in pi_rats] - max_rank = max(ranks) - min_rank = min(ranks) - - # Base size calculation - if len(set(ranks)) == 1: - # All have the same rank - base_size = 3 + pool = players_in_scene else: - if player.rank == max_rank: - base_size = 4 - elif player.rank == min_rank: - base_size = 2 - else: - base_size = 3 - - # Captain check (highest rank Pi-Rat is de facto Captain) - # The rulebook: 'Initially, the highest-Ranked Pi-Rat becomes the de facto Captain. - # Captain Privileges: The Captain has their Hand size increased by 1, regardless of their Rank.' - # If there's a tie for highest rank, we'll designate the first one in alphabetical/ID order. - highest_ranked_pirats = [p for p in pi_rats if p.rank == max_rank] - highest_ranked_pirats.sort(key=lambda p: p.id) - is_captain = len(highest_ranked_pirats) > 0 and highest_ranked_pirats[0].id == player.id - - if is_captain: + pool = [p for p in players_in_scene if p.role != "deep"] + if not pool: + pool = [player] + + ranks = [p.rank for p in pool] + if player.rank >= max(ranks): + base_size = 4 + elif player.rank <= min(ranks): + base_size = 2 + else: + base_size = 3 + + if captain_id is not None and player.id == captain_id: return base_size + 1 return base_size -def is_player_captain(player: Player, players_in_scene: List[Player]) -> bool: - """Helper to check if a player is currently the captain in the scene.""" - if player.role == "deep": - return False - pi_rats = [p for p in players_in_scene if p.role != "deep"] - if not pi_rats: - return False - max_rank = max(p.rank for p in pi_rats) - highest_ranked = [p for p in pi_rats if p.rank == max_rank] - highest_ranked.sort(key=lambda p: p.id) - return len(highest_ranked) > 0 and highest_ranked[0].id == player.id +def is_player_captain(player: Player, game: Game) -> bool: + """The Captain is a persistent position tracked on the Game (set once the crew steals a ship).""" + return game.captain_player_id is not None and game.captain_player_id == player.id + +def capture_hand_maxes(players: List[Player], captain_id: Optional[str] = None) -> dict: + """Snapshot every player's max hand size, for detecting increases after a Rank/Captain change.""" + return {p.id: calculate_max_hand_size(p, players, captain_id) for p in players} + +def apply_hand_increases(db: Session, game: Game, old_maxes: dict): + """ + 'Anytime your Hand size increases, you immediately draw a card.' + Compares current max hand sizes against a snapshot and draws the difference + for any player whose max increased. (Decreases never force a discard.) + """ + for p in game.players: + new_max = calculate_max_hand_size(p, game.players, game.captain_player_id) + old_max = old_maxes.get(p.id, new_max) + if new_max > old_max: + draw_cards_for_player(db, game, p, new_max - old_max) + +def change_player_rank(db: Session, game: Game, player: Player, delta: int): + """Changes a player's Rank and grants immediate draws for any resulting hand size increases.""" + old_maxes = capture_hand_maxes(game.players, game.captain_player_id) + player.rank = max(1, player.rank + delta) + db.add(player) + db.commit() + apply_hand_increases(db, game, old_maxes) + +def set_captain(db: Session, game: Game, player_id: Optional[str], reason: str = ""): + """Assigns (or clears) the Captain, granting the +1 hand size draw to the new Captain.""" + if game.captain_player_id == player_id: + return + old_maxes = capture_hand_maxes(game.players, game.captain_player_id) + game.captain_player_id = player_id + db.add(game) + db.commit() + apply_hand_increases(db, game, old_maxes) + if player_id: + new_captain = db.get(Player, player_id) + if new_captain: + msg = f"{new_captain.name} is now the Captain!" + if reason: + msg += f" ({reason})" + add_game_event(db, game.id, msg) + elif reason: + add_game_event(db, game.id, f"The Captain's position is vacant. ({reason})") def reshuffle_discard_pile(db: Session, game: Game): """ @@ -91,13 +108,13 @@ def reshuffle_discard_pile(db: Session, game: Game): all_hand_cards.update(get_player_hand(player)) # 2. Get active cards on the table - # We must keep the original_card of active obstacles, and the top card in their played_cards list + # Obstacle cards and their whole played-card columns stay on the table until the Obstacle is discarded active_table_cards = set() for obs in game.obstacles: active_table_cards.add(obs.original_card) played = json.loads(obs.played_cards) - if played: - active_table_cards.add(played[-1]["card"]) + for entry in played: + active_table_cards.add(entry["card"]) # 3. The full deck of 54 cards full_deck = [] diff --git a/src/pirats/crud_challenge.py b/src/pirats/crud_challenge.py new file mode 100644 index 0000000..48d9768 --- /dev/null +++ b/src/pirats/crud_challenge.py @@ -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 + } diff --git a/src/pirats/crud_character.py b/src/pirats/crud_character.py index 6217c9b..4ec7a8f 100644 --- a/src/pirats/crud_character.py +++ b/src/pirats/crud_character.py @@ -6,6 +6,27 @@ from .crud_base import get_player, get_game, add_game_event # --- Character Creation Operations --- +def auto_delegate_all(db: Session, game: Game): + """ + Assigns the Like/Hate delegated questions for every player at once. + Uses a shuffled cycle so each player answers exactly one Like and one Hate, + never their own, and (with 3+ players) from two different crewmates. + """ + players = list(game.players) + if len(players) < 2: + return + random.shuffle(players) + n = len(players) + for i, p in enumerate(players): + if not p.other_like_from_player_id: + p.other_like_from_player_id = players[(i + 1) % n].id + p.other_like = "" + if not p.other_hate_from_player_id: + p.other_hate_from_player_id = players[(i - 1) % n].id + p.other_hate = "" + db.add(p) + db.commit() + def delegate_question(db: Session, player_id: str, question_type: str, target_player_id: str): player = get_player(db, player_id) if not player: @@ -31,27 +52,51 @@ def submit_delegated_answer(db: Session, target_player_id: str, question_type: s db.commit() def submit_techniques(db: Session, player_id: str, tech1: str, tech2: str, tech3: str): + """ + Saves a player's 3 techniques. Returns an error string if any of them collide + (case-insensitively) with each other or with techniques other players already + submitted, so the player can fix it immediately instead of the whole pool + being reset after everyone submits. + """ player = get_player(db, player_id) if not player: - return - player.created_techniques = json.dumps([tech1, tech2, tech3]) + return None + + techs = [tech1, tech2, tech3] + cleaned = [t.strip().lower() for t in techs] + if len(set(cleaned)) < 3: + return "Your 3 techniques must all be different." + + game = get_game(db, player.game_id) + if game: + taken = set() + for p in game.players: + if p.id == player.id: + continue + for t in json.loads(p.created_techniques): + taken.add(t.strip().lower()) + clashes = [techs[i] for i, c in enumerate(cleaned) if c in taken] + if clashes: + return f"Already taken by a crewmate: {', '.join(clashes)}. Pick something more original!" + + player.created_techniques = json.dumps(techs) db.add(player) db.commit() - + # Check if all players have submitted techniques. If so, trigger the swap! - game = get_game(db, player.game_id) if not game: - return - + return None + all_submitted = True for p in game.players: techs = json.loads(p.created_techniques) if len(techs) < 3 or not all(techs): all_submitted = False break - + if all_submitted: trigger_technique_swap(db, game) + return None def trigger_technique_swap(db: Session, game: Game): """ @@ -214,8 +259,8 @@ def roll_new_character( set_game_deck(game, deck) set_player_hand(player, []) - # 2. Reset properties & randomize starting rank between 1 and 3 - player.rank = random.randint(1, 3) + # 2. Reset properties. New recruits always start at Rank 1. + player.rank = 1 player.is_ready = False player.completed_personal_1 = False player.completed_personal_2 = False @@ -224,6 +269,11 @@ def roll_new_character( player.needs_rank_3_bonus = False player.is_dead = False player.is_ghost = False + player.tax_banned = False + + # A dead Captain's position does not pass to their replacement recruit + if game.captain_player_id == player.id: + game.captain_player_id = None # Save player-provided details player.avatar_look = avatar_look.strip() @@ -231,15 +281,38 @@ def roll_new_character( player.first_words = first_words.strip() player.good_at_math = good_at_math.strip() - # Scent/Name is "Recruit [Smell]" + # Scent/Name is "Recruit [Smell]" — strip lead-ins like "smells like" or + # "faintly of" (the suggestion pool uses these) so the name reads naturally. clean_smell = avatar_smell.strip() - if clean_smell.lower().startswith("like "): - clean_smell = clean_smell[5:] + lead_ins = [ + "suspiciously smelling of", "pleasantly smelling of", "reminiscent of", + "smells like", "a mix of", "like a mix of", "strongly of", "faintly of", + "like", "of", + ] + stripped = True + while stripped: + stripped = False + for lead in lead_ins: + if clean_smell.lower().startswith(lead + " "): + clean_smell = clean_smell[len(lead) + 1:] + stripped = True + clean_smell = clean_smell[:1].upper() + clean_smell[1:] player.name = f"Recruit {clean_smell}" - - # 3. Randomly assign 3 new secret techniques from cards.TECHNIQUE_SUGGESTIONS + + # 3. Randomly assign 3 new secret techniques, avoiding any technique already + # in play on another player's sheet (collisions make for boring recruits). from .cards import TECHNIQUE_SUGGESTIONS - chosen_techs = random.sample(TECHNIQUE_SUGGESTIONS, 3) + in_use = set() + for p in game.players: + if p.id == player.id: + continue + for t in [p.tech_jack, p.tech_queen, p.tech_king] + json.loads(p.created_techniques) + json.loads(p.swapped_techniques): + if t: + in_use.add(t.strip().lower()) + available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use] + if len(available) < 3: + available = TECHNIQUE_SUGGESTIONS + chosen_techs = random.sample(available, 3) player.created_techniques = json.dumps(chosen_techs) player.swapped_techniques = json.dumps(chosen_techs) player.tech_jack = chosen_techs[0] @@ -271,7 +344,7 @@ def roll_new_character( db.commit() # 5. Draw cards up to new hand size based on new Rank - max_size = calculate_max_hand_size(player, game.players) + max_size = calculate_max_hand_size(player, game.players, game.captain_player_id) draw_cards_for_player(db, game, player, max_size) add_game_event(db, game.id, f"{player.name} has joined the crew as a new Rank {player.rank} recruit!") diff --git a/src/pirats/crud_scene.py b/src/pirats/crud_scene.py index 30fb814..edf579d 100644 --- a/src/pirats/crud_scene.py +++ b/src/pirats/crud_scene.py @@ -1,13 +1,14 @@ import json import random -from typing import Tuple, Dict, Any +from typing import Tuple, Dict, Any, Optional from sqlmodel import Session -from .models import Game, Player, Obstacle +from .models import Game, Player, Obstacle, Challenge from . import cards from .crud_base import ( get_player, get_game, get_player_hand, set_player_hand, get_game_deck, set_game_deck, calculate_max_hand_size, draw_cards_for_player, - add_game_event + add_game_event, reshuffle_discard_pile, capture_hand_maxes, apply_hand_increases, + change_player_rank, set_captain ) # --- Scene Setup Operations --- @@ -24,15 +25,17 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: """ Validates that: 1. At least 1 Pi-Rat and 1 Deep player are selected. - 2. Any player who was Deep in the previous scene must play Pi-Rat in this scene. - If valid, starts the scene: shuffles deck, draws obstacles, draws starting hands, resets ready flags. + 2. First scene: the Rank 3 player must play the Deep, the Rank 1 player must play their Pi-Rat. + 3. Any player who was Deep in the previous scene must play Pi-Rat in this scene. + If valid, starts the scene: shuffles discards into the deck, tops up the Obstacle List + (unbeaten Obstacles persist across scenes), and deals starting hands in the first scene. """ game = get_game(db, game_id) if not game: return False, "Game not found." - + players = game.players - + # Default any unselected roles to 'pirat' for p in players: if p.role is None: @@ -41,18 +44,15 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: db.commit() if game.current_scene_number == 1: - max_rank = max((p.rank for p in players), default=1) - highest_ranked_players = [p for p in players if p.rank == max_rank] - - for p in players: - if p.role == "deep" and p not in highest_ranked_players: - return False, f"Only the highest ranked player(s) (Rank {max_rank}) can play The Deep in the first scene." - - if len(highest_ranked_players) == 1: - must_be_deep = highest_ranked_players[0] - if must_be_deep.role != "deep": - return False, f"{must_be_deep.name} is the highest ranked player and must play The Deep in the first scene." - + # The Rank 3 starter must play the Deep; the Rank 1 starter must play their Pi-Rat. + # Rank 2 players may choose either role. + if len(players) >= 2: + for p in players: + if p.rank >= 3 and p.role != "deep": + return False, f"{p.name} starts at Rank 3 and must play The Deep in the first scene." + if p.rank <= 1 and p.role != "pirat": + return False, f"{p.name} starts at Rank 1 and must play their Pi-Rat in the first scene." + # 1. Check previous Deep players # "Players who were Deep Players in the previous Scene must play their Pi-Rats in the following Scene." # Note: Only enforce if this is scene 2+, and we have enough players to make a rotation. @@ -73,59 +73,51 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: # 3. Check counts pirats_count = sum(1 for p in players if p.role == "pirat") deeps_count = sum(1 for p in players if p.role == "deep") - + if pirats_count < 1: return False, "You need at least one Pi-Rat player to play." if deeps_count < 1: return False, "You need at least one Deep player." - + # Everything is valid! Start the scene. - # A. Reset obstacles - for obs in game.obstacles: - db.delete(obs) + # A. Clear votes and stale challenges. Obstacles persist across scenes. for vote in game.votes: db.delete(vote) + for challenge in game.challenges: + db.delete(challenge) db.commit() - - # B. Set previous_role to current role, reset is_ready + + # B. Set previous_role to current role, reset per-scene flags for p in players: p.previous_role = p.role p.is_ready = False + p.tax_banned = False db.add(p) - - # C. Shuffle remaining deck (cards not in player hands) - all_hand_cards = set() - for p in players: - all_hand_cards.update(get_player_hand(p)) - - full_deck = [] - for suit in cards.SUITS.keys(): - for val in cards.VALUES: - full_deck.append(f"{val}{suit}") - full_deck.extend(["Joker1", "Joker2"]) - - deck = [c for c in full_deck if c not in all_hand_cards] - random.shuffle(deck) - - # D. Draw Obstacles: At least D + 1 obstacles, plus any extra_obstacles from Jokers - num_obstacles_to_draw = deeps_count + 1 + game.extra_obstacles - # Reset extra_obstacles for the next scene - game.extra_obstacles = 0 - - # Draw obstacles, handling Jokers - drawn_obstacles = [] - while len(drawn_obstacles) < num_obstacles_to_draw: + db.commit() + + # C. Return discards to the deck and shuffle (cards in hands or on the table stay where they are) + reshuffle_discard_pile(db, game) + deck = get_game_deck(game) + + # D. Top up the Obstacle List until it holds at least Deep Players + 1 Obstacles, + # plus the permanent increase from Jokers previously drawn as Obstacles. + required_obstacles = deeps_count + 1 + game.extra_obstacles + db.refresh(game) + active_count = len(game.obstacles) + drawn_count = 0 + while active_count + drawn_count < required_obstacles: if not deck: break card = deck.pop(0) parsed = cards.parse_card(card) - + if parsed["is_joker"]: - # Joker drawn as Obstacle: - # Immediately discard Jokers when they are drawn this way, and increase the total obstacles + # Joker drawn as an Obstacle: discard it and permanently increase the + # number of Obstacles required for following scenes by 1. game.extra_obstacles += 1 + add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.") continue - + info = cards.get_obstacle_info(card) obstacle = Obstacle( game_id=game.id, @@ -137,30 +129,27 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]: played_cards="[]" ) db.add(obstacle) - drawn_obstacles.append(obstacle) - - # E. Top up player hands to their maximum size - for p in players: - max_size = calculate_max_hand_size(p, players) - current_hand = get_player_hand(p) - if len(current_hand) < max_size: - diff = max_size - len(current_hand) - # Draw diff cards - for _ in range(diff): - if not deck: - break - card = deck.pop(0) - current_hand.append(card) + drawn_count += 1 + + # E. Deal starting hands in the first scene. In later scenes hands carry over; + # players only draw via suit-color matches, hand size increases, and the Deep's upkeep redraw. + if game.current_scene_number == 1: + for p in players: + max_size = calculate_max_hand_size(p, players, game.captain_player_id) + current_hand = get_player_hand(p) + while len(current_hand) < max_size and deck: + current_hand.append(deck.pop(0)) p.hand_cards = json.dumps(current_hand) db.add(p) - + game.deck_cards = json.dumps(deck) game.phase = "scene" db.add(game) db.commit() - - add_game_event(db, game.id, f"Scene {game.current_scene_number} has started! {num_obstacles_to_draw} obstacles drawn.") - + + total_obstacles = active_count + drawn_count + add_game_event(db, game.id, f"Scene {game.current_scene_number} has started! {drawn_count} new obstacle(s) drawn ({total_obstacles} active).") + return True, "Scene started successfully!" def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]: @@ -168,83 +157,56 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]: player = get_player(db, player_id) if not game or not player: return [] - + players = game.players allowed = ["pirat", "deep"] - + if game.current_scene_number == 1: - max_rank = max((p.rank for p in players), default=1) - highest_ranked_players = [p for p in players if p.rank == max_rank] - - if player not in highest_ranked_players: - if "deep" in allowed: - allowed.remove("deep") - elif len(highest_ranked_players) == 1: - if "pirat" in allowed: - allowed.remove("pirat") + # Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either. + if len(players) >= 2: + if player.rank >= 3: + allowed = ["deep"] + elif player.rank <= 1: + allowed = ["pirat"] elif game.current_scene_number > 1: # Rule 1: Previous Deep must play Pi-Rat (if >= 3 players) if len(players) >= 3 and player.previous_role == "deep": if "deep" in allowed: allowed.remove("deep") - + # Rule 2: If only 1 eligible Deep, they MUST play Deep eligible_deeps = [p for p in players if p.previous_role != "deep"] if len(eligible_deeps) == 1 and eligible_deeps[0].id == player_id: if "pirat" in allowed: allowed.remove("pirat") - + return allowed # --- Scene Gameplay Operations --- -def play_card_on_obstacle( - db: Session, - player_id: str, - obstacle_id: str, - card_code: str -) -> Tuple[bool, str, Dict[str, Any]]: +def resolve_card_against_obstacle( + db: Session, + game: Game, + player: Player, + obstacle: Obstacle, + card_code: str, + acting_rank: int +) -> Dict[str, Any]: """ - Plays a card from player's hand against an active obstacle. - Updates the obstacle value, checks matching suit color to draw back, - and returns resolution results. + Resolves one (non-Joker) card from a player against an Obstacle: + determines success (Secret Pirate Techniques, Gat/Name privileges, face-card + Obstacle values), appends the card to the Obstacle's column, and updates + the Obstacle's current value. Does not touch hands or draws. """ - player = get_player(db, player_id) - obstacle = db.get(Obstacle, obstacle_id) - game = get_game(db, player.game_id) - - if not player or not obstacle or not game: - return False, "Game entities not found.", {} - - hand = get_player_hand(player) - if card_code not in hand: - return False, "Card not in hand.", {} - - # Remove from hand - hand.remove(card_code) - set_player_hand(player, hand) - - # Parse card and obstacle played_parsed = cards.parse_card(card_code) - orig_obs_parsed = cards.parse_card(obstacle.original_card) - - # Check if already completed - played_list = json.loads(obstacle.played_cards) - current_successes = sum(1 for c in played_list if c.get("success") is True) - required_successes = sum(1 for p in game.players if p.role != "deep") - if current_successes >= required_successes: - return False, "This obstacle is already completed.", {} - - # 1. Determine Success/Failure success = False details = "" is_technique = False tech_name = "" - - # Value rules: - # A. Face Cards (Jack, Queen, King) played by Pi-Rat are Secret Pirate Techniques: Automatic Success! - if played_parsed["value"] in ["J", "Q", "K"] and player.role == "pirat": + + # Face Cards (Jack, Queen, King) played by a Pi-Rat trigger a Secret Pirate Technique: automatic success! + if played_parsed["value"] in ["J", "Q", "K"] and player.role != "deep": success = True is_technique = True if played_parsed["value"] == "J": @@ -254,57 +216,41 @@ def play_card_on_obstacle( elif played_parsed["value"] == "K": tech_name = player.tech_king or "King Secret Technique" details = f"Used Secret Pirate Technique: '{tech_name}'!" - - # B. Jokers played by Pi-Rat: - elif played_parsed["is_joker"]: - success = True - details = "Played a Joker! This obstacle is discarded, and a new one is drawn." - else: - # Regular card play vs Obstacle value. + # Regular card play vs Obstacle value. The last card in the column (or the + # original Obstacle card) determines the value; face cards count as the + # Rank of the challenged Pi-Rat. obs_val_card = cards.parse_card(obstacle.original_card) played_on_obs = json.loads(obstacle.played_cards) if played_on_obs: obs_val_card = cards.parse_card(played_on_obs[-1]["card"]) - + if obs_val_card["value"] in ["J", "Q", "K"]: - target_value = player.rank - obs_detail = f"Rank {player.rank} (Face Card Obstacle)" + target_value = acting_rank + obs_detail = f"Rank {acting_rank} (Face Card Obstacle)" else: target_value = obstacle.current_value obs_detail = str(target_value) - - # What is the played card's value? + played_val = played_parsed["numeric_value"] - - # Apply privileges: + + # Gat privilege: Black cards +1. Name privilege: Red cards +1. privilege_bonus = 0 if player.completed_personal_1 and played_parsed["suit"] in ["C", "S"]: privilege_bonus = 1 if player.completed_personal_2 and played_parsed["suit"] in ["H", "D"]: privilege_bonus = 1 - + final_played_value = played_val + privilege_bonus - - # Compare + if final_played_value > target_value: success = True details = f"Success! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}." else: success = False details = f"Failure! Played {played_parsed['display']} ({final_played_value}) vs Obstacle value {obs_detail}." - - # 2. Card Draw (Step 6 of Resolving Challenges) - drew_card = None - if not played_parsed["is_joker"]: - if played_parsed["color"] == orig_obs_parsed["color"]: - # Match! Draw 1 card - drawn = draw_cards_for_player(db, game, player, 1) - if drawn: - drew_card = drawn[0] - details += " (Drew a card back due to matching suit colors!)" - - # 3. Update Obstacle Table & Played Card list + + # Place the card in the Obstacle's column; it becomes the Obstacle's new value. played_list = json.loads(obstacle.played_cards) played_list.append({ "card": card_code, @@ -314,91 +260,126 @@ def play_card_on_obstacle( "details": details }) obstacle.played_cards = json.dumps(played_list) - - # The last played card (if not Joker) becomes the obstacle's new value - if not played_parsed["is_joker"]: - obstacle.current_value = played_parsed["numeric_value"] - db.add(obstacle) - else: - # It's a Joker! We replace the obstacle. - db.delete(obstacle) - db.commit() - - # Draw replacement obstacle from deck - deck = get_game_deck(game) - new_obs = None - while deck: - new_card = deck.pop(0) - new_parsed = cards.parse_card(new_card) - if new_parsed["is_joker"]: - game.extra_obstacles += 1 - continue - - info = cards.get_obstacle_info(new_card) - new_obs = Obstacle( - game_id=game.id, - original_card=new_card, - suit=info["suit"], - title=info["title"], - description=info["description"], - current_value=info["initial_value"], - played_cards="[]" - ) - db.add(new_obs) - break - game.deck_cards = json.dumps(deck) - db.add(game) - db.commit() - - db.add(player) + obstacle.current_value = played_parsed["numeric_value"] + db.add(obstacle) db.commit() - - event_msg = f"{player.name} played {played_parsed['display']} on '{obstacle.title}'. {details}" - add_game_event(db, game.id, event_msg) - - res_dict = { + + return { "success": success, "details": details, "is_technique": is_technique, - "tech_name": tech_name, - "drew_card": drew_card, - "is_joker": played_parsed["is_joker"] + "tech_name": tech_name } - - return True, "Card played successfully.", res_dict -def evaluate_hand_sizes(db: Session, game_id: str): +def play_joker(db: Session, player_id: str, obstacle_id: str, card_code: str) -> Tuple[bool, str]: """ - Recalculates max hand size for all players in the game. - If a player's current hand size is less than their max hand size, - draws cards to fill it up immediately. + Pirate Luck! A Joker may be played at any time to discard one Obstacle (and its + column) from the list entirely and draw a new one. The Joker is then discarded. """ - game = get_game(db, game_id) + player = get_player(db, player_id) + obstacle = db.get(Obstacle, obstacle_id) + if not player or not obstacle: + return False, "Game entities not found." + game = get_game(db, player.game_id) if not game: - return - - for player in game.players: - max_size = calculate_max_hand_size(player, game.players) - current_hand = get_player_hand(player) - if len(current_hand) < max_size: - draw_cards_for_player(db, game, player, max_size - len(current_hand)) + return False, "Game not found." + + parsed = cards.parse_card(card_code) + if not parsed["is_joker"]: + return False, "That card is not a Joker." + + hand = get_player_hand(player) + if card_code not in hand: + return False, "Card not in hand." + hand.remove(card_code) + set_player_hand(player, hand) + db.add(player) + + obstacle_title = obstacle.title + + # Remove the obstacle from any open challenge it was applied to + for challenge in game.challenges: + if challenge.status != "open": + continue + obstacle_ids = json.loads(challenge.obstacle_ids) + if obstacle_id in obstacle_ids: + obstacle_ids.remove(obstacle_id) + challenge.obstacle_ids = json.dumps(obstacle_ids) + db.add(challenge) + + db.delete(obstacle) + db.commit() + + # Draw a replacement Obstacle from the deck + deck = get_game_deck(game) + new_title = None + while deck: + new_card = deck.pop(0) + new_parsed = cards.parse_card(new_card) + if new_parsed["is_joker"]: + game.extra_obstacles += 1 + add_game_event(db, game.id, "A Joker surfaced as an Obstacle! Future scenes permanently require one more Obstacle.") + continue + + info = cards.get_obstacle_info(new_card) + new_obs = Obstacle( + game_id=game.id, + original_card=new_card, + suit=info["suit"], + title=info["title"], + description=info["description"], + current_value=info["initial_value"], + played_cards="[]" + ) + db.add(new_obs) + new_title = info["title"] + break + game.deck_cards = json.dumps(deck) + db.add(game) + db.commit() + + msg = f"{player.name} played a Joker! '{obstacle_title}' is discarded" + msg += f" and replaced by '{new_title}'." if new_title else " (no replacement left in the deck)." + add_game_event(db, game.id, msg) + + return True, msg def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, status: bool): - """Toggles Personal or Crew objectives and adjusts states/Ranks accordingly.""" + """Toggles Personal or Crew objectives and adjusts states/Ranks/Captaincy accordingly.""" if obj_type.startswith("crew_"): game = get_game(db, game_id) if not game: return - if obj_type == "crew_1": game.completed_crew_1 = status - elif obj_type == "crew_2": game.completed_crew_2 = status - elif obj_type == "crew_3": game.completed_crew_3 = status - db.add(game) - db.commit() + if obj_type == "crew_1": + game.completed_crew_1 = status + db.add(game) + db.commit() + if status and not game.captain_player_id: + # "Once the crew controls a ship, the highest-Ranked Pi-Rat becomes the de facto Captain." + pi_rats = [p for p in game.players if p.role != "deep" and not p.is_dead and not p.is_ghost] + if pi_rats: + max_rank = max(p.rank for p in pi_rats) + candidates = [p for p in pi_rats if p.rank == max_rank] + set_captain(db, game, random.choice(candidates).id, "Highest-ranked Pi-Rat aboard the stolen ship") + elif not status and game.captain_player_id: + # The ship is lost: the Captain's position goes with it. + set_captain(db, game, None, "The ship was lost") + elif obj_type == "crew_2": + game.completed_crew_2 = status + db.add(game) + db.commit() + elif obj_type == "crew_3": + game.completed_crew_3 = status + db.add(game) + db.commit() return player = get_player(db, target_id) if not player: return - + game = get_game(db, game_id) + if not game: + return + rank_diff = 0 if obj_type == "personal_1": if status and not player.completed_personal_1: @@ -406,7 +387,7 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s elif not status and player.completed_personal_1: rank_diff = -1 player.completed_personal_1 = status - + elif obj_type == "personal_2": if status and not player.completed_personal_2: rank_diff = 1 @@ -415,7 +396,7 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s rank_diff = -1 player.needs_name = False player.completed_personal_2 = status - + elif obj_type == "personal_3": if status and not player.completed_personal_3: player.needs_rank_3_bonus = True @@ -424,15 +405,17 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s player.needs_rank_3_bonus = False player.is_dead = False player.completed_personal_3 = status - - player.rank += rank_diff - player.rank = max(1, player.rank) + db.add(player) db.commit() - + if rank_diff != 0: - evaluate_hand_sizes(db, game_id) - + change_player_rank(db, game, player, rank_diff) + + # The Captain remains in power until they give up the position, are defeated, or die. + if obj_type == "personal_3" and status and game.captain_player_id == player.id: + set_captain(db, game, None, f"{player.name}'s story arc is complete") + obj_name = obj_type.replace('_', ' ').title() status_text = "completed" if status else "unmarked" add_game_event(db, game_id, f"{player.name} marked objective '{obj_name}' as {status_text}.") @@ -441,33 +424,47 @@ def rollback_card_play(db: Session, obstacle_id: str) -> Tuple[bool, str]: obstacle = db.get(Obstacle, obstacle_id) if not obstacle: return False, "Obstacle not found." - + played_list = json.loads(obstacle.played_cards) if not played_list: return False, "No cards to roll back." - + last_play = played_list.pop() card_code = last_play["card"] player_id = last_play["player_id"] - + # Update obstacle's value if played_list: prev_card_code = played_list[-1]["card"] obstacle.current_value = cards.parse_card(prev_card_code)["numeric_value"] else: obstacle.current_value = cards.get_obstacle_info(obstacle.original_card)["initial_value"] - + obstacle.played_cards = json.dumps(played_list) db.add(obstacle) - - # Return card to player's hand + + # Remove the matching play from any open challenge that applied this obstacle player = get_player(db, player_id) if player: + game = get_game(db, player.game_id) + if game: + for challenge in game.challenges: + if challenge.status != "open": + continue + plays = json.loads(challenge.plays) + for i in range(len(plays) - 1, -1, -1): + p = plays[i] + if p.get("obstacle_id") == obstacle_id and p.get("card") == card_code and p.get("player_id") == player_id: + plays.pop(i) + challenge.plays = json.dumps(plays) + db.add(challenge) + break + # Return card to player's hand hand = get_player_hand(player) hand.append(card_code) set_player_hand(player, hand) db.add(player) - + db.commit() return True, "Rolled back the last played card." @@ -477,3 +474,13 @@ def clear_completed_obstacle(db: Session, game_id: str, obstacle_id: str): add_game_event(db, game_id, f"The Deep cleared the completed obstacle '{obstacle.title}'.") db.delete(obstacle) db.commit() + +def finish_game(db: Session, game_id: str): + """Ends the story. The wrap-up screen shows how the crew measured up.""" + game = get_game(db, game_id) + if not game: + return + game.phase = "ended" + db.add(game) + db.commit() + add_game_event(db, game_id, "The story has ended! The Pi-Rats party til they pass out in a pile. 🎉") diff --git a/src/pirats/crud_upkeep.py b/src/pirats/crud_upkeep.py index c99f1ca..2c998ae 100644 --- a/src/pirats/crud_upkeep.py +++ b/src/pirats/crud_upkeep.py @@ -1,16 +1,30 @@ import json import random -from typing import List +from typing import List, Tuple from sqlmodel import Session, select from .models import Game, Player, Vote from .crud_base import ( get_player, get_game, get_player_hand, set_player_hand, - get_game_deck, set_game_deck, draw_cards_for_player, add_game_event + get_game_deck, set_game_deck, draw_cards_for_player, add_game_event, + calculate_max_hand_size, change_player_rank ) # --- Between Scenes Operations --- -def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str): +def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str) -> Tuple[bool, str]: + """ + Every Player (including last scene's Deep Players) nominates one *other* Pi-Rat + Player to Rank up. Deep Players from the previous scene cannot be nominated. + """ + voter = get_player(db, voter_id) + nominee = get_player(db, nominated_id) + if not voter or not nominee or voter.game_id != game_id or nominee.game_id != game_id: + return False, "Player not found." + if voter_id == nominated_id: + return False, "You cannot nominate yourself. Nice try." + if nominee.role == "deep": + return False, "Deep Players from the previous scene cannot be nominated." + # Remove any existing vote by this voter for this game existing = db.exec( select(Vote).where(Vote.game_id == game_id, Vote.voter_player_id == voter_id) @@ -18,7 +32,7 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str for v in existing: db.delete(v) db.commit() - + vote = Vote( game_id=game_id, voter_player_id=voter_id, @@ -26,6 +40,7 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str ) db.add(vote) db.commit() + return True, "Vote submitted." def end_scene_and_transition(db: Session, game_id: str): """Moves game phase to between_scenes.""" @@ -66,8 +81,7 @@ def process_between_scenes_votes(db: Session, game: Game): winner_id = random.choice(winners) winner = get_player(db, winner_id) if winner: - winner.rank += 1 - db.add(winner) + change_player_rank(db, game, winner, 1) add_game_event(db, game.id, f"{winner.name} was voted to rank up! They are now Rank {winner.rank}.") # Clear all votes @@ -132,9 +146,8 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]): db.add(player) db.commit() - # 2. Draw cards back up to max size - # Max size for deep player is Rank + 1 - max_size = player.rank + 1 + # 2. Draw cards back up to max size (based on Rank, per the hand size table) + max_size = calculate_max_hand_size(player, game.players, game.captain_player_id) if len(hand) < max_size: diff = max_size - len(hand) draw_cards_for_player(db, game, player, diff) diff --git a/src/pirats/database.py b/src/pirats/database.py index ba9732e..da46816 100644 --- a/src/pirats/database.py +++ b/src/pirats/database.py @@ -25,10 +25,12 @@ def create_db_and_tables(): ("game", "completed_crew_1", "BOOLEAN DEFAULT FALSE"), ("game", "completed_crew_2", "BOOLEAN DEFAULT FALSE"), ("game", "completed_crew_3", "BOOLEAN DEFAULT FALSE"), + ("game", "captain_player_id", "VARCHAR"), ("player", "needs_name", "BOOLEAN DEFAULT FALSE"), ("player", "needs_rank_3_bonus", "BOOLEAN DEFAULT FALSE"), ("player", "is_dead", "BOOLEAN DEFAULT FALSE"), ("player", "is_ghost", "BOOLEAN DEFAULT FALSE"), + ("player", "tax_banned", "BOOLEAN DEFAULT FALSE"), ] for table, col, def_type in columns: diff --git a/src/pirats/main.py b/src/pirats/main.py index e8bcf6a..098c3b6 100644 --- a/src/pirats/main.py +++ b/src/pirats/main.py @@ -60,6 +60,7 @@ def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_sessi "player": player.model_dump(), "players": [p.model_dump() for p in game.players], "obstacles": [o.model_dump() for o in game.obstacles], + "challenges": [c.model_dump() for c in game.challenges], "votes": [v.model_dump() for v in game.votes], "events": [e.model_dump() for e in events], } @@ -68,12 +69,14 @@ def get_game_state(game_id: str, player_id: str, db: Session = Depends(get_sessi from .routes_lobby import router as lobby_router from .routes_character import router as character_router from .routes_scene import router as scene_router +from .routes_challenge import router as challenge_router from .routes_upkeep import router as upkeep_router from .routes_admin import router as admin_router api.include_router(lobby_router) api.include_router(character_router) api.include_router(scene_router) +api.include_router(challenge_router) api.include_router(upkeep_router) api.include_router(admin_router) diff --git a/src/pirats/models.py b/src/pirats/models.py index a641416..a6e810a 100644 --- a/src/pirats/models.py +++ b/src/pirats/models.py @@ -7,18 +7,20 @@ class Game(SQLModel, table=True): id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) admin_key: str = Field(default_factory=lambda: str(uuid.uuid4())) crew_name: Optional[str] = Field(default=None) - phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep" + phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "ended" current_scene_number: int = Field(default=1) - extra_obstacles: int = Field(default=0) # Extra obstacles for next scene (from Jokers) + extra_obstacles: int = Field(default=0) # Permanent increase to required obstacles (from Jokers drawn as Obstacles) deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...] completed_crew_1: bool = Field(default=False) # Steal a Ship completed_crew_2: bool = Field(default=False) # Choose a Captain completed_crew_3: bool = Field(default=False) # Commit Piracy - + captain_player_id: Optional[str] = Field(default=None) # Persistent captaincy; set once the crew controls a ship + players: List["Player"] = Relationship(back_populates="game", cascade_delete=True) obstacles: List["Obstacle"] = Relationship(back_populates="game", cascade_delete=True) votes: List["Vote"] = Relationship(back_populates="game", cascade_delete=True) events: List["GameEvent"] = Relationship(back_populates="game", cascade_delete=True) + challenges: List["Challenge"] = Relationship(back_populates="game", cascade_delete=True) class Player(SQLModel, table=True): id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) @@ -60,6 +62,7 @@ class Player(SQLModel, table=True): needs_rank_3_bonus: bool = Field(default=False) is_dead: bool = Field(default=False) is_ghost: bool = Field(default=False) + tax_banned: bool = Field(default=False) # Failed a refused Gat/Name Tax; cannot initiate one again this scene # Relationships game: Optional[Game] = Relationship(back_populates="players") @@ -85,6 +88,26 @@ class Obstacle(SQLModel, table=True): except Exception: return 0 +class Challenge(SQLModel, table=True): + id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) + game_id: str = Field(foreign_key="game.id", index=True) + challenge_type: str = Field(default="deep") # "deep" (called by Deep Players) or "pvp" (Pi-Rat vs Pi-Rat) + target_player_id: str = Field(...) # The originally challenged Pi-Rat + acting_player_id: str = Field(...) # Who attempts it (changes when a Gat/Name Tax is accepted) + challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it + obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges) + temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle + stakes: str = Field(default="") + plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}] + status: str = Field(default="open") # "open", "succeeded", "failed" + + # Gat/Name Tax state + tax_type: Optional[str] = Field(default=None) # "gat" or "name" + tax_target_id: Optional[str] = Field(default=None) # The player asked to take over the challenge + tax_state: Optional[str] = Field(default=None) # "requested", "accepted", "refused" + + game: Optional[Game] = Relationship(back_populates="challenges") + class Vote(SQLModel, table=True): id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) game_id: str = Field(foreign_key="game.id", index=True) diff --git a/src/pirats/routes_challenge.py b/src/pirats/routes_challenge.py new file mode 100644 index 0000000..b4af65e --- /dev/null +++ b/src/pirats/routes_challenge.py @@ -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"]} diff --git a/src/pirats/routes_character.py b/src/pirats/routes_character.py index 5dd56dc..0bee3a8 100644 --- a/src/pirats/routes_character.py +++ b/src/pirats/routes_character.py @@ -25,7 +25,7 @@ def player_save_basic_details( player.avatar_look = avatar_look.strip() player.avatar_smell = avatar_smell.strip() player.first_words = first_words.strip() - player.good_at_math = good_at_math + player.good_at_math = good_at_math.strip() db.add(player) db.commit() @@ -125,7 +125,9 @@ def player_submit_techniques( tech3: str = Form(...), db: Session = Depends(get_session) ): - crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip()) + error = crud.submit_techniques(db, player_id, tech1.strip(), tech2.strip(), tech3.strip()) + if error: + return JSONResponse({"error": error}, status_code=400) return {"status": "ok"} # Assign Swapped Techniques to Face Cards (Jack, Queen, King) diff --git a/src/pirats/routes_lobby.py b/src/pirats/routes_lobby.py index ca9b878..13cf3c4 100644 --- a/src/pirats/routes_lobby.py +++ b/src/pirats/routes_lobby.py @@ -14,6 +14,8 @@ def lobby_start_character_creation(game_id: str, db: Session = Depends(get_sessi game.phase = "character_creation" db.add(game) db.commit() + # The Like/Hate questions involve no player decision, so assign them up front. + crud.auto_delegate_all(db, game) crud.add_game_event(db, game.id, "Phase changed: Character Creation") return {"status": "ok"} diff --git a/src/pirats/routes_scene.py b/src/pirats/routes_scene.py index 56a20b3..896ea1e 100644 --- a/src/pirats/routes_scene.py +++ b/src/pirats/routes_scene.py @@ -35,7 +35,7 @@ def start_scene_route(game_id: str, db: Session = Depends(get_session)): return JSONResponse({"error": msg}, status_code=400) return {"status": "ok"} -# Pi-Rat plays card against an obstacle +# Pi-Rat plays a card against an obstacle that is part of an open Challenge @router.post("/game/{game_id}/player/{player_id}/play-card") def play_card_route( game_id: str, @@ -44,7 +44,7 @@ def play_card_route( card_code: str = Form(...), db: Session = Depends(get_session) ): - ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code) + ok, msg, res = crud.play_challenge_card(db, player_id, obstacle_id, card_code) if not ok: return JSONResponse({"error": msg}, status_code=400) return { @@ -54,7 +54,7 @@ def play_card_route( "success": res["success"] } -# Pi-Rat plays a Joker to replace an obstacle +# Pi-Rat plays a Joker to replace an obstacle (playable at any time) @router.post("/game/{game_id}/player/{player_id}/play-joker") def play_joker_route( game_id: str, @@ -63,7 +63,7 @@ def play_joker_route( obstacle_id: str = Form(...), db: Session = Depends(get_session) ): - ok, msg, res = crud.play_card_on_obstacle(db, player_id, obstacle_id, card_code) + ok, msg = crud.play_joker(db, player_id, obstacle_id, card_code) if not ok: return JSONResponse({"error": msg}, status_code=400) return {"status": "ok", "message": "Played Joker: Discarded obstacle and drew a new replacement!"} @@ -133,13 +133,13 @@ def bonus_rank_up_route( ): player = crud.get_player(db, player_id) target = crud.get_player(db, target_player_id) - if player and target and player.needs_rank_3_bonus: - target.rank += 1 + game = crud.get_game(db, game_id) + if player and target and game and player.needs_rank_3_bonus: player.needs_rank_3_bonus = False - db.add(target) db.add(player) db.commit() - crud.evaluate_hand_sizes(db, game_id) + crud.change_player_rank(db, game, target, 1) + crud.add_game_event(db, game_id, f"{player.name} completed their story and grants {target.name} a Rank! They are now Rank {target.rank}.") return {"status": "ok"} # Pi-Rat becomes a ghost @@ -172,3 +172,31 @@ def clear_obstacle_route( ): crud.clear_completed_obstacle(db, game_id, obstacle_id) return {"status": "ok"} + +# Deep (or the Captain stepping down) sets or clears the Captain +@router.post("/game/{game_id}/player/{player_id}/set-captain") +def set_captain_route( + game_id: str, + player_id: str, + target_player_id: str = Form(""), # empty string clears the position + db: Session = Depends(get_session) +): + game = crud.get_game(db, game_id) + player = crud.get_player(db, player_id) + if not game or not player: + raise HTTPException(status_code=404, detail="Game or Player not found") + if player.role != "deep" and game.captain_player_id != player.id: + return JSONResponse({"error": "Only the Deep or the current Captain can reassign the Captaincy."}, status_code=403) + new_captain_id = target_player_id.strip() or None + if new_captain_id: + target = crud.get_player(db, new_captain_id) + if not target or target.role == "deep" or target.is_dead: + return JSONResponse({"error": "The Captain must be a living Pi-Rat in the crew."}, status_code=400) + crud.set_captain(db, game, new_captain_id, f"Decided by {player.name}") + return {"status": "ok"} + +# End the story (wrap-up screen) +@router.post("/game/{game_id}/finish") +def finish_game_route(game_id: str, db: Session = Depends(get_session)): + crud.finish_game(db, game_id) + return {"status": "ok"} diff --git a/src/pirats/routes_upkeep.py b/src/pirats/routes_upkeep.py index 537d65e..b59ce13 100644 --- a/src/pirats/routes_upkeep.py +++ b/src/pirats/routes_upkeep.py @@ -15,7 +15,9 @@ def submit_vote_route( nominated_id: str = Form(...), db: Session = Depends(get_session) ): - crud.submit_rank_vote(db, game_id, player_id, nominated_id) + ok, msg = crud.submit_rank_vote(db, game_id, player_id, nominated_id) + if not ok: + return JSONResponse({"error": msg}, status_code=400) return {"status": "ok"} # Player Ready for Next Scene diff --git a/tests/test_game.py b/tests/test_game.py index 8463775..f236113 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -104,60 +104,74 @@ def test_character_creation_and_swap(session): assert "deep" in roles assert "pirat" in roles -def test_scene_start_and_challenges(session): +def make_scene_game(session, num_pirats=1): + """Helper: a game in scene phase with 1 Deep player and num_pirats Pi-Rats.""" game = crud.create_game(session) - p1 = crud.add_player(session, game.id, "P1", is_creator=True) - p2 = crud.add_player(session, game.id, "P2") - - # Bypass char creation for quick scene test - p1.rank = 3 - p1.role = "deep" - p1.previous_role = "deep" - - p2.rank = 1 - p2.role = "pirat" - p2.previous_role = "pirat" - - session.add_all([p1, p2]) + deep = crud.add_player(session, game.id, "Deep1", is_creator=True) + deep.rank = 3 + deep.role = "deep" + deep.previous_role = "deep" + session.add(deep) + pirats = [] + for i in range(num_pirats): + p = crud.add_player(session, game.id, f"Rat{i+1}") + p.rank = 1 if i == 0 else 2 + p.role = "pirat" + p.previous_role = "pirat" + session.add(p) + pirats.append(p) session.commit() - - # Confirm setup (1 Deep, 1 Pi-Rat) - success, msg = crud.confirm_scene_setup(session, game.id) - assert success - + # Stub shuffle so the rebuilt deck keeps its construction order (Jokers last) + # and no Joker can be drawn as an Obstacle, which would bump extra_obstacles + import random + orig_shuffle = random.shuffle + random.shuffle = lambda x: None + try: + success, msg = crud.confirm_scene_setup(session, game.id) + finally: + random.shuffle = orig_shuffle + assert success, msg session.refresh(game) + return game, deep, pirats + +def test_scene_start_and_challenges(session): + game, deep, (p2,) = make_scene_game(session) assert game.phase == "scene" assert len(game.obstacles) == 2 # Deeps (1) + 1 = 2 obstacles - - obs_list = game.obstacles - obs = obs_list[0] - + + obs = game.obstacles[0] + # Give p2 a known hand for testing p2.hand_cards = json.dumps(["10D", "2H", "JS", "Joker1"]) - session.add(p2) - session.commit() - - # Play card: 10D (value 10) vs Obstacle. Let's make sure obstacle value is less than 10. - # Set obstacle value to 5 obs.current_value = 5 - session.add(obs) + session.add_all([p2, obs]) session.commit() - - # Play 10D (red) on obstacle. Let's check original obstacle color. - # If original obstacle was red, we expect a card draw. + + # Playing a card without an open Challenge must be rejected + ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "10D") + assert not ok + assert "Challenge" in msg + + # The Deep calls a Challenge applying the obstacle + ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="The cheese is on the line") + assert ok, msg + session.refresh(game) + challenge = game.challenges[0] + assert challenge.status == "open" + assert challenge.acting_player_id == p2.id + orig_is_red = cards.parse_card(obs.original_card)["color"] == "red" - deck_before = len(crud.get_game_deck(game)) - - ok, msg, res = crud.play_card_on_obstacle(session, p2.id, obs.id, "10D") + + ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "10D") assert ok assert res["success"] assert "Success" in res["details"] - + session.refresh(p2) session.refresh(obs) # Obstacle current value should update to 10 assert obs.current_value == 10 - + # Check if hand size matches card draw rule # 10D was played (-1 card). If colors matched, drew 1 back (+1 card). hand = crud.get_player_hand(p2) @@ -168,27 +182,48 @@ def test_scene_start_and_challenges(session): assert len(hand) == 3 assert res["drew_card"] is None + # The Deep resolves the challenge: at least one success -> succeeded + ok, msg = crud.resolve_challenge(session, challenge.id, deep.id) + assert ok + session.refresh(challenge) + assert challenge.status == "succeeded" + +def test_assistant_does_not_draw(session): + game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) + obs = game.obstacles[0] + obs.current_value = 5 + # Helper card guaranteed to match the obstacle's color + helper_card = "9D" if cards.parse_card(obs.original_card)["color"] == "red" else "9S" + p3.hand_cards = json.dumps([helper_card]) + session.add_all([obs, p3]) + session.commit() + + ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id]) + assert ok + session.refresh(game) + challenge = game.challenges[0] + + # p3 assists: plays a color-matching card but must NOT draw back + ok, msg, res = crud.play_challenge_card(session, p3.id, obs.id, helper_card) + assert ok + assert res["success"] + assert res["drew_card"] is None + session.refresh(p3) + assert crud.get_player_hand(p3) == [] + def test_secret_technique_auto_success(session): - game = crud.create_game(session) - p1 = crud.add_player(session, game.id, "P1") - p1.role = "pirat" - p1.rank = 2 + game, deep, (p1,) = make_scene_game(session) + obs = game.obstacles[0] p1.tech_jack = "Pocket Sand" p1.hand_cards = json.dumps(["JS"]) - session.add(p1) - - obs = Obstacle( - game_id=game.id, - original_card="10C", - suit="C", - title="Knights", - current_value=10 - ) - session.add(obs) + obs.current_value = 10 + session.add_all([p1, obs]) session.commit() - + + ok, msg = crud.create_challenge(session, game.id, deep.id, p1.id, [obs.id]) + assert ok # Play Jack. J is a face card -> auto success! - ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "JS") + ok, msg, res = crud.play_challenge_card(session, p1.id, obs.id, "JS") assert ok assert res["success"] assert res["is_technique"] @@ -204,12 +239,12 @@ def test_joker_play(session): deck.remove("10C") game.deck_cards = json.dumps(deck) session.add(game) - + p1 = crud.add_player(session, game.id, "P1") p1.role = "pirat" p1.hand_cards = json.dumps(["Joker1"]) session.add(p1) - + obs = Obstacle( game_id=game.id, original_card="10C", @@ -219,16 +254,18 @@ def test_joker_play(session): ) session.add(obs) session.commit() - - # Play Joker - ok, msg, res = crud.play_card_on_obstacle(session, p1.id, obs.id, "Joker1") + + # Play Joker: discards the obstacle and draws a replacement + ok, msg = crud.play_joker(session, p1.id, obs.id, "Joker1") assert ok - assert res["is_joker"] - + # Verify obstacle was deleted and replaced session.refresh(game) assert len(game.obstacles) == 1 assert game.obstacles[0].original_card != "10C" + # Joker left the player's hand + session.refresh(p1) + assert crud.get_player_hand(p1) == [] def test_obstacle_success_count(session): game = crud.create_game(session) @@ -249,23 +286,77 @@ def test_obstacle_success_count(session): assert obs.success_count == 2 -def test_non_deep_player_treatment(session): +def test_captaincy_and_hand_sizes(session): game = crud.create_game(session) p1 = crud.add_player(session, game.id, "Captain Barnaby") p2 = crud.add_player(session, game.id, "Crewmate Pip") - - # p1 is deep, p2 is None (non-deep) + p3 = crud.add_player(session, game.id, "Lowly Larry") + p1.role = "deep" - p2.role = None - session.add_all([p1, p2]) + p1.rank = 2 + p2.role = "pirat" + p2.rank = 2 + p3.role = "pirat" + p3.rank = 1 + session.add_all([p1, p2, p3]) session.commit() - - # Check captain status: p2 should be captain because p2 is the only non-deep player - assert crud.is_player_captain(p2, game.players) - assert not crud.is_player_captain(p1, game.players) - - # Check hand size: p2 should participate in hand size calculations and get Captain privileges (+1) - assert crud.calculate_max_hand_size(p2, game.players) == 4 + + # No captain until the crew controls a ship + assert game.captain_player_id is None + assert not crud.is_player_captain(p2, game) + + # Highest pirat (p2) -> 4 cards, lowest (p3) -> 2 cards + assert crud.calculate_max_hand_size(p2, game.players, game.captain_player_id) == 4 + assert crud.calculate_max_hand_size(p3, game.players, game.captain_player_id) == 2 + + # If everyone shares a Rank, they are all 'highest' -> 4 cards + p3.rank = 2 + session.add(p3) + session.commit() + assert crud.calculate_max_hand_size(p3, game.players, game.captain_player_id) == 4 + + # Completing Crew Objective 1 (Steal a Ship) makes the highest-ranked Pi-Rat the Captain + crud.toggle_objective(session, game.id, p1.id, "crew_1", True) + session.refresh(game) + assert game.captain_player_id in (p2.id, p3.id) + captain = crud.get_player(session, game.captain_player_id) + assert crud.is_player_captain(captain, game) + # Captain privilege: +1 hand size + assert crud.calculate_max_hand_size(captain, game.players, game.captain_player_id) == 5 + + # Losing the ship vacates the Captaincy + crud.toggle_objective(session, game.id, p1.id, "crew_1", False) + session.refresh(game) + assert game.captain_player_id is None + +def test_captain_tax_on_failed_challenge(session): + game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) + # p3 (rank 2) becomes captain + crud.set_captain(session, game, p3.id) + session.refresh(game) + assert game.captain_player_id == p3.id + start_rank = p3.rank + + obs = game.obstacles[0] + obs.current_value = 13 + p3.hand_cards = json.dumps(["2C"]) + session.add_all([obs, p3]) + session.commit() + + ok, msg = crud.create_challenge(session, game.id, deep.id, p3.id, [obs.id]) + assert ok + session.refresh(game) + challenge = [c for c in game.challenges if c.status == "open"][0] + + ok, msg, res = crud.play_challenge_card(session, p3.id, obs.id, "2C") + assert ok + assert not res["success"] + + ok, msg = crud.resolve_challenge(session, challenge.id, deep.id) + assert ok + session.refresh(p3) + # Captain Tax: lost a rank for personally failing a Challenge + assert p3.rank == start_rank - 1 def test_set_role_endpoint(): from fastapi.testclient import TestClient @@ -389,22 +480,24 @@ def test_confirm_deep_refresh(session): session.refresh(p1) session.refresh(game) - - # Hand should not contain 2C and 3C, and should have drawn 5H and 6H + + # Hand should not contain 2C and 3C, and should have drawn back up to max + # hand size (4: the only player counts as highest rank) hand = json.loads(p1.hand_cards) assert "2C" not in hand assert "3C" not in hand assert "4C" in hand assert "5H" in hand assert "6H" in hand - assert len(hand) == 3 - + assert "7H" in hand + assert len(hand) == 4 + # Discarded cards should be at the end of the deck deck = json.loads(game.deck_cards) assert "2C" in deck assert "3C" in deck - assert deck == ["7H", "8H", "2C", "3C"] - + assert deck == ["8H", "2C", "3C"] + # Since P1 was the only resting deep player and is now ready, phase should have advanced to scene_setup! assert game.phase == "scene_setup" @@ -564,8 +657,8 @@ def test_roll_new_character(session): assert p1.first_words == "No fear!" assert p1.good_at_math == "Yes" - # Verify rank randomized between 1-3 - assert p1.rank in [1, 2, 3] + # New recruits always start at Rank 1 + assert p1.rank == 1 # Verify name is "Recruit Spiced Rum" assert p1.name == "Recruit Spiced Rum" @@ -588,4 +681,331 @@ def test_roll_new_character(session): assert "3D" in deck or "3D" in hand assert "4H" in deck or "4H" in hand +def test_gat_tax_accept(session): + game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) + obs = game.obstacles[0] + # p2 is gatless; p3 has a gat + p3.completed_personal_1 = True + p2.hand_cards = json.dumps(["2C", "3C"]) + p3.hand_cards = json.dumps(["10C"]) + session.add_all([p2, p3]) + session.commit() + ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id]) + assert ok + session.refresh(game) + challenge = game.challenges[0] + + ok, msg = crud.request_tax(session, challenge.id, p2.id, p3.id) + assert ok, msg + session.refresh(challenge) + assert challenge.tax_state == "requested" + assert challenge.tax_type == "gat" + + # Plays are blocked while the tax is pending + ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "2C") + assert not ok + + ok, msg = crud.respond_tax(session, challenge.id, p3.id, accept=True) + assert ok + session.refresh(challenge) + session.refresh(p2) + session.refresh(p3) + # The gatted pi-rat takes over and received one random card from the requester + assert challenge.acting_player_id == p3.id + assert len(crud.get_player_hand(p2)) == 1 + assert len(crud.get_player_hand(p3)) == 2 + +def test_gat_tax_refuse_and_fail(session): + game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) + obs = game.obstacles[0] + obs.current_value = 13 + p3.completed_personal_1 = True + p2.hand_cards = json.dumps(["2C"]) + session.add_all([obs, p2, p3]) + session.commit() + p2_start_rank = p2.rank + + ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id]) + assert ok + session.refresh(game) + challenge = game.challenges[0] + + ok, msg = crud.request_tax(session, challenge.id, p2.id, p3.id) + assert ok + ok, msg = crud.respond_tax(session, challenge.id, p3.id, accept=False) + assert ok + + session.refresh(p2) + session.refresh(p3) + # Refusal: the gat changes paws immediately, requester completes Objective 1 (+1 Rank) + assert p2.completed_personal_1 + assert not p3.completed_personal_1 + assert p2.rank == p2_start_rank + 1 + + # The requester attempts the challenge themselves... and fails + ok, msg, res = crud.play_challenge_card(session, p2.id, obs.id, "2C") + assert ok + assert not res["success"] + ok, msg = crud.resolve_challenge(session, challenge.id, deep.id) + assert ok + + session.refresh(p2) + session.refresh(p3) + # Failure: the gat goes back, the rank reverts, and no more taxes this scene + assert not p2.completed_personal_1 + assert p3.completed_personal_1 + assert p2.rank == p2_start_rank + assert p2.tax_banned + +def test_pvp_challenge(session): + game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) + p2.hand_cards = json.dumps(["7C"]) + p3.hand_cards = json.dumps(["9C"]) + session.add_all([p2, p3]) + session.commit() + + ok, msg = crud.create_pvp_challenge(session, game.id, p2.id, p3.id, "7C") + assert ok, msg + session.refresh(p2) + assert crud.get_player_hand(p2) == [] # temp obstacle card left the hand + + session.refresh(game) + duel = [c for c in game.challenges if c.challenge_type == "pvp"][0] + assert duel.temp_card == "7C" + + # Defender beats 7 with 9; matching color (both black) -> draws a card back + ok, msg, res = crud.play_pvp_defense(session, duel.id, p3.id, "9C") + assert ok + assert res["success"] + assert res["drew_card"] is not None + session.refresh(duel) + assert duel.status == "succeeded" + +def test_obstacles_persist_across_scenes(session): + game, deep, (p2,) = make_scene_game(session) + assert len(game.obstacles) == 2 + surviving_ids = {o.id for o in game.obstacles} + + # End the scene, vote, and set up scene 2 with swapped roles + crud.end_scene_and_transition(session, game.id) + crud.transition_to_deep_upkeep(session, game.id) + crud.confirm_deep_refresh(session, deep.id, []) + session.refresh(game) + assert game.phase == "scene_setup" + assert game.current_scene_number == 2 + + deep.role = "pirat" + p2.role = "deep" + session.add_all([deep, p2]) + session.commit() + + success, msg = crud.confirm_scene_setup(session, game.id) + assert success, msg + session.refresh(game) + # Unbeaten obstacles remain; the list is already at Deep(1)+1=2, so nothing new is drawn + assert {o.id for o in game.obstacles} == surviving_ids + +def test_joker_obstacle_increase_is_permanent(session): + game = crud.create_game(session) + deep = crud.add_player(session, game.id, "Deep1") + deep.rank = 3 + deep.role = "deep" + p2 = crud.add_player(session, game.id, "Rat1") + p2.rank = 1 + p2.role = "pirat" + game.extra_obstacles = 1 # a Joker was drawn as an Obstacle earlier + session.add_all([game, deep, p2]) + session.commit() + + # Stub shuffle so the rebuilt deck keeps its construction order (Jokers last) + # and no Joker can be drawn as an Obstacle here + import random + orig_shuffle = random.shuffle + random.shuffle = lambda x: None + + try: + success, msg = crud.confirm_scene_setup(session, game.id) + finally: + random.shuffle = orig_shuffle + + assert success, msg + session.refresh(game) + # Deep(1) + 1 + permanent increase(1) = 3 obstacles + assert len(game.obstacles) == 3 + # The increase is permanent, not consumed + assert game.extra_obstacles == 1 + +def test_first_scene_role_choice(session): + game = crud.create_game(session) + p1 = crud.add_player(session, game.id, "P1") + p2 = crud.add_player(session, game.id, "P2") + p3 = crud.add_player(session, game.id, "P3") + p1.rank = 3 + p2.rank = 1 + p3.rank = 2 + session.add_all([p1, p2, p3]) + session.commit() + + # Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either + assert crud.get_allowed_roles(session, game.id, p1.id) == ["deep"] + assert crud.get_allowed_roles(session, game.id, p2.id) == ["pirat"] + assert set(crud.get_allowed_roles(session, game.id, p3.id)) == {"pirat", "deep"} + + # A Rank 2 player choosing Deep alongside the Rank 3 player is legal + p1.role = "deep" + p2.role = "pirat" + p3.role = "deep" + session.add_all([p1, p2, p3]) + session.commit() + success, msg = crud.confirm_scene_setup(session, game.id) + assert success, msg + +def test_vote_validation(session): + game = crud.create_game(session) + p1 = crud.add_player(session, game.id, "P1") + p2 = crud.add_player(session, game.id, "P2") + p3 = crud.add_player(session, game.id, "P3") + p1.role = "deep" + p2.role = "pirat" + p3.role = "pirat" + session.add_all([p1, p2, p3]) + session.commit() + + # Self-nomination is rejected + ok, msg = crud.submit_rank_vote(session, game.id, p2.id, p2.id) + assert not ok + + # Nominating a previous-scene Deep player is rejected + ok, msg = crud.submit_rank_vote(session, game.id, p2.id, p1.id) + assert not ok + + # But the Deep player may vote + ok, msg = crud.submit_rank_vote(session, game.id, p1.id, p2.id) + assert ok + +def test_rank_up_draws_immediately(session): + game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) + # p2 is rank 1 (lowest -> max 2), p3 is rank 2 (highest -> max 4) + hand_before = len(crud.get_player_hand(p2)) + crud.toggle_objective(session, game.id, p2.id, "personal_1", True) + session.refresh(p2) + assert p2.rank == 2 + # p2 jumped from lowest (2 cards) to highest-tied (4 cards): draws 2 immediately + assert len(crud.get_player_hand(p2)) == hand_before + 2 + + + +def test_auto_delegate_all(session): + game = crud.create_game(session) + p1 = crud.add_player(session, game.id, "P1", is_creator=True) + p2 = crud.add_player(session, game.id, "P2") + p3 = crud.add_player(session, game.id, "P3") + session.refresh(game) + + crud.auto_delegate_all(session, game) + + players = [p1, p2, p3] + for p in players: + session.refresh(p) + assert p.other_like_from_player_id and p.other_like_from_player_id != p.id + assert p.other_hate_from_player_id and p.other_hate_from_player_id != p.id + # With 3+ players, like and hate go to different crewmates + assert p.other_like_from_player_id != p.other_hate_from_player_id + + # Workload is even: each player answers exactly one like and one hate + like_answerers = sorted(p.other_like_from_player_id for p in players) + hate_answerers = sorted(p.other_hate_from_player_id for p in players) + assert like_answerers == sorted(p.id for p in players) + assert hate_answerers == sorted(p.id for p in players) + +def test_auto_delegate_all_two_players(session): + game = crud.create_game(session) + p1 = crud.add_player(session, game.id, "P1", is_creator=True) + p2 = crud.add_player(session, game.id, "P2") + session.refresh(game) + + crud.auto_delegate_all(session, game) + session.refresh(p1) + session.refresh(p2) + assert p1.other_like_from_player_id == p2.id + assert p1.other_hate_from_player_id == p2.id + assert p2.other_like_from_player_id == p1.id + assert p2.other_hate_from_player_id == p1.id + +def test_submit_techniques_rejects_collisions(session): + game = crud.create_game(session) + p1 = crud.add_player(session, game.id, "P1", is_creator=True) + p2 = crud.add_player(session, game.id, "P2") + + # Duplicates within a single submission are rejected + err = crud.submit_techniques(session, p1.id, "Tail Whip", "tail whip", "Cheese Decoy") + assert err is not None + session.refresh(p1) + assert json.loads(p1.created_techniques) == [] + + # Valid submission goes through + err = crud.submit_techniques(session, p1.id, "Tail Whip", "Pocket Sand", "Cheese Decoy") + assert err is None + + # A second player colliding (case-insensitively) with the pool is rejected + err = crud.submit_techniques(session, p2.id, "POCKET SAND", "Bilge Bomb", "Rat King Roar") + assert err is not None + session.refresh(p2) + assert json.loads(p2.created_techniques) == [] + + # And a unique set is accepted, triggering the swap + err = crud.submit_techniques(session, p2.id, "Plank Pirouette", "Bilge Bomb", "Rat King Roar") + assert err is None + session.refresh(game) + assert game.phase == "swap_techniques" + +def test_roll_new_character_smell_name_normalization(session): + game = crud.create_game(session) + p1 = crud.add_player(session, game.id, "P1") + p2 = crud.add_player(session, game.id, "P2") + p1.is_dead = True + session.add(p1) + session.commit() + + crud.roll_new_character( + session, + game.id, + p1.id, + avatar_look="Shiny eyes", + avatar_smell="suspiciously smelling of ink and sour lemons", + first_words="No fear!", + good_at_math="Yes" + ) + session.refresh(p1) + assert p1.name == "Recruit Ink and sour lemons" + +def test_roll_new_character_avoids_technique_collisions(session): + game = crud.create_game(session) + p1 = crud.add_player(session, game.id, "P1") + p2 = crud.add_player(session, game.id, "P2") + + # Give the surviving player techniques straight from the suggestion pool + p2.tech_jack = cards.TECHNIQUE_SUGGESTIONS[0] + p2.tech_queen = cards.TECHNIQUE_SUGGESTIONS[1] + p2.tech_king = cards.TECHNIQUE_SUGGESTIONS[2] + p1.is_dead = True + session.add_all([p1, p2]) + session.commit() + + # Shrink the pool so a collision would be guaranteed without the exclusion logic + import pirats.crud_character # noqa: F401 (module uses cards.TECHNIQUE_SUGGESTIONS) + original = cards.TECHNIQUE_SUGGESTIONS + cards.TECHNIQUE_SUGGESTIONS = original[:6] + try: + crud.roll_new_character( + session, game.id, p1.id, + avatar_look="Shiny eyes", avatar_smell="Spiced Rum", + first_words="No fear!", good_at_math="Yes" + ) + finally: + cards.TECHNIQUE_SUGGESTIONS = original + + session.refresh(p1) + recruit_techs = {p1.tech_jack, p1.tech_queen, p1.tech_king} + assert recruit_techs == set(original[3:6])