feat(svc): ChooseClassAsync + ChooseCardAsync (draft state machine)

Implements the class-selection and card-pick turns for the Take Two arena draft:
- ChooseClassAsync validates class is offered, locks ClassId, generates first pick set via pool
- ChooseCardAsync appends the two picked cards, advances SelectTurn 1–15, completes draft at turn 15
- 6 new tests covering happy paths and all error codes (class_not_offered, invalid_state, invalid_selection)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-05-31 11:03:50 -04:00
parent d550f66481
commit cc40e2d2e8
2 changed files with 237 additions and 2 deletions

View File

@@ -142,8 +142,82 @@ public class ArenaTwoPickService : IArenaTwoPickService
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}
public Task<ClassChooseResponseDto> ChooseClassAsync(long viewerId, int classId) => throw new NotImplementedException();
public Task<CardChooseResponseDto> ChooseCardAsync(long viewerId, long selectedId) => throw new NotImplementedException();
public async Task<ClassChooseResponseDto> ChooseClassAsync(long viewerId, int classId)
{
var run = await _runs.GetByViewerIdAsync(viewerId)
?? throw new ArenaTwoPickException("arena_two_pick_no_active_run");
if (run.ClassId != 0)
throw new ArenaTwoPickException("arena_two_pick_invalid_state");
var candidates = JsonSerializer.Deserialize<List<int>>(run.CandidateClassIdsJson) ?? new();
if (!candidates.Contains(classId))
throw new ArenaTwoPickException("arena_two_pick_class_not_offered");
run.ClassId = classId;
run.LeaderSkinId = ResolveClassDefaultLeaderSkin(classId);
var pairs = _pool.GeneratePickSetsForTurn(classId, turn: 1, startingPairId: run.NextCandidateId, _rng);
run.NextCandidateId += pairs.Count;
run.SelectTurn = 1;
run.PendingPickSetsJson = JsonSerializer.Serialize(pairs);
await _runs.UpsertAsync(run);
return new ClassChooseResponseDto
{
ClassInfo = ProjectClassInfo(run),
DeckInfo = ProjectDeckInfo(run),
CandidateCardList = pairs.Select(p => new CandidatePairDto
{
Id = p.Id, Turn = p.Turn, SetNum = p.SetNum,
CardId1 = p.CardId1, CardId2 = p.CardId2,
IsSelected = p.IsSelected ? 1 : 0,
}).ToList(),
};
}
// Placeholder: class default skin = class id. Matches the capture's "leader_skin_id":"1" when class_id=1.
private static long ResolveClassDefaultLeaderSkin(int classId) => classId;
public async Task<CardChooseResponseDto> ChooseCardAsync(long viewerId, long selectedId)
{
var run = await _runs.GetByViewerIdAsync(viewerId)
?? throw new ArenaTwoPickException("arena_two_pick_no_active_run");
if (run.ClassId == 0 || run.IsSelectCompleted)
throw new ArenaTwoPickException("arena_two_pick_invalid_state");
var pending = JsonSerializer.Deserialize<List<CandidatePair>>(run.PendingPickSetsJson) ?? new();
var pick = pending.FirstOrDefault(p => p.Id == selectedId)
?? throw new ArenaTwoPickException("arena_two_pick_invalid_selection");
var selectedCards = JsonSerializer.Deserialize<List<long>>(run.SelectedCardIdsJson) ?? new();
selectedCards.Add(pick.CardId1);
selectedCards.Add(pick.CardId2);
run.SelectedCardIdsJson = JsonSerializer.Serialize(selectedCards);
List<CandidatePair>? nextPairs = null;
if (run.SelectTurn < 15)
{
run.SelectTurn += 1;
nextPairs = _pool.GeneratePickSetsForTurn(run.ClassId, run.SelectTurn, run.NextCandidateId, _rng);
run.NextCandidateId += nextPairs.Count;
run.PendingPickSetsJson = JsonSerializer.Serialize(nextPairs);
}
else
{
run.IsSelectCompleted = true;
run.PendingPickSetsJson = "[]";
}
await _runs.UpsertAsync(run);
return new CardChooseResponseDto
{
DeckInfo = ProjectDeckInfo(run),
CandidateCardList = nextPairs?.Select(p => new CandidatePairDto
{
Id = p.Id, Turn = p.Turn, SetNum = p.SetNum,
CardId1 = p.CardId1, CardId2 = p.CardId2,
IsSelected = p.IsSelected ? 1 : 0,
}).ToList(),
};
}
public Task<FinishResponseDto> RetireAsync(long viewerId) => throw new NotImplementedException();
public Task<FinishResponseDto> FinishAsync(long viewerId) => throw new NotImplementedException();
public Task<BattleFinishResultDto> RecordBattleResultAsync(long viewerId, bool isWin) => throw new NotImplementedException();

