Files
Dune/components/richese-auctions.tsx
T

478 lines
17 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 { integerInputValue } from '../lib/integer-input';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import type { RicheseAuctionMethod } from '@/game/richese-auction';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { CardInspector, CardRules } from './card-inspector';
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
const buttonClass = 'min-h-11 whitespace-normal motion-reduce:transition-none';
const methodName = {
normal: 'Normal auction',
onceAround: 'Once Around',
silent: 'Silent auction',
};
/** Only supplied owner-projected hand/cache identities are selectable. */
export function RicheseAuctionDecision({ game, act, busy }: Props) {
const id = useId();
const [cardId, setCardId] = useState('');
const [method, setMethod] = useState<RicheseAuctionMethod>('onceAround');
const [direction, setDirection] = useState<'clockwise' | 'counterclockwise'>(
'clockwise',
);
const [claim, setClaim] = useState('');
const bidding = game.richeseBidding;
const decision = game.decision;
const me = game.players.find((player) => player.id === game.me);
if (
!bidding ||
!me ||
decision?.player !== game.me ||
(decision.kind !== 'richeseCache' && bidding.owner !== game.me) ||
![
'richeseBlackMarket',
'richeseDeclaration',
'richeseCache',
'richeseCacheTerms',
'richeseUnbid',
].includes(decision.kind)
)
return null;
const send = (fields: Omit<Action, 'type'>) => {
if (!busy) act({ type: 'decision', event: bidding.event, ...fields });
};
if (decision.kind === 'richeseCacheTerms') {
const cacheMethod = method === 'normal' ? 'onceAround' : method;
return (
<section className="flex min-w-0 flex-col gap-4" aria-label="Richese native cache auction terms">
<p>
You remain the Richese seller. Choose the auction method and direction;
the occupied Homeworld controller then chooses only the physical cache card.
</p>
{bidding.offerBlocked && <p className="notice">{bidding.offerBlocked}</p>}
<label htmlFor={`${id}-method`}>Auction method</label>
<select id={`${id}-method`} value={cacheMethod} disabled={busy || !!bidding.offerBlocked}
className="min-h-11 max-w-full"
onChange={(event) => setMethod(event.target.value as RicheseAuctionMethod)}>
<option value="onceAround">Once Around</option>
<option value="silent">Silent auction</option>
</select>
{cacheMethod === 'onceAround' && (
<>
<label htmlFor={`${id}-direction`}>Bidding direction</label>
<select id={`${id}-direction`} value={direction} disabled={busy || !!bidding.offerBlocked}
className="min-h-11"
onChange={(event) => setDirection(event.target.value as typeof direction)}>
<option value="clockwise">Clockwise</option>
<option value="counterclockwise">Counterclockwise</option>
</select>
</>
)}
<Button className={buttonClass} disabled={busy || !!bidding.offerBlocked}
onClick={() => send({ method: cacheMethod, direction })}>
Set native cache auction terms
</Button>
</section>
);
}
if (decision.kind === 'richeseDeclaration')
return (
<section
className="flex flex-col gap-4"
aria-label="Richese auction position"
>
<p>
Choose whether your face-up cache card is auctioned before or after
the ordinary cards. This declaration precedes Ixian auction
inspection.
</p>
<Button
className={buttonClass}
disabled={busy}
onClick={() => send({ position: 'first' })}
>
Auction cache card first
</Button>
<Button
className={buttonClass}
variant="outline"
disabled={busy}
onClick={() => send({ position: 'last' })}
>
Auction cache card last
</Button>
</section>
);
if (decision.kind === 'richeseUnbid') {
const room = (me.hand?.length ?? 0) < me.handLimit;
return (
<section
className="flex flex-col gap-4"
aria-label="Unsold Richese cache card"
>
<p>
No player offered spice. Keep the cache card for free if your hand has
room, or remove it from this game.
</p>
{game.richeseAuction?.card && (
<>
<CardRules card={game.richeseAuction.card} />
<CardInspector card={game.richeseAuction.card} />
</>
)}
{!room && (
<p className="fine">Your hand is full; the card cannot be added.</p>
)}
<Button
className={buttonClass}
disabled={busy || !room}
onClick={() => send({ keep: true })}
>
Keep card for free
</Button>
<Button
className={buttonClass}
variant="outline"
disabled={busy}
onClick={() => send({ keep: false })}
>
Remove card from game
</Button>
</section>
);
}
const blackMarket = decision.kind === 'richeseBlackMarket';
const cardOnly = decision.kind === 'richeseCache' && bidding.owner !== game.me;
if (cardOnly && !bidding.cacheTerms)
return <output className="notice block">{bidding.offerBlocked ?? 'Waiting for the native Richese seller to set cache auction terms.'}</output>;
const cards = blackMarket ? (me.hand ?? []) : (bidding.cache ?? []);
const selected = cards.find((card) => card.id === cardId) ?? cards[0];
const chosenMethod =
cardOnly ? bidding.cacheTerms!.method :
!blackMarket && method === 'normal' ? 'onceAround' : method;
return (
<section
className="flex min-w-0 flex-col gap-4"
aria-label={blackMarket ? 'Black Market offer' : 'Richese cache offer'}
>
<p className="m-0 text-sm leading-6">
{blackMarket
? 'Offer a card from your hand without showing its face. Atreides may inspect it. An optional public claim may be truthful or a bluff. If no one pays spice, you keep the card.'
: 'Choose one card from the original Richese cache. Its face is revealed to everyone when offered. Cache cards do not count toward a hand limit; Richese remains the seller.'}
</p>
{cardOnly && bidding.cacheTerms && (
<p className="fine">
Native Richese terms: {methodName[bidding.cacheTerms.method]}
{bidding.cacheTerms.method === 'onceAround' ? ` · ${bidding.cacheTerms.direction}` : ''}.
You choose the cache card only; the seller’s terms do not transfer.
</p>
)}
{bidding.offerBlocked && (
<p id={`${id}-offer-blocked`} className="notice">
{bidding.offerBlocked}
</p>
)}
{cards.length ? (
<>
<label htmlFor={`${id}-card`}>Card to auction</label>
<select
id={`${id}-card`}
value={selected?.id ?? ''}
disabled={busy}
className="min-h-11 max-w-full"
onChange={(event) => setCardId(event.target.value)}
>
{cards.map((card) => (
<option key={card.id} value={card.id}>
{card.name}
</option>
))}
</select>
{selected && (
<>
<CardRules card={selected} />
<CardInspector card={selected} />
</>
)}
{!cardOnly && (
<>
<label htmlFor={`${id}-method`}>Auction method</label>
<select
id={`${id}-method`}
value={chosenMethod}
disabled={busy}
className="min-h-11 max-w-full"
onChange={(event) =>
setMethod(event.target.value as RicheseAuctionMethod)
}
>
{blackMarket && <option value="normal">Normal auction</option>}
<option value="onceAround">Once Around</option>
<option value="silent">Silent auction</option>
</select>
{chosenMethod === 'onceAround' && (
<>
<label htmlFor={`${id}-direction`}>Bidding direction</label>
<select
id={`${id}-direction`}
value={direction}
disabled={busy}
className="min-h-11"
onChange={(event) =>
setDirection(event.target.value as typeof direction)
}
>
<option value="clockwise">Clockwise</option>
<option value="counterclockwise">Counterclockwise</option>
</select>
</>
)}
</>
)}
{blackMarket && (
<>
<label htmlFor={`${id}-claim`}>Public claim (optional)</label>
<Input
id={`${id}-claim`}
value={claim}
maxLength={300}
disabled={busy}
onChange={(event) => setClaim(event.target.value)}
placeholder="What you say you are selling"
/>
</>
)}
<p className="fine">
Karama cannot acquire this auction’s card.{' '}
{chosenMethod === 'silent'
? 'Every eligible player submits once; zero is allowed. Equal positive bids use storm order.'
: chosenMethod === 'onceAround'
? 'Each eligible player has one bidding opportunity, normally ending with Richese.'
: 'Bidding continues in order until the other bidders pass.'}
</p>
<Button
className={buttonClass}
aria-describedby={
bidding.offerBlocked ? `${id}-offer-blocked` : undefined
}
disabled={busy || !selected || !!bidding.offerBlocked}
onClick={() =>
send({
card: selected!.id,
...(!cardOnly ? { method: chosenMethod, direction } : {}),
...(blackMarket && claim.trim() ? { claim } : {}),
})
}
>
Offer {blackMarket ? 'Black Market' : 'cache'} card
</Button>
</>
) : (
<p>No card is available to offer.</p>
)}
{blackMarket && (
<Button
className={buttonClass}
variant="outline"
disabled={busy}
onClick={() => send({ decline: true })}
>
Skip Black Market
</Button>
)}
</section>
);
}
/** Only projected public bids and this viewer's own sealed submission are rendered. */
export function RicheseAuctionLot({ game, act, busy }: Props) {
const id = useId();
const auction = game.richeseAuction;
const [draft, setDraft] = useState({ event: '', amount: '', ally: '0' });
if (!auction) return null;
const me = game.me;
const silent = auction.method === 'silent';
const minimum = silent ? 0 : auction.bid + 1;
const bidKey = `${auction.event}/${minimum}`;
const amountText = draft.event === bidKey ? draft.amount : String(minimum);
const allyText = draft.event === bidKey ? draft.ally : '0';
const amount = integerInputValue(amountText);
const allyPayment = integerInputValue(allyText);
const update = (part: Partial<typeof draft>) =>
setDraft({
event: bidKey,
amount: amountText,
ally: allyText,
...part,
});
const ownPayment = amount - allyPayment;
const allowedTurn =
!auction.outcome &&
auction.eligible.includes(me) &&
(silent ? !auction.submitted.includes(me) : auction.active === me);
const canBid =
allowedTurn &&
Number.isSafeInteger(amount) &&
amount >= minimum &&
Number.isSafeInteger(allyPayment) &&
allyPayment >= 0 &&
allyPayment <= auction.allyAvailable &&
ownPayment >= 0 &&
ownPayment <= auction.ownAvailable;
const name = (player: string | null) =>
game.players.find((p) => p.id === player)?.name ?? 'Player';
const send = (bid: number | null, ally: number) => {
if (!busy)
act({
type: 'richeseBid',
event: auction.event,
amount: bid,
allyPayment: ally,
});
};
return (
<section
className="flex min-w-0 flex-col gap-4"
aria-label="Richese auction"
>
<h3 className="m-0 font-serif text-xl">
{auction.source === 'cache' ? 'Richese cache' : 'Black Market'} ·{' '}
{methodName[auction.method]}
</h3>
{auction.card ? (
<>
<p className="m-0 font-semibold">{auction.card.name}</p>
{auction.source === 'blackMarket' && (
<p className="fine">
This face is private to you through ownership or authorized
inspection.
</p>
)}
<CardRules card={auction.card} />
<CardInspector card={auction.card} />
</>
) : (
<p>The Black Market card remains face down.</p>
)}
{auction.inspectionBlocked && (
<output className="notice block">{auction.inspectionBlocked}</output>
)}
<p className="fine">
{name(auction.owner)} is selling. Karama cannot acquire this card.
</p>
{auction.source === 'blackMarket' && (
<p className="notice">
Provisional paid self-purchase: if the seller wins a positive bid, they
retain this same card and pay the Emperor, or the Bank if absent. It
counts as one sale and needs no additional hand space. An unbid card
stays free and unsold.
</p>
)}
{auction.claim && (
<p className="notice">Seller’s public claim: {auction.claim}</p>
)}
{silent ? (
<>
<p className="m-0">
{auction.submitted.length} of {auction.eligible.length} bids
submitted.
</p>
{auction.ownBid !== null && (
<p className="notice">
Your sealed bid: {auction.ownBid} spice, including{' '}
{auction.ownAllyPayment} pledged by your ally.
</p>
)}
{auction.revealedBids && (
<ul>
{Object.entries(auction.revealedBids).map(([player, bid]) => (
<li key={player}>
{name(player)}: {bid} spice
</li>
))}
</ul>
)}
</>
) : (
<p className="m-0">
Highest bid: {auction.bid} spice
{auction.bidder ? ` from ${name(auction.bidder)}` : ''}.
</p>
)}
{allowedTurn ? (
<>
<p className="fine">
Available funding: {auction.ownAvailable} own spice and{' '}
{auction.allyAvailable} pledged spice.
</p>
<label htmlFor={`${id}-bid`}>
{silent ? 'Sealed bid' : 'Your bid'} (minimum {minimum})
</label>
<Input
id={`${id}-bid`}
type="number"
min={minimum}
step={1}
max={auction.ownAvailable + auction.allyAvailable}
value={amountText}
disabled={busy}
onChange={(event) => update({ amount: event.target.value })}
/>
{auction.allyAvailable > 0 && (
<>
<label htmlFor={`${id}-ally`}>Pay from ally’s pledge</label>
<Input
id={`${id}-ally`}
type="number"
min={0}
step={1}
max={Number.isFinite(amount) ? Math.min(amount, auction.allyAvailable) : auction.allyAvailable}
value={allyText}
disabled={busy}
onChange={(event) => update({ ally: event.target.value })}
/>
</>
)}
<p className="fine">
{canBid
? `Payment if you win: ${ownPayment} own spice + ${allyPayment} ally spice.`
: 'Choose a valid bid and funding split within the available spice.'}{' '}
{silent
? 'Your submitted amount stays private until all bids are in and cannot be changed.'
: 'Raise the current bid or pass.'}
</p>
<Button
className={buttonClass}
disabled={busy || !canBid}
onClick={() => send(amount, allyPayment)}
>
{silent ? 'Seal bid' : 'Raise bid'}
</Button>
<Button
className={buttonClass}
variant="outline"
disabled={busy}
onClick={() => send(silent ? 0 : null, 0)}
>
{silent ? 'Submit zero' : 'Pass this bid'}
</Button>
</>
) : (
<p className="muted">
{auction.outcome
? 'The auction result is being resolved.'
: silent && auction.submitted.includes(me)
? 'Your bid is sealed. Waiting for the other bidders.'
: !auction.eligible.includes(me)
? 'You are not eligible to bid on this card.'
: `Waiting for ${name(auction.active)} to bid.`}
</p>
)}
</section>
);
}