Integrate private Mentat questions with native Basic skill profiles

This commit is contained in:
Quintine committed 2026-10-02 19:36:07 +10:00
1 parent 3fc070fcfa
commit 589e1b9df2
16 files changed
+237 -45

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import {applyAction, type Game} from '../game/engine';
import {completedIxSkillsGame} from './ix-skills-fixture';
import {completedTleilaxuSkillsGame} from './tleilaxu-skills-fixture';
import {completedChoamSkillsGame} from './choam-skills-fixture';
import {completedMoritaniSkillsGame} from './moritani-skills-fixture';
import {finishToMovement} from './fixture-advanced-source';
export const NATIVE_MENTAT_FAMILIES = ['ixians','tleilaxu','choam','moritani'] as const;
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 {
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) {
game.deck.push(...p.hand);
p.hand = [];
p.reserves += Object.values(p.forces).reduce((sum, n) => sum + n, 0);
p.forces = {};
if (p.elites) {
p.elites.reserves += Object.values(p.elites.forces).reduce((sum, n) => sum + n, 0);
p.elites.forces = {};
}
}
for (const p of [owner,target]) {
assert.ok(p.reserves - (p.elites?.reserves ?? 0) >= 2);
p.reserves -= 2;
p.forces['arrakeen:10'] = 2;
}
const weapon = game.expansions.includes('ix') ? 'Weirding Way' : 'Crysknife';
const name = matching ? weapon : 'Baliset';
const index = game.deck.findIndex(c => c.name === name);
assert.ok(index >= 0);
target.hand.push(...game.deck.splice(index,1));
Object.assign(game,{phase:6,storm:18,active:owner.id,order:[owner.id,...game.players.filter(p => p.id !== owner.id).map(p => p.id)],ready:[],response:null,decision:null,phaseOpening:null});
return begin ? applyAction(game,owner.id,{type:'chooseBattle',territory:'arrakeen',target:target.id}) : game;
}
+3 -4
View File
@@ -4,6 +4,7 @@ import {applyAction,viewGame,initializeMoritaniAssassinateGameForAudit,createGam
import {DIFFICULTIES} from '../game/bot-profiles';
import {moritaniAssassinateSignature} from '../game/moritani-assassinate';
import {assassinationGame,stageAssassinationBattle,prepareAssassinationBattle,resolveAssassinationBattle,assassinationChoice,assassinationToMentat,assassinationPhysical,assassinationRejects,assassinationActions} from './moritani-assassinate-fixture';
import {advanceToNextStorm,finishToMovement} from './fixture-advanced-source';
void test('a real lost battle reveals one different opposing traitor, kills its native disc once and replaces the physical card at Mentat',()=>{
const staged=stageAssassinationBattle(assassinationGame()),inventory=assassinationPhysical(staged);
@@ -96,17 +97,15 @@ void test('a normal Moritani traitor reveal forfeits assassination and cannot cr
});
void test('a sourced normal-reveal forfeiture persists across turns and denies a later genuine loss',()=>{
const first=assassinationToMentat(resolveAssassinationBattle(stageAssassinationBattle(assassinationGame(['m','g','e']),'guild-0'),{normalCall:true}));
const first=finishToMovement(advanceToNextStorm(assassinationToMentat(resolveAssassinationBattle(stageAssassinationBattle(assassinationGame(['m','g','e']),'guild-0'),{normalCall:true}))));
assert.equal(first.moritaniAssassinate!.normalTraitorCall,true);assert.equal(first.moritaniAssassinate!.opportunities.length,0);
assert.match(viewGame(first,'m').moritaniAssassinate!.blocked!,/rest of this game/);
first.turn++;
assert.equal(first.turn,2);
first.players=[first.players[0],first.players[2],first.players[1]];
const second=resolveAssassinationBattle(stageAssassinationBattle(first));
assert.equal(second.lastBattleContext!.result,'normal');assert.notEqual(second.lastBattleContext!.winner,'m');
assert.equal(second.moritaniAssassinate!.normalTraitorCall,true);
assert.equal(second.moritaniAssassinate!.opportunities.length,0);
assert.notEqual(second.decision?.kind,'moritaniAssassinate');
assert.match(viewGame(second,'m').moritaniAssassinate!.blocked!,/rest of this game/);
});
void test('saved assassination receipts and cleanup obligations fail closed after corruption',()=>{
+4 -17
View File
@@ -190,14 +190,8 @@ void test('already-dead targets remain revealable for no death or spice, while p
void test('the sourced normal-reveal forfeiture and already normally revealed cards stay explicit private guards', () => {
const context = fixture();
context.state.normalTraitorCall = true;
assert.match(
moritaniAssassinateChoices(context).blocked!,
/forfeited Assassinate Leaders for the rest of this game/,
);
assert.throws(
() => quoteMoritaniAssassinate(context, 'atreides-1'),
/forfeited Assassinate Leaders for the rest of this game/,
);
assert.deepEqual(moritaniAssassinateChoices(context).cards, []);
assert.throws(() => quoteMoritaniAssassinate(context, 'atreides-1'));
context.state.normalTraitorCall = false;
context.normallyRevealed = ['atreides-1'];
const choices = moritaniAssassinateChoices(context);
@@ -205,16 +199,9 @@ void test('the sourced normal-reveal forfeiture and already normally revealed ca
choices.cards.map((c) => c.card),
['atreides-2'],
);
assert.match(choices.unavailable[0].reason, /normally revealed/);
assert.throws(
() => quoteMoritaniAssassinate(context, 'atreides-1'),
/eligible/,
);
assert.throws(() => quoteMoritaniAssassinate(context, 'atreides-1'));
context.normallyRevealed = ['atreides-1', 'atreides-2'];
assert.match(
moritaniAssassinateChoices(context).blocked!,
/normally revealed/,
);
assert.deepEqual(moritaniAssassinateChoices(context).cards, []);
});
void test('missing, duplicated, altered or exceptional target custody cannot select another physical leader or invent bounty', () => {
+51
View File
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {applyAction,viewGame} from '../game/engine';
import {botActions} from '../game/bots';
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');
for (const matching of [true,false]) {
let game = nativeMentatBattle(family,matching);
assert.equal(game.decision!.kind,'mentatQuestion');
const receipt = game.battle!.mentatQuestion!;
const weapon = game.expansions.includes('ix') ? 'Weirding Way' : 'Crysknife';
const shown = game.players.find(p => p.id === receipt.target)!.hand[0];
const owner = viewGame(game,receipt.owner);
assert.ok(owner.mentat.pending!.weapons.includes(weapon));
assert.ok(!owner.mentat.pending!.weapons.includes('Chemistry'));
game = applyAction(game,receipt.owner,{type:'decision',event:receipt.event,weapon});
const choice = viewGame(game,receipt.target).mentat.pending!;
assert.deepEqual(choice.cards,[shown]);
game = applyAction(game,receipt.target,{type:'decision',event:receipt.event,card:shown.id});
assert.equal(game.decision!.kind,'leaderSkillVisibility');
assert.equal(viewGame(game,receipt.owner).mentat.history[0].card.id,shown.id);
assert.ok(game.players.find(p => p.id === receipt.target)!.hand.some(c => c.id === shown.id));
assert.equal(game.battle!.preparation,undefined);
game = applyAction(game,receipt.owner,{type:'leaderSkillVisibility',event:game.battle!.event,hide:true});
assert.equal(game.battle!.leaderSkillHidden![receipt.owner],true);
}
});
}
void test('all four policies complete native Ixian Mentat naming and compulsory disclosure before posture',()=>{
for (const difficulty of DIFFICULTIES) {
let game = nativeMentatBattle('ixians');
const receipt = game.battle!.mentatQuestion!;
for (const id of [receipt.owner,receipt.target]) {
const view = viewGame(game,id);
view.players.find(p => p.id === id)!.bot = difficulty;
const action = botActions(view)[0];
assert.equal(action.event,receipt.event);
game = applyAction(game,id,action);
}
assert.equal(game.battle!.mentatQuestion!.stage,'answered');
assert.equal(game.decision!.kind,'leaderSkillVisibility');
assert.equal(viewGame(game,receipt.owner).mentat.history[0].card.id,game.players.find(p => p.id === receipt.target)!.hand[0].id);
}
});