Extend Semuta battles and specials to native factions
Verify and publish checkpoint container / container (push) Successful in 10m34s

This commit is contained in:
Quintine committed 2026-10-29 03:11:59 +11:00
1 parent 58303654e8
commit 4ee4d9fa10
6 files changed
+362 -4

No files matched your search

+11
View File
@@ -17,6 +17,17 @@ open mode gates.
## Current checkpoint and work
**Semuta battle and special cards with native factions — 29 October.
Prototyped / Partial.** Fresh Basic Richese games with independent cards now
offer supported battle-discard batches and completed Box, Distrans, simple
Truthtrance and Ornithopter discards with selected native factions seated.
Eight related files pass 83 cases; Ecaz and Tleilaxu fight actual battles, and
one Tleilaxu journey continues through declining its original Face Dance.
The special-card fixtures stage conserved card transfers; both select
unopened card and Spice order. Typecheck, lint and the production build pass. Moritani retention,
Ixian substitution and other competing parents remain separately guarded.
[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#battle-and-special-card-discards-with-native-factions--29-october-2026).
**Semuta ordinary specials with native factions — 29 October. Prototyped /
Partial.** Fresh Basic Richese and independent-card games can offer used Hajr,
Ghola, Harvester, Weather Control and Family Atomics to Semuta with selected
+15
View File
@@ -1,5 +1,20 @@
# Semuta discard producers and continuation inventory
## Battle and special-card discards with native factions — 29 October 2026
**Prototyped / Partial.** Fresh Basic Richese games with selected native
faction decks and independent Ecaz Treachery Cards can offer mandatory loser,
mandatory winner and selected winner battle discards, plus completed Box,
Distrans, definite fact/freeform Truthtrance and ordinary Ornithopter discards.
Claim or pass preserves the original effect and continuation. Genuine Ecaz
and Tleilaxu battles exercise the three batches; Tleilaxu's pending Face Dance
remains available. The special-card journeys stage conserved transfers of the
tested cards. Both fixtures select unopened card and Spice order. Moritani
retention, Ixian substitution, other pending parents and optional modules
retain their separate guards and remain unfinished. [Battle cases](../tests/semuta-independent-native-battle.test.ts)
and [special-card cases](../tests/semuta-independent-native-specials.test.ts)
cover the connected paths.
## Ordinary specials with native expansion factions — 29 October 2026
**Prototyped / Partial.** Fresh Basic Richese games with selected native
+10 -3
View File
@@ -9599,7 +9599,7 @@ function semutaOfferSupported(
(continuation.source === 'end' || continuation.source === 'cunning' || continuation.source === 'homeworld' ||
(continuation.source === 'move' &&
(!g.players.some(p => p.faction === 'ecaz' || p.faction === 'moritani') ||
bareBasicSemutaProfile(g)) &&
bareBasicSemutaProfile(g) || basicIndependentRecruitsSemutaProfile(g)) &&
!g.pendingArrivalOverlap))) &&
!(continuation.kind === 'truthtranceDiscard' &&
(continuation.consumed.source === undefined || continuation.consumed.source === 'shrine') &&
@@ -9708,7 +9708,14 @@ function semutaOfferSupported(
return (g.semutaPreview === true || g.semutaRecruitsPreview === true &&
(!g.pendingTreacheryDiscard || !!g.pendingTreacheryDiscard.reaction) &&
(continuation.kind === 'ordinaryCardDiscard' &&
['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect)
['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) ||
continuation.kind === 'battleResolved' || continuation.kind === 'winnerMandatoryDiscard' ||
continuation.kind === 'battleCleanup' && continuation.source === 'winner' ||
continuation.kind === 'nullentropyDiscard' || continuation.kind === 'distransDiscard' ||
continuation.kind === 'truthtranceDiscard' && continuation.promise === null &&
(continuation.record.question.kind === 'fact' || continuation.record.question.kind === 'freeform') ||
continuation.kind === 'ornithopterDiscard' &&
(continuation.source === 'end' || continuation.source === 'move')
? basicIndependentRecruitsSemutaProfile(g) : basicIndependentSemutaProfile(g)) &&
(continuation.kind === 'ordinaryCardDiscard' &&
['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect) ||
@@ -42809,7 +42816,7 @@ function applyActionInner(
`${p.name} played Distrans to give ${recipient.name} one private Treachery Card, then discarded Distrans. No spice, purchase bonus or recipient confirmation is required.`,
{ faction: p.faction, name: 'Distrans' },
);
if ((g.semutaPreview || g.semutaRecruitsPreview && basicIndependentSemutaProfile(g)) && !g.pendingTreacheryDiscard) {
if ((g.semutaPreview || g.semutaRecruitsPreview && basicIndependentRecruitsSemutaProfile(g)) && !g.pendingTreacheryDiscard) {
const continuation: DistransDiscardContinuation = {
kind: 'distransDiscard', owner: p.id, recipient: recipient.id,
transferred: result.transferred.id, card: result.discarded.id,
+1 -1
View File
@@ -6620,7 +6620,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, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits or a used Hajr, Ghola, Harvester, Weather Control or Family Atomics immediately after another player discards it, including when other selected expansion factions are seated. The original effect stays committed. In the separately supported classic-roster profile, supported battle cleanup, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card. Other Semuta opportunities remain unfinished.',
'In a fresh Basic game with Richese, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits or a used Hajr, Ghola, Harvester, Weather Control or Family Atomics immediately after another player discards it, including when other selected expansion factions are seated. Supported battle cleanup, completed Box searches, Distrans, simple Truthtrance and ordinary Ornithopter can also offer their discarded card. The original effect stays committed; other Semuta opportunities 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.',
@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { finishSemutaBattleOutcome } from './fixture-semuta-homeworld-choam';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { splitLocation } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { actBasicSemutaAmbassador as act, basicSemutaAmbassadorClean as clean,
basicSemutaAmbassadorInventory as inventory, basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador';
/** Original public selected-family setup, Richese cache sale, paid shipments
* and real native-faction battle. Only unopened starting-deck and Spice-card
* order are selected for this witness. */
function createNativeBattle(kind: 'loser-reinforcements' | 'loser-harass' | 'winner-mandatory' | 'winner-selected', native: 'tleilaxu' | 'ecaz') {
let game = createGame('SEMUTANATIVEBATTLE', newPlayer(native, native, native), false, ['choam', 'ix', 'ecaz']);
for (const faction of ['atreides', 'richese', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const loser = native, winner = 'atreides', holder = 'guild', location = 'red_chasm:7';
const perform = (actor: string, action: Action) => { game = act(game, actor, action); };
const reach = (until: (state: Game) => boolean) => {
for (let count = 0; count < 1800 && !until(game); count++) {
assert.notEqual(game.status, 'finished');
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const reaction = game.pendingTreacheryDiscard.reaction;
const actor = game.players.find(p => !reaction.passed.includes(p.id));
assert.ok(actor);
perform(actor.id, {type:'semutaPass', event:game.pendingTreacheryDiscard.batch.event});
} else {
const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step, `Native step required at ${game.phase}`);
perform(step.actor, step.action);
}
}
assert.ok(until(game), 'The real game must reach the requested boundary.');
};
perform(loser, {type:'moduleOption', module:'ecazTreachery', enabled:true});
for (const seat of game.players) perform(seat.id, {type:'ready'});
perform(loser, {type:'start'});
assert.equal(game.semutaRecruitsPreview, true);
const originalInventory = inventory(game);
const cardId = kind === 'loser-harass' ? 'ecaz-harass-withdraw' : 'ecaz-reinforcements';
const shield = game.deck.find(card => card.kind === 'shield'); assert.ok(shield);
const reserved = new Set([cardId, shield.id]);
const filler = game.deck.filter(card => !reserved.has(card.id)).slice(0, 2); assert.equal(filler.length, 2);
const starting = kind === 'winner-mandatory'
? [filler[0].id, cardId, filler[1].id, shield.id]
: [cardId, shield.id, filler[0].id, filler[1].id];
for (let index = 0; index < starting.length; index++) {
const from = game.deck.findIndex(card => card.id === starting[index]); assert.ok(from >= index);
[game.deck[index], game.deck[from]] = [game.deck[from], game.deck[index]];
}
reach(state => state.status === 'playing');
assert.ok(player(game, kind === 'winner-mandatory' ? winner : loser).hand.some(card => card.id === cardId));
if (kind !== 'winner-mandatory') assert.ok(player(game, winner).hand.some(card => card.id === shield.id));
for (let index = 0; index < 3; index++) {
const from = game.spiceDeck.findIndex((card, pos) => pos >= index && 'territory' in card && !card.discovery);
assert.ok(from >= index); game.spiceDeck.splice(index, 0, game.spiceDeck.splice(from, 1)[0]);
}
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
perform('richese', {type:'decision', event:game.richeseBidding!.event, card:SEMUTA_DRUG_ID,
method:'onceAround', direction:'counterclockwise'});
reach(state => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
for (let count = 0; !player(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
const lot = game.richeseAuction; assert.ok(lot?.active && !lot.outcome);
perform(lot.active, {type:'richeseBid', event:lot.event, amount:lot.active === holder ? 1 : null, allyPayment:0});
reach(clean);
}
assert.ok(player(game, holder).hand.some(card => card.id === SEMUTA_DRUG_ID));
const shipped = new Set<string>();
while (shipped.size < 2) {
reach(state => state.phase === 5 && !!state.active && clean(state));
const actor = game.active!;
if ([loser, winner].includes(actor) && !shipped.has(actor)) {
perform(actor, {type:'ship', ...splitLocation(location), amount:actor === winner ? 4 : 1, elite:0, allyPayment:0});
reach(clean); shipped.add(actor);
}
perform(actor, {type:'endMovement'});
}
reach(state => state.phase === 6 && !state.battle && clean(state));
const actor = game.active!;
const choice = viewGame(game, actor).battleChoices.find(row => row.territory === 'red_chasm' &&
[row.attacker, row.defender].includes(loser) && [row.attacker, row.defender].includes(winner));
assert.ok(choice);
perform(choice.chooser, {type:'chooseBattle', territory:choice.territory,
target:choice.attacker === choice.chooser ? choice.defender : choice.attacker});
reach(state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed !== false && clean(state));
const strongest = player(game, winner).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a,b) => b.strength-a.strength)[0];
const weakest = player(game, loser).leaders.filter(leader => !leader.dead && !leader.usedAt).sort((a,b) => a.strength-b.strength)[0];
assert.ok(strongest && weakest && strongest.strength + 1 > weakest.strength + 2);
perform(winner, {type:'battlePlan', dial:1, support:0, leader:strongest.id, weapon:null,
defense:kind === 'winner-mandatory' ? cardId : shield.id});
perform(loser, {type:'battlePlan', dial:0, support:0, leader:weakest.id,
weapon:kind === 'winner-mandatory' ? null : cardId, defense:null});
let final: {actor:string;action:Action} | undefined;
for (let count = 0; count < 100; count++) {
const step = nextRicheseStrongholdsNativeStep(game); assert.ok(step);
if (step.action.type === 'traitorCall') {
const voters = viewGame(game, game.host).battle!.traitorVoters;
if (voters.every(id => id === step.actor || game.battle!.traitorCalls[id] !== undefined)) { final = step; break; }
}
perform(step.actor, step.action);
}
assert.ok(final);
return {game, loser, winner, holder, location, card:cardId, shield:shield.id, originalInventory,
finalActor:final.actor, finalAction:final.action, kind};
}
function react(game: Game, holder: string, claim: boolean, selected: string) {
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
const event = pending.batch.event;
if (!claim) {
for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event});
return game;
}
game = act(game, holder, {type:'semutaCommit', event});
return game.pendingTreacheryDiscard?.reaction?.stage === 'select'
? act(game, holder, {type:'semutaSelect', event, card:selected}) : game;
}
function advance(game: Game, until: (state: Game) => boolean) {
for (let count = 0; count < 600 && !until(game); count++) {
if (game.pendingTreacheryDiscard?.reaction?.stage === 'offer') game = react(game, '', false, '');
else {
const next = nextRicheseStrongholdsNativeStep(game); assert.ok(next, `Original battle needs a step at ${game.phase}/${game.decision?.kind}`);
game = act(game, next.actor, next.action);
}
}
assert.ok(until(game)); return game;
}
for (const native of ['tleilaxu','ecaz'] as const)
for (const kind of ['loser-reinforcements','loser-harass','winner-mandatory','winner-selected'] as const)
for (const claim of [false,true]) void test(`Basic ${native} ${kind} offers Semuta: ${claim ? 'claim' : 'pass'}`, () => {
const f = createNativeBattle(kind, native);
let game = finishSemutaBattleOutcome(act(f.game, f.finalActor, f.finalAction));
if (kind === 'winner-selected') {
assert.equal(game.pendingTreacheryDiscard?.batch.cause, 'battle:mandatory');
game = react(game, f.holder, false, f.card);
game = advance(game, state => state.decision?.kind === 'battleCards' && state.decision.player === f.winner);
game = act(game, f.winner, {type:'decision', discard:[f.shield]});
}
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
assert.equal(pending.batch.cause, kind === 'winner-selected' ? 'battle:winner' :
kind === 'winner-mandatory' ? 'battle:winnerMandatory' : 'battle:mandatory');
const card = kind === 'winner-selected' ? f.shield : f.card;
assert.ok(pending.batch.entries.some(entry => entry.card.id === card));
const before = structuredClone(game);
if (native === 'tleilaxu') {
assert.equal(game.pendingFaceDance?.player, native);
assert.equal(game.pendingFaceDance?.winner, f.winner);
}
if (native === 'tleilaxu' && kind === 'loser-reinforcements') for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.holder); view.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const action = botActions(view).find(option => option.type === 'semutaCommit' || option.type === 'semutaPass');
assert.ok(action); assert.doesNotThrow(() => act(game, f.holder, action));
}
game = react(game, f.holder, claim, card);
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(player(game, f.loser).tanks, player(before, f.loser).tanks);
assert.equal(game.lastBattleContext?.event, before.lastBattleContext?.event);
assert.equal(player(game, f.holder).hand.some(held => held.id === card), claim);
assert.equal(game.discard.some(discarded => discarded.id === card), !claim);
assert.equal(game.discard.some(discarded => discarded.id === SEMUTA_DRUG_ID), claim);
assert.deepEqual(inventory(game), f.originalInventory);
assert.throws(() => act(game, f.holder, {type:'semutaCommit',event:pending.batch.event}));
if (native === 'tleilaxu') assert.equal(game.pendingFaceDance?.player, native);
if (native === 'tleilaxu' && kind === 'loser-reinforcements' && claim) {
game = advance(game, state => state.decision?.kind === 'faceDance' && state.decision.player === native);
game = act(game, native, {type:'decision', reveal:false});
assert.equal(game.pendingFaceDance ?? null, null);
assert.ok(player(game, f.holder).hand.some(held => held.id === card));
}
});
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { distance, location, TERRITORIES } from '../game/board';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
import { basicSemutaAmbassadorClean as clean, basicSemutaAmbassadorInventory as inventory,
basicSemutaAmbassadorPlayer as player } from './fixture-basic-semuta-ambassador';
import { moveSemutaEmperorCard, semutaEmperorCardId } from './fixture-semuta-emperor-share';
type Native = 'tleilaxu' | 'ecaz';
type Kind = 'box' | 'distrans' | 'truth' | 'ornithopter';
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
function advance(game: Game, until: (game: Game) => boolean): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
const pending = game.pendingTreacheryDiscard;
if (pending?.reaction?.stage === 'offer') {
const reaction = pending.reaction;
const next = game.players.find(row => !reaction.passed.includes(row.id)); assert.ok(next);
game = act(game, next.id, {type:'semutaPass', event:pending.batch.event});
} else {
const next = nextRicheseStrongholdsNativeStep(game);
assert.ok(next, `The native game needs a decision at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = act(game, next.actor, next.action);
}
}
throw Error('The original native game did not reach its requested boundary.');
}
/** Public Basic setup with selected native faction decks; Guild buys the real
* Semuta and Atreides buys Hajr. Unopened main/Spice ordering is selected.
* A single additional physical card is moved to Atreides after its purchase. */
function fixture(kind: Kind, native: Native) {
let game = createGame('SEMUTANATIVESPECIALS', newPlayer('atreides','Atreides','atreides'), false,
['choam','ix','ecaz']);
for (const faction of ['richese','guild',native] as const)
joinGame(game,newPlayer(faction,faction,faction));
game = act(game,'atreides',{type:'moduleOption',module:'ecazTreachery',enabled:true});
for (const seat of game.players) game = act(game,seat.id,{type:'ready'});
game = act(game,'atreides',{type:'start'});
assert.equal(game.status,'setup'); assert.equal(game.semutaRecruitsPreview,true);
const originalInventory = inventory(game);
const starting = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind))
.slice(0,game.players.length).map(card => card.id);
const hajr = game.deck.find(card => card.effect === 'hajr'); assert.ok(hajr);
for (const [position,id] of [...starting,hajr.id].entries()) {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position],game.deck[from]] = [game.deck[from],game.deck[position]];
}
game = advance(game,state => state.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); game.spiceDeck.splice(position,0,game.spiceDeck.splice(from,1)[0]);
}
game = advance(game,state => !state.response && !state.phaseOpening && state.decision?.kind === 'richeseCache');
game = act(game,'richese',{type:'decision',event:game.richeseBidding!.event,card:SEMUTA_DRUG_ID,
method:'onceAround',direction:'counterclockwise'});
game = advance(game,state => clean(state) && !!state.richeseAuction && !state.richeseAuction.outcome);
for (let count = 0; !player(game,'guild').hand.some(card => card.id === SEMUTA_DRUG_ID) && count < 8; count++) {
const lot = game.richeseAuction; assert.ok(lot && lot.active && !lot.outcome);
game = act(game,lot.active,{type:'richeseBid',event:lot.event,amount:lot.active === 'guild' ? 1 : null,allyPayment:0});
game = advance(game,clean);
}
assert.ok(player(game,'guild').hand.some(card => card.id === SEMUTA_DRUG_ID));
game = advance(game,state => state.phase === 3 && clean(state) && state.auction?.active === 'atreides');
assert.equal(game.auction!.cards[game.auction!.index].id,hajr.id);
game = act(game,'atreides',{type:'bid',amount:1,allyPayment:0});
game = advance(game,state => clean(state) && player(state,'atreides').hand.some(card => card.id === hajr.id));
game = advance(game,state => state.phase === 5 && state.active === 'atreides' && clean(state));
const effect = kind === 'box' ? 'nullentropyBox' : kind === 'truth' ? 'truthtrance' : kind;
const card = semutaEmperorCardId(game,item => item.effect === effect);
moveSemutaEmperorCard(game,card,'atreides');
assert.deepEqual(inventory(game),originalInventory);
let given: string | undefined, selected: string | undefined;
if (kind === 'box') {
game = act(game,'atreides',{type:'card',card:hajr.id});
const pending = game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
for (const seat of game.players) game = act(game,seat.id,{type:'semutaPass',event:pending.batch.event});
selected = hajr.id;
game = act(game,'atreides',{type:'card',card});
if (game.pendingNullentropy)
game = act(game,'atreides',{type:'decision',event:game.pendingNullentropy.event,card:selected});
} else if (kind === 'distrans') {
given = hajr.id;
game = act(game,'atreides',{type:'card',card,target:native,give:given});
} else if (kind === 'truth') {
game = act(game,'atreides',{type:'card',card});
while (game.truthtrance?.stage === 'priority') {
const next = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(next);
game = act(game,next.id,{type:'truthPass'});
}
game = act(game,'atreides',{type:'truthAsk',question:{kind:'freeform',target:native,
scope:'fact',text:'Do you intend to protect Arrakeen?'}});
game = act(game,native,{type:'truthAnswer',answer:'yes'});
} else {
const open = TERRITORIES.flatMap(place => place.type === 'sand' ? place.sectors.map(sector => ({place,sector})) : [])
.find(({place,sector}) => sector !== game.storm && game.players.every(seat =>
!Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`))));
assert.ok(open);
game = act(game,'atreides',{type:'ship',territory:open.place.id,sector:open.sector,amount:1,elite:0,allyPayment:0});
game = advance(game,clean);
const from = location(open.place.id,open.sector);
const target = TERRITORIES.flatMap(place => place.sectors.map(sector => ({place,sector})))
.find(({place,sector}) => place.id !== open.place.id && sector !== game.storm &&
distance(from,location(place.id,sector)) <= 1 && game.players.every(seat =>
!Object.keys(seat.forces).some(key => key.startsWith(`${place.id}:`))));
assert.ok(target);
game = act(game,'atreides',{type:'move',from,amount:1,elite:0,territory:target.place.id,
sector:target.sector,movementCard:card,ornithopter:'range3'});
}
return {game,card,given,selected,originalInventory};
}
for (const native of ['tleilaxu','ecaz'] as const) for (const kind of ['box','distrans','truth','ornithopter'] as const)
for (const claim of [false,true]) {
void test(`Basic ${native} ${kind} discard offers Semuta ${claim ? 'claim' : 'pass'} without replay`, () => {
const f = fixture(kind,native), frame = f.game.pendingTreacheryDiscard; assert.ok(frame?.reaction);
assert.deepEqual(frame.batch.entries.map(entry => entry.card.id),[f.card]);
assert.equal(viewGame(f.game,'guild').semutaReaction?.canCommit,true);
if (kind === 'distrans') {
assert.ok(player(f.game,native).hand.some(card => card.id === f.given));
assert.ok(!frame.batch.entries.some(entry => entry.card.id === f.given));
}
if (kind === 'box') assert.ok(player(f.game,'atreides').hand.some(card => card.id === f.selected));
if (kind === 'truth') assert.equal(f.game.truthHistory?.at(-1)?.answer,'yes');
const before = structuredClone({players:f.game.players.map(row => ({id:row.id,spice:row.spice,forces:row.forces})),
truthHistory:f.game.truthHistory,moved:player(f.game,'atreides').moved});
if (kind === 'distrans' && native === 'tleilaxu') for (const difficulty of DIFFICULTIES) {
const view = viewGame(f.game,'guild'); view.players.find(row => row.id === 'guild')!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.some(action => action.type === 'semutaCommit' || action.type === 'semutaPass'));
}
let done = f.game;
if (claim) {
done = act(done,'guild',{type:'semutaCommit',event:frame.batch.event});
if (done.pendingTreacheryDiscard?.reaction?.stage === 'select')
done = act(done,'guild',{type:'semutaSelect',event:frame.batch.event,card:f.card});
} else for (const seat of done.players) done = act(done,seat.id,{type:'semutaPass',event:frame.batch.event});
assert.equal(done.pendingTreacheryDiscard,null);
assert.deepEqual({players:done.players.map(row => ({id:row.id,spice:row.spice,forces:row.forces})),
truthHistory:done.truthHistory,moved:player(done,'atreides').moved},before);
assert.equal(player(done,'guild').hand.some(card => card.id === f.card),claim);
assert.equal(done.discard.some(card => card.id === f.card),!claim);
assert.equal(done.discard.some(card => card.id === SEMUTA_DRUG_ID),claim);
assert.deepEqual(inventory(done),f.originalInventory);
});
}