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 { 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];
|
||||
|
|
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 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Card } from "@/mlpccg";
|
||||
import { PackBuilder } from "./booster";
|
||||
|
||||
export type Provider = Iterator<Card>;
|
||||
|
||||
|
@ -63,3 +64,10 @@ export interface SessionOptions {
|
|||
}
|
||||
|
||||
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 { 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");
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue