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:
1 parent
368a7a5144
commit
640342527a
7 files changed
+450
-16
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user