Prototype Semuta after Recruits in Basic play
Verify and publish checkpoint container / container (push) Successful in 10m20s
Verify and publish checkpoint container / container (push) Successful in 10m20s
This commit is contained in:
1 parent
0e9209531a
commit
1ba942138d
9 files changed
+311
-18
No files matched your search
@@ -1278,6 +1278,14 @@ export function GameTable({
|
||||
{g.richeseBidding.offerBlocked}
|
||||
</output>
|
||||
)}
|
||||
{g.semutaRecruitsPreview && (
|
||||
<p className="notice" role="status">
|
||||
Semuta Drug preview: in this independent-card game, Semuta can react
|
||||
to another player discarding Recruits. Other Semuta discard
|
||||
opportunities in this combination are still being implemented.{' '}
|
||||
<a href="/rules?topic=card-recruits#card-recruits">Recruits rules</a>
|
||||
</p>
|
||||
)}
|
||||
{g.semutaPreview && (
|
||||
<p className="notice" role="status">
|
||||
{g.homeworlds ? (
|
||||
|
||||
@@ -16,6 +16,16 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Semuta after Recruits in Basic independent cards — 28 October. Prototyped /
|
||||
Partial.** Fresh Richese/classic Basic starts with the CHOAM deck and Ecaz
|
||||
Treachery Cards offer the actual Recruits discard to Semuta, retaining one
|
||||
committed revival benefit and the original phase. Other Semuta discards in
|
||||
this independent-card combination, native expansion factions and optional
|
||||
modules remain outside this preview. [Scope](SEMUTA_DISCARD_CONTINUATIONS.md#recruits-in-the-basic-independent-card-preview--28-october-2026).
|
||||
The related eleven-file union passes 118 cases and the application builds.
|
||||
Two Advanced module fixtures were corrected to follow the real ordered Revival;
|
||||
their earlier failures also reproduced on the previous revision.
|
||||
|
||||
**In progress: printed three-member alliance options.** Establish a canonical
|
||||
group and unanimous admission/voluntary leaving model for the printed
|
||||
three- and four-stronghold variants. The tested, unexposed foundation handles
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Semuta discard producers and continuation inventory
|
||||
|
||||
## Recruits in the Basic independent-card preview — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** A fresh Basic public start with Richese and classic
|
||||
factions, the CHOAM deck and independent Ecaz Treachery Cards can offer the
|
||||
physical Recruits discard to Semuta. Recruits commits its doubled free rates
|
||||
and seven-force ordinary limit once before the neutral claim/pass window; the
|
||||
same Revival resumes afterward. The recipient may hold the recovered card but
|
||||
cannot replay the effect in that turn. Existing physical cards and free-use
|
||||
ledgers remain authoritative. This is a Recruits-only preview: other Semuta
|
||||
discard producers in the independent-card combination remain unfinished, and
|
||||
other factions, decks and optional modules retain their prior boundaries.
|
||||
The printed discard opportunity follows the source audit below; the order of
|
||||
the completed effect and online reaction is a provisional implementation.
|
||||
|
||||
Read-only engine audit, 2026-09-07. Read the complete `docs/SEMUTA_DRUG_ENGINE_AUDIT.md`, then inspected every current `discard(...)` call, direct Treachery-discard mutation and `draw(g)` caller in `game/engine.ts`, plus the Truthtrance discard callback and pure Box/Distrans results. Names below are integration anchors; approximate current lines are navigation aids only. This report adopts no additional source ruling and changes no project file.
|
||||
|
||||
## Kull and a Basic Voice or Prescience response — 28 October 2026
|
||||
|
||||
+40
-3
@@ -1659,7 +1659,7 @@ export type Game = {
|
||||
kind: 'ordinaryCardDiscard';
|
||||
player: string;
|
||||
card: string;
|
||||
effect: 'hajr' | 'ghola' | 'harvester' | 'weather' | 'atomics';
|
||||
effect: 'hajr' | 'ghola' | 'harvester' | 'weather' | 'atomics' | 'recruits';
|
||||
resume: NonNullable<Game['pendingNullentropy']>['resume'];
|
||||
stateSignature: string;
|
||||
}
|
||||
@@ -2605,6 +2605,8 @@ export type Game = {
|
||||
moritaniAssassinatePreview?: true;
|
||||
/** Explicit development-only neutral Semuta reaction profile. */
|
||||
semutaPreview?: true;
|
||||
/** Fresh Basic independent-card preview for Semuta after Recruits only. */
|
||||
semutaRecruitsPreview?: true;
|
||||
moritaniAssassinate?: MoritaniAssassinateState;
|
||||
moritaniAssassinateCallEvents?: string[];
|
||||
moritaniAssassinateResume?: {
|
||||
@@ -6230,6 +6232,8 @@ function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) {
|
||||
...(g.grummanCollection === undefined ? {} : { grummanCollection: g.grummanCollection }),
|
||||
dukeVidal: g.dukeVidal,
|
||||
revivalFreeIncome: g.revivalFreeIncome,
|
||||
...(next.effect === 'recruits' ? {recruits: g.recruits, freeRevival: g.freeRevival,
|
||||
revivalRules: g.revivalRules, revivalOrder: g.revivalOrder} : {}),
|
||||
players: g.players.map((p) => ({
|
||||
id: p.id,
|
||||
faction: p.faction,
|
||||
@@ -6243,6 +6247,7 @@ function ordinaryDiscardSignature(g: Game, next: OrdinaryDiscardContinuation) {
|
||||
moved: p.moved,
|
||||
shipped: p.shipped,
|
||||
revived: p.revived,
|
||||
...(next.effect === 'recruits' ? {freeForcesRevived: p.freeForcesRevived} : {}),
|
||||
revivalCycle: p.revivalCycle,
|
||||
...(p.ecazRevivalCohort === undefined ? {} : { ecazRevivalCohort: p.ecazRevivalCohort }),
|
||||
leaderRevived: p.leaderRevived,
|
||||
@@ -7809,6 +7814,9 @@ function tupileDiscardSignature(g: Game, c: TupileDiscardContinuation) {
|
||||
}
|
||||
function treacheryDiscardIntegrity(g: Game) {
|
||||
const discovery = g.discoveryEnabled === true && !!g.discoveries && discoveryModeSupported(g);
|
||||
requireRule(g.semutaRecruitsPreview === undefined || g.semutaRecruitsPreview === true &&
|
||||
basicIndependentSemutaProfile(g) && !g.semutaPreview,
|
||||
'This Recruits Semuta preview no longer matches its original Basic profile.');
|
||||
requireRule(g.semutaPreview === undefined || g.semutaPreview === true &&
|
||||
g.expansions.includes('choam') && !!byFaction(g, 'richese') &&
|
||||
(!g.homeworlds || g.homeworldOccupationPreview === true && semutaHomeworldRoster(g)) &&
|
||||
@@ -7999,7 +8007,7 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
const owner = g.players.find((p) => p.id === c.player);
|
||||
requireRule(
|
||||
owner &&
|
||||
['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(
|
||||
['hajr', 'ghola', 'harvester', 'weather', 'atomics', 'recruits'].includes(
|
||||
c.effect,
|
||||
) &&
|
||||
batch.cause === `ordinary:${c.effect}` &&
|
||||
@@ -8022,7 +8030,11 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
'The consumed card no longer matches its pending revival income.',
|
||||
);
|
||||
requireRule(
|
||||
c.effect === 'hajr'
|
||||
c.effect === 'recruits'
|
||||
? g.semutaRecruitsPreview === true && basicIndependentSemutaProfile(g) &&
|
||||
g.phase === 4 && g.recruits?.turn === g.turn && g.recruits.player === c.player &&
|
||||
g.recruits.card === c.card
|
||||
: c.effect === 'hajr'
|
||||
? g.phase === 5 &&
|
||||
g.active === c.player &&
|
||||
g.hajr.filter((id) => id === c.player).length === 1
|
||||
@@ -9282,6 +9294,14 @@ function semutaOfferSupported(
|
||||
continuation: NonNullable<Game['pendingTreacheryDiscard']>['continuation'],
|
||||
entries: FreshDiscardBatch['entries'],
|
||||
) {
|
||||
if (continuation.kind === 'ordinaryCardDiscard' && continuation.effect === 'recruits')
|
||||
return g.semutaRecruitsPreview === true && basicIndependentSemutaProfile(g) &&
|
||||
g.status === 'playing' && g.phase === 4 && g.recruits?.turn === g.turn &&
|
||||
g.recruits.player === continuation.player && g.recruits.card === continuation.card &&
|
||||
entries.length === 1 && entries[0].publicFace && entries[0].discardedBy === continuation.player &&
|
||||
entries[0].card.id === RECRUITS_CARD_ID && entries[0].card.effect === 'recruits' &&
|
||||
!continuation.resume.response && !continuation.resume.decision &&
|
||||
!continuation.resume.pendingKarama && !continuation.resume.phaseOpening;
|
||||
if (continuation.kind === 'kullSuccessDiscard' || continuation.kind === 'kullCounterDiscard')
|
||||
return g.semutaPreview === true && g.advanced === false && bareBasicSemutaProfile(g) &&
|
||||
!!byFaction(g, 'richese') && kullSemutaParentSupported(kullSource(g, continuation.frame), continuation.frame) &&
|
||||
@@ -12928,6 +12948,17 @@ export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
|
||||
'Assassination requires Advanced Moritani in a supported selected native roster and module composition.');
|
||||
return initializeFactionExpansionsGameForAudit(state);
|
||||
}
|
||||
/** Independent Ecaz cards with Richese and classic factions, without other
|
||||
* modules, reuse the already supported Basic Semuta discard machinery. */
|
||||
function basicIndependentSemutaProfile(g: Game): boolean {
|
||||
return g.advanced === false && g.ecazTreachery === true && g.expansions.length === 1 &&
|
||||
g.expansions[0] === 'choam' && !!byFaction(g, 'richese') &&
|
||||
g.players.length >= 2 && g.players.length <= 6 &&
|
||||
g.players.every(p => p.faction === 'richese' || faction(p.faction).expansion === 'base') &&
|
||||
!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens && !g.strongholdCards &&
|
||||
!g.discoveryEnabled && !g.discoveries && !g.discoveryStash && !g.greatMaker &&
|
||||
!g.kullPreview && !g.nexusKullPreview && !g.ecazOccupyPreview && !g.moritaniAssassinatePreview;
|
||||
}
|
||||
/** Bare native Basic starts share the original deck/roster validation. Optional
|
||||
* modules retain their own explicit Semuta envelopes; saved games are not upgraded. */
|
||||
function bareBasicSemutaProfile(g: Game): boolean {
|
||||
@@ -13123,6 +13154,8 @@ function independentCardModulesProfile(g: Game): boolean {
|
||||
/** Shared fresh setup for the independent three-card Ecaz variant. */
|
||||
export function initializeEcazTreacheryGameForAudit(state: Game): Game {
|
||||
requireRule(!state.ecazTreachery, 'Ecaz Treachery Cards cannot redeal an existing inventory.');
|
||||
requireRule(state.semutaRecruitsPreview === undefined,
|
||||
'Fresh independent cards cannot inherit an existing Recruits Semuta marker.');
|
||||
const g = structuredClone(state);
|
||||
g.ecazTreachery = true;
|
||||
const discovery = independentDiscoveryProfile(g) && discoveryModuleProfile(g);
|
||||
@@ -13139,6 +13172,7 @@ export function initializeEcazTreacheryGameForAudit(state: Game): Game {
|
||||
requireFreshFactionInventory(g);
|
||||
}
|
||||
const initialized = initializeSetupGameForAudit(g, homeworlds, independentNexusProfile(g), false, discovery, false, false, true, true);
|
||||
if (basicIndependentSemutaProfile(initialized)) initialized.semutaRecruitsPreview = true;
|
||||
if (byFaction(initialized, 'ecaz')) initialized.ecazOccupyPreview = true;
|
||||
if (moritaniAssassinateModeSupported(initialized)) initializeMoritaniAssassinateState(initialized);
|
||||
return initialized;
|
||||
@@ -41692,6 +41726,8 @@ function applyActionInner(
|
||||
`${p.name} played Recruits. Current free revival rates are doubled and the ordinary force revival limit is seven for this turn.`,
|
||||
{ faction: p.faction, name: 'Recruits' },
|
||||
);
|
||||
if (execution === 'live' && g.semutaRecruitsPreview && basicIndependentSemutaProfile(g))
|
||||
stageOrdinaryCardDiscard(g, p, card);
|
||||
return g;
|
||||
}
|
||||
if (t === 'card' && action.card === 'richese-juice-of-sapho') {
|
||||
@@ -45915,6 +45951,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
botsPending: g.botsPending ?? false,
|
||||
semutaReaction: projectedSemutaReaction(g, me),
|
||||
semutaPreview: g.semutaPreview === true,
|
||||
semutaRecruitsPreview: g.semutaRecruitsPreview === true,
|
||||
automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || homeworldOccupiedIncomeAutomatic(g) || grummanCollectionAutomatic(g) || tupileAutomatic(g),
|
||||
botNextActionAt: g.botNextActionAt ?? null,
|
||||
code: g.code,
|
||||
|
||||
@@ -6606,6 +6606,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.',
|
||||
'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.',
|
||||
'In a fresh Basic game with Richese, classic factions, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it. The Recruits benefit still lasts for the turn and cannot be played again. This preview covers the Recruits discard only; other Semuta discard opportunities in this combination remain unfinished.',
|
||||
'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.',
|
||||
'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Fremen may now grant after play under the same provisional current-rate policy, with its original Karama response settled before commitment. Missing/inconsistent ledgers, pending transactions and same-turn replay remain guarded.',
|
||||
'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user