Connect Guild Nexus Betrayal receipt to Bureaucrat per ruling

This commit is contained in:
Quintine committed 2026-10-13 17:47:00 +11:00
1 parent 9b83b61669
commit df9bbc5e04
11 files changed
+534 -16

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { bureaucratAlliedFundingModeSupported, bureaucratPaymentModeSupported, bureaucratPaymentSignature } from '../game/bureaucrat-payment';
import { guildBetrayalNexusLeaderSkillsProfile } from '../game/leader-skill-profile';
import { validateLeaderSkills } from '../game/leader-skills';
import { validateNexusCards } from '../game/nexus-cards';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
import { createGuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
import {
betrayalFundingSeat as seat, chooseBetrayalFunding, createBureaucratBetrayalFundingFixture,
settleBetrayalFunding, useBetrayalFunding, type BureaucratBetrayalFundingFixture,
} from './fixture-bureaucrat-betrayal-funding';
function physicalShipment(game: Game) {
return { forces: game.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves,
tanks: p.tanks, elites: p.elites, advisors: p.advisors, shipped: p.shipped, moved: p.moved })),
phase: game.phase, active: game.active, remaining: game.movementRemaining };
}
/** The Bank has no wallet field: its exact net receipt is the decrease in all
* player-controlled spice, including deferred bribes and actual aid escrow. */
function playerControlledSpice(game: Game): number {
return game.players.reduce((total, p) => total + p.spice + p.bribes, 0) +
Object.values(game.aid).reduce((total, aid) => total + aid.amount, 0);
}
function rejectUnchanged(game: Game, actor: string, action: Action): void {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before, 'A rejected choice must not mutate any saved resource or continuation.');
}
function proveReceipt(f: BureaucratBetrayalFundingFixture, done: Game, diverted: number): void {
const invoice = f.game.pendingGuildBetrayal!.invoice;
assert.equal(invoice.price, f.primary + f.donorAmount);
assert.equal(invoice.ownPayment, f.primary);
assert.equal(invoice.allyPayment, f.donorAmount);
for (const p of done.players) {
assert.equal(p.spice, seat(f.beforePledge, p.id).spice -
(p.id === f.shipper ? f.primary : 0) - (p.id === f.donor ? f.donorAmount : 0) +
(p.id === f.holder ? f.price - diverted : 0), `Exactly the original debit/receipt for ${p.id}`);
assert.equal(p.bribes, seat(f.beforePledge, p.id).bribes);
}
if (f.donor) {
assert.equal(seat(f.before, f.donor).spice, seat(f.beforePledge, f.donor).spice - f.donorAmount);
assert.equal(f.before.aid[f.donor].amount, f.donorAmount);
assert.equal(done.aid[f.donor].amount, 0, 'The native donor escrow is consumed once, without a second wallet debit.');
}
assert.equal(playerControlledSpice(f.before) - playerControlledSpice(done), diverted,
'Only the ruled diversion leaves player custody for the implicit Spice Bank.');
assert.deepEqual(physicalShipment(done), physicalShipment(f.original), 'The original native shipment suffix runs once.');
const destination = `${String(f.declaration.territory)}:${Number(f.declaration.sector)}`;
assert.equal(seat(done, f.shipper).reserves, seat(f.before, f.shipper).reserves - f.price);
assert.equal(seat(done, f.shipper).forces[destination], (seat(f.before, f.shipper).forces[destination] ?? 0) + f.price);
assert.equal(seat(done, f.shipper).shipped, true);
assert.equal(done.pendingGuildBetrayal, null);
assert.equal(done.guildBetrayal!.status, 'completed');
assert.equal(done.nexusGuildBetrayalHistory!.length, f.before.nexusGuildBetrayalHistory!.length + 1);
assert.equal(done.guildBetrayalSourceReceipts!.length, f.before.guildBetrayalSourceReceipts!.length + 1);
const receipt = done.nexusGuildBetrayalHistory!.at(-1)!;
assert.deepEqual(receipt.invoice, invoice, 'Bureaucrat does not rewrite the gross invoice or its original funding.');
assert.equal(receipt.recipient, f.holder);
assert.equal(receipt.mode, 'betrayal');
assert.equal(receipt.nexusDiscardIndex, f.before.nexusCards!.cards!.discard.length);
assert.equal(done.guildBetrayalSourceReceipts!.at(-1)!.receipt, receipt.sourceReceipt);
assert.equal(done.nexusCards!.cards!.hands[f.holder], null);
assert.deepEqual(done.nexusCards!.cards!.discard, [...f.before.nexusCards!.cards!.discard, 'guild']);
validateNexusCards(done.nexusCards!.cards!, done.players);
assert.equal(done.bureaucratPayments?.pending, undefined);
rejectUnchanged(done, f.holder, { type: 'guildBetrayalUse', event: f.event });
rejectUnchanged(done, f.shipper, f.declaration);
const ended = applyAction(done, f.shipper, { type: 'endMovement' });
assert.notEqual(ended.active, f.shipper);
assert.deepEqual(ended.nexusGuildBetrayalHistory, done.nexusGuildBetrayalHistory);
assert.deepEqual(ended.guildBetrayalSourceReceipts, done.guildBetrayalSourceReceipts);
assert.deepEqual(ended.players.map(p => p.forces), done.players.map(p => p.forces));
rejectUnchanged(ended, f.shipper, { type: 'endMovement' });
}
void test('Fresh native Guild classic setup genuinely trains its distinct Bureaucrat and preserves all14 custody', () => {
const f = createBureaucratBetrayalFundingFixture({ donorAmount: 3 });
assert.equal(f.initial.status, 'setup');
assert.equal(f.initial.leaderSkills!.assignments.length, 0);
assert.equal(f.trained.status, 'setup');
assert.equal(f.trained.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')?.owner, f.trainer);
assert.ok(![f.shipper, f.holder, f.guild, f.donor].includes(f.trainer));
validateLeaderSkills(f.trained.leaderSkills!, f.trained.players);
const skills = f.trained.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());
assert.equal(guildBetrayalNexusLeaderSkillsProfile(f.game), true);
assert.equal(bureaucratPaymentModeSupported(f.game), true);
assert.equal(bureaucratAlliedFundingModeSupported(f.game), true);
});
for (const advanced of [false, true]) {
for (const [primary, donorAmount] of [[5, 0], [5, 3], [3, 5], [5, 5], [0, 5]]) {
for (const redirect of [false, true]) {
void test(`${advanced ? 'Advanced' : 'Basic'} Betrayal ${primary}+${donorAmount}: ${redirect ? 'holder gross minus two; Bank two' : 'holder retains gross'}`, () => {
const f = createBureaucratBetrayalFundingFixture({ advanced, primary, donorAmount });
const pending = useBetrayalFunding(f);
assert.equal(pending.decision?.kind, 'bureaucratPayment');
assert.equal(pending.decision?.player, f.trainer);
assert.equal(seat(pending, f.holder).spice, seat(f.before, f.holder).spice + f.price);
assert.deepEqual(physicalShipment(pending), physicalShipment(f.original));
const source = pending.bureaucratPayments!.pending!.source;
assert.equal(source.kind, 'shipment');
assert.equal(source.payee, f.holder);
assert.equal(source.amount, f.price);
assert.equal(source.payer, primary >= 5 ? f.shipper : f.donor);
if (f.donor) assert.deepEqual(source.contributions, [
...(primary ? [{ payer: f.shipper, amount: primary }] : []), { payer: f.donor, amount: donorAmount },
]);
const done = settleBetrayalFunding(chooseBetrayalFunding(pending, redirect));
proveReceipt(f, done, redirect ? 2 : 0);
assert.deepEqual(done.nexusGuildBetrayalHistory, pending.nexusGuildBetrayalHistory);
assert.deepEqual(done.guildBetrayalSourceReceipts, pending.guildBetrayalSourceReceipts);
assert.deepEqual(done.nexusCards!.cards, pending.nexusCards!.cards);
assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, redirect);
assert.equal(done.bureaucratPayments?.used.length ?? 0, redirect ? 1 : 0);
rejectUnchanged(done, f.trainer, { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true });
});
}
}
for (const options of [{ own: true }, { primary: 3, donorAmount: 3 }, { alreadyUsed: true }]) {
void test(`${advanced ? 'Advanced' : 'Basic'} Betrayal no offer ${JSON.stringify(options)}`, () => {
const f = createBureaucratBetrayalFundingFixture({ advanced, ...options });
const taken = useBetrayalFunding(f);
assert.notEqual(taken.decision?.kind, 'bureaucratPayment');
assert.equal(taken.bureaucratPayments?.pending, undefined);
proveReceipt(f, settleBetrayalFunding(taken), 0);
assert.equal(viewGame(taken, f.trainer).bureaucrat.usedThisPhase, 'alreadyUsed' in options);
if ('own' in options) assert.equal(f.holder, f.shipper);
if ('donorAmount' in options) {
assert.ok(f.price >= 5);
assert.ok(f.primary < 5 && f.donorAmount < 5, 'Gross never pools sub-five native legs.');
}
});
}
void test(`${advanced ? 'Advanced' : 'Basic'} private receipt hides which native leg qualifies`, () => {
const primary = createBureaucratBetrayalFundingFixture({ advanced, primary: 5, donorAmount: 3 });
const donor = createBureaucratBetrayalFundingFixture({ advanced, primary: 3, donorAmount: 5 });
const left = useBetrayalFunding(primary), right = useBetrayalFunding(donor);
for (const viewer of left.players) {
const a = viewGame(left, viewer.id), b = viewGame(right, viewer.id);
for (const projected of [a, b]) {
const prompt = projected.bureaucrat.pending!;
assert.equal(prompt.payer, null);
assert.equal(prompt.payee, primary.holder);
assert.equal(prompt.amount, 8);
assert.equal(prompt.owner, primary.trainer);
assert.equal(prompt.kind, 'shipment');
assert.equal(prompt.redirect, 2);
assert.equal('contributions' in prompt, false);
assert.equal('signature' in prompt, false);
assert.equal('bureaucratPayments' in projected, false);
assert.equal('bureaucratUseEvents' in projected, false);
}
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} stale, cross-actor and malformed continuation choices reject immutably`, () => {
const f = createBureaucratBetrayalFundingFixture({ advanced, primary: 3, donorAmount: 5 });
const pending = useBetrayalFunding(f);
const choice: Action = { type: 'decision', event: pending.bureaucratPaymentEvent, redirect: true };
for (const actor of pending.players.filter(p => p.id !== f.trainer)) rejectUnchanged(pending, actor.id, choice);
rejectUnchanged(pending, f.trainer, { ...choice, event: f.event });
rejectUnchanged(pending, f.trainer, { ...choice, event: 'stale-payment' });
rejectUnchanged(pending, f.trainer, { ...choice, redirect: 'yes' });
rejectUnchanged(pending, f.trainer, { ...choice, amount: f.price });
rejectUnchanged(pending, f.shipper, { type: 'endMovement' });
rejectUnchanged(pending, f.holder, { type: 'guildBetrayalUse', event: f.event });
for (const field of ['event', 'recipient', 'amount', 'kind']) {
const malformed = structuredClone(pending);
const frame = malformed.bureaucratPayments!.pending!;
assert.equal(frame.continuation.kind, 'guildBetrayal');
Object.assign(frame.continuation, { [field]: field === 'amount' ? f.price + 1 : field === 'recipient' ? f.guild : 'foreign' });
// Re-sign the outer frame to exercise the native receipt/continuation link,
// not merely the generic changed-signature check.
frame.signature = bureaucratPaymentSignature(frame);
const saved = structuredClone(malformed);
assert.throws(() => viewGame(malformed, f.trainer));
assert.throws(() => normalizeAutomaticGame(malformed));
rejectUnchanged(malformed, f.trainer, choice);
assert.deepEqual(malformed, saved);
}
proveReceipt(f, settleBetrayalFunding(chooseBetrayalFunding(pending, true)), 2);
});
void test(`${advanced ? 'Advanced' : 'Basic'} all four minimal owner bots legally resolve the original Betrayal receipt`, () => {
const f = createBureaucratBetrayalFundingFixture({ advanced, primary: 3, donorAmount: 5 });
const pending = useBetrayalFunding(f);
assert.equal(DIFFICULTIES.length, 4);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(pending, f.trainer);
view.players.find(p => p.id === f.trainer)!.bot = difficulty;
const choices = botActions(view);
assert.equal(choices.length, 1);
const done = settleBetrayalFunding(applyAction(pending, f.trainer, choices[0]));
proveReceipt(f, done, 2);
assert.equal(viewGame(done, f.trainer).bureaucrat.usedThisPhase, true);
rejectUnchanged(done, f.trainer, choices[0]);
}
});
void test(`${advanced ? 'Advanced' : 'Basic'} native no-skills Betrayal still credits its entire funded invoice without an offer`, () => {
const f = createGuildBetrayalFixture({ advanced, allyPayment: 1 });
const taken = applyAction(f.game, f.holder, { type: 'guildBetrayalUse', event: f.event });
assert.notEqual(taken.decision?.kind, 'bureaucratPayment');
assert.equal(taken.bureaucratPayments?.pending, undefined);
const done = settleBetrayalFunding(taken);
assert.equal(seat(done, f.holder).spice, seat(f.before, f.holder).spice + f.price);
assert.equal(seat(done, f.shipper).spice, seat(f.before, f.shipper).spice - f.game.pendingGuildBetrayal!.invoice.ownPayment);
assert.equal(seat(done, f.donor!).spice, seat(f.before, f.donor!).spice);
assert.equal(done.aid[f.donor!].amount, 0);
assert.equal(playerControlledSpice(done), playerControlledSpice(f.before));
assert.deepEqual(physicalShipment(done), physicalShipment(f.original));
assert.deepEqual(done.nexusGuildBetrayalHistory!.at(-1)!.invoice, f.game.pendingGuildBetrayal!.invoice);
assert.equal(done.nexusGuildBetrayalHistory!.at(-1)!.recipient, f.holder);
assert.deepEqual(done.nexusCards!.cards!.discard, [...f.before.nexusCards!.cards!.discard, 'guild']);
rejectUnchanged(done, f.holder, { type: 'guildBetrayalUse', event: f.event });
});
}
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import {
applyAction, createGame, initializeGuildBetrayalLeaderSkillsGameForAudit, joinGame,
newPlayer, viewGame, type Action, type Game,
} from '../game/engine';
import type { FactionId } from '../game/catalog';
import { LEADER_SKILL_CARDS, type LeaderSkillId } from '../game/leader-skill-cards';
import { validateLeaderSkills } from '../game/leader-skills';
import { createGuildBetrayalFixture, type GuildBetrayalFixture } from './fixture-nexus-guild-betrayal';
export const betrayalFundingSeat = (game: Game, id: string) => game.players.find(p => p.id === id)!;
/** Scope exactly the thirteen Fisher–Yates draws of the original all14 deck;
* all later setup entropy remains native. No assignment or offer is replaced. */
function skillLottery<T>(selected: LeaderSkillId[], operation: () => T): T {
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()!]), ...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 (array instanceof Uint32Array && array.length === 1 && cursor < rolls.length)
array[0] = rolls[cursor++];
else Reflect.apply(native, crypto, [array]);
return array;
};
try { const result = operation(); assert.equal(cursor, 13); return result; }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
function trainedSetup(advanced: boolean, allied: boolean): { initial: Game; trained: Game; trainer: string } {
const roster: [string, FactionId, LeaderSkillId][] = [
['holder', 'atreides', 'warmaster'], ['guild', 'guild', 'killer-medic'],
['shipper', 'emperor', 'master-of-assassins'],
...(allied ? [['donor', 'harkonnen', 'swordmaster-of-ginaz'] as [string, FactionId, LeaderSkillId]] : []),
['trainer', 'beneGesserit', 'bureaucrat'],
];
let game = createGame('BUREAUCRATBETRAYAL', newPlayer(roster[0][0], roster[0][0], roster[0][1]), advanced, []);
for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, id, faction));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = skillLottery(roster.map(([, , skill]) => skill), () => initializeGuildBetrayalLeaderSkillsGameForAudit(game));
const initial = structuredClone(game);
assert.equal(game.status, 'setup');
if (game.setupStage === 'prediction')
game = applyAction(game, 'trainer', { type: 'predict', faction: 'emperor', turn: 4 });
assert.equal(game.setupStage, 'leaderSkills');
while (game.setupStage === 'leaderSkills') {
const actor = Object.keys(game.leaderSkills!.offers)[0];
const offer = viewGame(game, actor).leaderSkills!;
const skill = roster.find(([id]) => id === actor)![2];
assert.ok(offer.offer!.cards.includes(skill) && !offer.unavailableSkills?.[skill]);
const leader = offer.eligibleLeaders[0];
assert.ok(leader);
game = applyAction(game, actor, { type: 'leaderSkill', event: offer.offer!.event, skill, leader: leader.id });
}
assert.equal(game.status, 'setup', 'The shared fixture owns the remaining native setup.');
validateLeaderSkills(game.leaderSkills!, game.players);
assert.equal(game.leaderSkills!.assignments.find(a => a.skill === 'bureaucrat')?.owner, 'trainer');
return { initial, trained: game, trainer: 'trainer' };
}
/** Finish only real native acknowledgements/income, declining optional ordinary
* Bureaucrat income for the all-pass physical-shipment comparison. */
export function settleBetrayalFunding(state: Game): Game {
let game = state;
for (let n = 0; n < 100; n++) {
if (game.pendingGuildBetrayal) {
const frame = game.pendingGuildBetrayal;
const actor = frame.required.find(id => !frame.passed.includes(id))!;
game = applyAction(game, actor, { type: 'guildBetrayalPass', event: frame.invoice.event });
} else if (game.decision?.kind === 'bureaucratPayment') {
game = chooseBetrayalFunding(game, false);
} else if (game.decision?.kind === 'guildShipment') {
game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
} else if (game.decision?.kind === 'advisor') {
game = applyAction(game, game.decision.player, { type: 'decision', accept: false });
} else if (game.response) {
const actor = game.players.find(p => !game.response!.passed.includes(p.id))!.id;
game = applyAction(game, actor, { type: 'passResponse' });
} else return game;
}
throw Error('The original Betrayal shipment continuation did not settle.');
}
export function chooseBetrayalFunding(game: Game, redirect: boolean): Game {
assert.equal(game.decision?.kind, 'bureaucratPayment');
return applyAction(game, game.decision!.player, { type: 'decision', event: game.bureaucratPaymentEvent, redirect });
}
export interface BureaucratBetrayalFundingFixture extends GuildBetrayalFixture {
initial: Game;
trained: Game;
trainer: string;
beforePledge: Game;
primary: number;
donorAmount: number;
}
/** Reuse the shared native fixture's conserved card custody and original setup,
* then declare the requested larger shipment from its pre-declaration boundary.
* The provisional two-force branch is never replayed into this branch. Wallets,
* forces, signed invoices, escrow and usage ledgers are not assigned here. */
export function createBureaucratBetrayalFundingFixture({
advanced = false, primary = 5, donorAmount = 0, own = false, alreadyUsed = false,
}: { advanced?: boolean; primary?: number; donorAmount?: number; own?: boolean; alreadyUsed?: boolean } = {}): BureaucratBetrayalFundingFixture {
const setup = trainedSetup(advanced, donorAmount > 0);
const base = createGuildBetrayalFixture({ initial: setup.trained, advanced,
payer: own ? 'holder' : 'other', allyPayment: donorAmount > 0 ? 1 : 0 });
let game = structuredClone(base.before);
if (base.donor) game = applyAction(game, base.donor, { type: 'pledgeAid', amount: 0 });
if (alreadyUsed) {
assert.ok(betrayalFundingSeat(game, base.shipper).spice >= primary + 5);
game = applyAction(game, base.shipper, { type: 'bribe', target: base.guild, amount: 5 });
game = chooseBetrayalFunding(game, true);
assert.equal(viewGame(game, setup.trainer).bureaucrat.usedThisPhase, true);
}
const beforePledge = structuredClone(game);
if (base.donor) game = applyAction(game, base.donor, { type: 'pledgeAid', amount: donorAmount });
assert.ok(betrayalFundingSeat(game, base.shipper).spice >= primary, 'The original wallet must fund its real invoice leg.');
const before = structuredClone(game);
const declaration: Action = { ...base.declaration, amount: primary + donorAmount, allyPayment: donorAmount };
game = applyAction(game, base.shipper, declaration);
if (game.decision?.kind === 'guildShipment')
game = applyAction(game, game.decision.player, { type: 'decision', allow: true });
assert.ok(game.pendingGuildBetrayal, 'A real accepted paid shipment creates the invoice.');
const invoice = game.pendingGuildBetrayal.invoice;
assert.equal(invoice.price, primary + donorAmount);
assert.equal(invoice.ownPayment, primary);
assert.equal(invoice.allyPayment, donorAmount);
return { ...base, ...setup, beforePledge, primary, donorAmount, game, before, declaration,
event: invoice.event, price: invoice.price, original: settleBetrayalFunding(game) };
}
export function useBetrayalFunding(fixture: BureaucratBetrayalFundingFixture): Game {
return applyAction(fixture.game, fixture.holder, { type: 'guildBetrayalUse', event: fixture.event });
}
+11 -1
View File
@@ -85,6 +85,8 @@ function enterGuildFixtureMovement(state: Game, minimumTurn = 1): Game {
throw new Error('Genuine Guild fixture did not reach Shipment and Movement.');
}
function holdGuildFixtureFace(game: Game, holder: string, face: 'guild' | 'choam'): void {
const extraFaces: FactionId[] = ['fremen', 'harkonnen', 'ixians', 'choam', 'ecaz', 'moritani', 'beneGesserit', 'atreides'];
let extraIndex = 0;
// Arrange only the conserved physical Nexus inventory, before any invoice.
// Public held-card presence is identical across hidden-face fixture variants.
let cards = game.nexusCards!.cards!;
@@ -93,7 +95,15 @@ function holdGuildFixtureFace(game: Game, holder: string, face: 'guild' | 'choam
if (held !== null) {cards.deck.push(held); cards.hands[p.id] = null;}
}
for (const p of game.players.filter(seat => !seat.ally)) {
const desired = p.id === holder ? face : p.faction === 'guild' ? 'atreides' : 'fremen';
let desired: FactionId;
if (p.id === holder) desired = face;
else if (p.faction === 'guild') desired = 'atreides';
else {
// Distinct conserved faces for every additional unallied seat; the first
// extra keeps the established 'fremen' assignment.
desired = extraFaces[extraIndex % extraFaces.length];
extraIndex++;
}
let index = cards.deck.indexOf(desired);
if (index < 0) {
index = cards.discard.indexOf(desired);