Files
pirats/frontend/src/components/scene/ChallengePanel.svelte
T

254 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { createEventDispatcher } from 'svelte';
import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltipHtml, obstacleTable } from '../../lib/cards';
import { tooltip } from '../../lib/tooltip';
import ObstacleItem from './ObstacleItem.svelte';
export let state;
export let completedDuels = [];
const dispatch = createEventDispatcher();
let error = '';
let taxTargetId = '';
// Deep: call-a-challenge form (moved here from the old Deep Control Panel)
let showCreate = false;
let challengeTargetId = '';
let stakesSuccess = '';
let stakesFailure = '';
let selectedObstacles = {};
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
$: challengedObstacleIds = new Set(openChallenges.flatMap(c => JSON.parse(c.obstacle_ids || '[]')));
$: 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');
$: isDeep = state.player.role === 'deep';
function playerName(id) {
return lookupName(state.players, id);
}
function taxType(player) {
if (!player.completed_personal_1) return 'Gat';
if (!player.completed_personal_2) return 'Name';
return null;
}
function eligibleTaxTargets() {
const type = taxType(state.player);
if (!type) return [];
return scenePirats.filter(p =>
p.id !== state.player.id && !p.is_dead &&
(type === 'Gat' ? p.completed_personal_1 : p.completed_personal_2)
);
}
async function post(path, data = null) {
error = '';
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/${path}`, 'POST', data);
return true;
} catch(e) {
error = e.message;
return false;
}
}
const resolveChallenge = (id) => post(`challenge/${id}/resolve`);
const cancelChallenge = (id) => post(`challenge/${id}/cancel`);
const respondTax = (id, accept) => post(`challenge/${id}/tax/respond`, { accept: accept ? 'true' : 'false' });
const pvpDefend = (id, cardCode) => post(`challenge/${id}/pvp/defend`, { card_code: cardCode });
async function requestTax(challengeId) {
if (await post(`challenge/${challengeId}/tax/request`, { target_player_id: taxTargetId })) {
taxTargetId = '';
}
}
async function createChallenge() {
const ids = Object.keys(selectedObstacles).filter(id => selectedObstacles[id]);
if (await post('challenge/create', {
target_player_id: challengeTargetId,
obstacle_ids: JSON.stringify(ids),
stakes_success: stakesSuccess,
stakes_failure: stakesFailure,
})) {
challengeTargetId = '';
stakesSuccess = '';
stakesFailure = '';
selectedObstacles = {};
showCreate = false;
}
}
async function endScene() {
error = '';
try {
await apiRequest(`/game/${state.game.id}/scene/end`, 'POST');
} catch(e) {
error = e.message;
}
}
</script>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
{#each openChallenges as ch}
{@const chObstacleIds = JSON.parse(ch.obstacle_ids || '[]')}
<div class="challenge-item">
<div class="challenge-head">
<h4>
{#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 class="challenge-actions">
<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_success}
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>✅ On success:</strong> {ch.stakes_success}</p>
{/if}
{#if ch.stakes_failure}
<p class="info-text" style="margin: 0.25rem 0 0 0;"><strong>❌ On failure:</strong> {ch.stakes_failure}</p>
{/if}
{#if ch.stakes && !ch.stakes_success && !ch.stakes_failure}
<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 use:tooltip={{ html: cardTooltipHtml(ch.temp_card, $obstacleTable, true) }}>{getCardDisplay(ch.temp_card)}</strong>{playerName(ch.acting_player_id)} must answer it!
</p>
{:else}
<p class="info-text" style="margin-top: 0.5rem;">{state.obstacles.filter(o => chObstacleIds.includes(o.id)).length > 1
? 'Other Pi-Rats may assist with one card each. Only the attempting Pi-Rat draws replacements after resolution.'
: 'Only the attempting Pi-Rat can play against this single Obstacle. Matching colors earn a replacement after resolution.'}</p>
<div class="challenge-obstacles">
{#each chObstacleIds as oid}
{@const obs = state.obstacles.find(o => o.id === oid)}
{#if obs}
<ObstacleItem {state} {obs} embedded />
{:else}
<p class="info-text" style="margin: 0;">An applied Obstacle was discarded.</p>
{/if}
{/each}
</div>
{/if}
<!-- Tax: pending request aimed at me -->
{#if ch.tax_state === 'requested'}
{#if myTaxRequest && myTaxRequest.id === ch.id}
<div class="tax-callout">
<p><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 class="challenge-actions">
<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().length > 0}
<div class="challenge-actions" style="margin-top: 0.75rem;">
<span class="info-text" style="font-size: 0.9rem; margin: 0;">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() as p}
<option value={p.id}>{displayName(p)}</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> Match the Obstacles color to draw a replacement, even if you lose:</p>
<div class="challenge-actions">
{#each hand.filter(c => !isJoker(c)) as card}
<button class="btn btn-secondary" use:tooltip={{ html: cardTooltipHtml(card, $obstacleTable, true) }} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
{/each}
</div>
</div>
{/if}
</div>
{/each}
<!-- Each player clears their own result feedback after reading it. -->
{#each completedDuels as ch (ch.id)}
<div class="challenge-item duel-result">
<div class="challenge-head">
<h4>⚔️ Duel: {playerName(ch.challenger_player_id)} vs {playerName(ch.target_player_id)}</h4>
<button class="btn btn-secondary btn-small" title="Dismiss this result for you" on:click={() => dispatch('dismissDuel', ch.id)}>Dismiss</button>
</div>
<p class="duel-outcome">
{ch.status === 'succeeded' ? '✓' : '✕'}
<strong>{playerName(ch.acting_player_id)} {ch.status === 'succeeded' ? 'won the defense' : 'lost the defense'}.</strong>
{playerName(ch.status === 'succeeded' ? ch.acting_player_id : ch.challenger_player_id)} wins the duel.
</p>
<p class="info-text">Temporary Obstacle: <strong use:tooltip={{ html: cardTooltipHtml(ch.temp_card, $obstacleTable, true) }}>{getCardDisplay(ch.temp_card)}</strong></p>
{#each JSON.parse(ch.plays || '[]') as play}
<p class="info-text">
Defense: <strong use:tooltip={{ html: cardTooltipHtml(play.card, $obstacleTable, true) }}>{getCardDisplay(play.card)}</strong>.
{play.details}
</p>
{/each}
</div>
{/each}
<!-- Deep controls: call a Challenge, and end the scene when none is active -->
<!-- The Deep calls Challenges and ends the scene only when none is active. -->
{#if isDeep && openChallenges.length === 0}
<div class="deep-challenge-controls">
<button class="btn {showCreate ? 'btn-secondary' : 'btn-primary'} btn-full" on:click={() => showCreate = !showCreate}>
{showCreate ? '✕ Cancel' : '⚔️ Call a Challenge'}
</button>
{#if showCreate}
<div class="sheet-group margin-top">
<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}>{displayName(p)} (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="input-field" placeholder="On success (optional): what they win…" bind:value={stakesSuccess} style="width: 100%; margin-bottom: 0.5rem;">
<input type="text" class="input-field" placeholder="On failure (optional): what it costs them…" bind:value={stakesFailure} 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>
{/if}
<div class="scene-upkeep-controls text-center">
<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>
</div>
</div>
{/if}