Counter incoming advisor matching before normal movement and shipment
Preserve residents and unused actions when one selected packet cannot match. Bind public matching receipts to private declarations and share packet-opportunity denials with legal bot filtering.
This commit is contained in:
1 parent
abf0305ddf
commit
438a5692a6
14 files changed
+992
-10
No files matched your search
@@ -0,0 +1,96 @@
|
||||
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 {
|
||||
createAdvisorMatchingMoveFixture, createAdvisorMatchingShipmentFixture,
|
||||
actAdvisorMatchingMove as act, advisorMatchingMovePlayer as player,
|
||||
advisorMatchingMoveInventory as inventory, commitAdvisorMatchingMove as commit,
|
||||
} from './fixture-advisor-matching-move';
|
||||
|
||||
const fixtures=[
|
||||
['advisor-to-fighter',()=>createAdvisorMatchingMoveFixture()],
|
||||
['fighter-to-advisor',()=>createAdvisorMatchingMoveFixture({direction:'fightersToAdvisors'})],
|
||||
['reserve-to-advisor',()=>createAdvisorMatchingShipmentFixture()],
|
||||
] as const;
|
||||
const physical=(g:Game)=>g.players.map(p=>({id:p.id,forces:p.forces,reserves:p.reserves,
|
||||
tanks:p.tanks,spice:p.spice,advisors:p.advisors,shipped:p.shipped,moved:p.moved}));
|
||||
function allow(g:Game){
|
||||
for(let n=0;g.response?.advisorMatchingSource&&n<30;n++){
|
||||
const actor=g.players.find(p=>!g.response!.passed.includes(p.id));assert.ok(actor);
|
||||
g=act(g,actor.id,{type:'passResponse'});
|
||||
}
|
||||
assert.ok(!g.response?.advisorMatchingSource);return g;
|
||||
}
|
||||
function rejected(g:Game,actor:string,action:Action){
|
||||
const before=structuredClone(g);assert.throws(()=>applyAction(g,actor,action));assert.deepEqual(g,before);
|
||||
}
|
||||
for(const [kind,fixture] of fixtures)for(const canceled of [false,true]){
|
||||
void test(`native ${kind} matching ${canceled?'prevention':'allowance'} preserves residents and commits only an allowed incoming packet`,()=>{
|
||||
const f=fixture(),before=structuredClone(f.game),pending=commit(f.game,f).game;
|
||||
const source=pending.response?.advisorMatchingSource;assert.ok(source);
|
||||
assert.equal(source.owner,f.bg);assert.equal(source.destination,f.destination);
|
||||
assert.equal(source.advisors,kind!=='advisor-to-fighter');
|
||||
assert.deepEqual(physical(pending),physical(before),'The matching response must precede transfer/payment/use.');
|
||||
assert.deepEqual(physical(normalizeAutomaticGame(pending)),physical(before));
|
||||
assert.ok(viewGame(pending,f.holder).responseControls?.cancelCards.includes(f.karama));
|
||||
assert.ok(!JSON.stringify(viewGame(pending,f.holder).response).includes('order'));
|
||||
assert.deepEqual(f.game,before);
|
||||
let done=canceled?act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}):allow(pending);
|
||||
assert.equal(done.pendingAdvisorMatching,undefined);assert.equal(done.response?.advisorMatchingSource,undefined);
|
||||
assert.equal(done.discard.filter(c=>c.id===f.karama).length,Number(canceled));
|
||||
assert.deepEqual(inventory(done),f.originalInventory);
|
||||
if(canceled){
|
||||
assert.deepEqual(physical(done),physical(before));assert.equal(done.advisorMatchingDenied?.length,1);
|
||||
rejected(done,f.bg,f.action);
|
||||
if(kind==='advisor-to-fighter') rejected(done,f.bg,{...f.action,sector:10});
|
||||
done=act(done,f.bg,kind==='reserve-to-advisor'
|
||||
? {type:'ship',territory:'imperial_basin',sector:9,amount:1,elite:0,allyPayment:0}
|
||||
: f.ordinaryAction);
|
||||
assert.equal(done.pendingAdvisorMatching,undefined);
|
||||
assert.equal(player(done,f.bg).moved,kind==='reserve-to-advisor'?0:1);
|
||||
assert.equal(player(done,f.bg).shipped,true);
|
||||
}else{
|
||||
assert.equal(player(done,f.bg).forces[f.destinationLocation],2);
|
||||
assert.equal(isAdvisor(player(done,f.bg),f.destination),source.advisors);
|
||||
if(kind==='reserve-to-advisor'){
|
||||
assert.equal(player(done,f.bg).reserves,player(before,f.bg).reserves-1);
|
||||
assert.equal(player(done,f.bg).spice,player(before,f.bg).spice-1);
|
||||
assert.equal(player(done,f.bg).shipped,true);assert.equal(player(done,f.bg).moved,0);
|
||||
}else{
|
||||
assert.equal(player(done,f.bg).forces[f.sourceLocation],undefined);
|
||||
assert.equal(player(done,f.bg).moved,1);
|
||||
assert.equal(player(done,f.bg).spice,player(before,f.bg).spice);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void test('an earned same-type fighter sector reposition creates no matching counter',()=>{
|
||||
const f=createAdvisorMatchingMoveFixture(),g=act(f.game,f.bg,f.ordinaryAction);
|
||||
assert.equal(g.pendingAdvisorMatching,undefined);assert.equal(g.response?.advisorMatchingSource,undefined);
|
||||
assert.equal(player(g,f.bg).forces['imperial_basin:10'],1);
|
||||
assert.equal(player(g,f.bg).moved,1);assert.ok(player(g,f.holder).hand.some(c=>c.id===f.karama));
|
||||
});
|
||||
|
||||
for(const [kind,fixture] of fixtures)void test(`${kind} mismatched private arrival rejects before a matching counter spends Karama`,()=>{
|
||||
const f=fixture(),g=commit(f.game,f).game;
|
||||
g.pendingAdvisorMatching!.signature='different-uncommitted-order';
|
||||
rejected(g,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
assert.ok(player(g,f.holder).hand.some(c=>c.id===f.karama));
|
||||
});
|
||||
|
||||
void test('all existing profiles can answer matching counters and choose a legal replacement after prevention',()=>{
|
||||
for(const [,fixture] of fixtures)for(const profile of DIFFICULTIES){
|
||||
const f=fixture(),pending=commit(f.game,f).game,own=viewGame(pending,f.holder);
|
||||
own.players.find(p=>p.id===f.holder)!.bot=profile;
|
||||
const response=botActions(own);assert.ok(response.length);
|
||||
assert.doesNotThrow(()=>allow(act(pending,f.holder,response[0])));
|
||||
const canceled=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}),bg=viewGame(canceled,f.bg);
|
||||
bg.players.find(p=>p.id===f.bg)!.bot=profile;
|
||||
const replacement=botActions(bg);assert.ok(replacement.length);
|
||||
assert.doesNotThrow(()=>act(canceled,f.bg,replacement[0]));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
advisorMatchingKey,
|
||||
AdvisorMatchingError,
|
||||
quoteAdvisorMatching,
|
||||
validateAdvisorMatching,
|
||||
type AdvisorMatchingContext,
|
||||
type AdvisorMatchingIntent,
|
||||
type AdvisorMatchingSource,
|
||||
} from '../game/advisor-matching';
|
||||
|
||||
function context(): AdvisorMatchingContext {
|
||||
return {
|
||||
advanced: true, status: 'playing', phase: 5, turn: 4, active: 'b',
|
||||
players: [
|
||||
{
|
||||
id: 'b', faction: 'beneGesserit', moved: 0, reserves: 10,
|
||||
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 } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function movement(fields: Partial<AdvisorMatchingIntent> = {}): AdvisorMatchingIntent {
|
||||
return {
|
||||
kind: 'movement', owner: 'b', move: 0, origin: 'hagga_basin', destination: 'imperial_basin',
|
||||
sector: 10, advisors: true, group: { 'hagga_basin:12': 1, 'hagga_basin:13': 1 }, ...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function shipment(fields: Partial<AdvisorMatchingIntent> = {}): AdvisorMatchingIntent {
|
||||
return {
|
||||
kind: 'shipment', owner: 'b', move: 0, origin: 'reserves', destination: 'imperial_basin',
|
||||
sector: 10, advisors: true, group: { reserves: 2 }, ...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function source(ctx = context(), intent = movement()): AdvisorMatchingSource {
|
||||
const quote = quoteAdvisorMatching(ctx, intent);
|
||||
assert.ok(quote);
|
||||
return { event: 'original-matching-response', ...quote };
|
||||
}
|
||||
|
||||
void test('movement matching quotes only the selected incoming packet in both directions and never changes resident forces or stance', () => {
|
||||
const ctx = context();
|
||||
const incoming = movement();
|
||||
const before = structuredClone({ ctx, incoming });
|
||||
const quote = quoteAdvisorMatching(ctx, incoming);
|
||||
assert.deepEqual(quote, {
|
||||
kind: 'movement', owner: 'b', turn: 4, move: 0, 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 = movement({
|
||||
origin: 'carthag', destination: 'arrakeen', sector: 10, advisors: false,
|
||||
group: { 'carthag:11': 1 },
|
||||
});
|
||||
assert.equal(quoteAdvisorMatching(ctx, reverse)?.advisors, false);
|
||||
const original = source(ctx, reverse);
|
||||
assert.doesNotThrow(() => validateAdvisorMatching(ctx, original));
|
||||
assert.deepEqual(Object.keys(original).sort(), [
|
||||
'advisors', 'destination', 'event', 'group', 'kind', 'move', 'origin', 'owner', 'sector', 'turn',
|
||||
]);
|
||||
assert.deepEqual(ctx, before.ctx);
|
||||
});
|
||||
|
||||
void test('Basic, foreign actors, other windows, absent residents and same-type ground groups need no matching response', () => {
|
||||
for (const patch of [
|
||||
{ advanced: false }, { status: 'setup' }, { phase: 4 }, { phase: 6 }, { active: 'a' },
|
||||
]) {
|
||||
assert.equal(quoteAdvisorMatching({ ...context(), ...patch }, movement()), null);
|
||||
}
|
||||
const outsider = context();
|
||||
outsider.players[0].faction = 'atreides';
|
||||
assert.equal(quoteAdvisorMatching(outsider, movement()), null);
|
||||
assert.equal(quoteAdvisorMatching(context(), movement({ owner: 'missing' })), null);
|
||||
assert.equal(quoteAdvisorMatching(context(), movement({
|
||||
destination: 'arrakeen', sector: 10, advisors: false,
|
||||
})), null);
|
||||
assert.equal(quoteAdvisorMatching(context(), movement({
|
||||
origin: 'carthag', group: { 'carthag:11': 1 }, advisors: true,
|
||||
})), null);
|
||||
assert.equal(quoteAdvisorMatching(context(), movement({
|
||||
origin: 'imperial_basin', destination: 'imperial_basin', sector: 11,
|
||||
group: { 'imperial_basin:9': 1 },
|
||||
})), null);
|
||||
const empty = context();
|
||||
empty.players[0].advisors = { ...empty.players[0].advisors, polar_sink: {} };
|
||||
assert.equal(quoteAdvisorMatching(empty, movement({ destination: 'polar_sink', sector: 0 })), null);
|
||||
});
|
||||
|
||||
void test('ordinary reserve shipments use the provisional fighter default and only joining resident advisors needs conversion', () => {
|
||||
const ctx = context();
|
||||
const before = structuredClone(ctx);
|
||||
assert.deepEqual(quoteAdvisorMatching(ctx, shipment()), {
|
||||
kind: 'shipment', owner: 'b', turn: 4, move: 0, origin: 'reserves', destination: 'imperial_basin',
|
||||
sector: 10, advisors: true, group: { reserves: 2 },
|
||||
});
|
||||
assert.doesNotThrow(() => validateAdvisorMatching(ctx, source(ctx, shipment())));
|
||||
assert.equal(quoteAdvisorMatching(ctx, shipment({ destination: 'arrakeen', sector: 10, advisors: false })), null);
|
||||
assert.equal(quoteAdvisorMatching(ctx, shipment({ destination: 'polar_sink', sector: 0, advisors: false })), null);
|
||||
assert.deepEqual(ctx, before);
|
||||
assert.throws(() => quoteAdvisorMatching(ctx, shipment({ move: 1 })), AdvisorMatchingError);
|
||||
assert.throws(() => quoteAdvisorMatching(ctx, shipment({ group: { 'hagga_basin:12': 2 } })), AdvisorMatchingError);
|
||||
ctx.players[0].reserves = 1;
|
||||
assert.throws(() => quoteAdvisorMatching(ctx, shipment()), AdvisorMatchingError);
|
||||
});
|
||||
|
||||
void test('stable matching keys ignore response UUID, destination sector and map insertion order while binding the owned opportunity and packet', () => {
|
||||
const original = source();
|
||||
const key = advisorMatchingKey(original);
|
||||
assert.equal(advisorMatchingKey({
|
||||
...original, event: 'new-response-uuid', sector: 11,
|
||||
group: { 'hagga_basin:13': 1, 'hagga_basin:12': 1 },
|
||||
}), key);
|
||||
for (const change of [
|
||||
{ turn: 5 }, { owner: 'other' }, { kind: 'shipment' as const }, { move: 1 },
|
||||
{ destination: 'carthag' }, { advisors: false }, { group: { 'hagga_basin:12': 1 } },
|
||||
]) {
|
||||
assert.notEqual(advisorMatchingKey({ ...original, ...change }), key);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Karama denial stops the same packet to the same territory only in its current owned opportunity', () => {
|
||||
const ctx = context();
|
||||
const original = source(ctx);
|
||||
ctx.advisorMatchingDenied = [advisorMatchingKey(original)];
|
||||
const before = structuredClone(ctx);
|
||||
assert.throws(() => quoteAdvisorMatching(ctx, movement()), /already prevented.*current owned opportunity/);
|
||||
assert.throws(() => quoteAdvisorMatching(ctx, movement({ sector: 11 })), AdvisorMatchingError);
|
||||
assert.throws(() => validateAdvisorMatching(ctx, { ...original, event: 'different-uuid', sector: 9 }), AdvisorMatchingError);
|
||||
assert.deepEqual(ctx, before);
|
||||
assert.ok(quoteAdvisorMatching(ctx, movement({ group: { 'hagga_basin:12': 1 } })));
|
||||
assert.ok(quoteAdvisorMatching(ctx, movement({ destination: 'carthag', sector: 11 })));
|
||||
assert.ok(quoteAdvisorMatching(ctx, shipment()));
|
||||
ctx.players[0].moved = 1;
|
||||
assert.ok(quoteAdvisorMatching(ctx, movement({ move: 1 })));
|
||||
ctx.players[0].moved = 0;
|
||||
ctx.turn = 5;
|
||||
assert.ok(quoteAdvisorMatching(ctx, movement()));
|
||||
ctx.turn = 4;
|
||||
ctx.players[0].advisors = { carthag: {} };
|
||||
assert.equal(quoteAdvisorMatching(ctx, movement({ advisors: false })), null);
|
||||
});
|
||||
|
||||
void test('source validation rejects stale owner, opportunity, physical selection and resident stance without changing the uncommitted action', () => {
|
||||
const changes: ((ctx: AdvisorMatchingContext, original: AdvisorMatchingSource) => void)[] = [
|
||||
(ctx) => { ctx.active = 'a'; },
|
||||
(ctx) => { ctx.turn++; },
|
||||
(ctx) => { ctx.phase = 6; },
|
||||
(ctx) => { ctx.players[0].moved = 1; },
|
||||
(ctx) => { delete ctx.players[0].moved; },
|
||||
(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.move = 1; },
|
||||
(_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 }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const ctx = context();
|
||||
const original = source(ctx);
|
||||
change(ctx, original);
|
||||
const before = structuredClone({ ctx, original });
|
||||
assert.throws(() => validateAdvisorMatching(ctx, original), (error: unknown) =>
|
||||
error instanceof AdvisorMatchingError && error.name === 'AdvisorMatchingError');
|
||||
assert.deepEqual({ ctx, original }, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('matching receipt shapes reject private order data, multiple-origin groups and non-board destinations', () => {
|
||||
const ctx = context();
|
||||
const original = source(ctx);
|
||||
for (const field of ['order', 'signature', 'leader', 'skills', 'payment', 'aloneFlipHold', 'phase']) {
|
||||
assert.throws(() => validateAdvisorMatching(ctx, { ...original, [field]: 'private-or-unrelated' } as AdvisorMatchingSource), AdvisorMatchingError);
|
||||
}
|
||||
for (const malformed of [
|
||||
null, [], { ...original, kind: 'ambassador' }, { ...original, advisors: 'true' },
|
||||
{ ...original, group: null }, { ...original, group: { 'hagga_basin:12': 1, 'carthag:11': 1 } },
|
||||
]) {
|
||||
assert.throws(() => validateAdvisorMatching(ctx, malformed as unknown as AdvisorMatchingSource), AdvisorMatchingError);
|
||||
}
|
||||
assert.throws(() => quoteAdvisorMatching(ctx, movement({ destination: 'homeworld:guild', sector: 0 })), AdvisorMatchingError);
|
||||
assert.throws(() => quoteAdvisorMatching(ctx, movement({ origin: 'homeworld:beneGesserit' })), AdvisorMatchingError);
|
||||
});
|
||||
|
||||
void test('matching uses only the selected public counters and stance identity without private funding, leaders or automatic-flip holds', () => {
|
||||
const ctx = context();
|
||||
const original = source(ctx);
|
||||
for (const player of ctx.players) {
|
||||
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'skills', 'ready']) {
|
||||
Object.defineProperty(player, field, {
|
||||
get() { throw new Error(`Advisor 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(`Matching interpreted unrelated arrival or automatic-flip ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const field of ['pendingMove', 'pendingShipment', 'leaderSkills', 'matchingOrder']) {
|
||||
Object.defineProperty(ctx, field, {
|
||||
get() { throw new Error(`Matching read private original parent ${field}`); },
|
||||
});
|
||||
}
|
||||
assert.ok(quoteAdvisorMatching(ctx, movement()));
|
||||
assert.doesNotThrow(() => validateAdvisorMatching(ctx, original));
|
||||
assert.ok(quoteAdvisorMatching(ctx, shipment()));
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
|
||||
advisorPreparationPlayer, nextAdvisorPreparationStep,
|
||||
} from './fixture-advisor-preparation';
|
||||
|
||||
export type AdvisorMatchingMoveDirection = 'advisorsToFighters' | 'fightersToAdvisors';
|
||||
export type AdvisorMatchingMoveStep = { actor: string; action: Action };
|
||||
export type AdvisorMatchingMoveTransition = { before: Game; steps: AdvisorMatchingMoveStep[]; after: Game };
|
||||
export const actAdvisorMatchingMove = actAdvisorPreparation;
|
||||
export const advisorMatchingMovePlayer = advisorPreparationPlayer;
|
||||
export const advisorMatchingMoveInventory = advisorPreparationInventory;
|
||||
export const advisorMatchingMoveClean = advisorPreparationClean;
|
||||
|
||||
/** Original preparation is declined. Any advisorFlip response after the owned
|
||||
* movement declaration remains caller-owned, including a new matching quote. */
|
||||
export function nextAdvisorMatchingMove(game: Game): AdvisorMatchingMoveStep | null {
|
||||
if (game.response?.kind === 'advisorFlip') return null;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard && game.decision?.kind === 'advisorBattle')
|
||||
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||
return nextAdvisorPreparationStep(game);
|
||||
}
|
||||
export function advanceAdvisorMatchingMove(game: Game, until: (state: Game) => boolean,
|
||||
steps: AdvisorMatchingMoveStep[] = []): Game {
|
||||
for (let count = 0; count < 1600; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The native matching-move chronology ended before its requested boundary');
|
||||
const next = nextAdvisorMatchingMove(game);
|
||||
assert.ok(next, `Choose the original matching response at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actAdvisorMatchingMove(game, next.actor, next.action, steps);
|
||||
}
|
||||
throw Error('The native advisor matching source did not reach its finite boundary');
|
||||
}
|
||||
export function passAdvisorMatchingMove(game: Game, steps: AdvisorMatchingMoveStep[] = []): 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 = actAdvisorMatchingMove(game, actor.id, { type: 'passResponse' }, steps);
|
||||
}
|
||||
assert.ok(!game.response || game.response.kind === 'advisorFlip'); return game;
|
||||
}
|
||||
|
||||
/** Genuine public Advanced BG/Atreides/Emperor base33 source. An original
|
||||
* advisor and a separate paid native fighter are earned before the owned move.
|
||||
* No matching rule permission, response, stance or committed move is installed. */
|
||||
export function createAdvisorMatchingMoveFixture(options: { direction?: AdvisorMatchingMoveDirection } = {}) {
|
||||
const direction = options.direction ?? 'advisorsToFighters';
|
||||
const bg = 'beneGesserit', holder = 'atreides', emperor = 'emperor';
|
||||
let game = createGame('ADVISORMATCHINGMOVE', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
|
||||
joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), steps: AdvisorMatchingMoveStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAdvisorMatchingMove(game, actor, action, steps); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorMatchingMove(game, until, steps); };
|
||||
const transition = (run: () => void): AdvisorMatchingMoveTransition => {
|
||||
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, []);
|
||||
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 = advisorMatchingMoveInventory(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], 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: AdvisorMatchingMoveTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextAdvisorMatchingMove(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(advisorMatchingMovePlayer(game, bg).forces['arrakeen:10'], 1);
|
||||
assert.ok(advisorMatchingMovePlayer(game, bg).advisors?.arrakeen);
|
||||
assert.equal(advisorMatchingMovePlayer(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);
|
||||
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.response && !state.phaseOpening && state.decision?.kind === 'advisorBattle');
|
||||
const openingOffer = structuredClone(game);
|
||||
const declinedPreparation = transition(() => {
|
||||
act(bg, { type: 'decision', accept: false });
|
||||
reach(state => state.phase === 5 && state.active === bg && advisorMatchingMoveClean(state));
|
||||
});
|
||||
const beforeShipment = structuredClone(game), beforeShipmentActionCount = steps.length;
|
||||
const matchingShipmentAction: Action = { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 };
|
||||
const shipment = transition(() => {
|
||||
act(bg, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(advisorMatchingMoveClean);
|
||||
});
|
||||
const player = advisorMatchingMovePlayer(game, bg);
|
||||
assert.equal(advisorMatchingMovePlayer(shipment.before, bg).spice - player.spice, 2);
|
||||
assert.equal(game.active, bg); assert.equal(game.phase, 5); assert.ok(advisorMatchingMoveClean(game));
|
||||
assert.equal(player.forces['arrakeen:10'], 1); assert.ok(player.advisors?.arrakeen);
|
||||
assert.equal(player.forces['imperial_basin:9'], 1); assert.ok(!player.advisors?.imperial_basin);
|
||||
assert.equal(player.shipped, true); assert.equal(player.moved, 0);
|
||||
assert.equal(advisorMatchingMovePlayer(game, holder).forces['arrakeen:10'], 10);
|
||||
assert.ok(advisorMatchingMovePlayer(game, holder).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(advisorMatchingMoveInventory(game), originalInventory);
|
||||
const advisorsToFighters: Action = { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 9, amount: 1 };
|
||||
const fightersToAdvisors: Action = { type: 'move', from: 'imperial_basin:9', territory: 'arrakeen', sector: 10, amount: 1 };
|
||||
const ordinaryAction: Action = { type: 'move', from: 'imperial_basin:9', territory: 'imperial_basin', sector: 10, amount: 1 };
|
||||
const action = direction === 'advisorsToFighters' ? advisorsToFighters : fightersToAdvisors;
|
||||
const origin = direction === 'advisorsToFighters' ? 'arrakeen' : 'imperial_basin';
|
||||
const destination = direction === 'advisorsToFighters' ? 'imperial_basin' : 'arrakeen';
|
||||
const sourceLocation = direction === 'advisorsToFighters' ? 'arrakeen:10' : 'imperial_basin:9';
|
||||
const destinationLocation = direction === 'advisorsToFighters' ? 'imperial_basin:9' : 'arrakeen:10';
|
||||
return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, lobby, setup,
|
||||
beforeStartingDeal, afterSetup, advisorSetup, openingOffer, declinedPreparation,
|
||||
beforeShipment, beforeShipmentActionCount, matchingShipmentAction, shipment,
|
||||
direction, bg, owner: bg, holder, atreides: holder, emperor, observer: emperor,
|
||||
karama: karama.id, card: karama.id, actor: bg, action, origin, destination,
|
||||
sourceLocation, destinationLocation, from: sourceLocation, to: destinationLocation, amount: 1,
|
||||
advisorsToFighters, fightersToAdvisors, ordinaryAction,
|
||||
movementStep: { actor: bg, action } satisfies AdvisorMatchingMoveStep,
|
||||
ordinaryStep: { actor: bg, action: ordinaryAction } satisfies AdvisorMatchingMoveStep,
|
||||
steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalSpice, dealOrder, spiceOrder,
|
||||
staging: [
|
||||
'Actual public Advanced BG/Atreides/Emperor ready/startPreview, native prediction/private Traitor choices, original base33 and no optional module. Original advisorSetup places one peaceful BG advisor beside Atreides’ native ten Arrakeen10 counters.',
|
||||
'Select only original scalar shuffle draws, three original unopened Treachery positions and two original unopened ordinary Spice positions. Atreides is actually dealt its original Karama; BG/Emperor receive ordinary cards. No later hand or card acquisition is staged.',
|
||||
'Actually complete safe first Storm, both Spice blows, Charity, Bidding and Revival, then decline the original advisor preparation. During its real first turn BG pays two to ship one original reserve into empty Basin9 as a fighter, retaining the original Arrakeen advisor.',
|
||||
'Stop with shipment genuinely used and ordinary movement still unused. Both original one-counter move payloads are exposed: advisor Arrakeen10 into own Basin9 fighter, or that fighter into own Arrakeen advisor. The matching rule response/permission and any force/stance/move consumption remain caller-owned outcomes.',
|
||||
'An additional ordinary control repositions the genuinely earned Basin fighter from sector9 to empty sector10 of the same territory. It introduces no opposing BG stance; no second manufactured group is claimed.',
|
||||
'No played force, phase, wallet, hand, advisor stance, Tanks, prevention, matching response or movement receipt is installed.',
|
||||
] };
|
||||
}
|
||||
export type AdvisorMatchingMoveFixture = ReturnType<typeof createAdvisorMatchingMoveFixture>;
|
||||
|
||||
/** Branch at the actual earlier unshipped source window. Its logged prefix
|
||||
* ends before the separate Basin shipment; the owned payload instead sends a
|
||||
* real reserve to the original resident Arrakeen advisor. No later state is set. */
|
||||
export function createAdvisorMatchingShipmentFixture() {
|
||||
const fixture = createAdvisorMatchingMoveFixture();
|
||||
const game = structuredClone(fixture.beforeShipment);
|
||||
const steps = structuredClone(fixture.steps.slice(0, fixture.beforeShipmentActionCount));
|
||||
const action = fixture.matchingShipmentAction;
|
||||
assert.equal(advisorMatchingMovePlayer(game, fixture.bg).shipped, false);
|
||||
assert.equal(advisorMatchingMovePlayer(game, fixture.bg).moved, 0);
|
||||
assert.equal(advisorMatchingMovePlayer(game, fixture.bg).forces['imperial_basin:9'] ?? 0, 0);
|
||||
return { ...fixture, game, source: structuredClone(game), beforeAction: structuredClone(game),
|
||||
kind: 'shipment' as const, origin: 'reserves', destination: 'arrakeen',
|
||||
sourceLocation: 'reserves', destinationLocation: 'arrakeen:10', from: 'reserves', to: 'arrakeen:10',
|
||||
actor: fixture.bg, action, amount: 1, cost: 1,
|
||||
shipmentStep: { actor: fixture.bg, action } satisfies AdvisorMatchingMoveStep,
|
||||
steps, actions: steps, sourceActionCount: steps.length,
|
||||
staging: [
|
||||
'Use the recorded genuine earlier public Advanced BG/Atreides/Emperor source prefix: original base33, prediction/private Traitors, actual BG advisorSetup beside A10 in Arrakeen10, original A Karama and ordinary BG/Emperor starting cards.',
|
||||
'Only original scalar shuffle draws, three original unopened Treachery positions and two original unopened ordinary Spice positions are selected. Native first Storm, both Spice blows, Charity, Bidding and Revival execute; original advisor preparation is actually declined.',
|
||||
'Return the actual saved source snapshot before BG’s first shipment, with both shipment and movement unused and no Basin counter present. Its action log ends at that original prefix; the separately recorded Basin shipment belongs only to the ground-move branch.',
|
||||
'The owned normal reserve-shipment payload sends one original reserve into BG’s resident Arrakeen advisor group for the normal one-spice stronghold price. Matching response, price payment, physical shipment and allowance consumption remain caller-owned outcomes; no response or permission is installed.',
|
||||
] };
|
||||
}
|
||||
|
||||
/** Submit only the original owned move declaration; never answer its counter. */
|
||||
export function commitAdvisorMatchingMove(source: Game, fixture: AdvisorMatchingMoveFixture,
|
||||
steps: AdvisorMatchingMoveStep[] = []) {
|
||||
const before = structuredClone(source), start = steps.length;
|
||||
const game = actAdvisorMatchingMove(source, fixture.actor, fixture.action, steps);
|
||||
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
|
||||
}
|
||||
export function finishAdvisorMatchingMove(source: Game, steps: AdvisorMatchingMoveStep[] = []): Game {
|
||||
return advanceAdvisorMatchingMove(source,
|
||||
game => game.phase >= 6 && game.phase <= 7 && advisorMatchingMoveClean(game), steps);
|
||||
}
|
||||
Reference in new issue
Block a user