Compose original Homeworld battle card disposal with Semuta
This commit is contained in:
1 parent
4966b1d062
commit
fbf77d0f80
12 files changed
+953
-2
No files matched your search
@@ -1174,6 +1174,9 @@ export function GameTable({
|
||||
Final Ornithopter disposal keeps the completed original groups;
|
||||
finite Once Around and clean unstarted movement Sapho costs keep
|
||||
the changed queue before its original next actor continues.
|
||||
Accepted losing-card, mandatory winning-card and selected winner
|
||||
disposal are separate fresh events. Recovery keeps the original
|
||||
casualties and paid native deployments; combat does not repeat.
|
||||
Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba
|
||||
costs also pause before their original effect. Printed Karama
|
||||
canceling these native powers or Guild, Emperor auction or Richese
|
||||
|
||||
@@ -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 / original Homeworld battle costs — 13 October.**
|
||||
Accepted losing-card, later mandatory winning-card and optional winner-selected
|
||||
disposal now offer separate fresh events. Six genuine before programs had no
|
||||
offer; fourteen parent claim/pass/exact-choice programs preserve paid native
|
||||
deployments, accepted combat and original casualty/cleanup order. Four further
|
||||
positive-deposit programs preserve six killed-leader income and collect six
|
||||
from the original eight-spice blow once: CHOAM8→14 Basic,7→13 Advanced.
|
||||
Three actual390px controls recover a losing Maula Pistol, mandatory Cheap Hero
|
||||
and selected winner Crysknife at Richese4/4; original winner cards and native
|
||||
Collection remain usable. Artifact: `/tmp/dune-homeworld-native-battle-3220.json`.
|
||||
No other timing/module/signature/price or public gate changes; local staged
|
||||
proof is not deployed/save/natural-full-game acceptance.
|
||||
Types/lint,118/118 across five focused files (48 new cases) and the application
|
||||
build pass. Initial fixture union-narrowing and comparator lint errors were
|
||||
repaired; deprecation, large chunks and static route-classification warnings
|
||||
remain recorded, not suppressed.
|
||||
|
||||
**Semuta / native Homeworld flight and finite order — 13 October.**
|
||||
Final Ornithopter and clean Sapho Once Around/Movement order costs now
|
||||
offer their used card. Six flight/eight order before programs had no offer;
|
||||
|
||||
@@ -38,6 +38,12 @@ and physical storm order. Recovery only releases the same next native actor.
|
||||
Hajr/prior flight, active Homeworld transfers, advisors, cyclic auctions,
|
||||
partly completed turns, first-versus-Guild and late battle timing remain guarded.
|
||||
|
||||
Public losing-card, mandatory winning-card and optional selected-winner
|
||||
disposal likewise offer separate fresh battle events. Original paid native
|
||||
deployments and accepted combat remain committed; recovery restores original
|
||||
casualties/cleanup, then normal Collection can credit its real board income.
|
||||
It does not refund support, revive a killed leader or rerun a battle.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,62 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 13 October 2026 — original Homeworld battle discard producers
|
||||
|
||||
The Homeworld Semuta gate now admits only the existing `battleResolved`,
|
||||
`winnerMandatoryDiscard` and `battleCleanup` producers in addition to its
|
||||
previous costs. Their original source/reward/queue/casualty validators remain;
|
||||
no signature, combat calculation, profile, payment or publication cutover.
|
||||
|
||||
Two genuine losing-batch before programs use actual Emperor4 and CHOAM1
|
||||
native shipments to Red Chasm7, priced8/2. Both sides play distinct original
|
||||
projectile/Shield pairs; accepted Emperor victory discards CHOAM's two played
|
||||
cards without an offer under the old gate. Four genuine winning-cost before
|
||||
programs use Emperor1/CHOAM4 shipments priced2/8, actual CHOAM projectile and
|
||||
printed Hero or original leader, and the real Emperor Worthless. Native killed
|
||||
Hasimir Fenring6 provides six original income. Mandatory Hero and later selected
|
||||
projectile likewise disposed without Semuta before admission.
|
||||
|
||||
Fourteen parent after programs exercise both rules modes, each losing-card
|
||||
choice or pass, and mandatory/selected winner claim/pass at Richese's full hand.
|
||||
All reach native Collection with original three winner survivors/reserves16/
|
||||
Tanks1 and one loser in Tanks/reserves19. Exact discarded custody changes only
|
||||
for the chosen physical card; accepted combat and leader death do not repeat.
|
||||
Four additional parent programs deliberately position the original Red Chasm
|
||||
Spice Card first, producing the printed eight-spice deposit. Original post-
|
||||
cleanup Collection takes six once, leaving two; CHOAM8→14 Basic and7→13 Advanced.
|
||||
|
||||
The first parent after probe incorrectly expected the battle's8-spice balance
|
||||
to remain8 after Collection and failed14≠8. That was a proof error, not a
|
||||
production income defect. The new winner tests had the same constant-resource
|
||||
assumption; they now use the shared native Collection quote and a deterministic
|
||||
physical eight-spice blow, preserving original resources except that legitimate
|
||||
credit. Actor/source trace-count echoes were removed, not re-pinned.
|
||||
|
||||
Three actual390px controls resolve the real final Traitor pass or selected
|
||||
winner disposal, commit Semuta at4/4, and use original cleanup/market controls to
|
||||
Collection. Losing Maula Pistol and Shield appear only in the committed exact
|
||||
selection; mandatory Hero follows all four passes of the earlier losing
|
||||
Worthless; the selected Crysknife leaves the original Shield held. No accepted
|
||||
combat or phase closure was replayed. Artifact outside Git:
|
||||
`/tmp/dune-homeworld-native-battle-3220.json`.
|
||||
|
||||
Owned QA hosting initially returned404 because middleware followed Vite's
|
||||
terminal handler, then failed real `next/image` resolution and missing Vinext
|
||||
environment definitions. The temporary server used the actual installed image
|
||||
shim and ordinary development definitions, not a replacement component.
|
||||
Optimizer aborts remain recorded. The optional local action-notice toggle was
|
||||
turned off through its normal control after the first battle notice. One fresh
|
||||
DOM observation failed after the selected-card phase closure; server state
|
||||
showed phase7 and all three original actions accepted, so none was repeated.
|
||||
Only owned services/tab/root files were stopped/removed. No saved room,
|
||||
deployment, natural full game or completed Homeworld mode is certified.
|
||||
Frozen verification: types and lint pass; five focused unit files pass118/118,
|
||||
including48 new consumer cases. Initial TypeScript failures were fixture
|
||||
reaction/chooser/amount narrowing; the subsequent lint failure required
|
||||
explicit two-card comparison. Only the failed check dependency was retried.
|
||||
The application build passes with module.register deprecation, client chunks
|
||||
over500kB and Vinext static route-classification limitations unchanged.
|
||||
|
||||
## 13 October 2026 — Homeworld carried-flight and finite Sapho disposal
|
||||
|
||||
The producer gate additionally admits existing `ornithopterDiscard`,
|
||||
|
||||
@@ -855,6 +855,27 @@ 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 public battle disposal.**
|
||||
`battleResolved`, `winnerMandatoryDiscard` and winner `battleCleanup` now
|
||||
offer their existing fresh events in this profile. The first losing batch,
|
||||
later mandatory winning card and optional selected winner cards stay separate.
|
||||
At most one other player's exact fresh card is acquired; retained winner cards
|
||||
are not candidates. Original paid deployments, result, casualties, leader deaths
|
||||
and reward order remain committed. No new timing or combat ruling follows.
|
||||
|
||||
Six genuine Basic/Advanced before programs disposed without an offer.
|
||||
Fourteen parent after programs exercise both losing-card choices/pass and
|
||||
mandatory/selected winner claim/pass. Three actual390px flows recover Maula
|
||||
Pistol, Cheap Hero or selected Crysknife at4/4 through original owner controls
|
||||
to Collection. Four further programs verify a real eight-spice native blow,
|
||||
three survivors collecting six once and original six-spice killed-leader income:
|
||||
CHOAM8→14 Basic/7→13 Advanced. The initial constant-balance proof failed because
|
||||
it omitted legitimate Collection; production income was not changed.
|
||||
Artifact: `/tmp/dune-homeworld-native-battle-3220.json`.
|
||||
Temporary hosting404/image/environment errors, optimizer aborts and a fresh
|
||||
DOM-observation failure are recorded in implementation evidence. No accepted
|
||||
action was replayed; no saved/deployed/full-mode claim is made.
|
||||
|
||||
**13 October 2026 — original Homeworld carried-flight and finite-order costs.**
|
||||
The existing profile now admits final `ornithopterDiscard`, clean
|
||||
`saphoAuctionDiscard` and `saphoMovementDiscard`. Native range, cohort,
|
||||
|
||||
+4
-1
@@ -6961,7 +6961,10 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'choamSaleDiscard' &&
|
||||
continuation.kind !== 'ornithopterDiscard' &&
|
||||
continuation.kind !== 'saphoAuctionDiscard' &&
|
||||
continuation.kind !== 'saphoMovementDiscard') return false;
|
||||
continuation.kind !== 'saphoMovementDiscard' &&
|
||||
continuation.kind !== 'battleResolved' &&
|
||||
continuation.kind !== 'winnerMandatoryDiscard' &&
|
||||
continuation.kind !== 'battleCleanup') return false;
|
||||
if (continuation.kind === 'battleResolved' &&
|
||||
!g.lastBattleContext?.mandatoryDiscard) return false;
|
||||
if ((continuation.kind === 'battleCleanup' ||
|
||||
|
||||
@@ -1189,6 +1189,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Paid Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid Kaitain disposal, Tupile cleanup and printed Karama canceling native Guild/Emperor/Richese-purchase income offer Semuta. Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba costs also pause before their original effect: claim/pass adds one movement bonus, denies free allowance without charging/moving forces, stops the uncommitted Baliset move, returns one original board counter before the same CHOAM close, or applies protection before the same recorded storm moves. Printed Karama canceling these powers pauses separately before denial; the retained Worthless fuel is not a candidate. A canceled reactive Baliset resumes its original declared move once; canceled Kulon/La La La/Gamont/Jubba do not apply their effects. A recovered card grants no other faction CHOAM power. Kaitain private groups require commitment before selection; each Tupile holder has a separate cost. Native income denial retains payment and the original occupied-share allocation. Basic unresolved epochs, Kull, other producers and public starts remain guarded.',
|
||||
'Legal completed CHOAM market sales also offer their used card in the Homeworld profile. Worthless cashout keeps its original two-spice credit; a non-Worthless duplicate keeps its three-spice credit and the original same-name witness stays held. The high-Tupile Worthless-sale prohibition still applies, including duplicate Worthless sales. Claim/pass cannot repeat payout, undo the actual reserve shipment or replace the original market close; no new bank-income interpretation is made.',
|
||||
'Final Ornithopter disposal also offers its used card in the Homeworld profile after the one-group range-three or original two-group movement completes, including an early end with no useful original group. Claim/pass cannot move those counters again or replay the paid native shipment; prior-move/Hajr, advisor and Homeworld-transfer boundaries remain. Clean Sapho Once Around and unstarted Movement first/last costs likewise offer after reordering the finite unacted queue. Earlier bids/funding and physical storm order stay committed, and the same next bidder or combined turn resumes once. Cyclic auctions, partly completed turns, first-versus-Guild and late battle timing are not opened.',
|
||||
'Original Homeworld battle disposal offers accepted losing cards, later mandatory winning cards and optional winner-selected cards as separate fresh events. Commit and exact selection recover at most one other player’s newly discarded card; unselected cards stay in the pile. The original paid deployments, accepted result, leader deaths and casualty obligations remain committed, with the same native winner cleanup and rewards afterward. A recovered card cannot replay combat or consume another original opportunity.',
|
||||
'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.',
|
||||
@@ -1271,6 +1272,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-homeworld-choam-sale.test.ts',
|
||||
'tests/semuta-homeworld-ornithopter.test.ts',
|
||||
'tests/semuta-homeworld-sapho.test.ts',
|
||||
'tests/semuta-homeworld-battle-loser.test.ts',
|
||||
'tests/semuta-homeworld-battle-winner.test.ts',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { splitLocation } from '../game/board';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
|
||||
createSemutaHomeworldChoamSetup } from './fixture-semuta-homeworld-choam';
|
||||
import { moveSemutaEmperorCard, semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
|
||||
export type SemutaHomeworldLoserFixture = {
|
||||
setup: Game; game: Game; beforeBattle: Game; owner: string; winner: string; holder: string;
|
||||
location: string; cards: string[]; winnerCards: string[]; action: Action; actor: string; support: number;
|
||||
winnerLeader: string; loserLeader: string; actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
shipments: { actor: string; action: Action; before: Game; after: Game; price: number }[];
|
||||
};
|
||||
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
|
||||
|
||||
/** Native continuation, except that the real end-of-Bidding gate is answered
|
||||
* with the entitled seat's own spending-free ready mode so no wallet is
|
||||
* decided by an unrelated generic bot policy. */
|
||||
function advance(state: Game, until: (game: Game) => boolean, actions: RicheseSkillsTechStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; !until(game) && n < 1600; n++) {
|
||||
assert.notEqual(game.status, 'finished', 'The original Homeworld game must reach the requested boundary');
|
||||
const end = game.biddingEnd;
|
||||
const seat = end && clean(game)
|
||||
? end.owners.find(id => !end.ready.includes(id)) : undefined;
|
||||
if (seat) {
|
||||
game = applySemutaHomeworldChoamStep(game, seat,
|
||||
{ type: 'biddingEnd', event: end!.event, mode: 'ready' }, actions);
|
||||
continue;
|
||||
}
|
||||
game = advanceSemutaHomeworldChoam(game, g => until(g) || !!g.biddingEnd, actions);
|
||||
}
|
||||
assert.ok(until(game), 'The native Homeworld continuation must reach its boundary');
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Original completed native setup; only printed hands and unplayed Spice order
|
||||
* are positioned. Every force, wallet, battle control and casualty is native. */
|
||||
export function createSemutaHomeworldLoserFixture(advanced = false, fullHolder = false): SemutaHomeworldLoserFixture {
|
||||
const native = createSemutaHomeworldChoamSetup(advanced);
|
||||
let game = native.game;
|
||||
const setup = structuredClone(game), actions = native.actions;
|
||||
const holder = native.holder, owner = native.owner, winner = native.canceller;
|
||||
const location = 'red_chasm:7';
|
||||
const staging = ['Conserved original Semuta in Richese hand.'];
|
||||
staging.push('Answer the real end-of-Bidding gate with each entitled seat\'s own spending-free ready mode.');
|
||||
const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 2);
|
||||
assert.equal(blows.length, 2);
|
||||
for (const [position, card] of blows.entries())
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
staging.push('Position two original unplayed non-worm Spice Cards.');
|
||||
const shipments: SemutaHomeworldLoserFixture['shipments'] = [];
|
||||
for (const [actor, amount, price] of [[winner, 4, 8], [owner, 1, 2]] as const) {
|
||||
game = advance(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, 'Actual native custody must supply the normal shipment');
|
||||
const action: Action = { type: 'ship', ...splitLocation(location), amount, elite: 0,
|
||||
homeworldSources: sources, allyPayment: 0 };
|
||||
const before = structuredClone(game);
|
||||
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
|
||||
game = advance(game, clean, actions);
|
||||
assert.equal(player(game, actor).spice, player(before, actor).spice - price);
|
||||
assert.equal(player(game, actor).reserves, player(before, actor).reserves - amount);
|
||||
assert.equal(player(game, actor).forces[location], amount);
|
||||
shipments.push({ actor, action, before, after: structuredClone(game), price });
|
||||
game = applySemutaHomeworldChoamStep(game, actor, { type: 'endMovement' }, actions);
|
||||
}
|
||||
game = advance(game, g => g.phase === 6 && clean(g), actions);
|
||||
const allocated: string[] = [];
|
||||
const give = (actor: string, kind: 'projectile' | 'shield') => {
|
||||
const card = treacheryDeck(game.expansions).find(c => c.kind === kind && !allocated.includes(c.id));
|
||||
assert.ok(card);
|
||||
allocated.push(card.id);
|
||||
return moveSemutaEmperorCard(game, card.id, actor);
|
||||
};
|
||||
const winnerCards = [give(winner, 'projectile'), give(winner, 'shield')];
|
||||
const cards = [give(owner, 'projectile'), give(owner, 'shield')];
|
||||
staging.push(`Conserved distinct original projectile/Shield pairs: ${winnerCards.join(', ')} to Emperor; ${cards.join(', ')} to CHOAM.`);
|
||||
if (fullHolder) {
|
||||
while (player(game, holder).hand.length < handLimit(player(game, holder))) {
|
||||
assert.ok(game.deck[0]);
|
||||
moveSemutaEmperorCard(game, game.deck[0].id, holder);
|
||||
}
|
||||
staging.push('Conserved original undealt cards fill Richese to four.');
|
||||
}
|
||||
for (const seat of game.players) assert.ok(seat.hand.length <= handLimit(seat));
|
||||
assert.ok(player(game, holder).hand.some(c => c.id === SEMUTA_DRUG_ID));
|
||||
const beforeBattle = structuredClone(game);
|
||||
const actor = game.active!;
|
||||
assert.ok(actor === owner || actor === winner, 'Choose only the actual original phase-six battle actor');
|
||||
game = applySemutaHomeworldChoamStep(game, actor,
|
||||
{ type: 'chooseBattle', territory: splitLocation(location).territory, target: actor === winner ? owner : winner }, actions);
|
||||
game = advance(game,
|
||||
g => !!g.battle && !g.battle.preparation && g.battle.preLeader?.closed !== false && clean(g), actions);
|
||||
const strongest = player(game, winner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => b.strength - a.strength)[0];
|
||||
const weakest = player(game, owner).leaders.filter(l => !l.dead && !l.usedAt).sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(strongest && weakest && strongest.strength + 1 > weakest.strength);
|
||||
const support = advanced ? 1 : 0;
|
||||
assert.ok(player(game, winner).spice >= support, 'The native budget must cover the Advanced spice support');
|
||||
game = applySemutaHomeworldChoamStep(game, winner, { type: 'battlePlan', dial: 1,
|
||||
support, leader: strongest.id, weapon: winnerCards[0], defense: winnerCards[1] }, actions);
|
||||
game = applySemutaHomeworldChoamStep(game, owner, { type: 'battlePlan', dial: 0,
|
||||
support: 0, leader: weakest.id, weapon: cards[0], defense: cards[1] }, actions);
|
||||
// Use the public native voter order; stop before its last real response.
|
||||
let final: RicheseSkillsTechStep | undefined;
|
||||
for (let n = 0; n < 100; n++) {
|
||||
const next = nextRicheseSkillsTechNativeStep(game);
|
||||
if (next.action.type === 'traitorCall') {
|
||||
const voters = viewGame(game, game.host).battle!.traitorVoters;
|
||||
if (voters.every(id => id === next.actor || game.battle!.traitorCalls[id] !== undefined)) {
|
||||
final = next;
|
||||
break;
|
||||
}
|
||||
}
|
||||
game = applySemutaHomeworldChoamStep(game, next.actor, next.action, actions);
|
||||
}
|
||||
assert.ok(final, 'Native final traitor-pass control must resolve the original battle');
|
||||
return { setup, game, beforeBattle, owner, winner, holder, location, cards, winnerCards, support,
|
||||
actor: final.actor, action: final.action, winnerLeader: strongest.id, loserLeader: weakest.id,
|
||||
actions, staging, shipments };
|
||||
}
|
||||
|
||||
export function disposeSemutaHomeworldLoser(f: SemutaHomeworldLoserFixture,
|
||||
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
return applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
|
||||
}
|
||||
|
||||
/** Keep the winner's held played cards, then use original income/market controls
|
||||
* to enter this same turn's Collection exactly once. */
|
||||
export function finishSemutaHomeworldLoser(state: Game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
return advance(state, g => g.phase === 7 && clean(g), actions ?? []);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { semutaEmperorPlayer as player, moveSemutaEmperorCard } from './fixture-semuta-emperor-share';
|
||||
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
|
||||
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
|
||||
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
|
||||
export type SemutaHomeworldWinnerKind = 'mandatory' | 'selected';
|
||||
export type SemutaHomeworldWinnerFixture = {
|
||||
kind: SemutaHomeworldWinnerKind; setup: Game; beforeBattle: Game; game: Game;
|
||||
owner: string; holder: string; winner: string; loser: string; location: string;
|
||||
card: string; kept: string; projectile: string; worthless: string;
|
||||
winnerLeader: string; loserLeader: string; actor: string; action: Action;
|
||||
actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
shipments: { actor: string; action: Action; before: Game; after: Game }[];
|
||||
earlierEvents: string[];
|
||||
};
|
||||
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingTreacheryDiscard;
|
||||
export function semutaHomeworldWinnerStock(g: Game) {
|
||||
return [g.deck, g.discard, g.richeseCache ?? [], g.richeseRemoved ?? [],
|
||||
...g.players.map(p => p.hand)].flat().map(c => c.id).sort();
|
||||
}
|
||||
export function semutaHomeworldWinnerResources(g: Game) {
|
||||
return { custody: structuredClone(g.homeworlds?.custody),
|
||||
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) })) };
|
||||
}
|
||||
function passOffer(state: Game, actions?: RicheseSkillsTechStep[]) {
|
||||
let g = state;
|
||||
const pending = g.pendingTreacheryDiscard;
|
||||
assert.equal(pending?.reaction?.stage, 'offer');
|
||||
const event = pending!.batch.event;
|
||||
for (const seat of state.players) {
|
||||
if (!g.pendingTreacheryDiscard || g.pendingTreacheryDiscard.batch.event !== event) break;
|
||||
const reaction = g.pendingTreacheryDiscard.reaction;
|
||||
assert.ok(reaction?.stage === 'offer');
|
||||
if (!reaction.passed.includes(seat.id))
|
||||
g = applySemutaHomeworldChoamStep(g, seat.id, { type: 'semutaPass', event }, actions);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Original first-turn game: native paid shipments and plans; only conserved
|
||||
* physical hands and two unplayed non-worm Spice Cards are positioned. */
|
||||
export function createSemutaHomeworldWinnerFixture(
|
||||
kind: SemutaHomeworldWinnerKind, advanced = false, fullHolder = false,
|
||||
): SemutaHomeworldWinnerFixture {
|
||||
const native = createSemutaHomeworldChoamSetup(advanced);
|
||||
let game = native.game;
|
||||
const setup = structuredClone(game), actions = native.actions;
|
||||
const owner = native.owner, holder = native.holder, loser = native.canceller;
|
||||
const staging = ['Conserved original physical Semuta held by Richese.'];
|
||||
const battleBlow = game.spiceDeck.find(c => 'territory' in c && c.territory === 'red_chasm');
|
||||
const otherBlow = game.spiceDeck.find(c => 'territory' in c && c !== battleBlow);
|
||||
assert.ok(battleBlow && otherBlow);
|
||||
const blows = [battleBlow, otherBlow];
|
||||
for (const [index, card] of blows.entries())
|
||||
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
staging.push('Reorder the original Red Chasm Spice Card and another non-worm card, proving real Collection income after cleanup.');
|
||||
const shipments: SemutaHomeworldWinnerFixture['shipments'] = [];
|
||||
for (const [actor, amount] of [[loser, 1], [owner, 4]] 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, player(before, actor).spice - 2 * amount);
|
||||
assert.equal(player(game, actor).reserves, player(before, actor).reserves - amount);
|
||||
game = applySemutaHomeworldChoamStep(game, actor, { type: 'endMovement' }, actions);
|
||||
}
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.phase === 6 && clean(g), actions);
|
||||
const projectile = holdSemutaHomeworldChoamCard(game, owner, 'Crysknife');
|
||||
const kept = holdSemutaHomeworldChoamCard(game, owner, 'Shield');
|
||||
const worthless = holdSemutaHomeworldChoamCard(game, loser, 'Baliset');
|
||||
const cheap = kind === 'mandatory' ? holdSemutaHomeworldChoamCard(game, owner, 'Cheap Hero') : null;
|
||||
staging.push('Conserved printed Crysknife and Shield to CHOAM; Worthless to Emperor.' +
|
||||
(cheap ? ' Conserved printed Cheap Hero 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 physical cards fill the Richese hand to its native limit.');
|
||||
}
|
||||
assert.ok(player(game, owner).hand.length <= handLimit(player(game, owner)));
|
||||
const beforeBattle = structuredClone(game);
|
||||
const chooser = game.active;
|
||||
assert.ok(chooser && [owner, loser].includes(chooser));
|
||||
const target = chooser === owner ? loser : owner;
|
||||
game = applySemutaHomeworldChoamStep(game, chooser,
|
||||
{ type: 'chooseBattle', territory: 'red_chasm', target }, actions);
|
||||
game = advanceSemutaHomeworldChoam(game,
|
||||
g => !!g.battle?.preLeader?.closed && clean(g), actions);
|
||||
const winnerLeader = cheap ?? player(game, owner).leaders.find(l => !l.dead && !l.usedAt)!.id;
|
||||
const loserLeader = player(game, loser).leaders.find(l => !l.dead && !l.usedAt)!.id;
|
||||
for (const actor of [game.battle!.attacker, game.battle!.defender]) {
|
||||
const action: Action = actor === owner
|
||||
? { type: 'battlePlan', dial: 1, support: advanced ? 1 : 0,
|
||||
leader: winnerLeader, weapon: projectile, defense: kept }
|
||||
: { type: 'battlePlan', dial: 0, support: 0, leader: loserLeader, weapon: worthless };
|
||||
game = applySemutaHomeworldChoamStep(game, actor, action, actions);
|
||||
}
|
||||
const voters = [game.battle!.attacker, game.battle!.defender];
|
||||
game = applySemutaHomeworldChoamStep(game, voters[0], { type: 'traitorCall', call: false }, actions);
|
||||
const finalResponse: Action = { type: 'traitorCall', call: false };
|
||||
const earlierEvents: string[] = [];
|
||||
let action = finalResponse;
|
||||
if (kind === 'selected') {
|
||||
game = applySemutaHomeworldChoamStep(game, voters[1], finalResponse, actions);
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
earlierEvents.push(game.pendingTreacheryDiscard.batch.event);
|
||||
game = passOffer(game, actions);
|
||||
}
|
||||
game = advanceSemutaHomeworldChoam(game,
|
||||
g => g.decision?.kind === 'battleCards', actions);
|
||||
action = { type: 'decision', discard: [projectile] };
|
||||
}
|
||||
return { kind, setup, beforeBattle, game, owner, holder, winner: owner, loser,
|
||||
location: 'red_chasm:7', card: cheap ?? projectile, kept, projectile, worthless,
|
||||
winnerLeader, loserLeader, actor: kind === 'mandatory' ? voters[1] : owner,
|
||||
action, actions, staging, shipments, earlierEvents };
|
||||
}
|
||||
|
||||
export function disposeSemutaHomeworldWinner(f: SemutaHomeworldWinnerFixture,
|
||||
state = f.game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = applySemutaHomeworldChoamStep(state, f.actor, f.action, actions);
|
||||
if (f.kind === 'mandatory' && game.pendingTreacheryDiscard?.continuation.kind === 'battleResolved')
|
||||
game = passOffer(game, actions);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Retain unselected original winner cards, complete the real CHOAM market and
|
||||
* reach Collection without ever replaying battle or editing the native clock. */
|
||||
export function finishSemutaHomeworldWinner(state: Game,
|
||||
actions?: RicheseSkillsTechStep[]): Game {
|
||||
const game = advanceSemutaHomeworldChoam(state, g => g.phase === 7 && clean(g), actions);
|
||||
assert.equal(game.turn, state.turn);
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
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 { splitLocation } from '../game/board';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import {
|
||||
createSemutaHomeworldLoserFixture, disposeSemutaHomeworldLoser, finishSemutaHomeworldLoser,
|
||||
type SemutaHomeworldLoserFixture,
|
||||
} from './fixture-semuta-homeworld-battle-loser';
|
||||
|
||||
function offerEvent(game: Game): string {
|
||||
const pending = game.pendingTreacheryDiscard;
|
||||
assert.ok(pending, 'A fresh public discard offer must be open');
|
||||
return pending.batch.event;
|
||||
}
|
||||
|
||||
function claimLoser(f: SemutaHomeworldLoserFixture, state: Game, card: string): Game {
|
||||
const event = offerEvent(state);
|
||||
return applyAction(applyAction(state, f.holder, { type: 'semutaCommit', event }),
|
||||
f.holder, { type: 'semutaSelect', event, card });
|
||||
}
|
||||
|
||||
function passLoser(state: Game): Game {
|
||||
const event = offerEvent(state);
|
||||
let game = state;
|
||||
for (const seat of state.players)
|
||||
game = applyAction(game, seat.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
|
||||
function cleanupDecision(game: Game) {
|
||||
const decision = game.decision;
|
||||
assert.ok(decision && decision.kind === 'battleCards',
|
||||
'The winning side keeps its held played cards under its own native cleanup');
|
||||
return decision;
|
||||
}
|
||||
|
||||
/** The public combatant facts every seat shares after the losers cede cards. */
|
||||
function assertSettled(f: SemutaHomeworldLoserFixture, game: Game) {
|
||||
const { winner, loser } = { winner: player(game, f.winner), loser: player(game, f.owner) };
|
||||
assert.equal(winner.forces[f.location], 3, 'The winner keeps exactly its surviving dialed force');
|
||||
assert.equal(winner.reserves, player(f.game, f.winner).reserves, 'Shipment already left the native reserve pool');
|
||||
assert.equal(loser.forces[f.location] ?? 0, 0, 'The entire losing force leaves the board');
|
||||
assert.equal(loser.tanks, 1, 'Exactly one losing force is destroyed to Tanks');
|
||||
assert.equal(loser.reserves, player(f.game, f.owner).reserves);
|
||||
for (const seat of game.players) {
|
||||
const view = viewGame(game, seat.id);
|
||||
assert.equal(view.players.find(p => p.id === f.winner)!.forces[f.location], 3);
|
||||
assert.equal(view.players.find(p => p.id === f.owner)!.forces[f.location] ?? 0, 0);
|
||||
assert.equal(view.players.find(p => p.id === f.owner)!.tanks, 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
const label = `${advanced ? 'Advanced' : 'Basic'} original paid Homeworld battle`;
|
||||
|
||||
void test(`${label}: native shipments and battle produce the public losing-card batch before any Semuta offer`, () => {
|
||||
const f = createSemutaHomeworldLoserFixture(advanced);
|
||||
assert.equal(f.setup.status, 'playing');
|
||||
assert.equal(f.game.turn, 1);
|
||||
assert.equal(f.game.phase, 6);
|
||||
assert.equal(f.game.pendingTreacheryDiscard ?? null, null, 'The losing batch has not been offered yet');
|
||||
assert.equal(f.game.decision ?? null, null);
|
||||
assert.ok([f.winner, f.owner].includes(f.game.active!));
|
||||
assert.equal(splitLocation(f.location).territory, 'red_chasm');
|
||||
assert.equal(new Set([f.winner, f.owner, f.holder]).size, 3);
|
||||
assert.equal(new Set([...f.cards, ...f.winnerCards]).size, 4, 'Four distinct physical weapon/defense cards');
|
||||
assert.equal(player(f.game, f.owner).hand.find(c => c.id === f.cards[0])!.kind, 'projectile');
|
||||
assert.equal(player(f.game, f.owner).hand.find(c => c.id === f.cards[1])!.kind, 'shield');
|
||||
assert.equal(player(f.game, f.winner).hand.find(c => c.id === f.winnerCards[0])!.kind, 'projectile');
|
||||
assert.equal(player(f.game, f.winner).hand.find(c => c.id === f.winnerCards[1])!.kind, 'shield');
|
||||
assert.ok(f.cards.every(id => !f.game.discard.some(c => c.id === id)));
|
||||
assert.equal(f.beforeBattle.phase, f.game.phase);
|
||||
assert.deepEqual(f.beforeBattle.players.map(p => p.forces), f.game.players.map(p => p.forces));
|
||||
|
||||
const voters = viewGame(f.game, f.game.host).battle!.traitorVoters;
|
||||
assert.equal(f.actor, voters[voters.length - 1], 'The last real traitor response belongs to the final public voter');
|
||||
assert.equal(f.action.type, 'traitorCall');
|
||||
assert.ok([f.winner, f.owner].includes(f.actor));
|
||||
|
||||
assert.equal(f.shipments.length, 2);
|
||||
const [winnerShip, loserShip] = f.shipments;
|
||||
assert.equal(winnerShip.actor, f.winner);
|
||||
assert.equal(loserShip.actor, f.owner);
|
||||
assert.equal(winnerShip.action.type, 'ship');
|
||||
assert.equal(loserShip.action.type, 'ship');
|
||||
assert.equal(winnerShip.action.amount, 4);
|
||||
assert.equal(loserShip.action.amount, 1);
|
||||
assert.equal(winnerShip.price, 8);
|
||||
assert.equal(loserShip.price, 2);
|
||||
for (const ship of f.shipments) {
|
||||
const amount = ship.action.amount;
|
||||
assert.ok(typeof amount === 'number');
|
||||
assert.equal(player(ship.before, ship.actor).spice - player(ship.after, ship.actor).spice, ship.price);
|
||||
assert.equal(ship.price, 2 * amount);
|
||||
assert.equal(player(ship.before, ship.actor).reserves - player(ship.after, ship.actor).reserves, amount);
|
||||
assert.equal(player(ship.after, ship.actor).forces[f.location], amount);
|
||||
assert.deepEqual(player(ship.after, ship.actor).elites, player(ship.before, ship.actor).elites,
|
||||
'Only ordinary native reserves depart, not Sardaukar');
|
||||
assert.deepEqual(ship.after.homeworlds?.custody, ship.before.homeworlds?.custody,
|
||||
'Kaitain supply comes from the native reserve pool, not staged custody');
|
||||
}
|
||||
assert.equal(player(f.game, f.winner).forces[f.location], 4);
|
||||
assert.equal(player(f.game, f.owner).forces[f.location], 1);
|
||||
|
||||
const done = disposeSemutaHomeworldLoser(f);
|
||||
assert.equal(done.phase, 6);
|
||||
assert.equal(done.turn, 1);
|
||||
assert.equal(done.lastBattleContext!.winner, f.winner);
|
||||
assert.equal(done.lastBattleContext!.territory, 'red_chasm');
|
||||
const pending = done.pendingTreacheryDiscard;
|
||||
assert.ok(pending);
|
||||
assert.equal(pending.batch.cause, 'battle:mandatory');
|
||||
assert.deepEqual(pending.batch.entries.map(entry => [entry.card.id, entry.discardedBy]).sort((a, b) => a[0].localeCompare(b[0])),
|
||||
f.cards.map(id => [id, f.owner]).sort((a, b) => a[0].localeCompare(b[0])), 'The batch is exactly the losing player\'s two played cards');
|
||||
assert.equal(pending.reaction?.stage, 'offer');
|
||||
for (const card of f.cards) assert.ok(done.discard.some(c => c.id === card));
|
||||
assert.ok(player(done, f.winner).hand.every(card => !f.cards.includes(card.id)));
|
||||
assert.equal(player(done, f.owner).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(done, f.owner).tanks, 1);
|
||||
assert.equal(player(done, f.winner).forces[f.location], 4,
|
||||
'The winner\'s own casualties belong to the original continuation, not the offer');
|
||||
assert.equal(player(done, f.winner).spice, player(f.game, f.winner).spice - f.support);
|
||||
assert.equal(f.support, advanced ? 1 : 0);
|
||||
assert.equal(player(done, f.winner).reserves, 16);
|
||||
assert.equal(player(done, f.owner).reserves, 19);
|
||||
assert.equal(player(done, f.holder).hand.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
||||
assert.deepEqual(done.homeworlds?.custody, f.game.homeworlds?.custody);
|
||||
for (const seat of done.players) {
|
||||
const view = viewGame(done, seat.id);
|
||||
assert.equal(view.semutaReaction?.canCommit, seat.id === f.holder);
|
||||
if (seat.id !== f.holder) assert.deepEqual(view.semutaReaction?.candidates, []);
|
||||
}
|
||||
assert.throws(() => applyAction(done, f.actor, f.action), 'The resolving battle control cannot be replayed');
|
||||
});
|
||||
|
||||
for (const fullHolder of [false, true]) {
|
||||
const holderLabel = fullHolder ? ' at the Richese hand limit' : '';
|
||||
|
||||
void test(`${label}${holderLabel}: Richese claims one actual public losing card through Commit then Select`, () => {
|
||||
const f = createSemutaHomeworldLoserFixture(advanced, fullHolder);
|
||||
const done = disposeSemutaHomeworldLoser(f);
|
||||
const event = offerEvent(done);
|
||||
if (fullHolder)
|
||||
assert.equal(player(done, f.holder).hand.length, handLimit(player(done, f.holder)));
|
||||
|
||||
const holderOffer = viewGame(done, f.holder).semutaReaction!;
|
||||
assert.equal(holderOffer.stage, 'offer');
|
||||
assert.equal(holderOffer.passed, false);
|
||||
assert.deepEqual(holderOffer.candidates, [], 'Commitment precedes any private card selection');
|
||||
const committed = applyAction(done, f.holder, { type: 'semutaCommit', event });
|
||||
const selection = committed.pendingTreacheryDiscard?.reaction;
|
||||
assert.equal(selection?.stage, 'select');
|
||||
assert.deepEqual([...selection!.candidates].sort(), [...f.cards].sort(),
|
||||
'The private choice is exactly the two public losing cards');
|
||||
assert.deepEqual(viewGame(committed, f.winner).semutaReaction?.candidates, []);
|
||||
assert.deepEqual(viewGame(committed, f.owner).semutaReaction?.candidates, []);
|
||||
const snapshot = JSON.stringify(committed);
|
||||
assert.throws(() => applyAction(committed, f.winner, { type: 'semutaSelect', event, card: f.cards[0] }));
|
||||
assert.throws(() => applyAction(committed, f.holder,
|
||||
{ type: 'semutaSelect', event, card: f.winnerCards[0] }));
|
||||
assert.equal(JSON.stringify(committed), snapshot, 'An illegal selection changes nothing');
|
||||
|
||||
const selected = claimLoser(f, done, f.cards[0]);
|
||||
assert.equal(selected.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(player(selected, f.holder).hand.filter(c => c.id === f.cards[0]).length, 1);
|
||||
assert.equal(player(selected, f.holder).hand.some(c => c.id === f.cards[1]), false);
|
||||
assert.ok(selected.discard.some(c => c.id === f.cards[1]), 'The unselected card stays discarded');
|
||||
assert.ok(selected.discard.some(c => c.id === SEMUTA_DRUG_ID));
|
||||
assert.equal(player(selected, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), false);
|
||||
if (fullHolder)
|
||||
assert.equal(player(selected, f.holder).hand.length, handLimit(player(selected, f.holder)));
|
||||
assertSettled(f, selected);
|
||||
const cleanup = cleanupDecision(selected);
|
||||
assert.equal(cleanup.player, f.winner);
|
||||
assert.deepEqual([...cleanup.cards].sort(), [...f.winnerCards].sort(),
|
||||
'Only the winner\'s own held played cards are at stake');
|
||||
|
||||
const kept = applyAction(selected, f.winner, { type: 'decision', discard: [] });
|
||||
assert.ok(f.winnerCards.every(card => player(kept, f.winner).hand.some(c => c.id === card)),
|
||||
'Held played cards stay with the winner by default');
|
||||
assert.throws(() => applyAction(kept, f.winner, { type: 'decision', discard: [] }),
|
||||
'The original cleanup is consumed exactly once');
|
||||
const finished = finishSemutaHomeworldLoser(kept);
|
||||
assert.equal(finished.phase, 7);
|
||||
assert.equal(finished.turn, 1);
|
||||
assert.ok(!finished.phaseOpening && !finished.response && !finished.decision && !finished.pendingTreacheryDiscard);
|
||||
assert.equal(player(finished, f.holder).hand.filter(c => c.id === f.cards[0]).length, 1);
|
||||
assertSettled(f, finished);
|
||||
assert.equal(player(finished, f.winner).reserves, 16);
|
||||
assert.equal(player(finished, f.owner).tanks, 1);
|
||||
assert.equal(player(finished, f.owner).reserves, 19);
|
||||
assert.throws(() => applyAction(finished, f.holder, { type: 'semutaCommit', event }),
|
||||
'The resolved losing batch cannot be claimed twice');
|
||||
assert.throws(() => applyAction(finished, f.holder, { type: 'semutaSelect', event, card: f.cards[1] }));
|
||||
assert.throws(() => applyAction(finished, f.actor, f.action));
|
||||
});
|
||||
|
||||
void test(`${label}${holderLabel}: passing conserves the printed Semuta and both public losing cards`, () => {
|
||||
const f = createSemutaHomeworldLoserFixture(advanced, fullHolder);
|
||||
const done = disposeSemutaHomeworldLoser(f);
|
||||
const event = offerEvent(done);
|
||||
const holderPassed = applyAction(done, f.holder, { type: 'semutaPass', event });
|
||||
assert.throws(() => applyAction(holderPassed, f.holder, { type: 'semutaPass', event }),
|
||||
'One seat answers this fresh discard exactly once');
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: 'stale-discard' }));
|
||||
const declined = passLoser(done);
|
||||
assert.equal(declined.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(player(declined, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), true);
|
||||
assert.ok(f.cards.every(card => declined.discard.some(c => c.id === card)));
|
||||
assertSettled(f, declined);
|
||||
assert.deepEqual([...cleanupDecision(declined).cards].sort(), [...f.winnerCards].sort());
|
||||
const finished = finishSemutaHomeworldLoser(applyAction(declined, f.winner, { type: 'decision', discard: [] }));
|
||||
assert.equal(finished.phase, 7);
|
||||
assert.ok(f.cards.every(card => finished.discard.some(c => c.id === card)));
|
||||
assert.equal(player(finished, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), true);
|
||||
assertSettled(f, finished);
|
||||
assert.throws(() => applyAction(finished, f.holder, { type: 'semutaPass', event }));
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${label}: the winner's held played cards are kept or discarded under its own original cleanup`, () => {
|
||||
const f = createSemutaHomeworldLoserFixture(advanced);
|
||||
const done = disposeSemutaHomeworldLoser(f);
|
||||
const mandatory = offerEvent(done);
|
||||
const selected = claimLoser(f, done, f.cards[1]);
|
||||
assert.equal(player(selected, f.holder).hand.filter(c => c.id === f.cards[1]).length, 1);
|
||||
assert.ok(selected.discard.some(c => c.id === f.cards[0]));
|
||||
const discarded = applyAction(selected, f.winner, { type: 'decision', discard: [f.winnerCards[0]] });
|
||||
assert.equal(player(discarded, f.winner).hand.some(c => c.id === f.winnerCards[0]), false);
|
||||
assert.ok(discarded.discard.some(c => c.id === f.winnerCards[0]));
|
||||
assert.ok(player(discarded, f.winner).hand.some(c => c.id === f.winnerCards[1]),
|
||||
'The unselected played card is retained');
|
||||
const winnerOffer = discarded.pendingTreacheryDiscard;
|
||||
assert.ok(winnerOffer, 'A discarded winner card opens its own fresh public offer');
|
||||
assert.notEqual(winnerOffer.batch.event, mandatory, 'Each public card batch has its own event');
|
||||
assert.deepEqual(winnerOffer.batch.entries.map(entry => [entry.card.id, entry.discardedBy]),
|
||||
[[f.winnerCards[0], f.winner]]);
|
||||
const resolved = passLoser(discarded);
|
||||
assert.equal(resolved.pendingTreacheryDiscard ?? null, null);
|
||||
const finished = finishSemutaHomeworldLoser(resolved);
|
||||
assert.equal(finished.phase, 7);
|
||||
assert.ok(finished.discard.some(c => c.id === f.winnerCards[0]));
|
||||
assert.ok(player(finished, f.winner).hand.some(c => c.id === f.winnerCards[1]));
|
||||
assertSettled(f, finished);
|
||||
assert.throws(() => applyAction(finished, f.holder,
|
||||
{ type: 'semutaCommit', event: mandatory }));
|
||||
});
|
||||
|
||||
void test(`${label}: every legal bot profile claims through the original controls to the same battlefield`, () => {
|
||||
const baseline = (() => {
|
||||
const f = createSemutaHomeworldLoserFixture(advanced);
|
||||
const selected = claimLoser(f, disposeSemutaHomeworldLoser(f), f.cards[0]);
|
||||
return { f, game: applyAction(selected, f.winner, { type: 'decision', discard: [] }) };
|
||||
})();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const f = createSemutaHomeworldLoserFixture(advanced);
|
||||
const done = disposeSemutaHomeworldLoser(f);
|
||||
const bidders = structuredClone(done);
|
||||
player(bidders, f.holder).bot = difficulty;
|
||||
const commit = botActions(viewGame(bidders, f.holder))[0];
|
||||
assert.ok(commit);
|
||||
assert.ok(commit.type === 'semutaCommit' || commit.type === 'semutaPass');
|
||||
let game = applyAction(bidders, f.holder, commit);
|
||||
if (game.pendingTreacheryDiscard) {
|
||||
const select = botActions(viewGame(game, f.holder))[0];
|
||||
assert.ok(select);
|
||||
assert.ok(select.type === 'semutaSelect' || select.type === 'semutaPass');
|
||||
game = applyAction(game, f.holder, select);
|
||||
}
|
||||
if (game.pendingTreacheryDiscard) game = passLoser(game);
|
||||
const cleanup = cleanupDecision(game);
|
||||
game = applyAction(game, cleanup.player, { type: 'decision', discard: [] });
|
||||
const finished = finishSemutaHomeworldLoser(game);
|
||||
assert.equal(finished.phase, 7);
|
||||
assertSettled(f, finished);
|
||||
assert.deepEqual(finished.players.map(seat => [seat.id, seat.forces, seat.tanks, seat.reserves]),
|
||||
baseline.game.players.map(seat => [seat.id, seat.forces, seat.tanks, seat.reserves]),
|
||||
`${difficulty} resolves the identical original casualties`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
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 { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
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 {
|
||||
createSemutaHomeworldWinnerFixture, disposeSemutaHomeworldWinner,
|
||||
finishSemutaHomeworldWinner, semutaHomeworldWinnerResources as resources,
|
||||
semutaHomeworldWinnerStock as stock, type SemutaHomeworldWinnerFixture,
|
||||
} from './fixture-semuta-homeworld-battle-winner';
|
||||
|
||||
function assertOriginalBattle(f: SemutaHomeworldWinnerFixture, game: Game) {
|
||||
assert.equal(game.turn, 1);
|
||||
assert.equal(game.lastBattleContext?.winner, f.winner);
|
||||
assert.equal(game.lastBattleContext?.result, 'normal');
|
||||
assert.equal(game.lastBattleContext?.territory, 'red_chasm');
|
||||
assert.ok(!game.battle, 'The accepted original battle is not reopened');
|
||||
assert.equal(player(game, f.winner).forces[f.location], 3);
|
||||
assert.equal(player(game, f.winner).reserves, 16);
|
||||
assert.equal(player(game, f.winner).tanks, 1);
|
||||
assert.equal(player(game, f.winner).battleLosses,
|
||||
player(f.beforeBattle, f.winner).battleLosses + 1);
|
||||
assert.equal(player(game, f.loser).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(game, f.loser).reserves, 19);
|
||||
assert.equal(player(game, f.loser).tanks, 1);
|
||||
assert.equal(player(game, f.loser).leaders.find(l => l.id === f.loserLeader)?.dead, true);
|
||||
assert.equal(player(f.beforeBattle, f.loser).leaders.find(l => l.id === f.loserLeader)?.dead, false);
|
||||
assert.equal(player(game, f.winner).spice,
|
||||
player(f.beforeBattle, f.winner).spice - Number(game.advanced) +
|
||||
player(f.beforeBattle, f.loser).leaders.find(l => l.id === f.loserLeader)!.strength,
|
||||
'Only original supported dial cost and killed-leader income change CHOAM spice');
|
||||
assert.equal(player(game, f.loser).spice, player(f.beforeBattle, f.loser).spice,
|
||||
'Emperor dials and pays zero; no CHOAM opponent battle income is invented');
|
||||
assert.deepEqual(game.homeworlds?.custody, f.shipments[1].after.homeworlds?.custody,
|
||||
'Original paid native Homeworld withdrawal remains unchanged by battle cleanup');
|
||||
for (const seat of game.players.filter(p => ![f.winner, f.loser].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.reserves, before.reserves);
|
||||
assert.equal(seat.tanks, before.tanks);
|
||||
assert.equal(seat.spice, before.spice);
|
||||
}
|
||||
assert.deepEqual(stock(game), stock(f.setup));
|
||||
}
|
||||
|
||||
function assertExchange(f: SemutaHomeworldWinnerFixture, pending: Game, game: Game, claim: boolean) {
|
||||
assert.ok(!game.pendingTreacheryDiscard);
|
||||
assert.deepEqual(resources(game), resources(pending), 'Semuta never repeats accepted combat or casualties');
|
||||
assert.deepEqual(game.lastBattleContext, pending.lastBattleContext);
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.card), false);
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.kept), true);
|
||||
if (f.kind === 'mandatory')
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.projectile), true);
|
||||
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.card).length, Number(claim));
|
||||
assert.equal(game.discard.filter(c => c.id === f.card).length, Number(!claim));
|
||||
assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim);
|
||||
assert.equal(game.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, Number(claim));
|
||||
assert.equal(game.discard.filter(c => c.id === f.worthless).length, 1,
|
||||
'Earlier losing Worthless remains in its original discard, not the later offer');
|
||||
for (const seat of game.players) {
|
||||
const expected = player(pending, seat.id).hand.map(c => c.id)
|
||||
.filter(id => seat.id !== f.holder || !claim || id !== SEMUTA_DRUG_ID);
|
||||
if (seat.id === f.holder && claim) expected.push(f.card);
|
||||
assert.deepEqual(seat.hand.map(c => c.id).sort(), expected.sort(),
|
||||
'Exactly the chosen card exchanges with Semuta; every other physical hand card stays put');
|
||||
}
|
||||
assertOriginalBattle(f, game);
|
||||
assert.equal(game.decision?.kind, f.kind === 'mandatory' ? 'battleCards' : 'choamMarket');
|
||||
assert.equal(game.decision?.player, f.owner);
|
||||
}
|
||||
|
||||
function finishOnce(f: SemutaHomeworldWinnerFixture, state: Game) {
|
||||
const expected = resources(state), context = structuredClone(state.lastBattleContext);
|
||||
const collection = quoteSpiceCollection(state);
|
||||
assert.equal(collection.receipts.find(receipt => receipt.player === f.owner)!.desert, 6,
|
||||
'Three surviving ordinary counters collect six from the original eight-spice blow');
|
||||
for (const receipt of collection.receipts)
|
||||
expected.players.find(p => p.id === receipt.player)!.spice = receipt.balance;
|
||||
const game = finishSemutaHomeworldWinner(state);
|
||||
assert.equal(game.phase, 7);
|
||||
assert.equal(game.turn, 1);
|
||||
assert.ok(!game.decision && !game.response && !game.pendingTreacheryDiscard);
|
||||
assert.deepEqual(resources(game), expected,
|
||||
'Original Collection credits its board once; battle casualties and death income do not repeat');
|
||||
assert.deepEqual(game.spice, collection.spice);
|
||||
assert.deepEqual(game.lastBattleContext, context);
|
||||
assert.throws(() => applyAction(game, f.owner, { type: 'decision', done: true }),
|
||||
'The original native CHOAM market cannot close twice');
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const kind of ['mandatory', 'selected'] as const) {
|
||||
const label = `${advanced ? 'Advanced' : 'Basic'} paid Homeworld winner ${kind}`;
|
||||
for (const claim of [false, true]) void test(`${label}: ${claim ? 'claim' : 'all-pass'} exact fresh card before one native cleanup`, () => {
|
||||
const f = createSemutaHomeworldWinnerFixture(kind, advanced);
|
||||
for (const shipment of f.shipments) {
|
||||
assert.equal(shipment.action.type, 'ship');
|
||||
assert.equal(player(shipment.after, shipment.actor).spice,
|
||||
player(shipment.before, shipment.actor).spice - (shipment.actor === f.loser ? 2 : 8));
|
||||
assert.equal(player(shipment.after, shipment.actor).reserves,
|
||||
player(shipment.before, shipment.actor).reserves - (shipment.actor === f.loser ? 1 : 4));
|
||||
assert.deepEqual(player(shipment.after, shipment.actor).elites, player(shipment.before, shipment.actor).elites,
|
||||
'These quotes ship ordinary native counters, not Sardaukar');
|
||||
}
|
||||
const pending = disposeSemutaHomeworldWinner(f);
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.card]);
|
||||
assert.equal(pending.discard.filter(c => c.id === f.card).length, 1);
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction?.canCommit, true);
|
||||
assertOriginalBattle(f, pending);
|
||||
assert.throws(() => applyAction(pending, f.actor, f.action), 'Accepted original disposal cannot replay');
|
||||
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', discard: [] }));
|
||||
assert.throws(() => applyAction(pending, f.loser, { type: 'ready' }));
|
||||
assert.throws(() => applyAction(pending, f.loser, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(pending, f.holder, { type: 'semutaCommit', event: `${event}-retired` }));
|
||||
const acknowledged = applyAction(pending, f.loser, { type: 'semutaPass', event });
|
||||
assert.deepEqual(resources(acknowledged), resources(pending));
|
||||
assert.equal(viewGame(acknowledged, f.holder).semutaReaction?.canCommit, true,
|
||||
'A nonholder acknowledgement does not advance cleanup or close holder entitlement');
|
||||
assert.throws(() => applyAction(acknowledged, f.loser, { type: 'semutaPass', event }));
|
||||
assert.throws(() => applyAction(acknowledged, f.loser, { type: 'semutaCommit', event }));
|
||||
const done = claim
|
||||
? applyAction(acknowledged, f.holder, { type: 'semutaCommit', event })
|
||||
: reactSemutaHomeworldChoam(structuredClone(pending), f.holder, false);
|
||||
assertExchange(f, pending, done, claim);
|
||||
for (const action of [{ type: 'semutaCommit', event }, { type: 'semutaPass', event },
|
||||
{ type: 'semutaSelect', event, card: f.card }])
|
||||
assert.throws(() => applyAction(done, f.holder, action));
|
||||
const collected = finishOnce(f, done);
|
||||
assert.equal(player(collected, f.owner).hand.some(c => c.id === f.kept), true);
|
||||
assert.throws(() => applyAction(collected, f.holder, { type: 'semutaCommit', event }));
|
||||
});
|
||||
|
||||
void test(`${label}: full native Richese hand exchanges atomically and entitlement stays private`, () => {
|
||||
const f = createSemutaHomeworldWinnerFixture(kind, advanced, true);
|
||||
assert.equal(player(f.game, f.holder).hand.length, 4);
|
||||
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
|
||||
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
|
||||
const pending = disposeSemutaHomeworldWinner(f);
|
||||
const neutral = disposeSemutaHomeworldWinner(f, absent);
|
||||
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(JSON.parse(JSON.stringify(viewGame(pending, seat.id))),
|
||||
JSON.parse(JSON.stringify(viewGame(neutral, seat.id))),
|
||||
'Nonholders cannot infer the private Semuta entitlement from consumer views');
|
||||
assert.equal(viewGame(pending, seat.id).semutaReaction?.canCommit, false);
|
||||
}
|
||||
const claimed = reactSemutaHomeworldChoam(pending, f.holder, true);
|
||||
assertExchange(f, pending, claimed, true);
|
||||
assert.equal(player(claimed, f.holder).hand.length, 4);
|
||||
const passed = reactSemutaHomeworldChoam(neutral, f.holder, false);
|
||||
assert.deepEqual(resources(claimed), resources(passed));
|
||||
finishOnce(f, claimed);
|
||||
finishOnce(f, passed);
|
||||
});
|
||||
|
||||
void test(`${label}: original selected multi-card batch requires a private exact one-card commitment`, () => {
|
||||
const f = createSemutaHomeworldWinnerFixture(kind, advanced, true);
|
||||
let originalCleanup = f.game;
|
||||
if (kind === 'mandatory') {
|
||||
const mandatory = disposeSemutaHomeworldWinner(f);
|
||||
originalCleanup = reactSemutaHomeworldChoam(mandatory, f.holder, false);
|
||||
}
|
||||
assert.equal(originalCleanup.decision?.kind, 'battleCards');
|
||||
const pending = applyAction(originalCleanup, f.owner,
|
||||
{ type: 'decision', discard: [f.projectile, f.kept] });
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(viewGame(pending, f.holder).semutaReaction?.candidates, []);
|
||||
assert.equal(player(pending, f.owner).hand.some(c => c.id === f.projectile || c.id === f.kept), false);
|
||||
assert.throws(() => applyAction(pending, f.holder, { type: 'semutaSelect', event, card: f.projectile }));
|
||||
const committed = applyAction(pending, f.holder, { type: 'semutaCommit', event });
|
||||
assert.equal(committed.pendingTreacheryDiscard?.reaction?.stage, 'select');
|
||||
assert.deepEqual(viewGame(committed, f.holder).semutaReaction?.candidates.map(c => c.id).sort(),
|
||||
[f.projectile, f.kept].sort());
|
||||
for (const seat of committed.players.filter(p => p.id !== f.holder)) {
|
||||
assert.deepEqual(viewGame(committed, seat.id).semutaReaction?.candidates, []);
|
||||
assert.throws(() => applyAction(committed, seat.id, { type: 'semutaSelect', event, card: f.projectile }));
|
||||
}
|
||||
assert.throws(() => applyAction(committed, f.holder, { type: 'semutaPass', event }));
|
||||
assert.throws(() => applyAction(committed, f.holder, { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(committed, f.holder, { type: 'semutaSelect', event, card: f.worthless }));
|
||||
assert.deepEqual(resources(committed), resources(pending));
|
||||
for (const chosen of [f.projectile, f.kept]) {
|
||||
const done = applyAction(structuredClone(committed), f.holder, { type: 'semutaSelect', event, card: chosen });
|
||||
const other = chosen === f.projectile ? f.kept : f.projectile;
|
||||
assert.equal(player(done, f.holder).hand.filter(c => c.id === chosen).length, 1);
|
||||
assert.equal(player(done, f.holder).hand.length, 4);
|
||||
assert.equal(done.discard.filter(c => c.id === chosen).length, 0);
|
||||
assert.equal(done.discard.filter(c => c.id === other).length, 1);
|
||||
assert.equal(done.discard.filter(c => c.id === SEMUTA_DRUG_ID).length, 1);
|
||||
assert.equal(done.discard.filter(c => c.id === f.worthless).length, 1);
|
||||
if (kind === 'mandatory') assert.equal(done.discard.filter(c => c.id === f.card).length, 1);
|
||||
assert.deepEqual(resources(done), resources(pending));
|
||||
assert.deepEqual(stock(done), stock(f.setup));
|
||||
const remaining = player(pending, f.holder).hand.map(c => c.id).filter(id => id !== SEMUTA_DRUG_ID);
|
||||
assert.deepEqual(player(done, f.holder).hand.map(c => c.id).sort(), [...remaining, chosen].sort());
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'semutaSelect', event, card: other }));
|
||||
finishOnce(f, done);
|
||||
}
|
||||
});
|
||||
|
||||
for (const difficulty of DIFFICULTIES) void test(`${difficulty}: legal minimum ${label} consumer and native continuation`, () => {
|
||||
const f = createSemutaHomeworldWinnerFixture(kind, advanced);
|
||||
const pending = disposeSemutaHomeworldWinner(f);
|
||||
const own = viewGame(pending, f.holder);
|
||||
own.players.find(p => p.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(own)[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 });
|
||||
}
|
||||
}
|
||||
assertExchange(f, pending, game, action.type === 'semutaCommit');
|
||||
finishOnce(f, game);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} original losing, mandatory winning and selected cleanup are distinct fresh events`, () => {
|
||||
const f = createSemutaHomeworldWinnerFixture('mandatory', advanced);
|
||||
let game = applyAction(f.game, f.actor, f.action);
|
||||
const losingEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.worthless]);
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.card), true,
|
||||
'Mandatory winner Hero is still physically reserved through the earlier losing offer');
|
||||
assert.equal(player(game, f.owner).forces[f.location], 4, 'Winner casualties follow the first offer');
|
||||
assert.equal(player(game, f.owner).tanks, 0);
|
||||
assert.equal(player(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), true);
|
||||
game = reactSemutaHomeworldChoam(game, f.holder, false);
|
||||
const mandatoryEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
assert.notEqual(mandatoryEvent, losingEvent);
|
||||
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.card]);
|
||||
assertOriginalBattle(f, game);
|
||||
assert.throws(() => applyAction(game, f.holder, { type: 'semutaCommit', event: losingEvent }));
|
||||
assert.throws(() => applyAction(game, f.loser, { type: 'semutaPass', event: losingEvent }));
|
||||
game = reactSemutaHomeworldChoam(game, f.holder, false);
|
||||
assert.equal(game.decision?.kind, 'battleCards');
|
||||
assert.equal(game.decision?.player, f.owner);
|
||||
assert.throws(() => applyAction(game, f.loser, { type: 'decision', discard: [f.projectile] }));
|
||||
game = applyAction(game, f.owner, { type: 'decision', discard: [f.projectile] });
|
||||
const selectedEvent = game.pendingTreacheryDiscard!.batch.event;
|
||||
assert.notEqual(selectedEvent, losingEvent);
|
||||
assert.notEqual(selectedEvent, mandatoryEvent);
|
||||
assert.deepEqual(game.pendingTreacheryDiscard?.batch.entries.map(e => e.card.id), [f.projectile]);
|
||||
assert.throws(() => applyAction(game, f.holder, { type: 'semutaCommit', event: mandatoryEvent }));
|
||||
const before = resources(game);
|
||||
game = reactSemutaHomeworldChoam(game, f.holder, true);
|
||||
assert.deepEqual(resources(game), before);
|
||||
assert.equal(player(game, f.holder).hand.filter(c => c.id === f.projectile).length, 1);
|
||||
assert.equal(game.discard.filter(c => c.id === f.card).length, 1);
|
||||
assert.equal(game.discard.filter(c => c.id === f.worthless).length, 1);
|
||||
assert.equal(player(game, f.owner).hand.some(c => c.id === f.kept), true);
|
||||
finishOnce(f, game);
|
||||
});
|
||||
@@ -30,7 +30,7 @@ async function main() {
|
||||
'\nSemuta with CHOAM and optional Ix preserves original unused Tech Tokens in fresh 3–6-seat Basic/Advanced Richese lobbies under existing native prerequisites. Advanced additionally retains original unused Stronghold Cards at2–6; both modules require3–6. Native setup, first-Storm Tech and final-Mentat Stronghold assignment remain original. Mandatory/winner discards react before the original winner takes one loser-owned token, before Face Dance. Closing Gamont reacts before the original CHOAM finish and changed-board claim. Optional original skills require --leader-skills as described below; other modules, the separate CHOAM/Ecaz Moritani entry, retrofit and public starts remain excluded.',
|
||||
'\n--profile semuta --leader-skills explicitly composes original all14 skills with fresh native Basic Richese/CHOAM and classics using exact CHOAM, or Advanced native E1/E2 with Richese and selected required CHOAM/optional Ix decks, at2–6 seats. Optional original Tech requires3+; Strongholds requires Advanced. Original private offers/training, Auditor exclusion, normal Mentat question, typed Suk rescue and Rihani inspection/exchange remain. Traitor/Face Dancer movement is not a Treachery discard. Basic Ix mixtures without their original Nexus envelope, E3/Moritani, Homeworld/Discovery/Banker overlays, public starts and played-game conversion are not opened.',
|
||||
'\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. Native printed CHOAM costs pause before their effect; printed Karama canceling these powers or native Guild/Emperor/Richese income is separately offered before denial. 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/Sapho battle timing, other producers/modules, retrofit and public starts remain guarded.',
|
||||
'\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. Native printed CHOAM costs pause before their effect; printed Karama canceling these powers or native Guild/Emperor/Richese income is separately offered before denial. 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/Sapho battle timing, other producers/modules, retrofit and public starts remain guarded.',
|
||||
'\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.' +
|
||||
|
||||
Reference in new issue
Block a user