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:
@@ -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 _)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user