Files
Dune/tests/semuta-skills.test.ts

176 lines
10 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
import {
createSemutaSkillsMentatFixture, semutaSkillsDiscardStep, reactSemutaSkillsDiscard,
openSemutaSkillsMentat, reloadSemutaSkills as reload, semutaSkillsPlayer,
semutaSkillsInventory, semutaSkillsPhysical, type SemutaSkillsMentatFixture,
} from './fixture-semuta-skills';
const profiles = [
{ advanced: false, tech: false, strongholds: false },
{ advanced: false, tech: true, strongholds: false },
{ advanced: true, tech: false, strongholds: false },
{ advanced: true, tech: true, strongholds: false },
{ advanced: true, tech: false, strongholds: true },
{ advanced: true, tech: true, strongholds: true },
];
function reject(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function questionResources(game: Game) {
return structuredClone({ players: game.players, deck: game.deck, discard: game.discard,
cache: game.richeseCache, removed: game.richeseRemoved, skills: game.leaderSkills,
tech: game.techTokens, strongholds: game.strongholdCards });
}
function pause(f: SemutaSkillsMentatFixture): Game {
const step = semutaSkillsDiscardStep(f), game = applyAction(reload(f.game), step.actor, step.action);
const pending = game.pendingTreacheryDiscard!;
assert.equal(pending.reaction?.stage, 'offer');
assert.equal(pending.batch.cause, 'ordinary:hajr');
assert.deepEqual(pending.batch.entries.map(e => ({ id: e.card.id, actor: e.discardedBy, publicFace: e.publicFace })),
[{ id: f.hajr, actor: f.spender, publicFace: true }]);
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
if (pending.continuation.kind !== 'ordinaryCardDiscard') throw Error('Expected the original Hajr continuation');
assert.equal(pending.continuation.player, f.spender);
assert.equal(pending.continuation.effect, 'hajr');
assert.equal(game.active, f.spender);
assert.equal(game.phase, 5);
assert.equal(game.hajr.filter(id => id === f.spender).length, 1);
assert.deepEqual(semutaSkillsPhysical(game), semutaSkillsPhysical(f.game));
assert.deepEqual(semutaSkillsInventory(game), semutaSkillsInventory(f.game));
reject(game, f.spender, { type: 'endMovement' });
return game;
}
function resumedQuestion(pending: Game, f: SemutaSkillsMentatFixture, claim: boolean): Game {
const event = pending.pendingTreacheryDiscard!.batch.event;
const movement = reactSemutaSkillsDiscard(reload(pending), claim);
assert.equal(movement.pendingTreacheryDiscard, null);
assert.equal(movement.phase, 5);
assert.equal(movement.active, f.spender, 'The original Atreides closing owner resumes before Battle');
assert.deepEqual(movement.movementRemaining, f.game.movementRemaining);
assert.equal(movement.hajr.filter(id => id === f.spender).length, 1);
assert.equal(semutaSkillsPlayer(movement, f.owner).hand.some(c => c.id === f.hajr), claim);
assert.equal(semutaSkillsPlayer(movement, f.owner).hand.some(c => c.id === f.semuta), !claim);
assert.equal(movement.discard.filter(c => c.id === f.hajr).length, Number(!claim));
assert.equal(movement.discard.filter(c => c.id === f.semuta).length, Number(claim));
assert.deepEqual(semutaSkillsPhysical(movement), semutaSkillsPhysical(f.game));
assert.deepEqual(semutaSkillsInventory(movement), semutaSkillsInventory(f.game));
reject(movement, f.owner, { type: 'semutaCommit', event });
const game = openSemutaSkillsMentat(reload(movement), f);
assert.equal(game.decision?.kind, 'mentatQuestion', 'Semuta must retain the original native skill consumer');
assert.equal(game.decision?.player, f.owner);
assert.equal(game.battle?.mentatQuestion?.leader, f.trainer);
assert.equal(game.battle?.mentatQuestion?.stage, 'name');
assert.equal(game.battle?.mentatQuestion?.owner, f.owner);
assert.equal(game.battle?.mentatQuestion?.target, f.target);
assertAdvancedNativeCustody(game);
assert.deepEqual(semutaSkillsInventory(game), semutaSkillsInventory(f.game));
return game;
}
for (const profile of profiles) {
const label = `${profile.advanced ? 'Advanced' : 'Basic'}${profile.tech ? ' Tech' : ''}${profile.strongholds ? ' Strongholds' : ''}`;
void test(`${label} Semuta claim/pass resumes the native owner before a genuine private Mentat question and reply`, () => {
const f = createSemutaSkillsMentatFixture(profile.advanced, profile.tech, profile.strongholds);
const pending = pause(f);
for (const claim of [false, true]) for (const matched of [false, true]) {
const game = resumedQuestion(pending, f, claim), before = questionResources(game);
const event = game.battle!.mentatQuestionEvent!;
const weapon = matched ? 'Crysknife' : 'Lasgun';
const expected = matched ? f.weapon : f.fallback;
assert.ok(viewGame(game, f.owner).mentat.pending!.weapons.includes(weapon));
for (const id of [f.target, f.spender]) {
assert.deepEqual(viewGame(game, id).mentat.pending!.weapons, []);
assert.deepEqual(viewGame(game, id).mentat.pending!.cards, []);
reject(game, id, { type: 'decision', event, weapon });
}
const named = applyAction(reload(game), f.owner, { type: 'decision', event, weapon });
assert.equal(named.decision?.kind, 'mentatQuestion');
assert.equal(named.decision?.player, f.target, 'Both exact and fallback questions use the same target-owned reply');
assert.equal(named.battle!.mentatQuestion!.stage, 'reveal');
assert.equal(named.battle!.mentatQuestionEvent, event);
const choices = viewGame(named, f.target).mentat.pending!.cards;
assert.deepEqual(choices.map(c => c.id), matched ? [f.weapon] : [f.weapon, f.fallback]);
for (const id of [f.owner, f.spender]) {
assert.deepEqual(viewGame(named, id).mentat.pending!.cards, []);
assert.deepEqual(viewGame(named, id).mentat.history, []);
reject(named, id, { type: 'decision', event, card: expected });
}
if (matched) reject(named, f.target, { type: 'decision', event, card: f.fallback });
reject(named, f.target, { type: 'decision', event: 'stale', card: expected });
const shown = applyAction(reload(named), f.target, { type: 'decision', event, card: expected });
assert.equal(shown.battle!.mentatQuestion!.stage, 'answered');
assert.equal(shown.decision?.kind, 'leaderSkillVisibility');
assert.deepEqual(questionResources(shown), before, 'A normal question never transfers, reserves, spends or edits components');
assert.deepEqual(semutaSkillsPlayer(shown, f.target).hand.map(c => c.id), [f.weapon, f.fallback]);
for (const id of [f.owner, f.target]) {
const history = viewGame(shown, id).mentat.history;
assert.equal(history.length, 1);
assert.equal(history[0].event, event);
assert.equal(history[0].card.id, expected);
assert.equal(history[0].weapon, weapon);
}
assert.deepEqual(viewGame(shown, f.spender).mentat.history, []);
assert.ok(!JSON.stringify(viewGame(shown, f.spender)).includes(expected));
assert.ok(!JSON.stringify(shown.log).includes(expected));
reject(shown, f.owner, { type: 'decision', event, weapon });
reject(shown, f.target, { type: 'decision', event, card: expected });
let posture = reload(shown);
while (posture.decision?.kind === 'leaderSkillVisibility') {
const decision = posture.decision;
posture = applyAction(posture, decision.player,
{ type: 'leaderSkillVisibility', event: decision.event, hide: false });
}
assert.equal(posture.battle!.leaderSkillHidden!.r, false, 'The normal Mentat may stay face-up after the private question');
assert.ok(posture.battle?.preparation || posture.decision?.kind === 'fullPlanOffer' || posture.battle?.preLeader);
assertAdvancedNativeCustody(posture);
assert.deepEqual(semutaSkillsInventory(posture), semutaSkillsInventory(f.game));
}
});
}
void test('all four AI profiles participate legally in the original private Mentat contract after Semuta claim/pass', () => {
for (const profile of profiles) {
const f = createSemutaSkillsMentatFixture(profile.advanced, profile.tech, profile.strongholds);
const pending = pause(f);
for (const claim of [false, true]) {
const question = resumedQuestion(pending, f, claim);
for (const difficulty of DIFFICULTIES) {
const own = viewGame(question, f.owner);
own.players.find(p => p.id === f.owner)!.bot = difficulty;
const name = botActions(own)[0];
assert.ok(name && own.mentat.pending!.weapons.includes(name.weapon as string));
assert.equal(name.event, question.battle!.mentatQuestionEvent);
for (const id of [f.target, f.spender]) {
const other = viewGame(question, id);
other.players.find(p => p.id === id)!.bot = difficulty;
assert.deepEqual(botActions(other), []);
}
const named = applyAction(reload(question), f.owner, name), before = questionResources(named);
const target = viewGame(named, f.target);
target.players.find(p => p.id === f.target)!.bot = difficulty;
const answer = botActions(target)[0];
assert.ok(answer && target.mentat.pending!.cards.some(c => c.id === answer.card));
assert.equal(answer.event, name.event);
for (const id of [f.owner, f.spender]) {
const other = viewGame(named, id);
other.players.find(p => p.id === id)!.bot = difficulty;
assert.deepEqual(botActions(other), []);
}
const shown = applyAction(reload(named), f.target, answer);
assert.equal(shown.decision?.kind, 'leaderSkillVisibility');
assert.deepEqual(questionResources(shown), before);
assert.equal(viewGame(shown, f.owner).mentat.history[0].card.id, answer.card);
assert.deepEqual(viewGame(shown, f.spender).mentat.history, []);
assertAdvancedNativeCustody(shown);
}
}
}
});