Card liquefication
This commit is contained in:
88
SVSim.Database/Repositories/Card/CardInventoryRepository.cs
Normal file
88
SVSim.Database/Repositories/Card/CardInventoryRepository.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
44
SVSim.Database/Repositories/Card/ICardInventoryRepository.cs
Normal file
44
SVSim.Database/Repositories/Card/ICardInventoryRepository.cs
Normal 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,
|
||||
}
|
||||
130
SVSim.EmulatedEntrypoint/Controllers/CardController.cs
Normal file
130
SVSim.EmulatedEntrypoint/Controllers/CardController.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Repositories.Card;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Card;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Card;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// /card/* — viewer card-inventory mutations. v1 implements /card/destruct only; reserved
|
||||
/// for /card/create, /card/protect, /card/create-foil-card.
|
||||
/// </summary>
|
||||
[Route("card")]
|
||||
public class CardController : SVSimController
|
||||
{
|
||||
private readonly ICardInventoryRepository _inventory;
|
||||
private readonly ILogger<CardController> _log;
|
||||
|
||||
public CardController(ICardInventoryRepository inventory, ILogger<CardController> log)
|
||||
{
|
||||
_inventory = inventory;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
[HttpPost("destruct")]
|
||||
public async Task<ActionResult<CardDestructResponse>> Destruct(CardDestructRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
if (!TryParseDestructDict(request.CardIdNumberArray, out var destructCounts, out var snapshots, out var parseError))
|
||||
return BadRequest(new { error = parseError });
|
||||
|
||||
if (destructCounts.Count == 0)
|
||||
return BadRequest(new { error = "malformed_request" });
|
||||
|
||||
var outcome = await _inventory.DestructCards(viewerId, destructCounts);
|
||||
if (!outcome.IsSuccess)
|
||||
return BadRequest(new { error = ErrorKey(outcome.Error!.Value) });
|
||||
|
||||
// Client snapshot mismatch is warn-log only; never blocks the request.
|
||||
foreach (var (cardId, snapshot) in snapshots)
|
||||
{
|
||||
// We don't carry pre-state counts back, but post + destructed = pre.
|
||||
int destructedNum = destructCounts[cardId];
|
||||
int reconstructedPre = outcome.Result!.NewOwnedCounts[cardId] + destructedNum;
|
||||
if (reconstructedPre != snapshot)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"Destruct possession-snapshot mismatch: card={CardId} client_snapshot={Snapshot} server_pre={ServerPre}",
|
||||
cardId, snapshot, reconstructedPre);
|
||||
}
|
||||
}
|
||||
|
||||
// Wire spec is int; clamp the ulong total so a hypothetical 2B+ balance can't underflow
|
||||
// to a negative wire value. Realistic balances are well under int.MaxValue.
|
||||
int redEtherWire = outcome.Result!.NewRedEtherTotal > int.MaxValue
|
||||
? int.MaxValue
|
||||
: (int)outcome.Result!.NewRedEtherTotal;
|
||||
var rewardList = new List<RewardListEntry>
|
||||
{
|
||||
new() { RewardType = 1, RewardId = 0, RewardNum = redEtherWire },
|
||||
};
|
||||
foreach (var (cardId, postCount) in outcome.Result!.NewOwnedCounts)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry { RewardType = 5, RewardId = cardId, RewardNum = postCount });
|
||||
}
|
||||
|
||||
return new CardDestructResponse { RewardList = rewardList };
|
||||
}
|
||||
|
||||
private static string ErrorKey(DestructError error) => error switch
|
||||
{
|
||||
DestructError.UnknownCard => "unknown_card",
|
||||
DestructError.NotDestructible => "not_destructible",
|
||||
DestructError.CardProtected => "card_protected",
|
||||
DestructError.InsufficientCards => "insufficient_cards",
|
||||
_ => "malformed_request",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the inner JSON of <c>card_id_number_array</c>. Values are
|
||||
/// <c>"<num_to_destruct>,<client_possession_snapshot>"</c> — both strings.
|
||||
/// Returns false (and sets <paramref name="errorKey"/>) on any structural problem.
|
||||
/// </summary>
|
||||
private static bool TryParseDestructDict(
|
||||
string raw,
|
||||
out Dictionary<long, int> destructCounts,
|
||||
out Dictionary<long, int> clientSnapshots,
|
||||
out string errorKey)
|
||||
{
|
||||
destructCounts = new();
|
||||
clientSnapshots = new();
|
||||
errorKey = "malformed_request";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return false;
|
||||
|
||||
JsonDocument? doc;
|
||||
try { doc = JsonDocument.Parse(raw); }
|
||||
catch (JsonException) { return false; }
|
||||
|
||||
using (doc)
|
||||
{
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return false;
|
||||
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!long.TryParse(prop.Name, out long cardId))
|
||||
return false;
|
||||
if (prop.Value.ValueKind != JsonValueKind.String)
|
||||
return false;
|
||||
|
||||
var pair = prop.Value.GetString()!.Split(',');
|
||||
if (pair.Length != 2)
|
||||
return false;
|
||||
if (!int.TryParse(pair[0], out int num) || num <= 0)
|
||||
return false;
|
||||
if (!int.TryParse(pair[1], out int snapshot) || snapshot < 0)
|
||||
return false;
|
||||
|
||||
destructCounts[cardId] = num;
|
||||
clientSnapshots[cardId] = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Card;
|
||||
|
||||
/// <summary>
|
||||
/// POST /card/destruct. The single payload field is a JSON-encoded STRING (double-encoded
|
||||
/// — see docs/api-spec/endpoints/post-login/card-destruct.md). The inner object maps
|
||||
/// cardId → "<num_to_destruct>,<client_possession_snapshot>". Both inner values
|
||||
/// are strings. This DTO keeps it as a single string; parsing happens in CardController.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class CardDestructRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("card_id_number_array")]
|
||||
[Key("card_id_number_array")]
|
||||
public string CardIdNumberArray { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Card;
|
||||
|
||||
/// <summary>
|
||||
/// /card/destruct response data. reward_list entries are POST-STATE TOTALS (the client's
|
||||
/// PlayerStaticData.UpdateHaveUserGoodsNumByJsonData does direct assignment) — one
|
||||
/// RewardType=1 RedEther entry plus one RewardType=5 Card entry per destructed cardId.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class CardDestructResponse
|
||||
{
|
||||
[JsonPropertyName("reward_list")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
[Key("reward_list")]
|
||||
public List<RewardListEntry> RewardList { get; set; } = new();
|
||||
}
|
||||
@@ -59,6 +59,7 @@ public class Program
|
||||
});
|
||||
builder.Services.AddTransient<IViewerRepository, ViewerRepository>();
|
||||
builder.Services.AddTransient<ICardRepository, CardRepository>();
|
||||
builder.Services.AddTransient<ICardInventoryRepository, CardInventoryRepository>();
|
||||
builder.Services.AddTransient<ICollectionRepository, CollectionRepository>();
|
||||
builder.Services.AddTransient<IGlobalsRepository, GlobalsRepository>();
|
||||
builder.Services.AddTransient<IDeckRepository, DeckRepository>();
|
||||
|
||||
195
SVSim.UnitTests/Controllers/CardControllerTests.cs
Normal file
195
SVSim.UnitTests/Controllers/CardControllerTests.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for POST /card/destruct. Exercises the controller's double-decode of
|
||||
/// the JSON-string-in-JSON payload and the reward_list post-state-totals shape. The
|
||||
/// validate/mutate logic itself is covered by <c>CardInventoryRepositoryTests</c>; the tests
|
||||
/// here just confirm the wire contract.
|
||||
/// </summary>
|
||||
public class CardControllerTests
|
||||
{
|
||||
private static StringContent DestructBody(string innerJson) =>
|
||||
new(
|
||||
$$"""{"card_id_number_array":{{JsonSerializer.Serialize(innerJson)}},"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""",
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_happy_path_returns_redether_and_card_post_totals()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
// Inner JSON: cardId -> "<num>,<client_snapshot>". The snapshot is informational only.
|
||||
var response = await client.PostAsync("/card/destruct",
|
||||
DestructBody("{\"10001001\":\"2,5\"}"));
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
// The ShadowverseTranslationMiddleware only fires for UnityPlayer UA requests; test
|
||||
// clients send plain HTTP so the controller's JSON is returned unwrapped.
|
||||
var rewardList = JsonDocument.Parse(body).RootElement
|
||||
.GetProperty("reward_list");
|
||||
|
||||
// Two entries — one RedEther (type 1), one Card (type 5).
|
||||
var entries = rewardList.EnumerateArray()
|
||||
.Select(e => (Type: e.GetProperty("reward_type").GetInt32(),
|
||||
Id: e.GetProperty("reward_id").GetInt64(),
|
||||
Num: e.GetProperty("reward_num").GetInt32()))
|
||||
.ToList();
|
||||
|
||||
Assert.That(entries, Has.Member((Type: 1, Id: 0L, Num: 100)),
|
||||
"RedEther post-state total = 2 * 50 = 100");
|
||||
Assert.That(entries, Has.Member((Type: 5, Id: 10001001L, Num: 3)),
|
||||
"Card post-state owned count = 5 - 2 = 3");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_without_auth_header_returns_401()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient(); // no auth header
|
||||
|
||||
var response = await client.PostAsync("/card/destruct",
|
||||
DestructBody("{\"10001001\":\"1,1\"}"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[TestCase("", Description = "empty string")]
|
||||
[TestCase("not json", Description = "non-JSON garbage")]
|
||||
[TestCase("{\"10001001\":\"1\"}", Description = "value missing snapshot")]
|
||||
[TestCase("{\"10001001\":\"0,5\"}", Description = "num=0 not allowed")]
|
||||
[TestCase("{\"10001001\":\"-1,5\"}", Description = "negative num")]
|
||||
[TestCase("{\"abc\":\"1,5\"}", Description = "non-numeric cardId")]
|
||||
[TestCase("{\"10001001\":5}", Description = "value not a string")]
|
||||
[TestCase("[]", Description = "root must be object, not array")]
|
||||
public async Task Destruct_with_malformed_inner_json_returns_400(string innerJson)
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/destruct", DestructBody(innerJson));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
|
||||
Assert.That(body, Does.Contain("malformed_request"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_with_empty_inner_object_returns_400()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/destruct", DestructBody("{}"));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
|
||||
Assert.That(body, Does.Contain("malformed_request"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_unknown_card_returns_400_unknown_card()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/destruct",
|
||||
DestructBody("{\"99999999\":\"1,0\"}"));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
|
||||
Assert.That(body, Does.Contain("unknown_card"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_not_destructible_returns_400_not_destructible()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 0, craftCost: 0);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/destruct",
|
||||
DestructBody("{\"10001001\":\"1,3\"}"));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
|
||||
Assert.That(body, Does.Contain("not_destructible"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_protected_returns_400_card_protected()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50, isProtected: true);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/destruct",
|
||||
DestructBody("{\"10001001\":\"1,3\"}"));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
|
||||
Assert.That(body, Does.Contain("card_protected"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_insufficient_cards_returns_400_insufficient_cards()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, dustReward: 50);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/destruct",
|
||||
DestructBody("{\"10001001\":\"3,2\"}"));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
|
||||
Assert.That(body, Does.Contain("insufficient_cards"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_proceeds_when_client_possession_snapshot_disagrees_with_server()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// Server has 3 owned; client thinks it has 5 (stale snapshot).
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
// Inner JSON: destruct 1, client snapshot=5 (disagrees with server count=3).
|
||||
// Spec: snapshot mismatch is warn-log only, never blocks the request.
|
||||
var response = await client.PostAsync("/card/destruct",
|
||||
DestructBody("{\"10001001\":\"1,5\"}"));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
var entries = JsonDocument.Parse(body).RootElement
|
||||
.GetProperty("reward_list")
|
||||
.EnumerateArray()
|
||||
.Select(e => (Type: e.GetProperty("reward_type").GetInt32(),
|
||||
Id: e.GetProperty("reward_id").GetInt64(),
|
||||
Num: e.GetProperty("reward_num").GetInt32()))
|
||||
.ToList();
|
||||
|
||||
// Vials awarded based on actual server count, not client snapshot.
|
||||
Assert.That(entries, Has.Member((Type: 1, Id: 0L, Num: 50)));
|
||||
Assert.That(entries, Has.Member((Type: 5, Id: 10001001L, Num: 2)));
|
||||
}
|
||||
}
|
||||
@@ -216,6 +216,89 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds an OwnedCardEntry for the viewer. Uses an existing card from the minimal test set
|
||||
/// when <paramref name="cardId"/> matches one (10001001/10001002/10001003); otherwise the
|
||||
/// caller must have inserted the card row themselves. <paramref name="dustReward"/> is written
|
||||
/// onto the card's CollectionInfo so destruct tests can compute expected vials.
|
||||
///
|
||||
/// NOTE: This helper ALWAYS resets the viewer's RedEther to 0 (so destruct tests can assert
|
||||
/// literal post-state totals). Callers that need a non-zero balance should re-assign after seeding.
|
||||
/// </summary>
|
||||
public async Task SeedOwnedCardAsync(
|
||||
long viewerId,
|
||||
long cardId,
|
||||
int count,
|
||||
int dustReward = 50,
|
||||
int craftCost = 200,
|
||||
bool isProtected = false)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
var card = await db.Cards.FirstOrDefaultAsync(c => c.Id == cardId);
|
||||
if (card is null)
|
||||
{
|
||||
card = new ShadowverseCardEntry
|
||||
{
|
||||
Id = cardId,
|
||||
Name = $"SeededCard{cardId}",
|
||||
Rarity = Rarity.Bronze,
|
||||
CollectionInfo = new CardCollectionInfo { CraftCost = craftCost, DustReward = dustReward },
|
||||
};
|
||||
db.Cards.Add(card);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
else if (card.CollectionInfo is null || card.CollectionInfo.DustReward != dustReward || card.CollectionInfo.CraftCost != craftCost)
|
||||
{
|
||||
card.CollectionInfo = new CardCollectionInfo { CraftCost = craftCost, DustReward = dustReward };
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
|
||||
var owned = viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId);
|
||||
if (owned is null)
|
||||
{
|
||||
viewer.Cards.Add(new OwnedCardEntry { Card = card, Count = count, IsProtected = isProtected });
|
||||
}
|
||||
else
|
||||
{
|
||||
owned.Count = count;
|
||||
owned.IsProtected = isProtected;
|
||||
}
|
||||
viewer.Currency.RedEther = 0; // Reset RedEther so destruct tests can assert literal post-state totals
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts <paramref name="count"/> copies of <paramref name="cardId"/> into the viewer's deck
|
||||
/// in the given format + slot. Tests use this to set up deck-strip scenarios for /card/destruct.
|
||||
/// The card must already exist (typically via SeedOwnedCardAsync, which inserts the card row).
|
||||
/// </summary>
|
||||
public async Task AddCardToDeckAsync(long viewerId, Format format, int deckNumber, long cardId, int count)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
var viewer = await db.Viewers
|
||||
.Include(v => v.Decks).ThenInclude(d => d.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
var deck = viewer.Decks.First(d => d.Format == format && d.Number == deckNumber);
|
||||
var card = await db.Cards.FirstAsync(c => c.Id == cardId);
|
||||
|
||||
var existing = deck.Cards.FirstOrDefault(c => c.Card.Id == cardId);
|
||||
if (existing is null)
|
||||
{
|
||||
deck.Cards.Add(new DeckCard { Card = card, Count = count });
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.Count = count;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
284
SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs
Normal file
284
SVSim.UnitTests/Repositories/CardInventoryRepositoryTests.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Repositories.Card;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for <c>CardInventoryRepository.DestructCards</c>. Exercises the validate→mutate
|
||||
/// loop directly so tests don't need to round-trip through HTTP; the controller-level wire
|
||||
/// behavior is covered separately in <c>CardControllerTests</c>.
|
||||
/// </summary>
|
||||
public class CardInventoryRepositoryTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Destruct_single_card_decrements_count_and_awards_vials()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.True, outcome.Error?.ToString());
|
||||
Assert.That(outcome.Result!.NewRedEtherTotal, Is.EqualTo(50UL));
|
||||
Assert.That(outcome.Result!.NewOwnedCounts[10001001L], Is.EqualTo(4));
|
||||
|
||||
// Verify persisted state matches what was returned
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.Currency.RedEther, Is.EqualTo(50UL));
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(4));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_batch_decrements_each_card_and_sums_vials()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50);
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001002L, count: 2, dustReward: 200);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int>
|
||||
{
|
||||
{ 10001001L, 2 },
|
||||
{ 10001002L, 1 },
|
||||
});
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.True);
|
||||
// 2 * 50 + 1 * 200 = 300
|
||||
Assert.That(outcome.Result!.NewRedEtherTotal, Is.EqualTo(300UL));
|
||||
Assert.That(outcome.Result!.NewOwnedCounts[10001001L], Is.EqualTo(1));
|
||||
Assert.That(outcome.Result!.NewOwnedCounts[10001002L], Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_leaves_zero_count_row_after_destructing_last_copy()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// We just verify the OwnedCardEntry row survives a destruct-to-zero, so future operations (re-protect, re-craft) can attach to it.
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 1, dustReward: 50);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
|
||||
Assert.That(outcome.IsSuccess, Is.True);
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
|
||||
var owned = viewer.Cards.FirstOrDefault(c => c.Card.Id == 10001001L);
|
||||
Assert.That(owned, Is.Not.Null, "OwnedCardEntry row should remain after destruct-to-zero");
|
||||
Assert.That(owned!.Count, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_rejects_unknown_card_without_mutation()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 99_999_999L, 1 } });
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.False);
|
||||
Assert.That(outcome.Error, Is.EqualTo(DestructError.UnknownCard));
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.Currency.RedEther, Is.EqualTo(0UL));
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_rejects_insufficient_count_without_mutation()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, dustReward: 50);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 3 } });
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.False);
|
||||
Assert.That(outcome.Error, Is.EqualTo(DestructError.InsufficientCards));
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.Currency.RedEther, Is.EqualTo(0UL));
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_rejects_protected_card_without_mutation()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50, isProtected: true);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.False);
|
||||
Assert.That(outcome.Error, Is.EqualTo(DestructError.CardProtected));
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.Currency.RedEther, Is.EqualTo(0UL));
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_rejects_non_destructible_card_without_mutation()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// dustReward=0 marks a card as IsNotCraftDestruct (e.g. tokens, basics)
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 0, craftCost: 0);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.False);
|
||||
Assert.That(outcome.Error, Is.EqualTo(DestructError.NotDestructible));
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_validates_full_batch_before_mutating()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50);
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001002L, count: 3, dustReward: 200, isProtected: true);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int>
|
||||
{
|
||||
{ 10001001L, 2 }, // would-be valid
|
||||
{ 10001002L, 1 }, // protected — fails validation
|
||||
});
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.False);
|
||||
Assert.That(outcome.Error, Is.EqualTo(DestructError.CardProtected));
|
||||
|
||||
// Critical: the valid card must be untouched. Proves validation runs against the full
|
||||
// batch before any inventory write.
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.Include(v => v.Cards).ThenInclude(c => c.Card).FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.Currency.RedEther, Is.EqualTo(0UL), "no vials awarded when batch fails");
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(5));
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001002L).Count, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_strips_excess_copies_from_a_deck()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50);
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1);
|
||||
await factory.AddCardToDeckAsync(viewerId, Format.Rotation, deckNumber: 1, cardId: 10001001L, count: 3);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
// Destruct 2 — owned drops from 3 to 1 — deck must lose 2 of the 3 copies it had.
|
||||
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 2 } });
|
||||
Assert.That(outcome.IsSuccess, Is.True);
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var deck = await db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.Decks)
|
||||
.Include(d => d.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(d => d.Format == Format.Rotation && d.Number == 1);
|
||||
|
||||
var deckCard = deck.Cards.First(c => c.Card.Id == 10001001L);
|
||||
Assert.That(deckCard.Count, Is.EqualTo(1), "deck should now hold only 1 copy of the card");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_strips_excess_across_multiple_decks()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, dustReward: 50);
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1);
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 2);
|
||||
await factory.AddCardToDeckAsync(viewerId, Format.Rotation, 1, cardId: 10001001L, count: 3);
|
||||
await factory.AddCardToDeckAsync(viewerId, Format.Rotation, 2, cardId: 10001001L, count: 3);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
// Destruct 1 — owned drops from 3 to 2 — each deck must lose 1 copy.
|
||||
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
|
||||
Assert.That(outcome.IsSuccess, Is.True);
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var decks = await db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.Decks)
|
||||
.Include(d => d.Cards).ThenInclude(c => c.Card)
|
||||
.Where(d => d.Format == Format.Rotation)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var deck in decks)
|
||||
{
|
||||
Assert.That(deck.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(2),
|
||||
$"deck {deck.Number} should now hold 2 copies");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Destruct_leaves_deck_untouched_when_owned_still_covers()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 5, dustReward: 50);
|
||||
await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1);
|
||||
await factory.AddCardToDeckAsync(viewerId, Format.Rotation, 1, cardId: 10001001L, count: 2);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
// Destruct 2 — owned drops from 5 to 3 — deck still uses only 2, no strip needed.
|
||||
var outcome = await repo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 2 } });
|
||||
Assert.That(outcome.IsSuccess, Is.True);
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var deck = await db.Viewers
|
||||
.Where(v => v.Id == viewerId)
|
||||
.SelectMany(v => v.Decks)
|
||||
.Include(d => d.Cards).ThenInclude(c => c.Card)
|
||||
.FirstAsync(d => d.Format == Format.Rotation && d.Number == 1);
|
||||
|
||||
Assert.That(deck.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(2),
|
||||
"deck untouched because owned (3) still covers usage (2)");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user