Support Moritani assassination of captured and foreign-ghola targets
Retain printed native identity and valid original custody while projecting only uniform potential bounty before commitment. Resolve living/dead targets once, clear selected capture/concealment and retain ordinary Ghola return custody. Selected concealed-outcome disclosure is explicitly provisional. Verified: 14 affected offline files100/100, types/lint and production build; six actual choice/gameplay/GameTable SSR journeys and twelve profile paths. Native capture source47 actions; actual paid Ghola prefix140 actions followed by explicit conserved seven-action battle. No saved-room/D1/reset/restart or deployment operation. Runtime-only driver fixes reused unchanged passed checks.
This commit is contained in:
1 parent
61828150b9
commit
eff668b202
25 files changed
+1041
-57
No files matched your search
@@ -17,6 +17,22 @@ export function MoritaniAssassinate({
|
|||||||
busy: boolean;
|
busy: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [selected, setSelected] = useState('');
|
const [selected, setSelected] = useState('');
|
||||||
|
return <MoritaniAssassinateChoice game={game} act={act} busy={busy} selected={selected} onSelect={setSelected} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoritaniAssassinateChoice({
|
||||||
|
game,
|
||||||
|
act,
|
||||||
|
busy,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
game: GameView;
|
||||||
|
act: (action: Action) => void;
|
||||||
|
busy: boolean;
|
||||||
|
selected: string;
|
||||||
|
onSelect: (card: string) => void;
|
||||||
|
}) {
|
||||||
const offer = game.moritaniAssassinate;
|
const offer = game.moritaniAssassinate;
|
||||||
const decision = game.decision;
|
const decision = game.decision;
|
||||||
if (
|
if (
|
||||||
@@ -30,9 +46,9 @@ export function MoritaniAssassinate({
|
|||||||
const pending = offer.pending;
|
const pending = offer.pending;
|
||||||
const card = pending.cards.find((candidate) => candidate.card === selected) ?? pending.cards[0];
|
const card = pending.cards.find((candidate) => candidate.card === selected) ?? pending.cards[0];
|
||||||
const blocked = busy || !!game.response || !!game.truthtrance;
|
const blocked = busy || !!game.response || !!game.truthtrance;
|
||||||
|
const revealBlocked = blocked || !!pending.blocked;
|
||||||
const opponent = game.players.find((player) => player.id === pending.opponent);
|
const opponent = game.players.find((player) => player.id === pending.opponent);
|
||||||
const factionName = opponent ? faction(opponent.faction).name : 'the opposing faction';
|
const factionName = opponent ? faction(opponent.faction).name : 'the opposing faction';
|
||||||
const selectedLeader = card && game.allLeaders.find((leader) => leader.id === card.card);
|
|
||||||
return (
|
return (
|
||||||
<section className="min-w-0 space-y-3" aria-label="Moritani Assassinate Leaders">
|
<section className="min-w-0 space-y-3" aria-label="Moritani Assassinate Leaders">
|
||||||
<h3 className="font-serif text-xl">Assassinate Leaders</h3>
|
<h3 className="font-serif text-xl">Assassinate Leaders</h3>
|
||||||
@@ -43,7 +59,10 @@ export function MoritaniAssassinate({
|
|||||||
</p>
|
</p>
|
||||||
<p className="fine">
|
<p className="fine">
|
||||||
This advantage is available once against each faction. It cannot be canceled
|
This advantage is available once against each faction. It cannot be canceled
|
||||||
with Karama. A dead target remains a legal choice, but yields no spice.
|
with Karama. The potential bounty is the leader’s printed strength and is
|
||||||
|
awarded only if the target is alive. An already-dead target remains a
|
||||||
|
legal choice and awards no spice. Revealing the physical Traitor Card
|
||||||
|
makes the actual outcome public.
|
||||||
</p>
|
</p>
|
||||||
{pending.blocked && <p className="notice">{pending.blocked}</p>}
|
{pending.blocked && <p className="notice">{pending.blocked}</p>}
|
||||||
{pending.cards.length > 0 ? (
|
{pending.cards.length > 0 ? (
|
||||||
@@ -54,31 +73,32 @@ export function MoritaniAssassinate({
|
|||||||
aria-label="Traitor Card to reveal"
|
aria-label="Traitor Card to reveal"
|
||||||
className="min-h-11 w-full rounded border border-[#65644b] bg-[#171b17] px-3 py-2"
|
className="min-h-11 w-full rounded border border-[#65644b] bg-[#171b17] px-3 py-2"
|
||||||
value={card?.card ?? ''}
|
value={card?.card ?? ''}
|
||||||
disabled={blocked}
|
disabled={revealBlocked}
|
||||||
onChange={(event) => setSelected(event.currentTarget.value)}
|
onChange={(event) => onSelect(event.currentTarget.value)}
|
||||||
>
|
>
|
||||||
{pending.cards.map((candidate) => (
|
{pending.cards.map((candidate) => (
|
||||||
<option key={candidate.card} value={candidate.card}>
|
<option key={candidate.card} value={candidate.card}>
|
||||||
{candidate.name}{candidate.dead ? ' · dead target · 0 spice' : ` · ${candidate.bounty} spice`}
|
{candidate.name} · {candidate.potentialBounty} spice potential bounty
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{card && (
|
{card && (
|
||||||
<div className="rounded-lg border border-[#485542] p-4 space-y-2">
|
<div className="rounded-lg border border-[#485542] p-4 space-y-2">
|
||||||
<p className="m-0"><strong>{card.name}</strong> · {card.dead ? '0 spice; target already dead' : `${card.bounty} spice bounty`}</p>
|
<p className="m-0"><strong>{card.name}</strong> · {card.potentialBounty} spice potential bounty</p>
|
||||||
<p className="m-0 text-sm">Printed leader strength: {selectedLeader?.strength ?? 'unknown'}</p>
|
<p className="m-0 text-sm">Printed leader strength: {card.potentialBounty}</p>
|
||||||
<LeaderInspector
|
<LeaderInspector
|
||||||
kind="traitor"
|
kind="traitor"
|
||||||
assassination="choice"
|
assassination="choice"
|
||||||
identity={{ name: card.name, factionName, strength: selectedLeader?.strength ?? 0 }}
|
identity={{ name: card.name, factionName, strength: card.potentialBounty }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="moritani-assassinate-reveal"
|
||||||
className="min-h-11 whitespace-normal"
|
className="min-h-11 whitespace-normal"
|
||||||
disabled={blocked || !card}
|
disabled={revealBlocked || !card}
|
||||||
onClick={() => card && !blocked && act({ type: 'decision', event: pending.event, card: card.card })}
|
onClick={() => card && !revealBlocked && act({ type: 'decision', event: pending.event, card: card.card })}
|
||||||
>
|
>
|
||||||
Reveal {card?.name ?? 'selected Traitor Card'} publicly
|
Reveal {card?.name ?? 'selected Traitor Card'} publicly
|
||||||
</Button>
|
</Button>
|
||||||
@@ -87,6 +107,7 @@ export function MoritaniAssassinate({
|
|||||||
<p className="notice">No eligible opposing-faction Traitor Card is available for this advantage.</p>
|
<p className="notice">No eligible opposing-faction Traitor Card is available for this advantage.</p>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="moritani-assassinate-decline"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="min-h-11 whitespace-normal"
|
className="min-h-11 whitespace-normal"
|
||||||
disabled={blocked}
|
disabled={blocked}
|
||||||
|
|||||||
@@ -16,6 +16,29 @@ open mode gates.
|
|||||||
|
|
||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
**Moritani captured and foreign-ghola targets — 24 October. Prototyped / Partial.**
|
||||||
|
Assassinate now accepts the opposing printed faction's valid captured or ordinary
|
||||||
|
foreign-ghola target. Pending choices show uniform printed potential bounty;
|
||||||
|
actual concealed death/custody does not change the choice label. A committed
|
||||||
|
reveal kills/pays once if alive, or preserves prior death count for zero bounty.
|
||||||
|
Selected capture/concealment ends; ordinary Ghola return custody, original
|
||||||
|
cleanup and one Mentat replacement remain. Making a selected concealed execution's
|
||||||
|
actual outcome public is explicit provisional policy. Auditor/controller and
|
||||||
|
original module guards remain.
|
||||||
|
[Source, settlement and bounded proof](MORITANI_ASSASSINATE_LEADERS.md#native-target-custody--24-october-2026).
|
||||||
|
|
||||||
|
Disjoint control/capture/Ghola lanes and bounded review pass **14 files100/100**,
|
||||||
|
types/lint/build, six actual choice/gameplay/GameTable SSR journeys and twelve
|
||||||
|
existing-profile continuations (twelve accepted proposals). Genuine47-action
|
||||||
|
native capture/Keep-or-Execute/later loss sources cover both hidden outcomes.
|
||||||
|
The Ghola branch earns a140-action death/paid revival prefix, then explicitly
|
||||||
|
stages a conserved seven-action later battle and physical Traitor swap.
|
||||||
|
Feedback repairs stale projection consumers, a public-roster assertion and
|
||||||
|
private smoke continuation drivers; source-identical checks are reused rather
|
||||||
|
than rerun after driver-only repairs. Full modes, broader combinations and
|
||||||
|
comprehensive/browser/deployed assurance remain open. No saved-room/D1/reset/
|
||||||
|
restart/deployment operation or AI refinement.
|
||||||
|
|
||||||
**Atreides special inspection before reveal — 24 October. Prototyped / Partial.**
|
**Atreides special inspection before reveal — 24 October. Prototyped / Partial.**
|
||||||
Supplied physical27 F now controls the native special Karama deadline. Declining
|
Supplied physical27 F now controls the native special Karama deadline. Declining
|
||||||
the original offer preserves quiet later use with zero or one sealed plans.
|
the original offer preserves quiet later use with zero or one sealed plans.
|
||||||
|
|||||||
@@ -1,5 +1,40 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 24 October 2026 — Moritani native targets under capture or foreign Ghola
|
||||||
|
|
||||||
|
Replace the target-custody exclusion with actual native Harkonnen/Tleilaxu
|
||||||
|
producer identities, retaining canonical original disc/name/value/faction,
|
||||||
|
native controller, shared-Duke and Auditor boundaries. Keep actual life status
|
||||||
|
inside the settlement quote; pending projection contains only canonical identity
|
||||||
|
and printed potential bounty. Persistent human choices and minimal bots share
|
||||||
|
that shape. Revealing a living target kills once for printed Bank bounty;
|
||||||
|
already-dead execution keeps its prior death count and awards zero. Clear the
|
||||||
|
selected capture/concealment, retain ordinary Ghola return custody, then original
|
||||||
|
release/skill/cleanup/Mentat mechanics. Selected concealed-outcome disclosure is
|
||||||
|
provisional under14October; unselected secrecy and mode/roster guards remain.
|
||||||
|
|
||||||
|
Disjoint fixture/control lanes and bounded review pass14 affected files100/100,
|
||||||
|
types/lint/build, six actual choice/gameplay/GameTable SSR journeys through
|
||||||
|
Mentat and twelve legal profile continuations. Both47-action capture outcomes
|
||||||
|
come from real public Advanced setup, held-card selection, paid shipment,
|
||||||
|
movement, capture and subsequent loss against a different Emperor disc. The
|
||||||
|
Ghola branch reuses an actual140-action native death/paid revival prefix with
|
||||||
|
an explicit conserved Traitor swap and seven-action later battle. Its target
|
||||||
|
record is untouched until the actual assassination. This is no unstaged-game,
|
||||||
|
broader custody/combination or full-mode certificate.
|
||||||
|
|
||||||
|
Feedback retains original Collection income, updates old pending-choice shape/
|
||||||
|
value assertions, reads actual public death from the native roster and fixes
|
||||||
|
two further typed consumers. Private smoke now handles owned Face Dancer/other
|
||||||
|
aftermath and original Nexus closing controls before expecting Mentat replacement.
|
||||||
|
The final runtime-only pass verifies unchanged source hashes and reuses the
|
||||||
|
previous passed tests/types/lint/build. No application checks are relabelled as
|
||||||
|
rerun. Source/command/link proof and four prose/reference overlays remain bound.
|
||||||
|
[Source, exact evidence and limits](MORITANI_ASSASSINATE_LEADERS.md#native-target-custody--24-october-2026).
|
||||||
|
No saved-room/D1/reset/restart/deployment operation or AI calibration.
|
||||||
|
Private proof:20261024-moritani-assassinate-target/frozen-integrated-3 plus
|
||||||
|
source-identical frozen-integrated-5/reused-checks evidence.
|
||||||
|
|
||||||
## 24 October 2026 — Atreides special inspection until public reveal
|
## 24 October 2026 — Atreides special inspection until public reveal
|
||||||
|
|
||||||
Use supplied physical27 F's before-reveal deadline after declining the original
|
Use supplied physical27 F's before-reveal deadline after declining the original
|
||||||
|
|||||||
@@ -1,12 +1,107 @@
|
|||||||
# Moritani: Advanced Assassinate Leaders preview
|
# Moritani: Advanced Assassinate Leaders preview
|
||||||
|
|
||||||
Updated18 October2026. **Prototyped / Partial**. Supported fresh native
|
Updated24 October2026. **Prototyped / Partial**. Supported fresh native
|
||||||
Advanced Moritani starts initialize this printed advantage automatically,
|
Advanced Moritani starts initialize this printed advantage automatically,
|
||||||
including plain faction starts and the independent-card profile. Normal lobby
|
including plain faction starts and the independent-card profile. Normal lobby
|
||||||
access is a labelled preview, not complete-mode or publication acceptance.
|
access is a labelled preview, not complete-mode or publication acceptance.
|
||||||
This battle-loss ability is separate from the random Assassination Terror token.
|
This battle-loss ability is separate from the random Assassination Terror token.
|
||||||
Played saves are not assigned fabricated prior-call or opportunity history.
|
Played saves are not assigned fabricated prior-call or opportunity history.
|
||||||
|
|
||||||
|
## Native target custody — 24 October 2026
|
||||||
|
|
||||||
|
The authorized supplied revision, physical **p35 D**, selects a different held
|
||||||
|
Traitor identity of the opposing printed faction. A valid native Harkonnen
|
||||||
|
capture or ordinary Tleilaxu foreign-Ghola return does not rewrite that faction.
|
||||||
|
The target pool now accepts those actual producer flags, including an already
|
||||||
|
dead target. It still requires one canonical disc in the original native roster,
|
||||||
|
matching printed name/value/faction and the original native controller. Unknown
|
||||||
|
custodians, arbitrary foreign controllers, shared Duke's nonexistent Traitor and
|
||||||
|
Auditor's explicit capture/foreign-Ghola prohibitions remain excluded. The older
|
||||||
|
captured-winning-disc work below did not itself enable these targets.
|
||||||
|
|
||||||
|
Pending choices now contain only canonical card/name and printed **potential
|
||||||
|
bounty**. Living, dead, captured, concealed-executed and ordinary foreign-ghola
|
||||||
|
targets have the same choice shape; controls and minimal bots do not receive
|
||||||
|
actual life/capture/Ghola fields to rank or label those choices. Every publicly
|
||||||
|
qualifying loss still gets the same private opportunity, and rivals receive no
|
||||||
|
candidate identities. Public completed history retains the actual awarded bounty.
|
||||||
|
|
||||||
|
Committing the original event-bound physical reveal kills a living target once,
|
||||||
|
clears its territory use and awards printed value. An already-dead target adds
|
||||||
|
no death or spice, retaining its existing execution/death count. Clear only the
|
||||||
|
selected `capturedBy` and `concealed`; retain ordinary `gholaBy` for its existing
|
||||||
|
return/buyback contract. A new death uses the existing global last-native
|
||||||
|
Harkonnen captive-release hook and idempotent skill-return hook. Original
|
||||||
|
cleanup, once-per-printed-faction use, normal-call forfeiture and one physical
|
||||||
|
Mentat retirement/replacement remain unchanged. Declining changes none of the
|
||||||
|
target's custody or bounty and does not spend that faction's use.
|
||||||
|
|
||||||
|
**Selected outcome disclosure is provisional.** Under standing 14 October
|
||||||
|
choose-recommended authority, committing the selected Traitor reveal makes its
|
||||||
|
actual death/zero-bounty outcome public, including a previously concealed
|
||||||
|
execution. Supplied D does not expressly settle that disclosure; the publisher's
|
||||||
|
captured-own-winning-disc answer is not a target-custody FAQ. Unselected discs
|
||||||
|
retain existing secrecy, subject to the ordinary last-native captive release.
|
||||||
|
No mode, module or roster admission is changed.
|
||||||
|
|
||||||
|
The primary fixture earns the held Emperor card, battle, Harkonnen capture,
|
||||||
|
Keep/Execute decision and later Emperor victory against Moritani through actual
|
||||||
|
public native Advanced play. A different surviving Fenring disc supplies the
|
||||||
|
opportunity; Burseg3 remains the selected target. Only original undealt positions,
|
||||||
|
scalar shuffle choices and the actual capture permutation are selected. A second
|
||||||
|
fixture earns the ordinary Guild disc's combat death and paid Tleilaxu foreign
|
||||||
|
revival, then explicitly stages a conserved later Guild/Mori battle and one
|
||||||
|
physical Traitor swap. That branch does not claim a wholly unstaged chronology.
|
||||||
|
The native capture source takes47 actual actions in either branch. The paid
|
||||||
|
Ghola prefix takes140 actual actions; its separate later battle uses seven
|
||||||
|
actual actions after the documented conserved positioning.
|
||||||
|
|
||||||
|
### Current scoped proof
|
||||||
|
|
||||||
|
The frozen **14-file100/100** affected offline union, `npm run check:quick` and
|
||||||
|
production build pass. Six actual choice callbacks/gameplay/full GameTable SSR
|
||||||
|
journeys cover captive, concealed-executed and foreign-ghola reveal/decline,
|
||||||
|
including original physical Mentat replacement. Twelve own-view paths across
|
||||||
|
the existing four profiles accept twelve original actions and finish the same
|
||||||
|
continuation. Ten new native/controlled engine cases, three added quote cases
|
||||||
|
and the current five control cases cover eligibility, uniform choices, actual
|
||||||
|
death/payment, source/event/owner rejection and decline. Earlier trained/native
|
||||||
|
discs and other projected-choice consumers retain their actual rule assertions.
|
||||||
|
|
||||||
|
Narrow feedback first caught a test that ignored Harkonnen's ordinary two-spice
|
||||||
|
stronghold income when battle completion starts Collection. Runtime stays
|
||||||
|
unchanged. First frozen79/82 feedback repairs old choice-schema consumers and
|
||||||
|
a death assertion that read the printed registry instead of the public native
|
||||||
|
roster; four selected cases pass4/4. Subsequent82/82 tests pass, then typecheck
|
||||||
|
finds two further old pending-bounty consumers, which join the final union.
|
||||||
|
|
||||||
|
Final100/100/types/lint/build are in `frozen-integrated-3`. Private smoke feedback
|
||||||
|
first stopped at entering Mentat before queued Nexus replacement, then at an
|
||||||
|
owned Face Dancer choice. The corrected driver explicitly declines optional
|
||||||
|
aftermath and follows original closing controls to the actual receipt. The
|
||||||
|
runtime-only `frozen-integrated-5` checks source equality before reusing those
|
||||||
|
three passed check steps; `reused-checks.json` records their origin. Tests/build
|
||||||
|
were not rerun after these private driver-only repairs.
|
||||||
|
|
||||||
|
All evidence binds base `61828150b951a859eb440f4987c59ef5a0038ba9`, tree
|
||||||
|
`2f72c115cca1bf3afdc61c9cfbcaab568be872f6db3d885d4296ca159c7006bb`
|
||||||
|
(2714 nonignored files). Private logs, rendered controls and source hashes stay
|
||||||
|
outside Git in `20261024-moritani-assassinate-target`; final checkpoint prose/
|
||||||
|
reference overlays are reviewed separately without changing engine/tests.
|
||||||
|
This is no browser/hydration/mobile/deployed or complete-game certification.
|
||||||
|
|
||||||
|
Relevant paths: [quote and projection](../game/moritani-assassinate.ts),
|
||||||
|
[choice controls](../components/moritani-assassinate.tsx),
|
||||||
|
[native capture fixture](../tests/fixture-moritani-assassinate-target.ts),
|
||||||
|
[paid-Ghola prefix and controlled battle](../tests/fixture-moritani-assassinate-ghola-target.ts),
|
||||||
|
[target outcomes](../tests/moritani-assassinate-target.test.ts),
|
||||||
|
[quote cases](../tests/moritani-assassinate-quote.test.ts) and
|
||||||
|
[choice cases](../tests/moritani-assassinate-controls.test.tsx).
|
||||||
|
|
||||||
|
Full modes, wider combinations, comprehensive rules/save/privacy/custody and
|
||||||
|
browser/deployed acceptance, plus full AI, remain unfinished. This prototype
|
||||||
|
does not run a capture-restoration campaign or operate on saved rooms/D1.
|
||||||
|
|
||||||
**18 October registered Skills:** mixed native E1/E2/E3 seats and unused
|
**18 October registered Skills:** mixed native E1/E2/E3 seats and unused
|
||||||
selected decks no longer need Nexus or one exact E3 family for all14. Actual
|
selected decks no longer need Nexus or one exact E3 family for all14. Actual
|
||||||
Mori binds its original Advanced producer; a CHOAM native-source fee and normal
|
Mori binds its original Advanced producer; a CHOAM native-source fee and normal
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
# Rule decision index
|
# Rule decision index
|
||||||
|
|
||||||
|
## Moritani native targets under capture or foreign Ghola — 24 October 2026
|
||||||
|
|
||||||
|
Supplied physical35 D identifies the opposing printed faction and a different
|
||||||
|
held Traitor identity; capture or ordinary foreign-Ghola control does not change
|
||||||
|
that identity. Admit the actual native Harkonnen/Tleilaxu producer flags on the
|
||||||
|
unique original disc, retaining canonical value, native controller and Auditor's
|
||||||
|
explicit exclusions. Pending choices uniformly show printed potential bounty,
|
||||||
|
without actual death/capture/Ghola status. Only a committed physical reveal
|
||||||
|
resolves a new death/bounty or an already-dead zero-bounty result. Clear the
|
||||||
|
selected capture/concealment and retain ordinary `gholaBy`; normal captive
|
||||||
|
release and skill returns retain their existing rules.
|
||||||
|
|
||||||
|
Under the 14 October recommended-policy authority, the selected actual outcome
|
||||||
|
becomes public even after a concealed execution. That disclosure is provisional:
|
||||||
|
the source does not expressly settle it, and the captured-own-winning-disc FAQ
|
||||||
|
does not answer this target question. Unselected discs retain their existing
|
||||||
|
secrecy, subject to normal last-native captive release. No new module or roster
|
||||||
|
permission is granted.
|
||||||
|
[Source, settlement and bounded proof](MORITANI_ASSASSINATE_LEADERS.md#native-target-custody--24-october-2026).
|
||||||
|
|
||||||
## Atreides special inspection before public reveal — 24 October 2026
|
## Atreides special inspection before public reveal — 24 October 2026
|
||||||
|
|
||||||
Supplied physical27 F permits the native Advanced special Karama before Battle
|
Supplied physical27 F permits the native Advanced special Karama before Battle
|
||||||
|
|||||||
+1
-1
@@ -1829,7 +1829,7 @@ function policyActions(g: GameView): Action[] {
|
|||||||
if (d.kind === 'moritaniAssassinate') {
|
if (d.kind === 'moritaniAssassinate') {
|
||||||
const pending = g.moritaniAssassinate?.pending;
|
const pending = g.moritaniAssassinate?.pending;
|
||||||
if (!pending || pending.event !== d.event) return [];
|
if (!pending || pending.event !== d.event) return [];
|
||||||
const cards = [...pending.cards].sort((a,b) => b.bounty-a.bounty || a.card.localeCompare(b.card));
|
const cards = [...pending.cards].sort((a,b) => b.potentialBounty-a.potentialBounty || a.card.localeCompare(b.card));
|
||||||
return [cards.length && !pending.blocked
|
return [cards.length && !pending.blocked
|
||||||
? {type:'decision',event:d.event,card:cards[0].card}
|
? {type:'decision',event:d.event,card:cards[0].card}
|
||||||
: {type:'decision',event:d.event,decline:true}];
|
: {type:'decision',event:d.event,decline:true}];
|
||||||
|
|||||||
+12
-5
@@ -62,7 +62,7 @@ import { captureFremenCunningOccurrence, quoteFremenCunning, quoteFremenCunningR
|
|||||||
import { fremenNexusModeSupported } from './fremen-nexus-profile';
|
import { fremenNexusModeSupported } from './fremen-nexus-profile';
|
||||||
import { createNexusFremenProtection, currentNexusFremenProtectionOwner, answerNexusFremenProtection, validateNexusFremenProtection, type NexusFremenProtectionFrame, type NexusFremenProtectionInput } from './nexus-fremen-protection';
|
import { createNexusFremenProtection, currentNexusFremenProtectionOwner, answerNexusFremenProtection, validateNexusFremenProtection, type NexusFremenProtectionFrame, type NexusFremenProtectionInput } from './nexus-fremen-protection';
|
||||||
import { truthKnowledgeOf } from './truthtrance-knowledge';
|
import { truthKnowledgeOf } from './truthtrance-knowledge';
|
||||||
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, moritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
|
import { createMoritaniAssassinateOpportunity, moritaniAssassinateTrigger, projectedMoritaniAssassinateChoices, quoteMoritaniAssassinate, moritaniAssassinateSignature, validateMoritaniAssassinate, type MoritaniAssassinateState, type MoritaniAssassinateReceipt } from './moritani-assassinate';
|
||||||
import { moritaniTerrorAssassinationPool, moritaniTerrorAssassinationValue } from './moritani-terror-assassination';
|
import { moritaniTerrorAssassinationPool, moritaniTerrorAssassinationValue } from './moritani-terror-assassination';
|
||||||
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
|
import { createNexusGuildSecretAlly, validateNexusGuildSecretAlly, quoteNexusGuildSecretShipment, type NexusGuildSecretAllyReceipt } from './nexus-guild-secret-ally';
|
||||||
import { shipmentAvailable } from './shipment-opportunity';
|
import { shipmentAvailable } from './shipment-opportunity';
|
||||||
@@ -8845,7 +8845,7 @@ function moritaniAssassinateModeSupported(g: Game) {
|
|||||||
function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) {
|
function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) {
|
||||||
const p = getPlayer(g,receipt.owner), opponent = getPlayer(g,receipt.opponent);
|
const p = getPlayer(g,receipt.owner), opponent = getPlayer(g,receipt.opponent);
|
||||||
return {state:g.moritaniAssassinate!,receipt,held:p.traitors,
|
return {state:g.moritaniAssassinate!,receipt,held:p.traitors,
|
||||||
normallyRevealed:p.revealedTraitors ?? [],leaders:opponent.leaders};
|
normallyRevealed:p.revealedTraitors ?? [],leaders:opponent.leaders,custodians:g.players};
|
||||||
}
|
}
|
||||||
function moritaniAssassinateIntegrity(g: Game) {
|
function moritaniAssassinateIntegrity(g: Game) {
|
||||||
const state = g.moritaniAssassinate, resume = g.moritaniAssassinateResume;
|
const state = g.moritaniAssassinate, resume = g.moritaniAssassinateResume;
|
||||||
@@ -8945,7 +8945,7 @@ function projectedMoritaniAssassinate(g: Game, viewer: string) {
|
|||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
const pending = state.opportunities.find(r => r.stage === 'choice');
|
const pending = state.opportunities.find(r => r.stage === 'choice');
|
||||||
const own = viewer === state.owner;
|
const own = viewer === state.owner;
|
||||||
const choices = pending && own ? nexusRule(() => moritaniAssassinateChoices(moritaniAssassinateContext(g,pending))) : null;
|
const choices = pending && own ? nexusRule(() => projectedMoritaniAssassinateChoices(moritaniAssassinateContext(g,pending))) : null;
|
||||||
return {owner:state.owner,blocked:own && state.normalTraitorCall
|
return {owner:state.owner,blocked:own && state.normalTraitorCall
|
||||||
? 'Moritani forfeited Assassinate Leaders for the rest of this game after revealing its Traitor Card normally.' : null,
|
? 'Moritani forfeited Assassinate Leaders for the rest of this game after revealing its Traitor Card normally.' : null,
|
||||||
pending:pending ? {event:pending.event,opponent:pending.opponent,territory:pending.territory,
|
pending:pending ? {event:pending.event,opponent:pending.opponent,territory:pending.territory,
|
||||||
@@ -8973,9 +8973,16 @@ function actMoritaniAssassinate(g: Game, decision: Extract<Decision,{kind:'morit
|
|||||||
target.dead = true;
|
target.dead = true;
|
||||||
target.deaths++;
|
target.deaths++;
|
||||||
delete target.usedAt;
|
delete target.usedAt;
|
||||||
if (getPlayer(g, r.opponent).faction === 'harkonnen') returnCaptives(g, getPlayer(g, r.opponent));
|
|
||||||
finishLeaderSkillCustody(g);
|
|
||||||
}
|
}
|
||||||
|
// The selected public reveal settles this original disc even if a hidden
|
||||||
|
// execution already killed it. Ordinary foreign-ghola return custody stays.
|
||||||
|
delete target.capturedBy;
|
||||||
|
delete target.concealed;
|
||||||
|
if (quote.kill) {
|
||||||
|
const harkonnen = byFaction(g, 'harkonnen');
|
||||||
|
if (harkonnen) returnCaptives(g, harkonnen);
|
||||||
|
}
|
||||||
|
finishLeaderSkillCustody(g);
|
||||||
const owner = getPlayer(g,r.owner);
|
const owner = getPlayer(g,r.owner);
|
||||||
requireRule(Number.isSafeInteger(owner.spice + quote.bounty), 'Assassination income exceeds the supported spice balance.');
|
requireRule(Number.isSafeInteger(owner.spice + quote.bounty), 'Assassination income exceeds the supported spice balance.');
|
||||||
owner.spice += quote.bounty;
|
owner.spice += quote.bounty;
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export type MoritaniAssassinateContext = {
|
|||||||
held: readonly string[];
|
held: readonly string[];
|
||||||
normallyRevealed: readonly string[];
|
normallyRevealed: readonly string[];
|
||||||
leaders: readonly Leader[];
|
leaders: readonly Leader[];
|
||||||
|
/** Native producer identities; never included in the pending player choices. */
|
||||||
|
custodians?: readonly { id: string; faction: FactionId }[];
|
||||||
};
|
};
|
||||||
/** The engine owns the full traitor universe and the actual Mentat draw.
|
/** The engine owns the full traitor universe and the actual Mentat draw.
|
||||||
* These lists verify the still-reserved and permanently removed card zones. */
|
* These lists verify the still-reserved and permanently removed card zones. */
|
||||||
@@ -59,6 +61,12 @@ export type MoritaniAssassinateChoice = {
|
|||||||
bounty: number;
|
bounty: number;
|
||||||
dead: boolean;
|
dead: boolean;
|
||||||
};
|
};
|
||||||
|
export type MoritaniAssassinateProjectedChoice = {
|
||||||
|
card: string;
|
||||||
|
name: string;
|
||||||
|
/** Printed potential only: actual life/capture/Ghola status is not disclosed. */
|
||||||
|
potentialBounty: number;
|
||||||
|
};
|
||||||
|
|
||||||
const opponents: readonly FactionId[] = [
|
const opponents: readonly FactionId[] = [
|
||||||
'atreides',
|
'atreides',
|
||||||
@@ -335,6 +343,9 @@ export function moritaniAssassinateChoices(
|
|||||||
'That Traitor Card is already reserved or removed by Assassinate.';
|
'That Traitor Card is already reserved or removed by Assassinate.';
|
||||||
const matches = leaders.filter((l) => l.id === card),
|
const matches = leaders.filter((l) => l.id === card),
|
||||||
leader = matches[0];
|
leader = matches[0];
|
||||||
|
const nativeCustodian = (id: string | undefined, faction: FactionId) =>
|
||||||
|
!!id && id !== receipt.opponent &&
|
||||||
|
context.custodians?.some(player => player.id === id && player.faction === faction);
|
||||||
if (
|
if (
|
||||||
!reason &&
|
!reason &&
|
||||||
(matches.length !== 1 ||
|
(matches.length !== 1 ||
|
||||||
@@ -343,14 +354,15 @@ export function moritaniAssassinateChoices(
|
|||||||
leader.strength !== native.strength ||
|
leader.strength !== native.strength ||
|
||||||
leader.faction !== receipt.faction ||
|
leader.faction !== receipt.faction ||
|
||||||
typeof leader.dead !== 'boolean' ||
|
typeof leader.dead !== 'boolean' ||
|
||||||
leader.capturedBy ||
|
(leader.capturedBy !== undefined && !nativeCustodian(leader.capturedBy, 'harkonnen')) ||
|
||||||
leader.gholaBy ||
|
(leader.gholaBy !== undefined && !nativeCustodian(leader.gholaBy, 'tleilaxu')) ||
|
||||||
leader.concealed ||
|
(leader.concealed && !nativeCustodian(leader.concealed.captor, 'harkonnen')) ||
|
||||||
|
(card === CHOAM_AUDITOR_ID && (leader.capturedBy || leader.gholaBy || leader.concealed)) ||
|
||||||
(leader.controller !== undefined &&
|
(leader.controller !== undefined &&
|
||||||
leader.controller !== receipt.opponent))
|
leader.controller !== receipt.opponent))
|
||||||
)
|
)
|
||||||
reason =
|
reason =
|
||||||
'Assassinate with missing, altered, captured or foreign-ghola target custody remains pending.';
|
'Assassinate needs one canonical native target with valid original custody.';
|
||||||
if (reason) {
|
if (reason) {
|
||||||
unavailable.push({ card, reason });
|
unavailable.push({ card, reason });
|
||||||
continue;
|
continue;
|
||||||
@@ -371,6 +383,19 @@ export function moritaniAssassinateChoices(
|
|||||||
unavailable,
|
unavailable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/** Pending options do not reveal whether a secret captive has been executed.
|
||||||
|
* Normal, captured, already-dead and foreign-ghola identities use the same shape.
|
||||||
|
* Only a committed reveal resolves actual death and awarded bounty.
|
||||||
|
*/
|
||||||
|
export function projectedMoritaniAssassinateChoices(context: MoritaniAssassinateContext) {
|
||||||
|
const choices = moritaniAssassinateChoices(context);
|
||||||
|
const cards: MoritaniAssassinateProjectedChoice[] = choices.cards.map(choice => ({
|
||||||
|
card: choice.card,
|
||||||
|
name: choice.name,
|
||||||
|
potentialBounty: printed(context.receipt.faction, choice.card)!.strength,
|
||||||
|
}));
|
||||||
|
return { blocked: choices.blocked, cards };
|
||||||
|
}
|
||||||
export function quoteMoritaniAssassinate(
|
export function quoteMoritaniAssassinate(
|
||||||
context: MoritaniAssassinateContext,
|
context: MoritaniAssassinateContext,
|
||||||
card: string,
|
card: string,
|
||||||
|
|||||||
+6
-6
@@ -3421,15 +3421,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'An already-dead named leader remains a legal reveal, awarding no death or spice. During Mentat Pause, set the revealed card aside face up as a marker and draw a new Traitor Card. You can use this advantage only once against each faction in the game.',
|
'An already-dead named leader remains a legal reveal, awarding no death or spice. During Mentat Pause, set the revealed card aside face up as a marker and draw a new Traitor Card. You can use this advantage only once against each faction in the game.',
|
||||||
'A Mentat replacement is an ordinary physical Traitor Card, including the original Ix Hero identity when present. After original owned initialization, retire the victim permanently. The replacement itself may later be legally exchanged, returned and drawn again; keep its old receipt unchanged and count one live card, not one per historical reference.',
|
'A Mentat replacement is an ordinary physical Traitor Card, including the original Ix Hero identity when present. After original owned initialization, retire the victim permanently. The replacement itself may later be legally exchanged, returned and drawn again; keep its old receipt unchanged and count one live card, not one per historical reference.',
|
||||||
'The authorized revised Advanced PDF, physical page35, requires that you have not revealed your Traitor Card normally this game. A normal own-card reveal therefore forfeits Assassinate Leaders for the rest of this game, across turns and opponents. Existing state preserves that forfeiture; Karama has no effect against the ability. This is the authorized source, not a new GF9 erratum.',
|
'The authorized revised Advanced PDF, physical page35, requires that you have not revealed your Traitor Card normally this game. A normal own-card reveal therefore forfeits Assassinate Leaders for the rest of this game, across turns and opponents. Existing state preserves that forfeiture; Karama has no effect against the ability. This is the authorized source, not a new GF9 erratum.',
|
||||||
'The surviving actual played disc need not be native to its victorious controller. Native Ecaz/Duke6, Tleilaxu/foreign Guild5, CHOAM/Auditor2 and Harkonnen captives use the winner’s printed-faction targets. Captivity or Ghola control never rewrites native identity. Auditor has a matching Traitor outside CHOAM’s ordinary five; it can be a different native target for two but not the disc just played. Duke has no Traitor, and Cheap Hero is not a physical opposing disc. Exceptional captured/Ghola targets remain separate.',
|
'The surviving played disc need not be native to its victorious controller. Captivity and Ghola control never rewrite native identity. A different printed-faction target may itself be captured or an ordinary foreign ghola. Pending choices show printed potential bounty without disclosing actual life/custody; the selected public reveal pays only if alive. Its capture/concealment ends, while ordinary ghola return custody remains. Revealing a selected concealed execution’s actual outcome is provisional. Auditor retains its explicit capture/Ghola exclusions; Duke has no Traitor and Cheap Hero is not a physical opposing disc.',
|
||||||
'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Fresh supported plain/card Advanced Moritani starts initialize this printed advantage automatically; Basic never gains it. Standalone or paired Ecaz/Moritani/classic all14 Skills retains optional independent cards, original Tech3+ and Advanced Strongholds2+, with Advanced Harkonnen and mixed-family Skills guards. Played saves do not gain invented prior-call history.',
|
'Terror Assassination chooses a random leader when its token is triggered. It does not use this battle-loss condition, different-leader Traitor Card or once-per-faction allowance. Fresh supported plain/card Advanced Moritani starts initialize this printed advantage automatically; Basic never gains it. Standalone or paired Ecaz/Moritani/classic all14 Skills retains optional independent cards, original Tech3+ and Advanced Strongholds2+, with Advanced Harkonnen and mixed-family Skills guards. Played saves do not gain invented prior-call history.',
|
||||||
],
|
],
|
||||||
checklist: [
|
checklist: [
|
||||||
{area:'Implementation',status:'Partial',detail:'Canonical played-disc/native target semantics remain. Moritani replacement accepts physical Hero separately; permanent victim cards join the shared removed census, while historical replacement IDs may move/recur legally. Exchange, Face Dancer, Betrayal and Rihani consumers bind their own source zone. Current authorization/census, revealed-card reservation and original parent remain.',evidence:['game/moritani-assassinate.ts','game/nexus-traitor-exchange.ts','game/nexus-face-dancers.ts','game/nexus-harkonnen-betrayal.ts','game/rihani-decipherer.ts','game/engine.ts']},
|
{area:'Implementation',status:'Partial',detail:'Canonical native target identity now admits valid captures and ordinary foreign Gholas. Uniform pending potential bounty keeps actual death/custody server-side until reveal; living targets die/pay once, dead targets yield zero. Selected capture/concealment clears, ordinary Ghola return custody and original physical retirement/replacement remain. Auditor/controller and mode guards stay.',evidence:['game/moritani-assassinate.ts','game/engine.ts']},
|
||||||
{area:'Player controls',status:'Partial',detail:'Actual390px readiness reaches private Hero inspector. Original Secret Ally returns historical Hasimir6/Caid3, keeps Esmar3 and restores Mori placement; passing reaches next Storm. Later actual Esmar3 reveal draws one Hasimir6 again, while two victim markers remain public and out of play. No overflow/browser errors.',evidence:['components/moritani-assassinate.tsx','components/nexus-traitors.tsx','components/game-table.tsx']},
|
{area:'Player controls',status:'Partial',detail:'Persistent choices display printed potential bounty uniformly and submit the original event-bound reveal/decline. Actual outcome is public only after commitment; prior Hero/private replacement and public face-up history retain their controls.',evidence:['components/moritani-assassinate.tsx','components/nexus-traitors.tsx','components/game-table.tsx']},
|
||||||
{area:'AI',status:'Partial',detail:'Four Ready policies preserve owned initialization before Hero draw; four mandatory-return policies legally finish the actual augmented hand and restore parent. Each return policy keeps Hasimir in this position; human/scripted old-card return is distinct. No strategic calibration claim.',evidence:['game/bots.ts','game/nexus-traitor-options.ts']},
|
{area:'AI',status:'Partial',detail:'All four profiles use only projected printed potential bounty and preserve original reveal/decline legality. Twelve current own-view capture/execution/Ghola proposals and continuations pass; strategy and calibration remain deferred.',evidence:['game/bots.ts','tests/moritani-assassinate-target.test.ts']},
|
||||||
{area:'Documentation',status:'Partial',detail:'Permanent victim markers, active physical cards and historical draw references are separate. Old absent-zone tuple grammar remains; later markers do not rewrite completed history. Receipt consistency does not authenticate every past save. Target exceptions, private-step UX, Skills/Harkonnen and broader modes remain open.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md','docs/NEXUS_HARKONNEN_RULES.md','docs/NEXUS_TLEILAXU_RULES.md','docs/RULE_DECISIONS.md']},
|
{area:'Documentation',status:'Partial',detail:'Supplied native-target selection, selected concealed-outcome disclosure as provisional policy, retained Ghola loan and explicit controlled staging are recorded. Permanent victim markers and historical draws remain distinct. Wider modes and final assurance remain open.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md','docs/RULE_DECISIONS.md']},
|
||||||
{area:'Verification',status:'Partial',detail:'Affected15-file134/134, eleven original API/policy cases and three actual390px journeys pass. Native original Hero, old-card return, restored placement, second paid battle/retirement and repeated physical draw preserve source history and one live card. Older fixtures now consume actual borrowed Voice before native powers/plans; false ownership/uniqueness and wording/markup pins removed. No saved/deployed/full-mode acceptance.',evidence:['tests/moritani-hero-replacement.test.ts','tests/moritani-replacement-exchange.test.ts','tests/nexus-traitor-exchange.test.ts','tests/nexus-face-dancers.test.ts','tests/nexus-harkonnen-betrayal.test.ts','tests/rihani-face-dancers.test.ts']},
|
{area:'Verification',status:'Partial',detail:'Frozen14-file100/100, types/lint/build, six actual choice/gameplay/table-SSR journeys and twelve legal profile paths pass. Native47-action capture branches and a140-action paid-Ghola prefix plus controlled seven-action battle remain scoped; no complete-mode, broad custody/recovery or browser/deployed certificate.',evidence:['tests/moritani-assassinate-target.test.ts','tests/fixture-moritani-assassinate-target.ts','tests/fixture-moritani-assassinate-ghola-target.ts','tests/moritani-assassinate-quote.test.ts','tests/moritani-assassinate-controls.test.tsx','tests/moritani-hero-replacement.test.ts']},
|
||||||
],
|
],
|
||||||
related: [
|
related: [
|
||||||
'faction-moritani',
|
'faction-moritani',
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ void test('Advanced all14 assassination kills only the held native trainer, retu
|
|||||||
assert.equal(pending.phase, 6);
|
assert.equal(pending.phase, 6);
|
||||||
assert.ok(pending.moritaniAssassinateResume!.continuation.cards.includes(card));
|
assert.ok(pending.moritaniAssassinateResume!.continuation.cards.includes(card));
|
||||||
assert.equal(guild.leaders.find(l => l.id === pending.moritaniAssassinate!.opportunities.at(-1)!.opposingLeader)!.dead, false);
|
assert.equal(guild.leaders.find(l => l.id === pending.moritaniAssassinate!.opportunities.at(-1)!.opposingLeader)!.dead, false);
|
||||||
assert.equal(viewGame(pending, moritani.id).moritaniAssassinate!.pending!.cards.find(c => c.card === target)!.bounty, killed.strength);
|
assert.equal(viewGame(pending, moritani.id).moritaniAssassinate!.pending!.cards.find(c => c.card === target)!.potentialBounty, killed.strength);
|
||||||
assert.deepEqual(viewGame(pending, guild.id).moritaniAssassinate!.pending!.cards, []);
|
assert.deepEqual(viewGame(pending, guild.id).moritaniAssassinate!.pending!.cards, []);
|
||||||
reject(pending, guild.id, action);
|
reject(pending, guild.id, action);
|
||||||
reject(pending, moritani.id, { ...action, event: 'stale' });
|
reject(pending, moritani.id, { ...action, event: 'stale' });
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ void test('Advanced Moritani after-loss assassination kills the actual unused tr
|
|||||||
reject(pending, f.opponent, action); reject(pending, f.owner, { ...action, card: battle.enemyLeader });
|
reject(pending, f.opponent, action); reject(pending, f.owner, { ...action, card: battle.enemyLeader });
|
||||||
const reserve = [...pending.traitorReserve!], held = [...player(pending, f.owner).traitors];
|
const reserve = [...pending.traitorReserve!], held = [...player(pending, f.owner).traitors];
|
||||||
const quote = viewGame(pending, f.owner).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!;
|
const quote = viewGame(pending, f.owner).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!;
|
||||||
assert.equal(quote.bounty, 3);
|
assert.equal(quote.potentialBounty, 3);
|
||||||
let killed = step(pending, { actor: f.owner, action });
|
let killed = step(pending, { actor: f.owner, action });
|
||||||
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.dead, true);
|
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.dead, true);
|
||||||
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1);
|
assert.equal(player(killed, f.opponent).leaders.find(l => l.id === target.id)!.deaths, target.deaths + 1);
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { territory } from '../game/board';
|
||||||
|
import {
|
||||||
|
FOREIGN_GHOLA_DISC,
|
||||||
|
createMoritaniForeignGholaWinnerFixture,
|
||||||
|
} from './fixture-moritani-foreign-ghola-winner';
|
||||||
|
import {
|
||||||
|
advanceMixedE3Moritani,
|
||||||
|
assertMixedE3MoritaniPhysical,
|
||||||
|
mixedE3MoritaniClean,
|
||||||
|
mixedE3MoritaniPlayer,
|
||||||
|
mixedE3MoritaniReload,
|
||||||
|
mixedE3MoritaniTraitorCustody,
|
||||||
|
openMixedE3MoritaniBattle,
|
||||||
|
revealMixedE3MoritaniPlans,
|
||||||
|
settleMixedE3MoritaniBoundary,
|
||||||
|
type MixedE3MoritaniStep,
|
||||||
|
} from './fixture-mixed-e3-moritani-nexus';
|
||||||
|
|
||||||
|
/** Stocks survive the explicitly controlled later position and its real loss. */
|
||||||
|
export function moritaniAssassinateGholaTargetInventory(g: Game) {
|
||||||
|
return {
|
||||||
|
cards: [...g.deck, ...g.discard, ...g.players.flatMap(player => player.hand)]
|
||||||
|
.map(card => card.id).sort(),
|
||||||
|
traitors: mixedE3MoritaniTraitorCustody(g),
|
||||||
|
forces: g.players.map(player => ({
|
||||||
|
id: player.id,
|
||||||
|
total: player.reserves + player.tanks +
|
||||||
|
Object.values(player.forces).reduce((sum, count) => sum + count, 0),
|
||||||
|
elite: (player.elites?.reserves ?? 0) + (player.elites?.tanks ?? 0) +
|
||||||
|
Object.values(player.elites?.forces ?? {}).reduce((sum, count) => sum + count, 0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One disclosed physical swap, including a possible original Face Dancer slot. */
|
||||||
|
function swapHeldTraitor(g: Game, owner: string, target: string) {
|
||||||
|
const player = mixedE3MoritaniPlayer(g, owner);
|
||||||
|
const previous = player.traitors[0];
|
||||||
|
assert.ok(previous, 'Moritani needs one actual held card for the conserved swap.');
|
||||||
|
if (player.traitors.includes(target)) return { previous: target, zone: 'already held' };
|
||||||
|
const before = mixedE3MoritaniTraitorCustody(g);
|
||||||
|
const reserve = g.traitorReserve?.indexOf(target) ?? -1;
|
||||||
|
let zone: string;
|
||||||
|
if (reserve >= 0) {
|
||||||
|
g.traitorReserve![reserve] = previous;
|
||||||
|
zone = 'original Traitor reserve';
|
||||||
|
} else {
|
||||||
|
const holder = g.players.find(seat => seat.id !== owner && seat.traitors.includes(target));
|
||||||
|
if (holder) {
|
||||||
|
holder.traitors[holder.traitors.indexOf(target)] = previous;
|
||||||
|
zone = `${holder.faction} held Traitor Card`;
|
||||||
|
} else {
|
||||||
|
const dancerOwner = g.players.find(seat => seat.faceDancers?.some(card => card.leader === target));
|
||||||
|
const dancer = dancerOwner?.faceDancers?.find(card => card.leader === target);
|
||||||
|
assert.ok(dancer && !dancer.revealed,
|
||||||
|
'The selected card must exist in one original unrevealed physical zone.');
|
||||||
|
dancer.leader = previous;
|
||||||
|
zone = `${dancerOwner!.faction} unrevealed Face Dancer slot`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
player.traitors[0] = target;
|
||||||
|
assert.deepEqual(mixedE3MoritaniTraitorCustody(g), before,
|
||||||
|
'The controlled swap must conserve every physical Traitor Card.');
|
||||||
|
return { previous, zone };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genuine native death and paid foreign revival, followed by a deliberately
|
||||||
|
* controlled conserved later battle. This is not a complete unstaged game.
|
||||||
|
* The target remains the engine-produced Guild disc in its original roster;
|
||||||
|
* no target life, death history, capture or Ghola field is assigned here.
|
||||||
|
*/
|
||||||
|
export function createMoritaniAssassinateGholaTargetFixture() {
|
||||||
|
const source = createMoritaniForeignGholaWinnerFixture();
|
||||||
|
const prefix = mixedE3MoritaniReload(source.revival.after);
|
||||||
|
const owner = source.moritani, opponent = source.guild, target = FOREIGN_GHOLA_DISC;
|
||||||
|
const originalTarget = structuredClone(mixedE3MoritaniPlayer(prefix, opponent)
|
||||||
|
.leaders.find(leader => leader.id === target)!);
|
||||||
|
assert.equal(originalTarget.dead, false);
|
||||||
|
assert.equal(originalTarget.gholaBy, source.tleilaxu);
|
||||||
|
assert.equal(originalTarget.faction, 'guild');
|
||||||
|
assert.ok(prefix.advanced && prefix.nexusCards && !prefix.leaderSkills);
|
||||||
|
assert.equal(prefix.phase, 4);
|
||||||
|
assert.ok(mixedE3MoritaniClean(prefix) && !prefix.pendingRevival && !prefix.battle);
|
||||||
|
assert.equal(prefix.moritaniAssassinate!.opportunities.length, 0);
|
||||||
|
assertMixedE3MoritaniPhysical(prefix);
|
||||||
|
const originalInventory = moritaniAssassinateGholaTargetInventory(prefix);
|
||||||
|
|
||||||
|
// The source factory continues farther for its own winning-disc witness.
|
||||||
|
// Keep only its actual action prefix through this paid revival and the
|
||||||
|
// immediately following native response passes, before later phase readiness.
|
||||||
|
const revivalIndex = source.actions.findIndex(step => step.actor === source.tleilaxu &&
|
||||||
|
step.action.type === 'reviveForeignGhola' && step.action.leader === target);
|
||||||
|
assert.ok(revivalIndex >= 0);
|
||||||
|
let sourceActionCount = revivalIndex + 1;
|
||||||
|
while (source.actions[sourceActionCount]?.action.type === 'passResponse') sourceActionCount++;
|
||||||
|
const sourceActions = structuredClone(source.actions.slice(0, sourceActionCount));
|
||||||
|
|
||||||
|
let game = mixedE3MoritaniReload(prefix);
|
||||||
|
const swap = swapHeldTraitor(game, owner, target);
|
||||||
|
game.phase = 6;
|
||||||
|
game.ready = [];
|
||||||
|
game.order = [opponent, owner, ...game.order.filter(id => id !== opponent && id !== owner)];
|
||||||
|
game.active = opponent;
|
||||||
|
for (const player of game.players) {
|
||||||
|
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
|
||||||
|
player.forces = {};
|
||||||
|
if (player.elites) {
|
||||||
|
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
|
||||||
|
player.elites.forces = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const destination = ['arrakeen', 'carthag', 'sietch_tabr']
|
||||||
|
.map(id => ({ id, sector: territory(id).sectors.find(sector => sector !== game.storm) }))
|
||||||
|
.find(candidate => candidate.sector !== undefined);
|
||||||
|
assert.ok(destination);
|
||||||
|
const location = `${destination.id}:${destination.sector}`;
|
||||||
|
for (const actor of [opponent, owner]) {
|
||||||
|
const player = mixedE3MoritaniPlayer(game, actor);
|
||||||
|
assert.ok(player.reserves >= 3);
|
||||||
|
player.reserves -= 3;
|
||||||
|
player.forces[location] = 3;
|
||||||
|
}
|
||||||
|
assert.deepEqual(moritaniAssassinateGholaTargetInventory(game), originalInventory);
|
||||||
|
assertMixedE3MoritaniPhysical(game);
|
||||||
|
const arranged = mixedE3MoritaniReload(game);
|
||||||
|
|
||||||
|
const winningDisc = mixedE3MoritaniPlayer(game, opponent).leaders
|
||||||
|
.filter(leader => leader.id !== target && !leader.dead && !leader.capturedBy &&
|
||||||
|
!leader.gholaBy && !leader.usedAt)
|
||||||
|
.sort((first, second) => second.strength - first.strength)[0];
|
||||||
|
const losingDisc = mixedE3MoritaniPlayer(game, owner).leaders
|
||||||
|
.filter(leader => !leader.dead && !leader.capturedBy && !leader.gholaBy && !leader.usedAt)
|
||||||
|
.sort((first, second) => first.strength - second.strength)[0];
|
||||||
|
assert.ok(winningDisc && losingDisc && winningDisc.strength > losingDisc.strength,
|
||||||
|
'Different living native Guild and Moritani discs must produce the unarmed loss.');
|
||||||
|
const actions: MixedE3MoritaniStep[] = [];
|
||||||
|
game = openMixedE3MoritaniBattle(game, opponent, owner, actions);
|
||||||
|
game = advanceMixedE3Moritani(game, state => !!state.battle && mixedE3MoritaniClean(state) &&
|
||||||
|
!state.battle.preparation && state.battle.preLeader?.closed !== false, actions);
|
||||||
|
const plans: MixedE3MoritaniStep[] = [
|
||||||
|
{ actor: opponent, action: { type: 'battlePlan', leader: winningDisc.id, dial: 0, support: 0 } },
|
||||||
|
{ actor: owner, action: { type: 'battlePlan', leader: losingDisc.id, dial: 0, support: 0 } },
|
||||||
|
];
|
||||||
|
game = revealMixedE3MoritaniPlans(game, plans, actions);
|
||||||
|
const boundary = settleMixedE3MoritaniBoundary(game, null, actions);
|
||||||
|
assert.equal(boundary.kind, 'assassination',
|
||||||
|
'The real different-Guild-disc victory must open the original native target choice.');
|
||||||
|
game = boundary.game;
|
||||||
|
const decision = game.decision;
|
||||||
|
assert.ok(decision?.kind === 'moritaniAssassinate' && decision.player === owner);
|
||||||
|
const event = decision.event;
|
||||||
|
const receipt = game.moritaniAssassinate!.opportunities.find(candidate => candidate.event === event)!;
|
||||||
|
assert.equal(receipt.opponent, opponent);
|
||||||
|
assert.equal(receipt.faction, 'guild');
|
||||||
|
assert.equal(receipt.opposingLeader, winningDisc.id);
|
||||||
|
assert.notEqual(receipt.opposingLeader, target);
|
||||||
|
assert.deepEqual(mixedE3MoritaniPlayer(game, opponent).leaders.find(leader => leader.id === target),
|
||||||
|
originalTarget, 'The genuinely revived target must remain untouched through the later battle.');
|
||||||
|
const choice = viewGame(game, owner).moritaniAssassinate!.pending!.cards
|
||||||
|
.find(candidate => candidate.card === target);
|
||||||
|
assert.ok(choice, 'The actual held foreign-Ghola native identity must be selectable.');
|
||||||
|
assert.equal(choice.potentialBounty, originalTarget.strength);
|
||||||
|
assert.deepEqual(moritaniAssassinateGholaTargetInventory(game), originalInventory);
|
||||||
|
assertMixedE3MoritaniPhysical(game);
|
||||||
|
const action: Action = { type: 'decision', event, card: target };
|
||||||
|
const staging = [
|
||||||
|
...source.staging,
|
||||||
|
'Reuse only the source fixture’s genuine native Guild death and paid Tleilaxu foreign-revival prefix. The later source winning-Ghola battle is not used as this target witness.',
|
||||||
|
`Explicitly swap Moritani’s physical ${swap.previous} Traitor Card with ${target} from the ${swap.zone}; preserve the complete original Traitor/Face Dancer census. This is controlled card positioning, not a new deal or a natural draw.`,
|
||||||
|
'Explicitly stage the later phase-six/order/army position: return every current board counter to its own reserves, retain all Tanks and elite stocks, then place three normal Guild and three normal Moritani counters together outside the unchanged storm. No wallet, hand, mode, target disc or custody field is assigned.',
|
||||||
|
'Use actual chooseBattle, ordinary preparation, two unarmed private Battle Plans and normal Traitor declines. A different living native Guild disc wins; the original paid foreign Ghola remains unchanged in the Guild roster with Tleilaxu custody. Stop at the real Moritani Assassinate Leaders decision.',
|
||||||
|
];
|
||||||
|
return { game, owner, opponent, target, event, potentialBounty: choice.potentialBounty,
|
||||||
|
action, originalInventory, sourceActions, sourceActionCount, actions,
|
||||||
|
actionCount: sourceActions.length + actions.length, staging,
|
||||||
|
source, prefix, arranged, originalTarget, tleilaxu: source.tleilaxu,
|
||||||
|
location, plans, winningDisc: winningDisc.id, losingDisc: losingDisc.id };
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyAction, captureCandidates, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||||
|
|
||||||
|
export type AssassinateTargetStep = { actor: string; action: Action };
|
||||||
|
export type AssassinateTargetTransition = { before: Game; steps: AssassinateTargetStep[]; after: Game };
|
||||||
|
export const assassinateTargetPlayer = (game: Game, id: string) => {
|
||||||
|
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
|
||||||
|
};
|
||||||
|
export const assassinateTargetClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||||
|
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
|
||||||
|
export const assassinateTargetInventory = (game: Game) => {
|
||||||
|
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||||
|
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
|
||||||
|
const ids = cards.map(card => card.id).sort();
|
||||||
|
assert.equal(new Set(ids).size, ids.length, 'Original physical Treachery Cards retain distinct current slots');
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
export const assassinateTargetTraitors = (game: Game) => {
|
||||||
|
const cards = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
|
||||||
|
[...player.traitors, ...player.traitorChoices, ...(player.faceDancers ?? []).map(card => card.leader)]),
|
||||||
|
...(game.moritaniAssassinate?.opportunities.filter(receipt => receipt.stage === 'replaced').map(receipt => receipt.card!) ?? [])];
|
||||||
|
assert.equal(new Set(cards).size, cards.length, 'Every original physical Traitor Card has one current slot');
|
||||||
|
return cards.sort();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Original scalar shuffle order; UUIDs and other native byte entropy remain
|
||||||
|
* unchanged. Selected values are used only by the actual capture permutation. */
|
||||||
|
function originalLottery<T>(run: () => T, selected?: readonly number[]): T {
|
||||||
|
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||||
|
const original = crypto.getRandomValues.bind(crypto); let cursor = 0;
|
||||||
|
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||||
|
if (array instanceof Uint32Array && array.length === 1)
|
||||||
|
array[0] = selected && cursor < selected.length ? selected[cursor++] : 0xffffffff;
|
||||||
|
else Reflect.apply(original, crypto, [array]);
|
||||||
|
return array;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const result = run();
|
||||||
|
if (selected) assert.equal(cursor, selected.length, 'The original capture consumes exactly its selected permutation');
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||||
|
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function actAssassinateTarget(game: Game, actor: string, action: Action,
|
||||||
|
actions: AssassinateTargetStep[] = []): Game {
|
||||||
|
const next = originalLottery(() => applyAction(game, actor, action));
|
||||||
|
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||||
|
}
|
||||||
|
/** Native windows only; captures, assassination and private plans stay owned
|
||||||
|
* caller choices. Optional Terror placement and special Karama are declined. */
|
||||||
|
export function nextAssassinateTarget(game: Game): AssassinateTargetStep | null {
|
||||||
|
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||||
|
const decision = game.decision;
|
||||||
|
if (decision?.kind === 'captureOffer' || decision?.kind === 'capturedLeader' ||
|
||||||
|
decision?.kind === 'moritaniAssassinate') return null;
|
||||||
|
if (game.status === 'setup' && game.setupStage === 'traitors' &&
|
||||||
|
assassinateTargetPlayer(game, 'moritani').traitorChoices.length)
|
||||||
|
return { actor: 'moritani', action: { type: 'traitor', leader: 'emperor-3' } };
|
||||||
|
if (decision?.kind === 'moritaniSetup')
|
||||||
|
return { actor: decision.player, action: { type: 'decision', territory: 'imperial_basin', sector: 11 } };
|
||||||
|
if (decision?.kind === 'moritaniPlacement')
|
||||||
|
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||||
|
if (decision?.kind === 'moritaniSpecialKarama')
|
||||||
|
return { actor: decision.player, action: { type: 'decision', event: game.moritaniSpecialKarama!.source.event, decline: true } };
|
||||||
|
}
|
||||||
|
return nextRicheseStrongholdsNativeStep(game);
|
||||||
|
}
|
||||||
|
export function advanceAssassinateTarget(game: Game, until: (state: Game) => boolean,
|
||||||
|
actions: AssassinateTargetStep[] = []): Game {
|
||||||
|
for (let count = 0; count < 1800; count++) {
|
||||||
|
if (until(game)) return game;
|
||||||
|
assert.notEqual(game.status, 'finished', 'The genuine assassination-target chronology ended before its owned boundary');
|
||||||
|
const next = nextAssassinateTarget(game);
|
||||||
|
assert.ok(next, `Choose the actual capture, private plan or assassination at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||||
|
game = actAssassinateTarget(game, next.actor, next.action, actions);
|
||||||
|
}
|
||||||
|
throw Error('The native assassination-target chronology did not reach its finite boundary');
|
||||||
|
}
|
||||||
|
export function passAssassinateTarget(game: Game, actions: AssassinateTargetStep[] = []): Game {
|
||||||
|
for (let count = 0; game.response && count < 60; count++) {
|
||||||
|
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||||
|
game = actAssassinateTarget(game, actor.id, { type: 'passResponse' }, actions);
|
||||||
|
}
|
||||||
|
assert.ok(!game.response); return game;
|
||||||
|
}
|
||||||
|
function capturePermutation(pool: readonly string[], target: string) {
|
||||||
|
const desired = [target, ...pool.filter(id => id !== target)], current = [...pool], rolls: number[] = [];
|
||||||
|
assert.equal(current.filter(id => id === target).length, 1);
|
||||||
|
for (let index = current.length - 1; index > 0; index--) {
|
||||||
|
const from = current.indexOf(desired[index]); assert.ok(from >= 0 && from <= index);
|
||||||
|
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
|
||||||
|
[current[index], current[from]] = [current[from], current[index]];
|
||||||
|
}
|
||||||
|
assert.deepEqual(current, desired); return rolls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Genuine original capture, including its own response if a card keeps it
|
||||||
|
* open. Select only this real permutation, never candidate bodies or custody. */
|
||||||
|
function declareCapture(source: Game, target: string, actions: AssassinateTargetStep[]) {
|
||||||
|
assert.equal(source.decision?.kind, 'captureOffer');
|
||||||
|
const owner = source.decision!.player;
|
||||||
|
const pool = captureCandidates(source, 'emperor', 'carthag').map(leader => leader.id);
|
||||||
|
const rolls = capturePermutation(pool, target), before = structuredClone(source), start = actions.length;
|
||||||
|
let game = source, declared = structuredClone(source);
|
||||||
|
originalLottery(() => {
|
||||||
|
const step: AssassinateTargetStep = { actor: owner, action: { type: 'decision', accept: true } };
|
||||||
|
game = applyAction(game, step.actor, step.action); actions.push(structuredClone(step));
|
||||||
|
declared = structuredClone(game);
|
||||||
|
for (let count = 0; game.response && count < 60; count++) {
|
||||||
|
assert.equal(game.response.kind, 'capture');
|
||||||
|
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||||
|
const pass: AssassinateTargetStep = { actor: actor.id, action: { type: 'passResponse' } };
|
||||||
|
game = applyAction(game, pass.actor, pass.action); actions.push(pass);
|
||||||
|
}
|
||||||
|
}, rolls);
|
||||||
|
assert.equal(game.decision?.kind, 'capturedLeader');
|
||||||
|
assert.equal(game.decision?.kind === 'capturedLeader' && game.decision.leader, target);
|
||||||
|
return { game, declared, transition: { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) },
|
||||||
|
pool, rolls };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public Advanced Harkonnen/Emperor/Moritani. Emperor actually divides one
|
||||||
|
* paid shipment between Carthag and Mori's real setup position. Harkonnen wins
|
||||||
|
* the first original battle, captures Burseg through its native lottery and
|
||||||
|
* keeps or executes him. A different living Emperor disc then beats Moritani.
|
||||||
|
* Stop at that actual assassination opportunity, before any reveal/decline. */
|
||||||
|
export function createMoritaniAssassinateTargetFixture(options: { execute?: boolean } = {}) {
|
||||||
|
const captor = 'harkonnen', opponent = 'emperor', owner = 'moritani', target = 'emperor-3';
|
||||||
|
let game = createGame('MORITANIASSASSINATETARGET', newPlayer(captor, 'Harkonnen', 'harkonnen'), true, ['ecaz']);
|
||||||
|
joinGame(game, newPlayer(opponent, 'Emperor', 'emperor'));
|
||||||
|
joinGame(game, newPlayer(owner, 'Moritani', 'moritani'));
|
||||||
|
const initial = structuredClone(game), actions: AssassinateTargetStep[] = [];
|
||||||
|
const act = (actor: string, action: Action) => { game = actAssassinateTarget(game, actor, action, actions); };
|
||||||
|
const reach = (until: (state: Game) => boolean) => { game = advanceAssassinateTarget(game, until, actions); };
|
||||||
|
const transition = (run: () => void): AssassinateTargetTransition => {
|
||||||
|
const before = structuredClone(game), start = actions.length; run();
|
||||||
|
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||||
|
};
|
||||||
|
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||||
|
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||||
|
const setup = structuredClone(game);
|
||||||
|
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
|
||||||
|
assert.deepEqual(game.players.map(player => player.faction), [captor, opponent, owner]);
|
||||||
|
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||||
|
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||||
|
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||||
|
assert.ok(assassinateTargetPlayer(game, owner).traitorChoices.includes(target),
|
||||||
|
'The identity-order original third-seat Traitor offer really contains Burseg');
|
||||||
|
const originalInventory = assassinateTargetInventory(game), originalTraitors = assassinateTargetTraitors(game);
|
||||||
|
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 4);
|
||||||
|
assert.equal(ordinary.length, 4);
|
||||||
|
const dealOrder = ordinary.map((card, position) => {
|
||||||
|
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||||
|
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||||
|
return { position, from, card: card.id };
|
||||||
|
});
|
||||||
|
const beforeStartingDeal = structuredClone(game);
|
||||||
|
reach(state => state.status === 'playing');
|
||||||
|
const afterSetup = structuredClone(game);
|
||||||
|
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), ordinary.map(card => card.id));
|
||||||
|
assert.ok(assassinateTargetPlayer(game, owner).traitors.includes(target));
|
||||||
|
assert.equal(assassinateTargetPlayer(game, owner).forces['imperial_basin:11'], 6);
|
||||||
|
assert.equal(assassinateTargetPlayer(game, captor).forces['carthag:11'], 10);
|
||||||
|
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||||
|
const spiceOrder = [0, 1].map(position => {
|
||||||
|
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||||
|
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||||
|
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||||
|
});
|
||||||
|
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||||
|
reach(state => state.turn === 1 && state.phase === 5 && state.active === opponent && assassinateTargetClean(state));
|
||||||
|
const shipment = transition(() => {
|
||||||
|
act(opponent, { type: 'ship', territory: 'carthag', sector: 11, amount: 2, elite: 0, allyPayment: 0 });
|
||||||
|
reach(assassinateTargetClean);
|
||||||
|
});
|
||||||
|
assert.equal(assassinateTargetPlayer(shipment.before, opponent).spice - assassinateTargetPlayer(game, opponent).spice, 2);
|
||||||
|
const movement = transition(() => {
|
||||||
|
act(opponent, { type: 'move', from: 'carthag:11', territory: 'imperial_basin', sector: 11, amount: 1 });
|
||||||
|
reach(assassinateTargetClean);
|
||||||
|
});
|
||||||
|
assert.equal(assassinateTargetPlayer(game, opponent).forces['carthag:11'], 1);
|
||||||
|
assert.equal(assassinateTargetPlayer(game, opponent).forces['imperial_basin:11'], 1);
|
||||||
|
reach(state => state.phase === 6 && state.active === captor && assassinateTargetClean(state));
|
||||||
|
const beforeFirstBattle = structuredClone(game);
|
||||||
|
const firstBattleStep: AssassinateTargetStep = { actor: captor,
|
||||||
|
action: { type: 'chooseBattle', territory: 'carthag', target: opponent } };
|
||||||
|
act(firstBattleStep.actor, firstBattleStep.action);
|
||||||
|
reach(state => !!state.battle && assassinateTargetClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false);
|
||||||
|
const firstPlans: AssassinateTargetStep[] = [
|
||||||
|
{ actor: captor, action: { type: 'battlePlan', leader: 'harkonnen-0', dial: 0, support: 0, weapon: null, defense: null } },
|
||||||
|
{ actor: opponent, action: { type: 'battlePlan', leader: 'emperor-4', dial: 0, support: 0, weapon: null, defense: null } },
|
||||||
|
];
|
||||||
|
for (const step of firstPlans) act(step.actor, step.action);
|
||||||
|
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'captureOffer');
|
||||||
|
const captureOffer = structuredClone(game), capture = declareCapture(game, target, actions);
|
||||||
|
game = capture.game;
|
||||||
|
const beforeCaptureChoice = structuredClone(game);
|
||||||
|
const captureChoiceStep: AssassinateTargetStep = { actor: captor,
|
||||||
|
action: { type: 'decision', mode: options.execute ? 'execute' : 'keep' } };
|
||||||
|
const captureChoice = transition(() => {
|
||||||
|
act(captureChoiceStep.actor, captureChoiceStep.action);
|
||||||
|
reach(state => !state.battle && assassinateTargetClean(state));
|
||||||
|
});
|
||||||
|
const afterCapture = structuredClone(game), victim = assassinateTargetPlayer(game, opponent).leaders.find(leader => leader.id === target)!;
|
||||||
|
assert.ok(victim.concealed);
|
||||||
|
assert.equal(victim.dead, !!options.execute);
|
||||||
|
assert.equal(victim.capturedBy, options.execute ? undefined : captor);
|
||||||
|
assert.equal(victim.deaths, options.execute ? 2 : 0);
|
||||||
|
const beforeSecondBattle = structuredClone(game), choice = game.players.flatMap(player =>
|
||||||
|
viewGame(game, player.id).battleChoices).find(choice => choice.territory === 'imperial_basin');
|
||||||
|
assert.ok(choice);
|
||||||
|
const secondBattleStep: AssassinateTargetStep = { actor: choice.chooser,
|
||||||
|
action: { type: 'chooseBattle', territory: 'imperial_basin',
|
||||||
|
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||||
|
act(secondBattleStep.actor, secondBattleStep.action);
|
||||||
|
reach(state => !!state.battle && assassinateTargetClean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false);
|
||||||
|
const secondPlans: AssassinateTargetStep[] = [
|
||||||
|
{ actor: opponent, action: { type: 'battlePlan', leader: 'emperor-0', dial: 0, support: 0, weapon: null, defense: null } },
|
||||||
|
{ actor: owner, action: { type: 'battlePlan', leader: 'moritani-4', dial: 0, support: 0, weapon: null, defense: null } },
|
||||||
|
];
|
||||||
|
for (const step of secondPlans) act(step.actor, step.action);
|
||||||
|
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'moritaniAssassinate');
|
||||||
|
const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
|
||||||
|
assert.equal(receipt.stage, 'choice'); assert.equal(receipt.faction, 'emperor');
|
||||||
|
assert.equal(receipt.opponent, opponent); assert.equal(receipt.opposingLeader, 'emperor-0');
|
||||||
|
assert.ok(assassinateTargetPlayer(game, owner).traitors.includes(target));
|
||||||
|
assert.deepEqual(assassinateTargetInventory(game), originalInventory);
|
||||||
|
assert.deepEqual(assassinateTargetTraitors(game), originalTraitors);
|
||||||
|
const reveal: Action = { type: 'decision', event: receipt.event, card: target };
|
||||||
|
const decline: Action = { type: 'decision', event: receipt.event, decline: true };
|
||||||
|
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, shipment, movement,
|
||||||
|
beforeFirstBattle, firstBattleStep, firstPlans, captureOffer, captureDeclared: capture.declared,
|
||||||
|
capture: capture.transition, capturePool: capture.pool, captureRolls: capture.rolls,
|
||||||
|
beforeCaptureChoice, captureChoiceStep, captureChoice, afterCapture,
|
||||||
|
beforeSecondBattle, secondBattleStep, secondPlans,
|
||||||
|
owner, moritani: owner, opponent, emperor: opponent, captor, harkonnen: captor, target,
|
||||||
|
winningDisc: 'emperor-0', territory: 'imperial_basin', sector: 11, location: 'imperial_basin:11',
|
||||||
|
event: receipt.event, receipt: structuredClone(receipt), execute: !!options.execute,
|
||||||
|
potentialBounty: victim.strength, action: reveal,
|
||||||
|
reveal, revealStep: { actor: owner, action: reveal } satisfies AssassinateTargetStep,
|
||||||
|
decline, actions, sourceActionCount: actions.length, originalInventory, originalTraitors,
|
||||||
|
originalSpice, dealOrder, spiceOrder,
|
||||||
|
staging: [
|
||||||
|
'Actual public native Advanced Harkonnen/Emperor/Moritani ready/start and private setup. Original scalar shuffle order naturally offers emperor-3 in Moritani’s third-seat Traitor packet; Moritani actually chooses it.',
|
||||||
|
'Select four original unopened ordinary Treachery positions and two original unopened ordinary Spice positions. Native Moritani setup really places six counters in empty Imperial Basin11. No optional module is selected.',
|
||||||
|
'Emperor actually pays two to ship two reserves to Carthag11, then really moves one to Imperial Basin11. Native first Storm, dual Spice, Charity, Bidding, Revival and movement turns execute normally.',
|
||||||
|
'Harkonnen actually wins Carthag with Feyd6 against Bashar2, without weapons or leader deaths. Scope only the ensuing native capture permutation to choose unused Burseg3; no candidate body, captive flag, death or concealment is assigned. The original capturedLeader action really keeps or executes that disc.',
|
||||||
|
'A different surviving native Emperor disc, Fenring6, really wins Imperial Basin against Moritani’s Vando1. The original printed-Emperor assassination opportunity opens while Moritani still physically holds Burseg’s original Traitor Card.',
|
||||||
|
'Stop before the original reveal/decline. Both live-captive and concealed-executed variants use native actions and retain the same printed target identity. No phase, wallet, army, alliance, plan, custody state or assassination receipt is installed.',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type MoritaniAssassinateTargetFixture = ReturnType<typeof createMoritaniAssassinateTargetFixture>;
|
||||||
|
|
||||||
|
/** Call after an explicit reveal or decline. Native casualty/card controls and
|
||||||
|
* original Mentat replacement continue once; no custody or replacement is set. */
|
||||||
|
export function finishMoritaniAssassinateTarget(source: Game, fixture: MoritaniAssassinateTargetFixture,
|
||||||
|
actions: AssassinateTargetStep[] = []): Game {
|
||||||
|
assert.notEqual(source.decision?.kind, 'moritaniAssassinate', 'Choose the owned reveal or decline before continuing');
|
||||||
|
return advanceAssassinateTarget(source, game => game.phase === 8 && !game.battle &&
|
||||||
|
!game.response && !game.phaseOpening && !game.pendingTreacheryDiscard &&
|
||||||
|
game.moritaniAssassinate!.opportunities.some(receipt => receipt.event === fixture.event &&
|
||||||
|
(receipt.stage === 'replaced' || receipt.stage === 'declined')), actions);
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export type ForeignGholaWinnerWindow = {
|
|||||||
event: string; turn: number; territory: string; owner: string; opponent: string;
|
event: string; turn: number; territory: string; owner: string; opponent: string;
|
||||||
faction: string; opposingLeader: string;
|
faction: string; opposingLeader: string;
|
||||||
/** Owner-only opportunity; a rival view must stay empty. */
|
/** Owner-only opportunity; a rival view must stay empty. */
|
||||||
cards: { card: string; name: string; bounty: number; dead: boolean }[];
|
cards: { card: string; name: string; potentialBounty: number }[];
|
||||||
blocked: string | null;
|
blocked: string | null;
|
||||||
rivalCards: number;
|
rivalCards: number;
|
||||||
};
|
};
|
||||||
@@ -261,7 +261,7 @@ export function createMoritaniForeignGholaWinnerFixture(): ForeignGholaWinnerFix
|
|||||||
assert.equal(window.cards.some((choice) => choice.card === FOREIGN_GHOLA_DISC), false,
|
assert.equal(window.cards.some((choice) => choice.card === FOREIGN_GHOLA_DISC), false,
|
||||||
'The borrowed disc’s printed faction never supplies the target pool.');
|
'The borrowed disc’s printed faction never supplies the target pool.');
|
||||||
assert.equal(window.rivalCards, 0, 'Rivals see the decision without Moritani’s private options.');
|
assert.equal(window.rivalCards, 0, 'Rivals see the decision without Moritani’s private options.');
|
||||||
assert.equal(window.cards.find((choice) => choice.card === setup.target)!.bounty,
|
assert.equal(window.cards.find((choice) => choice.card === setup.target)!.potentialBounty,
|
||||||
leaders('tleilaxu')[0].strength, 'The printed bounty of the held card is quoted.');
|
leaders('tleilaxu')[0].strength, 'The printed bounty of the held card is quoted.');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -289,6 +289,7 @@ export function revealMoritaniForeignGholaWitness(f: ForeignGholaWinnerFixture,
|
|||||||
assert.ok(choice, 'Choose one of the real private reveal options.');
|
assert.ok(choice, 'Choose one of the real private reveal options.');
|
||||||
const before = mixedE3MoritaniPlayer(g, f.moritani);
|
const before = mixedE3MoritaniPlayer(g, f.moritani);
|
||||||
const victim = mixedE3MoritaniPlayer(g, f.tleilaxu).leaders.find((l) => l.id === card)!;
|
const victim = mixedE3MoritaniPlayer(g, f.tleilaxu).leaders.find((l) => l.id === card)!;
|
||||||
|
assert.equal(victim.dead, false, 'This original witness selects a living native target.');
|
||||||
const rivalSpice = mixedE3MoritaniPlayer(g, f.tleilaxu).spice;
|
const rivalSpice = mixedE3MoritaniPlayer(g, f.tleilaxu).spice;
|
||||||
const reserveBefore = [...(g.traitorReserve ?? [])];
|
const reserveBefore = [...(g.traitorReserve ?? [])];
|
||||||
g = stepMixedE3Moritani(g, { actor: f.moritani,
|
g = stepMixedE3Moritani(g, { actor: f.moritani,
|
||||||
@@ -297,7 +298,7 @@ export function revealMoritaniForeignGholaWitness(f: ForeignGholaWinnerFixture,
|
|||||||
const killed = mixedE3MoritaniPlayer(after, f.tleilaxu).leaders.find((l) => l.id === card)!;
|
const killed = mixedE3MoritaniPlayer(after, f.tleilaxu).leaders.find((l) => l.id === card)!;
|
||||||
assert.equal(killed.dead, true, 'The revealed native disc really died.');
|
assert.equal(killed.dead, true, 'The revealed native disc really died.');
|
||||||
assert.equal(killed.deaths, victim.deaths + 1);
|
assert.equal(killed.deaths, victim.deaths + 1);
|
||||||
assert.equal(mixedE3MoritaniPlayer(after, f.moritani).spice, before.spice + choice.bounty,
|
assert.equal(mixedE3MoritaniPlayer(after, f.moritani).spice, before.spice + victim.strength,
|
||||||
'The printed bounty is paid once from the bank.');
|
'The printed bounty is paid once from the bank.');
|
||||||
assert.equal(mixedE3MoritaniPlayer(after, f.tleilaxu).spice, rivalSpice, 'The revealing seat pays nothing.');
|
assert.equal(mixedE3MoritaniPlayer(after, f.tleilaxu).spice, rivalSpice, 'The revealing seat pays nothing.');
|
||||||
assert.equal(after.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
|
assert.equal(after.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
|
||||||
@@ -321,7 +322,7 @@ export function revealMoritaniForeignGholaWitness(f: ForeignGholaWinnerFixture,
|
|||||||
const heldBefore = mixedE3MoritaniPlayer(f.window.game, f.moritani).forces[f.location] ?? 0;
|
const heldBefore = mixedE3MoritaniPlayer(f.window.game, f.moritani).forces[f.location] ?? 0;
|
||||||
assert.equal(mixedE3MoritaniPlayer(settled, f.moritani).forces[f.location] ?? 0, heldBefore,
|
assert.equal(mixedE3MoritaniPlayer(settled, f.moritani).forces[f.location] ?? 0, heldBefore,
|
||||||
'Moritani’s real counters at its starting stronghold are unchanged by the loss and its aftermath.');
|
'Moritani’s real counters at its starting stronghold are unchanged by the loss and its aftermath.');
|
||||||
return { window: f.window.game, after, mentat: settled, replacement, retired: card, bounty: choice.bounty };
|
return { window: f.window.game, after, mentat: settled, replacement, retired: card, bounty: receipt.bounty };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { registerHooks } from 'node:module';
|
import { registerHooks } from 'node:module';
|
||||||
import { createElement } from 'react';
|
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||||
import { renderToStaticMarkup } from 'react-dom/server';
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
import type { GameView } from '../game/engine';
|
import type { Action, GameView } from '../game/engine';
|
||||||
|
|
||||||
const aliases = registerHooks({
|
const aliases = registerHooks({
|
||||||
resolve(specifier, context, next) {
|
resolve(specifier, context, next) {
|
||||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
|
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { MoritaniAssassinate, MoritaniAssassinateHistory } = await import('../components/moritani-assassinate');
|
const { MoritaniAssassinate, MoritaniAssassinateChoice, MoritaniAssassinateHistory } = await import('../components/moritani-assassinate');
|
||||||
|
|
||||||
function view(overrides: Record<string, unknown> = {}) {
|
function view(overrides: Record<string, unknown> = {}) {
|
||||||
return {
|
return {
|
||||||
@@ -34,8 +34,8 @@ function view(overrides: Record<string, unknown> = {}) {
|
|||||||
pending: {
|
pending: {
|
||||||
event: 'assassinate-1', opponent: 'o', territory: 'Arrakeen',
|
event: 'assassinate-1', opponent: 'o', territory: 'Arrakeen',
|
||||||
cards: [
|
cards: [
|
||||||
{ card: 'traitor-1', name: 'Duke Leto', bounty: 5, dead: false },
|
{ card: 'traitor-1', name: 'Duke Leto', potentialBounty: 5 },
|
||||||
{ card: 'traitor-2', name: 'Thufir Hawat', bounty: 0, dead: true },
|
{ card: 'traitor-2', name: 'Thufir Hawat', potentialBounty: 4 },
|
||||||
], blocked: null,
|
], blocked: null,
|
||||||
},
|
},
|
||||||
history: [],
|
history: [],
|
||||||
@@ -48,22 +48,96 @@ function markup(game: GameView, busy = false) {
|
|||||||
return renderToStaticMarkup(createElement(MoritaniAssassinate, { game, act() {}, busy }));
|
return renderToStaticMarkup(createElement(MoritaniAssassinate, { game, act() {}, busy }));
|
||||||
}
|
}
|
||||||
|
|
||||||
void test('owner sees public traitor choices, bounty, dead-target legality and rules', () => {
|
function click(node: ReactNode, selector: string): boolean {
|
||||||
|
for (const child of Children.toArray(node)) {
|
||||||
|
if (!isValidElement<{
|
||||||
|
children?: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
'data-testid'?: string;
|
||||||
|
}>(child)) continue;
|
||||||
|
if (child.props['data-testid'] === selector && child.props.onClick) {
|
||||||
|
child.props.onClick();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (click(child.props.children, selector)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(game: GameView, selected = 'traitor-1', busy = false, decline = false): Action[] {
|
||||||
|
const actions: Action[] = [];
|
||||||
|
const tree = MoritaniAssassinateChoice({
|
||||||
|
game, selected, busy, onSelect() {}, act(action) { actions.push(action); },
|
||||||
|
});
|
||||||
|
click(tree, decline ? 'moritani-assassinate-decline' : 'moritani-assassinate-reveal');
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('owner sees canonical traitor names and printed potential bounties with conditional reward rules', () => {
|
||||||
const html = markup(view());
|
const html = markup(view());
|
||||||
assert.match(html, /Traitor Card to reveal/);
|
assert.match(html, /Traitor Card to reveal/);
|
||||||
assert.match(html, /Duke Leto/);
|
assert.match(html, /Duke Leto/);
|
||||||
assert.match(html, /5 spice bounty/);
|
assert.match(html, /5 spice potential bounty/);
|
||||||
assert.match(html, /Thufir Hawat · dead target · 0 spice/);
|
assert.match(html, /Thufir Hawat · 4 spice potential bounty/);
|
||||||
assert.match(html, /replaced automatically during Mentat Pause/);
|
assert.match(html, /replaced automatically during Mentat Pause/);
|
||||||
assert.match(html, /cannot be canceled with Karama/);
|
assert.match(html, /cannot be canceled with Karama/);
|
||||||
const dead = markup(view({ moritaniAssassinate: {
|
assert.match(html, /potential bounty is the leader’s printed strength/);
|
||||||
|
assert.match(html, /awarded only if the target is alive/);
|
||||||
|
assert.match(html, /already-dead target remains a legal choice and awards no spice/);
|
||||||
|
assert.match(html, /physical Traitor Card makes the actual outcome public/);
|
||||||
|
const other = markup(view({ moritaniAssassinate: {
|
||||||
owner: 'm', blocked: null, pending: {
|
owner: 'm', blocked: null, pending: {
|
||||||
event: 'assassinate-1', opponent: 'o', territory: 'Arrakeen',
|
event: 'assassinate-1', opponent: 'o', territory: 'Arrakeen',
|
||||||
cards: [{ card: 'traitor-2', name: 'Thufir Hawat', bounty: 0, dead: true }], blocked: null,
|
cards: [{ card: 'traitor-2', name: 'Thufir Hawat', potentialBounty: 4 }], blocked: null,
|
||||||
}, history: [],
|
}, history: [],
|
||||||
} }));
|
} }));
|
||||||
assert.match(dead, /0 spice; target already dead/);
|
assert.match(other, /Thufir Hawat · 4 spice potential bounty/);
|
||||||
assert.match(dead, /Printed leader strength: 4/);
|
assert.match(other, /Printed leader strength: 4/);
|
||||||
|
assert.doesNotMatch(other, /dead target · 0 spice|0 spice; target already dead/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('real reveal and decline callbacks retain the selected physical card and original event', () => {
|
||||||
|
const game = view();
|
||||||
|
for (const card of game.moritaniAssassinate!.pending!.cards) {
|
||||||
|
assert.deepEqual(choose(game, card.card), [{ type: 'decision', event: 'assassinate-1', card: card.card }]);
|
||||||
|
}
|
||||||
|
assert.deepEqual(choose(game, 'traitor-2', false, true), [{ type: 'decision', event: 'assassinate-1', decline: true }]);
|
||||||
|
assert.deepEqual(choose(game, 'stale-selection'), [{ type: 'decision', event: 'assassinate-1', card: 'traitor-1' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('pending choice labels remain uniform for living, dead, captured, concealed-executed and foreign-Ghola targets', () => {
|
||||||
|
const states = [
|
||||||
|
{ dead: false },
|
||||||
|
{ dead: true },
|
||||||
|
{ dead: false, capturedBy: 'h' },
|
||||||
|
{ dead: false, capturedBy: 'h', concealedExecuted: true },
|
||||||
|
{ dead: false, gholaBy: 'o' },
|
||||||
|
];
|
||||||
|
for (const state of states) {
|
||||||
|
const game = view();
|
||||||
|
const leader = game.allLeaders.find((candidate) => candidate.id === 'traitor-1')!;
|
||||||
|
Object.assign(leader, state);
|
||||||
|
// Only the uniform pending projection is a choice source. Even an available
|
||||||
|
// public leader record must not supply a private custody-dependent label.
|
||||||
|
for (const field of ['dead', 'capturedBy', 'concealedExecuted', 'gholaBy']) {
|
||||||
|
Object.defineProperty(leader, field, {
|
||||||
|
get() { throw new Error(`Pending choice read target ${field}`); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const card of game.moritaniAssassinate!.pending!.cards) {
|
||||||
|
for (const field of ['dead', 'bounty', 'capturedBy', 'gholaBy']) {
|
||||||
|
Object.defineProperty(card, field, {
|
||||||
|
get() { throw new Error(`Pending choice read internal ${field}`); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const html = markup(game);
|
||||||
|
assert.match(html, /Duke Leto · 5 spice potential bounty<\/option>/);
|
||||||
|
assert.match(html, /<strong>Duke Leto<\/strong> · 5 spice potential bounty/);
|
||||||
|
assert.match(html, /Printed leader strength: 5/);
|
||||||
|
assert.doesNotMatch(html, /dead target · 0 spice|0 spice; target already dead|captured|concealed-executed|foreign-Ghola/);
|
||||||
|
assert.deepEqual(choose(game), [{ type: 'decision', event: 'assassinate-1', card: 'traitor-1' }]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('nonowner, stale, blocked, response and Truthtrance contexts stay guarded', () => {
|
void test('nonowner, stale, blocked, response and Truthtrance contexts stay guarded', () => {
|
||||||
@@ -78,6 +152,25 @@ void test('nonowner, stale, blocked, response and Truthtrance contexts stay guar
|
|||||||
assert.match(blocked, />Continue</);
|
assert.match(blocked, />Continue</);
|
||||||
assert.match(markup(view({ response: { kind: 'test' } })), /disabled=""/);
|
assert.match(markup(view({ response: { kind: 'test' } })), /disabled=""/);
|
||||||
assert.match(markup(view({ truthtrance: { stage: 'answer' } })), /disabled=""/);
|
assert.match(markup(view({ truthtrance: { stage: 'answer' } })), /disabled=""/);
|
||||||
|
assert.deepEqual(choose(view(), 'traitor-1', true), []);
|
||||||
|
assert.deepEqual(choose(view(), 'traitor-1', true, true), []);
|
||||||
|
for (const game of [
|
||||||
|
view({ me: 'o' }),
|
||||||
|
view({ decision: { kind: 'moritaniAssassinate', player: 'm', event: 'old' } }),
|
||||||
|
view({ response: { kind: 'test' } }),
|
||||||
|
view({ truthtrance: { stage: 'answer' } }),
|
||||||
|
]) {
|
||||||
|
assert.deepEqual(choose(game), []);
|
||||||
|
assert.deepEqual(choose(game, 'traitor-1', false, true), []);
|
||||||
|
}
|
||||||
|
const noCard = view();
|
||||||
|
noCard.moritaniAssassinate!.pending!.cards = [];
|
||||||
|
assert.deepEqual(choose(noCard), []);
|
||||||
|
assert.deepEqual(choose(noCard, '', false, true), [{ type: 'decision', event: 'assassinate-1', decline: true }]);
|
||||||
|
const unavailable = view();
|
||||||
|
unavailable.moritaniAssassinate!.pending!.blocked = 'This opportunity has ended.';
|
||||||
|
assert.deepEqual(choose(unavailable), []);
|
||||||
|
assert.deepEqual(choose(unavailable, '', false, true), [{ type: 'decision', event: 'assassinate-1', decline: true }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('public history shows face-up traitor markers and read-only inspection for every seat', () => {
|
void test('public history shows face-up traitor markers and read-only inspection for every seat', () => {
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ void test('a real lost battle reveals one different opposing traitor, kills its
|
|||||||
void test('an already dead target remains a legal assassination reveal with no bounty or repeated death',()=>{
|
void test('an already dead target remains a legal assassination reveal with no bounty or repeated death',()=>{
|
||||||
const pending=resolveAssassinationBattle(stageAssassinationBattle(assassinationGame(),'guild-1',true));
|
const pending=resolveAssassinationBattle(stageAssassinationBattle(assassinationGame(),'guild-1',true));
|
||||||
const before=pending.players[0].spice,dead=structuredClone(pending.players[1].leaders[1]);
|
const before=pending.players[0].spice,dead=structuredClone(pending.players[1].leaders[1]);
|
||||||
assert.equal(viewGame(pending,'m').moritaniAssassinate!.pending!.cards[0].bounty,0);
|
const choice=viewGame(pending,'m').moritaniAssassinate!.pending!.cards[0];
|
||||||
|
assert.equal(choice.potentialBounty,dead.strength);
|
||||||
|
assert.equal('dead' in choice,false);assert.equal('bounty' in choice,false);
|
||||||
const done=assassinationChoice(pending,'guild-1');
|
const done=assassinationChoice(pending,'guild-1');
|
||||||
assert.equal(done.players[0].spice,before);assert.deepEqual(done.players[1].leaders[1],dead);
|
assert.equal(done.players[0].spice,before);assert.deepEqual(done.players[1].leaders[1],dead);
|
||||||
const next=assassinationToMentat(done);assert.equal(next.moritaniAssassinate!.opportunities[0].stage,'replaced');
|
const next=assassinationToMentat(done);assert.equal(next.moritaniAssassinate!.opportunities[0].stage,'replaced');
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { leaders } from '../game/cards';
|
import { createAuditorLeader, leaders } from '../game/cards';
|
||||||
import {
|
import {
|
||||||
createMoritaniAssassinateOpportunity,
|
createMoritaniAssassinateOpportunity,
|
||||||
moritaniAssassinateChoices,
|
moritaniAssassinateChoices,
|
||||||
|
projectedMoritaniAssassinateChoices,
|
||||||
moritaniAssassinateSignature,
|
moritaniAssassinateSignature,
|
||||||
moritaniAssassinateTrigger,
|
moritaniAssassinateTrigger,
|
||||||
quoteMoritaniAssassinate,
|
quoteMoritaniAssassinate,
|
||||||
@@ -201,7 +202,7 @@ void test('the sourced normal-reveal forfeiture and already normally revealed ca
|
|||||||
assert.deepEqual(moritaniAssassinateChoices(context).cards, []);
|
assert.deepEqual(moritaniAssassinateChoices(context).cards, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('missing, duplicated, altered or exceptional target custody cannot select another physical leader or invent bounty', () => {
|
void test('missing, duplicated, altered or invalid target custody cannot select another physical leader or invent bounty', () => {
|
||||||
for (const damage of [
|
for (const damage of [
|
||||||
(c: MoritaniAssassinateContext) => {
|
(c: MoritaniAssassinateContext) => {
|
||||||
c.leaders = c.leaders.filter((l) => l.id !== 'atreides-1');
|
c.leaders = c.leaders.filter((l) => l.id !== 'atreides-1');
|
||||||
@@ -248,6 +249,67 @@ void test('missing, duplicated, altered or exceptional target custody cannot sel
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('native capture and foreign-ghola targets retain one printed choice without disclosing life or custody', () => {
|
||||||
|
const base = fixture();
|
||||||
|
base.custodians = [{ id: 'h', faction: 'harkonnen' }, { id: 't', faction: 'tleilaxu' }];
|
||||||
|
const expected = projectedMoritaniAssassinateChoices(base);
|
||||||
|
const original = base.leaders[1];
|
||||||
|
for (const patch of [
|
||||||
|
{},
|
||||||
|
{ dead: true, deaths: 1 },
|
||||||
|
{ capturedBy: 'h', concealed: { captor: 'h', controller: 'a', dead: false, deaths: 0 } },
|
||||||
|
{ dead: true, deaths: 2, concealed: { captor: 'h', controller: 'a', dead: false, deaths: 0 } },
|
||||||
|
{ gholaBy: 't', deaths: 1 },
|
||||||
|
{ gholaBy: 't', dead: true, deaths: 2 },
|
||||||
|
{ gholaBy: 't', capturedBy: 'h', deaths: 1,
|
||||||
|
concealed: { captor: 'h', controller: 't', dead: false, deaths: 1 } },
|
||||||
|
]) {
|
||||||
|
const context = structuredClone(base);
|
||||||
|
Object.assign(context.leaders[1], patch);
|
||||||
|
const before = structuredClone(context);
|
||||||
|
const projected = projectedMoritaniAssassinateChoices(context);
|
||||||
|
assert.deepEqual(projected, expected);
|
||||||
|
assert.deepEqual(Object.keys(projected.cards[0]).sort(), ['card', 'name', 'potentialBounty']);
|
||||||
|
const quote = quoteMoritaniAssassinate(context, original.id);
|
||||||
|
assert.equal(quote.kill, !context.leaders[1].dead);
|
||||||
|
assert.equal(quote.bounty, context.leaders[1].dead ? 0 : original.strength);
|
||||||
|
assert.deepEqual(context, before);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native producer identities and the existing native controller are still required for exceptional targets', () => {
|
||||||
|
const base = fixture();
|
||||||
|
base.custodians = [{ id: 'h', faction: 'harkonnen' }, { id: 't', faction: 'tleilaxu' }];
|
||||||
|
for (const patch of [
|
||||||
|
{ capturedBy: 't' }, { gholaBy: 'h' },
|
||||||
|
{ concealed: { captor: 't', controller: 'a', dead: false, deaths: 0 } },
|
||||||
|
{ capturedBy: 'h', controller: 'h' }, { gholaBy: 't', controller: 't' },
|
||||||
|
]) {
|
||||||
|
const context = structuredClone(base);
|
||||||
|
Object.assign(context.leaders[1], patch);
|
||||||
|
assert.ok(!moritaniAssassinateChoices(context).cards.some(choice => choice.card === 'atreides-1'));
|
||||||
|
assert.throws(() => quoteMoritaniAssassinate(context, 'atreides-1'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Auditor remains a native printed two-spice target with its explicit capture and foreign-ghola exclusions', () => {
|
||||||
|
const context = fixture('choam');
|
||||||
|
const auditor = createAuditorLeader();
|
||||||
|
context.leaders = [...context.leaders, auditor];
|
||||||
|
context.held = [auditor.id];
|
||||||
|
context.custodians = [{ id: 'h', faction: 'harkonnen' }, { id: 't', faction: 'tleilaxu' }];
|
||||||
|
assert.equal(quoteMoritaniAssassinate(context, auditor.id).bounty, 2);
|
||||||
|
for (const patch of [
|
||||||
|
{ capturedBy: 'h' }, { gholaBy: 't' },
|
||||||
|
{ concealed: { captor: 'h', controller: 'a', dead: false, deaths: 0 } },
|
||||||
|
]) {
|
||||||
|
const changed = structuredClone(context);
|
||||||
|
Object.assign(changed.leaders.at(-1)!, patch);
|
||||||
|
assert.deepEqual(projectedMoritaniAssassinateChoices(changed).cards, []);
|
||||||
|
assert.throws(() => quoteMoritaniAssassinate(changed, auditor.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
void test('a revealed card remains held until one Mentat replacement, and the used opposing faction cannot be assassinated again', () => {
|
void test('a revealed card remains held until one Mentat replacement, and the used opposing faction cannot be assassinated again', () => {
|
||||||
const context = fixture(),
|
const context = fixture(),
|
||||||
revealed = quoteMoritaniAssassinate(context, 'atreides-1').receipt;
|
revealed = quoteMoritaniAssassinate(context, 'atreides-1').receipt;
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||||
|
import {
|
||||||
|
createMoritaniAssassinateTargetFixture as fixture,
|
||||||
|
actAssassinateTarget as act,
|
||||||
|
assassinateTargetPlayer as player,
|
||||||
|
assassinateTargetInventory as inventory,
|
||||||
|
assassinateTargetTraitors as traitors,
|
||||||
|
finishMoritaniAssassinateTarget as finish,
|
||||||
|
} from './fixture-moritani-assassinate-target';
|
||||||
|
|
||||||
|
function rejected(game: Game, actor: string, action: Action) {
|
||||||
|
const before = structuredClone(game);
|
||||||
|
assert.throws(() => applyAction(game, actor, action));
|
||||||
|
assert.deepEqual(game, before);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('a genuinely captured native target is publicly assassinated once for its printed bounty and replaced at Mentat', () => {
|
||||||
|
const f = fixture();
|
||||||
|
const target = player(f.game, f.opponent).leaders.find(l => l.id === f.target)!;
|
||||||
|
assert.equal(target.capturedBy, f.captor);
|
||||||
|
assert.equal(target.dead, false);
|
||||||
|
assert.ok(target.concealed);
|
||||||
|
assert.notEqual(f.game.moritaniAssassinate!.opportunities.at(-1)!.opposingLeader, f.target);
|
||||||
|
const choice = viewGame(f.game, f.owner).moritaniAssassinate!.pending!;
|
||||||
|
assert.equal(choice.blocked, null);
|
||||||
|
assert.deepEqual(choice.cards.find(card => card.card === f.target), {
|
||||||
|
card: f.target, name: target.name, potentialBounty: target.strength,
|
||||||
|
});
|
||||||
|
const money = player(f.game, f.owner).spice;
|
||||||
|
const captorMoney = player(f.game, f.captor).spice;
|
||||||
|
const done = act(f.game, f.owner, f.action);
|
||||||
|
const dead = player(done, f.opponent).leaders.find(l => l.id === f.target)!;
|
||||||
|
assert.equal(dead.dead, true);
|
||||||
|
assert.equal(dead.deaths, target.deaths + 1);
|
||||||
|
assert.equal(dead.capturedBy, undefined);
|
||||||
|
assert.equal(dead.concealed, undefined);
|
||||||
|
assert.equal(dead.usedAt, undefined);
|
||||||
|
assert.equal(player(done, f.owner).spice, money + f.potentialBounty);
|
||||||
|
assert.equal(done.phase, 7);
|
||||||
|
assert.equal(player(done, f.captor).spice, captorMoney + 2);
|
||||||
|
assert.ok(done.log.some(row => row.text === 'Harkonnen received 2 spice from strongholds.'));
|
||||||
|
assert.equal(done.moritaniAssassinate!.opportunities.at(-1)!.bounty, f.potentialBounty);
|
||||||
|
assert.equal(player(done, f.opponent).leaders.find(l => l.id === f.winningDisc)!.dead, false);
|
||||||
|
rejected(done, f.owner, f.action);
|
||||||
|
const mentat = finish(done, f);
|
||||||
|
const receipt = mentat.moritaniAssassinate!.opportunities.find(r => r.event === f.event)!;
|
||||||
|
assert.equal(receipt.stage, 'replaced');
|
||||||
|
assert.ok(receipt.replacement);
|
||||||
|
assert.notEqual(receipt.replacement, f.target);
|
||||||
|
assert.equal(player(mentat, f.owner).traitors.includes(f.target), false);
|
||||||
|
assert.deepEqual(inventory(mentat), f.originalInventory);
|
||||||
|
assert.deepEqual(traitors(mentat), f.originalTraitors);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a genuinely concealed execution has the same pending choices and resolves publicly with zero bounty and no repeated death', () => {
|
||||||
|
const live = fixture();
|
||||||
|
const f = fixture({ execute: true });
|
||||||
|
const target = player(f.game, f.opponent).leaders.find(l => l.id === f.target)!;
|
||||||
|
assert.equal(target.dead, true);
|
||||||
|
assert.equal(target.deaths, 2);
|
||||||
|
assert.ok(target.concealed);
|
||||||
|
const visible = viewGame(f.game, f.owner).moritaniAssassinate!.pending!;
|
||||||
|
const liveVisible = viewGame(live.game, live.owner).moritaniAssassinate!.pending!;
|
||||||
|
assert.deepEqual(visible.cards, liveVisible.cards);
|
||||||
|
assert.equal(visible.blocked, liveVisible.blocked);
|
||||||
|
for (const card of visible.cards)
|
||||||
|
assert.deepEqual(Object.keys(card).sort(), ['card', 'name', 'potentialBounty']);
|
||||||
|
const money = player(f.game, f.owner).spice;
|
||||||
|
const done = act(f.game, f.owner, f.action);
|
||||||
|
const dead = player(done, f.opponent).leaders.find(l => l.id === f.target)!;
|
||||||
|
assert.equal(dead.dead, true);
|
||||||
|
assert.equal(dead.deaths, target.deaths);
|
||||||
|
assert.equal(dead.concealed, undefined);
|
||||||
|
assert.equal(dead.capturedBy, undefined);
|
||||||
|
assert.equal(player(done, f.owner).spice, money);
|
||||||
|
assert.equal(done.moritaniAssassinate!.opportunities.at(-1)!.bounty, 0);
|
||||||
|
assert.ok(done.log.some(row => row.text.includes('already in the Tanks; no new death or spice')));
|
||||||
|
for (const actor of [f.owner, f.opponent, f.captor]) {
|
||||||
|
const actual = viewGame(done, actor).players.find(p => p.id === f.opponent)!.leaders.find(l => l.id === f.target)!;
|
||||||
|
assert.equal(actual.dead, true);
|
||||||
|
assert.equal(viewGame(done, actor).moritaniAssassinate!.history.at(-1)!.bounty, 0);
|
||||||
|
}
|
||||||
|
const mentat = finish(done, f);
|
||||||
|
assert.equal(mentat.moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
|
||||||
|
assert.deepEqual(traitors(mentat), f.originalTraitors);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const execute of [false, true]) {
|
||||||
|
void test(`declining the ${execute ? 'concealed-executed' : 'living captive'} target preserves its original custody and physical Traitor`, () => {
|
||||||
|
const f = fixture({ execute });
|
||||||
|
const target = structuredClone(player(f.game, f.opponent).leaders.find(l => l.id === f.target)!);
|
||||||
|
const money = player(f.game, f.owner).spice;
|
||||||
|
const done = act(f.game, f.owner, f.decline);
|
||||||
|
assert.deepEqual(player(done, f.opponent).leaders.find(l => l.id === f.target), target);
|
||||||
|
assert.equal(player(done, f.owner).spice, money);
|
||||||
|
const mentat = finish(done, f);
|
||||||
|
assert.equal(mentat.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
|
||||||
|
assert.ok(player(mentat, f.owner).traitors.includes(f.target));
|
||||||
|
assert.deepEqual(player(mentat, f.opponent).leaders.find(l => l.id === f.target), target);
|
||||||
|
assert.deepEqual(traitors(mentat), f.originalTraitors);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('the target remains event-bound and owner-only with the original winning-disc restriction', () => {
|
||||||
|
const f = fixture();
|
||||||
|
rejected(f.game, f.captor, f.action);
|
||||||
|
rejected(f.game, f.owner, { ...f.action, event: 'other-battle' });
|
||||||
|
rejected(f.game, f.owner, { ...f.action, card: f.winningDisc });
|
||||||
|
rejected(f.game, f.owner, { ...f.action, card: DUKE_VIDAL_ID });
|
||||||
|
for (const actor of [f.opponent, f.captor])
|
||||||
|
assert.deepEqual(viewGame(f.game, actor).moritaniAssassinate!.pending!.cards, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const profile of DIFFICULTIES) {
|
||||||
|
void test(`${profile} legally uses the uniform potential-bounty choice for both genuine capture outcomes`, () => {
|
||||||
|
for (const execute of [false, true]) {
|
||||||
|
const f = fixture({ execute });
|
||||||
|
const own = viewGame(f.game, f.owner);
|
||||||
|
own.players.find(p => p.id === f.owner)!.bot = profile;
|
||||||
|
const actions = botActions(own);
|
||||||
|
assert.deepEqual(actions, [f.action]);
|
||||||
|
const done = act(f.game, f.owner, actions[0]);
|
||||||
|
assert.equal(done.moritaniAssassinate!.opportunities.at(-1)!.bounty, execute ? 0 : f.potentialBounty);
|
||||||
|
assert.equal(finish(done, f).moritaniAssassinate!.opportunities.at(-1)!.stage, 'replaced');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('an actual paid foreign-ghola target keeps native identity and Tleilaxu return custody after the controlled later battle', async () => {
|
||||||
|
const { createMoritaniAssassinateGholaTargetFixture } = await import('./fixture-moritani-assassinate-ghola-target');
|
||||||
|
const f = createMoritaniAssassinateGholaTargetFixture();
|
||||||
|
const target = player(f.game, f.opponent).leaders.find(l => l.id === f.target)!;
|
||||||
|
assert.equal(target.dead, false);
|
||||||
|
assert.ok(target.gholaBy);
|
||||||
|
const controller = target.gholaBy;
|
||||||
|
const money = player(f.game, f.owner).spice;
|
||||||
|
const controllerMoney = player(f.game, controller).spice;
|
||||||
|
const done = applyAction(f.game, f.owner, f.action);
|
||||||
|
const killed = player(done, f.opponent).leaders.find(l => l.id === f.target)!;
|
||||||
|
assert.equal(killed.faction, target.faction);
|
||||||
|
assert.equal(killed.dead, true);
|
||||||
|
assert.equal(killed.deaths, target.deaths + 1);
|
||||||
|
assert.equal(killed.gholaBy, controller);
|
||||||
|
assert.equal(killed.concealed, undefined);
|
||||||
|
assert.equal(player(done, f.owner).spice, money + f.potentialBounty);
|
||||||
|
assert.equal(player(done, controller).spice, controllerMoney);
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ void test('an original surviving Auditor2 win opens native CHOAM assassination,
|
|||||||
assert.ok(ownChoice, 'The actual owner receives the real private opportunity.');
|
assert.ok(ownChoice, 'The actual owner receives the real private opportunity.');
|
||||||
assert.equal(ownChoice.blocked, null);
|
assert.equal(ownChoice.blocked, null);
|
||||||
assert.deepEqual(ownChoice.cards.map(c => c.card), [f.target]);
|
assert.deepEqual(ownChoice.cards.map(c => c.card), [f.target]);
|
||||||
assert.equal(ownChoice.cards[0].bounty, printedTarget.strength);
|
assert.equal(ownChoice.cards[0].potentialBounty, printedTarget.strength);
|
||||||
assert.deepEqual(viewGame(pending, f.native).moritaniAssassinate!.pending!.cards, []);
|
assert.deepEqual(viewGame(pending, f.native).moritaniAssassinate!.pending!.cards, []);
|
||||||
|
|
||||||
const targetBefore = mixedE3MoritaniPlayer(pending, f.native).leaders.find(l => l.id === f.target)!;
|
const targetBefore = mixedE3MoritaniPlayer(pending, f.native).leaders.find(l => l.id === f.target)!;
|
||||||
@@ -79,7 +79,7 @@ void test('a different surviving CHOAM disc permits the actual Auditor Traitor t
|
|||||||
const f = createMoritaniAuditorWinnerFixture({ auditorTarget: true });
|
const f = createMoritaniAuditorWinnerFixture({ auditorTarget: true });
|
||||||
const pending = pendingMoritaniAuditorAssassination(f);
|
const pending = pendingMoritaniAuditorAssassination(f);
|
||||||
const own = viewGame(pending, f.moritani).moritaniAssassinate!.pending!;
|
const own = viewGame(pending, f.moritani).moritaniAssassinate!.pending!;
|
||||||
assert.deepEqual(own.cards.map(c => ({ card: c.card, bounty: c.bounty })), [
|
assert.deepEqual(own.cards.map(c => ({ card: c.card, bounty: c.potentialBounty })), [
|
||||||
{ card: CHOAM_AUDITOR_ID, bounty: 2 },
|
{ card: CHOAM_AUDITOR_ID, bounty: 2 },
|
||||||
]);
|
]);
|
||||||
const beforeSpice = mixedE3MoritaniPlayer(pending, f.moritani).spice;
|
const beforeSpice = mixedE3MoritaniPlayer(pending, f.moritani).spice;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ void test('original Ecaz Ambassador Duke6 win opens the formerly missing native
|
|||||||
const offer = viewGame(reload(pending), moritani).moritaniAssassinate!.pending!;
|
const offer = viewGame(reload(pending), moritani).moritaniAssassinate!.pending!;
|
||||||
assert.equal(offer.blocked, null);
|
assert.equal(offer.blocked, null);
|
||||||
assert.deepEqual(offer.cards.map(c => c.card), [target]);
|
assert.deepEqual(offer.cards.map(c => c.card), [target]);
|
||||||
assert.equal(offer.cards[0].bounty, native.strength);
|
assert.equal(offer.cards[0].potentialBounty, native.strength);
|
||||||
assert.equal(viewGame(pending, ecaz).moritaniAssassinate!.pending!.cards.length, 0);
|
assert.equal(viewGame(pending, ecaz).moritaniAssassinate!.pending!.cards.length, 0);
|
||||||
const d = pending.decision;
|
const d = pending.decision;
|
||||||
assert.ok(d?.kind === 'moritaniAssassinate');
|
assert.ok(d?.kind === 'moritaniAssassinate');
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ void test('a real Tleilaxu foreign ghola victory opens Moritani’s private loss
|
|||||||
'Only the opposing player’s native Tleilaxu identities are targets.');
|
'Only the opposing player’s native Tleilaxu identities are targets.');
|
||||||
assert.equal(f.window.cards.some((choice) => choice.card === FOREIGN_GHOLA_DISC), false,
|
assert.equal(f.window.cards.some((choice) => choice.card === FOREIGN_GHOLA_DISC), false,
|
||||||
'The borrowed foreign disc never becomes a target identity.');
|
'The borrowed foreign disc never becomes a target identity.');
|
||||||
assert.equal(f.window.cards.find((choice) => choice.card === f.target)!.bounty, f.bounty);
|
assert.equal(f.window.cards.find((choice) => choice.card === f.target)!.potentialBounty, f.bounty);
|
||||||
|
|
||||||
// Original continuation: printed bounty, one physical death, Mentat replacement.
|
// Original continuation: printed bounty, one physical death, Mentat replacement.
|
||||||
const outcome = revealMoritaniForeignGholaWitness(f);
|
const outcome = revealMoritaniForeignGholaWitness(f);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ void test('original native Ixians loss pays the held native target bounty and Me
|
|||||||
assert.equal(pendingReceipt.faction, 'ixians');
|
assert.equal(pendingReceipt.faction, 'ixians');
|
||||||
assert.equal(pendingReceipt.opposingLeader, f.battle.enemyLeader);
|
assert.equal(pendingReceipt.opposingLeader, f.battle.enemyLeader);
|
||||||
const ownerChoice = viewGame(f.pending, f.moritani).moritaniAssassinate!.pending!;
|
const ownerChoice = viewGame(f.pending, f.moritani).moritaniAssassinate!.pending!;
|
||||||
assert.deepEqual(ownerChoice.cards.map(card => ({ card: card.card, bounty: card.bounty })),
|
assert.deepEqual(ownerChoice.cards.map(card => ({ card: card.card, bounty: card.potentialBounty })),
|
||||||
[{ card: f.target, bounty: target.strength }]);
|
[{ card: f.target, bounty: target.strength }]);
|
||||||
assert.deepEqual(viewGame(f.pending, f.native).moritaniAssassinate!.pending!.cards, []);
|
assert.deepEqual(viewGame(f.pending, f.native).moritaniAssassinate!.pending!.cards, []);
|
||||||
assert.equal(mixedE3MoritaniPlayer(f.revealed, f.moritani).spice,
|
assert.equal(mixedE3MoritaniPlayer(f.revealed, f.moritani).spice,
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ void test('Advanced standalone Moritani keeps the printed Assassinate power thro
|
|||||||
assert.equal(choice.blocked, null);
|
assert.equal(choice.blocked, null);
|
||||||
assert.deepEqual(choice.cards, [{ card: f.traitorTarget,
|
assert.deepEqual(choice.cards, [{ card: f.traitorTarget,
|
||||||
name: owned(f, f.pending, f.traitorTarget).name,
|
name: owned(f, f.pending, f.traitorTarget).name,
|
||||||
bounty: owned(f, f.pending, f.traitorTarget).strength, dead: false }]);
|
potentialBounty: owned(f, f.pending, f.traitorTarget).strength }]);
|
||||||
assert.equal(choice.cards.some((card) => card.card === f.winnerLeader), false);
|
assert.equal(choice.cards.some((card) => card.card === f.winnerLeader), false);
|
||||||
assert.deepEqual(choiceOf(f.pending, f.opponent).cards, []);
|
assert.deepEqual(choiceOf(f.pending, f.opponent).cards, []);
|
||||||
assert.deepEqual(choiceOf(f.pending, f.third).cards, []);
|
assert.deepEqual(choiceOf(f.pending, f.third).cards, []);
|
||||||
@@ -171,7 +171,7 @@ void test('Advanced standalone Moritani keeps the printed power without Leader S
|
|||||||
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
|
assert.equal(f.pending.decision?.kind, 'moritaniAssassinate');
|
||||||
const choice = choiceOf(f.pending, f.moritani);
|
const choice = choiceOf(f.pending, f.moritani);
|
||||||
assert.deepEqual(choice.cards.map((card) => card.card), [f.traitorTarget]);
|
assert.deepEqual(choice.cards.map((card) => card.card), [f.traitorTarget]);
|
||||||
assert.equal(choice.cards[0].bounty, owned(f, f.pending, f.traitorTarget).strength);
|
assert.equal(choice.cards[0].potentialBounty, owned(f, f.pending, f.traitorTarget).strength);
|
||||||
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
|
const spent = chooseMoritaniIndependentSkillsAssassination(f.pending, f.traitorTarget);
|
||||||
const target = owned(f, spent, f.traitorTarget);
|
const target = owned(f, spent, f.traitorTarget);
|
||||||
assert.equal(target.dead, true);
|
assert.equal(target.dead, true);
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ void test('normal held Suk physical rescue precedes unused-disc assassination, c
|
|||||||
assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3);
|
assert.equal(player(pending, f.opponent).spice, player(f.revealed, f.opponent).spice - 3);
|
||||||
const d = pending.decision;
|
const d = pending.decision;
|
||||||
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected loss choice');
|
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected loss choice');
|
||||||
assert.equal(viewGame(pending, f.moritani).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!.bounty, 3);
|
assert.equal(viewGame(pending, f.moritani).moritaniAssassinate!.pending!.cards.find(c => c.card === target.id)!.potentialBounty, 3);
|
||||||
const action = { type: 'decision', event: d.event, card: target.id };
|
const action = { type: 'decision', event: d.event, card: target.id };
|
||||||
reject(pending, f.opponent, action); reject(pending, f.moritani, { ...action, event: 'stale' });
|
reject(pending, f.opponent, action); reject(pending, f.moritani, { ...action, event: 'stale' });
|
||||||
reject(pending, f.moritani, { ...action, card: f.opponentLeader });
|
reject(pending, f.moritani, { ...action, card: f.opponentLeader });
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ for (const hasKarama of [false, true]) void test(`the normal Guild Suk rescue an
|
|||||||
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected original loss choice');
|
if (d?.kind !== 'moritaniAssassinate') throw Error('Expected original loss choice');
|
||||||
const action: Action = { type: 'decision', event: d.event, card: f.target };
|
const action: Action = { type: 'decision', event: d.event, card: f.target };
|
||||||
const privateMenu = viewGame(pending, f.moritani).moritaniAssassinate!.pending!.cards;
|
const privateMenu = viewGame(pending, f.moritani).moritaniAssassinate!.pending!.cards;
|
||||||
assert.equal(privateMenu.find(c => c.card === f.target)!.bounty, 3);
|
assert.equal(privateMenu.find(c => c.card === f.target)!.potentialBounty, 3);
|
||||||
assert.equal(privateMenu.some(c => c.card === f.opponentLeader), false);
|
assert.equal(privateMenu.some(c => c.card === f.opponentLeader), false);
|
||||||
reject(pending, f.opponent, action); reject(pending, f.moritani, { ...action, event: `${d.event}:stale` });
|
reject(pending, f.opponent, action); reject(pending, f.moritani, { ...action, event: `${d.event}:stale` });
|
||||||
reject(pending, f.moritani, { ...action, card: f.opponentLeader });
|
reject(pending, f.moritani, { ...action, card: f.opponentLeader });
|
||||||
|
|||||||
Reference in new issue
Block a user