254 lines
13 KiB
Svelte
254 lines
13 KiB
Svelte
<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 Obstacle’s 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}
|