From 83b842eaafe92b5a068159a27b9ceee341ad6243 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 30 Oct 2023 11:39:12 -0400 Subject: [PATCH 1/6] Added fire+forget for external launch and batch deploy. Fire and forget still needs a resolution for http context. --- .../Challenges/ChallengeServiceTests.cs | 4 - .../Common/Services/FireAndForgetService.cs | 2 +- .../Features/Challenge/ChallengeExceptions.cs | 5 + .../Challenge/ChallengeGraderUrlService.cs | 83 ++++++++++++++ .../Features/Challenge/ChallengeService.cs | 29 +---- .../GameStart/ExternalSyncGameStartService.cs | 106 ++++++++++++++---- .../Game/GameStart/GameStartService.cs | 14 ++- .../Game/GameStart/SyncStartGameService.cs | 2 +- src/Gameboard.Api/Structure/AppSettings.cs | 1 + 9 files changed, 189 insertions(+), 57 deletions(-) create mode 100644 src/Gameboard.Api/Features/Challenge/ChallengeGraderUrlService.cs diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs index 8fe8290ef..ed7b0dbef 100644 --- a/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Challenges/ChallengeServiceTests.cs @@ -108,9 +108,7 @@ string userId A.Fake(), fakeGameEngineService, A.Fake(), - A.Fake(), A.Fake(), - A.Fake(), A.Fake>(), A.Fake(), A.Fake(), @@ -230,9 +228,7 @@ string userId A.Fake(), fakeGameEngineService, A.Fake(), - A.Fake(), A.Fake(), - A.Fake(), A.Fake>(), A.Fake(), A.Fake(), diff --git a/src/Gameboard.Api/Common/Services/FireAndForgetService.cs b/src/Gameboard.Api/Common/Services/FireAndForgetService.cs index 4f6175f42..13b25a858 100644 --- a/src/Gameboard.Api/Common/Services/FireAndForgetService.cs +++ b/src/Gameboard.Api/Common/Services/FireAndForgetService.cs @@ -43,7 +43,7 @@ public void Fire(Func doWork, CancellationToken cancellatio try { using var scope = _serviceScopeFactory.CreateScope(); - await doWork(_serviceScopeFactory.CreateScope()); + await doWork(scope); } catch (Exception ex) { diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeExceptions.cs b/src/Gameboard.Api/Features/Challenge/ChallengeExceptions.cs index a53566326..55bccbe96 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeExceptions.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeExceptions.cs @@ -9,3 +9,8 @@ internal class GamespaceLimitReached : GameboardException { public GamespaceLimitReached(string gameId, string teamId) : base($""" Team(s) {teamId} are already at the maximum number of gamespaces permitted for game "{gameId}." """) { } } + +internal class GraderUrlResolutionError : GameboardException +{ + public GraderUrlResolutionError() : base("Gameboard was unable to resolve a grader URL.") { } +} diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeGraderUrlService.cs b/src/Gameboard.Api/Features/Challenge/ChallengeGraderUrlService.cs new file mode 100644 index 000000000..02b38f66c --- /dev/null +++ b/src/Gameboard.Api/Features/Challenge/ChallengeGraderUrlService.cs @@ -0,0 +1,83 @@ +using System; +using System.Linq; +using System.Threading; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; + +namespace Gameboard.Api.Features.Challenges; + +public interface IChallengeGraderUrlService +{ + public string BuildGraderUrl(); +} + +internal class ChallengeGraderUrlService : IChallengeGraderUrlService +{ + private readonly HttpContext _httpContext; + private readonly ILogger _logger; + private readonly LinkGenerator _linkGenerator; + private readonly IServer _server; + + public ChallengeGraderUrlService + ( + IHttpContextAccessor httpContextAccessor, + LinkGenerator linkGenerator, + ILogger logger, + IServer server + ) + { + _httpContext = httpContextAccessor.HttpContext; + _linkGenerator = linkGenerator; + _logger = logger; + _server = server; + } + + public string BuildGraderUrl() + { + // prefer to get this from the current request, but it can be null if the call + // is coming from a background service that runs without an active request. + // weirdly, HttpContext has a RequestAborted property of type CancellationToken, + // but that property isn't accessible if the HttpContext has been disposed. + // So we try/catch. If the context is disposed, we'll get an ObjectDisposed + // exception, but we catch all exceptions anyway in case we can recover + // with the server.Features.Get strategy. + try + { + return string.Join + ( + '/', + _linkGenerator.GetUriByAction + ( + _httpContext, + "Grade", + "Challenge", + null, + _httpContext.Request.Scheme, + _httpContext.Request.Host, + _httpContext.Request.PathBase + ) + ); + } + catch (ObjectDisposedException ex) + { + _logger.LogInformation($"Attempt to build grader URL with HttpContextAccessor failed: {ex.GetType().Name} :: {ex.Message} Attempting backup strategy..."); + + if (_server is not null) + { + var addresses = _server.Features.Get(); + + var rootUrl = addresses.Addresses.FirstOrDefault(a => a.Contains("https")); + if (rootUrl.IsEmpty()) + rootUrl = addresses.Addresses.FirstOrDefault(); + + if (!rootUrl.IsEmpty()) + return $"{rootUrl}/challenge/grade"; + } + } + + throw new GraderUrlResolutionError(); + } +} diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeService.cs b/src/Gameboard.Api/Features/Challenge/ChallengeService.cs index 59eadcd0b..d44f7655c 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeService.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeService.cs @@ -15,11 +15,9 @@ using Gameboard.Api.Features.Teams; using Gameboard.Api.Features.Scores; using MediatR; -using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; -using Microsoft.AspNetCore.Http; namespace Gameboard.Api.Services; @@ -29,9 +27,7 @@ public partial class ChallengeService : _Service private readonly IChallengeStore _challengeStore; private readonly IGameEngineService _gameEngine; private readonly IGuidService _guids; - private readonly IHttpContextAccessor _httpContextAccessor; private readonly IJsonService _jsonService; - private readonly LinkGenerator _linkGenerator; private readonly IMapper _mapper; private readonly IMediator _mediator; private readonly IMemoryCache _memCache; @@ -42,7 +38,8 @@ public partial class ChallengeService : _Service private readonly IStore _store; private readonly ITeamService _teamService; - public ChallengeService( + public ChallengeService + ( ConsoleActorMap actorMap, CoreOptions coreOptions, IChallengeStore challengeStore, @@ -50,9 +47,7 @@ public ChallengeService( IChallengeSyncService challengeSyncService, IGameEngineService gameEngine, IGuidService guids, - IHttpContextAccessor httpContextAccessor, IJsonService jsonService, - LinkGenerator linkGenerator, ILogger logger, IMapper mapper, IMediator mediator, @@ -69,9 +64,7 @@ ITeamService teamService _challengeSyncService = challengeSyncService; _gameEngine = gameEngine; _guids = guids; - _httpContextAccessor = httpContextAccessor; _jsonService = jsonService; - _linkGenerator = linkGenerator; _mapper = mapper; _mediator = mediator; _memCache = memCache; @@ -81,24 +74,6 @@ ITeamService teamService _teamService = teamService; } - public string BuildGraderUrl() - { - var request = _httpContextAccessor.HttpContext.Request; - - return string.Join('/', new string[] - { - _linkGenerator.GetUriByAction - ( - _httpContextAccessor.HttpContext, - "Grade", - "Challenge", - null, - request.Scheme, - request.Host,request.PathBase - ) - }); - } - public async Task GetOrCreate(NewChallenge model, string actorId, string graderUrl) { var entity = await _challengeStore.Load(model); diff --git a/src/Gameboard.Api/Features/Game/GameStart/ExternalSyncGameStartService.cs b/src/Gameboard.Api/Features/Game/GameStart/ExternalSyncGameStartService.cs index 7ab2a5937..fcf662c1a 100644 --- a/src/Gameboard.Api/Features/Game/GameStart/ExternalSyncGameStartService.cs +++ b/src/Gameboard.Api/Features/Game/GameStart/ExternalSyncGameStartService.cs @@ -7,6 +7,7 @@ using Gameboard.Api.Common.Services; using Gameboard.Api.Data; using Gameboard.Api.Data.Abstractions; +using Gameboard.Api.Features.Challenges; using Gameboard.Api.Features.GameEngine; using Gameboard.Api.Features.Games.Start; using Gameboard.Api.Features.Teams; @@ -25,9 +26,11 @@ internal class ExternalSyncGameStartService : IExternalSyncGameStartService private readonly ChallengeService _challengeService; private readonly IChallengeStore _challengeStore; private readonly IStore _challengeSpecStore; + private readonly CoreOptions _coreOptions; private readonly IGamebrainService _gamebrainService; private readonly IGameEngineService _gameEngineService; private readonly IGameHubBus _gameHubBus; + private readonly IChallengeGraderUrlService _graderUrlService; private readonly IJsonService _jsonService; private readonly ILockService _lockService; private readonly ILogger _logger; @@ -45,9 +48,11 @@ public ExternalSyncGameStartService ChallengeService challengeService, IChallengeStore challengeStore, IStore challengeSpecStore, + CoreOptions coreOptions, IGamebrainService gamebrainService, IGameEngineService gameEngineService, IGameHubBus gameHubBus, + IChallengeGraderUrlService graderUrlService, IJsonService jsonService, ILockService lockService, ILogger logger, @@ -64,9 +69,11 @@ IValidatorService validator _challengeService = challengeService; _challengeStore = challengeStore; _challengeSpecStore = challengeSpecStore; + _coreOptions = coreOptions; _gamebrainService = gamebrainService; _gameEngineService = gameEngineService; _gameHubBus = gameHubBus; + _graderUrlService = graderUrlService; _jsonService = jsonService; _lockService = lockService; _logger = logger; @@ -129,7 +136,6 @@ public async Task Start(GameModeStartRequest request, Cancellati await _store.DoTransaction(async dbContext => { - var debugDbContext = dbContext.ChangeTracker.DebugView.ShortView; Log("Gathering data...", request.GameId); await _gameHubBus.SendExternalGameLaunchStart(request.State); @@ -241,7 +247,7 @@ public async Task GetStartPhase(string gameId, CancellationToken private async Task>> DeployChallenges(GameModeStartRequest request, CancellationToken cancellationToken) { - Log("Deploying challenges...", request.GameId); + Log($"Deploying {request.State.ChallengesTotal} challenges/gamespaces...", request.GameId); var teamDeployedChallenges = new Dictionary>(); // deploy all challenges @@ -267,7 +273,7 @@ private async Task>> DeployChallenges(GameMo Variant = 0 }, team.Captain.UserId, - _challengeService.BuildGraderUrl(), + _graderUrlService.BuildGraderUrl(), cancellationToken ); @@ -291,27 +297,19 @@ private async Task> DeployGa await _gameHubBus.SendExternalGameGamespacesDeployStart(request.State); var challengeGamespaces = new Dictionary(); - // Create one task for each gamespace - var gamespaceTasks = request.State.ChallengesCreated.Select(async c => - { - _logger.LogInformation(message: $"""Starting {c.GameEngineType} gamespace for challenge "{c.Challenge.Id}" (teamId "{c.TeamId}")..."""); - var challengeState = await _gameEngineService.StartGamespace(new GameEngineGamespaceStartRequest - { - ChallengeId = c.Challenge.Id, - GameEngineType = c.GameEngineType - }); - - request.State.GamespacesStarted.Add(challengeState); - await _gameHubBus.SendExternalGameGamespacesDeployProgressChange(request.State); - _logger.LogInformation(message: $"""Gamespace started for challenge "{c.Challenge.Id}"."""); + // Create one task for each gamespace in batches of the size specified in the app's + // helm chart config + var gamespaceDeployBatches = BuildDeployBatches(request, _coreOptions); + var challengeStates = new Dictionary(); - // keep the state given to us by the engine - return challengeState; - }); + Log($"Using {gamespaceDeployBatches.Count()} batches to deploy {request.State.ChallengesTotal} challenges...", request.GameId); + foreach (var batch in gamespaceDeployBatches) + { + var deployResults = await Task.WhenAll(batch); - // fire off the tasks and wait - var challengeStates = (await Task.WhenAll(gamespaceTasks)) - .ToDictionary(state => state.Id); + foreach (var deployResult in deployResults) + challengeStates.Add(deployResult.Id, deployResult); + } foreach (var deployedChallenge in request.State.ChallengesCreated) { @@ -340,6 +338,63 @@ await _store return challengeGamespaces; } + /// + /// Create batches of gamespace deploy requests from challenges. + /// + /// An external game can have many challenges, and each challenge has an associated gamespace. For + /// each gamespace, Gameboard must issue a request to the game engine that causes it to deploy + /// the gamespace. Requesting all of these at once can cause issues with request timeouts, so + /// we optionally allow Gameboard sysadmins to configure a batch size appropriate to their game engine. + /// + /// By default, this value is 4, meaning that upon start of an external game, Gameboard will issue + /// requests to the game engine in batches of 4 until all gamespaces have been deployed. Configure this + /// with the Core__GameEngineDeployBatchSize setting in Gameboard's helm chart. + /// + /// + /// + /// + private IEnumerable>> BuildDeployBatches(GameModeStartRequest request, CoreOptions coreOptions) + { + // first, create a task for each gamespace to be deployed + var gamespaceTasks = request.State.ChallengesCreated.Select(async c => + { + _logger.LogInformation(message: $"""Starting {c.GameEngineType} gamespace for challenge "{c.Challenge.Id}" (teamId "{c.TeamId}")..."""); + var challengeState = await _gameEngineService.StartGamespace(new GameEngineGamespaceStartRequest + { + ChallengeId = c.Challenge.Id, + GameEngineType = c.GameEngineType + }); + + request.State.GamespacesStarted.Add(challengeState); + await _gameHubBus.SendExternalGameGamespacesDeployProgressChange(request.State); + _logger.LogInformation(message: $"""Gamespace started for challenge "{c.Challenge.Id}"."""); + + // keep the state given to us by the engine + return challengeState; + }).ToArray(); + + // if the setting isn't configured or is a nonsense value, just return all the tasks in one batch + if (coreOptions.GameEngineDeployBatchSize <= 1) + return new IEnumerable>[] { gamespaceTasks.ToArray() }; + + // otherwise, create batches of the appropriate size plus an additional batch for any leftovers + var batchList = new List>>(); + List> currentBatch = null; + + for (var challengeIndex = 0; challengeIndex < gamespaceTasks.Length; challengeIndex++) + { + if (challengeIndex % _coreOptions.GameEngineDeployBatchSize == 0) + { + currentBatch = new List>(); + batchList.Add(currentBatch); + } + + currentBatch.Add(gamespaceTasks[challengeIndex]); + } + + return batchList; + } + private async Task> DeployGamespaces(GameModeStartRequest request, CancellationToken cancellationToken) { // start all gamespaces @@ -445,6 +500,13 @@ await _store .ExecuteUpdateAsync(g => g.SetProperty(g => g.GameEnd, gameEndTime), cancellationToken); } + private Task ChallengeToGamespaceStartTask(GameStartStateChallenge c) + => _gameEngineService.StartGamespace(new GameEngineGamespaceStartRequest + { + ChallengeId = c.Challenge.Id, + GameEngineType = c.GameEngineType + }); + private void Log(string message, string gameId) { var prefix = $"""[EXTERNAL / SYNC - START GAME "{gameId}"] - {_now.Get()} - """; diff --git a/src/Gameboard.Api/Features/Game/GameStart/GameStartService.cs b/src/Gameboard.Api/Features/Game/GameStart/GameStartService.cs index fd7a1e9f0..f21cb02fb 100644 --- a/src/Gameboard.Api/Features/Game/GameStart/GameStartService.cs +++ b/src/Gameboard.Api/Features/Game/GameStart/GameStartService.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -11,6 +10,7 @@ using Gameboard.Api.Structure; using MediatR; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Gameboard.Api.Features.Games.Start; @@ -25,6 +25,7 @@ public interface IGameStartService internal class GameStartService : IGameStartService { private readonly IExternalSyncGameStartService _externalSyncGameStartService; + private readonly IFireAndForgetService _fireAndForgetService; private readonly IGameHubBus _gameHubBus; private readonly ILogger _logger; private readonly IMapper _mapper; @@ -37,6 +38,7 @@ internal class GameStartService : IGameStartService public GameStartService ( IExternalSyncGameStartService externalSyncGameStartService, + IFireAndForgetService fireAndForgetService, IGameHubBus gameHubBus, ILogger logger, IMediator mediator, @@ -48,6 +50,7 @@ ITeamService teamService ) { _externalSyncGameStartService = externalSyncGameStartService; + _fireAndForgetService = fireAndForgetService; _gameHubBus = gameHubBus; _logger = logger; _mapper = mapper; @@ -100,7 +103,14 @@ public async Task HandleSyncStartStateChanged(string gameId, CancellationToken c return; // for now, we're assuming the "happy path" of sync start games being external games, but we'll separate them later - // var session = await StartSynchronizedSession(gameId); + // NOTE: we also use a special service to kick this off, because if we don't, the player who initiated the game start + // won't get a response for several minutes and will likely receive a timeout error. Updates on the status + // of the game launch are reported via SignalR. + // _fireAndForgetService.Fire(async serviceScope => + // { + // var service = serviceScope.ServiceProvider.GetRequiredService(); + // await service.Start(new GameStartRequest { GameId = state.Game.Id }, cancellationToken); + // }, cancellationToken); await Start(new GameStartRequest { GameId = state.Game.Id }, cancellationToken); } diff --git a/src/Gameboard.Api/Features/Game/GameStart/SyncStartGameService.cs b/src/Gameboard.Api/Features/Game/GameStart/SyncStartGameService.cs index 11a325e19..3c3ef222b 100644 --- a/src/Gameboard.Api/Features/Game/GameStart/SyncStartGameService.cs +++ b/src/Gameboard.Api/Features/Game/GameStart/SyncStartGameService.cs @@ -89,7 +89,7 @@ public async Task GetSyncStartState(string gameId, CancellationT var players = await _store .WithNoTracking() .Where(p => p.GameId == gameId) - .ToArrayAsync(); + .ToArrayAsync(cancellationToken); // if we have no players, we're not ready to play if (!players.Any()) diff --git a/src/Gameboard.Api/Structure/AppSettings.cs b/src/Gameboard.Api/Structure/AppSettings.cs index 30ab99678..e63a69681 100644 --- a/src/Gameboard.Api/Structure/AppSettings.cs +++ b/src/Gameboard.Api/Structure/AppSettings.cs @@ -159,6 +159,7 @@ public CorsPolicy Build() public class CoreOptions { + public int GameEngineDeployBatchSize { get; set; } = 4; public string GameEngineUrl { get; set; } = "http://localhost:5004"; public string GamebrainUrl { get; set; } = "https://launchpad.cisa.gov/test/gamebrain/"; public string GameEngineClientName { get; set; } From 4cf5efeaf3c1b9eb9dddaf30533e83601ba3b27a Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 30 Oct 2023 11:53:38 -0400 Subject: [PATCH 2/6] Add logging on console access attempt. --- src/Gameboard.Api/Features/Challenge/ChallengeController.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Gameboard.Api/Features/Challenge/ChallengeController.cs b/src/Gameboard.Api/Features/Challenge/ChallengeController.cs index c053153d1..ba81724ce 100644 --- a/src/Gameboard.Api/Features/Challenge/ChallengeController.cs +++ b/src/Gameboard.Api/Features/Challenge/ChallengeController.cs @@ -296,8 +296,8 @@ public async Task> Audit([FromRoute] st public async Task GetConsole([FromBody] ConsoleRequest model) { await Validate(new Entity { Id = model.SessionId }); - var isTeamMember = await ChallengeService.UserIsTeamPlayer(model.SessionId, Actor.Id); + Logger.LogInformation($"""Console access attempt on console "{model.Id}": User {Actor.Id}, roles {Actor.Role}, is team member? {isTeamMember}."""); AuthorizeAny( () => Actor.IsDirector, @@ -305,7 +305,6 @@ public async Task GetConsole([FromBody] ConsoleRequest model) () => Actor.IsSupport, () => isTeamMember ); - var result = await ChallengeService.GetConsole(model, isTeamMember.Equals(false)); if (isTeamMember) From 70eaa86aadcf0f5b00b55eb9e6eaeb5716752304 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 30 Oct 2023 12:29:55 -0400 Subject: [PATCH 3/6] Code cleanup --- .../PlayersTableDenormalizationService.cs | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/src/Gameboard.Api/Features/Player/PlayersTableDenormalizationService.cs b/src/Gameboard.Api/Features/Player/PlayersTableDenormalizationService.cs index 5457365f5..1abc61b0a 100644 --- a/src/Gameboard.Api/Features/Player/PlayersTableDenormalizationService.cs +++ b/src/Gameboard.Api/Features/Player/PlayersTableDenormalizationService.cs @@ -90,28 +90,4 @@ await _store.DoTransaction(async ctx => await ctx.SaveChangesAsync(cancellationToken); }, cancellationToken); } - - /* - public async Task UpdateRanks(string gameId) - { - var players = await DbContext.Players - .Where(p => p.GameId == gameId) - .OrderByDescending(p => p.Score) - .ThenBy(p => p.Time) - .ThenByDescending(p => p.CorrectCount) - .ThenByDescending(p => p.PartialCount) - .ToArrayAsync() - ; - int rank = 0; - - foreach (var team in players.GroupBy(p => p.TeamId)) - { - rank += 1; - foreach (var player in team) - player.Rank = rank; - } - - await DbContext.SaveChangesAsync(); - } - */ } From 038c9f50d3cb994a974da7105559e56c4769ad4b Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 30 Oct 2023 13:24:07 -0400 Subject: [PATCH 4/6] Guard against null teamIds in enrollment report. --- .../Reports/Queries/EnrollmentReport/EnrollmentReportService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs index e61dade30..f4464e198 100644 --- a/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs +++ b/src/Gameboard.Api/Features/Reports/Queries/EnrollmentReport/EnrollmentReportService.cs @@ -125,6 +125,7 @@ public async Task GetRawResults(EnrollmentReportPara MaxPossiblePoints = c.Points }) }) + .Where(p => p.TeamId is not null && p.TeamId != string.Empty) .GroupBy(p => p.TeamId) .ToDictionary(g => g.Key, g => g.ToList()); From f61b5f6ad6bceda1899fb204e3591caa4f596bfe Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 30 Oct 2023 14:42:22 -0400 Subject: [PATCH 5/6] Add tests for batch deploy of external game resources. --- .../ExternalGameDeployBatchServiceTests.cs | 82 +++++++++++++++++ .../ExternalGameDeployBatchService.cs | 90 +++++++++++++++++++ .../GameStart/ExternalSyncGameStartService.cs | 65 +------------- .../Game/GameStart/GameStartModels.cs | 4 +- .../Game/GameStart/GameStartService.cs | 1 - 5 files changed, 178 insertions(+), 64 deletions(-) create mode 100644 src/Gameboard.Api.Tests.Unit/Tests/Features/Games/ExternalGameDeployBatchServiceTests.cs create mode 100644 src/Gameboard.Api/Features/Game/External/Services/ExternalGameDeployBatchService.cs diff --git a/src/Gameboard.Api.Tests.Unit/Tests/Features/Games/ExternalGameDeployBatchServiceTests.cs b/src/Gameboard.Api.Tests.Unit/Tests/Features/Games/ExternalGameDeployBatchServiceTests.cs new file mode 100644 index 000000000..c56266ab0 --- /dev/null +++ b/src/Gameboard.Api.Tests.Unit/Tests/Features/Games/ExternalGameDeployBatchServiceTests.cs @@ -0,0 +1,82 @@ +using Gameboard.Api.Common; +using Gameboard.Api.Features.GameEngine; +using Gameboard.Api.Features.Games; +using Gameboard.Api.Features.Games.External; +using Microsoft.Extensions.Logging; + +namespace Gameboard.Api.Tests.Unit; + +public class ExternalGameDeployBatchServiceTests +{ + [Theory, GameboardAutoData] + public void BuildDeployBatches_WithFixedBatchSizeAndChallengeCount_ReturnsCorrectBatchCount(IFixture fixture) + { + // given a deploy request with 17 challenges and a batch size of 6 + var challengeCount = 17; + var request = BuildFakeGameModeStartRequest(challengeCount, fixture); + + // create sut and its options + var sut = new ExternalGameDeployBatchService + ( + new CoreOptions { GameEngineDeployBatchSize = 6 }, + A.Fake(), + A.Fake(), + A.Fake>() + ); + + // when batches are built + var result = sut.BuildDeployBatches(request); + + // we expect three batches + result.Count().ShouldBe(3); + // and the last should have 5 tasks in it + result.Last().Count().ShouldBe(5); + } + + [Theory, GameboardAutoData] + public void BuildDeployBatches_WithNoConfiguredBatchSize_ReturnsExpectedBatchCount(IFixture fixture) + { + // given a deploy request with any challenge count and no set batch size + var challengeCount = fixture.Create(); + var request = BuildFakeGameModeStartRequest(challengeCount, fixture); + + var sut = new ExternalGameDeployBatchService + ( + new CoreOptions { GameEngineDeployBatchSize = 0 }, + A.Fake(), + A.Fake(), + A.Fake>() + ); + + // when batches are built + var result = sut.BuildDeployBatches(request); + + // we expect one batch with length equal to the challenge count + result.Count().ShouldBe(1); + result.First().Count().ShouldBe(challengeCount); + } + + private GameModeStartRequest BuildFakeGameModeStartRequest(int challengeCount, IFixture fixture) + { + var gameId = fixture.Create(); + + var request = new GameModeStartRequest + { + GameId = fixture.Create(), + State = new GameStartState + { + Game = new SimpleEntity { Id = gameId, Name = "game" }, + ChallengesTotal = challengeCount, + Now = DateTimeOffset.UtcNow + }, + Context = new GameModeStartRequestContext + { + SessionLengthMinutes = fixture.Create(), + SpecIds = fixture.CreateMany(challengeCount) + } + }; + + request.State.ChallengesCreated.AddRange(fixture.CreateMany(challengeCount).ToList()); + return request; + } +} diff --git a/src/Gameboard.Api/Features/Game/External/Services/ExternalGameDeployBatchService.cs b/src/Gameboard.Api/Features/Game/External/Services/ExternalGameDeployBatchService.cs new file mode 100644 index 000000000..87cd4bcff --- /dev/null +++ b/src/Gameboard.Api/Features/Game/External/Services/ExternalGameDeployBatchService.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Gameboard.Api.Features.GameEngine; +using Microsoft.Extensions.Logging; + +namespace Gameboard.Api.Features.Games.External; + +public interface IExternalGameDeployBatchService +{ + /// + /// Create batches of gamespace deploy requests from challenges. + /// + /// An external game can have many challenges, and each challenge has an associated gamespace. For + /// each gamespace, Gameboard must issue a request to the game engine that causes it to deploy + /// the gamespace. Requesting all of these at once can cause issues with request timeouts, so + /// we optionally allow Gameboard sysadmins to configure a batch size appropriate to their game engine. + /// + /// By default, this value is 4, meaning that upon start of an external game, Gameboard will issue + /// requests to the game engine in batches of 4 until all gamespaces have been deployed. Configure this + /// with the Core__GameEngineDeployBatchSize setting in Gameboard's helm chart. + /// + /// + /// + IEnumerable>> BuildDeployBatches(GameModeStartRequest request); +} + +internal class ExternalGameDeployBatchService : IExternalGameDeployBatchService +{ + private readonly CoreOptions _coreOptions; + private readonly IGameEngineService _gameEngine; + private readonly IGameHubBus _gameHubBus; + private readonly ILogger _logger; + + public ExternalGameDeployBatchService + ( + CoreOptions coreOptions, + IGameEngineService gameEngine, + IGameHubBus gameHubBus, + ILogger logger + ) + { + _coreOptions = coreOptions; + _gameEngine = gameEngine; + _gameHubBus = gameHubBus; + _logger = logger; + } + + public IEnumerable>> BuildDeployBatches(GameModeStartRequest request) + { + // first, create a task for each gamespace to be deployed + var gamespaceTasks = request.State.ChallengesCreated.Select(async c => + { + _logger.LogInformation(message: $"""Starting {c.GameEngineType} gamespace for challenge "{c.Challenge.Id}" (teamId "{c.TeamId}")..."""); + var challengeState = await _gameEngine.StartGamespace(new GameEngineGamespaceStartRequest + { + ChallengeId = c.Challenge.Id, + GameEngineType = c.GameEngineType + }); + + request.State.GamespacesStarted.Add(challengeState); + await _gameHubBus.SendExternalGameGamespacesDeployProgressChange(request.State); + _logger.LogInformation(message: $"""Gamespace started for challenge "{c.Challenge.Id}"."""); + + // keep the state given to us by the engine + return challengeState; + }).ToArray(); + + // if the setting isn't configured or is a nonsense value, just return all the tasks in one batch + if (_coreOptions.GameEngineDeployBatchSize <= 1) + return new IEnumerable>[] { gamespaceTasks.ToArray() }; + + // otherwise, create batches of the appropriate size plus an additional batch for any leftovers + var batchList = new List>>(); + List> currentBatch = null; + + for (var challengeIndex = 0; challengeIndex < gamespaceTasks.Length; challengeIndex++) + { + if (challengeIndex % _coreOptions.GameEngineDeployBatchSize == 0) + { + currentBatch = new List>(); + batchList.Add(currentBatch); + } + + currentBatch.Add(gamespaceTasks[challengeIndex]); + } + + return batchList; + } +} diff --git a/src/Gameboard.Api/Features/Game/GameStart/ExternalSyncGameStartService.cs b/src/Gameboard.Api/Features/Game/GameStart/ExternalSyncGameStartService.cs index fcf662c1a..007515d00 100644 --- a/src/Gameboard.Api/Features/Game/GameStart/ExternalSyncGameStartService.cs +++ b/src/Gameboard.Api/Features/Game/GameStart/ExternalSyncGameStartService.cs @@ -26,7 +26,7 @@ internal class ExternalSyncGameStartService : IExternalSyncGameStartService private readonly ChallengeService _challengeService; private readonly IChallengeStore _challengeStore; private readonly IStore _challengeSpecStore; - private readonly CoreOptions _coreOptions; + private readonly IExternalGameDeployBatchService _externalGameDeployBatchService; private readonly IGamebrainService _gamebrainService; private readonly IGameEngineService _gameEngineService; private readonly IGameHubBus _gameHubBus; @@ -48,7 +48,7 @@ public ExternalSyncGameStartService ChallengeService challengeService, IChallengeStore challengeStore, IStore challengeSpecStore, - CoreOptions coreOptions, + IExternalGameDeployBatchService externalGameDeployBatchService, IGamebrainService gamebrainService, IGameEngineService gameEngineService, IGameHubBus gameHubBus, @@ -69,7 +69,7 @@ IValidatorService validator _challengeService = challengeService; _challengeStore = challengeStore; _challengeSpecStore = challengeSpecStore; - _coreOptions = coreOptions; + _externalGameDeployBatchService = externalGameDeployBatchService; _gamebrainService = gamebrainService; _gameEngineService = gameEngineService; _gameHubBus = gameHubBus; @@ -299,7 +299,7 @@ private async Task> DeployGa // Create one task for each gamespace in batches of the size specified in the app's // helm chart config - var gamespaceDeployBatches = BuildDeployBatches(request, _coreOptions); + var gamespaceDeployBatches = _externalGameDeployBatchService.BuildDeployBatches(request); var challengeStates = new Dictionary(); Log($"Using {gamespaceDeployBatches.Count()} batches to deploy {request.State.ChallengesTotal} challenges...", request.GameId); @@ -338,63 +338,6 @@ await _store return challengeGamespaces; } - /// - /// Create batches of gamespace deploy requests from challenges. - /// - /// An external game can have many challenges, and each challenge has an associated gamespace. For - /// each gamespace, Gameboard must issue a request to the game engine that causes it to deploy - /// the gamespace. Requesting all of these at once can cause issues with request timeouts, so - /// we optionally allow Gameboard sysadmins to configure a batch size appropriate to their game engine. - /// - /// By default, this value is 4, meaning that upon start of an external game, Gameboard will issue - /// requests to the game engine in batches of 4 until all gamespaces have been deployed. Configure this - /// with the Core__GameEngineDeployBatchSize setting in Gameboard's helm chart. - /// - /// - /// - /// - private IEnumerable>> BuildDeployBatches(GameModeStartRequest request, CoreOptions coreOptions) - { - // first, create a task for each gamespace to be deployed - var gamespaceTasks = request.State.ChallengesCreated.Select(async c => - { - _logger.LogInformation(message: $"""Starting {c.GameEngineType} gamespace for challenge "{c.Challenge.Id}" (teamId "{c.TeamId}")..."""); - var challengeState = await _gameEngineService.StartGamespace(new GameEngineGamespaceStartRequest - { - ChallengeId = c.Challenge.Id, - GameEngineType = c.GameEngineType - }); - - request.State.GamespacesStarted.Add(challengeState); - await _gameHubBus.SendExternalGameGamespacesDeployProgressChange(request.State); - _logger.LogInformation(message: $"""Gamespace started for challenge "{c.Challenge.Id}"."""); - - // keep the state given to us by the engine - return challengeState; - }).ToArray(); - - // if the setting isn't configured or is a nonsense value, just return all the tasks in one batch - if (coreOptions.GameEngineDeployBatchSize <= 1) - return new IEnumerable>[] { gamespaceTasks.ToArray() }; - - // otherwise, create batches of the appropriate size plus an additional batch for any leftovers - var batchList = new List>>(); - List> currentBatch = null; - - for (var challengeIndex = 0; challengeIndex < gamespaceTasks.Length; challengeIndex++) - { - if (challengeIndex % _coreOptions.GameEngineDeployBatchSize == 0) - { - currentBatch = new List>(); - batchList.Add(currentBatch); - } - - currentBatch.Add(gamespaceTasks[challengeIndex]); - } - - return batchList; - } - private async Task> DeployGamespaces(GameModeStartRequest request, CancellationToken cancellationToken) { // start all gamespaces diff --git a/src/Gameboard.Api/Features/Game/GameStart/GameStartModels.cs b/src/Gameboard.Api/Features/Game/GameStart/GameStartModels.cs index 19cfc057b..b44b2b4b9 100644 --- a/src/Gameboard.Api/Features/Game/GameStart/GameStartModels.cs +++ b/src/Gameboard.Api/Features/Game/GameStart/GameStartModels.cs @@ -7,9 +7,9 @@ namespace Gameboard.Api.Features.Games; public class GameStartState { public required SimpleEntity Game { get; set; } - public List ChallengesCreated { get; private set; } = new List(); + public List ChallengesCreated { get; } = new List(); public int ChallengesTotal { get; set; } = 0; - public List GamespacesStarted { get; set; } = new List(); + public List GamespacesStarted { get; } = new List(); public int GamespacesTotal { get; set; } = 0; public List Players { get; } = new List(); public List Teams { get; } = new List(); diff --git a/src/Gameboard.Api/Features/Game/GameStart/GameStartService.cs b/src/Gameboard.Api/Features/Game/GameStart/GameStartService.cs index f21cb02fb..2b13e498b 100644 --- a/src/Gameboard.Api/Features/Game/GameStart/GameStartService.cs +++ b/src/Gameboard.Api/Features/Game/GameStart/GameStartService.cs @@ -10,7 +10,6 @@ using Gameboard.Api.Structure; using MediatR; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Gameboard.Api.Features.Games.Start; From f3f13dbb6607ead3d2a6d37cde3dc8bd6d360712 Mon Sep 17 00:00:00 2001 From: Ben Stein Date: Mon, 30 Oct 2023 15:25:38 -0400 Subject: [PATCH 6/6] Fixed an issue that prevented player scores, ranks, and challenge completion counts not to be zeroed when their session is reset but not unenrolled. --- .../Teams/Requests/ResetTeamSesssion/ResetTeamSession.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Gameboard.Api/Features/Teams/Requests/ResetTeamSesssion/ResetTeamSession.cs b/src/Gameboard.Api/Features/Teams/Requests/ResetTeamSesssion/ResetTeamSession.cs index a543eead3..ad1b90d2a 100644 --- a/src/Gameboard.Api/Features/Teams/Requests/ResetTeamSesssion/ResetTeamSession.cs +++ b/src/Gameboard.Api/Features/Teams/Requests/ResetTeamSesssion/ResetTeamSession.cs @@ -72,10 +72,14 @@ await _store .ExecuteUpdateAsync ( p => p + .SetProperty(p => p.CorrectCount, 0) + .SetProperty(p => p.IsReady, false) + .SetProperty(p => p.PartialCount, 0) + .SetProperty(p => p.Rank, 0) + .SetProperty(p => p.Score, 0) .SetProperty(p => p.SessionBegin, DateTimeOffset.MinValue) .SetProperty(p => p.SessionEnd, DateTimeOffset.MinValue) - .SetProperty(p => p.SessionMinutes, 0) - .SetProperty(p => p.IsReady, false), + .SetProperty(p => p.SessionMinutes, 0), cancellationToken ); }