Basic draft library (#19)
All checks were successful
continuous-integration/drone/push Build is passing
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:
parent
77f146625c
commit
55fad9db70
15 changed files with 915 additions and 9 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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() {
|
||||
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
211
src/mlpccg/draft/booster.ts
Normal 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
18
src/mlpccg/draft/bot.ts
Normal 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
48
src/mlpccg/draft/cube.ts
Normal 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);
|
||||
}
|
||||
}
|
81
src/mlpccg/draft/i8pcube.ts
Normal file
81
src/mlpccg/draft/i8pcube.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
5
src/mlpccg/draft/index.ts
Normal file
5
src/mlpccg/draft/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export * from "./cube";
|
||||
export * from "./booster";
|
||||
export * from "./types";
|
||||
export * from "./session";
|
||||
export * from "./bot";
|
34
src/mlpccg/draft/provider.ts
Normal file
34
src/mlpccg/draft/provider.ts
Normal 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
262
src/mlpccg/draft/session.ts
Normal 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
90
src/mlpccg/draft/types.ts
Normal 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;
|
||||
}
|
|
@ -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
11
src/testing/IDBShim.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -2,3 +2,4 @@ export * from "./MockDataConnection";
|
|||
export * from "./MockPeer";
|
||||
export * from "./MockHelper";
|
||||
export * from "./EventHook";
|
||||
export * from "./IDBShim";
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
131
src/tests/unit/draft.spec.ts
Normal file
131
src/tests/unit/draft.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue