Compose Advanced Leader Skills and Strongholds with optional Tech
This commit is contained in:
1 parent
3c7725c3a5
commit
f259488a9a
19 files changed
+1570
-54
No files matched your search
@@ -0,0 +1,295 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { strongholdControllers } from '../game/stronghold-cards';
|
||||
import { ownedTech, TECH_TOKENS } from '../game/tech-tokens';
|
||||
import type { BattleResolutionQuote } from '../game/battle-resolution-quote';
|
||||
import type { SukRescueOption } from '../game/suk-graduate';
|
||||
import {
|
||||
classicSkillsStrongholdPlayer as player, createClassicSkillsStrongholdFixture,
|
||||
finishClassicSkillsStrongholdBattle, nextClassicSkillsStrongholdStep,
|
||||
prepareClassicSkillsStrongholdBattle, quoteClassicSkillsStrongholdBattle,
|
||||
revealClassicSkillsStrongholdBattle, type ClassicSkillsStrongholdFixture,
|
||||
} from './fixture-classic-skills-stronghold';
|
||||
|
||||
function custody(game: Game): void {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.auction?.cards.slice(game.auction.index) ?? []), ...game.players.flatMap(seat => seat.hand)];
|
||||
assert.deepEqual(cards.map(card => card.id).sort(), treacheryDeck([]).map(card => card.id).sort());
|
||||
const skills = game.leaderSkills!;
|
||||
assert.deepEqual([...skills.deck, ...skills.assignments.map(assignment => assignment.skill),
|
||||
...Object.values(skills.offers).flatMap(offer => offer.cards)].sort(), LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||
for (const seat of game.players) {
|
||||
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((sum, count) => sum + count, 0), 20);
|
||||
if (seat.elites) {
|
||||
const total = seat.faction === 'fremen' ? 3 : 5;
|
||||
assert.equal(seat.elites.reserves + seat.elites.tanks + Object.values(seat.elites.forces).reduce((sum, count) => sum + count, 0), total);
|
||||
for (const [key, count] of Object.entries(seat.elites.forces)) assert.ok(count <= (seat.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
function bonus(quote: BattleResolutionQuote, source: Game, actor: string) {
|
||||
return source.battle!.attacker === actor ? quote.leaderSkillBonuses.attacker : quote.leaderSkillBonuses.defender;
|
||||
}
|
||||
function moneyBeforeCleanup(fixture: ClassicSkillsStrongholdFixture, revealed: Game, cleanup: Game) {
|
||||
assert.equal(cleanup.decision?.kind, 'battleCards');
|
||||
const quote = quoteClassicSkillsStrongholdBattle(revealed);
|
||||
for (const seat of revealed.players) {
|
||||
const debit = quote.payments.filter(payment => payment.player === seat.id).reduce((sum, payment) => sum + payment.ownPayment, 0);
|
||||
const income = quote.strongholdIncome.filter(receipt => receipt.player === seat.id).reduce((sum, receipt) => sum + receipt.amount, 0);
|
||||
const bounty = quote.bounty?.player === seat.id ? quote.bounty.amount : 0;
|
||||
assert.equal(player(cleanup, seat.id).spice, seat.spice - debit + income + bounty,
|
||||
'Actual own debit, Stronghold bank income and original killed-leader bounty settle before Collection');
|
||||
}
|
||||
assert.equal(cleanup.lastBattleContext!.winner, quote.winner);
|
||||
assert.deepEqual(cleanup.strongholdCards, fixture.afterFirstMentat.strongholdCards,
|
||||
'Battle casualties do not steal retained cards before the next END Mentat');
|
||||
custody(cleanup);
|
||||
return quote;
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action): void {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('original Advanced classic two-through-six-seat inventories survive real setup and first END Mentat custody without redeal', () => {
|
||||
for (let count = 2; count <= 6; count++) {
|
||||
const fixture = createClassicSkillsStrongholdFixture({ seatIds: Array.from({ length: count }, (_, index) => `classic-${count}-${index}`), optionalTech: count >= 3 });
|
||||
custody(fixture.initial); custody(fixture.afterSetup); custody(fixture.beforeBattle);
|
||||
const settled = applyAction(fixture.beforeFirstMentat, fixture.firstMentatStep.actor, fixture.firstMentatStep.action);
|
||||
assert.equal(settled.turn, 2);
|
||||
assert.equal(settled.strongholdCards!.owners.arrakeen, fixture.owner);
|
||||
assert.deepEqual(settled.strongholdCards!.owners, strongholdControllers(fixture.beforeFirstMentat.players, false));
|
||||
assert.deepEqual(settled.strongholdCards, fixture.afterFirstMentat.strongholdCards);
|
||||
assert.deepEqual(fixture.beforeBattle.players.map(seat => seat.id), fixture.initial.players.map(seat => seat.id));
|
||||
if (count >= 3) {
|
||||
assert.deepEqual(TECH_TOKENS.map(token => fixture.beforeBattle.techTokens![token.id].owner),
|
||||
TECH_TOKENS.map(token => fixture.afterFirstMentat.techTokens![token.id].owner));
|
||||
assert.ok(TECH_TOKENS.every(token => fixture.beforeBattle.players.some(seat => seat.id === fixture.beforeBattle.techTokens![token.id].owner)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('a fresh authenticated original lobby retains human IDs and input state while composing Strongholds, all fourteen Skills and optional Tech', () => {
|
||||
let lobby = createGame('AUTHCLASSIC', newPlayer('human-emperor', 'Emperor', 'emperor'), true);
|
||||
joinGame(lobby, newPlayer('human-guild', 'Guild', 'guild'));
|
||||
joinGame(lobby, newPlayer('human-atreides', 'Atreides', 'atreides'));
|
||||
lobby = applyAction(lobby, lobby.host, { type: 'strongholdCards', enabled: true });
|
||||
lobby = applyAction(lobby, lobby.host, { type: 'techTokens', enabled: true });
|
||||
const original = structuredClone(lobby);
|
||||
const fixture = createClassicSkillsStrongholdFixture({ initial: lobby, optionalTech: true, bankerIncome: true, ownerDial: 6, support: 6 });
|
||||
assert.deepEqual(lobby, original);
|
||||
assert.deepEqual(fixture.initial.players.map(seat => seat.id), original.players.map(seat => seat.id));
|
||||
assert.equal(fixture.owner, 'human-guild');
|
||||
assert.equal(fixture.banker, 'human-atreides');
|
||||
assert.equal(fixture.afterFirstMentat.strongholdCards!.owners.arrakeen, 'human-guild');
|
||||
const rescue = finishClassicSkillsStrongholdBattle(revealClassicSkillsStrongholdBattle(fixture), { stop: 'skill' });
|
||||
assert.equal(rescue.decision?.kind, 'sukRescue');
|
||||
assert.deepEqual(viewGame(rescue, 'human-atreides').spiceBankerIncome!.deferred, [{ owner: 'human-atreides', amount: 1 }]);
|
||||
custody(rescue);
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{ support: 0, dial: 4, own: 0, casualties: 8 },
|
||||
{ support: 2, dial: 4, own: 0, casualties: 6 },
|
||||
{ support: 5, dial: 6, own: 3, casualties: 7 },
|
||||
{ support: 6, dial: 6, own: 4, casualties: 6 },
|
||||
]) {
|
||||
void test(`Arrakeen original support ${scenario.support}: actual own ${scenario.own}, not bank-funded support, controls Banker while trained Suk rescues physical losses`, () => {
|
||||
const fixture = createClassicSkillsStrongholdFixture({ bankerIncome: true, skill: 'suk-graduate', band: 'skilled',
|
||||
ownerDial: scenario.dial, support: scenario.support, ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' });
|
||||
const revealed = revealClassicSkillsStrongholdBattle(fixture);
|
||||
const quote = quoteClassicSkillsStrongholdBattle(revealed);
|
||||
const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!;
|
||||
assert.equal(payment.bankSupport, Math.min(2, scenario.support));
|
||||
assert.equal(payment.ownPayment, scenario.own);
|
||||
assert.equal(quote.casualties!.options[0].normal, scenario.casualties);
|
||||
assert.equal(quote.sukGraduate?.mode, 'skilled');
|
||||
const before = player(revealed, fixture.owner);
|
||||
const bankerBalance = player(revealed, fixture.banker!).spice;
|
||||
let game = finishClassicSkillsStrongholdBattle(revealed, { stop: 'skill' });
|
||||
const decision = game.decision; assert.ok(decision?.kind === 'sukRescue');
|
||||
assert.equal(player(game, fixture.owner).spice, before.spice - scenario.own + 3,
|
||||
'The bank share is not spendable currency and rescue does not refund originally committed support');
|
||||
assert.equal(player(game, fixture.banker!).spice, bankerBalance);
|
||||
assert.deepEqual(viewGame(game, fixture.banker!).spiceBankerIncome!.deferred,
|
||||
scenario.own === 4 ? [{ owner: fixture.banker!, amount: 1 }] : []);
|
||||
const source = game.spiceBankerIncome!.sources.find(receipt => receipt.source.kind === 'battle-support' && receipt.source.bankLegs.some(leg => leg.payer === fixture.owner));
|
||||
if (scenario.own) assert.deepEqual(source!.source.bankLegs, [{ payer: fixture.owner, amount: scenario.own }]);
|
||||
else assert.equal(source, undefined, 'Zero actual payer debit produces no qualifying bank source');
|
||||
const choice = decision.options.findIndex(option => option.normal === 3 && option.elite === 0 && option.kept?.key === fixture.key);
|
||||
assert.ok(choice >= 0);
|
||||
const action: Action = { type: 'decision', event: decision.event, choice };
|
||||
reject(game, fixture.opponent, action);
|
||||
reject(game, fixture.owner, { ...action, event: `${decision.event}-old` });
|
||||
game = applyAction(game, fixture.owner, action);
|
||||
assert.equal(player(game, fixture.owner).forces[fixture.key], 8 - scenario.casualties + 1);
|
||||
assert.equal(player(game, fixture.owner).reserves, before.reserves + 2);
|
||||
assert.equal(player(game, fixture.owner).tanks, before.tanks + scenario.casualties - 3);
|
||||
assert.equal(player(game, fixture.owner).battleLosses, before.battleLosses + scenario.casualties - 3);
|
||||
moneyBeforeCleanup(fixture, revealed, game);
|
||||
reject(game, fixture.owner, action);
|
||||
});
|
||||
}
|
||||
|
||||
void test('four real policies physically choose original Suk rescue before winner-card cleanup and the actual losing-owner Tech reward', () => {
|
||||
const fixture = createClassicSkillsStrongholdFixture({ optionalTech: true, bankerIncome: true, skill: 'suk-graduate', band: 'skilled',
|
||||
ownerDial: 6, support: 6, ownerWeapon: 'projectile', ownerDefense: 'shield', opponentWeapon: 'projectile' });
|
||||
const revealed = revealClassicSkillsStrongholdBattle(fixture);
|
||||
const originalTokens = ownedTech(revealed.techTokens, fixture.opponent);
|
||||
assert.equal(originalTokens.length, 1, 'Original three-seat assignment supplies a real losing-owner token');
|
||||
const originalOwner = player(revealed, fixture.owner);
|
||||
const originalEnemy = player(revealed, fixture.opponent);
|
||||
const window = finishClassicSkillsStrongholdBattle(revealed, { stop: 'skill' });
|
||||
assert.deepEqual(window.techTokens, revealed.techTokens);
|
||||
assert.equal(player(window, fixture.opponent).tanks, originalEnemy.tanks + 8);
|
||||
assert.equal(player(window, fixture.opponent).leaders.find(leader => leader.id === fixture.planActions[1].action.leader)!.dead, true);
|
||||
assert.ok(window.discard.some(card => card.id === fixture.planActions[1].action.weapon), 'Loser played-card cleanup precedes winner rescue');
|
||||
assert.ok(window.decision?.kind === 'sukRescue');
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(window, fixture.owner);
|
||||
view.players.find(seat => seat.id === fixture.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(candidate => candidate.type === 'decision');
|
||||
assert.ok(action && typeof action.choice === 'number');
|
||||
const rescue: SukRescueOption = window.decision.options[action.choice];
|
||||
assert.equal(rescue.normal, 3); assert.equal(rescue.elite, 0); assert.equal(rescue.kept?.key, fixture.key);
|
||||
const cleanup = applyAction(window, fixture.owner, action);
|
||||
assert.equal(player(cleanup, fixture.owner).forces[fixture.key], 3);
|
||||
assert.equal(player(cleanup, fixture.owner).reserves, originalOwner.reserves + 2);
|
||||
assert.equal(player(cleanup, fixture.owner).tanks, originalOwner.tanks + 3);
|
||||
assert.equal(player(cleanup, fixture.owner).spice, originalOwner.spice - 4 + 3);
|
||||
assert.equal(cleanup.decision?.kind, 'battleCards');
|
||||
assert.deepEqual(cleanup.techTokens, revealed.techTokens, 'The original reward waits for actual winner cleanup');
|
||||
const resolved = finishClassicSkillsStrongholdBattle(cleanup);
|
||||
assert.equal(resolved.techTokens![originalTokens[0]].owner, fixture.owner);
|
||||
assert.deepEqual(ownedTech(resolved.techTokens, fixture.opponent), []);
|
||||
assert.deepEqual(TECH_TOKENS.filter(token => resolved.techTokens![token.id].owner !== revealed.techTokens![token.id].owner).map(token => token.id), originalTokens);
|
||||
assert.equal(resolved.lastBattleContext!.winner, fixture.owner);
|
||||
assert.equal(resolved.lastBattleContext!.sukRescue!.completed, true);
|
||||
custody(resolved);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Carthag actual non-poison Shield saves the normal Prana Bindu-trained disc, whose matching physical defense bonus decides the win', () => {
|
||||
const fixture = createClassicSkillsStrongholdFixture({ kind: 'carthag', skill: 'prana-bindu-adept', band: 'normal',
|
||||
ownerDefense: 'shield', opponentWeapon: 'poison', ownerDial: 0.5, opponentDial: 1, opponentSupport: 1 });
|
||||
const revealed = revealClassicSkillsStrongholdBattle(fixture);
|
||||
const cleanup = finishClassicSkillsStrongholdBattle(revealed, { stop: 'cleanup' });
|
||||
const quote = moneyBeforeCleanup(fixture, revealed, cleanup);
|
||||
assert.deepEqual(bonus(quote, revealed, fixture.owner), { bonus: 1, applied: [{ skill: 'prana-bindu-adept', amount: 1, mode: 'normal' }] });
|
||||
assert.equal(quote.effects!.defenderDead, false);
|
||||
assert.equal(quote.leaderDeaths.defender, false);
|
||||
assert.equal(player(cleanup, fixture.owner).leaders.find(leader => leader.id === fixture.planActions[0].action.leader)!.dead, false);
|
||||
assert.equal(quote.winner, fixture.owner);
|
||||
assert.equal(quote.scores!.defender, 4.5);
|
||||
assert.equal(quote.scores!.attacker, 4);
|
||||
const unclaimed = createClassicSkillsStrongholdFixture({ kind: 'carthag', cardHolder: 'unclaimed', skill: 'prana-bindu-adept', band: 'normal',
|
||||
ownerDefense: 'shield', opponentWeapon: 'poison', ownerDial: 0.5, opponentDial: 1, opponentSupport: 1 });
|
||||
const poisoned = finishClassicSkillsStrongholdBattle(revealClassicSkillsStrongholdBattle(unclaimed), { stop: 'cleanup' });
|
||||
assert.equal(player(poisoned, unclaimed.owner).leaders.find(leader => leader.id === unclaimed.planActions[0].action.leader)!.dead, true);
|
||||
assert.equal(poisoned.lastBattleContext!.winner, unclaimed.opponent);
|
||||
});
|
||||
|
||||
void test('Habbanya holder-only priority resolves the tie created by the actual normal Warmaster Worthless modifier, never a lower score', () => {
|
||||
for (const holder of ['owner', 'unclaimed'] as const) {
|
||||
const fixture = createClassicSkillsStrongholdFixture({ kind: 'habbanya_ridge_sietch', cardHolder: holder,
|
||||
skill: 'warmaster', band: 'normal', ownerWeapon: 'worthless', ownerDefense: 'shield', opponentDefense: 'shield', opponentDial: 1 });
|
||||
const revealed = revealClassicSkillsStrongholdBattle(fixture);
|
||||
assert.equal(revealed.battle!.defender, fixture.owner);
|
||||
const cleanup = finishClassicSkillsStrongholdBattle(revealed, { stop: 'cleanup' });
|
||||
const quote = moneyBeforeCleanup(fixture, revealed, cleanup);
|
||||
assert.equal(bonus(quote, revealed, fixture.owner).bonus, 1);
|
||||
assert.deepEqual(quote.scores, { attacker: 4, defender: 4 });
|
||||
assert.equal(quote.winner, holder === 'owner' ? fixture.owner : fixture.opponent);
|
||||
if (holder === 'owner') {
|
||||
let lower = prepareClassicSkillsStrongholdBattle(fixture);
|
||||
lower = applyAction(lower, fixture.owner, { ...fixture.planActions[0].action, weapon: null });
|
||||
lower = applyAction(lower, fixture.opponent, fixture.planActions[1].action);
|
||||
const loser = finishClassicSkillsStrongholdBattle(lower, { stop: 'cleanup' });
|
||||
assert.equal(loser.lastBattleContext!.winner, fixture.opponent, 'Retained Habbanya custody does not turn a Warmaster-less lower total into a tie');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Sietch Tabr pays the actual opposing declared dial only after the surviving skilled Mentat bonus makes its holder the winner', () => {
|
||||
const fixture = createClassicSkillsStrongholdFixture({ kind: 'sietch_tabr', skill: 'mentat', band: 'skilled',
|
||||
ownerDial: 0.5, opponentDial: 2, opponentSupport: 2, opponentLeaderStrength: 5, ownerDefense: 'shield', opponentDefense: 'shield' });
|
||||
const revealed = revealClassicSkillsStrongholdBattle(fixture);
|
||||
const cleanup = finishClassicSkillsStrongholdBattle(revealed, { stop: 'cleanup' });
|
||||
const quote = moneyBeforeCleanup(fixture, revealed, cleanup);
|
||||
assert.deepEqual(bonus(quote, revealed, fixture.owner), { bonus: 2, applied: [{ skill: 'mentat', amount: 2, mode: 'skilled' }] });
|
||||
assert.deepEqual(quote.scores, { attacker: 7, defender: 7.5 });
|
||||
assert.equal(quote.winner, fixture.owner);
|
||||
assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: 2 }]);
|
||||
let normal = prepareClassicSkillsStrongholdBattle(fixture);
|
||||
const otherDisc = player(normal, fixture.owner).leaders.find(leader => leader.strength === 3)!;
|
||||
normal = applyAction(normal, fixture.owner, { ...fixture.planActions[0].action, leader: otherDisc.id });
|
||||
normal = applyAction(normal, fixture.opponent, fixture.planActions[1].action);
|
||||
const lost = finishClassicSkillsStrongholdBattle(normal, { stop: 'cleanup' });
|
||||
assert.equal(lost.lastBattleContext!.winner, fixture.opponent);
|
||||
assert.equal(player(lost, fixture.owner).spice, player(normal, fixture.owner).spice,
|
||||
'The holder gets no Tabr income on the actual loss without its trained Mentat total');
|
||||
});
|
||||
|
||||
void test('Tuek pays each actually played Worthless card on both Warmaster-modified wins and losses, but mutual traitors exclude income and comparison', () => {
|
||||
for (const losing of [false, true]) {
|
||||
const fixture = createClassicSkillsStrongholdFixture({ kind: 'tueks_sietch', skill: 'warmaster', band: 'normal',
|
||||
ownerWeapon: 'worthless', ownerDefense: losing ? 'worthless' : 'shield', opponentDefense: 'shield',
|
||||
opponentDial: losing ? 2 : 0, opponentSupport: losing ? 2 : 0 });
|
||||
const revealed = revealClassicSkillsStrongholdBattle(fixture);
|
||||
const cleanup = finishClassicSkillsStrongholdBattle(revealed, { stop: 'cleanup' });
|
||||
const quote = moneyBeforeCleanup(fixture, revealed, cleanup);
|
||||
assert.deepEqual(bonus(quote, revealed, fixture.owner), { bonus: 1, applied: [{ skill: 'warmaster', amount: 1, mode: 'normal' }] });
|
||||
assert.equal(quote.winner, losing ? fixture.opponent : fixture.owner);
|
||||
assert.deepEqual(quote.strongholdIncome, [{ player: fixture.owner, amount: losing ? 4 : 2 }]);
|
||||
if (losing) for (const slot of ['weapon', 'defense'] as const)
|
||||
assert.ok(cleanup.discard.some(card => card.id === fixture.planActions[0].action[slot]), 'Loss physically discards each paid Worthless card');
|
||||
}
|
||||
const mutual = createClassicSkillsStrongholdFixture({ kind: 'tueks_sietch', skill: 'warmaster', band: 'normal',
|
||||
ownerWeapon: 'worthless', ownerDefense: 'worthless', traitors: 'both', ownerDial: 1, support: 1 });
|
||||
const revealed = revealClassicSkillsStrongholdBattle(mutual);
|
||||
const ended = finishClassicSkillsStrongholdBattle(revealed, { callers: [mutual.owner, mutual.opponent] });
|
||||
assert.equal(ended.lastBattleContext!.winner, null);
|
||||
assert.equal(player(ended, mutual.owner).spice, player(revealed, mutual.owner).spice - Number(mutual.planActions[0].action.support),
|
||||
'Mutual traitors retain the original support debit and earn neither played-Worthless income nor killed-leader bounty');
|
||||
assert.equal(player(ended, mutual.owner).forces[mutual.key] ?? 0, 0);
|
||||
assert.equal(player(ended, mutual.opponent).forces[mutual.key] ?? 0, 0);
|
||||
for (const slot of ['weapon', 'defense'] as const)
|
||||
assert.ok(ended.discard.some(card => card.id === mutual.planActions[0].action[slot]));
|
||||
custody(ended);
|
||||
});
|
||||
|
||||
void test('four legal own-seat policies seal an affordable original Arrakeen plan without spending the bank contribution or a future Suk/Tech reward', () => {
|
||||
const fixture = createClassicSkillsStrongholdFixture({ kind: 'arrakeen', skill: 'suk-graduate', band: 'skilled', optionalTech: true });
|
||||
let game = prepareClassicSkillsStrongholdBattle(fixture);
|
||||
const gift = Math.max(1, player(game, fixture.owner).spice - 5);
|
||||
game = applyAction(game, fixture.owner, { type: 'bribe', target: fixture.opponent, amount: gift });
|
||||
const balance = player(game, fixture.owner).spice;
|
||||
reject(game, fixture.owner, { ...fixture.planActions[0].action, dial: 8, support: 8 });
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
const view = viewGame(game, fixture.owner);
|
||||
view.players.find(seat => seat.id === fixture.owner)!.bot = difficulty;
|
||||
const action = botActions(view).find(candidate => candidate.type === 'battlePlan');
|
||||
assert.ok(action);
|
||||
const sealed = applyAction(game, fixture.owner, action);
|
||||
assert.equal(player(sealed, fixture.owner).spice, balance, 'Native plans commit support but debit only on resolution');
|
||||
assert.deepEqual(sealed.techTokens, game.techTokens);
|
||||
let revealed = sealed;
|
||||
while (revealed.response || revealed.phaseOpening || revealed.decision?.kind === 'fullPlanOffer') {
|
||||
const next = nextClassicSkillsStrongholdStep(revealed); assert.ok(next);
|
||||
revealed = applyAction(revealed, next.actor, next.action);
|
||||
}
|
||||
revealed = applyAction(revealed, fixture.opponent, fixture.planActions[1].action);
|
||||
const quote = quoteClassicSkillsStrongholdBattle(revealed);
|
||||
const payment = quote.payments.find(receipt => receipt.player === fixture.owner)!;
|
||||
assert.equal(payment.bankSupport, Math.min(2, Number(action.support ?? 0)));
|
||||
assert.equal(payment.ownPayment, Math.max(0, Number(action.support ?? 0) - 2));
|
||||
assert.ok(payment.ownPayment <= balance);
|
||||
const ended = finishClassicSkillsStrongholdBattle(revealed);
|
||||
assert.equal(ended.lastBattleContext!.winner, quote.winner);
|
||||
custody(ended);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, handLimit, initializeLeaderSkillsGameForAudit,
|
||||
initializeSpiceBankerIncomeGameForAudit, joinGame, newPlayer, viewGame,
|
||||
type Action, type Game, type Player,
|
||||
} from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import type { Card } from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
|
||||
import { leaderSkillStrongholdCount } from '../game/leader-skill-battle-board';
|
||||
import { createStrongholdCards, strongholdControllers, type StrongholdId } from '../game/stronghold-cards';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { quoteBattleResolution, type ResolutionCombatant } from '../game/battle-resolution-quote';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type ClassicSkillsStrongholdStep = { actor: string; action: Action };
|
||||
export type ClassicSkillsStrongholdOptions = {
|
||||
/** Fresh authenticated Advanced classic lobby; original IDs/order survive. */
|
||||
initial?: Game;
|
||||
seatIds?: string[];
|
||||
optionalTech?: boolean;
|
||||
bankerIncome?: boolean;
|
||||
kind?: Exclude<StrongholdId, 'hidden_mobile_stronghold'>;
|
||||
skill?: LeaderSkillId;
|
||||
band?: 'normal' | 'skilled';
|
||||
ownerFaction?: FactionId;
|
||||
opponentFaction?: FactionId;
|
||||
cardHolder?: 'owner' | 'opponent' | 'unclaimed';
|
||||
ownerDial?: number;
|
||||
support?: number;
|
||||
opponentDial?: number;
|
||||
opponentSupport?: number;
|
||||
ownerLeaderStrength?: number;
|
||||
opponentLeaderStrength?: number;
|
||||
ownerWeapon?: Card['kind'];
|
||||
ownerDefense?: Card['kind'];
|
||||
opponentWeapon?: Card['kind'];
|
||||
opponentDefense?: Card['kind'];
|
||||
traitors?: 'owner' | 'both';
|
||||
};
|
||||
export type ClassicSkillsStrongholdFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeFirstMentat: Game;
|
||||
firstMentatStep: ClassicSkillsStrongholdStep;
|
||||
afterFirstMentat: Game;
|
||||
beforeBattle: Game;
|
||||
battleAction: Action;
|
||||
game: Game;
|
||||
actor: string;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
banker?: string;
|
||||
trainer: string;
|
||||
kind: StrongholdId;
|
||||
key: string;
|
||||
skill: LeaderSkillId;
|
||||
band: 'normal' | 'skilled';
|
||||
planActions: ClassicSkillsStrongholdStep[];
|
||||
staging: string[];
|
||||
};
|
||||
|
||||
export function classicSkillsStrongholdPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(p => p.id === id);
|
||||
assert.ok(player, `Missing original classic seat ${id}`);
|
||||
return player;
|
||||
}
|
||||
|
||||
/** Control only the original fourteen-card shuffle, before any native deal. */
|
||||
function withOffers<T>(selected: LeaderSkillId[], initialize: () => T): T {
|
||||
assert.equal(new Set(selected).size, selected.length);
|
||||
const working: LeaderSkillId[] = LEADER_SKILL_CARDS.map(card => card.id);
|
||||
const remainder = working.filter(skill => !selected.includes(skill));
|
||||
const desired = selected.flatMap(skill => [skill, remainder.shift()!]);
|
||||
desired.push(...remainder);
|
||||
const 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) * 0x100000000 / (i + 1)));
|
||||
[working[i], working[j]] = [working[j], working[i]];
|
||||
}
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const native = 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(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return initialize(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** The existing native policy owns readiness and projected response passes.
|
||||
* In particular, a phase opening must close before a response can be passed. */
|
||||
export function nextClassicSkillsStrongholdStep(game: Game): ClassicSkillsStrongholdStep | null {
|
||||
if (!game.phaseOpening && !game.response) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'leaderSkillVisibility')
|
||||
return { actor: decision.player, action: { type: 'leaderSkillVisibility', event: decision.event, hide: false } };
|
||||
if (decision?.kind === 'mentatQuestion')
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, decline: true } };
|
||||
if (decision?.kind === 'techToken')
|
||||
return { actor: decision.player, action: { type: 'decision', token: decision.choices[0] } };
|
||||
if (decision?.kind === 'sukRescue') {
|
||||
const choice = decision.options.map((option, index) => ({ index, saved: option.normal + option.elite, kept: !!option.kept }))
|
||||
.sort((a, b) => b.saved - a.saved || Number(b.kept) - Number(a.kept))[0].index;
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event, choice } };
|
||||
}
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
function step(game: Game): Game {
|
||||
const next = nextClassicSkillsStrongholdStep(game);
|
||||
assert.ok(next, 'Native continuation stopped at an unsealed human plan');
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
|
||||
function advance(game: Game, predicate: (state: Game) => boolean): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (predicate(game)) return game;
|
||||
assert.equal(game.status, 'playing');
|
||||
game = step(game);
|
||||
}
|
||||
throw Error('Original classic continuation did not reach its requested boundary');
|
||||
}
|
||||
|
||||
function reserveBoard(game: Game): void {
|
||||
for (const player of game.players) {
|
||||
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 = {};
|
||||
}
|
||||
if (player.advisors) player.advisors = {};
|
||||
}
|
||||
}
|
||||
function place(game: Game, actor: string, key: string, count: number): void {
|
||||
const player = classicSkillsStrongholdPlayer(game, actor);
|
||||
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= count, 'Stage ordinary physical counters, not hidden elite counters');
|
||||
player.reserves -= count;
|
||||
player.forces[key] = (player.forces[key] ?? 0) + count;
|
||||
}
|
||||
function heldCard(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], used: string[] = []): Card {
|
||||
const player = classicSkillsStrongholdPlayer(game, actor);
|
||||
const held = player.hand.find(card => matches(card) && !used.includes(card.id));
|
||||
if (held) return held;
|
||||
const index = game.deck.findIndex(card => matches(card) && !used.includes(card.id));
|
||||
const donor = index < 0 ? game.players.find(p => p.id !== actor && p.hand.some(card => matches(card) && !used.includes(card.id))) : undefined;
|
||||
assert.ok(index >= 0 || donor, 'Required original card must retain actual deck/hand custody');
|
||||
assert.ok(player.hand.length < handLimit(player));
|
||||
const card = index >= 0 ? game.deck.splice(index, 1)[0]
|
||||
: donor!.hand.splice(donor!.hand.findIndex(card => matches(card) && !used.includes(card.id)), 1)[0];
|
||||
player.hand.push(card);
|
||||
staging.push(`Conserved ${card.id}: native ${donor?.id ?? 'deck'} custody → ${actor}'s hand`);
|
||||
return card;
|
||||
}
|
||||
function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
||||
const player = classicSkillsStrongholdPlayer(game, actor);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
const returned = player.traitors[0];
|
||||
const index = game.traitorReserve?.indexOf(identity) ?? -1;
|
||||
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||
assert.ok(returned && (index >= 0 || donor), 'Conserved original traitor must remain in native custody');
|
||||
if (index >= 0) game.traitorReserve!.splice(index, 1, returned);
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||
player.traitors[0] = identity;
|
||||
staging.push(`Conserved traitor ${identity}: native ${donor?.id ?? 'reserve'} custody exchanged for ${actor}'s ${returned}`);
|
||||
}
|
||||
|
||||
/** Real original setup and first END Mentat acquisition. Labeled staging only
|
||||
* conserves physical board/cards and orders unplayed Spice Cards. No phase,
|
||||
* turn, wallet, Stronghold/Tech owner, plan, quote or earned receipt is assigned. */
|
||||
export function createClassicSkillsStrongholdFixture(options: ClassicSkillsStrongholdOptions = {}): ClassicSkillsStrongholdFixture {
|
||||
const skill = options.skill ?? 'suk-graduate';
|
||||
const band = options.band ?? 'skilled';
|
||||
const kind = options.kind ?? 'arrakeen';
|
||||
const staging: string[] = [];
|
||||
let game: Game;
|
||||
if (options.initial) game = structuredClone(options.initial);
|
||||
else {
|
||||
const ids = options.seatIds ?? ['emperor', 'guild', 'atreides'];
|
||||
assert.ok(ids.length >= 2 && ids.length <= 6 && new Set(ids).size === ids.length);
|
||||
const factions: FactionId[] = ['emperor', 'guild', 'atreides', 'fremen', 'harkonnen', 'beneGesserit'];
|
||||
game = createGame('CLASSICSKILLSSTRONGHOLD', newPlayer(ids[0], factions[0], factions[0]), true);
|
||||
for (let index = 1; index < ids.length; index++) joinGame(game, newPlayer(ids[index], factions[index], factions[index]));
|
||||
}
|
||||
assert.equal(game.status, 'lobby', 'Only the original fresh authenticated undealt lobby is accepted');
|
||||
assert.equal(game.advanced, true);
|
||||
assert.deepEqual(game.expansions, []);
|
||||
assert.ok(game.players.length >= 2 && game.players.length <= 6);
|
||||
const owner = game.players.find(p => p.faction === (options.ownerFaction ?? 'guild'))?.id;
|
||||
const opponent = game.players.find(p => p.faction === (options.opponentFaction ?? 'emperor'))?.id;
|
||||
assert.ok(owner && opponent && owner !== opponent);
|
||||
const banker = options.bankerIncome ? game.players.find(p => p.faction === 'atreides')?.id : undefined;
|
||||
if (options.bankerIncome) assert.ok(banker && banker !== owner && banker !== opponent && game.players.length >= 3);
|
||||
if (!game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
assert.deepEqual(game.strongholdCards, createStrongholdCards());
|
||||
if (options.optionalTech) {
|
||||
assert.ok(game.players.length >= 3);
|
||||
if (!game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
}
|
||||
if (game.techTokens) assert.deepEqual(game.techTokens, createTechTokens());
|
||||
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
|
||||
const passive: LeaderSkillId[] = (['sandmaster', 'smuggler', 'rihani-decipherer', 'prana-bindu-adept', 'killer-medic', 'master-of-assassins'] as const)
|
||||
.filter(candidate => candidate !== skill);
|
||||
const selected = game.players.map(player => player.id === owner ? skill : player.id === banker ? 'spice-banker' as const : passive.shift()!);
|
||||
game = withOffers(selected, () => options.bankerIncome
|
||||
? initializeSpiceBankerIncomeGameForAudit(game) : initializeLeaderSkillsGameForAudit(game));
|
||||
const initial = structuredClone(game);
|
||||
for (let count = 0; game.status === 'setup' && count < 200; count++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
||||
const offer = game.leaderSkills!.offers[actor];
|
||||
const wanted = selected[game.players.findIndex(player => player.id === actor)];
|
||||
const view = viewGame(game, actor).leaderSkills!;
|
||||
assert.ok(offer.cards.includes(wanted) && !view.unavailableSkills?.[wanted]);
|
||||
const eligible = new Set(view.eligibleLeaders.map(leader => leader.id));
|
||||
const leader = classicSkillsStrongholdPlayer(game, actor).leaders.filter(leader => eligible.has(leader.id))
|
||||
.sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(leader);
|
||||
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.event, skill: wanted, leader: leader.id });
|
||||
} else game = step(game);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
// Four conserved unplayed non-worm blows keep the two-turn suffix out of Nexus.
|
||||
const blows = game.spiceDeck.filter(card => 'territory' in card).slice(0, 4);
|
||||
for (let index = 0; index < blows.length; index++) {
|
||||
const from = game.spiceDeck.indexOf(blows[index]);
|
||||
game.spiceDeck.splice(index, 0, game.spiceDeck.splice(from, 1)[0]);
|
||||
}
|
||||
staging.push('Controlled original unplayed Spice Card order: four non-worm blows; not a natural shuffle history');
|
||||
game = advance(game, state => state.phase === 8 && clean(state));
|
||||
reserveBoard(game);
|
||||
const key = `${kind}:${territory(kind).sectors[0]}`;
|
||||
const holder = options.cardHolder === 'opponent' ? opponent : options.cardHolder === 'unclaimed' ? undefined : owner;
|
||||
if (holder) place(game, holder, key, 1);
|
||||
staging.push('Before genuine first END Mentat: conserved board counters through their own reserves; only the labeled card claimant occupies one stronghold');
|
||||
let beforeFirstMentat: Game | undefined;
|
||||
let firstMentatStep: ClassicSkillsStrongholdStep | undefined;
|
||||
for (let count = 0; game.turn === 1 && count < 200; count++) {
|
||||
const next = nextClassicSkillsStrongholdStep(game); assert.ok(next);
|
||||
const before = structuredClone(game);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
|
||||
}
|
||||
assert.ok(beforeFirstMentat && firstMentatStep);
|
||||
assert.equal(game.turn, 2);
|
||||
assert.equal(game.strongholdCards!.owners[kind], holder ?? null);
|
||||
assert.deepEqual(game.strongholdCards!.owners, strongholdControllers(beforeFirstMentat.players, false));
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
// Use the actual physical Weather Control card rather than fabricate a storm.
|
||||
// It also makes physical attacker/defender deterministic from retained circles.
|
||||
const weather = heldCard(game, owner, card => card.effect === 'weather', staging);
|
||||
game = advance(game, state => state.phase === 0 && clean(state));
|
||||
game = applyAction(game, owner, { type: 'card', card: weather.id, amount: 0 });
|
||||
game = advance(game, state => state.phase === 5 && clean(state));
|
||||
reserveBoard(game);
|
||||
for (const actor of [owner, opponent]) place(game, actor, key, 8);
|
||||
staging.push('Before genuine turn-two movement completion: conserve eight ordinary counters per combatant; retained first-Mentat card custody is unchanged');
|
||||
assert.ok(!territory(kind).sectors.includes(game.storm), 'Original storm must leave the battle territory available');
|
||||
const used: string[] = [];
|
||||
const card = (actor: string, face: Card['kind'] | undefined): string | null => {
|
||||
if (!face) return null;
|
||||
const held = heldCard(game, actor, physical => physical.kind === face, staging, used);
|
||||
used.push(held.id);
|
||||
return held.id;
|
||||
};
|
||||
const ownerWeapon = card(owner, options.ownerWeapon);
|
||||
const ownerDefense = card(owner, options.ownerDefense);
|
||||
const opponentWeapon = card(opponent, options.opponentWeapon);
|
||||
const opponentDefense = card(opponent, options.opponentDefense);
|
||||
const trainer = game.leaderSkills!.assignments.find(assignment => assignment.owner === owner)!.leader;
|
||||
const untrained = (actor: string, strength: number | undefined) => {
|
||||
const candidates = classicSkillsStrongholdPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt &&
|
||||
!game.leaderSkills!.assignments.some(assignment => assignment.leader === leader.id));
|
||||
const leader = strength === undefined ? candidates.find(leader => leader.strength === 3) ?? candidates[0] : candidates.find(leader => leader.strength === strength);
|
||||
assert.ok(leader, 'Requested original untrained printed disc must exist');
|
||||
return leader.id;
|
||||
};
|
||||
const ownerLeader = band === 'skilled' ? trainer : untrained(owner, options.ownerLeaderStrength);
|
||||
const opponentLeader = untrained(opponent, options.opponentLeaderStrength);
|
||||
if (options.traitors) {
|
||||
conserveTraitor(game, owner, opponentLeader, staging);
|
||||
if (options.traitors === 'both') conserveTraitor(game, opponent, ownerLeader, staging);
|
||||
}
|
||||
game = advance(game, state => state.phase === 6 && clean(state));
|
||||
const beforeBattle = structuredClone(game);
|
||||
const actor = game.active!;
|
||||
assert.ok(actor === owner || actor === opponent);
|
||||
const battleAction: Action = { type: 'chooseBattle', territory: kind, target: actor === owner ? opponent : owner };
|
||||
game = applyAction(game, actor, battleAction);
|
||||
const planActions: ClassicSkillsStrongholdStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', dial: options.ownerDial ?? 0, support: options.support ?? 0, leader: ownerLeader, weapon: ownerWeapon, defense: ownerDefense } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: opponentLeader, weapon: opponentWeapon, defense: opponentDefense } },
|
||||
];
|
||||
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, battleAction, game, actor, owner, opponent, banker, trainer, kind, key, skill, band, planActions, staging };
|
||||
}
|
||||
|
||||
/** Human UI can stop at the original pre-plan state; runtime proof deliberately
|
||||
* passes native windows and chooses the requested legal normal/skilled band. */
|
||||
export function prepareClassicSkillsStrongholdBattle(fixture: ClassicSkillsStrongholdFixture, state = fixture.game): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let count = 0; count < 160; count++) {
|
||||
const next = nextClassicSkillsStrongholdStep(game);
|
||||
if (!next) { assert.ok(game.battle && !game.battle.revealed); return game; }
|
||||
if (next.action.type === 'leaderSkillVisibility') next.action.hide = next.actor === fixture.owner && fixture.band === 'skilled';
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
throw Error('Original classic plan boundary did not open');
|
||||
}
|
||||
export function revealClassicSkillsStrongholdBattle(fixture: ClassicSkillsStrongholdFixture): Game {
|
||||
let game = prepareClassicSkillsStrongholdBattle(fixture);
|
||||
for (const plan of fixture.planActions) {
|
||||
game = applyAction(game, plan.actor, plan.action);
|
||||
while (game.response || game.phaseOpening || game.decision?.kind === 'fullPlanOffer') game = step(game);
|
||||
}
|
||||
assert.ok(game.battle?.revealed);
|
||||
return game;
|
||||
}
|
||||
export function finishClassicSkillsStrongholdBattle(state: Game, options: { stop?: 'skill' | 'cleanup' | 'tech'; callers?: string[] } = {}): Game {
|
||||
let game = structuredClone(state);
|
||||
for (let count = 0; count < 240; count++) {
|
||||
if (options.stop === 'skill' && game.decision?.kind === 'sukRescue') return game;
|
||||
if (options.stop === 'cleanup' && game.decision?.kind === 'battleCards') return game;
|
||||
if (options.stop === 'tech' && game.decision?.kind === 'techToken') return game;
|
||||
if (!game.battle && clean(game) && !game.pendingTreacheryDiscard) return game;
|
||||
const next = nextClassicSkillsStrongholdStep(game); assert.ok(next);
|
||||
if (next.action.type === 'traitorCall') next.action.call = options.callers?.includes(next.actor) ?? false;
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
throw Error('Original classic skill/Stronghold battle suffix did not finish');
|
||||
}
|
||||
|
||||
/** Quote the actual revealed source, with both native physical support and
|
||||
* assigned skill bands. Do not use the standalone Stronghold quote adapter,
|
||||
* which intentionally has no Leader Skill inputs. */
|
||||
export function quoteClassicSkillsStrongholdBattle(game: Game) {
|
||||
const battle = game.battle; assert.ok(battle?.revealed);
|
||||
const combatant = (actor: string): ResolutionCombatant => {
|
||||
const player = classicSkillsStrongholdPlayer(game, actor);
|
||||
const view = viewGame(game, actor).battle!;
|
||||
assert.ok(view.ownForces);
|
||||
return {
|
||||
id: actor, faction: player.faction, ally: player.ally, spice: player.spice, hand: player.hand,
|
||||
plan: battle.plans[actor], leader: player.leaders.find(leader => leader.id === battle.plans[actor].leader),
|
||||
forces: view.ownForces, stronghold: view.strongholdEffects[actor],
|
||||
poisonTooth: battle.poisonTooth?.[actor], lateDefense: battle.lateDefense?.[actor], stoneMode: battle.stoneBurner?.[actor],
|
||||
leaderSkills: game.leaderSkills!.assignments.filter(assignment => assignment.owner === actor)
|
||||
.map(assignment => ({ skill: assignment.skill, leader: assignment.leader, faceUp: !battle.leaderSkillHidden?.[actor], captured: false })),
|
||||
occupiedStrongholds: leaderSkillStrongholdCount(game, player),
|
||||
};
|
||||
};
|
||||
return quoteBattleResolution({
|
||||
advanced: game.advanced, turn: game.turn, territory: battle.territory,
|
||||
aggressor: viewGame(game, game.host).battle!.aggressor,
|
||||
attacker: combatant(battle.attacker), defender: combatant(battle.defender),
|
||||
voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor,
|
||||
beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : classicSkillsStrongholdPlayer(game, actor).ally!,
|
||||
called: battle.traitorCalls[actor] ?? false, traitors: classicSkillsStrongholdPlayer(game, actor).traitors })),
|
||||
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand)],
|
||||
pendingAuditorPresent: false, pendingRetentionPresent: false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, handLimit, joinGame, newPlayer, viewGame, type Action, type Game, type Player } from '../game/engine';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, territory } from '../game/board';
|
||||
import { isAuditorLeader, type Card } from '../game/cards';
|
||||
import type { FactionId } from '../game/catalog';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { strongholdControllers, type StrongholdId } from '../game/stronghold-cards';
|
||||
import { initializeAdvancedNativeSkillsSetup, type AdvancedNativeSkillsOptions } from './fixture-advanced-native-skills';
|
||||
import { nextStrongholdFactionsNativeStep, quoteStrongholdFactionsBattle, type StrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type NativeSkillsStrongholdCase = 'ix-copy-suk' | 'choam-support' | 'choam-auditor' |
|
||||
'tleilaxu-tabr' | 'tleilaxu-tuek' | 'richese-marker' | 'richese-stone';
|
||||
export type NativeSkillsStrongholdOptions = {
|
||||
/** A fresh authenticated Advanced lobby; retain IDs, order and selected native decks. */
|
||||
initial?: Game;
|
||||
kind?: NativeSkillsStrongholdCase;
|
||||
optionalTech?: boolean;
|
||||
};
|
||||
export type NativeSkillsStrongholdFixture = {
|
||||
initial: Game;
|
||||
offered: Game;
|
||||
afterSetup: Game;
|
||||
beforeFirstMentat: Game;
|
||||
firstMentatStep: StrongholdFactionsNativeStep;
|
||||
afterFirstMentat: Game;
|
||||
beforeBattle: Game;
|
||||
game: Game;
|
||||
kind: NativeSkillsStrongholdCase;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
choam?: string;
|
||||
tleilaxu?: string;
|
||||
trainer: string;
|
||||
territory: StrongholdId;
|
||||
location: string;
|
||||
boardSource: string;
|
||||
copyAction: Action | null;
|
||||
plans: StrongholdFactionsNativeStep[];
|
||||
acquisition: { before: Game; after: Game; steps: StrongholdFactionsNativeStep[] } | null;
|
||||
shipment: { before: Game; after: Game; step: StrongholdFactionsNativeStep } | null;
|
||||
actions: StrongholdFactionsNativeStep[];
|
||||
staging: string[];
|
||||
};
|
||||
|
||||
export function nativeSkillsStrongholdPlayer(game: Game, id: string): Player {
|
||||
const player = game.players.find(p => p.id === id);
|
||||
assert.ok(player, `Missing original native seat ${id}`);
|
||||
return player;
|
||||
}
|
||||
export function assertNativeSkillsStrongholdCustody(game: Game): void {
|
||||
validateLeaderSkills(game.leaderSkills!, game.players);
|
||||
for (const p of game.players) {
|
||||
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0), 20, p.faction);
|
||||
if (p.elites) {
|
||||
const inventory = p.faction === 'ixians' ? 7 : p.faction === 'fremen' ? 3 : 5;
|
||||
assert.equal(p.elites.reserves + p.elites.tanks + Object.values(p.elites.forces).reduce((a, b) => a + b, 0), inventory, p.faction);
|
||||
for (const [key, count] of Object.entries(p.elites.forces)) assert.ok(count <= (p.forces[key] ?? 0));
|
||||
}
|
||||
}
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand),
|
||||
...(game.richeseCache ?? []), ...(game.richeseRemoved ?? [])];
|
||||
assert.equal(new Set(cards.map(c => c.id)).size, cards.length, 'Canonical treachery/cache cards retain single physical custody');
|
||||
}
|
||||
const clean = (g: Game) => !g.phaseOpening && !g.response && !g.decision;
|
||||
const key = (id: StrongholdId) => id === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `${id}:${territory(id).sectors[0]}`;
|
||||
|
||||
/** Existing native policies, with deliberate no-call/no-spend skill choices.
|
||||
* Stop before human plans and HMS declarations; never fabricate a window. */
|
||||
export function nextNativeSkillsStrongholdStep(game: Game): StrongholdFactionsNativeStep | null {
|
||||
if (game.decision?.kind === 'richeseCache') {
|
||||
const card = game.richeseCache!.find(c => c.id !== 'richese-stone-burner'); assert.ok(card);
|
||||
return { actor: game.decision.player, action: { type: 'decision', event: game.richeseBidding!.event,
|
||||
card: card.id, method: 'onceAround', direction: 'counterclockwise' } };
|
||||
}
|
||||
if (game.decision?.kind === 'richeseUnbid') return { actor: game.decision.player,
|
||||
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||
if (game.decision?.kind === 'leaderSkillVisibility') return { actor: game.decision.player,
|
||||
action: { type: 'leaderSkillVisibility', event: game.decision.event, hide: true } };
|
||||
if (game.decision?.kind === 'mentatQuestion') return { actor: game.decision.player,
|
||||
action: { type: 'decision', event: game.decision.event, decline: true } };
|
||||
if (game.decision?.kind === 'rihani') return { actor: game.decision.player,
|
||||
action: { type: 'decision', event: game.decision.event, draw: false } };
|
||||
if (game.decision?.kind === 'battleLosses') {
|
||||
const decision = game.decision;
|
||||
const elite = Math.max(...decision.options.map(option => option.elite));
|
||||
return { actor: decision.player, action: { type: 'decision',
|
||||
choice: decision.options.findIndex(option => option.elite === elite) } };
|
||||
}
|
||||
if (game.decision?.kind === 'sukRescue') {
|
||||
const decision = game.decision;
|
||||
const maximum = Math.max(...decision.options.map(o => o.normal + o.elite));
|
||||
const choice = decision.options.findIndex(o => o.normal + o.elite === maximum && o.kept?.kind === 'normal');
|
||||
return { actor: decision.player, action: { type: 'decision', event: decision.event,
|
||||
choice: choice >= 0 ? choice : decision.options.findIndex(o => o.normal + o.elite === maximum) } };
|
||||
}
|
||||
if (game.decision?.kind === 'techToken') return { actor: game.decision.player,
|
||||
action: { type: 'decision', token: game.decision.choices[0] } };
|
||||
if (game.decision?.kind === 'strongholdCopy' || game.decision?.kind === 'faceDance') return null;
|
||||
return game.players.some(p => p.faction === 'richese')
|
||||
? nextRicheseStrongholdsNativeStep(game) : nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
function act(game: Game, next: StrongholdFactionsNativeStep, actions?: StrongholdFactionsNativeStep[]): Game {
|
||||
actions?.push(structuredClone(next));
|
||||
return applyAction(game, next.actor, next.action);
|
||||
}
|
||||
export function advanceNativeSkillsStronghold(state: Game, until: (g: Game) => boolean,
|
||||
actions?: StrongholdFactionsNativeStep[], firstDial = 0): Game {
|
||||
let game = state;
|
||||
for (let i = 0; i < 1800; i++) {
|
||||
if (until(game)) return game;
|
||||
assert.equal(game.status, 'playing');
|
||||
const next = nextNativeSkillsStrongholdStep(game);
|
||||
assert.ok(next, `Real human choice required at ${game.decision?.kind ?? 'battle plans'}`);
|
||||
if (game.turn === 1 && next.action.type === 'stormDial') next.action.amount = firstDial;
|
||||
game = act(game, next, actions);
|
||||
}
|
||||
throw Error('Native Skills/Stronghold continuation did not reach its requested boundary');
|
||||
}
|
||||
function reserveBoard(game: Game): void {
|
||||
for (const p of game.players) {
|
||||
p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0); p.forces = {};
|
||||
if (p.elites) {
|
||||
p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0); p.elites.forces = {};
|
||||
}
|
||||
if (p.advisors) p.advisors = {};
|
||||
}
|
||||
}
|
||||
function place(game: Game, actor: string, location: string, count: number, elite = 0): void {
|
||||
const p = nativeSkillsStrongholdPlayer(game, actor);
|
||||
assert.ok(p.reserves >= count && count >= elite && (p.elites?.reserves ?? 0) >= elite);
|
||||
p.reserves -= count; p.forces[location] = (p.forces[location] ?? 0) + count;
|
||||
if (elite) { p.elites!.reserves -= elite; p.elites!.forces[location] = (p.elites!.forces[location] ?? 0) + elite; }
|
||||
}
|
||||
function held(game: Game, actor: string, predicate: (c: Card) => boolean, staging: string[], excluded: string[] = []): string {
|
||||
const p = nativeSkillsStrongholdPlayer(game, actor);
|
||||
const owned = p.hand.find(c => predicate(c) && !excluded.includes(c.id));
|
||||
if (owned) return owned.id;
|
||||
const index = game.deck.findIndex(c => predicate(c) && !excluded.includes(c.id));
|
||||
assert.ok(index >= 0 && p.hand.length < handLimit(p), 'A canonical native card and real hand capacity are required');
|
||||
const card = game.deck.splice(index, 1)[0]; p.hand.push(card);
|
||||
staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`);
|
||||
return card.id;
|
||||
}
|
||||
function untrained(game: Game, id: string, strongest = false): string {
|
||||
const p = nativeSkillsStrongholdPlayer(game, id);
|
||||
const trained = new Set(game.leaderSkills!.assignments.map(a => a.leader));
|
||||
const leader = p.leaders.filter(l => !l.dead && !isAuditorLeader(l) && !trained.has(l.id))
|
||||
.sort((a, b) => strongest ? b.strength - a.strength : a.strength - b.strength)[0];
|
||||
assert.ok(leader); return leader.id;
|
||||
}
|
||||
/** Same conserved Traitor/Face Dancer exchange as the native Skills/Tech fixture.
|
||||
* Label this rule position explicitly; never claim it was the random draw. */
|
||||
function matchingDancer(game: Game, actor: string, leader: string, staging: string[]): void {
|
||||
const dancer = nativeSkillsStrongholdPlayer(game, actor).faceDancers!.find(c => !c.revealed);
|
||||
assert.ok(dancer);
|
||||
if (nativeSkillsStrongholdPlayer(game, actor).faceDancers!.some(c => c.leader === leader && !c.revealed)) return;
|
||||
const index = game.traitorReserve!.indexOf(leader);
|
||||
if (index >= 0) game.traitorReserve![index] = dancer.leader;
|
||||
else {
|
||||
const donor = game.players.find(p => p.traitors.includes(leader));
|
||||
assert.ok(donor, 'Matching physical Traitor has a native custodian');
|
||||
donor.traitors[donor.traitors.indexOf(leader)] = dancer.leader;
|
||||
}
|
||||
staging.push(`Conserved matching Face Dancer ${leader}: native Traitor custody exchanged with ${dancer.leader}`);
|
||||
dancer.leader = leader;
|
||||
}
|
||||
|
||||
/** Genuine Advanced native setup, first Storm (optional original Tech assignment),
|
||||
* and actual first END Mentat. Only initial physical skill offers, unplayed Spice
|
||||
* Card order and labeled conserved board/card positions are controlled. Wallets,
|
||||
* phases, turns, owners, quotes and earned receipts are never assigned. */
|
||||
export function createNativeSkillsStrongholdFixture(options: NativeSkillsStrongholdOptions = {}): NativeSkillsStrongholdFixture {
|
||||
const kind = options.kind ?? 'ix-copy-suk';
|
||||
const ix = kind === 'ix-copy-suk';
|
||||
const tleilaxuCase = kind === 'tleilaxu-tabr' || kind === 'tleilaxu-tuek';
|
||||
const richese = kind === 'richese-marker' || kind === 'richese-stone';
|
||||
const family = ix ? 'ixians' : tleilaxuCase ? 'tleilaxu' : richese ? 'richese' : 'choam';
|
||||
const ownerFaction: FactionId = tleilaxuCase || kind === 'choam-support' ? 'guild' : family;
|
||||
const factions: FactionId[] = ix ? ['ixians', 'emperor', 'choam', 'fremen'] :
|
||||
tleilaxuCase ? ['guild', 'tleilaxu', 'emperor'] : richese ? ['richese', 'choam', 'guild'] :
|
||||
kind === 'choam-support' ? ['guild', 'choam', 'emperor'] : ['choam', 'guild', 'emperor'];
|
||||
const decks = ix ? ['ix', 'choam'] : [tleilaxuCase ? 'ix' : 'choam'];
|
||||
let game = options.initial ? structuredClone(options.initial)
|
||||
: createGame('NATIVESKILLSSTRONGHOLD', newPlayer(factions[0], factions[0], factions[0]), true, decks);
|
||||
if (!options.initial) for (const faction of factions.slice(1)) joinGame(game, newPlayer(faction, faction, faction));
|
||||
assert.equal(game.status, 'lobby', 'Continue only a fresh authenticated undealt lobby');
|
||||
assert.equal(game.advanced, true);
|
||||
assert.ok(game.players.length >= 2 && game.players.length <= 6);
|
||||
assert.ok(game.expansions.every(deck => deck === 'ix' || deck === 'choam'));
|
||||
const owner = game.players.find(p => p.faction === ownerFaction)?.id;
|
||||
const opponent = game.players.find(p => p.faction === (tleilaxuCase ? 'tleilaxu' : richese ? 'guild' :
|
||||
ix || kind === 'choam-support' ? 'emperor' : 'guild'))?.id;
|
||||
assert.ok(owner && opponent);
|
||||
const choam = game.players.find(p => p.faction === 'choam')?.id;
|
||||
const tleilaxu = game.players.find(p => p.faction === 'tleilaxu')?.id;
|
||||
if (!game.strongholdCards) game = applyAction(game, game.host, { type: 'strongholdCards', enabled: true });
|
||||
if (options.optionalTech && !game.techTokens) game = applyAction(game, game.host, { type: 'techTokens', enabled: true });
|
||||
if (game.techTokens) assert.ok(game.players.length >= 3);
|
||||
const initial = structuredClone(game), actions: StrongholdFactionsNativeStep[] = [], staging: string[] = [];
|
||||
const native: AdvancedNativeSkillsOptions = { family, skillOwner: ownerFaction, requestedSkill: 'suk-graduate', initial: game };
|
||||
game = initializeAdvancedNativeSkillsSetup(native);
|
||||
const offered = structuredClone(game);
|
||||
for (let i = 0; game.status === 'setup' && i < 200; i++) {
|
||||
if (game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0];
|
||||
const offer = game.leaderSkills!.offers[actor], view = viewGame(game, actor).leaderSkills!;
|
||||
const skill = actor === owner ? 'suk-graduate' : offer.cards.find(c => !view.unavailableSkills?.[c]);
|
||||
const eligible = new Set(view.eligibleLeaders.map(l => l.id));
|
||||
const leader = nativeSkillsStrongholdPlayer(game, actor).leaders.filter(l => eligible.has(l.id)).sort((a, b) => b.strength - a.strength)[0];
|
||||
assert.ok(skill && offer.cards.includes(skill) && leader);
|
||||
game = act(game, { actor, action: { type: 'leaderSkill', event: offer.event, skill, leader: leader.id } }, actions);
|
||||
} else {
|
||||
const next = nextNativeSkillsStrongholdStep(game); assert.ok(next); game = act(game, next, actions);
|
||||
}
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
const trainer = game.leaderSkills!.assignments.find(a => a.owner === owner && a.skill === 'suk-graduate')!.leader;
|
||||
// Genuine non-worm cards stay in their native deck. No piles or phase receipts are injected.
|
||||
const blows = game.spiceDeck.filter(c => 'territory' in c).slice(0, 6);
|
||||
for (const [position, card] of blows.entries()) game.spiceDeck.splice(position, 0, game.spiceDeck.splice(game.spiceDeck.indexOf(card), 1)[0]);
|
||||
staging.push('Conserved first six unplayed non-worm Spice Cards moved to the front of the native Spice Deck; not a natural shuffle history.');
|
||||
const stronghold: StrongholdId = ix ? MOBILE_STRONGHOLD : kind === 'tleilaxu-tabr' ? 'sietch_tabr' :
|
||||
kind === 'tleilaxu-tuek' ? 'tueks_sietch' : kind === 'richese-stone' ? 'habbanya_ridge_sietch' : 'arrakeen';
|
||||
const location = key(stronghold), firstDial = kind === 'tleilaxu-tuek' || kind === 'richese-stone' ? 4 : 0;
|
||||
let acquisition: NativeSkillsStrongholdFixture['acquisition'] = null;
|
||||
if (kind === 'richese-stone') {
|
||||
game = advanceNativeSkillsStronghold(game, g => g.decision?.kind === 'richeseCache', actions, firstDial);
|
||||
const before = structuredClone(game), steps: StrongholdFactionsNativeStep[] = [];
|
||||
for (let i = 0; !nativeSkillsStrongholdPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner') && i < 100; i++) {
|
||||
const next = game.decision?.kind === 'richeseCache' ? { actor: owner, action: {
|
||||
type: 'decision' as const, event: game.richeseBidding!.event, card: 'richese-stone-burner',
|
||||
method: 'onceAround', direction: 'counterclockwise' } } :
|
||||
game.decision?.kind === 'richeseUnbid' ? { actor: owner, action: {
|
||||
type: 'decision' as const, event: game.richeseBidding!.event, keep: true } } : nextNativeSkillsStrongholdStep(game);
|
||||
assert.ok(next); game = act(game, next, steps);
|
||||
}
|
||||
assert.ok(nativeSkillsStrongholdPlayer(game, owner).hand.some(c => c.id === 'richese-stone-burner'));
|
||||
actions.push(...steps); acquisition = { before, after: structuredClone(game), steps };
|
||||
}
|
||||
let shipment: NativeSkillsStrongholdFixture['shipment'] = null;
|
||||
if (kind === 'richese-marker') {
|
||||
game = advanceNativeSkillsStronghold(game, g => g.phase === 5 && clean(g) && g.active === owner, actions, firstDial);
|
||||
reserveBoard(game);
|
||||
const p = nativeSkillsStrongholdPlayer(game, owner), token = p.noField!.tokens.find(t => t.value === 5)!;
|
||||
const before = structuredClone(game), step: StrongholdFactionsNativeStep = { actor: owner,
|
||||
action: { type: 'ship', territory: stronghold, sector: territory(stronghold).sectors[0], noField: token.id, event: p.noFieldEvent } };
|
||||
game = act(game, step, actions);
|
||||
game = advanceNativeSkillsStronghold(game, clean, actions, firstDial);
|
||||
shipment = { before, after: structuredClone(game), step };
|
||||
staging.push('Actual native No-Field five shipment: no marker is assigned or materialized by the fixture.');
|
||||
}
|
||||
game = advanceNativeSkillsStronghold(game, g => g.phase === 8 && clean(g), actions, firstDial);
|
||||
reserveBoard(game);
|
||||
if (kind !== 'richese-marker') place(game, owner, location, 1, ix ? 1 : 0);
|
||||
if (ix) place(game, owner, key('arrakeen'), 1);
|
||||
staging.push('Before actual first END Mentat: conserve original board counters through native reserves; owner controls only the requested Stronghold (Ix also Arrakeen), no three-hold victory.');
|
||||
let beforeFirstMentat: Game | undefined, firstMentatStep: StrongholdFactionsNativeStep | undefined;
|
||||
while (game.turn === 1) {
|
||||
const before = structuredClone(game), next = nextNativeSkillsStrongholdStep(game); assert.ok(next);
|
||||
game = act(game, next, actions);
|
||||
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
|
||||
}
|
||||
assert.ok(beforeFirstMentat && firstMentatStep);
|
||||
assert.equal(game.strongholdCards!.owners[stronghold], owner);
|
||||
const afterFirstMentat = structuredClone(game);
|
||||
game = advanceNativeSkillsStronghold(game, g => g.phase === 5 && clean(g), actions);
|
||||
reserveBoard(game);
|
||||
const boardSource = 'polar_sink:0';
|
||||
if (kind === 'richese-marker') {
|
||||
const p = nativeSkillsStrongholdPlayer(game, owner);
|
||||
assert.ok(p.noField!.deployed && p.reserves >= 3);
|
||||
place(game, owner, boardSource, p.reserves - 3);
|
||||
place(game, opponent, location, 6);
|
||||
staging.push('Conserved marker-only battle: Richese has exactly three reserves; every other surviving Richese physical counter is in Polar Sink, not alongside the concealed marker.');
|
||||
} else {
|
||||
for (const actor of [owner, opponent]) place(game, actor, location, 8, ix && actor === owner ? 2 : 0);
|
||||
if (ix) {
|
||||
place(game, owner, key('arrakeen'), 1);
|
||||
place(game, owner, key('sietch_tabr'), 1);
|
||||
}
|
||||
if (tleilaxuCase) {
|
||||
place(game, opponent, boardSource, 3);
|
||||
matchingDancer(game, opponent, trainer, staging);
|
||||
}
|
||||
staging.push('Before actual turn-two Movement completion: conserve eight battle counters per combatant (Ix six Suboids/two cyborgs); keep real first-Mentat card custody and all native wallets.');
|
||||
if (ix) staging.push('The second copy-control counter (Tabr) is staged only in real turn-two Movement, after the first genuine two-hold END Mentat claim. This three-hold battle position is controlled, not natural history; no later Ix Mentat continuation or victory is suppressed.');
|
||||
}
|
||||
let weapon: string | null = null, defense: string | null = null, enemyWeapon: string | null = null;
|
||||
if (tleilaxuCase || kind === 'choam-auditor') weapon = held(game, owner, c => c.kind === 'projectile', staging);
|
||||
if (tleilaxuCase) {
|
||||
defense = held(game, owner, c => c.kind === (kind === 'tleilaxu-tuek' ? 'worthless' : 'shield'), staging, weapon ? [weapon] : []);
|
||||
enemyWeapon = held(game, opponent, c => c.kind === 'worthless', staging, defense ? [defense] : []);
|
||||
}
|
||||
if (ix) defense = held(game, owner, c => c.kind === 'shield', staging);
|
||||
if (kind === 'richese-stone') weapon = 'richese-stone-burner';
|
||||
const ownerLeader = kind === 'choam-auditor' ? nativeSkillsStrongholdPlayer(game, owner).leaders.find(isAuditorLeader)!.id :
|
||||
kind === 'richese-stone' ? untrained(game, owner) : trainer;
|
||||
const opponentLeader = untrained(game, opponent);
|
||||
game = advanceNativeSkillsStronghold(game, g => g.phase === 6 && clean(g), actions);
|
||||
const beforeBattle = structuredClone(game);
|
||||
assert.ok(game.active === owner || game.active === opponent);
|
||||
game = act(game, { actor: game.active!, action: { type: 'chooseBattle', territory: stronghold, target: game.active === owner ? opponent : owner } }, actions);
|
||||
game = advanceNativeSkillsStronghold(game, g => g.decision?.kind === 'strongholdCopy' ||
|
||||
!!g.battle && clean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false, actions);
|
||||
const copyAction: Action | null = game.decision?.kind === 'strongholdCopy'
|
||||
? { type: 'decision', event: game.decision.event, stronghold: 'arrakeen' } : null;
|
||||
const ownerDial = ix ? 5 : kind === 'richese-marker' ? 3 : kind === 'richese-stone' ? 2 : 4;
|
||||
const support = ix ? 1 : ownerDial;
|
||||
const opponentDial = tleilaxuCase || kind === 'richese-stone' || kind === 'choam-auditor' ? 2 : 0;
|
||||
const plans: StrongholdFactionsNativeStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', dial: ownerDial, support, leader: ownerLeader, weapon, defense } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: opponentDial, support: opponentDial, leader: opponentLeader, weapon: enemyWeapon } },
|
||||
];
|
||||
assertNativeSkillsStrongholdCustody(game);
|
||||
return { initial, offered, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, game,
|
||||
kind, owner, opponent, choam, tleilaxu, trainer, territory: stronghold, location, boardSource,
|
||||
copyAction, plans, acquisition, shipment, actions, staging };
|
||||
}
|
||||
|
||||
export function revealNativeSkillsStrongholdBattle(fixture: NativeSkillsStrongholdFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
if (fixture.copyAction) {
|
||||
game = applyAction(game, fixture.owner, fixture.copyAction);
|
||||
game = advanceNativeSkillsStronghold(game, g => !!g.battle && clean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
|
||||
}
|
||||
for (const plan of fixture.plans) {
|
||||
game = applyAction(game, plan.actor, plan.action);
|
||||
if (!game.battle?.revealed) game = advanceNativeSkillsStronghold(game, g => clean(g) && !g.battle?.preparation && g.battle?.preLeader?.closed !== false);
|
||||
}
|
||||
assert.ok(game.battle?.revealed);
|
||||
return game;
|
||||
}
|
||||
/** Stop at consumer-visible choices; fully resolved means this battle, not a made-up phase. */
|
||||
export function settleNativeSkillsStrongholdBattle(state: Game, stop: 'suk' | 'cards' | 'faceDance' | 'complete' = 'complete'): Game {
|
||||
return advanceNativeSkillsStronghold(state, g =>
|
||||
stop === 'suk' && g.decision?.kind === 'sukRescue' ||
|
||||
stop === 'cards' && g.decision?.kind === 'battleCards' ||
|
||||
stop === 'faceDance' && g.decision?.kind === 'faceDance' ||
|
||||
!g.battle && clean(g) && !g.pendingTreacheryDiscard);
|
||||
}
|
||||
export function finishNativeSkillsStrongholdTurn(state: Game): Game {
|
||||
return advanceNativeSkillsStronghold(state, g => g.turn > state.turn);
|
||||
}
|
||||
export function nativeSkillsStrongholdControllers(game: Game) {
|
||||
return strongholdControllers(game.players, !!game.mobileStronghold?.location);
|
||||
}
|
||||
/** The shared quote is authoritative for native typed forces/payment/income.
|
||||
* These cases deliberately choose Suk (no strength bonus) or no active skill. */
|
||||
export const quoteNativeSkillsStrongholdBattle = quoteStrongholdFactionsBattle;
|
||||
@@ -83,14 +83,10 @@ export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactions
|
||||
return { actor, action: { type: 'ready' } };
|
||||
}
|
||||
if (game.response) {
|
||||
// Use projected legal responses: an absent Karama does not create a pause.
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(p => p.id === player.id)!.bot = 'Easy';
|
||||
const action = botActions(view).find(a => a.type === 'passResponse');
|
||||
if (action) return { actor: player.id, action };
|
||||
}
|
||||
throw Error(`No actual eligible counter at ${game.response.kind}`);
|
||||
// The persisted pass queue defines legality; a policy recommendation is not a legal-action menu.
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id))?.id;
|
||||
assert.ok(actor, `No remaining original response at ${game.response.kind}`);
|
||||
return { actor, action: { type: 'passResponse' } };
|
||||
}
|
||||
const decision = game.decision;
|
||||
if (decision) {
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { FACTIONS } from '../game/catalog';
|
||||
import { treacheryDeck, isAuditorLeader } from '../game/cards';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { ownedTech } from '../game/tech-tokens';
|
||||
import { quoteSpiceCollection } from '../game/board-resolution-quote';
|
||||
import {
|
||||
assertNativeSkillsStrongholdCustody as custody, createNativeSkillsStrongholdFixture as fixture,
|
||||
finishNativeSkillsStrongholdTurn, nativeSkillsStrongholdControllers,
|
||||
nativeSkillsStrongholdPlayer as player, nextNativeSkillsStrongholdStep,
|
||||
quoteNativeSkillsStrongholdBattle, revealNativeSkillsStrongholdBattle as reveal,
|
||||
settleNativeSkillsStrongholdBattle as settle, type NativeSkillsStrongholdFixture,
|
||||
} from './fixture-native-skills-stronghold';
|
||||
|
||||
/** Native Collection is an independent later receipt, not a battle subsidy or bounty. */
|
||||
function collectionIncome(game: Game, id: string): number {
|
||||
if (game.phase !== 7 || game.phaseOpening) return 0;
|
||||
return quoteSpiceCollection({ ...game, players: game.players.map(p => ({ ...p, spice: 0 })) }).receipts
|
||||
.filter(r => r.player === id).reduce((sum, r) => sum + r.strongholds + r.collected, 0);
|
||||
}
|
||||
function assertBattleWallets(before: Game, after: Game): void {
|
||||
const quote = quoteNativeSkillsStrongholdBattle(before);
|
||||
for (const p of before.players) {
|
||||
const paid = quote.payments.find(r => r.player === p.id)?.ownPayment ?? 0;
|
||||
const stronghold = quote.strongholdIncome.filter(r => r.player === p.id).reduce((sum, r) => sum + r.amount, 0);
|
||||
const bounty = quote.bounty?.player === p.id ? quote.bounty.amount : 0;
|
||||
const choam = quote.choamIncome?.owner === p.id ? quote.choamIncome.amount : 0;
|
||||
assert.equal(player(after, p.id).spice, p.spice - paid + stronghold + bounty + choam + collectionIncome(after, p.id), p.faction);
|
||||
}
|
||||
}
|
||||
function afterCopy(f: NativeSkillsStrongholdFixture): Game {
|
||||
assert.ok(f.copyAction);
|
||||
let game = applyAction(f.game, f.owner, f.copyAction);
|
||||
for (let i = 0; (game.decision || game.response || game.battle?.preparation || game.battle?.preLeader?.closed === false) && i < 100; i++) {
|
||||
const next = nextNativeSkillsStrongholdStep(game); assert.ok(next);
|
||||
game = applyAction(game, next.actor, next.action);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('original Advanced native offers, wallets, decks/cache and physical 20/7/5/3 survive genuine Stronghold and optional Tech setup', () => {
|
||||
for (const kind of ['ix-copy-suk', 'tleilaxu-tabr', 'richese-marker'] as const) {
|
||||
for (const optionalTech of [false, true]) {
|
||||
const f = fixture({ kind, optionalTech });
|
||||
const skills = f.offered.leaderSkills!;
|
||||
assert.deepEqual([...skills.deck, ...Object.values(skills.offers).flatMap(o => o.cards), ...skills.assignments.map(a => a.skill)].sort(),
|
||||
LEADER_SKILL_CARDS.map(c => c.id).sort());
|
||||
for (const p of f.afterSetup.players) assert.equal(p.spice, FACTIONS.find(c => c.id === p.faction)!.spice, p.faction);
|
||||
const physical = [...f.afterSetup.deck, ...f.afterSetup.discard, ...f.afterSetup.players.flatMap(p => p.hand), ...(f.afterSetup.richeseCache ?? [])];
|
||||
const canonical = [...treacheryDeck(f.afterSetup.expansions), ...(kind === 'richese-marker' ? richeseCards() : [])];
|
||||
assert.deepEqual(physical.map(c => c.id).sort(), canonical.map(c => c.id).sort());
|
||||
assert.equal(nativeSkillsStrongholdControllers(f.beforeFirstMentat)[f.territory], f.owner);
|
||||
assert.equal(f.beforeFirstMentat.strongholdCards!.owners[f.territory], null);
|
||||
const claimed = applyAction(f.beforeFirstMentat, f.firstMentatStep.actor, f.firstMentatStep.action);
|
||||
assert.equal(claimed.strongholdCards!.owners[f.territory], f.owner);
|
||||
assert.equal(claimed.turn, 2);
|
||||
if (optionalTech) {
|
||||
const seats = new Set(f.afterFirstMentat.players.map(p => p.id));
|
||||
assert.ok(Object.values(f.afterFirstMentat.techTokens!).every(token => token.owner && seats.has(token.owner)));
|
||||
if (kind !== 'richese-marker') {
|
||||
const native = f.afterSetup.players.find(p => p.faction === (kind === 'ix-copy-suk' ? 'ixians' : 'tleilaxu'))!;
|
||||
assert.ok(ownedTech(f.afterFirstMentat.techTokens, native.id).includes(kind === 'ix-copy-suk' ? 'heighliners' : 'axlotl'));
|
||||
}
|
||||
}
|
||||
custody(f.afterSetup); custody(f.afterFirstMentat); custody(f.game);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('authenticated original lobby keeps human IDs and native decks while actually reaching HMS copy and typed trained-disc Suk', () => {
|
||||
const initial = createGame('NATIVEHUMAN', newPlayer('human-ix', 'Human Ix', 'ixians'), true, ['ix', 'choam']);
|
||||
joinGame(initial, newPlayer('human-emperor', 'Human Emperor', 'emperor'));
|
||||
joinGame(initial, newPlayer('human-choam', 'Human CHOAM', 'choam'));
|
||||
joinGame(initial, newPlayer('human-fremen', 'Human Fremen', 'fremen'));
|
||||
const before = structuredClone(initial);
|
||||
const f = fixture({ initial, kind: 'ix-copy-suk', optionalTech: true });
|
||||
assert.deepEqual(initial, before);
|
||||
assert.deepEqual(f.game.players.map(p => p.id), before.players.map(p => p.id));
|
||||
assert.equal(f.owner, 'human-ix');
|
||||
assert.equal(f.game.decision?.kind, 'strongholdCopy');
|
||||
assert.deepEqual(f.game.decision?.kind === 'strongholdCopy' ? [...f.game.decision.choices].sort() : [], ['arrakeen', 'sietch_tabr']);
|
||||
const pending = structuredClone(f.game);
|
||||
assert.throws(() => applyAction(f.game, f.owner, f.plans[0].action));
|
||||
assert.deepEqual(f.game, pending, 'No hidden plan can precede the public HMS copy declaration');
|
||||
const ready = afterCopy(f);
|
||||
assert.equal(viewGame(ready, f.opponent).battle!.strongholdEffects[f.owner], 'arrakeen');
|
||||
const revealed = reveal(f), quote = quoteNativeSkillsStrongholdBattle(revealed);
|
||||
assert.equal(quote.payments.find(p => p.player === f.owner)!.bankSupport, 1);
|
||||
assert.equal(quote.payments.find(p => p.player === f.owner)!.ownPayment, 0);
|
||||
const rescue = settle(revealed, 'suk');
|
||||
assert.equal(rescue.decision?.kind, 'sukRescue');
|
||||
if (rescue.decision?.kind !== 'sukRescue') throw Error('Native typed casualties must reach Suk');
|
||||
assert.deepEqual(rescue.pendingSukRescue!.losses, { normal: 4, elite: 2, paidNormal: 0, paidElite: 1 });
|
||||
const choice = rescue.decision.options.findIndex(o => o.normal === 2 && o.elite === 1 && o.kept?.kind === 'normal');
|
||||
assert.ok(choice >= 0);
|
||||
const resolved = settle(applyAction(rescue, f.owner, { type: 'decision', event: rescue.decision.event, choice }));
|
||||
assert.equal(resolved.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(resolved, f.owner).forces[f.location], 3);
|
||||
assert.equal(player(resolved, f.owner).elites!.forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(resolved, f.owner).elites!.reserves, 6);
|
||||
assert.equal(player(resolved, f.owner).elites!.tanks, 1);
|
||||
assert.equal(player(resolved, f.owner).tanks, 3);
|
||||
assert.equal(player(resolved, f.opponent).tanks, 8);
|
||||
assertBattleWallets(revealed, resolved); custody(resolved);
|
||||
});
|
||||
|
||||
void test('actual Arrakeen subsidy funds trained Suk without suppressing CHOAM original declared-support income', () => {
|
||||
const f = fixture({ kind: 'choam-support', optionalTech: true });
|
||||
const revealed = reveal(f), quote = quoteNativeSkillsStrongholdBattle(revealed);
|
||||
assert.equal(quote.payments.find(r => r.player === f.owner)!.ownPayment, 2);
|
||||
assert.equal(quote.payments.find(r => r.player === f.owner)!.bankSupport, 2);
|
||||
assert.deepEqual(quote.choamIncome, { owner: f.choam, amount: 2 });
|
||||
const resolved = settle(revealed);
|
||||
assert.equal(player(resolved, f.owner).forces[f.location], 5);
|
||||
assert.equal(player(resolved, f.owner).tanks, 1);
|
||||
assert.equal(player(resolved, f.choam!).spice, player(revealed, f.choam!).spice + 2);
|
||||
assertBattleWallets(revealed, resolved); custody(resolved);
|
||||
});
|
||||
|
||||
void test('native CHOAM Auditor is excluded from training and cannot borrow a hidden trained Suk rescue', () => {
|
||||
const f = fixture({ kind: 'choam-auditor' });
|
||||
const eligible = viewGame(f.offered, f.owner).leaderSkills!.eligibleLeaders;
|
||||
const auditor = player(f.afterSetup, f.owner).leaders.find(isAuditorLeader)!;
|
||||
assert.ok(auditor && eligible.every(l => l.id !== auditor.id));
|
||||
assert.ok(f.afterSetup.leaderSkills!.assignments.every(a => a.leader !== auditor.id));
|
||||
const revealed = reveal(f), quote = quoteNativeSkillsStrongholdBattle(revealed);
|
||||
assert.equal(quote.auditor!.owner, f.owner);
|
||||
assert.equal(quote.auditor!.survived, true);
|
||||
const resolved = settle(revealed);
|
||||
assert.equal(resolved.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(resolved, f.owner).forces[f.location], 4);
|
||||
assert.equal(player(resolved, f.owner).tanks, 4, 'Unskilled Auditor has no trained-disc Suk rescue');
|
||||
assertBattleWallets(revealed, resolved); custody(resolved);
|
||||
});
|
||||
|
||||
void test('Tabr/Tuek original winner keeps spice/cards and optional Tech before Face Dance; matching-disc death returns Suk once without a second bounty', () => {
|
||||
for (const kind of ['tleilaxu-tabr', 'tleilaxu-tuek'] as const) {
|
||||
for (const optionalTech of [false, true]) {
|
||||
const f = fixture({ kind, optionalTech });
|
||||
const revealed = reveal(f), quote = quoteNativeSkillsStrongholdBattle(revealed);
|
||||
assert.deepEqual(quote.strongholdIncome, [{ player: f.owner, amount: 2 }]);
|
||||
const suk = settle(revealed, 'suk');
|
||||
assert.equal(suk.decision?.kind, 'sukRescue');
|
||||
const cards = settle(suk, 'cards');
|
||||
assert.equal(cards.decision?.kind, 'battleCards');
|
||||
assert.equal(cards.decision?.player, f.owner);
|
||||
const usedDefense = f.plans[0].action.defense as string;
|
||||
const cleaned = applyAction(cards, f.owner, { type: 'decision', discard: [usedDefense] });
|
||||
const face = settle(cleaned, 'faceDance');
|
||||
assert.equal(face.decision?.kind, 'faceDance');
|
||||
assert.ok(face.discard.some(c => c.id === usedDefense), 'Original winner card choice finishes before Face Dance');
|
||||
assert.equal(player(face, f.owner).forces[f.location], 5, 'Original Suk rescue completes first');
|
||||
assert.equal(player(face, f.owner).tanks, 1);
|
||||
assert.equal(player(face, f.owner).spice, player(revealed, f.owner).spice - 4 + quote.bounty!.amount + 2);
|
||||
if (optionalTech) assert.ok(ownedTech(face.techTokens, f.owner).includes('axlotl'));
|
||||
const cardIds = player(face, f.owner).hand.map(c => c.id).sort();
|
||||
const reserves = player(face, f.owner).reserves;
|
||||
const dancerReserves = player(face, f.tleilaxu!).reserves;
|
||||
const danced = applyAction(face, f.tleilaxu!, { type: 'decision', reveal: true,
|
||||
sources: { reserves: 1, [f.boardSource]: 2 }, sector: Number(f.location.split(':')[1]) });
|
||||
assert.equal(player(danced, f.owner).reserves, reserves + 5);
|
||||
assert.equal(player(danced, f.owner).forces[f.location] ?? 0, 0);
|
||||
assert.equal(player(danced, f.tleilaxu!).forces[f.location], 3);
|
||||
assert.equal(player(danced, f.tleilaxu!).forces[f.boardSource], 1);
|
||||
assert.equal(player(danced, f.tleilaxu!).reserves, dancerReserves - 1);
|
||||
assert.equal(player(danced, f.owner).leaders.find(l => l.id === f.trainer)!.dead, true);
|
||||
assert.equal(danced.leaderSkills!.assignments.some(a => a.leader === f.trainer), false);
|
||||
assert.equal(danced.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
|
||||
assert.deepEqual(player(danced, f.owner).hand.map(c => c.id).sort(), cardIds);
|
||||
for (const p of face.players)
|
||||
assert.equal(player(danced, p.id).spice, p.spice + collectionIncome(danced, p.id),
|
||||
`${p.faction}: Face Dance awards no additional leader bounty or confiscation`);
|
||||
if (optionalTech) assert.ok(ownedTech(danced.techTokens, f.owner).includes('axlotl'));
|
||||
assert.equal(nativeSkillsStrongholdControllers(danced)[f.territory], f.tleilaxu);
|
||||
assert.equal(danced.strongholdCards!.owners[f.territory], f.owner, 'Board replacement does not immediately transfer retained Stronghold Card');
|
||||
const ended = finishNativeSkillsStrongholdTurn(danced);
|
||||
assert.equal(ended.strongholdCards!.owners[f.territory], f.tleilaxu);
|
||||
assert.equal(ended.leaderSkills!.deck.filter(s => s === 'suk-graduate').length, 1);
|
||||
custody(danced); custody(ended);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('original mixed ordinary-force/No-Field boundary remains guarded even with trained Suk and a retained Stronghold Card', () => {
|
||||
const f = fixture({ kind: 'richese-marker' });
|
||||
const game = structuredClone(f.beforeBattle), richese = player(game, f.owner);
|
||||
// Explicit conserved alternative board position: one existing Polar Sink
|
||||
// counter accompanies the marker, not a shipment or played-turn claim.
|
||||
richese.forces[f.boardSource]--;
|
||||
richese.forces[f.location] = 1;
|
||||
const before = structuredClone(game), actor = game.active!;
|
||||
assert.throws(() => applyAction(game, actor, { type: 'chooseBattle', territory: f.territory,
|
||||
target: actor === f.owner ? f.opponent : f.owner }), /Mixed ordinary-force and No-Field/);
|
||||
assert.deepEqual(game, before); custody(game);
|
||||
});
|
||||
|
||||
void test('reserve-capped marker-only Richese materializes three after both plans, earns retained Arrakeen subsidy and rescues only physical casualties', () => {
|
||||
const f = fixture({ kind: 'richese-marker', optionalTech: true });
|
||||
assert.equal(player(f.shipment!.after, f.owner).spice, player(f.shipment!.before, f.owner).spice - 1);
|
||||
const own = viewGame(f.game, f.owner).battle!;
|
||||
assert.equal(own.ownForces!.normal, 3);
|
||||
const before = structuredClone(f.game);
|
||||
assert.throws(() => applyAction(f.game, f.owner, { ...f.plans[0].action, dial: 4 }));
|
||||
assert.deepEqual(f.game, before);
|
||||
const sealed = applyAction(f.game, f.owner, f.plans[0].action);
|
||||
assert.ok(player(sealed, f.owner).noField!.deployed);
|
||||
assert.equal(player(sealed, f.owner).forces[f.location] ?? 0, 0);
|
||||
const revealed = reveal(f), quote = quoteNativeSkillsStrongholdBattle(revealed);
|
||||
assert.equal(player(revealed, f.owner).forces[f.location], 3);
|
||||
assert.equal(quote.payments.find(p => p.player === f.owner)!.ownPayment, 1);
|
||||
assert.equal(quote.payments.find(p => p.player === f.owner)!.bankSupport, 2);
|
||||
const resolved = settle(revealed);
|
||||
assert.equal(player(resolved, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(resolved, f.owner).reserves, 2);
|
||||
assert.equal(player(resolved, f.owner).tanks, 0);
|
||||
assert.equal(player(resolved, f.opponent).tanks, 6);
|
||||
assertBattleWallets(revealed, resolved); custody(resolved);
|
||||
});
|
||||
|
||||
void test('native cache-acquired Stone uses retained Habbanya in both modes with all skills hidden and untrained battle discs', () => {
|
||||
for (const mode of ['kill', 'ignore'] as const) {
|
||||
const f = fixture({ kind: 'richese-stone' });
|
||||
assert.ok(f.acquisition!.before.richeseCache!.some(c => c.id === 'richese-stone-burner'));
|
||||
assert.ok(player(f.acquisition!.after, f.owner).hand.some(c => c.id === 'richese-stone-burner'));
|
||||
assert.equal(f.acquisition!.after.richeseCache!.some(c => c.id === 'richese-stone-burner'), false);
|
||||
for (const p of f.acquisition!.before.players) assert.equal(player(f.acquisition!.after, p.id).spice, p.spice);
|
||||
const offered = reveal(f);
|
||||
assert.equal(offered.decision?.kind, 'stoneBurner');
|
||||
const chosen = applyAction(offered, f.owner, { type: 'decision', event: offered.battle!.event, mode });
|
||||
const quote = quoteNativeSkillsStrongholdBattle(chosen), resolved = settle(chosen);
|
||||
assert.equal(quote.winner, f.owner);
|
||||
assert.deepEqual(quote.stone!.attacker, [6]);
|
||||
assert.deepEqual(quote.stone!.defender, [6]);
|
||||
assert.equal(player(resolved, f.owner).forces[f.location], 6);
|
||||
assert.equal(player(resolved, f.owner).tanks, 2);
|
||||
for (const plan of f.plans) assert.equal(player(resolved, plan.actor).leaders.find(l => l.id === plan.action.leader)!.dead, mode === 'kill');
|
||||
assert.ok(player(resolved, f.owner).hand.some(c => c.id === 'richese-stone-burner'));
|
||||
assertBattleWallets(chosen, resolved); custody(resolved);
|
||||
}
|
||||
});
|
||||
|
||||
void test('four existing policies finish changed HMS/Suk and capped-marker windows with physical and economic outcomes', () => {
|
||||
const ix = fixture({ kind: 'ix-copy-suk' });
|
||||
const marker = fixture({ kind: 'richese-marker' });
|
||||
const stone = fixture({ kind: 'richese-stone' });
|
||||
const stoneChoice = reveal(stone);
|
||||
const ixRescue = settle(reveal(ix), 'suk');
|
||||
const markerRescue = settle(reveal(marker), 'suk');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const copyView = viewGame(ix.game, ix.owner); copyView.players.find(p => p.id === ix.owner)!.bot = difficulty;
|
||||
const copy = botActions(copyView)[0];
|
||||
const declared = applyAction(ix.game, ix.owner, copy);
|
||||
assert.ok(['arrakeen', 'sietch_tabr'].includes(declared.battle!.strongholdCopy!));
|
||||
const copiedBattle = reveal({ ...ix, copyAction: copy });
|
||||
const copiedQuote = quoteNativeSkillsStrongholdBattle(copiedBattle);
|
||||
assert.equal(copiedQuote.payments.find(p => p.player === ix.owner)!.ownPayment,
|
||||
declared.battle!.strongholdCopy === 'arrakeen' ? 0 : 1);
|
||||
const copiedResult = settle(copiedBattle);
|
||||
assert.equal(player(copiedResult, ix.owner).forces[ix.location], 3);
|
||||
assert.equal(player(copiedResult, ix.owner).tanks, 3);
|
||||
assertBattleWallets(copiedBattle, copiedResult);
|
||||
let markerPlans = structuredClone(marker.game);
|
||||
for (const actor of [marker.owner, marker.opponent]) {
|
||||
const view = viewGame(markerPlans, actor); view.players.find(p => p.id === actor)!.bot = difficulty;
|
||||
const plan = botActions(view).find(a => a.type === 'battlePlan'); assert.ok(plan);
|
||||
markerPlans = applyAction(markerPlans, actor, plan);
|
||||
for (let n = 0; !markerPlans.battle?.revealed && (markerPlans.response || markerPlans.decision) && n < 100; n++) {
|
||||
const next = nextNativeSkillsStrongholdStep(markerPlans); assert.ok(next);
|
||||
markerPlans = applyAction(markerPlans, next.actor, next.action);
|
||||
}
|
||||
}
|
||||
assert.equal(player(markerPlans, marker.owner).forces[marker.location], 3);
|
||||
const markerResult = settle(markerPlans);
|
||||
assertBattleWallets(markerPlans, markerResult); custody(markerResult);
|
||||
const stoneView = viewGame(stoneChoice, stone.owner); stoneView.players.find(p => p.id === stone.owner)!.bot = difficulty;
|
||||
const mode = botActions(stoneView)[0];
|
||||
const chosenStone = applyAction(stoneChoice, stone.owner, mode), stoneResult = settle(chosenStone);
|
||||
assert.equal(stoneResult.lastBattleContext!.winner, stone.owner);
|
||||
assert.equal(player(stoneResult, stone.owner).forces[stone.location], 6);
|
||||
for (const plan of stone.plans)
|
||||
assert.equal(player(stoneResult, plan.actor).leaders.find(l => l.id === plan.action.leader)!.dead, mode.mode === 'kill');
|
||||
assertBattleWallets(chosenStone, stoneResult); custody(stoneResult);
|
||||
for (const [f, pending] of [[ix, ixRescue], [marker, markerRescue]] as const) {
|
||||
const view = viewGame(pending, f.owner); view.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
const rescued = settle(applyAction(pending, f.owner, action));
|
||||
assert.equal(rescued.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(rescued, f.owner).tanks, f.kind === 'richese-marker' ? 0 : 3,
|
||||
`${difficulty}/${f.kind}: three real casualties saved, no unpaid cyborg manufactured`);
|
||||
assert.equal(player(rescued, f.opponent).tanks, f.kind === 'richese-marker' ? 6 : 8);
|
||||
if (f.kind === 'richese-marker') {
|
||||
assert.equal(player(rescued, f.owner).forces[f.location], 1);
|
||||
assert.equal(player(rescued, f.owner).reserves, 2);
|
||||
} else assert.equal(player(rescued, f.owner).forces[f.location], 3);
|
||||
custody(rescued);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -880,7 +880,7 @@ void test('Banker income rejects incompatible profiles and public activation wit
|
||||
game => { game.players.splice(1); },
|
||||
game => { game.players.push(...Array.from({ length: 4 }, (_, index) => newPlayer(`extra${index}`, `Extra ${index}`, 'guild'))); },
|
||||
];
|
||||
const modules = ['homeworlds', 'strongholdCards'].map(type => {
|
||||
const modules = ['homeworlds'].map(type => {
|
||||
const game = applyAction(native, native.host, { type, enabled: true });
|
||||
for (const player of game.players) player.ready = true;
|
||||
return game;
|
||||
|
||||
Reference in new issue
Block a user