Offer native CHOAM Homeworld discard costs to Semuta
This commit is contained in:
1 parent
68f7a101a3
commit
506bf25bc6
13 files changed
+762
-10
No files matched your search
@@ -1167,13 +1167,15 @@ export function GameTable({
|
||||
Semuta Drug with Homeworlds development preview · original Richese,
|
||||
CHOAM and classic occupation setup, with no other optional modules.
|
||||
Paid high-Kaitain closing discards, occupied Tupile lost-slot cleanup,
|
||||
a completed paid Nullentropy Box search, a clean Distrans transfer,
|
||||
a definite Truthtrance answer, an ordinary used special
|
||||
card played in this profile and printed Karama canceling native Guild,
|
||||
Emperor auction or Richese purchase income offer Semuta here. Private discarded faces remain
|
||||
hidden until commitment; a simultaneous group requires one selection.
|
||||
Claim or Continue keeps the paid price or lost slot and resumes the
|
||||
same original source once. Canceling native income does not cancel
|
||||
paid Nullentropy Box search, clean Distrans transfer, definite
|
||||
Truthtrance answers and used ordinary special cards offer Semuta.
|
||||
Native printed Kulon, Baliset, La La La and Trip to Gamont costs
|
||||
also pause before their original effect. Printed Karama canceling
|
||||
Guild, Emperor auction or Richese purchase income remains supported.
|
||||
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.
|
||||
Canceling native income does not cancel
|
||||
the printed occupied share: its original receiver still allocates it
|
||||
before Movement, the next auction lot or saved purchase controls.
|
||||
Other Homeworld producers and Basic unresolved Tupile loss remain
|
||||
|
||||
@@ -29,6 +29,32 @@ 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 Homeworld Worthless powers — 13 October.**
|
||||
The existing Homeworld envelope now offers the printed Kulon, reactive
|
||||
Baliset, La La La and Trip to Gamont discard costs before their original
|
||||
effects. Native setup, real Homeworld reserve shipment and original phase
|
||||
controls remain; no wallet, clock, alliance or force/custody ledger is assigned.
|
||||
Eight actual before programs had no offer. Sixteen parent claim/pass programs
|
||||
now finish original movement, Revival or closing Mentat once. Kulon adds one
|
||||
range without another move; Baliset stops an uncommitted Atreides move;
|
||||
La La La removes only free allowance; Gamont returns one original board
|
||||
counter to Caladan, not Tanks revival.
|
||||
|
||||
Four actual390px controls declare/allow the powers, recover their costs and
|
||||
resume original ownership. Kulon/Baliset exchange at4/4; the paid CHOAM counter
|
||||
moves at range two and Atreides takes one alternate move after Baliset.
|
||||
Gamont board10→9/reserves10→11/Tanks0 precedes the original CHOAM close to
|
||||
turn2. La La La retains forces and reaches original phase5 Atreides foresight.
|
||||
Evidence: `/tmp/dune-homeworld-native-powers-3214.json`. This is local staged-
|
||||
card proof, not deployed, saved-room or full-mode acceptance. Jubba, Kull
|
||||
counter costs, Nexus and unrelated Homeworld producers remain excluded.
|
||||
The batch passes types/lint and122/122 across eight affected unit files.
|
||||
Forty-two new behavioral cases cover both modes, original decline/cancel,
|
||||
full-hand private exchange and all four minimal legal bot profiles.
|
||||
The application build passes with Vinext's large-chunk and static route-
|
||||
classification warnings; no warning was suppressed and no release gate opens.
|
||||
|
||||
|
||||
**Bureaucrat individual-payer threshold and evidence correction — 13 October.**
|
||||
Separate allied shares below five are no longer summed to create Bureaucrat
|
||||
eligibility. Basic/Advanced native setup and an actual worm alliance reproduce
|
||||
|
||||
@@ -14,6 +14,14 @@ card is the same public fresh batch, claim recovers it and pass leaves it
|
||||
discarded, and the card's own committed effect, payment, gift, answer
|
||||
history, wallets, custody and occupation history are unchanged.
|
||||
|
||||
Native printed Kulon, Baliset, La La La and Trip to Gamont disposal also offers
|
||||
Semuta in this envelope before that power's original effect. Claim/pass adds
|
||||
Kulon's movement bonus once, finishes the free-revival denial without
|
||||
inventing forces or a debit, stops the same uncommitted Baliset move, or
|
||||
returns one original board counter to its native reserves before the same
|
||||
CHOAM closing-Mentat controls resume. Jubba and other producers remain outside
|
||||
this batch; no Nexus or wider optional-module combination is admitted.
|
||||
|
||||
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,58 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 13 October 2026 — native Homeworld CHOAM Worthless discard continuation
|
||||
|
||||
The Homeworld Semuta producer gate excluded the native printed Kulon,
|
||||
Baliset, La La La and Trip to Gamont costs even though their existing native
|
||||
effects already worked there. The gate now admits those exact four existing
|
||||
continuation kinds; no signature, action shape, payment rule, force rule or
|
||||
optional-module profile changes.
|
||||
|
||||
The fixtures use the exposed original post-setup snapshot of the real
|
||||
CHOAM/Richese/Emperor/Atreides Homeworld initializer, before the older Kaitain
|
||||
fixture's hand staging/disposal. Subsequent phase steps are native; selected
|
||||
later physical cards and unplayed Spice order are conserved, not minted.
|
||||
Native reserve shipment supplies the actual CHOAM movement counter.
|
||||
An actual Atreides declaration supplies reactive Baliset's uncommitted move.
|
||||
La La La uses the original free allowance without fabricated Tanks, and
|
||||
Gamont uses an original Atreides starting board counter.
|
||||
|
||||
Eight before programs (both modes × four powers) discarded once without a
|
||||
Semuta offer. Sixteen parent post-cutover claim/pass programs all offer and
|
||||
finish the same original suffix: range-two Kulon/alternate Baliset movement
|
||||
reach Collection with one move, La La La continues to original Movement,
|
||||
and Gamont returns one force to Caladan before CHOAM's original close reaches
|
||||
turn2 Storm. Native decline/Karama Baliset paths still move the original
|
||||
declared group once and retain its unused Baliset.
|
||||
|
||||
Four actual390px human flows use native power controls and Karama allowance,
|
||||
then the owned Semuta commit. Kulon/Baliset exchange at Richese4/4 and retain
|
||||
CHOAM's two-spice paid shipment/reserves19. Kulon moves that counter from
|
||||
Red Chasm7 to Minor Erg5; Baliset leaves ten Atreides counters uncommitted
|
||||
before its legal Oh Gap9 alternate move. La La La leaves reserves/Tanks and
|
||||
wallets unchanged, then reaches the original phase5 Atreides-spice response.
|
||||
Gamont returns board10→9/reserves10→11/Tanks0 and restores the actual CHOAM
|
||||
closing control, which reaches turn2/phase0. Accepted actions/state are in
|
||||
`/tmp/dune-homeworld-native-powers-3214.json` outside Git.
|
||||
|
||||
The first browser navigation timed out while Vite optimized/reloaded
|
||||
dependencies; the warmed service worked without restarting it. A label/From
|
||||
selector mismatch was reported; the actual observed select handled the same
|
||||
route. Cosmetic notices sometimes dim captures. Final browser errors were
|
||||
empty. Only owned temporary files/service/tab were removed. This is local
|
||||
proof, not deployed, persisted-room or complete-rules certification.
|
||||
Jubba, Kull counter disposal, Nexus and other producer gates remain unchanged.
|
||||
Verification: TypeScript passed after repairing reaction-union narrowing;
|
||||
lint passed after removing an unused fixture import. The first test run
|
||||
hit an incidental undefined-versus-null expectation for proactive Revival;
|
||||
that expectation was deleted, not re-pinned. Actual allowance, force,
|
||||
custody and original-control assertions remain. The final eight-file union
|
||||
passes122/122, including42 new cases and all four minimal legal profiles.
|
||||
The application build also passes. It reports module.register deprecation,
|
||||
client chunks over500kB and Vinext static route-classification limitations.
|
||||
Those warnings are not hidden; this checkpoint does not certify deployment.
|
||||
|
||||
|
||||
## 13 October 2026 — individual Bureaucrat payer threshold; evidence correction
|
||||
|
||||
The printed skill names one other payer's payment of at least five spice.
|
||||
|
||||
@@ -855,6 +855,32 @@ 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 — native CHOAM Homeworld Worthless costs.** The existing
|
||||
Homeworld envelope now admits the printed Kulon, Baliset, La La La and Trip
|
||||
to Gamont discard continuations. Their original native rules are unchanged;
|
||||
the authorized supplied Advanced rulebook physical p29 lists the same four
|
||||
powers, and Semuta has no printed Homeworld exclusion. No Nexus fuel, Jubba,
|
||||
Kull counter or unrelated producer is added.
|
||||
|
||||
Original CHOAM/Richese/Emperor/Atreides Homeworld setup and real phase controls
|
||||
produce these costs. Native reserve shipment supplies CHOAM's movement group;
|
||||
a real declared Atreides move supplies reactive Baliset. La La La removes the
|
||||
original free allowance without fabricated Tanks, and closing Gamont returns
|
||||
one original Atreides board counter to Caladan rather than reviving it.
|
||||
Eight actual before programs discarded the printed cost without an offer.
|
||||
Sixteen parent claim/pass programs after admission retain paid prefixes and
|
||||
complete the original movement, Revival or closing-Mentat suffix once.
|
||||
|
||||
Four actual390px owned controls declare the powers, pass native Karama,
|
||||
recover the used cost and resume original controls. Kulon/Baliset exchange
|
||||
at Richese's full4/4 hand; Kulon moves the paid CHOAM counter at range two,
|
||||
Baliset leaves the original ten Atreides counters uncommitted before one
|
||||
alternate move, La La La leaves forces and wallets unchanged, and Gamont
|
||||
returns board10→9/reserves10→11/Tanks0 before CHOAM's original closing choice.
|
||||
`/tmp/dune-homeworld-native-powers-3214.json` records accepted actions and
|
||||
observed state outside Git. Local staged-card proof is not deployed, saved-room
|
||||
or complete-mode acceptance.
|
||||
|
||||
**11 October 2026 — Homeworld ordinary-card reaction.** The bounded Homeworld
|
||||
entry now also offers Semuta for an ordinary used special card played in its
|
||||
roster, a clean Distrans transfer, a definite Truthtrance answer and the used
|
||||
|
||||
+5
-1
@@ -6951,7 +6951,11 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'ordinaryCardDiscard' &&
|
||||
continuation.kind !== 'nullentropyDiscard' &&
|
||||
continuation.kind !== 'distransDiscard' &&
|
||||
continuation.kind !== 'truthtranceDiscard') return false;
|
||||
continuation.kind !== 'truthtranceDiscard' &&
|
||||
continuation.kind !== 'choamLaLaLaDiscard' &&
|
||||
continuation.kind !== 'choamGamontDiscard' &&
|
||||
continuation.kind !== 'choamKulonDiscard' &&
|
||||
continuation.kind !== 'choamBalisetDiscard') return false;
|
||||
if (continuation.kind === 'battleResolved' &&
|
||||
!g.lastBattleContext?.mandatoryDiscard) return false;
|
||||
if ((continuation.kind === 'battleCleanup' ||
|
||||
|
||||
+3
-1
@@ -1186,7 +1186,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'A distinct printed Karama canceling those five ordinary native CHOAM printed or Cunning powers also offers its public cost before the original denial completes. CHOAM retains the declared fuel; a Cunning Nexus remains spent. Claim or Continue restores the same saved response and one original movement, free-revival, storm or closing-Mentat suffix. Acquiring Karama cannot return the canceled force, grant range, stop the original request, protect the storm or restore Nexus. The retained CHOAM fuel is not a discard candidate; Kull and unsupported arrival/flight parents remain separate.',
|
||||
'A printed Karama canceling the Emperor’s shared gift or extra allied revival also offers that exact public cost before the denial completes. The canceled share moves no spice, forces or revival; the Emperor’s ally and extra-revival ledger are unchanged. Claim or Continue restores the saved response and finishes the cancellation once. Only the actual Semuta holder sees the private claimant choice; other seats see the neutral offer.',
|
||||
'A printed Karama canceling original paid Guild shipment income, non-Ambassador Emperor auction income or Richese special-purchase income also offers its exact public cost before the native denial. The price and physical transaction have already committed: claim or Continue cannot refund spice, re-ship forces, undo cache acquisition or replay a purchase. Original Movement, next-lot inspection or the Richese purchase’s suspended controls resume once. A winner may spend the very Karama it just bought; claiming that identity does not reverse its payment. Homeworld and Ambassador payment compositions remain separate.',
|
||||
'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. A completed paid Nullentropy Box search, a clean Distrans transfer, a definite Truthtrance answer, an ordinary used special card played in this profile, paid high-Kaitain disposal, occupied Tupile lost-slot cleanup and printed Karama canceling original Guild/Emperor auction/Richese-purchase income offer Semuta in this envelope. Kaitain pays two spice per card; its private group requires commitment before one selection. Each lost Tupile holder has a separate private cost before the next cleanup or saved opening. Native income cancellation retains the full original price and acquisition/forces but does not cancel the printed occupied portion; its original receiver still allocates it once before Movement, the next lot or saved purchase controls. Low-native rounding, paid invoice and occupation source remain original. Basic unresolved epochs, other Homeworld producers, module mixtures and public starts stay guarded.',
|
||||
'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Paid Nullentropy Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid high-Kaitain disposal, occupied Tupile cleanup and printed Karama canceling Guild/Emperor auction/Richese-purchase income offer Semuta. Native printed Kulon, Baliset, La La La and Trip to Gamont costs also pause before their original effect: claim/pass adds the movement bonus once, denies free revival without moving or charging forces, stops the original uncommitted Baliset move, or returns one original board counter to native reserves before the same CHOAM closing choice. A recovered Worthless card does not grant another faction CHOAM powers. Kaitain private groups require commitment before one selection; each Tupile holder has a separate cost. Canceling native income keeps payment and acquisition/forces but not native credit; its printed occupied portion still goes through the original receiver once. Basic unresolved epochs, Jubba, other producers, module mixtures and public starts remain guarded.',
|
||||
'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.',
|
||||
@@ -1262,6 +1262,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'tests/semuta-homeworld-guild-income.test.ts',
|
||||
'tests/semuta-homeworld-emperor-income.test.ts',
|
||||
'tests/semuta-homeworld-ordinary.test.ts',
|
||||
'tests/semuta-homeworld-choam-movement.test.ts',
|
||||
'tests/semuta-homeworld-choam-returns.test.ts',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { handLimit, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES, gameDistance, location, splitLocation } from '../game/board';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { advanceSemutaHomeworldChoam, applySemutaHomeworldChoamStep,
|
||||
createSemutaHomeworldChoamSetup, holdSemutaHomeworldChoamCard } from './fixture-semuta-homeworld-choam';
|
||||
import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
|
||||
import type { RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
|
||||
export type SemutaHomeworldChoamMovementEffect = 'kulon' | 'baliset';
|
||||
export type SemutaHomeworldChoamMovementFixture = {
|
||||
setup: Game; game: Game; beforeShipment: Game; afterShipment: Game; beforeMove: Game;
|
||||
owner: string; holder: string; target: string; canceller: string; card: string;
|
||||
effect: SemutaHomeworldChoamMovementEffect; action: Action; shipment: Action;
|
||||
move: Action; tooFar: Action | null; alternateMove: Action | null;
|
||||
origin: string; destination: string; actions: RicheseSkillsTechStep[]; staging: string[];
|
||||
};
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
|
||||
export function semutaHomeworldChoamMovementStock(game: Game): string[] {
|
||||
return [game.deck, game.discard, game.richeseCache ?? [], game.richeseRemoved ?? [],
|
||||
...game.players.map(player => player.hand)].flat().map(card => card.id).sort();
|
||||
}
|
||||
export function semutaHomeworldChoamMovementResources(game: Game) {
|
||||
return { custody: structuredClone(game.homeworlds?.custody),
|
||||
players: game.players.map(player => ({ id: player.id, spice: player.spice,
|
||||
reserves: player.reserves, tanks: player.tanks, forces: structuredClone(player.forces),
|
||||
elites: structuredClone(player.elites), shipped: player.shipped, moved: player.moved,
|
||||
ally: player.ally })), spice: structuredClone(game.spice) };
|
||||
}
|
||||
function destinationAtRange(game: Game, origin: string, range: number, excluded: string[] = []) {
|
||||
const destination = TERRITORIES.filter(t => t.type === 'sand' && !excluded.includes(t.id))
|
||||
.flatMap(t => t.sectors.map(sector => location(t.id, sector)))
|
||||
.find(key => splitLocation(key).sector !== game.storm &&
|
||||
game.players.every(player => !(player.forces[key] > 0)) &&
|
||||
gameDistance(game, origin, key, k => splitLocation(k).sector === game.storm) === range);
|
||||
assert.ok(destination, `Original board has a free sand destination at range ${range}`);
|
||||
return destination;
|
||||
}
|
||||
|
||||
/** Original setup, first-turn order and paid native reserves shipment. Only
|
||||
* conserved physical hands and two still-unplayed Spice Cards are positioned. */
|
||||
export function createSemutaHomeworldChoamMovementFixture(
|
||||
effect: SemutaHomeworldChoamMovementEffect, advanced = false, fullHolder = false,
|
||||
): SemutaHomeworldChoamMovementFixture {
|
||||
const native = createSemutaHomeworldChoamSetup(advanced);
|
||||
let game = native.game;
|
||||
const setup = structuredClone(game), actions = native.actions;
|
||||
const staging = ['Conserved original physical Semuta in Richese hand.'];
|
||||
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('Reorder two original unplayed non-worm Spice Cards; no new deck.');
|
||||
game = advanceSemutaHomeworldChoam(game,
|
||||
g => g.phase === 5 && g.active === native.owner && clean(g), actions);
|
||||
assert.equal(game.turn, 1);
|
||||
const card = holdSemutaHomeworldChoamCard(game, native.owner, effect === 'kulon' ? 'Kulon' : 'Baliset');
|
||||
holdSemutaHomeworldChoamCard(game, native.canceller, 'Karama');
|
||||
staging.push(`Conserved printed ${effect} to CHOAM and original Karama to Emperor.`);
|
||||
assert.ok(semutaEmperorPlayer(game, native.owner).hand.length <= handLimit(semutaEmperorPlayer(game, native.owner)));
|
||||
if (fullHolder) {
|
||||
const holder = semutaEmperorPlayer(game, native.holder);
|
||||
while (holder.hand.length < handLimit(holder)) {
|
||||
assert.ok(game.deck[0]);
|
||||
moveSemutaEmperorCard(game, game.deck[0].id, holder.id);
|
||||
}
|
||||
staging.push('Conserved original undealt cards fill Richese to its native hand limit.');
|
||||
}
|
||||
const origin = effect === 'kulon' ? 'red_chasm:7'
|
||||
: Object.keys(semutaEmperorPlayer(game, native.target).forces).find(key => semutaEmperorPlayer(game, native.target).forces[key] > 0)!;
|
||||
assert.ok(origin);
|
||||
const destination = destinationAtRange(game, origin, effect === 'kulon' ? 2 : 1);
|
||||
const shipTo = effect === 'kulon' ? origin : destination;
|
||||
const sources = nativeShipmentSources(viewGame(game, native.owner), 1, 0);
|
||||
assert.ok(sources);
|
||||
const shipment: Action = { type: 'ship', ...splitLocation(shipTo), amount: 1, elite: 0,
|
||||
homeworldSources: sources, allyPayment: 0 };
|
||||
const beforeShipment = structuredClone(game);
|
||||
game = applySemutaHomeworldChoamStep(game, native.owner, shipment, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, g => clean(g), actions);
|
||||
const afterShipment = structuredClone(game);
|
||||
const move: Action = { type: 'move', from: origin, amount: 1, elite: 0, ...splitLocation(destination) };
|
||||
let beforeMove = structuredClone(game);
|
||||
let tooFar: Action | null = null, alternateMove: Action | null = null;
|
||||
if (effect === 'kulon') {
|
||||
tooFar = { type: 'move', from: origin, amount: 1, elite: 0,
|
||||
...splitLocation(destinationAtRange(game, origin, 3)) };
|
||||
} else {
|
||||
game = applySemutaHomeworldChoamStep(game, native.owner, { type: 'endMovement' }, actions);
|
||||
game = advanceSemutaHomeworldChoam(game,
|
||||
g => g.phase === 5 && g.active === native.target && clean(g), actions);
|
||||
alternateMove = { type: 'move', from: origin, amount: 1, elite: 0,
|
||||
...splitLocation(destinationAtRange(game, origin, 1, [splitLocation(destination).territory])) };
|
||||
beforeMove = structuredClone(game);
|
||||
game = applySemutaHomeworldChoamStep(game, native.target, move, actions);
|
||||
assert.equal(game.decision?.kind, 'choamMovement');
|
||||
assert.deepEqual(semutaEmperorPlayer(game, native.target).forces, semutaEmperorPlayer(beforeMove, native.target).forces);
|
||||
assert.equal(semutaEmperorPlayer(game, native.target).moved, 0);
|
||||
}
|
||||
const view = viewGame(game, native.owner);
|
||||
const printed = choamPowerPlays(view, effect).find(play => play.source === 'printed' && play.card.id === card);
|
||||
assert.ok(printed);
|
||||
const action = choamPowerAction(view, printed, effect === 'baliset'
|
||||
? { target: native.target, territory: splitLocation(destination).territory } : {});
|
||||
assert.ok(action);
|
||||
return { setup, game, beforeShipment, afterShipment, beforeMove,
|
||||
owner: native.owner, holder: native.holder, target: effect === 'kulon' ? native.owner : native.target,
|
||||
canceller: native.canceller, card, effect, action, shipment, move, tooFar, alternateMove,
|
||||
origin, destination, actions, staging };
|
||||
}
|
||||
|
||||
/** Pass only actual entitled native Worthless cancellation controls. */
|
||||
export function allowSemutaHomeworldChoamMovement(state: Game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = state;
|
||||
for (let count = 0; game.response && count < 32; count++) {
|
||||
assert.equal(game.response.kind, 'choamWorthless');
|
||||
const entitled = game.players.find(player => {
|
||||
const controls = viewGame(game, player.id).responseControls;
|
||||
return controls && !controls.hasPassed && controls.cancelCards.length > 0;
|
||||
});
|
||||
assert.ok(entitled, 'An original entitled cancellation control must allow the power');
|
||||
game = applySemutaHomeworldChoamStep(game, entitled.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
export function disposeSemutaHomeworldChoamMovement(fixture: SemutaHomeworldChoamMovementFixture,
|
||||
state = fixture.game, actions?: RicheseSkillsTechStep[]): Game {
|
||||
return allowSemutaHomeworldChoamMovement(
|
||||
applySemutaHomeworldChoamStep(state, fixture.owner, fixture.action, actions), actions);
|
||||
}
|
||||
|
||||
/** Execute a useful original ground move, then finish the original phase by
|
||||
* native controls. No phase, active player, wallet or custody is reassigned. */
|
||||
export function finishSemutaHomeworldChoamMovement(state: Game,
|
||||
fixture: SemutaHomeworldChoamMovementFixture, actions?: RicheseSkillsTechStep[]): Game {
|
||||
let game = applySemutaHomeworldChoamStep(state, fixture.target,
|
||||
fixture.effect === 'kulon' ? fixture.move : fixture.alternateMove!, actions);
|
||||
game = advanceSemutaHomeworldChoam(game, g => g.phase > 5 && clean(g), actions);
|
||||
assert.equal(game.turn, 1);
|
||||
return game;
|
||||
}
|
||||
export function withoutSemutaHomeworldChoamMovementHolder(state: Game, holder: string): Game {
|
||||
const game = structuredClone(state), hand = semutaEmperorPlayer(game, holder).hand;
|
||||
const index = hand.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
||||
const replacement = game.deck.findIndex(card => card.effect !== 'karama');
|
||||
assert.ok(index >= 0 && replacement >= 0);
|
||||
const semuta = hand[index];
|
||||
hand[index] = game.deck.splice(replacement, 1)[0];
|
||||
game.deck.push(semuta);
|
||||
return game;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { choamPowerAction, choamPowerPlays } from '../game/choam-power-options';
|
||||
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
|
||||
import { semutaEmperorPlayer } 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 SemutaHomeworldChoamReturnPower = 'laLaLa' | 'gamont';
|
||||
export type SemutaHomeworldChoamReturnsFixture = {
|
||||
game: Game;
|
||||
setup: Game;
|
||||
owner: string;
|
||||
holder: string;
|
||||
target: string;
|
||||
card: string;
|
||||
power: SemutaHomeworldChoamReturnPower;
|
||||
action: Action;
|
||||
from: string;
|
||||
actions: RicheseSkillsTechStep[];
|
||||
originalCards: string[];
|
||||
};
|
||||
|
||||
/** Original native setup, first Revival or closing Mentat, no assigned clocks,
|
||||
* wallets, Tanks, force positions or Homeworld custody. The selected Atreides
|
||||
* counter is still on its original starting board sector. Only the two physical
|
||||
* card positions (Semuta and the printed power) are conserved staging. */
|
||||
export function createSemutaHomeworldChoamReturnsFixture(
|
||||
power: SemutaHomeworldChoamReturnPower, advanced = false,
|
||||
): SemutaHomeworldChoamReturnsFixture {
|
||||
const native = createSemutaHomeworldChoamSetup(advanced);
|
||||
const { owner, holder, target, actions } = native;
|
||||
let game = native.game;
|
||||
const card = holdSemutaHomeworldChoamCard(game, owner,
|
||||
power === 'laLaLa' ? 'La La La' : 'Trip to Gamont');
|
||||
const setup = structuredClone(game);
|
||||
const originalCards = biddingPhysicalIds(game);
|
||||
const from = Object.keys(semutaEmperorPlayer(setup, target).forces)[0];
|
||||
assert.ok(from, 'The original Atreides setup has an on-board force');
|
||||
game = advanceSemutaHomeworldChoam(game, state => power === 'laLaLa'
|
||||
? state.phase === 4 && !state.phaseOpening && !state.response && !state.decision && !state.pendingTreacheryDiscard
|
||||
: state.decision?.kind === 'choamMentat', actions);
|
||||
assert.equal(game.turn, setup.turn, 'Never advance to a replacement source turn');
|
||||
assert.equal(game.phase, power === 'laLaLa' ? 4 : 8);
|
||||
assert.deepEqual(semutaEmperorPlayer(game, target).forces,
|
||||
semutaEmperorPlayer(setup, target).forces, 'The recalled force is original, not a staged shipment or casualty');
|
||||
assert.deepEqual(biddingPhysicalIds(game), originalCards);
|
||||
if (power === 'gamont') {
|
||||
assert.equal(game.decision!.player, owner);
|
||||
assert.equal(game.choamMentatPending, true);
|
||||
}
|
||||
const view = viewGame(game, owner);
|
||||
const play = choamPowerPlays(view, power).find(play => play.source === 'printed' && play.card.id === card);
|
||||
assert.ok(play);
|
||||
const action = choamPowerAction(view, play, { target, ...(power === 'gamont' ? { from, elite: 0 } : {}) });
|
||||
assert.ok(action, 'The original private controls admit the printed power');
|
||||
return { game, setup, owner, holder, target, card, power, action, from, actions, originalCards };
|
||||
}
|
||||
|
||||
/** Complete the real Karama response queue, stopping at the cost offer. */
|
||||
export function playSemutaHomeworldChoamReturns(
|
||||
fixture: SemutaHomeworldChoamReturnsFixture, state = fixture.game,
|
||||
): Game {
|
||||
let game = applySemutaHomeworldChoamStep(state, fixture.owner, fixture.action);
|
||||
for (let n = 0; game.response?.kind === 'choamWorthless' && n < game.players.length; n++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id))!.id;
|
||||
game = applyAction(game, actor, { type: 'passResponse' });
|
||||
}
|
||||
assert.notEqual(game.response?.kind, 'choamWorthless');
|
||||
return game;
|
||||
}
|
||||
|
||||
export function semutaHomeworldChoamReturnsForces(game: Game) {
|
||||
const worlds = viewGame(game, game.host).homeworlds!.worlds!;
|
||||
return game.players.map(player => {
|
||||
const away = worlds.filter(home => home.native !== player.id)
|
||||
.map(home => home.forces[player.id]).filter(Boolean);
|
||||
return {
|
||||
id: player.id,
|
||||
total: player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0)
|
||||
+ away.reduce((sum, group) => sum + group.normal + group.elite, 0),
|
||||
elite: (player.elites?.reserves ?? 0) + (player.elites?.tanks ?? 0)
|
||||
+ Object.values(player.elites?.forces ?? {}).reduce((sum, n) => sum + n, 0)
|
||||
+ away.reduce((sum, group) => sum + group.elite, 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, type Action, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { createSemutaKaitainFixture } from './fixture-semuta-kaitain';
|
||||
import { nextRicheseSkillsTechNativeStep, type RicheseSkillsTechStep } from './fixture-richese-skills-tech';
|
||||
import { moveSemutaEmperorCard, semutaEmperorPlayer } from './fixture-semuta-emperor-share';
|
||||
|
||||
export function createSemutaHomeworldChoamSetup(advanced = false) {
|
||||
const native = createSemutaKaitainFixture({ advanced, count: 1 });
|
||||
// This exposed snapshot is the original completed native setup, before that
|
||||
// fixture's later Kaitain hand staging or paid disposal. Not a clock rewind.
|
||||
const game = structuredClone(native.setup);
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.ok(game.homeworlds?.custody && game.semutaPreview);
|
||||
assert.ok(game.players.some(p => p.faction === 'choam'));
|
||||
moveSemutaEmperorCard(game, SEMUTA_DRUG_ID, native.holder);
|
||||
assert.ok(semutaEmperorPlayer(game, native.holder).hand.length <= handLimit(semutaEmperorPlayer(game, native.holder)));
|
||||
return { game, owner: native.choam, holder: native.holder,
|
||||
target: game.players.find(p => p.faction === 'atreides')!.id,
|
||||
canceller: native.owner, actions: [] as RicheseSkillsTechStep[] };
|
||||
}
|
||||
|
||||
/** Conserved physical card only; never mint a face or rewrite the whole deck. */
|
||||
export function holdSemutaHomeworldChoamCard(game: Game, owner: string, name: string): string {
|
||||
const card = [...treacheryDeck(game.expansions), ...richeseCards()].find(card => card.name === name);
|
||||
assert.ok(card, `Missing printed card ${name}`);
|
||||
return moveSemutaEmperorCard(game, card.id, owner);
|
||||
}
|
||||
|
||||
/** Caller tests the requested boundary before each real native action. */
|
||||
export function advanceSemutaHomeworldChoam(
|
||||
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');
|
||||
let next: RicheseSkillsTechStep;
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending?.reaction;
|
||||
if (pending && reaction?.stage === 'offer') {
|
||||
const actor = game.players.find(p => !reaction.passed.includes(p.id))!.id;
|
||||
next = { actor, action: { type: 'semutaPass', event: pending.batch.event } };
|
||||
} else if (game.decision?.kind === 'choamMarket') {
|
||||
next = { actor: game.decision.player, action: { type: 'decision', done: true } };
|
||||
} else next = nextRicheseSkillsTechNativeStep(game);
|
||||
actions?.push(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
assert.ok(until(game), 'The native Homeworld continuation must reach its boundary');
|
||||
return game;
|
||||
}
|
||||
|
||||
export function reactSemutaHomeworldChoam(state: Game, holder: string, claim: boolean): Game {
|
||||
const event = state.pendingTreacheryDiscard!.batch.event;
|
||||
if (claim) return applyAction(state, holder, { type: 'semutaCommit', event });
|
||||
let game = state;
|
||||
for (const player of state.players) game = applyAction(game, player.id, { type: 'semutaPass', event });
|
||||
return game;
|
||||
}
|
||||
|
||||
export function applySemutaHomeworldChoamStep(game: Game, actor: string, action: Action,
|
||||
actions?: RicheseSkillsTechStep[]): Game {
|
||||
actions?.push({ actor, action });
|
||||
return applyAction(game, actor, action);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, handLimit, normalizeAutomaticGame, 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 { splitLocation } from '../game/board';
|
||||
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
|
||||
import { semutaEmperorPlayer as player } from './fixture-semuta-emperor-share';
|
||||
import {
|
||||
createSemutaHomeworldChoamMovementFixture, disposeSemutaHomeworldChoamMovement,
|
||||
finishSemutaHomeworldChoamMovement,
|
||||
semutaHomeworldChoamMovementResources as resources, semutaHomeworldChoamMovementStock as stock,
|
||||
withoutSemutaHomeworldChoamMovementHolder,
|
||||
type SemutaHomeworldChoamMovementEffect,
|
||||
} from './fixture-semuta-homeworld-choam-movement';
|
||||
|
||||
const effects: SemutaHomeworldChoamMovementEffect[] = ['kulon', 'baliset'];
|
||||
for (const advanced of [false, true]) for (const effect of effects) {
|
||||
const label = `${advanced ? 'Advanced' : 'Basic'} Homeworld ${effect}`;
|
||||
for (const claim of [false, true]) void test(`${label}: Semuta ${claim ? 'claim' : 'pass'} preserves paid native shipment and applies the printed movement power once`, () => {
|
||||
const f = createSemutaHomeworldChoamMovementFixture(effect, advanced);
|
||||
const inventory = stock(f.game), paid = resources(f.game);
|
||||
assert.equal(player(f.afterShipment, f.owner).spice, player(f.beforeShipment, f.owner).spice - 2,
|
||||
'One ordinary off-planet force into desert pays exactly two spice');
|
||||
assert.equal(player(f.afterShipment, f.owner).reserves, player(f.beforeShipment, f.owner).reserves - 1);
|
||||
assert.equal(player(f.afterShipment, f.owner).forces[effect === 'kulon' ? f.origin : f.destination], 1);
|
||||
assert.deepEqual(f.afterShipment.homeworlds?.custody, f.setup.homeworlds?.custody);
|
||||
if (effect === 'kulon') assert.throws(() => applyAction(f.game, f.owner, f.move));
|
||||
else {
|
||||
assert.equal(f.game.decision?.kind, 'choamMovement');
|
||||
assert.deepEqual(player(f.game, f.target).forces, player(f.beforeMove, f.target).forces);
|
||||
assert.equal(player(f.game, f.target).moved, 0, 'Declared move remains wholly uncommitted');
|
||||
}
|
||||
const offered = disposeSemutaHomeworldChoamMovement(f);
|
||||
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true);
|
||||
assert.equal(offered.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(player(offered, f.owner).hand.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(resources(offered), paid, 'Paid prefix, original force custody and wallets are frozen during the offer');
|
||||
assert.deepEqual(stock(offered), inventory);
|
||||
assert.deepEqual(normalizeAutomaticGame(structuredClone(offered)), offered);
|
||||
assert.throws(() => applyAction(offered, f.target, f.move));
|
||||
assert.equal(viewGame(offered, f.owner).choamMovementBonus, 0);
|
||||
assert.deepEqual(viewGame(offered, f.target).balisetRestrictions, []);
|
||||
const event = offered.pendingTreacheryDiscard!.batch.event;
|
||||
const resumed = reactSemutaHomeworldChoam(structuredClone(offered), f.holder, claim);
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
assert.deepEqual(resources(resumed), paid);
|
||||
assert.deepEqual(stock(resumed), inventory);
|
||||
assert.equal(player(resumed, f.holder).hand.filter(card => card.id === f.card).length, claim ? 1 : 0);
|
||||
assert.equal(resumed.discard.filter(card => card.id === f.card).length, claim ? 0 : 1);
|
||||
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, claim ? 1 : 0);
|
||||
assert.throws(() => applyAction(resumed, f.holder, { type: 'semutaCommit', event }));
|
||||
if (effect === 'kulon') {
|
||||
assert.equal(viewGame(resumed, f.owner).choamMovementBonus, 1);
|
||||
assert.throws(() => applyAction(resumed, f.owner, f.tooFar!),
|
||||
'Exactly one extra range, not two');
|
||||
const moved = applyAction(resumed, f.owner, f.move);
|
||||
assert.equal(player(moved, f.owner).moved, 1);
|
||||
assert.equal(player(moved, f.owner).forces[f.origin] ?? 0, 0);
|
||||
assert.equal(player(moved, f.owner).forces[f.destination], 1);
|
||||
assert.throws(() => applyAction(moved, f.owner, f.move),
|
||||
'Kulon does not grant a second move');
|
||||
} else {
|
||||
const restrictions = viewGame(resumed, f.target).balisetRestrictions;
|
||||
assert.equal(restrictions.length, 1);
|
||||
assert.equal(restrictions[0].territory, splitLocation(f.destination).territory);
|
||||
assert.equal(resumed.pendingChoamMove, null);
|
||||
assert.equal(player(resumed, f.target).moved, 0);
|
||||
assert.deepEqual(player(resumed, f.target).forces, player(f.beforeMove, f.target).forces);
|
||||
assert.throws(() => applyAction(resumed, f.target, f.move));
|
||||
assert.throws(() => applyAction(resumed, f.owner, f.action),
|
||||
'The used printed physical card cannot apply a second restriction');
|
||||
}
|
||||
const completed = finishSemutaHomeworldChoamMovement(resumed, f);
|
||||
assert.equal(completed.phase, 7, 'Real endMovement and CHOAM phase controls reach Spice Collection');
|
||||
assert.equal(completed.turn, 1);
|
||||
assert.equal(player(completed, f.target).moved, 1);
|
||||
assert.equal(player(completed, f.owner).reserves, player(f.afterShipment, f.owner).reserves,
|
||||
'A response and its suffix never ship the original reserve twice');
|
||||
assert.deepEqual(completed.homeworlds?.custody, f.setup.homeworlds?.custody);
|
||||
assert.deepEqual(stock(completed), inventory);
|
||||
});
|
||||
|
||||
void test(`${label}: singleton exchange is legal at the original full hand limit and entitlement remains private`, () => {
|
||||
const f = createSemutaHomeworldChoamMovementFixture(effect, advanced, true);
|
||||
const absent = withoutSemutaHomeworldChoamMovementHolder(f.game, f.holder);
|
||||
assert.equal(player(f.game, f.holder).hand.length, handLimit(player(f.game, f.holder)));
|
||||
const offered = disposeSemutaHomeworldChoamMovement(f);
|
||||
const neutral = disposeSemutaHomeworldChoamMovement(f, absent);
|
||||
assert.equal(viewGame(offered, f.holder).semutaReaction?.canCommit, true,
|
||||
'Exchanging physical Semuta for the singleton needs no fifth slot');
|
||||
assert.equal(viewGame(neutral, f.holder).semutaReaction?.canCommit, false);
|
||||
for (const seat of offered.players.filter(seat => seat.id !== f.holder)) {
|
||||
assert.deepEqual(viewGame(offered, seat.id), viewGame(neutral, seat.id),
|
||||
'An uninvolved player cannot infer whether Richese owns Semuta');
|
||||
assert.equal(viewGame(offered, seat.id).semutaReaction?.canCommit, false);
|
||||
assert.throws(() => applyAction(offered, seat.id,
|
||||
{ type: 'semutaCommit', event: offered.pendingTreacheryDiscard!.batch.event }));
|
||||
}
|
||||
const claimed = reactSemutaHomeworldChoam(offered, f.holder, true);
|
||||
assert.equal(player(claimed, f.holder).hand.length, handLimit(player(claimed, f.holder)));
|
||||
assert.equal(player(claimed, f.holder).hand.some(card => card.id === f.card), true);
|
||||
assert.equal(player(claimed, f.holder).hand.some(card => card.id === SEMUTA_DRUG_ID), false);
|
||||
assert.deepEqual(stock(claimed), stock(f.game));
|
||||
assert.equal(player(finishSemutaHomeworldChoamMovement(claimed, f), f.target).moved, 1);
|
||||
});
|
||||
|
||||
void test(`${label}: all four minimal bot profiles legally claim or pass then finish the native move`, () => {
|
||||
const f = createSemutaHomeworldChoamMovementFixture(effect, advanced);
|
||||
const offered = disposeSemutaHomeworldChoamMovement(f);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game: Game = structuredClone(offered);
|
||||
const own = viewGame(game, f.holder);
|
||||
own.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(own)[0];
|
||||
assert.ok(action, `${difficulty} has an entitled neutral receipt action`);
|
||||
assert.ok(['semutaCommit', 'semutaPass'].includes(action.type));
|
||||
game = applyAction(game, f.holder, action);
|
||||
while (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
|
||||
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
|
||||
if (reaction?.stage !== 'offer') throw Error('Missing neutral discard offer');
|
||||
const actor = game.players.find(seat => !reaction.passed.includes(seat.id))!;
|
||||
game = applyAction(game, actor.id, { type: 'semutaPass', event: pending.batch.event });
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(player(game, f.holder).hand.some(card => card.id === f.card), action.type === 'semutaCommit');
|
||||
assert.deepEqual(resources(game), resources(f.game));
|
||||
const completed = finishSemutaHomeworldChoamMovement(game, f);
|
||||
assert.equal(player(completed, f.target).moved, 1);
|
||||
assert.deepEqual(stock(completed), stock(f.game));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} native Baliset decline and Karama allow the original declared move exactly once`, () => {
|
||||
const f = createSemutaHomeworldChoamMovementFixture('baliset', advanced);
|
||||
const declined = applyAction(f.game, f.owner, { type: 'decision', decline: true });
|
||||
const declared = applyAction(f.game, f.owner, f.action);
|
||||
const karama = player(declared, f.canceller).hand.find(card => card.name === 'Karama');
|
||||
assert.ok(karama);
|
||||
const cancellation = applyAction(declared, f.canceller, { type: 'card', mode: 'cancel', card: karama.id });
|
||||
const allowed = advanceSemutaHomeworldChoam(cancellation,
|
||||
game => !game.pendingTreacheryDiscard && !game.response && !game.decision && !game.phaseOpening);
|
||||
for (const game of [declined, allowed]) {
|
||||
assert.equal(player(game, f.target).moved, 1);
|
||||
assert.equal(player(game, f.target).forces[f.origin], player(f.beforeMove, f.target).forces[f.origin] - 1);
|
||||
assert.equal(player(game, f.target).forces[f.destination], 1);
|
||||
assert.equal(player(game, f.owner).hand.some(card => card.id === f.card), true);
|
||||
assert.equal(game.discard.some(card => card.id === f.card), false);
|
||||
assert.equal(game.pendingChoamMove, null);
|
||||
assert.deepEqual(viewGame(game, f.target).balisetRestrictions, []);
|
||||
assert.equal(player(game, f.owner).reserves, player(f.afterShipment, f.owner).reserves);
|
||||
assert.deepEqual(game.homeworlds?.custody, f.setup.homeworlds?.custody);
|
||||
assert.deepEqual(stock(game), stock(f.game));
|
||||
assert.throws(() => applyAction(game, f.owner, { type: 'decision', decline: true }));
|
||||
assert.throws(() => applyAction(game, f.target, f.move));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, 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 { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
|
||||
import { semutaEmperorPlayer } from './fixture-semuta-emperor-share';
|
||||
import { advanceSemutaHomeworldChoam, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
|
||||
import {
|
||||
createSemutaHomeworldChoamReturnsFixture, playSemutaHomeworldChoamReturns,
|
||||
semutaHomeworldChoamReturnsForces, type SemutaHomeworldChoamReturnsFixture,
|
||||
} from './fixture-semuta-homeworld-choam-returns';
|
||||
|
||||
const wallets = (game: Game) => game.players.map(player => ({ id: player.id, spice: player.spice }));
|
||||
const counters = (game: Game, id: string) => {
|
||||
const p = semutaEmperorPlayer(game, id);
|
||||
return { forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
elites: p.elites, revived: p.revived, freeForcesRevived: p.freeForcesRevived, battleLosses: p.battleLosses };
|
||||
};
|
||||
const homeForces = (game: Game) => viewGame(game, game.host).homeworlds!.worlds!
|
||||
.map(home => ({ id: home.id, native: home.native, forces: home.forces }));
|
||||
|
||||
/** Shared observable contract: exactly one printed effect follows the recovery,
|
||||
* and recovery changes card ownership only, not the source's native economics. */
|
||||
function assertSettled(f: SemutaHomeworldChoamReturnsFixture, done: Game, claim: boolean) {
|
||||
assert.equal(done.pendingTreacheryDiscard, null);
|
||||
const holder = semutaEmperorPlayer(done, f.holder);
|
||||
assert.equal(holder.hand.some(card => card.id === f.card), claim);
|
||||
assert.equal(holder.hand.some(card => card.id === SEMUTA_DRUG_ID), !claim);
|
||||
assert.equal(done.discard.filter(card => card.id === f.card).length, Number(!claim));
|
||||
assert.equal(done.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
|
||||
assert.equal(semutaEmperorPlayer(done, f.owner).hand.some(card => card.id === f.card), false);
|
||||
assert.deepEqual(biddingPhysicalIds(done), f.originalCards);
|
||||
assert.deepEqual(semutaHomeworldChoamReturnsForces(done), semutaHomeworldChoamReturnsForces(f.setup));
|
||||
assert.deepEqual(wallets(done), wallets(f.game));
|
||||
assert.equal(done.turn, f.game.turn);
|
||||
assert.equal(done.phase, f.game.phase);
|
||||
for (const player of done.players.filter(player => player.id !== f.target))
|
||||
assert.deepEqual(counters(done, player.id), counters(f.game, player.id));
|
||||
|
||||
if (f.power === 'laLaLa') {
|
||||
const before = viewGame(f.game, f.target).revival;
|
||||
const after = viewGame(done, f.target).revival;
|
||||
assert.ok(before.freeRemaining > 0, 'Original Atreides has a genuine free allowance');
|
||||
assert.equal(before.freeBlocked, false);
|
||||
assert.equal(after.freeRemaining, 0, 'The printed power removes the actual free-revival allowance');
|
||||
assert.equal(after.freeBlocked, true);
|
||||
assert.equal(after.limit, before.limit, 'Paid revival allowance is not canceled');
|
||||
assert.equal(viewGame(done, f.owner).choamWorthless!.targets.includes(f.target), false);
|
||||
for (const player of done.players.filter(player => player.id !== f.target))
|
||||
assert.equal(viewGame(done, player.id).revival.freeRemaining, viewGame(f.game, player.id).revival.freeRemaining);
|
||||
assert.deepEqual(counters(done, f.target), counters(f.game, f.target),
|
||||
'The original Revival has no Tanks: proactive denial must not invent or revive a casualty');
|
||||
assert.deepEqual(homeForces(done), homeForces(f.game));
|
||||
} else {
|
||||
const before = semutaEmperorPlayer(f.game, f.target), after = semutaEmperorPlayer(done, f.target);
|
||||
assert.equal(after.forces[f.from], before.forces[f.from] - 1);
|
||||
assert.equal(after.reserves, before.reserves + 1, 'One ORIGINAL board token returns to native reserves');
|
||||
assert.equal(after.tanks, before.tanks, 'Trip is not a resurrection from Tanks');
|
||||
assert.equal(after.revived, before.revived);
|
||||
assert.equal(after.freeForcesRevived, before.freeForcesRevived);
|
||||
assert.equal(after.battleLosses, before.battleLosses);
|
||||
assert.deepEqual(after.elites, before.elites);
|
||||
assert.deepEqual(Object.entries(after.forces).filter(([key]) => key !== f.from),
|
||||
Object.entries(before.forces).filter(([key]) => key !== f.from));
|
||||
const expectedHomes = structuredClone(homeForces(f.game));
|
||||
const nativeHome = expectedHomes.find(home => home.native === f.target)!;
|
||||
nativeHome.forces[f.target].normal++;
|
||||
assert.deepEqual(homeForces(done), expectedHomes, 'Public Caladan custody gains exactly that one ordinary token');
|
||||
assert.equal(done.decision?.kind, 'choamMentat');
|
||||
assert.equal(done.decision!.player, f.owner, 'The original closing owner resumes before victory');
|
||||
assert.equal(viewGame(done, f.target).mentatVictoryPending, true);
|
||||
assert.equal(done.status, 'playing');
|
||||
assert.throws(() => applyAction(done, f.target, { type: 'decision', done: true }));
|
||||
}
|
||||
assert.throws(() => applyAction(done, f.owner, f.action), 'The original printed power cannot replay');
|
||||
if (claim) assert.throws(() => applyAction(done, f.holder, f.action),
|
||||
'Recovering a Worthless card does not grant the non-CHOAM holder its faction power');
|
||||
}
|
||||
|
||||
function consumeOriginalSuffix(f: SemutaHomeworldChoamReturnsFixture, state: Game) {
|
||||
const before = structuredClone(counters(state, f.target));
|
||||
const homes = homeForces(state);
|
||||
const next = f.power === 'gamont'
|
||||
? applyAction(state, f.owner, { type: 'decision', done: true })
|
||||
: advanceSemutaHomeworldChoam(state, game => game.phase === 5 && !game.phaseOpening && !game.response && !game.decision);
|
||||
assert.equal(next.turn, f.game.turn + Number(f.power === 'gamont'));
|
||||
assert.equal(next.phase, f.power === 'gamont' ? 0 : 5);
|
||||
assert.equal(next.status, 'playing');
|
||||
assert.deepEqual(counters(next, f.target), before, 'The real next stage must not run the power a second time');
|
||||
assert.deepEqual(homeForces(next), homes);
|
||||
assert.deepEqual(biddingPhysicalIds(next), f.originalCards);
|
||||
if (f.power === 'gamont') {
|
||||
assert.equal(next.choamMentatPending, false);
|
||||
assert.throws(() => applyAction(next, f.owner, { type: 'decision', done: true }),
|
||||
'The original closing decision is consumed once');
|
||||
}
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const power of ['laLaLa', 'gamont'] as const) {
|
||||
for (const claim of [false, true]) void test(
|
||||
`Homeworld ${advanced ? 'Advanced' : 'Basic'} native ${power}: Semuta ${claim ? 'claims' : 'passes'} before the original effect`, () => {
|
||||
const f = createSemutaHomeworldChoamReturnsFixture(power, advanced);
|
||||
const pending = playSemutaHomeworldChoamReturns(f);
|
||||
const cost = pending.pendingTreacheryDiscard;
|
||||
assert.ok(cost, 'The actual printed disposal pauses before its native effect');
|
||||
assert.equal(viewGame(pending, f.holder).semutaReaction!.canCommit, true);
|
||||
assert.equal(pending.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.deepEqual(counters(pending, f.target), counters(f.game, f.target));
|
||||
assert.deepEqual(homeForces(pending), homeForces(f.game));
|
||||
assert.deepEqual(wallets(pending), wallets(f.game));
|
||||
assert.equal(viewGame(pending, f.target).revival.freeRemaining, viewGame(f.game, f.target).revival.freeRemaining);
|
||||
assert.throws(() => applyAction(pending, f.owner, f.action));
|
||||
assert.throws(() => applyAction(pending, f.owner, { type: 'decision', done: true }),
|
||||
'Closing/Revival cannot skip the actual recovery window');
|
||||
assert.throws(() => applyAction(pending, f.target, { type: 'ready' }));
|
||||
assert.throws(() => applyAction(pending, f.target, { type: 'semutaCommit', event: cost.batch.event }));
|
||||
const done = reactSemutaHomeworldChoam(pending, f.holder, claim);
|
||||
assertSettled(f, done, claim);
|
||||
assert.throws(() => applyAction(done, f.holder, { type: 'semutaCommit', event: cost.batch.event }));
|
||||
assert.throws(() => applyAction(done, f.target, { type: 'semutaPass', event: cost.batch.event }));
|
||||
consumeOriginalSuffix(f, done);
|
||||
},
|
||||
);
|
||||
|
||||
for (const difficulty of DIFFICULTIES) void test(
|
||||
`${difficulty} legally completes Homeworld ${advanced ? 'Advanced' : 'Basic'} native ${power} recovery and original controls`, () => {
|
||||
const f = createSemutaHomeworldChoamReturnsFixture(power, advanced);
|
||||
let game = playSemutaHomeworldChoamReturns(f);
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
const view = viewGame(game, f.holder);
|
||||
view.players.find(player => player.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action);
|
||||
assert.ok(action.type === 'semutaCommit' || action.type === 'semutaPass');
|
||||
game = applyAction(game, f.holder, action);
|
||||
for (const player of game.players) {
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer'
|
||||
&& !game.pendingTreacheryDiscard.reaction.passed.includes(player.id))
|
||||
game = applyAction(game, player.id, { type: 'semutaPass', event });
|
||||
}
|
||||
const claimed = semutaEmperorPlayer(game, f.holder).hand.some(card => card.id === f.card);
|
||||
assertSettled(f, game, claimed);
|
||||
if (power === 'gamont') {
|
||||
const closingView = viewGame(game, f.owner);
|
||||
closingView.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||
const close = botActions(closingView)[0];
|
||||
assert.ok(close);
|
||||
const after = applyAction(game, f.owner, close);
|
||||
assert.equal(after.turn, game.turn + 1);
|
||||
assert.equal(after.phase, 0);
|
||||
assert.equal(after.choamMentatPending, false);
|
||||
assert.deepEqual(counters(after, f.target), counters(game, f.target));
|
||||
assert.deepEqual(homeForces(after), homeForces(game));
|
||||
assert.deepEqual(biddingPhysicalIds(after), f.originalCards);
|
||||
} else consumeOriginalSuffix(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 and source history remain original. A completed paid Nullentropy Box search, a clean Distrans transfer, a definite Truthtrance answer, ordinary used special cards, paid high-Kaitain disposal, occupied-Tupile lost-slot cleanup and printed Karama canceling original Guild/Emperor auction/Richese-purchase income offer Semuta before their original suffix. Canceling native income does not cancel the printed occupied portion: its original receiver still allocates it once before Movement, the next lot or saved purchase controls. Other Homeworld producers, Nexus, Skills, Tech, Strongholds, Discovery, Ix/E3 mixing, 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 and source history remain original. Paid Nullentropy Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary special cards, paid high-Kaitain disposal, occupied-Tupile cleanup and printed Karama canceling native Guild/Emperor auction/Richese-purchase income offer Semuta before their original suffix. Native printed Kulon, Baliset, La La La and Trip to Gamont costs also pause before their original effect; recovery cannot repeat a move or force return. Canceling native income does not cancel the printed occupied portion: its original receiver still allocates it once before Movement, the next lot or saved purchase controls. Jubba, other Homeworld producers, Nexus, Skills, Tech, Strongholds, Discovery, Ix/E3 mixing, 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