Preserve Homeworld and Junction transfers through advisor conversion
Capture surviving alone BG groups after committed native board-to-world and Junction board transfers. Keep typed payment/custody and shipment use, private original declaration snapshots and one original Junction arrival. Preserve Junction immunity independently of the BG conversion counter. Validation: 18 affected files218/218, types/lint/build, nine native transport and GameTable SSR journeys, sixteen existing-profile response paths. Genuine 37/38/42/36-action sources earn original routes and costs3/5/10/1. Rename two fixture variables rejected by lint without changing rule validation. Broader combinations/full modes remain unfinished. Not deployed; no saved-room/D1, reset or restart operation. Runtime fingerprint 5365a5d6fba829c6bcee1cb1e00977ec41769912a32180062e52af0ca59645da; checkpoint prose/reference overlays are reviewed separately.
This commit is contained in:
1 parent
d83db397c0
commit
0b3413fcd8
12 files changed
+849
-15
No files matched your search
@@ -2479,7 +2479,9 @@ export function GameTable({
|
||||
? 'Trip to Gamont is complete and its card use stays spent. These advisors are now alone and must become fighters before victory is checked.'
|
||||
: g.response.automaticAdvisorFlip.producer === 'battleLosses'
|
||||
? 'Battle losses and mandatory card disposal are complete. These surviving advisors are now alone and must become fighters before the remaining cleanup.'
|
||||
: 'The ground movement is complete. These advisors are now alone and must become fighters.'}
|
||||
: g.response.automaticAdvisorFlip.producer === 'homeworldTransport'
|
||||
? 'The Homeworld transport and payment are complete. These advisors are now alone and must become fighters before any remaining arrival.'
|
||||
: '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.
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
# Mandatory advisor conversions
|
||||
|
||||
## Homeworld and Junction transport — 24 October 2026
|
||||
|
||||
**Prototyped / Partial.** Compose physical17/28 E's
|
||||
existing counter with the separate Homeworld transport paths. Native Guild
|
||||
Arrakis-to-Homeworld shipment and board-origin Junction transport can remove the
|
||||
last outsider beside advisors. Junction can also move advisors into an empty
|
||||
board territory. Capture those groups after the actual typed force transfer,
|
||||
before occupation/settlement can erase their stance or grant fighter benefits.
|
||||
|
||||
Original transport interception, privilege, tariff, payment, custody deposit,
|
||||
shipment use and income retain their order. Cancellation of the resulting BG
|
||||
conversion reverses none of them. Junction's own permission/rate immunity remains
|
||||
distinct from the independently counterable BG advantage, per the existing
|
||||
[Junction source contract](JUNCTION_TRANSPORT_RULES.md#karama-and-offer-lifetime).
|
||||
The provisional continuously-alone hold remains unchanged.
|
||||
|
||||
The public phase5 receipt contains only a new UUID, native/Junction kind, actor,
|
||||
origin/destination and selected physical group/counts. The original declaration
|
||||
event includes a private state snapshot: retain it only in the private parent,
|
||||
alongside the exact optional Junction arrival. Public response and Karama source
|
||||
selection never copy that original event or private funding/continuation. Check
|
||||
the actual committed world deposit or board counters instead of rerunning a
|
||||
pre-transfer permission, funding or shipment-availability quote.
|
||||
|
||||
World destinations have no arrival suffix. For board-origin Junction arrival,
|
||||
consume the parent before resuming original Intrusion and territory entry once.
|
||||
Native/foreign world departures that do not alter a board advisor group retain
|
||||
their original flow. This does not broaden routes, start gates or transport
|
||||
entitlements. Broader combinations, other special arrivals, remote Face Dance
|
||||
sources and storm casualties remain unfinished.
|
||||
|
||||
[Native fixture](../tests/fixture-advisor-homeworld-flip.ts) starts public Advanced
|
||||
BG/Atreides/Guild with Homeworlds enabled by the actual lobby action and original
|
||||
base33. Actual advisorSetup shares Tuek's five Guild counters for a native return,
|
||||
or Arrakeen's ten Atreides counters for Junction. Original Atreides Karama stays
|
||||
held through real first-round controls. Native return costs3 for five; sponsored
|
||||
own-world return costs5 for ten. A real BG reserve shipment into Basin earns the
|
||||
separate destination Intrusion route, transporting ten Atreides there for10.
|
||||
Another actual Junction offer moves BG's original advisor into empty Basin for1.
|
||||
No played force, phase, hand, wallet, native reserve or stance is installed.
|
||||
|
||||
[Engine cases](../tests/advisor-homeworld-flip-engine.test.ts) cover both response
|
||||
outcomes for those four routes, original native privilege prevention, private
|
||||
source/arrival mismatch and minimal existing-profile participation. Original
|
||||
world deposits or board counters stay transferred, shipment stays used and the
|
||||
ordinary move stays unused. Frozen **18 files218/218**, types/lint/build, nine
|
||||
native transport/response/GameTable SSR journeys and sixteen existing-profile
|
||||
paths pass. Native return uses37 source actions, Junction return38, earned
|
||||
Intrusion42 and actual BG empty arrival36. Their original costs3/5/10/1 remain
|
||||
spent through both conversion outcomes. Earlier native permission prevention
|
||||
keeps the original force/payment/unused shipment. Standard controls render;
|
||||
this is not a new callback/browser/mobile/deployed certificate.
|
||||
|
||||
Initial feedback passed218/218 and types, then lint rejected two test variables
|
||||
named module. Renaming only those variables passes focused lint and the final
|
||||
frozen218/218, types/lint/build and runtime, without changing rule validation.
|
||||
Private proof and original-action HTML:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-homeworld-flips/`.
|
||||
`frozen-integrated-2/report.json` binds tree
|
||||
`5365a5d6fba829c6bcee1cb1e00977ec41769912a32180062e52af0ca59645da`
|
||||
on base `d83db397c0365421034a53315c5604ae1c525177`. Checkpoint prose/reference
|
||||
updates are separately reviewed and hashed after proof. Broader contributor/occupation/Nexus/Semuta compositions are
|
||||
not certified by these native own-world and board scenarios.
|
||||
|
||||
## Battle losses before optional cleanup — 24 October 2026
|
||||
|
||||
**Prototyped / Partial.** Compose the supplied
|
||||
|
||||
+28
-4
@@ -16,6 +16,34 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Advisor conversion after Homeworld/Junction transport — 24 October. Prototyped / Partial.**
|
||||
Native Guild board-to-world and Junction board transfers now capture resulting
|
||||
alone advisors after typed transfer/payment and before settlement benefits.
|
||||
Original privilege/rate, custody, shipment use and income retain their order.
|
||||
Only the BG conversion is prevented; Junction's own Homeworld advantage retains
|
||||
immunity. A fresh public physical receipt omits the private original declaration
|
||||
snapshot. Consume the parent before one original Junction board arrival; world
|
||||
destinations have none. The existing continuously-alone hold is unchanged.
|
||||
[Source, native routes and limits](ADVISOR_AUTOMATIC_FLIPS.md#homeworld-and-junction-transport--24-october-2026).
|
||||
|
||||
Disjoint helper/native-fixture lanes, bounded timing review and root integration
|
||||
pass **18 files218/218**, types/lint/build, nine native transport/response/
|
||||
GameTable SSR journeys and sixteen existing-profile paths. Genuine37/38/42/36-
|
||||
action public Advanced Homeworld sources earn native return, Junction own-world
|
||||
return, actual destination Intrusion and BG advisor arrival into empty Basin.
|
||||
Costs3/5/10/1 remain spent through either conversion outcome; original native
|
||||
privilege prevention still leaves the transport uncommitted. Lint feedback
|
||||
renames two fixture variables without relaxing checks or rule validation.
|
||||
Wider contributor/occupation/Nexus/Semuta combinations and remaining automatic
|
||||
producers stay open. No AI refinement, comprehensive/browser/deployed assurance
|
||||
or saved-room/D1/reset/restart/deployment operation.
|
||||
|
||||
Next concrete conversion gaps include remote Face Dance source departures,
|
||||
sector-specific storm losses and special Ambassador/Discovery relocation.
|
||||
Harass withdrawal and Diplomat retreat origin already use the battle capture
|
||||
boundary; they do not need duplicate producers. Matching-group interpretation
|
||||
and complete Advanced/mode acceptance remain unfinished.
|
||||
|
||||
**Advisor conversion after battle losses — 24 October. Prototyped / Partial.**
|
||||
Actual battle losses can now leave surviving bystanding advisors in a sourced
|
||||
Karama conversion response. Preserve casualties, payments, deaths and mandatory
|
||||
@@ -40,10 +68,6 @@ reinforcement/replacement/Semuta combinations and other producers remain open.
|
||||
No AI refinement, comprehensive/browser/deployed assurance or saved-room/D1/
|
||||
reset/restart/deployment operation.
|
||||
|
||||
Next native conversion work includes other withdrawals, special arrivals and
|
||||
matching-group flips. Battle, ground, transport and Gamont proofs do not certify
|
||||
all causes or complete Advanced modes.
|
||||
|
||||
**Advisor conversion after Trip to Gamont — 24 October. Prototyped / Partial.**
|
||||
Gamont now completes its original card use and return/revelation before the
|
||||
resulting mandatory advisor-conversion counter. Preventing conversion preserves
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 24 October 2026 — Advisor conversion after Homeworld/Junction transfer
|
||||
|
||||
Native Guild board-to-world and board-origin Junction transport now hold
|
||||
resulting alone BG groups after typed payment/transfer and before occupation or
|
||||
fighter benefits. Preserve original interception/privilege/rate, custody,
|
||||
shipment use, income and ordinary movement. Junction's Homeworld permission/
|
||||
tariff remains immune while the separate BG advantage can be countered. The
|
||||
existing provisional continuously-alone duration is unchanged.
|
||||
|
||||
An eight-field public receipt uses a fresh UUID and physical transfer metadata.
|
||||
The original declaration event contains a private state snapshot; keep it and
|
||||
the exact optional arrival only in the private parent. Check committed world
|
||||
custody/board counters rather than rerunning original pre-transfer legality.
|
||||
Consume the parent before original Junction Intrusion/territory entry once;
|
||||
world destinations have no arrival. No transport route or start gate opens.
|
||||
|
||||
Disjoint helper/native-fixture lanes, bounded timing review and root integration
|
||||
pass **18 files218/218**, types/lint/build, nine actual native transport/response/
|
||||
GameTable SSR journeys and sixteen legal existing-profile paths. Public Advanced
|
||||
BG/Atreides/Guild enables Homeworlds through actual lobby controls, preserving
|
||||
base33 and original physical setup/Karama. Sources take37 native return,38
|
||||
Junction return,42 earned Intrusion and36 BG advisor-arrival actions. Actual
|
||||
costs3/5/10/1 remain spent through both conversion outcomes. Original native
|
||||
permission prevention leaves force, payment and shipment uncommitted. No played
|
||||
army, wallet, hand, phase, native reserve, stance or receipt is installed.
|
||||
|
||||
Initial218/218 and types passed; lint rejected two test variables named module.
|
||||
Their scoped rename passes focused lint and the final frozen218/218, quick,
|
||||
build and runtime. No rule guard, source shape or lint check is weakened.
|
||||
Private proof and original-action HTML:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-homeworld-flips/`.
|
||||
Frozen `frozen-integrated-2/report.json` binds tree
|
||||
`5365a5d6fba829c6bcee1cb1e00977ec41769912a32180062e52af0ca59645da`,
|
||||
2741 files on base `d83db397c0365421034a53315c5604ae1c525177`.
|
||||
Prose/reference overlays are separately reviewed/hashed. Standard controls
|
||||
render; no new callback/browser/mobile/deployed or complete-mode claim. Wider
|
||||
contributor/occupation/Nexus/Semuta compositions, remote Face Dance/storm/other
|
||||
producers, comprehensive assurance and full AI refinement remain due. No saved-
|
||||
room/D1, reset, restart or deployment operation.
|
||||
[Connected behavior and limits](ADVISOR_AUTOMATIC_FLIPS.md#homeworld-and-junction-transport--24-october-2026).
|
||||
|
||||
## 24 October 2026 — Advisor conversion after committed battle losses
|
||||
|
||||
Supplied Advanced physical17/28 E now compose at native battle loss boundaries.
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# Rule decision index
|
||||
|
||||
## Advisor conversion after Homeworld transport — 24 October 2026
|
||||
|
||||
Extend the existing physical17/28 E composition to native Guild board-to-world
|
||||
and board-origin Junction transfers. Capture surviving alone groups only after
|
||||
the typed transfer, preserving original interception/privilege/rate, payment,
|
||||
custody, shipment use and income. Junction's own Homeworld permission/tariff
|
||||
immunity does not make the separate BG conversion advantage immune. Keep the
|
||||
existing provisional continuously-alone duration. A fresh public UUID/physical
|
||||
receipt omits the private original declaration snapshot. Consume the private
|
||||
parent before resuming a Junction board arrival once; world destinations have
|
||||
no arrival. No original pre-transfer eligibility is rerun after commitment.
|
||||
[Connected source and continuation](ADVISOR_AUTOMATIC_FLIPS.md#homeworld-and-junction-transport--24-october-2026).
|
||||
|
||||
## Advisor conversion after battle losses — 24 October 2026
|
||||
|
||||
Compose supplied Advanced physical17's group counter with physical28 E's alone
|
||||
|
||||
@@ -18,6 +18,17 @@ export type AutomaticAdvisorTransport = {
|
||||
group: Record<string, number>;
|
||||
};
|
||||
|
||||
export type AutomaticAdvisorHomeworldTransport = {
|
||||
event: string;
|
||||
kind: 'native' | 'junction';
|
||||
player: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
amount: number;
|
||||
elite: number;
|
||||
group: Record<string, number>;
|
||||
};
|
||||
|
||||
export type AutomaticAdvisorGamont = {
|
||||
event: string;
|
||||
player: string;
|
||||
@@ -47,6 +58,7 @@ export type AutomaticAdvisorFlipSource = {
|
||||
} & (
|
||||
| { producer: 'groundMovement'; mover: string; move: number }
|
||||
| { producer: 'guildTransport'; transport: AutomaticAdvisorTransport }
|
||||
| { producer: 'homeworldTransport'; transport: AutomaticAdvisorHomeworldTransport }
|
||||
| { producer: 'gamont'; gamont: AutomaticAdvisorGamont }
|
||||
| { producer: 'battleLosses'; battle: AutomaticAdvisorBattle }
|
||||
);
|
||||
@@ -79,6 +91,7 @@ export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & MobileBoard
|
||||
result: string;
|
||||
} | null;
|
||||
battle?: unknown;
|
||||
homeworlds?: unknown;
|
||||
};
|
||||
|
||||
export class AutomaticAdvisorFlipError extends Error {
|
||||
@@ -218,6 +231,50 @@ function validateGamontSource(
|
||||
'Automatic advisor conversion lost its final CHOAM Mentat opportunity.');
|
||||
}
|
||||
|
||||
function validateHomeworldTransportSource(
|
||||
ctx: AutomaticAdvisorFlipContext,
|
||||
source: Extract<AutomaticAdvisorFlipSource, { producer: 'homeworldTransport' }>,
|
||||
) {
|
||||
const transport = source.transport;
|
||||
requireFlip(record(ctx.homeworlds) && record(ctx.homeworlds.custody) && record(transport) &&
|
||||
Object.keys(transport).sort().join(',') === 'amount,destination,elite,event,group,kind,origin,player' &&
|
||||
identifier(transport.event) && (transport.kind === 'native' || transport.kind === 'junction') &&
|
||||
identifier(transport.player) && identifier(transport.origin) && identifier(transport.destination) &&
|
||||
positive(transport.amount) && nonnegative(transport.elite) && transport.elite <= transport.amount &&
|
||||
record(transport.group) && Object.values(transport.group).every(positive) &&
|
||||
Object.values(transport.group).reduce((total, count) => total + count, 0) === transport.amount,
|
||||
'Automatic advisor conversion lost its public Homeworld transport receipt or module.');
|
||||
const actors = ctx.players.filter((player) => player.id === transport.player);
|
||||
const actor = actors[0];
|
||||
requireFlip(actors.length === 1 && actor.shipped === true,
|
||||
'Automatic advisor conversion lost its completed Homeworld transport slot.');
|
||||
const places = gameTerritories(ctx);
|
||||
const origin = places.find((place) => place.id === transport.origin);
|
||||
const point = splitLocation(transport.destination);
|
||||
const boardDestination = places.find((place) =>
|
||||
transport.destination === `${point.territory}:${point.sector}` &&
|
||||
place.id === point.territory && place.sectors.includes(point.sector));
|
||||
const worldDestination = ctx.players.some((player) =>
|
||||
transport.destination === `homeworld:${player.faction}` ||
|
||||
(player.faction === 'emperor' && transport.destination === 'homeworld:emperor:salusa'));
|
||||
requireFlip(origin && Object.keys(transport.group).every((key) => {
|
||||
const selected = splitLocation(key);
|
||||
return key === `${selected.territory}:${selected.sector}` && selected.territory === transport.origin &&
|
||||
origin.sectors.includes(selected.sector);
|
||||
}) && (source.territory === transport.origin ||
|
||||
(boardDestination && source.territory === boardDestination.id)) &&
|
||||
(transport.kind === 'native'
|
||||
? actor.faction === 'guild' && worldDestination
|
||||
: ctx.players.filter((player) => player.faction === 'guild').length === 1 &&
|
||||
(worldDestination || !!boardDestination)),
|
||||
'Automatic advisor conversion needs the original native or Junction Homeworld transport locations.');
|
||||
if (boardDestination) {
|
||||
const deposited = actor.forces[transport.destination];
|
||||
requireFlip(nonnegative(deposited) && deposited >= transport.amount,
|
||||
'Automatic advisor conversion lost the Homeworld transport board deposit.');
|
||||
}
|
||||
}
|
||||
|
||||
function validateBattleSource(
|
||||
ctx: AutomaticAdvisorFlipContext,
|
||||
source: Extract<AutomaticAdvisorFlipSource, { producer: 'battleLosses' }>,
|
||||
@@ -256,6 +313,8 @@ export function validateAutomaticAdvisorFlip(
|
||||
Object.keys(source).sort().join(',') === 'event,forces,move,mover,owner,phase,producer,territory,turn') ||
|
||||
(source.producer === 'guildTransport' &&
|
||||
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,territory,transport,turn') ||
|
||||
(source.producer === 'homeworldTransport' &&
|
||||
Object.keys(source).sort().join(',') === 'event,forces,owner,phase,producer,territory,transport,turn') ||
|
||||
(source.producer === 'gamont' &&
|
||||
Object.keys(source).sort().join(',') === 'event,forces,gamont,owner,phase,producer,territory,turn') ||
|
||||
(source.producer === 'battleLosses' &&
|
||||
@@ -277,6 +336,7 @@ export function validateAutomaticAdvisorFlip(
|
||||
requireFlip(movers.length === 1 && positive(source.move) && movers[0].moved === source.move,
|
||||
'Automatic advisor conversion lost its original completed movement.');
|
||||
} else if (source.producer === 'guildTransport') validateTransportSource(ctx, source);
|
||||
else if (source.producer === 'homeworldTransport') validateHomeworldTransportSource(ctx, source);
|
||||
else if (source.producer === 'gamont') validateGamontSource(ctx, source);
|
||||
else validateBattleSource(ctx, source);
|
||||
const forces = physicalGroup(owner, source.territory);
|
||||
|
||||
+96
-5
@@ -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 } from './advisor-automatic-flip';
|
||||
import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport, type AutomaticAdvisorGamont, type AutomaticAdvisorBattle, type AutomaticAdvisorHomeworldTransport } 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';
|
||||
@@ -1270,6 +1270,12 @@ export type Game = {
|
||||
resume: { kind: 'winner'; player: string; territory: string; cards: string[] } | { kind: 'aftermath' } | null;
|
||||
signature?: string;
|
||||
};
|
||||
pendingAutomaticAdvisorHomeworldTransport?: {
|
||||
receipt: AutomaticAdvisorHomeworldTransport;
|
||||
sourceEvent: string;
|
||||
arrival?: { territory: string; sector: number; amount: number; elite: number };
|
||||
signature: string;
|
||||
};
|
||||
advisorPreparation?: AdvisorPreparationWindow;
|
||||
ecazOccupyPreview?: boolean;
|
||||
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
|
||||
@@ -20310,6 +20316,12 @@ function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) {
|
||||
'The mandatory conversion has lost its original completed battle.');
|
||||
validateBattleAdvisorResume(g, parent);
|
||||
}
|
||||
if (source.producer === 'homeworldTransport') {
|
||||
const parent = g.pendingAutomaticAdvisorHomeworldTransport;
|
||||
requireRule(parent && JSON.stringify(source.transport) === JSON.stringify(parent.receipt),
|
||||
'The mandatory conversion has lost its original committed Homeworld transport.');
|
||||
validateHomeworldAdvisorTransport(g, parent);
|
||||
}
|
||||
automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source));
|
||||
return source;
|
||||
}
|
||||
@@ -20461,6 +20473,66 @@ function finishAutomaticAdvisorBattle(g: Game) {
|
||||
finishWinner(g, getPlayer(g, resume.player), resume.territory, resume.cards);
|
||||
else finishBattle(g);
|
||||
}
|
||||
function homeworldAdvisorTransportSignature(parent: NonNullable<Game['pendingAutomaticAdvisorHomeworldTransport']>) {
|
||||
return JSON.stringify([parent.receipt, parent.sourceEvent, parent.arrival ?? null]);
|
||||
}
|
||||
function prepareHomeworldAdvisorFlips(g: Game, transport: Omit<AutomaticAdvisorHomeworldTransport, 'event'>,
|
||||
sourceEvent: string, arrival?: NonNullable<Game['pendingAutomaticAdvisorHomeworldTransport']>['arrival']) {
|
||||
const destination = transport.destination.startsWith('homeworld:') ? null : splitLocation(transport.destination).territory;
|
||||
const groups = automaticAdvisorRule(() => quoteAutomaticAdvisorFlips(g))
|
||||
.filter(group => group.territory === transport.origin || group.territory === destination);
|
||||
if (!groups.length) return false;
|
||||
requireRule(!g.pendingAutomaticAdvisorFlips?.length && !g.pendingAutomaticAdvisorHomeworldTransport &&
|
||||
!g.pendingAutomaticAdvisorTransport && !g.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle,
|
||||
'Finish the original advisor conversion before another Homeworld transport.');
|
||||
// The original declaration event includes a private snapshot. Only this new
|
||||
// public UUID and physical transfer metadata enter the conversion response.
|
||||
const receipt: AutomaticAdvisorHomeworldTransport = { event: crypto.randomUUID(), ...transport };
|
||||
const parent: NonNullable<Game['pendingAutomaticAdvisorHomeworldTransport']> = {
|
||||
receipt, sourceEvent, ...(arrival ? { arrival: { ...arrival } } : {}), signature: '',
|
||||
};
|
||||
parent.signature = homeworldAdvisorTransportSignature(parent);
|
||||
g.pendingAutomaticAdvisorHomeworldTransport = 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: 'homeworldTransport', transport: receipt };
|
||||
getPlayer(g, group.player).advisors![group.territory].aloneFlipHold = source.event;
|
||||
(g.pendingAutomaticAdvisorFlips ??= []).push(source);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function validateHomeworldAdvisorTransport(g: Game, parent: NonNullable<Game['pendingAutomaticAdvisorHomeworldTransport']>) {
|
||||
const { receipt, arrival } = parent;
|
||||
const actor = getPlayer(g, receipt.player);
|
||||
requireRule(g.status === 'playing' && g.phase === 5 && g.homeworlds?.custody && actor.shipped &&
|
||||
typeof parent.sourceEvent === 'string' && parent.sourceEvent.length > 0 &&
|
||||
parent.signature === homeworldAdvisorTransportSignature(parent),
|
||||
'The committed Homeworld transport lost its original private continuation.');
|
||||
if (receipt.destination.startsWith('homeworld:')) {
|
||||
const deposit = homeworldRule(() => homeworldForceGroups(homeworldContext(g), g.homeworlds!.custody!))
|
||||
.find(world => world.id === receipt.destination)?.forces[actor.id];
|
||||
requireRule(!arrival && deposit && deposit.normal + deposit.elite >= receipt.amount && deposit.elite >= receipt.elite,
|
||||
'The original Homeworld transport lost its deposited physical counters.');
|
||||
} else requireRule(receipt.kind === 'junction' && arrival &&
|
||||
location(arrival.territory, arrival.sector) === receipt.destination &&
|
||||
arrival.amount === receipt.amount && arrival.elite === receipt.elite,
|
||||
'The committed Junction transport lost its original board arrival.');
|
||||
}
|
||||
function finishAutomaticAdvisorHomeworldTransport(g: Game) {
|
||||
const parent = g.pendingAutomaticAdvisorHomeworldTransport;
|
||||
if (!parent) return;
|
||||
requireRule(!g.pendingAutomaticAdvisorFlips?.length,
|
||||
'Finish the original advisor conversions before resuming Homeworld transport.');
|
||||
validateHomeworldAdvisorTransport(g, parent);
|
||||
delete g.pendingAutomaticAdvisorHomeworldTransport;
|
||||
if (parent.arrival) {
|
||||
const { territory: to, sector, amount, elite } = parent.arrival;
|
||||
const actor = getPlayer(g, parent.receipt.player);
|
||||
intrusion(g, actor, to);
|
||||
openTerritoryEntry(g, actor, to, sector, amount, elite, 'shipment');
|
||||
}
|
||||
}
|
||||
function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) {
|
||||
for (const entry of released)
|
||||
delete getPlayer(g, entry.player).advisors?.[entry.territory];
|
||||
@@ -22480,7 +22552,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.pendingAutomaticAdvisorGamont && !g.pendingAutomaticAdvisorBattle && !g.pendingAutomaticAdvisorHomeworldTransport,
|
||||
'Resolve mandatory advisor conversions before checking victory.');
|
||||
quote ??= currentVictoryQuote(g);
|
||||
applyAdvisorReleases(g, quote.released);
|
||||
@@ -28302,7 +28374,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' ? 'transport and payment stay' : automaticAdvisorFlip.producer === 'gamont' ? 'Trip to Gamont effect and card use stay' : automaticAdvisorFlip.producer === 'battleLosses' ? 'battle losses and costs 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' : '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;
|
||||
@@ -28310,6 +28382,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
finishAutomaticAdvisorTransport(g);
|
||||
finishAutomaticAdvisorGamont(g);
|
||||
finishAutomaticAdvisorBattle(g);
|
||||
finishAutomaticAdvisorHomeworldTransport(g);
|
||||
} else openAutomaticAdvisorFlip(g);
|
||||
} else finishAdvisorReaction(g, response);
|
||||
}
|
||||
@@ -28758,6 +28831,9 @@ function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransp
|
||||
const {option, rate, quote, arrival, nativeDeparture, promise, sourceLock, advisors, allyPayment, destination} =
|
||||
quoteJunctionDeclaration(g, p, declaration);
|
||||
if (!settlement && beginGuildBetrayal(g, {source: 'junction', transport: declaration})) return;
|
||||
const advisorArrival = advisors || (quote.originKind === 'arrakis' && !!arrival &&
|
||||
g.advanced && p.faction === 'beneGesserit' && isAdvisor(p, quote.origin) &&
|
||||
!at(p, arrival.territory) && !g.players.some(other => other.id !== p.id && at(other, arrival.territory)));
|
||||
// Junction permission and tariff are Homeworld effects and resist Karama.
|
||||
// All validation precedes this atomic settlement; there is no special-stop frame.
|
||||
payWithAlly(g, p, quote.cost, allyPayment);
|
||||
@@ -28770,14 +28846,19 @@ function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransp
|
||||
owner.reserves = seat.reserves;
|
||||
if (owner.elites) owner.elites.reserves = seat.eliteReserves;
|
||||
}
|
||||
observeOccupation(g);
|
||||
if (arrival) {
|
||||
place(p, arrival.territory, arrival.sector, quote.amount, quote.elite);
|
||||
if (advisors) {
|
||||
if (advisorArrival) {
|
||||
const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[arrival.territory]?.lockedTurn ?? 0);
|
||||
(p.advisors ??= {})[arrival.territory] = { ...p.advisors?.[arrival.territory], ...(lockedTurn ? { lockedTurn } : {}) };
|
||||
} else if (p.advisors) delete p.advisors[arrival.territory];
|
||||
}
|
||||
const heldConversion = quote.originKind === 'arrakis' && prepareHomeworldAdvisorFlips(g,
|
||||
{ kind: 'junction', player: p.id, origin: quote.origin, destination, amount: quote.amount, elite: quote.elite,
|
||||
group: Object.fromEntries(quote.sources.map(source => [source.key,
|
||||
source.before.normal + source.before.elite - source.after.normal - source.after.elite])) },
|
||||
declaration.event, arrival ? { ...arrival, amount: quote.amount, elite: quote.elite } : undefined);
|
||||
observeOccupation(g);
|
||||
p.shipped = true;
|
||||
closeAdvisorPreparationOnShipment(g, p.id, 'junction');
|
||||
if (declaration.forfeitPurchasedRate) g.karamaShipping = null;
|
||||
@@ -28800,6 +28881,10 @@ function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransp
|
||||
quote.sources.map((s) => combatLocationName(g, s.key)).join(' and ');
|
||||
log(g, `${p.name} accepted ${getPlayer(g, option.owner).name}’s Junction ${rate}-price offer and transported ${quote.amount} physical forces (${quote.elite} special) from ${sourceName} to ${arrival ? `${territory(arrival.territory).name}, sector ${arrival.sector}` : combatLocationName(g, destination)} for ${quote.cost} spice (${quote.cost - allyPayment} own, ${allyPayment} pledged). The shipment is used; movement remains available.${declaration.forfeitPurchasedRate ? ' The separate purchased Karama price ended unused; no card is refunded.' : ''}`,
|
||||
{faction: 'guild', name: 'Junction transport'});
|
||||
if (heldConversion) {
|
||||
openAutomaticAdvisorFlip(g);
|
||||
return;
|
||||
}
|
||||
if (arrival) {
|
||||
const bg = byFaction(g, 'beneGesserit');
|
||||
const followup = offPlanet && bg && bg.id !== p.id && spiritualAdvisorMaximum(g, bg.id) > 0
|
||||
@@ -28926,6 +29011,11 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
|
||||
owner.reserves = seat.reserves;
|
||||
if (owner.elites) owner.elites.reserves = seat.eliteReserves;
|
||||
}
|
||||
const heldConversion = shipment.route === 'arrakis' && prepareHomeworldAdvisorFlips(g,
|
||||
{ kind: 'native', player: p.id, origin: splitLocation(Object.keys(shipment.sources)[0]).territory,
|
||||
destination: shipment.destination, amount: quote.amount, elite: quote.elite,
|
||||
group: Object.fromEntries(Object.entries(shipment.sources).map(([key, selected]) =>
|
||||
[key, selected.normal + selected.elite])) }, shipment.event);
|
||||
observeOccupation(g);
|
||||
p.shipped = true;
|
||||
closeAdvisorPreparationOnShipment(g, p.id, 'homeworld');
|
||||
@@ -28945,6 +29035,7 @@ function commitHomeworldShipment(g: Game, shipment: PendingHomeworldShipment, se
|
||||
if (purchasedRate) g.karamaShipping = null;
|
||||
log(g, `${p.name} shipped ${quote.amount} physical forces (${quote.elite} special) from ${quote.sourceNames.join(' and ')} to ${combatLocationName(g, shipment.destination)} for ${quote.cost} spice (${quote.cost - shipment.allyPayment} own, ${shipment.allyPayment} pledged). This uses their shipment; movement remains available.${shipment.guildSecretEvent ? ' Guild Secret Ally is spent for this half-price shipment; the payment goes to the bank.' : ''}`,
|
||||
{faction: shipment.guildSecretEvent ? 'guild' : p.faction, name: shipment.guildSecretEvent ? 'Secret Ally Homeworld shipment' : 'Homeworld shipment'});
|
||||
if (heldConversion) openAutomaticAdvisorFlip(g);
|
||||
}
|
||||
function declareHomeworldShipment(g: Game, p: Player, intent: HomeworldShipmentIntent & {route?: 'arrakis';guildSecretEvent?:string}, allyPayment?: unknown) {
|
||||
const quote = homeworldShipmentQuote(g, intent);
|
||||
|
||||
@@ -184,6 +184,7 @@ const cancellationSources = {
|
||||
transport: response.automaticAdvisorFlip.producer === 'guildTransport' ? g.pendingAutomaticAdvisorTransport ?? null : null,
|
||||
gamont: response.automaticAdvisorFlip.producer === 'gamont' ? g.pendingAutomaticAdvisorGamont?.receipt ?? null : null,
|
||||
battle: response.automaticAdvisorFlip.producer === 'battleLosses' ? g.pendingAutomaticAdvisorBattle?.receipt ?? null : null,
|
||||
homeworld: response.automaticAdvisorFlip.producer === 'homeworldTransport' ? g.pendingAutomaticAdvisorHomeworldTransport?.receipt ?? null : null,
|
||||
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
|
||||
: response.advisorPreparationSource
|
||||
? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
|
||||
|
||||
+10
-5
@@ -4316,6 +4316,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'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.',
|
||||
'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. Other non-ground automatic conversions and matching-group flip counters 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.',
|
||||
@@ -4327,7 +4328,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Implementation",
|
||||
"status": "Partial",
|
||||
"detail": "Ground movement, committed Guild transport, Gamont and actual battle losses now have distinct event-bound mandatory-conversion sources. Shared settlement preserves prevented continuously-alone groups. Battle receipts select public identity/outcome only; a private cursor resumes original winner/aftermath after mandatory disposal, skills and substitution without replaying casualties. Transport retains paid transfer and one arrival; Gamont defers the original final chooser. Phases2–5 preparation retains its own window and per-territory prevention. Other automatic producers and matching-group counters remain unfinished.",
|
||||
"detail": "Homeworld/Junction board transfers now capture alone groups before occupation/settlement, retain completed typed transfer/payment and shipment use, keep original snapshot events private and resume only original Junction board arrival after the consumed parent. Existing ground/Guild transport/Gamont/battle/preparation sources retain separate exact receipts and timing. Other automatic producers and matching-group counters remain unfinished.",
|
||||
"evidence": [
|
||||
"game/advisor-automatic-flip.ts",
|
||||
"game/advisors.ts",
|
||||
@@ -4342,7 +4343,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Player controls",
|
||||
"status": "Partial",
|
||||
"detail": "Standard Karama/Allow controls explain completed movement, paid transport, Gamont card use or battle losses and the provisional continuously-alone hold. Battle guidance distinguishes committed casualties from remaining cleanup. Existing preparation controls remain available at quiet phases2–5 boundaries, including after initial decline; first actual shipment closes that window. No new confirmation or timing delay.",
|
||||
"detail": "Standard Karama/Allow controls distinguish completed Homeworld transport/payment from the pending BG conversion and remaining arrival. Existing ground, ordinary transport, Gamont, battle and phase2–5 preparation guidance retain their contracts. No new confirmation or timer.",
|
||||
"evidence": [
|
||||
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
|
||||
"components/advisor-preparation.tsx",
|
||||
@@ -4353,8 +4354,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "AI",
|
||||
"status": "Partial",
|
||||
"detail": "Eight existing-profile native winner-dial-all/mutual-Traitor response paths resolve legally to actual Collection without replay. Existing ground, transport, Gamont and preparation paths retain their separate evidence. No strategy refinement or difficulty calibration.",
|
||||
"detail": "Sixteen existing-profile native Homeworld/Junction response paths accept legal actions without replaying transfer/payment or consuming ordinary movement. Prior ground/Guild transport/Gamont/battle/preparation paths retain separate evidence. No strategy refinement or difficulty calibration.",
|
||||
"evidence": [
|
||||
"tests/advisor-homeworld-flip-engine.test.ts",
|
||||
"tests/fixture-advisor-homeworld-flip.ts",
|
||||
"tests/advisor-battle-flip-engine.test.ts",
|
||||
"tests/fixture-advisor-battle-flip.ts",
|
||||
"tests/advisor-gamont-flip-engine.test.ts",
|
||||
@@ -4367,7 +4370,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Documentation",
|
||||
"status": "Partial",
|
||||
"detail": "Source physical17/28 E composes the group counter with mandatory conversion. Continuously-alone duration and battle cleanup priority are explicitly provisional. Public receipts, private continuation boundaries, native scenario provenance and remaining producers/combinations are distinguished. Preparation and supported Ecaz coexistence keep their separate contracts.",
|
||||
"detail": "Physical17/28 E composes the group counter with mandatory conversion. Homeworld/Junction guidance distinguishes immune transport from BG conversion, private original snapshots, public physical evidence and one original arrival. Continuously-alone duration and earlier battle priority remain provisional. Remaining producers and combinations stay explicit.",
|
||||
"evidence": [
|
||||
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
|
||||
"docs/ADVISOR_PREPARATION.md",
|
||||
@@ -4378,8 +4381,10 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Verification",
|
||||
"status": "Partial",
|
||||
"detail": "Battle conversion passes frozen16-file264/264, types/lint/build, five native battle/response/GameTable SSR journeys and eight existing-profile paths. Genuine44-action public sources earn original forces/cards and actual two-versus-zero city income; Lasgun/Shield destroys the advisor without a counter. Stale Semuta fixtures now submit existing final acknowledgements; production guards stay unchanged. Earlier ground/transport/Gamont/preparation evidence is in linked contracts. Full skill/Caladan/Face Dance/Semuta combinations, remaining producers, complete modes, browser/mobile/deployed and comprehensive assurance stay open.",
|
||||
"detail": "Homeworld/Junction passes frozen18-file218/218, types/lint/build, nine native transport/response/GameTable SSR journeys and sixteen profile paths. Genuine37/38/42/36-action sources earn original native/Junction returns, destination Intrusion and BG empty arrival. Costs3/5/10/1 remain spent through both outcomes; earlier native permission prevention stays uncommitted. A fixture-variable lint repair preserves strict source validation. Earlier producer proofs remain linked; wider contributor/occupation/Nexus/Semuta, remote Face Dance/storm/other producers, full modes and browser/mobile/deployed/comprehensive acceptance remain open.",
|
||||
"evidence": [
|
||||
"tests/advisor-homeworld-flip-engine.test.ts",
|
||||
"tests/fixture-advisor-homeworld-flip.ts",
|
||||
"tests/advisor-battle-flip-engine.test.ts",
|
||||
"tests/fixture-advisor-battle-flip.ts",
|
||||
"tests/advisor-gamont-flip-engine.test.ts",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type AutomaticAdvisorTransport,
|
||||
type AutomaticAdvisorGamont,
|
||||
type AutomaticAdvisorBattle,
|
||||
type AutomaticAdvisorHomeworldTransport,
|
||||
} from '../game/advisor-automatic-flip';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
|
||||
|
||||
@@ -18,6 +19,7 @@ type GroundSource = Extract<AutomaticAdvisorFlipSource, { producer: 'groundMovem
|
||||
type TransportSource = Extract<AutomaticAdvisorFlipSource, { producer: 'guildTransport' }>;
|
||||
type GamontSource = Extract<AutomaticAdvisorFlipSource, { producer: 'gamont' }>;
|
||||
type BattleSource = Extract<AutomaticAdvisorFlipSource, { producer: 'battleLosses' }>;
|
||||
type HomeworldSource = Extract<AutomaticAdvisorFlipSource, { producer: 'homeworldTransport' }>;
|
||||
|
||||
function context(): AutomaticAdvisorFlipContext {
|
||||
return {
|
||||
@@ -632,3 +634,175 @@ void test('battle validation reads only its six public context fields and the cu
|
||||
ctx.battle = false;
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
|
||||
});
|
||||
|
||||
function homeworldTransport(
|
||||
kind: AutomaticAdvisorHomeworldTransport['kind'] = 'native',
|
||||
destination = 'homeworld:richese',
|
||||
) {
|
||||
const { ctx, source: ground } = held();
|
||||
ctx.homeworlds = { custody: { visitors: {}, salusa: null } };
|
||||
ctx.players[1].faction = kind === 'native' ? 'guild' : 'atreides';
|
||||
ctx.players[1].shipped = true;
|
||||
ctx.players[1].forces = destination.startsWith('homeworld:') ? {} : { [destination]: 3 };
|
||||
if (kind === 'junction') ctx.players = [...ctx.players, { id: 'g', faction: 'guild', forces: {} }];
|
||||
const transport: AutomaticAdvisorHomeworldTransport = {
|
||||
event: 'fresh-public-homeworld-transport-event', kind, player: 'a', origin: 'imperial_basin',
|
||||
destination, amount: 3, elite: 0, group: { 'imperial_basin:9': 2, 'imperial_basin:10': 1 },
|
||||
};
|
||||
const source: HomeworldSource = {
|
||||
event: 'automatic-after-original-homeworld-transport', owner: ground.owner,
|
||||
territory: ground.territory, turn: ground.turn, phase: 5,
|
||||
forces: ground.forces, producer: 'homeworldTransport', transport,
|
||||
};
|
||||
ctx.players[0].advisors = {
|
||||
...ctx.players[0].advisors,
|
||||
imperial_basin: { lockedTurn: 4, aloneFlipHold: source.event },
|
||||
};
|
||||
return { ctx, source };
|
||||
}
|
||||
|
||||
void test('native Guild and Junction Homeworld transports retain exact public source shapes and current native destinations', () => {
|
||||
for (const original of [
|
||||
homeworldTransport(), homeworldTransport('native', 'homeworld:guild'),
|
||||
homeworldTransport('junction'), homeworldTransport('junction', 'carthag:11'),
|
||||
]) {
|
||||
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', 'territory', 'transport', 'turn']);
|
||||
assert.deepEqual(Object.keys(original.source.transport).sort(), ['amount', 'destination', 'elite', 'event', 'group', 'kind', 'origin', 'player']);
|
||||
}
|
||||
const salusa = homeworldTransport('junction', 'homeworld:emperor:salusa');
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(salusa.ctx, salusa.source), AutomaticAdvisorFlipError);
|
||||
salusa.ctx.players = [...salusa.ctx.players, { id: 'e', faction: 'emperor', forces: {} }];
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(salusa.ctx, salusa.source));
|
||||
});
|
||||
|
||||
void test('a Junction BG advisor arrival binds the actual destination group and deposited physical board counters', () => {
|
||||
const { ctx, source } = homeworldTransport('junction', 'imperial_basin:9');
|
||||
ctx.players[1].forces = {};
|
||||
ctx.players[0].shipped = true;
|
||||
source.transport.player = 'b';
|
||||
source.transport.origin = 'hagga_basin';
|
||||
source.transport.group = { 'hagga_basin:12': 2 };
|
||||
source.transport.amount = 2;
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
|
||||
source.transport.destination = 'imperial_basin:10';
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
|
||||
const departed = homeworldTransport('junction', 'carthag:11');
|
||||
departed.ctx.players[1].forces = { 'carthag:11': 2 };
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(departed.ctx, departed.source), AutomaticAdvisorFlipError);
|
||||
});
|
||||
|
||||
void test('Homeworld transport rejects malformed metadata, missing native permissions, current locations and changed held groups', () => {
|
||||
const changes: ((ctx: AutomaticAdvisorFlipContext, source: HomeworldSource) => void)[] = [
|
||||
(_ctx, source) => { source.transport.event = ''; },
|
||||
(_ctx, source) => { source.transport.player = 'missing'; },
|
||||
(_ctx, source) => { source.transport.origin = 'missing'; },
|
||||
(_ctx, source) => { source.transport.destination = 'homeworld:emperor'; },
|
||||
(_ctx, source) => { source.transport.destination = 'reserves'; },
|
||||
(_ctx, source) => { source.transport.destination = 'carthag:11'; },
|
||||
(_ctx, source) => { source.transport.amount = 0; },
|
||||
(_ctx, source) => { source.transport.amount = 2; },
|
||||
(_ctx, source) => { source.transport.elite = -1; },
|
||||
(_ctx, source) => { source.transport.elite = 4; },
|
||||
(_ctx, source) => { source.transport.elite = 1.5; },
|
||||
(_ctx, source) => { source.transport.group = {}; },
|
||||
(_ctx, source) => { source.transport.group = { 'carthag:11': 3 }; },
|
||||
(_ctx, source) => { source.transport.group = { 'imperial_basin:9': 3.5 }; },
|
||||
(_ctx, source) => { source.transport.group = { 'imperial_basin:9': 3, 'imperial_basin:10': 0 }; },
|
||||
(_ctx, source) => { source.transport.group = { 'imperial_basin:8': 3 }; },
|
||||
(_ctx, source) => { source.transport.group = { 'imperial_basin:9.0': 3 }; },
|
||||
(_ctx, source) => {
|
||||
source.transport.origin = 'hagga_basin';
|
||||
source.transport.group = { 'hagga_basin:12': 3 };
|
||||
},
|
||||
(ctx) => { ctx.players[1].faction = 'atreides'; },
|
||||
(ctx) => { ctx.players[1].shipped = false; },
|
||||
(ctx) => { delete ctx.players[1].shipped; },
|
||||
(ctx) => { ctx.players = [...ctx.players, { ...ctx.players[1] }]; },
|
||||
(_ctx, source) => { source.forces = { 'imperial_basin:9': 3 }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const original = homeworldTransport();
|
||||
change(original.ctx, original.source);
|
||||
const before = structuredClone(original);
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
|
||||
assert.deepEqual(original, before);
|
||||
}
|
||||
const junction = homeworldTransport('junction', 'carthag:11');
|
||||
junction.ctx.players = junction.ctx.players.filter((player) => player.faction !== 'guild');
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(junction.ctx, junction.source), AutomaticAdvisorFlipError);
|
||||
for (const destination of ['carthag', 'carthag:10', 'carthag:11.0', 'homeworld:emperor:salusa']) {
|
||||
const invalid = homeworldTransport('junction', destination);
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(invalid.ctx, invalid.source), AutomaticAdvisorFlipError);
|
||||
}
|
||||
for (const homeworldState of [undefined, null, [], false, 'homeworlds', {}, { custody: null }, { custody: [] }]) {
|
||||
const original = homeworldTransport();
|
||||
original.ctx.homeworlds = homeworldState;
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(original.ctx, original.source), AutomaticAdvisorFlipError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Homeworld source receipts reject private original events and stale producer payloads at both layers', () => {
|
||||
const { ctx, source } = homeworldTransport();
|
||||
for (const field of ['homeworldShipmentEvent', 'card', 'nexusEvent', 'funds', 'permission']) {
|
||||
for (const malformed of [
|
||||
{ ...source, [field]: 'private-original-state' },
|
||||
{ ...source, transport: { ...source.transport, [field]: 'private-original-state' } },
|
||||
]) {
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
|
||||
}
|
||||
}
|
||||
for (const malformed of [
|
||||
{ ...source, transport: null },
|
||||
{ ...source, transport: { ...source.transport, kind: 'guild' } },
|
||||
{ ...source, transport: { ...source.transport, group: null } },
|
||||
{ ...source, transport: transported().source.transport },
|
||||
]) {
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as unknown as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Homeworld transport stays phase-five-only and leaves all earlier producer receipt shapes and windows unchanged', () => {
|
||||
for (const original of [held(), transported(), gamont(false), battleResult()]) {
|
||||
const before = JSON.stringify(original.source);
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
|
||||
assert.equal(JSON.stringify(original.source), before);
|
||||
}
|
||||
for (const phase of [4, 6, 8]) {
|
||||
const { ctx, source } = homeworldTransport();
|
||||
ctx.phase = phase;
|
||||
source.phase = phase;
|
||||
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Homeworld source validation never reads original shipment events, private module players or pretransfer funding', () => {
|
||||
for (const original of [homeworldTransport(), homeworldTransport('junction', 'carthag:11')]) {
|
||||
const custody = {};
|
||||
for (const field of ['visitors', 'salusa', 'players']) {
|
||||
Object.defineProperty(custody, field, {
|
||||
get() { throw new Error(`Advisor transport helper read delegated world custody ${field}`); },
|
||||
});
|
||||
}
|
||||
const homeworldState = { custody };
|
||||
Object.defineProperty(homeworldState, 'players', {
|
||||
get() { throw new Error('Advisor transport helper read private Homeworld players'); },
|
||||
});
|
||||
original.ctx.homeworlds = homeworldState;
|
||||
for (const field of ['homeworldShipmentEvent', 'pendingHomeworldShipment', 'pendingAutomaticAdvisorHomeworldTransport']) {
|
||||
Object.defineProperty(original.ctx, field, {
|
||||
get() { throw new Error(`Advisor transport helper read private parent ${field}`); },
|
||||
});
|
||||
}
|
||||
for (const player of original.ctx.players) {
|
||||
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers', 'reserves', 'pledged', 'guildTransportPermission']) {
|
||||
Object.defineProperty(player, field, {
|
||||
get() { throw new Error(`Advisor transport helper read ${player.id} private funding or custody ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(original.ctx, original.source));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { isAdvisor, fighterCount } from '../game/advisors';
|
||||
import { homeworldContext } from '../game/homeworld-game';
|
||||
import { homeworldForceGroups } from '../game/homeworld-custody';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createAdvisorHomeworldFlipFixture as fixture, actAdvisorHomeworldFlip as act,
|
||||
commitAdvisorHomeworldFlip as commit, finishAdvisorHomeworldFlip as finish,
|
||||
advisorHomeworldFlipPlayer as player, advisorHomeworldFlipInventory as inventory,
|
||||
} from './fixture-advisor-homeworld-flip';
|
||||
|
||||
const cases = [
|
||||
{ kind: 'native' as const },
|
||||
{ kind: 'junction' as const },
|
||||
{ kind: 'junction' as const, intrusion: true },
|
||||
{ kind: 'junction' as const, advisorArrival: true },
|
||||
];
|
||||
function allow(g: Game) {
|
||||
for (let count = 0; g.response?.automaticAdvisorFlip && 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.ok(!g.response?.automaticAdvisorFlip); return g;
|
||||
}
|
||||
function rejected(g: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action)); assert.deepEqual(g, before);
|
||||
}
|
||||
const physical = (g: Game) => ({ custody: g.homeworlds!.custody,
|
||||
players: g.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves, tanks: p.tanks,
|
||||
spice: p.spice, shipped: p.shipped, moved: p.moved })) });
|
||||
|
||||
for (const config of cases) for (const canceled of [false, true]) {
|
||||
void test(`${config.kind}${config.intrusion ? ' Intrusion' : config.advisorArrival ? ' advisor arrival' : ' return'} ${canceled ? 'prevention' : 'allowance'} keeps paid transport and resumes only its own arrival`, () => {
|
||||
const f = fixture(config), before = structuredClone(f.game), pending = commit(f.game, f).game;
|
||||
assert.deepEqual(f.game, before);
|
||||
const source = pending.response?.automaticAdvisorFlip; assert.ok(source?.producer === 'homeworldTransport');
|
||||
assert.equal(source.transport.kind, config.kind); assert.equal(source.transport.destination, f.to);
|
||||
assert.equal(pending.decision, null);
|
||||
assert.equal(player(pending, f.shipper).spice, player(before, f.shipper).spice - f.cost);
|
||||
assert.equal(player(pending, f.shipper).forces[f.from], undefined);
|
||||
assert.equal(player(pending, f.shipper).shipped, true); assert.equal(player(pending, f.shipper).moved, 0);
|
||||
assert.equal(fighterCount(player(pending, f.bg), f.territory), 0);
|
||||
assert.ok(viewGame(pending, f.holder).responseControls?.cancelCards.includes(f.karama));
|
||||
const parent = pending.pendingAutomaticAdvisorHomeworldTransport!;
|
||||
assert.ok(parent.sourceEvent.length > 0 && parent.sourceEvent !== source.transport.event);
|
||||
const publicResponse = JSON.stringify(viewGame(pending, f.holder).response);
|
||||
assert.ok(!publicResponse.includes('sourceEvent') && !publicResponse.includes('signature'));
|
||||
assert.ok(!publicResponse.includes(parent.sourceEvent));
|
||||
if (f.to.startsWith('homeworld:')) {
|
||||
const deposit = homeworldForceGroups(homeworldContext(pending), pending.homeworlds!.custody!)
|
||||
.find(world => world.id === f.to)!.forces[f.shipper];
|
||||
assert.equal(deposit.normal + deposit.elite, 20);
|
||||
assert.equal(parent.arrival, undefined);
|
||||
} else assert.equal(player(pending, f.shipper).forces[f.to], f.amount);
|
||||
if (config.intrusion) rejected(pending, f.bg, { type: 'decision', accept: false });
|
||||
const committed = physical(pending);
|
||||
let done = canceled
|
||||
? act(pending, f.holder, { type: 'card', card: f.karama, mode: 'cancel' }) : allow(pending);
|
||||
assert.equal(done.pendingAutomaticAdvisorHomeworldTransport, undefined);
|
||||
assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
|
||||
assert.deepEqual(physical(done), committed);
|
||||
assert.equal(isAdvisor(player(done, f.bg), f.territory), canceled);
|
||||
if (config.intrusion) {
|
||||
assert.equal(done.decision?.kind, 'intrusion');
|
||||
assert.equal(done.decision?.player, f.bg);
|
||||
done = act(done, f.bg, { type: 'decision', accept: false });
|
||||
assert.equal(done.decision, null);
|
||||
} else assert.equal(done.decision, null);
|
||||
for (let read = 0; read < 3; read++) {
|
||||
done = normalizeAutomaticGame(done); assert.equal(done.decision, null);
|
||||
assert.deepEqual(physical(done), committed);
|
||||
}
|
||||
const finished = finish(done);
|
||||
assert.ok(finished.phase === 6 || finished.phase === 7);
|
||||
assert.deepEqual(inventory(finished), f.originalInventory);
|
||||
assert.equal(finished.discard.filter(c => c.id === f.karama).length, canceled ? 1 : 0);
|
||||
assert.equal(player(finished, f.shipper).forces[f.from], undefined);
|
||||
assert.equal(player(finished, f.shipper).tanks, player(before, f.shipper).tanks);
|
||||
});
|
||||
}
|
||||
|
||||
void test('earlier native transport privilege prevention leaves Homeworld payment and the last outsider untouched', () => {
|
||||
const f = fixture(); let declared = act(f.game, f.shipper, f.action);
|
||||
if (declared.decision?.kind === 'homeworldShipmentGuild') declared = act(declared,
|
||||
declared.decision.player, { type: 'decision', event: declared.decision.event, allow: true });
|
||||
assert.equal(declared.response?.kind, 'guildTransportPermission');
|
||||
const done = act(declared, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
assert.equal(done.pendingAutomaticAdvisorHomeworldTransport, undefined);
|
||||
assert.equal(player(done, f.shipper).forces[f.from], f.amount);
|
||||
assert.equal(player(done, f.shipper).spice, player(f.game, f.shipper).spice);
|
||||
assert.equal(player(done, f.shipper).shipped, false);
|
||||
assert.equal(isAdvisor(player(done, f.bg), f.territory), true);
|
||||
});
|
||||
|
||||
void test('changed private source or arrival rejects before a conversion counter spends its card', () => {
|
||||
const f = fixture({ kind: 'junction', intrusion: true }), pending = commit(f.game, f).game;
|
||||
for (const field of ['source', 'arrival'] as const) {
|
||||
const invalid = structuredClone(pending);
|
||||
if (field === 'source') invalid.pendingAutomaticAdvisorHomeworldTransport!.sourceEvent = 'different-original-source';
|
||||
else invalid.pendingAutomaticAdvisorHomeworldTransport!.arrival!.sector = 9;
|
||||
rejected(invalid, f.holder, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
assert.deepEqual(physical(invalid), physical(pending));
|
||||
assert.ok(player(invalid, f.holder).hand.some(c => c.id === f.karama));
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four existing profiles resolve each Homeworld transport conversion legally', () => {
|
||||
for (const config of cases) for (const profile of DIFFICULTIES) {
|
||||
const f = fixture(config), 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);
|
||||
let done = allow(act(pending, f.holder, actions[0]));
|
||||
if (done.decision?.kind === 'intrusion') done = act(done, f.bg, { type: 'decision', accept: false });
|
||||
assert.equal(done.pendingAutomaticAdvisorHomeworldTransport, undefined);
|
||||
assert.deepEqual(physical(done), physical(pending));
|
||||
assert.deepEqual(inventory(done), f.originalInventory);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { guildHomeworldShipmentChoice } from '../game/guild-homeworld-shipment-options';
|
||||
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 AdvisorHomeworldFlipStep = { actor: string; action: Action };
|
||||
export type AdvisorHomeworldFlipTransition = { before: Game; steps: AdvisorHomeworldFlipStep[]; after: Game };
|
||||
export const actAdvisorHomeworldFlip = actAdvisorPreparation;
|
||||
export const advisorHomeworldFlipClean = advisorPreparationClean;
|
||||
export const advisorHomeworldFlipInventory = advisorPreparationInventory;
|
||||
export const advisorHomeworldFlipPlayer = advisorPreparationPlayer;
|
||||
|
||||
/** Native continuation preserves the new mandatory conversion and original
|
||||
* destination Intrusion. Guild timing normally yields the next ordinary seat. */
|
||||
export function nextAdvisorHomeworldFlip(game: Game): AdvisorHomeworldFlipStep | null {
|
||||
if (game.response?.automaticAdvisorFlip) return null;
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
if (game.decision?.kind === 'intrusion') return null;
|
||||
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 advanceAdvisorHomeworldFlip(game: Game, until: (state: Game) => boolean,
|
||||
steps: AdvisorHomeworldFlipStep[] = [], options: { takeGuild?: boolean } = {}): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The native Homeworld chronology ended before its requested boundary');
|
||||
const next = nextAdvisorHomeworldFlip(game);
|
||||
assert.ok(next, `Choose the original conversion or Intrusion at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
if (options.takeGuild && !game.phaseOpening && !game.response && game.decision?.kind === 'guildTiming')
|
||||
next.action = { type: 'decision', take: true };
|
||||
game = actAdvisorHomeworldFlip(game, next.actor, next.action, steps);
|
||||
}
|
||||
throw Error('The native Homeworld advisor source did not reach its finite boundary');
|
||||
}
|
||||
/** Ordinary response passes never consume the new advisor conversion. */
|
||||
export function passAdvisorHomeworldFlip(game: Game, steps: AdvisorHomeworldFlipStep[] = []): 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 = actAdvisorHomeworldFlip(game, actor.id, { type: 'passResponse' }, steps);
|
||||
}
|
||||
assert.ok(!game.response || game.response.automaticAdvisorFlip); return game;
|
||||
}
|
||||
|
||||
/** Actual public Advanced classic + Homeworlds setup. Return either the native
|
||||
* Guild return or a real sponsored Junction choice, before its declaration.
|
||||
* Only original unopened card/Spice positions and scalar shuffle draws change. */
|
||||
export function createAdvisorHomeworldFlipFixture(options: {
|
||||
kind?: 'native' | 'junction'; intrusion?: boolean; advisorArrival?: boolean;
|
||||
} = {}) {
|
||||
const kind = options.kind ?? 'native', intrusion = options.intrusion ?? false;
|
||||
const advisorArrival = options.advisorArrival ?? false;
|
||||
assert.ok(!intrusion || kind === 'junction', 'The earned destination Intrusion uses Junction transport');
|
||||
assert.ok(!advisorArrival || kind === 'junction' && !intrusion, 'The advisor arrival uses its own Junction route');
|
||||
const bg = 'beneGesserit', holder = 'atreides', guild = 'guild';
|
||||
const territory = kind === 'native' ? 'tueks_sietch' : 'arrakeen', sector = kind === 'native' ? 5 : 10;
|
||||
let game = createGame('ADVISORHOMEWORLDFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
|
||||
joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(guild, 'Guild', 'guild'));
|
||||
const initial = structuredClone(game), steps: AdvisorHomeworldFlipStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actAdvisorHomeworldFlip(game, actor, action, steps); };
|
||||
const reach = (until: (state: Game) => boolean, takeGuild = false) => {
|
||||
game = advanceAdvisorHomeworldFlip(game, until, steps, { takeGuild });
|
||||
};
|
||||
const transition = (run: () => void): AdvisorHomeworldFlipTransition => {
|
||||
const before = structuredClone(game), start = steps.length; run();
|
||||
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
const enableHomeworlds = transition(() => { act(game.host, { type: 'homeworlds', 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.ok(game.homeworlds);
|
||||
assert.deepEqual(game.expansions, []);
|
||||
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 = advisorHomeworldFlipInventory(game); assert.equal(originalInventory.length, 33);
|
||||
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(karama);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
|
||||
assert.equal(ordinary.length, 2);
|
||||
const selected = [ordinary[0], karama, ordinary[1]];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
let advisorSetup: AdvisorHomeworldFlipTransition | null = null;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
const next = nextAdvisorHomeworldFlip(game); assert.ok(next);
|
||||
const before = next.action.type === 'advisorSetup' ? structuredClone(game) : null;
|
||||
if (before) next.action = { type: 'advisorSetup', territory, sector };
|
||||
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));
|
||||
const from = `${territory}:${sector}`;
|
||||
assert.equal(advisorHomeworldFlipPlayer(game, bg).forces[from], 1);
|
||||
assert.ok(advisorHomeworldFlipPlayer(game, bg).advisors?.[territory]);
|
||||
assert.equal(advisorHomeworldFlipPlayer(game, kind === 'native' ? guild : holder).forces[from], kind === 'native' ? 5 : 10);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && !state.phaseOpening && !state.response && state.decision?.kind === 'advisorBattle');
|
||||
const openingOffer = structuredClone(game);
|
||||
const declinedPreparation = transition(() => {
|
||||
act(bg, { type: 'decision', accept: false });
|
||||
reach(state => state.phase === 5 && state.active === (kind === 'native' ? guild : bg) && advisorHomeworldFlipClean(state), kind === 'native');
|
||||
});
|
||||
let intrusionShipment: AdvisorHomeworldFlipTransition | null = null;
|
||||
let bgEnd: AdvisorHomeworldFlipTransition | null = null;
|
||||
let junctionOffer: AdvisorHomeworldFlipTransition | null = null;
|
||||
let offerStep: AdvisorHomeworldFlipStep | null = null;
|
||||
if (kind === 'junction') {
|
||||
if (intrusion) {
|
||||
intrusionShipment = transition(() => {
|
||||
act(bg, { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(advisorHomeworldFlipClean);
|
||||
});
|
||||
assert.equal(advisorHomeworldFlipPlayer(intrusionShipment.before, bg).spice - advisorHomeworldFlipPlayer(game, bg).spice, 2);
|
||||
assert.equal(advisorHomeworldFlipPlayer(game, bg).forces['imperial_basin:10'], 1);
|
||||
assert.ok(!advisorHomeworldFlipPlayer(game, bg).advisors?.imperial_basin);
|
||||
}
|
||||
if (!advisorArrival) {
|
||||
bgEnd = transition(() => {
|
||||
act(bg, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === holder && advisorHomeworldFlipClean(state));
|
||||
});
|
||||
}
|
||||
const sponsor = viewGame(game, guild).junctionTransport; assert.ok(sponsor?.canOffer);
|
||||
assert.equal(sponsor.recipient, advisorArrival ? bg : holder);
|
||||
offerStep = { actor: guild, action: { type: 'offerJunctionTransport', event: sponsor.offerEvent, rate: 'half' } };
|
||||
junctionOffer = transition(() => { act(offerStep!.actor, offerStep!.action); });
|
||||
}
|
||||
const shipper = kind === 'native' ? guild : advisorArrival ? bg : holder;
|
||||
const amount = kind === 'native' ? 5 : advisorArrival ? 1 : 10;
|
||||
const to = kind === 'native' ? 'homeworld:guild' : intrusion || advisorArrival ? 'imperial_basin:10' : 'homeworld:atreides';
|
||||
const sources: HomeworldShipmentSources = { [from]: { normal: amount, elite: 0 } };
|
||||
const quote = kind === 'native'
|
||||
? guildHomeworldShipmentChoice(viewGame(game, shipper), to, sources, 0)
|
||||
: junctionTransportChoice(viewGame(game, shipper), to, sources, 0);
|
||||
assert.equal(quote.blocked, null); assert.ok(quote.action);
|
||||
const action = quote.action, cost = quote.cost;
|
||||
assert.equal(cost, kind === 'native' ? 3 : advisorArrival ? 1 : intrusion ? 10 : 5);
|
||||
assert.equal(game.turn, 1); assert.equal(game.phase, 5); assert.equal(game.active, shipper);
|
||||
assert.ok(advisorHomeworldFlipClean(game));
|
||||
assert.equal(advisorHomeworldFlipPlayer(game, shipper).forces[from], amount);
|
||||
assert.equal(advisorHomeworldFlipPlayer(game, shipper).shipped, false);
|
||||
assert.equal(advisorHomeworldFlipPlayer(game, shipper).moved, 0);
|
||||
assert.equal(advisorHomeworldFlipPlayer(game, bg).forces[from], 1);
|
||||
assert.ok(advisorHomeworldFlipPlayer(game, bg).advisors?.[territory]);
|
||||
assert.ok(advisorHomeworldFlipPlayer(game, holder).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(advisorHomeworldFlipInventory(game), originalInventory);
|
||||
return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial,
|
||||
enableHomeworlds, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup,
|
||||
openingOffer, declinedPreparation, intrusionShipment, bgEnd, junctionOffer, offerStep,
|
||||
kind, intrusion, advisorArrival, bg, owner: bg, holder, atreides: holder, guild, shipper, actor: shipper,
|
||||
karama: karama.id, card: karama.id, territory: advisorArrival ? 'imperial_basin' : territory,
|
||||
origin: territory, sector, location: from,
|
||||
from, to, destination: to, sources, amount, cost, action,
|
||||
transportStep: { actor: shipper, action } satisfies AdvisorHomeworldFlipStep,
|
||||
steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalSpice, dealOrder, spiceOrder,
|
||||
staging: [
|
||||
'Actual public Advanced Bene Gesserit/Atreides/Guild setup enables Homeworlds with the original lobby action, then uses ready/startPreview, native prediction and private Traitor decisions. Original base33, physical native Homeworld pools and ordinary starting forces remain; no other optional module is selected.',
|
||||
'Select only original scalar shuffle draws, three original unopened Treachery positions and two original unopened ordinary Spice positions. Atreides really receives its original starting Karama while BG/Guild receive ordinary cards; no played hand or card acquisition is staged.',
|
||||
kind === 'native'
|
||||
? 'Actual advisorSetup places the original BG advisor beside Guild’s native five Tuek’s Sietch5 counters. Safe first Storm, both Spice blows, Charity, Bidding and Revival execute normally. BG declines preparation; Guild actually takes its first native early turn and retains all five source counters.'
|
||||
: `Actual advisorSetup places the original BG advisor beside Atreides’ native ten Arrakeen10 counters. Safe first Storm, both Spice blows, Charity, Bidding and Revival execute normally. BG declines preparation and Guild declines original early timing.${advisorArrival ? ' BG retains its actual unused turn.' : ' BG’s real turn ends before the unused Atreides shipment.'}`,
|
||||
intrusion
|
||||
? 'During its actual first turn BG pays two spice to ship one native reserve into empty Imperial Basin10 as a fighter. Original Guild interception/income resolves through actual decisions and passes. The original Arrakeen advisor remains peaceful; Atreides retains its full original ten-spice shipment budget.'
|
||||
: 'No native source group is moved or reduced before the tested declaration.',
|
||||
kind === 'native'
|
||||
? 'Stop before the exact owner-view guildHomeworldShipmentChoice returning five real Tuek’s counters to native Junction for three. Original Guild interception, transport permission, rate, payment and physical return remain caller continuations.'
|
||||
: advisorArrival
|
||||
? 'Guild actually offers its current high-population Junction half tariff to BG. Stop before moving the original Arrakeen advisor to empty Basin10 for one spice; a mandatory destination conversion follows the committed transfer.'
|
||||
: intrusion
|
||||
? 'Guild actually offers its current high-population Junction half tariff to Atreides. Stop before the exact junctionTransportChoice transporting all ten real Arrakeen counters to BG’s earned Basin fighter for ten; origin conversion and original destination Intrusion remain caller continuations.'
|
||||
: 'Guild actually offers its current high-population Junction half tariff to Atreides. Stop before the exact junctionTransportChoice returning all ten real Arrakeen counters to native Caladan for five. Junction permission and tariff retain their original immune source.',
|
||||
'No played army, phase, wallet, hand, native reserve, advisor stance, hold, transport privilege, price, response or receipt is installed. Automatic conversion and Intrusion are not answered by commit/continuation helpers.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type AdvisorHomeworldFlipFixture = ReturnType<typeof createAdvisorHomeworldFlipFixture>;
|
||||
|
||||
/** Declare once and allow only original Homeworld interception/permission/rate
|
||||
* and income. Stop before conversion or original destination Intrusion; legacy
|
||||
* runtimes may instead finish at their clean committed boundary. */
|
||||
export function commitAdvisorHomeworldFlip(source: Game, fixture: AdvisorHomeworldFlipFixture,
|
||||
steps: AdvisorHomeworldFlipStep[] = []) {
|
||||
const before = structuredClone(source), start = steps.length;
|
||||
let game = source;
|
||||
if (!game.pendingHomeworldShipment && !game.response && !advisorHomeworldFlipPlayer(game, fixture.shipper).shipped)
|
||||
game = actAdvisorHomeworldFlip(game, fixture.shipper, fixture.action, steps);
|
||||
for (let count = 0; count < 120; count++) {
|
||||
const shipper = advisorHomeworldFlipPlayer(game, fixture.shipper);
|
||||
const committed = shipper.shipped && (shipper.forces[fixture.from] ?? 0) === 0;
|
||||
if (game.response?.automaticAdvisorFlip || game.decision?.kind === 'intrusion' ||
|
||||
(committed && advisorHomeworldFlipClean(game))) {
|
||||
assert.ok(committed, 'The original typed transport physically commits before conversion/Intrusion');
|
||||
assert.ok(advisorHomeworldFlipPlayer(game, fixture.holder).hand.some(card => card.id === fixture.karama));
|
||||
assert.deepEqual(advisorHomeworldFlipInventory(game), fixture.originalInventory);
|
||||
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)),
|
||||
price: advisorHomeworldFlipPlayer(before, fixture.shipper).spice - shipper.spice };
|
||||
}
|
||||
if (game.decision?.kind === 'homeworldShipmentGuild' && !game.response) {
|
||||
game = actAdvisorHomeworldFlip(game, game.decision.player,
|
||||
{ type: 'decision', event: game.decision.event, allow: true }, steps); continue;
|
||||
}
|
||||
assert.ok(game.response && ['guildTransportPermission', 'guildRate', 'guildIncome'].includes(game.response.kind),
|
||||
'Allow only the original Homeworld transport continuations before conversion');
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actAdvisorHomeworldFlip(game, actor.id, { type: 'passResponse' }, steps);
|
||||
}
|
||||
throw Error('The native Homeworld transport did not reach its committed boundary');
|
||||
}
|
||||
|
||||
/** Finish only after caller-owned conversion and Intrusion have been resolved. */
|
||||
export function finishAdvisorHomeworldFlip(source: Game, steps: AdvisorHomeworldFlipStep[] = []): Game {
|
||||
return advanceAdvisorHomeworldFlip(source,
|
||||
game => !game.battle && game.phase >= 6 && game.phase <= 7 && advisorHomeworldFlipClean(game), steps);
|
||||
}
|
||||
Reference in new issue
Block a user