diff --git a/Makefile b/Makefile index 86d4055e3..7325e96d4 100644 --- a/Makefile +++ b/Makefile @@ -109,16 +109,13 @@ settings: $(SENTRY_DSN_PATH) $(SECRET_KEY_PATH) | keys echo 'export const SENTRY_DSN = "${SENTRY_DSN}";' >> $(SHARED_CONFIG_PATH) -build: docker-compose.yml settings - docker compose build --pull - initialize: build docker compose run --rm server yarn initdb -docker-compose.yml: base.yml staging.base.yml $(ENVIR).yml config.mk $(DB_DATA_PATH) $(DATA_DUMP_PATH) $(LOG_DATA_PATH) $(REDIS_SETTINGS_PATH) $(ORMCONFIG_PATH) $(NUXT_ORMCONFIG_PATH) $(PGPASS_PATH) $(SERVER_ENV) settings +docker-compose.yml: base.yml $(ENVIR).yml config.mk $(DB_DATA_PATH) $(DATA_DUMP_PATH) $(LOG_DATA_PATH) $(REDIS_SETTINGS_PATH) $(ORMCONFIG_PATH) $(NUXT_ORMCONFIG_PATH) $(PGPASS_PATH) $(SERVER_ENV) settings case "$(ENVIR)" in \ dev) docker compose -f base.yml -f "$(ENVIR).yml" config > docker-compose.yml;; \ - staging|prod) docker compose -f base.yml -f staging.base.yml -f "$(ENVIR).yml" config > docker-compose.yml;; \ + staging|prod) docker compose -f base.yml -f staging.yml -f "$(ENVIR).yml" config > docker-compose.yml;; \ *) echo "invalid environment. must be either dev, staging or prod" 1>&2; exit 1;; \ esac diff --git a/base.yml b/base.yml index 27e53dbeb..05ac519a3 100644 --- a/base.yml +++ b/base.yml @@ -3,7 +3,7 @@ services: build: context: . restart: always - image: port-of-mars/server/dev:latest + image: port-of-mars/server:dev depends_on: - redis - db @@ -15,6 +15,7 @@ services: - ./keys:/run/secrets - ./scripts:/scripts - ./server/.env:/code/server/.env + - ./.prettierrc:/code/.prettierrc # XXX: nuxt disabled until typeorm + auth support is properly added # https://github.com/virtualcommons/port-of-mars/issues/795 # https://github.com/virtualcommons/port-of-mars/issues/809 diff --git a/client/src/api/stats/request.ts b/client/src/api/stats/request.ts new file mode 100644 index 000000000..3bccbfa3c --- /dev/null +++ b/client/src/api/stats/request.ts @@ -0,0 +1,33 @@ +import { url } from "@port-of-mars/client/util"; +import { LeaderboardData, PlayerStatItem } from "@port-of-mars/shared/types"; +import { TStore } from "@port-of-mars/client/plugins/tstore"; +import { AjaxRequest } from "@port-of-mars/client/plugins/ajax"; + +export class StatsAPI { + constructor(public store: TStore, public ajax: AjaxRequest) {} + + async getLeaderboardData(limit?: number): Promise { + try { + const params = limit ? `?limit=${limit}` : ""; + return await this.ajax.get(url(`/stats/leaderboard${params}`), ({ data }) => { + return data; + }); + } catch (e) { + console.log("Unable to retrieve leaderboard data"); + console.log(e); + throw e; + } + } + + async getPlayerHistory(): Promise> { + try { + return await this.ajax.get(url("/stats/history"), ({ data }) => { + return data; + }); + } catch (e) { + console.log("Unable to retrieve player game history"); + console.log(e); + throw e; + } + } +} diff --git a/client/src/components/global/Navbar.vue b/client/src/components/global/Navbar.vue index e12d1e55b..e27de7ae6 100644 --- a/client/src/components/global/Navbar.vue +++ b/client/src/components/global/Navbar.vue @@ -33,6 +33,9 @@ title="Game Manual" >Manual + Leaderboard Play @@ -53,6 +56,7 @@ {{ username }} + Game History Sign Out @@ -75,6 +79,8 @@ import { MANUAL_PAGE, GAME_PAGE, LOBBY_PAGE, + PLAYER_HISTORY_PAGE, + LEADERBOARD_PAGE, } from "@port-of-mars/shared/routes"; import { isDevOrStaging, Constants } from "@port-of-mars/shared/settings"; import _ from "lodash"; @@ -100,6 +106,8 @@ export default class Header extends Vue { login = { name: LOGIN_PAGE }; manual = { name: MANUAL_PAGE }; game = { name: GAME_PAGE }; + leaderboard = { name: LEADERBOARD_PAGE }; + history = { name: PLAYER_HISTORY_PAGE }; lobby = { name: LOBBY_PAGE }; async created() { diff --git a/client/src/components/leaderboard/PlayerStatItem.vue b/client/src/components/leaderboard/PlayerStatItem.vue deleted file mode 100644 index bf19546b8..000000000 --- a/client/src/components/leaderboard/PlayerStatItem.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - - diff --git a/client/src/components/stats/GameStats.vue b/client/src/components/stats/GameStats.vue new file mode 100644 index 000000000..f16ff19d1 --- /dev/null +++ b/client/src/components/stats/GameStats.vue @@ -0,0 +1,69 @@ + + + + diff --git a/client/src/components/stats/LeaderboardTable.vue b/client/src/components/stats/LeaderboardTable.vue new file mode 100644 index 000000000..78613e8b7 --- /dev/null +++ b/client/src/components/stats/LeaderboardTable.vue @@ -0,0 +1,109 @@ + + + diff --git a/client/src/router.ts b/client/src/router.ts index aa2dd2644..f9cd3164c 100644 --- a/client/src/router.ts +++ b/client/src/router.ts @@ -7,6 +7,8 @@ import Rooms from "@port-of-mars/client/views/admin/Rooms.vue"; import Reports from "@port-of-mars/client/views/admin/Reports.vue"; import Settings from "@port-of-mars/client/views/admin/Settings.vue"; import Login from "@port-of-mars/client/views/Login.vue"; +import Leaderboard from "@port-of-mars/client/views/Leaderboard.vue"; +import PlayerHistory from "@port-of-mars/client/views/PlayerHistory.vue"; import Lobby from "@port-of-mars/client/views/Lobby.vue"; import LobbyRoom from "@port-of-mars/client/components/lobby/LobbyRoom.vue"; import LobbyRoomList from "@port-of-mars/client/components/lobby/LobbyRoomList.vue"; @@ -23,6 +25,8 @@ import { LOGIN_PAGE, LOBBY_PAGE, GAME_PAGE, + LEADERBOARD_PAGE, + PLAYER_HISTORY_PAGE, REGISTER_PAGE, VERIFY_PAGE, MANUAL_PAGE, @@ -66,6 +70,8 @@ const router = new VueRouter({ ], }, { ...PAGE_META[GAME_PAGE], component: Game }, + { ...PAGE_META[LEADERBOARD_PAGE], component: Leaderboard }, + { ...PAGE_META[PLAYER_HISTORY_PAGE], component: PlayerHistory }, { ...PAGE_META[REGISTER_PAGE], component: Register }, { ...PAGE_META[VERIFY_PAGE], component: Verify }, { ...PAGE_META[MANUAL_PAGE], component: Manual }, diff --git a/client/src/stylesheets/main.scss b/client/src/stylesheets/main.scss index 7342b1a34..78d4a84de 100644 --- a/client/src/stylesheets/main.scss +++ b/client/src/stylesheets/main.scss @@ -197,6 +197,17 @@ button { } } +.custom-table { + @extend .table-dark; + th { + @extend h4; + font-size: 1.1rem; + } + td { + @extend p; + } +} + // override bootstrap-vue dark text .text-dark { color: $white !important; diff --git a/client/src/views/Home.vue b/client/src/views/Home.vue index 62885b822..409da4d06 100644 --- a/client/src/views/Home.vue +++ b/client/src/views/Home.vue @@ -30,15 +30,6 @@
- - -

