Filter unsupported ordinary AI arrival candidates

Bots previously retried ordinary ship/move arrivals rejected by the existing Ambassador/Terror guard. A focused owner-view adapter now calls the same immutable arrival quote as authoritative preflight after source and payment selection. It preserves single reactions, faction exemptions, BG stance, actual Guild contribution income and No-Field component pricing. Specialized Homeworld, explicit Nexus and special movement routes retain their existing adapters; no simultaneous-reaction priority or module gate changes.

Independent rules/privacy review passed. Six new regressions cover all four profiles, executable alternatives, real-engine single reactions, Guild-funded contributions, BG accompaniment/intrusion, owned No-Fields, immutable JSON views and access traps on rival private data and concealed Terror faces. The adapter catches only typed arrival failures and does not sample randomness. Twenty-five focused rules and recovery cases passed, followed by types, lint and 4829 offline tests on frozen source. No UI, build, dependency, HTTP or database code changed; build and HTTP suites were not repeated.

Eighteen genuine Basic/Advanced base and expansion samples completed: 9470 accepted actions, 247 periodic JSON restores and zero rejected candidates. The original reproduction dropped forty Basic and four Advanced rejected candidates; the six expansion baseline samples dropped eighty-two more. All accepted sequences matched their same-seed predecessors after excluding trace counters and consistently mapping random UUID v4 event identifiers by first appearance. Action values, repeated identity relationships, summaries and winners were preserved. Samples do not certify all optional modules, every AI candidate or difficulty ordering.

After the reported power failure, the development server remained healthy and the existing private QA seat restored after browser refresh. All 1038 opening saved games and original seat, recovery, room-entry and handover records are unchanged. SQLite integrity and foreign keys pass. No server restart, database reset or recurring automation was needed. Full rules and publication completion gates remain closed.

