Compose original Homeworld battle reward cancellation costs with Semuta

This commit is contained in:
Quintine committed 2026-10-13 07:37:31 +11:00
1 parent afe3008cae
commit ec574ab0f5
12 files changed
+910 -1

No files matched your search

+3
View File
@@ -1190,6 +1190,9 @@ export function GameTable({
Native Advanced BG extra-charity and allied Emperor gift/extra-
revival cancellation costs also retain the consumed claim and
original ally, casualties and unawarded benefit.
Printed Karama canceling native capture, Auditor or quoted battle
income also keeps accepted combat, paid support and original
cleanup; the canceled reward does not occur.
Private discarded faces stay hidden until commitment; a simultaneous
group requires one selection. Claim or Continue resumes the same
original source once without undoing payment or force commitment.
+17
View File
@@ -29,6 +29,23 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Semuta / native battle-reward cancellation — 13 October.**
Original Advanced Harkonnen capture, CHOAM Auditor and quoted force-income
printed Karama costs now offer before denial. Six positive before programs
had no offer;13 parent programs preserve paid deployments/support, accepted
casualties, unsampled captives, killed Auditor and income-before-audit order.
Three actual390px declaration/cancel/4-of-4 recovery flows finish original
Collection: H9→11/E8, canceled income C8/E0→4, canceled audit C9/E2→6.
The two E survivors collect4 once; no support refund, extra income, captive
transfer, audit payment/inspection or casualty replay. Artifact:
`/tmp/dune-homeworld-native-reward-3224.json`.
Basic retains no native capture. No profile/signature/price/public gate changes
or save/deployed/full-mode acceptance follows.
Types/lint and117/117 across eight affected unit files pass (31 new cases).
The initial fixture type narrowing and zero-bounty-null representation pins
were corrected/removed; no rule or bot change. Build passes with unchanged
deprecation, large-chunk and static route-classification warnings.
**Semuta / original shared-benefit cancellations — 13 October.**
Native Advanced BG extra-charity and genuinely allied Emperor gift/extra-revival
printed Karama costs now offer their cards. Six positive before programs had
@@ -65,6 +65,12 @@ real casualties and empty extra-revival ledger stay intact; ordinary legal
revival is a separate later action. Wealthy Basic BG's ordinary eligibility
and all unresolved source/publication boundaries remain unchanged.
Native capture, Auditor and quoted battle-income printed Karama costs also
offer before original denial. Accepted battle, actual paid support, casualties
and leader death stay committed; recovery cannot sample/transfer a captive,
pay/inspect an audit or refund/credit canceled income. Earlier allowed income
precedes a separately canceled audit. Native cleanup/Collection still follows.
Actual selected original cleanup cards are privately discarded first. The
typed receipt retains only that cleanup's event, original held-ID commitment
and suspended opening; its lease row remains pending during the neutral
+61
View File
@@ -1,5 +1,66 @@
# Dune implementation status
## 13 October 2026 — original Homeworld battle-reward Karama costs
Only existing `karamaBattleDiscard` is newly admitted by the Homeworld
Semuta gate. Its existing native capture/Auditor/force-income validator,
original resource/casualty/owner commitments and reward order remain;
Ix/Moritani stay outside this roster. No signing, price or profile cutover.
Two genuine thin/full capture before programs use original Advanced
R/C/E/A/H Homeworld setup. E's actual two-normal Kaitain shipment enters
native Carthag11 for2; H's funded dial1/support1 defeats E's original weak
leader/dial0 with no weapons. H keeps9/Tanks1, E loses2/reserves18, both
leaders live and no bounty occurs. H owns and accepts native captureOffer;
printed E Karama spent without Semuta before original denial. The first
reported E6 balance included incidental prior native cleanup; it is not a
shipping-income rule or fixed wallet claim. The final fixture uses real
spending-free Bidding readiness: original E10→8, H10→9 support, half1 rounds0.
Four genuine thin/full CHOAM before programs use original Advanced R/E/C/A.
E4 Red Chasm7 shipment8 and C1 shipment2 establish original forces. Actual
E Fenring6 dial2/support2 pays2 and keeps two survivors/Tanks2; C loses1.
Native one-spice income quotes half of that eligible two-spice payment.
Canceling it leaves C8/E0. The audit variant uses actual E Crysknife against
C's Snooper, kills original Auditor2 and awards original bounty2 (E2).
Native income first credits C8→9, then C declares its audit; printed E
Karama spends without Semuta and denies audit before payment/inspection.
No phase/wealth/force/leader/receipt assignment establishes any of these.
Thirteen parent after/negative programs exercise thin/full claim/pass for
capture and both CHOAM scopes, plus a true Basic no-capture companion.
Current original leaders/Traitors, custody, context, casualties, wallets and
physical unplayed card stay committed. Original Collection quotes precede
the last close and credit once: H9→11/E8; canceled income C8/E0→4; canceled
audit C9/E2→6. Actual two E survivors collect4 from the original eight-spice
blow, leaving4. No recovered cost restores its canceled reward or replay.
Three actual390px flows start at native H captureOffer, C unpaid income or
dead-Auditor owner offer. H chooses Capture a leader; C chooses Audit opposing
hand; E's real Cancel with Karama, then R's4/4 Commit, completes denial.
Capture samples/transfers no disc; canceled force income preserves E support
debit2; audit keeps C's earlier1 and E's bounty2 without payment/inspection.
Each uses original C Finish control to phase7 with the quoted balances.
Artifact outside Git: `/tmp/dune-homeworld-native-reward-3224.json`.
One fresh DOM observation failed after accepted capture close; fresh server
state showed phase7 and four original actions accepted. No action was replayed.
The managed tab's recorded errors were empty; the tool failure remains recorded.
Only owned service/tab/root files were stopped/removed. No saved/deployed/
natural-full-game or completed mode is certified.
Parent removed no-module Tech/default and receipt-count echoes. Four-profile
CHOAM tests now apply the real proposed close and assert actual Collection,
instead of pinning the bot's action object. The initial frozen TypeScript
failure was the fixture's union Decision event access; a captured native
choamAudit discriminator narrows it without casts or a rule change.
Frozen types/lint pass. The first unit run failed four income rows that
pinned zero bounty to null; the real quote can represent a zero amount.
That representation assertion was deleted, not re-pinned; actual paid/bounty
balances remain asserted. Final affected eight-file union passes117/117,
including31 new consumer cases. Only failed check dependencies were retried.
Build passes with unchanged module.register deprecation, client chunks
over500kB and static route-classification warnings. No broader/deployed proof.
## 13 October 2026 — original shared-benefit cancellation costs
Only existing `karamaBgCharityDiscard` and `karamaEmperorShareDiscard`
+19
View File
@@ -855,6 +855,25 @@ consumer cases pass on that genuine setup. Later held cards/Tanks use
conserved transfers, not replacement decks or minted forces. No new ruling,
module entry, public gate or complete-mode certification follows.
**13 October 2026 — original Homeworld battle-reward cancellation costs.**
Printed Karama canceling native Advanced capture, CHOAM Auditor or quoted
force income now offers before its existing denial. Accepted combat,
paid native deployment/support, casualties and original leader death remain.
Recovery cannot sample/transfer a captive, pay/inspect an audit, refund support
or credit canceled income; an audit denial retains already allowed income.
The same existing private/current-hand and per-payer calculation applies.
Six actual before programs had no offer;13 parent after/negative programs
preserve original resources and one native Collection. Three actual390px
owned declaration/cancel/4-of-4 recovery flows reach phase7: H9→11/E8,
income C8/E0→4, audit C9/E2→6. Two E survivors collect4 once, no new bounty or
extra payment. Artifact: `/tmp/dune-homeworld-native-reward-3224.json`.
The initial E6 capture report was incidental native cleanup, not a fee/
Emperor-income ruling; the corrected real ready-gate preserves E10→8.
Basic capture remains absent. Tool DOM observation failed after an accepted
close; actual state was inspected and no action replayed. Existing source,
profile, signature and public gates remain; no saved/deployed/full-mode claim.
**13 October 2026 — original Homeworld shared-benefit cancellation costs.**
Native Advanced BG extra-charity and allied Emperor gift/extra-revival
printed Karama costs now offer before original denial. BG's real consumed
+2 -1
View File
@@ -6973,7 +6973,8 @@ function semutaOfferSupported(
continuation.kind !== 'karamaPurchaseDiscard' &&
continuation.kind !== 'karamaPaymentDiscard' &&
continuation.kind !== 'karamaBgCharityDiscard' &&
continuation.kind !== 'karamaEmperorShareDiscard') return false;
continuation.kind !== 'karamaEmperorShareDiscard' &&
continuation.kind !== 'karamaBattleDiscard') return false;
if (continuation.kind === 'battleResolved' &&
!g.lastBattleContext?.mandatoryDiscard) return false;
if ((continuation.kind === 'battleCleanup' ||
+3
View File
@@ -1193,6 +1193,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Clean Homeworld Residual Poison disposal offers its used card after exactly one native random eligible leader enters the Tanks, with no spice bounty or force loss; claim/pass resumes the original shared preparation and does not redraw the victim. Early Sapho aggressor disposal likewise keeps the original participants/chooser and changed ordinary tie priority, while clean between-battle first/last disposal keeps earlier resolved battles and only reorders remaining chooser opportunities. Physical storm order is unchanged. Readied or inspected Poison costs, late Sapho timing and Advanced Harkonnen captive disclosure remain outside these clean reactions.',
'Printed Karama canceling original CHOAM opening income or native Inflation placement also offers its used card in the Homeworld profile. Claim/pass completes the same denial once: unpaid income stays withheld, and the original placement attempt remains spent with no token. Clean normal-lot printed Karama purchase and free winning-bid payment likewise offer their costs before exactly one original reserved card arrives without spice payment. Earlier bids, the normal pool, Richese cache, original wallets and later native controls stay committed. Richese lots, BG Worthless conversion, Kull and other cancellations remain separate.',
'The native Advanced BG extra-charity cancellation cost also offers printed Karama after the genuine claim allowance is consumed but before denial; claim/pass withholds the same extra allowance and cannot renew that turn’s claim. Wealthy Basic BG retains its original ordinary-charity guard. Allied Emperor gift and extra-revival cancellation costs likewise offer after original native setup, actual casualty/alliance and declaration: recovering the card does not transfer the canceled gift, return a counter or award extra revival. Original ally, populations, wallets, Tanks and native closing controls remain committed.',
'Original native Homeworld battle-reward cancellation costs also offer printed Karama: Advanced Harkonnen capture, native CHOAM Auditor and quoted force-payment income. Recovery preserves the accepted battle, paid support, leader death and original casualty/cleanup order, then completes the same denial. It cannot sample or transfer a captive, pay/inspect an audit, refund support or credit canceled income. An audit cancellation leaves any earlier allowed force-payment income intact. Existing exact roster, private-custody and source guards remain.',
'An ordinary Ghola played during battle also offers its consumed card after revival. Claim or Continue preserves the revived leader or forces and the opposing sealed plan, then normal planning resumes. It cannot revive again by resuming the receipt.',
'A completed paid Nullentropy Box search during battle offers only the used Box. Claim or Continue preserves the two-spice payment, private recovered card, already-shuffled pile and sealed opposing plan. The recovered card may be used in normal legal planning; the search is not repeated.',
'A clean Distrans transfer during battle also offers only the used Distrans. The private card has already reached its recipient; claim or Continue cannot repeat the gift or change a sealed plan. A recipient holding Semuta may claim the used Distrans and keep the already-transferred card.',
@@ -1283,6 +1284,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/semuta-homeworld-karama-auction.test.ts',
'tests/semuta-homeworld-bg-charity.test.ts',
'tests/semuta-homeworld-emperor-share.test.ts',
'tests/semuta-homeworld-capture-karama.test.ts',
'tests/semuta-homeworld-choam-battle-karama.test.ts',
],
},
],
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import {
createGame, joinGame, newPlayer, initializeSemutaGameForAudit, handLimit,
viewGame, type Action, type Game,
} from '../game/engine';
import { nativeShipmentSources } from '../game/homeworld-options';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { moveSemutaEmperorCard, semutaEmperorCardId, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { applySemutaHomeworldChoamStep } from './fixture-semuta-homeworld-choam';
import { advanceSemutaHomeworldEmperorShare, nextSemutaHomeworldEmperorShareStep }
from './fixture-semuta-homeworld-emperor-share';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
export type SemutaHomeworldCaptureKaramaFixture = {
initial: Game; setup: Game; beforeBattle: Game; acceptedBattle: Game;
nativecaptureOffer: Game | null; originalresponse: Game | null;
gameBeforeCost: Game; game: Game;
paidShipment: { actor: string; action: Action; before: Game; after: Game };
actor: string; owner: string; holder: string; captor: string; victim: string;
winner: string; loser: string; location: string; card: string;
winnerLeader: string; loserLeader: string; action: Action;
actions: RicheseSkillsTechStep[]; staging: string[];
};
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
export function semutaHomeworldCaptureKaramaStock(g: Game): string[] {
return [g.deck, g.discard, g.richeseCache ?? [], g.richeseRemoved ?? [],
...g.players.map(p => p.hand)].flat().map(c => c.id).sort();
}
export function semutaHomeworldCaptureKaramaResources(g: Game) {
return { custody: structuredClone(g.homeworlds?.custody), spice: structuredClone(g.spice),
players: g.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves,
tanks: p.tanks, forces: structuredClone(p.forces), elites: structuredClone(p.elites),
battleLosses: p.battleLosses, leaders: structuredClone(p.leaders),
traitors: [...p.traitors], ally: p.ally })),
context: structuredClone(g.lastBattleContext), tech: structuredClone(g.techTokens) };
}
/** Genuine first-turn R/C/E/A/H native setup. Only physical hand positions and
* still-unplayed Spice Card order are staged; no clocks or counters are assigned. */
export function createSemutaHomeworldCaptureKaramaFixture(
fullHolder = false, advanced = true,
): SemutaHomeworldCaptureKaramaFixture {
let game = createGame('SEMUTAHOMECAPTURE', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
for (const [id, name, faction] of [
['c', 'CHOAM', 'choam'], ['e', 'Emperor', 'emperor'],
['a', 'Atreides', 'atreides'], ['h', 'Harkonnen', 'harkonnen'],
] as const) joinGame(game, newPlayer(id, name, faction));
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
const act = (actor: string, action: Action) => {
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
};
act(game.host, { type: 'homeworlds', enabled: true });
for (const seat of game.players) act(seat.id, { type: 'ready' });
const initial = structuredClone(game);
game = initializeSemutaGameForAudit(game);
game = advanceSemutaHomeworldEmperorShare(game, g => g.status === 'playing', actions);
const setup = structuredClone(game);
const blows = game.spiceDeck.filter(c => 'territory' in c).slice(0, 2);
assert.equal(blows.length, 2);
for (const [i, card] of blows.entries())
game.spiceDeck.splice(i, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('Reorder two original still-unplayed non-worm Spice Cards.');
game = advanceSemutaHomeworldEmperorShare(game,
g => g.phase === 5 && g.active === 'e' && clean(g), actions);
assert.equal(game.turn, 1);
assert.equal(player(game, 'h').forces['carthag:11'], 10);
assert.equal(player(game, 'e').spice, player(setup, 'e').spice,
'Explicit original Bidding readiness must leave the Emperor funding untouched for the real paid deployment');
const sources = nativeShipmentSources(viewGame(game, 'e'), 2, 0);
assert.ok(sources);
const shipment: Action = { type: 'ship', territory: 'carthag', sector: 11,
amount: 2, elite: 0, homeworldSources: sources, allyPayment: 0 };
const beforeShipment = structuredClone(game);
act('e', shipment);
game = advanceSemutaHomeworldEmperorShare(game, clean, actions);
const paidShipment = { actor: 'e', action: shipment, before: beforeShipment, after: structuredClone(game) };
assert.equal(player(game, 'e').spice, player(beforeShipment, 'e').spice - 2);
assert.equal(player(game, 'e').reserves, player(beforeShipment, 'e').reserves - 2);
assert.deepEqual(player(game, 'e').elites, player(beforeShipment, 'e').elites);
act('e', { type: 'endMovement' });
game = advanceSemutaHomeworldEmperorShare(game, g => g.phase === 6 && clean(g), actions);
// Later conserved custody, not an auction award or a copied physical face.
game.deck.push(...player(game, 'r').hand.splice(0));
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, 'r');
const existingKarama = player(game, 'e').hand.find(c => c.effect === 'karama');
const card = existingKarama?.id ?? moveSemutaEmperorCard(game,
semutaEmperorCardId(game, c => c.effect === 'karama'), 'e');
assert.ok(player(game, 'e').hand.length <= handLimit(player(game, 'e')));
if (fullHolder) while (player(game, 'r').hand.length < handLimit(player(game, 'r'))) {
assert.ok(game.deck[0]);
moveSemutaEmperorCard(game, game.deck[0].id, 'r');
}
staging.push(`Conserved original ${SEMUTA_DRUG_ID} to Richese and printed ${card} to Emperor; ` +
(fullHolder ? 'Richese filled to four using original undealt cards.' : 'Richese holds only Semuta.'));
const beforeBattle = structuredClone(game);
const chooser = game.active;
assert.ok(chooser && ['e', 'h'].includes(chooser));
act(chooser, { type: 'chooseBattle', territory: 'carthag', target: chooser === 'e' ? 'h' : 'e' });
game = advanceSemutaHomeworldEmperorShare(game,
g => !!g.battle?.preLeader?.closed && clean(g), actions);
const winnerLeader = player(game, 'h').leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => b.strength - a.strength)[0].id;
const loserLeader = player(game, 'e').leaders.filter(l => !l.dead && !l.usedAt)
.sort((a, b) => a.strength - b.strength)[0].id;
assert.ok(player(game, 'h').leaders.find(l => l.id === winnerLeader)!.strength + 1 >
player(game, 'e').leaders.find(l => l.id === loserLeader)!.strength);
const battle = game.battle!;
for (const actor of [battle.attacker, battle.defender]) act(actor,
{ type: 'battlePlan', dial: actor === 'h' ? 1 : 0,
support: advanced && actor === 'h' ? 1 : 0,
leader: actor === 'h' ? winnerLeader : loserLeader });
for (const actor of [battle.attacker, battle.defender]) act(actor, { type: 'traitorCall', call: false });
const acceptedBattle = structuredClone(game);
let nativecaptureOffer: Game | null = null, originalresponse: Game | null = null;
if (advanced) {
game = advanceSemutaHomeworldEmperorShare(game, g => g.decision?.kind === 'captureOffer', actions);
assert.equal(game.decision?.player, 'h');
nativecaptureOffer = structuredClone(game);
act('h', { type: 'decision', accept: true });
assert.equal(game.response?.kind, 'capture');
assert.equal(game.response?.owner, 'h');
originalresponse = structuredClone(game);
} else {
game = advanceSemutaHomeworldEmperorShare(game, g => g.phase === 7 && clean(g), actions);
assert.equal(game.pendingCapture, null);
}
return { initial, setup, beforeBattle, acceptedBattle, nativecaptureOffer, originalresponse,
gameBeforeCost: game, game, paidShipment, actor: 'e', owner: 'e', holder: 'r',
captor: 'h', victim: 'e', winner: 'h', loser: 'e', location: 'carthag:11', card,
winnerLeader, loserLeader, action: { type: 'card', mode: 'cancel', card }, actions, staging };
}
export function disposeSemutaHomeworldCaptureKarama(f: SemutaHomeworldCaptureKaramaFixture,
state = f.gameBeforeCost, actions?: RicheseSkillsTechStep[]): Game {
return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
}
/** Stop before the original final Battle/market close; quote Collection before
* its native credits, never quote an already-collected phase-seven board twice. */
export function finishSemutaHomeworldCaptureKarama(state: Game, actions: RicheseSkillsTechStep[] = []) {
assert.equal(state.pendingTreacheryDiscard, null,
'Resolve the original printed-cost offer before the native Collection continuation');
let game = state;
for (let n = 0; n < 1600; n++) {
assert.equal(game.turn, state.turn);
assert.equal(game.phase, 6);
const beforeCollection = game;
const collection = quoteSpiceCollection(beforeCollection);
const next = nextSemutaHomeworldEmperorShareStep(game);
game = applySemutaHomeworldChoamStep(game, next.actor, next.action, actions);
if (game.phase === 7) {
game = advanceSemutaHomeworldEmperorShare(game, clean, actions);
return { beforeCollection, collection, game };
}
}
throw new Error('Original native capture cleanup must reach Collection');
}
@@ -0,0 +1,163 @@
import assert from 'node:assert/strict';
import {
createGame, handLimit, initializeSemutaGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import { nativeShipmentSources } from '../game/homeworld-options';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { isAuditorLeader } from '../game/choam-auditor';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { applySemutaHomeworldChoamStep,
holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
import { advanceSemutaHomeworldEmperorShare as advanceSemutaHomeworldChoam } from './fixture-semuta-homeworld-emperor-share';
export type SemutaHomeworldChoamBattleKaramaScope = 'income' | 'audit';
export type SemutaHomeworldChoamBattleKaramaFixture = {
scope: SemutaHomeworldChoamBattleKaramaScope;
initial: Game; setup: Game; beforeBattle: Game; acceptedBattle: Game;
afterPaidBattle: Game; nativeIncome: Game; auditOffer?: Game;
originalResponse: Game; gameBeforeCost: Game; game: Game;
shipments: { actor: string; action: Action; before: Game; after: Game }[];
actor: string; action: Action; declaration?: Action;
card: string; karama: string; amount: number; canceller: string; owner: string;
holder: string; winner: string; loser: string; location: string;
winnerLeader: string; loserLeader: string; usedIds: string[];
unplayedPrivateCard: string; projectile?: string; snooper?: string;
actions: RicheseSkillsTechStep[]; staging: string[];
};
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
/** Original Advanced R/E/C/A setup and paid first-turn battle. Only conserved
* physical card positions and still-unplayed Spice Card order are staged. */
export function createSemutaHomeworldChoamBattleKaramaFixture(
scope: SemutaHomeworldChoamBattleKaramaScope, fullHolder = false,
): SemutaHomeworldChoamBattleKaramaFixture {
let game = createGame('SEMUTAHOMEWORLDCHOAMBATTLE', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
joinGame(game, newPlayer('c', 'CHOAM', 'choam'));
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
const actions: RicheseSkillsTechStep[] = [], staging: string[] = [];
game = applySemutaHomeworldChoamStep(game, game.host, { type: 'homeworlds', enabled: true }, actions);
for (const seat of game.players)
game = applySemutaHomeworldChoamStep(game, seat.id, { type: 'ready' }, actions);
const initial = structuredClone(game);
game = initializeSemutaGameForAudit(game);
game = advanceSemutaHomeworldChoam(game, g => g.status === 'playing', actions);
const setup = structuredClone(game);
const owner = 'c', canceller = 'e', holder = 'r';
const red = game.spiceDeck.find(c => 'territory' in c && c.territory === 'red_chasm');
const other = game.spiceDeck.find(c => 'territory' in c && c !== red);
assert.ok(red && other);
for (const [index, card] of [red, other].entries())
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('Position two original unplayed non-worm Spice Cards, first Red Chasm; no clock or resource staging.');
const shipments: SemutaHomeworldChoamBattleKaramaFixture['shipments'] = [];
for (const [actor, amount] of [[canceller, 4], [owner, 1]] as const) {
game = advanceSemutaHomeworldChoam(game,
g => g.phase === 5 && g.active === actor && clean(g), actions);
assert.equal(game.turn, 1);
const sources = nativeShipmentSources(viewGame(game, actor), amount, 0);
assert.ok(sources);
const action: Action = { type: 'ship', territory: 'red_chasm', sector: 7,
amount, elite: 0, homeworldSources: sources, allyPayment: 0 };
const before = structuredClone(game);
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
game = advanceSemutaHomeworldChoam(game, clean, actions);
shipments.push({ actor, action, before, after: structuredClone(game) });
assert.equal(player(game, actor).spice, 10 - amount * 2);
game = applySemutaHomeworldChoamStep(game, actor, { type: 'endMovement' }, actions);
}
game = advanceSemutaHomeworldChoam(game, g => g.phase === 6 && clean(g), actions);
// Late physical staging cannot alter native readiness, shipment or battle funding.
for (const actor of [canceller, owner, holder]) game.deck.push(...player(game, actor).hand.splice(0));
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, holder);
const karama = holdSemutaHomeworldChoamCard(game, canceller, 'Karama');
const unplayedPrivateCard = holdSemutaHomeworldChoamCard(game, canceller, 'Hajr');
const projectile = scope === 'audit' ? holdSemutaHomeworldChoamCard(game, canceller, 'Crysknife') : undefined;
const snooper = scope === 'audit' ? holdSemutaHomeworldChoamCard(game, owner, 'Snooper') : undefined;
staging.push('Conserve original physical Semuta to Richese, Karama and unused Hajr to Emperor; audit only: Crysknife to Emperor and nonprojectile Snooper to CHOAM.');
if (fullHolder) {
while (player(game, holder).hand.length < handLimit(player(game, holder))) {
const card = game.deck.find(c => c.id !== SEMUTA_DRUG_ID);
assert.ok(card);
moveSemutaEmperorCard(game, card.id, holder);
}
staging.push('Conserved undealt cards fill Richese to its native hand limit.');
}
const winnerLeader = player(game, canceller).leaders.find(l => l.name === 'Hasimir Fenring')!.id;
const loserLeader = player(game, owner).leaders.find(l => scope === 'audit'
? isAuditorLeader(l) : !isAuditorLeader(l) && !l.dead && !l.usedAt)!.id;
const beforeBattle = structuredClone(game);
const chooser = game.active;
assert.ok(chooser && [owner, canceller].includes(chooser));
game = applySemutaHomeworldChoamStep(game, chooser,
{ type: 'chooseBattle', territory: 'red_chasm', target: chooser === owner ? canceller : owner }, actions);
game = advanceSemutaHomeworldChoam(game, g => !!g.battle?.preLeader?.closed && clean(g), actions);
for (const actor of [game.battle!.attacker, game.battle!.defender]) {
const action: Action = actor === canceller
? { type: 'battlePlan', dial: 2, support: 2, leader: winnerLeader, ...(projectile ? { weapon: projectile } : {}) }
: { type: 'battlePlan', dial: 0, support: 0, leader: loserLeader, ...(snooper ? { defense: snooper } : {}) };
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
}
const acceptedBattle = structuredClone(game);
// Every earlier losing/winning Semuta event is passed by genuine controls.
game = advanceSemutaHomeworldChoam(game, g => g.response?.kind === 'choamBattleIncome', actions);
const afterPaidBattle = structuredClone(game), nativeIncome = structuredClone(game);
assert.equal(game.pendingChoamBattleIncome?.amount, 1);
assert.equal(player(game, owner).spice, 8);
assert.equal(player(game, canceller).spice, scope === 'audit' ? 2 : 0);
let auditOffer: Game | undefined, declaration: Action | undefined;
if (scope === 'audit') {
game = advanceSemutaHomeworldChoam(game, g => g.decision?.kind === 'choamAudit', actions);
auditOffer = structuredClone(game);
assert.equal(player(game, owner).spice, 9);
const decision = game.decision;
assert.ok(decision?.kind === 'choamAudit');
declaration = { type: 'decision', event: decision.event, audit: true };
game = applySemutaHomeworldChoamStep(game, owner, declaration, actions);
}
assert.equal(game.response?.kind, scope === 'income' ? 'choamBattleIncome' : 'choamAudit');
assert.ok(viewGame(game, canceller).responseControls?.cancelCards.includes(karama));
const originalResponse = structuredClone(game), gameBeforeCost = structuredClone(game);
return { scope, initial, setup, beforeBattle, acceptedBattle, afterPaidBattle, nativeIncome,
auditOffer, originalResponse, gameBeforeCost, game, shipments,
actor: canceller, action: { type: 'card', mode: 'cancel', card: karama }, declaration,
card: karama, karama, amount: 1, canceller, owner, holder, winner: canceller,
loser: owner, location: 'red_chasm:7', winnerLeader, loserLeader,
usedIds: [...(game.pendingAuditor?.usedCards ?? [])], unplayedPrivateCard, projectile, snooper,
actions, staging };
}
export function disposeSemutaHomeworldChoamBattleKarama(
f: SemutaHomeworldChoamBattleKaramaFixture, state = f.game, actions?: RicheseSkillsTechStep[],
): Game {
return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
}
export function reactSemutaHomeworldChoamBattleKarama(
state: Game, holder: string, claim: boolean, actions?: RicheseSkillsTechStep[],
): Game {
const pending = state.pendingTreacheryDiscard, reaction = pending?.reaction;
assert.ok(pending && reaction?.stage === 'offer');
const event = pending.batch.event;
if (claim) return applySemutaHomeworldChoamStep(state, holder, { type: 'semutaCommit', event }, actions);
let game = state;
for (const seat of state.players.filter(p => !reaction.passed.includes(p.id)))
game = applySemutaHomeworldChoamStep(game, seat.id, { type: 'semutaPass', event }, actions);
return game;
}
/** Quote real Collection before the final native market close, not a second
* Collection quote on an already credited phase-seven snapshot. */
export function finishSemutaHomeworldChoamBattleKarama(state: Game, actions?: RicheseSkillsTechStep[]) {
let game = advanceSemutaHomeworldChoam(state, g => g.decision?.kind === 'choamMarket', actions);
assert.equal(game.turn, state.turn);
const beforeCollection = structuredClone(game), collection = quoteSpiceCollection(game);
const actor = game.decision!.player, action: Action = { type: 'decision', done: true };
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
game = advanceSemutaHomeworldChoam(game, g => g.phase === 7 && clean(g), actions);
assert.equal(game.turn, state.turn);
return { game, beforeCollection, collection, actor, action };
}
@@ -0,0 +1,269 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import {
createSemutaHomeworldCaptureKaramaFixture, disposeSemutaHomeworldCaptureKarama,
finishSemutaHomeworldCaptureKarama, semutaHomeworldCaptureKaramaResources as resources,
semutaHomeworldCaptureKaramaStock as stock, type SemutaHomeworldCaptureKaramaFixture,
} from './fixture-semuta-homeworld-capture-karama';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'Rejected controls must not mutate the original native source');
}
function originalBattle(f: SemutaHomeworldCaptureKaramaFixture, g: Game) {
const h = player(g, f.captor), e = player(g, f.victim);
assert.equal(g.turn, 1);
assert.equal(g.lastBattleContext?.winner, f.captor);
assert.equal(g.lastBattleContext?.result, 'normal');
assert.equal(g.lastBattleContext?.territory, 'carthag');
assert.equal(g.battle, null);
assert.equal(player(f.setup, f.captor).forces[f.location], 10);
assert.equal(player(f.beforeBattle, f.captor).forces[f.location], 10);
assert.equal(h.forces[f.location], 9);
assert.equal(h.reserves, 10);
assert.equal(h.tanks, 1);
assert.equal(h.battleLosses, player(f.beforeBattle, f.captor).battleLosses + 1);
assert.equal(e.forces[f.location] ?? 0, 0);
assert.equal(e.reserves, 18);
assert.equal(e.tanks, 2);
assert.equal(e.battleLosses, player(f.beforeBattle, f.victim).battleLosses + 2);
assert.equal(h.leaders.find(l => l.id === f.winnerLeader)?.dead, false);
assert.equal(e.leaders.find(l => l.id === f.loserLeader)?.dead, false);
assert.equal(h.spice, player(f.beforeBattle, f.captor).spice - 1,
'Only the original Harkonnen supported dial leaves its wallet; no fabricated bounty is created');
assert.equal(e.spice, player(f.beforeBattle, f.victim).spice,
'The original Emperor payer receives no battle income for this one-spice support');
assert.deepEqual(g.homeworlds?.custody, f.paidShipment.after.homeworlds?.custody);
for (const seat of g.players.filter(p => ![f.captor, f.victim].includes(p.id))) {
const before = player(f.beforeBattle, seat.id);
assert.deepEqual(seat.forces, before.forces);
assert.deepEqual(seat.elites, before.elites);
assert.equal(seat.spice, before.spice);
assert.equal(seat.reserves, before.reserves);
assert.equal(seat.tanks, before.tanks);
}
assert.deepEqual(stock(g), stock(f.setup));
}
function unchangedPrivateSource(f: SemutaHomeworldCaptureKaramaFixture, g: Game) {
for (const seat of g.players) {
assert.deepEqual(seat.leaders, player(f.gameBeforeCost, seat.id).leaders,
'No candidate is sampled, captured, concealed, transferred, killed or rewarded by a canceled capture');
assert.deepEqual(seat.traitors, player(f.gameBeforeCost, seat.id).traitors);
assert.ok(seat.leaders.every(l => !l.capturedBy && !l.concealed));
}
assert.deepEqual(g.traitorReserve, f.gameBeforeCost.traitorReserve);
assert.deepEqual(g.homeworldOccupationHistory, f.gameBeforeCost.homeworldOccupationHistory);
assert.deepEqual(g.homeworldOccupiedIncomeHistory, f.gameBeforeCost.homeworldOccupiedIncomeHistory);
assert.deepEqual(g.homeworldOccupiedPercentageLedger, f.gameBeforeCost.homeworldOccupiedPercentageLedger);
assert.deepEqual(g.tupileIntelligence, f.gameBeforeCost.tupileIntelligence);
assert.deepEqual(g.giediCollection, f.gameBeforeCost.giediCollection);
}
function pausedCost(f: SemutaHomeworldCaptureKaramaFixture, g: Game) {
originalBattle(f, g);
unchangedPrivateSource(f, g);
assert.equal(f.nativecaptureOffer?.decision?.kind, 'captureOffer');
assert.equal(f.nativecaptureOffer?.decision?.player, f.captor);
assert.equal(f.originalresponse?.response?.kind, 'capture');
assert.equal(f.originalresponse?.response?.owner, f.captor);
assert.deepEqual(g.pendingCapture,
{ player: f.captor, loser: f.victim, territory: 'carthag' });
assert.equal(g.response, null, 'Printed cancellation response is detached while Semuta owns controls');
const pending = g.pendingTreacheryDiscard;
assert.ok(pending);
assert.equal(pending.reaction?.stage, 'offer');
assert.equal(pending.continuation.kind, 'karamaBattleDiscard');
assert.equal(pending.batch.cause, 'karama:capture');
assert.deepEqual(pending.batch.entries.map(e => ({ card: e.card.id, player: e.discardedBy, publicFace: e.publicFace })),
[{ card: f.card, player: f.actor, publicFace: true }]);
assert.equal(player(g, f.actor).hand.some(c => c.id === f.card), false);
assert.equal(g.discard.filter(c => c.id === f.card).length, 1);
assert.equal(viewGame(g, f.holder).semutaReaction?.canCommit, true);
assert.equal(g.decision?.kind === 'capturedLeader', false);
assert.deepEqual(resources(g), resources(f.gameBeforeCost));
}
function canceledExchange(f: SemutaHomeworldCaptureKaramaFixture, pending: Game, g: Game, claim: boolean) {
originalBattle(f, g);
unchangedPrivateSource(f, g);
assert.equal(g.pendingCapture, null);
assert.equal(g.response, null);
assert.equal(g.pendingTreacheryDiscard, null);
assert.notEqual(g.decision?.kind, 'capturedLeader');
assert.deepEqual(resources(g), resources(pending));
for (const seat of g.players) {
const expected = player(pending, seat.id).hand.map(c => c.id);
if (seat.id === f.holder && claim) expected.splice(expected.indexOf(SEMUTA_DRUG_ID), 1, f.card);
assert.deepEqual(seat.hand.map(c => c.id).sort(), expected.sort(),
'Only Richese exchanges its actual Semuta for Emperor’s spent printed Karama; no native hand draws');
}
assert.equal(player(g, f.actor).hand.some(c => c.id === f.card), false);
assert.equal(player(g, f.holder).hand.filter(c => c.id === f.card).length, Number(claim));
assert.equal(g.discard.filter(c => c.id === f.card).length, Number(!claim));
assert.equal(g.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.equal(player(g, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim);
}
function finishOnce(f: SemutaHomeworldCaptureKaramaFixture, state: Game) {
const finished = finishSemutaHomeworldCaptureKarama(state);
const expected = resources(finished.beforeCollection);
for (const receipt of finished.collection.receipts)
expected.players.find(p => p.id === receipt.player)!.spice = receipt.balance;
expected.spice = finished.collection.spice;
assert.equal(finished.collection.receipts.find(r => r.player === f.captor)!.strongholds, 2,
'Nine surviving Harkonnen counters on Carthag collect the two original stronghold points');
assert.equal(finished.collection.receipts.find(r => r.player === f.captor)!.desert, 0,
'The winner has no original desert presence to collect');
assert.equal(finished.game.phase, 7);
assert.equal(finished.game.turn, 1);
assert.deepEqual(resources(finished.game), expected,
'Only original Collection balances change: paid shipment, accepted casualties and living leaders never repeat');
assert.deepEqual(stock(finished.game), stock(f.setup));
assert.deepEqual(finished.game.players.map(p => p.hand), state.players.map(p => p.hand));
assert.deepEqual(finished.game.players.map(p => p.traitors), state.players.map(p => p.traitors));
assert.equal(finished.game.pendingCapture, null);
rejected(finished.game, f.captor, { type: 'decision', accept: true });
rejected(finished.game, f.actor, f.action);
return finished.game;
}
for (const fullHolder of [false, true]) for (const claim of [false, true]) {
void test(`Advanced native Homeworld capture cost: ${fullHolder ? 'full four' : 'thin'} Richese ${claim ? 'claim' : 'all-pass'} cancels capture before one original Collection`, () => {
const f = createSemutaHomeworldCaptureKaramaFixture(fullHolder);
const paid = f.paidShipment;
assert.equal(player(paid.before, f.victim).reserves, 20);
assert.equal(player(paid.after, f.victim).reserves, 18);
assert.equal(player(paid.after, f.victim).forces[f.location], 2);
assert.equal(player(paid.after, f.victim).spice, player(paid.before, f.victim).spice - 2);
assert.deepEqual(player(paid.after, f.victim).elites, player(paid.before, f.victim).elites);
const pending = disposeSemutaHomeworldCaptureKarama(f);
pausedCost(f, pending);
const event = pending.pendingTreacheryDiscard!.batch.event;
rejected(pending, f.actor, f.action);
rejected(pending, f.actor, { type: 'passResponse' });
rejected(pending, f.captor, { type: 'decision', accept: true });
rejected(pending, f.captor, { type: 'decision', mode: 'execute' });
rejected(pending, f.captor, { type: 'semutaCommit', event });
rejected(pending, f.actor, { type: 'semutaCommit', event });
rejected(pending, f.holder, { type: 'semutaCommit', event: `${event}-retired` });
rejected(pending, f.holder, { type: 'semutaSelect', event, card: f.card });
const acknowledged = applyAction(pending, f.actor, { type: 'semutaPass', event });
assert.deepEqual(resources(acknowledged), resources(pending));
assert.equal(viewGame(acknowledged, f.holder).semutaReaction?.canCommit, true);
rejected(acknowledged, f.actor, { type: 'semutaPass', event });
const done = reactSemutaHomeworldChoam(claim ? acknowledged : pending, f.holder, claim);
canceledExchange(f, pending, done, claim);
assert.equal(player(done, f.holder).hand.length, fullHolder ? handLimit(player(done, f.holder)) : 1);
for (const action of [{ type: 'semutaCommit', event }, { type: 'semutaPass', event },
{ type: 'semutaSelect', event, card: f.card }, f.action]) rejected(done, f.holder, action);
const collected = finishOnce(f, done);
rejected(collected, f.holder, { type: 'semutaCommit', event });
});
}
void test('Native capture cost private forks expose only the holder’s current entitlement, not leaders or Giedi/Traitor custody', () => {
const f = createSemutaHomeworldCaptureKaramaFixture(true);
const absent = withoutSemutaHomeworldChoamMovementHolder(f.gameBeforeCost, f.holder);
const pending = disposeSemutaHomeworldCaptureKarama(f);
const neutral = disposeSemutaHomeworldCaptureKarama(f, absent);
pausedCost(f, pending);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
for (const seat of pending.players.filter(p => p.id !== f.holder)) {
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
assert.deepEqual(JSON.parse(JSON.stringify(viewGame(pending, seat.id))),
JSON.parse(JSON.stringify(viewGame(neutral, seat.id))),
'A conserved hidden Richese card replacement cannot reveal entitlement to another actor');
assert.deepEqual(viewGame(pending, seat.id).semutaReaction?.candidates, []);
}
const event = neutral.pendingTreacheryDiscard!.batch.event;
rejected(neutral, f.holder, { type: 'semutaCommit', event });
const claimed = reactSemutaHomeworldChoam(pending, f.holder, true);
const passed = reactSemutaHomeworldChoam(neutral, f.holder, false);
assert.deepEqual(resources(claimed), resources(passed));
canceledExchange(f, pending, claimed, true);
finishOnce(f, claimed);
finishOnce(f, passed);
});
void test('Native capture cost refuses changed source, original owner, casualty context and clock without granting a claim', () => {
const f = createSemutaHomeworldCaptureKaramaFixture();
const pending = disposeSemutaHomeworldCaptureKarama(f);
const event = pending.pendingTreacheryDiscard!.batch.event;
const mutations: ((g: Game) => void)[] = [
g => { g.pendingCapture!.player = f.victim; },
g => { g.pendingCapture!.loser = 'a'; },
g => { g.pendingCapture!.territory = 'arrakeen'; },
g => { g.pendingCapture = null; },
g => { g.phase = 7; },
g => { g.advanced = false; },
g => { player(g, f.captor).forces[f.location]++; },
g => { player(g, f.victim).tanks--; },
g => { g.lastBattleContext!.winner = f.victim; },
g => { g.lastBattleContext!.territory = 'arrakeen'; },
g => {
const c = g.pendingTreacheryDiscard!.continuation;
assert.equal(c.kind, 'karamaBattleDiscard');
if (c.kind !== 'karamaBattleDiscard') throw Error('Expected native printed cost');
c.use.response.owner = f.victim;
},
g => { g.pendingTreacheryDiscard!.batch.entries[0].discardedBy = f.captor; },
];
for (const mutate of mutations) {
const changed = structuredClone(pending);
mutate(changed);
rejected(changed, f.holder, { type: 'semutaCommit', event });
rejected(changed, f.actor, { type: 'semutaPass', event });
}
pausedCost(f, pending);
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: legal minimum original capture-cost consumer retires the reward without capture`, () => {
const f = createSemutaHomeworldCaptureKaramaFixture(true);
const pending = disposeSemutaHomeworldCaptureKarama(f);
pausedCost(f, pending);
const view = viewGame(pending, f.holder);
view.players.find(p => p.id === f.holder)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action);
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass');
let game = applyAction(pending, f.holder, action);
if (game.pendingTreacheryDiscard) {
const event = game.pendingTreacheryDiscard.batch.event;
for (const seat of game.players) {
if (!game.pendingTreacheryDiscard) break;
const reaction = game.pendingTreacheryDiscard.reaction;
assert.ok(reaction?.stage === 'offer');
if (!reaction.passed.includes(seat.id)) game = applyAction(game, seat.id, { type: 'semutaPass', event });
}
}
canceledExchange(f, pending, game, action.type === 'semutaCommit');
finishOnce(f, game);
});
void test('Basic companion original Homeworld battle never produces native capture or a fabricated cancel response', () => {
const f = createSemutaHomeworldCaptureKaramaFixture(false, false);
assert.equal(f.acceptedBattle.pendingCapture, null);
assert.equal(f.nativecaptureOffer, null);
assert.equal(f.originalresponse, null);
assert.equal(f.game.pendingCapture, null);
assert.equal(f.game.phase, 7);
assert.equal(f.game.lastBattleContext?.winner, f.captor);
assert.equal(player(f.game, f.captor).forces[f.location], 9);
assert.equal(player(f.game, f.victim).tanks, 2);
assert.deepEqual(f.game.players.map(p => p.leaders), f.acceptedBattle.players.map(p => p.leaders));
assert.equal(f.game.discard.some(c => c.id === f.card), false);
assert.equal(player(f.game, f.actor).hand.some(c => c.id === f.card), true);
rejected(f.game, f.actor, f.action);
rejected(f.game, f.captor, { type: 'decision', accept: true });
assert.deepEqual(stock(f.game), stock(f.setup));
});
@@ -0,0 +1,207 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, handLimit, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { treacheryDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { auditCandidates } from '../game/choam-auditor';
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
import { quoteSpiceCollection } from '../game/board-resolution-quote';
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
import { semutaHomeworldWinnerStock as stock } from './fixture-semuta-homeworld-battle-winner';
import { withoutSemutaHomeworldChoamMovementHolder } from './fixture-semuta-homeworld-choam-movement';
import {
createSemutaHomeworldChoamBattleKaramaFixture as create,
disposeSemutaHomeworldChoamBattleKarama as dispose,
reactSemutaHomeworldChoamBattleKarama as react,
finishSemutaHomeworldChoamBattleKarama as finish,
type SemutaHomeworldChoamBattleKaramaFixture,
} from './fixture-semuta-homeworld-choam-battle-karama';
function resources(game: Game) {
return { custody: structuredClone(game.homeworlds?.custody),
players: game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves,
tanks: p.tanks, forces: structuredClone(p.forces), elites: structuredClone(p.elites),
leaders: structuredClone(p.leaders), battleLosses: p.battleLosses,
revived: p.revived, freeForcesRevived: p.freeForcesRevived, ally: p.ally })) };
}
function assertOriginalEconomics(f: SemutaHomeworldChoamBattleKaramaFixture) {
assert.equal(f.setup.advanced, true);
assert.deepEqual(stock(f.game), [...treacheryDeck(['choam']), ...richeseCards()].map(c => c.id).sort());
for (const [index, amount] of [4, 1].entries()) {
const shipment = f.shipments[index];
assert.equal(player(shipment.before, shipment.actor).spice, 10);
assert.equal(player(shipment.after, shipment.actor).spice, 10 - 2 * amount);
assert.equal(player(shipment.after, shipment.actor).reserves, player(shipment.before, shipment.actor).reserves - amount);
assert.equal(player(shipment.after, shipment.actor).forces[f.location], amount);
assert.deepEqual(player(shipment.after, 'a').forces, player(f.setup, 'a').forces);
assert.deepEqual(player(shipment.after, 'r').forces, player(f.setup, 'r').forces);
}
const state = f.acceptedBattle, battle = state.battle!;
const combatant = (id: string): ResolutionCombatant => {
const p = player(state, id), plan = battle.plans[id];
return { id, faction: p.faction, ally: p.ally, spice: p.spice, hand: p.hand,
plan, leader: p.leaders.find(l => l.id === plan.leader),
forces: { normal: p.forces[f.location], elite: 0, normalFixedHalf: false,
eliteStrength: 2, freeSupport: false } };
};
const quote = quoteBattleResolution({ advanced: true, typedCasualties: true,
turn: state.turn, territory: battle.territory,
attacker: combatant(battle.attacker), defender: combatant(battle.defender),
voters: viewGame(state, state.host).battle!.traitorVoters.map(id => ({ id,
beneficiary: id, called: false, traitors: player(state, id).traitors })),
participants: state.players, physicalCards: [state.deck, state.discard,
state.richeseCache ?? [], state.richeseRemoved ?? [], ...state.players.map(p => p.hand)].flat(),
pendingAuditorPresent: false, pendingRetentionPresent: false });
assert.equal(quote.result, 'normal');
assert.equal(quote.winner, f.canceller);
assert.deepEqual(quote.payments.find(p => p.player === f.canceller), {
player: f.canceller, cost: 2, ownPayment: 2, allyPayment: 0,
donor: null, bankSupport: 0, freeByTraitor: false });
assert.equal(quote.payments.find(p => p.player === f.owner)!.ownPayment, 0);
assert.deepEqual(quote.choamIncome, { owner: f.owner, amount: 1 });
assert.deepEqual(f.nativeIncome.pendingChoamBattleIncome, quote.choamIncome);
assert.equal(player(f.afterPaidBattle, f.canceller).spice, 2 - 2 + (quote.bounty?.amount ?? 0));
assert.equal(player(f.afterPaidBattle, f.canceller).forces[f.location], 2);
assert.equal(player(f.afterPaidBattle, f.canceller).tanks, player(f.beforeBattle, f.canceller).tanks + 2);
assert.equal(player(f.afterPaidBattle, f.owner).forces[f.location] ?? 0, 0);
assert.equal(player(f.afterPaidBattle, f.owner).tanks, player(f.beforeBattle, f.owner).tanks + 1);
assert.deepEqual(f.afterPaidBattle.lastBattle, [f.canceller, f.owner]);
assert.equal(f.afterPaidBattle.lastBattleContext!.winner, f.canceller);
assert.equal(player(f.afterPaidBattle, f.canceller).leaders.find(l => l.id === f.winnerLeader)!.strength, 6);
assert.equal(player(f.afterPaidBattle, f.owner).leaders.find(l => l.id === f.loserLeader)!.dead, f.scope === 'audit');
if (f.scope === 'audit') {
assert.deepEqual(quote.bounty, { player: f.canceller, amount: 2 });
assert.equal(f.game.pendingAuditor!.survived, false);
assert.equal(player(f.game, f.owner).spice, 9);
assert.equal(player(f.nativeIncome, f.owner).spice, 8);
assert.deepEqual(f.usedIds, [f.projectile]);
assert.ok(player(f.game, f.canceller).hand.some(c => c.id === f.projectile));
assert.deepEqual(auditCandidates(player(f.game, f.canceller).hand, f.usedIds).map(c => c.id).sort(),
[f.card, f.unplayedPrivateCard].sort());
assert.equal(viewGame(f.game, f.owner).auditor?.count, 1);
}
}
function assertDenied(f: SemutaHomeworldChoamBattleKaramaFixture, game: Game, claim: boolean) {
assert.ok(!game.pendingTreacheryDiscard);
assert.ok(!game.pendingChoamBattleIncome);
assert.ok(!game.pendingAuditor);
assert.ok(!game.auditorInsight);
assert.ok(!game.response);
assert.equal(game.decision?.kind, 'choamMarket');
assert.equal(game.decision!.player, f.owner);
assert.deepEqual(resources(game), resources(f.gameBeforeCost));
assert.deepEqual(stock(game), stock(f.setup));
assert.deepEqual(game.lastBattleContext, f.gameBeforeCost.lastBattleContext);
assert.equal(player(game, f.owner).spice, f.scope === 'income' ? 8 : 9);
assert.equal(player(game, f.canceller).spice, f.scope === 'income' ? 0 : 2);
assert.equal(player(game, f.canceller).hand.some(c => c.id === f.card), false);
assert.equal(player(game, f.holder).hand.some(c => c.id === f.card), claim);
assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim);
assert.equal(game.discard.filter(c => c.id === f.card).length, Number(!claim));
assert.equal(game.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.ok(player(game, f.canceller).hand.some(c => c.id === f.unplayedPrivateCard));
if (f.projectile) assert.ok(player(game, f.canceller).hand.some(c => c.id === f.projectile));
if (f.snooper) assert.equal(game.discard.filter(c => c.id === f.snooper).length, 1);
assert.throws(() => applyAction(game, f.canceller, f.action));
if (f.declaration) assert.throws(() => applyAction(game, f.owner, f.declaration!));
if (claim) assert.throws(() => applyAction(game, f.holder, f.action), 'Recovered Karama cannot replay a completed reward');
}
function assertCollection(f: SemutaHomeworldChoamBattleKaramaFixture, state: Game) {
const completed = finish(state);
assert.equal(completed.beforeCollection.decision!.player, f.owner);
assert.equal(completed.game.phase, 7);
assert.equal(completed.game.turn, state.turn);
for (const receipt of completed.collection.receipts)
assert.equal(player(completed.game, receipt.player).spice, receipt.balance);
assert.equal(completed.collection.receipts.find(r => r.player === f.canceller)!.collected, 4);
assert.equal(player(completed.game, f.owner).spice, f.scope === 'income' ? 8 : 9);
assert.deepEqual(completed.game.spice, completed.collection.spice);
assert.deepEqual(stock(completed.game), stock(state));
for (const seat of completed.game.players) {
assert.deepEqual(seat.forces, player(state, seat.id).forces);
assert.equal(seat.reserves, player(state, seat.id).reserves);
assert.equal(seat.tanks, player(state, seat.id).tanks);
}
assert.throws(() => applyAction(completed.game, completed.actor, completed.action), 'Original market close cannot credit Collection twice');
}
for (const scope of ['income', 'audit'] as const) {
for (const full of [false, true]) for (const claim of [false, true])
void test(`Advanced Homeworld CHOAM ${scope}: ${full ? 'full' : 'thin'} Richese ${claim ? 'claims' : 'passes'} paid Emperor Karama before native denial`, () => {
const f = create(scope, full);
assertOriginalEconomics(f);
const pending = dispose(f), cost = pending.pendingTreacheryDiscard;
assert.ok(cost, 'Actual printed cancellation cost pauses before denying the original reward');
assert.equal(cost.continuation.kind, 'karamaBattleDiscard');
assert.deepEqual(cost.batch.entries.map(c => ({ card: c.card.id, owner: c.discardedBy })), [{ card: f.card, owner: f.canceller }]);
assert.equal(pending.discard.filter(c => c.id === f.card).length, 1);
assert.deepEqual(resources(pending), resources(f.gameBeforeCost));
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
assert.throws(() => applyAction(pending, f.canceller, f.action));
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', done: true }));
assert.throws(() => applyAction(pending, f.owner, { type: 'ready' }));
assert.throws(() => applyAction(pending, f.owner, { type: 'semutaCommit', event: cost.batch.event }));
assert.throws(() => applyAction(pending, f.holder, { type: 'semutaCommit', event: `${cost.batch.event}-stale` }));
const done = react(pending, f.holder, claim);
assertDenied(f, done, claim);
if (full) assert.equal(player(done, f.holder).hand.length, handLimit(player(done, f.holder)));
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }));
assert.throws(() => applyAction(done, f.owner, { type: 'semutaPass', event: cost.batch.event }));
assertCollection(f, done);
});
for (const full of [false, true]) void test(`Advanced Homeworld CHOAM ${scope}: ${full ? 'full' : 'thin'} private original Semuta entitlement never leaks to other seats`, () => {
const f = create(scope, full), pending = dispose(f);
const neutral = dispose(f, withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder));
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
for (const seat of pending.players.filter(p => p.id !== f.holder)) {
assert.deepEqual(viewGame(pending, seat.id), viewGame(neutral, seat.id));
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
assert.throws(() => applyAction(pending, seat.id, { type: 'semutaCommit', event: pending.pendingTreacheryDiscard!.batch.event }));
}
const claimed = react(pending, f.holder, true), passed = react(neutral, f.holder, false);
assertDenied(f, claimed, true);
assert.ok(!passed.pendingTreacheryDiscard && !passed.response && !passed.pendingAuditor && !passed.auditorInsight);
assert.equal(passed.decision?.kind, 'choamMarket');
assert.equal(passed.discard.filter(c => c.id === f.card).length, 1);
assert.equal(player(passed, f.holder).hand.some(c => c.id === f.card), false);
assert.deepEqual(resources(claimed), resources(passed));
if (scope === 'audit') {
assert.deepEqual(auditCandidates(player(claimed, f.canceller).hand, f.usedIds).map(c => c.id), [f.unplayedPrivateCard]);
assert.ok(!viewGame(claimed, f.owner).auditor);
assert.ok(!viewGame(claimed, f.owner).auditorInsight);
}
assertCollection(f, claimed);
});
for (const difficulty of DIFFICULTIES) void test(`${difficulty} legally handles Advanced Homeworld ${scope} cancellation then original CHOAM market`, () => {
const f = create(scope), pending = dispose(f), own = viewGame(pending, f.holder);
own.players.find(p => p.id === f.holder)!.bot = difficulty;
const action = botActions(own)[0];
assert.ok(action && (action.type === 'semutaCommit' || action.type === 'semutaPass'));
let game = applyAction(pending, f.holder, action);
if (game.pendingTreacheryDiscard) game = react(game, f.holder, false);
assertDenied(f, game, action.type === 'semutaCommit');
const closing = viewGame(game, f.owner);
closing.players.find(p => p.id === f.owner)!.bot = difficulty;
const close = botActions(closing)[0];
assert.ok(close);
const collection = quoteSpiceCollection(game);
const completed = applyAction(game, f.owner, close);
assert.equal(completed.phase, 7);
assert.equal(player(completed, f.owner).spice, f.scope === 'income' ? 8 : 9);
assert.equal(player(completed, f.canceller).forces[f.location], 2);
assert.equal(player(completed, f.owner).tanks, 1);
for (const receipt of collection.receipts)
assert.equal(player(completed, receipt.player).spice, receipt.balance);
assert.deepEqual(completed.spice, collection.spice);
assert.deepEqual(completed.lastBattleContext, game.lastBattleContext);
});
}
+1
View File
@@ -32,6 +32,7 @@ async function main() {
'\n--profile semuta --nexus-cards retains original all12 Nexus in fresh native Richese CHOAM/optional Ix lobbies, optionally --leader-skills, original Tech3+ and Advanced Strongholds2+. Single/paired CHOAM or both selected E1/E2 native families retain original prerequisites. No starting Nexus hand is added: a qualifying actual end-Spice draw follows a Nexus and settled alliance. CHOAM Secret Ally spends a Worthless for two bank spice only with CHOAM absent; CHOAM Betrayal spends its Nexus to discard one server-sampled native CHOAM hand card without compensation. Semuta claim/pass retires the original receipt once, after the committed fresh discard. No Homeworld, Discovery, E3, retrofit or public gate opens.',
'\n--profile semuta --homeworlds uses original fresh Basic/Advanced Richese with CHOAM and classic factions, exact CHOAM deck, at2–6 seats. Native occupation setup/history remain original. Paid Box, Distrans, definite Truthtrance, ordinary specials, Kaitain, Tupile cleanup and legal CHOAM market sales offer used cards before their original suffix. Final Ornithopter disposal keeps completed original groups; finite Once Around and clean unstarted movement Sapho disposal keeps the reordered queue/funding. Accepted losing-card, mandatory winning-card and selected winner disposal remain separate fresh events, preserving paid deployments, casualties and original rewards without replaying combat. Clean Residual Poison keeps one native random death without bounty; early Sapho aggressor and remaining Battle chooser costs keep original participants and finite priority. Native printed CHOAM costs pause before their effect; printed Karama canceling these powers or native Guild/Emperor/Richese income is separately offered before denial. Printed CHOAM opening-income/Inflation cancellation and normal-lot Karama purchase/free-payment costs also pause before their original denial, spent attempt or zero-spice acquisition. Retained fuel/witness and credited sale spice remain unchanged. No repeated move/return/storm/payout/refund is introduced. Kull, Hajr/prior-flight integration, unresolved cyclic/partial/Guild/late Sapho timing, readied/inspected Poison costs, Advanced Harkonnen captive disclosure, other producers/modules, retrofit and public starts remain guarded.',
'\nThe same Homeworld Semuta entry also offers printed Karama canceling native Advanced BG extra charity and genuine allied Emperor gift/extra revival. The consumed charity claim, original ally, paid deployment/casualties, wallets and unawarded revival remain committed. Wealthy Basic BG keeps its ordinary-charity guard; no new benefit or profile is granted.',
'\nPrinted native Homeworld capture/Auditor/force-income Karama cancellation costs also offer their card. Accepted combat, paid support, leader death and original casualty/cleanup remain committed; the canceled reward never samples, transfers, inspects, refunds or credits. Earlier allowed income stays intact before a separately canceled audit.',
'\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Advanced uses distinct selected ecaz/ix/choam decks; Basic uses the exact ecaz deck with classic/optional Moritani opponents. Ordinary33/47/35 cards retain real original offers/HMS/Auditor/Face Dancers and Richese cache. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38 (Advanced only). Basic combined battles require an even Ecaz force count; the preserved publisher odd-force casualty-rounding conflict rejects before any lead choice. Native Richese ordinary and marker-only pools compose with selected lead plans; original own mixed No-Field dialing, whole-plan disclosure and optional overlays remain guarded.' +
'\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. Banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.',
'\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' +