166 lines
11 KiB
TypeScript
166 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { viewGame, type Game } from '../game/engine';
|
|
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { askTruthMovementAdvanced as ask } from './fixture-truth-movement-advanced';
|
|
import { createTruthMovementNoFieldFixture as fixture, actTruthMovementNoField as act,
|
|
advanceTruthMovementNoField as advance, truthMovementNoFieldClean as clean,
|
|
truthMovementNoFieldPlayer as player, truthMovementNoFieldInventory as inventory } from './fixture-truth-movement-no-field';
|
|
|
|
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced' : 'Basic'} No-Field reveal prepares five physical forces without fulfilling movement early`, () => {
|
|
const f = fixture({ advanced });
|
|
let g = ask(f.game, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
|
|
assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['yes', 'no']);
|
|
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
|
|
const old = structuredClone(player(g, f.target)), reveal = viewGame(g, f.target).movementPromisePolicy?.[0];
|
|
assert.equal(reveal?.type, 'revealNoField');
|
|
g = act(g, f.target, reveal!);
|
|
assert.equal(player(g, f.target).reserves, old.reserves - 5);
|
|
assert.equal(player(g, f.target).noField?.lastShipped, old.noField?.lastShipped);
|
|
assert.equal(player(g, f.target).moved, 0); assert.ok(!g.movementPromises![0].fulfilled);
|
|
const move = viewGame(g, f.target).movementPromisePolicy?.[0]; assert.equal(move?.type, 'move');
|
|
g = act(g, f.target, move!); assert.equal(g.movementPromises![0].fulfilled, true);
|
|
assert.equal(player(g, f.target).forces['arrakeen:10'], 5);
|
|
assert.deepEqual(inventory(g), f.originalCards);
|
|
});
|
|
|
|
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} one-spice native No-Field shipment and reveal supply the promised five-force ground move`, () => {
|
|
const f = fixture({ advanced });
|
|
let g = act(f.beforeShipment, f.target, { type: 'bribe', target: f.asker,
|
|
amount: player(f.beforeShipment, f.target).spice - 1 });
|
|
g = ask(g, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
|
|
assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes'));
|
|
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
|
|
const shipment = viewGame(g, f.target).movementPromisePolicy?.[0];
|
|
assert.equal(shipment?.type, 'ship'); assert.ok(shipment.noField);
|
|
g = advance(act(g, f.target, shipment), clean);
|
|
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
|
|
assert.equal(player(g, f.target).spice, 0); assert.equal(player(g, f.target).reserves, 20);
|
|
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
|
|
assert.ok(!g.movementPromises![0].fulfilled);
|
|
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
|
|
assert.equal(g.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(g), f.originalCards);
|
|
});
|
|
|
|
void test('native zero marker is presence rather than a promised physical force', () => {
|
|
const f = fixture({ value: 0 });
|
|
const g = ask(f.game, f.asker, f.truthtrance, f.target, 1, 'arrakeen');
|
|
assert.deepEqual(viewGame(g, f.target).truthMovementAnswers, ['no']);
|
|
});
|
|
|
|
void test('controlled scarce reserves cap owned reveal preparation without changing printed token value', () => {
|
|
const f = fixture(), source = structuredClone(f.game), own = player(source, f.target);
|
|
// Controlled original reserve-to-Tanks allocation, not a native casualty claim.
|
|
own.tanks += own.reserves - 2; own.reserves = 2;
|
|
const impossible = ask(source, f.asker, f.truthtrance, f.target, 3, 'arrakeen');
|
|
assert.deepEqual(viewGame(impossible, f.target).truthMovementAnswers, ['no']);
|
|
let g = ask(source, f.asker, f.truthtrance, f.target, 2, 'arrakeen');
|
|
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
|
|
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
|
|
assert.equal(player(g, f.target).reserves, 0); assert.equal(player(g, f.target).forces['imperial_basin:9'], 2);
|
|
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
|
|
assert.equal(g.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(g), f.originalCards);
|
|
});
|
|
|
|
const distance = (g: Game, from: string, to: string) => gameDistance(g, from, to,
|
|
point => splitLocation(point).sector !== 0 && splitLocation(point).sector === g.storm);
|
|
function controlledPhysical(g: Game, actor: string, amount: number) {
|
|
const own = player(g, actor); own.reserves -= amount; own.forces['imperial_basin:9'] = amount;
|
|
}
|
|
|
|
for (const advanced of [false, true]) void test(`controlled ${advanced ? 'Advanced' : 'Basic'} zero-marker city positioning prepares physical Hajr movement without counting the marker`, () => {
|
|
const f = fixture({ advanced, value: 0 }); let g = structuredClone(f.game);
|
|
// Original physical reserve counters and original Hajr are conserved controls.
|
|
controlledPhysical(g, f.target, 2);
|
|
const at = g.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0);
|
|
const hajr = g.deck.splice(at, 1)[0]; player(g, f.target).hand.push(hajr);
|
|
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
|
|
.find(to => to.sector !== g.storm && distance(g, 'imperial_basin:9', `${to.territory}:${to.sector}`) === 3);
|
|
assert.ok(to);
|
|
g = ask(g, f.asker, f.truthtrance, f.target, 2, to.territory);
|
|
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
|
|
const first = viewGame(g, f.target).movementPromisePolicy?.[0];
|
|
assert.equal(first?.type, 'move'); assert.equal(first.noField, f.token); assert.deepEqual(first.forces, {});
|
|
g = advance(act(g, f.target, first), clean);
|
|
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
|
|
const play = viewGame(g, f.target).movementPromisePolicy?.[0]; assert.equal(play?.card, hajr.id);
|
|
g = advance(act(g, f.target, play!), clean);
|
|
g = act(g, f.target, viewGame(g, f.target).movementPromisePolicy![0]);
|
|
assert.equal(g.movementPromises![0].fulfilled, true); assert.deepEqual(inventory(g), f.originalCards);
|
|
});
|
|
|
|
void test('a mixed physical-plus-marker move fulfills only its physical quantity', () => {
|
|
const f = fixture({ value: 0 }), source = structuredClone(f.game);
|
|
controlledPhysical(source, f.target, 3);
|
|
const impossible = ask(source, f.asker, f.truthtrance, f.target, 4, 'arrakeen');
|
|
assert.deepEqual(viewGame(impossible, f.target).truthMovementAnswers, ['no']);
|
|
let g = ask(source, f.asker, f.truthtrance, f.target, 3, 'arrakeen');
|
|
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
|
|
g = act(g, f.target, { type: 'move', forces: { 'imperial_basin:9': 3 },
|
|
noField: f.token, event: player(g, f.target).noFieldEvent, territory: 'arrakeen', sector: 10 });
|
|
assert.equal(g.movementPromises![0].fulfilled, true);
|
|
assert.equal(player(g, f.target).forces['arrakeen:10'], 3);
|
|
const marker = player(g, f.target).noField?.deployed?.location;
|
|
assert.ok(marker && 'territory' in marker);
|
|
assert.equal(marker.territory, 'arrakeen');
|
|
});
|
|
|
|
void test('controlled original Karama prevents a native No-Field preparation before payment and releases its now impossible promise', () => {
|
|
const f = fixture(), source = structuredClone(f.beforeShipment);
|
|
const at = source.deck.findIndex(card => card.effect === 'karama'); assert.ok(at >= 0);
|
|
const karama = source.deck.splice(at, 1)[0]; player(source, f.asker).hand.push(karama);
|
|
let g = act(source, f.target, { type: 'bribe', target: f.asker, amount: player(source, f.target).spice - 1 });
|
|
g = ask(g, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
|
|
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
|
|
const before = structuredClone(player(g, f.target)), shipment = viewGame(g, f.target).movementPromisePolicy?.[0];
|
|
assert.ok(shipment?.noField);
|
|
g = act(g, f.target, shipment);
|
|
assert.equal(g.response?.kind, 'richeseNoField');
|
|
assert.deepEqual(player(g, f.target).noField, before.noField);
|
|
assert.equal(player(g, f.target).spice, before.spice);
|
|
assert.ok(!g.movementPromises![0].fulfilled && !g.movementPromises![0].released);
|
|
g = act(g, f.asker, { type: 'card', card: karama.id, mode: 'cancel' });
|
|
assert.equal(player(g, f.target).shipped, false); assert.equal(player(g, f.target).spice, before.spice);
|
|
// The original Karama disposal must finish its native Semuta offer before
|
|
// the deferred prevention and impossibility release actually settle.
|
|
if (g.pendingTreacheryDiscard) assert.equal(g.movementPromises![0].released, undefined);
|
|
g = advance(g, clean);
|
|
assert.equal(player(g, f.target).shipped, false); assert.equal(player(g, f.target).spice, before.spice);
|
|
assert.equal(g.movementPromises![0].released, true); assert.deepEqual(inventory(g), f.originalCards);
|
|
});
|
|
|
|
void test('controlled earlier Basic boundary cannot voluntarily reveal away the only city-range completion', () => {
|
|
const f = fixture({ value: 0 }); let g = structuredClone(f.game);
|
|
controlledPhysical(g, f.target, 2);
|
|
g = advance(act(g, f.target, f.markerMove), clean);
|
|
g = act(g, f.target, { type: 'bribe', target: f.asker, amount: player(g, f.target).spice });
|
|
// Explicit earlier-phase boundary, not a claimed native second-round trace.
|
|
g.phase = 4; g.ready = [];
|
|
const to = TERRITORIES.filter(t => t.type === 'sand').flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector })))
|
|
.find(to => to.sector !== g.storm && distance(g, 'imperial_basin:9', `${to.territory}:${to.sector}`) === 3);
|
|
assert.ok(to);
|
|
g = ask(g, f.asker, f.truthtrance, f.target, 2, to.territory);
|
|
assert.ok(viewGame(g, f.target).truthMovementAnswers!.includes('yes'));
|
|
g = advance(act(g, f.target, { type: 'truthAnswer', answer: 'yes' }), clean);
|
|
const before = structuredClone(g), own = player(g, f.target);
|
|
assert.throws(() => act(g, f.target, { type: 'revealNoField', token: own.noField!.deployed!.tokenId,
|
|
event: own.noFieldEvent }));
|
|
assert.deepEqual(g, before);
|
|
});
|
|
|
|
void test('all existing profiles use the actual owned reveal then physical move legally', () => {
|
|
const f = fixture({ advanced: true });
|
|
const question = ask(f.game, f.asker, f.truthtrance, f.target, 5, 'arrakeen');
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let g = question, count = 0;
|
|
while (g.phase === 5 && g.active === f.target && count++ < 20) {
|
|
if (!clean(g) && !g.truthtrance) { g = advance(g, clean); continue; }
|
|
const view = viewGame(g, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
|
|
const action = botActions(view)[0]; assert.ok(action); g = act(g, f.target, action);
|
|
}
|
|
assert.ok(count < 20); assert.equal(g.movementPromises![0].fulfilled, true);
|
|
}
|
|
});
|