Counter incoming BG matching before Fremen Ambassador relocation
Bind the selected packet to its original allied grant before transfer or collection. Preserve the committed trigger, restore legal replacement choices after prevention and resume the original movement once.
This commit is contained in:
1 parent
767bace774
commit
c999054ae0
15 files changed
+1040
-12
No files matched your search
@@ -4,6 +4,7 @@ import { useId, useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { location, splitLocation, territory } from '@/game/board';
|
||||
import { sandmasterDestinationCollection } from '@/game/sandmaster-movement';
|
||||
import { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from '@/game/advisor-ambassador-matching';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
@@ -94,8 +95,18 @@ export function AmbassadorMovement({
|
||||
sandmaster?.piles.some((pile) => pile.key === sandmasterSelection.key)
|
||||
? sandmasterSelection.key
|
||||
: sandmaster?.key;
|
||||
let matchingBlock:string|null=null;
|
||||
if(destination&&total>0){
|
||||
try{
|
||||
quoteAdvisorAmbassadorMatching({...game,pendingAmbassador:{...entry,turn:game.turn,phase:game.phase,
|
||||
beneficiary:game.me},advisorAmbassadorMatchingDenied:entry.matchingDenied},
|
||||
{parent:entry.event,owner:game.me,origin:source.territory,destination:destination.territory,
|
||||
sector:destination.sector,advisors:destination.advisors,group:selectedForces});
|
||||
}catch(error){if(!(error instanceof AdvisorAmbassadorMatchingError))throw error;matchingBlock=error.message;}
|
||||
}
|
||||
const reason =
|
||||
destination?.blocked ??
|
||||
matchingBlock ??
|
||||
(!destination
|
||||
? 'Choose an available destination.'
|
||||
: !total && !includesMarker
|
||||
@@ -121,6 +132,9 @@ export function AmbassadorMovement({
|
||||
Relocate a group from one territory. This does not spend your ordinary
|
||||
shipment or movement. The Ambassador has already been triggered.
|
||||
</p>
|
||||
{game.advanced&&game.players.find(player=>player.id===game.me)?.faction==='beneGesserit'&&(
|
||||
<p className="notice">An opposite-type incoming group has a separate matching response before relocation. If matching is prevented, choose another legal packet or destination; the original trigger stays committed.</p>
|
||||
)}
|
||||
<label className="flex flex-col gap-2" htmlFor={`${id}-source`}>
|
||||
Source territory
|
||||
<select
|
||||
|
||||
@@ -2503,6 +2503,13 @@ export function GameTable({
|
||||
{' '}Any already-played movement or declaration card keeps its original use. Choose a legal replacement if matching is prevented.
|
||||
</p>
|
||||
)}
|
||||
{g.response.kind==='advisorFlip'&&g.response.advisorAmbassadorMatchingSource&&(
|
||||
<p className="notice">
|
||||
The incoming Fremen Ambassador group must match your resident forces. No selected force has moved or collected spice yet.
|
||||
{' '}Preventing matching restores the original relocation choice and blocks this same packet here for this Ambassador event.
|
||||
{' '}The trigger stays committed; your ordinary shipment and movement remain unchanged.
|
||||
</p>
|
||||
)}
|
||||
{g.response.kind==='advisorFlip'&&g.response.spiritualAdvisorSource&&(
|
||||
<p className="notice">
|
||||
The free advisor packet must become fighters to match your Polar Sink residents. No free force has arrived yet.
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
# Matching incoming Bene Gesserit groups
|
||||
|
||||
## Fremen Ambassador relocation — 24 October 2026
|
||||
|
||||
**Prototyped / Partial.** The supplied revised
|
||||
Advanced physical28 matching clause covers incoming shipping/moving groups;
|
||||
physical17 permits preventing the group's flip. Fremen Ambassador relocation
|
||||
can move a genuine BG ally's opposite-type group into its residents. This is a
|
||||
separate matching operation before the physical transfer, not the later alone
|
||||
conversion. Basic and same-type/empty arrivals retain their original behavior.
|
||||
|
||||
Native grant, token, phase, mutual ally, route, fresh-advisor locks and occupancy
|
||||
remain the original producer's authority. After their quote succeeds, offer
|
||||
matching before CHOAM's movement reaction, Sandmaster collection or any transfer.
|
||||
The public ten-field source binds event, original Ambassador parent, owner,
|
||||
turn/phase, origin, destination/sector, target stance and selected physical group.
|
||||
The exact original order and parent signature stay private. An inactive ordinary
|
||||
player may own this independent allied grant; do not use their normal moved
|
||||
index as its identity. Existing normal matching sources retain their shape.
|
||||
|
||||
Under the existing provisional prevention policy, restore the original owned
|
||||
move chooser while retaining the already-consumed trigger and all ordinary
|
||||
allowances. Deny that same packet/destination/stance for this Ambassador event;
|
||||
a sector, response UUID or optional fighter flag cannot retry it. Another
|
||||
packet or destination remains available. Do not invent a trigger refund, a new
|
||||
decline action or a turn-wide ban. Minimal bots and human drafts use the shared
|
||||
quote to omit blocked packets. Allowance consumes the matching parent before
|
||||
one original CHOAM/transfer/arrival suffix; original post-transfer mandatory
|
||||
conversions retain their own timing. Already-recorded old continuations are
|
||||
not reopened or replayed.
|
||||
|
||||
[Native fixture](../tests/fixture-advisor-ambassador-matching.ts) earns the
|
||||
public Advanced BG/Ecaz/Atreides base33 setup, original Arrakeen advisor, paid
|
||||
two-force Tuek's fighter group, real first-Revival Fremen placement, actual
|
||||
turn-two Shai-Hulud/Nexus alliance and a paid third-party entrant. Ecaz selects
|
||||
its genuinely allied BG beneficiary. No played force, hand, alliance, phase or
|
||||
matching response is installed. Both direction payloads and later replacement
|
||||
choices remain caller-owned. [Connected cases](../tests/advisor-ambassador-matching-engine.test.ts)
|
||||
cover allowance/prevention, unchanged pending physical state, scoped retry and
|
||||
different-packet/empty replacement, exact parent binding and minimal profiles.
|
||||
The frozen batch passes **17 files358/358**, types/lint/build, six native
|
||||
declaration/response/replacement/GameTable SSR journeys and eight existing-profile
|
||||
paths with sixteen accepted response/replacement actions. Each source uses81
|
||||
actual public actions. Both matching directions retain forces, stances, money
|
||||
and allowances while pending. Allowance moves once without replaying the trigger;
|
||||
prevention preserves that grant, rejects the original packet and admits either
|
||||
the actual two-force packet or empty Polar Sink replacement. The no-matching
|
||||
route retains the original Karama. Four existing profiles respond and choose a
|
||||
legal replacement after prevention, without strategic refinement.
|
||||
|
||||
The first native allowance passes. Feedback only repairs an empty-source
|
||||
zero/absence test assertion; the focused repair and final union pass. The native
|
||||
runtime required no application source repair. Private proof and original-source
|
||||
HTML live in
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-ambassador-matching/`.
|
||||
`frozen-integrated-2/report.json` binds tree
|
||||
`c2f21f81711c05ce6eb466d152fc65de53bb816dbcf04215e92fa3028e513107`,
|
||||
2761 files on base `767bace7747ccf3cfb62600dc31cd47b2d46117b`.
|
||||
Only checkpoint prose/reference overlays follow that proof, with separate
|
||||
link/evidence/source-invariance review. SSR is not callback/hydration or browser
|
||||
acceptance. Guild/Junction transport, two-origin gathering, wider modules and
|
||||
final mode/browser/deployed acceptance remain open. No saved-room/D1/server/
|
||||
reset/restart/deployment operation.
|
||||
|
||||
## Normal movement and reserve shipment — 24 October 2026
|
||||
|
||||
**Prototyped / Partial.** The supplied revised
|
||||
|
||||
@@ -16,6 +16,32 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Fremen Ambassador incoming matching — 24 October. Prototyped / Partial.**
|
||||
Incoming opposite-type BG relocation now offers Karama before CHOAM movement
|
||||
reactions, Sandmaster collection or physical transfer. Its real original allied
|
||||
grant owns the selected packet, independently of the ordinary active player.
|
||||
Prevention restores that move chooser and provisionally blocks only the same
|
||||
packet/destination/stance for that event, retaining the consumed token and all
|
||||
ordinary allowances. Another packet or destination remains available. Consume
|
||||
the private matching parent before one original movement/arrival suffix; later
|
||||
mandatory alone conversions stay separate.
|
||||
[Source, contract and native evidence](ADVISOR_MATCHING.md#fremen-ambassador-relocation--24-october-2026).
|
||||
|
||||
Disjoint helper/native-fixture lanes, bounded source/integration review and root
|
||||
integration pass **17 files358/358**, types/lint/build, six native
|
||||
declaration/response/replacement/GameTable SSR journeys and eight existing-profile
|
||||
paths with sixteen accepted response/replacement actions. Genuine81-action
|
||||
public Advanced BG/Ecaz/Atreides base33 sources earn actual prediction/Traitors,
|
||||
original advisor, paid two-force fighter shipment, paid Fremen token, turn-two
|
||||
Shai-Hulud/Nexus alliance, third-party entry and actual BG beneficiary. Both
|
||||
matching directions preserve physical state while waiting; allowance moves once,
|
||||
and prevention keeps the grant, rejects the same packet and admits a different
|
||||
packet or empty target. No played force, phase, hand, alliance or response is
|
||||
installed. Feedback repairs one empty-source zero/absence assertion; runtime
|
||||
needed no application source fix. No AI refinement or saved-room/D1/server/
|
||||
reset/restart/deployment operation. Guild/Junction, two-origin gathering, wider
|
||||
modules and final mode/browser/deployed assurance remain open.
|
||||
|
||||
**Spiritual Advisor targets and conversion — 24 October. Prototyped / Partial.**
|
||||
Supplied physical28 redirects retained non-Polar fighters to Polar Sink or
|
||||
decline unless Intrusion first changed them. Fresh Advanced declarations now
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 24 October 2026 — Incoming BG matching through a native Fremen Ambassador
|
||||
|
||||
Supplied physical28's marked matching clause now composes the native allied
|
||||
Fremen Ambassador relocation. After original grant, phase, route, lock and
|
||||
arrival legality, counter the selected opposite-type BG group before CHOAM,
|
||||
Sandmaster collection or transfer. Residents retain their stance. A public
|
||||
ten-field source selects the actual packet and original Ambassador event;
|
||||
the exact original movement and parent signature remain private. Its allied
|
||||
beneficiary need not be the ordinary active player. Existing source formats
|
||||
and already-recorded older continuations retain their protocol.
|
||||
|
||||
Under the existing provisional remedy, prevention restores the original chooser
|
||||
and blocks that packet/destination/stance for this event. Do not refund the
|
||||
committed trigger, spend ordinary allowances, introduce a decline or ban all
|
||||
flips for the turn. A different packet/destination remains available. Consume
|
||||
the private matching parent before the existing CHOAM/transfer/arrival suffix
|
||||
once; later mandatory alone conversion remains independent. Public controls
|
||||
and minimal bots reuse the pure quote for prevented packets.
|
||||
|
||||
Disjoint helper/native-fixture lanes, bounded source/integration review and root
|
||||
integration pass **17 files358/358**, types/lint/build and six native
|
||||
declaration/response/replacement/GameTable SSR journeys. Genuine81-action public
|
||||
Advanced BG/Ecaz/Atreides base33 sources earn original setup/cards, paid native
|
||||
fighter shipment, actual token placement, turn-two Shai-Hulud/Nexus alliance,
|
||||
paid third-party entry and genuine BG beneficiary. Both matching directions,
|
||||
both responses, the different-packet/empty replacements and a no-match route
|
||||
retain physical custody, grants and ordinary allowances. Eight existing-profile
|
||||
paths accept sixteen response/replacement actions. No played force, phase,
|
||||
hand, alliance or response is installed. Feedback repairs a zero/absence test
|
||||
assertion; focused repair and final union pass. Native runtime needed no
|
||||
application source fix.
|
||||
|
||||
Private proof:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-ambassador-matching/`.
|
||||
`frozen-integrated-2/report.json` binds tree
|
||||
`c2f21f81711c05ce6eb466d152fc65de53bb816dbcf04215e92fa3028e513107`,
|
||||
2761 files on base `767bace7747ccf3cfb62600dc31cd47b2d46117b`.
|
||||
Final prose/reference overlays are separately reviewed. No new callback/
|
||||
hydration/browser/mobile/deployed certificate. Guild/Junction, two-origin
|
||||
gathering, wider modules and final mode/assurance remain open. No AI refinement
|
||||
or saved-room/D1/server/reset/restart/deployment operation.
|
||||
[Source, original continuation and limits](ADVISOR_MATCHING.md#fremen-ambassador-relocation--24-october-2026).
|
||||
|
||||
## 24 October 2026 — Spiritual Advisor targets, matching and alone conversion
|
||||
|
||||
Supplied physical28 starts the free packet advisor-side and directs retained
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Rule decision index
|
||||
|
||||
## Fremen Ambassador incoming matching — 24 October 2026
|
||||
|
||||
Compose supplied physical28's incoming shipping/moving matching clause and
|
||||
physical17's group counter with a native Fremen Ambassador grant. After original
|
||||
legality and before CHOAM/collection/transfer, counter only the opposite-type
|
||||
BG packet; residents keep their stance. Its original Ambassador event owns
|
||||
prevention, independently of ordinary active player or moved index. Under the
|
||||
existing provisional remedy, prevention restores the original chooser, retains
|
||||
the consumed trigger and ordinary allowances, and denies the same packet/target
|
||||
stance in that destination for that event. Different packets/destinations stay
|
||||
available. No new decline, refund or turn-wide ban is introduced. Consume the
|
||||
private matching parent before one original suffix; later mandatory conversion
|
||||
is separate. Basic and recorded older continuations retain their protocol.
|
||||
[Source, contract and evidence](ADVISOR_MATCHING.md#fremen-ambassador-relocation--24-october-2026).
|
||||
|
||||
## Spiritual Advisor targets and distinct conversions — 24 October 2026
|
||||
|
||||
Supplied physical28 E ships an advisor and redirects retained non-Polar fighters
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { gameTerritories, splitLocation, type MobileBoard } from './board';
|
||||
import type { FactionId } from './catalog';
|
||||
import type { AdvisorStance } from './advisor-automatic-flip';
|
||||
|
||||
export type AdvisorAmbassadorMatchingSource = {
|
||||
event: string;
|
||||
parent: string;
|
||||
owner: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
origin: string;
|
||||
destination: string;
|
||||
sector: number;
|
||||
advisors: boolean;
|
||||
group: Record<string, number>;
|
||||
};
|
||||
|
||||
export type AdvisorAmbassadorMatchingIntent = Omit<AdvisorAmbassadorMatchingSource, 'event' | 'turn' | 'phase'>;
|
||||
export type AdvisorAmbassadorMatchingQuote = Omit<AdvisorAmbassadorMatchingSource, 'event'>;
|
||||
|
||||
export type AdvisorAmbassadorMatchingPlayer = {
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
forces: Readonly<Record<string, number>>;
|
||||
advisors?: Readonly<Record<string, AdvisorStance>>;
|
||||
};
|
||||
|
||||
export type AdvisorAmbassadorMatchingContext = MobileBoard & {
|
||||
advanced: boolean;
|
||||
status: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
players: readonly AdvisorAmbassadorMatchingPlayer[];
|
||||
pendingAmbassador?: {
|
||||
event: string;
|
||||
turn?: number;
|
||||
phase?: number;
|
||||
effect?: string;
|
||||
stage?: string;
|
||||
beneficiary?: string | null;
|
||||
} | null;
|
||||
advisorAmbassadorMatchingDenied?: readonly string[];
|
||||
};
|
||||
|
||||
export class AdvisorAmbassadorMatchingError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AdvisorAmbassadorMatchingError';
|
||||
}
|
||||
}
|
||||
|
||||
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 AdvisorAmbassadorMatchingError(message);
|
||||
}
|
||||
|
||||
/** Only the original public Ambassador opportunity, packet and target stance
|
||||
* identify prevention. A new response UUID or destination sector is no retry. */
|
||||
export function advisorAmbassadorMatchingKey(
|
||||
source: AdvisorAmbassadorMatchingSource | AdvisorAmbassadorMatchingQuote,
|
||||
): string {
|
||||
const group = Object.entries(source.group).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
||||
return JSON.stringify([
|
||||
'advisorAmbassadorMatching', source.turn, source.parent, source.owner,
|
||||
source.origin, source.destination, source.advisors, group,
|
||||
]);
|
||||
}
|
||||
|
||||
function hasResidents(player: AdvisorAmbassadorMatchingPlayer, territory: string) {
|
||||
return Object.entries(player.forces).some(([key, count]) =>
|
||||
splitLocation(key).territory === territory && count > 0);
|
||||
}
|
||||
|
||||
/** The original Fremen grant, not the active ordinary mover, owns this request.
|
||||
* Native phase permission, route, locks and occupancy stay with that producer. */
|
||||
export function quoteAdvisorAmbassadorMatching(
|
||||
ctx: AdvisorAmbassadorMatchingContext,
|
||||
intent: AdvisorAmbassadorMatchingIntent,
|
||||
): AdvisorAmbassadorMatchingQuote | null {
|
||||
if (ctx.advanced !== true || ctx.status !== 'playing') return null;
|
||||
requireMatching(record(intent) && identifier(intent.owner), 'Choose the native BG Ambassador beneficiary.');
|
||||
const owners = ctx.players.filter((player) => player.id === intent.owner);
|
||||
const owner = owners[0];
|
||||
if (owners.length !== 1 || owner.faction !== 'beneGesserit') return null;
|
||||
const parent = ctx.pendingAmbassador;
|
||||
if (!record(parent) || parent.effect !== 'fremen' || parent.stage !== 'move' || parent.beneficiary !== owner.id) return null;
|
||||
requireMatching(identifier(intent.parent) && identifier(parent.event) && intent.parent === parent.event &&
|
||||
positive(ctx.turn) && parent.turn === ctx.turn && nonnegative(ctx.phase) && ctx.phase <= 8 && parent.phase === ctx.phase,
|
||||
'The original current-turn Fremen Ambassador relocation has changed.');
|
||||
requireMatching(identifier(intent.origin) && identifier(intent.destination) && nonnegative(intent.sector) &&
|
||||
typeof intent.advisors === 'boolean',
|
||||
'Choose the original board packet and resident destination stance.');
|
||||
const places = gameTerritories(ctx);
|
||||
const origin = places.find((place) => place.id === intent.origin);
|
||||
const destination = places.find((place) => place.id === intent.destination && place.sectors.includes(intent.sector));
|
||||
requireMatching(origin && destination, 'Ambassador matching needs a current board origin and destination sector.');
|
||||
// Sector-only relocation retains the territory's existing single BG type.
|
||||
if (intent.origin === intent.destination || !hasResidents(owner, intent.destination)) return null;
|
||||
const targetAdvisors = !!owner.advisors?.[intent.destination];
|
||||
requireMatching(intent.advisors === targetAdvisors,
|
||||
'The resident BG group no longer has the declared Ambassador matching stance.');
|
||||
if (!!owner.advisors?.[intent.origin] === targetAdvisors) return null;
|
||||
requireMatching(record(intent.group) && Object.keys(intent.group).length > 0,
|
||||
'Select one nonempty physical BG packet for Ambassador matching.');
|
||||
const group: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const key of Object.keys(intent.group)) {
|
||||
const point = splitLocation(key);
|
||||
requireMatching(key === `${point.territory}:${point.sector}` && point.territory === intent.origin &&
|
||||
origin.sectors.includes(point.sector),
|
||||
'Select the incoming physical packet from one current board origin.');
|
||||
const count = intent.group[key];
|
||||
const held = owner.forces[key];
|
||||
requireMatching(positive(count) && nonnegative(held) && held >= count,
|
||||
'The incoming Ambassador packet is no longer held at its original location.');
|
||||
total += count;
|
||||
requireMatching(positive(total), 'The incoming Ambassador packet exceeds the supported physical count.');
|
||||
group[key] = count;
|
||||
}
|
||||
const quote: AdvisorAmbassadorMatchingQuote = {
|
||||
parent: parent.event, owner: owner.id, turn: ctx.turn, phase: ctx.phase,
|
||||
origin: intent.origin, destination: intent.destination, sector: intent.sector,
|
||||
advisors: targetAdvisors, group,
|
||||
};
|
||||
requireMatching(ctx.advisorAmbassadorMatchingDenied === undefined ||
|
||||
(Array.isArray(ctx.advisorAmbassadorMatchingDenied) &&
|
||||
ctx.advisorAmbassadorMatchingDenied.every((key) => typeof key === 'string')),
|
||||
'The Ambassador matching prevention history is invalid.');
|
||||
requireMatching(!ctx.advisorAmbassadorMatchingDenied?.includes(advisorAmbassadorMatchingKey(quote)),
|
||||
'Karama already prevented this selected packet from matching in that territory during the original Ambassador opportunity.');
|
||||
return quote;
|
||||
}
|
||||
|
||||
/** Recheck the same uncommitted packet before either counter outcome. */
|
||||
export function validateAdvisorAmbassadorMatching(
|
||||
ctx: AdvisorAmbassadorMatchingContext,
|
||||
source: AdvisorAmbassadorMatchingSource,
|
||||
): void {
|
||||
requireMatching(record(source) &&
|
||||
Object.keys(source).sort().join(',') === 'advisors,destination,event,group,origin,owner,parent,phase,sector,turn' &&
|
||||
identifier(source.event) && positive(source.turn) && source.turn === ctx.turn &&
|
||||
nonnegative(source.phase) && source.phase === ctx.phase,
|
||||
'Ambassador matching lost its original current-turn public source.');
|
||||
const quote = quoteAdvisorAmbassadorMatching(ctx, {
|
||||
parent: source.parent, owner: source.owner, 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 Fremen Ambassador source.');
|
||||
}
|
||||
@@ -117,6 +117,7 @@ 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 { quoteAdvisorAmbassadorMatching, AdvisorAmbassadorMatchingError } from './advisor-ambassador-matching';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
|
||||
import { reinforcementsBattleForces } from './reinforcements-homeworld';
|
||||
import type { NativeReserveSelections } from './homeworld-native-reserves';
|
||||
@@ -418,6 +419,12 @@ function fremenAmbassadorMoves(g: GameView): Action[] {
|
||||
(destination.advisors ? 3 : 0);
|
||||
const sandmaster = !marker
|
||||
? sandmasterDestinationCollection(g, me.id, source.territory, destination.territory, destination.sector) : null;
|
||||
try{
|
||||
quoteAdvisorAmbassadorMatching({...g,pendingAmbassador:{...entry,turn:g.turn,phase:g.phase,
|
||||
beneficiary:me.id},advisorAmbassadorMatchingDenied:entry.matchingDenied},
|
||||
{parent:entry.event,owner:me.id,origin:source.territory,destination:destination.territory,
|
||||
sector:destination.sector,advisors:destination.advisors,group:forces});
|
||||
}catch(error){if(!(error instanceof AdvisorAmbassadorMatchingError))throw error;continue;}
|
||||
candidates.push({
|
||||
score,
|
||||
action: {
|
||||
|
||||
+90
-8
@@ -610,6 +610,7 @@ import {
|
||||
import { canUseAsKaramaRole } from './shrine';
|
||||
import { quoteAdvisorMatching, validateAdvisorMatching, advisorMatchingKey,
|
||||
AdvisorMatchingError, type AdvisorMatchingSource } from './advisor-matching';
|
||||
import { quoteAdvisorAmbassadorMatching, validateAdvisorAmbassadorMatching, advisorAmbassadorMatchingKey, AdvisorAmbassadorMatchingError, type AdvisorAmbassadorMatchingSource } from './advisor-ambassador-matching';
|
||||
import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival, validateSpiritualAdvisorSource,
|
||||
spiritualAdvisorMatchingKey, SpiritualAdvisorError, type SpiritualAdvisorSource } from './spiritual-advisor';
|
||||
import {
|
||||
@@ -1072,6 +1073,7 @@ export type ResponseWindow = {
|
||||
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
|
||||
advisorPreparationSource?: AdvisorPreparationSource;
|
||||
advisorMatchingSource?: AdvisorMatchingSource;
|
||||
advisorAmbassadorMatchingSource?: AdvisorAmbassadorMatchingSource;
|
||||
spiritualAdvisorSource?: SpiritualAdvisorSource;
|
||||
fremenGrant?: FremenRevivalGrantSource;
|
||||
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
|
||||
@@ -1295,6 +1297,12 @@ export type Game = {
|
||||
intent: {kind:'movement'; order:MovementOrder} | {kind:'shipment'; order:PendingShipment};
|
||||
};
|
||||
advisorMatchingDenied?: string[];
|
||||
pendingAdvisorAmbassadorMatching?: {
|
||||
receipt: AdvisorAmbassadorMatchingSource;
|
||||
order: MovementOrder;
|
||||
signature: string;
|
||||
parentSignature: string;
|
||||
};
|
||||
pendingSpiritualAdvisor?: {
|
||||
event: string;
|
||||
turn: number;
|
||||
@@ -1825,6 +1833,7 @@ export type Game = {
|
||||
owner: string; territory: string; sector: number; tokenEvent: string;
|
||||
offer?: GuildAmbassadorMarkerQuote;
|
||||
};
|
||||
advisorMatchingDenied?: string[];
|
||||
relocation?: {
|
||||
order: FremenAmbassadorMove;
|
||||
next: 'flip' | 'intrusion' | 'terror' | 'finish';
|
||||
@@ -14623,6 +14632,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
}
|
||||
}
|
||||
function movementCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
if (response.advisorAmbassadorMatchingSource) { currentAdvisorAmbassadorMatching(g,response); return null; }
|
||||
if (response.spiritualAdvisorSource) { currentSpiritualAdvisorResponse(g,response); return null; }
|
||||
if (response.advisorMatchingSource) { currentAdvisorMatching(g, response); return null; }
|
||||
try {
|
||||
@@ -18559,7 +18569,9 @@ function finishGuildAmbassadorAdvisorArrival(g:Game,response:ResponseWindow,
|
||||
function quoteAmbassadorRelocation(g: Game, action: Action) {
|
||||
const { beneficiary } = currentFremenAmbassador(g, stringField(action.event));
|
||||
try {
|
||||
return quoteFremenAmbassadorMove(g, beneficiary.id, action);
|
||||
const move=quoteFremenAmbassadorMove(g, beneficiary.id, action);
|
||||
quoteAmbassadorMatching(g,move);
|
||||
return move;
|
||||
} catch (error) {
|
||||
if (error instanceof FremenAmbassadorMoveError)
|
||||
throw new RuleError(error.message);
|
||||
@@ -19764,11 +19776,10 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
|
||||
} else if (entry.stage === 'move') {
|
||||
const move = quoteAmbassadorRelocation(g, action);
|
||||
validateAmbassadorRelocationArrival(g, move);
|
||||
offerChoamMovement(g, {
|
||||
...move,
|
||||
source: 'ambassador',
|
||||
ambassadorEvent: entry.event,
|
||||
});
|
||||
const order:MovementOrder={...move,source:'ambassador',ambassadorEvent:entry.event};
|
||||
const matching=quoteAmbassadorMatching(g,move);
|
||||
if(matching)openAdvisorAmbassadorMatching(g,matching,order);
|
||||
else offerChoamMovement(g,order);
|
||||
} else {
|
||||
requireRule(
|
||||
entry.effect === 'choam' || entry.effect === 'ixians',
|
||||
@@ -20578,6 +20589,71 @@ function completeSpiritualAdvisorArrival(g:Game){
|
||||
openTerritoryEntry(g,bg,order.territory,order.sector,order.amount,0,'advisor');
|
||||
}
|
||||
}
|
||||
function ambassadorMatchingRule<T>(quote:()=>T):T{
|
||||
try{return quote();}catch(error){if(error instanceof AdvisorAmbassadorMatchingError)throw new RuleError(error.message);throw error;}
|
||||
}
|
||||
function quoteAmbassadorMatching(g:Game,move:FremenAmbassadorMove){
|
||||
const entry=g.pendingAmbassador;
|
||||
if(!entry)return null;
|
||||
return ambassadorMatchingRule(()=>quoteAdvisorAmbassadorMatching({...g,
|
||||
advisorAmbassadorMatchingDenied:entry.advisorMatchingDenied},
|
||||
{parent:entry.event,owner:move.player,origin:move.origin,destination:move.to,sector:move.sector,
|
||||
advisors:move.advisors,group:Object.fromEntries(move.group)}));
|
||||
}
|
||||
function openAdvisorAmbassadorMatching(g:Game,quote:Omit<AdvisorAmbassadorMatchingSource,'event'>,order:MovementOrder){
|
||||
requireRule(!g.pendingAdvisorAmbassadorMatching&&!g.response&&!g.decision,
|
||||
'Finish the original Ambassador matching response first.');
|
||||
const receipt={event:crypto.randomUUID(),...quote};
|
||||
g.pendingAdvisorAmbassadorMatching={receipt,order,signature:JSON.stringify(order),
|
||||
parentSignature:JSON.stringify(g.pendingAmbassador)};
|
||||
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:[],advisorAmbassadorMatchingSource:receipt};
|
||||
}
|
||||
function currentAdvisorAmbassadorMatching(g:Game,response:ResponseWindow){
|
||||
const parent=g.pendingAdvisorAmbassadorMatching,source=response.advisorAmbassadorMatchingSource;
|
||||
requireRule(parent&&source&&response.kind==='advisorFlip'&&response.owner===source.owner&&
|
||||
response.location===source.destination&&response.advisors===source.advisors&&
|
||||
response.amount===Object.values(source.group).reduce((n,count)=>n+count,0)&&
|
||||
!response.advisorResume&&!response.advisorMatchingSource&&!response.spiritualAdvisorSource&&
|
||||
!response.automaticAdvisorFlip&&!response.advisorPreparationSource&&
|
||||
JSON.stringify(parent.receipt)===JSON.stringify(source)&&parent.signature===JSON.stringify(parent.order)&&
|
||||
parent.parentSignature===JSON.stringify(g.pendingAmbassador)&&parent.order.source==='ambassador'&&
|
||||
parent.order.ambassadorEvent===source.parent&&parent.order.player===source.owner,
|
||||
'The Ambassador matching counter lost its original uncommitted relocation.');
|
||||
currentFremenAmbassador(g,source.parent);
|
||||
ambassadorMatchingRule(()=>validateAdvisorAmbassadorMatching({...g,
|
||||
advisorAmbassadorMatchingDenied:g.pendingAmbassador!.advisorMatchingDenied},source));
|
||||
validateMovementOrder(g,getPlayer(g,source.owner),parent.order);
|
||||
validateAmbassadorRelocationArrival(g,parent.order);
|
||||
const quote=quoteAmbassadorMatching(g,parent.order);
|
||||
requireRule(quote&&JSON.stringify(quote)===JSON.stringify(Object.fromEntries(
|
||||
Object.entries(source).filter(([key])=>key!=='event'))),
|
||||
'The Ambassador matching counter no longer describes its original selected packet.');
|
||||
return parent;
|
||||
}
|
||||
function advisorAmbassadorMatchingIntegrity(g:Game){
|
||||
if(!g.pendingAdvisorAmbassadorMatching)return;
|
||||
const continuation=g.pendingTreacheryDiscard?.continuation;
|
||||
const controls=[g,g.pendingExchange,g.pendingNullentropy?.resume,g.pendingRicheseGift?.resume,
|
||||
g.pendingRichesePurchaseIncome?.resume,g.summonedWorm?.resume,g.pendingKull?.resume,
|
||||
continuation&&'resume'in continuation?continuation.resume:null];
|
||||
const responses=controls.flatMap(c=>c?.response?[c.response]:[]);
|
||||
for(const {pending}of savedKaramaContexts(g))if(pending.use.kind==='cancel')responses.push(pending.use.response);
|
||||
const matching=responses.filter(response=>response.advisorAmbassadorMatchingSource);
|
||||
requireRule(matching.length>0,'Restore the original Ambassador matching response.');
|
||||
for(const response of matching)currentAdvisorAmbassadorMatching(g,response);
|
||||
}
|
||||
function finishAdvisorAmbassadorMatching(g:Game,response:ResponseWindow,canceled:boolean){
|
||||
const parent=currentAdvisorAmbassadorMatching(g,response),source=parent.receipt;
|
||||
delete g.pendingAdvisorAmbassadorMatching;
|
||||
if(canceled){
|
||||
const key=advisorAmbassadorMatchingKey(source),entry=g.pendingAmbassador!;
|
||||
if(!(entry.advisorMatchingDenied??=[]).includes(key))entry.advisorMatchingDenied.push(key);
|
||||
g.decision={kind:'ecazAmbassador',player:source.owner};
|
||||
log(g,`${getPlayer(g,source.owner).name}'s incoming Ambassador group matching in ${territory(source.destination).name} was prevented before relocation. Choose another legal packet or destination; the original trigger stays committed and ordinary allowances are unchanged.`,
|
||||
{faction:'beneGesserit',name:'Ambassador group matching prevented'});
|
||||
}else offerChoamMovement(g,parent.order);
|
||||
}
|
||||
function matchingAdvisorRule<T>(quote: () => T): T {
|
||||
try { return quote(); }
|
||||
catch(error) { if(error instanceof AdvisorMatchingError) throw new RuleError(error.message); throw error; }
|
||||
@@ -23110,7 +23186,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.pendingAdvisorMatching && !g.pendingSpiritualAdvisor,
|
||||
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingAdvisorAmbassadorMatching && !g.pendingSpiritualAdvisor,
|
||||
'Resolve mandatory advisor conversions before checking victory.');
|
||||
quote ??= currentVictoryQuote(g);
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
@@ -28902,7 +28978,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
);
|
||||
} else if (response.kind === 'advisorFlip') {
|
||||
const bg = getPlayer(g, response.owner);
|
||||
if (response.spiritualAdvisorSource) finishSpiritualAdvisorResponse(g,response,canceled);
|
||||
if (response.advisorAmbassadorMatchingSource) finishAdvisorAmbassadorMatching(g,response,canceled);
|
||||
else if (response.spiritualAdvisorSource) finishSpiritualAdvisorResponse(g,response,canceled);
|
||||
else if (response.advisorMatchingSource) finishAdvisorMatching(g,response,canceled);
|
||||
else if (response.advisorResume === 'planetologistGather') {
|
||||
movementCancellationQuote(g, response);
|
||||
@@ -32586,6 +32663,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
treacheryDiscardIntegrity(state);
|
||||
shipmentPromiseIntegrity(state);
|
||||
saphoMovementIntegrity(state);
|
||||
advisorAmbassadorMatchingIntegrity(state);
|
||||
spiritualAdvisorIntegrity(state);
|
||||
ambassadorRelocationIntegrity(state);
|
||||
ambassadorRevivalIncomeIntegrity(state);
|
||||
@@ -32997,6 +33075,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
treacheryDiscardIntegrity(state);
|
||||
shipmentPromiseIntegrity(state);
|
||||
saphoMovementIntegrity(state);
|
||||
advisorAmbassadorMatchingIntegrity(state);
|
||||
spiritualAdvisorIntegrity(state);
|
||||
ambassadorRelocationIntegrity(state);
|
||||
ambassadorRevivalIncomeIntegrity(state);
|
||||
@@ -37083,6 +37162,7 @@ export function viewGame(state: Game, id: string) {
|
||||
treacheryDiscardIntegrity(state);
|
||||
shipmentPromiseIntegrity(state);
|
||||
saphoMovementIntegrity(state);
|
||||
advisorAmbassadorMatchingIntegrity(state);
|
||||
spiritualAdvisorIntegrity(state);
|
||||
ambassadorRelocationIntegrity(state);
|
||||
ambassadorRevivalIncomeIntegrity(state);
|
||||
@@ -37366,6 +37446,8 @@ export function viewGame(state: Game, id: string) {
|
||||
effect: entry.effect ?? token.effect,
|
||||
beneficiary: entry.beneficiary ?? null,
|
||||
noField: ambassadorMarkerView(g, id),
|
||||
...(entry.beneficiary===id&&entry.advisorMatchingDenied?.length
|
||||
? {matchingDenied:[...entry.advisorMatchingDenied]} : {}),
|
||||
shipment:
|
||||
entry.stage === 'ship' && id === entry.beneficiary
|
||||
? (() => {
|
||||
|
||||
@@ -194,6 +194,8 @@ const cancellationSources = {
|
||||
...(response.automaticAdvisorFlip.producer === 'spiritualArrival'
|
||||
? { spiritual:g.pendingSpiritualAdvisor?.source??null } : {}),
|
||||
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
|
||||
: response.advisorAmbassadorMatchingSource
|
||||
? {matching:response.advisorAmbassadorMatchingSource,pending:g.pendingAdvisorAmbassadorMatching?.receipt??null}
|
||||
: response.advisorMatchingSource
|
||||
? {matching:response.advisorMatchingSource,pending:g.pendingAdvisorMatching?.receipt??null}
|
||||
: response.spiritualAdvisorSource
|
||||
|
||||
@@ -307,6 +307,7 @@ export function quoteMovementCancellation(
|
||||
// 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 (response.kind === 'advisorFlip' && response.advisorAmbassadorMatchingSource) return null;
|
||||
if (response.kind === 'advisorFlip' && response.spiritualAdvisorSource) return null;
|
||||
if (
|
||||
![
|
||||
|
||||
+18
-4
@@ -4315,6 +4315,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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. 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.',
|
||||
'An allied Fremen Ambassador relocation also offers incoming matching before any selected force moves or collects spice. Residents keep their type. Provisionally, prevention restores the same relocation chooser while denying that same packet here for this Ambassador event. The already-triggered token and ordinary movement/shipment remain unchanged; another legal packet or destination stays available.',
|
||||
'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 +4334,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Implementation",
|
||||
"status": "Partial",
|
||||
"detail": "Fresh Advanced Spiritual declarations share target legality and distinct shipping/matching/committed-alone stages. A public seven-field source binds a private original chooser/parent/order; late unavailability restores choice before placement, and committed callbacks never recheck withdrawn reserves. Ordinary/Guild continuation and prior alone holds stay intact. Basic and already-recorded legacy responses keep their old protocol.",
|
||||
"detail": "Fremen Ambassador incoming matching now owns a separate precommit counter tied to the actual allied grant, with scoped denial and one original CHOAM/transfer/arrival suffix. Residents, consumed trigger and ordinary allowances are retained. Fresh Advanced Spiritual declarations share target legality and distinct shipping/matching/committed-alone stages. A public seven-field source binds a private original chooser/parent/order; late unavailability restores choice before placement, and committed callbacks never recheck withdrawn reserves. Ordinary/Guild continuation and prior alone holds stay intact. Basic and already-recorded legacy responses keep their old protocol.",
|
||||
"evidence": [
|
||||
"game/advisor-ambassador-matching.ts",
|
||||
"tests/advisor-ambassador-matching.test.ts",
|
||||
"tests/advisor-ambassador-matching-engine.test.ts",
|
||||
"tests/fixture-advisor-ambassador-matching.ts",
|
||||
"tests/fixture-spiritual-advisor-flow.ts",
|
||||
"tests/spiritual-advisor-engine.test.ts",
|
||||
"tests/spiritual-advisor.test.ts",
|
||||
@@ -4354,8 +4359,9 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Player controls",
|
||||
"status": "Partial",
|
||||
"detail": "Shared projected target choices drive ordinary/Guild buttons and minimal bots. Persistent notices distinguish uncommitted shipping/matching from an already-arrived force awaiting conversion. Same prevented matching packet stays blocked when the original chooser returns. Fifteen native GameTable SSR journeys and two controlled late-stance cases pass; callback/hydration and browser acceptance remain separate.",
|
||||
"detail": "Ambassador relocation drafts and persistent matching notices reuse the shared packet denial quote. Six native declaration/response/replacement GameTable SSR journeys pass; callback/hydration and browser acceptance remain separate. Shared projected target choices drive ordinary/Guild buttons and minimal bots. Persistent notices distinguish uncommitted shipping/matching from an already-arrived force awaiting conversion. Same prevented matching packet stays blocked when the original chooser returns. Fifteen native GameTable SSR journeys and two controlled late-stance cases pass; callback/hydration and browser acceptance remain separate.",
|
||||
"evidence": [
|
||||
"components/ambassador-movement.tsx",
|
||||
"docs/SPIRITUAL_ADVISORS.md",
|
||||
"docs/ADVISOR_MATCHING.md",
|
||||
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
|
||||
@@ -4367,8 +4373,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "AI",
|
||||
"status": "Partial",
|
||||
"detail": "Minimal policies consume projected legal targets, including all-blocked matching fallback to decline. Twenty-four paths across four existing profiles, two native parents and three stances accept72 choice/response actions. This is legal participation; no strategy refinement or difficulty calibration.",
|
||||
"detail": "Fremen Ambassador candidates reuse the public packet quote to omit prevented retries. Eight native existing-profile paths accept sixteen response/replacement actions after Ambassador matching; no strategy refinement. Minimal policies consume projected legal targets, including all-blocked matching fallback to decline. Twenty-four paths across four existing profiles, two native parents and three stances accept72 choice/response actions. This is legal participation; no strategy refinement or difficulty calibration.",
|
||||
"evidence": [
|
||||
"game/advisor-ambassador-matching.ts",
|
||||
"tests/advisor-ambassador-matching.test.ts",
|
||||
"tests/advisor-ambassador-matching-engine.test.ts",
|
||||
"tests/fixture-advisor-ambassador-matching.ts",
|
||||
"tests/fixture-spiritual-advisor-flow.ts",
|
||||
"tests/spiritual-advisor-engine.test.ts",
|
||||
"tests/spiritual-advisor.test.ts",
|
||||
@@ -4413,8 +4423,12 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Verification",
|
||||
"status": "Partial",
|
||||
"detail": "The frozen batch passes22 files390/390, types/lint/build, fifteen native ordinary/Guild declaration/response/GameTable SSR journeys, two controlled late-stance cases and twenty-four existing-profile paths. Public35/37-action ordinary and45/48-action Guild sources earn actual shipment/Intrusion; a second genuine grant preserves the held group. Four older matching/projection expectations are updated. Controlled legacy responses are format compatibility, not saved captures. Wider Homeworld/Tech/Nexus/No-Field combinations, other matching producers, full modes and final assurance remain open.",
|
||||
"detail": "The new Fremen Ambassador batch passes17 files358/358, types/lint/build, six native declaration/response/replacement GameTable SSR journeys and eight existing-profile paths. Genuine81-action sources earn a Nexus alliance and actual allied grant; both directions and responses retain trigger/ordinary allowances. One zero/absence assertion is repaired. The following earlier frozen results retain their original scope. The frozen batch passes22 files390/390, types/lint/build, fifteen native ordinary/Guild declaration/response/GameTable SSR journeys, two controlled late-stance cases and twenty-four existing-profile paths. Public35/37-action ordinary and45/48-action Guild sources earn actual shipment/Intrusion; a second genuine grant preserves the held group. Four older matching/projection expectations are updated. Controlled legacy responses are format compatibility, not saved captures. Wider Homeworld/Tech/Nexus/No-Field combinations, other matching producers, full modes and final assurance remain open.",
|
||||
"evidence": [
|
||||
"game/advisor-ambassador-matching.ts",
|
||||
"tests/advisor-ambassador-matching.test.ts",
|
||||
"tests/advisor-ambassador-matching-engine.test.ts",
|
||||
"tests/fixture-advisor-ambassador-matching.ts",
|
||||
"tests/fixture-spiritual-advisor-flow.ts",
|
||||
"tests/spiritual-advisor-engine.test.ts",
|
||||
"tests/spiritual-advisor.test.ts",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createAdvisorAmbassadorMatchingFixture as fixture,
|
||||
actAdvisorAmbassadorMatching as act, finishAdvisorAmbassadorMatching as finish,
|
||||
advisorAmbassadorMatchingPlayer as player, advisorAmbassadorMatchingInventory as inventory,
|
||||
} from './fixture-advisor-ambassador-matching';
|
||||
|
||||
const physical=(g:Game)=>g.players.map(p=>({id:p.id,forces:p.forces,reserves:p.reserves,tanks:p.tanks,
|
||||
spice:p.spice,moved:p.moved,shipped:p.shipped}));
|
||||
const allowances=(g:Game)=>g.players.map(p=>({id:p.id,reserves:p.reserves,tanks:p.tanks,
|
||||
spice:p.spice,moved:p.moved,shipped:p.shipped}));
|
||||
function allow(g:Game){
|
||||
const event=g.response?.advisorAmbassadorMatchingSource?.event;assert.ok(event);
|
||||
for(let count=0;g.response?.advisorAmbassadorMatchingSource?.event===event&&count<20;count++){
|
||||
const actor=g.players.find(p=>!g.response!.passed.includes(p.id));assert.ok(actor);
|
||||
g=act(g,actor.id,{type:'passResponse'});
|
||||
}
|
||||
assert.notEqual(g.response?.advisorAmbassadorMatchingSource?.event,event);return g;
|
||||
}
|
||||
function reject(g:Game,actor:string,action:Action){
|
||||
const before=structuredClone(g);assert.throws(()=>applyAction(g,actor,action));assert.deepEqual(g,before);
|
||||
}
|
||||
for(const direction of ['fightersToAdvisors','advisorsToFighters'] as const){
|
||||
void test(`native allied Fremen Ambassador ${direction} waits before relocation then commits its original arrival once`,()=>{
|
||||
const f=fixture({direction}),before=player(f.game,f.bg),target=f.destinationLocation.split(':')[0];
|
||||
const pending=act(f.game,f.bg,f.action);
|
||||
assert.equal(pending.response?.kind,'advisorFlip');assert.ok(pending.response?.advisorAmbassadorMatchingSource);
|
||||
assert.equal(pending.pendingAmbassador?.stage,'move');assert.notEqual(pending.active,f.bg);
|
||||
assert.deepEqual(physical(pending),physical(f.game));assert.deepEqual(pending.pendingAmbassador,f.originalParent);
|
||||
assert.deepEqual(physical(normalizeAutomaticGame(pending)),physical(f.game));
|
||||
const done=finish(allow(pending)),after=player(done,f.bg);
|
||||
assert.equal(after.forces[f.sourceLocation]??0,before.forces[f.sourceLocation]-1);
|
||||
assert.equal(after.forces[f.destinationLocation],before.forces[f.destinationLocation]+1);
|
||||
assert.equal(isAdvisor(after,target),direction==='fightersToAdvisors');
|
||||
assert.deepEqual(allowances(done),allowances(f.game));
|
||||
assert.ok(!done.pendingAmbassador);assert.equal(done.pendingAdvisorAmbassadorMatching,undefined);
|
||||
assert.equal(done.ecazAmbassadors!.tokens.find(t=>t.id===f.token)!.zone,'used');
|
||||
assert.equal(done.log.filter(l=>l.text.includes('through the Fremen Ambassador. Their ordinary')).length,1);
|
||||
assert.ok(player(done,f.holder).hand.some(c=>c.id===f.karama));
|
||||
assert.deepEqual(inventory(done),f.originalInventory);
|
||||
});
|
||||
void test(`native allied Fremen Ambassador ${direction} prevention retains grant and blocks only the same packet in this territory`,()=>{
|
||||
const f=fixture({direction}),pending=act(f.game,f.bg,f.action);
|
||||
const stopped=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
assert.equal(stopped.pendingAdvisorAmbassadorMatching,undefined);
|
||||
assert.equal(stopped.decision?.kind,'ecazAmbassador');assert.equal(stopped.decision?.player,f.bg);
|
||||
assert.equal(stopped.pendingAmbassador?.event,f.event);assert.equal(stopped.pendingAmbassador?.stage,'move');
|
||||
assert.deepEqual(physical(stopped),physical(f.game));assert.equal(stopped.discard.filter(c=>c.id===f.karama).length,1);
|
||||
reject(stopped,f.bg,f.action);
|
||||
reject(stopped,f.bg,{...f.action,fighters:true});
|
||||
const view=viewGame(stopped,f.bg);assert.equal(view.ambassadorEntry?.matchingDenied?.length,1);
|
||||
assert.ok(!('matchingDenied'in(viewGame(stopped,f.holder).ambassadorEntry??{})));
|
||||
const done=finish(act(stopped,f.bg,f.noMatch));
|
||||
assert.equal(player(done,f.bg).forces['polar_sink:0'],1);
|
||||
assert.deepEqual(allowances(done),allowances(f.game));assert.ok(!done.pendingAmbassador);
|
||||
assert.deepEqual(inventory(done),f.originalInventory);
|
||||
});
|
||||
void test(`native allied Fremen Ambassador ${direction} public counter omits original order and private continuation`,()=>{
|
||||
const f=fixture({direction}),pending=act(f.game,f.bg,f.action),view=viewGame(pending,f.holder);
|
||||
const source=view.response?.advisorAmbassadorMatchingSource;assert.ok(source);
|
||||
assert.deepEqual(Object.keys(source).sort(),['advisors','destination','event','group','origin','owner','parent','phase','sector','turn']);
|
||||
assert.equal(source.parent,f.event);assert.equal(source.owner,f.bg);
|
||||
assert.equal('pendingAdvisorAmbassadorMatching'in view,false);
|
||||
assert.equal(JSON.stringify(source).includes('signature'),false);
|
||||
});
|
||||
for(const level of DIFFICULTIES)void test(`${level} responds to native ${direction} matching and chooses a legal replacement after prevention`,()=>{
|
||||
const f=fixture({direction}),pending=act(f.game,f.bg,f.action),response=viewGame(pending,f.holder);
|
||||
response.players.find(p=>p.id===f.holder)!.bot=level;
|
||||
const answer=botActions(response)[0];assert.ok(answer);assert.doesNotThrow(()=>applyAction(pending,f.holder,answer));
|
||||
const stopped=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}),view=viewGame(stopped,f.bg);
|
||||
view.players.find(p=>p.id===f.bg)!.bot=level;
|
||||
const replacement=botActions(view)[0];assert.ok(replacement);assert.equal(replacement.type,'decision');
|
||||
assert.doesNotThrow(()=>applyAction(stopped,f.bg,replacement));
|
||||
});
|
||||
}
|
||||
void test('a different actual packet can match during the same native Ambassador grant after prevention',()=>{
|
||||
const f=fixture(),stopped=act(act(f.game,f.bg,f.action),f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
const replacement=act(stopped,f.bg,f.largerPacket),done=finish(replacement);
|
||||
assert.ok(!done.pendingAmbassador);assert.equal(done.pendingAdvisorAmbassadorMatching,undefined);
|
||||
assert.equal(player(done,f.bg).forces['arrakeen:10'],3);assert.equal(player(done,f.bg).forces['tueks_sietch:5']??0,0);
|
||||
assert.equal(isAdvisor(player(done,f.bg),'arrakeen'),true);assert.deepEqual(allowances(done),allowances(f.game));
|
||||
assert.equal(done.discard.filter(c=>c.id===f.karama).length,1);assert.deepEqual(inventory(done),f.originalInventory);
|
||||
});
|
||||
void test('an actual no-match Ambassador relocation creates no counter and retains the original Karama',()=>{
|
||||
const f=fixture(),done=finish(act(f.game,f.bg,f.noMatch));
|
||||
assert.ok(!done.response);assert.equal(done.pendingAdvisorAmbassadorMatching,undefined);
|
||||
assert.equal(player(done,f.bg).forces['polar_sink:0'],1);
|
||||
assert.ok(player(done,f.holder).hand.some(c=>c.id===f.karama));assert.deepEqual(allowances(done),allowances(f.game));
|
||||
});
|
||||
void test('a mismatched original Ambassador parent or selected order is rejected before cancellation cost',()=>{
|
||||
const f=fixture(),pending=act(f.game,f.bg,f.action);
|
||||
for(const field of ['signature','parentSignature'] as const){
|
||||
const changed=structuredClone(pending);changed.pendingAdvisorAmbassadorMatching![field]+='changed';
|
||||
reject(changed,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
assert.ok(player(changed,f.holder).hand.some(c=>c.id===f.karama));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
advisorAmbassadorMatchingKey,
|
||||
AdvisorAmbassadorMatchingError,
|
||||
quoteAdvisorAmbassadorMatching,
|
||||
validateAdvisorAmbassadorMatching,
|
||||
type AdvisorAmbassadorMatchingContext,
|
||||
type AdvisorAmbassadorMatchingIntent,
|
||||
type AdvisorAmbassadorMatchingSource,
|
||||
} from '../game/advisor-ambassador-matching';
|
||||
|
||||
function context(): AdvisorAmbassadorMatchingContext {
|
||||
return {
|
||||
advanced: true, status: 'playing', turn: 4, phase: 5,
|
||||
players: [
|
||||
{
|
||||
id: 'b', faction: 'beneGesserit',
|
||||
forces: {
|
||||
'hagga_basin:12': 3, 'hagga_basin:13': 2, 'imperial_basin:9': 2,
|
||||
'carthag:11': 2, 'arrakeen:10': 1,
|
||||
},
|
||||
advisors: { imperial_basin: { lockedTurn: 4 }, carthag: {} },
|
||||
},
|
||||
{ id: 'a', faction: 'atreides', forces: { 'imperial_basin:9': 1 } },
|
||||
{ id: 'e', faction: 'ecaz', forces: {} },
|
||||
],
|
||||
pendingAmbassador: {
|
||||
event: 'original-fremen-ambassador', turn: 4, phase: 5,
|
||||
effect: 'fremen', stage: 'move', beneficiary: 'b',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function intent(fields: Partial<AdvisorAmbassadorMatchingIntent> = {}): AdvisorAmbassadorMatchingIntent {
|
||||
return {
|
||||
parent: 'original-fremen-ambassador', owner: 'b', origin: 'hagga_basin',
|
||||
destination: 'imperial_basin', sector: 10, advisors: true,
|
||||
group: { 'hagga_basin:12': 1, 'hagga_basin:13': 1 }, ...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function source(ctx = context(), incoming = intent()): AdvisorAmbassadorMatchingSource {
|
||||
const quote = quoteAdvisorAmbassadorMatching(ctx, incoming);
|
||||
assert.ok(quote);
|
||||
return { event: 'matching-counter', ...quote };
|
||||
}
|
||||
|
||||
void test('Fremen Ambassador matching quotes only the selected incoming BG packet in either direction before transfer', () => {
|
||||
const ctx = context();
|
||||
const incoming = intent();
|
||||
const before = structuredClone({ ctx, incoming });
|
||||
const quote = quoteAdvisorAmbassadorMatching(ctx, incoming);
|
||||
assert.deepEqual(quote, {
|
||||
parent: 'original-fremen-ambassador', owner: 'b', turn: 4, phase: 5,
|
||||
origin: 'hagga_basin', destination: 'imperial_basin', sector: 10, advisors: true,
|
||||
group: { 'hagga_basin:12': 1, 'hagga_basin:13': 1 },
|
||||
});
|
||||
assert.deepEqual({ ctx, incoming }, before);
|
||||
assert.notEqual(quote!.group, incoming.group);
|
||||
quote!.group['hagga_basin:12'] = 99;
|
||||
assert.equal(ctx.players[0].forces['hagga_basin:12'], 3);
|
||||
assert.equal(incoming.group['hagga_basin:12'], 1);
|
||||
const reverse = intent({
|
||||
origin: 'carthag', destination: 'arrakeen', sector: 10, advisors: false,
|
||||
group: { 'carthag:11': 1 },
|
||||
});
|
||||
assert.equal(quoteAdvisorAmbassadorMatching(ctx, reverse)?.advisors, false);
|
||||
const original = source(ctx, reverse);
|
||||
assert.doesNotThrow(() => validateAdvisorAmbassadorMatching(ctx, original));
|
||||
assert.deepEqual(Object.keys(original).sort(), [
|
||||
'advisors', 'destination', 'event', 'group', 'origin', 'owner', 'parent', 'phase', 'sector', 'turn',
|
||||
]);
|
||||
assert.deepEqual(ctx, before.ctx);
|
||||
});
|
||||
|
||||
void test('Basic, foreign beneficiaries, same-type groups and absent residents do not open Ambassador matching', () => {
|
||||
for (const patch of [{ advanced: false }, { status: 'setup' }, { pendingAmbassador: null }]) {
|
||||
assert.equal(quoteAdvisorAmbassadorMatching({ ...context(), ...patch }, intent()), null);
|
||||
}
|
||||
const foreign = context();
|
||||
foreign.players[0].faction = 'atreides';
|
||||
assert.equal(quoteAdvisorAmbassadorMatching(foreign, intent()), null);
|
||||
assert.equal(quoteAdvisorAmbassadorMatching(context(), intent({ owner: 'missing' })), null);
|
||||
assert.equal(quoteAdvisorAmbassadorMatching(context(), intent({
|
||||
destination: 'arrakeen', sector: 10, advisors: false,
|
||||
})), null);
|
||||
assert.equal(quoteAdvisorAmbassadorMatching(context(), intent({
|
||||
origin: 'carthag', group: { 'carthag:11': 1 },
|
||||
})), null);
|
||||
const empty = context();
|
||||
empty.players[0].advisors = { ...empty.players[0].advisors, polar_sink: {} };
|
||||
assert.equal(quoteAdvisorAmbassadorMatching(empty, intent({ destination: 'polar_sink', sector: 0 })), null);
|
||||
const sameTerritory = intent({
|
||||
origin: 'imperial_basin', destination: 'imperial_basin', sector: 11,
|
||||
group: { 'imperial_basin:9': 1 },
|
||||
});
|
||||
assert.equal(quoteAdvisorAmbassadorMatching(context(), sameTerritory), null);
|
||||
assert.throws(() => validateAdvisorAmbassadorMatching(context(), {
|
||||
event: 'unnecessary-counter', turn: 4, phase: 5, ...sameTerritory,
|
||||
}), AdvisorAmbassadorMatchingError);
|
||||
});
|
||||
|
||||
void test('the current Fremen parent authorizes a non-active beneficiary without using ordinary move or shipment slots', () => {
|
||||
const ctx = context();
|
||||
Object.defineProperty(ctx, 'active', { value: 'a' });
|
||||
Object.defineProperty(ctx.players[0], 'moved', { value: 9 });
|
||||
Object.defineProperty(ctx.players[0], 'shipped', { value: true });
|
||||
for (const phase of [1, 3, 5, 8]) {
|
||||
ctx.phase = phase;
|
||||
ctx.pendingAmbassador!.phase = phase;
|
||||
const original = source(ctx);
|
||||
assert.equal(original.phase, phase);
|
||||
assert.doesNotThrow(() => validateAdvisorAmbassadorMatching(ctx, original));
|
||||
}
|
||||
for (const patch of [
|
||||
{ effect: 'guild' }, { stage: 'offer' }, { beneficiary: 'a' },
|
||||
]) {
|
||||
const other = context();
|
||||
Object.assign(other.pendingAmbassador!, patch);
|
||||
assert.equal(quoteAdvisorAmbassadorMatching(other, intent()), null);
|
||||
assert.throws(() => validateAdvisorAmbassadorMatching(other, source()), AdvisorAmbassadorMatchingError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('matching rejects stale parent, turn, phase, resident stance and uncommitted physical selection without mutation', () => {
|
||||
const changes: ((ctx: AdvisorAmbassadorMatchingContext, original: AdvisorAmbassadorMatchingSource) => void)[] = [
|
||||
(ctx) => { ctx.turn++; },
|
||||
(ctx) => { ctx.phase = 3; },
|
||||
(ctx) => { ctx.pendingAmbassador!.event = 'another-ambassador'; },
|
||||
(ctx) => { ctx.pendingAmbassador!.turn = 3; },
|
||||
(ctx) => { ctx.pendingAmbassador!.phase = 1; },
|
||||
(ctx) => { ctx.players[0].forces = { ...ctx.players[0].forces, 'hagga_basin:12': 0 }; },
|
||||
(ctx) => { ctx.players[0].forces = { ...ctx.players[0].forces, 'imperial_basin:9': 0 }; },
|
||||
(ctx) => { ctx.players[0].advisors = { carthag: {} }; },
|
||||
(_ctx, original) => { original.event = ''; },
|
||||
(_ctx, original) => { original.parent = 'another-ambassador'; },
|
||||
(_ctx, original) => { original.owner = 'missing'; },
|
||||
(_ctx, original) => { original.turn = 3; },
|
||||
(_ctx, original) => { original.phase = 3; },
|
||||
(_ctx, original) => { original.advisors = false; },
|
||||
(_ctx, original) => { original.destination = 'missing'; },
|
||||
(_ctx, original) => { original.sector = 8; },
|
||||
(_ctx, original) => { original.group = {}; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12': 4 }; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12': 0 }; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12': 1.5 }; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12.0': 1 }; },
|
||||
(_ctx, original) => { original.group = { 'carthag:11': 1 }; },
|
||||
(_ctx, original) => { original.group = { reserves: 1 }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const ctx = context();
|
||||
const original = source(ctx);
|
||||
change(ctx, original);
|
||||
const before = structuredClone({ ctx, original });
|
||||
assert.throws(() => validateAdvisorAmbassadorMatching(ctx, original), (error: unknown) =>
|
||||
error instanceof AdvisorAmbassadorMatchingError && error.name === 'AdvisorAmbassadorMatchingError');
|
||||
assert.deepEqual({ ctx, original }, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('the Ambassador denial key binds the original opportunity and packet while ignoring counter UUID and destination sector', () => {
|
||||
const original = source();
|
||||
const key = advisorAmbassadorMatchingKey(original);
|
||||
assert.equal(advisorAmbassadorMatchingKey({
|
||||
...original, event: 'new-counter', sector: 11,
|
||||
group: { 'hagga_basin:13': 1, 'hagga_basin:12': 1 },
|
||||
}), key);
|
||||
for (const change of [
|
||||
{ turn: 5 }, { parent: 'new-parent' }, { owner: 'other' }, { origin: 'carthag' },
|
||||
{ destination: 'carthag' }, { advisors: false }, { group: { 'hagga_basin:12': 1 } },
|
||||
]) {
|
||||
assert.notEqual(advisorAmbassadorMatchingKey({ ...original, ...change }), key);
|
||||
}
|
||||
});
|
||||
|
||||
void test('prevention stops only the same Ambassador packet and destination during its original parent opportunity', () => {
|
||||
const ctx = context();
|
||||
const original = source(ctx);
|
||||
ctx.advisorAmbassadorMatchingDenied = [advisorAmbassadorMatchingKey(original)];
|
||||
const before = structuredClone(ctx);
|
||||
assert.throws(() => quoteAdvisorAmbassadorMatching(ctx, intent()), /already prevented.*original Ambassador opportunity/);
|
||||
assert.throws(() => validateAdvisorAmbassadorMatching(ctx, {
|
||||
...original, event: 'new-counter', sector: 11,
|
||||
}), AdvisorAmbassadorMatchingError);
|
||||
assert.deepEqual(ctx, before);
|
||||
assert.ok(quoteAdvisorAmbassadorMatching(ctx, intent({ group: { 'hagga_basin:12': 1 } })));
|
||||
assert.ok(quoteAdvisorAmbassadorMatching(ctx, intent({ destination: 'carthag', sector: 11 })));
|
||||
ctx.pendingAmbassador!.event = 'later-original-ambassador';
|
||||
assert.ok(quoteAdvisorAmbassadorMatching(ctx, intent({ parent: 'later-original-ambassador' })));
|
||||
ctx.pendingAmbassador!.event = original.parent;
|
||||
ctx.turn = 5;
|
||||
ctx.pendingAmbassador!.turn = 5;
|
||||
assert.ok(quoteAdvisorAmbassadorMatching(ctx, intent()));
|
||||
});
|
||||
|
||||
void test('strict matching receipts exclude private data, multi-origin packets and non-board locations', () => {
|
||||
const ctx = context();
|
||||
const original = source(ctx);
|
||||
for (const field of ['order', 'signature', 'token', 'noField', 'skills', 'move', 'payment', 'kind']) {
|
||||
assert.throws(() => validateAdvisorAmbassadorMatching(ctx, {
|
||||
...original, [field]: 'private-or-unrelated',
|
||||
} as AdvisorAmbassadorMatchingSource), AdvisorAmbassadorMatchingError);
|
||||
}
|
||||
for (const malformed of [
|
||||
null, [], { ...original, advisors: 'true' }, { ...original, group: null },
|
||||
{ ...original, group: { 'hagga_basin:12': 1, 'carthag:11': 1 } },
|
||||
]) {
|
||||
assert.throws(() => validateAdvisorAmbassadorMatching(ctx,
|
||||
malformed as unknown as AdvisorAmbassadorMatchingSource), AdvisorAmbassadorMatchingError);
|
||||
}
|
||||
assert.throws(() => quoteAdvisorAmbassadorMatching(ctx,
|
||||
intent({ destination: 'homeworld:guild', sector: 0 })), AdvisorAmbassadorMatchingError);
|
||||
assert.throws(() => quoteAdvisorAmbassadorMatching(ctx,
|
||||
intent({ origin: 'homeworld:beneGesserit' })), AdvisorAmbassadorMatchingError);
|
||||
});
|
||||
|
||||
void test('Ambassador matching reads only public parent identity, physical BG counters and stance, without private eligibility or native order data', () => {
|
||||
const ctx = context();
|
||||
const incoming = intent();
|
||||
const original = source(ctx);
|
||||
for (const player of ctx.players) {
|
||||
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'skills', 'noField', 'elites', 'moved', 'reserves', 'shipped']) {
|
||||
Object.defineProperty(player, field, {
|
||||
get() { throw new Error(`Ambassador matching read ${player.id} private or unrelated ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const stance of Object.values(ctx.players[0].advisors!)) {
|
||||
for (const field of ['lockedTurn', 'aloneFlipHold']) {
|
||||
Object.defineProperty(stance, field, {
|
||||
get() { throw new Error(`Ambassador matching read unrelated ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const object of [ctx, ctx.pendingAmbassador!, incoming]) {
|
||||
for (const field of ['active', 'order', 'signature', 'token', 'forecast', 'pendingMove', 'pendingShipment', 'advisorMatchingDenied']) {
|
||||
Object.defineProperty(object, field, {
|
||||
get() { throw new Error(`Ambassador matching read original private ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.defineProperty(ctx.players[1], 'forces', {
|
||||
get() { throw new Error('Ambassador matching reran foreign occupancy'); },
|
||||
});
|
||||
assert.ok(quoteAdvisorAmbassadorMatching(ctx, incoming));
|
||||
assert.doesNotThrow(() => validateAdvisorAmbassadorMatching(ctx, original));
|
||||
assert.deepEqual(Object.keys(quoteAdvisorAmbassadorMatching(ctx, incoming)!).sort(), [
|
||||
'advisors', 'destination', 'group', 'origin', 'owner', 'parent', 'phase', 'sector', 'turn',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
|
||||
import {
|
||||
actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
|
||||
advisorPreparationPlayer, nextAdvisorPreparationStep,
|
||||
} from './fixture-advisor-preparation';
|
||||
|
||||
export type AdvisorAmbassadorMatchingStep = { actor: string; action: Action };
|
||||
export type AdvisorAmbassadorMatchingTransition = { before: Game; steps: AdvisorAmbassadorMatchingStep[]; after: Game };
|
||||
export const actAdvisorAmbassadorMatching = actAdvisorPreparation;
|
||||
export const advisorAmbassadorMatchingPlayer = advisorPreparationPlayer;
|
||||
export const advisorAmbassadorMatchingInventory = advisorPreparationInventory;
|
||||
export const advisorAmbassadorMatchingClean = advisorPreparationClean;
|
||||
|
||||
/** Native continuation never chooses an Ambassador relocation or a new
|
||||
* matching response. Ordinary preparation and Spiritual choices are declined. */
|
||||
export function nextAdvisorAmbassadorMatching(game: Game): AdvisorAmbassadorMatchingStep | null {
|
||||
if (game.response?.kind === 'advisorFlip') return null;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const ecaz = game.players.find(player => player.faction === 'ecaz');
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' && ecaz?.reserves === 20)
|
||||
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'ecazPlacement' || decision?.kind === 'ecazAmbassador') return null;
|
||||
if (decision?.kind === 'advisorBattle')
|
||||
return { actor: decision.player, action: { type: 'decision', accept: false } };
|
||||
}
|
||||
return nextAdvisorPreparationStep(game);
|
||||
}
|
||||
export function advanceAdvisorAmbassadorMatching(game: Game, until: (state: Game) => boolean,
|
||||
steps: AdvisorAmbassadorMatchingStep[] = []): Game {
|
||||
for (let count = 0; count < 2200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The native allied Ambassador chronology ended before its requested boundary');
|
||||
const next = nextAdvisorAmbassadorMatching(game);
|
||||
assert.ok(next, `Choose the original Ambassador/matching control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actAdvisorAmbassadorMatching(game, next.actor, next.action, steps);
|
||||
}
|
||||
throw Error('The original allied Fremen Ambassador source did not reach its finite boundary');
|
||||
}
|
||||
export function passAdvisorAmbassadorMatching(game: Game, steps: AdvisorAmbassadorMatchingStep[] = []): Game {
|
||||
for (let count = 0; game.response && game.response.kind !== 'advisorFlip' && count < 60; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actAdvisorAmbassadorMatching(game, actor.id, { type: 'passResponse' }, steps);
|
||||
}
|
||||
assert.ok(!game.response || game.response.kind === 'advisorFlip'); return game;
|
||||
}
|
||||
function originalFremenPacket() {
|
||||
const original = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
|
||||
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['fremen', ...original.filter(effect => effect !== 'fremen').slice(0, 4)];
|
||||
const order = [...supply, ...original.filter(effect => !supply.includes(effect))];
|
||||
const current = [...original], scalars: number[] = [];
|
||||
for (let index = current.length - 1; index > 0; index--) {
|
||||
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
|
||||
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
|
||||
[current[index], current[from]] = [current[from], current[index]];
|
||||
}
|
||||
assert.deepEqual(current, order); assert.equal(scalars.length, 9); return { supply, order, scalars };
|
||||
}
|
||||
function dealOriginalPacket(game: Game, step: AdvisorAmbassadorMatchingStep,
|
||||
steps: AdvisorAmbassadorMatchingStep[], scalars: number[]): Game {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1)
|
||||
array[0] = cursor < scalars.length ? scalars[cursor++] : 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try {
|
||||
const next = applyAction(game, step.actor, step.action);
|
||||
assert.equal(cursor, scalars.length, 'The native original Ecaz packet consumes its selected draws once');
|
||||
steps.push(structuredClone(step)); return next;
|
||||
} finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Public Advanced BG/Ecaz/Atreides base33. A paid native fighter group and the
|
||||
* original advisor survive to a real turn-two Nexus alliance. Ecaz then earns
|
||||
* an original Fremen trigger for its actual BG ally. Stop before relocation. */
|
||||
export function createAdvisorAmbassadorMatchingFixture(options: {
|
||||
direction?: 'fightersToAdvisors' | 'advisorsToFighters';
|
||||
} = {}) {
|
||||
const direction = options.direction ?? 'fightersToAdvisors';
|
||||
const bg = 'beneGesserit', ecaz = 'ecaz', holder = 'atreides';
|
||||
let game = createGame('ADVISORAMBASSADORMATCHING', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true, ['ecaz']);
|
||||
joinGame(game, newPlayer(ecaz, 'Ecaz', 'ecaz')); joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
|
||||
const initial = structuredClone(game), steps: AdvisorAmbassadorMatchingStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAdvisorAmbassadorMatching(game, actor, action, steps); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorAmbassadorMatching(game, until, steps); };
|
||||
const transition = (run: () => void): AdvisorAmbassadorMatchingTransition => {
|
||||
const before = structuredClone(game), start = steps.length; run();
|
||||
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, ['ecaz']);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
const originalInventory = advisorAmbassadorMatchingInventory(game); assert.equal(originalInventory.length, 33);
|
||||
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
|
||||
assert.equal(ordinary.length, 2);
|
||||
const selected = [ordinary[0], ordinary[1], karama];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game), packet = originalFremenPacket();
|
||||
let packetCreation: AdvisorAmbassadorMatchingTransition | null = null;
|
||||
let advisorSetup: AdvisorAmbassadorMatchingTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextAdvisorAmbassadorMatching(game); assert.ok(next);
|
||||
const before = structuredClone(game), start = steps.length;
|
||||
const createsPacket = game.setupStage === 'traitors' && next.action.type === 'traitor' &&
|
||||
game.players.filter(player => player.traitorChoices.length).length === 1;
|
||||
game = createsPacket ? dealOriginalPacket(game, next, steps, packet.scalars)
|
||||
: actAdvisorAmbassadorMatching(game, next.actor, next.action, steps);
|
||||
const record = { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||
if (createsPacket) packetCreation = record;
|
||||
if (next.action.type === 'advisorSetup') advisorSetup = record;
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(packetCreation && advisorSetup);
|
||||
const afterSetup = structuredClone(game), setupActionCount = steps.length;
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(advisorAmbassadorMatchingPlayer(game, bg).forces['arrakeen:10'], 1);
|
||||
assert.ok(advisorAmbassadorMatchingPlayer(game, bg).advisors?.arrakeen);
|
||||
assert.equal(advisorAmbassadorMatchingPlayer(game, holder).forces['arrakeen:10'], 10);
|
||||
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id => game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
!['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory));
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.turn === 1 && state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
const token = game.ecazAmbassadors!.tokens.find(candidate => candidate.effect === 'fremen')!;
|
||||
assert.equal(token.zone, 'supply');
|
||||
const placement = transition(() => {
|
||||
act(ecaz, { type: 'decision', token: token.id, territory: 'carthag' });
|
||||
game = passAdvisorAmbassadorMatching(game, steps);
|
||||
});
|
||||
assert.equal(advisorAmbassadorMatchingPlayer(placement.before, ecaz).spice - advisorAmbassadorMatchingPlayer(game, ecaz).spice, 1);
|
||||
act(ecaz, { type: 'decision', decline: true });
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === bg && advisorAmbassadorMatchingClean(state));
|
||||
const fighterShipment = transition(() => {
|
||||
act(bg, { type: 'ship', territory: 'tueks_sietch', sector: 5, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(advisorAmbassadorMatchingClean);
|
||||
});
|
||||
assert.equal(advisorAmbassadorMatchingPlayer(fighterShipment.before, bg).spice - advisorAmbassadorMatchingPlayer(game, bg).spice, 2);
|
||||
assert.equal(advisorAmbassadorMatchingPlayer(game, bg).forces['tueks_sietch:5'], 2);
|
||||
assert.ok(!advisorAmbassadorMatchingPlayer(game, bg).advisors?.tueks_sietch);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && advisorAmbassadorMatchingClean(state) &&
|
||||
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
|
||||
const beforeWorm = structuredClone(game), beforeWormInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(wormIndex >= 0);
|
||||
const worm = game.spiceDeck.splice(wormIndex, 1)[0]; game.spiceDeck.unshift(worm);
|
||||
const wormOrder = { position: 0, from: wormIndex, card: structuredClone(worm) };
|
||||
const wormFollowupOrder = [1, 2].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
!['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory));
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeWormInventory);
|
||||
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && advisorAmbassadorMatchingClean(state));
|
||||
const nexus = structuredClone(game);
|
||||
const alliance = transition(() => {
|
||||
act(bg, { type: 'alliance', target: ecaz });
|
||||
act(ecaz, { type: 'alliance', target: bg });
|
||||
});
|
||||
assert.equal(advisorAmbassadorMatchingPlayer(game, bg).ally, ecaz);
|
||||
assert.equal(advisorAmbassadorMatchingPlayer(game, ecaz).ally, bg);
|
||||
const allianceActionCount = steps.length;
|
||||
reach(state => state.turn === 2 && state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
|
||||
act(ecaz, { type: 'decision', decline: true });
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === holder && advisorAmbassadorMatchingClean(state));
|
||||
const entrantShipment = transition(() => {
|
||||
act(holder, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'offer');
|
||||
});
|
||||
const offer = structuredClone(game), event = game.pendingAmbassador!.event;
|
||||
const trigger = transition(() => {
|
||||
act(ecaz, { type: 'decision', event, trigger: true, beneficiary: bg });
|
||||
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'move');
|
||||
});
|
||||
assert.equal(game.pendingAmbassador!.beneficiary, bg); assert.equal(game.pendingAmbassador!.effect, 'fremen');
|
||||
assert.equal(game.decision?.player, bg);
|
||||
assert.equal(advisorAmbassadorMatchingPlayer(game, bg).forces['arrakeen:10'], 1);
|
||||
assert.ok(advisorAmbassadorMatchingPlayer(game, bg).advisors?.arrakeen);
|
||||
assert.equal(advisorAmbassadorMatchingPlayer(game, bg).forces['tueks_sietch:5'], 2);
|
||||
assert.ok(!advisorAmbassadorMatchingPlayer(game, bg).advisors?.tueks_sietch);
|
||||
assert.equal(advisorAmbassadorMatchingPlayer(game, bg).ally, ecaz);
|
||||
assert.equal(advisorAmbassadorMatchingPlayer(game, ecaz).ally, bg);
|
||||
assert.ok(advisorAmbassadorMatchingPlayer(game, holder).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(advisorAmbassadorMatchingInventory(game), originalInventory);
|
||||
const advisorsToFighters: Action = { type: 'decision', event, forces: { 'arrakeen:10': 1 }, territory: 'tueks_sietch', sector: 5 };
|
||||
const fightersToAdvisors: Action = { type: 'decision', event, forces: { 'tueks_sietch:5': 1 }, territory: 'arrakeen', sector: 10 };
|
||||
const largerPacket: Action = { type: 'decision', event, forces: { 'tueks_sietch:5': 2 }, territory: 'arrakeen', sector: 10 };
|
||||
const noMatch: Action = { type: 'decision', event, forces: { 'tueks_sietch:5': 1 }, territory: 'polar_sink', sector: 0 };
|
||||
const action = direction === 'fightersToAdvisors' ? fightersToAdvisors : advisorsToFighters;
|
||||
const sourceLocation = direction === 'fightersToAdvisors' ? 'tueks_sietch:5' : 'arrakeen:10';
|
||||
const destinationLocation = direction === 'fightersToAdvisors' ? 'arrakeen:10' : 'tueks_sietch:5';
|
||||
return { game, source: structuredClone(game), beforeSelection: structuredClone(game), initial, lobby, setup,
|
||||
beforeStartingDeal, afterSetup, advisorSetup, packet, packetCreation, placement, fighterShipment,
|
||||
beforeWorm, wormOrder, wormFollowupOrder, nexus, alliance, entrantShipment, offer, beforeTrigger: offer, trigger, triggerState: trigger.after,
|
||||
originalParent: structuredClone(game.pendingAmbassador), direction, bg, owner: bg, ecaz, holder, atreides: holder,
|
||||
karama: karama.id, card: karama.id, token: token.id, event, actor: bg, beneficiary: bg,
|
||||
action, advisorsToFighters, fightersToAdvisors, largerPacket, noMatch,
|
||||
matchingActions: [fightersToAdvisors, advisorsToFighters], replacement: largerPacket,
|
||||
replacements: [largerPacket, noMatch], replacementActions: [largerPacket, noMatch],
|
||||
sourceLocation, destinationLocation, from: sourceLocation, to: destinationLocation,
|
||||
relocationStep: { actor: bg, action } satisfies AdvisorAmbassadorMatchingStep,
|
||||
steps, actions: steps, sourceActionCount: steps.length, setupActionCount, allianceActionCount,
|
||||
originalInventory, originalSpice, dealOrder, spiceOrder,
|
||||
staging: [
|
||||
'Actual public Advanced BG/Ecaz/Atreides ready/startPreview, original base33 and bare E3. Original prediction/private Traitors, native Ecaz Basin6 setup and actual BG advisorSetup beside Atreides’ native ten Arrakeen counters execute normally.',
|
||||
'Select only original scalar shuffle draws, three original unopened Treachery positions, two safe ordinary first-turn Spice positions, the original Ecaz nine-draw Fremen packet and one still-unopened turn-two Shai-Hulud followed by two safe original land cards. Atreides actually receives and retains its original printed Karama; no dealt hand is installed.',
|
||||
'Ecaz actually pays one at first Revival to place its original Fremen token in Carthag. During its real first movement turn BG actually pays two to ship two reserves into empty Tuek’s5 as fighters, retaining the original Arrakeen advisor. Other native turns and Collection/Mentat complete without a battle or force staging.',
|
||||
'Actual native turn-two Storm Card1 leaves both BG groups safe. The actual Shai-Hulud and Nexus permit BG’s real alliance proposal and Ecaz’s real acceptance. The reciprocal alliance is never assigned; no Nexus Card module is selected.',
|
||||
'Atreides actually ships one reserve into the still-placed Carthag token. The original entry/Spiritual controls resolve normally, then Ecaz really triggers Fremen for its actual BG ally. Stop at the original BG-owned stage move, before selecting or consuming any relocation packet.',
|
||||
'One original board exposes both opposite-type directions, two genuine one/two-fighter packets for retry, and a no-match relocation to empty Polar Sink. The new matching response, force transfer, mode change and later parent callbacks remain caller-owned outcomes; no played phase, alliance, army, wallet, stance, prevention or receipt is installed.',
|
||||
] };
|
||||
}
|
||||
export type AdvisorAmbassadorMatchingFixture = ReturnType<typeof createAdvisorAmbassadorMatchingFixture>;
|
||||
|
||||
/** Submit only one original source selection; later matching stays owned. */
|
||||
export function commitAdvisorAmbassadorMatching(source: Game, fixture: AdvisorAmbassadorMatchingFixture,
|
||||
action: Action = fixture.action, steps: AdvisorAmbassadorMatchingStep[] = []) {
|
||||
const before = structuredClone(source), start = steps.length;
|
||||
const game = actAdvisorAmbassadorMatching(source, fixture.bg, action, steps);
|
||||
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
|
||||
}
|
||||
export function finishAdvisorAmbassadorMatching(source: Game, steps: AdvisorAmbassadorMatchingStep[] = []): Game {
|
||||
return advanceAdvisorAmbassadorMatching(source,
|
||||
game => !game.pendingAmbassador && advisorAmbassadorMatchingClean(game), steps);
|
||||
}
|
||||
Reference in new issue
Block a user