Enable Basic Richese Semuta in Nexus games
Verify and publish checkpoint container / container (push) Successful in 10m35s
Verify and publish checkpoint container / container (push) Successful in 10m35s
This commit is contained in:
1 parent
7680e1ee69
commit
5b66628399
13 files changed
+494
-15
No files matched your search
+13
-3
@@ -17,15 +17,25 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic public Nexus with Semuta — 30 October. Prototyped / Partial.**
|
||||
Fresh Basic CHOAM-deck Richese/Nexus starts use supported Semuta reactions,
|
||||
including CHOAM trade and Betrayal and simultaneous Sapho question ordering.
|
||||
Emperor Secret Ally revival and ordinary bank purchase retain their original
|
||||
controls; a completed Truthtrance at the bank invoice can offer Semuta before
|
||||
the invoice resumes. Nine related files pass 119 cases. Real public journeys
|
||||
and audit-start Emperor journeys stage conserved card order, positions and an
|
||||
earned Nexus identity. Advanced, other modules and nested sources, full games and deployed acceptance remain
|
||||
open. [Scope](NEXUS_CARD_RULES.md#basic-public-semuta-and-nexus--30-october-2026).
|
||||
|
||||
**Basic mixed-deck Nexus reactions — 30 October. Prototyped / Partial.**
|
||||
In supported selected-deck Nexus starts, Ixian Betrayal now recognizes the
|
||||
actual selected stock and Richese cache, while Harkonnen Betrayal recognizes
|
||||
Basic expansion leaders and the Ix Cheap Hero identity. An owed replacement
|
||||
survives the Ix phase-opening window and draws at the original Mentat opening.
|
||||
Advanced selected-deck Harkonnen reactions remain unfinished and no longer
|
||||
block the original call. Public Semuta/Nexus activation remains deferred:
|
||||
its marker would suppress existing Emperor and Sapho abilities. Twelve related
|
||||
files pass 105 cases; older Advanced drivers were updated for ordered Charity
|
||||
block the original call. The later public Semuta entry above addresses the
|
||||
identified Emperor and Sapho blockers in its narrower Basic scope. Twelve
|
||||
related files pass 105 cases; older Advanced drivers were updated for ordered Charity
|
||||
and Revival, with one failure reproduced on the previous revision.
|
||||
[Scope](NEXUS_CARD_RULES.md#basic-mixed-deck-nexus-reactions--30-october-2026).
|
||||
|
||||
|
||||
@@ -874,9 +874,30 @@ Public-start journeys use genuine setup, worm, Nexus draw, Ixian auction and
|
||||
Harkonnen battle; unopened deck order and, for battle, a conserved traitor
|
||||
transfer and force placement are staged. Pure helper tests additionally cover
|
||||
the Cheap Hero identity. Other rosters, nested effects, complete games and
|
||||
deployed acceptance remain open. Public Semuta/Nexus activation is deferred
|
||||
because the marker currently suppresses existing Emperor Nexus effects and
|
||||
Sapho order; the separate audit initializer is unchanged.
|
||||
deployed acceptance remain open. The later public Semuta entry below removes
|
||||
the stated Emperor and Sapho blockers for its narrower CHOAM-only Basic scope.
|
||||
|
||||
## Basic public Semuta and Nexus — 30 October 2026
|
||||
|
||||
**Prototyped / Partial.** A fresh Basic Richese game with the CHOAM deck,
|
||||
classic or CHOAM peers and Nexus Cards now initializes the existing Semuta
|
||||
protocol. Public setup still requires a qualifying Nexus and actual alliance
|
||||
before a player can draw. CHOAM Secret Ally trade and CHOAM Betrayal retain
|
||||
their original costs and offer their eligible discard; claim or pass resumes the
|
||||
original effect. Simultaneous Truthtrance Sapho ordering likewise resumes the
|
||||
same questions. Emperor Secret Ally's extra revival and ordinary bank-auction
|
||||
purchase remain available, and a completed Truthtrance during the bank payment
|
||||
can offer Semuta before restoring that original invoice.
|
||||
|
||||
The public journeys exercise setup, real Nexus draws, both CHOAM effects and
|
||||
Sapho order; conserved unopened card order, card ownership, force placement
|
||||
and one earned Nexus identity are staged. Emperor revival and bank purchase
|
||||
have genuine audit-start journeys with staged positions, cards and Nexus
|
||||
identity. The ordering combines existing printed effects and the project's
|
||||
provisional reaction convention; it is not a new publisher ruling. Advanced,
|
||||
other optional modules, additional selected decks, nested discard parents,
|
||||
complete games and deployed acceptance remain open. Existing games are not
|
||||
reinitialized.
|
||||
|
||||
## Semuta with original native Nexus — 11 October 2026
|
||||
|
||||
@@ -884,8 +905,9 @@ Fresh `semuta --nexus-cards` composes native Richese with the original
|
||||
single/paired CHOAM or both selected E1/E2 Nexus envelope, Basic/Advanced2–6.
|
||||
Optional explicit `--leader-skills`, Tech3+ and Advanced Strongholds2+ keep
|
||||
their original prerequisites. Actual native setup and a qualifying end-Spice
|
||||
draw remain mandatory: no starting Nexus hand, Homeworlds, Discovery, E3,
|
||||
played-game retrofit or public mode gate is introduced.
|
||||
draw remain mandatory: no starting Nexus hand, Homeworlds, Discovery, E3 or
|
||||
played-game retrofit is introduced. The later public Basic scope above uses
|
||||
this existing initializer.
|
||||
|
||||
The two existing CHOAM discard producers retain their printed mode distinction:
|
||||
Secret Ally with CHOAM absent spends a Worthless and Nexus for two bank spice
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic public Semuta and Nexus — 30 October 2026
|
||||
|
||||
Fresh CHOAM-deck Richese Nexus starts compose existing Semuta, CHOAM and
|
||||
Emperor effects and simultaneous Sapho ordering. Their timing reuses the
|
||||
existing provisional online reaction convention, not a new publisher ruling.
|
||||
[Scope](NEXUS_CARD_RULES.md#basic-public-semuta-and-nexus--30-october-2026).
|
||||
|
||||
## Basic mixed-deck Nexus reactions — 30 October 2026
|
||||
|
||||
Previously initialized Ixian and Harkonnen reactions use the actual selected
|
||||
|
||||
+18
-1
@@ -8092,6 +8092,7 @@ function semutaHomeworldRoster(g: Game): boolean {
|
||||
function semutaTruthOrderProfile(g: Game): boolean {
|
||||
if (bareBasicSemutaProfile(g) || semutaHomeworldRoster(g)) return true;
|
||||
if (selectedDiscoverySkillsSemutaProfile(g)) return true;
|
||||
if (g.semutaPreview === true && !g.advanced && semutaNexusProfile(g)) return true;
|
||||
return g.semutaPreview === true && g.expansions.length === 1 && g.expansions[0] === 'choam' &&
|
||||
!!byFaction(g, 'richese') && g.players.every(p => p.faction === 'richese' ||
|
||||
p.faction === 'choam' || CLASSIC_FACTIONS[p.faction] === true) &&
|
||||
@@ -10164,7 +10165,12 @@ function semutaOfferSupported(
|
||||
? ordinaryDiscardIncomeMatches(g, continuation)
|
||||
: continuation.kind === 'ornithopterDiscard' && continuation.source === 'cunning'
|
||||
? continuation.resume.response?.kind === 'nexusGuildCunning'
|
||||
: !continuation.resume.response) && !continuation.resume.decision &&
|
||||
: !continuation.resume.response) &&
|
||||
(!continuation.resume.decision || continuation.kind === 'truthtranceDiscard' &&
|
||||
ordinaryAuctionDiscard && !g.advanced && semutaNexusProfile(g) &&
|
||||
continuation.resume.decision.kind === 'auctionPayment' &&
|
||||
continuation.resume.decision.player === g.auction?.bidder &&
|
||||
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction)) &&
|
||||
!continuation.resume.pendingKarama &&
|
||||
(continuation.kind === 'amalDiscard'
|
||||
? continuation.resume.phaseOpening?.passed.length === 0
|
||||
@@ -11190,6 +11196,16 @@ function publicSemutaDiscoveryProfile(g: Game): boolean {
|
||||
!g.homeworlds && !g.nexusCards && g.discoveryEnabled === true && !g.ecazTreachery &&
|
||||
!g.leaderSkills && !g.kullPreview && !g.nexusKullPreview && discoveryModuleProfile(g);
|
||||
}
|
||||
/** Fresh Basic CHOAM-deck Nexus starts retain the existing native Semuta path. */
|
||||
function publicSemutaNexusProfile(g: Game): boolean {
|
||||
return g.status === 'lobby' && g.advanced === false &&
|
||||
g.players.length >= 2 && g.players.length <= 6 &&
|
||||
g.expansions.length === 1 && g.expansions[0] === 'choam' && !!byFaction(g, 'richese') &&
|
||||
g.players.every(p => p.faction === 'richese' || p.faction === 'choam' || CLASSIC_FACTIONS[p.faction] === true) &&
|
||||
!!g.nexusCards && !g.homeworlds && !g.discoveryEnabled && !g.ecazTreachery && !g.leaderSkills &&
|
||||
!g.techTokens && !g.strongholdCards && !g.twoPlayerControllers && !g.shortAllianceVictory &&
|
||||
!g.coalitionPreview && !g.kullPreview && !g.nexusKullPreview;
|
||||
}
|
||||
/** Route a normal start through the existing genuine setup for the selected modules. */
|
||||
function startModules(state: Game): Game {
|
||||
const options = state.lobbyOptions ?? {};
|
||||
@@ -11221,6 +11237,7 @@ function startModules(state: Game): Game {
|
||||
// also selected; unsupported combinations must reject, not silently omit cards.
|
||||
if (options.ecazTreachery) return initializeEcazTreacheryGameForAudit(g);
|
||||
if (g.nexusCards) {
|
||||
if (publicSemutaNexusProfile(g)) return initializeSemutaGameForAudit(g);
|
||||
const natives: Record<string, [string, string]> = { ix: ['ixians', 'tleilaxu'], choam: ['choam', 'richese'], ecaz: ['ecaz', 'moritani'] };
|
||||
const pair = g.expansions.length === 1 ? natives[g.expansions[0]] : undefined;
|
||||
return pair && pair.every(id => g.players.some(p => p.faction === id))
|
||||
|
||||
@@ -352,7 +352,12 @@ export function validateEmperorNexusPurchase(
|
||||
* roster and delegated Discovery/Homeworld/Tech/Stronghold envelopes, and the
|
||||
* separately flagged preview aliases never open one. */
|
||||
function nativeBankNexusProfile(g: Game): boolean {
|
||||
if (g.semutaPreview || g.advancedPreview || g.kullPreview || g.nexusKullPreview ||
|
||||
const basicSemutaNexus = g.advanced === false && g.semutaPreview === true &&
|
||||
g.expansions.length === 1 && g.expansions[0] === 'choam' &&
|
||||
g.players.some(player => player.faction === 'richese') &&
|
||||
!g.homeworlds && !g.leaderSkills && !g.techTokens && !g.strongholdCards &&
|
||||
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker;
|
||||
if (g.semutaPreview && !basicSemutaNexus || g.advancedPreview || g.kullPreview || g.nexusKullPreview ||
|
||||
g.guildBetrayalPreview || g.richeseBetrayalPreview ||
|
||||
g.nexusIxianReplacementPreview || g.nexusIxianBetrayalPreview ||
|
||||
g.nexusHarkonnenBetrayalPreview) return false;
|
||||
|
||||
@@ -233,6 +233,12 @@ export function validateEmperorNexusRevival(
|
||||
/** Reuse original fresh roster/module predicates, independently of purchase admission. */
|
||||
function revivalModeSupported(g: Game): boolean {
|
||||
if (nexusCoreProfile(g)) return true;
|
||||
// The original Semuta/Nexus E1/E2 entry already supplies native Nexus
|
||||
// custody. Spending Emperor's Nexus for its extra return discards no
|
||||
// Treachery Card, so the Semuta marker does not change this continuation.
|
||||
if (g.semutaPreview === true && !g.advanced && !g.leaderSkills &&
|
||||
!g.techTokens && !g.strongholdCards &&
|
||||
(singleE1E2NexusProfile(g) || pairedNexusModulesProfile(g) || mixedE1E2NexusProfile(g))) return true;
|
||||
if (g.semutaPreview || g.advancedPreview || g.kullPreview || g.nexusKullPreview ||
|
||||
g.guildBetrayalPreview || g.richeseBetrayalPreview || g.nexusIxianReplacementPreview ||
|
||||
g.nexusIxianBetrayalPreview || g.nexusHarkonnenBetrayalPreview || g.ecazTreachery)
|
||||
|
||||
@@ -1360,6 +1360,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'In fresh Basic games with Richese, classic opponents, the CHOAM deck and Tech Tokens, the existing Semuta preview is also enabled. Other module combinations remain separate; existing started games are not retrofitted.',
|
||||
'Fresh Basic and Advanced Richese games with the CHOAM deck and Leader Skills now use the existing bounded Semuta reaction. Optional Tech Tokens require three seats; Advanced may also select Stronghold Cards. Supported discards retain their original continuations, while other discard sources remain unfinished. Existing games are not converted.',
|
||||
'Fresh Basic and Advanced Richese games with the CHOAM deck and Discoveries also use the bounded Semuta reaction. Shrine role costs and a committed Sapho Testing Station reorder retain their original effects and continue after claim or pass. Other producers and combinations remain unfinished; existing games are not converted.',
|
||||
'Fresh Basic Richese games with the CHOAM deck and Nexus also enable supported Semuta reactions. CHOAM Secret Ally and Betrayal keep their original effects; Sapho ordering and Emperor Secret Ally revival or ordinary bank purchase remain available. Other modules, Advanced starts and nested discard sources retain their separate limits.',
|
||||
'In supported Basic games with both Discoveries and Leader Skills, CHOAM and Ixian Ambassador discards can also offer Semuta during ordinary movement or shipment, a paired Ambassador/Terror entry, and a clean Fremen worm ride. CHOAM credit stays paid; Ixian replacement and the original entry continue once after claim or pass. Other nested sources and Advanced Ambassador interactions remain unfinished.',
|
||||
'Fresh Basic and Advanced Richese games with the CHOAM deck and Homeworlds also use the bounded Semuta reaction. A supported Sapho change to simultaneous Truthtrance order pauses for Semuta, then resumes the same questions. Other discard sources and module combinations remain unfinished; existing games are not converted.',
|
||||
'Fresh Advanced Richese preview games with only the CHOAM expansion deck and classic or CHOAM opponents enable Semuta reactions for supported ordinary Hajr, Ghola, Harvester and Weather Control discards, completed Box and Distrans, simple definite Truthtrance answers, clean Residual Poison, completed CHOAM sales, supported printed CHOAM Worthless costs and supported battle cleanup. Other discard parents and optional-module combinations remain unfinished. Existing games are not retrofitted.',
|
||||
|
||||
@@ -67,6 +67,12 @@ export function nextSemutaNexusBetrayalStep(game: Game): SemutaNexusBetrayalStep
|
||||
assert.ok(skill && leader, 'An actual original offer must have an eligible training choice');
|
||||
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } };
|
||||
}
|
||||
if (!game.response && !game.phaseOpening && !game.decision && !game.truthtrance && !game.pendingNullentropy) {
|
||||
if (game.phase === 2 && game.charityOrder?.remaining[0])
|
||||
return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } };
|
||||
if (game.phase === 4 && game.revivalOrder?.remaining[0])
|
||||
return { actor: game.revivalOrder.remaining[0], action: { type: 'ready' } };
|
||||
}
|
||||
return nextRicheseSkillsTechNativeStep(game);
|
||||
}
|
||||
function orderUnplayedSpice(game: Game, kinds: ('land' | 'worm')[]): void {
|
||||
|
||||
@@ -76,6 +76,12 @@ export function nextSemutaNexusTradeNativeStep(game: Game): SemutaNexusTradeStep
|
||||
assert.ok(skill && own.eligibleLeaders[0], 'Train an actual original offer on an eligible living disc');
|
||||
return { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id } };
|
||||
}
|
||||
if (clean(game) && !game.truthtrance && !game.pendingNullentropy) {
|
||||
if (game.phase === 2 && game.charityOrder?.remaining[0])
|
||||
return { actor: game.charityOrder.remaining[0], action: { type: 'ready' } };
|
||||
if (game.phase === 4 && game.revivalOrder?.remaining[0])
|
||||
return { actor: game.revivalOrder.remaining[0], action: { type: 'ready' } };
|
||||
}
|
||||
return nextRicheseSkillsTechNativeStep(game);
|
||||
}
|
||||
export function advanceSemutaNexusTrade(
|
||||
|
||||
@@ -96,17 +96,22 @@ void test('the previously accepted classic public condition survives unrelated p
|
||||
}
|
||||
});
|
||||
|
||||
void test('Ecaz variant, the missing module and the seated Emperor stay gated', () => {
|
||||
void test('selected independent Ecaz cards and an unused selected E3 deck retain the public core bank path', () => {
|
||||
const ecazTreachery = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
|
||||
ecazTreachery.ecazTreachery = true;
|
||||
assert.equal(emperorNexusBankPurchaseModeSupported(ecazTreachery), true);
|
||||
const unusedEcaz = lobby(['atreides', 'harkonnen'], ['ecaz']);
|
||||
assert.equal(emperorNexusBankPurchaseModeSupported(unusedEcaz), true);
|
||||
});
|
||||
|
||||
void test('the missing module, Sandtrout and seated Emperor stay gated', () => {
|
||||
const withoutNexus = lobby(['atreides', 'harkonnen', 'fremen'], []);
|
||||
withoutNexus.nexusCards = undefined;
|
||||
const sandtrout = lobby(['atreides', 'harkonnen', 'fremen'], []);
|
||||
sandtrout.sandtrout = true;
|
||||
const ecazTreachery = lobby(['ecaz', 'atreides', 'harkonnen'], ['ecaz']);
|
||||
ecazTreachery.ecazTreachery = true;
|
||||
const blocked: [string, Game][] = [
|
||||
['a missing Nexus module', withoutNexus],
|
||||
['Sandtrout', sandtrout],
|
||||
['Ecaz Treachery', ecazTreachery],
|
||||
['an Emperor seat', lobby(['atreides', 'harkonnen', 'emperor'], [])],
|
||||
['an Emperor seat on a native roster', lobby(['harkonnen', 'richese', 'emperor'], ['choam'])],
|
||||
];
|
||||
@@ -118,7 +123,6 @@ void test('unsupported rosters and unsupported module envelopes stay gated', ()
|
||||
const blocked: [string, Game][] = [
|
||||
['an Ecaz seat on a classic roster', lobby(['atreides', 'ecaz'], [])],
|
||||
['a native without its expansion', lobby(['atreides', 'richese'], [])],
|
||||
['an E3 expansion without its natives', lobby(['atreides', 'harkonnen'], ['ecaz'])],
|
||||
['a native from the other E1/E2 family', lobby(['richese', 'ixians', 'atreides'], ['choam'])],
|
||||
['a repeated native faction', lobby(['richese', 'richese', 'choam'], ['choam'])],
|
||||
['Tech with only two seats', withTech(lobby(['atreides', 'harkonnen'], []))],
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { emperorNexusBankPurchaseModeSupported } from '../game/nexus-emperor-purchase';
|
||||
import { advanceSemutaNexusTrade, semutaNexusTradeInventory, semutaNexusTradePlayer, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade';
|
||||
import { nextNexusEmperorBankStep, stepNexusEmperorBank } from './fixture-nexus-emperor-bank';
|
||||
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard && !game.truthtrance;
|
||||
function frontSpice(game: Game, kinds: ('land' | 'worm')[]) {
|
||||
for (let i = 0; i < kinds.length; i++) {
|
||||
const index = game.spiceDeck.findIndex((card, n) => n >= i && (kinds[i] === 'land' ? 'territory' in card : 'worm' in card));
|
||||
assert.ok(index >= i);
|
||||
game.spiceDeck.splice(i, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
/** Real initial setup, original Nexus and auction. Only unopened Spice order,
|
||||
* already-drawn Nexus identity and conserved board positions are staged. */
|
||||
function fixture() {
|
||||
let game = createGame('SEMUTANEXUSBANK', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
|
||||
for (const [id, faction] of [['g','guild'],['a','atreides'],['h','harkonnen']] as const)
|
||||
joinGame(game, newPlayer(id, faction, faction));
|
||||
game.nexusCards = { cards: null, phase: null };
|
||||
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||
game = initializeSemutaGameForAudit(game);
|
||||
assert.equal(game.semutaPreview, true);
|
||||
game = advanceSemutaNexusTrade(game, g => g.status === 'playing');
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
||||
game = advanceSemutaNexusTrade(game, g => g.phase === 1 && clean(g));
|
||||
frontSpice(game, ['land','land']);
|
||||
game = advanceSemutaNexusTrade(game, g => g.phase === 5 && clean(g));
|
||||
for (const player of game.players) {
|
||||
player.reserves += Object.values(player.forces).reduce((total, count) => total + count, 0);
|
||||
player.forces = {};
|
||||
if (player.elites) {
|
||||
player.elites.reserves += Object.values(player.elites.forces).reduce((total, count) => total + count, 0);
|
||||
player.elites.forces = {};
|
||||
}
|
||||
}
|
||||
game = advanceSemutaNexusTrade(game, g => g.turn === 2 && g.phase === 1 && clean(g));
|
||||
frontSpice(game, ['worm','land','land']);
|
||||
game = advanceSemutaNexusTrade(game, g => g.nexus && !g.spiceWindow && clean(g));
|
||||
game = applyAction(game, 'g', {type:'alliance', target:'a'});
|
||||
game = applyAction(game, 'a', {type:'alliance', target:'g'});
|
||||
game = advanceSemutaNexusTrade(game, g => g.nexusCards?.phase?.stage === 'drawing');
|
||||
game = applyAction(game, 'r', {type:'nexusCardChoice', turn:game.turn, card:null, choice:'draw', ownRedraws:0});
|
||||
swapSemutaNexusTradeIdentity(game, 'r', 'emperor');
|
||||
game = advanceSemutaNexusTrade(game, g => g.phase === 3 && !!g.auction && !g.richeseAuction && clean(g));
|
||||
while (game.auction!.active !== 'r') game = applyAction(game, game.auction!.active, {type:'passBid'});
|
||||
game = applyAction(game, 'r', {type:'bid', amount:2, allyPayment:0});
|
||||
game = advanceSemutaNexusTrade(game, g => g.decision?.kind === 'auctionPayment');
|
||||
assert.equal(game.decision!.player, 'r');
|
||||
assert.equal(game.auction!.bid, 2);
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const use of [false, true]) void test(`Basic Semuta Nexus bank auction ${use ? 'uses Emperor Secret Ally' : 'pays original price'} once`, () => {
|
||||
let game = fixture();
|
||||
const inventory = semutaNexusTradeInventory(game), before = structuredClone(game);
|
||||
const buyer = semutaNexusTradePlayer(game, 'r'), card = game.auction!.cards[game.auction!.index];
|
||||
const offer = viewGame(game, 'r').nexusEmperorSecretAlly;
|
||||
assert.ok(offer?.purchase && !offer.purchase.blocked);
|
||||
assert.ok(buyer.spice >= 2);
|
||||
const price = buyer.spice;
|
||||
game = applyAction(game, 'r', use ? {type:'nexusEmperorPurchase', event:offer.event} : {type:'decision', karama:false});
|
||||
assert.deepEqual(before.auction!.cards[before.auction!.index], card);
|
||||
assert.equal(semutaNexusTradePlayer(game, 'r').spice, price - (use ? 0 : 2));
|
||||
assert.ok(semutaNexusTradePlayer(game, 'r').hand.some(held => held.id === card.id));
|
||||
assert.equal(game.nexusCards!.cards!.hands.r, use ? null : 'emperor');
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(face => face === 'emperor').length, Number(use));
|
||||
for (let n = 0; n < 100 && game.auction?.index === before.auction!.index; n++) {
|
||||
assert.notEqual(game.decision?.kind, 'auctionPayment', 'Original lot must not charge twice');
|
||||
game = stepNexusEmperorBank(game, nextNexusEmperorBankStep(game));
|
||||
}
|
||||
assert.notEqual(game.auction?.index, before.auction!.index);
|
||||
assert.equal(semutaNexusTradePlayer(game, 'r').spice, price - (use ? 0 : 2));
|
||||
assert.deepEqual(semutaNexusTradeInventory(game), inventory);
|
||||
});
|
||||
|
||||
void test('Basic Semuta bank purchase uses the supported native roster, not unrelated overlay flags', () => {
|
||||
const game = createGame('BANKPROFILE', newPlayer('r','Richese','richese'), false, ['choam']);
|
||||
joinGame(game, newPlayer('g','Guild','guild'));
|
||||
game.nexusCards = {cards:null,phase:null}; game.semutaPreview = true;
|
||||
assert.equal(emperorNexusBankPurchaseModeSupported(game), true);
|
||||
for (const change of [
|
||||
(g:Game) => { g.advanced = true; },
|
||||
(g:Game) => { g.homeworlds = {custody:null}; },
|
||||
(g:Game) => { g.discoveryEnabled = true; },
|
||||
(g:Game) => { g.expansions.push('ix'); },
|
||||
(g:Game) => { g.nexusCards = undefined; },
|
||||
(g:Game) => { joinGame(g,newPlayer('e','Emperor','emperor')); },
|
||||
]) {
|
||||
const unsupported = structuredClone(game); change(unsupported);
|
||||
assert.equal(emperorNexusBankPurchaseModeSupported(unsupported), false);
|
||||
}
|
||||
});
|
||||
|
||||
for (const claim of [false, true]) for (const use of [false, true]) void test(
|
||||
`Semuta ${claim ? 'claim' : 'pass'} after Truthtrance retains the original Emperor Nexus bank offer and ${use ? 'Use' : 'Pay'}`, () => {
|
||||
let game = fixture();
|
||||
// Move only existing physical cards into holders before using the already
|
||||
// opened native window; exchange a held card to retain each hand size.
|
||||
const place = (owner: string, matches: (card: Game['deck'][number]) => boolean) => {
|
||||
const seat = semutaNexusTradePlayer(game, owner);
|
||||
const existing = seat.hand.find(matches); if (existing) return existing.id;
|
||||
const zones = [game.deck, game.discard, game.richeseCache!, game.richeseRemoved!,
|
||||
...game.players.filter(player => player.id !== owner).map(player => player.hand)];
|
||||
const source = zones.find(cards => cards.some(matches)); assert.ok(source);
|
||||
const index = source.findIndex(matches), displaced = seat.hand.pop(); assert.ok(displaced);
|
||||
const [card] = source.splice(index, 1, displaced); seat.hand.push(card); return card.id;
|
||||
};
|
||||
const truth = place('h', card => card.effect === 'truthtrance');
|
||||
const semuta = place('a', card => card.effect === 'semutaDrug');
|
||||
const original = semutaNexusTradeInventory(game), spice = semutaNexusTradePlayer(game,'r').spice;
|
||||
const originalOffer = viewGame(game,'r').nexusEmperorSecretAlly!;
|
||||
game = applyAction(game, 'h', {type:'card', card:truth});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id))!.id;
|
||||
game = applyAction(game, actor, {type:'truthPass'});
|
||||
}
|
||||
const name = semutaNexusTradePlayer(game,'r').hand[0].name;
|
||||
game = applyAction(game, 'h', {type:'truthAsk',question:{kind:'fact',target:'r',fact:{kind:'hand',name}}});
|
||||
game = applyAction(game, 'r', {type:'truthAnswer',answer:'yes'});
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const event = game.pendingTreacheryDiscard!.batch.event;
|
||||
if (claim) game = applyAction(game, 'a', {type:'semutaCommit',event});
|
||||
else for (const seat of game.players) game = applyAction(game, seat.id, {type:'semutaPass',event});
|
||||
assert.equal(semutaNexusTradePlayer(game,'a').hand.some(card => card.id === truth), claim);
|
||||
assert.equal(game.discard.some(card => card.id === semuta), claim);
|
||||
assert.equal(game.discard.some(card => card.id === truth), !claim);
|
||||
assert.equal(viewGame(game,'r').nexusEmperorSecretAlly?.event, originalOffer.event);
|
||||
game = applyAction(game, 'r', use ? {type:'nexusEmperorPurchase',event:originalOffer.event} : {type:'decision',karama:false});
|
||||
assert.equal(semutaNexusTradePlayer(game,'r').spice,spice - (use ? 0 : 2));
|
||||
assert.equal(game.nexusCards!.cards!.hands.r,use ? null : 'emperor');
|
||||
assert.deepEqual(semutaNexusTradeInventory(game),original);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { emperorNexusPools } from '../game/nexus-emperor-secret-ally';
|
||||
import { forceRevivalRemaining, freeRevivalRemaining } from '../game/revival';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { advanceSemutaNexusTrade, semutaNexusTradeInventory, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade';
|
||||
|
||||
const player = (g: Game, id: string) => { const p = g.players.find(seat => seat.id === id); assert.ok(p); return p; };
|
||||
const quiet = (g: Game) => !g.phaseOpening && !g.response && !g.decision && !g.pendingRevival;
|
||||
function front(g: Game, kinds: ('land' | 'worm')[]): void {
|
||||
for (const [position, kind] of kinds.entries()) {
|
||||
const index = g.spiceDeck.findIndex((card, i) => i >= position && (kind === 'land' ? 'territory' in card : 'worm' in card));
|
||||
assert.ok(index >= position);
|
||||
g.spiceDeck.splice(position, 0, g.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
}
|
||||
/** Genuine Semuta/Nexus setup and an earned closing Nexus draw. Only original
|
||||
* unopened Spice order, conserved board placement, Nexus identity and Tanks
|
||||
* counters are staged; no phase, card receipt or money is invented. */
|
||||
function fixture(native: 'classic' | 'choam' | 'tleilaxu') {
|
||||
const tleilaxu = native === 'tleilaxu';
|
||||
let g = createGame('SEMUTANEXUSEMPEROR', newPlayer('r', 'Richese', 'richese'), false,
|
||||
tleilaxu ? ['choam', 'ix'] : ['choam']);
|
||||
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(g, newPlayer('g', 'Guild', 'guild'));
|
||||
joinGame(g, newPlayer('p', native, native === 'classic' ? 'harkonnen' : native));
|
||||
g.nexusCards = {cards:null, phase:null};
|
||||
for (const p of g.players) g = applyAction(g, p.id, {type:'ready'});
|
||||
g = initializeSemutaGameForAudit(g);
|
||||
g = advanceSemutaNexusTrade(g, state => state.status === 'playing');
|
||||
assert.equal(g.semutaPreview, true);
|
||||
assert.ok(Object.values(g.nexusCards!.cards!.hands).every(card => card === null));
|
||||
const cards = semutaNexusTradeInventory(g);
|
||||
g = advanceSemutaNexusTrade(g, state => state.phase === 1 && quiet(state));
|
||||
front(g, ['land', 'land']);
|
||||
g = advanceSemutaNexusTrade(g, state => state.phase === 5 && quiet(state));
|
||||
for (const p of g.players) {
|
||||
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0); p.forces = {};
|
||||
if (p.elites) { p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0); p.elites.forces = {}; }
|
||||
}
|
||||
g = advanceSemutaNexusTrade(g, state => state.turn === 2 && state.phase === 1 && quiet(state));
|
||||
front(g, ['worm', 'land', 'land']);
|
||||
g = advanceSemutaNexusTrade(g, state => state.nexus && !state.spiceWindow && quiet(state));
|
||||
g = applyAction(g, 'p', {type:'alliance', target:'g'});
|
||||
g = applyAction(g, 'g', {type:'alliance', target:'p'});
|
||||
g = advanceSemutaNexusTrade(g, state => state.nexusCards?.phase?.stage === 'drawing');
|
||||
assert.ok(g.nexusCards!.phase!.eligible.includes('a'));
|
||||
g = applyAction(g, 'a', {type:'nexusCardChoice', turn:g.turn, card:null, choice:'draw', ownRedraws:0});
|
||||
assert.ok(g.nexusCards!.cards!.hands.a);
|
||||
swapSemutaNexusTradeIdentity(g, 'a', 'emperor');
|
||||
g = advanceSemutaNexusTrade(g, state => state.phase === 4 && quiet(state));
|
||||
const owner = player(g, 'a'); assert.ok(owner.reserves >= 6); owner.reserves -= 6; owner.tanks += 6;
|
||||
assert.deepEqual(semutaNexusTradeInventory(g), cards);
|
||||
return {game:g, cards};
|
||||
}
|
||||
|
||||
for (const native of ['classic', 'choam', 'tleilaxu'] as const) void test(`Basic Semuta/Nexus preserves Emperor's extra revival with ${native}`, () => {
|
||||
const tleilaxu = native === 'tleilaxu';
|
||||
const f = fixture(native), g = f.game, owner = player(g, 'a');
|
||||
const before = emperorNexusPools(owner), quota = forceRevivalRemaining(g, owner), free = freeRevivalRemaining(g, owner);
|
||||
const semutaHeld = g.players.flatMap(p => p.hand).some(card => card.id === SEMUTA_DRUG_ID);
|
||||
const offer = viewGame(g, 'a').nexusEmperorSecretAlly; assert.ok(offer);
|
||||
assert.equal(offer.revival.blocked, null); assert.equal(offer.revival.amount, 3);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(g, 'a'); view.players.find(p => p.id === 'a')!.bot = difficulty;
|
||||
const action = botActions(view).find(candidate => candidate.type === 'nexusEmperorRevive');
|
||||
assert.ok(action, `${difficulty} must retain a legal Emperor revival`);
|
||||
assert.doesNotThrow(() => applyAction(g, 'a', action));
|
||||
}
|
||||
const otherSpice = tleilaxu ? player(g, 'p').spice : 0;
|
||||
let result = applyAction(g, 'a', {type:'nexusEmperorRevive', event:offer.event, elite:0});
|
||||
// A held cancellation card can keep native Tleilaxu income pending; finish
|
||||
// that original response before checking the credit, without replaying the
|
||||
// already-completed Nexus return.
|
||||
if (result.response) {
|
||||
assert.equal(result.response.kind, 'revivalIncome');
|
||||
assert.equal(player(result, 'p').spice, otherSpice);
|
||||
result = advanceSemutaNexusTrade(result, state => quiet(state));
|
||||
}
|
||||
assert.deepEqual(emperorNexusPools(player(result, 'a')), {...before, reserves:before.reserves + 3, tanks:before.tanks - 3});
|
||||
assert.equal(forceRevivalRemaining(result, player(result, 'a')), quota);
|
||||
assert.equal(freeRevivalRemaining(result, player(result, 'a')), free);
|
||||
assert.equal(result.nexusCards!.cards!.hands.a, null);
|
||||
assert.equal(result.nexusCards!.cards!.discard.filter(card => card === 'emperor').length, 1);
|
||||
assert.equal(result.nexusEmperorSecretHistory?.[0]?.stage, 'returned');
|
||||
assert.equal(result.pendingRevival, null);
|
||||
assert.equal(result.pendingTreacheryDiscard ?? null, null);
|
||||
assert.equal(result.semutaPreview, true);
|
||||
assert.equal(result.players.flatMap(p => p.hand).some(card => card.id === SEMUTA_DRUG_ID), semutaHeld);
|
||||
if (tleilaxu) assert.equal(player(result, 'p').spice, otherSpice + 1);
|
||||
assert.deepEqual(semutaNexusTradeInventory(result), f.cards);
|
||||
assert.throws(() => applyAction(result, 'a', {type:'nexusEmperorRevive', event:offer.event, elite:0}));
|
||||
const continued = advanceSemutaNexusTrade(result, state => state.phase === 5 && quiet(state));
|
||||
assert.equal(continued.nexusEmperorSecretHistory?.[0]?.stage, 'returned');
|
||||
assert.deepEqual(semutaNexusTradeInventory(continued), f.cards);
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { nexusChoamTradeAction } from '../game/nexus-choam-trade-options';
|
||||
import { nexusChoamBetrayalAction } from '../game/nexus-choam-betrayal-options';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { advanceSemutaNexusTrade, semutaNexusTradeInventory, swapSemutaNexusTradeIdentity } from './fixture-semuta-nexus-trade';
|
||||
import { finishOriginalTruthQuestions, finishTruthOrder } from './fixture-sapho-truth-order';
|
||||
|
||||
const SAPHO = 'richese-juice-of-sapho';
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
const seat = (game: Game, id: string) => { const p = game.players.find(row => row.id === id); assert.ok(p); return p; };
|
||||
const act = (game: Game, id: string, action: Parameters<typeof applyAction>[2]) => applyAction(game,id,action);
|
||||
|
||||
/** Genuine public lobby and setup; original unopened Spice order is staged to
|
||||
* avoid random unrelated first-turn events. Physical card locations are staged
|
||||
* later, with inventory conserved. */
|
||||
function publicStart(paired = false) {
|
||||
let game = createGame('SEMUTANEXUSPUBLIC',newPlayer('r','Richese','richese'),false,['choam']);
|
||||
for (const [id, faction] of (paired
|
||||
? [['a','atreides'],['e','emperor'],['c','choam']] as const
|
||||
: [['a','atreides'],['e','emperor'],['g','guild']] as const))
|
||||
joinGame(game,newPlayer(id,faction,faction));
|
||||
game = act(game,'r',{type:'moduleOption',module:'nexusCards',enabled:true});
|
||||
for (const p of game.players) game = act(game,p.id,{type:'ready'});
|
||||
game = act(game,'r',{type:'start'});
|
||||
assert.equal(game.semutaPreview,true);
|
||||
game = advanceSemutaNexusTrade(game,state => state.status === 'playing');
|
||||
assert.ok(Object.values(game.nexusCards!.cards!.hands).every(card => card === null));
|
||||
const inventory = semutaNexusTradeInventory(game);
|
||||
for (const position of [0,1]) {
|
||||
const index=game.spiceDeck.findIndex((card,i)=>i>=position && 'territory' in card);
|
||||
assert.ok(index>=position); game.spiceDeck.splice(position,0,game.spiceDeck.splice(index,1)[0]);
|
||||
}
|
||||
return {game,inventory};
|
||||
}
|
||||
function hold(game: Game, id: string, match: (card: Game['deck'][number])=>boolean) {
|
||||
const owner=seat(game,id);
|
||||
const sources=[game.deck,game.discard,game.richeseCache!,...game.players.map(p=>p.hand).filter(hand=>hand!==owner.hand)];
|
||||
const own=owner.hand.find(match); if (own) return own;
|
||||
const source=sources.find(zone=>zone.some(match)); assert.ok(source);
|
||||
const index=source.findIndex(match), card=source.splice(index,1)[0]; owner.hand.push(card); return card;
|
||||
}
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic public Nexus CHOAM Secret Ally discard offers Semuta ${claim?'claim':'pass'} and continues`,()=>{
|
||||
const f=publicStart(); let game=f.game;
|
||||
// Return starting forces to their original reserves to avoid unrelated battles.
|
||||
for (const p of game.players) {p.reserves+=Object.values(p.forces).reduce((sum,n)=>sum+n,0);p.forces={};}
|
||||
game=advanceSemutaNexusTrade(game,state=>state.turn===2 && state.phase===1 && clean(state));
|
||||
const worm=game.spiceDeck.findIndex(card=>'worm' in card&&!card.greatMaker&&!card.suppressed); assert.ok(worm>=0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]);
|
||||
game=advanceSemutaNexusTrade(game,state=>state.nexus&&!state.spiceWindow&&clean(state));
|
||||
game=act(game,'e',{type:'alliance',target:'g'});game=act(game,'g',{type:'alliance',target:'e'});
|
||||
game=advanceSemutaNexusTrade(game,state=>state.nexusCards?.phase?.stage==='drawing');
|
||||
assert.ok(game.nexusCards!.phase!.eligible.includes('a'));
|
||||
game=act(game,'a',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
||||
assert.ok(game.nexusCards!.cards!.hands.a);
|
||||
swapSemutaNexusTradeIdentity(game,'a','choam');
|
||||
game=advanceSemutaNexusTrade(game,state=>state.phase===7&&clean(state));
|
||||
for (const id of ['r','a']) game.deck.push(...seat(game,id).hand.splice(0));
|
||||
const semuta=hold(game,'r',card=>card.id===SEMUTA_DRUG_ID);
|
||||
const cost=hold(game,'a',card=>card.kind==='worthless');
|
||||
assert.deepEqual(semutaNexusTradeInventory(game),f.inventory);
|
||||
const action=nexusChoamTradeAction(viewGame(game,'a'),cost.id);assert.ok(action);
|
||||
const before=seat(game,'a').spice;
|
||||
game=act(game,'a',action);
|
||||
assert.equal(seat(game,'a').spice,before+2);
|
||||
assert.equal(game.nexusCards!.cards!.hands.a,null);
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind,'nexusChoamTrade');
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
const event=game.pendingTreacheryDiscard!.batch.event;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(game,'r'); view.players.find(p=>p.id==='r')!.bot=difficulty;
|
||||
const choice=botActions(view).find(row=>row.type==='semutaCommit'||row.type==='semutaPass');assert.ok(choice);
|
||||
assert.doesNotThrow(()=>act(game,'r',choice));
|
||||
}
|
||||
if (claim) game=act(game,'r',{type:'semutaCommit',event});
|
||||
else for (const p of game.players) game=act(game,p.id,{type:'semutaPass',event});
|
||||
assert.equal(game.pendingTreacheryDiscard,null);
|
||||
assert.equal(game.nexusChoamTrades![0].stage,'complete');
|
||||
assert.equal(seat(game,'a').spice,before+2);
|
||||
assert.equal(seat(game,'r').hand.some(card=>card.id===cost.id),claim);
|
||||
assert.equal(game.discard.some(card=>card.id===semuta.id),claim);
|
||||
assert.deepEqual(semutaNexusTradeInventory(game),f.inventory);
|
||||
game=advanceSemutaNexusTrade(game,state=>state.phase===8&&clean(state));
|
||||
assert.equal(game.nexusChoamTrades![0].stage,'complete');
|
||||
});
|
||||
|
||||
void test('Basic public Nexus Sapho Truth order offers Semuta and resumes both original questions',()=>{
|
||||
const f=publicStart(); let game=f.game;
|
||||
game=advanceSemutaNexusTrade(game,state=>state.phase===5&&clean(state));
|
||||
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
||||
const holders=game.order.filter(id=>id==='a'||id==='e');assert.equal(holders.length,2);
|
||||
const holder=holders[1],other=holders[0];
|
||||
hold(game,'r',card=>card.id===SEMUTA_DRUG_ID);
|
||||
hold(game,holder,card=>card.id===SAPHO);
|
||||
const first=hold(game,holder,card=>card.effect==='truthtrance');
|
||||
const second=hold(game,other,card=>card.effect==='truthtrance');
|
||||
assert.deepEqual(semutaNexusTradeInventory(game),f.inventory);
|
||||
const parent={turn:game.turn,phase:game.phase,active:game.active,order:[...game.order],movementRemaining:structuredClone(game.movementRemaining)};
|
||||
game=act(game,holder,{type:'card',card:first.id});game=act(game,other,{type:'card',card:second.id});
|
||||
while(game.truthtrance?.stage==='priority') {
|
||||
const p=game.players.find(row=>!game.truthtrance!.passed.includes(row.id));assert.ok(p);game=act(game,p.id,{type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage,'order');const frame=game.truthtrance!.order!;
|
||||
assert.ok(viewGame(game,holder).saphoTruthOrderChoices.some(choice=>choice.card===SAPHO&&choice.mode==='first'));
|
||||
const pending=act(game,holder,{type:'saphoTruthOrder',event:frame.event,revision:frame.revision,card:SAPHO,mode:'first'});
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind,'saphoTruthOrderDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
assert.equal(pending.truthtrance,null);
|
||||
const event=pending.pendingTreacheryDiscard!.batch.event;
|
||||
for (const claim of [false,true]) {
|
||||
let next=structuredClone(pending);
|
||||
if (claim) next=act(next,'r',{type:'semutaCommit',event});
|
||||
else for (const p of next.players) next=act(next,p.id,{type:'semutaPass',event});
|
||||
assert.equal(next.pendingTreacheryDiscard,null);
|
||||
assert.equal(seat(next,'r').hand.some(card=>card.id===SAPHO),claim);
|
||||
next=finishTruthOrder(next);
|
||||
assert.equal(next.truthtrance!.queue[0].player,holder);
|
||||
assert.deepEqual(next.order,parent.order);assert.equal(next.turn,parent.turn);assert.equal(next.phase,parent.phase);
|
||||
assert.equal(next.active,parent.active);assert.deepEqual(next.movementRemaining,parent.movementRemaining);
|
||||
next=finishOriginalTruthQuestions(next);
|
||||
assert.equal(next.truthtrance,null);assert.deepEqual(semutaNexusTradeInventory(next),f.inventory);
|
||||
}
|
||||
});
|
||||
|
||||
for (const claim of [false,true]) void test(`Basic public paired CHOAM Nexus Betrayal offers Semuta ${claim?'claim':'pass'} and retires once`,()=>{
|
||||
const f=publicStart(true); let game=f.game;
|
||||
for (const p of game.players) {p.reserves+=Object.values(p.forces).reduce((sum,n)=>sum+n,0);p.forces={};}
|
||||
game=advanceSemutaNexusTrade(game,state=>state.turn===2&&state.phase===1&&clean(state));
|
||||
const worm=game.spiceDeck.findIndex(card=>'worm' in card&&!card.greatMaker&&!card.suppressed);assert.ok(worm>=0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]);
|
||||
game=advanceSemutaNexusTrade(game,state=>state.nexus&&!state.spiceWindow&&clean(state));
|
||||
game=act(game,'r',{type:'alliance',target:'c'});game=act(game,'c',{type:'alliance',target:'r'});
|
||||
game=advanceSemutaNexusTrade(game,state=>state.nexusCards?.phase?.stage==='drawing');
|
||||
assert.ok(game.nexusCards!.phase!.eligible.includes('a'));
|
||||
game=act(game,'a',{type:'nexusCardChoice',turn:game.turn,card:null,choice:'draw',ownRedraws:0});
|
||||
assert.ok(game.nexusCards!.cards!.hands.a);swapSemutaNexusTradeIdentity(game,'a','choam');
|
||||
game=advanceSemutaNexusTrade(game,state=>state.phase===2&&clean(state));
|
||||
for(const p of game.players) game.deck.push(...p.hand.splice(0));
|
||||
hold(game,'r',card=>card.id===SEMUTA_DRUG_ID);
|
||||
const target=hold(game,'c',card=>card.kind==='worthless');
|
||||
assert.deepEqual(semutaNexusTradeInventory(game),f.inventory);
|
||||
const action=nexusChoamBetrayalAction(viewGame(game,'a'));assert.ok(action);
|
||||
const wallet=seat(game,'c').spice;
|
||||
game=act(game,'a',action);
|
||||
assert.equal(game.nexusCards!.cards!.hands.a,null);
|
||||
assert.equal(game.pendingTreacheryDiscard?.continuation.kind,'nexusChoamBetrayal');
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage,'offer');
|
||||
assert.equal(game.pendingTreacheryDiscard?.batch.entries[0].card.id,target.id);
|
||||
const event=game.pendingTreacheryDiscard!.batch.event;
|
||||
if(claim) game=act(game,'r',{type:'semutaCommit',event});
|
||||
else for(const p of game.players) game=act(game,p.id,{type:'semutaPass',event});
|
||||
assert.equal(game.pendingTreacheryDiscard,null);
|
||||
assert.equal(seat(game,'r').hand.some(card=>card.id===target.id),claim);
|
||||
assert.equal(seat(game,'c').spice,wallet);
|
||||
assert.equal(game.nexusCards!.cards!.discard.filter(card=>card==='choam').length,1);
|
||||
assert.deepEqual(semutaNexusTradeInventory(game),f.inventory);
|
||||
});
|
||||
Reference in new issue
Block a user