106 lines
6.1 KiB
TypeScript
106 lines
6.1 KiB
TypeScript
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));
|
|
}
|
|
});
|