Implement Richese Nexus Secret Ally shipment and recovery
This commit is contained in:
1 parent
ae66668e2e
commit
6ba1050a92
21 files changed
+2286
-103
No files matched your search
@@ -0,0 +1,475 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { botActions } from '../game/bots';
|
||||
import { orderNexusSpice } from './fixture-nexus-cards';
|
||||
import {
|
||||
applyAction,
|
||||
normalizeAutomaticGame,
|
||||
viewGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import {
|
||||
nexusRicheseFixture,
|
||||
nexusRicheseRequest,
|
||||
nexusRicheseAllow,
|
||||
nexusRicheseReload,
|
||||
nexusRicheseInventory,
|
||||
holdNexusRicheseCard,
|
||||
} from './fixture-nexus-richese';
|
||||
|
||||
function reject(g: Game, id: string, action: Action) {
|
||||
const before = nexusRicheseReload(g);
|
||||
assert.throws(() => applyAction(g, id, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
function stable(g: Game) {
|
||||
nexusRicheseInventory(g);
|
||||
for (const p of g.players)
|
||||
assert.deepEqual(viewGame(nexusRicheseReload(g), p.id), viewGame(g, p.id));
|
||||
assert.deepEqual(
|
||||
normalizeAutomaticGame(nexusRicheseReload(g)),
|
||||
nexusRicheseReload(g),
|
||||
);
|
||||
}
|
||||
|
||||
void test('Richese Secret Ally ships five actual counters at one-counter cost for ordinary, Guild and Fremen factions', () => {
|
||||
for (const ownerFaction of ['atreides', 'guild', 'fremen'] as const)
|
||||
for (const amount of [1, 5]) {
|
||||
const f = nexusRicheseFixture({ ownerFaction, spice: 2 });
|
||||
const territory =
|
||||
ownerFaction === 'fremen' ? 'the_great_flat' : 'red_chasm';
|
||||
const sector = ownerFaction === 'fremen' ? 15 : 7;
|
||||
const action = nexusRicheseRequest(f, amount, territory, sector);
|
||||
const g = nexusRicheseAllow(applyAction(f.g, f.owner, action));
|
||||
const p = g.players[0];
|
||||
assert.equal(
|
||||
p.spice,
|
||||
ownerFaction === 'fremen' ? 2 : ownerFaction === 'guild' ? 1 : 0,
|
||||
);
|
||||
assert.equal(p.reserves, 20 - amount);
|
||||
assert.equal(p.forces[`${territory}:${sector}`], amount);
|
||||
assert.equal(p.shipped, true);
|
||||
assert.equal(p.moved, 0);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((c) => c === 'richese').length,
|
||||
1,
|
||||
);
|
||||
reject(g, f.owner, action);
|
||||
stable(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Richese declaration rejects excess forces, stale events, foreign seats, wrong ranges and insufficient payment immutably', () => {
|
||||
const f = nexusRicheseFixture({ spice: 1 });
|
||||
const action = nexusRicheseRequest(f);
|
||||
for (const bad of [
|
||||
{ ...action, amount: 0 },
|
||||
{ ...action, amount: 6 },
|
||||
{ ...action, nexus: 'old' },
|
||||
{ ...action, territory: 'red_chasm', sector: 7 },
|
||||
{ ...action, territory: 'arrakeen', sector: 9 },
|
||||
{ ...action, homeworldSources: { unknown: { normal: 5, elite: 0 } } },
|
||||
])
|
||||
reject(f.g, f.owner, bad);
|
||||
reject(f.g, f.target, action);
|
||||
for (const type of ['guildShip', 'homeworldShip', 'guildHomeworldShip'])
|
||||
reject(f.g, f.owner, { ...action, type });
|
||||
const fremen = nexusRicheseFixture({ ownerFaction: 'fremen' });
|
||||
reject(
|
||||
fremen.g,
|
||||
fremen.owner,
|
||||
nexusRicheseRequest(fremen, 5, 'arrakeen', 10),
|
||||
);
|
||||
const storm = nexusRicheseReload(f.g);
|
||||
storm.storm = 10;
|
||||
reject(storm, f.owner, action);
|
||||
});
|
||||
|
||||
void test('five typed Emperor forces withdraw from Kaitain and Salusa once while their single-force price stays one', () => {
|
||||
const f = nexusRicheseFixture({
|
||||
ownerFaction: 'emperor',
|
||||
advanced: true,
|
||||
homeworlds: true,
|
||||
spice: 1,
|
||||
});
|
||||
const action = {
|
||||
...nexusRicheseRequest(f),
|
||||
elite: 2,
|
||||
homeworldSources: {
|
||||
'homeworld:emperor': { normal: 3, elite: 0 },
|
||||
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
|
||||
},
|
||||
};
|
||||
const g = applyAction(f.g, f.owner, action);
|
||||
assert.equal(g.players[0].spice, 0);
|
||||
assert.equal(g.players[0].forces['arrakeen:10'], 5);
|
||||
assert.equal(g.players[0].elites!.forces['arrakeen:10'], 2);
|
||||
assert.equal(g.players[0].reserves, 15);
|
||||
assert.equal(g.homeworlds!.custody!.salusa!.elite, 3);
|
||||
stable(g);
|
||||
});
|
||||
|
||||
void test('Guild interception preserves the complete five-force declaration and spends Nexus on either allowance or stop', () => {
|
||||
for (const stop of [false, true]) {
|
||||
const f = nexusRicheseFixture({
|
||||
advanced: true,
|
||||
guild: true,
|
||||
karama: true,
|
||||
spice: 1,
|
||||
});
|
||||
const action = nexusRicheseRequest(f);
|
||||
let g = applyAction(f.g, f.owner, action);
|
||||
assert.equal(g.decision?.kind, 'guildShipment');
|
||||
assert.equal(g.pendingShipment!.amount, 5);
|
||||
assert.equal(g.pendingShipment!.cost, 1);
|
||||
assert.equal(g.players[0].reserves, 20);
|
||||
assert.equal(g.players[0].spice, 1);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
|
||||
stable(g);
|
||||
g = applyAction(
|
||||
nexusRicheseReload(g),
|
||||
f.target,
|
||||
stop
|
||||
? { type: 'card', mode: 'special', card: f.karama }
|
||||
: { type: 'decision', allow: true },
|
||||
);
|
||||
g = nexusRicheseAllow(g);
|
||||
assert.equal(g.players[0].reserves, stop ? 20 : 15);
|
||||
assert.equal(g.players[0].spice, stop ? 1 : 0);
|
||||
assert.equal(g.players[1].spice, stop ? 20 : 21);
|
||||
assert.equal(
|
||||
g.nexusCards!.cards!.discard.filter((c) => c === 'richese').length,
|
||||
1,
|
||||
);
|
||||
reject(g, f.owner, action);
|
||||
stable(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('independent Karama discounts the one-force price, routes payment to bank and preserves actual force count', () => {
|
||||
const f = nexusRicheseFixture({ guild: true, spice: 1 });
|
||||
const card = holdNexusRicheseCard(f.g, f.owner, 'karama');
|
||||
let g = applyAction(f.g, f.owner, {
|
||||
type: 'card',
|
||||
card: card.id,
|
||||
mode: 'shipment',
|
||||
target: f.owner,
|
||||
});
|
||||
g = applyAction(g, f.owner, nexusRicheseRequest(f, 5, 'red_chasm', 7, g));
|
||||
assert.equal(g.players[0].spice, 0);
|
||||
assert.equal(g.players[0].forces['red_chasm:7'], 5);
|
||||
assert.equal(g.players[1].spice, 20);
|
||||
assert.equal(g.response, null);
|
||||
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
|
||||
stable(g);
|
||||
});
|
||||
|
||||
void test('the real shipment opens BG accompaniment with all five arrivals and leaves normal movement available afterward', () => {
|
||||
const f = nexusRicheseFixture({
|
||||
advanced: true,
|
||||
opponentFaction: 'beneGesserit',
|
||||
});
|
||||
let g = applyAction(f.g, f.owner, nexusRicheseRequest(f));
|
||||
assert.equal(g.decision?.kind, 'advisor');
|
||||
assert.equal(g.players[0].forces['arrakeen:10'], 5);
|
||||
g = applyAction(nexusRicheseReload(g), f.target, {
|
||||
type: 'decision',
|
||||
accept: true,
|
||||
accompany: true,
|
||||
});
|
||||
g = nexusRicheseAllow(g);
|
||||
assert.equal(g.players[1].forces['arrakeen:10'], 1);
|
||||
assert.equal(g.players[1].reserves, 19);
|
||||
assert.equal(g.players[0].moved, 0);
|
||||
assert.equal(g.active, f.owner);
|
||||
stable(g);
|
||||
});
|
||||
|
||||
void test('Richese held-card availability and original shipment evidence remain private across other seats and reload', () => {
|
||||
const f = nexusRicheseFixture({ advanced: true, guild: true });
|
||||
for (const id of [f.target, f.observer])
|
||||
assert.equal(viewGame(f.g, id).nexusRichese, null);
|
||||
let g = applyAction(f.g, f.owner, nexusRicheseRequest(f));
|
||||
for (const id of [f.target, f.observer]) {
|
||||
const view = viewGame(g, id);
|
||||
assert.equal(Object.hasOwn(view, 'nexusRicheseHistory'), false);
|
||||
assert.equal(view.nexusRichese, null);
|
||||
assert.equal(view.decision?.kind, 'guildShipment');
|
||||
if (view.decision?.kind === 'guildShipment')
|
||||
assert.equal(view.decision.amount, 5);
|
||||
}
|
||||
g = applyAction(nexusRicheseReload(g), f.target, {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
g = nexusRicheseAllow(g);
|
||||
stable(g);
|
||||
});
|
||||
|
||||
void test('Richese pending source rejects event/history deletion, original decision edits and valid-looking typed reallocations', () => {
|
||||
const f = nexusRicheseFixture({
|
||||
ownerFaction: 'emperor',
|
||||
advanced: true,
|
||||
homeworlds: true,
|
||||
guild: true,
|
||||
});
|
||||
const pending = applyAction(f.g, f.owner, {
|
||||
...nexusRicheseRequest(f),
|
||||
elite: 2,
|
||||
homeworldSources: {
|
||||
'homeworld:emperor': { normal: 3, elite: 0 },
|
||||
'homeworld:emperor:salusa': { normal: 0, elite: 2 },
|
||||
},
|
||||
});
|
||||
for (const edit of [
|
||||
(g: Game) => {
|
||||
delete g.pendingShipment!.nexusEvent;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.nexusRicheseHistory;
|
||||
},
|
||||
(g: Game) => {
|
||||
delete g.nexusRicheseLast;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusRicheseHistory![0].signature = 'changed';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusRicheseHistory![0].frame = '{}';
|
||||
},
|
||||
(g: Game) => {
|
||||
g.pendingShipment!.elite = 1;
|
||||
g.pendingShipment!.homeworldSources!['homeworld:emperor'].normal = 4;
|
||||
g.pendingShipment!.homeworldSources!['homeworld:emperor:salusa'].elite =
|
||||
1;
|
||||
},
|
||||
(g: Game) => {
|
||||
if (g.decision?.kind === 'guildShipment') g.decision.amount = 4;
|
||||
},
|
||||
(g: Game) => {
|
||||
if (g.decision?.kind === 'guildShipment') g.decision.shipper = f.observer;
|
||||
},
|
||||
(g: Game) => {
|
||||
g.nexusRicheseLast!.stage = 'shipped';
|
||||
},
|
||||
]) {
|
||||
const bad = nexusRicheseReload(pending);
|
||||
edit(bad);
|
||||
const before = nexusRicheseReload(bad);
|
||||
for (const p of bad.players) assert.throws(() => viewGame(bad, p.id));
|
||||
assert.throws(() => normalizeAutomaticGame(bad));
|
||||
assert.throws(() =>
|
||||
applyAction(bad, f.target, { type: 'decision', allow: true }),
|
||||
);
|
||||
assert.deepEqual(bad, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a genuine Truthtrance interruption preserves Richese pending shipment, paid quote and Guild interception', () => {
|
||||
const f = nexusRicheseFixture({ advanced: true, guild: true, spice: 1 });
|
||||
const truth = holdNexusRicheseCard(f.g, f.observer, 'truthtrance');
|
||||
let g = applyAction(f.g, f.owner, nexusRicheseRequest(f));
|
||||
const shipment = structuredClone(g.pendingShipment),
|
||||
decision = structuredClone(g.decision),
|
||||
history = structuredClone(g.nexusRicheseHistory);
|
||||
g = applyAction(g, f.observer, { type: 'card', card: truth.id });
|
||||
while (g.truthtrance?.stage === 'priority')
|
||||
g = applyAction(
|
||||
g,
|
||||
g.players.find((p) => !g.truthtrance!.passed.includes(p.id))!.id,
|
||||
{ type: 'truthPass' },
|
||||
);
|
||||
g = applyAction(g, f.observer, {
|
||||
type: 'truthAsk',
|
||||
question: {
|
||||
kind: 'fact',
|
||||
target: f.owner,
|
||||
fact: { kind: 'hand', name: 'Shield' },
|
||||
},
|
||||
});
|
||||
g = applyAction(nexusRicheseReload(g), f.owner, {
|
||||
type: 'truthAnswer',
|
||||
answer: 'no',
|
||||
});
|
||||
assert.deepEqual(g.pendingShipment, shipment);
|
||||
assert.deepEqual(g.decision, decision);
|
||||
assert.deepEqual(g.nexusRicheseHistory, history);
|
||||
g = applyAction(g, f.target, { type: 'decision', allow: true });
|
||||
g = nexusRicheseAllow(g);
|
||||
assert.equal(g.players[0].reserves, 15);
|
||||
assert.equal(g.players[0].spice, 0);
|
||||
assert.equal(g.discard.filter((c) => c.id === truth.id).length, 1);
|
||||
stable(g);
|
||||
});
|
||||
|
||||
void test('completed Richese history survives ordinary movement, genuine next-turn phases and a later full-price shipment', () => {
|
||||
const f = nexusRicheseFixture();
|
||||
let g = applyAction(f.g, f.owner, nexusRicheseRequest(f));
|
||||
const history = structuredClone(g.nexusRicheseHistory);
|
||||
g = applyAction(g, f.owner, {
|
||||
type: 'move',
|
||||
from: 'arrakeen:10',
|
||||
territory: 'hagga_basin',
|
||||
sector: 12,
|
||||
amount: 5,
|
||||
});
|
||||
assert.equal(g.players[0].forces['hagga_basin:12'], 5);
|
||||
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
let ordered = false;
|
||||
for (
|
||||
let step = 0;
|
||||
!(
|
||||
g.turn === 2 &&
|
||||
g.phase === 5 &&
|
||||
!g.response &&
|
||||
!g.decision &&
|
||||
!g.phaseOpening
|
||||
) && step < 180;
|
||||
step++
|
||||
) {
|
||||
if (g.turn === 2 && g.phase === 1 && !ordered) {
|
||||
orderNexusSpice(g, ['land', 'land']);
|
||||
ordered = true;
|
||||
}
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const v = viewGame(g, p.id);
|
||||
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(v)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
next,
|
||||
`Next turn stalled at ${g.turn}/${g.phase}/${g.decision?.kind}`,
|
||||
);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.turn, 2);
|
||||
assert.equal(g.phase, 5);
|
||||
while (g.active !== f.owner)
|
||||
g = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
assert.deepEqual(g.nexusRicheseHistory, history);
|
||||
assert.equal(g.nexusCards!.cards!.hands[f.owner], null);
|
||||
const before = g.players[0].spice,
|
||||
reserves = g.players[0].reserves;
|
||||
g = applyAction(g, f.owner, {
|
||||
type: 'ship',
|
||||
territory: 'red_chasm',
|
||||
sector: 7,
|
||||
amount: 1,
|
||||
});
|
||||
assert.equal(g.players[0].spice, before - 2);
|
||||
assert.equal(g.players[0].reserves, reserves - 1);
|
||||
assert.deepEqual(g.nexusRicheseHistory, history);
|
||||
stable(g);
|
||||
});
|
||||
|
||||
void test('native Ixians may ship five typed counters into their real mobile stronghold but another faction cannot', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = nexusRicheseFixture({
|
||||
ownerFaction: 'ixians',
|
||||
advanced,
|
||||
spice: 1,
|
||||
});
|
||||
assert.ok(f.g.mobileStronghold?.location);
|
||||
const g = applyAction(f.g, f.owner, {
|
||||
...nexusRicheseRequest(f, 5, 'hidden_mobile_stronghold', 0),
|
||||
elite: 2,
|
||||
});
|
||||
assert.equal(g.players[0].forces['hidden_mobile_stronghold:0'], 5);
|
||||
assert.equal(g.players[0].elites!.forces['hidden_mobile_stronghold:0'], 2);
|
||||
assert.equal(g.players[0].spice, 0);
|
||||
stable(g);
|
||||
}
|
||||
const other = nexusRicheseFixture({ opponentFaction: 'ixians' });
|
||||
assert.ok(other.g.mobileStronghold?.location);
|
||||
reject(
|
||||
other.g,
|
||||
other.owner,
|
||||
nexusRicheseRequest(other, 5, 'hidden_mobile_stronghold', 0),
|
||||
);
|
||||
});
|
||||
|
||||
void test('a genuinely placed Terror token sees five arriving Richese-discounted counters under low Grumman', () => {
|
||||
const f = nexusRicheseFixture({
|
||||
opponentFaction: 'moritani',
|
||||
homeworlds: true,
|
||||
});
|
||||
let g = f.g;
|
||||
g.players[1].reserves = 7;
|
||||
g.players[1].forces = { 'polar_sink:0': 13 };
|
||||
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
assert.equal(g.phase, 7);
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
assert.equal(g.decision?.kind, 'moritaniPlacement');
|
||||
const token = g.moritaniTerror!.tokens.find((t) => t.kind === 'robbery')!;
|
||||
g = nexusRicheseAllow(
|
||||
applyAction(g, f.target, {
|
||||
type: 'decision',
|
||||
token: token.id,
|
||||
territory: 'arrakeen',
|
||||
}),
|
||||
);
|
||||
let ordered = false;
|
||||
for (
|
||||
let step = 0;
|
||||
!(
|
||||
g.turn === 2 &&
|
||||
g.phase === 5 &&
|
||||
!g.response &&
|
||||
!g.decision &&
|
||||
!g.phaseOpening
|
||||
) && step < 180;
|
||||
step++
|
||||
) {
|
||||
if (g.turn === 2 && g.phase === 1 && !ordered) {
|
||||
orderNexusSpice(g, ['land', 'land']);
|
||||
ordered = true;
|
||||
}
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const v = viewGame(g, p.id);
|
||||
v.players.find((s) => s.id === p.id)!.bot = 'Easy';
|
||||
const action = botActions(v)[0];
|
||||
if (action) {
|
||||
next = applyAction(g, p.id, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert.ok(
|
||||
next,
|
||||
`Terror progression stalled at ${g.turn}/${g.phase}/${g.decision?.kind}`,
|
||||
);
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.turn, 2);
|
||||
assert.equal(g.phase, 5);
|
||||
while (g.active !== f.owner)
|
||||
g = applyAction(g, g.active!, { type: 'endMovement' });
|
||||
assert.equal(g.players[1].reserves, 7);
|
||||
const spice = g.players[0].spice;
|
||||
g = applyAction(g, f.owner, nexusRicheseRequest(f, 5, 'arrakeen', 10, g));
|
||||
assert.equal(g.players[0].spice, spice - 1);
|
||||
assert.equal(g.pendingTerrorEntry?.amount, 5);
|
||||
assert.equal(g.players[0].forces['arrakeen:10'], 5);
|
||||
const beforeRobbery = g.players[0].spice;
|
||||
g = applyAction(nexusRicheseReload(g), f.target, {
|
||||
type: 'decision',
|
||||
reveal: true,
|
||||
});
|
||||
g = applyAction(g, f.target, { type: 'decision', choice: 'spice' });
|
||||
assert.equal(
|
||||
g.players[0].spice,
|
||||
beforeRobbery - Math.ceil(beforeRobbery / 2),
|
||||
);
|
||||
assert.equal(
|
||||
g.moritaniTerror!.tokens.find((t) => t.id === token.id)!.status,
|
||||
'removed',
|
||||
);
|
||||
stable(g);
|
||||
});
|
||||
Reference in new issue
Block a user