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

@@ -0,0 +1,86 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Shared upsert path for the three Colosseum curated-deck pools. Each subclass binds the
/// EF entity type + the seed filename; the load + key + diff logic lives here. Empty seed
/// files are non-fatal — the pools ship empty by default per the plan (admins fill them
/// per-event).
/// </summary>
public abstract class ColosseumCuratedDeckImporterBase<TEntity>
where TEntity : class, IColosseumCuratedDeck, new()
{
protected abstract string SeedFileName { get; }
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
var path = Path.Combine(seedDir, SeedFileName);
if (!File.Exists(path))
{
Console.WriteLine($"[{GetType().Name}] missing {path}; skipping.");
return 0;
}
var seeds = SeedLoader.LoadList<ColosseumCuratedDeckSeed>(path);
var set = context.Set<TEntity>();
var existing = await set.ToDictionaryAsync(d => d.DeckNo);
int upserted = 0;
foreach (var s in seeds)
{
if (existing.TryGetValue(s.DeckNo, out var row))
{
row.ClassId = s.ClassId;
row.CardListJson = s.CardListJson;
row.SleeveId = s.SleeveId;
row.LeaderSkinId = s.LeaderSkinId;
row.DisplayOrder = s.DisplayOrder;
}
else
{
set.Add(new TEntity
{
DeckNo = s.DeckNo,
ClassId = s.ClassId,
CardListJson = s.CardListJson,
SleeveId = s.SleeveId,
LeaderSkinId = s.LeaderSkinId,
DisplayOrder = s.DisplayOrder,
});
}
upserted++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[{GetType().Name}] upserted={upserted}");
return upserted;
}
}
public sealed class ColosseumCuratedDeckSeed
{
public int DeckNo { get; set; }
public int ClassId { get; set; }
public string CardListJson { get; set; } = "[]";
public long SleeveId { get; set; }
public long LeaderSkinId { get; set; }
public int DisplayOrder { get; set; }
}
public sealed class ColosseumHofDecksImporter : ColosseumCuratedDeckImporterBase<ColosseumHofDeck>
{
protected override string SeedFileName => "colosseum-hof-decks.json";
}
public sealed class ColosseumWindFallDecksImporter : ColosseumCuratedDeckImporterBase<ColosseumWindFallDeck>
{
protected override string SeedFileName => "colosseum-windfall-decks.json";
}
public sealed class ColosseumAvatarDecksImporter : ColosseumCuratedDeckImporterBase<ColosseumAvatarDeck>
{
protected override string SeedFileName => "colosseum-avatar-decks.json";
}

View File

@@ -85,6 +85,9 @@ public static class Program
await new AvatarAbilityImporter().ImportAsync(context, opts.SeedDir);
await new ArenaSeasonImporter().ImportAsync(context, opts.SeedDir);
await new ArenaTwoPickRewardImporter().ImportAsync(context, opts.SeedDir);
await new ColosseumHofDecksImporter().ImportAsync(context, opts.SeedDir);
await new ColosseumWindFallDecksImporter().ImportAsync(context, opts.SeedDir);
await new ColosseumAvatarDecksImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassSeasonImporter().ImportAsync(context, opts.SeedDir);
await new BattlePassRewardImporter().ImportAsync(context, opts.SeedDir);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddColosseumCuratedDecks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ColosseumAvatarDecks",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DeckNo = table.Column<int>(type: "integer", nullable: false),
ClassId = table.Column<int>(type: "integer", nullable: false),
CardListJson = table.Column<string>(type: "jsonb", nullable: false),
SleeveId = table.Column<long>(type: "bigint", nullable: false),
LeaderSkinId = table.Column<long>(type: "bigint", nullable: false),
DisplayOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ColosseumAvatarDecks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ColosseumHofDecks",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DeckNo = table.Column<int>(type: "integer", nullable: false),
ClassId = table.Column<int>(type: "integer", nullable: false),
CardListJson = table.Column<string>(type: "jsonb", nullable: false),
SleeveId = table.Column<long>(type: "bigint", nullable: false),
LeaderSkinId = table.Column<long>(type: "bigint", nullable: false),
DisplayOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ColosseumHofDecks", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ColosseumWindFallDecks",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
DeckNo = table.Column<int>(type: "integer", nullable: false),
ClassId = table.Column<int>(type: "integer", nullable: false),
CardListJson = table.Column<string>(type: "jsonb", nullable: false),
SleeveId = table.Column<long>(type: "bigint", nullable: false),
LeaderSkinId = table.Column<long>(type: "bigint", nullable: false),
DisplayOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ColosseumWindFallDecks", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_ColosseumAvatarDecks_DeckNo",
table: "ColosseumAvatarDecks",
column: "DeckNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ColosseumHofDecks_DeckNo",
table: "ColosseumHofDecks",
column: "DeckNo",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ColosseumWindFallDecks_DeckNo",
table: "ColosseumWindFallDecks",
column: "DeckNo",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ColosseumAvatarDecks");
migrationBuilder.DropTable(
name: "ColosseumHofDecks");
migrationBuilder.DropTable(
name: "ColosseumWindFallDecks");
}
}
}

