diff --git a/.drone.yml b/.drone.yml index 13ee39a..5a02eee 100644 --- a/.drone.yml +++ b/.drone.yml @@ -116,14 +116,14 @@ steps: - name: test image: node commands: - - yarn test:unit + - yarn test:unit --runInBand depends_on: - dependencies - name: coverage image: node commands: - - yarn test:unit --coverage + - yarn test:unit --coverage --runInBand depends_on: - test # Must run after test otherwise SQLite will get mad diff --git a/src/mlpccg/database.ts b/src/mlpccg/database.ts index 0fdb78b..c4b9aae 100644 --- a/src/mlpccg/database.ts +++ b/src/mlpccg/database.ts @@ -1,10 +1,10 @@ import Dexie from "dexie"; -import { Card, CardFilter, StoredImages } from "./types"; +import { Card, CardFilter, StoredImage } from "./types"; import { cardFullName } from "./card"; class CardDatabase extends Dexie { public cards: Dexie.Table; - public images: Dexie.Table; + public images: Dexie.Table; public constructor() { super("CardDatabase"); @@ -20,7 +20,9 @@ class CardDatabase extends Dexie { export let Database: CardDatabase | null = null; export function initDB() { - Database = new CardDatabase(); + if (Database == null) { + Database = new CardDatabase(); + } } export async function getCards(filter: CardFilter) { @@ -151,3 +153,15 @@ export async function getCards(filter: CardFilter) { }); return await results.toArray(); } + +export async function cardFromIDs(cardIDs: string[]): Promise { + if (Database == null) { + throw new Error("Database was not initialized, init with 'initDB()'"); + } + let table = Database.cards; + //TODO Replace with .bulkGet when upgrading to Dexie 3.x + return await table + .where("ID") + .anyOf(cardIDs) + .toArray(); +} diff --git a/src/mlpccg/draft/booster.ts b/src/mlpccg/draft/booster.ts new file mode 100644 index 0000000..e1123b4 --- /dev/null +++ b/src/mlpccg/draft/booster.ts @@ -0,0 +1,211 @@ +import { Card, getCards } from "@/mlpccg"; +import { Pack, PackSchema, AlternateProvider } from "./types"; + +/* + +(This data was taken from the MLP:CCG wikia at mlpccg.fandom.com and confirmed + by people at the MLP:CCG Discord) + +Distribution rates for packs is usually 8 commons, 3 uncommons and 1 rare. + +No Fixed or Promo cards can be found in packs. + +UR distribution depends on set: + - PR has 1/13 chance of UR replacing a common + - CN->AD has 1/11 chance of UR replacing a common + - EO->FF has 1/3 chance of SR/UR replacing a common + +SR are twice as common as UR, so that's one more thing to keep in mind. + +Lastly, RR can replace another common in the ratio of ~1/2 every 6 boxes, depending +on set. Specifically, this is the RR ratio for each set: + - EO->HM: 1/108 + - MT->FF: 1/216 + +*/ + +// Returns the pack schema for a specific set +async function setSchema(set: string): Promise { + // Force set name to uppercase + set = set.toUpperCase(); + + // Return blank schemas for invalid sets + if (set == "RR" || set == "CS") { + return { slots: [] }; + } + + // Get cards for set + let cards = await getCards({ Sets: [set] }); + let cardMap = spanByRarity(cards); + + let rr: AlternateProvider[] = []; + let srur: AlternateProvider[] = []; + + // Check for RR chances + /* + switch (set) { + case "EO": + case "HM": + rr = [ + { + probability: 1.0 / 108.0, + provider: randomProvider([ + //TODO + ]) + } + ]; + break; + case "MT": + case "HM": + case "SB": + case "FF": + rr = [ + { + probability: 1.0 / 216.0, + provider: randomProvider([ + //TODO + ]) + } + ]; + break; + } + */ + + // Check for SR/UR chances + switch (set) { + case "PR": + srur = [ + { + probability: 1.0 / 13.0, + provider: randomProvider(cardMap["UR"]) + } + ]; + break; + case "CN": + case "CG": + case "AD": + srur = [ + { + probability: 1.0 / 11.0, + provider: randomProvider(cardMap["UR"]) + } + ]; + break; + default: + srur = [ + { + probability: (1.0 / 9.0) * 2.0, + provider: randomProvider(cardMap["SR"]) + }, + { + probability: 1.0 / 9.0, + provider: randomProvider(cardMap["UR"]) + } + ]; + break; + } + + return { + slots: [ + { + amount: 6, + provider: randomProvider(cardMap["C"]), + alternate: [] + }, + { + amount: 1, + provider: randomProvider(cardMap["C"]), + alternate: rr + }, + { + amount: 1, + provider: randomProvider(cardMap["C"]), + alternate: srur + }, + { + amount: 1, + provider: randomProvider(cardMap["R"]), + alternate: [] + }, + { + amount: 3, + provider: randomProvider(cardMap["U"]), + alternate: [] + } + ] + }; +} + +export class PackBuilder { + schema: PackSchema; + constructor(schema: PackSchema) { + this.schema = schema; + } + + buildPack(): Pack { + let pack = []; + for (const slot of this.schema.slots) { + let provider = slot.provider; + + // Check for alternates by generating a random and checking cumulated + // probability. Ie. if one card would show 5% of the time, another would + // show up 10% of the time, the algorithm would do something like this: + // + // With Math.random() == 0.85: + // ALTERNATE NO ALTERNATE + // [0.00-0.05][0.06----0.15][0.16------------1.00] + // ^ 0.85 + // + // With Math.random() == 0.03: + // ALTERNATE NO ALTERNATE + // [0.00-0.05][0.06----0.15][0.16------------1.00] + // ^ 0.03 + + const rnd = Math.random(); + let currentProb = 0; + for (const alternate of slot.alternate) { + currentProb += alternate.probability; + // Alternate matched + if (currentProb > rnd) { + provider = alternate.provider; + break; + } + } + for (let i = 0; i < slot.amount; i++) { + const res = provider.next(); + if (res.done) { + // No more cards to get from this, exit early + break; + } + pack.push(res.value); + } + } + return pack; + } + + static async fromSet(set: string): Promise { + let schema = await setSchema(set); + let builder = new PackBuilder(schema); + return builder; + } +} + +// Yields random cards from a chosen pool +export function* randomProvider(pool: Card[]) { + while (true) { + const idx = Math.floor(Math.random() * pool.length); + yield pool[idx]; + } +} + +// Divides a list of card to a map of rarities +// ie. [ff14, ff16, ff17] => { "C" : ["ff14"], "U": ["ff17"], "R": ["ff16"] } +export function spanByRarity(pool: Card[]): Record { + return pool.reduce((map, current) => { + if (!(current.Rarity in map)) { + map[current.Rarity] = []; + } + map[current.Rarity].push(current); + return map; + }, Object.create(null)); +} diff --git a/src/mlpccg/draft/bot.ts b/src/mlpccg/draft/bot.ts new file mode 100644 index 0000000..081b177 --- /dev/null +++ b/src/mlpccg/draft/bot.ts @@ -0,0 +1,18 @@ +import { Card } from "@/mlpccg"; +import { SessionPlayer } from "./session"; + +export class DraftBot { + assign(player: SessionPlayer) { + player.on("available-picks", cards => { + const pick = this.pick(cards); + // setTimeout hack to avoid handlers being called before the rest of the code + setTimeout(() => player.pick(pick.ID), 0); + }); + } + + pick(picks: Card[]): Card { + // For now, pick a random card + const idx = Math.floor(Math.random() * picks.length); + return picks[idx]; + } +} diff --git a/src/mlpccg/draft/cube.ts b/src/mlpccg/draft/cube.ts new file mode 100644 index 0000000..1e437e7 --- /dev/null +++ b/src/mlpccg/draft/cube.ts @@ -0,0 +1,48 @@ +import { Card, cardFromIDs } from "@/mlpccg"; +import { PackSchema } from "./types"; +import axios from "axios"; + +export class Cube { + private pool: Card[]; + + constructor(pool: Card[]) { + this.pool = pool; + } + + schema(): PackSchema { + return { + slots: [ + { + amount: 15, + provider: this.provider(), + alternate: [] + } + ] + }; + } + + *provider() { + while (this.pool.length > 0) { + const idx = Math.floor(Math.random() * this.pool.length); + const card = this.pool.splice(idx, 1); + yield card[0]; + } + } + + static async fromCardIDs(cardIDs: string[]): Promise { + const cards = await cardFromIDs(cardIDs); + return new this(cards); + } + + static async fromList(list: string): Promise { + const ids = list.split("\n").map(x => x.trim()); + return await this.fromCardIDs(ids); + } + + static async fromURL(url: string) { + const res = await axios(url, { + responseType: "text" + }); + return await this.fromList(res.data); + } +} diff --git a/src/mlpccg/draft/i8pcube.ts b/src/mlpccg/draft/i8pcube.ts new file mode 100644 index 0000000..1b46687 --- /dev/null +++ b/src/mlpccg/draft/i8pcube.ts @@ -0,0 +1,81 @@ +import { Card, cardFromIDs } from "@/mlpccg"; +import { + PackSchema, + I8PCubeSchema, + I8PPackSchema, + I8PFileSchema, + DraftSchema +} from "./types"; +import axios from "axios"; +import { PackBuilder } from "./booster"; + +export class I8PCube { + private pools: Record; + private packschema: I8PPackSchema[]; + private problemCount: number; + + constructor(cubefile: I8PCubeSchema) { + this.pools = cubefile.Cards; + this.packschema = cubefile.Schema; + this.problemCount = cubefile.ProblemPackSize; + } + + schema(): DraftSchema { + return { + boosters: { + main: 4, + problem: 1 + }, + factories: { + main: new PackBuilder({ + slots: this.packschema.map(s => ({ + amount: s.Amount, + provider: this.provider(s.Type), + alternate: [] + })) + }), + problem: new PackBuilder({ + slots: [ + { + amount: this.problemCount, + provider: this.provider("problem"), + alternate: [] + } + ] + }) + } + }; + } + + *provider(name: string | "all") { + let poolname = name; + while (true) { + if (name == "all") { + const pools = Object.keys(this.pools); + const idx = Math.floor(Math.random() * pools.length); + poolname = pools[idx]; + } + const pool = this.pools[poolname]; + if (pool.length <= 0) { + return; + } + const idx = Math.floor(Math.random() * pool.length); + const card = pool.splice(idx, 1); + yield card[0]; + } + } + + static async fromURL(url: string) { + const res = await axios(url); + const cubefile = res.data as I8PFileSchema; + let cards: Record = {}; + for (const pool in cubefile.Cards) { + cards[pool] = await cardFromIDs(cubefile.Cards[pool]); + } + return new this({ + Cards: cards, + ProblemPackSize: cubefile.ProblemPackSize, + Schema: cubefile.Schema + }); + } +} diff --git a/src/mlpccg/draft/index.ts b/src/mlpccg/draft/index.ts new file mode 100644 index 0000000..5dee001 --- /dev/null +++ b/src/mlpccg/draft/index.ts @@ -0,0 +1,5 @@ +export * from "./cube"; +export * from "./booster"; +export * from "./types"; +export * from "./session"; +export * from "./bot"; diff --git a/src/mlpccg/draft/provider.ts b/src/mlpccg/draft/provider.ts new file mode 100644 index 0000000..3f3ee03 --- /dev/null +++ b/src/mlpccg/draft/provider.ts @@ -0,0 +1,34 @@ +import { DraftSchema, Pack } from "./types"; +import { PackBuilder } from "./booster"; + +export class DraftProvider { + private schema: DraftSchema; + + constructor(schema: DraftSchema) { + this.schema = schema; + } + + getPacks(): Pack[] { + let out = []; + for (const boosterSlot in this.schema.boosters) { + const amount = this.schema.boosters[boosterSlot]; + const factory = this.schema.factories[boosterSlot]; + if (!factory) { + throw new Error( + `booster type ${boosterSlot} was referenced in schema but was not provided a builder` + ); + } + for (let i = 0; i < amount; i++) { + out.push(factory.buildPack()); + } + } + return out; + } + + static set(factory: PackBuilder, amount: number): DraftProvider { + return new DraftProvider({ + boosters: { normal: amount }, + factories: { normal: factory } + }); + } +} diff --git a/src/mlpccg/draft/session.ts b/src/mlpccg/draft/session.ts new file mode 100644 index 0000000..c3e9d02 --- /dev/null +++ b/src/mlpccg/draft/session.ts @@ -0,0 +1,262 @@ +import { PackBuilder, Cube, DraftOptions } from "."; +import EventEmitter from "eventemitter3"; +import { Card } from "@/mlpccg"; +import { Pack, Direction } from "./types"; +import { DraftProvider } from "./provider"; +import { DraftBot } from "./bot"; +import { I8PCube } from "./i8pcube"; + +export class Session extends EventEmitter { + private options: DraftOptions; + private provider: DraftProvider; + private pod: SessionPlayer[] = []; + private players: string[] = []; + private pending: number[] = []; + private assigned: boolean = false; + private direction: Direction = "cw"; + + constructor(options: DraftOptions, provider: DraftProvider) { + super(); + this.options = options; + this.provider = provider; + this.pod = new Array(options.players).fill(0).map((x, i) => { + const player = new SessionPlayer(provider.getPacks()); + player.on("pick", this.picked.bind(this, i)); + return player; + }); + // Populate prev/next references + this.pod.forEach((val, i) => { + if (i > 0) { + val.prev = this.pod[i - 1]; + } else { + val.prev = this.pod[this.pod.length - 1]; + } + if (i < this.pod.length - 1) { + val.next = this.pod[i + 1]; + } else { + val.next = this.pod[0]; + } + }); + } + + public assign( + players: string[], + assignFn: (name: string, instance: SessionPlayer) => void + ) { + // Figure out how many players there are vs spots to be filled + this.players = players; + const spots = this.options.players; + const playerNum = players.length; + if (playerNum > spots) { + throw new Error("too many players in the pod"); + } + + if (playerNum < 1) { + throw new Error("not enough players"); + } + + // Place players in the pod + switch (this.options.spacing) { + case "evenly": + const playerRatio = spots / playerNum; + let i = 0; + for (const player of players) { + const pos = Math.floor(playerRatio * i); + this.pod[pos].name = player; + assignFn(player, this.pod[pos]); + i += 1; + } + break; + case "randomly": + for (const player of players) { + while (true) { + const idx = Math.floor(Math.random() * spots); + if (this.pod[idx].name == "") { + this.pod[idx].name = player; + assignFn(name, this.pod[idx]); + break; + } + } + } + break; + } + + // All the non-assigned places go to bots! + this.pod.forEach(p => { + if (p.name == "") { + p.name = "bot"; + const bot = new DraftBot(); + bot.assign(p); + } + }); + + this.assigned = true; + } + + public start() { + if (!this.assigned) { + throw new Error("Must assign players first (see assign())"); + } + this.emit("start", this.order); + this.nextPack(); + } + + public get order(): string[] { + return this.pod.map(p => p.name); + } + + private picked( + playerIndex: number, + _card: string, + lastPick: boolean, + lastPack: boolean + ) { + if (!this.pending.includes(playerIndex)) { + // Uh oh. + throw new Error( + `unexpected pick: player "${this.pod[playerIndex].name}" already picked their card` + ); + } + const idx = this.pending.indexOf(playerIndex); + this.pending.splice(idx, 1); + + this.emit("player-pick", this.pod[playerIndex].name); + + // Don't continue unless everyone picked their card + if (this.pending.length > 0) { + return; + } + + // Was this the last pick for this round of packs? + if (lastPick) { + // Was the it the last pack? + if (lastPack) { + this.emit("draft-over"); + this.pod.forEach(p => p.emit("your-picks", p.picks)); + return; + } + this.nextPack(); + return; + } + + // Pass packs between players for next pick + this.resetPending(); + this.pod.forEach(p => p.sendPack(this.direction)); + this.emit("next-pick"); + } + + private nextPack() { + this.resetPending(); + this.flipOrder(); + this.pod.forEach(p => p.nextPack()); + this.emit("next-pack"); + } + + private resetPending() { + this.pending = this.pod.map((_, i) => i); + } + + private flipOrder() { + if (this.direction == "cw") { + this.direction = "ccw"; + } else { + this.direction = "cw"; + } + } + + static async create(options: DraftOptions): Promise { + switch (options.source) { + case "set": { + const factory = await PackBuilder.fromSet(options.set); + const provider = DraftProvider.set(factory, options.packs); + return new Session(options, provider); + } + case "block": + throw new Error("not implemented"); + case "cube": { + const cube = await Cube.fromURL(options.url); + const factory = new PackBuilder(cube.schema()); + const provider = DraftProvider.set(factory, options.packs); + return new Session(options, provider); + } + case "i8pcube": + const cube = await I8PCube.fromURL(options.url); + const provider = new DraftProvider(cube.schema()); + return new Session(options, provider); + default: + throw new Error("Unknown draft source"); + } + } +} + +export class SessionPlayer extends EventEmitter { + public name: string = ""; + public currentPack?: Pack; + public picks: Pack; + public packs: Pack[]; + public toSend: Pack | null = null; + public next?: SessionPlayer; + public prev?: SessionPlayer; + public ready: boolean = false; + + constructor(packs: Pack[]) { + super(); + this.packs = packs; + this.picks = []; + } + + public pick(card: string) { + if (!this.ready) { + throw new Error("not ready to pick"); + } + if (!this.currentPack) { + throw new Error("no pack to pick from"); + } + const idx = this.currentPack.findIndex(c => c.ID == card); + if (idx < 0) { + throw new Error("card not in available picks"); + } + const pick = this.currentPack.splice(idx, 1); + this.picks.push(pick[0]); + this.toSend = this.currentPack; + this.ready = false; + + this.emit("pick", card, this.currentPack.length < 1, this.packs.length < 1); + } + + public sendPack(direction: Direction) { + if (!this.toSend) { + throw new Error("no pack to pass"); + } + if (this.toSend.length < 1) { + throw new Error("empty pack"); + } + if (direction == "cw") { + if (!this.next) { + throw new Error("no player to pass cards to"); + } + this.next.receivePack(this.toSend); + } else { + if (!this.prev) { + throw new Error("no player to pass cards to"); + } + this.prev.receivePack(this.toSend); + } + this.toSend = null; + } + + public receivePack(cards: Pack) { + this.currentPack = cards; + this.ready = true; + this.emit("available-picks", cards); + } + + public nextPack() { + // Open new pack + const newPack = this.packs.shift(); + if (!newPack) { + throw new Error("no packs left"); + } + this.receivePack(newPack); + } +} diff --git a/src/mlpccg/draft/types.ts b/src/mlpccg/draft/types.ts new file mode 100644 index 0000000..7b2931a --- /dev/null +++ b/src/mlpccg/draft/types.ts @@ -0,0 +1,90 @@ +import { Card } from "@/mlpccg"; +import { PackBuilder } from "./booster"; + +export type Provider = Iterator; + +export type Pack = Card[]; + +export interface PackSchema { + slots: PackSlot[]; +} + +export interface PackSlot { + amount: number; + provider: Provider; + alternate: AlternateProvider[]; +} + +export interface AlternateProvider { + probability: number; + provider: Provider; +} + +export interface SetDraftOptions { + source: "set"; + set: string; +} + +export interface BlockDraftOptions { + source: "block"; + block: string; +} + +export interface CubeDraftOptions { + source: "cube"; + url: string; +} + +export interface I8PCubeDraftOptions { + source: "i8pcube"; + url: string; +} + +export interface LimitedBoosterDraft { + type: "booster-draft"; + packs: number; +} + +export interface LimitedSealedDraft { + type: "sealed"; + packs: number; +} + +export type LimitedGameType = LimitedBoosterDraft | LimitedSealedDraft; + +export type DraftType = + | SetDraftOptions + | BlockDraftOptions + | CubeDraftOptions + | I8PCubeDraftOptions; + +export interface SessionOptions { + players: number; + spacing: "evenly" | "randomly"; +} + +export type DraftOptions = SessionOptions & LimitedGameType & DraftType; + +export interface DraftSchema { + boosters: Record; + factories: Record; +} + +export type Direction = "cw" | "ccw"; + +export interface I8PCubeSchema { + Schema: I8PPackSchema[]; + ProblemPackSize: number; + Cards: Record; +} + +export interface I8PFileSchema { + Schema: I8PPackSchema[]; + ProblemPackSize: number; + Cards: Record; +} + +export interface I8PPackSchema { + Amount: number; + Type: string; +} diff --git a/src/mlpccg/types.ts b/src/mlpccg/types.ts index 7a8e6d3..85e1b4c 100644 --- a/src/mlpccg/types.ts +++ b/src/mlpccg/types.ts @@ -2,7 +2,7 @@ export type Rarity = "C" | "U" | "R" | "SR" | "UR" | "RR"; export type PowerRequirement = { [key: string]: number }; -export interface StoredImages { +export interface StoredImage { id: string; image: Blob; } diff --git a/src/testing/IDBShim.ts b/src/testing/IDBShim.ts new file mode 100644 index 0000000..59fdb2c --- /dev/null +++ b/src/testing/IDBShim.ts @@ -0,0 +1,11 @@ +import Dexie from "dexie"; + +let init = false; + +export function setupIDBShim() { + if (!init) { + const setGlobalVars = require("indexeddbshim"); + setGlobalVars(Dexie.dependencies); + init = true; + } +} diff --git a/src/testing/index.ts b/src/testing/index.ts index a0824bf..a9aa129 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -2,3 +2,4 @@ export * from "./MockDataConnection"; export * from "./MockPeer"; export * from "./MockHelper"; export * from "./EventHook"; +export * from "./IDBShim"; diff --git a/src/tests/unit/database.spec.ts b/src/tests/unit/database.spec.ts index b87cf75..d1d77ff 100644 --- a/src/tests/unit/database.spec.ts +++ b/src/tests/unit/database.spec.ts @@ -1,7 +1,7 @@ import { loadSets, getCards, Database, initDB, cardFullName } from "@/mlpccg"; -import Dexie from "dexie"; -const setGlobalVars = require("indexeddbshim"); -setGlobalVars(Dexie.dependencies); +import { setupIDBShim } from "@/testing"; + +setupIDBShim(); describe("mlpccg/Database", () => { beforeAll(async () => { diff --git a/src/tests/unit/draft.spec.ts b/src/tests/unit/draft.spec.ts new file mode 100644 index 0000000..1db04e2 --- /dev/null +++ b/src/tests/unit/draft.spec.ts @@ -0,0 +1,131 @@ +import { setupIDBShim, EventHook } from "@/testing"; +import { initDB, loadSets, Database, Card } from "@/mlpccg"; +import { + PackBuilder, + spanByRarity, + Cube, + Session, + DraftOptions +} from "@/mlpccg/draft"; + +setupIDBShim(); + +const testSessionOptions: DraftOptions = { + type: "booster-draft", + source: "set", + set: "FF", + packs: 2, + players: 4, + spacing: "evenly" +}; + +describe("mlpccg/draft", () => { + beforeAll(async () => { + jest.setTimeout(15000); + initDB(); + await loadSets(); + }); + + test("Set booster packs are generated correctly", async () => { + expect(Database).toBeTruthy(); + const builder = await PackBuilder.fromSet("FF"); + const pack = builder.buildPack(); + // Check pack size + expect(pack).toHaveLength(12); + const rarities = spanByRarity(pack); + // Check pack distribution + expect(rarities["R"]).toHaveLength(1); + expect(rarities["U"]).toHaveLength(3); + }); + + test("Cube can load a newline separated card list", async () => { + expect(Database).toBeTruthy(); + const cubeCards = ["ff10", "ff11", "ff12", "ff13", "ff14", "ff15"]; + const cubeList = cubeCards.join("\n"); + const cube = await Cube.fromList(cubeList); + const builder = new PackBuilder(cube.schema()); + const pack = builder.buildPack(); + // Pack size should only be 6, since there are not enough cards for a 12 cards pack + expect(pack).toHaveLength(6); + // Make sure pack has ALL the cards from the pool, no duplicates + const sortedPack = pack.map(c => c.ID).sort(); + expect(sortedPack).toEqual(cubeCards); + }); + + test("A session can be initialized", async () => { + expect(Database).toBeTruthy(); + const session = await Session.create(testSessionOptions); + const hook = new EventHook(); + hook.hookEmitter(session, "start", "session-start"); + session.assign(["test1", "test2"], () => { + // Do nothing + }); + session.start(); + await hook.expect("session-start"); + }); + + test("Players receive pick events and can pick cards", async () => { + expect(Database).toBeTruthy(); + const session = await Session.create(testSessionOptions); + const hook = new EventHook(); + hook.hookEmitter(session, "start", "session-start"); + hook.hookEmitter(session, "next-pack", "session-new-pack"); + hook.hookEmitter(session, "next-pick", "session-new-pick"); + hook.hookEmitter(session, "player-pick", "session-picked"); + hook.hookEmitter(session, "draft-over", "session-done"); + session.assign(["test1", "test2"], (name, player) => { + player.on("available-picks", cards => { + // setTimeout hack to avoid handlers being called before the rest of the code + setTimeout(() => player.pick(cards[0].ID), 0); + }); + }); + session.start(); + await hook.expect("session-start"); + for (let i = 0; i < testSessionOptions.packs; i++) { + await hook.expect("session-new-pack"); + for (let j = 0; j < 12; j++) { + for (let p = 0; p < testSessionOptions.players; p++) { + await hook.expect("session-picked"); + } + if (i < testSessionOptions.packs - 1) { + await hook.expect("session-new-pick"); + } + } + } + await hook.expect("session-done"); + }); + + test("Sessions can load and draft I8PCube files", async () => { + expect(Database).toBeTruthy(); + const session = await Session.create({ + type: "booster-draft", + source: "i8pcube", + url: "https://mcg.zyg.ovh/cubes/hamchacube.json", + packs: 4, + players: 4, + spacing: "evenly" + }); + const hook = new EventHook(); + hook.hookEmitter(session, "start", "session-start"); + session.assign(["test1"], (_, player) => { + hook.hookEmitter(player, "available-picks", "got-cards"); + }); + session.start(); + await hook.expect("session-start"); + await hook.expect("got-cards", 1000, (cards: Card[]) => { + expect(cards).toHaveLength(12); + // Check for 2 or more multicolor cards + const multicolor = cards.filter( + c => + c.Element.length > 1 || + (c.Requirement && Object.keys(c.Requirement).length > 1) + ); + expect(multicolor.length).toBeGreaterThanOrEqual(2); + // Check for 2 or more entry cards + const entry = cards.filter( + c => !c.Requirement || c.Requirement.length < 1 + ); + expect(entry.length).toBeGreaterThanOrEqual(2); + }); + }); +});