Support concealed No-Field Guild transport
This commit is contained in:
1 parent
e41abdaf2e
commit
933a9c1379
24 files changed
+1138
-74
No files matched your search
@@ -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));
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user