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.
This commit is contained in:
gamer147
2026-06-13 12:49:57 -04:00
parent cbee0f9a50
commit d126649ad4
25 changed files with 6152 additions and 6 deletions

View File

@@ -1,5 +1,7 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
@@ -12,6 +14,7 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaColosseum;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaColosseum;
using SVSim.EmulatedEntrypoint.Services;
using SVSim.EmulatedEntrypoint.Services.ArenaColosseum;
namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -30,19 +33,28 @@ public class ArenaColosseumController : SVSimController
private readonly IInventoryService _inventory;
private readonly IDeckRepository _decks;
private readonly IColosseumProgressionService _progression;
private readonly IArenaTwoPickCardPoolService _pool;
private readonly IRandom _rng;
private readonly SVSimDbContext _db;
public ArenaColosseumController(
IGameConfigService config,
IArenaColosseumRunRepository runs,
IInventoryService inventory,
IDeckRepository decks,
IColosseumProgressionService progression)
IColosseumProgressionService progression,
IArenaTwoPickCardPoolService pool,
IRandom rng,
SVSimDbContext db)
{
_config = config;
_runs = runs;
_inventory = inventory;
_decks = decks;
_progression = progression;
_pool = pool;
_rng = rng;
_db = db;
}
[HttpPost("top")]
@@ -286,6 +298,287 @@ public class ArenaColosseumController : SVSimController
});
}
[HttpPost("get_candidate_classes")]
public async Task<IActionResult> GetCandidateClasses([FromBody] BaseRequest _)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
var run = await _runs.GetByViewerIdAsync(vid);
if (run is null) return BadRequest(new { error = "no_active_run" });
// No persistent slate yet — sample 3 from the configured allow-list per
// ArenaTwoPickConfig.AllowedClassIds. Idempotent re-call gets a fresh slate; the
// spec says "logged server-side so re-calling is idempotent" — Phase 3 v1 doesn't
// yet persist the slate (the slate stamps in on /class_choose).
var aCfg = _config.Get<ArenaTwoPickConfig>();
if (aCfg.AllowedClassIds.Count < 3)
{
return BadRequest(new { error = "arena_two_pick_allowed_class_ids_misconfigured" });
}
var sampled = aCfg.AllowedClassIds
.OrderBy(_ => _rng.Next(int.MaxValue))
.Take(3)
.ToList();
// Persist onto the run so /class_choose can validate the pick.
run.CandidateClassIdsJson = JsonSerializer.Serialize(sampled);
await _runs.UpsertAsync(run);
// v1 emits Normal-mode shape only (per plan §"Defer Chaos until live capture lands").
// Server still ACCEPTS chaos_id on /class_choose for forward compatibility.
return Ok(new GetCandidateClassesResponse
{
ClassId1 = sampled[0],
ClassId2 = sampled[1],
ClassId3 = sampled[2],
});
}
[HttpPost("class_choose")]
public async Task<IActionResult> ClassChoose([FromBody] ArenaColosseumClassChooseRequest req)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
var run = await _runs.GetByViewerIdAsync(vid);
if (run is null) return BadRequest(new { error = "no_active_run" });
if (run.ClassId != 0) return BadRequest(new { error = "arena_colosseum_invalid_state" });
// Mutually-exclusive request shape per class-choose.md.
bool isNormal = req.ClassId != 0 && req.ChaosId == 0;
bool isChaos = req.ChaosId != 0 && req.ClassId == 0;
if (!isNormal && !isChaos)
{
return BadRequest(new { error = "class_choose_requires_class_id_xor_chaos_id" });
}
var candidates = JsonSerializer.Deserialize<List<int>>(run.CandidateClassIdsJson) ?? new();
int chosenClassId = isNormal ? req.ClassId : ResolveChaosClassId(req.ChaosId);
if (isNormal && !candidates.Contains(chosenClassId))
{
return BadRequest(new { error = "arena_colosseum_class_not_offered" });
}
run.ClassId = chosenClassId;
run.ChaosId = isChaos ? req.ChaosId : 0;
run.LeaderSkinId = chosenClassId; // class-default skin convention from TwoPick
var pool = _config.Get<ColosseumSeasonConfig>().PoolCardSetIds;
var pairs = _pool.GeneratePickSetsForTurn(
chosenClassId, turn: 1, startingPairId: run.NextCandidateId, _rng, poolCardSetIds: pool);
run.NextCandidateId += pairs.Count;
run.SelectTurn = 1;
run.PendingPickSetsJson = JsonSerializer.Serialize(pairs);
await _runs.UpsertAsync(run);
return Ok(new SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick.ClassChooseResponseDto
{
ClassInfo = ProjectClassInfo(run),
DeckInfo = ProjectDeckInfo(run),
CandidateCardList = pairs.Select(ToDto).ToList(),
});
}
[HttpPost("get_candidate_cards")]
public async Task<IActionResult> GetCandidateCards([FromBody] BaseRequest _)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
var run = await _runs.GetByViewerIdAsync(vid);
if (run is null) return BadRequest(new { error = "no_active_run" });
// Idempotent resume — no state mutation here, just replay the current snapshot.
var pending = JsonSerializer.Deserialize<List<CandidatePair>>(run.PendingPickSetsJson) ?? new();
return Ok(new GetCandidateCardsResponse
{
DeckInfo = ProjectDeckInfo(run),
CandidateCardList = pending.Select(ToDto).ToList(),
LeaderSkinId = run.LeaderSkinId == 0 ? null : run.LeaderSkinId,
ClassInfo = ProjectClassInfo(run),
});
}
[HttpPost("card_choose")]
public async Task<IActionResult> CardChoose([FromBody] ArenaColosseumCardChooseRequest req)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
var run = await _runs.GetByViewerIdAsync(vid);
if (run is null) return BadRequest(new { error = "no_active_run" });
if (run.ClassId == 0 || run.IsSelectCompleted)
return BadRequest(new { error = "arena_colosseum_invalid_state" });
var pending = JsonSerializer.Deserialize<List<CandidatePair>>(run.PendingPickSetsJson) ?? new();
var pick = pending.FirstOrDefault(p => p.Id == req.SelectedId);
if (pick is null)
return BadRequest(new { error = "arena_colosseum_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;
var pool = _config.Get<ColosseumSeasonConfig>().PoolCardSetIds;
nextPairs = _pool.GeneratePickSetsForTurn(
run.ClassId, run.SelectTurn, run.NextCandidateId, _rng, poolCardSetIds: pool);
run.NextCandidateId += nextPairs.Count;
run.PendingPickSetsJson = JsonSerializer.Serialize(nextPairs);
}
else
{
run.IsSelectCompleted = true;
run.PendingPickSetsJson = "[]";
}
await _runs.UpsertAsync(run);
return Ok(new SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick.CardChooseResponseDto
{
DeckInfo = ProjectDeckInfo(run),
CandidateCardList = nextPairs?.Select(ToDto).ToList(),
});
}
[HttpPost("get_hof_deck_list")]
public Task<IActionResult> GetHofDeckList([FromBody] BaseRequest _) =>
GetCuratedListAsync<ColosseumHofDeck>();
[HttpPost("get_windfall_deck_list")]
public Task<IActionResult> GetWindFallDeckList([FromBody] BaseRequest _) =>
GetCuratedListAsync<ColosseumWindFallDeck>();
[HttpPost("get_avatar_deck_list")]
public Task<IActionResult> GetAvatarDeckList([FromBody] BaseRequest _) =>
GetCuratedListAsync<ColosseumAvatarDeck>();
[HttpPost("register_hof_deck")]
public Task<IActionResult> RegisterHofDeck([FromBody] RegisterCuratedDeckRequest req) =>
RegisterCuratedAsync<ColosseumHofDeck>(req);
[HttpPost("register_windfall_deck")]
public Task<IActionResult> RegisterWindFallDeck([FromBody] RegisterCuratedDeckRequest req) =>
RegisterCuratedAsync<ColosseumWindFallDeck>(req);
[HttpPost("register_avatar_deck")]
public Task<IActionResult> RegisterAvatarDeck([FromBody] RegisterCuratedDeckRequest req) =>
RegisterCuratedAsync<ColosseumAvatarDeck>(req);
/// <summary>
/// Shared list dispatcher for the three curated-deck pools. Wire shape: bare array at
/// <c>data</c> per get-curated-deck-list.md (NOT a wrapper object — client iterates
/// <c>ResponseData["data"]</c> directly).
/// </summary>
private async Task<IActionResult> GetCuratedListAsync<TEntity>()
where TEntity : class, IColosseumCuratedDeck
{
if (!TryGetViewerId(out _)) return Unauthorized();
var rows = await _db.Set<TEntity>().AsNoTracking()
.OrderBy(d => d.DisplayOrder).ThenBy(d => d.DeckNo)
.ToListAsync();
var entries = rows.Select(r => new ColosseumCuratedDeckEntry
{
DeckId = r.DeckNo,
ClassId = r.ClassId,
CardList = JsonSerializer.Deserialize<List<long>>(r.CardListJson) ?? new(),
SleeveId = r.SleeveId == 0 ? null : r.SleeveId,
SkinId = r.LeaderSkinId == 0 ? null : r.LeaderSkinId,
}).ToList();
return Ok(entries);
}
/// <summary>
/// Shared register dispatcher — validates each <c>deck_no_list</c> entry exists in the
/// pool table for <typeparamref name="TEntity"/> (cross-pool register is rejected via
/// the per-pool lookup). Persists onto the active run, NO <c>is_published</c> flag here
/// — that's constructed-format-only per register-curated-deck.md.
/// </summary>
private async Task<IActionResult> RegisterCuratedAsync<TEntity>(RegisterCuratedDeckRequest req)
where TEntity : class, IColosseumCuratedDeck
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
var run = await _runs.GetByViewerIdAsync(vid);
if (run is null) return BadRequest(new { error = "no_active_run" });
List<int> deckNos;
try
{
deckNos = JsonSerializer.Deserialize<List<int>>(req.DeckNoList) ?? new();
}
catch (JsonException)
{
return BadRequest(new { error = "deck_no_list_malformed" });
}
if (deckNos.Count == 0)
{
return BadRequest(new { error = "deck_no_list_empty" });
}
var set = _db.Set<TEntity>();
foreach (var no in deckNos)
{
// Per-pool lookup — registering an HOF deck_no against /register_windfall_deck
// resolves to null here and rejects (cross-pool isolation).
var found = await set.AnyAsync(d => d.DeckNo == no);
if (!found)
{
return BadRequest(new { error = "deck_not_found", deck_no = no });
}
}
run.RegisteredDeckNoListJson = JsonSerializer.Serialize(deckNos);
// Curated-register has no is_published flag; clear any prior value to keep state
// consistent if the viewer switches from constructed to curated mid-bracket.
run.IsPublished = false;
await _runs.UpsertAsync(run);
return Ok(new { });
}
/// <summary>v1 placeholder — Phase 3 §"Defer Chaos until live capture lands". Chaos
/// chara ids map to a class via prod data (e.g. via a ChaosInfoMap). Until that lands,
/// fall back to the chaos id mod 8 + 1 to keep the pool service happy. Real impl reads
/// from <c>ColosseumChaosConfig</c> once captured.</summary>
private static int ResolveChaosClassId(int chaosId) => ((chaosId - 1) % 8) + 1;
private static SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick.CandidatePairDto
ToDto(CandidatePair p) => new()
{
Id = p.Id, Turn = p.Turn, SetNum = p.SetNum,
CardId1 = p.CardId1, CardId2 = p.CardId2,
IsSelected = p.IsSelected ? 1 : 0,
};
private static SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick.ClassInfoDto
ProjectClassInfo(ViewerArenaColosseumRun run)
{
var ids = JsonSerializer.Deserialize<List<int>>(run.CandidateClassIdsJson) ?? new();
return new()
{
ClassId1 = ids.ElementAtOrDefault(0),
ClassId2 = ids.ElementAtOrDefault(1),
ClassId3 = ids.ElementAtOrDefault(2),
SelectedClassId = run.ClassId,
};
}
private static SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick.DeckInfoDto
ProjectDeckInfo(ViewerArenaColosseumRun run)
{
var cards = JsonSerializer.Deserialize<List<long>>(run.SelectedCardIdsJson) ?? new();
return new()
{
TwoPickEntryId = run.EntryId,
ClassId = run.ClassId,
IsSelectCompleted = run.IsSelectCompleted,
SelectedCardIds = cards,
SelectTurn = run.SelectTurn == 0 ? 1 : run.SelectTurn,
};
}
[HttpPost("retire")]
public async Task<IActionResult> Retire([FromBody] BaseRequest _)
{