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 _)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum;
|
||||
|
||||
/// <summary>
|
||||
/// Wire shape for a single curated deck on <c>/get_{hof|windfall|avatar}_deck_list</c>.
|
||||
/// The list response is a BARE ARRAY at the <c>data</c> level per spec — client iterates
|
||||
/// directly without a wrapper object. Sleeve/skin are optional; client falls back to
|
||||
/// defaults when absent.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class ColosseumCuratedDeckEntry
|
||||
{
|
||||
[JsonPropertyName("deck_id")] [Key("deck_id")]
|
||||
public int DeckId { get; set; }
|
||||
|
||||
[JsonPropertyName("class_id")] [Key("class_id")]
|
||||
public int ClassId { get; set; }
|
||||
|
||||
[JsonPropertyName("card_list")] [Key("card_list")]
|
||||
public List<long> CardList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("sleeve_id")] [Key("sleeve_id")]
|
||||
public long? SleeveId { get; set; }
|
||||
|
||||
[JsonPropertyName("skin_id")] [Key("skin_id")]
|
||||
public long? SkinId { get; set; }
|
||||
|
||||
[JsonPropertyName("deck_name")] [Key("deck_name")]
|
||||
public string? DeckName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaColosseum;
|
||||
|
||||
[MessagePackObject]
|
||||
public sealed class ArenaColosseumCardChooseRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("selected_id")] [Key("selected_id")]
|
||||
public long SelectedId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaColosseum;
|
||||
|
||||
/// <summary>
|
||||
/// <c>POST /arena_colosseum/class_choose</c>. Two mutually-exclusive request shapes per
|
||||
/// class-choose.md — Normal sends <c>class_id</c>, Chaos sends <c>chaos_id</c>. Both fields
|
||||
/// are bound on this DTO; the server picks the mode by which is non-zero and rejects when
|
||||
/// both are present (or neither).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class ArenaColosseumClassChooseRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("class_id")] [Key("class_id")]
|
||||
public int ClassId { get; set; }
|
||||
|
||||
/// <summary>Chaos sub-mode replay id. 0 in Normal mode.</summary>
|
||||
[JsonPropertyName("chaos_id")] [Key("chaos_id")]
|
||||
public int ChaosId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaColosseum;
|
||||
|
||||
/// <summary>
|
||||
/// Shared request shape for the three curated-deck register URLs (HOF / WindFall / Avatar).
|
||||
/// Same JSON-encoded-string gotcha as <see cref="ArenaColosseumRegisterDeckRequest"/> —
|
||||
/// <c>deck_no_list</c> is a wire string like <c>"[1001,1002]"</c>. No <c>is_published</c>
|
||||
/// here (constructed-only flag per spec).
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class RegisterCuratedDeckRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("deck_no_list")] [Key("deck_no_list")]
|
||||
public string DeckNoList { get; set; } = "[]";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaColosseum;
|
||||
|
||||
/// <summary>
|
||||
/// <c>POST /arena_colosseum/get_candidate_cards</c>. Idempotent draft-resume — server emits
|
||||
/// the current snapshot for the active run plus the pending pair offer. The Common
|
||||
/// <c>DeckInfoDto</c>/<c>CandidatePairDto</c>/<c>ClassInfoDto</c> shapes are shared with
|
||||
/// arena-two-pick and arena-competition per spec.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class GetCandidateCardsResponse
|
||||
{
|
||||
[JsonPropertyName("deck_info")] [Key("deck_info")]
|
||||
public DeckInfoDto DeckInfo { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("candidate_card_list")] [Key("candidate_card_list")]
|
||||
public List<CandidatePairDto> CandidateCardList { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("leader_skin_id")] [Key("leader_skin_id")]
|
||||
public long? LeaderSkinId { get; set; }
|
||||
|
||||
[JsonPropertyName("class_info")] [Key("class_info")]
|
||||
public ClassInfoDto ClassInfo { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaColosseum;
|
||||
|
||||
/// <summary>
|
||||
/// <c>POST /arena_colosseum/get_candidate_classes</c>. Two mutually-exclusive sub-shapes —
|
||||
/// Normal 2-pick emits <c>class_id_1/2/3</c>; Chaos emits <c>chaos_id_1/2/3</c> +
|
||||
/// <c>chaos_info</c>. <c>WhenWritingNull</c> strips the inactive branch so the wire matches
|
||||
/// the spec exactly.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public sealed class GetCandidateClassesResponse
|
||||
{
|
||||
[JsonPropertyName("class_id_1")] [Key("class_id_1")]
|
||||
public int? ClassId1 { get; set; }
|
||||
|
||||
[JsonPropertyName("class_id_2")] [Key("class_id_2")]
|
||||
public int? ClassId2 { get; set; }
|
||||
|
||||
[JsonPropertyName("class_id_3")] [Key("class_id_3")]
|
||||
public int? ClassId3 { get; set; }
|
||||
|
||||
[JsonPropertyName("chaos_id_1")] [Key("chaos_id_1")]
|
||||
public int? ChaosId1 { get; set; }
|
||||
|
||||
[JsonPropertyName("chaos_id_2")] [Key("chaos_id_2")]
|
||||
public int? ChaosId2 { get; set; }
|
||||
|
||||
[JsonPropertyName("chaos_id_3")] [Key("chaos_id_3")]
|
||||
public int? ChaosId3 { get; set; }
|
||||
|
||||
[JsonPropertyName("selected_chaos_id")] [Key("selected_chaos_id")]
|
||||
public int? SelectedChaosId { get; set; }
|
||||
|
||||
[JsonPropertyName("selected_class_id")] [Key("selected_class_id")]
|
||||
public int? SelectedClassId { get; set; }
|
||||
|
||||
[JsonPropertyName("selected_leader_skin_id")] [Key("selected_leader_skin_id")]
|
||||
public long? SelectedLeaderSkinId { get; set; }
|
||||
}
|
||||
@@ -17,14 +17,28 @@ public class ArenaTwoPickCardPoolService : IArenaTwoPickCardPoolService
|
||||
_db = db; _config = config;
|
||||
}
|
||||
|
||||
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng)
|
||||
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) =>
|
||||
GeneratePickSetsForTurn(classId, turn, startingPairId, rng, poolCardSetIds: null);
|
||||
|
||||
public List<CandidatePair> GeneratePickSetsForTurn(
|
||||
int classId, int turn, long startingPairId, IRandom rng, IReadOnlyList<int>? poolCardSetIds)
|
||||
{
|
||||
var aCfg = _config.Get<ArenaTwoPickConfig>();
|
||||
var cCfg = _config.Get<ChallengeConfig>();
|
||||
|
||||
var setIds = cCfg.PoolCardSetIds is { Count: > 0 } ids
|
||||
? ids
|
||||
: _config.Get<RotationConfig>().RotationCardSetIds ?? new List<int>();
|
||||
// Caller-supplied override wins (e.g. ColosseumSeasonConfig.PoolCardSetIds). Falls
|
||||
// back to ChallengeConfig → RotationConfig per the original TK2 resolution chain.
|
||||
IReadOnlyList<int> setIds;
|
||||
if (poolCardSetIds is { Count: > 0 })
|
||||
{
|
||||
setIds = poolCardSetIds;
|
||||
}
|
||||
else
|
||||
{
|
||||
var cCfg = _config.Get<ChallengeConfig>();
|
||||
setIds = cCfg.PoolCardSetIds is { Count: > 0 } ids
|
||||
? ids
|
||||
: _config.Get<RotationConfig>().RotationCardSetIds ?? new List<int>();
|
||||
}
|
||||
|
||||
var setIdsArr = setIds.ToArray();
|
||||
|
||||
|
||||
@@ -9,4 +9,13 @@ public interface IArenaTwoPickCardPoolService
|
||||
/// (startingPairId, startingPairId+1); set_num = 1, 2; isSelected = false.
|
||||
/// </summary>
|
||||
List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng);
|
||||
|
||||
/// <summary>
|
||||
/// Pool-override variant — used by Arena Colosseum's 2-Pick mode, where the draft pool
|
||||
/// comes from the per-season <c>ColosseumSeasonConfig.PoolCardSetIds</c> rather than the
|
||||
/// global <c>ChallengeConfig.PoolCardSetIds</c>. Pass an empty/null list to fall back to
|
||||
/// the default-pool resolution (challenge → rotation).
|
||||
/// </summary>
|
||||
List<CandidatePair> GeneratePickSetsForTurn(
|
||||
int classId, int turn, long startingPairId, IRandom rng, IReadOnlyList<int>? poolCardSetIds);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user