diff --git a/components/game-table.tsx b/components/game-table.tsx
index 561e64e2..bb176e49 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -4895,7 +4895,7 @@ export function GameTable({
{g.advanced
? 'Advanced allied transit: ship ordinary forces here, then depart before finishing. Supplied p15 sends all your forces left sharing with an ally to Tanks, including old forces; advisor, Polar Sink and native Ecaz coexistence exemptions remain.'
- : 'Basic allied transit: ship ordinary forces here, then move the visiting group out before finishing your turn. Provisional fallback: new visitors left behind go to Tanks, even if departure is prevented. This does not waive the older allied co-occupation deadline.'}
+ : 'Basic allied transit: ship forces or your own concealed No-Field here, then move the visiting group out before finishing your turn. Provisional fallback: new visitors left behind go to Tanks, even if departure is prevented. This does not waive the older allied co-occupation deadline.'}
{' '}Storm and stronghold capacity still apply.
)}
@@ -5271,6 +5271,7 @@ export function GameTable({
Move your new visitors out of {g.basicAlliedShipment.territory} before finishing.
Provisional Basic policy: finishing now sends {g.basicAlliedShipment.normal} normal
{' '}and {g.basicAlliedShipment.elite} special visiting forces to Tanks, not your ally’s forces.
+ {g.basicAlliedShipment.noField && ' Your No-Field will also be revealed; its newly placed forces will go to Tanks.'}
)}
diff --git a/docs/ALLIED_TRANSIT_RULES.md b/docs/ALLIED_TRANSIT_RULES.md
index df9cf3cd..2b428bff 100644
--- a/docs/ALLIED_TRANSIT_RULES.md
+++ b/docs/ALLIED_TRANSIT_RULES.md
@@ -104,6 +104,21 @@ ending warnings with enabled finish. No broad suite, recovery campaign or
deployed acceptance was run.
+## Basic own No-Field transit — 28 October 2026
+
+**Prototyped / Partial.** Richese may ship its own single concealed No-Field
+into an eligible ally-occupied territory, then move it out during its turn.
+Storm and stronghold capacity remain in force. The owner sees a warning without
+revealing the token value to other players. If the marker remains beside the
+ally at turn end, the provisional no-departure policy reveals it, places up to
+its value from available reserves and sends those newly materialized forces to
+Tanks. A zero marker reveals without casualties. Physical visitors use the
+same pre-arrival baseline; an earlier voluntary reveal is not repeated.
+The source FAQ permits allied shipment transit and the Richese rules count a
+concealed marker as one force; their combination and no-exit consequence are
+provisional. Allied No-Field use, paired markers, paid companions and other
+transport compositions remain separate.
+
## Basic ordinary allied shipment prototype — 8 October 2026
The content-first Basic pass connects the FAQ's ordinary physical
@@ -131,7 +146,7 @@ co-occupation deadlines still resolve separately. AI prioritizes available
native departures and does not retain a stronghold sentinel from a
visiting group.
-Concealed and allied No-Field arrivals, mobile/homeworld transports and
+Allied No-Field arrivals, mobile/homeworld transports and
optional arrival compositions are not expanded by the Basic prototype.
Advanced ordinary physical entry is connected in the follow-up above.
Refinement of interrupted and mixed-cohort interactions remains later work.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 25b29250..114d6554 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -16,6 +16,16 @@ open mode gates.
## Current checkpoint and work
+**Basic own No-Field allied transit — 28 October. Prototyped / Partial.**
+Richese can ship one concealed marker into an ally's territory and move it
+out on its turn. The provisional no-exit path reveals it and removes only the
+newly materialized visitors, preserving the earlier physical baseline. The
+owner sees the warning without revealing the value; paired markers and paid
+companions remain guarded. [Scope](ALLIED_TRANSIT_RULES.md#basic-own-no-field-transit--28-october-2026).
+The related seven-file batch passes 67 cases, including real Nexus, departure,
+revelation, AI and prevention paths. The prevention and capacity probes use
+explicitly recorded custody-preserving setup changes.
+
**Semuta after Richese specials and simple Truthtrance in Basic independent
cards — 28 October. Prototyped / Partial.** The Richese/classic preview adds
completed Box, Distrans, definite fact/freeform Truthtrance and ordinary
@@ -195,7 +205,9 @@ Run 7 was canceled before starting; run 8 for `e529cc2` succeeded and
published digest `sha256:9a188718e46adec7ec9e4edc29fe03126c1afe2a349bb426052339a5ee4dce55`.
Run 9 was canceled before starting; run 10 for `1ba9421` succeeded and
published digest `sha256:2c403b71b24de97188804c874f621af5b72bc6dd807bbcdcca9cbe7ab420029c`.
-Run 11 for `4d769af` is running.
+Run 11 for `4d769af` succeeded and published digest
+`sha256:766e3b94f9268fc5a089a1a47c2cf031a5c6a25bd789c51f7e3e81f4eeaa528e`.
+Run 12 was canceled before starting; run 13 for `50c6201` is running.
Deployment remains unverified.
Existing NAS deployment and its persistent storage remain untouched.
diff --git a/game/allied-separation.ts b/game/allied-separation.ts
index b64b80d2..ec21371d 100644
--- a/game/allied-separation.ts
+++ b/game/allied-separation.ts
@@ -13,6 +13,8 @@ export type BasicAlliedShipment = Readonly<{
turn: number;
territory: string;
baseline: Readonly<{ normal: number; elite: number }>;
+ /** Private token identity for a newly shipped concealed visitor. */
+ noField?: string;
}>;
type ShipmentContext = Readonly<{
diff --git a/game/bots.ts b/game/bots.ts
index 218aa3fe..df13eccf 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1,4 +1,5 @@
import { liveMovementExpressionPromises } from './movement-expression-promises';
+import { isAlliedShipmentVisit } from './allied-separation';
import { discoveryBotActions } from './discovery-options';
import { normalAuctionHandBlock } from './normal-auction';
import { atreidesFullPlanAction } from './atreides-full-plan';
@@ -3565,6 +3566,9 @@ function policyActions(g: GameView): Action[] {
to.t !== MOBILE_STRONGHOLD &&
!territoryEntryBlock(g.players, me.id, to.t),
);
+ const markerShipmentTargets = targets.filter(to => to.t !== MOBILE_STRONGHOLD &&
+ !territoryEntryBlock(g.players, me.id, to.t, false,
+ !g.advanced && isAlliedShipmentVisit(g, me, to.t)));
if (noField?.canShip && ownNoField && !deployed && !me.shipped) {
const tokens = ownNoField.tokens.filter(
(token) => token.id !== ownNoField.lastShipped,
@@ -3594,10 +3598,11 @@ function policyActions(g: GameView): Action[] {
}
}
if (selected)
- for (const to of markerTargets.slice(0, 24)) {
+ for (const to of markerShipmentTargets.slice(0, 24)) {
const smuggler = !!quoteSmugglerNoField(g, me.id, to.t);
const companions = noField.homeworldForcesEnabled && me.reserves > (smuggler ? 1 : 0) ? [1, 0] : [0];
for (const amount of companions) {
+ if ((amount > 0 || smuggler) && isAlliedShipmentVisit(g, me, to.t)) continue;
const group = quoteRicheseHomeworldForces({ enabled: !!noField.homeworldForcesEnabled,
reserves: me.reserves, amount, smuggler });
if (Math.min(selected.value, me.reserves) + group.physical === 0 && to.enemy > 0) continue;
@@ -4505,10 +4510,13 @@ export function botActions(g: GameView): Action[] {
g.phase === 5 && g.active === me.id && me.shipped && me.moved === 0) {
// Depart from shared groups before unrelated moves; do not retain a
// sentinel that the ending alliance constraint would send to Tanks.
- const departures = actions.filter(action => action.type === 'move' &&
+ const marker = g.richeseNoField?.private?.deployed;
+ const markerTerritory = marker && isBoardNoFieldLocation(marker.location) ? territory(marker.location.territory).name : null;
+ const departures = actions.filter(action => action.type === 'move' && (
typeof action.from === 'string' &&
(territory(splitLocation(action.from).territory).name === g.basicAlliedShipment?.territory ||
- advancedDepartures?.includes(territory(splitLocation(action.from).territory).name)));
+ advancedDepartures?.includes(territory(splitLocation(action.from).territory).name)) ||
+ !!action.noField && !!g.basicAlliedShipment?.noField && markerTerritory === g.basicAlliedShipment.territory));
if (departures.length) return departures;
}
const ghola = standaloneGholaAction(g, actions);
diff --git a/game/engine.ts b/game/engine.ts
index b87ae911..e119e9c3 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -23135,16 +23135,25 @@ function basicAlliedShipmentView(g: Game, p: Player) {
const visit = g.basicAlliedShipment;
if (g.advanced || g.phase !== 5 || !visit || visit.player !== p.id ||
visit.turn !== g.turn || p.ally !== visit.ally) return null;
- getPlayer(g, visit.ally);
+ const ally = getPlayer(g, visit.ally);
+ if (ally.ally !== p.id || !at(ally, visit.territory) || sharesEcazOccupation(g, p, visit.territory)) return null;
const quote = occupancyRule(() => quoteBasicAlliedShipment(g, p, visit));
- return quote ? { ...quote, territory: territory(quote.territory).name } : null;
+ const deployed = p.noField?.deployed;
+ const noField = !!visit.noField && deployed?.tokenId === visit.noField && deployed.controller === p.id &&
+ isBoardNoFieldLocation(deployed.location) && deployed.location.territory === visit.territory;
+ return quote || noField ? { territory: territory(visit.territory).name,
+ normal: quote?.normal ?? 0, elite: quote?.elite ?? 0, ...(noField ? {noField: true as const} : {}) } : null;
}
/** Provisional no-exit policy: only the excess physical visitors go to Tanks. */
function finishBasicAlliedShipment(g: Game, p: Player) {
const visit = g.basicAlliedShipment;
if (!visit || visit.player !== p.id) return;
- const quote = basicAlliedShipmentView(g, p);
+ let quote = basicAlliedShipmentView(g, p);
+ if (quote?.noField) {
+ revealPlayerNoField(g, p, 'alliedSeparation');
+ quote = basicAlliedShipmentView(g, p);
+ }
g.basicAlliedShipment = null;
if (!quote) return;
let normal = quote.normal;
@@ -38421,6 +38430,11 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
advisors,
} = shipment;
const p = getPlayer(g, shipment.player);
+ if (!g.advanced && !shipment.alliedNoField && n > 0) {
+ const visit = occupancyRule(() => makeBasicAlliedShipment(g, p, to));
+ if (visit) g.basicAlliedShipment = shipment.noField
+ ? { ...visit, noField: shipment.noField.tokenId } : visit;
+ }
let pairRevealed: ReturnType['revealed'] | null = null;
if (shipment.noField) {
requireRule(
@@ -38491,10 +38505,6 @@ function commitShipment(g: Game, shipment: PendingShipment, settlement?: {recipi
}
let homeworldOrigins = '';
if (!shipment.noField) {
- if (!g.advanced && !shipment.alliedNoField && n > 0) {
- const visit = occupancyRule(() => makeBasicAlliedShipment(g, p, to));
- if (visit) g.basicAlliedShipment = visit;
- }
const receipts = withdrawNativeReserves(
g,
p,
@@ -44762,7 +44772,9 @@ function applyActionInner(
'Only Ixians may ship directly into the mobile stronghold.',
);
allowedEntry(g, p, to, s, !guildSecretEvent && g.advanced && p.faction === 'fremen', advisors,
- action.noField === undefined && isAlliedShipmentVisit(g, p, to));
+ (action.noField === undefined || !g.advanced && !richesePair &&
+ action.noFieldReserves === undefined && action.smuggler !== true) &&
+ isAlliedShipmentVisit(g, p, to));
let noField: PendingShipment['noField'];
if (action.noField !== undefined) {
requireRule(
diff --git a/game/reference.ts b/game/reference.ts
index 7b0b10fd..55b63f63 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -1122,6 +1122,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Concealed zero, three and five tokens represent one force until they are revealed.',
steps: [
'Richese has three No-Fields, numbered zero, three and five. A concealed token counts as one force. Only one may be deployed on Arrakis, and consecutive own or allied shipments cannot use the same token.',
+ 'In Basic play, your own single concealed No-Field may provisionally ship into an ally’s territory if it can legally enter; move it out before your turn ends. If it remains, reveal it and send its newly materialized forces to Tanks under the provisional no-departure policy. A zero marker reveals but contributes no casualties. Existing physical forces and the ally are not part of that new-visitor loss. Paired markers and paid companion forces keep their separate guards.',
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return. Select the marker separately from physical counters; ordinary movement remains available.',
'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld, alone or with physical forces from those reserves. On a later turn, you may ship that same concealed marker between distinct eligible foreign Homeworlds, alone or with selected physical Richese counters from the marker’s current World. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal, when available reserves are placed there. Only one marker may be deployed globally. Own-Homeworld placement or return and optional-module combinations remain unavailable. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
diff --git a/tests/fixture-no-field-allied-transit.ts b/tests/fixture-no-field-allied-transit.ts
new file mode 100644
index 00000000..2f618ea4
--- /dev/null
+++ b/tests/fixture-no-field-allied-transit.ts
@@ -0,0 +1,81 @@
+import assert from 'node:assert/strict';
+import { type Action, type Game } from '../game/engine';
+import type { NoFieldValue } from '../game/richese-no-field';
+import {
+ actNoFieldAlliedSeparation as act, advanceNoFieldAlliedSeparation as advance,
+ createNoFieldAlliedSeparationFixture, noFieldAlliedSeparationClean as clean,
+ noFieldAlliedSeparationInventory as inventory, noFieldAlliedSeparationPlayer as player,
+ nextNoFieldAlliedSeparation,
+ type NoFieldAlliedSeparationStep,
+} from './fixture-no-field-allied-separation';
+
+export { act, advance, clean, inventory, player };
+
+/** Start with the existing genuine Basic setup and first Storm. Replace only
+ * the source's prospective marker shipment with a paid two-force shipment;
+ * Emperor's paid two forces are already across the actual Storm. The next
+ * real worm opens Nexus and both factions consent before Richese's next turn.
+ * Only unopened Spice-card order is selected; no played force, purse,
+ * alliance, phase, marker or receipt is installed. */
+export function createNoFieldAlliedTransitFixture(value: NoFieldValue = 0) {
+ const base = createNoFieldAlliedSeparationFixture({advanced: false, value});
+ let game = structuredClone(base.markerShipment.before);
+ const actor = base.actor, ally = base.ally, other = base.other;
+ const actions: NoFieldAlliedSeparationStep[] = [];
+ const perform = (id: string, action: Action) => { game = act(game, id, action, actions); };
+ const reach = (predicate: (state: Game) => boolean) => {
+ for (let count = 0; count < 1800 && !predicate(game); count++) {
+ const own = player(game, actor);
+ // The two-force first shipment costs four, so native turn-two Charity
+ // must actually bring Richese from one spice to two for the marker.
+ if (game.turn === 2 && game.phase === 2 && clean(game) && own.spice < 2 && own.charityTurn !== game.turn) {
+ perform(actor, {type: 'charity'}); continue;
+ }
+ const step = nextNoFieldAlliedSeparation(game); assert.ok(step);
+ perform(step.actor, step.action);
+ }
+ assert.ok(predicate(game), 'The real transit source must reach its requested boundary.');
+ };
+ assert.equal(game.active, actor); assert.equal(game.turn, 1); assert.equal(game.storm, 10);
+ assert.equal(player(game, ally).forces['imperial_basin:11'], 2);
+ assert.equal(player(game, actor).noField!.deployed, null);
+ const beforePhysical = structuredClone(game);
+ perform(actor, {type: 'ship', territory: 'imperial_basin', sector: 9, amount: 2, elite: 0, allyPayment: 0});
+ reach(clean);
+ assert.equal(player(game, actor).forces['imperial_basin:9'], 2);
+ assert.equal(player(beforePhysical, actor).spice - player(game, actor).spice, 4);
+ perform(actor, {type: 'endMovement'});
+ reach(state => state.turn === 2 && state.phase === 0 && clean(state));
+ const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
+ for (let position = 0; position < 2; position++) {
+ const from = game.spiceDeck.findIndex((card, index) => index >= position && (position === 0
+ ? 'worm' in card && !card.greatMaker && !card.suppressed
+ : 'territory' in card && !card.discovery && card.territory !== 'imperial_basin' &&
+ game.players.every(seat => !Object.entries(seat.forces).some(([key, count]) =>
+ count > 0 && key.startsWith(`${card.territory}:`)))));
+ assert.ok(from >= position);
+ game.spiceDeck.splice(position, 0, game.spiceDeck.splice(from, 1)[0]);
+ }
+ assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
+ reach(state => state.turn === 2 && state.phase === 1 && state.nexus &&
+ !state.spiceWindow && !state.spiceResolution && clean(state));
+ perform(actor, {type: 'alliance', target: ally});
+ perform(ally, {type: 'alliance', target: actor});
+ assert.equal(player(game, actor).ally, ally); assert.equal(player(game, ally).ally, actor);
+ reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && clean(state));
+ const own = player(game, actor), marker = own.noField?.tokens.find(item => item.value === value);
+ assert.ok(marker && own.noFieldEvent);
+ assert.equal(own.forces['imperial_basin:9'], 2);
+ assert.equal(player(game, ally).forces['imperial_basin:11'], 2);
+ assert.equal(own.allySinceTurn, game.turn);
+ assert.ok(own.spice >= 2);
+ const ship: Action = {type: 'ship', noField: marker.id, event: own.noFieldEvent,
+ territory: 'imperial_basin', sector: 9, allyPayment: 0};
+ return {game, actor, ally, other, value, token: marker.id, ship, actions,
+ baseline: 2, original: inventory(game), base};
+}
+
+/** Resolve the original No-Field response and any native arrival controls. */
+export function finishAlliedMarkerShipment(game: Game): Game {
+ return advance(game, state => state.phase === 5 && clean(state) && !state.pendingShipment);
+}
diff --git a/tests/no-field-allied-transit.test.ts b/tests/no-field-allied-transit.test.ts
new file mode 100644
index 00000000..0bd80ec5
--- /dev/null
+++ b/tests/no-field-allied-transit.test.ts
@@ -0,0 +1,136 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { TERRITORIES } from '../game/board';
+import { act, advance, clean, createNoFieldAlliedTransitFixture as fixture,
+ finishAlliedMarkerShipment, inventory, player } from './fixture-no-field-allied-transit';
+
+function ship(f: ReturnType): Game {
+ const before = structuredClone(f.game);
+ let game = act(f.game, f.actor, f.ship);
+ assert.deepEqual(f.game, before, 'Declaring the real shipment does not mutate the input.');
+ // The native action can resolve the no-response case immediately when no
+ // player can intervene, or retain its real Richese response for passes.
+ if (game.response) assert.equal(game.response.kind, 'richeseNoField');
+ game = finishAlliedMarkerShipment(game);
+ assert.equal(player(before, f.actor).spice - player(game, f.actor).spice, 2);
+ assert.equal(player(game, f.actor).reserves, player(before, f.actor).reserves);
+ assert.equal(player(game, f.actor).forces['imperial_basin:9'], f.baseline);
+ assert.equal(player(game, f.actor).noField!.deployed!.tokenId, f.token);
+ assert.deepEqual(inventory(game), f.original);
+ const warning = viewGame(game, f.actor).basicAlliedShipment;
+ assert.ok(warning); assert.equal(warning.territory, 'Imperial Basin');
+ assert.equal(warning.normal, 0); assert.equal(warning.elite, 0); assert.equal(warning.noField, true);
+ assert.equal(viewGame(game, f.ally).basicAlliedShipment, null);
+ assert.equal(viewGame(game, f.ally).richeseNoField?.private, null);
+ return game;
+}
+
+for (const value of [0, 3, 5] as const) {
+ void test(`Basic allied marker ${value} may depart on its own original movement`, () => {
+ const f = fixture(value); let game = ship(f);
+ const event = player(game, f.actor).noFieldEvent; assert.ok(event);
+ game = act(game, f.actor, {type: 'move', noField: f.token, event,
+ forces: {}, territory: 'polar_sink', sector: 0});
+ game = advance(game, clean);
+ assert.deepEqual(player(game, f.actor).noField!.deployed!.location, {territory: 'polar_sink', sector: 0});
+ assert.equal(viewGame(game, f.actor).basicAlliedShipment, null);
+ game = act(game, f.actor, {type: 'endMovement'});
+ assert.ok(player(game, f.actor).noField!.deployed);
+ assert.equal(player(game, f.actor).forces['imperial_basin:9'], f.baseline);
+ assert.equal(player(game, f.ally).forces['imperial_basin:11'], 2);
+ assert.deepEqual(inventory(game), f.original);
+ });
+
+ void test(`Basic allied marker ${value} left at turn end reveals and loses only its new forces`, () => {
+ const f = fixture(value), game = ship(f), before = player(game, f.actor);
+ const reserves = before.reserves, tanks = before.tanks, event = before.noFieldEvent;
+ const after = act(game, f.actor, {type: 'endMovement'});
+ const own = player(after, f.actor), materialized = Math.min(value, reserves);
+ assert.equal(own.noField!.deployed, null); assert.notEqual(own.noFieldEvent, event);
+ assert.equal(own.noField!.lastShipped, f.token);
+ assert.equal(own.reserves, reserves - materialized); assert.equal(own.tanks, tanks + materialized);
+ assert.equal(own.forces['imperial_basin:9'], f.baseline);
+ assert.equal(player(after, f.ally).forces['imperial_basin:11'], 2);
+ assert.equal(viewGame(after, f.actor).basicAlliedShipment, null);
+ assert.deepEqual(inventory(after), f.original);
+ });
+}
+
+void test('voluntary revelation preserves the same visitor obligation without revealing twice', () => {
+ const f = fixture(3); let game = ship(f);
+ const event = player(game, f.actor).noFieldEvent; assert.ok(event);
+ game = act(game, f.actor, {type: 'revealNoField', event, token: f.token});
+ const revealedEvent = player(game, f.actor).noFieldEvent;
+ assert.equal(player(game, f.actor).forces['imperial_basin:9'], f.baseline + 3);
+ assert.equal(viewGame(game, f.actor).basicAlliedShipment?.normal, 3);
+ game = act(game, f.actor, {type: 'endMovement'});
+ assert.equal(player(game, f.actor).noFieldEvent, revealedEvent);
+ assert.equal(player(game, f.actor).forces['imperial_basin:9'], f.baseline);
+ assert.equal(player(game, f.actor).tanks, 3);
+ assert.equal(player(game, f.ally).forces['imperial_basin:11'], 2);
+ assert.deepEqual(inventory(game), f.original);
+});
+
+void test('each minimal AI profile can complete an allied marker visit legally', () => {
+ const f = fixture(3), source = ship(f);
+ for (const difficulty of DIFFICULTIES) {
+ let game = structuredClone(source);
+ for (let count = 0; game.phase === 5 && game.active === f.actor && count < 60; count++) {
+ if (!clean(game)) { game = advance(game, clean); continue; }
+ const view = viewGame(game, f.actor);
+ view.players.find(seat => seat.id === f.actor)!.bot = difficulty;
+ const next = botActions(view)[0]; assert.ok(next);
+ game = act(game, f.actor, next);
+ }
+ assert.notEqual(game.active, f.actor, `${difficulty} must finish the visiting turn.`);
+ assert.equal(player(game, f.ally).forces['imperial_basin:11'], 2);
+ assert.deepEqual(inventory(game), f.original);
+ }
+});
+
+void test('Karama cancels the marker visit before payment, custody or visitor receipt', () => {
+ const f = fixture(3), game = structuredClone(f.game), opponent = player(game, f.other);
+ // Controlled conserved card transfer: select one original undealt Karama
+ // into an opponent's free hand slot to exercise the native response.
+ const from = game.deck.findIndex(card => card.effect === 'karama');
+ assert.ok(from >= 0); assert.ok(opponent.hand.length < 4);
+ const [karama] = game.deck.splice(from, 1); opponent.hand.push(karama);
+ assert.deepEqual(inventory(game), f.original);
+ const before = structuredClone(game);
+ let attempted = act(game, f.actor, f.ship);
+ assert.equal(attempted.response?.kind, 'richeseNoField');
+ attempted = act(attempted, f.other, {type: 'card', mode: 'cancel', card: karama.id});
+ attempted = advance(attempted, state => clean(state) && !state.pendingShipment);
+ assert.equal(player(attempted, f.actor).spice, player(before, f.actor).spice);
+ assert.equal(player(attempted, f.actor).reserves, player(before, f.actor).reserves);
+ assert.deepEqual(player(attempted, f.actor).noField, player(before, f.actor).noField);
+ assert.equal(player(attempted, f.actor).shipped, false);
+ assert.equal(player(attempted, f.actor).forces['imperial_basin:9'], f.baseline);
+ assert.equal(player(attempted, f.ally).forces['imperial_basin:11'], 2);
+ assert.ok(attempted.basicAlliedShipment == null);
+ assert.equal(attempted.discard.filter(card => card.id === karama.id).length, 1);
+ assert.deepEqual(inventory(attempted), f.original);
+});
+
+void test('allied transit does not bypass storm or the stronghold occupancy limit', () => {
+ const f = fixture(3), storm = TERRITORIES.find(place => place.type !== 'polar' && place.sectors.includes(f.game.storm));
+ assert.ok(storm);
+ const original = structuredClone(f.game);
+ assert.throws(() => applyAction(f.game, f.actor, {...f.ship, territory: storm.id, sector: f.game.storm}), /storm/i);
+ assert.deepEqual(f.game, original);
+
+ const blocked = structuredClone(f.game), ally = player(blocked, f.ally);
+ // Controlled force relocation retains the two physical ally counters; the
+ // native Atreides garrison supplies the second existing stronghold occupant.
+ assert.ok(player(blocked, f.other).forces['arrakeen:10'] > 0);
+ assert.equal(ally.forces['imperial_basin:11'], 2);
+ delete ally.forces['imperial_basin:11']; ally.forces['arrakeen:10'] = 2;
+ assert.notEqual(blocked.storm, 10);
+ const before = structuredClone(blocked);
+ assert.throws(() => applyAction(blocked, f.actor, {...f.ship, territory: 'arrakeen', sector: 10}), /stronghold.*three/i);
+ assert.deepEqual(blocked, before);
+ assert.deepEqual(inventory(blocked), f.original);
+});