Preserve committed Guild transport through advisor conversion responses

Offer mandatory advisor conversion prevention after Guild cross-shipment and reserve returns. Keep original permission/rate/income, payment, physical pieces and shipment use; bind the exact group to one transport parent and resume original arrival/Intrusion once. Ordinary movement stays unused and the existing provisional continuously-alone duration remains. Preserve old ground receipts, private projection behavior and all transport/module guards.

Validation: 17 files243/243, types/lint/build, eight native transport/response/GameTable SSR journeys and eight existing-profile paths. Genuine36/42-action sources earn original counters/cards and actual paid-shipment Intrusion. Preserve the unaffordable full-price cross outcome and prove the affordable return. No saved-room/D1/restart/deployment operation; broader producers, full modes and browser/deployed acceptance remain open.
This commit is contained in:
Quintine committed 2026-10-24 19:58:56 +11:00
1 parent 3aca64412f
commit d0f52c7bae
13 files changed
+805 -43

No files matched your search

+4 -2
View File
@@ -2473,8 +2473,10 @@ export function GameTable({
)}
{g.response.automaticAdvisorFlip && (
<p className="notice">
The ground movement is complete. These advisors are now alone and must become fighters.
{' '}Karama prevents only this conversion; it does not undo the move.
{g.response.automaticAdvisorFlip.producer === 'guildTransport'
? 'The transport and payment are complete. These advisors are now alone and must become fighters.'
: '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.
</p>
+69 -3
View File
@@ -1,4 +1,70 @@
# Mandatory advisor conversion after ground movement
# Mandatory advisor conversions
## Guild transport and reserve returns — 24 October 2026
**Prototyped / Partial.** The same physical17 counter and physical28 E mandatory
conversion now compose with committed Guild cross-shipment and reserve returns.
The original privilege, rate, settlement and payment responses retain their
order. Actual payment, force transfer, shipment use and promise/Cunning receipts
commit once before the conversion response. Ordinary ground movement is not
consumed. Canceling only this conversion neither refunds payment nor reverses
transport or revives the earlier permission/rate opportunity.
The source stores the public original route, selected physical group, amount,
elite count and a unique committed transport event. It binds to one saved parent
and the exact held advisor group. Holds are captured before board-derived
occupation observations; the response opens only after original slot bookkeeping.
An existing Guild-income response retains priority. Once all conversion outcomes
finish, the parent is consumed before original Intrusion/territory entry resumes
once. A reserve return has no arrival suffix. Hidden funding shares, hands and
wallets are not copied into that public source.
From-reserves Fremen transport and private movement-preparation projections do
not manufacture conversion events. Only changed origin/destination groups are
considered. A transported BG advisor arriving alone retains its original advisor
stance until this counter resolves. Existing source, typed-transport, No-Field,
Nexus, module and start guards remain; no new entitlement is granted. The existing
provisional continuously-alone duration is unchanged. Exact old ground receipt
shape remains accepted; new transport fields are additive.
[Native transport fixture](../tests/fixture-advisor-transport-flip.ts) starts
public Advanced BG/Atreides/Guild, actually placing the advisor beside Guild’s
original five counters at printed Tuek’s Sietch sector5. Atreides receives an
original starting Karama; real first-round controls, declined preparation and
native early Guild timing lead to the pre-transport source. Caller actions allow
original permission/rate, then stop before the post-payment conversion. No played
army, phase, hand, wallet, reserve, hold or response is installed. A separate
Intrusion source actually defers Guild, pays for one BG reserve to Imperial
Basin as a fighter, resolves original interception/income, ends BG’s turn and
takes Guild’s next early turn. The tested cross-shipment then produces the origin
conversion followed by a real destination Intrusion; it is not a synthetic
pending decision.
[Engine cases](../tests/advisor-transport-flip-engine.test.ts) cover both routes,
both outcomes, a real later ordinary move, earlier permission/rate independence,
affordable full-price return versus an uncommitted unaffordable cross declaration,
source-parent rejection and minimal existing profiles. Pure transport/legacy
cases remain in the shared quote suite. Frozen **17 files243/243**, types/lint/build, eight native transport/response/
GameTable SSR journeys and eight existing-profile paths pass. Native default
sources take36 actions; the actual paid-shipment Intrusion source takes42.
Cross costs5 and return3 before the conversion. Earlier privilege prevention
leaves pieces unchanged; an unaffordable canceled cross tariff remains
uncommitted, while a full-price return really settles before unopposed
conversion. The first241/242 feedback corrected the funding expectation without
staging spice, then added the negative branch. Two focused cases and the final
frozen243/243 pass. An existing ground consumer now narrows the strict source
union before reading its mover; no source shape is relaxed.
Private reports, rendered HTML and hashes:
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-transport-flips/`.
`frozen-integrated-2/report.json` records fingerprint
`cca2ff32114b6854c9339fff72f3384d68d1772d5539811594cd82f0c3e8d4fa`
on base `3aca64412feabae750b1adc2796a4086505a49c9`. Prose/reference evidence is
reviewed and hashed separately afterward. Standard controls are rendered; this
is no new component-callback/browser/mobile/deployed certificate.
Shipments outside this transport path, other withdrawals, casualties/aftermath,
matching-group conversions and wider interleavings remain open. This is not
complete cancellation or full Advanced/mode acceptance.
## Source and provisional duration — 24 October 2026
@@ -45,8 +111,8 @@ cannot simply erase the canceled result or offer an immediate free retry.
Existing minimal bot response handling remains, without strategy changes.
No start gate, physical inventory, save, schema or room is reset. Setup and
legacy unheld non-ground conversions keep their existing behavior. Remaining
mandatory producers include shipments, withdrawals, casualties/aftermath and
remaining unheld non-ground conversions keep their existing behavior. Remaining
mandatory producers beyond the transport follow-up include other shipments, withdrawals, casualties/aftermath and
other special arrivals; matching an existing fighter group and broader
interleavings also remain unfinished. This checkpoint does not claim complete
advisor-conversion cancellation or full Advanced compliance.
+28 -5
View File
@@ -16,6 +16,34 @@ open mode gates.
## Current checkpoint and work
**Advisor conversion after committed Guild transport — 24 October. Prototyped / Partial.**
Guild cross-shipment and reserve returns now retain the original paid transfer
and shipment use before mandatory advisor-conversion prevention. An exact public
transport event owns the held group and one later arrival. Canceling conversion
refunds nothing; ordinary ground movement remains unused. Original permission,
rate and income retain their priority. After conversion, the consumed parent
resumes actual destination Intrusion once; a reserve return has no arrival.
The provisional continuously-alone hold and all transport/module guards remain.
[Source, continuation and limits](ADVISOR_AUTOMATIC_FLIPS.md#guild-transport-and-reserve-returns--24-october-2026).
Disjoint helper/native-fixture lanes, bounded continuation review and root
integration pass **17 files243/243**, types/lint/build, eight native transport/
response/GameTable SSR journeys and eight legal existing-profile paths. Genuine
36-action default sources place the native advisor beside Guild’s original five
Tuek’s sector5 counters and retain Atreides’ original Karama. A42-action source
really ships BG’s second group to Basin, pays native Guild income, ends BG and
uses the next early Guild turn to earn Intrusion. Normal routes pay5 cross/3
return before the new counter. Feedback preserves an unaffordable full-price
cross declaration and adds the actually affordable full-price return; no wallet
is staged. Exact legacy ground receipts remain accepted. No AI refinement,
full-mode/comprehensive/browser/deployed assurance or saved-room/D1/reset/restart/
deployment operation.
Next native conversion producers remain non-transport withdrawals (including
Trip to Gamont before Mentat victory), casualties/aftermath and other special
arrivals. Extend each original continuation before claiming complete mandatory
conversion cancellation; preserve setup’s automatic exception.
**Mandatory advisor conversion after ground movement — 24 October. Prototyped / Partial.**
A native advisor group moving into an empty destination, or left alone by the
last outsider’s ground departure, now opens Karama prevention after the physical
@@ -37,11 +65,6 @@ rendered, without a new component-callback or browser certificate. No AI
refinement, full-mode, comprehensive or deployed assurance, and no saved-room/D1/
reset/restart/deployment operation.
Next native rule slice: connect the same mandatory-conversion protocol to
non-ground last-outsider removal/return producers before their later collection
or victory effects. Keep setup and completed movement independent; do not treat
this ground slice as complete automatic-flip cancellation.
**Tleilaxu proactive Revival prevention — 24 October. Prototyped / Partial.**
The native Advanced special Karama now selects any one player at a quiet Revival
boundary, including self, ally, ready and prior-return targets. The original
+47
View File
@@ -1,5 +1,52 @@
# Dune implementation status
## 24 October 2026 — Advisor counter after committed Guild transport
Extend the mandatory-conversion protocol to actual Guild cross-shipment and
reserve returns. Preserve original permission/rate/settlement/income order,
paid costs, physical transfer, shipment use and promise/Cunning records. Capture
only affected origin/destination groups before board-derived occupation reads;
open conversion after original slot bookkeeping. One public original-route/
group/event parent revalidates before prevention disposal/outcome. Consume it
before original Intrusion/territory arrival resumes once. Returns have no arrival;
ordinary ground movement remains unused. Private feasibility simulations and
from-reserves Fremen transport create no event or held parent.
The source union preserves exact old ground JSON and adds the transport variant.
The old continuously-alone cancellation duration stays explicitly provisional;
remaining withdrawals, casualties, matching groups and special arrivals remain
unfinished. Existing transport/Nexus/typed/No-Field/module guards stay intact.
Standard response controls explain that transport/payment are already complete.
No bot strategy changes or new confirmation/timer are introduced.
Frozen **17 files243/243**, types/lint/build, eight native transport/response/
GameTable SSR journeys and eight existing-profile paths pass. Genuine36-action
public Advanced BG/Atreides/Guild sources preserve original setup/deal and all
first-round controls, with actual early Guild timing. Native Tuek’s is sector5.
Cross pays5; reserve return pays3 before conversion. The42-action Intrusion source
actually waits Guild, pays2 for a BG reserve to Basin, allows Guild income, ends
BG and takes Guild’s next early turn. Actual arrival then resumes its independent
Intrusion, and repeated normalization does not reopen it. No played force, phase,
hand, wallet, reserve, hold, response or receipt is installed.
First frozen union is241/242: one test assumed the original5-spice Guild could
pay a canceled10-spice cross tariff. Preserve the engine’s original uncommitted
outcome; use the earned affordable full-price reserve return for successful
rate/conversion independence and add the negative cross case. Both focused
cases pass. Narrow the existing ground test to its producer before reading
mover; retain strict source union types. Final frozen243/243 and all checks pass
without private smoke-driver repairs or source changes during proof.
[Contract and remaining producers](ADVISOR_AUTOMATIC_FLIPS.md#guild-transport-and-reserve-returns--24-october-2026).
Private evidence: `/home/quintine/.local/share/dune/checkpoints/20261024-advisor-transport-flips/`.
Frozen fingerprint:
`cca2ff32114b6854c9339fff72f3384d68d1772d5539811594cd82f0c3e8d4fa`
(2,735 nonignored files), on base `3aca64412feabae750b1adc2796a4086505a49c9`.
Final prose/reference overlay is separately reviewed and hashed. Rendered
standard controls do not certify a new component callback or browser/hydration/
mobile/deployed journey. Full modes, combinations and comprehensive assurance
remain open. No saved-room/D1/reset/restart/deployment operation.
## 24 October 2026 — Mandatory advisor ground-conversion counter
Connect supplied physical17’s token-group flip counter with physical28 E’s
+12
View File
@@ -1,5 +1,17 @@
# Rule decision index
## Advisor conversion after committed Guild transport — 24 October 2026
Extend the existing physical17/28 E composition to Guild cross-shipment and
reserve returns. Preserve original permission/rate/income order and the one
completed payment, physical transfer and shipment use. An event-bound public
transport parent and exact held advisor group own the counter; consume the parent
before original Intrusion/territory entry resumes once. No arrival follows a
reserve return, and ordinary movement remains unused. Preserve the existing
provisional continuously-alone duration and all transport/module guards. Private
preparation projections and from-reserves Fremen transport create no occurrence.
[Source, continuation and limits](ADVISOR_AUTOMATIC_FLIPS.md#guild-transport-and-reserve-returns--24-october-2026).
## Mandatory advisor conversion after ground movement — 24 October 2026
Compose supplied Advanced physical17’s token-group flip counter with physical28 E’s
+68 -14
View File
@@ -1,5 +1,5 @@
import type { FactionId } from './catalog';
import { splitLocation } from './board';
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import { presenceAt, type ForcePresence } from './force-presence';
export type AdvisorStance = {
@@ -7,23 +7,36 @@ export type AdvisorStance = {
aloneFlipHold?: string;
};
export type AutomaticAdvisorTransport = {
event: string;
player: string;
origin: string;
to: string;
sector: number;
amount: number;
elite: number;
group: Record<string, number>;
};
export type AutomaticAdvisorFlipSource = {
event: string;
owner: string;
territory: string;
turn: number;
phase: number;
producer: 'groundMovement';
mover: string;
move: number;
forces: Record<string, number>;
};
} & (
| { producer: 'groundMovement'; mover: string; move: number }
| { producer: 'guildTransport'; transport: AutomaticAdvisorTransport }
);
export type AutomaticAdvisorFlipPlayer = ForcePresence & {
id: string;
faction: FactionId;
advisors?: Readonly<Record<string, AdvisorStance>>;
moved?: number;
shipped?: boolean;
reserves?: number;
};
export type AdvisorSettlementContext = {
@@ -31,7 +44,7 @@ export type AdvisorSettlementContext = {
players: readonly AutomaticAdvisorFlipPlayer[];
};
export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & {
export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & MobileBoard & {
status: string;
turn: number;
phase: number;
@@ -48,6 +61,8 @@ const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const positive = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) > 0;
const nonnegative = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireFlip(value: unknown, message: string): asserts value {
@@ -68,7 +83,7 @@ function currentWindow(ctx: AutomaticAdvisorFlipContext) {
return ctx.advanced === true && ctx.status === 'playing' && ctx.phase === 5 && positive(ctx.turn);
}
/** Only the ground-movement producer opens a response for these current groups.
/** A supported committed producer opens a response for these current groups.
* No cards, resources, leaders or hidden marker values determine eligibility. */
export function quoteAutomaticAdvisorFlips(ctx: AutomaticAdvisorFlipContext): {
player: string;
@@ -108,18 +123,55 @@ export function advisorStanceAfterSettlement(
return stance.aloneFlipHold !== undefined ? { ...stance } : undefined;
}
/** Validate the same committed move and exact held group before either response outcome. */
function validateTransportSource(
ctx: AutomaticAdvisorFlipContext,
source: Extract<AutomaticAdvisorFlipSource, { producer: 'guildTransport' }>,
) {
const transport = source.transport;
requireFlip(record(transport) &&
Object.keys(transport).sort().join(',') === 'amount,elite,event,group,origin,player,sector,to' &&
identifier(transport.event) && identifier(transport.player) && identifier(transport.origin) &&
identifier(transport.to) && nonnegative(transport.sector) && transport.sector <= 18 &&
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 exact committed Guild transport metadata.');
const places = gameTerritories(ctx);
const origin = places.find((place) => place.id === transport.origin);
const destination = places.find((place) => place.id === transport.to);
requireFlip(origin && (transport.to === 'reserves' ||
(destination && destination.sectors.includes(transport.sector))) &&
Object.keys(transport.group).every((key) => {
const point = splitLocation(key);
return key === `${point.territory}:${point.sector}` && point.territory === transport.origin &&
origin.sectors.includes(point.sector);
}) && (source.territory === transport.origin ||
(transport.to !== 'reserves' && source.territory === transport.to)),
'Automatic advisor conversion must belong to the original transport origin or destination.');
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 native transport slot.');
const deposited = transport.to === 'reserves'
? actor.reserves : actor.forces[`${transport.to}:${transport.sector}`];
requireFlip(nonnegative(deposited) && deposited >= transport.amount,
'Automatic advisor conversion lost the original transported physical counters.');
}
/** Validate the same committed producer and exact held group before either response outcome. */
export function validateAutomaticAdvisorFlip(
ctx: AutomaticAdvisorFlipContext,
source: AutomaticAdvisorFlipSource,
): void {
requireFlip(record(source) &&
Object.keys(source).sort().join(',') === 'event,forces,move,mover,owner,phase,producer,territory,turn' &&
((source.producer === 'groundMovement' && identifier(source.mover) &&
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')) &&
identifier(source.event) && identifier(source.owner) && identifier(source.territory) &&
identifier(source.mover) && source.producer === 'groundMovement' &&
positive(source.turn) && source.turn === ctx.turn && source.phase === 5 &&
currentWindow(ctx) && source.phase === ctx.phase,
'Automatic advisor conversion lost its original current-turn ground movement.');
'Automatic advisor conversion lost its original current-turn ground movement or Guild transport.');
const owners = ctx.players.filter((player) => player.id === source.owner);
const owner = owners[0];
requireFlip(owners.length === 1 && owner?.faction === 'beneGesserit' &&
@@ -127,9 +179,11 @@ export function validateAutomaticAdvisorFlip(
'Automatic advisor conversion needs its original native owner and held occurrence.');
requireFlip(!otherPresence(ctx, owner, source.territory),
'Automatic advisor conversion requires the original group to remain alone.');
const movers = ctx.players.filter((player) => player.id === source.mover);
requireFlip(movers.length === 1 && positive(source.move) && movers[0].moved === source.move,
'Automatic advisor conversion lost its original completed movement.');
if (source.producer === 'groundMovement') {
const movers = ctx.players.filter((player) => player.id === source.mover);
requireFlip(movers.length === 1 && positive(source.move) && movers[0].moved === source.move,
'Automatic advisor conversion lost its original completed movement.');
} else validateTransportSource(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) &&
+52 -10
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 } from './advisor-automatic-flip';
import { quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError, type AdvisorStance, type AutomaticAdvisorFlipSource, type AutomaticAdvisorTransport } 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';
@@ -1263,6 +1263,7 @@ type RicheseAllyOffer = {
};
export type Game = {
pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[];
pendingAutomaticAdvisorTransport?: AutomaticAdvisorTransport;
advisorPreparation?: AdvisorPreparationWindow;
ecazOccupyPreview?: boolean;
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
@@ -20289,6 +20290,9 @@ function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) {
response.advisorResume === undefined &&
JSON.stringify(g.pendingAutomaticAdvisorFlips?.[0]) === JSON.stringify(source),
'The mandatory advisor conversion has lost its original movement source.');
if (source.producer === 'guildTransport')
requireRule(JSON.stringify(source.transport) === JSON.stringify(g.pendingAutomaticAdvisorTransport),
'The mandatory conversion has lost its original committed Guild transport.');
automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source));
return source;
}
@@ -20301,16 +20305,41 @@ function openAutomaticAdvisorFlip(g: Game) {
advisors: false, passed: [], automaticAdvisorFlip: source,
intent: `${getPlayer(g, source.owner).name}: mandatory advisor-to-fighter conversion in ${territory(source.territory).name}.` };
}
function prepareAutomaticAdvisorFlips(g: Game, mover: Player) {
const groups = automaticAdvisorRule(() => quoteAutomaticAdvisorFlips(g));
function prepareAutomaticAdvisorFlips(g: Game, mover: Player,
transport?: Omit<AutomaticAdvisorTransport, 'event'>, deferResponse = false) {
const groups = automaticAdvisorRule(() => quoteAutomaticAdvisorFlips(g))
.filter(group => !transport || group.territory === transport.origin || group.territory === transport.to);
if (!groups.length) return false;
let parent: AutomaticAdvisorTransport | undefined;
if (transport) {
requireRule(!g.pendingAutomaticAdvisorTransport && !g.pendingAutomaticAdvisorFlips?.length,
'Finish the original advisor conversion before another transport.');
parent = { event: crypto.randomUUID(), ...transport };
g.pendingAutomaticAdvisorTransport = parent;
}
for (const group of groups) {
const source: AutomaticAdvisorFlipSource = { event: crypto.randomUUID(), owner: group.player,
territory: group.territory, turn: g.turn, phase: g.phase, producer: 'groundMovement',
mover: mover.id, move: mover.moved, forces: group.forces };
territory: group.territory, turn: g.turn, phase: g.phase, forces: group.forces,
...(parent ? { producer: 'guildTransport' as const, transport: parent }
: { producer: 'groundMovement' as const, mover: mover.id, move: mover.moved }) };
getPlayer(g, group.player).advisors![group.territory].aloneFlipHold = source.event;
(g.pendingAutomaticAdvisorFlips ??= []).push(source);
}
openAutomaticAdvisorFlip(g);
if (!deferResponse) openAutomaticAdvisorFlip(g);
return true;
}
function finishAutomaticAdvisorTransport(g: Game) {
const transport = g.pendingAutomaticAdvisorTransport;
if (!transport) return;
requireRule(g.phase === 5 && !g.pendingAutomaticAdvisorFlips?.length,
'Finish the original conversions before resuming this transport arrival.');
delete g.pendingAutomaticAdvisorTransport;
const p = getPlayer(g, transport.player);
if (transport.to !== 'reserves') {
intrusion(g, p, transport.to);
if (transport.origin !== transport.to)
openTerritoryEntry(g, p, transport.to, transport.sector, transport.amount, transport.elite, 'guildTransport');
}
}
function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) {
for (const entry of released)
@@ -28141,11 +28170,12 @@ 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 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' ? 'transport and payment 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;
openAutomaticAdvisorFlip(g);
if (!g.pendingAutomaticAdvisorFlips?.length) finishAutomaticAdvisorTransport(g);
else openAutomaticAdvisorFlip(g);
} else finishAdvisorReaction(g, response);
}
} else if (response.kind === 'capture') {
@@ -29507,6 +29537,9 @@ function commitGuildTransport(
if (!settlement && beginGuildBetrayal(g, {source: 'guildTransport', frame, fromReserves})) return;
const { origin, group, eliteGroup, to, sector, amount, elite, cost, allyPayment, advisors } = frame;
const sourceLock = p.advisors?.[origin]?.lockedTurn;
const advisorArrival = advisors || (!movementPreparation && !fromReserves && to !== 'reserves' &&
g.advanced && p.faction === 'beneGesserit' && isAdvisor(p, origin) && !at(p, to) &&
!g.players.some(other => other.id !== p.id && at(other, to)));
if (guildSecretEvent) recordGuildSecretShipment(g, p.id, guildSecretEvent, 'cross',
{ ...frame, guildSecretEvent });
bindGuildCunningShipment(g, p, 'guild', frame);
@@ -29534,11 +29567,16 @@ function commitGuildTransport(
addNativeReserves(g, p, amount, elite);
} else {
place(p, to, sector, amount, elite);
if (advisors) {
if (advisorArrival) {
const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
(p.advisors ??= {})[to] = { ...p.advisors?.[to], ...(lockedTurn ? { lockedTurn } : {}) };
} else if (p.advisors) delete p.advisors[to];
}
// Capture after physical transfer, before any board-derived benefit. Private
// feasibility projections neither create events nor retain response parents.
const heldConversion = !movementPreparation && !fromReserves &&
prepareAutomaticAdvisorFlips(g, p, { player: p.id, origin, to, sector, amount, elite,
group: Object.fromEntries(group) }, true);
observeOccupation(g);
finishShipmentPromises(g, p, fromReserves ? { territory: to, amount } : null);
finishGuildCunningShipment(g, p.id, 'guild', frame);
@@ -29550,6 +29588,10 @@ function commitGuildTransport(
guildSecretEvent
? { faction: 'guild', name: 'Secret Ally transport' }
: { faction: p.faction, name: 'Guild transport' });
if (heldConversion) {
openAutomaticAdvisorFlip(g);
return;
}
if (to !== 'reserves') {
intrusion(g, p, to);
if (origin !== to)
@@ -32045,6 +32087,7 @@ function settleAutomaticContinuations(g: Game) {
prepareTupileCleanup(g);
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return;
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
if (!g.response && !g.decision && g.pendingAutomaticAdvisorFlips?.length) openAutomaticAdvisorFlip(g);
if (g.pendingArrivalReaction?.length && !g.response && !g.decision &&
!g.pendingTerrorEntry && !g.pendingAmbassador && !g.pendingArrivalOverlap) {
const deferred = g.pendingArrivalReaction.shift()!;
@@ -32055,7 +32098,6 @@ function settleAutomaticContinuations(g: Game) {
deferred.amount, deferred.elite, deferred.cause, deferred.resume);
continue;
}
if (!g.response && !g.decision && g.pendingAutomaticAdvisorFlips?.length) openAutomaticAdvisorFlip(g);
const response = g.response;
const before = JSON.stringify(g);
const bonus = g.pendingHomeworldOccupiedBonus;
+1
View File
@@ -181,6 +181,7 @@ const cancellationSources = {
advisorFlip: (g, response) =>
response.automaticAdvisorFlip
? { source: response.automaticAdvisorFlip, pending: g.pendingAutomaticAdvisorFlips?.[0] ?? null,
transport: response.automaticAdvisorFlip.producer === 'guildTransport' ? g.pendingAutomaticAdvisorTransport ?? 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 -6
View File
@@ -4312,7 +4312,8 @@ export const RULE_TOPICS: RuleTopic[] = [
'In Advanced, advisors may coexist with their ally without triggering the end-turn alliance constraint in either direction. Neither the advisors nor the allied fighters are lost for that coexistence. Bene Gesserit fighters remain subject to ordinary separation; Basic receives no new advisor exception.',
'After Fremen setup, choose the starting advisor’s territory and sector. Setup conversions remain automatic. During play, advisors alone must become fighters. A ground move into an empty territory or the last outsider’s ground departure now offers Karama prevention after the physical move, before fighter benefits. Allow to convert once; cancellation leaves the completed movement intact.',
'A normal shipment enters as fighters unless joining existing advisors. Moving or shipping into your existing group must match its type. Advisors moved into an occupied territory without another Bene Gesserit group may remain peaceful or request a flip to fighters.',
'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. Non-ground automatic conversions and matching-group flip counters remain unfinished.',
'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.',
'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.',
'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.',
@@ -4323,7 +4324,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Implementation",
"status": "Partial",
"detail": "Ground mandatory conversion is now prototyped: physical advisor arrival/last-outsider departure commits before an exact sourced Karama response. Shared normalization/board quotes retain a prevented continuously-alone stance. Non-ground producers and matching-group conversion remain unfinished. Earlier preparation: Shared native public-board eligibility wraps a current-turn receipt and exact owner/territory/turn source. Direct preparation resolves before-cost and outcome validation without entering movement from earlier phases. Native future declaration queues remain intact; successful shipment commits close the receipt, including markers and positive Sneak Attack.",
"detail": "Committed Guild cross/return now binds an exact public transport parent, holds the affected group before board benefits, preserves payment/shipment use, and resumes arrival once after conversion. Private projections/from-reserves do not create occurrences; legacy ground receipt shape remains. Earlier ground/preparation: Ground mandatory conversion is now prototyped: physical advisor arrival/last-outsider departure commits before an exact sourced Karama response. Shared normalization/board quotes retain a prevented continuously-alone stance. Non-ground producers and matching-group conversion remain unfinished. Earlier preparation: Shared native public-board eligibility wraps a current-turn receipt and exact owner/territory/turn source. Direct preparation resolves before-cost and outcome validation without entering movement from earlier phases. Native future declaration queues remain intact; successful shipment commits close the receipt, including markers and positive Sneak Attack.",
"evidence": [
"game/advisor-automatic-flip.ts",
"game/advisors.ts",
@@ -4338,7 +4339,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Player controls",
"status": "Partial",
"detail": "Standard Karama/Allow responses show the completed ground move and explicit provisional continuous-alone duration, with no new confirmation or timer. Earlier preparation controls: Persistent owner-only territory actions and blocked/provisional guidance work in phases2–5 at quiet boundaries, including after initial decline. Busy and mandatory interactions stay blocked; private counts/cards/balances are not read. Original phase5 chooser remains.",
"detail": "Transport conversion uses original Karama/Allow controls with completed-payment guidance; later real Intrusion remains its own decision. Earlier controls: Standard Karama/Allow responses show the completed ground move and explicit provisional continuous-alone duration, with no new confirmation or timer. Earlier preparation controls: Persistent owner-only territory actions and blocked/provisional guidance work in phases2–5 at quiet boundaries, including after initial decline. Busy and mandatory interactions stay blocked; private counts/cards/balances are not read. Original phase5 chooser remains.",
"evidence": [
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
"components/advisor-preparation.tsx",
@@ -4349,8 +4350,9 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "AI",
"status": "Partial",
"detail": "Eight existing-profile ground-conversion paths accept legal standard responses and continue without a new strategy. Earlier preparation: The existing native preparation preference consumes the projected window; sixteen own-view existing-profile response paths accept16 legal proposals. No strategy tuning or difficulty calibration is claimed.",
"detail": "Eight existing-profile transport response paths stay legal without consuming ordinary movement or changing strategy. Earlier paths: Eight existing-profile ground-conversion paths accept legal standard responses and continue without a new strategy. Earlier preparation: The existing native preparation preference consumes the projected window; sixteen own-view existing-profile response paths accept16 legal proposals. No strategy tuning or difficulty calibration is claimed.",
"evidence": [
"tests/advisor-transport-flip-engine.test.ts",
"tests/advisor-automatic-flip-engine.test.ts",
"game/bots.ts",
"docs/ADVISOR_PREPARATION.md"
@@ -4359,7 +4361,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Documentation",
"status": "Partial",
"detail": "The ground-conversion contract distinguishes the printed counter, automatic setup, provisional continuous-alone duration and remaining producers. Earlier preparation: Supplied physical28 G and publisher timing distinguish the open window from shipment allowance flags. Denied-territory scope is provisional, existing Ecaz co-side composition is retained, and old pending/unknown-clock behavior and remaining forced-flip/combination limits are explicit.",
"detail": "Guild transport source/slot/payment and exactly-once arrival are distinguished from permission/rate; continuously-alone duration remains provisional. Earlier guidance: The ground-conversion contract distinguishes the printed counter, automatic setup, provisional continuous-alone duration and remaining producers. Earlier preparation: Supplied physical28 G and publisher timing distinguish the open window from shipment allowance flags. Denied-territory scope is provisional, existing Ecaz co-side composition is retained, and old pending/unknown-clock behavior and remaining forced-flip/combination limits are explicit.",
"evidence": [
"docs/ADVISOR_AUTOMATIC_FLIPS.md",
"docs/ADVISOR_PREPARATION.md",
@@ -4370,8 +4372,10 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Verification",
"status": "Partial",
"detail": "Ground conversion passes frozen16-file199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Genuine34/36-action sources retain originals; standard controls render without new callback/browser/deployed certification. Earlier preparation evidence: 16 files229/229, types/lint/build, twelve genuine new-control/gameplay/GameTable SSR journeys and sixteen minimal response paths pass. Public23/29/32/38/39-action sources preserve original setup advisor/Karama; a separate controlled ground/Terror unit proves positive Sneak Attack closure and zero nonclosure. Full combinations, visual/browser/deployed and comprehensive assurance remain open.",
"detail": "Transport passes frozen17-file243/243, types/lint/build, eight native transport/response/GameTable SSR journeys and eight existing-profile paths. Genuine36/42-action sources earn original Tueks5 and actual paid destination Intrusion. Affordable full-price return and unfunded cross remain distinct. Broader producers and final assurance stay open. Earlier proof: Ground conversion passes frozen16-file199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Genuine34/36-action sources retain originals; standard controls render without new callback/browser/deployed certification. Earlier preparation evidence: 16 files229/229, types/lint/build, twelve genuine new-control/gameplay/GameTable SSR journeys and sixteen minimal response paths pass. Public23/29/32/38/39-action sources preserve original setup advisor/Karama; a separate controlled ground/Terror unit proves positive Sneak Attack closure and zero nonclosure. Full combinations, visual/browser/deployed and comprehensive assurance remain open.",
"evidence": [
"tests/advisor-transport-flip-engine.test.ts",
"tests/fixture-advisor-transport-flip.ts",
"tests/advisor-automatic-flip-engine.test.ts",
"tests/advisor-automatic-flip-quote.test.ts",
"tests/fixture-advisor-automatic-flip.ts",
+3 -1
View File
@@ -30,7 +30,9 @@ for (const kind of ['arrival', 'departure'] as const) {
assert.deepEqual(f.game, before);
assert.equal(pending.response?.kind, 'advisorFlip');
assert.equal(pending.response?.automaticAdvisorFlip?.territory, f.territory);
assert.equal(pending.response?.automaticAdvisorFlip?.mover, f.actor);
const source = pending.response?.automaticAdvisorFlip;
assert.ok(source?.producer === 'groundMovement');
assert.equal(source.mover, f.actor);
assert.equal(player(pending, f.actor).moved, player(before, f.actor).moved + 1);
assert.equal(player(pending, f.actor).forces[f.from], undefined);
assert.equal(player(pending, f.actor).forces[f.to], f.amount);
+149 -2
View File
@@ -8,7 +8,12 @@ import {
type AdvisorSettlementContext,
type AutomaticAdvisorFlipContext,
type AutomaticAdvisorFlipSource,
type AutomaticAdvisorTransport,
} from '../game/advisor-automatic-flip';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
type GroundSource = Extract<AutomaticAdvisorFlipSource, { producer: 'groundMovement' }>;
type TransportSource = Extract<AutomaticAdvisorFlipSource, { producer: 'guildTransport' }>;
function context(): AutomaticAdvisorFlipContext {
return {
@@ -28,7 +33,7 @@ function context(): AutomaticAdvisorFlipContext {
function held() {
const ctx = context();
const choice = quoteAutomaticAdvisorFlips(ctx).find((entry) => entry.territory === 'imperial_basin')!;
const source: AutomaticAdvisorFlipSource = {
const source: GroundSource = {
event: 'automatic-after-original-ground-move', owner: choice.player,
territory: choice.territory, turn: ctx.turn, phase: ctx.phase,
producer: 'groundMovement', mover: 'a', move: 1, forces: choice.forces,
@@ -127,7 +132,7 @@ void test('the response validates its original held event and exact physical gro
});
void test('changed window, owner, occurrence, group, occupation and movement sources throw the named rule error', () => {
const changes: ((ctx: AutomaticAdvisorFlipContext, source: AutomaticAdvisorFlipSource) => void)[] = [
const changes: ((ctx: AutomaticAdvisorFlipContext, source: GroundSource) => void)[] = [
(ctx) => { ctx.advanced = false; },
(ctx) => { ctx.status = 'lobby'; },
(ctx) => { ctx.phase = 4; },
@@ -186,3 +191,145 @@ void test('quotes, settlement and response matching do not inspect private cards
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
assert.deepEqual(ctx.players.map((player) => ({ forces: player.forces, advisors: player.advisors })), before);
});
function transported(to: 'carthag' | 'reserves' = 'carthag') {
const { ctx, source: ground } = held();
const transport: AutomaticAdvisorTransport = {
event: 'committed-native-guild-transport', player: 'a', origin: 'imperial_basin',
to, sector: to === 'reserves' ? 0 : 11, amount: 3, elite: 0,
group: { 'imperial_basin:9': 2, 'imperial_basin:10': 1 },
};
ctx.players[1].shipped = true;
ctx.players[1].forces = to === 'reserves' ? {} : { 'carthag:11': 3 };
ctx.players[1].reserves = to === 'reserves' ? 3 : 0;
const source: TransportSource = {
event: 'automatic-after-original-guild-transport', owner: ground.owner,
territory: ground.territory, turn: ground.turn, phase: ground.phase,
forces: ground.forces, producer: 'guildTransport', transport,
};
ctx.players[0].advisors = {
...ctx.players[0].advisors,
imperial_basin: { lockedTurn: 4, aloneFlipHold: source.event },
};
return { ctx, source };
}
void test('committed cross-transport and reserve return bind the same held advisor origin and physical deposit', () => {
for (const destination of ['carthag', 'reserves'] as const) {
const { ctx, source } = transported(destination);
const before = structuredClone({ ctx, source });
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
assert.deepEqual({ ctx, source }, before);
assert.deepEqual(Object.keys(source).sort(), ['event', 'forces', 'owner', 'phase', 'producer', 'territory', 'transport', 'turn']);
assert.deepEqual(advisorStanceAfterSettlement({ advanced: true, players: ctx.players }, 'b', 'imperial_basin'), {
lockedTurn: 4, aloneFlipHold: source.event,
});
}
});
void test('a native BG transported advisor arrival can bind its destination group without imposing a Guild-faction actor', () => {
const { ctx, source } = transported();
source.transport = {
event: 'committed-bg-cross-transport', player: 'b', origin: 'hagga_basin',
to: 'imperial_basin', sector: 9, amount: 2, elite: 0, group: { 'hagga_basin:12': 2 },
};
ctx.players[0].shipped = true;
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
source.transport.sector = 10;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
});
void test('transport metadata uses current board identities and rejects unplaced dynamic destinations', () => {
const { ctx, source } = transported();
source.transport.to = MOBILE_STRONGHOLD;
source.transport.sector = 0;
ctx.players[1].forces = { [MOBILE_LOCATION]: 3 };
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
ctx.mobileStronghold = { location: 'imperial_basin:9' };
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
ctx.mobileStronghold.location = null;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
});
void test('changed transport metadata, selected group, native slot, deposit or response location rejects before settlement', () => {
const changes: ((ctx: AutomaticAdvisorFlipContext, source: TransportSource) => void)[] = [
(_ctx, source) => { source.transport.event = ''; },
(_ctx, source) => { source.transport.player = ''; },
(_ctx, source) => { source.transport.player = 'missing'; },
(_ctx, source) => { source.transport.origin = 'missing'; },
(_ctx, source) => { source.transport.to = 'missing'; },
(_ctx, source) => { source.transport.sector = -1; },
(_ctx, source) => { source.transport.sector = 11.5; },
(_ctx, source) => { source.transport.sector = 12; },
(_ctx, source) => { source.transport.amount = 0; },
(_ctx, source) => { source.transport.amount = 4; },
(_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 = { 'imperial_basin:9': 3.5 }; },
(_ctx, source) => { source.transport.group = { 'imperial_basin:9': 3, 'imperial_basin:10': 0 }; },
(_ctx, source) => { source.transport.group = { 'carthag:11': 3 }; },
(_ctx, source) => { source.transport.group = { 'imperial_basin:8': 3 }; },
(_ctx, source) => { source.transport.group = { 'imperial_basin:9.0': 3 }; },
(_ctx, source) => {
source.transport.origin = 'arrakeen';
source.transport.group = { 'arrakeen:10': 3 };
},
(ctx) => { ctx.players[1].shipped = false; },
(ctx) => { delete ctx.players[1].shipped; },
(ctx) => { ctx.players[1].forces = { 'carthag:11': 2 }; },
(ctx) => { ctx.players[1].forces = { 'carthag:10': 3 }; },
(ctx) => { ctx.players = [...ctx.players, { ...ctx.players[1] }]; },
(_ctx, source) => { source.forces = { 'imperial_basin:9': 3 }; },
];
for (const change of changes) {
const { ctx, source } = transported();
change(ctx, source);
const before = structuredClone({ ctx, source });
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
assert.deepEqual({ ctx, source }, before);
}
const { ctx, source } = transported('reserves');
ctx.players[1].reserves = 2;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
delete ctx.players[1].reserves;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
for (const malformed of [
{ ...source, mover: 'a' },
{ ...source, transport: null },
{ ...source, transport: { ...source.transport, paid: 1 } },
{ ...source, transport: { ...source.transport, group: null } },
]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
});
void test('old ground receipts keep their exact JSON shape and never require transport slot or reserve metadata', () => {
const { ctx, source } = held();
for (const player of ctx.players) {
for (const field of ['shipped', 'reserves']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Ground source read transport-only ${field}`); },
});
}
}
const saved = JSON.parse(JSON.stringify(source)) as GroundSource;
assert.deepEqual(Object.keys(saved).sort(), ['event', 'forces', 'move', 'mover', 'owner', 'phase', 'producer', 'territory', 'turn']);
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, saved));
assert.throws(() => validateAutomaticAdvisorFlip(ctx, { ...saved, transport: {} } as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
});
void test('transport validation reads only public slot and physical deposit metadata, not wallets, cards or leaders', () => {
for (const destination of ['carthag', 'reserves'] as const) {
const { ctx, source } = transported(destination);
for (const player of ctx.players) {
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Transport advisor rule read ${player.id} private ${field}`); },
});
}
}
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
}
});
+165
View File
@@ -0,0 +1,165 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { fighterCount, isAdvisor } from '../game/advisors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAdvisorTransportFlipFixture as fixture,
actAdvisorTransportFlip as act,
commitAdvisorTransportFlip as commit,
advisorTransportFlipPlayer as player,
advisorTransportFlipInventory as inventory,
passAdvisorTransportFlip as pass,
finishAdvisorTransportFlip as finish,
} from './fixture-advisor-transport-flip';
function passKind(source: Game, kind: string) {
let g = source;
for (let i = 0; g.response?.kind === kind && i < 20; i++) {
const actor = g.players.find(p => !g.response!.passed.includes(p.id)); assert.ok(actor);
g = act(g, actor.id, { type: 'passResponse' });
}
assert.notEqual(g.response?.kind, kind); return g;
}
for (const kind of ['cross', 'return'] as const) for (const canceled of [false, true]) {
void test(`native ${kind} transport stays paid and physically committed after ${canceled ? 'preventing' : 'allowing'} advisor conversion`, () => {
const f = fixture({ kind }), original = structuredClone(f.game);
const committed = commit(f.game, f), pending = committed.game;
assert.deepEqual(f.game, original);
assert.equal(pending.response?.kind, 'advisorFlip');
assert.equal(pending.response?.automaticAdvisorFlip?.producer, 'guildTransport');
assert.ok(committed.price > 0);
assert.equal(player(pending, f.guild).spice, player(original, f.guild).spice - committed.price);
assert.equal(player(pending, f.guild).forces[f.from], undefined);
assert.equal(player(pending, f.guild).moved, 0);
assert.equal(player(pending, f.guild).shipped, true);
assert.equal(fighterCount(player(pending, f.bg), f.territory), 0);
assert.ok(viewGame(pending, f.holder).responseControls?.cancelCards.includes(f.card));
if (kind === 'return') assert.equal(player(pending, f.guild).reserves, player(original, f.guild).reserves + 5);
else assert.equal(player(pending, f.guild).forces[f.to], 5);
const actor = structuredClone(player(pending, f.guild));
const done = canceled
? act(pending, f.holder, { type: 'card', card: f.card, mode: 'cancel' })
: pass(pending);
assert.deepEqual(player(done, f.guild), actor);
assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
assert.equal(done.pendingAutomaticAdvisorTransport, undefined);
assert.equal(isAdvisor(player(done, f.bg), f.territory), canceled);
assert.equal(done.discard.filter(c => c.id === f.card).length, canceled ? 1 : 0);
assert.deepEqual(inventory(done), f.originalInventory);
const continued = finish(normalizeAutomaticGame(done));
assert.ok(continued.phase >= 6);
assert.equal(isAdvisor(player(continued, f.bg), f.territory), canceled);
assert.deepEqual(inventory(continued), f.originalInventory);
});
}
void test('a completed cross transport retains an actual legal ordinary ground move after prevention', () => {
const f = fixture(), pending = commit(f.game, f).game;
const done = act(pending, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
const price = player(done, f.guild).spice;
const moved = act(done, f.guild, { type: 'move', from: f.to,
territory: 'arrakeen', sector: 10, amount: 1 });
assert.equal(player(moved, f.guild).moved, 1);
assert.equal(player(moved, f.guild).spice, price);
assert.equal(player(moved, f.guild).forces['arrakeen:10'], 1);
assert.equal(moved.discard.filter(c => c.id === f.card).length, 1);
assert.equal(isAdvisor(player(moved, f.bg), f.territory), true);
});
void test('preventing original transport permission never creates a physical conversion occurrence', () => {
const f = fixture(), permission = act(f.game, f.guild, f.action);
assert.equal(permission.response?.kind, 'guildTransportPermission');
const done = act(permission, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
assert.equal(player(done, f.guild).forces[f.from], 5);
assert.equal(player(done, f.guild).spice, player(f.game, f.guild).spice);
assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
assert.equal(done.pendingAutomaticAdvisorTransport, undefined);
assert.equal(player(done, f.bg).advisors?.[f.territory]?.aloneFlipHold, undefined);
assert.equal(isAdvisor(player(done, f.bg), f.territory), true);
});
void test('a prevented rate still settles the original physical transport before an unopposed automatic conversion', () => {
const f = fixture({ kind: 'return' }), permission = act(f.game, f.guild, f.action);
const rate = passKind(permission, 'guildTransportPermission');
assert.equal(rate.response?.kind, 'guildRate');
const done = act(rate, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
assert.equal(player(done, f.guild).forces[f.from], undefined);
assert.equal(player(done, f.guild).reserves, player(f.game, f.guild).reserves + 5);
assert.ok(player(done, f.guild).spice < player(f.game, f.guild).spice);
assert.equal(player(done, f.guild).moved, 0);
assert.equal(isAdvisor(player(done, f.bg), f.territory), false);
assert.equal(done.pendingAutomaticAdvisorTransport, undefined);
assert.equal(done.response, null);
assert.deepEqual(inventory(done), f.originalInventory);
});
void test('an unaffordable canceled cross tariff leaves the original army and advisor occurrence untouched', () => {
const f = fixture(), permission = act(f.game, f.guild, f.action);
const rate = passKind(permission, 'guildTransportPermission');
assert.equal(rate.response?.kind, 'guildRate');
const done = act(rate, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
assert.equal(player(done, f.guild).forces[f.from], 5);
assert.equal(player(done, f.guild).forces[f.to], undefined);
assert.equal(player(done, f.guild).spice, player(f.game, f.guild).spice);
assert.equal(isAdvisor(player(done, f.bg), f.territory), true);
assert.equal(player(done, f.bg).advisors?.[f.territory]?.aloneFlipHold, undefined);
assert.equal(done.pendingAutomaticAdvisorTransport, undefined);
assert.equal(done.pendingAutomaticAdvisorFlips, undefined);
assert.deepEqual(inventory(done), f.originalInventory);
});
void test('a changed committed transport parent rejects before discarding the prevention card', () => {
const f = fixture(), pending = commit(f.game, f).game;
pending.pendingAutomaticAdvisorTransport = { ...pending.pendingAutomaticAdvisorTransport!, amount: 4 };
const before = structuredClone(pending);
assert.throws(() => applyAction(pending, f.holder, { type: 'card', card: f.card, mode: 'cancel' }));
assert.deepEqual(pending, before);
assert.ok(player(pending, f.holder).hand.some(c => c.id === f.card));
});
for (const canceled of [false, true]) {
void test(`transport arrival resumes its actual Intrusion once after ${canceled ? 'prevented' : 'allowed'} origin conversion`, () => {
const f = fixture({ intrusion: true }), pending = commit(f.game, f).game;
const paid = player(pending, f.guild).spice;
assert.equal(pending.decision, null);
assert.equal(pending.response?.automaticAdvisorFlip?.producer, 'guildTransport');
const arrival = canceled
? act(pending, f.holder, { type: 'card', card: f.card, mode: 'cancel' })
: pass(pending);
assert.equal(arrival.pendingAutomaticAdvisorTransport, undefined);
assert.equal(arrival.decision?.kind, 'intrusion');
assert.equal(arrival.decision?.kind === 'intrusion' && arrival.decision.territory, 'imperial_basin');
assert.equal(player(arrival, f.guild).forces[f.to], 5);
assert.equal(player(arrival, f.guild).spice, paid);
assert.equal(player(arrival, f.guild).moved, 0);
let done = pass(act(arrival, f.bg, { type: 'decision', accept: true }));
assert.equal(isAdvisor(player(done, f.bg), 'imperial_basin'), true);
assert.equal(isAdvisor(player(done, f.bg), f.territory), canceled);
for (let read = 0; read < 3; read++) {
done = normalizeAutomaticGame(done);
assert.equal(done.decision, null);
assert.equal(done.response, null);
assert.equal(done.pendingAutomaticAdvisorTransport, undefined);
}
assert.deepEqual(inventory(done), f.originalInventory);
assert.ok(finish(done).phase >= 6);
});
}
void test('four existing profiles resolve both committed transport conversions legally', () => {
for (const kind of ['cross', 'return'] as const) for (const profile of DIFFICULTIES) {
const f = fixture({ kind }), pending = commit(f.game, f).game;
const own = viewGame(pending, f.holder);
own.players.find(p => p.id === f.holder)!.bot = profile;
const action = botActions(own)[0]; assert.ok(action);
const done = pass(act(pending, f.holder, action));
assert.equal(done.pendingAutomaticAdvisorTransport, undefined);
assert.equal(player(done, f.guild).moved, 0);
assert.equal(player(done, f.guild).shipped, true);
assert.deepEqual(inventory(done), f.originalInventory);
assert.ok(finish(done).phase >= 6);
}
});
+197
View File
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import {
actAdvisorPreparation, advisorPreparationClean, advisorPreparationInventory,
advisorPreparationPlayer, nextAdvisorPreparationStep,
} from './fixture-advisor-preparation';
export type AdvisorTransportFlipStep = { actor: string; action: Action };
export type AdvisorTransportFlipTransition = {
before: Game; steps: AdvisorTransportFlipStep[]; after: Game;
};
export const actAdvisorTransportFlip = actAdvisorPreparation;
export const advisorTransportFlipClean = advisorPreparationClean;
export const advisorTransportFlipInventory = advisorPreparationInventory;
export const advisorTransportFlipPlayer = advisorPreparationPlayer;
/** The original public setup places the advisor beside Guild's native army.
* Guild may actually take its native early turn; no origin group is moved. */
export function nextAdvisorTransportFlip(game: Game): AdvisorTransportFlipStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.status === 'setup' && game.setupStage === 'forces' &&
!advisorTransportFlipPlayer(game, 'beneGesserit').advisorSetup)
return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'tueks_sietch', sector: 5 } };
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: true } };
}
return nextAdvisorPreparationStep(game);
}
export function advanceAdvisorTransportFlip(game: Game, until: (state: Game) => boolean,
steps: AdvisorTransportFlipStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine transport-flip chronology ended before its requested boundary');
const next = nextAdvisorTransportFlip(game);
assert.ok(next, `Choose the original native control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actAdvisorTransportFlip(game, next.actor, next.action, steps);
}
throw Error('The native transport-flip source did not reach its finite boundary');
}
export function passAdvisorTransportFlip(game: Game, steps: AdvisorTransportFlipStep[] = []): Game {
for (let count = 0; game.response && count < 60; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actAdvisorTransportFlip(game, actor.id, { type: 'passResponse' }, steps);
}
assert.ok(!game.response); return game;
}
/** Public Advanced BG/Atreides/Guild. The native advisor and all five original
* Guild counters share Tuek's Sietch, whose printed sector is 5. Stop BEFORE
* cross/return transport. All payment, transfer and flip responses are native
* caller continuations; no played force, phase, wallet, hand or hold is set. */
export function createAdvisorTransportFlipFixture(options: { kind?: 'cross' | 'return'; intrusion?: boolean } = {}) {
const kind = options.kind ?? 'cross', intrusion = options.intrusion ?? false;
assert.ok(!intrusion || kind === 'cross', 'The native Intrusion source uses the cross route');
const bg = 'beneGesserit', holder = 'atreides', guild = 'guild';
let game = createGame('ADVISORTRANSPORTFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
joinGame(game, newPlayer(guild, 'Guild', 'guild'));
const initial = structuredClone(game), steps: AdvisorTransportFlipStep[] = [];
const act = (actor: string, action: Action) => { game = actAdvisorTransportFlip(game, actor, action, steps); };
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorTransportFlip(game, until, steps); };
const transition = (run: () => void): AdvisorTransportFlipTransition => {
const before = structuredClone(game), start = steps.length; run();
return { before, steps: structuredClone(steps.slice(start)), after: structuredClone(game) };
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
assert.equal(game.expansions.length, 0);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalInventory = advisorTransportFlipInventory(game);
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: AdvisorTransportFlipTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextAdvisorTransportFlip(game); assert.ok(next);
const before = next.action.type === 'advisorSetup' ? structuredClone(game) : null;
act(next.actor, next.action);
if (before) advisorSetup = { before, steps: [structuredClone(next)], after: structuredClone(game) };
}
assert.equal(game.status, 'playing'); assert.ok(advisorSetup);
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
assert.equal(advisorTransportFlipPlayer(game, bg).forces['tueks_sietch:5'], 1);
assert.ok(advisorTransportFlipPlayer(game, bg).advisors?.tueks_sietch);
assert.equal(advisorTransportFlipPlayer(game, guild).forces['tueks_sietch:5'], 5);
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 });
if (intrusion) {
for (let count = 0; count < 100 && !(game.phase === 5 && game.active === bg && advisorTransportFlipClean(game)); count++) {
const next = nextAdvisorTransportFlip(game); assert.ok(next);
if (!game.phaseOpening && !game.response && game.decision?.kind === 'guildTiming')
next.action = { type: 'decision', take: false };
act(next.actor, next.action);
}
assert.ok(game.phase === 5 && game.active === bg && advisorTransportFlipClean(game));
} else reach(state => state.phase === 5 && state.active === guild && advisorTransportFlipClean(state));
});
let intrusionShipment: AdvisorTransportFlipTransition | null = null;
let bgEnd: AdvisorTransportFlipTransition | null = null;
if (intrusion) {
intrusionShipment = transition(() => {
act(bg, { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 1, elite: 0, allyPayment: 0 });
reach(advisorTransportFlipClean);
});
assert.equal(advisorTransportFlipPlayer(intrusionShipment.before, bg).spice - advisorTransportFlipPlayer(game, bg).spice, 2);
assert.equal(advisorTransportFlipPlayer(game, bg).forces['imperial_basin:10'], 1);
assert.ok(!advisorTransportFlipPlayer(game, bg).advisors?.imperial_basin);
bgEnd = transition(() => {
act(bg, { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === guild && advisorTransportFlipClean(state));
});
}
assert.equal(game.active, guild); assert.equal(game.phase, 5); assert.ok(advisorTransportFlipClean(game));
assert.equal(advisorTransportFlipPlayer(game, bg).forces['tueks_sietch:5'], 1);
assert.ok(advisorTransportFlipPlayer(game, bg).advisors?.tueks_sietch);
assert.equal(advisorTransportFlipPlayer(game, guild).forces['tueks_sietch:5'], 5);
assert.equal(advisorTransportFlipPlayer(game, guild).shipped, false);
assert.equal(advisorTransportFlipPlayer(game, guild).moved, 0);
assert.equal(advisorTransportFlipPlayer(game, bg).forces['imperial_basin:10'] ?? 0, intrusion ? 1 : 0);
assert.ok(game.players.filter(player => player.id !== bg).every(player => (player.forces['imperial_basin:10'] ?? 0) === 0));
assert.ok(advisorTransportFlipPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.deepEqual(advisorTransportFlipInventory(game), originalInventory);
const from = 'tueks_sietch:5', to = kind === 'return' ? 'reserves' : 'imperial_basin:10';
const action: Action = { type: 'guildShip', from, amount: 5, elite: 0, allyPayment: 0,
territory: kind === 'return' ? 'reserves' : 'imperial_basin', ...(kind === 'cross' ? { sector: 10 } : {}) };
return { game, source: structuredClone(game), beforeAction: structuredClone(game), initial, lobby, setup,
beforeStartingDeal, afterSetup, advisorSetup, openingOffer, declinedPreparation, intrusionShipment, bgEnd,
kind, intrusion, bg, owner: bg, holder, atreides: holder, guild, actor: guild, observer: guild,
card: karama.id, karama: karama.id, action, transportStep: { actor: guild, action } satisfies AdvisorTransportFlipStep,
territory: 'tueks_sietch', sector: 5, from, to, amount: 5,
steps, actions: steps, sourceActionCount: steps.length, originalInventory, originalSpice, dealOrder, spiceOrder,
staging: [
'Actual public Advanced Bene Gesserit/Atreides/Guild ready/startPreview, prediction and private Traitor choices. The native advisorSetup action places one advisor at Tuek’s Sietch5 beside Guild’s original five counters. The printed board and native setup both use sector5; no sector9 position is manufactured.',
'Select only original scalar shuffle order, three original unopened Treachery positions and two original unopened ordinary Spice positions. Atreides really receives its original starting Karama; no later card or hand acquisition is staged.',
intrusion
? 'Actually complete safe first Storm, both Spice blows, Charity, normal Bidding and Revival, then decline optional advisor preparation and Guild’s first native early timing opportunity. BG pays two to ship one reserve into empty Imperial Basin10 as a fighter; original Guild interception/income controls resolve with the original Karama retained. BG really ends its turn, then Guild takes its next native early opportunity. The original BG advisor and five Guild counters remain at Tuek’s Sietch5.'
: 'Actually complete safe first Storm, both Spice blows, Charity, normal Bidding and Revival. Decline original optional advisor preparation, then actually take Guild’s native early movement turn and allow its original response. All five Guild source counters remain unmoved.',
kind === 'return'
? 'Stop before the actual guildShip return of all five original Tuek’s counters to abstract reserves. Permission, rate, payment, physical return and automatic advisor conversion remain caller actions.'
: intrusion
? 'Stop before the actual guildShip cross-shipment of all five original Tuek’s counters to Imperial Basin10, where BG’s real paid fighter now stands. Original permission/rate, physical transfer, origin automatic conversion and destination Intrusion remain caller continuations.'
: 'Stop before the actual guildShip cross-shipment of all five original Tuek’s counters to empty Imperial Basin10. Permission, rate, payment, physical transfer and automatic advisor conversion remain caller actions.',
'No played army, advisor stance, phase, wallet, hand, reserve, hold, response or receipt is assigned. This source does not assert an automatic-flip outcome.',
],
};
}
export type AdvisorTransportFlipFixture = ReturnType<typeof createAdvisorTransportFlipFixture>;
/** Allow only original transport continuations and stop BEFORE passing the
* automatic advisor response. Older engines can reach a clean committed state;
* this source helper does not substitute an expected flip outcome. */
export function commitAdvisorTransportFlip(source: Game, fixture: AdvisorTransportFlipFixture,
steps: AdvisorTransportFlipStep[] = []) {
const before = structuredClone(source), start = steps.length;
let game = source;
if (!game.pendingGuildTransport && !game.response)
game = actAdvisorTransportFlip(game, fixture.guild, fixture.action, steps);
for (let count = 0; count < 100; count++) {
const guild = advisorTransportFlipPlayer(game, fixture.guild);
const committed = guild.shipped && (guild.forces[fixture.from] ?? 0) === 0;
if (game.response?.automaticAdvisorFlip || (committed && advisorTransportFlipClean(game))) {
assert.ok(committed, 'The actual physical transport commits before its advisor response');
assert.ok(advisorTransportFlipPlayer(game, fixture.holder).hand.some(card => card.id === fixture.card));
return { game, before, after: structuredClone(game), steps: structuredClone(steps.slice(start)),
price: advisorTransportFlipPlayer(before, fixture.guild).spice - guild.spice };
}
const next = nextAdvisorTransportFlip(game); assert.ok(next, 'The original transport continuation needs its live native control');
game = actAdvisorTransportFlip(game, next.actor, next.action, steps);
}
throw Error('The native Guild transport did not reach its physical commitment boundary');
}
export function finishAdvisorTransportFlip(source: Game, steps: AdvisorTransportFlipStep[] = []): Game {
return advanceAdvisorTransportFlip(source, game => game.phase >= 6 && game.phase <= 7 && advisorTransportFlipClean(game), steps);
}