diff --git a/backend/game.js b/backend/game.js index 62135eee..78dbff01 100644 --- a/backend/game.js +++ b/backend/game.js @@ -1,16 +1,16 @@ const crypto = require("crypto"); const path = require("path"); -const {shuffle, truncate} = require("lodash"); +const { shuffle } = require("lodash"); const uuid = require("uuid"); const jsonfile = require("jsonfile"); const Bot = require("./player/bot"); const Human = require("./player/human"); -const Pool = require("./pool"); +const PoolBuilder = require("./pool/poolBuilder"); const Room = require("./room"); const Rooms = require("./rooms"); const logger = require("./logger"); const Sock = require("./sock"); -const {saveDraftStats, getDataDir} = require("./data"); +const { saveDraftStats, getDataDir } = require("./data"); module.exports = class Game extends Room { constructor({ hostId, title, seats, type, sets, cube, isPrivate, modernOnly, totalChaos, chaosPacksNumber, picksPerPack }) { @@ -20,11 +20,7 @@ module.exports = class Game extends Room { title, seats: parseInt(seats), type, - cube, isPrivate, - modernOnly, - totalChaos, - chaosPacksNumber, picksPerPack: parseInt(picksPerPack), delta: -1, hostID: hostId, @@ -32,57 +28,10 @@ module.exports = class Game extends Room { players: [], round: 0, bots: 0, - sets: sets || [], - isDecadent: false, secret: uuid.v4(), - logger: logger.child({ id: gameID }) + logger: logger.child({ id: gameID }), + poolBuilder: new PoolBuilder(type, sets, cube, modernOnly, totalChaos, chaosPacksNumber) }); - // Handle packsInfos to show various informations about the game - switch(type) { - case "draft": - case "sealed": - this.packsInfo = this.sets.join(" / "); - this.rounds = this.sets.length; - break; - case "decadent draft": - // Sets should all be the same and there can be a large number of them. - // Compress this info into e.g. "36x IKO" instead of "IKO / IKO / ...". - this.packsInfo = `${this.sets.length}x ${this.sets[0]}`; - this.rounds = this.sets.length; - this.isDecadent = true; - break; - case "cube draft": - this.packsInfo = `${cube.packs} packs with ${cube.cards} cards from a pool of ${cube.list.length} cards`; - if (cube.burnsPerPack > 0) { - this.packsInfo += ` and ${cube.burnsPerPack} cards to burn per pack`; - } - this.rounds = this.cube.packs; - break; - case "cube sealed": - this.packsInfo = `${cube.cubePoolSize} cards per player from a pool of ${cube.list.length} cards`; - this.rounds = this.cube.packs; - break; - case "chaos draft": - case "chaos sealed": { - const chaosOptions = []; - chaosOptions.push(`${this.chaosPacksNumber} Packs`); - chaosOptions.push(modernOnly ? "Modern sets only" : "Not modern sets only"); - chaosOptions.push(totalChaos ? "Total Chaos" : "Not Total Chaos"); - this.packsInfo = `${chaosOptions.join(", ")}`; - this.rounds = this.chaosPacksNumber; - break; - } - default: - this.packsInfo = ""; - } - - if (cube) { - Object.assign(this, { - cubePoolSize: cube.cubePoolSize, - packsNumber: cube.packs, - playerPackSize: cube.cards - }); - } this.renew(); Rooms.add(gameID, this); @@ -120,15 +69,15 @@ module.exports = class Game extends Room { // The number of games which have a player still in them. static numActiveGames() { return Rooms.getAll() - .filter(({isActive}) => isActive) + .filter(({ isActive }) => isActive) .length; } // The number of players in active games. static totalNumPlayers() { return Rooms.getAll() - .filter(({isActive}) => isActive) - .reduce((count, {players}) => { + .filter(({ isActive }) => isActive) + .reduce((count, { players }) => { return count + players.filter(x => x.isConnected && !x.isBot).length; }, 0); } @@ -148,7 +97,7 @@ module.exports = class Game extends Room { static getRoomInfo() { return Rooms.getAll() - .filter(({isPrivate, didGameStart, isActive}) => !isPrivate && !didGameStart && isActive) + .filter(({ isPrivate, didGameStart, isActive }) => !isPrivate && !didGameStart && isActive) .reduce((acc, game) => { const usedSeats = game.players.length; const totalSeats = game.seats; @@ -161,7 +110,7 @@ module.exports = class Game extends Room { usedSeats, totalSeats, name: game.name, - packsInfo: game.packsInfo, + packsInfo: game.poolBuilder.info, type: game.type, timeCreated: game.timeCreated, }); @@ -207,7 +156,7 @@ module.exports = class Game extends Room { super.join(sock); this.logger.debug(`${sock.name} joined the game`); - const h = new Human(sock, this.picksPerPack, this.getBurnsPerPack(), this.id); + const h = new Human(sock, this.picksPerPack, this.poolBuilder.implicitBurnsPerPack, this.id); if (h.id === this.hostID) { h.isHost = true; sock.once("start", this.start.bind(this)); @@ -222,17 +171,6 @@ module.exports = class Game extends Room { this.meta(); } - getBurnsPerPack() { - switch (this.type) { - case "decadent draft": - return Number.MAX_VALUE; - case "cube draft": - return this.cube.burnsPerPack; - default: - return 0; - } - } - swap([i, j]) { const l = this.players.length; @@ -266,15 +204,15 @@ module.exports = class Game extends Room { isHost: h.isHost, round: this.round, self: this.players.indexOf(h), - sets: this.sets, + sets: this.poolBuilder.sets, gameId: this.id }); h.send("gameInfos", { type: this.type, - packsInfo: this.packsInfo, - sets: this.sets, + packsInfo: this.poolBuilder.info, + sets: this.poolBuilder.sets, picksPerPack: this.picksPerPack, - burnsPerPack: this.type === "cube draft" ? this.cube.burnsPerPack : 0 + burnsPerPack: this.poolBuilder.explicitBurnsPerPack }); if (this.isGameFinished) { @@ -322,9 +260,9 @@ module.exports = class Game extends Room { } uploadDraftStats() { - const draftStats = this.cube - ? { list: this.cube.list } - : { sets: this.sets }; + const draftStats = this.poolBuilder.isCube + ? { list: this.poolBuilder.cubeList } + : { sets: this.poolBuilder.sets }; draftStats.id = this.id; draftStats.draft = {}; @@ -345,14 +283,14 @@ module.exports = class Game extends Room { } }); const cubeHash = /cube/.test(this.type) - ? crypto.createHash("SHA512").update(this.cube.list.join("")).digest("hex") + ? crypto.createHash("SHA512").update(this.poolBuilder.cubeList.join("")).digest("hex") : ""; const draftcap = { "gameID": this.id, "players": this.players.length - this.bots, "type": this.type, - "sets": this.sets, + "sets": this.poolBuilder.sets, "seats": this.seats, "time": Date.now(), "cap": this.players.map((player, seat) => ({ @@ -407,7 +345,7 @@ module.exports = class Game extends Room { }); } - if (this.round++ === this.rounds) { + if (this.round++ === this.poolBuilder.rounds) { return this.end(); } @@ -466,64 +404,6 @@ module.exports = class Game extends Room { return this.players.map((player) => player.getPlayerDeck()); } - - createPool() { - switch (this.type) { - case "cube draft": { - this.pool = Pool.DraftCube({ - cubeList: this.cube.list, - playersLength: this.players.length, - packsNumber: this.cube.packs, - playerPackSize: this.cube.cards - }); - break; - } - case "cube sealed": { - this.pool = Pool.SealedCube({ - cubeList: this.cube.list, - playersLength: this.players.length, - playerPoolSize: this.cubePoolSize - }); - break; - } - case "draft": - case "decadent draft": { - this.pool = Pool.DraftNormal({ - playersLength: this.players.length, - sets: this.sets - }); - break; - } - - case "sealed": { - this.pool = Pool.SealedNormal({ - playersLength: this.players.length, - sets: this.sets - }); - break; - } - case "chaos draft": { - this.pool = Pool.DraftChaos({ - playersLength: this.players.length, - packsNumber: this.chaosPacksNumber, - modernOnly: this.modernOnly, - totalChaos: this.totalChaos - }); - break; - } - case "chaos sealed": { - this.pool = Pool.SealedChaos({ - playersLength: this.players.length, - packsNumber: this.chaosPacksNumber, - modernOnly: this.modernOnly, - totalChaos: this.totalChaos - }); - break; - } - default: throw new Error(`Type ${this.type} not recognized`); - } - } - handleSealed() { this.round = -1; this.players.forEach((p) => { @@ -534,7 +414,7 @@ module.exports = class Game extends Room { } handleDraft() { - const {players, useTimer, timerLength} = this; + const { players, useTimer, timerLength } = this; players.forEach((p, self) => { p.useTimer = useTimer; @@ -547,21 +427,14 @@ module.exports = class Game extends Room { this.startRound(); } - shouldAddBots() { - return this.addBots && !this.isDecadent; - } - start({ addBots, useTimer, timerLength, shufflePlayers }) { try { Object.assign(this, { addBots, useTimer, timerLength, shufflePlayers }); this.renew(); - if (this.shouldAddBots()) { + if (this.addBots) { while (this.players.length < this.seats) { - const burnsPerPack = this.type === "cube draft" - ? this.cube.burnsPerPack - : 0; - this.players.push(new Bot(this.picksPerPack, burnsPerPack, this.id)); + this.players.push(new Bot(this.picksPerPack, this.poolBuilder.implicitBurnsPerPack, this.id)); this.bots++; } } @@ -570,7 +443,7 @@ module.exports = class Game extends Room { this.players = shuffle(this.players); } - this.createPool(); + this.pool = this.poolBuilder.createPool(this.players.length); if (/sealed/.test(this.type)) { this.handleSealed(); @@ -580,7 +453,7 @@ module.exports = class Game extends Room { this.logger.info(`Game ${this.id} started.\n${this.toString()}`); Game.broadcastGameInfo(); - } catch(err) { + } catch (err) { this.logger.error(`Game ${this.id} encountered an error while starting: ${err.stack} GameState: ${this.toString()}`); this.players.forEach(player => { if (!player.isBot) { @@ -603,25 +476,23 @@ module.exports = class Game extends Room { title: ${this.title} seats: ${this.seats} type: ${this.type} - sets: ${this.sets} isPrivate: ${this.isPrivate} picksPerPack: ${this.picksPerPack} - modernOnly: ${this.modernOnly} - totalChaos: ${this.totalChaos} - chaosPacksNumber: ${this.chaosPacksNumber} - packsInfos: ${this.packsInfo} players: ${this.players.length} (${this.players.filter(pl => !pl.isBot).map(pl => pl.name).join(", ")}) bots: ${this.bots} - ${this.cube ? - `cubePoolSize: ${this.cube.cubePoolSize} - packsNumber: ${this.cube.packs} - playerPackSize: ${this.cube.cards} - cube: ${truncate(this.cube.list, 30)}` - : ""}`; + poolBuilder: + ${this.poolBuilder.toString()}`; } getNextPlayer(index) { - const {length} = this.players; + const { length } = this.players; return this.players[(index % length + length) % length]; } + + // Accessor required for unit test "can make a draft with 4 sets" + // (Note: It's weird that the unit tests evaluate private class members instead of + // validating behavior, probably worth a look at the approach at some point) + get rounds() { + return this.poolBuilder.rounds; + } }; diff --git a/backend/boosterGenerator.js b/backend/pool/boosterGenerator.js similarity index 85% rename from backend/boosterGenerator.js rename to backend/pool/boosterGenerator.js index 7ed72806..34ae7998 100644 --- a/backend/boosterGenerator.js +++ b/backend/pool/boosterGenerator.js @@ -1,7 +1,7 @@ -const {getCardByUuid, getSet, getBoosterRules} = require("./data"); -const logger = require("./logger"); +const { getCardByUuid, getSet, getBoosterRules } = require("../data"); +const logger = require("../logger"); const weighted = require("weighted"); -const {sample, sampleSize, random, concat} = require("lodash"); +const { sample, sampleSize, random, concat } = require("lodash"); const makeBoosterFromRules = (setCode) => { const set = getSet(setCode); @@ -17,9 +17,9 @@ const makeBoosterFromRules = (setCode) => { try { const { boosters, totalWeight, sheets } = setRules; const boosterSheets = weighted( - boosters.map(({sheets}) => sheets), - boosters.map(({weight}) => weight), - {total: totalWeight}); + boosters.map(({ sheets }) => sheets), + boosters.map(({ weight }) => weight), + { total: totalWeight }); return Object.entries(boosterSheets) .flatMap(chooseCards(sheets)); } catch (error) { @@ -65,7 +65,7 @@ const chooseCards = sheets => ([sheetCode, numberOfCardsToPick]) => { return randomCards.map(toCard(sheetCode)); }; -function getRandomCardsWithColorBalance({cardsByColor, cards}, numberOfCardsToPick) { +function getRandomCardsWithColorBalance({ cardsByColor, cards }, numberOfCardsToPick) { const ret = new Set(); // Pick one card of each color @@ -90,7 +90,7 @@ function getRandomCardsWithColorBalance({cardsByColor, cards}, numberOfCardsToPi return [...ret]; } -function getRandomCards({cards, totalWeight: total}, numberOfCardsToPick) { +function getRandomCards({ cards, totalWeight: total }, numberOfCardsToPick) { const ret = new Set(); // Fast way to avoid duplicate diff --git a/backend/boosterGenerator.spec.js b/backend/pool/boosterGenerator.spec.js similarity index 100% rename from backend/boosterGenerator.spec.js rename to backend/pool/boosterGenerator.spec.js diff --git a/backend/pool.js b/backend/pool/pool.js similarity index 92% rename from backend/pool.js rename to backend/pool/pool.js index 304b8859..0e53fdd5 100644 --- a/backend/pool.js +++ b/backend/pool/pool.js @@ -1,6 +1,6 @@ -const {sample, shuffle, random, range, times, constant, pull} = require("lodash"); +const { sample, shuffle, random, range, times, constant, pull } = require("lodash"); const boosterGenerator = require("./boosterGenerator"); -const { getCardByUuid, getCardByName, getRandomSet, getExpansionOrCoreModernSets: getModernList, getExansionOrCoreSets: getSetsList } = require("./data"); +const { getCardByUuid, getCardByName, getRandomSet, getExpansionOrCoreModernSets: getModernList, getExansionOrCoreSets: getSetsList } = require("../data"); const draftId = require("uuid").v1; /** @@ -38,7 +38,7 @@ const replaceRNGSet = (sets) => ( ); const SealedNormal = ({ playersLength, sets }) => ( - times(playersLength , constant(replaceRNGSet(sets))) + times(playersLength, constant(replaceRNGSet(sets))) .map(sets => sets.flatMap(boosterGenerator)) .map(addCardIdsToBoosterCards) ); @@ -96,7 +96,7 @@ const DraftChaos = ({ playersLength, packsNumber = 3, modernOnly, totalChaos }) }; const SealedChaos = ({ playersLength, packsNumber = 6, modernOnly, totalChaos }) => { - const pool = DraftChaos({playersLength, packsNumber, modernOnly, totalChaos}); + const pool = DraftChaos({ playersLength, packsNumber, modernOnly, totalChaos }); return range(playersLength) .map(() => pool.splice(0, packsNumber).flat()) .map(addCardIdsToBoosterCards); diff --git a/backend/pool.spec.js b/backend/pool/pool.spec.js similarity index 98% rename from backend/pool.spec.js rename to backend/pool/pool.spec.js index 8af099b2..74acec7c 100644 --- a/backend/pool.spec.js +++ b/backend/pool/pool.spec.js @@ -3,7 +3,7 @@ const assert = require("assert"); const Pool = require("./pool"); const {range, times, constant} = require("lodash"); -const {getPlayableSets} = require("./data"); +const {getPlayableSets} = require("../data"); const assertPackIsCorrect = (got) => { const cardIds = new Set(); diff --git a/backend/pool/poolBuilder.js b/backend/pool/poolBuilder.js new file mode 100644 index 00000000..26ba4a8b --- /dev/null +++ b/backend/pool/poolBuilder.js @@ -0,0 +1,217 @@ +const Pool = require("./pool"); +const { truncate } = require("lodash"); + +/** + * @brief Manages parameters that control the type, grouping and quantity of cards in a pool + */ +module.exports = class PoolBuilder { + /** + * + * @param {string} type + * Format + Pool desciption enum provided by frontend + * @param {string[]} sets + * List of set tags describing the packs that should be used by a single player + * @param {object} cube + * Cube options object generated in frontend + * {list, cards, packs, cubePoolSize, burnsPerPack} + * @param {boolean} modernOnly + * If true, chaos packs are composed exclusively of modern cards + * @param {boolean} totalChaos + * If true, chaos packs are composed of cards from randomly selected sets + * @param {int} chaosPacksNumber + * How many packs to use if this is a chaos draft + */ + constructor(type, sets, cube, modernOnly, totalChaos, chaosPacksNumber) { + this.type = type; + this.cube = cube; + this.modernOnly = modernOnly; + this.totalChaos = totalChaos; + this.chaosPacksNumber = chaosPacksNumber; + this.setTypes = sets || []; + } + + /** + * Create a pool of cards to play with + * @param {int} playersLength + * How many players are participating in this pool + * @returns + * ... not sure? + * Looks like an array of arrays of... something? + */ + createPool(playersLength) { + switch (this.type) { + case "cube draft": + return Pool.DraftCube({ + cubeList: this.cube.list, + playersLength: playersLength, + packsNumber: this.cube.packs, + playerPackSize: this.cube.cards + }); + case "cube sealed": + return Pool.SealedCube({ + cubeList: this.cube.list, + playersLength: playersLength, + playerPoolSize: this.cube.cubePoolSize + }); + case "draft": + case "decadent draft": + return Pool.DraftNormal({ + playersLength: playersLength, + sets: this.setTypes + }); + case "sealed": + return Pool.SealedNormal({ + playersLength: playersLength, + sets: this.setTypes + }); + case "chaos draft": + return Pool.DraftChaos({ + playersLength: playersLength, + packsNumber: this.chaosPacksNumber, + modernOnly: this.modernOnly, + totalChaos: this.totalChaos + }); + case "chaos sealed": + return Pool.SealedChaos({ + playersLength: playersLength, + packsNumber: this.chaosPacksNumber, + modernOnly: this.modernOnly, + totalChaos: this.totalChaos + }); + default: throw new Error(`Type ${this.type} not recognized`); + } + } + + /** + * @returns {string} + * A string describing the configuration of this pool + * (i.e. what cards are in this pool?) + */ + get info() { + switch (this.type) { + case "draft": + case "sealed": + return this.setTypes.join(" / "); + case "decadent draft": + // Sets should all be the same and there can be a large number of them. + // Compress this info into e.g. "36x IKO" instead of "IKO / IKO / ...". + return `${this.setTypes.length}x ${this.setTypes[0]}`; + case "cube draft": + var packsInfo = `${this.cube.packs} packs with ${this.cube.cards} cards from a pool of ${this.cube.list.length} cards`; + if (this.cube.burnsPerPack > 0) { + packsInfo += ` and ${this.cube.burnsPerPack} cards to burn per pack`; + } + return packsInfo; + case "cube sealed": + return `${this.cube.cubePoolSize} cards per player from a pool of ${this.cube.list.length} cards`; + case "chaos draft": + case "chaos sealed": + var chaosOptions = []; + chaosOptions.push(`${this.chaosPacksNumber} Packs`); + chaosOptions.push(this.modernOnly ? "Modern sets only" : "Not modern sets only"); + chaosOptions.push(this.totalChaos ? "Total Chaos" : "Not Total Chaos"); + return `${chaosOptions.join(", ")}`; + default: + return ""; + } + } + + /** + * @returns {int} + * The number of draft rounds that should be done to consume this pool + */ + get rounds() { + switch (this.type) { + case "draft": + case "decadent draft": + return this.setTypes.length; + case "cube draft": + return this.cube.packs; + case "chaos draft": + return this.chaosPacksNumber; + default: + return 0; + } + } + + /** + * @returns {int} + * The number of burn cards that should be done on each draft pick + * (implicit here meaning both burns done in the frontend or the backend) + */ + get implicitBurnsPerPack() { + switch (this.type) { + case "decadent draft": + return Number.MAX_VALUE; + case "cube draft": + return this.cube.burnsPerPack; + default: + return 0; + } + } + + /** + * @returns {int} + * The number of burn cards that should be done on each draft pick + * (explicit here meaning both burns done in the frontend) + */ + get explicitBurnsPerPack() { + switch (this.type) { + case "decadent draft": + // Decadent drafts implicitly burn the rest of the pack, we don't need + // to make the user select them all + return 0; + default: + return this.implicitBurnsPerPack; + } + } + + /** + * @returns {string[]} + * A list of sets used to create the packs for a player in this draft. + * Empty list if invalid. + */ + get sets() { + return this.setTypes; + } + + /** + * @returns {boolean} + * true if this pool is backed by a cube set + */ + get isCube() { + return this.cube ? true : false; + } + + /** + * @returns {string[]} + * A list of all the cards in the cube pool for this draft. + * Empty list if invalid. + */ + get cubeList() { + return this.cube ? this.cube.list : []; + } + + /** + * @returns {string} + * Get a debug string representation of this object + */ + toString() { + return ` + Pool Builder + ---------- + type: ${this.type} + sets: ${this.setTypes} + modernOnly: ${this.modernOnly} + totalChaos: ${this.totalChaos} + chaosPacksNumber: ${this.chaosPacksNumber} + info: ${this.info} + ${this.cube ? + `cubePoolSize: ${this.cube.cubePoolSize} + packsNumber: ${this.cube.packs} + playerPackSize: ${this.cube.cards} + cube: ${truncate(this.cube.list, 30)}` + : ""}`; + } + +};