From 17168eb18d42ec16c0e90409c7a955b443979148 Mon Sep 17 00:00:00 2001 From: sgfost Date: Tue, 24 Oct 2023 14:46:10 -0700 Subject: [PATCH] feat(WIP): add conditional game setup/metadata for tournament/freeplay - start to add tournament dashboard (pre-lobby) step - refactor survey/tournament routes --- client/src/router.ts | 3 ++ client/src/views/TournamentDashboard.vue | 1 + server/src/entity/Game.ts | 4 +++ server/src/index.ts | 4 +-- .../migration/1698183026092-AddGameType.ts | 15 ++++++++++ server/src/rooms/game/index.ts | 2 -- server/src/rooms/game/types.ts | 3 +- server/src/rooms/lobby/freeplay/index.ts | 2 +- server/src/rooms/lobby/tournament/index.ts | 2 +- server/src/routes/index.ts | 2 +- .../src/routes/{survey.ts => tournament.ts} | 30 +++++++++++++++---- server/src/services/persistence.ts | 15 +++++----- server/src/services/tournament.ts | 29 +++++++++++++++++- server/src/util.ts | 18 +++++++++-- shared/src/routes.ts | 14 ++++++++- shared/src/types.ts | 19 ++++++------ 16 files changed, 129 insertions(+), 34 deletions(-) create mode 100644 client/src/views/TournamentDashboard.vue create mode 100644 server/src/migration/1698183026092-AddGameType.ts rename server/src/routes/{survey.ts => tournament.ts} (53%) diff --git a/client/src/router.ts b/client/src/router.ts index cbdd9957c..a005fbfd7 100644 --- a/client/src/router.ts +++ b/client/src/router.ts @@ -11,6 +11,7 @@ import Leaderboard from "@port-of-mars/client/views/Leaderboard.vue"; import PlayerHistory from "@port-of-mars/client/views/PlayerHistory.vue"; import FreePlayLobby from "@port-of-mars/client/views/FreePlayLobby.vue"; import TournamentLobby from "@port-of-mars/client/views/TournamentLobby.vue"; +import TournamentDashboard from "@port-of-mars/client/views/TournamentDashboard.vue"; import LobbyRoom from "@port-of-mars/client/components/lobby/LobbyRoom.vue"; import LobbyRoomList from "@port-of-mars/client/components/lobby/LobbyRoomList.vue"; import Game from "@port-of-mars/client/views/Game.vue"; @@ -28,6 +29,7 @@ import { LOGIN_PAGE, FREE_PLAY_LOBBY_PAGE, TOURNAMENT_LOBBY_PAGE, + TOURNAMENT_DASHBOARD_PAGE, GAME_PAGE, SOLO_GAME_PAGE, LEADERBOARD_PAGE, @@ -77,6 +79,7 @@ const router = new VueRouter({ ], }, { ...PAGE_META[TOURNAMENT_LOBBY_PAGE], component: TournamentLobby }, + { ...PAGE_META[TOURNAMENT_DASHBOARD_PAGE], component: TournamentDashboard }, { ...PAGE_META[GAME_PAGE], component: Game }, { ...PAGE_META[SOLO_GAME_PAGE], component: SoloGame }, { ...PAGE_META[LEADERBOARD_PAGE], component: Leaderboard }, diff --git a/client/src/views/TournamentDashboard.vue b/client/src/views/TournamentDashboard.vue new file mode 100644 index 000000000..237b099cd --- /dev/null +++ b/client/src/views/TournamentDashboard.vue @@ -0,0 +1 @@ + diff --git a/server/src/entity/Game.ts b/server/src/entity/Game.ts index 0d0c2e70a..bee6056d6 100644 --- a/server/src/entity/Game.ts +++ b/server/src/entity/Game.ts @@ -1,3 +1,4 @@ +import { GameType } from "@port-of-mars/shared/types"; import { Column, CreateDateColumn, @@ -39,6 +40,9 @@ export class Game { @Column() tournamentRoundId!: number; + @Column({ default: "freeplay" }) + type!: GameType; + @OneToMany(type => Player, player => player.game) players!: Array; diff --git a/server/src/index.ts b/server/src/index.ts index dacce785a..98662b934 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -31,7 +31,7 @@ import { gameRouter, quizRouter, accountRouter, - surveyRouter, + tournamentRouter, statusRouter, statsRouter, } from "@port-of-mars/server/routes"; @@ -186,7 +186,7 @@ async function createApp() { app.use("/admin", adminRouter); app.use("/auth", authRouter); - app.use("/survey", surveyRouter); + app.use("/tournament", tournamentRouter); app.use("/stats", statsRouter); app.use("/game", gameRouter); app.use("/quiz", quizRouter); diff --git a/server/src/migration/1698183026092-AddGameType.ts b/server/src/migration/1698183026092-AddGameType.ts new file mode 100644 index 000000000..930c4059b --- /dev/null +++ b/server/src/migration/1698183026092-AddGameType.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddGameType1698183026092 implements MigrationInterface { + name = "AddGameType1698183026092"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "game" ADD "type" character varying NOT NULL DEFAULT 'freeplay'` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "game" DROP COLUMN "type"`); + } +} diff --git a/server/src/rooms/game/index.ts b/server/src/rooms/game/index.ts index dd7a3fd51..85d10acdf 100644 --- a/server/src/rooms/game/index.ts +++ b/server/src/rooms/game/index.ts @@ -155,8 +155,6 @@ async function onCreate( logger.info("roomId: %s", room.roomId); room.gameId = await room.persister.initialize(options, room.roomId, shouldEnableDb); const snapshot = room.state.toJSON(); - // FIXME: consider taking game state snapshots periodically during the game as well as at the end of the game - // https://github.com/virtualcommons/port-of-mars/issues/718 const event = new TakenStateSnapshot(snapshot); room.persister.persist([event], room.getMetadata()); room.clock.setInterval(() => gameLoop(room), 1000); diff --git a/server/src/rooms/game/types.ts b/server/src/rooms/game/types.ts index de3bef6cd..7d4860631 100644 --- a/server/src/rooms/game/types.ts +++ b/server/src/rooms/game/types.ts @@ -1,7 +1,7 @@ import { Client, Room } from "colyseus"; import { GameState, Player } from "@port-of-mars/server/rooms/game/state"; import { Responses } from "@port-of-mars/shared/game/responses"; -import { MarsEventData, Role } from "@port-of-mars/shared/types"; +import { GameType, MarsEventData, Role } from "@port-of-mars/shared/types"; import * as ge from "@port-of-mars/server/rooms/game/events/types"; import { GameEvent } from "@port-of-mars/server/entity/GameEvent"; @@ -24,6 +24,7 @@ export interface Persister { } export interface GameOpts extends GameStateOpts { + type: GameType; tournamentRoundId: number; } diff --git a/server/src/rooms/lobby/freeplay/index.ts b/server/src/rooms/lobby/freeplay/index.ts index e4b933574..a5a21d25b 100644 --- a/server/src/rooms/lobby/freeplay/index.ts +++ b/server/src/rooms/lobby/freeplay/index.ts @@ -39,7 +39,7 @@ export class FreePlayLobbyRoom extends LobbyRoom { // set ready to false so that invitations are only sent once this.state.setRoomReadiness(false); const usernames = await this.getFilledUsernames(); - const gameOpts = await buildGameOpts(usernames); + const gameOpts = await buildGameOpts(usernames, "freeplay"); const room = await matchMaker.createRoom(GameRoom.NAME, gameOpts); logger.info(`${this.roomName} created game room ${room.roomId}`); // send room data for new websocket connection diff --git a/server/src/rooms/lobby/tournament/index.ts b/server/src/rooms/lobby/tournament/index.ts index 2046ff9e0..e42c9c1d3 100644 --- a/server/src/rooms/lobby/tournament/index.ts +++ b/server/src/rooms/lobby/tournament/index.ts @@ -41,7 +41,7 @@ export class TournamentLobbyRoom extends LobbyRoom { async sendGroupInvitations() { const usernames = this.pendingGroup.map(client => client.username); - const gameOpts = await buildGameOpts(usernames); + const gameOpts = await buildGameOpts(usernames, "tournament"); const room = await matchMaker.createRoom(GameRoom.NAME, gameOpts); logger.info(`${this.roomName} created game room ${room.roomId}`); // send room data for new websocket connection diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index e4fa1cb91..4eb874036 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -9,4 +9,4 @@ export * from "./middleware"; export * from "./quiz"; export * from "./account"; export * from "./status"; -export * from "./survey"; +export * from "./tournament"; diff --git a/server/src/routes/survey.ts b/server/src/routes/tournament.ts similarity index 53% rename from server/src/routes/survey.ts rename to server/src/routes/tournament.ts index 9d0f34657..450f15c03 100644 --- a/server/src/routes/survey.ts +++ b/server/src/routes/tournament.ts @@ -1,17 +1,17 @@ import { Router, Request, Response } from "express"; +import { User } from "@port-of-mars/server/entity"; import { getServices } from "@port-of-mars/server/services"; import { getPagePath, FREE_PLAY_LOBBY_PAGE } from "@port-of-mars/shared/routes"; import { settings, getLogger } from "@port-of-mars/server/settings"; import { ServerError } from "@port-of-mars/server/util"; -export const surveyRouter = Router(); +export const tournamentRouter = Router(); const logger = getLogger(__filename); -// https://portofmars.asu.edu/survey/complete/?pid=${e://Field/pid}&surveyId=${e://Field/SurveyID}&tid=${e://Field/tid} -// http://localhost:8081/survey/complete?pid=cdf6a97d-d537-4fc5-b655-7c22651cc61c&tid=2&surveyId=SV_0c8tCMZkAUh4V8x - -surveyRouter.get("/complete", async (req: Request, res: Response, next) => { +// https://portofmars.asu.edu/tournament/survey/complete/?pid=${e://Field/pid}&surveyId=${e://Field/SurveyID}&tid=${e://Field/tid} +// http://localhost:8081/tournament/survey/complete?pid=cdf6a97d-d537-4fc5-b655-7c22651cc61c&tid=2&surveyId=SV_0c8tCMZkAUh4V8x +tournamentRouter.get("/survey/complete", async (req: Request, res: Response, next) => { logger.debug("trying to mark survey complete"); try { const participantId = String(req.query.pid); @@ -34,3 +34,23 @@ surveyRouter.get("/complete", async (req: Request, res: Response, next) => { next(e); } }); + +tournamentRouter.get("/dashboard", async (req: Request, res: Response, next) => { + try { + const user = req.user as User; + if (user) { + res.status(401); + } + const tournamentData = await getServices().tournament.getDashboardData(user.id); + if (!tournamentData) { + res.status(404).json({ + kind: "danger", + message: "You do not have a valid invitation to the current tournament round.", + }); + } + res.json(tournamentData); + } catch (e) { + logger.fatal("Unable to get tournament dashboard data: %s", e); + next(e); + } +}); diff --git a/server/src/services/persistence.ts b/server/src/services/persistence.ts index 56bfe81d9..30e9d50fe 100644 --- a/server/src/services/persistence.ts +++ b/server/src/services/persistence.ts @@ -89,22 +89,23 @@ export class DBPersister implements Persister { async initialize(options: GameOpts, roomId: string, shouldCreatePlayers = true): Promise { logger.debug("initializing game %s", roomId); - const g = new Game(); + const game = new Game(); const f = async (em: EntityManager) => { if (_.isNull(options.tournamentRoundId)) { throw new Error("could not find matching tournament round"); } - g.buildId = BUILD_ID; - g.tournamentRoundId = options.tournamentRoundId; - g.roomId = roomId; + game.buildId = BUILD_ID; + game.tournamentRoundId = options.tournamentRoundId; + game.roomId = roomId; + game.type = options.type; - await em.save(g); + await em.save(game); if (shouldCreatePlayers) { const rawUsers = await this.selectUsersByUsername(em, Object.keys(options.userRoles)); - await this.createPlayers(em, g.id, options.userRoles, rawUsers); + await this.createPlayers(em, game.id, options.userRoles, rawUsers); } - return g.id; + return game.id; }; if (this.em.queryRunner?.isTransactionActive) { return await f(this.em); diff --git a/server/src/services/tournament.ts b/server/src/services/tournament.ts index db5010625..1f79f40e0 100644 --- a/server/src/services/tournament.ts +++ b/server/src/services/tournament.ts @@ -10,7 +10,7 @@ import { getServices } from "@port-of-mars/server/services"; import { getLogger } from "@port-of-mars/server/settings"; import { BaseService } from "@port-of-mars/server/services/db"; import { TournamentRoundDate } from "@port-of-mars/server/entity/TournamentRoundDate"; -import { TournamentStatus } from "@port-of-mars/shared/types"; +import { TournamentDashboardData, TournamentStatus } from "@port-of-mars/shared/types"; import { isDev } from "@port-of-mars/shared/settings"; const logger = getLogger(__filename); @@ -52,6 +52,7 @@ export class TournamentService extends BaseService { }); } + // FIXME: rename this stuff to use freePlay instead of open or vice versa async getOpenTournamentRound(): Promise { const tournamentId = (await this.getOpenTournament()).id; return await this.em.getRepository(TournamentRound).findOneOrFail({ @@ -284,6 +285,32 @@ export class TournamentService extends BaseService { this.em.save(invite); } + /** + * Aggregrates and returns Tournament Dashboard data (survey status, schedule, etc.) + */ + async getDashboardData(userId: number): Promise { + const invite = await this.getActiveRoundInvite(userId); + if (invite) { + const tournamentRound = await this.getCurrentTournamentRound(); + const scheduledDates = await this.getScheduledDates(tournamentRound); + const announcement = tournamentRound.announcement ?? ""; + const description = tournamentRound.tournament.description ?? ""; + const status = { + schedule: scheduledDates.map((date: Date) => date.getTime()), + championship: tournamentRound.championship, + round: tournamentRound.roundNumber, + announcement, + description, + }; + return { + status, + introSurveyUrl: "", // TODO: build survey url + hasCompletedIntroSurvey: invite.hasCompletedIntroSurvey, + hasParticipated: invite.hasParticipated, + }; + } + } + /** * Returns true if there is a scheduled game within a specific time window of now. Currently set to open 10 mins before the * scheduled game, and 30 minutes after the scheduled game (i.e., 40 minute window). diff --git a/server/src/util.ts b/server/src/util.ts index 2ab96cd07..625db4795 100644 --- a/server/src/util.ts +++ b/server/src/util.ts @@ -1,7 +1,7 @@ import _ from "lodash"; import * as assert from "assert"; import * as to from "typeorm"; -import { ROLES, DashboardMessage } from "@port-of-mars/shared/types"; +import { ROLES, DashboardMessage, GameType } from "@port-of-mars/shared/types"; import { GameOpts, GameStateOpts } from "@port-of-mars/server/rooms/game/types"; import { getFixedMarsEventDeck, @@ -83,12 +83,23 @@ export async function mockGameInitOpts(): Promise { return { ...mockGameStateInitOpts(), tournamentRoundId: currentTournamentRound.id, + type: "tournament", }; } -export async function buildGameOpts(usernames: Array): Promise { +async function getCurrentTournamentRound(type: GameType) { + // get the current tournament round, if freeplay game then get the special open/freeplay tournament round + const tournamentService = getServices().tournament; + if (type === "freeplay") { + return await tournamentService.getOpenTournamentRound(); + } else { + return await tournamentService.getCurrentTournamentRound(); + } +} + +export async function buildGameOpts(usernames: Array, type: GameType): Promise { const services = getServices(); - const currentTournamentRound = await services.tournament.getCurrentTournamentRound(); + const currentTournamentRound = await getCurrentTournamentRound(type); assert.strictEqual(usernames.length, ROLES.length); logger.info("building game opts with current tournament round [%d]", currentTournamentRound.id); for (const u of usernames) { @@ -115,6 +126,7 @@ export async function buildGameOpts(usernames: Array): Promise deck: getRandomizedMarsEventDeck(), numberOfGameRounds: currentTournamentRound.numberOfGameRounds, tournamentRoundId: currentTournamentRound.id, + type, }; } diff --git a/shared/src/routes.ts b/shared/src/routes.ts index c757c74d4..1b3620903 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -2,6 +2,7 @@ export const ADMIN_PAGE = "Admin" as const; export const LOGIN_PAGE = "Login" as const; export const FREE_PLAY_LOBBY_PAGE = "FreePlayLobby" as const; export const TOURNAMENT_LOBBY_PAGE = "TournamentLobby" as const; +export const TOURNAMENT_DASHBOARD_PAGE = "TournamentDashboard" as const; export const GAME_PAGE = "Game" as const; export const SOLO_GAME_PAGE = "SoloGame" as const; export const LEADERBOARD_PAGE = "Leaderboard" as const; @@ -21,6 +22,7 @@ export type Page = | "Login" | "FreePlayLobby" | "TournamentLobby" + | "TournamentDashboard" | "Game" | "SoloGame" | "PlayerHistory" @@ -36,6 +38,7 @@ export const PAGES: Array = [ LOGIN_PAGE, FREE_PLAY_LOBBY_PAGE, TOURNAMENT_LOBBY_PAGE, + TOURNAMENT_DASHBOARD_PAGE, GAME_PAGE, SOLO_GAME_PAGE, PLAYER_HISTORY_PAGE, @@ -127,7 +130,16 @@ export const PAGE_META: { }, }, [TOURNAMENT_LOBBY_PAGE]: { - path: "/tournament", + path: "/tournament/lobby", + meta: { + requiresAuth: true, + requiresTournamentEnabled: true, + }, + }, + [TOURNAMENT_DASHBOARD_PAGE]: { + path: "/tournament/dashboard", + name: TOURNAMENT_DASHBOARD_PAGE, + props: true, meta: { requiresAuth: true, requiresTournamentEnabled: true, diff --git a/shared/src/types.ts b/shared/src/types.ts index 34d1682b4..31ec9f1ca 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -64,7 +64,8 @@ export interface LeaderboardData { withoutBots: Array; } -export type LobbyType = "freeplay" | "tournament"; +export type GameType = "freeplay" | "tournament"; +export type LobbyType = GameType; export interface LobbyChatMessageData { username: string; @@ -431,14 +432,6 @@ export interface AdminStats { bannedUsers: number; } -export interface TournamentPlayerData { - hasInvite: boolean; - mustTakeTutorial: boolean; - mustTakeIntroSurvey: boolean; - canPlayGame: boolean; - shouldTakeExitSurvey: boolean; -} - export interface TournamentStatus { round: number; schedule: Array; // list of timestamps for upcoming games @@ -447,6 +440,14 @@ export interface TournamentStatus { description: string; } +export interface TournamentDashboardData { + // mirrors a round invite (TournamentRoundInvite) for use in the tournament pre-lobby dashboard + status: TournamentStatus; + introSurveyUrl: string; + hasCompletedIntroSurvey: boolean; + hasParticipated: boolean; +} + export interface ClientInitData { isTournamentEnabled: boolean; isFreePlayEnabled: boolean;