Skip to content

Commit

Permalink
More refactoring around CardPool
Browse files Browse the repository at this point in the history
 - Added helper method 'removeCard' to remove a single copy of a card from the pool.
 - Removed corresponding calls to, now removed, 'removeCardFromCardPool'.
 - Removed now useless 'monocoloredCount' and 'othersCount' from ColorBalancedSlotCache, superseeded by CardPool.count().
 - Fixed previously broken collection test: Default was changed to 'ignoreCollections': false a while back but the tests were simply not working.
  • Loading branch information
Senryoku committed Oct 19, 2023
1 parent 76b51c5 commit f54efb1
Show file tree
Hide file tree
Showing 7 changed files with 66 additions and 86 deletions.
79 changes: 28 additions & 51 deletions src/BoosterFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { CardID, Card, CardPool, SlotedCardPool, UniqueCard } from "./CardTypes.js";
import { Cards, getUnique, BoosterCardsBySet, CardsBySet, getCard } from "./Cards.js";
import { shuffleArray, randomInt, Options, random, getRandom, weightedRandomIdx } from "./utils.js";
import { removeCardFromCardPool, pickCard, countCards } from "./cardUtils.js";
import { pickCard } from "./cardUtils.js";
import { BasicLandSlot } from "./LandSlot.js";
import { Constants } from "./Constants.js";

