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
@@ -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