Files
SVSimServer/SVSim.UnitTests/Controllers/ArenaColosseumControllerDraftTests.cs
gamer147 d126649ad4 feat(arena-colosseum): 2-pick + curated deck sources (phase 3)
Closes the family. arena-colosseum 10/16 → 15/16 zero stubs (the 16th —
finish_load — is dead per project_dead_battle_endpoints).

* 2-Pick draft lift onto ArenaColosseumController:
  - get_candidate_classes samples from ArenaTwoPickConfig.AllowedClassIds
    and persists the slate onto the run.
  - class_choose accepts class_id XOR chaos_id; both populate run.ClassId,
    chaos branch stores ChaosId for replay.
  - get_candidate_cards is the idempotent draft-resume snapshot.
  - card_choose appends both cards from the picked pair, advances turn 1..15.
  - Pool override: ArenaTwoPickCardPoolService gets a non-breaking
    GeneratePickSetsForTurn(..., poolCardSetIds) overload; Colosseum routes
    pass ColosseumSeasonConfig.PoolCardSetIds (falls back to challenge →
    rotation when empty).
* Curated-deck schema: ColosseumHofDeck / ColosseumWindFallDeck /
  ColosseumAvatarDeck — three identical tables (separate per per-pool
  operational lifecycle), unique on DeckNo. Migration AddColosseumCuratedDecks.
* IColosseumCuratedDeck interface + a generic ColosseumCuratedDeckImporterBase<T>
  with three concrete subclasses (HOF / WindFall / Avatar), registered in
  Bootstrap. Seed files ship empty.
* 6 curated endpoints on ArenaColosseumController: get_{hof|windfall|avatar}_deck_list
  return BARE-ARRAY data per spec; register_{hof|windfall|avatar}_deck share
  one generic RegisterCuratedAsync<T> dispatcher. Cross-pool register
  rejected via per-pool lookup. Curated register has no is_published flag
  (constructed-only) — clears the run's flag for state consistency.
* Tests: 5 draft HTTP tests + 11 curated-deck HTTP tests (4 parameterized
  3 ways across HOF/WindFall/Avatar + a cross-pool isolation test + an
  is_published clear test). Existing TwoPick service tests updated for the
  new pool overload. Full suite: 1347/1347.

Phase 3 ship gate met. Branch ready for merge.
2026-06-13 12:49:57 -04:00