View File

@@ -0,0 +1,161 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Services;
public class ArenaTwoPickServiceDraftTests
{
private sealed class FakePool : IArenaTwoPickCardPoolService
{
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new()
{
new() { Id = startingPairId, Turn = turn, SetNum = 1,
CardId1 = 1000 + turn * 10 + 1, CardId2 = 1000 + turn * 10 + 2, IsSelected = false },
new() { Id = startingPairId + 1, Turn = turn, SetNum = 2,
CardId1 = 2000 + turn * 10 + 1, CardId2 = 2000 + turn * 10 + 2, IsSelected = false },
};
}
private sealed class FakeEntitlements : IViewerEntitlements
{
public bool IsFreeplay { get; init; }
public long EffectiveBalance(SVSim.Database.Models.Viewer viewer, SpendCurrency currency) => 0;
public bool OwnsCard(SVSim.Database.Models.Viewer viewer, long cardId) => IsFreeplay;
public bool OwnsCosmetic(SVSim.Database.Models.Viewer viewer, CosmeticType type, int id) => IsFreeplay;
public Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<OwnedCardEntry>>(new List<OwnedCardEntry>());
public Task<EffectiveCosmetics> EffectiveCosmeticsAsync(SVSim.Database.Models.Viewer viewer, CancellationToken ct = default)
=> throw new NotSupportedException();
}
private static async Task<(IArenaTwoPickService, IArenaTwoPickRunRepository, long viewerId)> SetupWithActiveRunAsync(int classChosen = 0)
{
var factory = new SVSimTestFactory();
var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
await db.Database.EnsureCreatedAsync();
var viewer = new SVSim.Database.Models.Viewer { Id = 7, DisplayName = "v", Currency = new ViewerCurrency() };
db.Viewers.Add(viewer);
await db.SaveChangesAsync();
var runs = new ArenaTwoPickRunRepository(db);
await runs.UpsertAsync(new ViewerArenaTwoPickRun
{
ViewerId = 7, EntryId = 4242,
CandidateClassIdsJson = "[1,7,8]",
ClassId = classChosen, MaxBattleCount = 7,
SelectTurn = classChosen == 0 ? 0 : 1,
PendingPickSetsJson = classChosen == 0
? "[]"
: JsonSerializer.Serialize(new List<CandidatePair>
{
new() { Id = 1, Turn = 1, SetNum = 1, CardId1 = 11, CardId2 = 12 },
new() { Id = 2, Turn = 1, SetNum = 2, CardId1 = 21, CardId2 = 22 },
}),
NextCandidateId = classChosen == 0 ? 1 : 3,
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
});
var svc = new ArenaTwoPickService(
runs,
scope.ServiceProvider.GetRequiredService<IArenaTwoPickRewardRepository>(),
new FakePool(),
scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
scope.ServiceProvider.GetRequiredService<RewardGrantService>(),
new FakeEntitlements(),
new SystemRandom(seed: 1),
db);
return (svc, runs, 7);
}
[Test]
public async Task ChooseClassAsync_persists_class_and_emits_first_pick_sets()
{
var (svc, runs, vid) = await SetupWithActiveRunAsync();
var dto = await svc.ChooseClassAsync(vid, classId: 7);
Assert.That(dto.ClassInfo.SelectedClassId, Is.EqualTo(7));
Assert.That(dto.DeckInfo.SelectTurn, Is.EqualTo(1));
Assert.That(dto.DeckInfo.IsSelectCompleted, Is.False);
Assert.That(dto.CandidateCardList.Count, Is.EqualTo(2));
Assert.That(dto.CandidateCardList[0].Id, Is.EqualTo(1));
Assert.That(dto.CandidateCardList[1].Id, Is.EqualTo(2));
var row = await runs.GetByViewerIdAsync(vid);
Assert.That(row!.ClassId, Is.EqualTo(7));
Assert.That(row.NextCandidateId, Is.EqualTo(3));
}
[Test]
public async Task ChooseClassAsync_rejects_class_not_offered()
{
var (svc, _, vid) = await SetupWithActiveRunAsync();
var ex = Assert.ThrowsAsync<ArenaTwoPickException>(() => svc.ChooseClassAsync(vid, classId: 4));
Assert.That(ex!.ErrorCode, Is.EqualTo("arena_two_pick_class_not_offered"));
}
[Test]
public async Task ChooseClassAsync_rejects_when_class_already_chosen()
{
var (svc, _, vid) = await SetupWithActiveRunAsync(classChosen: 1);
var ex = Assert.ThrowsAsync<ArenaTwoPickException>(() => svc.ChooseClassAsync(vid, classId: 1));
Assert.That(ex!.ErrorCode, Is.EqualTo("arena_two_pick_invalid_state"));
}
[Test]
public async Task ChooseCardAsync_appends_two_cards_and_advances_turn()
{
var (svc, runs, vid) = await SetupWithActiveRunAsync(classChosen: 1);
var dto = await svc.ChooseCardAsync(vid, selectedId: 2);
Assert.That(dto.DeckInfo.SelectedCardIds, Is.EqualTo(new List<long> { 21, 22 }));
Assert.That(dto.DeckInfo.SelectTurn, Is.EqualTo(2));
Assert.That(dto.CandidateCardList!.Count, Is.EqualTo(2));
var row = await runs.GetByViewerIdAsync(vid);
Assert.That(row!.NextCandidateId, Is.EqualTo(5));
}
[Test]
public async Task ChooseCardAsync_at_turn_15_completes_the_draft_and_omits_pick_list()
{
var (svc, runs, vid) = await SetupWithActiveRunAsync(classChosen: 1);
// Fast-forward to turn 15 by writing the row directly.
var row = await runs.GetByViewerIdAsync(vid);
row!.SelectTurn = 15;
var pending = new List<CandidatePair>
{
new() { Id = 100, Turn = 15, SetNum = 1, CardId1 = 71, CardId2 = 72 },
new() { Id = 101, Turn = 15, SetNum = 2, CardId1 = 81, CardId2 = 82 },
};
row.PendingPickSetsJson = JsonSerializer.Serialize(pending);
row.SelectedCardIdsJson = JsonSerializer.Serialize(Enumerable.Range(1, 28).Select(i => (long)i).ToList());
await runs.UpsertAsync(row);
var dto = await svc.ChooseCardAsync(vid, selectedId: 100);
Assert.That(dto.DeckInfo.IsSelectCompleted, Is.True);
Assert.That(dto.DeckInfo.SelectedCardIds.Count, Is.EqualTo(30));
Assert.That(dto.CandidateCardList, Is.Null, "omitted on completion per spec");
}
[Test]
public async Task ChooseCardAsync_rejects_invalid_selected_id()
{
var (svc, _, vid) = await SetupWithActiveRunAsync(classChosen: 1);
var ex = Assert.ThrowsAsync<ArenaTwoPickException>(() => svc.ChooseCardAsync(vid, selectedId: 999));
Assert.That(ex!.ErrorCode, Is.EqualTo("arena_two_pick_invalid_selection"));
}
}