Preserve binding Truthtrance through Salusa Ghola
This commit is contained in:
1 parent
53b37adf9f
commit
b620cea873
15 files changed
+515
-33
No files matched your search
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import type { PlanClaim } from '../game/battle-promises';
|
||||
import type { TruthQuestion } from '../game/truthtrance';
|
||||
import {
|
||||
createSalusaGholaFixture, actSalusaGhola, advanceSalusaGhola, salusaGholaClean,
|
||||
salusaGholaPlayer, salusaGholaInventory, type SalusaGholaStep,
|
||||
} from './fixture-salusa-ghola';
|
||||
|
||||
export const actSalusaGholaTruth = actSalusaGhola;
|
||||
export const salusaGholaTruthPlayer = salusaGholaPlayer;
|
||||
export const salusaGholaTruthInventory = salusaGholaInventory;
|
||||
export const salusaGholaTruthClean = salusaGholaClean;
|
||||
|
||||
/** Original public source and native Truth protocol. The optional inspection
|
||||
* was really declared/answered before this original Truth card is used. No
|
||||
* binding, frozen answer, battle commitment or physical receipt is installed. */
|
||||
export function createSalusaGholaTruthFixture(options: {
|
||||
claim?: PlanClaim; answer?: 'yes'|'no'; inspectionDial?: 1|2;
|
||||
} = {}) {
|
||||
const base = createSalusaGholaFixture({firstTerritory:'imperial_basin',truthtrance:true,
|
||||
...(options.inspectionDial === undefined ? {} : {inspectionDial:options.inspectionDial})});
|
||||
const actor = base.actor, opponent = base.opponent;
|
||||
assert.equal(opponent,'atreides');
|
||||
const truth = salusaGholaPlayer(base.game,opponent).hand.find(c => c.effect === 'truthtrance');
|
||||
assert.ok(truth);
|
||||
const claim: PlanClaim = structuredClone(options.claim ?? {kind:'dial',compare:'eq',value:2});
|
||||
const answer = options.answer ?? 'yes';
|
||||
const question: Extract<TruthQuestion,{kind:'battlePlan'}> = {
|
||||
kind:'battlePlan',target:actor,territory:base.territory,claim,
|
||||
};
|
||||
const actions: SalusaGholaStep[] = [...base.sourceActions], at = actions.length;
|
||||
let game = actSalusaGhola(structuredClone(base.game),opponent,{type:'card',card:truth.id},actions);
|
||||
const declared = structuredClone(game);
|
||||
for (let n = 0; game.truthtrance?.stage === 'priority' && n < game.players.length; n++) {
|
||||
const owner = game.players.find(p => !game.truthtrance!.passed.includes(p.id)); assert.ok(owner);
|
||||
game = actSalusaGhola(game,owner.id,{type:'truthPass'},actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage,'ask');
|
||||
const beforeQuestion = structuredClone(game);
|
||||
game = actSalusaGhola(game,opponent,{type:'truthAsk',question},actions);
|
||||
const asked = structuredClone(game);
|
||||
assert.ok(viewGame(game,actor).truthBattleAnswers?.includes(answer),
|
||||
'The actual native Truth question must offer the requested definite answer.');
|
||||
game = actSalusaGhola(game,actor,{type:'truthAnswer',answer},actions);
|
||||
assert.equal(game.truthtrance,null);
|
||||
assert.deepEqual(game.battle!.plans,{});
|
||||
assert.equal(game.discard.filter(c => c.id === truth.id).length,1);
|
||||
assert.deepEqual(salusaGholaInventory(game),base.original);
|
||||
return {...base,base,game,source:structuredClone(game),actor,opponent,truthCard:truth.id,
|
||||
claim,answer,question,declared,beforeQuestion,asked,event:game.battle!.event!,
|
||||
actions,sourceActions:actions,sourceCount:actions.length,truthActions:actions.slice(at),
|
||||
disclosure:base.disclosure+' Atreides actually received its original unopened Truthtrance instead of Shield. Its native printed declaration, original priority passes, battle-plan question and Emperor definite answer create the live binding; no promise is installed.'};
|
||||
}
|
||||
export type SalusaGholaTruthFixture = ReturnType<typeof createSalusaGholaTruthFixture>;
|
||||
|
||||
export function salusaGholaTruthPlan(game: Game, dial = 2, support = 0): Action {
|
||||
const leaders = salusaGholaPlayer(game).leaders.filter(l => !l.dead && !l.usedAt && !l.capturedBy)
|
||||
.sort((a,b) => b.strength-a.strength);
|
||||
assert.ok(leaders[0]);
|
||||
return {type:'battlePlan',leader:leaders[0].id,dial,support};
|
||||
}
|
||||
|
||||
/** Actual native seals and calls only. It does not play Ghola or manufacture a
|
||||
* replacement inspection answer; the caller must finish the current chooser. */
|
||||
export function finishSalusaGholaTruthBattle(f: SalusaGholaTruthFixture, state: Game,
|
||||
plan: Action = salusaGholaTruthPlan(state), actions: SalusaGholaStep[] = []) {
|
||||
let game = actSalusaGhola(state,f.actor,plan,actions);
|
||||
const afterPlan = structuredClone(game);
|
||||
const enemy = salusaGholaPlayer(game,f.opponent).leaders.filter(l => !l.dead && !l.usedAt && !l.capturedBy)
|
||||
.sort((a,b) => a.strength-b.strength)[0]; assert.ok(enemy);
|
||||
game = actSalusaGhola(game,f.opponent,{type:'battlePlan',leader:enemy.id,dial:0,support:0},actions);
|
||||
const revealed = structuredClone(game);
|
||||
game = advanceSalusaGhola(game,g => !g.battle && salusaGholaClean(g),actions);
|
||||
return {game,afterPlan,revealed,actions};
|
||||
}
|
||||
@@ -75,7 +75,7 @@ function livingLeader(game: Game, actor: string, strongest: boolean) {
|
||||
* while unopened; native setup, payments, typed shipments, split movement and
|
||||
* the earlier combat casualty create every later resource and battle field. */
|
||||
export function createSalusaGholaFixture(options: {
|
||||
firstTerritory?: 'imperial_basin'|'arrakeen'; inspectionDial?: 1|2;
|
||||
firstTerritory?: 'imperial_basin'|'arrakeen'; inspectionDial?: 1|2; truthtrance?: true;
|
||||
} = {}) {
|
||||
const actor = 'emperor', atreides = 'atreides', guild = 'guild';
|
||||
let game = createGame('SALUSAGHOLA',newPlayer(actor,'Emperor','emperor'),true);
|
||||
@@ -91,9 +91,10 @@ export function createSalusaGholaFixture(options: {
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status,'setup'); assert.ok(game.players.every(p => p.hand.length === 0));
|
||||
const ghola = game.deck.find(c => c.effect === 'ghola');
|
||||
const shield = game.deck.find(c => c.kind === 'shield'), projectile = game.deck.find(c => c.kind === 'projectile');
|
||||
assert.ok(ghola && shield && projectile);
|
||||
const selected = [ghola,shield,projectile], dealOrder = selected.map((card,position) => {
|
||||
const atreidesCard = game.deck.find(c => options.truthtrance ? c.effect === 'truthtrance' : c.kind === 'shield');
|
||||
const projectile = game.deck.find(c => c.kind === 'projectile');
|
||||
assert.ok(ghola && atreidesCard && projectile);
|
||||
const selected = [ghola,atreidesCard,projectile], dealOrder = selected.map((card,position) => {
|
||||
const from = game.deck.findIndex(c => c.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position],game.deck[from]] = [game.deck[from],game.deck[position]];
|
||||
return {position,from,card:card.id};
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { PlanClaim } from '../game/battle-promises';
|
||||
import { homeworldRevivalActionBlock } from '../game/homeworld-revival-deployment-options';
|
||||
import { createSalusaGholaTruthFixture } from './fixture-salusa-ghola-truth';
|
||||
|
||||
type Fixture = ReturnType<typeof createSalusaGholaTruthFixture>;
|
||||
type Case = 'dial2' | 'dial1' | 'support';
|
||||
const sources = new Map<Case, Fixture>();
|
||||
function source(kind: Case): Fixture {
|
||||
let fixture = sources.get(kind);
|
||||
if (!fixture) {
|
||||
const claim: PlanClaim = kind === 'support'
|
||||
? { kind: 'support', compare: 'gte', value: 1 }
|
||||
: { kind: 'dial', compare: 'eq', value: kind === 'dial1' ? 1 : 2 };
|
||||
fixture = createSalusaGholaTruthFixture({ claim, answer: 'yes', inspectionDial: kind === 'dial1' ? 1 : 2 });
|
||||
sources.set(kind, fixture);
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
const profiles = ['Easy', 'Medium', 'Hard', 'Brutal'] as const;
|
||||
function ownPromise(game: Game, actor: string) {
|
||||
const promise = game.battle?.truthPromises?.find(row => row.player === actor && !row.released);
|
||||
assert.ok(promise, 'The actual answered Truthtrance promise remains live.');
|
||||
return promise;
|
||||
}
|
||||
|
||||
// These are shared control-adapter and minimal-policy checks over the earned
|
||||
// native source. No Ghola amount selector or Battle Plan form is operated here;
|
||||
// GameTable SSR is covered by the integration owner's separate runtime smoke.
|
||||
void test('the exact owned Ghola group remains available when Salusa High preserves the native Yes dial2 promise', () => {
|
||||
const f = source('dial2'), game = structuredClone(f.game), view = viewGame(game, f.actor);
|
||||
assert.deepEqual(f.action, { type: 'card', card: f.card, amount: 1, elite: 1 });
|
||||
assert.deepEqual(ownPromise(game, f.actor).claim, f.claim);
|
||||
assert.equal(homeworldRevivalActionBlock(view, f.action), null);
|
||||
assert.equal(view.ghola.available, true); assert.ok(view.ghola.cards.includes(f.card));
|
||||
assert.equal(view.ghola.maxForces, 1); assert.equal(view.ghola.eliteRemaining, 1);
|
||||
const returned = applyAction(game, f.actor, f.action);
|
||||
assert.equal(returned.battle?.event, game.battle?.event);
|
||||
assert.deepEqual(ownPromise(returned, f.actor).claim, f.claim);
|
||||
assert.equal(ownPromise(returned, f.actor).answer, true);
|
||||
assert.equal(viewGame(returned, f.actor).battle?.ownForces?.eliteFreeSupport, true);
|
||||
assert.equal(returned.discard.filter(card => card.id === f.card).length, 1);
|
||||
assert.equal(returned.players.find(player => player.id === f.actor)!.elites!.tanks, 0);
|
||||
});
|
||||
|
||||
void test('the selected Ghola group block agrees with native rejection for an odd-dial or paid-support Yes promise', () => {
|
||||
for (const kind of ['dial1', 'support'] as const) {
|
||||
const f = source(kind), game = structuredClone(f.game), view = viewGame(game, f.actor);
|
||||
const blocked = homeworldRevivalActionBlock(view, f.action);
|
||||
assert.ok(blocked, `${kind}: the actual selected return needs a visible block.`);
|
||||
assert.match(blocked, /Truthtrance|promise/i);
|
||||
assert.ok(view.homeworldRevivalBlocks?.some(row => row.source === 'ghola' &&
|
||||
row.amount === f.action.amount && row.elite === f.action.elite && row.reason === blocked));
|
||||
assert.equal(view.ghola.maxForces, 0); assert.equal(view.ghola.eliteRemaining, 0);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, f.actor, f.action), error => {
|
||||
assert.ok(error instanceof Error);
|
||||
assert.equal(error.message, blocked);
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(ownPromise(game, f.actor).answer, true);
|
||||
assert.ok(game.players.find(player => player.id === f.actor)!.hand.some(card => card.id === f.card));
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles omit blocked Ghola and retain legal required plans before and after an allowed return', () => {
|
||||
for (const kind of ['dial1', 'support', 'dial2'] as const) {
|
||||
const f = source(kind);
|
||||
const start = kind === 'dial2' ? applyAction(f.game, f.actor, f.action) : f.game;
|
||||
for (const profile of profiles) {
|
||||
const game = structuredClone(start), view = viewGame(game, f.actor);
|
||||
view.players.find(player => player.id === f.actor)!.bot = profile;
|
||||
const actions = botActions(view);
|
||||
assert.ok(actions.length, `${kind}/${profile}: current legal participation remains available.`);
|
||||
assert.ok(actions.every(action => action.type !== 'card' || action.card !== f.card),
|
||||
`${kind}/${profile}: no blocked or already spent Ghola is offered.`);
|
||||
const plans = actions.filter(action => action.type === 'battlePlan');
|
||||
assert.ok(plans.length, `${kind}/${profile}: the live promise has a legal current plan.`);
|
||||
for (const plan of plans) {
|
||||
if (kind === 'support') assert.ok(Number(plan.support) >= 1);
|
||||
else assert.equal(plan.dial, kind === 'dial1' ? 1 : 2);
|
||||
if (kind === 'dial2') assert.equal(plan.support ?? 0, 0);
|
||||
const sealed = applyAction(game, f.actor, plan);
|
||||
assert.ok(sealed.battle?.plans[f.actor]);
|
||||
assert.equal(ownPromise(sealed, f.actor).answer, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createSalusaGholaTruthFixture as fixture, actSalusaGholaTruth as act,
|
||||
salusaGholaTruthPlayer as p, salusaGholaTruthInventory as inventory,
|
||||
salusaGholaTruthPlan as plan, finishSalusaGholaTruthBattle as finish,
|
||||
type SalusaGholaTruthFixture,
|
||||
} from './fixture-salusa-ghola-truth';
|
||||
import { salusaGholaArmy as army } from './fixture-salusa-ghola';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game,actor,action));
|
||||
assert.deepEqual(game,before);
|
||||
}
|
||||
function binding(game: Game, f: SalusaGholaTruthFixture) {
|
||||
assert.deepEqual(game.battle!.truthPromises,[{player:f.actor,asker:f.opponent,
|
||||
claim:f.claim,answer:f.answer === 'yes'}]);
|
||||
assert.equal(game.discard.filter(c => c.id === f.truthCard).length,1);
|
||||
assert.equal(game.truthHistory!.at(-1)!.answer,f.answer);
|
||||
assert.deepEqual(game.truthHistory!.at(-1)!.question,f.question);
|
||||
assert.deepEqual(inventory(game),f.base.original);
|
||||
}
|
||||
function done(game: Game, f: SalusaGholaTruthFixture, gholaUsed: boolean) {
|
||||
assert.equal(game.battle,null);
|
||||
assert.equal(game.lastBattleContext!.winner,f.actor);
|
||||
assert.equal(game.discard.filter(c => c.id === f.truthCard).length,1);
|
||||
assert.equal(game.discard.filter(c => c.id === f.card).length,Number(gholaUsed));
|
||||
assert.equal(game.truthHistory!.at(-1)!.answer,f.answer);
|
||||
assert.deepEqual(game.truthHistory!.at(-1)!.question,f.question);
|
||||
assert.deepEqual(inventory(game),f.base.original);
|
||||
}
|
||||
|
||||
void test('earned native Truth Yes dial2 permits Salusa Ghola and preserves its original binding through the actual plan', () => {
|
||||
const f = fixture();
|
||||
assert.equal(p(f.base.afterSetup,f.opponent).hand[0].id,f.truthCard);
|
||||
assert.equal(f.base.beforeDeal.players.every(p => p.hand.length === 0),true);
|
||||
assert.equal(f.truthActions.filter(s => s.action.type === 'card').length,1);
|
||||
assert.equal(f.truthActions.filter(s => s.action.type === 'truthAsk').length,1);
|
||||
assert.equal(f.truthActions.filter(s => s.action.type === 'truthAnswer').length,1);
|
||||
assert.equal(f.sourceCount,f.sourceActions.length);
|
||||
assert.deepEqual(f.game.battle!.plans,{});
|
||||
binding(f.game,f);
|
||||
const game = act(structuredClone(f.game),f.actor,f.action);
|
||||
assert.deepEqual(army(game),{normal:0,elite:2});
|
||||
assert.equal(p(game).elites!.tanks,0);
|
||||
binding(game,f);
|
||||
const result = finish(f,game);
|
||||
assert.equal(result.afterPlan.battle!.plans[f.actor].dial,2);
|
||||
assert.equal(result.afterPlan.battle!.plans[f.actor].support,0);
|
||||
assert.equal(result.revealed.battle!.truthPromises![0].released,undefined);
|
||||
done(result.game,f,true);
|
||||
});
|
||||
|
||||
void test('earned native Truth No dial1 remains feasible after Salusa Ghola without releasing its aggregate answer', () => {
|
||||
const f = fixture({claim:{kind:'dial',compare:'eq',value:1},answer:'no'});
|
||||
const game = act(structuredClone(f.game),f.actor,f.action);
|
||||
assert.deepEqual(army(game),{normal:0,elite:2});
|
||||
binding(game,f);
|
||||
const result = finish(f,game,plan(game,2,0));
|
||||
assert.equal(result.revealed.battle!.truthPromises![0].answer,false);
|
||||
assert.equal(result.revealed.battle!.truthPromises![0].released,undefined);
|
||||
done(result.game,f,true);
|
||||
});
|
||||
|
||||
void test('earned native Truth Yes odd1 rejects Salusa Ghola before cost and can finish dial1 before later printed use', () => {
|
||||
const f = fixture({claim:{kind:'dial',compare:'eq',value:1}});
|
||||
binding(f.game,f);
|
||||
reject(f.game,f.actor,f.action);
|
||||
assert.deepEqual(army(f.game),{normal:0,elite:1});
|
||||
assert.equal(p(f.game).elites!.tanks,1);
|
||||
assert.ok(p(f.game).hand.some(c => c.id === f.card));
|
||||
assert.equal(f.game.discard.some(c => c.id === f.card),false);
|
||||
const result = finish(f,structuredClone(f.game),plan(f.game,1,0));
|
||||
assert.equal(result.afterPlan.battle!.plans[f.actor].dial,1);
|
||||
done(result.game,f,false);
|
||||
const returned = act(result.game,f.actor,f.action);
|
||||
assert.deepEqual(army(returned),{normal:0,elite:2});
|
||||
assert.equal(returned.discard.filter(c => c.id === f.card).length,1);
|
||||
assert.deepEqual(inventory(returned),f.base.original);
|
||||
});
|
||||
|
||||
void test('earned native Truth Yes paid support rejects Salusa Ghola while the original supported plan stays legal', () => {
|
||||
const f = fixture({claim:{kind:'support',compare:'gte',value:1}});
|
||||
binding(f.game,f);
|
||||
reject(f.game,f.actor,f.action);
|
||||
assert.deepEqual(army(f.game),{normal:0,elite:1});
|
||||
assert.equal(p(f.game).elites!.tanks,1);
|
||||
const result = finish(f,structuredClone(f.game),plan(f.game,2,1));
|
||||
assert.equal(result.afterPlan.battle!.plans[f.actor].support,1);
|
||||
assert.equal(result.afterPlan.battle!.plans[f.actor].dial,2);
|
||||
done(result.game,f,false);
|
||||
});
|
||||
|
||||
void test('earned grouped Truth Yes odd1 or even2 keeps its live even alternative after Salusa Ghola', () => {
|
||||
const f = fixture({claim:{kind:'or',terms:[
|
||||
{kind:'dial',compare:'eq',value:1},{kind:'dial',compare:'eq',value:2},
|
||||
]}});
|
||||
const game = act(structuredClone(f.game),f.actor,f.action);
|
||||
binding(game,f);
|
||||
assert.deepEqual(army(game),{normal:0,elite:2});
|
||||
const result = finish(f,game,plan(game,2,0));
|
||||
assert.deepEqual(result.revealed.battle!.truthPromises![0].claim,f.claim);
|
||||
assert.equal(result.revealed.battle!.truthPromises![0].released,undefined);
|
||||
done(result.game,f,true);
|
||||
});
|
||||
|
||||
void test('earned Prescience odd1 and native Truth Yes even2 can renew the inspection through actual Salusa Ghola', () => {
|
||||
const f = fixture({inspectionDial:1});
|
||||
assert.ok(f.base.inspection);
|
||||
assert.equal(f.base.inspection.answered.battle!.prescience!.value,1);
|
||||
assert.equal(f.game.battle!.prescience!.value,1);
|
||||
binding(f.game,f);
|
||||
let game = act(structuredClone(f.game),f.actor,f.action);
|
||||
assert.deepEqual(army(game),{normal:0,elite:2});
|
||||
binding(game,f);
|
||||
assert.equal(Object.hasOwn(game.battle!.prescience!,'value'),false);
|
||||
assert.equal(game.battle!.preparation?.kind,'prescienceAnswer');
|
||||
assert.equal(game.battle!.preparation?.owner,f.actor);
|
||||
reject(game,f.actor,{type:'prescienceAnswer',value:1});
|
||||
game = act(game,f.actor,{type:'prescienceAnswer',value:2});
|
||||
assert.equal(game.battle!.prescience!.value,2);
|
||||
binding(game,f);
|
||||
const result = finish(f,game,plan(game,2,0));
|
||||
done(result.game,f,true);
|
||||
});
|
||||
Reference in new issue
Block a user