Dead and awaiting-recruit Pi-Rats can no longer be nominated to rank up (they'd reset to Rank 1 anyway). submit_rank_vote rejects them, and the upkeep dropdown only lists living, in-play crewmates via the shared eligible_vote_nominees helper. When a voter has no eligible nominees (e.g. a lone survivor whose only crewmates are the previous Deep and a dead rat) they now skip voting entirely and can ready up directly, instead of deadlocking — which is why dead players were previously left nominable. The roster shows such players as "No vote". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
372 lines
16 KiB
Svelte
372 lines
16 KiB
Svelte
<script>
|
|
import { apiRequest } from '../lib/api';
|
|
import { displayName } from '../lib/cards';
|
|
import Card from './Card.svelte';
|
|
|
|
export let state;
|
|
|
|
let voting = false;
|
|
let readying = false;
|
|
let confirming = false;
|
|
|
|
let selectedVoteId = '';
|
|
|
|
// Helpers
|
|
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
|
|
|
// A nominee must be another living, in-play Pi-Rat: not the voter, not a
|
|
// previous-scene Deep, and not dead / awaiting-recruit (they'd reset to
|
|
// Rank 1 anyway). Mirrors crud_upkeep.is_eligible_nominee.
|
|
function eligibleNomineesFor(voter) {
|
|
return state.players.filter(p =>
|
|
p.id !== voter.id && p.role !== 'deep' && !p.is_dead && !p.needs_reroll
|
|
);
|
|
}
|
|
$: myNominees = eligibleNomineesFor(state.player);
|
|
$: hasVoted = !!state.votes.find(v => v.voter_player_id === state.player.id);
|
|
// With no eligible crewmates, the voter skips voting entirely (no deadlock).
|
|
$: mustVote = myNominees.length > 0;
|
|
|
|
// Drag & Drop State
|
|
let keepCards = [];
|
|
let discardCards = [];
|
|
let dragNDropInitialized = false;
|
|
|
|
// Ghost & Re-rolling fate choice
|
|
async function becomeGhost() {
|
|
try {
|
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/become-ghost`, 'POST');
|
|
} catch(e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
await apiRequest(`/game/${state.game.id}/finish`, 'POST');
|
|
} catch(e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
$: {
|
|
if (state && state.game.phase === 'deep_upkeep') {
|
|
if (!dragNDropInitialized) {
|
|
keepCards = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
|
discardCards = [];
|
|
dragNDropInitialized = true;
|
|
}
|
|
} else {
|
|
dragNDropInitialized = false;
|
|
}
|
|
}
|
|
|
|
let draggedCardCode = null;
|
|
|
|
function handleDragStart(e, card) {
|
|
draggedCardCode = card;
|
|
e.dataTransfer.setData("text/plain", card);
|
|
}
|
|
|
|
function handleDragOver(e) {
|
|
e.preventDefault();
|
|
}
|
|
|
|
function handleDropToKeep(e) {
|
|
e.preventDefault();
|
|
const card = e.dataTransfer.getData("text/plain") || draggedCardCode;
|
|
if (card && !keepCards.includes(card)) {
|
|
discardCards = discardCards.filter(c => c !== card);
|
|
keepCards = [...keepCards, card];
|
|
}
|
|
}
|
|
|
|
function handleDropToDiscard(e) {
|
|
e.preventDefault();
|
|
const card = e.dataTransfer.getData("text/plain") || draggedCardCode;
|
|
if (card && !discardCards.includes(card)) {
|
|
keepCards = keepCards.filter(c => c !== card);
|
|
discardCards = [...discardCards, card];
|
|
}
|
|
}
|
|
|
|
function moveToDiscard(card) {
|
|
keepCards = keepCards.filter(c => c !== card);
|
|
if (!discardCards.includes(card)) {
|
|
discardCards = [...discardCards, card];
|
|
}
|
|
}
|
|
|
|
function moveToKeep(card) {
|
|
discardCards = discardCards.filter(c => c !== card);
|
|
if (!keepCards.includes(card)) {
|
|
keepCards = [...keepCards, card];
|
|
}
|
|
}
|
|
|
|
// Mirrors the backend hand size table: highest rank -> 4, lowest -> 2, middle -> 3, Captain +1.
|
|
// Deep players are ranked against all players.
|
|
$: maxHandSize = (() => {
|
|
const ranks = state.players.map(p => p.rank);
|
|
let base;
|
|
if (state.player.rank >= Math.max(...ranks)) base = 4;
|
|
else if (state.player.rank <= Math.min(...ranks)) base = 2;
|
|
else base = 3;
|
|
return base + (state.game.captain_player_id === state.player.id ? 1 : 0);
|
|
})();
|
|
$: isOverLimit = keepCards.length > maxHandSize;
|
|
|
|
async function submitVote() {
|
|
if (!selectedVoteId) return;
|
|
voting = true;
|
|
try {
|
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-vote`, 'POST', {
|
|
nominated_id: selectedVoteId
|
|
});
|
|
} catch(e) {
|
|
console.error(e);
|
|
} finally {
|
|
voting = false;
|
|
}
|
|
}
|
|
|
|
async function setReady() {
|
|
readying = true;
|
|
try {
|
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/ready-next`, 'POST');
|
|
} catch(e) {
|
|
console.error(e);
|
|
} finally {
|
|
readying = false;
|
|
}
|
|
}
|
|
|
|
async function confirmRefresh() {
|
|
confirming = true;
|
|
try {
|
|
let discards = JSON.stringify(discardCards);
|
|
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/confirm-refresh`, 'POST', {
|
|
discard_cards: discards
|
|
});
|
|
} catch(e) {
|
|
console.error(e);
|
|
} finally {
|
|
confirming = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="between-scenes-view text-center">
|
|
<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 && !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>
|
|
<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">
|
|
<h3>1. Pirate Ranking (Voting)</h3>
|
|
<p class="section-desc">Every player (including the Deep) nominates one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p>
|
|
|
|
{#if state.game.phase === 'deep_upkeep'}
|
|
<div class="voted-confirmation-box glass-panel">
|
|
<p class="success-text">✔️ Voting complete! Results tallied.</p>
|
|
</div>
|
|
{:else if hasVoted}
|
|
<div class="voted-confirmation-box glass-panel">
|
|
<p class="success-text">✔️ Vote submitted! Waiting for other players to submit their nominations.</p>
|
|
</div>
|
|
{:else if !mustVote}
|
|
<div class="voted-confirmation-box glass-panel">
|
|
<p class="info-text">No living crewmates left to nominate — you skip voting this round. Go ahead and ready up.</p>
|
|
</div>
|
|
{:else}
|
|
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
|
|
<div class="form-group inline-group">
|
|
<select class="select-field" bind:value={selectedVoteId} required>
|
|
<option value="">Nominate crewmate...</option>
|
|
{#each myNominees as p}
|
|
<option value={p.id}>{displayName(p)} (Rank {p.rank})</option>
|
|
{/each}
|
|
</select>
|
|
<button type="submit" class="btn btn-primary" disabled={voting || !selectedVoteId}>Cast Nomination</button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
|
|
<!-- Voting Roster Status -->
|
|
{#if state.game.phase !== 'deep_upkeep'}
|
|
<div class="vote-status-table margin-top">
|
|
<h4>Nomination Progress:</h4>
|
|
<div class="player-chips list-chips" id="voting-roster">
|
|
{#each state.players as p}
|
|
{@const voted = state.votes.some(v => v.voter_player_id === p.id)}
|
|
{@const skips = eligibleNomineesFor(p).length === 0}
|
|
<div class="player-chip {voted || skips ? 'voted-chip' : 'not-voted-chip'}">
|
|
<span class="name">{displayName(p)}</span>
|
|
<span class="status-badge">{voted ? 'Done' : skips ? 'No vote' : 'Voting...'}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Resting Deep Player Card -->
|
|
<div class="card glass-panel deep-rest-card">
|
|
<h3>2. Resting Deep Upkeep</h3>
|
|
|
|
{#if state.player.role === "deep"}
|
|
<div class="deep-rest-panel glass-panel">
|
|
<p class="info-text">You controlled the Deep in the last scene and your Pi-Rat was resting. You will discard your current hand and redraw back up to your maximum hand size.</p>
|
|
|
|
{#if state.game.phase === 'between_scenes'}
|
|
<p class="info-text text-center gold-text" style="margin-top: 1rem;">Voting is currently in progress. Hand refresh will begin after voting concludes.</p>
|
|
{:else if state.game.phase === 'deep_upkeep'}
|
|
<p class="info-text" style="margin-top: 1rem;">
|
|
<strong>Your Hand Size Limit: {maxHandSize} cards.</strong><br/>
|
|
Drag cards to the "Discard Pile" zone to discard them, or click them to move them.
|
|
</p>
|
|
|
|
<div class="upkeep-drag-container">
|
|
<!-- KEEP HAND ZONE -->
|
|
<div class="upkeep-box"
|
|
on:dragover={handleDragOver}
|
|
on:drop={handleDropToKeep}>
|
|
<h4 class="gold-text">Hand (Keep)</h4>
|
|
<div class="upkeep-flex">
|
|
{#each keepCards as card}
|
|
<Card {card} draggable={true} style="cursor: pointer;"
|
|
title="Click or drag to discard"
|
|
on:dragstart={(e) => handleDragStart(e, card)}
|
|
on:click={() => moveToDiscard(card)} />
|
|
{:else}
|
|
<p class="empty-text text-center w-full">No cards in hand.</p>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- DISCARD PILE ZONE -->
|
|
<div class="upkeep-box"
|
|
on:dragover={handleDragOver}
|
|
on:drop={handleDropToDiscard}>
|
|
<h4 class="gold-text">Discard Pile</h4>
|
|
<div class="upkeep-flex">
|
|
{#each discardCards as card}
|
|
<Card {card} draggable={true} style="cursor: pointer;"
|
|
title="Click or drag to keep"
|
|
on:dragstart={(e) => handleDragStart(e, card)}
|
|
on:click={() => moveToKeep(card)} />
|
|
{:else}
|
|
<p class="empty-text text-center w-full">Drag cards here to discard them.</p>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if isOverLimit}
|
|
<div class="notice-banner danger" style="margin: 1rem 0; max-width: none;">
|
|
<p class="text-danger font-bold" style="margin: 0;">⚠️ Discard Required: You have selected {keepCards.length} cards to keep, which exceeds your maximum hand limit of {maxHandSize}. Please discard at least {keepCards.length - maxHandSize} card(s).</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<button class="btn btn-primary mt-4 w-full" on:click={confirmRefresh} disabled={confirming || isOverLimit}>
|
|
Confirm Hand Refresh
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
{#if state.game.phase === 'between_scenes'}
|
|
<p class="info-text text-center">Your Pi-Rat was active. Your hand cards carry over to the next scene.</p>
|
|
{:else if state.game.phase === 'deep_upkeep'}
|
|
<p class="info-text text-center gold-text">Waiting for resting Deep player to refresh their hand...</p>
|
|
{/if}
|
|
{/if}
|
|
|
|
<div class="roster-sheet-summary margin-top text-left glass-panel">
|
|
<h4>Crew Rank Ledger:</h4>
|
|
<ul class="ranks-ledger">
|
|
{#each state.players as p}
|
|
<li>
|
|
<strong>{displayName(p)}:</strong> Rank {p.rank}
|
|
{#if p.role === 'deep'}<span class="deep-badge">Deep</span>{:else if p.is_ghost}<span class="ghost-badge">Ghost</span>{:else}<span class="pirat-badge">Pi-Rat</span>{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Next Scene Ready Up -->
|
|
<div class="action-box margin-top">
|
|
{#if state.game.phase === 'deep_upkeep'}
|
|
{#if state.player.role === 'deep'}
|
|
<p class="info-text gold-text">⚠️ Please complete your Hand Refresh above to proceed to the next scene.</p>
|
|
{:else}
|
|
<div class="waiting-box">
|
|
<p>Waiting for resting Deep player to refresh their hand...</p>
|
|
<div class="spinner-small"></div>
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
{#if mustVote && !hasVoted}
|
|
<p class="info-text gold-text" style="margin-bottom:0.5rem;">⚠️ You must nominate a crewmate above before you can ready up.</p>
|
|
<button class="btn btn-primary btn-large" disabled>Ready for Next Scene</button>
|
|
{:else}
|
|
{#if state.player.is_ready}
|
|
<div class="waiting-box">
|
|
<p>Ready! Waiting for other players to ready up...</p>
|
|
<div class="spinner-small"></div>
|
|
</div>
|
|
{:else}
|
|
<button on:click={setReady}
|
|
disabled={readying}
|
|
class="btn btn-primary btn-large glow-effect">
|
|
Ready for Next Scene
|
|
</button>
|
|
{/if}
|
|
{/if}
|
|
{/if}
|
|
|
|
{#if state.player.is_admin && state.game.phase === 'between_scenes'}
|
|
<div class="end-story-box">
|
|
<p class="info-text" style="font-size: 0.85rem;">All good stories end. When the crew agrees the legend is complete:</p>
|
|
<button on:click={finishGame} class="btn btn-danger">🏴☠️ End the Story</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|