Dead Pi-Rat reroll rework
This commit is contained in:
10
TODO.md
10
TODO.md
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
79
frontend/src/components/FaceCardAssigner.svelte
Normal file
79
frontend/src/components/FaceCardAssigner.svelte
Normal 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>
|
||||||
309
frontend/src/components/RecruitPhase.svelte
Normal file
309
frontend/src/components/RecruitPhase.svelte
Normal 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>
|
||||||
@@ -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');
|
||||||
@@ -67,7 +67,13 @@
|
|||||||
<!-- Role Selection Card -->
|
<!-- Role Selection Card -->
|
||||||
<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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<div class="fate-choices">
|
||||||
{#if !isRolling}
|
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost}>
|
||||||
<div class="fate-choices">
|
👻 Remain as a Ghost
|
||||||
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost}>
|
</button>
|
||||||
👻 Remain as a Ghost
|
<button class="btn btn-primary btn-large glow-effect" on:click={chooseReRoll}>
|
||||||
</button>
|
🐀 Roll a New Character
|
||||||
<button class="btn btn-primary btn-large glow-effect" on:click={() => isRolling = true}>
|
</button>
|
||||||
🐀 Roll a New Character
|
</div>
|
||||||
</button>
|
<p class="info-text italic" style="margin-top: 1.5rem;">
|
||||||
</div>
|
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 class="info-text italic" style="margin-top: 1.5rem;">
|
</p>
|
||||||
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh at Rank 1 with surprise Secret Techniques and a name based on their smell.
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<div class="creation-form-wrapper">
|
|
||||||
<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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -244,10 +244,11 @@ 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,
|
||||||
name=name, # the Pi-Rat goes by "Recruit {smell}" once character creation fills in a smell
|
name=name, # the Pi-Rat goes by "Recruit {smell}" once character creation fills in a smell
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
def choose_reroll(db: Session, player_id: str):
|
||||||
|
"""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)
|
player = get_player(db, player_id)
|
||||||
game = get_game(db, game_id)
|
if not player:
|
||||||
if not player or not game:
|
return False, "Player not found."
|
||||||
return
|
if player.needs_reroll:
|
||||||
|
return True, "Already queued for a new recruit."
|
||||||
# 1. Return current hand cards to the deck
|
if not player.is_dead or player.is_ghost:
|
||||||
from .crud_base import get_player_hand, set_player_hand, get_game_deck, set_game_deck, draw_cards_for_player, calculate_max_hand_size
|
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)
|
||||||
deck = get_game_deck(game)
|
if hand:
|
||||||
deck.extend(hand)
|
deck = get_game_deck(game)
|
||||||
random.shuffle(deck)
|
deck.extend(hand)
|
||||||
set_game_deck(game, deck)
|
random.shuffle(deck)
|
||||||
set_player_hand(player, [])
|
set_game_deck(game, deck)
|
||||||
|
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!"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,38 +90,52 @@ 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:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Process votes & rank up winner
|
# Process votes & rank up winner
|
||||||
process_between_scenes_votes(db, game)
|
process_between_scenes_votes(db, game)
|
||||||
|
|
||||||
# Reset ready flags
|
# Reset ready flags
|
||||||
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"
|
||||||
|
db.add(game)
|
||||||
|
db.commit()
|
||||||
add_game_event(db, game.id, "Phase changed: Deep Upkeep", kind="phase")
|
add_game_event(db, game.id, "Phase changed: Deep Upkeep", kind="phase")
|
||||||
else:
|
else:
|
||||||
# Fallback in case there were no Deep players
|
# No resting Deep players: skip straight past the deep_upkeep phase
|
||||||
# Just advance directly 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.role = None
|
|
||||||
p.is_ready = False
|
|
||||||
db.add(p)
|
|
||||||
|
|
||||||
db.add(game)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -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", ...]
|
||||||
@@ -64,6 +64,10 @@ class Player(SQLModel, table=True):
|
|||||||
is_dead: bool = Field(default=False)
|
is_dead: bool = Field(default=False)
|
||||||
is_ghost: bool = Field(default=False)
|
is_ghost: bool = Field(default=False)
|
||||||
tax_banned: bool = Field(default=False) # Failed a refused Gat/Name Tax; cannot initiate one again this scene
|
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")
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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",
|
session.refresh(p1)
|
||||||
first_words="No fear!",
|
assert p1.needs_reroll
|
||||||
good_at_math="Yes"
|
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(p1)
|
||||||
|
assert game.phase == "recruit_creation"
|
||||||
|
assert game.current_scene_number == 2 # not incremented yet
|
||||||
|
|
||||||
|
# Activation reset the sheet and returned the old hand to the deck
|
||||||
|
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"])
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# 5. Crewmates answer Like/Hate and write the techniques
|
||||||
|
crud.submit_delegated_answer(session, p1.id, "like", "Great hat", p1.other_like_from_player_id)
|
||||||
|
crud.submit_delegated_answer(session, p1.id, "hate", "Bad jokes", p1.other_hate_from_player_id)
|
||||||
|
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
|
||||||
|
|
||||||
|
# 6. The recruit assigns J/Q/K and joins the crew
|
||||||
|
ok, msg = crud.finalize_recruit(session, p1.id, techs[1], techs[0], techs[2])
|
||||||
|
assert ok, msg
|
||||||
session.refresh(p1)
|
session.refresh(p1)
|
||||||
session.refresh(game)
|
session.refresh(game)
|
||||||
|
assert not p1.needs_reroll
|
||||||
# Verify properties
|
assert p1.tech_jack == techs[1]
|
||||||
assert not p1.is_dead
|
assert p1.tech_queen == techs[0]
|
||||||
assert not p1.is_ghost
|
assert p1.tech_king == techs[2]
|
||||||
assert not p1.completed_personal_1
|
assert json.loads(p1.swapped_techniques) == techs
|
||||||
assert not p1.completed_personal_2
|
assert len(json.loads(p1.hand_cards)) > 0 # starting hand dealt
|
||||||
assert not p1.completed_personal_3
|
|
||||||
assert p1.avatar_look == "Shiny eyes"
|
# Last recruit done -> next scene's setup begins
|
||||||
assert p1.avatar_smell == "Spiced Rum"
|
assert game.phase == "scene_setup"
|
||||||
assert p1.first_words == "No fear!"
|
assert game.current_scene_number == 3
|
||||||
assert p1.good_at_math == "Yes"
|
|
||||||
|
|
||||||
# New recruits always start at Rank 1
|
|
||||||
assert p1.rank == 1
|
|
||||||
|
|
||||||
# Verify name is "Recruit Spiced Rum"
|
|
||||||
assert p1.name == "Recruit Spiced Rum"
|
|
||||||
|
|
||||||
# Verify techniques auto-assigned
|
|
||||||
assert p1.tech_jack != ""
|
|
||||||
assert p1.tech_queen != ""
|
|
||||||
assert p1.tech_king != ""
|
|
||||||
|
|
||||||
# Verify delegated targets exist (p2 is the only other player)
|
|
||||||
assert p1.other_like_from_player_id == p2.id
|
|
||||||
assert p1.other_hate_from_player_id == p2.id
|
|
||||||
|
|
||||||
# Verify hand was refreshed up to new max hand size
|
|
||||||
hand = json.loads(p1.hand_cards)
|
|
||||||
assert len(hand) > 0
|
|
||||||
# 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
|
||||||
|
|||||||
Reference in New Issue
Block a user