Commit Graph

328 Commits

Author SHA1 Message Date
gamer147
9e8ebd1b2b fix(battle-node): preserve long type on numeric array elements in FromJson
Root cause for the lingering mulligan failure: the inline conditional
expression in MsgEnvelope.ToObject

    JsonValueKind.Number => el.TryGetInt64(out var l) ? l : el.GetDouble(),

unified its branches to the common implicit-convertible type. long→double
is implicit, so both branches collapsed to double and the integer value
silently widened. Inside an array (idxList:[2]), each element came back
as boxed double; OfType<long> in ExtractIdxList then filtered every
entry out, so swapIndices arrived empty and BuildSwapResponse echoed
the unchanged hand — exactly the diff-against-Deal mismatch the client
flagged as "Card swap failed: AbandonCards[2]/DrawCards[]".

Extract a ParseNumber helper that returns object explicitly so each
branch boxes its own runtime type. Also harden ExtractIdxList to accept
any boxed numeric type (long/int/double/decimal/string) so a future
JSON-parser drift can't silently regress this path again.

Two regression tests:
- FromJson_NumericArray_PreservesLongTypeOnEachElement: confirms the
  fix at the JSON-parse layer with a hardcoded "{\"idxList\":[2,3]}".
- Swap_WithIdxListContainingTwo_ProducesHandWithFreshIdxAtPosition1:
  exercises the dispatch end-to-end with a Body holding a real boxed
  long; asserts position 1 of the response hand is the fresh deck idx 4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:40:50 -04:00
gamer147
77fb93f3ea fix(battle-node): real mulligan card replacement + opponent TurnStart push
Two issues caught during v1 smoke at the mulligan / first-turn boundary:

1) BuildSwapResponse ignored the player's idxList and echoed the same
   3-card hand back. The client diffs the new self[] against the Deal
   to compute "drawn cards" — empty diff against the same hand throws
   "Card swap failed: AbandonCards[X]/DrawCards[]". Replace swapped
   idxs with fresh deck idxs (initial hand was 1/2/3, deck has 4..30
   still available). Same hand must flow into Ready since the client
   diffs again there. Move the hand computation into a new helper
   ComputeHandAfterSwap and have ComputeResponses thread it through
   both BuildSwapResponse and BuildReady.

2) The client doesn't transition to the "Opponent's turn…" display
   on its own after sending TurnEnd — it waits for the server to push
   an opponent TurnStart (per prod TK2 capture line 14). Without it
   the UI just sits on the end-of-turn frame. Add a TurnEnd handler
   that pushes a minimal TurnStart{spin} and transitions to a new
   OpponentTurn phase, which IS the documented v1 stopping point.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:30:44 -04:00
gamer147
e06d97ef6f fix(battle-node): respond to InitBattle/Loaded, not InitNetwork
Pushing Matched in response to InitNetwork lands it before
MatchingInitBattle() finishes wiring up the OnReceivedEvent handler
and setting status=Connect. The client's Matched-case in
ReactionReceiveUri only transitions to StartLoad when status is
Connect at the moment of receipt; otherwise the frame is silently
dropped at the state machine and the matchmaking UI never advances.

The real connect-handshake sequence (per MatchingNetworkConnectChecker
+ Matching.cs):
  1. WS opens.
  2. Client emits InitNetwork (cat=general).
  3. Server replies InitNetwork ack → _initNetworkSuccess = true.
  4. MatchingInitBattle: status=Connect; emit InitBattle; subscribe
     OnReceivedEvent matching handler.
  5. Server replies Matched → status=StartLoad, StartBattleLoad.
  6. Asset load done → client emits Loaded.
  7. Server replies BattleStart + Deal → status=Prepared, GotoBattle.

Add AwaitingInitBattle phase, gate Matched on InitBattle receipt, and
gate BattleStart+Deal on Loaded receipt. Update dispatch and
integration tests to walk the new sequence; InitBattle's wire cat is
Matching(2), not Battle(1).

