Preserve permanent Traitor markers through legal replacement recycling

This commit is contained in:
Quintine committed 2026-10-15 19:08:16 +11:00
1 parent 1cd7db7d0b
commit e265af3cb3
29 files changed
+1273 -68

No files matched your search

+24
View File
@@ -29,6 +29,30 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Physical Traitor replacements and permanent markers — 15 October.**
Original native Ix play now draws the real Hero Traitor after Dominic4's
retirement, without turning it into a leader disc or faction target.
Shared optional permanent-card census preserves retired victims across
Nexus exchange, Face Dancer, Betrayal source/replacement and Rihani consumers.
Old absent-zone formats remain; completed source history stays historical.
A replacement may legally return and be drawn again: native Hasimir6 return,
retained Esmar3, a real turn-three Guild loss/retirement and one Hasimir6
redraw now work without rewriting the first receipt or older Nexus history.
Affected15-file134/134, final types/lint/build and11 original API/policy paths pass.
Existing build deprecation/chunk/Unknown-route warnings remain. Actual styled390px
controls Ready through owned Mentat initialization, inspect private Hero,
return Hasimir/Caid, restore/pass Mori placement, reach next Storm and reveal
Esmar3 before the second draw. One live Hasimir, two permanent markers;
no overflow/browser errors. All four mandatory-return policies legally keep
Hasimir; the human/scripted old-card return is distinct, not calibrated strategy.
Older adjacent fixtures now consume actual borrowed Voice before native
Prescience/plans; obsolete ownership/uniqueness and wording/markup pins are
removed, not re-pinned. No wallet/hand/army/leader/phase/battle staging.
[Canonical roles, exact source and boundaries](MORITANI_ASSASSINATE_LEADERS.md#physical-replacement-roles-and-retired-card-zone--15-october-2026).
No saved/deployed room touched. Full assurance, optional combinations,
exceptional targets, public starts and full project completion remain open.
**Canonical Moritani winning discs and ordinary Guild arrival — 15 October.**
The surviving played disc no longer has to be an ordinary native of its
victorious player. Original Ecaz/Duke6 opens Sanya Ecaz4; original
+66
View File
@@ -1,5 +1,71 @@
# Dune implementation status
## 15 October 2026 — Physical Traitor replacements, retired zone and legal recycling
Original Moritani Mentat replacement accepts the actual Ix Hero Traitor as a
physical card, not a faction leader disc/target. Native Ix play retires Dominic4
and draws one Hero; native controlled leaders and five-disc identity stay unchanged.
The actual owned phase-opening initialization still precedes automatic retirement:
phase8 alone is not a completed Mentat draw.
Added optional `NexusTraitorSnapshot.removed`, derived only from actual replaced
Mori victim cards. Full canonical census retains existing Loyalty exclusion;
removed cards cannot enter reserve/hands/Face Dancers or fund draw/return.
Exchange sources and Face Dancer receipts conditionally bind this physical zone,
preserving old absent-field grammar. Betrayal's whole serialized source already
carries it; replacement rejects a source-permanent card without parsing that
inventory for a still-due/null draw. Rihani's full before/state preserves the
zone and rejects rewriting it. No Game marker field is imported from a helper:
engine application only changes actual reserve/hands/Face Dancers.
Removed the false coupling between a historical replacement ID and permanent
live ownership/unique historical draw. Original Harkonnen Secret Ally may return
Hasimir6, keep newly drawn Esmar3 and restore the original Mori placement.
A real later turn-three paid battle/retirement draws the same physical Hasimir
again. First receipt/signature and older Nexus source stay unchanged after a
second public marker; one live card and both permanent victims remain conserved.
Current actor/card authorization, physical census, revealed-until-Mentat rule,
native target, once-use and normal-call forfeiture remain.
Three failing-before observations identify the role-only Hero parser, missing
retired-card census and historical-live-ownership guard in actual native play.
Shared helper/engine consumers and original native producers are integrated
without a save-schema migration, second deck or fabricated card.
Shared fixture options direct only original undealt reserve prefix and actual
closing Nexus singleton; return-shuffle entropy and conserved unplayed Spice
ordering are disclosed. No raw played reserve/hand/wallet/forces/leader/phase/
battle or ledger assignment produces the accepted native paths.
Focused nine-file76/76 passes. The broader affected15-file union exposed obsolete
old ownership/uniqueness assertions and fixtures skipping original borrowed Voice
before Prescience/plans. Those fixtures now use the existing canonical native
preparation actions, leaving requested native Prescience available. No rules or
bot legality is weakened. Repaired affected15-file **134/134** passes.
Removed markup/wiring/wording pins, unnecessary casts and their unused imports.
**Eleven original API/policy scenarios** pass: Hero/next Storm, four final
Ready paths through original owned initialization, real old-card return,
native repeated draw, and four mandatory-return policies. All four return
policies keep Hasimir in that position; the human/scripted return gives it back.
No strategic tuning or calibration claim.
Actual styled390px GameTable performs final Collection readiness and private
Hero inspection; draws two Secret Ally cards; native checkboxes return Hasimir6
plus Caid3, keep Esmar3 and restore/pass Mori placement; original continuation
reaches next Storm. A separate genuine recycled chronology reveals Esmar3 and
shows one Hasimir6 after second Mentat, with both public markers and unchanged
older Nexus history. No horizontal overflow/browser errors. The first UI
return uses live randomness; the separately generated recycled chronology
discloses its original return-shuffle entropy.
This implements printed card mechanics, not a new privacy/custody/recovery
campaign. Receipt consistency does not authenticate every alleged past save.
No saved/deployed rooms, public gates or existing game state are changed;
full combinations, assurance, exceptional targets and full goal remain open.
Final types/lint and production build pass after reference integration.
Existing module.register deprecation, large-chunk and Unknown-route warnings remain.
[Canonical source, controls and scope](MORITANI_ASSASSINATE_LEADERS.md#physical-replacement-roles-and-retired-card-zone--15-october-2026).
## 15 October 2026 — Canonical Moritani winning discs, Auditor targets and Guild entry
Separated canonical played-disc identity from the victorious player's printed
+12
View File
@@ -5,6 +5,18 @@ Leader Skills module from *CHOAM & Richese*. The later [first runtime prototype]
records connected behavior and remaining gaps. This contract does not enable
the module or certify its combinations.
## Rihani and permanent Traitor markers — 15 October 2026
The shared canonical Traitor snapshot now includes an optional permanent
`removed` zone derived from Moritani's retired victims. Rihani preserves that
zone in its full before/state receipt and cannot peek, draw, return or rewrite
one of those cards. Existing used-card exclusions, native Tleilaxu Face Dancer
classification and both original skill bands remain. Old absent-zone JSON
receipts keep their existing body/signature. Focused physical-transition
coverage verifies this consumer; the coupled native Nexus proof is not a
claim of a new full native Rihani/Moritani or saved-game assurance campaign.
[Canonical shared role/census and evidence](MORITANI_ASSASSINATE_LEADERS.md#physical-replacement-roles-and-retired-card-zone--15-october-2026).
## Sources and local evidence
The governing source is Gale Force Nine's twelve-page English *CHOAM &
+68 -10
View File
@@ -209,6 +209,62 @@ Exceptional captured/Ghola **targets**, Skills/Harkonnen, private-step UX,
remaining arrival combinations, full assurance and deployed acceptance remain
separate. Passing these local native paths does not certify complete modes.
## Physical replacement roles and retired-card zone — 15 October 2026
The original Mentat draw takes a **physical Traitor Card**, not necessarily a
faction leader-disc identity. With the original Ix deck selected, the single
Cheap Hero / Heroine Traitor is a legal replacement. It remains a Traitor:
no physical leader, native-faction assassination target or Duke Traitor is
manufactured. Original native Ix play kills Dominic Vernius4, retires its real
held card and draws that original reserve Hero once.
Retired victims remain permanently out of circulation. The shared
`NexusTraitorSnapshot.removed` zone derives from actual `stage: 'replaced'`
victim cards, not from historical replacement IDs. It participates in the
canonical full census, retaining the existing Ecaz Loyalty exclusion, but
cannot appear in reserve, ordinary hands or Face Dancers. Harkonnen exchange,
Tleilaxu Cunning, Betrayal source/replacement and Rihani consumers preserve
that zone. This is distinct from Face Dancers temporarily set aside before
their own return shuffle, or a Betrayal-declared card returned to the deck.
A replacement's recorded ID is a **historical draw**, not permanent ownership.
Its real card can later be exchanged, returned and drawn again. Keep the old
receipt/signature unchanged; count current custody once, not once per past
reference. Original native CHOAM/Auditor play draws Hasimir6, Harkonnen Secret
Ally returns that card while keeping Esmar3, then a real turn-three Guild loss
retires Esmar3 and original Mentat draws the same Hasimir6 again. Both public
victim markers stay out; one live Hasimir card remains.
New Nexus exchange sources carry/sign the optional permanent zone when present.
Absent-zone records retain their original grammar/signature; completed history
validates its recorded physical state rather than adopting later live markers.
No old receipt is rewritten when a second victim retires. Engine application
never rewrites the derived Game marker zone from an exchange result. Existing
actor/card authorization, full current census, native target guards and
revealed-card reservation remain.
Affected15-file **134/134** passes, including adjacent engine/continuation
consumers. **Eleven original API/policy paths** cover Hero/next Storm, all four
Ready paths through actual owned Mentat initialization, legal old-card return,
the repeated native draw and all four pending-return policies. All four return
policies legally keep Hasimir; the human/scripted return chooses to give it back.
This is legal participation, not strategic calibration.
Actual styled390px GameTable completes final Collection readiness, displays
the private Hero inspector, draws two real Nexus cards, returns Hasimir/Caid
through the native checkboxes, restores the original Mori placement, passes it
and reaches the next Storm. A separate original recycled chronology reveals
Esmar3 and displays one Hasimir6 after the second draw. No overflow/browser
errors. Only original deal/return-shuffle entropy and conserved unplayed
Spice/Nexus ordering are directed; no hand, wallet, force, leader, phase or
battle is staged. No saved/deployed rooms or broad assurance are covered.
The wider consumer run exposed older fixtures skipping the real borrowed-Voice
opportunity before native Prescience/plans. They now consume the original
canonical preparation actions; no engine legality is weakened. Old permanent
replacement-ownership/uniqueness and wording/markup pins were removed, not
re-pinned. Receipt consistency is not a claim of authenticated past save history.
## Save and physical custody
The resolved battle binds the opportunity's event, opposing faction and surviving
@@ -217,17 +273,19 @@ the decision opens. Reveal/decline resumes that exact cleanup once. A normal
traitor call has a separate event ledger; its explanatory log waits until the
normal public reveal, preserving sealed submissions.
A physical Traitor Card belongs to one of three zones: reserve deck, player hands,
or this ability's public set-aside markers. Revealed cards stay held until Mentat;
retired cards never return to the deck in this profile. A replacement remains with
Moritani or becomes the card consumed by a later-turn assassination against another
faction. Cross-zone duplication, missing replacements, duplicate draws, forged
same-turn chains and altered continuation receipts fail validation.
A physical Traitor Card belongs to reserve, an ordinary/Face Dancer hand,
or a public permanent marker in the existing canonical census. Revealed
assassination cards remain held until their actual Mentat retirement.
Retired victims never return. Historical replacement references may recur
after legal physical exchanges; they are not additional live cards or a
permanent hand-ownership constraint. Cross-zone duplication and altered
pending continuations still reject.
Replacement runs automatically when Mentat begins. In this bounded profile it does
not compete with other traitor draws, and Terror placement does not alter traitor
custody. Ordering with Face Dancers, Nexus/Rihani draws, Extortion and other modules
remains an explicit integration boundary, not an inferred universal ordering.
Replacement runs during original Mentat initialization, after the owned phase
opening; merely displaying phase8 does not mean that initialization finished.
The current native Nexus path can pause/resume original Mori placement without
repeating retirement, drawing or payment. Complete timing/optional-module and
save-authenticity assurance remain separate from these exercised local paths.
## Reproduction and evidence
+19
View File
@@ -2,6 +2,25 @@
Original source audit, 10 September 2026, with dated bounded runtime follow-ups below. This document establishes the printed component inventory, source protocol and implementation boundaries; none lifts expansion release gates. The ordinary worm-triggered alliance Nexus already in the engine is distinct from this optional card module.
## Moritani permanent markers and live replacements — 15 October 2026
Original Harkonnen Secret Ally now works after Moritani has permanently retired
a victim Traitor. The canonical snapshot carries optional `removed` cards:
they remain in its census but cannot be drawn, held or returned. This keeps the
existing Ecaz Loyalty exclusion. Exchange and Face Dancer history bind their
own source zone; old absent-zone grammar stays unchanged. Betrayal's serialized
source and Rihani's before/state also preserve it. No mode gate is widened.
A recorded Mentat replacement remains an ordinary physical card: it may be
returned and later drawn again without rewriting the older receipt or treating
two historical references as two live cards. Hero is a legal original Ix
Traitor replacement, not a physical opposing leader or faction target.
Actual native Hasimir return, suspended Mori placement, next-turn Guild
target/Esmar3 retirement and one physical Hasimir redraw are connected.
[Canonical role/census contract, 134/134 and11 API/policy paths](MORITANI_ASSASSINATE_LEADERS.md#physical-replacement-roles-and-retired-card-zone--15-october-2026).
Actual390px controls/inspectors pass without overflow/browser errors;
full combinations, saved/deployed assurance and publication remain open.
## Canonical Moritani winning discs — 15 October 2026
Existing native Advanced profiles now distinguish the actual surviving played
+18
View File
@@ -2,6 +2,24 @@
Source audit, 10 September 2026. This is a rules contract, not a runtime completion claim. The [common Nexus lifecycle](NEXUS_CARD_RULES.md) and existing expansion release gates still apply. The pending reaction-privacy question remains unresolved; this audit does not authorize a Betrayal reaction policy.
## Permanent markers and legal replacement return — 15 October 2026
Shared Harkonnen exchange snapshots now include Moritani's derived permanent
`removed` zone in the canonical census. It is not reserve or hand custody and
cannot fund a draw/return. New source records bind it; old absent-zone tuple
grammar remains unchanged, and older completed history does not adopt later
live markers. A historical Moritani replacement can legally return and be
drawn again; only its actual public victim marker remains permanently out.
This is distinct from Betrayal returning the declared Traitor to reserve,
which may legitimately be redrawn. Its whole serialized source preserves
existing permanent markers and cannot use one for replacement. The bounded
classic-only Betrayal roster is unchanged; no native Moritani/Betrayal
composition is certified by the pure source/replacement regressions.
Actual native Secret Ally controls return Hasimir6, keep Esmar3, restore Mori
placement and preserve historical receipts through a later same-card redraw.
[Canonical source/role, native evidence and remaining gates](MORITANI_ASSASSINATE_LEADERS.md#physical-replacement-roles-and-retired-card-zone--15-october-2026).
**1 October bounded local follow-up, verified 2 October:** `harkonnen-betrayal`
connects a separate fresh classic/base-deck/Nexus-only Basic/Advanced preview.
Native allied Harkonnen counters precede a uniform acknowledgement by public
+13
View File
@@ -9,6 +9,19 @@ Source audit, 10 September 2026. This document does not certify runtime support
- [GF9 Ixians & Tleilaxu, pp.6–7,9 and Karama table](https://www.gf9games.com/dunegame/wp-content/uploads/2020/09/IxianAndTleilaxuRulebook.pdf#page=7): ordinary Face Dancer cycle, native revival powers, typed Ixian forces and cancellation boundaries.
- [GF9 base rulebook, pp.9,13,16–19,22](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf#page=9), and [November 2020 FAQ, pp.2,5–9](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=2): ordinary revival, repeated deaths, special forces, Kwisatz, Ghola and special revival permissions. Fresh official indexed excerpts were inspected. Existing Ghola implementation is not substitute authority for this Nexus effect.
## Permanent Traitor markers — 15 October 2026
The shared snapshot's optional `removed` zone accounts for Moritani victim
cards permanently out of circulation, retaining the canonical census and
existing Loyalty exclusion. It is **not** Cunning's temporary set-aside group:
those revealed Face Dancers still return after the replacement draw.
Cunning draws only active reserve cards, preserves the permanent zone and
binds its own original source in history. Later markers do not rewrite an
older completed receipt; absent-zone signatures keep their old grammar.
Focused Face Dancer/engine regressions and the coupled native exchange
checkpoint pass, not a new full native Moritani/Face-Dancer combination audit.
[Physical role/census contract and actual proof](MORITANI_ASSASSINATE_LEADERS.md#physical-replacement-roles-and-retired-card-zone--15-october-2026).
## Cunning: complete physical operation
Native Tleilaxu sets aside every revealed Face Dancer, privately draws the same number of replacements, and only then shuffles the set-aside identities into the Traitor Deck. Unrevealed dancers remain held. The panel has no phase restriction, no subset choice and no second choice after drawing. [Printed Tleilaxu face](https://boardgamegeek.com/image/7767032/dune-ecaz-and-moritani)
+16
View File
@@ -2,6 +2,22 @@
Updated 15 October 2026. This is the authoritative navigation index for existing decisions, not a replacement rulebook or a new ruling. Detailed linked contracts retain their source citations and exact supported boundaries. Runtime follow-ups supersede historical absence claims; passing tests establish software behavior, not publisher authority.
## Physical Traitor roles — 15 October 2026
- A Moritani Mentat replacement is an actual Traitor Card, including the one
Hero identity when the original Ix deck supplies it. Hero is not a leader
disc or printed opposing-faction assassination target; Duke has no Traitor.
- Public retired victims remain permanently out. A historical replacement ID
is not permanent current ownership: the real card may be legally returned
and later drawn again, with its old receipt unchanged.
- The canonical snapshot's optional permanent zone is derived from actual
retired victims, retaining existing Loyalty exclusion and all active-custody
checks. Old absent-zone formats remain; completed records keep their own
source rather than adopting later markers. No new numeric ruling, privilege,
mode gate or full save-authenticity claim is introduced.
[Native chronology, controls and current evidence](MORITANI_ASSASSINATE_LEADERS.md#physical-replacement-roles-and-retired-card-zone--15-october-2026).
## Source-clear interaction cutovers — 15 October 2026
- **Moritani played disc versus target faction:** the surviving actual
+9 -11
View File
@@ -3282,10 +3282,15 @@ function nexusTraitorUniverse(g: Game) {
] })), g.expansions.includes('ix')), g.ecazLoyalty);
}
function nexusTraitorSnapshot(g: Game): NexusTraitorSnapshot {
return { reserve: [...(g.traitorReserve ?? [])], players: g.players.map(p => ({
const snapshot: NexusTraitorSnapshot = { reserve: [...(g.traitorReserve ?? [])], players: g.players.map(p => ({
id: p.id, faction: p.faction, traitors: [...p.traitors],
...(p.faceDancers ? { faceDancers: structuredClone(p.faceDancers) } : {}),
})) };
if (g.moritaniAssassinate) {
for (const receipt of g.moritaniAssassinate.opportunities)
if (receipt.stage === 'replaced') (snapshot.removed ??= []).push(receipt.card!);
}
return snapshot;
}
function pendingNexusTraitors(g: Game) {
return g.nexusTraitorExchanges?.find(exchange => exchange.stage === 'return') ?? null;
@@ -3371,6 +3376,7 @@ function nexusTraitorOffer(g: Game, id: string) {
return { event: JSON.stringify(['nexusTraitors', g.turn, g.phase, g.nexusTraitorExchanges?.length ?? 0, id]), mode, draw, blocked };
}
function applyNexusTraitorSnapshot(g: Game, snapshot: NexusTraitorSnapshot) {
// Permanently removed cards are derived from original game receipts, not rewritten by an exchange.
g.traitorReserve = [...snapshot.reserve];
for (const entry of snapshot.players) {
const player = getPlayer(g, entry.id);
@@ -8288,16 +8294,8 @@ function moritaniAssassinateIntegrity(g: Game) {
'Assassination must preserve every physical Traitor Card in hand, deck or face-up set-aside custody.');
for (const r of state.opportunities.filter(r => r.stage === 'revealed'))
requireRule(getPlayer(g,r.owner).traitors.includes(r.card!), 'The revealed assassination card must remain held until Mentat replacement.');
const replacements = new Set<string>();
for (const [index,r] of state.opportunities.entries()) if (r.stage === 'replaced') {
const laterUses = state.opportunities.slice(index+1).filter(next => next.card === r.replacement &&
(next.stage === 'revealed' || next.stage === 'replaced'));
const ownedReplacement = laterUses.length ? laterUses.every(next => next.turn > r.turn)
: getPlayer(g,r.owner).traitors.includes(r.replacement!);
requireRule(!replacements.has(r.replacement!) && ownedReplacement,
'Each assassination replacement must stay with Moritani or be consumed by its later assassination.');
replacements.add(r.replacement!);
}
// Replacement IDs record historical draws, not permanent hand ownership.
// Later legal exchanges/recycled draws retain the complete physical census above.
}
}
function syncMoritaniAssassinateReceipt(g: Game, receipt: MoritaniAssassinateReceipt) {
+2 -1
View File
@@ -1,6 +1,7 @@
import { CHOAM_AUDITOR_ID, createAuditorLeader, leaders as printedLeaders, type Leader } from './cards';
import { FACTIONS, type FactionId } from './catalog';
import { DUKE_VIDAL_ID } from './duke-vidal';
import { CHEAP_HERO_TRAITOR } from './traitors';
export type MoritaniAssassinateReceipt = {
event: string;
@@ -267,7 +268,7 @@ export function validateMoritaniAssassinate(
else
requireAssassinate(
identifier(r.replacement) &&
printedDiscs.has(r.replacement) &&
(printedDiscs.has(r.replacement) || r.replacement === CHEAP_HERO_TRAITOR) &&
r.replacement !== r.card &&
(!custody ||
(custody.removed.includes(r.card!) &&
+1
View File
@@ -108,6 +108,7 @@ function signature(receipt: Omit<NexusFaceDancerReceipt, 'signature'>): string {
receipt.replaced,
receipt.drawn,
receipt.settledReserve,
...(Object.hasOwn(receipt.source, 'removed') ? [receipt.source.removed] : []),
]);
}
function resultingState(receipt: NexusFaceDancerReceipt): NexusTraitorSnapshot {
+6
View File
@@ -440,6 +440,11 @@ function validateReplacement(
(replacement.drawn === null || receipt.source.universe.includes(replacement.drawn)) &&
replacement.signature === replacementSignature(replacement),
'Harkonnen Betrayal replacement lost its exact successful Use obligation or actual drawn identity.');
if (replacement.drawn !== null) {
const [, originalTraitors] = JSON.parse(receipt.source.inventory) as [unknown, NexusTraitorSnapshot];
requireBetrayal(!originalTraitors.removed?.includes(replacement.drawn),
'Permanently set-aside Traitor Cards cannot fund a Betrayal replacement.');
}
}
export function validateHarkonnenBetrayalReplacement(
replacement: HarkonnenBetrayalReplacement, history: readonly HarkonnenBetrayalReceipt[],
@@ -458,6 +463,7 @@ export function drawHarkonnenBetrayalReplacement(
'Harkonnen Betrayal replacement draws exactly once from its actual canonical Traitor Deck.');
const result: HarkonnenBetrayalReplacement = { ...replacement, status: 'drawn', drawn, signature: '' };
result.signature = replacementSignature(result);
validateReplacement(result, history);
return result;
}
export function validateHarkonnenBetrayalReplacements(
+16 -3
View File
@@ -10,6 +10,8 @@ export type NexusExchangePlayer = {
export type NexusTraitorSnapshot = {
reserve: string[];
players: NexusExchangePlayer[];
/** Physical cards permanently set aside, not available for any draw or return. */
removed?: string[];
};
export type NexusTraitorExchange = {
version: 1;
@@ -26,6 +28,7 @@ export type NexusTraitorExchange = {
ownerFaction: FactionId;
reserve: string[];
others: NexusExchangePlayer[];
removed?: string[];
};
settledReserve?: string[];
signature: string;
@@ -119,8 +122,9 @@ export function validateNexusTraitorSnapshot(
requireExchange(
ids(universe) &&
universe.length > 0 &&
exact(state, ['reserve', 'players']) &&
exact(state, ['reserve', 'players', ...(Object.hasOwn(state, 'removed') ? ['removed'] : [])]) &&
ids(state.reserve) &&
(!Object.hasOwn(state, 'removed') || ids(state.removed)) &&
Array.isArray(state.players) &&
state.players.length >= 2 &&
state.players.length <= 6,
@@ -135,6 +139,7 @@ export function validateNexusTraitorSnapshot(
);
const physical = [
...state.reserve,
...(state.removed ?? []),
...state.players.flatMap((p) => [
...p.traitors,
...(p.faceDancers ?? []).map((c) => c.leader),
@@ -198,6 +203,7 @@ function signature(exchange: Omit<NexusTraitorExchange, 'signature'>): string {
exchange.source.reserve,
exchange.source.others.map(playerTuple),
exchange.settledReserve ?? null,
...(Object.hasOwn(exchange.source, 'removed') ? [exchange.source.removed] : []),
]);
}
function validateMode(
@@ -252,8 +258,10 @@ export function validateNexusTraitorExchange(
ids(exchange.drawn) &&
ids(exchange.returned) &&
Array.isArray(exchange.before) &&
exact(exchange.source, ['ownerFaction', 'reserve', 'others']) &&
exact(exchange.source, ['ownerFaction', 'reserve', 'others',
...(Object.hasOwn(exchange.source, 'removed') ? ['removed'] : [])]) &&
ids(exchange.source.reserve) &&
(!Object.hasOwn(exchange.source, 'removed') || ids(exchange.source.removed)) &&
Array.isArray(exchange.source.others),
'Invalid saved Nexus traitor exchange.',
);
@@ -275,6 +283,7 @@ export function validateNexusTraitorExchange(
validatePlayer(originalOwner);
const original: NexusTraitorSnapshot = {
reserve: exchange.source.reserve,
...(Object.hasOwn(exchange.source, 'removed') ? { removed: exchange.source.removed } : {}),
players: state.players.map((p) =>
p.id === exchange.owner
? originalOwner
@@ -282,7 +291,9 @@ export function validateNexusTraitorExchange(
),
};
requireExchange(
exchange.source.others.length === state.players.length - 1 &&
Object.hasOwn(state, 'removed') === Object.hasOwn(exchange.source, 'removed') &&
equal(state.removed, exchange.source.removed) &&
exchange.source.others.length === state.players.length - 1 &&
equal(
exchange.source.others.map(playerTuple),
state.players.filter((p) => p.id !== exchange.owner).map(playerTuple),
@@ -357,6 +368,7 @@ export function beginNexusTraitorExchange(
source: {
ownerFaction: player.faction,
reserve: [...state.reserve],
...(Object.hasOwn(state, 'removed') ? { removed: [...state.removed!] } : {}),
others: structuredClone(
state.players.filter((p) => p.id !== input.owner),
),
@@ -454,6 +466,7 @@ export function validateNexusTraitorHistory(
validateNexusTraitorExchange(
{
reserve: exchange.settledReserve!,
...(Object.hasOwn(exchange.source, 'removed') ? { removed: [...exchange.source.removed!] } : {}),
players: [owner, ...structuredClone(exchange.source.others)],
},
universe,
+23 -11
View File
@@ -1964,6 +1964,7 @@ export const RULE_TOPICS: RuleTopic[] = [
title: 'Cheap Hero and Heroine traitor',
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary:
'One expansion traitor card can betray either Cheap Hero card repeatedly.',
steps: [
@@ -1971,14 +1972,23 @@ export const RULE_TOPICS: RuleTopic[] = [
'If an opponent uses either hero as their battle leader, the holder may reveal this traitor. The ordinary traitor outcome applies: the opponent loses its army and played cards; the hero has zero strength and grants no leader bounty.',
'Keep the traitor after revealing it. You can call it again against either hero in later battles, including against a different opponent. Revealed traitor identities remain visible in the player panels; unrevealed holdings remain private.',
'Harkonnen may call this traitor against its ally’s opponent through the ordinary ally-power response. Kwisatz Haderach protects an accompanying hero against a traitor call.',
'When this original expansion identity is in the actual Traitor Deck, Moritani may draw it as a normal Mentat assassination replacement. It remains a private Traitor Card, not a physical opposing leader disc or native-faction assassination target. The retired victim stays permanently out of the deck.',
'The matching, outcome, public memory and AI support are implemented and tested. Full expansion deck setup and later expansion interactions remain gated with the rest of the expansion.',
],
checklist: [
{area:'Implementation',status:'Partial',detail:'Canonical Hero is accepted as a physical Moritani replacement, separately from printed leader-disc and faction-target lookup. Full active/retired census still prevents fabrication or copying; no Hero leader or Duke Traitor is added.',evidence:['game/traitors.ts','game/moritani-assassinate.ts','game/engine.ts']},
{area:'Player controls',status:'Partial',detail:'Actual390px final Collection readiness and original owned Mentat initialization produce one Hero in the private hand; its real inspector shows Any faction and strength0 with normal traitor guidance. No overflow/browser errors; no extra replacement selector.',evidence:['components/game-table.tsx']},
{area:'AI',status:'Partial',detail:'All four actual Ready policies advance through owned initialization before the automatic Hero draw. The card choice comes from the original reserve, not AI invention; strategic calibration remains deferred.',evidence:['game/bots.ts']},
{area:'Documentation',status:'Partial',detail:'One physical expansion Traitor identity, two matching Hero Treachery cards and no leader disc are distinct. Replacement legality does not make Hero a faction assassination target; permanent victims stay out.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md','docs/NEXUS_CARD_RULES.md']},
{area:'Verification',status:'Partial',detail:'Affected15-file134/134, five original Hero API/policy paths, next-Storm continuation and actual390px inspector pass. Illegal physical-disc/target use and one-card census boundaries are covered. No saved/deployed or complete-expansion acceptance.',evidence:['tests/fixture-moritani-hero-replacement.ts','tests/moritani-hero-replacement.test.ts']},
],
related: [
'setup',
'battle',
'faction-harkonnen',
'advanced-atreides',
'ix-modules',
'moritani-assassinate-leaders',
],
},
{
@@ -3150,16 +3160,17 @@ export const RULE_TOPICS: RuleTopic[] = [
'After you lose a battle, this advanced advantage is available only if the opposing leader disc survived and no Traitor was called.',
'You may reveal a Traitor Card for that opposing faction, but it must name a different leader from the one you just fought. If that named leader is not in the Tanks, kill it and collect spice for its value.',
'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.',
'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.',
'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. Moritani starts remain unavailable in Basic and Advanced.',
],
checklist: [
{area:'Implementation',status:'Partial',detail:'Cached canonical printed discs including actual Auditor distinguish played identity from victorious faction. Actual pre-release surviving control/plan binds the opportunity; native target lookup includes Auditor2 but excludes the played disc. State/signature, once-use, game-long forfeiture, custody guards and original physical Mentat replacement remain.',evidence:['game/moritani-assassinate.ts','game/engine.ts']},
{area:'Player controls',status:'Partial',detail:'Actual390px Sanya4 after Duke6, Zoal3 after foreign Guild5, Frankos4 after Auditor2, Auditor target2 and played-Auditor private Continue are usable. Original Mentat replacement remains private. Native Guild-funded Ambassador shipment/permission/acquisition also connects; private-step UX remains separate.',evidence:['components/moritani-assassinate.tsx','components/game-table.tsx']},
{area:'AI',status:'Partial',detail:'All four owner profiles make accepted target/decline decisions in all five original positions; ordinary Guild-income Ambassador candidate quote is clear. Minimal legal participation, no strategic tuning or calibration claim.',evidence:['game/bots.ts','game/bot-arrival.ts']},
{area:'Documentation',status:'Partial',detail:'Authorized p35 conditions, native component identity, Auditor matching Traitor and printed bounty are explicit. Separate provisional Terror policies do not define this ability. Exceptional targets, private-step UX, Skills/Harkonnen and broader arrival gates remain open.',evidence:['docs/MORITANI_ASSASSINATE_LEADERS.md','docs/CHOAM_AUDITOR.md','docs/RULE_DECISIONS.md']},
{area:'Verification',status:'Partial',detail:'Affected15-file134/134, types/lint and25 original API/policy cases pass. Real acquisition, projectile deaths/foreign return, native battles, public reveal/private replacement and actual390px controls are exercised. No wallet/hand/army/leader/phase/battle staging; only original entropy and conserved unplayed ordering. No saved/deployed or full-mode acceptance.',evidence:['tests/moritani-ecaz-duke-winner.test.ts','tests/moritani-foreign-ghola-winner.test.ts','tests/moritani-auditor-winner.test.ts','tests/moritani-harkonnen-assassinate.test.ts','tests/moritani-assassinate-quote.test.ts']},
{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:'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:'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:'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:'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']},
],
related: [
'faction-moritani',
@@ -4600,8 +4611,9 @@ export const RULE_TOPICS: RuleTopic[] = [
'Harkonnen Cunning draws one Traitor Card before you choose one held card to return. It can pause another action, including a response or decision. Finish the private return before that action continues; active Truthtrance takes priority. Returning an identity already declared as a traitor does not cancel that declaration.',
'With Harkonnen absent, Secret Ally draws two Traitor Cards during Mentat Pause, then you choose exactly two distinct held identities to return. Newly drawn cards may be returned. Tleilaxu receives Face Dancers and can return newly drawn or existing unrevealed dancers; previously revealed dancers stay protected.',
'The Harkonnen exchange spends the Nexus Card when you draw and shuffles the returned cards into the Traitor Deck after your selection. Your original hand size is restored. Other players see that the exchange is pending, without learning your drawn or returned identities.',
'Moritani victim cards permanently set aside are outside reserve, ordinary hands and Face Dancers, so Nexus draws and returns cannot recycle them. A card merely drawn as an earlier Moritani replacement is different: it may be legally returned and later drawn again, with the older receipt unchanged. Actual Hasimir6 return, resumed Mori placement and a later Esmar3 retirement/redraw are connected; two historical references still represent one physical card.',
'Harkonnen Betrayal has a separate fresh local harkonnen-betrayal profile for an actual personal or allied native traitor call. After any original allied counter allows it, uniform public possible held-Nexus owners acknowledge the actually declared traitor before its outcome. Use returns that exact physical traitor immediately and schedules one private actual Mentat replacement; all-pass keeps the original call. This provisional bounded convention does not answer the outstanding universal reaction policy; see its dedicated guide for bounded verified native/private/recovery/CLI/phone evidence and unchanged module/public/deployment gates.',
'Tleilaxu Cunning sets aside all your revealed Face Dancers, draws their replacements secretly, and only then shuffles the set-aside cards into the Traitor Deck. Unrevealed dancers stay in your hand. This single operation does not use or renew your ordinary Mentat replacement; the native all-three-revealed reset keeps its separate shuffle-before-draw order.',
'Tleilaxu Cunning sets aside all your revealed Face Dancers, draws replacements secretly, then shuffles that temporary group into the Traitor Deck. Unrevealed dancers stay held; permanent Moritani victim markers never enter that draw/return group. Original native Mentat and all-three-revealed usage/order remain distinct. Recorded source zones and old absent-zone history remain unchanged by later live markers.',
'Ixian Cunning is used in your battle before submitting your Battle Plan. Your Suboids then count as strength one without spice support in every battle for the rest of the turn, in Basic and Advanced. Cyborg strength and support are unchanged. Earlier inspected or promised plan elements remain binding; you cannot activate the card if the new strength would make them impossible.',
'Ixian Secret Ally has a fresh local ixian-replacement profile for normal paid or printed Karama-paid auction purchases in classic base-deck/Nexus Basic or Advanced games. A supported unallied buyer may spend its actual Nexus to discard exactly the just-purchased card and draw the real deck top privately; Pass keeps both cards. Full-hand replacement and empty-deck same-card redraw are allowed, and original payment/income/auction continuation occur once. Harkonnen buyers, cache/Black Market/special origins and combined modules remain guarded. The neutral buyer choice is an inferred privacy convention, not publisher timing. See the dedicated guide for bounded verified native, private, recovery and phone evidence; this is not full-module certification.',
'Ixian Betrayal has a separate fresh native-Ixian ixian-betrayal profile for both original Bidding inspection/draw and Advanced Technology attempts. Native counters resolve first; then every publicly possible unallied rival held-Nexus seat acknowledges uniformly before the effect. A legal Use spends one physical Ixian Nexus to deny only that attempt; all-pass preserves the native effect once. No completed-card undo or whole-phase ban is added. This bounded ordering/privacy is an inference, not universal publisher priority; see its bounded verified native/private/recovery/phone evidence and unchanged Richese/module/public/deployment gates.',
@@ -4646,11 +4658,11 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['ecaz-modules', 'spice-blow', 'alliance-funding', 'choam-worthless', 'choam-kull', 'nexus-choam-kull', 'nexus-richese-betrayal', 'nexus-guild-betrayal', 'nexus-ixian-replacement', 'nexus-ixian-betrayal'],
checklist: [
{area:"Implementation",status:'Partial',detail:"Printed current-turn Ecaz Cunning now uses any original quiet phase rather than an artificial Battle-only fence. Existing no-phase guard preserves committed auctions, Battle, decisions and physical continuations. New version3 binds actual phase and ordinary/captured custody; legacy ordinary/version2 Battle grammar stays strict. Prior capture, Ecaz-only Ghola composition, purchase/revival sources and roster/shared-training gates remain; full combinations and assurance stay open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts"]},
{area:"Player controls",status:'Partial',detail:"Actual canonical-styled390px controls spend Cunning in Revival10 to10, return an executed Duke at deaths2 without ordinary slot use, and act during Harkonnen Shipment without stealing its turn. Original Ambassador placement and Guild timing block until their own controls resolve. Original battle release and unused expiry follow. No overflow/browser errors; prior capture/purchase controls remain. Broader journeys stay open.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx"]},
{area:"AI",status:'Partial',detail:"All four minimal legal profiles spend early Cunning in actual Revival and Shipment for captured and returned-Tanks sources, preserving original chooser, wallets and physical disc. No hidden rival information or second roster is used. Prior purchase/revival adapters remain; strategic AI and four-tier calibration are deferred.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
{area:"Documentation",status:'Partial',detail:"Printed no-phase/current-turn Cunning is separated from conservative quiet-boundary ordering, provisional capture return and binding Ecaz-only revival. New actual-phase history and strict old Battle formats are explicit. No foreign Ghola, roster or shared-training widening; prior source interpretations and full-mode gates remain.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md"]},
{area:"Verification",status:'Partial',detail:"Affected19-file union189/189, types/lint and thirty-one actual native scenarios pass: both custody sources across six original phases, all four profiles in Revival/Shipment, both expiries and original battle release. Actual styled390px Revival, other-player Shipment and returned Tanks preserve native blockers, costs/allowances and turn. Actual previous ordinary/version2 QA receipts remain accepted. Obsolete phase/blanket Harkonnen/held-hand pins are deleted. No saved/deployed rooms touched; broader assurance and full acceptance stay open.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts", "tests/duke-captured-custody.test.ts", "tests/nexus-ecaz-duke-captured.test.ts", "tests/fixture-nexus-duke-capture.ts", "tests/nexus-dukLine truncated
{area:"Implementation",status:'Partial',detail:"Shared Traitor snapshots now account for permanent Moritani markers outside reserve/hands/Face Dancers, retaining existing Loyalty exclusion. Exchange, Face Dancer, Betrayal and Rihani consumers bind their own source zone; absent-zone grammar stays unchanged. Historical replacements may be legally returned and drawn again. Existing Cunning timing, costs, custody, actor/parent and roster gates remain; full combinations stay open.",evidence:['game/nexus-cards.ts', 'game/nexus-card-phase.ts', 'game/nexus-traitor-exchange.ts', 'game/traitor-declarations.ts', 'game/nexus-face-dancers.ts', 'game/nexus-suboids.ts', 'game/nexus-advisors.ts', 'game/nexus-sardaukar-options.ts', 'game/nexus-fremen-revival.ts', 'game/nexus-fremen-cunning.ts', 'game/nexus-ecaz-betrayal.ts', 'game/nexus-ecaz-inquiry.ts', 'game/nexus-ecaz-duke.ts', 'game/duke-vidal.ts', 'game/nexus-emperor-secret-ally.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal.ts', 'game/nexus-richese-options.ts', 'game/nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/nexus-guild-secret-ally-options.ts', 'game/nexus-module-profile.ts', 'game/engine.ts', 'game/nexus-tleilaxu-revival.ts', 'game/nexus-borrowed-voice.ts', 'game/combat-response-quote.ts', 'game/karama-context.ts', 'game/nexus-fremen-protection.ts', 'game/admin-seat-ai-shape.ts', 'game/richese-cancellation.ts', 'game/bot-mobility.ts', 'game/planetologist-movement.ts', 'game/revival.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally.ts', 'game/revival-resume.ts', 'game/revival-cancellation.ts', 'game/homeworld-revival-deployment.ts', 'game/homeworld-revival-return.ts', "game/nexus-emperor-purchase.ts", "game/auction-continuation-quote.ts", "game/nexus-emperor-purchase.ts", "game/ambassador-phase.ts", "game/ambassador-resume.ts", "game/battle-aftermath-quote.ts", "game/ecaz-duke-acquisition.ts", "game/ecaz-duke-revival.ts", "game/nexus-ecaz-duke-options.ts", "game/nexus-play-boundary.ts"]},
{area:"Player controls",status:'Partial',detail:"Actual390px Secret Ally draws two, returns historical Hasimir6 plus Caid3, keeps Esmar3 and restores the original Mori placement. Passing it reaches the next Storm. A later genuine Esmar3 retirement draws one physical Hasimir again while older exchange history stays unchanged. Hero replacement inspector is private; no overflow/browser errors. Earlier Cunning/purchase controls remain bounded evidence, not full acceptance.",evidence:['components/nexus-cards.tsx', 'components/nexus-traitors.tsx', 'components/nexus-tleilaxu.tsx', 'components/nexus-suboids.tsx', 'components/nexus-advisors.tsx', 'components/nexus-sardaukar.tsx', 'components/choam-power-cost.tsx', 'components/moritani-terror.tsx', 'components/nexus-moritani-betrayal.tsx', 'components/nexus-ecaz-betrayal.tsx', 'components/nexus-ecaz-inquiry.tsx', 'components/nexus-ecaz-duke.tsx', 'components/nexus-richese-betrayal.tsx', 'components/game-table.tsx', 'components/nexus-tleilaxu-revival.tsx', 'components/battle-preparation.tsx', 'components/nexus-fremen-revival.tsx','components/nexus-emperor-payment.tsx', 'components/nexus-emperor-secret-ally.tsx', "components/nexus-emperor-seller-purchase.tsx", "components/nexus-emperor-ambassador-purchase.tsx", "components/richese-special-karama.tsx"]},
{area:"AI",status:'Partial',detail:"All four profiles legally complete the actual augmented-hand return, retaining the original parent; in this position each keeps Hasimir rather than following the scripted old-card return. All four also Ready through original owned Mentat initialization before automatic Hero draw. Existing other module adapters remain; strategic AI and calibration are deferred.",evidence:['game/nexus-card-options.ts', 'game/nexus-traitor-options.ts', 'game/nexus-tleilaxu-options.ts', 'game/nexus-suboid-options.ts', 'game/nexus-advisor-options.ts', 'game/nexus-sardaukar-options.ts', 'game/choam-power-options.ts', 'game/nexus-moritani-options.ts', 'game/nexus-moritani-betrayal-options.ts', 'game/nexus-ecaz-betrayal-options.ts', 'game/nexus-ecaz-inquiry-options.ts', 'game/nexus-ecaz-duke-options.ts', 'game/nexus-richese-options.ts', 'game/bot-nexus-richese-betrayal.ts', 'game/nexus-guild-cunning-options.ts', 'game/bots.ts', 'game/nexus-fremen-revival-options.ts','game/nexus-emperor-payment.ts', 'game/nexus-emperor-secret-ally-options.ts']},
{area:"Documentation",status:'Partial',detail:"Permanent victim markers, live Traitor custody and historical replacement references are distinct. Hero is a Traitor replacement, not a leader disc or native faction target. Sources/receipts keep their own optional removed zone; old absent formats and existing Loyalty exclusion remain. No past-save authenticity, new numeric ruling or profile/public gate widening is claimed.",evidence:['docs/NEXUS_CARD_RULES.md', 'docs/NEXUS_ATREIDES_RULES.md', 'docs/NEXUS_HARKONNEN_RULES.md', 'docs/NEXUS_TLEILAXU_RULES.md', 'docs/NEXUS_IXIAN_CUNNING_RULES.md', 'docs/NEXUS_CUNNING_RUNTIME.md', 'docs/NEXUS_BENE_GESSERIT_RULES.md', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'docs/NEXUS_EMPEROR_RULES.md', 'docs/NEXUS_SARDAUKAR_RUNTIME.md', 'docs/NEXUS_FREMEN_RULES.md', 'docs/NEXUS_ECAZ_RULES.md', 'docs/NEXUS_CHOAM_RULES.md', 'docs/NEXUS_CHOAM_RUNTIME.md', 'docs/NEXUS_MORITANI_RULES.md', 'docs/NEXUS_MORITANI_RUNTIME.md', 'docs/NEXUS_RICHESE_RUNTIME.md', 'docs/NEXUS_RICHESE_RULES.md', 'docs/NEXUS_GUILD_CUNNING_RUNTIME.md', 'docs/NEXUS_GUILD_RULES.md', "docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md", "docs/RULE_DECISIONS.md", "docs/RICHESE_AMBASSADOR_RULES.md", "docs/RICHESE_AMBASSADOR_IMPLEMENTATION.md", "docs/RICHESE_ACQUISITION_RULES.md", "docs/DUKE_VIDAL_RULES.md", "docs/ECAZ_DUKE_ACQUISITION.md", "docs/ECAZ_REVIVAL_RULES.md"]},
{area:"Verification",status:'Partial',detail:"Current affected15-file134/134, eleven original API/policy cases and three actual390px journeys pass: physical Hero/next Storm, historical replacement return/resumed placement, then the same physical draw after a later second marker. Adjacent old fixtures now consume original borrowed Voice before native Prescience/plans. Earlier Cunning/native evidence stays historical; no saved/deployed or full-module assurance claim.",evidence:['tests/mixed-nexus-modules-runtime.test.ts', 'tests/paired-e3-nexus-modules-runtime.test.ts', 'tests/sample-custody.test.ts', 'tests/ambassador-terror-overlap-engine.test.ts', 'tests/single-nexus-e1-runtime.test.ts', 'tests/single-nexus-e2-runtime.test.ts', 'tests/e3-nexus-ecaz-runtime.test.ts', 'tests/e3-nexus-moritani-runtime.test.ts', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts', 'tests/nexus-cards.test.ts', 'tests/nexus-card-engine.test.ts', 'tests/nexus-fremen-cunning.test.ts', 'tests/nexus-fremen-cunning-recovery.test.ts', 'tests/nexus-fremen-cunning-summon.test.ts', 'tests/nexus-fremen-cunning-summon-recovery.test.ts', 'tests/nexus-ecaz-betrayal-quotes.test.ts', 'tests/nexus-ecaz-betrayal.test.ts', 'tests/nexus-ecaz-betrayal-recovery.test.ts', 'tests/nexus-ecaz-inquiry.test.ts', 'tests/nexus-ecaz-duke-quote.test.ts', 'tests/nexus-ecaz-duke-engine.test.ts', 'tests/nexus-ecaz-duke-recovery.test.ts', 'tests/nexus-traitor-recovery.test.ts', 'tests/nexus-face-dancer-engine.test.ts', 'tests/nexus-suboids-engine.test.ts', 'tests/normal-free-support.test.ts', 'tests/nexus-suboid-controls.test.ts', 'tests/nexus-advisor-controls.test.ts', 'tests/nexus-advisor-bots.test.ts', 'tests/nexus-sardaukar-controls.test.ts', 'tests/nexus-sardaukar-bots.test.ts', 'tests/nexus-choam-controls.test.ts', 'tests/nexus-choam-bots.test.ts', 'tests/nexus-moritani-controls.test.ts', 'tests/nexus-moritani-bots.test.ts', 'tests/nexus-moritani-betrayal.test.ts', 'tests/nexus-richese-controls.test.ts', 'tests/nexus-richese-bots.test.ts', 'tests/nexus-richese-betrayal-engine.test.ts', 'tests/nexus-richese-betrayal-recovery.test.ts', 'tests/nexus-guild-cunning-controls.test.ts', 'tests/nexus-guild-cunning-bots.test.ts', 'tests/nexus-module-payments-runtime.test.ts', 'tests/nexus-module-battles-runtime.test.ts', 'tests/paired-ix-nexus-modules-runtime.test.ts', 'tests/paired-choam-nexus-modules-runtime.test.ts', 'tests/classic-nexus-skills-payments-runtime.test.ts', 'tests/classic-nexus-skills-battles-runtime.test.ts', 'tests/nexus-skills-modules-payments-runtime.test.ts', 'tests/nexus-skills-modules-battles-runtime.test.ts', 'tests/paired-ix-nexus-skills-modules-runtime.test.ts', 'tests/paired-choam-nexus-skills-modules-runtime.test.ts', 'tests/ecaz-nexus-skills-modules-runtime.test.ts', 'tests/moritani-nexus-skills-modules-runtime.test.ts', 'docs/NEXUS_CARD_RULES.md', 'tests/nexus-tleilaxu-revival.test.ts', 'tests/fixture-nexus-tleilaxu-secret-ally.ts', 'tests/nexus-tleilaxu-secret-ally-runtime.test.ts', 'tests/nexus-borrowed-voice.test.ts', 'tests/fixture-nexus-borrowed-voice.ts', 'tests/nexus-borrowed-voice-runtime.test.ts', 'tests/nexus-fremen-protection.test.ts', 'tests/fixture-nexus-fremen-protection.ts', 'tests/nexus-fremen-protection-runtime.test.ts', 'tests/fixture-stronghold-factions.ts', 'tests/fixture-nexus-advisor-fresh.ts', 'tests/nexus-advisor-fresh-runtime.test.ts', 'tests/fixture-nexus-advisor-storm.ts', 'tests/nexus-advisor-storm-runtime.test.ts', 'docs/NEXUS_ADVISOR_RUNTIME.md', 'tests/nexus-fremen-free-quota.test.ts', 'tests/fixture-nexus-fremen-free-quota.ts', 'tests/nexus-fremen-free-quota-runtime.test.ts', 'tests/nexus-fremen-revival.test.ts','tests/nexus-emperor-payment.test.ts','tests/fixture-nexus-emperor-payment.ts','tests/nexus-emperor-payment-runtime.test.ts','docs/NEXUS_EMPEROR_RULES.md', 'tests/nexus-emperor-extra-source.test.ts', 'tests/nexus-emperor-extra-continuation.test.ts', 'tests/fixture-nexus-emperor-extra.ts', 'tests/nexus-emperor-extra-runtime.test.ts', 'tests/fixture-single-nexus-e1.ts', 'docs/NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md', "tests/fixture-nexus-emperor-seller.ts", "tests/nexus-emperor-seller-purchase.test.ts", "tests/nexus-emperor-seller-runtime.test.ts", "tests/fixture-nexus-emperor-ambassador.ts", "tests/nexus-emperor-ambassador-purchase.test.ts", "tests/nexus-emperor-ambassador-runtime.test.ts", "tests/fixture-nexus-emperor-special.ts", "tests/nexus-emperor-special-purchase.test.ts", "tests/nexus-emperor-special-runtime.test.ts", "tests/karama-intents.test.ts", "tests/fixture-nexus-emperor-bank.ts", "tests/nexus-emperor-bank-profile.test.ts", "tests/nexus-emperor-bank-runtime.test.ts", "tests/duke-captured-custody.test.ts", "tests/nexus-ecaz-duke-captured.test.ts", "tests/fixture-nexus-duke-capture.ts", "tests/nexus-duke-capture-runtime.test.ts", "tests/nexus-duke-timing-runtime.test.ts"]},
],
},
{
+2 -1
View File
@@ -91,8 +91,9 @@ export function validateRihani(receipt: RihaniReceipt, universe: readonly string
const old = receipt.before.players.find((p) => p.id === receipt.owner);
const owner = receipt.state.players.find((p) => p.id === receipt.owner);
requireRihani(old && owner && owner.faction === old.faction &&
same(receipt.before.removed, receipt.state.removed) &&
same(receipt.before.players.filter((p) => p.id !== receipt.owner), receipt.state.players.filter((p) => p.id !== receipt.owner)),
'Rihani changed another player’s physical Traitors.');
'Rihani changed immutable physical Traitor custody.');
requireRihani(Array.isArray(receipt.used) && new Set(receipt.used).size === receipt.used.length &&
members(receipt.deckAfterPeek, receipt.before.reserve) &&
(receipt.skill.normal || same(receipt.deckAfterPeek, receipt.before.reserve)) &&
+20 -7
View File
@@ -61,6 +61,10 @@ export type MixedE3MoritaniOptions = {
startingCards?: Partial<Record<FactionId, readonly Card['kind'][]>>;
/** Desired physical opposing-faction card in the original undealt Traitor offer. */
traitorTarget?: string;
/** Original undealt reserve prefix, before any Face Dancer reshuffle. */
initialReserveTraitors?: readonly string[];
/** Actual Moritani closing Nexus draw, conserving the original undealt deck. */
nexusCard?: FactionId;
/** Reveal Moritani's own held Traitor normally during the loss battle. */
normalCall?: boolean;
seatIds?: readonly string[];
@@ -262,7 +266,7 @@ function traitorBlockStart(g: Game, actor: string): number {
}
/** Only the original Traitor shuffle is scoped, including the pre-deal Ecaz
* Loyalty draw when Ecaz itself is seated (five/six-native combined roster). */
function traitorRolls(g: Game, target: string, moritani: string): number[] {
function traitorRolls(g: Game, target: string, moritani: string, reserveCards?: readonly string[]): number[] {
const pool = traitorPool(g);
const ecazSeated = g.players.some(p => p.faction === 'ecaz');
const loyal = ecazSeated && g.advanced ? chooseEcazLoyalty(pool, 0xffffffff / 0x100000000) : null;
@@ -270,6 +274,15 @@ function traitorRolls(g: Game, target: string, moritani: string): number[] {
const desired = [...source];
const [at, to] = [desired.indexOf(target), traitorBlockStart(g, moritani)];
assert.ok(at >= 0 && to >= 0); [desired[at], desired[to]] = [desired[to], desired[at]];
if (reserveCards?.length) {
const reserveAt = g.players.filter(p => p.faction !== 'tleilaxu').length * 4;
assert.equal(new Set(reserveCards).size, reserveCards.length);
for (const [offset, card] of reserveCards.entries()) {
const from = desired.indexOf(card), slot = reserveAt + offset;
assert.ok(from >= 0 && slot < desired.length && card !== target);
[desired[from], desired[slot]] = [desired[slot], desired[from]];
}
}
return [...(loyal ? [0xffffffff] : []), ...permutation(source, desired)];
}
/** One requested skill per faction, each genuinely offered; the rest is the
@@ -414,7 +427,7 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {})
const spiceCount = spiceDeck(g.expansions.includes('ix')).length +
(g.discoveryEnabled ? DISCOVERY_SPICE_CARDS.length + 1 : 0);
rolls.push(...identity(spiceCount - 1));
if (!skills) rolls.push(...traitorRolls(g, target, moritani));
if (!skills) rolls.push(...traitorRolls(g, target, moritani, options.initialReserveTraitors));
g = lottery(rolls, initialize);
}
}
@@ -445,7 +458,7 @@ export function createMixedE3MoritaniSetup(options: MixedE3MoritaniOptions = {})
if (Object.keys(g.leaderSkills!.offers).length === 1) {
// Original order: the chosen card returns to the physical deck, then the
// real Traitor deal (and pre-deal Ecaz Loyalty when Ecaz is seated).
const rolls = [...identity(g.leaderSkills!.deck.length), ...traitorRolls(g, target, moritani)];
const rolls = [...identity(g.leaderSkills!.deck.length), ...traitorRolls(g, target, moritani, options.initialReserveTraitors)];
actions.push(structuredClone(step));
g = lottery(rolls, () => applyAction(mixedE3MoritaniReload(g), step.actor, step.action));
} else g = stepMixedE3Moritani(g, step, actions);
@@ -518,7 +531,7 @@ export function orderMixedE3MoritaniSpice(g: Game, worm = false): void {
/** Real alliance change by two unallied seats, then Moritani's own genuine
* closing Nexus draw after both Advanced Spice piles have been dealt. */
export function closeMixedE3Moritani(f: MixedE3MoritaniSetup, state: Game):
export function closeMixedE3Moritani(f: MixedE3MoritaniSetup, state: Game, card: FactionId = 'moritani'):
{ alliance: Game; drawing: Game; draw: MixedE3MoritaniTransition; game: Game } | null {
let g = advanceMixedE3Moritani(state, s => !!s.nexus && !s.spiceWindow && !s.spiceResolution && mixedE3MoritaniClean(s), f.actions);
const others = g.players.filter(p => p.id !== f.moritani && !p.ally); assert.ok(others.length >= 2);
@@ -527,9 +540,9 @@ export function closeMixedE3Moritani(f: MixedE3MoritaniSetup, state: Game):
const alliance = mixedE3MoritaniReload(g);
g = advanceMixedE3Moritani(g, s => s.nexusCards?.phase?.stage === 'drawing' && mixedE3MoritaniClean(s), f.actions);
if (!g.nexusCards!.phase!.eligible.includes(f.moritani)) return null;
const cards = g.nexusCards!.cards!, at = cards.deck.indexOf('moritani'); assert.ok(at >= 0);
const cards = g.nexusCards!.cards!, at = cards.deck.indexOf(card); assert.ok(at >= 0);
cards.deck.unshift(cards.deck.splice(at, 1)[0]);
f.staging.push('Conserved undealt original Moritani Nexus singleton ordered before its real unallied closing draw.');
f.staging.push(`Conserved undealt original ${card} Nexus singleton ordered before its real unallied closing draw.`);
const drawing = mixedE3MoritaniReload(g), step: MixedE3MoritaniStep = { actor: f.moritani,
action: { type: 'nexusCardChoice', turn: g.turn, card: cards.hands[f.moritani] ?? null, choice: 'draw', ownRedraws: 0 } };
g = stepMixedE3Moritani(g, step, f.actions);
@@ -557,7 +570,7 @@ export function createMixedE3MoritaniProgramme(options: MixedE3MoritaniOptions =
result.firstMentat = firstMentat;
g = advanceMixedE3Moritani(g, s => s.phase === 1 && mixedE3MoritaniClean(s), f.actions);
orderMixedE3MoritaniSpice(g, true);
const closed = closeMixedE3Moritani(f, g);
const closed = closeMixedE3Moritani(f, g, options.nexusCard);
if (closed) { g = closed.game; result.alliance = closed.alliance; result.drawing = closed.drawing;
result.draws = [closed.draw]; }
g = advanceMixedE3Moritani(g, s => s.phase === 5 && mixedE3MoritaniClean(s), f.actions);
+2 -1
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import { CHOAM_AUDITOR_ID } from '../game/choam-auditor';
import { type Action, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import {
advanceMixedE3Moritani, createMixedE3MoritaniProgramme, mixedE3MoritaniClean,
mixedE3MoritaniPlayer, mixedE3MoritaniReload, nextMixedE3MoritaniStep,
@@ -22,7 +23,7 @@ export type MoritaniAuditorWinnerFixture = MixedE3MoritaniProgramme & {
* battle selection are actual actions; no disc, card, wallet or army is staged.
* Auditor2 beats Vando1 at dial0, so its one genuinely shipped force survives. */
export function createMoritaniAuditorWinnerFixture(
options: { seatIds?: readonly string[]; auditorTarget?: boolean } = {},
options: { seatIds?: readonly string[]; auditorTarget?: boolean; nexusCard?: FactionId; initialReserveTraitors?: readonly string[] } = {},
): MoritaniAuditorWinnerFixture {
const f = createMixedE3MoritaniProgramme({
rules: 'advanced', families: ['choam'], skills: false,
+104
View File
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import type { Action, Game } from '../game/engine';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import {
advanceMixedE3Moritani, createMixedE3MoritaniProgramme, mixedE3MoritaniClean,
mixedE3MoritaniReload, nextMixedE3MoritaniStep, settleMixedE3MoritaniBoundary,
stageMixedE3MoritaniLoss, stepMixedE3Moritani,
} from './fixture-mixed-e3-moritani-nexus';
import type {
MixedE3MoritaniBattle, MixedE3MoritaniProgramme, MixedE3MoritaniStep, MixedE3MoritaniTransition,
} from './fixture-mixed-e3-moritani-nexus';
export type MoritaniHeroReplacementFixture = MixedE3MoritaniProgramme & {
battle: MixedE3MoritaniBattle;
pending: Game;
reveal: MixedE3MoritaniTransition;
/** Immediately after the actual native target reveal, before battle cleanup. */
revealed: Game;
/** Original Collection boundary, before the automatic Mentat replacement. */
preMentat: Game;
};
/** Fixed own-seat aftermath choices: retain used cards and choose a legal
* physical casualty option. Never calls a normal Traitor or stages custody. */
export function nextMoritaniHeroReplacementStep(g: Game): MixedE3MoritaniStep | null {
if (!g.response && !g.phaseOpening && !g.pendingTreacheryDiscard) {
const d = g.decision;
if (d?.kind === 'battleLosses') {
const elite = Math.max(...d.options.map(option => option.elite));
return { actor: d.player, action: { type: 'decision',
choice: d.options.findIndex(option => option.elite === elite) } };
}
if (d?.kind === 'battleCards')
return { actor: d.player, action: { type: 'decision', discard: [] } };
}
const next = nextMixedE3MoritaniStep(g);
if (next?.action.type === 'traitorCall') next.action.call = false;
return next;
}
export function moritaniHeroTargetAction(f: Pick<MoritaniHeroReplacementFixture, 'moritani' | 'target'>,
pending: Game): Action {
const d = pending.decision;
assert.ok(d?.kind === 'moritaniAssassinate' && d.player === f.moritani);
return { type: 'decision', event: d.event, card: f.target };
}
/** Original no-Skills Advanced E1/E3 play with native Ixians and classic seats.
* The shared producer selects only original undealt shuffle entropy, placing
* the single physical Ix Hero Traitor first in the original remaining reserve.
* There is no Tleilaxu seat or final Face Dancer reshuffle. Shared programme
* Spice/Nexus ordering conserves unplayed cards; no played Traitor reserve,
* hand, leader, wallet, army, battle or phase is edited here.
*
* Deliberately stops BEFORE the formerly failing Mentat transition, so the
* same builder supports failing-before API proof and actual GameTable play. */
export function createMoritaniHeroReplacementFixture(
options: { seatIds?: readonly string[] } = {},
): MoritaniHeroReplacementFixture {
const f = createMixedE3MoritaniProgramme({
rules: 'advanced', families: ['ix'], natives: { ix: 'ixians' }, skills: false,
homeworlds: false, discovery: false, tech: false, strongholds: false,
initialReserveTraitors: [CHEAP_HERO_TRAITOR], ...options,
});
assert.equal(f.nativeFaction, 'ixians');
assert.equal(f.afterSetup.traitorReserve![0], CHEAP_HERO_TRAITOR);
f.staging.push('Original undealt Traitor shuffle entropy places the canonical Ix Cheap Hero Traitor first in the remaining reserve; no played reserve reorder.');
const battle = stageMixedE3MoritaniLoss(f, f.game);
const boundary = settleMixedE3MoritaniBoundary(battle.revealed, null, f.actions);
assert.equal(boundary.kind, 'assassination');
const pending = mixedE3MoritaniReload(boundary.game);
const step = { actor: f.moritani, action: moritaniHeroTargetAction(f, pending) };
const revealed = stepMixedE3Moritani(mixedE3MoritaniReload(pending), step, f.actions);
const reveal = { before: mixedE3MoritaniReload(pending), step,
after: mixedE3MoritaniReload(revealed) };
let g = mixedE3MoritaniReload(revealed);
for (let n = 0; n < 240; n++) {
if (!g.battle && mixedE3MoritaniClean(g)) break;
const next = nextMoritaniHeroReplacementStep(g);
assert.ok(next, `Choose the original Ixians aftermath ${g.decision?.kind ?? g.phase}.`);
g = stepMixedE3Moritani(g, next, f.actions);
}
assert.ok(!g.battle && mixedE3MoritaniClean(g));
const preMentat = advanceMixedE3Moritani(g,
state => state.phase === 7 && mixedE3MoritaniClean(state), f.actions);
assert.equal(preMentat.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed');
assert.equal(preMentat.traitorReserve![0], CHEAP_HERO_TRAITOR);
return { ...f, battle, pending, reveal, revealed, preMentat };
}
/** Runs the original phase transition and automatic replacement, stopping at
* the first genuine Mentat Terror-placement control. May also start directly
* after the target reveal for GameTable/API continuation. */
export function finishMoritaniHeroReplacement(f: MoritaniHeroReplacementFixture,
state: Game = f.preMentat): Game {
let g = mixedE3MoritaniReload(state);
for (let n = 0; n < 500; n++) {
if (!g.response && !g.phaseOpening && g.decision?.kind === 'moritaniPlacement') return g;
const next = nextMoritaniHeroReplacementStep(g);
assert.ok(next, `Choose the original Hero-replacement continuation ${g.decision?.kind ?? g.phase}.`);
g = stepMixedE3Moritani(g, next, f.actions);
}
throw Error('The original Ixians battle did not reach Mentat replacement.');
}
@@ -0,0 +1,336 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { nexusTraitorDrawAction, nexusTraitorReturnAction } from '../game/nexus-traitor-options';
import {
advanceMixedE3Moritani, mixedE3MoritaniClean,
mixedE3MoritaniPlayer, mixedE3MoritaniReload,
orderMixedE3MoritaniSpice, quoteMixedE3MoritaniBattle, revealMixedE3MoritaniPlans,
settleMixedE3MoritaniBoundary, shipMixedE3Moritani, stepMixedE3Moritani,
type MixedE3MoritaniStep, type MixedE3MoritaniTransition,
} from './fixture-mixed-e3-moritani-nexus';
import {
createMoritaniAuditorWinnerFixture, finishMoritaniAuditorReplacement,
nextMoritaniAuditorWinnerStep, pendingMoritaniAuditorAssassination,
revealMoritaniAuditorTarget, type MoritaniAuditorWinnerFixture,
} from './fixture-moritani-auditor-winner';
/** Original undealt Traitor reserve prefix requested from the real initial
* shuffle: the card the original Mentat actually replaces the retired native
* CHOAM victim with, the real Guild Traitor retained by the Secret Ally draw,
* and the second card returned beside that replacement. Positions 16..18 of
* the original deal remain the undealt head after setup appends every unchosen
* offer, so the reserve head here is exactly this prefix without the drawn
* replacement. */
const RESERVE_PREFIX = ['emperor-0', 'guild-2', 'emperor-2'] as const;
/** Printed territories a genuine paid shipment may legally occupy. */
const EXCHANGE_SITES = [
'red_chasm', 'habbanya_ridge_sietch', 'sietch_tabr', 'hagga_basin', 'imperial_basin',
'arrakeen', 'carthag', 'false_wall_south', 'broken_land', 'south_mesa',
] as const;
export type MoritaniReplacementExchangeFixture = MoritaniAuditorWinnerFixture & {
/** The actual public face-up card the original Mentat retired from play. */
retiredVictim: string;
/** The private card that original Mentat actually drew and still holds. */
historicalReplacement: string;
/** A real Traitor Card of the loaded reserve head, drawn by Secret Ally. */
guildTraitor: string;
reservePrefix: readonly string[];
/** Original phase-8 window, retaining Moritani's optional placement while Nexus pauses it. */
ready: Game;
/** The genuine owner-only Secret Ally draw for that offer. */
draw: Action;
/** The two physical cards the original draw takes from the reserve head. */
drawn: readonly string[];
history: { event: string; card: string; replacement: string; signature: string };
};
/** Inverse of the engine's real Fisher-Yates reserve shuffle: the exact
* original randomness that lands one physical reserve in the requested
* layout. Directed only onto a shuffle this fixture itself just caused. */
function fisherRolls(source: readonly string[], desired: readonly string[]): number[] {
assert.deepEqual([...source].sort(), [...desired].sort());
const working = [...source], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i, 'The requested reserve layout is not a permutation of the shuffle.');
rolls.push(Math.floor((j + 0.5) * 0x100000000 / (i + 1)));
[working[i], working[j]] = [working[j], working[i]];
}
return rolls;
}
/** Runs one real action with the supplied original shuffle entropy, and proves
* that entropy was the only randomness the action consumed. */
function directedRolls<T>(rolls: readonly number[], operation: () => T): { value: T; consumed: number } {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const native = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (cursor < rolls.length && array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++];
else Reflect.apply(native, crypto, [array]);
return array;
};
try { return { value: operation(), consumed: cursor }; }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Highest- or lowest-strength legal commitment for one combatant, chosen by
* trying the engine's own plan validator. Never assumes a fixed dial or budget. */
function legalPlan(g: Game, actor: string, leader: string, prefer: 'max' | 'min'): Action {
const spice = mixedE3MoritaniPlayer(g, actor).spice;
const supports = prefer === 'max' ? Array.from({ length: spice + 1 }, (_, index) => spice - index) : [0];
const dials = prefer === 'max' ? Array.from({ length: 13 }, (_, index) => 12 - index) : [0];
const reasons = new Set<string>();
for (const support of supports) for (const dial of dials) {
const action: Action = { type: 'battlePlan', leader, dial, support };
try { applyAction(mixedE3MoritaniReload(g), actor, action); return action; }
catch (error) { reasons.add(error instanceof Error ? error.message : String(error)); }
}
throw Error(`No legal battle plan for ${actor} with ${leader} (spice ${spice}): ${[...reasons].join(' | ')}`);
}
/** First printed territory no original force occupies and whose storm-free
* sector can legally receive a real shipment. */
function freeMixedE3MoritaniLocation(g: Game): string {
for (const id of EXCHANGE_SITES) {
const occupied = g.players.some(player => Object.keys(player.forces).some(key => key.startsWith(`${id}:`)));
if (occupied) continue;
const sector = territory(id).sectors.find(candidate => candidate !== 0 && candidate !== g.storm);
if (sector === undefined) continue;
return `${id}:${sector}`;
}
throw Error('The original programme left no free printed territory for the second real shipment.');
}
function exchangeOffer(g: Game, owner: string) {
const offer = viewGame(g, owner).nexusTraitors?.offer;
assert.ok(offer, 'The owner must receive the real Harkonnen Nexus offer.');
assert.equal(offer.mode, 'secretAlly');
assert.equal(offer.blocked, null);
assert.equal(offer.draw, 2);
return offer;
}
/** Original Advanced CHOAM victory, reveal, Mentat retirement and private
* replacement. Secret Ally pauses the still-owned Moritani placement control;
* only original undealt reserve positions and the conserved Nexus draw are directed. */
export function createMoritaniReplacementExchangeFixture(
options: { seatIds?: readonly string[] } = {},
): MoritaniReplacementExchangeFixture {
const f = createMoritaniAuditorWinnerFixture({
nexusCard: 'harkonnen', initialReserveTraitors: RESERVE_PREFIX, seatIds: options.seatIds,
});
f.staging.push(
`Conserved undealt original Traitor reserve ordered as ${RESERVE_PREFIX.join(', ')} `
+ 'before the real deal; the Moritani closing draw remains the undealt harkonnen Nexus singleton.',
);
const pending = pendingMoritaniAuditorAssassination(f);
const reveal = revealMoritaniAuditorTarget(f, pending);
const replaced = finishMoritaniAuditorReplacement(f, reveal.after);
const receipt = replaced.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'replaced', 'The original Mentat must retire the revealed card.');
assert.equal(receipt.card, f.target);
assert.equal(receipt.replacement, RESERVE_PREFIX[0],
'The original Mentat replacement must draw the reserve head, never the retired card.');
assert.deepEqual(mixedE3MoritaniPlayer(replaced, f.moritani).traitors, [RESERVE_PREFIX[0]]);
assert.equal(replaced.traitorReserve!.includes(f.target), false,
'A retired victim never re-enters the physical reserve.');
const ready = replaced;
assert.equal(ready.decision?.kind, 'moritaniPlacement');
assert.equal(ready.nexusTraitorPending ?? null, null);
const view = viewGame(ready, f.moritani);
const offer = exchangeOffer(ready, f.moritani);
assert.deepEqual(ready.traitorReserve!.slice(0, 2), RESERVE_PREFIX.slice(1),
'The original reserve head must hold the retained Guild Traitor and the second return.');
const draw = nexusTraitorDrawAction(view, offer.event, 'secretAlly');
assert.ok(draw, 'The actual owner must be able to play the real Secret Ally draw.');
return {
...f, retiredVictim: f.target, historicalReplacement: RESERVE_PREFIX[0],
guildTraitor: RESERVE_PREFIX[1], reservePrefix: RESERVE_PREFIX,
ready: mixedE3MoritaniReload(ready), draw, drawn: ready.traitorReserve!.slice(0, 2),
history: { event: receipt.event, card: receipt.card!, replacement: receipt.replacement!, signature: receipt.signature },
};
}
/** Owner-only draw at the real boundary; the physical draw is untouched by any
* shuffle, so only the two fixed reserve-head cards leave the deck. */
export function moritaniReplacementDrawStep(
f: MoritaniReplacementExchangeFixture, state: Game = f.ready,
): MixedE3MoritaniTransition {
const before = mixedE3MoritaniReload(state);
const offer = exchangeOffer(before, f.moritani);
const action = nexusTraitorDrawAction(viewGame(before, f.moritani), offer.event, 'secretAlly');
assert.ok(action, 'The real owner-only Secret Ally draw must stay legal.');
const step: MixedE3MoritaniStep = { actor: f.moritani, action };
return { before, step, after: stepMixedE3Moritani(before, step, f.actions) };
}
/** Owner-only return including the historical replacement, with the original
* return shuffle directed onto the fixture's own disclosed layout: the
* historical card returns first, the freshly drawn Guild Traitor is kept. */
export function moritaniReplacementReturnStep(
f: MoritaniReplacementExchangeFixture, drawn: Game, cards: readonly string[],
): { before: Game; step: MixedE3MoritaniStep; rolls: number[]; after: Game } {
const before = mixedE3MoritaniReload(drawn);
const view = viewGame(before, f.moritani);
const pendingReturn = view.nexusTraitors?.pending;
assert.ok(pendingReturn, 'The owner must hold the real pending Secret Ally return.');
const action = nexusTraitorReturnAction(view, pendingReturn.event, cards);
assert.ok(action, 'The exact owner-only physical return must stay legal.');
const base = [...before.traitorReserve!, ...cards];
const desired = [f.historicalReplacement, ...base.filter(card => card !== f.historicalReplacement)];
const rolls = fisherRolls(base, desired);
f.staging.push('Directed the fixture-caused original return shuffle so the returned historical replacement leads the reserve again.');
const step: MixedE3MoritaniStep = { actor: f.moritani, action };
const result = directedRolls(rolls, () => stepMixedE3Moritani(before, step, f.actions));
assert.equal(result.consumed, rolls.length,
'The original return shuffle must consume exactly the directed Fisher-Yates rolls.');
assert.deepEqual(result.value.traitorReserve, desired,
'The real return shuffle must land on the directed physical reserve.');
return { before, step, rolls, after: result.value };
}
/** The exact owner-only return action for API/UI smoke, keeping the freshly
* drawn Guild Traitor and giving the historical replacement back. The caller
* applies it with its own live randomness. */
export function moritaniReplacementReturnAction(
f: MoritaniReplacementExchangeFixture, drawn: Game,
): Action {
const view = viewGame(mixedE3MoritaniReload(drawn), f.moritani);
const pendingReturn = view.nexusTraitors?.pending;
assert.ok(pendingReturn, 'The owner must hold the real pending Secret Ally return.');
const cards = pendingReturn.choices.map(choice => choice.id).filter(id => id !== f.guildTraitor);
assert.equal(cards.length, 2);
assert.ok(cards.includes(f.historicalReplacement),
'The owner must be able to return the historical replacement.');
const action = nexusTraitorReturnAction(view, pendingReturn.event, cards);
assert.ok(action, 'The exact owner-only physical return must stay legal.');
return action;
}
/** Completes the real Secret Ally draw and return, keeping the freshly drawn
* Guild Traitor and giving the historical replacement back to the deck. */
export function finishMoritaniReplacementExchange(
f: MoritaniReplacementExchangeFixture, state: Game = f.ready,
): {
drawn: Game; exchanged: Game; returnCards: string[];
exchange: { before: Game; step: MixedE3MoritaniStep; after: Game };
} {
const draw = moritaniReplacementDrawStep(f, state);
assert.deepEqual(draw.after.traitorReserve, draw.before.traitorReserve!.slice(2),
'The draw removes exactly its two physical reserve-head cards.');
const action = moritaniReplacementReturnAction(f, draw.after);
const returnCards = action.cards as string[];
const back = moritaniReplacementReturnStep(f, draw.after, returnCards);
return { drawn: draw.after, exchanged: back.after, returnCards,
exchange: { before: back.before, step: back.step, after: back.after } };
}
export type MoritaniReplacementRedrawProgramme = {
fixture: MoritaniReplacementExchangeFixture;
exchanged: Game;
/** Which of the two real shipments arrived first in the live original order. */
arrivalOrder: 'moritani' | 'guild';
location: string;
arrival: MixedE3MoritaniTransition;
assault: MixedE3MoritaniTransition;
plans: MixedE3MoritaniStep[];
revealed: Game;
/** The real second native-equivalent assassination boundary. */
pending: Game;
reveal: MixedE3MoritaniTransition;
/** After the second original Mentat drew the same physical card again. */
redrawn: Game;
};
/** Stops as soon as one original Mentat replacement is on record, never at an
* intermediate owner window. */
function finishReceiptReplacement(f: MoritaniAuditorWinnerFixture, state: Game, event: string): Game {
let g = mixedE3MoritaniReload(state);
for (let n = 0; n < 600; n++) {
const receipt = g.moritaniAssassinate?.opportunities.find(r => r.event === event);
if (receipt?.stage === 'replaced') return g;
const next = nextMoritaniAuditorWinnerStep(g);
assert.ok(next, `Choose the original replacement aftermath ${g.decision?.kind ?? g.phase}.`);
g = stepMixedE3Moritani(g, next, f.actions);
}
throw Error('The original battle did not reach its Mentat replacement.');
}
/** Same source-clear original programme continued through the real turn-three
* storm, conserved unplayed Spice, two real paid shipments and a second
* original battle loss, so the next Mentat can draw the physical card the
* earlier Nexus return gave back. */
export function createMoritaniReplacementRedrawProgramme(
options: { seatIds?: readonly string[] } = {},
): MoritaniReplacementRedrawProgramme {
const fixture = createMoritaniReplacementExchangeFixture(options);
const exchanged = finishMoritaniReplacementExchange(fixture).exchanged;
const guildSeat = fixture.seats.guild;
assert.ok(guildSeat, 'The original classic roster must seat the Guild.');
// The turn-three deal is drawn from the unplayed Spice deck; fronting two
// real land cards is the same disclosed conserved ordering used at turn one.
orderMixedE3MoritaniSpice(exchanged);
let g = advanceMixedE3Moritani(exchanged,
s => s.turn === 3 && s.phase === 1 && mixedE3MoritaniClean(s), fixture.actions);
g = advanceMixedE3Moritani(g,
s => s.phase === 5 && (s.active === fixture.moritani || s.active === guildSeat) && mixedE3MoritaniClean(s),
fixture.actions);
const location = freeMixedE3MoritaniLocation(g);
const mover = g.active;
assert.ok(mover === fixture.moritani || mover === guildSeat,
'The live original movement order must open with one of the two combatants.');
const first: string = mover === guildSeat ? guildSeat : fixture.moritani;
const arrival = shipMixedE3Moritani(g, first, location, 1, 0, fixture.actions);
const other = mover === guildSeat ? fixture.moritani : guildSeat;
const assault = shipMixedE3Moritani(arrival.settled, other, location, 1, 0, fixture.actions);
fixture.staging.push(`Real paid turn-three shipments into the free printed location ${location}: `
+ `${first} first, then ${other}; no force count, card or wallet was staged.`);
const choices = (state: Game) => {
const quote = viewGame(state, state.host).battleChoices;
assert.equal(quote.length, 1, 'The two real shipments must create exactly one original battle pair.');
assert.deepEqual([quote[0].attacker, quote[0].defender].sort(),
[fixture.moritani, guildSeat].sort());
return quote[0];
};
const armed = advanceMixedE3Moritani(assault.settled, s => s.phase === 6 && mixedE3MoritaniClean(s), fixture.actions);
const choice = choices(armed);
const fought = stepMixedE3Moritani(armed, { actor: choice.chooser, action: {
type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } }, fixture.actions);
const entered = advanceMixedE3Moritani(fought, s => !!s.battle && mixedE3MoritaniClean(s) &&
!s.battle.preparation && s.battle.preLeader?.closed !== false, fixture.actions);
const own = mixedE3MoritaniPlayer(entered, fixture.moritani).leaders
.filter(leader => !leader.dead)
.sort((one, two) => one.strength - two.strength)[0];
const opposing = mixedE3MoritaniPlayer(entered, guildSeat).leaders
.filter(leader => !leader.dead && leader.id !== fixture.guildTraitor)
.sort((one, two) => two.strength - one.strength)[0];
assert.ok(own && opposing, 'The original deal must still supply the second real battle discs.');
const plans: MixedE3MoritaniStep[] = [
{ actor: fixture.moritani, action: legalPlan(entered, fixture.moritani, own.id, 'min') },
{ actor: guildSeat, action: legalPlan(entered, guildSeat, opposing.id, 'max') },
];
const revealed = revealMixedE3MoritaniPlans(entered, plans, fixture.actions);
assert.equal(quoteMixedE3MoritaniBattle(revealed).winner, guildSeat,
'The real Guild disc must survive the genuine second loss.');
const boundary = settleMixedE3MoritaniBoundary(revealed, null, fixture.actions);
assert.equal(boundary.kind, 'assassination');
const decision = boundary.game.decision;
assert.ok(decision?.kind === 'moritaniAssassinate' && decision.player === fixture.moritani);
const ownChoices = viewGame(boundary.game, fixture.moritani).moritaniAssassinate!.pending!;
assert.deepEqual(ownChoices.cards.map(entry => entry.card), [fixture.guildTraitor]);
const step: MixedE3MoritaniStep = { actor: fixture.moritani,
action: { type: 'decision', event: decision.event, card: fixture.guildTraitor } };
const reveal: MixedE3MoritaniTransition = { before: mixedE3MoritaniReload(boundary.game), step,
after: stepMixedE3Moritani(boundary.game, step, fixture.actions) };
const redrawn = finishReceiptReplacement(fixture, reveal.after,
reveal.after.moritaniAssassinate!.opportunities.at(-1)!.event);
return { fixture, exchanged, arrivalOrder: first === fixture.moritani ? 'moritani' : 'guild',
location, arrival, assault, plans, revealed, pending: boundary.game, reveal, redrawn };
}
+8 -4
View File
@@ -19,6 +19,7 @@ import {
finishNexusSpice,
orderNexusSpice,
} from './fixture-nexus-cards';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export function nexusTraitorFixture(
options: {
@@ -233,11 +234,14 @@ export function nexusTraitorBattle(g: Game, reveal = true): Game {
type: 'battlePreparationReady',
event: g.battle!.event,
});
// Consume the original absent-BG opportunity before native Prescience.
// When requested, leave that native power for the caller's actual use.
while (g.battle!.preparation && (reveal || g.battle!.preparation.kind === 'nexusVoice')) {
const step = nextStrongholdFactionsNativeStep(g);
assert.ok(step);
g = applyAction(g, step.actor, step.action);
}
if (!reveal) return g;
while (g.battle!.preparation)
g = applyAction(g, g.battle!.preparation.owner, {
type: 'declineBattlePower',
});
for (const id of [owner.id, target.id])
g = applyAction(g, id, {
type: 'battlePlan',
-13
View File
@@ -2,7 +2,6 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {applyAction,viewGame,initializeMoritaniAssassinateGameForAudit,createGame,newPlayer,joinGame,type Game} from '../game/engine';
import {DIFFICULTIES} from '../game/bot-profiles';
import {moritaniAssassinateSignature} from '../game/moritani-assassinate';
import {assassinationGame,stageAssassinationBattle,prepareAssassinationBattle,resolveAssassinationBattle,assassinationChoice,assassinationToMentat,assassinationPhysical,assassinationRejects,assassinationActions} from './moritani-assassinate-fixture';
import {advanceToNextStorm,finishToMovement} from './fixture-advanced-source';
@@ -121,11 +120,6 @@ void test('saved assassination receipts and cleanup obligations fail closed afte
const settled=assassinationToMentat(assassinationChoice(pending,'guild-1'));
const missing=structuredClone(settled);missing.moritaniAssassinate!.opportunities=[];
assert.throws(()=>viewGame(missing,'m'));
const wrongDraw=structuredClone(settled),receipt=wrongDraw.moritaniAssassinate!.opportunities[0];
receipt.replacement=wrongDraw.traitorReserve![0];
receipt.signature=moritaniAssassinateSignature(receipt);
wrongDraw.lastBattleContext!.moritaniAssassinate!.signature=receipt.signature;
assert.throws(()=>viewGame(wrongDraw,'m'),/replacement must stay/);
});
void test('the explicit preview refuses Basic and exceptional-faction lobbies without mutation',()=>{
@@ -166,16 +160,9 @@ void test('a private replacement may fund a later assassination against another
first.players=[first.players[0],first.players[2],first.players[1]];
const second=resolveAssassinationBattle(stageAssassinationBattle(first,'emperor-1'));
const revealed=assassinationChoice(second,'emperor-1');
const sameTurn=structuredClone(revealed),earlier=sameTurn.moritaniAssassinate!.opportunities[0];
earlier.turn=sameTurn.turn;earlier.signature=moritaniAssassinateSignature(earlier);
assert.throws(()=>viewGame(sameTurn,'m'),/replacement must stay/);
const final=assassinationToMentat(revealed);
assert.deepEqual(final.moritaniAssassinate!.opportunities.map(r=>r.stage),['replaced','replaced']);
const physical=assassinationPhysical(final);physical.forces.sort((a,b)=>a.id.localeCompare(b.id));inventory.forces.sort((a,b)=>a.id.localeCompare(b.id));
assert.deepEqual(physical,inventory);
for(const p of final.players)assert.deepEqual(viewGame(JSON.parse(JSON.stringify(final)),p.id),viewGame(final,p.id));
const bad=structuredClone(final),later=bad.moritaniAssassinate!.opportunities[1];
later.turn=bad.moritaniAssassinate!.opportunities[0].turn;later.signature=moritaniAssassinateSignature(later);
bad.lastBattleContext!.moritaniAssassinate!.signature=later.signature;
assert.throws(()=>viewGame(bad,'m'),/replacement must stay/);
});
+1 -1
View File
@@ -153,7 +153,7 @@ void test('Harkonnen reveal retains its physical card until one distinct Mentat
assert.throws(() => validateMoritaniAssassinate(state, {
turn: 2, held: ['moritani-0'], removed: ['harkonnen-1'],
}));
for (const replacement of ['harkonnen-1', DUKE_VIDAL_ID, 'cheap-hero-traitor'])
for (const replacement of ['harkonnen-1', DUKE_VIDAL_ID])
assert.throws(() => validateMoritaniAssassinate({
...state, opportunities: [revised(revealed, { stage: 'replaced', replacement })],
}));
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { leaders } from '../game/cards';
import { applyAction, viewGame } from '../game/engine';
import {
moritaniAssassinateChoices, moritaniAssassinateTrigger, quoteMoritaniAssassinate,
} from '../game/moritani-assassinate';
import { CHEAP_HERO_TRAITOR } from '../game/traitors';
import {
advanceMixedE3Moritani, assertMixedE3MoritaniPhysical, assertMixedE3MoritaniRejects,
mixedE3MoritaniClean, mixedE3MoritaniPlayer, mixedE3MoritaniTraitorCustody,
quoteMixedE3MoritaniBattle,
} from './fixture-mixed-e3-moritani-nexus';
import {
createMoritaniHeroReplacementFixture, finishMoritaniHeroReplacement, moritaniHeroTargetAction,
} from './fixture-moritani-hero-replacement';
void test('original native Ixians loss pays the held native target bounty and Mentat privately draws the one physical Hero without reviving its retired victim', () => {
const f = createMoritaniHeroReplacementFixture();
const target = leaders('ixians').find(leader => leader.id === f.target)!;
assert.ok(target);
assert.equal(f.afterSetup.traitorReserve![0], CHEAP_HERO_TRAITOR);
assert.equal(mixedE3MoritaniTraitorCustody(f.afterSetup).filter(card => card === CHEAP_HERO_TRAITOR).length, 1);
assertMixedE3MoritaniPhysical(f.afterSetup);
const arrival = f.battle.arrival!;
const beforeShipment = mixedE3MoritaniPlayer(arrival.before, f.native);
const afterShipment = mixedE3MoritaniPlayer(f.battle.arrangements, f.native);
assert.equal(beforeShipment.reserves - afterShipment.reserves, 1);
assert.equal((afterShipment.forces[f.location] ?? 0) - (beforeShipment.forces[f.location] ?? 0), 1);
assert.equal(beforeShipment.spice - afterShipment.spice, 1);
assert.equal(quoteMixedE3MoritaniBattle(f.battle.revealed).winner, f.native);
assert.notEqual(f.battle.enemyLeader, f.target);
assert.equal(f.pending.lastBattleContext!.result, 'normal');
assert.equal(f.pending.lastBattleContext!.winner, f.native);
const pendingReceipt = f.pending.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(pendingReceipt.faction, 'ixians');
assert.equal(pendingReceipt.opposingLeader, f.battle.enemyLeader);
const ownerChoice = viewGame(f.pending, f.moritani).moritaniAssassinate!.pending!;
assert.deepEqual(ownerChoice.cards.map(card => ({ card: card.card, bounty: card.bounty })),
[{ card: f.target, bounty: target.strength }]);
assert.deepEqual(viewGame(f.pending, f.native).moritaniAssassinate!.pending!.cards, []);
assert.equal(mixedE3MoritaniPlayer(f.revealed, f.moritani).spice,
mixedE3MoritaniPlayer(f.pending, f.moritani).spice + target.strength);
assert.equal(mixedE3MoritaniPlayer(f.revealed, f.native).spice,
mixedE3MoritaniPlayer(f.pending, f.native).spice);
const killed = mixedE3MoritaniPlayer(f.revealed, f.native).leaders.find(leader => leader.id === f.target)!;
assert.equal(killed.dead, true);
assert.equal(killed.deaths,
mixedE3MoritaniPlayer(f.pending, f.native).leaders.find(leader => leader.id === f.target)!.deaths + 1);
assert.ok(mixedE3MoritaniPlayer(f.preMentat, f.moritani).traitors.includes(f.target),
'The revealed victim stays physically held and reserved until original Mentat.');
assert.equal(f.preMentat.traitorReserve![0], CHEAP_HERO_TRAITOR);
assertMixedE3MoritaniPhysical(f.preMentat);
const replaced = finishMoritaniHeroReplacement(f);
assert.equal(replaced.phase, 8);
assert.equal(replaced.turn, f.preMentat.turn);
const receipt = replaced.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'replaced');
assert.equal(receipt.event, pendingReceipt.event);
assert.equal(receipt.card, f.target);
assert.equal(receipt.replacement, CHEAP_HERO_TRAITOR);
assert.equal(receipt.bounty, target.strength);
assert.deepEqual(replaced.traitorReserve, f.preMentat.traitorReserve!.slice(1));
const hand = mixedE3MoritaniPlayer(replaced, f.moritani).traitors;
assert.equal(hand.filter(card => card === CHEAP_HERO_TRAITOR).length, 1);
assert.equal(hand.includes(f.target), false);
assert.equal(replaced.traitorReserve!.includes(f.target), false);
assert.equal(replaced.players.some(player => player.traitors.includes(f.target) ||
player.traitorChoices.includes(f.target) || player.faceDancers?.some(card => card.leader === f.target)), false);
assert.deepEqual(mixedE3MoritaniTraitorCustody(replaced), mixedE3MoritaniTraitorCustody(f.afterSetup));
assertMixedE3MoritaniPhysical(replaced);
for (const player of replaced.players) {
const publicHistory = viewGame(replaced, player.id).moritaniAssassinate!.history.at(-1)!;
assert.equal(publicHistory.card, f.target);
assert.equal(publicHistory.stage, 'replaced');
assert.equal(Object.hasOwn(publicHistory, 'replacement'), false);
}
const ownerView = viewGame(replaced, f.moritani);
assert.ok(ownerView.players.find(player => player.id === f.moritani)!.traitors?.includes(CHEAP_HERO_TRAITOR));
const rivalView = viewGame(replaced, f.native);
assert.equal(rivalView.players.find(player => player.id === f.moritani)!.traitors, undefined);
const beforeReplay = structuredClone(replaced);
assert.throws(() => applyAction(replaced, f.moritani, moritaniHeroTargetAction(f, f.pending)));
assert.deepEqual(replaced, beforeReplay);
const nextTurn = advanceMixedE3Moritani(replaced,
state => state.turn === replaced.turn + 1 && state.phase === 1 && mixedE3MoritaniClean(state), f.actions);
assert.deepEqual(nextTurn.moritaniAssassinate!.opportunities, replaced.moritaniAssassinate!.opportunities);
assert.deepEqual(nextTurn.traitorReserve, replaced.traitorReserve);
assert.deepEqual(mixedE3MoritaniPlayer(nextTurn, f.moritani).traitors, hand);
assert.equal(mixedE3MoritaniPlayer(nextTurn, f.native).leaders.find(leader => leader.id === f.target)!.dead, true);
assertMixedE3MoritaniPhysical(nextTurn);
});
void test('the canonical Hero replacement remains a Traitor identity, never a physical battle disc or native-faction assassination target', () => {
const f = createMoritaniHeroReplacementFixture();
for (const actor of [f.moritani, f.native])
assertMixedE3MoritaniRejects(structuredClone(f.battle.arrangements), actor,
{ type: 'battlePlan', leader: CHEAP_HERO_TRAITOR, dial: 0, support: 0 });
const replaced = finishMoritaniHeroReplacement(f);
assert.equal(replaced.players.flatMap(player => player.leaders).some(leader => leader.id === CHEAP_HERO_TRAITOR), false);
assert.equal(viewGame(replaced, f.moritani).allLeaders.some(leader => leader.id === CHEAP_HERO_TRAITOR), false);
// Pure identity guard with the ACTUALLY DRAWN private hand and the original
// choice receipt: no game/hand/phase is staged to manufacture another battle.
const state = f.pending.moritaniAssassinate!;
const receipt = state.opportunities.at(-1)!;
const context = { state, receipt, held: mixedE3MoritaniPlayer(replaced, f.moritani).traitors,
normallyRevealed: [], leaders: mixedE3MoritaniPlayer(f.pending, f.native).leaders };
assert.deepEqual(moritaniAssassinateChoices(context).cards, []);
assert.throws(() => quoteMoritaniAssassinate(context, CHEAP_HERO_TRAITOR));
assert.equal(moritaniAssassinateTrigger({ advanced: true, owner: f.moritani,
ownerFaction: 'moritani', opponent: f.native, faction: 'ixians', event: receipt.event,
turn: receipt.turn, territory: receipt.territory, opposingLeader: CHEAP_HERO_TRAITOR,
opposingLeaderSurvived: true, normalBattle: true, lostBattle: true, traitorCalled: false }), false);
assert.ok(mixedE3MoritaniPlayer(replaced, f.moritani).traitors.includes(CHEAP_HERO_TRAITOR));
assertMixedE3MoritaniPhysical(replaced);
});
+118
View File
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { leaders } from '../game/cards';
import { viewGame, type Action } from '../game/engine';
import {
assertMixedE3MoritaniPhysical, assertMixedE3MoritaniRejects, mixedE3MoritaniPlayer,
quoteMixedE3MoritaniBattle,
} from './fixture-mixed-e3-moritani-nexus';
import {
createMoritaniReplacementExchangeFixture, createMoritaniReplacementRedrawProgramme,
finishMoritaniReplacementExchange, moritaniReplacementDrawStep,
} from './fixture-moritani-replacement-exchange';
void test('an original Harkonnen Secret Ally return gives the historical Mentat replacement back to the physical deck without disturbing its public receipt', () => {
const f = createMoritaniReplacementExchangeFixture();
assertMixedE3MoritaniPhysical(f.ready);
assert.deepEqual(mixedE3MoritaniPlayer(f.ready, f.moritani).traitors, [f.historicalReplacement]);
const own = viewGame(f.ready, f.moritani);
assert.equal(own.nexusCards!.card, 'harkonnen');
assert.deepEqual(own.nexusTraitors!.pending, null);
assert.equal(own.nexusTraitors!.offer!.draw, 2);
assert.equal(own.nexusTraitors!.offer!.blocked, null);
const result = finishMoritaniReplacementExchange(f);
const exchange = result.exchanged.nexusTraitorExchanges!.at(-1)!;
assert.equal(exchange.stage, 'complete');
assert.deepEqual(exchange.drawn, [...f.drawn]);
assert.deepEqual([...exchange.returned].sort(), [...result.returnCards].sort());
assert.ok(result.returnCards.includes(f.historicalReplacement),
'The owner really gave the historical replacement back.');
assert.deepEqual(exchange.before, [f.historicalReplacement]);
assert.equal(result.exchanged.traitorReserve![0], f.historicalReplacement,
'The returned historical replacement is a physical reserve card again.');
assert.deepEqual(mixedE3MoritaniPlayer(result.exchanged, f.moritani).traitors, [f.guildTraitor],
'Only the real newly drawn Traitor Card stays in the owner hand.');
assert.equal(result.exchanged.traitorReserve!.includes(f.retiredVictim), false);
for (const player of result.exchanged.players)
assert.equal(player.traitors.includes(f.retiredVictim), false);
const receipt = result.exchanged.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(receipt.stage, 'replaced');
assert.equal(receipt.replacement, f.historicalReplacement);
assert.deepEqual({ event: receipt.event, card: receipt.card, replacement: receipt.replacement,
signature: receipt.signature }, f.history,
'The recorded public victim, its private replacement and the receipt signature must not be rewritten.');
assert.equal(result.exchanged.turn, f.ready.turn);
assert.equal(result.exchanged.phase, 8);
assert.equal(result.exchanged.nexusTraitorPending ?? null, null);
assert.deepEqual(result.exchanged.decision, f.ready.decision,
'The owner keeps its suspended original Mentat placement after returning physical cards.');
assert.equal(result.exchanged.nexusCards!.cards!.discard.filter(card => card === 'harkonnen').length, 1);
assertMixedE3MoritaniPhysical(result.exchanged);
});
void test('the physical return stays owner-only and cannot be replayed against the real custody', () => {
const f = createMoritaniReplacementExchangeFixture();
const guild = f.seats.guild!;
const rival = viewGame(f.ready, guild).nexusTraitors!;
assert.equal(rival.offer, null);
assert.equal(rival.pending, null);
assertMixedE3MoritaniRejects(f.ready, guild, f.draw);
const drawn = moritaniReplacementDrawStep(f);
const pending = viewGame(drawn.after, f.moritani).nexusTraitors!.pending!;
assert.deepEqual(pending.choices.map(choice => choice.id), [f.historicalReplacement, ...f.drawn]);
assert.deepEqual(mixedE3MoritaniPlayer(drawn.after, f.moritani).traitors,
[f.historicalReplacement, ...f.drawn]);
const rivalPending = viewGame(drawn.after, guild).nexusTraitors!.pending!;
assert.equal(rivalPending.event, pending.event);
assert.deepEqual(rivalPending.choices, []);
const giveBack: Action = { type: 'nexusTraitorReturn', event: pending.event,
cards: [f.historicalReplacement, f.guildTraitor] };
assertMixedE3MoritaniRejects(drawn.after, guild, giveBack);
assertMixedE3MoritaniRejects(drawn.after, f.moritani,
{ type: 'nexusTraitorReturn', event: pending.event,
cards: [f.historicalReplacement, f.historicalReplacement] });
assertMixedE3MoritaniRejects(drawn.after, f.moritani, f.draw);
});
void test('a later original battle loss lets the next Mentat draw the same physical card again while the older Nexus history stays unchanged', () => {
const programme = createMoritaniReplacementRedrawProgramme();
const f = programme.fixture;
const guild = f.seats.guild!;
assertMixedE3MoritaniPhysical(programme.exchanged);
const historical = structuredClone(programme.exchanged.nexusTraitorExchanges);
assert.equal(quoteMixedE3MoritaniBattle(programme.revealed).winner, guild,
'The real second loss must be a surviving Guild win.');
const receipts = programme.redrawn.moritaniAssassinate!.opportunities;
assert.equal(receipts.length, 2);
const [first, second] = receipts;
assert.equal(first.stage, 'replaced');
assert.equal(second.stage, 'replaced');
assert.equal(second.turn, 3);
assert.equal(second.card, f.guildTraitor, 'The second public marker retires the held Guild Traitor.');
assert.equal(second.replacement, first.replacement,
'The later original Mentat replacement may be the very card the older Nexus history returned.');
assert.equal(second.replacement, f.historicalReplacement);
assert.equal(second.bounty, leaders('guild').find(leader => leader.id === f.guildTraitor)!.strength);
assert.equal(mixedE3MoritaniPlayer(programme.redrawn, guild).leaders
.find(leader => leader.id === f.guildTraitor)!.dead, true);
assert.deepEqual(mixedE3MoritaniPlayer(programme.redrawn, f.moritani).traitors, [f.historicalReplacement],
'The same physical card is really held again.');
assert.equal(programme.redrawn.traitorReserve!.includes(f.historicalReplacement), false);
assert.equal(programme.redrawn.traitorReserve!.includes(f.guildTraitor), false);
assert.equal(programme.redrawn.traitorReserve!.includes(f.retiredVictim), false);
for (const player of programme.redrawn.players)
for (const retired of [f.retiredVictim, f.guildTraitor])
assert.equal(player.traitors.includes(retired), false);
assert.deepEqual(programme.redrawn.nexusTraitorExchanges, historical,
'A later public marker must not rewrite or reinterpret the earlier Nexus exchange history.');
assert.deepEqual({ event: first.event, card: first.card, replacement: first.replacement,
signature: first.signature }, f.history);
assertMixedE3MoritaniPhysical(programme.redrawn);
});
+6
View File
@@ -15,6 +15,7 @@ import {
nexusTraitorBattle,
} from './fixture-nexus-traitors';
import { nexusAllow, nexusReload } from './fixture-nexus-cards';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
function fixture(
options: {
@@ -121,6 +122,11 @@ function actuallyReveal(): Game {
type: 'battlePreparationReady',
event: g.battle!.event,
});
while (g.battle!.preparation) {
const step = nextStrongholdFactionsNativeStep(g);
assert.ok(step);
g = applyAction(g, step.actor, step.action);
}
for (const [id, disc] of [
['q', leader.id],
['r', opposing.id],
+72
View File
@@ -225,6 +225,78 @@ void test('changed whole-batch history, source statuses, original draw order and
assert.deepEqual(corrupt, before);
}
});
void test('permanently set-aside cards are never drawn, returned or resurrected as Face Dancer replacements', () => {
const state = fixture();
const setAside = state.reserve.shift()!;
state.removed = [setAside];
const before = reload(state);
const result = replaceNexusFaceDancers(state, universe, event, () => 0.999);
assert.deepEqual(state, before);
assert.equal(result.receipt.drawn.includes(setAside), false);
assert.equal(result.state.reserve.includes(setAside), false);
assert.equal(
result.state.players[0].faceDancers!.some((c) => c.leader === setAside),
false,
);
assert.deepEqual(result.state.removed, [setAside]);
validateNexusTraitorSnapshot(result.state, universe);
validateNexusFaceDancerHistory(universe, reload(result.receipt));
});
void test('replacement history validates its recorded permanent zone and rejects a rewritten one', () => {
const state = fixture();
state.removed = [state.reserve.shift()!];
const receipt = replaceNexusFaceDancers(state, universe, event, () => 0.999).receipt;
validateNexusFaceDancerHistory(universe, reload(receipt));
for (const mutate of [
(r: NexusFaceDancerReceipt) => {
r.source.removed = [];
},
(r: NexusFaceDancerReceipt) => {
r.source.removed!.push(r.drawn[0]);
},
(r: NexusFaceDancerReceipt) => {
delete (r.source as Partial<NexusTraitorSnapshot>).removed;
},
(r: NexusFaceDancerReceipt) => {
r.replaced = [...r.replaced, r.source.removed![0]];
},
]) {
const corrupt = reload(receipt);
mutate(corrupt);
const before = reload(corrupt);
assert.throws(() => validateNexusFaceDancerHistory(universe, corrupt));
assert.deepEqual(corrupt, before);
}
});
void test('a permanent zone that duplicates reserve or held Face Dancers or omits physical cards is rejected', () => {
for (const mutate of [
(s: NexusTraitorSnapshot) => {
s.removed = [s.reserve[0]];
},
(s: NexusTraitorSnapshot) => {
s.removed = [s.players[0].faceDancers![0].leader];
},
(s: NexusTraitorSnapshot) => {
s.reserve.pop();
s.removed = ['foreign-identity'];
},
(s: NexusTraitorSnapshot) => {
s.removed = [s.reserve.pop()!];
s.players[1].traitors.pop();
},
]) {
const state = fixture();
mutate(state);
const before = reload(state);
assert.throws(() => validateNexusTraitorSnapshot(state, universe));
assert.throws(() =>
replaceNexusFaceDancers(state, universe, event, () => {
throw Error('Corrupt census must not shuffle');
}),
);
assert.deepEqual(state, before);
}
});
void test('corrupt physical census and invalid event metadata reject without invoking shuffle', () => {
for (const mutate of [
(s: NexusTraitorSnapshot) => {
+38
View File
@@ -379,6 +379,44 @@ void test('bounded prototype rejects simultaneous replacement obligations but re
assert.throws(() => validateHarkonnenBetrayalReplacements(history, second.cursor, [secondDrawn]), HarkonnenBetrayalError);
});
void test('permanent set-aside custody survives Betrayal serialization and can never fund its replacement draw', () => {
const ctx = context();
const setAside = ctx.traitors.reserve.shift()!;
ctx.traitors.removed = [setAside];
const native = authority(ctx);
const source = createHarkonnenBetrayalSource(ctx, native, parent);
validateHarkonnenBetrayalSource(ctx, structuredClone(source), native, parent);
const use = closeHarkonnenBetrayalSource(source, 'use', 'a', initialHarkonnenBetrayalCursor());
const replacement = createHarkonnenBetrayalReplacement(use.receipt);
validateHarkonnenBetrayalReplacement(replacement, [use.receipt], use.cursor, ctx.universe);
assert.throws(() => drawHarkonnenBetrayalReplacement(replacement, [use.receipt], use.cursor,
setAside, ctx.universe), HarkonnenBetrayalError);
const next = later(ctx), nextNative = authority(next);
const nextSource = createHarkonnenBetrayalSource(next, nextNative, 'later-native-set-aside-parent');
const pass = closeHarkonnenBetrayalSource(nextSource, 'pass', null, use.cursor);
const history = [use.receipt, pass.receipt];
validateHarkonnenBetrayalHistory(history, pass.cursor, next.universe);
validateHarkonnenBetrayalReplacements(history, pass.cursor, [replacement], next.universe);
const recycled = next.traitors.reserve[0];
const drawn = drawHarkonnenBetrayalReplacement(replacement, history, pass.cursor, recycled, next.universe);
assert.equal(drawn.status, 'drawn');
assert.equal(replacement.status, 'due');
});
void test('Betrayal live custody keeps the permanent zone inside one complete canonical census', () => {
const ctx = context();
const setAside = ctx.traitors.reserve.shift()!;
ctx.traitors.removed = [setAside];
const native = authority(ctx);
const source = createHarkonnenBetrayalSource(ctx, native, parent);
assert.deepEqual(source.required, ['a', 'e']);
for (const variant of ['duplicate', 'missing', 'foreign'] as const) {
const changed = structuredClone(ctx);
if (variant === 'duplicate') changed.traitors.reserve.push(setAside);
if (variant === 'missing') changed.traitors.removed = [];
if (variant === 'foreign') changed.traitors.removed = ['foreign-identity'];
assert.throws(() => createHarkonnenBetrayalSource(changed, native, parent));
}
});
void test('native attempt history serialization stays linear with nonrecursive current and previous heads', () => {
const ctx = context(), history: HarkonnenBetrayalReceipt[] = [];
let cursor = initialHarkonnenBetrayalCursor(), halfway = 0;
+121
View File
@@ -309,6 +309,127 @@ void test('pending state binds original reserve order, owner augmented hand and
);
}
});
void test('a permanent set-aside zone keeps its cards in the printed universe but out of reserve, hands and returns', () => {
const state = fixture();
state.reserve.splice(state.reserve.indexOf('e'), 1);
state.removed = ['e'];
const before = reload(state);
const begun = beginNexusTraitorExchange(state, universe, input());
assert.deepEqual(state, before);
assert.deepEqual(begun.exchange.drawn, ['f']);
assert.equal(
nexusTraitorReturnChoices(begun.state, universe, begun.exchange).includes('e'),
false,
);
const done = finishNexusTraitorExchange(
begun.state,
universe,
begun.exchange,
['f'],
() => 0.999,
);
assert.deepEqual(done.state.reserve, ['g', 'h', 'f']);
assert.deepEqual(done.state.removed, ['e']);
assert.equal(done.state.reserve.includes('e'), false);
validateNexusTraitorHistory(universe, reload(done.exchange));
for (const mutate of [
(s: NexusTraitorSnapshot) => {
s.reserve.push('e');
},
(s: NexusTraitorSnapshot) => {
s.removed = ['e', 'e'];
},
(s: NexusTraitorSnapshot) => {
s.removed = ['unknown'];
},
(s: NexusTraitorSnapshot) => {
s.removed = [];
},
(s: NexusTraitorSnapshot) => {
s.players[1].traitors = ['d', 'e'];
},
]) {
const broken = reload(state);
mutate(broken);
const unchanged = reload(broken);
assert.throws(() => validateNexusTraitorSnapshot(broken, universe));
assert.deepEqual(broken, unchanged);
}
});
void test('a pending exchange cannot rewrite the permanent card zone even when the current census is conserved', () => {
const paved = fixture();
paved.reserve.splice(paved.reserve.indexOf('e'), 1);
paved.removed = ['e'];
const begun = beginNexusTraitorExchange(paved, universe, input());
for (const mutate of [
(s: NexusTraitorSnapshot) => {
const moved = s.reserve[0];
s.reserve[0] = s.removed![0];
s.removed = [moved];
},
(s: NexusTraitorSnapshot) => {
delete (s as Partial<NexusTraitorSnapshot>).removed;
s.reserve.unshift('e');
},
]) {
const state = reload(begun.state);
mutate(state);
validateNexusTraitorSnapshot(state, universe);
assert.throws(() =>
validateNexusTraitorExchange(state, universe, begun.exchange),
);
}
});
void test('completed exchange history keeps its original zone while later retirement permits another legal exchange', () => {
const state = fixture();
state.reserve.splice(state.reserve.indexOf('e'), 1);
state.removed = ['e'];
const begun = beginNexusTraitorExchange(state, universe, input());
const done = finishNexusTraitorExchange(
begun.state,
universe,
begun.exchange,
['f'],
() => 0.999,
);
const later = reload(done.state);
later.removed!.push(later.reserve.shift()!);
assert.deepEqual(later.removed, ['e', 'g']);
assert.equal(later.reserve.includes('e'), false);
validateNexusTraitorSnapshot(later, universe);
validateNexusTraitorHistory(universe, reload(done.exchange));
assert.deepEqual(done.exchange.source.removed, ['e']);
assert.throws(() =>
validateNexusTraitorExchange(later, universe, done.exchange),
);
const next = beginNexusTraitorExchange(later, universe, { ...input(), event: 'after-second-marker', turn: 3 });
const continued = finishNexusTraitorExchange(next.state, universe, next.exchange, [next.exchange.drawn[0]], () => 0.999);
assert.deepEqual(continued.state.removed, ['e', 'g']);
assert.equal(continued.state.reserve.some(card => later.removed!.includes(card)), false);
validateNexusTraitorHistory(universe, done.exchange);
validateNexusTraitorHistory(universe, continued.exchange);
});
void test('an earlier completed history without a permanent zone is never reinterpreted against later markers', () => {
const begun = beginNexusTraitorExchange(fixture(), universe, input());
const done = finishNexusTraitorExchange(
begun.state,
universe,
begun.exchange,
['a'],
() => 0.999,
);
const later = reload(done.state);
later.removed = [later.reserve.shift()!];
validateNexusTraitorSnapshot(later, universe);
validateNexusTraitorHistory(universe, reload(done.exchange));
const next = beginNexusTraitorExchange(later, universe, { ...input(), event: 'after-first-marker', turn: 3 });
const continued = finishNexusTraitorExchange(next.state, universe, next.exchange, [next.exchange.drawn[0]], () => 0.999);
assert.equal(continued.state.reserve.includes(later.removed![0]), false);
assert.equal(continued.state.players.some(player => player.traitors.includes(later.removed![0]) ||
player.faceDancers?.some(card => card.leader === later.removed![0])), false);
validateNexusTraitorHistory(universe, done.exchange);
validateNexusTraitorHistory(universe, continued.exchange);
});
void test('signed completed history rejects alteration but remains valid after later legitimate card custody changes', () => {
const begun = beginNexusTraitorExchange(fixture(), universe, input());
const done = finishNexusTraitorExchange(
+31 -5
View File
@@ -119,7 +119,7 @@ void test('Tleilaxu Rihani normal-only inspection, lower-only exchange and pre-d
assert.deepEqual(declined.peeked, both.peeked);
assert.deepEqual(declined.drawn, []);
conserved(restore(declined));
assert.throws(() => chooseRihaniDraw(declined, universe, declined.state, true), /already been answered/);
assert.throws(() => chooseRihaniDraw(declined, universe, declined.state, true));
});
void test('Rihani cannot replace a revealed Face Dancer or return both newly drawn cards', () => {
@@ -127,7 +127,7 @@ void test('Rihani cannot replace a revealed Face Dancer or return both newly dra
const drawn = chooseRihaniDraw(offer, universe, offer.state, true);
for (const given of [universe[1], ...drawn.drawn]) {
const before = structuredClone(drawn);
assert.throws(() => finishRihani(drawn, universe, drawn.state, drawn.drawn[0], given, random), /unused Traitor/);
assert.throws(() => finishRihani(drawn, universe, drawn.state, drawn.drawn[0], given, random));
assert.deepEqual(drawn, before);
}
const allRevealed = snapshot();
@@ -152,13 +152,13 @@ void test('Rihani rejects status changes in old, drawn and retained Face Dancers
resigned(complete, (r) => { r.state.players[0].faceDancers![2].revealed = true; }),
resigned(drawn, (r) => { r.eligible.push(universe[1]); }),
];
for (const receipt of invalid) assert.throws(() => validateRihani(receipt, universe), /custody|drawn cards|returned card|unused cards/);
for (const receipt of invalid) assert.throws(() => validateRihani(receipt, universe));
const changedCurrent = structuredClone(drawn.state);
changedCurrent.players[0].faceDancers![1].revealed = false;
assert.throws(() => finishRihani(drawn, universe, changedCurrent, drawn.drawn[0], universe[0], random), /current Traitor custody/);
assert.throws(() => finishRihani(drawn, universe, changedCurrent, drawn.drawn[0], universe[0], random));
const duplicate = resigned(drawn, (r) => { r.state.reserve.push(r.drawn[0]); });
assert.throws(() => validateRihani(duplicate, universe), /census/);
assert.throws(() => validateRihani(duplicate, universe));
});
void test('ordinary Rihani receipts retain the existing used-Traitor exclusion and JSON transition', () => {
@@ -176,3 +176,29 @@ void test('ordinary Rihani receipts retain the existing used-Traitor exclusion a
assert.equal(Object.hasOwn(done.state.players[0], 'faceDancers'), false);
validateRihani(restore(done), universe, done.state);
});
void test('Rihani excludes permanently retired cards throughout peek, draw, return and recorded custody', () => {
const original = snapshot();
const retired = original.reserve.shift()!;
original.removed = [retired];
const offer = begin(true, true, original);
assert.equal(offer.peeked.includes(retired), false);
const pending = chooseRihaniDraw(offer, universe, offer.state, true);
assert.equal(pending.drawn.includes(retired), false);
const done = finishRihani(pending, universe, pending.state, pending.drawn[0], universe[0], random);
assert.deepEqual(done.state.removed, [retired]);
assert.equal(done.state.reserve.includes(retired), false);
assert.equal(done.state.players.some(p => p.traitors.includes(retired) ||
p.faceDancers?.some(card => card.leader === retired)), false);
assert.ok(done.state.reserve.includes(universe[0]));
assert.ok(done.state.players[0].faceDancers!.some(card => card.leader === pending.drawn[0] && !card.revealed));
conserved(done);
const rewritten = resigned(done, r => {
r.state.removed = [universe[0]];
r.state.reserve[r.state.reserve.indexOf(universe[0])] = retired;
});
validateNexusTraitorSnapshot(rewritten.state, universe);
const before = structuredClone(rewritten);
assert.throws(() => validateRihani(rewritten, universe));
assert.deepEqual(rewritten, before);
});