Offer Karama against mandatory advisor ground conversions

Keep completed moves while allowing native advisor-to-fighter conversion to be prevented after empty arrival or the last outsider's ground departure. Bind each queued response to its actual whole group and movement. Shared settlement honors the explicit provisional continuously-alone cancellation duration; setup remains automatic and non-ground producers remain open.

Validation: 16 related files199/199, types/lint/build, five native move/response/GameTable SSR journeys and eight legal existing-profile paths. Native34/36-action public Advanced sources earn original setup/counters/Karama. Existing standard controls render; no new component-callback, browser/deployed or full-mode certificate. No saved-room/D1/restart/deployment operation.
This commit is contained in:
Quintine committed 2026-10-24 19:44:12 +11:00
1 parent 1a505525a8
commit 3aca64412f
15 files changed
+874 -34

No files matched your search

+8
View File
@@ -2471,6 +2471,14 @@ export function GameTable({
{' '}leaves {g.paymentIncome.bank} spice in the bank. The payer’s cost stays the same.
</p>
)}
{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.
{' '}Provisional rule: a prevented group stays advisors while continuously alone,
until another faction returns or the group leaves.
</p>
)}
{g.response.kind === 'advisor' && (
<p className="notice">
{g.players.find((player) => player.id === g.response?.owner)?.name} declared {g.response.amount ?? 1} free {(g.response.amount ?? 1) === 1 ? 'force' : 'forces'} to {territory(splitLocation(g.response.location ?? 'polar_sink:0').territory).name}.
+89
View File
@@ -0,0 +1,89 @@
# Mandatory advisor conversion after ground movement
## Source and provisional duration — 24 October 2026
Status: **Prototyped / Partial**. Supplied authorized Advanced physical28 E says
advisors alone in a territory automatically become fighters. Physical17’s Karama
table permits preventing one group’s flip to or from advisors. Applying the two
connects a real counter to that mandatory conversion; automatic does not itself
confer immunity. Physical16 permits Karama only after setup/At Start, so setup
conversions remain automatic without this response.
This slice connects ground-movement producers: an advisor group entering an
empty destination and the last outsider departing its territory. The physical
move, counters, normal movement opportunity and any completed movement promise
are already committed. The ordinary advisorFlip response then permits or
prevents the conversion. Prevention cannot undo movement or spend another move.
The 14 October choose-recommended authority selects a **provisional duration**:
the prevented group remains advisors while continuously alone in that territory,
across phase and turn changes. Another faction’s presence or the physical group
becoming empty closes that occurrence. A group moving elsewhere gets its new
destination’s ordinary stance and a new conversion opportunity when applicable.
This duration is not a directly retrieved publisher ruling. Repeated normalization
cannot simply erase the canceled result or offer an immediate free retry.
## Connected behavior
- [Pure rule](../game/advisor-automatic-flip.ts) identifies current public groups,
binds a current turn/phase/mover/movement count and exact physical group, and
settles a held stance without reading cards, leaders or spice.
- [Engine](../game/engine.ts) marks each occurrence after physical ground
movement, queues its original source and opens the existing native response.
Allowance deletes the advisor stance; cancellation retains the occurrence
hold. The source is revalidated before card disposal and outcome settlement.
Multiple groups retain ordered independent responses.
- [Advisor normalization](../game/advisors.ts) and the shared
[board resolution quote](../game/board-resolution-quote.ts) retain held
advisors. Such forces still do not fight, collect, grant stronghold income,
count for victory or supply ornithopters. Ordinary arrival matching retains an
existing held group’s stance instead of silently converting it when joined.
- [Karama source selection](../game/karama-context.ts) binds the actual pending
occurrence rather than unrelated game state. The normal
[table response](../components/game-table.tsx) explains that the move is complete
and displays the provisional duration. There is no new confirmation or timer.
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
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.
## Bounded verification
The [native fixture](../tests/fixture-advisor-automatic-flip.ts) starts genuine
public Advanced Bene Gesserit/Atreides/Guild. The native advisorSetup action puts
one advisor beside Atreides’ original ten Arrakeen forces. Only unopened original
Treachery/Spice order and scalar shuffle order are selected. Atreides receives
the original Karama, both Spice draws and ordinary first-round controls execute,
the original optional preparation is declined and Guild uses its timing choice.
The arrival branch stops at BG’s turn; the departure branch really skips that
turn and stops at Atreides’. No played army, phase, wallet, stance, card hand,
response or prevention receipt is installed.
[Native engine cases](../tests/advisor-automatic-flip-engine.test.ts) cover both
allowed/canceled moves, continuous hold through actual phase continuation,
an actual outsider return, changed-source rejection and existing profiles.
[Pure cases](../tests/advisor-automatic-flip-quote.test.ts) cover group selection,
settlement and exact source validation. The former empty-arrival expectation in
[advisor regressions](../tests/advisors.test.ts) is cut over explicitly.
Frozen **16 files199/199**, types/lint and production build pass. Five native
move/response/GameTable SSR journeys cover allowed/canceled arrival/departure and
an actual outsider return. Eight existing-profile paths accept legal standard
responses and continue. The original arrival source takes34 actions; departure
takes36, including the real skipped BG movement. Existing Karama/Allow controls
render, without a new component-callback or browser certificate. One redundant
import is repaired after the first passing199-case union; the final frozen
source then passes every check.
Private reports, rendered HTML and hashes:
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-automatic-flips/`.
`frozen-integrated-2/report.json` records source fingerprint
`385c4758cd118574be8431593267a3198dd570eea6960c02e45a462e23494c4c`
on base `1a505525a8aa1499bc6270da2241506baf3e5be2`. Prose/reference evidence is
reviewed and hashed separately after verification.
Comprehensive rules, combinations, saved-game/recovery/privacy/custody assurance,
browser/mobile/deployed acceptance and full AI refinement remain open.
+26
View File
@@ -16,6 +16,32 @@ open mode gates.
## Current checkpoint and work
**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
movement and before fighter benefits. Allow converts once. Cancellation preserves
the move and holds the advisor stance while continuously alone; another faction’s
actual return clears the hold. That duration is explicitly provisional. Setup
stays automatic; non-ground mandatory producers and matching-group conversions
remain open scope.
[Source, implementation and limits](ADVISOR_AUTOMATIC_FLIPS.md).
Disjoint pure-helper/native-fixture lanes, bounded source review and root
integration pass **16 files199/199**, types/lint/build, five native move/response/
GameTable SSR journeys and eight legal existing-profile paths. Genuine34/36-action
public Advanced setup, original advisor placement, actual first-round controls,
preparation decline and Guild timing retain Atreides’ original Karama; no played
army, phase, hand, wallet or hold is staged. Feedback removes one redundant import;
all frozen source checks then pass. Standard existing Karama/Allow controls are
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
+45
View File
@@ -1,5 +1,50 @@
# Dune implementation status
## 24 October 2026 — Mandatory advisor ground-conversion counter
Connect supplied physical17’s token-group flip counter with physical28 E’s
alone-advisor obligation. Actual ground movement commits first, then opens the
existing advisorFlip response for empty advisor arrivals or the last outsider’s
departure. Exact turn/phase/mover/movement-count/whole-group sources and ordered
pending occurrences revalidate before disposal or outcome. Allow converts once;
preventing the flip retains the physical move and advisor stance. No card or
movement opportunity is replayed. Setup conversions remain automatic.
The 14 October selected duration is provisional: retain the hold while that
territory’s group is continuously alone, including across phases/turns; foreign
presence or physical emptiness ends it. Both normalization and readonly board
resolution honor the same stance, so prevented forces gain no fighter, income,
collection or victory benefits. Joining held advisors retains their stance.
Native ground-movement scope does not implement every other mandatory producer,
matching-group conversion or wider special arrival. Existing minimal AI response
policy is reused, without strategy refinement.
Frozen **16 files199/199**, types/lint/build, five native move/response/GameTable
SSR journeys and eight existing-profile paths pass. Genuine34-action arrival and
36-action departure sources use public Advanced BG/Atreides/Guild setup, original
advisorSetup at Arrakeen, unopened original deals, actual Storm/Spice/Charity/
Bidding/Revival, preparation decline and Guild timing. Departure actually skips
BG’s turn before the original ten Atreides move. No played army, phase, hand,
wallet, stance, response or hold is installed. Four allowance/cancellation
journeys continue to Battle/Collection; a fifth actual outsider return ends the
hold without changing advisor stance.
One narrow native arrival case passes. The first frozen union passes199/199 and
types; lint catches the import made redundant by shared settlement. Remove it,
then rerun the coherent frozen checks: all pass, including runtime without
source changes or private driver repairs. Standard existing Karama/Allow
controls render; this is SSR/engine proof, not new component-callback,
browser/mobile or deployed evidence.
[Contract and remaining producers](ADVISOR_AUTOMATIC_FLIPS.md). Private evidence:
`/home/quintine/.local/share/dune/checkpoints/20261024-advisor-automatic-flips/`.
Frozen source fingerprint:
`385c4758cd118574be8431593267a3198dd570eea6960c02e45a462e23494c4c`
(2,733 nonignored files), based on `1a505525a8aa1499bc6270da2241506baf3e5be2`.
Final prose/reference overlay is separately reviewed and hashed. Complete
Advanced/modes, remaining producers, combinations and comprehensive assurance
stay open. No saved-room/D1/reset/restart/deployment operation.
## 24 October 2026 — Tleilaxu proactive Revival prevention
Implement supplied physical38 H’s any-player Revival window through a focused
+17
View File
@@ -1,5 +1,22 @@
# Rule decision index
## Mandatory advisor conversion after ground movement — 24 October 2026
Compose supplied Advanced physical17’s token-group flip counter with physical28 E’s
mandatory conversion when alone. A committed advisor move into an empty territory,
or the last outsider’s ground departure, now opens the original advisorFlip
Karama response before the group gains fighter benefits. Allowance converts once;
cancellation preserves the completed movement and leaves the group as advisors.
Setup stays automatic under physical16’s post-setup Karama timing.
The 14 October recommended policy provisionally retains that cancellation while
the territory’s advisor group is continuously alone, across phase/turn changes.
Foreign presence or physical emptiness ends the held occurrence; it is not an
immediate retry opportunity for the canceled flip. This duration is not a direct
publisher ruling. The shared settlement/board quote honors the hold. Other
mandatory producers and matching-group conversions remain open scope.
[Source, connected movement and limits](ADVISOR_AUTOMATIC_FLIPS.md).
## Tleilaxu proactive Revival prevention — 24 October 2026
Supplied Advanced physical38 H permits selecting any one player during Revival,
+139
View File
@@ -0,0 +1,139 @@
import type { FactionId } from './catalog';
import { splitLocation } from './board';
import { presenceAt, type ForcePresence } from './force-presence';
export type AdvisorStance = {
lockedTurn?: number;
aloneFlipHold?: string;
};
export type AutomaticAdvisorFlipSource = {
event: string;
owner: string;
territory: string;
turn: number;
phase: number;
producer: 'groundMovement';
mover: string;
move: number;
forces: Record<string, number>;
};
export type AutomaticAdvisorFlipPlayer = ForcePresence & {
id: string;
faction: FactionId;
advisors?: Readonly<Record<string, AdvisorStance>>;
moved?: number;
};
export type AdvisorSettlementContext = {
advanced: boolean;
players: readonly AutomaticAdvisorFlipPlayer[];
};
export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & {
status: string;
turn: number;
phase: number;
};
export class AutomaticAdvisorFlipError extends Error {
constructor(message: string) {
super(message);
this.name = 'AutomaticAdvisorFlipError';
}
}
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 record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireFlip(value: unknown, message: string): asserts value {
if (!value) throw new AutomaticAdvisorFlipError(message);
}
function physicalGroup(player: AutomaticAdvisorFlipPlayer, territory: string): Record<string, number> {
return Object.fromEntries(Object.entries(player.forces).filter(
([key, count]) => splitLocation(key).territory === territory && count > 0,
));
}
function otherPresence(ctx: AdvisorSettlementContext, player: AutomaticAdvisorFlipPlayer, territory: string) {
return ctx.players.some((other) => other.id !== player.id && presenceAt(other, territory) > 0);
}
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.
* No cards, resources, leaders or hidden marker values determine eligibility. */
export function quoteAutomaticAdvisorFlips(ctx: AutomaticAdvisorFlipContext): {
player: string;
territory: string;
forces: Record<string, number>;
}[] {
if (!currentWindow(ctx)) return [];
const flips: { player: string; territory: string; forces: Record<string, number> }[] = [];
for (const player of ctx.players) {
if (player.faction !== 'beneGesserit') continue;
for (const [territory, stance] of Object.entries(player.advisors ?? {})) {
if (stance.aloneFlipHold !== undefined || otherPresence(ctx, player, territory)) continue;
const forces = physicalGroup(player, territory);
if (Object.keys(forces).length) flips.push({ player: player.id, territory, forces });
}
}
return flips;
}
/** Keep a prevented alone occurrence until physical emptiness or foreign entry.
* With no occurrence hold, retain the legacy automatic conversion, including setup. */
export function advisorStanceAfterSettlement(
ctx: AdvisorSettlementContext,
player: AutomaticAdvisorFlipPlayer | string,
territory: string,
): AdvisorStance | undefined {
const current = ctx.players.find((candidate) => candidate.id === (typeof player === 'string' ? player : player.id));
const stance = current?.advisors?.[territory];
if (!current || current.faction !== 'beneGesserit' || !stance) return undefined;
if (!ctx.advanced) return { ...stance };
if (!Object.keys(physicalGroup(current, territory)).length) return undefined;
if (otherPresence(ctx, current, territory)) {
const remaining = { ...stance };
delete remaining.aloneFlipHold;
return remaining;
}
return stance.aloneFlipHold !== undefined ? { ...stance } : undefined;
}
/** Validate the same committed move 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' &&
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.');
const owners = ctx.players.filter((player) => player.id === source.owner);
const owner = owners[0];
requireFlip(owners.length === 1 && owner?.faction === 'beneGesserit' &&
owner.advisors?.[source.territory]?.aloneFlipHold === source.event,
'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.');
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) &&
Object.keys(forces).sort().join(',') === Object.keys(source.forces).sort().join(',') &&
Object.entries(forces).every(([key, count]) => source.forces[key] === count),
'Automatic advisor conversion needs the same complete physical advisor group.');
}
+5 -6
View File
@@ -1,4 +1,5 @@
import type { Game, Player } from './engine';
import { advisorStanceAfterSettlement } from './advisor-automatic-flip';
import { presenceAt, type ForcePresence } from './force-presence';
/** Effective presence includes a concealed No-Field; physical units stay in forces. */
@@ -18,11 +19,9 @@ export function settleAdvisors(g: Game) {
const bg = g.players.find((p) => p.faction === 'beneGesserit');
if (!bg?.advisors) return;
for (const t of Object.keys(bg.advisors)) {
if (
!forceCount(bg, t) ||
!g.players.some((p) => p.id !== bg.id && forceCount(p, t))
)
delete bg.advisors[t];
const stance = advisorStanceAfterSettlement(g, bg, t);
if (stance) bg.advisors[t] = stance;
else delete bg.advisors[t];
}
}
@@ -37,8 +36,8 @@ export function arrivalAsAdvisor(
accompanying = false,
) {
if (!g.advanced || p.faction !== 'beneGesserit') return false;
if (forceCount(p, to)) return isAdvisor(p, to);
if (!g.players.some((other) => other.id !== p.id && forceCount(other, to)))
return false;
if (forceCount(p, to)) return isAdvisor(p, to);
return accompanying || (!!source && isAdvisor(p, source));
}
+7 -6
View File
@@ -1,6 +1,7 @@
import type { Player } from './engine';
import { advisorStanceAfterSettlement } from './advisor-automatic-flip';
import type { ForcePresence } from './force-presence';
import { presenceAt, presenceByLocation } from './force-presence';
import { presenceByLocation } from './force-presence';
import { fighterCount, isAdvisor } from './advisors';
import { quoteAidRefunds, PhaseResourceError } from './phase-resource-quote';
import { ecazOccupancyIdentity } from './ecaz-occupy';
@@ -81,14 +82,14 @@ export function settledBoard(g: BoardContext) {
const players = g.players.map((p) => {
if (!g.advanced || p.faction !== 'beneGesserit' || !p.advisors) return p;
const advisors = { ...p.advisors };
for (const t of Object.keys(advisors))
if (
!presenceAt(p, t) ||
!g.players.some((other) => other.id !== p.id && presenceAt(other, t))
) {
for (const t of Object.keys(advisors)) {
const stance = advisorStanceAfterSettlement(g, p, t);
if (stance) advisors[t] = stance;
else {
delete advisors[t];
released.push({ player: p.id, territory: t });
}
}
return {
id: p.id,
faction: p.faction,
+62 -11
View File
@@ -12,6 +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 { 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';
@@ -664,7 +665,7 @@ export type Player = {
noField?: RicheseNoField;
noFieldEvent?: string;
noFieldBlockedTurn?: number;
advisors?: Record<string, { lockedTurn?: number }>;
advisors?: Record<string, AdvisorStance>;
advisorSetup?: boolean;
charityTurn?: number;
specialKaramaUsed?: boolean;
@@ -1064,6 +1065,7 @@ type SpiceBankerIncomePayment = {
};
export type ResponseWindow = {
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
advisorPreparationSource?: AdvisorPreparationSource;
fremenGrant?: FremenRevivalGrantSource;
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
@@ -1260,6 +1262,7 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[];
advisorPreparation?: AdvisorPreparationWindow;
ecazOccupyPreview?: boolean;
/** Fresh native Guild/Nexus payment-replacement profile, never inferred from a save. */
@@ -12041,7 +12044,7 @@ function decideDiscoveryEntry(g: Game, p: Player, action: Action) {
removeGroup(p,arrival.groups.map(group => [group.source,group.normal+group.elite]),
Object.fromEntries(arrival.groups.map(group => [group.source,group.elite])));
place(p,arrival.destination,0,arrival.amount,arrival.elite);
if (advisors) (p.advisors ??= {})[arrival.destination] = {};
if (advisors) (p.advisors ??= {})[arrival.destination] ??= {};
else if (p.advisors) delete p.advisors[arrival.destination];
log(g, `${p.name} moved ${arrival.amount} ${arrival.amount === 1 ? 'force' : 'forces'}${arrival.elite ? `, including ${arrival.elite} elite ${arrival.elite === 1 ? 'force' : 'forces'},` : ''} from ${territory(arrival.parent).name} into ${territory(arrival.destination).name} before the storm. This one-time Discovery entry spends no spice or normal movement.`);
const child: DiscoveryEntryArrivalChild = {owner:p.id,territory:arrival.destination,sector:0,
@@ -14554,6 +14557,7 @@ function revivalCancellationQuote(g: Game, response: ResponseWindow) {
}
function movementCancellationQuote(g: Game, response: ResponseWindow) {
try {
if (response.automaticAdvisorFlip) { currentAutomaticAdvisorFlip(g, response); return null; }
if (response.advisorResume === 'preparation') { currentAdvisorPreparationResponse(g, response); return null; }
return quoteMovementCancellation(g, response);
} catch (error) {
@@ -16139,12 +16143,15 @@ function movementStance(g: Game, p: Player, origins: string[], to: string, fight
requireRule(!gather.blocked, gather.blocked ?? 'The mixed gather cannot flip its advisors.');
requireRule(!gather.requiresFlip || fighters,
'Declare a common fighter arrival for a mixed advisor/fighter gather.');
const advisors = !gather.requiresFlip && arrivalAsAdvisor(g, p, to, origins[0]);
const aloneArrival = g.advanced && p.faction === 'beneGesserit' && !gather.requiresFlip &&
isAdvisor(p, origins[0]) && !at(p, to) &&
!g.players.some(other => other.id !== p.id && at(other, to));
const advisors = !gather.requiresFlip && (aloneArrival || arrivalAsAdvisor(g, p, to, origins[0]));
const lockedTurn = Math.max(0, ...origins.map(origin => p.advisors?.[origin]?.lockedTurn ?? 0)) || undefined;
requireRule(advisors || lockedTurn !== g.turn ||
!g.players.some(other => other.id !== p.id && at(other, to)),
'New advisors cannot become fighters this turn.');
const wantsFighters = advisors && fighters;
const wantsFighters = advisors && fighters && !aloneArrival;
requireRule(!wantsFighters || !advisorFlipMovementBlocked(g, p),
'Advisor flipping was canceled for this movement. Choose a legal replacement without flipping advisors.');
requireRule(!wantsFighters || (lockedTurn !== g.turn && !at(p, to) &&
@@ -17016,7 +17023,7 @@ function commitGroundMovementPieces(g: Game, move: MovementOrder) {
place(p, to, s, n - (move.noField ? 1 : 0), elite);
if (advisors) {
const arrivalLock = Math.max(lockedTurn ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
(p.advisors ??= {})[to] = arrivalLock ? { lockedTurn: arrivalLock } : {};
(p.advisors ??= {})[to] = { ...p.advisors?.[to], ...(arrivalLock ? { lockedTurn: arrivalLock } : {}) };
} else if (p.advisors) delete p.advisors[to];
if (wantsFighters)
g.response = {
@@ -17049,6 +17056,7 @@ function completeMove(g: Game, move: MovementOrder) {
const flight = g.ornithopter;
commitGroundMovementPieces(g, move);
recordGroundMovementPromises(g, p, move);
prepareAutomaticAdvisorFlips(g, p);
const cunning = currentGuildCunning(g,id);
if (cunning) { requireRule(cunning.stage === 'extraMove', 'Guild Cunning permits no movement before the second shipment.'); cunning.movesAfter++; saveGuildCunning(g,cunning); }
const completed: CompletedMovement = {
@@ -18385,7 +18393,7 @@ function finishGuildAmbassadorAdvisor(
place(bg, quote.territory, quote.sector, quote.amount);
techIncome(g, 'heighliners', bg);
if (quote.advisors)
(bg.advisors ??= {})[quote.territory] = { lockedTurn: g.turn };
(bg.advisors ??= {})[quote.territory] = { ...bg.advisors?.[quote.territory], lockedTurn: g.turn };
const receipt = g.pendingAmbassador!.shipmentReceipt!;
receipt.next = 'finish';
receipt.advisorArrival = {
@@ -18568,7 +18576,7 @@ function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) {
if (physical) place(p, move.to, move.sector, physical, move.elite);
if (move.advisors) {
const lockedTurn = Math.max(move.lockedTurn ?? 0, p.advisors?.[move.to]?.lockedTurn ?? 0);
(p.advisors ??= {})[move.to] = lockedTurn ? { lockedTurn } : {};
(p.advisors ??= {})[move.to] = { ...p.advisors?.[move.to], ...(lockedTurn ? { lockedTurn } : {}) };
} else if (p.advisors) delete p.advisors[move.to];
entry.stage = 'arrival';
entry.relocation = { order: move, next: 'flip' };
@@ -20270,6 +20278,40 @@ function boardResolution<T>(quote: () => T): T {
throw error;
}
}
function automaticAdvisorRule<T>(quote: () => T): T {
try { return quote(); }
catch (error) { if (error instanceof AutomaticAdvisorFlipError) throw new RuleError(error.message); throw error; }
}
function currentAutomaticAdvisorFlip(g: Game, response: ResponseWindow) {
const source = response.automaticAdvisorFlip;
requireRule(response.kind === 'advisorFlip' && source && response.advisors === false &&
response.owner === source.owner && response.location === source.territory &&
response.advisorResume === undefined &&
JSON.stringify(g.pendingAutomaticAdvisorFlips?.[0]) === JSON.stringify(source),
'The mandatory advisor conversion has lost its original movement source.');
automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source));
return source;
}
function openAutomaticAdvisorFlip(g: Game) {
if (g.response || g.decision || g.truthtrance || g.phaseOpening) return;
const source = g.pendingAutomaticAdvisorFlips?.[0];
if (!source) return;
automaticAdvisorRule(() => validateAutomaticAdvisorFlip(g, source));
g.response = { kind: 'advisorFlip', owner: source.owner, location: source.territory,
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));
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 };
getPlayer(g, group.player).advisors![group.territory].aloneFlipHold = source.event;
(g.pendingAutomaticAdvisorFlips ??= []).push(source);
}
openAutomaticAdvisorFlip(g);
}
function applyAdvisorReleases(g: Game, released: AdvisorRelease[]) {
for (const entry of released)
delete getPlayer(g, entry.player).advisors?.[entry.territory];
@@ -27201,6 +27243,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
currentFactionPayment(g);
const response = g.response!;
const advisorPreparationSource = currentAdvisorPreparationResponse(g, response);
const automaticAdvisorFlip = response.automaticAdvisorFlip ? currentAutomaticAdvisorFlip(g, response) : null;
const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null;
if (g.pendingKull?.stage === 'counter' && response.kind === 'choamWorthless') kullIntegrity(g);
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
@@ -28096,7 +28139,14 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
`${bg.name} changed all forces in ${territory(to).name} to ${response.advisors ? 'advisors' : 'fighters'}.`,
);
}
finishAdvisorReaction(g, response);
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.`,
{ faction: bg.faction, name: 'Mandatory advisor conversion prevented' });
g.pendingAutomaticAdvisorFlips!.shift();
if (!g.pendingAutomaticAdvisorFlips!.length) delete g.pendingAutomaticAdvisorFlips;
openAutomaticAdvisorFlip(g);
} else finishAdvisorReaction(g, response);
}
} else if (response.kind === 'capture') {
const capture = g.pendingCapture!;
@@ -28413,7 +28463,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
techIncome(g, 'heighliners', owner);
if (advisors) {
owner.advisors ??= {};
owner.advisors[target.territory] = { lockedTurn: g.turn };
owner.advisors[target.territory] = { ...owner.advisors[target.territory], lockedTurn: g.turn };
}
log(
g,
@@ -28560,7 +28610,7 @@ function commitJunctionTransport(g: Game, p: Player, declaration: JunctionTransp
place(p, arrival.territory, arrival.sector, quote.amount, quote.elite);
if (advisors) {
const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[arrival.territory]?.lockedTurn ?? 0);
(p.advisors ??= {})[arrival.territory] = lockedTurn ? { lockedTurn } : {};
(p.advisors ??= {})[arrival.territory] = { ...p.advisors?.[arrival.territory], ...(lockedTurn ? { lockedTurn } : {}) };
} else if (p.advisors) delete p.advisors[arrival.territory];
}
p.shipped = true;
@@ -29486,7 +29536,7 @@ function commitGuildTransport(
place(p, to, sector, amount, elite);
if (advisors) {
const lockedTurn = Math.max(sourceLock ?? 0, p.advisors?.[to]?.lockedTurn ?? 0);
(p.advisors ??= {})[to] = lockedTurn ? { lockedTurn } : {};
(p.advisors ??= {})[to] = { ...p.advisors?.[to], ...(lockedTurn ? { lockedTurn } : {}) };
} else if (p.advisors) delete p.advisors[to];
}
observeOccupation(g);
@@ -32005,6 +32055,7 @@ 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;
+4 -1
View File
@@ -179,7 +179,10 @@ const cancellationSources = {
checks: g.battle?.powerChecks,
}),
advisorFlip: (g, response) =>
response.advisorPreparationSource
response.automaticAdvisorFlip
? { source: response.automaticAdvisorFlip, pending: g.pendingAutomaticAdvisorFlips?.[0] ?? null,
group: g.players.find(p => p.id === response.owner)?.advisors?.[response.location ?? ''] ?? null }
: response.advisorPreparationSource
? { preparation: response.advisorPreparationSource, window: g.advisorPreparation ?? null }
: response.advisorResume === 'ambassador'
? {
+17 -7
View File
@@ -4310,20 +4310,24 @@ export const RULE_TOPICS: RuleTopic[] = [
'Bene Gesserit forces share one token type throughout a territory. The map marks advisors with a dashed outer ring and labels their stance in the territory details.',
'Advisors do not fight, contest stronghold victory, block another faction’s entry, collect spice, earn stronghold income, grant ornithopters or enable Family Atomics. Storms, worms, explosions and Atomics still destroy them.',
'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. Advisors become fighters automatically whenever no other faction remains in their territory.',
'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.',
'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.',
'A current-turn window closes on a committed shipment, including a concealed zero No-Field, independent Ambassador shipment or positive Sneak Attack. Proposals, stopped shipments, ground movement and unshipped turns do not close it. Under the explicit provisional retry policy, Karama prevents that territory’s preparation for the rest of this window; Intrusion, Nexus and automatic flips remain independent. Focused evidence does not certify complete modes, broader priority/optional or forced-flip cancellation behavior.',
'A current-turn window closes on a committed shipment, including a concealed zero No-Field, independent Ambassador shipment or positive Sneak Attack. Proposals, stopped shipments, ground movement and unshipped turns do not close it. Under the explicit provisional retry policy, Karama prevents that territory’s preparation for the rest of this window; Intrusion, Nexus and automatic flips remain independent. Focused evidence does not certify complete modes, broader priority/optional or remaining automatic-flip cancellation behavior.',
],
related: ['faction-beneGesserit', 'movement', 'battle', 'victory'],
checklist: [
{
"area": "Implementation",
"status": "Partial",
"detail": "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": "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",
"game/board-resolution-quote.ts",
"game/advisor-preparation-window.ts",
"game/engine.ts",
"game/movement-phase-quote.ts",
@@ -4334,8 +4338,9 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Player controls",
"status": "Partial",
"detail": "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": "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",
"components/game-table.tsx",
"tests/advisor-preparation-controls.test.tsx"
@@ -4344,8 +4349,9 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "AI",
"status": "Partial",
"detail": "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 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-automatic-flip-engine.test.ts",
"game/bots.ts",
"docs/ADVISOR_PREPARATION.md"
]
@@ -4353,8 +4359,9 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Documentation",
"status": "Partial",
"detail": "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": "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",
"docs/RULE_DECISIONS.md",
"docs/ECAZ_OCCUPY_RULES.md"
@@ -4363,8 +4370,11 @@ export const RULE_TOPICS: RuleTopic[] = [
{
"area": "Verification",
"status": "Partial",
"detail": "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": "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-automatic-flip-engine.test.ts",
"tests/advisor-automatic-flip-quote.test.ts",
"tests/fixture-advisor-automatic-flip.ts",
"tests/fixture-advisor-preparation.ts",
"tests/advisor-preparation-engine.test.ts",
"tests/advisor-preparation-window.test.ts",
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { fighterCount, isAdvisor } from '../game/advisors';
import { quoteBattleBoard, quoteSpiceCollection } from '../game/board-resolution-quote';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAdvisorAutomaticFlipFixture as fixture,
actAdvisorAutomaticFlip as act,
advisorAutomaticFlipPlayer as player,
advisorAutomaticFlipInventory as inventory,
passAdvisorAutomaticFlip as pass,
finishAdvisorAutomaticFlip as finish,
advanceAdvisorAutomaticFlip as advance,
advisorAutomaticFlipClean as clean,
} from './fixture-advisor-automatic-flip';
const cancel = (g: Game, f: ReturnType<typeof fixture>) => act(g, f.holder,
{ type: 'card', card: f.card, mode: 'cancel' });
function rejected(g: Game, actor: string, action: Action) {
const before = structuredClone(g); assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
for (const kind of ['arrival', 'departure'] as const) {
void test(`native ${kind} commits physical ground movement before its mandatory conversion response`, () => {
const f = fixture({ kind }), before = structuredClone(f.game);
const pending = act(f.game, f.actor, f.action);
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);
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);
assert.equal(isAdvisor(player(pending, f.bg), f.territory), true);
assert.equal(fighterCount(player(pending, f.bg), f.territory), 0);
assert.ok(player(pending, f.holder).hand.some(c => c.id === f.card));
const allowed = pass(pending);
assert.equal(isAdvisor(player(allowed, f.bg), f.territory), false);
assert.equal(fighterCount(player(allowed, f.bg), f.territory), 1);
assert.equal(allowed.pendingAutomaticAdvisorFlips, undefined);
assert.equal(player(allowed, f.actor).moved, player(pending, f.actor).moved);
assert.deepEqual(inventory(allowed), f.originalInventory);
assert.ok(finish(allowed).phase >= 6);
});
void test(`native ${kind} cancellation preserves advisors and the original committed movement through phase continuation`, () => {
const f = fixture({ kind }), pending = act(f.game, f.actor, f.action);
const stopped = cancel(pending, f), hold = player(stopped, f.bg).advisors?.[f.territory]?.aloneFlipHold;
assert.ok(hold);
assert.equal(stopped.response, null);
assert.equal(stopped.pendingAutomaticAdvisorFlips, undefined);
assert.equal(player(stopped, f.actor).moved, 1);
assert.equal(player(stopped, f.actor).forces[f.to], f.amount);
assert.equal(stopped.discard.filter(c => c.id === f.card).length, 1);
for (let read = 0; read < 3; read++) {
const normalized = normalizeAutomaticGame(stopped);
assert.equal(isAdvisor(player(normalized, f.bg), f.territory), true);
const own = viewGame(normalized, f.bg).players.find(p => p.id === f.bg)!;
assert.equal(isAdvisor(own, f.territory), true);
assert.ok(!quoteBattleBoard(normalized).released.some(r => r.player === f.bg && r.territory === f.territory));
const collection = quoteSpiceCollection(normalized).receipts.find(r => r.player === f.bg)!;
assert.equal(collection.strongholds, 0);
assert.equal(collection.collected, 0);
}
const continued = finish(stopped);
assert.ok(continued.phase >= 6);
assert.equal(player(continued, f.bg).advisors?.[f.territory]?.aloneFlipHold, hold);
assert.equal(fighterCount(player(continued, f.bg), f.territory), 0);
assert.deepEqual(inventory(continued), f.originalInventory);
rejected(stopped, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
});
}
void test('an actual outsider return ends the continuously-alone suppression without changing advisor stance', () => {
const f = fixture({ kind: 'arrival' });
let g = cancel(act(f.game, f.actor, f.action), f);
assert.ok(player(g, f.bg).advisors?.[f.territory]?.aloneFlipHold);
g = advance(g, state => state.phase === 5 && state.active === f.holder && clean(state));
g = act(g, f.holder, { ...f.action, amount: 10 });
assert.equal(player(g, f.holder).forces[f.to], 10);
assert.equal(isAdvisor(player(g, f.bg), f.territory), true);
assert.equal(player(g, f.bg).advisors?.[f.territory]?.aloneFlipHold, undefined);
assert.equal(g.pendingAutomaticAdvisorFlips, undefined);
assert.deepEqual(inventory(g), f.originalInventory);
});
void test('a changed mandatory source rejects before Karama disposal or a second movement', () => {
const f = fixture(), pending = act(f.game, f.actor, f.action);
const changed = structuredClone(pending);
changed.response!.automaticAdvisorFlip = { ...changed.response!.automaticAdvisorFlip!, territory: 'arrakeen' };
rejected(changed, f.holder, { type: 'card', card: f.card, mode: 'cancel' });
assert.ok(player(changed, f.holder).hand.some(c => c.id === f.card));
assert.equal(player(changed, f.actor).moved, 1);
});
void test('four existing profiles can resolve both native mandatory conversions legally', () => {
for (const kind of ['arrival', 'departure'] as const) for (const profile of DIFFICULTIES) {
const f = fixture({ kind }), pending = act(f.game, f.actor, f.action);
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 settled = pass(act(pending, f.holder, action));
assert.equal(settled.pendingAutomaticAdvisorFlips, undefined);
assert.equal(player(settled, f.actor).moved, 1);
assert.deepEqual(inventory(settled), f.originalInventory);
assert.ok(finish(settled).phase >= 6);
}
});
+188
View File
@@ -0,0 +1,188 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
advisorStanceAfterSettlement,
AutomaticAdvisorFlipError,
quoteAutomaticAdvisorFlips,
validateAutomaticAdvisorFlip,
type AdvisorSettlementContext,
type AutomaticAdvisorFlipContext,
type AutomaticAdvisorFlipSource,
} from '../game/advisor-automatic-flip';
function context(): AutomaticAdvisorFlipContext {
return {
advanced: true, status: 'playing', turn: 4, phase: 5,
players: [
{
id: 'b', faction: 'beneGesserit', moved: 0,
forces: { 'imperial_basin:9': 2, 'imperial_basin:10': 1, 'arrakeen:10': 1, 'carthag:11': 2, 'hagga_basin:12': 0 },
advisors: { imperial_basin: { lockedTurn: 4 }, arrakeen: {}, hagga_basin: {} },
},
{ id: 'a', faction: 'atreides', moved: 1, forces: { 'carthag:11': 3 } },
{ id: 'r', faction: 'richese', moved: 0, forces: {} },
],
};
}
function held() {
const ctx = context();
const choice = quoteAutomaticAdvisorFlips(ctx).find((entry) => entry.territory === 'imperial_basin')!;
const source: AutomaticAdvisorFlipSource = {
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,
};
ctx.players[0].advisors = {
...ctx.players[0].advisors,
imperial_basin: { ...ctx.players[0].advisors!.imperial_basin, aloneFlipHold: source.event },
};
return { ctx, source };
}
void test('the phase-five quote finds whole physical advisor groups that became alone without using fresh locks as immunity', () => {
const ctx = context();
const before = structuredClone(ctx);
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), [
{ player: 'b', territory: 'imperial_basin', forces: { 'imperial_basin:9': 2, 'imperial_basin:10': 1 } },
{ player: 'b', territory: 'arrakeen', forces: { 'arrakeen:10': 1 } },
]);
assert.deepEqual(ctx, before);
const quote = quoteAutomaticAdvisorFlips(ctx);
quote[0].forces['imperial_basin:9'] = 99;
assert.equal(ctx.players[0].forces['imperial_basin:9'], 2);
assert.equal(quoteAutomaticAdvisorFlips(ctx)[0].forces['imperial_basin:9'], 2);
});
void test('quotes exclude held, occupied and physically empty advisor groups while setup and other phases stay automatic', () => {
const { ctx } = held();
ctx.players[1].forces = { ...ctx.players[1].forces, 'arrakeen:10': 1 };
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []);
for (const patch of [
{ advanced: false }, { status: 'lobby' }, { phase: 0 }, { phase: 4 }, { phase: 6 },
]) {
assert.deepEqual(quoteAutomaticAdvisorFlips({ ...context(), ...patch }), []);
}
const setup = context();
setup.phase = 0;
assert.equal(advisorStanceAfterSettlement(setup, 'b', 'imperial_basin'), undefined);
assert.equal(advisorStanceAfterSettlement(setup, 'b', 'hagga_basin'), undefined);
});
void test('settlement uses only advanced and players, retains a held occurrence and clears that hold when another faction returns', () => {
const { ctx, source } = held();
const board: AdvisorSettlementContext = { advanced: ctx.advanced, players: ctx.players };
const original = structuredClone(ctx);
const stance = advisorStanceAfterSettlement(board, ctx.players[0], 'imperial_basin');
assert.deepEqual(stance, { lockedTurn: 4, aloneFlipHold: source.event });
assert.notEqual(stance, ctx.players[0].advisors!.imperial_basin);
assert.equal(advisorStanceAfterSettlement(board, 'b', 'arrakeen'), undefined);
assert.deepEqual(ctx, original);
ctx.players[1].forces = { ...ctx.players[1].forces, 'imperial_basin:11': 1 };
assert.deepEqual(advisorStanceAfterSettlement(board, 'b', 'imperial_basin'), { lockedTurn: 4 });
assert.equal(ctx.players[0].advisors!.imperial_basin.aloneFlipHold, source.event);
const basic = { ...board, advanced: false };
assert.deepEqual(advisorStanceAfterSettlement(basic, 'b', 'imperial_basin'), { lockedTurn: 4, aloneFlipHold: source.event });
});
void test('a continuous alone hold survives later phases and turns, while emptiness and a newly moved group do not inherit it', () => {
const { ctx, source } = held();
ctx.phase = 7;
ctx.turn++;
assert.deepEqual(advisorStanceAfterSettlement(ctx, 'b', 'imperial_basin'), { lockedTurn: 4, aloneFlipHold: source.event });
ctx.phase = 5;
assert.equal(quoteAutomaticAdvisorFlips(ctx).some((entry) => entry.territory === 'imperial_basin'), false);
ctx.players[0].forces = { 'hagga_basin:12': 3 };
ctx.players[0].advisors = { ...ctx.players[0].advisors, hagga_basin: { lockedTurn: 4 } };
assert.equal(advisorStanceAfterSettlement(ctx, 'b', 'imperial_basin'), undefined);
assert.equal(advisorStanceAfterSettlement(ctx, 'b', 'hagga_basin'), undefined);
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), [
{ player: 'b', territory: 'hagga_basin', forces: { 'hagga_basin:12': 3 } },
]);
});
void test('a concealed No-Field supplies public occupation without exposing its physical identity or printed value', () => {
const { ctx } = held();
ctx.players[2].noField = { deployed: { location: { territory: 'imperial_basin', sector: 9 } } };
Object.defineProperty(ctx.players[2].noField, 'tokens', {
get() { throw new Error('Automatic conversion read No-Field inventory'); },
});
Object.defineProperty(ctx.players[2].noField.deployed, 'value', {
get() { throw new Error('Automatic conversion read No-Field value'); },
});
assert.deepEqual(advisorStanceAfterSettlement(ctx, 'b', 'imperial_basin'), { lockedTurn: 4 });
assert.equal(quoteAutomaticAdvisorFlips(ctx).some((entry) => entry.territory === 'imperial_basin'), false);
});
void test('the response validates its original held event and exact physical group regardless of map insertion order', () => {
const { ctx, source } = held();
const before = structuredClone({ ctx, source });
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, {
...source, forces: { 'imperial_basin:10': 1, 'imperial_basin:9': 2 },
}));
assert.deepEqual({ ctx, source }, before);
delete ctx.players[0].advisors;
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), AutomaticAdvisorFlipError);
});
void test('changed window, owner, occurrence, group, occupation and movement sources throw the named rule error', () => {
const changes: ((ctx: AutomaticAdvisorFlipContext, source: AutomaticAdvisorFlipSource) => void)[] = [
(ctx) => { ctx.advanced = false; },
(ctx) => { ctx.status = 'lobby'; },
(ctx) => { ctx.phase = 4; },
(ctx) => { ctx.turn++; },
(_ctx, source) => { source.phase = 4; },
(_ctx, source) => { source.owner = 'a'; },
(_ctx, source) => { source.owner = 'missing'; },
(_ctx, source) => { source.territory = 'arrakeen'; },
(_ctx, source) => { source.event = 'other-occurrence'; },
(ctx) => { ctx.players[0].advisors = { imperial_basin: { aloneFlipHold: 'other-occurrence' } }; },
(ctx) => { ctx.players[0].forces = { 'imperial_basin:9': 3 }; },
(ctx) => { ctx.players[0].forces = {}; },
(ctx) => { ctx.players[1].forces = { 'imperial_basin:11': 1 }; },
(ctx) => { ctx.players[2].noField = { deployed: { location: { territory: 'imperial_basin', sector: 10 } } }; },
(_ctx, source) => { source.forces = {}; },
(_ctx, source) => { source.forces = { 'imperial_basin:9': 2 }; },
(_ctx, source) => { source.forces['imperial_basin:9'] = 1; },
(_ctx, source) => { source.forces['arrakeen:10'] = 1; },
(_ctx, source) => { source.forces['imperial_basin:9'] = 0; },
(_ctx, source) => { source.forces['imperial_basin:9'] = 1.5; },
(_ctx, source) => { source.move = 0; },
(_ctx, source) => { source.move = 2; },
(_ctx, source) => { source.mover = 'missing'; },
(ctx) => { ctx.players[1].moved = 2; },
(ctx) => { delete ctx.players[1].moved; },
(_ctx, source) => { (source as { producer: string }).producer = 'shipment'; },
];
for (const change of changes) {
const { ctx, source } = held();
change(ctx, source);
const before = structuredClone({ ctx, source });
assert.throws(() => validateAutomaticAdvisorFlip(ctx, source), (error: unknown) =>
error instanceof AutomaticAdvisorFlipError && error.name === 'AutomaticAdvisorFlipError');
assert.deepEqual({ ctx, source }, before);
}
const { ctx, source } = held();
for (const malformed of [null, [], { ...source, extra: true }, { ...source, forces: null }, { ...source, event: '' }]) {
assert.throws(() => validateAutomaticAdvisorFlip(ctx, malformed as AutomaticAdvisorFlipSource), AutomaticAdvisorFlipError);
}
});
void test('quotes, settlement and response matching do not inspect private cards, leaders or spice', () => {
const { ctx, source } = held();
const before = ctx.players.map((player) => structuredClone({ forces: player.forces, advisors: player.advisors }));
for (const player of ctx.players) {
for (const field of ['hand', 'leaders', 'spice', 'traitors', 'faceDancers']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Automatic advisor rule read ${player.id} private ${field}`); },
});
}
}
assert.equal(quoteAutomaticAdvisorFlips(ctx).some((entry) => entry.territory === 'arrakeen'), true);
assert.deepEqual(advisorStanceAfterSettlement({ advanced: ctx.advanced, players: ctx.players }, 'b', 'imperial_basin'), {
lockedTurn: 4, aloneFlipHold: source.event,
});
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, source));
assert.deepEqual(ctx.players.map((player) => ({ forces: player.forces, advisors: player.advisors })), before);
});
+9 -3
View File
@@ -199,7 +199,7 @@ void test('moving fighters into existing advisors matches their stance, and the
assert.equal(isAdvisor(g.players[1], 'arrakeen'), false);
assert.equal(fighterCount(g.players[1], 'arrakeen'), 3);
});
void test('advisors moving to an empty territory become fighters; optional occupied-territory flips can be canceled after movement', () => {
void test('empty and occupied advisor arrivals offer conversion cancellation after the committed movement', () => {
const g = fixture();
g.players[0].hand = [baseDeck().find((c) => c.effect === 'karama')!];
army(g, 'a', { 'arrakeen:10': 1 });
@@ -217,8 +217,14 @@ void test('advisors moving to an empty territory become fighters; optional occup
assert.equal(isAdvisor(allow(moved).players[1], 'imperial_basin'), false);
g.players[2].forces = {};
const empty = move(g, 'b', 'arrakeen:10', 'imperial_basin', 10, 2);
assert.equal(isAdvisor(empty.players[1], 'imperial_basin'), false);
assert.equal(empty.response, null);
assert.equal(isAdvisor(empty.players[1], 'imperial_basin'), true);
assert.equal(empty.response?.kind, 'advisorFlip');
assert.ok(empty.response?.automaticAdvisorFlip);
assert.equal(isAdvisor(allow(empty).players[1], 'imperial_basin'), false);
const held = cancel(empty);
assert.equal(isAdvisor(held.players[1], 'imperial_basin'), true);
assert.ok(held.players[1].advisors?.imperial_basin.aloneFlipHold);
assert.equal(held.players[1].moved, 1);
});
void test('intrusion without advisor accompaniment restores the same decision and private views', () => {
const g = fixture();
+147
View File
@@ -0,0 +1,147 @@
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 AdvisorAutomaticFlipStep = { actor: string; action: Action };
export type AdvisorAutomaticFlipTransition = {
before: Game; steps: AdvisorAutomaticFlipStep[]; after: Game;
};
export const actAdvisorAutomaticFlip = actAdvisorPreparation;
export const advisorAutomaticFlipClean = advisorPreparationClean;
export const advisorAutomaticFlipInventory = advisorPreparationInventory;
export const advisorAutomaticFlipPlayer = advisorPreparationPlayer;
/** Original native setup/phase controls; retain the source advisor stance by
* declining optional preparation and leave Guild's movement until last. */
export function nextAdvisorAutomaticFlip(game: Game): AdvisorAutomaticFlipStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'advisorBattle')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
if (game.decision?.kind === 'guildTiming')
return { actor: game.decision.player, action: { type: 'decision', take: false } };
}
return nextAdvisorPreparationStep(game);
}
export function advanceAdvisorAutomaticFlip(game: Game, until: (state: Game) => boolean,
steps: AdvisorAutomaticFlipStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The native automatic-flip chronology ended before its requested boundary');
const next = nextAdvisorAutomaticFlip(game);
assert.ok(next, `Choose the original private control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actAdvisorAutomaticFlip(game, next.actor, next.action, steps);
}
throw Error('The genuine automatic-flip source did not reach its finite boundary');
}
export function passAdvisorAutomaticFlip(game: Game, steps: AdvisorAutomaticFlipStep[] = []): 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 = actAdvisorAutomaticFlip(game, actor.id, { type: 'passResponse' }, steps);
}
assert.ok(!game.response); return game;
}
/** Public Advanced BG/Atreides/Guild with the native advisor actually placed at
* occupied Arrakeen. Stop BEFORE a real ground movement would leave advisors
* alone: BG empty-territory arrival, or the last Atreides outsiders' departure.
* No played force, stance, phase, wallet, hand or prevention receipt is set. */
export function createAdvisorAutomaticFlipFixture(options: { kind: 'arrival' | 'departure' } = { kind: 'arrival' }) {
const kind = options.kind, bg = 'beneGesserit', holder = 'atreides', observer = 'guild';
let game = createGame('ADVISORAUTOMATICFLIP', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), true);
joinGame(game, newPlayer(holder, 'Atreides', 'atreides'));
joinGame(game, newPlayer(observer, 'Guild', 'guild'));
const initial = structuredClone(game), steps: AdvisorAutomaticFlipStep[] = [];
const act = (actor: string, action: Action) => { game = actAdvisorAutomaticFlip(game, actor, action, steps); };
const reach = (until: (state: Game) => boolean) => { game = advanceAdvisorAutomaticFlip(game, until, steps); };
const transition = (run: () => void): AdvisorAutomaticFlipTransition => {
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 = advisorAutomaticFlipInventory(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: AdvisorAutomaticFlipTransition | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const next = nextAdvisorAutomaticFlip(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(advisorAutomaticFlipPlayer(game, bg).forces['arrakeen:10'], 1);
assert.ok(advisorAutomaticFlipPlayer(game, bg).advisors?.arrakeen);
assert.equal(advisorAutomaticFlipPlayer(game, holder).forces['arrakeen:10'], 10);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
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 === bg && advisorAutomaticFlipClean(state));
});
let skippedBg: AdvisorAutomaticFlipTransition | null = null;
if (kind === 'departure') skippedBg = transition(() => {
act(bg, { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === holder && advisorAutomaticFlipClean(state));
});
const actor = kind === 'arrival' ? bg : holder, amount = kind === 'arrival' ? 1 : 10;
const territory = kind === 'arrival' ? 'imperial_basin' : 'arrakeen';
assert.equal(game.active, actor); assert.ok(advisorAutomaticFlipClean(game));
assert.equal(advisorAutomaticFlipPlayer(game, bg).forces['arrakeen:10'], 1);
assert.ok(advisorAutomaticFlipPlayer(game, bg).advisors?.arrakeen);
assert.equal(advisorAutomaticFlipPlayer(game, holder).forces['arrakeen:10'], 10);
assert.ok(game.players.every(player => (player.forces['imperial_basin:10'] ?? 0) === 0));
assert.ok(advisorAutomaticFlipPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.deepEqual(advisorAutomaticFlipInventory(game), originalInventory);
const action: Action = { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount };
return { game, source: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup,
advisorSetup, openingOffer, declinedPreparation, skippedBg,
kind, bg, owner: bg, holder, atreides: holder, observer, guild: observer,
actor, card: karama.id, karama: karama.id, action,
movementStep: { actor, action } satisfies AdvisorAutomaticFlipStep,
from: 'arrakeen:10', to: 'imperial_basin:10', territory, automaticTerritory: territory, sector: 10, amount,
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 original advisorSetup action places one advisor at occupied Arrakeen10 beside the ten native Atreides counters.',
'Select only original scalar shuffle order, three original unopened Treachery positions and two original unopened ordinary Spice positions. Atreides actually receives the original starting Karama; no later hand or card acquisition is staged.',
'Actually complete safe first Storm, both Spice blows, Charity, normal Bidding and Revival. Decline the original optional advisorBattle preparation and defer Guild through its native timing choices; no faction ships or moves an origin counter.',
kind === 'departure'
? 'Actually end BG’s unused movement turn, then stop at Atreides’ native active turn with all ten original Arrakeen counters still present. Their whole-group ground move to empty Imperial Basin10 remains the caller action.'
: 'Stop at BG’s native active turn with its original advisor still beside Atreides. Moving that one advisor to empty Imperial Basin10 remains the caller action.',
'The source ends before the tested movement. No played army, stance, phase, wallet, Tanks, hold, response or cancellation receipt is assigned, and no automatic-flip outcome is asserted.',
],
};
}
export type AdvisorAutomaticFlipFixture = ReturnType<typeof createAdvisorAutomaticFlipFixture>;
/** After the caller resolves any actual automatic-flip response, continue the
* original turns into the next native Battle/Collection boundary. */
export function finishAdvisorAutomaticFlip(source: Game, steps: AdvisorAutomaticFlipStep[] = []): Game {
return advanceAdvisorAutomaticFlip(source, game => game.phase >= 6 && game.phase <= 7 && advisorAutomaticFlipClean(game), steps);
}