Caught during v1 smoke walkthrough — battle-traffic.ndjson showed the
client receiving Matched/BattleStart at sub-millisecond gaps after
InitNetwork ack, but never advancing past matchmaking.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 02:08:04 -04:00
gamer147
0b859f1c8e fix(check): merge anonymous resignup viewer into Steam-linked viewer
GameStart already detects the Steam-vs-UDID mismatch produced by
wipe-and-resignup; it now also reclaims the orphan. New
ViewerRepository.MergeAnonymousViewerInto transfers the fresh UDID
from V_new onto V_old in one save (freeing the unique-index slot),
then deletes V_new in a second save. Partial-failure mode is a
benign null-UDID viewer; two rows never contend for the same UDID.
Side benefit: future GetViewerByUdid lookups now short-circuit to
V_old without going through the Steam handler.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:59:47 -04:00
gamer147
01b0c64a63 fix(battle-node): inject resultCode=1 into every scripted synchronize push
The client's OnReceived routing drops any synchronize push whose
resultCode != Success(1) — and absent counts as 0(None), which is
also dropped. Our InitNetwork ack and BattleFinish already included
resultCode=1, but the five lifecycle bodies (Matched, BattleStart,
Deal, Swap response, Ready) didn't, so the client silently dropped
every one of them.

Symptom: battle-traffic.ndjson capture showed the client receiving
InitNetwork/Matched/BattleStart, but the UI stayed at the matchmaking
screen until timeout — Matched/BattleStart were dropped at the
routing layer before they ever reached the state machine. Move the
resultCode injection into the shared EnvelopeForPush helper so every
scripted push gets it.

Caught during v1 smoke walkthrough.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:55:35 -04:00
gamer147
e7dac31d52 fix(check): emit rewrite_viewer_id when UDID and Steam viewers disagree
Wipe-and-resignup left the client stuck with the blank V_new's id in
Certification.ViewerId. /tool/signup is anonymous, so it can't see the
Steam ticket and creates a fresh anonymous viewer keyed on the new UDID;
the Steam handler on the next request resolves to V_old and serves its
data, but no normal-response hook overwrites Certification.ViewerId.
GameStart now compares the UDID-keyed viewer to the auth-resolved one
and emits rewrite_viewer_id when they differ, which Cute/GameStartCheckTask
writes back into Certification.ViewerId.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:49:56 -04:00
gamer147
cc32223d7d fix(battle-node): strip/prepend EIO3 type byte on binary WS frames
Engine.IO v3 frames over WebSocket prepend the packet-type byte (0x04
for Message) to BINARY frames, the binary analog of the leading digit
on text frames. The real client honors this and our session was
treating the entire binary frame as the Socket.IO attachment payload —
the msgpack decoder saw 0x04 as a positive fixint and failed
deserialization on every inbound msg event.

Symmetric fix: strip 0x04 from inbound binary frames in
BattleSession.RunAsync, prepend 0x04 to outbound binary frames in
EncodeAndSendAsync. RawSocketIoTestClient gets the same on both
directions so the integration test still exercises the same wire
shape as a real client.

Caught during v1 smoke walkthrough, after the WS upgrade started
succeeding (101 Switching Protocols).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:48:52 -04:00
gamer147
ccc9b41473 fix(battle-node): header-based WS detection in auth; split unknown-bid vs mismatch logs
Previous fix used Context.WebSockets.IsWebSocketRequest, but that
requires UseWebSockets() to have already run — and UseBattleNode
(which calls UseWebSockets) is registered AFTER UseAuthentication
in Program.cs, so the WS feature isn't installed when auth runs.
Switch to reading the raw Upgrade header, which works regardless
of middleware order.

Also split the WS handler's "Unknown battle/viewer pair" warning
into two distinct cases so we can tell unknown-BattleId from
viewer-id-mismatch (which lets us see whether the bridge stored
the right viewer or the client is encrypting a different id).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:17:42 -04:00
gamer147
1252f7bd35 fix(battle-node): read WS credentials from headers; skip Steam auth on WS upgrades
Two issues caught in the real-client smoke:

1) BestHTTP's SocketOptions.AdditionalQueryParams puts BattleId and
   viewerId on HTTP request HEADERS for WebSocket-only transport
   (NOT on the URL query string as the in-battle/transport.md spec
   says). Real clients therefore send them as headers; our handler
   was reading from query and rejecting every connect with "Unknown
   battle/viewer pair: <bid>/<garbage>". Fix: header-first, query-
   fallback (so the integration test still works against TestServer).

2) The Steam auth handler was running on every WS upgrade and
   throwing NotSupportedException on Request.Body.Seek (Kestrel's
   HttpRequestStream doesn't support Seek, and a WS upgrade is GET
   with Content-Length: 0 anyway). It flooded logs and added no
   value — the battle node has its own per-connection credentials.
   Skip auth when IsWebSocketRequest is true.

Spec correction for in-battle/transport.md to follow.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:12:21 -04:00
gamer147
5525dbee24 fix(battle-node): node_server_url matches prod wire format (no scheme, with path)
Prod do_matching captures (data_dumps/captures/traffic_prod_tk2_*) send
the node URL as host:port/socket.io/ with no scheme prefix —
e.g. "node06.shadowverse.jp:13560/socket.io/". BestHTTP's SocketManager
expects this exact shape; the leading ws:// we were sending plus the
missing /socket.io/ path was preventing the client from completing the
post-do_matching connect (eventually times out with "connection timed
out").

Update BattleNodeOptions default, Program.cs override, and both
controller and bridge tests to use "localhost:5148/socket.io/".

Discovered during v1 smoke walkthrough.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:06:40 -04:00
gamer147
f765d5c7d4 chore(emulated-entrypoint): quiet EFCore info logs in appsettings 2026-06-01 01:02:32 -04:00
gamer147
9776873073 fix(arena-tk2): include card_master_id in do_matching success response
The decompiled client's DoMatchingBase.SettingCardMasterId calls
jsonData["card_master_id"].ToInt() with no Keys.Contains guard when
matching_state ∈ {3004, 3007, 3011}. Omitting the field crashes the
client with KeyNotFoundException at Cute.NetworkManager+Connect.

Add CardMasterId to DoMatchingResponseDto with a default value of 1
(matching the /load/index response and prod captures). Extend the
controller test to assert the field is present.

Caught during the v1 smoke walk-through; full client log line:
  [Error: Unity Log] KeyNotFoundException: The given key was not
  present in the dictionary.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:52:57 -04:00
gamer147
b1397e3a3e refactor(repositories): move static caches into IMemoryCache, enable within-fixture parallelism
BattlePassRepository._curveCache and MissionCatalogRepository._maxLevelCache
were private-static fields populated lazily on first read from whatever
DbContext happened to be in scope. In production "one DbContext lineage
per process" makes that fine. Under parallel test execution each
SVSimTestFactory owns its own SQLite :memory: DB, so the first reader's
DB (often empty, in tests that don't seed BP) poisoned the cache for
concurrent readers from a seeded DB — assertions like "BP level info
must be present after seeding" failed because the process-static cache
returned an empty list populated by the other test's empty DB.

The first patch attempted a `BypassCacheForTests` static flag, which is
exactly the kind of test-only seam that rots the production code: future
caches get the same flag, repos accumulate hidden knobs, and the
underlying invariant ("a cache populated from arbitrary scope serves
arbitrary scope") goes unaddressed.

Instead, move both caches into the DI-registered IMemoryCache.
AddMemoryCache() registers it as singleton-per-service-provider:
production has one provider → one IMemoryCache → identical caching
semantics to before. Each WebApplicationFactory builds its own
provider → its own IMemoryCache → cache is naturally scoped per fixture,
no cross-test bleed possible.

The ResetLevelCurveCache() method and its three call sites
(SVSimTestFactory.SeedGlobalsAsync, BattlePassServiceTests,
LoadControllerTests) are deleted — a fresh factory owns a fresh empty
cache, no manual invalidation needed.

With this and the previous StoryService fixture-instance fix in place,
ParallelScope.All works: 776/776 in 57s wall clock (down from 59s on
Fixtures, 2m13s pre-parallelism).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:41:09 -04:00
gamer147
66c456c1c8 test(story-service): per-test fixture instance + unique InMemoryDb name
NUnit's default FixtureLifeCycle is SingleInstance — every test in a
class shares one fixture instance, so [SetUp]-initialised fields like
_master / _viewer / _service are reset on every test against the same
object. Under serial execution that's fine; under parallel execution
concurrent SetUps wipe each other's Mock setups and the service code
NREs trying to dereference unconfigured stubs.

Compounding it, NewInMemoryDb was being called with nameof(SetUp) which
is the literal string "SetUp", so every test in the fixture also shared
the same EF InMemory database (the provider keys stores by name).

Two fixes:
- [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] on StoryServiceTests
  so each test gets its own instance with its own Mocks.
- Suffix the InMemoryDb name with a Guid so concurrent callers never
  share a store.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:40:51 -04:00
gamer147
31f26655ba test(pack-controller): derive expected active-pack count from seed at runtime
The full-catalog regression test hardcoded "35 active packs as of
2026-05-23" but the controller filters by DateTime.UtcNow against each
pack's commence/complete dates. When two packs (99047, 80047) crossed
their complete_date of 2026-06-01 01:59:59 UTC, the test started
failing with Expected: 35 / But was: 33 — which had been masked all
along by NUnit's trx serializer OOMing on a different test.

The hardcoded count conflated three things that happened to be equal
on the day the test was written: packs in the seed file, packs active
right now, and 35. The test's real intent (per its class docstring) is
"every pack the importer ingests round-trips through /pack/info";
pinning the clock with TimeProvider would solve today's drift but
re-break the moment someone regenerates the seed or retires a pack.

Expected count now derives from the seed file at test time, filtered
by the same predicate the controller uses (PackRepository
.GetActivePacks: IsEnabled && commence <= now <= complete) via the
shared ImporterBase.ParseWireDateTime parser so any date-string quirk
parses identically on both sides. Spot-check on pack 99047 swapped for
"any pack with non-default pack_category" — same schema-fidelity
coverage (non-zero category survives JSON round trip) without pinning
to an id that rotates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:23:15 -04:00
gamer147
7914bab84e test(unit-tests): silence captured stdout in Testing env
The unit-test suite was spending most of its wall clock writing logs.
NUnit captures stdout per test and embeds it in the trx; with HttpLogging
emitting full request/response per controller call, EF Core SQL at
Information level, and ReferenceDataImporter banners running ~500x
(once per factory construction), the trx grew to 3.2 GB and the NUnit
result-XML serializer OOMed in StringBuilder.ToString() — which the
runner reported as one mysteriously failed test, masking a real
date-dependent failure underneath.

Three sources silenced under environment "Testing":
- appsettings.Testing.json drops Default + Microsoft.AspNetCore +
  HttpLoggingMiddleware + EntityFrameworkCore to Warning.
- Program.cs skips app.UseHttpLogging() entirely (avoids the
  middleware overhead, not just the log emission).
- ReferenceDataImporter takes optional TextWriters; the test factory
  passes TextWriter.Null. Per-importer helpers become instance methods
  so they can use the injected writer.

Result on a fresh run with ParallelScope.Fixtures already in place:
- Test duration: 1m46s -> 59s
- Wall clock: 2m23s -> 1m00s
- trx size: 3.2 GB -> 1.7 MB

The previously-masked date-dependent failure (PackControllerFullCatalog
.Info_returns_full_35_pack_catalog_from_production_seed asserting 35
active packs as of 2026-05-23 against a live clock) is now visible and
can be addressed separately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:13:42 -04:00
gamer147
d093d872ae test(unit-tests): parallelize at the fixture level
NUnit's default ParallelScope is Self (serial). With ~736 tests each
constructing its own SVSimTestFactory (full ASP.NET host + SQLite :memory:
+ ReferenceDataImporter seeding 7270 rows from CSVs), the suite was
running ~2m13s serial. ParallelScope.Fixtures drops it to ~1m46s — a
~20% wall-clock reduction with zero new failures.

Stayed at Fixtures rather than All because ParallelScope.All exposes
the process-static BattlePassRepository._curveCache (and likely other
similar caches) to races inside heavy-globals fixtures (LoadController,
PackControllerFullCatalog, StoryService — all consistent failures
under All, flaky 3-7 fails across runs). Within-fixture parallelism
is blocked on cleaning those up first.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 00:06:47 -04:00
gamer147
905fdc780a test(battle-node): end-to-end flow test through Ready via WebApplicationFactory
Boots SVSimTestFactory (in-memory SQLite + reference-data CSV import),
mints a battle via IMatchingBridge, opens a raw Socket.IO v2 client
against the in-process TestServer, drives InitNetwork → Loaded → Swap,
and asserts the right scripted frames come back in order.

Verifies the full transport stack end-to-end: EIO3+SIO2 framing,
encryptForNode codec, MsgPayloadCodec roundtrip, InboundTracker
pubSeq dedup + ack echo, OutboundSequencer playSeq assignment, and
ScriptedLifecycle's Path-A frame builders.

Note: RawSocketIoTestClient.DisposeAsync skips the graceful CloseAsync
handshake — TestServer's in-process WebSocket implementation can hang
on it. Abrupt Dispose is fine: the server's ReceiveAsync throws
WebSocketException, BattleSession.RunAsync returns, and the handler
completes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:37:31 -04:00
gamer147
ff51c33b6c feat(arena-tk2): do_matching mints battle via IMatchingBridge, returns 3004 2026-05-31 22:53:20 -04:00
gamer147
88ed8254af feat(emulated-entrypoint): wire AddBattleNode + UseBattleNode into the web host 2026-05-31 22:49:31 -04:00
gamer147
1dd6a70e8d feat(battle-node): WebSocket endpoint at /socket.io/ + DI extension methods 2026-05-31 22:34:54 -04:00
gamer147
f19da481c3 fix(battle-node): MatchingBridge avoids Math.Abs(int.MinValue) overflow
Cast GetHashCode() result to long before Math.Abs to prevent OverflowException
on the ~1-in-4B case where GetHashCode returns int.MinValue. Adds a regression
test pinning the 12-digit decimal format end-to-end.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:33:35 -04:00
gamer147
d3c4b3083e feat(battle-node): IMatchingBridge + MatchingBridge mint battle id + node url 2026-05-31 22:31:04 -04:00
gamer147
680630050b fix(battle-node): BattleSession crash safety, fresh-key per push, phase guards
- Wrap HandleMsgEventAsync / HandleAliveEventAsync bodies in try/catch(Exception)
  logging at Error, eliminating async-void unobserved-exception crash risk (Issue 1).
- Replace deterministic seq-based key generator with RandomNumberGenerator.GetInt32
  so each EncodeAndSendAsync call uses a fresh random key (Issue 2).
- Add `when Phase == …` guards to InitNetwork / Loaded / Swap cases in
  ComputeResponses; add default arm that logs+drops out-of-order URIs (Issue 3).
- Widen SendSioAckAsync arg from int to long; drop (int) cast at call site;
  boundary cast to int is now checked() for defensive overflow detection (Issue 4).
- Update RunAsync doc comment (was stale Task-13 placeholder) (Issue 5).
- Add Kill and out-of-order-Swap-before-Loaded tests (Issue 6).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:28:13 -04:00
gamer147
f6aee5b0f8 feat(battle-node): BattleSession routes lifecycle URIs through ScriptedLifecycle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:21:55 -04:00
gamer147
30b457c9a0 fix(battle-node): assert Bid is in envelope (not Body) on BuildMatched
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:18:12 -04:00
gamer147
0fd4f5f9f7 feat(battle-node): ScriptedLifecycle frame builders (Path-A static opponent) 2026-05-31 22:15:44 -04:00
gamer147
a306295fe2 feat(battle-node): BattleSession skeleton with EIO/SIO read pump 2026-05-31 22:10:17 -04:00
gamer147
22a4825265 feat(battle-node): Gungnir alive-body builders (scs/ocs ONLINE placeholders) 2026-05-31 22:07:31 -04:00
gamer147
82b7d1e940 feat(battle-node): OutboundSequencer assigns playSeq + archives for Resume 2026-05-31 22:05:16 -04:00
gamer147
87051737da feat(battle-node): InboundTracker dedupes client pubSeq + tracks high-water 2026-05-31 22:02:56 -04:00
gamer147
3ade8ff4f5 feat(battle-node): in-memory IBattleSessionStore + PendingBattle 2026-05-31 22:00:40 -04:00
gamer147
c0c2bb5772 feat(battle-node): MsgPayloadCodec encodes/decodes msgpack↔envelope chain 2026-05-31 21:58:06 -04:00
gamer147
4cc8b3c01c fix(battle-node): MsgEnvelope rejects reserved Body keys + complete ReceiveNodeResultCode
ToJson now throws ArgumentException when a Body key collides with a reserved
envelope field (uri/viewerId/uuid/bid/try/cat/pubSeq/playSeq); FromJson reuses
the same shared ReservedEnvelopeKeys HashSet. ReceiveNodeResultCode expanded
from 9 to 31 codes to mirror the full enums.md catalog. Two regression tests
added for the collision guard and PascalCase uri serialization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:55:11 -04:00
gamer147
383044dd8f feat(battle-node): NetworkBattleUri / EmitCategory enums and MsgEnvelope record
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:50:17 -04:00
gamer147
6ff4f70f1a fix(battle-node): SocketIoFrame disposal safety + escaping + empty-args encoding
- Wrap all JsonDocument.Parse calls in using blocks and Clone() each
  retained JsonElement to eliminate UAF hazard after GC.
- Use JsonSerializer.Serialize with UnsafeRelaxedJsonEscaping so event
  names with " or \ produce \" / \ rather than " / plain \;
  avoids malformed JSON on Encode().
- Guard the [ ] block in Encode() behind EventName-or-args check so
  Connect/Disconnect packets round-trip as bare "0"/"1" not "0[]".
- Add three regression tests: Connect no-bracket, Event round-trip,
  special-char event name escaping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:46:02 -04:00
gamer147
8b1f613407 feat(battle-node): SocketIoFrame parse/encode for SIO2 incl. binary attachments 2026-05-31 21:39:53 -04:00
gamer147
6c6664f011 feat(battle-node): EngineIoFrame parse/encode for EIO3 packets 2026-05-31 21:34:11 -04:00
gamer147
a786599416 fix(battle-node): clarify NodeCrypto.GenerateKey contract + add fixed-vector regression test
Replace inaccurate GenerateKey docstring (it claimed to port Cryptographer.generateKeyString
directly but the input shape differs: server uses one hex digit per call, client uses
Random.Next(0,65535) per call). New doc is honest about the difference and explains why
it's safe. Add EncryptForNode_FixedVector_ProducesStableOutput: a pinned AES-CBC vector
that catches encoding/IV/padding regressions that would slip past the roundtrip test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:31:22 -04:00
gamer147
0a2eddd920 feat(battle-node): port AES-256-CBC encryptForNode/decryptForNode codec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:26:05 -04:00
gamer147
50790a706c feat(battle-node): scaffold SVSim.BattleNode class library 2026-05-31 21:21:14 -04:00
gamer147
dd231b081d Merge branch 'inventory-service'
InventoryService consolidation: replaces RewardGrantService,
CurrencySpendService, ViewerEntitlements, and CardAcquisitionService
with a single scoped-transaction facade IInventoryService.

- BeginAsync loads viewer with canonical inventory graph + extras
- TrySpendAsync/TryDebitAsync/GrantAsync queue ops; CommitAsync saves
- Result carries RewardList (post-state, currency-collision-resolved)
  + Deltas (verbatim queued) for distinct wire fields
- Freeplay logic folded into the tx surface
- 14 callers ported (Load, BuildDeck, Pack, LeaderSkin, Sleeve,
  ItemPurchase, SpotCardExchange, Gift, Achievement, Puzzle, Story,
  BattlePass, ArenaTwoPick, GachaPoint); CardInventoryRepository.Create
  ported, Destruct deferred
- 8 old service files + 4 test files deleted
- 713/713 tests pass

Spec: docs/superpowers/specs/2026-05-31-inventory-service-design.md
Plan: docs/superpowers/plans/2026-05-31-inventory-service.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:25:43 -04:00
gamer147
a033bf361a fix(battle-pass): remove redundant SaveChanges after CommitAsync
CommitAsync's inner SaveChangesAsync already flushes the AddClaim
rows + progress.IsPremium mutation alongside the inventory grants
(same scoped DbContext). The trailing _db.SaveChangesAsync was a
no-op in BuyPremium and only meaningful in AddPoints when no level
crossed (no tx opened) — restructured to an else branch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 18:48:26 -04:00
gamer147
2ee40c6df7 test(inventory): wire-shape regression for spend+grant+cascade
Serializes result.RewardList with snake_case+WhenWritingNull options and
asserts the three entries come out in expected first-touch order:
Crystal post-state (500), Card post-state count (3), Sleeve cascade (1).
Also verifies snake_case key names are actually emitted.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 17:12:16 -04:00
gamer147
2c62a7be80 refactor(inventory): delete old primitives after InventoryService cutover
Removed RewardGrantService, CurrencySpendService, ICurrencySpendService,
ViewerEntitlements, IViewerEntitlements, CardAcquisitionService,
ICardAcquisitionService, CardGrantResult and their tests
(RewardGrantServiceTests, CurrencySpendServiceTests,
CardAcquisitionServiceTests, ViewerEntitlementsTests). Removed four DI
registrations from Program.cs. No caller references any deleted type;
GrantedReward and EffectiveCosmetics were pre-moved to InventoryGrantTypes.cs
in the prior commit. Build clean, 712/712 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 17:07:30 -04:00
gamer147
df0e132459 refactor(inventory): move GrantedReward + EffectiveCosmetics into Inventory namespace folder
Both types stay in namespace SVSim.Database.Services so existing using directives
in controllers, services, and tests resolve without change. Their definitions are
extracted to SVSim.Database/Services/Inventory/InventoryGrantTypes.cs; the empty
husks in RewardGrantService.cs and IViewerEntitlements.cs will be deleted in the
next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 17:03:06 -04:00
gamer147
c37c04c1b7 refactor(gacha-point): route TryExchangeAsync through IInventoryTransaction
Change signature from (Viewer, packId, cardId) to (IInventoryTransaction, packId, cardId).
Drop RewardGrantService from GachaPointService ctor. PackController.ExchangeGachaPoint opens
tx with GachaPointBalances/Received extra includes, passes tx, commits on success.
Update GachaPointServiceTests to use inv.BeginAsync + tx pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:55:08 -04:00
gamer147
b6bf9b7495 refactor(arena-two-pick): route entry/finish through InventoryService
Replace RewardGrantService + ICurrencySpendService + IViewerEntitlements with
IInventoryService. tx.IsFreeplay replaces FakeEntitlements.IsFreeplay; debit
helpers take IInventoryTransaction. ComputePostStateRewardList deleted (replaced
by result.RewardList from CommitAsync). Update 5 test files to new 8-arg ctor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:51:03 -04:00
gamer147
26bc4fe2ab refactor(battle-pass): route BuyPremiumAsync and AddPointsAsync through InventoryService
Replace RewardGrantService + ICurrencySpendService with IInventoryService tx.
CommitAsync's currency-collision rule replaces the manual Crystal RemoveAll+re-append
scrub in BuyPremiumAsync. AddPointsAsync uses result.Deltas for NewlyClaimed to
preserve per-track visibility (two Rupy grants stay two entries).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:46:13 -04:00
gamer147
7c4bc2966f refactor(story): route FinishAsync rewards through InventoryService
Replace RewardGrantService with IInventoryService tx. Per-reward GrantAsync
calls inside try/catch preserve the NotSupportedException skip; CommitAsync
returns result.RewardList (post-state totals) and accumulated delta list feeds
story_reward_list. Update StoryServiceTests to inject IInventoryService.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:42:38 -04:00