Counter BG matching before fresh Guild and Junction transport
Preserve original grants, private funding and unused shipments while matching is pending. Restore legal replacement choices after prevention and resume the original permission, price and transfer once.
This commit is contained in:
1 parent
c999054ae0
commit
b2a20226d7
17 files changed
+1444
-17
No files matched your search
@@ -2503,6 +2503,16 @@ 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.advisorTransportMatchingSource&&(
|
||||
<p className="notice">
|
||||
The incoming transport group must match your residents before payment or force transfer.
|
||||
{' '}Preventing matching leaves the shipment unused, while this same packet here stays unavailable for the original opportunity.
|
||||
{' '}Choose another legal packet or destination.
|
||||
{g.response.advisorTransportMatchingSource.producer==='junction'
|
||||
? ' The Junction permission and tariff remain immune; the original payment and transfer follow allowance.'
|
||||
: ' Original transport permission, price and payment follow separately if matching is allowed.'}
|
||||
</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.
|
||||
|
||||
@@ -1,5 +1,83 @@
|
||||
# Matching incoming Bene Gesserit groups
|
||||
|
||||
## Guild and Junction transport — 24 October 2026
|
||||
|
||||
**Prototyped / Partial.** Compose supplied revised
|
||||
Advanced physical28's marked matching clause and physical17's group counter
|
||||
with fresh Guild cross-transport and Junction board arrivals. Incoming BG board
|
||||
groups retain their actual source stance. A Junction native or foreign World
|
||||
packet provisionally starts with the ordinary fighter default; multiple selected
|
||||
world keys are one original transport packet. Match only opposite-type arrivals
|
||||
joining actual BG residents. Returns to reserves/worlds need no board matching.
|
||||
|
||||
The selected provisional priority offers matching after the original fresh
|
||||
declaration's legality/funding quote, before native Guild transport permission,
|
||||
rate, Guild Betrayal settlement or any payment/transfer. These sources do not
|
||||
specify their relative response order. Junction's Homeworld permission and
|
||||
tariff stay immune; matching targets only BG's independent group conversion.
|
||||
Absent-Guild Secret Ally retains its unspent actual Nexus and bank price until
|
||||
the original physical commitment. Native Guild Cunning belongs to Guild, not
|
||||
BG; it grants no extra BG transport. Purchased Karama is an independent price,
|
||||
not a cross-transport permission.
|
||||
|
||||
The public ten-field source contains only event, producer, owner, turn/phase,
|
||||
origin, destination/sector, target stance and physical group. Private native
|
||||
opportunity identity, original order/funding and exact signature remain outside
|
||||
that receipt. Before either counter outcome, revalidate the real original grant
|
||||
and packet using a nonmutating projection of its owned matching controls;
|
||||
Junction/Secret frontdoor busy checks must not reject their own response. Never
|
||||
create matching events in movement-preparation simulation.
|
||||
|
||||
Under the existing provisional remedy, prevent only that packet/destination/
|
||||
stance in the same producer opportunity, retaining the unused shipment and
|
||||
original Junction offer or Secret card. A different packet/destination remains
|
||||
available. Price changes, destination sectors and response UUIDs cannot retry
|
||||
the same original packet. Human quotes and minimal bots share those denials.
|
||||
Allowance consumes the matching parent before invoking the exact original
|
||||
permission/Secret/Junction suffix once. Existing stored permission/rate/Betrayal
|
||||
continuations keep their old protocol; do not reopen them or replay costs. This
|
||||
fresh-declaration cutover preserves the original full-rate cleanup timing.
|
||||
|
||||
[Native sources](../tests/fixture-advisor-transport-matching.ts) earn original
|
||||
public Advanced BG/Guild/Atreides setup, original advisor and Karama, paid
|
||||
fighters, actual Nexus alliance or current high-native Junction offer. Separate
|
||||
[Secret Ally source](../tests/fixture-advisor-secret-transport-matching.ts)
|
||||
earns absent-Guild Nexus entitlement with BG unallied and an actual Atreides/
|
||||
Emperor alliance supplying native draw eligibility. Sources use85 public actions
|
||||
for allied Guild,82 for Junction board,36 for native World and74 for Secret.
|
||||
No played force, hand, phase, alliance, grant, offer or response is installed.
|
||||
|
||||
Frozen **25 files437/437**, types/lint/build, twenty native declaration/response/
|
||||
replacement/original-permission/rate/GameTable SSR journeys, two controlled
|
||||
Truthtrance cases and sixteen existing-profile paths with32 accepted actions
|
||||
pass. Both board directions and native World arrivals preserve state while
|
||||
matching waits. Prevention keeps the actual grant, blocks the same packet and
|
||||
admits empty or genuine two-counter replacements. Matching allowance still
|
||||
precedes original Guild permission and, when a real discount exists, rate.
|
||||
A one-force city price has no separate discount; actual desert discount does.
|
||||
Original permission denial and unaffordable full-rate cancellation leave the
|
||||
physical shipment unused. Secret keeps its Nexus through prevention and spends
|
||||
it once on actual transfer. Junction retains original tariff/custody/income.
|
||||
|
||||
Bounded review identifies and repairs Truthtrance's own busy overlay in grant
|
||||
validation and an inconsistent matching-only price restriction. A labelled
|
||||
controlled original-card relocation reproduces the busy failure; both overlay
|
||||
regressions then preserve real Truthtrance priority and resume the same matching
|
||||
response. Runtime feedback corrects city-discount and actual empty-target
|
||||
assumptions; type feedback normalizes nullable World tables at three adapters.
|
||||
Controlled Truthtrance custody is not a native earned draw. Standard controls
|
||||
render; SSR does not establish callback/hydration or browser acceptance.
|
||||
|
||||
Private proof and HTML:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-transport-matching/`.
|
||||
`frozen-integrated-4/report.json` binds tree
|
||||
`43e21e75f3d9b4130593e5c4427c1df86232ea8e8506408c38861706e09437d7`,
|
||||
2766 files on base `c999054ae02d5a03d1005448f9c467bf0c44bed3`.
|
||||
Checkpoint prose/reference overlays are separately reviewed. Two-origin
|
||||
gathering, mixed foreign-World/purchased-price/Betrayal/other modules, all valid
|
||||
combinations and final browser/deployed/mode assurance remain open. No AI
|
||||
refinement or saved-room/D1/server/reset/restart/deployment operation.
|
||||
|
||||
## Fremen Ambassador relocation — 24 October 2026
|
||||
|
||||
**Prototyped / Partial.** The supplied revised
|
||||
@@ -58,8 +136,8 @@ HTML live in
|
||||
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/
|
||||
acceptance. The Guild/Junction follow-up is now prototyped above; 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
|
||||
|
||||
+37
-2
@@ -16,6 +16,40 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Guild/Junction incoming matching — 24 October. Prototyped / Partial.**
|
||||
Fresh native Guild and absent-Guild Secret Ally cross-transports plus Junction
|
||||
board/World arrivals now offer incoming BG matching before original permission,
|
||||
price, settlement or physical transfer. This selected priority is provisional;
|
||||
Junction permission/tariff remain immune. Public ten-field physical sources omit
|
||||
private orders, scope and funding. Prevention retains unused shipment and grant,
|
||||
denying only that packet/destination/stance in its producer opportunity. Shared
|
||||
human/bot quotes retain replacements; consume the private matching parent before
|
||||
one original suffix. Stored old continuations keep their previous protocol.
|
||||
[Source, fresh cutover and evidence](ADVISOR_MATCHING.md#guild-and-junction-transport--24-october-2026).
|
||||
|
||||
Disjoint helper/native-fixture lanes, bounded source/integration review and root
|
||||
integration pass **25 files437/437**, types/lint/build, twenty native
|
||||
declaration/response/replacement/original-permission/rate/GameTable SSR journeys,
|
||||
two controlled Truthtrance cases and sixteen existing-profile paths with32
|
||||
accepted response/replacement actions. Genuine85-action allied-Guild sources,
|
||||
82-action Junction board,36-action native World and74-action Secret sources
|
||||
earn original base33 setup/Karama, paid fighters, actual Nexus alliance or high
|
||||
Junction offer. Secret BG stays unallied; an actual Atreides/Emperor alliance
|
||||
earns its genuine Guild Nexus draw. No played state is installed in native
|
||||
sources. Both matching outcomes preserve physical state while waiting; later
|
||||
permission and actual discounted-price counters remain separate. A city price
|
||||
with no discount has no rate counter. Nexus is consumed once at actual Secret
|
||||
commitment; original unaffordable full-price cancellation stays unused.
|
||||
|
||||
Review repairs the owned quiet Truthtrance projection and removes an inconsistent
|
||||
matching-only price restriction. The controlled overlay reproduces the busy
|
||||
failure, then both overlay regressions pass; real priority and original response
|
||||
remain. Feedback corrects city-discount/empty-target smoke expectations and
|
||||
normalizes nullable World data at three adapters. Two-origin gathering, mixed
|
||||
foreign-World/purchased-price/Betrayal/module combinations and final mode/browser/
|
||||
deployed assurance remain open. No AI refinement or saved-room/D1/server/reset/
|
||||
restart/deployment operation.
|
||||
|
||||
**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
|
||||
@@ -39,8 +73,9 @@ 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.
|
||||
reset/restart/deployment operation. The Guild/Junction follow-up is now prototyped
|
||||
above; 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
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 24 October 2026 — Fresh Guild, Junction and Secret Ally incoming BG matching
|
||||
|
||||
Incoming opposite-type BG packets now have a distinct precommit counter through
|
||||
native allied Guild cross-transport, absent-Guild Secret Ally, Junction board
|
||||
relocation and Junction native/foreign World arrival. Supplied physical28's
|
||||
marked matching clause/physical17's group counter support the conversion;
|
||||
World incoming fighter default and matching-before-permission/rate/settlement
|
||||
priority remain explicit provisional compositions. Junction's own Homeworld
|
||||
permission and tariff retain immunity. Existing stored continuations retain
|
||||
their old protocol, avoiding any replay of original costs or rate cleanup.
|
||||
|
||||
Public ten-field receipts select physical packets only. Keep original scope,
|
||||
funding, order and signature private; before either counter outcome revalidate
|
||||
the original actual grant through a quiet projection of owned interruptions.
|
||||
Prevention leaves shipment/grant unused while denying the same packet/target
|
||||
stance in that destination for the producer opportunity. Price, sector and
|
||||
counter UUID changes cannot retry it; another packet/destination remains.
|
||||
Human quotes and minimal bots share these denials. Allowance consumes the parent
|
||||
before one original permission/Secret/Junction suffix. No matching events are
|
||||
created during movement-preparation simulation.
|
||||
|
||||
Disjoint helper/native-fixture lanes, bounded source/integration review and root
|
||||
integration pass **25 files437/437**, types/lint/build, twenty native
|
||||
declaration/response/replacement/original-permission/rate/GameTable SSR journeys,
|
||||
two controlled Truthtrance cases and sixteen existing-profile paths with32
|
||||
accepted response/replacement actions. Genuine85/82/36/74-action original
|
||||
base33 sources earn allied-Guild, Junction board, native World and Secret
|
||||
respectively. Original setup/Karama, paid forces, actual Nexus alliance/high
|
||||
Junction offer and actual Guild Nexus draw remain authoritative. Secret BG stays
|
||||
unallied while Atreides/Emperor earn its native draw eligibility. No played
|
||||
state is installed in those native sources. Permission and actual-discount
|
||||
responses still follow matching; original unfunded full-price cancellation
|
||||
leaves shipment unused. Secret spends its physical Nexus once at commitment.
|
||||
|
||||
Review repairs Truthtrance's owned busy projection and a matching-only price
|
||||
inconsistency. A controlled real-card relocation reproduces the busy failure;
|
||||
both repaired overlay cases preserve actual priority and original matching.
|
||||
Smoke feedback corrects city-discount and empty-target assumptions; type
|
||||
feedback normalizes nullable World data at three adapters. The final union and
|
||||
runtime pass. Private proof:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-transport-matching/`.
|
||||
`frozen-integrated-4/report.json` binds tree
|
||||
`43e21e75f3d9b4130593e5c4427c1df86232ea8e8506408c38861706e09437d7`,
|
||||
2766 files on base `c999054ae02d5a03d1005448f9c467bf0c44bed3`.
|
||||
Final prose/reference overlays are reviewed separately. Controlled Truthtrance
|
||||
is not an earned draw; SSR is not callback/hydration/browser acceptance. Mixed
|
||||
foreign-World/purchased-price/Betrayal/other modules, two-origin gathering and
|
||||
final mode/assurance remain open. No AI refinement or saved-room/D1/server/reset/
|
||||
restart/deployment operation.
|
||||
[Source, ordering, original continuations and limits](ADVISOR_MATCHING.md#guild-and-junction-transport--24-october-2026).
|
||||
|
||||
## 24 October 2026 — Incoming BG matching through a native Fremen Ambassador
|
||||
|
||||
Supplied physical28's marked matching clause now composes the native allied
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Rule decision index
|
||||
|
||||
## Guild/Junction incoming matching — 24 October 2026
|
||||
|
||||
Supplied physical28's marked shipping/moving clause and physical17's group
|
||||
counter apply to opposite-type incoming BG transport packets. Junction World
|
||||
packets provisionally retain the ordinary reserve fighter default. Under the
|
||||
14 October policy, select fresh-declaration matching after quote and before
|
||||
permission/rate/Betrayal/payment, without reopening stored old continuations.
|
||||
Junction permission/tariff remain immune; Secret Ally is not consumed before
|
||||
physical commitment. Prevent only that packet/destination/stance in the actual
|
||||
producer opportunity, retaining unused shipment and original grant. Keep native
|
||||
scope, funding and signature private; consume the matching parent before one
|
||||
original suffix. Broader modes remain unfinished.
|
||||
[Source, original ordering and evidence](ADVISOR_MATCHING.md#guild-and-junction-transport--24-october-2026).
|
||||
|
||||
## Fremen Ambassador incoming matching — 24 October 2026
|
||||
|
||||
Compose supplied physical28's incoming shipping/moving matching clause and
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { gameTerritories, splitLocation, type MobileBoard } from './board';
|
||||
import type { FactionId } from './catalog';
|
||||
import type { AdvisorStance } from './advisor-automatic-flip';
|
||||
|
||||
/** The selected incoming group, before its transport or payment is committed. */
|
||||
export type AdvisorTransportMatchingSource = {
|
||||
event: string;
|
||||
producer: 'guild' | 'junction';
|
||||
owner: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
origin: string;
|
||||
destination: string;
|
||||
sector: number;
|
||||
advisors: boolean;
|
||||
group: Record<string, number>;
|
||||
};
|
||||
|
||||
export type AdvisorTransportMatchingIntent = Omit<AdvisorTransportMatchingSource, 'event' | 'turn' | 'phase'>;
|
||||
export type AdvisorTransportMatchingQuote = Omit<AdvisorTransportMatchingSource, 'event'>;
|
||||
export type AdvisorTransportMatchingPlayer = {
|
||||
id: string;
|
||||
faction: FactionId;
|
||||
forces: Readonly<Record<string, number>>;
|
||||
advisors?: Readonly<Record<string, AdvisorStance>>;
|
||||
shipped?: boolean;
|
||||
};
|
||||
export type AdvisorTransportMatchingWorld = {
|
||||
id: string;
|
||||
forces: Readonly<Record<string, { normal: number; elite: number }>>;
|
||||
};
|
||||
export type AdvisorTransportMatchingContext = MobileBoard & {
|
||||
advanced: boolean;
|
||||
status: string;
|
||||
turn: number;
|
||||
phase: number;
|
||||
active: string | null;
|
||||
players: readonly AdvisorTransportMatchingPlayer[];
|
||||
worlds?: readonly AdvisorTransportMatchingWorld[];
|
||||
/** Private native opportunity identity, never copied into the public source. */
|
||||
advisorTransportMatchingOpportunity?: string;
|
||||
advisorTransportMatchingDenied?: readonly string[];
|
||||
};
|
||||
|
||||
export class AdvisorTransportMatchingError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'AdvisorTransportMatchingError';
|
||||
}
|
||||
}
|
||||
|
||||
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 AdvisorTransportMatchingError(message);
|
||||
}
|
||||
|
||||
/** A fresh counter UUID or destination sector cannot retry the same native
|
||||
* opportunity. The private scope belongs only to this local prevention key. */
|
||||
export function advisorTransportMatchingKey(
|
||||
source: AdvisorTransportMatchingSource | AdvisorTransportMatchingQuote,
|
||||
opportunity: string,
|
||||
): string {
|
||||
requireMatching(identifier(opportunity), 'Transport matching needs its original native opportunity.');
|
||||
const group = Object.entries(source.group).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
|
||||
return JSON.stringify([
|
||||
'advisorTransportMatching', opportunity, source.turn, source.owner, source.producer,
|
||||
source.origin, source.destination, source.advisors, group,
|
||||
]);
|
||||
}
|
||||
|
||||
function hasResidents(player: AdvisorTransportMatchingPlayer, destination: string) {
|
||||
return Object.entries(player.forces).some(([key, count]) =>
|
||||
splitLocation(key).territory === destination && count > 0);
|
||||
}
|
||||
|
||||
function selectedGroup(
|
||||
ctx: AdvisorTransportMatchingContext,
|
||||
owner: AdvisorTransportMatchingPlayer,
|
||||
intent: AdvisorTransportMatchingIntent,
|
||||
originSectors: readonly number[] | undefined,
|
||||
): Record<string, number> {
|
||||
requireMatching(record(intent.group) && Object.keys(intent.group).length > 0,
|
||||
'Select a nonempty physical BG packet for transport matching.');
|
||||
const keys = Object.keys(intent.group);
|
||||
if (!originSectors) {
|
||||
requireMatching(intent.producer === 'junction' && Array.isArray(ctx.worlds) &&
|
||||
intent.origin === [...keys].sort()[0],
|
||||
'Junction Homeworld matching needs its canonical first selected source world.');
|
||||
}
|
||||
const group: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const key of keys) {
|
||||
let held: number | undefined;
|
||||
if (originSectors) {
|
||||
const point = splitLocation(key);
|
||||
requireMatching(key === `${point.territory}:${point.sector}` && point.territory === intent.origin &&
|
||||
originSectors.includes(point.sector),
|
||||
'Select the physical transport packet from one current board territory.');
|
||||
held = owner.forces[key];
|
||||
} else {
|
||||
const worlds = ctx.worlds!.filter((world) => world.id === key);
|
||||
const world = worlds[0];
|
||||
requireMatching(ctx.players.some((player) => key === `homeworld:${player.faction}` ||
|
||||
(player.faction === 'emperor' && key === 'homeworld:emperor:salusa')) &&
|
||||
worlds.length === 1 && record(world.forces) && Object.hasOwn(world.forces, owner.id) &&
|
||||
record(world.forces[owner.id]),
|
||||
'Select BG counters actually held on current source Homeworlds.');
|
||||
// Only normal physical BG counters participate; other owners and special
|
||||
// counter counts are unrelated to this incoming selected packet.
|
||||
held = world.forces[owner.id].normal;
|
||||
}
|
||||
const count = intent.group[key];
|
||||
requireMatching(positive(count) && nonnegative(held) && held >= count,
|
||||
'The incoming transport packet is no longer held at its original physical sources.');
|
||||
total += count;
|
||||
requireMatching(positive(total), 'The incoming transport packet exceeds the supported physical count.');
|
||||
group[key] = count;
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/** The native transport keeps grant, route, occupancy, rate and funding
|
||||
* permission. This quote only opens an incoming-group stance change. */
|
||||
export function quoteAdvisorTransportMatching(
|
||||
ctx: AdvisorTransportMatchingContext,
|
||||
intent: AdvisorTransportMatchingIntent,
|
||||
): AdvisorTransportMatchingQuote | 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 BG transport 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 || owner.shipped === true) return null;
|
||||
requireMatching((intent.producer === 'guild' || intent.producer === 'junction') &&
|
||||
identifier(intent.origin) && identifier(intent.destination) && nonnegative(intent.sector) &&
|
||||
typeof intent.advisors === 'boolean',
|
||||
'Choose the native transport, original packet and resident destination stance.');
|
||||
// Returning to a Homeworld or reserves never matches an Arrakis resident.
|
||||
if (intent.destination === 'reserves' || intent.destination.startsWith('homeworld:')) return null;
|
||||
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 && (origin ||
|
||||
(intent.producer === 'junction' && intent.origin.startsWith('homeworld:'))),
|
||||
'Transport matching needs its current board destination and physical board or Junction Homeworld origin.');
|
||||
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 transport matching stance.');
|
||||
// A Homeworld packet provisionally has the ordinary incoming fighter default.
|
||||
const incomingAdvisors = origin ? !!owner.advisors?.[intent.origin] : false;
|
||||
if (incomingAdvisors === targetAdvisors) return null;
|
||||
const group = selectedGroup(ctx, owner, intent, origin?.sectors);
|
||||
requireMatching(identifier(ctx.advisorTransportMatchingOpportunity),
|
||||
'Transport matching needs its original native opportunity.');
|
||||
const quote: AdvisorTransportMatchingQuote = {
|
||||
producer: intent.producer, owner: owner.id, turn: ctx.turn, phase: ctx.phase,
|
||||
origin: intent.origin, destination: intent.destination, sector: intent.sector,
|
||||
advisors: targetAdvisors, group,
|
||||
};
|
||||
requireMatching(ctx.advisorTransportMatchingDenied === undefined ||
|
||||
(Array.isArray(ctx.advisorTransportMatchingDenied) &&
|
||||
ctx.advisorTransportMatchingDenied.every((key) => typeof key === 'string')),
|
||||
'The transport matching prevention history is invalid.');
|
||||
requireMatching(!ctx.advisorTransportMatchingDenied?.includes(
|
||||
advisorTransportMatchingKey(quote, ctx.advisorTransportMatchingOpportunity)),
|
||||
'Karama already prevented this selected packet from matching in that territory during the original transport opportunity.');
|
||||
return quote;
|
||||
}
|
||||
|
||||
/** Validate the still-uncommitted packet before either counter outcome. */
|
||||
export function validateAdvisorTransportMatching(
|
||||
ctx: AdvisorTransportMatchingContext,
|
||||
source: AdvisorTransportMatchingSource,
|
||||
): void {
|
||||
requireMatching(record(source) &&
|
||||
Object.keys(source).sort().join(',') === 'advisors,destination,event,group,origin,owner,phase,producer,sector,turn' &&
|
||||
identifier(source.event) && positive(source.turn) && source.turn === ctx.turn &&
|
||||
source.phase === 5 && source.phase === ctx.phase,
|
||||
'Transport matching lost its original current-turn public source.');
|
||||
const quote = quoteAdvisorTransportMatching(ctx, {
|
||||
producer: source.producer, 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 transport matching source.');
|
||||
}
|
||||
+3
-1
@@ -118,6 +118,7 @@ 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 { guildTransportQuote } from './transport-quote';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
|
||||
import { reinforcementsBattleForces } from './reinforcements-homeworld';
|
||||
import type { NativeReserveSelections } from './homeworld-native-reserves';
|
||||
@@ -4278,7 +4279,8 @@ 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) && botAdvisorMatchingAllowed(g,sourced) ? [sourced] : [];
|
||||
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) && !botArrivalBlock(g, sourced) && botAdvisorMatchingAllowed(g,sourced) &&
|
||||
(sourced.type!=='guildShip'||!g.advisorTransportMatchingDenied?.length||!guildTransportQuote(g,sourced).unavailableReasons.length) ? [sourced] : [];
|
||||
});
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const advancedDepartures = g.advancedAllySeparation?.territories;
|
||||
|
||||
+135
-7
@@ -611,6 +611,7 @@ 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 { quoteAdvisorTransportMatching, validateAdvisorTransportMatching, advisorTransportMatchingKey, AdvisorTransportMatchingError, type AdvisorTransportMatchingSource } from './advisor-transport-matching';
|
||||
import { spiritualAdvisorTargetBlock, spiritualAdvisorArrival, validateSpiritualAdvisorSource,
|
||||
spiritualAdvisorMatchingKey, SpiritualAdvisorError, type SpiritualAdvisorSource } from './spiritual-advisor';
|
||||
import {
|
||||
@@ -1074,6 +1075,7 @@ export type ResponseWindow = {
|
||||
advisorPreparationSource?: AdvisorPreparationSource;
|
||||
advisorMatchingSource?: AdvisorMatchingSource;
|
||||
advisorAmbassadorMatchingSource?: AdvisorAmbassadorMatchingSource;
|
||||
advisorTransportMatchingSource?: AdvisorTransportMatchingSource;
|
||||
spiritualAdvisorSource?: SpiritualAdvisorSource;
|
||||
fremenGrant?: FremenRevivalGrantSource;
|
||||
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
|
||||
@@ -1303,6 +1305,14 @@ export type Game = {
|
||||
signature: string;
|
||||
parentSignature: string;
|
||||
};
|
||||
advisorTransportMatchingDenied?: {owner:string;turn:number;key:string}[];
|
||||
pendingAdvisorTransportMatching?: {
|
||||
receipt: AdvisorTransportMatchingSource;
|
||||
opportunity: string;
|
||||
signature: string;
|
||||
intent: {kind:'guild';frame:GuildTransportOrder;fromReserves:boolean;secret?:string}
|
||||
| {kind:'junction';declaration:JunctionTransportDeclaration};
|
||||
};
|
||||
pendingSpiritualAdvisor?: {
|
||||
event: string;
|
||||
turn: number;
|
||||
@@ -14632,6 +14642,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
}
|
||||
}
|
||||
function movementCancellationQuote(g: Game, response: ResponseWindow) {
|
||||
if (response.advisorTransportMatchingSource) { currentAdvisorTransportMatching(g,response); return null; }
|
||||
if (response.advisorAmbassadorMatchingSource) { currentAdvisorAmbassadorMatching(g,response); return null; }
|
||||
if (response.spiritualAdvisorSource) { currentSpiritualAdvisorResponse(g,response); return null; }
|
||||
if (response.advisorMatchingSource) { currentAdvisorMatching(g, response); return null; }
|
||||
@@ -16592,6 +16603,7 @@ function groundMovementProbe(source: Game, actor: string): Game {
|
||||
game.pendingKarama = null; game.pendingTreacheryDiscard = null;
|
||||
game.pendingFremenMove = null; game.pendingIxMove = null; game.pendingChoamMove = null;
|
||||
game.pendingAmbassador = null; game.pendingTerrorEntry = null; game.pendingArrivalOverlap = null;
|
||||
delete game.pendingAdvisorTransportMatching;
|
||||
game.pendingArrivalReaction = []; game.pendingExchange = null;
|
||||
if (game.pendingShipment?.player === game.active && recoverableUnsupportedShipment(game.pendingShipment)) {
|
||||
const shipment = game.pendingShipment; game.pendingShipment = null;
|
||||
@@ -20589,6 +20601,113 @@ function completeSpiritualAdvisorArrival(g:Game){
|
||||
openTerritoryEntry(g,bg,order.territory,order.sector,order.amount,0,'advisor');
|
||||
}
|
||||
}
|
||||
type TransportMatchingIntent=NonNullable<Game['pendingAdvisorTransportMatching']>['intent'];
|
||||
function transportMatchingRule<T>(quote:()=>T):T{
|
||||
try{return quote();}catch(error){if(error instanceof AdvisorTransportMatchingError)throw new RuleError(error.message);throw error;}
|
||||
}
|
||||
function transportMatchingScopes(g:Game,owner:string){
|
||||
return {guild:JSON.stringify(['guild',g.turn,owner,guildTransportOpportunity(g,owner)]),
|
||||
secret:JSON.stringify(['guildSecret',g.turn,owner]),
|
||||
junction:JSON.stringify(['junction',g.turn,owner,g.junctionOffer?.recipient===owner?g.junctionOffer.event:null])};
|
||||
}
|
||||
function transportMatchingContext(g:Game,owner:string,opportunity:string){
|
||||
return {...g,worlds:g.homeworlds?.custody?(homeworldRule(()=>homeworldTable(g))??undefined):undefined,
|
||||
advisorTransportMatchingOpportunity:opportunity,
|
||||
advisorTransportMatchingDenied:g.advisorTransportMatchingDenied?.filter(r=>r.owner===owner&&r.turn===g.turn).map(r=>r.key)};
|
||||
}
|
||||
function quoteTransportMatching(g:Game,intent:TransportMatchingIntent){
|
||||
const owner=intent.kind==='guild'?intent.frame.player:intent.declaration.player;
|
||||
const scopes=transportMatchingScopes(g,owner);
|
||||
if(intent.kind==='guild'){
|
||||
if(intent.fromReserves)return null;
|
||||
const order=intent.frame,opportunity=intent.secret?scopes.secret:scopes.guild;
|
||||
const quote=transportMatchingRule(()=>quoteAdvisorTransportMatching(transportMatchingContext(g,owner,opportunity),
|
||||
{producer:'guild',owner,origin:order.origin,destination:order.to,sector:order.sector,
|
||||
advisors:order.advisors,group:Object.fromEntries(order.group)}));
|
||||
return quote?{quote,opportunity}:null;
|
||||
}
|
||||
const {quote:order,arrival,advisors}=quoteJunctionDeclaration(g,getPlayer(g,owner),intent.declaration);
|
||||
if(!arrival)return null;
|
||||
const group=Object.fromEntries(order.sources.map(source=>[source.key,
|
||||
source.before.normal+source.before.elite-source.after.normal-source.after.elite]));
|
||||
const origin=order.originKind==='homeworld'?Object.keys(group).sort()[0]:order.origin;
|
||||
const quote=transportMatchingRule(()=>quoteAdvisorTransportMatching(transportMatchingContext(g,owner,scopes.junction),
|
||||
{producer:'junction',owner,origin,destination:arrival.territory,sector:arrival.sector,advisors,group}));
|
||||
return quote?{quote,opportunity:scopes.junction}:null;
|
||||
}
|
||||
function openAdvisorTransportMatching(g:Game,intent:TransportMatchingIntent){
|
||||
const matching=quoteTransportMatching(g,intent);if(!matching)return false;
|
||||
requireRule(!g.response&&!g.decision&&!g.pendingAdvisorTransportMatching,
|
||||
'Finish the original incoming transport matching first.');
|
||||
const receipt={event:crypto.randomUUID(),...matching.quote};
|
||||
g.pendingAdvisorTransportMatching={receipt,intent,signature:JSON.stringify(intent),opportunity:matching.opportunity};
|
||||
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:[],advisorTransportMatchingSource:receipt};
|
||||
return true;
|
||||
}
|
||||
function transportMatchingShadow(g:Game,event:string){
|
||||
const shadow=structuredClone(g);
|
||||
const owned=(control:unknown)=>{
|
||||
const c=control as {response?:ResponseWindow|null;pendingKarama?:Game['pendingKarama']}|null|undefined;
|
||||
return c?.response?.advisorTransportMatchingSource?.event===event||
|
||||
(c?.pendingKarama?.use.kind==='cancel'&&c.pendingKarama.use.response.advisorTransportMatchingSource?.event===event);
|
||||
};
|
||||
if(owned(shadow.pendingExchange))shadow.pendingExchange=null;
|
||||
if(owned(shadow.pendingNullentropy?.resume))shadow.pendingNullentropy=null;
|
||||
if(owned(shadow.pendingRicheseGift?.resume))shadow.pendingRicheseGift=null;
|
||||
if(owned(shadow.pendingRichesePurchaseIncome?.resume))shadow.pendingRichesePurchaseIncome=null;
|
||||
const continuation=shadow.pendingTreacheryDiscard?.continuation;
|
||||
if(continuation&&'resume'in continuation&&owned(continuation.resume))shadow.pendingTreacheryDiscard=null;
|
||||
shadow.response=null;shadow.decision=null;shadow.pendingKarama=null;shadow.truthtrance=null;
|
||||
return shadow;
|
||||
}
|
||||
function currentAdvisorTransportMatching(g:Game,response:ResponseWindow){
|
||||
const parent=g.pendingAdvisorTransportMatching,source=response.advisorTransportMatchingSource;
|
||||
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.advisorAmbassadorMatchingSource&&
|
||||
!response.spiritualAdvisorSource&&!response.automaticAdvisorFlip&&!response.advisorPreparationSource&&
|
||||
JSON.stringify(parent.receipt)===JSON.stringify(source)&&parent.signature===JSON.stringify(parent.intent)&&
|
||||
source.producer===(parent.intent.kind==='guild'?'guild':'junction'),
|
||||
'The transport matching counter lost its original uncommitted declaration.');
|
||||
const quiet=transportMatchingShadow(g,source.event),owner=getPlayer(quiet,source.owner);
|
||||
if(parent.intent.kind==='guild')validateGuildTransportForCommit(quiet,owner,parent.intent.frame,
|
||||
parent.intent.fromReserves,parent.intent.secret);
|
||||
const matching=quoteTransportMatching(quiet,parent.intent);
|
||||
requireRule(matching&&matching.opportunity===parent.opportunity&&
|
||||
JSON.stringify(matching.quote)===JSON.stringify(Object.fromEntries(Object.entries(source).filter(([key])=>key!=='event'))),
|
||||
'The transport matching counter no longer owns its original packet or grant.');
|
||||
transportMatchingRule(()=>validateAdvisorTransportMatching(transportMatchingContext(quiet,source.owner,parent.opportunity),source));
|
||||
return parent;
|
||||
}
|
||||
function advisorTransportMatchingIntegrity(g:Game){
|
||||
if(!g.pendingAdvisorTransportMatching)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(r=>r.advisorTransportMatchingSource);
|
||||
requireRule(matching.length>0,'Restore the original transport matching response.');
|
||||
for(const response of matching)currentAdvisorTransportMatching(g,response);
|
||||
}
|
||||
function finishAdvisorTransportMatching(g:Game,response:ResponseWindow,canceled:boolean){
|
||||
const parent=currentAdvisorTransportMatching(g,response),source=parent.receipt;
|
||||
delete g.pendingAdvisorTransportMatching;
|
||||
if(canceled){
|
||||
const key=advisorTransportMatchingKey(source,parent.opportunity);
|
||||
if(!g.advisorTransportMatchingDenied?.some(r=>r.key===key))
|
||||
(g.advisorTransportMatchingDenied??=[]).push({owner:source.owner,turn:g.turn,key});
|
||||
log(g,`${getPlayer(g,source.owner).name}'s incoming transport matching in ${territory(source.destination).name} was prevented before payment or transfer. Choose another legal packet or destination; the original shipment opportunity remains unused.`,
|
||||
{faction:'beneGesserit',name:'Transport group matching prevented'});
|
||||
}else if(parent.intent.kind==='guild'){
|
||||
const intent=parent.intent,owner=getPlayer(g,source.owner);
|
||||
if(intent.secret)commitGuildTransport(g,owner,intent.frame,intent.fromReserves,intent.secret);
|
||||
else openGuildTransportPermission(g,owner,intent.frame,intent.fromReserves);
|
||||
}else commitJunctionTransport(g,getPlayer(g,source.owner),parent.intent.declaration);
|
||||
}
|
||||
function ambassadorMatchingRule<T>(quote:()=>T):T{
|
||||
try{return quote();}catch(error){if(error instanceof AdvisorAmbassadorMatchingError)throw new RuleError(error.message);throw error;}
|
||||
}
|
||||
@@ -23186,7 +23305,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.pendingAdvisorAmbassadorMatching && !g.pendingSpiritualAdvisor,
|
||||
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation && !g.pendingAdvisorMatching && !g.pendingAdvisorAmbassadorMatching && !g.pendingAdvisorTransportMatching && !g.pendingSpiritualAdvisor,
|
||||
'Resolve mandatory advisor conversions before checking victory.');
|
||||
quote ??= currentVictoryQuote(g);
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
@@ -28978,7 +29097,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
);
|
||||
} else if (response.kind === 'advisorFlip') {
|
||||
const bg = getPlayer(g, response.owner);
|
||||
if (response.advisorAmbassadorMatchingSource) finishAdvisorAmbassadorMatching(g,response,canceled);
|
||||
if (response.advisorTransportMatchingSource) finishAdvisorTransportMatching(g,response,canceled);
|
||||
else 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') {
|
||||
@@ -29555,7 +29675,7 @@ function performJunctionTransport(g: Game, p: Player, action: Action) {
|
||||
const quote = quoteJunctionDeclaration(g, p, declaration, action.allyPayment === undefined);
|
||||
declaration.allyPayment = action.allyPayment === undefined ? quote.allyPayment :
|
||||
contribution(g, p, quote.quote.cost, action.allyPayment);
|
||||
commitJunctionTransport(g, p, declaration);
|
||||
if(!openAdvisorTransportMatching(g,{kind:'junction',declaration}))commitJunctionTransport(g,p,declaration);
|
||||
}
|
||||
function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}) {
|
||||
requireRule(g.homeworlds?.custody, 'Homeworld shipment requires saved physical custody.');
|
||||
@@ -30350,14 +30470,15 @@ function nativeGuildTransportPermission(g: Game, p: Player): boolean {
|
||||
return !!guild && p.ally === guild.id && guild.ally === p.id;
|
||||
}
|
||||
function validateGuildTransportForCommit(
|
||||
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean,
|
||||
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean, guildSecretEvent?:string,
|
||||
) {
|
||||
const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame;
|
||||
requireRule(g.status === 'playing' && g.phase === 5 && frame.turn === g.turn &&
|
||||
frame.player === p.id && g.active === p.id && shipmentAvailable(g, p),
|
||||
'This Guild transport no longer belongs to an unused shipment opportunity.');
|
||||
requireRule(nativeGuildTransportPermission(g, p),
|
||||
requireRule(guildSecretEvent?validateGuildSecretDeclaration(g,p.id,guildSecretEvent):nativeGuildTransportPermission(g,p),
|
||||
'Native Guild transport requires the Guild or its reciprocal ally.');
|
||||
requireRule(!guildSecretEvent||!fromReserves,'Use the reserve shipment selector for Guild Secret Ally reinforcements.');
|
||||
if (fromReserves) {
|
||||
requireRule(p.faction === 'fremen' && origin === 'reserves' &&
|
||||
group.length === 0 && Object.keys(eliteGroup).length === 0,
|
||||
@@ -30375,7 +30496,7 @@ function validateGuildTransportForCommit(
|
||||
'The original Guild transport force group changed or entered the storm.');
|
||||
}
|
||||
requireRule((fromReserves || to === 'reserves' || to !== origin) &&
|
||||
(to !== 'reserves' || p.faction === 'guild') &&
|
||||
(to !== 'reserves' || p.faction === 'guild' || !!guildSecretEvent) &&
|
||||
(to !== MOBILE_STRONGHOLD || p.faction === 'ixians') &&
|
||||
advisors === (to !== 'reserves' && arrivalAsAdvisor(g, p, to)),
|
||||
'The original Guild transport destination or advisor stance is unavailable.');
|
||||
@@ -30384,7 +30505,7 @@ function validateGuildTransportForCommit(
|
||||
!g.players.some(other => other.id !== p.id && at(other, to)),
|
||||
'New advisors cannot become fighters this turn.');
|
||||
if (to !== 'reserves') allowedEntry(g, p, to, sector, false, advisors);
|
||||
const halfRate = g.karamaShipping?.player === p.id ||
|
||||
const halfRate = !!guildSecretEvent || g.karamaShipping?.player === p.id ||
|
||||
!(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id);
|
||||
requireRule(cost === guildShipmentCost(
|
||||
to === 'reserves' ? 'reserves' : territory(to).type, amount, halfRate),
|
||||
@@ -32663,6 +32784,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
treacheryDiscardIntegrity(state);
|
||||
shipmentPromiseIntegrity(state);
|
||||
saphoMovementIntegrity(state);
|
||||
advisorTransportMatchingIntegrity(state);
|
||||
advisorAmbassadorMatchingIntegrity(state);
|
||||
spiritualAdvisorIntegrity(state);
|
||||
ambassadorRelocationIntegrity(state);
|
||||
@@ -33075,6 +33197,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
treacheryDiscardIntegrity(state);
|
||||
shipmentPromiseIntegrity(state);
|
||||
saphoMovementIntegrity(state);
|
||||
advisorTransportMatchingIntegrity(state);
|
||||
advisorAmbassadorMatchingIntegrity(state);
|
||||
spiritualAdvisorIntegrity(state);
|
||||
ambassadorRelocationIntegrity(state);
|
||||
@@ -36583,6 +36706,7 @@ function applyActionInner(
|
||||
turn: g.turn, player: id, origin, group, eliteGroup, to, sector,
|
||||
amount: n, elite, cost, allyPayment, advisors,
|
||||
};
|
||||
if(execution==='live'&&openAdvisorTransportMatching(g,{kind:'guild',frame,fromReserves,...(guildSecretEvent?{secret:guildSecretEvent}:{})}))return g;
|
||||
if (!guildSecretEvent) {
|
||||
openGuildTransportPermission(g, p, frame, fromReserves);
|
||||
return g;
|
||||
@@ -37162,6 +37286,7 @@ export function viewGame(state: Game, id: string) {
|
||||
treacheryDiscardIntegrity(state);
|
||||
shipmentPromiseIntegrity(state);
|
||||
saphoMovementIntegrity(state);
|
||||
advisorTransportMatchingIntegrity(state);
|
||||
advisorAmbassadorMatchingIntegrity(state);
|
||||
spiritualAdvisorIntegrity(state);
|
||||
ambassadorRelocationIntegrity(state);
|
||||
@@ -37633,6 +37758,9 @@ export function viewGame(state: Game, id: string) {
|
||||
homeworldRevivalDeployment: projectedHomeworldRevivalReturn(g, id),
|
||||
caladanReinforcement: projectedHomeworldVictoryReturn(g, id),
|
||||
homeworldRevivalBlocks: homeworldRevivalChoiceBlocks(g, me),
|
||||
...(g.advisorTransportMatchingDenied?.some(r=>r.owner===id&&r.turn===g.turn)?{
|
||||
advisorTransportMatchingDenied:g.advisorTransportMatchingDenied.filter(r=>r.owner===id&&r.turn===g.turn).map(r=>r.key),
|
||||
advisorTransportMatchingScopes:transportMatchingScopes(g,id)}:{}),
|
||||
homeworlds: g.homeworlds
|
||||
? { worlds: homeworldRule(() => homeworldTable(g)) }
|
||||
: null,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type HomeworldShipmentSources,
|
||||
} from './homeworld-shipment-options';
|
||||
import { liveShipmentPromises, matchesShipment } from './shipment-promises';
|
||||
import { quoteAdvisorTransportMatching, AdvisorTransportMatchingError } from './advisor-transport-matching';
|
||||
|
||||
export function junctionTransportOrigins(view: GameView) {
|
||||
const me = view.players.find((player) => player.id === view.me);
|
||||
@@ -219,6 +220,16 @@ export function junctionTransportChoice(
|
||||
arrival.territory,
|
||||
quote.originKind === 'arrakis' ? quote.origin : undefined,
|
||||
);
|
||||
if(view.advisorTransportMatchingDenied?.length){
|
||||
const group=Object.fromEntries(quote.sources.map(source=>[source.key,
|
||||
source.before.normal+source.before.elite-source.after.normal-source.after.elite]));
|
||||
try{
|
||||
quoteAdvisorTransportMatching({...view,worlds:view.homeworlds?.worlds??undefined,
|
||||
advisorTransportMatchingOpportunity:view.advisorTransportMatchingScopes?.junction},
|
||||
{producer:'junction',owner:me.id,origin:quote.originKind==='homeworld'?Object.keys(group).sort()[0]:quote.origin,
|
||||
destination:arrival.territory,sector:arrival.sector,advisors:result.advisors,group});
|
||||
}catch(error){if(!(error instanceof AdvisorTransportMatchingError))throw error;result.blocked=error.message;}
|
||||
}
|
||||
const entryBlock = territoryEntryBlock(
|
||||
view.players,
|
||||
me.id,
|
||||
|
||||
@@ -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.advisorTransportMatchingSource
|
||||
? {matching:response.advisorTransportMatchingSource,pending:g.pendingAdvisorTransportMatching?.receipt??null}
|
||||
: response.advisorAmbassadorMatchingSource
|
||||
? {matching:response.advisorAmbassadorMatchingSource,pending:g.pendingAdvisorAmbassadorMatching?.receipt??null}
|
||||
: response.advisorMatchingSource
|
||||
|
||||
@@ -308,6 +308,7 @@ export function quoteMovementCancellation(
|
||||
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.advisorTransportMatchingSource) return null;
|
||||
if (response.kind === 'advisorFlip' && response.spiritualAdvisorSource) return null;
|
||||
if (
|
||||
![
|
||||
|
||||
+34
-5
@@ -4316,6 +4316,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'Fresh Guild cross-transport and Junction board arrivals also offer incoming matching before their original permission, price, payment or transfer. Junction Homeworld groups provisionally enter fighter-side. Prevention leaves the original shipment and grant unused while that same packet here stays unavailable for this producer opportunity; choose another legal packet or destination. Junction permission and tariff remain immune. Secret Ally keeps its actual Nexus until the shipment commits.',
|
||||
'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.',
|
||||
@@ -4334,8 +4335,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Implementation",
|
||||
"status": "Partial",
|
||||
"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.",
|
||||
"detail": "Fresh Guild/native Secret Ally and Junction board/Homeworld arrival declarations now own precommit matching before their original permission/rate/settlement suffix. Original scope, funding and order stay private; prevention retains unused shipment and grant. 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-transport-matching.ts",
|
||||
"game/transport-quote.ts",
|
||||
"game/junction-transport-options.ts",
|
||||
"tests/advisor-transport-matching.test.ts",
|
||||
"tests/advisor-transport-matching-engine.test.ts",
|
||||
"tests/fixture-advisor-transport-matching.ts",
|
||||
"tests/fixture-advisor-secret-transport-matching.ts",
|
||||
"game/advisor-ambassador-matching.ts",
|
||||
"tests/advisor-ambassador-matching.test.ts",
|
||||
"tests/advisor-ambassador-matching-engine.test.ts",
|
||||
@@ -4359,8 +4367,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Player controls",
|
||||
"status": "Partial",
|
||||
"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.",
|
||||
"detail": "Transport and Junction quotes share scoped packet denials and persistent before-payment matching guidance; twenty native GameTable SSR journeys and two controlled Truthtrance cases pass. 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": [
|
||||
"game/advisor-transport-matching.ts",
|
||||
"game/transport-quote.ts",
|
||||
"game/junction-transport-options.ts",
|
||||
"tests/advisor-transport-matching.test.ts",
|
||||
"tests/advisor-transport-matching-engine.test.ts",
|
||||
"tests/fixture-advisor-transport-matching.ts",
|
||||
"tests/fixture-advisor-secret-transport-matching.ts",
|
||||
"components/ambassador-movement.tsx",
|
||||
"docs/SPIRITUAL_ADVISORS.md",
|
||||
"docs/ADVISOR_MATCHING.md",
|
||||
@@ -4373,8 +4388,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "AI",
|
||||
"status": "Partial",
|
||||
"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.",
|
||||
"detail": "Transport policies filter prevented packets through the same quotes; sixteen native transport paths across existing profiles accept32 response/replacement actions. No strategic refinement or difficulty calibration. 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-transport-matching.ts",
|
||||
"game/transport-quote.ts",
|
||||
"game/junction-transport-options.ts",
|
||||
"tests/advisor-transport-matching.test.ts",
|
||||
"tests/advisor-transport-matching-engine.test.ts",
|
||||
"tests/fixture-advisor-transport-matching.ts",
|
||||
"tests/fixture-advisor-secret-transport-matching.ts",
|
||||
"game/advisor-ambassador-matching.ts",
|
||||
"tests/advisor-ambassador-matching.test.ts",
|
||||
"tests/advisor-ambassador-matching-engine.test.ts",
|
||||
@@ -4410,7 +4432,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Documentation",
|
||||
"status": "Partial",
|
||||
"detail": "Supplied physical28 redirects retained non-Polar fighters and starts Spiritual Advisors advisor-side, with no alone-conversion Polar exemption. Distinct physical17 counters, provisional Polar matching/restored-choice remedy, continuously-alone duration and the legacy source cutover are explicit. Wider combinations remain open.",
|
||||
"detail": "Guild/Junction/World/Secret matching records provisional response priority, immune Junction tariff, private scope and fresh-declaration cutover. Source and native/control evidence remain separate from broader mode acceptance. Supplied physical28 redirects retained non-Polar fighters and starts Spiritual Advisors advisor-side, with no alone-conversion Polar exemption. Distinct physical17 counters, provisional Polar matching/restored-choice remedy, continuously-alone duration and the legacy source cutover are explicit. Wider combinations remain open.",
|
||||
"evidence": [
|
||||
"docs/SPIRITUAL_ADVISORS.md",
|
||||
"docs/ADVISOR_MATCHING.md",
|
||||
@@ -4423,8 +4445,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Verification",
|
||||
"status": "Partial",
|
||||
"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.",
|
||||
"detail": "The fresh transport batch passes25 files437/437, types/lint/build, twenty native Guild/Junction/World/Secret GameTable SSR journeys, two controlled Truthtrance cases and sixteen existing-profile paths. Genuine85/82/36/74-action sources earn original grants; permission and actual-discount responses remain separate, and Secret spends Nexus once at commitment. Review repairs the owned Truthtrance shadow and price consistency; smoke/type feedback is repaired. Earlier results below retain their original scope. 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-transport-matching.ts",
|
||||
"game/transport-quote.ts",
|
||||
"game/junction-transport-options.ts",
|
||||
"tests/advisor-transport-matching.test.ts",
|
||||
"tests/advisor-transport-matching-engine.test.ts",
|
||||
"tests/fixture-advisor-transport-matching.ts",
|
||||
"tests/fixture-advisor-secret-transport-matching.ts",
|
||||
"game/advisor-ambassador-matching.ts",
|
||||
"tests/advisor-ambassador-matching.test.ts",
|
||||
"tests/advisor-ambassador-matching-engine.test.ts",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Action, GameView } from './engine';
|
||||
import { splitLocation, validGameLocation } from './board';
|
||||
import { botEntryAllowed, guildTransportCost } from './bot-mobility';
|
||||
import { shipmentPaymentBounds } from './shipment-price';
|
||||
import { quoteAdvisorTransportMatching, AdvisorTransportMatchingError } from './advisor-transport-matching';
|
||||
|
||||
/** Preview the submitted Guild transport using only this seat's entitled view.
|
||||
* The authoritative action still revalidates the selection when it arrives.
|
||||
@@ -23,6 +24,7 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
const fromReserves = action.from === 'reserves';
|
||||
let total = 0;
|
||||
let origin: string | undefined;
|
||||
const matchingGroup:Record<string,number>={};
|
||||
let validForces = true;
|
||||
const unitSelection = (
|
||||
value: unknown,
|
||||
@@ -101,6 +103,7 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
: action.elite,
|
||||
);
|
||||
if (typeof count !== 'number' || count <= 0) continue;
|
||||
matchingGroup[key]=count;
|
||||
if (origin && origin !== source.territory) {
|
||||
validForces = false;
|
||||
unavailableReasons.push('Select forces from one territory.');
|
||||
@@ -188,6 +191,14 @@ export function guildTransportQuote(g: GameView, action: Action) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if(g.advisorTransportMatchingDenied?.length&&origin&&!fromReserves){
|
||||
try{
|
||||
quoteAdvisorTransportMatching({...g,worlds:g.homeworlds?.worlds??undefined,
|
||||
advisorTransportMatchingOpportunity:g.advisorTransportMatchingScopes?.[nexus?'secret':'guild']},
|
||||
{producer:'guild',owner:p.id,origin,destination:to,sector:Number(action.sector??0),
|
||||
advisors:!!p.advisors?.[to],group:matchingGroup});
|
||||
}catch(error){if(!(error instanceof AdvisorTransportMatchingError))throw error;unavailableReasons.push(error.message);}
|
||||
}
|
||||
return {
|
||||
physicalForces: validForces && total > 0 ? total : undefined,
|
||||
quote: validShare
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Action } from '../game/engine';
|
||||
import { isAdvisor } from '../game/advisors';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { guildTransportQuote } from '../game/transport-quote';
|
||||
import { junctionTransportChoice } from '../game/junction-transport-options';
|
||||
import { createAdvisorTransportMatchingFixture as fixture,actAdvisorTransportMatching as act,
|
||||
finishAdvisorTransportMatching as finish,advisorTransportMatchingPlayer as player,
|
||||
advisorTransportMatchingInventory as inventory } from './fixture-advisor-transport-matching';
|
||||
import { createAdvisorSecretTransportMatchingFixture as secretFixture } from './fixture-advisor-secret-transport-matching';
|
||||
import { placeFixtureHand } from './fixture-hand';
|
||||
|
||||
const physical=(g:Game)=>g.players.map(p=>({id:p.id,forces:p.forces,advisors:p.advisors,
|
||||
reserves:p.reserves,tanks:p.tanks,spice:p.spice,moved:p.moved,shipped:p.shipped}));
|
||||
function layer(g:Game){const r=g.response;return r?JSON.stringify([r.kind,r.advisorTransportMatchingSource?.event,r.guildTransportEvent,r.guildRateEvent]):null;}
|
||||
function allow(g:Game){const original=layer(g);assert.ok(original);
|
||||
for(let count=0;layer(g)===original&&count<30;count++){
|
||||
const actor=g.players.find(p=>!g.response!.passed.includes(p.id));assert.ok(actor);g=act(g,actor.id,{type:'passResponse'});
|
||||
}
|
||||
assert.notEqual(layer(g),original);return g;
|
||||
}
|
||||
function complete(g:Game){
|
||||
for(let count=0;g.response&&(g.response.advisorTransportMatchingSource||
|
||||
['guildTransportPermission','guildRate'].includes(g.response.kind))&&count<5;count++)g=allow(g);
|
||||
return finish(g);
|
||||
}
|
||||
function reject(g:Game,actor:string,action:Action){const before=structuredClone(g);assert.throws(()=>applyAction(g,actor,action));assert.deepEqual(g,before);}
|
||||
const scenarios=[
|
||||
['guild-fighters',()=>fixture()],['guild-advisors',()=>fixture({direction:'advisorsToFighters'})],
|
||||
['junction-fighters',()=>fixture({kind:'junctionBoard'})],['junction-advisors',()=>fixture({kind:'junctionBoard',direction:'advisorsToFighters'})],
|
||||
['junction-world',()=>fixture({kind:'junctionWorld'})],
|
||||
['secret-fighters',()=>secretFixture()],['secret-advisors',()=>secretFixture({direction:'advisorsToFighters'})],
|
||||
] as const;
|
||||
for(const [name,create]of scenarios){
|
||||
void test(`native ${name} matching waits before payment or transfer and resumes the original transport once`,()=>{
|
||||
const f=create(),pending=act(f.game,f.bg,f.action),source=pending.response?.advisorTransportMatchingSource;assert.ok(source);
|
||||
assert.deepEqual(physical(pending),physical(f.game));assert.deepEqual(physical(normalizeAutomaticGame(pending)),physical(f.game));
|
||||
assert.ok(!pending.pendingGuildTransport);assert.equal(player(pending,f.bg).shipped,false);
|
||||
assert.deepEqual(pending.junctionOffer,f.game.junctionOffer);
|
||||
const next=allow(pending);assert.equal(next.pendingAdvisorTransportMatching,undefined);
|
||||
if(name.startsWith('guild')){assert.equal(next.response?.kind,'guildTransportPermission');assert.deepEqual(physical(next),physical(f.game));}
|
||||
const done=complete(next),owner=player(done,f.bg);
|
||||
assert.equal(owner.forces[f.destinationLocation],player(f.game,f.bg).forces[f.destinationLocation]+1);
|
||||
if(f.sourceLocation.startsWith('homeworld:'))assert.equal(owner.reserves,player(f.game,f.bg).reserves-1);
|
||||
else assert.equal(owner.forces[f.sourceLocation]??0,player(f.game,f.bg).forces[f.sourceLocation]-1);
|
||||
assert.equal(owner.spice,player(f.game,f.bg).spice-f.cost);assert.equal(owner.moved,player(f.game,f.bg).moved);assert.equal(owner.shipped,true);
|
||||
assert.equal(isAdvisor(owner,source.destination),source.advisors);assert.equal(done.pendingAdvisorTransportMatching,undefined);
|
||||
assert.ok(player(done,f.holder).hand.some(c=>c.id===f.karama));assert.deepEqual(inventory(done),f.originalInventory);
|
||||
if(name.startsWith('secret'))assert.equal(done.nexusGuildSecretHistory?.length,1);
|
||||
});
|
||||
void test(`native ${name} prevention retains original entitlement and blocks a repeated packet while allowing a real replacement`,()=>{
|
||||
const f=create(),pending=act(f.game,f.bg,f.action),stopped=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
assert.deepEqual(physical(stopped),physical(f.game));assert.equal(stopped.pendingAdvisorTransportMatching,undefined);
|
||||
assert.deepEqual(stopped.junctionOffer,f.game.junctionOffer);assert.equal(stopped.nexusGuildSecretHistory?.length??0,0);
|
||||
if(name.startsWith('secret'))assert.equal(stopped.nexusCards?.cards?.hands[f.bg],'guild');
|
||||
reject(stopped,f.bg,f.action);
|
||||
const view=viewGame(stopped,f.bg);assert.equal(view.advisorTransportMatchingDenied?.length,1);
|
||||
assert.equal(viewGame(stopped,f.holder).advisorTransportMatchingDenied,undefined);
|
||||
if(f.action.type==='guildShip')assert.ok(guildTransportQuote(view,f.action).unavailableReasons.some(reason=>/prevented/.test(reason)));
|
||||
else assert.match(junctionTransportChoice(view,String(f.action.destination),f.action.sources as Record<string,{normal:number;elite:number}>).blocked??'',/prevented/);
|
||||
const done=complete(act(stopped,f.bg,f.noMatch));
|
||||
const replacementLocation=f.noMatch.type==='guildShip'?`${String(f.noMatch.territory)}:${Number(f.noMatch.sector)}`:String(f.noMatch.destination);
|
||||
assert.equal(player(done,f.bg).forces[replacementLocation],1);
|
||||
assert.equal(player(done,f.bg).spice,player(f.game,f.bg).spice-f.cost);assert.equal(player(done,f.bg).shipped,true);
|
||||
assert.equal(done.discard.filter(c=>c.id===f.karama).length,1);assert.deepEqual(inventory(done),f.originalInventory);
|
||||
if(name.startsWith('secret'))assert.equal(done.nexusGuildSecretHistory?.length,1);
|
||||
});
|
||||
void test(`native ${name} counter exposes only ten physical source fields`,()=>{
|
||||
const f=create(),pending=act(f.game,f.bg,f.action),view=viewGame(pending,f.holder),source=view.response?.advisorTransportMatchingSource;assert.ok(source);
|
||||
assert.deepEqual(Object.keys(source).sort(),['advisors','destination','event','group','origin','owner','phase','producer','sector','turn']);
|
||||
assert.equal('pendingAdvisorTransportMatching'in view,false);assert.equal(JSON.stringify(source).includes('opportunity'),false);
|
||||
});
|
||||
}
|
||||
for(const [name,create]of scenarios.filter(([name])=>!name.endsWith('advisors'))){
|
||||
void test(`native ${name} admits a different actual matching packet in the original unused opportunity`,()=>{
|
||||
const f=create(),stopped=act(act(f.game,f.bg,f.action),f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
const done=complete(act(stopped,f.bg,f.largerPacket));
|
||||
assert.equal(player(done,f.bg).forces['arrakeen:10'],3);assert.equal(player(done,f.bg).shipped,true);
|
||||
assert.equal(done.discard.filter(c=>c.id===f.karama).length,1);assert.deepEqual(inventory(done),f.originalInventory);
|
||||
});
|
||||
for(const profile of DIFFICULTIES)void test(`${profile} handles native ${name} matching and a legal replacement`,()=>{
|
||||
const f=create(),pending=act(f.game,f.bg,f.action),view=viewGame(pending,f.holder);view.players.find(p=>p.id===f.holder)!.bot=profile;
|
||||
const answer=botActions(view)[0];assert.ok(answer);assert.doesNotThrow(()=>applyAction(pending,f.holder,answer));
|
||||
const stopped=act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}),replacementView=viewGame(stopped,f.bg);
|
||||
replacementView.players.find(p=>p.id===f.bg)!.bot=profile;
|
||||
const replacement=botActions(replacementView)[0];assert.ok(replacement);assert.doesNotThrow(()=>applyAction(stopped,f.bg,replacement));
|
||||
});
|
||||
}
|
||||
void test('allowed matching retains the separate original Guild permission counter',()=>{
|
||||
const f=fixture(),permission=allow(act(f.game,f.bg,f.action));assert.equal(permission.response?.kind,'guildTransportPermission');
|
||||
const denied=act(permission,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
assert.deepEqual(physical(denied),physical(f.game));assert.equal(denied.pendingAdvisorTransportMatching,undefined);
|
||||
assert.equal(viewGame(denied,f.bg).guildTransportCanceled,true);
|
||||
});
|
||||
void test('allowed matching and permission retain original full-rate cancellation when funding cannot complete',()=>{
|
||||
const f=fixture({direction:'advisorsToFighters'}),rate=allow(allow(act(f.game,f.bg,f.action)));assert.equal(rate.response?.kind,'guildRate');
|
||||
const denied=act(rate,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
assert.deepEqual(physical(denied),physical(f.game));assert.equal(denied.pendingAdvisorTransportMatching,undefined);
|
||||
assert.equal(denied.guildRateBlocked?.player,f.bg);assert.equal(player(denied,f.bg).shipped,false);
|
||||
});
|
||||
void test('private original intent and opportunity mismatches fail before cancellation cost',()=>{
|
||||
const f=fixture(),pending=act(f.game,f.bg,f.action);
|
||||
for(const field of ['signature','opportunity'] as const){const changed=structuredClone(pending);changed.pendingAdvisorTransportMatching![field]+='changed';
|
||||
reject(changed,f.holder,{type:'card',card:f.karama,mode:'cancel'});assert.ok(player(changed,f.holder).hand.some(c=>c.id===f.karama));}
|
||||
});
|
||||
for(const [name,create]of [['junction',()=>fixture({kind:'junctionWorld'})],['secret',()=>secretFixture()]] as const)
|
||||
void test(`controlled ${name} Truthtrance overlay preserves the already-declared matching response and real priority`,()=>{
|
||||
const f=create(),staged=structuredClone(f.game),truth=staged.deck.find(c=>c.effect==='truthtrance');assert.ok(truth);
|
||||
// Controlled card custody only: relocate an original physical card, preserving
|
||||
// the earned native grant/forces/alliance. This is not an earned card draw.
|
||||
const owner=player(staged,f.bg);assert.ok(owner.hand.length<4);
|
||||
placeFixtureHand(staged,staged.players.findIndex(p=>p.id===f.bg),[...owner.hand,truth]);
|
||||
const pending=act(staged,f.bg,f.action),receipt=structuredClone(pending.response);
|
||||
let g=act(pending,f.bg,{type:'card',card:truth.id});assert.ok(g.truthtrance);
|
||||
assert.deepEqual(viewGame(g,f.bg).response,viewGame(pending,f.bg).response);
|
||||
reject(g,f.holder,{type:'passResponse'});
|
||||
for(let count=0;g.truthtrance?.stage==='priority'&&count<10;count++){
|
||||
const actor=g.players.find(p=>!g.truthtrance!.passed.includes(p.id));assert.ok(actor);g=act(g,actor.id,{type:'truthPass'});
|
||||
}
|
||||
g=act(g,f.bg,{type:'truthAsk',question:{kind:'fact',target:f.holder,fact:{kind:'hand',name:'Karama'}}});
|
||||
g=act(g,f.holder,{type:'truthAnswer',answer:'yes'});assert.ok(!g.truthtrance);assert.deepEqual(g.response,receipt);
|
||||
const stopped=act(g,f.holder,{type:'card',card:f.karama,mode:'cancel'});
|
||||
assert.deepEqual(physical(stopped),physical(f.game));assert.equal(stopped.pendingAdvisorTransportMatching,undefined);
|
||||
assert.deepEqual(inventory(stopped),f.originalInventory);
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
advisorTransportMatchingKey,
|
||||
AdvisorTransportMatchingError,
|
||||
quoteAdvisorTransportMatching,
|
||||
validateAdvisorTransportMatching,
|
||||
type AdvisorTransportMatchingContext,
|
||||
type AdvisorTransportMatchingIntent,
|
||||
type AdvisorTransportMatchingSource,
|
||||
} from '../game/advisor-transport-matching';
|
||||
|
||||
function context(): AdvisorTransportMatchingContext {
|
||||
return {
|
||||
advanced: true, status: 'playing', turn: 4, phase: 5, active: 'b',
|
||||
advisorTransportMatchingOpportunity: 'private-original-transport-opportunity',
|
||||
players: [
|
||||
{
|
||||
id: 'b', faction: 'beneGesserit', shipped: false,
|
||||
forces: {
|
||||
'hagga_basin:12': 3, 'hagga_basin:13': 2, 'imperial_basin:9': 2,
|
||||
'carthag:11': 2, 'arrakeen:10': 1,
|
||||
},
|
||||
advisors: { imperial_basin: { lockedTurn: 4 }, carthag: {} },
|
||||
},
|
||||
{ id: 'a', faction: 'atreides', forces: { 'imperial_basin:9': 1 } },
|
||||
{ id: 'g', faction: 'guild', forces: {} },
|
||||
],
|
||||
worlds: [
|
||||
{ id: 'homeworld:atreides', forces: { b: { normal: 2, elite: 0 }, a: { normal: 8, elite: 0 } } },
|
||||
{ id: 'homeworld:beneGesserit', forces: { b: { normal: 4, elite: 0 } } },
|
||||
{ id: 'homeworld:guild', forces: { b: { normal: 1, elite: 0 }, g: { normal: 10, elite: 0 } } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function intent(fields: Partial<AdvisorTransportMatchingIntent> = {}): AdvisorTransportMatchingIntent {
|
||||
return {
|
||||
producer: 'guild', owner: 'b', origin: 'hagga_basin', destination: 'imperial_basin',
|
||||
sector: 10, advisors: true, group: { 'hagga_basin:12': 1, 'hagga_basin:13': 1 }, ...fields,
|
||||
};
|
||||
}
|
||||
|
||||
function worldIntent(fields: Partial<AdvisorTransportMatchingIntent> = {}): AdvisorTransportMatchingIntent {
|
||||
return intent({
|
||||
producer: 'junction', origin: 'homeworld:atreides',
|
||||
group: { 'homeworld:beneGesserit': 2, 'homeworld:atreides': 1 }, ...fields,
|
||||
});
|
||||
}
|
||||
|
||||
function source(ctx = context(), incoming = intent()): AdvisorTransportMatchingSource {
|
||||
const quote = quoteAdvisorTransportMatching(ctx, incoming);
|
||||
assert.ok(quote);
|
||||
return { event: 'matching-counter', ...quote };
|
||||
}
|
||||
|
||||
void test('Guild and Junction board matching quote the selected incoming BG group in both directions before transport', () => {
|
||||
for (const producer of ['guild', 'junction'] as const) {
|
||||
const ctx = context();
|
||||
const incoming = intent({ producer });
|
||||
const before = structuredClone({ ctx, incoming });
|
||||
const quote = quoteAdvisorTransportMatching(ctx, incoming);
|
||||
assert.deepEqual(quote, {
|
||||
producer, owner: 'b', turn: 4, phase: 5, origin: 'hagga_basin',
|
||||
destination: 'imperial_basin', sector: 10, advisors: true,
|
||||
group: { 'hagga_basin:12': 1, 'hagga_basin:13': 1 },
|
||||
});
|
||||
assert.deepEqual({ ctx, incoming }, before);
|
||||
assert.notEqual(quote!.group, incoming.group);
|
||||
quote!.group['hagga_basin:12'] = 99;
|
||||
assert.equal(incoming.group['hagga_basin:12'], 1);
|
||||
const reverse = intent({
|
||||
producer, origin: 'carthag', destination: 'arrakeen', sector: 10, advisors: false,
|
||||
group: { 'carthag:11': 1 },
|
||||
});
|
||||
assert.equal(quoteAdvisorTransportMatching(ctx, reverse)?.advisors, false);
|
||||
assert.doesNotThrow(() => validateAdvisorTransportMatching(ctx, source(ctx, reverse)));
|
||||
assert.deepEqual(ctx, before.ctx);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Junction world matching uses owned normal physical counters and the first sorted source key without publishing the native scope', () => {
|
||||
const ctx = context();
|
||||
const incoming = worldIntent();
|
||||
const before = structuredClone({ ctx, incoming });
|
||||
const original = source(ctx, incoming);
|
||||
assert.equal(original.origin, 'homeworld:atreides');
|
||||
assert.equal(original.advisors, true);
|
||||
assert.deepEqual(original.group, { 'homeworld:beneGesserit': 2, 'homeworld:atreides': 1 });
|
||||
assert.doesNotThrow(() => validateAdvisorTransportMatching(ctx, original));
|
||||
assert.deepEqual(Object.keys(original).sort(), [
|
||||
'advisors', 'destination', 'event', 'group', 'origin', 'owner', 'phase', 'producer', 'sector', 'turn',
|
||||
]);
|
||||
assert.ok(!JSON.stringify(original).includes(ctx.advisorTransportMatchingOpportunity!));
|
||||
assert.deepEqual({ ctx, incoming }, before);
|
||||
assert.throws(() => quoteAdvisorTransportMatching(ctx, worldIntent({ origin: 'homeworld:beneGesserit' })), AdvisorTransportMatchingError);
|
||||
assert.throws(() => quoteAdvisorTransportMatching(ctx, worldIntent({ producer: 'guild' })), AdvisorTransportMatchingError);
|
||||
assert.equal(quoteAdvisorTransportMatching(ctx, worldIntent({
|
||||
destination: 'arrakeen', sector: 10, advisors: false,
|
||||
})), null);
|
||||
});
|
||||
|
||||
void test('Basic, inactive or spent native shipments, same-type groups, empty territories and return destinations need no matching quote', () => {
|
||||
for (const patch of [
|
||||
{ advanced: false }, { status: 'setup' }, { phase: 4 }, { phase: 6 }, { phase: 8 }, { active: 'a' },
|
||||
]) {
|
||||
assert.equal(quoteAdvisorTransportMatching({ ...context(), ...patch }, intent()), null);
|
||||
}
|
||||
const spent = context();
|
||||
spent.players[0].shipped = true;
|
||||
assert.equal(quoteAdvisorTransportMatching(spent, intent()), null);
|
||||
const foreign = context();
|
||||
foreign.players[0].faction = 'atreides';
|
||||
assert.equal(quoteAdvisorTransportMatching(foreign, intent()), null);
|
||||
assert.equal(quoteAdvisorTransportMatching(context(), intent({ owner: 'missing' })), null);
|
||||
assert.equal(quoteAdvisorTransportMatching(context(), intent({
|
||||
destination: 'arrakeen', sector: 10, advisors: false,
|
||||
})), null);
|
||||
assert.equal(quoteAdvisorTransportMatching(context(), intent({ origin: 'carthag', group: { 'carthag:11': 1 } })), null);
|
||||
assert.equal(quoteAdvisorTransportMatching(context(), intent({ destination: 'polar_sink', sector: 0 })), null);
|
||||
assert.equal(quoteAdvisorTransportMatching(context(), intent({
|
||||
origin: 'imperial_basin', sector: 11, group: { 'imperial_basin:9': 1 },
|
||||
})), null);
|
||||
const noScope = context();
|
||||
delete noScope.advisorTransportMatchingOpportunity;
|
||||
for (const destination of ['reserves', 'homeworld:beneGesserit']) {
|
||||
assert.equal(quoteAdvisorTransportMatching(noScope, intent({ destination, sector: 0 })), null);
|
||||
}
|
||||
assert.equal(quoteAdvisorTransportMatching(noScope, intent({ destination: 'polar_sink', sector: 0 })), null);
|
||||
assert.throws(() => quoteAdvisorTransportMatching(noScope, intent()), /original native opportunity/);
|
||||
});
|
||||
|
||||
void test('the private opportunity scopes prevention while UUID, sector and group insertion order do not change the same packet key', () => {
|
||||
const original = source();
|
||||
const scope = context().advisorTransportMatchingOpportunity!;
|
||||
const key = advisorTransportMatchingKey(original, scope);
|
||||
assert.equal(advisorTransportMatchingKey({
|
||||
...original, event: 'new-counter', sector: 11,
|
||||
group: { 'hagga_basin:13': 1, 'hagga_basin:12': 1 },
|
||||
}, scope), key);
|
||||
for (const change of [
|
||||
{ turn: 5 }, { owner: 'other' }, { producer: 'junction' as const }, { origin: 'carthag' },
|
||||
{ destination: 'carthag' }, { advisors: false }, { group: { 'hagga_basin:12': 1 } },
|
||||
]) {
|
||||
assert.notEqual(advisorTransportMatchingKey({ ...original, ...change }, scope), key);
|
||||
}
|
||||
assert.notEqual(advisorTransportMatchingKey(original, 'later-native-opportunity'), key);
|
||||
assert.throws(() => advisorTransportMatchingKey(original, ''), AdvisorTransportMatchingError);
|
||||
});
|
||||
|
||||
void test('transport prevention retains the uncommitted packet and blocks its same-territory retry only within the original native scope', () => {
|
||||
const ctx = context();
|
||||
const original = source(ctx);
|
||||
ctx.advisorTransportMatchingDenied = [advisorTransportMatchingKey(original, ctx.advisorTransportMatchingOpportunity!)];
|
||||
const before = structuredClone(ctx);
|
||||
assert.throws(() => quoteAdvisorTransportMatching(ctx, intent()), /already prevented.*original transport opportunity/);
|
||||
assert.throws(() => validateAdvisorTransportMatching(ctx, {
|
||||
...original, event: 'new-counter', sector: 11,
|
||||
}), AdvisorTransportMatchingError);
|
||||
assert.deepEqual(ctx, before);
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, intent({ group: { 'hagga_basin:12': 1 } })));
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, intent({ destination: 'carthag', sector: 11 })));
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, intent({ producer: 'junction' })));
|
||||
ctx.advisorTransportMatchingOpportunity = 'later-native-opportunity';
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, intent()));
|
||||
ctx.advisorTransportMatchingOpportunity = before.advisorTransportMatchingOpportunity;
|
||||
ctx.turn++;
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, intent()));
|
||||
});
|
||||
|
||||
void test('validation rejects stale native windows, target stance and selected board sources without changing the action', () => {
|
||||
const changes: ((ctx: AdvisorTransportMatchingContext, original: AdvisorTransportMatchingSource) => void)[] = [
|
||||
(ctx) => { ctx.active = 'a'; },
|
||||
(ctx) => { ctx.phase = 6; },
|
||||
(ctx) => { ctx.turn++; },
|
||||
(ctx) => { ctx.players[0].shipped = true; },
|
||||
(ctx) => { delete ctx.advisorTransportMatchingOpportunity; },
|
||||
(ctx) => { ctx.players[0].forces = { ...ctx.players[0].forces, 'hagga_basin:12': 0 }; },
|
||||
(ctx) => { ctx.players[0].forces = { ...ctx.players[0].forces, 'imperial_basin:9': 0 }; },
|
||||
(ctx) => { ctx.players[0].advisors = { carthag: {} }; },
|
||||
(_ctx, original) => { original.event = ''; },
|
||||
(_ctx, original) => { original.owner = 'missing'; },
|
||||
(_ctx, original) => { original.turn = 3; },
|
||||
(_ctx, original) => { original.phase = 3; },
|
||||
(_ctx, original) => { original.advisors = false; },
|
||||
(_ctx, original) => { original.destination = 'missing'; },
|
||||
(_ctx, original) => { original.sector = 8; },
|
||||
(_ctx, original) => { original.group = {}; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12': 4 }; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12': 0 }; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12': -1 }; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12': 1.5 }; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12.0': 1 }; },
|
||||
(_ctx, original) => { original.group = { 'hagga_basin:12': 1, 'carthag:11': 1 }; },
|
||||
(_ctx, original) => { original.group = { reserves: 1 }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const ctx = context();
|
||||
const original = source(ctx);
|
||||
change(ctx, original);
|
||||
const before = structuredClone({ ctx, original });
|
||||
assert.throws(() => validateAdvisorTransportMatching(ctx, original), (error: unknown) =>
|
||||
error instanceof AdvisorTransportMatchingError && error.name === 'AdvisorTransportMatchingError');
|
||||
assert.deepEqual({ ctx, original }, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Junction matching rejects absent, foreign-only, excessive or mixed board/world physical selections', () => {
|
||||
const changes: ((ctx: AdvisorTransportMatchingContext, incoming: AdvisorTransportMatchingIntent) => void)[] = [
|
||||
(ctx) => { delete ctx.worlds; },
|
||||
(ctx) => { ctx.worlds = ctx.worlds!.filter((world) => world.id !== 'homeworld:atreides'); },
|
||||
(ctx) => { ctx.worlds = [...ctx.worlds!, ctx.worlds![0]]; },
|
||||
(ctx) => { ctx.worlds![0].forces = { a: { normal: 10, elite: 0 } }; },
|
||||
(_ctx, incoming) => { incoming.group = { 'homeworld:atreides': 3 }; },
|
||||
(_ctx, incoming) => { incoming.group = { 'homeworld:atreides': 0 }; },
|
||||
(_ctx, incoming) => { incoming.group = { 'homeworld:atreides': 1, 'hagga_basin:12': 1 }; },
|
||||
(_ctx, incoming) => { incoming.group = { 'homeworld:missing': 1 }; },
|
||||
(_ctx, incoming) => { incoming.group = { 'homeworld:atreides:0': 1 }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const ctx = context();
|
||||
const incoming = worldIntent();
|
||||
change(ctx, incoming);
|
||||
assert.throws(() => quoteAdvisorTransportMatching(ctx, incoming), AdvisorTransportMatchingError);
|
||||
}
|
||||
const ctx = context();
|
||||
ctx.worlds![0].forces = { b: { normal: 0, elite: 2 } };
|
||||
assert.throws(() => quoteAdvisorTransportMatching(ctx, worldIntent()), AdvisorTransportMatchingError);
|
||||
});
|
||||
|
||||
void test('transport sources contain exactly ten public fields and current board locations remain room-specific', () => {
|
||||
const ctx = context();
|
||||
const original = source(ctx);
|
||||
for (const field of ['opportunity', 'advisorTransportMatchingOpportunity', 'order', 'signature', 'grant', 'token', 'noField', 'payment', 'rate']) {
|
||||
assert.throws(() => validateAdvisorTransportMatching(ctx, {
|
||||
...original, [field]: 'private-or-unrelated',
|
||||
} as AdvisorTransportMatchingSource), AdvisorTransportMatchingError);
|
||||
}
|
||||
for (const malformed of [
|
||||
null, [], { ...original, producer: 'ambassador' }, { ...original, advisors: 'true' }, { ...original, group: null },
|
||||
]) {
|
||||
assert.throws(() => validateAdvisorTransportMatching(ctx,
|
||||
malformed as unknown as AdvisorTransportMatchingSource), AdvisorTransportMatchingError);
|
||||
}
|
||||
const mobile = intent({ destination: 'hidden_mobile_stronghold', sector: 0 });
|
||||
assert.throws(() => quoteAdvisorTransportMatching(ctx, mobile), AdvisorTransportMatchingError);
|
||||
ctx.mobileStronghold = { location: 'hagga_basin:12' };
|
||||
ctx.players[0].forces = { ...ctx.players[0].forces, 'hidden_mobile_stronghold:0': 1 };
|
||||
ctx.players[0].advisors = { ...ctx.players[0].advisors, hidden_mobile_stronghold: {} };
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, mobile));
|
||||
assert.doesNotThrow(() => validateAdvisorTransportMatching(ctx, source(ctx, mobile)));
|
||||
});
|
||||
|
||||
void test('board matching avoids native grants, hands, wallets, concealed forces, forecasts and unrelated world custody', () => {
|
||||
const ctx = context();
|
||||
const incoming = intent();
|
||||
const original = source(ctx);
|
||||
for (const player of ctx.players) {
|
||||
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'skills', 'noField', 'eliteForces', 'reserves', 'moved']) {
|
||||
Object.defineProperty(player, field, {
|
||||
get() { throw new Error(`Transport matching read ${player.id} private or unrelated ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const stance of Object.values(ctx.players[0].advisors!)) {
|
||||
for (const field of ['lockedTurn', 'aloneFlipHold']) {
|
||||
Object.defineProperty(stance, field, {
|
||||
get() { throw new Error(`Transport matching read unrelated stance ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const object of [ctx, incoming]) {
|
||||
for (const field of ['worlds', 'homeworlds', 'nexus', 'grant', 'signature', 'rate', 'forecast', 'stormCard', 'pendingShipment', 'advisorMatchingDenied']) {
|
||||
Object.defineProperty(object, field, {
|
||||
get() { throw new Error(`Transport matching read private native ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.defineProperty(ctx.players[1], 'forces', {
|
||||
get() { throw new Error('Transport matching reran native foreign occupancy'); },
|
||||
});
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, incoming));
|
||||
assert.doesNotThrow(() => validateAdvisorTransportMatching(ctx, original));
|
||||
});
|
||||
|
||||
void test('world matching reads only the selected owner normal counters without other-world or other-owner values', () => {
|
||||
const ctx = context();
|
||||
const original = source(ctx, worldIntent());
|
||||
for (const world of ctx.worlds!) {
|
||||
for (const owner of ['a', 'g']) {
|
||||
Object.defineProperty(world.forces, owner, {
|
||||
get() { throw new Error(`World matching read foreign ${owner} counters`); },
|
||||
});
|
||||
}
|
||||
Object.defineProperty(world.forces.b, 'elite', {
|
||||
get() { throw new Error('World matching used special instead of normal BG counters'); },
|
||||
});
|
||||
}
|
||||
Object.defineProperty(ctx.worlds![2], 'forces', {
|
||||
get() { throw new Error('World matching read an unselected world'); },
|
||||
});
|
||||
for (const player of ctx.players) {
|
||||
Object.defineProperty(player, 'reserves', {
|
||||
get() { throw new Error('World matching read private native reserve bookkeeping'); },
|
||||
});
|
||||
}
|
||||
assert.ok(quoteAdvisorTransportMatching(ctx, worldIntent()));
|
||||
assert.doesNotThrow(() => validateAdvisorTransportMatching(ctx, original));
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { splitLocation } from '../game/board';
|
||||
import {
|
||||
nexusGuildSecretAllyAction, nexusGuildSecretAllyCanAct, nexusGuildSecretAllyQuote,
|
||||
} from '../game/nexus-guild-secret-ally-options';
|
||||
import { NEXUS_FACTIONS } from '../game/nexus-cards';
|
||||
import {
|
||||
actAdvisorTransportMatching, advanceAdvisorTransportMatching, nextAdvisorTransportMatching,
|
||||
passAdvisorTransportMatching, finishAdvisorTransportMatching,
|
||||
advisorTransportMatchingPlayer, advisorTransportMatchingInventory, advisorTransportMatchingClean,
|
||||
type AdvisorTransportMatchingDirection, type AdvisorTransportMatchingStep,
|
||||
} from './fixture-advisor-transport-matching';
|
||||
|
||||
export type AdvisorSecretTransportMatchingStep = AdvisorTransportMatchingStep;
|
||||
export type AdvisorSecretTransportMatchingTransition = { before: Game; steps: AdvisorSecretTransportMatchingStep[]; after: Game };
|
||||
export const actAdvisorSecretTransportMatching = actAdvisorTransportMatching;
|
||||
export const nextAdvisorSecretTransportMatching = nextAdvisorTransportMatching;
|
||||
export const advanceAdvisorSecretTransportMatching = advanceAdvisorTransportMatching;
|
||||
export const passAdvisorSecretTransportMatching = passAdvisorTransportMatching;
|
||||
export const finishAdvisorSecretTransportMatching = finishAdvisorTransportMatching;
|
||||
export const advisorSecretTransportMatchingPlayer = advisorTransportMatchingPlayer;
|
||||
export const advisorSecretTransportMatchingInventory = advisorTransportMatchingInventory;
|
||||
export const advisorSecretTransportMatchingClean = advisorTransportMatchingClean;
|
||||
export const advisorSecretTransportNexusInventory = (game: Game) => {
|
||||
const cards = game.nexusCards!.cards!;
|
||||
const identities = [...cards.deck, ...cards.discard, ...Object.values(cards.hands).filter(card => card !== null)].sort();
|
||||
assert.equal(new Set(identities).size, identities.length, 'Every original physical Nexus singleton retains one current slot');
|
||||
return identities;
|
||||
};
|
||||
|
||||
/** Public Advanced BG/Atreides/Emperor with actual original Nexus setup. BG
|
||||
* stays unallied; a real A/Emperor alliance permits BG's native closing draw.
|
||||
* Only the still-unopened Guild singleton is selected before that real draw. */
|
||||
export function createAdvisorSecretTransportMatchingFixture(options: { direction?: AdvisorTransportMatchingDirection } = {}) {
|
||||
const direction = options.direction ?? 'fightersToAdvisors';
|
||||
const bg = 'beneGesserit', holder = 'atreides', emperor = 'emperor';
|
||||
let game = createGame('ADVISORSECRETTRANSPORTMATCHING', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
|
||||
joinGame(game, newPlayer(holder, 'Atreides', 'atreides')); joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), steps: AdvisorSecretTransportMatchingStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAdvisorSecretTransportMatching(game, actor, action, steps); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorSecretTransportMatching(game, until, steps); };
|
||||
const transition = (run: () => void): AdvisorSecretTransportMatchingTransition => {
|
||||
const before = structuredClone(game), start = steps.length; run();
|
||||
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
const enableNexus = transition(() => { act(game.host, { type: 'moduleOption', module: 'nexusCards', enabled: true }); });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, []);
|
||||
assert.ok(game.nexusCards?.cards);
|
||||
assert.ok(!game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
|
||||
!game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
const originalInventory = advisorSecretTransportMatchingInventory(game); assert.equal(originalInventory.length, 33);
|
||||
const originalNexusInventory = advisorSecretTransportNexusInventory(game);
|
||||
assert.deepEqual(originalNexusInventory, [...NEXUS_FACTIONS].sort());
|
||||
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
|
||||
assert.equal(ordinary.length, 2);
|
||||
const selected = [ordinary[0], karama, ordinary[1]];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let advisorSetup: AdvisorSecretTransportMatchingTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextAdvisorSecretTransportMatching(game); assert.ok(next);
|
||||
const before = next.action.type === 'advisorSetup' ? structuredClone(game) : null;
|
||||
act(next.actor, next.action);
|
||||
if (before) advisorSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(advisorSecretTransportMatchingPlayer(game, bg).forces['arrakeen:10'], 1);
|
||||
assert.ok(advisorSecretTransportMatchingPlayer(game, bg).advisors?.arrakeen);
|
||||
assert.equal(advisorSecretTransportMatchingPlayer(game, holder).forces['arrakeen:10'], 10);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
!['imperial_basin', 'arrakeen'].includes(card.territory));
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === bg && advisorSecretTransportMatchingClean(state));
|
||||
const fighterShipment = transition(() => {
|
||||
act(bg, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(advisorSecretTransportMatchingClean);
|
||||
});
|
||||
assert.equal(advisorSecretTransportMatchingPlayer(fighterShipment.before, bg).spice - advisorSecretTransportMatchingPlayer(game, bg).spice, 4);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && advisorSecretTransportMatchingClean(state) &&
|
||||
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
|
||||
const beforeWorm = structuredClone(game), beforeWormInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const from = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(from >= 0); const worm = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(worm);
|
||||
const wormOrder = { position: 0, from, card: structuredClone(worm) };
|
||||
const wormFollowupOrder = [1, 2].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
!['imperial_basin', 'arrakeen'].includes(card.territory));
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeWormInventory);
|
||||
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && advisorSecretTransportMatchingClean(state));
|
||||
const nexus = structuredClone(game);
|
||||
const alliance = transition(() => {
|
||||
act(holder, { type: 'alliance', target: emperor }); act(emperor, { type: 'alliance', target: holder });
|
||||
});
|
||||
assert.equal(advisorSecretTransportMatchingPlayer(game, holder).ally, emperor);
|
||||
assert.equal(advisorSecretTransportMatchingPlayer(game, emperor).ally, holder);
|
||||
assert.equal(advisorSecretTransportMatchingPlayer(game, bg).ally, null);
|
||||
reach(state => state.nexusCards?.phase?.stage === 'drawing' && advisorSecretTransportMatchingClean(state));
|
||||
const beforeNexusOrder = structuredClone(game), cards = game.nexusCards!.cards!;
|
||||
assert.equal(cards.hands[bg], null);
|
||||
const guildIndex = cards.deck.indexOf('guild'); assert.ok(guildIndex >= 0);
|
||||
cards.deck.unshift(cards.deck.splice(guildIndex, 1)[0]);
|
||||
const nexusOrder = { card: 'guild', from: guildIndex, position: 0 };
|
||||
assert.deepEqual(advisorSecretTransportNexusInventory(game), originalNexusInventory);
|
||||
const beforeNexusDraw = structuredClone(game);
|
||||
const drawStep: AdvisorSecretTransportMatchingStep = { actor: bg, action: {
|
||||
type: 'nexusCardChoice', turn: game.turn, card: cards.hands[bg], choice: 'draw', ownRedraws: 0,
|
||||
} };
|
||||
const nexusDraw = transition(() => { act(drawStep.actor, drawStep.action); });
|
||||
assert.equal(game.nexusCards!.cards!.hands[bg], 'guild');
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === bg && advisorSecretTransportMatchingClean(state));
|
||||
const view = viewGame(game, bg), offer = view.nexusGuildSecretAlly;
|
||||
assert.ok(offer && !offer.blocked && nexusGuildSecretAllyCanAct(view));
|
||||
const declaration = (source: string, destination: string, amount: number) => {
|
||||
const to = splitLocation(destination);
|
||||
const ordinary: Action = { type: 'guildShip', from: source, territory: to.territory,
|
||||
sector: to.sector, amount, elite: 0, allyPayment: 0 };
|
||||
const action = nexusGuildSecretAllyAction(view, offer.event, ordinary); assert.ok(action);
|
||||
const quote = nexusGuildSecretAllyQuote(view, to.territory, amount); assert.ok(quote);
|
||||
return { action, cost: quote.cost, sources: { [source]: { normal: amount, elite: 0 } } };
|
||||
};
|
||||
const fighterQuote = declaration('imperial_basin:9', 'arrakeen:10', 1);
|
||||
const advisorQuote = declaration('arrakeen:10', 'imperial_basin:9', 1);
|
||||
const largerQuote = declaration('imperial_basin:9', 'arrakeen:10', 2);
|
||||
const emptyQuote = declaration('imperial_basin:9', 'polar_sink:0', 1);
|
||||
const quote = direction === 'fightersToAdvisors' ? fighterQuote : advisorQuote;
|
||||
const sourceLocation = direction === 'fightersToAdvisors' ? 'imperial_basin:9' : 'arrakeen:10';
|
||||
const destinationLocation = direction === 'fightersToAdvisors' ? 'arrakeen:10' : 'imperial_basin:9';
|
||||
const player = advisorSecretTransportMatchingPlayer(game, bg);
|
||||
assert.equal(player.ally, null); assert.ok(!game.players.some(player => player.faction === 'guild'));
|
||||
assert.equal(player.forces['imperial_basin:9'], 2); assert.ok(!player.advisors?.imperial_basin);
|
||||
assert.equal(player.forces['arrakeen:10'], 1); assert.ok(player.advisors?.arrakeen);
|
||||
assert.equal(player.shipped, false); assert.equal(player.moved, 0);
|
||||
assert.equal(quote.cost, 1); assert.ok(quote.cost <= player.spice);
|
||||
assert.equal(game.nexusCards!.cards!.hands[bg], 'guild');
|
||||
assert.ok(advisorSecretTransportMatchingPlayer(game, holder).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(advisorSecretTransportMatchingInventory(game), originalInventory);
|
||||
assert.deepEqual(advisorSecretTransportNexusInventory(game), originalNexusInventory);
|
||||
return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, enableNexus,
|
||||
lobby, setup, beforeStartingDeal, afterSetup, advisorSetup, fighterShipment,
|
||||
beforeWorm, wormOrder, wormFollowupOrder, nexus, alliance, beforeNexusOrder, nexusOrder,
|
||||
beforeNexusDraw, drawStep, nexusDraw, afterNexusDraw: nexusDraw.after,
|
||||
kind: 'secret' as const, direction, bg, owner: bg, holder, atreides: holder, emperor, guild: null,
|
||||
karama: karama.id, card: karama.id, nexusCard: 'guild' as const, event: offer.event, offer: structuredClone(offer),
|
||||
actor: bg, shipper: bg, action: quote.action, sources: quote.sources, amount: 1, cost: quote.cost,
|
||||
origin: splitLocation(sourceLocation).territory, destination: splitLocation(destinationLocation).territory,
|
||||
sourceLocation, destinationLocation, from: sourceLocation, to: destinationLocation,
|
||||
fightersToAdvisors: fighterQuote.action, advisorsToFighters: advisorQuote.action,
|
||||
largerPacket: largerQuote.action, noMatch: emptyQuote.action, replacement: largerQuote.action,
|
||||
replacements: [largerQuote.action, emptyQuote.action], matchingActions: [fighterQuote.action, advisorQuote.action],
|
||||
actionQuotes: { fighter: fighterQuote, advisor: advisorQuote, larger: largerQuote, empty: emptyQuote },
|
||||
transportStep: { actor: bg, action: quote.action } satisfies AdvisorSecretTransportMatchingStep,
|
||||
steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalNexusInventory,
|
||||
originalSpice, dealOrder, spiceOrder,
|
||||
staging: [
|
||||
'Actual public Advanced BG/Atreides/Emperor lobby enables Nexus through its original moduleOption action, then ready/startPreview and native base33/all12 Nexus setup. Guild is absent; no Homeworld, Discovery, Skills, Tech or Stronghold module is selected.',
|
||||
'Original prediction/private Traitors and actual advisorSetup place one BG advisor beside Atreides’ native ten Arrakeen10 counters. Only original scalar shuffles, three unopened Treachery positions and safe unopened Spice positions are chosen; Atreides actually receives and retains its printed Karama.',
|
||||
'During its real first turn BG pays four to ship two original reserves into empty Basin9 as fighters. Native Storm/dual Spice/Charity/Bidding/Revival, original preparation decline and peaceful remaining turns preserve both groups without a force, wallet, hand, phase or stance installation.',
|
||||
'At actual turn-two Shai-Hulud Nexus, Atreides really proposes alliance and Emperor really accepts. BG stays unallied. This separate native alliance supplies printed closing-draw eligibility; no BG alliance is created and no Nexus Card can be drawn at an entirely unallied table.',
|
||||
'Only the original still-unopened Guild Nexus singleton is moved to the next original deck position before BG’s real nexusCardChoice draw. The actual hand/phase history and all12 physical singletons are preserved; no held card, draw receipt or history is installed.',
|
||||
'Stop at BG’s real unused turn with the live absent-Guild Secret Ally offer. Both original guildShip directions carry its real event, plus packet2/empty replacements. The original Guild Nexus remains held; matching, physical transport, bank payment and recordGuildSecretShipment consumption remain caller-owned outcomes.',
|
||||
] };
|
||||
}
|
||||
export type AdvisorSecretTransportMatchingFixture = ReturnType<typeof createAdvisorSecretTransportMatchingFixture>;
|
||||
|
||||
export function commitAdvisorSecretTransportMatching(source: Game, fixture: AdvisorSecretTransportMatchingFixture,
|
||||
action: Action = fixture.action, steps: AdvisorSecretTransportMatchingStep[] = []) {
|
||||
const before = structuredClone(source), start = steps.length;
|
||||
const game = actAdvisorSecretTransportMatching(source, fixture.bg, action, steps);
|
||||
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { splitLocation, territory } from '../game/board';
|
||||
import { guildShipmentCost } from '../game/shipment-price';
|
||||
import { junctionTransportChoice } from '../game/junction-transport-options';
|
||||
import type { HomeworldShipmentSources } from '../game/homeworld-shipment-options';
|
||||
import {
|
||||
actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
|
||||
advisorPreparationPlayer, nextAdvisorPreparationStep,
|
||||
} from './fixture-advisor-preparation';
|
||||
|
||||
export type AdvisorTransportMatchingKind = 'guild' | 'junctionBoard' | 'junctionWorld';
|
||||
export type AdvisorTransportMatchingDirection = 'fightersToAdvisors' | 'advisorsToFighters';
|
||||
export type AdvisorTransportMatchingStep = { actor: string; action: Action };
|
||||
export type AdvisorTransportMatchingTransition = { before: Game; steps: AdvisorTransportMatchingStep[]; after: Game };
|
||||
export const actAdvisorTransportMatching = actAdvisorPreparation;
|
||||
export const advisorTransportMatchingPlayer = advisorPreparationPlayer;
|
||||
export const advisorTransportMatchingInventory = advisorPreparationInventory;
|
||||
export const advisorTransportMatchingClean = advisorPreparationClean;
|
||||
|
||||
function ownedTransportResponse(game: Game) {
|
||||
return !!game.response && ['advisorFlip', 'guildTransportPermission', 'guildRate'].includes(game.response.kind);
|
||||
}
|
||||
/** New matching and original permission/rate controls remain caller-owned.
|
||||
* Native early Guild timing yields the ordinary seat while sourcing BG's turn. */
|
||||
export function nextAdvisorTransportMatching(game: Game): AdvisorTransportMatchingStep | null {
|
||||
if (ownedTransportResponse(game)) return null;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
if (game.decision?.kind === 'advisorBattle')
|
||||
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||
if (game.decision?.kind === 'guildTiming')
|
||||
return { actor: game.decision.player, action: { type: 'decision', take: false } };
|
||||
if (game.decision?.kind === 'homeworldShipmentGuild')
|
||||
return { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, allow: true } };
|
||||
}
|
||||
return nextAdvisorPreparationStep(game);
|
||||
}
|
||||
export function advanceAdvisorTransportMatching(game: Game, until: (state: Game) => boolean,
|
||||
steps: AdvisorTransportMatchingStep[] = []): Game {
|
||||
for (let count = 0; count < 2200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original transport chronology ended before its requested boundary');
|
||||
const next = nextAdvisorTransportMatching(game);
|
||||
assert.ok(next, `Choose the original matching/permission/rate control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actAdvisorTransportMatching(game, next.actor, next.action, steps);
|
||||
}
|
||||
throw Error('The native advisor transport matching source did not reach its finite boundary');
|
||||
}
|
||||
export function passAdvisorTransportMatching(game: Game, steps: AdvisorTransportMatchingStep[] = []): Game {
|
||||
for (let count = 0; game.response && !ownedTransportResponse(game) && count < 60; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actAdvisorTransportMatching(game, actor.id, { type: 'passResponse' }, steps);
|
||||
}
|
||||
assert.ok(!game.response || ownedTransportResponse(game)); return game;
|
||||
}
|
||||
|
||||
/** Native Guild ally cross-shipment or actual high-population Junction offer.
|
||||
* BG's two opposed board groups are earned by real setup and a paid shipment;
|
||||
* World mode instead uses the untouched original typed native reserve pool. */
|
||||
export function createAdvisorTransportMatchingFixture(options: {
|
||||
kind?: AdvisorTransportMatchingKind; direction?: AdvisorTransportMatchingDirection;
|
||||
} = {}) {
|
||||
const kind = options.kind ?? 'guild', direction = options.direction ?? 'fightersToAdvisors';
|
||||
assert.ok(kind !== 'junctionWorld' || direction === 'fightersToAdvisors', 'The native World source enters resident advisors');
|
||||
const bg = 'beneGesserit', guild = 'guild', holder = 'atreides', homeworlds = kind !== 'guild';
|
||||
let game = createGame('ADVISORTRANSPORTMATCHING', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
|
||||
joinGame(game, newPlayer(guild, 'Guild', 'guild')); joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
|
||||
const initial = structuredClone(game), steps: AdvisorTransportMatchingStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAdvisorTransportMatching(game, actor, action, steps); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorTransportMatching(game, until, steps); };
|
||||
const transition = (run: () => void): AdvisorTransportMatchingTransition => {
|
||||
const before = structuredClone(game), start = steps.length; run();
|
||||
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
const enableHomeworlds = homeworlds ? transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); }) : null;
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, []);
|
||||
assert.equal(!!game.homeworlds, homeworlds);
|
||||
assert.ok(!game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
|
||||
!game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
const originalInventory = advisorTransportMatchingInventory(game); assert.equal(originalInventory.length, 33);
|
||||
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
|
||||
assert.equal(ordinary.length, 2);
|
||||
const selected = [ordinary[0], ordinary[1], karama];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let advisorSetup: AdvisorTransportMatchingTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextAdvisorTransportMatching(game); assert.ok(next);
|
||||
const before = next.action.type === 'advisorSetup' ? structuredClone(game) : null;
|
||||
act(next.actor, next.action);
|
||||
if (before) advisorSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(advisorTransportMatchingPlayer(game, bg).forces['arrakeen:10'], 1);
|
||||
assert.ok(advisorTransportMatchingPlayer(game, bg).advisors?.arrakeen);
|
||||
assert.equal(advisorTransportMatchingPlayer(game, holder).forces['arrakeen:10'], 10);
|
||||
assert.equal(advisorTransportMatchingPlayer(game, guild).forces['tueks_sietch:5'], 5);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1, 2, 3].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
!['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory));
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === bg && advisorTransportMatchingClean(state));
|
||||
const beforeFirstShipment = structuredClone(game);
|
||||
let fighterShipment: AdvisorTransportMatchingTransition | null = null;
|
||||
let beforeWorm: Game | null = null, nexus: Game | null = null;
|
||||
let wormOrder: { position: number; from: number; card: Game['spiceDeck'][number] } | null = null;
|
||||
let wormFollowupOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
|
||||
let alliance: AdvisorTransportMatchingTransition | null = null;
|
||||
if (kind !== 'junctionWorld') {
|
||||
fighterShipment = transition(() => {
|
||||
act(bg, { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(advisorTransportMatchingClean);
|
||||
});
|
||||
assert.equal(advisorTransportMatchingPlayer(fighterShipment.before, bg).spice - advisorTransportMatchingPlayer(game, bg).spice, 4);
|
||||
assert.equal(advisorTransportMatchingPlayer(game, bg).forces['imperial_basin:9'], 2);
|
||||
assert.ok(!advisorTransportMatchingPlayer(game, bg).advisors?.imperial_basin);
|
||||
if (kind === 'guild') {
|
||||
reach(state => state.turn === 2 && state.phase === 1 && advisorTransportMatchingClean(state) &&
|
||||
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
|
||||
beforeWorm = structuredClone(game);
|
||||
const beforeWormInventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const from = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(from >= 0); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.unshift(card);
|
||||
wormOrder = { position: 0, from, card: structuredClone(card) };
|
||||
wormFollowupOrder = [1, 2].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
!['imperial_basin', 'arrakeen', 'tueks_sietch'].includes(card.territory));
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeWormInventory);
|
||||
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && advisorTransportMatchingClean(state));
|
||||
nexus = structuredClone(game);
|
||||
alliance = transition(() => {
|
||||
act(bg, { type: 'alliance', target: guild }); act(guild, { type: 'alliance', target: bg });
|
||||
});
|
||||
assert.equal(advisorTransportMatchingPlayer(game, bg).ally, guild);
|
||||
assert.equal(advisorTransportMatchingPlayer(game, guild).ally, bg);
|
||||
}
|
||||
reach(state => state.turn === 2 && state.phase === 5 && state.active === bg && advisorTransportMatchingClean(state));
|
||||
}
|
||||
let offerStep: AdvisorTransportMatchingStep | null = null;
|
||||
let junctionOffer: AdvisorTransportMatchingTransition | null = null;
|
||||
if (homeworlds) {
|
||||
const sponsor = viewGame(game, guild).junctionTransport; assert.ok(sponsor?.canOffer);
|
||||
assert.equal(sponsor.recipient, bg);
|
||||
offerStep = { actor: guild, action: { type: 'offerJunctionTransport', event: sponsor.offerEvent, rate: 'half' } };
|
||||
junctionOffer = transition(() => { act(offerStep!.actor, offerStep!.action); });
|
||||
}
|
||||
const nativeWorld = homeworlds ? viewGame(game, bg).homeworlds!.worlds!.find(world => world.native === bg && !world.secondary)!.id : null;
|
||||
const fighterLocation = kind === 'junctionWorld' ? nativeWorld! : 'imperial_basin:9';
|
||||
const declaration = (from: string, to: string, amount: number) => {
|
||||
const sources: HomeworldShipmentSources = { [from]: { normal: amount, elite: 0 } };
|
||||
if (kind === 'guild') {
|
||||
const destination = splitLocation(to);
|
||||
const action: Action = { type: 'guildShip', from, territory: destination.territory,
|
||||
sector: destination.sector, amount, elite: 0, allyPayment: 0 };
|
||||
return { action, sources, cost: guildShipmentCost(territory(destination.territory).type, amount, true) };
|
||||
}
|
||||
const quote = junctionTransportChoice(viewGame(game, bg), to, sources, 0);
|
||||
assert.equal(quote.blocked, null); assert.ok(quote.action);
|
||||
return { action: quote.action, sources, cost: quote.cost };
|
||||
};
|
||||
const fighterQuote = declaration(fighterLocation, 'arrakeen:10', 1);
|
||||
const advisorQuote = kind === 'junctionWorld' ? null : declaration('arrakeen:10', 'imperial_basin:9', 1);
|
||||
const largerQuote = declaration(fighterLocation, 'arrakeen:10', 2);
|
||||
const emptyQuote = declaration(fighterLocation, kind === 'junctionWorld' ? 'imperial_basin:9' : 'polar_sink:0', 1);
|
||||
const quote = direction === 'fightersToAdvisors' ? fighterQuote : advisorQuote!;
|
||||
const sourceLocation = direction === 'fightersToAdvisors' ? fighterLocation : 'arrakeen:10';
|
||||
const destinationLocation = direction === 'fightersToAdvisors' ? 'arrakeen:10' : 'imperial_basin:9';
|
||||
const action = quote.action, player = advisorTransportMatchingPlayer(game, bg);
|
||||
assert.equal(game.active, bg); assert.equal(game.phase, 5); assert.ok(advisorTransportMatchingClean(game));
|
||||
assert.equal(player.shipped, false); assert.equal(player.moved, 0);
|
||||
assert.equal(player.forces['arrakeen:10'], 1); assert.ok(player.advisors?.arrakeen);
|
||||
if (kind !== 'junctionWorld') assert.equal(player.forces['imperial_basin:9'], 2);
|
||||
assert.ok(quote.cost <= player.spice); assert.equal(quote.cost, 1);
|
||||
assert.ok(advisorTransportMatchingPlayer(game, holder).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(advisorTransportMatchingInventory(game), originalInventory);
|
||||
return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, enableHomeworlds,
|
||||
lobby, setup, beforeStartingDeal, afterSetup, advisorSetup, beforeFirstShipment, fighterShipment,
|
||||
beforeWorm, wormOrder, wormFollowupOrder, nexus, alliance, offerStep, junctionOffer,
|
||||
kind, direction, bg, owner: bg, guild, holder, atreides: holder, karama: karama.id, card: karama.id,
|
||||
actor: bg, shipper: bg, nativeWorld, sourceLocation, destinationLocation, from: sourceLocation, to: destinationLocation,
|
||||
origin: sourceLocation.startsWith('homeworld:') ? sourceLocation : splitLocation(sourceLocation).territory,
|
||||
destination: splitLocation(destinationLocation).territory,
|
||||
action, sources: quote.sources, amount: 1, cost: quote.cost,
|
||||
fightersToAdvisors: fighterQuote.action, advisorsToFighters: advisorQuote?.action ?? null,
|
||||
largerPacket: largerQuote.action, noMatch: emptyQuote.action,
|
||||
replacement: largerQuote.action, replacements: [largerQuote.action, emptyQuote.action],
|
||||
matchingActions: [fighterQuote.action, ...(advisorQuote ? [advisorQuote.action] : [])],
|
||||
actionQuotes: { fighter: fighterQuote, advisor: advisorQuote, larger: largerQuote, empty: emptyQuote },
|
||||
transportStep: { actor: bg, action } satisfies AdvisorTransportMatchingStep,
|
||||
steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalSpice, dealOrder, spiceOrder,
|
||||
staging: [
|
||||
'Actual public Advanced BG/Guild/Atreides ready/startPreview, original base33 and no card/force modules. Original prediction/private Traitors and actual advisorSetup place BG beside Atreides’ ten Arrakeen10 counters. Atreides retains its originally dealt printed Karama.',
|
||||
homeworlds
|
||||
? 'Homeworlds are enabled by their actual lobby action. Original typed native pools and high-population Junction remain native; no custody, population or offer is installed.'
|
||||
: 'No Homeworld or optional module is selected. The native Guild alliance is earned later through the actual Nexus.',
|
||||
'Select only original scalar shuffle draws, three unopened Treachery positions and four safe unopened ordinary Spice positions. Native Storm/dual Spice/Charity/Bidding/Revival and original preparation decline/Guild timing/interception/income controls execute.',
|
||||
kind === 'junctionWorld'
|
||||
? 'Stop at the original first-turn BG unused shipment after Guild’s actual high-population half-price Junction offer. The real quote selects one ordinary counter from BG’s original native World and joins its resident Arrakeen advisor; no prior board fighter is claimed.'
|
||||
: 'BG actually pays four to ship two reserves into empty Basin9 on turn1. Guild’s native five remain in Tuek’s, avoiding a manufactured battle. Original Arrakeen advisors and Basin fighters survive native Collection/Mentat and turn-two Storm1; BG’s next shipment and movement remain unused.',
|
||||
kind === 'guild'
|
||||
? 'One original unopened turn-two Shai-Hulud and two safe original follow-up land cards earn an actual Nexus. BG actually proposes alliance and Guild actually accepts; only that reciprocal alliance permits the returned native guildShip cross declaration.'
|
||||
: 'Guild actually submits its current live Junction offer event at half tariff to BG; every returned declaration is the exact owner-view junctionTransportChoice over original typed sources. No native Guild alliance is claimed.',
|
||||
'Stop before the declaration and before new matching/original permission/rate. Expose both board directions, a genuine two-counter retry packet and an empty-destination replacement where applicable. No played phase, army, wallet, hand, alliance, stance, transport privilege, offer, response or receipt is installed.',
|
||||
] };
|
||||
}
|
||||
export type AdvisorTransportMatchingFixture = ReturnType<typeof createAdvisorTransportMatchingFixture>;
|
||||
|
||||
/** Declare only; matching and original permission/rate are never auto-passed. */
|
||||
export function commitAdvisorTransportMatching(source: Game, fixture: AdvisorTransportMatchingFixture,
|
||||
action: Action = fixture.action, steps: AdvisorTransportMatchingStep[] = []) {
|
||||
const before = structuredClone(source), start = steps.length;
|
||||
const game = actAdvisorTransportMatching(source, fixture.bg, action, steps);
|
||||
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
|
||||
}
|
||||
/** After explicit owned responses, resume only the original transport parent. */
|
||||
export function finishAdvisorTransportMatching(source: Game, steps: AdvisorTransportMatchingStep[] = []): Game {
|
||||
return advanceAdvisorTransportMatching(source,
|
||||
game => !game.pendingGuildTransport && !game.pendingHomeworldShipment && advisorTransportMatchingClean(game), steps);
|
||||
}
|
||||
Reference in new issue
Block a user