Allow Semuta recovery before native Ghola income

Reuse the exact ordinary-discard income qualification for admission and
parent validation. Preserve physical revival and native conditional income
through claim, decline, full-hand exchange and original allow/cancel.
This commit is contained in:
Quintine committed 2026-10-24 16:48:20 +11:00
1 parent 19e554666e
commit 047011e243
11 files changed
+524 -19

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import { viewGame, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import {
createTruthMovementBoxGholaFixture, actTruthMovementGhola,
advanceTruthMovementGhola, truthMovementGholaClean, truthMovementGholaInventory,
truthMovementGholaPlayer, type TruthMovementGholaStep,
} from './fixture-truth-movement-ghola';
export const semutaGholaIncomePlayer = truthMovementGholaPlayer;
export const semutaGholaIncomeInventory = truthMovementGholaInventory;
export const semutaGholaIncomeClean = truthMovementGholaClean;
export const actSemutaGholaIncome = actTruthMovementGhola;
export type SemutaGholaIncomeStep = TruthMovementGholaStep;
/** Pass only the current fresh group; do not automatically allow its suspended
* income or any later response. Every acknowledgement is a real native action. */
export function passSemutaGholaIncome(source: Game): Game {
const pending = source.pendingTreacheryDiscard;
assert.equal(pending?.reaction?.stage, 'offer');
const event = pending!.batch.event;
let game = source;
for (const p of source.players) {
if (!game.pendingTreacheryDiscard?.reaction ||
game.pendingTreacheryDiscard.reaction.stage !== 'offer') break;
if (!game.pendingTreacheryDiscard.reaction.passed.includes(p.id))
game = actSemutaGholaIncome(game, p.id, { type: 'semutaPass', event });
}
return game;
}
export function claimSemutaGholaIncome(source: Game, actor: string, card: string): Game {
const event = source.pendingTreacheryDiscard!.batch.event;
let game = actSemutaGholaIncome(source, actor, { type: 'semutaCommit', event });
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = actSemutaGholaIncome(game, actor, { type: 'semutaSelect', event, card });
return game;
}
/** Complete only actual response acknowledgements. Printed cancellation is an
* explicit caller action, not a fixture-installed denied receipt. */
export function allowSemutaGholaIncome(source: Game): Game {
let game = source;
for (let count = 0; game.response && count < game.players.length; count++) {
const player = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(player);
game = actSemutaGholaIncome(game, player.id, { type: 'passResponse' });
}
assert.equal(game.response, null); return game;
}
/** Genuine native source/casualty/Box acquisition, followed by explicitly
* controlled conserved Semuta and Karama acquisitions. No response, receipt,
* force, wallet, phase or finished revival is installed. */
export function createSemutaGholaIncomeFixture(options: { fullHand?: boolean } = {}) {
const original = createTruthMovementBoxGholaFixture({ tleilaxu: true });
const owner = original.target, claimant = 'richese', incomeOwner = 'tleilaxu',
counterOwner = original.asker;
const actions: SemutaGholaIncomeStep[] = [...original.actions];
const originalInventory = semutaGholaIncomeInventory(original.game);
let game = actSemutaGholaIncome(original.game, owner, original.boxStep.action, actions);
const search = viewGame(game, owner).nullentropy?.search;
if (search) {
assert.ok(search.cards.some(card => card.id === original.ghola));
game = actSemutaGholaIncome(game, owner,
{ type: 'decision', event: search.event, card: original.ghola }, actions);
}
// A sole eligible card is acquired automatically by the original Box rule.
game = advanceTruthMovementGhola(game, semutaGholaIncomeClean, actions);
assert.ok(semutaGholaIncomePlayer(game, owner).hand.some(card => card.id === original.ghola));
assert.equal(semutaGholaIncomePlayer(game, owner).spice, 11);
assert.equal(game.pendingNullentropy, null);
assert.equal(game.semutaPreview, true);
assert.deepEqual(semutaGholaIncomeInventory(game), originalInventory);
const afterBox = structuredClone(game);
const cacheIndex = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
assert.ok(cacheIndex >= 0, 'Acquire the original physical Semuta from its native cache');
const semuta = game.richeseCache!.splice(cacheIndex, 1)[0];
semutaGholaIncomePlayer(game, claimant).hand.push(semuta);
const counterIndex = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(counterIndex >= 0);
const counter = game.deck.splice(counterIndex, 1)[0];
semutaGholaIncomePlayer(game, counterOwner).hand.push(counter);
if (options.fullHand) while (semutaGholaIncomePlayer(game, claimant).hand.length < 4) {
const index = game.deck.findIndex(card => ['projectile', 'poison', 'shield', 'snooper', 'worthless'].includes(card.kind));
assert.ok(index >= 0); semutaGholaIncomePlayer(game, claimant).hand.push(game.deck.splice(index, 1)[0]);
}
assert.equal(semutaGholaIncomePlayer(game, claimant).hand.length, options.fullHand ? 4 : 1);
assert.ok(game.players.every(p => p.hand.length <= 4));
assert.deepEqual(semutaGholaIncomeInventory(game), originalInventory);
assert.ok(semutaGholaIncomeClean(game));
const gholaAction: Action = { type: 'card', card: original.ghola, amount: 1, elite: 0 };
return { game, source: original, afterBox, owner, target: owner, claimant, incomeOwner, counterOwner,
counter: counter.id, semuta: semuta.id, ghola: original.ghola, gholaAction,
originalInventory, actions, sourceActionCount: actions.length, fullHand: !!options.fullHand,
staging: [...original.staging,
'Actually pay two spice with the original held Box and recover the original publicly spent Ghola; native sole-choice completion or the original private search event owns that acquisition. Finish the Box discard through actual neutral passes before staging any Semuta holder.',
'At the clean pre-Ghola boundary only, explicitly conserve the original cache Semuta into Richese hand and the original deck Karama into Emperor hand. The latter keeps the real Tleilaxu income response conditional. Optional full-hand cases move three original ordinary deck cards to Richese. No physical card is duplicated and no pending control, income receipt, wallet, army, phase, clock or revived outcome is installed. Future Ghola, Semuta and income actions remain caller choices.'],
};
}
export type SemutaGholaIncomeFixture = ReturnType<typeof createSemutaGholaIncomeFixture>;
+120
View File
@@ -0,0 +1,120 @@
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 { viewGame, type Action, type Game } from '../game/engine';
import {
createSemutaGholaIncomeFixture, actSemutaGholaIncome as act,
passSemutaGholaIncome, allowSemutaGholaIncome,
semutaGholaIncomePlayer as player,
} from './fixture-semuta-ghola-income';
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)];
}
const markup = (props: Props) => renderToStaticMarkup(createElement(SemutaReaction, props));
function click(game: Game, actor: string, label: 'Continue' | 'Commit Semuta Drug'): Action {
const sent: Action[] = [], props = { game: viewGame(game, actor), busy: false,
act: (action: Action) => { sent.push(action); } };
const control = controls(SemutaReaction(props)).find(button => button.children === label);
assert.ok(control, `The actual fresh discard offers ${label}`);
assert.equal(control.disabled, false);
assert.match(markup(props), /Fresh Treachery discard/);
assert.match(markup(props), /Continue when you are ready/);
const busy = controls(SemutaReaction({ ...props, busy: true })).find(button => button.children === label);
assert.ok(busy); assert.equal(busy.disabled, true);
busy.onClick(); assert.deepEqual(sent, [], 'Busy callbacks cannot commit or acknowledge');
control.onClick(); assert.equal(sent.length, 1);
assert.deepEqual(sent[0], { type: label === 'Continue' ? 'semutaPass' : 'semutaCommit',
event: props.game.semutaReaction!.event });
return sent[0];
}
function parkedIncome(game: Game) {
const continuation = game.pendingTreacheryDiscard?.continuation;
assert.ok(continuation?.kind === 'ordinaryCardDiscard');
assert.equal(continuation.effect, 'ghola');
const income = continuation.resume.response;
assert.ok(income?.kind === 'revivalIncome');
return structuredClone(income);
}
void test('real Ghola Continue remains readable and leaves income parked until every other seat responds', () => {
const f = createSemutaGholaIncomeFixture();
const originalIncome = player(f.game, f.incomeOwner).spice;
const reserves = player(f.game, f.owner).reserves;
let game = act(f.game, f.owner, f.gholaAction);
const income = parkedIncome(game), event = game.pendingTreacheryDiscard!.batch.event;
assert.equal(player(game, f.owner).reserves, reserves + 1);
assert.equal(player(game, f.owner).tanks, 0);
assert.equal(player(game, f.incomeOwner).spice, originalIncome);
assert.equal(game.response, null);
const before = structuredClone(game);
for (let reading = 0; reading < 2; reading++) {
const html = markup({ game: viewGame(game, f.owner), busy: false, act() {} });
assert.match(html, />Continue<\/button>/);
assert.match(html, /Continue when you are ready/);
}
assert.deepEqual(game, before, 'Reading cannot acknowledge the card or allow native income');
game = act(game, f.owner, click(game, f.owner, 'Continue'));
assert.deepEqual(parkedIncome(game), income);
assert.equal(game.pendingTreacheryDiscard!.batch.event, event);
assert.equal(game.response, null);
const waited = markup({ game: viewGame(game, f.owner), busy: false, act() {} });
assert.match(waited, /You continued. Waiting for other seats/);
assert.doesNotMatch(waited, /<button/);
assert.equal(player(game, f.incomeOwner).spice, originalIncome);
game = passSemutaGholaIncome(game);
assert.equal(game.pendingTreacheryDiscard, null);
assert.deepEqual(game.response, income);
assert.equal(player(game, f.owner).reserves, reserves + 1);
assert.equal(player(game, f.incomeOwner).spice, originalIncome);
assert.equal(SemutaReaction({ game: viewGame(game, f.owner), busy: false, act() {} }), null);
});
for (const fullHand of [false, true]) {
void test(`real Ghola Commit forwards its event and atomically claims the sole card${fullHand ? ' at a full hand' : ''} before income resumes`, () => {
const f = createSemutaGholaIncomeFixture({ fullHand });
const originalIncome = player(f.game, f.incomeOwner).spice;
const reserves = player(f.game, f.owner).reserves;
const handSize = player(f.game, f.claimant).hand.length;
const allowances = [player(f.game, f.owner).revived, player(f.game, f.owner).freeForcesRevived,
player(f.game, f.owner).leaderRevived];
let game = act(f.game, f.owner, f.gholaAction);
const income = parkedIncome(game);
const props = { game: viewGame(game, f.claimant), busy: false, act() {} };
assert.equal(props.game.semutaReaction!.canCommit, true);
assert.match(markup(props), />Continue<\/button>/);
assert.match(markup(props), />Commit Semuta Drug<\/button>/);
const html = markup({ ...props, busy: true });
assert.match(html, /disabled=""[^>]*>Continue<\/button>/);
assert.match(html, /disabled=""[^>]*>Commit Semuta Drug<\/button>/);
const action = click(game, f.claimant, 'Commit Semuta Drug');
const before = structuredClone(game);
assert.throws(() => act(game, f.claimant, { ...action, event: 'stale-ghola-discard' }));
assert.deepEqual(game, before);
game = act(game, f.claimant, action);
// One actual eligible Ghola finishes immediately under the existing sole-
// candidate rule. No artificial second card or selection stage is installed.
assert.equal(game.pendingTreacheryDiscard, null);
assert.deepEqual(game.response, income);
assert.equal(player(game, f.claimant).hand.length, handSize);
assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), true);
assert.equal(player(game, f.claimant).hand.some(card => card.id === f.semuta), false);
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
assert.equal(player(game, f.owner).reserves, reserves + 1);
assert.equal(player(game, f.owner).tanks, 0);
assert.deepEqual([player(game, f.owner).revived, player(game, f.owner).freeForcesRevived,
player(game, f.owner).leaderRevived], allowances);
assert.equal(player(game, f.incomeOwner).spice, originalIncome);
assert.equal(markup({ game: viewGame(game, f.claimant), busy: false, act() {} }), '');
game = allowSemutaGholaIncome(game);
assert.equal(player(game, f.incomeOwner).spice, originalIncome + 1);
assert.equal(player(game, f.owner).reserves, reserves + 1);
});
}
+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Action, type Game } from '../game/engine';
import {
createSemutaGholaIncomeFixture as fixture, actSemutaGholaIncome as act,
semutaGholaIncomePlayer as player, semutaGholaIncomeInventory as inventory,
passSemutaGholaIncome as pass, claimSemutaGholaIncome as claim,
allowSemutaGholaIncome as allow,
} from './fixture-semuta-ghola-income';
const quotas = (game: Game, actor: string) => {
const p = player(game, actor);
return { revived: p.revived, freeForcesRevived: p.freeForcesRevived,
leaderRevived: p.leaderRevived, elitesRevived: p.elites?.revived };
};
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
void test('a real Ghola return offers Semuta before its original conditional Tleilaxu income', () => {
const f = fixture(), source = structuredClone(f.game), beforeIncome = player(source, f.incomeOwner).spice;
const game = act(source, f.owner, f.gholaAction), pending = game.pendingTreacheryDiscard;
assert.equal(pending?.reaction?.stage, 'offer');
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
if (pending.continuation.kind !== 'ordinaryCardDiscard') assert.fail('Retain the original ordinary-card parent');
assert.equal(pending.continuation.effect, 'ghola');
assert.deepEqual(pending.continuation.resume.response,
{ kind: 'revivalIncome', owner: f.incomeOwner, recipient: f.owner, amount: 1, passed: [] });
assert.equal(game.response, null);
assert.equal(player(game, f.owner).reserves, 10); assert.equal(player(game, f.owner).tanks, 0);
assert.equal(player(game, f.incomeOwner).spice, beforeIncome);
assert.deepEqual(quotas(game, f.owner), quotas(source, f.owner));
for (const p of game.players) {
const offer = viewGame(game, p.id).semutaReaction!;
assert.deepEqual(offer.candidates, []); assert.equal(offer.canCommit, p.id === f.claimant);
}
assert.deepEqual(source, f.game); assert.deepEqual(inventory(game), f.originalInventory);
});
for (const claims of [false, true]) for (const canceled of [false, true])
void test('Ghola income ' + (claims ? 'claim' : 'pass') + ' keeps physical returns through native ' +
(canceled ? 'cancellation' : 'allowance'), () => {
const f = fixture(), beforeIncome = player(f.game, f.incomeOwner).spice;
const pending = act(f.game, f.owner, f.gholaAction);
const originalResponse = pending.pendingTreacheryDiscard?.continuation;
assert.ok(originalResponse?.kind === 'ordinaryCardDiscard');
let game = claims ? claim(pending, f.claimant, f.ghola) : pass(pending);
assert.equal(game.pendingTreacheryDiscard, null);
assert.deepEqual(game.response, originalResponse.resume.response,
'Claim/pass restores the same actual income frame before any counter or payment');
assert.equal(player(game, f.incomeOwner).spice, beforeIncome);
assert.equal(player(game, f.owner).reserves, 10); assert.equal(player(game, f.owner).tanks, 0);
assert.deepEqual(quotas(game, f.owner), quotas(f.game, f.owner));
assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), claims);
assert.equal(game.discard.some(card => card.id === f.semuta), claims);
assert.equal(game.discard.some(card => card.id === f.ghola), !claims);
game = canceled
? act(game, f.counterOwner, { type: 'card', card: f.counter, mode: 'cancel' })
: allow(game);
assert.equal(game.response, null);
assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(!canceled));
assert.equal(player(game, f.owner).spice, 11, 'Ghola and a counter do not refund or repay the Box');
assert.equal(player(game, f.owner).reserves, 10); assert.equal(player(game, f.owner).tanks, 0);
assert.deepEqual(quotas(game, f.owner), quotas(f.game, f.owner));
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('a full-hand Semuta claimant exchanges its physical cost before the unchanged Ghola income resumes', () => {
const f = fixture({ fullHand: true }), pending = act(f.game, f.owner, f.gholaAction);
assert.equal(player(pending, f.claimant).hand.length, 4);
const game = claim(pending, f.claimant, f.ghola);
assert.equal(player(game, f.claimant).hand.length, 4);
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ghola));
assert.ok(!player(game, f.claimant).hand.some(card => card.id === f.semuta));
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
assert.equal(game.response?.kind, 'revivalIncome');
assert.deepEqual(game.response.passed, []);
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('a controlled own Tleilaxu Ghola return retains the one-spice self-income through Semuta recovery', () => {
const f = fixture(), source = structuredClone(f.game), native = player(source, f.incomeOwner);
// Explicit conserved prior acquisition/casualty, not a natural-source claim.
const holder = player(source, f.owner), index = holder.hand.findIndex(card => card.id === f.ghola);
assert.ok(index >= 0); native.hand.push(holder.hand.splice(index, 1)[0]);
assert.ok(native.reserves > 0); native.reserves--; native.tanks++;
const reserves = native.reserves, income = native.spice, originalQuotas = quotas(source, native.id);
let game = act(source, native.id, f.gholaAction);
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
const continuation = game.pendingTreacheryDiscard!.continuation;
assert.ok(continuation.kind === 'ordinaryCardDiscard');
assert.deepEqual(continuation.resume.response,
{ kind: 'revivalIncome', owner: native.id, recipient: native.id, amount: 1, passed: [] });
game = claim(game, f.claimant, f.ghola);
assert.equal(player(game, native.id).spice, income);
assert.equal(player(game, native.id).reserves, reserves + 1); assert.equal(player(game, native.id).tanks, 0);
game = allow(game);
assert.equal(player(game, native.id).spice, income + 1);
assert.deepEqual(quotas(game, native.id), originalQuotas);
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('a controlled native leader Ghola can be recovered before income without returning the separate Tank', () => {
const f = fixture(), source = structuredClone(f.game), owner = player(source, f.owner);
// Explicit prior leader death; the original physical disc stays with its owner.
const leader = owner.leaders[0]; leader.dead = true; leader.deaths = 1;
const income = player(source, f.incomeOwner).spice, beforeQuotas = quotas(source, f.owner);
let game = act(source, f.owner, { type: 'card', card: f.ghola, leader: leader.id });
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
assert.equal(player(game, f.owner).leaders.find(disc => disc.id === leader.id)!.dead, false);
assert.equal(player(game, f.owner).reserves, 9); assert.equal(player(game, f.owner).tanks, 1);
game = claim(game, f.claimant, f.ghola);
assert.equal(game.response?.kind, 'revivalIncome'); assert.equal(player(game, f.incomeOwner).spice, income);
game = allow(game);
assert.equal(player(game, f.incomeOwner).spice, income + 1);
assert.deepEqual(quotas(game, f.owner), beforeQuotas);
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('stale fresh events and altered Ghola-income parents reject before card custody or income changes', () => {
const f = fixture(), pending = act(f.game, f.owner, f.gholaAction);
const event = pending.pendingTreacheryDiscard!.batch.event;
rejected(pending, f.claimant, { type: 'semutaCommit', event: 'stale-ghola-discard' });
rejected(pending, f.claimant, { type: 'semutaSelect', event, card: f.ghola });
const changed = structuredClone(pending), continuation = changed.pendingTreacheryDiscard!.continuation;
assert.equal(continuation.kind, 'ordinaryCardDiscard');
if (continuation.kind !== 'ordinaryCardDiscard') assert.fail('Expected the real Ghola parent');
assert.ok(continuation.resume.response?.kind === 'revivalIncome');
continuation.resume.response.amount = 2;
const before = structuredClone(changed);
assert.throws(() => viewGame(changed, f.claimant)); assert.deepEqual(changed, before);
rejected(changed, f.claimant, { type: 'semutaCommit', event });
});