222 lines
10 KiB
C#

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// Phase 3 2-Pick draft coverage on /arena_colosseum/{get_candidate_classes, class_choose,
/// get_candidate_cards, card_choose}. Mirrors the existing ArenaTwoPick tests, with the
/// pool override sourced from <c>ColosseumSeasonConfig.PoolCardSetIds</c> instead of
/// <c>ChallengeConfig.PoolCardSetIds</c>.
/// </summary>
public class ArenaColosseumControllerDraftTests
{
private static readonly object Envelope =
new { viewer_id = "0", steam_id = 0, steam_session_ticket = "" };
private static async Task ActivateChaosCapableSeasonAsync(SVSimTestFactory factory)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
// Pool override: the test card-set's id (10001) — see SVSimTestFactory.SeedMinimalCardSet.
var seasonJson = JsonSerializer.Serialize(new
{
IsColosseumPeriod = true,
SeasonId = 7,
DeckFormat = (int)Format.TwoPick,
IsNormalTwoPick = true,
PoolCardSetIds = new[] { 10001 },
});
await UpsertConfigAsync(db, "ColosseumSeason", seasonJson);
}
private static async Task UpsertConfigAsync(SVSimDbContext db, string section, string json)
{
var existing = await db.GameConfigs.FirstOrDefaultAsync(s => s.SectionName == section);
if (existing is null)
db.GameConfigs.Add(new GameConfigSection { SectionName = section, ValueJson = json });
else
existing.ValueJson = json;
await db.SaveChangesAsync();
}
private static async Task SeedRunAsync(SVSimTestFactory factory, long viewerId)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.ViewerArenaColosseumRuns.Add(new ViewerArenaColosseumRun
{
ViewerId = viewerId,
EntryId = 9999,
SeasonId = 7,
RoundId = 1,
DeckFormat = Format.TwoPick,
});
// The pool service filters cards by `CollectionInfo != null` — the minimal SVSimTestFactory
// seed lacks that. Stamp it on every card in the test set so the pool service can
// actually emit a candidate pair for any class.
var cards = await db.Cards.ToListAsync();
foreach (var c in cards)
{
c.CollectionInfo = new CardCollectionInfo { CraftCost = 40, DustReward = 10 };
}
await db.SaveChangesAsync();
}
[Test]
public async Task GetCandidateClasses_seeds_three_classes_on_run()
{
using var factory = new SVSimTestFactory();
await ActivateChaosCapableSeasonAsync(factory);
var vid = await factory.SeedViewerAsync();
await SeedRunAsync(factory, vid);
using var client = factory.CreateAuthenticatedClient(vid);
var resp = await client.PostAsync("/arena_colosseum/get_candidate_classes",
JsonContent.Create(Envelope));
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var body = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.That(root.GetProperty("class_id_1").GetInt32(), Is.GreaterThan(0));
Assert.That(root.GetProperty("class_id_2").GetInt32(), Is.GreaterThan(0));
Assert.That(root.GetProperty("class_id_3").GetInt32(), Is.GreaterThan(0));
using var verifyScope = factory.Services.CreateScope();
var db = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var run = await db.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid);
var stored = JsonSerializer.Deserialize<List<int>>(run.CandidateClassIdsJson)!;
Assert.That(stored.Count, Is.EqualTo(3),
"the slate must be persisted onto the run so /class_choose can validate against it");
}
[Test]
public async Task ClassChoose_rejects_class_not_in_slate()
{
using var factory = new SVSimTestFactory();
await ActivateChaosCapableSeasonAsync(factory);
var vid = await factory.SeedViewerAsync();
await SeedRunAsync(factory, vid);
// Force a known slate.
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var run = await db.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid);
run.CandidateClassIdsJson = "[1,2,3]";
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(vid);
var resp = await client.PostAsync("/arena_colosseum/class_choose",
JsonContent.Create(new { class_id = 8, viewer_id = "0", steam_id = 0, steam_session_ticket = "" }));
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
var body = await resp.Content.ReadAsStringAsync();
StringAssert.Contains("class_not_offered", body);
}
[Test]
public async Task ClassChoose_normal_advances_run_to_turn_1_with_a_pair_offered()
{
using var factory = new SVSimTestFactory();
await ActivateChaosCapableSeasonAsync(factory);
var vid = await factory.SeedViewerAsync();
await SeedRunAsync(factory, vid);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var run = await db.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid);
run.CandidateClassIdsJson = "[1,2,3]";
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(vid);
var resp = await client.PostAsync("/arena_colosseum/class_choose",
JsonContent.Create(new { class_id = 1, viewer_id = "0", steam_id = 0, steam_session_ticket = "" }));
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var body = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.That(root.GetProperty("class_info").GetProperty("selected_class_id").GetString(), Is.EqualTo("1"),
"selected_class_id is wire-stringified per existing TwoPick convention");
Assert.That(root.GetProperty("candidate_card_list").GetArrayLength(), Is.EqualTo(2),
"the pool service emits exactly 2 candidate pairs per turn");
using var verifyScope = factory.Services.CreateScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var verifyRun = await verifyDb.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid);
Assert.That(verifyRun.ClassId, Is.EqualTo(1));
Assert.That(verifyRun.SelectTurn, Is.EqualTo(1));
}
[Test]
public async Task CardChoose_appends_to_selected_cards_and_advances_turn()
{
using var factory = new SVSimTestFactory();
await ActivateChaosCapableSeasonAsync(factory);
var vid = await factory.SeedViewerAsync();
await SeedRunAsync(factory, vid);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var run = await db.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid);
run.CandidateClassIdsJson = "[1,2,3]";
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(vid);
// Drive /class_choose to populate the pending pair.
var classResp = await client.PostAsync("/arena_colosseum/class_choose",
JsonContent.Create(new { class_id = 1, viewer_id = "0", steam_id = 0, steam_session_ticket = "" }));
var classBody = await classResp.Content.ReadAsStringAsync();
using var classDoc = JsonDocument.Parse(classBody);
long firstPairId = long.Parse(classDoc.RootElement.GetProperty("candidate_card_list")[0].GetProperty("id").GetString()!);
var cardResp = await client.PostAsync("/arena_colosseum/card_choose",
JsonContent.Create(new { selected_id = firstPairId, viewer_id = "0", steam_id = 0, steam_session_ticket = "" }));
Assert.That(cardResp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var verifyScope = factory.Services.CreateScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var verifyRun = await verifyDb.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid);
var picks = JsonSerializer.Deserialize<List<long>>(verifyRun.SelectedCardIdsJson)!;
Assert.That(picks.Count, Is.EqualTo(2),
"first card_choose appends both cards from the picked pair");
Assert.That(verifyRun.SelectTurn, Is.EqualTo(2));
}
[Test]
public async Task ClassChoose_chaos_branch_stores_chaos_id_on_run()
{
using var factory = new SVSimTestFactory();
await ActivateChaosCapableSeasonAsync(factory);
var vid = await factory.SeedViewerAsync();
await SeedRunAsync(factory, vid);
using var client = factory.CreateAuthenticatedClient(vid);
var resp = await client.PostAsync("/arena_colosseum/class_choose",
JsonContent.Create(new { chaos_id = 101, viewer_id = "0", steam_id = 0, steam_session_ticket = "" }));
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var run = await db.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid);
Assert.That(run.ChaosId, Is.EqualTo(101));
Assert.That(run.ClassId, Is.GreaterThan(0),
"chaos id must resolve to a non-zero class for the pool service");
}
}