Keep Atreides special inspection available until plan reveal

Allow native Advanced use after declining the first offer, including a sealed
target. Reuse projected card/target permission in persistent controls and
minimal bots; retain sealed plans and finish target-last public reveal once.

Source: authorized supplied physical27 F. Ordinary preparation priority and
target-first ordering remain provisional; no new reading acknowledgement.

Verified: 11 affected offline files126/126, types/lint, production build, five
actual control/gameplay/GameTable SSR journeys and eight legal profile paths.
No saved-room, D1, reset, restart or deployment operation.
This commit is contained in:
Quintine committed 2026-10-24 18:49:22 +11:00
1 parent 660b0a0c25
commit 61828150b9
15 files changed
+917 -70

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Children, createElement, isValidElement, type ReactNode } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { AtreidesFullPlan } from '../components/atreides-full-plan';
import { baseDeck } from '../game/cards';
import {
createGame,
newPlayer,
viewGame,
type Action,
type GameView,
} from '../game/engine';
// These client projections contain public battle metadata and actual card IDs.
// The runtime suite owns preparation, physical spending and private inspection.
function projected(): GameView {
const game = createGame('ATREIDESFULLPLANUI', newPlayer('a', 'Atreides', 'atreides'), true);
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('g', 'Guild', 'guild'));
Object.assign(game, {
status: 'playing', phase: 6, turn: 3, order: ['a', 'e', 'g'],
});
const cards = baseDeck().filter((card) => card.effect === 'karama').slice(0, 2);
game.players[0].hand = cards;
game.deck = baseDeck().filter((card) => !cards.some((held) => held.id === card.id));
const view = viewGame(game, 'a');
view.battle = {
attacker: 'a', defender: 'e', submitted: [], revealed: false,
noFieldPlayers: [], fullPlan: null,
} as unknown as NonNullable<GameView['battle']>;
view.atreidesFullPlan = { blocked: null, targets: ['a', 'e'], cards: cards.map((card) => card.id) };
return view;
}
function markup(game: GameView, busy = false) {
return renderToStaticMarkup(createElement(AtreidesFullPlan, { game, busy, act() {} }));
}
function click(node: ReactNode, selector: string): boolean {
for (const child of Children.toArray(node)) {
if (!isValidElement<{
children?: ReactNode;
onClick?: () => void;
'data-testid'?: string;
}>(child)) continue;
if (child.props['data-testid'] === selector && child.props.onClick) {
child.props.onClick();
return true;
}
if (click(child.props.children, selector)) return true;
}
return false;
}
function inspect(game: GameView, card: string, target = 'e', busy = false): Action[] {
const actions: Action[] = [];
click(AtreidesFullPlan({ game, busy, act(action) { actions.push(action); } }),
`atreides-full-plan-${card}-${target}`);
return actions;
}
void test('the actual full-plan buttons use every projected physical card and target with the original special action', () => {
const game = projected();
assert.equal(game.atreidesFullPlan!.cards.length, 2);
const html = markup(game);
assert.match(html, /aria-label="Atreides full Battle Plan inspection"/);
assert.match(html, /Spend Karama · inspect Atreides/);
assert.match(html, /Spend Karama · inspect Emperor/);
assert.equal((html.match(/data-testid="atreides-full-plan-/g) ?? []).length, 4);
for (const card of game.atreidesFullPlan!.cards) {
for (const target of game.atreidesFullPlan!.targets) {
assert.deepEqual(inspect(game, card, target), [{ type: 'card', mode: 'special', card, target }]);
}
}
assert.deepEqual(inspect(game, 'stale-card'), []);
assert.deepEqual(inspect(game, game.atreidesFullPlan!.cards[0], 'g'), []);
});
void test('initial-offer and later quiet controls both allow the same card action without owning a decline or pause', () => {
const game = projected();
const card = game.atreidesFullPlan!.cards[0];
game.decision = { kind: 'fullPlanOffer', player: 'a' };
assert.deepEqual(inspect(game, card), [{ type: 'card', mode: 'special', card, target: 'e' }]);
game.decision = null;
const html = markup(game);
assert.deepEqual(inspect(game, card), [{ type: 'card', mode: 'special', card, target: 'e' }]);
assert.match(html, /after battle preparation and before public plan revelation/);
assert.match(html, /including when zero or one plan is sealed/);
assert.match(html, /Leaving this power unused now keeps the opportunity open until public revelation/);
assert.match(html, /Battle plan submission continues normally/);
assert.doesNotMatch(html, /Keep special power for later|Confirm inspection|Acknowledge inspection/);
});
void test('public submitted status keeps both targets available and explains the original own sealed plan', () => {
const game = projected();
const card = game.atreidesFullPlan!.cards[0];
assert.match(markup(game), /0 of 2 plans sealed/);
game.battle!.submitted = ['e'];
assert.match(markup(game), /1 of 2 plans sealed/);
assert.match(markup(game), /inspect Emperor · sealed plan/);
assert.doesNotMatch(markup(game), /Your original sealed plan stays fixed/);
assert.deepEqual(inspect(game, card, 'e'), [{ type: 'card', mode: 'special', card, target: 'e' }]);
game.battle!.submitted = ['a'];
const html = markup(game);
assert.match(html, /Your original sealed plan stays fixed/);
assert.match(html, /Plans reveal as soon as the other combatant seals/);
assert.match(html, /this power does not unlock either plan/);
assert.deepEqual(inspect(game, card, 'a'), [{ type: 'card', mode: 'special', card, target: 'a' }]);
assert.deepEqual(inspect(game, card, 'e'), [{ type: 'card', mode: 'special', card, target: 'e' }]);
});
void test('blocked reasons and cardless guidance remain readable without generating replacement actions', () => {
const game = projected();
const card = game.atreidesFullPlan!.cards[0];
game.atreidesFullPlan!.blocked = 'Finish the current battle preparation before inspecting a full plan.';
for (let render = 0; render < 3; render++) {
const html = markup(game);
assert.match(html, /Finish the current battle preparation before inspecting a full plan/);
assert.match(html, /aria-describedby="atreides-full-plan-unavailable"/);
assert.deepEqual(inspect(game, card), []);
}
game.atreidesFullPlan!.blocked = null;
game.atreidesFullPlan!.cards = [];
const cardless = markup(game);
assert.match(cardless, /No spendable Karama is currently available for this special power/);
assert.doesNotMatch(cardless, /data-testid="atreides-full-plan-/);
assert.deepEqual(inspect(game, card), []);
game.atreidesFullPlan!.targets = [];
assert.match(markup(game), /No combatant is currently available for full-plan inspection/);
});
void test('busy, other priorities and public revelation prevent the actual special-card callbacks', () => {
const game = projected();
const card = game.atreidesFullPlan!.cards[0];
assert.deepEqual(inspect(game, card, 'e', true), []);
assert.equal((markup(game, true).match(/<button(?=[^>]*data-testid="atreides-full-plan-)(?=[^>]*disabled="")[^>]*>/g) ?? []).length, 4);
for (const field of ['response', 'decision', 'phaseOpening', 'truthtrance', 'nexus', 'revealed'] as const) {
const changed = structuredClone(game);
if (field === 'response') changed.response = {} as NonNullable<GameView['response']>;
if (field === 'decision') changed.decision = { kind: 'fullPlanOffer', player: 'e' };
if (field === 'phaseOpening') changed.phaseOpening = {} as NonNullable<GameView['phaseOpening']>;
if (field === 'truthtrance') changed.truthtrance = {} as NonNullable<GameView['truthtrance']>;
if (field === 'nexus') changed.nexusTraitors = {
offer: null, pending: {} as NonNullable<NonNullable<GameView['nexusTraitors']>['pending']>,
};
if (field === 'revealed') changed.battle!.revealed = true;
assert.deepEqual(inspect(changed, card), [], field);
}
});
void test('the component honors the owner-only native Advanced battle projection', () => {
for (const field of ['projection', 'basic', 'viewer', 'faction', 'phase', 'battle', 'status'] as const) {
const game = projected();
const card = game.atreidesFullPlan!.cards[0];
if (field === 'projection') game.atreidesFullPlan = null;
if (field === 'basic') game.advanced = false;
if (field === 'viewer') game.me = 'e';
if (field === 'faction') game.players[0].faction = 'emperor';
if (field === 'phase') game.phase = 5;
if (field === 'battle') game.battle = null;
if (field === 'status') game.status = 'lobby';
assert.equal(markup(game), '', field);
assert.deepEqual(inspect(game, card), [], field);
}
});
void test('No-Field special-power guidance remains inline and never reads private plans, hands or wallets', () => {
const game = projected();
const card = game.atreidesFullPlan!.cards[0];
game.battle!.noFieldPlayers = ['e'];
Object.defineProperty(game.battle, 'plans', {
get() { throw new Error('Full-plan controls read private plans'); },
});
for (const player of game.players) {
for (const field of ['hand', 'spice', 'forces', 'noField']) {
Object.defineProperty(player, field, {
get() { throw new Error(`Full-plan controls read ${player.id} private ${field}`); },
});
}
}
const html = markup(game);
assert.match(html, /Provisional power distinction: special Karama reads the complete committed No-Field plan, including dial and support/);
assert.match(html, /Ordinary Prescience still cannot request its dial/);
assert.match(html, /private read does not reveal or materialize the marker before both plans seal/);
assert.match(html, /Existing sealed plans cannot change/);
assert.deepEqual(inspect(game, card), [{ type: 'card', mode: 'special', card, target: 'e' }]);
});
+127
View File
@@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createAtreidesFullPlanWindowFixture as fixture,
actAtreidesFullPlanWindow as act,
atreidesFullPlanWindowPlayer as player,
atreidesFullPlanWindowInventory as inventory,
finishAtreidesFullPlanWindow as finish,
} from './fixture-atreides-full-plan-window';
function rejected(game: Game, actor: string, action: Action, reason?: RegExp) {
const before = structuredClone(game);
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
else assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('actual paid setup and battle retain the special window after declining its first offer', () => {
const f = fixture();
assert.equal(f.offer.decision?.kind, 'fullPlanOffer');
assert.equal(f.game.decision, null);
assert.equal(f.game.battle?.revealed, false);
assert.deepEqual(f.game.battle?.plans, {});
assert.equal(viewGame(f.game, f.owner).atreidesFullPlan?.blocked, null);
assert.deepEqual(viewGame(f.game, f.owner).atreidesFullPlan?.cards, [f.karama]);
assert.equal(viewGame(f.game, f.guild).atreidesFullPlan, null);
assert.deepEqual(inventory(f.game), f.originalInventory);
const inspecting = act(f.game, f.owner, f.inspect);
assert.equal(inspecting.decision, null);
rejected(inspecting, f.owner, f.ownPlan, /requested by special prescience/);
const read = act(inspecting, f.opponent, f.opponentPlan);
assert.equal(read.decision, null);
assert.equal(read.battle?.revealed, false);
assert.ok(viewGame(read, f.owner).battle?.fullPlanInsight);
assert.equal(finish(read, f).phase, 7);
});
void test('a target sealed before activation is immediately readable without changing that plan or spending support early', () => {
const f = fixture();
const sealed = act(f.game, f.opponent, f.opponentPlan);
const before = structuredClone(player(sealed, f.opponent));
assert.equal(viewGame(sealed, f.owner).battle?.plans[f.opponent], undefined);
const read = act(sealed, f.owner, f.inspect);
assert.deepEqual(read.battle?.plans, sealed.battle?.plans);
assert.deepEqual(player(read, f.opponent), before);
assert.equal(read.response, null);
assert.equal(read.decision, null);
const insight = viewGame(read, f.owner).battle?.fullPlanInsight;
assert.deepEqual(insight?.plan, sealed.battle!.plans[f.opponent]);
assert.deepEqual(insight?.cards.map(card => card.id), [f.weapon]);
assert.equal(player(read, f.owner).specialKaramaUsed, true);
assert.equal(read.discard.filter(card => card.id === f.karama).length, 1);
assert.deepEqual(inventory(read), f.originalInventory);
for (const actor of [f.opponent, f.guild]) {
assert.equal(viewGame(read, actor).battle?.fullPlanInsight, null);
assert.equal(viewGame(read, actor).players.find(p => p.id === f.owner)?.hand, undefined);
}
rejected(read, f.opponent, { ...f.opponentPlan, dial: 0 }, /not available/);
rejected(read, f.owner, f.inspect, /already been used/);
const revealed = act(read, f.owner, f.ownPlan);
assert.equal(revealed.battle?.revealed, true);
assert.equal(viewGame(revealed, f.owner).battle?.fullPlanInsight, null);
assert.equal(finish(revealed, f).phase, 7);
});
void test('own seal then special activation and target-last commitment reveals both once without a read deadlock', () => {
const f = fixture();
const sealed = act(f.game, f.owner, f.ownPlan);
const inspecting = act(sealed, f.owner, f.inspect);
assert.deepEqual(inspecting.battle?.plans, sealed.battle?.plans);
assert.equal(inspecting.battle?.revealed, false);
assert.equal(inspecting.decision, null);
const revealed = act(inspecting, f.opponent, f.opponentPlan);
assert.equal(revealed.battle?.revealed, true);
assert.deepEqual(revealed.battle?.plans[f.owner], sealed.battle?.plans[f.owner]);
assert.equal(revealed.log.filter(row => row.text === 'Both battle plans are revealed.').length, 1);
assert.ok(revealed.log.some(row => row.text.includes('Both plans reveal immediately')));
assert.notEqual(revealed.decision?.kind, 'fullPlanRead');
rejected(revealed, f.owner, { ...f.ownPlan, dial: 0 }, /not available/);
const done = finish(revealed, f);
assert.equal(done.phase, 7);
assert.equal(done.battle, null);
assert.deepEqual(inventory(done), f.originalInventory);
});
void test('any one player includes the already sealed own plan without reopening it', () => {
const f = fixture();
const sealed = act(f.game, f.owner, f.ownPlan);
const read = act(sealed, f.owner, { ...f.inspect, target: f.owner });
assert.deepEqual(viewGame(read, f.owner).battle?.fullPlanInsight?.plan, sealed.battle?.plans[f.owner]);
assert.deepEqual(read.battle?.plans, sealed.battle?.plans);
assert.equal(viewGame(read, f.guild).battle?.fullPlanInsight, null);
assert.equal(finish(read, f).phase, 7);
});
void test('actual preparation, foreign target and public reveal retain their timing and card boundaries', () => {
const f = fixture();
const before = viewGame(f.beforePreparation, f.owner).atreidesFullPlan;
assert.ok(before?.blocked);
rejected(f.beforePreparation, f.owner, f.inspect);
rejected(f.game, f.owner, { ...f.inspect, target: f.guild }, /in this battle/);
rejected(f.game, f.owner, { ...f.inspect, card: f.defense }, /Karama card/);
let revealed = act(f.game, f.opponent, f.opponentPlan);
revealed = act(revealed, f.owner, f.ownPlan);
assert.equal(revealed.battle?.revealed, true);
assert.match(viewGame(revealed, f.owner).atreidesFullPlan!.blocked!, /already been revealed/);
rejected(revealed, f.owner, f.inspect, /already been revealed/);
assert.equal(player(revealed, f.owner).specialKaramaUsed, undefined);
assert.ok(player(revealed, f.owner).hand.some(card => card.id === f.karama));
});
for (const profile of DIFFICULTIES) {
void test(`${profile} uses the same legal private late-inspection offer and continues to Collection`, () => {
const f = fixture();
const sealed = act(f.game, f.opponent, f.opponentPlan);
const view = viewGame(sealed, f.owner);
view.players.find(p => p.id === f.owner)!.bot = profile;
const action = botActions(view)[0];
assert.deepEqual(action, f.inspect);
const read = act(sealed, f.owner, action);
assert.ok(viewGame(read, f.owner).battle?.fullPlanInsight);
assert.equal(finish(read, f).phase, 7);
});
}
+227
View File
@@ -0,0 +1,227 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type AtreidesFullPlanWindowStep = { actor: string; action: Action };
export type AtreidesFullPlanWindowTransition = {
before: Game; steps: AtreidesFullPlanWindowStep[]; after: Game;
};
export const atreidesFullPlanWindowPlayer = (game: Game, id: string) => {
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
};
export const atreidesFullPlanWindowClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
export const atreidesFullPlanWindowInventory = (game: Game) => {
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
const ids = cards.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Original physical Treachery Cards retain distinct current slots');
return ids;
};
/** Choose original scalar shuffle order only. UUIDs and every other native byte
* source remain untouched; no played cards, forces or sealed plans are replaced. */
function originalScalarOrder<T>(run: () => T): T {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
else Reflect.apply(original, crypto, [array]);
return array;
};
try { return run(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
export function actAtreidesFullPlanWindow(game: Game, actor: string, action: Action,
actions: AtreidesFullPlanWindowStep[] = []): Game {
const next = originalScalarOrder(() => applyAction(game, actor, action));
actions.push({ actor, action: structuredClone(action) }); return next;
}
/** Native continuation declines optional powers, including the special offer.
* It never spends Karama or chooses a combatant's private Battle Plan. */
export function nextAtreidesFullPlanWindowStep(game: Game): AtreidesFullPlanWindowStep | null {
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
if (game.decision?.kind === 'fullPlanOffer')
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
if (game.decision?.kind === 'fullPlanRead')
return { actor: game.decision.player, action: { type: 'decision', continue: true } };
if (game.decision?.kind === 'guildTiming')
return { actor: game.decision.player, action: { type: 'decision', take: false } };
}
return nextRicheseStrongholdsNativeStep(game);
}
export function advanceAtreidesFullPlanWindow(game: Game, until: (state: Game) => boolean,
actions: AtreidesFullPlanWindowStep[] = []): Game {
for (let count = 0; count < 1600; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine full-plan chronology ended before its requested boundary');
const next = nextAtreidesFullPlanWindowStep(game);
assert.ok(next, `Supply the original private plan at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actAtreidesFullPlanWindow(game, next.actor, next.action, actions);
}
throw Error('The native full-plan chronology did not reach its finite boundary');
}
export function passAtreidesFullPlanWindowResponses(game: Game,
actions: AtreidesFullPlanWindowStep[] = []): Game {
for (let count = 0; game.response && count < 60; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actAtreidesFullPlanWindow(game, actor.id, { type: 'passResponse' }, actions);
}
assert.ok(!game.response); return game;
}
export function cloneAtreidesFullPlanWindow(game: Game): Game {
assert.ok(!game.phaseOpening && !game.response && !game.truthtrance && !game.pendingTreacheryDiscard,
'Clone an actual available full-plan choice boundary');
return structuredClone(game);
}
/** Fresh public Advanced classic setup. Only original undealt Treachery/Spice
* positions and scalar shuffle choices are selected. An actual one-spice normal
* Shield purchase, paid shipments and a ground move create the native battle.
* The returned game has actually declined the original special offer, without
* committing either private plan or spending its real held Karama. */
export function createAtreidesFullPlanWindowFixture() {
const owner = 'atreides', opponent = 'emperor', guild = 'guild';
let game = createGame('ATREIDESFULLPLANWINDOW', newPlayer(owner, 'Atreides', 'atreides'), true);
joinGame(game, newPlayer(opponent, 'Emperor', 'emperor'));
joinGame(game, newPlayer(guild, 'Guild', 'guild'));
const initial = structuredClone(game), actions: AtreidesFullPlanWindowStep[] = [];
const act = (actor: string, action: Action) => { game = actAtreidesFullPlanWindow(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceAtreidesFullPlanWindow(game, until, actions); };
const transition = (run: () => void): AtreidesFullPlanWindowTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
assert.equal(game.expansions.length, 0);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalInventory = atreidesFullPlanWindowInventory(game);
const karama = game.deck.find(card => card.effect === 'karama');
const weapon = game.deck.find(card => card.kind === 'projectile');
const defense = game.deck.find(card => card.kind === 'shield');
const guildCard = game.deck.find(card => card.kind === 'snooper');
assert.ok(karama && weapon && defense && guildCard);
const selected = [karama, weapon, guildCard, defense];
const dealOrder = selected.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.slice(0, 3).map(card => card.id));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.phase === 3 && atreidesFullPlanWindowClean(state) &&
state.auction?.cards[state.auction.index]?.id === defense.id && state.auction.active === owner);
const acquisition = transition(() => {
act(owner, { type: 'bid', amount: 1, allyPayment: 0 });
reach(state => atreidesFullPlanWindowClean(state) && atreidesFullPlanWindowPlayer(state, owner).hand.some(card => card.id === defense.id));
});
assert.equal(atreidesFullPlanWindowPlayer(acquisition.before, owner).spice - atreidesFullPlanWindowPlayer(game, owner).spice, 1);
reach(state => state.phase === 5 && state.active === owner && atreidesFullPlanWindowClean(state));
const ownerShipment = transition(() => {
act(owner, { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 2, elite: 0, allyPayment: 0 });
reach(atreidesFullPlanWindowClean);
});
assert.equal(atreidesFullPlanWindowPlayer(ownerShipment.before, owner).spice - atreidesFullPlanWindowPlayer(game, owner).spice, 4);
const ownerMovement = transition(() => {
act(owner, { type: 'move', from: 'arrakeen:10', territory: 'imperial_basin', sector: 10, amount: 2 });
reach(atreidesFullPlanWindowClean);
});
assert.equal(atreidesFullPlanWindowPlayer(game, owner).forces['imperial_basin:10'], 4);
act(owner, { type: 'endMovement' });
reach(state => state.phase === 5 && state.active === opponent && atreidesFullPlanWindowClean(state));
const opponentShipment = transition(() => {
act(opponent, { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 2, elite: 0, allyPayment: 0 });
reach(atreidesFullPlanWindowClean);
});
assert.equal(atreidesFullPlanWindowPlayer(opponentShipment.before, opponent).spice - atreidesFullPlanWindowPlayer(game, opponent).spice, 4);
reach(state => state.phase === 6 && atreidesFullPlanWindowClean(state) && !!state.active);
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices
.find(choice => choice.territory === 'imperial_basin'); assert.ok(choice);
const battleStep: AtreidesFullPlanWindowStep = { actor: choice.chooser,
action: { type: 'chooseBattle', territory: 'imperial_basin',
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
act(battleStep.actor, battleStep.action);
const beforePreparation = structuredClone(game);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'fullPlanOffer');
const offer = structuredClone(game), offerActionCount = actions.length;
assert.equal(game.decision?.player, owner);
assert.ok(game.battle && !game.battle.revealed && Object.keys(game.battle.plans).length === 0);
const declineStep: AtreidesFullPlanWindowStep = { actor: owner, action: { type: 'decision', decline: true } };
act(declineStep.actor, declineStep.action);
const declined = structuredClone(game);
assert.ok(atreidesFullPlanWindowClean(game) && game.battle && !game.battle.revealed);
assert.equal(Object.keys(game.battle.plans).length, 0);
assert.ok(!atreidesFullPlanWindowPlayer(game, owner).specialKaramaUsed);
assert.ok(atreidesFullPlanWindowPlayer(game, owner).hand.some(card => card.id === karama.id));
assert.deepEqual(atreidesFullPlanWindowInventory(game), originalInventory);
const ownLeader = atreidesFullPlanWindowPlayer(game, owner).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
const opposingLeader = atreidesFullPlanWindowPlayer(game, opponent).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
assert.ok(ownLeader && opposingLeader);
const ownPlan: Action = { type: 'battlePlan', dial: 1, support: 1, leader: ownLeader.id,
weapon: null, defense: defense.id };
const opponentPlan: Action = { type: 'battlePlan', dial: 1, support: 1, leader: opposingLeader.id,
weapon: weapon.id, defense: null };
const inspect: Action = { type: 'card', mode: 'special', card: karama.id, target: opponent };
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, acquisition,
ownerShipment, ownerMovement, opponentShipment, beforeBattle, battleStep, beforePreparation,
offer, originalOffer: offer, declined, declineStep, owner, atreides: owner, opponent, emperor: opponent, guild,
territory: 'imperial_basin', sector: 10, location: 'imperial_basin:10',
karama: karama.id, weapon: weapon.id, defense: defense.id,
ownPlan, opponentPlan, targetPlan: opponentPlan, inspect,
ownPlanStep: { actor: owner, action: ownPlan } satisfies AtreidesFullPlanWindowStep,
opponentPlanStep: { actor: opponent, action: opponentPlan } satisfies AtreidesFullPlanWindowStep,
inspectStep: { actor: owner, action: inspect } satisfies AtreidesFullPlanWindowStep,
actions, offerActions: structuredClone(actions.slice(0, offerActionCount)), offerActionCount,
sourceActionCount: actions.length, originalInventory, originalSpice, dealOrder, spiceOrder,
staging: [
'Actual public native Advanced Atreides/Emperor/Guild ready/startPreview and private Traitor choices. Original scalar shuffle order keeps circles/deals reproducible; UUID and non-scalar entropy remain native.',
'Select only four original unopened Treachery positions: Atreides Karama, Emperor projectile weapon, Guild Snooper, then the first normal auction Shield. Select two original unopened ordinary Spice positions before the native dual Spice Blow.',
'Actually complete first Storm, dual Spice, Charity and native auction controls. Atreides wins the original Shield lot for one own spice and explicitly pays instead of using its held Karama.',
'Atreides actually pays four to ship two reserves to Imperial Basin10, then really moves two original Arrakeen counters into that group. Emperor actually pays four to ship two normal reserves there. Native Guild income, timing and movement endings retain their original controls.',
'Choose the real resulting battle and pass/decline native preparation. Record the original fullPlanOffer, then actually decline it. Stop with both plans unsealed and the original real Karama still held. Returned own/target plans may be legally sealed in either order by caller actions.',
'No played phase, wallet, army, alliance, hand acquisition, sealed plan, ownership or inspection receipt is assigned. No optional module, No-Field or exceptional custody is introduced.',
],
};
}
export type AtreidesFullPlanWindowFixture = ReturnType<typeof createAtreidesFullPlanWindowFixture>;
/** Finish this actual battle with its reusable owned plans. Missing target-first
* commitment is supplied first when an explicit inspection has already begun;
* native continuation never spends an otherwise unused special Karama. */
export function finishAtreidesFullPlanWindow(source: Game, fixture: AtreidesFullPlanWindowFixture,
actions: AtreidesFullPlanWindowStep[] = []): Game {
let game = source;
game = advanceAtreidesFullPlanWindow(game, state => !state.battle ||
(atreidesFullPlanWindowClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false), actions);
if (!game.battle) return game;
const target = game.battle.fullPlan?.target;
const actors = target && [fixture.owner, fixture.opponent].includes(target)
? [target, ...[fixture.owner, fixture.opponent].filter(actor => actor !== target)]
: [fixture.opponent, fixture.owner];
for (const actor of actors) {
if (game.battle && !game.battle.revealed && !game.battle.plans[actor])
game = actAtreidesFullPlanWindow(game, actor,
actor === fixture.owner ? fixture.ownPlan : fixture.opponentPlan, actions);
}
return advanceAtreidesFullPlanWindow(game, state => !state.battle && state.phase !== 6 &&
atreidesFullPlanWindowClean(state), actions);
}
+18 -2
View File
@@ -222,7 +222,22 @@ void test('Atreides may inspect an unrelated battle without giving its ally the
g = applyAction(g, 'e', targetPlan);
assert.equal(g.battle?.revealed, true);
});
void test('declining preserves the power and card, offers independently of hidden cards, and cannot be reopened mid-battle', () => {
void test('unrelated Atreides may decline then inspect an already sealed combatant without granting its ally access', () => {
let g = offer(fixture(true));
g.players[0].ally = 'b';
g.players[3].ally = 'a';
g = applyAction(g, 'a', { type: 'decision', decline: true });
g = applyAction(g, 'g', { type: 'battlePlan', dial: 0, support: 0, leader: 'guild-0' });
const sealed = structuredClone(g.battle!.plans.g);
g = inspect(g, 'g');
assert.deepEqual(viewGame(g, 'a').battle?.fullPlanInsight?.plan, sealed);
for (const actor of ['b', 'e', 'g'])
assert.equal(viewGame(g, actor).battle?.fullPlanInsight, null);
assert.deepEqual(g.battle?.plans.g, sealed);
g = applyAction(g, 'e', targetPlan);
assert.equal(g.battle?.revealed, true);
});
void test('declining preserves the power and card and allows later use before reveal without reopening the offer', () => {
const before = fixture();
const offered = offer(before);
before.players[0].hand = [];
@@ -233,7 +248,8 @@ void test('declining preserves the power and card, offers independently of hidde
});
assert.equal(declined.players[0].specialKaramaUsed, undefined);
assert.ok(declined.players[0].hand.some((c) => c.id === karama.id));
assert.throws(() => inspect(declined), /preparation/);
assert.equal(declined.decision, null);
assert.equal(inspect(declined).battle?.fullPlan?.target, 'e');
assert.ok(applyAction(declined, 'a', ownPlan).battle?.plans.a);
});
void test('ordinary prescience cancellation does not suppress special inspection', () => {
+1 -1
View File
@@ -170,7 +170,7 @@ void test('each permitted ordinary element can answer against a concealed marker
const declined = applyNoFieldFullPlanStep(fixture.game, fixture.declineStep);
assert.equal(noFieldFullPlanPlayer(declined, atreides).specialKaramaUsed, undefined);
assert.ok(noFieldFullPlanPlayer(declined, atreides).hand.some(c => c.id === cards.karama.id));
rejectedUnchanged(declined, atreides, fixture.inspectStep.action, /preparation/);
assert.equal(viewGame(declined, atreides).atreidesFullPlan?.blocked, null);
// No special target-first gate survives decline: Atreides may seal first.
const first = applyNoFieldFullPlanStep(declined, fixture.planActions[1]);
assert.equal(first.battle!.revealed, false);