From a97b3cf312c02eed4b5e5755c1f1ca61e2778a71 Mon Sep 17 00:00:00 2001 From: Ben Stein <115497763+sei-bstein@users.noreply.github.com> Date: Thu, 13 Jun 2024 10:31:24 -0400 Subject: [PATCH] v3.19.3 (#186) * Add validation to game editor. * Don't serve host API key in external host responses. * Initial work on game center * Finish batch user create * Add observe links to support tickets. Fix enroll bugs (Admin enroll and loading indicator on error) * Allow VM console urls to be built without a name, since they get a default one now. * Add work for 3.19.3 * Fix decimal precision for questionw eight --- .../src/app/admin/admin.module.ts | 27 +- .../create-users-modal.component.html | 70 +++ .../create-users-modal.component.scss | 0 .../create-users-modal.component.ts | 84 +++ .../game-center-players.component.html | 41 ++ .../game-center-players.component.scss | 0 .../game-center-players.component.ts | 37 ++ .../game-center-settings.component.html | 513 ++++++++++++++++++ .../game-center-settings.component.scss | 37 ++ .../game-center-settings.component.ts | 180 ++++++ ...me-center-team-context-menu.component.html | 80 +++ ...me-center-team-context-menu.component.scss | 0 ...game-center-team-context-menu.component.ts | 99 ++++ .../game-center-tickets.component.html | 22 + .../game-center-tickets.component.scss | 4 + .../game-center-tickets.component.ts | 24 + .../game-center/game-center.component.html | 68 +++ .../game-center/game-center.component.scss | 3 + .../game-center/game-center.component.ts | 37 ++ .../admin/dashboard/dashboard.component.html | 4 + .../game-designer.component.html | 4 - .../game-editor/game-editor.component.html | 14 + .../game-editor/game-editor.component.ts | 32 +- .../game-mapper/game-mapper.component.html | 4 +- .../game-mapper/game-mapper.component.ts | 37 +- .../game-classification-to-string.pipe.ts | 25 + .../player-registrar.component.html | 4 - .../user-registrar.component.html | 7 + .../user-registrar.component.ts | 19 +- .../gameboard-ui/src/app/api/admin.models.ts | 61 ++- .../gameboard-ui/src/app/api/admin.service.ts | 24 +- .../gameboard-ui/src/app/api/game-models.ts | 1 + .../gameboard-ui/src/app/api/game.service.ts | 2 +- .../src/app/api/player.service.ts | 6 +- .../gameboard-ui/src/app/api/spec-models.ts | 18 + .../gameboard-ui/src/app/api/spec.service.ts | 9 +- .../gameboard-ui/src/app/api/user-models.ts | 18 + .../gameboard-ui/src/app/api/user.service.ts | 11 +- .../src/app/app-routing.module.ts | 1 + .../game-card-image.component.ts | 2 +- .../modal-content.component.html | 2 +- .../modal-content/modal-content.component.ts | 2 +- .../gameboard-ui/src/app/game/game.module.ts | 12 +- .../player-enroll.component.html | 2 +- .../player-enroll/player-enroll.component.ts | 2 + .../app/home/landing/landing.component.html | 20 +- .../src/app/home/landing/landing.component.ts | 8 +- .../practice-challenge-list.component.html | 2 +- .../challenge-or-game-field.component.html | 6 +- .../challenge-or-game-field.component.ts | 1 + .../challenges-report.component.html | 23 +- .../challenges-report.component.ts | 14 +- ...-question-performance-modal.component.html | 46 ++ ...-question-performance-modal.component.scss | 4 + ...ec-question-performance-modal.component.ts | 22 + .../src/app/reports/reports.module.ts | 4 +- ...coreboard-team-detail-modal.component.html | 0 ...coreboard-team-detail-modal.component.scss | 0 .../scoreboard-team-detail-modal.component.ts | 0 .../scoreboard/scoreboard.component.html | 0 .../scoreboard/scoreboard.component.scss | 0 .../scoreboard/scoreboard.component.ts | 5 +- .../pipes/challenge-bonuses-to-tooltip.ts} | 0 .../pipes/score-to-tooltip.pipe.ts | 0 .../src/app/scoreboard/scoreboard.module.ts | 27 + .../src/app/services/router.service.ts | 14 +- .../ticket-support-tools.component.ts | 22 + .../ticket-list/ticket-list.component.html | 5 +- projects/gameboard-ui/src/styles.scss | 18 + 69 files changed, 1796 insertions(+), 94 deletions(-) create mode 100644 projects/gameboard-ui/src/app/admin/components/create-users-modal/create-users-modal.component.html create mode 100644 projects/gameboard-ui/src/app/admin/components/create-users-modal/create-users-modal.component.scss create mode 100644 projects/gameboard-ui/src/app/admin/components/create-users-modal/create-users-modal.component.ts create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center-players/game-center-players.component.html create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center-players/game-center-players.component.scss create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center-players/game-center-players.component.ts create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center-settings/game-center-settings.component.html create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center-settings/game-center-settings.component.scss create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center-settings/game-center-settings.component.ts create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.html create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.scss create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.ts create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center-tickets/game-center-tickets.component.html create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center-tickets/game-center-tickets.component.scss create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center-tickets/game-center-tickets.component.ts create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center.component.html create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center.component.scss create mode 100644 projects/gameboard-ui/src/app/admin/components/game-center/game-center.component.ts create mode 100644 projects/gameboard-ui/src/app/admin/pipes/game-classification-to-string.pipe.ts create mode 100644 projects/gameboard-ui/src/app/reports/components/spec-question-performance-modal/spec-question-performance-modal.component.html create mode 100644 projects/gameboard-ui/src/app/reports/components/spec-question-performance-modal/spec-question-performance-modal.component.scss create mode 100644 projects/gameboard-ui/src/app/reports/components/spec-question-performance-modal/spec-question-performance-modal.component.ts rename projects/gameboard-ui/src/app/{game => scoreboard}/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.html (100%) rename projects/gameboard-ui/src/app/{game => scoreboard}/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.scss (100%) rename projects/gameboard-ui/src/app/{game => scoreboard}/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.ts (100%) rename projects/gameboard-ui/src/app/{game => scoreboard}/components/scoreboard/scoreboard.component.html (100%) rename projects/gameboard-ui/src/app/{game => scoreboard}/components/scoreboard/scoreboard.component.scss (100%) rename projects/gameboard-ui/src/app/{game => scoreboard}/components/scoreboard/scoreboard.component.ts (93%) rename projects/gameboard-ui/src/app/{game/pipes/manual-bonuses-to-tooltip.pipe.ts => scoreboard/pipes/challenge-bonuses-to-tooltip.ts} (100%) rename projects/gameboard-ui/src/app/{game => scoreboard}/pipes/score-to-tooltip.pipe.ts (100%) create mode 100644 projects/gameboard-ui/src/app/scoreboard/scoreboard.module.ts diff --git a/projects/gameboard-ui/src/app/admin/admin.module.ts b/projects/gameboard-ui/src/app/admin/admin.module.ts index 2b2761df..697937ee 100644 --- a/projects/gameboard-ui/src/app/admin/admin.module.ts +++ b/projects/gameboard-ui/src/app/admin/admin.module.ts @@ -8,6 +8,7 @@ import { RouterModule } from '@angular/router'; import { ApiModule } from '../api/api.module'; import { CoreModule } from '../core/core.module'; +import { ScoreboardModule } from '@/scoreboard/scoreboard.module'; import { SponsorsModule } from '@/sponsors/sponsors.module'; import { UtilityModule } from '../utility/utility.module'; @@ -19,6 +20,7 @@ import { ChallengeBrowserComponent } from './challenge-browser/challenge-browser import { ChallengeObserverComponent } from './challenge-observer/challenge-observer.component'; import { ChallengeReportComponent } from './challenge-report/challenge-report.component'; import { ChallengeSpecEditorComponent } from './components/challenge-spec-editor/challenge-spec-editor.component'; +import { CreateUsersModalComponent } from './components/create-users-modal/create-users-modal.component'; import { DashboardComponent } from './dashboard/dashboard.component'; import { ExternalGameAdminComponent } from './components/external-game-admin/external-game-admin.component'; import { ExternalGameAdminPlayerContextMenuComponent } from './components/external-game-admin-player-context-menu/external-game-admin-player-context-menu.component'; @@ -30,6 +32,8 @@ import { ExternalSpecIdToChallengePipe } from './pipes/external-specid-to-challe import { ExternalGamePlayerStatusToFriendlyPipe } from './pipes/external-game-player-status-to-friendly.pipe'; import { FeedbackReportComponent } from './feedback-report/feedback-report.component'; import { GameBonusesConfigComponent } from './components/game-bonuses-config/game-bonuses-config.component'; +import { GameCenterComponent } from './components/game-center/game-center.component'; +import { GameClassificationToStringPipe } from './pipes/game-classification-to-string.pipe'; import { GameDesignerComponent } from './game-designer/game-designer.component'; import { GameEditorComponent } from './game-editor/game-editor.component'; import { GameMapperComponent } from './game-mapper/game-mapper.component'; @@ -66,6 +70,10 @@ import { SyncStartGameStateDescriptionPipe } from './pipes/sync-start-game-state import { ExternalGameHostPickerComponent } from './components/external-game-host-picker/external-game-host-picker.component'; import { ExternalHostEditorComponent } from './components/external-host-editor/external-host-editor.component'; import { DeleteExternalGameHostModalComponent } from './components/delete-external-game-host-modal/delete-external-game-host-modal.component'; +import { GameCenterPlayersComponent } from './components/game-center/game-center-players/game-center-players.component'; +import { GameCenterTeamContextMenuComponent } from './components/game-center/game-center-team-context-menu/game-center-team-context-menu.component'; +import { GameCenterSettingsComponent } from './components/game-center/game-center-settings/game-center-settings.component'; +import { GameCenterTicketsComponent } from './components/game-center/game-center-tickets/game-center-tickets.component'; @NgModule({ declarations: [ @@ -76,6 +84,7 @@ import { DeleteExternalGameHostModalComponent } from './components/delete-extern ChallengeObserverComponent, ChallengeReportComponent, ChallengeSpecEditorComponent, + CreateUsersModalComponent, DashboardComponent, ExternalGameAdminComponent, ExternalGameAdminPlayerContextMenuComponent, @@ -86,6 +95,8 @@ import { DeleteExternalGameHostModalComponent } from './components/delete-extern ExternalTeamToChallengeCreatedPipe, ExternalTeamChallengesToIsPredeployablePipe, FeedbackReportComponent, + GameCenterComponent, + GameClassificationToStringPipe, GameDesignerComponent, GameEditorComponent, GameMapperComponent, @@ -121,6 +132,10 @@ import { DeleteExternalGameHostModalComponent } from './components/delete-extern ExternalGameHostPickerComponent, ExternalHostEditorComponent, DeleteExternalGameHostModalComponent, + GameCenterPlayersComponent, + GameCenterTeamContextMenuComponent, + GameCenterSettingsComponent, + GameCenterTicketsComponent, ], imports: [ CommonModule, @@ -132,7 +147,16 @@ import { DeleteExternalGameHostModalComponent } from './components/delete-extern { path: '', pathMatch: 'full', redirectTo: 'dashboard' }, { path: 'dashboard', component: DashboardComponent }, { path: 'designer/:id', component: GameEditorComponent }, - { path: "game/:gameId/external", component: ExternalGameAdminComponent }, + { + path: 'game/:gameId', + component: GameCenterComponent, + children: [ + { path: "teams", component: PlayerRegistrarComponent } + ] + }, + { + path: "game/:gameId/external", pathMatch: 'full', component: ExternalGameAdminComponent + }, { path: "practice", component: PracticeComponent, children: [ { path: "", pathMatch: "full", redirectTo: "settings" }, @@ -161,6 +185,7 @@ import { DeleteExternalGameHostModalComponent } from './components/delete-extern CoreModule, ApiModule, UtilityModule, + ScoreboardModule, SponsorsModule, SystemNotificationsModule, ] diff --git a/projects/gameboard-ui/src/app/admin/components/create-users-modal/create-users-modal.component.html b/projects/gameboard-ui/src/app/admin/components/create-users-modal/create-users-modal.component.html new file mode 100644 index 00000000..3ee4fe9f --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/create-users-modal/create-users-modal.component.html @@ -0,0 +1,70 @@ + +
+ Enter space-delimited user GUIDs (globally unique identifiers) in the textbox below to create a new + {{appName}} user account for each. You can use the settings below to control some initial settings for + the created users. +
+ +
+ + + + {{appName}} user IDs may only contain the letters A through F (in upper or lowercase), + hyphens, and digits. The following IDs can't be used: + +
    +
  • {{invalidId}}
  • +
+
+ +
+

Settings

+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+
+ {{userIds.length}} + {{ "user" | pluralizer:userIds.length - invalidIds.length }} will be created. +
+
+
+ + + Loading... + diff --git a/projects/gameboard-ui/src/app/admin/components/create-users-modal/create-users-modal.component.scss b/projects/gameboard-ui/src/app/admin/components/create-users-modal/create-users-modal.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/projects/gameboard-ui/src/app/admin/components/create-users-modal/create-users-modal.component.ts b/projects/gameboard-ui/src/app/admin/components/create-users-modal/create-users-modal.component.ts new file mode 100644 index 00000000..b3de4bd0 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/create-users-modal/create-users-modal.component.ts @@ -0,0 +1,84 @@ +import { Component } from '@angular/core'; +import { UserService } from '@/api/user.service'; +import { ConfigService } from '@/utility/config.service'; +import { TryCreateUsersResponse } from '@/api/user-models'; +import { SponsorService } from '@/api/sponsor.service'; +import { SponsorWithChildSponsors } from '@/api/sponsor-models'; +import { firstValueFrom } from 'rxjs'; +import { GameService } from '@/api/game.service'; +import { SimpleEntity } from '@/api/models'; + +@Component({ + selector: 'app-create-users-modal', + templateUrl: './create-users-modal.component.html', + styleUrls: ['./create-users-modal.component.scss'] +}) +export class CreateUsersModalComponent { + onCreated?: (response: TryCreateUsersResponse) => void | Promise; + + protected allowSubsetCreation = false; + protected createWithSponsorId?: string; + protected unsetDefaultSponsorFlag = false; + + protected appName: string; + protected enrollInGameId?: string; + protected games: SimpleEntity[] = []; + protected hasInvalidIds = false; + protected invalidIds: string[] = []; + protected isWorking = false; + protected placeholder: string; + protected rawText: string = ""; + protected sponsors: SponsorWithChildSponsors[] = []; + protected userIds: string[] = []; + + private invalidIdsRegex = /[a-fA-F0-9-]{2,}/; + private onePerLineRegex = /\s+/gm; + + constructor( + config: ConfigService, + private gameService: GameService, + private sponsorService: SponsorService, + private usersService: UserService) { + this.appName = config.appName; + this.placeholder = "// one ID per line, e.g.:\n\n3496da07-d19e-440d-a246-e35f7b7bfcac\n9a53d8cd-ef88-44c0-96b2-fc8766b518dd\n\n//and so on"; + } + + async ngOnInit() { + this.isWorking = true; + this.games = (await firstValueFrom(this.gameService.list({ "orderBy": "name" }))).map(game => ({ + id: game.id, + name: game.name + })); + this.sponsors = await firstValueFrom(this.sponsorService.listWithChildren()); + this.isWorking = false; + } + + async confirm() { + this.isWorking = true; + const result = await this.usersService.tryCreateMany({ + allowSubsetCreation: this.allowSubsetCreation, + enrollInGameId: this.enrollInGameId, + sponsorId: this.createWithSponsorId, + unsetDefaultSponsorFlag: this.unsetDefaultSponsorFlag, + userIds: this.userIds + }); + this.isWorking = false; + + if (this.onCreated) + this.onCreated(result); + } + + protected handleTextInput() { + this.userIds = this.rawText + .split(this.onePerLineRegex) + .map(entry => entry.trim()) + .filter(entry => entry.length > 2); + + this.invalidIds = []; + for (const id of this.userIds) { + if (!id.match(this.invalidIdsRegex)) { + this.invalidIds.push(id); + } + } + } +} diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-players/game-center-players.component.html b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-players/game-center-players.component.html new file mode 100644 index 00000000..73cf064f --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-players/game-center-players.component.html @@ -0,0 +1,41 @@ + + + + + + + + + +

No players match your search.

+
+ + + Finding players... + diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-players/game-center-players.component.scss b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-players/game-center-players.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-players/game-center-players.component.ts b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-players/game-center-players.component.ts new file mode 100644 index 00000000..53e27096 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-players/game-center-players.component.ts @@ -0,0 +1,37 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { GameCenterTeamsResults } from '@/api/admin.models'; +import { AdminService } from '@/api/admin.service'; +import { Game } from '@/api/game-models'; +import { GameService } from '@/api/game.service'; +import { firstValueFrom } from 'rxjs'; + +@Component({ + selector: 'app-game-center-players', + templateUrl: './game-center-players.component.html', + styleUrls: ['./game-center-players.component.scss'] +}) +export class GameCenterPlayersComponent implements OnInit { + @Input() gameId?: string; + + protected game?: Game; + protected isLoading = false; + protected results?: GameCenterTeamsResults; + + constructor( + private adminService: AdminService, + private gameService: GameService) { } + + async ngOnInit(): Promise { + if (!this.gameId) + throw new Error("Component requires a gameId"); + + this.game = await firstValueFrom(this.gameService.retrieve(this.gameId)); + await this.load(); + } + + private async load() { + this.isLoading = true; + this.results = await this.adminService.getGameCenterTeams(this.gameId!); + this.isLoading = false; + } +} diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-settings/game-center-settings.component.html b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-settings/game-center-settings.component.html new file mode 100644 index 00000000..dfa81797 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-settings/game-center-settings.component.html @@ -0,0 +1,513 @@ +
+ +

+ {{game.name}} + + + +

+ {{game.id}} + + + Lobby + + +
+ + Metadata +
+ +
+ +
+
+ + + event name (i.e. Open, Semis, Finals) +
+ +
+ +
+ + + +
+ +
+ +
+ +
+ + + +
+ Featured games always show at the top of the homepage +
+ +
+ + + short, unique key distinguishing this event +
+ +
+ + + + + links this event to others in the series +
+ +
+ + + + + distinguish which track of the series +
+ +
+ + + + + links this event to others in this series season +
+ +
+ + + + + specify the intended audience level (i.e. Open, Amateur, Pro, etc.) +
+
+ +
+
+ +

+ Select an image for the game card shown in the public-facing portion of the app. For best + results, use an + image with an aspect ratio of 1:1.44 (e.g. 750x1080). +

+ + + +
+ +
+
+
+ +
+ + + + + text displayed at the top of the card +
+ +
+ + + + + text displayed in the middle of the card +
+ +
+ + + + + text displayed at the bottom of the card +
+
+ +
+
+ + +
+
+ +
+ +
+ +
+
+ + + design with HTML and inline/internal CSS; use a 11:8.5 aspect ratio + +
+

+ Insert dynamic content by referring to a property with double-curly syntax + {{"\{\{game_name\}\}"}}. For example, + <h1>{{"\{\{leaderboard_name\}\}"}}</h1>.
+ The following properties will get replaced when a player certificate renders: +

+
+      game_name — Name of this game
+      competition — Competition type of this game
+      season — Season of this game
+      round — Round of this game
+      track — Track of this game
+      user_name — Individual user's approved name
+      score — Total player score for this game
+      rank — Final leaderboard ranking of the player
+      leaderboard_name — Approved name for either team or individual
+      date — Date player's session ended for this game
+      player_count — Number of players who participated in this game
+      team_count — Number of teams who participated in this game
+

+ Tip: Create an outer div with fixed height and width and position: relative. + Create inner + divs with position: absolute; text-align: center; and set textbox width and X/Y + position with + top: __px; left: __px; width: __px;. + To add a background image, use + background-size: 100% 100%; background-image: url('URL_HERE'); +

+
+
+
+
+ +
+ + Modes +
+ +
+
+ +
+ + + +
+
+ +
+ +
+ + + +
+ + If enabled, this game's card will be visible on the homepage even when it's in Practice mode. + +
+ +
+ +
+ + + +
+ + If Synchronized Start is enabled, all players will need to ready up before playing. The session will + start + automatically when everyone is ready. + +
+ +
+ + + Does the game use standard VMs, or is it played on an external host? +
+ +
+ + +
+
+ +
+ + Settings +
+ +
+ + +
+

Execution

+
+ +
+ + + yyyy-mm-ddT00:00:00+00:00 +
+ +
+ + + yyyy-mm-ddT00:00:00+00:00 +
+ +
+ +
+ The game's open date must be less than its close date. +
+ +
+ + + duration of game session in minutes +
+ +
+ + + total concurrent sessions allowed for game +
+ +
+ + + max gamespaces per session +
+ +
+ + + max grading attempts +
+ +
+ +
+ + + +
+ shows documentation and challenge to player prior to Starting it +
+ +
+ +
+ + + +
+ permission for players to reset their game session +
+ +
+ +
+ + + +
+ allows players to start within a session length of the execution period end +
+ +
+ +
+ + + +
+ allows players to view the complete scoreboard after the game ends +
+
+ +
+

Registration

+
+
+
+ + + +
+ +
+ +
+ +
+ + + email domain list for RegistrationType.Domain +
+ +
+
+ + + yyyy-mm-ddT00:00:00+00:00 + +
+ +
+ + + yyyy-mm-ddT00:00:00+00:00 +
+
+ +
+
+ + The registration period's open date must be prior to its close date. + +
+
+ +
+
+ + + +
+ +
+ + + +
+
+
+ +
+
+ + The minimum team size must be less than (or equal to) the maximum team size. + +
+ +
+ + The minimum team size must be a positive integer. + +
+
+ +
+ +
+ + + +
+ when required, team members must have the same sponsor +
+ +
+ + + presented and approved when registering for this game +
+
+
+
+
+ + + Loading the game... + diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-settings/game-center-settings.component.scss b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-settings/game-center-settings.component.scss new file mode 100644 index 00000000..ea5216ab --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-settings/game-center-settings.component.scss @@ -0,0 +1,37 @@ +@import "../../../../../scss/variables"; + +label { + margin-bottom: 0; +} + +.dropzone { + height: 360px; + width: 240px; +} + +.dropzone-target { + border: dashed 1px $foreground; +} + +.section-header { + font-weight: 300; + cursor: pointer; + + span { + font-size: 2rem; + } +} + +.section { + margin-bottom: 2rem; +} + +.cert-info { + font-size: 87.5%; +} + +.help-text { + font-style: italic; + font-size: 0.8em; + margin-top: 0.5rem; +} diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-settings/game-center-settings.component.ts b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-settings/game-center-settings.component.ts new file mode 100644 index 00000000..e82a567f --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-settings/game-center-settings.component.ts @@ -0,0 +1,180 @@ +import { FeedbackTemplate } from '@/api/feedback-models'; +import { ExternalGameHost, Game, GameEngineMode, GameRegistrationType } from '@/api/game-models'; +import { GameService } from '@/api/game.service'; +import { fa } from '@/services/font-awesome.service'; +import { PracticeService } from '@/services/practice.service'; +import { UnsubscriberService } from '@/services/unsubscriber.service'; +import { YamlService } from '@/services/yaml.service'; +import { ToastService } from '@/utility/services/toast.service'; +import { KeyValue } from '@angular/common'; +import { AfterViewInit, Component, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core'; +import { FormGroup, NgForm } from '@angular/forms'; +import { debounceTime, filter, firstValueFrom, switchMap, tap } from 'rxjs'; + +export type SelectedSubTab = "settings" | "modes" | "registration"; + +@Component({ + selector: 'app-game-center-settings', + templateUrl: './game-center-settings.component.html', + styleUrls: ['./game-center-settings.component.scss'] +}) +export class GameCenterSettingsComponent implements AfterViewInit, OnChanges { + @Input() gameId?: string; + @ViewChild(NgForm) form?: FormGroup; + + private needsPracticeModeEnabledRefresh = false; + + protected fa = fa; + protected game?: Game; + protected isDirty = false; + protected selectedSubTab: SelectedSubTab = "settings"; + protected showCertificateInfo = false; + protected suggestions = { + competition: new Map(), + track: new Map(), + season: new Map(), + division: new Map(), + cardText1: new Map(), + cardText2: new Map(), + cardText3: new Map() + }; + + constructor( + private gameService: GameService, + private practiceService: PracticeService, + private toastService: ToastService, + private unsub: UnsubscriberService, + private yamlService: YamlService) { } + + ngAfterViewInit(): void { + if (!this.form) + throw new Error("Couldn't resolve the editor form."); + + this.unsub.add( + this.form.valueChanges.pipe( + filter(f => !!this.form && !this.form.pristine && (this.form.valid || false) && !!this.game && this.doAdditionalValidation(f)), + tap(values => { + this.isDirty = true; + this.needsPracticeModeEnabledRefresh = values.playerMode !== this.game!.playerMode; + }), + debounceTime(500), + switchMap(g => this.gameService.update(this.game!)), + tap(r => this.isDirty = false), + filter(f => this.needsPracticeModeEnabledRefresh), + switchMap(g => this.gameService.retrieve(this.game!.id).pipe( + tap(game => { + if (this.needsPracticeModeEnabledRefresh) { + this.practiceService.gamePlayerModeChanged({ gameId: game.id, isPractice: game.isPracticeMode }); + this.needsPracticeModeEnabledRefresh = false; + } + })) + ) + ).subscribe() + ); + } + + async ngOnChanges(changes: SimpleChanges) { + if (!this.gameId || this.gameId === this.game?.id) + return; + + const games = await firstValueFrom(this.gameService.list({})); + for (const game of games) { + this.countGameField(this.suggestions.competition, game.competition); + this.countGameField(this.suggestions.track, game.track); + this.countGameField(this.suggestions.season, game.season); + this.countGameField(this.suggestions.division, game.division); + this.countGameField(this.suggestions.cardText1, game.cardText1); + this.countGameField(this.suggestions.cardText1, game.cardText1); + this.countGameField(this.suggestions.cardText1, game.cardText1); + } + + this.game = games.find(g => g.id === this.gameId); + } + + async handleExternalGameHostChanged(host: ExternalGameHost) { + if (!this.game) + throw new Error("Game is required"); + + if (this.game.mode == "external" && this.game.externalHostId != host.id) { + this.game.externalHostId = host.id; + await firstValueFrom(this.gameService.update(this.game)); + this.toastService.showMessage(`Changed to host **${host.name}**`); + } + } + + protected async handleFeedbackTemplateChange(template?: FeedbackTemplate) { + if (!this.game) + throw new Error("Game is required"); + + if (template) { + this.game.feedbackConfig = this.yamlService.render(template); + this.game.feedbackTemplate = template; + } + else { + this.game.feedbackConfig = ""; + this.game.feedbackTemplate = undefined; + } + + await firstValueFrom(this.gameService.update(this.game)); + } + + protected async handleModeChange(event: Event) { + if (!this.game) + throw new Error("Game is required."); + + const gameMode = ((event?.target as any).value as GameEngineMode); + this.game.mode = gameMode; + + if (this.game.mode != "external") + this.game.externalHostId = undefined; + + await firstValueFrom(this.gameService.update(this.game)); + } + + protected async clearImage() { + if (!this.game) + throw new Error("Game is required"); + + await this.gameService.deleteGameCardImage(this.game.id); + this.game.logo = ""; + } + + protected sortByCount(a: KeyValue, b: KeyValue) { + // order DESC by occurrence count + if (a.value < b.value) return 1; + if (a.value > b.value) return -1; + + // order ASC alphabetically by name for occurrence tie + if (a.key < b.key) return -1; + if (a.key > b.key) return 1; + return 0; + } + + protected upload(files: File[], type: string): void { + if (!this.game) + throw new Error("Game is required"); + + this.gameService.uploadImage(this.game.id, type, files[0]).subscribe( + r => this.game!.logo = r.filename + ); + } + + private countGameField(fieldMap: Map, value: string) { + // if field value not blank, increment occurrence count by 1 + if (!!value) + fieldMap.set(value, (fieldMap.get(value) ?? 0) + 1); + } + + private doAdditionalValidation(game: Game) { + if (game.minTeamSize > game.maxTeamSize) + return false; + + if (game.gameStart > game.gameEnd) + return false; + + if (game.registrationType == GameRegistrationType.open && game.registrationOpen > game.registrationClose) + return false; + + return true; + } +} diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.html b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.html new file mode 100644 index 00000000..a4f7fd48 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.html @@ -0,0 +1,80 @@ +
+ + +
+ + + + + +
  • + +
  • +
    diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.scss b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.ts b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.ts new file mode 100644 index 00000000..8f91c96f --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-team-context-menu/game-center-team-context-menu.component.ts @@ -0,0 +1,99 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { TeamSessionResetType } from '@/api/teams.models'; +import { GameCenterTeamsSession } from '@/api/admin.models'; +import { SimpleEntity } from '@/api/models'; +import { fa } from '@/services/font-awesome.service'; +import { ClipboardService } from '@/utility/services/clipboard.service'; +import { ToastService } from '@/utility/services/toast.service'; +import { GameService } from '@/api/game.service'; +import { firstValueFrom } from 'rxjs'; +import { SyncStartService } from '@/services/sync-start.service'; +import { PlayerService } from '@/api/player.service'; +import { ModalConfirmService } from '@/services/modal-confirm.service'; +import { ManageManualChallengeBonusesModalComponent } from '../../manage-manual-challenge-bonuses-modal/manage-manual-challenge-bonuses-modal.component'; +import { TeamService } from '@/api/team.service'; + +export interface TeamSessionResetRequest { + teamId: string; + resetType: TeamSessionResetType; +} + +@Component({ + selector: 'app-game-center-team-context-menu', + templateUrl: './game-center-team-context-menu.component.html', + styleUrls: ['./game-center-team-context-menu.component.scss'] +}) +export class GameCenterTeamContextMenuComponent { + @Input() game?: { id: string; name: string; isSyncStart: boolean; }; + @Input() team?: { + id: string; + name: string; + captain: SimpleEntity; + isReady: boolean; + session: GameCenterTeamsSession; + }; + @Output() teamUpdated = new EventEmitter(); + + protected fa = fa; + protected hasStartedSession = false; + + + constructor( + private clipboard: ClipboardService, + private gameService: GameService, + private modalService: ModalConfirmService, + private playerService: PlayerService, + private syncStartService: SyncStartService, + private teamService: TeamService, + private toastService: ToastService) { } + + ngOnInit(): void { + if (!this.game?.id) + throw new Error("No gameId provided"); + if (!this.team) + throw new Error("No team provided"); + + this.hasStartedSession = !!this.team.session.start; + } + + async copy(text: string, description: string) { + await this.clipboard.copy(text); + this.toastService.showMessage(`Copied ${description} **${text}** to your clipboard.`); + } + + async handleDeployResources(team: SimpleEntity) { + await this.gameService.deployResources(this.game!.id, [team.id]); + this.toastService.showMessage(`Resources are being deployed for **${team.name}**.`); + } + + async handleManageBonuses(team: SimpleEntity) { + this.modalService.openComponent({ + content: ManageManualChallengeBonusesModalComponent, + context: { + teamId: team.id + }, + }); + } + + async handleResetRequest(team: SimpleEntity, type: TeamSessionResetType) { + await firstValueFrom(this.teamService.resetSession({ teamId: team.id, resetType: type })); + this.teamUpdated.emit(team); + } + + async handleUpdateReady(team: SimpleEntity, isReady: boolean) { + await firstValueFrom(this.syncStartService.updateTeamReadyState(team.id, { isReady })); + this.teamUpdated.emit(team); + + this.toastService.showMessage( + isReady ? + `**${team.name}** has been readied.` : + `**${team.name}**'s is no longer ready.` + ); + } + + async handleStartSession(team: SimpleEntity) { + await firstValueFrom(this.playerService.startPlayerId(this.team!.captain.id)); + this.teamUpdated.emit(team); + this.toastService.showMessage(`Session started for **${team.name}**`); + } +} diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-tickets/game-center-tickets.component.html b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-tickets/game-center-tickets.component.html new file mode 100644 index 00000000..2d5b522e --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-tickets/game-center-tickets.component.html @@ -0,0 +1,22 @@ + + + + + + Looking for tickets... + + + +

    No tickets have been created for this game. Nice!

    +
    diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-tickets/game-center-tickets.component.scss b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-tickets/game-center-tickets.component.scss new file mode 100644 index 00000000..51d6d989 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-tickets/game-center-tickets.component.scss @@ -0,0 +1,4 @@ +a { + text-decoration: none; + color: unset; +} diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center-tickets/game-center-tickets.component.ts b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-tickets/game-center-tickets.component.ts new file mode 100644 index 00000000..d2e4207e --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center-tickets/game-center-tickets.component.ts @@ -0,0 +1,24 @@ +import { Ticket, TicketSummary } from '@/api/support-models'; +import { SupportService } from '@/api/support.service'; +import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; + +@Component({ + selector: 'app-game-center-tickets', + templateUrl: './game-center-tickets.component.html', + styleUrls: ['./game-center-tickets.component.scss'] +}) +export class GameCenterTicketsComponent implements OnChanges { + @Input() gameId!: string; + + protected isLoading = false; + protected tickets: TicketSummary[] = []; + + constructor(private supportService: SupportService) { } + + async ngOnChanges(changes: SimpleChanges): Promise { + this.isLoading = true; + this.tickets = await firstValueFrom(this.supportService.list({ orderItem: 'key', gameId: this.gameId })); + this.isLoading = false; + } +} diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center.component.html b/projects/gameboard-ui/src/app/admin/components/game-center/game-center.component.html new file mode 100644 index 00000000..38f99054 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center.component.html @@ -0,0 +1,68 @@ + +
    + +
    +

    {{ ctx.name }}

    + +

    + {{ ctx.isExternal ? "External" : "Standard" }} + {{ ctx.isPractice ? "Practice" : "Competitive" }} + {{ ctx.isTeamGame ? "Team" : "Individual" }} + Game +

    + +

    + {{ ctx | gameClassificationToString }} +

    + +

    + {{ ctx.executionWindow.start | friendlyDateAndTime }} + — + {{ ctx.executionWindow.end | friendlyDateAndTime }} +

    + +
    +
    + Registration Available +
    +
    Live
    +
    +
    +
    + + + NOTE: This feature is under active development, and some things might not as expected just + yet. Stay tuned as we continue to improve the game management process! 🚀 + + +
    + + + + + + + + + + + + + + +

    This feature is in development. Check back soon!

    +
    + + + +
    + +
    + +
    +
    +
    + + + Loading the game... + diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center.component.scss b/projects/gameboard-ui/src/app/admin/components/game-center/game-center.component.scss new file mode 100644 index 00000000..5524c5dd --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center.component.scss @@ -0,0 +1,3 @@ +app-game-card-image { + max-width: 120px; +} diff --git a/projects/gameboard-ui/src/app/admin/components/game-center/game-center.component.ts b/projects/gameboard-ui/src/app/admin/components/game-center/game-center.component.ts new file mode 100644 index 00000000..71f9f7e3 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/components/game-center/game-center.component.ts @@ -0,0 +1,37 @@ +import { GameCenterContext } from '@/api/admin.models'; +import { AdminService } from '@/api/admin.service'; +import { Game } from '@/api/game-models'; +import { GameService } from '@/api/game.service'; +import { UnsubscriberService } from '@/services/unsubscriber.service'; +import { Component } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { firstValueFrom } from 'rxjs'; + +@Component({ + selector: 'app-game-center', + templateUrl: './game-center.component.html', + styleUrls: ['./game-center.component.scss'], + providers: [UnsubscriberService] +}) +export class GameCenterComponent { + protected game?: Game; + protected gameCenterCtx?: GameCenterContext; + + constructor( + route: ActivatedRoute, + unsub: UnsubscriberService, + private adminService: AdminService, + private gameService: GameService) { + unsub.add( + route.paramMap.subscribe(paramMap => this.load(paramMap.get("gameId"))) + ); + } + + private async load(gameId: string | null) { + if (gameId === null || gameId == this.game?.id) + return; + + this.game = await firstValueFrom(this.gameService.retrieve(gameId)); + this.gameCenterCtx = await this.adminService.getGameCenterContext(gameId); + } +} diff --git a/projects/gameboard-ui/src/app/admin/dashboard/dashboard.component.html b/projects/gameboard-ui/src/app/admin/dashboard/dashboard.component.html index d2a6ef9e..a9783e99 100644 --- a/projects/gameboard-ui/src/app/admin/dashboard/dashboard.component.html +++ b/projects/gameboard-ui/src/app/admin/dashboard/dashboard.component.html @@ -73,6 +73,10 @@
    -
    Drag in a yaml game array
    @@ -42,10 +41,7 @@ -
    - -
    diff --git a/projects/gameboard-ui/src/app/admin/game-editor/game-editor.component.html b/projects/gameboard-ui/src/app/admin/game-editor/game-editor.component.html index b072ac36..d6b15707 100644 --- a/projects/gameboard-ui/src/app/admin/game-editor/game-editor.component.html +++ b/projects/gameboard-ui/src/app/admin/game-editor/game-editor.component.html @@ -50,6 +50,20 @@

    +
    + +
    + + + +
    + Featured games always show at the top of the homepage +
    +
    diff --git a/projects/gameboard-ui/src/app/admin/game-editor/game-editor.component.ts b/projects/gameboard-ui/src/app/admin/game-editor/game-editor.component.ts index 824adf6f..0a4187c7 100644 --- a/projects/gameboard-ui/src/app/admin/game-editor/game-editor.component.ts +++ b/projects/gameboard-ui/src/app/admin/game-editor/game-editor.component.ts @@ -1,11 +1,11 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. -import { AfterViewInit, Component, Input, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, Input, OnChanges, SimpleChanges, ViewChild } from '@angular/core'; import { FormGroup, NgForm } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { Observable, firstValueFrom } from 'rxjs'; -import { debounceTime, filter, map, switchMap, tap } from 'rxjs/operators'; +import { debounceTime, filter, first, map, switchMap, tap } from 'rxjs/operators'; import { ExternalGameHost, Game, GameEngineMode, GameRegistrationType } from '../../api/game-models'; import { GameService } from '../../api/game.service'; import { KeyValue } from '@angular/common'; @@ -22,7 +22,7 @@ import { YamlService } from '@/services/yaml.service'; templateUrl: './game-editor.component.html', styleUrls: ['./game-editor.component.scss'] }) -export class GameEditorComponent implements AfterViewInit { +export class GameEditorComponent implements AfterViewInit, OnChanges { @Input() game!: Game; @ViewChild(NgForm) form!: FormGroup; @@ -58,26 +58,25 @@ export class GameEditorComponent implements AfterViewInit { ) { // one-time get list of all games for field suggestions - api.list({}).subscribe( - games => this.addSuggestions(games) - ); + api.list({}).pipe(first()).subscribe(games => this.addSuggestions(games)); this.game$ = route.params.pipe( map(p => p.id), filter(id => !!id), switchMap(id => api.retrieve(id)), tap(g => { - this.game = g; this.title.set(`Edit "${g.name}"`); - - // ensure that the feedback template yaml string is consistent with the configuration - if (this.game.feedbackConfig) { - this.game.feedbackTemplate = this.yamlService.parse(this.game.feedbackConfig); - } + this.loadGame(g); }) ); } + ngOnChanges(changes: SimpleChanges): void { + if (changes.game?.currentValue?.id !== this.game.id) { + this.loadGame(this.game); + } + } + ngAfterViewInit(): void { this.updated$ = this.form.valueChanges.pipe( filter(f => !this.form.pristine && (this.form.valid || false) && this.doAdditionalValidation(f)), @@ -193,4 +192,13 @@ export class GameEditorComponent implements AfterViewInit { return true; } + + private loadGame(game: Game) { + this.game = game; + + // ensure that the feedback template yaml string is consistent with the configuration + if (this.game.feedbackConfig) { + this.game.feedbackTemplate = this.yamlService.parse(this.game.feedbackConfig); + } + } } diff --git a/projects/gameboard-ui/src/app/admin/game-mapper/game-mapper.component.html b/projects/gameboard-ui/src/app/admin/game-mapper/game-mapper.component.html index cec1580a..cba2b8ea 100644 --- a/projects/gameboard-ui/src/app/admin/game-mapper/game-mapper.component.html +++ b/projects/gameboard-ui/src/app/admin/game-mapper/game-mapper.component.html @@ -6,7 +6,7 @@ -
    +
    @@ -94,7 +94,7 @@

    Automatic Bonuses

    Reset
    -
    +
    {{this.specHover?.name}}
    Points: {{this.specHover?.points}}
    diff --git a/projects/gameboard-ui/src/app/admin/game-mapper/game-mapper.component.ts b/projects/gameboard-ui/src/app/admin/game-mapper/game-mapper.component.ts index de6211ff..41be4d6a 100644 --- a/projects/gameboard-ui/src/app/admin/game-mapper/game-mapper.component.ts +++ b/projects/gameboard-ui/src/app/admin/game-mapper/game-mapper.component.ts @@ -2,7 +2,7 @@ // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. import { AfterViewInit, Component, ElementRef, EventEmitter, HostListener, Input, OnInit, Output, Renderer2, ViewChild } from '@angular/core'; -import { Observable, Subject } from 'rxjs'; +import { Observable, Subject, firstValueFrom } from 'rxjs'; import { debounceTime, filter, map, switchMap, tap } from 'rxjs/operators'; import { Game } from '../../api/game-models'; import { GameService } from '../../api/game.service'; @@ -23,10 +23,8 @@ export class GameMapperComponent implements OnInit, AfterViewInit { @Input() game!: Game; @Output() specsUpdated = new EventEmitter(); @ViewChild('mapbox') mapboxRef!: ElementRef; - @ViewChild('callout') calloutRef!: ElementRef; protected fa = fa; - mapbox!: HTMLDivElement; callout!: HTMLDivElement; specDrag: Spec | null = null; specHover: Spec | null = null; @@ -137,7 +135,10 @@ export class GameMapperComponent implements OnInit, AfterViewInit { ); } - ngOnInit(): void { + async ngOnInit(): Promise { + if (!this.game?.id) + this.game = await firstValueFrom(this.gameSvc.retrieve(this.game.id)); + this.game.mapUrl = this.game.background ? `${this.config.imagehost}/${this.game.background}` : `${this.config.basehref}assets/map.png` @@ -146,13 +147,13 @@ export class GameMapperComponent implements OnInit, AfterViewInit { ngAfterViewInit(): void { this.refresh(); - this.mapbox = this.mapboxRef.nativeElement as HTMLDivElement; - this.callout = this.calloutRef.nativeElement as HTMLDivElement; } refresh(): void { - this.refresh$.next(this.game.id); - this.recentExternals$.next(); + if (this.game?.id) { + this.refresh$.next(this.game.id); + this.recentExternals$.next(); + } } view(v: string): void { @@ -212,22 +213,23 @@ export class GameMapperComponent implements OnInit, AfterViewInit { mousemove(e: MouseEvent) { if (!this.specDrag) { return; } + const mapBox = this.mapboxRef.nativeElement; if (this.altkey) { // resize radius as percentage of mapbox/svg - const centerx = this.specDrag.x * this.mapbox.clientWidth; - const centery = this.specDrag.y * this.mapbox.clientHeight; + const centerx = this.specDrag.x * mapBox.clientWidth; + const centery = this.specDrag.y * mapBox.clientHeight; const deltaX = e.offsetX - centerx; const deltaY = e.offsetY - centery; const r = Math.sqrt( Math.pow(Math.abs(deltaX), 2) + Math.pow(Math.abs(deltaY), 2) ); - this.specDrag.r = Math.max(.01, r / this.mapbox.clientWidth); + this.specDrag.r = Math.max(.01, r / mapBox.clientWidth); } else { // set location as percentage of mapbox/svg - this.specDrag.x = e.offsetX / this.mapbox.clientWidth; - this.specDrag.y = e.offsetY / this.mapbox.clientHeight; + this.specDrag.x = e.offsetX / mapBox.clientWidth; + this.specDrag.y = e.offsetY / mapBox.clientHeight; } this.updating$.next(this.specDrag); @@ -244,12 +246,13 @@ export class GameMapperComponent implements OnInit, AfterViewInit { mouseenter(e: MouseEvent, spec: Spec) { this.specHover = spec; spec.c = 'purple'; + const mapBox = this.mapboxRef.nativeElement; if (this.showCallout) { - const middle = this.mapbox.clientWidth / 2; - const centerr = spec.r * this.mapbox.clientWidth; - const centerx = spec.x * this.mapbox.clientWidth + centerr; - const centery = spec.y * this.mapbox.clientHeight + centerr; + const middle = mapBox.clientWidth / 2; + const centerr = spec.r * mapBox.clientWidth; + const centerx = spec.x * mapBox.clientWidth + centerr; + const centery = spec.y * mapBox.clientHeight + centerr; const deltaX = middle - centerx; const deltaY = middle - centery; const vectorX = deltaX / Math.abs(deltaX); diff --git a/projects/gameboard-ui/src/app/admin/pipes/game-classification-to-string.pipe.ts b/projects/gameboard-ui/src/app/admin/pipes/game-classification-to-string.pipe.ts new file mode 100644 index 00000000..e621de30 --- /dev/null +++ b/projects/gameboard-ui/src/app/admin/pipes/game-classification-to-string.pipe.ts @@ -0,0 +1,25 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +type GameClassification = { + competition: string | null | undefined; + season: string | null | undefined; + track: string | null | undefined; +} + +@Pipe({ name: 'gameClassificationToString' }) +export class GameClassificationToStringPipe implements PipeTransform { + transform(value: GameClassification): string | null { + if (!value) + return null; + + const classificationBits: string[] = [ + value.competition || "", + value.season || "", + value.track || "" + ]; + + return classificationBits + .filter(b => !!b) + .join(" | "); + } +} diff --git a/projects/gameboard-ui/src/app/admin/player-registrar/player-registrar.component.html b/projects/gameboard-ui/src/app/admin/player-registrar/player-registrar.component.html index 5cddee8a..5509a711 100644 --- a/projects/gameboard-ui/src/app/admin/player-registrar/player-registrar.component.html +++ b/projects/gameboard-ui/src/app/admin/player-registrar/player-registrar.component.html @@ -47,10 +47,6 @@

    Players — {{ctx.game.name}}

    prac - | diff --git a/projects/gameboard-ui/src/app/admin/user-registrar/user-registrar.component.html b/projects/gameboard-ui/src/app/admin/user-registrar/user-registrar.component.html index 07b32684..aac21763 100644 --- a/projects/gameboard-ui/src/app/admin/user-registrar/user-registrar.component.html +++ b/projects/gameboard-ui/src/app/admin/user-registrar/user-registrar.component.html @@ -61,6 +61,13 @@

    Users

    +
    + +
    + Results limited to 200. Refine search term if necessary. diff --git a/projects/gameboard-ui/src/app/admin/user-registrar/user-registrar.component.ts b/projects/gameboard-ui/src/app/admin/user-registrar/user-registrar.component.ts index 419fea73..1b9d8468 100644 --- a/projects/gameboard-ui/src/app/admin/user-registrar/user-registrar.component.ts +++ b/projects/gameboard-ui/src/app/admin/user-registrar/user-registrar.component.ts @@ -5,11 +5,14 @@ import { Component } from '@angular/core'; import { BehaviorSubject, interval, merge, Observable } from 'rxjs'; import { debounceTime, map, switchMap, tap } from 'rxjs/operators'; import { Search } from '../../api/models'; -import { ApiUser, UserRole } from '../../api/user-models'; +import { ApiUser, TryCreateUsersResponse, UserRole } from '../../api/user-models'; import { UserService } from '../../api/user.service'; import { fa } from '@/services/font-awesome.service'; import { SortService } from '@/services/sort.service'; import { SortDirection } from '@/core/models/sort-direction'; +import { ModalConfirmService } from '@/services/modal-confirm.service'; +import { CreateUsersModalComponent } from '../components/create-users-modal/create-users-modal.component'; +import { ToastService } from '@/utility/services/toast.service'; type UserRegistrarSort = "name" | "lastLogin" | "createdOn"; @@ -34,7 +37,9 @@ export class UserRegistrarComponent { constructor( private api: UserService, + private modalService: ModalConfirmService, private sortService: SortService, + private toastService: ToastService ) { this.source$ = merge( this.refresh$, @@ -123,6 +128,18 @@ export class UserRegistrarComponent { this.update(model); } + protected handleAddUsersClick() { + this.modalService.openComponent({ + content: CreateUsersModalComponent, + context: { + onCreated: (response: TryCreateUsersResponse) => { + this.toastService.showMessage(`Created **${response.users.length}** user${response.users.length == 1 ? "s" : ""}.`); + this.refresh$.next(true); + } + } + }); + } + private sortResults(results: ApiUser[], sort: UserRegistrarSort, direction: SortDirection) { switch (sort) { case "lastLogin": diff --git a/projects/gameboard-ui/src/app/api/admin.models.ts b/projects/gameboard-ui/src/app/api/admin.models.ts index 94500ef6..c993aafd 100644 --- a/projects/gameboard-ui/src/app/api/admin.models.ts +++ b/projects/gameboard-ui/src/app/api/admin.models.ts @@ -1,6 +1,6 @@ import { DateTime } from "luxon"; import { GameEngineType } from "./spec-models"; -import { SimpleEntity } from "./models"; +import { PagedArray, SimpleEntity, SimpleSponsor } from "./models"; export interface AppActiveChallengeSpec { id: string; @@ -54,6 +54,65 @@ export interface AppActiveTeam { score: number; } +export interface GameCenterContext { + id: string; + name: string; + logo?: string; + competition: string | null; + season: string | null; + track: string | null; + executionWindow: { + start: DateTime, + end: DateTime + }, + isExternal: boolean; + isLive: boolean; + isPractice: boolean; + isRegistrationActive: boolean; + isTeamGame: boolean; + + challengeCount: number; + openTicketCount: number; + pointsAvailable: number; +} + +export interface GameCenterTeamsResults { + teams: PagedArray; +} + +export interface GameCenterTeamsResultsTeam { + id: string; + name: string; + + captain: GameCenterTeamsPlayer; + challengesCompleteCount: number; + challengesPartialCount: number; + challengesRemainingCount: number; + isExtended: boolean; + isReady: boolean; + players: GameCenterTeamsPlayer[]; + rank?: number; + registeredOn?: DateTime; + session: GameCenterTeamsSession; + ticketCount: number; +} + +export interface GameCenterTeamsPlayer { + id: string; + name: string; + isReady: boolean; + sponsor: SimpleSponsor; +} + +export interface GameCenterTeamsSession { + start?: number; + end?: number; + timeRemainingMs?: number; + timeSinceStartMs?: number; +} + +export type GameCenterTeamsStatus = "complete" | "notStarted" | "playing"; + export interface GetSiteOverviewStatsResponse { activeCompetitiveChallenges: number; activePracticeChallenges: number; diff --git a/projects/gameboard-ui/src/app/api/admin.service.ts b/projects/gameboard-ui/src/app/api/admin.service.ts index 0eb83a35..f5c82843 100644 --- a/projects/gameboard-ui/src/app/api/admin.service.ts +++ b/projects/gameboard-ui/src/app/api/admin.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable, map, tap } from 'rxjs'; +import { Observable, firstValueFrom, map, tap } from 'rxjs'; import { ApiUrlService } from '@/services/api-url.service'; -import { GetAppActiveChallengesResponse, GetAppActiveTeamsResponse, GetSiteOverviewStatsResponse, SendAnnouncement } from './admin.models'; +import { GameCenterContext, GameCenterTeamsResults, GetAppActiveChallengesResponse, GetAppActiveTeamsResponse, GetSiteOverviewStatsResponse, SendAnnouncement } from './admin.models'; import { PlayerMode } from './player-models'; import { DateTime } from 'luxon'; @@ -39,6 +39,26 @@ export class AdminService { ); } + async getGameCenterContext(gameId: string): Promise { + return firstValueFrom(this.http.get(this.apiUrl.build(`admin/games/${gameId}/game-center`)).pipe( + tap(ctx => { + ctx.executionWindow.start = DateTime.fromJSDate(new Date(ctx.executionWindow.start.toString())); + ctx.executionWindow.end = DateTime.fromJSDate(new Date(ctx.executionWindow.end.toString())); + }) + )); + } + + async getGameCenterTeams(gameId: string): Promise { + return firstValueFrom(this.http.get(this.apiUrl.build(`admin/games/${gameId}/game-center/teams`)).pipe( + tap(results => { + for (const team of results.teams.items) { + if (team.registeredOn) + team.registeredOn = DateTime.fromJSDate(new Date(team.registeredOn?.toString())); + } + }) + )); + } + getOverallSiteStats(): Observable { return this.http.get(this.apiUrl.build("admin/stats")); } diff --git a/projects/gameboard-ui/src/app/api/game-models.ts b/projects/gameboard-ui/src/app/api/game-models.ts index 925dcc09..49567d28 100644 --- a/projects/gameboard-ui/src/app/api/game-models.ts +++ b/projects/gameboard-ui/src/app/api/game-models.ts @@ -53,6 +53,7 @@ export interface GameDetail { cardText1: string; cardText2: string; cardText3: string; + isFeatured: boolean; isLive: boolean; hasEnded: boolean; } diff --git a/projects/gameboard-ui/src/app/api/game.service.ts b/projects/gameboard-ui/src/app/api/game.service.ts index db6a18cd..f930741b 100644 --- a/projects/gameboard-ui/src/app/api/game.service.ts +++ b/projects/gameboard-ui/src/app/api/game.service.ts @@ -8,7 +8,7 @@ import { map, tap } from 'rxjs/operators'; import { SyncStartGameState } from '../game/game.models'; import { ConfigService } from '../utility/config.service'; import { ChallengeGate } from './board-models'; -import { ChangedGame, Game, GameEngineMode, GameGroup, GamePlayState, NewGame, SessionForecast, UploadedFile } from './game-models'; +import { ChangedGame, Game, GameGroup, NewGame, SessionForecast, UploadedFile } from './game-models'; import { TimeWindow } from './player-models'; import { Spec } from './spec-models'; diff --git a/projects/gameboard-ui/src/app/api/player.service.ts b/projects/gameboard-ui/src/app/api/player.service.ts index a3667721..8d750028 100644 --- a/projects/gameboard-ui/src/app/api/player.service.ts +++ b/projects/gameboard-ui/src/app/api/player.service.ts @@ -53,7 +53,11 @@ export class PlayerService { } public start(player: Player): Observable { - return this.http.put(`${this.url}/player/${player.id}/start`, {}).pipe( + return this.startPlayerId(player.id); + } + + public startPlayerId(playerId: string): Observable { + return this.http.put(`${this.url}/player/${playerId}/start`, {}).pipe( map(p => this.transform(p) as Player), tap(p => this._playerSessionStarted$.next(p.id)) ); diff --git a/projects/gameboard-ui/src/app/api/spec-models.ts b/projects/gameboard-ui/src/app/api/spec-models.ts index 1faf53f3..a602acf2 100644 --- a/projects/gameboard-ui/src/app/api/spec-models.ts +++ b/projects/gameboard-ui/src/app/api/spec-models.ts @@ -1,6 +1,8 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. +import { SimpleEntity } from "./models"; + export interface ExternalSpec { externalId: string; name: string; @@ -70,3 +72,19 @@ export interface ChallengeSpecBonusViewModel { export enum ChallengeBonusType { SolveSpeed = 0 } + +export interface GetChallengeSpecQuestionPerformanceResult { + challengeSpec: SimpleEntity; + game: SimpleEntity; + questions: ChallengeSpecQuestionPerformance[]; +} + +export interface ChallengeSpecQuestionPerformance { + questionRank: number; + hint: string; + prompt: string; + pointValue: number; + + countCorrect: number; + countSubmitted: number; +} diff --git a/projects/gameboard-ui/src/app/api/spec.service.ts b/projects/gameboard-ui/src/app/api/spec.service.ts index ea79b2ad..ba8bf64d 100644 --- a/projects/gameboard-ui/src/app/api/spec.service.ts +++ b/projects/gameboard-ui/src/app/api/spec.service.ts @@ -3,10 +3,9 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Observable, Subject, of } from 'rxjs'; -import { map } from 'rxjs/operators'; +import { Observable, Subject, firstValueFrom, of } from 'rxjs'; import { ConfigService } from '../utility/config.service'; -import { ChallengeSpecBonusViewModel, ChangedSpec, ExternalSpec, NewSpec, Spec, SpecSummary } from './spec-models'; +import { ChallengeSpecBonusViewModel, ChangedSpec, ExternalSpec, GetChallengeSpecQuestionPerformanceResult, NewSpec, Spec, SpecSummary } from './spec-models'; @Injectable({ providedIn: 'root' }) export class SpecService { @@ -20,6 +19,10 @@ export class SpecService { this.url = config.apphost + 'api'; } + public getQuestionPerformance(specId: string): Promise { + return firstValueFrom(this.http.get(`${this.url}/challengespecs/${specId}/question-performance`)); + } + public list(filter: any): Observable { return this.http.get(this.url + '/challengespecs', { params: filter }); } diff --git a/projects/gameboard-ui/src/app/api/user-models.ts b/projects/gameboard-ui/src/app/api/user-models.ts index affe4632..a27f7615 100644 --- a/projects/gameboard-ui/src/app/api/user-models.ts +++ b/projects/gameboard-ui/src/app/api/user-models.ts @@ -1,6 +1,7 @@ // Copyright 2021 Carnegie Mellon University. All Rights Reserved. // Released under a MIT (SEI)-style license. See LICENSE.md in the project root for license information. +import { SimpleEntity } from "./models"; import { Sponsor } from "./sponsor-models"; export interface ApiUser { @@ -91,11 +92,28 @@ export interface Announcement { message: string; } +export interface TryCreateUsersRequest { + allowSubsetCreation: boolean; + enrollInGameId?: string; + sponsorId?: string; + unsetDefaultSponsorFlag?: boolean; + userIds: string[]; +} + export interface TryCreateUserResult { isNewUser: boolean; user: ApiUser; } +export interface TryCreateUsersResponse { + users: { + id: string; + name: string; + sponsor: SimpleEntity; + isNewUser: boolean; + }[] +} + // just use this for convenience during the authentication process (see the utiltiy user service). // There are other properties in the profile that may be useful, but just mapping the key ones right now export interface UserOidcProfile { diff --git a/projects/gameboard-ui/src/app/api/user.service.ts b/projects/gameboard-ui/src/app/api/user.service.ts index eda96723..a92045ed 100644 --- a/projects/gameboard-ui/src/app/api/user.service.ts +++ b/projects/gameboard-ui/src/app/api/user.service.ts @@ -3,10 +3,10 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { Observable, Subject } from 'rxjs'; +import { Observable, Subject, firstValueFrom } from 'rxjs'; import { map, tap } from 'rxjs/operators'; import { ConfigService } from '../utility/config.service'; -import { Announcement, ApiUser, ChangedUser, NewUser, TreeNode, TryCreateUserResult, UpdateUserSettingsRequest, UserSettings } from './user-models'; +import { Announcement, ApiUser, ChangedUser, NewUser, TreeNode, TryCreateUserResult, TryCreateUsersRequest, TryCreateUsersResponse, UpdateUserSettingsRequest, UserSettings } from './user-models'; import { LogService } from '@/services/log.service'; import { ApiUrlService } from '@/services/api-url.service'; @@ -46,6 +46,10 @@ export class UserService { ); } + public tryCreateMany(req: TryCreateUsersRequest) { + return firstValueFrom(this.http.post(this.apiUrl.build("users"), req)); + } + public update(model: ChangedUser, disallowedName: string | null = null): Observable { return this.http.put(this.apiUrl.build("user"), model).pipe( map(r => this.transform(r as ApiUser, disallowedName)), @@ -138,8 +142,7 @@ export class UserService { user.roleTag = user.role.split(', ') .map(a => a.substring(0, 1).toUpperCase() + (a.startsWith('d') ? a.substring(1, 2) : '')) - .join('') - ; + .join(''); return user; } } diff --git a/projects/gameboard-ui/src/app/app-routing.module.ts b/projects/gameboard-ui/src/app/app-routing.module.ts index 405bfee7..bf0233fb 100644 --- a/projects/gameboard-ui/src/app/app-routing.module.ts +++ b/projects/gameboard-ui/src/app/app-routing.module.ts @@ -17,6 +17,7 @@ const routes: Routes = [ }, { path: 'game', + title: "Game", loadChildren: () => import('./game/game.module').then(m => m.GameModule) }, { diff --git a/projects/gameboard-ui/src/app/core/components/game-card-image/game-card-image.component.ts b/projects/gameboard-ui/src/app/core/components/game-card-image/game-card-image.component.ts index 30a4e61e..fb28e3ca 100644 --- a/projects/gameboard-ui/src/app/core/components/game-card-image/game-card-image.component.ts +++ b/projects/gameboard-ui/src/app/core/components/game-card-image/game-card-image.component.ts @@ -5,6 +5,6 @@ import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; templateUrl: './game-card-image.component.html' }) export class GameCardImageComponent { - @Input() game?: { id: string; name: string; logo: string }; + @Input() game?: { id: string; name: string; logo?: string }; @Input() width: string = "100%"; } diff --git a/projects/gameboard-ui/src/app/core/components/modal-content/modal-content.component.html b/projects/gameboard-ui/src/app/core/components/modal-content/modal-content.component.html index 9efcf969..189bfdae 100644 --- a/projects/gameboard-ui/src/app/core/components/modal-content/modal-content.component.html +++ b/projects/gameboard-ui/src/app/core/components/modal-content/modal-content.component.html @@ -27,7 +27,7 @@
    {{ cancelButtonText || "Cancel" }}
    diff --git a/projects/gameboard-ui/src/app/core/components/modal-content/modal-content.component.ts b/projects/gameboard-ui/src/app/core/components/modal-content/modal-content.component.ts index 7a276871..906ce076 100644 --- a/projects/gameboard-ui/src/app/core/components/modal-content/modal-content.component.ts +++ b/projects/gameboard-ui/src/app/core/components/modal-content/modal-content.component.ts @@ -1,6 +1,5 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'; import { ModalConfirmService } from '@/services/modal-confirm.service'; -import { first, firstValueFrom } from 'rxjs'; @Component({ selector: 'app-modal-content', @@ -14,6 +13,7 @@ export class ModalContentComponent { @Input() subSubtitle?: string; @Input() cancelButtonText?: string; @Input() confirmButtonText?: string; + @Input() confirmDisabled = false; @Input() isDangerConfirm = false; @Output() confirm = new EventEmitter(); diff --git a/projects/gameboard-ui/src/app/game/game.module.ts b/projects/gameboard-ui/src/app/game/game.module.ts index a7fe844c..1c9cf9cc 100644 --- a/projects/gameboard-ui/src/app/game/game.module.ts +++ b/projects/gameboard-ui/src/app/game/game.module.ts @@ -25,21 +25,18 @@ import { GamePageComponent } from './pages/game-page/game-page.component'; import { GamespaceQuizComponent } from './gamespace-quiz/gamespace-quiz.component'; import { HubStateToPlayerStatusPipe } from './pipes/hub-state-to-player-status.pipe'; import { IndexToSubmittedAnswersPipe } from './pipes/index-to-submitted-answers.pipe'; -import { ChallengeBonusesToTooltip } from './pipes/manual-bonuses-to-tooltip.pipe'; import { PlayComponent } from './components/play/play.component'; import { PlayerEnrollComponent } from './player-enroll/player-enroll.component'; import { PlayerPresenceComponent } from './player-presence/player-presence.component'; import { PlayerSessionComponent } from './player-session/player-session.component'; -import { ScoreboardComponent } from './components/scoreboard/scoreboard.component'; import { ScoreboardPageComponent } from './pages/scoreboard-page/scoreboard-page.component'; import { ScoreboardTableComponent } from './scoreboard-table/scoreboard-table.component'; -import { ScoreboardTeamDetailModalComponent } from './components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component'; -import { ScoreToTooltipPipe } from './pipes/score-to-tooltip.pipe'; import { SessionForecastComponent } from './session-forecast/session-forecast.component'; import { SessionStartControlsComponent } from './components/session-start-controls/session-start-controls.component'; import { SessionStartCountdownComponent } from './components/session-start-countdown/session-start-countdown.component'; import { TeamChallengeScoresToChallengeResultTypeCountPipe } from './pipes/team-challenge-scores-to-challenge-result-type-count.pipe'; import { UserIsPlayingGuard } from '@/guards/user-is-playing.guard'; +import { ScoreboardModule } from '@/scoreboard/scoreboard.module'; const MODULE_DECLARATIONS = [ CertificateComponent, @@ -55,19 +52,15 @@ const MODULE_DECLARATIONS = [ HubStateToPlayerStatusPipe, IndexToSubmittedAnswersPipe, LateStartBannerComponent, - ChallengeBonusesToTooltip, PlayComponent, PlayerEnrollComponent, PlayerPresenceComponent, PlayerSessionComponent, - ScoreboardComponent, ScoreboardPageComponent, ScoreboardTableComponent, - ScoreboardTeamDetailModalComponent, SessionForecastComponent, SessionStartControlsComponent, SessionStartCountdownComponent, - ScoreToTooltipPipe, TeamChallengeScoresToChallengeResultTypeCountPipe, ]; @@ -87,7 +80,8 @@ const MODULE_DECLARATIONS = [ { path: ':id', component: GamePageComponent, children: [] } ]), CoreModule, - UtilityModule + UtilityModule, + ScoreboardModule ], exports: [ ChallengeDeployCountdownComponent, diff --git a/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.html b/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.html index 2d6db421..7ba7cf2b 100644 --- a/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.html +++ b/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.html @@ -60,7 +60,7 @@ Enroll - Admin Enroll diff --git a/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.ts b/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.ts index 897ae3fc..3c50f6c3 100644 --- a/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.ts +++ b/projects/gameboard-ui/src/app/game/player-enroll/player-enroll.component.ts @@ -177,6 +177,8 @@ export class PlayerEnrollComponent implements OnInit, OnDestroy { catch (err) { this.errors.push(err); } + + this.isEnrolling = false; } protected async handleUnenroll(p: Player): Promise { diff --git a/projects/gameboard-ui/src/app/home/landing/landing.component.html b/projects/gameboard-ui/src/app/home/landing/landing.component.html index 9f4d0b3f..532a3da2 100644 --- a/projects/gameboard-ui/src/app/home/landing/landing.component.html +++ b/projects/gameboard-ui/src/app/home/landing/landing.component.html @@ -14,9 +14,20 @@
    + +
    + +
    +

    Featured Games

    +
    + + + +
    +
    +
    -

    Live!

    @@ -25,7 +36,6 @@

    Live!

    -
    @@ -97,6 +107,12 @@

    Open Game

    Closed

    + +
    + Loading featured games... +
    +
    +
    Loading games... diff --git a/projects/gameboard-ui/src/app/home/landing/landing.component.ts b/projects/gameboard-ui/src/app/home/landing/landing.component.ts index 471b7e03..7a4cda3b 100644 --- a/projects/gameboard-ui/src/app/home/landing/landing.component.ts +++ b/projects/gameboard-ui/src/app/home/landing/landing.component.ts @@ -17,6 +17,7 @@ import { GameService } from '../../api/game.service'; }) export class LandingComponent implements OnInit { refresh$ = new BehaviorSubject(true); + featured$: Observable; past$: Observable; present$: Observable; future$: Observable; @@ -35,10 +36,15 @@ export class LandingComponent implements OnInit { private router: Router, api: GameService ) { + this.featured$ = this.refresh$.pipe( + debounceTime(400), + switchMap(() => api.list({ isFeatured: true, filter: [], term: this.searchText })), + tap(g => { if (g.length > 0) { this.showSearchBar = true; } }), + ); this.past$ = this.refresh$.pipe( debounceTime(400), switchMap(() => api.listGrouped({ filter: ['past', "competitive"], term: this.searchText })), - tap(g => { if (g.length > 0) { this.showSearchBar = true; } }) + tap(g => { if (g.length > 0) { this.showSearchBar = true; } }), ); this.present$ = this.refresh$.pipe( debounceTime(200), diff --git a/projects/gameboard-ui/src/app/prac/components/practice-challenge-list/practice-challenge-list.component.html b/projects/gameboard-ui/src/app/prac/components/practice-challenge-list/practice-challenge-list.component.html index 0b99b6d6..b992d597 100644 --- a/projects/gameboard-ui/src/app/prac/components/practice-challenge-list/practice-challenge-list.component.html +++ b/projects/gameboard-ui/src/app/prac/components/practice-challenge-list/practice-challenge-list.component.html @@ -41,7 +41,7 @@

    Need a place to start?

    [appQueryParamModel]="{ name: 'term', debounce: 500, resetQueryParams: ['skip', 'take'] }">
    - diff --git a/projects/gameboard-ui/src/app/reports/components/challenge-or-game-field/challenge-or-game-field.component.html b/projects/gameboard-ui/src/app/reports/components/challenge-or-game-field/challenge-or-game-field.component.html index 5a3bd3b1..9825efe1 100644 --- a/projects/gameboard-ui/src/app/reports/components/challenge-or-game-field/challenge-or-game-field.component.html +++ b/projects/gameboard-ui/src/app/reports/components/challenge-or-game-field/challenge-or-game-field.component.html @@ -1,6 +1,8 @@
    -

    {{ - challengeName || game.name }}

    +

    + {{ challengeName || game.name }} +

    diff --git a/projects/gameboard-ui/src/app/reports/components/challenge-or-game-field/challenge-or-game-field.component.ts b/projects/gameboard-ui/src/app/reports/components/challenge-or-game-field/challenge-or-game-field.component.ts index 298f8b9f..da6d7b21 100644 --- a/projects/gameboard-ui/src/app/reports/components/challenge-or-game-field/challenge-or-game-field.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/challenge-or-game-field/challenge-or-game-field.component.ts @@ -10,5 +10,6 @@ export class ChallengeOrGameFieldComponent { @Input() challengeName?: string; @Input() game?: ReportGame; @Input() disableLinks = false; + @Input() mainLabelClass = ""; @Input() fontSize: "small" | "large" = "small"; } diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html index b059acc3..c314ba81 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.html @@ -21,15 +21,16 @@

    - + + - + - - - +
    SummarySummary Players Average Performance @@ -44,15 +45,17 @@
    ChallengeGame Players - Avg. Score + + Score + - Avg. Solve Time + + Solve Time @@ -76,12 +79,12 @@
    - {{ record.challengeSpec.name }} -
    - + {{record.challengeSpec.name}} + {{ record.distinctPlayerCount }} diff --git a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.ts b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.ts index 7d49b3c8..0376a27a 100644 --- a/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.ts +++ b/projects/gameboard-ui/src/app/reports/components/reports/challenges-report/challenges-report.component.ts @@ -7,6 +7,8 @@ import { ChallengesReportService } from '../challenges-report.service'; import { MultiSelectQueryParamModel } from '@/core/models/multi-select-query-param.model'; import { SimpleEntity } from '@/api/models'; import { DateRangeQueryParamModel } from '@/core/models/date-range-query-param.model'; +import { ModalConfirmService } from '@/services/modal-confirm.service'; +import { SpecQuestionPerformanceModalComponent } from '../../spec-question-performance-modal/spec-question-performance-modal.component'; export interface ChallengesReportContext { isLoading: boolean, @@ -56,10 +58,20 @@ export class ChallengesReportComponent extends ReportComponentBase({ + content: SpecQuestionPerformanceModalComponent, + context: { specId: spec.id }, + modalClasses: ["modal-lg", "modal-dialog-centered"] + }); + } + protected async updateView(parameters: ChallengesReportFlatParameters): Promise { if (!this.challengesReportService) return { metaData: await firstValueFrom(this.reportsService.getReportMetaData(ReportKey.ChallengesReport)) }; diff --git a/projects/gameboard-ui/src/app/reports/components/spec-question-performance-modal/spec-question-performance-modal.component.html b/projects/gameboard-ui/src/app/reports/components/spec-question-performance-modal/spec-question-performance-modal.component.html new file mode 100644 index 00000000..e14f4ff5 --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/spec-question-performance-modal/spec-question-performance-modal.component.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +
    QuestionPoint ValueSubmissionsCorrect
    +
    {{question.prompt}}
    + {{question.hint}} +
    + {{ question.pointValue | number:"1.0-2" }} + + {{question.countSubmitted}} + + {{question.countCorrect}} + + + ({{ ((question.countCorrect / question.countSubmitted) * 100) | number:"1.0-2" }}%) + + +
    +
    + + + Loading question performance... + diff --git a/projects/gameboard-ui/src/app/reports/components/spec-question-performance-modal/spec-question-performance-modal.component.scss b/projects/gameboard-ui/src/app/reports/components/spec-question-performance-modal/spec-question-performance-modal.component.scss new file mode 100644 index 00000000..80536a68 --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/spec-question-performance-modal/spec-question-performance-modal.component.scss @@ -0,0 +1,4 @@ +td { + border-bottom: dashed 1px gray; + padding: 4px 0; +} diff --git a/projects/gameboard-ui/src/app/reports/components/spec-question-performance-modal/spec-question-performance-modal.component.ts b/projects/gameboard-ui/src/app/reports/components/spec-question-performance-modal/spec-question-performance-modal.component.ts new file mode 100644 index 00000000..fc5ea870 --- /dev/null +++ b/projects/gameboard-ui/src/app/reports/components/spec-question-performance-modal/spec-question-performance-modal.component.ts @@ -0,0 +1,22 @@ +import { GetChallengeSpecQuestionPerformanceResult } from '@/api/spec-models'; +import { SpecService } from '@/api/spec.service'; +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-spec-question-performance-modal', + templateUrl: './spec-question-performance-modal.component.html', + styleUrls: ['./spec-question-performance-modal.component.scss'] +}) +export class SpecQuestionPerformanceModalComponent implements OnInit { + specId?: string; + protected context?: GetChallengeSpecQuestionPerformanceResult; + + constructor(private specService: SpecService) { } + + async ngOnInit() { + if (!this.specId) + throw new Error("Requires a spec."); + + this.context = await this.specService.getQuestionPerformance(this.specId); + } +} diff --git a/projects/gameboard-ui/src/app/reports/reports.module.ts b/projects/gameboard-ui/src/app/reports/reports.module.ts index 4c0b4030..beab52dc 100644 --- a/projects/gameboard-ui/src/app/reports/reports.module.ts +++ b/projects/gameboard-ui/src/app/reports/reports.module.ts @@ -49,6 +49,7 @@ import { SiteUsagePlayerListComponent } from './components/reports/site-usage-re import { SiteUsageReportSponsorsModalComponent } from './components/reports/site-usage-report/site-usage-report-sponsors-modal/site-usage-report-sponsors-modal.component'; import { SiteUsageReportChallengesListComponent } from './components/reports/site-usage-report/site-usage-report-challenges-list/site-usage-report-challenges-list.component'; import { SortHeaderComponent } from './components/sort-header/sort-header.component'; +import { SpecQuestionPerformanceModalComponent } from './components/spec-question-performance-modal/spec-question-performance-modal.component'; @NgModule({ declarations: [ @@ -96,7 +97,8 @@ import { SortHeaderComponent } from './components/sort-header/sort-header.compon SiteUsagePlayerListComponent, SiteUsageReportSponsorsModalComponent, SiteUsageReportChallengesListComponent, - SortHeaderComponent + SortHeaderComponent, + SpecQuestionPerformanceModalComponent ], imports: [ CommonModule, diff --git a/projects/gameboard-ui/src/app/game/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.html b/projects/gameboard-ui/src/app/scoreboard/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.html similarity index 100% rename from projects/gameboard-ui/src/app/game/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.html rename to projects/gameboard-ui/src/app/scoreboard/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.html diff --git a/projects/gameboard-ui/src/app/game/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.scss b/projects/gameboard-ui/src/app/scoreboard/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.scss similarity index 100% rename from projects/gameboard-ui/src/app/game/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.scss rename to projects/gameboard-ui/src/app/scoreboard/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.scss diff --git a/projects/gameboard-ui/src/app/game/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.ts b/projects/gameboard-ui/src/app/scoreboard/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.ts similarity index 100% rename from projects/gameboard-ui/src/app/game/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.ts rename to projects/gameboard-ui/src/app/scoreboard/components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component.ts diff --git a/projects/gameboard-ui/src/app/game/components/scoreboard/scoreboard.component.html b/projects/gameboard-ui/src/app/scoreboard/components/scoreboard/scoreboard.component.html similarity index 100% rename from projects/gameboard-ui/src/app/game/components/scoreboard/scoreboard.component.html rename to projects/gameboard-ui/src/app/scoreboard/components/scoreboard/scoreboard.component.html diff --git a/projects/gameboard-ui/src/app/game/components/scoreboard/scoreboard.component.scss b/projects/gameboard-ui/src/app/scoreboard/components/scoreboard/scoreboard.component.scss similarity index 100% rename from projects/gameboard-ui/src/app/game/components/scoreboard/scoreboard.component.scss rename to projects/gameboard-ui/src/app/scoreboard/components/scoreboard/scoreboard.component.scss diff --git a/projects/gameboard-ui/src/app/game/components/scoreboard/scoreboard.component.ts b/projects/gameboard-ui/src/app/scoreboard/components/scoreboard/scoreboard.component.ts similarity index 93% rename from projects/gameboard-ui/src/app/game/components/scoreboard/scoreboard.component.ts rename to projects/gameboard-ui/src/app/scoreboard/components/scoreboard/scoreboard.component.ts index 9776d5ec..c9010580 100644 --- a/projects/gameboard-ui/src/app/game/components/scoreboard/scoreboard.component.ts +++ b/projects/gameboard-ui/src/app/scoreboard/components/scoreboard/scoreboard.component.ts @@ -4,13 +4,11 @@ import { ScoringService } from '@/services/scoring/scoring.service'; import { ScoreboardData, ScoreboardDataTeam } from '@/services/scoring/scoring.models'; import { ModalConfirmService } from '@/services/modal-confirm.service'; import { ScoreboardTeamDetailModalComponent } from '../scoreboard-team-detail-modal/scoreboard-team-detail-modal.component'; -import { UnsubscriberService } from '@/services/unsubscriber.service'; @Component({ selector: 'app-scoreboard', templateUrl: './scoreboard.component.html', styleUrls: ['./scoreboard.component.scss'], - providers: [UnsubscriberService] }) export class ScoreboardComponent implements OnInit, OnDestroy { @Input() gameId?: string; @@ -29,8 +27,7 @@ export class ScoreboardComponent implements OnInit, OnDestroy { constructor( private modalConfirmService: ModalConfirmService, - private scoreService: ScoringService, - private unsub: UnsubscriberService) { } + private scoreService: ScoringService) { } async ngOnInit() { if (!this.gameId) diff --git a/projects/gameboard-ui/src/app/game/pipes/manual-bonuses-to-tooltip.pipe.ts b/projects/gameboard-ui/src/app/scoreboard/pipes/challenge-bonuses-to-tooltip.ts similarity index 100% rename from projects/gameboard-ui/src/app/game/pipes/manual-bonuses-to-tooltip.pipe.ts rename to projects/gameboard-ui/src/app/scoreboard/pipes/challenge-bonuses-to-tooltip.ts diff --git a/projects/gameboard-ui/src/app/game/pipes/score-to-tooltip.pipe.ts b/projects/gameboard-ui/src/app/scoreboard/pipes/score-to-tooltip.pipe.ts similarity index 100% rename from projects/gameboard-ui/src/app/game/pipes/score-to-tooltip.pipe.ts rename to projects/gameboard-ui/src/app/scoreboard/pipes/score-to-tooltip.pipe.ts diff --git a/projects/gameboard-ui/src/app/scoreboard/scoreboard.module.ts b/projects/gameboard-ui/src/app/scoreboard/scoreboard.module.ts new file mode 100644 index 00000000..4807e92d --- /dev/null +++ b/projects/gameboard-ui/src/app/scoreboard/scoreboard.module.ts @@ -0,0 +1,27 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { CoreModule } from '@/core/core.module'; + +import { ChallengeBonusesToTooltip } from './pipes/challenge-bonuses-to-tooltip'; +import { ScoreboardComponent } from './components/scoreboard/scoreboard.component'; +import { ScoreboardTeamDetailModalComponent } from './components/scoreboard-team-detail-modal/scoreboard-team-detail-modal.component'; +import { ScoreToTooltipPipe } from './pipes/score-to-tooltip.pipe'; + +const PUBLIC_DECLARATIONS = [ + ScoreboardComponent, + ScoreboardTeamDetailModalComponent +]; + +@NgModule({ + declarations: [ + ...PUBLIC_DECLARATIONS, + ChallengeBonusesToTooltip, + ScoreToTooltipPipe + ], + imports: [ + CommonModule, + CoreModule + ], + exports: PUBLIC_DECLARATIONS +}) +export class ScoreboardModule { } diff --git a/projects/gameboard-ui/src/app/services/router.service.ts b/projects/gameboard-ui/src/app/services/router.service.ts index 089549c5..6696f48f 100644 --- a/projects/gameboard-ui/src/app/services/router.service.ts +++ b/projects/gameboard-ui/src/app/services/router.service.ts @@ -59,6 +59,14 @@ export class RouterService implements OnDestroy { return `/user/${localUserId}/certificates/${mode}/${challengeSpecOrGameId}`; } + public getObserveChallengeUrl(gameId: string, challengeId: string) { + return `/admin/observer/challenges/${gameId}?search=${challengeId}`; + } + + public getObserveTeamsUrl(gameId: string, teamId: string) { + return `admin/observer/teams/${gameId}?search=${teamId}`; + } + public getPracticeAreaWithSearchUrl(searchTerm: string) { return this.router.createUrlTree(["practice"], { queryParams: { term: searchTerm } }); } @@ -108,11 +116,11 @@ export class RouterService implements OnDestroy { } public buildVmConsoleUrl(vm: VmState, isPractice = false) { - if (!vm || !vm.isolationId || !vm.name) { - throw new Error(`Can't launch a VM console without an isolationId and a name.`); + if (!vm || !vm.isolationId) { + throw new Error(`Can't launch a VM console without an isolationId.`); } - return `${this.config.mkshost}?f=1&s=${vm.isolationId}&v=${vm.name}${isPractice ? "&l=true" : ""}`; + return `${this.config.mkshost}?f=1&s=${vm.isolationId}&v=${vm.name || 'Console'}${isPractice ? "&l=true" : ""}`; } public toVmConsole(vm: VmState) { diff --git a/projects/gameboard-ui/src/app/support/components/ticket-support-tools/ticket-support-tools.component.ts b/projects/gameboard-ui/src/app/support/components/ticket-support-tools/ticket-support-tools.component.ts index 83e865e0..368380e2 100644 --- a/projects/gameboard-ui/src/app/support/components/ticket-support-tools/ticket-support-tools.component.ts +++ b/projects/gameboard-ui/src/app/support/components/ticket-support-tools/ticket-support-tools.component.ts @@ -20,6 +20,21 @@ export interface TicketSupportToolsContext { styleUrls: ['./ticket-support-tools.component.scss'], template: `