Dead Pi-Rat reroll rework

This commit is contained in:
2026-06-12 11:32:15 -07:00
parent df7a0cc83f
commit f61201144f
18 changed files with 908 additions and 460 deletions

10
TODO.md
View File

@@ -1,7 +1,5 @@
A roughly triaged list of features and improvements needed for the app, in descending order of criticality. A roughly triaged list of features and improvements needed for the app, in descending order of criticality.
- [ ] **Dead Pi-Rat re-roll flow.** I suspect this is badly broken, as it was implemented in a rush by an older model. In particular, other players will need to contribute likes/hates and techniques, which I believe are not currently implemented. This flow should also be used for new players who join after initial character creation, and should take place as a between-scenes phase after Deep Upkeep.
- [ ] **Dev Mode.** Move the dev mode toggle in character creation to the hamburger mode (and leave it around for all phases in case we want to add other dev mode features). It's now only visible to admin player, but affects all players. For non-admin players, the "suggest" buttons in the character creator are hidden unless dev mode is on. For the admin player, they also get the "auto-assign everything" button in the hamburger menu, renamed to "Skip Character Creation", which fully automates any incomplete parts of character creation for all players and moves on to the pre-scene phase. - [ ] **Dev Mode.** Move the dev mode toggle in character creation to the hamburger mode (and leave it around for all phases in case we want to add other dev mode features). It's now only visible to admin player, but affects all players. For non-admin players, the "suggest" buttons in the character creator are hidden unless dev mode is on. For the admin player, they also get the "auto-assign everything" button in the hamburger menu, renamed to "Skip Character Creation", which fully automates any incomplete parts of character creation for all players and moves on to the pre-scene phase.
- [ ] **Technique swapping.** During character creation, players can offer to swap techniques with another player, as long as the technique they're offering wasn't created by the player they're offering to swap with. The swap offer should contain no information other than that a swap has been offered. This requires tracking who created each technique, which we should be doing anyway since it's illegal for a player to wind up with their own techniques and I'm not sure the current shuffler cares about that. I want two options here: First option, players start with all of their own techniques and the game can't proceed to technique assignment to cards until they've successfully swapped them all away and everyone has clicked a "ready" button. Second, a button in the hamburger menu that the Admin player can click to fully auto-assign techniques, which randomly distributes them to eligible players and automatically assigns them to J/Q/K. This button should only appear during technique distribution. It does not require dev mode, just admin privileges. - [ ] **Technique swapping.** During character creation, players can offer to swap techniques with another player, as long as the technique they're offering wasn't created by the player they're offering to swap with. The swap offer should contain no information other than that a swap has been offered. This requires tracking who created each technique, which we should be doing anyway since it's illegal for a player to wind up with their own techniques and I'm not sure the current shuffler cares about that. I want two options here: First option, players start with all of their own techniques and the game can't proceed to technique assignment to cards until they've successfully swapped them all away and everyone has clicked a "ready" button. Second, a button in the hamburger menu that the Admin player can click to fully auto-assign techniques, which randomly distributes them to eligible players and automatically assigns them to J/Q/K. This button should only appear during technique distribution. It does not require dev mode, just admin privileges.
@@ -15,3 +13,11 @@ A roughly triaged list of features and improvements needed for the app, in desce
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing. - [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint. - [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
- [ ] **Defensive coding.** While we trust our players, this app is exposed on the open internet. I'd like to take basic precautions like making sure we're safe against SQL injection, doing some basic sanitizing of free inputs from players, and making sure that every game action API call is specific and constrained enough that it can't just do arbitrary state updates.
- [ ] **Player minimum.** You need at least three players to start a game.
- [ ] **Player kicking.** Admins should be able to kick players out of the game so that it doesn't get stuck if a player vanishes.
- [ ] **Dead Pi-Rat rank voting streamlined**. Don't allow voting for dead Pi-Rats. If a player's only vote options are dead, then they skip voting entirely.

View File

@@ -3,6 +3,7 @@
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions'; import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions';
import { displayName } from '../lib/cards'; import { displayName } from '../lib/cards';
import FaceCardAssigner from './FaceCardAssigner.svelte';
export let state; export let state;
@@ -352,130 +353,7 @@
<div class="alert alert-danger">{faceError}</div> <div class="alert alert-danger">{faceError}</div>
{/if} {/if}
<div class="available-techniques-container"> <FaceCardAssigner techniques={swappedTechniques} bind:jack={jackTech} bind:queen={queenTech} bind:king={kingTech} />
<h4>Available Swapped Techniques</h4>
<p class="instruction-help">Drag a technique onto a card slot below to assign it.</p>
<div class="techniques-drag-pool">
{#each swappedTechniques as tech}
<div
draggable={tech !== jackTech && tech !== queenTech && tech !== kingTech}
on:dragstart={(e) => e.dataTransfer.setData('text/plain', tech)}
class="draggable-tech-chip {tech === jackTech || tech === queenTech || tech === kingTech ? 'assigned-hidden' : ''}"
>
<span class="drag-icon">⋮⋮</span> {tech}
</div>
{/each}
</div>
</div>
<!-- Visual Card Slots Grid -->
<div class="face-cards-slots-grid">
<!-- JACK SLOT -->
<div class="face-card-slot-wrapper">
<div class="face-card-slot {jackTech ? 'has-assignment' : ''}"
on:dragover|preventDefault
on:drop={(e) => {
const tech = e.dataTransfer.getData('text/plain');
if (tech) {
if (queenTech === tech) queenTech = '';
if (kingTech === tech) kingTech = '';
jackTech = tech;
}
}}>
<div class="card-bg-letter">J</div>
<div class="card-slot-header">
<span class="rank">J</span>
</div>
<div class="card-slot-body">
<div class="card-title">Jack</div>
<div class="drop-zone-placeholder">Drop Technique Here</div>
<div class="assigned-tech-content">
{#if jackTech}
<div class="assigned-tech-chip">
{jackTech}
<span class="remove-tech-btn" on:click={() => jackTech = ''}>×</span>
</div>
{/if}
</div>
</div>
<div class="card-slot-footer">
<span></span>
<span class="rank">J</span>
</div>
</div>
</div>
<!-- QUEEN SLOT -->
<div class="face-card-slot-wrapper">
<div class="face-card-slot {queenTech ? 'has-assignment' : ''}"
on:dragover|preventDefault
on:drop={(e) => {
const tech = e.dataTransfer.getData('text/plain');
if (tech) {
if (jackTech === tech) jackTech = '';
if (kingTech === tech) kingTech = '';
queenTech = tech;
}
}}>
<div class="card-bg-letter">Q</div>
<div class="card-slot-header">
<span class="rank">Q</span>
</div>
<div class="card-slot-body">
<div class="card-title">Queen</div>
<div class="drop-zone-placeholder">Drop Technique Here</div>
<div class="assigned-tech-content">
{#if queenTech}
<div class="assigned-tech-chip">
{queenTech}
<span class="remove-tech-btn" on:click={() => queenTech = ''}>×</span>
</div>
{/if}
</div>
</div>
<div class="card-slot-footer">
<span></span>
<span class="rank">Q</span>
</div>
</div>
</div>
<!-- KING SLOT -->
<div class="face-card-slot-wrapper">
<div class="face-card-slot {kingTech ? 'has-assignment' : ''}"
on:dragover|preventDefault
on:drop={(e) => {
const tech = e.dataTransfer.getData('text/plain');
if (tech) {
if (jackTech === tech) jackTech = '';
if (queenTech === tech) queenTech = '';
kingTech = tech;
}
}}>
<div class="card-bg-letter">K</div>
<div class="card-slot-header">
<span class="rank">K</span>
</div>
<div class="card-slot-body">
<div class="card-title">King</div>
<div class="drop-zone-placeholder">Drop Technique Here</div>
<div class="assigned-tech-content">
{#if kingTech}
<div class="assigned-tech-chip">
{kingTech}
<span class="remove-tech-btn" on:click={() => kingTech = ''}>×</span>
</div>
{/if}
</div>
</div>
<div class="card-slot-footer">
<span></span>
<span class="rank">K</span>
</div>
</div>
</div>
</div>
<div class="mt-6"> <div class="mt-6">
<button <button

View File

@@ -0,0 +1,79 @@
<script>
// Drag-and-drop assignment of 3 techniques onto the J/Q/K face cards.
// Used during initial character creation and between-scenes recruit creation.
export let techniques = [];
export let jack = '';
export let queen = '';
export let king = '';
const slots = [
{ key: 'jack', letter: 'J', title: 'Jack' },
{ key: 'queen', letter: 'Q', title: 'Queen' },
{ key: 'king', letter: 'K', title: 'King' },
];
$: values = { jack, queen, king };
$: assigned = [jack, queen, king];
function set(key, val) {
if (key === 'jack') jack = val;
else if (key === 'queen') queen = val;
else king = val;
}
function handleDrop(e, key) {
const tech = e.dataTransfer.getData('text/plain');
if (!tech) return;
for (const s of slots) {
if (s.key !== key && values[s.key] === tech) set(s.key, '');
}
set(key, tech);
}
</script>
<div class="available-techniques-container">
<h4>Available Techniques</h4>
<p class="instruction-help">Drag a technique onto a card slot below to assign it.</p>
<div class="techniques-drag-pool">
{#each techniques as tech}
<div
draggable={!assigned.includes(tech)}
on:dragstart={(e) => e.dataTransfer.setData('text/plain', tech)}
class="draggable-tech-chip {assigned.includes(tech) ? 'assigned-hidden' : ''}"
>
<span class="drag-icon">⋮⋮</span> {tech}
</div>
{/each}
</div>
</div>
<div class="face-cards-slots-grid">
{#each slots as s}
<div class="face-card-slot-wrapper">
<div class="face-card-slot {values[s.key] ? 'has-assignment' : ''}"
on:dragover|preventDefault
on:drop={(e) => handleDrop(e, s.key)}>
<div class="card-bg-letter">{s.letter}</div>
<div class="card-slot-header">
<span class="rank">{s.letter}</span>
</div>
<div class="card-slot-body">
<div class="card-title">{s.title}</div>
<div class="drop-zone-placeholder">Drop Technique Here</div>
<div class="assigned-tech-content">
{#if values[s.key]}
<div class="assigned-tech-chip">
{values[s.key]}
<span class="remove-tech-btn" on:click={() => set(s.key, '')}>×</span>
</div>
{/if}
</div>
</div>
<div class="card-slot-footer">
<span></span>
<span class="rank">{s.letter}</span>
</div>
</div>
</div>
{/each}
</div>

View File

@@ -0,0 +1,309 @@
<script>
import { apiRequest } from '../lib/api';
import { getSuggestion, getTechniqueSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards';
import FaceCardAssigner from './FaceCardAssigner.svelte';
export let state;
function getPlayerName(id) {
if (!id) return 'None';
const p = state.players.find(x => x.id === id);
return p ? displayName(p) : 'Unknown';
}
function incomingTechs(p) {
return p.incoming_techniques ? JSON.parse(p.incoming_techniques) : [];
}
$: me = state.player;
$: recruits = state.players.filter(p => p.needs_reroll);
$: iAmRecruit = me.needs_reroll;
// --- My recruit sheet (only if I'm a pending recruit) ---
let basicDetails = {
avatar_look: state.player.avatar_look || '',
avatar_smell: state.player.avatar_smell || '',
first_words: state.player.first_words || '',
good_at_math: state.player.good_at_math || ''
};
let savingBasic = false;
async function saveBasicDetails() {
savingBasic = true;
try {
await apiRequest(`/game/${state.game.id}/player/${me.id}/save-basic`, 'POST', basicDetails);
} catch(e) {
console.error(e);
} finally {
savingBasic = false;
}
}
$: basicComplete = !!(me.avatar_look && me.avatar_smell && me.first_words && me.good_at_math);
$: likeDone = !me.other_like_from_player_id || !!me.other_like;
$: hateDone = !me.other_hate_from_player_id || !!me.other_hate;
$: myTechs = incomingTechs(me);
$: myTechTexts = myTechs.map(s => s.text);
$: techsDone = myTechTexts.length === 3 && myTechTexts.every(t => t);
// J/Q/K assignment + finalize
let jackTech = '', queenTech = '', kingTech = '';
let finalizing = false;
let finalizeError = '';
async function finalizeRecruit() {
finalizing = true;
finalizeError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${me.id}/finalize-recruit`, 'POST', {
jack: jackTech, queen: queenTech, king: kingTech
});
} catch(e) {
finalizeError = e.message;
} finally {
finalizing = false;
}
}
// --- Helping other recruits ---
// Like/Hate answers (same endpoint as character creation)
let inboxAnswers = {};
async function submitDelegatedAnswer(type, targetId) {
const answer = (inboxAnswers[`${targetId}:${type}`] || '').trim();
if (!answer) return;
try {
await apiRequest(`/game/${state.game.id}/player/${me.id}/submit-delegate/${targetId}/${type}`, 'POST', { answer });
delete inboxAnswers[`${targetId}:${type}`];
inboxAnswers = inboxAnswers;
} catch(e) {
console.error(e);
}
}
// Technique contributions: one draft per (recruit, slot)
let techDrafts = {};
let techErrors = {};
async function contributeTechnique(recruitId, slot) {
const text = (techDrafts[`${recruitId}:${slot}`] || '').trim();
if (!text) return;
try {
await apiRequest(`/game/${state.game.id}/player/${me.id}/contribute-technique`, 'POST', {
recruit_id: recruitId, slot, text
});
delete techDrafts[`${recruitId}:${slot}`];
techDrafts = techDrafts;
delete techErrors[`${recruitId}:${slot}`];
techErrors = techErrors;
} catch(e) {
techErrors[`${recruitId}:${slot}`] = e.message;
techErrors = techErrors;
}
}
async function suggestTechnique(recruitId, slot) {
techDrafts[`${recruitId}:${slot}`] = await getTechniqueSuggestion(Object.values(techDrafts));
techDrafts = techDrafts;
}
// My outstanding tasks for other recruits
$: myTasks = recruits.filter(p => p.id !== me.id).map(p => ({
recruit: p,
like: p.other_like_from_player_id === me.id && !p.other_like,
hate: p.other_hate_from_player_id === me.id && !p.other_hate,
techSlots: incomingTechs(p).map((s, i) => ({ ...s, slot: i })).filter(s => s.from_id === me.id)
})).filter(t => t.like || t.hate || t.techSlots.length > 0);
function recruitProgress(p) {
const techs = incomingTechs(p);
const parts = [];
parts.push(p.avatar_look && p.avatar_smell && p.first_words && p.good_at_math ? '✅ Records' : '❌ Records');
const likeOk = !p.other_like_from_player_id || p.other_like;
const hateOk = !p.other_hate_from_player_id || p.other_hate;
parts.push(likeOk && hateOk ? '✅ Like/Hate' : '❌ Like/Hate');
parts.push(`${techs.filter(s => s.text).length}/3 Techniques`);
return parts.join(' · ');
}
</script>
<div class="recruit-phase-view">
<div class="view-header text-center">
<h2>🐀 New Recruits</h2>
<p>Fresh rats are washing aboard! Recruits fill in their Rat Records while the crew answers their Like/Hate questions and writes their Secret Techniques.</p>
</div>
<div class="creation-grid max-w-5xl mx-auto">
{#if iAmRecruit}
<!-- MY NEW RECRUIT SHEET -->
<div class="card glass-panel section-basic">
<h3>I. Your New Rat Records {basicComplete ? '✅' : '❌'}</h3>
{#if basicComplete}
<div class="read-only-sheet">
<div class="record-line"><strong>Look:</strong> {me.avatar_look}</div>
<div class="record-line"><strong>Smell:</strong> {me.avatar_smell}</div>
<div class="record-line"><strong>First Words:</strong> "{me.first_words}"</div>
<div class="record-line"><strong>Good at Math?</strong> {me.good_at_math}</div>
</div>
{:else}
<form on:submit|preventDefault={saveBasicDetails} class="creation-form">
<div class="form-group">
<label for="avatar_look">What does your Pi-Rat look like?</label>
<div class="input-row">
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>
</div>
</div>
<div class="form-group">
<label for="avatar_smell">What does your Pi-Rat smell like? (Determines name)</label>
<div class="input-row">
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>
</div>
</div>
<div class="form-group">
<label for="first_words">What were your first words after transforming?</label>
<div class="input-row">
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>
</div>
</div>
<div class="form-group">
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
<div class="input-row">
<input type="text" id="good_at_math" placeholder="e.g. Thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>
</div>
</div>
<button type="submit" class="btn btn-primary btn-full" disabled={savingBasic}>Save Records</button>
</form>
{/if}
</div>
<div class="card glass-panel section-delegations">
<h3>II. Crew Delegations {likeDone && hateDone ? '✅' : '❌'}</h3>
<p class="section-desc">Crewmates decide what their rats like and hate about your new recruit.</p>
<div class="delegation-list">
<div class="delegation-box glass-panel">
<h4>What does another rat LIKE about you?</h4>
<div class="answer-box">
{me.other_like ? `"${me.other_like}"` : '(Waiting for answer...)'}
</div>
<div class="author-label"> {getPlayerName(me.other_like_from_player_id)}</div>
</div>
<div class="delegation-box glass-panel">
<h4>What does another rat HATE about you?</h4>
<div class="answer-box">
{me.other_hate ? `"${me.other_hate}"` : '(Waiting for answer...)'}
</div>
<div class="author-label"> {getPlayerName(me.other_hate_from_player_id)}</div>
</div>
</div>
</div>
<div class="card glass-panel section-techniques" style="grid-column: span 2;">
<h3>III. Your New Secret Techniques {techsDone ? '✅' : '❌'}</h3>
{#if !techsDone}
<p class="section-desc">Your crewmates are writing 3 fresh techniques for you:</p>
<div class="submitted-techniques text-center mt-4">
{#each myTechs as slot, i}
<div class="tech-chip">#{i + 1}: {slot.text ? slot.text : `Waiting for ${getPlayerName(slot.from_id)}...`}</div>
{/each}
</div>
{:else}
<div class="max-w-4xl mx-auto space-y-6">
<p>Drag and drop your new techniques onto your Face Cards:</p>
{#if finalizeError}
<div class="alert alert-danger">{finalizeError}</div>
{/if}
<FaceCardAssigner techniques={myTechTexts} bind:jack={jackTech} bind:queen={queenTech} bind:king={kingTech} />
<div class="mt-6">
<button
class="btn btn-primary btn-large w-full glow-effect"
disabled={finalizing || !basicComplete || !likeDone || !hateDone || !jackTech || !queenTech || !kingTech}
on:click={finalizeRecruit}
>
{finalizing ? 'Joining the Crew...' : '🐀 Join the Crew!'}
</button>
{#if !basicComplete || !likeDone || !hateDone}
<p class="info-text text-center" style="margin-top: 0.5rem;">Finish your Rat Records and wait for your Like/Hate answers before joining.</p>
{/if}
</div>
</div>
{/if}
</div>
{/if}
<!-- TASKS FOR OTHER RECRUITS -->
<div class="card glass-panel section-inbox" style="grid-column: span 2;">
<h3>{iAmRecruit ? 'IV.' : 'I.'} Your Inbox (Help the Recruits)</h3>
<p class="section-desc">Answer questions and write Secret Techniques for the fresh recruits. Be creative and have fun with it!</p>
<div class="inbox-list">
{#each myTasks as task (task.recruit.id)}
{#if task.like}
<div class="inbox-item">
<label>What do you LIKE about {displayName(task.recruit)}?</label>
<div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:like`]} placeholder="e.g. They always share their cheese"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:like`] = getSuggestion('like')}>Suggest</button>
<button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', task.recruit.id)}>Submit</button>
</div>
</div>
{/if}
{#if task.hate}
<div class="inbox-item">
<label>What do you HATE about {displayName(task.recruit)}?</label>
<div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:hate`]} placeholder="e.g. They snore too loudly"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:hate`] = getSuggestion('hate')}>Suggest</button>
<button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', task.recruit.id)}>Submit</button>
</div>
</div>
{/if}
{#each task.techSlots as slot}
<div class="inbox-item">
<label>
Write a Secret Technique for {displayName(task.recruit)}
{#if slot.text}<span class="text-success">(submitted: "{slot.text}" — you can revise it)</span>{/if}
</label>
{#if techErrors[`${task.recruit.id}:${slot.slot}`]}
<div class="alert alert-danger">{techErrors[`${task.recruit.id}:${slot.slot}`]}</div>
{/if}
<div class="input-row">
<input type="text" class="input-field" bind:value={techDrafts[`${task.recruit.id}:${slot.slot}`]} placeholder="e.g. Parabolic boarding bounce"/>
<button type="button" class="btn btn-secondary btn-small" on:click={() => suggestTechnique(task.recruit.id, slot.slot)}>Suggest</button>
<button class="btn btn-primary" disabled={!(techDrafts[`${task.recruit.id}:${slot.slot}`] || '').trim()} on:click={() => contributeTechnique(task.recruit.id, slot.slot)}>Submit</button>
</div>
</div>
{/each}
{/each}
{#if myTasks.length === 0}
<p class="inbox-empty-message text-muted italic">
{#if iAmRecruit}
No tasks your crewmates are hard at work on your new rat.
{:else}
Nothing left to write. Waiting for the recruits to finish their sheets...
{/if}
</p>
{/if}
</div>
</div>
<!-- RECRUIT PROGRESS -->
<div class="card glass-panel section-crew-status" style="grid-column: span 2;">
<h3>{iAmRecruit ? 'V.' : 'II.'} Recruit Progress</h3>
<div class="crew-status-list">
<ul class="space-y-2 mt-4">
{#each recruits as p}
<li class="status-row">
<span>{displayName(p)}</span>
<span class="text-sm text-muted">{recruitProgress(p)}</span>
</li>
{/each}
{#if recruits.length === 0}
<li class="status-row"><span class="text-success">All recruits are aboard! Heading to the next scene...</span></li>
{/if}
</ul>
</div>
</div>
</div>
</div>

View File

@@ -41,7 +41,7 @@
} }
} }
$: allRolesAssigned = state.players.every(p => p.role !== null); $: allRolesAssigned = state.players.every(p => p.role !== null || p.needs_reroll);
$: deepPlayer = state.players.find(p => p.role === 'deep'); $: deepPlayer = state.players.find(p => p.role === 'deep');
$: piratPlayer = state.players.find(p => p.role === 'pirat'); $: piratPlayer = state.players.find(p => p.role === 'pirat');
$: canStart = allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_creator || state.player.role === 'deep'); $: canStart = allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_creator || state.player.role === 'deep');
@@ -68,6 +68,12 @@
<div class="card glass-panel role-selection-card"> <div class="card glass-panel role-selection-card">
<h3>Select Your Role</h3> <h3>Select Your Role</h3>
{#if state.player.needs_reroll}
<div class="notice-banner accent">
<p class="info-text" style="margin: 0;">🐀 You don't have a Pi-Rat yet! You'll spectate this scene and create your recruit with the crew between scenes.</p>
</div>
{/if}
<div class="role-buttons"> <div class="role-buttons">
<button on:click={() => setRole('pirat')} <button on:click={() => setRole('pirat')}
disabled={settingRole || !allowedRoles.includes('pirat')} disabled={settingRole || !allowedRoles.includes('pirat')}
@@ -83,14 +89,14 @@
</div> </div>
<div class="role-restrictions mt-4"> <div class="role-restrictions mt-4">
{#if !allowedRoles.includes('pirat')} {#if !allowedRoles.includes('pirat') && !state.player.needs_reroll}
{#if state.game.current_scene_number === 1} {#if state.game.current_scene_number === 1}
<p class="text-danger text-sm italic">You start at Rank 3, so you must play The Deep for the first scene!</p> <p class="text-danger text-sm italic">You start at Rank 3, so you must play The Deep for the first scene!</p>
{:else} {:else}
<p class="text-danger text-sm italic">You are the only eligible player for The Deep this scene!</p> <p class="text-danger text-sm italic">You are the only eligible player for The Deep this scene!</p>
{/if} {/if}
{/if} {/if}
{#if !allowedRoles.includes('deep')} {#if !allowedRoles.includes('deep') && !state.player.needs_reroll}
{#if state.game.current_scene_number === 1} {#if state.game.current_scene_number === 1}
<p class="text-danger text-sm italic">You start at Rank 1, so you must play your Pi-Rat for the first scene!</p> <p class="text-danger text-sm italic">You start at Rank 1, so you must play your Pi-Rat for the first scene!</p>
{:else} {:else}

View File

@@ -1,6 +1,5 @@
<script> <script>
import { apiRequest } from '../lib/api'; import { apiRequest } from '../lib/api';
import { getSuggestion } from '../lib/suggestions';
import { displayName } from '../lib/cards'; import { displayName } from '../lib/cards';
import Card from './Card.svelte'; import Card from './Card.svelte';
@@ -20,16 +19,7 @@
let discardCards = []; let discardCards = [];
let dragNDropInitialized = false; let dragNDropInitialized = false;
// Ghost & Re-rolling fate states // Ghost & Re-rolling fate choice
let isRolling = false;
let reRollDetails = {
avatar_look: '',
avatar_smell: '',
first_words: '',
good_at_math: ''
};
let rollingError = '';
async function becomeGhost() { async function becomeGhost() {
try { try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/become-ghost`, 'POST'); await apiRequest(`/game/${state.game.id}/player/${state.player.id}/become-ghost`, 'POST');
@@ -38,6 +28,14 @@
} }
} }
async function chooseReRoll() {
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/choose-reroll`, 'POST');
} catch(e) {
console.error(e);
}
}
async function finishGame() { 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; 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 { try {
@@ -47,30 +45,6 @@
} }
} }
async function submitReRoll() {
if (!reRollDetails.avatar_look.trim() ||
!reRollDetails.avatar_smell.trim() ||
!reRollDetails.first_words.trim() ||
!reRollDetails.good_at_math.trim()) {
rollingError = 'Please fill out all character details.';
return;
}
rollingError = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/roll-new-character`, 'POST', reRollDetails);
isRolling = false;
// Clear inputs
reRollDetails = {
avatar_look: '',
avatar_smell: '',
first_words: '',
good_at_math: ''
};
} catch(e) {
rollingError = e.message;
}
}
$: { $: {
if (state && state.game.phase === 'deep_upkeep') { if (state && state.game.phase === 'deep_upkeep') {
if (!dragNDropInitialized) { if (!dragNDropInitialized) {
@@ -182,74 +156,31 @@
<h2>Between Scenes (Scene #{state.game.current_scene_number} Concluded)</h2> <h2>Between Scenes (Scene #{state.game.current_scene_number} Concluded)</h2>
<p class="description">Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.</p> <p class="description">Upkeep and tallying. Nominate a crewmate to Rank Up, redraw hand cards for resting Deep players, and ready up for the next scene.</p>
{#if state.player.is_dead && !state.player.is_ghost} {#if state.player.is_dead && !state.player.is_ghost && !state.player.needs_reroll}
<!-- Death Fate Panel --> <!-- Death Fate Panel -->
<div class="glass-panel death-fate-card"> <div class="glass-panel death-fate-card">
<h2>💀 Your Pi-Rat Has Died!</h2> <h2>💀 Your Pi-Rat Has Died!</h2>
<p class="description"> <p class="description">
Your story arc is complete! You went out in a blaze of glory (or retired honorably). You must now choose how your journey continues: Your story arc is complete! You went out in a blaze of glory (or retired honorably). You must now choose how your journey continues:
</p> </p>
{#if !isRolling}
<div class="fate-choices"> <div class="fate-choices">
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost}> <button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost}>
👻 Remain as a Ghost 👻 Remain as a Ghost
</button> </button>
<button class="btn btn-primary btn-large glow-effect" on:click={() => isRolling = true}> <button class="btn btn-primary btn-large glow-effect" on:click={chooseReRoll}>
🐀 Roll a New Character 🐀 Roll a New Character
</button> </button>
</div> </div>
<p class="info-text italic" style="margin-top: 1.5rem;"> <p class="info-text italic" style="margin-top: 1.5rem;">
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. Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh at Rank 1 — after upkeep, the whole crew will help create them.
</p> </p>
{:else}
<div class="creation-form-wrapper">
<h3>Create Your New Recruit</h3>
{#if rollingError}
<div class="alert alert-danger" style="margin-bottom: 1.5rem;">{rollingError}</div>
{/if}
<div class="form-group">
<label>What does your Pi-Rat look like?</label>
<div class="input-row">
<input type="text" placeholder="e.g. Scruffy snout, wearing a tattered vest" bind:value={reRollDetails.avatar_look} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.avatar_look = getSuggestion('look')}>Suggest</button>
</div>
</div>
<div class="form-group">
<label>What does your Pi-Rat smell like? (Determines name)</label>
<div class="input-row">
<input type="text" placeholder="e.g. Spiced rum, salty cheese, gunpowder" bind:value={reRollDetails.avatar_smell} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>
</div>
</div>
<div class="form-group">
<label>What were your first words after transforming?</label>
<div class="input-row">
<input type="text" placeholder="e.g. Shiver my decimals!" bind:value={reRollDetails.first_words} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.first_words = getSuggestion('first_words')}>Suggest</button>
</div>
</div>
<div class="form-group">
<label>Is your Pi-Rat good at Math?</label>
<div class="input-row">
<input type="text" placeholder="e.g. Thinks Pi is a dessert" bind:value={reRollDetails.good_at_math} required class="input-field">
<button type="button" class="btn btn-secondary btn-small" on:click={() => reRollDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>
</div>
</div>
<div class="form-actions">
<button class="btn btn-secondary" on:click={() => isRolling = false}>Cancel</button>
<button class="btn btn-primary" on:click={submitReRoll}>Submit New Character</button>
</div>
</div>
{/if}
</div> </div>
{:else} {:else}
{#if state.player.needs_reroll}
<div class="notice-banner" style="margin-bottom: 1rem;">
<p style="margin: 0;">🐀 A fresh recruit is on the way! Once upkeep wraps up, you and the crew will create your new Pi-Rat together.</p>
</div>
{/if}
<div class="between-grid"> <div class="between-grid">
<!-- Ranks and Voting Card --> <!-- Ranks and Voting Card -->
<div class="card glass-panel voting-card"> <div class="card glass-panel voting-card">

View File

@@ -54,6 +54,13 @@
<div class="alert alert-danger">{error}</div> <div class="alert alert-danger">{error}</div>
{/if} {/if}
{#if state.player.needs_reroll}
<div class="prompt-box accent text-center">
<h4>🐀 Recruit Incoming!</h4>
<p class="info-text" style="margin-bottom: 0; font-size: 0.9rem;">You don't have a Pi-Rat in this scene. Sit back, enjoy the chaos, and you'll create your recruit with the crew between scenes.</p>
</div>
{/if}
{#if state.player.is_dead} {#if state.player.is_dead}
<div class="prompt-box danger text-center"> <div class="prompt-box danger text-center">
<h4>💀 You have Died / Retired!</h4> <h4>💀 You have Died / Retired!</h4>
@@ -102,7 +109,7 @@
</div> </div>
<!-- Duel another Pi-Rat --> <!-- Duel another Pi-Rat -->
{#if !state.player.is_dead && duelTargets.length > 0} {#if !state.player.is_dead && !state.player.needs_reroll && duelTargets.length > 0}
<div class="sheet-group margin-top"> <div class="sheet-group margin-top">
<h4>⚔️ Duel a Pi-Rat</h4> <h4>⚔️ Duel a Pi-Rat</h4>
<p class="info-text" style="font-size: 0.85rem;">Challenge a crewmate (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p> <p class="info-text" style="font-size: 0.85rem;">Challenge a crewmate (e.g. for the Captaincy)! Your card becomes a temporary Obstacle they must beat. Both cards are discarded afterwards.</p>

View File

@@ -8,6 +8,7 @@
import SceneSetupPhase from '../components/SceneSetupPhase.svelte'; import SceneSetupPhase from '../components/SceneSetupPhase.svelte';
import ScenePhase from '../components/ScenePhase.svelte'; import ScenePhase from '../components/ScenePhase.svelte';
import UpkeepPhase from '../components/UpkeepPhase.svelte'; import UpkeepPhase from '../components/UpkeepPhase.svelte';
import RecruitPhase from '../components/RecruitPhase.svelte';
import GameOverPhase from '../components/GameOverPhase.svelte'; import GameOverPhase from '../components/GameOverPhase.svelte';
import EventLog from '../components/EventLog.svelte'; import EventLog from '../components/EventLog.svelte';
@@ -91,6 +92,8 @@
<ScenePhase {state} /> <ScenePhase {state} />
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'} {:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
<UpkeepPhase {state} /> <UpkeepPhase {state} />
{:else if state.game.phase === 'recruit_creation'}
<RecruitPhase {state} />
{:else if state.game.phase === 'ended'} {:else if state.game.phase === 'ended'}
<GameOverPhase {state} /> <GameOverPhase {state} />
{:else} {:else}

View File

@@ -244,9 +244,10 @@ def get_player(db: Session, player_id: str) -> Optional[Player]:
def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player: def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -> Player:
game = db.get(Game, game_id) game = db.get(Game, game_id)
role = None
if game and game.phase == "scene": # Players who join after initial character creation create their Pi-Rat in
role = "pirat" # the between-scenes recruit_creation phase; until then they spectate.
needs_reroll = bool(game and game.phase not in ("lobby", "character_creation"))
player = Player( player = Player(
game_id=game_id, game_id=game_id,
@@ -256,20 +257,28 @@ def add_player(db: Session, game_id: str, name: str, is_creator: bool = False) -
rank=2, # default starting rank (will be set properly when starting character creation) rank=2, # default starting rank (will be set properly when starting character creation)
hand_cards="[]", hand_cards="[]",
is_ready=False, is_ready=False,
role=role role=None,
needs_reroll=needs_reroll
) )
db.add(player) db.add(player)
db.commit() db.commit()
db.refresh(player) db.refresh(player)
# Players joining mid-scene would otherwise never receive cards (starting if game:
# hands are only dealt when scene 1 starts), so deal them in immediately.
if game and game.phase == "scene":
db.refresh(game) db.refresh(game)
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id) if game.phase == "character_creation":
draw_cards_for_player(db, game, player, max_size) # Late joiner to creation: hand out their Like/Hate questions
from .crud_character import auto_delegate_all
auto_delegate_all(db, game)
elif game.phase == "recruit_creation":
# Recruit creation is already underway; join it immediately
from .crud_character import activate_recruit
activate_recruit(db, game, player)
add_game_event(db, game_id, f"{name} joined the crew!", kind="join") msg = f"{name} joined the crew!"
if needs_reroll and game and game.phase != "recruit_creation":
msg += " They'll create their Pi-Rat with the crew between scenes."
add_game_event(db, game_id, msg, kind="join")
return player return player

View File

@@ -50,6 +50,8 @@ def create_challenge(
return False, "The Deep cannot challenge itself. Pick a Pi-Rat!" return False, "The Deep cannot challenge itself. Pick a Pi-Rat!"
if target.is_dead: if target.is_dead:
return False, f"{target.name} is dead. The Deep has no quarrel with the deceased." return False, f"{target.name} is dead. The Deep has no quarrel with the deceased."
if target.needs_reroll:
return False, f"{target.name} has no Pi-Rat in this scene."
if not obstacle_ids: if not obstacle_ids:
return False, "Apply at least one Obstacle to the Challenge." return False, "Apply at least one Obstacle to the Challenge."
@@ -262,7 +264,7 @@ def request_tax(db: Session, challenge_id: str, requester_id: str, tax_target_id
return False, "A Tax has already been called on this Challenge." return False, "A Tax has already been called on this Challenge."
if requester.tax_banned: if requester.tax_banned:
return False, "You failed a refused Tax this scene — no more Gat/Name Taxes for you!" 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: if target.id == requester.id or target.role == "deep" or target.is_dead or target.needs_reroll:
return False, "Pick another living Pi-Rat in the scene." return False, "Pick another living Pi-Rat in the scene."
if not requester.completed_personal_1: if not requester.completed_personal_1:
@@ -380,6 +382,8 @@ def create_pvp_challenge(
return False, "You cannot challenge yourself. (Well, you can, but not with cards.)" return False, "You cannot challenge yourself. (Well, you can, but not with cards.)"
if defender.is_dead: if defender.is_dead:
return False, f"{defender.name} is dead. Let them rest." return False, f"{defender.name} is dead. Let them rest."
if challenger.needs_reroll or defender.needs_reroll:
return False, "Pending recruits have no Pi-Rat to duel with."
for challenge in game.challenges: for challenge in game.challenges:
if challenge.status == "open" and defender.id in (challenge.target_player_id, challenge.acting_player_id): if challenge.status == "open" and defender.id in (challenge.target_player_id, challenge.acting_player_id):

View File

@@ -263,99 +263,192 @@ def assign_face_card_techniques(db: Session, player_id: str, jack: str, queen: s
db.commit() db.commit()
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase") add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
def roll_new_character( # --- Recruit Creation (death re-rolls and late joiners) ---
db: Session, #
game_id: str, # Dead Pi-Rats who choose to re-roll (and players who joined after initial
player_id: str, # character creation) are queued with needs_reroll=True. Once between-scenes
avatar_look: str, # upkeep finishes, the game detours through the "recruit_creation" phase:
avatar_smell: str, # the recruit answers the basic sheet questions while crewmates answer their
first_words: str, # Like/Hate questions and write their 3 replacement techniques. The recruit
good_at_math: str # then assigns those techniques to J/Q/K and finalizes, and once every pending
): # recruit has finalized the game moves on to scene_setup.
player = get_player(db, player_id)
game = get_game(db, game_id)
if not player or not game:
return
# 1. Return current hand cards to the deck def choose_reroll(db: Session, player_id: str):
from .crud_base import get_player_hand, set_player_hand, get_game_deck, set_game_deck, draw_cards_for_player, calculate_max_hand_size """A dead Pi-Rat opts to come back as a fresh recruit (vs. becoming a Ghost).
The actual creation happens in the recruit_creation phase after upkeep."""
player = get_player(db, player_id)
if not player:
return False, "Player not found."
if player.needs_reroll:
return True, "Already queued for a new recruit."
if not player.is_dead or player.is_ghost:
return False, "Only dead Pi-Rats can roll a new recruit."
player.needs_reroll = True
db.add(player)
db.commit()
add_game_event(db, player.game_id, f"{player.name} will return as a fresh recruit! The crew will help create them after upkeep.", kind="join")
return True, "Queued for recruit creation."
def techniques_in_use(game: Game, exclude_player_id: str = None) -> set:
"""Lowercased technique names already on someone's sheet or pending for a recruit."""
in_use = set()
for p in game.players:
if p.id == exclude_player_id:
continue
texts = [p.tech_jack, p.tech_queen, p.tech_king]
texts += json.loads(p.created_techniques) + json.loads(p.swapped_techniques)
texts += [s["text"] for s in json.loads(p.incoming_techniques)]
for t in texts:
if t:
in_use.add(t.strip().lower())
return in_use
def activate_recruit(db: Session, game: Game, player):
"""Wipes a pending recruit's sheet and assigns crewmates to answer their
Like/Hate questions and write their 3 replacement techniques."""
from .crud_base import get_player_hand, set_player_hand, get_game_deck, set_game_deck
# Return any leftover hand to the deck
hand = get_player_hand(player) hand = get_player_hand(player)
if hand:
deck = get_game_deck(game) deck = get_game_deck(game)
deck.extend(hand) deck.extend(hand)
random.shuffle(deck) random.shuffle(deck)
set_game_deck(game, deck) set_game_deck(game, deck)
set_player_hand(player, []) set_player_hand(player, [])
# 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
player.completed_personal_3 = False
player.needs_name = False
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 # A dead Captain's position does not pass to their replacement recruit
if game.captain_player_id == player.id: if game.captain_player_id == player.id:
game.captain_player_id = None game.captain_player_id = None
# Save player-provided details # New recruits always start at Rank 1 with a blank sheet
player.avatar_look = avatar_look.strip() player.rank = 1
player.avatar_smell = avatar_smell.strip() player.role = None
player.first_words = first_words.strip() player.is_ready = False
player.good_at_math = good_at_math.strip() player.is_dead = False
player.is_ghost = False
player.tax_banned = False
player.needs_name = False
player.needs_rank_3_bonus = False
player.completed_personal_1 = False
player.completed_personal_2 = False
player.completed_personal_3 = False
player.avatar_look = ""
player.avatar_smell = ""
player.first_words = ""
player.good_at_math = ""
player.created_techniques = "[]"
player.swapped_techniques = "[]"
player.tech_jack = ""
player.tech_queen = ""
player.tech_king = ""
player.name = recruit_name(player) player.name = recruit_name(player)
# 3. Randomly assign 3 new secret techniques, avoiding any technique already # Crewmates fill in the social questions and write the recruit's techniques.
# in play on another player's sheet (collisions make for boring recruits). # Prefer helpers who aren't busy creating their own recruit.
from .cards import TECHNIQUE_SUGGESTIONS others = [p for p in game.players if p.id != player.id]
in_use = set() helpers = [p for p in others if not p.needs_reroll] or others
for p in game.players: if helpers:
if p.id == player.id: random.shuffle(helpers)
continue player.other_like_from_player_id = helpers[0].id
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]
player.tech_queen = chosen_techs[1]
player.tech_king = chosen_techs[2]
# 4. Auto-delegate Like/Hate questions to other players
other_players = [p for p in game.players if p.id != player.id]
if other_players:
like_target = random.choice(other_players)
player.other_like_from_player_id = like_target.id
player.other_like = "" player.other_like = ""
player.other_hate_from_player_id = helpers[1 % len(helpers)].id
if len(other_players) >= 2:
remaining = [op for op in other_players if op.id != like_target.id]
hate_target = random.choice(remaining)
else:
hate_target = random.choice(other_players)
player.other_hate_from_player_id = hate_target.id
player.other_hate = "" player.other_hate = ""
slots = [{"from_id": helpers[i % len(helpers)].id, "text": ""} for i in range(3)]
player.incoming_techniques = json.dumps(slots)
else: else:
# Solo game: nobody to ask, so fall back to the suggestion pool
from .cards import TECHNIQUE_SUGGESTIONS
player.other_like_from_player_id = None player.other_like_from_player_id = None
player.other_like = "" player.other_like = ""
player.other_hate_from_player_id = None player.other_hate_from_player_id = None
player.other_hate = "" player.other_hate = ""
in_use = techniques_in_use(game, exclude_player_id=player.id)
available = [t for t in TECHNIQUE_SUGGESTIONS if t.strip().lower() not in in_use]
if len(available) < 3:
available = TECHNIQUE_SUGGESTIONS
chosen = random.sample(available, 3)
player.incoming_techniques = json.dumps([{"from_id": None, "text": t} for t in chosen])
db.add(player) db.add(player)
db.add(game) db.add(game)
db.commit() db.commit()
# 5. Draw cards up to new hand size based on new Rank def start_recruit_creation(db: Session, game: Game):
"""Enters the recruit_creation phase and activates every pending recruit."""
game.phase = "recruit_creation"
db.add(game)
db.commit()
for p in game.players:
if p.needs_reroll:
activate_recruit(db, game, p)
add_game_event(db, game.id, "Phase changed: New Recruits — help your crewmates create their fresh Rank 1 Pi-Rats!", kind="phase")
def contribute_recruit_technique(db: Session, contributor_id: str, recruit_id: str, slot: int, text: str):
"""A crewmate writes (or revises) one of their assigned technique slots for a recruit."""
recruit = get_player(db, recruit_id)
contributor = get_player(db, contributor_id)
if not recruit or not contributor or recruit.game_id != contributor.game_id:
return False, "Player not found."
game = get_game(db, recruit.game_id)
if not game or game.phase != "recruit_creation" or not recruit.needs_reroll:
return False, "That recruit isn't being created right now."
text = text.strip()
if not text:
return False, "Technique can't be empty."
slots = json.loads(recruit.incoming_techniques)
if slot < 0 or slot >= len(slots):
return False, "Invalid technique slot."
if slots[slot]["from_id"] != contributor_id:
return False, "That technique slot belongs to another crewmate."
in_use = techniques_in_use(game, exclude_player_id=recruit.id)
for i, s in enumerate(slots):
if i != slot and s["text"]:
in_use.add(s["text"].strip().lower())
if text.lower() in in_use:
return False, f'"{text}" is already in play. Pick something more original!'
slots[slot]["text"] = text
recruit.incoming_techniques = json.dumps(slots)
db.add(recruit)
db.commit()
return True, "Technique contributed."
def finalize_recruit(db: Session, player_id: str, jack: str, queen: str, king: str):
"""The recruit assigns their crew-written techniques to J/Q/K and joins the crew.
Once the last pending recruit finalizes, the game advances to scene_setup."""
player = get_player(db, player_id)
if not player:
return False, "Player not found."
game = get_game(db, player.game_id)
if not game or game.phase != "recruit_creation" or not player.needs_reroll:
return False, "No recruit creation in progress."
if not (player.avatar_look and player.avatar_smell and player.first_words and player.good_at_math):
return False, "Fill out your Rat Records first."
if player.other_like_from_player_id and not player.other_like:
return False, "A crewmate still owes you a LIKE answer."
if player.other_hate_from_player_id and not player.other_hate:
return False, "A crewmate still owes you a HATE answer."
texts = [s["text"] for s in json.loads(player.incoming_techniques)]
if len(texts) < 3 or not all(texts):
return False, "Your crewmates haven't finished writing your techniques."
if sorted([jack, queen, king]) != sorted(texts):
return False, "Assign each of your 3 new techniques to exactly one face card."
player.swapped_techniques = json.dumps(texts)
player.tech_jack = jack
player.tech_queen = queen
player.tech_king = king
player.needs_reroll = False
db.add(player)
db.commit()
# Deal the new recruit's starting hand
from .crud_base import calculate_max_hand_size, draw_cards_for_player
max_size = calculate_max_hand_size(player, game.players, game.captain_player_id) max_size = calculate_max_hand_size(player, game.players, game.captain_player_id)
draw_cards_for_player(db, game, player, max_size) draw_cards_for_player(db, game, player, max_size)
add_game_event(db, game.id, f"{player.name} has joined the crew as a fresh Rank {player.rank} recruit!", kind="join")
add_game_event(db, game.id, f"{player.name} has joined the crew as a new Rank {player.rank} recruit!", kind="join") if not any(p.needs_reroll for p in game.players):
from .crud_upkeep import begin_next_scene_setup
begin_next_scene_setup(db, game)
return True, "Welcome aboard!"

View File

@@ -35,11 +35,15 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
players = game.players players = game.players
# Default any unselected roles to 'pirat' # Default any unselected roles to 'pirat'. Pending recruits have no Pi-Rat
# yet, so they stay roleless and spectate until the next between-scenes.
for p in players: for p in players:
if p.role is None: if p.role is None and not p.needs_reroll:
p.role = "pirat" p.role = "pirat"
db.add(p) db.add(p)
elif p.needs_reroll:
p.role = None
db.add(p)
db.commit() db.commit()
if game.current_scene_number == 1: if game.current_scene_number == 1:
@@ -63,7 +67,7 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
# 2. Check single eligible Deep player # 2. Check single eligible Deep player
# If only one player is eligible to play the Deep, they must play the Deep. # If only one player is eligible to play the Deep, they must play the Deep.
if game.current_scene_number > 1: if game.current_scene_number > 1:
eligible_deeps = [p for p in players if p.previous_role != "deep"] eligible_deeps = [p for p in players if p.previous_role != "deep" and not p.needs_reroll]
if len(eligible_deeps) == 1: if len(eligible_deeps) == 1:
must_be_deep_player = eligible_deeps[0] must_be_deep_player = eligible_deeps[0]
if must_be_deep_player.role != "deep": if must_be_deep_player.role != "deep":
@@ -134,6 +138,8 @@ def confirm_scene_setup(db: Session, game_id: str) -> Tuple[bool, str]:
# players only draw via suit-color matches, hand size increases, and the Deep's upkeep redraw. # players only draw via suit-color matches, hand size increases, and the Deep's upkeep redraw.
if game.current_scene_number == 1: if game.current_scene_number == 1:
for p in players: for p in players:
if p.needs_reroll:
continue
max_size = calculate_max_hand_size(p, players, game.captain_player_id) max_size = calculate_max_hand_size(p, players, game.captain_player_id)
current_hand = get_player_hand(p) current_hand = get_player_hand(p)
while len(current_hand) < max_size and deck: while len(current_hand) < max_size and deck:
@@ -160,6 +166,10 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
players = game.players players = game.players
allowed = ["pirat", "deep"] allowed = ["pirat", "deep"]
if player.needs_reroll:
# Pending recruits spectate until their Pi-Rat is created between scenes
return []
if game.current_scene_number == 1: if game.current_scene_number == 1:
# Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either. # Rank 3 must play the Deep, Rank 1 must play their Pi-Rat, Rank 2 may choose either.
if len(players) >= 2: if len(players) >= 2:
@@ -174,7 +184,7 @@ def get_allowed_roles(db: Session, game_id: str, player_id: str) -> list[str]:
allowed.remove("deep") allowed.remove("deep")
# Rule 2: If only 1 eligible Deep, they MUST play Deep # Rule 2: If only 1 eligible Deep, they MUST play Deep
eligible_deeps = [p for p in players if p.previous_role != "deep"] eligible_deeps = [p for p in players if p.previous_role != "deep" and not p.needs_reroll]
if len(eligible_deeps) == 1 and eligible_deeps[0].id == player_id: if len(eligible_deeps) == 1 and eligible_deeps[0].id == player_id:
if "pirat" in allowed: if "pirat" in allowed:
allowed.remove("pirat") allowed.remove("pirat")

View File

@@ -23,6 +23,8 @@ def submit_rank_vote(db: Session, game_id: str, voter_id: str, nominated_id: str
return False, "You cannot nominate yourself. Nice try." return False, "You cannot nominate yourself. Nice try."
if nominee.role == "deep": if nominee.role == "deep":
return False, "Deep Players from the previous scene cannot be nominated." return False, "Deep Players from the previous scene cannot be nominated."
# Dead/re-rolling players stay nominable on purpose: in small crews they can
# be a voter's only legal pick, and blocking them would deadlock the vote.
# Remove any existing vote by this voter for this game # Remove any existing vote by this voter for this game
existing = db.exec( existing = db.exec(
@@ -88,6 +90,27 @@ def process_between_scenes_votes(db: Session, game: Game):
db.delete(v) db.delete(v)
db.commit() db.commit()
def begin_next_scene_setup(db: Session, game: Game):
"""Advances to the next scene's setup: bump the scene counter and clear roles."""
game.current_scene_number += 1
game.phase = "scene_setup"
for p in game.players:
p.role = None
p.is_ready = False
db.add(p)
db.add(game)
db.commit()
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
def advance_after_upkeep(db: Session, game: Game):
"""After votes (and any resting-Deep hand refresh), detour through recruit
creation if anyone is waiting on a new Pi-Rat; otherwise straight to setup."""
if any(p.needs_reroll for p in game.players):
from .crud_character import start_recruit_creation
start_recruit_creation(db, game)
else:
begin_next_scene_setup(db, game)
def transition_to_deep_upkeep(db: Session, game_id: str): def transition_to_deep_upkeep(db: Session, game_id: str):
game = get_game(db, game_id) game = get_game(db, game_id)
if not game: if not game:
@@ -100,26 +123,19 @@ def transition_to_deep_upkeep(db: Session, game_id: str):
for p in game.players: for p in game.players:
p.is_ready = False p.is_ready = False
db.add(p) db.add(p)
db.commit()
# Check if we have resting Deep players (previous_role was 'deep') # Check if we have resting Deep players (previous_role was 'deep')
has_resting_deep = any(p.previous_role == "deep" for p in game.players) has_resting_deep = any(p.previous_role == "deep" for p in game.players)
if has_resting_deep: if has_resting_deep:
game.phase = "deep_upkeep" game.phase = "deep_upkeep"
add_game_event(db, game.id, "Phase changed: Deep Upkeep", kind="phase")
else:
# Fallback in case there were no Deep players
# Just advance directly to scene_setup
game.current_scene_number += 1
game.phase = "scene_setup"
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
for p in game.players:
p.role = None
p.is_ready = False
db.add(p)
db.add(game) db.add(game)
db.commit() db.commit()
add_game_event(db, game.id, "Phase changed: Deep Upkeep", kind="phase")
else:
# No resting Deep players: skip straight past the deep_upkeep phase
advance_after_upkeep(db, game)
def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]): def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
player = get_player(db, player_id) player = get_player(db, player_id)
@@ -160,13 +176,4 @@ def confirm_deep_refresh(db: Session, player_id: str, discard_cards: List[str]):
resting_deep = [p for p in game.players if p.previous_role == "deep"] resting_deep = [p for p in game.players if p.previous_role == "deep"]
if all(p.is_ready for p in resting_deep): if all(p.is_ready for p in resting_deep):
# All resting deep players have refreshed their hands! # All resting deep players have refreshed their hands!
# Transition to scene_setup! advance_after_upkeep(db, game)
game.current_scene_number += 1
game.phase = "scene_setup"
add_game_event(db, game.id, "Phase changed: Scene Setup", kind="phase")
for p in game.players:
p.is_ready = False
p.role = None
db.add(p)
db.add(game)
db.commit()

View File

@@ -0,0 +1,34 @@
"""recruit creation columns
Revision ID: 71c6d3e50ba3
Revises: 0001
Create Date: 2026-06-12 11:12:06.389057
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '71c6d3e50ba3'
down_revision = '0001'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('player', schema=None) as batch_op:
batch_op.add_column(sa.Column('needs_reroll', sa.Boolean(), nullable=False, server_default=sa.false()))
batch_op.add_column(sa.Column('incoming_techniques', sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default='[]'))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('player', schema=None) as batch_op:
batch_op.drop_column('incoming_techniques')
batch_op.drop_column('needs_reroll')
# ### end Alembic commands ###

View File

@@ -7,7 +7,7 @@ class Game(SQLModel, table=True):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True) id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
admin_key: str = Field(default_factory=lambda: str(uuid.uuid4())) admin_key: str = Field(default_factory=lambda: str(uuid.uuid4()))
crew_name: Optional[str] = Field(default=None) crew_name: Optional[str] = Field(default=None)
phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "ended" phase: str = Field(default="lobby") # "lobby", "character_creation", "swap_techniques", "scene_setup", "scene", "between_scenes", "deep_upkeep", "recruit_creation", "ended"
current_scene_number: int = Field(default=1) current_scene_number: int = Field(default=1)
extra_obstacles: int = Field(default=0) # Permanent increase to required obstacles (from Jokers drawn as Obstacles) extra_obstacles: int = Field(default=0) # Permanent increase to required obstacles (from Jokers drawn as Obstacles)
deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...] deck_cards: str = Field(default="[]") # JSON string of list of card codes, e.g. ["AS", "10D", "KH", ...]
@@ -65,6 +65,10 @@ class Player(SQLModel, table=True):
is_ghost: bool = Field(default=False) is_ghost: bool = Field(default=False)
tax_banned: bool = Field(default=False) # Failed a refused Gat/Name Tax; cannot initiate one again this scene tax_banned: bool = Field(default=False) # Failed a refused Gat/Name Tax; cannot initiate one again this scene
# Recruit creation (death re-roll, or joining after initial character creation)
needs_reroll: bool = Field(default=False) # Waiting on the between-scenes recruit_creation phase
incoming_techniques: str = Field(default="[]") # JSON list of {"from_id", "text"}: techniques crewmates write for this recruit
# Relationships # Relationships
game: Optional[Game] = Relationship(back_populates="players") game: Optional[Game] = Relationship(back_populates="players")

View File

@@ -154,24 +154,46 @@ def player_assign_face_techniques(
crud.assign_face_card_techniques(db, player_id, jack, queen, king) crud.assign_face_card_techniques(db, player_id, jack, queen, king)
return {"status": "ok"} return {"status": "ok"}
# Roll a new character after death # Dead Pi-Rat opts to come back as a fresh recruit (created between scenes)
@router.post("/game/{game_id}/player/{player_id}/roll-new-character") @router.post("/game/{game_id}/player/{player_id}/choose-reroll")
def roll_new_character_route( def choose_reroll_route(
game_id: str, game_id: str,
player_id: str, player_id: str,
avatar_look: str = Form(...),
avatar_smell: str = Form(...),
first_words: str = Form(...),
good_at_math: str = Form(...),
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
crud.roll_new_character( ok, msg = crud.choose_reroll(db, player_id)
db, if not ok:
game_id, return JSONResponse({"error": msg}, status_code=400)
player_id, return {"status": "ok"}
avatar_look=avatar_look,
avatar_smell=avatar_smell, # Write (or revise) one of your assigned technique slots for a pending recruit
first_words=first_words, @router.post("/game/{game_id}/player/{player_id}/contribute-technique")
good_at_math=good_at_math def contribute_technique_route(
) game_id: str,
player_id: str,
recruit_id: str = Form(...),
slot: int = Form(...),
text: str = Form(...),
db: Session = Depends(get_session)
):
ok, msg = crud.contribute_recruit_technique(db, player_id, recruit_id, slot, text)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}
# Recruit assigns their crew-written techniques to J/Q/K and joins the crew
@router.post("/game/{game_id}/player/{player_id}/finalize-recruit")
def finalize_recruit_route(
game_id: str,
player_id: str,
jack: str = Form(...),
queen: str = Form(...),
king: str = Form(...),
db: Session = Depends(get_session)
):
if len({jack, queen, king}) < 3:
return JSONResponse({"error": "Each card must be assigned a unique technique."}, status_code=400)
ok, msg = crud.finalize_recruit(db, player_id, jack, queen, king)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"} return {"status": "ok"}

View File

@@ -152,6 +152,7 @@ def become_ghost_route(
if player and player.is_dead: if player and player.is_dead:
player.is_ghost = True player.is_ghost = True
player.is_dead = False player.is_dead = False
player.needs_reroll = False # in case they queued a re-roll first, ghosthood wins
db.add(player) db.add(player)
db.commit() db.commit()
return {"status": "ok"} return {"status": "ok"}

View File

@@ -616,68 +616,102 @@ def test_become_ghost_flow(session):
finally: finally:
app.dependency_overrides.clear() app.dependency_overrides.clear()
def test_roll_new_character(session): def test_recruit_creation_full_flow(session):
"""A dead Pi-Rat re-rolls: queued in between_scenes, created cooperatively
in the recruit_creation phase, then the game advances to scene_setup."""
game = crud.create_game(session) game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1") p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2") # helper player for delegation target p2 = crud.add_player(session, game.id, "P2")
p3 = crud.add_player(session, game.id, "P3")
game.phase = "between_scenes"
game.current_scene_number = 2
p1.is_dead = True p1.is_dead = True
p1.rank = 3 p1.rank = 3
p1.hand_cards = json.dumps(["2C", "3D", "4H"]) p1.hand_cards = json.dumps(["2C", "3D", "4H"])
p1.completed_personal_1 = True p1.completed_personal_1 = True
p1.completed_personal_2 = True p1.completed_personal_2 = True
p1.completed_personal_3 = True p1.completed_personal_3 = True
p1.name = "Dread Pirate Roberts"
session.add_all([p1, p2]) session.add_all([game, p1, p2, p3])
session.commit() session.commit()
crud.roll_new_character( # 1. The dead player opts to re-roll (a living player can't)
session, ok, msg = crud.choose_reroll(session, p2.id)
game.id, assert not ok
p1.id, ok, msg = crud.choose_reroll(session, p1.id)
avatar_look="Shiny eyes", assert ok, msg
avatar_smell="Spiced Rum",
first_words="No fear!",
good_at_math="Yes"
)
session.refresh(p1) session.refresh(p1)
assert p1.needs_reroll
assert p1.is_dead # still dead until the recruit is created
# 2. Upkeep ends (no resting Deep) -> detours into recruit_creation
crud.transition_to_deep_upkeep(session, game.id)
session.refresh(game) session.refresh(game)
session.refresh(p1)
assert game.phase == "recruit_creation"
assert game.current_scene_number == 2 # not incremented yet
# Verify properties # Activation reset the sheet and returned the old hand to the deck
assert not p1.is_dead
assert not p1.is_ghost
assert not p1.completed_personal_1
assert not p1.completed_personal_2
assert not p1.completed_personal_3
assert p1.avatar_look == "Shiny eyes"
assert p1.avatar_smell == "Spiced Rum"
assert p1.first_words == "No fear!"
assert p1.good_at_math == "Yes"
# New recruits always start at Rank 1
assert p1.rank == 1 assert p1.rank == 1
assert not p1.is_dead
assert not p1.completed_personal_1
assert p1.avatar_look == ""
assert p1.tech_jack == ""
assert json.loads(p1.hand_cards) == []
deck = json.loads(game.deck_cards)
assert all(c in deck for c in ["2C", "3D", "4H"])
# Verify name is "Recruit Spiced Rum" # Crewmates were assigned to answer Like/Hate and write 3 techniques
assert p1.other_like_from_player_id in (p2.id, p3.id)
assert p1.other_hate_from_player_id in (p2.id, p3.id)
assert p1.other_like_from_player_id != p1.other_hate_from_player_id
slots = json.loads(p1.incoming_techniques)
assert len(slots) == 3
assert all(s["from_id"] in (p2.id, p3.id) for s in slots)
assert all(s["text"] == "" for s in slots)
# 3. Can't finalize before the crew has done its part
ok, msg = crud.finalize_recruit(session, p1.id, "a", "b", "c")
assert not ok
# 4. The recruit fills in their basic records
p1.avatar_look = "Shiny eyes"
p1.avatar_smell = "Spiced Rum"
p1.first_words = "No fear!"
p1.good_at_math = "Yes"
p1.name = crud.recruit_name(p1)
session.add(p1)
session.commit()
assert p1.name == "Recruit Spiced Rum" assert p1.name == "Recruit Spiced Rum"
# Verify techniques auto-assigned # 5. Crewmates answer Like/Hate and write the techniques
assert p1.tech_jack != "" crud.submit_delegated_answer(session, p1.id, "like", "Great hat", p1.other_like_from_player_id)
assert p1.tech_queen != "" crud.submit_delegated_answer(session, p1.id, "hate", "Bad jokes", p1.other_hate_from_player_id)
assert p1.tech_king != "" techs = ["Plank Pirouette", "Bilge Bomb", "Rat King Roar"]
for i, s in enumerate(slots):
# The wrong crewmate can't fill someone else's slot
wrong = p3.id if s["from_id"] == p2.id else p2.id
ok, msg = crud.contribute_recruit_technique(session, wrong, p1.id, i, techs[i])
assert not ok
ok, msg = crud.contribute_recruit_technique(session, s["from_id"], p1.id, i, techs[i])
assert ok, msg
# Verify delegated targets exist (p2 is the only other player) # 6. The recruit assigns J/Q/K and joins the crew
assert p1.other_like_from_player_id == p2.id ok, msg = crud.finalize_recruit(session, p1.id, techs[1], techs[0], techs[2])
assert p1.other_hate_from_player_id == p2.id assert ok, msg
session.refresh(p1)
session.refresh(game)
assert not p1.needs_reroll
assert p1.tech_jack == techs[1]
assert p1.tech_queen == techs[0]
assert p1.tech_king == techs[2]
assert json.loads(p1.swapped_techniques) == techs
assert len(json.loads(p1.hand_cards)) > 0 # starting hand dealt
# Verify hand was refreshed up to new max hand size # Last recruit done -> next scene's setup begins
hand = json.loads(p1.hand_cards) assert game.phase == "scene_setup"
assert len(hand) > 0 assert game.current_scene_number == 3
# Old cards returned to deck
deck = json.loads(game.deck_cards)
assert "2C" in deck or "2C" in hand
assert "3D" in deck or "3D" in hand
assert "4H" in deck or "4H" in hand
def test_gat_tax_accept(session): def test_gat_tax_accept(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
@@ -1049,55 +1083,77 @@ def test_submit_techniques_rejects_collisions(session):
session.refresh(game) session.refresh(game)
assert game.phase == "swap_techniques" assert game.phase == "swap_techniques"
def test_roll_new_character_smell_name_normalization(session): def test_recruit_smell_name_normalization(session):
game = crud.create_game(session) game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1") p1 = crud.add_player(session, game.id, "P1")
crud.add_player(session, game.id, "P2") p1.avatar_smell = "suspiciously smelling of ink and sour lemons"
p1.is_dead = True assert crud.recruit_name(p1) == "Recruit Ink and sour lemons"
session.add(p1)
session.commit()
crud.roll_new_character( def test_contribute_technique_rejects_collisions(session):
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) game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1") p1 = crud.add_player(session, game.id, "P1")
p2 = crud.add_player(session, game.id, "P2") p2 = crud.add_player(session, game.id, "P2")
# Give the surviving player techniques straight from the suggestion pool p2.tech_jack = "Parabolic Boarding Bounce"
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 p1.is_dead = True
p1.needs_reroll = True
session.add_all([p1, p2]) session.add_all([p1, p2])
session.commit() session.commit()
# Shrink the pool so a collision would be guaranteed without the exclusion logic crud.start_recruit_creation(session, game)
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) session.refresh(p1)
recruit_techs = {p1.tech_jack, p1.tech_queen, p1.tech_king} slots = json.loads(p1.incoming_techniques)
assert recruit_techs == set(original[3:6]) assert all(s["from_id"] == p2.id for s in slots)
# A name already on another sheet is rejected (case-insensitively)
ok, msg = crud.contribute_recruit_technique(session, p2.id, p1.id, 0, "parabolic boarding bounce")
assert not ok
ok, msg = crud.contribute_recruit_technique(session, p2.id, p1.id, 0, "Bilge Bomb")
assert ok, msg
# A name already written into another of the recruit's slots is rejected too
ok, msg = crud.contribute_recruit_technique(session, p2.id, p1.id, 1, "Bilge Bomb")
assert not ok
def test_solo_recruit_gets_pool_techniques(session):
"""With no crewmates to ask, the recruit's techniques come from the pool."""
game = crud.create_game(session)
p1 = crud.add_player(session, game.id, "P1")
p1.is_dead = True
p1.needs_reroll = True
session.add(p1)
session.commit()
crud.start_recruit_creation(session, game)
session.refresh(p1)
slots = json.loads(p1.incoming_techniques)
assert len(slots) == 3
assert all(s["from_id"] is None for s in slots)
assert all(s["text"] in cards.TECHNIQUE_SUGGESTIONS for s in slots)
assert p1.other_like_from_player_id is None
def test_late_joiner_queued_for_recruit_creation(session):
game, deep, (p2,) = make_scene_game(session)
assert game.phase == "scene"
newbie = crud.add_player(session, game.id, "Latecomer")
assert newbie.needs_reroll
assert newbie.role is None
assert json.loads(newbie.hand_cards) == []
# Scene ends, everyone votes/readies, resting Deep refreshes...
crud.end_scene_and_transition(session, game.id)
crud.transition_to_deep_upkeep(session, game.id)
session.refresh(game)
assert game.phase == "deep_upkeep"
crud.confirm_deep_refresh(session, deep.id, [])
session.refresh(game)
# ...and instead of scene_setup we land in recruit_creation
assert game.phase == "recruit_creation"
session.refresh(newbie)
slots = json.loads(newbie.incoming_techniques)
assert len(slots) == 3
assert all(s["from_id"] in (deep.id, p2.id) for s in slots)
def test_reshuffle_keeps_open_pvp_temp_card_out_of_deck(session): def test_reshuffle_keeps_open_pvp_temp_card_out_of_deck(session):
game, deep, (p2, p3) = make_scene_game(session, num_pirats=2) game, deep, (p2, p3) = make_scene_game(session, num_pirats=2)
@@ -1123,17 +1179,6 @@ def test_reshuffle_keeps_open_pvp_temp_card_out_of_deck(session):
crud.reshuffle_discard_pile(session, game) crud.reshuffle_discard_pile(session, game)
assert "7C" in crud.get_game_deck(game) assert "7C" in crud.get_game_deck(game)
def test_late_joiner_is_dealt_a_hand(session):
game, deep, (p2,) = make_scene_game(session)
assert game.phase == "scene"
newbie = crud.add_player(session, game.id, "Stowaway")
assert newbie.role == "pirat"
session.refresh(game)
expected = crud.calculate_max_hand_size(newbie, game.players, game.captain_player_id)
assert expected > 0
assert len(crud.get_player_hand(newbie)) == expected
def test_technique_suggestions_endpoint(): def test_technique_suggestions_endpoint():
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from pirats.main import app from pirats.main import app