Compose explicit native Leader Skills with Semuta continuations
This commit is contained in:
1 parent
03e3d9bdbf
commit
90c6f0020c
15 files changed
+998
-20
No files matched your search
@@ -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');
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user