About

@@ -59,7 +50,25 @@ exercise for future human space communities.

+ + +

+ + +

Gameplay

@@ -77,14 +86,32 @@ earn you the Victory Points necessary to win the game.

+

+ +

Community

+

+ Whether you are looking to discuss the game or find a team, connect with other players + by joining our + community Discord or by joining a room in the + game lobby and using the built-in chat. +

+

+ Keep track of your performance with your + personal stats page and visit the + full leaderboard + to see how you stack up against other players. +

+
- +

Top Players

+
+ +
@@ -99,7 +126,8 @@ import { Component, Prop, Vue } from "vue-property-decorator"; import Footer from "@port-of-mars/client/components/global/Footer.vue"; import CharCarousel from "@port-of-mars/client/components/global/CharCarousel.vue"; import AgeTooltip from "@port-of-mars/client/components/global/AgeTooltip.vue"; -import { LOBBY_PAGE } from "@port-of-mars/shared/routes"; +import LeaderboardTable from "@port-of-mars/client/components/stats/LeaderboardTable.vue"; +import { LEADERBOARD_PAGE, LOBBY_PAGE, PLAYER_HISTORY_PAGE } from "@port-of-mars/shared/routes"; import { isDevOrStaging, Constants } from "@port-of-mars/shared/settings"; @Component({ @@ -107,6 +135,7 @@ import { isDevOrStaging, Constants } from "@port-of-mars/shared/settings"; AgeTooltip, CharCarousel, Footer, + LeaderboardTable, }, }) export default class Home extends Vue { @@ -116,6 +145,8 @@ export default class Home extends Vue { isDevMode: boolean = false; currentYear = new Date().getFullYear(); lobby = { name: LOBBY_PAGE }; + leaderboard = { name: LEADERBOARD_PAGE }; + GameStats = { name: PLAYER_HISTORY_PAGE }; get constants() { return Constants; diff --git a/client/src/views/Leaderboard.vue b/client/src/views/Leaderboard.vue new file mode 100644 index 000000000..f66d2e200 --- /dev/null +++ b/client/src/views/Leaderboard.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/client/src/views/PlayerHistory.vue b/client/src/views/PlayerHistory.vue new file mode 100644 index 000000000..13c00cb17 --- /dev/null +++ b/client/src/views/PlayerHistory.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/dev.yml b/dev.yml index 02bba3e49..34ce977f4 100644 --- a/dev.yml +++ b/dev.yml @@ -4,12 +4,13 @@ services: dockerfile: client/Dockerfile context: . restart: always - image: port-of-mars/client/dev:latest + image: port-of-mars/client:dev volumes: - ./client:/code/client - ./keys/sentry_dsn:/run/secrets/sentry_dsn - ./shared/src:/code/shared/src - /code/client/node_modules + - ./.prettierrc:/code/.prettierrc command: './dev.sh' ports: - '127.0.0.1:8081:8080' diff --git a/prod.yml b/prod.yml index 825ba8386..261b3cdfd 100644 --- a/prod.yml +++ b/prod.yml @@ -6,6 +6,4 @@ services: build: args: NODE_ARG: production - image: port-of-mars/server/prod:latest - ports: - - '2567:2567' + image: port-of-mars/server:prod diff --git a/server/src/index.ts b/server/src/index.ts index aa34fc698..98812e628 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -26,6 +26,7 @@ import { registrationRouter, surveyRouter, statusRouter, + statsRouter, } from "@port-of-mars/server/routes"; import { ServerError } from "./util"; @@ -193,6 +194,7 @@ async function createApp() { app.use("/admin", adminRouter); app.use("/auth", authRouter); app.use("/survey", surveyRouter); + app.use("/stats", statsRouter); app.use("/game", gameRouter); app.use("/quiz", quizRouter); app.use("/registration", registrationRouter); diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 76fe2f3f7..d6566e3ac 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -4,6 +4,7 @@ export * from "./admin"; export * from "./auth"; export * from "./game"; +export * from "./stats"; export * from "./middleware"; export * from "./quiz"; export * from "./registration"; diff --git a/server/src/routes/stats.ts b/server/src/routes/stats.ts new file mode 100644 index 000000000..d70cba0d5 --- /dev/null +++ b/server/src/routes/stats.ts @@ -0,0 +1,26 @@ +import { Router } from "express"; +import { getServices } from "@port-of-mars/server/services"; +import { User } from "@port-of-mars/server/entity"; + +export const statsRouter = Router(); + +statsRouter.get("/leaderboard", async (req, res, next) => { + const limitStr = req.query.limit as string; + const limit = parseInt(limitStr) || 50; + try { + const leaderboardData = await getServices().leaderboard.getLeaderboardData(limit); + res.json(leaderboardData); + } catch (e) { + next(e); + } +}); + +statsRouter.get("/history", async (req, res, next) => { + try { + const user = req.user as User; + const playerStats = await getServices().leaderboard.getPlayerHistory(user); + res.json(playerStats); + } catch (e) { + next(e); + } +}); diff --git a/server/src/services/index.ts b/server/src/services/index.ts index e5c6c67b4..2e18dc613 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -7,7 +7,7 @@ import { TournamentService } from "@port-of-mars/server/services/tournament"; import { ScheduleService } from "@port-of-mars/server/services/schedule"; import { QuizService } from "@port-of-mars/server/services/quiz"; import { SurveyService } from "@port-of-mars/server/services/survey"; -import { LeaderboardService } from "@port-of-mars/server/services/leaderboard"; +import { StatsService } from "@port-of-mars/server/services/stats"; import { getConnection } from "@port-of-mars/server/util"; import { TimeService } from "@port-of-mars/server/services/time"; import { GameService } from "@port-of-mars/server/services/game"; @@ -89,10 +89,10 @@ export class ServiceProvider { return this._survey; } - private _leaderboard?: LeaderboardService; + private _leaderboard?: StatsService; get leaderboard() { if (!this._leaderboard) { - this._leaderboard = new LeaderboardService(this); + this._leaderboard = new StatsService(this); } return this._leaderboard; } diff --git a/server/src/services/leaderboard.ts b/server/src/services/leaderboard.ts deleted file mode 100644 index 500e8f4e7..000000000 --- a/server/src/services/leaderboard.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { User } from "@port-of-mars/server/entity/User"; -import { PlayerStatItem } from "@port-of-mars/shared/types"; -import { TournamentRound } from "@port-of-mars/server/entity/TournamentRound"; -import { Game, Player } from "@port-of-mars/server/entity"; -import { BaseService } from "@port-of-mars/server/services/db"; -import { IsNull, Not, SelectQueryBuilder } from "typeorm"; -import _ from "lodash"; - -export class LeaderboardService extends BaseService { - async getStats(user: User, tournamentRound: TournamentRound): Promise> { - const games = await this.em.getRepository(Game).find({ - join: { alias: "games", innerJoin: { players: "games.players" } }, - where: (qb: SelectQueryBuilder) => { - qb.where({ tournamentRound, dateFinalized: Not(IsNull()) }).andWhere( - "players.user.id = :userId", - { userId: user.id } - ); - }, - relations: ["players"], - }); - - const previousGames: Array = games.map(g => { - const maxScore = g.players.reduce((currentMax: number, player: Player) => { - if (player.points ?? 0 > currentMax) { - return player.points ?? 0; - } else { - return currentMax; - } - }, 0); - const playerScores = g.players.map((player: Player) => ({ - role: player.role, - points: player.points ?? 0, - winner: player.points === maxScore, - isSelf: player.userId === user.id, - })); - // sort players by points descending - playerScores.sort((a, b) => b.points - a.points); - - return { - time: g.dateCreated.getTime(), - round: tournamentRound.id, - tournamentName: tournamentRound.tournament.name, - playerScores, - victory: g.status === "victory", - }; - }); - return _.sortBy(previousGames, ["time"], ["desc"]); - } -} diff --git a/server/src/services/stats.ts b/server/src/services/stats.ts new file mode 100644 index 000000000..67126cf3a --- /dev/null +++ b/server/src/services/stats.ts @@ -0,0 +1,104 @@ +import { User } from "@port-of-mars/server/entity/User"; +import { LeaderboardData, PlayerStatItem } from "@port-of-mars/shared/types"; +import { Game, Player } from "@port-of-mars/server/entity"; +import { BaseService } from "@port-of-mars/server/services/db"; +import { IsNull, Not, SelectQueryBuilder } from "typeorm"; + +export class StatsService extends BaseService { + /* Player stats */ + async getGamesWithUser(user: User): Promise> { + return this.em.getRepository(Game).find({ + join: { alias: "games", innerJoin: { players: "games.players" } }, + where: (qb: SelectQueryBuilder) => { + qb.where({ dateFinalized: Not(IsNull()) }).andWhere("players.user.id = :userId", { + userId: user.id, + }); + }, + relations: ["players"], + order: { dateCreated: "DESC" }, + }); + } + + async getPlayerHistory(user: User): Promise> { + const games = await this.getGamesWithUser(user); + + return games.map(g => { + const maxScore = g.players.reduce((currentMax: number, player: Player) => { + if ((player.points ?? 0) > currentMax) { + return player.points ?? 0; + } else { + return currentMax; + } + }, 0); + const playerScores = g.players.map((player: Player) => ({ + role: player.role, + points: player.points ?? 0, + winner: player.points === maxScore, + isSelf: player.userId === user.id, + })); + // sort by role + playerScores.sort((a, b) => a.role.localeCompare(b.role)); + + return { + time: g.dateCreated.getTime(), + round: g.tournamentRoundId, + playerScores, + victory: g.status === "victory", + }; + }); + } + + /* Leaderboard */ + getUserPointsQuery(options: { where: string; limit: number }) { + return this.em + .getRepository(Game) + .createQueryBuilder("game") + .select( + "row_number() over (order by sum(case when game.status = 'victory' then player.points else 0 end) desc)", + "rank" + ) + .addSelect("user.username", "username") + .addSelect("sum(case when game.status = 'victory' then player.points else 0 end)", "points") + .addSelect("count(game.id) filter (where game.status = 'victory')", "wins") + .addSelect("count(game.id) filter (where game.status = 'defeat')", "losses") + .innerJoin("game.players", "player") + .innerJoin("player.user", "user") + .where(options.where) // where clause slot to change the set of considered games + .groupBy("user.id") + .having("sum(case when game.status = 'victory' then player.points else 0 end) IS NOT NULL") + .orderBy("points", "DESC") + .limit(options.limit); + } + + async getLeaderboardWithBots(limit: number) { + // don't include bots in the actual highschore data + const query = this.getUserPointsQuery({ where: "user.isSystemBot = false", limit }); + return query.getRawMany(); + } + + async getLeaderboardWithoutBots(limit: number) { + // sub-query to get all games that don't have any bots + const noBotGamesQuery = this.em + .getRepository(Game) + .createQueryBuilder("game") + .select("game.id") + .innerJoin("game.players", "player") + .innerJoin("player.user", "user") + .where("user.isSystemBot = false") + .groupBy("game.id") + .having("count(*) = 5"); + + const query = this.getUserPointsQuery({ + where: `game.id in ( ${noBotGamesQuery.getQuery()} )`, + limit, + }); + return query.getRawMany(); + } + + async getLeaderboardData(limit: number): Promise { + return { + withBots: await this.getLeaderboardWithBots(limit), + withoutBots: await this.getLeaderboardWithoutBots(limit), + }; + } +} diff --git a/shared/src/routes.ts b/shared/src/routes.ts index 9ebeb180c..299cef9e5 100644 --- a/shared/src/routes.ts +++ b/shared/src/routes.ts @@ -2,6 +2,8 @@ export const ADMIN_PAGE = "Admin" as const; export const LOGIN_PAGE = "Login" as const; export const LOBBY_PAGE = "Lobby" as const; export const GAME_PAGE = "Game" as const; +export const LEADERBOARD_PAGE = "Leaderboard" as const; +export const PLAYER_HISTORY_PAGE = "PlayerHistory" as const; export const REGISTER_PAGE = "Register" as const; export const VERIFY_PAGE = "Verify" as const; export const MANUAL_PAGE = "Manual" as const; @@ -16,6 +18,8 @@ export type Page = | "Login" | "Lobby" | "Game" + | "PlayerHistory" + | "Leaderboard" | "Register" | "Verify" | "Manual" @@ -26,6 +30,8 @@ export const PAGES: Array = [ LOGIN_PAGE, LOBBY_PAGE, GAME_PAGE, + PLAYER_HISTORY_PAGE, + LEADERBOARD_PAGE, REGISTER_PAGE, VERIFY_PAGE, MANUAL_PAGE, @@ -72,6 +78,20 @@ export const PAGE_META: { requiresAuth: true, }, }, + [LEADERBOARD_PAGE]: { + path: "/leaderboard", + name: LEADERBOARD_PAGE, + meta: { + requiresAuth: false, + }, + }, + [PLAYER_HISTORY_PAGE]: { + path: "/history", + name: PLAYER_HISTORY_PAGE, + meta: { + requiresAuth: true, + }, + }, [LOBBY_PAGE]: { path: "/lobby", meta: { diff --git a/shared/src/types.ts b/shared/src/types.ts index a5b83cbc0..99c1b2a05 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -18,6 +18,19 @@ export const BAN = "ban" as const; export const NONE = "none" as const; export const MODERATION_ACTION_TYPES = [MUTE, BAN, NONE]; +export interface LeaderboardItem { + rank: number; + username: string; + points: number; + wins: number; + losses: number; +} + +export interface LeaderboardData { + withBots: Array; + withoutBots: Array; +} + export interface LobbyChatMessageData { username: string; message: string; @@ -341,13 +354,13 @@ export interface ActionItem { export interface GameMetadata { time: number; // unix timestamp round: number; - tournamentName: string; } export type PlayerScores = Array<{ role: Role; points: number; winner: boolean; + isSelf?: boolean; }>; export type PlayerStatItem = GameMetadata & { diff --git a/staging.base.yml b/staging.base.yml deleted file mode 100644 index 830974f71..000000000 --- a/staging.base.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: '3' -services: - db: - image: postgres:12 - volumes: - - ./keys/pom_db_password:/run/secrets/pom_db_password - environment: - POSTGRES_USER: marsmadness - POSTGRES_PASSWORD_FILE: /run/secrets/pom_db_password - POSTGRES_DB: port_of_mars - server: - build: - dockerfile: server/deploy/Dockerfile.prod - args: - NODE_ARG: staging - image: port-of-mars/server/staging:latest diff --git a/staging.yml b/staging.yml index 796757ee2..425d67ffe 100644 --- a/staging.yml +++ b/staging.yml @@ -1,5 +1,17 @@ -version: '3' services: + db: + image: postgres:12 + volumes: + - ./keys/pom_db_password:/run/secrets/pom_db_password + environment: + POSTGRES_USER: marsmadness + POSTGRES_PASSWORD_FILE: /run/secrets/pom_db_password + POSTGRES_DB: port_of_mars server: + build: + dockerfile: server/deploy/Dockerfile.prod + args: + NODE_ARG: staging + image: port-of-mars/server/staging:latest ports: - '2567:2567'