Skip to content

Commit

Permalink
feat: solo game exporting
Browse files Browse the repository at this point in the history
usage: `yarn cli dumpsolo [--start <YYYY-MM-DD>] [--end <YYYY-MM-DD>]`

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
  • Loading branch information
sgfost committed Dec 6, 2023
1 parent 8e67c58 commit 43d1f76
Show file tree
Hide file tree
Showing 6 changed files with 297 additions and 0 deletions.
31 changes: 31 additions & 0 deletions server/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,27 @@ async function withConnection<T>(f: (em: EntityManager) => Promise<T>): 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,
Expand Down Expand Up @@ -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 <date>", "Start date (YYYY-MM-DD)")
.option("-e, --end <date>", "End date (YYYY-MM-DD)")
.action(async cmd => {
await withConnection(em => exportSoloData(em, cmd.start, cmd.end));
})
)
.addCommand(
program
.createCommand("checkquiz")
Expand Down
7 changes: 7 additions & 0 deletions server/src/entity/SoloGameRound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
104 changes: 104 additions & 0 deletions server/src/migration/1701805620516-ComputeSoloRoundInitialValues.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
await queryRunner.query(`ALTER TABLE "solo_game_round" DROP COLUMN "initialPoints"`);
await queryRunner.query(`ALTER TABLE "solo_game_round" DROP COLUMN "initialSystemHealth"`);
}
}
2 changes: 2 additions & 0 deletions server/src/rooms/sologame/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()];
Expand Down Expand Up @@ -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" });
Expand Down
7 changes: 7 additions & 0 deletions server/src/rooms/sologame/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions server/src/services/sologame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventCardData[]> {
Expand Down Expand Up @@ -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);
Expand All @@ -199,4 +205,144 @@ export class SoloGameService extends BaseService {
}
return round;
}

async getGameIdsBetween(start: Date, end: Date): Promise<Array<number>> {
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<number>) {
/**
* 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<number>) {
/**
* 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<number>) {
/**
* 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}`);
}
}
}

0 comments on commit 43d1f76

Please sign in to comment.