Skip to content

Commit

Permalink
v3.27.1 (#576)
Browse files Browse the repository at this point in the history
* Fixed a bug that could cause score denormalization to choke without a rerank.

* Add unit tests for team cumulative time calculation

* Restored oprhaned team advance feature
  • Loading branch information
sei-bstein authored Jan 3, 2025
1 parent 23cb932 commit 25aade0
Show file tree
Hide file tree
Showing 10 changed files with 249 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using Gameboard.Api.Features.Teams;

namespace Gameboard.Api.Tests.Unit;

public class CumulativeTimeCalculatorTests
{
[Theory, GameboardAutoData]
public void CalculateCumulativeTimeMs_WithOneChallenge_EqualsSolveTime
(
int scoreTimeOffset,
IFixture fixture
)
{
// given a challenge with fixed start and end
var startTime = fixture.Create<DateTimeOffset>();
var teamTime = new TeamChallengeTime
{
TeamId = fixture.Create<string>(),
ChallengeId = fixture.Create<string>(),
StartTime = startTime,
LastScoreTime = startTime.AddMilliseconds(scoreTimeOffset)
};

// when it's calculated
var sut = new CumulativeTimeCalculator();
var result = sut.CalculativeCumulativeTimeMs([teamTime]);

// then
result.ShouldBe(scoreTimeOffset);
}

[Theory, GameboardAutoData]
public void CalculateCumulativeTimeMs_WithTwoChallenge_EqualsSolveTime
(
int scoreTimeOffset1,
int scoreTimeOffset2,
DateTimeOffset startTime1,
DateTimeOffset startTime2,
IFixture fixture
)
{
// given a challenge with fixed start and end
var teamTimes = new TeamChallengeTime[]
{
new()
{
TeamId = fixture.Create<string>(),
ChallengeId = fixture.Create<string>(),
StartTime = startTime1,
LastScoreTime = startTime1.AddMilliseconds(scoreTimeOffset1)
},
new()
{
TeamId = fixture.Create<string>(),
ChallengeId = fixture.Create<string>(),
StartTime = startTime2,
LastScoreTime = startTime2.AddMilliseconds(scoreTimeOffset2)
}
};

// when it's calculated
var sut = new CumulativeTimeCalculator();
var result = sut.CalculativeCumulativeTimeMs(teamTimes);

// then
result.ShouldBe(scoreTimeOffset1 + scoreTimeOffset2);
}

[Theory, GameboardAutoData]
public void CalculateCumulativeTimeMs_WithUnstartedChallenge_YieldsZero(IFixture fixture)
{
// given a challenge with fixed start and end
var teamTimes = new TeamChallengeTime[]
{
new()
{
TeamId = fixture.Create<string>(),
ChallengeId = fixture.Create<string>(),
StartTime = null,
LastScoreTime = fixture.Create<DateTimeOffset>()
}
};

// when it's calculated
var sut = new CumulativeTimeCalculator();
var result = sut.CalculativeCumulativeTimeMs(teamTimes);

// then
result.ShouldBe(0);
}

[Theory, GameboardAutoData]
public void CalculateCumulativeTimeMs_WithUnscoredChallenge_YieldsZero(IFixture fixture)
{
// given a challenge with fixed start and end
var teamTimes = new TeamChallengeTime[]
{
new()
{
TeamId = fixture.Create<string>(),
ChallengeId = fixture.Create<string>(),
StartTime = fixture.Create<DateTimeOffset>(),
LastScoreTime = null
}
};

// when it's calculated
var sut = new CumulativeTimeCalculator();
var result = sut.CalculativeCumulativeTimeMs(teamTimes);

// then
result.ShouldBe(0);
}
}
8 changes: 8 additions & 0 deletions src/Gameboard.Api/Data/Store/Store.cs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ public Task<TEntity> SingleOrDefaultAsync<TEntity>(Expression<Func<TEntity, bool

public async Task<IEnumerable<TEntity>> SaveAddRange<TEntity>(params TEntity[] entities) where TEntity : class, IEntity
{
foreach (var entity in entities)
{
if (entity.Id.IsEmpty())
{
entity.Id = _guids.Generate();
}
}

_dbContext.AddRange(entities);
await _dbContext.SaveChangesAsync();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,12 @@ public async Task<GameCenterTeamsResults> Handle(GetGameCenterTeamsQuery request
Id = tId,
Name = captain.Name,
IsExtended = captain.SessionEnd is not null && captain.SessionBegin is not null && (captain.SessionEnd.Value - captain.SessionBegin.Value).TotalMinutes > gameSessionMinutes,
Advancement = captain.Advancement is null ? null : new GameCenterTeamsAdvancement
{
FromGame = captain.Advancement.FromGame,
FromTeam = captain.Advancement.FromTeam,
Score = captain.Advancement.Score
},
Captain = new GameCenterTeamsPlayer
{
Id = captain.Id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public sealed class GameCenterTeamsResultsTeam
public required string Id { get; set; }
public required string Name { get; set; }

public required GameCenterTeamsAdvancement Advancement { get; set; }
public required GameCenterTeamsPlayer Captain { get; set; }
public required int ChallengesCompleteCount { get; set; }
public required int ChallengesPartialCount { get; set; }
Expand All @@ -60,7 +61,7 @@ public sealed class GameCenterTeamsAdvancement
{
public required SimpleEntity FromGame { get; set; }
public required SimpleEntity FromTeam { get; set; }
public required double Score { get; set; }
public required double? Score { get; set; }
}

public enum GameCenterTeamsSessionStatus
Expand Down
8 changes: 5 additions & 3 deletions src/Gameboard.Api/Features/Player/Services/PlayerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,7 @@ public async Task AdvanceTeams(TeamAdvancement model)
var teams = await _store
.WithNoTracking<Data.Player>()
.Where(p => p.GameId == model.GameId)
.Where(p => model.TeamIds.Contains(p.TeamId))
.Select(p => new
{
p.Id,
Expand Down Expand Up @@ -591,11 +592,12 @@ await _store
.Where(p => allAdvancingPlayerIds.Contains(p.Id))
.ExecuteUpdateAsync(up => up.SetProperty(p => p.Advanced, true));


// for now, this is a little goofy, but raising any team's score change will rerank the game,
// and that's what we want, so...
if (teams.Count > 0)
await _mediator.Publish(new ScoreChangedNotification(model.TeamIds.First()));
if (enrollments.Count > 0)
{
await _mediator.Publish(new ScoreChangedNotification(enrollments.First().TeamId));
}
}

public async Task<PlayerCertificate> MakeCertificate(string id)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Gameboard.Api.Features.Users;
using Gameboard.Api.Services;
using Gameboard.Api.Structure.MediatR;
using MediatR;

namespace Gameboard.Api.Features.Teams;

public record AdvanceTeamsCommand(string GameId, bool IncludeScores, IEnumerable<string> TeamIds) : IRequest;

internal sealed class AdvanceTeamsHandler
(
PlayerService playerService,
ITeamService teamService,
IValidatorService validatorService
) : IRequestHandler<AdvanceTeamsCommand>
{
private readonly PlayerService _playerService = playerService;
private readonly ITeamService _teamService = teamService;
private readonly IValidatorService _validator = validatorService;

public async Task Handle(AdvanceTeamsCommand request, CancellationToken cancellationToken)
{
// sanitize input
var finalTeamIds = request.TeamIds.Distinct().ToArray();

await _validator
.Auth(c => c.RequirePermissions(PermissionKey.Teams_Enroll))
.AddEntityExistsValidator<Data.Game>(request.GameId)
.AddValidator(async ctx =>
{
var captains = await _teamService.ResolveCaptains(request.TeamIds, cancellationToken);

// ensure all teams are represented
var unreppedTeamIds = finalTeamIds.Where(t => !captains.ContainsKey(t)).ToArray();
if (unreppedTeamIds.Length > 0)
{
foreach (var unreppedTeam in unreppedTeamIds)
{
ctx.AddValidationException(new ResourceNotFound<Team>(unreppedTeam));
}
}
})
.Validate(cancellationToken);

var gameId = await _teamService.GetGameId(request.TeamIds, cancellationToken);
await _playerService.AdvanceTeams(new TeamAdvancement
{
GameId = gameId,
NextGameId = request.GameId,
TeamIds = request.TeamIds.ToArray(),
WithScores = request.IncludeScores
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace Gameboard.Api.Features.Teams;

public interface ICumulativeTimeCalculator
{
long CalculativeCumulativeTimeMs(IEnumerable<TeamChallengeTime> times);
}

public class CumulativeTimeCalculator : ICumulativeTimeCalculator
{
public long CalculativeCumulativeTimeMs(IEnumerable<TeamChallengeTime> times)
=> times
.Where(t => t.StartTime.IsNotEmpty())
.Where(t => t.LastScoreTime.IsNotEmpty())
.Sum(t => Math.Max(t.LastScoreTime.Value.ToUnixTimeMilliseconds() - t.StartTime.Value.ToUnixTimeMilliseconds(), 0));
}
21 changes: 18 additions & 3 deletions src/Gameboard.Api/Features/Teams/Services/TeamService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -341,10 +341,16 @@ public async Task<long> GetCumulativeTimeMs(string id, CancellationToken cancell
.Where(c => c.TeamId == id)
.WhereDateIsNotEmpty(c => c.LastScoreTime)
.WhereDateIsNotEmpty(c => c.StartTime)
.Select(c => new { c.LastScoreTime, c.StartTime })
.Select(c => new TeamChallengeTime
{
TeamId = c.TeamId,
ChallengeId = c.Id,
StartTime = c.StartTime == DateTimeOffset.MinValue ? null : c.StartTime,
LastScoreTime = c.LastScoreTime == DateTimeOffset.MinValue ? null : c.LastScoreTime,
})
.ToArrayAsync(cancellationToken);

return teamChallengeTimes.Sum(c => c.LastScoreTime.ToUnixTimeMilliseconds() - c.StartTime.ToUnixTimeMilliseconds());
return CalculateCumulativeTimeMs(teamChallengeTimes);
}

public async Task<Team> GetTeam(string id)
Expand Down Expand Up @@ -576,10 +582,19 @@ await _store
.Select(c => _gameEngine.ExtendSession(c.Id, sessionWindow.End, c.GameEngineType))
.ToArray();

if (pushGamespaceUpdateTasks.Any())
if (pushGamespaceUpdateTasks.Length > 0)
{
await Task.WhenAll(pushGamespaceUpdateTasks);
}
}

// protected for unit testing
protected long CalculateCumulativeTimeMs(IEnumerable<TeamChallengeTime> challengeTimes)
=> challengeTimes
.Where(t => t.StartTime.IsNotEmpty())
.Where(t => t.LastScoreTime.IsNotEmpty())
.Sum(t => Math.Max(t.LastScoreTime.Value.ToUnixTimeMilliseconds() - t.StartTime.Value.ToUnixTimeMilliseconds(), 0));

private async Task<TeamState> GetTeamState(string teamId, SimpleEntity actor, CancellationToken cancellationToken)
{
var captain = await ResolveCaptain(teamId, cancellationToken);
Expand Down
4 changes: 4 additions & 0 deletions src/Gameboard.Api/Features/Teams/TeamController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ ITeamService teamService
private readonly IMediator _mediator = mediator;
private readonly ITeamService _teamService = teamService;

[HttpPost("advance")]
public Task AdvanceTeams([FromBody] AdvanceTeamsRequest request)
=> _mediator.Send(new AdvanceTeamsCommand(request.GameId, request.IncludeScores, request.TeamIds));

[HttpDelete("{teamId}/players/{playerId}")]
public Task<RemoveFromTeamResponse> RemovePlayer([FromRoute] string teamId, [FromRoute] string playerId)
=> _mediator.Send(new RemoveFromTeamCommand(playerId));
Expand Down
15 changes: 15 additions & 0 deletions src/Gameboard.Api/Features/Teams/TeamsModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@

namespace Gameboard.Api.Features.Teams;

public class AdvanceTeamsRequest
{
public required string GameId { get; set; }
public required bool IncludeScores { get; set; }
public required string[] TeamIds { get; set; }
}

public class PromoteToManagerRequest
{
public User Actor { get; set; }
Expand Down Expand Up @@ -112,6 +119,14 @@ public string[] SponsorList
}
}

public sealed class TeamChallengeTime
{
public required string TeamId { get; set; }
public required string ChallengeId { get; set; }
public required DateTimeOffset? StartTime { get; set; }
public required DateTimeOffset? LastScoreTime { get; set; }
}

public class TeamPlayer
{
public string Id { get; set; }
Expand Down

0 comments on commit 25aade0

Please sign in to comment.