Card liquefication

This commit is contained in:
gamer147
2026-05-24 14:42:44 -04:00
parent d9ef9fe1fc
commit 12fb2f4801
9 changed files with 862 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Card;
public class CardInventoryRepository : ICardInventoryRepository
{
private readonly SVSimDbContext _db;
public CardInventoryRepository(SVSimDbContext db)
{
_db = db;
}
public async Task<DestructOutcome> DestructCards(long viewerId, IReadOnlyDictionary<long, int> destructCounts)
{
// Load covers cards + currency + decks. DeckCard.Card and OwnedCardEntry.Card both
// need explicit Includes — owned-collection auto-loading does not cover nested nav refs
// (see project_ef_nav_include_pitfall memory).
//
// AsSplitQuery is essential here. Without it, EF emits one SQL with a cartesian JOIN
// across OwnedCardEntry × DeckCard, materializing ~|owned_cards| × |deck_cards| rows
// for a single destruct. For a real account that's ~1500 × ~1600 = 2.4M rows and ~5s
// round-trip. Split queries issue separate SELECTs per Include chain — total rows
// stay linear in the data instead of multiplicative.
var viewer = await _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card).ThenInclude(c => c.CollectionInfo)
.Include(v => v.Decks).ThenInclude(d => d.Cards).ThenInclude(c => c.Card)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
var ownedByCardId = viewer.Cards.ToDictionary(c => c.Card.Id);
foreach (var (cardId, num) in destructCounts)
{
// TryGetValue can succeed with Card.Id == 0 due to an EF owned-collection nav-ref
// default-init quirk (see project_ef_nav_include_pitfall memory).
if (!ownedByCardId.TryGetValue(cardId, out var owned) || owned.Card.Id == 0)
return DestructOutcome.Fail(DestructError.UnknownCard);
if (owned.IsProtected)
return DestructOutcome.Fail(DestructError.CardProtected);
if (owned.Card.CollectionInfo is null || owned.Card.CollectionInfo.DustReward <= 0)
return DestructOutcome.Fail(DestructError.NotDestructible);
if (owned.Count < num)
return DestructOutcome.Fail(DestructError.InsufficientCards);
}
// Explicit transaction. The current implementation only issues one SaveChangesAsync, which
// EF would wrap implicitly. Making it explicit pins the boundary as the surface grows
// (e.g., if a future revision splits the save into multiple round-trips).
using var tx = await _db.Database.BeginTransactionAsync();
ulong totalVials = 0;
var postCounts = new Dictionary<long, int>(destructCounts.Count);
foreach (var (cardId, num) in destructCounts)
{
var owned = ownedByCardId[cardId];
owned.Count -= num;
totalVials += (ulong)owned.Card.CollectionInfo!.DustReward * (ulong)num;
postCounts[cardId] = owned.Count;
}
viewer.Currency.RedEther += totalVials;
// Deck auto-strip: any deck holding more copies of a destructed card than the viewer now owns
// has the excess removed. DeckCard.Count is the multiplicity; a row that hits 0 is deleted so
// wire serialization (card_id_array expansion) doesn't emit a phantom.
foreach (var deck in viewer.Decks)
{
// Iterate a snapshot so we can remove rows mid-loop.
foreach (var deckCard in deck.Cards.ToList())
{
if (!postCounts.TryGetValue(deckCard.Card.Id, out int newOwned))
continue;
int excess = deckCard.Count - newOwned;
if (excess <= 0)
continue;
deckCard.Count -= excess;
if (deckCard.Count == 0)
deck.Cards.Remove(deckCard);
}
}
await _db.SaveChangesAsync();
await tx.CommitAsync();
return DestructOutcome.Ok(new DestructResult(viewer.Currency.RedEther, postCounts));
}
}

View File

@@ -0,0 +1,44 @@
namespace SVSim.Database.Repositories.Card;
/// <summary>
/// Mutating operations on a viewer's card inventory (destruct, create, protect…).
/// Read-only catalog queries live on <see cref="ICardRepository"/>.
/// </summary>
public interface ICardInventoryRepository
{
/// <summary>
/// Validate-then-mutate destruct of owned cards. Atomic: all validation runs before any
/// mutation, and the mutation phase is wrapped in an explicit DB transaction so a mid-flight
/// EF failure rolls back currency + inventory + deck-strip together.
/// </summary>
/// <param name="viewerId">Authenticated viewer.</param>
/// <param name="destructCounts">cardId → num_to_destruct. Empty dict is rejected by the caller.</param>
/// <returns>
/// <see cref="DestructResult"/> with post-state totals on success, or a
/// <see cref="DestructError"/> when validation fails. On error nothing is written.
/// </returns>
Task<DestructOutcome> DestructCards(long viewerId, IReadOnlyDictionary<long, int> destructCounts);
}
/// <summary>
/// Either a success payload or an error code. Discriminated by which field is set.
/// </summary>
public sealed record DestructOutcome(DestructResult? Result, DestructError? Error)
{
public bool IsSuccess => Result is not null;
public static DestructOutcome Ok(DestructResult r) => new(r, null);
public static DestructOutcome Fail(DestructError e) => new(null, e);
}
public sealed record DestructResult(
ulong NewRedEtherTotal,
IReadOnlyDictionary<long, int> NewOwnedCounts); // cardId → post-destruct Count
public enum DestructError
{
UnknownCard,
NotDestructible,
CardProtected,
InsufficientCards,
}