Integrate exact Ixian Nexus purchased-card replacement

This commit is contained in:
Quintine committed 2026-10-01 17:12:46 +10:00
1 parent c43ab64872
commit a2b04dbfba
26 files changed
+4588 -34

No files matched your search

+23 -3
View File
@@ -81,6 +81,7 @@ import { SemutaReaction } from './semuta-reaction';
import { ChoamKull } from './choam-kull';
import { NexusRicheseBetrayal } from './nexus-richese-betrayal';
import { NexusGuildBetrayal } from './nexus-guild-betrayal';
import { NexusIxianReplacement } from './nexus-ixian-replacement';
import { OrnithopterMovement } from './ornithopter-movement';
import { DiscoveryOrnithopterMovement } from './discovery-flight-movement';
import { PlanetologistMovement } from './planetologist-movement';
@@ -305,8 +306,8 @@ export function GameTable({
);
const reactionBusy =
transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot;
const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction;
const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction;
const busy = reactionBusy || g.automaticContinuationPending || !!g.semutaReaction || !!g.kullReaction || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement;
const reactionOwnsControls = !!g.kullReaction || !!g.kullCounterEvent || !!g.richeseBetrayalReaction || !!g.guildBetrayalReaction || !!g.nexusIxianReplacement;
const canActivateKarama = (card: Card) =>
!g.karamaBlocked && canUseAsKaramaRole(g, me, card);
const traitorBattle = g.battle;
@@ -790,6 +791,13 @@ export function GameTable({
a.event !== guildReaction.event ||
(a.type === 'guildBetrayalUse' && (!guildReaction.canUse || guildReaction.blocked))) return;
}
const replacement = g.nexusIxianReplacement;
if (replacement && a.type !== 'setAutopilot') {
if (reactionBusy || g.status !== 'playing' || !replacement.canPass || replacement.buyer !== me.id ||
(a.type !== 'nexusIxianReplacementPass' && a.type !== 'nexusIxianReplacementUse') ||
a.event !== replacement.event ||
(a.type === 'nexusIxianReplacementUse' && (!replacement.canUse || replacement.blocked))) return;
}
void send(a);
};
const choose = (id: string) => {
@@ -1077,6 +1085,14 @@ export function GameTable({
<a href="/rules?topic=nexus-guild-betrayal#nexus-guild-betrayal">Preview rules and limits</a>
</p>
)}
{g.nexusIxianReplacementPreview && (
<p className="notice" role="status">
Ixian Nexus purchased-card replacement development preview · classic Basic/Advanced,
base Treachery and Nexus only. Normal paid and printed Karama purchases are supported;
Harkonnen buyers and special auctions are guarded. Not complete Ixian or certified Nexus rules.{' '}
<a href="/rules?topic=nexus-ixian-replacement#nexus-ixian-replacement">Preview rules and limits</a>
</p>
)}
{g.status !== 'lobby' && (
<nav className="phase-track" aria-label="Turn phases">
{PHASES.map((p, i) => (
@@ -1721,6 +1737,8 @@ export function GameTable({
<NexusRicheseBetrayal game={g} act={act} busy={reactionBusy} />
) : g.kullReaction ? (
<ChoamKull game={g} act={act} busy={reactionBusy} />
) : g.nexusIxianReplacement ? (
<NexusIxianReplacement game={g} act={act} busy={reactionBusy} />
) : g.nexusCards?.waiting.length ? (
<p className="muted">The next phase begins when the remaining Nexus card choices are finished.</p>
) : g.truthtrance ? (
@@ -2607,6 +2625,8 @@ export function GameTable({
{g.players.find((p) => p.id === g.decision?.player)?.name} to
complete this decision.
</p>
) : g.decision.kind === 'nexusIxianReplacement' ? (
<NexusIxianReplacement game={g} act={act} busy={reactionBusy} />
) : g.decision.kind === 'nullentropy' ? (
<NullentropySearch game={g} act={act} busy={busy} />
) : g.decision.kind === 'richeseAllyOpportunity' ? (
@@ -3484,7 +3504,7 @@ export function GameTable({
accept: false,
})}
</>
) : (
) : g.decision.kind === 'battleCards' && (
<>
<p className="muted">
You may keep or discard each card played in this battle.
+80
View File
@@ -0,0 +1,80 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { nexusCardMode } from '@/game/nexus-cards';
import { CardInspector, CardRules } from './card-inspector';
import { NexusCardFace } from './nexus-cards';
import { Button } from './ui/button';
/** The public boundary is neutral; only the buyer's projection authorizes a choice. */
export function NexusIxianReplacement({ game, act, busy }: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const offer = game.nexusIxianReplacement;
if (!offer) return null;
const buyer = game.players.find((player) => player.id === offer.buyer);
const own = game.players.find((player) => player.id === game.me);
const canRespond = !!offer.event && offer.buyer === game.me && offer.canPass;
const canUse = canRespond && offer.canUse && !offer.blocked && !!offer.purchased;
const priorityBlocked = !!game.truthtrance || !!game.response || !!game.phaseOpening ||
!!game.automaticContinuationPending || !!game.kullReaction || !!game.kullCounterEvent ||
!!game.richeseBetrayalReaction || !!game.guildBetrayalReaction || !!game.semutaReaction ||
!!game.nexusCards?.waiting.length || !!game.nexusTraitors?.pending ||
(!!game.decision && (game.decision.kind !== 'nexusIxianReplacement' ||
game.decision.player !== offer.buyer || game.decision.event !== offer.event));
const disabled = busy || game.status !== 'playing' || game.phase !== 3 || priorityBlocked ||
!!game.roomControl?.paused || !!game.roomControl?.closed || !!own?.autopilot;
const ownIxianMode = game.nexusCards?.card === 'ixians' && own
? nexusCardMode('ixians', own.faction, game.players.map((player) => player.faction))
: undefined;
return (
<section aria-label="Purchased card choice" data-testid="nexus-ixian-replacement" className="notice min-w-0 space-y-3">
<h3>Purchased card choice</h3>
<p>{buyer?.name ?? 'The buyer'} chooses whether to keep the completed purchase before the auction continues.</p>
<p className="fine">
This neutral development-preview boundary is offered independently of the buyer’s private Nexus face.
Only the buyer can choose. The original payment stays paid; this is not another purchase or auction.{' '}
<a href="/rules?topic=nexus-ixian-replacement#nexus-ixian-replacement">Purchased-card timing and preview limits</a>
</p>
{canRespond && offer.purchased && (
<article aria-label="Your purchased Treachery Card" className="treachery-card">
<h4>{offer.purchased.name}</h4>
<CardRules card={offer.purchased} />
<CardInspector card={offer.purchased} />
</article>
)}
{game.nexusCards?.card === 'ixians' && (
<div aria-label="Your private Ixian Nexus card">
<NexusCardFace card="ixians" mode={ownIxianMode} />
{canRespond && <p className="fine">
Secret Ally requires you to be unallied with native Ixians absent. In this preview,
eligible Use spends your Ixian Nexus and discards only the exact Treachery Card just purchased,
then draws the actual next card from the Treachery Deck. It does not take an unsold auction card.
If the deck is empty, the discard is reshuffled after your purchased card enters it;
drawing that same physical card again is legal. Keeping retains both cards.
</p>}
</div>
)}
{!canRespond ? (
<p aria-live="polite">Waiting for {buyer?.name ?? 'the buyer'}’s purchased card choice.</p>
) : (
<>
{offer.blocked && <p className="fine">{offer.blocked}</p>}
<Button data-testid="nexus-ixian-replacement-pass" className="game-action min-h-11 whitespace-normal" disabled={disabled}
onClick={() => { if (!disabled) act({ type: 'nexusIxianReplacementPass', event: offer.event }); }}>
Keep purchased card
</Button>
{canUse && (
<Button data-testid="nexus-ixian-replacement-use" className="game-action min-h-11 whitespace-normal" disabled={disabled}
onClick={() => { if (!disabled) act({ type: 'nexusIxianReplacementUse', event: offer.event }); }}>
Use Ixian Nexus · replace purchased card
</Button>
)}
</>
)}
</section>
);
}