Expand independent-card Semuta continuations
Verify and publish checkpoint container / container (push) Successful in 11m22s

This commit is contained in:
Quintine committed 2026-10-28 22:39:34 +11:00
1 parent 6719245f40
commit 50c6201d33
6 files changed
+136 -4

No files matched your search

@@ -0,0 +1,105 @@
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 { viewGame, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { actIndependentOrdinary as act, advanceIndependentOrdinary,
createIndependentOrdinaryFixture, independentOrdinaryClean,
independentOrdinaryInventory as inventory, independentOrdinaryPlayer as player,
} from './fixture-semuta-independent-ordinary';
import { moveSemutaEmperorCard, semutaEmperorCardId } from './fixture-semuta-emperor-share';
type Kind = 'box' | 'distrans' | 'truth' | 'ornithopter';
function close(game: Game, holder: string, claim: boolean): Game {
const batch = game.pendingTreacheryDiscard; assert.ok(batch?.reaction);
const event = batch.batch.event;
if (claim) return act(game, holder, {type:'semutaCommit', event});
for (const seat of game.players) game = act(game, seat.id, {type:'semutaPass', event});
return game;
}
/** Real public independent-card game and Semuta acquisition. The additional
* printed card is moved once between existing physical zones to isolate this
* interaction; no force, money, phase or event is staged. */
function fixture(kind: Kind) {
const source = createIndependentOrdinaryFixture('hajr');
let game = structuredClone(source.game);
const effect = kind === 'box' ? 'nullentropyBox' : kind === 'truth' ? 'truthtrance' : kind;
const card = semutaEmperorCardId(game, item => item.effect === effect);
moveSemutaEmperorCard(game, card, source.owner);
const initialInventory = inventory(game);
let given: string | undefined, selected: string | undefined;
if (kind === 'box') {
// Use the genuinely bought Hajr to create an actual discard target.
game = act(game, source.owner, source.play);
game = close(game, source.holder, false);
selected = source.card;
game = act(game, source.owner, {type:'card', card});
// A sole eligible discard is selected automatically by the native Box.
if (game.pendingNullentropy)
game = act(game, source.owner, {type:'decision', event:game.pendingNullentropy.event, card:selected});
} else if (kind === 'distrans') {
given = source.card;
game = act(game, source.owner, {type:'card', card, target:'emperor', give:given});
} else if (kind === 'truth') {
game = act(game, source.owner, {type:'card', card});
while (game.truthtrance?.stage === 'priority') {
const seat = game.players.find(row => !game.truthtrance!.passed.includes(row.id)); assert.ok(seat);
game = act(game, seat.id, {type:'truthPass'});
}
game = act(game, source.owner, {type:'truthAsk', question:{kind:'freeform', target:'emperor',
scope:'fact', text:'Do you intend to protect Arrakeen?'}});
game = act(game, 'emperor', {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, source.owner, {type:'ship', territory:open.place.id, sector:open.sector, amount:1, elite:0, allyPayment:0});
game = advanceIndependentOrdinary(game, independentOrdinaryClean);
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);
const action: Action = {type:'move', from, amount:1, elite:0, territory:target.place.id,
sector:target.sector, movementCard:card, ornithopter:'range3'};
game = act(game, source.owner, action);
}
return {game, owner:source.owner, holder:source.holder, card, given, selected, initialInventory};
}
for (const kind of ['box','distrans','truth','ornithopter'] as const) for (const claim of [false,true]) {
void test(`independent-card ${kind} Semuta ${claim ? 'claim' : 'pass'} preserves the completed effect`, () => {
const f = fixture(kind), pending = f.game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
assert.deepEqual(pending.batch.entries.map(entry => entry.card.id), [f.card]);
assert.equal(viewGame(f.game, f.holder).semutaReaction?.canCommit, true);
if (kind === 'distrans') {
assert.ok(player(f.game,'emperor').hand.some(card => card.id === f.given));
assert.ok(!pending.batch.entries.some(entry => entry.card.id === f.given));
}
if (kind === 'box') assert.ok(player(f.game,f.owner).hand.some(card => card.id === f.selected));
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,f.owner).moved});
const done = close(f.game, f.holder, claim);
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,f.owner).moved}, before);
assert.equal(player(done,f.holder).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.initialInventory);
});
}
void test('all AI profiles can answer the independent-card Distrans discard', () => {
const f = fixture('distrans');
for (const difficulty of DIFFICULTIES) {
const game = structuredClone(f.game); player(game,f.holder).bot = difficulty;
const answer = botActions(viewGame(game,f.holder)).find(action => action.type === 'semutaCommit' || action.type === 'semutaPass');
assert.ok(answer); assert.doesNotThrow(() => act(game,f.holder,answer));
}
});