Files
pirats/frontend/src/components/scene/ChallengePanel.svelte
Tim McCarthy 39dc07d6b6 Split Deep challenge stakes into success and failure fields
The Deep now states what happens on success and on failure as two
separate fields when calling a Challenge, instead of one free-text
stakes blob. ChallengePanel shows them as " On success" / " On
failure"; the event log lists both.

- Challenge.stakes_success / stakes_failure (+ migration); the old
  single `stakes` column is kept (vestigial) so in-scene rollback
  snapshots taken before the upgrade still deserialize, and ChallengePanel
  falls back to it for any pre-existing challenge.
- create_challenge / route / DeepControlPanel updated; tests adjusted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 09:28:38 -07:00

150 lines
7.5 KiB
Svelte

<script>
import { apiRequest } from '../../lib/api';
import { getCardDisplay, isJoker, displayName, playerName as lookupName, cardTooltip, obstacleTable } from '../../lib/cards';
export let state;
let error = '';
let taxTargetId = '';
$: hand = state.player.hand_cards ? JSON.parse(state.player.hand_cards) : [];
$: openChallenges = (state.challenges || []).filter(c => c.status === 'open');
$: 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 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 = '';
}
}
</script>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
{#each openChallenges as ch}
{@const chPlays = JSON.parse(ch.plays || '[]')}
{@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 title={cardTooltip(ch.temp_card, $obstacleTable)}>{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 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> Pick a card:</p>
<div class="challenge-actions">
{#each hand.filter(c => !isJoker(c)) as card}
<button class="btn btn-secondary" title={cardTooltip(card, $obstacleTable)} on:click={() => pvpDefend(ch.id, card)}>{getCardDisplay(card)}</button>
{/each}
</div>
</div>
{/if}
</div>
{/each}