Activate approved normal Mentat and document Emperor gift ruling

This commit is contained in:
Quintine committed 2026-10-11 16:46:17 +11:00
1 parent eb63967b5c
commit d1ba8e0c7e
40 files changed
+270 -138

No files matched your search

+4
View File
@@ -161,6 +161,10 @@ void test('CHOAM prepaid ally support consumes its original donor pool and exclu
const donorFunds = donor.spice;
const enemyFunds = enemy.spice;
game = applyAction(game, owner.id, { type: 'chooseBattle', territory: 'wind_pass', target: enemy.id });
if (game.decision?.kind === 'mentatQuestion')
game = applyAction(game, game.decision.player, {
type: 'decision', event: game.decision.event, decline: true,
});
while (game.decision?.kind === 'leaderSkillVisibility')
game = applyAction(game, game.decision.player,
{ type: 'leaderSkillVisibility', event: game.decision.event, hide: true });
-1
View File
@@ -153,7 +153,6 @@ void test('the explicit preview keeps expansion, module and separate development
game => { game.discoveryEnabled = true; },
game => { game.leaderSkills = createLeaderSkills(() => 0.25); },
game => { game.ecazTreachery = true; },
game => { game.mentatQuestionPreview = true; },
game => { game.moritaniAssassinatePreview = true; },
];
for (const change of changes) {
+25
View File
@@ -120,6 +120,31 @@ void test('Bureaucrat requires two other players and tracks the unique physical
!bureaucratPaymentModeSupported({ expansions: ['choam'], homeworlds: {} }),
);
});
void test('Emperor gifts keep private amounts and do not consume Bureaucrat before a later qualifying payment', () => {
let game = bureaucratPaymentGame({ advanced: true });
bureaucratPlayer(game, 'e').ally = 'g';
bureaucratPlayer(game, 'g').ally = 'e';
takeBureaucratCard(game, 'p', 'Karama');
const pending = applyAction(game, 'e', { type: 'emperorGift', amount: 7 });
assert.equal(pending.response?.kind, 'emperorGift');
for (const id of ['b', 'p'])
assert.equal(viewGame(pending, id).response!.amount, undefined);
for (const id of ['e', 'g'])
assert.equal(viewGame(pending, id).response!.amount, 7);
game = allowBureaucratIncome(bureaucratReload(pending));
assert.equal(bureaucratPlayer(game, 'e').spice, 23);
assert.equal(bureaucratPlayer(game, 'g').spice, 37);
assert.equal(bureaucratPlayer(game, 'b').spice, 30);
assert.equal(game.decision, null);
assert.equal(viewGame(game, 'b').bureaucrat.usedThisPhase, false);
const paid = applyAction(game, 'p', bribe);
assert.equal(paid.decision?.kind, 'bureaucratPayment');
const redirected = choose(paid);
assert.equal(bureaucratPlayer(redirected, 'p').spice, 25);
assert.equal(bureaucratPlayer(redirected, 'e').bribes, 3);
assert.equal(viewGame(redirected, 'b').bureaucrat.usedThisPhase, true);
});
for (const advanced of [false, true])
for (const choam of [false, true])
void test(`single-payer ${choam ? 'CHOAM' : 'base'} ${advanced ? 'Advanced' : 'Basic'} shipment diverts two after payment without replaying arrival`, () => {
+2
View File
@@ -61,6 +61,8 @@ export function stagedMarketGhola(
game = actChoamGhola(game, owner, { type: 'chooseBattle', territory: 'wind_pass', target: opponent });
for (let step = 0; step < 30; step++) {
if (game.response) game = passChoamGholaResponses(game);
else if (game.decision?.kind === 'mentatQuestion')
game = actChoamGhola(game, game.decision.player, { type: 'decision', event: game.decision.event, decline: true });
else if (game.decision?.kind === 'leaderSkillVisibility')
game = actChoamGhola(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: true });
else if (game.battle?.preparation)
+6
View File
@@ -410,6 +410,12 @@ void test('a real concealed Planetologist green Special is discarded once after
territory: 'arrakeen',
target: 'a',
});
if (game.decision?.kind === 'mentatQuestion')
game = applyAction(game, game.decision.player, {
type: 'decision',
event: game.decision.event,
decline: true,
});
while (game.decision?.kind === 'leaderSkillVisibility') {
const decision = game.decision;
game = applyAction(game, decision.player, {
+2 -3
View File
@@ -54,10 +54,9 @@ void test('Discovery cannot redeal started components or silently remove another
void test('Discovery Skills cannot silently drop unsupported native families or unrelated modules and previews', () => {
const unsupported = lobby(['ixians', 'ecaz', 'guild'], ['ix', 'ecaz']);
assert.throws(() => initializeLeaderSkillsGameForAudit(unsupported), RuleError);
const overlays = Array.from({ length: 3 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
const overlays = Array.from({ length: 2 }, () => lobby(['guild', 'emperor', 'harkonnen'], []));
overlays[0].advanced = false; overlays[0].strongholdCards = createStrongholdCards();
overlays[1].mentatQuestionPreview = true;
overlays[2].spiceBankerIncomePreview = true;
overlays[1].spiceBankerIncomePreview = true;
for (const game of overlays) assert.throws(() => initializeLeaderSkillsGameForAudit(game), RuleError);
const two = lobby(['guild', 'emperor'], []); two.techTokens = createTechTokens();
assert.throws(() => initializeLeaderSkillsGameForAudit(two), RuleError);
@@ -69,7 +69,6 @@ for (const advanced of [false, true]) for (const tech of [false, true]) {
assert.equal(f.game.richeseCache!.some(c => c.id === f.green), false);
assert.ok(f.game.richeseCache!.length > 0, 'Two-turn bounded recipes do not bypass the exhausted-cache ruling.');
assert.equal(f.game.spiceBankerIncomePreview, undefined);
assert.equal(f.game.mentatQuestionPreview, undefined);
if (tech) {
assert.deepEqual(f.setup.techTokens, createTechTokens());
assert.ok(TECH_TOKENS.every(token => f.afterFirstStorm.techTokens![token.id].owner !== null));
-1
View File
@@ -582,7 +582,6 @@ void test('selected genuine three-player Advanced base sample fails honestly, re
const snapshot = join(first, 'failed-base-3-advanced.json');
for (const field of [
'ecazTreachery',
'mentatQuestionPreview',
'moritaniAssassinatePreview',
'moritaniAssassinate',
'moritaniAssassinateResume',
+1 -1
View File
@@ -217,7 +217,7 @@ export function createDiscoveryNativeE2SkillsFixture(options: DiscoveryNativeE2S
joinGame(game, newPlayer(faction, faction, faction));
assert.deepEqual(game.expansions, ['choam']);
if (options.advanced !== undefined) assert.equal(game.advanced, options.advanced);
assert.ok(!game.homeworlds && !game.nexusCards && !game.spiceBankerIncomePreview && !game.mentatQuestionPreview);
assert.ok(!game.homeworlds && !game.nexusCards && !game.spiceBankerIncomePreview);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 &&
game.leaderSkills?.assignments.length === 0 && game.players.every(p =>
p.hand.length <= (p.faction === 'harkonnen' ? 2 : 1) && p.traitors.length === 0 && p.traitorChoices.length === 0),
+1 -1
View File
@@ -149,7 +149,7 @@ export function initializeDiscoveryNativeE3Skills(options: DiscoveryNativeE3Skil
assert.ok(game.players.every(p => ['moritani', 'guild', 'atreides', 'emperor', 'fremen', 'beneGesserit',
...(advanced ? [] : ['harkonnen'])].includes(p.faction)));
assert.ok(!strongholds || advanced);
assert.ok(!game.nexusCards && !game.homeworlds && !game.ecazTreachery && !game.spiceBankerIncomePreview && !game.mentatQuestionPreview);
assert.ok(!game.nexusCards && !game.homeworlds && !game.ecazTreachery && !game.spiceBankerIncomePreview);
if (game.status === 'lobby') {
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
@@ -117,7 +117,7 @@ export function initializeDiscoveryStandaloneNexusE3Skills(options: DiscoverySta
assert.ok(advanced || native !== 'ecaz' || game.players.length % 2 === 0, 'Basic odd-seat Ecaz remains source guarded.');
assert.ok(!strongholds || advanced);
assert.ok(!game.homeworlds && !game.ecazTreachery && !game.advancedPreview && !game.semutaPreview &&
!game.spiceBankerIncomePreview && !game.mentatQuestionPreview);
!game.spiceBankerIncomePreview);
if (game.status === 'lobby') {
if (!!game.techTokens !== tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: tech });
if (!!game.strongholdCards !== strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: strongholds });
+1
View File
@@ -46,6 +46,7 @@ export function nextMoritaniSkillsModulesStep(game: Game): StrongholdFactionsNat
return nextStrongholdFactionsNativeStep(game);
const d = game.decision;
if (d?.kind === 'leaderSkillVisibility') return { actor: d.player, action: { type: 'leaderSkillVisibility', event: d.event, hide: false } };
if (d?.kind === 'mentatQuestion') return { actor: d.player, action: { type: 'decision', event: d.event, decline: true } };
if (d?.kind === 'moritaniSetup') {
const action = moritaniSkillsModulesPolicy(game, d.player)[0]; assert.ok(action);
return { actor: d.player, action };
+1 -2
View File
@@ -10,14 +10,13 @@ export const NATIVE_MENTAT_FAMILIES = ['ixians','tleilaxu','choam','moritani'] a
export type NativeMentatFamily = typeof NATIVE_MENTAT_FAMILIES[number];
/** Real native setup/assignment and Storm; conserved rule-unit battle position. */
export function nativeMentatBattle(family: NativeMentatFamily, matching = true, preview = true, begin = true): Game {
export function nativeMentatBattle(family: NativeMentatFamily, matching = true, begin = true): Game {
const options = {requestedSkill:'mentat' as const};
let game = family === 'ixians' ? completedIxSkillsGame(options)
: family === 'tleilaxu' ? completedTleilaxuSkillsGame(options)
: family === 'choam' ? completedChoamSkillsGame(options)
: completedMoritaniSkillsGame(options);
game = finishToMovement(game);
if (preview) game.mentatQuestionPreview = true;
const owner = game.players.find(p => p.faction === family)!;
const target = game.players.find(p => p.faction === 'emperor')!;
for (const p of game.players) {
+2
View File
@@ -57,6 +57,8 @@ function open(game: Game, territory = 'wind_pass') {
game = act(game, 'i', { type: 'chooseBattle', territory, target: 'e' });
for (let step = 0; step < 30; step++) {
if (game.response) game = passResponses(game);
else if (game.decision?.kind === 'mentatQuestion')
game = act(game, game.decision.player, { type: 'decision', event: game.decision.event, decline: true });
else if (game.decision?.kind === 'leaderSkillVisibility')
game = act(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: true });
else if (game.battle?.preparation)
+7
View File
@@ -136,6 +136,7 @@ function clearBattlePreparation(state: Game): Game {
let g = state;
while (
g.decision?.kind === 'leaderSkillVisibility' ||
g.decision?.kind === 'mentatQuestion' ||
g.decision?.kind === 'fullPlanOffer' ||
g.battle?.preparation
)
@@ -145,6 +146,12 @@ function clearBattlePreparation(state: Game): Game {
event: g.decision.event,
hide: false,
});
else if (g.decision?.kind === 'mentatQuestion')
g = applyAction(g, g.decision.player, {
type: 'decision',
event: g.decision.event,
decline: true,
});
else if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, {
type: 'decision',
+18
View File
@@ -182,6 +182,12 @@ function battleFor(
territory: 'arrakeen',
target: 'e',
});
if (next.decision?.kind === 'mentatQuestion')
next = applyAction(next, next.decision.player, {
type: 'decision',
event: next.decision.event,
decline: true,
});
while (next.decision?.kind === 'leaderSkillVisibility') {
const decision = next.decision;
next = applyAction(next, decision.player, {
@@ -404,6 +410,12 @@ void test('a Voice that forbids the last alternate Cheap Hero automatically conc
territory: 'arrakeen',
target: 'b',
});
if (g.decision?.kind === 'mentatQuestion')
g = applyAction(g, g.decision.player, {
type: 'decision',
event: g.decision.event,
decline: true,
});
while (g.decision?.kind === 'leaderSkillVisibility')
g = applyAction(g, g.decision.player, {
type: 'leaderSkillVisibility',
@@ -513,6 +525,12 @@ void test('Ghola revival during an unfinished battle preserves its power stage t
territory: 'carthag',
target: 'e',
});
if (g.decision?.kind === 'mentatQuestion')
g = applyAction(g, g.decision.player, {
type: 'decision',
event: g.decision.event,
decline: true,
});
while (g.decision?.kind === 'leaderSkillVisibility')
g = applyAction(g, g.decision.player, {
type: 'leaderSkillVisibility',
+11
View File
@@ -381,6 +381,17 @@ void test('battle death and Ghola revival preserve one private replacement skill
territory: 'arrakeen',
target: bg.id,
});
if (game.decision?.kind === 'mentatQuestion') {
const owner = f.auths.findIndex(
(auth) => auth.playerId === game.decision!.player,
);
assert.ok(owner >= 0);
game = await act(f, owner, {
type: 'decision',
event: game.decision.event,
decline: true,
});
}
while (game.decision?.kind === 'leaderSkillVisibility') {
const owner = f.auths.findIndex(
(auth) => auth.playerId === game.decision!.player,
-4
View File
@@ -34,14 +34,12 @@ export function mentatQuestionGame({
cards = ['Crysknife', 'Snooper'],
begin = true,
captured = false,
preview = true,
}: {
advanced?: boolean;
choam?: boolean;
cards?: string[];
begin?: boolean;
captured?: boolean;
preview?: boolean;
} = {}): Game {
const native = choam ? 'richese' : 'emperor';
let game = createGame(
@@ -57,8 +55,6 @@ export function mentatQuestionGame({
joinGame(game, newPlayer('o', 'Observer', 'guild'));
for (const player of game.players)
game = applyAction(game, player.id, { type: 'ready' });
// Server-only review fixture opt-in; ordinary Leader Skills starts remain unchanged.
if (preview) game.mentatQuestionPreview = true;
const index = LEADER_SKILL_CARDS.findIndex((card) => card.id === 'mentat');
let cursor = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
+20 -29
View File
@@ -107,38 +107,16 @@ void test('Mentat names exact printed/default weapons and validates physical han
assert.throws(() => mentatHand(ixDeck().slice(0, 1), []));
});
void test('Mentat requires a private preview opt-in and never exposes or accepts that flag through a player view or action', () => {
void test('fresh supported Leader Skills battles offer a private Mentat question without a separate opt-in', () => {
for (const advanced of [false, true])
for (const choam of [false, true]) {
const ordinary = mentatQuestionGame({ advanced, choam, preview: false });
assert.equal(ordinary.mentatQuestionPreview, undefined);
assert.equal(ordinary.battle!.mentatQuestionVersion, undefined);
assert.equal(ordinary.battle!.mentatQuestion, undefined);
assert.equal(ordinary.decision?.kind, 'leaderSkillVisibility');
assert.equal(viewGame(ordinary, 'a').mentat.pending, null);
assert.equal(
applyAction(ordinary, 'a', {
type: 'advanceBots',
mentatQuestionPreview: true,
}).mentatQuestionPreview,
undefined,
);
const game = mentatReload(mentatQuestionGame({ advanced, choam }));
assert.equal(game.battle!.mentatQuestionVersion, 1);
assert.equal(normalizeAutomaticGame(game).decision?.kind, 'mentatQuestion');
assert.equal(viewGame(game, 'a').mentat.pending!.stage, 'name');
assert.deepEqual(viewGame(game, 'd').mentat.pending!.weapons, []);
assert.deepEqual(viewGame(game, 'o').mentat.history, []);
}
const preview = mentatReload(mentatQuestionGame());
assert.equal(preview.mentatQuestionPreview, true);
assert.equal(
normalizeAutomaticGame(preview).decision?.kind,
'mentatQuestion',
);
for (const id of ['a', 'd', 'o'])
assert.equal('mentatQuestionPreview' in viewGame(preview, id), false);
delete preview.mentatQuestionPreview;
for (const operation of [
() => viewGame(preview, 'a'),
() => normalizeAutomaticGame(preview),
() => applyAction(preview, 'a', { type: 'advanceBots' }),
])
assert.throws(operation);
});
for (const advanced of [false, true])
@@ -430,6 +408,19 @@ void test('legacy unmarked battles are not reopened and later new battles receiv
viewGame(normalizeAutomaticGame(mentatReload(legacy)), 'a').mentat.pending,
null,
);
const continued = finishPowers(normalizeAutomaticGame(mentatReload(legacy)));
assert.equal(continued.battle!.event, legacy.battle!.event);
assert.equal(continued.battle!.mentatQuestionVersion, undefined);
assert.equal(continued.battle!.mentatQuestion, undefined);
assert.equal(continued.decision, null);
assert.deepEqual(viewGame(continued, 'a').mentat.history, []);
const planned = applyAction(continued, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
});
assert.ok(planned.battle!.plans.a);
assert.equal(planned.battle!.mentatQuestion, undefined);
let game = finishPowers(ask(mentatQuestionGame()));
const original = game.battle!.mentatQuestionEvent;
game = applyAction(game, 'a', {
+3 -1
View File
@@ -85,7 +85,9 @@ function openBattle(state: Game, owner = 'm', opponent = 'e', hide = true): Game
if (g.decision?.kind === 'leaderSkillVisibility') {
const d = g.decision;
g = act(g, d.player, { type: 'leaderSkillVisibility', event: d.event, hide: d.player === owner ? hide : true });
} else if (g.decision?.kind === 'mentatQuestion' || g.decision?.kind === 'fullPlanOffer') {
} else if (g.decision?.kind === 'mentatQuestion') {
g = act(g, g.decision.player, { type: 'decision', event: g.decision.event, decline: true });
} else if (g.decision?.kind === 'fullPlanOffer') {
g = act(g, g.decision.player, { type: 'decision', decline: true });
} else if (g.battle?.preparation) {
g = act(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
+1 -4
View File
@@ -6,10 +6,7 @@ import {DIFFICULTIES} from '../game/bot-profiles';
import {NATIVE_MENTAT_FAMILIES,nativeMentatBattle} from './fixture-native-mentat';
for (const family of NATIVE_MENTAT_FAMILIES) {
void test(`${family} Mentat requires an explicit preview and resumes native posture after a matching or fallback answer`,()=>{
const ordinary = nativeMentatBattle(family,true,false);
assert.equal(ordinary.battle!.mentatQuestion,undefined);
assert.equal(ordinary.decision!.kind,'leaderSkillVisibility');
void test(`${family} Mentat asks normally and resumes native posture after a matching or fallback answer`,()=>{
for (const matching of [true,false]) {
let game = nativeMentatBattle(family,matching);
assert.equal(game.decision!.kind,'mentatQuestion');
@@ -32,6 +32,7 @@ function open(g: Game, owner = 't', opponent = 'e') {
g = act(g, owner, { type: 'chooseBattle', territory: 'wind_pass', target: opponent });
for (let i = 0; i < 30; i++) {
if (g.response) g = act(g, g.players.find(p => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
else if (g.decision?.kind === 'mentatQuestion') g = act(g, g.decision.player, { type: 'decision', event: g.decision.event, decline: true });
else if (g.decision?.kind === 'leaderSkillVisibility') g = act(g, g.decision.player, { type: 'leaderSkillVisibility', event: g.decision.event, hide: true });
else if (g.battle?.preparation) g = act(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
else break;
+2
View File
@@ -29,6 +29,8 @@ function open(g: Game, owner = 't') {
g = act(g, owner, { type: 'chooseBattle', territory: 'wind_pass', target: 'e' });
for (let i = 0; i < 30; i++) {
if (g.response) g = act(g, g.players.find(p => !g.response!.passed.includes(p.id))!.id, { type: 'passResponse' });
else if (g.decision?.kind === 'mentatQuestion')
g = act(g, g.decision.player, { type: 'decision', event: g.decision.event, decline: true });
else if (g.decision?.kind === 'leaderSkillVisibility')
g = act(g, g.decision.player, { type: 'leaderSkillVisibility', event: g.decision.event, hide: true });
else if (g.battle?.preparation) g = act(g, g.battle.preparation.owner, { type: 'declineBattlePower' });
+2
View File
@@ -63,6 +63,8 @@ function killedOriginalSkill(owner: 't' | 'a' = 't'): Game {
game = act(game, owner, { type: 'chooseBattle', territory: 'wind_pass', target: 'e' });
for (let step = 0; step < 20; step++) {
if (game.response) game = passWindow(game);
else if (game.decision?.kind === 'mentatQuestion')
game = act(game, game.decision.player, { type: 'decision', event: game.decision.event, decline: true });
else if (game.decision?.kind === 'leaderSkillVisibility') {
const decision = game.decision;
game = act(game, decision.player, {