Counter incoming BG matching before Fremen Ambassador relocation

Bind the selected packet to its original allied grant before transfer or collection. Preserve the committed trigger, restore legal replacement choices after prevention and resume the original movement once.
This commit is contained in:
Quintine committed 2026-10-24 22:38:55 +11:00
1 parent 767bace774
commit c999054ae0
15 files changed
+1040 -12

No files matched your search

@@ -0,0 +1,101 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { isAdvisor } from '../game/advisors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createAdvisorAmbassadorMatchingFixture as fixture,
actAdvisorAmbassadorMatching as act, finishAdvisorAmbassadorMatching as finish,
advisorAmbassadorMatchingPlayer as player, advisorAmbassadorMatchingInventory as inventory,
} from './fixture-advisor-ambassador-matching';
const physical=(g:Game)=>g.players.map(p=>({id:p.id,forces:p.forces,reserves:p.reserves,tanks:p.tanks,
spice:p.spice,moved:p.moved,shipped:p.shipped}));
const allowances=(g:Game)=>g.players.map(p=>({id:p.id,reserves:p.reserves,tanks:p.tanks,
spice:p.spice,moved:p.moved,shipped:p.shipped}));
function allow(g:Game){
const event=g.response?.advisorAmbassadorMatchingSource?.event;assert.ok(event);
for(let count=0;g.response?.advisorAmbassadorMatchingSource?.event===event&&count<20;count++){
const actor=g.players.find(p=>!g.response!.passed.includes(p.id));assert.ok(actor);
g=act(g,actor.id,{type:'passResponse'});
}
assert.notEqual(g.response?.advisorAmbassadorMatchingSource?.event,event);return g;
}
function reject(g:Game,actor:string,action:Action){
const before=structuredClone(g);assert.throws(()=>applyAction(g,actor,action));assert.deepEqual(g,before);
}
for(const direction of ['fightersToAdvisors','advisorsToFighters'] as const){
void test(`native allied Fremen Ambassador ${direction} waits before relocation then commits its original arrival once`,()=>{
const f=fixture({direction}),before=player(f.game,f.bg),target=f.destinationLocation.split(':')[0];
const pending=act(f.game,f.bg,f.action);
assert.equal(pending.response?.kind,'advisorFlip');assert.ok(pending.response?.advisorAmbassadorMatchingSource);
assert.equal(pending.pendingAmbassador?.stage,'move');assert.notEqual(pending.active,f.bg);
assert.deepEqual(physical(pending),physical(f.game));assert.deepEqual(pending.pendingAmbassador,f.originalParent);
assert.deepEqual(physical(normalizeAutomaticGame(pending)),physical(f.game));
const done=finish(allow(pending)),after=player(done,f.bg);
assert.equal(after.forces[f.sourceLocation]??0,before.forces[f.sourceLocation]-1);
assert.equal(after.forces[f.destinationLocation],before.forces[f.destinationLocation]+1);
assert.equal(isAdvisor(after,target),direction==='fightersToAdvisors');
assert.deepEqual(allowances(done),allowances(f.game));
assert.ok(!done.pendingAmbassador);assert.equal(done.pendingAdvisorAmbassadorMatching,undefined);
assert.equal(done.ecazAmbassadors!.tokens.find(t=>t.id===f.token)!.zone,'used');
assert.equal(done.log.filter(l=>l.text.includes('through the Fremen Ambassador. Their ordinary')).length,1);
assert.ok(player(done,f.holder).hand.some(c=>c.id===f.karama));
assert.deepEqual(inventory(done),f.originalInventory);
});
void test(`native allied Fremen Ambassador ${direction} prevention retains grant and blocks only the same packet in this territory`,()=>{
const f=fixture({direction}),pending=act(f.game,f.bg,f.action);
const stopped=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'});
assert.equal(stopped.pendingAdvisorAmbassadorMatching,undefined);
assert.equal(stopped.decision?.kind,'ecazAmbassador');assert.equal(stopped.decision?.player,f.bg);
assert.equal(stopped.pendingAmbassador?.event,f.event);assert.equal(stopped.pendingAmbassador?.stage,'move');
assert.deepEqual(physical(stopped),physical(f.game));assert.equal(stopped.discard.filter(c=>c.id===f.karama).length,1);
reject(stopped,f.bg,f.action);
reject(stopped,f.bg,{...f.action,fighters:true});
const view=viewGame(stopped,f.bg);assert.equal(view.ambassadorEntry?.matchingDenied?.length,1);
assert.ok(!('matchingDenied'in(viewGame(stopped,f.holder).ambassadorEntry??{})));
const done=finish(act(stopped,f.bg,f.noMatch));
assert.equal(player(done,f.bg).forces['polar_sink:0'],1);
assert.deepEqual(allowances(done),allowances(f.game));assert.ok(!done.pendingAmbassador);
assert.deepEqual(inventory(done),f.originalInventory);
});
void test(`native allied Fremen Ambassador ${direction} public counter omits original order and private continuation`,()=>{
const f=fixture({direction}),pending=act(f.game,f.bg,f.action),view=viewGame(pending,f.holder);
const source=view.response?.advisorAmbassadorMatchingSource;assert.ok(source);
assert.deepEqual(Object.keys(source).sort(),['advisors','destination','event','group','origin','owner','parent','phase','sector','turn']);
assert.equal(source.parent,f.event);assert.equal(source.owner,f.bg);
assert.equal('pendingAdvisorAmbassadorMatching'in view,false);
assert.equal(JSON.stringify(source).includes('signature'),false);
});
for(const level of DIFFICULTIES)void test(`${level} responds to native ${direction} matching and chooses a legal replacement after prevention`,()=>{
const f=fixture({direction}),pending=act(f.game,f.bg,f.action),response=viewGame(pending,f.holder);
response.players.find(p=>p.id===f.holder)!.bot=level;
const answer=botActions(response)[0];assert.ok(answer);assert.doesNotThrow(()=>applyAction(pending,f.holder,answer));
const stopped=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}),view=viewGame(stopped,f.bg);
view.players.find(p=>p.id===f.bg)!.bot=level;
const replacement=botActions(view)[0];assert.ok(replacement);assert.equal(replacement.type,'decision');
assert.doesNotThrow(()=>applyAction(stopped,f.bg,replacement));
});
}
void test('a different actual packet can match during the same native Ambassador grant after prevention',()=>{
const f=fixture(),stopped=act(act(f.game,f.bg,f.action),f.holder,{type:'card',card:f.karama,mode:'cancel'});
const replacement=act(stopped,f.bg,f.largerPacket),done=finish(replacement);
assert.ok(!done.pendingAmbassador);assert.equal(done.pendingAdvisorAmbassadorMatching,undefined);
assert.equal(player(done,f.bg).forces['arrakeen:10'],3);assert.equal(player(done,f.bg).forces['tueks_sietch:5']??0,0);
assert.equal(isAdvisor(player(done,f.bg),'arrakeen'),true);assert.deepEqual(allowances(done),allowances(f.game));
assert.equal(done.discard.filter(c=>c.id===f.karama).length,1);assert.deepEqual(inventory(done),f.originalInventory);
});
void test('an actual no-match Ambassador relocation creates no counter and retains the original Karama',()=>{
const f=fixture(),done=finish(act(f.game,f.bg,f.noMatch));
assert.ok(!done.response);assert.equal(done.pendingAdvisorAmbassadorMatching,undefined);
assert.equal(player(done,f.bg).forces['polar_sink:0'],1);
assert.ok(player(done,f.holder).hand.some(c=>c.id===f.karama));assert.deepEqual(allowances(done),allowances(f.game));
});
void test('a mismatched original Ambassador parent or selected order is rejected before cancellation cost',()=>{
const f=fixture(),pending=act(f.game,f.bg,f.action);
for(const field of ['signature','parentSignature'] as const){
const changed=structuredClone(pending);changed.pendingAdvisorAmbassadorMatching![field]+='changed';
reject(changed,f.holder,{type:'card',card:f.karama,mode:'cancel'});
assert.ok(player(changed,f.holder).hand.some(c=>c.id===f.karama));
}
});
+252
View File
@@ -0,0 +1,252 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
advisorAmbassadorMatchingKey,
AdvisorAmbassadorMatchingError,
quoteAdvisorAmbassadorMatching,
validateAdvisorAmbassadorMatching,
type AdvisorAmbassadorMatchingContext,
type AdvisorAmbassadorMatchingIntent,
type AdvisorAmbassadorMatchingSource,
} from '../game/advisor-ambassador-matching';
function context(): AdvisorAmbassadorMatchingContext {
return {
advanced: true, status: 'playing', turn: 4, phase: 5,
players: [
{
id: 'b', faction: 'beneGesserit',
forces: {
'hagga_basin:12': 3, 'hagga_basin:13': 2, 'imperial_basin:9': 2,
'carthag:11': 2, 'arrakeen:10': 1,
},
advisors: { imperial_basin: { lockedTurn: 4 }, carthag: {} },
},
{ id: 'a', faction: 'atreides', forces: { 'imperial_basin:9': 1 } },
{ id: 'e', faction: 'ecaz', forces: {} },
],
pendingAmbassador: {
event: 'original-fremen-ambassador', turn: 4, phase: 5,
effect: 'fremen', stage: 'move', beneficiary: 'b',
},
};
}
function intent(fields: Partial<AdvisorAmbassadorMatchingIntent> = {}): AdvisorAmbassadorMatchingIntent {
return {
parent: 'original-fremen-ambassador', owner: 'b', origin: 'hagga_basin',
destination: 'imperial_basin', sector: 10, advisors: true,
group: { 'hagga_basin:12': 1, 'hagga_basin:13': 1 }, ...fields,
};
}
function source(ctx = context(), incoming = intent()): AdvisorAmbassadorMatchingSource {
const quote = quoteAdvisorAmbassadorMatching(ctx, incoming);
assert.ok(quote);
return { event: 'matching-counter', ...quote };
}
void test('Fremen Ambassador matching quotes only the selected incoming BG packet in either direction before transfer', () => {
const ctx = context();
const incoming = intent();
const before = structuredClone({ ctx, incoming });
const quote = quoteAdvisorAmbassadorMatching(ctx, incoming);
assert.deepEqual(quote, {
parent: 'original-fremen-ambassador', owner: 'b', turn: 4, phase: 5,
origin: 'hagga_basin', destination: 'imperial_basin', sector: 10, advisors: true,
group: { 'hagga_basin:12': 1, 'hagga_basin:13': 1 },
});
assert.deepEqual({ ctx, incoming }, before);
assert.notEqual(quote!.group, incoming.group);
quote!.group['hagga_basin:12'] = 99;
assert.equal(ctx.players[0].forces['hagga_basin:12'], 3);
assert.equal(incoming.group['hagga_basin:12'], 1);
const reverse = intent({
origin: 'carthag', destination: 'arrakeen', sector: 10, advisors: false,
group: { 'carthag:11': 1 },
});
assert.equal(quoteAdvisorAmbassadorMatching(ctx, reverse)?.advisors, false);
const original = source(ctx, reverse);
assert.doesNotThrow(() => validateAdvisorAmbassadorMatching(ctx, original));
assert.deepEqual(Object.keys(original).sort(), [
'advisors', 'destination', 'event', 'group', 'origin', 'owner', 'parent', 'phase', 'sector', 'turn',
]);
assert.deepEqual(ctx, before.ctx);
});
void test('Basic, foreign beneficiaries, same-type groups and absent residents do not open Ambassador matching', () => {
for (const patch of [{ advanced: false }, { status: 'setup' }, { pendingAmbassador: null }]) {
assert.equal(quoteAdvisorAmbassadorMatching({ ...context(), ...patch }, intent()), null);
}
const foreign = context();
foreign.players[0].faction = 'atreides';
assert.equal(quoteAdvisorAmbassadorMatching(foreign, intent()), null);
assert.equal(quoteAdvisorAmbassadorMatching(context(), intent({ owner: 'missing' })), null);
assert.equal(quoteAdvisorAmbassadorMatching(context(), intent({
destination: 'arrakeen', sector: 10, advisors: false,
})), null);
assert.equal(quoteAdvisorAmbassadorMatching(context(), intent({
origin: 'carthag', group: { 'carthag:11': 1 },
})), null);
const empty = context();
empty.players[0].advisors = { ...empty.players[0].advisors, polar_sink: {} };
assert.equal(quoteAdvisorAmbassadorMatching(empty, intent({ destination: 'polar_sink', sector: 0 })), null);
const sameTerritory = intent({
origin: 'imperial_basin', destination: 'imperial_basin', sector: 11,
group: { 'imperial_basin:9': 1 },
});
assert.equal(quoteAdvisorAmbassadorMatching(context(), sameTerritory), null);
assert.throws(() => validateAdvisorAmbassadorMatching(context(), {
event: 'unnecessary-counter', turn: 4, phase: 5, ...sameTerritory,
}), AdvisorAmbassadorMatchingError);
});
void test('the current Fremen parent authorizes a non-active beneficiary without using ordinary move or shipment slots', () => {
const ctx = context();
Object.defineProperty(ctx, 'active', { value: 'a' });
Object.defineProperty(ctx.players[0], 'moved', { value: 9 });
Object.defineProperty(ctx.players[0], 'shipped', { value: true });
for (const phase of [1, 3, 5, 8]) {
ctx.phase = phase;
ctx.pendingAmbassador!.phase = phase;
const original = source(ctx);
assert.equal(original.phase, phase);
assert.doesNotThrow(() => validateAdvisorAmbassadorMatching(ctx, original));
}
for (const patch of [
{ effect: 'guild' }, { stage: 'offer' }, { beneficiary: 'a' },
]) {
const other = context();
Object.assign(other.pendingAmbassador!, patch);
assert.equal(quoteAdvisorAmbassadorMatching(other, intent()), null);
assert.throws(() => validateAdvisorAmbassadorMatching(other, source()), AdvisorAmbassadorMatchingError);
}
});
void test('matching rejects stale parent, turn, phase, resident stance and uncommitted physical selection without mutation', () => {
const changes: ((ctx: AdvisorAmbassadorMatchingContext, original: AdvisorAmbassadorMatchingSource) => void)[] = [
(ctx) => { ctx.turn++; },
(ctx) => { ctx.phase = 3; },
(ctx) => { ctx.pendingAmbassador!.event = 'another-ambassador'; },
(ctx) => { ctx.pendingAmbassador!.turn = 3; },
(ctx) => { ctx.pendingAmbassador!.phase = 1; },
(ctx) => { ctx.players[0].forces = { ...ctx.players[0].forces, 'hagga_basin:12': 0 }; },
(ctx) => { ctx.players[0].forces = { ...ctx.players[0].forces, 'imperial_basin:9': 0 }; },
(ctx) => { ctx.players[0].advisors = { carthag: {} }; },
(_ctx, original) => { original.event = ''; },
(_ctx, original) => { original.parent = 'another-ambassador'; },
(_ctx, original) => { original.owner = 'missing'; },
(_ctx, original) => { original.turn = 3; },
(_ctx, original) => { original.phase = 3; },
(_ctx, original) => { original.advisors = false; },
(_ctx, original) => { original.destination = 'missing'; },
(_ctx, original) => { original.sector = 8; },
(_ctx, original) => { original.group = {}; },
(_ctx, original) => { original.group = { 'hagga_basin:12': 4 }; },
(_ctx, original) => { original.group = { 'hagga_basin:12': 0 }; },
(_ctx, original) => { original.group = { 'hagga_basin:12': 1.5 }; },
(_ctx, original) => { original.group = { 'hagga_basin:12.0': 1 }; },
(_ctx, original) => { original.group = { 'carthag:11': 1 }; },
(_ctx, original) => { original.group = { reserves: 1 }; },
];
for (const change of changes) {
const ctx = context();
const original = source(ctx);
change(ctx, original);
const before = structuredClone({ ctx, original });
assert.throws(() => validateAdvisorAmbassadorMatching(ctx, original), (error: unknown) =>
error instanceof AdvisorAmbassadorMatchingError && error.name === 'AdvisorAmbassadorMatchingError');
assert.deepEqual({ ctx, original }, before);
}
});
void test('the Ambassador denial key binds the original opportunity and packet while ignoring counter UUID and destination sector', () => {
const original = source();
const key = advisorAmbassadorMatchingKey(original);
assert.equal(advisorAmbassadorMatchingKey({
...original, event: 'new-counter', sector: 11,
group: { 'hagga_basin:13': 1, 'hagga_basin:12': 1 },
}), key);
for (const change of [
{ turn: 5 }, { parent: 'new-parent' }, { owner: 'other' }, { origin: 'carthag' },
{ destination: 'carthag' }, { advisors: false }, { group: { 'hagga_basin:12': 1 } },
]) {
assert.notEqual(advisorAmbassadorMatchingKey({ ...original, ...change }), key);
}
});
void test('prevention stops only the same Ambassador packet and destination during its original parent opportunity', () => {
const ctx = context();
const original = source(ctx);
ctx.advisorAmbassadorMatchingDenied = [advisorAmbassadorMatchingKey(original)];
const before = structuredClone(ctx);
assert.throws(() => quoteAdvisorAmbassadorMatching(ctx, intent()), /already prevented.*original Ambassador opportunity/);
assert.throws(() => validateAdvisorAmbassadorMatching(ctx, {
...original, event: 'new-counter', sector: 11,
}), AdvisorAmbassadorMatchingError);
assert.deepEqual(ctx, before);
assert.ok(quoteAdvisorAmbassadorMatching(ctx, intent({ group: { 'hagga_basin:12': 1 } })));
assert.ok(quoteAdvisorAmbassadorMatching(ctx, intent({ destination: 'carthag', sector: 11 })));
ctx.pendingAmbassador!.event = 'later-original-ambassador';
assert.ok(quoteAdvisorAmbassadorMatching(ctx, intent({ parent: 'later-original-ambassador' })));
ctx.pendingAmbassador!.event = original.parent;
ctx.turn = 5;
ctx.pendingAmbassador!.turn = 5;
assert.ok(quoteAdvisorAmbassadorMatching(ctx, intent()));
});
void test('strict matching receipts exclude private data, multi-origin packets and non-board locations', () => {
const ctx = context();
const original = source(ctx);
for (const field of ['order', 'signature', 'token', 'noField', 'skills', 'move', 'payment', 'kind']) {
assert.throws(() => validateAdvisorAmbassadorMatching(ctx, {
...original, [field]: 'private-or-unrelated',
} as AdvisorAmbassadorMatchingSource), AdvisorAmbassadorMatchingError);
}
for (const malformed of [
null, [], { ...original, advisors: 'true' }, { ...original, group: null },
{ ...original, group: { 'hagga_basin:12': 1, 'carthag:11': 1 } },
]) {
assert.throws(() => validateAdvisorAmbassadorMatching(ctx,
malformed as unknown as AdvisorAmbassadorMatchingSource), AdvisorAmbassadorMatchingError);
}
assert.throws(() => quoteAdvisorAmbassadorMatching(ctx,
intent({ destination: 'homeworld:guild', sector: 0 })), AdvisorAmbassadorMatchingError);
assert.throws(() => quoteAdvisorAmbassadorMatching(ctx,
intent({ origin: 'homeworld:beneGesserit' })), AdvisorAmbassadorMatchingError);
});
void test('Ambassador matching reads only public parent identity, physical BG counters and stance, without private eligibility or native order data', () => {
const ctx = context();
const incoming = intent();
const original = source(ctx);
for (const player of ctx.players) {
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'skills', 'noField', 'elites', 'moved', 'reserves', 'shipped']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Ambassador matching read ${player.id} private or unrelated ${field}`); },
});
}
}
for (const stance of Object.values(ctx.players[0].advisors!)) {
for (const field of ['lockedTurn', 'aloneFlipHold']) {
Object.defineProperty(stance, field, {
get() { throw new Error(`Ambassador matching read unrelated ${field}`); },
});
}
}
for (const object of [ctx, ctx.pendingAmbassador!, incoming]) {
for (const field of ['active', 'order', 'signature', 'token', 'forecast', 'pendingMove', 'pendingShipment', 'advisorMatchingDenied']) {
Object.defineProperty(object, field, {
get() { throw new Error(`Ambassador matching read original private ${field}`); },
});
}
}
Object.defineProperty(ctx.players[1], 'forces', {
get() { throw new Error('Ambassador matching reran foreign occupancy'); },
});
assert.ok(quoteAdvisorAmbassadorMatching(ctx, incoming));
assert.doesNotThrow(() => validateAdvisorAmbassadorMatching(ctx, original));
assert.deepEqual(Object.keys(quoteAdvisorAmbassadorMatching(ctx, incoming)!).sort(), [
'advisors', 'destination', 'group', 'origin', 'owner', 'parent', 'phase', 'sector', 'turn',
]);
});
@@ -0,0 +1,246 @@
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 {
actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
advisorPreparationPlayer, nextAdvisorPreparationStep,
} from './fixture-advisor-preparation';
export type AdvisorAmbassadorMatchingStep = { actor: string; action: Action };
export type AdvisorAmbassadorMatchingTransition = { before: Game; steps: AdvisorAmbassadorMatchingStep[]; after: Game };
export const actAdvisorAmbassadorMatching = actAdvisorPreparation;
export const advisorAmbassadorMatchingPlayer = advisorPreparationPlayer;
export const advisorAmbassadorMatchingInventory = advisorPreparationInventory;
export const advisorAmbassadorMatchingClean = advisorPreparationClean;
/** Native continuation never chooses an Ambassador relocation or a new
* matching response. Ordinary preparation and Spiritual choices are declined. */
export function nextAdvisorAmbassadorMatching(game: Game): AdvisorAmbassadorMatchingStep | null {
if (game.response?.kind === 'advisorFlip') return null;
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
const ecaz = game.players.find(player => player.faction === 'ecaz');
if (game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20)
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
const decision = game.decision;
if (decision?.kind === 'ecazPlacement' || decision?.kind === 'ecazAmbassador') return null;
if (decision?.kind === 'advisorBattle')
return { actor: decision.player, action: { type: 'decision', accept: false } };
}
return nextAdvisorPreparationStep(game);
}
export function advanceAdvisorAmbassadorMatching(game: Game, until: (state: Game) => boolean,
steps: AdvisorAmbassadorMatchingStep[] = []): Game {
for (let count = 0; count < 2200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The native allied Ambassador chronology ended before its requested boundary');
const next = nextAdvisorAmbassadorMatching(game);
assert.ok(next, `Choose the original Ambassador/matching control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actAdvisorAmbassadorMatching(game, next.actor, next.action, steps);
}
throw Error('The original allied Fremen Ambassador source did not reach its finite boundary');
}
export function passAdvisorAmbassadorMatching(game: Game, steps: AdvisorAmbassadorMatchingStep[] = []): Game {
for (let count = 0; game.response && game.response.kind !== 'advisorFlip' && count < 60; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actAdvisorAmbassadorMatching(game, actor.id, { type: 'passResponse' }, steps);
}
assert.ok(!game.response || game.response.kind === 'advisorFlip'); return game;
}
function originalFremenPacket() {
const original = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['fremen', ...original.filter(effect => effect !== 'fremen').slice(0, 4)];
const order = [...supply, ...original.filter(effect => !supply.includes(effect))];
const current = [...original], scalars: number[] = [];
for (let index = current.length - 1; index > 0; index--) {
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
[current[index], current[from]] = [current[from], current[index]];
}
assert.deepEqual(current, order); assert.equal(scalars.length, 9); return { supply, order, scalars };
}
function dealOriginalPacket(game: Game, step: AdvisorAmbassadorMatchingStep,
steps: AdvisorAmbassadorMatchingStep[], scalars: number[]): Game {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = 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 < scalars.length ? scalars[cursor++] : 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try {
const next = applyAction(game, step.actor, step.action);
assert.equal(cursor, scalars.length, 'The native original Ecaz packet consumes its selected draws once');
steps.push(structuredClone(step)); return next;
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Public Advanced BG/Ecaz/Atreides base33. A paid native fighter group and the
* original advisor survive to a real turn-two Nexus alliance. Ecaz then earns
* an original Fremen trigger for its actual BG ally. Stop before relocation. */
export function createAdvisorAmbassadorMatchingFixture(options: {
direction?: 'fightersToAdvisors' | 'advisorsToFighters';
} = {}) {
const direction = options.direction ?? 'fightersToAdvisors';
const bg = 'beneGesserit', ecaz = 'ecaz', holder = 'atreides';
let game = createGame('ADVISORAMBASSADORMATCHING', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true, ['ecaz']);
joinGame(game, newPlayer(ecaz, 'Ecaz', 'ecaz')); joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
const initial = structuredClone(game), steps: AdvisorAmbassadorMatchingStep[] = [];
const act = (actor: string, action: Action) => { game = actAdvisorAmbassadorMatching(game, actor, action, steps); };
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorAmbassadorMatching(game, until, steps); };
const transition = (run: () => void): AdvisorAmbassadorMatchingTransition => {
const before = structuredClone(game), start = steps.length; run();
return { before, steps: structuredClone(steps.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', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, ['ecaz']);
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));
const originalInventory = advisorAmbassadorMatchingInventory(game); assert.equal(originalInventory.length, 33);
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
assert.equal(ordinary.length, 2);
const selected = [ordinary[0], ordinary[1], karama];
const dealOrder = selected.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 };
});
const beforeStartingDeal = structuredClone(game), packet = originalFremenPacket();
let packetCreation: AdvisorAmbassadorMatchingTransition | null = null;
let advisorSetup: AdvisorAmbassadorMatchingTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextAdvisorAmbassadorMatching(game); assert.ok(next);
const before = structuredClone(game), start = steps.length;
const createsPacket = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
game.players.filter(player => player.traitorChoices.length).length === 1;
game = createsPacket ? dealOriginalPacket(game, next, steps, packet.scalars)
: actAdvisorAmbassadorMatching(game, next.actor, next.action, steps);
const record = { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
if (createsPacket) packetCreation = record;
if (next.action.type === 'advisorSetup') advisorSetup = record;
}
assert.equal(game.status, 'playing'); assert.ok(packetCreation && advisorSetup);
const afterSetup = structuredClone(game), setupActionCount = steps.length;
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
assert.equal(advisorAmbassadorMatchingPlayer(game, bg).forces['arrakeen:10'], 1);
assert.ok(advisorAmbassadorMatchingPlayer(game, bg).advisors?.arrakeen);
assert.equal(advisorAmbassadorMatchingPlayer(game, holder).forces['arrakeen:10'], 10);
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id => game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
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 &&
!['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory));
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.turn === 1 && state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
const token = game.ecazAmbassadors!.tokens.find(candidate => candidate.effect === 'fremen')!;
assert.equal(token.zone, 'supply');
const placement = transition(() => {
act(ecaz, { type: 'decision', token: token.id, territory: 'carthag' });
game = passAdvisorAmbassadorMatching(game, steps);
});
assert.equal(advisorAmbassadorMatchingPlayer(placement.before, ecaz).spice - advisorAmbassadorMatchingPlayer(game, ecaz).spice, 1);
act(ecaz, { type: 'decision', decline: true });
reach(state => state.turn === 1 && state.phase === 5 && state.active === bg && advisorAmbassadorMatchingClean(state));
const fighterShipment = transition(() => {
act(bg, { type: 'ship', territory: 'tueks_sietch', sector: 5, amount: 2, elite: 0, allyPayment: 0 });
reach(advisorAmbassadorMatchingClean);
});
assert.equal(advisorAmbassadorMatchingPlayer(fighterShipment.before, bg).spice - advisorAmbassadorMatchingPlayer(game, bg).spice, 2);
assert.equal(advisorAmbassadorMatchingPlayer(game, bg).forces['tueks_sietch:5'], 2);
assert.ok(!advisorAmbassadorMatchingPlayer(game, bg).advisors?.tueks_sietch);
reach(state => state.turn === 2 && state.phase === 1 && advisorAmbassadorMatchingClean(state) &&
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
const beforeWorm = structuredClone(game), beforeWormInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(wormIndex >= 0);
const worm = game.spiceDeck.splice(wormIndex, 1)[0]; game.spiceDeck.unshift(worm);
const wormOrder = { position: 0, from: wormIndex, card: structuredClone(worm) };
const wormFollowupOrder = [1, 2].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
!['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory));
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(), beforeWormInventory);
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && advisorAmbassadorMatchingClean(state));
const nexus = structuredClone(game);
const alliance = transition(() => {
act(bg, { type: 'alliance', target: ecaz });
act(ecaz, { type: 'alliance', target: bg });
});
assert.equal(advisorAmbassadorMatchingPlayer(game, bg).ally, ecaz);
assert.equal(advisorAmbassadorMatchingPlayer(game, ecaz).ally, bg);
const allianceActionCount = steps.length;
reach(state => state.turn === 2 && state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
act(ecaz, { type: 'decision', decline: true });
reach(state => state.turn === 2 && state.phase === 5 && state.active === holder && advisorAmbassadorMatchingClean(state));
const entrantShipment = transition(() => {
act(holder, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 });
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'offer');
});
const offer = structuredClone(game), event = game.pendingAmbassador!.event;
const trigger = transition(() => {
act(ecaz, { type: 'decision', event, trigger: true, beneficiary: bg });
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'move');
});
assert.equal(game.pendingAmbassador!.beneficiary, bg); assert.equal(game.pendingAmbassador!.effect, 'fremen');
assert.equal(game.decision?.player, bg);
assert.equal(advisorAmbassadorMatchingPlayer(game, bg).forces['arrakeen:10'], 1);
assert.ok(advisorAmbassadorMatchingPlayer(game, bg).advisors?.arrakeen);
assert.equal(advisorAmbassadorMatchingPlayer(game, bg).forces['tueks_sietch:5'], 2);
assert.ok(!advisorAmbassadorMatchingPlayer(game, bg).advisors?.tueks_sietch);
assert.equal(advisorAmbassadorMatchingPlayer(game, bg).ally, ecaz);
assert.equal(advisorAmbassadorMatchingPlayer(game, ecaz).ally, bg);
assert.ok(advisorAmbassadorMatchingPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.deepEqual(advisorAmbassadorMatchingInventory(game), originalInventory);
const advisorsToFighters: Action = { type: 'decision', event, forces: { 'arrakeen:10': 1 }, territory: 'tueks_sietch', sector: 5 };
const fightersToAdvisors: Action = { type: 'decision', event, forces: { 'tueks_sietch:5': 1 }, territory: 'arrakeen', sector: 10 };
const largerPacket: Action = { type: 'decision', event, forces: { 'tueks_sietch:5': 2 }, territory: 'arrakeen', sector: 10 };
const noMatch: Action = { type: 'decision', event, forces: { 'tueks_sietch:5': 1 }, territory: 'polar_sink', sector: 0 };
const action = direction === 'fightersToAdvisors' ? fightersToAdvisors : advisorsToFighters;
const sourceLocation = direction === 'fightersToAdvisors' ? 'tueks_sietch:5' : 'arrakeen:10';
const destinationLocation = direction === 'fightersToAdvisors' ? 'arrakeen:10' : 'tueks_sietch:5';
return { game, source: structuredClone(game), beforeSelection: structuredClone(game), initial, lobby, setup,
beforeStartingDeal, afterSetup, advisorSetup, packet, packetCreation, placement, fighterShipment,
beforeWorm, wormOrder, wormFollowupOrder, nexus, alliance, entrantShipment, offer, beforeTrigger: offer, trigger, triggerState: trigger.after,
originalParent: structuredClone(game.pendingAmbassador), direction, bg, owner: bg, ecaz, holder, atreides: holder,
karama: karama.id, card: karama.id, token: token.id, event, actor: bg, beneficiary: bg,
action, advisorsToFighters, fightersToAdvisors, largerPacket, noMatch,
matchingActions: [fightersToAdvisors, advisorsToFighters], replacement: largerPacket,
replacements: [largerPacket, noMatch], replacementActions: [largerPacket, noMatch],
sourceLocation, destinationLocation, from: sourceLocation, to: destinationLocation,
relocationStep: { actor: bg, action } satisfies AdvisorAmbassadorMatchingStep,
steps, actions: steps, sourceActionCount: steps.length, setupActionCount, allianceActionCount,
originalInventory, originalSpice, dealOrder, spiceOrder,
staging: [
'Actual public Advanced BG/Ecaz/Atreides ready/startPreview, original base33 and bare E3. Original prediction/private Traitors, native Ecaz Basin6 setup and actual BG advisorSetup beside Atreides’ native ten Arrakeen counters execute normally.',
'Select only original scalar shuffle draws, three original unopened Treachery positions, two safe ordinary first-turn Spice positions, the original Ecaz nine-draw Fremen packet and one still-unopened turn-two Shai-Hulud followed by two safe original land cards. Atreides actually receives and retains its original printed Karama; no dealt hand is installed.',
'Ecaz actually pays one at first Revival to place its original Fremen token in Carthag. During its real first movement turn BG actually pays two to ship two reserves into empty Tuek’s5 as fighters, retaining the original Arrakeen advisor. Other native turns and Collection/Mentat complete without a battle or force staging.',
'Actual native turn-two Storm Card1 leaves both BG groups safe. The actual Shai-Hulud and Nexus permit BG’s real alliance proposal and Ecaz’s real acceptance. The reciprocal alliance is never assigned; no Nexus Card module is selected.',
'Atreides actually ships one reserve into the still-placed Carthag token. The original entry/Spiritual controls resolve normally, then Ecaz really triggers Fremen for its actual BG ally. Stop at the original BG-owned stage move, before selecting or consuming any relocation packet.',
'One original board exposes both opposite-type directions, two genuine one/two-fighter packets for retry, and a no-match relocation to empty Polar Sink. The new matching response, force transfer, mode change and later parent callbacks remain caller-owned outcomes; no played phase, alliance, army, wallet, stance, prevention or receipt is installed.',
] };
}
export type AdvisorAmbassadorMatchingFixture = ReturnType<typeof createAdvisorAmbassadorMatchingFixture>;
/** Submit only one original source selection; later matching stays owned. */
export function commitAdvisorAmbassadorMatching(source: Game, fixture: AdvisorAmbassadorMatchingFixture,
action: Action = fixture.action, steps: AdvisorAmbassadorMatchingStep[] = []) {
const before = structuredClone(source), start = steps.length;
const game = actAdvisorAmbassadorMatching(source, fixture.bg, action, steps);
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
}
export function finishAdvisorAmbassadorMatching(source: Game, steps: AdvisorAmbassadorMatchingStep[] = []): Game {
return advanceAdvisorAmbassadorMatching(source,
game => !game.pendingAmbassador && advisorAmbassadorMatchingClean(game), steps);
}