Support paid No-Field companions in Basic allied transit
Verify and publish checkpoint container / container (push) Successful in 10m34s

This commit is contained in:
Quintine committed 2026-10-28 22:49:42 +11:00
1 parent 8083598cef
commit 40d567a053
7 files changed
+270 -9

No files matched your search

+3 -1
View File
@@ -108,6 +108,8 @@ deployed acceptance was run.
**Prototyped / Partial.** Richese may ship its own single concealed No-Field
into an eligible ally-occupied territory, then move it out during its turn.
High Richese Homeworld may include paid physical reserve companions; the whole
group must depart, using its existing combined price and typed reserve custody.
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
@@ -116,7 +118,7 @@ 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
provisional. Allied No-Field use, paired markers and other
transport compositions remain separate.
## Basic ordinary allied shipment prototype — 8 October 2026
+11 -2
View File
@@ -16,12 +16,21 @@ open mode gates.
## Current checkpoint and work
**Basic allied No-Field transit with paid companions — 28 October.
Prototyped / Partial.** High Richese Homeworld can include paid physical
reserves beside its own visiting marker. The existing temporary-visit baseline
and no-exit policy apply to both, with one original invoice and marker response.
Native-action tests cover departure, 0/3/5 reveal, voluntary reveal and Karama
cancellation. All four AI profiles can choose a legal joint departure. The
related eleven-file batch passes 84 cases; paired markers, Smuggler and other
combinations remain separate.
**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).
owner sees the warning without revealing the value; paired markers 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.
+25 -2
View File
@@ -3548,6 +3548,8 @@ function policyActions(g: GameView): Action[] {
const physicalAlongsideMarker = marker
? countAt(me, marker.location.territory) > 0
: !!(deployedWorld && g.homeworlds?.worlds?.find(world => world.id === deployedWorld)?.forces[me.id]);
const visitingMarker = !!marker && !!g.basicAlliedShipment?.noField &&
territory(marker.location.territory).name === g.basicAlliedShipment.territory;
const reveal =
noField?.canReveal &&
deployed &&
@@ -3555,7 +3557,7 @@ function policyActions(g: GameView): Action[] {
? { type: 'revealNoField', token: deployed.tokenId, event: noField.event }
: null;
if (
reveal &&
reveal && !visitingMarker &&
(physicalAlongsideMarker ||
!me.shipped ||
(me.moved ?? 0) >= (me.movesAllowed ?? 1))
@@ -3602,7 +3604,7 @@ function policyActions(g: GameView): Action[] {
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;
if (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;
@@ -3629,6 +3631,20 @@ function policyActions(g: GameView): Action[] {
const ordinary = botGroundMoveAllowed(g, me, from, to.key, 0);
if (!ordinary && !(markerPlanetologist &&
botGroundMoveAllowed(g, me, from, to.key, 0, 'range'))) continue;
if (visitingMarker && g.basicAlliedShipment && g.basicAlliedShipment.normal > 0) {
let remaining = g.basicAlliedShipment.normal;
const forces: Record<string, number> = {};
for (const [key, total] of Object.entries(me.forces)) {
if (splitLocation(key).territory !== marker.location.territory ||
!botGroundMoveAllowed(g, me, key, to.key, 0, ordinary ? undefined : 'range')) continue;
const normal = total - (me.elites?.forces[key] ?? 0);
const count = Math.min(remaining, normal);
if (count > 0) { forces[key] = count; remaining -= count; }
}
if (!remaining) actions.push({ type: 'move', ...(!ordinary ? {planetologist: 'range'} : {}),
forces, noField: marker.tokenId,
event: noField.event, territory: to.t, sector: to.s });
}
actions.push({
type: 'move',
...(!ordinary ? { planetologist: 'range' } : {}),
@@ -4517,6 +4533,13 @@ export function botActions(g: GameView): Action[] {
(territory(splitLocation(action.from).territory).name === g.basicAlliedShipment?.territory ||
advancedDepartures?.includes(territory(splitLocation(action.from).territory).name)) ||
!!action.noField && !!g.basicAlliedShipment?.noField && markerTerritory === g.basicAlliedShipment.territory));
if (g.basicAlliedShipment?.noField && g.basicAlliedShipment.normal > 0) {
const together = departures.filter(action => action.noField && action.forces &&
typeof action.forces === 'object' && !Array.isArray(action.forces) &&
Object.values(action.forces).reduce<number>((sum, value) => sum + (typeof value === 'number' ? value : 0), 0) >=
g.basicAlliedShipment!.normal);
if (together.length) return together;
}
if (departures.length) return departures;
}
const ghola = standaloneGholaAction(g, actions);
+4 -3
View File
@@ -37660,7 +37660,8 @@ function validateLeaderSkillNoFieldShipment(g: Game, shipment: PendingShipment)
'The saved No-Field shipment lost its current unused opportunity or concealed marker.');
integer(shipment.sector, 0, 18, 'No-Field shipment sector');
requireRule(shipment.territory !== MOBILE_STRONGHOLD, 'Richese cannot ship into the mobile stronghold.');
allowedEntry(g, p, shipment.territory, shipment.sector, false, false);
allowedEntry(g, p, shipment.territory, shipment.sector, false, false,
!g.advanced && !shipment.smugglerCompanion && isAlliedShipmentVisit(g, p, shipment.territory));
noFieldRule(() => deployRicheseNoField(p.noField!, {
tokenId: shipment.noField!.tokenId,
controller: p.id,
@@ -37934,7 +37935,7 @@ function openGuildRate(g: Game, shipment: PendingShipment): boolean {
owner: byFaction(g, 'guild')!.id,
passed: [],
guildRateEvent: event,
intent: `${getPlayer(g, shipment.player).name} declared ${shipment.alliedNoField ? 'one allied No-Field shipment' : shipment.noField ? 'one concealed No-Field' : shipment.nexusEvent ? `${shipment.amount} physical reserve forces priced as one by Richese Secret Ally` : `${shipment.amount} reserve forces`}${shipment.smugglerCompanion ? ' with one free Smuggler companion' : shipment.smuggler ? ' including one free Smuggler companion' : ''} to ${territory(shipment.territory).name} at the Guild rate of ${shipment.cost} spice. Karama can remove this discount before any payment or arrival.${shipment.smuggler || shipment.smugglerCompanion ? ' The independent free companion remains free.' : ''} The same paid units would cost ${fullCost} at full price; if the original authorized split cannot pay that amount, the declaration returns unused and a replacement this turn uses the full rate.${shipment.nexusEvent ? ' Richese still prices only one counter, not the actual force count. Its Nexus is already spent: an unaffordable declaration ends without delivery or a card refund, while the ordinary shipment remains available.' : ''}`,
intent: `${getPlayer(g, shipment.player).name} declared ${shipment.alliedNoField ? 'one allied No-Field shipment' : shipment.noField ? `one concealed No-Field${shipment.noFieldReserves ? ` and ${shipment.noFieldReserves} paid reserve forces` : ''}` : shipment.nexusEvent ? `${shipment.amount} physical reserve forces priced as one by Richese Secret Ally` : `${shipment.amount} reserve forces`}${shipment.smugglerCompanion ? ' with one free Smuggler companion' : shipment.smuggler ? ' including one free Smuggler companion' : ''} to ${territory(shipment.territory).name} at the Guild rate of ${shipment.cost} spice. Karama can remove this discount before any payment or arrival.${shipment.smuggler || shipment.smugglerCompanion ? ' The independent free companion remains free.' : ''} The same paid units would cost ${fullCost} at full price; if the original authorized split cannot pay that amount, the declaration returns unused and a replacement this turn uses the full rate.${shipment.nexusEvent ? ' Richese still prices only one counter, not the actual force count. Its Nexus is already spent: an unaffordable declaration ends without delivery or a card refund, while the ordinary shipment remains available.' : ''}`,
};
log(g, `${getPlayer(g, shipment.player).name} declared a Guild-rate reserve shipment; payment and arrival wait for the rate response.`);
return true;
@@ -44773,7 +44774,7 @@ function applyActionInner(
);
allowedEntry(g, p, to, s, !guildSecretEvent && g.advanced && p.faction === 'fremen', advisors,
(action.noField === undefined || !g.advanced && !richesePair &&
action.noFieldReserves === undefined && action.smuggler !== true) &&
action.smuggler !== true) &&
isAlliedShipmentVisit(g, p, to));
let noField: PendingShipment['noField'];
if (action.noField !== undefined) {
+1 -1
View File
@@ -1122,7 +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.',
'In Basic play, your own single concealed No-Field may provisionally ship into an ally’s territory if it can legally enter; move it and any paid accompanying forces out before your turn ends. Paid companions require the High Richese Homeworld power. If the marker 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 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.',
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import type { NoFieldValue } from '../game/richese-no-field';
import { actNoFieldAlliedSeparation as act, noFieldAlliedSeparationClean as clean,
noFieldAlliedSeparationPlayer as player, nextNoFieldAlliedSeparation } from './fixture-no-field-allied-separation';
import { nextRicheseHomeworldShipmentNativeStep, richeseHomeworldShipmentInventory as inventory,
richeseHomeworldShipmentPopulation } from './fixture-richese-homeworld-shipment';
export { act, clean, inventory, player };
/** Original Basic + Homeworlds setup: one real paid counter per side in the
* storm-separated Basin, then an actual worm, consensual alliance and an aid
* pledge. Only unopened Treachery and Spice order is selected. */
export function createNoFieldAlliedCompanionsFixture(value: NoFieldValue) {
const actor = 'richese', ally = 'emperor', other = 'atreides';
let game = createGame('NOFIELDALLIEDCOMPANIONS', newPlayer(actor, 'Richese', 'richese'), false, ['choam']);
joinGame(game, newPlayer(ally, 'Emperor', 'emperor'));
joinGame(game, newPlayer(other, 'Atreides', 'atreides'));
const actions: {actor: string; action: Action}[] = [];
const perform = (id: string, action: Action) => { game = act(game, id, action, actions); };
const reach = (predicate: (state: Game) => boolean) => {
for (let i = 0; i < 1800 && !predicate(game); i++) {
if (game.turn === 2 && game.phase === 2 && clean(game) && player(game, actor).spice < 2 &&
player(game, actor).charityTurn !== game.turn) {
perform(actor, {type: 'charity'}); continue;
}
const step = nextNoFieldAlliedSeparation(game) ?? nextRicheseHomeworldShipmentNativeStep(game);
assert.ok(step, `Missing native control at ${game.turn}/${game.phase}/${game.decision?.kind}`);
perform(step.actor, step.action);
}
assert.ok(predicate(game), 'Reach the requested native boundary');
};
for (const [index, seat] of game.players.entries()) perform(seat.id, {type: 'seatPosition', position: index + 4});
perform(ally, {type: 'seatPosition', position: 1}); perform(actor, {type: 'seatPosition', position: 2});
perform(other, {type: 'seatPosition', position: 3});
perform(game.host, {type: 'homeworlds', enabled: true});
for (const seat of game.players) perform(seat.id, {type: 'ready'});
perform(game.host, {type: 'start'});
const ordinary = game.deck.find(card => card.kind === 'projectile');
const shield = game.deck.find(card => card.kind === 'shield');
const karama = game.deck.find(card => card.effect === 'karama');
assert.ok(ordinary && shield && karama);
for (const [index, id] of [ordinary.id, shield.id, karama.id].entries()) {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= index);
[game.deck[index], game.deck[from]] = [game.deck[from], game.deck[index]];
}
reach(state => state.status === 'playing');
assert.ok(player(game, other).hand.some(card => card.id === karama.id));
const original = inventory(game);
const safe = (position: number, worm: boolean) => {
const from = game.spiceDeck.findIndex((card, i) => i >= position && (worm
? '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, n]) => n > 0 && key.startsWith(`${card.territory}:`)))));
assert.ok(from >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(from, 1)[0]);
};
safe(0, false);
for (const [index, dialer] of game.stormDialers.entries()) perform(dialer, {type: 'stormDial', amount: index ? 5 : 4});
reach(state => state.turn === 1 && state.phase === 5 && clean(state));
assert.equal(game.storm, 10);
while (game.phase === 5) {
const current = game.active;
if (clean(game) && current === ally && !player(game, ally).shipped) {
perform(ally, {type: 'ship', territory: 'imperial_basin', sector: 11, amount: 1, elite: 0, allyPayment: 0,
homeworldSources: {'homeworld:emperor': {normal: 1, elite: 0}}});
reach(clean); continue;
}
if (clean(game) && current === actor && !player(game, actor).shipped) {
perform(actor, {type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0,
homeworldSources: {'homeworld:richese': {normal: 1, elite: 0}}});
reach(clean); perform(actor, {type: 'endMovement'}); break;
}
const step = nextNoFieldAlliedSeparation(game) ?? nextRicheseHomeworldShipmentNativeStep(game); assert.ok(step);
perform(step.actor, step.action);
}
reach(state => state.turn === 2 && state.phase === 0 && clean(state));
safe(0, true); safe(1, false);
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});
reach(state => state.turn === 2 && state.phase === 5 && state.active === actor && clean(state));
assert.equal(player(game, actor).forces['imperial_basin:9'], 1);
assert.equal(player(game, ally).forces['imperial_basin:11'], 1);
assert.equal(richeseHomeworldShipmentPopulation(game).side, 'high');
assert.equal(player(game, actor).ally, ally); assert.equal(player(game, ally).ally, actor);
const own = player(game, actor), token = own.noField!.tokens.find(token => token.value === value);
assert.ok(token && own.noFieldEvent);
const needed = Math.max(0, 4 - own.spice); assert.ok(player(game, ally).spice >= needed);
if (needed) perform(ally, {type: 'pledgeAid', amount: needed});
const ship: Action = {type: 'ship', noField: token.id, event: player(game, actor).noFieldEvent!,
noFieldReserves: 1, territory: 'imperial_basin', sector: 9, allyPayment: needed};
return {game, actor, ally, other, token: token.id, karama: karama.id, value, ship,
allyPayment: needed, baseline: 1, companions: 1, original, actions};
}
export function advanceAlliedCompanions(game: Game, until: (state: Game) => boolean): Game {
for (let i = 0; i < 1800; i++) {
if (until(game)) return game;
const step = nextNoFieldAlliedSeparation(game) ?? nextRicheseHomeworldShipmentNativeStep(game); assert.ok(step);
game = act(game, step.actor, step.action);
}
throw Error('Native allied companion controls did not reach the requested boundary');
}
+124
View File
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { act, advanceAlliedCompanions as advance, clean, createNoFieldAlliedCompanionsFixture as fixture,
inventory, player } from './fixture-no-field-allied-companions';
function ship(f: ReturnType<typeof fixture>): Game {
const before = structuredClone(f.game);
let game = act(f.game, f.actor, f.ship);
assert.deepEqual(f.game, before);
if (game.response) assert.equal(game.response.kind, 'richeseNoField');
game = advance(game, state => clean(state) && !state.pendingShipment);
const own = player(game, f.actor);
assert.equal(player(before, f.actor).spice - own.spice, 4 - f.allyPayment);
assert.equal(own.reserves, player(before, f.actor).reserves - f.companions);
assert.equal(own.forces['imperial_basin:9'], f.baseline + f.companions);
assert.equal(own.noField!.deployed!.tokenId, f.token);
const warning = viewGame(game, f.actor).basicAlliedShipment;
assert.ok(warning); assert.equal(warning.normal, f.companions);
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);
assert.deepEqual(inventory(game), f.original);
return game;
}
for (const value of [0, 3, 5] as const) {
void test(`Basic allied paid companion and marker ${value} may leave together`, () => {
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: {'imperial_basin:9': f.companions}, 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.actor).forces['polar_sink:0'], f.companions);
assert.equal(player(game, f.ally).forces['imperial_basin:11'], 1);
assert.deepEqual(inventory(game), f.original);
});
void test(`Basic allied paid companion and marker ${value} remaining at turn end are lost`, () => {
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), revealed = 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 - revealed);
assert.equal(own.tanks, tanks + revealed + f.companions);
assert.equal(own.forces['imperial_basin:9'], f.baseline);
assert.equal(player(after, f.ally).forces['imperial_basin:11'], 1);
assert.equal(viewGame(after, f.actor).basicAlliedShipment, null);
assert.deepEqual(inventory(after), f.original);
});
}
void test('voluntary reveal adds actual marker forces to the paid companion obligation', () => {
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 + f.companions + 3);
assert.equal(viewGame(game, f.actor).basicAlliedShipment?.normal, f.companions + 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, f.companions + 3);
assert.equal(player(game, f.ally).forces['imperial_basin:11'], 1);
assert.deepEqual(inventory(game), f.original);
});
void test('opponent Karama cancels the combined invoice before payment or custody', () => {
const f = fixture(3), before = structuredClone(f.game);
let game = act(f.game, f.actor, f.ship);
assert.equal(game.response?.kind, 'richeseNoField');
game = act(game, f.other, {type: 'card', mode: 'cancel', card: f.karama});
game = advance(game, state => clean(state) && !state.pendingShipment);
assert.equal(player(game, f.actor).spice, player(before, f.actor).spice);
assert.equal(player(game, f.actor).reserves, player(before, f.actor).reserves);
assert.deepEqual(player(game, f.actor).noField, player(before, f.actor).noField);
assert.equal(player(game, f.actor).shipped, false);
assert.equal(player(game, f.actor).forces['imperial_basin:9'], f.baseline);
assert.equal(player(game, f.ally).forces['imperial_basin:11'], 1);
assert.deepEqual(game.aid, before.aid);
assert.ok(game.basicAlliedShipment == null);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(inventory(game), f.original);
});
void test('each AI profile legally moves the marker and paid companion together without visitor losses', () => {
const f = fixture(3), source = ship(f);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(source);
const tanks = player(game, f.actor).tanks;
const view = viewGame(game, f.actor);
view.players.find(seat => seat.id === f.actor)!.bot = difficulty;
const departure = botActions(view)[0]; assert.ok(departure, `${difficulty} must choose a legal departure`);
assert.equal(departure.type, 'move');
assert.equal(departure.noField, f.token);
assert.equal((departure.forces as Record<string, number>)['imperial_basin:9'], f.companions);
game = act(game, f.actor, departure);
game = advance(game, clean);
assert.notEqual(departure.territory, 'imperial_basin');
assert.deepEqual(player(game, f.actor).noField!.deployed!.location,
{territory: departure.territory, sector: departure.sector});
assert.equal(viewGame(game, f.actor).basicAlliedShipment, null);
for (let count = 0; game.phase === 5 && game.active === f.actor && count < 60; count++) {
if (!clean(game)) { game = advance(game, clean); continue; }
const nextView = viewGame(game, f.actor);
nextView.players.find(seat => seat.id === f.actor)!.bot = difficulty;
const next = botActions(nextView)[0]; assert.ok(next);
game = act(game, f.actor, next);
}
assert.notEqual(game.active, f.actor, `${difficulty} must finish its visiting turn`);
assert.equal(player(game, f.actor).tanks, tanks);
assert.equal(player(game, f.ally).forces['imperial_basin:11'], 1);
assert.deepEqual(inventory(game), f.original);
}
});