feat(svc): ArenaTwoPickCardPoolService (rarity-weighted, class+neutral)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -104,6 +104,7 @@ public class Program
|
||||
builder.Services.AddScoped<IStoryMasterRepository, StoryMasterRepository>();
|
||||
builder.Services.AddScoped<IViewerStoryProgressRepository, ViewerStoryProgressRepository>();
|
||||
builder.Services.AddScoped<IArenaTwoPickRunRepository, ArenaTwoPickRunRepository>();
|
||||
builder.Services.AddScoped<IArenaTwoPickCardPoolService, ArenaTwoPickCardPoolService>();
|
||||
builder.Services.AddScoped<IStoryService, StoryService>();
|
||||
builder.Services.AddScoped<IDeckListBuilder, DeckListBuilder>();
|
||||
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
||||
|
||||
117
SVSim.EmulatedEntrypoint/Services/ArenaTwoPickCardPoolService.cs
Normal file
117
SVSim.EmulatedEntrypoint/Services/ArenaTwoPickCardPoolService.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public class ArenaTwoPickCardPoolService : IArenaTwoPickCardPoolService
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IGameConfigService _config;
|
||||
|
||||
public ArenaTwoPickCardPoolService(SVSimDbContext db, IGameConfigService config)
|
||||
{
|
||||
_db = db; _config = config;
|
||||
}
|
||||
|
||||
public List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng)
|
||||
{
|
||||
var aCfg = _config.Get<ArenaTwoPickConfig>();
|
||||
var cCfg = _config.Get<ChallengeConfig>();
|
||||
|
||||
var setIds = cCfg.PoolCardSetIds is { Count: > 0 } ids
|
||||
? ids
|
||||
: _config.Get<RotationConfig>().RotationCardSetIds ?? new List<int>();
|
||||
|
||||
var setIdsArr = setIds.ToArray();
|
||||
|
||||
// Cards belong to sets via the ShadowverseCardSetEntry.Cards collection.
|
||||
// Class membership uses the Class navigation property: null = neutral (classId 0).
|
||||
// Collectible = CollectionInfo != null.
|
||||
var pool = _db.CardSets
|
||||
.Where(s => setIdsArr.Contains(s.Id))
|
||||
.SelectMany(s => s.Cards)
|
||||
.Include(c => c.Class)
|
||||
.Include(c => c.CollectionInfo)
|
||||
.Where(c => c.CollectionInfo != null)
|
||||
.Where(c => c.Class == null || c.Class.Id == classId)
|
||||
.ToList();
|
||||
|
||||
// Group by (isNeutral, Rarity) for O(1) bucket lookup.
|
||||
var byBucket = pool
|
||||
.GroupBy(c => (c.Class == null, c.Rarity))
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var pairs = new List<CandidatePair>(2);
|
||||
for (int setNum = 1; setNum <= 2; setNum++)
|
||||
{
|
||||
pairs.Add(new CandidatePair
|
||||
{
|
||||
Id = startingPairId + (setNum - 1),
|
||||
Turn = turn,
|
||||
SetNum = setNum,
|
||||
CardId1 = DrawOne(byBucket, aCfg, rng),
|
||||
CardId2 = DrawOne(byBucket, aCfg, rng),
|
||||
IsSelected = false,
|
||||
});
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
|
||||
private static long DrawOne(
|
||||
Dictionary<(bool isNeutral, Rarity rarity), List<ShadowverseCardEntry>> byBucket,
|
||||
ArenaTwoPickConfig cfg,
|
||||
IRandom rng)
|
||||
{
|
||||
var rarity = PickRarity(cfg, rng);
|
||||
var isNeutral = rng.NextDouble() < cfg.NeutralMixRate;
|
||||
|
||||
var candidates =
|
||||
TryBucket(byBucket, isNeutral, rarity)
|
||||
?? TryBucket(byBucket, !isNeutral, rarity)
|
||||
?? FallbackByRarity(byBucket, isNeutral, rarity)
|
||||
?? byBucket.Values.SelectMany(v => v).ToList();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
"ArenaTwoPickCardPoolService: card pool is empty for the configured class/set scope");
|
||||
|
||||
var pick = candidates[rng.Next(candidates.Count)];
|
||||
return pick.Id;
|
||||
}
|
||||
|
||||
private static List<ShadowverseCardEntry>? TryBucket(
|
||||
Dictionary<(bool, Rarity), List<ShadowverseCardEntry>> b,
|
||||
bool neutral,
|
||||
Rarity r) =>
|
||||
b.TryGetValue((neutral, r), out var v) && v.Count > 0 ? v : null;
|
||||
|
||||
private static List<ShadowverseCardEntry>? FallbackByRarity(
|
||||
Dictionary<(bool, Rarity), List<ShadowverseCardEntry>> b,
|
||||
bool neutral,
|
||||
Rarity r)
|
||||
{
|
||||
Rarity[] order = { Rarity.Legendary, Rarity.Gold, Rarity.Silver, Rarity.Bronze };
|
||||
int idx = Array.IndexOf(order, r);
|
||||
for (int i = idx + 1; i < order.Length; i++)
|
||||
{
|
||||
if (TryBucket(b, neutral, order[i]) is { } v) return v;
|
||||
if (TryBucket(b, !neutral, order[i]) is { } w) return w;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Rarity PickRarity(ArenaTwoPickConfig cfg, IRandom rng)
|
||||
{
|
||||
var roll = rng.NextDouble();
|
||||
if (roll < cfg.LegendaryRate) return Rarity.Legendary;
|
||||
roll -= cfg.LegendaryRate;
|
||||
if (roll < cfg.GoldRate) return Rarity.Gold;
|
||||
roll -= cfg.GoldRate;
|
||||
if (roll < cfg.SilverRate) return Rarity.Silver;
|
||||
return Rarity.Bronze;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
public interface IArenaTwoPickCardPoolService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns exactly 2 candidate pairs for the requested turn. Ids assigned monotonically
|
||||
/// (startingPairId, startingPairId+1); set_num = 1, 2; isSelected = false.
|
||||
/// </summary>
|
||||
List<CandidatePair> GeneratePickSetsForTurn(int classId, int turn, long startingPairId, IRandom rng);
|
||||
}
|
||||
142
SVSim.UnitTests/Services/ArenaTwoPickCardPoolServiceTests.cs
Normal file
142
SVSim.UnitTests/Services/ArenaTwoPickCardPoolServiceTests.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class ArenaTwoPickCardPoolServiceTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds a fresh in-memory DB with cards. Each card tuple is
|
||||
/// (id, classId, setId, rarity, collectible).
|
||||
/// classId == 0 means neutral (Class navigation = null).
|
||||
/// </summary>
|
||||
private static async Task<SVSimDbContext> SeedCardsAsync(
|
||||
params (long id, int classId, int setId, Rarity rarity, bool collectible)[] cards)
|
||||
{
|
||||
var factory = new SVSimTestFactory();
|
||||
var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
|
||||
// Collect required class ids and ensure ClassEntry rows exist.
|
||||
var requiredClassIds = cards.Select(c => c.classId).Where(id => id != 0).Distinct().ToList();
|
||||
foreach (var cid in requiredClassIds)
|
||||
{
|
||||
if (!db.Classes.Any(c => c.Id == cid))
|
||||
db.Classes.Add(new ClassEntry { Id = cid, Name = $"Class{cid}" });
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Load all needed classes into the context's Local cache so navigation assignments below work.
|
||||
await db.Classes.Where(c => requiredClassIds.Contains(c.Id)).LoadAsync();
|
||||
var classLookup = db.Classes.Local.ToDictionary(c => c.Id);
|
||||
|
||||
// Group cards by set and create CardSet entries with navigation.
|
||||
var bySet = cards.GroupBy(c => c.setId);
|
||||
foreach (var group in bySet)
|
||||
{
|
||||
var set = new ShadowverseCardSetEntry
|
||||
{
|
||||
Id = group.Key,
|
||||
Name = $"TestSet{group.Key}",
|
||||
IsInRotation = true,
|
||||
};
|
||||
foreach (var c in group)
|
||||
{
|
||||
var classEntry = c.classId == 0
|
||||
? null
|
||||
: classLookup[c.classId];
|
||||
set.Cards.Add(new ShadowverseCardEntry
|
||||
{
|
||||
Id = c.id,
|
||||
Name = $"Card{c.id}",
|
||||
Rarity = c.rarity,
|
||||
Class = classEntry,
|
||||
CollectionInfo = c.collectible
|
||||
? new CardCollectionInfo { CraftCost = 200, DustReward = 50 }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
db.CardSets.Add(set);
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
return db;
|
||||
}
|
||||
|
||||
private sealed class FakeRandom : IRandom
|
||||
{
|
||||
private readonly Queue<double> _doubles;
|
||||
private readonly Queue<int> _ints;
|
||||
public FakeRandom(IEnumerable<double> doubles, IEnumerable<int> ints)
|
||||
{ _doubles = new(doubles); _ints = new(ints); }
|
||||
public double NextDouble() => _doubles.Dequeue();
|
||||
public int Next(int maxExclusive) => _ints.Dequeue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GeneratePickSets_returns_two_pairs_with_monotonic_ids_and_correct_turn()
|
||||
{
|
||||
await using var db = await SeedCardsAsync(
|
||||
(100001010L, 1, 10015, Rarity.Bronze, true),
|
||||
(900001010L, 0, 10015, Rarity.Bronze, true));
|
||||
var config = new ArenaTwoPickConfig();
|
||||
var challenge = new ChallengeConfig { PoolCardSetIds = new() { 10015 } };
|
||||
var svc = new ArenaTwoPickCardPoolService(db, StubConfig(config, challenge));
|
||||
var rng = new FakeRandom(
|
||||
doubles: Enumerable.Repeat(0.99, 8),
|
||||
ints: Enumerable.Repeat(0, 8));
|
||||
|
||||
var pairs = svc.GeneratePickSetsForTurn(classId: 1, turn: 3, startingPairId: 42, rng);
|
||||
|
||||
Assert.That(pairs.Count, Is.EqualTo(2));
|
||||
Assert.That(pairs[0].Id, Is.EqualTo(42));
|
||||
Assert.That(pairs[1].Id, Is.EqualTo(43));
|
||||
Assert.That(pairs[0].Turn, Is.EqualTo(3));
|
||||
Assert.That(pairs[1].Turn, Is.EqualTo(3));
|
||||
Assert.That(pairs[0].SetNum, Is.EqualTo(1));
|
||||
Assert.That(pairs[1].SetNum, Is.EqualTo(2));
|
||||
Assert.That(pairs[0].IsSelected, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Empty_PoolCardSetIds_falls_back_to_RotationConfig_RotationCardSetIds()
|
||||
{
|
||||
await using var db = await SeedCardsAsync(
|
||||
(100001010L, 1, 10015, Rarity.Bronze, true));
|
||||
var config = new ArenaTwoPickConfig();
|
||||
var challenge = new ChallengeConfig { PoolCardSetIds = new() };
|
||||
var rotation = new RotationConfig { RotationCardSetIds = new() { 10015 } };
|
||||
var svc = new ArenaTwoPickCardPoolService(db, StubConfig(config, challenge, rotation));
|
||||
var rng = new FakeRandom(
|
||||
doubles: Enumerable.Repeat(0.99, 8),
|
||||
ints: Enumerable.Repeat(0, 8));
|
||||
|
||||
var pairs = svc.GeneratePickSetsForTurn(classId: 1, turn: 1, startingPairId: 1, rng);
|
||||
Assert.That(pairs.Count, Is.EqualTo(2));
|
||||
Assert.That(pairs[0].CardId1, Is.EqualTo(100001010L));
|
||||
}
|
||||
|
||||
private static IGameConfigService StubConfig(ArenaTwoPickConfig a, ChallengeConfig c, RotationConfig? r = null) =>
|
||||
new StubGameConfigService(a, c, r ?? new RotationConfig());
|
||||
|
||||
private sealed class StubGameConfigService : IGameConfigService
|
||||
{
|
||||
private readonly ArenaTwoPickConfig _a;
|
||||
private readonly ChallengeConfig _c;
|
||||
private readonly RotationConfig _r;
|
||||
public StubGameConfigService(ArenaTwoPickConfig a, ChallengeConfig c, RotationConfig r) { _a = a; _c = c; _r = r; }
|
||||
public T Get<T>() where T : class, new() =>
|
||||
typeof(T) == typeof(ArenaTwoPickConfig) ? (T)(object)_a :
|
||||
typeof(T) == typeof(ChallengeConfig) ? (T)(object)_c :
|
||||
typeof(T) == typeof(RotationConfig) ? (T)(object)_r :
|
||||
new T();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user