diff --git a/scripts/tournament/2023-10/round-1/setup.sh b/scripts/tournament/2023-10/round-1/setup.sh index 1a3991565..53495210b 100755 --- a/scripts/tournament/2023-10/round-1/setup.sh +++ b/scripts/tournament/2023-10/round-1/setup.sh @@ -5,10 +5,15 @@ CLI_CMD="${1:-cli}" # in prod change to cli:prod e.g., `./setup.sh cli:prod` ## create Fall 2023 Mars Madness tournament + Round 1 structure yarn ${CLI_CMD} tournament create --tournamentName="2023-11 Mars Madness" --description="The next Mars Madness tournament begins November 10, 2023! Top-scoring players on surviving teams will advance to the next round. Players who make it to the championship round will receive a tabletop game version of Port of Mars, and the winner of the championship will receive a top prize of \$1000 USD!" +# add variable life as usual card treatments to the tournament +# FIXME: change this to the specific treatments we actually want +yarn ${CLI_CMD} tournament treatment create -n "Less LAU" -d "50% less (6) Life as Usual cards" -o "[{\"eventId\": \"lifeAsUsual\", \"quantity\": 6}]" +yarn ${CLI_CMD} tournament treatment create -n "Normal LAU" -d "Default (12) Life as Usual cards" -o "[{\"eventId\": \"lifeAsUsual\", \"quantity\": 12}]" +yarn ${CLI_CMD} tournament treatment create -n "More LAU" -d "50% more (18) Life as Usual cards" -o "[{\"eventId\": \"lifeAsUsual\", \"quantity\": 18}]" yarn ${CLI_CMD} tournament round create --introSurveyUrl=https://asu.co1.qualtrics.com/jfe/form/SV_0c8tCMZkAUh4V8x --exitSurveyUrl=https://asu.co1.qualtrics.com/jfe/form/SV_6FNhPbsBuybTjEN --announcement="REGISTRATION FOR ROUND 1 IS NOW OPEN. Register, complete the Port of Mars Mission Control onboarding, and sign in during a scheduled launch time to compete in the next Mars Madness tournament!" # set up 3 launch dates per day from 2023-11-10 to 2023-11-16 for day in 10 11 12 13 14 15 16; do yarn ${CLI_CMD} tournament round date --date="2023-11-${day}T12:00:00-07:00"; yarn ${CLI_CMD} tournament round date --date="2023-11-${day}T15:00:00-07:00"; yarn ${CLI_CMD} tournament round date --date="2023-11-${day}T19:00:00-07:00"; -done \ No newline at end of file +done diff --git a/server/src/cli.ts b/server/src/cli.ts index 43a773364..987ea0724 100644 --- a/server/src/cli.ts +++ b/server/src/cli.ts @@ -11,7 +11,7 @@ import { } from "@port-of-mars/server/services/replay"; import { DBPersister } from "@port-of-mars/server/services/persistence"; import { EnteredDefeatPhase, EnteredVictoryPhase } from "@port-of-mars/server/rooms/game/events"; -import { Phase } from "@port-of-mars/shared/types"; +import { MarsEventOverride, Phase } from "@port-of-mars/shared/types"; import { getLogger } from "@port-of-mars/server/settings"; import { Game, @@ -345,6 +345,29 @@ async function exportTournamentRoundEmails( } } +async function createTournamentTreatment( + em: EntityManager, + name: string, + description: string, + overridesRaw: string, + tournamentId?: number +): Promise { + const sp = getServices(em); + let overrides: MarsEventOverride[]; + try { + overrides = JSON.parse(overridesRaw); + const treatment = await sp.tournament.createTreatment( + name, + description, + overrides, + tournamentId + ); + logger.debug("created tournament treatment: %o", treatment); + } catch (e) { + logger.fatal("Unable to create treatment: %s", e); + } +} + async function createTournamentRoundDate( em: EntityManager, date: Date, @@ -382,6 +405,34 @@ program program .createCommand("tournament") .description("tournamament subcommands") + .addCommand( + program + .createCommand("treatment") + .description("treatment subcommands") + .addCommand( + program + .createCommand("create") + .requiredOption("-n --name ", "Treatment name") + .requiredOption("-d --description ", "Treatment description") + .requiredOption( + "-o --overrides ", + "Mars event overrides (JSON string, e.g., '[ {'eventId': 'lifeAsUsual', 'quantity': 12} ])" + ) + .option("--tournamentId ", "ID of the tournament", customParseInt) + .description("add a Treatment (set of mars event overrides) to a Tournament") + .action(async cmd => { + await withConnection(em => + createTournamentTreatment( + em, + cmd.name, + cmd.description, + cmd.overrides, + cmd.tournamentId + ) + ); + }) + ) + ) .addCommand( program .createCommand("round") diff --git a/server/src/data/MarsEvents.ts b/server/src/data/MarsEvents.ts index da9656ec7..b37a723a0 100644 --- a/server/src/data/MarsEvents.ts +++ b/server/src/data/MarsEvents.ts @@ -219,7 +219,7 @@ const _marsEvents: Array = [ }, ]; -export function getMarsEventDeckItems(): Array { +export function getDefaultMarsEventDeck(): Array { const AVAILABLE_EVENTS: Array<[string, number]> = [ ["audit", 1], ["bondingThroughAdversity", 1], diff --git a/server/src/entity/Game.ts b/server/src/entity/Game.ts index 65acfd19f..3f42985c1 100644 --- a/server/src/entity/Game.ts +++ b/server/src/entity/Game.ts @@ -55,5 +55,8 @@ export class Game { winner?: Player; @ManyToOne(type => Treatment, treatment => treatment.games) - treatment!: Treatment; + treatment?: Treatment; + + @Column({ nullable: true }) + treatmentId?: number; } diff --git a/server/src/entity/Treatment.ts b/server/src/entity/Treatment.ts index 8760976f7..134837e7c 100644 --- a/server/src/entity/Treatment.ts +++ b/server/src/entity/Treatment.ts @@ -1,3 +1,4 @@ +import { MarsEventOverride } from "@port-of-mars/shared/types"; import { OneToMany, Column, @@ -27,10 +28,7 @@ export class Treatment { tournaments!: Array; @Column("jsonb", { nullable: true }) - marsEventOverrides!: { - eventId: string; - quantity: number; - }[]; + marsEventOverrides!: MarsEventOverride[]; @OneToMany(type => Game, game => game.treatment, { nullable: true }) games!: Array; diff --git a/server/src/entity/index.ts b/server/src/entity/index.ts index 8d54c175c..b0e5fbba4 100644 --- a/server/src/entity/index.ts +++ b/server/src/entity/index.ts @@ -8,6 +8,7 @@ export * from "./QuestionResponse"; export * from "./Quiz"; export * from "./QuizSubmission"; export * from "./Tournament"; +export * from "./Treatment"; export * from "./TournamentRound"; export * from "./TournamentRoundInvite"; export * from "./User"; diff --git a/server/src/rooms/game/state/marsevents/common.ts b/server/src/rooms/game/state/marsevents/common.ts index b8a9187f2..227fafdd7 100644 --- a/server/src/rooms/game/state/marsevents/common.ts +++ b/server/src/rooms/game/state/marsevents/common.ts @@ -1,7 +1,7 @@ -import { MarsEventData } from "@port-of-mars/shared/types"; +import { MarsEventData, MarsEventOverride } from "@port-of-mars/shared/types"; import _ from "lodash"; import { GameState } from "@port-of-mars/server/rooms/game/state"; -import { MarsEventDeckItem, getMarsEventDeckItems } from "@port-of-mars/server/data/MarsEvents"; +import { MarsEventDeckItem, getDefaultMarsEventDeck } from "@port-of-mars/server/data/MarsEvents"; export interface MarsEventStateConstructor { new (data?: any): MarsEventState; @@ -20,12 +20,28 @@ export function expandCopies(marsEventsCollection: Array): Ar ); } -export function getFixedMarsEventDeck(): Array { - return _.clone(expandCopies(getMarsEventDeckItems())); +/** + * Get a fixed (unshuffled) mars event deck with the given overrides + */ +export function getFixedMarsEventDeck( + eventOverrides?: MarsEventOverride[] | null +): Array { + const deck = getDefaultMarsEventDeck(); + if (eventOverrides) { + for (const override of eventOverrides) { + const index = deck.findIndex(item => item.event.id === override.eventId); + if (index !== -1) { + deck[index].numberOfCopies = override.quantity; + } + } + } + return _.clone(expandCopies(deck)); } -export function getRandomizedMarsEventDeck(): Array { - return _.shuffle(getFixedMarsEventDeck()); +export function getRandomizedMarsEventDeck( + eventOverrides: MarsEventOverride[] | null +): Array { + return _.shuffle(getFixedMarsEventDeck(eventOverrides)); } // eslint-disable-next-line @typescript-eslint/ban-types diff --git a/server/src/rooms/game/types.ts b/server/src/rooms/game/types.ts index 9d7b4f11b..9ec77c939 100644 --- a/server/src/rooms/game/types.ts +++ b/server/src/rooms/game/types.ts @@ -25,6 +25,7 @@ export interface Persister { export interface GameOpts extends GameStateOpts { tournamentRoundId: number; + treatmentId?: number; } export interface GameStateOpts { diff --git a/server/src/services/persistence.ts b/server/src/services/persistence.ts index 30e9d50fe..cca2efc20 100644 --- a/server/src/services/persistence.ts +++ b/server/src/services/persistence.ts @@ -98,8 +98,11 @@ export class DBPersister implements Persister { game.tournamentRoundId = options.tournamentRoundId; game.roomId = roomId; game.type = options.type; - + if (options.treatmentId) { + game.treatmentId = options.treatmentId; + } await em.save(game); + if (shouldCreatePlayers) { const rawUsers = await this.selectUsersByUsername(em, Object.keys(options.userRoles)); await this.createPlayers(em, game.id, options.userRoles, rawUsers); diff --git a/server/src/services/tournament.ts b/server/src/services/tournament.ts index c738080a9..2e3b0f080 100644 --- a/server/src/services/tournament.ts +++ b/server/src/services/tournament.ts @@ -4,6 +4,8 @@ import { Tournament, TournamentRound, TournamentRoundInvite, + Treatment, + Game, } from "@port-of-mars/server/entity"; import { MoreThan, Not, SelectQueryBuilder } from "typeorm"; import { getServices } from "@port-of-mars/server/services"; @@ -12,6 +14,7 @@ import { BaseService } from "@port-of-mars/server/services/db"; import { TournamentRoundDate } from "@port-of-mars/server/entity/TournamentRoundDate"; import { GameType, + MarsEventOverride, TournamentRoundInviteStatus, TournamentStatus, } from "@port-of-mars/shared/types"; @@ -244,6 +247,86 @@ export class TournamentService extends BaseService { }; } + /** + * Create a treatment and add it to the given or current tournament + */ + async createTreatment( + name: string, + description: string, + overrides: MarsEventOverride[], + tournamentId?: number + ) { + let treatment = new Treatment(); + treatment.name = name; + treatment.description = description; + treatment.marsEventOverrides = overrides; + treatment = await this.em.getRepository(Treatment).save(treatment); + + let tournament; + if (!tournamentId) { + tournament = await this.getActiveTournament(); + } else { + tournament = await this.em.getRepository(Tournament).findOne({ + where: { id: tournamentId }, + relations: ["treatments"], + }); + if (!tournament) { + throw new Error(`Tournament with ID ${tournamentId} not found`); + } + } + if (!tournament.treatments) { + tournament.treatments = []; + } + tournament.treatments.push(treatment); + await this.em.getRepository(Tournament).save(tournament); + + return treatment; + } + + /** + * Retrieve the set of treatments for a given tournament + */ + async getTreatments(tournamentId: number): Promise { + const tournament = await this.em + .getRepository(Tournament) + .createQueryBuilder("tournament") + .leftJoinAndSelect("tournament.treatments", "treatment") + .where("tournament.id = :tournamentId", { tournamentId }) + .getOne(); + + return tournament ? tournament.treatments : []; + } + + /** + * Retrieve the next treatment to use for a given tournament + * Treatments are cycled through in order + */ + async getNextTreatment(tournamentId: number): Promise { + const treatments = await this.getTreatments(tournamentId); + if (treatments.length === 0) { + return null; + } + // get the last game played in the tournament + const lastGame = await this.em + .getRepository(Game) + .createQueryBuilder("game") + .select("game.treatmentId") + .innerJoin("game.tournamentRound", "tournamentRound") + .where("tournamentRound.tournamentId = :tournamentId", { tournamentId }) + .orderBy("game.id", "DESC") + .getOne(); + + // determine the next treatment by cycling through the list + let nextTreatment; + if (lastGame && lastGame.treatmentId) { + const lastTreatmentIndex = treatments.findIndex(t => t.id === lastGame.treatmentId); + nextTreatment = treatments[(lastTreatmentIndex + 1) % treatments.length]; + } else { + [nextTreatment] = treatments; // start with the first if no games played yet + } + return nextTreatment; + } + async createRound( data: Pick< TournamentRound, diff --git a/server/src/util.ts b/server/src/util.ts index 8fca81589..255ab4b6e 100644 --- a/server/src/util.ts +++ b/server/src/util.ts @@ -110,12 +110,15 @@ export async function buildGameOpts(usernames: Array, type: GameType): P playerData.forEach((p, i) => { playerOpts.set(shuffledRoles[i], p); }); + const treatment = await services.tournament.getNextTreatment(currentTournamentRound.tournamentId); + const eventOverrides = treatment?.marsEventOverrides ?? null; return { userRoles: _.zipObject(usernames, shuffledRoles), playerOpts, - deck: getRandomizedMarsEventDeck(), + deck: getRandomizedMarsEventDeck(eventOverrides), numberOfGameRounds: currentTournamentRound.numberOfGameRounds, tournamentRoundId: currentTournamentRound.id, + treatmentId: treatment?.id, type, }; } diff --git a/shared/src/types.ts b/shared/src/types.ts index fd286338c..440b95451 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -231,6 +231,11 @@ export interface MarsEventData { timeDuration?: number; } +export interface MarsEventOverride { + eventId: string; + quantity: number; +} + export enum MarsLogCategory { audit = "AUDIT", newRound = "NEW ROUND",