Connect occupied Homeworld economics and Bidding
This commit is contained in:
1 parent
a6e80c7e23
commit
0774ae6f83
32 files changed
+3220
-111
No files matched your search
@@ -25,6 +25,8 @@ import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
|
||||
import { CaladanReinforcement } from './caladan-reinforcement';
|
||||
import { GrummanCollection } from './grumman-collection';
|
||||
import { HomeworldOccupiedIncome } from './homeworld-occupied-income';
|
||||
import { HomeworldOccupiedPercentage } from './homeworld-occupied-percentage';
|
||||
import { HomeworldOccupiedBonus } from './homeworld-occupied-bonus';
|
||||
import { TupileIntelligence } from './tupile-intelligence';
|
||||
import { NexusCards } from './nexus-cards';
|
||||
import { NexusTraitors } from './nexus-traitors';
|
||||
@@ -1098,14 +1100,25 @@ export function GameTable({
|
||||
{g.advanced && g.status !== 'lobby' && <AdvancedPreviewNotice compact />}
|
||||
{g.homeworldOccupationPreview && (
|
||||
<output className="notice block">
|
||||
Homeworld occupation development preview · continuous, uncontested
|
||||
qualification only. Printed bank Collection, immediate ally sharing
|
||||
and occupied native defenses are connected. Departure, replacement,
|
||||
contested/repopulated histories, percentage income and other unfinished
|
||||
occupied powers remain guarded. Not complete or certified Homeworld rules.{' '}
|
||||
Homeworld occupation development preview · printed bank Collection,
|
||||
percentage income, original Bidding effects, immediate ally sharing
|
||||
and occupied native defenses are connected. Advanced occupation benefits
|
||||
are retained until the occupier’s last force leaves; Basic lifecycle and
|
||||
unresolved competing-owner cases remain guarded. Not complete or certified Homeworld rules.{' '}
|
||||
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
|
||||
</output>
|
||||
)}
|
||||
{g.homeworldOccupiedPercentage?.blocked && (g.decision?.kind !== 'homeworldOccupiedPercentage' || g.decision.player !== g.me) && (
|
||||
<HomeworldOccupiedPercentage game={g} act={act} busy={true} />
|
||||
)}
|
||||
{g.homeworldOccupiedBonus?.blocked && (g.decision?.kind !== 'homeworldOccupiedBonus' || g.decision.player !== g.me) && (
|
||||
<HomeworldOccupiedBonus game={g} act={act} busy={true} />
|
||||
)}
|
||||
{g.richeseBidding?.stage === 'cacheOffer' && g.richeseBidding.offerBlocked && !g.decision && (
|
||||
<output className="notice block" aria-label="Blocked Richese cache choice">
|
||||
{g.richeseBidding.offerBlocked}
|
||||
</output>
|
||||
)}
|
||||
{g.nexusKullPreview ? (
|
||||
<p className="notice" role="status">
|
||||
CHOAM Nexus Cunning · Kull Wahad development preview. Any eligible
|
||||
@@ -2469,6 +2482,10 @@ export function GameTable({
|
||||
? 'Grumman Collection'
|
||||
: g.decision.kind === 'homeworldOccupiedIncome'
|
||||
? 'Occupied Homeworld bank income'
|
||||
: g.decision.kind === 'homeworldOccupiedPercentage'
|
||||
? 'Occupied Homeworld percentage income'
|
||||
: g.decision.kind === 'homeworldOccupiedBonus'
|
||||
? 'Occupied Giedi Prime bonus card'
|
||||
: g.decision.kind === 'leaderSkillVisibility'
|
||||
? 'Position your skilled leader'
|
||||
: g.decision.kind === 'mentatQuestion'
|
||||
@@ -2503,6 +2520,7 @@ export function GameTable({
|
||||
'richeseBlackMarket',
|
||||
'richeseDeclaration',
|
||||
'richeseCache',
|
||||
'richeseCacheTerms',
|
||||
'richeseUnbid',
|
||||
].includes(g.decision.kind)
|
||||
? 'Richese auction decision'
|
||||
@@ -2737,6 +2755,7 @@ export function GameTable({
|
||||
) : g.decision.kind === 'richeseBlackMarket' ||
|
||||
g.decision.kind === 'richeseDeclaration' ||
|
||||
g.decision.kind === 'richeseCache' ||
|
||||
g.decision.kind === 'richeseCacheTerms' ||
|
||||
g.decision.kind === 'richeseUnbid' ? (
|
||||
<RicheseAuctionDecision
|
||||
key={`${g.richeseBidding?.event}-${g.decision.kind}-${g.me}`}
|
||||
@@ -2796,6 +2815,10 @@ export function GameTable({
|
||||
<GrummanCollection game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'homeworldOccupiedIncome' ? (
|
||||
<HomeworldOccupiedIncome offer={g.homeworldOccupiedIncome} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'homeworldOccupiedPercentage' ? (
|
||||
<HomeworldOccupiedPercentage game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'homeworldOccupiedBonus' ? (
|
||||
<HomeworldOccupiedBonus game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'ecazSpice' ? (
|
||||
g.ecazSpice?.allocation ? (
|
||||
<EcazSpice
|
||||
@@ -3872,6 +3895,9 @@ export function GameTable({
|
||||
</span>
|
||||
<h3>{g.auction.card?.name ?? 'Unknown treachery'}</h3>
|
||||
{g.auction.card && <CardInspector card={g.auction.card} />}
|
||||
{g.auction.inspectionBlocked && (
|
||||
<output className="notice block">{g.auction.inspectionBlocked}</output>
|
||||
)}
|
||||
{!!g.ixTechnology?.known.length && (
|
||||
<details>
|
||||
<summary>Your inspected pool · order unknown</summary>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { FACTIONS } from '@/game/catalog';
|
||||
import { homeworldCard } from '@/game/homeworld-cards';
|
||||
import { HomeworldFace } from './homeworld-cards';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
|
||||
type Offer = NonNullable<GameView['homeworldOccupiedBonus']>;
|
||||
|
||||
export function HomeworldOccupiedBonus({ game, act, busy }: Props) {
|
||||
const offer = game.homeworldOccupiedBonus;
|
||||
if (!offer) return null;
|
||||
const authorized = game.decision?.kind === 'homeworldOccupiedBonus' &&
|
||||
game.decision.player === game.me && game.decision.event === offer.event && offer.owner === game.me;
|
||||
if (!authorized && !offer.blocked) return null;
|
||||
return <BonusChoice key={offer.event} offer={offer} game={game} act={act} busy={busy || !authorized} />;
|
||||
}
|
||||
|
||||
function BonusChoice({ offer, act, busy }: Props & { offer: Offer }) {
|
||||
const [recipient, setRecipient] = useState(offer.owner);
|
||||
const selected = offer.recipients.find((player) => player.player === recipient) ?? offer.recipients[0];
|
||||
return (
|
||||
<section aria-label="Occupied Giedi Prime bonus card" className="space-y-3">
|
||||
<h3>Giedi Prime · occupied purchase bonus</h3>
|
||||
<p>
|
||||
{offer.ownerName} chooses who receives one real treachery card from the original purchase bonus.
|
||||
Only the server-listed eligible recipients can receive it. The card stays private to its recipient.
|
||||
</p>
|
||||
<p className="fine">
|
||||
This printed Homeworld effect is separate from the ordinary cancelable Harkonnen advantage and cannot be canceled by Karama.
|
||||
This is one card, not one card per recipient.
|
||||
</p>
|
||||
<p className="notice">
|
||||
Development preview · Advanced occupation benefits are retained until your last occupying force leaves. Basic lifecycle and unresolved competing-owner cases remain guarded. Not complete or certified Homeworld rules.
|
||||
</p>
|
||||
<details className="rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<summary className="cursor-pointer py-2">Inspect Giedi Prime source card</summary>
|
||||
<HomeworldFace card={homeworldCard('giedi_prime')!} />
|
||||
</details>
|
||||
{offer.blocked ? <output className="notice block">{offer.blocked}</output> : !selected ? (
|
||||
<p className="notice">No eligible recipient is available.</p>
|
||||
) : (
|
||||
<form className="space-y-3" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!busy) act({ type: 'decision', event: offer.event, recipient: selected.player });
|
||||
}}>
|
||||
<label className="block space-y-1">
|
||||
<span>Bonus card recipient</span>
|
||||
<select aria-label="Occupied bonus card recipient" className="min-h-11 max-w-full"
|
||||
value={selected.player} disabled={busy} onChange={(event) => setRecipient(event.target.value)}>
|
||||
{offer.recipients.map((player) => (
|
||||
<option key={player.player} value={player.player}>
|
||||
{player.name} · {FACTIONS.find((faction) => faction.id === player.faction)?.name ?? player.faction}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" className="game-action" disabled={busy}>Give one bonus card to {selected.name}</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { homeworldCard } from '@/game/homeworld-cards';
|
||||
import { HomeworldFace } from './homeworld-cards';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
|
||||
type Offer = NonNullable<GameView['homeworldOccupiedPercentage']>;
|
||||
|
||||
export function HomeworldOccupiedPercentage({ game, act, busy }: Props) {
|
||||
const offer = game.homeworldOccupiedPercentage;
|
||||
if (!offer) return null;
|
||||
const authorized = game.decision?.kind === 'homeworldOccupiedPercentage' &&
|
||||
game.decision.player === game.me && game.decision.event === offer.event && offer.owner === game.me;
|
||||
if (!authorized && !offer.blocked) return null;
|
||||
return <PercentageChoice key={offer.event} offer={offer} game={game} act={act} busy={busy || !authorized} />;
|
||||
}
|
||||
|
||||
function PercentageChoice({ offer, game, act, busy }: Props & { offer: Offer }) {
|
||||
const [ownAmount, setOwnAmount] = useState(offer.maxOwnAmount);
|
||||
const valid = Number.isSafeInteger(ownAmount) && ownAmount >= offer.minOwnAmount && ownAmount <= offer.maxOwnAmount;
|
||||
const source = game.homeworlds?.worlds?.find((world) => world.id === offer.world);
|
||||
const card = source ? homeworldCard(source.card) : undefined;
|
||||
return (
|
||||
<section aria-label="Occupied Homeworld percentage income" className="space-y-3">
|
||||
<h3>{offer.name} · occupied percentage income</h3>
|
||||
<p>
|
||||
Allocate {offer.amount} spice from the actual completed payment or Collection income.
|
||||
{offer.allyName ? ` Keep it or immediately share any portion with ${offer.allyName}.` : ' You have no reciprocal ally to share with.'}
|
||||
</p>
|
||||
<p className="fine">
|
||||
This is the actual occupied portion, not a printed Bank icon award or a bribe from your personal spice.
|
||||
The printed Homeworld effect cannot be canceled by Karama; ordinary native advantages remain separate.
|
||||
</p>
|
||||
<p className="notice">
|
||||
Development preview · Advanced occupation benefits are retained until your last occupying force leaves. Basic lifecycle and unresolved competing-owner cases remain guarded. Not complete or certified Homeworld rules.
|
||||
</p>
|
||||
{card && (
|
||||
<details className="rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<summary className="cursor-pointer py-2">Inspect {offer.name} source card</summary>
|
||||
<HomeworldFace card={card} />
|
||||
</details>
|
||||
)}
|
||||
{offer.blocked ? <output className="notice block">{offer.blocked}</output> : (
|
||||
<form className="space-y-3" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!busy && valid) act({ type: 'decision', event: offer.event, ownAmount });
|
||||
}}>
|
||||
{offer.ally && (
|
||||
<>
|
||||
<label className="block space-y-1">
|
||||
<span>Spice to keep · {offer.minOwnAmount}–{offer.maxOwnAmount}</span>
|
||||
<Input aria-label="Occupied percentage spice to keep" type="number"
|
||||
min={offer.minOwnAmount} max={offer.maxOwnAmount} step={1}
|
||||
value={Number.isNaN(ownAmount) ? '' : ownAmount} disabled={busy}
|
||||
onChange={(event) => setOwnAmount(event.target.value === '' ? Number.NaN : Number(event.target.value))} />
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" disabled={busy || offer.amount < offer.minOwnAmount || offer.amount > offer.maxOwnAmount}
|
||||
onClick={() => setOwnAmount(offer.amount)}>Keep all</Button>
|
||||
<Button type="button" variant="outline" disabled={busy || offer.minOwnAmount > 0}
|
||||
onClick={() => setOwnAmount(0)}>Give all to {offer.allyName}</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<output className="notice block" aria-live="polite">
|
||||
{valid
|
||||
? `Credits: ${offer.ownerName} ${ownAmount}${offer.allyName ? ` · ${offer.allyName} ${offer.amount - ownAmount}` : ''}. Total ${offer.amount}.`
|
||||
: `Keep a whole number from ${offer.minOwnAmount} through ${offer.maxOwnAmount}.`}
|
||||
</output>
|
||||
<Button type="submit" className="game-action" disabled={busy || !valid}>Settle {offer.name} percentage income</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,12 @@ export function IxTechnology({
|
||||
const kind = g.decision?.kind;
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const [position, setPosition] = useState('bottom');
|
||||
if (
|
||||
g.decision?.player !== g.me ||
|
||||
!['ixSetup', 'ixAuction', 'ixTechnology', 'ixAllyCard'].includes(kind ?? '')
|
||||
) return null;
|
||||
if (kind === 'ixAuction' && g.ixTechnology?.blocked)
|
||||
return <output className="notice block">{g.ixTechnology.blocked}</output>;
|
||||
const cards =
|
||||
kind === 'ixSetup'
|
||||
? g.ixTechnology?.setup
|
||||
@@ -57,7 +63,7 @@ export function IxTechnology({
|
||||
{kind === 'ixSetup'
|
||||
? 'Choose your starting card. The remaining cards are shuffled and dealt privately, one to each other faction; Harkonnen then draws its extra card.'
|
||||
: kind === 'ixAuction'
|
||||
? 'Privately choose one card to return to the deck. The remaining pool will be shuffled for auction.'
|
||||
? 'Privately choose one card from the original Ixian pool to return to the deck. The remaining pool will be shuffled for auction. This choice does not transfer unrelated Ixian Technology.'
|
||||
: 'Exchange one card from your hand for the upcoming card, before Atreides looks. You commit your offered card before learning what you receive. This advantage can be attempted once this round.'}{' '}
|
||||
<HelpTip topic="ixTechnology" />
|
||||
</p>
|
||||
|
||||
@@ -32,11 +32,12 @@ export function RicheseAuctionDecision({ game, act, busy }: Props) {
|
||||
!bidding ||
|
||||
!me ||
|
||||
decision?.player !== game.me ||
|
||||
bidding.owner !== game.me ||
|
||||
(decision.kind !== 'richeseCache' && bidding.owner !== game.me) ||
|
||||
![
|
||||
'richeseBlackMarket',
|
||||
'richeseDeclaration',
|
||||
'richeseCache',
|
||||
'richeseCacheTerms',
|
||||
'richeseUnbid',
|
||||
].includes(decision.kind)
|
||||
)
|
||||
@@ -44,6 +45,40 @@ export function RicheseAuctionDecision({ game, act, busy }: Props) {
|
||||
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
|
||||
@@ -111,10 +146,14 @@ export function RicheseAuctionDecision({ game, act, busy }: Props) {
|
||||
);
|
||||
}
|
||||
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 =
|
||||
!blackMarket && method === 'normal' ? 'onceAround' : method;
|
||||
cardOnly ? bidding.cacheTerms!.method :
|
||||
!blackMarket && method === 'normal' ? 'onceAround' : method;
|
||||
return (
|
||||
<section
|
||||
className="flex min-w-0 flex-col gap-4"
|
||||
@@ -123,8 +162,15 @@ export function RicheseAuctionDecision({ game, act, busy }: Props) {
|
||||
<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 your separate cache. Its face is revealed to everyone when offered. Cache cards do not count toward your hand limit.'}
|
||||
: '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}
|
||||
@@ -152,6 +198,8 @@ export function RicheseAuctionDecision({ game, act, busy }: Props) {
|
||||
<CardInspector card={selected} />
|
||||
</>
|
||||
)}
|
||||
{!cardOnly && (
|
||||
<>
|
||||
<label htmlFor={`${id}-method`}>Auction method</label>
|
||||
<select
|
||||
id={`${id}-method`}
|
||||
@@ -182,6 +230,8 @@ export function RicheseAuctionDecision({ game, act, busy }: Props) {
|
||||
<option value="counterclockwise">Counterclockwise</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{blackMarket && (
|
||||
<>
|
||||
@@ -213,8 +263,7 @@ export function RicheseAuctionDecision({ game, act, busy }: Props) {
|
||||
onClick={() =>
|
||||
send({
|
||||
card: selected!.id,
|
||||
method: chosenMethod,
|
||||
direction,
|
||||
...(!cardOnly ? { method: chosenMethod, direction } : {}),
|
||||
...(blackMarket && claim.trim() ? { claim } : {}),
|
||||
})
|
||||
}
|
||||
@@ -312,6 +361,9 @@ export function RicheseAuctionLot({ game, act, busy }: Props) {
|
||||
) : (
|
||||
<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>
|
||||
|
||||
Reference in new issue
Block a user