Prototype Semuta after Recruits in Basic play
Verify and publish checkpoint container / container (push) Successful in 10m20s

This commit is contained in:
Quintine committed 2026-10-28 22:19:12 +11:00
1 parent 0e9209531a
commit 1ba942138d
9 files changed
+311 -18

No files matched your search

+17 -5
View File
@@ -50,6 +50,8 @@ export type RecruitsModulesFixture = {
playAction: Action;
revivalAction: Action;
secondRevivalAction: Action;
/** Real ordered-ready and revival actions after playing Recruits. */
revivalSteps: RecruitsModulesStep[];
battle: {
attacker: string; defender: string; territory: string;
reviverPlan: RecruitsModulesStep; opponentPlan: RecruitsModulesStep;
@@ -275,11 +277,21 @@ export function createRecruitsModulesFixture(
const beforeRevival = structuredClone(game);
assert.equal(beforeRevival.recruits?.player, owner, 'The physical Recruits activation must be recorded');
const revivalAction: Action = { type: 'revive', amount, elite: 0 };
act(owner, revivalAction);
const afterRevival = structuredClone(game);
const secondRevivalAction: Action = { type: 'revive', amount: 1, elite: 0 };
act(opponent, secondRevivalAction);
const afterSecondRevival = structuredClone(game);
const from = steps.length;
const remaining = game.revivalOrder?.remaining;
const actors = remaining && remaining.indexOf(opponent) < remaining.indexOf(owner)
? [opponent, owner] : [owner, opponent];
let afterRevival: Game | undefined, afterSecondRevival: Game | undefined;
for (const actor of actors) {
if (game.revivalOrder)
advance(state => state.phase === 4 && state.revivalOrder?.remaining[0] === actor && recruitsModulesClean(state));
act(actor, actor === owner ? revivalAction : secondRevivalAction);
if (actor === owner) afterRevival = structuredClone(game);
else afterSecondRevival = structuredClone(game);
}
assert.ok(afterRevival && afterSecondRevival);
const revivalSteps = structuredClone(steps.slice(from));
assert.equal(game.techTokens?.axlotl.spice, accrual,
'The accrued income must still be held by the token immediately before the phase ends');
advance(state => state.phase === 5 && recruitsModulesClean(state));
@@ -294,7 +306,7 @@ export function createRecruitsModulesFixture(
afterSecondRevival, afterPhaseEnd, owner, opponent, location,
base: { freeRate: baseFreeRate, limit: baseLimit },
doubled: { freeRate: doubledFreeRate, limit: 7 },
tanks, amount, accrual, playAction, revivalAction, secondRevivalAction,
tanks, amount, accrual, playAction, revivalAction, secondRevivalAction, revivalSteps,
battle: {
attacker: choice.attacker, defender: choice.defender,
territory: location.territory, reviverPlan, opponentPlan,
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { TERRITORIES } from '../game/board';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import {
actBasicSemutaAmbassador, basicSemutaAmbassadorClean,
basicSemutaAmbassadorInventory, basicSemutaAmbassadorPlayer,
type BasicSemutaAmbassadorStep,
} from './fixture-basic-semuta-ambassador';
export const actSemutaRecruits = actBasicSemutaAmbassador;
export const semutaRecruitsInventory = basicSemutaAmbassadorInventory;
export const semutaRecruitsPlayer = basicSemutaAmbassadorPlayer;
export const semutaRecruitsClean = basicSemutaAmbassadorClean;
function next(game: Game): BasicSemutaAmbassadorStep | null {
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const reaction = game.pendingTreacheryDiscard.reaction;
const actor = game.players.find(player => !reaction.passed.includes(player.id)); assert.ok(actor);
return { actor: actor.id, action: {type: 'semutaPass', event: game.pendingTreacheryDiscard.batch.event} };
}
return nextRicheseStrongholdsNativeStep(game);
}
/** Public Basic selected-card start. Only unopened deal/Spice order is
* selected: Guild buys the physical Semuta, Atreides buys the original Recruits,
* then pays for a real shipment that the next Storm sends to the Tanks. */
export function createSemutaRecruitsFixture() {
let game = createGame('SEMUTARECRUITS', newPlayer('atreides', 'Atreides', 'atreides'), false, ['choam']);
for (const faction of ['emperor', 'richese', 'guild'] as const)
joinGame(game, newPlayer(faction, faction, faction));
const owner = 'atreides', holder = 'guild', actions: BasicSemutaAmbassadorStep[] = [];
const act = (actor: string, action: Action) => { game = actSemutaRecruits(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => {
for (let count = 0; count < 1600; count++) {
if (until(game)) return;
assert.notEqual(game.status, 'finished');
const step = next(game); assert.ok(step, `Native action required at ${game.turn}/${game.phase}/${game.decision?.kind}`);
act(step.actor, step.action);
}
throw Error('The original Semuta/Recruits source did not reach its requested boundary.');
};
act(owner, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(owner, { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.semutaRecruitsPreview, true);
assert.ok(game.ecazTreachery && !game.techTokens && !game.leaderSkills && !game.nexusCards && !game.homeworlds);
const originalInventory = semutaRecruitsInventory(game);
const starting = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind))
.slice(0, game.players.length).map(card => card.id);
assert.equal(starting.length, game.players.length);
const selectedDeal = [...starting, RECRUITS_CARD_ID];
for (let position = 0; position < selectedDeal.length; position++) {
const from = game.deck.findIndex(card => card.id === selectedDeal[position]); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
}
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const step = next(game); assert.ok(step); act(step.actor, step.action);
}
assert.equal(game.status, 'playing');
for (let position = 0; position < 4; position++) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
}
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
act('richese', { type: 'decision', event: game.richeseBidding!.event, card: SEMUTA_DRUG_ID,
method: 'onceAround', direction: 'counterclockwise' });
reach(state => semutaRecruitsClean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
for (let count = 0; !semutaRecruitsPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome);
act(lot.active, { type: 'richeseBid', event: lot.event, amount: lot.active === holder ? 1 : null, allyPayment: 0 });
reach(semutaRecruitsClean);
}
assert.ok(semutaRecruitsPlayer(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
reach(state => state.phase === 3 && semutaRecruitsClean(state) && state.auction?.active === owner);
assert.equal(game.auction!.cards[game.auction!.index].id, RECRUITS_CARD_ID);
act(owner, { type: 'bid', amount: 1, allyPayment: 0 });
reach(state => semutaRecruitsClean(state) && semutaRecruitsPlayer(state, owner).hand.some(card => card.id === RECRUITS_CARD_ID));
reach(state => state.phase === 5 && state.active === owner && semutaRecruitsClean(state));
const sector = game.storm % 18 + 1;
const destination = TERRITORIES.find(place => place.type === 'sand' && place.sectors.includes(sector) &&
game.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`))));
assert.ok(destination, 'The next Storm path has an empty legal sand sector');
const casualtyLocation = `${destination.id}:${sector}`;
act(owner, { type: 'ship', territory: destination.id, sector, amount: 3, elite: 0, allyPayment: 0 });
reach(state => state.turn === 2 && state.phase === 4 && semutaRecruitsClean(state));
assert.equal(semutaRecruitsPlayer(game, owner).forces[casualtyLocation], undefined);
assert.equal(semutaRecruitsPlayer(game, owner).tanks, 3);
assert.deepEqual(semutaRecruitsInventory(game), originalInventory);
return { game, owner, holder, lobby, setup, actions, originalInventory, casualtyLocation,
play: {type: 'card', card: RECRUITS_CARD_ID} as Action };
}
+24 -10
View File
@@ -27,9 +27,13 @@ for (const { label, options } of CONFIGURATIONS) {
const fixture = createRecruitsModulesFixture(options);
const ordinary = new Map(fixture.game.players.map(player =>
[player.id, viewGame(fixture.game, player.id).revival.freeRemaining]));
const original = structuredClone(fixture.game);
const original = fixture.game.revivalOrder
? advanceRecruitsModules(structuredClone(fixture.game), state => state.phase === 4 &&
state.revivalOrder?.remaining[0] === fixture.owner && recruitsModulesClean(state))
: structuredClone(fixture.game);
const beforeRejected = structuredClone(original);
assert.throws(() => applyAction(original, fixture.owner, fixture.revivalAction));
assert.deepEqual(original, fixture.game, 'The old three-force allowance cannot consume these counters');
assert.deepEqual(original, beforeRejected, 'The old three-force allowance cannot consume these counters');
const activated = applyAction(fixture.game, fixture.owner, fixture.playAction);
assert.equal(activated.discard.filter(card => card.id === RECRUITS_CARD_ID).length, 1);
@@ -40,8 +44,19 @@ for (const { label, options } of CONFIGURATIONS) {
assert.equal(rate?.freeRate, Math.min(2 * ordinary.get(player.id)!, 7),
'Every seat retains its own doubled current rate');
}
const before = recruitsModulesPlayer(activated, fixture.owner);
const returned = applyAction(activated, fixture.owner, fixture.revivalAction);
let returned = activated;
let before: ReturnType<typeof recruitsModulesPlayer> | undefined;
let afterOwner: ReturnType<typeof recruitsModulesPlayer> | undefined;
let opponentReturned: typeof returned | undefined;
for (const step of fixture.revivalSteps) {
if (step.actor === fixture.owner && step.action.type === 'revive')
before = structuredClone(recruitsModulesPlayer(returned, fixture.owner));
returned = applyAction(returned, step.actor, step.action);
if (step.actor === fixture.owner && step.action.type === 'revive')
afterOwner = structuredClone(recruitsModulesPlayer(returned, fixture.owner));
if (step.actor === fixture.opponent && step.action.type === 'revive') opponentReturned = structuredClone(returned);
}
assert.ok(before && afterOwner && opponentReturned);
const owner = recruitsModulesPlayer(returned, fixture.owner);
assert.equal(owner.revived, fixture.amount);
assert.equal(owner.freeForcesRevived, fixture.doubled.freeRate);
@@ -52,14 +67,13 @@ for (const { label, options } of CONFIGURATIONS) {
const accrual = ownedTech(returned.techTokens!, fixture.owner).length;
assert.equal(returned.techTokens!.axlotl.spice, accrual);
const second = applyAction(returned, fixture.opponent, fixture.secondRevivalAction);
assert.equal(recruitsModulesPlayer(second, fixture.opponent).freeForcesRevived, 1);
assert.equal(second.techTokens!.axlotl.spice, accrual,
'A second actual free revival does not repeat income');
assert.equal(recruitsModulesPlayer(second, fixture.owner).spice, owner.spice,
assert.equal(recruitsModulesPlayer(opponentReturned, fixture.opponent).freeForcesRevived, 1);
assert.equal(opponentReturned.techTokens!.axlotl.spice, accrual,
'Both actual free revivals share one phase income');
assert.equal(owner.spice, afterOwner.spice,
'Neither free return collects the pending income');
const settled = advanceRecruitsModules(second, state => state.phase === 5 && recruitsModulesClean(state));
const settled = advanceRecruitsModules(returned, state => state.phase === 5 && recruitsModulesClean(state));
assert.equal(recruitsModulesPlayer(settled, fixture.owner).spice, owner.spice + accrual,
'The phase boundary pays the original held income exactly once');
assert.equal(settled.techTokens!.axlotl.spice, 0);
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { RECRUITS_CARD_ID } from '../game/recruits';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import {
actSemutaRecruits as act, createSemutaRecruitsFixture as fixture,
semutaRecruitsInventory as inventory, semutaRecruitsPlayer as player,
} from './fixture-semuta-recruits';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
for (const claim of [false, true]) {
void test(`public Basic Richese and Ecaz-card Recruits ${claim ? 'claim' : 'pass'} preserves its real free-revival benefit`, () => {
const f = fixture(), before = structuredClone(f.game);
assert.ok(f.lobby.lobbyOptions?.ecazTreachery);
assert.equal(f.setup.semutaRecruitsPreview, true);
assert.equal(f.setup.semutaPreview, undefined);
assert.equal(viewGame(f.game, f.owner).semutaRecruitsPreview, true);
assert.ok(viewGame(f.game, f.owner).recruitsPreview?.play && !viewGame(f.game, f.owner).recruitsPreview!.play!.blocked);
assert.equal(viewGame(f.game, f.owner).revival.freeRemaining, 2);
const pending = act(f.game, f.owner, f.play);
assert.deepEqual(f.game, before);
const frame = pending.pendingTreacheryDiscard; assert.ok(frame);
assert.equal(frame.reaction?.stage, 'offer');
assert.equal(frame.continuation.kind, 'ordinaryCardDiscard');
assert.deepEqual(frame.batch.entries.map(entry => entry.card.id), [RECRUITS_CARD_ID]);
assert.equal(pending.recruits?.turn, pending.turn); assert.equal(pending.recruits?.player, f.owner);
for (const seat of pending.players) {
const reaction = viewGame(pending, seat.id).semutaReaction; assert.ok(reaction);
assert.deepEqual(reaction.candidates, []);
assert.equal(reaction.canCommit, seat.id === f.holder);
}
rejected(pending, f.owner, {type: 'revive', amount: 3, elite: 0});
const event = frame.batch.event;
let resumed: Game = pending;
if (claim) {
resumed = act(resumed, f.holder, {type: 'semutaCommit', event});
if (resumed.pendingTreacheryDiscard?.reaction?.stage === 'select')
resumed = act(resumed, f.holder, {type: 'semutaSelect', event, card: RECRUITS_CARD_ID});
} else {
for (const seat of pending.players) resumed = act(resumed, seat.id, {type: 'semutaPass', event});
}
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(viewGame(resumed, f.owner).revival.freeRemaining, 4);
assert.equal(viewGame(resumed, f.owner).revival.limit, 7);
assert.equal(player(resumed, f.holder).hand.some(card => card.id === RECRUITS_CARD_ID), claim);
assert.equal(resumed.discard.filter(card => card.id === RECRUITS_CARD_ID).length, Number(!claim));
assert.equal(resumed.discard.filter(card => card.id === SEMUTA_DRUG_ID).length, Number(claim));
assert.deepEqual(inventory(resumed), f.originalInventory);
rejected(resumed, f.holder, {type: 'semutaCommit', event});
if (claim) rejected(resumed, f.holder, f.play);
const old = {reserves: player(resumed, f.owner).reserves, tanks: player(resumed, f.owner).tanks,
spice: player(resumed, f.owner).spice};
const revived = act(resumed, f.owner, {type: 'revive', amount: 3, elite: 0});
assert.equal(player(revived, f.owner).tanks, old.tanks - 3);
assert.equal(player(revived, f.owner).reserves, old.reserves + 3);
assert.equal(player(revived, f.owner).spice, old.spice);
assert.equal(player(revived, f.owner).freeForcesRevived, 3);
assert.equal(revived.recruits?.turn, resumed.turn);
});
}
void test('each AI difficulty can legally answer the Recruits discard offer', () => {
const f = fixture();
const pending = act(f.game, f.owner, f.play);
for (const difficulty of DIFFICULTIES) {
const game = structuredClone(pending);
player(game, f.holder).bot = difficulty;
const action = botActions(viewGame(game, f.holder)).find(item =>
item.type === 'semutaCommit' || item.type === 'semutaPass');
assert.ok(action);
assert.doesNotThrow(() => act(game, f.holder, action));
}
});
void test('a changed revival parent cannot resume a saved Recruits discard', () => {
const f = fixture();
const pending = act(f.game, f.owner, f.play);
const stale = structuredClone(pending);
stale.freeRevival.push(f.owner);
assert.throws(() => viewGame(stale, f.owner), /completed effect/);
assert.ok(viewGame(pending, f.owner).semutaReaction);
});
void test('a native CHOAM seat does not silently enable this narrow preview', () => {
const f = fixture(), lobby = structuredClone(f.lobby);
const index = lobby.players.findIndex(seat => seat.id === 'emperor');
assert.ok(index >= 0);
lobby.players[index] = newPlayer('emperor', 'CHOAM', 'choam');
lobby.players[index].ready = true;
const game = act(lobby, lobby.host, {type: 'start'});
assert.equal(game.semutaRecruitsPreview, undefined);
assert.equal(game.semutaPreview, undefined);
});