Admit the original ordered Ecaz/Moritani entry in bare Basic Richese games, retain both native opportunities during fresh discard recovery, and bind each suspended child to its actual arrival. Show the current discard response in the table banner. Cover real five-seat setup and placements, both owner orders, physical payouts/draws/gifts, persistent controls and minimal legal bot participation.
224 lines
13 KiB
TypeScript
224 lines
13 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 { TERRITORIES, gameDistance, splitLocation } from '../game/board';
|
|
import { createTruthMovementChoamFixture } from './fixture-truth-movement-choam';
|
|
import { createBasicSemutaAmbassadorFixture } from './fixture-basic-semuta-ambassador';
|
|
import { viewGame, normalizeAutomaticGame, type Game } from '../game/engine';
|
|
import { createTruthMovementFixture, actTruthMovement as act, advanceTruthMovement as advance,
|
|
truthMovementPlayer as player, truthMovementInventory as inventory, truthMovementClean as clean } from './fixture-truth-movement';
|
|
|
|
function ask(game: Game, asker: string, card: string, target: string, minimum = 6, territory = 'carthag') {
|
|
game = act(game, asker, { type: 'card', card });
|
|
for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' });
|
|
return act(game, asker, { type: 'truthAsk', question: { kind: 'movement', target, territory, minimum } });
|
|
}
|
|
void test('an original public Basic movement Yes binds a real six-force move before turn end', () => {
|
|
const f = createTruthMovementFixture(), source = structuredClone(f.game);
|
|
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
|
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['yes', 'no']);
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
assert.equal(game.movementPromises?.length, 1);
|
|
assert.throws(() => act(game, f.target, { type: 'endMovement' }));
|
|
const guide = viewGame(game, f.target).movementPromisePolicy;
|
|
assert.ok(guide?.length);
|
|
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
|
|
assert.equal(game.movementPromises![0].fulfilled, true);
|
|
game = advance(game, clean);
|
|
game = act(game, f.target, { type: 'endMovement' });
|
|
assert.ok(!game.movementRemaining?.includes(f.target));
|
|
assert.equal(player(game).forces['carthag:11'], 6);
|
|
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(f.game, source);
|
|
});
|
|
|
|
void test('a nonmatching first move keeps Yes live until an owned Hajr completion', () => {
|
|
const f = createTruthMovementFixture();
|
|
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 4, territory: 'imperial_basin', sector: 9 });
|
|
game = advance(game, clean);
|
|
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
|
const next = viewGame(game, f.target).movementPromisePolicy?.[0];
|
|
assert.deepEqual(next, { type: 'card', card: f.hajr });
|
|
game = act(game, f.target, next!); game = advance(game, clean);
|
|
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
|
|
assert.equal(game.movementPromises![0].fulfilled, true);
|
|
game = advance(game, clean); game = act(game, f.target, { type: 'endMovement' });
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('No survives a nonmatching first move and forbids a later matching Hajr movement', () => {
|
|
const f = createTruthMovementFixture();
|
|
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
|
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 4, territory: 'imperial_basin', sector: 9 });
|
|
game = advance(game, clean);
|
|
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
|
game = act(game, f.target, { type: 'card', card: f.hajr! }); game = advance(game, clean);
|
|
const before = structuredClone(game);
|
|
assert.throws(() => act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 }));
|
|
assert.deepEqual(game, before);
|
|
game = act(game, f.target, { type: 'endMovement' });
|
|
assert.equal(game.movementPromises![0].fulfilled, true);
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('a voluntary nonmatching last movement cannot evade Yes when no Hajr is held', () => {
|
|
const f = createTruthMovementFixture({ hajr: false });
|
|
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
const before = structuredClone(game);
|
|
assert.throws(() => act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 10, territory: 'imperial_basin', sector: 9 }));
|
|
assert.deepEqual(game, before);
|
|
});
|
|
|
|
void test('controlled second physical Truthtrance binds compatible Yes/No thresholds without aggregating groups', () => {
|
|
const f = createTruthMovementFixture();
|
|
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
// Controlled acquisition of the other original card; the first question,
|
|
// original army, wallets and all movement allowances remain genuine.
|
|
const second = game.deck.splice(game.deck.findIndex(card => card.effect === 'truthtrance'), 1)[0];
|
|
assert.ok(second); player(game, f.asker).hand.push(second);
|
|
game = ask(game, f.asker, second.id, f.target, 7);
|
|
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['yes', 'no']);
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
|
game = act(game, f.target, { type: 'move', from: 'arrakeen:10', amount: 6, territory: 'carthag', sector: 11 });
|
|
assert.equal(game.movementPromises![0].fulfilled, true);
|
|
assert.equal(game.movementPromises![1].fulfilled, undefined);
|
|
game = advance(game, clean); game = act(game, f.target, { type: 'endMovement' });
|
|
assert.ok(game.movementPromises!.every(promise => promise.fulfilled));
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('a controlled impossible opposing outcome releases Yes without replaying or altering the original card cost', () => {
|
|
const f = createTruthMovementFixture();
|
|
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
// Explicit outcome staging, not a claim of an unstaged Atomics/Terror source.
|
|
const harmed = structuredClone(game), owner = player(harmed);
|
|
owner.tanks += owner.forces['arrakeen:10']; delete owner.forces['arrakeen:10'];
|
|
game = normalizeAutomaticGame(harmed);
|
|
assert.equal(game.movementPromises![0].released, true);
|
|
assert.equal(game.discard.filter(card => card.id === f.truthtrance).length, 1);
|
|
game = act(game, f.target, { type: 'endMovement' });
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('all four existing profiles answer and fulfill the actual Basic movement commitment legally', () => {
|
|
const f = createTruthMovementFixture();
|
|
const question = ask(f.game, f.asker, f.truthtrance, f.target);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let game = question, actions = 0;
|
|
while (game.active === f.target && game.phase === 5 && actions++ < 20) {
|
|
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
|
|
if (game.response || game.phaseOpening || game.decision) { game = advance(game, clean); continue; }
|
|
const action = botActions(view)[0]; assert.ok(action); game = act(game, f.target, action);
|
|
}
|
|
assert.ok(actions < 20); assert.equal(game.movementPromises![0].fulfilled, true);
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
}
|
|
});
|
|
|
|
function passSemuta(game: Game): Game {
|
|
if (!game.pendingTreacheryDiscard?.reaction) return game;
|
|
const event = game.pendingTreacheryDiscard.batch.event;
|
|
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
|
|
return game;
|
|
}
|
|
|
|
function ornithopterSource() {
|
|
const f = createBasicSemutaAmbassadorFixture({ effect: 'choam', cacheCard: 'ornithopter', ecazTruth: true });
|
|
const game = act(f.game, f.beneficiary, { type: 'decision', event: f.event, cards: [] });
|
|
const truth = player(game, f.owner).hand.find(card => card.effect === 'truthtrance')!;
|
|
assert.ok(truth); return { ...f, game, asker: f.owner, target: f.holder, truth: truth.id };
|
|
}
|
|
for (const answer of ['yes', 'no'] as const) void test(`actual bought two-group Ornithopter retains ${answer} after its first nonmatching group`, () => {
|
|
const f = ornithopterSource();
|
|
let game = ask(f.game, f.asker, f.truth, f.target, 1, 'habbanya_ridge_flat');
|
|
game = act(game, f.target, { type: 'truthAnswer', answer });
|
|
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'truthtranceDiscard');
|
|
game = passSemuta(game);
|
|
game = act(game, f.target, { type: 'move', from: 'tueks_sietch:5', amount: 1,
|
|
territory: 'south_mesa', sector: 5, movementCard: f.cacheCard, ornithopter: 'twoGroups' });
|
|
game = advance(game, clean);
|
|
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
|
const event = game.ornithopter!.event;
|
|
const second = { type: 'move', from: 'habbanya_ridge_sietch:17', amount: 1,
|
|
territory: 'habbanya_ridge_flat', sector: 17, ornithopterEvent: event };
|
|
if (answer === 'yes') {
|
|
game = act(game, f.target, second);
|
|
assert.equal(game.movementPromises![0].fulfilled, true, 'Physical commitment precedes final card disposal');
|
|
// Completed native Basic flights now offer their physical card before
|
|
// the original arrival; the already fulfilled promise survives that pause.
|
|
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'ornithopterDiscard');
|
|
assert.equal(game.pendingTreacheryDiscard.reaction?.stage, 'offer');
|
|
game = passSemuta(game);
|
|
assert.equal(game.movementPromises![0].fulfilled, true);
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
game = act(game, f.target, { type: 'endMovement' });
|
|
} else {
|
|
const before = structuredClone(game); assert.throws(() => act(game, f.target, second)); assert.deepEqual(game, before);
|
|
game = act(game, f.target, { type: 'endMovement' });
|
|
assert.equal(game.movementPromises![0].fulfilled, undefined, 'No remains live until the original ending continuation');
|
|
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
game = passSemuta(game);
|
|
assert.equal(game.movementPromises![0].fulfilled, true);
|
|
}
|
|
assert.equal(game.discard.filter(card => card.id === f.cacheCard).length, 1);
|
|
assert.deepEqual(inventory(game), f.originalTreachery);
|
|
});
|
|
|
|
void test('a real bought range-three Ornithopter supplies the authoritative Yes completion', () => {
|
|
const f = ornithopterSource();
|
|
let game = ask(f.game, f.asker, f.truth, f.target, 1, 'the_greater_flat');
|
|
assert.deepEqual(viewGame(game, f.target).truthMovementAnswers, ['yes', 'no']);
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'truthtranceDiscard');
|
|
game = passSemuta(game);
|
|
const next = viewGame(game, f.target).movementPromisePolicy?.[0];
|
|
assert.equal(next?.movementCard, f.cacheCard); assert.equal(next?.ornithopter, 'range3');
|
|
game = act(game, f.target, next!);
|
|
assert.equal(game.movementPromises![0].fulfilled, true);
|
|
game = passSemuta(game); game = act(game, f.target, { type: 'endMovement' });
|
|
assert.deepEqual(inventory(game), f.originalTreachery);
|
|
});
|
|
|
|
void test('a controlled opposing loss releases only the impossible Yes and keeps an independent smaller promise', () => {
|
|
const f = createTruthMovementFixture();
|
|
let game = ask(f.game, f.asker, f.truthtrance, f.target);
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
const second = game.deck.splice(game.deck.findIndex(card => card.effect === 'truthtrance'), 1)[0];
|
|
assert.ok(second); player(game, f.asker).hand.push(second);
|
|
game = ask(game, f.asker, second.id, f.target, 1, 'imperial_basin');
|
|
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
const harmed = structuredClone(game), owner = player(harmed);
|
|
owner.forces['arrakeen:10'] -= 9; owner.tanks += 9;
|
|
game = normalizeAutomaticGame(harmed);
|
|
assert.equal(game.movementPromises![0].released, true);
|
|
assert.equal(game.movementPromises![1].released, undefined);
|
|
assert.ok(viewGame(game, f.target).movementPromisePolicy?.length);
|
|
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
|
|
assert.equal(game.movementPromises![1].fulfilled, true);
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|
|
|
|
void test('actual native Kulon and city access preserve a four-territory CHOAM movement promise', () => {
|
|
const f = createTruthMovementChoamFixture();
|
|
const destination = TERRITORIES.flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))).find(to =>
|
|
gameDistance(f.game, 'arrakeen:10', `${to.territory}:${to.sector}`, key => {
|
|
const location = splitLocation(key); return location.sector !== 0 && location.sector === f.game.storm;
|
|
}) === 4);
|
|
assert.ok(destination);
|
|
let game = ask(f.game, f.asker, f.truth, f.target, 1, destination.territory);
|
|
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
const next = viewGame(game, f.target).movementPromisePolicy?.[0]; assert.ok(next);
|
|
assert.equal(next.movementCard, undefined);
|
|
game = act(game, f.target, next);
|
|
assert.equal(game.movementPromises![0].fulfilled, true);
|
|
assert.deepEqual(inventory(game), f.originalCards);
|
|
});
|