Restore ordinary Skills in bounded Guild Betrayal composition

This commit is contained in:
Quintine committed 2026-10-13 19:19:05 +11:00
1 parent 9fb680da09
commit ea12a68e56
12 files changed
+637 -11

No files matched your search

+18
View File
@@ -29,6 +29,24 @@ optional skill bands, combinations and public expansion starts remain open.
Rules implementation continues; comprehensive assurance and administration
remain deferred until all rules are implemented.
**Ordinary Skills in Guild Betrayal — 13 October.**
The new Bureaucrat composition had exposed all14 cards but omitted its profile
from ordinary skill quotes. A real Sandmaster move rejected while ordinary
movement succeeded; native Mentat battle naming was absent before posture.
The exact native-Guild classic profile now joins ordinary skill legality; it
explicitly rejects Homeworlds/Tech/Strongholds/Semuta and other overlays.
Actual CLI `guild-betrayal --leader-skills` dispatch is connected and exercised.
Native Sandmaster wallet5→6/pile6→5/moved1 precedes original Collection
wallet8/pile3. Actual390px Mentat naming→target Crysknife show retains the card,
records the answer only for entitled seats and restores original posture; paid
trainer wallet3 and all other wallets stay unchanged. Artifact:
`/tmp/dune-betrayal-mentat-3232.json`; browser errors empty.
Frozen42/42 across three affected files and types/lint pass after removing a
nonexistent controller-property pin and an incidental zero-spice-map shape pin.
No source rule, strategy, public start, saved-game or full-mode claim follows.
Final68/68 across five related unit files, types/lint and app build pass with
unchanged warnings; owned QA resources are released.
**Advanced occupied Bureaucrat receipts — 13 October.**
Actual Advanced Emperor and Guild gross8 receipts now prove native3/occupied3/
Bank2 through retained original qualifications, with original full payer shares.
+48
View File
@@ -1,5 +1,53 @@
# Dune implementation status
## 13 October 2026 — ordinary skill legality in Guild Betrayal
The previous composition dealt all14 cards but only admitted Bureaucrat to its
quote union. Original `ordinaryLeaderSkillModeSupported` omitted the new
Guild Betrayal profile, which also withheld Mentat naming and rejected optional
Sandmaster collection while allowing the same ordinary move.
The exact composition now joins ordinary skill legality. Its predicate checks
native Guild and rejects Homeworlds/Tech/Strongholds/Semuta in addition to
existing unrelated-overlay guards; no broader profile is enabled. Existing
ordinary quote consumers retain their rule calculations. Actual prototype
dispatch now supports `--profile guild-betrayal --leader-skills`; an isolated
in-memory room reaches original setup/offers/Nexus/cursor without touching saves.
The shared original trained-setup fixture is reusable for an actual chosen
trainer card; no offer or assignment is replaced.
Native BEFORE Sandmaster: original BG fighter1 Polar Sink/reserves19, printed
Hagga Basin6. Ordinary ground movement succeeds; identical collecting request
rejects immutably. One real original paid shipment/all-pass Betrayal prefix stays
bound. AFTER: exactly one spice debited and credited, original counter moved,
ordinary moved count1; native endMovement legitimately collects another2,
ending wallet8/pile3. The child post-Collection assertion expecting5 failed
after the actual skill path succeeded; no production rule was changed for it.
Native BEFORE Mentat: paid two-force deployment cost2 (BG5→3) against original
Atreides army, living trained native disc. Battle skipped Mentat naming and
opened original posture. AFTER: owner names Crysknife, target privately shows
the held card, entitled history records it and original posture/powers resume.
The child target plan incorrectly selected a face-up trained Warmaster;
consumer regression uses a real living untrained target disc and accepts both
native card-free plans, preserving held cards/resources.
Actual390px owner question and target Show Crysknife privately controls retain
the weapon and restore original posture. Owner history contains the answer;
outside history stays empty, paid wallets unchanged. Artifact
`/tmp/dune-betrayal-mentat-3232.json`; recorded browser errors empty.
Only owned QA host/tab/scaffold are removed.
Initial frozen42-case union had40 pass/two fixture assertions fail: nonexistent
assignment.controller and native automatic Collection creating zero pile keys.
Those representation/default pins are removed rather than re-pinned; the
abandoned request instead preserves its actual printed pile and trainer cash.
Final42/42 and types/lint pass. This proves the bounded connected paths, not
all skill combinations, strategic AI, full games, recovery or deployed acceptance.
Final frozen related union68/68 across five files, types/lint and app build pass
with unchanged module-registration/chunk/route-classification warnings.
## 13 October 2026 — actual Advanced occupied receipt coverage
Native Advanced Emperor and Guild programs settle actual gross8 into native3,
+10 -1
View File
@@ -70,7 +70,16 @@ Integrated verification should exercise first shipment followed by moved/decline
### Entry, physical cost and funding
The explicit fresh `guild-betrayal` development profile admits two to six ready classic-faction seats in Basic or Advanced, including native Guild and at least one other faction. It uses physical base decks and Nexus; Homeworlds are optional only when genuinely enabled and seeded during setup. It does not retrofit games, silently add Homeworlds, admit other expansion factions/decks or combine Leader Skills, Discoveries, Tech Tokens, Stronghold Cards, Semuta, Kull or Richese previews.
The explicit fresh `guild-betrayal` development profile admits two to six ready classic-faction seats in Basic or Advanced, including native Guild and at least one other faction. It uses physical base decks and Nexus; Homeworlds are optional only without Skills and when genuinely enabled and seeded during setup. It does not retrofit games, silently add Homeworlds, admit other expansion factions/decks or combine Discoveries, Tech Tokens, Stronghold Cards, Semuta, Kull or Richese previews.
Explicit `--profile guild-betrayal --leader-skills` instead retains the original
all14 setup and native classic Guild roster with no Homeworlds or other overlays.
The ordinary skill quote layer admits this exact composition rather than
silently disabling Mentat questions, Sandmaster movement or related skill bands.
The user-ruled Bureaucrat offer follows the original completed invoice; on a
qualified third-party share it diverts two from the holder receipt to Bank.
Payer costs, donor escrow, physical Nexus discard and shipment remain original.
See [Bureaucrat ruling and evidence](BUREAUCRAT_PAYMENTS.md#user-rulings--13-october-2026).
Only original native shipment producers are admitted here. Native Guild Nexus Cunning's second shipment and Richese Nexus Secret Ally's discounted shipment are blocked in this profile **before** spending either singleton or creating a native parent. Those effects outside this profile remain unchanged. Guild Secret Ally is already unavailable because native Guild is seated, not because this preview establishes a new prohibition on its printed panel.
+4 -2
View File
@@ -68,7 +68,8 @@ export function classicNexusLeaderSkillsProfile(game: LeaderSkillProfile): boole
/** Fresh classic Nexus/Skills composed only with the bounded Guild Betrayal preview. */
export function guildBetrayalNexusLeaderSkillsProfile(game: LeaderSkillProfile): boolean {
if (game.expansions.length || !game.guildBetrayalPreview ||
if (game.expansions.length || !game.guildBetrayalPreview || game.homeworlds ||
game.techTokens || game.strongholdCards || game.semutaPreview ||
!nexusLeaderSkillModulesSupported(game, false, false, true)) return false;
const players = game.players!;
for (let i = 0; i < players.length; i++) {
@@ -78,7 +79,7 @@ export function guildBetrayalNexusLeaderSkillsProfile(game: LeaderSkillProfile):
if (faction.id === players[i].faction) { classic = faction.expansion === 'base'; break; }
if (!classic) return false;
}
return true;
return players.some(player => player.faction === 'guild');
}
/** Both original natives in one E1/E2 family, optionally Homeworlds/Discovery/Tech/Strongholds. */
@@ -515,6 +516,7 @@ export function ordinaryLeaderSkillModeSupported(game: LeaderSkillProfile): bool
(!game.expansions.length || nativeExpansionLeaderSkillsProfile(game))) ||
classicTechLeaderSkillsProfile(game) || nativeTechLeaderSkillsProfile(game) ||
strongholdLeaderSkillsProfile(game) || classicNexusLeaderSkillsProfile(game) ||
guildBetrayalNexusLeaderSkillsProfile(game) ||
singleE1E2NexusLeaderSkillsProfile(game) ||
mixedE3NexusLeaderSkillsProfile(game) || mixedE1E2NexusLeaderSkillsProfile(game) || pairedE3NexusLeaderSkillsProfile(game) ||
pairedNexusLeaderSkillsProfile(game) || standaloneE3NexusLeaderSkillsProfile(game) ||
+1
View File
@@ -4777,6 +4777,7 @@ export const RULE_TOPICS: RuleTopic[] = [
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: [
'Fresh all14 Leader Skills preserves original classic, supported E1/E2 or standalone Ecaz OR Moritani setup with Homeworlds/Discovery/Tech3+/Advanced Strongholds under each envelope. Explicit --nexus-cards additionally admits single/paired one-family E1/E2, BOTH selected Ix+CHOAM native families/union47 and standalone OR paired Ecaz/Moritani/ecaz33 with classics. Basic/Advanced2–6, original Ix pre-training offer, ordinary native discs and actual hands remain; Auditor/shared Duke never train. Winning normal/trained Suk uses source-labelled native, visitor or nested fixed-loss pools; native saved counters stay at that home and external Emperor returns allocate original homes. Original phase-end industry, held benefits, cleanup/Tech before Face Dance and native assassination remain; unreconciled bands, mixed E3/three-family, public starts and played-save conversion stay separate.',
'Explicit guild-betrayal --leader-skills retains the original classic native-Guild Nexus profile without Homeworlds, Tech, Strongholds, Discovery, Semuta or other overlays. All14 training remains native. Ordinary skill quotes now admit the composition, restoring actual Mentat private questions before posture and Sandmaster ground collection rather than only enabling Bureaucrat. Native card custody, paid forces, original Betrayal Use/Pass and movement/plan continuation stay unchanged. Other combinations and complete-mode acceptance remain separate.',
'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 profiles compose native Ixians, Tleilaxu, CHOAM and/or Richese with classic opponents and distinct required family decks. All fourteen skills and native starts remain. Auditor stays unskilled; cyborg casualties, original CHOAM support and Richese capped marker revelation remain physical. Foreign revival creates no new skill. Supplied Advanced p34 excludes Richese auctions from Ixian Technology; own mixed No-Field, captured replacement, Advanced Atreides Suk/KH and unrelated overlays retain their guards.',
'Fresh three-through-six-seat Basic/Advanced development tables may compose all fourteen Skills with original Tech Tokens: classic factions, supported E1/E2 native families, or standalone Ecaz/Moritani with the exact ecaz deck and original roster exclusions. Starting cards/cache, skill/Traitor choices and printed/first-Storm assignment stay genuine. Existing leader-skills/banker-income entry preserves unused selected tokens; mixed E3 families, other overlays, public activation and saved-game conversion remain separate.',
+137
View File
@@ -0,0 +1,137 @@
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 {
betrayalMentatResources, betrayalMentatSeat, createBetrayalMentatQuestionFixture,
prepareBetrayalMentatPlans, reloadBetrayalMentat,
} from './fixture-betrayal-mentat-question';
function rejectBetrayalMentat(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('native Betrayal Mentat exact weapon shows privately before original posture and accepts card-free native plans', () => {
const fixture = createBetrayalMentatQuestionFixture();
const { owner, target, observer, leader } = fixture;
let game = reloadBetrayalMentat(fixture.battle);
assert.equal(game.decision?.kind, 'mentatQuestion');
assert.equal(game.decision?.player, owner);
assert.equal(game.battle!.preparation, undefined);
assert.equal(game.battle!.leaderSkillHidden, undefined);
assert.equal(viewGame(game, owner).mentat.pending!.stage, 'name');
const event = game.battle!.mentatQuestionEvent!;
const resources = betrayalMentatResources(game);
assert.deepEqual(resources, betrayalMentatResources(fixture.paid));
assert.ok(fixture.shipmentGateEvents.length);
const ask: Action = { type: 'decision', event, weapon: 'Crysknife' };
rejectBetrayalMentat(game, target, ask);
rejectBetrayalMentat(game, owner, { ...ask, event: 'stale' });
game = applyAction(game, owner, ask);
assert.equal(game.decision?.kind, 'mentatQuestion');
assert.equal(game.decision?.player, target);
assert.equal(game.battle!.preparation, undefined);
const held = betrayalMentatSeat(game, target).hand;
const weapon = held.find(card => card.name === 'Crysknife')!;
const other = held.find(card => card.name === 'Snooper')!;
assert.deepEqual(viewGame(game, target).mentat.pending!.cards,
held.filter(card => card.name === 'Crysknife'));
for (const id of [owner, observer, 'shipper'])
assert.deepEqual(viewGame(game, id).mentat.pending!.cards, []);
const show: Action = { type: 'decision', event, card: weapon.id };
rejectBetrayalMentat(game, owner, show);
rejectBetrayalMentat(game, target, { ...show, event: 'stale' });
rejectBetrayalMentat(game, target, { ...show, card: other.id });
game = applyAction(reloadBetrayalMentat(game), target, show);
assert.equal(game.decision?.kind, 'leaderSkillVisibility');
assert.equal(game.battle!.preparation, undefined);
assert.deepEqual(betrayalMentatResources(game), resources);
for (const id of [owner, target])
assert.equal(viewGame(game, id).mentat.history[0].card.id, weapon.id);
for (const id of [observer, 'shipper']) {
assert.deepEqual(viewGame(game, id).mentat.history, []);
assert.ok(!JSON.stringify(viewGame(game, id)).includes(weapon.id));
}
assert.ok(!JSON.stringify(game.log).includes(weapon.id));
rejectBetrayalMentat(game, target, show);
game = prepareBetrayalMentatPlans(game);
assert.equal(game.decision, null);
assert.equal(game.battle!.preparation, undefined);
assert.equal(game.battle!.leaderSkillHidden![owner], true);
assert.deepEqual(betrayalMentatResources(game), resources);
const targetSkill = game.leaderSkills!.assignments.find(a => a.owner === target)!.leader;
const targetLeader = betrayalMentatSeat(game, target).leaders.find(l =>
!l.dead && !l.capturedBy && l.id !== targetSkill)!;
game = applyAction(game, owner, { type: 'battlePlan', dial: 0, leader });
game = applyAction(game, target, { type: 'battlePlan', dial: 0, leader: targetLeader.id });
assert.equal(game.battle!.plans[owner].leader, leader);
assert.equal(game.battle!.plans[target].leader, targetLeader.id);
assert.equal(game.battle!.plans[target].weapon, null);
assert.equal(game.battle!.plans[target].defense, null);
assert.ok(betrayalMentatSeat(game, target).hand.some(card => card.id === weapon.id));
assert.deepEqual(betrayalMentatResources(game), resources);
});
void test('native Betrayal absent weapon offers only actual held alternatives and retains the freely shown defense', () => {
const fixture = createBetrayalMentatQuestionFixture();
const { owner, target, observer } = fixture;
const before = betrayalMentatResources(fixture.battle);
let game = applyAction(reloadBetrayalMentat(fixture.battle), owner,
{ type: 'decision', event: fixture.battle.battle!.mentatQuestionEvent, weapon: 'Lasgun' });
assert.equal(game.decision?.kind, 'mentatQuestion');
assert.equal(game.decision?.player, target);
const pending = viewGame(game, target).mentat.pending!;
assert.deepEqual(pending.cards, betrayalMentatSeat(game, target).hand);
const shown = pending.cards.find(card => card.name === 'Snooper')!;
rejectBetrayalMentat(game, observer, { type: 'decision', event: pending.event, card: shown.id });
rejectBetrayalMentat(game, target, { type: 'decision', event: pending.event, decline: true });
rejectBetrayalMentat(game, target, { type: 'decision', event: pending.event, card: 'not-held' });
game = applyAction(reloadBetrayalMentat(game), target,
{ type: 'decision', event: pending.event, card: shown.id });
assert.equal(viewGame(game, owner).mentat.history[0].card.id, shown.id);
assert.deepEqual(viewGame(game, observer).mentat.history, []);
assert.deepEqual(betrayalMentatResources(game), before);
game = prepareBetrayalMentatPlans(game);
const targetSkill = game.leaderSkills!.assignments.find(a => a.owner === target)!.leader;
const targetLeader = betrayalMentatSeat(game, target).leaders.find(l =>
!l.dead && !l.capturedBy && l.id !== targetSkill)!;
game = applyAction(game, owner, { type: 'battlePlan', dial: 0, leader: fixture.leader });
game = applyAction(game, target, { type: 'battlePlan', dial: 0, leader: targetLeader.id });
assert.equal(game.battle!.plans[target].defense, null);
assert.ok(betrayalMentatSeat(game, target).hand.some(card => card.id === shown.id));
assert.deepEqual(betrayalMentatResources(game), before);
});
void test('all four legal policies apply the composed private question and response through the original next battle preparation', () => {
const fixture = createBetrayalMentatQuestionFixture();
for (const difficulty of DIFFICULTIES) {
let game = reloadBetrayalMentat(fixture.battle);
const resources = betrayalMentatResources(game);
const own = viewGame(game, fixture.owner);
own.players.find(p => p.id === fixture.owner)!.bot = difficulty;
assert.deepEqual(botActions(viewGame(game, fixture.target)), []);
assert.deepEqual(botActions(viewGame(game, fixture.observer)), []);
const ask = botActions(own)[0];
assert.ok(own.mentat.pending!.weapons.includes(ask.weapon as string));
game = applyAction(game, fixture.owner, ask);
const target = viewGame(game, fixture.target);
target.players.find(p => p.id === fixture.target)!.bot = difficulty;
const show = botActions(target)[0];
assert.ok(target.mentat.pending!.cards.some(card => card.id === show.card));
game = applyAction(game, fixture.target, show);
assert.equal(viewGame(game, fixture.owner).mentat.history.length, 1);
assert.deepEqual(viewGame(game, fixture.observer).mentat.history, []);
assert.equal(game.decision?.kind, 'leaderSkillVisibility');
const actor = game.decision!.player;
const posture = viewGame(game, actor);
posture.players.find(p => p.id === actor)!.bot = difficulty;
game = applyAction(game, actor, botActions(posture)[0]);
game = prepareBetrayalMentatPlans(game);
assert.equal(game.decision, null);
assert.equal(game.battle!.preparation, undefined);
assert.deepEqual(betrayalMentatResources(game), resources);
}
});
+114
View File
@@ -0,0 +1,114 @@
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 { TERRITORIES, location, splitLocation } from '../game/board';
import { fighterCount } from '../game/advisors';
import {
quoteSandmasterMovement, sandmasterActionOrder, sandmasterDefaultChoice,
sandmasterEnteredTerritories, sandmasterShortestRoute, type SandmasterChoice,
} from '../game/sandmaster-movement';
import { createBetrayalSandmasterRoute, sandmasterTrainer } from './fixture-betrayal-sandmaster-route';
function unchanged(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('native Guild Betrayal Sandmaster debits its printed pile once and retains ordinary movement', () => {
const f = createBetrayalSandmasterRoute();
const before = sandmasterTrainer(f.game);
const assignment = f.game.leaderSkills!.assignments.find(skill => skill.owner === before.id)!;
assert.equal(assignment.skill, 'sandmaster');
assert.ok(before.leaders.some(leader => leader.id === assignment.leader && !leader.dead));
assert.ok(f.betrayalPasses > 0);
const quote = quoteSandmasterMovement(f.game, before.id, f.order, f.choice);
assert.equal(quote.leader, assignment.leader);
assert.deepEqual(quote.piles, [{ key: f.printedPile, before: f.printedAmount }]);
assert.deepEqual(sandmasterEnteredTerritories(quote.routes), [splitLocation(f.printedPile).territory]);
const ordinary = applyAction(f.game, before.id, f.ordinary);
const accepted = applyAction(JSON.parse(JSON.stringify(f.game)) as Game, before.id, f.action);
assert.equal(sandmasterTrainer(accepted).spice, before.spice + 1);
assert.deepEqual(accepted.spice, { ...f.game.spice, [f.printedPile]: f.printedAmount - 1 });
assert.deepEqual(sandmasterTrainer(accepted).forces, sandmasterTrainer(ordinary).forces);
assert.equal(sandmasterTrainer(accepted).forces[f.printedPile], 1);
assert.equal(sandmasterTrainer(accepted).reserves, before.reserves);
assert.equal(sandmasterTrainer(accepted).moved, before.moved + 1);
assert.equal(sandmasterTrainer(accepted).moved, sandmasterTrainer(ordinary).moved);
assert.equal(fighterCount(sandmasterTrainer(accepted), splitLocation(f.printedPile).territory), 1);
for (const player of f.game.players.filter(player => player.id !== before.id))
assert.equal(accepted.players.find(seat => seat.id === player.id)!.spice, player.spice);
unchanged(accepted, before.id, f.action);
const declined = applyAction(f.game, before.id, { ...f.ordinary, sandmaster: { ...f.choice, collect: [] } });
assert.deepEqual(declined.spice, ordinary.spice);
assert.equal(sandmasterTrainer(declined).spice, sandmasterTrainer(ordinary).spice);
assert.deepEqual(sandmasterTrainer(declined).forces, sandmasterTrainer(ordinary).forces);
assert.equal(sandmasterTrainer(declined).moved, sandmasterTrainer(ordinary).moved);
const continued = applyAction(accepted, before.id, { type: 'endMovement' });
const originalContinuation = applyAction(ordinary, before.id, { type: 'endMovement' });
assert.equal(continued.phase, originalContinuation.phase);
assert.equal(continued.active, originalContinuation.active);
// endMovement may advance straight through empty Battle to native Collection.
// Compare with the same original continuation, not with its pre-collection pile.
assert.equal(sandmasterTrainer(continued).spice, sandmasterTrainer(originalContinuation).spice + 1);
assert.equal(continued.spice[f.printedPile], originalContinuation.spice[f.printedPile] - 1);
});
void test('native Sandmaster rejects wrong actor, duplicate, invalid and abandoned requests immutably', () => {
const f = createBetrayalSandmasterRoute();
unchanged(f.game, 'holder', f.action);
unchanged(f.game, 'trainer', { ...f.action, sandmaster: { ...f.choice, collect: [f.printedPile, f.printedPile] } });
unchanged(f.game, 'trainer', { ...f.action, sandmaster: { ...f.choice, routes: {} } });
unchanged(f.game, 'trainer', { ...f.action, sandmaster: { ...f.choice, collect: ['polar_sink:0'] } });
const abandoned = applyAction(f.game, 'trainer', { type: 'endMovement' });
assert.equal(abandoned.spice[f.printedPile], f.printedAmount);
assert.equal(sandmasterTrainer(abandoned).spice, sandmasterTrainer(f.game).spice);
assert.deepEqual(sandmasterTrainer(abandoned).forces, sandmasterTrainer(f.game).forces);
unchanged(abandoned, 'trainer', f.action);
});
void test('a native empty destination offers no Sandmaster pile and keeps the free ground move unchanged', () => {
const f = createBetrayalSandmasterRoute();
const empty = TERRITORIES.flatMap(territory => territory.sectors.map(sector => location(territory.id, sector)))
.find(key => splitLocation(key).territory !== 'polar_sink' &&
!Object.entries(f.game.spice).some(([pile, amount]) => amount > 0 &&
splitLocation(pile).territory === splitLocation(key).territory) &&
sandmasterShortestRoute(f.game, 'trainer', 'polar_sink:0', key, 1));
assert.ok(empty);
const destination = splitLocation(empty);
const action: Action = { ...f.ordinary, territory: destination.territory, sector: destination.sector };
const choice = sandmasterDefaultChoice(viewGame(f.game, 'trainer'), 'trainer', action);
assert.ok(choice);
assert.deepEqual(choice.collect, []);
const order = sandmasterActionOrder(viewGame(f.game, 'trainer'), 'trainer', action);
assert.ok(order);
assert.deepEqual(quoteSandmasterMovement(f.game, 'trainer', order, choice).piles, []);
const next = applyAction(f.game, 'trainer', { ...action, sandmaster: choice });
assert.deepEqual(next.spice, f.game.spice);
assert.equal(sandmasterTrainer(next).spice, sandmasterTrainer(f.game).spice);
assert.equal(sandmasterTrainer(next).forces[empty], 1);
assert.equal(sandmasterTrainer(next).moved, sandmasterTrainer(f.game).moved + 1);
unchanged(f.game, 'trainer', { ...action, sandmaster: { ...choice, collect: [empty] } });
});
void test('all four minimal bot policies propose an actual native collecting Sandmaster move', () => {
const f = createBetrayalSandmasterRoute();
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const view = viewGame(f.game, 'trainer');
view.players.find(player => player.id === 'trainer')!.autopilot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'move' && candidate.sandmaster);
assert.ok(action, difficulty);
const choice = action.sandmaster as SandmasterChoice;
assert.ok(choice.collect.length > 0, difficulty);
const order = sandmasterActionOrder(view, 'trainer', action);
assert.ok(order, difficulty);
const quote = quoteSandmasterMovement(f.game, 'trainer', order, choice);
const next = applyAction(f.game, 'trainer', action);
assert.equal(sandmasterTrainer(next).spice, sandmasterTrainer(f.game).spice + quote.collect.length, difficulty);
for (const pile of quote.piles) assert.equal(next.spice[pile.key], pile.before - 1, difficulty);
assert.equal(sandmasterTrainer(next).moved, sandmasterTrainer(f.game).moved + 1, difficulty);
}
});
+170
View File
@@ -0,0 +1,170 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { splitLocation } from '../game/board';
import { drawNexusCard, validateNexusCards } from '../game/nexus-cards';
import { createGuildBetrayalSkillsSetup } from './fixture-bureaucrat-betrayal-funding';
export const betrayalMentatSeat = (game: Game, id: string) => game.players.find(p => p.id === id)!;
export const reloadBetrayalMentat = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
/** Native obligations only; pass ordinary bidding so original starting wallets
* remain available for the paid trainer deployment. No optional card is played. */
export function stepBetrayalMentat(game: Game): Game {
if (game.pendingGuildBetrayal) {
const frame = game.pendingGuildBetrayal;
const actor = frame.required.find(id => !frame.passed.includes(id))!;
return applyAction(game, actor, { type: 'guildBetrayalPass', event: frame.invoice.event });
}
if (game.decision?.kind === 'guildShipment')
return applyAction(game, game.decision.player, { type: 'decision', allow: true });
if (game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')
return applyAction(game, game.decision.player, { type: 'decision', accept: false });
if (game.response) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id))!.id;
return applyAction(game, actor, { type: 'passResponse' });
}
if (game.status === 'playing' && game.phase === 0 && !game.phaseOpening &&
!game.decision && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return applyAction(game, actor, { type: 'stormDial', amount: 0 });
}
if (game.auction && !game.decision && !game.phaseOpening)
return applyAction(game, game.auction.active!, { type: 'passBid' });
if (game.status === 'setup' && game.setupStage === 'traitors') {
const player = game.players.find(p => p.traitorChoices.length)!;
return applyAction(game, player.id, { type: 'traitor', leader: player.traitorChoices[0] });
}
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (action) return applyAction(game, player.id, action);
}
throw Error(`Native Mentat continuation stalled at ${game.status}/${game.phase}/${game.decision?.kind}`);
}
export function betrayalMentatResources(game: Game) {
return structuredClone({ players: game.players, deck: game.deck, discard: game.discard,
nexus: game.nexusCards?.cards, betrayal: game.guildBetrayal, pending: game.pendingGuildBetrayal });
}
export interface BetrayalMentatFixture {
initial: Game;
beforeShipment: Game;
shipment: Action;
paid: Game;
battle: Game;
owner: string;
target: string;
observer: string;
leader: string;
cardCustody: { card: string; from: string; to: string }[];
shipmentGateEvents: string[];
}
/** Actual all14 assignment, original traitors/cards/storm and native readiness.
* Only original unplayed Treachery custody is controlled, with each exchange
* recorded. Armies, wallets, order, phases and battle plans are never assigned. */
export function createBetrayalMentatQuestionFixture(): BetrayalMentatFixture {
const setup = createGuildBetrayalSkillsSetup(false, false, 'mentat');
let game = setup.trained;
for (let n = 0; n < 300 && !(game.status === 'playing' && game.phase === 5 &&
!game.phaseOpening && !game.response && !game.decision); n++) game = stepBetrayalMentat(game);
assert.equal(game.phase, 5);
assert.equal(game.turn, 1);
const owner = setup.trainer, target = 'holder', observer = 'guild';
const holder = betrayalMentatSeat(game, target);
const cardCustody: BetrayalMentatFixture['cardCustody'] = [];
// Arrange the original singleton Guild Nexus in its real non-Guild holder.
// Existing held faces are exchanged, not copied or newly invented.
let nexus = game.nexusCards!.cards!;
const former = nexus.hands[target];
const heldBy = Object.keys(nexus.hands).find(id => nexus.hands[id] === 'guild');
if (heldBy && heldBy !== target) {
nexus.hands[heldBy] = former;
nexus.hands[target] = 'guild';
cardCustody.push({ card: 'nexus:guild', from: heldBy, to: target });
} else if (!heldBy) {
if (former !== null) { nexus.deck.push(former); nexus.hands[target] = null; }
const index = nexus.deck.indexOf('guild');
assert.ok(index >= 0, 'Original unplayed Guild Nexus remains in its deck.');
nexus.deck.unshift(nexus.deck.splice(index, 1)[0]);
nexus = drawNexusCard(nexus, target, game.players, () => 0);
cardCustody.push({ card: 'nexus:guild', from: 'nexusDeck', to: target });
}
game.nexusCards!.cards = nexus;
validateNexusCards(nexus, game.players);
const shipper = betrayalMentatSeat(game, 'shipper');
assert.ok(shipper.hand.length, 'Native Emperor starting card remains unplayed.');
const extra = shipper.hand.shift()!;
holder.hand.push(extra);
cardCustody.push({ card: extra.id, from: 'shipper', to: target });
const inventory = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)]
.map(card => card.id).sort();
// The two target slots contain original physical Crysknife and Snooper.
// Exchange only unplayed cards, including any already dealt desired card.
for (const [slot, name] of ['Crysknife', 'Snooper'].entries()) {
if (holder.hand[slot].name === name) continue;
const pools = [{ id: 'deck', cards: game.deck },
...game.players.map(p => ({ id: p.id, cards: p.hand }))];
const pool = pools.find(p => p.cards.some(card => card.name === name))!;
assert.ok(pool, `Original unplayed ${name} must still exist.`);
const index = pool.cards.findIndex(card => card.name === name);
const original = holder.hand[slot], selected = pool.cards[index];
pool.cards[index] = original;
holder.hand[slot] = selected;
cardCustody.push({ card: original.id, from: target, to: pool.id },
{ card: selected.id, from: pool.id, to: target });
}
assert.deepEqual([...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)]
.map(card => card.id).sort(), inventory);
const source = Object.entries(holder.forces).find(([key, amount]) =>
amount > 0 && splitLocation(key).sector !== game.storm)!;
assert.ok(source, 'The original Atreides army has a storm-safe occupied location.');
const { territory, sector } = splitLocation(source[0]);
while (game.active !== owner) game = applyAction(game, game.active!, { type: 'endMovement' });
const beforeShipment = structuredClone(game);
const shipment: Action = { type: 'ship', territory, sector, amount: 2, allyPayment: 0 };
game = applyAction(game, owner, shipment);
const shipmentGateEvents: string[] = [];
for (let n = 0; n < 100 && (game.pendingGuildBetrayal || game.response || game.decision); n++) {
if (game.pendingGuildBetrayal) shipmentGateEvents.push(game.pendingGuildBetrayal.invoice.event);
game = stepBetrayalMentat(game);
}
const paid = structuredClone(game);
assert.equal(betrayalMentatSeat(paid, owner).forces[source[0]], 2);
assert.equal(betrayalMentatSeat(paid, owner).spice,
betrayalMentatSeat(beforeShipment, owner).spice - 2);
assert.ok(shipmentGateEvents.length, 'Ordinary Mentat must not suppress the original Betrayal gate.');
while (game.phase === 5 && !game.phaseOpening && !game.decision && !game.response)
game = applyAction(game, game.active!, { type: 'endMovement' });
for (let n = 0; n < 100 && (game.phaseOpening || game.response || game.decision); n++)
game = stepBetrayalMentat(game);
assert.equal(game.phase, 6);
assert.ok(game.active === owner || game.active === target);
game = applyAction(game, game.active!, { type: 'chooseBattle', territory,
target: game.active === owner ? target : owner });
const assignment = game.leaderSkills!.assignments.find(a => a.owner === owner)!;
assert.equal(assignment.skill, 'mentat');
assert.equal(betrayalMentatSeat(game, owner).leaders.find(l => l.id === assignment.leader)!.dead, false);
return { initial: setup.initial, beforeShipment, shipment, paid, battle: game,
owner, target, observer, leader: assignment.leader, cardCustody, shipmentGateEvents };
}
/** Finish original posture/power steps, without convenience plan assignments. */
export function prepareBetrayalMentatPlans(state: Game): Game {
let game = state;
for (let n = 0; n < 60; n++) {
if (game.decision?.kind === 'leaderSkillVisibility')
game = applyAction(game, game.decision.player, { type: 'leaderSkillVisibility',
event: game.decision.event, hide: game.decision.player === 'trainer' });
else if (game.decision?.kind === 'fullPlanOffer')
game = applyAction(game, game.decision.player, { type: 'decision', decline: true });
else if (game.battle?.preparation)
game = applyAction(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
else if (game.response) game = stepBetrayalMentat(game);
else return game;
}
throw Error('Original Mentat preparation did not complete.');
}
+124
View File
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { botEntryAllowed } from '../game/bot-mobility';
import { TERRITORIES, location, splitLocation } from '../game/board';
import { drawNexusCard } from '../game/nexus-cards';
import {
sandmasterActionOrder, sandmasterShortestRoute, type SandmasterChoice,
} from '../game/sandmaster-movement';
import { createGuildBetrayalSkillsSetup } from './fixture-bureaucrat-betrayal-funding';
export const sandmasterTrainer = (game: Game) => game.players.find(player => player.id === 'trainer')!;
/** Only unplayed physical card ordering/custody is controlled. All setup, piles,
* counters, wallets, movement budgets and Guild invoices use native actions. */
export function createBetrayalSandmasterRoute() {
const setup = createGuildBetrayalSkillsSetup(false, false, 'sandmaster');
let game = setup.trained;
const inventory = game.nexusCards!.cards!;
assert.ok(Object.values(inventory.hands).every(card => card === null));
const guildIndex = inventory.deck.indexOf('guild');
assert.ok(guildIndex >= 0);
inventory.deck.unshift(inventory.deck.splice(guildIndex, 1)[0]);
const controlledNexusOrder = [...inventory.deck];
// Preserve the original singleton inventory, arranging its unplayed custody
// before any shipment, exactly as the original Guild Betrayal fixture does.
game.nexusCards!.cards = drawNexusCard(inventory, 'holder', game.players, () => 0);
let orderedSpice = false;
let printedPile = '';
let printedAmount = 0;
let controlledSpiceOrder: typeof game.spiceDeck = [];
let betrayalPasses = 0;
const step = (state: Game): Game => {
if (state.pendingGuildBetrayal) {
const frame = state.pendingGuildBetrayal;
const actor = frame.required.find(id => !frame.passed.includes(id))!;
betrayalPasses++;
return applyAction(state, actor, { type: 'guildBetrayalPass', event: frame.invoice.event });
}
if (state.response) {
const actor = state.players.find(player => !state.response!.passed.includes(player.id))!.id;
return applyAction(state, actor, { type: 'passResponse' });
}
if (state.decision?.kind === 'guildShipment')
return applyAction(state, state.decision.player, { type: 'decision', allow: true });
if (state.decision?.kind === 'advisor')
return applyAction(state, state.decision.player, { type: 'decision', accept: false });
for (const player of state.players) {
const view = viewGame(state, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const proposed = botActions(view)[0];
if (!proposed) continue;
const action: Action = proposed.type === 'stormDial' ? { ...proposed, amount: 0 } :
proposed.type === 'bid' ? { type: 'passBid' } : proposed;
return applyAction(state, player.id, action);
}
throw new Error(`Native Sandmaster fixture stalled at ${state.status}/${state.phase}/${state.decision?.kind}`);
};
for (let steps = 0; steps < 700; steps++) {
if (game.status === 'playing' && game.phase === 1 && !orderedSpice) {
assert.equal(game.spiceDiscard.flat().length, 0);
const index = game.spiceDeck.findIndex(card => 'territory' in card &&
sandmasterShortestRoute(game, 'trainer', 'polar_sink:0', location(card.territory, card.sector), 1));
assert.ok(index >= 0, 'A printed first blow is adjacent to the original BG starting counter.');
const card = game.spiceDeck.splice(index, 1)[0];
assert.ok('territory' in card);
printedPile = location(card.territory, card.sector);
printedAmount = card.amount;
const secondIndex = game.spiceDeck.findIndex(other => 'territory' in other);
assert.ok(secondIndex >= 0);
const second = game.spiceDeck.splice(secondIndex, 1)[0];
game.spiceDeck.unshift(card, second);
controlledSpiceOrder = structuredClone(game.spiceDeck);
orderedSpice = true;
}
if (game.status === 'playing' && game.phase === 5 && !game.phaseOpening &&
!game.response && !game.decision && !game.pendingGuildBetrayal) break;
game = step(game);
}
assert.equal(game.phase, 5);
assert.ok(!game.phaseOpening);
assert.ok(orderedSpice);
assert.equal(game.spice[printedPile], printedAmount, 'The original printed blow created the pile.');
assert.equal(game.nexusCards!.cards!.hands.holder, 'guild', 'The conserved physical Guild face remains in recorded custody.');
let shipped = false;
for (let steps = 0; steps < 100 && game.active !== 'trainer'; steps++) {
if (game.pendingGuildBetrayal || game.response || game.decision) {
game = step(game);
continue;
}
const actor = game.active!;
if (!shipped && actor !== 'guild') {
const view = viewGame(game, actor);
const payer = view.players.find(player => player.id === actor)!;
const destination = TERRITORIES.filter(t => t.type === 'stronghold').flatMap(t =>
t.sectors.map(sector => ({ territory: t.id, sector }))).find(to =>
botEntryAllowed(view, payer, to.territory, to.sector, 'ship'));
assert.ok(destination);
game = applyAction(game, actor, { type: 'ship', ...destination, amount: 1 });
assert.ok(game.pendingGuildBetrayal, 'An unrelated ordinary shipment retains the original Betrayal gate.');
shipped = true;
} else game = applyAction(game, actor, { type: 'endMovement' });
}
while (game.pendingGuildBetrayal || game.response || game.decision) game = step(game);
assert.equal(game.active, 'trainer');
assert.ok(betrayalPasses > 0);
const trainer = sandmasterTrainer(game);
assert.equal(trainer.forces['polar_sink:0'], 1, 'The real original starting counter remains on the board.');
assert.equal(trainer.reserves, 19);
assert.equal(trainer.advisors?.polar_sink, undefined, 'Basic BG starting counter is a fighter.');
const at = splitLocation(printedPile);
const ordinary: Action = { type: 'move', from: 'polar_sink:0', amount: 1, territory: at.territory, sector: at.sector };
const order = sandmasterActionOrder(viewGame(game, 'trainer'), 'trainer', ordinary);
assert.ok(order);
const route = sandmasterShortestRoute(game, 'trainer', 'polar_sink:0', printedPile, order.range);
assert.ok(route);
const choice: SandmasterChoice = { routes: { 'polar_sink:0': route }, collect: [printedPile] };
const action: Action = { ...ordinary, sandmaster: choice };
return { game, ordinary, action, order, choice, printedPile, printedAmount,
betrayalPasses, controlledNexusOrder, controlledSpiceOrder };
}
+5 -4
View File
@@ -39,12 +39,13 @@ function skillLottery<T>(selected: LeaderSkillId[], operation: () => T): T {
}
}
function trainedSetup(advanced: boolean, allied: boolean): { initial: Game; trained: Game; trainer: string } {
export function createGuildBetrayalSkillsSetup(advanced = false, allied = false,
trainerSkill: LeaderSkillId = 'bureaucrat'): { initial: Game; trained: Game; trainer: string } {
const roster: [string, FactionId, LeaderSkillId][] = [
['holder', 'atreides', 'warmaster'], ['guild', 'guild', 'killer-medic'],
['shipper', 'emperor', 'master-of-assassins'],
...(allied ? [['donor', 'harkonnen', 'swordmaster-of-ginaz'] as [string, FactionId, LeaderSkillId]] : []),
['trainer', 'beneGesserit', 'bureaucrat'],
['trainer', 'beneGesserit', trainerSkill],
];
let game = createGame('BUREAUCRATBETRAYAL', newPlayer(roster[0][0], roster[0][0], roster[0][1]), advanced, []);
for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, id, faction));
@@ -66,7 +67,7 @@ function trainedSetup(advanced: boolean, allied: boolean): { initial: Game; trai
}
assert.equal(game.status, 'setup', 'The shared fixture owns the remaining native setup.');
validateLeaderSkills(game.leaderSkills!, game.players);
assert.equal(game.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')?.owner, 'trainer');
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === 'trainer')?.skill, trainerSkill);
return { initial, trained: game, trainer: 'trainer' };
}
@@ -114,7 +115,7 @@ export interface BureaucratBetrayalFundingFixture extends GuildBetrayalFixture {
export function createBureaucratBetrayalFundingFixture({
advanced = false, primary = 5, donorAmount = 0, own = false, alreadyUsed = false,
}: { advanced?: boolean; primary?: number; donorAmount?: number; own?: boolean; alreadyUsed?: boolean } = {}): BureaucratBetrayalFundingFixture {
const setup = trainedSetup(advanced, donorAmount > 0);
const setup = createGuildBetrayalSkillsSetup(advanced, donorAmount > 0);
const base = createGuildBetrayalFixture({ initial: setup.trained, advanced,
payer: own ? 'holder' : 'other', allyPayment: donorAmount > 0 ? 1 : 0 });
let game = structuredClone(base.before);
+5 -4
View File
@@ -18,6 +18,7 @@ import {
initializeRicheseBetrayalGameForAudit,
initializeNexusKullGameForAudit,
initializeGuildBetrayalGameForAudit,
initializeGuildBetrayalLeaderSkillsGameForAudit,
initializeIxianNexusReplacementGameForAudit,
initializeIxianNexusBetrayalGameForAudit,
initializeHarkonnenNexusBetrayalGameForAudit,
@@ -55,7 +56,7 @@ export type PrototypeOptions = {
nexusCards?: boolean;
/** Explicit classic Discovery/Skills composition, not a public activation. */
discoveries?: boolean;
/** Explicit original skills with Semuta or classic Homeworld occupation. */
/** Explicit original skills with Semuta, classic occupation or Guild Betrayal. */
leaderSkills?: boolean;
/** Original Richese/CHOAM and classic Homeworld occupation with Semuta only. */
homeworlds?: boolean;
@@ -104,8 +105,8 @@ export function startPrototypeRoom(
throw new Error('--nexus-cards requires the leader-skills or semuta profile.');
if (options.discoveries && profile !== 'leader-skills' && profile !== 'nexus')
throw new Error('--discoveries requires the leader-skills or nexus profile.');
if (options.leaderSkills && profile !== 'semuta' && profile !== 'homeworld-occupation')
throw new Error('--leader-skills requires the semuta or homeworld-occupation profile.');
if (options.leaderSkills && profile !== 'semuta' && profile !== 'homeworld-occupation' && profile !== 'guild-betrayal')
throw new Error('--leader-skills requires the semuta, homeworld-occupation or guild-betrayal profile.');
if (options.homeworlds && profile !== 'semuta')
throw new Error('--homeworlds requires the semuta profile.');
if (options.ecazTreachery) initial.ecazTreachery = true;
@@ -144,7 +145,7 @@ export function startPrototypeRoom(
nexusCards: initial.nexusCards ?? { cards: null, phase: null },
})
: profile === 'guild-betrayal'
? initializeGuildBetrayalGameForAudit({
? (options.leaderSkills ? initializeGuildBetrayalLeaderSkillsGameForAudit : initializeGuildBetrayalGameForAudit)({
...initial,
nexusCards: initial.nexusCards ?? { cards: null, phase: null },
})
+1
View File
@@ -34,6 +34,7 @@ async function main() {
'\nThe same Homeworld Semuta entry also offers printed Karama canceling native Advanced BG extra charity and genuine allied Emperor gift/extra revival. The consumed charity claim, original ally, paid deployment/casualties, wallets and unawarded revival remain committed. Wealthy Basic BG keeps its ordinary-charity guard; no new benefit or profile is granted.',
'\nPrinted native Homeworld capture/Auditor/force-income Karama cancellation costs also offer their card. Accepted combat, paid support, leader death and original casualty/cleanup remain committed; the canceled reward never samples, transfers, inspects, refunds or credits. Earlier allowed income stays intact before a separately canceled audit.',
'\n--profile homeworld-occupation --leader-skills composes original all14 Leader Skills with the classic2–6-seat occupied Homeworld income preview. Native setup, reserve custody, occupation history and owner allocation remain original. Bureaucrat diverts two from gross before the occupied percentage split while preserving the full original payer shares. Nexus Cards, expansions, Tech, Strongholds, Discovery, other overlays and conversion of played games remain excluded.',
'\n--profile guild-betrayal --leader-skills composes the bounded classic native-Guild Nexus preview with original all14 skills. Original payment/physical shipment and once-per-phase Bureaucrat receipt remain; ordinary Skills use their shared quotes. Homeworlds, expansions, Tech, Strongholds, Discovery, Semuta, other overlays and conversion of played games remain excluded.',
'\nEcaz-occupy (--profile ecaz-occupy) opts only a fresh ready 2–6-seat lobby with native Ecaz and classic/Ixian/Tleilaxu/CHOAM/Richese opponents (optional native Moritani). Advanced uses distinct selected ecaz/ix/choam decks; Basic uses the exact ecaz deck with classic/optional Moritani opponents. 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 (Advanced only). Basic combined battles require an even Ecaz force count; the preserved publisher odd-force casualty-rounding conflict rejects before any lead choice. 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 existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. Banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.',
'\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' +