Make sessions work (and add tests)
This commit is contained in:
parent
d37c6a5cc5
commit
c8697d8893
5 changed files with 285 additions and 36 deletions
|
@ -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];
|
||||||
|
|
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 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue