Align Advanced Ix starting cards with Harkonnen pool
This commit is contained in:
1 parent
e530d267a3
commit
84fdd404c9
14 files changed
+637
-12
No files matched your search
@@ -258,7 +258,8 @@ export function createIxIndependentSkillsRevivalFixture(options: IxIndependentSk
|
||||
const afterBattle = finishIxIndependentSkillsBattle(f, { rescue: 'none' });
|
||||
const bribe: IxIndependentSkillsStep = { actor: f.opponent, action: { type: 'bribe', target: f.ixians, amount: 3 } };
|
||||
let game = stepIxIndependentSkills(afterBattle, bribe);
|
||||
game = advanceIxIndependentSkills(game, g => g.turn === 2 && g.phase === 4 && ixIndependentSkillsClean(g) && !g.pendingRevival);
|
||||
game = advanceIxIndependentSkills(game, g => g.turn === 2 && g.phase === 4 && ixIndependentSkillsClean(g) && !g.pendingRevival &&
|
||||
(!g.revivalOrder || g.revivalOrder.remaining[0] === f.ixians));
|
||||
const staging = [...f.staging]; conserveCard(game, f.ixians, RECRUITS_CARD_ID, staging);
|
||||
const beforeRecruits = structuredClone(game);
|
||||
const priorReturn: IxIndependentSkillsStep = { actor: f.ixians, action: { type: 'revive', amount: 1, elite: 0 } };
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeIxGameForAudit, initializeLeaderSkillsGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { treacheryDeck } from '../game/cards';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { validateLeaderSkills } from '../game/leader-skills';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export type IxStartingPoolStep = { actor: string; action: Action };
|
||||
export type IxStartingPoolOptions = { advanced?: boolean; skills?: boolean; harkonnen?: boolean };
|
||||
export const ixStartingPoolPlayer = (game: Game, actor: string) => {
|
||||
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||
};
|
||||
export const ixStartingPoolReload = (game: Game): Game => JSON.parse(JSON.stringify(game)) as Game;
|
||||
|
||||
function permutation<T>(source: readonly T[], desired: readonly T[]): number[] {
|
||||
const working = [...source], rolls: number[] = [];
|
||||
for (let index = working.length - 1; index > 0; index--) {
|
||||
const from = working.indexOf(desired[index]); assert.ok(from >= 0 && from <= index);
|
||||
rolls.push(Math.floor((from + 0.5) * 0x100000000 / (index + 1)));
|
||||
[working[index], working[from]] = [working[from], working[index]];
|
||||
}
|
||||
assert.deepEqual(working, desired); return rolls;
|
||||
}
|
||||
/** Only original shuffle draws are selected. UUID and byte entropy remain native. */
|
||||
function entropy<T>(rolls: readonly number[], run: () => T): T {
|
||||
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 (array instanceof Uint32Array && array.length === 1) array[0] = rolls[cursor++] ?? 0xffffffff;
|
||||
else Reflect.apply(native, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actIxStartingPool(game: Game, actor: string, action: Action,
|
||||
actions: IxStartingPoolStep[] = []): Game {
|
||||
const next = entropy([], () => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
|
||||
/** Includes the still-private native draft; historical references are not slots. */
|
||||
export function ixStartingPoolInventory(game: Game) {
|
||||
const cards = [...game.deck, ...game.discard, ...(game.ixSetupCards ?? []),
|
||||
...game.players.flatMap(player => player.hand), ...(game.auction?.cards.slice(game.auction.index) ?? [])]
|
||||
.map(card => card.id).sort();
|
||||
assert.deepEqual(cards, treacheryDeck(game.expansions).map(card => card.id).sort());
|
||||
const traitors = [...(game.traitorReserve ?? []), ...game.players.flatMap(player =>
|
||||
[...player.traitors, ...player.traitorChoices])].sort();
|
||||
if (traitors.length) assert.deepEqual(traitors, traitorDeck(game.players, true).sort());
|
||||
const skills = game.leaderSkills ? [...game.leaderSkills.deck,
|
||||
...game.leaderSkills.assignments.map(row => row.skill),
|
||||
...Object.values(game.leaderSkills.offers).flatMap(offer => offer.cards)].sort() : [];
|
||||
if (game.leaderSkills) {
|
||||
validateLeaderSkills(game.leaderSkills, game.players);
|
||||
assert.deepEqual(skills, LEADER_SKILL_CARDS.map(card => card.id).sort());
|
||||
}
|
||||
for (const player of game.players) {
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((sum, amount) => sum + amount, 0), 20);
|
||||
if (player.elites) assert.equal(player.elites.reserves + player.elites.tanks +
|
||||
Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0), player.faction === 'ixians' ? 7 : 5);
|
||||
}
|
||||
return { cards, traitors, skills };
|
||||
}
|
||||
|
||||
/** The original Ix packet is deliberately caller-owned; no draft choice is guessed. */
|
||||
export function nextIxStartingPoolStep(game: Game): IxStartingPoolStep | null {
|
||||
if (game.decision?.kind === 'ixSetup') return null;
|
||||
if (game.status === 'setup' && game.setupStage === 'leaderSkills') {
|
||||
const actor = Object.keys(game.leaderSkills!.offers)[0], own = viewGame(game, actor).leaderSkills!;
|
||||
const skill = own.offer!.cards.find(card => !own.unavailableSkills?.[card]);
|
||||
const leader = own.eligibleLeaders[0]; assert.ok(skill && leader);
|
||||
return { actor, action: { type: 'leaderSkill', event: own.offer!.event, skill, leader: leader.id } };
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceIxStartingPool(game: Game, until: (state: Game) => boolean,
|
||||
actions: IxStartingPoolStep[] = []): Game {
|
||||
for (let count = 0; count < 200; count++) {
|
||||
if (until(game)) return game;
|
||||
const step = nextIxStartingPoolStep(game); assert.ok(step, 'Choose the actual private Ix starting card before setup continues');
|
||||
game = actIxStartingPool(game, step.actor, step.action, actions);
|
||||
}
|
||||
throw Error('Original Ix starting-card setup did not reach its finite boundary');
|
||||
}
|
||||
export function ixStartingPoolChoice(game: Game, card: string): Action {
|
||||
const own = viewGame(game, 'ixians');
|
||||
assert.equal(game.decision?.kind, 'ixSetup');
|
||||
assert.ok(own.ixTechnology?.setup?.some(candidate => candidate.id === card));
|
||||
return { type: 'decision', card };
|
||||
}
|
||||
export function finishIxStartingPool(game: Game, actions: IxStartingPoolStep[] = []): Game {
|
||||
return advanceIxStartingPool(game, state => state.status === 'playing', actions);
|
||||
}
|
||||
|
||||
/** Original fresh Ix/Hark/Atreides setup, or Emperor in the no-Hark control.
|
||||
* The supported Ix or all14 preview initializer is used, not a public start.
|
||||
* Only initial shuffle entropy is selected. No dealt hand, phase, force, purse,
|
||||
* pending receipt or module flag is installed after initialization.
|
||||
*/
|
||||
export function createIxStartingPoolFixture(options: IxStartingPoolOptions = {}) {
|
||||
const advanced = options.advanced ?? true, skills = options.skills ?? false, harkonnen = options.harkonnen ?? true;
|
||||
const second = harkonnen ? 'harkonnen' : 'emperor';
|
||||
let game = createGame('IXSTARTINGPOOL', newPlayer('ixians', 'Ixians', 'ixians'), advanced, ['ix']);
|
||||
joinGame(game, newPlayer(second, second, second));
|
||||
joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
|
||||
const initial = structuredClone(game), actions: IxStartingPoolStep[] = [];
|
||||
for (const player of game.players) game = actIxStartingPool(game, player.id, { type: 'ready' }, actions);
|
||||
const lobby = structuredClone(game), original = treacheryDeck(['ix']);
|
||||
const prefix = [original.find(card => card.kind === 'projectile')!, original.find(card => card.kind === 'shield')!,
|
||||
original.find(card => card.kind === 'snooper')!, original.find(card => card.effect === 'karama')!];
|
||||
const deckOrder = [...prefix.map(card => card.id), ...original.filter(card => !prefix.some(first => first.id === card.id)).map(card => card.id)];
|
||||
const rolls = [...(skills ? Array<number>(LEADER_SKILL_CARDS.length - 1).fill(0xffffffff) : []),
|
||||
...permutation(original.map(card => card.id), deckOrder)];
|
||||
game = entropy(rolls, () => skills ? initializeLeaderSkillsGameForAudit(game) : initializeIxGameForAudit(game));
|
||||
const initialized = structuredClone(game);
|
||||
game = advanceIxStartingPool(game, state => state.decision?.kind === 'ixSetup', actions);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.decision?.player, 'ixians');
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.discoveryEnabled && !game.techTokens && !game.strongholdCards && !game.ecazTreachery);
|
||||
assert.equal(!!game.leaderSkills, skills);
|
||||
assert.deepEqual(game.ixSetupCards!.map(card => card.id), deckOrder.slice(0, game.ixSetupCards!.length));
|
||||
const stock = ixStartingPoolInventory(game);
|
||||
return { initial, lobby, initialized, game, advanced, skills, harkonnen, second,
|
||||
pool: structuredClone(game.ixSetupCards!), prefix: prefix.map(card => card.id), extra: prefix[3].id,
|
||||
nextAfterFour: deckOrder[4], deckOrder, stock, actions,
|
||||
readyActions: actions.filter(step => step.action.type === 'ready'),
|
||||
setupActions: actions.filter(step => step.action.type !== 'ready'),
|
||||
sourceCounts: { ready: actions.filter(step => step.action.type === 'ready').length,
|
||||
beforeDraft: actions.filter(step => step.action.type !== 'ready').length, source: actions.length },
|
||||
sourceDisclosure: 'Genuine fresh Ix/Harkonnen/Atreides (or Emperor control) preview initialization, with optional original all14 Skills. Actual Ready/setup actions are recorded; initializer is not an action or public-start acceptance. Only original initial skill/Treachery shuffle entropy selected. Source stops before the actual Ix choice; no hand, army, purse, phase or receipt is installed.' };
|
||||
}
|
||||
|
||||
/** Conserved old-count UNIT supplement, not a native historical save claim.
|
||||
* Move only the untouched extra fourth card back to the undealt top, restoring
|
||||
* the old Advanced N-card boundary without minting/dropping any physical card.
|
||||
*/
|
||||
export function createLegacyIxStartingPoolSupplement(skills = false) {
|
||||
const base = createIxStartingPoolFixture({ advanced: true, skills });
|
||||
const game = ixStartingPoolReload(base.game);
|
||||
assert.equal(game.ixSetupCards!.length, game.players.length + 1);
|
||||
const extra = game.ixSetupCards!.pop()!;
|
||||
game.deck.unshift(extra);
|
||||
assert.deepEqual(ixStartingPoolInventory(game), base.stock);
|
||||
return { ...base, game, pool: structuredClone(game.ixSetupCards!),
|
||||
sourceDisclosure: `${base.sourceDisclosure} Controlled conserved old-count unit supplement: the untouched fourth pool card returns to the undealt top to represent the earlier pending N-card contract. This is not claimed native old setup history.` };
|
||||
}
|
||||
@@ -231,7 +231,7 @@ void test('all AI profiles complete genuine private Ix setup through JSON contin
|
||||
assert.ok(g.players.every((p) => p.hand.length === 0));
|
||||
|
||||
g = reachIxChoice(g, difficulty);
|
||||
assert.equal(g.ixSetupCards?.length, ROSTER.length);
|
||||
assert.equal(g.ixSetupCards?.length, ROSTER.length + (advanced ? 1 : 0));
|
||||
const ids = g.ixSetupCards!.map((card) => card.id);
|
||||
assert.deepEqual(viewGame(g, 'i').ixTechnology?.setup, g.ixSetupCards);
|
||||
for (const observer of ['t', 'a', 'h']) {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import type { Difficulty } from '../game/bot-profiles';
|
||||
import {
|
||||
actIxStartingPool as act, createIxStartingPoolFixture as fixture,
|
||||
createLegacyIxStartingPoolSupplement as legacy, finishIxStartingPool as finish,
|
||||
ixStartingPoolChoice as choice, ixStartingPoolInventory as inventory,
|
||||
ixStartingPoolPlayer as p, ixStartingPoolReload as reload, type IxStartingPoolStep,
|
||||
} from './fixture-ix-starting-pool';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = reload(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const skills of [false, true]) {
|
||||
void test(`genuine ${advanced ? 'Advanced' : 'Basic'} Ix starting pool ${skills ? 'with all14' : 'without Skills'} preserves original stock and native setup order`, () => {
|
||||
const f = fixture({ advanced, skills });
|
||||
assert.equal(f.sourceCounts.ready, f.readyActions.length);
|
||||
assert.equal(f.sourceCounts.beforeDraft, f.setupActions.length);
|
||||
assert.equal(f.sourceCounts.source, f.actions.length);
|
||||
assert.equal(f.pool.length, f.game.players.length + (advanced ? 1 : 0));
|
||||
assert.equal(f.game.players.every(player => player.hand.length === 0), true);
|
||||
assert.deepEqual(viewGame(f.game, 'ixians').ixTechnology?.setup?.map(card => card.id), f.pool.map(card => card.id));
|
||||
assert.deepEqual(inventory(f.game).cards, f.stock.cards);
|
||||
if (skills) {
|
||||
assert.equal(Object.keys(f.game.leaderSkills!.offers).length, 0);
|
||||
assert.equal(f.game.leaderSkills!.assignments.length, 0);
|
||||
assert.equal(f.actions.some(step => step.action.type === 'leaderSkill' || step.action.type === 'traitor'), false);
|
||||
} else assert.ok(f.actions.some(step => step.action.type === 'traitor'));
|
||||
});
|
||||
}
|
||||
|
||||
void test('fresh Advanced Ix may choose the actual fourth card and Harkonnen receives two from that same inspected remainder', () => {
|
||||
for (const skills of [false, true]) {
|
||||
const f = fixture({ advanced: true, skills }), beforeTop = f.game.deck[0].id;
|
||||
assert.equal(f.pool[3].id, f.extra);
|
||||
const game = finish(act(reload(f.game), 'ixians', choice(f.game, f.extra)));
|
||||
assert.deepEqual(p(game, 'ixians').hand.map(card => card.id), [f.extra]);
|
||||
const remainder = f.pool.filter(card => card.id !== f.extra).map(card => card.id);
|
||||
assert.equal(p(game, 'harkonnen').hand.length, 2);
|
||||
assert.ok(p(game, 'harkonnen').hand.every(card => remainder.includes(card.id)));
|
||||
assert.deepEqual([...p(game, 'harkonnen').hand, ...p(game, 'atreides').hand].map(card => card.id).sort(), remainder.sort());
|
||||
assert.equal(game.deck[0].id, beforeTop);
|
||||
assert.equal(game.deck[0].id, f.nextAfterFour, 'Fresh Advanced consumes no extra undealt card after the pool is dealt');
|
||||
assert.deepEqual(inventory(game).cards, f.stock.cards);
|
||||
assert.deepEqual(inventory(game).skills, f.stock.skills);
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic keeps the fourth card outside Ix inspection and actually deals it as the separate Harkonnen top-deck bonus', () => {
|
||||
for (const skills of [false, true]) {
|
||||
const f = fixture({ advanced: false, skills });
|
||||
assert.equal(f.game.deck[0].id, f.extra);
|
||||
assert.equal(f.pool.some(card => card.id === f.extra), false);
|
||||
reject(f.game, 'ixians', { type: 'decision', card: f.extra });
|
||||
const selected = f.pool[0].id;
|
||||
const game = finish(act(reload(f.game), 'ixians', choice(f.game, selected)));
|
||||
assert.deepEqual(p(game, 'ixians').hand.map(card => card.id), [selected]);
|
||||
assert.equal(p(game, 'harkonnen').hand.length, 2);
|
||||
assert.ok(p(game, 'harkonnen').hand.some(card => card.id === f.extra));
|
||||
assert.equal(p(game, 'harkonnen').hand.filter(card => f.pool.some(source => source.id === card.id)).length, 1);
|
||||
assert.equal(game.deck[0].id, f.nextAfterFour);
|
||||
assert.deepEqual(inventory(game).cards, f.stock.cards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('without a Harkonnen seat both Basic and Advanced retain the native one-card-per-faction draft', () => {
|
||||
for (const advanced of [false, true]) for (const skills of [false, true]) {
|
||||
const f = fixture({ advanced, skills, harkonnen: false });
|
||||
assert.equal(f.pool.length, f.game.players.length);
|
||||
const before = f.game.deck[0].id;
|
||||
const game = finish(act(reload(f.game), 'ixians', choice(f.game, f.pool[0].id)));
|
||||
assert.ok(game.players.every(player => player.hand.length === 1));
|
||||
assert.equal(game.deck[0].id, before);
|
||||
assert.deepEqual(inventory(game).cards, f.stock.cards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native Ix choice precedes all real skill offers assignments and Traitor selections in both modes', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = fixture({ advanced, skills: true }), actions: IxStartingPoolStep[] = [];
|
||||
const selected = f.pool.at(-1)!.id;
|
||||
let game = act(reload(f.game), 'ixians', choice(f.game, selected), actions);
|
||||
assert.equal(game.setupStage, 'leaderSkills');
|
||||
assert.equal(Object.keys(game.leaderSkills!.offers).length, game.players.length);
|
||||
assert.equal(game.leaderSkills!.assignments.length, 0);
|
||||
assert.equal(p(game, 'harkonnen').hand.length, 2);
|
||||
game = finish(game, actions);
|
||||
assert.equal(actions[0].action.card, selected);
|
||||
const firstSkill = actions.findIndex(step => step.action.type === 'leaderSkill');
|
||||
const firstTraitor = actions.findIndex(step => step.action.type === 'traitor');
|
||||
assert.ok(firstSkill > 0 && firstTraitor > firstSkill);
|
||||
assert.equal(game.leaderSkills!.assignments.length, game.players.length);
|
||||
assert.deepEqual(inventory(game).cards, f.stock.cards);
|
||||
assert.deepEqual(inventory(game).skills, f.stock.skills);
|
||||
}
|
||||
});
|
||||
|
||||
void test('native draft rejects another actor foreign card and replay after its actual one choice', () => {
|
||||
const f = fixture({ advanced: true, skills: true }), packet = choice(f.game, f.extra);
|
||||
reject(f.game, 'harkonnen', packet);
|
||||
reject(f.game, 'ixians', { type: 'decision', card: f.game.deck[0].id });
|
||||
const game = act(reload(f.game), 'ixians', packet);
|
||||
reject(game, 'ixians', packet);
|
||||
assert.equal(game.ixSetupCards, null);
|
||||
assert.equal(p(game, 'ixians').hand.filter(card => card.id === f.extra).length, 1);
|
||||
assert.deepEqual(inventory(game).cards, f.stock.cards);
|
||||
});
|
||||
|
||||
void test('controlled conserved old Advanced N-card draft completes its old separate top-deck Harkonnen bonus', () => {
|
||||
for (const skills of [false, true]) {
|
||||
const f = legacy(skills);
|
||||
assert.match(f.sourceDisclosure, /Controlled conserved old-count unit supplement/);
|
||||
assert.equal(f.pool.length, f.game.players.length);
|
||||
assert.equal(f.game.deck[0].id, f.extra);
|
||||
const game = finish(act(reload(f.game), 'ixians', choice(f.game, f.pool[0].id)));
|
||||
assert.equal(p(game, 'harkonnen').hand.length, 2);
|
||||
assert.ok(p(game, 'harkonnen').hand.some(card => card.id === f.extra));
|
||||
assert.equal(game.deck[0].id, f.nextAfterFour);
|
||||
assert.deepEqual(inventory(game).cards, f.stock.cards);
|
||||
assert.deepEqual(inventory(game).skills, f.stock.skills);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all four minimal profiles legally choose from the fresh Advanced pool and finish original all14 setup', () => {
|
||||
const f = fixture({ advanced: true, skills: true });
|
||||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] satisfies Difficulty[]) {
|
||||
let game = reload(f.game), accepted = 0;
|
||||
const own = viewGame(game, 'ixians'); own.players.find(player => player.id === 'ixians')!.bot = difficulty;
|
||||
const proposal = botActions(own).find(action => action.type === 'decision' && f.pool.some(card => card.id === action.card));
|
||||
assert.ok(proposal, `${difficulty} chooses one actual inspected card`);
|
||||
game = act(game, 'ixians', proposal); accepted++;
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
let progressed = false;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id); view.players.find(row => row.id === player.id)!.bot = difficulty;
|
||||
const action = botActions(view)[0];
|
||||
if (!action) continue;
|
||||
game = act(game, player.id, action); accepted++; progressed = true; break;
|
||||
}
|
||||
assert.ok(progressed, `${difficulty} finishes actual original setup`);
|
||||
}
|
||||
assert.equal(game.status, 'playing'); assert.ok(accepted > 1);
|
||||
assert.equal(p(game, 'harkonnen').hand.length, 2);
|
||||
assert.equal(game.deck[0].id, f.nextAfterFour);
|
||||
assert.deepEqual(inventory(game).cards, f.stock.cards);
|
||||
assert.deepEqual(inventory(game).skills, f.stock.skills);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
IxStartingCardError,
|
||||
ixStartingCardDealPlan,
|
||||
ixStartingCardPoolSize,
|
||||
type IxStartingCardGame,
|
||||
} from '../game/ixian-starting-cards';
|
||||
|
||||
function game(advanced: boolean, factions: readonly string[]): IxStartingCardGame {
|
||||
return { advanced, players: factions.map((faction, index) => ({ id: `seat-${index}`, faction })) };
|
||||
}
|
||||
const otherFactions = ['atreides', 'emperor', 'guild', 'fremen'] as const;
|
||||
|
||||
void test('fresh Ix pools add Harkonnen only in Advanced, from two through six seats', () => {
|
||||
for (let count = 2; count <= 6; count++) {
|
||||
for (const advanced of [false, true]) {
|
||||
const harkonnen = game(advanced, ['ixians', 'harkonnen', ...otherFactions.slice(0, count - 2)]);
|
||||
assert.equal(ixStartingCardPoolSize(harkonnen), count + (advanced ? 1 : 0));
|
||||
const ordinary = game(advanced, ['ixians', ...otherFactions, 'beneGesserit'].slice(0, count));
|
||||
assert.equal(ixStartingCardPoolSize(ordinary), count);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('new Advanced pools deal Harkonnen two from the shuffled remainder in original seat order', () => {
|
||||
for (let count = 2; count <= 6; count++) {
|
||||
for (const ixFirst of [false, true]) {
|
||||
const factions = ixFirst ? ['ixians', 'harkonnen'] : ['harkonnen', 'ixians'];
|
||||
const native = game(true, [...factions, ...otherFactions.slice(0, count - 2)]);
|
||||
const before = structuredClone(native);
|
||||
Object.freeze(native.players);
|
||||
Object.freeze(native);
|
||||
const plan = ixStartingCardDealPlan(native, count + 1);
|
||||
assert.deepEqual(plan, {
|
||||
kind: 'advanced-pool',
|
||||
seats: native.players.filter(player => player.faction !== 'ixians').map(player => ({
|
||||
player: player.id, amount: player.faction === 'harkonnen' ? 2 : 1,
|
||||
})),
|
||||
deckBonus: null,
|
||||
});
|
||||
assert.equal(plan.seats.reduce((amount, seat) => amount + seat.amount, 0), count);
|
||||
assert.deepEqual(native, before);
|
||||
plan.seats[0].amount = 99;
|
||||
assert.deepEqual(native, before);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('Basic and old Advanced N-card or short pools keep one-per-other then the Harkonnen deck bonus', () => {
|
||||
for (let count = 2; count <= 6; count++) {
|
||||
for (const advanced of [false, true]) {
|
||||
const native = game(advanced, ['harkonnen', ...otherFactions.slice(0, count - 2), 'ixians']);
|
||||
for (const poolCount of new Set([1, count - 1, count])) {
|
||||
assert.deepEqual(ixStartingCardDealPlan(native, poolCount), {
|
||||
kind: 'legacy-deck-bonus',
|
||||
seats: native.players.filter(player => player.faction !== 'ixians').map(player => ({ player: player.id, amount: 1 })),
|
||||
deckBonus: 'seat-0',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('without Harkonnen both modes deal one per other seat with no extra deck draw', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const native = game(advanced, ['atreides', 'ixians', 'fremen', 'guild']);
|
||||
for (const count of [1, 3, 4]) {
|
||||
assert.deepEqual(ixStartingCardDealPlan(native, count), {
|
||||
kind: 'legacy-deck-bonus',
|
||||
seats: [{ player: 'seat-0', amount: 1 }, { player: 'seat-2', amount: 1 }, { player: 'seat-3', amount: 1 }],
|
||||
deckBonus: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('a pool larger than its native source or an invalid count cannot select a new deal mode', () => {
|
||||
const advanced = game(true, ['ixians', 'harkonnen']);
|
||||
const basic = game(false, ['ixians', 'harkonnen']);
|
||||
const noHarkonnen = game(true, ['ixians', 'atreides']);
|
||||
for (const count of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 1, 4])
|
||||
assert.throws(() => ixStartingCardDealPlan(advanced, count), IxStartingCardError);
|
||||
assert.throws(() => ixStartingCardDealPlan(basic, 3), IxStartingCardError);
|
||||
assert.throws(() => ixStartingCardDealPlan(noHarkonnen, 3), IxStartingCardError);
|
||||
});
|
||||
|
||||
void test('the planner rejects missing Ixians, unregistered or duplicate seats and invalid native roster bounds', () => {
|
||||
const valid = game(true, ['ixians', 'harkonnen']);
|
||||
const invalid: unknown[] = [
|
||||
null,
|
||||
{ ...valid, advanced: 'advanced' },
|
||||
{ ...valid, players: undefined },
|
||||
game(true, ['ixians']),
|
||||
game(true, ['ixians', 'harkonnen', ...otherFactions, 'beneGesserit']),
|
||||
game(true, ['atreides', 'harkonnen']),
|
||||
game(true, ['ixians', 'ixians']),
|
||||
game(true, ['ixians', 'unknown']),
|
||||
{ ...valid, players: [{ id: 'seat', faction: 'ixians' }, { id: 'seat', faction: 'harkonnen' }] },
|
||||
{ ...valid, players: [{ id: '', faction: 'ixians' }, valid.players[1]] },
|
||||
{ ...valid, players: [valid.players[0], { id: ' seat ', faction: 'harkonnen' }] },
|
||||
];
|
||||
for (const native of invalid) {
|
||||
assert.throws(() => ixStartingCardPoolSize(native as IxStartingCardGame), IxStartingCardError);
|
||||
assert.throws(() => ixStartingCardDealPlan(native as IxStartingCardGame, 2), IxStartingCardError);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user