From 422c8f55c899edb206fbdc9c379a2cc1f9b34184 Mon Sep 17 00:00:00 2001 From: sgfost <46429375+sgfost@users.noreply.github.com> Date: Wed, 21 Jun 2023 18:50:35 -0700 Subject: [PATCH] feat: add solo game persistence, db interaction - add fixtures for game treatments and event cards - add service methods for persisting game data and drawing cards/picking treatments - reworked solo game entities - (re-)add migration for solo game data model ref #856 --- .../fixtures/sologame/SoloGameTreatment.yml | 74 +++++ .../fixtures/sologame/SoloMarsEventCard.yml | 119 ++++++++ server/package.json | 4 +- server/src/entity/SoloGame.ts | 21 +- server/src/entity/SoloGameRound.ts | 12 +- server/src/entity/SoloGameTreatment.ts | 10 +- server/src/entity/SoloMarsEventCard.ts | 21 +- server/src/entity/SoloMarsEventDeck.ts | 5 +- server/src/entity/SoloMarsEventDeckCard.ts | 16 +- server/src/entity/SoloPlayer.ts | 8 +- server/src/entity/SoloPlayerDecision.ts | 8 +- server/src/entity/User.ts | 4 + ...me.ts => 1687396740108-AddSoloGameData.ts} | 26 +- server/src/rooms/sologame/commands.ts | 60 ++-- server/src/rooms/sologame/index.ts | 7 +- server/src/rooms/sologame/state.ts | 39 ++- server/src/services/sologame.ts | 256 +++++++++++++----- server/src/util.ts | 1 + shared/src/sologame/types.ts | 16 +- 19 files changed, 551 insertions(+), 156 deletions(-) create mode 100644 server/fixtures/sologame/SoloGameTreatment.yml create mode 100644 server/fixtures/sologame/SoloMarsEventCard.yml rename server/src/migration/{1686336258677-addSoloGame.ts => 1687396740108-AddSoloGameData.ts} (59%) diff --git a/server/fixtures/sologame/SoloGameTreatment.yml b/server/fixtures/sologame/SoloGameTreatment.yml new file mode 100644 index 000000000..85506432e --- /dev/null +++ b/server/fixtures/sologame/SoloGameTreatment.yml @@ -0,0 +1,74 @@ +entity: SoloGameTreatment +items: + treatment1: + id: 1 + name: treatment1 + isKnownNumberOfRounds: false + isEventDeckKnown: false + thresholdInformation: unknown + treatment2: + id: 2 + name: treatment2 + isKnownNumberOfRounds: false + isEventDeckKnown: false + thresholdInformation: range + treatment3: + id: 3 + name: treatment3 + isKnownNumberOfRounds: false + isEventDeckKnown: false + thresholdInformation: known + treatment4: + id: 4 + name: treatment4 + isKnownNumberOfRounds: false + isEventDeckKnown: true + thresholdInformation: unknown + treatment5: + id: 5 + name: treatment5 + isKnownNumberOfRounds: false + isEventDeckKnown: true + thresholdInformation: range + treatment6: + id: 6 + name: treatment6 + isKnownNumberOfRounds: false + isEventDeckKnown: true + thresholdInformation: known + treatment7: + id: 7 + name: treatment7 + isKnownNumberOfRounds: true + isEventDeckKnown: false + thresholdInformation: unknown + treatment8: + id: 8 + name: treatment8 + isKnownNumberOfRounds: true + isEventDeckKnown: false + thresholdInformation: range + treatment9: + id: 9 + name: treatment9 + isKnownNumberOfRounds: true + isEventDeckKnown: false + thresholdInformation: known + treatment10: + id: 10 + name: treatment10 + isKnownNumberOfRounds: true + isEventDeckKnown: true + thresholdInformation: unknown + treatment11: + id: 11 + name: treatment11 + isKnownNumberOfRounds: true + isEventDeckKnown: true + thresholdInformation: range + treatment12: + id: 12 + name: treatment12 + isKnownNumberOfRounds: true + isEventDeckKnown: true + thresholdInformation: known diff --git a/server/fixtures/sologame/SoloMarsEventCard.yml b/server/fixtures/sologame/SoloMarsEventCard.yml new file mode 100644 index 000000000..79ffe7e82 --- /dev/null +++ b/server/fixtures/sologame/SoloMarsEventCard.yml @@ -0,0 +1,119 @@ +entity: SoloMarsEventCard +items: + event1: + id: 1 + codeName: "lifeAsUsual" + displayName: "Life As Usual" + flavorText: "As the first human outpost on Mars, having a \"usual\" day is pretty unusual." + effect: "No special effect." + drawMin: 10 + drawMax: 20 + rollMin: 0 + rollMax: 0 + systemHealthMultiplier: 0 + pointsMultiplier: 0 + resourcesMultiplier: 0 + event2: # needs special casing (draw 2 more cards) + id: 2 + codeName: "murphysLaw" + displayName: "Murphy's Law" + flavorText: "Residents at Port of Mars know better than to ask, \"what ELSE could go wrong?\"" + effect: "Reveal 2 more events for this round." + drawMin: 1 + drawMax: 1 + rollMin: 0 + rollMax: 0 + systemHealthMultiplier: 0 + pointsMultiplier: 0 + resourcesMultiplier: 0 + event3: + id: 3 + codeName: "lostTime" + displayName: "Lost Time" + flavorText: "Time flies when you're trying to stay alive." + effect: "Lose {roll} resource{s} for this round." + drawMin: 1 + drawMax: 1 + rollMin: 1 + rollMax: 8 + systemHealthMultiplier: 0 + pointsMultiplier: 0 + resourcesMultiplier: -1 + event4: + id: 4 + codeName: "richDeposit" + displayName: "Rich Deposit" + flavorText: "A stroke of luck in an otherwise unlucky day." + effect: "Gain {roll} resource{s} for this round." + drawMin: 1 + drawMax: 1 + rollMin: 1 + rollMax: 8 + systemHealthMultiplier: 0 + pointsMultiplier: 0 + resourcesMultiplier: 1 + event5: + id: 5 + codeName: "urgentRepairs" + displayName: "Urgent Repairs" + flavorText: "No pneumatic tires on mars, but there are always holes to patch." + effect: "{roll} resource{s} are immediately diverted to system health." + drawMin: 1 + drawMax: 1 + rollMin: 2 + rollMax: 7 + systemHealthMultiplier: 1 + pointsMultiplier: 0 + resourcesMultiplier: -1 + event6: + id: 6 + codeName: "hullBreach" + displayName: "hullBreach" + flavorText: "Accidents happen. It's unavoidable. Our job is to do our best to avoid them all the same." + effect: "Lose {roll} system health." + drawMin: 4 + drawMax: 4 + rollMin: 1 + rollMax: 10 + systemHealthMultiplier: -1 + pointsMultiplier: 0 + resourcesMultiplier: 0 + event7: + id: 7 + codeName: "softwareUpgrade" + displayName: "Software Upgrade" + flavorText: "A much needed patch to the system comes online." + effect: "Gain {roll} system health." + drawMin: 4 + drawMax: 4 + rollMin: 1 + rollMax: 10 + systemHealthMultiplier: 1 + pointsMultiplier: 0 + resourcesMultiplier: 0 + event8: + id: 8 + codeName: "lostCargo" + displayName: "Lost Cargo" + flavorText: "Precious cargo, now forever Martian property." + effect: "Lose {roll} point{s}." + drawMin: 4 + drawMax: 4 + rollMin: 1 + rollMax: 10 + systemHealthMultiplier: 0 + pointsMultiplier: -1 + resourcesMultiplier: 0 + event9: + id: 9 + codeName: "hitTheMotherload" + displayName: "Hit the Motherload" + flavorText: "A valuable find. Fortunately for us, not that useful for repairs." + effect: "Gain {roll} point{s}." + drawMin: 4 + drawMax: 4 + rollMin: 1 + rollMax: 10 + systemHealthMultiplier: 0 + pointsMultiplier: 1 + resourcesMultiplier: 0 diff --git a/server/package.json b/server/package.json index cff72c963..b5f63d434 100644 --- a/server/package.json +++ b/server/package.json @@ -14,7 +14,7 @@ "start": "ts-node-dev -r tsconfig-paths/register src/index.ts | tee -a /var/log/port-of-mars/index.log", "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js", "dangerously-dropdb": "dropdb -U marsmadness -h db port_of_mars", - "initdb": "createdb -U marsmadness -h db port_of_mars ; yarn typeorm migration:run && /scripts/openbeta/setup.sh", + "initdb": "createdb -U marsmadness -h db port_of_mars ; yarn typeorm migration:run && /scripts/openbeta/setup.sh ; yarn load-fixtures ./fixtures/sologame", "dangerously-loaddb": "psql -h db -U marsmadness port_of_mars < pom-db.sql", "dangerously-resetdb": "dropdb -U marsmadness -h db port_of_mars && createdb -U marsmadness -h db port_of_mars", "migrate": "yarn typeorm migration:run", @@ -25,7 +25,7 @@ "lint:fix": "eslint --fix -c .eslintrc.js ./ ../shared --ext .ts", "style": "prettier --config ../.prettierrc --check './**/*.ts' '../shared/**/*.ts'", "style:fix": "prettier --config ../.prettierrc --write './**/*.ts' '../shared/**/*.ts'", - "load-fixtures": "fixtures ./fixtures --config ormconfig.json --require ts-node/register --require tsconfig-paths/register" + "load-fixtures": "fixtures --config ormconfig.json --require ts-node/register --require tsconfig-paths/register" }, "author": "Center for Behavior, Institutions, and the Environment (https://cbie.asu.edu)", "license": "MIT", diff --git a/server/src/entity/SoloGame.ts b/server/src/entity/SoloGame.ts index 7086c7209..dd5a6e2af 100644 --- a/server/src/entity/SoloGame.ts +++ b/server/src/entity/SoloGame.ts @@ -9,12 +9,24 @@ import { } from "typeorm"; import { SoloGameTreatment } from "@port-of-mars/server/entity/SoloGameTreatment"; import { SoloMarsEventDeck } from "./SoloMarsEventDeck"; +import { SoloPlayer } from "./SoloPlayer"; +import { SoloGameStatus } from "@port-of-mars/shared/sologame"; @Entity() export class SoloGame { @PrimaryGeneratedColumn() id!: number; + @CreateDateColumn() + dateCreated!: Date; + + @OneToOne(type => SoloPlayer, player => player.game) + @JoinColumn() + player!: SoloPlayer; + + @Column() + playerId!: number; + @ManyToOne(type => SoloGameTreatment, { nullable: false }) @JoinColumn() treatment!: SoloGameTreatment; @@ -22,13 +34,16 @@ export class SoloGame { @Column() treatmentId!: number; - @CreateDateColumn() - dateCreated!: Date; - @OneToOne(type => SoloMarsEventDeck, { nullable: false }) @JoinColumn() deck!: SoloMarsEventDeck; @Column() deckId!: number; + + @Column({ + type: "enum", + enum: ["incomplete", "victory", "defeat"], + }) + status!: SoloGameStatus; } diff --git a/server/src/entity/SoloGameRound.ts b/server/src/entity/SoloGameRound.ts index a79c6fb58..575b6ffe7 100644 --- a/server/src/entity/SoloGameRound.ts +++ b/server/src/entity/SoloGameRound.ts @@ -5,19 +5,27 @@ import { PrimaryGeneratedColumn, CreateDateColumn, JoinColumn, + OneToMany, } from "typeorm"; import { SoloPlayerDecision } from "@port-of-mars/server/entity/SoloPlayerDecision"; +import { SoloMarsEventDeckCard } from "./SoloMarsEventDeckCard"; @Entity() export class SoloGameRound { @PrimaryGeneratedColumn() id!: number; + @CreateDateColumn() + dateCreated!: Date; + @Column() gameId!: number; - @CreateDateColumn() - dateCreated!: Date; + @Column() + roundNumber!: number; + + @OneToMany(type => SoloMarsEventDeckCard, card => card.round) + cards!: SoloMarsEventDeckCard[]; @OneToOne(type => SoloPlayerDecision, { nullable: false }) @JoinColumn() diff --git a/server/src/entity/SoloGameTreatment.ts b/server/src/entity/SoloGameTreatment.ts index 39ebe6f66..098780c3e 100644 --- a/server/src/entity/SoloGameTreatment.ts +++ b/server/src/entity/SoloGameTreatment.ts @@ -1,19 +1,13 @@ import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; +import { ThresholdInformation } from "@port-of-mars/shared/sologame/types"; -export type ThresholdInformation = "unknown" | "range" | "known"; @Entity() export class SoloGameTreatment { @PrimaryGeneratedColumn() id!: number; @Column() - name!: string; - - @Column() - order!: number; - - @Column() - isKnownNumberofRounds!: boolean; + isKnownNumberOfRounds!: boolean; @Column() isEventDeckKnown!: boolean; diff --git a/server/src/entity/SoloMarsEventCard.ts b/server/src/entity/SoloMarsEventCard.ts index 4b30cc75f..efcd53bba 100644 --- a/server/src/entity/SoloMarsEventCard.ts +++ b/server/src/entity/SoloMarsEventCard.ts @@ -6,32 +6,35 @@ export class SoloMarsEventCard { id!: number; @Column() - description!: string; + codeName!: string; @Column() displayName!: string; @Column() - codeName!: string; + flavorText!: string; + + @Column() + effect!: string; @Column() - minHealth!: number; + drawMin!: number; @Column() - maxHealth!: number; + drawMax!: number; @Column() - minPoints!: number; + rollMin!: number; @Column() - maxPoints!: number; + rollMax!: number; @Column() - minBlocks!: number; + systemHealthMultiplier!: number; @Column() - maxBlocks!: number; + pointsMultiplier!: number; @Column() - draw2!: string; + resourcesMultiplier!: number; } diff --git a/server/src/entity/SoloMarsEventDeck.ts b/server/src/entity/SoloMarsEventDeck.ts index dce82caf7..e9ee2d80d 100644 --- a/server/src/entity/SoloMarsEventDeck.ts +++ b/server/src/entity/SoloMarsEventDeck.ts @@ -1,4 +1,4 @@ -import { Column, Entity, PrimaryGeneratedColumn, OneToMany, JoinColumn } from "typeorm"; +import { Entity, PrimaryGeneratedColumn, OneToMany, JoinColumn } from "typeorm"; import { SoloMarsEventDeckCard } from "@port-of-mars/server/entity/SoloMarsEventDeckCard"; @Entity() @@ -6,9 +6,6 @@ export class SoloMarsEventDeck { @PrimaryGeneratedColumn() id!: number; - @Column() - codeName!: string; - @OneToMany(type => SoloMarsEventDeckCard, card => card.deck, { nullable: true }) @JoinColumn() cards!: SoloMarsEventDeckCard[]; diff --git a/server/src/entity/SoloMarsEventDeckCard.ts b/server/src/entity/SoloMarsEventDeckCard.ts index a754f72eb..94c682388 100644 --- a/server/src/entity/SoloMarsEventDeckCard.ts +++ b/server/src/entity/SoloMarsEventDeckCard.ts @@ -23,9 +23,21 @@ export class SoloMarsEventDeckCard { @Column() cardId!: number; - @ManyToOne(type => SoloGameRound) - round!: SoloGameRound; + @Column() + effectText!: string; @Column() + systemHealthEffect!: number; + + @Column() + resourcesEffect!: number; + + @Column() + pointsEffect!: number; + + @ManyToOne(type => SoloGameRound, round => round.cards, { nullable: true }) + round!: SoloGameRound; + + @Column({ nullable: true }) roundId!: number; } diff --git a/server/src/entity/SoloPlayer.ts b/server/src/entity/SoloPlayer.ts index 33e71ce07..97da0fdd6 100644 --- a/server/src/entity/SoloPlayer.ts +++ b/server/src/entity/SoloPlayer.ts @@ -5,7 +5,6 @@ import { OneToOne, PrimaryGeneratedColumn, CreateDateColumn, - JoinColumn, } from "typeorm"; import { User } from "./User"; import { SoloGame } from "@port-of-mars/server/entity/SoloGame"; @@ -15,7 +14,7 @@ export class SoloPlayer { @PrimaryGeneratedColumn() id!: number; - @ManyToOne(type => User, user => user.players, { nullable: false }) + @ManyToOne(type => User, user => user.soloPlayers, { nullable: false }) user!: User; @Column() @@ -24,11 +23,10 @@ export class SoloPlayer { @Column({ default: "" }) playerIp!: string; - @OneToOne(type => SoloGame) - @JoinColumn() + @OneToOne(type => SoloGame, game => game.player, { nullable: true }) game!: SoloGame; - @Column() + @Column({ nullable: true }) gameId!: number; @Column("int", { nullable: true }) diff --git a/server/src/entity/SoloPlayerDecision.ts b/server/src/entity/SoloPlayerDecision.ts index 39ded436e..a64ddf6fb 100644 --- a/server/src/entity/SoloPlayerDecision.ts +++ b/server/src/entity/SoloPlayerDecision.ts @@ -1,7 +1,13 @@ -import { Entity, PrimaryGeneratedColumn } from "typeorm"; +import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"; @Entity() export class SoloPlayerDecision { @PrimaryGeneratedColumn() id!: number; + + @Column() + systemHealthInvestment!: number; + + @Column() + pointsInvestment!: number; } diff --git a/server/src/entity/User.ts b/server/src/entity/User.ts index 173ebee7c..bf230e303 100644 --- a/server/src/entity/User.ts +++ b/server/src/entity/User.ts @@ -7,6 +7,7 @@ import { PrimaryGeneratedColumn, } from "typeorm"; import { Player } from "./Player"; +import { SoloPlayer } from "./SoloPlayer"; import { TournamentRoundInvite } from "./TournamentRoundInvite"; @Entity() @@ -29,6 +30,9 @@ export class User { @OneToMany(type => TournamentRoundInvite, invite => invite.user) invites!: Array; + @OneToMany(type => SoloPlayer, soloPlayer => soloPlayer.user) + soloPlayers!: Array; + @Column({ default: false }) passedQuiz!: boolean; diff --git a/server/src/migration/1686336258677-addSoloGame.ts b/server/src/migration/1687396740108-AddSoloGameData.ts similarity index 59% rename from server/src/migration/1686336258677-addSoloGame.ts rename to server/src/migration/1687396740108-AddSoloGameData.ts index d8c4c6faf..509b0f116 100644 --- a/server/src/migration/1686336258677-addSoloGame.ts +++ b/server/src/migration/1687396740108-AddSoloGameData.ts @@ -1,39 +1,41 @@ import {MigrationInterface, QueryRunner} from "typeorm"; -export class addSoloGame1686336258677 implements MigrationInterface { - name = 'addSoloGame1686336258677' +export class AddSoloGameData1687396740108 implements MigrationInterface { + name = 'AddSoloGameData1687396740108' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(`CREATE TYPE "public"."solo_game_treatment_thresholdinformation_enum" AS ENUM('unknown', 'range', 'known')`); - await queryRunner.query(`CREATE TABLE "solo_game_treatment" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, "order" integer NOT NULL, "isKnownNumberofRounds" boolean NOT NULL, "isEventDeckKnown" boolean NOT NULL, "thresholdInformation" "public"."solo_game_treatment_thresholdinformation_enum" NOT NULL, CONSTRAINT "PK_61a365004e22a4d84b35711f4c0" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "solo_player_decision" ("id" SERIAL NOT NULL, CONSTRAINT "PK_a39283302b3ed8728f66b8108fa" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "solo_game_round" ("id" SERIAL NOT NULL, "gameId" integer NOT NULL, "dateCreated" TIMESTAMP NOT NULL DEFAULT now(), "decisionId" integer NOT NULL, CONSTRAINT "REL_a31e55e614589c1806a4b96f15" UNIQUE ("decisionId"), CONSTRAINT "PK_410930ce91d0fb7658ac8e5203d" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "solo_mars_event_card" ("id" SERIAL NOT NULL, "description" character varying NOT NULL, "displayName" character varying NOT NULL, "codeName" character varying NOT NULL, CONSTRAINT "PK_7947d532c97b8ca371fb460d01c" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "solo_mars_event_deck_card" ("id" SERIAL NOT NULL, "dateCreated" TIMESTAMP NOT NULL DEFAULT now(), "deckId" integer NOT NULL, "cardId" integer NOT NULL, "roundId" integer NOT NULL, CONSTRAINT "PK_39035f7fd267d19f4863843efeb" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "solo_mars_event_deck" ("id" SERIAL NOT NULL, "codeName" character varying NOT NULL, CONSTRAINT "PK_875bc3785d3916d7e5c5807d3a9" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "solo_game" ("id" SERIAL NOT NULL, "treatmentId" integer NOT NULL, "dateCreated" TIMESTAMP NOT NULL DEFAULT now(), "deckId" integer NOT NULL, CONSTRAINT "REL_39a6a51dc8dbc70626d59fe06d" UNIQUE ("deckId"), CONSTRAINT "PK_a941170fd23d55a87d4e49cca7f" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "solo_player" ("id" SERIAL NOT NULL, "userId" integer NOT NULL, "playerIp" character varying NOT NULL DEFAULT '', "gameId" integer NOT NULL, "points" integer, "dateCreated" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "REL_68410b02beb97d426ee11e523e" UNIQUE ("gameId"), CONSTRAINT "PK_6b0ee07ab2bf9b16ad83c4f921c" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "solo_game_treatment" ("id" SERIAL NOT NULL, "isKnownNumberOfRounds" boolean NOT NULL, "isEventDeckKnown" boolean NOT NULL, "thresholdInformation" "public"."solo_game_treatment_thresholdinformation_enum" NOT NULL, CONSTRAINT "PK_61a365004e22a4d84b35711f4c0" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "solo_player_decision" ("id" SERIAL NOT NULL, "systemHealthInvestment" integer NOT NULL, "pointsInvestment" integer NOT NULL, CONSTRAINT "PK_a39283302b3ed8728f66b8108fa" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "solo_game_round" ("id" SERIAL NOT NULL, "dateCreated" TIMESTAMP NOT NULL DEFAULT now(), "gameId" integer NOT NULL, "roundNumber" integer NOT NULL, "decisionId" integer NOT NULL, CONSTRAINT "REL_a31e55e614589c1806a4b96f15" UNIQUE ("decisionId"), CONSTRAINT "PK_410930ce91d0fb7658ac8e5203d" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "solo_mars_event_card" ("id" SERIAL NOT NULL, "codeName" character varying NOT NULL, "displayName" character varying NOT NULL, "flavorText" character varying NOT NULL, "effect" character varying NOT NULL, "drawMin" integer NOT NULL, "drawMax" integer NOT NULL, "rollMin" integer NOT NULL, "rollMax" integer NOT NULL, "systemHealthMultiplier" integer NOT NULL, "pointsMultiplier" integer NOT NULL, "resourcesMultiplier" integer NOT NULL, CONSTRAINT "PK_7947d532c97b8ca371fb460d01c" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "solo_mars_event_deck_card" ("id" SERIAL NOT NULL, "dateCreated" TIMESTAMP NOT NULL DEFAULT now(), "deckId" integer NOT NULL, "cardId" integer NOT NULL, "effectText" character varying NOT NULL, "systemHealthEffect" integer NOT NULL, "resourcesEffect" integer NOT NULL, "pointsEffect" integer NOT NULL, "roundId" integer, CONSTRAINT "PK_39035f7fd267d19f4863843efeb" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "solo_mars_event_deck" ("id" SERIAL NOT NULL, CONSTRAINT "PK_875bc3785d3916d7e5c5807d3a9" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TYPE "public"."solo_game_status_enum" AS ENUM('incomplete', 'victory', 'defeat')`); + await queryRunner.query(`CREATE TABLE "solo_game" ("id" SERIAL NOT NULL, "dateCreated" TIMESTAMP NOT NULL DEFAULT now(), "playerId" integer NOT NULL, "treatmentId" integer NOT NULL, "deckId" integer NOT NULL, "status" "public"."solo_game_status_enum" NOT NULL, CONSTRAINT "REL_ee276d60507980ddead8f08c80" UNIQUE ("playerId"), CONSTRAINT "REL_39a6a51dc8dbc70626d59fe06d" UNIQUE ("deckId"), CONSTRAINT "PK_a941170fd23d55a87d4e49cca7f" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "solo_player" ("id" SERIAL NOT NULL, "userId" integer NOT NULL, "playerIp" character varying NOT NULL DEFAULT '', "gameId" integer, "points" integer, "dateCreated" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_6b0ee07ab2bf9b16ad83c4f921c" PRIMARY KEY ("id"))`); await queryRunner.query(`ALTER TABLE "solo_game_round" ADD CONSTRAINT "FK_a31e55e614589c1806a4b96f158" FOREIGN KEY ("decisionId") REFERENCES "solo_player_decision"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "solo_mars_event_deck_card" ADD CONSTRAINT "FK_a48822e171d01382a35c0d087fb" FOREIGN KEY ("deckId") REFERENCES "solo_mars_event_deck"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "solo_mars_event_deck_card" ADD CONSTRAINT "FK_794e3e38b173d64eec1f2962996" FOREIGN KEY ("cardId") REFERENCES "solo_mars_event_card"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "solo_mars_event_deck_card" ADD CONSTRAINT "FK_732e7ef62231a3b1f8a692b9ca9" FOREIGN KEY ("roundId") REFERENCES "solo_game_round"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "solo_game" ADD CONSTRAINT "FK_ee276d60507980ddead8f08c80a" FOREIGN KEY ("playerId") REFERENCES "solo_player"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "solo_game" ADD CONSTRAINT "FK_7ba902b4f916952de522fbf7d0e" FOREIGN KEY ("treatmentId") REFERENCES "solo_game_treatment"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "solo_game" ADD CONSTRAINT "FK_39a6a51dc8dbc70626d59fe06db" FOREIGN KEY ("deckId") REFERENCES "solo_mars_event_deck"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); await queryRunner.query(`ALTER TABLE "solo_player" ADD CONSTRAINT "FK_f3655aa944db2032d6d9453c5c7" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "solo_player" ADD CONSTRAINT "FK_68410b02beb97d426ee11e523ec" FOREIGN KEY ("gameId") REFERENCES "solo_game"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "solo_player" DROP CONSTRAINT "FK_68410b02beb97d426ee11e523ec"`); await queryRunner.query(`ALTER TABLE "solo_player" DROP CONSTRAINT "FK_f3655aa944db2032d6d9453c5c7"`); await queryRunner.query(`ALTER TABLE "solo_game" DROP CONSTRAINT "FK_39a6a51dc8dbc70626d59fe06db"`); await queryRunner.query(`ALTER TABLE "solo_game" DROP CONSTRAINT "FK_7ba902b4f916952de522fbf7d0e"`); + await queryRunner.query(`ALTER TABLE "solo_game" DROP CONSTRAINT "FK_ee276d60507980ddead8f08c80a"`); await queryRunner.query(`ALTER TABLE "solo_mars_event_deck_card" DROP CONSTRAINT "FK_732e7ef62231a3b1f8a692b9ca9"`); await queryRunner.query(`ALTER TABLE "solo_mars_event_deck_card" DROP CONSTRAINT "FK_794e3e38b173d64eec1f2962996"`); await queryRunner.query(`ALTER TABLE "solo_mars_event_deck_card" DROP CONSTRAINT "FK_a48822e171d01382a35c0d087fb"`); await queryRunner.query(`ALTER TABLE "solo_game_round" DROP CONSTRAINT "FK_a31e55e614589c1806a4b96f158"`); await queryRunner.query(`DROP TABLE "solo_player"`); await queryRunner.query(`DROP TABLE "solo_game"`); + await queryRunner.query(`DROP TYPE "public"."solo_game_status_enum"`); await queryRunner.query(`DROP TABLE "solo_mars_event_deck"`); await queryRunner.query(`DROP TABLE "solo_mars_event_deck_card"`); await queryRunner.query(`DROP TABLE "solo_mars_event_card"`); diff --git a/server/src/rooms/sologame/commands.ts b/server/src/rooms/sologame/commands.ts index 6c1b20951..9c345f8cc 100644 --- a/server/src/rooms/sologame/commands.ts +++ b/server/src/rooms/sologame/commands.ts @@ -5,9 +5,7 @@ import { SoloGameRoom } from "@port-of-mars/server/rooms/sologame"; import { getServices } from "@port-of-mars/server/services"; import { getRandomIntInclusive } from "@port-of-mars/server/util"; import { EventCard, SoloGameState, TreatmentParams } from "./state"; -// import { settings } from "@port-of-mars/server/settings"; - -// const logger = settings.logging.getLogger(__filename); +import { SoloGameStatus } from "@port-of-mars/shared/sologame"; abstract class Cmd extends Command {} abstract class CmdWithoutPayload extends Cmd> {} @@ -18,6 +16,8 @@ export class InitGameCmd extends Cmd<{ user: User }> { new SetPlayerCmd().setPayload({ user }), new CreateDeckCmd(), new SetTreatmentParamsCmd().setPayload({ user }), + new SetGameParamsCmd(), + new PersistGameCmd(), new SetFirstRoundCmd(), ]; } @@ -26,6 +26,7 @@ export class InitGameCmd extends Cmd<{ user: User }> { export class SetPlayerCmd extends Cmd<{ user: User }> { execute({ user } = this.payload) { this.state.player.assign({ + userId: user.id, username: user.username, points: 0, }); @@ -48,13 +49,11 @@ export class SetTreatmentParamsCmd extends Cmd<{ user: User }> { if (treatmentIds.length > 0) { const treatmentId = treatmentIds[Math.floor(Math.random() * treatmentIds.length)]; this.state.treatmentParams = new TreatmentParams(await service.getTreatmentById(treatmentId)); - } else { - this.state.treatmentParams = new TreatmentParams(await service.getRandomTreatment()); } } } -export class SetFirstRoundCmd extends CmdWithoutPayload { +export class SetGameParamsCmd extends CmdWithoutPayload { execute() { const defaults = SoloGameState.DEFAULTS; this.state.maxRound = getRandomIntInclusive(defaults.maxRound.min, defaults.maxRound.max); @@ -71,6 +70,24 @@ export class SetFirstRoundCmd extends CmdWithoutPayload { defaults.threeCardThreshold.min, threeCardThresholdMax ); + } +} + +export class PersistGameCmd extends CmdWithoutPayload { + async execute() { + const { sologame: service } = getServices(); + const game = await service.createGame(this.state); + this.state.gameId = game.id; + // keep track of deck card db ids after persisting the deck + this.state.eventCardDeck.forEach((card, index) => { + card.deckCardId = game.deck.cards[index].id; + }); + } +} + +export class SetFirstRoundCmd extends CmdWithoutPayload { + execute() { + const defaults = SoloGameState.DEFAULTS; this.state.round = 1; this.state.systemHealth = defaults.systemHealthMax; this.state.timeRemaining = defaults.timeRemaining; @@ -114,11 +131,11 @@ export class ApplyCardCmd extends Cmd<{ playerSkipped: boolean }> { if (playerSkipped) { this.room.eventTimeout?.clear(); } - this.state.player.points += this.state.activeRoundCard!.pointsDelta; - this.state.player.resources += this.state.activeRoundCard!.resourcesDelta; + this.state.player.points += this.state.activeRoundCard!.pointsEffect; + this.state.player.resources += this.state.activeRoundCard!.resourcesEffect; this.state.systemHealth = Math.min( SoloGameState.DEFAULTS.systemHealthMax, - this.state.systemHealth + this.state.activeRoundCard!.systemHealthDelta + this.state.systemHealth + this.state.activeRoundCard!.systemHealthEffect ); if (this.state.systemHealth <= 0) { return new EndGameCmd().setPayload({ status: "defeat" }); @@ -176,14 +193,22 @@ export class InvestCmd extends Cmd<{ systemHealthInvestment: number }> { this.state.systemHealth + systemHealthInvestment ); this.state.player.points += surplus; - return new SetNextRoundCmd(); + return new SetNextRoundCmd().setPayload({ + systemHealthInvestment, + pointsInvestment: surplus, + }); } } -export class SetNextRoundCmd extends CmdWithoutPayload { - execute() { +export class SetNextRoundCmd extends Cmd<{ + systemHealthInvestment: number; + pointsInvestment: number; +}> { + async execute({ systemHealthInvestment, pointsInvestment } = this.payload) { + const { sologame: service } = getServices(); + await service.createRound(this.state, systemHealthInvestment, pointsInvestment); + const defaults = SoloGameState.DEFAULTS; - // TODO: persist round this.state.round += 1; if (this.state.round > this.state.maxRound) { return new EndGameCmd().setPayload({ status: "victory" }); @@ -199,10 +224,13 @@ export class SetNextRoundCmd extends CmdWithoutPayload { } } -export class EndGameCmd extends Cmd<{ status: string }> { - execute({ status } = this.payload) { - // do any cleanup +export class EndGameCmd extends Cmd<{ status: SoloGameStatus }> { + async execute({ status } = this.payload) { + // do any additional cleanup + const { sologame: service } = getServices(); this.state.status = status; + await service.updateGameStatus(this.state.gameId, status); + await service.updatePlayerPoints(this.state.gameId, this.state.player.points); this.room.disconnect(); } } diff --git a/server/src/rooms/sologame/index.ts b/server/src/rooms/sologame/index.ts index e70a2b9fa..651054b8f 100644 --- a/server/src/rooms/sologame/index.ts +++ b/server/src/rooms/sologame/index.ts @@ -34,7 +34,12 @@ export class SoloGameRoom extends Room { this.clock.setInterval(() => { this.state.timeRemaining -= 1; if (this.state.timeRemaining <= 0) { - this.dispatcher.dispatch(new SetNextRoundCmd()); + this.dispatcher.dispatch( + new SetNextRoundCmd().setPayload({ + systemHealthInvestment: 0, + pointsInvestment: 0, + }) + ); } }, 1000); } diff --git a/server/src/rooms/sologame/state.ts b/server/src/rooms/sologame/state.ts index 2656a59d4..17a0f5cef 100644 --- a/server/src/rooms/sologame/state.ts +++ b/server/src/rooms/sologame/state.ts @@ -1,30 +1,36 @@ import { Schema, ArraySchema, type } from "@colyseus/schema"; -import { EventCardData, TreatmentData } from "@port-of-mars/shared/sologame"; +import { EventCardData, SoloGameStatus, TreatmentData } from "@port-of-mars/shared/sologame"; export class EventCard extends Schema { + id = 0; + deckCardId = 0; @type("string") codeName = ""; @type("string") displayName = ""; - @type("string") description = ""; - @type("int8") pointsDelta = 0; - @type("int8") resourcesDelta = 0; - @type("int8") systemHealthDelta = 0; + @type("string") flavorText = ""; + @type("string") effectText = ""; + @type("int8") pointsEffect = 0; + @type("int8") resourcesEffect = 0; + @type("int8") systemHealthEffect = 0; constructor(data: EventCardData) { super(); + this.id = data.id; this.codeName = data.codeName; this.displayName = data.displayName; - this.description = data.description; - this.pointsDelta = data.pointsDelta; - this.resourcesDelta = data.resourcesDelta; - this.systemHealthDelta = data.systemHealthDelta; + this.flavorText = data.flavorText; + this.effectText = data.effectText; + this.pointsEffect = data.pointsEffect; + this.resourcesEffect = data.resourcesEffect; + this.systemHealthEffect = data.systemHealthEffect; } get isMurphysLaw() { - return this.codeName === "murphys-law"; + return this.codeName === "murphysLaw"; } } export class Player extends Schema { + userId = 0; @type("string") username = ""; @type("uint8") resources = SoloGameState.DEFAULTS.resources; @type("uint8") points = SoloGameState.DEFAULTS.points; @@ -45,19 +51,22 @@ export class TreatmentParams extends Schema { } export class SoloGameState extends Schema { - @type("string") status = "incomplete"; + @type("string") status: SoloGameStatus = "incomplete"; @type("int8") systemHealth = SoloGameState.DEFAULTS.systemHealthMax; - @type("uint8") twoCardThreshold = SoloGameState.DEFAULTS.twoCardThreshold.max; - @type("uint8") threeCardThreshold = SoloGameState.DEFAULTS.threeCardThreshold.max; @type("uint8") timeRemaining = SoloGameState.DEFAULTS.timeRemaining; - @type("uint8") maxRound = SoloGameState.DEFAULTS.maxRound.max; @type("uint8") round = 1; @type(TreatmentParams) treatmentParams = new TreatmentParams(); @type(Player) player: Player = new Player(); - @type([EventCard]) eventCardDeck = new ArraySchema(); @type([EventCard]) roundEventCards = new ArraySchema(); @type("uint8") activeRoundCardIndex = -1; + gameId = 0; + // hidden properties + maxRound = SoloGameState.DEFAULTS.maxRound.max; + twoCardThreshold = SoloGameState.DEFAULTS.twoCardThreshold.max; + threeCardThreshold = SoloGameState.DEFAULTS.threeCardThreshold.max; + eventCardDeck: Array = []; + get points() { return this.player.points; } diff --git a/server/src/services/sologame.ts b/server/src/services/sologame.ts index a0f35e98f..9d3d2eaa8 100644 --- a/server/src/services/sologame.ts +++ b/server/src/services/sologame.ts @@ -1,85 +1,199 @@ import { BaseService } from "@port-of-mars/server/services/db"; -import { EventCardData, TreatmentData } from "@port-of-mars/shared/sologame"; +import { EventCardData, SoloGameStatus, TreatmentData } from "@port-of-mars/shared/sologame"; +import { + SoloGame, + SoloGameRound, + SoloGameTreatment, + SoloMarsEventCard, + SoloMarsEventDeck, + SoloMarsEventDeckCard, + SoloPlayer, + SoloPlayerDecision, + User, +} 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 { getLogger } from "@port-of-mars/server/settings"; + +// const logger = getLogger(__filename); export class SoloGameService extends BaseService { async drawEventCardDeck(): Promise { - return [ - { - codeName: "test-1", - displayName: "Test 1", - description: "Gain 10 points", - pointsDelta: 10, - resourcesDelta: 0, - systemHealthDelta: 0, - }, - { - codeName: "test-2", - displayName: "Test 2", - description: "Lose 3 resources", - pointsDelta: 0, - resourcesDelta: -3, - systemHealthDelta: 0, - }, - { - codeName: "test-3", - displayName: "Test 3", - description: "Gain 5 resources", - pointsDelta: 0, - resourcesDelta: 5, - systemHealthDelta: 0, - }, - { - codeName: "test-4", - displayName: "Test 4", - description: "Gain 10 system health", - pointsDelta: 0, - resourcesDelta: 0, - systemHealthDelta: 10, - }, - { - codeName: "test-5", - displayName: "Test 5", - description: "Lose 5 system health", - pointsDelta: 0, - resourcesDelta: 0, - systemHealthDelta: -5, - }, - { - codeName: "test-6", - displayName: "Compulsive Philanthropy", - description: "Invest 5 resources in system health", - pointsDelta: 0, - resourcesDelta: -5, - systemHealthDelta: 5, - }, - { - codeName: "murphys-law", - displayName: "Murphy's Law", - description: "Draw 2 more cards", - pointsDelta: 0, - resourcesDelta: 0, - systemHealthDelta: 0, - }, - ]; + /** + * draw a deck of event cards from the db + */ + const cards = await this.em.getRepository(SoloMarsEventCard).find(); + const deck: EventCardData[] = []; + + for (const card of cards) { + const drawAmt = getRandomIntInclusive(card.drawMin, card.drawMax); + for (let i = 0; i < drawAmt; i++) { + // card effects are encoded in the db with a range of possible rolls and + // a multiplier for each value (sys health, points, .. ), this is typically + // 0, 1 or -1 + const roll = getRandomIntInclusive(card.rollMin, card.rollMax); + // fill out templates from the db with the actual roll value and pluralization + const effectText = card.effect + .replace("{roll}", roll.toString()) + .replace("{s}", roll === 1 ? "" : "s"); + + deck.push({ + id: card.id, + codeName: card.codeName, + displayName: card.displayName, + flavorText: card.flavorText, + effectText, + pointsEffect: card.pointsMultiplier * roll, + resourcesEffect: card.resourcesMultiplier * roll, + systemHealthEffect: card.systemHealthMultiplier * roll, + }); + } + } + + return deck; } async getUserRemainingTreatments(userId: number): Promise> { - return [6]; + /** + * get the treatment Ids that a user has not yet played a game with. If they + * have played all the treatments, return the full range + */ + const numTreatments = await this.em.getRepository(SoloGameTreatment).count(); + const allTreatments = Array.from({ length: numTreatments }, (_, i) => i + 1); + const user = await this.em + .getRepository(User) + .createQueryBuilder("user") + .leftJoin("user.soloPlayers", "soloPlayer") + .leftJoin("soloPlayer.game", "soloGame") + .addSelect("soloGame.treatmentId") + .where("user.id = :userId", { userId }) + .getOne(); + + if (!user) { + throw new Error("User not found"); + } + if (!user.soloPlayers) { + return allTreatments; + } + + const treatmentIds = user.soloPlayers + .map(player => player.game && player.game.treatmentId) + .filter(Boolean); + + if (treatmentIds.length === numTreatments) { + // return [1, ... numTreatments] if they have gone through all the treatments + return allTreatments; + } + + return allTreatments.filter(id => !treatmentIds.includes(id)); } async getTreatmentById(id: number): Promise { - return { - isKnownNumberOfRounds: true, - isEventDeckKnown: true, - thresholdInformation: "known", - }; + return this.em.getRepository(SoloGameTreatment).findOneOrFail(id); } - async getRandomTreatment(): Promise { - return { - isKnownNumberOfRounds: true, - isEventDeckKnown: false, - thresholdInformation: "range", - }; + async createGame(state: SoloGameState): Promise { + /** + * create a new SoloGame in the db and return it + */ + const repo = this.em.getRepository(SoloGame); + const game = repo.create({ + player: await this.createPlayer(state.player.userId), + treatment: await this.createTreatment(state.treatmentParams), + deck: await this.createDeck(state.eventCardDeck), + status: state.status, + }); + await repo.save(game); + return repo.findOneOrFail(game.id, { relations: ["deck", "deck.cards"] }); + } + + async createPlayer(userId: number): Promise { + const repo = this.em.getRepository(SoloPlayer); + const player = repo.create({ + userId: userId, + }); + await repo.save(player); + return player; + } + + async createTreatment(treatmentData: TreatmentData): Promise { + const repo = this.em.getRepository(SoloGameTreatment); + const treatment = repo.create({ + isKnownNumberOfRounds: treatmentData.isKnownNumberOfRounds, + isEventDeckKnown: treatmentData.isEventDeckKnown, + thresholdInformation: treatmentData.thresholdInformation, + }); + await repo.save(treatment); + return treatment; + } + + async createDeck(cardData: EventCardData[]): Promise { + const deckCardRepo = this.em.getRepository(SoloMarsEventDeckCard); + const deckRepo = this.em.getRepository(SoloMarsEventDeck); + const deck = deckRepo.create({}); + await deckRepo.save(deck); + for (const card of cardData) { + const deckCard = deckCardRepo.create({ + deck, + deckId: deck.id, + cardId: card.id, + effectText: card.effectText, + systemHealthEffect: card.systemHealthEffect, + pointsEffect: card.pointsEffect, + resourcesEffect: card.resourcesEffect, + }); + await deckCardRepo.save(deckCard); + } + return deck; + } + + async updateGameStatus(gameId: number, status: SoloGameStatus) { + const repo = this.em.getRepository(SoloGame); + const game = await repo.findOneOrFail(gameId); + game.status = status; + await repo.save(game); + } + + async updatePlayerPoints(gameId: number, points: number) { + const repo = this.em.getRepository(SoloPlayer); + const player = await repo.findOneOrFail({ gameId }); + player.points = points; + await repo.save(player); + } + + async createRound( + state: SoloGameState, + systemHealthInvestment: number, + pointsInvestment: number + ) { + /** + * finalize/persist a game round by creating a new SoloGameRound tied to the SoloGame + * with id = gameId + */ + const roundRepo = this.em.getRepository(SoloGameRound); + const decisionRepo = this.em.getRepository(SoloPlayerDecision); + const deckCardRepo = this.em.getRepository(SoloMarsEventDeckCard); + const decision = decisionRepo.create({ + systemHealthInvestment, + pointsInvestment, + }); + await decisionRepo.save(decision); + const round = roundRepo.create({ + gameId: state.gameId, + roundNumber: state.round, + decision, + }); + await roundRepo.save(round); + + // additionally, set the round on all cards that were drawn this round + const cards = state.roundEventCards; + for (const card of cards) { + const deckCard = await deckCardRepo.findOneOrFail({ id: card.deckCardId }); + if (deckCard) { + deckCard.round = round; + await deckCardRepo.save(deckCard); + } + } + return round; } } diff --git a/server/src/util.ts b/server/src/util.ts index d13890a04..2ab96cd07 100644 --- a/server/src/util.ts +++ b/server/src/util.ts @@ -27,6 +27,7 @@ export function toUrl(page: Page): string { } export function getRandomIntInclusive(min: number, max: number): number { + if (min === max) return min; min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; //The maximum is inclusive and the minimum is inclusive diff --git a/shared/src/sologame/types.ts b/shared/src/sologame/types.ts index edac993cb..f5df4a047 100644 --- a/shared/src/sologame/types.ts +++ b/shared/src/sologame/types.ts @@ -1,14 +1,20 @@ export interface EventCardData { + id: number; codeName: string; displayName: string; - description: string; - pointsDelta: number; - resourcesDelta: number; - systemHealthDelta: number; + flavorText: string; + effectText: string; + pointsEffect: number; + resourcesEffect: number; + systemHealthEffect: number; } +export type ThresholdInformation = "unknown" | "range" | "known"; + export interface TreatmentData { isKnownNumberOfRounds: boolean; isEventDeckKnown: boolean; - thresholdInformation: "unknown" | "range" | "known"; + thresholdInformation: ThresholdInformation; } + +export type SoloGameStatus = "incomplete" | "victory" | "defeat";