Verified source tree: 75ab4624dbeff634d3bd4606b0deb4b701efca82d55c189896205cc9a576217d
This commit is contained in:
Quintine committed 2026-09-14 06:16:43 +10:00
1 parent 368a7a5144
commit 640342527a
7 files changed
+450 -16

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
# AI arrival checks
14 September 2026. This removes repeated AI candidates rejected by the existing
arrival guard. It does not choose an order for simultaneous reactions or lift
an unfinished expansion gate.
## Reproduction
The genuine combined-expansion samples with base seed `20260914` completed on
`368a7a5`, but Basic proposed forty and Advanced four unsupported Ambassador
arrivals before choosing legal alternatives. The default six expansion samples
also retained eighty-two rejected Ambassador/Terror arrival candidates. The
[deferred movement](DEFERRED_MOVEMENT_ARRIVAL.md) and
[shipment](DEFERRED_SHIPMENT_ARRIVAL.md) contracts explain the existing guard.
## Connected behavior
After constructing its candidate actions and native source/payment choices,
each AI profile uses `botArrivalBlock` to exclude known ordinary ship/move
arrival conflicts. The adapter calls the same `quoteCompletedMovementArrival`
used by authoritative preflight. It does not copy the Ambassador/Terror branches
or infer a new rule from token co-location.
The quote preserves same-territory movement, single reactions, own-faction and
allied exemptions, matching Ambassador immunity and Bene Gesserit stance.
Shipment additionally includes the prospective Guild income and Spiritual
Advisor opportunities. It uses the actual ordinary tariff and selected or
minimum necessary allied contribution. A Guild-funded payment that creates no
Guild income is kept. An owned No-Field is priced as one component without
reading its concealed value. Advanced Fremen storm arrival keeps its existing
response condition.
Only public board/faction/stance/reserve information and the acting seat's own
funds enter the adapter. Concealed Terror faces and rival hands, spice, Traitors
and Face Dancers are unnecessary. It does not mutate the view or sample
randomness. Only the known typed arrival error excludes a candidate; unexpected
faults propagate. The engine remains authoritative for every actual action.
Homeworlds, explicit Nexus shipments, special movement and unrecognized action
shapes retain their existing adapters and legal fallback search. They are not
classified as illegal by this narrower preview. This is not a complete action
validator or certification that every AI candidate in every module is legal.
## Evidence
Focused real-engine cases exercise the original rejected entries and executable
alternatives across all four profiles, supported single reactions and matching
faction immunity, Guild-funded shipments, No-Fields, BG accompaniment versus
fighter intrusion, JSON continuation and private-field access traps. The BG
advisor fixture retains another faction in its territory so normal automatic
stance settlement does not turn a lone advisor group into fighters.
Final checks and same-seed integrated results are recorded with the source-bound
private checkpoint and Git message. Existing saved games remain protected; no
server restart or database change is required.
+13 -14
View File
@@ -6,14 +6,13 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
Latest verified checkpoint: `9f4de64`, [compound shipment promises](COMPOUND_SHIPMENT_PROMISES.md).
Types, lint, **4,815 offline tests**, build and **43 HTTP tests** passed.
Genuine Basic/Advanced samples finished in 1,534 accepted actions, with 41
periodic save/restore checks and no rejected candidates. Each used one actual
held Truthtrance at a legal opportunity for a scenario-directed compound question.
Browser second-OR-branch fulfillment and private refresh/replay passed.
All 1,004 opening games and original seat/recovery records were preserved,
with 1,038 rooms after checks.
Latest verified checkpoint: `368a7a5`, [reusable game samples](VERIFICATION_WORKFLOW.md#reusable-base-and-faction-sample-games).
Types, lint and **4,823 offline tests** passed. Ten genuine base samples and
six expansion samples completed in 6,746 accepted actions with 175 periodic
save/restore checks. Base samples had no rejected candidates; expansion samples
retained 82 guarded arrival candidates before legal alternatives. The tooling
checkpoint changed no application, dependency, HTTP or database code.
All 1,038 opening games and original seat/recovery records were unchanged.
Earlier evidence remains in the [implementation history](IMPLEMENTATION_STATUS.md).
The connected [Harass & Withdraw prototype](HARASS_WITHDRAW_RUNTIME.md) supports either
@@ -31,12 +30,12 @@ and foreign-key checks passed, and refresh restored the test seat. No restart,
database reset or recurring automation was needed. Exact verification, source
fingerprints and delivery are recorded in the private report and Git message.
Current work: [reusable base samples](VERIFICATION_WORKFLOW.md#reusable-base-and-faction-sample-games)
connect genuine two-through-six-player Basic/Advanced verification to the existing
source-bound runner. Fixed seeds, exact card/force/elite custody, private JSON
restoration and retained failure snapshots replace repeated private harness copies.
The Moritani Secret Ally audit identified the same pending hidden-reaction UX
boundary as CHOAM inspection; the physical retention helper does not resolve it.
Current work: [AI arrival preflight](BOT_ARRIVAL_PREFLIGHT.md) reuses the engine's
existing reaction quote to avoid repeatedly proposing guarded ordinary entries.
The adapter preserves legal single reactions, faction exemptions, contribution
routing and private information; specialized routes retain their own adapters.
The Moritani Secret Ally audit retains the shared pending hidden-reaction UX
question. No arrival-order ruling or module/publication gate is changed.
## Authoritative navigation
+12
View File
@@ -1,6 +1,18 @@
# Dune implementation status
## 14 September — AI ordinary arrival preflight
[AI arrival checks](BOT_ARRIVAL_PREFLIGHT.md) filter known unsupported ordinary
shipment/movement candidates with the existing authoritative reaction quote.
All four profiles retain executable alternatives, single reactions, faction
exemptions and correctly routed Guild contributions. The public-input adapter
never reads concealed Terror faces or rival private resources. Special routes
remain on their existing adapters; no simultaneous reaction rule is invented.
Source-bound same-seed samples, focused regressions, required checks, saved-game
preservation and delivery are recorded in the private report and Git message.
## 14 September — reusable base player-count samples
The faction sample runner now also selects genuine base Basic/Advanced games
+3 -1
View File
@@ -60,7 +60,9 @@ has finished; expansion Traitor/Face Dancer zones are not certified by this
check. Every rejected candidate must leave its input unchanged. Every 37 actions
checks all private views after JSON restoration and absence of rival hand,
spice, Traitor and Face Dancer fields. Rejected candidates remain visible in private traces even when
a later candidate succeeds. Samples are development evidence, not calibration or
a later candidate succeeds. The [ordinary AI arrival adapter](BOT_ARRIVAL_PREFLIGHT.md)
filters known supported-scope reaction conflicts before attempting an action;
remaining rejections are still reported, never hidden by the runner. Samples are development evidence, not calibration or
complete rules certification.
The output is a new private directory outside the checkout. It contains private
+182
View File
@@ -0,0 +1,182 @@
import type { Action, GameView } from './engine';
import { arrivalAsAdvisor } from './advisors';
import { splitLocation, territory, validLocation } from './board';
import {
MovementArrivalError,
quoteCompletedMovementArrival,
type CompletedMovementArrivalInput,
} from './karama-movement-preflight';
import {
guildShipmentIncome,
reserveShipmentCost,
shipmentPaymentBounds,
} from './shipment-price';
import { quoteSmugglerShipment } from './smuggler-shipment';
/**
* Known arrival support for ordinary owner-view candidates, not a complete
* action validator. Homeworld, explicit Nexus and special movement routes keep
* their own adapters and fallbacks. No hidden Terror face or rival hand enters
* this quote, and unexpected failures are never treated as rejected candidates.
*/
export function botArrivalBlock(g: GameView, action: Action): string | null {
if (
g.status !== 'playing' ||
g.phase !== 5 ||
g.active !== g.me ||
!['ship', 'move'].includes(action.type) ||
g.homeworlds ||
action.nexus !== undefined ||
action.source !== undefined ||
action.homeworldSources !== undefined ||
typeof action.territory !== 'string' ||
typeof action.sector !== 'number' ||
!validLocation(action.territory, action.sector)
)
return null;
const me = g.players.find((p) => p.id === g.me);
if (!me) return null;
const to = action.territory;
const controls = {
response: !!g.response,
decision: !!g.decision,
pendingTerror: !!g.terrorEntry,
pendingAmbassador: !!g.ambassadorEntry,
paidBox: g.decision?.kind === 'nullentropy',
};
let order: CompletedMovementArrivalInput['order'];
if (action.type === 'ship') {
const amount = action.noField === undefined ? action.amount : 1;
if (
typeof amount !== 'number' ||
!Number.isSafeInteger(amount) ||
amount < 1 ||
(action.noField !== undefined && me.faction !== 'richese')
)
return null;
const guild = g.players.find((p) => p.faction === 'guild');
const halfRate =
me.faction === 'guild' ||
guild?.id === me.ally ||
g.karamaShipping?.player === me.id;
// A No-Field's concealed value and optional companion never change its price.
const smuggler =
action.noField === undefined && action.smuggler === true
? quoteSmugglerShipment(g, me.id, to, amount)
: null;
const cost = reserveShipmentCost(
{ faction: me.faction, halfRate },
territory(to).type,
amount - (smuggler ? 1 : 0),
);
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;
const income = guildShipmentIncome({
guild: guild?.id,
shipper: me.id,
ally: me.ally,
cost,
allyPayment,
bankOnly: g.karamaShipping?.player === me.id,
});
const bg = g.players.find((p) => p.faction === 'beneGesserit');
// Without Homeworlds, a positive public reserve pool grants accompaniment.
const accompaniment =
me.faction !== 'fremen' && !!bg && bg.id !== me.id && bg.reserves > 0;
controls.response ||=
income > 0 ||
(g.advanced && me.faction === 'fremen' && action.sector === g.storm);
controls.decision ||= accompaniment;
order = {
player: me.id,
origin: 'reserves',
to,
advisors: arrivalAsAdvisor(g, me, to),
wantsFighters: false,
};
} else {
if (
g.ornithopter?.active ||
[
'movementCard',
'ornithopter',
'ornithopterEvent',
'planetologist',
'sandmaster',
'discoveryOrnithopter',
'discoveryFlight',
'origins',
].some((key) => action[key] !== undefined)
)
return null;
let entries: [string, unknown][];
if (action.forces !== undefined) {
if (
!action.forces ||
typeof action.forces !== 'object' ||
Array.isArray(action.forces)
)
return null;
entries = Object.entries(action.forces);
} else {
if (typeof action.from !== 'string') return null;
entries = [[action.from, action.amount]];
}
const origins = new Set<string>();
if (action.noField !== undefined) {
const marker = me.noField?.deployed;
if (me.faction !== 'richese' || !marker) return null;
origins.add(marker.location.territory);
}
for (const [key, count] of entries) {
if (
typeof count !== 'number' ||
!Number.isSafeInteger(count) ||
count < 0
)
return null;
if (!count) continue;
const source = splitLocation(key);
if (!validLocation(source.territory, source.sector)) return null;
origins.add(source.territory);
}
if (origins.size !== 1) return null;
const origin = [...origins][0];
const advisors = arrivalAsAdvisor(g, me, to, origin);
order = {
player: me.id,
origin,
origins: [...origins],
to,
advisors,
wantsFighters: advisors && action.fighters === true,
};
}
try {
quoteCompletedMovementArrival({
advanced: g.advanced,
players: g.players,
order,
ambassadors: (g.ambassadors?.tokens ?? []).map(
({ zone, location, effect }) => ({ zone, location, effect }),
),
terror: (g.moritaniTerror?.tokens ?? []).map(({ status, location }) => ({
status,
location,
})),
controls,
flight: null,
});
return null;
} catch (error) {
if (error instanceof MovementArrivalError) return error.message;
throw error;
}
}
+2 -1
View File
@@ -87,6 +87,7 @@ import { sandmasterDefaultChoice } from './sandmaster-movement';
import { faction } from './catalog';
import { richeseCardDefinition } from './richese-cards';
import { presenceAt } from './force-presence';
import { botArrivalBlock } from './bot-arrival';
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
import { fighterCount, isAdvisor } from './advisors';
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
@@ -3803,7 +3804,7 @@ export function botActions(g: GameView): Action[] {
const actions = [...junctionTransportActions(g, rank(g)), ...policyActions(g)].flatMap((action) => {
if (action.type === 'move' && nexusGuildCunningActive(g) && !nexusGuildMovementAvailable(g)) return [];
const sourced = withNativeShipmentSources(g, action);
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) ? [sourced] : [];
return sourced && botHomeworldShipmentPaymentAllowed(g, sourced) && !homeworldRevivalActionBlock(g, sourced) && !botArrivalBlock(g, sourced) ? [sourced] : [];
});
const me = g.players.find((p) => p.id === g.me)!;
const ghola = standaloneGholaAction(g, actions);
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botArrivalBlock } from '../game/bot-arrival';
import { botActions } from '../game/bots';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import {
arrivalMove,
arrivalPlayer,
movementArrivalGame,
} from './deferred-movement-arrival-fixture';
import {
addShipmentArrivalAmbassador,
shipmentArrivalAction,
shipmentArrivalGame,
richeseNoFieldArrivalAction,
} from './fixture-deferred-shipment-arrival';
const overlap = /combined with another arrival reaction/;
const profiles = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
function ownView(game: Game, profile: (typeof profiles)[number] = 'Medium') {
const view = viewGame(game, game.active!);
view.players.find((player) => player.id === view.me)!.bot = profile;
return view;
}
void test('all four profiles avoid the reproduced Ambassador/Terror entries and keep an executable alternative', () => {
for (const shipment of [false, true]) {
const game = movementArrivalGame();
arrivalPlayer(game, game.active!).shipped = !shipment;
const action: Action = shipment ? shipmentArrivalAction : arrivalMove(game);
const before = JSON.stringify(game);
assert.throws(() => applyAction(game, game.active!, action), overlap);
for (const profile of profiles) {
const view = ownView(game, profile);
assert.match(botArrivalBlock(view, action) ?? '', overlap);
const actions = botActions(view);
assert.ok(actions.length, profile);
assert.equal(
actions.some(
(candidate) =>
['ship', 'move'].includes(candidate.type) &&
candidate.territory === 'carthag',
),
false,
);
const next = applyAction(game, view.me, actions[0]);
assert.ok(next);
assert.deepEqual(botActions(JSON.parse(JSON.stringify(view))), actions);
}
assert.equal(JSON.stringify(game), before);
}
});
void test('single reactions and matching-faction Ambassador immunity remain playable', () => {
for (const tokens of ['ambassador', 'terror'] as const) {
const game = movementArrivalGame('tleilaxu', tokens);
assert.equal(botArrivalBlock(ownView(game), arrivalMove(game)), null);
assert.equal(
applyAction(game, game.active!, arrivalMove(game)).decision?.kind,
'choamMovement',
);
for (const profile of profiles)
assert.ok(
botActions(ownView(game, profile)).some(
(action) => action.type === 'move' && action.territory === 'carthag',
),
);
}
const fremen = movementArrivalGame('fremen');
assert.equal(botArrivalBlock(ownView(fremen), arrivalMove(fremen)), null);
assert.ok(applyAction(fremen, fremen.active!, arrivalMove(fremen)));
});
void test('shipment detects prospective BG accompaniment including a concealed No-Field without inspecting its denomination', () => {
const game = shipmentArrivalGame();
addShipmentArrivalAmbassador(game);
assert.match(
botArrivalBlock(ownView(game), shipmentArrivalAction) ?? '',
overlap,
);
assert.throws(
() => applyAction(game, game.active!, shipmentArrivalAction),
overlap,
);
game.active = 'richese';
arrivalPlayer(game, 'richese').shipped = false;
const marker = richeseNoFieldArrivalAction(game);
assert.match(botArrivalBlock(ownView(game), marker) ?? '', overlap);
assert.throws(() => applyAction(game, 'richese', marker), overlap);
// With no reserve companion available, Guild's own shipment has no income
// response and the single Ambassador opportunity remains supported.
const bg = arrivalPlayer(game, 'bg');
bg.forces = { 'polar_sink:0': 20 };
bg.reserves = 0;
game.active = 'guild';
assert.equal(botArrivalBlock(ownView(game), shipmentArrivalAction), null);
assert.ok(applyAction(game, 'guild', shipmentArrivalAction));
});
void test('Guild income preview preserves a shipment funded entirely by the Guild ally', () => {
const game = shipmentArrivalGame();
addShipmentArrivalAmbassador(game);
const bg = arrivalPlayer(game, 'bg');
bg.reserves = 0;
bg.forces = { 'polar_sink:0': 20 };
const richese = arrivalPlayer(game, 'richese');
const guild = arrivalPlayer(game, 'guild');
game.active = richese.id;
richese.shipped = false;
richese.ally = guild.id;
guild.ally = richese.id;
game.aid[guild.id] = { recipient: richese.id, amount: 2 };
const ownPaid = { ...shipmentArrivalAction, amount: 4, allyPayment: 0 };
assert.match(botArrivalBlock(ownView(game), ownPaid) ?? '', overlap);
assert.throws(() => applyAction(game, richese.id, ownPaid), overlap);
const guildPaid = { ...ownPaid, allyPayment: 2 };
assert.equal(botArrivalBlock(ownView(game), guildPaid), null);
assert.ok(applyAction(game, richese.id, guildPaid));
richese.spice = 0;
const automaticShare: Action = { ...guildPaid };
delete automaticShare.allyPayment;
assert.equal(botArrivalBlock(ownView(game), automaticShare), null);
assert.ok(applyAction(game, richese.id, automaticShare));
});
void test('Advanced BG fighter intrusion is distinguished from advisor presence', () => {
const game = shipmentArrivalGame();
addShipmentArrivalAmbassador(game);
const guild = arrivalPlayer(game, 'guild');
guild.forces = { 'arrakeen:10': 3 };
guild.reserves = 17;
guild.shipped = true;
const bg = arrivalPlayer(game, 'bg');
bg.forces = { 'carthag:11': 1 };
bg.reserves = 19;
const move = {
type: 'move',
from: 'arrakeen:10',
territory: 'carthag',
sector: 11,
amount: 3,
};
assert.match(botArrivalBlock(ownView(game), move) ?? '', overlap);
assert.throws(() => applyAction(game, guild.id, move), overlap);
bg.advisors = { carthag: {} };
// Advisors need another faction present or automatic stance settlement
// correctly makes this lone group fighters before either quote.
const moritani = arrivalPlayer(game, 'moritani');
moritani.forces = { 'carthag:11': 1 };
moritani.reserves = 19;
assert.equal(botArrivalBlock(ownView(game), move), null);
assert.ok(applyAction(game, guild.id, move));
});
void test('arrival filtering never reads hidden rival resources or Terror faces and does not mutate the view', () => {
const game = movementArrivalGame();
const view = ownView(game);
const original = JSON.stringify(view);
for (const player of view.players.filter((player) => player.id !== view.me))
for (const field of ['hand', 'spice', 'traitors', 'faceDancers'])
Object.defineProperty(player, field, {
configurable: true,
get: () => {
throw Error(`Private ${field} read`);
},
});
for (const token of view.moritaniTerror!.tokens)
Object.defineProperty(token, 'kind', {
configurable: true,
get: () => {
throw Error('Hidden Terror face read');
},
});
assert.match(botArrivalBlock(view, arrivalMove(game)) ?? '', overlap);
assert.match(botArrivalBlock(view, shipmentArrivalAction) ?? '', overlap);
for (const player of view.players.filter((player) => player.id !== view.me))
for (const field of ['hand', 'spice', 'traitors', 'faceDancers'])
Reflect.deleteProperty(player, field);
// Restore public removed-token faces from the original before comparing.
view.moritaniTerror = JSON.parse(original).moritaniTerror;
assert.equal(JSON.stringify(view), original);
});