Resolve advisor conversion after Ambassador and Discovery transfers

Preserve original transfer and arrival cursors, admit no new Discovery advisor sources, and offer one mandatory counter for empty Ambassador advisor arrivals.
This commit is contained in:
Quintine committed 2026-10-24 21:32:12 +11:00
1 parent d66b594770
commit abf0305ddf
12 files changed
+996 -18

No files matched your search

+3 -1
View File
@@ -2485,7 +2485,9 @@ export function GameTable({
? 'Face Dance is complete: the winning army has returned, the replacement is placed and the Face Dancer is revealed. These surviving advisors are now alone and must become fighters before aftermath continues.'
: g.response.automaticAdvisorFlip.producer === 'storm'
? `Storm casualties in sector ${g.response.automaticAdvisorFlip.storm.sector} are complete. These surviving advisors are now alone and must become fighters before the remaining storm traversal.`
: 'The ground movement is complete. These advisors are now alone and must become fighters.'}
: g.response.automaticAdvisorFlip.producer === 'specialRelocation'
? `${g.response.automaticAdvisorFlip.relocation.kind === 'ambassador' ? 'Fremen Ambassador relocation' : 'The free Discovery force transfer'} is complete. These advisors are now alone and must become fighters before the original arrival continues.`
: 'The ground movement is complete. These advisors are now alone and must become fighters.'}
{' '}Karama prevents only this conversion; the completed action and its costs stay committed.
{' '}Provisional rule: a prevented group stays advisors while continuously alone,
until another faction returns or the group leaves.
+83
View File
@@ -1,5 +1,88 @@
# Mandatory advisor conversions
## Ambassador and Discovery relocation — 24 October 2026
**Prototyped / Partial.** Compose the supplied
Advanced physical17/28 E counter with the original independent relocation
producers. Fremen Ambassador movement can remove the last outsider beside BG
advisors, or carry advisors into an empty territory. Discovery's next-turn free
entry can remove the last outsider from a parent territory. Its original
non-advisor-only source restriction remains; it grants advisors no free entry.
Under the 14 October recommended policy, complete the physical move and any
original Sandmaster collection first, then resolve mandatory conversion before
the original arrival reactions. This selected composition is provisional,
consistent with the existing committed ground/transport counter. Preserve the
Ambassador's consumed token, independent movement and original entrant; preserve
the Discovery round's original one-time entry and exact typed transfer.
Keep an actual empty Ambassador advisor arrival in advisor stance until its
mandatory counter resolves. Carry the source's accompaniment lock, without
carrying its previous continuously-alone occurrence to another territory. The
original quote still sets no optional fighter request for that empty arrival;
preventing the mandatory flip cannot immediately retry it as an optional flip.
Occupied destinations retain their original matching-stance and optional-flip
rules. Existing Basic behavior is unchanged.
The six-field public receipt contains only a fresh event, Ambassador/Discovery
kind, actor, origin, destination and sector. Do not copy original move orders,
No-Field identities or denominations, Sandmaster identities, private signatures
or the original Discovery child. Root binds those original cursors separately
in a private parent. The original Ambassador phase permission remains
authoritative, including actual Homeworld Revival/victory arrivals; Discovery
stays in its phase0 pre-Storm round.
During the counter, the Ambassador retains its original first flip stage. A
nonmutating nested projection validates completed movement without consuming
that stage. Discovery retains its original arrival frame; its automatic BG
response may concern the parent territory, not the destination. Current and
parked Karama responses remain bound to the original queued occurrence. Exclude
simultaneous ordinary arrival children. Consume only the automatic parent
before one original optional flip/Intrusion/Terror or Discovery completion;
never repeat physical movement, collection, token consumption or round advance.
Cancellation retains the existing provisional continuously-alone duration.
See the original [Ambassador contract](FREMEN_AMBASSADOR_RULES.md),
[Discovery prototype](DISCOVERY_PROTOTYPE.md),
[native relocation fixture](../tests/fixture-advisor-relocation-flip.ts) and
[connected cases](../tests/advisor-relocation-flip-engine.test.ts).
The genuine Ambassador source takes44 public Advanced BG/Ecaz/Atreides actions
with original base33. Actual original packet draws earn a Fremen token, native
setup places Ecaz6 beside BG1 in Basin10, and paid token placement in Carthag
precedes Atreides' actual shipment/trigger. Relocating all six to empty Tuek's5
leaves the original BG advisor. The genuine Discovery source takes49 public
Advanced BG/Atreides/Emperor actions with actual module selection and base33.
The original Hagga Discovery Card places its native Cistern token in Gara
Kulon8. Actual BG setup, Atreides' paid shipment2, Intrusion, Collection
inspection/reveal and next-turn free entry earn the transferred last counter.
No played force, wallet, hand, phase, stance, alliance or receipt is installed
in either native source.
Frozen **19 files270/270**, types/lint/build, five native transfer/response/
GameTable SSR journeys and eight existing-profile paths pass. Both outcomes
retain the original physical move and allowances, then finish the original
arrival once. Original Discovery decline preserves the mixed parent group and
unused Karama. Two separately labelled controlled empty-arrival cases borrow
the native Ambassador cards/pieces/event but stage an alliance and BG
beneficiary; they are not native alliance evidence. Both retain one mandatory
conversion with no optional retry. Standard controls render; no new callback,
browser/mobile/deployed or complete-mode certificate is claimed.
Feedback corrects an optional-absence assertion in the smoke/tests and a missing
presence-helper reference in the new empty-arrival integration. Both focused
controlled cases then pass. Final review limits the new Karama selector field
to this producer, preserving existing serialized counter shapes; its focused
contract regression and final batch pass. This is not a recovery campaign.
Private proof and original-action HTML:
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-relocation-flips/`.
`frozen-integrated-4/report.json` binds tree
`e9f37b0270ae5779fe9620b95058a409941804917d55e3988a1f0b8751e4a120`,
2747 files on base `d66b594770b10af796fddf103583f7ad9c2b162b`.
Checkpoint prose/reference overlays are separately reviewed and hashed after
proof. Broader No-Field/Sandmaster/Homeworld/Nexus combinations, native allied
BG arrival, remaining matching-group interpretation and final assurance remain
open. No saved-room/D1, reset, restart or deployment operation was performed.
## Storm casualties before remaining traversal — 24 October 2026
**Prototyped / Partial.** Compose the supplied Advanced source's physical17
+27 -1
View File
@@ -16,6 +16,32 @@ open mode gates.
## Current checkpoint and work
**Special-relocation advisor conversion — 24 October. Prototyped / Partial.**
Fremen Ambassador and Discovery free-entry transfers now capture surviving alone
advisors before original arrivals. Preserve an empty Ambassador advisor arrival
through one mandatory counter, without granting advisor access to Discovery's
non-advisor-only free entry. The selected transfer/collection-before-conversion,
conversion-before-arrival order is provisional. Public six-field location
receipts omit orders, No-Fields/skills and private continuation. Original phase
permission, flip/entry stage, tokens and physical transfers remain bound; consume
the private parent before one original arrival resumes. Existing counter source
shapes remain unchanged.
[Source, native routes and limits](ADVISOR_AUTOMATIC_FLIPS.md#ambassador-and-discovery-relocation--24-october-2026).
Disjoint helper/native-fixture lanes, bounded continuation review and root
integration pass **19 files270/270**, types/lint/build, five native transfer/
response/GameTable SSR journeys, two labelled controlled empty-arrival cases
and eight existing-profile paths. Genuine44/49-action public Advanced base33
sources earn native Fremen packet/placement/trigger and actual Cistern placement,
Intrusion, Collection reveal and next-turn entry. Both conversion outcomes keep
the move/allowances; original Discovery decline retains its group. Controlled
alliance/beneficiary staging is not native-alliance evidence. Feedback repairs
an optional-absence assertion and missing presence-helper reference; the focused
serialized-source regression preserves older counters. Wider No-Field/skills/
Homeworld/Nexus combinations, native allied BG arrival, matching-group counters
and full modes remain open. No AI refinement, comprehensive/browser/deployed
assurance or saved-room/D1/reset/restart/deployment operation.
**Advisor conversion during storm traversal — 24 October. Prototyped / Partial.**
Current-sector losses now capture surviving alone advisors before the original
traversal continues. Retain them through unresolved Fremen casualty choices;
@@ -64,7 +90,7 @@ Homeworld/Nexus combinations with this counter remain open. No AI refinement,
comprehensive/browser/deployed assurance or saved-room/D1/reset/restart/deployment
operation.
Next conversion work includes special Ambassador/Discovery relocation.
Special Ambassador/Discovery relocation is now tracked above.
Matching-group interpretation and complete
Advanced/mode acceptance remain unfinished.
+49
View File
@@ -1,5 +1,54 @@
# Dune implementation status
## 24 October 2026 — Advisor conversion after independent relocation
Supplied physical17/28 E's counter now composes Fremen Ambassador and Discovery
free-entry transfers. Complete the move and original Sandmaster collection
before conversion, then resume original arrival reactions once. This selected
priority is explicit provisional composition under the 14 October authority.
Preserve the consumed Ambassador token, independent allowances and Discovery's
exact pre-Storm arrival frame/child. Discovery remains non-advisor-only; it does
not gain an empty-advisor free-entry path.
An actual empty Ambassador advisor arrival keeps its stance and lock until one
mandatory conversion. The original quote still sets no optional fighter request
there, preventing an immediate optional retry after Karama. Public six-field
location facts omit private move orders, No-Fields and skill identities. Root
retains the original private first-flip or Discovery arrival cursor, separately
binds live/parked responses and excludes simultaneous ordinary arrival children.
Consume the automatic parent before original optional flip/Intrusion/Terror or
Discovery completion. Original movement, collection and token use never replay.
The existing continuously-alone cancellation duration remains provisional.
Disjoint helper/native-fixture lanes, bounded timing review and root integration
pass **19 files270/270**, types/lint/build, five native transfer/response/GameTable
SSR journeys, two controlled empty-arrival cases and eight existing-profile
paths. Original public Advanced base33 sources take44 Ambassador and49 Discovery
actions. Actual Ecaz packet/setup, paid Fremen placement and Atreides shipment
trigger six counters' Basin10→Tuek's5 relocation. Actual Discovery module/setup,
native Cistern placement in Gara Kulon8, shipment2/Intrusion, Collection reveal
and next-turn entry move the last outsider into Cistern0. Both outcomes retain
physical transfer/allowances. Original Discovery decline leaves its mixed parent
and Karama intact. No played force, phase, hand, wallet, alliance, stance or
receipt is installed in those native sources. Controlled BG-beneficiary cases
explicitly stage alliance/beneficiary on borrowed original cards/pieces/event;
they do not certify an earned native alliance.
Feedback repairs an optional-absence smoke assertion and the new empty-arrival
presence helper. Both focused cases pass. Final contract review makes the added
Karama selector conditional, preserving prior serialized counter shapes; its
focused regression and final270/270/quick/build/runtime pass. Private proof:
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-relocation-flips/`.
`frozen-integrated-4/report.json` binds tree
`e9f37b0270ae5779fe9620b95058a409941804917d55e3988a1f0b8751e4a120`,
2747 files on base `d66b594770b10af796fddf103583f7ad9c2b162b`.
Prose/reference overlays are separately reviewed/hashed. Standard controls render;
no new callback/browser/mobile/deployed or complete-mode claim. Broader
No-Field/Sandmaster/Homeworld/Nexus combinations, native allied BG arrival,
matching-group counters, final assurance and full AI remain due. No saved-room/
D1, reset, restart or deployment operation.
[Connected source and limits](ADVISOR_AUTOMATIC_FLIPS.md#ambassador-and-discovery-relocation--24-october-2026).
## 24 October 2026 — Advisor conversion between storm sectors
Supplied physical17/28 E's counter now follows actual current-sector losses
+15
View File
@@ -1,5 +1,20 @@
# Rule decision index
## Advisor conversion after special relocation — 24 October 2026
Compose supplied Advanced physical17/28 E with actual Fremen Ambassador and
Discovery free-entry transfers. Provisionally complete movement and original
Sandmaster collection, then resolve the conversion counter before original
arrival reactions. Preserve consumed tokens, independent allowances and exact
Discovery round/child. Discovery retains non-advisor-only admission; only
Ambassador movement can carry advisors into an empty destination. Preserve
that arrival's lock through one mandatory conversion, without an optional retry
or an imported old alone occurrence. Public six-field location evidence omits
No-Fields/private orders/cursors. Retain original phase permissions, bind live
and parked responses, then consume the private parent before original arrival
resumes once. Continuously-alone cancellation duration remains provisional.
[Connected source and boundaries](ADVISOR_AUTOMATIC_FLIPS.md#ambassador-and-discovery-relocation--24-october-2026).
## Advisor conversion during storm traversal — 24 October 2026
Compose supplied Advanced physical17/28 E with actual current-sector storm
+42 -5
View File
@@ -68,6 +68,15 @@ export type AutomaticAdvisorStorm = {
sector: number;
};
export type AutomaticAdvisorRelocation = {
event: string;
kind: 'ambassador' | 'discovery';
player: string;
origin: string;
destination: string;
sector: number;
};
export type AutomaticAdvisorFlipSource = {
event: string;
owner: string;
@@ -83,6 +92,7 @@ export type AutomaticAdvisorFlipSource = {
| { producer: 'battleLosses'; battle: AutomaticAdvisorBattle }
| { producer: 'faceDance'; faceDance: AutomaticAdvisorFaceDance }
| { producer: 'storm'; storm: AutomaticAdvisorStorm }
| { producer: 'specialRelocation'; relocation: AutomaticAdvisorRelocation }
);
export type AutomaticAdvisorFlipPlayer = ForcePresence & {
@@ -155,7 +165,7 @@ function otherPresence(ctx: AdvisorSettlementContext, player: AutomaticAdvisorFl
function currentWindow(ctx: AutomaticAdvisorFlipContext) {
return ctx.advanced === true && ctx.status === 'playing' &&
(ctx.phase === 0 || ctx.phase === 5 || ctx.phase === 6 || ctx.phase === 8) && positive(ctx.turn);
[0, 1, 3, 5, 6, 8].includes(ctx.phase) && positive(ctx.turn);
}
/** A supported committed producer opens a response for these current groups.
@@ -408,6 +418,27 @@ function validateStormSource(
'Automatic advisor conversion needs its current exposed storm territory.');
}
function validateRelocationSource(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'specialRelocation' }>,
) {
const receipt = source.relocation;
requireFlip(record(receipt) &&
Object.keys(receipt).sort().join(',') === 'destination,event,kind,origin,player,sector' &&
identifier(receipt.event) && (receipt.kind === 'ambassador' || receipt.kind === 'discovery') &&
identifier(receipt.player) && identifier(receipt.origin) && identifier(receipt.destination) &&
nonnegative(receipt.sector),
'Automatic advisor conversion lost its exact public special-relocation receipt.');
const places = gameTerritories(ctx);
requireFlip(places.some((place) => place.id === receipt.origin) &&
places.some((place) => place.id === receipt.destination && place.sectors.includes(receipt.sector)) &&
(source.territory === receipt.origin || source.territory === receipt.destination),
'Automatic advisor conversion needs the current special-relocation origin or destination.');
const actors = ctx.players.filter((player) => player.id === receipt.player);
requireFlip(actors.length === 1 && presenceAt(actors[0], receipt.destination) > 0,
'Automatic advisor conversion lost the original relocated actor at its destination.');
}
/** Validate the same committed producer and exact held group before either response outcome. */
export function validateAutomaticAdvisorFlip(
ctx: AutomaticAdvisorFlipContext,
@@ -427,11 +458,16 @@ export function validateAutomaticAdvisorFlip(
(source.producer === 'faceDance' &&
Object.keys(source).sort().join(',') === 'event,faceDance,forces,owner,phase,producer,territory,turn') ||
(source.producer === 'storm' &&
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,storm,territory,turn')) &&
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,storm,territory,turn') ||
(source.producer === 'specialRelocation' &&
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,relocation,territory,turn')) &&
identifier(source.event) && identifier(source.owner) && identifier(source.territory) &&
positive(source.turn) && source.turn === ctx.turn &&
source.phase === (source.producer === 'storm' ? 0 : source.producer === 'gamont' ? 8 :
source.producer === 'battleLosses' || source.producer === 'faceDance' ? 6 : 5) &&
(source.producer === 'specialRelocation'
? record(source.relocation) && (source.relocation.kind === 'discovery'
? source.phase === 0 : source.relocation.kind === 'ambassador' && [1, 3, 5, 8].includes(source.phase))
: source.phase === (source.producer === 'storm' ? 0 : source.producer === 'gamont' ? 8 :
source.producer === 'battleLosses' || source.producer === 'faceDance' ? 6 : 5)) &&
currentWindow(ctx) && source.phase === ctx.phase,
'Automatic advisor conversion lost its original current-turn committed producer.');
const owners = ctx.players.filter((player) => player.id === source.owner);
@@ -450,7 +486,8 @@ export function validateAutomaticAdvisorFlip(
else if (source.producer === 'gamont') validateGamontSource(ctx, source);
else if (source.producer === 'battleLosses') validateBattleSource(ctx, source);
else if (source.producer === 'faceDance') validateFaceDanceSource(ctx, source);
else validateStormSource(ctx, source);
else if (source.producer === 'storm') validateStormSource(ctx, source);
else validateRelocationSource(ctx, source);
const forces = physicalGroup(owner, source.territory);
requireFlip(record(source.forces) && Object.keys(forces).length > 0 &&
Object.values(forces).every(positive) && Object.values(source.forces).every(positive) &&
+126 -4
View File
@@ -12,7 +12,7 @@ import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, h
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteAtreidesFullPlan } from './atreides-full-plan';
import { quoteTleilaxuSpecialKarama } from './tleilaxu-special-karama';
import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport, type AutomaticAdvisorFaceDance, type AutomaticAdvisorStorm } from './advisor-automatic-flip';
import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport, type AutomaticAdvisorFaceDance, type AutomaticAdvisorStorm, type AutomaticAdvisorRelocation } from './advisor-automatic-flip';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
@@ -1278,6 +1278,11 @@ export type Game = {
};
pendingAutomaticAdvisorFaceDance?: AutomaticAdvisorFaceDance;
pendingAutomaticAdvisorStorm?: { receipt: AutomaticAdvisorStorm; ready: boolean; cursor?: string };
pendingAutomaticAdvisorRelocation?: {
receipt: AutomaticAdvisorRelocation;
signature: string;
resume: { kind: 'ambassador'; event: string } | { kind: 'discovery'; child: DiscoveryEntryArrivalChild };
};
advisorPreparation?: AdvisorPreparationWindow;
ecazOccupyPreview?: boolean;
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
@@ -12005,6 +12010,20 @@ function discoveryEntryIntegrity(g: Game) {
requireRule(g.discoveryEnabled && g.discoveries && g.status === 'playing' && g.phase === 0 &&
frame.stage !== 'complete', 'Discovery entry must finish before the Storm Phase begins.');
nexusRule(() => validateDiscoveryEntryRound({...g, discoveries:g.discoveries!},frame));
const automatic = g.pendingAutomaticAdvisorRelocation;
if (automatic?.receipt.kind === 'discovery') {
requireRule(frame.stage === 'arrival' && !choices.length && !children.length,
'The Discovery conversion must precede its original arrival interaction.');
validateRelocationAdvisorParent(g, automatic);
const responses = controls.flatMap(control => {
const pending = control && 'pendingKarama' in control ? control.pendingKarama as Game['pendingKarama'] : null;
return [control?.response, pending?.use.kind === 'cancel' ? pending.use.response : null];
});
for (const {pending} of savedKaramaContexts(g))
if (pending.use.kind === 'cancel') responses.push(pending.use.response);
validateRelocationAdvisorResponse(g, automatic, responses);
return;
}
if (frame.stage === 'choose') {
const owner = frame.order[frame.cursor % frame.order.length];
requireRule(!children.length && choices.length === 1 && choices[0].player === owner &&
@@ -12070,6 +12089,8 @@ function decideDiscoveryEntry(g: Game, p: Player, action: Action) {
log(g, `${p.name} moved ${arrival.amount} ${arrival.amount === 1 ? 'force' : 'forces'}${arrival.elite ? `, including ${arrival.elite} elite ${arrival.elite === 1 ? 'force' : 'forces'},` : ''} from ${territory(arrival.parent).name} into ${territory(arrival.destination).name} before the storm. This one-time Discovery entry spends no spice or normal movement.`);
const child: DiscoveryEntryArrivalChild = {owner:p.id,territory:arrival.destination,sector:0,
amount:arrival.amount,elite:arrival.elite,discoveryEntry:arrival.signature};
if (prepareRelocationAdvisorFlips(g, {kind:'discovery',player:p.id,origin:arrival.parent,
destination:arrival.destination,sector:0}, {kind:'discovery',child})) return;
if (!intrusion(g,p,arrival.destination,{discoveryEntry:child})) completeDiscoveryEntryArrival(g,child);
}
function resolveDiscoveryBlow(g: Game, card: Extract<SpiceCard, {territory:string}>) {
@@ -17755,6 +17776,13 @@ function ambassadorRelocationIntegrity(g: Game) {
'This Ambassador relocation cannot own an accompaniment decision.',
);
const next = entry.relocation?.next;
if (next === 'flip' && g.pendingAutomaticAdvisorRelocation?.receipt.kind === 'ambassador') {
requireRule(!relatedResponses.length && !relatedDecisions.length && !relatedTerror,
'Complete mandatory advisor conversion before original Ambassador arrival reactions.');
validateRelocationAdvisorParent(g, g.pendingAutomaticAdvisorRelocation);
validateRelocationAdvisorResponse(g, g.pendingAutomaticAdvisorRelocation, responses);
return;
}
requireRule(
next === 'intrusion' || next === 'terror' || next === 'finish',
'The saved Ambassador arrival has an invalid continuation stage.',
@@ -18571,6 +18599,7 @@ function ambassadorRelocationMovement(g: Game, player: string) {
function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) {
const { entry, beneficiary: p } = currentFremenAmbassador(g);
validateAmbassadorRelocationArrival(g, move);
const emptyAdvisorArrival = emptyRelocatedAdvisorArrival(g, p, move.origin, move.to);
if (move.ambassadorSandmaster) {
const { key } = move.ambassadorSandmaster;
g.spice[key]--;
@@ -18595,7 +18624,7 @@ function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) {
removeGroup(p, move.group, move.eliteGroup);
const physical = move.total - (move.noField ? 1 : 0);
if (physical) place(p, move.to, move.sector, physical, move.elite);
if (move.advisors) {
if (move.advisors || emptyAdvisorArrival) {
const lockedTurn = Math.max(move.lockedTurn ?? 0, p.advisors?.[move.to]?.lockedTurn ?? 0);
(p.advisors ??= {})[move.to] = { ...p.advisors?.[move.to], ...(lockedTurn ? { lockedTurn } : {}) };
} else if (p.advisors) delete p.advisors[move.to];
@@ -18606,6 +18635,8 @@ function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) {
`${p.name} relocated ${physical} physical forces${move.noField ? ' and a concealed No-Field' : ''} from ${territory(move.origin).name} to ${territory(move.to).name}, sector ${move.sector}, through the Fremen Ambassador. Their ordinary shipment and movement allowances are unchanged.`,
{ faction: p.faction, name: 'Fremen Ambassador relocation' },
);
if (prepareRelocationAdvisorFlips(g, {kind:'ambassador',player:p.id,origin:move.origin,
destination:move.to,sector:move.sector}, {kind:'ambassador',event:entry.event})) return;
continueAmbassadorRelocation(g, entry.event);
}
function continueAmbassadorRelocation(g: Game, event: string) {
@@ -20340,6 +20371,12 @@ function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) {
'The mandatory conversion has lost its original storm sector.');
validateStormAdvisorCursor(g, parent);
}
if (source.producer === 'specialRelocation') {
const parent = g.pendingAutomaticAdvisorRelocation;
requireRule(parent && JSON.stringify(source.relocation) === JSON.stringify(parent.receipt),
'The mandatory conversion has lost its original completed relocation.');
validateRelocationAdvisorParent(g, parent);
}
automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source));
return source;
}
@@ -20665,6 +20702,90 @@ function finishAutomaticAdvisorStorm(g: Game) {
delete g.pendingAutomaticAdvisorStorm;
continueStorm(g);
}
function emptyRelocatedAdvisorArrival(g: Game, p: Player, origin: string, destination: string) {
return g.advanced && isAdvisor(p, origin) &&
!g.players.some(other => presenceAt(other, destination) > 0);
}
function relocationAdvisorSignature(g: Game, kind: AutomaticAdvisorRelocation['kind']) {
return JSON.stringify(kind === 'ambassador' ? g.pendingAmbassador : g.discoveryEntry);
}
function prepareRelocationAdvisorFlips(g: Game, relocation: Omit<AutomaticAdvisorRelocation, 'event'>,
resume: NonNullable<Game['pendingAutomaticAdvisorRelocation']>['resume']) {
const groups = automaticAdvisorRule(() => quoteAutomaticAdvisorFlips(g)).filter(group =>
group.territory === relocation.origin || group.territory === relocation.destination);
if (!groups.length) return false;
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorRelocation &&
!g.pendingAutomaticAdvisorTransport && !g.pendingAutomaticAdvisorHomeworldTransport &&
!g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle &&
!g.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm,
'Finish the original advisor conversion before a special relocation.');
const receipt = { event: crypto.randomUUID(), ...relocation };
const parent = { receipt, resume, signature: relocationAdvisorSignature(g, receipt.kind) };
g.pendingAutomaticAdvisorRelocation = parent;
validateRelocationAdvisorParent(g, parent);
for (const group of groups) {
const source: AutomaticAdvisorFlipSource = { event: crypto.randomUUID(), owner: group.player,
territory: group.territory, turn: g.turn, phase: g.phase, forces: group.forces,
producer: 'specialRelocation', relocation: receipt };
getPlayer(g, group.player).advisors![group.territory].aloneFlipHold = source.event;
(g.pendingAutomaticAdvisorFlips ??= []).push(source);
}
openAutomaticAdvisorFlip(g);
return true;
}
function validateRelocationAdvisorParent(g: Game, parent: NonNullable<Game['pendingAutomaticAdvisorRelocation']>) {
const receipt = parent.receipt;
requireRule(receipt.kind === parent.resume.kind &&
parent.signature === relocationAdvisorSignature(g, receipt.kind),
'The mandatory conversion lost its exact original relocation continuation.');
if (parent.resume.kind === 'ambassador') {
const {entry,beneficiary} = currentFremenAmbassador(g, parent.resume.event, true);
const move = entry.relocation!.order;
requireRule(entry.relocation!.next === 'flip' && receipt.player === beneficiary.id &&
receipt.origin === move.origin && receipt.destination === move.to && receipt.sector === move.sector,
'Finish the pending conversion before the original Ambassador arrival.');
// Validate completed physical provenance without consuming the first flip stage.
const context = { ...g, pendingAmbassador: { ...entry,
relocation: { ...entry.relocation!, next: 'intrusion' as const } } };
try { validateAmbassadorRelocationContext(context, entry.event, 'intrusion'); }
catch (error) { if (error instanceof MovementCancellationError) throw new RuleError(error.message); throw error; }
} else {
const frame = g.discoveryEntry, arrival = frame?.arrival;
requireRule(g.phase === 0 && frame?.stage === 'arrival' && arrival &&
receipt.player === arrival.owner && receipt.origin === arrival.parent &&
receipt.destination === arrival.destination && receipt.sector === 0,
'Finish the pending conversion before the original Discovery arrival.');
const child = parent.resume.child;
nexusRule(() => validateDiscoveryEntryArrivalChild(frame, child));
const owner = getPlayer(g, arrival.owner), key = `${arrival.destination}:0`;
requireRule(owner.forces[key] === arrival.destinationBefore + arrival.amount &&
(owner.elites?.forces[key] ?? 0) === arrival.destinationEliteBefore + arrival.elite,
'The completed Discovery transfer lost its original ordinary or elite counters.');
}
}
function validateRelocationAdvisorResponse(g: Game, parent: NonNullable<Game['pendingAutomaticAdvisorRelocation']>,
responses: readonly (ResponseWindow | null | undefined)[]) {
const source = g.pendingAutomaticAdvisorFlips?.[0];
requireRule(source?.producer === 'specialRelocation' &&
JSON.stringify(source.relocation) === JSON.stringify(parent.receipt) &&
responses.some(response => response?.kind === 'advisorFlip' &&
JSON.stringify(response.automaticAdvisorFlip) === JSON.stringify(source)),
'The original relocation has lost its owned mandatory advisor response.');
}
function finishAutomaticAdvisorRelocation(g: Game) {
const parent = g.pendingAutomaticAdvisorRelocation;
if (!parent) return;
requireRule(!g.pendingAutomaticAdvisorFlips?.length,
'Finish all original relocation conversions before arrival.');
validateRelocationAdvisorParent(g, parent);
delete g.pendingAutomaticAdvisorRelocation;
if (parent.resume.kind === 'ambassador') continueAmbassadorRelocation(g, parent.resume.event);
else {
const p = getPlayer(g, parent.receipt.player), child = parent.resume.child;
if (!intrusion(g, p, parent.receipt.destination, {discoveryEntry:child}))
completeDiscoveryEntryArrival(g, child);
}
}
function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) {
for (const entry of released)
delete getPlayer(g, entry.player).advisors?.[entry.territory];
@@ -22685,7 +22806,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.pendingAutomaticAdvisorFaceDance && !g.pendingAutomaticAdvisorStorm && !g.pendingAutomaticAdvisorRelocation,
'Resolve mandatory advisor conversions before checking victory.');
quote ??= currentVictoryQuote(g);
applyAdvisorReleases(g, quote.released);
@@ -28507,7 +28628,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
}
if (automaticAdvisorFlip) {
if (canceled)
log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed ${automaticAdvisorFlip.producer === 'guildTransport' || automaticAdvisorFlip.producer === 'homeworldTransport' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs stay' : automaticAdvisorFlip.producer === 'faceDance' ? 'Face Dance replacement and reveal stay' : automaticAdvisorFlip.producer === 'storm' ? 'current-sector storm casualties stay' : 'movement stays'} committed; this group remains advisors while continuously alone.`,
log(g, `${bg.name}'s mandatory conversion in ${territory(to).name} was prevented. The completed ${automaticAdvisorFlip.producer === 'guildTransport' || automaticAdvisorFlip.producer === 'homeworldTransport' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs stay' : automaticAdvisorFlip.producer === 'faceDance' ? 'Face Dance replacement and reveal stay' : automaticAdvisorFlip.producer === 'storm' ? 'current-sector storm casualties stay' : automaticAdvisorFlip.producer === 'specialRelocation' ? 'relocation and any collection stay' : 'movement stays'} committed; this group remains advisors while continuously alone.`,
{ faction: bg.faction, name: 'Mandatory advisor conversion prevented' });
g.pendingAutomaticAdvisorFlips!.shift();
if (!g.pendingAutomaticAdvisorFlips!.length) delete g.pendingAutomaticAdvisorFlips;
@@ -28518,6 +28639,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
finishAutomaticAdvisorHomeworldTransport(g);
finishAutomaticAdvisorFaceDance(g);
finishAutomaticAdvisorStorm(g);
finishAutomaticAdvisorRelocation(g);
} else openAutomaticAdvisorFlip(g);
} else finishAdvisorReaction(g, response);
}
+2
View File
@@ -187,6 +187,8 @@ const cancellationSources = {
homeworld: response.automaticAdvisorFlip.producer === 'homeworldTransport' ? g.pendingAutomaticAdvisorHomeworldTransport?.receipt ?? null : null,
faceDance: response.automaticAdvisorFlip.producer === 'faceDance' ? g.pendingAutomaticAdvisorFaceDance ?? null : null,
storm: response.automaticAdvisorFlip.producer === 'storm' ? g.pendingAutomaticAdvisorStorm?.receipt ?? null : null,
...(response.automaticAdvisorFlip.producer === 'specialRelocation'
? { relocation: g.pendingAutomaticAdvisorRelocation?.receipt ?? null } : {}),
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
: response.advisorPreparationSource
? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
+11 -6
View File
@@ -4321,7 +4321,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'Native Guild board-to-Homeworld and Junction board transfers can leave advisors alone after payment and physical transfer. Karama prevents only the resulting BG conversion; Junction permission and tariff stay immune. Original income resolves first, then any Junction board arrival resumes once. Original declaration snapshots stay private.',
'A completed Face Dance can leave advisors alone at remote board sources or, with zero replacement, at the battle target. Original force returns, replacement, leader death, revelation and any card cycle finish first. Karama can then prevent only the BG conversion; native Face Dance remains immune. Afterward the original aftermath resumes once.',
'After a storm sector removes the last outsider, surviving advisors elsewhere in that territory must convert. Provisionally finish every current-sector casualty choice first, then offer Karama prevention before the remaining traversal. Completed losses stay committed. Later sectors can still destroy advisors or fighters; advisors destroyed in the same sector create no conversion.',
'Provisional cancellation duration: a prevented advisor group stays advisors while continuously alone, including across phase and turn changes. Another faction returning or the group becoming empty ends that held occurrence. Later arrivals joining that same group match its stance. Special Ambassador/Discovery relocation and matching-group flip counters remain unfinished.',
'A completed Fremen Ambassador relocation or next-turn Discovery force transfer can leave advisors alone at its source. Ambassador movement can also bring advisors to an empty destination. Provisionally resolve their mandatory conversion after movement and any original collection, before the original arrival reactions continue. Karama keeps the transfer committed. Discovery free entry still admits non-advisor forces only.',
'Provisional cancellation duration: a prevented advisor group stays advisors while continuously alone, including across phase and turn changes. Another faction returning or the group becoming empty ends that held occurrence. Later arrivals joining that same group match its stance. Matching-group flip counters and wider combinations remain unfinished.',
'When another faction ships, moves or worm rides into your fighters, immediately choose whether to become advisors. The choice opens a Karama response. Repeated entry can offer a new choice.',
'After another faction’s off-planet shipment, choose a free force in the Polar Sink or an accompanying force at the shipment destination. An accompanying advisor group cannot flip to fighters in that turn while other factions remain.',
'After Spice Blow and Nexus, use Prepare advisors for battle during Charity, Bidding, Revival or before the first actual shipment. Quiet boundaries retain current mandatory priority. Each whole-territory flip has its original Karama response; its direct continuation preserves the current phase, auction, readiness and active movement. Declining the initial phase-five chooser leaves this window open. Ordinary allied presence, storm and fresh-advisor locks still restrict eligibility; the recorded supported Ecaz co-side composition remains separate.',
@@ -4332,7 +4333,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Implementation",
"status": "Partial",
"detail": "Storm traversal now captures surviving alone advisors after current-sector losses and retains them through original Fremen casualty choices. A public sector receipt binds a private ready/cursor parent; consume it before continuing once. Existing ground, transport, Gamont, battle and Face Dance contracts remain. Special relocation and matching-group causes remain unfinished.",
"detail": "Fremen Ambassador and Discovery free entry now capture resulting alone advisors before original arrivals. Preserve an empty Ambassador advisor arrival through one mandatory counter, without changing Discovery non-advisor admission. Private parent cursors retain original flip/entry stages; public six-field location evidence omits orders, No-Fields and skills. Matching-group and wider combinations remain unfinished.",
"evidence": [
"game/advisor-automatic-flip.ts",
"game/advisors.ts",
@@ -4347,7 +4348,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Player controls",
"status": "Partial",
"detail": "Standard Karama/Allow controls identify completed current-sector casualties and the remaining traversal. Prevention keeps actual losses; later sectors may still destroy the group. Existing producer and preparation guidance remains, without a new confirmation or timer.",
"detail": "Standard Karama/Allow controls distinguish completed special force transfer from pending conversion and original arrival. Existing producer and preparation guidance remains, without another confirmation or timer.",
"evidence": [
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
"components/advisor-preparation.tsx",
@@ -4358,8 +4359,10 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "AI",
"status": "Partial",
"detail": "Eight existing-profile native ending/continuing Storm conversion paths resolve legally through the original next phase. Completed current-sector losses remain; a later sector can still destroy BG. Earlier producer paths retain separate evidence. No strategy refinement or difficulty calibration.",
"detail": "Eight existing-profile native Ambassador/Discovery conversion paths resolve legally through original arrival completion. Transfers, allowances and original parent flow remain. Earlier producers retain separate evidence; no strategy refinement or difficulty calibration.",
"evidence": [
"tests/fixture-advisor-relocation-flip.ts",
"tests/advisor-relocation-flip-engine.test.ts",
"tests/storm-worm-cancellation.test.ts",
"tests/fixture-advisor-storm-flip.ts",
"tests/advisor-storm-flip-engine.test.ts",
@@ -4379,7 +4382,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Documentation",
"status": "Partial",
"detail": "Physical17/28 E composes the mandatory counter with current-sector losses. The selected priority after all current-sector casualties and before remaining traversal is explicitly provisional, as is continuously-alone cancellation duration. Public sector facts stay separate from private forecasts/protection/continuation.",
"detail": "Physical17/28 E composes conversion with original independent relocation. Priority after physical transfer/collection and before original arrivals is explicitly provisional. Original Ambassador phase permission and Discovery non-advisor-only admission remain; cancellation duration and wider combinations stay explicit.",
"evidence": [
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
"docs/ADVISOR_PREPARATION.md",
@@ -4390,8 +4393,10 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Verification",
"status": "Partial",
"detail": "Storm conversion passes frozen17-file220/220, types/lint/build, five native Storm/response/GameTable SSR journeys and eight profile paths. Genuine48-action sources earn base33, first dials, paid shipment/Intrusion and native turn-two Cards3/4. Ending9 preserves BG10; continuing10 destroys it after either response. Same-sector destruction has no conversion; controlled mixed-Fremen choices retain priority. Type feedback repairs only an empty-array fixture assertion. Wider combinations, special relocation/matching-group interpretation, complete modes and callback/browser/mobile/deployed/comprehensive acceptance remain open.",
"detail": "Special relocation passes frozen19-file270/270, types/lint/build, five native transfer/response/GameTable SSR journeys, two labelled controlled empty-arrival cases and eight profile paths. Genuine44/49-action base33 sources earn native packet/placement/trigger or actual Cistern placement, Intrusion, Collection reveal and next-turn entry. Both outcomes retain transfer; original decline preserves its group. Controlled alliances are not native-alliance evidence. Focused compatibility regression retains older serialized counters. Wider No-Field/skills/Homeworld/Nexus combinations, native allied BG arrival, matching-group interpretation, full modes and final assurance remain open.",
"evidence": [
"tests/fixture-advisor-relocation-flip.ts",
"tests/advisor-relocation-flip-engine.test.ts",
"tests/storm-worm-cancellation.test.ts",
"tests/fixture-advisor-storm-flip.ts",
"tests/advisor-storm-flip-engine.test.ts",
+188 -1
View File
@@ -14,6 +14,7 @@ import {
type AutomaticAdvisorHomeworldTransport,
type AutomaticAdvisorFaceDance,
type AutomaticAdvisorStorm,
type AutomaticAdvisorRelocation,
} from '../game/advisor-automatic-flip';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
@@ -24,6 +25,7 @@ type BattleSource = Extract<AutomaticAdvisorFlipSource, { producer: 'battleLosse
type HomeworldSource = Extract<AutomaticAdvisorFlipSource, { producer: 'homeworldTransport' }>;
type FaceDanceSource = Extract<AutomaticAdvisorFlipSource, { producer: 'faceDance' }>;
type StormSource = Extract<AutomaticAdvisorFlipSource, { producer: 'storm' }>;
type RelocationSource = Extract<AutomaticAdvisorFlipSource, { producer: 'specialRelocation' }>;
function context(): AutomaticAdvisorFlipContext {
return {
@@ -74,7 +76,7 @@ void test('quotes exclude held, occupied and physically empty advisor groups whi
ctx.players[1].forces = { ...ctx.players[1].forces, 'arrakeen:10': 1 };
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []);
for (const patch of [
{ advanced: false }, { status: 'lobby' }, { status: 'setup', phase: 0 }, { phase: 1 }, { phase: 4 }, { phase: 7 },
{ advanced: false }, { status: 'lobby' }, { status: 'setup', phase: 0 }, { phase: 2 }, { phase: 4 }, { phase: 7 },
]) {
assert.deepEqual(quoteAutomaticAdvisorFlips({ ...context(), ...patch }), []);
}
@@ -1172,3 +1174,188 @@ void test('storm stays phase-zero-only while every existing producer keeps its o
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
}
});
function relocation(kind: AutomaticAdvisorRelocation['kind'] = 'ambassador', phase = kind === 'discovery' ? 0 : 5) {
const { ctx, source: ground } = held();
ctx.phase = phase;
ctx.players[0].forces = { 'imperial_basin:9': 2, 'imperial_basin:10': 1, 'arrakeen:10': 1 };
ctx.players[1].faction = kind === 'discovery' ? 'fremen' : 'atreides';
ctx.players[1].forces = { 'carthag:11': 1 };
const receipt: AutomaticAdvisorRelocation = {
event: 'completed-public-special-relocation', kind, player: 'a',
origin: 'imperial_basin', destination: 'carthag', sector: 11,
};
const source: RelocationSource = {
event: 'automatic-after-original-special-relocation', owner: ground.owner,
territory: ground.territory, turn: ground.turn, phase,
forces: ground.forces, producer: 'specialRelocation', relocation: receipt,
};
ctx.players[0].advisors = {
...ctx.players[0].advisors,
imperial_basin: { lockedTurn: 4, aloneFlipHold: source.event },
};
return { ctx, source };
}
void test('Ambassador relocations retain exact six-field public receipts in all supported source phases and allow a BG arrival group', () => {
for (const phase of [1, 3, 5, 8]) {
const original = relocation('ambassador', phase);
const before = structuredClone(original);
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
assert.deepEqual(original, before);
assert.deepEqual(Object.keys(original.source).sort(), ['event', 'forces', 'owner', 'phase', 'producer', 'relocation', 'territory', 'turn']);
assert.deepEqual(Object.keys(original.source.relocation).sort(), ['destination', 'event', 'kind', 'origin', 'player', 'sector']);
}
const arrival = relocation();
arrival.source.relocation.player = 'b';
arrival.source.relocation.destination = 'hagga_basin';
arrival.source.relocation.sector = 12;
arrival.source.territory = 'hagga_basin';
arrival.source.forces = { 'hagga_basin:12': 2 };
arrival.ctx.players[0].forces = { 'hagga_basin:12': 2 };
arrival.ctx.players[0].advisors = { hagga_basin: { aloneFlipHold: arrival.source.event } };
arrival.ctx.players[1].forces = {};
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(arrival.ctx, arrival.source));
});
void test('Discovery relocation uses actual revealed interiors and the room board admits a placed HMS sector zero', () => {
const discovery = relocation('discovery');
discovery.ctx.discoveries = {
tokens: [{ face: 'cistern', status: 'placed', territory: 'hagga_basin', sector: 12, revealedTurn: 1 }],
};
discovery.source.relocation.destination = 'cistern';
discovery.source.relocation.sector = 0;
discovery.ctx.players[1].forces = { 'cistern:0': 1 };
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(discovery.ctx, discovery.source));
discovery.ctx.discoveries.tokens[0].revealedTurn = null;
assert.throws(() => validateAutomaticAdvisorFlip(discovery.ctx, discovery.source), AutomaticAdvisorFlipError);
discovery.ctx.discoveries.tokens[0].revealedTurn = 1;
discovery.source.relocation.origin = 'cistern';
discovery.source.relocation.destination = 'carthag';
discovery.source.relocation.sector = 11;
discovery.source.territory = 'cistern';
discovery.source.forces = { 'cistern:0': 2 };
discovery.ctx.players[0].forces = { 'cistern:0': 2 };
discovery.ctx.players[0].advisors = { cistern: { aloneFlipHold: discovery.source.event } };
discovery.ctx.players[1].forces = { 'carthag:11': 1 };
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(discovery.ctx, discovery.source));
const mobile = relocation();
mobile.ctx.players[1].faction = 'ixians';
mobile.ctx.mobileStronghold = { location: 'imperial_basin:9' };
mobile.source.relocation.destination = MOBILE_STRONGHOLD;
mobile.source.relocation.sector = 0;
mobile.ctx.players[1].forces = { [MOBILE_LOCATION]: 1 };
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(mobile.ctx, mobile.source));
mobile.ctx.mobileStronghold.location = null;
assert.throws(() => validateAutomaticAdvisorFlip(mobile.ctx, mobile.source), AutomaticAdvisorFlipError);
});
void test('a relocated concealed No-Field supplies effective destination presence without exposing its denomination or identity', () => {
const { ctx, source } = relocation();
source.relocation.player = 'r';
ctx.players[2].forces = {};
ctx.players[2].noField = { deployed: { location: { territory: 'carthag', sector: 11 } } };
for (const field of ['tokens', 'lastUsed', 'lastShipped', 'value']) {
Object.defineProperty(ctx.players[2].noField, field, {
get() { throw new Error(`Special relocation read hidden No-Field ${field}`); },
});
}
for (const field of ['tokenId', 'value']) {
Object.defineProperty(ctx.players[2].noField.deployed, field, {
get() { throw new Error(`Special relocation read hidden deployed No-Field ${field}`); },
});
}
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
ctx.players[2].noField.deployed = null;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
});
void test('special relocation rejects changed actor, current board locations, source binding, exact held group and private payloads', () => {
const changes: ((ctx: AutomaticAdvisorFlipContext, source: RelocationSource) => void)[] = [
(_ctx, source) => { source.relocation.event = ''; },
(_ctx, source) => { source.relocation.player = 'missing'; },
(ctx) => { ctx.players = [...ctx.players, { ...ctx.players[1] }]; },
(_ctx, source) => { source.relocation.origin = 'missing'; },
(_ctx, source) => { source.relocation.origin = 'homeworld:guild'; },
(_ctx, source) => { source.relocation.destination = 'homeworld:atreides'; },
(_ctx, source) => { source.relocation.destination = 'missing'; },
(_ctx, source) => { source.relocation.destination = 'carthag:11'; },
(_ctx, source) => { source.relocation.sector = -1; },
(_ctx, source) => { source.relocation.sector = 11.5; },
(_ctx, source) => { source.relocation.sector = 10; },
(_ctx, source) => { source.relocation.origin = 'hagga_basin'; },
(ctx) => { ctx.players[1].forces = {}; },
(ctx) => { ctx.players[1].forces = { 'arrakeen:10': 1 }; },
(ctx) => { ctx.players[0].forces = {}; },
(_ctx, source) => { source.forces = { 'imperial_basin:9': 3 }; },
];
for (const change of changes) {
const original = relocation();
change(original.ctx, original.source);
const before = structuredClone(original);
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
assert.deepEqual(original, before);
}
const { ctx, source } = relocation();
for (const field of ['group', 'noField', 'tokenId', 'order', 'skills', 'signature', 'amount']) {
for (const malformed of [
{ ...source, [field]: 'private-original-state' },
{ ...source, relocation: { ...source.relocation, [field]: 'private-original-state' } },
]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
}
for (const malformed of [
{ ...source, relocation: null },
{ ...source, relocation: { ...source.relocation, kind: 'worm' } },
{ ...source, relocation: { ...source.relocation, sector: '11' } },
]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as unknown as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
});
void test('relocation kind controls its own phases without widening any existing producer receipt or phase', () => {
for (const original of [held(), transported(), gamont(false), battleResult(), homeworldTransport(), faceDance(), storm()]) {
const before = JSON.stringify(original.source);
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
assert.equal(JSON.stringify(original.source), before);
for (const phase of [1, 3]) {
original.ctx.phase = phase;
original.source.phase = phase;
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
}
}
for (const phase of [0, 2, 4, 6, 7]) {
const original = relocation('ambassador', phase);
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
}
for (const phase of [1, 3, 5, 6, 8]) {
const original = relocation('discovery', phase);
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
}
for (const phase of [1, 3]) {
const quiet = context();
quiet.phase = phase;
assert.equal(quoteAutomaticAdvisorFlips(quiet).some((entry) => entry.territory === 'imperial_basin'), true);
quiet.status = 'setup';
assert.deepEqual(quoteAutomaticAdvisorFlips(quiet), []);
}
});
void test('completed relocation validation never reads private parent signatures, hands, skills or original allowances', () => {
for (const original of [relocation(), relocation('discovery')]) {
for (const player of original.ctx.players) {
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'reserves', 'moved', 'shipped', 'skills']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Special relocation read ${player.id} private or original ${field}`); },
});
}
}
for (const field of ['pendingAmbassador', 'pendingDiscoveryRelocation', 'pendingAutomaticAdvisorRelocation', 'movementOrder', 'leaderSkills']) {
Object.defineProperty(original.ctx, field, {
get() { throw new Error(`Special relocation read private parent ${field}`); },
});
}
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
}
});
@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { fighterCount, isAdvisor } from '../game/advisors';
import { splitLocation } from '../game/board';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { canceledResponseSource } from '../game/karama-context';
import { createAdvisorStormFlipFixture, commitAdvisorStormFlip } from './fixture-advisor-storm-flip';
import {
createAdvisorAmbassadorFlipFixture, createAdvisorDiscoveryFlipFixture,
actAdvisorRelocationFlip as act, advisorRelocationFlipPlayer as player,
advisorRelocationFlipInventory as inventory, finishAdvisorRelocationFlip as finish,
commitAdvisorRelocationFlip as commit,
} from './fixture-advisor-relocation-flip';
const fixtures = [
['ambassador', createAdvisorAmbassadorFlipFixture],
['discovery', createAdvisorDiscoveryFlipFixture],
] as const;
function allow(game: Game) {
for (let n = 0; game.response?.automaticAdvisorFlip && n < 30; n++) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id)); assert.ok(actor);
game = act(game, actor.id, { type: 'passResponse' });
}
assert.ok(!game.response?.automaticAdvisorFlip); return game;
}
const physical = (game: Game) => game.players.map(p => ({ id:p.id, forces:p.forces,
reserves:p.reserves, tanks:p.tanks, spice:p.spice, shipped:p.shipped, moved:p.moved }));
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('special relocation does not add a field to the saved source shape of an existing storm counter',()=>{
const f=createAdvisorStormFlipFixture(),pending=commitAdvisorStormFlip(f.game,f).game;
assert.ok(pending.response);
const selected=canceledResponseSource(pending,pending.response) as {source:Record<string,unknown>};
assert.deepEqual(Object.keys(selected.source).sort(),['battle','faceDance','gamont','group','homeworld','pending','source','storm','transport']);
assert.equal(JSON.stringify(selected).includes('"relocation":'),false);
});
for (const [kind, fixture] of fixtures) for (const prevented of [false,true]) {
void test(`native ${kind} ${prevented ? 'prevention' : 'allowance'} keeps the completed transfer and resumes original arrival once`, () => {
const f = fixture(), before = structuredClone(f.game), pending = commit(f.game,f).game;
assert.deepEqual(f.game,before);
const source = pending.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'specialRelocation');
assert.equal(source.relocation.kind,kind); assert.equal(source.territory,f.origin);
assert.equal(source.relocation.player,f.mover); assert.equal(source.relocation.destination,f.destination);
assert.deepEqual(Object.keys(source.relocation).sort(),['destination','event','kind','origin','player','sector']);
assert.equal(fighterCount(player(pending,f.bg),f.origin),0);
assert.equal(player(pending,f.bg).forces[f.location],1);
assert.equal(player(pending,f.mover).forces[f.from],undefined);
const amount = Object.entries(player(before,f.mover).forces).filter(([key]) => splitLocation(key).territory === f.origin)
.reduce((sum,[,count]) => sum+count,0);
assert.ok(amount > 0);
assert.equal(player(pending,f.mover).forces[f.destinationLocation],(player(before,f.mover).forces[f.destinationLocation]??0)+amount);
assert.deepEqual(pending.players.map(p => [p.spice,p.reserves,p.tanks,p.shipped,p.moved]),
before.players.map(p => [p.spice,p.reserves,p.tanks,p.shipped,p.moved]));
if (kind === 'ambassador') assert.equal(pending.pendingAmbassador?.relocation?.next,'flip');
else assert.equal(pending.discoveryEntry?.stage,'arrival');
assert.ok(viewGame(pending,f.holder).responseControls?.cancelCards.includes(f.karama));
assert.ok(!JSON.stringify(viewGame(pending,f.holder).response).includes('signature'));
const committed = physical(pending), stable = normalizeAutomaticGame(pending);
assert.deepEqual(physical(stable),committed);
assert.deepEqual(stable.pendingAutomaticAdvisorRelocation,pending.pendingAutomaticAdvisorRelocation);
let done = prevented ? act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}) : allow(pending);
done = finish(done);
assert.equal(done.pendingAutomaticAdvisorRelocation,undefined);
assert.equal(done.pendingAutomaticAdvisorFlips,undefined);
assert.ok(!done.pendingAmbassador); assert.equal(done.discoveryEntry,undefined);
assert.equal(isAdvisor(player(done,f.bg),f.origin),prevented);
assert.equal(fighterCount(player(done,f.bg),f.origin),prevented?0:1);
assert.deepEqual(physical(done),committed);
assert.equal(done.discard.filter(c => c.id === f.karama).length,Number(prevented));
assert.deepEqual(inventory(done),f.originalInventory);
assert.deepEqual(physical(normalizeAutomaticGame(done)),committed);
});
}
for (const [kind, fixture] of fixtures) void test(`${kind} rejects a changed private continuation before spending the conversion card`, () => {
const f=fixture(),pending=commit(f.game,f).game;
pending.pendingAutomaticAdvisorRelocation!.signature='different-original-relocation';
reject(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'});
assert.ok(player(pending,f.holder).hand.some(c=>c.id===f.karama));
});
void test('declining original native Discovery entry keeps the mixed parent group without a conversion',()=>{
const f=createAdvisorDiscoveryFlipFixture(),before=physical(f.game);
const done=finish(act(f.game,f.actor,{type:'decision',event:f.event,accept:false}));
assert.equal(done.pendingAutomaticAdvisorRelocation,undefined);assert.equal(done.pendingAutomaticAdvisorFlips,undefined);
assert.equal(done.discoveryEntry,undefined);assert.deepEqual(physical(done),before);
assert.equal(isAdvisor(player(done,f.bg),f.origin),true);
assert.ok(player(done,f.holder).hand.some(c=>c.id===f.karama));assert.deepEqual(inventory(done),f.originalInventory);
});
for (const prevented of [false,true]) void test(`controlled allied BG empty Ambassador arrival ${prevented?'prevention':'allowance'} has one mandatory flip and no optional retry`, () => {
const f=createAdvisorAmbassadorFlipFixture(),source=structuredClone(f.game);
// Borrow original cards, pieces, consumed token and entrant from the native
// source, but stage alliance/beneficiary selection explicitly. This is not
// evidence of a native earned alliance journey.
const owner=source.pendingAmbassador!.owner;
player(source,owner).ally=f.bg; player(source,f.bg).ally=owner;
source.pendingAmbassador!.beneficiary=f.bg; source.decision!.player=f.bg;
const action:Action={...f.action,forces:{[f.location]:1},eliteForces:{},fighters:true};
const pending=act(source,f.bg,action),automatic=pending.response?.automaticAdvisorFlip;
assert.ok(automatic?.producer==='specialRelocation');
assert.equal(automatic.territory,f.destination); assert.equal(automatic.owner,f.bg);
assert.equal(pending.pendingAmbassador?.relocation?.order.wantsFighters,false);
assert.equal(pending.pendingAmbassador?.relocation?.order.advisors,false);
assert.equal(isAdvisor(player(pending,f.bg),f.destination),true);
assert.equal(fighterCount(player(pending,f.bg),f.destination),0);
const committed=physical(pending);
const done=finish(prevented?act(pending,f.holder,{type:'card',card:f.karama,mode:'cancel'}):allow(pending));
assert.ok(!done.pendingAmbassador); assert.equal(done.pendingAutomaticAdvisorRelocation,undefined);
assert.equal(done.response?.kind==='advisorFlip',false,'An empty arrival cannot immediately retry the prevented occurrence.');
assert.equal(isAdvisor(player(done,f.bg),f.destination),prevented);
assert.deepEqual(physical(done),committed); assert.deepEqual(inventory(done),f.originalInventory);
});
void test('all four existing profiles resolve native Ambassador and Discovery conversion responses legally',()=>{
for(const [,fixture] of fixtures) for(const profile of DIFFICULTIES){
const f=fixture(),pending=commit(f.game,f).game,own=viewGame(pending,f.holder);
own.players.find(p=>p.id===f.holder)!.bot=profile;
const actions=botActions(own);assert.ok(actions.length);
const done=finish(allow(act(pending,f.holder,actions[0])));
assert.equal(done.pendingAutomaticAdvisorRelocation,undefined);
assert.deepEqual(physical(done),physical(pending));assert.deepEqual(inventory(done),f.originalInventory);
}
});
+320
View File
@@ -0,0 +1,320 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { AMBASSADOR_EFFECTS, type AmbassadorEffect } from '../game/ecaz-ambassadors';
import { DISCOVERY_CARD_PLACEMENTS, DISCOVERY_TOKEN_BY_ID } from '../game/discoveries';
import { discoveryEntryMoveAction } from '../game/discovery-entry-options';
import {
advisorPreparationClean, advisorPreparationInventory, advisorPreparationPlayer,
nextAdvisorPreparationStep,
} from './fixture-advisor-preparation';
export type AdvisorRelocationStep = { actor: string; action: Action };
export type AdvisorRelocationTransition = { before: Game; steps: AdvisorRelocationStep[]; after: Game };
export const advisorRelocationPlayer = advisorPreparationPlayer;
export const advisorRelocationInventory = advisorPreparationInventory;
export const advisorRelocationClean = advisorPreparationClean;
const cisternPlacement = DISCOVERY_CARD_PLACEMENTS['discovery-hagga-basin'];
/** Original native scalar draws only. Optional selected draws are consumed by
* one real packet/placement producer; UUIDs and byte entropy remain native. */
function originalRelocationAction(game: Game, actor: string, action: Action,
steps: AdvisorRelocationStep[], scalars: readonly number[] = []): Game {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto); let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1)
array[0] = cursor < scalars.length ? scalars[cursor++] : 0xffffffff;
else Reflect.apply(native, crypto, [array]);
return array;
};
try {
const next = applyAction(game, actor, action);
assert.equal(cursor, scalars.length, 'The actual original producer consumes its selected scalar draws');
steps.push({ actor, action: structuredClone(action) }); return next;
} finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actAdvisorRelocation(game: Game, actor: string, action: Action,
steps: AdvisorRelocationStep[] = []): Game {
return originalRelocationAction(game, actor, action, steps);
}
/** Original Ambassador choices stay owned; Discovery entries may be declined
* while seeking another seat's original offer. New conversion is never passed. */
export function nextAdvisorRelocation(game: Game): AdvisorRelocationStep | null {
if (game.response?.automaticAdvisorFlip) return null;
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
const ecaz = game.players.find(player => player.faction === 'ecaz');
if (game.status === 'setup' && game.setupStage === 'forces') {
if (ecaz?.reserves === 20)
return { actor: ecaz.id, action: { type: 'ecazSetup', placements: { 'imperial_basin:10': 6 } } };
if (!advisorRelocationPlayer(game, 'beneGesserit').advisorSetup)
return { actor: 'beneGesserit', action: { type: 'advisorSetup',
territory: ecaz ? 'imperial_basin' : cisternPlacement.territory,
sector: ecaz ? 10 : cisternPlacement.sector } };
}
const decision = game.decision;
if (decision?.kind === 'ecazPlacement' || decision?.kind === 'ecazAmbassador') return null;
if (decision?.kind === 'advisorBattle')
return { actor: decision.player, action: { type: 'decision', accept: false } };
if (decision?.kind === 'intrusion')
return { actor: decision.player, action: { type: 'decision', accept: true } };
if (decision?.kind === 'discoveryEntry')
return { actor: decision.player, action: { type: 'decision', event: decision.event, accept: false } };
}
return nextAdvisorPreparationStep(game);
}
export function advanceAdvisorRelocation(game: Game, until: (state: Game) => boolean,
steps: AdvisorRelocationStep[] = []): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The native relocation chronology ended before its requested boundary');
const next = nextAdvisorRelocation(game);
assert.ok(next, `Choose the original Ambassador/conversion at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actAdvisorRelocation(game, next.actor, next.action, steps);
}
throw Error('The native advisor relocation source did not reach its finite boundary');
}
export function passAdvisorRelocation(game: Game, steps: AdvisorRelocationStep[] = []): Game {
for (let count = 0; game.response && !game.response.automaticAdvisorFlip && count < 60; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actAdvisorRelocation(game, actor.id, { type: 'passResponse' }, steps);
}
assert.ok(!game.response || game.response.automaticAdvisorFlip); return game;
}
function fremenPacket() {
const original = AMBASSADOR_EFFECTS.filter(effect => effect !== 'ecaz');
const supply: Exclude<AmbassadorEffect, 'ecaz'>[] = ['fremen', ...original.filter(effect => effect !== 'fremen').slice(0, 4)];
const order = [...supply, ...original.filter(effect => !supply.includes(effect))];
const current = [...original], scalars: number[] = [];
for (let index = current.length - 1; index > 0; index--) {
const from = current.indexOf(order[index]); assert.ok(from >= 0 && from <= index);
scalars.push(Math.floor((from + 0.5) * 4294967296 / (index + 1)));
[current[index], current[from]] = [current[from], current[index]];
}
assert.deepEqual(current, order); assert.equal(scalars.length, 9); return { supply, order, scalars };
}
function createRelocationSetup(kind: 'ambassador' | 'discovery') {
const bg = 'beneGesserit', holder = 'atreides', other = kind === 'ambassador' ? 'ecaz' : 'emperor';
let game = createGame('ADVISORRELOCATIONFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true,
kind === 'ambassador' ? ['ecaz'] : []);
if (kind === 'ambassador') {
joinGame(game, newPlayer(other, 'Ecaz', 'ecaz')); joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
} else {
joinGame(game, newPlayer(holder, 'Atreides', 'atreides')); joinGame(game, newPlayer(other, 'Emperor', 'emperor'));
}
const initial = structuredClone(game), steps: AdvisorRelocationStep[] = [];
const act = (actor: string, action: Action) => { game = actAdvisorRelocation(game, actor, action, steps); };
if (kind === 'discovery') act(game.host, { type: 'moduleOption', module: 'discoveries', enabled: true });
const configured = structuredClone(game);
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
assert.equal(!!game.discoveryEnabled, kind === 'discovery');
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.ecazTreachery);
assert.ok(game.players.every(player => !player.hand.length));
const originalInventory = advisorRelocationInventory(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 = kind === 'ambassador' ? [ordinary[0], ordinary[1], karama] : [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), packet = kind === 'ambassador' ? fremenPacket() : null;
let packetCreation: AdvisorRelocationTransition | null = null;
let advisorSetup: AdvisorRelocationTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextAdvisorRelocation(game); assert.ok(next);
const before = structuredClone(game), start = steps.length;
const initializesForces = packet && game.setupStage === 'traitors' && next.action.type === 'traitor' &&
game.players.filter(player => player.traitorChoices.length).length === 1;
game = initializesForces
? originalRelocationAction(game, next.actor, next.action, steps, packet!.scalars)
: actAdvisorRelocation(game, next.actor, next.action, steps);
const record = { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
if (initializesForces) packetCreation = record;
if (next.action.type === 'advisorSetup') advisorSetup = record;
}
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.ok(advisorRelocationPlayer(game, holder).hand.some(card => card.id === karama.id));
if (packet) {
assert.ok(packetCreation);
assert.deepEqual(game.ecazAmbassadors!.cohort.map(id => game.ecazAmbassadors!.tokens.find(token => token.id === id)!.effect), packet.supply);
}
return { game, initial, configured, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup,
packet, packetCreation, kind, bg, holder, atreides: holder, other, karama: karama.id,
steps, actions: steps, originalInventory, dealOrder };
}
function orderOriginalSpice(game: Game, discovery: boolean) {
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 &&
(discovery && position === 0 ? card.discovery === 'discovery-hagga-basin' : !card.discovery));
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
return { originalSpice, spiceOrder };
}
/** Real Ecaz6/BG setup, paid original Fremen token and actual A entrant.
* Stop before Ecaz relocates its entire native Basin group away from BG. */
export function createAdvisorAmbassadorRelocationFixture() {
const base = createRelocationSetup('ambassador'), { bg, holder, karama, steps } = base, ecaz = base.other;
let game = base.game;
const act = (actor: string, action: Action) => { game = actAdvisorRelocation(game, actor, action, steps); };
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorRelocation(game, until, steps); };
const transition = (run: () => void): AdvisorRelocationTransition => {
const before = structuredClone(game), start = steps.length; run();
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
};
const spice = orderOriginalSpice(game, false), territory = 'imperial_basin', location = 'imperial_basin:10';
assert.equal(advisorRelocationPlayer(game, ecaz).forces[location], 6);
assert.equal(advisorRelocationPlayer(game, bg).forces[location], 1);
assert.ok(advisorRelocationPlayer(game, bg).advisors?.[territory]);
reach(state => state.phase === 4 && !state.response && !state.phaseOpening && state.decision?.kind === 'ecazPlacement');
const token = game.ecazAmbassadors!.tokens.find(candidate => candidate.effect === 'fremen')!;
assert.equal(token.zone, 'supply');
const placement = transition(() => {
act(ecaz, { type: 'decision', token: token.id, territory: 'carthag' });
game = passAdvisorRelocation(game, steps);
});
assert.equal(advisorRelocationPlayer(placement.before, ecaz).spice - advisorRelocationPlayer(game, ecaz).spice, 1);
act(ecaz, { type: 'decision', decline: true });
reach(state => state.phase === 5 && state.active === holder && advisorRelocationClean(state));
const shipment = transition(() => {
act(holder, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, elite: 0, allyPayment: 0 });
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'offer');
});
const offer = structuredClone(game), event = game.pendingAmbassador!.event;
const trigger = transition(() => {
act(ecaz, { type: 'decision', event, trigger: true, beneficiary: ecaz });
reach(state => !state.response && !state.phaseOpening && state.pendingAmbassador?.stage === 'move');
});
assert.equal(game.pendingAmbassador!.effect, 'fremen'); assert.equal(game.pendingAmbassador!.beneficiary, ecaz);
const destination = 'tueks_sietch', sector = 5;
const action: Action = { type: 'decision', event, forces: { [location]: 6 }, territory: destination, sector };
assert.ok(advisorRelocationPlayer(game, holder).hand.some(card => card.id === karama));
assert.deepEqual(advisorRelocationInventory(game), base.originalInventory);
return { ...base, ...spice, game, source: structuredClone(game), beforeAction: structuredClone(game),
ecaz, actor: ecaz, mover: ecaz, shipper: ecaz, kind: 'ambassador' as const, event, token: token.id,
placement, shipment, offer, trigger, territory, location, from: location, origin: territory,
destination, destinationLocation: `${destination}:${sector}`, to: `${destination}:${sector}`, sector, amount: 6, action,
relocationStep: { actor: ecaz, action } satisfies AdvisorRelocationStep,
sourceActionCount: steps.length, staging: [
'Actual public Advanced BG/Ecaz/Atreides ready/startPreview, native prediction, private Traitors and original base33. Exactly nine original packet draws on the last actual Traitor choice earn Fremen in Ecaz’s original supply; no token state is installed.',
'Original ecazSetup places six counters in Basin10; actual BG advisorSetup places one advisor beside them. Only three unopened Treachery positions and two original ordinary Spice positions are selected; Atreides retains its originally dealt Karama.',
'Native first Storm, both Spice blows, Charity, Bidding and Revival execute. Ecaz actually pays one to place the original Fremen token in Carthag, then ends placement. Earlier native movement turns skip without moving Basin forces.',
'Atreides actually ships one reserve to Carthag. The original entry offer really triggers the Fremen token for Ecaz and reaches its real relocation choice. Stop before moving all six original Basin counters to empty Tuek’s5, leaving the original BG advisor behind.',
'No played force, wallet, hand, alliance, phase, stance, relocation receipt or prevention is installed. The optional BG-beneficiary arrival route is excluded because it would require a separate genuine alliance chronology.',
] };
}
/** Actual Cistern placement/reveal and next-turn A entry. Only the original
* token placement lottery is selected; the parent is printed Gara Kulon8. */
export function createAdvisorDiscoveryRelocationFixture() {
const base = createRelocationSetup('discovery'), { bg, holder, karama, steps } = base;
let game = base.game;
const act = (actor: string, action: Action) => { game = actAdvisorRelocation(game, actor, action, steps); };
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorRelocation(game, until, steps); };
const transition = (run: () => void): AdvisorRelocationTransition => {
const before = structuredClone(game), start = steps.length; run();
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
};
const spice = orderOriginalSpice(game, true), territory = cisternPlacement.territory;
const location = `${territory}:${cisternPlacement.sector}`;
assert.equal(advisorRelocationPlayer(game, bg).forces[location], 1);
assert.ok(!advisorRelocationPlayer(game, bg).advisors?.[territory]);
reach(state => state.phase === 1 && advisorRelocationClean(state));
const beforeToken = structuredClone(game), tokenStart = steps.length;
const eligible = game.discoveries!.tokens.filter(token => token.status === 'supply' && token.type === DISCOVERY_TOKEN_BY_ID.cistern.type);
const selected = eligible.findIndex(token => token.face === 'cistern'); assert.ok(selected >= 0);
const tokenScalar = Math.floor((selected + 0.5) * 4294967296 / eligible.length);
for (let count = 0; !game.discoveries!.tokens.some(token => token.face === 'cistern' && token.status === 'placed') && count < 100; count++) {
const next = nextAdvisorRelocation(game); assert.ok(next);
const places = !game.response && !game.phaseOpening && !game.decision && next.action.type === 'ready' &&
game.ready.length === game.players.length - 1;
game = originalRelocationAction(game, next.actor, next.action, steps, places ? [tokenScalar] : []);
}
const token = game.discoveries!.tokens.find(candidate => candidate.face === 'cistern')!;
assert.equal(token.status, 'placed'); assert.equal(token.territory, territory);
const tokenPlacement: AdvisorRelocationTransition = {
before: beforeToken, steps: structuredClone(steps.slice(tokenStart)), after: structuredClone(game),
};
reach(state => state.phase === 5 && state.active === bg && advisorRelocationClean(state));
act(bg, { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === holder && advisorRelocationClean(state));
const shipment = transition(() => {
act(holder, { type: 'ship', territory, sector: cisternPlacement.sector, amount: 1, elite: 0, allyPayment: 0 });
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'intrusion');
});
const intrusion = transition(() => {
act(bg, { type: 'decision', accept: true });
reach(advisorRelocationClean);
});
assert.equal(advisorRelocationPlayer(shipment.before, holder).spice - advisorRelocationPlayer(game, holder).spice, 2);
assert.equal(advisorRelocationPlayer(game, holder).forces[location], 1);
assert.ok(advisorRelocationPlayer(game, bg).advisors?.[territory]);
reach(state => state.turn === 1 && state.phase === 7 && advisorRelocationClean(state));
assert.ok(viewGame(game, holder).discoveries!.canInspect.includes(token.id));
const revelation = transition(() => {
act(holder, { type: 'discovery', token: token.id, reveal: false });
act(holder, { type: 'discovery', token: token.id, reveal: true });
});
reach(state => state.turn === 2 && state.phase === 0 && !state.response && !state.phaseOpening &&
state.decision?.kind === 'discoveryEntry' && state.decision.player === holder);
const action = discoveryEntryMoveAction(viewGame(game, holder), [{ source: location, normal: 1, elite: 0 }]);
assert.ok(action); assert.ok(game.decision?.kind === 'discoveryEntry');
const event = game.decision.event;
assert.equal(advisorRelocationPlayer(game, holder).forces[location], 1);
assert.equal(advisorRelocationPlayer(game, bg).forces[location], 1);
assert.ok(advisorRelocationPlayer(game, bg).advisors?.[territory]);
assert.ok(advisorRelocationPlayer(game, holder).hand.some(card => card.id === karama));
assert.deepEqual(advisorRelocationInventory(game), base.originalInventory);
return { ...base, ...spice, game, source: structuredClone(game), beforeAction: structuredClone(game),
emperor: base.other, actor: holder, mover: holder, shipper: holder, kind: 'discovery' as const,
event, token: token.id, tokenPlacement, tokenScalar, shipment, intrusion, revelation,
territory, location, from: location, origin: territory, destination: 'cistern', destinationLocation: 'cistern:0', to: 'cistern:0', sector: 0, amount: 1,
action, relocationStep: { actor: holder, action } satisfies AdvisorRelocationStep,
sourceActionCount: steps.length, staging: [
'Actual public Advanced BG/Atreides/Emperor lobby enables only Discoveries through moduleOption, then ready/startPreview and original base33 setup. Original prediction, private Traitors and actual advisorSetup place BG’s counter in empty printed Gara Kulon8, making it a native fighter.',
'Only three original unopened Treachery positions, the original Hagga Basin Discovery Spice Card plus one ordinary Spice position, and the actual supply-token placement lottery are selected. That printed card places a real Cistern Hiereg in Gara Kulon8; no token identity, location or revealed turn is assigned.',
'Native Storm/dual Spice/Charity/Bidding/Revival run. BG actually ends movement. Atreides pays two to ship one reserve beside BG at the parent; actual accepted Intrusion and allowed ordinary advisorFlip make BG peaceful, and separate Spiritual Advisors accompaniment is declined. No battle is manufactured.',
'At real Collection, Atreides actually inspects and reveals Cistern. Remaining Collection/Mentat controls reach the original next-turn free Discovery entry. Stop before Atreides moves its sole parent counter into empty Cistern0, leaving BG’s original advisor behind.',
'Discovery entry remains restricted to non-advisor sources; BG never uses an advisor-only free entry. No played force, wallet, hand, phase, stance, revealed identity, entry frame, relocation receipt or prevention is installed.',
] };
}
export type AdvisorRelocationFixture = ReturnType<typeof createAdvisorAmbassadorRelocationFixture> |
ReturnType<typeof createAdvisorDiscoveryRelocationFixture>;
/** Commit only the real source action; the new conversion remains caller-owned. */
export function commitAdvisorRelocation(source: Game, fixture: AdvisorRelocationFixture,
steps: AdvisorRelocationStep[] = []) {
const before = structuredClone(source), start = steps.length;
const game = actAdvisorRelocation(source, fixture.actor, fixture.action, steps);
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)) };
}
/** After the explicit counter, resume only the original parent. Discovery
* stops when its entry round clears, before unrelated Storm readiness. */
export function finishAdvisorRelocation(source: Game, steps: AdvisorRelocationStep[] = []): Game {
const discovery = source.phase === 0;
return advanceAdvisorRelocation(source, game => discovery ? !game.discoveryEntry && !game.response?.automaticAdvisorFlip
: !game.pendingAmbassador && advisorRelocationClean(game), steps);
}
export const createAdvisorAmbassadorFlipFixture = createAdvisorAmbassadorRelocationFixture;
export const createAdvisorDiscoveryFlipFixture = createAdvisorDiscoveryRelocationFixture;
export const actAdvisorRelocationFlip = actAdvisorRelocation;
export const advisorRelocationFlipPlayer = advisorRelocationPlayer;
export const advisorRelocationFlipInventory = advisorRelocationInventory;
export const passAdvisorRelocationFlip = passAdvisorRelocation;
export const finishAdvisorRelocationFlip = finishAdvisorRelocation;
export const commitAdvisorRelocationFlip = commitAdvisorRelocation;