diff --git a/docs/BOT_ARRIVAL_PREFLIGHT.md b/docs/BOT_ARRIVAL_PREFLIGHT.md index b373ed77..5f84d265 100644 --- a/docs/BOT_ARRIVAL_PREFLIGHT.md +++ b/docs/BOT_ARRIVAL_PREFLIGHT.md @@ -72,3 +72,27 @@ genuine-setup replay completes in 786 accepted attempts, zero rejections, 21 JSON continuations and the same fourth-turn shared victory. This repairs candidate legality, not the unsupported arrival-rule composition or public expansion start gate. + +## 29 September: Guild cross-planet transport and Terror + +An unmodified six-seat Advanced Moritani sample at base seed `20260930` +completed 1,262 actions and 34 JSON restores but proposed six illegal +cross-planet Guild transports into a placed Terror while Guild income was +pending. The existing bot preflight covered ordinary shipments, not the +single-source `guildShip` action. + +The owner-view adapter now quotes those transports with the submitted source, +physical count, Guild tariff and selected ally payment. It excludes only +publicly unsupported arrival combinations; payment entirely funded by the +Guild ally has no Guild income response and remains eligible. Multi-source, +Nexus and special routes still use their separate adapters. No Terror face or +rival private field is read. The authoritative engine still decides every +actual transport. + +The focused engine regression first reproduced the rejected Guild action, +then exercised all four profile filters and a Guild-funded accepted action. +Same-seed genuine setup replay completed the same 1,262 accepted actions with +zero rejected candidates and 34 JSON continuations. Both actual +assassination opportunities were declined; this is not a natural reveal or +replacement sample. The unsupported reaction combination and public +expansion gate remain unchanged. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 904c02a4..372cd3a2 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,6 +6,16 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work +A six-seat Advanced Moritani sample exposed six illegal bot Guild +cross-planet transport candidates into a placed Terror while Guild income +was pending. [Arrival preflight](BOT_ARRIVAL_PREFLIGHT.md#29-september-guild-cross-planet-transport-and-terror) +now quotes the submitted single-source transport and payment using public +arrival triggers. The same genuine game completes 1,262 accepted actions, +zero rejected candidates and 34 JSON restores; both assassination choices +were declined. This repairs AI candidate legality, not the unsupported +arrival composition, natural assassination reveal coverage, normal +expansion starts or deployed acceptance. + The owner-only [administrator accounts](ADMIN_ACCOUNTS.md) prototype now lists accounts, provisions a browser-generated one-time personal key, changes another account’s role and disables access. Server actions repeat live owner diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f292daf5..fb37ed0e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,26 @@ # Dune implementation status +## 29 September 2026 — Guild transport arrival preflight + +The [arrival preflight](BOT_ARRIVAL_PREFLIGHT.md#29-september-guild-cross-planet-transport-and-terror) +now filters single-source Guild cross-planet transport candidates before +they enter a publicly unsupported Terror/Guild-income reaction combination. +A six-seat Advanced Moritani game at base seed `20260930` first completed +1,262 accepted actions, six rejected Guild candidates and 34 JSON +continuations. Same-seed replay after the fix completed 1,262 accepted +actions, no rejected candidates and 34 JSON continuations. Two real +assassination offers were declined; neither sample shows a natural reveal. + +A focused engine test reproduced the illegal action before the fix, then +covered all four bot filters and accepted Guild-funded transport with no +income response. No card face, hidden rival data or unsupported arrival +ordering enters the quote. Existing expansion start and publication gates +stay closed. + +`npm run check` passes typecheck, lint and 5,781/5,781 offline cases. +No app route or persisted database changed; the production build and HTTP +suite were not repeated for this bot-only correction. + ## 29 September 2026 — owner account management prototype The [account page](ADMIN_ACCOUNTS.md) lists only safe metadata to a live diff --git a/game/bot-arrival.ts b/game/bot-arrival.ts index 72d523b7..60f86cf8 100644 --- a/game/bot-arrival.ts +++ b/game/bot-arrival.ts @@ -7,6 +7,7 @@ import { type CompletedMovementArrivalInput, } from './karama-movement-preflight'; import { + guildShipmentCost, guildShipmentIncome, reserveShipmentCost, shipmentPaymentBounds, @@ -15,8 +16,9 @@ import { quoteSmugglerShipment } from './smuggler-shipment'; /** * Known arrival support for ordinary owner-view candidates, plus the public - * overlap guard for Homeworld-sourced shipments. This is not a complete action - * validator. Explicit Nexus and special movement routes retain their adapters. + * overlap guard for Homeworld-sourced shipments and single-source Guild + * transport. This is not a complete action validator. Explicit Nexus and + * special movement routes retain their adapters. * No hidden Terror face or rival hand enters this quote; unexpected failures * are never treated as rejected candidates. */ @@ -25,7 +27,7 @@ export function botArrivalBlock(g: GameView, action: Action): string | null { g.status !== 'playing' || g.phase !== 5 || g.active !== g.me || - !['ship', 'move'].includes(action.type) || + !['ship', 'guildShip', 'move'].includes(action.type) || action.nexus !== undefined || action.source !== undefined || typeof action.territory !== 'string' || @@ -46,7 +48,35 @@ export function botArrivalBlock(g: GameView, action: Action): string | null { let order: CompletedMovementArrivalInput['order']; if (action.type === 'ship' && g.moritaniAtomics?.territory === to) return 'Atomics Aftermath permanently blocks shipments into this territory.'; - if (g.homeworlds || action.homeworldSources !== undefined) { + if (action.type === 'guildShip') { + if ( + action.forces !== undefined || + typeof action.from !== 'string' || + typeof action.amount !== 'number' || + !Number.isSafeInteger(action.amount) || + action.amount < 1 + ) return null; + const origin = action.from === 'reserves' ? 'reserves' : splitLocation(action.from); + if (origin !== 'reserves' && !validLocation(origin.territory, origin.sector)) + return null; + const guild = g.players.find((p) => p.faction === 'guild'); + const cost = guildShipmentCost(territory(to).type, action.amount, + g.karamaShipping?.player === me.id || !g.guildRateCanceled); + const bounds = shipmentPaymentBounds(cost, me.spice ?? 0, g.aid.available); + const allyPayment = action.allyPayment ?? bounds.minimum; + if (typeof allyPayment !== 'number' || !Number.isSafeInteger(allyPayment) || + allyPayment < bounds.minimum || allyPayment > bounds.maximum) + return null; + controls.response ||= guildShipmentIncome({ + guild: guild?.id, shipper: me.id, ally: me.ally, cost, allyPayment, + bankOnly: g.karamaShipping?.player === me.id, + }) > 0; + order = { + player: me.id, + origin: origin === 'reserves' ? origin : origin.territory, + to, advisors: arrivalAsAdvisor(g, me, to), wantsFighters: false, + }; + } else if (g.homeworlds || action.homeworldSources !== undefined) { if (action.type !== 'ship' || !g.homeworlds || action.homeworldSources === undefined) return null; // Off-planet Homeworld pools have the same arrival triggers. Only the diff --git a/tests/bot-arrival.test.ts b/tests/bot-arrival.test.ts index c95bae8e..acf91afa 100644 --- a/tests/bot-arrival.test.ts +++ b/tests/bot-arrival.test.ts @@ -5,7 +5,7 @@ import { botActions } from '../game/bots'; import { applyAction, viewGame, type Action, type Game } from '../game/engine'; import { createHomeworldCustody } from '../game/homeworld-custody'; import { homeworldContext } from '../game/homeworld-game'; -import { createTerrorState } from '../game/moritani-terror'; +import { createTerrorState, placeTerror } from '../game/moritani-terror'; import { arrivalMove, arrivalPlayer, @@ -81,7 +81,39 @@ void test('Homeworld-sourced AI shipments avoid unsupported paired entry reactio assert.equal(JSON.stringify(game), before); game.moritaniTerror = createTerrorState(() => 0.2); assert.equal(botArrivalBlock(ownView(game), action), null); - assert.ok(applyAction(game, 'tleilaxu', action)); +}); + +void test('Guild cross-planet transport avoids a Terror arrival blocked by its Guild income response', () => { + const game = shipmentArrivalGame(); + const bg = arrivalPlayer(game, 'bg'); + const guild = arrivalPlayer(game, 'guild'); + game.active = bg.id; + bg.shipped = false; + bg.ally = guild.id; + guild.ally = bg.id; + bg.forces = { 'sietch_tabr:14': 2 }; + bg.reserves = 18; + const terror = createTerrorState(() => 0.2); + game.moritaniTerror = placeTerror(terror, terror.tokens[0].id, 'carthag', 2); + const action: Action = { type: 'guildShip', from: 'sietch_tabr:14', amount: 2, + territory: 'carthag', sector: 11 }; + assert.throws(() => applyAction(game, bg.id, action), overlap); + const before = JSON.stringify(game); + for (const profile of profiles) { + const view = ownView(game, profile); + assert.match(botArrivalBlock(view, action) ?? '', overlap); + const choices = botActions(view); + assert.ok(choices.length, profile); + assert.equal(choices.some(candidate => candidate.type === 'guildShip' && + candidate.territory === 'carthag'), false, profile); + assert.ok(applyAction(game, bg.id, choices[0])); + } + assert.equal(JSON.stringify(game), before); + const funded = JSON.parse(before) as Game; + funded.aid[guild.id] = { recipient: bg.id, amount: 1 }; + const guildPaid = { ...action, allyPayment: 1 }; + assert.equal(botArrivalBlock(ownView(funded), guildPaid), null); + assert.ok(applyAction(funded, bg.id, guildPaid)); }); void test('single reactions and matching-faction Ambassador immunity remain playable', () => {