Prototype private Mentat questions behind explicit preview opt-in
Connect the optional named-weapon question before skill posture and faction powers. Every accepted question waits for the same target-owned private card-show action, preserving held/absent privacy; matching cards are compulsory, otherwise the owner selects a fallback. Keep cards in hand and retain historical observations only for the questioner and target, without binding later plans. Support actual controls, inspectors, all four AI profiles and saved JSON/SQLite continuation. Normal activation remains disabled pending the user's uniform-response UX exception. The private flag is neither projected nor writable through actions. Legacy battles remain unchanged. Empty hands skip with an explicit unavailable receipt and no invented answer. Source and unresolved boundaries are documented, with a compact current-status dashboard preserving all goal, Git, subagent, usage and saved-game requirements. Independent review passed privacy, custody, sequencing and preview gating. Twenty-two focused tests cover canonical card roles, exact-match/fallback symmetry, target-only controls, captured lower-only behavior, stale/duplicate requests and concurrent SQLite writes. Typecheck, lint, 4605 offline tests, production build and 43 HTTP tests passed with stable source. The first broad run retained one outdated Mentat card-copy assertion; update it to verify private disclosure, custody, optional question and subsequent concealment. Gameplay source was unchanged by that test-only correction. Two genuine-setup sample games deliberately deal Mentat via a controlled initial skill shuffle. Basic: 100 actions, 3 JSON restores, turn-one Harkonnen win. Advanced: 507 actions, 14 restores, turn-five Harkonnen win. Each has one private observation, subsequent Atreides Prescience and no rejected candidate. These bounded force/skill custody samples do not establish general card-custody coverage, calibration or full module acceptance. After the reported outage all 665 opening rooms and original seat/recovery/receipt records were preserved (699 after integration). A fresh 666-room backup preceded checks; database integrity and foreign keys pass. The existing localhost server was reused; its live lock prevented a duplicate start. Existing Ecaz and Ixian browser seats restored their same hands and pending choices. A separate conserved staged Mentat preview restored its private history and posture, then legally concealed its leader before subsequent faction powers. Human/browser paths are distinguished from genuine-setup simulations. No database reset, recurring automation, public activation or publication. Verified source tree: 297ae06e0764d7cad525a802fe383a84971c3e9c5d6362c8cd0b79c5c3977caf Final source tree: 177c98415098ea42174a860d8b1fac4f7c7dd6c3e67c93b18330d1d0fc4edf0e The only post-verification edit removed one surplus newline at the end of the new developer document; its exact before/after content and fingerprints are retained privately. Staged diff checking caught this whitespace issue before commit. No application or test code changed.
This commit is contained in:
1 parent
903d861170
commit
9f1f783be4
21 files changed
+1717
-203
No files matched your search
+19
-1
@@ -1438,7 +1438,7 @@ function policyActions(g: GameView): Action[] {
|
||||
if (g.decision) {
|
||||
if (g.decision.player !== me.id) return [];
|
||||
const d = g.decision;
|
||||
if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival') return [];
|
||||
if (d.kind === 'leaderSkillVisibility' || d.kind === 'leaderSkillRevival' || d.kind === 'mentatQuestion') return [];
|
||||
if (d.kind === 'choamAudit')
|
||||
return [{ type: 'decision', event: d.event, audit: true }];
|
||||
if (d.kind === 'choamAuditPayment') {
|
||||
@@ -3659,6 +3659,24 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
|
||||
|
||||
/** Obligations apply across policy branches, including choosing to move first. */
|
||||
export function botActions(g: GameView): Action[] {
|
||||
const mentat = g.mentat?.pending;
|
||||
if (mentat) {
|
||||
if (mentat.player !== g.me || g.decision?.kind !== 'mentatQuestion' ||
|
||||
g.decision.player !== g.me || g.decision.event !== mentat.event ||
|
||||
g.truthtrance || g.response || g.automaticContinuationPending) return [];
|
||||
if (mentat.stage === 'name') {
|
||||
if (mentat.blocked || !mentat.weapons.length)
|
||||
return [{ type: 'decision', event: mentat.event, decline: true }];
|
||||
const preferred = rank(g) === 0 ? mentat.weapons :
|
||||
['Poison Tooth', 'Lasgun', 'Crysknife', 'Chaumas', ...mentat.weapons];
|
||||
const weapon = preferred.find((name) => mentat.weapons.includes(name))!;
|
||||
return [{ type: 'decision', event: mentat.event, weapon }];
|
||||
}
|
||||
const card = [...mentat.cards].sort((a, b) =>
|
||||
technologyCardValue(g, a) - technologyCardValue(g, b) || a.id.localeCompare(b.id),
|
||||
)[0];
|
||||
return card ? [{ type: 'decision', event: mentat.event, card: card.id }] : [];
|
||||
}
|
||||
const skill = g.leaderSkills;
|
||||
if (skill && !g.truthtrance && !g.response && !g.phaseOpening) {
|
||||
if (skill.battleChoice)
|
||||
|
||||
+167
@@ -1,5 +1,6 @@
|
||||
import { createLeaderSkills, validateLeaderSkills, dealLeaderSkills, chooseLeaderSkill, returnDeadLeaderSkills, offerRevivedLeaderSkill, drawRevivedLeaderSkills, declineRevivedLeaderSkill, LeaderSkillError, type LeaderSkillsState, type LeaderSkillsView } from './leader-skills';
|
||||
import { leaderSkillCard, type LeaderSkillId } from './leader-skill-cards';
|
||||
import { MENTAT_EMPTY_HAND, MentatQuestionError, mentatQuestionModeSupported, mentatWeaponNames, mentatHand, quoteMentatReveal, mentatSignature, type MentatQuestionReceipt, type MentatObservation, type MentatView } from './mentat-question';
|
||||
import {
|
||||
canUsePlanetologistBattleSpecial,
|
||||
isPlanetologistBattleSpecialCard,
|
||||
@@ -593,6 +594,10 @@ export type DiplomatDefenseReceipt = DiplomatDefenseQuote & {
|
||||
signature: string;
|
||||
};
|
||||
export type Battle = {
|
||||
/** Only newly created supported battles enter the pre-plan Mentat question. */
|
||||
mentatQuestionVersion?: 1;
|
||||
mentatQuestionEvent?: string;
|
||||
mentatQuestion?: MentatQuestionReceipt;
|
||||
/** New battles opt into the revealed Diplomat step; legacy battles are not reopened. */
|
||||
diplomatDefenseVersion?: 1;
|
||||
diplomatDefenseEvent?: string;
|
||||
@@ -741,6 +746,7 @@ export type Decision =
|
||||
| { kind: 'sukRescue'; player: string; event: string; territory: string; mode: 'normal' | 'skilled'; options: SukRescueOption[] }
|
||||
| { kind: 'rihani'; player: string; event: string; stage: 'offer' | 'return' }
|
||||
| { kind: 'fullPlanOffer'; player: string }
|
||||
| { kind: 'mentatQuestion'; player: string; event: string; owner: string; target: string; stage: 'name' | 'reveal'; weapon: string | null }
|
||||
| { kind: 'fullPlanRead'; player: string; target: string }
|
||||
| { kind: 'homeworldShipmentGuild'; player: string; shipper: string; destination: string; amount: number; event: string }
|
||||
| {
|
||||
@@ -1461,6 +1467,10 @@ export type Game = {
|
||||
/** Absent on legacy rooms whose starting cards were already dealt. */
|
||||
setupStage?: 'prediction' | 'skillTreachery' | 'leaderSkills' | 'traitors' | 'forces';
|
||||
leaderSkills?: LeaderSkillsState;
|
||||
/** Private development opt-in pending the uniform Mentat response UX decision. */
|
||||
mentatQuestionPreview?: true;
|
||||
mentatHistory?: MentatObservation[];
|
||||
mentatHistoryEvents?: string[];
|
||||
advanced: boolean;
|
||||
expansions: string[];
|
||||
players: Player[];
|
||||
@@ -4967,6 +4977,7 @@ function leaderSkillAssignmentUnavailable(
|
||||
: null;
|
||||
}
|
||||
function leaderSkillsIntegrity(g: Game) {
|
||||
mentatQuestionIntegrity(g);
|
||||
diplomatDefenseIntegrity(g);
|
||||
const noFieldShipment = leaderSkillNoFieldIntegrity(g);
|
||||
sandmasterIntegrity(g);
|
||||
@@ -6906,8 +6917,155 @@ function strongholdCopyChoices(g: Game, player: string): StrongholdId[] {
|
||||
(card) => card.id !== MOBILE_STRONGHOLD && controllers[card.id] === player,
|
||||
).map((card) => card.id);
|
||||
}
|
||||
function mentatRule<T>(quote: () => T): T {
|
||||
try { return quote(); } catch (error) {
|
||||
if (error instanceof MentatQuestionError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function createMentatQuestion(g: Game) {
|
||||
const b = g.battle!;
|
||||
if (b.mentatQuestionVersion !== 1) return;
|
||||
const owner = [b.attacker,b.defender].find(id => nativeLeaderSkill(g,id)?.skill === 'mentat');
|
||||
if (!owner) return;
|
||||
const trained = nativeLeaderSkill(g,owner)!, target = b.attacker === owner ? b.defender : b.attacker;
|
||||
const event = crypto.randomUUID();
|
||||
const receipt: MentatQuestionReceipt = {event,battle:b.event!,turn:g.turn,territory:b.territory,
|
||||
owner,target,leader:trained.leader,stage:'name',weapon:null,card:null,
|
||||
hand:mentatRule(() => mentatHand(getPlayer(g,target).hand,g.expansions)),signature:''};
|
||||
if (!receipt.hand.length) {receipt.stage = 'unavailable';log(g,MENTAT_EMPTY_HAND,{faction:getPlayer(g,owner).faction,name:'Mentat'});}
|
||||
receipt.signature = mentatSignature(receipt);
|
||||
b.mentatQuestion = receipt;
|
||||
b.mentatQuestionEvent = event;
|
||||
}
|
||||
function mentatQuestionIntegrity(g: Game) {
|
||||
requireRule(g.mentatQuestionPreview === undefined || g.mentatQuestionPreview === true,
|
||||
'The private Mentat preview flag is invalid.');
|
||||
const history = g.mentatHistory, events = g.mentatHistoryEvents;
|
||||
requireRule(history === undefined && events === undefined || Array.isArray(history) && Array.isArray(events) &&
|
||||
history.length === events.length && new Set(events).size === events.length && new Set(history.map(observation => observation?.event)).size === history.length,
|
||||
'The saved Mentat observations lost their independent event history.');
|
||||
for (const observation of history ?? []) {
|
||||
requireRule(observation && typeof observation === 'object' && typeof observation.event === 'string' && observation.event.length > 0 &&
|
||||
events!.filter(event => event === observation.event).length === 1 &&
|
||||
typeof observation.battle === 'string' && observation.battle.length > 0 &&
|
||||
Number.isSafeInteger(observation.turn) && observation.turn >= 1 && observation.turn <= g.turn &&
|
||||
typeof observation.territory === 'string' && observation.territory.length > 0 &&
|
||||
observation.owner !== observation.target && g.players.some(p => p.id === observation.target) &&
|
||||
g.players.some(p => p.id === observation.owner && p.leaders.some(l => l.id === observation.leader)) &&
|
||||
mentatWeaponNames(g.expansions).includes(observation.weapon) && observation.signature === mentatSignature(observation),
|
||||
'The saved Mentat observation changed its question, audience or event.');
|
||||
mentatRule(() => mentatHand([observation.card],g.expansions));
|
||||
}
|
||||
const b = g.battle, receipt = b?.mentatQuestion;
|
||||
const decisions = homeworldSavedDecisions(g).filter(d => d.kind === 'mentatQuestion');
|
||||
if (!receipt) {
|
||||
requireRule(receipt === undefined && !b?.mentatQuestionEvent && !decisions.length,
|
||||
'The saved Mentat question lost its original receipt.');
|
||||
requireRule(b?.mentatQuestionVersion === undefined || b.mentatQuestionVersion === 1 && g.mentatQuestionPreview === true && !!g.leaderSkills,
|
||||
'The saved Mentat question has an invalid battle version.');
|
||||
return;
|
||||
}
|
||||
requireRule(b && b.mentatQuestionVersion === 1 && g.mentatQuestionPreview === true && g.leaderSkills && mentatQuestionModeSupported(g) &&
|
||||
g.status === 'playing' && g.phase === 6 && receipt.battle === b.event && receipt.turn === g.turn && receipt.territory === b.territory &&
|
||||
typeof receipt.event === 'string' && receipt.event.length > 0 && receipt.event === b.mentatQuestionEvent &&
|
||||
[b.attacker,b.defender].includes(receipt.owner) && [b.attacker,b.defender].includes(receipt.target) && receipt.owner !== receipt.target &&
|
||||
receipt.signature === mentatSignature(receipt),
|
||||
'The saved Mentat question changed its battle, audience or original event.');
|
||||
mentatRule(() => mentatHand(receipt.hand,g.expansions));
|
||||
requireRule(['name','reveal','answered','declined','unavailable'].includes(receipt.stage), 'The saved Mentat question has an invalid stage.');
|
||||
requireRule(receipt.stage === 'unavailable' ? receipt.hand.length === 0 : receipt.hand.length > 0,
|
||||
'The saved Mentat opportunity changed its empty-hand boundary.');
|
||||
if (receipt.stage === 'name' || receipt.stage === 'declined' || receipt.stage === 'unavailable')
|
||||
requireRule(receipt.weapon === null && receipt.card === null, 'The unasked Mentat question cannot contain an answer.');
|
||||
else {
|
||||
requireRule(typeof receipt.weapon === 'string', 'The saved Mentat weapon question is missing.');
|
||||
const quote = mentatRule(() => quoteMentatReveal(receipt.hand,receipt.weapon!,g.expansions));
|
||||
requireRule(receipt.stage === 'reveal' ? receipt.card === null :
|
||||
receipt.card && quote.cards.some(card => JSON.stringify(card) === JSON.stringify(receipt.card)),
|
||||
'The saved Mentat disclosure does not answer its exact original question.');
|
||||
}
|
||||
const pending = receipt.stage === 'name' || receipt.stage === 'reveal';
|
||||
requireRule(decisions.length === (pending ? 1 : 0), 'The saved Mentat question lost or reopened its unique decision.');
|
||||
if (pending) {
|
||||
const assignment = nativeLeaderSkill(g,receipt.owner);
|
||||
requireRule(!b.revealed && !Object.keys(b.plans).length && !Object.keys(b.leaderSkillHidden ?? {}).length &&
|
||||
!b.preparation && !g.response && assignment?.skill === 'mentat' && assignment.leader === receipt.leader &&
|
||||
getPlayer(g,receipt.owner).leaders.some(l => l.id === receipt.leader && !l.dead && !l.capturedBy && !l.gholaBy) &&
|
||||
JSON.stringify(getPlayer(g,receipt.target).hand) === JSON.stringify(receipt.hand),
|
||||
'The pending Mentat question changed its trainer, timing or reserved private hand.');
|
||||
for (const card of receipt.hand) requireRule(physicalTreacheryCards(g).filter(c => c.id === card.id).length === 1,
|
||||
'The pending Mentat response must retain unique physical card custody.');
|
||||
const decision = decisions[0];
|
||||
requireRule(decision === g.decision && decision.event === receipt.event && decision.owner === receipt.owner && decision.target === receipt.target &&
|
||||
decision.player === (receipt.stage === 'name' ? receipt.owner : receipt.target) && decision.stage === receipt.stage && decision.weapon === receipt.weapon,
|
||||
'The Mentat decision no longer matches its atomic question and answering player.');
|
||||
}
|
||||
const observations = (history ?? []).filter(observation => observation.event === receipt.event);
|
||||
requireRule(observations.length === (receipt.stage === 'answered' ? 1 : 0),
|
||||
'The completed Mentat question lost or duplicated its private observation.');
|
||||
if (observations.length) {
|
||||
const {stage: _stage,hand: _hand,signature: _signature,...answer} = receipt;
|
||||
const {signature: _observationSignature,...observation} = observations[0];
|
||||
requireRule(JSON.stringify(answer) === JSON.stringify(observation), 'The Mentat historical answer changed after disclosure.');
|
||||
}
|
||||
}
|
||||
function projectedMentat(g: Game, viewer: string): MentatView {
|
||||
const receipt = g.battle?.mentatQuestion;
|
||||
const pending = receipt && (receipt.stage === 'name' || receipt.stage === 'reveal') ? receipt : null;
|
||||
return {pending: pending ? {event:pending.event,owner:pending.owner,target:pending.target,
|
||||
player:pending.stage === 'name' ? pending.owner : pending.target,stage:pending.stage as 'name'|'reveal',weapon:pending.weapon,
|
||||
weapons:pending.stage === 'name' && viewer === pending.owner && pending.hand.length ? mentatWeaponNames(g.expansions) : [],
|
||||
cards:pending.stage === 'reveal' && viewer === pending.target ?
|
||||
mentatRule(() => quoteMentatReveal(pending.hand,pending.weapon!,g.expansions)).cards : [],
|
||||
blocked:pending.stage === 'name' && !pending.hand.length ? MENTAT_EMPTY_HAND : null} : null,
|
||||
history:(g.mentatHistory ?? []).filter(observation => [observation.owner,observation.target].includes(viewer))
|
||||
.map(({signature: _signature,...observation}) => structuredClone(observation))};
|
||||
}
|
||||
function actMentatQuestion(g: Game, decision: Extract<Decision,{kind:'mentatQuestion'}>, action: Action) {
|
||||
const receipt = g.battle!.mentatQuestion!;
|
||||
requireRule(action.event === receipt.event, 'Choose the current Mentat question event.');
|
||||
if (decision.stage === 'name') {
|
||||
if (action.decline === true) {
|
||||
requireRule(Object.keys(action).every(key => ['type','event','decline'].includes(key)), 'Decline only this Mentat question.');
|
||||
receipt.stage = 'declined';
|
||||
log(g,`${getPlayer(g,receipt.owner).name} declined the optional Mentat question.`);
|
||||
} else {
|
||||
requireRule(Object.keys(action).every(key => ['type','event','weapon'].includes(key)), 'Name only one specific Mentat weapon.');
|
||||
const weapon = stringField(action.weapon);
|
||||
mentatRule(() => quoteMentatReveal(receipt.hand,weapon,g.expansions));
|
||||
receipt.weapon = weapon;
|
||||
log(g,`${getPlayer(g,receipt.owner).name} asked ${getPlayer(g,receipt.target).name} about ${weapon} with Mentat. The card shown is private and need not be played.`);
|
||||
// Every question uses the same target-owned response, so public timing
|
||||
// cannot reveal whether the named weapon was actually held.
|
||||
receipt.stage = 'reveal';
|
||||
}
|
||||
} else {
|
||||
requireRule(Object.keys(action).every(key => ['type','event','card'].includes(key)), 'Show exactly one held Treachery Card for Mentat.');
|
||||
const quote = mentatRule(() => quoteMentatReveal(receipt.hand,receipt.weapon!,g.expansions));
|
||||
const card = quote.cards.find(card => card.id === action.card);
|
||||
requireRule(card, 'Choose a held card that answers this Mentat question.');
|
||||
receipt.card = {...card}; receipt.stage = 'answered';
|
||||
}
|
||||
if (receipt.stage === 'answered') {
|
||||
const {stage: _stage,hand: _hand,signature: _signature,...answer} = receipt;
|
||||
const observation = {...answer,weapon:receipt.weapon!,card:receipt.card!,signature:''};
|
||||
observation.signature = mentatSignature(observation);
|
||||
(g.mentatHistory ??= []).push(observation);
|
||||
(g.mentatHistoryEvents ??= []).push(observation.event);
|
||||
log(g,`${getPlayer(g,receipt.target).name} privately showed one Treachery Card to ${getPlayer(g,receipt.owner).name}.`);
|
||||
}
|
||||
receipt.signature = mentatSignature(receipt);
|
||||
beginBattlePowers(g);
|
||||
}
|
||||
function beginBattlePowers(g: Game) {
|
||||
const b = g.battle!;
|
||||
if (b.mentatQuestion?.stage === 'name' || b.mentatQuestion?.stage === 'reveal') {
|
||||
const receipt = b.mentatQuestion;
|
||||
g.decision = {kind:'mentatQuestion',player:receipt.stage === 'name' ? receipt.owner : receipt.target,
|
||||
event:receipt.event,owner:receipt.owner,target:receipt.target,stage:receipt.stage as 'name'|'reveal',weapon:receipt.weapon};
|
||||
return;
|
||||
}
|
||||
if (g.leaderSkills) {
|
||||
b.leaderSkillHidden ??= {};
|
||||
const pending = [b.attacker, b.defender].find((id) =>
|
||||
@@ -19634,6 +19792,8 @@ function applyActionInner(
|
||||
requireRule(g.status !== 'finished', 'This game has ended.');
|
||||
const t = action.type;
|
||||
if (t === 'advanceBots') return g;
|
||||
requireRule(!(g.battle?.mentatQuestion?.stage === 'name' || g.battle?.mentatQuestion?.stage === 'reveal') || t === 'decision',
|
||||
'Resolve the current Mentat question before another action.');
|
||||
if (t === 'nexusCardChoice') { decideNexusCard(g, p, action); return g; }
|
||||
requireRule(g.nexusCards?.phase?.stage !== 'drawing', 'Finish the closing Nexus card choices first.');
|
||||
if (t === 'nexusTraitorDraw') { playNexusTraitorDraw(g, p, action); return g; }
|
||||
@@ -19949,6 +20109,10 @@ function applyActionInner(
|
||||
);
|
||||
g.decision = null;
|
||||
requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.');
|
||||
if (decision.kind === 'mentatQuestion') {
|
||||
actMentatQuestion(g, decision, action);
|
||||
return g;
|
||||
}
|
||||
if (decision.kind === 'ixRicheseTechnology') {
|
||||
const pending = g.pendingIxRicheseTechnology!;
|
||||
requireRule(action.event === pending.event && action.decline === true &&
|
||||
@@ -22628,6 +22792,7 @@ function applyActionInner(
|
||||
g.battle = {
|
||||
...choice,
|
||||
event: battleEvent,
|
||||
...(g.mentatQuestionPreview === true && g.leaderSkills && mentatQuestionModeSupported(g) ? { mentatQuestionVersion: 1 as const } : {}),
|
||||
...(g.leaderSkills && diplomatDefenseModeSupported(g) ? { diplomatDefenseVersion: 1 as const } : {}),
|
||||
...(byFaction(g, 'richese')
|
||||
? { preLeader: { event: battleEvent, ready: [], closed: false } }
|
||||
@@ -22638,6 +22803,7 @@ function applyActionInner(
|
||||
revealed: false,
|
||||
traitorCalls: {},
|
||||
};
|
||||
createMentatQuestion(g);
|
||||
beginStrongholdBattle(g);
|
||||
log(
|
||||
g,
|
||||
@@ -23096,6 +23262,7 @@ export function viewGame(state: Game, id: string) {
|
||||
setupStage: g.status === 'setup' ? (g.setupStage ?? null) : null,
|
||||
setupPending: setupPending(g),
|
||||
leaderSkills: projectedLeaderSkills(g, id),
|
||||
mentat: projectedMentat(g, id),
|
||||
rihani: projectedRihani(g, id),
|
||||
advanced: g.advanced,
|
||||
dukeVidal: g.dukeVidal
|
||||
|
||||
@@ -37,8 +37,8 @@ export const LEADER_SKILL_CARDS = [
|
||||
id: 'mentat',
|
||||
name: 'Mentat',
|
||||
normal: [
|
||||
'Before Battle Plans, name a specific weapon and ask whether your opponent holds it. They reveal that card if held, or a different Treachery Card otherwise.',
|
||||
'Revealing the named weapon does not require your opponent to use it.',
|
||||
'Before Battle Plans, you may name a specific weapon and ask whether your opponent holds it. They privately show you that card if held, or choose another Treachery Card to show you otherwise.',
|
||||
'The shown card stays in their hand and need not be used in battle. You may then move your Mentat behind the shield for possible use.',
|
||||
],
|
||||
battle: ['Add 2 to this leader’s strength.'],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { treacheryDeck, type Card } from './cards';
|
||||
import { richeseCards } from './richese-cards';
|
||||
import { quoteTupileIntelligenceAnswer } from './tupile-intelligence-answer';
|
||||
|
||||
export class MentatQuestionError extends Error {}
|
||||
export const MENTAT_EMPTY_HAND =
|
||||
'Mentat cannot ask while the opponent has no Treachery Card to show; the empty-hand ruling remains unresolved. This question was skipped without a disclosure.';
|
||||
export type MentatQuestionReceipt = {
|
||||
event: string;
|
||||
battle: string;
|
||||
turn: number;
|
||||
territory: string;
|
||||
owner: string;
|
||||
target: string;
|
||||
leader: string;
|
||||
stage: 'name' | 'reveal' | 'answered' | 'declined' | 'unavailable';
|
||||
weapon: string | null;
|
||||
card: Card | null;
|
||||
/** Private snapshot for this atomic question; never a later hand obligation. */
|
||||
hand: Card[];
|
||||
signature: string;
|
||||
};
|
||||
export type MentatObservation = Omit<
|
||||
MentatQuestionReceipt,
|
||||
'stage' | 'hand' | 'weapon' | 'card'
|
||||
> & { weapon: string; card: Card };
|
||||
export type MentatView = {
|
||||
pending: null | {
|
||||
event: string;
|
||||
owner: string;
|
||||
target: string;
|
||||
player: string;
|
||||
stage: 'name' | 'reveal';
|
||||
weapon: string | null;
|
||||
weapons: string[];
|
||||
cards: Card[];
|
||||
blocked: string | null;
|
||||
};
|
||||
history: Omit<MentatObservation, 'signature'>[];
|
||||
};
|
||||
export function mentatQuestionModeSupported(game: {
|
||||
expansions: readonly string[];
|
||||
homeworlds?: unknown;
|
||||
nexusCards?: unknown;
|
||||
discoveries?: unknown;
|
||||
discoveryEnabled?: unknown;
|
||||
strongholdCards?: unknown;
|
||||
techTokens?: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
(!game.expansions.length ||
|
||||
(game.expansions.length === 1 && game.expansions[0] === 'choam')) &&
|
||||
!game.homeworlds &&
|
||||
!game.nexusCards &&
|
||||
!game.discoveries &&
|
||||
!game.discoveryEnabled &&
|
||||
!game.strongholdCards &&
|
||||
!game.techTokens
|
||||
);
|
||||
}
|
||||
function inventory(expansions: readonly string[]): Card[] {
|
||||
return [
|
||||
...treacheryDeck(expansions),
|
||||
...(expansions.includes('choam') ? richeseCards() : []),
|
||||
];
|
||||
}
|
||||
/** Printed/default held-card roles, shared with Tupile; never battle-slot legality. */
|
||||
export function mentatWeaponNames(expansions: readonly string[]): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
inventory(expansions)
|
||||
.filter(
|
||||
(card) =>
|
||||
quoteTupileIntelligenceAnswer([card], 0, 'weapons').count === 1,
|
||||
)
|
||||
.map((card) => card.name),
|
||||
),
|
||||
];
|
||||
}
|
||||
export function mentatHand(
|
||||
hand: readonly Card[],
|
||||
expansions: readonly string[],
|
||||
): Card[] {
|
||||
const canonical = new Map(
|
||||
inventory(expansions).map((card) => [card.id, card]),
|
||||
);
|
||||
if (
|
||||
!Array.isArray(hand) ||
|
||||
hand.some(
|
||||
(card) =>
|
||||
!card ||
|
||||
!canonical.has(card.id) ||
|
||||
JSON.stringify(card) !== JSON.stringify(canonical.get(card.id)),
|
||||
) ||
|
||||
new Set(hand.map((card) => card.id)).size !== hand.length
|
||||
)
|
||||
throw new MentatQuestionError(
|
||||
'Mentat needs a valid uniquely held physical Treachery hand.',
|
||||
);
|
||||
return hand.map((card) => ({ ...card }));
|
||||
}
|
||||
export function quoteMentatReveal(
|
||||
hand: readonly Card[],
|
||||
weapon: string,
|
||||
expansions: readonly string[],
|
||||
): { cards: Card[] } {
|
||||
if (!mentatWeaponNames(expansions).includes(weapon))
|
||||
throw new MentatQuestionError('Name one specific canonical weapon.');
|
||||
const held = mentatHand(hand, expansions);
|
||||
if (!held.length) throw new MentatQuestionError(MENTAT_EMPTY_HAND);
|
||||
const matching = held.filter((card) => card.name === weapon);
|
||||
return {
|
||||
cards: matching.length ? matching : held,
|
||||
};
|
||||
}
|
||||
export function mentatSignature(
|
||||
receipt: MentatQuestionReceipt | MentatObservation,
|
||||
): string {
|
||||
return JSON.stringify({ ...receipt, signature: undefined });
|
||||
}
|
||||
+6
-5
@@ -3579,16 +3579,17 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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 remains unfinished.',
|
||||
'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. Lower retreat and modified defenses remain unfinished.',
|
||||
'Normal Mentat questioning, Bureaucrat payments, special Sandmaster relocation and Smuggler battle collection, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
'Mentat has a private developer preview for naming a specific weapon before posture and faction powers. The target privately shows the named held card or chooses another held card. It stays in hand 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 records an observation, not a later hand guarantee.',
|
||||
'Bureaucrat payments, special Sandmaster relocation and Smuggler battle collection, Spice Banker normal income and Diplomat retreat remain missing. Foreign gholas, Face Dancers and combined modules remain unfinished. This development prototype does not enable public module starts.',
|
||||
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
||||
],
|
||||
related: ['setup','choam-modules','implementation-checklist'],
|
||||
checklist: [
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion and optional Sandmaster ground-route collection are connected. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; normal income, other remaining bands, Diplomat retreat and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/diplomat-defense.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. Diplomat offers a named committed Worthless choice or decline after public reveal. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion and optional Sandmaster ground-route collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; normal income, other remaining bands, Diplomat retreat and combined modules remain missing.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/spice-banker.ts','game/sandmaster-movement.ts','game/diplomat-defense.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes and per-territory collection choices. Diplomat offers a named committed Worthless choice or decline after public reveal. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/diplomat-defense.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments, Sandmaster route collection funded Banker commitments and optional Diplomat copying from public revealed defenses. This is a legal prototype path; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/mentat-question.test.ts','tests/mentat-question-bots.test.ts','tests/mentat-question-controls.test.tsx','tests/mentat-question-recovery.test.ts','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RICHESE_CARD_DEFINITIONS } from './richese-cards';
|
||||
const DECISIONS = {
|
||||
leaderSkillVisibility: true,
|
||||
leaderSkillRevival: true,
|
||||
mentatQuestion: true,
|
||||
sukRescue: true,
|
||||
rihani: true,
|
||||
diplomatDefense: true,
|
||||
|
||||
Reference in new issue
Block a user