Fable will fix it!
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { apiRequest } from '../lib/api';
|
||||
import { getSuggestion } from '../lib/suggestions';
|
||||
import { getSuggestion, getSuggestions } from '../lib/suggestions';
|
||||
|
||||
export let state;
|
||||
|
||||
@@ -13,7 +14,6 @@
|
||||
};
|
||||
|
||||
let savingBasic = false;
|
||||
let delegating = false;
|
||||
|
||||
async function saveBasicDetails() {
|
||||
savingBasic = true;
|
||||
@@ -26,16 +26,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function autoDelegate() {
|
||||
delegating = true;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/delegate/auto`, 'POST');
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
delegating = false;
|
||||
// Like/Hate tasks are assigned automatically when character creation starts.
|
||||
// This is a fallback for games that entered the phase before assignments existed.
|
||||
onMount(async () => {
|
||||
if (!delegateComplete && state.players.length >= 2) {
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/delegate/auto`, 'POST');
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// A helper to get player name
|
||||
function getPlayerName(id) {
|
||||
@@ -47,15 +48,17 @@
|
||||
// Techniques
|
||||
let tech1 = '', tech2 = '', tech3 = '';
|
||||
let savingTechs = false;
|
||||
let techError = '';
|
||||
|
||||
async function submitTechniques() {
|
||||
savingTechs = true;
|
||||
techError = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-techniques`, 'POST', {
|
||||
tech1, tech2, tech3
|
||||
});
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
techError = e.message;
|
||||
} finally {
|
||||
savingTechs = false;
|
||||
}
|
||||
@@ -82,11 +85,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Submit delegated answers
|
||||
let answerLike = '', answerHate = '';
|
||||
async function submitDelegatedAnswer(type, targetId, answer) {
|
||||
// Submit delegated answers — one draft per (player, question) so editing one
|
||||
// task never bleeds into another.
|
||||
let inboxAnswers = {};
|
||||
async function submitDelegatedAnswer(type, targetId) {
|
||||
const answer = (inboxAnswers[`${targetId}:${type}`] || '').trim();
|
||||
if (!answer) return;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/submit-delegate/${targetId}/${type}`, 'POST', { answer });
|
||||
delete inboxAnswers[`${targetId}:${type}`];
|
||||
inboxAnswers = inboxAnswers;
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -107,42 +115,40 @@
|
||||
async function autoFillAll() {
|
||||
autoFilling = true;
|
||||
try {
|
||||
// 1. Basic details
|
||||
// 1. Basic details — drawn from the same pools as the Suggest buttons
|
||||
if (!basicComplete) {
|
||||
basicDetails = {
|
||||
avatar_look: 'A scruffy rat',
|
||||
avatar_smell: 'Like old cheese',
|
||||
first_words: 'Give me the loot!',
|
||||
good_at_math: 'No'
|
||||
avatar_look: getSuggestion('look'),
|
||||
avatar_smell: getSuggestion('smell'),
|
||||
first_words: getSuggestion('first_words'),
|
||||
good_at_math: getSuggestion('good_at_math')
|
||||
};
|
||||
await saveBasicDetails();
|
||||
}
|
||||
|
||||
// 2. Delegate
|
||||
if (!delegateComplete && state.players.length >= 2) {
|
||||
await autoDelegate();
|
||||
}
|
||||
|
||||
// 3. Answer questions (might need a quick refresh to get the questions, but we can do our best with current state)
|
||||
// Wait, state needs to be re-fetched to get the assigned questions. We can just fill what's currently in inbox.
|
||||
// 2. Answer pending inbox questions with pool suggestions
|
||||
for (const p of state.players) {
|
||||
if (p.other_like_from_player_id === state.player.id && !p.other_like) {
|
||||
await submitDelegatedAnswer('like', p.id, 'They are sneaky');
|
||||
inboxAnswers[`${p.id}:like`] = getSuggestion('like');
|
||||
await submitDelegatedAnswer('like', p.id);
|
||||
}
|
||||
if (p.other_hate_from_player_id === state.player.id && !p.other_hate) {
|
||||
await submitDelegatedAnswer('hate', p.id, 'They snore loud');
|
||||
inboxAnswers[`${p.id}:hate`] = getSuggestion('hate');
|
||||
await submitDelegatedAnswer('hate', p.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Techniques
|
||||
// 3. Techniques — 3 distinct pool picks; retry if another player
|
||||
// grabbed the same name first (the backend rejects collisions)
|
||||
if (createdTechniques.length === 0) {
|
||||
tech1 = 'Pocket Sand';
|
||||
tech2 = 'Tail Whip';
|
||||
tech3 = 'Cheese Decoy';
|
||||
await submitTechniques();
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
[tech1, tech2, tech3] = getSuggestions('techniques', 3);
|
||||
await submitTechniques();
|
||||
if (!techError) break;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Face Techniques - we need the swapped ones. We can't do this synchronously without waiting for the server
|
||||
// 4. Face Techniques - we need the swapped ones. We can't do this synchronously without waiting for the server
|
||||
// to process the state, because swapped_techniques comes from the server once everyone is done.
|
||||
if (!techniquesComplete && swappedTechniques.length === 3) {
|
||||
jackTech = swappedTechniques[0];
|
||||
@@ -235,16 +241,9 @@
|
||||
|
||||
<!-- SECTION 2: Delegated Questions (Like/Hate) -->
|
||||
<div class="card glass-panel section-delegations">
|
||||
<div class="card-header-flex" style="display:flex; justify-content:space-between; align-items:center; margin-bottom:1rem; border-bottom:1px solid rgba(212,175,55,0.2); padding-bottom:0.5rem;">
|
||||
<h3 style="border:none; margin:0; padding:0; color:var(--gold); font-family:var(--font-heading);">II. Crew Delegations {delegateComplete ? '✅' : '❌'}</h3>
|
||||
{#if !delegateComplete}
|
||||
<button on:click={autoDelegate} disabled={delegating || state.players.length < 2} class="btn btn-secondary btn-small">
|
||||
🎲 Auto-Assign Tasks
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="section-desc">You must let other players decide what they like and hate about your character.</p>
|
||||
|
||||
<h3>II. Crew Delegations {delegateComplete ? '✅' : '❌'}</h3>
|
||||
<p class="section-desc">Other players decide what their rats like and hate about your character. Crewmates have been randomly assigned to answer for you.</p>
|
||||
|
||||
<div class="delegation-list">
|
||||
{#if delegateComplete}
|
||||
<div class="delegation-box glass-panel">
|
||||
@@ -262,7 +261,7 @@
|
||||
<div class="author-label">— {getPlayerName(state.player.other_hate_from_player_id)}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mb-4 text-sm text-gray-400">Randomly assign crewmates to write your relationships by clicking "Auto-Assign Tasks".</p>
|
||||
<p class="mb-4 text-sm text-gray-400">Assigning crewmates to write your relationships...</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,8 +276,9 @@
|
||||
<div class="inbox-item">
|
||||
<label>What do you LIKE about {p.name}?</label>
|
||||
<div class="input-row">
|
||||
<input type="text" class="input-field" bind:value={answerLike} placeholder="e.g. They always share their cheese"/>
|
||||
<button class="btn btn-primary" on:click={() => submitDelegatedAnswer('like', p.id, answerLike)}>Submit</button>
|
||||
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/>
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button>
|
||||
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', p.id)}>Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -286,8 +286,9 @@
|
||||
<div class="inbox-item">
|
||||
<label>What do you HATE about {p.name}?</label>
|
||||
<div class="input-row">
|
||||
<input type="text" class="input-field" bind:value={answerHate} placeholder="e.g. They snore too loudly"/>
|
||||
<button class="btn btn-primary" on:click={() => submitDelegatedAnswer('hate', p.id, answerHate)}>Submit</button>
|
||||
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/>
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button>
|
||||
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', p.id)}>Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -304,26 +305,29 @@
|
||||
|
||||
{#if createdTechniques.length === 0}
|
||||
<p class="section-desc">Create 3 techniques that you will swap with other players. Make them cool or ridiculous!</p>
|
||||
{#if techError}
|
||||
<div class="alert alert-danger">{techError}</div>
|
||||
{/if}
|
||||
<form on:submit|preventDefault={submitTechniques} id="tech-form">
|
||||
<div class="form-group">
|
||||
<label for="tech1">Technique #1:</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tech1" placeholder="e.g. Carlo's cheese shield" bind:value={tech1} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech1 = getSuggestion('techniques')}>Suggest</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech1 = getSuggestion('techniques', [tech2, tech3])}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tech2">Technique #2:</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tech2" placeholder="e.g. Parabolic boarding bounce" bind:value={tech2} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech2 = getSuggestion('techniques')}>Suggest</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech2 = getSuggestion('techniques', [tech1, tech3])}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tech3">Technique #3:</label>
|
||||
<div class="input-row">
|
||||
<input type="text" id="tech3" placeholder="e.g. Look, a three-headed gull!" bind:value={tech3} required class="input-field">
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech3 = getSuggestion('techniques')}>Suggest</button>
|
||||
<button type="button" class="btn btn-secondary btn-small" on:click={() => tech3 = getSuggestion('techniques', [tech1, tech2])}>Suggest</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full glow-effect" disabled={savingTechs}>Submit Techniques to Swap Pool</button>
|
||||
|
||||
50
frontend/src/components/GameOverPhase.svelte
Normal file
50
frontend/src/components/GameOverPhase.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script>
|
||||
export let state;
|
||||
|
||||
$: pirats = state.players;
|
||||
$: crewDone = state.game.completed_crew_1 && state.game.completed_crew_2 && state.game.completed_crew_3;
|
||||
</script>
|
||||
|
||||
<div class="dashboard-container" style="max-width: 800px; margin: 0 auto; padding: 2rem;">
|
||||
<div class="card glass-panel" style="text-align: center; padding: 2rem;">
|
||||
<h1 style="font-family: var(--font-heading); color: var(--gold);">🎉 The Story Has Ended!</h1>
|
||||
<p class="section-desc" style="font-size: 1.1rem;">
|
||||
The Pi-Rats of {state.game.crew_name || 'the crew'} party til they pass out in a pile.
|
||||
Tell one last tale of how your Pi-Rat is remembered!
|
||||
</p>
|
||||
|
||||
<div class="sheet-group" style="text-align: left; margin-top: 2rem;">
|
||||
<h3 style="color: var(--gold);">Crew Objectives</h3>
|
||||
<div class="objectives-checklist">
|
||||
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_1} disabled> <span>Steal a Ship</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_2} disabled> <span>Choose a Captain</span></label>
|
||||
<label class="checkbox-label"><input type="checkbox" checked={state.game.completed_crew_3} disabled> <span>Commit Piracy</span></label>
|
||||
</div>
|
||||
{#if crewDone}
|
||||
<p class="success-text">A complete pirate legend! ⭐</p>
|
||||
{:else}
|
||||
<p class="info-text">Some deeds were left undone... a tale for the next crew.</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="sheet-group" style="text-align: left; margin-top: 1.5rem;">
|
||||
<h3 style="color: var(--gold);">The Crew</h3>
|
||||
{#each pirats as p}
|
||||
<div style="background: rgba(0,0,0,0.2); padding: 0.75rem; border-radius: 6px; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<span>
|
||||
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if}
|
||||
<strong>{p.name}</strong> (Rank {p.rank})
|
||||
{#if p.id === state.game.captain_player_id}<span style="color: var(--gold);"> ⭐ Captain</span>{/if}
|
||||
</span>
|
||||
<span style="font-size: 0.85rem; color: var(--text-muted);">
|
||||
{p.completed_personal_1 ? '🔫 Gat' : '— no gat'} ·
|
||||
{p.completed_personal_2 ? '📛 Named' : '— nameless'} ·
|
||||
{p.completed_personal_3 ? '⚰️ Story complete' : '⛵ Still sailing'}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="info-text" style="margin-top: 2rem;">Want more? Get the full game "The Magical Land of Yeld" at YeldStuff.com!</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,18 +8,32 @@
|
||||
|
||||
// Helpers
|
||||
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
|
||||
$: isCaptain = state.players.reduce((max, p) => p.rank > max.rank ? p : max, state.players[0])?.id === state.player.id;
|
||||
$: captain = state.players.find(p => p.id === state.game.captain_player_id) || null;
|
||||
$: isCaptain = state.game.captain_player_id === state.player.id;
|
||||
$: challenges = state.challenges || [];
|
||||
$: openChallenges = challenges.filter(c => c.status === 'open');
|
||||
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
|
||||
$: myOpenChallenge = openChallenges.find(c => c.acting_player_id === state.player.id) || null;
|
||||
$: myTaxRequest = openChallenges.find(c => c.tax_state === 'requested' && c.tax_target_id === state.player.id) || null;
|
||||
$: myPvpDefense = openChallenges.find(c => c.challenge_type === 'pvp' && c.acting_player_id === state.player.id) || null;
|
||||
$: scenePirats = state.players.filter(p => p.role === 'pirat');
|
||||
|
||||
function playerName(id) {
|
||||
return state.players.find(p => p.id === id)?.name || '???';
|
||||
}
|
||||
|
||||
function isJoker(code) {
|
||||
return !!code && code.startsWith('Joker');
|
||||
}
|
||||
|
||||
async function playCard(obstacleId, cardCode) {
|
||||
playingCard = true;
|
||||
error = '';
|
||||
try {
|
||||
const res = await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/play-card`, 'POST', {
|
||||
obstacle_id: obstacleId,
|
||||
card_code: cardCode
|
||||
});
|
||||
// Removed alert
|
||||
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
@@ -68,6 +82,125 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Challenge controls (Deep) ---
|
||||
let challengeTargetId = '';
|
||||
let challengeStakes = '';
|
||||
let selectedObstacles = {};
|
||||
|
||||
async function createChallenge() {
|
||||
error = '';
|
||||
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/create`, 'POST', {
|
||||
target_player_id: challengeTargetId,
|
||||
obstacle_ids: JSON.stringify(ids),
|
||||
stakes: challengeStakes
|
||||
});
|
||||
challengeTargetId = '';
|
||||
challengeStakes = '';
|
||||
selectedObstacles = {};
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveChallenge(challengeId) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/resolve`, 'POST');
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelChallenge(challengeId) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/cancel`, 'POST');
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Captain controls (Deep / current Captain) ---
|
||||
let captainSelectId = '';
|
||||
async function setCaptain() {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-captain`, 'POST', {
|
||||
target_player_id: captainSelectId
|
||||
});
|
||||
captainSelectId = '';
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gat/Name Tax ---
|
||||
let taxTargetId = '';
|
||||
function taxType(player) {
|
||||
if (!player.completed_personal_1) return 'Gat';
|
||||
if (!player.completed_personal_2) return 'Name';
|
||||
return null;
|
||||
}
|
||||
function eligibleTaxTargets(challenge) {
|
||||
const me = state.player;
|
||||
const type = taxType(me);
|
||||
if (!type) return [];
|
||||
return scenePirats.filter(p =>
|
||||
p.id !== me.id && !p.is_dead &&
|
||||
(type === 'Gat' ? p.completed_personal_1 : p.completed_personal_2)
|
||||
);
|
||||
}
|
||||
async function requestTax(challengeId) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/request`, 'POST', {
|
||||
target_player_id: taxTargetId
|
||||
});
|
||||
taxTargetId = '';
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
async function respondTax(challengeId, accept) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/tax/respond`, 'POST', {
|
||||
accept: accept ? 'true' : 'false'
|
||||
});
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pi-Rat vs Pi-Rat duels ---
|
||||
let pvpOpponentId = '';
|
||||
let pvpCard = '';
|
||||
async function createPvp() {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/pvp/create`, 'POST', {
|
||||
defender_id: pvpOpponentId,
|
||||
card_code: pvpCard
|
||||
});
|
||||
pvpOpponentId = '';
|
||||
pvpCard = '';
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
async function pvpDefend(challengeId, cardCode) {
|
||||
error = '';
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/${challengeId}/pvp/defend`, 'POST', {
|
||||
card_code: cardCode
|
||||
});
|
||||
} catch(e) {
|
||||
error = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
let showEventLog = false;
|
||||
|
||||
let newNameInput = '';
|
||||
@@ -85,18 +218,9 @@
|
||||
}
|
||||
|
||||
// Parsing card helpers
|
||||
function getCardValue(code) {
|
||||
if (!code) return 0;
|
||||
if (code === 'JOKER') return 0;
|
||||
let rank = code.slice(0, -1);
|
||||
if (['J', 'Q', 'K'].includes(rank)) return 10;
|
||||
if (rank === 'A') return 1;
|
||||
return parseInt(rank);
|
||||
}
|
||||
|
||||
function getCardDisplay(code) {
|
||||
if (!code) return "";
|
||||
if (code === 'JOKER') return "🃏 JOKER";
|
||||
if (isJoker(code)) return "🃏 JOKER";
|
||||
let rank = code.slice(0, -1);
|
||||
let suit = code.slice(-1);
|
||||
let suitEmoji = { 'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️' }[suit] || suit;
|
||||
@@ -107,31 +231,31 @@
|
||||
<div class="scene-view-layout" id="scene-layout-container" data-game-id={state.game.id} data-player-id={state.player.id}>
|
||||
<!-- LEFT COLUMN: The Shared Table -->
|
||||
<div class="table-column">
|
||||
|
||||
|
||||
<!-- Game Deck and Obstacles Roster -->
|
||||
<div class="card glass-panel obstacle-list-card">
|
||||
<div class="card-header">
|
||||
<h3>🌊 The Obstacle List</h3>
|
||||
<span class="deck-counter">🎴 Deck: {state.game.deck_cards ? JSON.parse(state.game.deck_cards).length : 0} cards left</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="obstacles-container" id="scene-obstacles-container">
|
||||
<!-- TOP STATUS BAR: Scene Info and Captain -->
|
||||
<div class="scene-status-banner glass-panel" style="margin-bottom: 1.5rem; padding: 1rem; background: rgba(7, 11, 18, 0.6); border: 1px solid rgba(212, 175, 55, 0.2); border-radius: 8px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
|
||||
<!-- Captain Badge -->
|
||||
<div>
|
||||
{#if isCaptain}
|
||||
{#if captain}
|
||||
<span class="captain-badge" style="background: rgba(212, 175, 55, 0.15); border: 1px solid var(--gold); color: var(--gold); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; font-weight: 700; font-family: var(--font-heading); display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
🏴☠️ Captain: {state.players.find(p => p.id === state.player.id)?.name} (Rank {state.players.find(p => p.id === state.player.id)?.rank})
|
||||
🏴☠️ Captain: {captain.name} (Rank {captain.rank}){isCaptain ? ' — You!' : ''}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="captain-badge" style="background: rgba(255, 255, 255, 0.05); border: 1px dashed rgba(255, 255, 255, 0.2); color: var(--text-muted); padding: 0.4rem 1rem; border-radius: 20px; font-size: 1rem; display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
🏴☠️ Captain: {state.players.reduce((max, p) => p.rank > max.rank ? p : max, state.players[0])?.name || 'None'}
|
||||
🏴☠️ Captain: None{state.game.completed_crew_1 ? '' : ' (steal a ship first!)'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Active Scene Roster Mini-list -->
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
|
||||
<span style="font-size: 0.85rem; color: var(--text-muted); font-family: var(--font-heading); margin-right: 0.25rem;">Active Roster:</span>
|
||||
@@ -141,8 +265,8 @@
|
||||
🌊 {p.name} (Deep)
|
||||
</span>
|
||||
{:else if p.role === 'pirat'}
|
||||
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {p.is_ghost ? 'border-color: #7890a8; color: #a0b0c0; background: rgba(120, 150, 180, 0.1);' : ''} {p.is_dead ? 'border-color: var(--red-suit); color: var(--red-suit); background: rgba(255, 42, 95, 0.1);' : ''} {p.id === state.players.reduce((max, p2) => p2.rank > max.rank ? p2 : max, state.players[0])?.id && !p.is_ghost && !p.is_dead ? 'border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);' : ''}">
|
||||
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {p.name} (Rank {p.rank}){p.id === state.players.reduce((max, p2) => p2.rank > max.rank ? p2 : max, state.players[0])?.id && !p.is_ghost && !p.is_dead ? ' ⭐' : ''}
|
||||
<span class="player-chip pirat-chip" style="font-size: 0.8rem; padding: 0.25rem 0.6rem; border-radius: 12px; margin: 0; display: inline-flex; align-items: center; gap: 0.25rem; {p.is_ghost ? 'border-color: #7890a8; color: #a0b0c0; background: rgba(120, 150, 180, 0.1);' : ''} {p.is_dead ? 'border-color: var(--red-suit); color: var(--red-suit); background: rgba(255, 42, 95, 0.1);' : ''} {p.id === state.game.captain_player_id ? 'border-color: var(--gold); color: var(--gold); background: rgba(212, 175, 55, 0.1);' : ''}">
|
||||
{#if p.is_ghost}👻{:else if p.is_dead}💀{:else}🐀{/if} {p.name} (Rank {p.rank}){p.id === state.game.captain_player_id ? ' ⭐' : ''}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -154,12 +278,98 @@
|
||||
<div class="alert alert-danger">{error}</div>
|
||||
{/if}
|
||||
|
||||
<!-- OPEN CHALLENGES -->
|
||||
{#each openChallenges as ch}
|
||||
{@const chPlays = JSON.parse(ch.plays || '[]')}
|
||||
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
|
||||
<div class="glass-panel" style="margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red-suit, #ff2a5f); border-radius: 8px; background: rgba(255, 42, 95, 0.06);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
||||
<h4 style="margin: 0; font-family: var(--font-heading);">
|
||||
{#if ch.challenge_type === 'pvp'}
|
||||
⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}
|
||||
{:else}
|
||||
⚔️ The Deep challenges {playerName(ch.target_player_id)}!
|
||||
{/if}
|
||||
</h4>
|
||||
{#if state.player.role === 'deep' && ch.challenge_type === 'deep'}
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn btn-primary" on:click={() => resolveChallenge(ch.id)} disabled={ch.tax_state === 'requested'}>Resolve</button>
|
||||
<button class="btn btn-secondary" on:click={() => cancelChallenge(ch.id)}>Withdraw</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if ch.stakes}
|
||||
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
|
||||
{/if}
|
||||
{#if ch.challenge_type === 'pvp'}
|
||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
||||
Temporary Obstacle: <strong>{getCardDisplay(ch.temp_card)}</strong> — {playerName(ch.acting_player_id)} must answer it!
|
||||
</p>
|
||||
{:else}
|
||||
<p class="info-text" style="margin: 0.5rem 0 0 0;">
|
||||
Applied Obstacles: {chObstacleIds.map(oid => state.obstacles.find(o => o.id === oid)?.title || '(discarded)').join(', ') || 'None left'}
|
||||
— attempted by <strong>{playerName(ch.acting_player_id)}</strong>{ch.acting_player_id !== ch.target_player_id ? ` (took it over via ${ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax)` : ''}.
|
||||
Other Pi-Rats may assist (assistants don't draw back).
|
||||
</p>
|
||||
{/if}
|
||||
{#if chPlays.length > 0}
|
||||
<p class="info-text" style="margin: 0.25rem 0 0 0; font-size: 0.85rem;">
|
||||
Plays: {chPlays.map(p => `${p.player_name}: ${getCardDisplay(p.card)} ${p.success ? '✔' : '✘'}`).join(' · ')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Tax: pending request aimed at me -->
|
||||
{#if ch.tax_state === 'requested'}
|
||||
{#if myTaxRequest && myTaxRequest.id === ch.id}
|
||||
<div style="margin-top: 0.75rem; padding: 0.75rem; border: 1px dashed var(--gold); border-radius: 6px;">
|
||||
<p style="margin: 0 0 0.5rem 0;"><strong>{playerName(ch.target_player_id)}</strong> calls a <strong>{ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax</strong> on you: take this Challenge for them!</p>
|
||||
<p class="info-text" style="font-size: 0.85rem;">Accept: you get one random card from their hand and attempt the Challenge. Refuse: you hand over your {ch.tax_type === 'gat' ? 'Gat' : 'Name'} — they keep it if they succeed!</p>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button class="btn btn-primary" on:click={() => respondTax(ch.id, true)}>Accept</button>
|
||||
<button class="btn btn-danger" on:click={() => respondTax(ch.id, false)}>Refuse</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="info-text gold-text" style="margin-top: 0.5rem;">⏳ Waiting for {playerName(ch.tax_target_id)} to answer the {ch.tax_type === 'gat' ? 'Gat' : 'Name'} Tax...</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Tax: option for the challenged player -->
|
||||
{#if ch.challenge_type === 'deep' && ch.acting_player_id === state.player.id && !ch.tax_state && !state.player.tax_banned && taxType(state.player) && eligibleTaxTargets(ch).length > 0}
|
||||
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<span class="info-text" style="font-size: 0.9rem;">No {taxType(state.player)}? Make it someone else's problem:</span>
|
||||
<select class="select-field" bind:value={taxTargetId} style="max-width: 200px;">
|
||||
<option value="">Pick a crewmate...</option>
|
||||
{#each eligibleTaxTargets(ch) as p}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="btn btn-secondary" on:click={() => requestTax(ch.id)} disabled={!taxTargetId}>Call {taxType(state.player)} Tax</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- PvP: defender picks a card -->
|
||||
{#if myPvpDefense && myPvpDefense.id === ch.id}
|
||||
<div style="margin-top: 0.75rem;">
|
||||
<p style="margin: 0 0 0.5rem 0;"><strong>Defend yourself!</strong> Pick a card:</p>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
{#each hand.filter(c => !isJoker(c)) as card}
|
||||
<button class="btn btn-secondary" on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each state.obstacles as obs}
|
||||
{@const column_cards = JSON.parse(obs.played_cards || '[]')}
|
||||
{@const active_card_code = column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card}
|
||||
{@const success_count = column_cards.filter(c => c.success).length}
|
||||
{@const is_completed = success_count >= state.players.filter(p => p.role !== 'deep').length}
|
||||
{@const is_completed = success_count >= state.players.length}
|
||||
{@const in_challenge = challengedObstacleIds.has(obs.id)}
|
||||
<div class="obstacle-item suit-{obs.suit.toLowerCase()} {is_completed ? 'completed' : ''}"
|
||||
style={in_challenge ? 'box-shadow: 0 0 0 2px var(--red-suit, #ff2a5f);' : ''}
|
||||
data-obstacle-id={obs.id}
|
||||
on:dragover={(e) => { if (!is_completed) e.preventDefault(); }}
|
||||
on:dragenter={(e) => { if (!is_completed) e.currentTarget.classList.add("drag-over"); }}
|
||||
@@ -170,56 +380,52 @@
|
||||
e.currentTarget.classList.remove("drag-over");
|
||||
const cardCode = e.dataTransfer.getData('text/plain');
|
||||
if (cardCode) {
|
||||
if (cardCode === 'JOKER') playJoker(obs.id, cardCode);
|
||||
if (isJoker(cardCode)) playJoker(obs.id, cardCode);
|
||||
else playCard(obs.id, cardCode);
|
||||
}
|
||||
}}>
|
||||
<div class="obstacle-card-wrapper" style="display: flex; justify-content: center; align-items: center;">
|
||||
<div class="card-medium suit-{active_card_code.slice(-1).toLowerCase()} {active_card_code === 'JOKER' ? 'joker-card' : ''}" title="Active Card: {getCardDisplay(active_card_code)}">
|
||||
<div class="card-medium suit-{active_card_code.slice(-1).toLowerCase()}" title="Active Card: {getCardDisplay(active_card_code)}">
|
||||
<div class="card-corner top-left">
|
||||
<span class="val">{active_card_code === 'JOKER' ? '🃏' : active_card_code.slice(0, -1)}</span>
|
||||
<span class="suit">{active_card_code === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||
<span class="val">{active_card_code.slice(0, -1)}</span>
|
||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card-center">
|
||||
{#if active_card_code === 'JOKER'}
|
||||
🃏
|
||||
{:else}
|
||||
<span class="giant-symbol" style="font-size: 1.5rem;">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||
{/if}
|
||||
<span class="giant-symbol" style="font-size: 1.5rem;">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card-corner bottom-right">
|
||||
<span class="val">{active_card_code === 'JOKER' ? '🃏' : active_card_code.slice(0, -1)}</span>
|
||||
<span class="suit">{active_card_code === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||
<span class="val">{active_card_code.slice(0, -1)}</span>
|
||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[active_card_code.slice(-1)]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="obstacle-details">
|
||||
<h4>{obs.title}</h4>
|
||||
<h4>{obs.title} {#if in_challenge}<span style="color: var(--red-suit, #ff2a5f); font-size: 0.8rem;">⚔️ In Challenge</span>{/if}</h4>
|
||||
<p class="desc">{obs.description}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Value Display -->
|
||||
<div class="obstacle-value-display text-center">
|
||||
<span class="val-label">Current Difficulty</span>
|
||||
<span class="val-number">
|
||||
{#if ['J', 'Q', 'K'].includes(obs.original_card.slice(0, -1))}
|
||||
Rank ({state.player.rank})
|
||||
{#if ['J', 'Q', 'K'].includes((column_cards.length > 0 ? column_cards[column_cards.length - 1].card : obs.original_card).slice(0, -1))}
|
||||
Challenged Rat's Rank
|
||||
{:else}
|
||||
{obs.current_value}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Successes Tracker -->
|
||||
<div class="obstacle-successes-display text-center">
|
||||
<span class="val-label">Successes</span>
|
||||
<span class="val-number" style="font-size: 1.5rem;">
|
||||
{success_count} / {state.players.filter(p => p.role !== 'deep').length}
|
||||
{success_count} / {state.players.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Played Cards Column -->
|
||||
<div class="played-column">
|
||||
<h5>Card History (Column)</h5>
|
||||
@@ -229,16 +435,16 @@
|
||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[obs.original_card.slice(-1)]}</span>
|
||||
</div>
|
||||
{#each column_cards as pc}
|
||||
<div class="card-mini played-card rotated {pc.success ? 'success' : 'failure'}"
|
||||
<div class="card-mini played-card rotated {pc.success ? 'success' : 'failure'}"
|
||||
title="{pc.player_name} played {getCardDisplay(pc.card)}">
|
||||
<span class="val">{pc.card === 'JOKER' ? '🃏' : pc.card.slice(0, -1)}</span>
|
||||
<span class="suit">{pc.card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]}</span>
|
||||
<span class="val">{pc.card.slice(0, -1)}</span>
|
||||
<span class="suit">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[pc.card.slice(-1)]}</span>
|
||||
<span class="owner">{pc.player_name.slice(0,4)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{#if is_completed && state.player.role === 'deep'}
|
||||
<div class="text-center" style="margin-top: 1rem;">
|
||||
<button class="btn btn-success" on:click={() => clearObstacle(obs.id)} style="width: 100%; border: 1px solid rgba(255,255,255,0.2);">Clear Obstacle</button>
|
||||
@@ -249,19 +455,58 @@
|
||||
<p class="empty-text">No active obstacles. Deep players can end the scene!</p>
|
||||
{/each}
|
||||
|
||||
<!-- Event Log Removed from inline layout -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- RIGHT COLUMN: Private Hand & Character Sheet -->
|
||||
<div class="private-column">
|
||||
<!-- DEEP CONTROLS: End Scene & Objectives -->
|
||||
<!-- DEEP CONTROLS: Challenges, End Scene & Objectives -->
|
||||
{#if state.player.role === "deep"}
|
||||
<div class="card glass-panel deep-panel-card" style="max-height: 80vh; overflow-y: auto;">
|
||||
<h3 class="deep-text text-center">🌊 Deep Control Panel</h3>
|
||||
|
||||
|
||||
<!-- Call a Challenge -->
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">⚔️ Call a Challenge</h4>
|
||||
<p class="info-text" style="font-size: 0.85rem;">Establish the stakes, pick a Pi-Rat, and apply one or more Obstacles. They play one card per Obstacle — beating one passes; each failure breeds a complication.</p>
|
||||
<select class="select-field" bind:value={challengeTargetId} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<option value="">Challenge which Pi-Rat?</option>
|
||||
{#each scenePirats.filter(p => !p.is_dead) as p}
|
||||
<option value={p.id}>{p.name} (Rank {p.rank})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div style="margin-bottom: 0.5rem;">
|
||||
{#each state.obstacles as obs}
|
||||
{@const taken = challengedObstacleIds.has(obs.id)}
|
||||
<label class="checkbox-label" style="{taken ? 'opacity: 0.5;' : ''}">
|
||||
<input type="checkbox" bind:checked={selectedObstacles[obs.id]} disabled={taken}>
|
||||
<span>{obs.title}{taken ? ' (in a challenge)' : ''}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<input type="text" class="form-control" placeholder="Stakes (optional): what happens on success/failure?" bind:value={challengeStakes} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<button class="btn btn-primary btn-full" on:click={createChallenge}
|
||||
disabled={!challengeTargetId || !Object.values(selectedObstacles).some(v => v)}>
|
||||
Call Challenge
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Captain Assignment -->
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">🏴☠️ Captaincy</h4>
|
||||
<p class="info-text" style="font-size: 0.85rem;">The Captain holds power until they step down, lose a duel, die, or the ship is lost. Reassign after a leadership duel or resignation.</p>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<select class="select-field" bind:value={captainSelectId} style="flex: 1;">
|
||||
<option value="">No Captain</option>
|
||||
{#each scenePirats.filter(p => !p.is_dead) as p}
|
||||
<option value={p.id}>{p.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="btn btn-secondary" on:click={setCaptain}>Set</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
||||
<div class="objectives-checklist">
|
||||
@@ -294,10 +539,10 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
<!-- End Scene Button -->
|
||||
<div class="scene-upkeep-controls text-center" style="margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
|
||||
<p class="info-text">Conclude the scene once players have made attempts or the obstacle list is depleted.</p>
|
||||
<p class="info-text">End the scene once every Pi-Rat has faced a Challenge and had a chance at an Objective, or the Obstacle List is empty.</p>
|
||||
<button on:click={endScene} class="btn btn-danger btn-large btn-full glow-effect">
|
||||
End Scene & Proceed to Ranking
|
||||
</button>
|
||||
@@ -308,11 +553,11 @@
|
||||
<!-- Player Hand Card -->
|
||||
<div class="card glass-panel hand-card">
|
||||
<h3>🃏 Your Hand</h3>
|
||||
<p class="section-desc">Keep your cards secret! {#if state.player.role !== "deep"}Drag a card onto an active obstacle to play it.{/if}</p>
|
||||
|
||||
<p class="section-desc">Keep your cards secret! {#if state.player.role !== "deep"}When the Deep challenges you (or you assist a crewmate), drag a card onto an applied obstacle to play it. Jokers can hit any obstacle, any time.{/if}</p>
|
||||
|
||||
<div class="hand-flex">
|
||||
{#each hand as card}
|
||||
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card === 'JOKER' ? 'joker-card' : ''}"
|
||||
<div class="card-large suit-{isJoker(card) ? 'joker' : card.slice(-1).toLowerCase()} {isJoker(card) ? 'joker-card' : ''}"
|
||||
draggable={state.player.role !== "deep"}
|
||||
on:dragstart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', card);
|
||||
@@ -322,31 +567,31 @@
|
||||
e.currentTarget.classList.remove("dragging");
|
||||
}}>
|
||||
<div class="card-corner top-left">
|
||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card-center">
|
||||
{#if card === 'JOKER'}
|
||||
{#if isJoker(card)}
|
||||
🃏
|
||||
{:else}
|
||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card-tech-overlay">
|
||||
{#if card.slice(0, -1) === "J"}
|
||||
{#if !isJoker(card) && card.slice(0, -1) === "J"}
|
||||
<div class="tech-tag" title="Automatic success when played">J: "{state.player.tech_jack}"</div>
|
||||
{:else if card.slice(0, -1) === "Q"}
|
||||
{:else if !isJoker(card) && card.slice(0, -1) === "Q"}
|
||||
<div class="tech-tag" title="Automatic success when played">Q: "{state.player.tech_queen}"</div>
|
||||
{:else if card.slice(0, -1) === "K"}
|
||||
{:else if !isJoker(card) && card.slice(0, -1) === "K"}
|
||||
<div class="tech-tag" title="Automatic success when played">K: "{state.player.tech_king}"</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card-corner bottom-right">
|
||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
<span class="val">{isJoker(card) ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{isJoker(card) ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -354,12 +599,12 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Player Character Sheet Card -->
|
||||
{#if state.player.role !== "deep"}
|
||||
<div class="card glass-panel sheet-card">
|
||||
<h3>🐀 {state.player.name}'s Character Sheet</h3>
|
||||
|
||||
|
||||
<div class="character-sheet-details">
|
||||
{#if state.player.is_dead}
|
||||
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.1); border: 1px dashed var(--red-suit); padding: 1rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
||||
@@ -386,13 +631,19 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if state.player.tax_banned}
|
||||
<div class="sheet-group prompt-box" style="background: rgba(255, 42, 95, 0.08); border: 1px dashed var(--red-suit); padding: 0.75rem; border-radius: 8px; margin-bottom: 1rem; text-align: center;">
|
||||
<p class="info-text" style="margin: 0; font-size: 0.9rem;">🚫 You failed a refused Tax — no more Gat/Name Taxes for you this scene.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="sheet-group">
|
||||
<h4>Description:</h4>
|
||||
<p><strong>Look:</strong> {state.player.avatar_look}</p>
|
||||
<p><strong>Smell:</strong> {state.player.avatar_smell}</p>
|
||||
<p><strong>First Words:</strong> "{state.player.first_words}"</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4>Assigned Secret Techniques (J/Q/K):</h4>
|
||||
<ul class="techniques-list-sheet">
|
||||
@@ -402,6 +653,27 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Duel another Pi-Rat -->
|
||||
{#if !state.player.is_dead && scenePirats.filter(p => p.id !== state.player.id && !p.is_dead).length > 0}
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">⚔️ 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>
|
||||
<select class="select-field" bind:value={pvpOpponentId} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<option value="">Challenge whom?</option>
|
||||
{#each scenePirats.filter(p => p.id !== state.player.id && !p.is_dead) as p}
|
||||
<option value={p.id}>{p.name} (Rank {p.rank})</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select class="select-field" bind:value={pvpCard} style="width: 100%; margin-bottom: 0.5rem;">
|
||||
<option value="">Throw which card?</option>
|
||||
{#each hand.filter(c => !isJoker(c)) as card}
|
||||
<option value={card}>{getCardDisplay(card)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="btn btn-secondary btn-full" on:click={createPvp} disabled={!pvpOpponentId || !pvpCard}>Throw Down!</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="sheet-group margin-top">
|
||||
<h4 style="color: var(--gold);">Crew Objectives</h4>
|
||||
<div class="objectives-checklist">
|
||||
@@ -448,7 +720,7 @@
|
||||
<button class="toggle-log-btn" on:click={() => showEventLog = !showEventLog}>
|
||||
{showEventLog ? '⬇️ Hide Log' : '📜 Event Log'}
|
||||
</button>
|
||||
|
||||
|
||||
{#if showEventLog}
|
||||
<div class="log-content font-mono text-sm space-y-2 p-2">
|
||||
{#each state.events as event}
|
||||
|
||||
@@ -72,14 +72,14 @@
|
||||
<div class="role-restrictions mt-4">
|
||||
{#if !allowedRoles.includes('pirat')}
|
||||
{#if state.game.current_scene_number === 1}
|
||||
<p class="text-red-400 text-sm italic">You are the highest ranked player, so you must play The Deep for the first scene!</p>
|
||||
<p class="text-red-400 text-sm italic">You start at Rank 3, so you must play The Deep for the first scene!</p>
|
||||
{:else}
|
||||
<p class="text-red-400 text-sm italic">You are the only eligible player for The Deep this scene!</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !allowedRoles.includes('deep')}
|
||||
{#if state.game.current_scene_number === 1}
|
||||
<p class="text-red-400 text-sm italic">Only the highest ranked player(s) can play The Deep in the first scene!</p>
|
||||
<p class="text-red-400 text-sm italic">You start at Rank 1, so you must play your Pi-Rat for the first scene!</p>
|
||||
{:else}
|
||||
<p class="text-red-400 text-sm italic">You played The Deep last scene, so you must play a Pi-Rat!</p>
|
||||
{/if}
|
||||
|
||||
@@ -36,6 +36,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function finishGame() {
|
||||
if (!confirm('End the story for everyone? Best saved for when every player has played the Deep, each Pi-Rat has completed a Personal Objective, and all 3 Crew Objectives are done.')) return;
|
||||
try {
|
||||
await apiRequest(`/game/${state.game.id}/finish`, 'POST');
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReRoll() {
|
||||
if (!reRollDetails.avatar_look.trim() ||
|
||||
!reRollDetails.avatar_smell.trim() ||
|
||||
@@ -115,7 +124,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: maxHandSize = state.player.rank + 1;
|
||||
// Mirrors the backend hand size table: highest rank -> 4, lowest -> 2, middle -> 3, Captain +1.
|
||||
// Deep players are ranked against all players.
|
||||
$: maxHandSize = (() => {
|
||||
const ranks = state.players.map(p => p.rank);
|
||||
let base;
|
||||
if (state.player.rank >= Math.max(...ranks)) base = 4;
|
||||
else if (state.player.rank <= Math.min(...ranks)) base = 2;
|
||||
else base = 3;
|
||||
return base + (state.game.captain_player_id === state.player.id ? 1 : 0);
|
||||
})();
|
||||
$: isOverLimit = keepCards.length > maxHandSize;
|
||||
|
||||
async function submitVote() {
|
||||
@@ -180,7 +198,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<p class="info-text" style="margin-top: 1.5rem; font-style: italic; color: var(--text-muted);">
|
||||
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh with a randomized starting Rank between 1 and 3.
|
||||
Ghosts look back into the living world, watch over friends, and help their crew. New recruits start fresh at Rank 1 with surprise Secret Techniques and a name based on their smell.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="creation-form-wrapper" style="text-align: left; background: rgba(0,0,0,0.2); padding: 1.5rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.05);">
|
||||
@@ -234,8 +252,8 @@
|
||||
<!-- Ranks and Voting Card -->
|
||||
<div class="card glass-panel voting-card">
|
||||
<h3>1. Pirate Ranking (Voting)</h3>
|
||||
<p class="section-desc">Nominate one crewmate who best exemplified pirate qualities in the previous scene. Previous Deep players are ineligible to receive votes.</p>
|
||||
|
||||
<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>
|
||||
@@ -244,10 +262,6 @@
|
||||
<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 state.player.role === 'deep'}
|
||||
<div class="voted-confirmation-box glass-panel">
|
||||
<p class="info-text">The Deep does not vote.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={submitVote} class="vote-form inline-form">
|
||||
<div class="form-group inline-group">
|
||||
@@ -270,7 +284,7 @@
|
||||
<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) || p.role === 'deep'}
|
||||
{@const voted = state.votes.some(v => v.voter_player_id === p.id)}
|
||||
<div class="player-chip {voted ? 'voted-chip' : 'not-voted-chip'}">
|
||||
<span class="name">{p.name}</span>
|
||||
<span class="status-badge">{voted ? 'Done' : 'Voting...'}</span>
|
||||
@@ -305,26 +319,26 @@
|
||||
<h4 class="gold-text">Hand (Keep)</h4>
|
||||
<div class="upkeep-flex">
|
||||
{#each keepCards as card}
|
||||
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card === 'JOKER' ? 'joker-card' : ''}"
|
||||
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}"
|
||||
draggable="true"
|
||||
on:dragstart={(e) => handleDragStart(e, card)}
|
||||
on:click={() => moveToDiscard(card)}
|
||||
style="cursor: pointer;"
|
||||
title="Click or drag to discard">
|
||||
<div class="card-corner top-left">
|
||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
<div class="card-center">
|
||||
{#if card === 'JOKER'}
|
||||
{#if card.startsWith('Joker')}
|
||||
🃏
|
||||
{:else}
|
||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-corner bottom-right">
|
||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -340,26 +354,26 @@
|
||||
<h4 class="gold-text">Discard Pile</h4>
|
||||
<div class="upkeep-flex">
|
||||
{#each discardCards as card}
|
||||
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card === 'JOKER' ? 'joker-card' : ''}"
|
||||
<div class="card-large suit-{card.slice(-1).toLowerCase()} {card.startsWith('Joker') ? 'joker-card' : ''}"
|
||||
draggable="true"
|
||||
on:dragstart={(e) => handleDragStart(e, card)}
|
||||
on:click={() => moveToKeep(card)}
|
||||
style="cursor: pointer;"
|
||||
title="Click or drag to keep">
|
||||
<div class="card-corner top-left">
|
||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
<div class="card-center">
|
||||
{#if card === 'JOKER'}
|
||||
{#if card.startsWith('Joker')}
|
||||
🃏
|
||||
{:else}
|
||||
<span class="giant-symbol">{{'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="card-corner bottom-right">
|
||||
<span class="val">{card === 'JOKER' ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card === 'JOKER' ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
<span class="val">{card.startsWith('Joker') ? '🃏' : card.slice(0, -1)}</span>
|
||||
<span class="suit">{card.startsWith('Joker') ? '' : {'C': '♣️', 'S': '♠️', 'H': '♥️', 'D': '♦️'}[card.slice(-1)]}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -414,7 +428,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if state.player.role === 'pirat' && !state.votes.find(v => v.voter_player_id === state.player.id)}
|
||||
{#if !state.votes.find(v => v.voter_player_id === state.player.id)}
|
||||
<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}
|
||||
@@ -432,6 +446,13 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if state.player.is_creator && state.game.phase === 'between_scenes'}
|
||||
<div style="margin-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 1rem;">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user