Expand Down Expand Up @@ -44,9 +44,7 @@ function isEmpty(slotedCardPool: SlotedCardPool): boolean {
class ColorBalancedSlotCache {
byColor: { [color: string]: CardPool } = {};
monocolored: CardPool;
monocoloredCount: number;
others: CardPool;
othersCount: number;

constructor(cardPool: CardPool, options: Options = {}) {
const localGetCard = options.getCard ?? getCard;
Expand All @@ -62,14 +60,11 @@ class ColorBalancedSlotCache {
.map((k) => this.byColor[k]))
for (const [cid, val] of cardPool.entries()) this.monocolored.set(cid, val);

this.monocoloredCount = countCards(this.monocolored);
this.others = new CardPool();
for (const cardPool of Object.keys(this.byColor)
.filter((k) => k.length !== 1)
.map((k) => this.byColor[k]))
for (const [cid, val] of cardPool.entries()) this.others.set(cid, val);

this.othersCount = countCards(this.others);
}
}

Expand All @@ -85,15 +80,11 @@ export class ColorBalancedSlot {
this.cache = new ColorBalancedSlotCache(_cardPool, options);
}

// Signals the supplied card has been picked and has to be removed from the internal cache.
syncCache(pickedCard: Card) {
removeCardFromCardPool(pickedCard.id, this.cache.byColor[pickedCard.colors.join()]);
if (pickedCard.colors.length === 1) {
removeCardFromCardPool(pickedCard.id, this.cache.monocolored);
--this.cache.monocoloredCount;
} else {
removeCardFromCardPool(pickedCard.id, this.cache.others);
--this.cache.othersCount;
}
this.cache.byColor[pickedCard.colors.join()].removeCard(pickedCard.id);
if (pickedCard.colors.length === 1) this.cache.monocolored.removeCard(pickedCard.id);
else this.cache.others.removeCard(pickedCard.id);
}

// Returns cardCount color balanced cards picked from cardPool.
Expand All @@ -110,14 +101,9 @@ export class ColorBalancedSlot {
pickedCards.push(pickedCard);

if (options?.withReplacement !== true) {
removeCardFromCardPool(pickedCard.id, this.cardPool);
if (pickedCard.colors.length === 1) {
removeCardFromCardPool(pickedCard.id, this.cache.monocolored);
--this.cache.monocoloredCount;
} else {
removeCardFromCardPool(pickedCard.id, this.cache.others);
--this.cache.othersCount;
}
this.cardPool.removeCard(pickedCard.id);
if (pickedCard.colors.length === 1) this.cache.monocolored.removeCard(pickedCard.id);
else this.cache.others.removeCard(pickedCard.id);
}
}
}
Expand All @@ -133,12 +119,12 @@ export class ColorBalancedSlot {
// If cr < as, x = 0 is the best we can do.
// If c or a are small, we need to ignore x and use remaning cards. Negative x acts like 0.
const seededMonocolors = pickedCards.length; // s
const c = this.cache.monocoloredCount + seededMonocolors;
const a = this.cache.othersCount;
const c = this.cache.monocolored.count() + seededMonocolors;
const a = this.cache.others.count();
const remainingCards = cardCount - seededMonocolors; // r
const x = (c * remainingCards - a * seededMonocolors) / (remainingCards * (c + a));
for (let i = pickedCards.length; i < cardCount; ++i) {
const type = (random.bool(x) && this.cache.monocoloredCount !== 0) || this.cache.othersCount === 0;
const type = (random.bool(x) && this.cache.monocolored.count() !== 0) || this.cache.others.count() === 0;
const pickedCard = pickCard(
type ? this.cache.monocolored : this.cache.others,
pickedCards.concat(duplicateProtection),
Expand All @@ -147,10 +133,8 @@ export class ColorBalancedSlot {
pickedCards.push(pickedCard);

if (options?.withReplacement !== true) {
removeCardFromCardPool(pickedCard.id, this.cardPool);
removeCardFromCardPool(pickedCard.id, this.cache.byColor[pickedCard.colors.join()]);
if (type) --this.cache.monocoloredCount;
else --this.cache.othersCount;
this.cardPool.removeCard(pickedCard.id);
this.cache.byColor[pickedCard.colors.join()].removeCard(pickedCard.id);
}
}
// Shuffle to avoid obvious signals to other players
Expand Down Expand Up @@ -203,7 +187,7 @@ export class BoosterFactory implements IBoosterFactory {
if (rarityCheck <= foilRarityRates[r] && foilCardPool[r].size > 0) {
const pickedCard = pickCard(foilCardPool[r]);
// Synchronize color balancing dictionary
if (this.options.colorBalance && this.colorBalancedSlot && pickedCard.rarity == "common")
if (this.options.colorBalance && this.colorBalancedSlot && pickedCard.rarity === "common")
this.colorBalancedSlot.syncCache(pickedCard);
pickedCard.foil = true;
booster.push(pickedCard);
Expand Down Expand Up @@ -294,16 +278,9 @@ function rollSpecialCardRarity(
return pickedRarity;
}

function countMap<T>(map: Map<T, number>): number {
let acc = 0;
for (const v of map.values()) acc += v;
return acc;
}

function countBySlot(cardPool: SlotedCardPool) {
function countBySlot(slotedCardPool: SlotedCardPool) {
const counts: { [slot: string]: number } = {};
for (const slot in cardPool)
counts[slot] = [...cardPool[slot].values()].reduce((acc: number, c: number): number => acc + c, 0);
for (const slot in slotedCardPool) counts[slot] = slotedCardPool[slot].count();
return counts;
}

Expand Down Expand Up @@ -497,7 +474,7 @@ class CMRBoosterFactory extends BoosterFactory {
pickedRarities[1] = rollSpecialCardRarity(legendaryCounts, targets, this.options);
for (const pickedRarity of pickedRarities) {
const pickedCard = pickCard(this.legendaryCreatures[pickedRarity], booster);
removeCardFromCardPool(pickedCard.id, this.completeCardPool[pickedCard.rarity]);
this.completeCardPool[pickedCard.rarity].removeCard(pickedCard.id);
booster.unshift(pickedCard);
}

Expand All @@ -511,9 +488,9 @@ class CMRBoosterFactory extends BoosterFactory {
}
const pickedFoil = pickCard(this.completeCardPool[foilRarity], [], { foil: true });
if (this.cardPool[pickedFoil.rarity].has(pickedFoil.id))
removeCardFromCardPool(pickedFoil.id, this.cardPool[pickedFoil.rarity]);
this.cardPool[pickedFoil.rarity].removeCard(pickedFoil.id);
if (this.legendaryCreatures[pickedFoil.rarity].has(pickedFoil.id))
removeCardFromCardPool(pickedFoil.id, this.legendaryCreatures[pickedFoil.rarity]);
this.legendaryCreatures[pickedFoil.rarity].removeCard(pickedFoil.id);
booster.unshift(pickedFoil);

return booster;
Expand Down Expand Up @@ -886,7 +863,7 @@ class CLBBoosterFactory extends BoosterFactory {
if (legendaryRarityCheck < 0.31 / 8.0) legendaryRarity = "mythic";
else if (legendaryRarityCheck < 0.31) legendaryRarity = "rare";
const pickedLegend = pickCard(this.legendaryCreaturesAndPlaneswalkers[legendaryRarity], booster);
removeCardFromCardPool(pickedLegend.id, this.completeCardPool[pickedLegend.rarity]);
this.completeCardPool[pickedLegend.rarity].removeCard(pickedLegend.id);

let backgroundRarity = "common";
const backgroundRarityCheck = random.real(0, 1);
Expand All @@ -901,7 +878,7 @@ class CLBBoosterFactory extends BoosterFactory {
if (this.legendaryBackgrounds[backgroundRarity].size <= 0)
return new MessageError("Error generating boosters", `Not enough legendary backgrounds.`);
const pickedBackground = pickCard(this.legendaryBackgrounds[backgroundRarity], booster);
removeCardFromCardPool(pickedBackground.id, this.completeCardPool[pickedBackground.rarity]);
this.completeCardPool[pickedBackground.rarity].removeCard(pickedBackground.id);

booster.unshift(pickedBackground);
booster.unshift(pickedLegend);
Expand All @@ -916,9 +893,9 @@ class CLBBoosterFactory extends BoosterFactory {
}
const pickedFoil = pickCard(this.completeCardPool[foilRarity], [], { foil: true });
if (this.cardPool[pickedFoil.rarity].has(pickedFoil.id))
removeCardFromCardPool(pickedFoil.id, this.cardPool[pickedFoil.rarity]);
this.cardPool[pickedFoil.rarity].removeCard(pickedFoil.id);
if (this.legendaryCreaturesAndPlaneswalkers[pickedFoil.rarity].has(pickedFoil.id))
removeCardFromCardPool(pickedFoil.id, this.legendaryCreaturesAndPlaneswalkers[pickedFoil.rarity]);
this.legendaryCreaturesAndPlaneswalkers[pickedFoil.rarity].removeCard(pickedFoil.id);
booster.push(pickedFoil);

return booster;
Expand Down Expand Up @@ -1467,9 +1444,9 @@ class MOMBoosterFactory extends BoosterFactory {

const insertedCards: UniqueCard[] = [];
if (targets.rare > 0) {
const raresCount = countMap(this.cardPool.rare);
const battleRaresCount = countMap(this.battleCards.rare);
const dfcRaresCount = countMap(this.doubleFacedCards.rare);
const raresCount = this.cardPool.rare.count();
const battleRaresCount = this.battleCards.rare.count();
const dfcRaresCount = this.doubleFacedCards.rare.count();

const rareTypeRoll = random.real(0, raresCount + battleRaresCount + dfcRaresCount);

Expand Down Expand Up @@ -1630,9 +1607,9 @@ class CMMBoosterFactory extends BoosterFactory {
}
const pickedFoil = pickCard(this.completeCardPool[foilRarity], [], { foil: true });
if (this.cardPool[pickedFoil.rarity].has(pickedFoil.id))
removeCardFromCardPool(pickedFoil.id, this.cardPool[pickedFoil.rarity]);
this.cardPool[pickedFoil.rarity].removeCard(pickedFoil.id);
if (this.legendaryCards[pickedFoil.rarity].has(pickedFoil.id))
removeCardFromCardPool(pickedFoil.id, this.legendaryCards[pickedFoil.rarity]);
this.legendaryCards[pickedFoil.rarity].removeCard(pickedFoil.id);
booster.unshift(pickedFoil);

return booster;
Expand Down
13 changes: 13 additions & 0 deletions src/CardTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,19 @@ export class CardPool extends Map<CardID, number> {
return this;
}

// Remove a single copy of a card from the pool.
removeCard(cid: CardID) {
const oldValue = this.get(cid);
if (!oldValue) {
console.error(`Called removeCard on a non-existing card (${cid}).`);
console.trace();
throw `Called removeCard on a non-existing card (${cid}).`;
}
if (oldValue === 1) this.delete(cid);
else super.set(cid, oldValue - 1); // Purposefully bypassing our caching overload and calling super.set directly here.
--this._count;
}

count() {
return this._count;
}
Expand Down
6 changes: 3 additions & 3 deletions src/CustomCardList.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ColorBalancedSlot } from "./BoosterFactory.js";
import { CardID, Card, SlotedCardPool, UniqueCard, CardPool } from "./CardTypes.js";
import { getCard } from "./Cards.js";
import { pickCard, removeCardFromCardPool } from "./cardUtils.js";
import { pickCard } from "./cardUtils.js";
import { MessageError } from "./Message.js";
import { isEmpty, Options, random, weightedRandomIdx, shuffleArray } from "./utils.js";

Expand Down Expand Up @@ -85,7 +85,7 @@ export function generateBoosterFromCustomCardList(
// however I don't have a better solution for now.
for (const slotName in cardsBySlot)
for (const cardId of options.removeFromCardPool)
if (cardsBySlot[slotName].has(cardId)) removeCardFromCardPool(cardId, cardsBySlot[slotName]);
if (cardsBySlot[slotName].has(cardId)) cardsBySlot[slotName].removeCard(cardId);
}

// Color balance the largest slot of each layout
Expand Down Expand Up @@ -241,7 +241,7 @@ export function generateBoosterFromCustomCardList(
// Workaround to handle the LoreSeeker draft effect with a limited number of cards
if (!options.withReplacement && options.removeFromCardPool) {
for (const cardId of options.removeFromCardPool)
if (localCollection.has(cardId)) removeCardFromCardPool(cardId, localCollection);
if (localCollection.has(cardId)) localCollection.removeCard(cardId);
}

const boosters = [];
Expand Down
3 changes: 1 addition & 2 deletions src/LandSlot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { CardID, CardPool } from "./CardTypes.js";
import { getUnique, getCard } from "./Cards.js";
import { getRandomMapKey, getRandom } from "./utils.js";
import { removeCardFromCardPool } from "./cardUtils.js";
import BasicLandIDs from "./data/BasicLandIDs.json" assert { type: "json" };

export class BasicLandSlot {
Expand Down Expand Up @@ -43,7 +42,7 @@ export class SpecialLandSlot extends BasicLandSlot {
pick() {
if (Math.random() <= this.rate && this.landsToDistribute.size > 0) {
const c = getRandomMapKey(this.landsToDistribute);
removeCardFromCardPool(c, this.landsToDistribute);
this.landsToDistribute.removeCard(c);
return getUnique(c);
} else {
return getUnique(getRandom(this.basicLandsIds));
Expand Down
11 changes: 5 additions & 6 deletions src/Session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use strict";
import { UserID, SessionID } from "./IDTypes.js";
import { countCards } from "./cardUtils.js";
import { shuffleArray, getRandom, arrayIntersect, Options, getNDisctinctRandom, pickRandom } from "./utils.js";
import { Connections, getPickedCardIds } from "./Connection.js";
import {
Expand Down Expand Up @@ -701,16 +700,16 @@ export class Session implements IIndexable {
);
// Make sure we have enough cards
for (const slot of ["common", "uncommon", "rare"]) {
const card_count = countCards((defaultFactory as BoosterFactory).cardPool[slot]);
const card_target =
const cardCount = (defaultFactory as BoosterFactory).cardPool[slot].count();
const cardTarget =
targets[slot] *
(boosterSpecificRules
? boosterQuantity
: customBoosters.reduce((a, v) => (v === "" ? a + 1 : a), 0));
if (card_count < card_target)
if (cardCount < cardTarget)
return new MessageError(
"Error generating boosters",
`Not enough cards (${card_count}/${card_target} ${slot}s) in collection.`
`Not enough cards (${cardCount}/${cardTarget} ${slot}s) in collection.`
);
}
}
Expand Down Expand Up @@ -770,7 +769,7 @@ export class Session implements IIndexable {
const multiplier = customBoosters.reduce((a, v) => (v === boosterSet ? a + 1 : a), 0); // Note: This won't be accurate in the case of 'random' sets.
for (const slot of ["common", "uncommon", "rare"]) {
if (
countCards((usedSets[boosterSet] as BoosterFactory).cardPool[slot]) <
(usedSets[boosterSet] as BoosterFactory).cardPool[slot].count() <
multiplier * playerCount * targets[slot]
)
return new MessageError(
Expand Down
31 changes: 7 additions & 24 deletions src/cardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,13 @@ import { CardID, Card, CardPool } from "./CardTypes.js";
import { getUnique } from "./Cards.js";
import { getRandomMapKey, random } from "./utils.js";

export function removeCardFromCardPool(cid: CardID, dict: CardPool) {
if (!dict.has(cid)) {
console.error(`Called removeCardFromCardPool on a non-existing card (${cid}).`);
console.trace();
throw `Called removeCardFromCardPool on a non-existing card (${cid}).`;
}
const newValue = dict.get(cid)! - 1;
if (newValue > 0) dict.set(cid, newValue);
else dict.delete(cid);
}

// Returns a random card from the pool, choosen uniformly across ALL cards (not UNIQUE ones),
// meaning cards present in multiple copies are more likely to be picked.
function getRandomCardFromCardPool(cardPool: CardPool): CardID {
const cardCount = cardPool.count();
const idx = random.integer(0, cardCount - 1);
const idx = random.integer(0, cardPool.count() - 1);

if (cardPool.size === cardCount) {
// Fast path (kinda, sadly we can't directly index into a map) for the singleton case.
if (cardPool.size === cardPool.count()) {
const r = cardPool.keys();
for (let i = 0; i < idx; ++i) r.next();
return r.next().value;
Expand Down Expand Up @@ -68,18 +57,12 @@ export function pickCard(
(cid) => booster.findIndex((card) => cid === card.id) === -1
);
if (candidates.length > 0) {
const tmpMap = new CardPool();
for (const cid of candidates) tmpMap.set(cid, cardPool.get(cid) as number);
cid = randomFunc(tmpMap);
const tmpPool = new CardPool();
for (const cid of candidates) tmpPool.set(cid, cardPool.get(cid) as number);
cid = randomFunc(tmpPool);
}
}
}
if (options?.withReplacement !== true) removeCardFromCardPool(cid, cardPool);
if (options?.withReplacement !== true) cardPool.removeCard(cid);
return getUnique(cid, options);
}

export function countCards(dict: CardPool): number {
let acc = 0;
for (const v of dict.values()) acc += v;
return acc;
}
9 changes: 9 additions & 0 deletions test/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ describe("Collection Restriction", function () {
waitForClientDisconnects(done);
});

it(`Disable ignoreCollections`, function (done) {
ownerIdx = clients.findIndex((c) => getUID(c) === Sessions[sessionID].owner);
clients[(ownerIdx + 1) % clients.length].once("ignoreCollections", (value) => {
expect(value).to.be.false;
done();
});
clients[ownerIdx].emit("ignoreCollections", false);
});

it(`Submit random collections.`, function (done) {
collections = Array(clients.length).fill({});
// Generate random collections
Expand Down

0 comments on commit f54efb1

Please sign in to comment.