Implement five CHOAM Nexus Cunning effects with private costs and recovery

This commit is contained in:
Quintine committed 2026-09-10 12:16:39 +10:00
1 parent a6d9235099
commit 290259bac2
24 files changed
+1935 -131

No files matched your search

+20 -13
View File
@@ -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
View File
@@ -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' && (
+70
View File
@@ -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
View File
@@ -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>
+31 -27
View File
@@ -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"
+2 -2
View File
@@ -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">