From 43d1f76d92e4436517618c92910dc9db8cae228a Mon Sep 17 00:00:00 2001 From: sgfost Date: Tue, 5 Dec 2023 18:34:05 -0700 Subject: [PATCH] feat: solo game exporting usage: `yarn cli dumpsolo [--start ] [--end ]` now tracking initialSystemHealth and initialPoints values per solo game round. instead of relying on being able to accurately infer this in the future, a (large) migration computes these values for all existing rounds and future games will simply keep track of them at runtime * migration also does some further cleanup of existing rounds by deleting all extra duplicate records in order for the initial values inference to take place resolves virtualcommons/planning#60 --- server/src/cli.ts | 31 ++++ server/src/entity/SoloGameRound.ts | 7 + ...805620516-ComputeSoloRoundInitialValues.ts | 104 +++++++++++++ server/src/rooms/sologame/commands.ts | 2 + server/src/rooms/sologame/state.ts | 7 + server/src/services/sologame.ts | 146 ++++++++++++++++++ 6 files changed, 297 insertions(+) create mode 100644 server/src/migration/1701805620516-ComputeSoloRoundInitialValues.ts diff --git a/server/src/cli.ts b/server/src/cli.ts index 9d01ce2f2..036c0fc03 100644 --- a/server/src/cli.ts +++ b/server/src/cli.ts @@ -53,6 +53,27 @@ async function withConnection(f: (em: EntityManager) => Promise): Promise< } } +async function exportSoloData(em: EntityManager, start?: string, end?: string) { + const soloGameService = getServices().sologame; + await mkdir("/dump/solo", { recursive: true }); + let gameIds; + if (start && end) { + const startDate = new Date(start); + const endDate = new Date(end); + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + logger.fatal("Invalid date format"); + return; + } + gameIds = await soloGameService.getGameIdsBetween(startDate, endDate); + } else if (start || end) { + logger.fatal("Must specify both start and end dates or neither"); + return; + } + await getServices().sologame.exportGamesCsv("/dump/solo/games.csv", gameIds); + await getServices().sologame.exportEventCardsCsv("/dump/solo/eventcards.csv", gameIds); + await getServices().sologame.exportInvestmentsCsv("/dump/solo/investments.csv", gameIds); +} + async function exportData( em: EntityManager, tournamentRoundId: number, @@ -636,6 +657,16 @@ program await withConnection(em => exportData(em, cmd.tournamentRoundId, cmd.gids)); }) ) + .addCommand( + program + .createCommand("dumpsolo") + .description("export solo game data to flat CSV files") + .option("-s, --start ", "Start date (YYYY-MM-DD)") + .option("-e, --end ", "End date (YYYY-MM-DD)") + .action(async cmd => { + await withConnection(em => exportSoloData(em, cmd.start, cmd.end)); + }) + ) .addCommand( program .createCommand("checkquiz") diff --git a/server/src/entity/SoloGameRound.ts b/server/src/entity/SoloGameRound.ts index 9bd2de52a..4f80f403a 100644 --- a/server/src/entity/SoloGameRound.ts +++ b/server/src/entity/SoloGameRound.ts @@ -33,6 +33,13 @@ export class SoloGameRound { @OneToMany(type => SoloMarsEventDeckCard, card => card.round) cards!: SoloMarsEventDeckCard[]; + // these are the initial values AFTER wear and tear + @Column() + initialSystemHealth!: number; + + @Column() + initialPoints!: number; + @OneToOne(type => SoloPlayerDecision, { nullable: false }) @JoinColumn() decision!: SoloPlayerDecision; diff --git a/server/src/migration/1701805620516-ComputeSoloRoundInitialValues.ts b/server/src/migration/1701805620516-ComputeSoloRoundInitialValues.ts new file mode 100644 index 000000000..7e868c46f --- /dev/null +++ b/server/src/migration/1701805620516-ComputeSoloRoundInitialValues.ts @@ -0,0 +1,104 @@ +import { MigrationInterface, QueryRunner, TableColumn } from "typeorm"; +import { SoloGame, SoloGameRound } from "@port-of-mars/server/entity"; +import { SoloGameState } from "@port-of-mars/server/rooms/sologame/state"; + +const WEAR_AND_TEAR = SoloGameState.DEFAULTS.systemHealthWear; +const MAX_SYSTEM_HEALTH = SoloGameState.DEFAULTS.systemHealthMax; +const STARTING_POINTS = SoloGameState.DEFAULTS.points; + +export class ComputeSoloRoundInitialValues1701805620516 implements MigrationInterface { + name = "ComputeSoloRoundInitialValues1701805620516"; + + public async up(queryRunner: QueryRunner): Promise { + // add (temporarily nullable) columns to SoloGameRound + await queryRunner.addColumns("solo_game_round", [ + new TableColumn({ + name: "initialSystemHealth", + type: "int", + isNullable: true, + }), + new TableColumn({ + name: "initialPoints", + type: "int", + isNullable: true, + }), + ]); + + await this.deleteSuperfluousRounds(queryRunner); + await this.computeMissingInitialValues(queryRunner); + + // remove nullable constraint from columns + await queryRunner.changeColumn( + "solo_game_round", + "initialSystemHealth", + new TableColumn({ + name: "initialSystemHealth", + type: "int", + isNullable: false, + }) + ); + await queryRunner.changeColumn( + "solo_game_round", + "initialPoints", + new TableColumn({ + name: "initialPoints", + type: "int", + isNullable: false, + }) + ); + } + + private async computeMissingInitialValues(queryRunner: QueryRunner): Promise { + // fetch all existing games with rounds, cards, decisions + const games = await queryRunner.manager.find(SoloGame, { + relations: ["rounds", "rounds.cards", "rounds.decision"], + }); + for (const game of games) { + let initialSystemHealth = MAX_SYSTEM_HEALTH; + let initialPoints = STARTING_POINTS; + game.rounds.sort((a, b) => a.roundNumber - b.roundNumber); + for (const round of game.rounds) { + // simulate a round + // apply wear/tear -> save initial values -> apply cards -> apply decision -> repeat + initialSystemHealth = Math.max(0, initialSystemHealth - WEAR_AND_TEAR); + await queryRunner.manager.update(SoloGameRound, round.id, { + initialSystemHealth, + initialPoints, + }); + for (const card of round.cards) { + initialSystemHealth = Math.min( + MAX_SYSTEM_HEALTH, + initialSystemHealth + card.systemHealthEffect + ); + initialPoints = Math.max(0, initialPoints + card.pointsEffect); + } + if (round.decision) { + initialSystemHealth = Math.min( + MAX_SYSTEM_HEALTH, + initialSystemHealth + round.decision.systemHealthInvestment + ); + initialPoints = Math.max(0, initialPoints + round.decision.pointsInvestment); + } + } + } + } + + private async deleteSuperfluousRounds(queryRunner: QueryRunner): Promise { + // remove duplicate round records that were created by a bug in the game logic + // we keep the last created round since this is the one that is referenced by the + // deck cards + await queryRunner.query(` + DELETE FROM "solo_game_round" + WHERE "id" NOT IN ( + SELECT MAX("id") + FROM "solo_game_round" + GROUP BY "gameId", "roundNumber" + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "solo_game_round" DROP COLUMN "initialPoints"`); + await queryRunner.query(`ALTER TABLE "solo_game_round" DROP COLUMN "initialSystemHealth"`); + } +} diff --git a/server/src/rooms/sologame/commands.ts b/server/src/rooms/sologame/commands.ts index 24e721389..657360dca 100644 --- a/server/src/rooms/sologame/commands.ts +++ b/server/src/rooms/sologame/commands.ts @@ -88,6 +88,7 @@ export class SetFirstRoundCmd extends CmdWithoutPayload { this.state.systemHealth = defaults.systemHealthMax - defaults.systemHealthWear; this.state.timeRemaining = defaults.timeRemaining; this.state.player.resources = defaults.resources; + this.state.updateRoundInitialValues(); this.state.isRoundTransitioning = false; return [new SendHiddenParamsCmd()]; @@ -278,6 +279,7 @@ export class SetNextRoundCmd extends CmdWithoutPayload { this.state.round += 1; this.state.systemHealth = Math.max(0, this.state.systemHealth - defaults.systemHealthWear); + this.state.updateRoundInitialValues(); if (this.state.systemHealth <= 0) { return new EndGameCmd().setPayload({ status: "defeat" }); diff --git a/server/src/rooms/sologame/state.ts b/server/src/rooms/sologame/state.ts index 5cd03e300..700b12f5a 100644 --- a/server/src/rooms/sologame/state.ts +++ b/server/src/rooms/sologame/state.ts @@ -72,6 +72,8 @@ export class SoloGameState extends Schema { @type("boolean") isRoundTransitioning = false; gameId = 0; + roundInitialSystemHealth = SoloGameState.DEFAULTS.systemHealthMax; + roundInitialPoints = 0; // hidden properties maxRound = SoloGameState.DEFAULTS.maxRound.max; twoEventsThreshold = SoloGameState.DEFAULTS.twoEventsThreshold.max; @@ -106,6 +108,11 @@ export class SoloGameState extends Schema { return this.roundEventCards.find(card => !card.expired); } + updateRoundInitialValues() { + this.roundInitialSystemHealth = this.systemHealth; + this.roundInitialPoints = this.points; + } + updateVisibleCards() { // update visible cards, needs to be called after making changes to the deck that should be // reflected on the client diff --git a/server/src/services/sologame.ts b/server/src/services/sologame.ts index 0b7461313..991743f42 100644 --- a/server/src/services/sologame.ts +++ b/server/src/services/sologame.ts @@ -13,6 +13,10 @@ import { } from "@port-of-mars/server/entity"; import { getRandomIntInclusive } from "@port-of-mars/server/util"; import { SoloGameState } from "@port-of-mars/server/rooms/sologame/state"; +import { createObjectCsvWriter } from "csv-writer"; +import { getLogger } from "@port-of-mars/server/settings"; + +const logger = getLogger(__filename); export class SoloGameService extends BaseService { async drawEventCardDeck(): Promise { @@ -184,6 +188,8 @@ export class SoloGameService extends BaseService { const round = roundRepo.create({ gameId: state.gameId, roundNumber: state.round, + initialSystemHealth: state.roundInitialSystemHealth, + initialPoints: state.roundInitialPoints, decision, }); await roundRepo.save(round); @@ -199,4 +205,144 @@ export class SoloGameService extends BaseService { } return round; } + + async getGameIdsBetween(start: Date, end: Date): Promise> { + const query = this.em + .getRepository(SoloGame) + .createQueryBuilder("game") + .select("game.id") + .where("game.dateCreated BETWEEN :start AND :end", { + start: start.toISOString().split("T")[0], + end: end.toISOString().split("T")[0], + }); + const result = await query.getRawMany(); + return result.map(row => row.game_id); + } + + async exportGamesCsv(path: string, gameIds?: Array) { + /** + * export a flat csv of past games specified by gameIds, + * or all games if gameIds is undefined + * gameId, userId. username, status, points, dateCreated, ...treatment + */ + let query = this.em + .getRepository(SoloGame) + .createQueryBuilder("game") + .leftJoinAndSelect("game.player", "player") + .leftJoinAndSelect("player.user", "user") + .leftJoinAndSelect("game.treatment", "treatment"); + if (gameIds) { + query = query.where("game.id IN (:...gameIds)", { gameIds }); + } + try { + const games = await query.getMany(); + const formattedGames = games.map(game => ({ + gameId: game.id, + userId: game.player.user.id, + username: game.player.user.username, + status: game.status, + points: game.player.points, + knownEventDeck: game.treatment.isEventDeckKnown, + knownEndRound: game.treatment.isNumberOfRoundsKnown, + thresholdInformation: game.treatment.thresholdInformation, + dateCreated: game.dateCreated.toISOString(), + })); + const header = Object.keys(formattedGames[0]).map(name => ({ + id: name, + title: name, + })); + const writer = createObjectCsvWriter({ path, header }); + await writer.writeRecords(formattedGames); + logger.info(`Solo game data exported successfully to ${path}`); + } catch (error) { + logger.fatal(`Error exporting solo game data: ${error}`); + } + } + + async exportEventCardsCsv(path: string, gameIds?: Array) { + /** + * export a flat csv of all event cards drawn in past games specified by gameIds + * or all games if gameIds is undefined + * + * gameId, cardId, roundId, roundNumber, name, effectText, systemHealthEffect, + * resourcesEffect, pointsEffect + */ + let query = this.em + .getRepository(SoloMarsEventDeckCard) + .createQueryBuilder("deckCard") + .leftJoinAndSelect("deckCard.round", "round") + .leftJoinAndSelect("round.game", "game") + .leftJoinAndSelect("deckCard.card", "eventCard") + .where("round.gameId IS NOT NULL"); + if (gameIds && gameIds.length > 0) { + query = query.andWhere("game.id IN (:...gameIds)", { gameIds }); + } + try { + const deckCards = await query.getMany(); + const formattedDeckCards = deckCards.map(deckCard => ({ + gameId: deckCard.round.gameId, + cardId: deckCard.id, + roundId: deckCard.round.id, + roundNumber: deckCard.round.roundNumber, + name: deckCard.card.displayName, + effectText: deckCard.effectText, + systemHealthEffect: deckCard.systemHealthEffect, + resourcesEffect: deckCard.resourcesEffect, + pointsEffect: deckCard.pointsEffect, + })); + const header = Object.keys(formattedDeckCards[0]).map(name => ({ + id: name, + title: name, + })); + const writer = createObjectCsvWriter({ path, header }); + await writer.writeRecords(formattedDeckCards); + logger.info(`Event cards data exported successfully to ${path}`); + } catch (error) { + logger.fatal(`Error exporting event cards data: ${error}`); + } + } + + async exportInvestmentsCsv(path: string, gameIds?: Array) { + /** + * export a flat csv of all player-made investments in past games specified by gameIds + * or all games if gameIds is undefined + * + * gameId, roundId, roundNumber, initialSystemHealth, initialPoints, + * systemHealthInvestment, pointsInvestment, dateCreated + * + * initial values are the values at the start of each round AFTER wear + * and tear has been applied + */ + let query = this.em + .getRepository(SoloGameRound) + .createQueryBuilder("round") + .leftJoinAndSelect("round.game", "game") + .leftJoinAndSelect("round.decision", "decision") + .where("round.gameId IS NOT NULL"); + if (gameIds && gameIds.length > 0) { + query = query.andWhere("game.id IN (:...gameIds)", { gameIds }); + } + try { + const rounds = await query.getMany(); + const formattedRounds = rounds.map(round => ({ + gameId: round.gameId, + roundId: round.id, + roundNumber: round.roundNumber, + initialSystemHealth: round.initialSystemHealth, + initialPoints: round.initialPoints, + systemHealthInvestment: round.decision.systemHealthInvestment, + pointsInvestment: round.decision.pointsInvestment, + dateCreated: round.dateCreated.toISOString(), + })); + const header = Object.keys(formattedRounds[0]).map(name => ({ + id: name, + title: name, + })); + const writer = createObjectCsvWriter({ path, header }); + await writer.writeRecords(formattedRounds); + logger.info(`Investment data exported successfully to ${path}`); + } catch (error) { + logger.fatal(`Error exporting investment data: ${error}`); + } + } }