Files
Dune/tests/richese-allied-no-field.test.ts
T
Quintine 9c6b2d884b
Verify and publish checkpoint container / container (push) Canceled after 0s
Support Basic allied No-Field transit
2026-10-28 22:53:10 +11:00

365 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { runBots } from '../game/bots';
import { createTerrorState, placeTerror } from '../game/moritani-terror';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createGame,
newPlayer,
applyAction,
viewGame,
type Game,
type Action,
RuleError,
} from '../game/engine';
import {
createRicheseNoField,
deployRicheseNoField,
} from '../game/richese-no-field';
import { baseDeck } from '../game/cards';
function fixture(advanced = false) {
const g = createGame(
'ALLYNOFIELD',
newPlayer('r', 'Richese', 'richese'),
advanced,
['choam'],
);
g.players.push(
newPlayer('a', 'Atreides', 'atreides'),
newPlayer('e', 'Emperor', 'emperor'),
);
g.status = 'playing';
g.phase = 5;
g.turn = 2;
g.active = 'a';
g.order = ['r', 'a', 'e'];
g.movementRemaining = ['a', 'r', 'e'];
g.storm = 18;
g.deck = baseDeck();
for (const p of g.players) {
p.forces = {};
p.reserves = 20;
p.tanks = 0;
p.spice = 10;
p.hand = [];
p.traitorChoices = [];
p.traitors = [];
}
g.players[0].ally = 'a';
g.players[1].ally = 'r';
g.players[0].noField = createRicheseNoField(['zero', 'three', 'five']);
g.players[0].noFieldEvent = 'event-a';
return g;
}
const send = (g: Game, id: string, a: Action) => applyAction(g, id, a);
const offer = (
g: Game,
token = 'five',
payer = 'r',
territory = 'arrakeen',
sector = 10,
) =>
send(g, 'r', {
type: 'offerRicheseNoField',
token,
event: g.players[0].noFieldEvent,
payer,
territory,
sector,
});
const accept = (g: Game, elite = 0) =>
send(g, 'a', {
type: 'decision',
event: g.richeseAllyOffer!.event,
accept: true,
elite,
});
function hold(g: Game, id: string, name: string) {
const i = g.deck.findIndex((c) => c.name === name);
assert.ok(i >= 0);
const c = g.deck.splice(i, 1)[0];
g.players.find((p) => p.id === id)!.hand.push(c);
return c.id;
}
function conserve(g: Game) {
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
}
void test('allied tokens0/3/5 materialize immediately for either full payer at exact one/two spice price', () => {
for (const token of ['zero', 'three', 'five'])
for (const payer of ['r', 'a'])
for (const to of ['arrakeen', 'imperial_basin']) {
let g = fixture();
g = offer(g, token, payer, to);
assert.equal(g.decision?.kind, 'richeseAllyShipment');
assert.equal(g.players[0].spice, 10);
assert.equal(g.players[0].noField!.deployed, null);
g = accept(g);
const n = token === 'zero' ? 0 : token === 'three' ? 3 : 5,
cost = to === 'arrakeen' ? 1 : 2;
assert.equal(g.players[1].forces[`${to}:10`] ?? 0, n);
assert.equal(g.players[1].reserves, 20 - n);
assert.equal(g.players[0].reserves, 20);
assert.equal(g.players.find((p) => p.id === payer)!.spice, 10 - cost);
assert.equal(g.players[1].shipped, true);
assert.equal(g.players[0].shipped, false);
assert.equal(g.players[0].noField!.deployed, null);
assert.equal(g.players[0].noField!.lastShipped, token);
conserve(g);
}
});
void test('private offers are identical to nonallies across denominations and preserve exact recipient consent', () => {
const views = [];
for (const token of ['zero', 'three', 'five']) {
const g = offer(fixture(), token);
views.push(viewGame(g, 'e'));
assert.equal(viewGame(g, 'e').richeseNoField!.allyOffer, null);
assert.equal(viewGame(g, 'a').richeseNoField!.private, null);
assert.ok(viewGame(g, 'a').richeseNoField!.allyOffer);
assert.equal(JSON.stringify(viewGame(g, 'a')).includes('event-a'), true);
assert.throws(
() =>
send(g, 'e', {
type: 'decision',
accept: true,
event: g.richeseAllyOffer!.event,
}),
RuleError,
);
const declined = send(g, 'a', {
type: 'decision',
decline: true,
event: g.richeseAllyOffer!.event,
});
assert.equal(declined.players[1].shipped, false);
assert.equal(declined.players[0].noField!.lastShipped, null);
assert.equal(declined.richeseAllyOffer, null);
}
// Event and action logs use the same public text and positions; only private offer data differs.
for (const v of views) {
v.log = v.log.map((l) => ({ ...l, time: '' }));
}
assert.deepEqual(views[0], views[1]);
assert.deepEqual(views[1], views[2]);
});
void test('existing own marker reveals only when shipment commits and shared last-use history cannot repeat', () => {
let g = fixture();
g.players[0].noField = deployRicheseNoField(g.players[0].noField!, {
tokenId: 'three',
controller: 'r',
location: { territory: 'imperial_basin', sector: 10 },
});
assert.throws(() => offer(g, 'three'), /twice in a row/);
g = offer(g);
assert.ok(g.players[0].noField!.deployed);
g = accept(g);
assert.equal(g.players[0].forces['imperial_basin:10'], 3);
assert.equal(g.players[0].reserves, 17);
assert.equal(g.players[0].noField!.lastShipped, 'five');
assert.equal(g.players[1].forces['arrakeen:10'], 5);
conserve(g);
g.active = 'r';
assert.throws(
() =>
send(g, 'r', {
type: 'ship',
noField: 'five',
event: g.players[0].noFieldEvent,
territory: 'sietch_tabr',
sector: 14,
}),
/twice in a row/,
);
});
void test('recipient selects scarce-reserve elite mix with physical token conservation', () => {
let g = fixture(true);
// Swap the native seats, retaining recipient/observer IDs and the alliance.
// Two Emperor factions would not be a legal shipment roster.
const recipient = g.players[1];
g.players[1] = { ...g.players[2], id: recipient.id, ally: recipient.ally };
g.players[2] = { ...recipient, id: 'e', ally: null };
g.players[1].reserves = 3;
g.players[1].tanks = 17;
g.players[1].elites = { reserves: 2, tanks: 3, forces: {}, revived: 0 };
g = offer(g);
const q = viewGame(g, 'a').richeseNoField!.allyOffer!;
assert.equal(q.amount, 3);
assert.equal(q.eliteMin, 2);
assert.equal(q.eliteMax, 2);
assert.throws(() => accept(g, 1), RuleError);
g = accept(JSON.parse(JSON.stringify(g)), 2);
assert.equal(g.players[1].reserves, 0);
assert.equal(g.players[1].elites!.reserves, 0);
assert.equal(g.players[1].elites!.forces['arrakeen:10'], 2);
conserve(g);
});
void test('Karama cancellation preserves prior hidden marker and permits ordinary allied shipment', () => {
let g = fixture();
const k = hold(g, 'e', 'Karama');
g.players[0].noField = deployRicheseNoField(g.players[0].noField!, {
tokenId: 'three',
controller: 'r',
location: { territory: 'imperial_basin', sector: 10 },
});
g = accept(offer(g));
assert.equal(g.response?.kind, 'richeseNoField');
assert.equal(g.players[0].noField!.deployed!.tokenId, 'three');
g = send(JSON.parse(JSON.stringify(g)), 'e', {
type: 'card',
mode: 'cancel',
card: k,
});
assert.equal(g.players[0].noField!.deployed!.tokenId, 'three');
assert.equal(g.players[0].spice, 10);
assert.equal(g.players[1].shipped, false);
assert.throws(() => offer(g), /prevented No-Field/);
g = send(g, 'a', {
type: 'ship',
amount: 2,
territory: 'arrakeen',
sector: 10,
});
assert.equal(g.players[1].forces['arrakeen:10'], 2);
conserve(g);
});
void test('Guild stopping allied shipment exposes only one marker and retains payer/token custody', () => {
let g = fixture(true);
g.players[2].faction = 'guild';
const k = hold(g, 'e', 'Karama');
g = accept(offer(g));
g = send(g, 'e', { type: 'passResponse' });
assert.equal(g.decision?.kind, 'guildShipment');
assert.equal(g.decision?.kind === 'guildShipment' ? g.decision.amount : 0, 1);
assert.equal(g.players[0].noField!.lastShipped, null);
g = send(g, 'e', { type: 'card', mode: 'special', card: k });
assert.equal(g.players[1].shipped, true);
assert.equal(g.players[0].spice, 10);
assert.equal(g.players[0].noField!.lastShipped, null);
conserve(g);
});
void test('Guild receives actual payer spice and Heighliner triggers for the ally recipient', () => {
let g = fixture();
g.players[2].faction = 'guild';
g.techTokens = {
axlotl: { owner: 'e', spice: 0 },
production: { owner: 'r', spice: 0 },
heighliners: { owner: 'r', spice: 0 },
};
g = accept(offer(g, 'five', 'a', 'imperial_basin'));
assert.equal(g.players[2].spice, 12);
assert.equal(g.players[1].spice, 8);
assert.equal(g.players[0].spice, 10);
assert.equal(g.techTokens!.heighliners.spice, 2);
conserve(g);
});
void test('stale acceptance and changed pending reserve allocation cannot cause payment or token replay', () => {
let g = offer(fixture());
assert.throws(
() => send(g, 'a', { type: 'decision', accept: true, event: 'old-event' }),
/no longer current/,
);
const k = hold(g, 'e', 'Karama');
g = accept(g);
g.players[1].reserves = 2;
g.players[1].tanks = 18;
g = send(g, 'e', { type: 'passResponse' });
assert.equal(g.response, null);
assert.equal(g.players[1].shipped, false);
assert.equal(g.players[0].noField!.lastShipped, null);
assert.equal(g.players[0].spice, 10);
assert.ok(k);
conserve(g);
});
void test('invalid payer, Advanced allied occupancy, wrong turn and on-planet Fremen reserves reject atomically', () => {
const g = fixture(),
before = structuredClone(g);
assert.throws(() => offer(g, 'five', 'e'), /ally payer/);
assert.deepEqual(g, before);
g.players[1].faction = 'fremen';
assert.throws(() => offer(g), /on the planet/);
g.players[1].faction = 'atreides';
g.active = 'r';
assert.throws(() => offer(g), /ally’s unused/);
g.active = 'a';
g.players[0].forces = { 'arrakeen:10': 1 };
g.players[0].reserves = 19;
g.advanced = true;
assert.throws(() => offer(g), /occupied by your ally/);
});
void test('allied two-spice shipments may split payment equally with private recipient consent', () => {
let g = fixture();
g.players[0].spice = 1;
g.players[1].spice = 1;
g = offer(g, 'five', 'both', 'imperial_basin');
assert.equal(viewGame(g, 'a').richeseNoField!.allyOffer!.ownerPayment, 1);
assert.equal(viewGame(g, 'a').richeseNoField!.allyOffer!.recipientPayment, 1);
g = accept(g);
assert.equal(g.players[0].spice, 0);
assert.equal(g.players[1].spice, 0);
assert.equal(g.players[1].forces['imperial_basin:10'], 5);
conserve(g);
assert.throws(() => offer(fixture(), 'five', 'both'), /two-spice/);
const poor = fixture();
poor.players[1].spice = 0;
const offered = offer(poor, 'five', 'both', 'imperial_basin');
assert.equal(viewGame(offered, 'r').richeseNoField!.allyOffer!.blocked, null);
assert.ok(viewGame(offered, 'a').richeseNoField!.allyOffer!.blocked);
assert.throws(() => accept(offered), /recipient needs/);
});
void test('unsupported simultaneous arrivals fail before consent is committed and leave decline available', () => {
let g = fixture();
g.players[2].faction = 'moritani';
const bg = newPlayer('b', 'Bene Gesserit', 'beneGesserit');
g.players.push(bg);
g.order.push('b');
g.moritaniTerror = createTerrorState(() => 0);
g.moritaniTerror = placeTerror(
g.moritaniTerror,
g.moritaniTerror.tokens[0].id,
'arrakeen',
1,
);
hold(g, 'e', 'Karama');
g = offer(g);
// The consent commits its real shipment; the committed Terror entry waits
// for the arrival's own reaction instead of failing the whole consent.
const committed = accept(g);
// The consent commits its shipment and opens the No-Field response instead of
// rejecting the whole consent because a Terror token shares the destination.
assert.equal(committed.response?.kind, 'richeseNoField');
assert.deepEqual(committed.players[1].forces, {}, 'No force moves before the response settles.');
conserve(committed);
// Once the response settles, the committed arrival still offers its Terror entry.
let arrived = committed;
for (let i = 0; arrived.response && i < 8; i++) {
const passer = arrived.players.find(p => {
const controls = viewGame(arrived, p.id).responseControls;
return controls && controls.cancelCards.length && !controls.hasPassed;
});
assert.ok(passer, 'The No-Field response keeps a legal passer.');
arrived = send(arrived, passer!.id, { type: 'passResponse' });
}
assert.ok((arrived.pendingArrivalReaction?.length ?? 0) > 0 ||
arrived.pendingTerrorEntry !== null || arrived.decision?.kind === 'moritaniTerror',
'The committed Terror entry is offered or waits behind the arrival reaction.');
conserve(arrived);
const automated = structuredClone(g);
automated.players[1].bot = 'Medium';
const declined = runBots(automated, 1);
assert.equal(declined.richeseAllyOffer, null);
assert.equal(declined.players[1].shipped, false);
g = send(g, 'a', {
type: 'decision',
event: g.richeseAllyOffer!.event,
decline: true,
});
assert.equal(g.pendingShipment, undefined);
assert.equal(g.response, null);
assert.equal(g.players[1].shipped, false);
assert.equal(g.players[0].spice, 10);
});