Pack logic cleanup
This commit is contained in:
@@ -171,7 +171,7 @@ public class LoadController : SVSimController
|
||||
MyPageBackgrounds = viewer.MyPageBackgrounds.Select(mpbg => mpbg.Id.ToString()).ToList(),
|
||||
LootBoxRegulations = new LootBoxRegulations(),
|
||||
GatheringInfo = new GatheringInfo(),
|
||||
IsBattlePassPeriod = cfg.IsBattlePassPeriod,
|
||||
IsBattlePassPeriod = cfg.Config.Rotation.IsBattlePassPeriod,
|
||||
// Optional per spec (load-index.md:228). We have BattlePassLevelEntry rows seeded, but
|
||||
// no per-viewer Battle Pass progression yet — emit null until that subsystem lands.
|
||||
BattlePassLevelInfo = null,
|
||||
@@ -214,8 +214,8 @@ public class LoadController : SVSimController
|
||||
}).ToList(),
|
||||
ArenaConfig = new ArenaConfig
|
||||
{
|
||||
UseChallengePickTwoPremiumCard = cfg.ChallengeUseTwoPickPremiumCard ? 1 : 0,
|
||||
ChallengePickTwoCardSleeve = (int)cfg.ChallengeTwoPickSleeveId,
|
||||
UseChallengePickTwoPremiumCard = cfg.Config.Challenge.UseTwoPickPremiumCard ? 1 : 0,
|
||||
ChallengePickTwoCardSleeve = (int)cfg.Config.Challenge.TwoPickSleeveId,
|
||||
},
|
||||
ArenaInfos = await BuildArenaInfosAsync(),
|
||||
RotationSets = rotationSets,
|
||||
@@ -226,7 +226,7 @@ public class LoadController : SVSimController
|
||||
ClassExp = classExps,
|
||||
RankInfo = (await _globalsRepository.GetRankInfo()).Select(ri => new RankInfo(ri)).ToList(),
|
||||
DeckFormat = Format.Rotation,
|
||||
CardSetIdForResourceDlView = cfg.CardSetIdForResourceDlView,
|
||||
CardSetIdForResourceDlView = cfg.Config.Rotation.CardSetIdForResourceDlView,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ public class MyPageController : SVSimController
|
||||
UserMyPageSetting = new MyPageBgSetting(),
|
||||
},
|
||||
BasicPuzzle = new BasicPuzzle { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
|
||||
IsBattlePassPeriod = cfg.IsBattlePassPeriod,
|
||||
IsBattlePassPeriod = cfg.Config.Rotation.IsBattlePassPeriod,
|
||||
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
|
||||
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
|
||||
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,
|
||||
|
||||
@@ -19,9 +19,9 @@ public class DefaultSettings
|
||||
|
||||
public DefaultSettings(GameConfiguration config)
|
||||
{
|
||||
this.DefaultMyPageBackground = config.DefaultMyPageBackground.Id;
|
||||
this.DefaultDegreeId = config.DefaultDegree.Id;
|
||||
this.DefaultEmblemId = config.DefaultEmblem.Id;
|
||||
this.DefaultMyPageBackground = config.Config.DefaultLoadout.MyPageBackgroundId;
|
||||
this.DefaultDegreeId = config.Config.DefaultLoadout.DegreeId;
|
||||
this.DefaultEmblemId = config.Config.DefaultLoadout.EmblemId;
|
||||
}
|
||||
|
||||
public DefaultSettings()
|
||||
|
||||
@@ -63,8 +63,14 @@ public class Program
|
||||
builder.Services.AddTransient<IGlobalsRepository, GlobalsRepository>();
|
||||
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
|
||||
builder.Services.AddTransient<IPackRepository, PackRepository>();
|
||||
// Scoped (not Singleton) to avoid the singleton-depends-on-scoped-DbContext lifecycle
|
||||
// pitfall. Cost: one indexed single-row query per request — trivial. Restart still picks
|
||||
// up DB-edit changes since each new request rebuilds the scope.
|
||||
builder.Services.AddScoped<SVSim.Database.Models.GameConfigRoot>(sp =>
|
||||
sp.GetRequiredService<SVSim.Database.Repositories.Globals.IGlobalsRepository>()
|
||||
.GetGameConfiguration("default").GetAwaiter().GetResult().Config);
|
||||
builder.Services.AddScoped<ICardPoolProvider, DbCardPoolProvider>();
|
||||
builder.Services.AddSingleton<PackOpenService>();
|
||||
builder.Services.AddScoped<PackOpenService>();
|
||||
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
||||
|
||||
#endregion
|
||||
@@ -93,6 +99,7 @@ public class Program
|
||||
if (dbContext.Database.IsRelational() && !app.Environment.IsEnvironment("Testing"))
|
||||
{
|
||||
dbContext.UpdateDatabase();
|
||||
dbContext.EnsureSeedDataAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,27 +16,26 @@ public class DbCardPoolProvider : ICardPoolProvider
|
||||
{
|
||||
case PackCategory.None:
|
||||
case PackCategory.LegendCardPack:
|
||||
// Standard pack — pool comes from the card set whose id equals base_pack_id.
|
||||
return _db.CardSets
|
||||
.Include(s => s.Cards)
|
||||
.Where(s => s.Id == pack.BasePackId)
|
||||
.SelectMany(s => s.Cards)
|
||||
.Where(c => !c.IsFoil)
|
||||
.ToList();
|
||||
|
||||
case PackCategory.SpecialCardPack:
|
||||
case PackCategory.LimitedSpecialCardPack:
|
||||
// Legendary-special packs pull from all rotation sets. The slot-8 forced-Legendary
|
||||
// rule in PackOpenService delivers the "at least one legendary" promise.
|
||||
return _db.CardSets
|
||||
.Where(s => s.IsInRotation)
|
||||
.Include(s => s.Cards)
|
||||
.SelectMany(s => s.Cards)
|
||||
.Where(c => !c.IsFoil)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
default:
|
||||
// Skin / starter / leader-skin packs aren't drawn in v1 — controller rejects earlier.
|
||||
return Array.Empty<ShadowverseCardEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
public ShadowverseCardEntry? TryGetFoilTwin(long baseCardId) =>
|
||||
_db.Cards.FirstOrDefault(c => c.Id == baseCardId + 1 && c.IsFoil);
|
||||
}
|
||||
|
||||
@@ -6,4 +6,12 @@ namespace SVSim.EmulatedEntrypoint.Services;
|
||||
public interface ICardPoolProvider
|
||||
{
|
||||
IReadOnlyList<ShadowverseCardEntry> GetPool(PackConfigEntry pack);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the foil twin of <paramref name="baseCardId"/> if it exists in master data
|
||||
/// (foil card_id = base card_id + 1 by the cards.json convention), else null. One DB
|
||||
/// hit per call; expected ~0.64 calls per 8-card pack at the default 8% rate.
|
||||
/// TODO(caching): folds into the broader caching wave once one exists.
|
||||
/// </summary>
|
||||
ShadowverseCardEntry? TryGetFoilTwin(long baseCardId);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Draws cards from a pack's pool using the original Shadowverse Classic rates:
|
||||
/// Slots 1-7: Bronze 67.44% / Silver 25% / Gold 6% / Legendary 1.5%
|
||||
/// Slot 8: Silver 76.92% / Gold 18.46% / Legendary 4.62% (no Bronze)
|
||||
/// Legendary-special packs (category 2/3, base >= 90001): slot 8 forced to Legendary.
|
||||
/// Draws cards from a pack's pool using rates from the injected <see cref="GameConfigRoot"/>'s
|
||||
/// <see cref="PackRateConfig"/>. Slot rarity selection is unified through one
|
||||
/// <see cref="PickRarity"/> + <see cref="ResolveWeights"/> pair — what was previously a
|
||||
/// hardcoded slot-1-7 vs slot-8 split now reads from <c>PackRateConfig.PerSlot</c>.
|
||||
///
|
||||
/// The 0.06% slack in slots 1-7 (rates sum to 99.94%) is folded into Bronze so cumulative
|
||||
/// weights add to exactly 1.0 — any RNG roll past the Gold band lands in either Legendary or
|
||||
/// Bronze, and we put it in Bronze to err on the player-unfriendly side of the spec.
|
||||
/// The "legendary-special slot-8 forced Legendary" rule stays in code (structural category
|
||||
/// promise, not a tunable rate).
|
||||
/// </summary>
|
||||
public class PackOpenService
|
||||
{
|
||||
private const int CardsPerPack = 8;
|
||||
|
||||
private readonly PackRateConfig _rates;
|
||||
|
||||
public PackOpenService(GameConfigRoot config)
|
||||
{
|
||||
_rates = config.PackRates;
|
||||
}
|
||||
|
||||
public DrawResult Draw(
|
||||
PackConfigEntry pack,
|
||||
ICardPoolProvider pools,
|
||||
@@ -45,48 +52,62 @@ public class PackOpenService
|
||||
{
|
||||
for (int s = 0; s < CardsPerPack; s++)
|
||||
{
|
||||
int slotNum = s + 1; // 1-based
|
||||
|
||||
Rarity rarity;
|
||||
if (s == CardsPerPack - 1)
|
||||
if (slotNum == CardsPerPack && isLegendarySpecial)
|
||||
{
|
||||
// Slot 8
|
||||
if (isLegendarySpecial)
|
||||
{
|
||||
rarity = Rarity.Legendary;
|
||||
}
|
||||
else
|
||||
{
|
||||
rarity = PickSlot8Rarity(rng);
|
||||
}
|
||||
// Structural category rule (not a tunable rate).
|
||||
rarity = Rarity.Legendary;
|
||||
}
|
||||
else
|
||||
{
|
||||
rarity = PickSlot1To7Rarity(rng);
|
||||
rarity = PickRarity(rng, ResolveWeights(slotNum));
|
||||
}
|
||||
|
||||
var card = PickCardOfRarity(rarity, poolByRarity, rng);
|
||||
|
||||
// Per-card, per-slot animated upgrade. Applies independently of rarity, slot
|
||||
// position, and pack category — including forced-Legendary slot-8 of specials.
|
||||
if (rng.NextDouble() < _rates.AnimatedRate)
|
||||
{
|
||||
var foil = pools.TryGetFoilTwin(card.Id);
|
||||
if (foil is not null) card = foil; // silently keep base if no twin exists
|
||||
}
|
||||
|
||||
slots.Add(new DrawnCard(card.Id, card.Rarity));
|
||||
}
|
||||
}
|
||||
return new DrawResult(slots);
|
||||
}
|
||||
|
||||
private static Rarity PickSlot1To7Rarity(IRandom rng)
|
||||
/// <summary>
|
||||
/// Returns the rarity weights for the given 1-based slot. Looks for a per-slot override
|
||||
/// keyed by <c>Slot == slotNum.ToString()</c>; falls back to the global Default.
|
||||
///
|
||||
/// NOTE: PerSlot is List<SlotRarityWeights> (not Dictionary) due to an EF Core 8
|
||||
/// jsonb-mapping limitation. Per-pack overrides would extend this resolver to check a
|
||||
/// per-pack collection first.
|
||||
/// </summary>
|
||||
private SlotRarityWeights ResolveWeights(int slotNum)
|
||||
{
|
||||
double r = rng.NextDouble();
|
||||
// Build cumulative bands in this order: Legendary -> Gold -> Silver -> Bronze (catch-all).
|
||||
if (r < 0.0150) return Rarity.Legendary; // 1.5%
|
||||
if (r < 0.0750) return Rarity.Gold; // +6% = 7.5%
|
||||
if (r < 0.3250) return Rarity.Silver; // +25% = 32.5%
|
||||
return Rarity.Bronze; // remaining (~67.5%; absorbs 0.06% slack)
|
||||
var slotKey = slotNum.ToString();
|
||||
var perSlot = _rates.PerSlot.FirstOrDefault(s => s.Slot == slotKey);
|
||||
return perSlot ?? _rates.Default;
|
||||
}
|
||||
|
||||
private static Rarity PickSlot8Rarity(IRandom rng)
|
||||
private static Rarity PickRarity(IRandom rng, SlotRarityWeights w)
|
||||
{
|
||||
// Cumulative-band order: Legendary -> Gold -> Silver -> Bronze (catch-all).
|
||||
// - When weights sum to <1.0 (SV Classic Default = 0.9994), the slack absorbs into
|
||||
// Bronze via the catch-all — preserves historic behavior.
|
||||
// - When weights sum to exactly 1.0 (SV Classic PerSlot[8] with Bronze=0), the catch-all
|
||||
// never fires and Bronze=0 holds naturally.
|
||||
double r = rng.NextDouble();
|
||||
// Renormalized over 32.5: Legendary 4.62%, Gold 18.46%, Silver 76.92%.
|
||||
if (r < 0.0462) return Rarity.Legendary;
|
||||
if (r < 0.2308) return Rarity.Gold; // 0.0462 + 0.1846
|
||||
return Rarity.Silver;
|
||||
double cum = w.Legendary; if (r < cum) return Rarity.Legendary;
|
||||
cum += w.Gold; if (r < cum) return Rarity.Gold;
|
||||
cum += w.Silver; if (r < cum) return Rarity.Silver;
|
||||
return Rarity.Bronze;
|
||||
}
|
||||
|
||||
private static ShadowverseCardEntry PickCardOfRarity(
|
||||
@@ -94,10 +115,12 @@ public class PackOpenService
|
||||
Dictionary<Rarity, List<ShadowverseCardEntry>> poolByRarity,
|
||||
IRandom rng)
|
||||
{
|
||||
// Fallback if the rolled rarity has no cards (e.g. pool has no Legendaries):
|
||||
// walk down to Gold -> Silver -> Bronze. This is a safety net for sparse master data;
|
||||
// healthy production pools have all four rarities.
|
||||
Rarity[] fallback = { rarity, Rarity.Gold, Rarity.Silver, Rarity.Bronze };
|
||||
// Fallback if the rolled rarity has no cards: walk down (and up) through all rarities.
|
||||
// Order: rolled rarity first, then Legendary -> Gold -> Silver -> Bronze, deduped by
|
||||
// LINQ Distinct. This handles both "no Legendaries" (fall down) and sparse pools that
|
||||
// only contain a single rarity (fall up). Safety net for sparse master data.
|
||||
Rarity[] fallback = new[] { rarity, Rarity.Legendary, Rarity.Gold, Rarity.Silver, Rarity.Bronze }
|
||||
.Distinct().ToArray();
|
||||
foreach (var r in fallback)
|
||||
{
|
||||
if (poolByRarity.TryGetValue(r, out var list) && list.Count > 0)
|
||||
|
||||
Reference in New Issue
Block a user