From 40d567a053efa79f50e702b0528a14798e7e781b Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:49:42 +1100 Subject: [PATCH] Support paid No-Field companions in Basic allied transit --- docs/ALLIED_TRANSIT_RULES.md | 4 +- docs/CURRENT_STATUS.md | 13 +- game/bots.ts | 27 ++++- game/engine.ts | 7 +- game/reference.ts | 2 +- tests/fixture-no-field-allied-companions.ts | 102 ++++++++++++++++ tests/no-field-allied-companions.test.ts | 124 ++++++++++++++++++++ 7 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 tests/fixture-no-field-allied-companions.ts create mode 100644 tests/no-field-allied-companions.test.ts diff --git a/docs/ALLIED_TRANSIT_RULES.md b/docs/ALLIED_TRANSIT_RULES.md index 2b428bff..5d82699e 100644 --- a/docs/ALLIED_TRANSIT_RULES.md +++ b/docs/ALLIED_TRANSIT_RULES.md @@ -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 diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 114d6554..8711f845 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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. diff --git a/game/bots.ts b/game/bots.ts index df13eccf..0c29a2ae 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -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 = {}; + 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((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); diff --git a/game/engine.ts b/game/engine.ts index e119e9c3..bed822ce 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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) { diff --git a/game/reference.ts b/game/reference.ts index 55b63f63..e8e31d21 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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.', diff --git a/tests/fixture-no-field-allied-companions.ts b/tests/fixture-no-field-allied-companions.ts new file mode 100644 index 00000000..c0bb32fb --- /dev/null +++ b/tests/fixture-no-field-allied-companions.ts @@ -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'); +} diff --git a/tests/no-field-allied-companions.test.ts b/tests/no-field-allied-companions.test.ts new file mode 100644 index 00000000..8a3bd98d --- /dev/null +++ b/tests/no-field-allied-companions.test.ts @@ -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): 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)['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); + } +});