Compose native Advanced Moritani skills with skill-first assassination

This commit is contained in:
Quintine committed 2026-10-03 15:34:12 +10:00
1 parent cb1f81977e
commit d41a7e03a9
18 files changed
+1113 -79

No files matched your search

+21
View File
@@ -16,6 +16,27 @@ open mode gates.
## Current checkpoint and work
**Advanced Moritani Skills — bounded Development Verified:**
all fourteen skills now compose with the original native/classic non-Harkonnen
assassination preview, exact `ecaz` deck and five native assignment discs.
Winner Suk rescue and Rihani choice precede assassination; completed casualties
do not replay. Actual trained-disc death returns its card once with only printed
bounty; normal traitor revelation still forfeits assassination for the game.
Affected64/64, CLI2/2, final native/guide25/25, types/lint/build and nine
controlled runtime cases pass. Five genuine games finish1,425 accepted actions/
no rejection,37 JSON continuations and24 battles; none opens assassination.
Fresh original CLI **7JGUWMAP v6** retains setup. Human390px **TQH86DL4 v20**
uses original admitted identities followed by a conserved battle/traitor/hand/
disc position. Actual Captain Aramsham5 plus normal Warmaster1 beats Vando1,
then native Moritani reveals the different Hasimir Fenring6, gains6 and returns
Warmaster once. Actual winner Trip to Gamont disposal and native Mentat produce
one private replacement; owned enlarged inspection and refresh keep wallet18/
Tanks8 with the retired card absent from the hand. The detailed current guide
is visually readable at390px (351px topic).
See [native Moritani composition](MORITANI_LEADER_SKILLS.md#advanced-assassination-composition--4-october-2026).
Basic/Harkonnen entries, Ecaz/Duke assignment, ordinary/private-step UX and
other roster/module gates remain. No full assurance, AI calibration or deployment.
**Advanced Tleilaxu Skills and foreign gholas — bounded Development Verified:**
native Tleilaxu, optional Ixians/CHOAM and classic seats now compose all fourteen
skills with original half-price foreign revival and no foreign skill draw.
+48
View File
@@ -1,5 +1,53 @@
# Dune implementation status
## 4 October 2026 — Advanced Moritani skills and winner-skill-first assassination
Two bounded test/fixture owners extended genuine native setup/Terror/physical
skill cases and original post-loss assassination cases. One integration owner
composed the fresh all14/non-Harkonnen classic profile, original assassination
state after setup, separate Banker opt-in, CLI and current guidance.
Basic including Harkonnen remains unchanged; no inferred Ecaz/Duke assignment
or wider native/module admission.
The first integrated normal-role case exposed actual pending Suk casualties
being replaced by the original assassination decision. The unspent receipt now
waits at winner skills; Suk rescue and Rihani choice finish first, then the
original assassination opens with a no-casualty continuation. Reveal/decline
cannot replay those costs. Source-specific regressions and actual programs
exercise both decisions. Winning-disc exclusion, printed bounty, public retirement,
one private Mentat replacement and whole-game normal-traitor forfeiture remain.
Fixture corrections preserve real Storm-source completion at later-turn staging,
normal opponent Suk rescue, optional winning Worthless retention and winner-only
killed-disc bounty. Removed obsolete guard-forwarding and bare-not-throw checks.
Affected64/64, CLI2/2, final native/guide25/25 and types/lint/build pass.
Nine actual controlled cases cover two skill-first decisions, manual/four-policy
trained-disc assassination/native replacement and explicit Mentat/Banker opt-ins.
Five genuine Advanced CLI games finish1,425 accepted actions/no rejection,
37 JSON continuations and24 battles, but contain no assassination opportunity:
that evidence comes from controlled programs and the human path. Stable new
scenario ordinals249–253 retain all old Basic samples. Fifteen local canonical
anchors resolve; no comprehensive assurance campaign or strategic calibration.
Original CLI **7JGUWMAP v6** preserves native setup. Human390px **TQH86DL4 v20**
keeps the exact admitted seat IDs, followed by a conserved Wind Pass/physical
traitor/Worthless/disc position. Real Captain Aramsham5/Trip to Gamont/normal
Warmaster1 defeats Vando1 by6–1. Moritani's original owner-only control reveals
the different Hasimir Fenring6, pays exactly6 and returns Warmaster once; own
Bureaucrat remains. The real winner discards Trip to Gamont, original Collection
readiness advances to Mentat, and one private physical replacement appears.
Passing free native Terror placement leaves wallet18/Tanks8 and one held Traitor
with Hasimir retired. Owned enlargement and refresh confirm usable continuation.
The detailed guide is visually readable at390px (351px topic width).
No natural-history claim for controlled positions, reset, public activation,
full-mode certification or deployed evidence.
Final targeted GETs preserve human v20/Mentat/wallet18/Tanks8/one held Traitor/
retired Hasimir and original CLI v6/setup. Sixteen final canonical scope anchors
resolve. Owned throwaway drivers and browser tabs are removed/released; QA games
and the original isolated store remain.
## 4 October 2026 — native Advanced Tleilaxu skills and foreign-ghola lifecycle
Two bounded owners composed original foreign-disc ownership/skill validation
+13
View File
@@ -28,6 +28,19 @@ activation still awaits the uniform-response UX decision; empty hands and
Advanced native/combined-module skills remain guarded. See
[native preview scope](MENTAT_QUESTION.md#native-skill-integration--2-october-2026).
## Advanced Moritani assassination composition — 4 October 2026
The [bounded native Moritani contract](MORITANI_LEADER_SKILLS.md#advanced-assassination-composition--4-october-2026)
composes all fourteen skills with the original classic/non-Harkonnen Advanced
assassination roster and exact `ecaz` deck. Five native assignment discs and
original setup remain; Basic/Harkonnen starts are unchanged.
Winner Suk rescue and Rihani choice finish before post-loss assassination.
A saved unspent receipt advances only after those skills, preserving the actual
winner, original excluded battle disc, printed bounty, card return and one
private native Mentat replacement without repeating casualties.
Explicit Mentat/Banker opt-ins remain separate; Ecaz/Duke assignment, further
native rosters, public activation and unrelated optional modules stay guarded.
## Advanced Tleilaxu foreign-ghola composition — 4 October 2026
The [native Tleilaxu contract](TLEILAXU_LEADER_SKILLS.md#advanced-native-composition--4-october-2026)
+2
View File
@@ -16,6 +16,8 @@ Ordinary activation, empty hands and other pending rulings remain unchanged.
The bounded [Advanced Tleilaxu composition](TLEILAXU_LEADER_SKILLS.md#advanced-native-composition--4-october-2026)
now uses the same explicit preview; original foreign revival does not assign a
new Mentat or any other skill.
The [bounded Advanced Moritani composition](MORITANI_LEADER_SKILLS.md#advanced-assassination-composition--4-october-2026)
also uses this explicit private preview before posture and faction powers.
## Native skill integration — 2 October 2026
+9 -3
View File
@@ -48,9 +48,15 @@ The preview supports native Moritani with Atreides, Bene Gesserit, Emperor,
Fremen and Guild opponents, Advanced rules and the Ecaz faction expansion, without
optional modules. Harkonnen presence is excluded uniformly from public configuration
so candidate availability cannot reveal secret captures. Other expansion factions,
Nexus, Leader Skills, Stronghold Cards, Homeworlds and Discoveries require further
custody, ordering and integration work. A first-version scope is not a printed
restriction on those combinations.
Nexus, Stronghold Cards, Homeworlds, Discoveries and other faction combinations
retain their separate integration boundaries. The
[bounded all14 native skill composition](MORITANI_LEADER_SKILLS.md#advanced-assassination-composition--4-october-2026)
now extends this same non-Harkonnen roster without opening public starts.
Winner Suk rescue and Rihani choice finish first; the unspent assassination
receipt waits, then opens its original private choice. Already completed
casualties cannot replay after reveal/decline. A different eligible trained
disc returns its physical skill once; printed bounty and original Mentat
replacement remain. This scope is not a printed restriction on other combinations.
Every publicly qualifying loss gets the same private reveal-or-continue opportunity,
regardless of the hidden eligible card. Only Moritani sees its candidate identities
+52 -8
View File
@@ -1,10 +1,53 @@
# Basic Moritani with Leader Skills
# Moritani with Leader Skills
Connected development prototype, 21 September 2026. Basic rules, Moritani and
two through six total seats with base-faction opponents now use the full
fourteen-card Leader Skills deck. Public starts and publication remain gated;
this integrates existing effects, not complete Leader Skills compliance.
## Advanced assassination composition — 4 October 2026
Fresh local Advanced `leader-skills` tables now compose all fourteen physical
skills with the existing Moritani assassination preview: native Moritani and
classic opponents except Harkonnen, two through six unique seats, exactly the
`ecaz` family deck and no unrelated modules. Original five native assignment
discs, starting cards, private offers, traitors and six-force placement remain.
Existing Basic Moritani/Harkonnen entries are unchanged. Ecaz/Duke skill
assignment and further native/module combinations are not opened by this scope.
Winner skills precede faction powers. A pending original Suk rescue or Rihani
choice finishes before the loser receives Assassinate Leaders. The saved
unspent receipt waits without exposing premature private choices; its later
continuation cannot replay already completed casualties. The winning battle
disc stays excluded under the original assassination contract. A different
eligible native trained disc may die: its exact skill returns once and Moritani
receives only its printed bounty. Original public set-aside and one private
Mentat replacement remain; normal traitor revelation still forfeits assassination
for the whole game, not just this turn.
Existing Terror death, eligible own revival/private replacement, normal+1 versus
trained+3, native Suk rescue, Enemy of My Enemy and mandatory ally retention use
original handlers. Ordinary winning Worthless cards may be retained; only the
existing compulsory uses must be discarded. Only the battle winner receives
killed-disc bounties. Explicit private Mentat and separate normal Banker opt-ins
also compose with this native preview; no new Terror placement fee exists.
Bounded evidence: affected64/64 and CLI2/2 pass. Five genuine games finish1,425
accepted actions/no rejection,37 JSON continuations and24 battles; none opens
assassination, so assassination evidence comes from controlled cases, not those
natural histories. Nine controlled runtime cases exercise Suk/Rihani-before-
assassination, manual/four-policy trained-disc death and one actual Mentat
replacement, plus original private Mentat and normal Banker support income.
Controlled positions are declared, not represented as natural phase histories.
Final native/guide25/25, types/lint/build and original CLI **7JGUWMAP v6** pass.
Human390px **TQH86DL4 v20** uses original admitted IDs and the declared
controlled battle position, then actual normal-role plans and Hasimir6 reveal,
physical Warmaster return, winner card disposal and one private native Mentat
replacement. Wallet18/Tanks8/one new held Traitor remain after refresh; the
owned enlarged face and detailed guide are readable at390px.
## Public configuration and setup
The existing `leader-skills` local initializer accepts exactly `['ecaz']`,
@@ -47,15 +90,16 @@ exceptional assassination pools and competing-arrival questions remain explicit.
## Remaining work
The [Leader Skills runtime](LEADER_SKILLS_RUNTIME.md) retains unfinished normal
Banker income, ordinary Mentat-question activation, modified Smuggler collection,
The [Leader Skills runtime](LEADER_SKILLS_RUNTIME.md) retains guarded default
Banker activation, ordinary Mentat questioning, modified Smuggler collection,
captured replacement entitlement and other source gaps. Its bounded Diplomat
retreat is connected for supported battles, not modified or simultaneous effects.
The private Mentat question preview has not been enabled for this roster.
Basic Moritani admission does not settle those effects or certify every valid
expansion/module combination. Advanced Moritani, Ecaz and Ixians need their own integration. The later
[Basic Tleilaxu checkpoint](TLEILAXU_LEADER_SKILLS.md) connects its own revival
response chains and Face Dancer interactions.
Private Mentat and normal Banker income are available only under their separate
explicit opt-ins; ordinary Mentat activation and recorded custody questions remain
pending. Basic Moritani admission does not certify every valid module combination.
The bounded Advanced composition above supersedes its earlier admission barrier;
Ecaz and further roster/module work remain. Native Tleilaxu has its separate
[Advanced foreign-ghola contract](TLEILAXU_LEADER_SKILLS.md).
Full AI strategy and difficulty calibration still wait for feature completion.
## Reproducible entry and verification
+3
View File
@@ -15,6 +15,9 @@ Ordinary/default activation and provisional earned custody are unchanged.
The bounded [Advanced Tleilaxu composition](TLEILAXU_LEADER_SKILLS.md#advanced-native-composition--4-october-2026)
also shares this separate opt-in. Its foreign revival never creates a Banker
assignment; original paid bank legs and own native trainer eligibility remain.
The [bounded Advanced Moritani composition](MORITANI_LEADER_SKILLS.md#advanced-assassination-composition--4-october-2026)
also shares this separate opt-in with its original assassination preview.
Native Terror placement remains free, not an invented qualifying bank payment.
## Normal income local contract — 2 October 2026
+43 -9
View File
@@ -1,4 +1,4 @@
import { advancedNativeLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
import { advancedMoritaniLeaderSkillsProfile, advancedNativeLeaderSkillsProfile, nativeExpansionLeaderSkillsProfile, ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
import { bribeTimingBlock, maximumBribe, type BribeOptions } from './bribe-options';
import { quoteSpicePlacement, stormExposesTerritory, stormSectorAfter, wormConsumesForces } from './disaster-rules';
import { isStormCardDistance, type StormCardComponent } from './storm-cards';
@@ -7510,7 +7510,7 @@ function moritaniAssassinateModeSupported(g: Game) {
return g.advanced && g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
g.players.some(p => p.faction === 'moritani') &&
g.players.every(p => ['moritani','atreides','beneGesserit','guild','emperor','fremen'].includes(p.faction)) &&
!g.nexusCards && !g.leaderSkills && !g.strongholdCards && !g.homeworlds &&
!g.nexusCards && (!g.leaderSkills || advancedMoritaniLeaderSkillsProfile(g)) && !g.strongholdCards && !g.homeworlds &&
!g.discoveryEnabled && !g.discoveries && !g.techTokens;
}
function moritaniAssassinateContext(g: Game, receipt: MoritaniAssassinateReceipt) {
@@ -7541,7 +7541,7 @@ function moritaniAssassinateIntegrity(g: Game) {
receipt.owner === state.owner && TERRITORIES.some(t => t.id === receipt.territory),
'The assassination history no longer matches its original battle participants.');
}
const pending = state.opportunities.filter(r => r.stage === 'choice');
const pending = state.opportunities.filter(r => r.stage === 'skills' || r.stage === 'choice');
requireRule(pending.length <= 1, 'Finish the current assassination opportunity first.');
const obligation = g.lastBattleContext?.moritaniAssassinate;
if (obligation) {
@@ -7555,6 +7555,15 @@ function moritaniAssassinateIntegrity(g: Game) {
g.lastBattleContext?.event === r.event && g.lastBattleContext.winner === r.opponent &&
g.lastBattleContext.result === 'normal' && g.lastBattleContext.territory === r.territory &&
obligation?.event === r.event, 'The assassination choice lost its resolved battle.');
if (r.stage === 'skills') {
const discard = g.pendingTreacheryDiscard?.continuation;
const decision = g.decision;
requireRule(g.leaderSkills && !resume &&
((discard?.kind === 'battleResolved' && discard.event === r.event) ||
(decision?.player === r.opponent &&
(decision.kind === 'battleLosses' || decision.kind === 'sukRescue' || decision.kind === 'rihani'))),
'Assassination must retain the winner’s original skill and casualty continuation.');
} else {
requireRule(resume && resume.event === r.event && resume.continuation.event === r.event &&
resume.continuation.winner === r.opponent && resume.continuation.territory === r.territory &&
resume.continuation.result === 'normal' && JSON.stringify(resume.continuation.combatants) === JSON.stringify(g.lastBattleContext.combatants) &&
@@ -7564,6 +7573,7 @@ function moritaniAssassinateIntegrity(g: Game) {
requireRule(decisions.length === 1 && decisions[0].player === r.owner &&
(decisions[0] as Extract<Decision,{kind:'moritaniAssassinate'}>).event === r.event,
'The assassination choice lost its owning decision.');
}
} else requireRule(!resume && g.decision?.kind !== 'moritaniAssassinate', 'A settled assassination cannot retain its decision.');
if (g.status === 'playing' || g.status === 'finished') {
const retired = state.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!);
@@ -7601,6 +7611,7 @@ function recordMoritaniAssassinateOpportunity(g: Game, b: Battle, winner: Player
traitorCalled:Object.values(b.traitorCalls).some(Boolean)};
if (!moritaniAssassinateTrigger(trigger)) return;
const receipt = createMoritaniAssassinateOpportunity(trigger);
if (g.leaderSkills) receipt.stage = 'skills';
state.opportunities.push(receipt);
syncMoritaniAssassinateReceipt(g,receipt);
}
@@ -7816,7 +7827,7 @@ function bankerIncomeProfile(g: Game): boolean {
!g.nexusCards && !g.homeworlds && !g.techTokens &&
!g.strongholdCards && !g.discoveryEnabled && !g.discoveries && !g.discoveryStash &&
!g.greatMaker && !g.ecazTreachery && !g.semutaPreview && !g.mentatQuestionPreview &&
!g.moritaniAssassinatePreview && !g.moritaniAssassinate && !g.advancedPreview &&
((!g.moritaniAssassinatePreview && !g.moritaniAssassinate) || advancedMoritaniLeaderSkillsProfile(g)) && !g.advancedPreview &&
!g.kullPreview && !g.nexusKullPreview && !g.guildBetrayalPreview && !g.richeseBetrayalPreview &&
!g.nexusIxianReplacementPreview && !g.nexusIxianBetrayalPreview && !g.nexusHarkonnenBetrayalPreview;
}
@@ -9268,15 +9279,18 @@ function requireFreshFactionInventory(state: Game) {
});
}), 'The faction initializer cannot overwrite existing inventories, leader custody or revival history.');
}
function initializeMoritaniAssassinateState(g: Game): void {
g.moritaniAssassinatePreview = true;
g.moritaniAssassinate = {version:1,owner:byFaction(g,'moritani')!.id,normalTraitorCall:false,opportunities:[]};
g.moritaniAssassinateCallEvents = [];
}
/** Genuine, explicitly opted-in preview; no player action enables this profile. */
export function initializeMoritaniAssassinateGameForAudit(state: Game): Game {
requireRule(!state.moritaniAssassinatePreview && !state.moritaniAssassinate && !state.moritaniAssassinateResume && !state.moritaniAssassinateCallEvents,
'The assassination preview starts only from a fresh lobby.');
requireRule(moritaniAssassinateModeSupported(state), 'The assassination preview needs Advanced Moritani with ordinary base opponents except Harkonnen, the Ecaz expansion and no optional modules.');
const g = initializeFactionExpansionsGameForAudit(state);
g.moritaniAssassinatePreview = true;
g.moritaniAssassinate = {version:1,owner:byFaction(g,'moritani')!.id,normalTraitorCall:false,opportunities:[]};
g.moritaniAssassinateCallEvents = [];
initializeMoritaniAssassinateState(g);
return g;
}
/** Development-only Semuta reaction in fresh Richese games, optionally with
@@ -9335,8 +9349,10 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
requireRule(!state.leaderSkills, 'Leader Skills cannot redeal existing skill cards.');
const g = structuredClone(state);
g.leaderSkills = createLeaderSkills(random);
return initializeSetupGameForAudit(g, false, false, false, false, true,
const initialized = initializeSetupGameForAudit(g, false, false, false, false, true,
g.expansions.length === 1 && g.expansions[0] === 'choam', nativeExpansionLeaderSkillsProfile(g));
if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized);
return initialized;
}
/** Fresh all-fourteen-card classic or supported native skills; no public income toggle. */
export function initializeSpiceBankerIncomeGameForAudit(state: Game): Game {
@@ -9348,8 +9364,10 @@ export function initializeSpiceBankerIncomeGameForAudit(state: Game): Game {
g.leaderSkills = createLeaderSkills(random);
g.spiceBankerIncomePreview = true;
g.spiceBankerIncome = bankerIncomeRule(() => createBankerIncomeState(bankerIncomeContext(g)));
return initializeSetupGameForAudit(g, false, false, false, false, true,
const initialized = initializeSetupGameForAudit(g, false, false, false, false, true,
g.expansions.length === 1 && g.expansions[0] === 'choam', g.expansions.length > 0);
if (advancedMoritaniLeaderSkillsProfile(initialized)) initializeMoritaniAssassinateState(initialized);
return initialized;
}
/** Gated development setup for the independent three-card Ecaz variant. */
export function initializeEcazTreacheryGameForAudit(state: Game): Game {
@@ -21291,6 +21309,22 @@ function projectedRihani(g: Game, owner: string) {
}
function finishWinner(g: Game, winner: Player, t: string, cards: string[]) {
if (startRihaniVictory(g, winner, t, cards)) return;
const assassination = g.moritaniAssassinate?.opportunities.find(r => r.stage === 'skills');
if (assassination) {
const context = g.lastBattleContext!;
requireRule(assassination.event === context.event && assassination.opponent === winner.id &&
assassination.territory === t && context.result === 'normal',
'Finish the original winner’s skills before its assassination response.');
assassination.stage = 'choice';
syncMoritaniAssassinateReceipt(g, assassination);
const continuation = {
kind: 'battleResolved' as const, event: context.event, result: 'normal' as const,
combatants: context.combatants, territory: t, winner: winner.id, cards,
};
context.moritaniAssassinate!.continuation = JSON.stringify(continuation);
continueResolvedBattle(g, continuation);
return;
}
if (g.pendingIxSubstitution) {
const pending = g.pendingIxSubstitution;
requireRule((pending.winner ?? pending.player) === winner.id && pending.territory === t &&
+27 -1
View File
@@ -38,6 +38,31 @@ export function basicMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): bool
FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'));
}
/** Existing bounded Advanced assassination roster, composed with native skills. */
export function advancedMoritaniLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
const players = game.players;
if (game.advanced !== true || game.expansions.length !== 1 ||
game.expansions[0] !== 'ecaz' || !players || players.length < 2 || players.length > 6 ||
!noOtherLeaderSkillModules(game) || game.semutaPreview || game.advancedPreview ||
game.kullPreview || game.nexusKullPreview || game.guildBetrayalPreview ||
game.richeseBetrayalPreview || game.nexusIxianReplacementPreview ||
game.nexusIxianBetrayalPreview || game.nexusHarkonnenBetrayalPreview) return false;
let native = false;
for (let i = 0; i < players.length; i++) {
const player = players[i];
for (let j = 0; j < i; j++) if (players[j].faction === player.faction) return false;
if (player.faction === 'moritani') native = true;
else {
if (player.faction === 'harkonnen') return false;
let classic = false;
for (const faction of FACTIONS)
if (faction.id === player.faction && faction.expansion === 'base') { classic = true; break; }
if (!classic) return false;
}
}
return native;
}
/** Native Basic Tleilaxu, including Face Dancers; foreign gholas are Advanced. */
export function basicTleilaxuLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return game.advanced === false && game.expansions.length === 1 &&
@@ -103,7 +128,8 @@ export function advancedNativeLeaderSkillsProfile(game: LeaderSkillProfile): boo
}
export function nativeExpansionLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
return basicExpansionLeaderSkillsProfile(game) || advancedNativeLeaderSkillsProfile(game);
return basicExpansionLeaderSkillsProfile(game) || advancedNativeLeaderSkillsProfile(game) ||
advancedMoritaniLeaderSkillsProfile(game);
}
/** Shared by rule quotes, controls and minimal legal bot participation. */
+4 -4
View File
@@ -9,7 +9,7 @@ export type MoritaniAssassinateReceipt = {
opponent: string;
faction: FactionId;
opposingLeader: string;
stage: 'choice' | 'declined' | 'revealed' | 'replaced';
stage: 'skills' | 'choice' | 'declined' | 'revealed' | 'replaced';
card: string | null;
bounty: number;
replacement: string | null;
@@ -211,17 +211,17 @@ export function validateMoritaniAssassinate(
(index === 0 || r.turn >= state.opportunities[index - 1].turn) &&
identifier(r.opposingLeader) &&
!!printed(r.faction, r.opposingLeader) &&
['choice', 'declined', 'revealed', 'replaced'].includes(r.stage) &&
['skills', 'choice', 'declined', 'revealed', 'replaced'].includes(r.stage) &&
r.signature === moritaniAssassinateSignature(r),
'Assassinate has a changed, duplicated or malformed battle opportunity.',
);
events.add(r.event);
if (r.stage === 'choice' || r.stage === 'declined') {
if (r.stage === 'skills' || r.stage === 'choice' || r.stage === 'declined') {
requireAssassinate(
r.card === null &&
r.bounty === 0 &&
r.replacement === null &&
(r.stage !== 'choice' ||
((r.stage !== 'skills' && r.stage !== 'choice') ||
(index === state.opportunities.length - 1 &&
(!custody || r.turn === custody.turn))),
'An unspent Assassinate choice cannot contain a reveal, bounty or replacement.',
+10 -10
View File
@@ -4482,20 +4482,20 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Advanced & expansions',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Basic Moritani and base opponents use the full fourteen-card Leader Skills deck in development tables.',
summary: 'Native Basic and bounded Advanced Moritani compose fourteen skills with Terror, original assassination and skill-first aftermath.',
steps: [
'Assign skills after starting Treachery Cards and before Traitor choices. Moritani places its six starting forces last. All fourteen skills remain in the physical deck; each player keeps one of two private choices and publicly assigns it to an eligible leader.',
'Connected movement, shipping, payment and battle skills use the same controls and legal quotes as the base profile. Terror Assassination returns a killed leader’s exact skill once; a later own-leader revival offers the normal private optional replacement. Sabotage affects Treachery Cards, not skills.',
'An Enemy of My Enemy alliance preserves each faction’s assignments. A losing ally cannot retain a used Planetologist Special or the Worthless card used for a Diplomat copy, because those cards must be discarded.',
'This profile uses Basic rules, Moritani with base opponents and no other optional modules. Existing unfinished Leader Skill effects and disputed cases remain unfinished. Advanced Moritani, Ecaz and further roster/module combinations need separate integration; ordinary public starts remain disabled.',
'Basic Moritani with base opponents keeps its existing entry, including Harkonnen. Fresh Advanced Moritani plus classic opponents except Harkonnen uses the ecaz deck and the original assassination preview with all fourteen skills. Winner Suk rescue and Rihani choice finish before post-loss assassination, without replaying casualties. The actual winning disc remains excluded; another eligible trained disc returns its skill once and pays only printed bounty. Normal traitor revelation forfeits assassination for the whole game. Ecaz/Duke skill assignment, other native families, optional modules and public starts remain separate.',
],
related: ['leader-skills', 'expansion-faction-games', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Genuine setup and shared public-profile guards connect existing skill effects to Basic Moritani. Terror custody and ally-retention disposal reuse settled contracts; unfinished effects remain explicit.', evidence: ['game/leader-skill-profile.ts', 'game/engine.ts'] },
{ area: 'Implementation', status: 'Partial', detail: 'Genuine setup, shared native skill quotes, original Terror/retention, skill-first Suk/Rihani continuation and actual trained-disc assassination/one Mentat replacement compose in the bounded Advanced roster. Basic and unresolved module/roster boundaries remain.', evidence: ['game/leader-skill-profile.ts', 'game/moritani-assassinate.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'Existing private offers, public assignments, battle and revival choices are reused; Planetologist controls share the expanded server predicate.', evidence: ['components/leader-skills.tsx', 'components/planetologist-movement.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing profiles retain minimal legal participation through shared public choices. Strategy and difficulty calibration remain deferred until game features are complete.', evidence: ['game/bots.ts', 'tools/faction-games.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'The integration guide records entry, exact scope, existing source contracts and remaining effects without claiming full module compliance.', evidence: ['docs/MORITANI_LEADER_SKILLS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Genuine setup, all physical assignments, Terror death/revival, movement/payment/battle continuations, mandatory retention exclusions and authenticated concurrent recovery have focused checks. Sample and browser results are recorded separately; full combined-rule acceptance remains open.', evidence: ['tests/moritani-skills-integration.test.ts', 'tests/moritani-skills-custody.test.ts', 'tests/moritani-skills-battle.test.ts', 'tests/moritani-skills-recovery.test.ts', 'tests/moritani-skills-worm.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Affected64/64 and CLI2/2 pass. Five genuine Advanced games finish1,425 accepted actions/no rejection/37 JSON continuations/24 battles; none opens assassination. Nine actual controlled cases cover original skill-first decisions, manual/four-policy assassination death/card return/one replacement and private Mentat/Banker opt-ins. Actual human/checkpoint evidence is recorded separately; full module acceptance remains open.', evidence: ['tests/advanced-moritani-skills.test.ts', 'tests/advanced-moritani-skills-assassinate.test.ts', 'tests/moritani-skills-integration.test.ts', 'tests/moritani-assassinate-engine.test.ts'] },
],
},
{
@@ -4507,7 +4507,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary: 'The fresh banker-income preview gains one deferred spice once per phase when another player makes one qualifying payment to the Spice Bank, then collects automatically at native Mentat.',
searchText: 'Spice Banker normal income bank payment four spice phase deferred front shield original owner death capture Mentat',
steps: [
'This separate fresh local profile admits unique two-to-six classic factions in Basic or Advanced with the base deck, supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill rosters with their single required deck, or bounded Advanced native Ixians, Tleilaxu and/or CHOAM plus classic seats with distinct required family decks. All fourteen skills and native setup remain. Native foreign revival never assigns a skill. Unrelated native families, Nexus, Homeworld, Discovery, Tech Tokens and other overlays stay guarded; no public start or old-game retrofit.',
'This separate fresh local profile admits two-to-six classic factions in Basic or Advanced with the base deck, the supported Basic native skill families, bounded Advanced Ixian/Tleilaxu/CHOAM plus classic seats with distinct required decks, or the separate Advanced Moritani/non-Harkonnen classic assassination roster with the ecaz deck. All fourteen skills and native setup remain. Foreign revival never assigns a skill, and Terror placement remains free. Unrelated native families and optional overlays stay guarded; no public start or old-game retrofit.',
'The printed normal band is mandatory once per phase: when another player makes a payment of at least four spice to the Spice Bank, gain one spice and place it in front of the shield. It is an additional bank grant, not a reduction of the payer’s payment or another player’s income.',
'Only an actual completed positive bank payment qualifies. A payment to a player, zero/free/waived cost, self-payment, invalid declaration or unfinished possible payment supplies no income. Several smaller payments do not combine to reach four.',
'Use the original actual bank portion after recipient and counter settlement, not a quoted fee or guessed wallet change. Paid auctions, funded shipments, paid revival and eligible resolved battle support keep their native continuation once. Canceled Emperor, Guild or Tleilaxu revival income can leave a real bank payment. Tleilaxu self-revival pays the Bank; other-player revival normally pays Tleilaxu. Its separate free-revival bank award is not payer debit: three paid plus that one reward never becomes four paid to the Bank. A stopped shipment supplies no payment.',
@@ -4522,7 +4522,7 @@ export const RULE_TOPICS: RuleTopic[] = [
],
related: ['leader-skills', 'battle', 'bidding', 'revival', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Implemented', detail: 'Actual final bank payer legs, native skill eligibility, one physical Banker phase grant and original-owner automatic Mentat collection connect through fresh classic, supported Basic native and bounded Advanced Ixian/Tleilaxu/CHOAM skill profiles. Tleilaxu self-revival and canceled revival income preserve actual payer cost separately from the free award.', evidence: ['game/engine.ts', 'game/spice-banker-income.ts'] },
{ area: 'Implementation', status: 'Implemented', detail: 'Actual final bank payer legs, one physical Banker phase grant and original-owner Mentat collection connect through fresh classic and supported native skill profiles, including separate bounded Advanced Moritani assassination. Tleilaxu paid revival remains distinct from its free award; free Terror placement is not a new payment.', evidence: ['game/engine.ts', 'game/spice-banker-income.ts'] },
{ area: 'Player controls', status: 'Implemented', detail: 'A full-width read-only public front-shield notice explains unavailable spice and automatic collection; genuine original payment and final Collection controls work at 390px through refresh. No new income action or confirmation.', evidence: ['components/spice-banker-income.tsx', 'components/game-table.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four minimal legal policies preserve identical available-budget actions with deferred income present or absent. Original wallets exclude front-shield currency; strategy/calibration remain deferred.', evidence: ['game/bots.ts', 'game/spice-banker.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'Printed gain/threshold/one-payment/phase/collection, original payment separation, provisional earned custody and per-payer split composition are explicit without universal rulings.', evidence: ['docs/LEADER_SKILLS_RULES.md', 'docs/SPICE_BANKER_RUNTIME.md', 'docs/RULE_DECISIONS.md'] },
@@ -4537,7 +4537,7 @@ export const RULE_TOPICS: RuleTopic[] = [
developmentStage: 'Prototyped',
summary: 'Fourteen physical skills, private assignment choices, five battle disciplines, Planetologist movement/battle effects and Suk Graduate rescues, Mentat/Bureaucrat scoring and Bureaucrat payment redirection, Sandmaster victory spice, Rihani exchanges, optional Smuggler shipping and unmodified battle collection, Sandmaster route and worm-ride collection, sealed Spice Banker battle spending and optional Diplomat base-defense copying in development tables. Other effects and combined-module acceptance remain unfinished.',
steps: [
'A development profile also connects Basic Moritani with base opponents and no other optional modules. It keeps the full deck and existing unfinished-effect boundaries; see Moritani with Leader Skills.',
'Basic Moritani retains base opponents including Harkonnen. Its separate bounded Advanced classic/non-Harkonnen roster now composes the original assassination preview and all fourteen skills: winner Suk rescue and Rihani choice finish first, then an eligible different trained disc may be assassinated with its exact card return and printed bounty. No casualties repeat; original set-aside and one private Mentat replacement remain. See Moritani with Leader Skills for the exact scope.',
'Fresh Advanced development profiles compose native Ixians, Tleilaxu and/or CHOAM with classic opponents, two through six unique seats and distinct required Ix/CHOAM decks. All fourteen skills and original native starting choices remain. Auditor never receives a skill. Typed cyborg support flows through Suk rescue before native substitution; CHOAM keeps original payer/income and Smuggler-before-Collection order. Foreign-ghola revival gives control without a new skill. Basic entries are unchanged; captured replacement, Advanced Atreides Suk/KH and unrelated overlays remain guarded.',
'With this optional module, starting Treachery Cards are dealt after prediction and before skill assignment. Every faction privately receives two skills, keeps one, returns the other to the shuffled deck, and assigns its chosen card to an eligible leader. Skill and leader become public before traitor selection.',
'Before Battle Plans, leave the skilled leader face up to keep its normal effect, or move it behind the shield for possible use. A face-up leader cannot be selected for the plan. Concealing it while choosing another leader gives neither effect. A leader that must be used because no alternative remains moves behind the shield automatically.',
@@ -4551,12 +4551,12 @@ export const RULE_TOPICS: RuleTopic[] = [
'A surviving skilled Sandmaster victory automatically adds three board spice to an existing pile in the battle territory, even with no surviving forces. Multiple piles await a placement ruling.',
'Rihani normally inspects two random Traitor Deck cards privately, then reshuffles them. A surviving selected native Rihani also offers a separate optional draw of two. Decide before seeing them: once drawn, keep one new card and publicly reveal and return one unused old card. The other new card returns secretly. A captured Rihani grants only the optional exchange. Your private history records both samples; a card just called to win is already used.',
'Smuggler can include one free accompanying force in an off-planet shipment into an empty territory. Select the entire physical total; three forces cost the ordinary price of two. The checkbox can decline this optional benefit. Guild rounding follows the free force. Richese may instead ship one separate free reserve force beside its owned No-Field into an empty territory; the marker retains its ordinary price and hidden value. Existing forces, including advisors, prevent the bonus; ordinary Fremen on-planet reinforcement has no bonus.',
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic, supported Basic native or bounded Advanced Ixian/Tleilaxu/CHOAM banker-income profile; ordinary/default starts are unchanged.',
'Spice Banker can commit one through three own spice with its Battle Plan, separate from force support. Only the surviving selected skilled disc gains that strength. The amount stays private until authorized plan inspection or public reveal. It is paid on win or loss, including leader death, except a sole traitor-call winner spends nothing. Normal deferred income connects only in the separate fresh classic or supported native banker-income profiles, including the bounded Advanced Moritani roster; ordinary/default starts are unchanged.',
'Diplomat may use one committed Worthless card as the opponent’s base Shield or Snooper if you played no defense. After both plans reveal, choose the card or decline before traitor decisions. A native Diplomat may protect its own selected trained leader this way; a captive grants no upper effect. The chosen Worthless must be discarded afterward, even if you win. A surviving trained leader that loses a supported battle may retreat selected undialed physical counters up to its printed/copied disc strength into one empty adjacent non-stronghold territory before ordinary loser losses; modified defenses remain unfinished.',
'Mentat has an explicit private developer preview for naming a specific weapon before posture and faction powers, including supported Basic Ixian/Tleilaxu/CHOAM/Moritani and bounded Advanced Ixian/Tleilaxu/CHOAM skill starts. The target privately shows the named held card or chooses another held card. Weirding Way is a nameable default weapon; Chemistry is not. The card stays held and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history is an observation, not a later hand guarantee.',
'Mentat has an explicit private developer preview for naming a specific weapon before posture and faction powers in supported classic and native skill profiles, including bounded Advanced Moritani. The target privately shows the named held card or chooses another held card. Weirding Way is a nameable default weapon; Chemistry is not. The card stays held and need not enter a Battle Plan. Every answer uses the same private response step; ordinary activation awaits the user’s timing-design decision. Empty hands skip without an answer. Private history is an observation, not a later hand guarantee.',
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income, Richese sales and actual paid Tleilaxu revival income in supported Basic or bounded Advanced native skill profiles have connected payment paths. A separate free-revival award is not part of the paid price and stays with Tleilaxu. Split funding, gifts, other payment families and combined modules remain unfinished.',
'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.',
'Other special Sandmaster relocation remains missing. Normal Banker income has a separate fresh classic, supported Basic native or bounded Advanced Ixian/Tleilaxu/CHOAM source-to-Mentat preview with provisional original-earned-faction custody, not general activation. Diplomat retreat uses a documented conservative outcome-order interpretation in standalone Leader Skills play. Native Advanced foreign gholas and Face Dancers follow the bounded Tleilaxu contract; other combined modules remain unfinished. This development prototype does not enable public module starts.',
'Other special Sandmaster relocation remains missing. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers and skill-first Moritani assassination follow their bounded contracts; Ecaz/Duke assignment and other combined modules remain unfinished. This prototype does not enable public module starts.',
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
],
related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
@@ -0,0 +1,455 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
advancedNativePlayer as player,
advancedNativeStep,
advanceAdvancedNativeSkillsToPhase,
assertAdvancedNativeCustody as custody,
completedAdvancedNativeSkillsGame,
openAdvancedNativeSkillBattle,
rejectAdvancedNativeAction as reject,
stageAdvancedNativeSkillBattle,
} from './fixture-advanced-native-skills';
import { assassinationPhysical } from './moritani-assassinate-fixture';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
function assignment(game: Game, owner: string) {
const trained = game.leaderSkills!.assignments.find(a => a.owner === owner);
assert.ok(trained);
return trained;
}
function untrained(game: Game, owner: string, strongest = false) {
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const candidates = game.players.find(p => p.id === owner)!.leaders
.filter(l => !l.dead && !trained.has(l.id));
candidates.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength);
assert.ok(candidates[0]);
return candidates[0];
}
/** Only after genuine native setup: swap two existing physical identities,
* following the original assassination fixture. Never manufacture a traitor. */
function holdPhysicalTraitor(game: Game, identity: string): void {
const owner = player(game, 'moritani');
const old = owner.traitors[0];
assert.ok(old);
if (old === identity) return;
const holder = game.players.find(p => p.id !== owner.id && p.traitors.includes(identity));
if (holder) holder.traitors[holder.traitors.indexOf(identity)] = old;
else {
const index = game.traitorReserve!.indexOf(identity);
assert.ok(index >= 0, 'The requested physical native identity must still exist.');
game.traitorReserve![index] = old;
}
owner.traitors[0] = identity;
}
/** All14 and the original assassination state come from the fresh production
* initializer. Only board/card positions are controlled after native setup. */
function stagedGame(bankerIncome = false) {
const native = completedAdvancedNativeSkillsGame({
family: 'moritani', skillOwner: 'guild', requestedSkill: 'warmaster',
opponents: ['guild', 'emperor'], bankerIncome,
});
assert.equal(assignment(native, player(native, 'guild').id).skill, 'warmaster');
assert.ok(native.moritaniAssassinate);
const game = stageAdvancedNativeSkillBattle(native,
player(native, 'guild').id, player(native, 'moritani').id);
game.spice['wind_pass:14'] = 0; // Isolate battle/assassination income from Collection.
return game;
}
function stageCard(game: Game, owner: string, kind: 'worthless' | 'projectile'): string {
const index = game.deck.findIndex(c => c.kind === kind);
assert.ok(index >= 0, 'The conserved native deck must contain the staged card.');
const [card] = game.deck.splice(index, 1);
game.players.find(p => p.id === owner)!.hand.push(card);
return card.id;
}
function resolvePlans(game: Game, normalCall = false): Game {
game = applyAction(game, player(game, 'guild').id, { type: 'traitorCall', call: false });
return applyAction(game, player(game, 'moritani').id, { type: 'traitorCall', call: normalCall });
}
function pendingAssassination(): { game: Game; target: string; card: string } {
let game = stagedGame();
const guild = player(game, 'guild').id;
const moritani = player(game, 'moritani').id;
const target = assignment(game, guild).leader;
holdPhysicalTraitor(game, target);
const card = stageCard(game, guild, 'worthless');
const winningLeader = untrained(game, guild).id;
const losingLeader = untrained(game, moritani).id;
// The face-up trainer grants the different winning disc its normal +1.
// The trainer itself remains an eligible original assassination target.
game = openAdvancedNativeSkillBattle(game, guild, moritani, false);
game = applyAction(game, guild, { type: 'battlePlan', leader: winningLeader, dial: 0, support: 0, weapon: card });
game = applyAction(game, moritani, { type: 'battlePlan', leader: losingLeader, dial: 0, support: 0 });
game = resolvePlans(game);
assert.equal(game.lastBattleContext?.result, 'normal');
assert.equal(game.lastBattleContext?.winner, guild);
assert.equal(game.decision?.kind, 'moritaniAssassinate');
return { game, target, card };
}
function choose(game: Game, card: string | null): Game {
const decision = game.decision;
assert.equal(decision?.kind, 'moritaniAssassinate');
if (decision?.kind !== 'moritaniAssassinate') throw new Error('Expected original assassination choice.');
return applyAction(game, decision.player, {
type: 'decision', event: decision.event,
...(card === null ? { decline: true } : { card }),
});
}
function finishCards(game: Game, discard: string[] = []): Game {
for (let i = 0; (game.decision || game.response || game.pendingTreacheryDiscard || game.battle) && i < 100; i++) {
if (game.decision?.kind === 'battleCards') {
game = applyAction(game, game.decision.player, { type: 'decision', discard });
} else game = advancedNativeStep(game);
}
assert.equal(game.battle, null);
assert.equal(game.decision, null);
return game;
}
void test('Advanced all14 assassination kills only the held native trainer, returns its skill once and resumes real winner-card cleanup before one private Mentat replacement', () => {
const { game: pending, target, card } = pendingAssassination();
const moritani = player(pending, 'moritani');
const guild = player(pending, 'guild');
const killed = guild.leaders.find(l => l.id === target)!;
const ownTrainer = structuredClone(assignment(pending, moritani.id));
const survivingAssignments = pending.leaderSkills!.assignments.filter(a => a.leader !== target);
const inventory = assassinationPhysical(pending);
const traitors = [...moritani.traitors];
const reserve = [...pending.traitorReserve!];
const event = pending.moritaniAssassinate!.opportunities.at(-1)!.event;
const action: Action = { type: 'decision', event, card: target };
assert.equal(pending.phase, 6);
assert.ok(pending.moritaniAssassinateResume!.continuation.cards.includes(card));
assert.equal(guild.leaders.find(l => l.id === pending.moritaniAssassinate!.opportunities.at(-1)!.opposingLeader)!.dead, false);
assert.equal(viewGame(pending, moritani.id).moritaniAssassinate!.pending!.cards.find(c => c.card === target)!.bounty, killed.strength);
assert.deepEqual(viewGame(pending, guild.id).moritaniAssassinate!.pending!.cards, []);
reject(pending, guild.id, action);
reject(pending, moritani.id, { ...action, event: 'stale' });
let game = applyAction(reload(pending), moritani.id, action);
const receipt = game.moritaniAssassinate!.opportunities.at(-1)!;
const dead = player(game, 'guild').leaders.find(l => l.id === target)!;
assert.equal(dead.dead, true);
assert.equal(dead.deaths, killed.deaths + 1);
assert.equal(dead.strength, killed.strength);
assert.equal(receipt.bounty, killed.strength);
assert.equal(receipt.stage, 'revealed');
assert.equal(receipt.replacement, null);
assert.equal(player(game, 'moritani').spice, moritani.spice + killed.strength);
assert.equal(player(game, 'guild').spice, guild.spice);
assert.deepEqual(game.leaderSkills!.assignments, survivingAssignments);
assert.deepEqual(assignment(game, moritani.id), ownTrainer);
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
assert.deepEqual(player(game, 'guild').leaders.filter(l => l.id !== target), guild.leaders.filter(l => l.id !== target));
assert.deepEqual(player(game, 'moritani').traitors, traitors);
assert.deepEqual(game.traitorReserve, reserve);
assert.equal(game.moritaniAssassinateResume, undefined);
assert.equal(game.decision?.kind, 'battleCards');
assert.equal(game.decision?.player, guild.id);
assert.equal(player(game, 'guild').hand.some(c => c.id === card), true);
custody(game);
reject(game, moritani.id, action);
// Exercise the saved ORIGINAL winner cleanup rather than erasing the decision.
game = finishCards(reload(game), [card]);
assert.equal(player(game, 'guild').hand.some(c => c.id === card), false);
assert.equal(game.discard.filter(c => c.id === card).length, 1);
assert.deepEqual(assassinationPhysical(game), inventory);
const replacement = game.traitorReserve![0];
assert.ok(replacement && replacement !== target);
game = advanceAdvancedNativeSkillsToPhase(reload(game), 8);
const settled = game.moritaniAssassinate!.opportunities.at(-1)!;
assert.equal(settled.stage, 'replaced');
assert.equal(settled.replacement, replacement);
assert.deepEqual(game.traitorReserve, reserve.slice(1));
assert.deepEqual(player(game, 'moritani').traitors, [...traitors.filter(c => c !== target), replacement]);
assert.equal(game.players.flatMap(p => p.traitors).includes(target), false);
assert.equal(game.traitorReserve!.includes(target), false);
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
assert.deepEqual(assignment(game, moritani.id), ownTrainer);
assert.equal(player(game, 'moritani').spice, moritani.spice + killed.strength);
assert.deepEqual(assassinationPhysical(game), inventory);
const rival = viewGame(reload(game), guild.id);
assert.equal(rival.moritaniAssassinate!.history.at(-1)!.card, target);
assert.equal(rival.moritaniAssassinate!.history.at(-1)!.stage, 'replaced');
assert.equal('replacement' in rival.moritaniAssassinate!.history.at(-1)!, false);
assert.equal('traitors' in rival.players.find(p => p.id === moritani.id)!, false);
custody(game);
reject(game, moritani.id, action);
const turn = game.turn;
for (let i = 0; game.turn === turn && i < 1000; i++) game = advancedNativeStep(game);
assert.equal(game.turn, turn + 1);
while (game.phase === 0 && game.status === 'playing') game = advancedNativeStep(game);
// The original per-faction assassination does not expire on a new turn.
game = stageAdvancedNativeSkillBattle(reload(game), guild.id, moritani.id);
game.spice['wind_pass:14'] = 0;
const winner = untrained(game, guild.id, true).id;
const loser = untrained(game, moritani.id).id;
game = openAdvancedNativeSkillBattle(game, guild.id, moritani.id);
game = applyAction(game, guild.id, { type: 'battlePlan', leader: winner, dial: 0, support: 0 });
game = applyAction(game, moritani.id, { type: 'battlePlan', leader: loser, dial: 0, support: 0 });
game = resolvePlans(game);
assert.equal(game.lastBattleContext?.result, 'normal');
assert.equal(game.lastBattleContext?.winner, guild.id);
assert.equal(game.moritaniAssassinate!.opportunities.length, 1);
assert.equal(game.moritaniAssassinate!.opportunities[0].stage, 'replaced');
assert.notEqual(game.decision?.kind, 'moritaniAssassinate');
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
assert.deepEqual(assignment(game, moritani.id), ownTrainer);
custody(finishCards(game));
});
void test('declining the original loss opportunity preserves the real trainer, skill, wallet and traitor through Mentat', () => {
const { game: pending, target } = pendingAssassination();
const skills = structuredClone(pending.leaderSkills);
const moritani = player(pending, 'moritani');
const trained = structuredClone(player(pending, 'guild').leaders.find(l => l.id === target)!);
const held = [...moritani.traitors];
const reserve = [...pending.traitorReserve!];
let game = finishCards(choose(reload(pending), null));
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'declined');
game = advanceAdvancedNativeSkillsToPhase(game, 8);
assert.deepEqual(game.leaderSkills, skills);
assert.deepEqual(player(game, 'guild').leaders.find(l => l.id === target), trained);
assert.equal(player(game, 'moritani').spice, moritani.spice);
assert.deepEqual(player(game, 'moritani').traitors, held);
assert.deepEqual(game.traitorReserve, reserve);
assert.deepEqual(viewGame(game, moritani.id).moritaniAssassinate!.history, []);
custody(game);
});
void test('the surviving lower Warmaster wins a real loss, but the original source excludes that winning trained disc from assassination', () => {
let game = stagedGame();
const guild = player(game, 'guild').id;
const moritani = player(game, 'moritani').id;
const trainer = assignment(game, guild).leader;
const trained = structuredClone(player(game, 'guild').leaders.find(l => l.id === trainer)!);
holdPhysicalTraitor(game, trainer);
const worthless = stageCard(game, guild, 'worthless');
const loser = untrained(game, moritani, true);
// Guild's printed 5 loses to 4+3; only the surviving lower +3 makes it win.
assert.equal(trained.strength, 5);
assert.equal(loser.strength, 4);
const funds = player(game, 'moritani').spice;
game = openAdvancedNativeSkillBattle(game, guild, moritani);
game = applyAction(game, guild, { type: 'battlePlan', leader: trainer, dial: 0, support: 0, weapon: worthless });
game = applyAction(game, moritani, { type: 'battlePlan', leader: loser.id, dial: 3, support: 3 });
game = resolvePlans(game);
assert.equal(game.lastBattleContext?.winner, guild);
assert.equal(game.decision?.kind, 'moritaniAssassinate');
const own = viewGame(reload(game), moritani).moritaniAssassinate!.pending!;
assert.deepEqual(own.cards, []);
reject(game, moritani, { type: 'decision', event: own.event, card: trainer });
game = finishCards(choose(game, null));
assert.equal(player(game, 'guild').leaders.find(l => l.id === trainer)!.dead, false);
assert.equal(assignment(game, guild).skill, 'warmaster');
assert.equal(game.leaderSkills!.deck.includes('warmaster'), false);
assert.equal(player(game, 'moritani').spice, funds - 3);
custody(game);
});
void test('weapon death removes the lower Warmaster bonus before scoring and prevents a spurious Moritani loss', () => {
let game = stagedGame();
const guild = player(game, 'guild').id;
const moritani = player(game, 'moritani').id;
const trainer = assignment(game, guild).leader;
const printed = player(game, 'guild').leaders.find(l => l.id === trainer)!.strength;
const ownTrainer = structuredClone(assignment(game, moritani));
holdPhysicalTraitor(game, untrained(game, guild).id);
const worthless = stageCard(game, guild, 'worthless');
const weapon = stageCard(game, moritani, 'projectile');
const attacker = untrained(game, moritani).id;
const funds = player(game, 'moritani').spice;
game = openAdvancedNativeSkillBattle(game, guild, moritani);
game = applyAction(game, guild, { type: 'battlePlan', leader: trainer, dial: 0, support: 0, weapon: worthless });
game = applyAction(game, moritani, { type: 'battlePlan', leader: attacker, dial: 0, support: 0, weapon });
game = resolvePlans(game);
// A dead skilled disc contributes neither printed 5 nor lower +3. If lower
// strength were applied before survival, Guild would incorrectly win this.
assert.equal(game.lastBattleContext?.result, 'normal');
assert.equal(game.lastBattleContext?.winner, moritani);
assert.equal(player(game, 'guild').leaders.find(l => l.id === trainer)!.dead, true);
assert.equal(player(game, 'guild').leaders.find(l => l.id === trainer)!.deaths, 1);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === trainer), false);
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
assert.deepEqual(assignment(game, moritani), ownTrainer);
assert.equal(player(game, 'moritani').spice, funds + printed);
assert.deepEqual(game.moritaniAssassinate!.opportunities, []);
assert.notEqual(game.decision?.kind, 'moritaniAssassinate');
game = finishCards(reload(game), [weapon]);
assert.equal(game.discard.filter(c => c.id === worthless).length, 1);
assert.equal(game.discard.filter(c => c.id === weapon).length, 1);
custody(game);
});
void test('an actual normal Moritani loss cannot assassinate when the winning trained disc was killed by the real weapon', () => {
let game = stagedGame();
const guild = player(game, 'guild').id;
const moritani = player(game, 'moritani').id;
const trainer = assignment(game, guild).leader;
holdPhysicalTraitor(game, untrained(game, guild).id);
const held = [...player(game, 'moritani').traitors];
const funds = player(game, 'moritani').spice;
const ownTrainer = structuredClone(assignment(game, moritani));
const worthless = stageCard(game, guild, 'worthless');
const weapon = stageCard(game, moritani, 'projectile');
const attackingLeader = untrained(game, moritani).id;
game = openAdvancedNativeSkillBattle(game, guild, moritani);
game = applyAction(game, guild, { type: 'battlePlan', leader: trainer, dial: 4, support: 4, weapon: worthless });
game = applyAction(game, moritani, { type: 'battlePlan', leader: attackingLeader, dial: 0, support: 0, weapon });
game = resolvePlans(game);
assert.equal(game.lastBattleContext?.result, 'normal');
assert.equal(game.lastBattleContext?.winner, guild);
assert.equal(player(game, 'guild').leaders.find(l => l.id === trainer)!.dead, true);
assert.equal(player(game, 'guild').leaders.find(l => l.id === trainer)!.deaths, 1);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === trainer), false);
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
assert.deepEqual(assignment(game, moritani), ownTrainer);
assert.deepEqual(game.moritaniAssassinate!.opportunities, []);
assert.notEqual(game.decision?.kind, 'moritaniAssassinate');
assert.deepEqual(player(game, 'moritani').traitors, held);
assert.equal(player(game, 'moritani').spice, funds);
game = finishCards(game, [worthless]);
assert.equal(player(game, 'guild').tanks, 4);
assert.equal(player(game, 'guild').forces['wind_pass:14'], 4);
assert.equal(player(game, 'moritani').tanks, 8);
assert.equal(game.discard.filter(c => c.id === weapon).length, 1);
assert.equal(game.discard.filter(c => c.id === worthless).length, 1);
custody(game);
});
void test('the fresh private Banker wrapper retains normal-traitor ENTIRE-GAME forfeiture through a later native turn loss', () => {
let game = stagedGame(true);
const guild = player(game, 'guild').id;
const moritani = player(game, 'moritani').id;
const trainer = assignment(game, guild).leader;
const ownTrainer = structuredClone(assignment(game, moritani));
holdPhysicalTraitor(game, trainer);
const funds = player(game, 'moritani').spice;
const printed = player(game, 'guild').leaders.find(l => l.id === trainer)!.strength;
const held = [...player(game, 'moritani').traitors];
const reserve = [...game.traitorReserve!];
game = openAdvancedNativeSkillBattle(game, guild, moritani);
game = applyAction(game, guild, { type: 'battlePlan', leader: trainer, dial: 0, support: 0 });
game = applyAction(game, moritani, { type: 'battlePlan', leader: untrained(game, moritani).id, dial: 0, support: 0 });
game = finishCards(resolvePlans(game, true));
assert.equal(game.lastBattleContext?.result, 'traitor');
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(game.moritaniAssassinateCallEvents!.length, 1);
assert.equal(player(game, 'guild').leaders.find(l => l.id === trainer)!.dead, true);
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
assert.deepEqual(assignment(game, moritani), ownTrainer);
assert.equal(player(game, 'moritani').spice, funds + printed);
assert.deepEqual(player(game, 'moritani').traitors, held);
assert.deepEqual(game.traitorReserve, reserve);
const turn = game.turn;
for (let i = 0; game.turn === turn && i < 1000; i++) game = advancedNativeStep(game);
assert.equal(game.turn, turn + 1);
while (game.phase === 0 && game.status === 'playing') game = advancedNativeStep(game);
assert.deepEqual(player(game, 'moritani').traitors, held);
assert.deepEqual(game.traitorReserve, reserve);
const emperor = player(game, 'emperor').id;
// Explicit second conserved battle position, after an actual native turn
// boundary. Never simulate expiry by assigning turn or resetting the guard.
game = stageAdvancedNativeSkillBattle(reload(game), emperor, moritani);
game.spice['wind_pass:14'] = 0;
const winner = untrained(game, emperor, true).id;
const loser = untrained(game, moritani).id;
game = openAdvancedNativeSkillBattle(game, emperor, moritani);
game = applyAction(game, emperor, { type: 'battlePlan', leader: winner, dial: 0, support: 0 });
game = applyAction(game, moritani, { type: 'battlePlan', leader: loser, dial: 0, support: 0 });
game = applyAction(game, emperor, { type: 'traitorCall', call: false });
game = applyAction(game, moritani, { type: 'traitorCall', call: false });
assert.equal(game.lastBattleContext?.result, 'normal');
assert.equal(game.lastBattleContext?.winner, emperor);
assert.equal(game.moritaniAssassinate!.normalTraitorCall, true);
assert.equal(game.moritaniAssassinateCallEvents!.length, 1);
assert.deepEqual(game.moritaniAssassinate!.opportunities, []);
assert.notEqual(game.decision?.kind, 'moritaniAssassinate');
custody(finishCards(game));
});
void test('every original policy can finish the current JSON assassination choice with real death, skill return and retained physical card', () => {
const { game: pending, target } = pendingAssassination();
const owner = player(pending, 'moritani');
const strength = player(pending, 'guild').leaders.find(l => l.id === target)!.strength;
for (const difficulty of DIFFICULTIES) {
const current = reload(pending);
const view = viewGame(current, owner.id);
view.players.find(p => p.id === owner.id)!.bot = difficulty;
const action = botActions(view)[0];
assert.ok(action, difficulty);
const game = applyAction(current, owner.id, action);
assert.equal(game.moritaniAssassinate!.opportunities.at(-1)!.stage, 'revealed', difficulty);
assert.equal(player(game, 'guild').leaders.find(l => l.id === target)!.dead, true);
assert.equal(player(game, 'moritani').spice, owner.spice + strength);
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === target), false);
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
assert.deepEqual(player(game, 'moritani').traitors, owner.traitors);
assert.equal(game.decision?.kind, 'battleCards');
custody(finishCards(game));
}
});
void test('the winner finishes original Suk rescue and Rihani choice before Moritani can assassinate, without replaying casualties', () => {
for (const skill of ['suk-graduate', 'rihani-decipherer'] as const) {
const native = completedAdvancedNativeSkillsGame({
family: 'moritani', skillOwner: 'emperor', requestedSkill: skill,
});
const winner = player(native, 'emperor').id, loser = player(native, 'moritani').id;
let game = stageAdvancedNativeSkillBattle(native, winner, loser);
game.spice['wind_pass:14'] = 0;
const leader = assignment(game, winner).leader;
const enemy = untrained(game, loser).id;
const support = skill === 'suk-graduate' ? 5 : 1;
const balance = player(game, 'emperor').spice;
game = openAdvancedNativeSkillBattle(game, winner, loser);
game = applyAction(game, winner, { type: 'battlePlan', leader, dial: support, support });
game = applyAction(game, loser, { type: 'battlePlan', leader: enemy, dial: 0, support: 0 });
game = applyAction(game, winner, { type: 'traitorCall', call: false });
game = applyAction(game, loser, { type: 'traitorCall', call: false });
assert.equal(game.lastBattleContext!.winner, winner);
assert.equal(viewGame(game, loser).moritaniAssassinate!.pending, null);
if (skill === 'suk-graduate') {
assert.equal(game.decision?.kind, 'sukRescue');
if (game.decision?.kind !== 'sukRescue') throw new Error('Original winner rescue must remain actionable.');
const choice = game.decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.key === 'wind_pass:14');
assert.ok(choice >= 0);
game = applyAction(reload(game), winner, { type: 'decision', event: game.decision.event, choice });
assert.equal(player(game, 'emperor').forces['wind_pass:14'], 4);
assert.equal(player(game, 'emperor').tanks, 2);
} else {
assert.equal(game.decision?.kind, 'rihani');
if (game.decision?.kind !== 'rihani') throw new Error('Original winner Rihani choice must remain actionable.');
const held = [...player(game, 'emperor').traitors];
game = applyAction(reload(game), winner, { type: 'decision', event: game.decision.event, draw: false });
assert.deepEqual(player(game, 'emperor').traitors, held);
assert.equal(player(game, 'emperor').forces['wind_pass:14'], 7);
assert.equal(player(game, 'emperor').tanks, 1);
}
assert.equal(game.decision?.kind, 'moritaniAssassinate');
const forces = structuredClone(player(game, 'emperor').forces);
const tanks = player(game, 'emperor').tanks;
const reserves = player(game, 'emperor').reserves;
game = finishCards(choose(reload(game), null));
assert.deepEqual(player(game, 'emperor').forces, forces);
assert.equal(player(game, 'emperor').tanks, tanks);
assert.equal(player(game, 'emperor').reserves, reserves);
assert.equal(player(game, 'emperor').spice, balance - support);
custody(game);
}
});
+391
View File
@@ -0,0 +1,391 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer,
normalizeAutomaticGame, viewGame, type Action, type Game,
} from '../game/engine';
import { leaders, treacheryDeck, type Card } from '../game/cards';
import { traitorDeck } from '../game/traitors';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { newRevivalRules } from '../game/revival';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
advancedNativePlayer as player, advancedNativeStep, assertAdvancedNativeCustody,
completedAdvancedNativeSkillsGame, completeAdvancedNativeSkillsSetup,
initializeAdvancedNativeSkillsSetup, openAdvancedNativeSkillBattle,
rejectAdvancedNativeAction as reject, stageAdvancedNativeSkillBattle,
finishAdvancedNativeSkillAftermath,
} from './fixture-advanced-native-skills';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
const act = (game: Game, actor: string, action: Action): Game => applyAction(reload(game), actor, action);
const wallets = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice]));
const trainer = (game: Game, actor: string) => game.leaderSkills!.assignments.find(a => a.owner === actor)!;
function custody(game: Game): void {
assertAdvancedNativeCustody(game);
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id);
assert.deepEqual(cards.sort(), treacheryDeck(['ecaz']).map(c => c.id).sort());
assert.equal(cards.length, 33);
assert.deepEqual(game.players.flatMap(p => p.leaders.map(l => l.id)).sort(),
game.players.flatMap(p => leaders(p.faction).map(l => l.id)).sort());
const traitors = [...game.traitorReserve!, ...game.players.flatMap(p => p.traitors),
...(game.moritaniAssassinate?.opportunities.filter(r => r.stage === 'replaced').map(r => r.card!) ?? [])];
assert.deepEqual(traitors.sort(), traitorDeck(game.players).sort());
assert.equal(new Set(traitors).size, traitors.length);
}
function take(game: Game, actor: string, matches: (card: Card) => boolean): Card {
const index = game.deck.findIndex(matches);
assert.ok(index >= 0, 'Staged card transfers must use the genuine native deck.');
const card = game.deck.splice(index, 1)[0];
game.players.find(p => p.id === actor)!.hand.push(card);
return card;
}
function allow(state: Game): Game {
let game = state;
for (let i = 0; game.response && i < 30; i++) game = advancedNativeStep(game);
assert.equal(game.response, null);
return game;
}
/** Explicit phase controls after real setup, not a claim of natural phase history.
* Placement is the original free native action; no wallet or inventory is edited. */
function placeTerror(state: Game, kind: 'assassination' | 'robbery'): Game {
let game = reload(state);
Object.assign(game, { phase: 7, active: null, ready: [], decision: null, response: null, phaseOpening: null });
for (const seat of game.players) game = act(game, seat.id, { type: 'ready' });
assert.equal(game.decision?.kind, 'moritaniPlacement');
const owner = player(game, 'moritani').id;
const token = game.moritaniTerror!.tokens.find(t => t.kind === kind)!;
const funds = wallets(game);
game = allow(act(game, owner, { type: 'decision', token: token.id, territory: 'carthag' }));
assert.equal(game.moritaniTerror!.tokens.find(t => t.id === token.id)!.location, 'carthag');
assert.deepEqual(wallets(game), funds, 'Native Terror placement is free.');
custody(game);
return game;
}
function enterTerror(state: Game, actor: string): Game {
const game = reload(state);
Object.assign(game, { phase: 5, storm: 18, active: actor, ready: [], decision: null,
response: null, phaseOpening: null, movementRemaining: [...game.order] });
const entrant = game.players.find(p => p.id === actor)!;
entrant.shipped = false;
entrant.moved = 0;
return act(game, actor, { type: 'ship', territory: 'carthag', sector: 11, amount: 1 });
}
function resolve(state: Game, actor: string, target: string): Game {
let game = act(state, actor, { type: 'traitorCall', call: false });
game = act(game, target, { type: 'traitorCall', call: false });
return game;
}
void test('Advanced native setup deals starting cards, full14 skills, traitors and original six-force placement in source order', () => {
const lobby = createGame('MORISKIL', newPlayer('original-m', 'Moritani', 'moritani'), true, ['ecaz']);
joinGame(lobby, newPlayer('original-a', 'Atreides', 'atreides'));
joinGame(lobby, newPlayer('original-e', 'Emperor', 'emperor'));
const options = { family: 'moritani' as const, requestedSkill: 'warmaster' as const, initial: lobby };
let game = initializeAdvancedNativeSkillsSetup(options);
assert.deepEqual(game.players.map(p => p.id), lobby.players.map(p => p.id));
assert.equal(game.setupStage, 'leaderSkills');
assert.equal(game.leaderSkills!.deck.length, 8);
assert.deepEqual(game.players.map(p => p.hand.length), [1, 1, 1]);
assert.deepEqual(game.players.map(p => p.traitors), [[], [], []]);
assert.equal(player(game, 'moritani').reserves, 20);
for (const seat of game.players) {
const own = viewGame(game, seat.id).leaderSkills!;
assert.equal(own.offer!.cards.length, 2);
assert.equal(own.assignments.length, 0);
assert.equal(own.offer!.event, game.leaderSkills!.offers[seat.id].event);
}
for (const seat of game.players) {
const offer = game.leaderSkills!.offers[seat.id];
const own = viewGame(game, seat.id).leaderSkills!;
const skill = offer.cards.find(c => !own.unavailableSkills?.[c])!;
game = act(game, seat.id, { type: 'leaderSkill', event: offer.event, skill, leader: own.eligibleLeaders[0].id });
}
assert.equal(game.setupStage, 'traitors');
assert.equal(player(game, 'moritani').reserves, 20);
while (game.status === 'setup' && game.decision?.kind !== 'moritaniSetup') game = advancedNativeStep(game);
assert.equal(game.decision?.kind, 'moritaniSetup');
assert.deepEqual(game.players.map(p => p.traitors.length), [1, 1, 1]);
game = completeAdvancedNativeSkillsSetup(game, options);
assert.equal(game.phase, 0);
assert.equal(player(game, 'moritani').reserves, 14);
assert.equal(Object.values(player(game, 'moritani').forces).reduce((a, b) => a + b, 0), 6);
assert.equal(game.moritaniAssassinate!.owner, 'original-m');
custody(game);
// An admitted original setup must retain its physical offers and native deck.
const offered = initializeAdvancedNativeSkillsSetup(options);
const continued = initializeAdvancedNativeSkillsSetup({ ...options, initial: reload(offered), requestedSkill: 'suk-graduate' });
assert.deepEqual(continued, offered);
});
void test('all fourteen native skills assign genuine Moritani discs and all four policies consume private legal offers', () => {
for (const card of LEADER_SKILL_CARDS) {
const game = completedAdvancedNativeSkillsGame({ family: 'moritani', requestedSkill: card.id });
assert.equal(trainer(game, player(game, 'moritani').id).skill, card.id);
assert.equal(player(game, 'moritani').leaders.find(l => l.id === trainer(game, player(game, 'moritani').id).leader)!.dead, false);
custody(game);
}
const offered = initializeAdvancedNativeSkillsSetup({ family: 'moritani', requestedSkill: 'warmaster' });
const actor = player(offered, 'moritani').id;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(reload(offered), actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view)[0];
assert.equal(action?.type, 'leaderSkill', difficulty);
const selected = act(offered, actor, action);
assert.ok(offered.leaderSkills!.offers[actor].cards.includes(trainer(selected, actor).skill));
assert.equal(selected.leaderSkills!.offers[actor], undefined);
assert.equal(trainer(selected, actor).leader, action.leader);
assertAdvancedNativeCustody(selected);
}
});
void test('native Terror kills a real trained disc, returns only its card, and eligible paid own revival offers one private optional draw', () => {
let game = completedAdvancedNativeSkillsGame({ family: 'moritani', skillOwner: 'emperor', requestedSkill: 'warmaster' });
const owner = player(game, 'moritani').id, actor = player(game, 'emperor').id;
const assignment = structuredClone(trainer(game, actor));
// Controlled leader availability makes the native random victim unambiguous;
// no trained card, force or wallet is fabricated or removed by this staging.
for (const leader of player(game, 'emperor').leaders) if (leader.id !== assignment.leader) {
leader.dead = true; leader.deaths = 1;
}
game = placeTerror(game, 'assassination');
const beforeEntry = reload(game);
game = enterTerror(game, actor);
assert.equal(player(game, 'emperor').reserves, player(beforeEntry, 'emperor').reserves - 1);
assert.equal(player(game, 'emperor').forces['carthag:11'], 1);
assert.equal(viewGame(game, owner).terrorEntry!.kind, 'assassination');
assert.equal('kind' in viewGame(game, actor).terrorEntry!, false);
const others = game.leaderSkills!.assignments.filter(a => a.owner !== actor);
const funds = wallets(game);
const strength = player(game, 'emperor').leaders.find(l => l.id === assignment.leader)!.strength;
game = act(game, owner, { type: 'decision', reveal: true });
assert.equal(player(game, 'emperor').leaders.find(l => l.id === assignment.leader)!.dead, true);
assert.equal(game.leaderSkills!.assignments.some(a => a.owner === actor), false);
assert.deepEqual(game.leaderSkills!.assignments, others);
assert.equal(game.leaderSkills!.deck.filter(c => c === assignment.skill).length, 1);
assert.deepEqual(wallets(game), { ...funds, [owner]: funds[owner] + strength });
custody(game);
reject(game, owner, { type: 'decision', reveal: true });
// Explicit conserved Revival phase; retain real post-shipment money and Tanks.
Object.assign(game, { phase: 4, active: null, ready: [], decision: null, response: null,
phaseOpening: null, revivalRules: newRevivalRules() });
const paidBefore = wallets(game);
const offered = act(game, actor, { type: 'reviveLeader', leader: assignment.leader });
assert.equal(offered.decision?.kind, 'leaderSkillRevival');
assert.equal(player(offered, 'emperor').leaders.find(l => l.id === assignment.leader)!.dead, false);
assert.deepEqual(wallets(offered), { ...paidBefore, [actor]: paidBefore[actor] - strength });
assert.deepEqual(viewGame(offered, actor).leaderSkills!.offer!.cards, []);
for (const seat of offered.players.filter(p => p.id !== actor)) assert.equal(viewGame(offered, seat.id).leaderSkills!.offer, null);
const event = offered.leaderSkills!.offers[actor].event;
reject(offered, owner, { type: 'leaderSkill', event, mode: 'draw' });
reject(offered, actor, { type: 'leaderSkill', event: `${event}:stale`, mode: 'draw' });
const declined = act(offered, actor, { type: 'leaderSkill', event, mode: 'decline' });
assert.equal(declined.leaderSkills!.assignments.some(a => a.owner === actor), false);
assert.equal(declined.leaderSkills!.offers[actor], undefined);
custody(declined);
const drawn = act(offered, actor, { type: 'leaderSkill', event, mode: 'draw' });
const own = viewGame(drawn, actor).leaderSkills!;
assert.equal(own.offer!.cards.length, 2);
for (const seat of drawn.players.filter(p => p.id !== actor)) assert.equal(viewGame(drawn, seat.id).leaderSkills!.offer, null);
const skill = own.offer!.cards.find(c => !own.unavailableSkills?.[c])!;
const chosen = act(drawn, actor, { type: 'leaderSkill', event, skill, leader: assignment.leader });
assert.deepEqual(trainer(chosen, actor), { owner: actor, leader: assignment.leader, skill });
assert.deepEqual(wallets(chosen), wallets(offered));
custody(chosen);
reject(chosen, actor, { type: 'leaderSkill', event, skill, leader: assignment.leader });
for (const difficulty of DIFFICULTIES) {
const view = viewGame(reload(offered), actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const next = act(offered, actor, botActions(view)[0]);
assert.deepEqual(wallets(next), wallets(offered));
assert.equal(player(next, 'emperor').leaders.find(l => l.id === assignment.leader)!.dead, false);
assert.ok(next.leaderSkills!.offers[actor]?.cards.length === 2 || next.leaderSkills!.offers[actor] === undefined);
custody(next);
}
});
void test('native Suk distinguishes one normal casualty return from the concealed three-force rescue and all four policies keep paid loss custody', () => {
const setup = completedAdvancedNativeSkillsGame({ family: 'moritani', requestedSkill: 'suk-graduate' });
const actor = player(setup, 'moritani').id, target = player(setup, 'emperor').id;
for (const hidden of [false, true]) {
let game = stageAdvancedNativeSkillBattle(setup, actor, target);
const funds = wallets(game), reserves = player(game, 'moritani').reserves;
const leader = player(game, 'moritani').leaders.find(l => hidden
? l.id === trainer(game, actor).leader : l.id !== trainer(game, actor).leader && l.strength === 4)!;
const enemy = player(game, 'emperor').leaders.find(l => l.id !== trainer(game, target).leader)!;
game = openAdvancedNativeSkillBattle(game, actor, target, hidden);
game = act(game, actor, { type: 'battlePlan', dial: 5, support: 5, leader: leader.id });
game = act(game, target, { type: 'battlePlan', dial: 0, support: 0, leader: enemy.id });
game = finishAdvancedNativeSkillAftermath(resolve(game, actor, target));
assert.equal(game.lastBattleContext!.winner, actor);
assert.equal(player(game, 'moritani').spice, funds[actor] - 5);
assert.equal(player(game, 'emperor').spice, funds[target]);
if (hidden) {
assert.equal(game.decision?.kind, 'sukRescue');
if (game.decision?.kind !== 'sukRescue') throw new Error('Expected the original native rescue decision.');
const pending = game;
const choice = game.decision.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.key === 'wind_pass:14');
assert.ok(choice >= 0);
reject(game, target, { type: 'decision', event: game.decision.event, choice });
game = act(game, actor, { type: 'decision', event: game.decision.event, choice });
assert.equal(player(game, 'moritani').tanks, 2);
assert.equal(player(game, 'moritani').forces['wind_pass:14'], 4);
assert.equal(player(game, 'moritani').reserves, reserves + 2);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(reload(pending), actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const rescued = act(pending, actor, botActions(view)[0]);
assert.equal(rescued.pendingSukRescue, null);
assert.equal(player(rescued, 'moritani').tanks, 2);
assert.equal(player(rescued, 'moritani').spice, funds[actor] - 5);
custody(rescued);
}
} else {
assert.equal(game.pendingSukRescue, null);
assert.equal(player(game, 'moritani').tanks, 4);
assert.equal(player(game, 'moritani').forces['wind_pass:14'], 3);
assert.equal(player(game, 'moritani').reserves, reserves + 1);
}
custody(game);
}
});
void test('native normal +1 and concealed trainer +3 change real winners while paid dials produce physical casualties and mandatory card cleanup', () => {
const roles = [
['warmaster', 'worthless'], ['master-of-assassins', 'poison'], ['swordmaster-of-ginaz', 'projectile'],
['killer-medic', 'snooper'], ['prana-bindu-adept', 'shield'],
] as const;
for (const [skill, kind] of roles) for (const hidden of [false, true]) {
const setup = completedAdvancedNativeSkillsGame({ family: 'moritani', requestedSkill: skill });
const actor = player(setup, 'moritani').id, target = player(setup, 'emperor').id;
let game = stageAdvancedNativeSkillBattle(setup, actor, target);
assert.deepEqual(wallets(game), wallets(setup));
const role = take(game, actor, c => c.kind === kind);
const own = player(game, 'moritani');
const selected = hidden ? own.leaders.find(l => l.id === trainer(game, actor).leader)!
: own.leaders.find(l => l.id !== trainer(game, actor).leader && l.strength === 4)!;
const enemy = player(game, 'emperor').leaders.filter(l => l.id !== trainer(game, target).leader && !own.traitors.includes(l.id))
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(enemy);
// Opponent finishes exactly two strength above our unbonused plan: normal
// +1 loses and trained +3 wins. Dial/support and casualties are physical.
const lethal = kind === 'poison' || kind === 'projectile';
const enemyDial = selected.strength + 3 - (lethal ? 0 : enemy.strength);
assert.ok(enemyDial >= 0 && enemyDial <= 8);
const funds = wallets(game);
const normalEnemyRescue = !hidden && trainer(game, target).skill === 'suk-graduate' && enemyDial > 0 ? 1 : 0;
const enemyReserves = player(game, 'emperor').reserves;
game = openAdvancedNativeSkillBattle(game, actor, target, hidden);
if (!hidden) reject(game, actor, { type: 'battlePlan', dial: 1, support: 1, leader: trainer(game, actor).leader, weapon: role.id });
const slot = kind === 'snooper' || kind === 'shield' ? 'defense' : 'weapon';
game = act(game, actor, { type: 'battlePlan', dial: 1, support: 1, leader: selected.id, [slot]: role.id });
game = act(game, target, { type: 'battlePlan', dial: enemyDial, support: enemyDial, leader: enemy.id });
game = finishAdvancedNativeSkillAftermath(resolve(game, actor, target));
assert.equal(game.lastBattleContext!.result, 'normal');
assert.equal(game.lastBattleContext!.winner, hidden ? actor : target, `${skill}/${hidden}`);
assert.equal(player(game, 'moritani').tanks, hidden ? 1 : 8);
assert.equal(player(game, 'moritani').forces['wind_pass:14'] ?? 0, hidden ? 7 : 0);
assert.equal(player(game, 'emperor').tanks, hidden ? 8 : enemyDial - normalEnemyRescue);
assert.equal(player(game, 'emperor').reserves, enemyReserves + normalEnemyRescue);
assert.equal(player(game, 'moritani').leaders.find(l => l.id === selected.id)!.dead, false);
assert.equal(player(game, 'emperor').leaders.find(l => l.id === enemy.id)!.dead, lethal);
assert.equal(player(game, 'moritani').spice, funds[actor] - 1 + (hidden && lethal ? enemy.strength : 0));
assert.equal(player(game, 'emperor').spice, funds[target] - enemyDial + (!hidden && lethal ? enemy.strength : 0));
assert.equal(game.discard.filter(c => c.id === role.id).length, hidden ? 0 : 1);
assert.equal(player(game, 'moritani').hand.some(c => c.id === role.id), hidden);
custody(game);
}
});
void test('original Enemy of My Enemy and ally retention exclude a consumed native Planetologist Special', () => {
let game = completedAdvancedNativeSkillsGame({ family: 'moritani', skillOwner: 'emperor', requestedSkill: 'planetologist' });
const m = player(game, 'moritani').id, actor = player(game, 'emperor').id, target = player(game, 'atreides').id;
game = enterTerror(placeTerror(game, 'robbery'), actor);
game = allow(act(game, m, { type: 'decision', alliance: true }));
game = act(game, actor, { type: 'decision', accept: true });
assert.equal(player(game, 'moritani').ally, actor);
assert.equal(player(game, 'emperor').ally, m);
game = stageAdvancedNativeSkillBattle(game, actor, target);
const mandatory = take(game, actor, c => c.effect === 'atomics');
const retained = take(game, actor, c => c.kind === 'snooper');
const enemyDefense = take(game, target, c => c.kind === 'snooper');
game = openAdvancedNativeSkillBattle(game, actor, target);
game = act(game, actor, { type: 'battlePlan', dial: 0, support: 0, leader: trainer(game, actor).leader,
weapon: mandatory.id, defense: retained.id });
const enemy = player(game, 'atreides').leaders.find(l => l.id !== trainer(game, target).leader)!;
game = act(game, target, { type: 'battlePlan', dial: 5, support: 5, leader: enemy.id, defense: enemyDefense.id });
game = resolve(game, actor, target);
for (let i = 0; game.decision?.kind !== 'moritaniRetention' && (game.response || game.decision || game.pendingTreacheryDiscard) && i < 30; i++) {
if (game.decision?.kind === 'battleCards') game = act(game, game.decision.player, { type: 'decision', discard: [] });
else game = advancedNativeStep(game);
}
assert.equal(game.lastBattleContext!.winner, target);
assert.equal(game.decision?.kind, 'moritaniRetention');
assert.deepEqual(game.moritaniRetention!.eligible, [retained.id]);
assert.equal(game.discard.filter(c => c.id === mandatory.id).length, 1);
reject(game, actor, { type: 'decision', keep: mandatory.id });
reject(game, m, { type: 'decision', keep: retained.id });
for (const difficulty of DIFFICULTIES) {
const view = viewGame(reload(game), actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const done = allow(act(game, actor, botActions(view)[0]));
assert.equal(done.moritaniRetention, null);
assert.equal(done.discard.filter(c => c.id === mandatory.id).length, 1);
custody(done);
}
game = allow(act(game, actor, { type: 'decision', keep: retained.id }));
assert.equal(game.moritaniRetention, null);
assert.equal(player(game, 'emperor').hand.some(c => c.id === retained.id), true);
custody(game);
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
});
void test('new native admission preserves public, Harkonnen assassination, foreign family/module and Advanced Atreides Suk boundaries', () => {
const make = (faction: 'atreides' | 'harkonnen' | 'ixians' | 'ecaz' = 'atreides', expansions: Game['expansions'] = ['ecaz']) => {
const game = createGame('MORIGATE', newPlayer('original-m', 'Moritani', 'moritani'), true, expansions);
joinGame(game, newPlayer('original-other', faction, faction));
return game;
};
reject(make(), 'original-m', { type: 'start' });
for (const game of [make('harkonnen'), make('ixians', ['ecaz', 'ix']), make('ecaz'), make('atreides', ['ecaz', 'choam'])]) {
game.players.forEach(p => { p.ready = true; });
const before = reload(game);
assert.throws(() => initializeLeaderSkillsGameForAudit(game));
assert.deepEqual(game, before);
}
const discoveryLobby = make();
discoveryLobby.players.forEach(p => { p.ready = true; });
discoveryLobby.discoveryEnabled = true;
assert.throws(() => initializeLeaderSkillsGameForAudit(discoveryLobby));
// Basic Moritani keeps its original Harkonnen-compatible skills profile; it
// does not acquire the Advanced-only assassination preview.
const basicLobby = make('harkonnen');
basicLobby.advanced = false;
basicLobby.players.forEach(p => { p.ready = true; });
const basic = completeAdvancedNativeSkillsSetup(initializeLeaderSkillsGameForAudit(basicLobby), { family: 'moritani' });
assert.equal(basic.advanced, false);
assert.equal(basic.moritaniAssassinatePreview, undefined);
assert.equal(player(basic, 'harkonnen').traitors.length, 4);
assert.equal(trainer(basic, 'original-m').owner, 'original-m');
custody(basic);
const options = { family: 'moritani' as const, skillOwner: 'atreides' as const, requestedSkill: 'suk-graduate' as const };
const offered = initializeAdvancedNativeSkillsSetup(options);
const actor = player(offered, 'atreides').id, offer = offered.leaderSkills!.offers[actor];
const own = viewGame(offered, actor).leaderSkills!;
reject(offered, actor, { type: 'leaderSkill', event: offer.event, skill: 'suk-graduate', leader: own.eligibleLeaders[0].id });
const complete = completeAdvancedNativeSkillsSetup(offered, options);
assert.notEqual(trainer(complete, actor).skill, 'suk-graduate');
assert.ok(complete.leaderSkills!.deck.includes('suk-graduate'));
custody(complete);
});
+10 -6
View File
@@ -242,10 +242,10 @@ void test('Advanced Moritani assassination samples retain their preview and phys
assert.equal(existsSync(unsupported), false);
});
void test('Moritani Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
void test('Moritani Skills samples preserve full Basic and Advanced native decks through saved continuation', (t) => {
const area = temporary(t);
const out = join(area, 'skills');
const result = run(out, '--profile', 'moritani-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
const result = run(out, '--profile', 'moritani-skills', '--rules', 'basic', '--players', '2', '--seed', '1000', '--max-actions', '1');
assert.equal(result.status, 1);
assertFailedEvidence(out, 'moritani-skills-2-basic', 1016, false);
const snapshot = join(out, 'failed-moritani-skills-2-basic.json');
@@ -256,10 +256,14 @@ void test('Moritani Skills samples preserve their full module on saved continuat
const resumed = join(area, 'skills-resumed');
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(resumed, 'moritani-skills-2-basic', 1016, true);
const unsupported = join(area, 'unsupported');
const bad = run(unsupported, '--profile', 'moritani-skills', '--rules', 'advanced');
assert.equal(bad.status, 1);
assert.equal(existsSync(unsupported), false);
const advanced = join(area, 'advanced');
assert.equal(run(advanced, '--profile', 'moritani-skills', '--rules', 'advanced', '--players', '2', '--seed', '1000', '--max-actions', '1').status, 1);
assertFailedEvidence(advanced, 'moritani-skills-2-advanced', 1249, false);
const state = json<typeof game>(join(advanced, 'failed-moritani-skills-2-advanced.json'));
assert.equal(state.advanced, true);
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
assert.equal(new Set(cards).size, 14);
assert.equal(cards.length, 14);
});
void test('Tleilaxu Skills samples preserve full Basic and Advanced native decks through saved continuation', (t) => {
+12 -2
View File
@@ -10,7 +10,7 @@ import { newRevivalRules } from '../game/revival';
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
export type AdvancedNativeSkillsOptions = {
family: 'ixians' | 'choam' | 'tleilaxu';
family: 'ixians' | 'choam' | 'tleilaxu' | 'moritani';
requestedSkill?: LeaderSkillId;
skillOwner?: FactionId;
opponents?: readonly FactionId[];
@@ -53,7 +53,7 @@ function withSkillShuffle<T>(skill: LeaderSkillId, ownerIndex: number, initializ
* physical skill offers. No played save is converted and no deck is cherry-picked. */
export function initializeAdvancedNativeSkillsSetup(options: AdvancedNativeSkillsOptions): Game {
const ownerFaction = options.skillOwner ?? options.family;
const expansion = options.family === 'choam' ? 'choam' : 'ix';
const expansion = options.family === 'moritani' ? 'ecaz' : options.family === 'choam' ? 'choam' : 'ix';
let game: Game;
if (options.initial) game = structuredClone(options.initial);
else {
@@ -106,6 +106,12 @@ export function completedAdvancedNativeSkillsGame(options: AdvancedNativeSkillsO
}
export function advancedNativeStep(game: Game): Game {
// Original native Moritani choices stay real actions; decline optional Terror
// placement/use rather than letting a policy invent fixture phase history.
if (game.decision?.kind === 'moritaniPlacement')
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
if (game.decision?.kind === 'moritaniTerror' && ['select', 'offer'].includes(game.pendingTerrorEntry!.stage))
return applyAction(game, game.decision.player, { type: 'decision', decline: true });
const next = nextSpiceBankerIncomeNativeStep(game);
assert.ok(next);
return applyAction(game, next.actor, next.action);
@@ -225,6 +231,10 @@ export function finishAdvancedNativeSkillAftermath(state: Game): Game {
let game = state;
for (let i = 0; (game.response || game.decision || game.battle || game.pendingTreacheryDiscard) && i < 100; i++) {
if (game.decision?.kind === 'sukRescue') break;
if (game.decision?.kind === 'moritaniAssassinate') {
game = applyAction(game, game.decision.player, { type: 'decision', event: game.decision.event, decline: true });
continue;
}
if (game.decision?.kind === 'ixSubstitution') {
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
} else if (game.decision?.kind === 'battleCards') {
+1 -25
View File
@@ -2,13 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { basicMoritaniLeaderSkillsProfile } from '../game/leader-skill-profile';
import { spiceBankerModeSupported } from '../game/spice-banker';
import { diplomatDefenseModeSupported } from '../game/diplomat-defense';
import { sandmasterModeSupported } from '../game/sandmaster-movement';
import { smugglerBattleModeSupported } from '../game/smuggler-battle';
import { bureaucratPaymentModeSupported } from '../game/bureaucrat-payment';
import { planetologistMovementModeSupported } from '../game/planetologist-movement';
import { planetologistMoveDraft } from '../components/planetologist-movement';
import { TERRITORIES, distance, location, splitLocation } from '../game/board';
import { botActions } from '../game/bots';
@@ -40,16 +33,8 @@ void test('every canonical skill can be dealt and publicly assigned to Basic Mor
}
});
void test('Moritani skill integration shares public guards and retains Advanced, Ecaz and optional-module boundaries', () => {
const game = completedMoritaniSkillsGame();
const gates = [basicMoritaniLeaderSkillsProfile, spiceBankerModeSupported, diplomatDefenseModeSupported,
sandmasterModeSupported, smugglerBattleModeSupported, bureaucratPaymentModeSupported, planetologistMovementModeSupported];
for (const gate of gates) {
assert.equal(gate(game), true, gate.name);
assert.equal(gate(viewGame(game, 'a')), true, `${gate.name} projected`);
}
void test('Moritani skill starts retain immutable Ecaz, optional-module and public-start rejection', () => {
for (const change of [
(g: Game) => { g.advanced = true; },
(g: Game) => { g.players[1] = newPlayer('a', 'Ecaz', 'ecaz'); },
(g: Game) => { g.expansions.push('choam'); },
(g: Game) => { g.discoveryEnabled = true; },
@@ -62,7 +47,6 @@ void test('Moritani skill integration shares public guards and retains Advanced,
const before = JSON.stringify(lobby);
assert.throws(() => initializeLeaderSkillsGameForAudit(lobby));
assert.equal(JSON.stringify(lobby), before);
assert.equal(basicMoritaniLeaderSkillsProfile(lobby), false);
}
const publicLobby = createGame('MORIPUBL', newPlayer('m', 'Moritani', 'moritani'), false, ['ecaz']);
joinGame(publicLobby, newPlayer('a', 'Atreides', 'atreides'));
@@ -98,14 +82,6 @@ void test('Moritani Planetologist controls and bots use the same extended moveme
assert.equal(player(done, 'm').forces[from] ?? 0, 0);
assert.equal(player(done, 'm').moved, 1);
assertMoritaniSkillsCustody(done);
// Make the same two-step target useful; only legal availability is assessed.
game.spice = { [to]: 8 };
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'm'); view.players.find(p => p.id === 'm')!.bot = difficulty;
const action = botActions(view).find(a => a.type === 'move' && a.planetologist === 'range');
assert.ok(action, difficulty);
assert.doesNotThrow(() => applyAction(game, 'm', action));
}
});
void test('Moritani Smuggler ships the free companion once and Bureaucrat redirects a real paid bribe after restore', () => {
+11 -10
View File
@@ -212,13 +212,16 @@ const COMBINED_HOMEWORLD_NEXUS_SCENARIOS: readonly Scenario[] =
const MORITANI_SKILLS_ROSTER: readonly FactionId[] = [
'moritani', 'emperor', 'guild', 'harkonnen', 'fremen', 'beneGesserit',
];
const MORITANI_SKILLS_SCENARIOS: readonly Scenario[] = [2, 3, 4, 5, 6].map(players => ({
ordinal: 16 + players - 2,
profile: 'moritani-skills',
rules: 'basic',
expansions: ['ecaz'],
roster: MORITANI_SKILLS_ROSTER.slice(0, players),
}));
const MORITANI_SKILLS_SCENARIOS: readonly Scenario[] = [
...[2, 3, 4, 5, 6].map((players): Scenario => ({
ordinal: 16 + players - 2, profile: 'moritani-skills', rules: 'basic',
expansions: ['ecaz'], roster: MORITANI_SKILLS_ROSTER.slice(0, players),
})),
...[2, 3, 4, 5, 6].map((players): Scenario => ({
ordinal: 249 + players - 2, profile: 'moritani-skills', rules: 'advanced', expansions: ['ecaz'],
roster: (['moritani', 'atreides', 'emperor', 'guild', 'fremen', 'beneGesserit'] as FactionId[]).slice(0, players),
})),
];
const MORITANI_ASSASSINATE_ROSTER: readonly FactionId[] = [
'moritani', 'atreides', 'emperor', 'fremen', 'guild', 'beneGesserit',
];
@@ -348,7 +351,7 @@ function usage() {
'[--resume FAILED_GAME.json]\n' +
'Runs genuine setup and gameplay offline. Default/all keeps the six expansion samples; combined-nexus and combined-homeworld-nexus add five/six-seat Basic/Advanced all-expansion samples. Base, Homeworld, Nexus, Homeworld-Nexus, expansion roster, paired expansion Nexus, Ecaz card variant, Advanced Moritani assassination and skill profiles select their documented rosters. Stronghold-factions is Advanced only: Ixians + CHOAM, native Ixians + Tleilaxu, or native CHOAM + Richese with classic opponents, two through six seats; mixed four-native rosters also run at four through six seats. Selected Ix/CHOAM decks determine the canonical 47/35 Treachery Cards; Richese has a separate ten-card cache and all samples have six separate Stronghold Cards without other modules. Scenario names identify their native roster. --players requires a supported profile. Output must be a new private directory outside the checkout.' +
'\nEcaz-occupy is Advanced only: native Ecaz plus classic/Ixian/Tleilaxu/CHOAM/Richese paired or mixed rosters, two through six seats; selected E3/E1/E2 sources determine ordinary33/47/35 cards. Explicit independent three-card samples additionally use36/50/38. Richese marker-only or ordinary pools retain original reveal timing; own mixed No-Field dialing, whole-plan inspection and optional overlays remain guarded.' +
'\nIx-skills, choam-skills and tleilaxu-skills retain their Basic samples and add Advanced native plus classic rosters, two through six seats. Ix-skills also selects Ixian+CHOAM; tleilaxu-skills additionally selects Tleilaxu+Ixian and three-to-six-seat Tleilaxu+Ixian+CHOAM. All fourteen skills and distinct required family decks remain. Moritani-skills remains Basic only.'
'\nAll four native skill profiles retain their Basic samples. Advanced Ix-skills/choam-skills/tleilaxu-skills use the documented native plus classic families; Ix-skills also selects Ixian+CHOAM, and tleilaxu-skills selects Tleilaxu+Ixian and three-to-six-seat Tleilaxu+Ixian+CHOAM. Advanced moritani-skills composes its existing non-Harkonnen classic assassination roster with all fourteen skills and the ecaz deck. Public starts and unrelated module/roster guards remain.'
);
}
@@ -734,8 +737,6 @@ async function main() {
throw new Error('--players requires a roster or optional-module profile.');
if ((profile === 'combined-nexus' || profile === 'combined-homeworld-nexus') && players !== 'all' && ![5, 6].includes(players))
throw new Error(`--profile ${profile} supports only five or six players.`);
if (profile === 'moritani-skills' && rules === 'advanced')
throw new Error('Moritani Leader Skills profiles currently support only Basic samples.');
if (profile === 'moritani-assassinate' && rules === 'basic')
throw new Error('Moritani assassination samples require Advanced rules.');
if (profile === 'stronghold-factions' && rules === 'basic')
+1 -1
View File
@@ -24,7 +24,7 @@ async function main() {
console.log(
'Usage: node --import tsx tools/start-prototype.ts --profile ix|discovery|leader-skills|banker-income|factions|stronghold-factions|nexus|moritani-assassinate|ecaz-treachery|ecaz-occupy|semuta|kull|richese-betrayal|nexus-kull|guild-betrayal|ixian-replacement|ixian-betrayal|harkonnen-betrayal --db PATH --room CODE --version NUMBER --out /private/new-directory\nStarts only a fresh ready lobby for the selected development prototype. Stronghold-factions opts a fresh ready 2–6-seat Advanced roster with at least one selected native Ixians, Tleilaxu, CHOAM or Richese faction and otherwise classic opponents into Stronghold Cards alone. Select one or both distinct Ix/CHOAM decks; each native faction requires its family deck. The selected decks supply 47 Ix or combined, or 35 CHOAM canonical physical Treachery Cards, with a separate ten-card Richese cache when Richese plays. Native setup choices, HMS placement, Face Dancers, Richese cache and CHOAM Auditor remain genuine; custody starts unowned and is claimed by actual END Mentat. It does not redeal, reset or retrofit a saved game. Banker-income opts a fresh 2–6-seat classic Basic/Advanced base-deck roster, supported Basic Ixian/Tleilaxu/CHOAM/Moritani skill roster with its single required deck, or Advanced native Ixians, Tleilaxu and/or CHOAM plus classic roster with distinct required family decks, into automatic deferred normal income and native Mentat collection with all fourteen Leader Skills. Original bank payer costs exclude the separate Tleilaxu free-revival award; already-earned spice stays with its original faction under the existing provisional policy. Nexus enables the existing private Nexus setup. Moritani-assassinate explicitly opts into the bounded Advanced assassination preview. Semuta opts a fresh Richese roster into bounded clean-discard reactions; an included Ix deck supplies physical Thumper without opening public starts. Kull opts a fresh eligible CHOAM roster into the user-selected Karama interception preview. Richese-betrayal opts a fresh paired CHOAM/Richese Nexus roster into cache-purchase veto and sale-bank diversion. Nexus-kull opts a fresh CHOAM-plus-classic roster with CHOAM/Ix decks and Nexus into source-aware printed or any-Treachery-fueled Cunning Kull. Guild-betrayal opts a fresh classic roster with native Guild into full funded shipment-payment replacement; it preserves an explicitly enabled Homeworld module for Junction routes. Ixian-replacement opts a fresh classic Basic/Advanced base-deck roster, or Basic Tleilaxu plus classic opponents with the original Ix47 deck and no optional Sandtrout, into the exact normal-auction purchased-card replacement with Nexus alone. Native Ixians, Harkonnen buyers and special purchase sources remain unsupported. Ixian-betrayal opts a fresh 2–6-seat Basic/Advanced native Ixian plus classic/Tleilaxu roster with Ix 47-card Treachery and Nexus only into prevention of one actual native Bidding draw or Advanced Technology exchange, after native Karama counters. Harkonnen-betrayal opts a fresh 2–6-seat classic Basic/Advanced roster with native Harkonnen, base 33-card Treachery and Nexus alone into cancellation of an actual declared personal or allied Harkonnen Traitor Card after native counters, physical retirement and shuffle, and one private actual Mentat replacement. Modules, overlays, public starts and old-game retrofits remain excluded. Nexus is not dealt initially: a qualifying alliance requires at least three seats; two unallied receivers require four. Backs up all rooms first, preserves sessions and existing games, and rejects stale versions. Normal game-start and publication gates remain closed.',
'\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat Advanced lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Distinct selected decks include ecaz and each native ix/choam family; ordinary33/47/35 cards retain real original offers/HMS/Auditor/Face Dancers and Richese cache. Explicit --ecaz-treachery independently adds the three physical variant cards, making36/50/38. Native Richese ordinary and marker-only pools compose with selected lead plans; original own mixed No-Field dialing, whole-plan disclosure and optional overlays remain guarded.' +
'\nLeader-skills retains the existing classic and Basic native entries and now composes Advanced native Ixians, Tleilaxu and/or CHOAM with classic opponents, their distinct required ix/choam decks and all fourteen skills. Original native setup, Face Dancers, Auditor exclusion, typed force support and skill posture remain. Native foreign-ghola revival never draws or assigns a new skill. Explicit --mentat-question retains its private uniform preview; banker-income retains separate normal deferred income in the same supported native subset. Public activation, empty-hand/captured-replacement rulings and other optional overlays remain pending.',
'\nLeader-skills retains existing classic and Basic native entries. Advanced native Ixians, Tleilaxu and/or CHOAM plus classic rosters use distinct required ix/choam decks and all fourteen skills; original setup, Face Dancers, Auditor exclusion and typed aftermath remain. Foreign-ghola revival never draws a new skill. A separate Advanced Moritani plus non-Harkonnen classic roster uses the ecaz deck, five native assignment discs and original assassination preview; winner skills finish before post-loss assassination. Explicit --mentat-question remains a private uniform preview; banker-income retains separate normal deferred income in these supported native subsets. Public activation, empty-hand/captured-replacement rulings, Ecaz/Duke skill assignment and other optional overlays remain pending.',
);
return;
}