Files
Dune/components/juice-of-sapho.tsx
T

194 lines
13 KiB
TypeScript
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.
'use client';
import { useId } from 'react';
import type { Action, GameView } from '@/game/engine';
import { richeseCardDefinition } from '@/game/richese-cards';
import { Button } from './ui/button';
import { CardInspector } from './card-inspector';
/** Reasons use public timing only; exact card reservation checks stay on the server. */
function unavailableReason(game: GameView) {
if (game.status !== 'playing')
return 'Order changes are available during play.';
if (game.phase === 5)
return 'Movement order supports clean combined-turn boundaries and queuing behind a started active turn. Your own started turn may be paused at a clean boundary and resumed last, with spent actions retained. Finish pending interactions and preparations first. The Advanced Guild retains its next timing choice; another player cannot displace an already granted Guild turn, and last remains after the Guild. A Karama-locked privilege stays locked. Completed turns and actual shipment and movement counters stay unchanged. Already-held positions and committed cards remain unavailable.';
if (game.truthtrance || game.response || game.phaseOpening ||
(game.decision && !(game.phase === 8 && game.decision.kind === 'moritaniExtortion')))
return 'Finish the current interaction before changing turn order.';
if (game.phase === 2)
return !game.advanced || !game.charityOrder
? 'Charity order changes require an ordered Advanced Charity phase. Basic and an already-opened unstamped phase keep their existing timing.'
: 'Change the order of remaining Charity turns. Another player finishes any started turn first. At a clean boundary, you may pause your own started turn and resume it last with your claim unchanged. Completed turns stay finished; already-held positions and committed cards are unavailable.';
if (game.phase === 4)
return !game.advanced || !game.revivalOrder
? 'Revival order changes require an ordered Advanced Revival phase. Basic and an already-opened unstamped phase keep their existing timing.'
: 'Change the order of remaining Revival turns. Another player finishes any started turn first. At a clean boundary, you may pause your own started turn and resume it last with used allowances unchanged. Completed turns stay finished; already-held positions and committed cards are unavailable.';
if (game.phase === 8 && game.extortion?.pending)
return 'Only an unanswered non-Moritani player can change their Extortion choice position. Earlier declines stay finished. Already-held first/last positions and committed cards are unavailable; you may still decline payment when your choice arrives.';
if (game.phase === 3) {
if (game.richeseAuction?.method === 'silent')
return 'Silent bids are simultaneous. These controls do not change their tie order.';
if (game.auction || game.richeseAuction?.method === 'normal')
return 'No cyclic order change is available for your pending bid. A passed player waits for a new raise; the current high bidder has no pending bid. Already-held positions and committed cards are unavailable.';
return 'No order change is available for you in this lot. You must not have bid or passed; discarding this card can open a hand slot, but a completed opportunity cannot be repeated. A card already committed elsewhere cannot be used.';
}
if (game.phase === 6)
return game.battle
? game.battle.saphoTimingVersion === 1
? 'Become aggressor at a clean boundary before both players finish final battle actions. Already-held priority, a committed card, pending decisions and closed final opportunities are unavailable. Finish the current battle and its aftermath before changing remaining battle-choice order.'
: 'This older battle retains its pre-plan opportunity, before declaring ready or committing a plan. Already-held priority and committed cards are unavailable. Finish the current battle and its aftermath before changing remaining battle-choice order.'
: 'You need an unresolved battle and an available first or last position. Completed battles stay resolved, and a card committed elsewhere cannot be used.';
return 'These controls support Once Around and cyclic bidding, movement order, remaining battle-choice order and aggressor priority. Other phase modes remain unfinished.';
}
type SaphoOption = NonNullable<GameView['saphoOptions']>[number];
/** Submit only action fields: projected timing guidance is display metadata. */
export function SaphoOrderButton({
card,
option,
act,
busy,
}: {
card: string;
option: SaphoOption;
act: (action: Action) => void;
busy: boolean;
}) {
const bidding = option.scope === 'onceAround' || option.scope === 'cyclicAuction';
return (
<Button
className="min-h-11 whitespace-normal motion-reduce:transition-none"
disabled={busy}
onClick={() => {
if (!busy) act({
type: 'card', card, scope: option.scope, event: option.event, mode: option.mode,
});
}}
>
{option.scope === 'battleAggressor' ? 'Become aggressor and discard Juice of Sapho' :
(option.scope === 'movement' || option.scope === 'revival' || option.scope === 'charity') && option.resumeOwn ? `Resume your ${option.scope} last and discard Juice of Sapho` :
`${bidding ? 'Bid' : option.scope === 'battleOrder' ? 'Choose battles' : option.scope === 'extortion' ? 'Take your Extortion choice' : option.scope === 'revival' ? 'Take your Revival turn' : option.scope === 'charity' ? 'Take your Charity turn' : 'Take your movement turn'} ${option.mode} and discard Juice of Sapho`}
</Button>
);
}
export function JuiceOfSapho({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const me = game.players.find((p) => p.id === game.me);
const card = me?.hand?.find(
(c) => richeseCardDefinition(c)?.card.effect === 'juiceOfSapho',
);
if (!card) return null;
const options = (game.saphoOptions ?? []).filter(option =>
option.scope === 'charity'
? game.advanced && game.phase === 2 && !!game.charityOrder?.remaining.includes(game.me)
: option.scope !== 'revival' ||
(game.advanced && game.phase === 4 && game.revivalOrder?.remaining.includes(game.me) &&
!game.revivalOrder.completed.includes(game.me)));
return (
<details className="my-4 min-w-0">
<summary className="min-h-11 cursor-pointer py-3">
Juice of Sapho: change order or become aggressor
</summary>
<div className="flex min-w-0 flex-col gap-4 py-3">
<p>
Choose one available order change, then discard Juice of Sapho. This
does not grant an extra bid, shipment, movement or battle.
</p>
<CardInspector card={card} />
{options.length ? (
options.map((option) => {
const once = option.scope === 'onceAround';
const cyclic = option.scope === 'cyclicAuction';
const battle = option.scope === 'battleOrder';
const aggressor = option.scope === 'battleAggressor';
const extortion = option.scope === 'extortion';
const revival = option.scope === 'revival';
const charity = option.scope === 'charity';
const resumeOwn = (option.scope === 'movement' || revival || charity) && option.resumeOwn;
const afterCurrent = (option.scope === 'movement' || revival || charity) && option.afterCurrent;
const currentPlayer = afterCurrent
? game.players.find(player => player.id === afterCurrent)?.name ?? 'The active player'
: null;
return (
<div
key={`${option.scope}-${option.event}-${option.mode}`}
className="flex min-w-0 flex-col gap-2"
>
{currentPlayer && (charity ? (
<p aria-label="Provisional queued Charity order">
Provisional queued Charity order: {currentPlayer} finishes
the current Charity turn unchanged. Your {option.mode}
{' '}position applies among later unstarted Charity turns.
Completed turns and claims stay finished; each player
still has only one Charity claim.
</p>
) : revival ? (
<p aria-label="Provisional queued Revival order">
Provisional queued Revival order: {currentPlayer} finishes
the current Revival turn unchanged. Your {option.mode}
{' '}position applies among later unstarted Revival turns.
Completed turns, returned pieces, payments and revival
allowances stay unchanged.
</p>
) : (
<p>
Provisional queued movement order: {currentPlayer} finishes
the current combined shipment and movement turn unchanged.
Your {option.mode} position applies among later unstarted
combined turns. Completed turns and actual shipment and
movement counters stay unchanged.
{game.advanced && game.players.some(player => player.faction === 'guild' && player.id !== game.me)
? ' The Advanced Guild keeps its native timing choice at the next boundary. A last position still follows the Guild.'
: ''}
</p>
))}
<p>
{resumeOwn
? charity
? 'Pause your started Charity turn and resume it last, after the other remaining players. Your completed claim and its payment remain unchanged. This timing is provisional.'
: revival
? 'Pause your started Revival turn and resume it last, after the other remaining players. Returned pieces, costs and used revival allowances remain unchanged. This timing is provisional.'
: 'Pause your started turn and resume it last, after the other remaining players. Shipment and movement already used stay spent; no extra action is granted. This timing is provisional.'
: aggressor
? 'Become this battle’s aggressor. You win ordinary ties; a Habbanya Stronghold advantage still takes precedence. The participants, committed plans and later battle choices stay unchanged.'
: extortion
? `Take your Extortion choice ${option.mode} among unanswered players. Earlier declines and Moritani’s five-spice bank collection remain. You may decline instead of paying; the first actual three-spice payment ends all offers.`
: cyclic
? `Bid ${option.mode} in the current cyclic lot. Provisional duration: this lot only. Existing bids, funding and passes stay committed. Later circuits use the changed cycle; the next lot returns to its normal opener.`
: once
? `Bid ${option.mode} in the current Once Around lot. Each eligible player still has only one bidding opportunity.`
: battle
? `Choose your remaining battles ${option.mode}. Completed battles stay resolved. This changes who chooses next, not the aggressor or tie advantage of a battle. Other players may still choose battles against you before your turn.`
: charity
? `Take the ${option.mode} remaining Charity turn. Completed Charity turns stay finished. Each player still has only one Charity claim; changing order does not grant another claim or reopen completed turns.`
: revival
? `Take the ${option.mode} remaining Revival turn. Completed Revival turns stay finished. This changes the remaining order without granting another revival, resetting allowances or returning completed players to the queue.`
: `Take the ${option.mode} remaining combined shipment and movement turn. Completed turns stay completed${option.mode === 'last' ? ', and the Guild cannot move behind you' : game.advanced && game.players.some(player => player.faction === 'guild' && player.id !== game.me) ? '. Provisional first priority: the Guild retains its next legal timing choice and may take its turn before yours; a Karama-locked privilege stays locked' : ''}.`}
</p>
<SaphoOrderButton card={card.id} option={option} act={act} busy={busy} />
</div>
);
})
) : (
<p className="notice" id={`${id}-reason`}>
{unavailableReason(game)}
</p>
)}
<p className="text-sm">
Late aggressor timing in new battles uses a provisional shared final opportunity. Older battles retain their pre-plan timing. Other phase modes are still unfinished.
</p>
</div>
</details>
);
}