Offer Semuta after independent-card Ix ally replacement
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 06:48:09 +11:00
1 parent 72f6ec2b12
commit 3f4925e6e1
8 files changed
+268 -10

No files matched your search

+25 -8
View File
@@ -68,8 +68,11 @@ export function finishSemutaIxAllyCost(game: Game, actions: SemutaIxAllyCostStep
/** Original R/C/I/F/G setup, cache1, actual worm Nexus pact and ordinary paid
* auction. Neither the delivered card nor a replacement is installed. */
export function createSemutaIxAllyCostFixture(options: { advanced?: boolean } = {}) {
const advanced = options.advanced ?? false;
export function createSemutaIxAllyCostFixture(options: { advanced?: boolean; independent?: boolean; independentLot?: boolean } = {}) {
const advanced = options.advanced ?? false, independent = options.independent ?? false;
const independentLot = options.independentLot ?? false;
assert.ok(!independentLot || independent, 'The selected independent card needs the variant');
assert.ok(!advanced || !independent, 'The independent native Semuta source is Basic only');
const richese = 'richese', holder = 'choam', owner = 'ixians', buyer = 'fremen', spender = 'guild';
let game = createGame('SEMUTAIXALLYCOST', newPlayer(richese, 'Richese', 'richese'), advanced, ['choam', 'ix']);
for (const faction of ['choam', 'ixians', 'fremen', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
@@ -80,16 +83,20 @@ export function createSemutaIxAllyCostFixture(options: { advanced?: boolean } =
const before = structuredClone(game), at = actions.length; run();
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
};
if (independent) act(game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
for (const p of game.players) act(p.id, { type: 'ready' });
const lobby = structuredClone(game);
if (advanced) game = withClassicDiscoveryNexusLottery(() => initializeSemutaGameForAudit(game));
else act(game.host, { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.semutaPreview, true);
assert.equal(game.status, 'setup');
assert.equal(game.semutaPreview === true, !independent);
assert.equal(game.semutaRecruitsPreview === true, independent);
assert.equal(game.ecazTreachery === true, independent);
assert.ok(game.players.every(p => p.hand.length === 0));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = semutaIxAllyCostInventory(game); assert.equal(originalInventory.length, 57);
!game.strongholdCards && !game.discoveryEnabled);
const originalInventory = semutaIxAllyCostInventory(game); assert.equal(originalInventory.length, independent ? 60 : 57);
const printed = game.deck.find(card => card.effect === 'karama' && !card.id.startsWith('ix-'));
const shield = game.deck.find(card => card.kind === 'shield');
const ordinary = game.deck.filter(card => card.kind === 'projectile' && !card.effect).slice(0, 3);
@@ -141,10 +148,20 @@ export function createSemutaIxAllyCostFixture(options: { advanced?: boolean } =
});
assert.equal(semutaIxAllyCostPlayer(game, owner).ally, buyer);
assert.equal(semutaIxAllyCostPlayer(game, buyer).ally, owner);
// Optional source variation selects one still-unopened card into Ix's next
// inspected pool. Ix returns the first inspected card, and controlled scalar
// lottery leaves the rest in order, so Recruits becomes the first real lot.
let selectedIndependentLot: string | null = null;
if (independentLot) {
const index = game.deck.findIndex(card => card.id === 'ecaz-recruits'); assert.ok(index >= 0);
const [card] = game.deck.splice(index, 1); game.deck.splice(1, 0, card);
selectedIndependentLot = card.id;
}
const beforeBidding = structuredClone(game), biddingAt = actions.length;
reach(state => state.turn === 2 && state.phase === 3 && !!state.auction &&
state.auction.index === 0 && state.auction.active === buyer && semutaIxAllyCostClean(state));
const pool = { before: beforeBidding, steps: structuredClone(actions.slice(biddingAt)), after: structuredClone(game) };
if (selectedIndependentLot) assert.equal(game.auction!.cards[game.auction!.index].id, selectedIndependentLot);
let paymentBefore: Game | null = null, paymentAt = -1;
const beforePurchase = structuredClone(game), purchaseAt = actions.length;
const awaitingReplacement = (state: Game) => state.decision?.kind === 'ixAllyCard';
@@ -171,7 +188,7 @@ export function createSemutaIxAllyCostFixture(options: { advanced?: boolean } =
assert.ok(game.response?.kind === 'ixAllyCard'); assert.equal(game.response.owner, owner);
assert.equal(game.response.recipient, buyer); assert.equal(game.decision ?? null, null);
assert.ok(game.deck[0]); assert.deepEqual(semutaIxAllyCostInventory(game), originalInventory);
return { game, kind: 'ixAllyCard' as const, advanced, blackMarket: false as const,
return { game, kind: 'ixAllyCard' as const, advanced, independent, selectedIndependentLot, blackMarket: false as const,
owner, holder, spender, buyer, recipient: buyer, richese, cost, karama: cost, semuta: SEMUTA_DRUG_ID,
action: { type: 'card', card: cost, mode: 'cancel' } as Action,
originalResponse: structuredClone(game.response), originalPending: structuredClone(game.pendingIxAlly!),
@@ -182,10 +199,10 @@ export function createSemutaIxAllyCostFixture(options: { advanced?: boolean } =
acquisition, acquired, cache: acquisition, alliance, pact: alliance, pool, purchase, payment, request,
actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Original public Basic start or original undealt Advanced initializer for R/C/I/F/G with native CHOAM/Ix decks. Only unopened deal/Spice positions and scalar lotteries are selected. Native Ix chooses its inspected Shield; Guild receives the printed Karama; CHOAM actually pays cache1 for Semuta.',
`Original public Basic start${independent ? ' with independent Ecaz cards' : ''} or original undealt Advanced initializer for R/C/I/F/G with native CHOAM/Ix decks. Only unopened deal/Spice positions and scalar lotteries are selected. Native Ix chooses its inspected Shield; Guild receives the printed Karama; CHOAM actually pays cache1 for Semuta.`,
'Original first-turn movement is unused. A real turn2 worm over the empty Funeral pile opens Nexus; Ix and Fremen actually propose and accept their pact. Native HMS, pool-return, optional Technology decline and other original controls remain real actions.',
'Fremen bids and pays one own spice for an actual ordinary auction card. There is no Emperor seat, so the printed payment goes to the Bank. The card is delivered into the buyer hand and native pendingIxAlly/currentAuctionSale remain; Fremen really requests replacement before the cancellation cost.',
'No played hand, wallet, card, ally, force, clock, pool, purchase, pending control or replacement is installed. Cache replacement and absent-Ix Secret Ally are outside these ordinary native sources.',
`${independentLot ? 'One still-unopened Recruits was selected into Ix’s later inspected pool before bidding. ' : ''}No played hand, wallet, card, ally, force, clock, pool, purchase, pending control or replacement is installed. Cache replacement and absent-Ix Secret Ally are outside these ordinary native sources.`,
] };
}
export type SemutaIxAllyCostFixture = ReturnType<typeof createSemutaIxAllyCostFixture>;
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement, type ReactElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { SemutaReaction } from '../components/semuta-reaction';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { createSemutaIxAllyCostFixture, allowSemutaIxAllyCost } from './fixture-semuta-ix-ally-cost';
type Props = Parameters<typeof SemutaReaction>[0];
type Control = { children?: unknown; disabled?: boolean; onClick: () => void };
function controls(node: unknown): Control[] {
if (Array.isArray(node)) return node.flatMap(controls);
if (!node || typeof node !== 'object' || !('props' in node)) return [];
const props = (node as ReactElement<{children?: unknown; disabled?: boolean; onClick?: () => void}>).props;
return [...(props.onClick ? [{...props, onClick:props.onClick}] : []), ...controls(props.children)];
}
function label(node: unknown): string {
if (Array.isArray(node)) return node.map(label).join('');
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (!node || typeof node !== 'object' || !('props' in node)) return '';
return label((node as ReactElement<{children?: unknown}>).props.children);
}
const markup = (props: Props) => renderToStaticMarkup(createElement(SemutaReaction, props));
function buttonAction(game: Game, actor: string, name: string, expected: Action): Action {
const sent: Action[] = [], props: Props = {game:viewGame(game, actor), busy:false,
act(action) { sent.push(action); }};
const button = controls(SemutaReaction(props)).find(item => label(item.children) === name);
assert.ok(button, `Expected the ${name} control`); assert.equal(button.disabled, false);
const busy = controls(SemutaReaction({...props, busy:true})).find(item => label(item.children) === name);
assert.ok(busy); assert.equal(busy.disabled, true); busy.onClick(); assert.deepEqual(sent, []);
button.onClick(); assert.deepEqual(sent, [expected]); return sent[0];
}
void test('independent Ecaz-card Ixian replacement exposes a neutral offer and privately claims its single card', () => {
const f = createSemutaIxAllyCostFixture({independent:true});
assert.equal(f.game.advanced, false); assert.equal(f.game.ecazTreachery, true);
assert.equal(f.game.semutaRecruitsPreview, true);
let game = allowSemutaIxAllyCost(structuredClone(f.game));
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
assert.equal(pending.continuation.kind, 'ixAllyCard'); assert.equal(pending.reaction.stage, 'offer');
const event = pending.batch.event, discarded = f.deliveredCard;
assert.deepEqual(pending.batch.entries.map(row => ({id:row.card.id, owner:row.discardedBy, face:row.publicFace})),
[{id:discarded.id, owner:f.buyer, face:false}]);
const holder = viewGame(game, f.holder), observer = viewGame(game, f.owner);
assert.equal(holder.semutaReaction?.canCommit, true);
assert.equal(observer.semutaReaction?.canCommit, false);
assert.deepEqual(holder.semutaReaction?.candidates, []);
assert.deepEqual(observer.semutaReaction?.candidates, []);
const holderHtml = markup({game:holder, act() {}, busy:false});
const observerHtml = markup({game:observer, act() {}, busy:false});
for (const html of [holderHtml, observerHtml]) {
assert.match(html, /Fresh Treachery discard/); assert.match(html, />Continue<\/button>/);
assert.ok(!html.includes(discarded.id)); assert.ok(!html.includes(discarded.name));
}
assert.match(holderHtml, />Commit Semuta Drug<\/button>/);
assert.doesNotMatch(observerHtml, /Commit Semuta Drug/);
const pass = buttonAction(game, f.owner, 'Continue', {type:'semutaPass', event});
game = applyAction(game, f.owner, pass);
assert.match(markup({game:viewGame(game, f.owner), act() {}, busy:false}), /You continued/);
const commit = buttonAction(game, f.holder, 'Commit Semuta Drug', {type:'semutaCommit', event});
game = applyAction(game, f.holder, commit);
assert.equal(game.pendingTreacheryDiscard, null, 'A sole physical candidate is claimed without an extra confirmation.');
const own = viewGame(game, f.holder), rival = viewGame(game, f.owner);
assert.ok(own.players.find(player => player.id === f.holder)?.hand?.some(card => card.id === discarded.id));
assert.equal(rival.players.find(player => player.id === f.holder)?.hand, undefined);
assert.equal(SemutaReaction({game:own, act() {}, busy:false}), null);
const saved = structuredClone(game);
assert.throws(() => applyAction(game, f.holder, commit));
assert.deepEqual(game, saved);
});
+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createSemutaIxAllyCostFixture as fixture, actSemutaIxAllyCost as act,
passSemutaIxAllyCost as pass, claimSemutaIxAllyCost as claim,
allowSemutaIxAllyCost as allow, finishSemutaIxAllyCost as finish,
semutaIxAllyCostPlayer as player, semutaIxAllyCostInventory as inventory,
type SemutaIxAllyCostFixture,
} from './fixture-semuta-ix-ally-cost';
/** The producer is a real ordinary paid purchase and native alliance use. */
function source(f: SemutaIxAllyCostFixture) {
assert.equal(f.independent,true); assert.equal(f.setup.semutaRecruitsPreview,true);
assert.equal(f.setup.semutaPreview,undefined); assert.equal(f.setup.ecazTreachery,true);
assert.equal(f.originalInventory.length,60);
assert.ok(f.lobby.players.every(seat => seat.ready));
assert.equal(f.pact.before.nexus,true);
assert.deepEqual(f.pact.steps.map(step => step.action.type),['alliance','alliance']);
assert.equal(player(f.game,f.owner).ally,f.buyer); assert.equal(player(f.game,f.buyer).ally,f.owner);
assert.equal(f.originalSale.origin,'normal'); assert.equal(f.originalSale.amount,1);
assert.equal(player(f.payment.before,f.buyer).spice - player(f.payment.after,f.buyer).spice,1);
assert.ok(f.purchase.steps.some(step => step.actor === f.buyer && step.action.type === 'bid'));
assert.equal(f.game.response?.kind,'ixAllyCard');
assert.ok(player(f.game,f.buyer).hand.some(card => card.id === f.deliveredCardId));
assert.deepEqual(inventory(f.game),f.originalInventory);
}
function open(f: SemutaIxAllyCostFixture, game = structuredClone(f.game)): Game {
game = allow(game);
const pending = game.pendingTreacheryDiscard; assert.ok(pending);
assert.equal(pending.continuation.kind,'ixAllyCard');
assert.equal(pending.reaction?.stage,'offer');
assert.deepEqual(pending.batch.entries.map(entry => ({id:entry.card.id,by:entry.discardedBy,public:entry.publicFace})),
[{id:f.deliveredCardId,by:f.buyer,public:false}]);
assert.equal(game.discard.filter(card => card.id === f.deliveredCardId).length,1);
assert.equal(player(game,f.buyer).hand.some(card => card.id === f.deliveredCardId),false);
assert.deepEqual(game.currentAuctionSale,f.originalSale);
assert.equal(viewGame(game,f.holder).semutaReaction?.canCommit,true);
for (const seat of game.players) {
const reaction = viewGame(game,seat.id).semutaReaction; assert.ok(reaction);
assert.deepEqual(reaction.candidates,[]);
assert.equal(reaction.canCommit,seat.id === f.holder);
}
return game;
}
for (const claiming of [false,true]) void test(
`independent-card native Ix ally discard offers Semuta ${claiming ? 'claim' : 'pass'} before the replacement draw`, () => {
const f = fixture({independent:true,independentLot:true}); source(f);
assert.equal(f.deliveredCardId,'ecaz-recruits'); assert.equal(f.selectedIndependentLot,f.deliveredCardId);
const offered = open(f), event = offered.pendingTreacheryDiscard!.batch.event;
assert.deepEqual(offered.deck,f.originalDeck);
const buyerSpice = player(offered,f.buyer).spice, buyerHand = player(offered,f.buyer).hand.length;
const after = claiming ? claim(offered,f.holder) : pass(offered);
const done = finish(after);
assert.equal(done.pendingTreacheryDiscard ?? null,null);
assert.equal(done.currentAuctionSale ?? null,null); assert.equal(done.pendingIxAlly ?? null,null);
assert.equal(done.auction?.index,f.originalAuction.index + 1);
assert.equal(player(done,f.buyer).spice,buyerSpice);
assert.equal(player(done,f.buyer).hand.length,buyerHand + 1);
assert.equal(player(done,f.buyer).hand.filter(card => card.id === f.replacementTop.id).length,1);
assert.deepEqual(done.deck,f.originalDeck.slice(1));
assert.equal(player(done,f.holder).hand.some(card => card.id === f.deliveredCardId),claiming);
assert.equal(done.discard.some(card => card.id === f.deliveredCardId),!claiming);
assert.equal(done.discard.some(card => card.id === f.semuta),claiming);
assert.deepEqual(inventory(done),f.originalInventory);
const before = structuredClone(done);
assert.throws(() => act(done,f.holder,{type:'semutaCommit',event}));
assert.deepEqual(done,before);
},
);
for (const claiming of [false,true]) void test(
`independent-card Ix ally ${claiming ? 'claim' : 'pass'} precedes a controlled empty-deck reshuffle`, () => {
const f = fixture({independent:true,independentLot:true}); source(f);
const arranged = structuredClone(f.game);
// Controlled conserved shortage after the real paid purchase: original
// undealt deck moves to discard, without touching the delivered lot.
arranged.discard.push(...arranged.deck); arranged.deck = [];
assert.deepEqual(inventory(arranged),f.originalInventory);
const offered = open(f,arranged), originalDiscard = offered.discard.map(card => card.id);
assert.equal(offered.deck.length,0); assert.ok(originalDiscard.includes(f.deliveredCardId));
const buyerHand = player(offered,f.buyer).hand.map(card => card.id);
const buyerSpice = player(offered,f.buyer).spice;
const after = claiming ? claim(offered,f.holder) : pass(offered);
const done = finish(after);
const replacement = player(done,f.buyer).hand.filter(card => !buyerHand.includes(card.id));
assert.equal(replacement.length,1);
const eligible = new Set(originalDiscard.filter(id => !claiming || id !== f.deliveredCardId));
if (claiming) eligible.add(f.semuta);
assert.ok(eligible.has(replacement[0].id));
assert.equal(player(done,f.buyer).spice,buyerSpice);
assert.equal(done.currentAuctionSale ?? null,null); assert.equal(done.pendingIxAlly ?? null,null);
assert.equal(done.auction?.index,f.originalAuction.index + 1);
assert.equal(player(done,f.holder).hand.some(card => card.id === f.deliveredCardId),claiming);
assert.deepEqual(inventory(done),f.originalInventory);
},
);
void test('all minimal bot profiles can legally answer the original independent-card Ix discard offer', () => {
const f = fixture({independent:true}); source(f);
const offered = open(f);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(offered,f.holder);
view.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const action = botActions(view).find(item => item.type === 'semutaPass' || item.type === 'semutaCommit');
assert.ok(action);
const next = act(structuredClone(offered),f.holder,action);
assert.deepEqual(inventory(next),f.originalInventory);
}
});
void test('independent-card native Ix replacement cancellation spends its real Karama without a Semuta cost offer', () => {
const f = fixture({independent:true}); source(f);
let game = act(structuredClone(f.game),f.spender,f.action);
if (game.pendingKull) {
const reaction = viewGame(game,'choam').kullReaction; assert.ok(reaction);
game = act(game,'choam',{type:'kullDecision',event:reaction.event,decline:true});
}
assert.equal(game.pendingTreacheryDiscard ?? null,null);
assert.equal(game.response?.kind === 'ixAllyCard',false);
assert.equal(player(game,f.spender).hand.some(card => card.id === f.cost),false);
assert.equal(game.discard.filter(card => card.id === f.cost).length,1);
const done = finish(game);
assert.equal(done.currentAuctionSale ?? null,null); assert.equal(done.pendingIxAlly ?? null,null);
assert.equal(done.auction?.index,f.originalAuction.index + 1);
assert.deepEqual(done.deck,f.originalDeck);
assert.ok(player(done,f.buyer).hand.some(card => card.id === f.deliveredCardId));
assert.ok(player(done,f.holder).hand.some(card => card.id === f.semuta));
assert.deepEqual(inventory(done),f.originalInventory);
});