Dead Pi-Rat reroll rework
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { getSuggestion, getTechniqueSuggestion, getTechniqueSuggestions } from '../lib/suggestions';
|
||||
import { displayName } from '../lib/cards';
|
||||
import FaceCardAssigner from './FaceCardAssigner.svelte';
|
||||
|
||||
export let state;
|
||||
|
||||
@@ -352,130 +353,7 @@
|
||||
<div class="alert alert-danger">{faceError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="available-techniques-container">
|
||||
<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>
|
||||
<FaceCardAssigner techniques={swappedTechniques} bind:jack={jackTech} bind:queen={queenTech} bind:king={kingTech} />
|
||||
|
||||
<div class="mt-6">
|
||||
<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');
|
||||
$: piratPlayer = state.players.find(p => p.role === 'pirat');
|
||||
$: canStart = allRolesAssigned && deepPlayer && piratPlayer && (state.player.is_creator || state.player.role === 'deep');
|
||||
@@ -67,7 +67,13 @@
|
||||
<!-- Role Selection Card -->
|
||||
<div class="card glass-panel role-selection-card">
|
||||
<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">
|
||||
<button on:click={() => setRole('pirat')}
|
||||
disabled={settingRole || !allowedRoles.includes('pirat')}
|
||||
@@ -83,14 +89,14 @@
|
||||
</div>
|
||||
|
||||
<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}
|
||||
<p class="text-danger text-sm italic">You start at Rank 3, so you must play The Deep for the first scene!</p>
|
||||
{:else}
|
||||
<p class="text-danger text-sm italic">You are the only eligible player for The Deep this scene!</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !allowedRoles.includes('deep')}
|
||||
{#if !allowedRoles.includes('deep') && !state.player.needs_reroll}
|
||||
{#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>
|
||||
{:else}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script>
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { getSuggestion } from '../lib/suggestions';
|
||||
import { displayName } from '../lib/cards';
|
||||
import Card from './Card.svelte';
|
||||
|
||||
@@ -20,16 +19,7 @@
|
||||
let discardCards = [];
|
||||
let dragNDropInitialized = false;
|
||||
|
||||
// Ghost & Re-rolling fate states
|
||||
let isRolling = false;
|
||||
let reRollDetails = {
|
||||
avatar_look: '',
|
||||
avatar_smell: '',
|
||||
first_words: '',
|
||||
good_at_math: ''
|
||||
};
|
||||
let rollingError = '';
|
||||
|
||||
// Ghost & Re-rolling fate choice
|
||||
async function becomeGhost() {
|
||||
try {
|
||||
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() {
|
||||
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 {
|
||||
@@ -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 (!dragNDropInitialized) {
|
||||
@@ -182,74 +156,31 @@
|
||||
<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>
|
||||
|
||||
{#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 -->
|
||||
<div class="glass-panel death-fate-card">
|
||||
<h2>💀 Your Pi-Rat Has Died!</h2>
|
||||
<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:
|
||||
</p>
|
||||
|
||||
{#if !isRolling}
|
||||
<div class="fate-choices">
|
||||
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost}>
|
||||
👻 Remain as a Ghost
|
||||
</button>
|
||||
<button class="btn btn-primary btn-large glow-effect" on:click={() => isRolling = true}>
|
||||
🐀 Roll a New Character
|
||||
</button>
|
||||
</div>
|
||||
<p class="info-text italic" style="margin-top: 1.5rem;">
|
||||
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh at Rank 1 with surprise Secret Techniques and a name based on their smell.
|
||||
</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 class="fate-choices">
|
||||
<button class="btn btn-secondary btn-large glow-effect" on:click={becomeGhost}>
|
||||
👻 Remain as a Ghost
|
||||
</button>
|
||||
<button class="btn btn-primary btn-large glow-effect" on:click={chooseReRoll}>
|
||||
🐀 Roll a New Character
|
||||
</button>
|
||||
</div>
|
||||
<p class="info-text italic" style="margin-top: 1.5rem;">
|
||||
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh at Rank 1 — after upkeep, the whole crew will help create them.
|
||||
</p>
|
||||
</div>
|
||||
{: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">
|
||||
<!-- Ranks and Voting Card -->
|
||||
<div class="card glass-panel voting-card">
|
||||
|
||||
@@ -54,6 +54,13 @@
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/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}
|
||||
<div class="prompt-box danger text-center">
|
||||
<h4>💀 You have Died / Retired!</h4>
|
||||
@@ -102,7 +109,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import SceneSetupPhase from '../components/SceneSetupPhase.svelte';
|
||||
import ScenePhase from '../components/ScenePhase.svelte';
|
||||
import UpkeepPhase from '../components/UpkeepPhase.svelte';
|
||||
import RecruitPhase from '../components/RecruitPhase.svelte';
|
||||
import GameOverPhase from '../components/GameOverPhase.svelte';
|
||||
import EventLog from '../components/EventLog.svelte';
|
||||
|
||||
@@ -91,6 +92,8 @@
|
||||
<ScenePhase {state} />
|
||||
{:else if state.game.phase === 'between_scenes' || state.game.phase === 'deep_upkeep'}
|
||||
<UpkeepPhase {state} />
|
||||
{:else if state.game.phase === 'recruit_creation'}
|
||||
<RecruitPhase {state} />
|
||||
{:else if state.game.phase === 'ended'}
|
||||
<GameOverPhase {state} />
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user