Pack logic cleanup

This commit is contained in:
gamer147
2026-05-24 09:27:10 -04:00
parent 79209bd70b
commit d9ef9fe1fc
33 changed files with 71175 additions and 245 deletions

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&lt;SlotRarityWeights&gt; (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)