Compare commits
14 Commits
39b38e3c80
...
71b0c66631
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b0c66631 | ||
|
|
433408dddb | ||
|
|
1ee31c1689 | ||
|
|
ecf819ca61 | ||
|
|
b64123a9aa | ||
|
|
bac10b91ff | ||
|
|
9b5fe6dd83 | ||
|
|
442399b268 | ||
|
|
1eb34c7830 | ||
|
|
e1becca659 | ||
|
|
dd80f5187a | ||
|
|
a851e6aa20 | ||
|
|
0867c5bd05 | ||
|
|
6e106d646b |
@@ -8,6 +8,13 @@ namespace SVSim.Database.Models;
|
||||
[Owned]
|
||||
public class OwnedCardEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Game rule: a viewer may own at most this many copies of a single card. Mirrors the
|
||||
/// client constant <c>CardMake.CAN_CREATE_MAX = 3</c>. Used by <c>/card/create</c> to
|
||||
/// reject batches that would exceed the cap.
|
||||
/// </summary>
|
||||
public const int MaxCopies = 3;
|
||||
|
||||
public ShadowverseCardEntry Card { get; set; } = new ShadowverseCardEntry();
|
||||
public int Count { get; set; }
|
||||
public bool IsProtected { get; set; }
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.Database.Repositories.Card;
|
||||
|
||||
public class CardInventoryRepository : ICardInventoryRepository
|
||||
{
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly RewardGrantService _grants;
|
||||
|
||||
public CardInventoryRepository(SVSimDbContext db)
|
||||
public CardInventoryRepository(SVSimDbContext db, RewardGrantService grants)
|
||||
{
|
||||
_db = db;
|
||||
_grants = grants;
|
||||
}
|
||||
|
||||
public async Task<DestructOutcome> DestructCards(long viewerId, IReadOnlyDictionary<long, int> destructCounts)
|
||||
@@ -45,9 +49,6 @@ public class CardInventoryRepository : ICardInventoryRepository
|
||||
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;
|
||||
@@ -69,7 +70,6 @@ public class CardInventoryRepository : ICardInventoryRepository
|
||||
// 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))
|
||||
@@ -88,4 +88,87 @@ public class CardInventoryRepository : ICardInventoryRepository
|
||||
|
||||
return DestructOutcome.Ok(new DestructResult(viewer.Currency.RedEther, postCounts));
|
||||
}
|
||||
|
||||
public async Task<CreateOutcome> CreateCards(long viewerId, IReadOnlyDictionary<long, int> createCounts)
|
||||
{
|
||||
// Load viewer with owned cards + their catalog rows (for CraftCost). Decks aren't needed —
|
||||
// create never modifies them. AsSplitQuery for symmetry with destruct and to avoid any
|
||||
// future cartesian explosion if more Includes are added.
|
||||
var viewer = await _db.Viewers
|
||||
.Include(v => v.Cards).ThenInclude(c => c.Card).ThenInclude(c => c.CollectionInfo)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
|
||||
var ownedByCardId = viewer.Cards.ToDictionary(c => c.Card.Id);
|
||||
|
||||
// For unknown_card validation we need the catalog rows for ids the viewer DOESN'T own yet.
|
||||
var requestedIds = createCounts.Keys.ToList();
|
||||
var catalogRows = await _db.Cards
|
||||
.Include(c => c.CollectionInfo)
|
||||
.Where(c => requestedIds.Contains(c.Id))
|
||||
.ToDictionaryAsync(c => c.Id);
|
||||
|
||||
ulong totalCost = 0;
|
||||
foreach (var (cardId, num) in createCounts)
|
||||
{
|
||||
// unknown_card: must be in the global catalog
|
||||
if (!catalogRows.TryGetValue(cardId, out var catalogCard))
|
||||
return CreateOutcome.Fail(CreateError.UnknownCard);
|
||||
|
||||
// not_craftable: client's IsNotCraftDestruct check — CraftCost ≤ 0 means uncraftable
|
||||
if (catalogCard.CollectionInfo is null || catalogCard.CollectionInfo.CraftCost <= 0)
|
||||
return CreateOutcome.Fail(CreateError.NotCraftable);
|
||||
|
||||
// would_exceed_max_copies: viewer already owns N → can craft at most MaxCopies - N
|
||||
int existingCount = ownedByCardId.TryGetValue(cardId, out var owned) && owned.Card.Id != 0
|
||||
? owned.Count
|
||||
: 0;
|
||||
if (existingCount + num > OwnedCardEntry.MaxCopies)
|
||||
return CreateOutcome.Fail(CreateError.WouldExceedMaxCopies);
|
||||
|
||||
totalCost += (ulong)catalogCard.CollectionInfo.CraftCost * (ulong)num;
|
||||
}
|
||||
|
||||
// insufficient_vials checked after summing the full batch — all-or-nothing
|
||||
if (viewer.Currency.RedEther < totalCost)
|
||||
return CreateOutcome.Fail(CreateError.InsufficientVials);
|
||||
|
||||
using var tx = await _db.Database.BeginTransactionAsync();
|
||||
|
||||
// Debit RedEther directly. ApplyAsync only credits — debit-pair operations live in this
|
||||
// repo, symmetric with destruct.
|
||||
viewer.Currency.RedEther -= totalCost;
|
||||
|
||||
// Per-card grant via RewardGrantService — single source of truth for Card-typed grants,
|
||||
// and fires the CardCosmeticReward cascade for first-time owners. See
|
||||
// feedback_reward_grant_service memory.
|
||||
var allGrants = new List<GrantedReward>();
|
||||
foreach (var (cardId, num) in createCounts)
|
||||
{
|
||||
var granted = await _grants.ApplyAsync(viewer, UserGoodsType.Card, cardId, num);
|
||||
allGrants.AddRange(granted);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return CreateOutcome.Ok(new CreateResult(viewer.Currency.RedEther, allGrants));
|
||||
}
|
||||
|
||||
public async Task<ProtectOutcome> SetProtected(long viewerId, long cardId, bool isProtected)
|
||||
{
|
||||
// Lighter load than create/destruct: only need viewer's owned-cards collection. No decks,
|
||||
// no currency, no CollectionInfo.
|
||||
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 || owned.Card.Id == 0)
|
||||
return ProtectOutcome.Fail(ProtectError.UnknownCard);
|
||||
|
||||
owned.IsProtected = isProtected;
|
||||
await _db.SaveChangesAsync();
|
||||
return ProtectOutcome.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,20 @@ public interface ICardInventoryRepository
|
||||
/// <see cref="DestructError"/> when validation fails. On error nothing is written.
|
||||
/// </returns>
|
||||
Task<DestructOutcome> DestructCards(long viewerId, IReadOnlyDictionary<long, int> destructCounts);
|
||||
|
||||
/// <summary>
|
||||
/// Validate-then-mutate craft of cards from RedEther. Atomic in a transaction; on validation
|
||||
/// failure nothing is written. Routes Card grants through <see cref="Services.RewardGrantService.ApplyAsync"/>
|
||||
/// so the CardCosmeticReward cascade fires for first-time owners.
|
||||
/// </summary>
|
||||
/// <param name="createCounts">cardId → num_to_create. Empty dict is rejected by the caller.</param>
|
||||
Task<CreateOutcome> CreateCards(long viewerId, IReadOnlyDictionary<long, int> createCounts);
|
||||
|
||||
/// <summary>
|
||||
/// Toggle the <see cref="OwnedCardEntry.IsProtected"/> flag for a single card. Idempotent.
|
||||
/// Accepts cards with Count=0 (preserves the destruct→re-craft round-trip invariant).
|
||||
/// </summary>
|
||||
Task<ProtectOutcome> SetProtected(long viewerId, long cardId, bool isProtected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -42,3 +56,39 @@ public enum DestructError
|
||||
CardProtected,
|
||||
InsufficientCards,
|
||||
}
|
||||
|
||||
public sealed record CreateOutcome(CreateResult? Result, CreateError? Error)
|
||||
{
|
||||
public bool IsSuccess => Result is not null;
|
||||
|
||||
public static CreateOutcome Ok(CreateResult r) => new(r, null);
|
||||
public static CreateOutcome Fail(CreateError e) => new(null, e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a successful create. <see cref="Grants"/> is the flattened
|
||||
/// <see cref="Services.GrantedReward"/> list returned by <see cref="Services.RewardGrantService.ApplyAsync"/>
|
||||
/// — one Card entry per crafted cardId plus any cosmetic-cascade entries.
|
||||
/// </summary>
|
||||
public sealed record CreateResult(
|
||||
ulong NewRedEtherTotal,
|
||||
IReadOnlyList<Services.GrantedReward> Grants);
|
||||
|
||||
public enum CreateError
|
||||
{
|
||||
UnknownCard,
|
||||
NotCraftable,
|
||||
WouldExceedMaxCopies,
|
||||
InsufficientVials,
|
||||
}
|
||||
|
||||
public sealed record ProtectOutcome(bool IsSuccess, ProtectError? Error)
|
||||
{
|
||||
public static ProtectOutcome Ok() => new(true, null);
|
||||
public static ProtectOutcome Fail(ProtectError e) => new(false, e);
|
||||
}
|
||||
|
||||
public enum ProtectError
|
||||
{
|
||||
UnknownCard,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Repositories.Card;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Card;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Card;
|
||||
@@ -8,8 +10,8 @@ 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.
|
||||
/// /card/* — viewer card-inventory mutations. Ships /card/destruct, /card/create, /card/protect.
|
||||
/// /card/create_foil_card is reserved for a follow-up slice.
|
||||
/// </summary>
|
||||
[Route("card")]
|
||||
public class CardController : SVSimController
|
||||
@@ -28,7 +30,7 @@ public class CardController : SVSimController
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
if (!TryParseDestructDict(request.CardIdNumberArray, out var destructCounts, out var snapshots, out var parseError))
|
||||
if (!TryParseCardCountDict(request.CardIdNumberArray, out var destructCounts, out var snapshots, out var parseError))
|
||||
return BadRequest(new { error = parseError });
|
||||
|
||||
if (destructCounts.Count == 0)
|
||||
@@ -69,6 +71,85 @@ public class CardController : SVSimController
|
||||
return new CardDestructResponse { RewardList = rewardList };
|
||||
}
|
||||
|
||||
[HttpPost("create")]
|
||||
public async Task<ActionResult<CardCreateResponse>> Create(CardCreateRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
if (!TryParseCardCountDict(request.CardIdNumberArray, out var createCounts, out var snapshots, out var parseError))
|
||||
return BadRequest(new { error = parseError });
|
||||
|
||||
if (createCounts.Count == 0)
|
||||
return BadRequest(new { error = "malformed_request" });
|
||||
|
||||
var outcome = await _inventory.CreateCards(viewerId, createCounts);
|
||||
if (!outcome.IsSuccess)
|
||||
return BadRequest(new { error = CreateErrorKey(outcome.Error!.Value) });
|
||||
|
||||
// Snapshot mismatch is warn-log only. pre-state = post-state - num.
|
||||
var grants = outcome.Result!.Grants;
|
||||
foreach (var (cardId, snapshot) in snapshots)
|
||||
{
|
||||
int requestedNum = createCounts[cardId];
|
||||
int postCount = grants.FirstOrDefault(g => g.RewardType == (int)UserGoodsType.Card && g.RewardId == cardId)?.RewardNum ?? 0;
|
||||
int reconstructedPre = postCount - requestedNum;
|
||||
if (reconstructedPre != snapshot)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"Create 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. Mirrors destruct's clamp.
|
||||
int redEtherWire = outcome.Result!.NewRedEtherTotal > int.MaxValue
|
||||
? int.MaxValue
|
||||
: (int)outcome.Result!.NewRedEtherTotal;
|
||||
var rewardList = new List<RewardListEntry>
|
||||
{
|
||||
new() { RewardType = (int)UserGoodsType.RedEther, RewardId = 0, RewardNum = redEtherWire },
|
||||
};
|
||||
foreach (var grant in grants)
|
||||
{
|
||||
rewardList.Add(new RewardListEntry
|
||||
{
|
||||
RewardType = grant.RewardType,
|
||||
RewardId = grant.RewardId,
|
||||
RewardNum = grant.RewardNum,
|
||||
});
|
||||
}
|
||||
|
||||
return new CardCreateResponse { RewardList = rewardList };
|
||||
}
|
||||
|
||||
private static string CreateErrorKey(CreateError error) => error switch
|
||||
{
|
||||
CreateError.UnknownCard => "unknown_card",
|
||||
CreateError.NotCraftable => "not_craftable",
|
||||
CreateError.WouldExceedMaxCopies => "would_exceed_max_copies",
|
||||
CreateError.InsufficientVials => "insufficient_vials",
|
||||
_ => "malformed_request",
|
||||
};
|
||||
|
||||
[HttpPost("protect")]
|
||||
public async Task<ActionResult<CardProtectResponse>> Protect(CardProtectRequest request)
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
var outcome = await _inventory.SetProtected(viewerId, request.CardId, request.IsProtected);
|
||||
if (!outcome.IsSuccess)
|
||||
{
|
||||
return outcome.Error switch
|
||||
{
|
||||
ProtectError.UnknownCard => BadRequest(new { error = "unknown_card" }),
|
||||
_ => BadRequest(new { error = "malformed_request" }),
|
||||
};
|
||||
}
|
||||
|
||||
return new CardProtectResponse();
|
||||
}
|
||||
|
||||
private static string ErrorKey(DestructError error) => error switch
|
||||
{
|
||||
DestructError.UnknownCard => "unknown_card",
|
||||
@@ -83,13 +164,13 @@ public class CardController : SVSimController
|
||||
/// <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(
|
||||
private static bool TryParseCardCountDict(
|
||||
string raw,
|
||||
out Dictionary<long, int> destructCounts,
|
||||
out Dictionary<long, int> counts,
|
||||
out Dictionary<long, int> clientSnapshots,
|
||||
out string errorKey)
|
||||
{
|
||||
destructCounts = new();
|
||||
counts = new();
|
||||
clientSnapshots = new();
|
||||
errorKey = "malformed_request";
|
||||
|
||||
@@ -120,7 +201,7 @@ public class CardController : SVSimController
|
||||
if (!int.TryParse(pair[1], out int snapshot) || snapshot < 0)
|
||||
return false;
|
||||
|
||||
destructCounts[cardId] = num;
|
||||
counts[cardId] = num;
|
||||
clientSnapshots[cardId] = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Card;
|
||||
|
||||
/// <summary>
|
||||
/// POST /card/create. The single payload field is a JSON-encoded STRING (double-encoded —
|
||||
/// see docs/api-spec/endpoints/post-login/card-create.md). Inner object maps
|
||||
/// cardId → "<num_to_create>,<client_possession_snapshot>". Both inner values
|
||||
/// are strings. Same wire format as /card/destruct; CardController parses both with the
|
||||
/// shared TryParseCardCountDict helper.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class CardCreateRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("card_id_number_array")]
|
||||
[Key("card_id_number_array")]
|
||||
public string CardIdNumberArray { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using MessagePack;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Card;
|
||||
|
||||
/// <summary>
|
||||
/// POST /card/protect. Toggles the protected-card flag for a single card. The client (see
|
||||
/// Wizard/CardProtectTask.cs) sends is_protected as a real boolean — the 0|1 int inconsistency
|
||||
/// noted in the spec lives on the /load/index response side and is out of scope here.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class CardProtectRequest : BaseRequest
|
||||
{
|
||||
[JsonPropertyName("card_id")]
|
||||
[Key("card_id")]
|
||||
public long CardId { get; set; }
|
||||
|
||||
[JsonPropertyName("is_protected")]
|
||||
[Key("is_protected")]
|
||||
public bool IsProtected { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using MessagePack;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Card;
|
||||
|
||||
/// <summary>
|
||||
/// /card/create 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 crafted cardId,
|
||||
/// plus cascade entries for any CardCosmeticReward rows attached to the crafted cards.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class CardCreateResponse
|
||||
{
|
||||
[JsonPropertyName("reward_list")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
[Key("reward_list")]
|
||||
public List<RewardListEntry> RewardList { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using MessagePack;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Card;
|
||||
|
||||
/// <summary>
|
||||
/// /card/protect response data — empty. The client just needs result_code=1 in the envelope's
|
||||
/// data_headers; it mutates its own FavoriteCardList from its request-side knowledge.
|
||||
/// </summary>
|
||||
[MessagePackObject]
|
||||
public class CardProtectResponse
|
||||
{
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
@@ -19,6 +22,12 @@ public class CardControllerTests
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
private static StringContent CreateBody(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()
|
||||
{
|
||||
@@ -192,4 +201,302 @@ public class CardControllerTests
|
||||
Assert.That(entries, Has.Member((Type: 1, Id: 0L, Num: 50)));
|
||||
Assert.That(entries, Has.Member((Type: 5, Id: 10001001L, Num: 2)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_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: 0, craftCost: 200);
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/create",
|
||||
CreateBody("{\"10001001\":\"2,0\"}"));
|
||||
|
||||
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();
|
||||
|
||||
// 1000 - (2 * 200) = 600
|
||||
Assert.That(entries, Has.Member((Type: 1, Id: 0L, Num: 600)),
|
||||
"RedEther post-state total = 1000 - 400 = 600");
|
||||
Assert.That(entries, Has.Member((Type: 5, Id: 10001001L, Num: 2)),
|
||||
"Card post-state owned count = 0 + 2 = 2");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_without_auth_header_returns_401()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync("/card/create",
|
||||
CreateBody("{\"10001001\":\"1,0\"}"));
|
||||
|
||||
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,0\"}", Description = "num=0 not allowed")]
|
||||
[TestCase("{\"10001001\":\"-1,0\"}", Description = "negative num")]
|
||||
[TestCase("{\"abc\":\"1,0\"}", Description = "non-numeric cardId")]
|
||||
[TestCase("{\"10001001\":5}", Description = "value not a string")]
|
||||
[TestCase("[]", Description = "root must be object, not array")]
|
||||
public async Task Create_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/create", CreateBody(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 Create_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/create", CreateBody("{}"));
|
||||
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 Create_unknown_card_returns_400_unknown_card()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/create",
|
||||
CreateBody("{\"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 Create_not_craftable_returns_400_not_craftable()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 0, dustReward: 0);
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/create",
|
||||
CreateBody("{\"10001001\":\"1,0\"}"));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
|
||||
Assert.That(body, Does.Contain("not_craftable"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_would_exceed_max_copies_returns_400()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 3, craftCost: 200);
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/create",
|
||||
CreateBody("{\"10001001\":\"1,3\"}"));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
|
||||
Assert.That(body, Does.Contain("would_exceed_max_copies"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_insufficient_vials_returns_400()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 200);
|
||||
await factory.SetRedEtherAsync(viewerId, 100UL); // half of needed
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/create",
|
||||
CreateBody("{\"10001001\":\"1,0\"}"));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest), body);
|
||||
Assert.That(body, Does.Contain("insufficient_vials"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_proceeds_when_client_possession_snapshot_disagrees_with_server()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// Server has 0 owned; client thinks it has 5 (stale snapshot).
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 200);
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
// Inner JSON: create 1, client snapshot=5 (disagrees with server count=0).
|
||||
// Spec: snapshot mismatch is warn-log only, never blocks the request.
|
||||
var response = await client.PostAsync("/card/create",
|
||||
CreateBody("{\"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();
|
||||
|
||||
// RedEther and card count based on actual server state, not client snapshot.
|
||||
Assert.That(entries, Has.Member((Type: 1, Id: 0L, Num: 800)),
|
||||
"RedEther post-state total = 1000 - 200 = 800");
|
||||
Assert.That(entries, Has.Member((Type: 5, Id: 10001001L, Num: 1)),
|
||||
"Card post-state owned count = 0 + 1 = 1");
|
||||
}
|
||||
|
||||
private static StringContent ProtectBody(long cardId, bool isProtected) =>
|
||||
new(
|
||||
$$"""{"card_id":{{cardId}},"is_protected":{{(isProtected ? "true" : "false")}},"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""",
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
[Test]
|
||||
public async Task Protect_toggles_flag_for_owned_card()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/protect", ProtectBody(10001001L, isProtected: true));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
|
||||
// Verify persisted flag
|
||||
using var scope = factory.Services.CreateScope();
|
||||
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).IsProtected, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Protect_round_trip_unsets_flag()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, isProtected: true);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/protect", ProtectBody(10001001L, isProtected: false));
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
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).IsProtected, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Protect_without_auth_header_returns_401()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync("/card/protect", ProtectBody(10001001L, isProtected: true));
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Protect_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/protect", ProtectBody(99_999_999L, isProtected: true));
|
||||
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 Protect_returns_empty_data_object()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 1);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var response = await client.PostAsync("/card/protect", ProtectBody(10001001L, isProtected: true));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
// The translation middleware only wraps for UnityPlayer UA; test clients see the raw
|
||||
// controller payload, which for CardProtectResponse is an empty object.
|
||||
Assert.That(body.Trim(), Is.EqualTo("{}"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Protect_then_load_index_emits_is_protected_one()
|
||||
{
|
||||
// Spec: /load/index user_card_list[].is_protected is an int wire value (0 or 1),
|
||||
// not a bool. Protect a card then verify /load/index round-trips the flag correctly.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
// Set the protect flag.
|
||||
var protectResponse = await client.PostAsync("/card/protect",
|
||||
ProtectBody(10001001L, isProtected: true));
|
||||
Assert.That(protectResponse.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await protectResponse.Content.ReadAsStringAsync());
|
||||
|
||||
// Call /load/index and parse user_card_list.
|
||||
const string IndexRequestJson =
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","carrier":"web","card_master_hash":""}""";
|
||||
var loadResponse = await client.PostAsync("/load/index",
|
||||
new StringContent(IndexRequestJson, Encoding.UTF8, "application/json"));
|
||||
var loadBody = await loadResponse.Content.ReadAsStringAsync();
|
||||
Assert.That(loadResponse.StatusCode, Is.EqualTo(HttpStatusCode.OK), loadBody);
|
||||
|
||||
var cardEntry = JsonDocument.Parse(loadBody).RootElement
|
||||
.GetProperty("user_card_list")
|
||||
.EnumerateArray()
|
||||
.FirstOrDefault(e => e.GetProperty("card_id").GetInt64() == 10001001L);
|
||||
|
||||
Assert.That(cardEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined),
|
||||
"Expected card 10001001 in user_card_list");
|
||||
Assert.That(cardEntry.GetProperty("is_protected").GetInt32(), Is.EqualTo(1),
|
||||
"is_protected wire value must be 1 (int) after protect call");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +315,20 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the viewer's RedEther balance to <paramref name="amount"/>. Call this AFTER
|
||||
/// <see cref="SeedOwnedCardAsync"/>, which resets RedEther to 0. Create tests use this
|
||||
/// to give the viewer enough vials to craft.
|
||||
/// </summary>
|
||||
public async Task SetRedEtherAsync(long viewerId, ulong amount)
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
viewer.Currency.RedEther = amount;
|
||||
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.
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Card;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
@@ -281,4 +282,311 @@ public class CardInventoryRepositoryTests
|
||||
Assert.That(deck.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(2),
|
||||
"deck untouched because owned (3) still covers usage (2)");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_single_card_debits_vials_and_grants_copy()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 200);
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.CreateCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.True, outcome.Error?.ToString());
|
||||
Assert.That(outcome.Result!.NewRedEtherTotal, Is.EqualTo(800UL), "1000 - 200 = 800");
|
||||
|
||||
var grants = outcome.Result!.Grants;
|
||||
Assert.That(grants.Any(g => g.RewardType == (int)UserGoodsType.Card
|
||||
&& g.RewardId == 10001001L
|
||||
&& g.RewardNum == 1), Is.True);
|
||||
|
||||
// Verify persisted state
|
||||
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(800UL));
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_batch_charges_sum_and_grants_each_card()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 200);
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001002L, count: 0, craftCost: 800);
|
||||
await factory.SetRedEtherAsync(viewerId, 5_000UL);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.CreateCards(viewerId, new Dictionary<long, int>
|
||||
{
|
||||
{ 10001001L, 2 },
|
||||
{ 10001002L, 1 },
|
||||
});
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.True);
|
||||
// 2 * 200 + 1 * 800 = 1200 → 5000 - 1200 = 3800
|
||||
Assert.That(outcome.Result!.NewRedEtherTotal, Is.EqualTo(3_800UL));
|
||||
|
||||
var grants = outcome.Result!.Grants;
|
||||
Assert.That(grants.Any(g => g.RewardId == 10001001L && g.RewardNum == 2), Is.True);
|
||||
Assert.That(grants.Any(g => g.RewardId == 10001002L && g.RewardNum == 1), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_rejects_unknown_card_without_mutation()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.CreateCards(viewerId, new Dictionary<long, int> { { 99_999_999L, 1 } });
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.False);
|
||||
Assert.That(outcome.Error, Is.EqualTo(CreateError.UnknownCard));
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.Currency.RedEther, Is.EqualTo(1_000UL), "no debit on rejection");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_rejects_not_craftable_card_without_mutation()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// craftCost=0 mirrors IsNotCraftDestruct on basic/token cards
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 0, dustReward: 0);
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.CreateCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.False);
|
||||
Assert.That(outcome.Error, Is.EqualTo(CreateError.NotCraftable));
|
||||
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.Currency.RedEther, Is.EqualTo(1_000UL));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_rejects_when_would_exceed_max_copies()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// Viewer already owns 2 copies — crafting 2 more would push to 4, exceeding MaxCopies=3.
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, craftCost: 200);
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.CreateCards(viewerId, new Dictionary<long, int> { { 10001001L, 2 } });
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.False);
|
||||
Assert.That(outcome.Error, Is.EqualTo(CreateError.WouldExceedMaxCopies));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_at_boundary_2_to_3_succeeds()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, craftCost: 200);
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
// 2 + 1 = 3 = MaxCopies — must succeed
|
||||
var outcome = await repo.CreateCards(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);
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_rejects_insufficient_vials_without_mutation()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 200);
|
||||
await factory.SetRedEtherAsync(viewerId, 199UL); // one less than needed
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.CreateCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.False);
|
||||
Assert.That(outcome.Error, Is.EqualTo(CreateError.InsufficientVials));
|
||||
|
||||
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(199UL), "no debit on rejection");
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(0), "no grant on rejection");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_validates_full_batch_before_mutating()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 0, craftCost: 200);
|
||||
// Second card has count=3 already — adding any would exceed MaxCopies
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001002L, count: 3, craftCost: 200);
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.CreateCards(viewerId, new Dictionary<long, int>
|
||||
{
|
||||
{ 10001001L, 1 }, // would-be valid
|
||||
{ 10001002L, 1 }, // would push to 4 — fails validation
|
||||
});
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.False);
|
||||
Assert.That(outcome.Error, Is.EqualTo(CreateError.WouldExceedMaxCopies));
|
||||
|
||||
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(1_000UL), "no debit when batch fails");
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).Count, Is.EqualTo(0), "valid card untouched");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_first_time_owner_triggers_cosmetic_cascade()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
|
||||
// Set up a card with a cosmetic-cascade row pointing at a Skin the viewer doesn't own.
|
||||
// Use ids outside the seeded 10001001–10001003 range so the cascade can't accidentally
|
||||
// pick up unrelated rows.
|
||||
const long cardId = 999_003_010L;
|
||||
const long skinId = 999_003_011L;
|
||||
ctx.Cards.Add(new ShadowverseCardEntry
|
||||
{
|
||||
Id = cardId, Name = "CreateCascadeCard", Rarity = Rarity.Gold,
|
||||
CollectionInfo = new CardCollectionInfo { CraftCost = 800, DustReward = 200 },
|
||||
});
|
||||
ctx.LeaderSkins.Add(new LeaderSkinEntry { Id = (int)skinId, Name = "CreateCascadeSkin" });
|
||||
ctx.CardCosmeticRewards.Add(new CardCosmeticReward
|
||||
{
|
||||
CardId = cardId, Type = CosmeticType.Skin, CosmeticId = skinId, Quantity = 1,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
// Give the viewer enough RedEther in a separate scope so the helper's reset doesn't fire.
|
||||
await factory.SetRedEtherAsync(viewerId, 1_000UL);
|
||||
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
var outcome = await repo.CreateCards(viewerId, new Dictionary<long, int> { { cardId, 1 } });
|
||||
|
||||
Assert.That(outcome.IsSuccess, Is.True);
|
||||
|
||||
var grants = outcome.Result!.Grants;
|
||||
// One Card grant + one Skin cascade grant
|
||||
Assert.That(grants.Any(g => g.RewardType == (int)UserGoodsType.Card && g.RewardId == cardId), Is.True);
|
||||
Assert.That(grants.Any(g => g.RewardType == (int)UserGoodsType.Skin && g.RewardId == skinId), Is.True);
|
||||
|
||||
var viewer = await ctx.Viewers
|
||||
.Include(v => v.LeaderSkins)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(viewer.LeaderSkins.Any(s => s.Id == (int)skinId), Is.True, "cascade actually granted the skin");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SetProtected_flips_flag_on_owned_card()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, isProtected: false);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.SetProtected(viewerId, 10001001L, isProtected: true);
|
||||
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);
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).IsProtected, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SetProtected_unsets_flag_when_isProtected_false()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 2, isProtected: true);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.SetProtected(viewerId, 10001001L, isProtected: false);
|
||||
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);
|
||||
Assert.That(viewer.Cards.First(c => c.Card.Id == 10001001L).IsProtected, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SetProtected_allows_zero_count_row()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// Round-trip: own 1 → destruct 1 → Count=0 row remains → protect succeeds
|
||||
await factory.SeedOwnedCardAsync(viewerId, cardId: 10001001L, count: 1, dustReward: 50);
|
||||
using var setup = factory.Services.CreateScope();
|
||||
var setupRepo = setup.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
var destruct = await setupRepo.DestructCards(viewerId, new Dictionary<long, int> { { 10001001L, 1 } });
|
||||
Assert.That(destruct.IsSuccess, Is.True, "setup precondition: destruct-to-zero");
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
var outcome = await repo.SetProtected(viewerId, 10001001L, isProtected: true);
|
||||
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.First(c => c.Card.Id == 10001001L);
|
||||
Assert.That(owned.Count, Is.EqualTo(0));
|
||||
Assert.That(owned.IsProtected, Is.True, "protect on zero-count row must persist");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SetProtected_unknown_card_returns_error()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// No OwnedCardEntry row at all
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<ICardInventoryRepository>();
|
||||
|
||||
var outcome = await repo.SetProtected(viewerId, 99_999_999L, isProtected: true);
|
||||
Assert.That(outcome.IsSuccess, Is.False);
|
||||
Assert.That(outcome.Error, Is.EqualTo(ProtectError.UnknownCard));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user