diff --git a/src/mlpccg/draft/bot.ts b/src/mlpccg/draft/bot.ts index b6e9ee5..1d443ea 100644 --- a/src/mlpccg/draft/bot.ts +++ b/src/mlpccg/draft/bot.ts @@ -1,7 +1,15 @@ import { Card } from "@/mlpccg"; +import { SessionPlayer } from "./session"; export class DraftBot { - pick(picks: Card[]) { + assign(player: SessionPlayer) { + player.on("available-picks", cards => { + const pick = this.pick(cards); + setTimeout(() => player.pick(pick.ID), 0); + }); + } + + pick(picks: Card[]): Card { // For now, pick a random card const idx = Math.floor(Math.random() * picks.length); return picks[idx]; diff --git a/src/mlpccg/draft/provider.ts b/src/mlpccg/draft/provider.ts new file mode 100644 index 0000000..3f3ee03 --- /dev/null +++ b/src/mlpccg/draft/provider.ts @@ -0,0 +1,34 @@ +import { DraftSchema, Pack } from "./types"; +import { PackBuilder } from "./booster"; + +export class DraftProvider { + private schema: DraftSchema; + + constructor(schema: DraftSchema) { + this.schema = schema; + } + + getPacks(): Pack[] { + let out = []; + for (const boosterSlot in this.schema.boosters) { + const amount = this.schema.boosters[boosterSlot]; + const factory = this.schema.factories[boosterSlot]; + if (!factory) { + throw new Error( + `booster type ${boosterSlot} was referenced in schema but was not provided a builder` + ); + } + for (let i = 0; i < amount; i++) { + out.push(factory.buildPack()); + } + } + return out; + } + + static set(factory: PackBuilder, amount: number): DraftProvider { + return new DraftProvider({ + boosters: { normal: amount }, + factories: { normal: factory } + }); + } +} diff --git a/src/mlpccg/draft/session.ts b/src/mlpccg/draft/session.ts index 3b07255..56b6653 100644 --- a/src/mlpccg/draft/session.ts +++ b/src/mlpccg/draft/session.ts @@ -1,55 +1,78 @@ import { PackBuilder, Cube, DraftOptions } from "."; import EventEmitter from "eventemitter3"; import { Card } from "@/mlpccg"; +import { Pack, Direction } from "./types"; +import { DraftProvider } from "./provider"; +import { DraftBot } from "./bot"; -export class Session { +export class Session extends EventEmitter { private options: DraftOptions; - private factory: PackBuilder; - private pod: SessionPlayer[]; - private players: string[]; - private pending: number[]; + private provider: DraftProvider; + private pod: SessionPlayer[] = []; + private players: string[] = []; + private pending: number[] = []; + private assigned: boolean = false; + private direction: Direction = "cw"; - constructor(options: DraftOptions, factory: PackBuilder) { + constructor(options: DraftOptions, provider: DraftProvider) { + super(); this.options = options; - this.factory = factory; - this.players = []; - this.pending = []; + this.provider = provider; this.pod = new Array(options.players).fill(0).map((x, i) => { - const player = new SessionPlayer(); + const player = new SessionPlayer(provider.getPacks()); player.on("pick", this.picked.bind(this, i)); return player; }); + // Populate prev/next references + this.pod.forEach((val, i) => { + if (i > 0) { + val.prev = this.pod[i - 1]; + } else { + val.prev = this.pod[this.pod.length - 1]; + } + if (i < this.pod.length - 1) { + val.next = this.pod[i + 1]; + } else { + val.next = this.pod[0]; + } + }); } - public start() { + public assign( + players: string[], + assignFn: (name: string, instance: SessionPlayer) => void + ) { // Figure out how many players there are vs spots to be filled + this.players = players; const spots = this.options.players; - const players = this.players.length; - if (players > spots) { + const playerNum = players.length; + if (playerNum > spots) { throw new Error("too many players in the pod"); } - if (players < 1) { + if (playerNum < 1) { throw new Error("not enough players"); } // Place players in the pod switch (this.options.spacing) { case "evenly": - const playerRatio = spots / players; + const playerRatio = spots / playerNum; let i = 0; - for (const player of this.players) { + for (const player of players) { const pos = Math.floor(playerRatio * i); - this.pod[pos].name = name; + this.pod[pos].name = player; + assignFn(player, this.pod[pos]); i += 1; } break; case "randomly": - for (const player of this.players) { + for (const player of players) { while (true) { const idx = Math.floor(Math.random() * spots); - if (!this.pod[idx].name) { + if (this.pod[idx].name == "") { this.pod[idx].name = player; + assignFn(name, this.pod[idx]); break; } } @@ -57,26 +80,86 @@ export class Session { break; } - //TODO + // All the non-assigned places go to bots! + this.pod.forEach(p => { + if (p.name == "") { + p.name = "bot"; + const bot = new DraftBot(); + bot.assign(p); + } + }); + + this.assigned = true; + } + + public start() { + if (!this.assigned) { + throw new Error("Must assign players first (see assign())"); + } + this.emit("start", this.order); + this.nextPack(); } public get order(): string[] { - return this.pod.map(p => p.name || "bot"); + return this.pod.map(p => p.name); } - private picked(playerIndex: number, card: string) { + private picked( + playerIndex: number, + _card: string, + lastPick: boolean, + lastPack: boolean + ) { if (!this.pending.includes(playerIndex)) { // Uh oh. throw new Error( - `unexpected pick: player #${playerIndex} already picked their card` + `unexpected pick: player "${this.pod[playerIndex].name}" already picked their card` ); } const idx = this.pending.indexOf(playerIndex); this.pending.splice(idx, 1); - // Check if everyone picked their card - if (this.pending.length < 1) { - //TODO - //NEXT PACK + + this.emit("player-pick", this.pod[playerIndex].name); + + // Don't continue unless everyone picked their card + if (this.pending.length > 0) { + return; + } + + // Was this the last pick for this round of packs? + if (lastPick) { + // Was the it the last pack? + if (lastPack) { + this.emit("draft-over"); + this.pod.forEach(p => p.emit("your-picks", p.picks)); + return; + } + this.nextPack(); + return; + } + + // Pass packs between players for next pick + this.resetPending(); + this.pod.forEach(p => p.sendPack(this.direction)); + this.emit("next-pick"); + } + + private nextPack() { + this.resetPending(); + this.flipOrder(); + this.pod.forEach(p => p.nextPack()); + this.emit("next-pack"); + } + + private resetPending() { + this.pending = this.pod.map((_, i) => i); + } + + private flipOrder() { + if (this.direction == "cw") { + this.direction = "ccw"; + } else { + this.direction = "cw"; } } @@ -84,14 +167,16 @@ export class Session { switch (options.source) { case "set": { const factory = await PackBuilder.fromSet(options.set); - return new Session(options, factory); + const provider = DraftProvider.set(factory, options.packs); + return new Session(options, provider); } case "block": throw new Error("not implemented"); case "cube": { const cube = await Cube.fromURL(options.url); const factory = new PackBuilder(cube.schema()); - return new Session(options, factory); + const provider = DraftProvider.set(factory, options.packs); + return new Session(options, provider); } case "i8pcube": throw new Error("not implemented"); @@ -102,17 +187,73 @@ export class Session { } export class SessionPlayer extends EventEmitter { - public name?: string; - public currentPack?: Card[]; + public name: string = ""; + public currentPack?: Pack; + public picks: Pack; + public packs: Pack[]; + public toSend: Pack | null = null; public next?: SessionPlayer; + public prev?: SessionPlayer; + public ready: boolean = false; + + constructor(packs: Pack[]) { + super(); + this.packs = packs; + this.picks = []; + } public pick(card: string) { + if (!this.ready) { + throw new Error("not ready to pick"); + } if (!this.currentPack) { throw new Error("no pack to pick from"); } - if (!this.currentPack.find(c => c.ID == card)) { + const idx = this.currentPack.findIndex(c => c.ID == card); + if (idx < 0) { throw new Error("card not in available picks"); } - this.emit("pick", card); + const pick = this.currentPack.splice(idx, 1); + this.picks.push(pick[0]); + this.toSend = this.currentPack; + this.ready = false; + + this.emit("pick", card, this.currentPack.length < 1, this.packs.length < 1); + } + + public sendPack(direction: Direction) { + if (!this.toSend) { + throw new Error("no pack to pass"); + } + if (this.toSend.length < 1) { + throw new Error("empty pack"); + } + if (direction == "cw") { + if (!this.next) { + throw new Error("no player to pass cards to"); + } + this.next.receivePack(this.toSend); + } else { + if (!this.prev) { + throw new Error("no player to pass cards to"); + } + this.prev.receivePack(this.toSend); + } + this.toSend = null; + } + + public receivePack(cards: Pack) { + this.currentPack = cards; + this.ready = true; + this.emit("available-picks", cards); + } + + public nextPack() { + // Open new pack + const newPack = this.packs.shift(); + if (!newPack) { + throw new Error("no packs left"); + } + this.receivePack(newPack); } } diff --git a/src/mlpccg/draft/types.ts b/src/mlpccg/draft/types.ts index 3306bd6..8d09467 100644 --- a/src/mlpccg/draft/types.ts +++ b/src/mlpccg/draft/types.ts @@ -1,4 +1,5 @@ import { Card } from "@/mlpccg"; +import { PackBuilder } from "./booster"; export type Provider = Iterator; @@ -63,3 +64,10 @@ export interface SessionOptions { } export type DraftOptions = SessionOptions & LimitedGameType & DraftType; + +export interface DraftSchema { + boosters: Record; + factories: Record; +} + +export type Direction = "cw" | "ccw"; diff --git a/src/tests/unit/draft.spec.ts b/src/tests/unit/draft.spec.ts index d9015e3..5f3bbb7 100644 --- a/src/tests/unit/draft.spec.ts +++ b/src/tests/unit/draft.spec.ts @@ -1,9 +1,24 @@ -import { setupIDBShim } from "@/testing"; +import { setupIDBShim, EventHook } from "@/testing"; import { initDB, loadSets, Database } from "@/mlpccg"; -import { PackBuilder, spanByRarity, Cube } from "@/mlpccg/draft"; +import { + PackBuilder, + spanByRarity, + Cube, + Session, + DraftOptions +} from "@/mlpccg/draft"; setupIDBShim(); +const testSessionOptions: DraftOptions = { + type: "booster-draft", + source: "set", + set: "FF", + packs: 2, + players: 4, + spacing: "evenly" +}; + describe("mlpccg/draft", () => { beforeAll(async () => { jest.setTimeout(15000); @@ -36,4 +51,47 @@ describe("mlpccg/draft", () => { const sortedPack = pack.map(c => c.ID).sort(); expect(sortedPack).toEqual(cubeCards); }); + + test("A session can be initialized", async () => { + expect(Database).toBeTruthy(); + const session = await Session.create(testSessionOptions); + const hook = new EventHook(); + hook.hookEmitter(session, "start", "session-start"); + session.assign(["test1", "test2"], () => { + // Do nothing + }); + session.start(); + await hook.expect("session-start"); + }); + + test("Players receive pick events and can pick cards", async () => { + expect(Database).toBeTruthy(); + const session = await Session.create(testSessionOptions); + const hook = new EventHook(); + hook.hookEmitter(session, "start", "session-start"); + hook.hookEmitter(session, "next-pack", "session-new-pack"); + hook.hookEmitter(session, "next-pick", "session-new-pick"); + hook.hookEmitter(session, "player-pick", "session-picked"); + hook.hookEmitter(session, "draft-over", "session-done"); + session.assign(["test1", "test2"], (name, player) => { + player.on("available-picks", cards => { + // setTimeout hack to avoid handlers being called before the rest of the code + setTimeout(() => player.pick(cards[0].ID), 0); + }); + }); + session.start(); + await hook.expect("session-start"); + for (let i = 0; i < testSessionOptions.packs; i++) { + await hook.expect("session-new-pack"); + for (let j = 0; j < 12; j++) { + for (let p = 0; p < testSessionOptions.players; p++) { + await hook.expect("session-picked"); + } + if (i < testSessionOptions.packs - 1) { + await hook.expect("session-new-pick"); + } + } + } + await hook.expect("session-done"); + }); });