Support concealed No-Field Guild transport

This commit is contained in:
Quintine committed 2026-10-27 12:44:34 +11:00
1 parent e41abdaf2e
commit 933a9c1379
24 files changed
+1138 -74

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { guildNoFieldTransportActions as actions } from '../game/bot-guild-no-field-transport';
import { createGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { territory } from '../game/board';
import { guildTransportQuote } from '../game/transport-quote';
import { botHomeworldShipmentPaymentAllowed } from '../game/homeworld-payment-options';
/** Public/owner-entitled projection fixtures for this bot adapter only.
* These are not played-state or physical card/force acquisition programmes.
*/
function fixture(mode: 'native' | 'secret' = 'native', value: 0 | 3 | 5 = 0): GameView {
const source = createGame('BOT-NF-GUILD', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
source.players.push(newPlayer(mode === 'native' ? 'g' : 'f', 'Partner', mode === 'native' ? 'guild' : 'fremen'),
newPlayer('a', 'Atreides', 'atreides'), newPlayer('e', 'Emperor', 'emperor'));
const view = viewGame(source, 'r');
Object.assign(view, { status: 'playing', phase: 5, turn: 2, active: 'r',
order: source.players.map(player => player.id),
storm: 18, response: null, decision: null, truthtrance: null, phaseOpening: null,
automaticContinuationPending: false });
for (const seat of view.players) {
seat.forces = {};
seat.ally = null;
seat.shipped = false;
}
const own = view.players[0];
own.spice = 2;
own.reserves = 0;
if (mode === 'native') {
own.ally = 'g';
view.players[1].ally = 'r';
}
view.aid.available = 0;
const location = { territory: 'imperial_basin', sector: 10 };
const tokenId = `nf-${value}`;
const publicMarker = { deployed: { controller: 'r', location: { ...location }, effectiveForces: 1 as const }, lastUsed: null };
own.noField = publicMarker;
view.richeseNoField = {
owner: 'r', event: 'owned-marker-view-source', public: publicMarker,
private: { tokens: [{ id: 'nf-0', value: 0 }, { id: 'nf-3', value: 3 }, { id: 'nf-5', value: 5 }],
lastShipped: tokenId, deployed: { tokenId, controller: 'r', location: { ...location } } },
canShip: false, homeworldForcesEnabled: false, shipBlock: 'A marker is already deployed.',
canReveal: true, revealBlock: null, canOfferAlly: false, allyOfferBlock: 'No ordinary allied offer.',
allyPricing: null, allyDeclined: false, allyOffer: null,
};
if (mode === 'secret') {
view.nexusCards = { card: 'guild', deckCount: 11, discardCount: 0,
held: Object.fromEntries(view.players.map(player => [player.id, player.id === 'r'])),
turn: null, choices: [], waiting: [] };
view.nexusGuildSecretAlly = { event: JSON.stringify(['nexusGuildSecretAlly', view.turn, view.me]), blocked: null };
}
return view;
}
function legal(view: GameView, choices: Action[]): void {
assert.ok(choices.length > 0);
for (const choice of choices) {
const quote = guildTransportQuote(view, choice);
assert.deepEqual(quote.unavailableReasons, [], JSON.stringify(choice));
assert.equal(botHomeworldShipmentPaymentAllowed(view, choice), true);
assert.ok(quote.quote);
assert.equal(quote.physicalForces, 0);
assert.equal(choice.type, 'guildShip');
assert.equal(choice.noField, view.richeseNoField!.private!.deployed!.tokenId);
assert.equal(choice.event, view.richeseNoField!.event);
assert.deepEqual(choice.forces, {});
assert.equal(choice.allyPayment, quote.quote.pledgedPayment);
assert.equal(choice.amount, undefined);
assert.equal(choice.from, undefined);
}
}
void test('a reciprocal Guild ally chooses quoted marker-only cross routes without revelation or native reserve return', () => {
const view = fixture(), before = structuredClone(view), choices = actions(view);
legal(view, choices);
assert.ok(choices.every(choice => choice.territory !== 'imperial_basin' && choice.territory !== 'reserves'));
assert.ok(choices.every(choice => choice.nexus === undefined));
assert.deepEqual(actions(view), choices, 'enumeration remains stable');
assert.deepEqual(view, before);
const variant = fixture('native', 5);
variant.players[0].reserves = 20;
assert.deepEqual(actions(variant).map(choice => ({ ...choice, noField: undefined })),
choices.map(choice => ({ ...choice, noField: undefined })), 'hidden value and reserves do not choose permission or destinations');
view.players[1].ally = null;
assert.deepEqual(actions(view), [], 'a one-sided alliance supplies no Guild permission');
});
void test('an actual absent-Guild Secret Ally adds its original Nexus source and permits a concealed reserve return', () => {
const view = fixture('secret'), choices = actions(view);
legal(view, choices);
const returned = choices.find(choice => choice.territory === 'reserves');
assert.ok(returned);
assert.equal(returned.sector, 0);
assert.ok(choices.every(choice => choice.nexus === view.nexusGuildSecretAlly!.event));
const wrongCard = structuredClone(view);
wrongCard.nexusCards!.card = 'ecaz';
assert.deepEqual(actions(wrongCard), []);
const allied = structuredClone(view);
allied.players[0].ally = 'f';
allied.players[1].ally = 'r';
assert.deepEqual(actions(allied), [], 'Secret Ally does not manufacture a real alliance');
});
void test('stale or missing owned sources and closed shipment windows produce no marker transport packet', () => {
const view = fixture(), old = actions(view);
legal(view, old);
view.richeseNoField!.event = 'renewed-owned-marker-source';
assert.ok(actions(view).every(choice => choice.event === view.richeseNoField!.event));
assert.ok(guildTransportQuote(view, old[0]).unavailableReasons.length > 0);
const absent = fixture();
absent.richeseNoField = null;
assert.deepEqual(actions(absent), []);
const foreign = fixture();
foreign.richeseNoField!.private!.deployed!.controller = 'g';
assert.deepEqual(actions(foreign), []);
const used = fixture();
used.players[0].shipped = true;
assert.deepEqual(actions(used), []);
const waiting = fixture();
waiting.phaseOpening = { passed: [] };
assert.deepEqual(actions(waiting), []);
const otherTurn = fixture();
otherTurn.active = 'g';
assert.deepEqual(actions(otherTurn), []);
});
void test('shared quotes exclude storm and occupied destinations and supply only the minimum pledged funding', () => {
const sourceStorm = fixture();
sourceStorm.storm = 10;
assert.deepEqual(actions(sourceStorm), []);
const view = fixture();
const carthag = territory('carthag').sectors[0], arrakeen = territory('arrakeen').sectors[0];
view.players[1].forces[`carthag:${carthag}`] = 1;
view.players[2].forces[`arrakeen:${arrakeen}`] = 1;
view.players[3].forces[`arrakeen:${arrakeen}`] = 1;
const choices = actions(view);
legal(view, choices);
assert.ok(choices.every(choice => choice.territory !== 'carthag' && choice.territory !== 'arrakeen'));
view.players[0].spice = 0;
assert.deepEqual(actions(view), []);
view.aid.available = 1;
const funded = actions(view);
legal(view, funded);
assert.ok(funded.every(choice => choice.allyPayment === 1));
});
+204
View File
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { NEXUS_FACTIONS, validateNexusCards } from '../game/nexus-cards';
import type { NoFieldValue } from '../game/richese-no-field';
import { actFamilyAtomicsNoField, familyAtomicsNoFieldPlayer, familyAtomicsNoFieldReload,
familyAtomicsNoFieldInventory, familyAtomicsNoFieldClean, nextFamilyAtomicsNoFieldStep,
type FamilyAtomicsNoFieldStep } from './fixture-family-atomics-no-field';
export type GuildNoFieldTransportStep = FamilyAtomicsNoFieldStep;
export const actGuildNoFieldTransport = actFamilyAtomicsNoField;
export const guildNoFieldTransportPlayer = familyAtomicsNoFieldPlayer;
export const guildNoFieldTransportReload = familyAtomicsNoFieldReload;
export const guildNoFieldTransportClean = (game: Game) => familyAtomicsNoFieldClean(game) &&
!game.pendingGuildTransport && !game.pendingGuildBetrayal;
export function guildNoFieldTransportInventory(game: Game) {
const original = familyAtomicsNoFieldInventory(game), cards = game.nexusCards?.cards;
if (cards) {
validateNexusCards(cards, game.players);
assert.deepEqual([...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort(),
[...NEXUS_FACTIONS].sort());
}
return original;
}
/** Quiet native decisions preserve the original shipment opportunity and held
* Nexus. No optional future transport, redraw or allied token offer is chosen.
*/
export function nextGuildNoFieldTransport(game: Game): GuildNoFieldTransportStep | null {
const betrayal = game.pendingGuildBetrayal;
if (betrayal) {
const actor = betrayal.required.find(actor => !betrayal.passed.includes(actor)); assert.ok(actor);
return { actor, action: { type: 'guildBetrayalPass', event: betrayal.invoice.event } };
}
if (!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision?.kind === 'guildTiming') return { actor: decision.player, action: { type: 'decision', take: false } };
if (decision?.kind === 'richeseAllyOpportunity') return { actor: decision.player, action: { type: 'decision', decline: true } };
if (decision?.kind === 'nexusIxianReplacement')
return { actor: decision.player, action: { type: 'nexusIxianReplacementPass', event: decision.event } };
const phase = game.nexusCards?.phase, cards = game.nexusCards?.cards;
if (phase?.stage === 'drawing' && cards) {
const actor = phase.eligible.find(actor => !phase.done.includes(actor)); assert.ok(actor);
return { actor, action: { type: 'nexusCardChoice', turn: phase.turn, card: cards.hands[actor], choice: 'keep', ownRedraws: 0 } };
}
}
return nextFamilyAtomicsNoFieldStep(game);
}
export function advanceGuildNoFieldTransport(game: Game, until: (state: Game) => boolean,
actions: GuildNoFieldTransportStep[] = []): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original transport chronology ended before its requested boundary');
const next = nextGuildNoFieldTransport(game); assert.ok(next, 'Finish the actual native pending control');
game = actGuildNoFieldTransport(game, next.actor, next.action, actions);
}
throw Error('The original Guild/No-Field chronology did not reach its finite boundary');
}
export function settleGuildNoFieldTransport(game: Game, actions: GuildNoFieldTransportStep[] = []): Game {
return advanceGuildNoFieldTransport(game, guildNoFieldTransportClean, actions);
}
export function passGuildNoFieldPermission(game: Game, actions: GuildNoFieldTransportStep[] = []): Game {
assert.equal(game.response?.kind, 'guildTransportPermission');
while (game.response?.kind === 'guildTransportPermission') {
const next = nextGuildNoFieldTransport(game); assert.ok(next); assert.equal(next.action.type, 'passResponse');
game = actGuildNoFieldTransport(game, next.actor, next.action, actions);
}
return game;
}
type SpiceSelection = { position: number; from: number; card: Game['spiceDeck'][number] };
function selectSpice(game: Game, position: number, worm = false): SpiceSelection {
const from = game.spiceDeck.findIndex((card, index) => index >= position && (worm
? 'worm' in card && !card.greatMaker && !card.suppressed
: 'territory' in card && !card.discovery && card.sector !== game.storm &&
!['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory)));
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
return { position, from, card: structuredClone(card) };
}
export function guildNoFieldTransportAction(game: Game, route: 'cross' | 'return' = 'cross'): Action {
const own = guildNoFieldTransportPlayer(game, 'richese'), marker = own.noField?.deployed; assert.ok(marker && own.noFieldEvent);
const forces = Object.fromEntries(Object.entries(own.forces).filter(([key, amount]) =>
amount > 0 && key.startsWith(`${marker.location.territory}:`)));
const nexus = viewGame(game, own.id).nexusGuildSecretAlly;
return { type: 'guildShip', noField: marker.tokenId, event: own.noFieldEvent, forces,
territory: route === 'return' ? 'reserves' : 'polar_sink', sector: 0, allyPayment: 0,
...(nexus && !nexus.blocked ? { nexus: nexus.event } : {}) };
}
/** Fresh public setup, original dealt Karama, real marker/army shipment and a
* real next worm. Secret selects the original Guild face BEFORE its native
* closing draw; no held hand is exchanged or direct draw helper invoked.
*/
export function createGuildNoFieldTransportFixture(options: {
source?: 'native' | 'secret'; value?: NoFieldValue; companions?: boolean;
} = {}) {
const source = options.source ?? 'native', value = options.value ?? 0, companions = options.companions ?? false;
const owner = 'richese', guild = source === 'native' ? 'guild' : null, canceler = 'atreides';
let game = createGame('GUILDNFTRANSPORT', newPlayer(owner, 'Richese', 'richese'), true, ['choam']);
joinGame(game, newPlayer(canceler, 'Atreides', 'atreides'));
joinGame(game, newPlayer(guild ?? 'emperor', guild ? 'Guild' : 'Emperor', guild ? 'guild' : 'emperor'));
const initial = structuredClone(game), actions: GuildNoFieldTransportStep[] = [];
const act = (actor: string, action: Action) => { game = actGuildNoFieldTransport(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceGuildNoFieldTransport(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), from = actions.length; run();
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
};
if (source === 'secret') act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.ok(game.players.every(player => !player.hand.length));
const karama = game.deck.find(card => card.effect === 'karama');
const ordinary = game.deck.filter(card => card.kind === 'projectile').slice(0, 2); assert.ok(karama); assert.equal(ordinary.length, 2);
const deal = [ordinary[0], karama, ordinary[1]];
const dealOrder = deal.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), setupActions = structuredClone(actions);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal.map(card => card.id));
const original = guildNoFieldTransportInventory(game), firstSpiceOrder = [selectSpice(game, 0), selectSpice(game, 1)];
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && guildNoFieldTransportClean(state));
let physicalShipment: ReturnType<typeof transition> | null = null;
let markerShipment: ReturnType<typeof transition> | null = null;
let token = '';
const shipMarker = () => {
const own = guildNoFieldTransportPlayer(game, owner), selected = own.noField!.tokens.find(token => token.value === value);
assert.ok(selected && own.noFieldEvent); token = selected.id;
markerShipment = transition(() => {
act(owner, { type: 'ship', noField: token, event: own.noFieldEvent, territory: 'imperial_basin', sector: 9 });
reach(guildNoFieldTransportClean);
});
assert.equal(guildNoFieldTransportPlayer(game, owner).reserves, guildNoFieldTransportPlayer(markerShipment.before, owner).reserves);
};
if (companions) {
physicalShipment = transition(() => {
act(owner, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0 });
reach(guildNoFieldTransportClean);
});
assert.equal(guildNoFieldTransportPlayer(game, owner).forces['imperial_basin:9'], 1);
} else shipMarker();
reach(state => state.turn === 2 && state.phase === 0 && guildNoFieldTransportClean(state));
const beforeWorm = structuredClone(game), nexusSpiceOrder = [selectSpice(game, 0, true), selectSpice(game, 1), selectSpice(game, 2)];
let alliance: ReturnType<typeof transition> | null = null;
let draw: ReturnType<typeof transition> | null = null;
let nexusSelection: { from: number; card: 'guild' } | null = null;
if (source === 'native') {
reach(state => state.turn === 2 && state.phase === 1 && !!state.nexus && !state.spiceWindow &&
!state.spiceResolution && guildNoFieldTransportClean(state));
alliance = transition(() => {
act(owner, { type: 'alliance', target: guild! }); act(guild!, { type: 'alliance', target: owner });
});
} else {
reach(state => state.turn === 2 && state.phase === 1 && !!state.nexus && !state.spiceWindow &&
!state.spiceResolution && guildNoFieldTransportClean(state));
alliance = transition(() => {
act(canceler, { type: 'alliance', target: 'emperor' }); act('emperor', { type: 'alliance', target: canceler });
});
assert.equal(guildNoFieldTransportPlayer(game, owner).ally, null);
reach(state => state.turn === 2 && (state.phase > 1 || state.nexusCards?.phase?.stage === 'drawing'));
assert.equal(game.phase, 1, 'The actual closing Nexus must offer unallied Richese its draw');
const cards = game.nexusCards!.cards!, from = cards.deck.indexOf('guild'); assert.ok(from >= 0);
cards.deck.unshift(cards.deck.splice(from, 1)[0]); nexusSelection = { from, card: 'guild' };
draw = transition(() => act(owner, { type: 'nexusCardChoice', turn: game.turn,
card: cards.hands[owner], choice: 'draw', ownRedraws: 0 }));
assert.equal(game.nexusCards!.cards!.hands[owner], 'guild');
}
reach(state => state.turn === 2 && state.phase === 5 && state.active === owner && guildNoFieldTransportClean(state));
const secondMovement = structuredClone(game);
let thirdSpiceOrder: SpiceSelection[] = [];
if (companions) {
shipMarker();
reach(state => state.turn === 3 && state.phase === 0 && guildNoFieldTransportClean(state));
thirdSpiceOrder = [selectSpice(game, 0), selectSpice(game, 1)];
reach(state => state.turn === 3 && state.phase === 5 && state.active === owner && guildNoFieldTransportClean(state));
}
assert.ok(markerShipment);
const shipment = markerShipment as { before: Game; steps: GuildNoFieldTransportStep[]; after: Game };
const own = guildNoFieldTransportPlayer(game, owner), event = own.noFieldEvent; assert.ok(event);
assert.equal(own.noField!.deployed!.tokenId, token); assert.equal(own.shipped, false);
assert.equal(own.noFieldEvent, guildNoFieldTransportPlayer(shipment.after, owner).noFieldEvent);
assert.equal(own.forces['imperial_basin:9'] ?? 0, companions ? 1 : 0);
assert.equal(own.tanks, 0); assert.equal(own.ally, guild);
assert.equal(guildNoFieldTransportPlayer(game, canceler).hand.some(card => card.id === karama.id), true);
if (source === 'secret') assert.equal(game.nexusCards!.cards!.hands[owner], 'guild');
assert.deepEqual(guildNoFieldTransportInventory(game), original);
return { game, sourceGame: structuredClone(game), initial, lobby, setup, afterSetup, source, value, companions,
owner, actor: owner, guild, canceler, karama: karama.id, token, event, marker: structuredClone(own.noField),
action: guildNoFieldTransportAction(game), returnAction: guildNoFieldTransportAction(game, 'return'),
markerShipment: shipment, physicalShipment, beforeWorm, alliance, draw, nexusSelection, secondMovement,
original, dealOrder, firstSpiceOrder, nexusSpiceOrder, thirdSpiceOrder, setupActions, actions,
sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { setup: setupActions.length, markerShipment: shipment.steps.length,
physicalShipment: physicalShipment?.steps.length ?? 0, alliance: alliance?.steps.length ?? 0,
draw: draw?.steps.length ?? 0, source: actions.length },
sourceDisclosure: `Actual public Advanced preview Richese/Atreides/${guild ? 'Guild' : 'Emperor'} +CHOAM-deck setup; ` +
(source === 'secret' ? 'Nexus Cards is actually opted in before readiness/start. At the actual turn2 worm Nexus, Atreides and Emperor mutually ally while Richese remains unallied; this real settled alliance enables its native closing draw. The original Guild card is selected in the unopened Nexus deck before that actual draw; no held-card exchange or direct draw helper is used. '
: 'The actual turn2 worm opens a Nexus and Richese/Guild both consent to their ordinary alliance; native Guild timing always WAITs. ') +
'Only original unopened starting-card/Spice/Nexus positions and scalar lotteries are chosen. Atreides receives original Karama; all counters, cards, income and windows remain native. ' +
(companions ? 'Actual ordinary1 shipment on turn1 precedes actual marker shipment on turn2 into the same Basin9; the original marker and physical companion remain until the unused turn3 shipment. '
: 'Actual paid original marker shipment to empty Basin9 on turn1 remains until the unused turn2 shipment. ') +
'Counts derive from recorded arrays. No hand, purse, army, phase, alliance, marker, source, receipt or transport is installed.' };
}
export type GuildNoFieldTransportFixture = ReturnType<typeof createGuildNoFieldTransportFixture>;
+7 -2
View File
@@ -18,8 +18,11 @@ export type NexusGuildCunningFixture = {
observer: string;
karama?: string;
};
/** Reuse genuine final native Guild setup, then exchange two physical Nexus
* identities before any Cunning declaration; no grant or progress is invented. */
/** CONTROLLED historical Movement boundary after genuine Guild setup. The
* inherited fixture stages wallets, reserve pools and phase5; retire its old
* phase2 Charity frame consistently with that declared jump. Exchange physical
* Nexus identities before Cunning; no grant, progress or completed Charity turn
* is presented as native history. */
export function nexusGuildCunningFixture(
options: Pick<
NexusRicheseFixtureOptions,
@@ -32,6 +35,8 @@ export function nexusGuildCunningFixture(
> = {},
): NexusGuildCunningFixture {
const f = nexusRicheseFixture({ ...options, ownerFaction: 'guild' });
assert.equal(f.g.phase, 5);
delete f.g.charityOrder;
const cards = f.g.nexusCards!.cards!,
index = cards.deck.indexOf('guild');
assert.ok(index >= 0);
+7 -2
View File
@@ -25,8 +25,11 @@ export type NexusGuildSecretAllyFixtureOptions = Omit<
'guild'
>;
};
/** Genuine final native setup without Guild. Exchange physical Nexus identities
* before any declaration; no effect receipt or shipment allowance is staged. */
/** CONTROLLED historical Movement boundary after genuine setup: the inherited
* Richese fixture stages its wallets, reserve pools and phase5 rather than
* playing the intervening phases. Retire its old phase2 Charity frame with that
* declared jump. Physical Nexus identities are exchanged before declaration;
* no transport receipt or completed native Charity opportunity is invented. */
export function nexusGuildSecretAllyFixture(
options: NexusGuildSecretAllyFixtureOptions = {},
) {
@@ -36,6 +39,8 @@ export function nexusGuildSecretAllyFixture(
options.opponentFaction ??
(options.ownerFaction === 'harkonnen' ? 'beneGesserit' : 'harkonnen'),
});
assert.equal(f.g.phase, 5);
delete f.g.charityOrder;
const cards = f.g.nexusCards!.cards!,
index = cards.deck.indexOf('guild');
assert.ok(index >= 0);
@@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
import { guildTransportQuote } from '../game/transport-quote';
import { actGuildNoFieldTransport as act, advanceGuildNoFieldTransport as advance,
createGuildNoFieldTransportFixture as fixture, guildNoFieldTransportClean as clean,
guildNoFieldTransportInventory as inventory, guildNoFieldTransportPlayer as p,
guildNoFieldTransportReload as reload, passGuildNoFieldPermission as passPermission,
settleGuildNoFieldTransport as settle } from './fixture-guild-no-field-transport';
function reject(game: Game, actor: string, action: Action) {
const before = reload(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function body(game: Game, actor: string) {
const own = p(game, actor); return { reserves: own.reserves, tanks: own.tanks, forces: structuredClone(own.forces) };
}
void test('native alliance and Secret closing draw sources retain original paid markers and actual arrays', () => {
for (const source of ['native', 'secret'] as const) {
const f = fixture({ source });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.sourceCounts.setup, f.setupActions.length); assert.equal(f.sourceCounts.source, f.sourceActions.length);
assert.equal(f.sourceCounts.markerShipment, f.markerShipment.steps.length);
assert.ok(f.markerShipment.steps.some(step => step.action.type === 'ship' && step.action.noField === f.token));
assert.equal(p(f.markerShipment.before, f.owner).spice - p(f.markerShipment.after, f.owner).spice, 2);
assert.equal(p(f.markerShipment.before, f.owner).reserves, p(f.game, f.owner).reserves);
assert.equal(f.game.turn, 2); assert.equal(f.game.active, f.owner); assert.equal(p(f.game, f.owner).shipped, false);
if (source === 'native') {
assert.ok(f.alliance); assert.equal(f.sourceCounts.alliance, f.alliance.steps.length);
assert.deepEqual(f.alliance.steps.map(step => step.action.type), ['alliance', 'alliance']);
assert.ok(f.sourceActions.some(step => step.action.type === 'decision' && step.action.take === false));
} else {
assert.ok(f.draw && f.nexusSelection && f.alliance); assert.equal(f.sourceCounts.draw, f.draw.steps.length);
assert.deepEqual(f.alliance.steps.map(step => [step.actor, step.action.type, step.action.target]),
[['atreides', 'alliance', 'emperor'], ['emperor', 'alliance', 'atreides']]);
assert.equal(p(f.draw.before, f.owner).ally, null);
assert.deepEqual(f.draw.before.nexusCards!.phase!.eligible, [f.owner]);
assert.equal(f.draw.before.nexusCards!.cards!.hands[f.owner], null);
assert.equal(f.draw.after.nexusCards!.cards!.hands[f.owner], 'guild');
assert.ok(f.sourceActions.some(step => step.action.type === 'moduleOption' && step.action.module === 'nexusCards'));
}
assert.deepEqual(inventory(f.game), f.original);
}
});
void test('native allied CROSS accepts concealed zero three and five through real permission and rate without materializing', () => {
for (const value of [0, 3, 5] as const) {
const f = fixture({ value }), before = body(f.game, f.owner), spice = p(f.game, f.owner).spice, guildSpice = p(f.game, f.guild!).spice;
const quote = guildTransportQuote(viewGame(f.game, f.owner), f.action);
assert.deepEqual(quote.unavailableReasons, []); assert.equal(quote.quote?.cost, 1);
let game = act(reload(f.game), f.owner, f.action);
assert.equal(game.response?.kind, 'guildTransportPermission'); assert.deepEqual(body(game, f.owner), before);
assert.deepEqual(p(game, f.owner).noField, f.marker); assert.equal(p(game, f.owner).noFieldEvent, f.event);
game = passPermission(game); assert.equal(game.response?.kind, 'guildRate');
game = settle(game);
assert.deepEqual(body(game, f.owner), before); assert.equal(p(game, f.owner).spice, spice - 1);
assert.equal(p(game, f.guild!).spice, guildSpice + 1);
assert.deepEqual(p(game, f.owner).noField!.deployed!.location, { territory: 'polar_sink', sector: 0 });
assert.notEqual(p(game, f.owner).noFieldEvent, f.event);
assert.equal(p(game, f.owner).noField!.lastShipped, f.token); assert.deepEqual(p(game, f.owner).noField!.tokens, f.marker!.tokens);
assert.equal(p(game, f.owner).shipped, true); assert.equal(p(game, f.owner).moved, 0);
reject(game, f.owner, f.action); assert.deepEqual(inventory(game), f.original);
}
});
void test('actual native permission cancellation spends only original Karama and retains concealed custody and unused shipment', () => {
const f = fixture({ value: 5 }), before = body(f.game, f.owner), spice = p(f.game, f.owner).spice;
let game = act(reload(f.game), f.owner, f.action);
assert.equal(game.response?.kind, 'guildTransportPermission');
game = settle(act(game, f.canceler, { type: 'card', mode: 'cancel', card: f.karama }));
assert.deepEqual(body(game, f.owner), before); assert.equal(p(game, f.owner).spice, spice);
assert.deepEqual(p(game, f.owner).noField, f.marker); assert.equal(p(game, f.owner).noFieldEvent, f.event);
assert.equal(p(game, f.owner).shipped, false); assert.equal(viewGame(game, f.owner).guildTransportCanceled, true);
assert.equal(viewGame(game, f.owner).guildRateCanceled, false);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(inventory(game), f.original);
});
void test('actual rate cancellation pays full price once or withdraws an unfunded marker quote after a real bribe', () => {
for (const unfunded of [false, true]) {
const f = fixture({ value: 3 }); let game = reload(f.game);
if (unfunded) game = act(game, f.owner, { type: 'bribe', target: f.canceler, amount: p(game, f.owner).spice - 1 });
const before = body(game, f.owner), spice = p(game, f.owner).spice, guildSpice = p(game, f.guild!).spice;
game = passPermission(act(game, f.owner, f.action)); assert.equal(game.response?.kind, 'guildRate');
game = settle(act(game, f.canceler, { type: 'card', mode: 'cancel', card: f.karama }));
assert.deepEqual(body(game, f.owner), before); assert.equal(viewGame(game, f.owner).guildRateCanceled, unfunded);
if (unfunded) {
assert.equal(p(game, f.owner).spice, spice); assert.equal(p(game, f.guild!).spice, guildSpice);
assert.deepEqual(p(game, f.owner).noField, f.marker); assert.equal(p(game, f.owner).noFieldEvent, f.event);
assert.equal(p(game, f.owner).shipped, false);
} else {
assert.equal(p(game, f.owner).spice, spice - 2); assert.equal(p(game, f.guild!).spice, guildSpice + 2);
assert.equal(p(game, f.owner).shipped, true);
assert.deepEqual(p(game, f.owner).noField!.deployed!.location, { territory: 'polar_sink', sector: 0 });
}
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1); assert.deepEqual(inventory(game), f.original);
}
});
void test('stale foreign invalid physical and ordinary ally RETURN selections reject before transport costs', () => {
const f = fixture();
reject(f.game, f.owner, { ...f.action, event: 'old-owned-marker-event' });
reject(f.game, f.owner, { ...f.action, noField: 'foreign-token' }); reject(f.game, f.canceler, f.action);
reject(f.game, f.owner, { ...f.action, forces: { 'imperial_basin:9': 1 } });
reject(f.game, f.owner, f.returnAction);
assert.deepEqual(p(f.game, f.owner).noField, f.marker); assert.deepEqual(inventory(f.game), f.original);
});
void test('actual absent-Guild Secret CROSS and RETURN handle concealed zero three and five and spend one original Nexus', () => {
for (const value of [0, 3, 5] as const) for (const returns of [false, true]) {
const f = fixture({ source: 'secret', value }), before = body(f.game, f.owner), spice = p(f.game, f.owner).spice;
const proposed = returns ? f.returnAction : f.action;
const quote = guildTransportQuote(viewGame(f.game, f.owner), proposed);
assert.deepEqual(quote.unavailableReasons, []); assert.equal(quote.quote?.cost, 1);
const game = settle(act(reload(f.game), f.owner, proposed));
assert.deepEqual(body(game, f.owner), before); assert.equal(p(game, f.owner).spice, spice - 1);
assert.equal(game.nexusCards!.cards!.hands[f.owner], null);
assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1);
assert.equal(p(game, f.owner).noField!.lastShipped, f.token); assert.notEqual(p(game, f.owner).noFieldEvent, f.event);
assert.deepEqual(p(game, f.owner).noField!.tokens, f.marker!.tokens);
if (returns) { assert.equal(p(game, f.owner).noField!.deployed, null); assert.equal(p(game, f.owner).noField!.hiddenReturn, true); }
else assert.deepEqual(p(game, f.owner).noField!.deployed!.location, { territory: 'polar_sink', sector: 0 });
assert.equal(p(game, f.owner).shipped, true); reject(game, f.owner, proposed);
assert.deepEqual(inventory(game), f.original);
}
});
void test('a genuine earlier physical companion returns separately from its later concealed five marker', () => {
const f = fixture({ source: 'secret', value: 5, companions: true }); assert.ok(f.physicalShipment);
assert.equal(f.physicalShipment.before.turn, 1); assert.equal(f.markerShipment.before.turn, 2); assert.equal(f.game.turn, 3);
assert.equal(p(f.game, f.owner).forces['imperial_basin:9'], 1);
assert.deepEqual(f.returnAction.forces, { 'imperial_basin:9': 1 });
const before = p(f.game, f.owner), quote = guildTransportQuote(viewGame(f.game, f.owner), f.returnAction);
assert.deepEqual(quote.unavailableReasons, []); assert.equal(quote.quote?.cost, 1);
const game = settle(act(reload(f.game), f.owner, f.returnAction)), after = p(game, f.owner);
assert.equal(after.reserves, before.reserves + 1); assert.equal(after.tanks, before.tanks);
assert.deepEqual(after.forces, {}); assert.equal(after.noField!.deployed, null); assert.equal(after.noField!.hiddenReturn, true);
assert.equal(after.noField!.lastShipped, f.token); assert.deepEqual(after.noField!.tokens, f.marker!.tokens);
assert.equal(after.spice, before.spice - 1); assert.equal(game.nexusCards!.cards!.discard.filter(card => card === 'guild').length, 1);
assert.deepEqual(inventory(game), f.original);
});
void test('Secret hidden return preserves the same-token next-shipment ban at an actual later unused opportunity', () => {
const f = fixture({ source: 'secret', value: 0 });
let game = settle(act(reload(f.game), f.owner, f.returnAction));
const turn = game.turn;
// The remaining original unopened land faces are selected before their real draws.
game = advance(game, state => state.turn === turn + 1 && state.phase === 0 && clean(state));
for (const position of [0, 1]) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery && card.territory !== 'imperial_basin');
assert.ok(from >= position); const [card] = game.spiceDeck.splice(from, 1); game.spiceDeck.splice(position, 0, card);
}
game = advance(game, state => state.phase === 5 && state.active === f.owner && clean(state));
assert.equal(p(game, f.owner).shipped, false); assert.equal(p(game, f.owner).noField!.lastShipped, f.token);
reject(game, f.owner, { type: 'ship', noField: f.token, event: p(game, f.owner).noFieldEvent, territory: 'polar_sink', sector: 0 });
const other = p(game, f.owner).noField!.tokens.find(token => token.id !== f.token)!;
game = settle(act(game, f.owner, { type: 'ship', noField: other.id, event: p(game, f.owner).noFieldEvent, territory: 'polar_sink', sector: 0 }));
assert.equal(p(game, f.owner).noField!.deployed!.tokenId, other.id); assert.equal(p(game, f.owner).reserves, 20);
assert.deepEqual(inventory(game), f.original);
});
void test('all four minimal profiles propose a legal owned native or Secret marker transport without reading its value', () => {
for (const source of ['native', 'secret'] as const) {
const f = fixture({ source, value: 0 });
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
const view = viewGame(f.game, f.owner); view.players.find(player => player.id === f.owner)!.bot = difficulty;
const proposed = botActions(view).find(action => action.type === 'guildShip' && action.noField === f.token);
assert.ok(proposed, `${source}/${difficulty} supplies the actual owned marker transport`);
const game = settle(act(reload(f.game), f.owner, proposed));
assert.equal(p(game, f.owner).shipped, true); assert.equal(p(game, f.owner).reserves, p(f.game, f.owner).reserves);
assert.equal(p(game, f.owner).tanks, 0); assert.deepEqual(inventory(game), f.original);
}
}
});
@@ -0,0 +1,157 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { createGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { createRicheseNoField, deployRicheseNoField, type NoFieldValue } from '../game/richese-no-field';
import { guildTransportQuote } from '../game/transport-quote';
import { quoteNexusGuildSecretShipment } from '../game/nexus-guild-secret-ally';
import { nexusGuildSecretAllyAction, nexusGuildSecretAllyQuote } from '../game/nexus-guild-secret-ally-options';
import { ShipmentQuote } from '../components/shipment-quote';
/** Controlled unit source, not an original-game chronology: initialize public
* phase/armies and owned marker custody, then consume the real view projection.
* Secret Ally offers below are explicitly public-view construction supplements;
* native declaration, disposal and transport outcomes belong to engine tests. */
function projected({ value = 0, forces = {}, secret = false }: {
value?: NoFieldValue;
forces?: Record<string, number>;
secret?: boolean;
} = {}) {
const game = createGame('GUILDNFFORCEQUOTE', newPlayer('r', 'Richese', 'richese'), true, ['choam']);
game.players.push(newPlayer('other', secret ? 'Atreides' : 'Guild', secret ? 'atreides' : 'guild'),
newPlayer('h', 'Harkonnen', 'harkonnen'));
Object.assign(game, { status: 'playing', phase: 5, turn: 2, active: 'r',
order: ['r', 'other', 'h'], movementRemaining: ['r', 'other', 'h'], storm: 18 });
const owner = game.players.find(player => player.id === 'r')!;
owner.spice = 30;
owner.forces = { ...forces };
owner.reserves = 20 - Object.values(forces).reduce((sum, count) => sum + count, 0);
const stock = createRicheseNoField(['controlled-zero', 'controlled-three', 'controlled-five']);
const token = stock.tokens.find(candidate => candidate.value === value)!;
owner.noField = deployRicheseNoField(stock, { tokenId: token.id, controller: owner.id,
location: { territory: 'imperial_basin', sector: 10 } });
owner.noFieldEvent = 'controlled-current-owner-event';
if (!secret) {
owner.ally = 'other';
game.players.find(player => player.id === 'other')!.ally = owner.id;
}
const view = viewGame(game, owner.id);
if (secret) {
view.nexusCards = { card: 'guild', held: { r: true, other: false, h: false },
deckCount: 11, discardCount: 0, turn: view.turn, choices: [], waiting: [] };
view.nexusGuildSecretAlly = { event: JSON.stringify(['nexusGuildSecretAlly', view.turn, view.me]), blocked: null };
}
assert.ok(view.richeseNoField?.private?.deployed);
const action: Action = { type: 'guildShip', from: 'imperial_basin:10', forces: { ...forces },
territory: 'carthag', sector: 11, noField: token.id, event: view.richeseNoField.event };
return { view, action };
}
function secretAction(view: GameView, action: Action) {
const event = view.nexusGuildSecretAlly?.event; assert.ok(event);
return nexusGuildSecretAllyAction(view, event, action);
}
void test('Secret Ally prices one marker separately from zero to twenty physical forces and preserves physical-only bounds', () => {
assert.deepEqual(quoteNexusGuildSecretShipment('stronghold', 1, true), {
physicalAmount: 0, cost: 1, source: 'nexusGuildSecretAlly',
});
assert.deepEqual(quoteNexusGuildSecretShipment('stronghold', 21, true), {
physicalAmount: 20, cost: 11, source: 'nexusGuildSecretAlly',
});
assert.deepEqual(quoteNexusGuildSecretShipment('reserves', 20), {
physicalAmount: 20, cost: 10, source: 'nexusGuildSecretAlly',
});
assert.deepEqual(quoteNexusGuildSecretShipment('homeworld', 1), {
physicalAmount: 1, cost: 1, source: 'nexusGuildSecretAlly',
});
assert.throws(() => quoteNexusGuildSecretShipment('homeworld', 1, true));
assert.throws(() => quoteNexusGuildSecretShipment('stronghold', 21));
for (const amount of [0, -1, 1.5, 22])
assert.throws(() => quoteNexusGuildSecretShipment('stronghold', amount, true));
});
void test('a concealed marker including zero quotes one billable unit and displays zero physical forces plus one marker', () => {
for (const value of [0, 3, 5] as const) {
const { view, action } = projected({ value });
const quote = guildTransportQuote(view, action);
assert.deepEqual(quote.unavailableReasons, []);
assert.equal(quote.physicalForces, 0);
assert.equal(quote.noField, true);
assert.equal(quote.quote?.cost, 1);
const html = renderToStaticMarkup(createElement(ShipmentQuote, { label: 'Guild cross-shipment', ...quote }));
assert.match(html, /Physical forces selected<\/dt><dd[^>]*>0<\/dd>/);
assert.match(html, /Concealed No-Field selected<\/dt><dd[^>]*>1 marker<\/dd>/);
assert.match(html, /excluded from the physical force count/);
assert.doesNotMatch(html, /Reveal|Materialize|5 forces|3 forces/);
}
});
void test('a mixed same-territory group bills twenty physical counters plus its marker once', () => {
const { view, action } = projected({ forces: { 'imperial_basin:10': 12, 'imperial_basin:11': 8 } });
const quote = guildTransportQuote(view, action);
assert.deepEqual(quote.unavailableReasons, []);
assert.equal(quote.physicalForces, 20);
assert.equal(quote.quote?.cost, 11);
const ordinary = { ...action }; delete ordinary.noField; delete ordinary.event;
const physical = guildTransportQuote(view, ordinary);
assert.equal(physical.physicalForces, 20);
assert.equal(physical.quote?.cost, 10);
assert.equal(physical.noField, undefined);
const html = renderToStaticMarkup(createElement(ShipmentQuote, physical));
assert.doesNotMatch(html, /Concealed No-Field selected|one shipment unit/);
});
void test('current marker identity event source storm and physical quantities reject forged selections in quote and adapter', () => {
const source = projected({ secret: true, forces: { 'imperial_basin:10': 2, 'imperial_basin:11': 1 } });
const invalid: Action[] = [
{ ...source.action, noField: 'another-token' },
{ ...source.action, event: 'old-owner-event' },
{ ...source.action, forces: { 'imperial_basin:10': 3 } },
{ ...source.action, forces: { 'imperial_basin:10': -1 } },
{ ...source.action, forces: { 'imperial_basin:10': 0.5 } },
{ ...source.action, forces: [] },
{ ...source.action, from: 'reserves' },
];
for (const action of invalid) {
assert.equal(secretAction(source.view, action), null);
assert.ok(guildTransportQuote(source.view, { ...action, nexus: source.view.nexusGuildSecretAlly!.event }).unavailableReasons.length);
}
for (const storm of [10, 11]) {
const view = structuredClone(source.view); view.storm = storm;
assert.equal(secretAction(view, source.action), null);
assert.match(guildTransportQuote(view, { ...source.action, nexus: view.nexusGuildSecretAlly!.event }).unavailableReasons.join(' '), /source sector is in the storm/);
}
const foreign = structuredClone(source.view);
foreign.richeseNoField!.public.deployed!.controller = 'other';
assert.equal(secretAction(foreign, source.action), null);
const mixedOrigin = structuredClone(source.view);
mixedOrigin.players.find(player => player.id === mixedOrigin.me)!.forces['red_chasm:7'] = 1;
const outside = { ...source.action, forces: { 'red_chasm:7': 1 } };
assert.equal(secretAction(mixedOrigin, outside), null);
assert.match(guildTransportQuote(mixedOrigin, { ...outside, nexus: mixedOrigin.nexusGuildSecretAlly!.event }).unavailableReasons.join(' '), /one territory/);
});
void test('ordinary Guild alliance grants cross-only while an explicit current absent-Guild source grants return at its own rate', () => {
const native = projected({ forces: { 'imperial_basin:10': 2 } });
const returned = { ...native.action, territory: 'reserves', sector: 0 };
assert.match(guildTransportQuote(native.view, returned).unavailableReasons.join(' '), /Only the Guild may return/);
native.view.guildRateCanceled = true;
assert.equal(guildTransportQuote(native.view, native.action).quote?.cost, 3);
const source = projected({ secret: true, forces: { 'imperial_basin:10': 2 } });
source.view.guildRateCanceled = true;
assert.equal(nexusGuildSecretAllyQuote(source.view, 'reserves', 21, true)?.physicalAmount, 20);
assert.equal(nexusGuildSecretAllyQuote(source.view, 'reserves', 21), null);
for (const territory of ['carthag', 'reserves']) {
const original = { ...source.action, territory, sector: territory === 'reserves' ? 0 : 11 };
const selected = secretAction(source.view, original); assert.ok(selected);
assert.deepEqual(selected, { ...original, nexus: source.view.nexusGuildSecretAlly!.event });
const quote = guildTransportQuote(source.view, selected);
assert.deepEqual(quote.unavailableReasons, []);
assert.equal(quote.physicalForces, 2);
assert.equal(quote.quote?.cost, 2);
}
assert.equal(secretAction(source.view, { ...source.action, type: 'ship', amount: 1 }), null);
assert.equal(secretAction(source.view, { ...source.action, type: 'homeworldShip', sources: {} }), null);
assert.equal(nexusGuildSecretAllyAction(source.view, 'old-nexus-event', source.action), null);
});