View File

@@ -970,6 +970,41 @@ namespace SVSim.Database.Migrations
b.ToTable("ClassExpCurve");
});
modelBuilder.Entity("SVSim.Database.Models.ColosseumAvatarDeck", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("CardListJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<int>("ClassId")
.HasColumnType("integer");
b.Property<int>("DeckNo")
.HasColumnType("integer");
b.Property<int>("DisplayOrder")
.HasColumnType("integer");
b.Property<long>("LeaderSkinId")
.HasColumnType("bigint");
b.Property<long>("SleeveId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("DeckNo")
.IsUnique();
b.ToTable("ColosseumAvatarDecks");
});
modelBuilder.Entity("SVSim.Database.Models.ColosseumConfig", b =>
{
b.Property<int>("Id")
@@ -1041,6 +1076,76 @@ namespace SVSim.Database.Migrations
b.ToTable("Colosseums");
});
modelBuilder.Entity("SVSim.Database.Models.ColosseumHofDeck", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("CardListJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<int>("ClassId")
.HasColumnType("integer");
b.Property<int>("DeckNo")
.HasColumnType("integer");
b.Property<int>("DisplayOrder")
.HasColumnType("integer");
b.Property<long>("LeaderSkinId")
.HasColumnType("bigint");
b.Property<long>("SleeveId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("DeckNo")
.IsUnique();
b.ToTable("ColosseumHofDecks");
});
modelBuilder.Entity("SVSim.Database.Models.ColosseumWindFallDeck", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("CardListJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<int>("ClassId")
.HasColumnType("integer");
b.Property<int>("DeckNo")
.HasColumnType("integer");
b.Property<int>("DisplayOrder")
.HasColumnType("integer");
b.Property<long>("LeaderSkinId")
.HasColumnType("bigint");
b.Property<long>("SleeveId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("DeckNo")
.IsUnique();
b.ToTable("ColosseumWindFallDecks");
});
modelBuilder.Entity("SVSim.Database.Models.DailyLoginBonusEntry", b =>
{
b.Property<int>("Id")

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace SVSim.Database.Models;
/// <summary>
/// Curated Avatar (themed-character) deck for Arena Colosseum. See
/// <see cref="ColosseumHofDeck"/> for the rationale on the duplicated schema.
/// </summary>
public class ColosseumAvatarDeck : IColosseumCuratedDeck
{
public long Id { get; set; }
public int DeckNo { get; set; }
public int ClassId { get; set; }
[Column(TypeName = "jsonb")]
public string CardListJson { get; set; } = "[]";
public long SleeveId { get; set; }
public long LeaderSkinId { get; set; }
public int DisplayOrder { get; set; }
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace SVSim.Database.Models;
/// <summary>
/// Curated Hall-of-Fame deck pool for Arena Colosseum. Identical schema to
/// <see cref="ColosseumWindFallDeck"/> and <see cref="ColosseumAvatarDeck"/> — separate
/// tables instead of an enum-discriminated shared one because the operational lifecycle
/// (per-pool importer, per-pool register endpoint, per-pool query) is independent.
/// </summary>
public class ColosseumHofDeck : IColosseumCuratedDeck
{
public long Id { get; set; }
public int DeckNo { get; set; }
public int ClassId { get; set; }
[Column(TypeName = "jsonb")]
public string CardListJson { get; set; } = "[]";
public long SleeveId { get; set; }
public long LeaderSkinId { get; set; }
public int DisplayOrder { get; set; }
}

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace SVSim.Database.Models;
/// <summary>
/// Curated WindFall (limited-pool wildcard) deck for Arena Colosseum. See
/// <see cref="ColosseumHofDeck"/> for the rationale on the duplicated schema.
/// </summary>
public class ColosseumWindFallDeck : IColosseumCuratedDeck
{
public long Id { get; set; }
public int DeckNo { get; set; }
public int ClassId { get; set; }
[Column(TypeName = "jsonb")]
public string CardListJson { get; set; } = "[]";
public long SleeveId { get; set; }
public long LeaderSkinId { get; set; }
public int DisplayOrder { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace SVSim.Database.Models;
/// <summary>
/// Common shape across the three curated-deck pool tables — used by the controller's
/// generic list+register dispatcher (Phase 3 Task 10) to avoid duplicating identical code
/// per pool. The interface is plumbing only; the pools stay distinct EF entity types.
/// </summary>
public interface IColosseumCuratedDeck
{
long Id { get; set; }
int DeckNo { get; set; }
int ClassId { get; set; }
string CardListJson { get; set; }
long SleeveId { get; set; }
long LeaderSkinId { get; set; }
int DisplayOrder { get; set; }
}

View File

@@ -108,6 +108,9 @@ public class SVSimDbContext : DbContext
public DbSet<ArenaTwoPickReward> ArenaTwoPickRewards { get; set; } = null!;
public DbSet<ViewerArenaTwoPickRun> ViewerArenaTwoPickRuns { get; set; } = null!;
public DbSet<ViewerArenaColosseumRun> ViewerArenaColosseumRuns { get; set; } = null!;
public DbSet<ColosseumHofDeck> ColosseumHofDecks { get; set; } = null!;
public DbSet<ColosseumWindFallDeck> ColosseumWindFallDecks { get; set; } = null!;
public DbSet<ColosseumAvatarDeck> ColosseumAvatarDecks { get; set; } = null!;
public DbSet<SerialCodeEntry> SerialCodes => Set<SerialCodeEntry>();
public DbSet<SerialCodeRewardEntry> SerialCodeRewards => Set<SerialCodeRewardEntry>();
@@ -497,6 +500,12 @@ public class SVSimDbContext : DbContext
b.Property(e => e.OpponentRotationId).IsRequired();
});
// Colosseum curated-deck pools — DeckNo is the wire identifier admins reference in
// register endpoints; uniqueness per pool is the contract clients rely on.
modelBuilder.Entity<ColosseumHofDeck>().HasIndex(d => d.DeckNo).IsUnique();
modelBuilder.Entity<ColosseumWindFallDeck>().HasIndex(d => d.DeckNo).IsUnique();
modelBuilder.Entity<ColosseumAvatarDeck>().HasIndex(d => d.DeckNo).IsUnique();
base.OnModelCreating(modelBuilder);
}

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 _)
{

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; } = "[]";
}

View File

@@ -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();
}

View File

@@ -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; }
}

