Compose explicit native Leader Skills with Semuta continuations

This commit is contained in:
Quintine committed 2026-10-12 08:27:20 +11:00
1 parent 03e3d9bdbf
commit 90c6f0020c
15 files changed
+998 -20

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
import assert from 'node:assert/strict';
import { applyAction, type Action, type Game, type Player } from '../game/engine';
import type { Card } from '../game/cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { initializeSemutaSkillsGame, swapSemutaSkill } from './fixture-semuta-skills';
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
export type SemutaSkillAftermathStep = { actor: string; action: Action };
export type SemutaSkillAftermathOptions = {
skill?: 'suk-graduate' | 'rihani-decipherer';
advanced?: boolean;
tech?: boolean;
strongholds?: boolean;
};
export type SemutaSkillAftermathFixture = {
beforeBattle: Game;
game: Game;
actions: SemutaSkillAftermathStep[];
/** Direct replay from beforeBattle to the active mandatory loser offer. */
battleActions: SemutaSkillAftermathStep[];
staging: string[];
inventory: string[];
skill: 'suk-graduate' | 'rihani-decipherer';
owner: string;
opponent: string;
holder: string;
trainer: string;
losingLeader: string;
key: string;
weapon: string;
defense: string;
losingCard: string;
};
export const reloadSemutaSkillAftermath = (game: Game): Game => JSON.parse(JSON.stringify(game));
export function semutaSkillAftermathPlayer(game: Game, actor: string): Player {
const player = game.players.find(p => p.id === actor);
assert.ok(player, `Missing native skill seat ${actor}`);
return player;
}
export function semutaSkillAftermathInventory(game: Game): string[] {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)];
const ids = cards.map(c => c.id).sort();
assert.equal(new Set(ids).size, ids.length);
return ids;
}
export function assertSemutaSkillAftermathComponents(game: Game, inventory: string[]): void {
assert.deepEqual(semutaSkillAftermathInventory(game), inventory);
validateLeaderSkills(game.leaderSkills!, game.players);
for (const player of game.players) {
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, n) => sum + n, 0), 20);
if (player.elites) {
assert.equal(player.elites.reserves + player.elites.tanks + Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0), 5);
for (const [key, amount] of Object.entries(player.elites.forces)) assert.ok(amount <= (player.forces[key] ?? 0));
}
}
}
/** Human no-spend/no-call policy at original controls; never manufactures a window. */
export function nextSemutaSkillAftermathStep(game: Game): SemutaSkillAftermathStep | null {
if (game.pendingTreacheryDiscard?.reaction || game.decision?.kind === 'sukRescue' || game.decision?.kind === 'rihani') return null;
const decision = game.decision;
if (!game.phaseOpening && !game.response && decision?.kind === 'leaderSkillVisibility')
return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event, hide: true } };
if (!game.phaseOpening && !game.response && decision?.kind === 'mentatQuestion')
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
return nextRicheseStrongholdsNativeStep(game);
}
function advance(game: Game, until: (game: Game) => boolean, actions: SemutaSkillAftermathStep[]): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.equal(game.turn, 1);
const next = nextSemutaSkillAftermathStep(game);
assert.ok(next, 'Stop at an original human skill, Semuta or private-plan control');
actions.push(next);
game = applyAction(game, next.actor, next.action);
}
throw new Error('Native Semuta skill aftermath did not reach its requested control');
}
function take(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[]): string {
for (const [source, zone] of [['deck', game.deck], ['discard', game.discard], ['Richese cache', game.richeseCache ?? []],
...game.players.map(p => [p.id, p.hand] as const)] as const) {
const index = zone.findIndex(matches);
if (index < 0) continue;
const card = zone.splice(index, 1)[0];
semutaSkillAftermathPlayer(game, actor).hand.push(card);
staging.push(`Conserved physical ${card.id}: ${source} → ${actor} hand`);
return card.id;
}
throw new Error('Required original physical Treachery Card is unavailable');
}
/** Genuine native all14 setup and training, then actual first Storm/Bidding/Movement.
* Only skill identity, unplayed Spice Card order and conserved physical positions
* are staged. Phases, wallets, leader strength, outcomes and module owners are not. */
export function createSemutaSkillAftermathFixture(options: SemutaSkillAftermathOptions = {}): SemutaSkillAftermathFixture {
const skill = options.skill ?? 'suk-graduate', owner = 'e', opponent = 'a', holder = 'r';
const advanced = options.advanced ?? false, tech = options.tech ?? false, strongholds = options.strongholds ?? false;
assert.ok(advanced || !strongholds);
let game = initializeSemutaSkillsGame(advanced, tech, strongholds);
const actions: SemutaSkillAftermathStep[] = [], staging: string[] = [];
const trainer = swapSemutaSkill(game, owner, skill);
staging.push(`Conserved ${skill} exchanged into ${owner}'s genuinely trained living ${trainer}; original assignment/disc retained`);
const inventory = semutaSkillAftermathInventory(game);
// Prevent an incidental worm/alliance program; every unplayed card stays in its deck.
for (const [position, card] of game.spiceDeck.filter(c => 'territory' in c).slice(0, 2).entries())
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
staging.push('Conserved first two unplayed non-worm Spice Cards ordered first in native deck');
const clean = (state: Game) => !state.phaseOpening && !state.response && !state.decision;
game = advance(game, state => state.phase === 5 && clean(state), actions);
for (const player of game.players) {
game.deck.push(...player.hand); player.hand = [];
player.reserves += Object.values(player.forces).reduce((sum, n) => sum + n, 0); player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, n) => sum + n, 0);
player.elites.forces = {};
}
}
const key = 'arrakeen:10';
assert.notEqual(game.storm, 10);
for (const actor of [owner, opponent]) {
const player = semutaSkillAftermathPlayer(game, actor);
assert.ok(player.reserves >= 8);
player.reserves -= 8; player.forces[key] = 8;
}
const emperor = semutaSkillAftermathPlayer(game, owner);
if (advanced) {
assert.ok(emperor.elites && emperor.elites.reserves >= 2);
emperor.elites.reserves -= 2; emperor.elites.forces[key] = 2;
}
staging.push(advanced
? 'Conserved board through own reserves; Emperor stages six ordinary/two elite and Atreides eight ordinary at Arrakeen'
: 'Conserved board through own reserves; both Basic combatants stage eight ordinary counters at Arrakeen');
game = advance(game, state => state.phase === 6 && clean(state), actions);
const weapon = take(game, owner, c => c.kind === (skill === 'suk-graduate' ? 'projectile' : 'artillery'), staging);
const defense = take(game, owner, c => c.kind === 'shield', staging);
const losingCard = take(game, opponent, c => c.kind === 'worthless', staging);
take(game, holder, c => c.id === SEMUTA_DRUG_ID, staging);
const assigned = new Set(game.leaderSkills!.assignments.map(a => a.leader));
const losingLeader = semutaSkillAftermathPlayer(game, opponent).leaders.filter(l => !assigned.has(l.id) && !l.dead && !l.usedAt)
.sort((a, b) => a.strength - b.strength)[0];
assert.ok(losingLeader);
assertSemutaSkillAftermathComponents(game, inventory);
const beforeBattle = structuredClone(game);
const battleStart = actions.length;
assert.ok(game.active === owner || game.active === opponent);
const choose: SemutaSkillAftermathStep = { actor: game.active!, action: { type: 'chooseBattle', territory: 'arrakeen', target: game.active === owner ? opponent : owner } };
actions.push(choose); game = applyAction(game, choose.actor, choose.action);
game = advance(game, state => !!state.battle && !state.battle.preparation && state.battle.preLeader?.closed === true && clean(state), actions);
for (const next of [
{ actor: owner, action: { type: 'battlePlan', leader: trainer, dial: 4, support: advanced ? 4 : 0, weapon, defense } },
{ actor: opponent, action: { type: 'battlePlan', leader: losingLeader.id, dial: 0, support: 0, weapon: losingCard } },
] satisfies SemutaSkillAftermathStep[]) {
actions.push(next); game = applyAction(game, next.actor, next.action);
if (game.response || game.decision?.kind === 'fullPlanOffer')
game = advance(game, state => !state.response && state.decision?.kind !== 'fullPlanOffer', actions);
}
game = advance(game, state => state.pendingTreacheryDiscard?.reaction?.stage === 'offer', actions);
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'battleResolved');
assert.equal(game.lastBattleContext!.winner, owner);
return { beforeBattle, game, actions, battleActions: actions.slice(battleStart),
staging, inventory, skill, owner, opponent, holder,
trainer, losingLeader: losingLeader.id, key, weapon, defense, losingCard };
}
export function passSemutaSkillAftermath(game: Game): Game {
const pending = game.pendingTreacheryDiscard;
assert.equal(pending?.reaction?.stage, 'offer');
const event = pending!.batch.event;
const reaction = pending!.reaction; assert.ok(reaction?.stage === 'offer');
for (const player of game.players) if (!reaction.passed.includes(player.id))
game = applyAction(game, player.id, { type: 'semutaPass', event });
return game;
}
export function claimSemutaSkillAftermath(game: Game, holder: string, card: string): Game {
const event = game.pendingTreacheryDiscard!.batch.event;
assert.ok(game.pendingTreacheryDiscard!.batch.entries.some(entry => entry.card.id === card));
game = applyAction(game, holder, { type: 'semutaCommit', event });
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select')
game = applyAction(reloadSemutaSkillAftermath(game), holder, { type: 'semutaSelect', event, card });
assert.ok(semutaSkillAftermathPlayer(game, holder).hand.some(held => held.id === card));
return game;
}
/** Select the original typed four-ordinary/zero-elite casualty allocation. */
export function allocateSemutaSkillAftermathLosses(game: Game): Game {
if (game.decision?.kind !== 'battleLosses') return game;
const choice = game.decision.options.findIndex(o => o.normal === 4 && o.elite === 0);
assert.ok(choice >= 0);
return applyAction(game, game.decision.player, { type: 'decision', choice });
}
/** Complete only original post-skill winner cleanup/reward, not Collection. */
export function finishSemutaSkillAftermath(game: Game): Game {
for (let count = 0; count < 100; count++) {
if (!game.pendingTreacheryDiscard && !game.decision && !game.response && !game.battle) return game;
if (game.pendingTreacheryDiscard?.reaction) game = passSemutaSkillAftermath(game);
else {
const next = nextSemutaSkillAftermathStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
}
throw new Error('Original post-skill cleanup did not complete');
}
+226
View File
@@ -0,0 +1,226 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer,
viewGame, type Action, type Game, type Player,
} from '../game/engine';
import { TERRITORIES } from '../game/board';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { nextRicheseSkillsTechNativeStep } from './fixture-richese-skills-tech';
import { assertAdvancedNativeCustody } from './fixture-advanced-native-skills';
export type SemutaSkillsStep = { actor: string; action: Action };
export const reloadSemutaSkills = (game: Game): Game => JSON.parse(JSON.stringify(game));
export function semutaSkillsPlayer(game: Game, id: string): Player {
const player = game.players.find(p => p.id === id);
assert.ok(player, `Missing original ${id} seat`);
return player;
}
/** Control only the original fourteen-card shuffle, not native setup lotteries.
* Safe companion skills keep this fixture focused on the normal Mentat consumer. */
function withOriginalOffers<T>(initialize: () => T): T {
const selected: LeaderSkillId[] = ['mentat', 'killer-medic', 'swordmaster-of-ginaz'];
const originalCards = LEADER_SKILL_CARDS.map(c => c.id);
const remaining = originalCards.filter(c => !selected.includes(c));
const desired = selected.flatMap(c => [c, remaining.shift()!]);
desired.push(...remaining);
const working = [...originalCards], rolls: number[] = [];
for (let i = working.length - 1; i > 0; i--) {
const j = working.indexOf(desired[i]);
assert.ok(j >= 0 && j <= i);
rolls.push(Math.floor(((j + 0.5) / (i + 1)) * 0x100000000));
[working[i], working[j]] = [working[j], working[i]];
}
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
const original = crypto.getRandomValues.bind(crypto);
let cursor = 0;
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
if (cursor < rolls.length) {
assert.ok(array instanceof Uint32Array && array.length === 1);
array[0] = rolls[cursor++];
} else Reflect.apply(original, crypto, [array]);
return array;
};
try { return initialize(); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Fresh native Richese/CHOAM setup; no played save conversion or invented
* assignment. Every seat selects its actual two-card offer on an eligible disc. */
export function initializeSemutaSkillsGame(advanced = false, tech = false, strongholds = false): Game {
assert.ok(advanced || !strongholds, 'Original Stronghold Cards require Advanced rules');
let game = createGame('SEMUTASKILLS', newPlayer('r', 'Richese', 'richese'), advanced, ['choam']);
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
if (tech) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
if (strongholds) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = withOriginalOffers(() => initializeSemutaGameForAudit(game, true));
const selected: Record<string, LeaderSkillId> = {
r: 'mentat', a: 'killer-medic', e: 'swordmaster-of-ginaz',
};
for (let step = 0; game.status === 'setup' && step < 250; step++) {
if (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = game.leaderSkills!.offers[actor], own = viewGame(game, actor).leaderSkills!;
const eligible = new Set(own.eligibleLeaders.map(l => l.id));
const leader = semutaSkillsPlayer(game, actor).leaders.filter(l => eligible.has(l.id))
.sort((a, b) => b.strength - a.strength)[0];
const skill = selected[actor];
assert.ok(offer.cards.includes(skill) && !own.unavailableSkills?.[skill] && leader,
'Actual original offer and living eligible leader must supply training');
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill, leader: leader.id });
} else {
const next = nextRicheseSkillsTechNativeStep(game);
game = applyAction(game, next.actor, next.action);
}
}
assert.equal(game.status, 'playing');
assertAdvancedNativeCustody(game);
return game;
}
/** Explicit physical staging AFTER original offers/training: exchange the
* requested card with the owner's existing card, retaining both exact slots
* and the original living trained leader. No assignment is added or removed. */
export function swapSemutaSkill(game: Game, owner: string, skill: LeaderSkillId): string {
const skills = game.leaderSkills!;
validateLeaderSkills(skills, game.players);
const assignment = skills.assignments.find(a => a.owner === owner);
assert.ok(assignment, 'Only an actually trained original owner may exchange a physical card');
const leader = semutaSkillsPlayer(game, owner).leaders.find(l => l.id === assignment.leader);
assert.ok(leader && !leader.dead && !leader.capturedBy && !leader.gholaBy);
if (assignment.skill === skill) return assignment.leader;
const previous = assignment.skill, donor = skills.assignments.find(a => a.skill === skill);
if (donor) donor.skill = previous;
else {
const offer = Object.values(skills.offers).find(o => o.cards.includes(skill));
if (offer) offer.cards[offer.cards.indexOf(skill)] = previous;
else {
const index = skills.deck.indexOf(skill);
assert.ok(index >= 0, 'Requested original physical skill must have a real custody slot');
skills.deck[index] = previous;
}
}
assignment.skill = skill;
validateLeaderSkills(skills, game.players);
return assignment.leader;
}
export function semutaSkillsInventory(game: Game): string[] {
const cards = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
...(game.richeseRemoved ?? []), ...game.players.flatMap(p => p.hand)].map(c => c.id).sort();
assert.equal(new Set(cards).size, cards.length, 'Each original Treachery Card has one physical slot');
return cards;
}
export function semutaSkillsPhysical(game: Game) {
return structuredClone({
players: game.players.map(p => ({ id: p.id, spice: p.spice, forces: p.forces,
reserves: p.reserves, tanks: p.tanks, elites: p.elites, leaders: p.leaders,
traitors: p.traitors, revealedTraitors: p.revealedTraitors })),
skills: game.leaderSkills, tech: game.techTokens, strongholds: game.strongholdCards,
});
}
export type SemutaSkillsMentatFixture = {
game: Game; owner: 'r'; target: 'e'; spender: 'a'; trainer: string;
semuta: string; hajr: string; weapon: string; fallback: string; territory: string; location: string;
};
function takeCard(game: Game, actor: string, identity: string): string {
const zones = [game.deck, game.discard, game.richeseCache!, ...game.players.map(p => p.hand)];
for (const zone of zones) {
const index = zone.findIndex(c => c.id === identity || c.name === identity || c.effect === identity);
if (index < 0) continue;
const card = zone.splice(index, 1)[0];
semutaSkillsPlayer(game, actor).hand.push(card);
return card.id;
}
throw Error(`No original physical ${identity} card remains available`);
}
/** Genuine setup and original phase controls reach Movement. Only the conserved
* board/hand position is staged: native forces return to their own reserves,
* then five original counters per combatant and canonical cards change slots.
* Wallets, printed leaders, skill training, turn, phase and module custody stay real. */
export function createSemutaSkillsMentatFixture(advanced = false, tech = false, strongholds = false): SemutaSkillsMentatFixture {
let game = initializeSemutaSkillsGame(advanced, tech, strongholds);
for (let n = 0; n < 1500; n++) {
if (game.phase === 5 && !game.phaseOpening && !game.response && !game.decision) break;
assert.equal(game.turn, 1);
const next = nextRicheseSkillsTechNativeStep(game);
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.phase, 5);
assert.ok(!game.phaseOpening && !game.response && !game.decision);
// End preceding actual movement owners, leaving Atreides as the real Hajr owner.
for (let n = 0; game.active !== 'a' && n < 3; n++)
game = applyAction(game, game.active!, { type: 'endMovement' });
assert.equal(game.active, 'a');
const physical = semutaSkillsPhysical(game), cards = semutaSkillsInventory(game);
for (const player of game.players) {
game.deck.push(...player.hand); player.hand = [];
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
player.elites.forces = {};
}
}
const battleTerritory = TERRITORIES.find(t => t.type === 'sand' && !t.sectors.includes(game.storm));
assert.ok(battleTerritory);
const location = `${battleTerritory.id}:${battleTerritory.sectors[0]}`;
for (const actor of ['r', 'e']) {
const player = semutaSkillsPlayer(game, actor);
assert.ok(player.reserves >= 5);
player.reserves -= 5; player.forces[location] = 5;
}
const semuta = takeCard(game, 'r', SEMUTA_DRUG_ID), hajr = takeCard(game, 'a', 'hajr');
const weapon = takeCard(game, 'e', 'Crysknife'), fallback = takeCard(game, 'e', 'Snooper');
assertAdvancedNativeCustody(game);
assert.deepEqual(semutaSkillsInventory(game), cards);
for (const player of game.players) {
const before = physical.players.find(p => p.id === player.id)!;
assert.equal(player.spice, before.spice);
assert.deepEqual(player.leaders, before.leaders);
}
assert.deepEqual(game.leaderSkills, physical.skills);
return { game, owner: 'r', target: 'e', spender: 'a',
trainer: game.leaderSkills!.assignments.find(a => a.owner === 'r')!.leader,
semuta, hajr, weapon, fallback, territory: battleTerritory.id, location };
}
export function semutaSkillsDiscardStep(fixture: SemutaSkillsMentatFixture): SemutaSkillsStep {
return { actor: fixture.spender, action: { type: 'card', card: fixture.hajr } };
}
export function reactSemutaSkillsDiscard(state: Game, claim: boolean): Game {
let game = state;
const pending = game.pendingTreacheryDiscard!;
assert.equal(pending.reaction?.stage, 'offer');
const event = pending.batch.event;
if (claim) return applyAction(game, 'r', { type: 'semutaCommit', event });
for (const player of game.players)
game = applyAction(game, player.id, { type: 'semutaPass', event });
return game;
}
/** The discarded Hajr continuation must return Atreides' Movement first.
* Native movement closings then choose the original Richese/Emperor battle. */
export function openSemutaSkillsMentat(state: Game, fixture: SemutaSkillsMentatFixture): Game {
let game = state;
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.active, fixture.spender);
assert.equal(game.phase, 5);
for (let n = 0; game.phase === 5 && n < 5; n++)
game = applyAction(game, game.active!, { type: 'endMovement' });
assert.equal(game.phase, 6);
for (let n = 0; game.phaseOpening && n < 3; n++) {
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id))!;
game = applyAction(game, actor.id, { type: 'ready' });
}
const actor = game.active!;
assert.ok(actor === fixture.owner || actor === fixture.target);
return applyAction(game, actor, { type: 'chooseBattle', territory: fixture.territory,
target: actor === fixture.owner ? fixture.target : fixture.owner });
}
+213
View File
@@ -0,0 +1,213 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { ownedTech } from '../game/tech-tokens';
import {
allocateSemutaSkillAftermathLosses, assertSemutaSkillAftermathComponents,
claimSemutaSkillAftermath, createSemutaSkillAftermathFixture, finishSemutaSkillAftermath,
passSemutaSkillAftermath, reloadSemutaSkillAftermath, semutaSkillAftermathPlayer,
} from './fixture-semuta-skill-aftermath';
const profiles = [
{ name: 'Basic native', advanced: false, tech: false, strongholds: false },
{ name: 'Advanced native', advanced: true, tech: false, strongholds: false },
{ name: 'Advanced original Tech', advanced: true, tech: true, strongholds: false },
{ name: 'Advanced original Strongholds', advanced: true, tech: false, 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);
}
for (const profile of profiles) for (const claim of [false, true]) {
void test(`${profile.name}: mandatory loser ${claim ? 'claim' : 'pass'} restores exactly one original typed Suk rescue`, () => {
const f = createSemutaSkillAftermathFixture(profile);
let game = reloadSemutaSkillAftermath(f.game);
const original = semutaSkillAftermathPlayer(f.beforeBattle, f.owner);
const enemy = semutaSkillAftermathPlayer(f.beforeBattle, f.opponent);
const event = game.pendingTreacheryDiscard!.batch.event;
assert.equal(game.pendingTreacheryDiscard!.batch.cause, 'battle:mandatory');
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.losingCard]);
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'battleResolved');
assert.equal(game.pendingSukRescue!.losses, null);
assert.equal(game.lastBattleContext!.sukRescue!.completed, false);
assert.equal(semutaSkillAftermathPlayer(game, f.owner).forces[f.key], 8, 'Winner casualties await the fresh loser discard reaction');
assert.equal(semutaSkillAftermathPlayer(game, f.owner).tanks, original.tanks);
assert.equal(semutaSkillAftermathPlayer(game, f.opponent).forces[f.key] ?? 0, 0);
assert.equal(semutaSkillAftermathPlayer(game, f.opponent).tanks, enemy.tanks + 8);
assert.equal(semutaSkillAftermathPlayer(game, f.opponent).leaders.find(l => l.id === f.losingLeader)!.dead, true);
if (profile.tech) assert.deepEqual(game.techTokens, f.beforeBattle.techTokens, 'Original reward remains after rescue/card cleanup');
reject(game, f.owner, { type: 'decision', event: game.pendingSukRescue!.event, choice: 0 });
game = claim ? claimSemutaSkillAftermath(game, f.holder, f.losingCard) : passSemutaSkillAftermath(game);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaSelect', event, card: f.losingCard });
game = allocateSemutaSkillAftermathLosses(reloadSemutaSkillAftermath(game));
assert.ok(game.decision?.kind === 'sukRescue');
assert.equal(game.decision.mode, 'skilled');
assert.equal(semutaSkillAftermathPlayer(game, f.owner).forces[f.key], 8);
const rescue = game.decision;
const choice = rescue.options.findIndex(o => o.normal === 3 && o.elite === 0 && o.kept?.kind === 'normal' && o.kept.key === f.key);
assert.ok(choice >= 0);
const rescueAction: Action = { type: 'decision', event: rescue.event, choice };
reject(game, f.opponent, rescueAction);
reject(game, f.owner, { ...rescueAction, event: `${rescue.event}-stale` });
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, rescueAction);
const rescued = semutaSkillAftermathPlayer(game, f.owner);
assert.equal(rescued.forces[f.key], 5, 'Three ordinary board casualties removed; saved fourth remains');
if (profile.advanced) assert.equal(rescued.elites!.forces[f.key], 2, 'Neither elite counter was selected as a casualty');
assert.equal(rescued.reserves, original.reserves + 2);
assert.equal(rescued.tanks, original.tanks + 1);
if (profile.advanced) {
assert.equal(rescued.elites!.reserves, original.elites!.reserves);
assert.equal(rescued.elites!.tanks, original.elites!.tanks);
}
assert.equal(rescued.battleLosses, original.battleLosses + 4);
assert.equal(game.pendingSukRescue, null);
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
assert.ok(game.decision?.kind === 'battleCards');
assert.deepEqual([...game.decision.cards].sort(), [f.weapon, f.defense].sort());
reject(game, f.owner, rescueAction);
assert.equal(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.losingCard), claim);
assert.equal(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === SEMUTA_DRUG_ID), !claim);
assertSemutaSkillAftermathComponents(game, f.inventory);
// Original optional winner disposal happens AFTER the skill, using its own receipt.
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, { type: 'decision', discard: [f.weapon] });
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'battleCleanup');
assert.equal(game.pendingTreacheryDiscard!.batch.cause, 'battle:winner');
assert.equal(game.lastBattleContext!.sukRescue!.completed, true);
const cleanupEvent = game.pendingTreacheryDiscard!.batch.event;
assert.notEqual(cleanupEvent, event);
const physicalAfterRescue = structuredClone({ forces: rescued.forces, reserves: rescued.reserves, tanks: rescued.tanks, elites: rescued.elites });
game = finishSemutaSkillAftermath(reloadSemutaSkillAftermath(game));
const finished = semutaSkillAftermathPlayer(game, f.owner);
assert.deepEqual({ forces: finished.forces, reserves: finished.reserves, tanks: finished.tanks, elites: finished.elites }, physicalAfterRescue);
assert.ok(finished.hand.some(c => c.id === f.defense));
assert.equal(finished.hand.some(c => c.id === f.weapon), false);
assert.equal(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.losingCard), claim);
assert.equal(game.phase, 7);
if (profile.tech) {
const won = ownedTech(f.beforeBattle.techTokens, f.opponent)[0];
assert.ok(won);
assert.equal(game.techTokens![won].owner, f.owner);
assert.equal(ownedTech(game.techTokens, f.opponent).length, 0);
}
reject(game, f.owner, rescueAction);
reject(game, f.holder, { type: 'semutaCommit', event: cleanupEvent });
assertSemutaSkillAftermathComponents(game, f.inventory);
});
}
for (const profile of profiles) for (const claim of [false, true]) {
void test(`${profile.name}: loser ${claim ? 'claim' : 'pass'} restores Rihani ${claim ? 'exchange' : 'decline'} before compulsory winning Artillery disposal`, () => {
const f = createSemutaSkillAftermathFixture({ ...profile, skill: 'rihani-decipherer' });
let game = reloadSemutaSkillAftermath(f.game);
const ownerBefore = semutaSkillAftermathPlayer(f.beforeBattle, f.owner);
const event = game.pendingTreacheryDiscard!.batch.event;
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'battleResolved');
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.losingCard]);
assert.equal(game.lastBattleContext!.rihani!.completed, false);
assert.deepEqual(game.pendingWinnerDiscards!.cards, [f.weapon]);
assert.ok(semutaSkillAftermathPlayer(game, f.owner).hand.some(c => c.id === f.weapon), 'Winning compulsory Artillery is not an invented pre-skill target');
assert.equal(game.rihaniHistory?.length ?? 0, 0);
assert.equal(game.discard.some(c => c.id === f.weapon), false);
const initialTraitors = [...semutaSkillAftermathPlayer(game, f.owner).traitors];
game = claim ? claimSemutaSkillAftermath(game, f.holder, f.losingCard) : passSemutaSkillAftermath(game);
game = allocateSemutaSkillAftermathLosses(reloadSemutaSkillAftermath(game));
assert.ok(game.decision?.kind === 'rihani' && game.decision.stage === 'offer');
assert.equal(game.pendingTreacheryDiscard, null, 'Traitor drawing is not a Treachery discard reaction');
assert.equal(semutaSkillAftermathPlayer(game, f.owner).forces[f.key], 4);
if (profile.advanced) assert.equal(semutaSkillAftermathPlayer(game, f.owner).elites!.forces[f.key], 2);
assert.equal(semutaSkillAftermathPlayer(game, f.owner).tanks, ownerBefore.tanks + 4);
assert.equal(semutaSkillAftermathPlayer(game, f.owner).reserves, ownerBefore.reserves);
assert.ok(semutaSkillAftermathPlayer(game, f.owner).leaders.find(l => l.id === f.trainer && !l.dead));
const rihaniEvent = game.decision.event;
const drawAction: Action = { type: 'decision', event: rihaniEvent, draw: claim };
reject(game, f.opponent, drawAction);
reject(game, f.holder, { type: 'semutaCommit', event });
reject(game, f.holder, { type: 'semutaSelect', event, card: f.weapon });
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, drawAction);
let exchangeAction: Action | undefined;
let kept: string | undefined, given: string | undefined, unkept: string | undefined;
if (claim) {
assert.ok(game.decision?.kind === 'rihani' && game.decision.stage === 'return');
const receipt = game.rihaniHistory![0];
assert.equal(receipt.drawn.length, 2);
assert.deepEqual(semutaSkillAftermathPlayer(game, f.owner).traitors, [...initialTraitors, ...receipt.drawn]);
assert.equal(game.pendingTreacheryDiscard, null);
assert.equal(game.pendingWinnerDiscards!.cards[0], f.weapon);
kept = receipt.drawn[0]; unkept = receipt.drawn[1]; given = receipt.eligible[0];
exchangeAction = { type: 'decision', event: rihaniEvent, cards: [kept, given] };
reject(game, f.owner, drawAction);
reject(game, f.owner, { type: 'decision', event: rihaniEvent, cards: [given, kept] });
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, exchangeAction);
assert.deepEqual(semutaSkillAftermathPlayer(game, f.owner).traitors, [...initialTraitors.filter(id => id !== given), kept]);
assert.ok(game.traitorReserve!.includes(given) && game.traitorReserve!.includes(unkept));
assert.equal(game.traitorReserve!.includes(kept), false);
} else {
assert.deepEqual(semutaSkillAftermathPlayer(game, f.owner).traitors, initialTraitors);
assert.deepEqual(game.rihaniHistory![0].drawn, []);
assert.equal(game.rihaniHistory![0].given, null);
}
assert.equal(game.lastBattleContext!.rihani!.completed, true);
assert.equal(game.rihaniHistory!.length, 1);
assert.equal(game.rihaniHistory![0].stage, 'complete');
assert.equal(game.pendingWinnerDiscards, null);
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'winnerMandatoryDiscard');
assert.equal(game.pendingTreacheryDiscard!.batch.cause, 'battle:winnerMandatory');
assert.deepEqual(game.pendingTreacheryDiscard!.batch.entries.map(e => e.card.id), [f.weapon]);
assert.ok(game.pendingTreacheryDiscard!.batch.entries.every(e => e.card.kind === 'artillery'), 'No Traitor or Face Dancer is a Treachery discard target');
const compulsoryEvent = game.pendingTreacheryDiscard!.batch.event;
reject(game, f.owner, drawAction);
if (exchangeAction) reject(game, f.owner, exchangeAction);
game = passSemutaSkillAftermath(reloadSemutaSkillAftermath(game));
assert.ok(game.decision?.kind === 'battleCards');
assert.deepEqual(game.decision.cards, [f.defense]);
const cleanup = applyAction(reloadSemutaSkillAftermath(game), f.owner, { type: 'decision', discard: [f.defense] });
assert.equal(cleanup.pendingTreacheryDiscard!.continuation.kind, 'battleCleanup');
game = finishSemutaSkillAftermath(reloadSemutaSkillAftermath(cleanup));
assert.equal(game.rihaniHistory!.length, 1);
assert.equal(game.phase, 7);
assert.equal(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.losingCard), claim);
if (claim) {
assert.equal(game.rihaniHistory![0].kept, kept);
assert.equal(game.rihaniHistory![0].given, given);
assert.ok(semutaSkillAftermathPlayer(game, f.owner).traitors.includes(kept!));
assert.ok(game.traitorReserve!.includes(given!) && game.traitorReserve!.includes(unkept!));
}
reject(game, f.holder, { type: 'semutaCommit', event: compulsoryEvent });
reject(game, f.owner, drawAction);
assertSemutaSkillAftermathComponents(game, f.inventory);
});
}
void test('A late compulsory Artillery claim cannot precede or replay the original Rihani exchange', () => {
const f = createSemutaSkillAftermathFixture({ advanced: true, skill: 'rihani-decipherer' });
let game = allocateSemutaSkillAftermathLosses(passSemutaSkillAftermath(reloadSemutaSkillAftermath(f.game)));
assert.ok(game.decision?.kind === 'rihani');
const event = game.decision.event;
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, { type: 'decision', event, draw: true });
const receipt = game.rihaniHistory![0], kept = receipt.drawn[0], given = receipt.eligible[0];
const exchange: Action = { type: 'decision', event, cards: [kept, given] };
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, exchange);
assert.equal(game.pendingTreacheryDiscard!.continuation.kind, 'winnerMandatoryDiscard');
const compulsoryEvent = game.pendingTreacheryDiscard!.batch.event;
const skillReceipt = structuredClone(game.rihaniHistory), forces = structuredClone(semutaSkillAftermathPlayer(game, f.owner).forces);
game = claimSemutaSkillAftermath(reloadSemutaSkillAftermath(game), f.holder, f.weapon);
assert.ok(game.decision?.kind === 'battleCards');
assert.deepEqual(game.rihaniHistory, skillReceipt);
assert.deepEqual(semutaSkillAftermathPlayer(game, f.owner).forces, forces);
assert.ok(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.weapon));
assert.ok(semutaSkillAftermathPlayer(game, f.owner).traitors.includes(kept));
reject(game, f.owner, exchange);
reject(game, f.holder, { type: 'semutaCommit', event: compulsoryEvent });
game = applyAction(reloadSemutaSkillAftermath(game), f.owner, { type: 'decision', discard: [] });
game = finishSemutaSkillAftermath(game);
assert.ok(semutaSkillAftermathPlayer(game, f.owner).hand.some(c => c.id === f.defense));
assert.ok(semutaSkillAftermathPlayer(game, f.holder).hand.some(c => c.id === f.weapon));
assert.deepEqual(game.rihaniHistory, skillReceipt);
assertSemutaSkillAftermathComponents(game, f.inventory);
// Projection at the completed JSON boundary exercises canonical skill receipts.
for (const player of game.players) viewGame(reloadSemutaSkillAftermath(game), player.id);
});
+175
View File
@@ -0,0 +1,175 @@
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);
}
}
}
});