Implement five CHOAM Nexus Cunning effects with private costs and recovery
This commit is contained in:
1 parent
a6d9235099
commit
290259bac2
24 files changed
+1935
-131
No files matched your search
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { ChoamPowerCost, useChoamPower } from './choam-power-cost';
|
||||
import { choamPowerAction } from '@/game/choam-power-options';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HelpTip } from './help-tip';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
@@ -14,12 +16,12 @@ export function ChoamBaliset({
|
||||
act: (a: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const power = useChoamPower(g, 'baliset');
|
||||
const [target, setTarget] = useState('');
|
||||
const [territory, setTerritory] = useState('');
|
||||
if (!g.choamWorthless || g.phase !== 5) return null;
|
||||
const d = g.decision?.kind === 'choamMovement' ? g.decision : null;
|
||||
const card = g.choamWorthless.cards.find((c) => c.name === 'Baliset');
|
||||
if (!card && !d) return null;
|
||||
if (!power.play && !d) return null;
|
||||
const players = g.players.filter((p) => p.faction !== 'choam');
|
||||
const selectedPlayer =
|
||||
d?.mover ?? players.find((p) => p.id === target)?.id ?? players[0]?.id;
|
||||
@@ -50,7 +52,7 @@ export function ChoamBaliset({
|
||||
forces have moved yet.
|
||||
</p>
|
||||
) : (
|
||||
card && (
|
||||
power.play && (
|
||||
<>
|
||||
<label>
|
||||
Player{' '}
|
||||
@@ -81,23 +83,28 @@ export function ChoamBaliset({
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{card && (
|
||||
<ChoamPowerCost {...power} busy={busy} />
|
||||
{power.play && (
|
||||
<Button
|
||||
disabled={busy || !selectedPlayer || !selectedTerritory}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: card.id,
|
||||
disabled={
|
||||
busy ||
|
||||
!selectedPlayer ||
|
||||
!selectedTerritory ||
|
||||
!power.play ||
|
||||
!choamPowerAction(g, power.play)
|
||||
}
|
||||
onClick={() => {
|
||||
const action = choamPowerAction(g, power.play!, {
|
||||
target: selectedPlayer,
|
||||
territory: selectedTerritory,
|
||||
})
|
||||
}
|
||||
});
|
||||
if (action) act(action);
|
||||
}}
|
||||
>
|
||||
Prevent movement
|
||||
</Button>
|
||||
)}
|
||||
{!card && d && <p className="fine">No available Baliset play.</p>}
|
||||
{!power.play && d && <p className="fine">No available Baliset play.</p>}
|
||||
{d && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
+14
-11
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { ChoamPowerCost, useChoamPower } from './choam-power-cost';
|
||||
import { choamPowerAction } from '@/game/choam-power-options';
|
||||
import { HelpTip } from './help-tip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
@@ -13,9 +15,9 @@ export function ChoamGamont({
|
||||
act: (a: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const power = useChoamPower(g, 'gamont');
|
||||
const [selection, setSelection] = useState('');
|
||||
if (!g.choamWorthless || g.phase !== 8) return null;
|
||||
const card = g.choamWorthless.cards.find((c) => c.name === 'Trip to Gamont');
|
||||
const options = g.choamWorthless.gamont;
|
||||
const key = (o: (typeof options)[number]) =>
|
||||
`${o.target}/${o.key}/${o.elite}`;
|
||||
@@ -31,7 +33,8 @@ export function ChoamGamont({
|
||||
opportunity. A No-Field in the selected sector must reveal first; the
|
||||
card is used even if no force is there to return.
|
||||
</p>
|
||||
{card && options.length > 0 && (
|
||||
<ChoamPowerCost {...power} busy={busy} />
|
||||
{power.play && options.length > 0 && (
|
||||
<>
|
||||
<label>
|
||||
Force to return{' '}
|
||||
@@ -65,23 +68,23 @@ export function ChoamGamont({
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
disabled={busy || !chosen}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: card.id,
|
||||
disabled={
|
||||
busy || !chosen || !power.play || !choamPowerAction(g, power.play)
|
||||
}
|
||||
onClick={() => {
|
||||
const action = choamPowerAction(g, power.play!, {
|
||||
target: chosen.target,
|
||||
from: chosen.key,
|
||||
elite: chosen.elite,
|
||||
})
|
||||
}
|
||||
});
|
||||
if (action) act(action);
|
||||
}}
|
||||
>
|
||||
Return this force
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(!card || !options.length) && (
|
||||
{(!power.play || !options.length) && (
|
||||
<p className="fine">No available Trip to Gamont play.</p>
|
||||
)}
|
||||
{g.decision?.kind === 'choamMentat' && (
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import {
|
||||
choamPowerKey,
|
||||
choamPowerPlays,
|
||||
CHOAM_POWER_NAMES,
|
||||
type ChoamPowerEffect,
|
||||
type ChoamPowerPlay,
|
||||
} from '@/game/choam-power-options';
|
||||
import { CardInspector } from './card-inspector';
|
||||
|
||||
export function useChoamPower(game: GameView, effect: ChoamPowerEffect) {
|
||||
const [selected, select] = useState('');
|
||||
const plays = choamPowerPlays(game, effect);
|
||||
const play =
|
||||
plays.find((candidate) => choamPowerKey(candidate) === selected) ??
|
||||
plays.find((candidate) => !candidate.blocked) ??
|
||||
plays[0];
|
||||
return { plays, play, select };
|
||||
}
|
||||
|
||||
export function ChoamPowerCost({
|
||||
plays,
|
||||
play,
|
||||
select,
|
||||
busy,
|
||||
}: {
|
||||
plays: ChoamPowerPlay[];
|
||||
play?: ChoamPowerPlay;
|
||||
select: (key: string) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
if (!play) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{plays.length > 1 && (
|
||||
<label>
|
||||
Card to use for {CHOAM_POWER_NAMES[play.effect]}
|
||||
<select
|
||||
value={choamPowerKey(play)}
|
||||
disabled={busy}
|
||||
onChange={(event) => select(event.target.value)}
|
||||
>
|
||||
{plays.map((candidate) => (
|
||||
<option
|
||||
key={choamPowerKey(candidate)}
|
||||
value={choamPowerKey(candidate)}
|
||||
>
|
||||
{candidate.card.name} ·{' '}
|
||||
{candidate.source === 'nexus'
|
||||
? 'CHOAM Nexus Cunning'
|
||||
: 'printed power'}
|
||||
{candidate.blocked ? ' · unavailable' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<p>
|
||||
Use <strong>{play.card.name}</strong> for{' '}
|
||||
{CHOAM_POWER_NAMES[play.effect]}.
|
||||
{play.source === 'nexus' &&
|
||||
' This spends CHOAM Nexus Cunning once. Karama can prevent the effect and leave this Treachery Card in hand; the Nexus stays spent.'}
|
||||
</p>
|
||||
<CardInspector card={play.card} />
|
||||
{play.blocked && <p className="fine">{play.blocked}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+13
-10
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { ChoamPowerCost, useChoamPower } from './choam-power-cost';
|
||||
import { choamPowerAction } from '@/game/choam-power-options';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { HelpTip } from './help-tip';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
@@ -14,10 +16,10 @@ export function ChoamStorm({
|
||||
act: (a: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const power = useChoamPower(g, 'jubba');
|
||||
const [selected, setSelected] = useState('');
|
||||
const decision = g.decision?.kind === 'choamStorm' ? g.decision : null;
|
||||
if (!decision || decision.player !== g.me) return null;
|
||||
const card = g.choamWorthless?.cards.find((c) => c.name === 'Jubba Cloak');
|
||||
const choice =
|
||||
decision.territories.find((t) => t.territory === selected) ??
|
||||
decision.territories[0];
|
||||
@@ -51,17 +53,18 @@ export function ChoamStorm({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{card ? (
|
||||
<ChoamPowerCost {...power} busy={busy} />
|
||||
{power.play ? (
|
||||
<Button
|
||||
disabled={busy || !choice}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: card.id,
|
||||
territory: choice.territory,
|
||||
})
|
||||
disabled={
|
||||
busy || !choice || !power.play || !choamPowerAction(g, power.play)
|
||||
}
|
||||
onClick={() => {
|
||||
const action = choamPowerAction(g, power.play!, {
|
||||
territory: choice.territory,
|
||||
});
|
||||
if (action) act(action);
|
||||
}}
|
||||
>
|
||||
Protect this territory
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { ChoamPowerCost, useChoamPower } from './choam-power-cost';
|
||||
import { choamPowerAction, choamPowerPlays } from '@/game/choam-power-options';
|
||||
import { HelpTip } from './help-tip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
@@ -13,7 +15,7 @@ export function ChoamWorthless({
|
||||
busy: boolean;
|
||||
}) {
|
||||
const [target, setTarget] = useState('');
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const power = useChoamPower(g, g.phase === 4 ? 'laLaLa' : 'kulon');
|
||||
const reactive = g.decision?.kind === 'choamFreeRevival';
|
||||
if (!g.choamWorthless || ![4, 5].includes(g.phase)) return null;
|
||||
const selected = g.choamWorthless.targets.includes(target)
|
||||
@@ -53,32 +55,34 @@ export function ChoamWorthless({
|
||||
force revival.
|
||||
</p>
|
||||
)}
|
||||
{g.choamWorthless.cards
|
||||
.filter((c) =>
|
||||
g.phase === 4 ? c.name === 'La La La' : c.name === 'Kulon',
|
||||
)
|
||||
.map((c) => (
|
||||
<Button
|
||||
key={c.id}
|
||||
disabled={
|
||||
busy ||
|
||||
(g.phase === 5 &&
|
||||
(g.active !== me.id ||
|
||||
(me.moved ?? 0) >= (me.movesAllowed ?? 1))) ||
|
||||
(g.phase === 4 && !recipient)
|
||||
}
|
||||
onClick={() =>
|
||||
act({
|
||||
type: 'card',
|
||||
mode: 'choam',
|
||||
card: c.id,
|
||||
target: recipient,
|
||||
})
|
||||
}
|
||||
>
|
||||
Use {c.name}
|
||||
</Button>
|
||||
))}
|
||||
<ChoamPowerCost {...power} busy={busy} />
|
||||
{power.play && (
|
||||
<Button
|
||||
disabled={
|
||||
busy ||
|
||||
(g.phase === 4 && !recipient) ||
|
||||
!choamPowerAction(g, power.play)
|
||||
}
|
||||
onClick={() => {
|
||||
const action = choamPowerAction(
|
||||
g,
|
||||
power.play!,
|
||||
g.phase === 4 ? { target: recipient } : {},
|
||||
);
|
||||
if (action) act(action);
|
||||
}}
|
||||
>
|
||||
Use {g.phase === 4 ? 'La La La' : 'Kulon'}
|
||||
</Button>
|
||||
)}
|
||||
{choamPowerPlays(g, 'kull').some(
|
||||
(play) => play.source === 'nexus' && play.effect === 'kull',
|
||||
) && (
|
||||
<p className="fine">
|
||||
Kull Wahad’s Karama prevention effect is not implemented. CHOAM
|
||||
Cunning cannot use it yet.
|
||||
</p>
|
||||
)}
|
||||
{reactive && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -56,7 +56,7 @@ export function NexusCardFace({ card, mode, enlarged = false }: {
|
||||
<DialogTitle>{identity.name} Nexus card</DialogTitle>
|
||||
<DialogClose render={<Button variant="outline" className="min-h-11 min-w-16" />}>Close</DialogClose>
|
||||
</div>
|
||||
<DialogDescription className="sr-only">All three Nexus effects and their faction requirements. Atreides actions appear when available; other card effects are not playable yet.</DialogDescription>
|
||||
<DialogDescription className="sr-only">All three Nexus effects and their faction requirements. Available actions appear at their required timing; unfinished effects remain unavailable.</DialogDescription>
|
||||
<div className="min-h-0 overflow-y-auto overscroll-contain p-3 sm:p-5"><NexusCardFace card={card} mode={mode} enlarged /></div>
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
@@ -118,7 +118,7 @@ export function NexusCards({ game, act, busy }: { game: GameView; act: (action:
|
||||
<div>
|
||||
<h3>Nexus cards</h3>
|
||||
<p className="fine">{offer.deckCount} in deck · {offer.discardCount} discarded · {Object.values(offer.held).filter((held) => held === true).length} held</p>
|
||||
<p>Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit and Emperor Cunning. Use the controls shown at each effect’s timing; other effects remain unavailable.</p>
|
||||
<p>Playable effects include Atreides inspections, Harkonnen exchanges, and Tleilaxu, Ixian, Bene Gesserit, Emperor and CHOAM Cunning. Use the controls shown at each effect’s timing. CHOAM’s Kull Wahad effect and other unfinished effects remain unavailable.</p>
|
||||
</div>
|
||||
{game.nexusAtreides && offer.card === 'atreides' && (
|
||||
<div aria-label="Atreides Nexus effect" className="space-y-2">
|
||||
|
||||
Reference in new issue
Block a user