Compose Advanced Tleilaxu skills and original foreign ghola lifecycle
This commit is contained in:
1 parent
d327d1943b
commit
d86d2088e6
18 files changed
+678
-72
No files matched your search
@@ -218,7 +218,7 @@ void test('native CHOAM Bureaucrat redirects only another payer while its native
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('Advanced native admission preserves Atreides Suk and foreign-ghola unresolved boundaries without mutation', () => {
|
||||
void test('Advanced native admission preserves Atreides Suk and rejects a forged Ix-controlled foreign ghola without mutation', () => {
|
||||
const options = { family: 'ixians' as const, skillOwner: 'atreides' as const, requestedSkill: 'suk-graduate' as const };
|
||||
let game = initializeAdvancedNativeSkillsSetup(options);
|
||||
while (game.decision?.kind === 'ixSetup') game = advancedNativeStep(game);
|
||||
@@ -230,11 +230,11 @@ void test('Advanced native admission preserves Atreides Suk and foreign-ghola un
|
||||
assert.ok(game.leaderSkills!.deck.includes('suk-graduate'));
|
||||
assert.notEqual(game.leaderSkills!.assignments.find(a => a.owner === actor)!.skill, 'suk-graduate');
|
||||
custody(game);
|
||||
// Controlled original foreign-ghola custody boundary: use an untrained foreign
|
||||
// leader, not a fabricated skill assignment or replacement deck.
|
||||
// An Ixian cannot control this original foreign disc. Legitimate native
|
||||
// Tleilaxu revival is covered separately, without fabricating assignments.
|
||||
const foreign = player(game, 'emperor').leaders.find(l => l.id !== trainer(game, player(game, 'emperor').id))!;
|
||||
foreign.gholaBy = player(game, 'ixians').id;
|
||||
reject(game, actor, { type: 'ready' }, /foreign gholas/);
|
||||
reject(game, actor, { type: 'ready' });
|
||||
});
|
||||
|
||||
void test('captured native trainer blocks replacement revival, retaining the physical skill and Tanks leader', () => {
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame,
|
||||
type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { leaders, treacheryDeck, type Card } from '../game/cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { DUKE_VIDAL_ID } from '../game/duke-vidal';
|
||||
import { newRevivalRules } from '../game/revival';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
advancedNativePlayer as player, advancedNativeStep, assertAdvancedNativeCustody,
|
||||
completedAdvancedNativeSkillsGame, createAdvancedNativeSkillBattle,
|
||||
openAdvancedNativeSkillBattle, rejectAdvancedNativeAction as reject,
|
||||
stageAdvancedNativeSkillBattle, stageAdvancedTleilaxuForeignRevival,
|
||||
} from './fixture-advanced-native-skills';
|
||||
import { completedTleilaxuSkillsGame } from './tleilaxu-skills-fixture';
|
||||
|
||||
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
const act = (game: Game, actor: string, action: Action): Game => applyAction(reload(game), actor, action);
|
||||
const wallets = (game: Game) => Object.fromEntries(game.players.map(p => [p.id, p.spice]));
|
||||
const trainer = (game: Game, actor: string) => game.leaderSkills!.assignments.find(a => a.owner === actor)!;
|
||||
const setup = () => completedAdvancedNativeSkillsGame({ family: 'tleilaxu', requestedSkill: 'warmaster' });
|
||||
|
||||
function custody(game: Game): void {
|
||||
assertAdvancedNativeCustody(game);
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index) ?? [])].map(c => c.id);
|
||||
assert.deepEqual(cards.sort(), treacheryDeck(game.expansions).map(c => c.id).sort());
|
||||
assert.deepEqual(game.players.flatMap(p => p.leaders.map(l => l.id)).sort(),
|
||||
game.players.flatMap(p => [...leaders(p.faction).map(l => l.id),
|
||||
...(game.advanced && p.faction === 'choam' ? ['choam-auditor'] : [])]).sort());
|
||||
const traitors = [...game.traitorReserve!, ...game.players.flatMap(p => p.traitors),
|
||||
...game.players.flatMap(p => (p.faceDancers ?? []).map(c => c.leader))];
|
||||
assert.deepEqual(traitors.sort(), traitorDeck(game.players, true).sort());
|
||||
assert.equal(new Set(traitors).size, traitors.length);
|
||||
}
|
||||
|
||||
function take(game: Game, actor: string, matches: (card: Card) => boolean): Card {
|
||||
const index = game.deck.findIndex(matches);
|
||||
assert.ok(index >= 0, 'Transfer a physical card from the original native deck.');
|
||||
const card = game.deck.splice(index, 1)[0];
|
||||
game.players.find(p => p.id === actor)!.hand.push(card);
|
||||
return card;
|
||||
}
|
||||
|
||||
function settleRevival(state: Game): Game {
|
||||
let game = state;
|
||||
for (let i = 0; (game.response || game.pendingRevival) && i < 100; i++)
|
||||
game = advancedNativeStep(reload(game));
|
||||
assert.equal(game.pendingRevival, null);
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
function aftermath(state: Game, stopAtFaceDance = false, stopAtCapture = false): Game {
|
||||
let game = state;
|
||||
for (let i = 0; (game.response || game.decision || game.battle || game.pendingTreacheryDiscard) && i < 100; i++) {
|
||||
if (stopAtCapture && game.decision?.kind === 'captureOffer') return game;
|
||||
if (game.decision?.kind === 'faceDance') {
|
||||
if (stopAtFaceDance) return game;
|
||||
game = act(game, game.decision.player, { type: 'decision', reveal: false });
|
||||
} else if (game.decision?.kind === 'battleCards') {
|
||||
game = act(game, game.decision.player, { type: 'decision', discard: [] });
|
||||
} else game = advancedNativeStep(reload(game));
|
||||
}
|
||||
assert.equal(game.battle, null);
|
||||
assert.equal(game.decision, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
function resolve(game: Game, actor: string, target: string): Game {
|
||||
game = act(game, actor, { type: 'traitorCall', call: false });
|
||||
return act(game, target, { type: 'traitorCall', call: false });
|
||||
}
|
||||
|
||||
/** A native weapon actually kills the original assigned trainer. No staged
|
||||
* skill return or invented earned currency is used to remove its assignment. */
|
||||
function killedNativeTrainer(): { game: Game; actor: string; leader: string } {
|
||||
let game = createAdvancedNativeSkillBattle({ family: 'tleilaxu', requestedSkill: 'warmaster' });
|
||||
const actor = player(game, 'tleilaxu').id;
|
||||
const enemy = player(game, 'emperor').id;
|
||||
const leader = trainer(game, actor).leader;
|
||||
const poison = take(game, enemy, c => c.kind === 'poison');
|
||||
const defender = player(game, 'emperor').leaders.find(l => l.id !== trainer(game, enemy).leader)!;
|
||||
game = openAdvancedNativeSkillBattle(reload(game), actor, enemy);
|
||||
game = act(game, actor, { type: 'battlePlan', dial: 0, support: 0, leader });
|
||||
game = act(game, enemy, { type: 'battlePlan', dial: 0, support: 0, leader: defender.id, weapon: poison.id });
|
||||
game = aftermath(resolve(game, actor, enemy));
|
||||
assert.equal(player(game, 'tleilaxu').leaders.find(l => l.id === leader)!.dead, true);
|
||||
assert.equal(game.leaderSkills!.assignments.some(a => a.owner === actor), false);
|
||||
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
|
||||
custody(game);
|
||||
return { game, actor, leader };
|
||||
}
|
||||
|
||||
void test('native foreign revival keeps original custody, half-price payment and no skill draw even without a trainer', () => {
|
||||
for (const hasTrainer of [true, false]) {
|
||||
const original = hasTrainer ? setup() : killedNativeTrainer().game;
|
||||
const staged = stageAdvancedTleilaxuForeignRevival(original);
|
||||
assert.deepEqual(wallets(staged.game), wallets(original));
|
||||
const before = reload(staged.game);
|
||||
const skillState = structuredClone(before.leaderSkills);
|
||||
const disc = player(before, 'emperor').leaders.find(l => l.id === staged.leader)!;
|
||||
const price = Math.ceil(disc.strength / 2);
|
||||
const game = settleRevival(act(before, staged.actor, { type: 'reviveForeignGhola', leader: disc.id }));
|
||||
const returned = player(game, 'emperor').leaders.find(l => l.id === disc.id)!;
|
||||
assert.equal(returned.dead, false);
|
||||
assert.equal(returned.gholaBy, staged.actor);
|
||||
assert.equal(player(game, 'tleilaxu').leaders.some(l => l.id === disc.id), false);
|
||||
assert.equal(game.players.flatMap(p => p.leaders).filter(l => l.id === disc.id).length, 1);
|
||||
assert.deepEqual(wallets(game), { ...wallets(before), [staged.actor]: player(before, 'tleilaxu').spice - price });
|
||||
assert.equal(player(game, 'tleilaxu').leaderRevived, true);
|
||||
assert.deepEqual(game.leaderSkills, skillState);
|
||||
assert.equal(viewGame(game, staged.actor).leaderSkills!.offer, null);
|
||||
assert.equal(game.decision, null);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
reject(game, staged.actor, { type: 'reviveForeignGhola', leader: disc.id });
|
||||
assert.deepEqual(before, staged.game, 'Revival cannot mutate its caller-owned input.');
|
||||
custody(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a separate native Warmaster gives only its normal +1 to the selected original foreign ghola', () => {
|
||||
const staged = stageAdvancedTleilaxuForeignRevival(setup());
|
||||
const revived = settleRevival(act(staged.game, staged.actor, { type: 'reviveForeignGhola', leader: staged.leader }));
|
||||
const assignment = structuredClone(trainer(revived, staged.actor));
|
||||
const foreign = player(revived, 'emperor').leaders.find(l => l.id === staged.leader)!;
|
||||
assert.equal(foreign.strength, 2);
|
||||
for (const [visible, dial, expectedWinner] of [
|
||||
[true, 0, staged.actor], [false, 0, staged.originalOwner], [true, 1, staged.originalOwner],
|
||||
] as const) {
|
||||
let game = stageAdvancedNativeSkillBattle(revived, staged.actor, staged.originalOwner);
|
||||
const worthless = take(game, staged.actor, c => c.kind === 'worthless');
|
||||
const defender = player(game, 'emperor').leaders.find(l => l.strength === 3 && !l.dead)!;
|
||||
const funds = wallets(game);
|
||||
game = openAdvancedNativeSkillBattle(reload(game), staged.actor, staged.originalOwner, !visible);
|
||||
game = act(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: foreign.id, weapon: worthless.id });
|
||||
game = act(game, staged.originalOwner, { type: 'battlePlan', dial, support: dial, leader: defender.id });
|
||||
game = aftermath(resolve(game, staged.actor, staged.originalOwner));
|
||||
// 2+1 beats/ties 3; hiding the normal skill loses. 2+1 loses to 3+1,
|
||||
// whereas incorrectly giving the trainer's selected-disc +3 would win.
|
||||
assert.equal(game.lastBattleContext!.winner, expectedWinner);
|
||||
assert.deepEqual(trainer(game, staged.actor), assignment);
|
||||
assert.equal(player(game, 'emperor').leaders.find(l => l.id === foreign.id)!.gholaBy, staged.actor);
|
||||
assert.deepEqual(wallets(game), { ...funds, [staged.originalOwner]: funds[staged.originalOwner] - dial });
|
||||
custody(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('foreign ghola death preserves the native trainer; negotiated original-owner buyback clears control exactly once', () => {
|
||||
const staged = stageAdvancedTleilaxuForeignRevival(setup());
|
||||
const revived = settleRevival(act(staged.game, staged.actor, { type: 'reviveForeignGhola', leader: staged.leader }));
|
||||
const skills = structuredClone(revived.leaderSkills);
|
||||
let game = stageAdvancedNativeSkillBattle(revived, staged.actor, staged.originalOwner);
|
||||
const poison = take(game, staged.originalOwner, c => c.kind === 'poison');
|
||||
const defender = player(game, 'emperor').leaders.find(l => !l.dead && l.id !== trainer(game, staged.originalOwner).leader)!;
|
||||
game = openAdvancedNativeSkillBattle(reload(game), staged.actor, staged.originalOwner, false);
|
||||
game = act(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: staged.leader });
|
||||
game = act(game, staged.originalOwner, { type: 'battlePlan', dial: 0, support: 0, leader: defender.id, weapon: poison.id });
|
||||
game = aftermath(resolve(game, staged.actor, staged.originalOwner));
|
||||
const dead = player(game, 'emperor').leaders.find(l => l.id === staged.leader)!;
|
||||
assert.equal(dead.dead, true);
|
||||
assert.equal(dead.gholaBy, staged.actor);
|
||||
assert.deepEqual(game.leaderSkills, skills);
|
||||
custody(game);
|
||||
// Controlled next Revival boundary, retaining actual battle deaths, cards,
|
||||
// bounty and wallets. This is not claimed to be a played phase history.
|
||||
Object.assign(game, { phase: 4, active: null, ready: [], decision: null, response: null,
|
||||
phaseOpening: null, revivalRules: newRevivalRules() });
|
||||
for (const p of game.players) p.leaderRevived = false;
|
||||
const before = reload(game), funds = wallets(game);
|
||||
reject(game, staged.originalOwner, { type: 'reviveLeader', leader: staged.leader });
|
||||
game = act(game, staged.originalOwner, { type: 'requestLeaderRevival', leader: staged.leader });
|
||||
game = act(game, staged.actor, { type: 'quoteLeaderRevival', target: staged.originalOwner, amount: 1 });
|
||||
game = settleRevival(act(game, staged.originalOwner, { type: 'acceptLeaderRevival' }));
|
||||
const ownAgain = player(game, 'emperor').leaders.find(l => l.id === staged.leader)!;
|
||||
assert.equal(ownAgain.dead, false);
|
||||
assert.equal(ownAgain.gholaBy, undefined);
|
||||
assert.deepEqual(wallets(game), { ...funds, [staged.originalOwner]: funds[staged.originalOwner] - 1,
|
||||
[staged.actor]: funds[staged.actor] + 1 });
|
||||
assert.deepEqual(game.leaderSkills, before.leaderSkills, 'Its surviving original trainer prevents a replacement draw.');
|
||||
reject(game, staged.originalOwner, { type: 'acceptLeaderRevival' });
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('own native trainer revival still offers an optional physical card after a no-skill foreign revival', () => {
|
||||
const killed = killedNativeTrainer();
|
||||
const staged = stageAdvancedTleilaxuForeignRevival(killed.game);
|
||||
const foreignRevived = settleRevival(act(staged.game, staged.actor, { type: 'reviveForeignGhola', leader: staged.leader }));
|
||||
const own = player(foreignRevived, 'tleilaxu').leaders.find(l => l.id === killed.leader)!;
|
||||
const previousDeck = [...foreignRevived.leaderSkills!.deck];
|
||||
const funds = wallets(foreignRevived);
|
||||
const offered = settleRevival(act(foreignRevived, staged.actor, { type: 'reviveLeader', leader: own.id }));
|
||||
assert.equal(offered.decision?.kind, 'leaderSkillRevival');
|
||||
const offer = offered.leaderSkills!.offers[staged.actor];
|
||||
assert.equal(offer.leader, own.id);
|
||||
assert.deepEqual(offer.cards, []);
|
||||
assert.deepEqual(offered.leaderSkills!.deck, previousDeck);
|
||||
assert.deepEqual(wallets(offered), { ...funds, [staged.actor]: funds[staged.actor] - Math.ceil(own.strength / 2) });
|
||||
assert.equal(player(offered, 'emperor').leaders.find(l => l.id === staged.leader)!.gholaBy, staged.actor);
|
||||
const declined = act(offered, staged.actor, { type: 'leaderSkill', event: offer.event, mode: 'decline' });
|
||||
assert.deepEqual(declined.leaderSkills!.deck, previousDeck);
|
||||
assert.equal(declined.leaderSkills!.offers[staged.actor], undefined);
|
||||
const drawn = act(offered, staged.actor, { type: 'leaderSkill', event: offer.event, mode: 'draw' });
|
||||
const choice = drawn.leaderSkills!.offers[staged.actor];
|
||||
assert.equal(choice.cards.length, 2);
|
||||
assert.equal(drawn.leaderSkills!.deck.length, previousDeck.length - 2);
|
||||
custody(drawn);
|
||||
reject(drawn, staged.actor, { type: 'leaderSkill', event: choice.event, skill: choice.cards[0], leader: staged.leader });
|
||||
const trained = act(drawn, staged.actor, { type: 'leaderSkill', event: choice.event, skill: choice.cards[0], leader: own.id });
|
||||
assert.equal(trainer(trained, staged.actor).leader, own.id);
|
||||
assert.equal(trainer(trained, staged.actor).skill, choice.cards[0]);
|
||||
custody(declined); custody(trained);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = reload(offered);
|
||||
for (let i = 0; game.leaderSkills!.offers[staged.actor] && i < 3; i++) {
|
||||
const view = viewGame(game, staged.actor);
|
||||
view.players.find(p => p.id === staged.actor)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
assert.ok(action, difficulty);
|
||||
game = act(game, staged.actor, action);
|
||||
}
|
||||
assert.equal(game.leaderSkills!.offers[staged.actor], undefined, difficulty);
|
||||
const assigned = game.leaderSkills!.assignments.find(a => a.owner === staged.actor);
|
||||
if (assigned) assert.equal(assigned.leader, own.id, difficulty);
|
||||
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === staged.leader), false, difficulty);
|
||||
assert.deepEqual(wallets(game), wallets(offered));
|
||||
custody(game);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Face Dance after an actual Advanced battle kills and returns the original trained card', () => {
|
||||
let game = createAdvancedNativeSkillBattle({ family: 'tleilaxu', skillOwner: 'atreides', requestedSkill: 'warmaster' });
|
||||
const actor = player(game, 'atreides').id, enemy = player(game, 'emperor').id;
|
||||
const controller = player(game, 'tleilaxu').id;
|
||||
const trained = trainer(game, actor);
|
||||
const leader = trained.leader;
|
||||
const dancers = player(game, 'tleilaxu').faceDancers!;
|
||||
if (!dancers.some(c => c.leader === leader)) {
|
||||
const card = dancers[0];
|
||||
const reserve = game.traitorReserve!.indexOf(leader);
|
||||
if (reserve >= 0) game.traitorReserve![reserve] = card.leader;
|
||||
else {
|
||||
const holder = game.players.find(p => p.traitors.includes(leader))!;
|
||||
assert.ok(holder);
|
||||
holder.traitors[holder.traitors.indexOf(leader)] = card.leader;
|
||||
}
|
||||
card.leader = leader;
|
||||
} // Conserved physical identity swap, not a fabricated extra Face Dancer.
|
||||
custody(game);
|
||||
const defender = player(game, 'emperor').leaders.find(l => l.strength === 2)!;
|
||||
game = openAdvancedNativeSkillBattle(reload(game), actor, enemy);
|
||||
game = act(game, actor, { type: 'battlePlan', dial: 1, support: 1, leader });
|
||||
game = act(game, enemy, { type: 'battlePlan', dial: 0, support: 0, leader: defender.id });
|
||||
game = aftermath(resolve(game, actor, enemy), true);
|
||||
assert.equal(game.lastBattleContext!.winner, actor);
|
||||
assert.equal(game.decision?.kind, 'faceDance');
|
||||
const funds = wallets(game), reserves = player(game, 'tleilaxu').reserves;
|
||||
game = act(game, controller, { type: 'decision', reveal: true, sources: { reserves: 1 }, sector: 14 });
|
||||
assert.equal(player(game, 'atreides').leaders.find(l => l.id === leader)!.dead, true);
|
||||
assert.equal(game.leaderSkills!.assignments.some(a => a.leader === leader), false);
|
||||
assert.equal(game.leaderSkills!.deck.filter(s => s === 'warmaster').length, 1);
|
||||
assert.equal(player(game, 'tleilaxu').forces['wind_pass:14'], 1);
|
||||
assert.equal(player(game, 'tleilaxu').reserves, reserves - 1);
|
||||
assert.deepEqual(wallets(game), funds);
|
||||
assert.equal(player(game, 'tleilaxu').faceDancers!.find(c => c.leader === leader)!.revealed, true);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('actual Harkonnen capture keeps a foreign ghola in its original roster and battle use returns Tleilaxu control', () => {
|
||||
const original = completedAdvancedNativeSkillsGame({
|
||||
family: 'tleilaxu', requestedSkill: 'warmaster', opponents: ['harkonnen', 'emperor'],
|
||||
});
|
||||
const staged = stageAdvancedTleilaxuForeignRevival(original);
|
||||
const revived = settleRevival(act(staged.game, staged.actor, { type: 'reviveForeignGhola', leader: staged.leader }));
|
||||
const captor = player(revived, 'harkonnen').id;
|
||||
const skills = structuredClone(revived.leaderSkills);
|
||||
let game = stageAdvancedNativeSkillBattle(revived, staged.actor, captor);
|
||||
// Conserved controlled eligibility: all other living Tleilaxu discs have
|
||||
// already been used elsewhere. Only the genuinely revived foreign disc is
|
||||
// eligible for this battle's random capture, without changing the card pool.
|
||||
for (const leader of player(game, 'tleilaxu').leaders)
|
||||
if (!leader.dead) leader.usedAt = 'arrakeen';
|
||||
const native = player(game, 'harkonnen').leaders.find(l => l.id !== trainer(game, captor).leader)!;
|
||||
game = openAdvancedNativeSkillBattle(reload(game), staged.actor, captor, false);
|
||||
game = act(game, staged.actor, { type: 'battlePlan', dial: 0, support: 0, leader: staged.leader });
|
||||
game = act(game, captor, { type: 'battlePlan', dial: 0, support: 0, leader: native.id });
|
||||
game = aftermath(resolve(game, staged.actor, captor), false, true);
|
||||
assert.equal(game.lastBattleContext!.winner, captor);
|
||||
assert.equal(game.decision?.kind, 'captureOffer');
|
||||
game = act(game, captor, { type: 'decision', accept: true });
|
||||
for (let i = 0; game.response && i < 100; i++) game = advancedNativeStep(reload(game));
|
||||
assert.equal(game.decision?.kind, 'capturedLeader');
|
||||
if (game.decision?.kind !== 'capturedLeader') throw new Error('Expected actual captured foreign leader.');
|
||||
assert.equal(game.decision.leader, staged.leader);
|
||||
assert.equal(game.decision.owner, staged.originalOwner);
|
||||
assert.equal(game.decision.controller, staged.actor);
|
||||
const captive = player(game, 'emperor').leaders.find(l => l.id === staged.leader)!;
|
||||
assert.equal(captive.capturedBy, captor);
|
||||
assert.equal(captive.gholaBy, staged.actor);
|
||||
assert.equal(captive.concealed!.controller, staged.actor);
|
||||
assert.deepEqual(game.leaderSkills, skills);
|
||||
custody(game);
|
||||
const choice = reload(game), funds = wallets(game);
|
||||
const executed = aftermath(act(choice, captor, { type: 'decision', mode: 'execute' }));
|
||||
const dead = player(executed, 'emperor').leaders.find(l => l.id === staged.leader)!;
|
||||
assert.equal(dead.dead, true);
|
||||
assert.equal(dead.capturedBy, undefined);
|
||||
assert.equal(dead.gholaBy, staged.actor);
|
||||
assert.equal(dead.concealed!.controller, staged.actor);
|
||||
assert.equal(dead.concealed!.dead, false, 'The original hidden living snapshot is retained after execution.');
|
||||
assert.deepEqual(wallets(executed), { ...funds, [captor]: funds[captor] + 2 });
|
||||
assert.deepEqual(executed.leaderSkills, skills);
|
||||
custody(executed);
|
||||
game = aftermath(act(choice, captor, { type: 'decision', mode: 'keep' }));
|
||||
assert.equal(player(game, 'emperor').leaders.find(l => l.id === staged.leader)!.capturedBy, captor);
|
||||
assert.deepEqual(wallets(game), funds);
|
||||
game = stageAdvancedNativeSkillBattle(game, captor, staged.originalOwner);
|
||||
const defender = player(game, 'emperor').leaders.find(l => l.strength === 3)!;
|
||||
game = openAdvancedNativeSkillBattle(reload(game), captor, staged.originalOwner, false);
|
||||
// The captured foreign disc is genuinely selectable by Harkonnen, not by
|
||||
// either its original Emperor owner or its pre-capture Tleilaxu controller.
|
||||
reject(game, staged.originalOwner, { type: 'battlePlan', dial: 0, support: 0, leader: staged.leader });
|
||||
game = act(game, captor, { type: 'battlePlan', dial: 0, support: 0, leader: staged.leader });
|
||||
game = act(game, staged.originalOwner, { type: 'battlePlan', dial: 0, support: 0, leader: defender.id });
|
||||
game = aftermath(resolve(game, captor, staged.originalOwner));
|
||||
assert.equal(game.lastBattleContext!.winner, staged.originalOwner);
|
||||
const returned = player(game, 'emperor').leaders.find(l => l.id === staged.leader)!;
|
||||
assert.equal(returned.dead, false);
|
||||
assert.equal(returned.capturedBy, undefined);
|
||||
assert.equal(returned.concealed, undefined);
|
||||
assert.equal(returned.gholaBy, staged.actor);
|
||||
assert.equal(player(game, 'tleilaxu').leaders.some(l => l.id === staged.leader), false);
|
||||
assert.deepEqual(wallets(game), funds);
|
||||
assert.deepEqual(game.leaderSkills, skills);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(game)), game);
|
||||
custody(game);
|
||||
});
|
||||
|
||||
void test('foreign revival retains Basic, controller, full-pool, capture, Auditor, Duke and foreign-training boundaries', () => {
|
||||
const staged = stageAdvancedTleilaxuForeignRevival(setup());
|
||||
const action: Action = { type: 'reviveForeignGhola', leader: staged.leader };
|
||||
reject(staged.game, staged.originalOwner, action);
|
||||
const full = reload(staged.game);
|
||||
player(full, 'tleilaxu').leaders.find(l => l.id === staged.ownDead)!.dead = false;
|
||||
reject(full, staged.actor, action);
|
||||
const captured = reload(staged.game);
|
||||
player(captured, 'emperor').leaders.find(l => l.id === staged.leader)!.capturedBy = staged.actor;
|
||||
reject(captured, staged.actor, action);
|
||||
reject(staged.game, staged.actor, { type: 'reviveForeignGhola', leader: DUKE_VIDAL_ID });
|
||||
const basic = completedTleilaxuSkillsGame();
|
||||
Object.assign(basic, { phase: 4, phaseOpening: null, response: null, decision: null });
|
||||
player(basic, 'emperor').leaders[1].dead = true;
|
||||
reject(basic, player(basic, 'tleilaxu').id, { type: 'reviveForeignGhola', leader: player(basic, 'emperor').leaders[1].id });
|
||||
const advanced = settleRevival(act(staged.game, staged.actor, action));
|
||||
const assignment = reload(advanced);
|
||||
const native = trainer(assignment, staged.actor);
|
||||
native.leader = staged.leader;
|
||||
reject(assignment, staged.actor, { type: 'ready' });
|
||||
const wrongController = reload(advanced);
|
||||
player(wrongController, 'emperor').leaders.find(l => l.id === staged.leader)!.gholaBy = staged.originalOwner;
|
||||
reject(wrongController, staged.actor, { type: 'ready' });
|
||||
const capturedGhola = reload(advanced);
|
||||
player(capturedGhola, 'emperor').leaders.find(l => l.id === staged.leader)!.capturedBy = staged.actor;
|
||||
reject(capturedGhola, staged.actor, { type: 'ready' });
|
||||
const basicGhola = reload(advanced);
|
||||
basicGhola.advanced = false;
|
||||
reject(basicGhola, staged.actor, { type: 'ready' });
|
||||
const lobby = createGame('ADVTLEIAUD', newPlayer('human-t', 'Tleilaxu', 'tleilaxu'), true, ['ix', 'choam']);
|
||||
joinGame(lobby, newPlayer('human-c', 'CHOAM', 'choam'));
|
||||
joinGame(lobby, newPlayer('human-e', 'Emperor', 'emperor'));
|
||||
const admitted = completedAdvancedNativeSkillsGame({ family: 'tleilaxu', requestedSkill: 'warmaster', initial: lobby });
|
||||
assert.deepEqual(admitted.players.map(p => p.id), ['human-t', 'human-c', 'human-e']);
|
||||
const audit = stageAdvancedTleilaxuForeignRevival(admitted);
|
||||
const auditor = player(audit.game, 'choam').leaders.find(l => l.id === 'choam-auditor')!;
|
||||
auditor.dead = true; auditor.deaths = 1;
|
||||
reject(audit.game, audit.actor, { type: 'reviveForeignGhola', leader: auditor.id });
|
||||
custody(audit.game);
|
||||
});
|
||||
@@ -262,10 +262,10 @@ void test('Moritani Skills samples preserve their full module on saved continuat
|
||||
assert.equal(existsSync(unsupported), false);
|
||||
});
|
||||
|
||||
void test('Tleilaxu Skills samples preserve their full module on saved continuation and reject Advanced before running', (t) => {
|
||||
void test('Tleilaxu Skills samples preserve full Basic and Advanced native decks through saved continuation', (t) => {
|
||||
const area = temporary(t);
|
||||
const out = join(area, 'skills');
|
||||
const result = run(out, '--profile', 'tleilaxu-skills', '--players', '2', '--seed', '1000', '--max-actions', '1');
|
||||
const result = run(out, '--profile', 'tleilaxu-skills', '--rules', 'basic', '--players', '2', '--seed', '1000', '--max-actions', '1');
|
||||
assert.equal(result.status, 1);
|
||||
assertFailedEvidence(out, 'tleilaxu-skills-2-basic', 1021, false);
|
||||
const snapshot = join(out, 'failed-tleilaxu-skills-2-basic.json');
|
||||
@@ -276,10 +276,16 @@ void test('Tleilaxu Skills samples preserve their full module on saved continuat
|
||||
const resumed = join(area, 'skills-resumed');
|
||||
assert.equal(run(resumed, '--resume', snapshot, '--seed', '1000', '--max-actions', '1').status, 1);
|
||||
assertFailedEvidence(resumed, 'tleilaxu-skills-2-basic', 1021, true);
|
||||
const unsupported = join(area, 'unsupported');
|
||||
const bad = run(unsupported, '--profile', 'tleilaxu-skills', '--rules', 'advanced');
|
||||
assert.equal(bad.status, 1);
|
||||
assert.equal(existsSync(unsupported), false);
|
||||
const advanced = join(area, 'advanced');
|
||||
assert.equal(run(advanced, '--profile', 'tleilaxu-skills', '--rules', 'advanced', '--players', '2', '--seed', '1000', '--max-actions', '1').status, 1);
|
||||
for (const [name, seed] of [['tleilaxu-skills-2-advanced', 1235], ['tleilaxu-skills-tleilaxu-ixians-2-advanced', 1240]] as const) {
|
||||
assertFailedEvidence(advanced, name, seed, false, 2);
|
||||
const state = json<typeof game>(join(advanced, `failed-${name}.json`));
|
||||
assert.equal(state.advanced, true);
|
||||
const cards = [...state.leaderSkills.deck, ...Object.values(state.leaderSkills.offers).flatMap(o => o.cards), ...state.leaderSkills.assignments.map(a => a.skill)];
|
||||
assert.equal(new Set(cards).size, 14);
|
||||
assert.equal(cards.length, 14);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Ixian Skills samples preserve full Basic and Advanced native decks through saved continuation', (t) => {
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { newRevivalRules } from '../game/revival';
|
||||
import { nextSpiceBankerIncomeNativeStep } from './fixture-spice-banker-income';
|
||||
|
||||
export type AdvancedNativeSkillsOptions = {
|
||||
family: 'ixians' | 'choam';
|
||||
family: 'ixians' | 'choam' | 'tleilaxu';
|
||||
requestedSkill?: LeaderSkillId;
|
||||
skillOwner?: FactionId;
|
||||
opponents?: readonly FactionId[];
|
||||
@@ -52,7 +53,7 @@ function withSkillShuffle<T>(skill: LeaderSkillId, ownerIndex: number, initializ
|
||||
* physical skill offers. No played save is converted and no deck is cherry-picked. */
|
||||
export function initializeAdvancedNativeSkillsSetup(options: AdvancedNativeSkillsOptions): Game {
|
||||
const ownerFaction = options.skillOwner ?? options.family;
|
||||
const expansion = options.family === 'ixians' ? 'ix' : 'choam';
|
||||
const expansion = options.family === 'choam' ? 'choam' : 'ix';
|
||||
let game: Game;
|
||||
if (options.initial) game = structuredClone(options.initial);
|
||||
else {
|
||||
@@ -144,8 +145,17 @@ export function assertAdvancedNativeCustody(game: Game): void {
|
||||
* This is not claimed to be a played turn history. */
|
||||
export function createAdvancedNativeSkillBattle(options: AdvancedNativeSkillsOptions): Game {
|
||||
const game = completedAdvancedNativeSkillsGame(options);
|
||||
const owner = advancedNativePlayer(game, options.skillOwner ?? options.family);
|
||||
const enemy = advancedNativePlayer(game, 'emperor');
|
||||
return stageAdvancedNativeSkillBattle(game,
|
||||
advancedNativePlayer(game, options.skillOwner ?? options.family).id,
|
||||
advancedNativePlayer(game, 'emperor').id);
|
||||
}
|
||||
|
||||
/** Reuse original authenticated seats and conserved inventories after revival. */
|
||||
export function stageAdvancedNativeSkillBattle(state: Game, actor: string, target: string): Game {
|
||||
const game = structuredClone(state);
|
||||
const owner = game.players.find(p => p.id === actor)!;
|
||||
const enemy = game.players.find(p => p.id === target)!;
|
||||
assert.ok(owner && enemy && owner !== enemy);
|
||||
for (const p of game.players) {
|
||||
game.deck.push(...p.hand);
|
||||
p.hand = [];
|
||||
@@ -171,11 +181,38 @@ export function createAdvancedNativeSkillBattle(options: AdvancedNativeSkillsOpt
|
||||
return game;
|
||||
}
|
||||
|
||||
export function openAdvancedNativeSkillBattle(state: Game, actor: string, target: string): Game {
|
||||
/** Explicit conserved Revival position after genuine Advanced Tleilaxu setup.
|
||||
* Only untrained original discs are staged dead; no assigned card is removed.
|
||||
* Wallets and all original card/force inventories remain untouched. */
|
||||
export function stageAdvancedTleilaxuForeignRevival(state: Game): {
|
||||
game: Game; actor: string; originalOwner: string; leader: string; ownDead: string;
|
||||
} {
|
||||
const game = structuredClone(state);
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.advanced, true);
|
||||
const owner = advancedNativePlayer(game, 'tleilaxu');
|
||||
const foreignOwner = advancedNativePlayer(game, 'emperor');
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const own = owner.leaders.find(l => !trained.has(l.id) && !l.gholaBy && !l.capturedBy)!;
|
||||
const foreign = foreignOwner.leaders.filter(l => !trained.has(l.id) && !l.capturedBy)
|
||||
.sort((a, b) => a.strength - b.strength)[0];
|
||||
assert.ok(own && foreign);
|
||||
for (const leader of [own, foreign]) {
|
||||
leader.dead = true;
|
||||
leader.deaths = Math.max(1, leader.deaths ?? 0);
|
||||
}
|
||||
Object.assign(game, { phase: 4, active: null, ready: [], decision: null, response: null,
|
||||
phaseOpening: null, revivalRules: newRevivalRules() });
|
||||
for (const p of game.players) p.leaderRevived = false;
|
||||
assertAdvancedNativeCustody(game);
|
||||
return { game, actor: owner.id, originalOwner: foreignOwner.id, leader: foreign.id, ownDead: own.id };
|
||||
}
|
||||
|
||||
export function openAdvancedNativeSkillBattle(state: Game, actor: string, target: string, hideSkills = true): Game {
|
||||
let game = applyAction(state, actor, { type: 'chooseBattle', territory: 'wind_pass', target });
|
||||
for (let i = 0; (game.response || game.decision || game.battle?.preparation) && i < 100; i++) {
|
||||
if (game.decision?.kind === 'leaderSkillVisibility') {
|
||||
game = applyAction(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: true });
|
||||
game = applyAction(game, game.decision.player, { type: 'leaderSkillVisibility', event: game.decision.event, hide: hideSkills });
|
||||
} else game = advancedNativeStep(game);
|
||||
}
|
||||
assert.equal(game.decision, null);
|
||||
|
||||
@@ -5,14 +5,12 @@ import { createElement } from 'react';
|
||||
import { applyAction, createGame, initializeLeaderSkillsGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { basicTleilaxuLeaderSkillsProfile } from '../game/leader-skill-profile';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { registerHooks } from 'node:module';
|
||||
const aliases = registerHooks({resolve(specifier,context,next){return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier,context);}});
|
||||
const { RihaniChoice } = await import('../components/rihani-decipherer');
|
||||
aliases.deregister();
|
||||
import { sampleInventory, verifySampleCustody } from '../tools/sample-custody';
|
||||
import { completedTleilaxuSkillsGame, initializedTleilaxuSkillsOffers, assertTleilaxuSkillsCustody as custody, tleilaxuSkillsPlayer as player, reloadTleilaxuSkillsGame as reload, rejectTleilaxuSkillsAction as reject } from './tleilaxu-skills-fixture';
|
||||
import { completedTleilaxuSkillsGame, assertTleilaxuSkillsCustody as custody, tleilaxuSkillsPlayer as player, reloadTleilaxuSkillsGame as reload, rejectTleilaxuSkillsAction as reject } from './tleilaxu-skills-fixture';
|
||||
|
||||
const act = (g: Game, owner: string, action: Action) => applyAction(reload(g), owner, action);
|
||||
function stable(g: Game) {
|
||||
@@ -56,7 +54,7 @@ function exchange(g: Game, owner: string) {
|
||||
return act(g, owner, { type: 'decision', event, cards: [r.drawn[0], r.eligible[0]] });
|
||||
}
|
||||
|
||||
void test('all fourteen skills admit genuine Basic Tleilaxu setup while public, Advanced, Richese and module gates remain closed', () => {
|
||||
void test('all fourteen skills admit genuine Basic Tleilaxu setup while public, Richese and module gates remain closed', () => {
|
||||
for (const card of LEADER_SKILL_CARDS) {
|
||||
const game = completedTleilaxuSkillsGame({ requestedSkill: card.id });
|
||||
assert.equal(game.leaderSkills!.assignments.find(a => a.owner === 't')!.skill, card.id);
|
||||
@@ -64,7 +62,7 @@ void test('all fourteen skills admit genuine Basic Tleilaxu setup while public,
|
||||
assert.equal(basicTleilaxuLeaderSkillsProfile(viewGame(game, 'a')), true);
|
||||
stable(game);
|
||||
}
|
||||
for (const mutate of [(g: Game) => {g.advanced = true;}, (g: Game) => {g.players[1] = newPlayer('x','Richese','richese');},
|
||||
for (const mutate of [(g: Game) => {g.players[1] = newPlayer('x','Richese','richese');},
|
||||
(g: Game) => {g.discoveryEnabled = true;}, (g: Game) => {g.expansions.push('choam');}]) {
|
||||
const g = createGame('TLEIGATE',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
|
||||
joinGame(g,newPlayer('e','Emperor','emperor'));g.players.forEach(p => {p.ready = true;});mutate(g);
|
||||
@@ -72,11 +70,6 @@ void test('all fourteen skills admit genuine Basic Tleilaxu setup while public,
|
||||
}
|
||||
const lobby = createGame('TLEIPUBL',newPlayer('t','Tleilaxu','tleilaxu'),false,['ix']);
|
||||
joinGame(lobby,newPlayer('e','Emperor','emperor'));lobby.players.forEach(p => {p.ready = true;});reject(lobby,'t',{type:'start'});
|
||||
const offer = initializedTleilaxuSkillsOffers({requestedSkill:'rihani-decipherer'});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(offer,'t');view.players.find(p => p.id === 't')!.bot = difficulty;
|
||||
const action = botActions(view).find(a => a.type === 'leaderSkill');assert.ok(action);assert.doesNotThrow(() => act(offer,'t',action));
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Rihani keeps a private Face Dancer, returns only an old unrevealed card and survives JSON across every choice', () => {
|
||||
@@ -99,10 +92,6 @@ void test('native Rihani keeps a private Face Dancer, returns only an old unreve
|
||||
assert.equal(viewGame(g,observer).rihani!.pending?.drawn,undefined);
|
||||
}
|
||||
reject(g,'t',{type:'decision',event:drawn.event,cards:[drawn.drawn[0],known]});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(g,'t');view.players.find(p => p.id === 't')!.bot = difficulty;
|
||||
const action=botActions(view).find(a=>a.type==='decision');assert.ok(action);assert.doesNotThrow(()=>act(g,'t',action));
|
||||
}
|
||||
g=act(g,'t',{type:'decision',event:drawn.event,cards:[drawn.drawn[0],drawn.eligible[0]]});
|
||||
assert.equal(player(g,'t').faceDancers!.length,3);
|
||||
assert.ok(player(g,'t').faceDancers!.some(c=>c.leader===known&&c.revealed));
|
||||
|
||||
Reference in new issue
Block a user