Extend Basic Semuta poison continuation
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
9c6b2d884b
commit
ea3b21f92c
7 files changed
+134
-26
No files matched your search
@@ -67,8 +67,10 @@ export function finishTruthFlightPoison(game: Game, actions: TruthFlightPoisonSt
|
||||
/** Fresh bare R/A/E/G original setup in its actual requested mode. Native
|
||||
* cache1 acquisition, funded physical shipment and real battle preparation
|
||||
* supply every current role; no Nexus/profile is removed from a played game. */
|
||||
export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPoisonEffect; advanced?: boolean } = {}) {
|
||||
export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPoisonEffect; advanced?: boolean; independentCards?: boolean } = {}) {
|
||||
const effect = options.effect ?? 'ornithopter', advanced = options.advanced ?? false;
|
||||
const independentCards = options.independentCards ?? false;
|
||||
assert.ok(!advanced || !independentCards, 'This independent-card source is Basic only.');
|
||||
const target = effect === 'ornithopter' ? 'emperor' : 'atreides', asker = 'richese', recipient = asker;
|
||||
const canonical = richeseCards().find(card => card.effect === effect); assert.ok(canonical); const card = canonical.id;
|
||||
let game = createGame('TRUTHFLIGHTPOISON', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
|
||||
@@ -93,6 +95,7 @@ export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPo
|
||||
}
|
||||
throw Error('The actual cache acquisition/source boundary must be reached finitely');
|
||||
};
|
||||
if (independentCards) act(game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' }); const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
@@ -132,7 +135,8 @@ export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPo
|
||||
assert.deepEqual(game.battle!.plans, {}); assert.ok(residualPoisonCandidates(truthFlightPoisonPlayer(game, asker).leaders, 'arrakeen').length > 1);
|
||||
const quote = viewGame(game, target).residualPoison; assert.ok(quote && !quote.blocked && quote.target === asker);
|
||||
}
|
||||
assert.ok(truthFlightPoisonClean(game)); assert.equal(!!game.semutaPreview, !advanced);
|
||||
assert.ok(truthFlightPoisonClean(game)); assert.equal(!!game.semutaPreview, !advanced && !independentCards);
|
||||
assert.equal(!!game.semutaRecruitsPreview, independentCards);
|
||||
assert.ok(!game.nexusCards && !game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards);
|
||||
assert.deepEqual(truthFlightPoisonInventory(game), originalCards); assert.deepEqual(truthFlightPoisonSpice(game), originalSpice);
|
||||
assert.deepEqual(truthFlightPoisonTraitors(game), originalTraitors); assert.equal(game.turnActionPromises?.length ?? 0, 0);
|
||||
@@ -141,7 +145,7 @@ export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPo
|
||||
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthFlightPoisonPlayer(game, target).spice };
|
||||
const mixed = truthFlightPoisonGroup('and', truthFlightPoisonUse(effect), truthFlightPoisonPayment(recipient));
|
||||
const question: TruthQuestion = { kind: 'turnActions', target, turn: game.turn, mixed };
|
||||
return { game, source: structuredClone(game), effect, advanced, target, asker, recipient, card, truth: truths[0], truths, truthHolders,
|
||||
return { game, source: structuredClone(game), effect, advanced, independentCards, target, asker, recipient, card, truth: truths[0], truths, truthHolders,
|
||||
initial, lobby, setup, beforeStartingDeal, startingTruthSelection, afterSetup, spiceOrder: structuredClone(front), acquisition, shipment, choice,
|
||||
origin: effect === 'ornithopter' ? 'imperial_basin:9' : null, poisonTarget: effect === 'residualPoison' ? asker : null,
|
||||
event: game.battle?.event ?? null, spiceFact, mixed, question, originalCards, originalSpice, originalTraitors,
|
||||
@@ -150,12 +154,12 @@ export function createTruthFlightPoisonFixture(options: { effect?: TruthFlightPo
|
||||
acquisition: acquisition.steps.length, shipment: shipment.steps.length },
|
||||
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
|
||||
end: { type: 'endMovement' } satisfies Action,
|
||||
sourceDisclosure: 'Genuine public bare Richese/Atreides/Emperor/Guild selected-CHOAM setup in its original Basic or Advanced preview mode. ' +
|
||||
sourceDisclosure: `Genuine public Richese/Atreides/Emperor/Guild selected-CHOAM setup in its original ${advanced ? 'Advanced preview' : 'Basic'} mode${independentCards ? ' with independent Ecaz Treachery Cards selected in the lobby' : ''}. ` +
|
||||
'Only two original unopened Richese/Guild Truth deals, original unopened first-turn land Spice positions and scalar lotteries are selected. ' +
|
||||
'The holder actually buys its canonical cache card for1. ' + (effect === 'ornithopter'
|
||||
? 'Emperor actually pays4 to ship two ordinary counters to Imperial Basin, retaining5 and its unused ground movement. '
|
||||
: 'Richese actually pays1 to ship one original counter into original Atreides10 in Arrakeen; actual chooseBattle and native preparation create the preleader window while Atreides retains9. ') +
|
||||
'Basic retains its original Semuta marker and native discard passes. No module, mode, played hand, wallet, army, battle, victim, movement, clock or receipt is installed. Source counts derive from actual arrays.',
|
||||
'Basic retains its original Semuta marker and native discard passes. No module, mode, played hand, wallet, army, battle, victim, movement, clock or receipt is installed after setup. Source counts derive from actual arrays.',
|
||||
};
|
||||
}
|
||||
export type TruthFlightPoisonFixture = ReturnType<typeof createTruthFlightPoisonFixture>;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { actTruthFlightPoison as act, createTruthFlightPoisonFixture,
|
||||
truthFlightPoisonAction, truthFlightPoisonInventory, truthFlightPoisonPlayer as player,
|
||||
} from './fixture-truth-flight-poison';
|
||||
|
||||
/** Actual public Basic independent-card setup, paid cache purchase, shipment
|
||||
* and pre-leader battle. Only Semuta is transferred from the original cache
|
||||
* to Guild's hand; it is not claimed as a real purchase. */
|
||||
function fixture() {
|
||||
const source = createTruthFlightPoisonFixture({ effect: 'residualPoison', independentCards: true });
|
||||
const game = structuredClone(source.game), holder = 'guild';
|
||||
assert.equal(game.semutaRecruitsPreview, true);
|
||||
const index = game.richeseCache!.findIndex(card => card.id === SEMUTA_DRUG_ID);
|
||||
assert.ok(index >= 0);
|
||||
assert.ok(player(game, holder).hand.length < 4);
|
||||
player(game, holder).hand.push(game.richeseCache!.splice(index, 1)[0]);
|
||||
assert.deepEqual(truthFlightPoisonInventory(game), source.originalCards);
|
||||
assert.ok(game.battle?.preLeader && !game.battle.preLeader.closed);
|
||||
return { ...source, game, holder };
|
||||
}
|
||||
|
||||
function open(source: ReturnType<typeof fixture>, who: 'owner' | 'opponent') {
|
||||
const readyActor = who === 'owner' ? source.target : source.asker;
|
||||
const ready = act(source.game, readyActor, { type: 'battlePreparationReady', event: source.event });
|
||||
assert.deepEqual(ready.battle!.preLeader!.ready, [readyActor]);
|
||||
const beforeDead = player(ready, source.asker).leaders.filter(leader => leader.dead).map(leader => leader.id);
|
||||
const pending = act(ready, source.target, truthFlightPoisonAction(ready, source));
|
||||
assert.equal(pending.pendingTreacheryDiscard?.continuation.kind, 'residualPoisonDiscard');
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.deepEqual(pending.battle!.preLeader!.ready, [readyActor]);
|
||||
const newlyDead = player(pending, source.asker).leaders.filter(leader => leader.dead && !beforeDead.includes(leader.id));
|
||||
assert.equal(newlyDead.length, 1);
|
||||
assert.equal(viewGame(pending, source.holder).semutaReaction?.canCommit, true);
|
||||
return { pending, victim: newlyDead[0].id, event: pending.pendingTreacheryDiscard!.batch.event };
|
||||
}
|
||||
|
||||
function react(game: Game, holder: string, event: string, claim: boolean): Game {
|
||||
if (claim) return act(game, holder, { type: 'semutaCommit', event });
|
||||
for (const seat of game.players) {
|
||||
if (!game.pendingTreacheryDiscard?.reaction) break;
|
||||
game = act(game, seat.id, { type: 'semutaPass', event });
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
for (const who of ['owner', 'opponent'] as const) for (const claim of [false, true]) {
|
||||
void test(`independent-card ${who}-ready Residual Poison ${claim ? 'claim' : 'pass'} resumes the original preparation once`, () => {
|
||||
const f = fixture(), { pending, victim, event } = open(f, who);
|
||||
const dead = player(pending, f.asker).leaders.filter(leader => leader.dead).length;
|
||||
const resumed = react(pending, f.holder, event, claim);
|
||||
assert.equal(resumed.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(resumed.battle!.preLeader!.ready, who === 'owner' ? [] : [f.asker]);
|
||||
assert.deepEqual(resumed.battle!.plans, pending.battle!.plans);
|
||||
assert.equal(player(resumed, f.asker).leaders.find(leader => leader.id === victim)?.dead, true);
|
||||
assert.equal(player(resumed, f.asker).leaders.filter(leader => leader.dead).length, dead);
|
||||
assert.equal(player(resumed, f.holder).hand.some(card => card.id === f.card), claim);
|
||||
assert.equal(resumed.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
|
||||
assert.equal(resumed.discard.some(card => card.id === f.card), !claim);
|
||||
assert.deepEqual(truthFlightPoisonInventory(resumed), f.originalCards);
|
||||
assert.throws(() => act(resumed, f.holder, { type: 'semutaCommit', event }));
|
||||
});
|
||||
}
|
||||
|
||||
void test('all profiles can answer an independent-card Residual Poison offer legally', () => {
|
||||
const f = fixture(), { pending, event } = open(f, 'opponent');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const game = structuredClone(pending);
|
||||
const view = viewGame(game, f.holder);
|
||||
view.players.find(seat => seat.id === f.holder)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action && (action.type === 'semutaCommit' || action.type === 'semutaPass'));
|
||||
let continued = act(game, f.holder, action);
|
||||
for (const seat of continued.players) {
|
||||
if (!continued.pendingTreacheryDiscard?.reaction) break;
|
||||
const offer = viewGame(continued, seat.id).semutaReaction;
|
||||
if (offer && !offer.passed) continued = act(continued, seat.id, { type: 'semutaPass', event });
|
||||
}
|
||||
assert.equal(continued.pendingTreacheryDiscard ?? null, null);
|
||||
assert.deepEqual(continued.battle!.preLeader!.ready, [f.asker]);
|
||||
assert.deepEqual(truthFlightPoisonInventory(continued), f.originalCards);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user