Basic draft library #19

Merged
hamcha merged 13 commits from feature/draft-lib into master 2019-09-16 13:53:07 +00:00
5 changed files with 285 additions and 36 deletions
Showing only changes of commit c8697d8893 - Show all commits

View file

@ -1,7 +1,15 @@
import { Card } from "@/mlpccg"; import { Card } from "@/mlpccg";
import { SessionPlayer } from "./session";
export class DraftBot { 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 // For now, pick a random card
const idx = Math.floor(Math.random() * picks.length); const idx = Math.floor(Math.random() * picks.length);
return picks[idx]; return picks[idx];

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

View file

@ -1,55 +1,78 @@
import { PackBuilder, Cube, DraftOptions } from "."; import { PackBuilder, Cube, DraftOptions } from ".";
import EventEmitter from "eventemitter3"; import EventEmitter from "eventemitter3";
import { Card } from "@/mlpccg"; 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 options: DraftOptions;
private factory: PackBuilder; private provider: DraftProvider;
private pod: SessionPlayer[]; private pod: SessionPlayer[] = [];
private players: string[]; private players: string[] = [];
private pending: number[]; 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.options = options;
this.factory = factory; this.provider = provider;
this.players = [];
this.pending = [];
this.pod = new Array(options.players).fill(0).map((x, i) => { 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)); player.on("pick", this.picked.bind(this, i));
return player; 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 // Figure out how many players there are vs spots to be filled
this.players = players;
const spots = this.options.players; const spots = this.options.players;
const players = this.players.length; const playerNum = players.length;
if (players > spots) { if (playerNum > spots) {
throw new Error("too many players in the pod"); throw new Error("too many players in the pod");
} }
if (players < 1) { if (playerNum < 1) {
throw new Error("not enough players"); throw new Error("not enough players");
} }
// Place players in the pod // Place players in the pod
switch (this.options.spacing) { switch (this.options.spacing) {
case "evenly": case "evenly":
const playerRatio = spots / players; const playerRatio = spots / playerNum;
let i = 0; let i = 0;
for (const player of this.players) { for (const player of players) {
const pos = Math.floor(playerRatio * i); const pos = Math.floor(playerRatio * i);
this.pod[pos].name = name; this.pod[pos].name = player;
assignFn(player, this.pod[pos]);
i += 1; i += 1;
} }
break; break;
case "randomly": case "randomly":
for (const player of this.players) { for (const player of players) {
while (true) { while (true) {
const idx = Math.floor(Math.random() * spots); const idx = Math.floor(Math.random() * spots);
if (!this.pod[idx].name) { if (this.pod[idx].name == "") {
this.pod[idx].name = player; this.pod[idx].name = player;
assignFn(name, this.pod[idx]);
break; break;
} }
} }
@ -57,26 +80,86 @@ export class Session {
break; 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[] { 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)) { if (!this.pending.includes(playerIndex)) {
// Uh oh. // Uh oh.
throw new Error( 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); const idx = this.pending.indexOf(playerIndex);
this.pending.splice(idx, 1); this.pending.splice(idx, 1);
// Check if everyone picked their card
if (this.pending.length < 1) { this.emit("player-pick", this.pod[playerIndex].name);
//TODO
//NEXT PACK // 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) { switch (options.source) {
case "set": { case "set": {
const factory = await PackBuilder.fromSet(options.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": case "block":
throw new Error("not implemented"); throw new Error("not implemented");
case "cube": { case "cube": {
const cube = await Cube.fromURL(options.url); const cube = await Cube.fromURL(options.url);
const factory = new PackBuilder(cube.schema()); 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": case "i8pcube":
throw new Error("not implemented"); throw new Error("not implemented");
@ -102,17 +187,73 @@ export class Session {
} }
export class SessionPlayer extends EventEmitter { export class SessionPlayer extends EventEmitter {
public name?: string; public name: string = "";
public currentPack?: Card[]; public currentPack?: Pack;
public picks: Pack;
public packs: Pack[];
public toSend: Pack | null = null;
public next?: SessionPlayer; public next?: SessionPlayer;
public prev?: SessionPlayer;
public ready: boolean = false;
constructor(packs: Pack[]) {
super();
this.packs = packs;
this.picks = [];
}
public pick(card: string) { public pick(card: string) {
if (!this.ready) {
throw new Error("not ready to pick");
}
if (!this.currentPack) { if (!this.currentPack) {
throw new Error("no pack to pick from"); 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"); 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);
} }
} }

View file

@ -1,4 +1,5 @@
import { Card } from "@/mlpccg"; import { Card } from "@/mlpccg";
import { PackBuilder } from "./booster";
export type Provider = Iterator<Card>; export type Provider = Iterator<Card>;
@ -63,3 +64,10 @@ export interface SessionOptions {
} }
export type DraftOptions = SessionOptions & LimitedGameType & DraftType; export type DraftOptions = SessionOptions & LimitedGameType & DraftType;
export interface DraftSchema {
boosters: Record<string, number>;
factories: Record<string, PackBuilder>;
}
export type Direction = "cw" | "ccw";

View file

@ -1,9 +1,24 @@
import { setupIDBShim } from "@/testing"; import { setupIDBShim, EventHook } from "@/testing";
import { initDB, loadSets, Database } from "@/mlpccg"; import { initDB, loadSets, Database } from "@/mlpccg";
import { PackBuilder, spanByRarity, Cube } from "@/mlpccg/draft"; import {
PackBuilder,
spanByRarity,
Cube,
Session,
DraftOptions
} from "@/mlpccg/draft";
setupIDBShim(); setupIDBShim();
const testSessionOptions: DraftOptions = {
type: "booster-draft",
source: "set",
set: "FF",
packs: 2,
players: 4,
spacing: "evenly"
};
describe("mlpccg/draft", () => { describe("mlpccg/draft", () => {
beforeAll(async () => { beforeAll(async () => {
jest.setTimeout(15000); jest.setTimeout(15000);
@ -36,4 +51,47 @@ describe("mlpccg/draft", () => {
const sortedPack = pack.map(c => c.ID).sort(); const sortedPack = pack.map(c => c.ID).sort();
expect(sortedPack).toEqual(cubeCards); 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");
});
}); });