Basic draft library (#19)
All checks were successful
continuous-integration/drone/push Build is passing

Supports set, cube, i8pcube drafts with dumb bots. No support for block draft yet.
This commit is contained in:
Hamcha 2019-09-16 13:53:07 +00:00 committed by Gitea
parent 77f146625c
commit 55fad9db70
15 changed files with 915 additions and 9 deletions

View file

@ -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

View file

@ -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<Card, string>;
public images: Dexie.Table<StoredImages, string>;
public images: Dexie.Table<StoredImage, string>;
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<Card[]> {
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();
}

211
src/mlpccg/draft/booster.ts Normal file
View file

@ -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<PackSchema> {
// 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<PackBuilder> {
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<string, Card[]> {
return pool.reduce((map, current) => {
if (!(current.Rarity in map)) {
map[current.Rarity] = [];
}
map[current.Rarity].push(current);
return map;
}, Object.create(null));
}

18
src/mlpccg/draft/bot.ts Normal file
View file

@ -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];
}
}

48
src/mlpccg/draft/cube.ts Normal file
View file

@ -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<Cube> {
const cards = await cardFromIDs(cardIDs);
return new this(cards);
}
static async fromList(list: string): Promise<Cube> {
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);
}
}

View file

@ -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<string, Card[]>;
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<string, Card[]> = {};
for (const pool in cubefile.Cards) {
cards[pool] = await cardFromIDs(cubefile.Cards[pool]);
}
return new this({
Cards: cards,
ProblemPackSize: cubefile.ProblemPackSize,
Schema: cubefile.Schema
});
}
}

View file

@ -0,0 +1,5 @@
export * from "./cube";
export * from "./booster";
export * from "./types";
export * from "./session";
export * from "./bot";

View file

@ -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 }
});
}
}

262
src/mlpccg/draft/session.ts Normal file
View file

@ -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<Session> {
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);
}
}

90
src/mlpccg/draft/types.ts Normal file
View file

@ -0,0 +1,90 @@
import { Card } from "@/mlpccg";
import { PackBuilder } from "./booster";
export type Provider = Iterator<Card>;
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<string, number>;
factories: Record<string, PackBuilder>;
}
export type Direction = "cw" | "ccw";
export interface I8PCubeSchema {
Schema: I8PPackSchema[];
ProblemPackSize: number;
Cards: Record<string, Card[]>;
}
export interface I8PFileSchema {
Schema: I8PPackSchema[];
ProblemPackSize: number;
Cards: Record<string, string[]>;
}
export interface I8PPackSchema {
Amount: number;
Type: string;
}

View file

@ -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;
}

11
src/testing/IDBShim.ts Normal file
View file

@ -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;
}
}

View file

@ -2,3 +2,4 @@ export * from "./MockDataConnection";
export * from "./MockPeer";
export * from "./MockHelper";
export * from "./EventHook";
export * from "./IDBShim";

View file

@ -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 () => {

View file

@ -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);
});
});
});