Skip to content

Commit

Permalink
feat(WIP): add conditional game setup/metadata for tournament/freeplay
Browse files Browse the repository at this point in the history
- start to add tournament dashboard (pre-lobby) step
- refactor survey/tournament routes
  • Loading branch information
sgfost committed Oct 25, 2023
1 parent f6e07cb commit 17168eb
Show file tree
Hide file tree
Showing 16 changed files with 129 additions and 34 deletions.
3 changes: 3 additions & 0 deletions client/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -28,6 +29,7 @@ import {
LOGIN_PAGE,
FREE_PLAY_LOBBY_PAGE,
TOURNAMENT_LOBBY_PAGE,
TOURNAMENT_DASHBOARD_PAGE,
GAME_PAGE,
SOLO_GAME_PAGE,
LEADERBOARD_PAGE,
Expand Down Expand Up @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions client/src/views/TournamentDashboard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!-- pre-lobby tournament page, requires taking survey, shows tournament info. etc. -->
4 changes: 4 additions & 0 deletions server/src/entity/Game.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { GameType } from "@port-of-mars/shared/types";
import {
Column,
CreateDateColumn,
Expand Down Expand Up @@ -39,6 +40,9 @@ export class Game {
@Column()
tournamentRoundId!: number;

@Column({ default: "freeplay" })
type!: GameType;

@OneToMany(type => Player, player => player.game)
players!: Array<Player>;

Expand Down
4 changes: 2 additions & 2 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
gameRouter,
quizRouter,
accountRouter,
surveyRouter,
tournamentRouter,
statusRouter,
statsRouter,
} from "@port-of-mars/server/routes";
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions server/src/migration/1698183026092-AddGameType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class AddGameType1698183026092 implements MigrationInterface {
name = "AddGameType1698183026092";

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "game" ADD "type" character varying NOT NULL DEFAULT 'freeplay'`
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "game" DROP COLUMN "type"`);
}
}
2 changes: 0 additions & 2 deletions server/src/rooms/game/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion server/src/rooms/game/types.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -24,6 +24,7 @@ export interface Persister {
}

export interface GameOpts extends GameStateOpts {
type: GameType;
tournamentRoundId: number;
}

Expand Down
2 changes: 1 addition & 1 deletion server/src/rooms/lobby/freeplay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class FreePlayLobbyRoom extends LobbyRoom<FreePlayLobbyRoomState> {
// 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
Expand Down
2 changes: 1 addition & 1 deletion server/src/rooms/lobby/tournament/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class TournamentLobbyRoom extends LobbyRoom<TournamentLobbyRoomState> {

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
Expand Down
2 changes: 1 addition & 1 deletion server/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ export * from "./middleware";
export * from "./quiz";
export * from "./account";
export * from "./status";
export * from "./survey";
export * from "./tournament";
30 changes: 25 additions & 5 deletions server/src/routes/survey.ts → server/src/routes/tournament.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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);
}
});
15 changes: 8 additions & 7 deletions server/src/services/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,22 +89,23 @@ export class DBPersister implements Persister {

async initialize(options: GameOpts, roomId: string, shouldCreatePlayers = true): Promise<number> {
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);
Expand Down
29 changes: 28 additions & 1 deletion server/src/services/tournament.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<TournamentRound> {
const tournamentId = (await this.getOpenTournament()).id;
return await this.em.getRepository(TournamentRound).findOneOrFail({
Expand Down Expand Up @@ -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<TournamentDashboardData | undefined> {
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).
Expand Down
18 changes: 15 additions & 3 deletions server/src/util.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -83,12 +83,23 @@ export async function mockGameInitOpts(): Promise<GameOpts> {
return {
...mockGameStateInitOpts(),
tournamentRoundId: currentTournamentRound.id,
type: "tournament",
};
}

export async function buildGameOpts(usernames: Array<string>): Promise<GameOpts> {
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<string>, type: GameType): Promise<GameOpts> {
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) {
Expand All @@ -115,6 +126,7 @@ export async function buildGameOpts(usernames: Array<string>): Promise<GameOpts>
deck: getRandomizedMarsEventDeck(),
numberOfGameRounds: currentTournamentRound.numberOfGameRounds,
tournamentRoundId: currentTournamentRound.id,
type,
};
}

Expand Down
14 changes: 13 additions & 1 deletion shared/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,6 +22,7 @@ export type Page =
| "Login"
| "FreePlayLobby"
| "TournamentLobby"
| "TournamentDashboard"
| "Game"
| "SoloGame"
| "PlayerHistory"
Expand All @@ -36,6 +38,7 @@ export const PAGES: Array<Page> = [
LOGIN_PAGE,
FREE_PLAY_LOBBY_PAGE,
TOURNAMENT_LOBBY_PAGE,
TOURNAMENT_DASHBOARD_PAGE,
GAME_PAGE,
SOLO_GAME_PAGE,
PLAYER_HISTORY_PAGE,
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 10 additions & 9 deletions shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ export interface LeaderboardData {
withoutBots: Array<LeaderboardItem>;
}

export type LobbyType = "freeplay" | "tournament";
export type GameType = "freeplay" | "tournament";
export type LobbyType = GameType;

export interface LobbyChatMessageData {
username: string;
Expand Down Expand Up @@ -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<number>; // list of timestamps for upcoming games
Expand All @@ -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;
Expand Down

0 comments on commit 17168eb

Please sign in to comment.