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:
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
4866
SVSim.Database/Migrations/20260613164340_AddColosseumCuratedDecks.Designer.cs
generated
Normal file
4866
SVSim.Database/Migrations/20260613164340_AddColosseumCuratedDecks.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
21
SVSim.Database/Models/ColosseumAvatarDeck.cs
Normal file
21
SVSim.Database/Models/ColosseumAvatarDeck.cs
Normal 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; }
|
||||
}
|
||||
23
SVSim.Database/Models/ColosseumHofDeck.cs
Normal file
23
SVSim.Database/Models/ColosseumHofDeck.cs
Normal 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; }
|
||||
}
|
||||
21
SVSim.Database/Models/ColosseumWindFallDeck.cs
Normal file
21
SVSim.Database/Models/ColosseumWindFallDeck.cs
Normal 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; }
|
||||
}
|
||||
17
SVSim.Database/Models/IColosseumCuratedDeck.cs
Normal file
17
SVSim.Database/Models/IColosseumCuratedDeck.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user