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:
Quintine committed 2026-10-24 21:50:02 +11:00
1 parent abf0305ddf
commit 438a5692a6
14 files changed
+992 -10

No files matched your search

+8
View File
@@ -2493,6 +2493,14 @@ export function GameTable({
until another faction returns or the group leaves.
</p>
)}
{g.response.advisorMatchingSource && (
<p className="notice">
The incoming group must become {g.response.advisorMatchingSource.advisors ? 'advisors' : 'fighters'} to match your forces already in the destination.
{' '}This {g.response.advisorMatchingSource.kind === 'movement' ? 'movement' : 'shipment'} has not happened yet.
{' '}Karama prevents this group from matching here during this action opportunity. Residents keep their type; no forces, shipment payment or unused action are spent.
{' '}Any already-played movement or declaration card keeps its original use. Choose a legal replacement if matching is prevented.
</p>
)}
{g.response.kind === 'advisor' && (
<p className="notice">
{g.players.find((player) => player.id === g.response?.owner)?.name} declared {g.response.amount ?? 1} free {(g.response.amount ?? 1) === 1 ? 'force' : 'forces'} to {territory(splitLocation(g.response.location ?? 'polar_sink:0').territory).name}.
+97
View File
@@ -0,0 +1,97 @@
# Matching incoming Bene Gesserit groups
## Normal movement and reserve shipment — 24 October 2026
**Prototyped / Partial.** The supplied revised
Advanced rulebook physical28/printed26, E, explicitly marks its matching-type
shipping/movement bullet with the Karama icon. Physical17/printed15 identifies
the canceled operation as flipping one group to or from advisors. The same
physical28 section requires a single BG type throughout each territory. The
selected incoming packet matches the residents; this grants no permission to
flip the resident group or leave mixed types after arrival.
An opposite-type ground group now receives a counter before physical movement.
The packet may be a selected subset across sectors of its one origin. Existing
same-type movement has no flip and no extra matching response. The source gives
no fixed ordinary reserve side. Under the 14 October recommended policy,
provisionally retain the existing normal-fighter shipment default: ordinary
reserves joining resident advisors need matching, while reserves joining
fighters are ordinary placement. Basic is unchanged.
Neither source specifies the remedy for preventing this mandatory matching.
The selected provisional composition stops the one declaration before transfer,
shipment payment or action use. Residents and the incoming packet keep their
original location/type. Permit a legal replacement; do not invent a consumed
shipment/movement or a turn-wide ban. Prevent the same packet from matching in
that destination territory in the same owned opportunity. A different sector
in that destination is not a new packet. A different packet/destination or
later earned movement/turn is independent. Do not reuse the continuously-alone
hold from [already committed conversions](ADVISOR_AUTOMATIC_FLIPS.md).
The public ten-field source selects a fresh event, kind, owner, turn, native
movement index, origin, destination, sector, resident stance and selected
physical group. The original order and its exact signature remain in a private
parent. The shared pure quote owns current held groups, resident matching and
scoped denial; existing native code retains route, fresh-advisor locks,
occupancy, funding, card preparation and actual physical commitment. Consume
the private parent before resuming the original CHOAM movement or normal
shipment suffix once. Existing declaration card uses stay in their original
state; no new refund is invented.
Humans retain the standard Karama/Allow controls, with a persistent explanation
that this arrival has not happened. The personalized bot adapter uses the same
pure quote to omit prevented packets, keeping only minimal legal participation.
No strategic refinement or difficulty calibration is added.
[Native fixture](../tests/fixture-advisor-matching-move.ts) earns public Advanced
BG/Atreides/Emperor setup with original base33 and Atreides' original Karama.
BG's original advisor shares Arrakeen with ten Atreides. Actual first phases
and declined preparation precede a real one-force shipment into empty Basin9
for2, earning a separate fighter. Both directions between those original groups
are actual movement declarations. The recorded pre-shipment prefix also owns
a normal one-force reserve shipment into the Arrakeen advisors for1. No played
force, phase, wallet, hand, stance or counter is installed.
[Connected cases](../tests/advisor-matching-engine.test.ts) cover both outcomes,
unchanged precommit state, same-sector retry restraint, legal replacements,
already-matching movement and original-parent mismatch. Frozen **19
files244/244**, types/lint/build and seven native declaration/response/GameTable
SSR journeys pass, alongside twelve existing-profile responses and twelve
accepted legal replacements after prevention. Ground sources take34 actual
actions; the original pre-shipment prefix takes33. Both ground directions and
normal reserves preserve original residents and held packets while waiting.
Allowance moves the selected counter once; normal shipment pays1 and spends
its slot once. Prevention preserves forces, payment and the unused action,
rejects the same packet/destination and permits the earned same-type sector
reposition or an empty-territory shipment. Same-type movement creates no
matching response and retains the original Karama.
The focused first native allowance and final frozen batch pass. Standard
controls render; this is not a new callback/browser/mobile/deployed certificate.
Private proof and original-source HTML:
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-matching-groups/`.
`frozen-integrated/report.json` binds tree
`f1dc33ab54b8f74e98c7c6e7950fddf9dfd2a16cf0e4648cb2c0617cda23a061`,
2752 files on base `abf0305ddfe6883f49b9f34c1d4e879c47dcf4f2`.
Checkpoint prose/reference overlays are separately reviewed and hashed after
proof. Existing-mode completeness, special relocation, Guild/Homeworld,
Planetologist gathering and Spiritual Advisor matching remain open. No saved-
room/D1, reset, restart or deployment operation was performed.
## Spiritual Advisor source cutover still due
Complete physical28 review also requires Polar Sink or decline when the
original shipment destination retains BG fighters, unless Intrusion changes
them first. Existing fighter-child ordering is provisional, not a specific
user override of that destination restriction. Preserve Basic. Target
availability must be shared by ordinary declarations, settlement, Guild
Ambassador quotes, projected controls and bots; keep Homeworld resource quotes
independent of destination legality.
The source does not expressly resolve the original destination itself being
Polar Sink with resident fighters. The recommended provisional composition
keeps the named Polar fallback and lets the incoming advisor packet match those
fighters under its own precommit matching counter, after the distinct Spiritual
Advisor shipping counter. The other faction's shipment is already committed
and cannot be undone. That separate parent/projection integration is pending;
this normal movement/shipment prototype does not certify it.
+34
View File
@@ -16,6 +16,40 @@ open mode gates.
## Current checkpoint and work
**Matching incoming BG groups — 24 October. Prototyped / Partial.**
Normal opposite-type ground arrivals and reserve-to-advisor shipments now offer
Karama before physical transfer/payment. Supplied physical28 E explicitly marks
matching; physical17 identifies one incoming packet. Residents never change.
The ordinary reserve fighter default and precommit remedy are explicit
provisional compositions. Prevention retains the unused action while denying
that same packet/destination in the same opportunity; same-type movement needs
no flip. A public receipt owns a private original order; consume the parent
before one original movement/shipment suffix. Personalized legal bot filtering
reuses the shared quote.
[Source, normal native paths and limits](ADVISOR_MATCHING.md).
Disjoint helper/native-fixture lanes, complete-page source review and root
integration pass **19 files244/244**, types/lint/build and seven native
normal declaration/response/GameTable SSR journeys, with twelve existing-profile
responses and twelve accepted replacements. Genuine34/33-action public Advanced
base33 sources earn the original advisor, paid Basin fighter and original
Karama. Both directions and normal shipment preserve state while waiting.
Allowed shipment pays1 once; prevention keeps payment/action unused, rejects
same-packet retry and admits the earned same-type or empty-territory replacement.
No AI refinement, comprehensive/browser/deployed assurance or saved-room/D1/
reset/restart/deployment operation. Wider transport, gathering and mode acceptance
remain open.
Bounded complete-page review also identifies a remaining Advanced Spiritual
Advisor source cutover: retained fighters at the original shipment destination
require Polar Sink or decline unless Intrusion changes them first. Polar Sink
fallback with resident fighters provisionally needs its own incoming matching
counter after the distinct shipping counter. Ordinary/Guild Ambassador target
quotes, projected choices and bots must share that restriction; do not apply
it to ordinary paid shipments. That independent Spiritual-parent integration
and special relocation/gather matching remain explicitly open after the normal
arrival prototype. Full rule/mode acceptance is still unfinished.
**Special-relocation advisor conversion — 24 October. Prototyped / Partial.**
Fremen Ambassador and Discovery free-entry transfers now capture surviving alone
advisors before original arrivals. Preserve an empty Ambassador advisor arrival
+47
View File
@@ -1,5 +1,52 @@
# Dune implementation status
## 24 October 2026 — Matching incoming advisor packets before arrival
Supplied physical28 E explicitly marks matching shipping/moving into resident
BG forces as counterable; physical17 names the incoming group, not the
residents. Normal opposite-type ground packets and ordinary reserves joining
advisors now receive a precommit counter. Same-type movement has no flip.
Ordinary reserves' fighter default is a stated provisional composition because
the supplied source gives no fixed reserve side.
The source does not specify the remedy when matching is prevented. Under the
14 October authority, stop only that declaration before transfer/payment/use,
retain original resident/incoming types and allow a legal replacement. Deny
the same packet/destination territory in the same owned opportunity; sector or
response UUID changes cannot retry it. Different packets/destinations and later
opportunities stay independent. Do not import a turn-wide ban, action loss or
the continuously-alone hold. Already-played declaration cards retain original
use. A strict public ten-field receipt selects physical matching facts; keep
the exact original order/signature private and consume its parent before one
original CHOAM movement or normal shipment suffix. Personalized minimal bots
reuse the same quote to remove prevented packets.
Disjoint helper/native-fixture lanes, complete-page source review and root
integration pass **19 files244/244**, types/lint/build and seven native
normal declaration/response/GameTable SSR journeys, plus twelve existing-profile
counter responses and twelve accepted replacement actions. Genuine34-action
public Advanced BG/Atreides/Emperor base33 sources earn the original Arrakeen
advisor and paid Basin fighter, retaining originally dealt Karama. The real
33-action prefix owns normal reserve-to-advisor shipment. Both ground directions
and shipment retain original board/funds/allowances while waiting. Allowance
commits once; shipment pays1 once. Prevention leaves the unused action and
payment, rejects the same packet and admits an earned same-type sector move
or an empty-territory shipment. Same-type movement keeps the original Karama.
No played force, phase, wallet, hand, stance or counter is installed.
The focused first native allowance and final frozen batch pass. Private proof:
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-matching-groups/`.
`frozen-integrated/report.json` binds tree
`f1dc33ab54b8f74e98c7c6e7950fddf9dfd2a16cf0e4648cb2c0617cda23a061`,
2752 files on base `abf0305ddfe6883f49b9f34c1d4e879c47dcf4f2`.
Prose/reference overlays are separately reviewed/hashed. Standard controls render;
no new callback/browser/mobile/deployed or complete-mode claim. Source review
also identifies an Advanced Spiritual target cutover and its separate Polar
matching parent still due; special relocation, Guild/Homeworld, gathering,
complete modes and final assurance remain open. No strategic AI work or saved-
room/D1/reset/restart/deployment operation.
[Connected source and limits](ADVISOR_MATCHING.md).
## 24 October 2026 — Advisor conversion after independent relocation
Supplied physical17/28 E's counter now composes Fremen Ambassador and Discovery
+15
View File
@@ -1,5 +1,20 @@
# Rule decision index
## Matching incoming advisor groups — 24 October 2026
Supplied physical28 E directly marks matching into resident BG forces as
counterable; physical17 names one incoming group's flip. Residents retain their
type. Opposite-type normal ground arrivals need a counter; same-type movement
does not flip. Ordinary reserves have no printed fixed side, so using the
existing normal-fighter default for reserve-to-advisor matching is explicit
provisional composition. Preventing matching provisionally stops that arrival
before transfer/payment/action use, while allowing a legal replacement. Prevent
the same packet/destination in the same opportunity, not all flips for the turn
or the continuously-alone duration. Existing card preparation retains its use.
Spiritual destinations/matching and special transport/gather combinations remain
separate open scope; do not certify them from these normal paths.
[Exact source, contracts and outstanding Spiritual cutover](ADVISOR_MATCHING.md).
## Advisor conversion after special relocation — 24 October 2026
Compose supplied Advanced physical17/28 E with actual Fremen Ambassador and
+159
View File
@@ -0,0 +1,159 @@
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import type { FactionId } from './catalog';
import type { AdvisorStance } from './advisor-automatic-flip';
/** One incoming selected group changes to the resident BG group's stance. */
export type AdvisorMatchingSource = {
event: string;
kind: 'movement' | 'shipment';
owner: string;
turn: number;
move: number;
origin: string;
destination: string;
sector: number;
advisors: boolean;
group: Record<string, number>;
};
export type AdvisorMatchingIntent = Omit<AdvisorMatchingSource, 'event' | 'turn'>;
export type AdvisorMatchingQuote = Omit<AdvisorMatchingSource, 'event'>;
export type AdvisorMatchingPlayer = {
id: string;
faction: FactionId;
forces: Readonly<Record<string, number>>;
advisors?: Readonly<Record<string, AdvisorStance>>;
moved?: number;
reserves?: number;
};
export type AdvisorMatchingContext = MobileBoard & {
advanced: boolean;
status: string;
phase: number;
turn: number;
active: string | null;
players: readonly AdvisorMatchingPlayer[];
advisorMatchingDenied?: readonly string[];
};
export class AdvisorMatchingError extends Error {
constructor(message: string) {
super(message);
this.name = 'AdvisorMatchingError';
}
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const nonnegative = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const positive = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) > 0;
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireMatching(value: unknown, message: string): asserts value {
if (!value) throw new AdvisorMatchingError(message);
}
/** Destination-sector retries and fresh response UUIDs do not change the packet.
* A new turn, native opportunity, selected group or target stance has its own key. */
export function advisorMatchingKey(source: AdvisorMatchingSource | AdvisorMatchingQuote): string {
const group = Object.entries(source.group).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
return JSON.stringify([
'advisorMatching', source.turn, source.owner, source.kind, source.move,
source.origin, source.destination, source.advisors, group,
]);
}
function residentForces(player: AdvisorMatchingPlayer, destination: string) {
return Object.entries(player.forces).some(([key, count]) =>
splitLocation(key).territory === destination && count > 0);
}
function selectedGroup(
player: AdvisorMatchingPlayer,
intent: AdvisorMatchingIntent,
originSectors: readonly number[],
): Record<string, number> {
requireMatching(record(intent.group) && Object.keys(intent.group).length > 0,
'Select one nonempty physical group for advisor matching.');
const group: Record<string, number> = {};
let total = 0;
for (const key of Object.keys(intent.group)) {
if (intent.kind === 'shipment') {
requireMatching(key === 'reserves', 'Normal BG shipment matching uses only the selected reserve forces.');
} else {
const point = splitLocation(key);
requireMatching(key === `${point.territory}:${point.sector}` && point.territory === intent.origin &&
originSectors.includes(point.sector),
'Select physical movement counters from the original board territory.');
}
const count = intent.group[key];
const held = intent.kind === 'shipment' ? player.reserves : player.forces[key];
requireMatching(positive(count) && nonnegative(held) && held >= count,
'The selected incoming physical group is no longer available.');
total += count;
requireMatching(positive(total), 'The selected incoming group exceeds the supported physical count.');
group[key] = count;
}
return group;
}
/** Matching is an incoming-group counter only. The caller keeps normal route,
* arrival permissions, funding and the uncommitted original action authoritative. */
export function quoteAdvisorMatching(
ctx: AdvisorMatchingContext,
intent: AdvisorMatchingIntent,
): AdvisorMatchingQuote | null {
if (ctx.advanced !== true || ctx.status !== 'playing' || ctx.phase !== 5 || !positive(ctx.turn)) return null;
requireMatching(record(intent) && identifier(intent.owner), 'Choose the current advisor-matching owner.');
const owners = ctx.players.filter((player) => player.id === intent.owner);
const owner = owners[0];
if (owners.length !== 1 || owner.faction !== 'beneGesserit' || ctx.active !== owner.id) return null;
requireMatching((intent.kind === 'movement' || intent.kind === 'shipment') && nonnegative(intent.move) &&
identifier(intent.origin) && identifier(intent.destination) && nonnegative(intent.sector) &&
typeof intent.advisors === 'boolean',
'Choose a valid native movement or reserve-shipment matching declaration.');
const places = gameTerritories(ctx);
const destination = places.find((place) => place.id === intent.destination && place.sectors.includes(intent.sector));
const origin = places.find((place) => place.id === intent.origin);
requireMatching(destination && (intent.kind === 'shipment' ? intent.origin === 'reserves' : origin),
'Advisor matching needs its current board destination and original physical source.');
if (!residentForces(owner, intent.destination)) return null;
const targetAdvisors = !!owner.advisors?.[intent.destination];
requireMatching(intent.advisors === targetAdvisors,
'The resident Bene Gesserit group no longer has the declared target stance.');
// Selected ordinary reserve forces provisionally arrive with the normal fighter default.
const incomingAdvisors = intent.kind === 'shipment' ? false : !!owner.advisors?.[intent.origin];
if (incomingAdvisors === targetAdvisors) return null;
requireMatching(intent.kind === 'shipment' ? intent.move === 0 :
nonnegative(owner.moved) && intent.move === owner.moved,
'Advisor matching lost its original owned movement or shipment opportunity.');
const group = selectedGroup(owner, intent, origin?.sectors ?? []);
const quote: AdvisorMatchingQuote = {
kind: intent.kind, owner: owner.id, turn: ctx.turn, move: intent.move,
origin: intent.origin, destination: intent.destination, sector: intent.sector,
advisors: targetAdvisors, group,
};
requireMatching(ctx.advisorMatchingDenied === undefined ||
(Array.isArray(ctx.advisorMatchingDenied) && ctx.advisorMatchingDenied.every((key) => typeof key === 'string')),
'The advisor-matching prevention history is invalid.');
requireMatching(!ctx.advisorMatchingDenied?.includes(advisorMatchingKey(quote)),
'Karama already prevented this selected group from matching in that territory during the current owned opportunity.');
return quote;
}
/** Validate before either response outcome; this never commits or refunds the action. */
export function validateAdvisorMatching(ctx: AdvisorMatchingContext, source: AdvisorMatchingSource): void {
requireMatching(record(source) &&
Object.keys(source).sort().join(',') === 'advisors,destination,event,group,kind,move,origin,owner,sector,turn' &&
identifier(source.event) && positive(source.turn) && source.turn === ctx.turn,
'Advisor matching lost its original current-turn public source.');
const quote = quoteAdvisorMatching(ctx, {
kind: source.kind, owner: source.owner, move: source.move, origin: source.origin,
destination: source.destination, sector: source.sector, advisors: source.advisors, group: source.group,
});
requireMatching(quote, 'There is no current incoming BG stance change for this matching source.');
}
+20 -1
View File
@@ -116,6 +116,7 @@ import { botNexusIxianBetrayalActions } from './bot-nexus-ixian-betrayal';
import { botNexusHarkonnenBetrayalActions } from './bot-nexus-harkonnen-betrayal';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { quoteAdvisorMatching, AdvisorMatchingError } from './advisor-matching';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
import { reinforcementsBattleForces } from './reinforcements-homeworld';
import type { NativeReserveSelections } from './homeworld-native-reserves';
@@ -4074,6 +4075,24 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
}
/** Obligations apply across policy branches, including choosing to move first. */
function botAdvisorMatchingAllowed(g:GameView,action:Action) {
if(!g.advisorMatchingDenied?.length || !['move','ship'].includes(action.type) || action.planetologist==='gather') return true;
const me=g.players.find(p=>p.id===g.me)!;
if(!g.advanced || me.faction!=='beneGesserit' || g.phase!==5 || g.active!==me.id) return true;
const destination=String(action.territory),sector=Number(action.sector);
const group:Record<string,number>=action.type==='ship' ? {reserves:Number(action.amount)} :
action.forces && typeof action.forces==='object' ? action.forces as Record<string,number> :
{[String(action.from)]:Number(action.amount)};
const origins=[...new Set(Object.keys(group).map(key=>splitLocation(key).territory))];
if(action.type==='move' && origins.length!==1) return true;
try {
quoteAdvisorMatching(g,{kind:action.type==='ship'?'shipment':'movement',owner:me.id,
move:action.type==='ship'?0:me.moved??0,
origin:action.type==='ship'?'reserves':origins[0],destination,sector,
advisors:isAdvisor(me,destination),group});
return true;
} catch(error) { if(error instanceof AdvisorMatchingError) return false; throw error; }
}
export function botActions(g: GameView): Action[] {
if (g.homeworldTupileBlocked) return [];
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
@@ -4249,7 +4268,7 @@ export function botActions(g: GameView): Action[] {
if (action.type === 'battlePlan' && g.ecazSpecialKarama?.declared &&
(action.weapon || action.defense)) return [];
const sourced = withNativeShipmentSources(g, action);
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) && !botArrivalBlock(g, sourced) ? [sourced] : [];
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) && !botArrivalBlock(g, sourced) && botAdvisorMatchingAllowed(g,sourced) ? [sourced] : [];
});
const me = g.players.find((p) => p.id === g.me)!;
const advancedDepartures = g.advancedAllySeparation?.territories;
+80 -3
View File
@@ -608,6 +608,8 @@ import {
type TechState,
} from './tech-tokens';
import { canUseAsKaramaRole } from './shrine';
import { quoteAdvisorMatching, validateAdvisorMatching, advisorMatchingKey,
AdvisorMatchingError, type AdvisorMatchingSource } from './advisor-matching';
import {
isAdvisor,
fighterCount,
@@ -1067,6 +1069,7 @@ type SpiceBankerIncomePayment = {
export type ResponseWindow = {
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
advisorPreparationSource?: AdvisorPreparationSource;
advisorMatchingSource?: AdvisorMatchingSource;
fremenGrant?: FremenRevivalGrantSource;
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
bureaucratPayment?: BureaucratPaymentSource;
@@ -1283,6 +1286,12 @@ export type Game = {
signature: string;
resume: { kind: 'ambassador'; event: string } | { kind: 'discovery'; child: DiscoveryEntryArrivalChild };
};
pendingAdvisorMatching?: {
receipt: AdvisorMatchingSource;
signature: string;
intent: {kind:'movement'; order:MovementOrder} | {kind:'shipment'; order:PendingShipment};
};
advisorMatchingDenied?: string[];
advisorPreparation?: AdvisorPreparationWindow;
ecazOccupyPreview?: boolean;
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
@@ -14598,6 +14607,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
}
}
function movementCancellationQuote(g: Game, response: ResponseWindow) {
if (response.advisorMatchingSource) { currentAdvisorMatching(g, response); return null; }
try {
if (response.automaticAdvisorFlip) { currentAutomaticAdvisorFlip(g, response); return null; }
if (response.advisorResume === 'preparation') { currentAdvisorPreparationResponse(g, response); return null; }
@@ -16957,6 +16967,7 @@ function declareGroundMovement(g: Game, p: Player, action: Action, quoteOnly = f
: `That destination is blocked or more than ${speed} territories away.`,
);
validateFlightSelection(g, p, move);
const matching = quoteMatchingMovement(g, move);
if (quoteOnly) return move;
checkGroundMovementPromises(g, p, move);
if (advisorGather) {
@@ -16964,6 +16975,8 @@ function declareGroundMovement(g: Game, p: Player, action: Action, quoteOnly = f
kind: 'advisorFlip', owner: id, location: to, advisors: false, amount: n, passed: [],
advisorResume: 'planetologistGather', advisorGatherMove: { turn: g.turn, move: p.moved, order: move },
};
} else if (matching) {
openAdvisorMatching(g, matching, {kind:'movement',order:move});
} else if (
!move.ornithopterRange && !move.discoveryFlight &&
p.faction === 'ixians' &&
@@ -20334,6 +20347,61 @@ function automaticAdvisorRule<T>(quote: () => T): T {
try { return quote(); }
catch (error) { if (error instanceof AutomaticAdvisorFlipError) throw new RuleError(error.message); throw error; }
}
function matchingAdvisorRule<T>(quote: () => T): T {
try { return quote(); }
catch(error) { if(error instanceof AdvisorMatchingError) throw new RuleError(error.message); throw error; }
}
function quoteMatchingMovement(g: Game, move: MovementOrder) {
if (move.planetologist?.mode === 'gather') return null;
return matchingAdvisorRule(() => quoteAdvisorMatching(g, {kind:'movement',owner:move.player,
move:getPlayer(g,move.player).moved,origin:move.origin,destination:move.to,sector:move.sector,
advisors:move.advisors,group:Object.fromEntries(move.group)}));
}
function quoteMatchingShipment(g: Game, shipment: PendingShipment) {
if (shipment.source || shipment.noField || shipment.ambassadorMarker || shipment.alliedNoField) return null;
return matchingAdvisorRule(() => quoteAdvisorMatching(g, {kind:'shipment',owner:shipment.player,
move:0,origin:'reserves',destination:shipment.territory,sector:shipment.sector,
advisors:shipment.advisors,group:{reserves:shipment.amount}}));
}
function openAdvisorMatching(g: Game, quote: Omit<AdvisorMatchingSource,'event'>,
intent: NonNullable<Game['pendingAdvisorMatching']>['intent']) {
requireRule(!g.pendingAdvisorMatching && !g.response && !g.decision,
'Finish the original group matching before another arrival.');
const receipt={event:crypto.randomUUID(),...quote};
g.pendingAdvisorMatching={receipt,intent,signature:JSON.stringify(intent)};
g.response={kind:'advisorFlip',owner:receipt.owner,location:receipt.destination,
advisors:receipt.advisors,amount:Object.values(receipt.group).reduce((n,count)=>n+count,0),
passed:[],advisorMatchingSource:receipt};
}
function currentAdvisorMatching(g: Game,response:ResponseWindow) {
const source=response.advisorMatchingSource,parent=g.pendingAdvisorMatching;
requireRule(source && parent && response.kind==='advisorFlip' && response.owner===source.owner &&
response.location===source.destination && response.advisors===source.advisors &&
!response.automaticAdvisorFlip && !response.advisorPreparationSource && !response.advisorResume &&
JSON.stringify(parent.receipt)===JSON.stringify(source) &&
parent.signature===JSON.stringify(parent.intent) && parent.intent.kind===source.kind,
'The matching counter has lost its original uncommitted arrival.');
matchingAdvisorRule(()=>validateAdvisorMatching(g,source));
const quote=parent.intent.kind==='movement'
? quoteMatchingMovement(g,parent.intent.order) : quoteMatchingShipment(g,parent.intent.order);
requireRule(quote && JSON.stringify(quote)===JSON.stringify(Object.fromEntries(
Object.entries(source).filter(([key])=>key!=='event'))),
'The matching counter no longer describes the selected incoming group.');
return parent;
}
function finishAdvisorMatching(g:Game,response:ResponseWindow,canceled:boolean) {
const parent=currentAdvisorMatching(g,response),source=parent.receipt;
delete g.pendingAdvisorMatching;
if(canceled){
const key=advisorMatchingKey(source);
if(!(g.advisorMatchingDenied??=[]).includes(key)) g.advisorMatchingDenied.push(key);
log(g,`${getPlayer(g,source.owner).name}'s incoming group matching in ${territory(source.destination).name} was prevented before ${source.kind==='movement'?'movement':'shipment'}. Residents keep their type; forces, payment and the unused action remain. Choose a legal replacement.`,
{faction:'beneGesserit',name:'Incoming group matching prevented'});
} else if(parent.intent.kind==='movement') {
validateMovementOrder(g,getPlayer(g,source.owner),parent.intent.order);
offerChoamMovement(g,parent.intent.order);
} else offerShipment(g,parent.intent.order,true);
}
function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) {
const source = response.automaticAdvisorFlip;
requireRule(response.kind === 'advisorFlip' && source && response.advisors === false &&
@@ -22806,7 +22874,7 @@ function decideSharedSpice(g: Game, id: string, action: Action) {
function victory(g: Game, quote?: VictoryQuote) {
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorTransport &&
!g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport &&
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation,
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching,
'Resolve mandatory advisor conversions before checking victory.');
quote ??= currentVictoryQuote(g);
applyAdvisorReleases(g, quote.released);
@@ -28598,7 +28666,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
);
} else if (response.kind === 'advisorFlip') {
const bg = getPlayer(g, response.owner);
if (response.advisorResume === 'planetologistGather') {
if (response.advisorMatchingSource) finishAdvisorMatching(g,response,canceled);
else if (response.advisorResume === 'planetologistGather') {
movementCancellationQuote(g, response);
const pending = response.advisorGatherMove!;
if (canceled) {
@@ -30099,12 +30168,17 @@ function finishGuildTransportRateResponse(g: Game, response: ResponseWindow, can
log(g, `Karama removed Guild's half-price transport rate. ${shipper.name} paid the full ${fullCost} spice for the unchanged physical transport.`);
}
}
function offerShipment(g: Game, shipment: PendingShipment) {
function offerShipment(g: Game, shipment: PendingShipment, matchingApproved = false) {
validatePhysicalShipment(g, shipment);
validateShipmentArrival(g, shipment);
if (!shipment.ambassadorMarker) checkShipmentIncomeFunding(g, getPlayer(g, shipment.player), shipment.cost, shipment.allyPayment);
const p = getPlayer(g, shipment.player),
id = p.id;
const matching = quoteMatchingShipment(g, shipment);
if (matching && !matchingApproved) {
openAdvisorMatching(g, matching, {kind:'shipment',order:shipment});
return;
}
const { territory: to, sector: s } = shipment;
const n = shipment.alliedNoField || shipment.ambassadorMarker ? 1 : shipment.amount;
const guild = byFaction(g, 'guild');
@@ -36039,6 +36113,7 @@ function applyActionInner(
shipment.noFieldSkillProof = noFieldSkillProof(shipment);
validatePhysicalShipment(g, shipment);
}
quoteMatchingShipment(g, shipment);
if (nexusEvent) recordNexusRicheseShipment(g,shipment);
bindGuildCunningShipment(g,p,'reserve',shipment);
if (execution === 'movementPreparation') {
@@ -37569,6 +37644,8 @@ export function viewGame(state: Game, id: string) {
freeBlocked: g.revivalRules?.freeBlocked?.includes(id) ?? false,
},
ghola: gholaOptions(g, me),
...(me.faction === 'beneGesserit' && g.advisorMatchingDenied?.length
? {advisorMatchingDenied:g.advisorMatchingDenied} : {}),
aid: {
pledged: g.aid?.[id]?.amount ?? 0,
available:
+2
View File
@@ -190,6 +190,8 @@ const cancellationSources = {
...(response.automaticAdvisorFlip.producer === 'specialRelocation'
? { relocation: g.pendingAutomaticAdvisorRelocation?.receipt ?? null } : {}),
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
: response.advisorMatchingSource
? {matching:response.advisorMatchingSource,pending:g.pendingAdvisorMatching?.receipt??null}
: response.advisorPreparationSource
? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
: response.advisorResume === 'ambassador'
+1
View File
@@ -306,6 +306,7 @@ export function quoteMovementCancellation(
): MovementCancellationQuote | null {
// This native pre-shipment action has a phase-independent source validator.
if (response.kind === 'advisorFlip' && response.advisorResume === 'preparation') return null;
if (response.kind === 'advisorFlip' && response.advisorMatchingSource) return null;
if (
![
'guildTiming',
+19 -6
View File
@@ -4314,7 +4314,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Advisors do not fight, contest stronghold victory, block another faction’s entry, collect spice, earn stronghold income, grant ornithopters or enable Family Atomics. Storms, worms, explosions and Atomics still destroy them.',
'In Advanced, advisors may coexist with their ally without triggering the end-turn alliance constraint in either direction. Neither the advisors nor the allied fighters are lost for that coexistence. Bene Gesserit fighters remain subject to ordinary separation; Basic receives no new advisor exception.',
'After Fremen setup, choose the starting advisor’s territory and sector. Setup conversions remain automatic. During play, advisors alone must become fighters. A ground move into an empty territory or the last outsider’s ground departure now offers Karama prevention after the physical move, before fighter benefits. Allow to convert once; cancellation leaves the completed movement intact.',
'A normal shipment enters as fighters unless joining existing advisors. Moving or shipping into your existing group must match its type. Advisors moved into an occupied territory without another Bene Gesserit group may remain peaceful or request a flip to fighters.',
'A normal shipment enters as fighters unless joining existing advisors. Opposite-type normal ground arrivals and normal reserves joining advisors now offer Karama matching prevention before movement or payment. Residents keep their type. Provisionally stop a prevented packet into this territory for that action opportunity, leaving a legal replacement and the unused action available. Same-type moves need no flip. Advisors entering another occupied territory may keep their existing optional fighter choice.',
'After Trip to Gamont completes during Mentat, the last outsider’s return or empty No-Field reveal can leave advisors alone. Karama can then prevent their conversion without undoing the completed effect or card use. The final CHOAM opportunity resumes afterward; victory waits for its original confirmation. Ordinary Mentat does not gain an extra final chooser.',
'A committed Guild cross-shipment or return to reserves can also leave advisors alone. Original transport permission, rate and any Guild income resolve first; payment, force transfer and shipment use stay committed if the later conversion is prevented. The ordinary ground move remains unused. Cross-shipment arrival follows the conversion once; a reserve return has no arrival.',
'After actual battle losses leave surviving bystanding advisors alone, Karama may prevent their conversion without undoing losses or costs. Mandatory card disposal, original skills and substitution finish first. Provisionally resolve this before optional winner-card cleanup and later aftermath, then resume the original cleanup once. An explosion that destroys the advisors creates no conversion.',
@@ -4333,8 +4333,9 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Implementation",
"status": "Partial",
"detail": "Fremen Ambassador and Discovery free entry now capture resulting alone advisors before original arrivals. Preserve an empty Ambassador advisor arrival through one mandatory counter, without changing Discovery non-advisor admission. Private parent cursors retain original flip/entry stages; public six-field location evidence omits orders, No-Fields and skills. Matching-group and wider combinations remain unfinished.",
"detail": "Normal opposite-type ground arrivals and reserve-to-advisor shipments now use a precommit matching receipt and private original order. Shared packet-scoped denial leaves residents and unused actions intact. Same-type ground movement remains direct. Special transport/gather and Spiritual source cutovers remain unfinished.",
"evidence": [
"docs/ADVISOR_MATCHING.md",
"game/advisor-automatic-flip.ts",
"game/advisors.ts",
"game/board-resolution-quote.ts",
@@ -4348,8 +4349,9 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Player controls",
"status": "Partial",
"detail": "Standard Karama/Allow controls distinguish completed special force transfer from pending conversion and original arrival. Existing producer and preparation guidance remains, without another confirmation or timer.",
"detail": "Standard Karama/Allow explains that matching precedes physical arrival/payment and does not flip residents. Prevention retains unused actions and original card uses, with a legal replacement. Seven native GameTable SSR journeys render standard controls; no new confirmation or timer.",
"evidence": [
"docs/ADVISOR_MATCHING.md",
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
"components/advisor-preparation.tsx",
"components/game-table.tsx",
@@ -4359,8 +4361,13 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "AI",
"status": "Partial",
"detail": "Eight existing-profile native Ambassador/Discovery conversion paths resolve legally through original arrival completion. Transfers, allowances and original parent flow remain. Earlier producers retain separate evidence; no strategy refinement or difficulty calibration.",
"detail": "Twelve existing-profile matching responses and twelve accepted legal replacements use shared packet/opportunity denials. Earlier producers retain separate evidence. No strategy refinement or difficulty calibration.",
"evidence": [
"tests/fixture-advisor-matching-move.ts",
"tests/advisor-matching-engine.test.ts",
"tests/advisor-matching.test.ts",
"game/advisor-matching.ts",
"docs/ADVISOR_MATCHING.md",
"tests/fixture-advisor-relocation-flip.ts",
"tests/advisor-relocation-flip-engine.test.ts",
"tests/storm-worm-cancellation.test.ts",
@@ -4382,8 +4389,9 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Documentation",
"status": "Partial",
"detail": "Physical17/28 E composes conversion with original independent relocation. Priority after physical transfer/collection and before original arrivals is explicitly provisional. Original Ambassador phase permission and Discovery non-advisor-only admission remain; cancellation duration and wider combinations stay explicit.",
"detail": "Physical28 E explicitly marks matching as counterable and physical17 identifies the incoming group. Shipment defaults and precommit remedy/packet-opportunity restraint are provisional. Spiritual destination/Polar matching cutovers and broader transports remain explicit open scope.",
"evidence": [
"docs/ADVISOR_MATCHING.md",
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
"docs/ADVISOR_PREPARATION.md",
"docs/RULE_DECISIONS.md",
@@ -4393,8 +4401,13 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Verification",
"status": "Partial",
"detail": "Special relocation passes frozen19-file270/270, types/lint/build, five native transfer/response/GameTable SSR journeys, two labelled controlled empty-arrival cases and eight profile paths. Genuine44/49-action base33 sources earn native packet/placement/trigger or actual Cistern placement, Intrusion, Collection reveal and next-turn entry. Both outcomes retain transfer; original decline preserves its group. Controlled alliances are not native-alliance evidence. Focused compatibility regression retains older serialized counters. Wider No-Field/skills/Homeworld/Nexus combinations, native allied BG arrival, matching-group interpretation, full modes and final assurance remain open.",
"detail": "Normal matching passes frozen19-file244/244, types/lint/build, seven native declaration/response/GameTable SSR journeys, twelve profile responses and twelve accepted replacements. Genuine34/33-action base33 sources earn original advisors/fighters/Karama. Both directions and reserve shipment wait without transfer; allowance commits once and shipment pays1. Prevention keeps payment/action unused, rejects same-packet retry and permits legal replacement. Same-type movement has no counter. Special relocation, Guild/Homeworld, gathering, Spiritual matching/source cutover, full modes and final assurance remain open.",
"evidence": [
"tests/fixture-advisor-matching-move.ts",
"tests/advisor-matching-engine.test.ts",
"tests/advisor-matching.test.ts",
"game/advisor-matching.ts",
"docs/ADVISOR_MATCHING.md",
"tests/fixture-advisor-relocation-flip.ts",
"tests/advisor-relocation-flip-engine.test.ts",
"tests/storm-worm-cancellation.test.ts",
+96
View File
@@ -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]));
}
});
+232
View File
@@ -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()));
});
+182
View File
@@ -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);
}