Basic draft library #19
15 changed files with 915 additions and 9 deletions
|
@ -116,14 +116,14 @@ steps:
|
||||||
- name: test
|
- name: test
|
||||||
image: node
|
image: node
|
||||||
commands:
|
commands:
|
||||||
- yarn test:unit
|
- yarn test:unit --runInBand
|
||||||
depends_on:
|
depends_on:
|
||||||
- dependencies
|
- dependencies
|
||||||
|
|
||||||
- name: coverage
|
- name: coverage
|
||||||
image: node
|
image: node
|
||||||
commands:
|
commands:
|
||||||
- yarn test:unit --coverage
|
- yarn test:unit --coverage --runInBand
|
||||||
depends_on:
|
depends_on:
|
||||||
- test # Must run after test otherwise SQLite will get mad
|
- test # Must run after test otherwise SQLite will get mad
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import Dexie from "dexie";
|
import Dexie from "dexie";
|
||||||
import { Card, CardFilter, StoredImages } from "./types";
|
import { Card, CardFilter, StoredImage } from "./types";
|
||||||
import { cardFullName } from "./card";
|
import { cardFullName } from "./card";
|
||||||
|
|
||||||
class CardDatabase extends Dexie {
|
class CardDatabase extends Dexie {
|
||||||
public cards: Dexie.Table<Card, string>;
|
public cards: Dexie.Table<Card, string>;
|
||||||
public images: Dexie.Table<StoredImages, string>;
|
public images: Dexie.Table<StoredImage, string>;
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super("CardDatabase");
|
super("CardDatabase");
|
||||||
|
@ -20,7 +20,9 @@ class CardDatabase extends Dexie {
|
||||||
export let Database: CardDatabase | null = null;
|
export let Database: CardDatabase | null = null;
|
||||||
|
|
||||||
export function initDB() {
|
export function initDB() {
|
||||||
|
if (Database == null) {
|
||||||
Database = new CardDatabase();
|
Database = new CardDatabase();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCards(filter: CardFilter) {
|
export async function getCards(filter: CardFilter) {
|
||||||
|
@ -151,3 +153,15 @@ export async function getCards(filter: CardFilter) {
|
||||||
});
|
});
|
||||||
return await results.toArray();
|
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 type PowerRequirement = { [key: string]: number };
|
||||||
|
|
||||||
export interface StoredImages {
|
export interface StoredImage {
|
||||||
id: string;
|
id: string;
|
||||||
image: Blob;
|
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 "./MockPeer";
|
||||||
export * from "./MockHelper";
|
export * from "./MockHelper";
|
||||||
export * from "./EventHook";
|
export * from "./EventHook";
|
||||||
|
export * from "./IDBShim";
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { loadSets, getCards, Database, initDB, cardFullName } from "@/mlpccg";
|
import { loadSets, getCards, Database, initDB, cardFullName } from "@/mlpccg";
|
||||||
import Dexie from "dexie";
|
import { setupIDBShim } from "@/testing";
|
||||||
const setGlobalVars = require("indexeddbshim");
|
|
||||||
setGlobalVars(Dexie.dependencies);
|
setupIDBShim();
|
||||||
|
|
||||||
describe("mlpccg/Database", () => {
|
describe("mlpccg/Database", () => {
|
||||||
beforeAll(async () => {
|
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