Counter BG matching before fresh Guild and Junction transport
Preserve original grants, private funding and unused shipments while matching is pending. Restore legal replacement choices after prevention and resume the original permission, price and transfer once.
This commit is contained in:
1 parent
c999054ae0
commit
b2a20226d7
17 files changed
+1444
-17
No files matched your search
@@ -0,0 +1,127 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { guildTransportQuote } from '../game/transport-quote';
|
||||
import { junctionTransportChoice } from '../game/junction-transport-options';
|
||||
import { createAdvisorTransportMatchingFixture as fixture,actAdvisorTransportMatching as act,
|
||||
finishAdvisorTransportMatching as finish,advisorTransportMatchingPlayer as player,
|
||||
advisorTransportMatchingInventory as inventory } from './fixture-advisor-transport-matching';
|
||||
import { createAdvisorSecretTransportMatchingFixture as secretFixture } from './fixture-advisor-secret-transport-matching';
|
||||
import { placeFixtureHand } from './fixture-hand';
|
||||
|
||||
const physical=(g:Game)=>g.players.map(p=>({id:p.id,forces:p.forces,advisors:p.advisors,
|
||||
reserves:p.reserves,tanks:p.tanks,spice:p.spice,moved:p.moved,shipped:p.shipped}));
|
||||
function layer(g:Game){const r=g.response;return r?JSON.stringify([r.kind,r.advisorTransportMatchingSource?.event,r.guildTransportEvent,r.guildRateEvent]):null;}
|
||||
function allow(g:Game){const original=layer(g);assert.ok(original);
|
||||
for(let count=0;layer(g)===original&&count<30;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(layer(g),original);return g;
|
||||
}
|
||||
function complete(g:Game){
|
||||
for(let count=0;g.response&&(g.response.advisorTransportMatchingSource||
|
||||
['guildTransportPermission','guildRate'].includes(g.response.kind))&&count<5;count++)g=allow(g);
|
||||
return finish(g);
|
||||
}
|
||||
function reject(g:Game,actor:string,action:Action){const before=structuredClone(g);assert.throws(()=>applyAction(g,actor,action));assert.deepEqual(g,before);}
|
||||
const scenarios=[
|
||||
['guild-fighters',()=>fixture()],['guild-advisors',()=>fixture({direction:'advisorsToFighters'})],
|
||||
['junction-fighters',()=>fixture({kind:'junctionBoard'})],['junction-advisors',()=>fixture({kind:'junctionBoard',direction:'advisorsToFighters'})],
|
||||
['junction-world',()=>fixture({kind:'junctionWorld'})],
|
||||
['secret-fighters',()=>secretFixture()],['secret-advisors',()=>secretFixture({direction:'advisorsToFighters'})],
|
||||
] as const;
|
||||
for(const [name,create]of scenarios){
|
||||
void test(`native ${name} matching waits before payment or transfer and resumes the original transport once`,()=>{
|
||||
const f=create(),pending=act(f.game,f.bg,f.action),source=pending.response?.advisorTransportMatchingSource;assert.ok(source);
|
||||
assert.deepEqual(physical(pending),physical(f.game));assert.deepEqual(physical(normalizeAutomaticGame(pending)),physical(f.game));
|
||||
assert.ok(!pending.pendingGuildTransport);assert.equal(player(pending,f.bg).shipped,false);
|
||||
assert.deepEqual(pending.junctionOffer,f.game.junctionOffer);
|
||||
const next=allow(pending);assert.equal(next.pendingAdvisorTransportMatching,undefined);
|
||||
if(name.startsWith('guild')){assert.equal(next.response?.kind,'guildTransportPermission');assert.deepEqual(physical(next),physical(f.game));}
|
||||
const done=complete(next),owner=player(done,f.bg);
|
||||
assert.equal(owner.forces[f.destinationLocation],player(f.game,f.bg).forces[f.destinationLocation]+1);
|
||||
if(f.sourceLocation.startsWith('homeworld:'))assert.equal(owner.reserves,player(f.game,f.bg).reserves-1);
|
||||
else assert.equal(owner.forces[f.sourceLocation]??0,player(f.game,f.bg).forces[f.sourceLocation]-1);
|
||||
assert.equal(owner.spice,player(f.game,f.bg).spice-f.cost);assert.equal(owner.moved,player(f.game,f.bg).moved);assert.equal(owner.shipped,true);
|
||||
assert.equal(isAdvisor(owner,source.destination),source.advisors);assert.equal(done.pendingAdvisorTransportMatching,undefined);
|
||||
assert.ok(player(done,f.holder).hand.some(c=>c.id===f.karama));assert.deepEqual(inventory(done),f.originalInventory);
|
||||
if(name.startsWith('secret'))assert.equal(done.nexusGuildSecretHistory?.length,1);
|
||||
});
|
||||
void test(`native ${name} prevention retains original entitlement and blocks a repeated packet while allowing a real replacement`,()=>{
|
||||
const f=create(),pending=act(f.game,f.bg,f.action),stopped=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
assert.deepEqual(physical(stopped),physical(f.game));assert.equal(stopped.pendingAdvisorTransportMatching,undefined);
|
||||
assert.deepEqual(stopped.junctionOffer,f.game.junctionOffer);assert.equal(stopped.nexusGuildSecretHistory?.length??0,0);
|
||||
if(name.startsWith('secret'))assert.equal(stopped.nexusCards?.cards?.hands[f.bg],'guild');
|
||||
reject(stopped,f.bg,f.action);
|
||||
const view=viewGame(stopped,f.bg);assert.equal(view.advisorTransportMatchingDenied?.length,1);
|
||||
assert.equal(viewGame(stopped,f.holder).advisorTransportMatchingDenied,undefined);
|
||||
if(f.action.type==='guildShip')assert.ok(guildTransportQuote(view,f.action).unavailableReasons.some(reason=>/prevented/.test(reason)));
|
||||
else assert.match(junctionTransportChoice(view,String(f.action.destination),f.action.sources as Record<string,{normal:number;elite:number}>).blocked??'',/prevented/);
|
||||
const done=complete(act(stopped,f.bg,f.noMatch));
|
||||
const replacementLocation=f.noMatch.type==='guildShip'?`${String(f.noMatch.territory)}:${Number(f.noMatch.sector)}`:String(f.noMatch.destination);
|
||||
assert.equal(player(done,f.bg).forces[replacementLocation],1);
|
||||
assert.equal(player(done,f.bg).spice,player(f.game,f.bg).spice-f.cost);assert.equal(player(done,f.bg).shipped,true);
|
||||
assert.equal(done.discard.filter(c=>c.id===f.karama).length,1);assert.deepEqual(inventory(done),f.originalInventory);
|
||||
if(name.startsWith('secret'))assert.equal(done.nexusGuildSecretHistory?.length,1);
|
||||
});
|
||||
void test(`native ${name} counter exposes only ten physical source fields`,()=>{
|
||||
const f=create(),pending=act(f.game,f.bg,f.action),view=viewGame(pending,f.holder),source=view.response?.advisorTransportMatchingSource;assert.ok(source);
|
||||
assert.deepEqual(Object.keys(source).sort(),['advisors','destination','event','group','origin','owner','phase','producer','sector','turn']);
|
||||
assert.equal('pendingAdvisorTransportMatching'in view,false);assert.equal(JSON.stringify(source).includes('opportunity'),false);
|
||||
});
|
||||
}
|
||||
for(const [name,create]of scenarios.filter(([name])=>!name.endsWith('advisors'))){
|
||||
void test(`native ${name} admits a different actual matching packet in the original unused opportunity`,()=>{
|
||||
const f=create(),stopped=act(act(f.game,f.bg,f.action),f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
const done=complete(act(stopped,f.bg,f.largerPacket));
|
||||
assert.equal(player(done,f.bg).forces['arrakeen:10'],3);assert.equal(player(done,f.bg).shipped,true);
|
||||
assert.equal(done.discard.filter(c=>c.id===f.karama).length,1);assert.deepEqual(inventory(done),f.originalInventory);
|
||||
});
|
||||
for(const profile of DIFFICULTIES)void test(`${profile} handles native ${name} matching and a legal replacement`,()=>{
|
||||
const f=create(),pending=act(f.game,f.bg,f.action),view=viewGame(pending,f.holder);view.players.find(p=>p.id===f.holder)!.bot=profile;
|
||||
const answer=botActions(view)[0];assert.ok(answer);assert.doesNotThrow(()=>applyAction(pending,f.holder,answer));
|
||||
const stopped=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}),replacementView=viewGame(stopped,f.bg);
|
||||
replacementView.players.find(p=>p.id===f.bg)!.bot=profile;
|
||||
const replacement=botActions(replacementView)[0];assert.ok(replacement);assert.doesNotThrow(()=>applyAction(stopped,f.bg,replacement));
|
||||
});
|
||||
}
|
||||
void test('allowed matching retains the separate original Guild permission counter',()=>{
|
||||
const f=fixture(),permission=allow(act(f.game,f.bg,f.action));assert.equal(permission.response?.kind,'guildTransportPermission');
|
||||
const denied=act(permission,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
assert.deepEqual(physical(denied),physical(f.game));assert.equal(denied.pendingAdvisorTransportMatching,undefined);
|
||||
assert.equal(viewGame(denied,f.bg).guildTransportCanceled,true);
|
||||
});
|
||||
void test('allowed matching and permission retain original full-rate cancellation when funding cannot complete',()=>{
|
||||
const f=fixture({direction:'advisorsToFighters'}),rate=allow(allow(act(f.game,f.bg,f.action)));assert.equal(rate.response?.kind,'guildRate');
|
||||
const denied=act(rate,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
assert.deepEqual(physical(denied),physical(f.game));assert.equal(denied.pendingAdvisorTransportMatching,undefined);
|
||||
assert.equal(denied.guildRateBlocked?.player,f.bg);assert.equal(player(denied,f.bg).shipped,false);
|
||||
});
|
||||
void test('private original intent and opportunity mismatches fail before cancellation cost',()=>{
|
||||
const f=fixture(),pending=act(f.game,f.bg,f.action);
|
||||
for(const field of ['signature','opportunity'] as const){const changed=structuredClone(pending);changed.pendingAdvisorTransportMatching![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));}
|
||||
});
|
||||
for(const [name,create]of [['junction',()=>fixture({kind:'junctionWorld'})],['secret',()=>secretFixture()]] as const)
|
||||
void test(`controlled ${name} Truthtrance overlay preserves the already-declared matching response and real priority`,()=>{
|
||||
const f=create(),staged=structuredClone(f.game),truth=staged.deck.find(c=>c.effect==='truthtrance');assert.ok(truth);
|
||||
// Controlled card custody only: relocate an original physical card, preserving
|
||||
// the earned native grant/forces/alliance. This is not an earned card draw.
|
||||
const owner=player(staged,f.bg);assert.ok(owner.hand.length<4);
|
||||
placeFixtureHand(staged,staged.players.findIndex(p=>p.id===f.bg),[...owner.hand,truth]);
|
||||
const pending=act(staged,f.bg,f.action),receipt=structuredClone(pending.response);
|
||||
let g=act(pending,f.bg,{type:'card',card:truth.id});assert.ok(g.truthtrance);
|
||||
assert.deepEqual(viewGame(g,f.bg).response,viewGame(pending,f.bg).response);
|
||||
reject(g,f.holder,{type:'passResponse'});
|
||||
for(let count=0;g.truthtrance?.stage==='priority'&&count<10;count++){
|
||||
const actor=g.players.find(p=>!g.truthtrance!.passed.includes(p.id));assert.ok(actor);g=act(g,actor.id,{type:'truthPass'});
|
||||
}
|
||||
g=act(g,f.bg,{type:'truthAsk',question:{kind:'fact',target:f.holder,fact:{kind:'hand',name:'Karama'}}});
|
||||
g=act(g,f.holder,{type:'truthAnswer',answer:'yes'});assert.ok(!g.truthtrance);assert.deepEqual(g.response,receipt);
|
||||
const stopped=act(g,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
assert.deepEqual(physical(stopped),physical(f.game));assert.equal(stopped.pendingAdvisorTransportMatching,undefined);
|
||||
assert.deepEqual(inventory(stopped),f.originalInventory);
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
advisorTransportMatchingKey,
|
||||
AdvisorTransportMatchingError,
|
||||
quoteAdvisorTransportMatching,
|
||||
validateAdvisorTransportMatching,
|
||||
type AdvisorTransportMatchingContext,
|
||||
type AdvisorTransportMatchingIntent,
|
||||
type AdvisorTransportMatchingSource,
|
||||
} from '../game/advisor-transport-matching';
|
||||
|
||||
function context(): AdvisorTransportMatchingContext {
|
||||
return {
|
||||
advanced: true, status: 'playing', turn: 4, phase: 5, active: 'b',
|
||||
advisorTransportMatchingOpportunity: 'private-original-transport-opportunity',
|
||||
players: [
|
||||
{
|
||||
id: 'b', faction: 'beneGesserit', shipped: false,
|
||||
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: 'g', faction: 'guild', forces: {} },
|
||||
],
|
||||
worlds: [
|
||||
{ id: 'homeworld:atreides', forces: { b: { normal: 2, elite: 0 }, a: { normal: 8, elite: 0 } } },
|
||||
{ id: 'homeworld:beneGesserit', forces: { b: { normal: 4, elite: 0 } } },
|
||||
{ id: 'homeworld:guild', forces: { b: { normal: 1, elite: 0 }, g: { normal: 10, elite: 0 } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function intent(fields: Partial<AdvisorTransportMatchingIntent> = {}): AdvisorTransportMatchingIntent {
|
||||
return {
|
||||
producer: 'guild', owner: 'b', origin: 'hagga_basin', destination: 'imperial_basin',
|
||||
sector: 10, advisors: true, group: { 'hagga_basin:12': 1, 'hagga_basin:13': 1 }, ...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function worldIntent(fields: Partial<AdvisorTransportMatchingIntent> = {}): AdvisorTransportMatchingIntent {
|
||||
return intent({
|
||||
producer: 'junction', origin: 'homeworld:atreides',
|
||||
group: { 'homeworld:beneGesserit': 2, 'homeworld:atreides': 1 }, ...fields,
|
||||
});
|
||||
}
|
||||
|
||||
function source(ctx = context(), incoming = intent()): AdvisorTransportMatchingSource {
|
||||
const quote = quoteAdvisorTransportMatching(ctx, incoming);
|
||||
assert.ok(quote);
|
||||
return { event: 'matching-counter', ...quote };
|
||||
}
|
||||
|
||||
void test('Guild and Junction board matching quote the selected incoming BG group in both directions before transport', () => {
|
||||
for (const producer of ['guild', 'junction'] as const) {
|
||||
const ctx = context();
|
||||
const incoming = intent({ producer });
|
||||
const before = structuredClone({ ctx, incoming });
|
||||
const quote = quoteAdvisorTransportMatching(ctx, incoming);
|
||||
assert.deepEqual(quote, {
|
||||
producer, 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(incoming.group['hagga_basin:12'], 1);
|
||||
const reverse = intent({
|
||||
producer, origin: 'carthag', destination: 'arrakeen', sector: 10, advisors: false,
|
||||
group: { 'carthag:11': 1 },
|
||||
});
|
||||
assert.equal(quoteAdvisorTransportMatching(ctx, reverse)?.advisors, false);
|
||||
assert.doesNotThrow(() => validateAdvisorTransportMatching(ctx, source(ctx, reverse)));
|
||||
assert.deepEqual(ctx, before.ctx);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Junction world matching uses owned normal physical counters and the first sorted source key without publishing the native scope', () => {
|
||||
const ctx = context();
|
||||
const incoming = worldIntent();
|
||||
const before = structuredClone({ ctx, incoming });
|
||||
const original = source(ctx, incoming);
|
||||
assert.equal(original.origin, 'homeworld:atreides');
|
||||
assert.equal(original.advisors, true);
|
||||
assert.deepEqual(original.group, { 'homeworld:beneGesserit': 2, 'homeworld:atreides': 1 });
|
||||
assert.doesNotThrow(() => validateAdvisorTransportMatching(ctx, original));
|
||||
assert.deepEqual(Object.keys(original).sort(), [
|
||||
'advisors', 'destination', 'event', 'group', 'origin', 'owner', 'phase', 'producer', 'sector', 'turn',
|
||||
]);
|
||||
assert.ok(!JSON.stringify(original).includes(ctx.advisorTransportMatchingOpportunity!));
|
||||
assert.deepEqual({ ctx, incoming }, before);
|
||||
assert.throws(() => quoteAdvisorTransportMatching(ctx, worldIntent({ origin: 'homeworld:beneGesserit' })), AdvisorTransportMatchingError);
|
||||
assert.throws(() => quoteAdvisorTransportMatching(ctx, worldIntent({ producer: 'guild' })), AdvisorTransportMatchingError);
|
||||
assert.equal(quoteAdvisorTransportMatching(ctx, worldIntent({
|
||||
destination: 'arrakeen', sector: 10, advisors: false,
|
||||
})), null);
|
||||
});
|
||||
|
||||
void test('Basic, inactive or spent native shipments, same-type groups, empty territories and return destinations need no matching quote', () => {
|
||||
for (const patch of [
|
||||
{ advanced: false }, { status: 'setup' }, { phase: 4 }, { phase: 6 }, { phase: 8 }, { active: 'a' },
|
||||
]) {
|
||||
assert.equal(quoteAdvisorTransportMatching({ ...context(), ...patch }, intent()), null);
|
||||
}
|
||||
const spent = context();
|
||||
spent.players[0].shipped = true;
|
||||
assert.equal(quoteAdvisorTransportMatching(spent, intent()), null);
|
||||
const foreign = context();
|
||||
foreign.players[0].faction = 'atreides';
|
||||
assert.equal(quoteAdvisorTransportMatching(foreign, intent()), null);
|
||||
assert.equal(quoteAdvisorTransportMatching(context(), intent({ owner: 'missing' })), null);
|
||||
assert.equal(quoteAdvisorTransportMatching(context(), intent({
|
||||
destination: 'arrakeen', sector: 10, advisors: false,
|
||||
})), null);
|
||||
assert.equal(quoteAdvisorTransportMatching(context(), intent({ origin: 'carthag', group: { 'carthag:11': 1 } })), null);
|
||||
assert.equal(quoteAdvisorTransportMatching(context(), intent({ destination: 'polar_sink', sector: 0 })), null);
|
||||
assert.equal(quoteAdvisorTransportMatching(context(), intent({
|
||||
origin: 'imperial_basin', sector: 11, group: { 'imperial_basin:9': 1 },
|
||||
})), null);
|
||||
const noScope = context();
|
||||
delete noScope.advisorTransportMatchingOpportunity;
|
||||
for (const destination of ['reserves', 'homeworld:beneGesserit']) {
|
||||
assert.equal(quoteAdvisorTransportMatching(noScope, intent({ destination, sector: 0 })), null);
|
||||
}
|
||||
assert.equal(quoteAdvisorTransportMatching(noScope, intent({ destination: 'polar_sink', sector: 0 })), null);
|
||||
assert.throws(() => quoteAdvisorTransportMatching(noScope, intent()), /original native opportunity/);
|
||||
});
|
||||
|
||||
void test('the private opportunity scopes prevention while UUID, sector and group insertion order do not change the same packet key', () => {
|
||||
const original = source();
|
||||
const scope = context().advisorTransportMatchingOpportunity!;
|
||||
const key = advisorTransportMatchingKey(original, scope);
|
||||
assert.equal(advisorTransportMatchingKey({
|
||||
...original, event: 'new-counter', sector: 11,
|
||||
group: { 'hagga_basin:13': 1, 'hagga_basin:12': 1 },
|
||||
}, scope), key);
|
||||
for (const change of [
|
||||
{ turn: 5 }, { owner: 'other' }, { producer: 'junction' as const }, { origin: 'carthag' },
|
||||
{ destination: 'carthag' }, { advisors: false }, { group: { 'hagga_basin:12': 1 } },
|
||||
]) {
|
||||
assert.notEqual(advisorTransportMatchingKey({ ...original, ...change }, scope), key);
|
||||
}
|
||||
assert.notEqual(advisorTransportMatchingKey(original, 'later-native-opportunity'), key);
|
||||
assert.throws(() => advisorTransportMatchingKey(original, ''), AdvisorTransportMatchingError);
|
||||
});
|
||||
|
||||
void test('transport prevention retains the uncommitted packet and blocks its same-territory retry only within the original native scope', () => {
|
||||
const ctx = context();
|
||||
const original = source(ctx);
|
||||
ctx.advisorTransportMatchingDenied = [advisorTransportMatchingKey(original, ctx.advisorTransportMatchingOpportunity!)];
|
||||
const before = structuredClone(ctx);
|
||||
assert.throws(() => quoteAdvisorTransportMatching(ctx, intent()), /already prevented.*original transport opportunity/);
|
||||
assert.throws(() => validateAdvisorTransportMatching(ctx, {
|
||||
...original, event: 'new-counter', sector: 11,
|
||||
}), AdvisorTransportMatchingError);
|
||||
assert.deepEqual(ctx, before);
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, intent({ group: { 'hagga_basin:12': 1 } })));
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, intent({ destination: 'carthag', sector: 11 })));
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, intent({ producer: 'junction' })));
|
||||
ctx.advisorTransportMatchingOpportunity = 'later-native-opportunity';
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, intent()));
|
||||
ctx.advisorTransportMatchingOpportunity = before.advisorTransportMatchingOpportunity;
|
||||
ctx.turn++;
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, intent()));
|
||||
});
|
||||
|
||||
void test('validation rejects stale native windows, target stance and selected board sources without changing the action', () => {
|
||||
const changes: ((ctx: AdvisorTransportMatchingContext, original: AdvisorTransportMatchingSource) => void)[] = [
|
||||
(ctx) => { ctx.active = 'a'; },
|
||||
(ctx) => { ctx.phase = 6; },
|
||||
(ctx) => { ctx.turn++; },
|
||||
(ctx) => { ctx.players[0].shipped = true; },
|
||||
(ctx) => { delete ctx.advisorTransportMatchingOpportunity; },
|
||||
(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.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 }; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12': 1.5 }; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12.0': 1 }; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12': 1, '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(() => validateAdvisorTransportMatching(ctx, original), (error: unknown) =>
|
||||
error instanceof AdvisorTransportMatchingError && error.name === 'AdvisorTransportMatchingError');
|
||||
assert.deepEqual({ ctx, original }, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Junction matching rejects absent, foreign-only, excessive or mixed board/world physical selections', () => {
|
||||
const changes: ((ctx: AdvisorTransportMatchingContext, incoming: AdvisorTransportMatchingIntent) => void)[] = [
|
||||
(ctx) => { delete ctx.worlds; },
|
||||
(ctx) => { ctx.worlds = ctx.worlds!.filter((world) => world.id !== 'homeworld:atreides'); },
|
||||
(ctx) => { ctx.worlds = [...ctx.worlds!, ctx.worlds![0]]; },
|
||||
(ctx) => { ctx.worlds![0].forces = { a: { normal: 10, elite: 0 } }; },
|
||||
(_ctx, incoming) => { incoming.group = { 'homeworld:atreides': 3 }; },
|
||||
(_ctx, incoming) => { incoming.group = { 'homeworld:atreides': 0 }; },
|
||||
(_ctx, incoming) => { incoming.group = { 'homeworld:atreides': 1, 'hagga_basin:12': 1 }; },
|
||||
(_ctx, incoming) => { incoming.group = { 'homeworld:missing': 1 }; },
|
||||
(_ctx, incoming) => { incoming.group = { 'homeworld:atreides:0': 1 }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const ctx = context();
|
||||
const incoming = worldIntent();
|
||||
change(ctx, incoming);
|
||||
assert.throws(() => quoteAdvisorTransportMatching(ctx, incoming), AdvisorTransportMatchingError);
|
||||
}
|
||||
const ctx = context();
|
||||
ctx.worlds![0].forces = { b: { normal: 0, elite: 2 } };
|
||||
assert.throws(() => quoteAdvisorTransportMatching(ctx, worldIntent()), AdvisorTransportMatchingError);
|
||||
});
|
||||
|
||||
void test('transport sources contain exactly ten public fields and current board locations remain room-specific', () => {
|
||||
const ctx = context();
|
||||
const original = source(ctx);
|
||||
for (const field of ['opportunity', 'advisorTransportMatchingOpportunity', 'order', 'signature', 'grant', 'token', 'noField', 'payment', 'rate']) {
|
||||
assert.throws(() => validateAdvisorTransportMatching(ctx, {
|
||||
...original, [field]: 'private-or-unrelated',
|
||||
} as AdvisorTransportMatchingSource), AdvisorTransportMatchingError);
|
||||
}
|
||||
for (const malformed of [
|
||||
null, [], { ...original, producer: 'ambassador' }, { ...original, advisors: 'true' }, { ...original, group: null },
|
||||
]) {
|
||||
assert.throws(() => validateAdvisorTransportMatching(ctx,
|
||||
malformed as unknown as AdvisorTransportMatchingSource), AdvisorTransportMatchingError);
|
||||
}
|
||||
const mobile = intent({ destination: 'hidden_mobile_stronghold', sector: 0 });
|
||||
assert.throws(() => quoteAdvisorTransportMatching(ctx, mobile), AdvisorTransportMatchingError);
|
||||
ctx.mobileStronghold = { location: 'hagga_basin:12' };
|
||||
ctx.players[0].forces = { ...ctx.players[0].forces, 'hidden_mobile_stronghold:0': 1 };
|
||||
ctx.players[0].advisors = { ...ctx.players[0].advisors, hidden_mobile_stronghold: {} };
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, mobile));
|
||||
assert.doesNotThrow(() => validateAdvisorTransportMatching(ctx, source(ctx, mobile)));
|
||||
});
|
||||
|
||||
void test('board matching avoids native grants, hands, wallets, concealed forces, forecasts and unrelated world custody', () => {
|
||||
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', 'eliteForces', 'reserves', 'moved']) {
|
||||
Object.defineProperty(player, field, {
|
||||
get() { throw new Error(`Transport 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(`Transport matching read unrelated stance ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const object of [ctx, incoming]) {
|
||||
for (const field of ['worlds', 'homeworlds', 'nexus', 'grant', 'signature', 'rate', 'forecast', 'stormCard', 'pendingShipment', 'advisorMatchingDenied']) {
|
||||
Object.defineProperty(object, field, {
|
||||
get() { throw new Error(`Transport matching read private native ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.defineProperty(ctx.players[1], 'forces', {
|
||||
get() { throw new Error('Transport matching reran native foreign occupancy'); },
|
||||
});
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, incoming));
|
||||
assert.doesNotThrow(() => validateAdvisorTransportMatching(ctx, original));
|
||||
});
|
||||
|
||||
void test('world matching reads only the selected owner normal counters without other-world or other-owner values', () => {
|
||||
const ctx = context();
|
||||
const original = source(ctx, worldIntent());
|
||||
for (const world of ctx.worlds!) {
|
||||
for (const owner of ['a', 'g']) {
|
||||
Object.defineProperty(world.forces, owner, {
|
||||
get() { throw new Error(`World matching read foreign ${owner} counters`); },
|
||||
});
|
||||
}
|
||||
Object.defineProperty(world.forces.b, 'elite', {
|
||||
get() { throw new Error('World matching used special instead of normal BG counters'); },
|
||||
});
|
||||
}
|
||||
Object.defineProperty(ctx.worlds![2], 'forces', {
|
||||
get() { throw new Error('World matching read an unselected world'); },
|
||||
});
|
||||
for (const player of ctx.players) {
|
||||
Object.defineProperty(player, 'reserves', {
|
||||
get() { throw new Error('World matching read private native reserve bookkeeping'); },
|
||||
});
|
||||
}
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, worldIntent()));
|
||||
assert.doesNotThrow(() => validateAdvisorTransportMatching(ctx, original));
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { splitLocation } from '../game/board';
|
||||
import {
|
||||
nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote,
|
||||
} from '../game/nexus-guild-secret-ally-options';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import {
|
||||
actAdvisorTransportMatching, advanceAdvisorTransportMatching, nextAdvisorTransportMatching,
|
||||
passAdvisorTransportMatching, finishAdvisorTransportMatching,
|
||||
advisorTransportMatchingPlayer, advisorTransportMatchingInventory, advisorTransportMatchingClean,
|
||||
type AdvisorTransportMatchingDirection, type AdvisorTransportMatchingStep,
|
||||
} from './fixture-advisor-transport-matching';
|
||||
|
||||
export type AdvisorSecretTransportMatchingStep = AdvisorTransportMatchingStep;
|
||||
export type AdvisorSecretTransportMatchingTransition = { before: Game; steps: AdvisorSecretTransportMatchingStep[]; after: Game };
|
||||
export const actAdvisorSecretTransportMatching = actAdvisorTransportMatching;
|
||||
export const nextAdvisorSecretTransportMatching = nextAdvisorTransportMatching;
|
||||
export const advanceAdvisorSecretTransportMatching = advanceAdvisorTransportMatching;
|
||||
export const passAdvisorSecretTransportMatching = passAdvisorTransportMatching;
|
||||
export const finishAdvisorSecretTransportMatching = finishAdvisorTransportMatching;
|
||||
export const advisorSecretTransportMatchingPlayer = advisorTransportMatchingPlayer;
|
||||
export const advisorSecretTransportMatchingInventory = advisorTransportMatchingInventory;
|
||||
export const advisorSecretTransportMatchingClean = advisorTransportMatchingClean;
|
||||
export const advisorSecretTransportNexusInventory = (game: Game) => {
|
||||
const cards = game.nexusCards!.cards!;
|
||||
const identities = [...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort();
|
||||
assert.equal(new Set(identities).size, identities.length, 'Every original physical Nexus singleton retains one current slot');
|
||||
return identities;
|
||||
};
|
||||
|
||||
/** Public Advanced BG/Atreides/Emperor with actual original Nexus setup. BG
|
||||
* stays unallied; a real A/Emperor alliance permits BG's native closing draw.
|
||||
* Only the still-unopened Guild singleton is selected before that real draw. */
|
||||
export function createAdvisorSecretTransportMatchingFixture(options: { direction?: AdvisorTransportMatchingDirection } = {}) {
|
||||
const direction = options.direction ?? 'fightersToAdvisors';
|
||||
const bg = 'beneGesserit', holder = 'atreides', emperor = 'emperor';
|
||||
let game = createGame('ADVISORSECRETTRANSPORTMATCHING', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
|
||||
joinGame(game, newPlayer(holder, 'Atreides', 'atreides')); joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), steps: AdvisorSecretTransportMatchingStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAdvisorSecretTransportMatching(game, actor, action, steps); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorSecretTransportMatching(game, until, steps); };
|
||||
const transition = (run: () => void): AdvisorSecretTransportMatchingTransition => {
|
||||
const before = structuredClone(game), start = steps.length; run();
|
||||
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
const enableNexus = transition(() => { act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true }); });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, []);
|
||||
assert.ok(game.nexusCards?.cards);
|
||||
assert.ok(!game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
|
||||
!game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
const originalInventory = advisorSecretTransportMatchingInventory(game); assert.equal(originalInventory.length, 33);
|
||||
const originalNexusInventory = advisorSecretTransportNexusInventory(game);
|
||||
assert.deepEqual(originalNexusInventory, [...NEXUS_FACTIONS].sort());
|
||||
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], karama, ordinary[1]];
|
||||
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);
|
||||
let advisorSetup: AdvisorSecretTransportMatchingTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextAdvisorSecretTransportMatching(game); assert.ok(next);
|
||||
const before = next.action.type === 'advisorSetup' ? structuredClone(game) : null;
|
||||
act(next.actor, next.action);
|
||||
if (before) advisorSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(advisorSecretTransportMatchingPlayer(game, bg).forces['arrakeen:10'], 1);
|
||||
assert.ok(advisorSecretTransportMatchingPlayer(game, bg).advisors?.arrakeen);
|
||||
assert.equal(advisorSecretTransportMatchingPlayer(game, holder).forces['arrakeen:10'], 10);
|
||||
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'].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 === 5 && state.active === bg && advisorSecretTransportMatchingClean(state));
|
||||
const fighterShipment = transition(() => {
|
||||
act(bg, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(advisorSecretTransportMatchingClean);
|
||||
});
|
||||
assert.equal(advisorSecretTransportMatchingPlayer(fighterShipment.before, bg).spice - advisorSecretTransportMatchingPlayer(game, bg).spice, 4);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && advisorSecretTransportMatchingClean(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 from = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(from >= 0); const worm = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(worm);
|
||||
const wormOrder = { position: 0, from, 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'].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 && advisorSecretTransportMatchingClean(state));
|
||||
const nexus = structuredClone(game);
|
||||
const alliance = transition(() => {
|
||||
act(holder, { type: 'alliance', target: emperor }); act(emperor, { type: 'alliance', target: holder });
|
||||
});
|
||||
assert.equal(advisorSecretTransportMatchingPlayer(game, holder).ally, emperor);
|
||||
assert.equal(advisorSecretTransportMatchingPlayer(game, emperor).ally, holder);
|
||||
assert.equal(advisorSecretTransportMatchingPlayer(game, bg).ally, null);
|
||||
reach(state => state.nexusCards?.phase?.stage === 'drawing' && advisorSecretTransportMatchingClean(state));
|
||||
const beforeNexusOrder = structuredClone(game), cards = game.nexusCards!.cards!;
|
||||
assert.equal(cards.hands[bg], null);
|
||||
const guildIndex = cards.deck.indexOf('guild'); assert.ok(guildIndex >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(guildIndex, 1)[0]);
|
||||
const nexusOrder = { card: 'guild', from: guildIndex, position: 0 };
|
||||
assert.deepEqual(advisorSecretTransportNexusInventory(game), originalNexusInventory);
|
||||
const beforeNexusDraw = structuredClone(game);
|
||||
const drawStep: AdvisorSecretTransportMatchingStep = { actor: bg, action: {
|
||||
type: 'nexusCardChoice', turn: game.turn, card: cards.hands[bg], choice: 'draw', ownRedraws: 0,
|
||||
} };
|
||||
const nexusDraw = transition(() => { act(drawStep.actor, drawStep.action); });
|
||||
assert.equal(game.nexusCards!.cards!.hands[bg], 'guild');
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === bg && advisorSecretTransportMatchingClean(state));
|
||||
const view = viewGame(game, bg), offer = view.nexusGuildSecretAlly;
|
||||
assert.ok(offer && !offer.blocked && nexusGuildSecretAllyCanAct(view));
|
||||
const declaration = (source: string, destination: string, amount: number) => {
|
||||
const to = splitLocation(destination);
|
||||
const ordinary: Action = { type: 'guildShip', from: source, territory: to.territory,
|
||||
sector: to.sector, amount, elite: 0, allyPayment: 0 };
|
||||
const action = nexusGuildSecretAllyAction(view, offer.event, ordinary); assert.ok(action);
|
||||
const quote = nexusGuildSecretAllyQuote(view, to.territory, amount); assert.ok(quote);
|
||||
return { action, cost: quote.cost, sources: { [source]: { normal: amount, elite: 0 } } };
|
||||
};
|
||||
const fighterQuote = declaration('imperial_basin:9', 'arrakeen:10', 1);
|
||||
const advisorQuote = declaration('arrakeen:10', 'imperial_basin:9', 1);
|
||||
const largerQuote = declaration('imperial_basin:9', 'arrakeen:10', 2);
|
||||
const emptyQuote = declaration('imperial_basin:9', 'polar_sink:0', 1);
|
||||
const quote = direction === 'fightersToAdvisors' ? fighterQuote : advisorQuote;
|
||||
const sourceLocation = direction === 'fightersToAdvisors' ? 'imperial_basin:9' : 'arrakeen:10';
|
||||
const destinationLocation = direction === 'fightersToAdvisors' ? 'arrakeen:10' : 'imperial_basin:9';
|
||||
const player = advisorSecretTransportMatchingPlayer(game, bg);
|
||||
assert.equal(player.ally, null); assert.ok(!game.players.some(player => player.faction === 'guild'));
|
||||
assert.equal(player.forces['imperial_basin:9'], 2); assert.ok(!player.advisors?.imperial_basin);
|
||||
assert.equal(player.forces['arrakeen:10'], 1); assert.ok(player.advisors?.arrakeen);
|
||||
assert.equal(player.shipped, false); assert.equal(player.moved, 0);
|
||||
assert.equal(quote.cost, 1); assert.ok(quote.cost <= player.spice);
|
||||
assert.equal(game.nexusCards!.cards!.hands[bg], 'guild');
|
||||
assert.ok(advisorSecretTransportMatchingPlayer(game, holder).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(advisorSecretTransportMatchingInventory(game), originalInventory);
|
||||
assert.deepEqual(advisorSecretTransportNexusInventory(game), originalNexusInventory);
|
||||
return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, enableNexus,
|
||||
lobby, setup, beforeStartingDeal, afterSetup, advisorSetup, fighterShipment,
|
||||
beforeWorm, wormOrder, wormFollowupOrder, nexus, alliance, beforeNexusOrder, nexusOrder,
|
||||
beforeNexusDraw, drawStep, nexusDraw, afterNexusDraw: nexusDraw.after,
|
||||
kind: 'secret' as const, direction, bg, owner: bg, holder, atreides: holder, emperor, guild: null,
|
||||
karama: karama.id, card: karama.id, nexusCard: 'guild' as const, event: offer.event, offer: structuredClone(offer),
|
||||
actor: bg, shipper: bg, action: quote.action, sources: quote.sources, amount: 1, cost: quote.cost,
|
||||
origin: splitLocation(sourceLocation).territory, destination: splitLocation(destinationLocation).territory,
|
||||
sourceLocation, destinationLocation, from: sourceLocation, to: destinationLocation,
|
||||
fightersToAdvisors: fighterQuote.action, advisorsToFighters: advisorQuote.action,
|
||||
largerPacket: largerQuote.action, noMatch: emptyQuote.action, replacement: largerQuote.action,
|
||||
replacements: [largerQuote.action, emptyQuote.action], matchingActions: [fighterQuote.action, advisorQuote.action],
|
||||
actionQuotes: { fighter: fighterQuote, advisor: advisorQuote, larger: largerQuote, empty: emptyQuote },
|
||||
transportStep: { actor: bg, action: quote.action } satisfies AdvisorSecretTransportMatchingStep,
|
||||
steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalNexusInventory,
|
||||
originalSpice, dealOrder, spiceOrder,
|
||||
staging: [
|
||||
'Actual public Advanced BG/Atreides/Emperor lobby enables Nexus through its original moduleOption action, then ready/startPreview and native base33/all12 Nexus setup. Guild is absent; no Homeworld, Discovery, Skills, Tech or Stronghold module is selected.',
|
||||
'Original prediction/private Traitors and actual advisorSetup place one BG advisor beside Atreides’ native ten Arrakeen10 counters. Only original scalar shuffles, three unopened Treachery positions and safe unopened Spice positions are chosen; Atreides actually receives and retains its printed Karama.',
|
||||
'During its real first turn BG pays four to ship two original reserves into empty Basin9 as fighters. Native Storm/dual Spice/Charity/Bidding/Revival, original preparation decline and peaceful remaining turns preserve both groups without a force, wallet, hand, phase or stance installation.',
|
||||
'At actual turn-two Shai-Hulud Nexus, Atreides really proposes alliance and Emperor really accepts. BG stays unallied. This separate native alliance supplies printed closing-draw eligibility; no BG alliance is created and no Nexus Card can be drawn at an entirely unallied table.',
|
||||
'Only the original still-unopened Guild Nexus singleton is moved to the next original deck position before BG’s real nexusCardChoice draw. The actual hand/phase history and all12 physical singletons are preserved; no held card, draw receipt or history is installed.',
|
||||
'Stop at BG’s real unused turn with the live absent-Guild Secret Ally offer. Both original guildShip directions carry its real event, plus packet2/empty replacements. The original Guild Nexus remains held; matching, physical transport, bank payment and recordGuildSecretShipment consumption remain caller-owned outcomes.',
|
||||
] };
|
||||
}
|
||||
export type AdvisorSecretTransportMatchingFixture = ReturnType<typeof createAdvisorSecretTransportMatchingFixture>;
|
||||
|
||||
export function commitAdvisorSecretTransportMatching(source: Game, fixture: AdvisorSecretTransportMatchingFixture,
|
||||
action: Action = fixture.action, steps: AdvisorSecretTransportMatchingStep[] = []) {
|
||||
const before = structuredClone(source), start = steps.length;
|
||||
const game = actAdvisorSecretTransportMatching(source, fixture.bg, action, steps);
|
||||
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { splitLocation, territory } from '../game/board';
|
||||
import { guildShipmentCost } from '../game/shipment-price';
|
||||
import { junctionTransportChoice } from '../game/junction-transport-options';
|
||||
import type { HomeworldShipmentSources } from '../game/homeworld-shipment-options';
|
||||
import {
|
||||
actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
|
||||
advisorPreparationPlayer, nextAdvisorPreparationStep,
|
||||
} from './fixture-advisor-preparation';
|
||||
|
||||
export type AdvisorTransportMatchingKind = 'guild' | 'junctionBoard' | 'junctionWorld';
|
||||
export type AdvisorTransportMatchingDirection = 'fightersToAdvisors' | 'advisorsToFighters';
|
||||
export type AdvisorTransportMatchingStep = { actor: string; action: Action };
|
||||
export type AdvisorTransportMatchingTransition = { before: Game; steps: AdvisorTransportMatchingStep[]; after: Game };
|
||||
export const actAdvisorTransportMatching = actAdvisorPreparation;
|
||||
export const advisorTransportMatchingPlayer = advisorPreparationPlayer;
|
||||
export const advisorTransportMatchingInventory = advisorPreparationInventory;
|
||||
export const advisorTransportMatchingClean = advisorPreparationClean;
|
||||
|
||||
function ownedTransportResponse(game: Game) {
|
||||
return !!game.response && ['advisorFlip', 'guildTransportPermission', 'guildRate'].includes(game.response.kind);
|
||||
}
|
||||
/** New matching and original permission/rate controls remain caller-owned.
|
||||
* Native early Guild timing yields the ordinary seat while sourcing BG's turn. */
|
||||
export function nextAdvisorTransportMatching(game: Game): AdvisorTransportMatchingStep | null {
|
||||
if (ownedTransportResponse(game)) return null;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
if (game.decision?.kind === 'advisorBattle')
|
||||
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||
if (game.decision?.kind === 'guildTiming')
|
||||
return { actor: game.decision.player, action: { type: 'decision', take: false } };
|
||||
if (game.decision?.kind === 'homeworldShipmentGuild')
|
||||
return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, allow: true } };
|
||||
}
|
||||
return nextAdvisorPreparationStep(game);
|
||||
}
|
||||
export function advanceAdvisorTransportMatching(game: Game, until: (state: Game) => boolean,
|
||||
steps: AdvisorTransportMatchingStep[] = []): Game {
|
||||
for (let count = 0; count < 2200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original transport chronology ended before its requested boundary');
|
||||
const next = nextAdvisorTransportMatching(game);
|
||||
assert.ok(next, `Choose the original matching/permission/rate control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actAdvisorTransportMatching(game, next.actor, next.action, steps);
|
||||
}
|
||||
throw Error('The native advisor transport matching source did not reach its finite boundary');
|
||||
}
|
||||
export function passAdvisorTransportMatching(game: Game, steps: AdvisorTransportMatchingStep[] = []): Game {
|
||||
for (let count = 0; game.response && !ownedTransportResponse(game) && count < 60; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actAdvisorTransportMatching(game, actor.id, { type: 'passResponse' }, steps);
|
||||
}
|
||||
assert.ok(!game.response || ownedTransportResponse(game)); return game;
|
||||
}
|
||||
|
||||
/** Native Guild ally cross-shipment or actual high-population Junction offer.
|
||||
* BG's two opposed board groups are earned by real setup and a paid shipment;
|
||||
* World mode instead uses the untouched original typed native reserve pool. */
|
||||
export function createAdvisorTransportMatchingFixture(options: {
|
||||
kind?: AdvisorTransportMatchingKind; direction?: AdvisorTransportMatchingDirection;
|
||||
} = {}) {
|
||||
const kind = options.kind ?? 'guild', direction = options.direction ?? 'fightersToAdvisors';
|
||||
assert.ok(kind !== 'junctionWorld' || direction === 'fightersToAdvisors', 'The native World source enters resident advisors');
|
||||
const bg = 'beneGesserit', guild = 'guild', holder = 'atreides', homeworlds = kind !== 'guild';
|
||||
let game = createGame('ADVISORTRANSPORTMATCHING', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
|
||||
joinGame(game, newPlayer(guild, 'Guild', 'guild')); joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
|
||||
const initial = structuredClone(game), steps: AdvisorTransportMatchingStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAdvisorTransportMatching(game, actor, action, steps); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorTransportMatching(game, until, steps); };
|
||||
const transition = (run: () => void): AdvisorTransportMatchingTransition => {
|
||||
const before = structuredClone(game), start = steps.length; run();
|
||||
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
const enableHomeworlds = homeworlds ? transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); }) : null;
|
||||
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, []);
|
||||
assert.equal(!!game.homeworlds, homeworlds);
|
||||
assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
|
||||
!game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
const originalInventory = advisorTransportMatchingInventory(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);
|
||||
let advisorSetup: AdvisorTransportMatchingTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextAdvisorTransportMatching(game); assert.ok(next);
|
||||
const before = next.action.type === 'advisorSetup' ? structuredClone(game) : null;
|
||||
act(next.actor, next.action);
|
||||
if (before) advisorSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(advisorTransportMatchingPlayer(game, bg).forces['arrakeen:10'], 1);
|
||||
assert.ok(advisorTransportMatchingPlayer(game, bg).advisors?.arrakeen);
|
||||
assert.equal(advisorTransportMatchingPlayer(game, holder).forces['arrakeen:10'], 10);
|
||||
assert.equal(advisorTransportMatchingPlayer(game, guild).forces['tueks_sietch:5'], 5);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1, 2, 3].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 === 5 && state.active === bg && advisorTransportMatchingClean(state));
|
||||
const beforeFirstShipment = structuredClone(game);
|
||||
let fighterShipment: AdvisorTransportMatchingTransition | null = null;
|
||||
let beforeWorm: Game | null = null, nexus: Game | null = null;
|
||||
let wormOrder: { position: number; from: number; card: Game['spiceDeck'][number] } | null = null;
|
||||
let wormFollowupOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
let alliance: AdvisorTransportMatchingTransition | null = null;
|
||||
if (kind !== 'junctionWorld') {
|
||||
fighterShipment = transition(() => {
|
||||
act(bg, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(advisorTransportMatchingClean);
|
||||
});
|
||||
assert.equal(advisorTransportMatchingPlayer(fighterShipment.before, bg).spice - advisorTransportMatchingPlayer(game, bg).spice, 4);
|
||||
assert.equal(advisorTransportMatchingPlayer(game, bg).forces['imperial_basin:9'], 2);
|
||||
assert.ok(!advisorTransportMatchingPlayer(game, bg).advisors?.imperial_basin);
|
||||
if (kind === 'guild') {
|
||||
reach(state => state.turn === 2 && state.phase === 1 && advisorTransportMatchingClean(state) &&
|
||||
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
|
||||
beforeWorm = structuredClone(game);
|
||||
const beforeWormInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const from = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(from >= 0); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(card);
|
||||
wormOrder = { position: 0, from, card: structuredClone(card) };
|
||||
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 && advisorTransportMatchingClean(state));
|
||||
nexus = structuredClone(game);
|
||||
alliance = transition(() => {
|
||||
act(bg, { type: 'alliance', target: guild }); act(guild, { type: 'alliance', target: bg });
|
||||
});
|
||||
assert.equal(advisorTransportMatchingPlayer(game, bg).ally, guild);
|
||||
assert.equal(advisorTransportMatchingPlayer(game, guild).ally, bg);
|
||||
}
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === bg && advisorTransportMatchingClean(state));
|
||||
}
|
||||
let offerStep: AdvisorTransportMatchingStep | null = null;
|
||||
let junctionOffer: AdvisorTransportMatchingTransition | null = null;
|
||||
if (homeworlds) {
|
||||
const sponsor = viewGame(game, guild).junctionTransport; assert.ok(sponsor?.canOffer);
|
||||
assert.equal(sponsor.recipient, bg);
|
||||
offerStep = { actor: guild, action: { type: 'offerJunctionTransport', event: sponsor.offerEvent, rate: 'half' } };
|
||||
junctionOffer = transition(() => { act(offerStep!.actor, offerStep!.action); });
|
||||
}
|
||||
const nativeWorld = homeworlds ? viewGame(game, bg).homeworlds!.worlds!.find(world => world.native === bg && !world.secondary)!.id : null;
|
||||
const fighterLocation = kind === 'junctionWorld' ? nativeWorld! : 'imperial_basin:9';
|
||||
const declaration = (from: string, to: string, amount: number) => {
|
||||
const sources: HomeworldShipmentSources = { [from]: { normal: amount, elite: 0 } };
|
||||
if (kind === 'guild') {
|
||||
const destination = splitLocation(to);
|
||||
const action: Action = { type: 'guildShip', from, territory: destination.territory,
|
||||
sector: destination.sector, amount, elite: 0, allyPayment: 0 };
|
||||
return { action, sources, cost: guildShipmentCost(territory(destination.territory).type, amount, true) };
|
||||
}
|
||||
const quote = junctionTransportChoice(viewGame(game, bg), to, sources, 0);
|
||||
assert.equal(quote.blocked, null); assert.ok(quote.action);
|
||||
return { action: quote.action, sources, cost: quote.cost };
|
||||
};
|
||||
const fighterQuote = declaration(fighterLocation, 'arrakeen:10', 1);
|
||||
const advisorQuote = kind === 'junctionWorld' ? null : declaration('arrakeen:10', 'imperial_basin:9', 1);
|
||||
const largerQuote = declaration(fighterLocation, 'arrakeen:10', 2);
|
||||
const emptyQuote = declaration(fighterLocation, kind === 'junctionWorld' ? 'imperial_basin:9' : 'polar_sink:0', 1);
|
||||
const quote = direction === 'fightersToAdvisors' ? fighterQuote : advisorQuote!;
|
||||
const sourceLocation = direction === 'fightersToAdvisors' ? fighterLocation : 'arrakeen:10';
|
||||
const destinationLocation = direction === 'fightersToAdvisors' ? 'arrakeen:10' : 'imperial_basin:9';
|
||||
const action = quote.action, player = advisorTransportMatchingPlayer(game, bg);
|
||||
assert.equal(game.active, bg); assert.equal(game.phase, 5); assert.ok(advisorTransportMatchingClean(game));
|
||||
assert.equal(player.shipped, false); assert.equal(player.moved, 0);
|
||||
assert.equal(player.forces['arrakeen:10'], 1); assert.ok(player.advisors?.arrakeen);
|
||||
if (kind !== 'junctionWorld') assert.equal(player.forces['imperial_basin:9'], 2);
|
||||
assert.ok(quote.cost <= player.spice); assert.equal(quote.cost, 1);
|
||||
assert.ok(advisorTransportMatchingPlayer(game, holder).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(advisorTransportMatchingInventory(game), originalInventory);
|
||||
return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, enableHomeworlds,
|
||||
lobby, setup, beforeStartingDeal, afterSetup, advisorSetup, beforeFirstShipment, fighterShipment,
|
||||
beforeWorm, wormOrder, wormFollowupOrder, nexus, alliance, offerStep, junctionOffer,
|
||||
kind, direction, bg, owner: bg, guild, holder, atreides: holder, karama: karama.id, card: karama.id,
|
||||
actor: bg, shipper: bg, nativeWorld, sourceLocation, destinationLocation, from: sourceLocation, to: destinationLocation,
|
||||
origin: sourceLocation.startsWith('homeworld:') ? sourceLocation : splitLocation(sourceLocation).territory,
|
||||
destination: splitLocation(destinationLocation).territory,
|
||||
action, sources: quote.sources, amount: 1, cost: quote.cost,
|
||||
fightersToAdvisors: fighterQuote.action, advisorsToFighters: advisorQuote?.action ?? null,
|
||||
largerPacket: largerQuote.action, noMatch: emptyQuote.action,
|
||||
replacement: largerQuote.action, replacements: [largerQuote.action, emptyQuote.action],
|
||||
matchingActions: [fighterQuote.action, ...(advisorQuote ? [advisorQuote.action] : [])],
|
||||
actionQuotes: { fighter: fighterQuote, advisor: advisorQuote, larger: largerQuote, empty: emptyQuote },
|
||||
transportStep: { actor: bg, action } satisfies AdvisorTransportMatchingStep,
|
||||
steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalSpice, dealOrder, spiceOrder,
|
||||
staging: [
|
||||
'Actual public Advanced BG/Guild/Atreides ready/startPreview, original base33 and no card/force modules. Original prediction/private Traitors and actual advisorSetup place BG beside Atreides’ ten Arrakeen10 counters. Atreides retains its originally dealt printed Karama.',
|
||||
homeworlds
|
||||
? 'Homeworlds are enabled by their actual lobby action. Original typed native pools and high-population Junction remain native; no custody, population or offer is installed.'
|
||||
: 'No Homeworld or optional module is selected. The native Guild alliance is earned later through the actual Nexus.',
|
||||
'Select only original scalar shuffle draws, three unopened Treachery positions and four safe unopened ordinary Spice positions. Native Storm/dual Spice/Charity/Bidding/Revival and original preparation decline/Guild timing/interception/income controls execute.',
|
||||
kind === 'junctionWorld'
|
||||
? 'Stop at the original first-turn BG unused shipment after Guild’s actual high-population half-price Junction offer. The real quote selects one ordinary counter from BG’s original native World and joins its resident Arrakeen advisor; no prior board fighter is claimed.'
|
||||
: 'BG actually pays four to ship two reserves into empty Basin9 on turn1. Guild’s native five remain in Tuek’s, avoiding a manufactured battle. Original Arrakeen advisors and Basin fighters survive native Collection/Mentat and turn-two Storm1; BG’s next shipment and movement remain unused.',
|
||||
kind === 'guild'
|
||||
? 'One original unopened turn-two Shai-Hulud and two safe original follow-up land cards earn an actual Nexus. BG actually proposes alliance and Guild actually accepts; only that reciprocal alliance permits the returned native guildShip cross declaration.'
|
||||
: 'Guild actually submits its current live Junction offer event at half tariff to BG; every returned declaration is the exact owner-view junctionTransportChoice over original typed sources. No native Guild alliance is claimed.',
|
||||
'Stop before the declaration and before new matching/original permission/rate. Expose both board directions, a genuine two-counter retry packet and an empty-destination replacement where applicable. No played phase, army, wallet, hand, alliance, stance, transport privilege, offer, response or receipt is installed.',
|
||||
] };
|
||||
}
|
||||
export type AdvisorTransportMatchingFixture = ReturnType<typeof createAdvisorTransportMatchingFixture>;
|
||||
|
||||
/** Declare only; matching and original permission/rate are never auto-passed. */
|
||||
export function commitAdvisorTransportMatching(source: Game, fixture: AdvisorTransportMatchingFixture,
|
||||
action: Action = fixture.action, steps: AdvisorTransportMatchingStep[] = []) {
|
||||
const before = structuredClone(source), start = steps.length;
|
||||
const game = actAdvisorTransportMatching(source, fixture.bg, action, steps);
|
||||
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
|
||||
}
|
||||
/** After explicit owned responses, resume only the original transport parent. */
|
||||
export function finishAdvisorTransportMatching(source: Game, steps: AdvisorTransportMatchingStep[] = []): Game {
|
||||
return advanceAdvisorTransportMatching(source,
|
||||
game => !game.pendingGuildTransport && !game.pendingHomeworldShipment && advisorTransportMatchingClean(game), steps);
|
||||
}
|
||||
Reference in new issue
Block a user