Support Basic No-Field Guild Ambassador shipments
Connect native concealment and allied supplier/recipient consent to the independent grant, with explicit provisional cap and transport-fee policy. Preserve actual tokens, ordinary allowances, source-scoped Karama prevention and original income/arrival continuation. Add genuine three/four-seat sources, focused quote/control/engine cases and bounded runtime/minimal-bot proof. Advanced and optional profiles retain their existing guards.
This commit is contained in:
1 parent
4b2f4f3fa3
commit
659e2a4a9a
19 files changed
+2221
-56
No files matched your search
@@ -0,0 +1,231 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type GuildAmbassadorNoFieldStep = { actor: string; action: Action };
|
||||
export type GuildAmbassadorNoFieldTransition = {
|
||||
before: Game; steps: GuildAmbassadorNoFieldStep[]; after: Game;
|
||||
};
|
||||
export const guildAmbassadorNoFieldPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const guildAmbassadorNoFieldClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||
export const guildAmbassadorNoFieldInventory = (game: Game) => {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? []),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(player => player.hand)];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Each original physical Treachery Card has one current slot');
|
||||
return ids;
|
||||
};
|
||||
|
||||
/** Select only original scalar lotteries. Nine selected draws belong to the
|
||||
* original Ambassador packet; UUIDs and other byte entropy remain native. */
|
||||
function withOriginalScalarLottery<T>(run: () => T, packet: readonly number[] = []): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1)
|
||||
array[0] = cursor < packet.length ? packet[cursor++] : 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const result = run(); assert.equal(cursor, packet.length, 'The original packet consumes its nine selected draws');
|
||||
return result;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actGuildAmbassadorNoField(game: Game, actor: string, action: Action,
|
||||
actions: GuildAmbassadorNoFieldStep[] = []): Game {
|
||||
const next = withOriginalScalarLottery(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
function nextStep(game: Game): GuildAmbassadorNoFieldStep | null {
|
||||
if (!game.response && !game.phaseOpening) {
|
||||
if (game.decision?.kind === 'ecazAmbassador' || game.decision?.kind === 'ecazPlacement') return null;
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' &&
|
||||
guildAmbassadorNoFieldPlayer(game, 'ecaz').reserves === 20)
|
||||
return { actor: 'ecaz', action: { type: 'ecazSetup', placements: { 'imperial_basin:9': 6 } } };
|
||||
if (game.decision?.kind === 'richeseCache') return {
|
||||
actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: game.richeseCache![0].id, method: 'onceAround', direction: 'counterclockwise' },
|
||||
};
|
||||
if (game.decision?.kind === 'richeseUnbid') return { actor: game.decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
}
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceGuildAmbassadorNoField(game: Game, until: (state: Game) => boolean,
|
||||
actions: GuildAmbassadorNoFieldStep[] = []): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Basic source ended before its next control');
|
||||
const next = nextStep(game);
|
||||
assert.ok(next, `Choose the original Ambassador control before advancing: ${JSON.stringify({
|
||||
status: game.status, phase: game.phase, active: game.active, decision: game.decision,
|
||||
entry: game.pendingAmbassador && { stage: game.pendingAmbassador.stage, effect: game.pendingAmbassador.effect },
|
||||
})}`);
|
||||
game = actGuildAmbassadorNoField(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Guild Ambassador source did not reach its finite boundary');
|
||||
}
|
||||
export function passGuildAmbassadorNoFieldResponses(game: Game,
|
||||
actions: GuildAmbassadorNoFieldStep[] = []): Game {
|
||||
for (let count = 0; game.response && count < 60; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actGuildAmbassadorNoField(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.equal(game.response, null); return game;
|
||||
}
|
||||
function originalGuildPacket() {
|
||||
const pool = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['guild', 'atreides', 'emperor', 'fremen', 'harkonnen'];
|
||||
const desired = [...supply, ...pool.filter(effect => !supply.includes(effect))];
|
||||
const current = [...pool], rolls: number[] = [];
|
||||
for (let i = current.length - 1; i > 0; i--) {
|
||||
const j = current.indexOf(desired[i]); assert.ok(j >= 0 && j <= i);
|
||||
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
|
||||
[current[i], current[j]] = [current[j], current[i]];
|
||||
}
|
||||
assert.deepEqual(current, desired); assert.equal(rolls.length, 9); return { rolls, supply };
|
||||
}
|
||||
|
||||
/** Fresh public bare Basic E2/E3 source. Ecaz pays for both original markers.
|
||||
* Its reusable self-marker really creates the Richese alliance at Carthag;
|
||||
* Richese explicitly accepts and Ecaz declines the optional Basic Duke loan.
|
||||
* Both future beneficiaries already made their own paid ordinary shipments and
|
||||
* ended their movement turns before Harkonnen pays for the Guild-marker entry.
|
||||
* Only original undealt card/packet order is selected. No wallet, army, phase,
|
||||
* alliance, allowance, token custody or continuation is assigned. */
|
||||
export function createGuildAmbassadorNoFieldFixture(options: {
|
||||
beneficiary: 'own' | 'allied'; priorMarker?: boolean; karama?: boolean; includeGuild?: boolean;
|
||||
}) {
|
||||
let game = createGame('GUILDAMBASSADORNOFIELD', newPlayer('ecaz', 'Ecaz', 'ecaz'), false, ['ecaz', 'choam']);
|
||||
const includeGuild = options.includeGuild !== false;
|
||||
const roster = includeGuild ? ['richese', 'harkonnen', 'guild'] as const : ['richese', 'harkonnen'] as const;
|
||||
for (const faction of roster)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const ownerEcaz = 'ecaz', richese = 'richese', entrantHark = 'harkonnen', guild = 'guild';
|
||||
const beneficiaryID = options.beneficiary === 'own' ? richese : ownerEcaz;
|
||||
const actions: GuildAmbassadorNoFieldStep[] = [], staging: string[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actGuildAmbassadorNoField(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceGuildAmbassadorNoField(game, until, actions); };
|
||||
const transition = (run: () => void): GuildAmbassadorNoFieldTransition => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, false);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalInventory = guildAmbassadorNoFieldInventory(game);
|
||||
const originalMainDeck = game.deck.map(card => card.id).sort();
|
||||
const originalCache = game.richeseCache!.map(card => card.id).sort();
|
||||
// Harkonnen receives two actual starting cards; all other seats receive one.
|
||||
const dealCount = game.players.length + 1;
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, dealCount);
|
||||
assert.equal(ordinary.length, dealCount);
|
||||
const heldKarama = options.karama ? game.deck.find(card => card.effect === 'karama') : null;
|
||||
if (options.karama) { assert.ok(heldKarama); ordinary[2] = heldKarama; }
|
||||
const deal = ordinary.map(card => card.id);
|
||||
const dealOrder = deal.map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), packet = originalGuildPacket();
|
||||
let packetCreation: GuildAmbassadorNoFieldTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextStep(game); assert.ok(next);
|
||||
const initializesForces = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
|
||||
game.players.filter(player => player.traitorChoices.length > 0).length === 1;
|
||||
if (initializesForces) {
|
||||
const before = structuredClone(game);
|
||||
game = withOriginalScalarLottery(() => applyAction(game, next.actor, next.action), packet.rolls);
|
||||
actions.push(structuredClone(next)); packetCreation = { before, steps: [structuredClone(next)], after: structuredClone(game) };
|
||||
} else act(next.actor, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(packetCreation);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id =>
|
||||
game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const selfToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!;
|
||||
const guildToken = game.ecazAmbassadors!.tokens.find(token => token.effect === 'guild')!;
|
||||
const placements = transition(() => {
|
||||
act(ownerEcaz, { type: 'decision', token: selfToken.id, territory: 'carthag' });
|
||||
reach(state => !state.response && state.decision?.kind === 'ecazPlacement');
|
||||
act(ownerEcaz, { type: 'decision', token: guildToken.id, territory: 'habbanya_ridge_sietch' });
|
||||
reach(state => !state.response && state.decision?.kind === 'ecazPlacement');
|
||||
act(ownerEcaz, { type: 'decision', decline: true });
|
||||
});
|
||||
assert.equal(guildAmbassadorNoFieldPlayer(game, ownerEcaz).spice,
|
||||
guildAmbassadorNoFieldPlayer(placements.before, ownerEcaz).spice - 3);
|
||||
reach(state => state.phase === 5 && state.active === ownerEcaz && guildAmbassadorNoFieldClean(state));
|
||||
assert.deepEqual(game.order, [ownerEcaz, richese, entrantHark, ...(includeGuild ? [guild] : [])]);
|
||||
assert.equal(game.storm, 1);
|
||||
const ordinaryEcaz = transition(() => {
|
||||
act(ownerEcaz, { type: 'ship', amount: 1, territory: 'polar_sink', sector: 0, elite: 0, allyPayment: 0 });
|
||||
reach(guildAmbassadorNoFieldClean); act(ownerEcaz, { type: 'endMovement' });
|
||||
reach(state => state.active === richese && guildAmbassadorNoFieldClean(state));
|
||||
});
|
||||
const tokens = guildAmbassadorNoFieldPlayer(game, richese).noField!.tokens.map(token => ({ ...token }));
|
||||
const priorToken = tokens.find(token => token.value === 3)!;
|
||||
const alliance = transition(() => {
|
||||
const richeseEvent = guildAmbassadorNoFieldPlayer(game, richese).noFieldEvent!;
|
||||
act(richese, options.priorMarker
|
||||
? { type: 'ship', noField: priorToken.id, event: richeseEvent, territory: 'carthag', sector: 11 }
|
||||
: { type: 'ship', amount: 1, territory: 'carthag', sector: 11, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
act(ownerEcaz, { type: 'decision', event: game.pendingAmbassador!.event,
|
||||
trigger: true, beneficiary: ownerEcaz, choice: 'alliance' });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'allianceReply');
|
||||
act(richese, { type: 'decision', event: game.pendingAmbassador!.event, accept: true });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'loan');
|
||||
act(ownerEcaz, { type: 'decision', event: game.pendingAmbassador!.event, loan: false });
|
||||
reach(guildAmbassadorNoFieldClean);
|
||||
});
|
||||
assert.equal(guildAmbassadorNoFieldPlayer(game, ownerEcaz).ally, richese);
|
||||
assert.equal(guildAmbassadorNoFieldPlayer(game, richese).ally, ownerEcaz);
|
||||
act(richese, { type: 'endMovement' });
|
||||
reach(state => state.active === entrantHark && guildAmbassadorNoFieldClean(state));
|
||||
const arrival = transition(() => {
|
||||
act(entrantHark, { type: 'ship', amount: 1, territory: 'habbanya_ridge_sietch', sector: 17,
|
||||
elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && state.pendingAmbassador?.stage === 'offer');
|
||||
});
|
||||
assert.equal(guildAmbassadorNoFieldPlayer(game, entrantHark).spice,
|
||||
guildAmbassadorNoFieldPlayer(arrival.before, entrantHark).spice - 1);
|
||||
const beforeTrigger = structuredClone(game);
|
||||
act(ownerEcaz, { type: 'decision', event: game.pendingAmbassador!.event, trigger: true, beneficiary: beneficiaryID });
|
||||
assert.ok(game.pendingAmbassador); assert.equal(game.pendingAmbassador.stage, 'ship');
|
||||
assert.equal(game.pendingAmbassador.effect, 'guild');
|
||||
assert.equal(game.decision?.player, beneficiaryID);
|
||||
assert.equal(game.active, entrantHark);
|
||||
assert.ok(guildAmbassadorNoFieldPlayer(game, ownerEcaz).shipped);
|
||||
assert.ok(guildAmbassadorNoFieldPlayer(game, richese).shipped);
|
||||
assert.deepEqual(guildAmbassadorNoFieldInventory(game), originalInventory);
|
||||
const event = game.pendingAmbassador.event;
|
||||
return { game, event, ownerEcaz, richese, beneficiaryID, entrantHark, guild, includeGuild, tokens,
|
||||
territory: 'sietch_tabr', sector: 14, desert: 'habbanya_ridge_flat', desertSector: 17,
|
||||
priorToken, currentTokenEvent: guildAmbassadorNoFieldPlayer(game, richese).noFieldEvent!,
|
||||
karama: heldKarama?.id ?? null, actions, staging, lobby, setup, beforeStartingDeal, afterSetup,
|
||||
originalInventory, originalMainDeck, originalCache, originalSpice, dealOrder, spiceOrder,
|
||||
packetCreation, placements, ordinaryEcaz, alliance, arrival, beforeTrigger };
|
||||
}
|
||||
export type GuildAmbassadorNoFieldFixture = ReturnType<typeof createGuildAmbassadorNoFieldFixture>;
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
actGuildAmbassadorNoField as act, advanceGuildAmbassadorNoField as advance,
|
||||
createGuildAmbassadorNoFieldFixture as fixture, guildAmbassadorNoFieldClean as clean,
|
||||
guildAmbassadorNoFieldInventory as inventory, guildAmbassadorNoFieldPlayer as player,
|
||||
passGuildAmbassadorNoFieldResponses as passResponses,
|
||||
} from './fixture-guild-ambassador-no-field';
|
||||
|
||||
void test('genuine three-seat allied Guild Ambassador shipment pays its fixed stronghold fee to the bank when Guild is absent', () => {
|
||||
const f = fixture({ beneficiary: 'allied', includeGuild: false, karama: true });
|
||||
const before = structuredClone(f.game), totalSpice = before.players.reduce((sum, p) => sum + p.spice, 0);
|
||||
const beforeAllowances = { active: before.active, remaining: before.movementRemaining,
|
||||
players: before.players.map(p => ({ id: p.id, shipped: p.shipped, moved: p.moved })) };
|
||||
assert.equal(f.includeGuild, false); assert.equal(before.players.length, 3);
|
||||
assert.ok(before.players.every(p => p.faction !== 'guild'));
|
||||
assert.deepEqual(before.players.map(p => p.faction), ['ecaz', 'richese', 'harkonnen']);
|
||||
assert.deepEqual(before.order, [f.ownerEcaz, f.richese, f.entrantHark]);
|
||||
assert.deepEqual(f.staging, []);
|
||||
assert.equal(f.dealOrder.length, 4, 'The original three-seat deal gives Harkonnen its actual second card');
|
||||
let game = act(f.game, f.beneficiaryID, { type: 'decision', event: f.event, requestNoField: true,
|
||||
territory: f.territory, sector: f.sector });
|
||||
assert.equal(game.decision?.player, f.richese);
|
||||
game = act(game, f.richese, { type: 'decision', event: f.event,
|
||||
offerNoField: f.tokens.find(token => token.value === 3)!.id,
|
||||
noFieldEvent: player(game, f.richese).noFieldEvent, payer: f.richese, elite: 0 });
|
||||
assert.equal(game.decision?.player, f.beneficiaryID);
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.spice, player(before, p.id).spice);
|
||||
assert.equal(p.reserves, player(before, p.id).reserves);
|
||||
assert.deepEqual(p.forces, player(before, p.id).forces);
|
||||
}
|
||||
game = passResponses(game); game = advance(game, clean);
|
||||
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`], 3);
|
||||
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 3);
|
||||
assert.equal(player(game, f.richese).noField!.deployed, null);
|
||||
assert.equal(player(game, f.richese).noField!.lastShipped, f.tokens.find(token => token.value === 3)!.id);
|
||||
assert.equal(player(game, f.richese).spice, player(before, f.richese).spice - 1);
|
||||
assert.equal(player(game, f.beneficiaryID).spice, player(before, f.beneficiaryID).spice);
|
||||
assert.equal(player(game, f.entrantHark).spice, player(before, f.entrantHark).spice);
|
||||
assert.equal(game.players.reduce((sum, p) => sum + p.spice, 0), totalSpice - 1);
|
||||
assert.deepEqual({ active: game.active, remaining: game.movementRemaining,
|
||||
players: game.players.map(p => ({ id: p.id, shipped: p.shipped, moved: p.moved })) }, beforeAllowances);
|
||||
assert.equal(game.pendingAmbassador, null);
|
||||
game = act(game, f.entrantHark, { type: 'move', from: 'habbanya_ridge_sietch:17', amount: 1,
|
||||
territory: f.desert, sector: f.desertSector });
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game, f.entrantHark).forces[`${f.desert}:${f.desertSector}`], 1);
|
||||
game = act(game, f.entrantHark, { type: 'endMovement' });
|
||||
assert.ok(!game.movementRemaining?.includes(f.entrantHark));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
AmbassadorNoField,
|
||||
AmbassadorNoFieldChoice,
|
||||
type AmbassadorNoFieldOffer,
|
||||
} from '../components/ambassador-no-field';
|
||||
import { AmbassadorShipment } from '../components/ambassador-shipment';
|
||||
import type { Action, GameView } from '../game/engine';
|
||||
|
||||
const event = 'guild-ambassador-no-field-original';
|
||||
|
||||
function option(
|
||||
key: string,
|
||||
fields: Partial<AmbassadorNoFieldOffer['options'][number]> = {},
|
||||
): AmbassadorNoFieldOffer['options'][number] {
|
||||
return {
|
||||
key,
|
||||
label: `Concealed No-Field ${key} · Arrakeen sector 10 · free`,
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
action: {
|
||||
type: 'decision', event, amount: 1, noField: `richese-${key}`,
|
||||
territory: 'arrakeen', sector: 10,
|
||||
},
|
||||
blocked: null,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function offer(stage: AmbassadorNoFieldOffer['stage'] = 'own'): AmbassadorNoFieldOffer {
|
||||
return {
|
||||
stage,
|
||||
policy: 'Provisional Guild Ambassador No-Field policy: a concealed native marker counts as one force and costs no spice. This independent grant does not spend ordinary shipment or movement.',
|
||||
options: [0, 3, 5].map((value) => option(String(value))),
|
||||
};
|
||||
}
|
||||
|
||||
// These component contracts deliberately use projected offers. The related
|
||||
// engine source fixtures exercise actual token custody, payment and continuation.
|
||||
function projected(noField: AmbassadorNoFieldOffer | null = offer()): GameView {
|
||||
return {
|
||||
me: 'r',
|
||||
players: [
|
||||
{ id: 'r', name: 'Richese', faction: 'richese', reserves: 10 },
|
||||
{ id: 'e', name: 'Ecaz', faction: 'ecaz', reserves: 10 },
|
||||
],
|
||||
decision: { kind: 'ecazAmbassador', player: 'r', event },
|
||||
ambassadorEntry: {
|
||||
event, stage: 'ship', effect: 'guild', beneficiary: 'r', noField,
|
||||
shipment: {
|
||||
maximum: 4, eliteReserves: 0,
|
||||
destinations: [{ territory: 'arrakeen', sector: 10, maximum: 4, blocked: null }],
|
||||
},
|
||||
},
|
||||
} as unknown as GameView;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(AmbassadorShipment, {
|
||||
game, busy, act() {},
|
||||
}));
|
||||
}
|
||||
|
||||
function click(node: ReactNode, selector: string): boolean {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{
|
||||
children?: ReactNode;
|
||||
onClick?: () => void;
|
||||
'data-testid'?: string;
|
||||
}>(child)) continue;
|
||||
if (child.props['data-testid'] === selector && child.props.onClick) {
|
||||
child.props.onClick();
|
||||
return true;
|
||||
}
|
||||
if (click(child.props.children, selector)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function choose(
|
||||
current: AmbassadorNoFieldOffer,
|
||||
selectedKey: string,
|
||||
busy = false,
|
||||
decline = false,
|
||||
): Action | undefined {
|
||||
let action: Action | undefined;
|
||||
const tree = AmbassadorNoFieldChoice({
|
||||
offer: current, event, selectedKey, busy,
|
||||
onSelect() {}, act(value) { action = value; },
|
||||
});
|
||||
click(tree, decline ? 'ambassador-no-field-decline' : 'ambassador-no-field-submit');
|
||||
return action;
|
||||
}
|
||||
|
||||
void test('native marker controls retain the separate physical grant and submit only the complete projected token action', () => {
|
||||
const current = offer();
|
||||
const html = markup(projected(current));
|
||||
assert.match(html, /aria-label="Ambassador No-Field shipment"/);
|
||||
assert.match(html, /Provisional Guild Ambassador No-Field policy/);
|
||||
assert.match(html, /does not spend ordinary shipment or movement/);
|
||||
for (const value of [0, 3, 5]) assert.match(html, new RegExp(`Concealed No-Field ${value}`));
|
||||
assert.match(html, /Selected: 4 physical forces, including 0 elites/);
|
||||
assert.match(html, /Ship selected forces for free/);
|
||||
assert.match(html, /Send no forces/);
|
||||
assert.doesNotMatch(html, /No-Field substitution.*unfinished/);
|
||||
for (const selected of current.options) {
|
||||
const action = choose(current, selected.key);
|
||||
assert.deepEqual(action, selected.action);
|
||||
assert.equal(action?.amount, 1);
|
||||
assert.equal(action?.elite, undefined);
|
||||
assert.equal(action?.smuggler, undefined);
|
||||
assert.equal(action?.type, 'decision');
|
||||
}
|
||||
});
|
||||
|
||||
void test('zero reserves still expose marker destinations without inventing physical forces', () => {
|
||||
const game = projected();
|
||||
game.players[0].reserves = 0;
|
||||
game.ambassadorEntry!.shipment!.maximum = 0;
|
||||
game.ambassadorEntry!.shipment!.destinations = [];
|
||||
const html = markup(game);
|
||||
assert.match(html, /value="arrakeen" selected=""/);
|
||||
assert.match(html, /value="arrakeen:10" selected=""/);
|
||||
assert.match(html, /Concealed No-Field 0/);
|
||||
assert.match(html, /Selected: 0 physical forces, including 0 elites/);
|
||||
assert.match(html, /You have no reserve forces available for this shipment/);
|
||||
assert.match(html, /Send no forces/);
|
||||
});
|
||||
|
||||
void test('an existing native marker can be revealed without choosing or carrying a destination', () => {
|
||||
const current = offer();
|
||||
current.options = [option('reveal', {
|
||||
label: 'Reveal existing No-Field', territory: undefined, sector: undefined,
|
||||
action: { type: 'decision', event, revealNoField: true },
|
||||
})];
|
||||
const selection = AmbassadorNoField({
|
||||
game: projected(current), busy: false, act() {}, destination: 'hagga_basin', sector: 12,
|
||||
});
|
||||
assert.ok(selection);
|
||||
assert.equal(selection.props.offer.options.length, 1);
|
||||
assert.deepEqual(choose(current, 'reveal'), current.options[0].action);
|
||||
});
|
||||
|
||||
void test('allied request hides token values and both private decision stages replace the physical form', () => {
|
||||
const request = offer('request');
|
||||
request.policy = 'Request a whole-grant No-Field substitution from your actual Richese ally. The ally chooses its private marker before you consent.';
|
||||
request.options = [option('request-arrakeen', {
|
||||
label: 'Ask Richese for a No-Field in Arrakeen sector 10',
|
||||
action: { type: 'decision', event, territory: 'arrakeen', sector: 10, requestNoField: true },
|
||||
})];
|
||||
const requestGame = projected(request);
|
||||
requestGame.me = 'e';
|
||||
requestGame.decision!.player = 'e';
|
||||
requestGame.ambassadorEntry!.beneficiary = 'e';
|
||||
const requestHtml = markup(requestGame);
|
||||
assert.match(requestHtml, /Request an allied No-Field/);
|
||||
assert.match(requestHtml, /Ask Richese for a No-Field in Arrakeen/);
|
||||
assert.doesNotMatch(requestHtml, /No-Field [035]/);
|
||||
assert.match(requestHtml, /Ship selected forces for free/);
|
||||
assert.deepEqual(choose(request, request.options[0].key), request.options[0].action);
|
||||
|
||||
for (const stage of ['supply', 'consent'] as const) {
|
||||
const current = offer(stage);
|
||||
current.policy = 'The allied marker reveals immediately: 3 reserve forces, capped at four. Mandatory transport fee: 1 spice to the Guild or bank, paid by either ally; this does not spend ordinary shipment or movement.';
|
||||
current.options = stage === 'supply'
|
||||
? [0, 3, 5].flatMap((value) => ['r', 'e'].map((payer) => option(`${stage}-${value}-${payer}`, {
|
||||
label: `Offer No-Field ${value} · ${Math.min(value, 4)} forces · ${payer === 'r' ? 'Richese' : 'Ecaz'} pays 1 spice`,
|
||||
action: { type: 'decision', event, choice: stage, token: `richese-${value}`, payer },
|
||||
})))
|
||||
: [option(`${stage}-3`, {
|
||||
label: 'Accept No-Field 3 · 3 forces · Richese pays 1 spice',
|
||||
action: { type: 'decision', event, choice: stage, token: 'richese-3', payer: 'r' },
|
||||
})];
|
||||
current.decline = { type: 'decision', event, declineNoField: true };
|
||||
current.declineLabel = 'Decline and return to the original grant';
|
||||
const game = projected(current);
|
||||
game.ambassadorEntry!.beneficiary = 'e';
|
||||
game.ambassadorEntry!.shipment = null;
|
||||
if (stage === 'consent') {
|
||||
game.me = 'e';
|
||||
game.decision!.player = 'e';
|
||||
}
|
||||
const html = markup(game);
|
||||
assert.match(html, /aria-label="Ambassador No-Field shipment"/);
|
||||
assert.match(html, /No-Field 3 · 3 forces · Richese pays 1 spice/);
|
||||
if (stage === 'supply') {
|
||||
assert.match(html, /No-Field 0 · 0 forces · Ecaz pays 1 spice/);
|
||||
assert.match(html, /No-Field 5 · 4 forces · Ecaz pays 1 spice/);
|
||||
}
|
||||
assert.match(html, /Mandatory transport fee: 1 spice to the Guild or bank/);
|
||||
assert.match(html, /Decline and return to the original grant/);
|
||||
assert.doesNotMatch(html, /Physical forces to ship|Ship selected forces for free|Send no forces/);
|
||||
for (const selected of current.options)
|
||||
assert.deepEqual(choose(current, selected.key), selected.action);
|
||||
assert.deepEqual(choose(current, '', false, true), current.decline);
|
||||
}
|
||||
});
|
||||
|
||||
void test('busy, unselected, blocked and obsolete choices cannot dispatch through their callbacks', () => {
|
||||
const current = offer('supply');
|
||||
current.options.push(option('blocked', { blocked: 'The payer cannot cover this transport fee.' }));
|
||||
current.decline = { type: 'decision', event, declineNoField: true };
|
||||
for (const selected of ['', 'obsolete', 'blocked']) assert.equal(choose(current, selected), undefined);
|
||||
assert.equal(choose(current, '3', true), undefined);
|
||||
assert.equal(choose(current, '', true, true), undefined);
|
||||
const html = renderToStaticMarkup(createElement(AmbassadorNoFieldChoice, {
|
||||
offer: current, event, selectedKey: '3', onSelect() {}, act() {}, busy: true,
|
||||
}));
|
||||
assert.equal((html.match(/<button\b[^>]*\sdisabled=""/g) ?? []).length, 2);
|
||||
assert.match(html, /The payer cannot cover this transport fee/);
|
||||
assert.equal(choose({ ...current, options: [] }, '3'), undefined);
|
||||
});
|
||||
|
||||
void test('fresh event, stage and destination projections reset selection and use only the incoming authoritative action', () => {
|
||||
const game = projected();
|
||||
const first = AmbassadorNoField({ game, busy: false, act() {}, destination: 'arrakeen', sector: 10 });
|
||||
assert.ok(first);
|
||||
const next = structuredClone(game);
|
||||
next.ambassadorEntry!.event = 'guild-ambassador-no-field-next';
|
||||
next.ambassadorEntry!.noField!.options[1].action = { type: 'decision', event: 'guild-ambassador-no-field-next', amount: 1, noField: 'new-3', territory: 'arrakeen', sector: 10 };
|
||||
const fresh = AmbassadorNoField({ game: next, busy: false, act() {}, destination: 'arrakeen', sector: 10 });
|
||||
assert.ok(fresh);
|
||||
assert.notEqual(first.key, fresh.key);
|
||||
assert.deepEqual(choose(fresh.props.offer, '3'), next.ambassadorEntry!.noField!.options[1].action);
|
||||
next.ambassadorEntry!.noField!.stage = 'supply';
|
||||
const stage = AmbassadorNoField({ game: next, busy: false, act() {} });
|
||||
assert.ok(stage);
|
||||
assert.notEqual(fresh.key, stage.key);
|
||||
const otherDestination = AmbassadorNoField({ game, busy: false, act() {}, destination: 'hagga_basin', sector: 12 });
|
||||
assert.ok(otherDestination);
|
||||
assert.notEqual(first.key, otherDestination.key);
|
||||
assert.equal(otherDestination.props.offer.options.length, 0);
|
||||
assert.equal(choose(otherDestination.props.offer, '3'), undefined);
|
||||
});
|
||||
|
||||
void test('only the current Ambassador decision actor gets controls, even if an obsolete projection remains', () => {
|
||||
const game = projected();
|
||||
game.decision!.player = 'e';
|
||||
assert.equal(markup(game), '');
|
||||
assert.equal(AmbassadorNoField({ game, busy: false, act() {} }), null);
|
||||
const ended = projected();
|
||||
ended.ambassadorEntry!.stage = 'move';
|
||||
assert.equal(markup(ended), '');
|
||||
const noOffer = projected(null);
|
||||
assert.doesNotMatch(markup(noOffer), /Ambassador No-Field shipment/);
|
||||
assert.match(markup(noOffer), /Send no forces/);
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actGuildAmbassadorNoField as act, advanceGuildAmbassadorNoField as advance,
|
||||
createGuildAmbassadorNoFieldFixture as fixture, guildAmbassadorNoFieldClean as clean,
|
||||
guildAmbassadorNoFieldInventory as inventory, guildAmbassadorNoFieldPlayer as player,
|
||||
passGuildAmbassadorNoFieldResponses as passResponses,
|
||||
type GuildAmbassadorNoFieldFixture,
|
||||
} from './fixture-guild-ambassador-no-field';
|
||||
|
||||
function allowances(game: Game) {
|
||||
return { active: game.active, order: game.order, remaining: game.movementRemaining,
|
||||
players: game.players.map(p => ({ id: p.id, shipped: p.shipped, moved: p.moved })) };
|
||||
}
|
||||
function material(game: Game) {
|
||||
return game.players.map(p => ({ id: p.id, spice: p.spice, reserves: p.reserves, forces: p.forces,
|
||||
noField: p.noField, noFieldEvent: p.noFieldEvent, noFieldBlockedTurn: p.noFieldBlockedTurn }));
|
||||
}
|
||||
function nativeAction(f: GuildAmbassadorNoFieldFixture, value: 0 | 3 | 5, extra: Partial<Action> = {}): Action {
|
||||
const token = f.tokens.find(token => token.value === value)!;
|
||||
return { type: 'decision', event: f.event, amount: 1, noField: token.id,
|
||||
noFieldEvent: player(f.game, f.richese).noFieldEvent,
|
||||
territory: f.territory, sector: f.sector, ...extra };
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action, reason?: RegExp) {
|
||||
const before = structuredClone(game), submitted = structuredClone(action);
|
||||
if (reason) assert.throws(() => act(game, actor, action), reason);
|
||||
else assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before); assert.deepEqual(action, submitted);
|
||||
}
|
||||
function finishEntrant(f: GuildAmbassadorNoFieldFixture, game: Game) {
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.pendingAmbassador, null);
|
||||
assert.equal(game.active, f.entrantHark);
|
||||
assert.equal(player(game, f.entrantHark).forces['habbanya_ridge_sietch:17'], 1);
|
||||
game = act(game, f.entrantHark, { type: 'move', from: 'habbanya_ridge_sietch:17', amount: 1,
|
||||
territory: f.desert, sector: f.desertSector });
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game, f.entrantHark).forces[`${f.desert}:${f.desertSector}`], 1);
|
||||
game = act(game, f.entrantHark, { type: 'endMovement' });
|
||||
game = advance(game, state => state.active === f.guild && clean(state));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('genuine Basic Guild marker source uses native deal, paid placements, accepted Ecaz alliance and paid entrant', () => {
|
||||
for (const beneficiary of ['own', 'allied'] as const) {
|
||||
const f = fixture({ beneficiary }), g = f.game;
|
||||
assert.deepEqual(f.staging, []);
|
||||
assert.equal(g.advanced, false); assert.deepEqual(g.expansions, ['ecaz', 'choam']);
|
||||
assert.ok(!g.homeworlds && !g.nexusCards && !g.leaderSkills && !g.techTokens &&
|
||||
!g.strongholdCards && !g.discoveryEnabled && !g.ecazTreachery);
|
||||
assert.equal(g.status, 'playing'); assert.equal(g.turn, 1); assert.equal(g.phase, 5);
|
||||
assert.ok(g.pendingAmbassador); assert.equal(g.pendingAmbassador.stage, 'ship');
|
||||
assert.equal(g.pendingAmbassador.effect, 'guild');
|
||||
assert.equal(g.pendingAmbassador.entrant, f.entrantHark);
|
||||
assert.equal(g.pendingAmbassador.beneficiary, f.beneficiaryID);
|
||||
assert.equal(g.decision?.player, f.beneficiaryID);
|
||||
assert.deepEqual(g.order, [f.ownerEcaz, f.richese, f.entrantHark, f.guild]);
|
||||
assert.equal(player(g, f.ownerEcaz).ally, f.richese); assert.equal(player(g, f.richese).ally, f.ownerEcaz);
|
||||
assert.equal(player(g, f.ownerEcaz).shipped, true); assert.equal(player(g, f.richese).shipped, true);
|
||||
assert.equal(player(g, f.entrantHark).shipped, true); assert.equal(player(g, f.guild).shipped, false);
|
||||
assert.equal(player(g, f.richese).forces['carthag:11'], 1);
|
||||
assert.equal(player(g, f.ownerEcaz).forces['polar_sink:0'], 1);
|
||||
assert.equal(player(g, f.entrantHark).forces['habbanya_ridge_sietch:17'], 1);
|
||||
assert.equal(g.ecazAmbassadors!.tokens.find(token => token.effect === 'ecaz')!.zone, 'supply');
|
||||
assert.equal(g.ecazAmbassadors!.tokens.find(token => token.effect === 'guild')!.zone, 'used');
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.richese && step.action.accept === true));
|
||||
assert.ok(f.alliance.steps.some(step => step.actor === f.ownerEcaz && step.action.loan === false));
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
}
|
||||
});
|
||||
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
void test(`native Basic Richese Guild grant conceals original ${value} for free without spending its ordinary allowance`, () => {
|
||||
const f = fixture({ beneficiary: 'own', karama: true }), before = structuredClone(f.game);
|
||||
const token = f.tokens.find(token => token.value === value)!;
|
||||
let game = act(f.game, f.richese, nativeAction(f, value));
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = passResponses(game);
|
||||
assert.deepEqual(player(game, f.richese).noField!.deployed, {
|
||||
tokenId: token.id, controller: f.richese, location: { territory: f.territory, sector: f.sector },
|
||||
});
|
||||
assert.equal(player(game, f.richese).noField!.lastShipped, token.id);
|
||||
assert.equal(player(game, f.richese).reserves, player(before, f.richese).reserves);
|
||||
assert.equal(player(game, f.richese).forces[`${f.territory}:${f.sector}`] ?? 0, 0);
|
||||
for (const p of game.players) assert.equal(p.spice, player(before, p.id).spice);
|
||||
assert.deepEqual(allowances(game), allowances(before));
|
||||
const publicMarker = viewGame(game, f.entrantHark).players.find(p => p.id === f.richese)!.noField!.deployed!;
|
||||
assert.equal(publicMarker.effectiveForces, 1);
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
finishEntrant(f, game);
|
||||
});
|
||||
}
|
||||
|
||||
function requestAllied(f: GuildAmbassadorNoFieldFixture, game = f.game, desert = false) {
|
||||
return act(game, f.beneficiaryID, { type: 'decision', event: f.event, requestNoField: true,
|
||||
territory: desert ? f.desert : f.territory, sector: desert ? f.desertSector : f.sector });
|
||||
}
|
||||
function offerAllied(f: GuildAmbassadorNoFieldFixture, game: Game, value: 0 | 3 | 5,
|
||||
payer = f.richese) {
|
||||
return act(game, f.richese, { type: 'decision', event: f.event,
|
||||
offerNoField: f.tokens.find(token => token.value === value)!.id,
|
||||
noFieldEvent: player(game, f.richese).noFieldEvent, payer, elite: 0 });
|
||||
}
|
||||
|
||||
void test('a five-marker materializes four available allied counters without inventing the missing fifth', () => {
|
||||
const f = fixture({ beneficiary: 'allied' });
|
||||
// Controlled prior reserve shortage after the genuine grant: move existing
|
||||
// Ecaz counters to its original setup location, without changing total custody.
|
||||
const source = structuredClone(f.game), recipient = player(source,f.beneficiaryID);
|
||||
recipient.forces['imperial_basin:9'] += recipient.reserves - 4;
|
||||
recipient.reserves = 4;
|
||||
const before = structuredClone(source);
|
||||
let game = requestAllied(f,source);
|
||||
game = offerAllied(f,game,5,f.richese);
|
||||
game = act(game,f.beneficiaryID,{type:'decision',event:f.event,acceptNoField:true});
|
||||
game = passResponses(game);
|
||||
assert.equal(player(game,f.beneficiaryID).reserves,0);
|
||||
assert.equal(player(game,f.beneficiaryID).forces[`${f.territory}:${f.sector}`],4);
|
||||
assert.equal(player(game,f.richese).noField!.lastShipped,f.tokens.find(token => token.value === 5)!.id);
|
||||
assert.deepEqual(allowances(game),allowances(before));
|
||||
for (const p of game.players) assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum,n) => sum+n,0),20);
|
||||
finishEntrant(f,game);
|
||||
});
|
||||
|
||||
void test('native No-Field remains available when the physical four-force grant has no reserve counter', () => {
|
||||
const f = fixture({ beneficiary: 'own' });
|
||||
// Controlled prior deployment conserves all original counters; the new
|
||||
// concealed marker itself withdraws none and may later reveal zero.
|
||||
const source = structuredClone(f.game), richese = player(source,f.richese);
|
||||
richese.forces['carthag:11'] += richese.reserves;
|
||||
richese.reserves = 0;
|
||||
const view = viewGame(source,f.richese);
|
||||
assert.equal(view.ambassadorEntry?.shipment?.maximum,0);
|
||||
assert.ok(view.ambassadorEntry?.noField?.options.some(option => option.action.noField === f.tokens.find(token => token.value === 5)!.id));
|
||||
let game = act(source,f.richese,nativeAction(f,5));
|
||||
game = passResponses(game);
|
||||
assert.equal(player(game,f.richese).reserves,0);
|
||||
assert.equal(player(game,f.richese).forces['carthag:11'],20);
|
||||
assert.equal(player(game,f.richese).noField!.deployed!.tokenId,f.tokens.find(token => token.value === 5)!.id);
|
||||
finishEntrant(f,game);
|
||||
});
|
||||
|
||||
void test('original ordinary starting hands automatically allow both Guild marker declarations without a pending Karama responder', () => {
|
||||
for (const beneficiary of ['own', 'allied'] as const) {
|
||||
const f = fixture({ beneficiary }), before = structuredClone(f.game);
|
||||
assert.ok(f.game.players.every(p => p.hand.every(card => card.effect !== 'karama')));
|
||||
let game = beneficiary === 'own' ? act(f.game, f.richese, nativeAction(f, 3)) : requestAllied(f);
|
||||
if (beneficiary === 'allied') {
|
||||
game = offerAllied(f, game, 3, f.beneficiaryID);
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
|
||||
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`], 3);
|
||||
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 3);
|
||||
assert.equal(player(game, f.richese).noField!.deployed, null);
|
||||
} else {
|
||||
assert.equal(player(game, f.richese).noField!.deployed!.tokenId, f.tokens.find(token => token.value === 3)!.id);
|
||||
assert.equal(player(game, f.richese).reserves, player(before, f.richese).reserves);
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
assert.deepEqual(allowances(game), allowances(before));
|
||||
finishEntrant(f, game);
|
||||
}
|
||||
});
|
||||
|
||||
for (const desert of [false, true]) {
|
||||
for (const payee of ['supplier', 'recipient', ...(desert ? ['both'] : [])]) {
|
||||
void test(`allied Basic three-marker ${desert ? 'desert' : 'stronghold'} grant charges the printed fee to ${payee}`, () => {
|
||||
const f = fixture({ beneficiary: 'allied', karama: true }), before = structuredClone(f.game);
|
||||
const payer = payee === 'supplier' ? f.richese : payee === 'recipient' ? f.beneficiaryID : 'both';
|
||||
let game = requestAllied(f, f.game, desert);
|
||||
assert.equal(game.decision?.player, f.richese);
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = offerAllied(f, game, 3, payer);
|
||||
assert.equal(game.decision?.player, f.beneficiaryID);
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = passResponses(game);
|
||||
const cost = desert ? 2 : 1, ownerPayment = payee === 'supplier' ? cost : payee === 'both' ? 1 : 0;
|
||||
const recipientPayment = cost - ownerPayment;
|
||||
const target = desert ? `${f.desert}:${f.desertSector}` : `${f.territory}:${f.sector}`;
|
||||
assert.equal(player(game, f.beneficiaryID).forces[target], 3);
|
||||
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 3);
|
||||
assert.equal(player(game, f.richese).reserves, player(before, f.richese).reserves);
|
||||
assert.equal(player(game, f.richese).noField!.deployed, null);
|
||||
assert.equal(player(game, f.richese).noField!.lastShipped, f.tokens.find(token => token.value === 3)!.id);
|
||||
assert.equal(player(game, f.richese).spice, player(before, f.richese).spice - ownerPayment);
|
||||
assert.equal(player(game, f.beneficiaryID).spice, player(before, f.beneficiaryID).spice - recipientPayment);
|
||||
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice + cost);
|
||||
assert.deepEqual(allowances(game), allowances(before));
|
||||
assert.deepEqual(inventory(game), f.originalInventory);
|
||||
finishEntrant(f, game);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void test('actual prior native three-marker must be explicitly revealed before a different Guild marker is declared', () => {
|
||||
const f = fixture({ beneficiary: 'own', priorMarker: true });
|
||||
const original = structuredClone(f.game), richese = player(original, f.richese);
|
||||
assert.deepEqual(richese.noField!.deployed, {
|
||||
tokenId: f.priorToken.id, controller: f.richese, location: { territory: 'carthag', sector: 11 },
|
||||
});
|
||||
reject(f.game, f.richese, nativeAction(f, 5), /reveal/i);
|
||||
let game = act(f.game, f.richese, { type: 'decision', event: f.event, revealNoField: true });
|
||||
assert.equal(game.pendingAmbassador?.stage, 'ship'); assert.equal(game.decision?.player, f.richese);
|
||||
assert.equal(player(game, f.richese).noField!.deployed, null);
|
||||
assert.equal(player(game, f.richese).forces['carthag:11'], 3);
|
||||
assert.equal(player(game, f.richese).reserves, richese.reserves - 3);
|
||||
assert.equal(player(game, f.richese).noField!.lastShipped, f.priorToken.id);
|
||||
assert.notEqual(player(game, f.richese).noFieldEvent, richese.noFieldEvent);
|
||||
assert.deepEqual(allowances(game), allowances(original));
|
||||
reject(game, f.richese, nativeAction(f, 5), /event|stale/i);
|
||||
reject(game, f.richese, nativeAction(f, 3, { noFieldEvent: player(game, f.richese).noFieldEvent }), /twice|same|row/i);
|
||||
const revealed = structuredClone(game);
|
||||
game = act(game, f.richese, nativeAction(f, 5, { noFieldEvent: player(game, f.richese).noFieldEvent }));
|
||||
game = passResponses(game);
|
||||
assert.equal(player(game, f.richese).noField!.deployed!.tokenId, f.tokens.find(token => token.value === 5)!.id);
|
||||
assert.equal(player(game, f.richese).reserves, player(revealed, f.richese).reserves);
|
||||
assert.equal(player(game, f.richese).spice, richese.spice);
|
||||
assert.deepEqual(allowances(game), allowances(original));
|
||||
finishEntrant(f, game);
|
||||
});
|
||||
|
||||
void test('allied zero grant reveals the actual older supplier marker only after recipient consent and neutral prevention passes', () => {
|
||||
const f = fixture({ beneficiary: 'allied', priorMarker: true, karama: true }), before = structuredClone(f.game);
|
||||
let game = requestAllied(f);
|
||||
game = offerAllied(f, game, 0, f.beneficiaryID);
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = passResponses(game);
|
||||
assert.equal(player(game, f.richese).forces['carthag:11'], 3);
|
||||
assert.equal(player(game, f.richese).reserves, player(before, f.richese).reserves - 3);
|
||||
assert.equal(player(game, f.richese).noField!.deployed, null);
|
||||
assert.equal(player(game, f.richese).noField!.lastShipped, f.tokens.find(token => token.value === 0)!.id);
|
||||
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves);
|
||||
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`] ?? 0, 0);
|
||||
assert.equal(player(game, f.beneficiaryID).spice, player(before, f.beneficiaryID).spice - 1);
|
||||
assert.equal(player(game, f.guild).spice, player(before, f.guild).spice + 1);
|
||||
assert.deepEqual(allowances(game), allowances(before));
|
||||
finishEntrant(f, game);
|
||||
});
|
||||
|
||||
for (const who of ['supplier', 'recipient'] as const) {
|
||||
void test(`allied Guild substitution ${who} refusal returns the same free physical grant without payment or token changes`, () => {
|
||||
const f = fixture({ beneficiary: 'allied' }), before = structuredClone(f.game);
|
||||
let game = requestAllied(f);
|
||||
if (who === 'recipient') game = offerAllied(f, game, 3);
|
||||
game = act(game, who === 'supplier' ? f.richese : f.beneficiaryID,
|
||||
{ type: 'decision', event: f.event, declineNoField: true });
|
||||
assert.equal(game.decision?.player, f.beneficiaryID);
|
||||
assert.ok(game.pendingAmbassador); assert.equal(game.pendingAmbassador.event, f.event);
|
||||
assert.equal(game.pendingAmbassador.stage, 'ship');
|
||||
assert.deepEqual(material(game), material(before)); assert.deepEqual(allowances(game), allowances(before));
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, amount: 4,
|
||||
territory: f.territory, sector: f.sector, elite: 0 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`], 4);
|
||||
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 4);
|
||||
for (const p of game.players) assert.equal(p.spice, player(before, p.id).spice);
|
||||
assert.deepEqual(player(game, f.richese).noField, player(before, f.richese).noField);
|
||||
assert.deepEqual(allowances(game), allowances(before)); finishEntrant(f, game);
|
||||
});
|
||||
}
|
||||
|
||||
void test('allied five cannot enlarge the four-force grant and malformed native mixtures preserve the live opportunity', () => {
|
||||
const allied = fixture({ beneficiary: 'allied' });
|
||||
const requested = requestAllied(allied);
|
||||
assert.ok(player(requested, allied.beneficiaryID).reserves > 4);
|
||||
reject(requested, allied.richese, { type: 'decision', event: allied.event,
|
||||
offerNoField: allied.tokens.find(token => token.value === 5)!.id,
|
||||
noFieldEvent: player(requested, allied.richese).noFieldEvent, payer: allied.richese, elite: 0 }, /four|4/i);
|
||||
const own = fixture({ beneficiary: 'own' });
|
||||
for (const extra of [{ amount: 4 }, { elite: 1 }, { allyPayment: 1 }, { noFieldEvent: 'stale-selection' },
|
||||
{ event: 'stale-parent' }, { alliedNoField: true }])
|
||||
reject(own.game, own.richese, nativeAction(own, 3, extra));
|
||||
reject(own.game, own.entrantHark, nativeAction(own, 3));
|
||||
let game = act(own.game, own.richese, nativeAction(own, 3));
|
||||
game = passResponses(game); finishEntrant(own, game);
|
||||
});
|
||||
|
||||
for (const beneficiary of ['own', 'allied'] as const) {
|
||||
void test(`actual held Karama prevents the ${beneficiary} Guild marker only, preserving ordinary shipment state and physical fallback`, () => {
|
||||
const f = fixture({ beneficiary, karama: true }), before = structuredClone(f.game);
|
||||
assert.ok(f.karama); assert.ok(player(f.game, f.entrantHark).hand.some(card => card.id === f.karama));
|
||||
let game = beneficiary === 'own' ? act(f.game, f.richese, nativeAction(f, 3)) : requestAllied(f);
|
||||
if (beneficiary === 'allied') {
|
||||
game = offerAllied(f, game, 3, f.beneficiaryID);
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, acceptNoField: true });
|
||||
}
|
||||
assert.equal(game.response?.kind, 'richeseNoField');
|
||||
assert.deepEqual(material(game), material(before));
|
||||
game = act(game, f.entrantHark, { type: 'card', mode: 'cancel', card: f.karama });
|
||||
game = advance(game, state => !state.response && !state.pendingTreacheryDiscard);
|
||||
assert.deepEqual(material(game), material(before)); assert.deepEqual(allowances(game), allowances(before));
|
||||
assert.ok(game.pendingAmbassador); assert.equal(game.pendingAmbassador.event, f.event);
|
||||
assert.equal(game.pendingAmbassador.stage, 'ship');
|
||||
assert.equal(game.decision?.player, f.beneficiaryID);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
assert.equal(player(game, f.richese).noFieldBlockedTurn, player(before, f.richese).noFieldBlockedTurn);
|
||||
assert.deepEqual(game.richeseAllyBlocked, before.richeseAllyBlocked);
|
||||
if (beneficiary === 'own') reject(game, f.richese, nativeAction(f, 3));
|
||||
else reject(game, f.beneficiaryID, { type: 'decision', event: f.event, requestNoField: true,
|
||||
territory: f.territory, sector: f.sector });
|
||||
game = act(game, f.beneficiaryID, { type: 'decision', event: f.event, amount: 1,
|
||||
territory: f.territory, sector: f.sector, elite: 0 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game, f.beneficiaryID).forces[`${f.territory}:${f.sector}`], 1);
|
||||
assert.equal(player(game, f.beneficiaryID).reserves, player(before, f.beneficiaryID).reserves - 1);
|
||||
assert.deepEqual(allowances(game), allowances(before)); finishEntrant(f, game);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { MOBILE_STRONGHOLD, TERRITORIES } from '../game/board';
|
||||
import {
|
||||
createRicheseNoField,
|
||||
deployRicheseNoField,
|
||||
} from '../game/richese-no-field';
|
||||
import {
|
||||
GuildAmbassadorMarkerError,
|
||||
markerDestinations,
|
||||
markerOwner,
|
||||
quoteGuildAmbassadorMarker as quote,
|
||||
type GuildAmbassadorMarkerSelection,
|
||||
} from '../game/guild-ambassador-no-field';
|
||||
|
||||
function fixture(faction: FactionId = 'ecaz', advanced = false) {
|
||||
const g = createGame('GUILDAMBMARKER', newPlayer('p', 'Recipient', faction), advanced);
|
||||
g.players.push(newPlayer('q', 'Other', 'atreides'),
|
||||
newPlayer('r', 'Supplier', faction === 'richese' ? 'ecaz' : 'richese'));
|
||||
Object.assign(g, { status: 'playing', turn: 2, phase: 5, storm: 18,
|
||||
active: 'q', order: ['p', 'q', 'r'] });
|
||||
for (const player of g.players)
|
||||
Object.assign(player, { forces: {}, reserves: 20, tanks: 0, spice: 5,
|
||||
elites: undefined, hand: [], shipped: true, moved: 2 });
|
||||
const owner = g.players.find((p) => p.faction === 'richese')!;
|
||||
owner.noField = createRicheseNoField(['token0', 'token3', 'token5']);
|
||||
owner.noFieldEvent = 'selection-event';
|
||||
if (owner.id !== 'p') {
|
||||
owner.ally = 'p';
|
||||
g.players[0].ally = owner.id;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function selection(extra: Partial<GuildAmbassadorMarkerSelection> = {}): GuildAmbassadorMarkerSelection {
|
||||
return { tokenId: 'token3', tokenEvent: 'selection-event',
|
||||
territory: 'sietch_tabr', sector: 14, ...extra };
|
||||
}
|
||||
function rejected(g: Game, chosen = selection(), checkFunds = true) {
|
||||
const before = structuredClone(g), previous = structuredClone(chosen);
|
||||
assert.throws(() => quote(g, 'p', chosen, checkFunds), GuildAmbassadorMarkerError);
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(chosen, previous);
|
||||
}
|
||||
function priorMarker(g: Game, value: 0 | 3 | 5,
|
||||
territory = 'red_chasm', sector = 7) {
|
||||
const owner = markerOwner(g, 'p')!;
|
||||
owner.noField = deployRicheseNoField(owner.noField!, {
|
||||
tokenId: `token${value}`, controller: owner.id,
|
||||
location: { territory, sector },
|
||||
});
|
||||
}
|
||||
|
||||
void test('native zero three and five substitute one free concealed marker with no reserve withdrawal', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const reserves of [0, 2, 20])
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
const g = fixture('richese', advanced), p = g.players[0];
|
||||
p.reserves = reserves;
|
||||
p.spice = 0;
|
||||
const chosen = selection({ tokenId: `token${value}` }), before = structuredClone(g);
|
||||
const result = quote(g, 'p', chosen);
|
||||
assert.deepEqual({ ...result, marker: { ...result.marker, stateSignature: '' } }, {
|
||||
player: 'p', amount: 1, elite: 0, territory: 'sietch_tabr', sector: 14,
|
||||
advisors: false, cost: 0, allyPayment: 0,
|
||||
marker: { mode: 'own', owner: 'p', tokenId: `token${value}`,
|
||||
tokenEvent: 'selection-event', value, payer: 'p', ownerPayment: 0,
|
||||
recipientPayment: 0, stateSignature: '' },
|
||||
});
|
||||
assert.ok(result.marker.stateSignature);
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(markerOwner(g, 'p'), p);
|
||||
assert.ok(markerDestinations(g, 'p').some((to) => to.territory === 'sietch_tabr'));
|
||||
}
|
||||
});
|
||||
|
||||
void test('allied materialization uses the exact reserve cap and rejects five actual counters instead of truncating', () => {
|
||||
for (const value of [0, 3, 5] as const)
|
||||
for (const reserves of [0, 1, 2, 3, 4, 5, 20]) {
|
||||
const g = fixture();
|
||||
g.players[0].reserves = reserves;
|
||||
const chosen = selection({ tokenId: `token${value}` });
|
||||
if (Math.min(value, reserves) > 4) {
|
||||
rejected(g, chosen);
|
||||
} else {
|
||||
const before = structuredClone(g), result = quote(g, 'p', chosen);
|
||||
assert.equal(result.amount, Math.min(value, reserves));
|
||||
assert.equal(result.marker.value, value);
|
||||
assert.equal(result.marker.mode, 'allied');
|
||||
assert.equal(result.marker.owner, 'r');
|
||||
assert.equal(result.cost, 1);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('allied mandatory fees permit either full payer or only the exact two-spice equal split', () => {
|
||||
for (const faction of ['ecaz', 'guild'] as const)
|
||||
for (const stronghold of [false, true])
|
||||
for (const payer of ['p', 'r', 'both']) {
|
||||
const g = fixture(faction);
|
||||
// The independent grant keeps the compulsory printed transport fee,
|
||||
// including an already purchased rate or the Guild's native rate.
|
||||
Object.assign(g, { karamaShipping: { player: 'p' } });
|
||||
const chosen = selection({ payer,
|
||||
territory: stronghold ? 'sietch_tabr' : 'red_chasm',
|
||||
sector: stronghold ? 14 : 7 });
|
||||
if (stronghold && payer === 'both') {
|
||||
rejected(g, chosen);
|
||||
continue;
|
||||
}
|
||||
const result = quote(g, 'p', chosen), cost = stronghold ? 1 : 2;
|
||||
assert.equal(result.cost, cost);
|
||||
assert.equal(result.marker.ownerPayment,
|
||||
payer === 'r' ? cost : payer === 'both' ? 1 : 0);
|
||||
assert.equal(result.marker.recipientPayment,
|
||||
payer === 'p' ? cost : payer === 'both' ? 1 : 0);
|
||||
assert.equal(result.allyPayment, 0);
|
||||
}
|
||||
rejected(fixture(), selection({ payer: 'q' }));
|
||||
rejected(fixture('richese'), selection({ payer: 'r' }));
|
||||
const g = fixture();
|
||||
g.players[2].spice = 0;
|
||||
rejected(g);
|
||||
assert.equal(quote(g, 'p', selection({ payer: 'p' })).marker.recipientPayment, 1);
|
||||
g.players[0].spice = 0;
|
||||
rejected(g, selection({ payer: 'p' }));
|
||||
assert.equal(quote(g, 'p', selection({ payer: 'p' }), false).cost, 1);
|
||||
rejected(g, selection({ payer: 'r' }), false);
|
||||
});
|
||||
|
||||
void test('allied typed reserves are selected within the real amount while native markers always have zero elites', () => {
|
||||
const g = fixture('emperor'), p = g.players[0];
|
||||
p.reserves = 4;
|
||||
p.elites = { reserves: 3, tanks: 0, forces: {}, revived: 0 };
|
||||
assert.equal(quote(g, 'p', selection()).elite, 2);
|
||||
assert.equal(quote(g, 'p', selection({ elite: 3 })).elite, 3);
|
||||
assert.equal(quote(g, 'p', selection({ tokenId: 'token5' })).elite, 3);
|
||||
for (const elite of [0, 1, 4]) rejected(g, selection({ elite }));
|
||||
assert.equal(quote(g, 'p', selection({ tokenId: 'token0' })).elite, 0);
|
||||
rejected(g, selection({ tokenId: 'token0', elite: 1 }));
|
||||
rejected(fixture('richese'), selection({ elite: 1 }));
|
||||
});
|
||||
|
||||
void test('native prior markers require an explicit reveal and shared last-token history remains binding', () => {
|
||||
const g = fixture('richese');
|
||||
priorMarker(g, 3);
|
||||
rejected(g, selection({ tokenId: 'token5' }));
|
||||
assert.throws(() => markerDestinations(g, 'p'), GuildAmbassadorMarkerError);
|
||||
g.players[0].noField!.deployed = null;
|
||||
rejected(g, selection());
|
||||
assert.equal(quote(g, 'p', selection({ tokenId: 'token5' })).amount, 1);
|
||||
g.players[0].noField!.hiddenReturn = true;
|
||||
rejected(g, selection());
|
||||
assert.equal(quote(g, 'p', selection({ tokenId: 'token0' })).marker.value, 0);
|
||||
rejected(fixture(), selection({ tokenEvent: 'old-selection-event' }));
|
||||
rejected(fixture(), selection({ tokenId: 'unknown-token' }));
|
||||
});
|
||||
|
||||
void test('allied prior reveal uses the supplier stock before checking actual destination occupancy', () => {
|
||||
for (const [oldValue, ownerReserves, allowed] of [
|
||||
[0, 20, true], [3, 0, true], [3, 1, false], [5, 2, false],
|
||||
] as const) {
|
||||
const g = fixture('emperor'), owner = g.players[2];
|
||||
g.players[0].reserves = 4;
|
||||
owner.reserves = ownerReserves;
|
||||
priorMarker(g, oldValue, 'sietch_tabr', 14);
|
||||
const chosen = selection({ tokenId: oldValue === 5 ? 'token3' : 'token5' });
|
||||
if (allowed) {
|
||||
const before = structuredClone(g), result = quote(g, 'p', chosen);
|
||||
assert.equal(result.amount, 4);
|
||||
assert.ok(markerDestinations(g, 'p').some((to) => to.territory === 'sietch_tabr'));
|
||||
assert.deepEqual(g, before);
|
||||
} else {
|
||||
rejected(g, chosen);
|
||||
assert.ok(!markerDestinations(g, 'p').some((to) => to.territory === 'sietch_tabr'));
|
||||
}
|
||||
assert.equal(owner.noField!.deployed!.tokenId, `token${oldValue}`);
|
||||
assert.equal(owner.reserves, ownerReserves);
|
||||
}
|
||||
const g = fixture();
|
||||
priorMarker(g, 3);
|
||||
rejected(g); // A compulsory reveal never clears the shared last-shipped ban.
|
||||
const before = structuredClone(g);
|
||||
assert.equal(quote(g, 'p', selection({ tokenId: 'token0' })).amount, 0);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('a No-Field recipient must be Richese or its current reciprocal off-planet ally', () => {
|
||||
const mutations: ((g: Game) => void)[] = [
|
||||
(g) => { g.players[0].ally = null; },
|
||||
(g) => { g.players[2].ally = null; },
|
||||
(g) => { g.players[0].ally = 'q'; },
|
||||
(g) => { g.players[0].faction = 'fremen'; },
|
||||
(g) => { g.players[2].faction = 'harkonnen'; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const g = fixture();
|
||||
mutate(g);
|
||||
rejected(g);
|
||||
}
|
||||
assert.equal(markerOwner(fixture(), 'q'), null);
|
||||
assert.equal(markerOwner(fixture(), 'missing'), null);
|
||||
});
|
||||
|
||||
void test('printed sectors, storm, mobile-stronghold exclusion and occupying sides apply to markers', () => {
|
||||
const g = fixture('richese');
|
||||
g.mobileStronghold = { location: 'wind_pass:14' };
|
||||
g.storm = 14;
|
||||
for (const to of [
|
||||
{ territory: 'sietch_tabr', sector: 14 },
|
||||
{ territory: 'wind_pass', sector: 14 },
|
||||
{ territory: 'sietch_tabr', sector: 15 },
|
||||
{ territory: MOBILE_STRONGHOLD, sector: 0 },
|
||||
{ territory: 'homeworld', sector: 0 },
|
||||
{ territory: 'discovery', sector: 0 },
|
||||
]) rejected(g, selection(to));
|
||||
assert.equal(quote(g, 'p', selection({ territory: 'wind_pass', sector: 15 })).amount, 1);
|
||||
assert.equal(quote(g, 'p', selection({ territory: 'polar_sink', sector: 0 })).amount, 1);
|
||||
assert.ok(markerDestinations(g, 'p').every((to) =>
|
||||
to.sector !== 14 && to.territory !== MOBILE_STRONGHOLD));
|
||||
g.storm = 18;
|
||||
g.players[1].forces = { 'sietch_tabr:14': 1 };
|
||||
g.players[2].forces = { 'sietch_tabr:14': 1 };
|
||||
rejected(g);
|
||||
});
|
||||
|
||||
void test('native Ecaz co-occupation and existing Bene Gesserit advisor stance retain shared entry rules', () => {
|
||||
const g = fixture();
|
||||
priorMarker(g, 3, 'sietch_tabr', 14);
|
||||
g.players[1].forces = { 'sietch_tabr:14': 1 };
|
||||
assert.equal(quote(g, 'p', selection({ tokenId: 'token0' })).advisors, false);
|
||||
const bg = fixture('beneGesserit', true);
|
||||
bg.players[0].forces = { 'sietch_tabr:14': 1 };
|
||||
bg.players[0].advisors = { sietch_tabr: { lockedTurn: bg.turn } };
|
||||
bg.players[1].forces = { 'sietch_tabr:14': 1 };
|
||||
bg.players[2].forces = { 'sietch_tabr:14': 1 };
|
||||
assert.equal(quote(bg, 'p', selection()).advisors, true);
|
||||
bg.advanced = false;
|
||||
rejected(bg);
|
||||
});
|
||||
|
||||
void test('malformed physical marker or typed custody and malformed selections reject without mutation', () => {
|
||||
const mutations: ((g: Game) => void)[] = [
|
||||
(g) => { g.players[0].reserves = -1; },
|
||||
(g) => { g.players[2].reserves = NaN; },
|
||||
(g) => { g.players[0].elites = { reserves: 21, tanks: 0, forces: {}, revived: 0 }; },
|
||||
(g) => { g.players[1].forces = { 'unknown:0': 1 }; },
|
||||
(g) => { g.players[1].forces = { 'red_chasm:7': -1 }; },
|
||||
(g) => { g.players[1].elites = { reserves: 0, tanks: 0, revived: 0,
|
||||
forces: { 'red_chasm:7': 1 } }; },
|
||||
(g) => { g.players[2].noField!.tokens[0].id = 'token3'; },
|
||||
(g) => { g.players[2].noFieldEvent = ''; },
|
||||
(g) => { priorMarker(g, 0); g.players[2].noField!.deployed!.controller = 'p'; },
|
||||
(g) => { g.players[0].forces = { 'sietch_tabr:14': Number.MAX_SAFE_INTEGER }; },
|
||||
(g) => { g.storm = 19; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const g = fixture();
|
||||
mutate(g);
|
||||
rejected(g);
|
||||
}
|
||||
for (const extra of [
|
||||
{ tokenId: null }, { tokenEvent: 1 }, { sector: 1.5 }, { payer: '' },
|
||||
{ elite: -1 }, { elite: NaN }, { elite: '0' },
|
||||
]) rejected(fixture(), { ...selection(), ...extra } as unknown as GuildAmbassadorMarkerSelection);
|
||||
});
|
||||
|
||||
void test('quotes and destination choices never consult cards RNG or an unchecked recipient balance', () => {
|
||||
const g = fixture(), chosen = selection({ payer: 'p' });
|
||||
priorMarker(g, 0);
|
||||
const expected = quote(g, 'p', chosen, false), destinations = markerDestinations(g, 'p');
|
||||
for (const player of g.players)
|
||||
for (const key of ['hand', 'leaders', 'traitors'])
|
||||
Object.defineProperty(player, key, { get() { throw new Error(`private ${key}`); } });
|
||||
Object.defineProperty(g.players[0], 'spice', {
|
||||
get() { throw new Error('unchecked private recipient balance'); },
|
||||
});
|
||||
for (const key of ['deck', 'discard', 'aid'])
|
||||
Object.defineProperty(g, key, { get() { throw new Error(`unneeded ${key}`); } });
|
||||
const random = Math.random;
|
||||
Math.random = () => { throw new Error('unexpected RNG'); };
|
||||
try {
|
||||
assert.deepEqual(quote(g, 'p', chosen, false), expected);
|
||||
assert.deepEqual(markerDestinations(g, 'p'), destinations);
|
||||
} finally {
|
||||
Math.random = random;
|
||||
}
|
||||
});
|
||||
|
||||
void test('the destination domain remains finite and every zero-reserve native choice has a quote witness', () => {
|
||||
const g = fixture('richese');
|
||||
g.players[0].reserves = 0;
|
||||
const before = structuredClone(g), choices = markerDestinations(g, 'p');
|
||||
assert.ok(choices.length > 0);
|
||||
assert.equal(new Set(choices.map((to) => `${to.territory}:${to.sector}`)).size, choices.length);
|
||||
assert.ok(choices.length <= TERRITORIES.flatMap((to) => to.sectors).length);
|
||||
for (const to of choices) {
|
||||
const result = quote(g, 'p', selection({ ...to, tokenId: 'token0' }));
|
||||
assert.equal(result.amount, 1);
|
||||
assert.equal(result.advisors, to.advisors);
|
||||
}
|
||||
const signature = quote(g, 'p', selection()).marker.stateSignature;
|
||||
assert.equal(quote(g, 'p', selection()).marker.stateSignature, signature);
|
||||
g.players[1].forces = { 'red_chasm:7': 1 };
|
||||
assert.notEqual(quote(g, 'p', selection()).marker.stateSignature, signature);
|
||||
g.players[1].forces = {};
|
||||
assert.deepEqual(g, before);
|
||||
choices.length = 0;
|
||||
assert.ok(markerDestinations(g, 'p').length > 0);
|
||||
});
|
||||
Reference in new issue
Block a user