diff --git a/src/RosettaCTF.API/Controllers/ChallengesController.cs b/src/RosettaCTF.API/Controllers/ChallengesController.cs index 42921f6..fb9224c 100644 --- a/src/RosettaCTF.API/Controllers/ChallengesController.cs +++ b/src/RosettaCTF.API/Controllers/ChallengesController.cs @@ -40,7 +40,7 @@ public sealed class ChallengesController : RosettaControllerBase private ICtfChallengeRepository ChallengeRepository { get; } private ChallengePreviewRepository ChallengePreviewRepository { get; } private ICtfChallengeCacheRepository ChallengeCacheRepository { get; } - private IScoringModel ScoringModel { get; } + private ScoreCalculatorService ScoreCalculator { get; } public ChallengesController( ILoggerFactory loggerFactory, @@ -50,13 +50,13 @@ public ChallengesController( ICtfChallengeRepository challengeRepository, ChallengePreviewRepository challengePreviewRepository, ICtfChallengeCacheRepository challengeCacheRepository, - IScoringModel scoringModel) + ScoreCalculatorService scoreCalculator) : base(loggerFactory, userRepository, userPreviewRepository, ctfConfigurationLoader) { this.ChallengeRepository = challengeRepository; this.ChallengePreviewRepository = challengePreviewRepository; this.ChallengeCacheRepository = challengeCacheRepository; - this.ScoringModel = scoringModel; + this.ScoreCalculator = scoreCalculator; } [HttpGet] @@ -137,16 +137,10 @@ public async Task>> SubmitFlag([FromRoute] string i if (valid && this.EventConfiguration.Scoring != CtfScoringMode.Static) { - var solves = await this.ChallengeCacheRepository.IncrementSolveCountAsync(challenge.Id, cancellationToken); - var baseline = await this.ChallengeCacheRepository.GetBaselineSolveCountAsync(cancellationToken); - var rate = solves / (double)baseline; - var postRate = (solves + 1.0) / baseline; - var cscore = this.ScoringModel.ComputeScore(challenge.BaseScore, rate); - var pscore = this.ScoringModel.ComputeScore(challenge.BaseScore, postRate); - - await this.ChallengeCacheRepository.UpdateScoreAsync(challenge.Id, pscore, cancellationToken); + var scoreInfo = await this.ScoreCalculator.ComputeCurrentScoreAsync(challenge, cancellationToken); + if (this.EventConfiguration.Scoring == CtfScoringMode.Freezer) - score = cscore; + score = scoreInfo.Current; } var solve = await this.ChallengeRepository.SubmitSolveAsync(flag, valid, challenge.Id, this.RosettaUser.Id, this.RosettaUser.Team.Id, score, cancellationToken); @@ -154,8 +148,13 @@ public async Task>> SubmitFlag([FromRoute] string i return this.Conflict(ApiResult.FromError(new ApiError(ApiErrorCode.AlreadySolved, "Your team already solved this challenge."))); if (valid && challenge.BaseScore == 1) + { await this.ChallengeCacheRepository.IncrementBaselineSolveCountAsync(cancellationToken); + if (this.EventConfiguration.Scoring != CtfScoringMode.Static) + await this.ScoreCalculator.UpdateAllScoresAsync(this.EventConfiguration.Scoring == CtfScoringMode.Freezer, false, cancellationToken); + } + return this.Ok(ApiResult.FromResult(valid)); } } diff --git a/src/RosettaCTF.API/Controllers/CtfTimeController.cs b/src/RosettaCTF.API/Controllers/CtfTimeController.cs index f5518be..3190574 100644 --- a/src/RosettaCTF.API/Controllers/CtfTimeController.cs +++ b/src/RosettaCTF.API/Controllers/CtfTimeController.cs @@ -34,6 +34,7 @@ public sealed class CtfTimeController : RosettaControllerBase { private ICtfChallengeRepository ChallengeRepository { get; } private ICtfChallengeCacheRepository ChallengeCacheRepository { get; } + private ScoreCalculatorService ScoreCalculator { get; } public CtfTimeController( ILoggerFactory loggerFactory, @@ -62,6 +63,7 @@ public async Task> Scoreboard(CancellationToken Tasks = challenges.Select(x => x.Title), Standings = this.CreateStandings(solves.GroupBy(x => x.Team), points) .OrderByDescending(x => x.Score) + .ThenBy(x => x.LastAccept) .Select((x, i) => { x.Pos = i + 1; return x; }) }; return this.Ok(scoreboard); diff --git a/src/RosettaCTF.API/Models/ScoreInfo.cs b/src/RosettaCTF.API/Models/ScoreInfo.cs new file mode 100644 index 0000000..09849fb --- /dev/null +++ b/src/RosettaCTF.API/Models/ScoreInfo.cs @@ -0,0 +1,30 @@ +// This file is part of RosettaCTF project. +// +// Copyright 2020 Emzi0767 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace RosettaCTF.Models +{ + public struct ScoreInfo + { + public int Current { get; } + public int Next { get; } + + public ScoreInfo(int current, int next) + { + this.Current = current; + this.Next = next; + } + } +} diff --git a/src/RosettaCTF.API/Models/ScoreLock.cs b/src/RosettaCTF.API/Models/ScoreLock.cs new file mode 100644 index 0000000..da2fe3c --- /dev/null +++ b/src/RosettaCTF.API/Models/ScoreLock.cs @@ -0,0 +1,36 @@ +// This file is part of RosettaCTF project. +// +// Copyright 2020 Emzi0767 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Threading; + +namespace RosettaCTF.Models +{ + public sealed class ScoreLock : IDisposable + { + private SemaphoreSlim Semaphore { get; } + + public ScoreLock(SemaphoreSlim semaphore) + { + this.Semaphore = semaphore; + } + + public void Dispose() + { + this.Semaphore.Release(1); + } + } +} diff --git a/src/RosettaCTF.API/Services/ChallengeBootstrapperService.cs b/src/RosettaCTF.API/Services/ChallengeBootstrapperService.cs index d3a4c41..181b3de 100644 --- a/src/RosettaCTF.API/Services/ChallengeBootstrapperService.cs +++ b/src/RosettaCTF.API/Services/ChallengeBootstrapperService.cs @@ -58,11 +58,16 @@ public async Task StartAsync(CancellationToken cancellationToken) var repository = services.GetRequiredService(); var configLoader = services.GetRequiredService(); var cache = services.GetRequiredService(); + var scorer = services.GetRequiredService(); var challenges = configLoader.LoadChallenges(); + var @event = configLoader.LoadEventData(); await repository.InstallAsync(challenges, cancellationToken); await cache.InstallAsync(challenges.SelectMany(x => x.Challenges) .ToDictionary(x => x.Id, x => x.BaseScore), cancellationToken); + + if (@event.Scoring != CtfScoringMode.Static) + await scorer.UpdateAllScoresAsync(@event.Scoring == CtfScoringMode.Freezer, true, cancellationToken); } } diff --git a/src/RosettaCTF.API/Services/ChallengePreviewRepository.cs b/src/RosettaCTF.API/Services/ChallengePreviewRepository.cs index 7e0706f..12aa8e4 100644 --- a/src/RosettaCTF.API/Services/ChallengePreviewRepository.cs +++ b/src/RosettaCTF.API/Services/ChallengePreviewRepository.cs @@ -156,9 +156,10 @@ public IEnumerable GetScoreboard( if (score == 0 || score == null) score = x.Sum(x => scores?[x.Challenge.Id] ?? 0); - return new { team = teams[x.Key], score = score.Value }; + return new { team = teams[x.Key], score = score.Value, lastAccept = x.Last().Timestamp }; }) .OrderByDescending(x => x.score) + .ThenBy(x => x.lastAccept) .Select((x, i) => new ScoreboardEntryPreview(x.team, x.score, i + 1, null)) .ToList(); diff --git a/src/RosettaCTF.API/Services/ScoreCalculatorService.cs b/src/RosettaCTF.API/Services/ScoreCalculatorService.cs new file mode 100644 index 0000000..8048a83 --- /dev/null +++ b/src/RosettaCTF.API/Services/ScoreCalculatorService.cs @@ -0,0 +1,143 @@ +// This file is part of RosettaCTF project. +// +// Copyright 2020 Emzi0767 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using RosettaCTF.Data; +using RosettaCTF.Models; + +namespace RosettaCTF.Services +{ + /// + /// Handles score computations. + /// + public sealed class ScoreCalculatorService + { + private ICtfChallengeRepository ChallengeRepository { get; } + private ICtfChallengeCacheRepository ChallengeCacheRepository { get; } + private IScoringModel ScoringModel { get; } + private ScoreLockService ScoreLockService { get; } + + public ScoreCalculatorService( + ICtfChallengeRepository ctfChallengeRepository, + ICtfChallengeCacheRepository ctfChallengeCacheRepository, + IScoringModel scoringModel, + ScoreLockService scoreLockService) + { + this.ChallengeRepository = ctfChallengeRepository; + this.ChallengeCacheRepository = ctfChallengeCacheRepository; + this.ScoringModel = scoringModel; + this.ScoreLockService = scoreLockService; + } + + public async Task ComputeCurrentScoreAsync(ICtfChallenge challenge, CancellationToken cancellationToken = default) + { + using var _ = await this.ScoreLockService.AcquireLockAsync(challenge.Id, cancellationToken); + + var solves = await this.ChallengeCacheRepository.IncrementSolveCountAsync(challenge.Id, cancellationToken); + var baseline = await this.ChallengeCacheRepository.GetBaselineSolveCountAsync(cancellationToken); + var rate = solves / (double)baseline; + var postRate = (solves + 1.0) / baseline; + var cscore = this.ScoringModel.ComputeScore(challenge.BaseScore, rate); + var pscore = this.ScoringModel.ComputeScore(challenge.BaseScore, postRate); + + await this.ChallengeCacheRepository.UpdateScoreAsync(challenge.Id, pscore, cancellationToken); + return new ScoreInfo(cscore, pscore); + } + + public async Task UpdateAllScoresAsync(bool freezeScores, bool includeBaseline, CancellationToken cancellationToken = default) + { + var baseline = includeBaseline + ? await this.RecountBaselineAsync(cancellationToken) + : await this.ChallengeCacheRepository.GetBaselineSolveCountAsync(cancellationToken); + + if (includeBaseline) + await this.ChallengeCacheRepository.SetBaselineSolveCountAsync(baseline, cancellationToken); + + await this.UpdateAllScoresJeopardyAsync(baseline, cancellationToken); + if (freezeScores) + await this.UpdateAllScoresFreezerAsync(baseline, cancellationToken); + } + + private async Task RecountBaselineAsync(CancellationToken cancellationToken = default) + { + var solves = await this.ChallengeRepository.GetSuccessfulSolvesAsync(cancellationToken); + return solves.Count(x => x.Challenge.BaseScore <= 1); + } + + private async Task UpdateAllScoresJeopardyAsync(double baseline, CancellationToken cancellationToken = default) + { + var challenges = await this.ChallengeRepository.GetChallengesAsync(cancellationToken); + challenges = challenges.Where(x => x.BaseScore > 1); + var locks = await Task.WhenAll(challenges.Select(x => this.ScoreLockService.AcquireLockAsync(x.Id, cancellationToken))); + + try + { + foreach (var challenge in challenges) + { + var solves = await this.ChallengeCacheRepository.GetSolveCountAsync(challenge.Id, cancellationToken); + var rate = solves / baseline; + var score = this.ScoringModel.ComputeScore(challenge.BaseScore, rate); + await this.ChallengeCacheRepository.UpdateScoreAsync(challenge.Id, score, cancellationToken); + } + } + finally + { + foreach (var @lock in locks) + @lock.Dispose(); + } + } + + private async Task UpdateAllScoresFreezerAsync(double baseline, CancellationToken cancellationToken = default) + { + var solves = await this.ChallengeRepository.GetSuccessfulSolvesAsync(cancellationToken); + solves.Where(x => x.Challenge.BaseScore > 1); + var locks = await Task.WhenAll(solves.Select(x => x.Challenge.Id) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(x => this.ScoreLockService.AcquireLockAsync(x, cancellationToken))); + + try + { + var updates = this.CreateUpdates(solves, baseline); + await this.ChallengeRepository.UpdateSolvesAsync(updates, cancellationToken); + + + } + finally + { + foreach (var @lock in locks) + @lock.Dispose(); + } + } + + private IEnumerable CreateUpdates(IEnumerable solves, double baseline) + { + foreach (var solveGroup in solves.GroupBy(x => x.Challenge.Id, StringComparer.OrdinalIgnoreCase)) + { + var i = 0; + foreach (var solve in solveGroup) + { + var rate = i++ / baseline; + var score = this.ScoringModel.ComputeScore(solve.Challenge.BaseScore, rate); + yield return new CtfSolveUpdate(solve, score); + } + } + } + } +} diff --git a/src/RosettaCTF.API/Services/ScoreLockService.cs b/src/RosettaCTF.API/Services/ScoreLockService.cs new file mode 100644 index 0000000..435df6a --- /dev/null +++ b/src/RosettaCTF.API/Services/ScoreLockService.cs @@ -0,0 +1,43 @@ +// This file is part of RosettaCTF project. +// +// Copyright 2020 Emzi0767 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using RosettaCTF.Models; + +namespace RosettaCTF.Services +{ + public sealed class ScoreLockService + { + private ConcurrentDictionary ScoreSemaphores { get; } + + public ScoreLockService() + { + this.ScoreSemaphores = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + } + + public async Task AcquireLockAsync(string challengeId, CancellationToken cancellationToken = default) + { + if (!this.ScoreSemaphores.TryGetValue(challengeId, out var semaphore)) + semaphore = this.ScoreSemaphores.GetOrAdd(challengeId, new SemaphoreSlim(1, 1)); + + await semaphore.WaitAsync(cancellationToken); + return new ScoreLock(semaphore); + } + } +} diff --git a/src/RosettaCTF.API/Startup.cs b/src/RosettaCTF.API/Startup.cs index 61851d7..88947f2 100644 --- a/src/RosettaCTF.API/Startup.cs +++ b/src/RosettaCTF.API/Startup.cs @@ -191,7 +191,9 @@ public void ConfigureServices(IServiceCollection services) .AddSingleton() .AddScoped() .AddScoped() - .AddTransient(); + .AddTransient() + .AddScoped() + .AddSingleton(); services.AddHostedService(); diff --git a/src/RosettaCTF.Abstractions/Data/Challenges/CtfSolveUpdate.cs b/src/RosettaCTF.Abstractions/Data/Challenges/CtfSolveUpdate.cs new file mode 100644 index 0000000..fe0b34a --- /dev/null +++ b/src/RosettaCTF.Abstractions/Data/Challenges/CtfSolveUpdate.cs @@ -0,0 +1,45 @@ +// This file is part of RosettaCTF project. +// +// Copyright 2020 Emzi0767 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +namespace RosettaCTF.Data +{ + /// + /// Represents information for updating solve information. + /// + public struct CtfSolveUpdate + { + /// + /// Gets the solve to update. + /// + public ICtfSolveSubmission Solve { get; } + + /// + /// Gets the new score for the solve. + /// + public int? NewScore { get; } + + /// + /// Creates new solve update info. + /// + /// Solve to update. + /// Score to update with. + public CtfSolveUpdate(ICtfSolveSubmission solve, int? newScore) + { + this.Solve = solve; + this.NewScore = newScore; + } + } +} diff --git a/src/RosettaCTF.Abstractions/Data/ICtfChallengeCacheRepository.cs b/src/RosettaCTF.Abstractions/Data/ICtfChallengeCacheRepository.cs index 2f23ec3..02534fc 100644 --- a/src/RosettaCTF.Abstractions/Data/ICtfChallengeCacheRepository.cs +++ b/src/RosettaCTF.Abstractions/Data/ICtfChallengeCacheRepository.cs @@ -39,6 +39,14 @@ public interface ICtfChallengeCacheRepository /// The amount of teams that solved the baseline challenge. Task IncrementBaselineSolveCountAsync(CancellationToken cancellationToken = default); + /// + /// Stes the number of teams that solved the baseline challenge. + /// + /// Number of baseline solves to set. + /// A token to cancel the operation. + /// A task encapsulating the operation. + Task SetBaselineSolveCountAsync(int count, CancellationToken cancellationToken = default); + /// /// Gets the current score of a challenge. /// diff --git a/src/RosettaCTF.Abstractions/Data/ICtfChallengeRepository.cs b/src/RosettaCTF.Abstractions/Data/ICtfChallengeRepository.cs index a4e8b2a..bf23008 100644 --- a/src/RosettaCTF.Abstractions/Data/ICtfChallengeRepository.cs +++ b/src/RosettaCTF.Abstractions/Data/ICtfChallengeRepository.cs @@ -73,9 +73,26 @@ public interface ICtfChallengeRepository /// ID of the submitting team. /// Frozen score, if applicable. /// Token to cancel any pending operation. - /// The created solution. + /// The created solve. Task SubmitSolveAsync(string flag, bool isValid, string challengeId, long userId, long teamId, int? score, CancellationToken cancellationToken = default); + /// + /// Updates a single solve. + /// + /// ID of the solve entry to update. + /// Score to set for it. + /// Token to cancel any pending operation. + /// The updated solve. + Task UpdateSolveAsync(long id, int? score, CancellationToken cancellationToken = default); + + /// + /// Updates multiple solves. + /// + /// Solve updates to process. + /// Token to cancel any pending operation. + /// A task representing the operation. + Task UpdateSolvesAsync(IEnumerable solveUpdates, CancellationToken cancellationToken = default); + /// /// Gets successful solves. /// diff --git a/src/RosettaCTF.Common.targets b/src/RosettaCTF.Common.targets index 3753f5d..12a0df7 100644 --- a/src/RosettaCTF.Common.targets +++ b/src/RosettaCTF.Common.targets @@ -30,7 +30,7 @@ RosettaCTF - 1.2.2 + 1.2.3 $(Version).0 $(Version).0 diff --git a/src/RosettaCTF.UI/package-lock.json b/src/RosettaCTF.UI/package-lock.json index d6d34fe..a68bbae 100644 --- a/src/RosettaCTF.UI/package-lock.json +++ b/src/RosettaCTF.UI/package-lock.json @@ -1,6 +1,6 @@ { "name": "rosetta-ctf-ui", - "version": "1.2.1", + "version": "1.2.3", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/src/RosettaCTF.UI/package.json b/src/RosettaCTF.UI/package.json index 5a5fb56..bf1767f 100644 --- a/src/RosettaCTF.UI/package.json +++ b/src/RosettaCTF.UI/package.json @@ -1,6 +1,6 @@ { "name": "rosetta-ctf-ui", - "version": "1.2.2", + "version": "1.2.3", "license": "Apache-2.0", "author": { "name": "Emzi0767", diff --git a/src/RosettaCTF.UI/src/app/team/team-detail/team-detail.component.less b/src/RosettaCTF.UI/src/app/team/team-detail/team-detail.component.less index 48e71dd..d1889f1 100644 --- a/src/RosettaCTF.UI/src/app/team/team-detail/team-detail.component.less +++ b/src/RosettaCTF.UI/src/app/team/team-detail/team-detail.component.less @@ -26,12 +26,12 @@ div.members { font-size: 11pt; &.header { - border-bottom: 1px solid #222; + border-bottom: 1px solid @theme-background-darker; font-weight: 600; } &:nth-child(2n + 1):not(:nth-child(1)) { - background: #444; + background: @theme-background-semidarker; } } } diff --git a/src/RosettaCTF.UI/src/app/team/team-manage/team-manage.component.less b/src/RosettaCTF.UI/src/app/team/team-manage/team-manage.component.less index 4c428ce..85443dd 100644 --- a/src/RosettaCTF.UI/src/app/team/team-manage/team-manage.component.less +++ b/src/RosettaCTF.UI/src/app/team/team-manage/team-manage.component.less @@ -26,4 +26,4 @@ small { } .grid-table(members, 3, min-content 1fr min-content); -.grid-table(solves, 4, 2fr 2fr min-content 1fr); +.grid-table(solves, 4, 1fr 2fr min-content 1fr); diff --git a/src/cache/RosettaCTF.Cache.Redis/RedisChallengeCacheRepository.cs b/src/cache/RosettaCTF.Cache.Redis/RedisChallengeCacheRepository.cs index 02bb479..d5e3990 100644 --- a/src/cache/RosettaCTF.Cache.Redis/RedisChallengeCacheRepository.cs +++ b/src/cache/RosettaCTF.Cache.Redis/RedisChallengeCacheRepository.cs @@ -41,6 +41,9 @@ public async Task GetBaselineSolveCountAsync(CancellationToken cancellation public async Task IncrementBaselineSolveCountAsync(CancellationToken cancellationToken = default) => (int)await this.Redis.IncrementValueAsync(BaselineKey, SolvesKey) - 1; + public async Task SetBaselineSolveCountAsync(int count, CancellationToken cancellationToken = default) + => await this.Redis.SetValueAsync(count, BaselineKey, SolvesKey); + public async Task GetScoreAsync(string challengeId, CancellationToken cancellationToken = default) => await this.Redis.GetValueAsync(ChallengesKey, challengeId, ScoreKey); diff --git a/src/database/RosettaCTF.Database.PostgreSQL/PostgresChallengeRepository.cs b/src/database/RosettaCTF.Database.PostgreSQL/PostgresChallengeRepository.cs index b6b128f..939dc8a 100644 --- a/src/database/RosettaCTF.Database.PostgreSQL/PostgresChallengeRepository.cs +++ b/src/database/RosettaCTF.Database.PostgreSQL/PostgresChallengeRepository.cs @@ -187,11 +187,39 @@ public async Task SubmitSolveAsync(string flag, bool isVali catch { return null; } } + public async Task UpdateSolveAsync(long id, int? score, CancellationToken cancellationToken = default) + { + var solve = await this.Database.Solves.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (solve == null) + return null; + + solve.Score = score; + this.Database.Solves.Update(solve); + await this.Database.SaveChangesAsync(cancellationToken); + + return solve; + } + + public async Task UpdateSolvesAsync(IEnumerable solveUpdates, CancellationToken cancellationToken = default) + { + foreach (var solveUpdate in solveUpdates) + { + if (!(solveUpdate.Solve is PostgresSolveSubmission solve)) + continue; + + solve.Score = solveUpdate.NewScore; + this.Database.Solves.Update(solve); + } + + await this.Database.SaveChangesAsync(cancellationToken); + } + public async Task> GetSuccessfulSolvesAsync(CancellationToken cancellationToken = default) => await this.Database.Solves .Where(x => x.IsValid) .Include(x => x.TeamInternal) .Include(x => x.ChallengeInternal).ThenInclude(x => x.CategoryInternal) + .OrderBy(x => x.Timestamp) .ToListAsync(cancellationToken); public async Task> GetSuccessfulSolvesAsync(long teamId, CancellationToken cancellationToken = default) @@ -199,6 +227,7 @@ public async Task> GetSuccessfulSolvesAsync(lon .Include(x => x.ChallengeInternal).ThenInclude(x => x.CategoryInternal) .Include(x => x.UserInternal).ThenInclude(x => x.CountryInternal) .Where(x => x.IsValid && x.TeamId == teamId) + .OrderBy(x => x.Timestamp) .ToListAsync(cancellationToken); public async Task> GetSuccessfulSolvesAsync(string challengeId, CancellationToken cancellationToken = default) @@ -206,6 +235,7 @@ public async Task> GetSuccessfulSolvesAsync(str .Include(x => x.TeamInternal) .Include(x => x.ChallengeInternal).ThenInclude(x => x.CategoryInternal) .Where(x => x.IsValid && x.ChallengeId == challengeId) + .OrderBy(x => x.Timestamp) .ToListAsync(cancellationToken); public async Task> GetAllSolvesAsync(long lastId = -1, CancellationToken cancellationToken = default) @@ -214,6 +244,7 @@ public async Task> GetAllSolvesAsync(long lastI .OrderBy(x => x.Id) .Include(x => x.TeamInternal) .Include(x => x.ChallengeInternal).ThenInclude(x => x.CategoryInternal) + .OrderBy(x => x.Timestamp) .ToListAsync(cancellationToken); } }