diff --git a/components/diplomat-defense.tsx b/components/diplomat-defense.tsx
index 0331186f..b857a954 100644
--- a/components/diplomat-defense.tsx
+++ b/components/diplomat-defense.tsx
@@ -34,7 +34,7 @@ export function DiplomatDefense({
return (
- The opposing committed defense,{' '}
+ The opposing used defense,{' '}
{source?.name ?? decision.source}, provides{' '}
{copiedDefense} protection. Diplomat can copy that
protection onto one of your committed Worthless cards.
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index a24bbd82..7749030f 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -29,6 +29,26 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
+**Diplomat used-defense contexts — 17 October.** Fresh capability3 copies the
+canonical role actually used, including late Portable Snooper after a prior
+false traitor choice; the recorded vote is never reopened. Native World-only
+votes and original invader late-defense choices remain. A true declaration
+prevents a fresh offer; a declined copy permits a legal own late defense, while
+a selected copy cannot add a second defense. Old1/2 source windows stay.
+World strength and held effects coexist independently, never imported by the
+copy. Real first end-Mentat earns Carthag: its own original Snooper protects
+a copied Shield from poison; no opposing source means no invented defense.
+Genuine paid Arrakis copy/decline and native Kaitain programmes retain actual
+survival/death, one selected Worthless disposal, source retention and typed
+custody (Kaitain15N/Salusa5E; three visiting Guild counters reach Tanks).
+Four390px journeys/25 actions reach original Collection with clean roots and
+no app errors. Twelve four-policy programmes/40 actions remain legal; the held
+Carthag policies decline and their context strategy remains deferred.
+Conserved cards/decks and controlled context scalar entropy are explicit.
+Parent removed forwarding and unrelated Guild-income pins. Frozen12-file union
+86/86 and final10 cases pass after nullable-source/end-Mentat typing fixes;
+types/lint/build pass with existing warnings. No full/deployed acceptance claimed.
+
**Smuggler modifier band — 17 October.** Under the standing recommended-choice
instruction, provisionally cap by copied/printed disc plus declared skill bonuses
and KH2. Actual Artillery zeroing voids collection; ordinary Stunner stays a
diff --git a/docs/DIPLOMAT_DEFENSE.md b/docs/DIPLOMAT_DEFENSE.md
index 0b885fac..d3afa5bd 100644
--- a/docs/DIPLOMAT_DEFENSE.md
+++ b/docs/DIPLOMAT_DEFENSE.md
@@ -1,12 +1,12 @@
# Diplomat copied-defense prototype
-13 September 2026. **Prototyped**, with **Partial** rules coverage. This connects
-the Diplomat's normal band for the canonical printed defenses that the opponent
-actually used: Shield, Snooper, Shield Snooper, defensive Weirding Way,
-defensive Chemistry and Portable Snooper sealed in the defense slot. The lower
+Updated 17 October 2026. **Prototyped**, with **Partial** rules coverage. The
+normal band copies canonical defenses actually used: Shield, Snooper, Shield
+Snooper, defensive Weirding Way/Chemistry and sealed or actually late-played
+Portable Snooper. Current battles compose original Homeworld/held-card contexts
+without inheriting the opponent's modifiers. The lower
[retreat prototype](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation)
-is now connected separately; combined modules and other defense sources remain
-unfinished. Public Leader Skills starts and publication remain gated.
+remains separate. Public starts, full modes and publication remain gated.
## Source and interpretation
@@ -29,14 +29,14 @@ face supplies nothing to copy. Canonical identity is matched by physical ID with
printed kind, name and effect, so a tampered card cannot inherit a role by
claiming a printed name.
-The copied role is the role the opponent actually used in the defense slot, and
+The copied role is the role the opponent actually used as a defense, and
each printed distinction survives: a Shield Snooper protects against both
projectile and poison weapons and still counts as a Shield for the lasgun
explosion; a defensive Weirding Way stops a projectile weapon but is **not** a
Shield and triggers no explosion; Chemistry stops Poison Tooth, which a Snooper
-copy does not; a Portable Snooper sealed in the defense slot copies only its
-poison-defense role. The shared battle-card role helpers supply
-these distinctions rather than a binary Shield/Snooper substitution.
+copy does not; a sealed or actually played late Portable Snooper copies only
+its poison-defense role. The shared battle-card role helpers supply these
+distinctions rather than a binary Shield/Snooper substitution.
**First-version interpretation.** Copying the opponent's already-used defensive
role does not copy its activation, its late-play opportunity or its own-slot
@@ -44,39 +44,37 @@ prerequisite. Two consequences are explicit implementation inferences. A
defensive Weirding Way keeps its printed requirement that another weapon or
Worthless card occupy the weapon slot of the plan it was played in, so the
opponent's real plan weapon is validated before any copy is offered; our own
-plan needs no weapon to receive that copied role. A copied Portable Snooper does
-not gain the original card's after-reveal play, which the prototype already
-excludes for every late defense. The copied role protects exactly like that
+plan needs no weapon to receive that copied role. A copied Portable Snooper
+does not gain the original card's after-reveal activation: the opponent must
+actually play its card before a late-source offer exists. The copied role protects exactly like that
defense without changing the Worthless card's identity or either revealed plan,
and the copied defense is resolved first so that the common surviving-trainer
rule is evaluated afterwards. If the copied type does not stop the weapon,
removing the dead trainer's skill does not change that outcome.
-Two timing and cleanup rules are explicit implementation inferences. The
-optional public choice occurs after both plans reveal and before traitor calls,
-because the copied defense cannot be known when the Worthless card is sealed.
-The chosen Worthless is always discarded after resolution, including when a
-successful traitor call preempts ordinary weapon effects. This combines the
-card's explicit discard instruction with the chosen scheduling point; the
-publisher sources do not separately address Diplomat versus traitor timing.
+Timing and cleanup remain explicit implementation interpretations. Sealed
+sources offer the public choice after reveal and before traitor calls as before.
+An actually played late source may offer it before the unresolved outcome,
+including after false traitor choices. Those submitted choices are not reopened.
+A successful traitor declaration prevents a fresh late-source offer. The selected
+Worthless always discards after resolution, including when a later successful
+traitor preempts ordinary weapon effects; this is not a publisher priority FAQ.
The [November 2020 GF9 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf),
page 8, supplies the general winner-retention rule, which the chosen card's
specific discard instruction overrides in this prototype.
## Connected behavior
-New supported battles carry a version marker and an independent event-bound
-receipt. The marker also records which copied sources that battle admits: a
-newly stamped battle admits every canonical printed defense above, while an
-already-open older battle keeps its original canonical base Shield and Snooper
-sources only, so widening the quote cannot invent a retroactive choice for a
-pending plan that never had one. After reveal, the server derives the eligible
-physical Worthless IDs,
-opposing defense and live native assignment from current custody. Choosing one
-records its copied kind; declining records that choice. Either result preserves
-the original plans and leaves every card in its hand until battle resolution.
-Legacy battles without the new marker keep their existing continuation and do
-not gain a retroactive Diplomat opportunity.
+New supported battles carry capability3 and an independent event-bound receipt.
+They admit canonical used sources, including actually played late Portable,
+within their original World/Stronghold skill profile. Capabilities1/2 retain
+their old base/printed-only source windows; unversioned battles are not reopened.
+The frame binds original plans, trainer and opposing used physical source.
+It does not confuse an unrelated later own defense with a historical decline.
+Choosing a committed Worthless records its copied kind; declining ends that
+opportunity without discarding it. Both preserve plans and cards until outcome.
+A copied defense bars a second actual defense through the late slot; a declined
+copy may still use an independently legal own Portable Snooper.
The copied printed role feeds ordinary weapon survival and existing Poison or
Projectile Defense skill checks through the shared battle-card role helpers.
@@ -129,11 +127,50 @@ checkpoint commit and private source-bound report.
The bounded [lower retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation)
now follows a documented conservative strength and outcome order; this does not
-settle a publisher priority ruling. Carthag-added protection, mirror and
-numeric or Stronghold strength modifiers, late Portable Snooper play, and any
-future copied or modified defense still need a separate contract for whether
-generic roles or named exceptions are inherited; the copied role never imports
-a modifier, an activation or a late-play window. Canonical additional Ix Shield
-and Snooper faces use those same printed roles in new battles. Homeworlds, Nexus,
-Discoveries, Stronghold Cards, Tech Tokens and other combined modules remain
-guarded. These limits preserve the existing public gates.
+settle a publisher priority ruling. Current used-defense copying coexists with
+original World strength and held-card effects; it never imports the opponent's
+virtual/numeric protection, activation or late-play right. Own earned Carthag
+may independently add its original Snooper to a copied Shield when its conditions
+hold. Carthag does not invent a defense for a physical Worthless with no copied
+source. Mirror/numeric inheritance and other exceptional interpretations remain
+separate; existing module envelopes and public gates are not widened.
+
+## Used-defense contexts — 17 October 2026
+
+Capability3 binds the role only after a real late defense is played. Prior false
+traitor decisions stay submitted; a native Homeworld vote may already be closed
+before the invader uses the original late-defense choice. Copy or decline then
+resumes that same original battle, never a replayed vote or re-sealed plan.
+A true declaration prevents a fresh opportunity; an earlier selected copy keeps
+its existing mandatory-discard contract. The same used-role quote supplies
+shared weapon survival, while original World/Stronghold modifiers remain local.
+
+The normal controlled reproduction had a real late Portable recorded after a
+false vote but no capability2 copy offer. Capability3 now offers its canonical
+poison role. Controlled boundaries also cover historical decline followed by
+own late defense, unchanged rejection of a second defense after copying, true
+traitor precedence and original printed-only legacy continuation.
+
+Genuine source-bound programmes use paid shipments/movement, native all14
+training and actual END Mentat ownership. Unopened deck/physical-card staging
+is disclosed; World/held-card scalar entropy is controlled, not claimed random.
+No live armies, wallets, clocks, initiative, owners or receipts are assigned.
+Five genuine programme branches reach original Collection: paid Arrakis copy
+and decline after a preserved false vote, native-only World late-copy timing,
+actual earned Carthag protection layered on a copied Shield, and the no-source
+negative. Kaitain15 normals and Salusa5 Sardaukar stay native while three real
+Guild visitors become casualties. Selected Worthless/source custody and original
+winner retention remain separate from the copy.
+
+Four390px control journeys/25 actions and twelve four-policy programmes/40
+actions exercise those paths. The held-Carthag policies legally decline; full
+context strategy is deferred, not claimed. Frozen related12-file union86/86
+and final10 used-defense cases pass after nullable lookup/state-narrowing fixes.
+Types/lint/build pass with existing warnings. New evidence:
+`tests/diplomat-used-defense.test.ts`,
+`tests/diplomat-late-journey.test.ts`,
+`tests/fixture-diplomat-late-journey.ts`,
+`tests/diplomat-context-journey.test.ts` and
+`tests/fixture-diplomat-context-journey.ts`.
+No HTTP/database, comprehensive assurance, full-mode or deployed proof is claimed.
+
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 4a235151..a0b04a3f 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,59 @@
# Dune implementation status
+## 17 October 2026 — Diplomat used-defense contexts
+
+An actual capability2 late Portable after Emperor's false traitor vote recorded
+the defense but offered no Diplomat copy. Fresh capability3 now binds that
+actually used canonical poison role before the unresolved outcome, preserving
+the submitted false vote. No fresh offer follows a true declaration. Existing
+capability1/2 base/printed source windows and unversioned continuations stay.
+No profile/public gate or database migration is added.
+
+The event-bound frame includes the opposing used late physical card, not an
+unrelated later own defense after a historical decline. Declining the copy
+preserves an otherwise legal own late Portable; an already selected copied
+defense bars a second actual defense. Canonical identity, original plans and
+mandatory selected-Worthless disposal remain. The pure resolution adapter's
+new-context bit is authoritative/transient, not a client action or imported
+modifier. World strength and original held-card effects remain independent.
+
+Two disjoint contributors supplied genuine native all14 programmes. Paid
+two-counter South Mesa shipment and Red Chasm movement precede two poison
+plans. Emperor's earlier false vote stays submitted; actual Guild Portable
+creates the copy/decline choice and only Guild submits the remaining vote.
+Copy keeps the trainer alive, sends Guild2 to Tanks and discards the selected
+Worthless/source once. Decline kills the trainer, loses Emperor2, and the
+winning Guild may retain its real Portable/poison. Original winner cleanup
+and Collection continue without manufacturing a second conflict.
+
+Real paid Guild invasion of Kaitain uses the native-only vote and original
+invader late-defense decision. Copy preserves Kaitain15N/Salusa5E, both
+leaders and native stock; visiting Guild3 go to Tanks. Real first-turn paid
+Carthag placement earns its card at actual END Mentat; a copied physical
+Shield then gains the holder's own original Snooper protection and survives
+poison. Carthag adds Snooper, not a virtual Shield. A physical Worthless with
+no opposing used defense gets neither copy nor invented protection and dies.
+
+Scalar context shuffle/Storm entropy and unopened decks/physical card/cache
+transfers are explicitly staged; no live army/wallet/phase/order/owner/receipt
+is assigned. Parent removed setup/forwarding and unrelated Guild-Collection
+income pins rather than changing or re-pinning that original income.
+
+Four actual390px journeys/25 actions prove late Copy/Decline, native World
+timing and independently earned Carthag protection; final roots have no
+overflow/app alerts/page errors. Twelve four-policy programmes/40 actions
+finish Collection; held-Carthag policies decline, so no context strategy is
+claimed. Owned tab/host/scaffolds are removed; real rooms/DBs, human server
+and deployment were untouched.
+
+Frozen related12-file union86/86 and final10 used-defense cases pass. Parent
+corrected nullable used-source lookup and callback-return state narrowing
+found by typecheck; final types/lint/build pass. Existing deprecation,
+chunk-size and route-classification warnings remain. Artifacts:
+`/tmp/dune-diplomat-used-*-proof.json`. No full-mode, calibrated AI, broad
+assurance or deployed acceptance is claimed; copied/numeric inheritance
+outside this printed-role context remains separately bounded.
+
## 17 October 2026 — Provisional modified Smuggler collection
The original raw KH/trainer plans reject unchanged at the modifier guard.
diff --git a/docs/LEADER_SKILLS_RUNTIME.md b/docs/LEADER_SKILLS_RUNTIME.md
index bf84d210..05f1edc5 100644
--- a/docs/LEADER_SKILLS_RUNTIME.md
+++ b/docs/LEADER_SKILLS_RUNTIME.md
@@ -885,9 +885,10 @@ the bounded Advanced Ixian/CHOAM `banker-income` profile; ordinary/default start
[Banker battle spending](SPICE_BANKER_RUNTIME.md) retains its separate
sealed funds and survivor strength. [Smuggler shipping](SMUGGLER_SHIPMENT.md)
connects its optional normal reserve bonus and the [owned No-Field companion](SMUGGLER_NO_FIELD.md).
-[Diplomat defense](DIPLOMAT_DEFENSE.md) connects its normal band for a physical
-Worthless card copying a canonical base Shield or Snooper. Its bounded lower
-[retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation) now moves
+[Diplomat defense](DIPLOMAT_DEFENSE.md#used-defense-contexts--17-october-2026)
+connects a physical committed Worthless to the canonical printed role actually
+used, including current late Portable and original World/held-card contexts.
+The separate [retreat](LEADER_SKILLS_RULES.md#bounded-diplomat-retreat-interpretation) now moves
selected undialed normal/elite counters to an empty adjacent non-stronghold
after a surviving trained leader loses. Modified-defense inheritance remains
guarded. [Smuggler battle collection](SMUGGLER_BATTLE.md)
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index f4a64bb4..69b11506 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -675,7 +675,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
| Spice Banker battle spending | Secret own-spice commitment is separate from support; only the surviving selected disc gains strength. Core Battle Plan payment rules spend it on death/loss and waive it for a sole traitor-call winner. This payment composition is explicit; upper deferred-income custody remains pending. | [Source contract and prototype](SPICE_BANKER_RUNTIME.md) |
| Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. The officially permitted owned No-Field plus a separate free force is now prototyped at the unchanged one-marker price. Legacy unstamped ordinary declarations keep their quoted full price. | [Ordinary contract](SMUGGLER_SHIPMENT.md), [No-Field combination](SMUGGLER_NO_FIELD.md) |
| Sandmaster movement | One optional board spice per distinct entered territory; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing. Native Fremen worm rides and physical Fremen Ambassador relocations collect optionally at the destination before arrival reactions, without an invented intermediate route. Owned one-pile-per-territory allocation now covers ground, direct destinations and native HMS relocation; sector allocation remains provisional and concealed No-Field movement stays separate. | [Ground/direct/HMS relocation](SANDMASTER_MOVEMENT.md), [worm rides](SANDMASTER_WORM.md) |
-| Diplomat defense | A committed Worthless copies the opponent's canonical printed defensive role: base/Ix Shield/Snooper, hybrid, defensive Weirding/Chemistry or sealed Portable. Named combat distinctions remain. Copying the used role, not activation or own-slot prerequisites, is a visibly qualified first version. Reveal-before-traitor timing and mandatory disposal remain implementation interpretations; existing base-only battle windows are not reopened. Late/mirror/numeric modifications remain separate. | [Source and connected prototype](DIPLOMAT_DEFENSE.md) |
+| Diplomat defense | A committed Worthless copies a canonical printed role actually used, including a real late Portable Snooper in capability3. Keep named protection, mandatory selected-card disposal and original World/held-card effects; import no opponent modifier, activation or late right. Preserve prior false traitor choices, skip fresh offers after a true declaration, and retain old capability1/2 windows. Own earned Carthag can independently supply its Snooper to a copied Shield; no used source means no invented defense. Scheduling remains a visibly provisional interpretation, not a new priority FAQ. Mirror/numeric inheritance stays separate. | [Used-defense scope](DIPLOMAT_DEFENSE.md#used-defense-contexts--17-october-2026) |
| Bureaucrat payments | Once per phase, divert two from one actual third-party payer contribution of at least five; never pool below-five shares or change full payer costs. Trainer/payee contributions cannot qualify but do not hide another eligible payer; private shares and qualifying identity stay withheld. Bribes qualify at shield transfer, not collection; gifts remain excluded by the 11 October ruling. Original native Karama income cancellation precedes Bureaucrat as an outcome-based inference. User ruled gross-first diversion before occupied Homeworld allocation: fresh classic occupation+Skills now connects gross8→Bank2/native3/occupied3 with original paid8 and contributions preserved; cancellation instead leaves occupied4/native0 and no Bureaucrat use. Original two-odd Guild rounding remains guarded. User also ruled Betrayal holder receipt minus2, connected in the bounded classic Nexus+Skills Betrayal composition. Other combinations remain unfinished. | [Source and connected boundary](BUREAUCRAT_PAYMENTS.md) |
| Leader battle effects | Rihani normal peek and reshuffle precede a separate committed optional draw; ordering is an explicit printed-order inference. Unused excludes actual calls, not mere disclosure. Mentat/Bureaucrat change scores; Sandmaster adds board spice. Owned multi-pile Smuggler debits and Sandmaster victory placement are connected under provisional allocation policies; modifier and exceptional Face Dancer composition remain separate. | [Source contract and prototype](LEADER_BATTLE_EFFECTS.md), [Smuggler allocation](SMUGGLER_BATTLE.md) |
| Source and readiness | Separate published rules, explicit implementation choices and unfinished interpretations; Advanced/expansion gates stay closed. | [Base/Advanced audit](BASE_ADVANCED_READINESS_20260907.md), [checklist](../game/reference.ts) |
diff --git a/game/battle-resolution-quote.ts b/game/battle-resolution-quote.ts
index b6067c21..e8c36fc9 100644
--- a/game/battle-resolution-quote.ts
+++ b/game/battle-resolution-quote.ts
@@ -86,7 +86,11 @@ export type ResolutionCombatant = ResolutionParticipant & {
reinforcementsReserves?: { normal: number; elite: number };
harassWithdraw?: HarassWithdrawContext;
harassSelection?: HarassWithdrawSelection;
- diplomatDefense?: Pick & { card: string };
+ diplomatDefense?: Pick & {
+ card: string;
+ /** Authoritative current battle opted into used-defense contexts. */
+ contextual?: true;
+ };
spice: number;
hand: readonly Card[];
plan: ResolutionPlan;
@@ -381,10 +385,11 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
if (copy === undefined) return defense;
const quote = quoteDiplomatDefense({ assignments: side.leaderSkills ?? [], selectedLeader: side.plan.leader,
weapon: card(side, side.plan.weapon), defense: card(side, side.plan.defense),
- opposingDefense: card(other, other.plan.defense), opposingWeapon: card(other, other.plan.weapon),
+ opposingDefense: card(other, copy?.contextual ? other.lateDefense ?? other.plan.defense : other.plan.defense),
+ opposingWeapon: card(other, other.plan.weapon),
expandedDefenses: true });
- requireQuote(copy && quote && !side.lateDefense && !other.lateDefense &&
- !side.stronghold && !other.stronghold && !input.homeworld &&
+ requireQuote(copy && quote && !side.lateDefense &&
+ (copy.contextual || (!other.lateDefense && !side.stronghold && !other.stronghold && !input.homeworld)) &&
copy.leader === quote.leader && copy.source === quote.source && copy.kind === quote.kind && quote.cards.includes(copy.card),
'The copied Diplomat defense lost its native trainer, committed Worthless or opposing printed defense.');
return copiedDiplomatDefense(quote, copy.card);
diff --git a/game/engine.ts b/game/engine.ts
index 7976b900..3e4e7b8a 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -733,7 +733,7 @@ export type Battle = {
mentatQuestionEvent?: string;
mentatQuestion?: MentatQuestionReceipt;
/** New battles opt into the revealed Diplomat step; legacy battles are not reopened. */
- diplomatDefenseVersion?: 1 | 2;
+ diplomatDefenseVersion?: 1 | 2 | 3;
diplomatDefenseEvent?: string;
diplomatDefense?: DiplomatDefenseReceipt;
/** New battles may pause after the outcome is known, before loser losses. */
@@ -22573,6 +22573,10 @@ function validatePortableSnooper(
!b.lateDefense?.[owner.id],
'You have already added Portable Snooper to this battle.',
);
+ requireRule(
+ b.diplomatDefense?.player !== owner.id || b.diplomatDefense.stage !== 'copied',
+ 'Your copied Diplomat card already supplies a defense.',
+ );
const card = owner.hand.find((c) => c.id === cardId);
requireRule(
card && isPortableSnooper(card),
@@ -22705,10 +22709,15 @@ function battleSpecialWeaponIntegrity(g: Game) {
}
function diplomatDefenseOffer(g: Game): (DiplomatDefenseQuote & { player: string }) | null {
const b = g.battle;
- if (!b?.revealed || !g.leaderSkills || !diplomatDefenseModeSupported(g) ||
- b.lateDefense && Object.keys(b.lateDefense).length) return null;
+ if (!b?.revealed || !g.leaderSkills || !diplomatDefenseModeSupported(g)) return null;
+ const contextual = b.diplomatDefenseVersion === 3;
+ if (!contextual && b.lateDefense && Object.keys(b.lateDefense).length) return null;
+ if (contextual && !b.diplomatDefense && traitorVoters(g, b).some(id => b.traitorCalls[id] === true)) return null;
for (const player of [b.attacker, b.defender]) {
const p = getPlayer(g, player), plan = b.plans[player];
+ // A decline remains historical if its owner later adds a real defense.
+ // Only a fresh copy opportunity needs the owner's unused defense role.
+ if (contextual && b.lateDefense?.[player] && !b.diplomatDefense) continue;
const other = getPlayer(g, b.attacker === player ? b.defender : b.attacker);
const opposingPlan = b.plans[other.id];
if (!plan || !opposingPlan) continue;
@@ -22718,9 +22727,9 @@ function diplomatDefenseOffer(g: Game): (DiplomatDefenseQuote & { player: string
.map(a => ({ skill: a.skill, leader: a.leader, faceUp: !b.leaderSkillHidden?.[player], captured: false })),
selectedLeader: plan.leader,
weapon: cardOf(p, plan.weapon), defense: cardOf(p, plan.defense),
- opposingDefense: cardOf(other, opposingPlan.defense),
+ opposingDefense: cardOf(other, contextual ? b.lateDefense?.[other.id] ?? opposingPlan.defense : opposingPlan.defense),
opposingWeapon: cardOf(other, opposingPlan.weapon),
- expandedDefenses: b.diplomatDefenseVersion === 2,
+ expandedDefenses: b.diplomatDefenseVersion === 2 || contextual,
});
if (quote) return { ...quote, player };
}
@@ -22728,8 +22737,14 @@ function diplomatDefenseOffer(g: Game): (DiplomatDefenseQuote & { player: string
}
function diplomatDefenseFrame(g: Game): string {
const b = g.battle!;
+ // Bind the opponent's used source, not a later own defense after declining.
+ const player = b.diplomatDefenseVersion === 3
+ ? b.diplomatDefense?.player ?? diplomatDefenseOffer(g)?.player : null;
+ const opponent = player ? getPlayer(g, player === b.attacker ? b.defender : b.attacker) : null;
return JSON.stringify({ event: b.event, turn: g.turn, territory: b.territory,
attacker: b.attacker, defender: b.defender, plans: b.plans, hidden: b.leaderSkillHidden,
+ ...(b.diplomatDefenseVersion === 3
+ ? { lateSource: opponent ? cardOf(opponent, b.lateDefense?.[opponent.id] ?? null) ?? null : null } : {}),
cards: [b.attacker, b.defender].map(id => {
const p = getPlayer(g, id), plan = b.plans[id];
return [plan.weapon, plan.defense, plan.leader].map(selected => cardOf(p, selected) ?? null);
@@ -22743,7 +22758,7 @@ function diplomatDefenseIntegrity(g: Game) {
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'diplomatDefense');
if (b?.diplomatDefenseVersion === undefined && b?.diplomatDefense === undefined &&
b?.diplomatDefenseEvent === undefined && !decisions.length) return;
- requireRule(b && (b.diplomatDefenseVersion === 1 || b.diplomatDefenseVersion === 2),
+ requireRule(b && (b.diplomatDefenseVersion === 1 || b.diplomatDefenseVersion === 2 || b.diplomatDefenseVersion === 3),
'The saved Diplomat choice lost its original battle version.');
const receipt = b.diplomatDefense;
const offer = diplomatDefenseOffer(g);
@@ -22766,8 +22781,10 @@ function diplomatDefenseIntegrity(g: Game) {
'The committed Diplomat cards must retain unique physical custody.');
requireRule(decisions.length === (receipt.stage === 'offered' ? 1 : 0),
'The saved Diplomat defense lost or reopened its unique public decision.');
- if (receipt.stage === 'offered') requireRule(Object.keys(b.traitorCalls).length === 0,
- 'Diplomat defense must be chosen before any traitor decisions.');
+ if (receipt.stage === 'offered') requireRule(b.diplomatDefenseVersion === 3
+ ? traitorVoters(g, b).every(id => b.traitorCalls[id] !== true)
+ : Object.keys(b.traitorCalls).length === 0,
+ 'Choose Diplomat defense before a successful traitor declaration or battle outcome.');
for (const decision of decisions) requireRule(decision.player === receipt.player &&
decision.event === receipt.event && decision.source === receipt.source &&
JSON.stringify(decision.cards) === JSON.stringify(receipt.cards),
@@ -22775,11 +22792,11 @@ function diplomatDefenseIntegrity(g: Game) {
}
function offerDiplomatDefense(g: Game): boolean {
const b = g.battle!;
- if ((b.diplomatDefenseVersion !== 1 && b.diplomatDefenseVersion !== 2) || b.diplomatDefense) return false;
+ if ((b.diplomatDefenseVersion !== 1 && b.diplomatDefenseVersion !== 2 && b.diplomatDefenseVersion !== 3) || b.diplomatDefense) return false;
const offer = diplomatDefenseOffer(g);
if (!offer) return false;
- requireRule(b.event && !Object.keys(b.traitorCalls).length,
- 'Diplomat defense needs the current revealed battle before traitor decisions.');
+ requireRule(b.event && (b.diplomatDefenseVersion === 3 || !Object.keys(b.traitorCalls).length),
+ 'Diplomat defense needs the current revealed battle before its unresolved outcome.');
const event = crypto.randomUUID();
const receipt: DiplomatDefenseReceipt = { ...offer, event, battle: b.event, turn: g.turn,
territory: b.territory, frame: diplomatDefenseFrame(g), stage: 'offered', card: null, signature: '' };
@@ -22787,7 +22804,7 @@ function offerDiplomatDefense(g: Game): boolean {
b.diplomatDefense = receipt;
b.diplomatDefenseEvent = event;
g.decision = { kind: 'diplomatDefense', player: offer.player, event, cards: [...offer.cards], source: offer.source };
- log(g, `${getPlayer(g, offer.player).name} may use Diplomat to make one committed Worthless card copy the opposing ${diplomatDefenseLabel(offer.kind)} before traitor decisions.`,
+ log(g, `${getPlayer(g, offer.player).name} may use Diplomat to make one committed Worthless card copy the opposing ${diplomatDefenseLabel(offer.kind)} before battle resolution.`,
{ faction: getPlayer(g, offer.player).faction, name: 'Diplomat defense' });
return true;
}
@@ -22994,7 +23011,8 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
lateDefense: b.lateDefense?.[p.id],
...(b.diplomatDefense?.stage === 'copied' && b.diplomatDefense.player === p.id
? { diplomatDefense: { leader: b.diplomatDefense.leader, card: b.diplomatDefense.card!,
- source: b.diplomatDefense.source, kind: b.diplomatDefense.kind } } : {}),
+ source: b.diplomatDefense.source, kind: b.diplomatDefense.kind,
+ ...(b.diplomatDefenseVersion === 3 ? { contextual: true as const } : {}) } } : {}),
poisonTooth: b.poisonTooth?.[p.id],
stoneMode: b.stoneBurner?.[p.id],
...(aid ? { aid: { donor: p.ally!, amount: aid.amount } } : {}),
@@ -30444,6 +30462,7 @@ function applyActionInner(
`${p.name} played Portable Snooper after revealing their battle plan. It protects against ordinary poison without changing the original plan. Battle and traitor decisions continue.`,
{ faction: p.faction, name: 'Portable Snooper' },
);
+ offerDiplomatDefense(g);
return g;
}
if (t === 'battlePreparationReady') {
@@ -31760,7 +31779,7 @@ function applyActionInner(
log(g, `${p.name} added Portable Snooper after the Homeworld battle plans were revealed.`, {faction: p.faction, name: 'Portable Snooper'});
}
(b.homeworldDefensePassed ??= []).push(id);
- advanceHomeworldReveal(g);
+ if (!offerDiplomatDefense(g)) advanceHomeworldReveal(g);
} else if (decision.kind === 'homeworldExplosion') {
homeworldBattleLossIntegrity(g);
homeworldSubstitutionIntegrity(g);
@@ -33795,7 +33814,7 @@ function applyActionInner(
event: battleEvent,
...(g.leaderSkills ? { smugglerCollectionVersion: 1 as const } : {}),
...(g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
- ...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 2 as const } : {}),
+ ...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 3 as const } : {}),
...(g.leaderSkills && ordinaryLeaderSkillModeSupported(g) ? { diplomatRetreatVersion: 1 as const } : {}),
...(g.ecazTreachery ? { harassAllocationVersion: 1 as const } : {}),
...(byFaction(g, 'richese')
diff --git a/game/reference.ts b/game/reference.ts
index cf9a6910..9e9adaf5 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -4952,7 +4952,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic or supported native banker-income profiles, including the bounded Advanced Moritani roster; ordinary/default starts are unchanged.',
- 'Diplomat may use one committed Worthless as the opponent’s canonical printed defensive role when you played no actual defense: Shield, Snooper, Shield Snooper, defensive Weirding Way or Chemistry, or sealed Portable Snooper. Hybrid protection, Weirding’s non-Shield behavior and Chemistry’s Poison Tooth exception remain distinct. Copying the used role without its activation or own-slot prerequisite is the explicit first-version interpretation. Choose or decline after reveal, before traitors; the Worthless must be discarded even if you win. Already-open base-only battles keep that source window. A native Diplomat can protect its own selected disc; a captive grants no upper band. A surviving trained loser may retreat selected undialed physical counters up to printed/copied disc strength to one empty adjacent non-stronghold before losses. Late/mirror/numeric modifications remain separate.',
+ 'Diplomat may use one committed Worthless as the opponent’s canonical printed defensive role when you played no actual defense: Shield, Snooper, Shield Snooper, defensive Weirding Way/Chemistry, or sealed/actually late-played Portable Snooper. Named protection stays distinct; copying the used role imports no activation, numeric modifier or late-play right. Sealed sources offer after reveal; a new late source may offer before the unresolved outcome, preserving prior false traitor decisions. No fresh opportunity follows a successful traitor declaration. The selected Worthless discards even after a win. Older base/printed-only battles keep their original source windows. Current World/Stronghold contexts retain independent original effects; own earned Carthag may add Snooper to a copied Shield but cannot create protection without a used source. A native Diplomat can protect its selected disc; captives grant no normal band. Surviving trained losers retain the separate printed/copied-strength retreat policy. Mirror/numeric inheritance remains separate.',
'Normal Mentat questions use the user-approved uniform private card-show step before posture and faction powers in supported Leader Skills games. The opponent shows the named held weapon or chooses another held card, even for an exact match or sole eligible card. Weirding Way is a nameable default weapon; Chemistry is not. Cards stay held and need not enter a Battle Plan. New battles activate the question without an extra opt-in; already-opened unversioned battles retain their original flow. Empty hands skip without an answer. Private history records an observation, not a later hand guarantee.',
'Bureaucrat may redirect two spice when one other player pays at least five to a player other than the skill owner, once per phase. Payer costs stay unchanged; declining preserves later use. Bribes qualify at transfer and remain unavailable until Mentat Pause. Supported ordinary/Richese auctions, Guild shipments and paid Tleilaxu revival have connected paths; a separate free-revival award cannot make a smaller price qualify. Allied payer shares never pool to reach five. Trainer or recipient contributions cannot qualify but do not hide an eligible other payer. Public prompts show gross and recipient, not private shares or which payer qualified. Supported classic and native E1/E2/E3 profiles and Homeworld Skills retain original costs and callbacks. Native Guild/Emperor/revival Karama cancellation resolves before Bureaucrat; Richese retains direct seller credit with no invented cancellation window. Basic has no Black Market; free Karama purchases remain normal-only. By user ruling Bureaucrat takes two off gross before occupied Homeworld allocation. Fresh classic occupation plus Skills now connects this: eight becomes two to Bank, three native and three occupied, with full original payer contributions retained. Decline gives four plus four; native income cancellation preserves the separate occupied share and creates no Bureaucrat offer. The low/occupied Guild two-odd contributor rounding boundary is unchanged. By user ruling Guild Nexus Betrayal also carries Bureaucrat on its holder receipt in the bounded classic Nexus plus Skills composition; the holder receives the fee minus two. Emperor gifts remain excluded by user ruling, without threshold disclosure. Other profiles, overlays and full-mode acceptance remain unfinished.',
'Smuggler records existing positive battle-territory piles at public reveal. Provisional modifier policy: cap by copied/printed disc plus declared Leader Skill bonuses and Kwisatz2. Pending spice cannot fund plans. A surviving controller collects even when losing or no forces remain, unless an actual zero-strength weapon effect resolves. Artillery Strike supplies that effect; the ordinary projectile Stunner does not. A sole own Traitor bypasses weapons and retains collection. Partial multi-pile collection asks its owner for exact sectors; all/none and single piles stay automatic. Both the modifier and allocation policies are explicit interpretations, not publisher rulings. Collection precedes Sandmaster; a drained pile receives none. Other module guards remain.',
@@ -4966,7 +4966,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus surviving-leader battle collection with reveal-time guidance and owned multi-pile allocations. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices, optional worm/physical Ambassador destination checkboxes and direct victory-pile placement when several piles remain. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/smuggler-battle-collection.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/sandmaster-victory-spice.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and modifier-inclusive collection plans, exact multi-pile Smuggler allocations, Sandmaster route, worm, physical Ambassador destination collection and victory-pile placement, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody. The 13 October 2026 ruling resolves the captured-card replacement question: the original assignment is retained and no replacement is drawn while it exists; other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
- { area: 'Verification', status: 'Partial', detail: 'Earlier native/optional-module and recovery evidence remains dated in linked contracts. Modified Smuggler adds actual seven-loss KH activation, short/ample reveal caps, shielded Artillery zeroing, sole-own-Traitor precedence and ordinary Stunner collection. Focused23 cases pass. Four390px journeys/32 actions retain winner cleanup and separate Collection; eight four-policy programmes/64 actions use offered modified KH plans. Provisional policy is not a publisher ruling or full-mode/deployed acceptance.', evidence: ['tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Earlier native/Smuggler/optional-module and recovery evidence remains dated in linked contracts. Used-defense capability3 adds preserved false-vote late copying, native-only Homeworld continuation and independently earned Carthag protection, with true-traitor, own-defense and legacy boundaries. Four390px journeys/25 actions reach original Collection; twelve four-policy programmes/40 actions retain legal copy/decline behavior. Printed-role/context scheduling remains provisional, not complete-mode, full strategy or deployed acceptance.', evidence: ['tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
],
},
{
diff --git a/tests/diplomat-context-journey.test.ts b/tests/diplomat-context-journey.test.ts
new file mode 100644
index 00000000..ab4956be
--- /dev/null
+++ b/tests/diplomat-context-journey.test.ts
@@ -0,0 +1,130 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame } from '../game/engine';
+import { weaponKills } from '../game/battle-cards';
+import {
+ applyDiplomatContextStep, assertDiplomatContextCustody, copyDiplomatContextJourney,
+ createDiplomatContextJourney, diplomatContextPlayer, finishDiplomatContextJourney,
+ revealDiplomatContextJourney, settleDiplomatContextJourney,
+} from './fixture-diplomat-context-journey';
+
+void test('original native World vote closes, invader uses actual late Portable, and Diplomat copies only its poison role before typed custody settles', () => {
+ const fixture = createDiplomatContextJourney();
+ const { game, owner, opponent, trainer, enemyLeader, source, worthless, territory } = fixture;
+ assert.ok(source);
+ const shipment = fixture.shipments[0];
+ assert.equal(diplomatContextPlayer(shipment.before, opponent).spice -
+ diplomatContextPlayer(shipment.after, opponent).spice, 2);
+ assert.equal(diplomatContextPlayer(shipment.before, opponent).reserves -
+ diplomatContextPlayer(shipment.after, opponent).reserves, 3);
+ const worlds = viewGame(game, owner).homeworlds!.worlds!;
+ const nativeWorld = worlds.find(world => world.id === territory)!;
+ const salusa = worlds.find(world => world.id === 'homeworld:emperor:salusa')!;
+ assert.equal(nativeWorld.card, 'kaitain');
+ assert.deepEqual(nativeWorld.forces[owner], { normal: 15, elite: 0 });
+ assert.deepEqual(nativeWorld.forces[opponent], { normal: 3, elite: 0 });
+ assert.deepEqual(salusa.forces[owner], { normal: 0, elite: 5 });
+
+ const revealed = revealDiplomatContextJourney(fixture);
+ assert.equal(revealed.battle!.diplomatDefense, undefined);
+ assert.deepEqual(viewGame(revealed, owner).battle!.traitorVoters, [owner]);
+ assert.equal(revealed.battle!.plans[opponent].defense, null);
+ const nativeVoted = applyAction(revealed, owner, { type: 'traitorCall', call: false });
+ assert.equal(nativeVoted.decision?.kind, 'homeworldDefense');
+ assert.equal(nativeVoted.decision?.player, opponent);
+ assert.equal(nativeVoted.battle!.traitorCalls[owner], false);
+ assert.equal(nativeVoted.battle!.traitorCalls[opponent], undefined);
+ assert.ok(nativeVoted.decision?.kind === 'homeworldDefense');
+ const used = applyDiplomatContextStep(nativeVoted, { actor: opponent,
+ action: { type: 'decision', event: nativeVoted.decision.event, use: true } });
+ assert.equal(used.decision?.kind, 'diplomatDefense');
+ assert.equal(used.battle!.lateDefense![opponent], source);
+ assert.deepEqual(used.battle!.homeworldDefensePassed, [opponent]);
+ assert.equal(used.battle!.traitorCalls[owner], false);
+ assert.deepEqual(used.battle!.diplomatDefense!.cards, [worthless]);
+ assert.equal(used.battle!.diplomatDefense!.source, source);
+ assert.equal(used.battle!.diplomatDefense!.kind, 'snooper');
+ assert.deepEqual(used.battle!.plans, revealed.battle!.plans);
+ const copied = copyDiplomatContextJourney(used, worthless);
+ assert.throws(() => applyAction(copied, owner, { type: 'traitorCall', call: false }),
+ 'The submitted native-only vote cannot be reopened after the copied defense.');
+ const settled = settleDiplomatContextJourney(copied);
+ assert.equal(settled.lastBattleContext!.winner, owner);
+ assert.equal(diplomatContextPlayer(settled, owner).leaders.find(l => l.id === trainer)!.dead, false);
+ assert.equal(diplomatContextPlayer(settled, opponent).leaders.find(l => l.id === enemyLeader)!.dead, false);
+ const finalWorlds = viewGame(settled, owner).homeworlds!.worlds!;
+ assert.deepEqual(finalWorlds.find(world => world.id === territory)!.forces[owner], nativeWorld.forces[owner]);
+ assert.deepEqual(finalWorlds.find(world => world.id === 'homeworld:emperor:salusa')!.forces[owner],
+ { normal: 0, elite: 5 }, 'Copying a defense never spends or relabels Sardaukar.');
+ assert.deepEqual(finalWorlds.find(world => world.id === territory)!.forces[opponent] ?? { normal: 0, elite: 0 },
+ { normal: 0, elite: 0 });
+ assert.equal(diplomatContextPlayer(settled, opponent).tanks, 3);
+ assert.equal(diplomatContextPlayer(settled, owner).elites!.tanks, 0);
+ assert.equal(settled.discard.filter(card => card.id === worthless).length, 1);
+ assert.equal(settled.discard.filter(card => card.id === source).length, 1);
+ assert.equal(diplomatContextPlayer(settled, owner).hand.some(card => card.id === worthless), false);
+ assertDiplomatContextCustody(settled);
+ assert.equal(finishDiplomatContextJourney(settled).phase, 7);
+});
+
+void test('an actually earned Carthag layers its own Snooper onto a copied printed Shield while preserving physical custody', () => {
+ const fixture = createDiplomatContextJourney({ kind: 'held-carthag' });
+ const { owner, opponent, trainer, enemyLeader, worthless, source, firstMentat } = fixture;
+ assert.ok(firstMentat && source);
+ assert.equal(firstMentat.before.strongholdCards!.owners.carthag, null);
+ assert.equal(firstMentat.before.players.find(p => p.id === owner)!.forces['carthag:11'], 1);
+ assert.equal(firstMentat.after.strongholdCards!.owners.carthag, owner);
+ assert.equal(fixture.game.strongholdCards!.owners.carthag, owner);
+ assert.equal(fixture.game.players.find(p => p.id === opponent)!.forces['carthag:11'], 1);
+ for (const shipment of fixture.shipments) {
+ assert.equal(diplomatContextPlayer(shipment.before, shipment.step.actor).reserves -
+ diplomatContextPlayer(shipment.after, shipment.step.actor).reserves, 1);
+ assert.equal(diplomatContextPlayer(shipment.before, shipment.step.actor).spice -
+ diplomatContextPlayer(shipment.after, shipment.step.actor).spice, 1);
+ }
+ assert.equal(weaponKills(
+ diplomatContextPlayer(fixture.game, opponent).hand.find(card => card.id === fixture.enemyWeapon),
+ diplomatContextPlayer(fixture.game, opponent).hand.find(card => card.id === source),
+ ), true, 'The exact copied printed Shield by itself does not protect against the exact poison weapon.');
+ const revealed = revealDiplomatContextJourney(fixture);
+ assert.equal(revealed.battle!.diplomatDefense!.source, source);
+ assert.equal(revealed.battle!.diplomatDefense!.kind, 'shield');
+ const settled = settleDiplomatContextJourney(copyDiplomatContextJourney(revealed, worthless));
+ assert.equal(settled.lastBattleContext!.winner, owner);
+ assert.equal(diplomatContextPlayer(settled, owner).leaders.find(l => l.id === trainer)!.dead, false,
+ 'Shield alone cannot stop poison: the holder’s original Carthag independently supplies Snooper.');
+ assert.equal(diplomatContextPlayer(settled, opponent).leaders.find(l => l.id === enemyLeader)!.dead, false);
+ assert.equal(diplomatContextPlayer(settled, owner).forces['carthag:11'], 1);
+ assert.equal(diplomatContextPlayer(settled, owner).tanks, 0);
+ assert.equal(diplomatContextPlayer(settled, owner).elites!.tanks, 0);
+ assert.equal(diplomatContextPlayer(settled, opponent).tanks, 1);
+ assert.equal(settled.strongholdCards!.owners.carthag, owner);
+ assert.equal(settled.discard.filter(card => card.id === worthless).length, 1);
+ assert.equal(settled.discard.filter(card => card.id === source).length, 1);
+ assert.equal(diplomatContextPlayer(settled, owner).hand.some(card => card.id === source), false);
+ assertDiplomatContextCustody(settled);
+ assert.equal(finishDiplomatContextJourney(settled).phase, 7);
+});
+
+void test('an earned Carthag with only a physical Worthless and no opposing actual defense supplies neither a copy source nor poison protection', () => {
+ const fixture = createDiplomatContextJourney({ kind: 'empty-carthag' });
+ const { owner, opponent, trainer, worthless } = fixture;
+ assert.equal(fixture.firstMentat!.after.strongholdCards!.owners.carthag, owner);
+ const revealed = revealDiplomatContextJourney(fixture);
+ assert.equal(revealed.battle!.plans[owner].weapon, worthless);
+ assert.equal(revealed.battle!.plans[owner].defense, null);
+ assert.equal(revealed.battle!.plans[opponent].defense, null);
+ assert.equal(revealed.battle!.diplomatDefense, undefined);
+ assert.notEqual(revealed.decision?.kind, 'diplomatDefense');
+ const settled = settleDiplomatContextJourney(revealed);
+ assert.equal(diplomatContextPlayer(settled, owner).leaders.find(l => l.id === trainer)!.dead, true);
+ assert.equal(settled.lastBattleContext!.winner, opponent);
+ assert.equal(settled.strongholdCards!.owners.carthag, owner,
+ 'Battle occupancy does not replace the original END Mentat card owner.');
+ assert.equal(diplomatContextPlayer(settled, owner).forces['carthag:11'] ?? 0, 0);
+ assert.equal(diplomatContextPlayer(settled, owner).tanks, 1);
+ assert.equal(diplomatContextPlayer(settled, owner).elites!.tanks, 0);
+ assert.equal(settled.discard.filter(card => card.id === worthless).length, 1);
+ assertDiplomatContextCustody(settled);
+});
+
diff --git a/tests/diplomat-late-journey.test.ts b/tests/diplomat-late-journey.test.ts
new file mode 100644
index 00000000..c5963881
--- /dev/null
+++ b/tests/diplomat-late-journey.test.ts
@@ -0,0 +1,118 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { advancedNativePlayer, assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
+import {
+ createDiplomatLateJourney, finishDiplomatLateJourney, settleDiplomatLateJourney,
+ type DiplomatLateJourney, type DiplomatLateJourneyStep,
+} from './fixture-diplomat-late-journey';
+
+function assertOutcome(game: Game, fixture: DiplomatLateJourney, branch: 'copy' | 'decline') {
+ const expected = fixture.expected[branch];
+ const own = advancedNativePlayer(game, 'emperor'), opposing = advancedNativePlayer(game, 'guild');
+ assert.equal(own.leaders.find(l => l.id === fixture.trainer)!.dead, expected.trainerDead);
+ assert.equal(opposing.leaders.find(l => l.id === fixture.opposingLeader)!.dead, false,
+ 'The actual Portable protects its original holder in either branch.');
+ assert.equal(own.forces[fixture.key] ?? 0, expected.ownForces);
+ assert.equal(opposing.forces[fixture.key] ?? 0, expected.opposingForces);
+ assert.equal(own.tanks, expected.ownTanks);
+ assert.equal(opposing.tanks, expected.opposingTanks);
+ assert.equal(game.lastBattleContext!.winner, expected.winner);
+ assert.equal(game.lastBattleContext!.event, fixture.event);
+ for (const [id, count] of [
+ [fixture.ownPoison, expected.ownPoisonDiscards],
+ [fixture.opposingPoison, expected.opposingPoisonDiscards],
+ [fixture.portable, expected.portableDiscards],
+ [fixture.worthless, expected.worthlessDiscards],
+ ] as const) {
+ assert.equal(game.discard.filter(c => c.id === id).length, count, id);
+ const physical = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
+ ...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)];
+ assert.equal(physical.filter(c => c.id === id).length, 1, 'Each original played card keeps exactly one physical custodian.');
+ }
+ assertAdvancedNativeCustody(game);
+}
+
+void test('genuine paid Richese programme offers the used late Portable after Emperor already declined traitor, then mandatory Worthless copy survives', () => {
+ const f = createDiplomatLateJourney();
+ for (const shipment of f.shipments) {
+ const before = shipment.before.players.find(p => p.id === shipment.step.actor)!;
+ const after = shipment.after.players.find(p => p.id === shipment.step.actor)!;
+ assert.equal(before.spice - after.spice, shipment.step.actor === f.owner ? 4 : 2);
+ assert.equal(before.reserves - after.reserves, 2);
+ assert.equal(after.forces['south_mesa:5'], 2);
+ }
+ for (const movement of f.movements) {
+ const after = movement.after.players.find(p => p.id === movement.step.actor)!;
+ assert.equal(after.forces[f.key], 2);
+ assert.equal(after.forces['south_mesa:5'] ?? 0, 0);
+ }
+ assert.equal(f.game.spice[f.key], 8);
+ assert.ok(!f.revealed.battle!.diplomatDefense);
+ assert.notEqual(f.revealed.decision?.kind, 'diplomatDefense');
+ assert.equal(f.revealed.battle!.traitorCalls[f.owner], undefined);
+ assert.equal(viewGame(f.revealed, f.owner).battle!.cards.some(c => c.id === f.portable), false);
+ assert.deepEqual(f.beforeLate.battle!.traitorCalls, { [f.owner]: false });
+ assert.equal(viewGame(f.beforeLate, f.opponent).portableSnooper!.blocked, null);
+ const offered = f.offered;
+ assert.ok(offered.decision?.kind === 'diplomatDefense');
+ assert.equal(offered.decision.player, f.owner);
+ assert.equal(offered.decision.source, f.portable);
+ assert.deepEqual(offered.decision.cards, [f.worthless]);
+ assert.equal(offered.battle!.diplomatDefense!.kind, 'snooper');
+ assert.equal(offered.battle!.lateDefense![f.opponent], f.portable);
+ assert.deepEqual(offered.battle!.plans, f.revealed.battle!.plans);
+ assert.equal(offered.battle!.event, f.event);
+ assert.deepEqual(offered.battle!.traitorCalls, { [f.owner]: false });
+ for (const actor of [f.owner, f.opponent, f.observer])
+ assert.ok(viewGame(offered, actor).battle!.cards.some(c => c.id === f.portable));
+ const copied = applyAction(offered, f.copyAction.actor, f.copyAction.action);
+ assert.equal(copied.battle!.diplomatDefense!.stage, 'copied');
+ assert.deepEqual(copied.battle!.plans, f.revealed.battle!.plans);
+ assert.deepEqual(copied.battle!.traitorCalls, { [f.owner]: false });
+ assert.deepEqual(viewGame(copied, f.owner).battle!.traitorVoters.filter(id => copied.battle!.traitorCalls[id] === undefined),
+ f.expected.copy.remainingVoters);
+ assert.ok(advancedNativePlayer(copied, 'emperor').hand.some(c => c.id === f.worthless),
+ 'Copy selection reserves the original Worthless until real resolution.');
+ const steps: DiplomatLateJourneyStep[] = [];
+ const { beforeResolution, cleanup } = settleDiplomatLateJourney(copied, f, steps);
+ assert.deepEqual(beforeResolution.battle!.traitorCalls, { [f.owner]: false });
+ assert.equal(cleanup.decision?.kind, 'battleCards');
+ assert.ok(cleanup.decision?.kind === 'battleCards');
+ assert.equal(cleanup.decision.player, f.owner);
+ assert.ok(cleanup.decision.cards.includes(f.ownPoison));
+ assert.ok(!cleanup.decision.cards.includes(f.worthless), 'The selected Worthless is mandatory, not optional winner cleanup.');
+ assertOutcome(cleanup, f, 'copy');
+ const collected = finishDiplomatLateJourney(cleanup, steps);
+ assert.equal(collected.spice[f.key], 8 - f.expected.copy.collection);
+ assert.equal(advancedNativePlayer(collected, 'emperor').spice - advancedNativePlayer(cleanup, 'emperor').spice,
+ f.expected.copy.collection);
+ assert.ok(advancedNativePlayer(collected, 'emperor').hand.some(c => c.id === f.ownPoison));
+ assertOutcome(collected, f, 'copy');
+});
+
+void test('declining that same genuine late offer leaves poison lethal and Guild retains its original Portable through winner cleanup and Collection', () => {
+ const f = createDiplomatLateJourney();
+ assert.ok(f.offered.decision?.kind === 'diplomatDefense');
+ const declined = applyAction(f.offered, f.declineAction.actor, f.declineAction.action);
+ assert.equal(declined.battle!.diplomatDefense!.stage, 'declined');
+ assert.equal(declined.battle!.diplomatDefense!.card, null);
+ assert.deepEqual(declined.battle!.traitorCalls, { [f.owner]: false });
+ assert.deepEqual(declined.battle!.plans, f.revealed.battle!.plans);
+ assert.equal(declined.battle!.event, f.event);
+ assert.deepEqual(viewGame(declined, f.owner).battle!.traitorVoters.filter(id => declined.battle!.traitorCalls[id] === undefined),
+ f.expected.decline.remainingVoters);
+ assert.ok(advancedNativePlayer(declined, 'emperor').hand.some(c => c.id === f.worthless));
+ const steps: DiplomatLateJourneyStep[] = [];
+ const { cleanup } = settleDiplomatLateJourney(declined, f, steps);
+ assert.ok(cleanup.decision?.kind === 'battleCards');
+ assert.equal(cleanup.decision.player, f.opponent);
+ assert.ok(cleanup.decision.cards.includes(f.portable));
+ assert.ok(cleanup.decision.cards.includes(f.opposingPoison));
+ assertOutcome(cleanup, f, 'decline');
+ const collected = finishDiplomatLateJourney(cleanup, steps);
+ assert.equal(collected.spice[f.key], 8 - f.expected.decline.collection);
+ assert.ok(advancedNativePlayer(collected, 'guild').hand.some(c => c.id === f.portable));
+ assert.ok(advancedNativePlayer(collected, 'guild').hand.some(c => c.id === f.opposingPoison));
+ assertOutcome(collected, f, 'decline');
+});
diff --git a/tests/diplomat-used-defense.test.ts b/tests/diplomat-used-defense.test.ts
new file mode 100644
index 00000000..fe04493c
--- /dev/null
+++ b/tests/diplomat-used-defense.test.ts
@@ -0,0 +1,112 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { applyAction, RuleError, viewGame, type Game } from '../game/engine';
+import {
+ completedAdvancedNativeSkillsGame, stageAdvancedNativeSkillBattle,
+ openAdvancedNativeSkillBattle, finishAdvancedNativeSkillAftermath,
+} from './fixture-advanced-native-skills';
+import { takeHarassCard } from './fixture-harass-withdraw';
+
+const player = (game: Game, id: string) => game.players.find(p => p.id === id)!;
+
+/** Conserved rule-unit position after original setup, not a played phase history. */
+function usedDefense(source: 'late' | 'shield' = 'late', legacy = false) {
+ let game = stageAdvancedNativeSkillBattle(completedAdvancedNativeSkillsGame({
+ family: 'richese', requestedSkill: 'diplomat', skillOwner: 'emperor', opponents: ['guild'],
+ }), 'emperor', 'guild');
+ const worthless = takeHarassCard(game, 'emperor', game.deck.find(c => c.kind === 'worthless')!.id);
+ const ownPoison = source === 'late'
+ ? takeHarassCard(game, 'emperor', game.deck.find(c => c.kind === 'poison')!.id) : null;
+ const enemyPoison = takeHarassCard(game, 'guild', game.deck.find(c => c.kind === 'poison')!.id);
+ const shield = source === 'shield'
+ ? takeHarassCard(game, 'guild', game.deck.find(c => c.kind === 'shield')!.id) : null;
+ const index = game.richeseCache!.findIndex(c => c.effect === 'portableSnooper');
+ assert.ok(index >= 0);
+ const portable = game.richeseCache!.splice(index, 1)[0];
+ const lateOwner = source === 'late' ? 'guild' : 'emperor';
+ player(game, lateOwner).hand.push(portable);
+ game = openAdvancedNativeSkillBattle(game, 'emperor', 'guild');
+ if (legacy) game.battle!.diplomatDefenseVersion = 2;
+ game = applyAction(game, 'emperor', { type: 'battlePlan', leader: 'emperor-0', dial: 0, support: 0,
+ weapon: ownPoison?.id ?? worthless.id, defense: ownPoison ? worthless.id : null });
+ game = applyAction(game, 'guild', { type: 'battlePlan', leader: 'guild-1', dial: 0, support: 0,
+ weapon: enemyPoison.id, defense: shield?.id });
+ return { game, worthless: worthless.id, portable: portable.id, lateOwner };
+}
+
+function copy(game: Game, card: string | null) {
+ assert.ok(game.decision?.kind === 'diplomatDefense');
+ return applyAction(game, 'emperor', { type: 'decision', event: game.decision!.event, card });
+}
+
+void test('a newly used late defense offers its actual role without reopening an earlier false traitor vote', () => {
+ const f = usedDefense();
+ let game = applyAction(f.game, 'emperor', { type: 'traitorCall', call: false });
+ game = applyAction(game, 'guild', { type: 'portableSnooper', event: game.battle!.event, card: f.portable });
+ assert.equal(game.decision?.kind, 'diplomatDefense');
+ assert.equal(game.battle!.traitorCalls.emperor, false);
+ assert.equal(game.battle!.diplomatDefense!.source, f.portable);
+ assert.equal(game.battle!.diplomatDefense!.kind, 'snooper');
+ assert.ok(viewGame(game, 'emperor').battle!.cards.some(c => c.id === f.portable));
+ const plans = structuredClone(game.battle!.plans);
+ game = copy(game, f.worthless);
+ assert.deepEqual(game.battle!.plans, plans);
+ const beforeDuplicate = structuredClone(game);
+ assert.throws(() => applyAction(game, 'emperor', { type: 'traitorCall', call: false }), RuleError);
+ assert.deepEqual(game, beforeDuplicate);
+ game = finishAdvancedNativeSkillAftermath(applyAction(game, 'guild', { type: 'traitorCall', call: false }));
+ assert.equal(player(game, 'emperor').leaders.find(l => l.id === 'emperor-0')!.dead, false);
+ assert.equal(game.discard.filter(c => c.id === f.worthless).length, 1);
+ assert.equal(game.discard.filter(c => c.id === f.portable).length, 1);
+});
+
+void test('declining an earlier copy preserves a later own Portable Snooper without reopening the skill', () => {
+ const f = usedDefense('shield');
+ let game = copy(f.game, null);
+ game = applyAction(game, 'emperor', { type: 'portableSnooper', event: game.battle!.event, card: f.portable });
+ assert.equal(game.decision, null);
+ assert.equal(game.battle!.diplomatDefense!.stage, 'declined');
+ assert.equal(game.battle!.lateDefense!.emperor, f.portable);
+ assert.ok(viewGame(game, 'guild').battle!.cards.some(c => c.id === f.portable));
+ game = applyAction(game, 'emperor', { type: 'traitorCall', call: false });
+ game = finishAdvancedNativeSkillAftermath(applyAction(game, 'guild', { type: 'traitorCall', call: false }));
+ assert.equal(player(game, 'emperor').leaders.find(l => l.id === 'emperor-0')!.dead, false);
+ assert.ok(player(game, 'emperor').hand.some(c => c.id === f.worthless), 'A declined copy adds no mandatory Worthless disposal');
+});
+
+void test('a copied Diplomat defense cannot gain a second actual defense through the late slot', () => {
+ const f = usedDefense('shield');
+ const game = copy(f.game, f.worthless), before = structuredClone(game);
+ assert.throws(() => applyAction(game, 'emperor', {
+ type: 'portableSnooper', event: game.battle!.event, card: f.portable,
+ }), RuleError);
+ assert.deepEqual(game, before);
+});
+
+void test('a successful traitor declaration does not open a fresh late-defense copy opportunity', () => {
+ const f = usedDefense();
+ const owner = player(f.game, 'emperor'), identity = 'guild-1';
+ if (!owner.traitors.includes(identity)) {
+ const returned = owner.traitors[0], donor = f.game.players.find(p => p.traitors.includes(identity));
+ if (donor) donor.traitors[donor.traitors.indexOf(identity)] = returned;
+ else {
+ const index = f.game.traitorReserve!.indexOf(identity); assert.ok(index >= 0);
+ f.game.traitorReserve![index] = returned;
+ }
+ owner.traitors[0] = identity;
+ }
+ let game = applyAction(f.game, 'emperor', { type: 'traitorCall', call: true });
+ game = applyAction(game, 'guild', { type: 'portableSnooper', event: game.battle!.event, card: f.portable });
+ assert.equal(game.battle!.traitorCalls.emperor, true);
+ assert.equal(game.battle!.diplomatDefense, undefined);
+ assert.equal(game.decision, null);
+});
+
+void test('an already-open printed-only capability keeps its original late-defense continuation', () => {
+ const f = usedDefense('late', true);
+ let game = applyAction(f.game, 'emperor', { type: 'traitorCall', call: false });
+ game = applyAction(game, 'guild', { type: 'portableSnooper', event: game.battle!.event, card: f.portable });
+ assert.equal(game.battle!.traitorCalls.emperor, false);
+ assert.equal(game.battle!.diplomatDefense, undefined);
+ assert.equal(game.decision, null);
+});
diff --git a/tests/fixture-diplomat-context-journey.ts b/tests/fixture-diplomat-context-journey.ts
new file mode 100644
index 00000000..485800bf
--- /dev/null
+++ b/tests/fixture-diplomat-context-journey.ts
@@ -0,0 +1,245 @@
+import assert from 'node:assert/strict';
+import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { type Card } from '../game/cards';
+import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
+import { homeworldForceGroups } from '../game/homeworld-custody';
+import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
+import { validateLeaderSkills } from '../game/leader-skills';
+import { initializeAdvancedNativeSkillsSetup } from './fixture-advanced-native-skills';
+import { nextNativeSkillsStrongholdStep } from './fixture-native-skills-stronghold';
+
+export type DiplomatContextStep = { actor: string; action: Action };
+export type DiplomatContextTransition = { before: Game; step: DiplomatContextStep; after: Game };
+export type DiplomatContextKind = 'homeworld' | 'held-carthag' | 'empty-carthag';
+export type DiplomatContextFixture = {
+ initial: Game;
+ offered: Game;
+ afterSetup: Game;
+ training: DiplomatContextTransition[];
+ shipments: DiplomatContextTransition[];
+ firstMentat: DiplomatContextTransition | null;
+ game: Game;
+ owner: string;
+ opponent: string;
+ trainer: string;
+ enemyLeader: string;
+ territory: string;
+ kind: DiplomatContextKind;
+ worthless: string;
+ source: string | null;
+ ownWeapon: string;
+ enemyWeapon: string;
+ plans: [DiplomatContextStep, DiplomatContextStep];
+ steps: DiplomatContextStep[];
+ staging: string[];
+};
+
+export const diplomatContextQuiet = (game: Game) =>
+ !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
+const plansReady = (game: Game) => diplomatContextQuiet(game) && !!game.battle &&
+ !game.battle.preparation && game.battle.preLeader?.closed !== false;
+export const diplomatContextPlayer = (game: Game, actor: string) => {
+ const player = game.players.find(p => p.id === actor); assert.ok(player); return player;
+};
+
+/** Scalar shuffle/Storm draws only; UUID byte arrays retain native entropy.
+ * The original all14 skill shuffle is still controlled by its existing helper. */
+function scalarEntropy(run: () => T): T {
+ const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ const native = crypto.getRandomValues.bind(crypto);
+ crypto.getRandomValues = (array: V): V => {
+ if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
+ else Reflect.apply(native, crypto, [array]);
+ return array;
+ };
+ try { return run(); }
+ finally {
+ if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
+ else Reflect.deleteProperty(crypto, 'getRandomValues');
+ }
+}
+export function applyDiplomatContextStep(game: Game, step: DiplomatContextStep): Game {
+ return scalarEntropy(() => applyAction(game, step.actor, step.action));
+}
+export function nextDiplomatContextStep(game: Game): DiplomatContextStep {
+ const decision = game.decision;
+ if (decision?.kind === 'homeworldShipmentGuild') return { actor: decision.player,
+ action: { type: 'decision', event: decision.event, allow: true } };
+ if (decision?.kind === 'homeworldDefense' || decision?.kind === 'diplomatDefense')
+ throw Error('Stop at the original human late-defense/copy choice.');
+ const next = nextNativeSkillsStrongholdStep(game);
+ assert.ok(next, 'A genuine contextual programme needs a native continuation.'); return next;
+}
+export function advanceDiplomatContext(
+ state: Game, until: (game: Game) => boolean, steps: DiplomatContextStep[] = [],
+): Game {
+ let game = state;
+ for (let n = 0; !until(game) && n < 1800; n++) {
+ assert.equal(game.status, 'playing');
+ const step = nextDiplomatContextStep(game); steps.push(structuredClone(step));
+ game = applyDiplomatContextStep(game, step);
+ }
+ assert.ok(until(game), 'Original contextual programme did not reach its requested boundary.'); return game;
+}
+
+/** All physical counters, including foreign Homeworld visitors, retain custody. */
+export function assertDiplomatContextCustody(game: Game): void {
+ validateLeaderSkills(game.leaderSkills!, game.players);
+ if (game.homeworlds) homeworldGameIntegrity(game);
+ const homes = game.homeworlds?.custody
+ ? homeworldForceGroups(homeworldContext(game), game.homeworlds.custody) : [];
+ for (const player of game.players) {
+ const visitors = homes.filter(home => home.native !== player.id)
+ .map(home => home.forces[player.id] ?? { normal: 0, elite: 0 });
+ const sum = (groups: Record) => Object.values(groups).reduce((a, b) => a + b, 0);
+ assert.equal(player.reserves + player.tanks + sum(player.forces) +
+ visitors.reduce((n, pool) => n + pool.normal + pool.elite, 0), 20);
+ if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks + sum(player.elites.forces) +
+ visitors.reduce((n, pool) => n + pool.elite, 0), 5);
+ }
+ const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
+ ...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
+ assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
+}
+
+/** Genuine Emperor/Richese/Guild setup, original paid shipment and original
+ * END Mentat. No army, wallet, clock, initiative, owner or receipt is assigned.
+ * Conserved physical card staging below is deliberately NOT an acquisition. */
+export function createDiplomatContextJourney(options: { initial?: Game; kind?: DiplomatContextKind } = {}): DiplomatContextFixture {
+ const kind = options.kind ?? 'homeworld';
+ let game = options.initial ? structuredClone(options.initial)
+ : createGame('DIPLOMATCONTEXT', newPlayer('emperor', 'Emperor', 'emperor'), true, ['choam']);
+ if (!options.initial) for (const faction of ['richese', 'guild'] as const)
+ joinGame(game, newPlayer(faction, faction, faction));
+ assert.equal(game.status, 'lobby'); assert.equal(game.advanced, true);
+ assert.deepEqual(game.players.map(p => p.faction).sort(), ['emperor', 'guild', 'richese']);
+ if (kind === 'homeworld' && !game.homeworlds)
+ game = applyAction(game, game.host, { type: 'homeworlds', enabled: true });
+ if (kind !== 'homeworld' && !game.strongholdCards)
+ game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
+ assert.equal(!!game.homeworlds, kind === 'homeworld');
+ assert.equal(!!game.strongholdCards, kind !== 'homeworld');
+ const initial = structuredClone(game), steps: DiplomatContextStep[] = [];
+ const owner = game.players.find(p => p.faction === 'emperor')!.id;
+ const opponent = game.players.find(p => p.faction === 'guild')!.id;
+ game = scalarEntropy(() => initializeAdvancedNativeSkillsSetup({ family: 'richese',
+ requestedSkill: 'diplomat', skillOwner: 'emperor', opponents: ['guild'], initial: game }));
+ const offered = structuredClone(game), training: DiplomatContextTransition[] = [];
+ const act = (step: DiplomatContextStep) => {
+ steps.push(structuredClone(step)); game = applyDiplomatContextStep(game, step); return game;
+ };
+ for (let n = 0; game.status === 'setup' && n < 200; n++) {
+ let step: DiplomatContextStep;
+ if (game.setupStage === 'leaderSkills') {
+ const actor = Object.keys(game.leaderSkills!.offers)[0];
+ const offer = game.leaderSkills!.offers[actor], view = viewGame(game, actor).leaderSkills!;
+ const skill = actor === owner ? 'diplomat' : offer.cards.find(card => !view.unavailableSkills?.[card]);
+ const leaders = diplomatContextPlayer(game, actor).leaders
+ .filter(leader => view.eligibleLeaders.some(candidate => candidate.id === leader.id))
+ .sort((a, b) => b.strength - a.strength);
+ assert.ok(skill && offer.cards.includes(skill) && leaders[0]);
+ step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leaders[0].id } };
+ } else step = nextDiplomatContextStep(game);
+ const before = structuredClone(game); act(step);
+ if (step.action.type === 'leaderSkill') training.push({ before, step, after: structuredClone(game) });
+ }
+ assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
+ const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === 'diplomat')!.leader;
+ const staging = [
+ 'Original fresh native all14 shuffle offers Diplomat to Emperor; real setup actions select the strongest eligible trainer. Scalar native shuffle and Storm draws are controlled, UUID entropy is untouched.',
+ 'Move six original unopened non-worm Spice Cards to the deck front; no spice pile, phase, force, wallet, owner or battle receipt is staged.',
+ ];
+ const land = game.spiceDeck.filter(card => 'territory' in card).slice(0, 6);
+ for (const [position, card] of land.entries())
+ game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
+ const reach = (until: (state: Game) => boolean) => {
+ game = advanceDiplomatContext(game, until, steps); return game;
+ };
+ const shipments: DiplomatContextTransition[] = [];
+ const ship = (actor: string, action: Action) => {
+ const before = structuredClone(game), step = { actor, action }; act(step);
+ reach(state => diplomatContextQuiet(state) && !state.pendingShipment && !state.pendingHomeworldShipment);
+ shipments.push({ before, step, after: structuredClone(game) });
+ };
+ let firstMentat: DiplomatContextTransition | null = null;
+ if (kind !== 'homeworld') {
+ reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && diplomatContextQuiet(state));
+ ship(owner, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, allyPayment: 0 });
+ reach(state => state.phase === 8 && diplomatContextQuiet(state));
+ assert.equal(game.strongholdCards!.owners.carthag, null);
+ while (game.turn === 1) {
+ const before = structuredClone(game), step = nextDiplomatContextStep(game), after = act(step);
+ if (after.turn === 2) firstMentat = { before, step, after: structuredClone(after) };
+ }
+ assert.ok(firstMentat); assert.equal(game.strongholdCards!.owners.carthag, owner);
+ reach(state => state.turn === 2 && state.phase === 5 && state.active === opponent && diplomatContextQuiet(state));
+ ship(opponent, { type: 'ship', territory: 'carthag', sector: 11, amount: 1, allyPayment: 0 });
+ } else {
+ reach(state => state.turn === 1 && state.phase === 5 && state.active === opponent && diplomatContextQuiet(state));
+ const choice = homeworldShipmentChoice(viewGame(game, opponent), 'homeworld:emperor',
+ { 'homeworld:guild': { normal: 3, elite: 0 } });
+ assert.ok(choice.action, choice.blocked ?? undefined); assert.ok(choice.cost > 0);
+ ship(opponent, choice.action);
+ }
+ reach(state => state.phase === 6 && diplomatContextQuiet(state));
+ // Deliberately exchange custody, never duplicate cards or claim a purchase.
+ // Keep hand capacity independent of random original starting-card ownership.
+ for (const player of game.players) { game.deck.push(...player.hand); player.hand = []; }
+ const held = (actor: string, predicate: (card: Card) => boolean): string => {
+ for (const pool of [game.deck, game.richeseCache ?? []]) {
+ const index = pool.findIndex(predicate);
+ if (index >= 0) {
+ const card = pool.splice(index, 1)[0]; diplomatContextPlayer(game, actor).hand.push(card);
+ staging.push(`Conserved physical ${card.id}: native stock/cache → ${actor}'s hand; not an acquired or paid card.`);
+ return card.id;
+ }
+ }
+ throw Error('Missing original physical card for labelled conserved custody staging.');
+ };
+ staging.push('At quiet Battle entry return original held Treachery to undealt stock before the labelled stock/cache transfers; all physical IDs and total deck/cache inventory are conserved.');
+ const worthless = held(owner, card => card.kind === 'worthless');
+ const ownWeapon = kind === 'homeworld' ? held(owner, card => card.kind === 'poison') : worthless;
+ const enemyWeapon = held(opponent, card => card.kind === 'poison');
+ const source = kind === 'empty-carthag' ? null : held(opponent, card => kind === 'homeworld'
+ ? card.effect === 'portableSnooper' : card.kind === 'shield');
+ const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
+ const enemyLeader = diplomatContextPlayer(game, opponent).leaders
+ .filter(leader => !leader.dead && !trained.has(leader.id)).sort((a, b) => a.strength - b.strength)[0].id;
+ const territory = kind === 'homeworld' ? 'homeworld:emperor' : 'carthag';
+ const actor = game.active!;
+ const choice = viewGame(game, actor).battleChoices.find(battle => battle.territory === territory &&
+ [battle.attacker, battle.defender].includes(owner) && [battle.attacker, battle.defender].includes(opponent));
+ assert.ok(choice && choice.chooser === actor);
+ act({ actor, action: { type: 'chooseBattle', territory, target: actor === owner ? opponent : owner } });
+ reach(plansReady); assert.deepEqual(game.battle!.plans, {});
+ const plans: DiplomatContextFixture['plans'] = [
+ { actor: owner, action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0,
+ weapon: ownWeapon, ...(kind === 'homeworld' ? { defense: worthless } : {}) } },
+ { actor: opponent, action: { type: 'battlePlan', leader: enemyLeader, dial: 0, support: 0,
+ weapon: enemyWeapon, ...(kind === 'held-carthag' ? { defense: source } : {}) } },
+ ];
+ assertDiplomatContextCustody(game);
+ return { initial, offered, afterSetup, training, shipments, firstMentat, game, owner, opponent,
+ trainer, enemyLeader, territory, kind, worthless, source, ownWeapon, enemyWeapon, plans, steps, staging };
+}
+
+export function revealDiplomatContextJourney(fixture: DiplomatContextFixture, steps: DiplomatContextStep[] = []): Game {
+ let game = structuredClone(fixture.game);
+ for (const step of fixture.plans) {
+ steps.push(step); game = applyDiplomatContextStep(game, step);
+ if (!game.battle?.revealed) game = advanceDiplomatContext(game, plansReady, steps);
+ }
+ assert.ok(game.battle?.revealed); return game;
+}
+export function copyDiplomatContextJourney(state: Game, card: string): Game {
+ assert.ok(state.decision?.kind === 'diplomatDefense');
+ return applyDiplomatContextStep(state, { actor: state.decision.player,
+ action: { type: 'decision', event: state.decision.event, card } });
+}
+export function settleDiplomatContextJourney(state: Game, steps: DiplomatContextStep[] = []): Game {
+ return advanceDiplomatContext(state, game => !game.battle && diplomatContextQuiet(game), steps);
+}
+/** Finish original Battle cleanup and stop at the next original Collection phase. */
+export function finishDiplomatContextJourney(state: Game, steps: DiplomatContextStep[] = []): Game {
+ return advanceDiplomatContext(state, game => game.phase === 7 && diplomatContextQuiet(game), steps);
+}
diff --git a/tests/fixture-diplomat-late-journey.ts b/tests/fixture-diplomat-late-journey.ts
new file mode 100644
index 00000000..7e10e07b
--- /dev/null
+++ b/tests/fixture-diplomat-late-journey.ts
@@ -0,0 +1,241 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction, createGame, joinGame, newPlayer, viewGame, type Game,
+} from '../game/engine';
+import {
+ advancedNativePlayer, initializeAdvancedNativeSkillsSetup,
+} from './fixture-advanced-native-skills';
+import { nextSkillsTechBattleStep, type SkillsTechBattleStep } from './fixture-skills-tech-battle';
+
+export type DiplomatLateJourneyStep = SkillsTechBattleStep;
+export type DiplomatLateJourneyTransition = {
+ before: Game; step: DiplomatLateJourneyStep; after: Game;
+};
+export type DiplomatLateJourney = {
+ initial: Game; beforeSetup: Game; afterSetup: Game;
+ training: DiplomatLateJourneyTransition[];
+ shipments: DiplomatLateJourneyTransition[];
+ movements: DiplomatLateJourneyTransition[];
+ beforeCards: Game; beforeBattle: Game; game: Game;
+ revealed: Game; beforeLate: Game; offered: Game;
+ owner: string; opponent: string; observer: string;
+ trainer: string; opposingLeader: string; territory: string; key: string; event: string;
+ ownPoison: string; opposingPoison: string; worthless: string; portable: string;
+ plans: [DiplomatLateJourneyStep, DiplomatLateJourneyStep];
+ ownerVote: DiplomatLateJourneyStep; portableAction: DiplomatLateJourneyStep;
+ copyAction: DiplomatLateJourneyStep; declineAction: DiplomatLateJourneyStep;
+ remainingVote: DiplomatLateJourneyStep;
+ expected: Record<'copy' | 'decline', {
+ winner: string; trainerDead: boolean; ownForces: number; opposingForces: number;
+ ownTanks: number; opposingTanks: number; ownPoisonDiscards: number;
+ opposingPoisonDiscards: number; portableDiscards: number; worthlessDiscards: number;
+ mandatoryWorthless: boolean; collection: number; remainingVoters: string[];
+ }>;
+ steps: DiplomatLateJourneyStep[]; staging: string[];
+};
+
+const quiet = (game: Game) => !game.phaseOpening && !game.response && !game.decision && !game.pendingTreacheryDiscard;
+const plansReady = (game: Game) => quiet(game) && !!game.battle &&
+ !game.battle.preparation && game.battle.preLeader?.closed !== false;
+const act = (game: Game, step: DiplomatLateJourneyStep, steps: DiplomatLateJourneyStep[]) => {
+ steps.push(structuredClone(step)); return applyAction(game, step.actor, step.action);
+};
+
+/** Native no-bid programme; leave the original Portable in its cache until the
+ * explicitly disclosed transfer. Hidden, unselected other trainers do not lend
+ * their battle skills to this two-actor poison comparison. */
+export function nextDiplomatLateJourneyStep(game: Game): DiplomatLateJourneyStep {
+ if (game.decision?.kind === 'leaderSkillVisibility')
+ return { actor: game.decision.player, action: { type: 'leaderSkillVisibility',
+ event: game.decision.event, hide: true } };
+ if (game.decision?.kind === 'richeseBlackMarket')
+ return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event, decline: true } };
+ if (game.decision?.kind === 'richeseDeclaration')
+ return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event, position: 'first' } };
+ if (game.decision?.kind === 'richeseCache') {
+ const card = game.richeseCache!.find(c => c.effect !== 'portableSnooper'); assert.ok(card);
+ return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
+ card: card.id, method: 'onceAround', direction: 'clockwise' } };
+ }
+ if (game.decision?.kind === 'richeseUnbid')
+ return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
+ const lot = game.richeseAuction;
+ if (lot && !lot.outcome && quiet(game)) {
+ const actor = lot.method === 'silent'
+ ? lot.order.find(id => lot.eligible.includes(id) && !Object.hasOwn(lot.sealed, id)) : lot.active;
+ assert.ok(actor);
+ return { actor, action: { type: 'richeseBid', event: lot.event, amount: lot.method === 'silent' ? 0 : null } };
+ }
+ return nextSkillsTechBattleStep(game);
+}
+
+export function advanceDiplomatLateJourney(
+ state: Game, until: (game: Game) => boolean, steps: DiplomatLateJourneyStep[] = [],
+): Game {
+ let game = state;
+ for (let n = 0; !until(game) && n < 1000; n++) {
+ assert.equal(game.status, 'playing');
+ assert.equal(game.turn, 1, 'Continue this original first-turn programme only.');
+ game = act(game, nextDiplomatLateJourneyStep(game), steps);
+ }
+ assert.ok(until(game), 'The original Diplomat programme must reach its requested action window.');
+ return game;
+}
+
+/** Fresh native Richese/Emperor/Guild Advanced all14 setup, actual Diplomat
+ * offer/training, real zero first-Storm dials, paid two-counter South Mesa
+ * shipments and actual movement into a native eight-spice Red Chasm blow.
+ * No live forces, wallets, phase, order, ownership, plan or receipt are assigned.
+ * Only original unopened cards and conserved hand/cache stock are staged below. */
+export function createDiplomatLateJourney(options: { initial?: Game } = {}): DiplomatLateJourney {
+ let initial: Game;
+ if (options.initial) initial = structuredClone(options.initial);
+ else {
+ initial = createGame('DIPLOMATLATE', newPlayer('emperor', 'Emperor', 'emperor'), true, ['choam']);
+ joinGame(initial, newPlayer('richese', 'Richese', 'richese'));
+ joinGame(initial, newPlayer('guild', 'Guild', 'guild'));
+ }
+ assert.equal(initial.status, 'lobby');
+ const setupOptions = { family: 'richese' as const, requestedSkill: 'diplomat' as const,
+ skillOwner: 'emperor' as const, opponents: ['guild'] as const, initial };
+ let game = initializeAdvancedNativeSkillsSetup(setupOptions);
+ const beforeSetup = structuredClone(game);
+ const owner = advancedNativePlayer(game, 'emperor').id;
+ const opponent = advancedNativePlayer(game, 'guild').id;
+ const observer = advancedNativePlayer(game, 'richese').id;
+ const steps: DiplomatLateJourneyStep[] = [], training: DiplomatLateJourneyTransition[] = [];
+ for (let n = 0; game.status === 'setup' && n < 200; n++) {
+ let step: DiplomatLateJourneyStep;
+ if (game.setupStage === 'leaderSkills') {
+ const actor = Object.keys(game.leaderSkills!.offers)[0], offer = game.leaderSkills!.offers[actor];
+ const view = viewGame(game, actor).leaderSkills!;
+ const skill = actor === owner ? 'diplomat' : offer.cards.find(c => !view.unavailableSkills?.[c]);
+ assert.ok(skill && offer.cards.includes(skill) && !view.unavailableSkills?.[skill]);
+ const eligible = game.players.find(p => p.id === actor)!.leaders
+ .filter(l => view.eligibleLeaders.some(candidate => candidate.id === l.id))
+ .sort((a, b) => b.strength - a.strength);
+ assert.ok(eligible[0]);
+ step = { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: eligible[0].id } };
+ } else step = nextDiplomatLateJourneyStep(game);
+ const before = structuredClone(game);
+ game = act(game, step, steps);
+ if (step.action.type === 'leaderSkill') training.push({ before, step, after: structuredClone(game) });
+ }
+ assert.equal(game.status, 'playing');
+ const afterSetup = structuredClone(game);
+ const staging = [
+ 'Control only the first original all14 shuffle so Emperor receives Diplomat; train its strongest original eligible disc with the real leaderSkill action. Other native setup and Traitor choices remain actions.',
+ 'Reorder original unopened Red Chasm and Broken Land non-worm Spice Cards before the first Advanced Spice Blow. Red Chasm receives its printed eight spice through the native phase, not an assigned pile.',
+ ];
+ for (const [position, territory] of ['red_chasm', 'broken_land'].entries()) {
+ const index = game.spiceDeck.findIndex((c, i) => i >= position && 'territory' in c && c.territory === territory);
+ assert.ok(index >= position); game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
+ }
+ const territory = 'red_chasm', key = 'red_chasm:7';
+ const shipments: DiplomatLateJourneyTransition[] = [], movements: DiplomatLateJourneyTransition[] = [];
+ game = advanceDiplomatLateJourney(game, g => g.phase === 5 && quiet(g), steps);
+ assert.equal(game.spice[key], 8);
+ while (game.phase === 5) {
+ if (!quiet(game)) { game = act(game, nextDiplomatLateJourneyStep(game), steps); continue; }
+ const actor = game.active!;
+ if ([owner, opponent].includes(actor)) {
+ const before = structuredClone(game);
+ const shipment: DiplomatLateJourneyStep = { actor, action: {
+ type: 'ship', territory: 'south_mesa', sector: 5, amount: 2, elite: 0, allyPayment: 0,
+ } };
+ game = act(game, shipment, steps);
+ game = advanceDiplomatLateJourney(game, quiet, steps);
+ shipments.push({ before, step: shipment, after: structuredClone(game) });
+ const beforeMove = structuredClone(game);
+ const movement: DiplomatLateJourneyStep = { actor, action: {
+ type: 'move', from: 'south_mesa:5', territory, sector: 7, amount: 2, elite: 0,
+ } };
+ game = act(game, movement, steps);
+ game = advanceDiplomatLateJourney(game, quiet, steps);
+ movements.push({ before: beforeMove, step: movement, after: structuredClone(game) });
+ }
+ game = act(game, { actor, action: { type: 'endMovement' } }, steps);
+ }
+ game = advanceDiplomatLateJourney(game, g => g.phase === 6 && quiet(g), steps);
+ const beforeCards = structuredClone(game);
+ for (const player of game.players) game.deck.push(...player.hand.splice(0));
+ const take = (actor: string, kind: 'poison' | 'worthless') => {
+ const index = game.deck.findIndex(c => c.kind === kind); assert.ok(index >= 0);
+ const card = game.deck.splice(index, 1)[0];
+ game.players.find(p => p.id === actor)!.hand.push(card); return card.id;
+ };
+ const ownPoison = take(owner, 'poison'), opposingPoison = take(opponent, 'poison'), worthless = take(owner, 'worthless');
+ const portableIndex = game.richeseCache!.findIndex(c => c.effect === 'portableSnooper');
+ assert.ok(portableIndex >= 0);
+ const portableCard = game.richeseCache!.splice(portableIndex, 1)[0], portable = portableCard.id;
+ advancedNativePlayer(game, 'guild').hand.push(portableCard);
+ staging.push(`Return every original starting hand to undealt stock, then conservedly transfer original poison ${ownPoison} and Worthless ${worthless} to Emperor, poison ${opposingPoison} to Guild, and original Portable Snooper ${portable} from the still-unbought Richese cache to Guild. No card is created; no paid card acquisition is claimed.`);
+ const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === 'diplomat')!.leader;
+ const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
+ const opposing = advancedNativePlayer(game, 'guild').leaders
+ .filter(l => !l.dead && !trained.has(l.id)).sort((a, b) => a.strength - b.strength)[0];
+ assert.ok(opposing);
+ assert.ok(advancedNativePlayer(game, 'emperor').leaders.find(l => l.id === trainer)!.strength > opposing.strength);
+ const beforeBattle = structuredClone(game);
+ const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === territory);
+ assert.ok(choice);
+ game = act(game, { actor: choice.chooser, action: { type: 'chooseBattle', territory,
+ target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, steps);
+ game = advanceDiplomatLateJourney(game, plansReady, steps);
+ const controls = structuredClone(game), event = game.battle!.event!;
+ const plans: DiplomatLateJourney['plans'] = [
+ { actor: owner, action: { type: 'battlePlan', leader: trainer, dial: 0, support: 0, weapon: ownPoison, defense: worthless } },
+ { actor: opponent, action: { type: 'battlePlan', leader: opposing.id, dial: 0, support: 0, weapon: opposingPoison } },
+ ];
+ for (const plan of plans) {
+ game = act(game, plan, steps); game = advanceDiplomatLateJourney(game, plansReady, steps);
+ }
+ assert.ok(game.battle!.revealed);
+ const revealed = structuredClone(game);
+ const ownerVote: DiplomatLateJourneyStep = { actor: owner, action: { type: 'traitorCall', call: false } };
+ game = act(game, ownerVote, steps);
+ const beforeLate = structuredClone(game);
+ const portableAction: DiplomatLateJourneyStep = { actor: opponent, action: { type: 'portableSnooper', card: portable, event } };
+ game = act(game, portableAction, steps);
+ assert.equal(game.decision?.kind, 'diplomatDefense');
+ assert.ok(game.decision?.kind === 'diplomatDefense');
+ const copyAction: DiplomatLateJourneyStep = { actor: owner,
+ action: { type: 'decision', event: game.decision.event, card: worthless } };
+ const declineAction: DiplomatLateJourneyStep = { actor: owner,
+ action: { type: 'decision', event: game.decision.event, card: null } };
+ const remainingVote: DiplomatLateJourneyStep = { actor: opponent, action: { type: 'traitorCall', call: false } };
+ return { initial, beforeSetup, afterSetup, training, shipments, movements, beforeCards, beforeBattle,
+ game: controls, revealed, beforeLate, offered: game, owner, opponent, observer, trainer,
+ opposingLeader: opposing.id, territory, key, event, ownPoison, opposingPoison, worthless, portable,
+ plans, ownerVote, portableAction, copyAction, declineAction, remainingVote,
+ expected: {
+ copy: { winner: owner, trainerDead: false, ownForces: 2, opposingForces: 0, ownTanks: 0, opposingTanks: 2,
+ ownPoisonDiscards: 0, opposingPoisonDiscards: 1, portableDiscards: 1, worthlessDiscards: 1,
+ mandatoryWorthless: true, collection: 4, remainingVoters: [opponent] },
+ decline: { winner: opponent, trainerDead: true, ownForces: 0, opposingForces: 2, ownTanks: 2, opposingTanks: 0,
+ ownPoisonDiscards: 1, opposingPoisonDiscards: 0, portableDiscards: 0, worthlessDiscards: 1,
+ mandatoryWorthless: false, collection: 4, remainingVoters: [opponent] },
+ }, steps, staging };
+}
+
+/** Resolve only the original still-unsubmitted Guild vote. Emperor's earlier
+ * false declaration is never replayed. Stop at real winner-card cleanup. */
+export function settleDiplomatLateJourney(
+ state: Game, fixture: DiplomatLateJourney, steps: DiplomatLateJourneyStep[] = [],
+) {
+ assert.equal(state.battle!.traitorCalls[fixture.owner], false);
+ assert.equal(state.battle!.traitorCalls[fixture.opponent], undefined);
+ assert.notEqual(state.decision?.kind, 'diplomatDefense');
+ const beforeResolution = structuredClone(state);
+ const afterVote = act(state, fixture.remainingVote, steps);
+ const cleanup = advanceDiplomatLateJourney(afterVote, g => g.decision?.kind === 'battleCards' && !g.response && !g.phaseOpening, steps);
+ return { beforeResolution, afterVote, cleanup };
+}
+
+/** Keep all optional winner cards; advance original phase-opening passes until
+ * ordinary Collection has run, without manufacturing another battle or vote. */
+export function finishDiplomatLateJourney(state: Game, steps: DiplomatLateJourneyStep[] = []): Game {
+ assert.equal(state.decision?.kind, 'battleCards');
+ const game = act(state, { actor: state.decision!.player, action: { type: 'decision', discard: [] } }, steps);
+ return advanceDiplomatLateJourney(game, g => g.phase === 7 && quiet(g), steps);
+}