From cd1c26249c5497c8fd6679db625d223499952cef Mon Sep 17 00:00:00 2001 From: Hamcha Date: Wed, 11 Sep 2019 14:42:44 +0200 Subject: [PATCH 01/13] Add basic booster pack generation --- src/mlpccg/draft/booster.ts | 212 ++++++++++++++++++++++++++++++++++++ src/mlpccg/draft/types.ts | 20 ++++ 2 files changed, 232 insertions(+) create mode 100644 src/mlpccg/draft/booster.ts create mode 100644 src/mlpccg/draft/types.ts diff --git a/src/mlpccg/draft/booster.ts b/src/mlpccg/draft/booster.ts new file mode 100644 index 0000000..59b5143 --- /dev/null +++ b/src/mlpccg/draft/booster.ts @@ -0,0 +1,212 @@ +import { Card } from "../types"; +import { Pack, PackSchema, AlternateProvider } from "./types"; +import { getCards } from "../database"; + +/* + +(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 +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"] } +function spanByRarity(pool: Card[]): Record { + return pool.reduce((map, current) => { + if (!(current.Rarity in map)) { + map[current.Rarity] = []; + } + map[current.Rarity] = current; + return map; + }, Object.create(null)); +} diff --git a/src/mlpccg/draft/types.ts b/src/mlpccg/draft/types.ts new file mode 100644 index 0000000..62be937 --- /dev/null +++ b/src/mlpccg/draft/types.ts @@ -0,0 +1,20 @@ +import { Card } from "../types"; + +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; +} -- 2.40.1 From e238485b21220e43d02d2e654a1587d44ef7ac6c Mon Sep 17 00:00:00 2001 From: Hamcha Date: Wed, 11 Sep 2019 17:55:58 +0200 Subject: [PATCH 02/13] Add cube --- src/mlpccg/database.ts | 9 ++++++++ src/mlpccg/draft/cube.ts | 47 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 src/mlpccg/draft/cube.ts diff --git a/src/mlpccg/database.ts b/src/mlpccg/database.ts index 0fdb78b..088f5ab 100644 --- a/src/mlpccg/database.ts +++ b/src/mlpccg/database.ts @@ -151,3 +151,12 @@ export async function getCards(filter: CardFilter) { }); return await results.toArray(); } + +export async function cardFromIDs(cardIDs: string[]): Promise { + 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/cube.ts b/src/mlpccg/draft/cube.ts new file mode 100644 index 0000000..b92338e --- /dev/null +++ b/src/mlpccg/draft/cube.ts @@ -0,0 +1,47 @@ +import { Card } from "../types"; +import { getCards, cardFromIDs } from "../database"; +import axios from "axios"; +import { PackSchema } from "./types"; + +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); + return await this.fromList(res.data); + } +} -- 2.40.1 From cf47d5db4afa93c5065a0abc40564c90946de39e Mon Sep 17 00:00:00 2001 From: Hamcha Date: Thu, 12 Sep 2019 11:20:06 +0200 Subject: [PATCH 03/13] Only initialize IDBShim and database once --- src/mlpccg/database.ts | 4 +++- src/testing/IDBShim.ts | 11 +++++++++++ src/tests/unit/database.spec.ts | 6 +++--- 3 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 src/testing/IDBShim.ts diff --git a/src/mlpccg/database.ts b/src/mlpccg/database.ts index 088f5ab..a9dd537 100644 --- a/src/mlpccg/database.ts +++ b/src/mlpccg/database.ts @@ -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) { 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/tests/unit/database.spec.ts b/src/tests/unit/database.spec.ts index b87cf75..1bb70a5 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/IDBShim"; + +setupIDBShim(); describe("mlpccg/Database", () => { beforeAll(async () => { -- 2.40.1 From 4486bcb35657fa0e4c1e506e57d74e04588a7a69 Mon Sep 17 00:00:00 2001 From: Hamcha Date: Thu, 12 Sep 2019 11:34:41 +0200 Subject: [PATCH 04/13] Grammar --- src/mlpccg/database.ts | 7 +++++-- src/mlpccg/types.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/mlpccg/database.ts b/src/mlpccg/database.ts index a9dd537..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"); @@ -155,6 +155,9 @@ export async function getCards(filter: CardFilter) { } 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 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; } -- 2.40.1 From 4e2afd4db3173859aa3333f11b0125132fd7b28b Mon Sep 17 00:00:00 2001 From: Hamcha Date: Thu, 12 Sep 2019 11:35:02 +0200 Subject: [PATCH 05/13] Add draft te st and fix spanByRarity --- src/mlpccg/draft/booster.ts | 6 +++--- src/mlpccg/draft/index.ts | 3 +++ src/tests/unit/draft.spec.ts | 25 +++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 src/mlpccg/draft/index.ts create mode 100644 src/tests/unit/draft.spec.ts diff --git a/src/mlpccg/draft/booster.ts b/src/mlpccg/draft/booster.ts index 59b5143..26627a3 100644 --- a/src/mlpccg/draft/booster.ts +++ b/src/mlpccg/draft/booster.ts @@ -192,7 +192,7 @@ export class PackBuilder { } // Yields random cards from a chosen pool -function* randomProvider(pool: Card[]) { +export function* randomProvider(pool: Card[]) { while (true) { const idx = Math.floor(Math.random() * pool.length); yield pool[idx]; @@ -201,12 +201,12 @@ function* randomProvider(pool: Card[]) { // Divides a list of card to a map of rarities // ie. [ff14, ff16, ff17] => { "C" : ["ff14"], "U": ["ff17"], "R": ["ff16"] } -function spanByRarity(pool: Card[]): Record { +export function spanByRarity(pool: Card[]): Record { return pool.reduce((map, current) => { if (!(current.Rarity in map)) { map[current.Rarity] = []; } - map[current.Rarity] = current; + map[current.Rarity].push(current); return map; }, Object.create(null)); } diff --git a/src/mlpccg/draft/index.ts b/src/mlpccg/draft/index.ts new file mode 100644 index 0000000..cb4f17a --- /dev/null +++ b/src/mlpccg/draft/index.ts @@ -0,0 +1,3 @@ +export * from "./cube"; +export * from "./booster"; +export * from "./types"; diff --git a/src/tests/unit/draft.spec.ts b/src/tests/unit/draft.spec.ts new file mode 100644 index 0000000..3d49435 --- /dev/null +++ b/src/tests/unit/draft.spec.ts @@ -0,0 +1,25 @@ +import { setupIDBShim } from "@/testing/IDBShim"; +import { initDB, loadSets, Database } from "@/mlpccg"; +import { PackBuilder, spanByRarity } from "@/mlpccg/draft"; + +setupIDBShim(); + +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); + }); +}); -- 2.40.1 From 6a863e79b777b8df399b93ca1a8d613ecf85ad90 Mon Sep 17 00:00:00 2001 From: Hamcha Date: Thu, 12 Sep 2019 14:37:34 +0200 Subject: [PATCH 06/13] Make tests run sequentially --- .drone.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 -- 2.40.1 From ff6a77d431919fda7fb0b4263cbcd00a9c36a514 Mon Sep 17 00:00:00 2001 From: Hamcha Date: Thu, 12 Sep 2019 14:37:50 +0200 Subject: [PATCH 07/13] Include shim in testing package --- src/testing/index.ts | 1 + src/tests/unit/database.spec.ts | 2 +- src/tests/unit/draft.spec.ts | 18 ++++++++++++++++-- 3 files changed, 18 insertions(+), 3 deletions(-) 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 1bb70a5..d1d77ff 100644 --- a/src/tests/unit/database.spec.ts +++ b/src/tests/unit/database.spec.ts @@ -1,5 +1,5 @@ import { loadSets, getCards, Database, initDB, cardFullName } from "@/mlpccg"; -import { setupIDBShim } from "@/testing/IDBShim"; +import { setupIDBShim } from "@/testing"; setupIDBShim(); diff --git a/src/tests/unit/draft.spec.ts b/src/tests/unit/draft.spec.ts index 3d49435..d9015e3 100644 --- a/src/tests/unit/draft.spec.ts +++ b/src/tests/unit/draft.spec.ts @@ -1,6 +1,6 @@ -import { setupIDBShim } from "@/testing/IDBShim"; +import { setupIDBShim } from "@/testing"; import { initDB, loadSets, Database } from "@/mlpccg"; -import { PackBuilder, spanByRarity } from "@/mlpccg/draft"; +import { PackBuilder, spanByRarity, Cube } from "@/mlpccg/draft"; setupIDBShim(); @@ -22,4 +22,18 @@ describe("mlpccg/draft", () => { 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); + }); }); -- 2.40.1 From 5688e499fc1a27b1151da3d904aee9328a46bc8b Mon Sep 17 00:00:00 2001 From: Hamcha Date: Thu, 12 Sep 2019 18:09:39 +0200 Subject: [PATCH 08/13] Start work on bot and sessions --- src/mlpccg/draft/booster.ts | 3 +-- src/mlpccg/draft/bot.ts | 9 +++++++++ src/mlpccg/draft/cube.ts | 5 ++--- src/mlpccg/draft/index.ts | 2 ++ src/mlpccg/draft/session.ts | 29 +++++++++++++++++++++++++++++ src/mlpccg/draft/types.ts | 28 +++++++++++++++++++++++++++- 6 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 src/mlpccg/draft/bot.ts create mode 100644 src/mlpccg/draft/session.ts diff --git a/src/mlpccg/draft/booster.ts b/src/mlpccg/draft/booster.ts index 26627a3..e1123b4 100644 --- a/src/mlpccg/draft/booster.ts +++ b/src/mlpccg/draft/booster.ts @@ -1,6 +1,5 @@ -import { Card } from "../types"; +import { Card, getCards } from "@/mlpccg"; import { Pack, PackSchema, AlternateProvider } from "./types"; -import { getCards } from "../database"; /* diff --git a/src/mlpccg/draft/bot.ts b/src/mlpccg/draft/bot.ts new file mode 100644 index 0000000..b6e9ee5 --- /dev/null +++ b/src/mlpccg/draft/bot.ts @@ -0,0 +1,9 @@ +import { Card } from "@/mlpccg"; + +export class DraftBot { + pick(picks: 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 index b92338e..fc6d4c1 100644 --- a/src/mlpccg/draft/cube.ts +++ b/src/mlpccg/draft/cube.ts @@ -1,7 +1,6 @@ -import { Card } from "../types"; -import { getCards, cardFromIDs } from "../database"; -import axios from "axios"; +import { Card, cardFromIDs } from "@/mlpccg"; import { PackSchema } from "./types"; +import axios from "axios"; export class Cube { private pool: Card[]; diff --git a/src/mlpccg/draft/index.ts b/src/mlpccg/draft/index.ts index cb4f17a..5dee001 100644 --- a/src/mlpccg/draft/index.ts +++ b/src/mlpccg/draft/index.ts @@ -1,3 +1,5 @@ export * from "./cube"; export * from "./booster"; export * from "./types"; +export * from "./session"; +export * from "./bot"; diff --git a/src/mlpccg/draft/session.ts b/src/mlpccg/draft/session.ts new file mode 100644 index 0000000..d9f2bf3 --- /dev/null +++ b/src/mlpccg/draft/session.ts @@ -0,0 +1,29 @@ +import { PackBuilder, Cube, DraftOptions } from "."; + +export class Session { + private options: DraftOptions; + private factory: PackBuilder; + + constructor(options: DraftOptions, factory: PackBuilder) { + this.options = options; + this.factory = factory; + } + + static async create(options: DraftOptions): Promise { + switch (options.type) { + case "set": { + const factory = await PackBuilder.fromSet(options.set); + return new Session(options, factory); + } + case "block": + throw new Error("not implemented"); + case "cube": { + const cube = await Cube.fromURL(options.url); + const factory = new PackBuilder(cube.schema()); + return new Session(options, factory); + } + case "i8pcube": + throw new Error("not implemented"); + } + } +} diff --git a/src/mlpccg/draft/types.ts b/src/mlpccg/draft/types.ts index 62be937..58f4ae7 100644 --- a/src/mlpccg/draft/types.ts +++ b/src/mlpccg/draft/types.ts @@ -1,4 +1,4 @@ -import { Card } from "../types"; +import { Card } from "@/mlpccg"; export type Provider = Iterator; @@ -18,3 +18,29 @@ export interface AlternateProvider { probability: number; provider: Provider; } + +export interface SetDraftOptions { + type: "set"; + set: string; +} + +export interface BlockDraftOptions { + type: "block"; + block: string; +} + +export interface CubeDraftOptions { + type: "cube"; + url: string; +} + +export interface I8PCubeDraftOptions { + type: "i8pcube"; + url: string; +} + +export type DraftOptions = + | SetDraftOptions + | BlockDraftOptions + | CubeDraftOptions + | I8PCubeDraftOptions; -- 2.40.1 From 5daa94b9ad4cc8d131e681a2d8ea9a7b118c6a9c Mon Sep 17 00:00:00 2001 From: Hamcha Date: Fri, 13 Sep 2019 17:55:41 +0200 Subject: [PATCH 09/13] Refactor draft options --- src/mlpccg/draft/types.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/mlpccg/draft/types.ts b/src/mlpccg/draft/types.ts index 58f4ae7..3306bd6 100644 --- a/src/mlpccg/draft/types.ts +++ b/src/mlpccg/draft/types.ts @@ -20,27 +20,46 @@ export interface AlternateProvider { } export interface SetDraftOptions { - type: "set"; + source: "set"; set: string; } export interface BlockDraftOptions { - type: "block"; + source: "block"; block: string; } export interface CubeDraftOptions { - type: "cube"; + source: "cube"; url: string; } export interface I8PCubeDraftOptions { - type: "i8pcube"; + source: "i8pcube"; url: string; } -export type DraftOptions = +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; -- 2.40.1 From d37c6a5cc5e2ce64cc74027ee8e52cb72ccf22d9 Mon Sep 17 00:00:00 2001 From: Hamcha Date: Fri, 13 Sep 2019 17:56:05 +0200 Subject: [PATCH 10/13] WIP sessions --- src/mlpccg/draft/session.ts | 91 ++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/src/mlpccg/draft/session.ts b/src/mlpccg/draft/session.ts index d9f2bf3..3b07255 100644 --- a/src/mlpccg/draft/session.ts +++ b/src/mlpccg/draft/session.ts @@ -1,16 +1,87 @@ import { PackBuilder, Cube, DraftOptions } from "."; +import EventEmitter from "eventemitter3"; +import { Card } from "@/mlpccg"; export class Session { private options: DraftOptions; private factory: PackBuilder; + private pod: SessionPlayer[]; + private players: string[]; + private pending: number[]; constructor(options: DraftOptions, factory: PackBuilder) { this.options = options; this.factory = factory; + this.players = []; + this.pending = []; + this.pod = new Array(options.players).fill(0).map((x, i) => { + const player = new SessionPlayer(); + player.on("pick", this.picked.bind(this, i)); + return player; + }); + } + + public start() { + // Figure out how many players there are vs spots to be filled + const spots = this.options.players; + const players = this.players.length; + if (players > spots) { + throw new Error("too many players in the pod"); + } + + if (players < 1) { + throw new Error("not enough players"); + } + + // Place players in the pod + switch (this.options.spacing) { + case "evenly": + const playerRatio = spots / players; + let i = 0; + for (const player of this.players) { + const pos = Math.floor(playerRatio * i); + this.pod[pos].name = name; + i += 1; + } + break; + case "randomly": + for (const player of this.players) { + while (true) { + const idx = Math.floor(Math.random() * spots); + if (!this.pod[idx].name) { + this.pod[idx].name = player; + break; + } + } + } + break; + } + + //TODO + } + + public get order(): string[] { + return this.pod.map(p => p.name || "bot"); + } + + private picked(playerIndex: number, card: string) { + if (!this.pending.includes(playerIndex)) { + // Uh oh. + throw new Error( + `unexpected pick: player #${playerIndex} already picked their card` + ); + } + const idx = this.pending.indexOf(playerIndex); + this.pending.splice(idx, 1); + // Check if everyone picked their card + if (this.pending.length < 1) { + //TODO + //NEXT PACK + } } static async create(options: DraftOptions): Promise { - switch (options.type) { + switch (options.source) { case "set": { const factory = await PackBuilder.fromSet(options.set); return new Session(options, factory); @@ -24,6 +95,24 @@ export class Session { } case "i8pcube": throw new Error("not implemented"); + default: + throw new Error("Unknown draft source"); } } } + +export class SessionPlayer extends EventEmitter { + public name?: string; + public currentPack?: Card[]; + public next?: SessionPlayer; + + public pick(card: string) { + if (!this.currentPack) { + throw new Error("no pack to pick from"); + } + if (!this.currentPack.find(c => c.ID == card)) { + throw new Error("card not in available picks"); + } + this.emit("pick", card); + } +} -- 2.40.1 From c8697d889358f49738197d9b4256f2caa51df662 Mon Sep 17 00:00:00 2001 From: Hamcha Date: Mon, 16 Sep 2019 11:56:05 +0200 Subject: [PATCH 11/13] Make sessions work (and add tests) --- src/mlpccg/draft/bot.ts | 10 +- src/mlpccg/draft/provider.ts | 34 ++++++ src/mlpccg/draft/session.ts | 207 +++++++++++++++++++++++++++++------ src/mlpccg/draft/types.ts | 8 ++ src/tests/unit/draft.spec.ts | 62 ++++++++++- 5 files changed, 285 insertions(+), 36 deletions(-) create mode 100644 src/mlpccg/draft/provider.ts diff --git a/src/mlpccg/draft/bot.ts b/src/mlpccg/draft/bot.ts index b6e9ee5..1d443ea 100644 --- a/src/mlpccg/draft/bot.ts +++ b/src/mlpccg/draft/bot.ts @@ -1,7 +1,15 @@ import { Card } from "@/mlpccg"; +import { SessionPlayer } from "./session"; export class DraftBot { - pick(picks: Card[]) { + assign(player: SessionPlayer) { + player.on("available-picks", cards => { + const pick = this.pick(cards); + 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/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 index 3b07255..56b6653 100644 --- a/src/mlpccg/draft/session.ts +++ b/src/mlpccg/draft/session.ts @@ -1,55 +1,78 @@ 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"; -export class Session { +export class Session extends EventEmitter { private options: DraftOptions; - private factory: PackBuilder; - private pod: SessionPlayer[]; - private players: string[]; - private pending: number[]; + private provider: DraftProvider; + private pod: SessionPlayer[] = []; + private players: string[] = []; + private pending: number[] = []; + private assigned: boolean = false; + private direction: Direction = "cw"; - constructor(options: DraftOptions, factory: PackBuilder) { + constructor(options: DraftOptions, provider: DraftProvider) { + super(); this.options = options; - this.factory = factory; - this.players = []; - this.pending = []; + this.provider = provider; this.pod = new Array(options.players).fill(0).map((x, i) => { - const player = new SessionPlayer(); + 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 start() { + 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 players = this.players.length; - if (players > spots) { + const playerNum = players.length; + if (playerNum > spots) { throw new Error("too many players in the pod"); } - if (players < 1) { + if (playerNum < 1) { throw new Error("not enough players"); } // Place players in the pod switch (this.options.spacing) { case "evenly": - const playerRatio = spots / players; + const playerRatio = spots / playerNum; let i = 0; - for (const player of this.players) { + for (const player of players) { const pos = Math.floor(playerRatio * i); - this.pod[pos].name = name; + this.pod[pos].name = player; + assignFn(player, this.pod[pos]); i += 1; } break; case "randomly": - for (const player of this.players) { + for (const player of players) { while (true) { const idx = Math.floor(Math.random() * spots); - if (!this.pod[idx].name) { + if (this.pod[idx].name == "") { this.pod[idx].name = player; + assignFn(name, this.pod[idx]); break; } } @@ -57,26 +80,86 @@ export class Session { break; } - //TODO + // 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 || "bot"); + return this.pod.map(p => p.name); } - private picked(playerIndex: number, card: string) { + private picked( + playerIndex: number, + _card: string, + lastPick: boolean, + lastPack: boolean + ) { if (!this.pending.includes(playerIndex)) { // Uh oh. throw new Error( - `unexpected pick: player #${playerIndex} already picked their card` + `unexpected pick: player "${this.pod[playerIndex].name}" already picked their card` ); } const idx = this.pending.indexOf(playerIndex); this.pending.splice(idx, 1); - // Check if everyone picked their card - if (this.pending.length < 1) { - //TODO - //NEXT PACK + + 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"; } } @@ -84,14 +167,16 @@ export class Session { switch (options.source) { case "set": { const factory = await PackBuilder.fromSet(options.set); - return new Session(options, factory); + 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()); - return new Session(options, factory); + const provider = DraftProvider.set(factory, options.packs); + return new Session(options, provider); } case "i8pcube": throw new Error("not implemented"); @@ -102,17 +187,73 @@ export class Session { } export class SessionPlayer extends EventEmitter { - public name?: string; - public currentPack?: Card[]; + 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"); } - if (!this.currentPack.find(c => c.ID == card)) { + const idx = this.currentPack.findIndex(c => c.ID == card); + if (idx < 0) { throw new Error("card not in available picks"); } - this.emit("pick", card); + 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 index 3306bd6..8d09467 100644 --- a/src/mlpccg/draft/types.ts +++ b/src/mlpccg/draft/types.ts @@ -1,4 +1,5 @@ import { Card } from "@/mlpccg"; +import { PackBuilder } from "./booster"; export type Provider = Iterator; @@ -63,3 +64,10 @@ export interface SessionOptions { } export type DraftOptions = SessionOptions & LimitedGameType & DraftType; + +export interface DraftSchema { + boosters: Record; + factories: Record; +} + +export type Direction = "cw" | "ccw"; diff --git a/src/tests/unit/draft.spec.ts b/src/tests/unit/draft.spec.ts index d9015e3..5f3bbb7 100644 --- a/src/tests/unit/draft.spec.ts +++ b/src/tests/unit/draft.spec.ts @@ -1,9 +1,24 @@ -import { setupIDBShim } from "@/testing"; +import { setupIDBShim, EventHook } from "@/testing"; import { initDB, loadSets, Database } from "@/mlpccg"; -import { PackBuilder, spanByRarity, Cube } from "@/mlpccg/draft"; +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); @@ -36,4 +51,47 @@ describe("mlpccg/draft", () => { 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"); + }); }); -- 2.40.1 From dddeca986706933d7c4c1deccd19f6cf897affd3 Mon Sep 17 00:00:00 2001 From: Hamcha Date: Mon, 16 Sep 2019 12:08:02 +0200 Subject: [PATCH 12/13] Add comment about setTimeout hack --- src/mlpccg/draft/bot.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mlpccg/draft/bot.ts b/src/mlpccg/draft/bot.ts index 1d443ea..081b177 100644 --- a/src/mlpccg/draft/bot.ts +++ b/src/mlpccg/draft/bot.ts @@ -5,6 +5,7 @@ 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); }); } -- 2.40.1 From 572bd90d97fa0556c7f7418d281b429824aa08c9 Mon Sep 17 00:00:00 2001 From: Hamcha Date: Mon, 16 Sep 2019 15:45:45 +0200 Subject: [PATCH 13/13] Add I8PCube --- src/mlpccg/draft/cube.ts | 4 +- src/mlpccg/draft/i8pcube.ts | 81 ++++++++++++++++++++++++++++++++++++ src/mlpccg/draft/session.ts | 5 ++- src/mlpccg/draft/types.ts | 17 ++++++++ src/tests/unit/draft.spec.ts | 36 +++++++++++++++- 5 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 src/mlpccg/draft/i8pcube.ts diff --git a/src/mlpccg/draft/cube.ts b/src/mlpccg/draft/cube.ts index fc6d4c1..1e437e7 100644 --- a/src/mlpccg/draft/cube.ts +++ b/src/mlpccg/draft/cube.ts @@ -40,7 +40,9 @@ export class Cube { } static async fromURL(url: string) { - const res = await axios(url); + 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/session.ts b/src/mlpccg/draft/session.ts index 56b6653..c3e9d02 100644 --- a/src/mlpccg/draft/session.ts +++ b/src/mlpccg/draft/session.ts @@ -4,6 +4,7 @@ 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; @@ -179,7 +180,9 @@ export class Session extends EventEmitter { return new Session(options, provider); } case "i8pcube": - throw new Error("not implemented"); + 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"); } diff --git a/src/mlpccg/draft/types.ts b/src/mlpccg/draft/types.ts index 8d09467..7b2931a 100644 --- a/src/mlpccg/draft/types.ts +++ b/src/mlpccg/draft/types.ts @@ -71,3 +71,20 @@ export interface DraftSchema { } 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/tests/unit/draft.spec.ts b/src/tests/unit/draft.spec.ts index 5f3bbb7..1db04e2 100644 --- a/src/tests/unit/draft.spec.ts +++ b/src/tests/unit/draft.spec.ts @@ -1,5 +1,5 @@ import { setupIDBShim, EventHook } from "@/testing"; -import { initDB, loadSets, Database } from "@/mlpccg"; +import { initDB, loadSets, Database, Card } from "@/mlpccg"; import { PackBuilder, spanByRarity, @@ -94,4 +94,38 @@ describe("mlpccg/draft", () => { } 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); + }); + }); }); -- 2.40.1