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.
318 lines
20 KiB
TypeScript
318 lines
20 KiB
TypeScript
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);
|
|
});
|
|
}
|