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

+90
View File
@@ -0,0 +1,90 @@
'use client';
import { ArrowRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { HelpTip } from '@/components/help-tip';
import type { Action, GameView } from '@/game/engine';
export function AtreidesFullPlan({
game,
act,
busy,
}: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const offer = game.atreidesFullPlan;
const me = game.players.find((player) => player.id === game.me);
const battle = game.battle;
if (!offer || game.status !== 'playing' || !game.advanced ||
game.phase !== 6 || !battle || me?.faction !== 'atreides') return null;
const ownOffer = game.decision?.kind === 'fullPlanOffer' && game.decision.player === game.me;
const priorityPending = !!game.response || !!game.phaseOpening ||
(!!game.decision && !ownOffer) || !!game.truthtrance || !!game.nexusTraitors?.pending;
const blocked = offer.blocked || (battle.revealed
? 'The Battle Plans have already been revealed.'
: priorityPending
? 'Finish the current response, decision or preparation before inspecting a full plan.'
: null);
const disabled = busy || !!blocked;
const ownSealed = battle.submitted.includes(game.me);
return (
<section className="notice space-y-3" aria-label="Atreides full Battle Plan inspection">
<h3 className="font-serif text-xl">Inspect a full Battle Plan</h3>
<p className="muted">
Spend your once-per-game special Karama after battle preparation and
before public plan revelation, including when zero or one plan is sealed.
If the chosen combatant has not sealed, they commit first; only you
receive the inspection. Existing sealed plans cannot change.
</p>
<p className="fine">
{battle.submitted.length} of 2 plans sealed. Leaving this power unused
now keeps the opportunity open until public revelation. Battle plan
submission continues normally.
</p>
{ownSealed && (
<p className="notice">
Your original sealed plan stays fixed. Plans reveal as soon as the
other combatant seals; this power does not unlock either plan.
</p>
)}
{!!battle.noFieldPlayers.length && (
<p className="notice">
Provisional power distinction: special Karama reads the complete
committed No-Field plan, including dial and support. Ordinary
Prescience still cannot request its dial. This private read does not
reveal or materialize the marker before both plans seal.
</p>
)}
<HelpTip topic="fullPlan" />
{blocked && <p className="notice" id="atreides-full-plan-unavailable">{blocked}</p>}
{!offer.cards.length && (
<p className="muted">No spendable Karama is currently available for this special power.</p>
)}
{!offer.targets.length && (
<p className="muted">No combatant is currently available for full-plan inspection.</p>
)}
<div className="flex flex-wrap gap-2">
{offer.cards.flatMap((card) => offer.targets.map((target) => {
const name = game.players.find((player) => player.id === target)?.name ?? 'the selected combatant';
return (
<Button
key={`${card}-${target}`}
data-testid={`atreides-full-plan-${card}-${target}`}
className="game-action min-h-11 whitespace-normal"
disabled={disabled}
aria-describedby={blocked ? 'atreides-full-plan-unavailable' : undefined}
onClick={() => {
if (!disabled) act({ type: 'card', mode: 'special', card, target });
}}
>
Spend Karama · inspect {name}{battle.submitted.includes(target) ? ' · sealed plan' : ''}
<ArrowRight size={15} />
</Button>
);
}))}
</div>
</section>
);
}
+3 -32
View File
@@ -54,6 +54,7 @@ import { AmbassadorBoardMarkers } from './ambassador-board-markers';
import { CharityClaim } from './charity-claim';
import { FremenRevivalGrant } from './fremen-revival-grant';
import { AdvisorPreparation } from './advisor-preparation';
import { AtreidesFullPlan } from './atreides-full-plan';
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
import { JunctionTransport } from './junction-transport';
@@ -1975,6 +1976,7 @@ export function GameTable({
<NexusCards game={g} act={act} busy={busy} />
<Recruits game={g} act={act} busy={busy} />
<AdvisorPreparation game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />
{g.decision?.kind !== 'fullPlanOffer' && <AtreidesFullPlan game={g} act={act} busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />}
<NexusTraitors game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<DiscoveryPanel game={g} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
<LeaderSkillsPanel skills={g.leaderSkills} leaders={g.allLeaders} players={g.players} act={act} busy={transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot} />
@@ -3311,38 +3313,7 @@ export function GameTable({
</>
) : g.decision.kind === 'fullPlanOffer' ? (
<>
<p className="muted">
You may spend your once-per-game special Karama to inspect
either combatant’s full plan. The chosen player commits
first, then only you receive the inspection.
</p>
{!!g.battle?.noFieldPlayers.length && (
<p className="notice">
Provisional power distinction: special Karama reads the
complete committed No-Field plan, including dial and
support. Ordinary Prescience still cannot request its dial.
This private read does not reveal or materialize the marker
before both plans seal.
</p>
)}
<HelpTip topic="fullPlan" />
{me.hand
?.filter(canActivateKarama)
.flatMap((c) =>
[g.battle!.attacker, g.battle!.defender].map((id) => (
<div key={`${c.id}-${id}`}>
{actionButton(
`Spend Karama · inspect ${g.players.find((p) => p.id === id)?.name}`,
{
type: 'card',
mode: 'special',
card: c.id,
target: id,
},
)}
</div>
)),
)}
<AtreidesFullPlan game={g} act={act} busy={busy} />
{actionButton('Keep special power for later', {
type: 'decision',
decline: true,
+85
View File
@@ -1,5 +1,90 @@
# Atreides full-plan interaction audit
## Before-reveal window — 24 October 2026
**Prototyped / Partial.** The authorized supplied revision, physical **p27 F**,
allows the native Advanced special Karama before Battle Plans are revealed.
The initial offer after preparation remains, but declining it no longer forfeits
this battle's later opportunity. A quiet boundary with zero or one sealed plans
permits the same held spendable card and once-per-game use, including Atreides
outside the current battle. This supersedes the older pre-first-seal deadline
documented in the historical audit below; no explicit user decision fixed it.
A focused public-state timing quote serves native preflight and the private
owner-only projection. It never reads the opposing plan. Persistent controls
reuse that permission and original special-card action; both combatants remain
selectable. The initial offer uses those same controls, with its separate keep
button. Minimal bots retain their existing own/allied target preference and
use the shared permission at the initial or later window. No strategy tuning.
Selecting an already sealed target immediately makes its original committed
plan and selected physical card faces available to Atreides alone. Selecting an
unsealed target retains the existing target-first rule. Already sealed fields,
physical cards, forces, support payments and Truthtrance commitments are not
reopened or refunded. The private read does not itself materialize a No-Field;
the previously recorded distinction remains unchanged.
If Atreides has already sealed, a target that commits last completes both
plans and the normal public reveal happens in that same action. There is no
new reading pause or acknowledgement and neither combatant can revise its
plan. This deliberately preserves the existing automatic reveal protocol.
Target-first sequencing and Voice/ordinary Prescience preparation priority
remain provisional implementation compositions; the source supplies the
before-reveal deadline rather than those network ordering details. Current
response, decision, Truthtrance, card-effect and preparation priority remains.
The genuine three-seat fixture uses public native Advanced setup and original
undealt card/Spice positions. Atreides earns its Shield at an actual one-spice
auction, pays four to ship two forces and moves two from Arrakeen; Emperor pays
four to ship two into that territory. Native preparation reaches the original
offer, which is actually declined. No played phase, army, wallet, hand or plan
is assigned. Branches use real legal plan actions in either sealing order.
The source prefix is49 actions at the original offer or50 after its decline.
### Current scoped proof
The frozen **11-file126/126** affected offline union, `npm run check:quick`,
production build and actual changed-control smoke pass. Nine new native-window
cases, seven UI cases and an unrelated-owner late read cover immediate sealed
inspection, target-first continuation, own-sealed target-last reveal, self target,
unchanged plans, physical cost, rejected timing/target/card choices and all four
existing profiles. Earlier No-Field assertions retain the original marker and
ordinary planning rather than asserting the obsolete deadline.
Five actual new-component callback/gameplay/full GameTable SSR journeys reach
Collection: initial, later zero-seal, target already sealed, own already sealed
and own sealed self target. Eight own-view profile paths (zero/one seal for each
profile) accept eight original inspection actions and continue the native battle.
This is runtime and server-rendered control evidence, not browser/hydration,
mobile, complete-game or deployed certification.
First frozen feedback was125/126: adding a new shared pre-leader readiness guard
incorrectly blocked an existing Richese/Semuta initial offer. The repaired quote
retains the original Voice/Prescience preparation priority, while shared readiness
continues to guard actual plan commitments. The exact failing case passes1/1.
Second frozen tests pass126/126; type feedback then corrects checklist schema
metadata and two intentional test assertion types, with no rule behavior change.
Final types/lint/build and all scoped proof pass on unchanged source.
The source-bound proof is based on `660b0a0c258aeab6b3002a42a1f5ef6d527c1006`,
tree `dee3d11ca7dbfc6454530650087ce25967cd5d16e5b9694ce962a52e092d00ff`
(2711 nonignored files). Private reports/logs/control renderings remain outside
Git in `20261024-atreides-full-plan-window/frozen-integrated-3`; the final
checkpoint prose/reference overlay is reviewed separately with source hashes.
Relevant paths: [timing and policy](../game/atreides-full-plan.ts),
[controls](../components/atreides-full-plan.tsx),
[native fixture](../tests/fixture-atreides-full-plan-window.ts),
[native regressions](../tests/atreides-full-plan-window.test.ts),
[control regressions](../tests/atreides-full-plan-ui.test.tsx),
[existing full-plan cases](../tests/full-plan.test.ts) and
[existing No-Field cases](../tests/no-field-full-plan.test.ts).
Full Basic/Advanced/expansion modes, wider optional combinations, comprehensive
rules/save/privacy/custody/browser/deployed acceptance and full AI remain
unfinished. No saved-room, D1, reset, restart or deployment operation is needed
for this slice. The historical evidence below retains its original date/scope.
13 September 2026. This audit verifies the existing native Advanced interaction
paths without promoting an implementation timing choice to an official ruling.
The public Advanced and unfinished expansion gates remain closed.
+20
View File
@@ -16,6 +16,26 @@ open mode gates.
## Current checkpoint and work
**Atreides special inspection before reveal — 24 October. Prototyped / Partial.**
Supplied physical27 F now controls the native special Karama deadline. Declining
the original offer preserves quiet later use with zero or one sealed plans.
A sealed target is privately readable immediately; an unsealed target commits
next. Existing plans cannot change, and a target that seals last reaches the
original public reveal without a reading acknowledgement or deadlock.
Persistent controls and minimal bots share spendable-card/target permission.
Ordinary preparation priority and target-first sequencing remain provisional.
[Source, continuation and bounded proof](ATREIDES_FULL_PLAN_TIMING.md#before-reveal-window--24-october-2026).
Disjoint fixture/control lanes and bounded source/continuation review pass
**11 files126/126**, types/lint/build, five actual control/gameplay/GameTable SSR
journeys into Collection and eight existing-profile paths accepting eight
inspection proposals. Genuine49/50-action public Advanced setup, a paid Shield
auction, two paid shipments and actual ground movement supply the battle.
Feedback removes an unintended new pre-leader restriction and repairs reference
metadata/two test type assertions. Full modes, wider combinations, comprehensive/
browser/deployed acceptance and AI refinement remain unfinished. No saved-room,
D1, reset, restart or deployment operation.
**Native advisor preparation — 24 October. Prototyped / Partial.**
Advanced Bene Gesserit can now declare preparation at quiet boundaries in
Charity, Bidding, Revival and before the first actual shipment, including after
+30
View File
@@ -1,5 +1,35 @@
# Dune implementation status
## 24 October 2026 — Atreides special inspection until public reveal
Use supplied physical27 F's before-reveal deadline after declining the original
special offer, including one committed plan. A focused public-state timing quote
serves preflight and owner-only card/target projection; persistent controls and
minimal existing-profile preference reuse it. A sealed target is read at once;
an unsealed one retains target-first commitment. Existing plans, physical pieces,
support costs and binding fields stay fixed. Removing the target-branch early
return lets the second seal reach normal public reveal once, without a new
reading acknowledgement. Preparation priority and sequencing remain provisional.
Disjoint fixture/control lanes and bounded review pass11 files126/126,
types/lint/build, five actual control/gameplay/GameTable SSR journeys through
Collection and eight profile paths accepting eight proposals. Genuine49/50
public native Advanced actions include an actual one-spice Shield auction,
two four-spice shipments and real Arrakeen ground movement. No played phase,
army, wallet, hand or plan is assigned. Core and existing No-Field tests now
retain late permission rather than the obsolete first-offer deadline; an
unrelated owner's late read retains private entitlement.
Feedback catches and removes an unintended shared pre-leader prerequisite absent
from original special preflight, retaining its separate gate on every plan seal.
Exact repaired Semuta/Atreides1/1 passes; later type feedback repairs existing
reference metadata usage and two test assertions. Final source is frozen through
all checks. Full modes, wider optional combinations, comprehensive/browser/
deployed assurance and AI refinement remain unfinished. No saved-room/D1/reset/
restart/deployment operation.
[Source, exact proof and limits](ATREIDES_FULL_PLAN_TIMING.md#before-reveal-window--24-october-2026).
Private proof:20261024-atreides-full-plan-window/frozen-integrated-3.
## 24 October 2026 — Native advisor preparation before first shipment
Expose the supplied Advanced BG after-Spice/Nexus window in phases2–5 at quiet
+15 -1
View File
@@ -1,5 +1,19 @@
# Rule decision index
## Atreides special inspection before public reveal — 24 October 2026
Supplied physical27 F permits the native Advanced special Karama before Battle
Plans are revealed. Declining the initial offer is no longer a deadline: quiet
boundaries with zero or one sealed plans retain the same physical-card and
once-per-game use. A selected sealed plan is read immediately by Atreides only;
an unsealed target commits next. Original sealed plans cannot be reopened.
Voice/ordinary Prescience preparation priority and target-first sequencing
remain provisional. If Atreides already sealed, the target's last commitment
reveals both plans immediately, with no extra reading acknowledgement or free
replanning. This source cutover supersedes the older pre-first-seal deadline;
it neither opens modes nor changes the recorded No-Field distinction.
[Source, implementation and limits](ATREIDES_FULL_PLAN_TIMING.md#before-reveal-window--24-october-2026).
## Native advisor preparation window — 24 October 2026
Apply supplied physical28 G and publisher base18 after-Spice/Nexus timing
@@ -1007,7 +1021,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Smuggler collection strength | The older source check/question produced no numerical answer. Under the later choose-recommended instruction, provisionally cap by copied/printed disc plus declared skill bonuses and KH2, retaining reveal piles and delayed survival settlement. Actual Artillery zeroing voids collection; the base Stunner remains a projectile. Sole own Traitor bypasses weapons, so it retains collection. Printed bounty does not change; this is not a publisher ruling. Other exceptional compositions remain separate. | [Source and explicit policy](LEADER_SKILLS_RULES.md#smuggler-modifier-policy--17-october-2026), [runtime contract](SMUGGLER_BATTLE.md#provisional-modifier-policy--17-october-2026) |
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Repeat Karama on a canceled compulsory cache lot | After one Karama cancels the compulsory Richese cache auction and restores the ordinary pool, the engine currently still accepts a second Karama against the resumed window and re-derives the ordinary count from the frozen pre-cache field. Whether a repeat cancellation should be refused is not settled; the invariant that a canceled lot never restores more than its frozen pre-cache count does hold. | [Richese auction source](RICHESE_AUCTION_RULES.md), [exhausted-cache runtime](IMPLEMENTATION_STATUS.md) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Native full-plan/Voice/Prescience/Truthtrance/Ghola interactions now have genuine-setup focused evidence, but the source audit does not settle the special offer/deadline or target-first interpretation. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [full-plan interaction audit](ATREIDES_FULL_PLAN_TIMING.md) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Supplied physical27 F now sets its before-public-reveal deadline, including quiet later use after declining the initial offer. Ordinary preparation priority and target-first sequencing remain implementation compositions, with sealed plans retained and no added reading acknowledgement. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [current full-plan timing](ATREIDES_FULL_PLAN_TIMING.md#before-reveal-window--24-october-2026) |
| Homeworld high-threshold revival deployment | The publisher does not state whether a return that itself first reaches the high threshold may deploy on that same return. The content-first prototype evaluates the resulting population, so the crossing return qualifies; this is a visibly provisional composition, not a located timing ruling. The Tleilaxu low-income start-condition snapshot is unchanged, and partial groups, split destinations, storm placement, allied-Homeworld limits and shipment-classified entry effects remain guarded. | [Benefits contract](HOMEWORLD_BENEFITS_RULES.md#provisional-high-threshold-revival-deployment--9-october-2026), [resumption record](IMPLEMENTATION_STATUS.md#9-october-2026--gated-captures-resumed-under-the-basic-occupation-and-odd-force-occupy-prototypes) |
| Occupied Homeworlds | The publisher question about retention after departure, replacement or contest, unique-power composition and shared income remains open; the first-occupier/current-occupier question was already sent. The content-first Basic benefit selector now uses the visibly provisional current-sole policy above (Tupile excepted), which is an implementation choice, not an answer to that question. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
| Nexus responses and composition | Betrayal private-response timing, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations remain source-bound. Tleilaxu Secret Ally now has the explicitly provisional recommended combined policy in its own row below; it is not borrowed authority from native revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and individual faction source/runtime documents |
+50
View File
@@ -0,0 +1,50 @@
import type { FactionId } from './catalog';
import type { Action, GameView } from './engine';
/** Timing needs only public commitment status, never the hidden plan contents. */
export type AtreidesFullPlanContext = {
status: string;
phase: number;
advanced: boolean;
owner: { id: string; faction: FactionId; specialKaramaUsed?: boolean };
blocked?: string | null;
battle: null | {
attacker: string;
defender: string;
revealed: boolean;
preparing: boolean;
fullPlan: boolean;
};
};
/** Supplied revised rules, physical p27 F: before Battle Plans are revealed.
* The original offer is a convenience, not a deadline. A sealed plan stays fixed.
* Voice/ordinary Prescience keep their existing preparation priority.
*/
export function quoteAtreidesFullPlan(context: AtreidesFullPlanContext) {
const { owner, battle } = context;
let blocked: string | null = null;
if (!context.advanced) blocked = 'Special Karama powers require the Advanced game.';
else if (owner.faction !== 'atreides') blocked = 'Only native Atreides may use this special Karama.';
else if (context.status !== 'playing' || context.phase !== 6 || !battle)
blocked = 'Use special prescience during a battle before plans are revealed.';
else if (owner.specialKaramaUsed || battle.fullPlan)
blocked = 'Your special Karama power has already been used this game.';
else if (battle.revealed) blocked = 'The Battle Plans have already been revealed.';
else if (battle.preparing)
blocked = 'Finish battle preparation before using special prescience.';
else if (context.blocked) blocked = context.blocked;
return { blocked, targets: battle ? [battle.attacker, battle.defender] : [] };
}
/** Reuse the existing own/allied inspection preference, without strategy tuning. */
export function atreidesFullPlanAction(game: GameView): Action | null {
const offer = game.atreidesFullPlan;
if (!offer || offer.blocked || !offer.cards.length) return null;
const me = game.players.find(player => player.id === game.me);
if (!me) return null;
const beneficiary = offer.targets.includes(me.id) ? me.id : me.ally;
if (!beneficiary || !offer.targets.includes(beneficiary)) return null;
const target = offer.targets.find(player => player !== beneficiary);
return target ? { type: 'card', mode: 'special', card: offer.cards[0], target } : null;
}
+4 -18
View File
@@ -1,4 +1,5 @@
import { discoveryBotActions } from './discovery-options';
import { atreidesFullPlanAction } from './atreides-full-plan';
import { liveMovementPromises } from './movement-promises';
import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions } from './ecaz-occupy-options';
import { quoteEcazOccupyDial } from './ecaz-occupy-battle';
@@ -2581,24 +2582,7 @@ function policyActions(g: GameView): Action[] {
];
}
if (d.kind === 'fullPlanOffer') {
const b = g.battle!;
const beneficiary = [b.attacker, b.defender].includes(me.id)
? me.id
: me.ally;
if (
specialCard &&
beneficiary &&
[b.attacker, b.defender].includes(beneficiary)
)
return [
{
type: 'card',
mode: 'special',
card: specialCard.id,
target: b.attacker === beneficiary ? b.defender : b.attacker,
},
];
return [{ type: 'decision', decline: true }];
return [atreidesFullPlanAction(g) ?? { type: 'decision', decline: true }];
}
// A legacy inspection gate is cleared by authoritative normalization.
if (d.kind === 'fullPlanRead') return [];
@@ -3801,6 +3785,8 @@ function policyActions(g: GameView): Action[] {
}
if (g.phase === 6) {
const b = g.battle;
const inspection = atreidesFullPlanAction(g);
if (inspection) return [inspection];
if (b?.outcomeWindow && !b.outcomeWindow.closed) {
if (![b.attacker, b.defender].includes(me.id) || b.outcomeWindow.ready.includes(me.id)) return [];
return [{type: 'battleOutcomeReady', event: b.outcomeWindow.event}];
+37 -15
View File
@@ -10,6 +10,7 @@ import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile,
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
import { advancedMoritaniLeaderSkillsProfile, selectedDeckLeaderSkillsProfile, homeworldLeaderSkillsProfile, independentTreacheryLeaderSkillsProfile, classicNexusLeaderSkillsProfile, classicTechLeaderSkillsProfile, guildBetrayalNexusLeaderSkillsProfile, mixedE1E2NexusLeaderSkillsProfile, mixedE3NexusLeaderSkillsProfile, nativeDiscoveryLeaderSkillsProfile, nativeEcazLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, nativeTechLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, pairedE3LeaderSkillsProfile, pairedE3NexusLeaderSkillsProfile, pairedNexusLeaderSkillsProfile, singleE1E2NexusLeaderSkillsProfile, standaloneE3NexusLeaderSkillsProfile, strongholdLeaderSkillsProfile } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteAtreidesFullPlan } from './atreides-full-plan';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
@@ -15347,9 +15348,13 @@ function auctionSpicePaymentFunded(g: Game, p: Player) {
}
function recordFullPlanInspection(g: Game, owner: string) {
const p = getPlayer(g, owner);
const b = g.battle;
const bothSealed = b && b.plans[b.attacker] && b.plans[b.defender];
log(
g,
`${p.name} can now inspect the committed battle plan privately. The plan remains available until both plans are revealed.`,
bothSealed
? `${p.name}'s inspection target has committed. Both plans reveal immediately; existing sealed plans cannot change.`
: `${p.name} can now inspect the committed battle plan privately. The plan remains available until both plans are revealed.`,
{ faction: p.faction, name: 'Full plan inspection' },
);
}
@@ -30774,6 +30779,31 @@ function actualSpecialKaramaCards(g: Game, p: Player) {
return p.hand.filter(card => card.kind === 'special' && card.effect === 'karama' &&
!karamaSpendingBlock(g, p, card, kullSpecialPurpose(p.faction)));
}
function atreidesFullPlanTiming(g: Game, p: Player) {
const b = g.battle;
const ownOffer = g.decision?.kind === 'fullPlanOffer' && g.decision.player === p.id;
return quoteAtreidesFullPlan({
status: g.status, phase: g.phase, advanced: g.advanced, owner: p,
blocked: g.truthtrance || g.response || (g.decision && !ownOffer) ||
g.phaseOpening || g.pendingKarama || g.pendingRevival || g.choamMarket ||
g.pendingTreacheryDiscard || g.pendingNullentropy
? 'Resolve the current response, decision or card effect before special prescience.' : null,
battle: b ? { attacker: b.attacker, defender: b.defender, revealed: b.revealed,
// Preserve the existing special offer before shared pre-leader readiness;
// that separate opportunity still guards every actual plan commitment.
preparing: !!b.preparation,
fullPlan: !!b.fullPlan } : null,
});
}
function atreidesFullPlanView(g: Game, p: Player) {
if (g.status !== 'playing' || !g.advanced || p.faction !== 'atreides' ||
g.phase !== 6 || !g.battle) return null;
const quote = atreidesFullPlanTiming(g, p);
const cards = p.hand.filter(card => canUseAsKaramaRole(g, p, card) &&
!karamaSpendingBlock(g, p, card, kullSpecialPurpose(p.faction))).map(card => card.id);
return { ...quote, cards,
blocked: quote.blocked ?? (cards.length ? null : 'A spendable Karama card is required.') };
}
function ecazSpecialKaramaContext(g: Game, p: Player) {
const b = g.battle;
return { status: g.status, phase: g.phase, advanced: g.advanced, owner: p,
@@ -31036,21 +31066,12 @@ export function prepareSpecialKaramaIntent(
);
return { ...base, kind: 'fremen', territory: t };
} else if (p.faction === 'atreides') {
const b = g.battle;
requireRule(
g.phase === 6 &&
!g.response &&
g.decision?.kind === 'fullPlanOffer' &&
g.decision.player === p.id &&
b &&
!b.revealed &&
!b.preparation &&
!Object.keys(b.plans).length,
'Choose special prescience after battle preparation and before plans are sealed.',
);
const quote = atreidesFullPlanTiming(g, p);
requireRule(!quote.blocked, quote.blocked ?? 'Special prescience is unavailable.');
const b = g.battle!;
const target = stringField(action.target);
requireRule(
[b.attacker, b.defender].includes(target),
quote.targets.includes(target),
'Choose a player in this battle.',
);
// Distinct special Karama inspects the sealed plan, not the token inventory.
@@ -31425,6 +31446,7 @@ function resolveSpecialKaramaEffect(g: Game, p: Player, card: Card, intent: Spec
g,
`${p.name} used special Karama to inspect ${getPlayer(g, target).name}’s entire battle plan.`,
);
if (b.plans[target]) recordFullPlanInspection(g, p.id);
} else if (intent.kind === 'guildHomeworld') {
const shipper = getPlayer(g, intent.target);
p.specialKaramaUsed = true;
@@ -35831,7 +35853,6 @@ function applyActionInner(
log(g, `${p.name} announced a Battle Plan without a leader or Cheap Hero; no battle cards can be played.`);
if (b.fullPlan?.target === id) {
recordFullPlanInspection(g, b.fullPlan.owner);
return g;
}
if (b.plans[b.attacker] && b.plans[b.defender]) {
for (const playerId of b.noFieldPlayers ?? []) {
@@ -37147,6 +37168,7 @@ export function viewGame(state: Game, id: string) {
}
: null,
richeseSpecialKarama: richeseSpecialKaramaView(g, me),
atreidesFullPlan: atreidesFullPlanView(g, me),
...(me.faction === 'ecaz' && g.advanced && g.battle
? { ecazSpecialKarama: ecazSpecialKaramaView(g, me) } : {}),
...(g.moritaniSpecialKarama?.source.owner === me.id && g.moritaniSpecialKarama.stage === 'offer'
+23 -1
View File
@@ -76,7 +76,7 @@ export const HELP = {
choamCombat:
'CHOAM may reserve spice for an ally’s advanced battle support. The ally chooses the payment split when sealing. A traitor victory costs the winner no support; any revealed traitor prevents CHOAM force-payment income.',
fullPlan:
'Atreides may spend its Advanced special Karama once per game to inspect the full committed plan. Only Atreides receives dial, leader, actual played faces, support and KH while the other combatant plans; no inspection confirmation. The provisional No-Field distinction permits this separate special power while ordinary dial Prescience stays barred. A private read does not reveal or materialize the token before both plans seal.',
'Atreides may spend its Advanced special Karama once per game after preparation and before public reveal, even after declining the first offer or one plan sealing. A sealed target is read immediately; an unsealed target commits first. Existing sealed plans cannot change. Only Atreides receives the private inspection; no confirmation. Both seals reveal immediately. The provisional No-Field distinction permits this separate special power while ordinary dial Prescience stays barred; private reading does not materialize its token.',
guildShipment:
'The Guild may spend its special Karama once per game to stop a declared off-planet shipment before payment or arrival. The shipper keeps its forces and spice and may still move. Fremen’s southern reinforcements are on-planet.',
specialKarama:
@@ -2716,6 +2716,28 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['revival'],
},
{
id: 'atreides-full-plan',
title: 'Atreides: special full Battle Plan inspection',
category: 'Factions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Spend the native Advanced special Karama before public revelation, including after one plan seals.',
steps: [
'After battle preparation, spend a projected available Karama and select either combatant. Keeping the power at the initial offer preserves later use before revelation, without opening another mandatory offer.',
'If the target has sealed, inspect the original full plan immediately. Otherwise that target commits next. Only Atreides receives the plan and selected card faces; an ally gains no automatic access.',
'Already sealed plans stay fixed. If you have already sealed, the other combatant’s last commitment reveals both plans immediately. The special power grants no replanning, payment refund or separate reading pause.',
'Preparation order and target-first sequencing remain provisional. The distinct No-Field permission retains ordinary dial Prescience’s prohibition and does not materialize the marker during private inspection.',
],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Shared before-reveal timing replaces the initial-offer deadline; sealed target reading and target-last public reveal preserve original commitments.', evidence: ['game/atreides-full-plan.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Initial and persistent later controls share projected spendable physical cards and combatant targets, with unavailable and own-sealed guidance.', evidence: ['components/atreides-full-plan.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'The existing own/allied target preference shares the legal projection at initial and later windows. Strategy and calibration remain deferred.', evidence: ['game/atreides-full-plan.ts', 'game/bots.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Supplied source deadline, retained provisional sequencing and same-action last-seal limitation are explicit.', evidence: ['docs/ATREIDES_FULL_PLAN_TIMING.md', 'docs/RULE_DECISIONS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Genuine49/50-action public setup, paid acquisition, shipments and movement create the native battle. Frozen11-file126/126, types/lint/build, five actual control/gameplay/table-SSR journeys and eight existing-profile paths pass. Full modes, broader combinations and comprehensive/browser/deployed assurance remain open.', evidence: ['tests/atreides-full-plan-window.test.ts', 'tests/atreides-full-plan-ui.test.tsx', 'tests/fixture-atreides-full-plan-window.ts', 'tests/full-plan.test.ts', 'tests/no-field-full-plan.test.ts'] },
],
related: ['card-karama', 'advanced-combat', 'battle-cards'],
},
{
id: 'card-karama',
title: 'Karama',
+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);