View File

@@ -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
// 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();

View File

@@ -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);
}

View File

@@ -0,0 +1,200 @@
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 curated-deck coverage — the 3 list URLs and 3 register URLs share one
/// generic dispatcher; the same scenarios are parameterized across HOF / WindFall / Avatar
/// to lock per-pool isolation.
/// </summary>
public class ArenaColosseumControllerCuratedDeckTests
{
private static readonly object Envelope =
new { viewer_id = "0", steam_id = 0, steam_session_ticket = "" };
public enum Pool { Hof, WindFall, Avatar }
private static string ListUrl(Pool pool) => pool switch
{
Pool.Hof => "/arena_colosseum/get_hof_deck_list",
Pool.WindFall => "/arena_colosseum/get_windfall_deck_list",
Pool.Avatar => "/arena_colosseum/get_avatar_deck_list",
_ => throw new ArgumentOutOfRangeException(),
};
private static string RegisterUrl(Pool pool) => pool switch
{
Pool.Hof => "/arena_colosseum/register_hof_deck",
Pool.WindFall => "/arena_colosseum/register_windfall_deck",
Pool.Avatar => "/arena_colosseum/register_avatar_deck",
_ => throw new ArgumentOutOfRangeException(),
};
private static async Task SeedCuratedDecksAsync(SVSimTestFactory factory, Pool pool, params int[] deckNos)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
foreach (var no in deckNos)
{
switch (pool)
{
case Pool.Hof:
db.ColosseumHofDecks.Add(new ColosseumHofDeck
{
DeckNo = no, ClassId = 1, DisplayOrder = no,
CardListJson = "[101,102,103]", SleeveId = 3000011,
});
break;
case Pool.WindFall:
db.ColosseumWindFallDecks.Add(new ColosseumWindFallDeck
{
DeckNo = no, ClassId = 2, DisplayOrder = no,
CardListJson = "[201,202,203]",
});
break;
case Pool.Avatar:
db.ColosseumAvatarDecks.Add(new ColosseumAvatarDeck
{
DeckNo = no, ClassId = 3, DisplayOrder = no,
CardListJson = "[301,302,303]", LeaderSkinId = 70000,
});
break;
}
}
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.Hof,
});
await db.SaveChangesAsync();
}
[Test]
[TestCase(Pool.Hof)]
[TestCase(Pool.WindFall)]
[TestCase(Pool.Avatar)]
public async Task GetDeckList_returns_seeded_entries_as_bare_array(Pool pool)
{
using var factory = new SVSimTestFactory();
await SeedCuratedDecksAsync(factory, pool, 1001, 1002);
var vid = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(vid);
var resp = await client.PostAsync(ListUrl(pool), JsonContent.Create(Envelope));
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var body = await resp.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(body);
Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Array),
$"{pool}: spec requires a BARE array at data — client iterates without a wrapper");
Assert.That(doc.RootElement.GetArrayLength(), Is.EqualTo(2));
Assert.That(doc.RootElement[0].GetProperty("deck_id").GetInt32(), Is.EqualTo(1001));
}
[Test]
[TestCase(Pool.Hof)]
[TestCase(Pool.WindFall)]
[TestCase(Pool.Avatar)]
public async Task RegisterDeck_round_trips_deck_no_list(Pool pool)
{
using var factory = new SVSimTestFactory();
await SeedCuratedDecksAsync(factory, pool, 1001);
var vid = await factory.SeedViewerAsync();
await SeedRunAsync(factory, vid);
using var client = factory.CreateAuthenticatedClient(vid);
var resp = await client.PostAsync(RegisterUrl(pool),
JsonContent.Create(new { deck_no_list = "[1001]", 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.RegisteredDeckNoListJson, Is.EqualTo("[1001]"));
}
[Test]
[TestCase(Pool.Hof)]
[TestCase(Pool.WindFall)]
[TestCase(Pool.Avatar)]
public async Task RegisterDeck_rejects_unknown_deck_no(Pool pool)
{
using var factory = new SVSimTestFactory();
await SeedCuratedDecksAsync(factory, pool, 1001);
var vid = await factory.SeedViewerAsync();
await SeedRunAsync(factory, vid);
using var client = factory.CreateAuthenticatedClient(vid);
var resp = await client.PostAsync(RegisterUrl(pool),
JsonContent.Create(new { deck_no_list = "[9999]", 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("deck_not_found", body);
}
[Test]
public async Task Cross_pool_register_rejected_hof_against_windfall()
{
using var factory = new SVSimTestFactory();
// 1001 lives in HOF only.
await SeedCuratedDecksAsync(factory, Pool.Hof, 1001);
var vid = await factory.SeedViewerAsync();
await SeedRunAsync(factory, vid);
using var client = factory.CreateAuthenticatedClient(vid);
// Register against WindFall — the HOF deck_no should not resolve.
var resp = await client.PostAsync(RegisterUrl(Pool.WindFall),
JsonContent.Create(new { deck_no_list = "[1001]", 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("deck_not_found", body);
}
[Test]
public async Task RegisterDeck_clears_is_published_flag_on_swap_from_constructed()
{
using var factory = new SVSimTestFactory();
await SeedCuratedDecksAsync(factory, Pool.Hof, 1001);
var vid = await factory.SeedViewerAsync();
await SeedRunAsync(factory, vid);
// Simulate the viewer having previously registered a constructed deck with is_published=true.
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var run = await db.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid);
run.IsPublished = true;
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(vid);
await client.PostAsync(RegisterUrl(Pool.Hof),
JsonContent.Create(new { deck_no_list = "[1001]", viewer_id = "0", steam_id = 0, steam_session_ticket = "" }));
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.IsPublished, Is.False,
"curated register has no is_published wire field — server clears it to keep state consistent");
}
}

View File

@@ -0,0 +1,221 @@
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");
}
}

View File

@@ -24,6 +24,8 @@ public class ArenaTwoPickServiceDraftTests
new() { Id = startingPairId + 1, Turn = turn, SetNum = 2,
CardId1 = 2000 + turn * 10 + 1, CardId2 = 2000 + turn * 10 + 2, IsSelected = false },
};
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng, IReadOnlyList<int>? poolCardSetIds)
=> GeneratePickSetsForTurn(classId, turn, startingPairId, rng);
}
private static async Task<(IArenaTwoPickService, IArenaTwoPickRunRepository, long viewerId)> SetupWithActiveRunAsync(int classChosen = 0)

View File

@@ -22,6 +22,8 @@ public class ArenaTwoPickServiceEntryTests
{
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng)
=> throw new NotSupportedException("pool not used in EntryAsync");
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng, IReadOnlyList<int>? poolCardSetIds)
=> throw new NotSupportedException("pool not used in EntryAsync");
}
private static async Task<(SVSimDbContext db, IArenaTwoPickService svc, long viewerId)> SetupAsync(

View File

@@ -21,6 +21,7 @@ public class ArenaTwoPickServiceFinishTests
private sealed class FakePool : IArenaTwoPickCardPoolService
{
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new();
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng, IReadOnlyList<int>? poolCardSetIds) => new();
}
private static async Task<(SVSimDbContext db, IArenaTwoPickService svc, long viewerId)> SetupWithRunAsync(

View File

@@ -21,6 +21,7 @@ public class ArenaTwoPickServiceWeightedRewardsTests
private sealed class FakePool : IArenaTwoPickCardPoolService
{
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng) => new();
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng, IReadOnlyList<int>? poolCardSetIds) => new();
}
/// <summary>