Compare commits

...

167 Commits

Author SHA1 Message Date
gamer147
5c4e427fab feat(battle-node): clear RealParticipant outbound archive on session terminate
Closes audit Md11. BattleSession.RunAsync now clears each
RealParticipant.Outbound archive immediately before the TerminateAsync
cascade, releasing the heavy dict the moment the battle ends instead of
waiting for the participant to be GC'd. Bots (NoOp / Scripted) don't
expose an OutboundSequencer, so the 'p is RealParticipant rp' conditional
cast is the natural filter.

Tests: 1 new BattleSessionTerminateCascadeTests — pre-load the archive,
drive RunAsync to completion via TestWebSocket.CompleteIncoming, assert
the archive is empty. Suite: 939 → 948.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:10:15 -04:00
gamer147
10d9f74d05 feat(battle-node): add OutboundSequencer.Clear() for terminate cascade
Audit Md11 (part 2 of 2). Adds an explicit Clear() so BattleSession can
release the archive at battle-end instead of waiting for the participant to
be GC'd. _next is intentionally NOT reset — a post-Clear emit is a bug per
the design, but the seq stream must stay monotonic if it does happen.

Tests cover empty archive after Clear, _next preservation across Clear,
and Clear-on-empty no-op. The BattleSession integration that calls Clear
lands in the next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:07:00 -04:00
gamer147
3991bcc653 feat(battle-node): bound InboundTracker with watermark-guarded sliding window
Audit Md11 (part 1 of 2). Replace unbounded HashSet<long> _seen with a
WindowSize=256 ring (HashSet + Queue, LRU eviction). The stale-below-window
guard (pubSeq <= HighWaterMark - WindowSize) prevents window eviction from
re-admitting old seqs as novel — the load-bearing invariant.

pubSeq is client-monotonic and SIO retransmit horizons are seconds-scale, so
256 covers realistic retries by a wide margin. HighWaterMark semantics
preserved (Gungnir still reports it).

Tests: 5 new InboundTrackerTests covering below-window guard, evicted-seq
rejection, within-window dedup after eviction, memory bound, and watermark
monotonicity.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:06:08 -04:00
gamer147
898b872edd fix(rank-battle): route ai-start through the queue-time MatchContext
Live-smoke bug 2026-06-02: queued Bloodcraft (deck #5), wire showed
classId=2 (Swordcraft) for self_info on the /ai_unlimited_rank_battle/start
response — client rendered the wrong leader.

Two layers of the same bug:

1. MatchContextBuilder.BuildForRankBattleAsync hardcoded deckNo=1 instead
   of taking it from the do_matching request — verified against
   data_dumps/captures/traffic.ndjson L17 where deck_no=5 was on the wire.
   Signature changes to (viewerId, format, deckNo); DoMatchingInternal
   passes req.DeckNo.

2. AiStartInternal rebuilt MatchContext from scratch — but the /ai_*/start
   request body is BaseRequest only, no deck_no on the wire. The fix uses
   the MatchContext the bridge already stored at do_matching resolution time
   (in the Bot PendingBattle), so deck/cosmetic data is consistent end-to-end.
   New IBattleSessionStore.TryFindPendingForViewer(viewerId) finds the
   viewer's pending battle for lookup. The store entry persists across
   ai_start (idempotent reads are fine — the WS handler removes on connect).
   No-pending sentinel: ai_id=-1 surfaces the "no AI assigned" error in the
   client.

Tests: 936 → 939 passing.
- MatchContextBuilderTests.BuildForRankBattle_uses_the_caller_supplied_deck_number
  seeds deck #1 (class 1) and deck #5 (class 6) and asserts the deckNo
  argument picks the right one.
- RankBattleControllerTests.AiStart_self_info_class_matches_queued_deck_number
  is the end-to-end regression: register Bot battle with deck #5, hit
  /ai_unlimited_rank_battle/start, assert self_info.classId == 6.
- RankBattleControllerTests.AiStart_without_pending_battle_returns_neg1_sentinel
  locks the defensive ai_id=-1 path.
- Existing AiStart_* tests bypass do_matching, so adapted to call a new
  RegisterBotBattleAsync helper that mirrors what InProcessPairUp does on
  AI-fallback resolution.

SeedDeckAsync gains an optional classId so test cases can differentiate
decks by class (was always picking Classes.First()).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 12:28:42 -04:00
gamer147
24f9b2240e feat(matching): move BotRoster from hardcoded fixture to DB-backed seed
Phase 3 shipped the AI rank battle bot pool as a hardcoded 8-entry list
inlined in SVSim.EmulatedEntrypoint/Matching/BotRoster.cs — editing meant
recompiling. Per PLAN.md 2026-06-02 item (d), move it to a Bootstrap
importer so the roster lives in seeds/bot-roster.json and the DB.

Shape mirrors PracticeOpponent end-to-end:
- BotRosterEntry (SVSim.Database/Models) — PK = AiId via the Id passthrough
  pattern. DbSet<BotRosterEntry> BotRoster on SVSimDbContext.
- AddBotRoster migration (DDL only, per migrations-are-DDL-only rule).
- seeds/bot-roster.json — 8 rows preserving the current prod-verified
  cosmetic ids (sleeve 704141010 / emblem 400001100 / degree 120027 /
  field 5) and series-1 ai_ids from rm_ai_setting.csv (1111..1181).
- BotRosterSeed POCO + BotRosterImporter (idempotent upsert keyed by AiId,
  leaves seed-missing rows intact). Wired into SVSim.Bootstrap/Program.cs
  next to PracticeOpponentImporter.
- IGlobalsRepository.GetBotRoster() + impl.

IBotRoster.Pick → PickAsync because BotRoster now depends on the transient
IGlobalsRepository. RankBattleController awaits the new signature. The
deterministic hash-on-ctx invariant (same ctx → same bot, so /ai_<fmt>/start
retries pick the same opponent) is preserved.

DI: AddSingleton<IBotRoster> → AddTransient (matches IGlobalsRepository's
lifetime). Test fixture's SeedGlobalsAsync also runs the importer so
RankBattleControllerTests + the rewritten BotRosterTests both see seeded
rows.

Tests: 931 → 936 passing. Existing 3 BotRosterTests reshaped for the DB
backing + 1 new "throws on empty roster" guard; 4 new
BotRosterImporterTests mirror PracticeOpponentImporterTests
(round-trip / idempotent / seed-missing-row-intact / ai_id=0 skip).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:58:19 -04:00
gamer147
8aead62116 fix(battle-node): revert Bot Matched/BattleStart push (corrupts OppoBattleStartInfo)
Previous commit 51e9dd2 changed Bot's InitBattle to push Matched and
Loaded to push BattleStart+Deal, on the theory that the architecture
spec's "no Matched in Bot mode" claim was wrong. That theory was based
on misreading Matching.cs:400 (the Matched handler) as a required
state-machine trigger.

End-to-end trace of the AI client flow shows:

  1. _initNetworkSuccess (set when the client receives uri=InitNetwork,
     i.e., our ack) is the actual trigger — MatchingNetworkConnectChecker
     phase 3 sees it and calls MatchingInitBattle.

  2. MatchingInitBattle (Matching.cs:298) for IsAINetwork IMMEDIATELY
     calls StartBattleLoad + GotoBattle right after emitting InitBattle.
     It does NOT wait for any wire envelope.

  3. The Matched handler at Matching.cs:400 is gated on
     status == Connect and is already past Prepared by the time the
     wire round-trip completes — sending Matched is harmless but
     unnecessary.

  4. The BattleStart handler at Matching.cs:417 runs UNCONDITIONALLY and
     SetNetworkInfo at RealTimeNetworkAgent.cs:1562 overwrites
     OppoBattleStartInfo with the wire envelope's oppoInfo. Our oppoInfo
     comes from NoOpBotParticipant.Context placeholders (classId/emblemId
     etc. = 0), corrupting the good values the client set from the HTTP
     AIBattleStart response.

The "Waiting for opponent" hang was caused by SBattleLoad.LoadOpponentAssets
trying to fetch emblemId=0, degreeId=0, etc. after BattleStart corrupted
OppoBattleStartInfo. The asset group load silently hangs on missing
assets, no error logged.

Restored the spec's original Bot arms: InitBattle ack-only, Loaded silent,
TurnEnd Judge-to-sender. ai-passive.md updated with the corrected reasoning
and a discovery-history note.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:23:13 -04:00
gamer147
d87f9beb81 fix(rank-battle): use prod-verified bot cosmetic ids to unblock LoadOpponentAssets
The "Waiting for opponent" hang traced to BattleStartControl.IsReady never
flipping true. That's gated by SBattleLoad.LoadOpponentAssets which calls
ResourcesManager.LoadAssetGroupSync with the bot's
{rank, emblemId, degreeId, countryCode} — and our placeholder ids (1/1/1/"NONE")
don't resolve to any asset in the client's resource bundle, so the callback
never fires.

Replaced with the Scripted bot's known-good prod values:
- SleeveId: 704141010
- EmblemId: 400001100
- DegreeId: 120027
- FieldId: 5
- CountryCode: "JPN"
- IsOfficial: 0

These are the same ids ScriptedBotParticipant.Context uses, which we know
load fine because the TK2 Scripted flow has been working end-to-end since
Phase 2.

Reference for the load chain (decompiled client):
  BattleUI.WaitForSetUp → m_SBattleLoad.WaitCallBack
    → BattleStartControl.SetUp → CheckAbleToInitialize
    → SBattleLoad.LoadOpponentAssets (SBattleLoad.cs:933)
    → ResourcesManager.LoadAssetGroupSync — hangs on missing assets

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 11:09:20 -04:00
gamer147
51e9dd2094 fix(battle-node): Bot mode must push Matched + BattleStart (client state-machine triggers)
Phase 3 shipped a Bot dispatch table that ack'd InitBattle without
pushing Matched and stayed silent on Loaded, per the architecture spec's
inference that "the client uses AIBattleStart HTTP data instead of
Matched in Bot mode." That inference was wrong.

The client's matching state machine (Matching.ReactionReceiveUri,
Matching.cs:400) gates StartBattleLoad() on the Matched envelope, and
BattleStart at Matching.cs:417 triggers GotoBattle. Without those
envelopes the client never transitions out of MatchingStatus.Connect —
which renders as the "Waiting for opponent" hang on the loading screen.
AIBattleStart HTTP only provides opponent cosmetics, not state-machine
triggers.

Fix: drop the Bot-specific InitBattle ack-only and Loaded silent arms;
let Bot fall through to the existing handshake arms that push Matched
and BattleStart + Deal. Only TurnEnd stays Bot-specific (Judge to
sender, not broadcast — there's no real other side to broadcast to).

Tests updated to match the corrected contract. ai-passive.md doc
amended with a correction note.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:56:22 -04:00
gamer147
45c4461515 fix(rank-battle): use real rm_ai_setting.csv ai_id values in BotRoster
Phase 3 shipped placeholder ai_id values 4001..4008, which the client's
RankMatchAISettingList.GetSettingData() couldn't resolve — the lookup
is .First() against the rm_ai_setting.csv master table and throws
InvalidOperationException ("Sequence contains no matching element")
when the id isn't present. Surfaced on live smoke as a Unity error
during battle load:

  Wizard.RankMatchAISettingDataSet.GetSettingData (System.Int32 enemyAiId)
  BattleUI+<WaitForSetUp>d__9.MoveNext ()

Replaced with the series-1 enemy_ai_id per class from
data_dumps/client-assets/rm_ai_setting.csv:
  1111=Forest, 1121=Sword, 1131=Rune, 1141=Dragon,
  1151=Shadow, 1161=Blood, 1171=Haven, 1181=Portal

Practice mode's AI catalog (practice_ai_setting.csv) uses a different
schema keyed by (class_id, difficulty) with no enemy_ai_id field, so
practice ids aren't reusable here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:41:38 -04:00
gamer147
bf783639c1 fix(rank-battle): inherit BaseRequest so auth fields survive translation roundtrip
The translation middleware decrypts + msgpack-decodes the request body
into the action's first-parameter type, then re-serializes that DTO to
JSON for the auth handler to read. Phase 3's DoMatchingRequestDto and
RankBattleFinishRequestDto didn't inherit BaseRequest, so viewer_id /
steam_id / steam_session_ticket were dropped during the msgpack → DTO
→ JSON pivot — the auth handler then saw a body with no auth fields
and 401'd every request.

Fixed by making both DTOs extend BaseRequest, mirroring the Phase 2 TK2
DoMatchingRequest pattern.

Also added [FromBody] BaseRequest parameters to the previously body-less
actions (AiStart × 2, ForceFinish, AddClientLog, GetLatestMasterPoint).
The translation middleware explicitly requires at least one parameter
to bind the decrypted msgpack body (see L130-136 of the middleware);
without it the request would throw InvalidOperationException at runtime.

Tests updated to post viewer_id / steam_id / steam_session_ticket
placeholder values in the request body, matching the existing TK2 test
pattern.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 09:29:48 -04:00
gamer147
8723cff998 test(battle-node): BotBattle_FullLifecycle integration test
Single-client end-to-end Bot lifecycle: InitNetwork → ack, InitBattle →
ack (no Matched), Loaded → silent, Swap → SwapResponse + Ready,
two TurnEnd cycles each producing a single Judge frame back to sender,
Retire → BattleFinish. Pending battle evicted at session start.

Closes Phase 3 — battle-node v2's three-phase migration (Scripted → PvP →
Bot) is now complete. Test budget: 884 → 931 (+47 across Phase 3).
Next: matching-queue API rewrite + real rank progression, as separate
specs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:32:07 -04:00
gamer147
fee84cca24 feat(battle-node): wire WS handler's case BattleType.Bot to real (Real, NoOp) session
Replaces the Phase-2 log-and-return stub with a real session
construction. P2 is always null for Bot (bridge contract), so no
WaitingRoom flow needed — single real WS, Phase-1 WhenAll-everything
RunAsync semantics work because NoOp.RunAsync completes immediately.
Integration test follows in the next task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:29:11 -04:00
gamer147
a4685a9188 feat(battle-node): Bot dispatch arms in ComputeFrames
Three new arms gated on Type == BattleType.Bot, placed before the
existing PvP / Scripted arms:
- InitBattle → ack to sender (no Matched push — client uses AIBattleStart HTTP data)
- Loaded → silent (no BattleStart, no Deal — client short-circuits to GotoBattle)
- TurnEnd / TurnEndFinal → Judge to sender only (not broadcast)

Other URIs in Bot mode fall through existing arms: Swap is Type-agnostic
(per-sender SwapResponse + Ready), Retire/Kill hits the existing
Scripted no-contest BattleFinish(Win), gameplay forwarders are gated on
Pvp so Bot's PlayActions/Echo/etc. fall through default (drop). 8 new
dispatch tests cover the wire contract.

Reference: docs/api-spec/in-battle/ai-passive.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:27:08 -04:00
gamer147
07eb6f1c05 feat(rank-battle): AiStart returns ai_id + camelCase self/oppo_info
AiStartInternal builds the self MatchContext, picks a bot from
IBotRoster, projects to the AiBattleStartResponseDto with camelCase
wire keys (sleeveId, emblemId, ... — see ai-start.md). turnState=0
(player first) is the safe default per the ai-start.md TODO; live
capture would clarify the enum.

No deck → ai_id=-1 fallback (the documented "no AI assigned" sentinel
per AIBattleStartTask.cs:21). 3 new wire-shape tests assert the
camelCase keys land verbatim in the JSON, plus self/oppo info come from
the right sources.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:24:04 -04:00
gamer147
bb63b0df2f feat(rank-battle): real DoMatching with PvP pair + AI fallback mapping
DoMatchingInternal calls IMatchingPairUpService.TryPairAsync, then maps:
- null result → 3002 RETRY (empty node_server_url, no battle_id)
- IsAiFallback → 3011 AI_BATTLE_MATCHING_SUCCEEDED
- IsOwner → 3007 SUCCEEDED_OWNER (cache pickup)
- joiner → 3004 SUCCEEDED

BuildForRankBattleAsync's InvalidOperationException (typically "no deck
for format") surfaces as 3001 ILLEGAL so the client shows the
matchmaking-error dialog rather than retrying.

card_master_id is a placeholder (0) per the per-battle card-master
split deferral. AI-fallback timing is covered by InProcessPairUp unit
tests; controller tests focus on the wire mapping (3002, 3004, 3007).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:21:38 -04:00
gamer147
7c4aa89d45 feat(rank-battle): RankBattleController shell + DTOs + routing smoke tests
Stands up the controller with all 13 rank-battle URL routes wired via
explicit absolute [HttpPost] attributes (multi-prefix family — can't ride
[Route(\"[controller]\")]). Real DoMatching / AiStart logic arrives in
later tasks; finish + telemetry + force-finish are returnable stubs as
of this task.

DTOs cover the request + response shapes per the spec. Note the
camelCase wire keys on AiBattlePlayerInfo (sleeveId, emblemId, ...) —
the AI battle subsystem uses camelCase, not the project-default
snake_case, per AIBattleStartTask.Parse's literal Keys.Contains lookups.

DoMatchingResponseDto.NodeServerUrl is non-nullable + always-emit (with
[JsonIgnore(Never)]) — matches Phase 2's TK2 fix because the client's
DoMatchingBase parser calls .ToString() without a Keys.Contains guard.

13 routing smoke tests confirm each URL resolves to the controller.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:19:02 -04:00
gamer147
a55187e10e feat(matching): IBotRoster + hardcoded BotRoster fixture (8 bots, one per class)
AIBotProfile carries the cosmetic metadata the AI rank-battle start
endpoint composes into oppo_info. BotRoster.Pick is deterministic per
MatchContext so mid-flight retries get the same opponent. ai_id values
4001..4008 are placeholders per the existing ai-start.md TODO — we have
no live capture of the prod catalog.

Future improvement: migrate Roster to a bot-roster.json seed under
SVSim.Bootstrap/Data/seeds/ for editability without rebuilds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:15:41 -04:00
gamer147
7eaf13893e feat(matching): MatchContextBuilder.BuildForRankBattleAsync for rank battles
Sibling to BuildForTwoPickAsync. Routes through IDeckRepository.GetDeck
to pull the viewer's deck #1 for the requested format (avoiding the
viewer-graph nav-ref auto-load pitfall — DeckCard.Card silently ships
card_id=0 via the default include path). Throws if the viewer has no
deck for the format. Cosmetics fall back to DefaultLoadoutConfig
defaults when unequipped, same shape as TK2.

Used by RankBattleController in a later task to build self-context for
/ai_<fmt>_rank_battle/start and to pair-up under /<fmt>_rank_battle/do_matching.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:13:19 -04:00
gamer147
b65cf81977 feat(matching): per-mode policy + AI-fallback branch in InProcessPairUp
InProcessPairUp now consults ModePolicyRegistry per call and reads the
fallback threshold from MatchingConfig via IServiceScopeFactory (singleton
service consuming a scoped IGameConfigService). New behavior for
PvpFirstThenAiFallback modes: when the calling viewer IS the slot's
waiter and Now - WaitingSince >= threshold, the waiter unparks and the
bridge resolves a Bot match. PvpOnly modes (TK2) keep parking forever
(modulo a 5-minute stale-waiter eviction backstop).

TimeProvider is injected so tests can drive time forward with
FakeTimeProvider — 7 new tests cover the four key transitions
(stay-parked / pair-pvp / fall-back / stale-evict) plus per-mode
isolation. Fixture uses [FixtureLifeCycle(InstancePerTestCase)] because
the assembly is Parallelizable(ParallelScope.All).

Program.cs registers ModePolicyRegistry with three rows: TK2 PvpOnly,
rotation/unlimited rank PvpFirstThenAiFallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 01:09:42 -04:00
gamer147
3866c93065 refactor(matching): extend PairUpResult with IsAiFallback flag
Pure shape change ahead of Phase 3 AI-fallback wiring — all current
callers pass IsAiFallback: false. TK2 will always emit false (PvpOnly
policy); rank-battle's PvpFirstThenAiFallback branch sets true after
the threshold elapses.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:57:25 -04:00
gamer147
d7bb44973a feat(matching): ModePolicy registry for per-mode pair-up policy
Adds PolicyKind enum (PvpOnly, PvpFirstThenAiFallback), ModePolicy
record, and ModePolicyRegistry singleton with last-wins dict + PvpOnly
default for unknown modes. Wired into InProcessPairUp in a later task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:55:18 -04:00
gamer147
b17c802581 feat(config): MatchingConfig section with AI-fallback threshold
Adds a [ConfigSection("Matching")] POCO carrying the
RankBattleAiFallbackThresholdSeconds tunable (default 15). Auto-picked
up by EnsureSeedDataAsync's reflection-based ConfigSection seeder.
Consumed by InProcessPairUp in a later task.

GameConfigurationJsonbTests.EnsureSeedData_writes_one_row_per_ConfigSection_with_ShippedDefaults_payload
updated to include the new Matching section in its expected keys.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:54:44 -04:00
gamer147
0095bdf0cf feat(arena-tk2): SoloDefaultsToScripted config flag for dev convenience
Adds BattleNodeOptions.SoloDefaultsToScripted (default false). When true,
the TK2 do_matching controller treats every solo poll as if ?scripted=1
were passed and returns a Scripted 3004 match immediately — useful for
the live client (which can't append query params) to drive the scripted
bot without needing a second player.

Toggle via "BattleNode:SoloDefaultsToScripted" in appsettings*.json
(Program.cs now binds the BattleNode section over the AddBattleNode
defaults). Turn off to test real PvP with two clients.

Trade-off documented on the option: while on, two simultaneous pollers
each get their own Scripted match instead of pairing, so PvP is
effectively disabled until the flag is flipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:48:14 -04:00
gamer147
8112b3f81f feat(arena-tk2): split do_matching success into 3007 owner / 3004 joiner
Mirrors prod's TK2 wire flow: the first arriver (parked, picks up cached
pair on a later poll) gets matching_state 3007 (SUCCEEDED_OWNER); the
second arriver (whose poll triggered the pair) gets 3004 (SUCCEEDED).

Observationally inert in the public matching code path today — the
client's Matching class writes isOwner from the response into a field
that nothing in TK2/ranked reads. Matching_Room (private rooms) DOES
read it but from a separate code path that doesn't consult our response.
We send the split anyway for prod fidelity and to leave room for future
flows (rematch UI, etc.) that might start consuming it.

TryPairAsync now returns PairUpResult(Match, IsOwner) instead of bare
PendingMatch?, so the controller can decide owner vs joiner without
re-deriving it.

Also documents on DoMatchingResponseDto why we omit prod's `room_id`
field (not in the client's DoMatchingDetail model; private-room flows
get their room id from a different API and don't consult this response).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 23:24:13 -04:00
gamer147
0ecd565774 fix(arena-tk2): park returns 3002 RETRY + empty node_server_url
Two client-crash bugs in the do_matching response when no partner is
waiting:

1. matching_state was 3001 (RC_BATTLE_MATCHING_ILLEGAL); the client's
   Matching.OnFinishedDoMatching switch maps that to an error dialog,
   not a retry. The retry state is 3002 (RC_BATTLE_MATCHING_RETRY).

2. node_server_url was omitted entirely. The client's
   DoMatchingBase.SettingDoMatchingData reads it via
   data["node_server_url"].ToString() with no Keys.Contains guard, so
   absence throws KeyNotFoundException out of NetworkManager.Connect
   before the matching_state switch is even reached. Prod RETRY
   captures send "" while waiting and the real URL only on SUCCEEDED;
   match that.

battle_id stays absent; its accessor IS guarded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 22:50:48 -04:00
gamer147
43c0a6cf31 test(battle-node): PvP integration tests (handshake, gameplay, Retire, disconnect, timeout)
Four end-to-end tests against two parallel RawSocketIoTestClients:
handshake to AfterReady on both sides with per-perspective Matched;
TurnEnd broadcast to both sides + Judge; A's PlayActions forwarded to
B; Retire flipped to Lose-for-sender, Win-for-other; A's abrupt WS
close cascades to BattleFinish(Win) for B with PendingBattle eviction;
waiting-room timeout closes the first arriver's WS (fallback long-wait
path — the 60s default is left in place; TestServer-side WS close is
observed via ReceiveAsync returning Close or throwing).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 22:30:01 -04:00
gamer147
225c20daeb feat(arena-tk2): PvP pair-up trigger via /do_matching, ?scripted=1 opt-in
Solo pollers park (3001 RETRY); two concurrent pollers pair and both
receive 3004 + same BattleId. Cache hits on the first arriver's next
poll. ?scripted=1 retains today's solo Scripted path for dev work.
Response DTO's BattleId/NodeServerUrl become nullable so 3001 omits
them on the wire (WhenWritingNull policy drops them).

ASP.NET's default bool binder rejects "1" as a value, so the scripted
opt-in is bound as string? and parsed permissively (accepts "1" and
"true"/"True"/etc.) rather than relying on built-in bool binding.
2026-06-01 22:14:04 -04:00
gamer147
28b1d7531a feat(emulated-entrypoint): InProcessPairUp service for TK2 PvP matching
Tiny per-mode FCFS slot. First poller parks; second pairs and triggers
bridge.RegisterBattle(p1, p2, Pvp). Match cached for first poller's
next poll (consume-on-read). No MMR, no cross-mode, no timeouts --
the proper queue API is a separate spec; this is the smallest thing
that lets TK2 PvP work end-to-end.
2026-06-01 22:06:49 -04:00
gamer147
0bb19320df feat(battle-node): WS handler Pvp branch with WaitingRoom
Pvp arrivers Pair-or-Park: second arriver constructs the session;
first arriver awaits self.AwaitSessionFinishedAsync (never calls
self.RunAsync directly because the session does). Park-race retries
Pair once. Bot type still stubbed for Phase 3. Scripted path unchanged.

Viewer-id validation extended to accept either P1 or P2 (PvP sessions
have both).
2026-06-01 22:02:21 -04:00
gamer147
ca5a1e926d feat(battle-node): RealParticipant session-finished signal + Pvp cascade
RealParticipant gains _sessionFinished TCS + MarkSessionFinished /
AwaitSessionFinishedAsync. PvP first-arriver's handler awaits the
signal instead of calling self.RunAsync (which the session does
internally on the same instance — double-call would race the WS read).

BattleSession.RunAsync branches on Type: Pvp uses WhenAny + synthesize
BattleFinish(Win) to survivor + WhenAll(drain); Scripted/Bot keep
Phase 1's WhenAll-everything semantics. Disconnect cascade now drives
end-of-battle when a WS drops without a graceful Retire.
2026-06-01 21:58:47 -04:00
gamer147
2789dc08cb feat(battle-node): WaitingRoom for PvP WS rendezvous
Per-BattleId slot keyed dict. Pair returns the first arriver to the
second; ParkAsync awaits a TCS and returns the second arriver. Timeout
defaults to BattleNodeOptions.WaitingRoomTimeout (60s); evict on timeout
keeps the dict clean. Singleton in DI; consumed by the handler in the
next task.
2026-06-01 21:55:11 -04:00
gamer147
db054205b3 feat(battle-node): PvP TurnEnd broadcast + flipped Retire/Kill result
Pvp TurnEnd/TurnEndFinal broadcasts TurnEnd+Judge to BOTH so each
client's JudgeOperation advances. Pvp Retire/Kill pushes BattleFinish
with flipped result (sender=Lose, other=Win). Scripted Retire keeps
Phase 1 behaviour (sender-only Win via BuildBattleFinishNoContest).
2026-06-01 21:51:27 -04:00
gamer147
72dc1887d9 feat(battle-node): PvP gameplay-frame forwarding arms
TurnStart / PlayActions / Echo / TurnEndActions / JudgeResult from a
real participant in Pvp mode forward to the other participant once
BothAfterReady. Scripted's bot-burst case arms (gated on
FakeOpponentViewerId) precede the PvP forwarder so they're unaffected.

The bot-emission TurnStart/TurnEnd/Judge guard was tightened from
`ReferenceEquals(from, A or B)` (always true) to call
IsRealForwardableFromScripted directly in the `when` clause. The prior
shape used `goto default` to drop non-bot senders, which would have
short-circuited the new PvP forwarder for TurnStart in PvP mode.
2026-06-01 21:46:28 -04:00
gamer147
8a97dd0194 test(battle-node): PvP handshake dispatch sees cross-perspective contexts
Tests assert that for Type=Pvp, A's InitBattle gets Matched with A's
ctx as selfInfo and B's ctx as oppoInfo, and symmetrically for B. Same
for Loaded/BattleStart. Swap stays per-sender (each runs their own
mulligan).
2026-06-01 21:41:35 -04:00
gamer147
875a4baa29 refactor(battle-node): move handshake phase reads to per-participant
ComputeFrames now reads (from as IHasHandshakePhase)?.Phase for the
four handshake arms (InitNetwork, InitBattle, Loaded, Swap) and the
TurnEnd gate, transitioning the participant's Phase instead of the
session's. RealParticipant implements IHasHandshakePhase via the new
Phase property; the session-level BattleSession.Phase stays for the
Terminal short-circuit.

Scripted dispatch + wire shape unchanged (single-Real-participant case
collapses to Phase 1 semantics). Test fixture migrates FakeParticipant
to FakeRealParticipant for the side that drives handshake states. The
bot's TurnEnd previously rode the session-level AfterReady arm; with
that arm now gated on the sender's per-participant Phase (which the
bot lacks), TurnEnd joins TurnStart/Judge in the scripted-bot
forwarder arm so the v1.2 burst still reaches the real participant.
2026-06-01 21:33:17 -04:00
gamer147
ac78473a3e feat(battle-node): add RealParticipant.Phase for per-side handshake state
Internal setter; defaults to AwaitingInitNetwork. PvP needs A and B to
progress through the handshake states independently, which the
session-level BattleSession.Phase can't model. Session migration to read
realFrom.Phase is the next task.
2026-06-01 21:25:11 -04:00
gamer147
b75eb512ea docs(battle-node): refresh ScriptedBotParticipant <remarks> to match Phase 2 wiring
Task 1's refactor made BattleSession read other.Context for the
Matched / BattleStart opponent half, but the class doc still claimed
the Context was ignored. Update it to match the new wiring.
2026-06-01 21:23:09 -04:00
gamer147
560feb231a refactor(battle-node): generalise BuildMatched/BuildBattleStart for PvP
Both helpers now take the opponent's MatchContext + an explicit seed
instead of pulling ScriptedProfiles.OpponentMatchedProfile / OpponentBattleStartProfile
internally. ScriptedBotParticipant.Context fixture absorbs the cosmetic
fields previously hardcoded in ScriptedProfiles so Scripted's wire bytes
stay identical - verified by integration tests still green.

Phase 2 prep: PvP arms will call the same helpers with the real opponent
participant's Context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 21:16:11 -04:00
gamer147
2d7cee38d3 refactor(battle-node): drop old BattleSession; rename V2 -> BattleSession
Old single-WS BattleSession + its dispatch/pump/ClipAckArg tests are
obsolete after the Task 9 handler cutover. ClipAckArg overflow + boundary
coverage moved into RealParticipantTests. BattleSessionV2 renamed back
to BattleSession; the V2 suffix was a placeholder during the parallel
-build refactor.
2026-06-01 20:10:14 -04:00
gamer147
91472df6fc refactor(battle-node): cut handler over to BattleSessionV2 + participants
Production WS path now constructs RealParticipant + ScriptedBotParticipant
and hands them to BattleSessionV2 instead of the old single-WS
BattleSession. Wire behaviour preserved end-to-end (BattleNodeFlowTests
still pass).

Also fixes a RunAsync bug uncovered by the cutover: WhenAny would
terminate the session as soon as the scripted bot's no-op RunAsync
resolved, killing the live WS read loop before any traffic arrived.
Phase 1 semantics are simpler — wait for ALL participants. Phase 2's
Pvp disconnect propagation will revisit this.
2026-06-01 20:07:45 -04:00
gamer147
bbc3a47f7a test(battle-node): BattleSessionV2 dispatch covers Scripted-mode routing
Mirrors v1.2's BattleSessionDispatchTests but asserts on (target, frame,
noStock) routing tuples returned by ComputeFrames. Covers InitNetwork
ack, InitBattle/Loaded/Swap server-synthesized broadcasts (to the real
participant only in Scripted mode), TurnEnd forwarding to the scripted
bot, scripted-bot-emitted frames routing back to the real participant,
Retire/Kill BattleFinish path, and out-of-order frame drops.
2026-06-01 20:03:06 -04:00
gamer147
b2f3d25be0 feat(battle-node): add BattleSessionV2 broker (unused yet)
Parallel to existing BattleSession. Subscribes to both participants'
FrameEmitted, dispatches via ComputeFrames(from, env) returning
(target, frame, noStock) routing tuples. Dispatch table currently only
covers Scripted-mode behaviour (preserves v1.2). Phase 2 adds Pvp arms;
Phase 3 adds Bot. Not yet wired into the handler — Task 9 cuts over.
2026-06-01 20:01:54 -04:00
gamer147
d665f88067 refactor(battle-node): unify IMatchingBridge.RegisterBattle signature
Single RegisterBattle(p1, p2?, type) with contract validation throws on
invalid combinations (Pvp requires both; Bot requires p2==null; Scripted
accepts either). PendingBattle carries Type + P1 + nullable P2. Handler
+ controller adapt; v1.2 behaviour preserved because Scripted is the
only type used today (Phase 2 adds Pvp, Phase 3 adds Bot).
2026-06-01 20:00:52 -04:00
gamer147
acd0997cfb feat(battle-node): add RealParticipant wrapping WS + sequencers
Lifts the WS read loop, SIO encode/decode, per-WS OutboundSequencer +
InboundTracker, and SIO ack out of BattleSession into a participant.
PushAsync(noStock=false) assigns playSeq via the sequencer; noStock=true
bypasses it. FrameEmitted fires on each deduplicated inbound envelope.
The existing BattleSession keeps its own copy of the WS code for now;
Task 9 cuts the handler over to use BattleSessionV2 + RealParticipant
and Task 10 deletes the old BattleSession + duplicate code.
2026-06-01 19:57:45 -04:00
gamer147
fcdcc5d590 feat(battle-node): add ScriptedBotParticipant wrapping v1.2 burst
PushAsync(TurnEnd|TurnEndFinal) fires FrameEmitted three times:
OpponentTurnStart + OpponentTurnEnd + OpponentJudge. Behaviour-identical
to the v1.2 case arm in BattleSession.ComputeResponses; just repackaged
as a participant. Other URIs are swallowed. Used by Phase 1 to preserve
v1.2 behaviour under the new abstraction; replaces the case-arm logic
in BattleSession in Task 7.
2026-06-01 19:56:01 -04:00
gamer147
553a79c795 feat(battle-node): add NoOpBotParticipant
Silent participant for the Phase 3 Bot type. PushAsync swallows;
FrameEmitted never fires; RunAsync completes immediately. ViewerId is
the existing FakeOpponentViewerId const for consistency with scripted
lifecycle builders. Three tests lock the no-op contract.
2026-06-01 19:55:00 -04:00
gamer147
9079715da6 feat(battle-node): add IBattleParticipant interface
Central abstraction for v2 broker. PushAsync (session -> participant),
FrameEmitted (participant -> session), RunAsync (drives inbound),
TerminateAsync (cleanup). Three impls land in Tasks 3-5.
2026-06-01 19:54:03 -04:00
gamer147
ae7ff25af0 feat(battle-node): add BattleType, BattleFinishReason, BattlePlayer
Phase 1 foundation types for the v2 broker architecture. Nothing uses
them yet; they land alongside the existing v1.2 code so subsequent
tasks can extract the participant interface and impls.
2026-06-01 19:53:31 -04:00
gamer147
479548fa56 test(battle-node): integration test expects three frames per cycle
End-to-end exercises the v1.2 burst: each TurnEnd from the client now
produces TurnStart + TurnEnd + Judge through the real WS pump.
2026-06-01 17:42:44 -04:00
gamer147
136149ed6b test(battle-node): wire-shape test for BuildOpponentJudge
Mirrors BuildOpponentTurnEnd_SerializesTurnStateAndResultCode. Guards
JudgeBody's JsonPropertyName keys against rename-induced wire breakage
(per feedback_wire_shape_tests pattern).
2026-06-01 17:40:33 -04:00
gamer147
1ef101f851 feat(battle-node): push Judge after opponent TurnEnd so client transitions
Third frame in the burst, per prod TurnEnd -> Judge pairing observed in
battle-traffic_tk2_regular.ndjson (positions 10->11, 17->18, etc.).
The client's TurnEndOperation sends its own Judge and gates the next turn
on a server-pushed Judge via JudgeOperation -> ControlTurnStartPlayer.
Closes the v1.1 'Opponent's turn... forever' hang caught during smoke.
2026-06-01 17:37:55 -04:00
gamer147
007513e55c test(battle-node): TurnEnd dispatch tests expect three-frame burst (TDD red)
Both single-cycle and consecutive-cycles tests now assert the v1.2
three-frame burst (TurnStart + TurnEnd + Judge). Currently failing —
ComputeResponses still pushes only two frames. Implementation follows.
2026-06-01 17:34:59 -04:00
gamer147
8a5b8b747d feat(battle-node): BuildOpponentJudge builder for v1.2 turn-end Judge
Adds the third frame of the burst. Wire shape from prod (spin + resultCode).
OpponentJudgeSpin const next to OpponentTurnStartSpin for consistency.
Single test locks uri, ViewerId, Cat, and body shape.
2026-06-01 17:32:22 -04:00
gamer147
70b2872589 feat(battle-node): add JudgeBody record for opponent turn-end Judge push
Mirrors OpponentTurnStartBody — JsonPropertyName-cased spin + resultCode
(default 1). First piece of the v1.2 three-frame turn-end burst; nothing
references it yet.
2026-06-01 17:30:00 -04:00
gamer147
5021217134 test(battle-node): wire-shape test + refresh stale comment for v1.1
Final-review follow-ups:
- BuildOpponentTurnStart's doc comment claimed the v1 client sits
  indefinitely — true before the loop closure, false after. Updated
  to describe the pair with BuildOpponentTurnEnd.
- TypedBodyWireShapeTests had no coverage for BuildOpponentTurnEnd;
  added the literal-JSON test so a future JsonPropertyName rename
  on TurnEndBody is caught.
2026-06-01 15:20:48 -04:00
gamer147
ff8e4abea8 test(battle-node): integration test drives two opponent-turn cycles
End-to-end through the real WS pump: after Ready, the test sends two
consecutive TurnEnd msgs and asserts the server pushes
TurnStart+TurnEnd for each. Exercises OutboundSequencer's playSeq
assignment across multiple cycles.
2026-06-01 15:04:21 -04:00
gamer147
decdef29cf test(battle-node): TurnEnd cycle can fire multiple times
Locks the loop invariant: after the first cycle the phase resets to
AfterReady, so the next player TurnEnd matches the same case arm and
produces the same two-frame burst.
2026-06-01 15:01:19 -04:00
gamer147
e30fdb7570 feat(battle-node): scripted opponent turn loop pushes TurnStart + TurnEnd
The TurnEnd/TurnEndFinal case in ComputeResponses now returns two envelopes
back-to-back — opponent TurnStart followed by opponent TurnEnd. Phase enters
OpponentTurn transiently then resets to AfterReady within the same call so
the next player TurnEnd can fire the cycle again. Closes the v1 'stays at
Opponent's turn… forever' stall.
2026-06-01 14:57:49 -04:00
gamer147
96ae090a3a test(battle-node): rewrite TurnEnd dispatch test for two-frame cycle
Replaces the v1.0 single-envelope/OpponentTurn-phase invariant with
the v1.1 two-envelope/AfterReady invariant. Currently failing —
ComputeResponses still does the v1.0 thing. Implementation follows.
2026-06-01 14:53:32 -04:00
gamer147
f24fc7c643 feat(battle-node): BuildOpponentTurnEnd builder for v1.1 turn loop
Pairs with BuildOpponentTurnStart. Wire shape from prod capture
(turnState=0, resultCode=1). Single test locks uri, ViewerId, Cat,
and body shape.
2026-06-01 14:49:52 -04:00
gamer147
d4926e31d6 feat(battle-node): add TurnEndBody record for opponent turn-end push
Mirrors OpponentTurnStartBody — JsonPropertyName-cased turnState +
resultCode (default 1). First piece of the scripted opponent turn-end
loop; nothing references it yet.
2026-06-01 14:46:21 -04:00
gamer147
1904ae4c0c refactor(battle-node): ScriptedLifecycle.InitialHand as ImmutableArray<long>
Audit Md4 cleanup: the prior long[] allowed in-place modification by any
caller with the field reference. ImmutableArray<long> enforces the constant
contract at the type level. ComputeHandAfterSwap uses ToArray() to produce
its mutable working copy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:02:23 -04:00
gamer147
6077844ee8 docs(battle-node): README reflects real drafted deck + cosmetics
Player-side fictions (dummy deck, classId=1) removed; the section now
documents which fields are real (deck, leader, cosmetics) vs still hardcoded
(rank, battlePoint, cardMaster, fieldId, seed) with a pointer to the spec's
§Deferred plumbing for each. "Where to extend" table loses the two done
items and gains a row for wiring future modes via IMatchContextBuilder.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 13:00:22 -04:00
gamer147
e3cc745a61 test(battle-node): end-to-end drafted deck flows into Matched frame
Seeds a viewer + completed TK2 run, drives the WS handshake to Matched, and
asserts every cardId in selfDeck matches the run's SelectedCardIdsJson. Read
from RawBody (codec's wire-form deserialization) — not from MatchedBody —
since the test client gets the JSON-roundtripped envelope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:51:33 -04:00
gamer147
b0488e3f2e feat(battle-node): BuildBattleStart consumes MatchContext for player half
ClassId/CharaId/CardMasterName/BattleType flow from ctx. PlayerBattleStart
Profile removed; Rank/BattlePoint remain as standalone consts pending real
per-viewer rank tracker. One test updated, one new test added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:49:54 -04:00
gamer147
f589283572 feat(battle-node): BuildMatched consumes MatchContext for player half
selfInfo cosmetics + 30-card selfDeck now read from MatchContext. Opponent
half stays in ScriptedProfiles. DummyCardId / BuildDummyDeck / PlayerMatched
Profile removed. Two new tests lock the deck-idx pairing and cosmetic
flow-through; TypedBodyWireShapeTests + lifecycle tests thread a fixture ctx.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:48:04 -04:00
gamer147
01f9bb722a feat(battle-node): thread MatchContext through bridge to BattleSession
IMatchingBridge.RegisterPendingBattle now takes a MatchContext; PendingBattle
carries it; BattleSession stores it. ArenaTwoPickBattleController builds ctx
from IMatchContextBuilder. ScriptedLifecycle still uses ScriptedProfiles for
the player half — Tasks 5/6 migrate the lifecycle.

Existing tests updated: MatchingBridgeTests, BattleNodeFlowTests,
InMemoryBattleSessionStoreTests, BattleSessionDispatchTests, BattleSession
PumpTests, ArenaTwoPickBattleControllerTests (which now seeds a TK2 run +
adds a no-active-run 400 case).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:44:42 -04:00
gamer147
a0fdb0f3c5 feat(match-context): add IMatchContextBuilder TK2 implementation
Assembles MatchContext from ArenaTwoPickRun + viewer cosmetics + config.
Per-mode interface — future modes (rank/free/open-room/...) add one method
each. DI scoped registration. Four tests cover happy path, no-run, incomplete
draft, default-loadout fallback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:40:26 -04:00
gamer147
89b3d23bde feat(viewer-repo): add LoadForMatchContextAsync for battle-node ctx build
Focused AsNoTracking load with Info.SelectedEmblem/SelectedDegree includes
for the new MatchContextBuilder. Single test locks the include graph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:37:44 -04:00
gamer147
0e8f5427c3 feat(battle-node): add MatchContext record for per-mode player snapshot
Public contract between HTTP-side do_matching controllers (assemble) and
SVSim.BattleNode (consume). First piece of the real-drafted-deck wiring;
nothing references it yet.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 12:35:43 -04:00
gamer147
ef3d7bb82b refactor(battle-node): WireConstants for SIO event names + crypto RNG battle id 2026-06-01 11:53:01 -04:00
gamer147
133346e3e8 refactor(battle-node): SocketIoFrame throws on namespace; typed JSON construction 2026-06-01 11:48:17 -04:00
gamer147
2588388d9d refactor(battle-node): distinct WS auth status codes + named handler delegate 2026-06-01 11:45:50 -04:00
gamer147
a364f539ad refactor(battle-node): tighten Phase setter to private; document sid opacity 2026-06-01 11:41:47 -04:00
gamer147
677b1f1392 feat(battle-node): BattleResult enum for BattleFinish.result wire codes 2026-06-01 11:41:16 -04:00
gamer147
eaf6d7160b refactor(battle-node): dedupe NodeCrypto AES setup into BuildAes helper 2026-06-01 11:36:48 -04:00
gamer147
34c4ca0237 fix(battle-node): NodeCrypto.GenerateKey masks rand source with & 0xF 2026-06-01 11:35:53 -04:00
gamer147
e4fbb155e4 test(battle-node): pump-level tests for async-Task dispatch, CT, Md5 clip 2026-06-01 11:15:10 -04:00
gamer147
21b7ddf6ae test(battle-node): TestWebSocket mock for pump-level unit tests 2026-06-01 11:13:54 -04:00
gamer147
4dd61343aa fix(battle-node): clip SIO ack arg instead of checked-cast throwing on overflow 2026-06-01 11:13:24 -04:00
gamer147
453865ade2 fix(battle-node): thread session CT through every send instead of None 2026-06-01 11:12:26 -04:00
gamer147
8cce667e02 fix(battle-node): await DispatchSocketIo instead of async-void fire-and-forget 2026-06-01 11:11:58 -04:00
gamer147
0764b8646f feat(battle-node): capture session-scoped CT in BattleSession.RunAsync 2026-06-01 11:11:31 -04:00
gamer147
e4691d616b fix(battle-node): emit envelope keys before body keys in MsgEnvelope.ToJson
Client RealTimeNetworkAgent.SetNetworkInfo iterates the synchronize-data
dict in insertion order. The "uri" key, when recognized as Matched, calls
GameMgr.InitializeSelfInfo which sets _selfDeck = null. Any "selfDeck"
processed before "uri" gets wiped; Matching.StartBattleLoad then crashes
on null.Select(...). Pre-refactor ToJson built a Dictionary envelope-first
then appended body keys, so the bug never surfaced. The typed-body rewrite
inverted the order — restoring envelope-first matches the prod wire.

Regression test BuildMatched_KeyOrder_PutsUriBeforeSelfDeckAndSelfInfo
locks the contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:53:51 -04:00
gamer147
19cc7980d1 test(battle-node): envelope-level wire-shape regression for scripted bodies 2026-06-01 10:40:54 -04:00
gamer147
5ee270eb16 refactor(battle-node): switch MsgEnvelope.Body to IMsgBody, migrate all sites 2026-06-01 10:40:09 -04:00
gamer147
118be92dc5 feat(battle-node): ScriptedProfiles named constants for scripted bodies 2026-06-01 10:35:45 -04:00
gamer147
c7745d8785 feat(battle-node): typed OpponentTurnStart/ResultCodeOnly/BattleFinish/AlivePush bodies 2026-06-01 10:35:18 -04:00
gamer147
97b9b6fe42 feat(battle-node): typed Deal/Swap/Ready bodies + PosIdx 2026-06-01 10:34:44 -04:00
gamer147
78a6fe93fb feat(battle-node): typed BattleStartBody + Self/Oppo info records 2026-06-01 10:34:07 -04:00
gamer147
d9fbb67f0c feat(battle-node): typed MatchedBody + Self/Oppo info records 2026-06-01 10:33:34 -04:00
gamer147
9217de3aa1 feat(battle-node): add IMsgBody marker + RawBody inbound wrapper 2026-06-01 10:32:44 -04:00
gamer147
c279b811ad docs(battle-node): project README + docstrings on hosting/lifecycle
Add a per-project README in SVSim.BattleNode/ that covers:
- Architecture (the six concern folders)
- The connect-handshake sequence verified end-to-end at smoke
- A wire-format-gotchas table for the spec divergences caught during
  v1 (headers vs query for credentials, schemeless node URL with
  /socket.io/ path, required card_master_id, required resultCode=1,
  Matched in response to InitBattle not InitNetwork, EIO3 0x04 prefix
  on binary frames, FromJson conditional-expression number-boxing)
- What the v1 scripted opponent does and what is hardcoded
- A "where to extend" table for v2 work
- The full test layout and cross-references to specs/plans

Fill in XML docs on the public surface that previously had none:
- BattleNodeExtensions.AddBattleNode / UseBattleNode (DI + middleware
  wiring, including the pipeline-order note that auth runs before
  UseWebSockets)
- BattleNodeWebSocketHandler class + HandleAsync (the validation chain)
- BattleSession.ComputeResponses (the lifecycle state machine, with
  the NoStock flag's meaning)
- ScriptedLifecycle class (v1 scope, resultCode injection rule,
  pointer to the "where to extend" section)
- MatchingBridge class (mint-id + register flow)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 08:57:15 -04:00
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
gamer147
a310697830 refactor(puzzle): route finish rewards through InventoryService
Replace RewardGrantService + HttpContext.RequestServices viewer load with
IInventoryService tx. Single BeginAsync/GrantAsync/CommitAsync wraps all
mission rewards on the win path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:40:16 -04:00
gamer147
4ba7d8f6d0 refactor(achievement): route receive_reward through InventoryService
Replace RewardGrantService with IInventoryService tx. EnsureCurrentAsync
still runs before BeginAsync to avoid EF concurrent-context conflicts;
tx.Viewer replaces the manually loaded viewer graph.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:39:07 -04:00
gamer147
369edd4537 refactor(gift): route tutorial gift_receive through InventoryService
Replace RewardGrantService with IInventoryService tx. GrantAsync returns
post-state totals directly, eliminating the manual ResolvePostStateRewardNum
helper. MissionData loaded via extra include on BeginAsync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:37:53 -04:00
gamer147
a2cec7c99e refactor(spot-card-exchange): route through InventoryService
Replace RewardGrantService + ICurrencySpendService with IInventoryService
tx pattern. BeginAsync loads viewer, TrySpendAsync debits SpotPoint,
GrantAsync grants card + cascade, CommitAsync saves.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:35:53 -04:00
gamer147
ad4d4e0646 refactor(item-purchase): route through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:26:34 -04:00
gamer147
9436a0d21b refactor(sleeve): route buy through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:25:10 -04:00
gamer147
45fa3d75bf refactor(leader-skin): route shop through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:23:50 -04:00
gamer147
4d6da23443 refactor(pack): route Open through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:20:23 -04:00
gamer147
57dd524d9f refactor(build-deck): route Buy through InventoryService
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:17:31 -04:00
gamer147
61013fcf5c refactor(card-inventory): route Create/Destruct through InventoryService
RedEther debit now goes through tx.TrySpendAsync (freeplay-aware);
Card grants route through tx.GrantAsync (cosmetic cascade for first-time
owners). Validation phase unchanged. DestructCards left on direct-viewer
path (structural mismatch: validation on one viewer, mutation on same
instance — clean tx port deferred to follow-up).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:15:40 -04:00
gamer147
1113e52f94 refactor(load): switch to InventoryService for entitlements
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:12:27 -04:00
gamer147
91909c5755 feat(inventory): read-side methods on IInventoryService + tx
EffectiveBalance/OwnsCard/OwnsCosmetic on the tx are freeplay-aware
against the live viewer. EffectiveOwnedCardsAsync/EffectiveCosmeticsAsync
on the service mirror today's ViewerEntitlements projections (used by
/load/index).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:05:35 -04:00
gamer147
ea340cde21 test(inventory): lifecycle — dispose rollback + use-after-commit
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:03:06 -04:00
gamer147
b0b9901c42 feat(inventory): CommitAsync + currency-collision rule
Last post-state per currency wins; non-currency grants collapse to final
count per (type, id). Deltas are verbatim queued, no cascade. SaveChanges
+ DB tx commit happen atomically inside Commit; failure leaves rollback
to DisposeAsync. CS0649 warning on _committed is now resolved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:02:39 -04:00
gamer147
1ba3f57709 feat(inventory): BackfillCardCosmeticsAsync
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:01:15 -04:00
gamer147
46d8239d5a feat(inventory): TryDebitAsync dispatches currencies + Item
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 16:00:24 -04:00
gamer147
301da9eeca feat(inventory): TrySpendAsync covers all 4 wallets + freeplay
Crystal/Rupy/RedEther freeplay no-op (returns configured amount,
balance unchanged); SpotPoint always real. Insufficient returns
current balance; success returns post-deduction balance.
SVSimTestFactory gains freeplayEnabled ctor overload that upserts
the Freeplay GameConfigSection row after EnsureSeedData.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:59:26 -04:00
gamer147
a821b7f6b4 feat(inventory): GrantAsync handles Card + cosmetic cascade
Card grants produce a post-state-total entry and run the CardCosmeticReward
cascade (foil twin → id-1 lookup). Cascade additions are skipped when the
viewer already owns the cosmetic; missing-master-row failures are logged
and dropped without failing the parent grant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:54:36 -04:00
gamer147
1f3f81d878 feat(inventory): GrantAsync handles Item branch
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:52:42 -04:00
gamer147
a1cf1d7519 feat(inventory): GrantAsync handles cosmetic branches
Sleeve/Emblem/Skin/Degree/MyPageBG grants are idempotent on the viewer's
owned-collection but always emit a wire entry at the top level (preserves
"+1 sleeve" purchase popup). Unknown ids throw InventoryCatalogException.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:51:46 -04:00
gamer147
3bc38b407b feat(inventory): GrantAsync handles currency branches
Crystal/Rupy/RedEther/SpotCardPoint grants mutate ViewerCurrency in place
and emit post-state-total wire entries. Op log records the post-state for
later currency-collision resolution in CommitAsync.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:50:27 -04:00
gamer147
02e86cf16c feat(inventory): BeginAsync loads viewer with canonical graph
Includes Cards/Sleeves/Emblems/LeaderSkins/Degrees/MyPageBackgrounds/Items
under AsSplitQuery, plus caller-supplied extras via InventoryLoadConfig.
Opens a DB transaction and returns an InventoryTransaction shell. All
mutation methods throw NotImplementedException until subsequent tasks
land them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:46:20 -04:00
gamer147
b181257aaa docs(inventory): XML docs for TrySpend/TryDebit/EffectiveBalance
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:42:41 -04:00
gamer147
220e5699cd feat(inventory): scaffold InventoryService namespace types
Empty interfaces + records for IInventoryService, IInventoryTransaction,
InventoryCommitResult, InventoryLoadConfig, InventoryCatalogException.
Implementation lands in subsequent commits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 15:38:51 -04:00
194 changed files with 15547 additions and 2518 deletions

View File

@@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.UnitTests", "SVSim.Un
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.Bootstrap", "SVSim.Bootstrap\SVSim.Bootstrap.csproj", "{666786D9-9A4D-49EA-A759-39055C57F9AA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SVSim.BattleNode", "SVSim.BattleNode\SVSim.BattleNode.csproj", "{F4549DD3-566A-4155-8D52-3A4D2A7072F7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -31,5 +33,9 @@ Global
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{666786D9-9A4D-49EA-A759-39055C57F9AA}.Release|Any CPU.Build.0 = Release|Any CPU
{F4549DD3-566A-4155-8D52-3A4D2A7072F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F4549DD3-566A-4155-8D52-3A4D2A7072F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F4549DD3-566A-4155-8D52-3A4D2A7072F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F4549DD3-566A-4155-8D52-3A4D2A7072F7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,4 @@
namespace SVSim.BattleNode;
/// <summary>Marker class so dotnet build emits the assembly. Remove once real content lands.</summary>
internal static class AssemblyMarker;

View File

@@ -0,0 +1,29 @@
namespace SVSim.BattleNode.Bridge;
/// <summary>
/// DI-injected options for the battle node. NodeServerUrl matches the prod
/// do_matching wire format: <c>host:port/socket.io/</c>, no scheme prefix.
/// BestHTTP's SocketManager parses it as the Socket.IO v2 endpoint URL.
/// </summary>
public sealed class BattleNodeOptions
{
public string NodeServerUrl { get; set; } = "localhost:5148/socket.io/";
/// <summary>
/// How long the first arriver's WS waits for a partner before disconnecting.
/// Matches the architecture spec's 60s default; override (typically lower)
/// in tests via the factory.
/// </summary>
public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Dev convenience: when true, matchmaking endpoints that would otherwise park
/// a solo poller (returning 3002 RETRY until a partner arrives) instead return
/// a Scripted match immediately — equivalent to passing <c>?scripted=1</c> on
/// every request. Turn off to test real PvP with two clients. Default false.
/// <para>Trade-off: while on, two viewers polling simultaneously each get
/// their own Scripted match instead of pairing with each other. Toggling off
/// is the only way to get PvP behavior.</para>
/// </summary>
public bool SoloDefaultsToScripted { get; set; } = false;
}

View File

@@ -0,0 +1,5 @@
namespace SVSim.BattleNode.Bridge;
/// <summary>One player slot for a pending battle. Carries the viewer's identity and
/// the per-battle MatchContext snapshot built at do_matching time.</summary>
public sealed record BattlePlayer(long ViewerId, MatchContext Context);

View File

@@ -0,0 +1,25 @@
using SVSim.BattleNode.Sessions;
namespace SVSim.BattleNode.Bridge;
public interface IMatchingBridge
{
/// <summary>
/// Mint a battle id, register a pending session, return the URL the client should
/// open a socket to.
/// </summary>
/// <remarks>
/// Contract rules (enforced; throws <see cref="ArgumentException"/>):
/// <list type="bullet">
/// <item><c>Pvp</c>: <paramref name="p2"/> required. Both viewers expected to
/// connect WS within 60s.</item>
/// <item><c>Bot</c>: <paramref name="p2"/> must be null. One viewer expected;
/// opponent runs in client.</item>
/// <item><c>Scripted</c>: <paramref name="p2"/> currently null; future
/// server-driven bot config rides on <paramref name="p2"/>.</item>
/// </list>
/// </remarks>
PendingMatch RegisterBattle(BattlePlayer p1, BattlePlayer? p2, BattleType type);
}
public sealed record PendingMatch(string BattleId, string NodeServerUrl);

View File

@@ -0,0 +1,29 @@
namespace SVSim.BattleNode.Bridge;
/// <summary>
/// Per-battle player snapshot captured at do_matching time and replayed into the scripted
/// lifecycle on WS connect. SVSim.BattleNode does not know how to build this — the HTTP-side
/// per-mode controller is the source. Snapshot semantics: cosmetic changes between matching
/// and WS connect have no effect on the in-battle render.
/// </summary>
public sealed record MatchContext(
// Player's drafted deck — exactly 30 entries, idx 1..30 paired with the chosen cardIds
// in the order this list provides them. Producer is responsible for the count.
IReadOnlyList<long> SelfDeckCardIds,
// Player class + leader (BattleStartSelfInfo)
string ClassId, // "1".."8"
string CharaId, // "1".."8" — equals ClassId when no leader skin chosen
string CardMasterName, // current card-master, e.g. "card_master_node_10015"
// Player cosmetics (MatchedSelfInfo)
string CountryCode, // "KOR", "JPN", ...
string UserName,
string SleeveId,
string EmblemId,
string DegreeId,
int FieldId,
int IsOfficial, // 0 or 1
// Battle-mode hint, currently TK2 == 11. Future modes populate their own value.
int BattleType);

View File

@@ -0,0 +1,57 @@
using System.Security.Cryptography;
using SVSim.BattleNode.Sessions;
namespace SVSim.BattleNode.Bridge;
/// <summary>
/// In-process implementation of <see cref="IMatchingBridge"/>. The HTTP-side
/// matching queue calls <see cref="RegisterBattle"/> once it has decided "these two
/// play each other" or "this viewer is solo (bot/scripted)."
/// </summary>
public sealed class MatchingBridge : IMatchingBridge
{
private readonly IBattleSessionStore _store;
private readonly BattleNodeOptions _options;
public MatchingBridge(IBattleSessionStore store, BattleNodeOptions options)
{
_store = store;
_options = options;
}
public PendingMatch RegisterBattle(BattlePlayer p1, BattlePlayer? p2, BattleType type)
{
ValidateContract(p1, p2, type);
// 12-digit decimal battle id mirrors the captures (e.g. "975695075012").
// Two unbiased 6-digit draws concatenated — RandomNumberGenerator.GetInt32 uses
// rejection sampling so the result is uniform on [0, 10^6).
var hi = RandomNumberGenerator.GetInt32(0, 1_000_000);
var lo = RandomNumberGenerator.GetInt32(0, 1_000_000);
var battleId = $"{hi:D6}{lo:D6}";
_store.RegisterPending(new PendingBattle(battleId, type, p1, p2));
return new PendingMatch(battleId, _options.NodeServerUrl);
}
private static void ValidateContract(BattlePlayer p1, BattlePlayer? p2, BattleType type)
{
if (p1 is null) throw new ArgumentNullException(nameof(p1));
switch (type)
{
case BattleType.Pvp:
if (p2 is null) throw new ArgumentException("Pvp requires both p1 and p2.", nameof(p2));
if (p1.ViewerId == p2.ViewerId)
throw new ArgumentException("Pvp requires distinct viewer ids.", nameof(p2));
break;
case BattleType.Bot:
if (p2 is not null) throw new ArgumentException("Bot must have p2==null.", nameof(p2));
break;
case BattleType.Scripted:
// p2 currently null; future server-driven bot will populate it.
break;
default:
throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown BattleType.");
}
}
}

View File

@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Sessions;
namespace SVSim.BattleNode.Hosting;
/// <summary>
/// Registration + pipeline extensions that turn an arbitrary ASP.NET Core host into a battle
/// node. The library has no dependency on any specific host project — call both methods from
/// wherever you build your <see cref="WebApplication"/>.
/// </summary>
public static class BattleNodeExtensions
{
/// <summary>
/// Register the battle node's services in DI. All four are singletons because none of them
/// carry per-request state — per-battle state lives on the <see cref="BattleSession"/>
/// instance the WebSocket handler constructs on connect.
/// </summary>
/// <param name="configure">
/// Optional callback to override <see cref="BattleNodeOptions"/> defaults. The default
/// <c>NodeServerUrl</c> assumes the EmulatedEntrypoint host on
/// <c>http://localhost:5148</c> and shares the port for the Socket.IO endpoint. Override
/// when the node runs on a different port/host or behind a reverse proxy.
/// </param>
public static IServiceCollection AddBattleNode(this IServiceCollection services, Action<BattleNodeOptions>? configure = null)
{
var options = new BattleNodeOptions();
configure?.Invoke(options);
services.AddSingleton(options);
services.AddSingleton<IBattleSessionStore, InMemoryBattleSessionStore>();
services.AddSingleton<IMatchingBridge, MatchingBridge>();
services.AddSingleton<IWaitingRoom, WaitingRoom>();
services.AddSingleton<BattleNodeWebSocketHandler>();
return services;
}
/// <summary>
/// Wire up the WebSocket middleware and map the Socket.IO endpoint at <c>/socket.io/</c>.
/// Call this AFTER any HTTP middleware that should still see non-WS requests (auth,
/// routing, controllers) and BEFORE <c>MapControllers()</c>. The endpoint accepts any
/// path under <c>/socket.io</c>; the handler doesn't read the sub-path, so default
/// Socket.IO clients targeting <c>/socket.io/?EIO=3&amp;transport=websocket</c> work
/// without configuration.
/// </summary>
/// <remarks>
/// Steam auth gets a free pass on WS upgrades — see
/// <c>SteamSessionAuthenticationHandler</c>'s header-based bypass. The node has its own
/// per-connection auth (encrypted viewerId in the upgrade headers, validated against the
/// matched battle id in <see cref="BattleNodeWebSocketHandler.HandleAsync"/>).
/// </remarks>
public static IApplicationBuilder UseBattleNode(this IApplicationBuilder app)
{
app.UseWebSockets();
app.Map("/socket.io", branch => branch.Run(HandleSocketIoAsync));
return app;
}
/// <summary>
/// Terminal handler for <c>/socket.io/*</c> — resolves the singleton
/// <see cref="BattleNodeWebSocketHandler"/> from DI and hands the request over.
/// Extracted from the inline lambda in <see cref="UseBattleNode"/> so stack traces
/// show a real method name during WS connect failures.
/// </summary>
private static async Task HandleSocketIoAsync(Microsoft.AspNetCore.Http.HttpContext ctx)
{
var handler = ctx.RequestServices.GetRequiredService<BattleNodeWebSocketHandler>();
await handler.HandleAsync(ctx);
}
}

View File

@@ -0,0 +1,222 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Sessions;
using SVSim.BattleNode.Sessions.Participants;
using SVSim.BattleNode.Wire;
namespace SVSim.BattleNode.Hosting;
/// <summary>
/// Validates an incoming WebSocket upgrade request, accepts it, and hands off to a fresh
/// <see cref="BattleSession"/>. Singleton; no per-request state.
/// </summary>
/// <remarks>
/// <para>The validation chain — cheapest checks first, crypto only after both params are
/// present, WS accept only after the store lookup confirms the credentials match an outstanding
/// pending battle:</para>
/// <list type="number">
/// <item>Reject non-WS requests with 400 (someone hit <c>/socket.io/</c> via plain HTTP).</item>
/// <item>Read <c>BattleId</c> and encrypted <c>viewerId</c> from request headers, falling back
/// to query string. The real client puts them on headers despite BestHTTP's
/// <c>AdditionalQueryParams</c> API name — see project README §Wire-format gotchas.</item>
/// <item>Decrypt the viewerId with <see cref="NodeCrypto.DecryptForNode"/>; reject on
/// parse/decrypt failure.</item>
/// <item>Look up the <see cref="PendingBattle"/> in the store and verify the decrypted viewer
/// matches the one the <see cref="Bridge.IMatchingBridge"/> registered.</item>
/// <item>AcceptWebSocketAsync, remove the pending entry (it's now an active session), construct
/// <see cref="BattleSession"/>, await <see cref="BattleSession.RunAsync"/> until the WS
/// closes.</item>
/// </list>
/// </remarks>
public sealed class BattleNodeWebSocketHandler
{
private readonly IBattleSessionStore _store;
private readonly IWaitingRoom _waitingRoom;
private readonly BattleNodeOptions _options;
private readonly ILogger<BattleNodeWebSocketHandler> _log;
private readonly ILoggerFactory _loggerFactory;
public BattleNodeWebSocketHandler(
IBattleSessionStore store,
IWaitingRoom waitingRoom,
BattleNodeOptions options,
ILoggerFactory loggerFactory)
{
_store = store;
_waitingRoom = waitingRoom;
_options = options;
_loggerFactory = loggerFactory;
_log = loggerFactory.CreateLogger<BattleNodeWebSocketHandler>();
}
/// <summary>
/// Endpoint entry point. Sets <see cref="HttpContext.Response"/> to 400 on any validation
/// failure; otherwise upgrades to a WebSocket and awaits
/// <see cref="BattleSession.RunAsync"/> until the connection closes.
/// </summary>
public async Task HandleAsync(HttpContext ctx)
{
// Status code mapping: 400 protocol violations (not WS, missing creds);
// 401 credential validation failures (decrypt, viewer mismatch); 404 unknown
// BattleId. Log messages carry the diagnostic detail; the wire code gives the
// client class of failure.
if (!ctx.WebSockets.IsWebSocketRequest)
{
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
// BestHTTP's SocketOptions.AdditionalQueryParams puts these on HTTP request HEADERS
// for the WebSocket-only transport (not on the URL query string). Real clients
// therefore send BattleId/viewerId as headers; the integration test sends them as
// query params for convenience. Check headers first, fall back to query.
var battleId = ReadCredential(ctx, "BattleId");
var encryptedViewerId = ReadCredential(ctx, "viewerId");
if (string.IsNullOrEmpty(battleId) || string.IsNullOrEmpty(encryptedViewerId))
{
_log.LogWarning("WS upgrade missing BattleId or viewerId (header or query).");
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
long viewerId;
try
{
var plain = NodeCrypto.DecryptForNode(encryptedViewerId);
viewerId = long.Parse(plain);
}
catch (Exception ex)
{
_log.LogWarning(ex, "viewerId failed to decrypt (encryptedLen={Len})", encryptedViewerId.Length);
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
var pending = _store.TryGetPending(battleId);
if (pending is null)
{
_log.LogWarning(
"WS upgrade for unknown BattleId={Bid} (decrypted viewerId={Vid}). " +
"Bridge may not have minted this battle, or it was already consumed/expired.",
battleId, viewerId);
ctx.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
var isP1 = viewerId == pending.P1.ViewerId;
var isP2 = pending.P2 is not null && viewerId == pending.P2.ViewerId;
if (!isP1 && !isP2)
{
_log.LogWarning(
"WS upgrade viewer-id mismatch on BattleId={Bid}: bridge expected={P1}/{P2}, decrypted={Got}.",
battleId, pending.P1.ViewerId, pending.P2?.ViewerId, viewerId);
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
var ws = await ctx.WebSockets.AcceptWebSocketAsync();
switch (pending.Type)
{
case BattleType.Scripted:
{
_store.RemovePending(battleId);
var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context,
_loggerFactory.CreateLogger<RealParticipant>());
var scriptedBot = new ScriptedBotParticipant();
var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot,
_loggerFactory.CreateLogger<BattleSession>());
await session.RunAsync(ctx.RequestAborted);
break;
}
case BattleType.Pvp:
{
// Pick this connection's MatchContext (P1's if isP1, P2's if isP2).
var selfCtx = isP1 ? pending.P1.Context : pending.P2!.Context;
var self = new RealParticipant(ws, viewerId, selfCtx,
_loggerFactory.CreateLogger<RealParticipant>());
var firstArriver = _waitingRoom.Pair(battleId, self);
if (firstArriver is not null)
{
// We are the SECOND arriver. Construct and drive the session.
_store.RemovePending(battleId);
var session = new BattleSession(
battleId, BattleType.Pvp, firstArriver, self,
_loggerFactory.CreateLogger<BattleSession>());
try
{
await session.RunAsync(ctx.RequestAborted);
}
finally
{
firstArriver.MarkSessionFinished();
}
}
else
{
// We are the FIRST arriver. Park; ParkAsync returns the second arriver
// on pairing, null on timeout / cancellation / TryAdd race.
var second = await _waitingRoom.ParkAsync(
battleId, self, _options.WaitingRoomTimeout, ctx.RequestAborted);
if (second is null)
{
// Either timeout (most common) or Park/Park race. Retry Pair once.
second = _waitingRoom.Pair(battleId, self);
if (second is null)
{
_log.LogWarning(
"PvP waiting-room timeout or race on BattleId={Bid}; first arriver disconnected.",
battleId);
_store.RemovePending(battleId);
return;
}
// Retry succeeded — we're the de-facto second arriver now. Own the session.
_store.RemovePending(battleId);
var raceSession = new BattleSession(
battleId, BattleType.Pvp, second, self,
_loggerFactory.CreateLogger<BattleSession>());
try { await raceSession.RunAsync(ctx.RequestAborted); }
finally { second.MarkSessionFinished(); }
return;
}
// Normal first-arriver path: session is being constructed/driven by the
// second arriver. Hold this HTTP request open until they signal completion.
// Do NOT call self.RunAsync — the session already does.
await self.AwaitSessionFinishedAsync(ctx.RequestAborted);
}
break;
}
case BattleType.Bot:
{
// Phase 3: real (Real, NoOp) session. Bot's pending always has P2 == null
// (per IMatchingBridge contract validation), so isP1 must be true here. The
// earlier isP1/isP2 check has already rejected viewer mismatches.
_store.RemovePending(battleId);
var botReal = new RealParticipant(ws, viewerId, pending.P1.Context,
_loggerFactory.CreateLogger<RealParticipant>());
var noopBot = new NoOpBotParticipant();
var botSession = new BattleSession(battleId, BattleType.Bot, botReal, noopBot,
_loggerFactory.CreateLogger<BattleSession>());
await botSession.RunAsync(ctx.RequestAborted);
break;
}
default:
_log.LogError("Unknown BattleType={Type} for BattleId={Bid}; closing WS", pending.Type, battleId);
return;
}
}
private static string ReadCredential(HttpContext ctx, string name)
{
var header = ctx.Request.Headers[name].ToString();
if (!string.IsNullOrEmpty(header)) return header;
return ctx.Request.Query[name].ToString();
}
}

View File

@@ -0,0 +1,26 @@
using SVSim.BattleNode.Sessions.Participants;
namespace SVSim.BattleNode.Hosting;
/// <summary>
/// Per-BattleId WS rendezvous for PvP. First arriver parks; second arriver pairs.
/// The handler reads the result and either constructs the session (second arriver)
/// or awaits termination via the participant's session-finished signal (first arriver).
/// </summary>
public interface IWaitingRoom
{
/// <summary>Try to claim a previously-parked first arriver. Returns the first
/// arriver (and clears the slot) if one is parked; null if this caller is the
/// first arriver (caller should then ParkAsync).</summary>
RealParticipant? Pair(string battleId, RealParticipant self);
/// <summary>Park as the first arriver; await pairing or timeout. Returns the
/// second arriver on pairing; null on timeout / cancellation / TryAdd race.</summary>
Task<RealParticipant?> ParkAsync(string battleId, RealParticipant self,
TimeSpan timeout, CancellationToken ct);
/// <summary>Best-effort cleanup; idempotent. Called on timeout or cancellation
/// so a stale TCS doesn't linger if the first arriver disconnects before
/// pairing.</summary>
void Evict(string battleId);
}

View File

@@ -0,0 +1,59 @@
using System.Collections.Concurrent;
using SVSim.BattleNode.Sessions.Participants;
namespace SVSim.BattleNode.Hosting;
/// <summary>
/// In-process <see cref="IWaitingRoom"/>. Backed by a ConcurrentDictionary of slots
/// keyed by BattleId. Each slot holds the first arriver's RealParticipant and a
/// TaskCompletionSource that gets set when the second arriver Pairs (or cancelled
/// on timeout / abort).
/// </summary>
public sealed class WaitingRoom : IWaitingRoom
{
private readonly ConcurrentDictionary<string, Slot> _rooms = new();
public RealParticipant? Pair(string battleId, RealParticipant self)
{
if (!_rooms.TryRemove(battleId, out var slot)) return null;
// Hand `self` (second arriver) to the first arriver's ParkAsync...
slot.SecondArriverTcs.TrySetResult(self);
// ...and return the first arriver to the second arriver's handler.
return slot.FirstArriver;
}
public async Task<RealParticipant?> ParkAsync(string battleId, RealParticipant self,
TimeSpan timeout, CancellationToken ct)
{
var slot = new Slot(self);
if (!_rooms.TryAdd(battleId, slot))
{
// Race: a concurrent Park already created a slot for the same BattleId.
// The bridge mints a fresh BattleId per registration, so this is rare;
// caller can re-Pair as insurance.
return null;
}
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(timeout);
using var reg = timeoutCts.Token.Register(() => slot.SecondArriverTcs.TrySetCanceled());
try
{
return await slot.SecondArriverTcs.Task;
}
catch (OperationCanceledException)
{
Evict(battleId);
return null;
}
}
public void Evict(string battleId) => _rooms.TryRemove(battleId, out _);
private sealed class Slot
{
public RealParticipant FirstArriver { get; }
public TaskCompletionSource<RealParticipant> SecondArriverTcs { get; } =
new(TaskCreationOptions.RunContinuationsAsynchronously);
public Slot(RealParticipant first) => FirstArriver = first;
}
}

View File

@@ -0,0 +1,183 @@
using System.Collections.Immutable;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
namespace SVSim.BattleNode.Lifecycle;
/// <summary>
/// v1 hand-rolled scripted opponent. Static frame builders for the five lifecycle uris
/// (Matched / BattleStart / Deal / Swap response / Ready) plus a trivial opponent TurnStart
/// the dispatch pushes after the player's TurnEnd. The values are templated from the TK2
/// captures at <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c> — anything
/// hardcoded here came from a real prod frame, with names + provenance in
/// <see cref="ScriptedProfiles"/>. The player-half of Matched/BattleStart now reads from
/// <see cref="MatchContext"/> instead of <see cref="ScriptedProfiles"/>.
/// </summary>
public static class ScriptedLifecycle
{
/// <summary>
/// Viewer id we present as the opponent on every scripted push. Out-of-range vs. real
/// viewer ids so it can't collide with a real account in the auth pipeline.
/// </summary>
public const long FakeOpponentViewerId = 999_999_999L;
public static MsgEnvelope BuildMatched(
MatchContext selfCtx, MatchContext oppoCtx,
long selfViewerId, long oppoViewerId,
string battleId, long seed) =>
EnvelopeForPush(NetworkBattleUri.Matched,
new MatchedBody(
SelfInfo: new MatchedSelfInfo(
CountryCode: selfCtx.CountryCode,
UserName: selfCtx.UserName,
SleeveId: selfCtx.SleeveId,
EmblemId: selfCtx.EmblemId,
DegreeId: selfCtx.DegreeId,
FieldId: selfCtx.FieldId,
IsOfficial: selfCtx.IsOfficial,
OppoId: oppoViewerId,
Seed: seed),
OppoInfo: new MatchedOppoInfo(
CountryCode: oppoCtx.CountryCode,
UserName: oppoCtx.UserName,
SleeveId: oppoCtx.SleeveId,
EmblemId: oppoCtx.EmblemId,
DegreeId: oppoCtx.DegreeId,
FieldId: oppoCtx.FieldId,
IsOfficial: oppoCtx.IsOfficial,
OppoId: selfViewerId,
Seed: seed,
OppoDeckCount: oppoCtx.SelfDeckCardIds.Count),
SelfDeck: BuildPlayerDeck(selfCtx.SelfDeckCardIds)),
bid: battleId);
public static MsgEnvelope BuildBattleStart(
MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId) =>
EnvelopeForPush(NetworkBattleUri.BattleStart,
new BattleStartBody(
TurnState: 0, // player goes first
BattleType: selfCtx.BattleType,
SelfInfo: new BattleStartSelfInfo(
Rank: ScriptedProfiles.PlayerRank,
BattlePoint: ScriptedProfiles.PlayerBattlePoint,
ClassId: selfCtx.ClassId,
CharaId: selfCtx.CharaId,
CardMasterName: selfCtx.CardMasterName),
OppoInfo: new BattleStartOppoInfo(
// Rank/IsMasterRank/BattlePoint/MasterPoint stay hardcoded —
// PvP rank tracking is deferred (per spec § Out of scope).
Rank: "1",
IsMasterRank: "0",
BattlePoint: 0,
MasterPoint: "0",
ClassId: oppoCtx.ClassId,
CharaId: oppoCtx.CharaId,
CardMasterName: oppoCtx.CardMasterName)));
public static MsgEnvelope BuildDeal() =>
EnvelopeForPush(NetworkBattleUri.Deal,
new DealBody(
Self: new[] { new PosIdx(0, 1), new PosIdx(1, 2), new PosIdx(2, 3) },
Oppo: new[] { new PosIdx(0, 1), new PosIdx(1, 2), new PosIdx(2, 3) }));
/// <summary>
/// Initial 3-card hand idxs from <see cref="BuildDeal"/>. Each position in this array
/// is one card; the value is the card's deck idx. <see cref="ImmutableArray{T}"/> enforces
/// the "read-only constant" contract at the type level — callers cannot mutate it, even
/// accidentally (the prior <c>long[]</c> allowed in-place modification by anyone with the
/// field reference).
/// </summary>
private static readonly ImmutableArray<long> InitialHand = ImmutableArray.Create<long>(1, 2, 3);
/// <summary>
/// Compute the player's hand after a mulligan. For every idx in <paramref name="swapIndices"/>
/// that is currently in the hand, replace it with the next unused deck idx (starting at 4,
/// since 1..3 were dealt). Positions of kept cards are preserved.
/// </summary>
public static long[] ComputeHandAfterSwap(IReadOnlyList<long> swapIndices)
{
var hand = InitialHand.ToArray();
var nextDeckIdx = 4L;
for (var pos = 0; pos < hand.Length; pos++)
{
if (swapIndices.Contains(hand[pos]))
{
hand[pos] = nextDeckIdx++;
}
}
return hand;
}
public static MsgEnvelope BuildSwapResponse(IReadOnlyList<long> hand) =>
EnvelopeForPush(NetworkBattleUri.Swap,
new SwapResponseBody(Self: BuildPosIdxList(hand)));
public static MsgEnvelope BuildReady(IReadOnlyList<long> hand) =>
EnvelopeForPush(NetworkBattleUri.Ready,
new ReadyBody(
Self: BuildPosIdxList(hand),
Oppo: BuildPosIdxList(InitialHand),
IdxChangeSeed: ScriptedProfiles.ReadyIdxChangeSeed,
Spin: ScriptedProfiles.ReadySpin));
/// <summary>
/// First half of the v1.1 scripted opponent turn cycle: pushed after the player's
/// TurnEnd, transitions the client into "Opponent's turn…" state. Paired with
/// <see cref="BuildOpponentTurnEnd"/>, which immediately follows and hands control
/// back to the player.
/// </summary>
public static MsgEnvelope BuildOpponentTurnStart() =>
EnvelopeForPush(NetworkBattleUri.TurnStart,
new OpponentTurnStartBody(Spin: ScriptedProfiles.OpponentTurnStartSpin));
/// <summary>
/// Server-pushed TurnEnd transition that closes the opponent's turn and hands control
/// back to the player. Paired with <see cref="BuildOpponentTurnStart"/> in the v1.1 loop.
/// Wire shape from prod capture battle-traffic_tk2_regular.ndjson L18:
/// <c>{"uri":"TurnEnd","turnState":0,"resultCode":1,"playSeq":N}</c>.
/// </summary>
public static MsgEnvelope BuildOpponentTurnEnd() =>
EnvelopeForPush(NetworkBattleUri.TurnEnd, new TurnEndBody(TurnState: 0));
/// <summary>
/// Server-pushed Judge frame that follows the opponent's TurnEnd and unblocks the
/// client's <c>JudgeOperation</c> → <c>ControlTurnStartPlayer</c>, transitioning to the
/// player's next turn. Without this frame the client hangs on "Opponent's turn…" —
/// see <c>data_dumps/captures/battle-traffic.ndjson</c> line 14 (client emits its own
/// Judge then waits forever).
/// </summary>
public static MsgEnvelope BuildOpponentJudge() =>
EnvelopeForPush(NetworkBattleUri.Judge, new JudgeBody(Spin: ScriptedProfiles.OpponentJudgeSpin));
private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand)
{
var list = new List<PosIdx>(hand.Count);
for (var pos = 0; pos < hand.Count; pos++)
{
list.Add(new PosIdx(Pos: pos, Idx: (int)hand[pos]));
}
return list;
}
private static IReadOnlyList<DeckCardRef> BuildPlayerDeck(IReadOnlyList<long> cardIds)
{
var deck = new List<DeckCardRef>(cardIds.Count);
for (var i = 0; i < cardIds.Count; i++)
{
deck.Add(new DeckCardRef(Idx: i + 1, CardId: cardIds[i]));
}
return deck;
}
private static MsgEnvelope EnvelopeForPush(NetworkBattleUri uri, IMsgBody body, string? bid = null) =>
new(uri,
ViewerId: FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: bid,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: body);
}

View File

@@ -0,0 +1,61 @@
using SVSim.BattleNode.Protocol.Bodies;
namespace SVSim.BattleNode.Lifecycle;
/// <summary>
/// Named constants and templates for the v1 scripted lifecycle. Every value here
/// originated in a real prod frame in
/// <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c>; pulling them out
/// of <see cref="ScriptedLifecycle"/> makes the magic numerics navigable and gives
/// the seed a single source of truth instead of two duplicated literals.
/// </summary>
internal static class ScriptedProfiles
{
// Shared per the spec — selfInfo.seed and oppoInfo.seed always agree.
// From frame[2] (Matched).
public const long BattleSeed = 17_548_138L;
public static readonly MatchedOppoInfo OpponentMatchedProfile = new(
CountryCode: "JPN",
UserName: "Opponent",
SleeveId: "704141010",
EmblemId: "400001100",
DegreeId: "120027",
FieldId: 5,
IsOfficial: 0,
OppoId: 0,
Seed: BattleSeed,
OppoDeckCount: 30);
// From frame[5] (BattleStart). Hardcoded; see spec §Deferred plumbing — sourcing these
// from real per-viewer state needs a TK2 rank/battle-point tracker.
public const string PlayerRank = "10";
public const string PlayerBattlePoint = "6270";
public static readonly BattleStartOppoInfo OpponentBattleStartProfile = new(
Rank: "1",
IsMasterRank: "0",
BattlePoint: 0,
MasterPoint: "0",
ClassId: "8",
CharaId: "8",
CardMasterName: "card_master_node_10015");
// From frame[8] (Ready). Provenance is "what prod sent"; the client
// doesn't validate, but echoing matches the capture protects against
// a regression on a future tightening.
public const int ReadyIdxChangeSeed = 771_335_280;
public const int ReadySpin = 243;
// Generic non-zero spin that lands the client in "Opponent's turn..."
// display state. v1 doesn't simulate the opponent — once this lands,
// the client sits there indefinitely.
public const int OpponentTurnStartSpin = 100;
/// <summary>
/// Server-pushed Judge frame spin value. Prod varies per push (55, 175, 73, ...) — it's
/// an animation seed, not a stateful value. Fixed at 100 here for test stability;
/// the client's <c>JudgeOperation</c> doesn't read it.
/// </summary>
public const int OpponentJudgeSpin = 100;
}

View File

@@ -0,0 +1,19 @@
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// Wire value of <c>result</c> on a BattleFinish frame. The client's
/// <c>BattleFinishResponsProcessing</c> switch maps these as:
/// 0 → LOSE, 1 → WIN, 2 → CONSISTENCY (desync / action-list mismatch).
/// </summary>
/// <remarks>
/// This is NOT the same as the client's in-memory <c>BATTLE_RESULT_TYPE</c> enum
/// (NONE=0, WIN=1, LOSE=2, CONSISTENCY=3) — the wire codes shift LOSE down to 0.
/// Always serialize as the int value, not the name; see the
/// <c>JsonNumberEnumConverter</c> on <see cref="Bodies.BattleFinishBody.Result"/>.
/// </remarks>
public enum BattleResult
{
Lose = 0,
Win = 1,
Consistency = 2,
}

View File

@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record AlivePushBody(
[property: JsonPropertyName("scs")] string Scs,
[property: JsonPropertyName("ocs")] string Ocs) : IMsgBody;

View File

@@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record BattleFinishBody(
[property: JsonPropertyName("result")]
[property: JsonConverter(typeof(JsonNumberEnumConverter<BattleResult>))]
BattleResult Result,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;

View File

@@ -0,0 +1,28 @@
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record BattleStartBody(
[property: JsonPropertyName("turnState")] int TurnState,
[property: JsonPropertyName("battleType")] int BattleType,
[property: JsonPropertyName("selfInfo")] BattleStartSelfInfo SelfInfo,
[property: JsonPropertyName("oppoInfo")] BattleStartOppoInfo OppoInfo,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
public sealed record BattleStartSelfInfo(
[property: JsonPropertyName("rank")] string Rank,
[property: JsonPropertyName("battlePoint")] string BattlePoint,
[property: JsonPropertyName("classId")] string ClassId,
[property: JsonPropertyName("charaId")] string CharaId,
[property: JsonPropertyName("cardMasterName")] string CardMasterName);
// Note: BattlePoint is int on the wire here (not string as on self) — matches the
// captured prod frame at data_dumps/captures/battle-traffic_tk2_regular.ndjson.
public sealed record BattleStartOppoInfo(
[property: JsonPropertyName("rank")] string Rank,
[property: JsonPropertyName("isMasterRank")] string IsMasterRank,
[property: JsonPropertyName("battlePoint")] int BattlePoint,
[property: JsonPropertyName("masterPoint")] string MasterPoint,
[property: JsonPropertyName("classId")] string ClassId,
[property: JsonPropertyName("charaId")] string CharaId,
[property: JsonPropertyName("cardMasterName")] string CardMasterName);

View File

@@ -0,0 +1,8 @@
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record DealBody(
[property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self,
[property: JsonPropertyName("oppo")] IReadOnlyList<PosIdx> Oppo,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;

View File

@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record JudgeBody(
[property: JsonPropertyName("spin")] int Spin,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;

View File

@@ -0,0 +1,36 @@
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record MatchedBody(
[property: JsonPropertyName("selfInfo")] MatchedSelfInfo SelfInfo,
[property: JsonPropertyName("oppoInfo")] MatchedOppoInfo OppoInfo,
[property: JsonPropertyName("selfDeck")] IReadOnlyList<DeckCardRef> SelfDeck,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;
public sealed record MatchedSelfInfo(
[property: JsonPropertyName("country_code")] string CountryCode,
[property: JsonPropertyName("userName")] string UserName,
[property: JsonPropertyName("sleeveId")] string SleeveId,
[property: JsonPropertyName("emblemId")] string EmblemId,
[property: JsonPropertyName("degreeId")] string DegreeId,
[property: JsonPropertyName("fieldId")] int FieldId,
[property: JsonPropertyName("isOfficial")] int IsOfficial,
[property: JsonPropertyName("oppoId")] long OppoId,
[property: JsonPropertyName("seed")] long Seed);
public sealed record MatchedOppoInfo(
[property: JsonPropertyName("country_code")] string CountryCode,
[property: JsonPropertyName("userName")] string UserName,
[property: JsonPropertyName("sleeveId")] string SleeveId,
[property: JsonPropertyName("emblemId")] string EmblemId,
[property: JsonPropertyName("degreeId")] string DegreeId,
[property: JsonPropertyName("fieldId")] int FieldId,
[property: JsonPropertyName("isOfficial")] int IsOfficial,
[property: JsonPropertyName("oppoId")] long OppoId,
[property: JsonPropertyName("seed")] long Seed,
[property: JsonPropertyName("oppoDeckCount")] int OppoDeckCount);
public sealed record DeckCardRef(
[property: JsonPropertyName("idx")] int Idx,
[property: JsonPropertyName("cardId")] long CardId);

View File

@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record OpponentTurnStartBody(
[property: JsonPropertyName("spin")] int Spin,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;

View File

@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record PosIdx(
[property: JsonPropertyName("pos")] int Pos,
[property: JsonPropertyName("idx")] int Idx);

View File

@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record ReadyBody(
[property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self,
[property: JsonPropertyName("oppo")] IReadOnlyList<PosIdx> Oppo,
[property: JsonPropertyName("idxChangeSeed")] int IdxChangeSeed,
[property: JsonPropertyName("spin")] int Spin,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;

View File

@@ -0,0 +1,6 @@
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record ResultCodeOnlyBody(
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;

View File

@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record SwapResponseBody(
[property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;

View File

@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record TurnEndBody(
[property: JsonPropertyName("turnState")] int TurnState,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody;

View File

@@ -0,0 +1,11 @@
namespace SVSim.BattleNode.Protocol;
/// <summary>The "cat" field on the msg envelope.</summary>
public enum EmitCategory
{
Battle = 1,
Matching = 2,
Room = 3,
Watch = 11,
General = 99,
}

View File

@@ -0,0 +1,10 @@
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// Marker for every type that can appear as <see cref="MsgEnvelope.Body"/>.
/// Implementers fall into two camps: typed records used on the outbound path
/// (one per scripted frame shape) and <see cref="RawBody"/> used on the inbound
/// path. The marker exists so the envelope can carry either without falling
/// back to <c>object</c>.
/// </summary>
public interface IMsgBody { }

View File

@@ -0,0 +1,175 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// The shared envelope on every encrypted msg / synchronize frame. Body is
/// <see cref="IMsgBody"/> — either a typed body record (outbound) or a
/// <see cref="RawBody"/> (inbound).
/// </summary>
public sealed record MsgEnvelope(
NetworkBattleUri Uri,
long ViewerId,
string Uuid,
string? Bid,
int Try,
EmitCategory Cat,
long? PubSeq,
long? PlaySeq,
IMsgBody Body)
{
private static readonly JsonSerializerOptions Options = CreateOptions();
private static readonly HashSet<string> ReservedEnvelopeKeys = new()
{
"uri", "viewerId", "uuid", "bid", "try", "cat", "pubSeq", "playSeq",
};
private static JsonSerializerOptions CreateOptions()
{
var opt = new JsonSerializerOptions
{
// Wire-key casing is bare camelCase via per-field [JsonPropertyName] —
// NOT EmulatedEntrypoint's snake_case policy. The naming-policy line
// that was here previously was dead code (every wire key is explicit).
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
opt.Converters.Add(new JsonStringEnumConverter());
return opt;
}
public static string ToJson(MsgEnvelope env)
{
// Envelope fields MUST come before body fields on the wire. The client's
// RealTimeNetworkAgent.SetNetworkInfo iterates the dict in insertion order and
// clears _selfDeck on the "uri" key (via GameMgr.InitializeSelfInfo). Any body
// field processed before "uri" is wiped before Matching.StartBattleLoad reads
// it back. The prod wire emits envelope keys first; we must too.
var result = new JsonObject();
result["uri"] = env.Uri.ToString();
result["viewerId"] = env.ViewerId;
result["uuid"] = env.Uuid;
result["try"] = env.Try;
result["cat"] = (int)env.Cat;
if (env.Bid is not null) result["bid"] = env.Bid;
if (env.PubSeq.HasValue) result["pubSeq"] = env.PubSeq.Value;
if (env.PlaySeq.HasValue) result["playSeq"] = env.PlaySeq.Value;
if (env.Body is RawBody raw)
{
// Inbound-echo path: flatten Entries to top-level keys.
foreach (var (k, v) in raw.Entries)
{
if (ReservedEnvelopeKeys.Contains(k))
throw new ArgumentException(
$"RawBody key '{k}' collides with a reserved envelope field. " +
$"Move it to a typed field on MsgEnvelope.",
nameof(env));
result[k] = ToJsonNode(v);
}
}
else
{
// Typed body: serialize via [JsonPropertyName] attributes on the record,
// then layer each field onto `result` after the envelope keys. DeepClone
// because S.T.Json JsonNodes can only have one parent; reassigning a node
// owned by `bodyNode` to `result` would throw without the clone.
var bodyNode = (JsonObject)JsonSerializer.SerializeToNode(env.Body, env.Body.GetType(), Options)!;
foreach (var prop in bodyNode)
{
result[prop.Key] = prop.Value?.DeepClone();
}
}
return result.ToJsonString(Options);
}
/// <summary>
/// Convert a boxed CLR value (as stored in <see cref="RawBody.Entries"/>) to a JsonNode.
/// Explicit type switch on the runtime type — `JsonValue.Create(object?)` would create
/// a `JsonValueCustomized&lt;object&gt;` that requires a TypeInfoResolver at serialize time
/// (introduced in S.T.Json 8.0 source-gen mode).
/// </summary>
private static JsonNode? ToJsonNode(object? value) => value switch
{
null => null,
string s => JsonValue.Create(s),
bool b => JsonValue.Create(b),
long l => JsonValue.Create(l),
int i => JsonValue.Create(i),
double d => JsonValue.Create(d),
decimal m => JsonValue.Create(m),
// Inbound-parsed nested objects come through as Dictionary<string, object?>; nested
// arrays as List<object?>. FromJson is the source of these shapes — see ToObject.
IDictionary<string, object?> dict => DictToJsonObject(dict),
IReadOnlyList<object?> list => ListToJsonArray(list),
_ => throw new InvalidOperationException(
$"RawBody contains a value of unsupported type {value.GetType().FullName}. " +
"Only primitives, nested dicts (object), and nested lists are recognized."),
};
private static JsonObject DictToJsonObject(IDictionary<string, object?> dict)
{
var obj = new JsonObject();
foreach (var (k, v) in dict) obj[k] = ToJsonNode(v);
return obj;
}
private static JsonArray ListToJsonArray(IReadOnlyList<object?> list)
{
var arr = new JsonArray();
foreach (var v in list) arr.Add(ToJsonNode(v));
return arr;
}
public static MsgEnvelope FromJson(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var uri = Enum.Parse<NetworkBattleUri>(root.GetProperty("uri").GetString()!);
var viewerId = root.GetProperty("viewerId").GetInt64();
var uuid = root.GetProperty("uuid").GetString()!;
var bid = root.TryGetProperty("bid", out var bidEl) ? bidEl.GetString() : null;
var @try = root.TryGetProperty("try", out var tryEl) ? tryEl.GetInt32() : 0;
var cat = root.TryGetProperty("cat", out var catEl) ? (EmitCategory)catEl.GetInt32() : EmitCategory.Battle;
var pubSeq = root.TryGetProperty("pubSeq", out var psEl) ? psEl.GetInt64() : (long?)null;
var playSeq = root.TryGetProperty("playSeq", out var plsEl) ? plsEl.GetInt64() : (long?)null;
var bodyDict = new Dictionary<string, object?>();
foreach (var prop in root.EnumerateObject())
{
if (ReservedEnvelopeKeys.Contains(prop.Name)) continue;
bodyDict[prop.Name] = ToObject(prop.Value);
}
return new MsgEnvelope(uri, viewerId, uuid, bid, @try, cat, pubSeq, playSeq, new RawBody(bodyDict));
}
private static object? ToObject(JsonElement el) => el.ValueKind switch
{
JsonValueKind.String => el.GetString(),
// Extracted to a helper because writing the conditional inline as
// el.TryGetInt64(out var l) ? l : el.GetDouble()
// unifies the conditional's branches to the common implicit-convertible type. long→double
// is implicit; so the result type collapses to double and the long value silently widens.
// Downstream OfType<long> filters then drop the (now boxed-double) entries, which broke
// the mulligan idxList extraction. Separate method returns object explicitly so each
// branch boxes its own runtime type.
JsonValueKind.Number => ParseNumber(el),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
JsonValueKind.Array => el.EnumerateArray().Select(ToObject).ToList(),
JsonValueKind.Object => el.EnumerateObject().ToDictionary(p => p.Name, p => ToObject(p.Value)),
_ => el.GetRawText(),
};
private static object ParseNumber(JsonElement el)
{
if (el.TryGetInt64(out var l)) return l;
return el.GetDouble();
}
}

View File

@@ -0,0 +1,26 @@
using MessagePack;
using SVSim.BattleNode.Wire;
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// Full chain between an envelope and the bytes that ride as a SocketIO binary attachment.
/// Inbound: bytes → msgpack-string → NodeCrypto.Decrypt → JSON → MsgEnvelope
/// Outbound: MsgEnvelope → JSON → NodeCrypto.Encrypt → msgpack-bytes
/// </summary>
public static class MsgPayloadCodec
{
public static MsgEnvelope Decode(byte[] msgpackBytes)
{
var encryptedString = MessagePackSerializer.Deserialize<string>(msgpackBytes);
var json = NodeCrypto.DecryptForNode(encryptedString);
return MsgEnvelope.FromJson(json);
}
public static byte[] Encode(MsgEnvelope envelope, string key)
{
var json = MsgEnvelope.ToJson(envelope);
var encryptedString = NodeCrypto.EncryptForNode(json, key);
return MessagePackSerializer.Serialize(encryptedString);
}
}

View File

@@ -0,0 +1,46 @@
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// Discriminator for every msg/synchronize envelope. Wire form is the bare member name
/// (case-sensitive). See docs/api-spec/in-battle/enums.md.
/// </summary>
public enum NetworkBattleUri
{
None,
Resume,
Retry,
InitNetwork,
InitBattle,
InitRoomBattle,
Matched,
Loaded,
Deal,
Swap,
Ready,
TurnStart,
TurnEndActions,
TurnEnd,
TurnEndFinal,
PlayActions,
BattleStart,
BattleFinish,
ChatStamp,
Gungnir,
Echo,
Retire,
OppoDisconnect,
End,
Judge,
Touch,
SelectSkill,
SelectObject,
SlideObject,
TurnEndReady,
RecoveryStart,
RecoveryEnd,
JudgeResult,
Maintenance,
ReplayFinish,
Kill,
Watch,
}

View File

@@ -0,0 +1,16 @@
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// Wraps a parsed-dictionary body for the inbound path. <see cref="MsgEnvelope.FromJson"/>
/// returns this; <see cref="MsgEnvelope.ToJson"/> flattens <see cref="Entries"/> back to
/// top-level keys when echoing.
/// </summary>
public sealed class RawBody : IMsgBody
{
public Dictionary<string, object?> Entries { get; }
public RawBody(Dictionary<string, object?> entries)
{
Entries = entries;
}
}

View File

@@ -0,0 +1,41 @@
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// The "resultCode" field on synchronize pushes. 1 = Success, else error.
/// Mirrors the full catalog from docs/api-spec/in-battle/enums.md, including
/// source typos in the original spec (RoomBattleReadeError, RoomTornament*).
/// </summary>
public enum ReceiveNodeResultCode
{
None = 0,
Success = 1,
Different_UUID = 30001,
RedisReplyError = 30002,
UnexistUserinfoError = 30003,
RoomStatusInfoError = 30101,
RoomCreateError = 30102,
RoomEntryError = 30103,
RoomKickError = 30104,
RoomLeaveError = 30105,
RoomReleaseError = 30106,
RoomForceReleaseError = 30107,
RoomReenterError = 30108,
RoomBattleReadeError = 30109, // source typo per spec, preserved
RoomTournamentDeckError = 30110,
RoomTournamentError = 30111,
RoomSetupLock = 30112,
MatchingTimeOut = 30201,
UnmatchedError = 30211,
CurrentBattleError = 30212,
UnexpectedPhaseError = 30213,
WatchError = 30302,
SwapTimeoutError = 31001,
FoundRemovedUserErrorSelf = 32101,
FoundRemovedUserErrorOppo = 32102,
FoundRemovedUserErrorWatcher = 32103,
RoomTimeEndError = 32104,
WatcherInRemovedOwnerRoomError = 32105,
RoomTornamentOwnTimeEndError = 32106, // source typo per spec, preserved
RoomTornamentOppoTimeEndError = 32107, // source typo per spec, preserved
BattleFinishTimeEnd = 32108,
}

View File

@@ -0,0 +1,27 @@
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// String constants that show up on the wire as opaque tags. Lifting them out of
/// inline string literals gives each one a single source of truth and a name that
/// reads at the use site.
/// </summary>
internal static class WireConstants
{
/// <summary>SIO event name for ordered server-pushed frames (the lifecycle channel).</summary>
public const string SynchronizeEvent = "synchronize";
/// <summary>SIO event name for client-emitted msg frames + their ack-responses.</summary>
public const string MsgEvent = "msg";
/// <summary>SIO event name for Gungnir keepalive frames (both directions).</summary>
public const string AliveEvent = "alive";
/// <summary>
/// Placeholder UUID we stamp on every server-originated envelope. Prod servers stamp a
/// real per-request UUID; the client doesn't validate it.
/// </summary>
public const string ServerUuid = "node-stub";
/// <summary>Gungnir scs/ocs value the v1 server reports unconditionally.</summary>
public const string OnlineStatus = "ONLINE";
}

142
SVSim.BattleNode/README.md Normal file
View File

@@ -0,0 +1,142 @@
# SVSim.BattleNode
Socket.IO node-server scaffolding for in-battle traffic. Implements the second of the prod 4-server topology — the realtime channel that handles `Matched` / `BattleStart` / `Deal` / per-action `PlayActions` / `Echo` / `TurnEnd` between the client and a server-side opponent.
**v1 scope** is "scripted thin sequencer": the server accepts a connection, walks a hand-rolled lifecycle from `InitNetwork` to mulligan + first turn + opponent TurnStart, then sits at the opponent's-turn screen indefinitely. No real opponent, no `battleCode` validation, no recovery. v2 work targets each of those.
The library has **no dependency on `SVSim.EmulatedEntrypoint`**. It exposes one DI seam (`IMatchingBridge`) and one ASP.NET Core integration surface (`AddBattleNode` / `UseBattleNode`). Pulling the node into a separate process later is one interface and one Kestrel binding.
## Architecture
```
SVSim.BattleNode/
├─ Bridge/ IMatchingBridge — what /do_matching calls to mint a battle id + node URL
├─ Hosting/ ASP.NET Core extensions + the /socket.io/ endpoint handler
├─ Lifecycle/ ScriptedLifecycle — the v1 hand-rolled Matched/BattleStart/Deal/Swap/Ready frames
├─ Protocol/ MsgEnvelope, NetworkBattleUri enum, msgpack ↔ envelope codec
├─ Reliability/ InboundTracker (pubSeq dedup), OutboundSequencer (playSeq archive), Gungnir
├─ Sessions/ BattleSession (per-connection state + WS pump), IBattleSessionStore
└─ Wire/ EIO3 framing, SIO2 framing, NodeCrypto (AES-256-CBC)
```
## Connect handshake (verified end-to-end against the real client)
```
┌────────┐ ┌────────────┐
│ Client │ │ BattleNode │
└────┬───┘ └──────┬─────┘
│ │
│ HTTP POST /arena_two_pick_battle/do_matching │ (HTTP host)
├──────────────────────────────────────────────────────────────►│
│ ◄── { matching_state:3004, battle_id, node_server_url, │
│ card_master_id, ... } │
│ │
│ WS upgrade ws://<node>/socket.io/ │
│ headers: BattleId, viewerId=encryptForNode(uid) │
├──────────────────────────────────────────────────────────────►│ AcceptWebSocketAsync
│ ◄── EIO3 Open 0{sid,upgrades:[],pingInterval,pingTimeout} │
│ │
│ msg: InitNetwork (cat=99/general) │
├──────────────────────────────────────────────────────────────►│
│ ◄── synchronize: InitNetwork{resultCode:1} │
│ │
│ MatchingInitBattle: status=Connect; subscribe receiver │
│ msg: InitBattle (cat=2/matching) │
├──────────────────────────────────────────────────────────────►│
│ ◄── synchronize: Matched{selfInfo,oppoInfo,selfDeck,bid} │
│ │
│ client loads decks/scene │
│ msg: Loaded │
├──────────────────────────────────────────────────────────────►│
│ ◄── synchronize: BattleStart{turnState,battleType,...} │
│ ◄── synchronize: Deal{self,oppo} │
│ │
│ mulligan UI; player chooses cards to swap │
│ msg: Swap{idxList:[...]} │
├──────────────────────────────────────────────────────────────►│
│ ◄── synchronize: Swap{self:[post-mulligan hand]} │
│ ◄── synchronize: Ready{self,oppo,idxChangeSeed,spin} │
│ │
│ turn 1: TurnStart, PlayActions, ..., TurnEnd │
├──────────────────────────────────────────────────────────────►│
│ ◄── synchronize: TurnStart{spin} (opponent turn signal) │
│ │
│ sits at "Opponent's turn…" — v1 stopping point │
```
Each push from us carries a contiguous `playSeq`; client-emit `pubSeq` is echoed back via the Socket.IO ack callback. `Gungnir` runs a 5s alive heartbeat in parallel reporting `scs:ONLINE,ocs:ONLINE`.
## Wire-format gotchas (discovered during v1 smoke)
These are not in the original protocol docs and tripped us during the smoke walkthrough — leaving them here so the next reader doesn't repeat the diagnosis.
| Spec said | Actual wire | Where it shows up |
|---|---|---|
| `AdditionalQueryParams` on the WS upgrade | **HTTP request headers**, not query string. BestHTTP misnames the API. | `BattleNodeWebSocketHandler.ReadCredential` reads `BattleId` / `viewerId` from headers first, query as fallback (for tests). |
| `node_server_url` ws://host:port | `host:port/socket.io/`**no scheme prefix**, **path included**. | `BattleNodeOptions.NodeServerUrl` default + `do_matching` response. |
| `card_master_id` optional | **Required** when `matching_state ∈ {3004,3007,3011}` — no `Keys.Contains` guard client-side. | Added to `DoMatchingResponseDto` with default `1`. |
| `resultCode` optional on pushes | **Required = 1** on every scripted synchronize frame; missing means "drop in error handler". | `ScriptedLifecycle.EnvelopeForPush` injects it. |
| Matched in response to InitNetwork | **InitBattle**. Matched in response to InitNetwork lands before the client's matching handler is subscribed and silently drops. | See dispatch in `BattleSession.ComputeResponses`. |
| WS binary frames carry raw msgpack | EIO3 prefixes binary frames with `0x04` (Message type byte), same as the leading digit on text frames. | `BattleSession.RunAsync` strips on read; `EncodeAndSendAsync` prepends on send. |
There's also a JSON parsing pitfall worth knowing about (and that broke the mulligan): the inline conditional `el.TryGetInt64(out var l) ? l : el.GetDouble()` unifies its branches to the common implicit-convertible type. Since `long → double` is implicit, the long silently widens to double, and `OfType<long>` downstream drops every entry. See `MsgEnvelope.ParseNumber` for the fix — keep number parsing in a separate method so each branch boxes its own runtime type.
## v1 scripted opponent — what the client sees
The player half of `Matched` / `BattleStart` reads from a `MatchContext` assembled in
`SVSim.EmulatedEntrypoint/Services/MatchContextBuilder` from the viewer's TK2 run + equipped
cosmetics + config — so the mulligan renders the real drafted deck, drafted class/leader,
and equipped emblem/degree. The opponent half stays scripted in `ScriptedProfiles`:
- **Opponent** is a fixed silhouette: `classId="8"`, JPN sleeve/emblem/degree, viewer id `999999999`.
- **Battle seed** is `17548138L` in both info blocks (the seed is *shared* per battle per the spec).
- **Mulligan** does real card replacement: any idx in your `idxList` is swapped for the next unused deck idx (`1..3` dealt, so `4..30` are pool).
- **Opponent's turn** never actually does anything — we push a single `TurnStart{spin:100}` after your `TurnEnd` so the UI transitions to the opponent-turn display, then sit.
A few player-side fields are still hardcoded pending a follow-up slice — `Rank`, `BattlePoint`,
`cardMasterName`, `fieldId`, and the per-battle RNG seed. See the spec's §Deferred plumbing
table at `docs/superpowers/specs/2026-06-01-battle-node-real-drafted-deck-design.md` for
what each needs.
## Where to extend
| You want to | Touch |
|---|---|
| Wire a new mode's `do_matching` (rank, free, open-room, …) | Add one `BuildFor<Mode>Async(viewerId, …)` method to `IMatchContextBuilder` reading that mode's deck source; the mode's controller calls `IMatchingBridge.RegisterPendingBattle(vid, ctx)`. No changes to `SVSim.BattleNode`. |
| Add a real AI opponent | Replace the static dispatch in `BattleSession.ComputeResponses` (`TurnEnd → opponent TurnStart` case) with one that drives a decision engine. The `OutboundSequencer` already assigns `playSeq` for whatever you push. |
| Implement recovery | `IBattleSessionStore` already keeps the pending registry. Add a per-battle archive (the `OutboundSequencer.Archive` already retains every assigned-playSeq push) and bind it to the HTTP `/battle/get_recovery_params` endpoint. |
| Validate `battleCode` | Port `NetworkConsistency.GetConsistency` from the client decompilation. Hook into `BattleSession.HandleMsgEventAsync` on `TurnEnd` / `Judge`. |
| Type the `orderList` register actions | Spec at `docs/api-spec/in-battle/register-actions.md` catalogs the eight shapes observed in TK2 captures. Build a discriminated union; replace `Dictionary<string, object?>` in the `Body` for the relevant URIs. |
## Test layout
```
SVSim.UnitTests/BattleNode/
├─ Bridge/ MatchingBridgeTests (3 tests — mint id, dedup, format)
├─ Integration/ BattleNodeFlowTests (end-to-end via WebApplicationFactory)
│ RawSocketIoTestClient (test helper)
├─ Lifecycle/ ScriptedLifecycleTests (11 tests)
├─ Protocol/ MsgEnvelopeTests (4 tests incl. number-array regression)
│ MsgPayloadCodecTests (2 tests — roundtrip + known vector)
├─ Reliability/ GungnirTests / InboundTrackerTests / OutboundSequencerTests
├─ Sessions/ BattleSessionDispatchTests (8 tests — phase-state machine)
│ InMemoryBattleSessionStoreTests
└─ Wire/ NodeCryptoTests (with fixed-vector regression)
EngineIoFrameTests
SocketIoFrameTests (incl. binary attachment + JSON escaping)
```
Total ~71 BattleNode-scoped tests. The integration test boots the EmulatedEntrypoint host via `SVSimTestFactory`, mints a battle through `IMatchingBridge`, opens a TestServer WebSocket, and walks the full handshake through Ready. It exercises every layer.
## Related docs
- `docs/api-spec/in-battle/transport.md` — Socket.IO + AES-for-node wire format, with smoke corrections inline.
- `docs/api-spec/in-battle/matching.md``do_matching` bridge + client state machine.
- `docs/api-spec/in-battle/server-to-client.md`, `client-to-server.md` — per-uri frame shapes.
- `docs/api-spec/in-battle/register-actions.md``orderList` action catalog (for v2).
- `docs/api-spec/in-battle/reliability.md` — pubSeq/playSeq stocking + Gungnir.
- `docs/api-spec/in-battle/recovery.md` — the reconnect handshake (deferred to v2).
- `docs/operations/battle-node-smoke.md` — manual end-to-end checklist.
- `docs/operations/battle-node-smoke-walkthrough.md` — annotated walkthrough with per-step diagnostics.
- `docs/superpowers/specs/2026-05-31-battle-node-transport-design.md` — v1 design.
- `docs/superpowers/plans/2026-05-31-battle-node-transport.md` — v1 implementation plan.

View File

@@ -0,0 +1,20 @@
namespace SVSim.BattleNode.Reliability;
/// <summary>
/// Body builders for the alive channel. The timer/loop that drives 5s emits lives on
/// BattleSession; this class is just the pure body-shape factory.
/// v1 always reports scs/ocs=ONLINE — real disconnect detection is deferred. The push
/// body itself is constructed inline in BattleSession.HandleAliveEventAsync using
/// AlivePushBody; only the emit body (sent by us TO the client on the alive channel,
/// currently unused in v1) remains here.
/// </summary>
public static class Gungnir
{
public static readonly TimeSpan EmitInterval = TimeSpan.FromSeconds(5);
public static Dictionary<string, object?> BuildAliveEmitBody(InboundTracker tracker) => new()
{
["currentSeq"] = tracker.HighWaterMark,
// actionSeq omitted in v1 — no turn-transition flag yet.
};
}

View File

@@ -0,0 +1,57 @@
namespace SVSim.BattleNode.Reliability;
/// <summary>
/// Per-session inbound-emit ledger. Dedupes the client's pubSeq so we never dispatch
/// a retransmitted emit twice; ack-echo (via SIO callback) is the caller's job.
/// </summary>
/// <remarks>
/// State is bounded: the ledger keeps the most recent <see cref="WindowSize"/>
/// pubSeqs in an LRU ring. Seqs below <c>HighWaterMark - WindowSize</c> are
/// treated as stale-below-window and rejected without recording — this is what
/// prevents window eviction from re-admitting an old seq as novel. The pubSeq is
/// client-assigned monotonically; the bound is sized well above the realistic
/// Socket.IO retransmit horizon, so legitimate retransmits always fall inside.
/// </remarks>
public sealed class InboundTracker
{
/// <summary>Sliding-window size. Anything below <c>HighWaterMark - WindowSize</c> is dropped.</summary>
public const int WindowSize = 256;
private readonly HashSet<long> _seen = new(WindowSize);
private readonly Queue<long> _order = new(WindowSize);
/// <summary>Highest pubSeq observed so far. Reported via Gungnir for diagnostics.</summary>
public long HighWaterMark { get; private set; }
/// <summary>Record an incoming pubSeq. Returns true if the caller should dispatch the envelope, false on duplicate or stale-below-window.</summary>
public bool Observe(long pubSeq)
{
// Stale-below-window guard. Required AFTER HighWaterMark is past the window,
// otherwise an evicted ring entry would re-admit as novel.
if (HighWaterMark > 0 && pubSeq <= HighWaterMark - WindowSize)
return false;
if (pubSeq > HighWaterMark)
{
HighWaterMark = pubSeq;
Record(pubSeq);
return true;
}
if (_seen.Contains(pubSeq))
return false;
Record(pubSeq);
return true;
}
private void Record(long pubSeq)
{
if (_order.Count >= WindowSize)
{
var evicted = _order.Dequeue();
_seen.Remove(evicted);
}
_order.Enqueue(pubSeq);
_seen.Add(pubSeq);
}
}

View File

@@ -0,0 +1,36 @@
using SVSim.BattleNode.Protocol;
namespace SVSim.BattleNode.Reliability;
/// <summary>
/// Per-session outbound ledger. Assigns monotonic playSeq to ordered pushes and archives
/// them for future Resume retransmit (v2). No-stock control pushes (BattleFinish/JudgeResult/Resume)
/// are wrapped with no playSeq and skip the archive.
/// </summary>
public sealed class OutboundSequencer
{
private long _next = 1;
private readonly Dictionary<long, MsgEnvelope> _archive = new();
public IReadOnlyDictionary<long, MsgEnvelope> Archive => _archive;
public MsgEnvelope AssignAndArchive(MsgEnvelope envelope)
{
var seq = _next++;
var stamped = envelope with { PlaySeq = seq };
_archive[seq] = stamped;
return stamped;
}
public MsgEnvelope WrapNoStock(MsgEnvelope envelope) =>
envelope with { PlaySeq = null };
/// <summary>
/// Drop all archived envelopes. Called from BattleSession's terminate cascade so
/// the archive — the heavy state — is released the moment the battle ends, rather
/// than waiting for the participant to be GC'd. <c>_next</c> is left untouched:
/// a participant emitting after Clear is a bug, not a recovery case, but the seq
/// stream stays monotonic so a stray emit doesn't silently re-use a playSeq value.
/// </summary>
public void Clear() => _archive.Clear();
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="MessagePack" Version="2.5.172" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SVSim.UnitTests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,13 @@
namespace SVSim.BattleNode.Sessions;
/// <summary>Reason a participant was terminated. Carried to
/// <see cref="IBattleParticipant.TerminateAsync"/> so impls can log/clean differently
/// per cause. Cleanup itself is the same regardless of reason.</summary>
public enum BattleFinishReason
{
NormalFinish,
Retire,
OpponentDisconnect,
Timeout,
ServerAbort,
}

View File

@@ -0,0 +1,390 @@
using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
using SVSim.BattleNode.Sessions.Participants;
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// v2 broker session. Holds two participants and brokers between them. Subscribes
/// to each participant's <see cref="IBattleParticipant.FrameEmitted"/>; on each frame,
/// runs <see cref="ComputeFrames"/> to determine the routing (target + frame + noStock
/// flag) and dispatches via <see cref="IBattleParticipant.PushAsync"/>.
/// </summary>
/// <remarks>
/// Phase 1 wires this for <see cref="BattleType.Scripted"/> only — the dispatch logic
/// preserves v1.2 behaviour. Phase 2 wires Pvp (broadcast Matched/BattleStart per-perspective,
/// forward gameplay frames between participants). Phase 3 wires Bot (ack-only).
/// </remarks>
public sealed class BattleSession
{
private readonly ILogger<BattleSession> _log;
public string BattleId { get; }
public BattleType Type { get; }
public IBattleParticipant A { get; }
public IBattleParticipant B { get; }
public BattleSessionPhase Phase { get; private set; } = BattleSessionPhase.AwaitingInitNetwork;
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
ILogger<BattleSession> log)
{
BattleId = battleId;
Type = type;
A = a;
B = b;
_log = log;
// Subscribe to both participants' emissions.
A.FrameEmitted += OnFrameFromA;
B.FrameEmitted += OnFrameFromB;
}
public async Task RunAsync(CancellationToken cancellation)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
var aTask = A.RunAsync(cts.Token);
var bTask = B.RunAsync(cts.Token);
if (Type == BattleType.Pvp)
{
// WhenAny: first WS drop / first graceful close triggers cascade.
// ScriptedBotParticipant.RunAsync also returns immediately; that's not used
// here (Pvp has two RealParticipants), but we'd still want a synthesized
// BattleFinish for the survivor if either side terminates first.
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
var survivor = first == aTask ? B : A;
if (Phase != BattleSessionPhase.Terminal)
{
// Involuntary drop (no graceful Retire): synthesize BattleFinish(Win) to survivor.
try
{
await survivor.PushAsync(
BuildBattleFinish(BattleResult.Win), noStock: true, cancellation)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_log.LogWarning(ex,
"BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)",
BattleId);
}
Phase = BattleSessionPhase.Terminal;
}
cts.Cancel(); // unblock the survivor's RunAsync read loop
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
catch { /* swallow cancellation / WS exceptions */ }
}
else
{
// Phase 1 semantics for Scripted/Bot: wait for ALL participants. The bot's
// RunAsync returns immediately; the session keeps running for the real one.
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
catch { /* swallow */ }
}
// Audit Md11 — release per-participant outbound archives at battle-end
// (only RealParticipant has one; bots don't archive). Heavy state is
// dropped synchronously here so the participant's TerminateAsync doesn't
// need to keep the dict alive through its disposal handshake.
if (A is RealParticipant rpA) rpA.Outbound.Clear();
if (B is RealParticipant rpB) rpB.Outbound.Clear();
await Task.WhenAll(
A.TerminateAsync(BattleFinishReason.NormalFinish),
B.TerminateAsync(BattleFinishReason.NormalFinish))
.ConfigureAwait(false);
}
private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct);
private Task OnFrameFromB(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(B, env, ct);
private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct)
{
try
{
var routes = ComputeFrames(from, env);
foreach (var (target, frame, noStock) in routes)
{
await target.PushAsync(frame, noStock, ct);
}
}
catch (Exception ex)
{
_log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId);
}
}
/// <summary>
/// Pure-logic dispatch: given an inbound frame from one participant, return the list
/// of (target, frame, noStock) tuples the session should dispatch. Transitions
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
/// standing up real participants.
/// </summary>
internal IReadOnlyList<(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock)> ComputeFrames(
IBattleParticipant from, MsgEnvelope env)
{
var result = new List<(IBattleParticipant, MsgEnvelope, bool)>();
var other = ReferenceEquals(from, A) ? B : A;
var phaseFrom = from as IHasHandshakePhase;
// The dispatch table only covers the Scripted-mode behaviour Phase 1 needs;
// Phase 2 (Pvp) and Phase 3 (Bot) add the other-type branches. Handshake-phase
// arms read the SENDER's Phase (per-participant); the session-level Phase
// remains only for the Terminal short-circuit.
switch (env.Uri)
{
case NetworkBattleUri.InitNetwork when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitNetwork:
result.Add((from, BuildAck(NetworkBattleUri.InitNetwork), true));
phaseFrom!.Phase = BattleSessionPhase.AwaitingInitBattle;
break;
// --- Phase 3 Bot arms — placed BEFORE the existing handshake arms so they
// win pattern matching on Type == Bot. Bot mode: ack handshake, silent
// Loaded, Judge-to-sender on TurnEnd. The rest reuse Scripted's arms
// (Retire/Kill → BattleFinishNoContest, Swap → per-sender response,
// default → drop). Reference: docs/api-spec/in-battle/ai-passive.md.
//
// Critically, do NOT push Matched or BattleStart for Bot mode. The
// architecture spec was right about this:
// 1. The client's MatchingInitBattle (Matching.cs:298) immediately calls
// StartBattleLoad + GotoBattle on the IsAINetwork branch right after
// emitting InitBattle — it does NOT wait for a wire Matched or
// BattleStart envelope. The state-machine trigger is _initNetworkSuccess
// (set when InitNetwork uri is received, i.e., our ack).
// 2. Sending Matched is harmless (gated on status == Connect, which is
// already past by the time the wire round-trip completes).
// 3. Sending BattleStart is ACTIVELY HARMFUL: its handler at
// Matching.cs:417 runs unconditionally and SetNetworkInfo
// (RealTimeNetworkAgent.cs:1553-1564) overwrites OppoBattleStartInfo
// with the wire envelope's oppoInfo. Our oppoInfo comes from
// NoOpBotParticipant.Context placeholders (classId:0, emblemId:0,
// etc.), corrupting the good values the client just set from the
// HTTP /ai_<fmt>_rank_battle/start response — subsequent asset
// loads (LoadOpponentAssets at SBattleLoad.cs:933) then look up
// non-existent assets and silently hang on "Waiting for opponent."
case NetworkBattleUri.InitBattle
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
// Ack only — NO Matched push.
result.Add((from, BuildAck(NetworkBattleUri.InitBattle), true));
phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
break;
case NetworkBattleUri.Loaded
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded:
// Silent — no BattleStart, no Deal. The client's AINetworkBattleManager
// populates opponent state from AIBattleStart HTTP data; pushing
// BattleStart here overwrites that state with zeros.
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
break;
case NetworkBattleUri.TurnEnd
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady:
case NetworkBattleUri.TurnEndFinal
when Type == BattleType.Bot && phaseFrom?.Phase == BattleSessionPhase.AfterReady:
// Judge to sender ONLY (not broadcast — there's no real other side).
// The client's JudgeOperation → ControlTurnStartPlayer flips back to
// the local AI's turn after this Judge arrives.
result.Add((from, BuildJudgeBroadcast(), false));
break;
case NetworkBattleUri.InitBattle when phaseFrom?.Phase == BattleSessionPhase.AwaitingInitBattle:
// Phase 1: push Matched only to the "real" participant. The session reads
// selfInfo from from.Context and oppoInfo from other.Context (the scripted
// bot's Context fixture preserves the prod-captured cosmetics that previously
// lived in ScriptedProfiles).
result.Add((from, ScriptedLifecycle.BuildMatched(
from.Context, other.Context,
from.ViewerId, other.ViewerId,
BattleId, ScriptedProfiles.BattleSeed), false));
phaseFrom!.Phase = BattleSessionPhase.AwaitingLoaded;
break;
case NetworkBattleUri.Loaded when phaseFrom?.Phase == BattleSessionPhase.AwaitingLoaded:
result.Add((from, ScriptedLifecycle.BuildBattleStart(
from.Context, other.Context, from.ViewerId), false));
result.Add((from, ScriptedLifecycle.BuildDeal(), false));
phaseFrom!.Phase = BattleSessionPhase.AwaitingSwap;
break;
case NetworkBattleUri.Swap when phaseFrom?.Phase == BattleSessionPhase.AwaitingSwap:
{
var hand = ScriptedLifecycle.ComputeHandAfterSwap(ExtractIdxList(env));
result.Add((from, ScriptedLifecycle.BuildSwapResponse(hand), false));
result.Add((from, ScriptedLifecycle.BuildReady(hand), false));
phaseFrom!.Phase = BattleSessionPhase.AfterReady;
break;
}
case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
if (Type == BattleType.Pvp && BothAfterReady())
{
// Broadcast TurnEnd + Judge to BOTH. Each client's JudgeOperation ->
// ControlTurnStartPlayer advances the active-player state machine.
var turnEndBroadcast = BuildTurnEndBroadcast();
var judgeBroadcast = BuildJudgeBroadcast();
result.Add((from, turnEndBroadcast, false));
result.Add((other, turnEndBroadcast, false));
result.Add((from, judgeBroadcast, false));
result.Add((other, judgeBroadcast, false));
}
else if (Type == BattleType.Scripted)
{
// Phase 1 Scripted: forward to bot; bot fires three-frame burst back.
result.Add((other, env, false));
}
// For Bot type, no-op (NoOpBot swallows; client handles its own turn end).
break;
case NetworkBattleUri.Retire:
case NetworkBattleUri.Kill:
if (Type == BattleType.Pvp)
{
result.Add((from, BuildBattleFinish(BattleResult.Lose), true));
result.Add((other, BuildBattleFinish(BattleResult.Win), true));
}
else
{
// Scripted (and future Bot) — sender wins by default (no real opponent).
result.Add((from, BuildBattleFinishNoContest(), true));
}
Phase = BattleSessionPhase.Terminal;
break;
// Frames emitted by the scripted bot (TurnStart / TurnEnd / Judge) — forward
// to the real participant. These match the v1.2 burst's three outbound pushes.
// Pre-migration this arm only handled TurnStart/Judge because the handshake
// TurnEnd arm above (gated on session-level Phase) also caught the bot's TurnEnd.
// Post-migration that arm gates on the sender's per-participant Phase, which the
// bot doesn't have, so the bot's TurnEnd now lands here.
// The `IsRealForwardableFromScripted` guard ensures this arm matches ONLY the
// scripted bot's emissions (sender ViewerId == FakeOpponentViewerId) — without
// it, a TurnStart/TurnEnd/Judge from a real participant in PvP mode would match
// here and `goto default` would skip the PvP forwarder arm below.
case NetworkBattleUri.TurnStart when IsRealForwardableFromScripted(from, env):
case NetworkBattleUri.TurnEnd when IsRealForwardableFromScripted(from, env):
case NetworkBattleUri.Judge when IsRealForwardableFromScripted(from, env):
// Generic forwarder for scripted-bot emissions. The Scripted bot's TurnStart,
// TurnEnd, and Judge are intended for the real participant.
result.Add((other, env, false));
break;
// --- PvP gameplay forwarding (post-AfterReady).
// Order matters: this MUST come after the FakeOpponentViewerId arms so
// Scripted bot emissions don't fall into the PvP forwarder.
case NetworkBattleUri.TurnStart when Type == BattleType.Pvp && BothAfterReady():
case NetworkBattleUri.PlayActions when Type == BattleType.Pvp && BothAfterReady():
case NetworkBattleUri.Echo when Type == BattleType.Pvp && BothAfterReady():
case NetworkBattleUri.TurnEndActions when Type == BattleType.Pvp && BothAfterReady():
case NetworkBattleUri.JudgeResult when Type == BattleType.Pvp && BothAfterReady():
result.Add((other, env, false));
break;
default:
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}",
BattleId, env.Uri, Phase, from.ViewerId);
break;
}
return result;
}
// Phase 1: the only "scripted-bot" emissions we need to forward are the three burst
// frames (TurnStart, TurnEnd, Judge) — and TurnEnd is already handled in the switch
// above as a forwardable bot emission. This helper exists so the TurnStart/Judge cases
// above only fire when the source is actually a participant (not malformed inbound).
private static bool IsRealForwardableFromScripted(IBattleParticipant from, MsgEnvelope env)
{
// The bot's emitted frames carry ViewerId == FakeOpponentViewerId.
return from.ViewerId == ScriptedLifecycle.FakeOpponentViewerId;
}
// Phase 2: PvP gameplay-frame forwarding is gated on BOTH sides having completed
// the handshake (i.e. reached AfterReady). Until then, an early TurnStart/PlayActions
// from one side has no valid recipient.
private bool BothAfterReady() =>
(A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady &&
(B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady;
private MsgEnvelope BuildAck(NetworkBattleUri uri) => new(
uri,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.General,
PubSeq: null,
PlaySeq: null,
Body: new ResultCodeOnlyBody());
private MsgEnvelope BuildBattleFinishNoContest() => new(
NetworkBattleUri.BattleFinish,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new BattleFinishBody(Result: BattleResult.Win));
private MsgEnvelope BuildTurnEndBroadcast() => new(
NetworkBattleUri.TurnEnd,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new TurnEndBody(TurnState: 0));
private MsgEnvelope BuildJudgeBroadcast() => new(
NetworkBattleUri.Judge,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new JudgeBody(Spin: ScriptedProfiles.OpponentJudgeSpin));
private MsgEnvelope BuildBattleFinish(BattleResult result) => new(
NetworkBattleUri.BattleFinish,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new BattleFinishBody(Result: result));
private static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
{
if (env.Body is not RawBody rawBody) return Array.Empty<long>();
if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
{
var result = new List<long>();
foreach (var item in seq)
{
switch (item)
{
case long l: result.Add(l); break;
case int i: result.Add(i); break;
case double d: result.Add((long)d); break;
case decimal m: result.Add((long)m); break;
case string s when long.TryParse(s, out var p): result.Add(p); break;
}
}
return result;
}
return Array.Empty<long>();
}
}

View File

@@ -0,0 +1,16 @@
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// Where we are in the v1 scripted lifecycle. Drives which scripted frames the session pushes
/// in response to inbound emits.
/// </summary>
public enum BattleSessionPhase
{
AwaitingInitNetwork,
AwaitingInitBattle,
AwaitingLoaded,
AwaitingSwap,
AfterReady,
OpponentTurn,
Terminal,
}

View File

@@ -0,0 +1,22 @@
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// Discriminator for a pending battle and the session it produces. See
/// docs/superpowers/specs/2026-06-01-battle-node-v2-architecture-design.md.
/// </summary>
public enum BattleType
{
/// <summary>Two real players. Server brokers between two WebSockets.
/// Both <c>BattlePlayer</c> slots required.</summary>
Pvp,
/// <summary>One real player; opponent runs in the client (prod's IsAINetwork
/// path; matched only in rank rotation / rank unlimited per prod). Server is
/// ack-only. <c>p2</c> must be null.</summary>
Bot,
/// <summary>One real player; server scripts the opponent (today's v1.2
/// behaviour, preserved as a solo testing harness). <c>p2</c> currently null;
/// future server-driven bot config can ride on <c>p2</c>.</summary>
Scripted,
}

View File

@@ -0,0 +1,46 @@
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Protocol;
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// One side of a battle. Two of these are held by a <c>BattleSession</c>; the session
/// brokers between them. Concrete impls (added in subsequent Phase-1 tasks):
/// <list type="bullet">
/// <item><c>RealParticipant</c> — WS-backed.</item>
/// <item><c>NoOpBotParticipant</c> — silent; for <c>BattleType.Bot</c> (AI-passive).</item>
/// <item><c>ScriptedBotParticipant</c> — wraps the v1.2 lifecycle for
/// <c>BattleType.Scripted</c> (solo testing harness).</item>
/// </list>
/// </summary>
public interface IBattleParticipant : IAsyncDisposable
{
/// <summary>Real viewer id, or a synthetic stable id for bots
/// (<see cref="Lifecycle.ScriptedLifecycle.FakeOpponentViewerId"/>).</summary>
long ViewerId { get; }
/// <summary>Per-battle MatchContext snapshot, used for building Matched/BattleStart
/// selfInfo when this participant is "self" in the perspective.</summary>
MatchContext Context { get; }
/// <summary>Session calls this to deliver a frame from the OTHER participant
/// (or a server-synthesized broadcast). Real impl: encode + WS-send.
/// NoOp: swallow. Scripted: may emit a response via <see cref="FrameEmitted"/>.</summary>
/// <param name="noStock">True for control frames (BattleFinish, JudgeResult, ack);
/// bypasses playSeq assignment + archive.</param>
Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct);
/// <summary>Participant fires this when it has a frame to send TO the session
/// (its own gameplay action). Real impl: fires on WS recv. NoOp: never fires.
/// Scripted: fires from inside PushAsync when the scripted lifecycle wants to
/// respond to an inbound frame.</summary>
event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
/// <summary>Drives the participant's inbound loop. For Real: the WS read loop
/// (returns when the WS closes). For NoOp/Scripted: completes immediately (the
/// session keeps running as long as the OTHER participant's RunAsync is alive).</summary>
Task RunAsync(CancellationToken ct);
/// <summary>Called when the battle ends. Concrete impls clean up (close WS, etc.).</summary>
Task TerminateAsync(BattleFinishReason reason);
}

View File

@@ -0,0 +1,23 @@
namespace SVSim.BattleNode.Sessions;
public interface IBattleSessionStore
{
/// <summary>Register a battle minted by the matching bridge, awaiting a WS connect.</summary>
void RegisterPending(PendingBattle battle);
/// <summary>Look up the pending battle. Returns null if not present.</summary>
PendingBattle? TryGetPending(string battleId);
/// <summary>
/// Find a pending battle this viewer is a participant in (P1 or P2). Used by the
/// HTTP-side <c>/ai_&lt;fmt&gt;/start</c> endpoint to retrieve the deck/cosmetic
/// context the viewer registered at <c>do_matching</c> time — the <c>/start</c>
/// request body carries no <c>deck_no</c> of its own. Returns null if the viewer
/// has no pending battle (already consumed by WS connect, never registered, or
/// evicted by timeout).
/// </summary>
PendingBattle? TryFindPendingForViewer(long viewerId);
/// <summary>Mark a battle as no longer pending (e.g. on successful connect or explicit close).</summary>
bool RemovePending(string battleId);
}

View File

@@ -0,0 +1,32 @@
using System.Collections.Concurrent;
namespace SVSim.BattleNode.Sessions;
public sealed class InMemoryBattleSessionStore : IBattleSessionStore
{
private readonly ConcurrentDictionary<string, PendingBattle> _pending = new();
public void RegisterPending(PendingBattle battle) =>
_pending[battle.BattleId] = battle;
public PendingBattle? TryGetPending(string battleId) =>
_pending.TryGetValue(battleId, out var b) ? b : null;
public PendingBattle? TryFindPendingForViewer(long viewerId)
{
// Linear scan — _pending is bounded by concurrent in-flight matches (low
// double digits at most), so this stays cheap. Returns whichever match the
// dictionary's enumerator yields first; in practice a viewer has at most one
// pending battle since each /do_matching either pairs/falls-back the existing
// slot or parks without registering.
foreach (var b in _pending.Values)
{
if (b.P1.ViewerId == viewerId) return b;
if (b.P2 is not null && b.P2.ViewerId == viewerId) return b;
}
return null;
}
public bool RemovePending(string battleId) =>
_pending.TryRemove(battleId, out _);
}

View File

@@ -0,0 +1,35 @@
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
namespace SVSim.BattleNode.Sessions.Participants;
/// <summary>
/// Silent participant — produces no frames, swallows everything pushed to it.
/// Used as the "other" participant in <see cref="BattleType.Bot"/> sessions, where
/// the real opponent runs in the client and the server has no opponent-side state
/// to model. ViewerId is <see cref="ScriptedLifecycle.FakeOpponentViewerId"/>;
/// Context is a fixed stub (irrelevant — never read because no frames are pushed
/// to the other side).
/// </summary>
public sealed class NoOpBotParticipant : IBattleParticipant
{
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
public MatchContext Context { get; } = new(
SelfDeckCardIds: Array.Empty<long>(),
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
CountryCode: "", UserName: "Bot", SleeveId: "0",
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0,
BattleType: 0);
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
public Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct) => Task.CompletedTask;
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
// Suppress unused-event warning — FrameEmitted is declared by the interface contract;
// intentionally never invoked.
private void Touch() => FrameEmitted?.Invoke(null!, default);
}

View File

@@ -0,0 +1,316 @@
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies;
using SVSim.BattleNode.Reliability;
using SVSim.BattleNode.Wire;
namespace SVSim.BattleNode.Sessions.Participants;
/// <summary>
/// Marker interface implemented by participants that own a handshake-phase cursor.
/// <see cref="BattleSession.ComputeFrames"/> reads the sender's <see cref="Phase"/>
/// when gating the handshake-phase arms (InitNetwork / InitBattle / Loaded / Swap)
/// and the TurnEnd-AfterReady forwarder. Bots don't implement this — they never
/// send the gating URIs.
/// </summary>
internal interface IHasHandshakePhase
{
BattleSessionPhase Phase { get; set; }
}
/// <summary>
/// WS-backed participant. Owns the WS read loop, SIO encoding/decoding, per-WS
/// <see cref="OutboundSequencer"/> + <see cref="InboundTracker"/>. Fires
/// <see cref="FrameEmitted"/> on each deduplicated inbound <see cref="MsgEnvelope"/>.
/// PushAsync encodes + sends; ordered pushes get a playSeq from the sequencer,
/// no-stock control pushes bypass it.
/// </summary>
public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
{
private readonly WebSocket _ws;
private readonly ILogger<RealParticipant> _log;
private CancellationToken _sessionCt;
public long ViewerId { get; }
public MatchContext Context { get; }
public InboundTracker Inbound { get; } = new();
public OutboundSequencer Outbound { get; } = new();
/// <summary>Per-side handshake progression. Session reads this when gating
/// handshake-phase synthesis (Matched / BattleStart / Deal / Swap response /
/// Ready). Session transitions via the setter after dispatch. Defaults to
/// AwaitingInitNetwork; only RealParticipant tracks this — bots have no phase
/// because they never send the gating URIs. Also satisfies
/// <see cref="IHasHandshakePhase"/> (the interface BattleSession uses to gate
/// handshake dispatch without depending on the concrete RealParticipant type).</summary>
internal BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
BattleSessionPhase IHasHandshakePhase.Phase
{
get => Phase;
set => Phase = value;
}
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
private readonly TaskCompletionSource<bool> _sessionFinished
= new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Called by the second arriver's handler (in a finally block) after
/// session.RunAsync completes. Signals the first arriver's handler that it can
/// return and let the HTTP request complete (which closes the WS).</summary>
internal void MarkSessionFinished() => _sessionFinished.TrySetResult(true);
/// <summary>Awaited by the first arriver's handler instead of calling RunAsync
/// (the session already calls RunAsync on this instance from the second arriver's
/// handler context — calling it twice would race the WS read loop). Returns when
/// either MarkSessionFinished fires or the passed CT cancels.</summary>
internal Task AwaitSessionFinishedAsync(CancellationToken ct)
{
if (_sessionFinished.Task.IsCompleted) return _sessionFinished.Task;
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var reg = ct.Register(() => tcs.TrySetCanceled(ct));
_sessionFinished.Task.ContinueWith(t =>
{
reg.Dispose();
if (t.IsCompletedSuccessfully) tcs.TrySetResult(true);
else if (t.IsFaulted) tcs.TrySetException(t.Exception!.InnerExceptions);
else tcs.TrySetCanceled();
}, TaskContinuationOptions.ExecuteSynchronously);
return tcs.Task;
}
public RealParticipant(WebSocket ws, long viewerId, MatchContext context,
ILogger<RealParticipant> log)
{
_ws = ws;
_log = log;
ViewerId = viewerId;
Context = context;
}
public async Task RunAsync(CancellationToken cancellation)
{
_sessionCt = cancellation;
await SendEioOpenAsync(cancellation);
var buffer = new byte[8192];
var pendingAttachments = new List<byte[]>();
SocketIoFrame? pendingFrame = null;
while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
{
var msg = await ReadCompleteMessageAsync(buffer, cancellation);
if (msg is null) break;
if (msg.Value.IsText)
{
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
if (text.Length == 0) continue;
var eio = EngineIoFrame.Parse(text);
if (eio.Type == EngineIoPacketType.Ping)
{
await SendTextAsync("3", cancellation);
continue;
}
if (eio.Type != EngineIoPacketType.Message) continue;
var sio = SocketIoFrame.Parse(eio.Payload);
if (sio.AttachmentCount > 0)
{
pendingFrame = sio;
pendingAttachments.Clear();
continue;
}
await DispatchSocketIo(sio);
}
else
{
var bin = msg.Value.Bytes;
if (bin.Length > 0 && bin[0] == (byte)EngineIoPacketType.Message)
{
bin = bin.AsSpan(1).ToArray();
}
pendingAttachments.Add(bin);
if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount)
{
var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray());
pendingFrame = null;
await DispatchSocketIo(assembled);
}
}
}
}
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
{
var stamped = noStock ? Outbound.WrapNoStock(envelope) : Outbound.AssignAndArchive(envelope);
await EncodeAndSendAsync(stamped, WireConstants.SynchronizeEvent, ct);
}
public Task TerminateAsync(BattleFinishReason reason)
{
// WS will close via the read loop exiting; nothing to do here.
return Task.CompletedTask;
}
public ValueTask DisposeAsync()
{
if (_ws.State == WebSocketState.Open || _ws.State == WebSocketState.CloseReceived)
{
try { _ws.Abort(); } catch { /* best effort */ }
}
_ws.Dispose();
return ValueTask.CompletedTask;
}
private async Task DispatchSocketIo(SocketIoFrame frame)
{
if (frame.Type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent)
{
switch (frame.EventName)
{
case WireConstants.MsgEvent when frame.BinaryAttachments.Count == 1:
await HandleMsgEventAsync(frame);
return;
case WireConstants.AliveEvent when frame.BinaryAttachments.Count == 1:
await HandleAliveEventAsync(frame);
return;
}
}
_log.LogDebug("RealParticipant viewer={Vid}: dropping SIO event={Event}", ViewerId, frame.EventName);
}
private async Task HandleMsgEventAsync(SocketIoFrame frame)
{
try
{
MsgEnvelope env;
try { env = MsgPayloadCodec.Decode(frame.BinaryAttachments[0]); }
catch (Exception ex)
{
_log.LogWarning(ex, "RealParticipant viewer={Vid}: failed to decode msg envelope", ViewerId);
return;
}
bool shouldDispatch = true;
if (env.PubSeq.HasValue)
{
shouldDispatch = Inbound.Observe(env.PubSeq.Value);
if (frame.AckId.HasValue)
{
await SendSioAckAsync(frame.AckId.Value, env.PubSeq.Value);
}
}
if (!shouldDispatch) return;
if (FrameEmitted is not null)
{
await FrameEmitted.Invoke(env, _sessionCt);
}
}
catch (Exception ex)
{
_log.LogError(ex, "RealParticipant viewer={Vid}: unhandled in HandleMsgEventAsync", ViewerId);
}
}
private async Task HandleAliveEventAsync(SocketIoFrame frame)
{
try
{
if (frame.AckId.HasValue)
{
await SendSioAckAsync(frame.AckId.Value, 0);
}
var aliveEnv = new MsgEnvelope(
Uri: NetworkBattleUri.Gungnir,
ViewerId: SVSim.BattleNode.Lifecycle.ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.General,
PubSeq: null,
PlaySeq: null,
Body: new AlivePushBody(Scs: WireConstants.OnlineStatus, Ocs: WireConstants.OnlineStatus));
var stamped = Outbound.WrapNoStock(aliveEnv);
await EncodeAndSendAsync(stamped, WireConstants.AliveEvent, _sessionCt);
}
catch (Exception ex)
{
_log.LogError(ex, "RealParticipant viewer={Vid}: unhandled in HandleAliveEventAsync", ViewerId);
}
}
private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName, CancellationToken ct)
{
var key = NodeCrypto.GenerateKey(() => RandomNumberGenerator.GetInt32(0, 16));
var bytes = MsgPayloadCodec.Encode(env, key);
var sio = SocketIoFrame.BinaryEventWithAttachments(eventName, new[] { bytes });
var (text, bins) = sio.Encode();
var eioText = $"{(int)EngineIoPacketType.Message}{text}";
await SendTextAsync(eioText, ct);
foreach (var bin in bins)
{
var prefixed = new byte[bin.Length + 1];
prefixed[0] = (byte)EngineIoPacketType.Message;
Buffer.BlockCopy(bin, 0, prefixed, 1, bin.Length);
await _ws.SendAsync(prefixed, WebSocketMessageType.Binary, endOfMessage: true, ct);
}
}
internal static int ClipAckArg(long arg, ILogger log, long viewerId)
{
if (arg > int.MaxValue)
{
log.LogWarning("RealParticipant viewer={Vid}: pubSeq {Seq} exceeds int.MaxValue; clipping.", viewerId, arg);
return int.MaxValue;
}
if (arg < int.MinValue)
{
log.LogWarning("RealParticipant viewer={Vid}: pubSeq {Seq} below int.MinValue; clipping.", viewerId, arg);
return int.MinValue;
}
return (int)arg;
}
private async Task SendSioAckAsync(int ackId, long arg)
{
var ack = SocketIoFrame.AckResponse(ackId, ClipAckArg(arg, _log, ViewerId));
var (text, _) = ack.Encode();
var eioText = $"{(int)EngineIoPacketType.Message}{text}";
await SendTextAsync(eioText, _sessionCt);
}
private async Task SendEioOpenAsync(CancellationToken ct)
{
var sid = Guid.NewGuid().ToString("N").Substring(0, 16);
var handshake = new EngineIoHandshake(sid, Array.Empty<string>(), 25000, 60000).ToJson();
await SendTextAsync($"0{handshake}", ct);
}
private Task SendTextAsync(string text, CancellationToken ct)
{
var bytes = Encoding.UTF8.GetBytes(text);
return _ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
}
private async Task<(byte[] Bytes, bool IsText)?> ReadCompleteMessageAsync(byte[] buffer, CancellationToken ct)
{
using var ms = new MemoryStream();
WebSocketReceiveResult result;
do
{
try { result = await _ws.ReceiveAsync(buffer, ct); }
catch (OperationCanceledException) { return null; }
catch (WebSocketException) { return null; }
if (result.MessageType == WebSocketMessageType.Close) return null;
ms.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
return (ms.ToArray(), result.MessageType == WebSocketMessageType.Text);
}
}

View File

@@ -0,0 +1,56 @@
using System.Linq;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
namespace SVSim.BattleNode.Sessions.Participants;
/// <summary>
/// Server-scripted opponent (today's v1.2 testing-harness behavior, repackaged).
/// On <see cref="PushAsync"/> with <c>TurnEnd</c> or <c>TurnEndFinal</c>, fires
/// <see cref="FrameEmitted"/> three times: <c>OpponentTurnStart</c>,
/// <c>OpponentTurnEnd</c>, <c>OpponentJudge</c>. All other URIs are swallowed
/// (no opponent reaction needed for v1.2 behavior).
/// </summary>
/// <remarks>
/// ViewerId, Context are fixtures matching <see cref="ScriptedLifecycle.FakeOpponentViewerId"/>
/// and a scripted opponent profile. The Context fixture is the source of truth for the
/// scripted opponent's half of Matched and BattleStart (cosmetics, deck count, class/chara) —
/// <see cref="BattleSession.ComputeFrames"/> reads <c>other.Context</c> for those frames.
/// Deal still uses fixed scripted frames that ignore Context.
/// </remarks>
public sealed class ScriptedBotParticipant : IBattleParticipant
{
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
public MatchContext Context { get; } = new(
// 30 dummy card ids so oppoCtx.SelfDeckCardIds.Count == 30 (matches the
// hardcoded OppoDeckCount that ScriptedProfiles.OpponentMatchedProfile shipped).
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
// BattleStart opponent half: ClassId/CharaId from ScriptedProfiles.OpponentBattleStartProfile.
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
// Matched opponent half: cosmetic fields from ScriptedProfiles.OpponentMatchedProfile.
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
BattleType: 0);
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
{
// v1.2 behavior: react to the player's TurnEnd / TurnEndFinal with the
// three-frame burst. Everything else is silently swallowed.
if (envelope.Uri is NetworkBattleUri.TurnEnd or NetworkBattleUri.TurnEndFinal)
{
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnStart(), ct).ConfigureAwait(false);
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false);
await EmitAsync(ScriptedLifecycle.BuildOpponentJudge(), ct).ConfigureAwait(false);
}
}
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
private Task EmitAsync(MsgEnvelope env, CancellationToken ct) =>
FrameEmitted?.Invoke(env, ct) ?? Task.CompletedTask;
}

View File

@@ -0,0 +1,10 @@
using SVSim.BattleNode.Bridge;
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// Sparse pre-connect record. Carries the battle type + one or two players. The
/// WebSocket handler reads this to validate the incoming WS connect and to
/// construct the right participants.
/// </summary>
public sealed record PendingBattle(string BattleId, BattleType Type, BattlePlayer P1, BattlePlayer? P2);

View File

@@ -0,0 +1,21 @@
namespace SVSim.BattleNode.Wire;
/// <summary>
/// Engine.IO v3 packet in WebSocket transport mode. Wire form: <c>&lt;digit&gt;&lt;payload&gt;</c>.
/// </summary>
public sealed record EngineIoFrame(EngineIoPacketType Type, string Payload)
{
public static EngineIoFrame Parse(string raw)
{
if (string.IsNullOrEmpty(raw))
throw new ArgumentException("Empty EIO frame", nameof(raw));
var typeChar = raw[0];
if (typeChar < '0' || typeChar > '6')
throw new ArgumentException($"Invalid EIO type char '{typeChar}'", nameof(raw));
var type = (EngineIoPacketType)(typeChar - '0');
var payload = raw.Length > 1 ? raw.Substring(1) : string.Empty;
return new EngineIoFrame(type, payload);
}
public string Encode() => $"{(int)Type}{Payload}";
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Wire;
/// <summary>
/// Payload of an EIO3 Open packet. Sent by the server to the client immediately after the WS upgrade.
/// </summary>
public sealed record EngineIoHandshake(
[property: JsonPropertyName("sid")] string Sid,
[property: JsonPropertyName("upgrades")] string[] Upgrades,
[property: JsonPropertyName("pingInterval")] int PingInterval,
[property: JsonPropertyName("pingTimeout")] int PingTimeout)
{
// Wire-key casing here is bare camelCase — NOT EmulatedEntrypoint's snake_case policy.
private static readonly JsonSerializerOptions Options = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public string ToJson() => JsonSerializer.Serialize(this, Options);
}

View File

@@ -0,0 +1,12 @@
namespace SVSim.BattleNode.Wire;
public enum EngineIoPacketType
{
Open = 0,
Close = 1,
Ping = 2,
Pong = 3,
Message = 4,
Upgrade = 5,
Noop = 6,
}

View File

@@ -0,0 +1,79 @@
using System.Security.Cryptography;
using System.Text;
namespace SVSim.BattleNode.Wire;
/// <summary>
/// AES-256-CBC encrypt/decrypt for the node socket channel. Port of
/// Cryptographer.EncryptRJ256ForNode / DecryptRJ256ForNode in the decompilation.
/// Key is prepended to ciphertext (cleartext); IV is the first 16 chars of the key.
/// </summary>
public static class NodeCrypto
{
/// <summary>
/// Generate a fresh 32-char key for server-initiated encryption.
/// Calls <paramref name="randHexDigit"/> 32 times; the result is masked with
/// <c>&amp; 0xF</c> so a misbehaving caller that returns a larger int still produces
/// exactly one hex digit per iteration (the internal contract is "32 hex chars").
/// The 32-char ASCII string is then base64-encoded and truncated to 32 chars.
/// </summary>
/// <remarks>
/// Differs from the client's <c>Cryptographer.generateKeyString</c> in input shape:
/// the client uses <c>Random.Next(0, 65535).ToString("x")</c> per iteration (14 hex
/// chars each). The output distribution is therefore different, but both produce a
/// valid 32-char UTF-8 AES-256 key — and the client never validates the server's key
/// since the server is decrypt-only in practice. Server-initiated encryption (e.g.
/// for <c>synchronize</c> pushes) uses this method.
/// </remarks>
public static string GenerateKey(Func<int> randHexDigit)
{
var sb = new StringBuilder(32);
for (var i = 0; i < 32; i++)
{
sb.Append((randHexDigit() & 0xF).ToString("x"));
}
var ascii = Encoding.ASCII.GetBytes(sb.ToString());
return Convert.ToBase64String(ascii).Substring(0, 32);
}
/// <summary>Encrypt: returns key + base64(AES-256-CBC(plain)).</summary>
public static string EncryptForNode(string plaintext, string key)
{
if (key.Length != 32)
throw new ArgumentException($"Key must be exactly 32 chars, got {key.Length}", nameof(key));
using var aes = BuildAes(key);
using var encryptor = aes.CreateEncryptor();
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
return key + Convert.ToBase64String(cipherBytes);
}
/// <summary>Decrypt: input[0..32] is key, input[32..] is base64(ciphertext).</summary>
public static string DecryptForNode(string encrypted)
{
if (encrypted.Length < 32)
throw new ArgumentException("Encrypted blob is shorter than the 32-char key prefix", nameof(encrypted));
var key = encrypted.Substring(0, 32);
var cipherBytes = Convert.FromBase64String(encrypted.Substring(32));
using var aes = BuildAes(key);
using var decryptor = aes.CreateDecryptor();
var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
return Encoding.UTF8.GetString(plainBytes);
}
/// <summary>
/// Configure an AES-256-CBC instance with the node's IV derivation (first 16 chars
/// of the key, UTF-8). Callers own disposal. Assumes <paramref name="key"/> is the
/// 32-char ASCII key the encrypt / decrypt path has already validated.
/// </summary>
private static Aes BuildAes(string key)
{
var aes = Aes.Create();
aes.KeySize = 256;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.Key = Encoding.UTF8.GetBytes(key);
aes.IV = Encoding.UTF8.GetBytes(key.Substring(0, 16));
return aes;
}
}

View File

@@ -0,0 +1,208 @@
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace SVSim.BattleNode.Wire;
file static class SocketIoJsonOptions
{
internal static readonly JsonSerializerOptions EventNameOptions = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
}
/// <summary>
/// Socket.IO v2 packet. Wire form: <c>&lt;type&gt;&lt;N&gt;-&lt;ackId?&gt;[json-args]</c> where
/// <c>&lt;N&gt;-</c> appears only on binary types (5/6). For binary events/acks, the JSON contains
/// placeholders <c>{"_placeholder":true,"num":N}</c> that index into <see cref="BinaryAttachments"/>.
/// </summary>
public sealed class SocketIoFrame
{
public SocketIoPacketType Type { get; }
public int? AckId { get; }
public int AttachmentCount { get; }
public string? EventName { get; }
public JsonElement[] RawArgs { get; }
public IReadOnlyList<byte[]> BinaryAttachments { get; }
public SocketIoFrame(
SocketIoPacketType type,
int? ackId,
int attachmentCount,
string? eventName,
JsonElement[] rawArgs,
IReadOnlyList<byte[]> binaryAttachments)
{
Type = type;
AckId = ackId;
AttachmentCount = attachmentCount;
EventName = eventName;
RawArgs = rawArgs;
BinaryAttachments = binaryAttachments;
}
/// <summary>
/// Parse the text portion of a SIO frame. For binary events the attachments arrive as separate
/// WS frames after the text — the caller wires them up via <see cref="WithAttachments"/>.
/// </summary>
public static SocketIoFrame Parse(string raw)
{
if (string.IsNullOrEmpty(raw))
throw new ArgumentException("Empty SIO payload", nameof(raw));
var type = (SocketIoPacketType)(raw[0] - '0');
var cursor = 1;
var attachmentCount = 0;
if (type is SocketIoPacketType.BinaryEvent or SocketIoPacketType.BinaryAck)
{
var dashIdx = raw.IndexOf('-', cursor);
if (dashIdx < 0)
throw new ArgumentException("Binary frame missing '-' separator", nameof(raw));
if (!int.TryParse(raw.AsSpan(cursor, dashIdx - cursor), out attachmentCount))
throw new ArgumentException("Binary frame attachment count not parseable", nameof(raw));
cursor = dashIdx + 1;
}
// Namespace prefix (only present if '/' starts here, terminated by ','). v1 only
// uses the default namespace; anything else is a protocol surprise we should
// surface rather than silently route to default. If we ever support non-default
// namespaces, capture into a property and let callers branch.
if (cursor < raw.Length && raw[cursor] == '/')
{
var commaIdx = raw.IndexOf(',', cursor);
var ns = commaIdx >= 0 ? raw.Substring(cursor, commaIdx - cursor) : raw.Substring(cursor);
throw new ArgumentException(
$"Socket.IO namespaces aren't supported — got '{ns}'. v1 expects default namespace only.",
nameof(raw));
}
int? ackId = null;
if (cursor < raw.Length && char.IsDigit(raw[cursor]))
{
var start = cursor;
while (cursor < raw.Length && char.IsDigit(raw[cursor])) cursor++;
ackId = int.Parse(raw.AsSpan(start, cursor - start));
}
var argsJson = cursor < raw.Length ? raw.Substring(cursor) : string.Empty;
JsonElement[] allElements;
if (string.IsNullOrEmpty(argsJson))
{
allElements = Array.Empty<JsonElement>();
}
else
{
using var doc = JsonDocument.Parse(argsJson);
allElements = doc.RootElement.EnumerateArray().Select(el => el.Clone()).ToArray();
}
string? eventName = null;
JsonElement[] rawArgs;
if (type is SocketIoPacketType.Event or SocketIoPacketType.BinaryEvent && allElements.Length > 0)
{
eventName = allElements[0].GetString();
// RawArgs excludes the leading event-name element so callers index args from 0.
rawArgs = allElements.Length > 1 ? allElements[1..] : Array.Empty<JsonElement>();
}
else
{
rawArgs = allElements;
}
return new SocketIoFrame(type, ackId, attachmentCount, eventName, rawArgs, Array.Empty<byte[]>());
}
/// <summary>
/// Return a new frame with the given binary attachments attached. Throws if the count doesn't
/// match the header's declared attachment count.
/// </summary>
public SocketIoFrame WithAttachments(IReadOnlyList<byte[]> attachments)
{
if (attachments.Count != AttachmentCount)
throw new ArgumentException(
$"Attachment count mismatch: header says {AttachmentCount}, got {attachments.Count}");
return new SocketIoFrame(Type, AckId, AttachmentCount, EventName, RawArgs, attachments);
}
/// <summary>
/// Build a binary event frame for the given event name + binary attachments.
/// The JSON args become <c>[eventName, {_placeholder:true,num:0}, {_placeholder:true,num:1}, ...]</c>.
/// </summary>
public static SocketIoFrame BinaryEventWithAttachments(string eventName, IReadOnlyList<byte[]> attachments)
{
// Build placeholders via the typed Nodes API; event name is stored separately.
var placeholders = new JsonArray();
for (var i = 0; i < attachments.Count; i++)
{
placeholders.Add(new JsonObject
{
["_placeholder"] = true,
["num"] = i,
});
}
return new SocketIoFrame(
SocketIoPacketType.BinaryEvent,
ackId: null,
attachmentCount: attachments.Count,
eventName: eventName,
rawArgs: NodesToElements(placeholders),
binaryAttachments: attachments);
}
/// <summary>Build an ack response with a single int argument (the spec's pubSeq echo).</summary>
public static SocketIoFrame AckResponse(int ackId, int arg)
{
var args = new JsonArray { arg };
return new SocketIoFrame(
SocketIoPacketType.Ack, ackId, 0, null, NodesToElements(args), Array.Empty<byte[]>());
}
/// <summary>
/// Convert a <see cref="JsonArray"/> into the <see cref="JsonElement"/>[] that
/// <see cref="RawArgs"/> stores. The current storage type is <see cref="JsonElement"/>
/// because <see cref="Parse"/> produces it from <see cref="JsonDocument"/>; this helper
/// keeps the typed-construction call sites without changing <see cref="RawArgs"/>.
/// </summary>
private static JsonElement[] NodesToElements(JsonArray nodes)
{
using var doc = JsonDocument.Parse(nodes.ToJsonString());
return doc.RootElement.EnumerateArray().Select(el => el.Clone()).ToArray();
}
/// <summary>
/// Encode to the wire form: (text payload, ordered list of binary attachments).
/// The caller is responsible for sending the text frame first then each binary attachment frame.
/// </summary>
public (string Text, IReadOnlyList<byte[]> Binaries) Encode()
{
var sb = new StringBuilder();
sb.Append((int)Type);
if (Type is SocketIoPacketType.BinaryEvent or SocketIoPacketType.BinaryAck)
{
sb.Append(AttachmentCount).Append('-');
}
if (AckId.HasValue) sb.Append(AckId.Value);
// Re-serialize args — for event/binary-event types, re-prepend the event name.
bool hasJsonPayload = EventName is not null || RawArgs.Length > 0;
if (hasJsonPayload)
{
sb.Append('[');
if (EventName is not null)
{
sb.Append(JsonSerializer.Serialize(EventName, SocketIoJsonOptions.EventNameOptions));
if (RawArgs.Length > 0) sb.Append(',');
}
for (var i = 0; i < RawArgs.Length; i++)
{
if (i > 0) sb.Append(',');
sb.Append(RawArgs[i].GetRawText());
}
sb.Append(']');
}
return (sb.ToString(), BinaryAttachments);
}
}

View File

@@ -0,0 +1,12 @@
namespace SVSim.BattleNode.Wire;
public enum SocketIoPacketType
{
Connect = 0,
Disconnect = 1,
Event = 2,
Ack = 3,
Error = 4,
BinaryEvent = 5,
BinaryAck = 6,
}

View File

@@ -0,0 +1,130 @@
[
{
"ai_id": 1111,
"country_code": "JPN",
"user_name": "Forestcraft AI",
"sleeve_id": 704141010,
"emblem_id": 400001100,
"degree_id": 120027,
"field_id": 5,
"is_official": 0,
"class_id": 1,
"chara_id": 1,
"rank": 10,
"battle_point": 0,
"is_master_rank": 0,
"master_point": 0
},
{
"ai_id": 1121,
"country_code": "JPN",
"user_name": "Swordcraft AI",
"sleeve_id": 704141010,
"emblem_id": 400001100,
"degree_id": 120027,
"field_id": 5,
"is_official": 0,
"class_id": 2,
"chara_id": 2,
"rank": 10,
"battle_point": 0,
"is_master_rank": 0,
"master_point": 0
},
{
"ai_id": 1131,
"country_code": "JPN",
"user_name": "Runecraft AI",
"sleeve_id": 704141010,
"emblem_id": 400001100,
"degree_id": 120027,
"field_id": 5,
"is_official": 0,
"class_id": 3,
"chara_id": 3,
"rank": 10,
"battle_point": 0,
"is_master_rank": 0,
"master_point": 0
},
{
"ai_id": 1141,
"country_code": "JPN",
"user_name": "Dragoncraft AI",
"sleeve_id": 704141010,
"emblem_id": 400001100,
"degree_id": 120027,
"field_id": 5,
"is_official": 0,
"class_id": 4,
"chara_id": 4,
"rank": 10,
"battle_point": 0,
"is_master_rank": 0,
"master_point": 0
},
{
"ai_id": 1151,
"country_code": "JPN",
"user_name": "Shadowcraft AI",
"sleeve_id": 704141010,
"emblem_id": 400001100,
"degree_id": 120027,
"field_id": 5,
"is_official": 0,
"class_id": 5,
"chara_id": 5,
"rank": 10,
"battle_point": 0,
"is_master_rank": 0,
"master_point": 0
},
{
"ai_id": 1161,
"country_code": "JPN",
"user_name": "Bloodcraft AI",
"sleeve_id": 704141010,
"emblem_id": 400001100,
"degree_id": 120027,
"field_id": 5,
"is_official": 0,
"class_id": 6,
"chara_id": 6,
"rank": 10,
"battle_point": 0,
"is_master_rank": 0,
"master_point": 0
},
{
"ai_id": 1171,
"country_code": "JPN",
"user_name": "Havencraft AI",
"sleeve_id": 704141010,
"emblem_id": 400001100,
"degree_id": 120027,
"field_id": 5,
"is_official": 0,
"class_id": 7,
"chara_id": 7,
"rank": 10,
"battle_point": 0,
"is_master_rank": 0,
"master_point": 0
},
{
"ai_id": 1181,
"country_code": "JPN",
"user_name": "Portalcraft AI",
"sleeve_id": 704141010,
"emblem_id": 400001100,
"degree_id": 120027,
"field_id": 5,
"is_official": 0,
"class_id": 8,
"chara_id": 8,
"rank": 10,
"battle_point": 0,
"is_master_rank": 0,
"master_point": 0
}
]

View File

@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore;
using SVSim.Bootstrap.Models.Seed;
using SVSim.Database;
using SVSim.Database.Models;
namespace SVSim.Bootstrap.Importers;
/// <summary>
/// Idempotent upsert of AI bot opponents from <c>seeds/bot-roster.json</c>.
/// Rows missing from the seed are LEFT INTACT (consistent with PracticeOpponentImporter;
/// a partial seed shouldn't silently delete entries).
/// </summary>
public class BotRosterImporter
{
public async Task<int> ImportAsync(SVSimDbContext context, string seedDir)
{
string path = Path.Combine(seedDir, "bot-roster.json");
var seed = SeedLoader.LoadList<BotRosterSeed>(path);
if (seed.Count == 0)
{
Console.WriteLine("[BotRosterImporter] No seed rows; skipping.");
return 0;
}
var existing = await context.BotRoster.ToDictionaryAsync(e => e.Id);
int created = 0, updated = 0;
foreach (var s in seed)
{
if (s.AiId == 0) continue;
var entry = existing.TryGetValue(s.AiId, out var ex)
? ex : new BotRosterEntry { Id = s.AiId };
entry.CountryCode = s.CountryCode;
entry.UserName = s.UserName;
entry.SleeveId = s.SleeveId;
entry.EmblemId = s.EmblemId;
entry.DegreeId = s.DegreeId;
entry.FieldId = s.FieldId;
entry.IsOfficial = s.IsOfficial;
entry.ClassId = s.ClassId;
entry.CharaId = s.CharaId;
entry.Rank = s.Rank;
entry.BattlePoint = s.BattlePoint;
entry.IsMasterRank = s.IsMasterRank;
entry.MasterPoint = s.MasterPoint;
if (ex is null)
{
context.BotRoster.Add(entry);
existing[s.AiId] = entry;
created++;
}
else updated++;
}
await context.SaveChangesAsync();
Console.WriteLine($"[BotRosterImporter] +{created}/~{updated}");
return created + updated;
}
}

View File

@@ -15,14 +15,30 @@ namespace SVSim.Bootstrap.Importers;
/// </summary>
public class ReferenceDataImporter
{
private readonly TextWriter _out;
private readonly TextWriter _err;
public ReferenceDataImporter() : this(Console.Out, Console.Error) { }
/// <summary>
/// Pass <see cref="TextWriter.Null"/> for both to silence progress banners (tests
/// instantiate this importer ~500 times per run; the captured stdout otherwise OOMs
/// the NUnit trx serializer).
/// </summary>
public ReferenceDataImporter(TextWriter output, TextWriter error)
{
_out = output;
_err = error;
}
public async Task ImportAllAsync(SVSimDbContext context, string dataDir)
{
if (!Directory.Exists(dataDir))
{
Console.Error.WriteLine($"[ReferenceDataImporter] Data dir missing: {dataDir}");
_err.WriteLine($"[ReferenceDataImporter] Data dir missing: {dataDir}");
return;
}
Console.WriteLine($"[ReferenceDataImporter] Reading CSVs from {dataDir}...");
_out.WriteLine($"[ReferenceDataImporter] Reading CSVs from {dataDir}...");
await ImportClasses(context, dataDir);
await ImportLeaderSkins(context, dataDir);
@@ -34,10 +50,10 @@ public class ReferenceDataImporter
await ImportRankInfo(context, dataDir);
await ImportClassExp(context, dataDir);
Console.WriteLine("[ReferenceDataImporter] Done.");
_out.WriteLine("[ReferenceDataImporter] Done.");
}
private static async Task ImportClasses(SVSimDbContext ctx, string dir)
private async Task ImportClasses(SVSimDbContext ctx, string dir)
{
var rows = ReadCsv<ClassEntry, ClassEntryMap>(dir, "classes.csv");
var existing = await ctx.Classes.ToDictionaryAsync(c => c.Id);
@@ -51,10 +67,10 @@ public class ReferenceDataImporter
else { ctx.Classes.Add(r); created++; }
}
await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] Classes: +{created} / ~{updated}");
_out.WriteLine($"[ReferenceDataImporter] Classes: +{created} / ~{updated}");
}
private static async Task ImportLeaderSkins(SVSimDbContext ctx, string dir)
private async Task ImportLeaderSkins(SVSimDbContext ctx, string dir)
{
var rows = ReadCsv<LeaderSkinEntry, LeaderSkinEntryMap>(dir, "leaderskins.csv");
// CSV writes class_chara_id=0 for neutral/unassigned; the FK column is nullable.
@@ -74,10 +90,10 @@ public class ReferenceDataImporter
else { ctx.LeaderSkins.Add(r); created++; }
}
await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] LeaderSkins: +{created} / ~{updated}");
_out.WriteLine($"[ReferenceDataImporter] LeaderSkins: +{created} / ~{updated}");
}
private static async Task ImportSleeves(SVSimDbContext ctx, string dir)
private async Task ImportSleeves(SVSimDbContext ctx, string dir)
{
var rows = ReadCsv<SleeveEntry, SleeveEntryMap>(dir, "sleeves.csv");
var existing = (await ctx.Sleeves.ToListAsync()).ToHashSet();
@@ -88,10 +104,10 @@ public class ReferenceDataImporter
ctx.Sleeves.Add(r); created++;
}
await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] Sleeves: +{created}");
_out.WriteLine($"[ReferenceDataImporter] Sleeves: +{created}");
}
private static async Task ImportEmblems(SVSimDbContext ctx, string dir)
private async Task ImportEmblems(SVSimDbContext ctx, string dir)
{
var rows = ReadCsv<EmblemEntry, EmblemEntryMap>(dir, "emblems.csv");
var existing = (await ctx.Emblems.Select(e => e.Id).ToListAsync()).ToHashSet();
@@ -102,10 +118,10 @@ public class ReferenceDataImporter
ctx.Emblems.Add(r); created++;
}
await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] Emblems: +{created}");
_out.WriteLine($"[ReferenceDataImporter] Emblems: +{created}");
}
private static async Task ImportDegrees(SVSimDbContext ctx, string dir)
private async Task ImportDegrees(SVSimDbContext ctx, string dir)
{
var rows = ReadCsv<DegreeEntry, DegreeEntryMap>(dir, "degrees.csv");
var existing = (await ctx.Degrees.Select(e => e.Id).ToListAsync()).ToHashSet();
@@ -116,10 +132,10 @@ public class ReferenceDataImporter
ctx.Degrees.Add(r); created++;
}
await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] Degrees: +{created}");
_out.WriteLine($"[ReferenceDataImporter] Degrees: +{created}");
}
private static async Task ImportBattlefields(SVSimDbContext ctx, string dir)
private async Task ImportBattlefields(SVSimDbContext ctx, string dir)
{
var rows = ReadCsv<BattlefieldEntry, BattlefieldEntryMap>(dir, "battlefields.csv");
var existing = await ctx.Battlefields.ToDictionaryAsync(b => b.Id);
@@ -133,10 +149,10 @@ public class ReferenceDataImporter
else { ctx.Battlefields.Add(r); created++; }
}
await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] Battlefields: +{created} / ~{updated}");
_out.WriteLine($"[ReferenceDataImporter] Battlefields: +{created} / ~{updated}");
}
private static async Task ImportMyPageBackgrounds(SVSimDbContext ctx, string dir)
private async Task ImportMyPageBackgrounds(SVSimDbContext ctx, string dir)
{
var rows = ReadCsv<MyPageBackgroundEntry, MyPageBackgroundEntryMap>(dir, "mypagebackgrounds.csv");
var existing = (await ctx.MyPageBackgrounds.Select(e => e.Id).ToListAsync()).ToHashSet();
@@ -147,10 +163,10 @@ public class ReferenceDataImporter
ctx.MyPageBackgrounds.Add(r); created++;
}
await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] MyPageBackgrounds: +{created}");
_out.WriteLine($"[ReferenceDataImporter] MyPageBackgrounds: +{created}");
}
private static async Task ImportRankInfo(SVSimDbContext ctx, string dir)
private async Task ImportRankInfo(SVSimDbContext ctx, string dir)
{
var rows = ReadCsv<RankInfoEntry, RankInfoEntryMap>(dir, "ranks.csv");
var existing = await ctx.RankInfo.ToDictionaryAsync(r => r.Id);
@@ -164,7 +180,7 @@ public class ReferenceDataImporter
else { ctx.RankInfo.Add(r); created++; }
}
await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] RankInfo: +{created} / ~{updated}");
_out.WriteLine($"[ReferenceDataImporter] RankInfo: +{created} / ~{updated}");
}
private static bool ApplyRankUpdates(RankInfoEntry e, RankInfoEntry r)
@@ -189,7 +205,7 @@ public class ReferenceDataImporter
return changed;
}
private static async Task ImportClassExp(SVSimDbContext ctx, string dir)
private async Task ImportClassExp(SVSimDbContext ctx, string dir)
{
var rows = ReadCsv<ClassExpEntry, ClassExpEntryMap>(dir, "classexp.csv");
var existing = await ctx.ClassExpCurve.ToDictionaryAsync(c => c.Id);
@@ -203,15 +219,15 @@ public class ReferenceDataImporter
else { ctx.ClassExpCurve.Add(r); created++; }
}
await ctx.SaveChangesAsync();
Console.WriteLine($"[ReferenceDataImporter] ClassExp: +{created} / ~{updated}");
_out.WriteLine($"[ReferenceDataImporter] ClassExp: +{created} / ~{updated}");
}
private static List<T> ReadCsv<T, TMap>(string dir, string fileName) where TMap : ClassMap<T>, new()
private List<T> ReadCsv<T, TMap>(string dir, string fileName) where TMap : ClassMap<T>, new()
{
string path = Path.Combine(dir, fileName);
if (!File.Exists(path))
{
Console.Error.WriteLine($"[ReferenceDataImporter] Missing CSV: {path}");
_err.WriteLine($"[ReferenceDataImporter] Missing CSV: {path}");
return new List<T>();
}
using var reader = new StreamReader(path);

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace SVSim.Bootstrap.Models.Seed;
public sealed class BotRosterSeed
{
[JsonPropertyName("ai_id")] public int AiId { get; set; }
[JsonPropertyName("country_code")] public string CountryCode { get; set; } = "";
[JsonPropertyName("user_name")] public string UserName { get; set; } = "";
[JsonPropertyName("sleeve_id")] public int SleeveId { get; set; }
[JsonPropertyName("emblem_id")] public int EmblemId { get; set; }
[JsonPropertyName("degree_id")] public int DegreeId { get; set; }
[JsonPropertyName("field_id")] public int FieldId { get; set; }
[JsonPropertyName("is_official")] public int IsOfficial { get; set; }
[JsonPropertyName("class_id")] public int ClassId { get; set; }
[JsonPropertyName("chara_id")] public int CharaId { get; set; }
[JsonPropertyName("rank")] public int Rank { get; set; }
[JsonPropertyName("battle_point")] public int BattlePoint { get; set; }
[JsonPropertyName("is_master_rank")] public int IsMasterRank { get; set; }
[JsonPropertyName("master_point")] public int MasterPoint { get; set; }
}

View File

@@ -97,6 +97,7 @@ public static class Program
await new RotationFlagUpdater().UpdateAsync(context);
await new PracticeOpponentImporter().ImportAsync(context, opts.SeedDir);
await new BotRosterImporter().ImportAsync(context, opts.SeedDir);
await new PaymentItemImporter().ImportAsync(context, opts.SeedDir);
await new ItemImporter().ImportAsync(context, opts.SeedDir);
await new SleeveShopImporter().ImportAsync(context, opts.SeedDir);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddBotRoster : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BotRoster",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false),
AiId = table.Column<int>(type: "integer", nullable: false),
CountryCode = table.Column<string>(type: "text", nullable: false),
UserName = table.Column<string>(type: "text", nullable: false),
SleeveId = table.Column<int>(type: "integer", nullable: false),
EmblemId = table.Column<int>(type: "integer", nullable: false),
DegreeId = table.Column<int>(type: "integer", nullable: false),
FieldId = table.Column<int>(type: "integer", nullable: false),
IsOfficial = table.Column<int>(type: "integer", nullable: false),
ClassId = table.Column<int>(type: "integer", nullable: false),
CharaId = table.Column<int>(type: "integer", nullable: false),
Rank = table.Column<int>(type: "integer", nullable: false),
BattlePoint = table.Column<int>(type: "integer", nullable: false),
IsMasterRank = table.Column<int>(type: "integer", nullable: false),
MasterPoint = table.Column<int>(type: "integer", nullable: false),
DateCreated = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DateUpdated = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BotRoster", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BotRoster");
}
}
}

View File

@@ -756,6 +756,66 @@ namespace SVSim.Database.Migrations
b.ToTable("Battlefields");
});
modelBuilder.Entity("SVSim.Database.Models.BotRosterEntry", b =>
{
b.Property<int>("Id")
.HasColumnType("integer");
b.Property<int>("AiId")
.HasColumnType("integer");
b.Property<int>("BattlePoint")
.HasColumnType("integer");
b.Property<int>("CharaId")
.HasColumnType("integer");
b.Property<int>("ClassId")
.HasColumnType("integer");
b.Property<string>("CountryCode")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("DateCreated")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DateUpdated")
.HasColumnType("timestamp with time zone");
b.Property<int>("DegreeId")
.HasColumnType("integer");
b.Property<int>("EmblemId")
.HasColumnType("integer");
b.Property<int>("FieldId")
.HasColumnType("integer");
b.Property<int>("IsMasterRank")
.HasColumnType("integer");
b.Property<int>("IsOfficial")
.HasColumnType("integer");
b.Property<int>("MasterPoint")
.HasColumnType("integer");
b.Property<int>("Rank")
.HasColumnType("integer");
b.Property<int>("SleeveId")
.HasColumnType("integer");
b.Property<string>("UserName")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("BotRoster");
});
modelBuilder.Entity("SVSim.Database.Models.BuildDeckProductEntry", b =>
{
b.Property<int>("Id")

View File

@@ -0,0 +1,39 @@
using SVSim.Database.Common;
namespace SVSim.Database.Models;
/// <summary>
/// One row per AI bot opponent the rank-battle AI-fallback path can pick. Populated
/// from seeds/bot-roster.json by SVSim.Bootstrap.BotRosterImporter.
///
/// The Id (= AiId) MUST match a row in the client's baked-in master CSV
/// <c>data_dumps/client-assets/rm_ai_setting.csv</c>; if it doesn't, the client's
/// <c>RankMatchAISettingList.GetSettingData(aiId)</c> throws
/// <c>InvalidOperationException</c> at battle-start.
///
/// Cosmetic ids (sleeve / emblem / degree / field) MUST resolve in
/// <c>SBattleLoad.LoadOpponentAssets</c>; placeholder 1s left the client hanging on
/// "Waiting for opponent". Prod-verified values come from the Scripted bot fixture.
/// </summary>
public class BotRosterEntry : BaseEntity<int>
{
/// <summary>Client AI catalog id (rm_ai_setting.csv enemy_ai_id). Also the PK.</summary>
public int AiId { get => Id; set => Id = value; }
public string CountryCode { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public int SleeveId { get; set; }
public int EmblemId { get; set; }
public int DegreeId { get; set; }
public int FieldId { get; set; }
public int IsOfficial { get; set; }
public int ClassId { get; set; }
public int CharaId { get; set; }
public int Rank { get; set; }
public int BattlePoint { get; set; }
public int IsMasterRank { get; set; }
public int MasterPoint { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace SVSim.Database.Models.Config;
/// <summary>
/// Tunables for the in-process pair-up matching service. Today: just the AI-fallback
/// threshold for rank-battle modes. The full matching-queue API is a separate spec;
/// this config section lives alongside the placeholder.
/// </summary>
[ConfigSection("Matching")]
public class MatchingConfig
{
/// <summary>
/// How long (seconds) a viewer must have been parked in a PvpFirstThenAiFallback
/// queue before their next /do_matching poll resolves to an AI battle.
/// Defaults to 15 — matches the prod 4s pre-AIBattleStart pause plus a comfortable
/// polling cycle.
/// </summary>
public int RankBattleAiFallbackThresholdSeconds { get; set; } = 15;
public static MatchingConfig ShippedDefaults() => new();
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.BattlePass;
@@ -6,14 +7,19 @@ namespace SVSim.Database.Repositories.BattlePass;
public sealed class BattlePassRepository : IBattlePassRepository
{
private readonly SVSimDbContext _db;
private readonly IMemoryCache _cache;
// Process-level cache for the immutable level curve. Bootstrap re-baseline = host restart = cache cleared.
private static IReadOnlyList<BattlePassLevelEntry>? _curveCache;
private static readonly SemaphoreSlim _curveCacheLock = new(1, 1);
// Per-host cache for the immutable level curve, scoped via the DI-registered IMemoryCache.
// In production "host == process"; in tests each WebApplicationFactory builds its own
// service provider so the cache is naturally isolated per fixture — avoids the pre-refactor
// race where a process-static cache populated from one test's DbContext served stale data
// to a parallel test reading from a different DB.
private const string LevelCurveCacheKey = "battlepass:level-curve";
public BattlePassRepository(SVSimDbContext db)
public BattlePassRepository(SVSimDbContext db, IMemoryCache cache)
{
_db = db;
_cache = cache;
}
public async Task<BattlePassSeasonEntry?> GetActiveSeasonAsync(DateTimeOffset when, CancellationToken ct)
@@ -42,25 +48,10 @@ public sealed class BattlePassRepository : IBattlePassRepository
public async Task<IReadOnlyList<BattlePassLevelEntry>> GetLevelCurveAsync(CancellationToken ct)
{
if (_curveCache is not null) return _curveCache;
await _curveCacheLock.WaitAsync(ct);
try
{
if (_curveCache is null)
{
_curveCache = await _db.BattlePassLevels.AsNoTracking()
.OrderBy(e => e.Level)
.ToListAsync(ct);
}
return _curveCache;
}
finally { _curveCacheLock.Release(); }
var cached = await _cache.GetOrCreateAsync(LevelCurveCacheKey, async _ =>
(IReadOnlyList<BattlePassLevelEntry>)await _db.BattlePassLevels.AsNoTracking()
.OrderBy(e => e.Level)
.ToListAsync(ct));
return cached!;
}
/// <summary>
/// Drops the process-level level-curve cache. Tests that seed BattlePassLevels after the
/// cache has already been populated (by an earlier test's HTTP call) must call this before
/// re-seeding so the next read fetches fresh rows.
/// </summary>
internal static void ResetLevelCurveCache() => _curveCache = null;
}

View File

@@ -2,18 +2,19 @@ using Microsoft.EntityFrameworkCore;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
namespace SVSim.Database.Repositories.Card;
public class CardInventoryRepository : ICardInventoryRepository
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _grants;
private readonly IInventoryService _inv;
public CardInventoryRepository(SVSimDbContext db, RewardGrantService grants)
public CardInventoryRepository(SVSimDbContext db, IInventoryService inv)
{
_db = db;
_grants = grants;
_inv = inv;
}
public async Task<DestructOutcome> DestructCards(long viewerId, IReadOnlyDictionary<long, int> destructCounts)
@@ -129,30 +130,27 @@ public class CardInventoryRepository : ICardInventoryRepository
totalCost += (ulong)catalogCard.CollectionInfo.CraftCost * (ulong)num;
}
// insufficient_vials checked after summing the full batch — all-or-nothing
// insufficient_vials pre-check (validation-before-mutation atomicity, keeps same error ordering)
if (viewer.Currency.RedEther < totalCost)
return CreateOutcome.Fail(CreateError.InsufficientVials);
using var tx = await _db.Database.BeginTransactionAsync();
// Mutation phase via InventoryService transaction — freeplay-aware RedEther debit,
// card grants with cosmetic cascade.
await using var tx = await _inv.BeginAsync(viewerId);
// Debit RedEther directly. ApplyAsync only credits — debit-pair operations live in this
// repo, symmetric with destruct.
viewer.Currency.RedEther -= totalCost;
var spendResult = await tx.TrySpendAsync(SpendCurrency.RedEther, (long)totalCost);
if (!spendResult.Success)
return CreateOutcome.Fail(CreateError.InsufficientVials);
// 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);
var granted = await tx.GrantAsync(UserGoodsType.Card, cardId, num);
allGrants.AddRange(granted);
}
await _db.SaveChangesAsync();
await tx.CommitAsync();
return CreateOutcome.Ok(new CreateResult(viewer.Currency.RedEther, allGrants));
return CreateOutcome.Ok(new CreateResult(tx.Viewer.Currency.RedEther, allGrants));
}
public async Task<ProtectOutcome> SetProtected(long viewerId, long cardId, bool isProtected)

View File

@@ -104,4 +104,7 @@ public class GlobalsRepository : IGlobalsRepository
public Task<List<PracticeOpponentEntry>> GetPracticeOpponents() =>
_dbContext.PracticeOpponents.AsNoTracking().OrderBy(e => e.ClassId).ThenBy(e => e.Id).ToListAsync();
public Task<List<BotRosterEntry>> GetBotRoster() =>
_dbContext.BotRoster.AsNoTracking().OrderBy(e => e.ClassId).ThenBy(e => e.Id).ToListAsync();
}

View File

@@ -31,4 +31,5 @@ public interface IGlobalsRepository
Task<PreReleaseInfo?> GetPreReleaseInfo();
Task<List<ShadowverseCardSetEntry>> GetRotationCardSets();
Task<List<PracticeOpponentEntry>> GetPracticeOpponents();
Task<List<BotRosterEntry>> GetBotRoster();
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using SVSim.Database.Models;
namespace SVSim.Database.Repositories.Mission;
@@ -6,13 +7,18 @@ namespace SVSim.Database.Repositories.Mission;
public sealed class MissionCatalogRepository : IMissionCatalogRepository
{
private readonly SVSimDbContext _db;
private readonly IMemoryCache _cache;
// Process-level cache for the derived MAX(Level) lookup. Cleared on host restart
// (re-bootstrap is the only legitimate way to mutate the catalog at runtime).
private static IReadOnlyDictionary<int, int>? _maxLevelCache;
private static readonly SemaphoreSlim _maxLevelLock = new(1, 1);
// Per-host cache for the derived MAX(Level) lookup, scoped via the DI-registered
// IMemoryCache. See BattlePassRepository for the per-host rationale (same parallel-test
// race avoidance — each WebApplicationFactory gets its own cache).
private const string MaxLevelCacheKey = "mission:achievement-max-level-by-type";
public MissionCatalogRepository(SVSimDbContext db) { _db = db; }
public MissionCatalogRepository(SVSimDbContext db, IMemoryCache cache)
{
_db = db;
_cache = cache;
}
public Task<List<MissionCatalogEntry>> GetByLotTypeAsync(int lotType, CancellationToken ct) =>
_db.MissionCatalog.AsNoTracking().Where(e => e.LotType == lotType).ToListAsync(ct);
@@ -40,21 +46,15 @@ public sealed class MissionCatalogRepository : IMissionCatalogRepository
public async Task<IReadOnlyDictionary<int, int>> GetMaxLevelByAchievementTypeAsync(CancellationToken ct)
{
if (_maxLevelCache is not null) return _maxLevelCache;
await _maxLevelLock.WaitAsync(ct);
try
var cached = await _cache.GetOrCreateAsync(MaxLevelCacheKey, async _ =>
{
if (_maxLevelCache is null)
{
var pairs = await _db.AchievementCatalog.AsNoTracking()
.GroupBy(e => e.AchievementType)
.Select(g => new { Type = g.Key, Max = g.Max(e => e.Level) })
.ToListAsync(ct);
_maxLevelCache = pairs.ToDictionary(p => p.Type, p => p.Max);
}
return _maxLevelCache;
}
finally { _maxLevelLock.Release(); }
var pairs = await _db.AchievementCatalog.AsNoTracking()
.GroupBy(e => e.AchievementType)
.Select(g => new { Type = g.Key, Max = g.Max(e => e.Level) })
.ToListAsync(ct);
return (IReadOnlyDictionary<int, int>)pairs.ToDictionary(p => p.Type, p => p.Max);
});
return cached!;
}
public async Task<IReadOnlyDictionary<int, int>> GetMinLevelByAchievementTypeAsync(CancellationToken ct)

View File

@@ -13,4 +13,18 @@ public interface IViewerRepository
ulong socialAccountIdentifier, ulong? shortUdid = null);
Task<Models.Viewer> RegisterAnonymousViewer(Guid udid);
Task LinkSteamToViewer(long viewerId, ulong steamId);
/// <summary>
/// Merges an anonymous viewer (just created by <c>/tool/signup</c> on a fresh UDID)
/// into a target viewer that the Steam ticket resolved to. Transfers the anonymous
/// viewer's UDID to the target, then deletes the anonymous viewer.
/// </summary>
Task MergeAnonymousViewerInto(long anonymousViewerId, long targetViewerId);
/// <summary>
/// Focused load for building a battle-node <c>MatchContext</c>: viewer + Info + Info's
/// equipped Emblem/Degree nav refs. Read-only (AsNoTracking). Returns null if the viewer
/// doesn't exist.
/// </summary>
Task<Models.Viewer?> LoadForMatchContextAsync(long viewerId);
}

View File

@@ -182,6 +182,33 @@ public class ViewerRepository : IViewerRepository
return false;
}
public async Task MergeAnonymousViewerInto(long anonymousViewerId, long targetViewerId)
{
if (anonymousViewerId == targetViewerId) return;
var anon = await _dbContext.Set<Models.Viewer>()
.FirstOrDefaultAsync(v => v.Id == anonymousViewerId);
if (anon is null) return;
var target = await _dbContext.Set<Models.Viewer>()
.FirstOrDefaultAsync(v => v.Id == targetViewerId)
?? throw new InvalidOperationException(
$"Cannot merge anonymous viewer {anonymousViewerId}: target viewer {targetViewerId} not found.");
// Two saves: free the UDID slot on the anonymous viewer first (drops the unique-index
// conflict), then reassign to the target and delete the anonymous row in the second
// save. The partial-failure mode (first save succeeds, second fails) leaves a benign
// null-UDID viewer that no client can resolve to — never two rows contending for the
// same UDID, which is the failure we actually need to prevent.
Guid? freedUdid = anon.Udid;
anon.Udid = null;
await _dbContext.SaveChangesAsync();
target.Udid = freedUdid;
_dbContext.Set<Models.Viewer>().Remove(anon);
await _dbContext.SaveChangesAsync();
}
public async Task LinkSteamToViewer(long viewerId, ulong steamId)
{
var viewer = await _dbContext.Set<Models.Viewer>()
@@ -201,6 +228,15 @@ public class ViewerRepository : IViewerRepository
await _dbContext.SaveChangesAsync();
}
public async Task<Models.Viewer?> LoadForMatchContextAsync(long viewerId)
{
return await _dbContext.Set<Models.Viewer>()
.AsNoTracking()
.Include(v => v.Info.SelectedEmblem)
.Include(v => v.Info.SelectedDegree)
.FirstOrDefaultAsync(v => v.Id == viewerId);
}
private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1)
{
Models.Viewer viewer = new Models.Viewer

View File

@@ -86,6 +86,7 @@ public class SVSimDbContext : DbContext
public DbSet<FeatureMaintenanceEntry> FeatureMaintenances => Set<FeatureMaintenanceEntry>();
public DbSet<PreReleaseInfo> PreReleaseInfos => Set<PreReleaseInfo>();
public DbSet<PracticeOpponentEntry> PracticeOpponents => Set<PracticeOpponentEntry>();
public DbSet<BotRosterEntry> BotRoster => Set<BotRosterEntry>();
public DbSet<PuzzleGroupEntry> PuzzleGroups => Set<PuzzleGroupEntry>();
public DbSet<PuzzleEntry> Puzzles => Set<PuzzleEntry>();
public DbSet<PuzzleMissionEntry> PuzzleMissions => Set<PuzzleMissionEntry>();

View File

@@ -1,51 +0,0 @@
using SVSim.Database.Models;
namespace SVSim.Database.Services;
public class CurrencySpendService : ICurrencySpendService
{
private readonly IViewerEntitlements _entitlements;
public CurrencySpendService(IViewerEntitlements entitlements) => _entitlements = entitlements;
public Task<SpendResult> TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default)
{
if (cost < 0) cost = 0;
// Freeplay bypass applies only to the three main currencies; SpotPoint always real.
if (_entitlements.IsFreeplay && currency != SpendCurrency.SpotPoint)
{
return Task.FromResult(new SpendResult(
SpendOutcome.Success, _entitlements.EffectiveBalance(viewer, currency)));
}
ulong current = GetBalance(viewer, currency);
if (current < (ulong)cost)
return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current));
ulong post = current - (ulong)cost;
SetBalance(viewer, currency, post);
return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post));
}
private static ulong GetBalance(Viewer v, SpendCurrency c) => c switch
{
SpendCurrency.Crystal => v.Currency.Crystals,
SpendCurrency.Rupee => v.Currency.Rupees,
SpendCurrency.RedEther => v.Currency.RedEther,
SpendCurrency.SpotPoint => v.Currency.SpotPoints,
_ => throw new ArgumentOutOfRangeException(nameof(c)),
};
private static void SetBalance(Viewer v, SpendCurrency c, ulong value)
{
switch (c)
{
case SpendCurrency.Crystal: v.Currency.Crystals = value; break;
case SpendCurrency.Rupee: v.Currency.Rupees = value; break;
case SpendCurrency.RedEther: v.Currency.RedEther = value; break;
case SpendCurrency.SpotPoint: v.Currency.SpotPoints = value; break;
default: throw new ArgumentOutOfRangeException(nameof(c));
}
}
}

View File

@@ -1,14 +0,0 @@
using SVSim.Database.Models;
namespace SVSim.Database.Services;
/// <summary>
/// Centralized debit primitive — the symmetric twin of <c>RewardGrantService.ApplyAsync</c>.
/// Encapsulates the affordability-check + deduction + post-state-total pattern that was inlined
/// across the shop/pack controllers. Does NOT call <c>SaveChangesAsync</c>; the caller saves.
/// Freeplay (for Crystal/Rupee/RedEther) makes spends always succeed without deducting.
/// </summary>
public interface ICurrencySpendService
{
Task<SpendResult> TrySpendAsync(Viewer viewer, SpendCurrency currency, long cost, CancellationToken ct = default);
}

View File

@@ -1,54 +0,0 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Database.Services;
/// <summary>
/// The single read/ownership authority for what a viewer is *treated as* owning. Knows the
/// Freeplay flag; all freeplay read-side behavior lives here. See
/// docs/superpowers/specs/2026-05-29-freeplay-mode-design.md.
/// </summary>
/// <remarks>
/// <b>Include precondition:</b> methods that inspect the viewer's collections require the
/// viewer to have been loaded with <c>.Include(v =&gt; v.Cards).ThenInclude(c =&gt; c.Card)</c>
/// and the cosmetic collections
/// (<c>Sleeves</c>, <c>Emblems</c>, <c>Degrees</c>, <c>LeaderSkins</c>, <c>MyPageBackgrounds</c>)
/// included. Without those includes the EF owned-collection nav refs are null or zero-filled
/// (see the EF owned-collection nav-include pitfall in MEMORY.md).
/// </remarks>
public interface IViewerEntitlements
{
/// <summary>True when the global Freeplay config section is enabled.</summary>
bool IsFreeplay { get; }
/// <summary>
/// The balance the viewer is treated as having: the configured freeplay amount for
/// Crystal/Rupee/RedEther when freeplay is on, otherwise (and always for SpotPoint) the real
/// <c>viewer.Currency</c> field.
/// </summary>
long EffectiveBalance(Viewer viewer, SpendCurrency currency);
bool OwnsCard(Viewer viewer, long cardId);
/// <summary><paramref name="type"/> uses <see cref="CosmeticType"/> (Skin == leader skin).</summary>
bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id);
/// <summary>The full owned-card projection for /load/index's user_card_list.</summary>
Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default);
/// <summary>The cosmetic id-lists + leader-skin catalog/owned-set for /load/index.</summary>
Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default);
}
/// <summary>
/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns"
/// (all of them in freeplay). Leader skins are always the full catalog with a per-skin owned flag;
/// <see cref="OwnedLeaderSkinIds"/> is every skin id in freeplay.
/// </summary>
public sealed record EffectiveCosmetics(
IReadOnlyList<int> SleeveIds,
IReadOnlyList<int> EmblemIds,
IReadOnlyList<int> DegreeIds,
IReadOnlyList<int> MyPageBackgroundIds,
IReadOnlyList<LeaderSkinEntry> AllLeaderSkins,
IReadOnlySet<int> OwnedLeaderSkinIds);

View File

@@ -0,0 +1,28 @@
using SVSim.Database.Models;
using SVSim.Database.Services;
namespace SVSim.Database.Services.Inventory;
public interface IInventoryService
{
/// <summary>
/// Loads the viewer with the canonical inventory graph (Cards.Card, Sleeves, Emblems,
/// LeaderSkins, Degrees, MyPageBackgrounds, Items.Item under AsSplitQuery), opens a DB
/// transaction, and returns a builder for queueing operations. Throws
/// <see cref="InventoryViewerNotFoundException"/> if the viewer does not exist.
/// </summary>
Task<IInventoryTransaction> BeginAsync(
long viewerId,
CancellationToken ct = default,
Action<InventoryLoadConfig>? configure = null);
Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default);
Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default);
long EffectiveBalance(Viewer viewer, SpendCurrency currency);
}
public sealed class InventoryViewerNotFoundException : Exception
{
public InventoryViewerNotFoundException(long viewerId)
: base($"Viewer {viewerId} not found") { }
}

View File

@@ -0,0 +1,49 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
namespace SVSim.Database.Services.Inventory;
/// <summary>
/// Scoped builder returned by <see cref="IInventoryService.BeginAsync"/>. Queue spend +
/// grant operations; commit to save and assemble the <see cref="InventoryCommitResult"/>.
/// <para>
/// Dispose without committing rolls back the underlying DB transaction and detaches any
/// in-memory mutations. <b>Always</b> wrap in <c>await using</c>.
/// </para>
/// </summary>
public interface IInventoryTransaction : IAsyncDisposable
{
Viewer Viewer { get; }
bool IsFreeplay { get; }
/// <summary>
/// Debits one of the four scalar wallets. Freeplay-aware for Crystal/Rupee/RedEther
/// (returns Success with the configured freeplay amount, balance unchanged); SpotPoint
/// always real. Returns <see cref="SpendOutcome.Insufficient"/> with current balance on
/// failure; viewer state is not mutated on failure.
/// </summary>
Task<SpendResult> TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default);
/// <summary>
/// Type-dispatched debit. Currencies (RedEther/Crystal/Rupy/SpotCardPoint) route to
/// <see cref="TrySpendAsync"/>; Item decrements <c>OwnedItemEntry.Count</c>. Returns
/// <see cref="SpendResult"/> whose <c>PostStateTotal</c> is the new wallet balance for
/// currencies and the remaining item count for Item.
/// </summary>
Task<SpendResult> TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default);
Task<IReadOnlyList<GrantedReward>> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default);
Task<int> BackfillCardCosmeticsAsync(CancellationToken ct = default);
/// <summary>
/// Freeplay-aware balance read against the live viewer; reflects any spends queued in
/// this transaction. Inside a transaction, use this; outside, use
/// <see cref="IInventoryService.EffectiveBalance"/>.
/// </summary>
long EffectiveBalance(SpendCurrency currency);
bool OwnsCard(long cardId);
bool OwnsCosmetic(CosmeticType type, int id);
Task<InventoryCommitResult> CommitAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,10 @@
namespace SVSim.Database.Services.Inventory;
/// <summary>
/// Thrown when an inventory operation references a catalog id that doesn't exist
/// (unknown card / item / cosmetic). Programmer error — bubbles to the global error handler.
/// </summary>
public sealed class InventoryCatalogException : Exception
{
public InventoryCatalogException(string message) : base(message) { }
}

View File

@@ -0,0 +1,20 @@
using SVSim.Database.Services;
namespace SVSim.Database.Services.Inventory;
/// <summary>
/// Result of <see cref="IInventoryTransaction.CommitAsync"/>.
/// <para>
/// <see cref="RewardList"/> — wire-shape entries with currency-collision resolved (one entry per
/// (type, id); for currencies that were both spent and granted, the last post-state in op order
/// wins). Use this for response <c>reward_list</c> fields.
/// </para>
/// <para>
/// <see cref="Deltas"/> — verbatim ordered (type, id, num) sequence the caller queued. No
/// collapse, no cosmetic-cascade entries. Use this for BP <c>achieved_info</c> and Story
/// <c>story_reward_list</c> popups.
/// </para>
/// </summary>
public sealed record InventoryCommitResult(
IReadOnlyList<GrantedReward> RewardList,
IReadOnlyList<GrantedReward> Deltas);

View File

@@ -0,0 +1,27 @@
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Database.Services;
/// <summary>
/// Wire-shape entry returned by <see cref="Inventory.IInventoryTransaction.GrantAsync"/> and
/// collected in <see cref="Inventory.InventoryCommitResult.RewardList"/> /
/// <see cref="Inventory.InventoryCommitResult.Deltas"/>. Field names match the
/// <c>reward_list</c> entries used by <c>/pack/open</c>, <c>/basic_puzzle/finish</c>, and
/// <c>/story/*/finish</c>. reward_num is a POST-STATE TOTAL for currencies and a count for
/// collection grants — see <see cref="Models.RewardListEntry"/>.
/// </summary>
public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum);
/// <summary>
/// Cosmetic projection bundle for /load/index. The four id-lists are "what the viewer owns"
/// (all of them in freeplay). Leader skins are always the full catalog with a per-skin owned flag;
/// <see cref="OwnedLeaderSkinIds"/> is every skin id in freeplay.
/// </summary>
public sealed record EffectiveCosmetics(
IReadOnlyList<int> SleeveIds,
IReadOnlyList<int> EmblemIds,
IReadOnlyList<int> DegreeIds,
IReadOnlyList<int> MyPageBackgroundIds,
IReadOnlyList<LeaderSkinEntry> AllLeaderSkins,
IReadOnlySet<int> OwnedLeaderSkinIds);

View File

@@ -0,0 +1,31 @@
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using SVSim.Database.Models;
namespace SVSim.Database.Services.Inventory;
/// <summary>
/// Caller-supplied extra <c>.Include</c> chains on top of the canonical viewer-inventory query
/// in <see cref="IInventoryService.BeginAsync"/>. Use to bring in extra collections needed by
/// the calling controller (e.g. <c>MissionData</c>, <c>BuildDeckPurchases</c>).
/// </summary>
public sealed class InventoryLoadConfig
{
internal List<Func<IQueryable<Viewer>, IQueryable<Viewer>>> Includes { get; } = new();
public InventoryLoadConfig WithInclude<TProperty>(
Expression<Func<Viewer, TProperty>> path)
{
Includes.Add(q => q.Include(path));
return this;
}
public InventoryLoadConfig WithInclude<TProperty, TThen>(
Expression<Func<Viewer, IEnumerable<TProperty>>> collectionPath,
Expression<Func<TProperty, TThen>> thenPath)
{
Includes.Add(q => q.Include(collectionPath).ThenInclude(thenPath));
return this;
}
}

View File

@@ -1,31 +1,68 @@
using SVSim.Database.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Repositories.Card;
using SVSim.Database.Repositories.Collectibles;
namespace SVSim.Database.Services;
namespace SVSim.Database.Services.Inventory;
public class ViewerEntitlements : IViewerEntitlements
public sealed class InventoryService : IInventoryService
{
private readonly SVSimDbContext _db;
private readonly IGameConfigService _config;
private readonly ICardRepository _cards;
private readonly ICollectionRepository _collection;
private readonly ILogger<InventoryService> _log;
public ViewerEntitlements(IGameConfigService config, ICardRepository cards, ICollectionRepository collection)
public InventoryService(
SVSimDbContext db,
IGameConfigService config,
ICardRepository cards,
ICollectionRepository collection,
ILogger<InventoryService> log)
{
_db = db;
_config = config;
_cards = cards;
_collection = collection;
_log = log;
}
private FreeplayConfig Cfg => _config.Get<FreeplayConfig>();
public async Task<IInventoryTransaction> BeginAsync(
long viewerId,
CancellationToken ct = default,
Action<InventoryLoadConfig>? configure = null)
{
var loadCfg = new InventoryLoadConfig();
configure?.Invoke(loadCfg);
public bool IsFreeplay => Cfg.Enabled;
IQueryable<Viewer> query = _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item);
foreach (var include in loadCfg.Includes)
query = include(query);
var viewer = await query
.AsSplitQuery()
.FirstOrDefaultAsync(v => v.Id == viewerId, ct)
?? throw new InventoryViewerNotFoundException(viewerId);
var freeplay = _config.Get<FreeplayConfig>();
var dbTx = await _db.Database.BeginTransactionAsync(ct);
return new InventoryTransaction(_db, dbTx, viewer, freeplay, _log);
}
public long EffectiveBalance(Viewer viewer, SpendCurrency currency)
{
var cfg = Cfg;
var cfg = _config.Get<FreeplayConfig>();
if (cfg.Enabled && currency != SpendCurrency.SpotPoint)
return checked((long)cfg.CurrencyAmount);
@@ -39,28 +76,12 @@ public class ViewerEntitlements : IViewerEntitlements
};
}
public bool OwnsCard(Viewer viewer, long cardId)
=> Cfg.Enabled || viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0);
public bool OwnsCosmetic(Viewer viewer, CosmeticType type, int id)
{
if (Cfg.Enabled) return true;
return type switch
{
CosmeticType.Sleeve => viewer.Sleeves.Any(s => s.Id == id),
CosmeticType.Emblem => viewer.Emblems.Any(e => e.Id == id),
CosmeticType.Degree => viewer.Degrees.Any(d => d.Id == id),
CosmeticType.Skin => viewer.LeaderSkins.Any(s => s.Id == id),
CosmeticType.MyPageBG => viewer.MyPageBackgrounds.Any(m => m.Id == id),
_ => false,
};
}
public async Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(Viewer viewer, CancellationToken ct = default)
public async Task<IReadOnlyList<OwnedCardEntry>> EffectiveOwnedCardsAsync(
Viewer viewer, CancellationToken ct = default)
{
var defaults = await _cards.GetDefaultCards();
var defaultIds = defaults.Select(c => c.Id).ToHashSet();
var cfg = Cfg;
var cfg = _config.Get<FreeplayConfig>();
if (cfg.Enabled)
{
@@ -81,11 +102,13 @@ public class ViewerEntitlements : IViewerEntitlements
.ToList();
}
public async Task<EffectiveCosmetics> EffectiveCosmeticsAsync(Viewer viewer, CancellationToken ct = default)
public async Task<EffectiveCosmetics> EffectiveCosmeticsAsync(
Viewer viewer, CancellationToken ct = default)
{
var allSkins = await _collection.GetLeaderSkins();
var cfg = _config.Get<FreeplayConfig>();
if (Cfg.Enabled)
if (cfg.Enabled)
{
return new EffectiveCosmetics(
await _collection.GetAllSleeveIds(),

View File

@@ -0,0 +1,455 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Logging;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
namespace SVSim.Database.Services.Inventory;
internal sealed class InventoryTransaction : IInventoryTransaction
{
private readonly SVSimDbContext _db;
private readonly IDbContextTransaction _dbTx;
private readonly ILogger _log;
private readonly FreeplayConfig _freeplay;
private bool _committed;
public Viewer Viewer { get; }
public bool IsFreeplay => _freeplay.Enabled;
private readonly List<InventoryOp> _ops = new();
internal abstract record InventoryOp;
internal sealed record SpendOp(SpendCurrency Currency, long Cost, long PostState) : InventoryOp;
internal sealed record GrantOp(UserGoodsType Type, long DetailId, int Num, int PostStateOrCount, bool IsCascade) : InventoryOp;
public InventoryTransaction(
SVSimDbContext db,
IDbContextTransaction dbTx,
Viewer viewer,
FreeplayConfig freeplay,
ILogger log)
{
_db = db;
_dbTx = dbTx;
Viewer = viewer;
_freeplay = freeplay;
_log = log;
}
public Task<SpendResult> TrySpendAsync(SpendCurrency currency, long cost, CancellationToken ct = default)
{
ThrowIfCommitted();
if (cost < 0) cost = 0;
if (_freeplay.Enabled && currency != SpendCurrency.SpotPoint)
{
long amount = checked((long)_freeplay.CurrencyAmount);
_ops.Add(new SpendOp(currency, cost, amount));
return Task.FromResult(new SpendResult(SpendOutcome.Success, amount));
}
ulong current = ReadBalance(currency);
if (current < (ulong)cost)
return Task.FromResult(new SpendResult(SpendOutcome.Insufficient, (long)current));
ulong post = current - (ulong)cost;
WriteBalance(currency, post);
_ops.Add(new SpendOp(currency, cost, (long)post));
return Task.FromResult(new SpendResult(SpendOutcome.Success, (long)post));
}
private ulong ReadBalance(SpendCurrency c) => c switch
{
SpendCurrency.Crystal => Viewer.Currency.Crystals,
SpendCurrency.Rupee => Viewer.Currency.Rupees,
SpendCurrency.RedEther => Viewer.Currency.RedEther,
SpendCurrency.SpotPoint => Viewer.Currency.SpotPoints,
_ => throw new ArgumentOutOfRangeException(nameof(c)),
};
private void WriteBalance(SpendCurrency c, ulong value)
{
switch (c)
{
case SpendCurrency.Crystal: Viewer.Currency.Crystals = value; break;
case SpendCurrency.Rupee: Viewer.Currency.Rupees = value; break;
case SpendCurrency.RedEther: Viewer.Currency.RedEther = value; break;
case SpendCurrency.SpotPoint: Viewer.Currency.SpotPoints = value; break;
default: throw new ArgumentOutOfRangeException(nameof(c));
}
}
public Task<SpendResult> TryDebitAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default)
{
ThrowIfCommitted();
return type switch
{
UserGoodsType.Crystal => TrySpendAsync(SpendCurrency.Crystal, num, ct),
UserGoodsType.Rupy => TrySpendAsync(SpendCurrency.Rupee, num, ct),
UserGoodsType.RedEther => TrySpendAsync(SpendCurrency.RedEther, num, ct),
UserGoodsType.SpotCardPoint => TrySpendAsync(SpendCurrency.SpotPoint, num, ct),
UserGoodsType.Item => Task.FromResult(DebitItem(detailId, num)),
_ => throw new NotSupportedException($"Debit not supported for {type}"),
};
}
private SpendResult DebitItem(long detailId, int num)
{
var owned = Viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
if (owned is null)
throw new InventoryCatalogException($"Item {detailId} not owned by viewer");
if (owned.Count < num)
return new SpendResult(SpendOutcome.Insufficient, owned.Count);
owned.Count -= num;
// Item debit logged as a synthetic SpendOp so CommitAsync can track it.
// Sentinel currency (int)-1 is filtered out by CommitAsync's currency-collision loop.
_ops.Add(new SpendOp((SpendCurrency)(-1) /* sentinel */, num, owned.Count));
// IsCascade: true so this GrantOp is excluded from BuildDeltas output.
_ops.Add(new GrantOp(UserGoodsType.Item, detailId, 0, owned.Count, IsCascade: true));
return new SpendResult(SpendOutcome.Success, owned.Count);
}
public async Task<IReadOnlyList<GrantedReward>> GrantAsync(UserGoodsType type, long detailId, int num, CancellationToken ct = default)
{
ThrowIfCommitted();
switch (type)
{
case UserGoodsType.Rupy:
Viewer.Currency.Rupees += (ulong)num;
var rupy = checked((int)Viewer.Currency.Rupees);
_ops.Add(new GrantOp(type, detailId, num, rupy, false));
return Single(type, detailId, rupy);
case UserGoodsType.Crystal:
Viewer.Currency.Crystals += (ulong)num;
var crystal = checked((int)Viewer.Currency.Crystals);
_ops.Add(new GrantOp(type, detailId, num, crystal, false));
return Single(type, detailId, crystal);
case UserGoodsType.RedEther:
Viewer.Currency.RedEther += (ulong)num;
var red = checked((int)Viewer.Currency.RedEther);
_ops.Add(new GrantOp(type, detailId, num, red, false));
return Single(type, detailId, red);
case UserGoodsType.SpotCardPoint:
Viewer.Currency.SpotPoints += (ulong)num;
var spot = checked((int)Viewer.Currency.SpotPoints);
_ops.Add(new GrantOp(type, detailId, num, spot, false));
return Single(type, detailId, spot);
case UserGoodsType.Sleeve:
AddCosmeticIfMissing(Viewer.Sleeves, detailId, _db.Sleeves);
_ops.Add(new GrantOp(type, detailId, num, 1, false));
return Single(type, detailId, 1);
case UserGoodsType.Emblem:
AddCosmeticIfMissing(Viewer.Emblems, detailId, _db.Emblems);
_ops.Add(new GrantOp(type, detailId, num, 1, false));
return Single(type, detailId, 1);
case UserGoodsType.Skin:
AddCosmeticIfMissing(Viewer.LeaderSkins, detailId, _db.LeaderSkins);
_ops.Add(new GrantOp(type, detailId, num, 1, false));
return Single(type, detailId, 1);
case UserGoodsType.Degree:
AddCosmeticIfMissing(Viewer.Degrees, detailId, _db.Degrees);
_ops.Add(new GrantOp(type, detailId, num, 1, false));
return Single(type, detailId, 1);
case UserGoodsType.MyPageBG:
AddCosmeticIfMissing(Viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds);
_ops.Add(new GrantOp(type, detailId, num, 1, false));
return Single(type, detailId, 1);
case UserGoodsType.Item:
{
var owned = Viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
int post;
if (owned is null)
{
var item = _db.Items.Find((int)detailId)
?? throw new InventoryCatalogException($"Item {detailId} not in catalog");
Viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = Viewer });
post = num;
}
else
{
owned.Count += num;
post = owned.Count;
}
_ops.Add(new GrantOp(type, detailId, num, post, false));
return Single(type, detailId, post);
}
case UserGoodsType.Card:
return await ApplyCardAsync(detailId, num, ct);
case UserGoodsType.SpotCard:
case UserGoodsType.SpotCardOnlyLatestCardPack:
throw new NotSupportedException(
$"{type} rewards are not yet supported — emitters use Card=5 instead.");
default:
throw new NotImplementedException(
$"UserGoodsType {type} grant lands in a subsequent task");
}
}
public async Task<int> BackfillCardCosmeticsAsync(CancellationToken ct = default)
{
ThrowIfCommitted();
var lookupIds = Viewer.Cards
.Select(c => c.Card.IsFoil ? c.Card.Id - 1 : c.Card.Id)
.Distinct()
.ToList();
var cascade = await _db.CardCosmeticRewards
.Where(r => lookupIds.Contains(r.CardId))
.ToListAsync(ct);
int granted = 0;
foreach (var reward in cascade)
{
if (AlreadyOwnsCosmetic(reward.Type, reward.CosmeticId)) continue;
if (TryAddCascadeCosmetic(reward, reward.CardId))
{
granted++;
_ops.Add(new GrantOp((UserGoodsType)(int)reward.Type, reward.CosmeticId, 1, 1, true));
}
}
return granted;
}
private bool AlreadyOwnsCosmetic(CosmeticType type, long id) => type switch
{
CosmeticType.Sleeve => Viewer.Sleeves.Any(s => s.Id == id),
CosmeticType.Emblem => Viewer.Emblems.Any(e => e.Id == id),
CosmeticType.Skin => Viewer.LeaderSkins.Any(s => s.Id == id),
CosmeticType.Degree => Viewer.Degrees.Any(d => d.Id == id),
CosmeticType.MyPageBG => Viewer.MyPageBackgrounds.Any(b => b.Id == id),
_ => false,
};
public long EffectiveBalance(SpendCurrency currency)
{
if (_freeplay.Enabled && currency != SpendCurrency.SpotPoint)
return checked((long)_freeplay.CurrencyAmount);
return currency switch
{
SpendCurrency.Crystal => (long)Viewer.Currency.Crystals,
SpendCurrency.Rupee => (long)Viewer.Currency.Rupees,
SpendCurrency.RedEther => (long)Viewer.Currency.RedEther,
SpendCurrency.SpotPoint => (long)Viewer.Currency.SpotPoints,
_ => throw new ArgumentOutOfRangeException(nameof(currency)),
};
}
public bool OwnsCard(long cardId)
=> _freeplay.Enabled || Viewer.Cards.Any(c => c.Card.Id == cardId && c.Count > 0);
public bool OwnsCosmetic(CosmeticType type, int id)
{
if (_freeplay.Enabled) return true;
return type switch
{
CosmeticType.Sleeve => Viewer.Sleeves.Any(s => s.Id == id),
CosmeticType.Emblem => Viewer.Emblems.Any(e => e.Id == id),
CosmeticType.Degree => Viewer.Degrees.Any(d => d.Id == id),
CosmeticType.Skin => Viewer.LeaderSkins.Any(s => s.Id == id),
CosmeticType.MyPageBG => Viewer.MyPageBackgrounds.Any(m => m.Id == id),
_ => false,
};
}
public async Task<InventoryCommitResult> CommitAsync(CancellationToken ct = default)
{
ThrowIfCommitted();
await _db.SaveChangesAsync(ct);
await _dbTx.CommitAsync(ct);
_committed = true;
var rewardList = BuildRewardList();
var deltas = BuildDeltas();
return new InventoryCommitResult(rewardList, deltas);
}
private IReadOnlyList<GrantedReward> BuildRewardList()
{
// Pass 1 — for each currency type, find the last op (spend OR grant) that touched it
// and emit a single entry with its post-state. Skip the sentinel item-debit currency.
var lastCurrencyPost = new Dictionary<UserGoodsType, int>();
var orderedTouches = new List<UserGoodsType>(); // preserve first-touch order for stable output
foreach (var op in _ops)
{
switch (op)
{
case SpendOp s when (int)s.Currency >= 0:
var goodsForSpend = SpendCurrencyToGoodsType(s.Currency);
if (!lastCurrencyPost.ContainsKey(goodsForSpend)) orderedTouches.Add(goodsForSpend);
lastCurrencyPost[goodsForSpend] = checked((int)s.PostState);
break;
case GrantOp g when IsCurrency(g.Type):
if (!lastCurrencyPost.ContainsKey(g.Type)) orderedTouches.Add(g.Type);
lastCurrencyPost[g.Type] = g.PostStateOrCount;
break;
}
}
var output = new List<GrantedReward>();
foreach (var type in orderedTouches)
{
output.Add(new GrantedReward((int)type, 0, lastCurrencyPost[type]));
}
// Pass 2 — non-currency grants: one entry per (type, id) using LAST post-state for items
// and cards (collapses multi-add to final count) and 1 for cosmetics.
var nonCurrencyKey = new Dictionary<(UserGoodsType, long), int>();
var nonCurrencyOrder = new List<(UserGoodsType, long)>();
foreach (var op in _ops.OfType<GrantOp>())
{
if (IsCurrency(op.Type)) continue;
var key = (op.Type, op.DetailId);
if (!nonCurrencyKey.ContainsKey(key)) nonCurrencyOrder.Add(key);
nonCurrencyKey[key] = op.PostStateOrCount;
}
foreach (var (type, id) in nonCurrencyOrder)
{
output.Add(new GrantedReward((int)type, id, nonCurrencyKey[(type, id)]));
}
return output;
}
private IReadOnlyList<GrantedReward> BuildDeltas()
=> _ops.OfType<GrantOp>()
.Where(o => !o.IsCascade)
.Select(o => new GrantedReward((int)o.Type, o.DetailId, o.Num))
.ToList();
private static bool IsCurrency(UserGoodsType t) =>
t is UserGoodsType.Crystal
or UserGoodsType.Rupy
or UserGoodsType.RedEther
or UserGoodsType.SpotCardPoint;
private static UserGoodsType SpendCurrencyToGoodsType(SpendCurrency c) => c switch
{
SpendCurrency.Crystal => UserGoodsType.Crystal,
SpendCurrency.Rupee => UserGoodsType.Rupy,
SpendCurrency.RedEther => UserGoodsType.RedEther,
SpendCurrency.SpotPoint => UserGoodsType.SpotCardPoint,
_ => throw new ArgumentOutOfRangeException(nameof(c)),
};
private static IReadOnlyList<GrantedReward> Single(UserGoodsType type, long id, int num)
=> new[] { new GrantedReward((int)type, id, num) };
private void ThrowIfCommitted()
{
if (_committed)
throw new InvalidOperationException("Inventory transaction already committed");
}
private async Task<IReadOnlyList<GrantedReward>> ApplyCardAsync(long cardId, int num, CancellationToken ct)
{
var owned = Viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId);
int postCount;
if (owned is null)
{
var card = await _db.Cards.FirstOrDefaultAsync(c => c.Id == cardId, ct)
?? throw new InventoryCatalogException($"Card {cardId} not in catalog");
owned = new OwnedCardEntry { Card = card, Count = num, IsProtected = false };
Viewer.Cards.Add(owned);
postCount = num;
}
else
{
owned.Count += num;
postCount = owned.Count;
}
var results = new List<GrantedReward>
{
new((int)UserGoodsType.Card, cardId, postCount),
};
_ops.Add(new GrantOp(UserGoodsType.Card, cardId, num, postCount, false));
long lookupId = owned.Card.IsFoil ? cardId - 1 : cardId;
var cascade = await _db.CardCosmeticRewards
.Where(r => r.CardId == lookupId)
.ToListAsync(ct);
foreach (var reward in cascade)
{
if (TryAddCascadeCosmetic(reward, lookupId))
{
results.Add(new GrantedReward((int)reward.Type, reward.CosmeticId, 1));
_ops.Add(new GrantOp((UserGoodsType)(int)reward.Type, reward.CosmeticId, 1, 1, true));
}
}
return results;
}
private bool TryAddCascadeCosmetic(CardCosmeticReward reward, long forCardId)
{
try
{
return reward.Type switch
{
CosmeticType.Sleeve => AddCosmeticIfMissing(Viewer.Sleeves, reward.CosmeticId, _db.Sleeves),
CosmeticType.Emblem => AddCosmeticIfMissing(Viewer.Emblems, reward.CosmeticId, _db.Emblems),
CosmeticType.Skin => AddCosmeticIfMissing(Viewer.LeaderSkins, reward.CosmeticId, _db.LeaderSkins),
CosmeticType.Degree => AddCosmeticIfMissing(Viewer.Degrees, reward.CosmeticId, _db.Degrees),
CosmeticType.MyPageBG => AddCosmeticIfMissing(Viewer.MyPageBackgrounds, reward.CosmeticId, _db.MyPageBackgrounds),
_ => false,
};
}
catch (InventoryCatalogException ex)
{
_log.LogWarning(ex,
"Card cascade: cosmetic {Type} {Id} for card {CardId} skipped (master row missing)",
reward.Type, reward.CosmeticId, forCardId);
return false;
}
}
private static bool AddCosmeticIfMissing<T>(List<T> collection, long detailId, Microsoft.EntityFrameworkCore.DbSet<T> catalog) where T : class
{
if (collection.Any(e => GetId(e) == detailId)) return false;
var entity = catalog.Find(checked((int)detailId))
?? throw new InventoryCatalogException(
$"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}");
collection.Add(entity);
return true;
}
private static long GetId<T>(T e)
{
var prop = typeof(T).GetProperty("Id")
?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property");
var val = prop.GetValue(e);
return val switch { long l => l, int i => i, _ => 0 };
}
public async ValueTask DisposeAsync()
{
if (!_committed)
{
await _dbTx.RollbackAsync();
_db.ChangeTracker.Clear();
}
await _dbTx.DisposeAsync();
}
}

View File

@@ -1,221 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SVSim.Database.Enums;
using SVSim.Database.Models;
namespace SVSim.Database.Services;
/// <summary>
/// Wire-shape entry returned by <see cref="RewardGrantService.ApplyAsync"/>. Field names match
/// the <c>reward_list</c> entries used by <c>/pack/open</c>, <c>/basic_puzzle/finish</c>, and
/// <c>/story/*/finish</c>. reward_num is a POST-STATE TOTAL for currencies and a count for
/// collection grants — see <see cref="Models.RewardListEntry"/>.
/// </summary>
public sealed record GrantedReward(int RewardType, long RewardId, int RewardNum);
/// <summary>
/// Single canonical grant primitive for every <see cref="UserGoodsType"/> the server hands to a
/// viewer. Switch on the type, mutate the appropriate viewer collection / <see cref="ViewerCurrency"/>
/// field, return the wire-shape entries to embed in the response's <c>reward_list</c>.
///
/// <para>
/// <b>DO NOT reimplement reward dispatch in a controller or new helper.</b> This service handles
/// RedEther, Crystal, SpotCardPoint, Item, Card (with <see cref="CardCosmeticReward"/> cascade),
/// Sleeve, Emblem, Degree, Rupy, Skin, MyPageBG — everything except the dead-letter SpotCard /
/// SpotCardOnlyLatestCardPack slots (use Card=5 instead). Endpoint code that takes a
/// list of <c>(type, id, num)</c> tuples should iterate and call <see cref="ApplyAsync"/>
/// per tuple — never switch on type yourself, never filter to "only card-typed rewards", never
/// build a second dispatch table. Past duplicate implementations (ICardAcquisitionService in the
/// EmulatedEntrypoint project, the first pass at /build_deck/buy) all silently dropped subsets of
/// types and produced the same bug: wire reward visible but viewer's collection unchanged. When a
/// new reward type comes up, add a case here. See <c>feedback_reward_grant_service</c> memory.
/// </para>
///
/// Card grants additionally run the <see cref="CardCosmeticReward"/> cascade: any cosmetic
/// associated with the granted card that the viewer doesn't yet own is granted too, and produces
/// an additional entry in the returned list. That's why the return type is a list: most types
/// produce one entry, Card produces 1 + N.
///
/// Caller is responsible for <see cref="SVSimDbContext.SaveChangesAsync(System.Threading.CancellationToken)"/> —
/// this service only mutates the in-memory graph so a controller can stack several grants in
/// a single transaction.
/// </summary>
public sealed class RewardGrantService
{
private readonly SVSimDbContext _db;
private readonly ILogger<RewardGrantService> _log;
public RewardGrantService(SVSimDbContext db, ILogger<RewardGrantService> log)
{
_db = db;
_log = log;
}
public async Task<IReadOnlyList<GrantedReward>> ApplyAsync(
Viewer viewer, UserGoodsType type, long detailId, int num, CancellationToken ct = default)
{
switch (type)
{
case UserGoodsType.Sleeve:
AddCosmeticIfMissing(viewer.Sleeves, detailId, _db.Sleeves);
return Single(type, detailId, 1);
case UserGoodsType.Emblem:
AddCosmeticIfMissing(viewer.Emblems, detailId, _db.Emblems);
return Single(type, detailId, 1);
case UserGoodsType.Skin: // LeaderSkin in our schema
AddCosmeticIfMissing(viewer.LeaderSkins, detailId, _db.LeaderSkins);
return Single(type, detailId, 1);
case UserGoodsType.Degree:
AddCosmeticIfMissing(viewer.Degrees, detailId, _db.Degrees);
return Single(type, detailId, 1);
case UserGoodsType.MyPageBG:
AddCosmeticIfMissing(viewer.MyPageBackgrounds, detailId, _db.MyPageBackgrounds);
return Single(type, detailId, 1);
case UserGoodsType.Rupy:
viewer.Currency.Rupees += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.Rupees));
case UserGoodsType.Crystal:
viewer.Currency.Crystals += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.Crystals));
case UserGoodsType.RedEther:
viewer.Currency.RedEther += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.RedEther));
case UserGoodsType.SpotCardPoint:
viewer.Currency.SpotPoints += (ulong)num;
return Single(type, detailId, checked((int)viewer.Currency.SpotPoints));
case UserGoodsType.Item:
{
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
if (owned is null)
{
var item = _db.Items.Find((int)detailId)
?? throw new InvalidOperationException($"Item {detailId} not in catalog");
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = num, Viewer = viewer });
return Single(type, detailId, num);
}
owned.Count += num;
return Single(type, detailId, owned.Count);
}
case UserGoodsType.Card:
return await ApplyCardAsync(viewer, detailId, num, ct);
case UserGoodsType.SpotCard:
case UserGoodsType.SpotCardOnlyLatestCardPack:
// Spot-card-typed grants don't appear in captures — emitters always use Card=5
// with the spot-card-specific id. These two enum slots remain unimplemented; if a
// capture ever shows one in a reward_list we'll know to wire them up here.
throw new NotSupportedException(
$"{type} rewards are not yet supported — emitters use Card=5 instead.");
default:
throw new NotSupportedException($"UserGoodsType {type} not yet handled by RewardGrantService");
}
}
private async Task<IReadOnlyList<GrantedReward>> ApplyCardAsync(
Viewer viewer, long cardId, int num, CancellationToken ct)
{
// Find-or-add OwnedCardEntry. Mirrors the primitive that used to live in
// IPackRepository.GrantCardsToViewer — now inline so we own the in-memory-only contract.
var owned = viewer.Cards.FirstOrDefault(c => c.Card.Id == cardId);
int postCount;
if (owned is null)
{
var card = await _db.Cards.FirstOrDefaultAsync(c => c.Id == cardId, ct)
?? throw new InvalidOperationException($"Card {cardId} not in catalog");
owned = new OwnedCardEntry { Card = card, Count = num, IsProtected = false };
viewer.Cards.Add(owned);
postCount = num;
}
else
{
owned.Count += num;
postCount = owned.Count;
}
var results = new List<GrantedReward>
{
new((int)UserGoodsType.Card, cardId, postCount),
};
// Cascade: cosmetic mappings live on the non-foil row. If the granted card is foil
// (card_id ends in 1, IsFoil=true), look up cascade against cardId - 1.
long lookupId = owned.Card.IsFoil ? cardId - 1 : cardId;
var cascade = await _db.CardCosmeticRewards
.Where(r => r.CardId == lookupId)
.ToListAsync(ct);
foreach (var reward in cascade)
{
if (TryAddCascadeCosmetic(viewer, reward, lookupId))
{
// CosmeticType numeric values are identical to UserGoodsType — direct cast is safe.
results.Add(new GrantedReward((int)reward.Type, reward.CosmeticId, 1));
}
}
return results;
}
private static IReadOnlyList<GrantedReward> Single(UserGoodsType type, long id, int num)
=> new[] { new GrantedReward((int)type, id, num) };
private bool TryAddCascadeCosmetic(Viewer viewer, CardCosmeticReward reward, long forCardId)
{
try
{
return reward.Type switch
{
CosmeticType.Sleeve => AddCosmeticIfMissing(viewer.Sleeves, reward.CosmeticId, _db.Sleeves),
CosmeticType.Emblem => AddCosmeticIfMissing(viewer.Emblems, reward.CosmeticId, _db.Emblems),
CosmeticType.Skin => AddCosmeticIfMissing(viewer.LeaderSkins, reward.CosmeticId, _db.LeaderSkins),
CosmeticType.Degree => AddCosmeticIfMissing(viewer.Degrees, reward.CosmeticId, _db.Degrees),
CosmeticType.MyPageBG => AddCosmeticIfMissing(viewer.MyPageBackgrounds, reward.CosmeticId, _db.MyPageBackgrounds),
_ => false,
};
}
catch (InvalidOperationException ex)
{
_log.LogWarning(ex,
"Card cascade: cosmetic {Type} {Id} for card {CardId} skipped (master row missing)",
reward.Type, reward.CosmeticId, forCardId);
return false;
}
}
private static bool AddCosmeticIfMissing<T>(List<T> collection, long detailId, DbSet<T> catalog) where T : class
{
bool alreadyOwned = collection.Any(e => GetId(e) == detailId);
if (alreadyOwned) return false;
var entity = catalog.Find(checked((int)detailId))
?? throw new InvalidOperationException(
$"Cosmetic id {detailId} not in catalog for type {typeof(T).Name}");
collection.Add(entity);
return true;
}
/// <summary>
/// Reflectively reads an entity's Id property — works for both <c>BaseEntity&lt;int&gt;</c>
/// (cosmetics) and <c>BaseEntity&lt;long&gt;</c> (e.g. Viewer/Card) without forcing two
/// non-generic overloads of <see cref="AddCosmeticIfMissing"/>.
/// </summary>
private static long GetId<T>(T e)
{
var prop = typeof(T).GetProperty("Id")
?? throw new InvalidOperationException($"Type {typeof(T).Name} missing Id property");
var val = prop.GetValue(e);
return val switch { long l => l, int i => i, _ => 0 };
}
}

View File

@@ -2,9 +2,8 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Mission;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Achievement;
using SVSim.EmulatedEntrypoint.Services;
@@ -22,20 +21,20 @@ public class AchievementController : SVSimController
private readonly IMissionCatalogRepository _catalog;
private readonly IViewerMissionStateService _state;
private readonly IMissionAssembler _assembler;
private readonly RewardGrantService _grantService;
private readonly IInventoryService _inv;
public AchievementController(
SVSimDbContext db,
IMissionCatalogRepository catalog,
IViewerMissionStateService state,
IMissionAssembler assembler,
RewardGrantService grantService)
IInventoryService inv)
{
_db = db;
_catalog = catalog;
_state = state;
_assembler = assembler;
_grantService = grantService;
_inv = inv;
}
[HttpPost("receive_reward")]
@@ -44,21 +43,15 @@ public class AchievementController : SVSimController
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// Load viewer with all the collections RewardGrantService may need to mutate.
var viewer = await _db.Viewers
.Include(v => v.MissionData)
.Include(v => v.Currency)
.Include(v => v.Cards)
.Include(v => v.Items)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.LeaderSkins)
.Include(v => v.MyPageBackgrounds)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId, ct);
// EnsureCurrentAsync needs a viewer id — use a lightweight pre-check load then
// materialize state before opening the inventory tx.
var viewerIdCheck = await _db.Viewers
.Where(v => v.Id == viewerId)
.Select(v => v.Id)
.FirstOrDefaultAsync(ct);
if (viewerIdCheck == 0) return Unauthorized();
await _state.EnsureCurrentAsync(viewer.Id, ct);
await _state.EnsureCurrentAsync(viewerId, ct);
await _db.SaveChangesAsync(ct);
// Re-read viewer's achievement for this type after state-service materialization.
@@ -75,9 +68,10 @@ public class AchievementController : SVSimController
return Ok(new { result_code = FailureResultCode });
}
// Grant via the canonical RewardGrantService primitive.
var granted = await _grantService.ApplyAsync(
viewer,
// Open inventory tx and grant via InventoryService.
await using var tx = await _inv.BeginAsync(viewerId, ct);
var granted = await tx.GrantAsync(
(UserGoodsType)catalogRow.RewardType,
catalogRow.RewardDetailId,
catalogRow.RewardNumber,
@@ -99,9 +93,9 @@ public class AchievementController : SVSimController
}
ach.NowAchievedLevel = request.Level;
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
var dto = await _assembler.BuildAsync(viewer, ct);
var dto = await _assembler.BuildAsync(tx.Viewer, ct);
var resp = new AchievementReceiveRewardResponse
{
UserMissionList = dto.UserMissionList,

View File

@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.BattleNode.Bridge;
using SVSim.EmulatedEntrypoint.Matching;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
using SVSim.EmulatedEntrypoint.Services;
@@ -9,13 +11,91 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class ArenaTwoPickBattleController : SVSimController
{
private readonly IArenaTwoPickService _svc;
public ArenaTwoPickBattleController(IArenaTwoPickService svc) => _svc = svc;
private readonly IMatchingBridge _matching;
private readonly IMatchContextBuilder _matchContextBuilder;
private readonly IMatchingPairUpService _pairUp;
private readonly BattleNodeOptions _battleNodeOptions;
public ArenaTwoPickBattleController(
IArenaTwoPickService svc,
IMatchingBridge matching,
IMatchContextBuilder matchContextBuilder,
IMatchingPairUpService pairUp,
BattleNodeOptions battleNodeOptions)
{
_svc = svc;
_matching = matching;
_matchContextBuilder = matchContextBuilder;
_pairUp = pairUp;
_battleNodeOptions = battleNodeOptions;
}
[HttpPost("do_matching")]
public IActionResult DoMatching([FromBody] DoMatchingRequest req)
public async Task<IActionResult> DoMatching(
[FromBody] DoMatchingRequest req,
[FromQuery(Name = "scripted")] string? scripted = null,
CancellationToken ct = default)
{
if (!TryGetViewerId(out _)) return Unauthorized();
return Ok(new DoMatchingResponseDto());
if (!TryGetViewerId(out var vid)) return Unauthorized();
// Accept "1" or "true" (case-insensitive) as opt-in for the legacy Scripted path.
// ASP.NET's default bool binder rejects "1", so do a permissive parse here.
// The server-side BattleNodeOptions.SoloDefaultsToScripted flag is the other
// route — it bypasses pair-up for every solo poll, useful when the live client
// (which can't append query params) needs a Scripted match.
var useScripted = (scripted is not null
&& (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase)))
|| _battleNodeOptions.SoloDefaultsToScripted;
try
{
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
if (useScripted)
{
var scriptedMatch = _matching.RegisterBattle(
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
p2: null,
SVSim.BattleNode.Sessions.BattleType.Scripted);
return Ok(new DoMatchingResponseDto
{
MatchingState = 3004,
BattleId = scriptedMatch.BattleId,
NodeServerUrl = scriptedMatch.NodeServerUrl,
});
}
var paired = await _pairUp.TryPairAsync(
"arena_two_pick_battle",
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
ct);
if (paired is null)
{
// 3002 = RC_BATTLE_MATCHING_RETRY: client polls again. 3001 is ILLEGAL
// and shows an error dialog on the client side. node_server_url must be
// present (the client's DoMatchingBase.SettingDoMatchingData calls
// .ToString() on it without a Keys.Contains guard); prod sends "" while
// waiting and the real URL only on SUCCEEDED. battle_id stays absent
// (its accessor IS guarded).
return Ok(new DoMatchingResponseDto
{
MatchingState = 3002,
NodeServerUrl = "",
});
}
// Owner (first arriver, cache hit) gets 3007 = RC_BATTLE_MATCHING_SUCCEEDED_OWNER;
// joiner (second arriver who triggered the pair) gets 3004 = RC_BATTLE_MATCHING_SUCCEEDED.
// See PairUpResult docs for why this split is observationally inert in TK2 today.
return Ok(new DoMatchingResponseDto
{
MatchingState = paired.IsOwner ? 3007 : 3004,
BattleId = paired.Match.BattleId,
NodeServerUrl = paired.Match.NodeServerUrl,
});
}
catch (ArenaTwoPickException ex)
{
return BadRequest(new { error_code = ex.ErrorCode });
}
}
[HttpPost("finish")]

View File

@@ -1,10 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.BuildDeck;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BuildDeck;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.BuildDeck;
@@ -20,39 +19,16 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class BuildDeckController : SVSimController
{
private readonly IBuildDeckRepository _repo;
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly ICurrencySpendService _spend;
private readonly IInventoryService _inv;
public BuildDeckController(
IBuildDeckRepository repo,
SVSimDbContext db,
RewardGrantService rewards,
ICurrencySpendService spend)
IInventoryService inv)
{
_repo = repo;
_db = db;
_rewards = rewards;
_spend = spend;
_inv = inv;
}
/// <summary>
/// Loads the viewer with the full cosmetic / inventory graph + BuildDeckPurchases. This is
/// the single load /build_deck/buy makes; every subsequent mutation operates on the returned
/// instance and the controller saves once at the end.
/// </summary>
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.LeaderSkins)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.BuildDeckPurchases)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// The wire shape for /build_deck/info has `data` as a bare collection of series, not a
// DTO with a `series_list` field. The client (BuildDeckPurchaseInfoTask.Parse) iterates
// `data` directly via numeric indexer:
@@ -194,60 +170,45 @@ public class BuildDeckController : SVSimController
break;
}
// Single viewer load with the full graph — every subsequent mutation (currency debit,
// purchase counter, card grants, cosmetic grants) operates on this one in-memory instance
// so we can save once at the end.
var viewer = await LoadViewerGraphAsync(viewerId);
var rewardList = new List<RewardListEntry>();
// Open the inventory transaction — loads canonical graph + BuildDeckPurchases.
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted,
cfg => cfg.WithInclude(v => v.BuildDeckPurchases));
var viewer = tx.Viewer;
// Debit + post-state currency entry
// Debit currency
if (request.SalesType == 1)
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, priceCrystal!.Value);
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, priceCrystal!.Value);
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal });
}
else if (request.SalesType == 2)
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, priceRupy!.Value);
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, priceRupy!.Value);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal });
}
// sales_type == 0 (free): no debit, no currency entry
// sales_type == 0 (free): no debit
// Compute series purchase total BEFORE this buy
int prevSeriesCount = product.Series!.Products
.Sum(p => purchases.TryGetValue(p.Id, out var v) ? v.PurchaseCount : 0);
int newSeriesCount = prevSeriesCount + 1;
// Increment purchase counter directly on the tracked viewer (we already loaded
// BuildDeckPurchases via LoadViewerGraphAsync). The repo's IncrementPurchaseCount would
// re-attach to the same instance and trigger an extra save — inlining keeps the
// controller's single-save model intact.
// Increment purchase counter on tx.Viewer (tx loaded BuildDeckPurchases via WithInclude).
var purchaseRow = viewer.BuildDeckPurchases.FirstOrDefault(p => p.ProductId == product.Id);
if (purchaseRow is null)
viewer.BuildDeckPurchases.Add(new ViewerBuildDeckProductPurchase { ProductId = product.Id, PurchaseCount = 1 });
else
purchaseRow.PurchaseCount += 1;
// Grant the 40 deck cards. Bucket by CardId so duplicate (CardId, IsSpot) rows don't
// emit redundant reward_list entries — ApplyAsync(Card, ...) runs the cosmetic cascade
// and returns a post-state-total entry per call.
var deckGrants = product.Cards
.GroupBy(c => c.CardId)
.Select(g => (UserGoodsType.Card, g.Key, g.Sum(c => c.Number)));
await ApplyRewardsAsync(viewer, deckGrants, rewardList);
// Grant deck cards (grouped by CardId)
foreach (var grp in product.Cards.GroupBy(c => c.CardId))
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Sum(c => c.Number));
// Per-buy rewards from the product catalog: sleeve, emblem, skin, sometimes extra cards
// (Set 4 grants 3 copies of the featured card as a type=5 reward).
await ApplyRewardsAsync(viewer, product.Rewards
.OrderBy(r => r.RewardIndex)
.Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)),
rewardList);
// Per-buy rewards
foreach (var r in product.Rewards.OrderBy(r => r.RewardIndex))
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
// Series-reward tier crossings: tiers where prevSeriesCount < TierIndex <= newSeriesCount.
// Captured tiers include type 4 (Item), 5 (Card), 6 (Sleeve), 7 (Emblem) — granting them
// all uniformly avoids the earlier card-only path that dropped non-card tier rewards.
// Series-reward tier crossings
var crossedTiers = product.Series.SeriesRewards
.Where(r => r.TierIndex > prevSeriesCount && r.TierIndex <= newSeriesCount)
.GroupBy(r => r.TierIndex)
@@ -257,13 +218,9 @@ public class BuildDeckController : SVSimController
var seriesRewards = new List<BuildDeckProductRewardDto>();
foreach (var tier in crossedTiers)
{
await ApplyRewardsAsync(viewer, tier
.OrderBy(r => r.ItemIndex)
.Select(r => ((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber)),
rewardList);
foreach (var item in tier.OrderBy(r => r.ItemIndex))
{
await tx.GrantAsync((UserGoodsType)item.RewardType, item.RewardDetailId, item.RewardNumber);
seriesRewards.Add(new BuildDeckProductRewardDto
{
RewardType = item.RewardType,
@@ -274,39 +231,17 @@ public class BuildDeckController : SVSimController
}
}
await _db.SaveChangesAsync();
var result = await tx.CommitAsync(HttpContext.RequestAborted);
return new BuildDeckBuyResponse
{
RewardList = rewardList,
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
SeriesRewards = seriesRewards,
};
}
/// <summary>
/// Dispatches each (type, id, num) tuple through <see cref="RewardGrantService.ApplyAsync"/>
/// and appends the resulting wire entries to <paramref name="rewardList"/>. Caller saves.
/// </summary>
private async Task ApplyRewardsAsync(
Viewer viewer,
IEnumerable<(UserGoodsType Type, long DetailId, int Number)> rewards,
List<RewardListEntry> rewardList)
{
foreach (var (type, detailId, number) in rewards)
{
var granted = await _rewards.ApplyAsync(viewer, type, detailId, number);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
}
}
[HttpPost("get_purchase_count")]
public async Task<ActionResult<BuildDeckGetPurchaseCountResponse>> GetPurchaseCount(
BuildDeckGetPurchaseCountRequest request)

View File

@@ -38,12 +38,36 @@ public class CheckController : SVSimController
?? throw new InvalidOperationException("Auth handler must set viewer in context.");
Viewer fullViewer = await _viewerRepository.GetViewerWithSocials(viewer.Id) ?? viewer;
// Wipe-and-resignup reconciliation: /tool/signup is anonymous on the wire and can't see
// the Steam ticket, so a freshly-wiped client lands a blank V_new keyed on its new UDID
// while the Steam handler on this very request resolves to the original V_old. The client
// has already written V_new.Id into Certification.ViewerId from the signup response; left
// alone, it stays wrong forever (NormalTask.Parse never reads data_headers.viewer_id —
// only SignUpTask / GameStartCheckTask.rewrite_viewer_id / the social-chain tasks do).
// Detect the mismatch by re-looking-up the UDID-keyed viewer and emit rewrite_viewer_id
// when it disagrees with the auth-resolved one.
long? rewriteViewerId = null;
Guid? udid = HttpContext.GetUdid();
if (udid is Guid u && u != Guid.Empty)
{
Viewer? udidViewer = await _viewerRepository.GetViewerByUdid(u);
if (udidViewer is not null && udidViewer.Id != fullViewer.Id)
{
rewriteViewerId = fullViewer.Id;
// Reclaim the orphan: transfer the fresh UDID onto the Steam-resolved viewer
// and delete the just-created blank anonymous one. Future GetViewerByUdid
// calls then short-circuit to V_old without going through the Steam handler.
await _viewerRepository.MergeAnonymousViewerInto(udidViewer.Id, fullViewer.Id);
}
}
return new GameStartResponse
{
NowViewerId = fullViewer.Id,
NowName = fullViewer.DisplayName,
NowTutorialStep = fullViewer.MissionData.TutorialState.ToString(),
IsSetTransitionPassword = true,
RewriteViewerId = rewriteViewerId,
// Stub rank map until per-format ranks are persisted (prod observed: "1"/"2"/"4"
// keys mapping to RankName_010 / RankName_017). Empty dict here may be safe but
// we don't yet know which client paths read this — match prod stub.

View File

@@ -2,7 +2,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
@@ -27,12 +27,12 @@ public class GiftController : SVSimController
};
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly IInventoryService _inv;
public GiftController(SVSimDbContext db, RewardGrantService rewards)
public GiftController(SVSimDbContext db, IInventoryService inv)
{
_db = db;
_rewards = rewards;
_inv = inv;
}
[HttpPost("/tutorial/gift_top")]
@@ -71,25 +71,7 @@ public class GiftController : SVSimController
var requestedIds = request.PresentIdArray.ToHashSet();
// Load viewer with the collections RewardGrantService mutates. Crystals/Rupees live on
// viewer.Currency (owned, auto-loads); Items live on viewer.Items (owned collection).
// MissionData is an owned type and auto-loads, but Include is listed explicitly to match
// the pattern in TutorialController.Update and to make the intent clear.
// AsSplitQuery is the default-safe pattern when including viewer collections
// (project memory: project_ef_split_query).
//
// ThenInclude(i => i.Item) is load-bearing: OwnedItemEntry.Item is a separate non-owned
// entity whose default initialiser is `new ItemEntry()` (Id=0). Without the explicit
// ThenInclude, RewardGrantService.ApplyAsync's `FirstOrDefault(i => i.Item.Id == ...)`
// never matches a pre-existing row → falls through to add a duplicate → (ViewerId, ItemId)
// unique index throws on SaveChanges (project_ef_nav_include_pitfall).
var viewer = await _db.Viewers
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.MissionData)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// Resolve which of the requested ids are still claimable for this viewer.
// Resolve which of the requested ids are still claimable for this viewer before opening tx.
var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts
.Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId))
.Select(g => g.PresentId)
@@ -100,23 +82,43 @@ public class GiftController : SVSimController
.Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId))
.ToList();
// Apply grants via the canonical primitive. Pass the loaded viewer, NOT viewerId.
// Open inventory tx with MissionData loaded for tutorial-step advance.
await using var tx = await _inv.BeginAsync(viewerId, configure:
cfg => cfg.WithInclude(v => v.MissionData));
// Apply grants via tx. Collect post-state per (type, detailId) for reward_list.
// Each GrantAsync returns a list of GrantedReward with post-state totals; for currencies
// only one entry is returned; for cards the cascade may return more entries (card + cosmetics).
// reward_list must carry post-state totals (client does direct assignment).
var rewardListEntries = new List<GiftRewardListEntry>();
foreach (var p in toClaim)
{
var goodsType = WireRewardTypeToUserGoodsType(int.Parse(p.RewardType));
await _rewards.ApplyAsync(viewer, goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount));
var granted = await tx.GrantAsync(goodsType, long.Parse(p.RewardDetailId), int.Parse(p.RewardCount));
// Use the first granted entry's post-state for the top-level gift reward_list entry.
// Gift rewards are currencies and items only (no cards in TutorialGifts), so granted
// always has exactly one element. The post-state total is already correct from tx.
if (granted.Count > 0)
{
rewardListEntries.Add(new GiftRewardListEntry
{
RewardType = p.RewardType,
RewardId = p.RewardDetailId,
RewardNum = granted[0].RewardNum.ToString(System.Globalization.CultureInfo.InvariantCulture),
});
}
}
// Advance tutorial state from 31 → 41 as a side-effect of this claim (no separate
// /tutorial/update → 41 call appears in the prod capture). Preserve max — don't downgrade
// viewers who are already past step 41.
const int GiftReceiveTutorialStep = 41;
if (viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
if (tx.Viewer.MissionData.TutorialState < GiftReceiveTutorialStep)
{
viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
tx.Viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
}
// Persist claim receipts in the same transaction.
// Persist claim receipts inside the same tx.
var now = DateTime.UtcNow;
foreach (var p in toClaim)
{
@@ -127,7 +129,7 @@ public class GiftController : SVSimController
ClaimedAt = now,
});
}
await _db.SaveChangesAsync();
await tx.CommitAsync();
var nowString = now.ToString("yyyy-MM-dd HH:mm:ss");
var allClaimedList = await _db.ViewerClaimedTutorialGifts
@@ -176,54 +178,18 @@ public class GiftController : SVSimController
// Hardcoding false hid the badge after partial claims even though present_list still
// carried unclaimed entries.
IsUnreceivedPresent = unclaimedPresents.Count > 0,
// reward_list entries must carry POST-STATE TOTALS, not gift deltas.
// The client's PlayerStaticData.UpdateHaveUserGoodsNumByJsonData does direct
// assignment on each entry's reward_num — emitting the delta would clobber
// the client-side cached balance down to the gift amount until the next /load/index.
// reward_list entries carry POST-STATE TOTALS (from tx.GrantAsync).
// See project memory: project_wire_reward_list_post_state.
//
// Iterate `toClaim` so idempotent re-receive doesn't re-emit post-state entries
// the client would direct-assign again (no-op on currency, but redundant traffic
// and risk of misinterpretation on item counts).
RewardList = toClaim
.Select(p => new GiftRewardListEntry
{
RewardType = p.RewardType,
RewardId = p.RewardDetailId,
RewardNum = ResolvePostStateRewardNum(p, viewer),
})
.ToList(),
// Iterate toClaim so idempotent re-receive doesn't re-emit post-state entries.
RewardList = rewardListEntries,
// Echo the persisted state, not a hardcoded 41. The state may already be past 41
// for replay/edge-case calls (the Math.Max-preserve block above keeps it stable);
// emitting 41 anyway would surface a regressed step to the client and desync the
// tutorial-state machine.
TutorialStep = viewer.MissionData.TutorialState,
TutorialStep = tx.Viewer.MissionData.TutorialState,
};
}
/// <summary>
/// Returns the post-grant viewer balance for the given gift entry, not the gift delta.
/// reward_list on wire carries post-state totals (client does direct assignment).
/// </summary>
private static string ResolvePostStateRewardNum(PresentDto gift, SVSim.Database.Models.Viewer viewer)
{
switch (gift.RewardType)
{
case "1": // Crystal
return ((long)viewer.Currency.Crystals).ToString(System.Globalization.CultureInfo.InvariantCulture);
case "9": // Rupy
return ((long)viewer.Currency.Rupees).ToString(System.Globalization.CultureInfo.InvariantCulture);
case "4": // Item
{
int itemId = int.Parse(gift.RewardDetailId, System.Globalization.CultureInfo.InvariantCulture);
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == itemId);
return ((long)(owned?.Count ?? 0)).ToString(System.Globalization.CultureInfo.InvariantCulture);
}
default:
return gift.RewardCount; // unknown type — fall back to gift count (better than 0)
}
}
private static UserGoodsType WireRewardTypeToUserGoodsType(int wireType) => wireType switch
{
1 => UserGoodsType.Crystal,

View File

@@ -4,6 +4,7 @@ using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ItemPurchase;
@@ -21,16 +22,14 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class ItemPurchaseController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly IInventoryService _inv;
private readonly TimeProvider _time;
private readonly ICurrencySpendService _spend;
public ItemPurchaseController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend)
public ItemPurchaseController(SVSimDbContext db, IInventoryService inv, TimeProvider time)
{
_db = db;
_rewards = rewards;
_inv = inv;
_time = time;
_spend = spend;
}
[HttpPost("info")]
@@ -115,28 +114,17 @@ public class ItemPurchaseController : SVSimController
if (rest <= 0)
return BadRequest(new { error = "sold_out" });
var viewer = await LoadViewerGraphAsync(viewerId);
var rewardList = new List<RewardListEntry>();
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
// Debit the require side. RewardGrantService is grant-only, so handle this inline.
var debit = await TryDebit(viewer, (UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState);
// Debit the require side via the tx.
var debit = await tx.TryDebitAsync(
(UserGoodsType)entry.RequireItemType, entry.RequireItemId, entry.RequireItemNum);
if (!debit.Success) return BadRequest(new { error = MapDebitError(entry.RequireItemType) });
// Grant the purchase side through the central dispatcher.
var granted = await _rewards.ApplyAsync(viewer,
(UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
// Grant the purchase side.
await tx.GrantAsync((UserGoodsType)entry.PurchaseItemType, entry.PurchaseItemId, entry.PurchaseItemNum);
// Increment the per-period counter.
// Increment the per-period counter (tracked via _db, outside the inventory tx).
if (counter is null)
{
_db.ViewerEventCounters.Add(new ViewerEventCounter
@@ -151,52 +139,27 @@ public class ItemPurchaseController : SVSimController
{
counter.Count++;
}
await _db.SaveChangesAsync();
return new ItemPurchasePurchaseResponse { RewardList = rewardList };
}
var result = await tx.CommitAsync(HttpContext.RequestAborted);
/// <summary>
/// Debit <paramref name="num"/> of (<paramref name="type"/>, <paramref name="detailId"/>)
/// from the viewer, returning a post-state-aware <see cref="RewardListEntry"/> the client
/// uses to refresh its cached count. Returns an error string on insufficient balance.
/// </summary>
private async Task<(RewardListEntry? PostState, string? Error)> TryDebit(
Viewer viewer, UserGoodsType type, long detailId, int num)
{
switch (type)
return new ItemPurchasePurchaseResponse
{
case UserGoodsType.RedEther:
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.RedEther, num);
if (!r.Success) return (null, "insufficient_red_ether");
return (new RewardListEntry { RewardType = 1, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
case UserGoodsType.Crystal:
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, num);
if (!r.Success) return (null, "insufficient_crystals");
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
case UserGoodsType.Rupy:
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, num);
if (!r.Success) return (null, "insufficient_rupees");
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
case UserGoodsType.Item:
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)detailId);
if (owned is null || owned.Count < num)
return (null, "insufficient_item");
owned.Count -= num;
return (new RewardListEntry { RewardType = 4, RewardId = detailId, RewardNum = owned.Count }, null);
default:
return (null, $"debit_type_not_supported:{type}");
}
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
};
}
private static string MapDebitError(int requireType) => requireType switch
{
(int)UserGoodsType.RedEther => "insufficient_red_ether",
(int)UserGoodsType.Crystal => "insufficient_crystals",
(int)UserGoodsType.Rupy => "insufficient_rupees",
(int)UserGoodsType.Item => "insufficient_item",
_ => "debit_type_not_supported",
};
private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}";
private static int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey)
@@ -204,15 +167,4 @@ public class ItemPurchaseController : SVSimController
var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime;
return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0;
}
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}

View File

@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.LeaderSkin;
@@ -29,19 +30,15 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class LeaderSkinController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly IInventoryService _inv;
private readonly TimeProvider _time;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
private readonly ICollectionRepository _collection;
public LeaderSkinController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection)
public LeaderSkinController(SVSimDbContext db, IInventoryService inv, TimeProvider time, ICollectionRepository collection)
{
_db = db;
_rewards = rewards;
_inv = inv;
_time = time;
_spend = spend;
_entitlements = entitlements;
_collection = collection;
}
@@ -69,7 +66,8 @@ public class LeaderSkinController : SVSimController
var skin = await _db.LeaderSkins.FindAsync(request.LeaderSkinId);
if (skin is null) return BadRequest(new { error = "unknown_skin" });
if (skin.ClassId != request.ClassId) return BadRequest(new { error = "skin_class_mismatch" });
if (!_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, skin.Id))
var cosmeticsForSet = await _inv.EffectiveCosmeticsAsync(viewer);
if (!cosmeticsForSet.OwnedLeaderSkinIds.Contains(skin.Id))
return BadRequest(new { error = "skin_not_owned" });
classData.LeaderSkin = skin;
@@ -88,18 +86,13 @@ public class LeaderSkinController : SVSimController
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
if (_entitlements.IsFreeplay)
{
var all = (await _collection.GetLeaderSkins()).Select(s => s.Id).OrderBy(id => id).ToList();
return new LeaderSkinIdsResponse { UserLeaderSkinIds = all };
}
var ids = await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
.OrderBy(id => id)
.ToListAsync();
var viewer = await _db.Viewers
.Include(v => v.LeaderSkins)
.FirstOrDefaultAsync(v => v.Id == viewerId);
if (viewer is null) return Unauthorized();
var cosmetics = await _inv.EffectiveCosmeticsAsync(viewer);
var ids = cosmetics.OwnedLeaderSkinIds.OrderBy(id => id).ToList();
return new LeaderSkinIdsResponse { UserLeaderSkinIds = ids };
}
@@ -108,12 +101,13 @@ public class LeaderSkinController : SVSimController
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var ownedSkinIds = _entitlements.IsFreeplay
? (await _collection.GetLeaderSkins()).Select(s => s.Id).ToHashSet()
: (await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.LeaderSkins.Select(s => s.Id))
.ToListAsync()).ToHashSet();
var viewerForProducts = await _db.Viewers
.Include(v => v.LeaderSkins)
.FirstOrDefaultAsync(v => v.Id == viewerId);
if (viewerForProducts is null) return Unauthorized();
var cosmeticsForProducts = await _inv.EffectiveCosmeticsAsync(viewerForProducts);
var ownedSkinIds = cosmeticsForProducts.OwnedLeaderSkinIds;
var claimedSeries = (await _db.ViewerLeaderSkinSetClaims
.Where(c => c.ViewerId == viewerId)
@@ -183,21 +177,41 @@ public class LeaderSkinController : SVSimController
if (!product.IsEnabled || product.Series is not { IsEnabled: true })
return BadRequest(new { error = "product_not_available" });
var viewer = await LoadViewerGraphAsync(viewerId);
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
// Already-purchased = viewer owns the leader_skin this product grants.
if (_entitlements.OwnsCosmetic(viewer, CosmeticType.Skin, product.LeaderSkinId))
if (tx.OwnsCosmetic(CosmeticType.Skin, product.LeaderSkinId))
return BadRequest(new { error = "already_purchased" });
var rewardList = new List<RewardListEntry>();
var debit = await DebitProductPrice(viewer, product, request.SalesType);
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState);
// Debit currency
switch (request.SalesType)
{
case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0:
break; // free
case 0:
return BadRequest(new { error = "price_not_available_for_currency" });
case 1:
if (product.SinglePriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" });
{ var r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.SinglePriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
break;
case 2:
if (product.SinglePriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" });
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.SinglePriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
break;
default:
return BadRequest(new { error = "invalid_sales_type" });
}
await ApplyRewardsAsync(viewer, product.Rewards, rewardList);
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
await _db.SaveChangesAsync();
return new LeaderSkinBuyResponse { RewardList = rewardList };
var result = await tx.CommitAsync(HttpContext.RequestAborted);
return new LeaderSkinBuyResponse
{
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
};
}
[HttpPost("buy_set")]
@@ -218,25 +232,44 @@ public class LeaderSkinController : SVSimController
if (!series.IsEnabled || series.SetSalesStatus == 0)
return BadRequest(new { error = "set_sale_not_active" });
var viewer = await LoadViewerGraphAsync(viewerId);
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
if (_entitlements.IsFreeplay)
if (tx.IsFreeplay)
return BadRequest(new { error = "already_purchased" });
var rewardList = new List<RewardListEntry>();
var debit = await DebitSetPrice(viewer, series, request.SalesType);
if (debit.Error is not null) return BadRequest(new { error = debit.Error });
if (debit.PostState is not null) rewardList.Add(debit.PostState);
// Grant every product's rewards; RewardGrantService is idempotent on already-owned
// cosmetics, so partial-set buyers don't double-add.
foreach (var p in series.Products.OrderBy(p => p.Id))
// Debit set price
switch (request.SalesType)
{
await ApplyRewardsAsync(viewer, p.Rewards, rewardList);
case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0:
break; // free
case 0:
return BadRequest(new { error = "price_not_available_for_currency" });
case 1:
if (series.SetPriceCrystal is null) return BadRequest(new { error = "price_not_available_for_currency" });
{ var r = await tx.TrySpendAsync(SpendCurrency.Crystal, series.SetPriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
break;
case 2:
if (series.SetPriceRupy is null) return BadRequest(new { error = "price_not_available_for_currency" });
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, series.SetPriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
break;
default:
return BadRequest(new { error = "invalid_sales_type" });
}
await _db.SaveChangesAsync();
return new LeaderSkinBuyResponse { RewardList = rewardList };
// Grant every product's rewards; tx.GrantAsync is idempotent on already-owned cosmetics.
foreach (var p in series.Products.OrderBy(p => p.Id))
{
foreach (var r in p.Rewards.OrderBy(r => r.OrderIndex))
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
}
var result = await tx.CommitAsync(HttpContext.RequestAborted);
return new LeaderSkinBuyResponse
{
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
};
}
[HttpPost("buy_set_item")]
@@ -257,16 +290,15 @@ public class LeaderSkinController : SVSimController
if (existingClaim is not null)
return new LeaderSkinBuyResponse { RewardList = new() };
var viewer = await LoadViewerGraphAsync(viewerId);
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
// Must own every skin in the series to claim the bonus.
var ownedSkinIds = viewer.LeaderSkins.Select(s => s.Id).ToHashSet();
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => ownedSkinIds.Contains(p.LeaderSkinId));
bool ownsAll = series.Products.Count > 0 && series.Products.All(p => tx.OwnsCosmetic(CosmeticType.Skin, p.LeaderSkinId));
if (!ownsAll)
return BadRequest(new { error = "series_not_completed" });
var rewardList = new List<RewardListEntry>();
await ApplyRewardsAsync(viewer, series.SetCompletionRewards, rewardList);
foreach (var r in series.SetCompletionRewards.OrderBy(r => r.OrderIndex))
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
_db.ViewerLeaderSkinSetClaims.Add(new ViewerLeaderSkinSetClaim
{
@@ -275,8 +307,13 @@ public class LeaderSkinController : SVSimController
ClaimedAt = _time.GetUtcNow().UtcDateTime,
});
await _db.SaveChangesAsync();
return new LeaderSkinBuyResponse { RewardList = rewardList };
var result = await tx.CommitAsync(HttpContext.RequestAborted);
return new LeaderSkinBuyResponse
{
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
};
}
/// <summary>
@@ -304,7 +341,7 @@ public class LeaderSkinController : SVSimController
return 1;
}
private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, HashSet<int> ownedSkinIds)
private static SkinProductDto ToProductDto(LeaderSkinShopProductEntry p, IReadOnlySet<int> ownedSkinIds)
{
bool isPurchased = ownedSkinIds.Contains(p.LeaderSkinId);
return new SkinProductDto
@@ -339,7 +376,7 @@ public class LeaderSkinController : SVSimController
/// emblem/sleeve typically come with the skin, so the heuristic is "skin owned → all three
/// bundle items are de-facto owned." Refine later if a capture shows independent state.
/// </summary>
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, HashSet<int> ownedSkinIds)
private static bool IsRewardOwned(LeaderSkinShopProductRewardEntry r, IReadOnlySet<int> ownedSkinIds)
{
// Skin reward: direct check.
if (r.RewardType == (int)UserGoodsType.Skin)
@@ -350,94 +387,4 @@ public class LeaderSkinController : SVSimController
return false;
}
private async Task<(RewardListEntry? PostState, string? Error)> DebitProductPrice(
Viewer viewer, LeaderSkinShopProductEntry product, int salesType)
{
switch (salesType)
{
case 0 when product.SinglePriceCrystal == 0 && product.SinglePriceRupy == 0:
return (null, null);
case 0:
return (null, "price_not_available_for_currency");
case 1:
if (product.SinglePriceCrystal is null) return (null, "price_not_available_for_currency");
return await DebitCrystal(viewer, product.SinglePriceCrystal.Value);
case 2:
if (product.SinglePriceRupy is null) return (null, "price_not_available_for_currency");
return await DebitRupy(viewer, product.SinglePriceRupy.Value);
default:
return (null, "invalid_sales_type");
}
}
private async Task<(RewardListEntry? PostState, string? Error)> DebitSetPrice(
Viewer viewer, LeaderSkinShopSeriesEntry series, int salesType)
{
switch (salesType)
{
case 0 when series.SetPriceCrystal == 0 && series.SetPriceRupy == 0:
return (null, null);
case 0:
return (null, "price_not_available_for_currency");
case 1:
if (series.SetPriceCrystal is null) return (null, "price_not_available_for_currency");
return await DebitCrystal(viewer, series.SetPriceCrystal.Value);
case 2:
if (series.SetPriceRupy is null) return (null, "price_not_available_for_currency");
return await DebitRupy(viewer, series.SetPriceRupy.Value);
default:
return (null, "invalid_sales_type");
}
}
private async Task<(RewardListEntry?, string?)> DebitCrystal(Viewer viewer, int amount)
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, amount);
if (!r.Success) return (null, "insufficient_crystals");
return (new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
private async Task<(RewardListEntry?, string?)> DebitRupy(Viewer viewer, int amount)
{
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, amount);
if (!r.Success) return (null, "insufficient_rupees");
return (new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)r.PostStateTotal }, null);
}
private async Task ApplyRewardsAsync<T>(
Viewer viewer, IEnumerable<T> rewards, List<RewardListEntry> rewardList) where T : notnull
{
foreach (var r in rewards)
{
var (type, detailId, number) = ExtractTuple(r);
var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)type, detailId, number);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
}
}
private static (int Type, long Id, int Num) ExtractTuple(object reward) => reward switch
{
LeaderSkinShopProductRewardEntry p => (p.RewardType, p.RewardDetailId, p.RewardNumber),
LeaderSkinShopSeriesRewardEntry s => (s.RewardType, s.RewardDetailId, s.RewardNumber),
_ => throw new InvalidOperationException($"unexpected reward type {reward.GetType().Name}"),
};
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.LeaderSkins)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}

View File

@@ -10,6 +10,7 @@ using PreReleaseInfoDto = SVSim.EmulatedEntrypoint.Models.Dtos.PreReleaseInfo;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Models.Dtos;
@@ -42,26 +43,24 @@ public class LoadController : SVSimController
private readonly IViewerRepository _viewerRepository;
private readonly IGlobalsRepository _globalsRepository;
private readonly ICardAcquisitionService _acquisition;
private readonly IGameConfigService _config;
private readonly IBattlePassService _battlePass;
private readonly IViewerMissionStateService _missionState;
private readonly SVSimDbContext _db;
private readonly IViewerEntitlements _entitlements;
private readonly IInventoryService _inv;
public LoadController(IViewerRepository viewerRepository, IGlobalsRepository globalsRepository,
ICardAcquisitionService acquisition, IGameConfigService config,
IGameConfigService config,
IBattlePassService battlePass, IViewerMissionStateService missionState,
SVSimDbContext db, IViewerEntitlements entitlements)
SVSimDbContext db, IInventoryService inv)
{
_viewerRepository = viewerRepository;
_globalsRepository = globalsRepository;
_acquisition = acquisition;
_config = config;
_battlePass = battlePass;
_missionState = missionState;
_db = db;
_entitlements = entitlements;
_inv = inv;
}
[HttpPost("index")]
@@ -84,7 +83,9 @@ public class LoadController : SVSimController
// .AsNoTracking() — the local `viewer` instance is detached, and the service's writes
// (on a separate tracked instance) won't appear on this snapshot. Without the re-fetch,
// the response payload would be one /load/index behind on newly-granted cosmetics.
await _acquisition.BackfillCosmeticsAsync(viewer.Id);
await using var tx = await _inv.BeginAsync(viewer.Id, ct);
await tx.BackfillCardCosmeticsAsync(ct);
await tx.CommitAsync(ct);
// Lazy-materialize mission/achievement state. Idempotent — safe to call every /load/index.
await _missionState.EnsureCurrentAsync(viewer.Id);
@@ -125,9 +126,9 @@ public class LoadController : SVSimController
// re-confirm the filter if we later move to Option B and start iterating card-sets.
// Owned-card projection (incl. the freeplay "all cards" path) lives in the entitlements
// service so both modes share one definition.
var allCardsAsOwned = await _entitlements.EffectiveOwnedCardsAsync(viewer, ct);
var allCardsAsOwned = await _inv.EffectiveOwnedCardsAsync(viewer, ct);
var cosmetics = await _entitlements.EffectiveCosmeticsAsync(viewer, ct);
var cosmetics = await _inv.EffectiveCosmeticsAsync(viewer, ct);
var classExpCurve = await _globalsRepository.GetClassExpCurve();
List<ClassExp> classExps = new();
@@ -168,10 +169,10 @@ public class LoadController : SVSimController
UserInfo = new UserInfo(deviceType, viewer),
UserCurrency = new UserCurrency(viewer)
{
Crystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal),
TotalCrystals = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal),
Rupees = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee),
RedEther = (ulong)_entitlements.EffectiveBalance(viewer, SpendCurrency.RedEther),
Crystals = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Crystal),
TotalCrystals = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Crystal),
Rupees = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.Rupee),
RedEther = (ulong)_inv.EffectiveBalance(viewer, SpendCurrency.RedEther),
},
UserItems = viewer.Items.Select(item => new UserItem(item)).ToList(),
SpotPoint = checked((int)viewer.Currency.SpotPoints),

View File

@@ -11,6 +11,7 @@ using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Pack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Pack;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -30,10 +31,8 @@ public class PackController : SVSimController
private readonly ICardFoilLookup _foils;
private readonly IRandom _rng;
private readonly SVSimDbContext _db;
private readonly ICardAcquisitionService _acquisition;
private readonly IInventoryService _inv;
private readonly IGachaPointService _gachaPoint;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
public PackController(
IPackRepository packs,
@@ -42,10 +41,8 @@ public class PackController : SVSimController
ICardFoilLookup foils,
IRandom rng,
SVSimDbContext db,
ICardAcquisitionService acquisition,
IGachaPointService gachaPoint,
ICurrencySpendService spend,
IViewerEntitlements entitlements)
IInventoryService inv,
IGachaPointService gachaPoint)
{
_packs = packs;
_opener = opener;
@@ -53,10 +50,8 @@ public class PackController : SVSimController
_foils = foils;
_rng = rng;
_db = db;
_acquisition = acquisition;
_inv = inv;
_gachaPoint = gachaPoint;
_spend = spend;
_entitlements = entitlements;
}
[HttpPost("info")]
@@ -207,26 +202,18 @@ public class PackController : SVSimController
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// Load the viewer with the collections the service mutates (balances, received marker,
// cards, cosmetics). AsSplitQuery per project_ef_split_query memory.
var viewer = await _db.Viewers
.Include(v => v.GachaPointBalances)
.Include(v => v.GachaPointReceived)
.Include(v => v.Cards)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.Degrees)
.Include(v => v.LeaderSkins)
.Include(v => v.MyPageBackgrounds)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// Open inventory tx with extra includes for GachaPointBalances + GachaPointReceived
// (needed by TryExchangeAsync to validate balance and already-received guard).
await using var tx = await _inv.BeginAsync(viewerId, configure: cfg => cfg
.WithInclude(v => v.GachaPointBalances)
.WithInclude(v => v.GachaPointReceived));
// Use odds_gacha_id (the seasonal pack id) — that's where the balance / received marker
// live. Mirrors the GetGachaPointRewards fix.
var outcome = await _gachaPoint.TryExchangeAsync(viewer, request.OddsGachaId, request.CardId);
var outcome = await _gachaPoint.TryExchangeAsync(tx, request.OddsGachaId, request.CardId);
if (!outcome.Success) return BadRequest(new { error = outcome.Error });
await _db.SaveChangesAsync();
await tx.CommitAsync();
return new ExchangeGachaPointResponse
{
@@ -287,13 +274,12 @@ public class PackController : SVSimController
if (!isTutorialPath && child.TypeDetail is not (1 or 2 or 3 or 4 or 5 or 6 or 7))
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
var viewer = await _db.Viewers
.Include(v => v.PackOpenCounts)
.Include(v => v.GachaPointBalances)
.Include(v => v.MissionData)
.Include(v => v.Items).ThenInclude(i => i.Item)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// Load viewer via InventoryService transaction with extra includes for pack-open needs.
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted, cfg => cfg
.WithInclude(v => v.PackOpenCounts)
.WithInclude(v => v.GachaPointBalances)
.WithInclude(v => v.MissionData));
var viewer = tx.Viewer;
// Tutorial alias is only valid pre-END. After state>=100 the viewer has already
// completed the tutorial — re-running the path would re-consume the ticket they
@@ -314,7 +300,7 @@ public class PackController : SVSimController
case 2: // CRYSTAL_MULTI (10-pack)
{
long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, cost);
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
break;
}
@@ -322,7 +308,7 @@ public class PackController : SVSimController
case 7: // RUPY_MULTI (10-pack)
{
long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
@@ -336,7 +322,7 @@ public class PackController : SVSimController
return BadRequest(new { error = "daily_free_already_claimed" });
long cost = (long)child.Cost * packNumber;
var r = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, cost);
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
break;
}
@@ -347,15 +333,11 @@ public class PackController : SVSimController
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
int ticketsNeeded = child.Cost * packNumber;
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
if (owned is null || owned.Count < ticketsNeeded)
return BadRequest(new { error = "insufficient_tickets" });
owned.Count -= ticketsNeeded;
var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded);
if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" });
break;
}
}
await _db.SaveChangesAsync();
}
// Increment open count + mark daily-free timestamp where relevant.
@@ -394,48 +376,17 @@ public class PackController : SVSimController
ownedCardIds,
_foils,
_rng);
var grant = await _acquisition.GrantManyAsync(viewerId, draw.Cards.Select(c => c.CardId));
// Grant drawn cards through the transaction — cosmetic cascade fires on first-time owners.
foreach (var grp in draw.Cards.GroupBy(c => c.CardId))
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count());
// Accrue gacha points (skip tutorial path — the starter pack isn't a real open).
if (!isTutorialPath)
{
_gachaPoint.Accrue(viewer, pack, child, drawCount);
await _db.SaveChangesAsync();
}
// Build reward_list. The service produces the type=5 (Card) entries with post-state counts
// plus any cosmetic grants. Currency entry (type=2 Crystals or type=9 Rupy) stays in the
// controller — it's a pack-purchase concern, not a card-grant concern. The client's
// PlayerStaticData.UpdateHaveUserGoodsNum does direct assignment, so currency/card counts
// must be the new TOTAL — emitting deltas would leave the on-screen balances stale.
var rewardList = new List<RewardListEntry>();
// Currency reward entries only apply to purchasable packs; tutorial path omits them.
if (!isTutorialPath)
{
if (child.TypeDetail is 1 or 2)
{
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Crystal) });
}
else if (child.TypeDetail is 3 or 6 or 7)
{
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)_entitlements.EffectiveBalance(viewer, SpendCurrency.Rupee) });
}
else if (child.TypeDetail is 4 or 5 && child.ItemId is long ticketItemId)
{
// Item post-state count for the ticket we just consumed — client direct-assigns
// _userItemDict, so this must be the new total (project_wire_reward_list_post_state).
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
rewardList.Add(new RewardListEntry
{
RewardType = 4, // Item
RewardId = ticketItemId,
RewardNum = owned?.Count ?? 0, // post-state total
});
}
}
rewardList.AddRange(grant.RewardList);
// Tutorial path consumes the granted ticket (same item_id used to gate display) so the
// pack drops out of /tutorial/pack_info on next refresh. Without this, the pack still
// shows item_number=1 after the tutorial pack-open, the client lets the user re-click
@@ -447,19 +398,12 @@ public class PackController : SVSimController
int? responseTutorialStep = null;
if (isTutorialPath)
{
if (child.ItemId is long ticketItemId)
if (child.ItemId is long tutorialTicketItemId)
{
var owned = viewer.Items.FirstOrDefault(i => i.Item.Id == (int)ticketItemId);
if (owned is not null)
{
owned.Count = Math.Max(0, owned.Count - packNumber);
rewardList.Add(new RewardListEntry
{
RewardType = 4, // Item
RewardId = ticketItemId,
RewardNum = owned.Count, // POST-STATE total
});
}
int ticketsToConsume = packNumber;
var debit = await tx.TryDebitAsync(UserGoodsType.Item, tutorialTicketItemId, ticketsToConsume);
// Silently accept if the viewer doesn't have the ticket (already consumed or never granted)
_ = debit;
}
// Max-preserve: never regress the persisted state, even though Gate B already
@@ -468,10 +412,16 @@ public class PackController : SVSimController
// the tutorial-END signal the client expects.
if (viewer.MissionData.TutorialState < TutorialEndStep)
viewer.MissionData.TutorialState = TutorialEndStep;
await _db.SaveChangesAsync();
responseTutorialStep = TutorialEndStep;
}
// CommitAsync saves all mutations and produces reward_list with currency-collision resolved.
// Tutorial path never calls TrySpendAsync so no currency op is in the log — correct.
var result = await tx.CommitAsync(HttpContext.RequestAborted);
var rewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList();
return new PackOpenResponse
{
PackList = draw.Cards.Select(c => new CardPackEntryDto

View File

@@ -1,12 +1,11 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Repositories.Globals;
using SVSim.Database.Repositories.Viewer;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.BasicPuzzle;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.BasicPuzzle;
@@ -26,20 +25,20 @@ public class PuzzleController : SVSimController
private readonly IPuzzleCatalogRepository _catalog;
private readonly IPuzzleClearRepository _clears;
private readonly PuzzleMissionEvaluator _evaluator;
private readonly RewardGrantService _rewards;
private readonly IInventoryService _inv;
private readonly ILogger<PuzzleController> _logger;
public PuzzleController(
IPuzzleCatalogRepository catalog,
IPuzzleClearRepository clears,
PuzzleMissionEvaluator evaluator,
RewardGrantService rewards,
IInventoryService inv,
ILogger<PuzzleController> logger)
{
_catalog = catalog;
_clears = clears;
_evaluator = evaluator;
_rewards = rewards;
_inv = inv;
_logger = logger;
}
@@ -175,28 +174,15 @@ public class PuzzleController : SVSimController
if (fresh.Count > 0)
{
// Load viewer with all the collections RewardGrantService might mutate. Split-query
// to avoid the cartesian-explode pitfall (CLAUDE.md "EF split query").
var ctx = HttpContext.RequestServices.GetRequiredService<SVSimDbContext>();
var viewer = await ctx.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
await using var tx = await _inv.BeginAsync(viewerId);
foreach (var status in fresh)
{
IReadOnlyList<GrantedReward> granted;
IReadOnlyList<SVSim.Database.Services.GrantedReward> granted;
try
{
granted = await _rewards.ApplyAsync(
viewer,
(SVSim.Database.Enums.UserGoodsType)status.Mission.RewardType,
granted = await tx.GrantAsync(
(UserGoodsType)status.Mission.RewardType,
status.Mission.RewardDetailId,
status.Mission.RewardNumber);
}
@@ -229,7 +215,7 @@ public class PuzzleController : SVSimController
}
}
await ctx.SaveChangesAsync();
await tx.CommitAsync();
}
response.WinCount = "1";

View File

@@ -0,0 +1,233 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Sessions;
using SVSim.Database.Enums;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Matching;
using SVSim.EmulatedEntrypoint.Models.Dtos.RankBattle;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// Rank battle family — covers rotation/unlimited human PvP + AI variants. Crossover
/// is out of scope (no AI variant; human-only). Multi-prefix URLs (rotation_rank_battle/,
/// unlimited_rank_battle/, ai_*_rank_battle/, rank_battle/) require explicit absolute
/// route attributes on each action; the controller doesn't extend SVSimController's
/// [Route("[controller]")] convention.
/// </summary>
[ApiController]
[Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)]
public sealed class RankBattleController : ControllerBase
{
private readonly IMatchingPairUpService _pairUp;
private readonly IMatchingBridge _bridge;
private readonly IBattleSessionStore _sessionStore;
private readonly IMatchContextBuilder _ctxBuilder;
private readonly IBotRoster _botRoster;
private readonly ILogger<RankBattleController> _log;
public RankBattleController(
IMatchingPairUpService pairUp,
IMatchingBridge bridge,
IBattleSessionStore sessionStore,
IMatchContextBuilder ctxBuilder,
IBotRoster botRoster,
ILogger<RankBattleController> log)
{
_pairUp = pairUp;
_bridge = bridge;
_sessionStore = sessionStore;
_ctxBuilder = ctxBuilder;
_botRoster = botRoster;
_log = log;
}
private bool TryGetViewerId(out long viewerId)
{
viewerId = 0;
var claim = User.Claims.FirstOrDefault(c => c.Type == ShadowverseClaimTypes.ViewerIdClaim)?.Value;
return claim is not null && long.TryParse(claim, out viewerId);
}
[HttpPost("/rotation_rank_battle/do_matching")]
public Task<IActionResult> DoMatchingRotation([FromBody] DoMatchingRequestDto req, CancellationToken ct)
=> DoMatchingInternal("rotation_rank_battle", Format.Rotation, req, ct);
[HttpPost("/unlimited_rank_battle/do_matching")]
public Task<IActionResult> DoMatchingUnlimited([FromBody] DoMatchingRequestDto req, CancellationToken ct)
=> DoMatchingInternal("unlimited_rank_battle", Format.Unlimited, req, ct);
// AIBattleStartTask has no SetParameter override, so the body is just the inherited
// PostParams (viewer_id / steam_id / steam_session_ticket) — but the translation
// middleware requires at least one parameter to bind the decrypted body. Use BaseRequest.
[HttpPost("/ai_rotation_rank_battle/start")]
public Task<IActionResult> AiStartRotation([FromBody] BaseRequest _, CancellationToken ct)
=> AiStartInternal(Format.Rotation, ct);
[HttpPost("/ai_unlimited_rank_battle/start")]
public Task<IActionResult> AiStartUnlimited([FromBody] BaseRequest _, CancellationToken ct)
=> AiStartInternal(Format.Unlimited, ct);
/// <summary>
/// Shared finish handler — RankBattleFinishTask parses the same wire shape for
/// all four URLs and routes server-side by URL (vs IsAINetwork flag in the client).
/// Stubbed for Phase 3: echo battle_result, emit zeros elsewhere. Real rank
/// progression math is a separate spec.
/// </summary>
[HttpPost("/rotation_rank_battle/finish")]
[HttpPost("/unlimited_rank_battle/finish")]
[HttpPost("/ai_rotation_rank_battle/finish")]
[HttpPost("/ai_unlimited_rank_battle/finish")]
public IActionResult Finish([FromBody] RankBattleFinishRequestDto req)
{
if (!TryGetViewerId(out var _)) return Unauthorized();
return Ok(new RankBattleFinishResponseDto
{
BattleResult = req.BattleResult,
// All other fields default to 0 in the DTO (ClassLevel defaults to 1).
});
}
// BaseRequest parameter on every body-less action so the translation middleware can
// bind the decrypted msgpack body (it explicitly requires at least one parameter).
[HttpPost("/rank_battle/force_finish")]
public IActionResult ForceFinish([FromBody] BaseRequest _)
{
if (!TryGetViewerId(out var _u)) return Unauthorized();
return Ok(new { });
}
[HttpPost("/rank_battle/add_client_log")]
[HttpPost("/rank_battle/add_all_client_log")]
[HttpPost("/rank_battle/add_last_turn_log")]
public IActionResult AddClientLog([FromBody] BaseRequest _)
{
if (!TryGetViewerId(out var _u)) return Unauthorized();
return Ok(new { });
}
[HttpPost("/rank_battle/get_latest_master_point")]
public IActionResult GetLatestMasterPoint([FromBody] BaseRequest _)
{
if (!TryGetViewerId(out var _u)) return Unauthorized();
return Ok(new { });
}
private async Task<IActionResult> DoMatchingInternal(string mode, Format format, DoMatchingRequestDto req, CancellationToken ct)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
MatchContext ctx;
try
{
ctx = await _ctxBuilder.BuildForRankBattleAsync(vid, format, req.DeckNo);
}
catch (InvalidOperationException ex)
{
// Most likely cause: viewer has no deck at that slot for this format. Surface
// as 3001 RC_BATTLE_MATCHING_ILLEGAL — the client shows the standard
// matchmaking-error dialog rather than retrying forever.
_log.LogWarning(ex, "BuildForRankBattleAsync failed for viewer {Vid} format {Fmt} deckNo {DeckNo}; returning 3001.", vid, format, req.DeckNo);
return Ok(new DoMatchingResponseDto { MatchingState = 3001, NodeServerUrl = "" });
}
var paired = await _pairUp.TryPairAsync(mode, new BattlePlayer(vid, ctx), ct);
if (paired is null)
{
// Parked. 3002 RETRY. node_server_url must be present as empty string —
// client's DoMatchingBase parser calls .ToString() without a guard.
return Ok(new DoMatchingResponseDto
{
MatchingState = 3002,
NodeServerUrl = "",
});
}
// Owner cache-pickup → 3007 (PvP) or 3011 (AI fallback).
// Joiner (only PvP) → 3004.
var state = paired switch
{
{ IsAiFallback: true } => 3011,
{ IsOwner: true } => 3007,
_ => 3004,
};
return Ok(new DoMatchingResponseDto
{
MatchingState = state,
BattleId = paired.Match.BattleId,
NodeServerUrl = paired.Match.NodeServerUrl,
// Placeholder per spec § Out of scope — per-battle card-master split is deferred.
CardMasterId = 0,
});
}
private async Task<IActionResult> AiStartInternal(Format format, CancellationToken ct)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
// The /ai_<fmt>/start request body is BaseRequest only — it carries no deck_no.
// The deck the viewer queued with was captured in the PendingBattle's MatchContext
// at /do_matching resolution time (when InProcessPairUp called bridge.RegisterBattle).
// Reuse that context so SelfInfo's classId/charaId/sleeveId match what the user
// actually picked. Rebuilding from deck #1 was the 2026-06-02 wire-bug — surfaced
// as "queued Bloodcraft, saw Swordcraft leader."
var pending = _sessionStore.TryFindPendingForViewer(vid);
if (pending is null)
{
_log.LogWarning("AiStart for viewer {Vid} format {Fmt} has no pending battle; returning ai_id=-1.", vid, format);
return Ok(new AiBattleStartResponseDto { AiId = -1 });
}
var selfCtx = pending.P1.Context;
var bot = await _botRoster.PickAsync(selfCtx, ct);
// Per spec, ai-start.md TODO: turnState semantics unclear. Default 0 (player first).
return Ok(new AiBattleStartResponseDto
{
AiId = bot.AiId,
TurnState = 0,
SelfInfo = new AiBattlePlayerInfo
{
CountryCode = selfCtx.CountryCode,
UserName = selfCtx.UserName,
SleeveId = int.TryParse(selfCtx.SleeveId, out var sId) ? sId : -1,
EmblemId = int.TryParse(selfCtx.EmblemId, out var eId) ? eId : -1,
DegreeId = int.TryParse(selfCtx.DegreeId, out var dId) ? dId : -1,
FieldId = selfCtx.FieldId,
IsOfficial = selfCtx.IsOfficial,
OppoId = bot.AiId,
Seed = 0,
Rank = 0,
BattlePoint = 0,
ClassId = int.TryParse(selfCtx.ClassId, out var cId) ? cId : -1,
CharaId = int.TryParse(selfCtx.CharaId, out var chId) ? chId : -1,
IsMasterRank = 0,
MasterPoint = 0,
},
OppoInfo = new AiBattlePlayerInfo
{
CountryCode = bot.CountryCode,
UserName = bot.UserName,
SleeveId = bot.SleeveId,
EmblemId = bot.EmblemId,
DegreeId = bot.DegreeId,
FieldId = bot.FieldId,
IsOfficial = bot.IsOfficial,
OppoId = (int)vid,
Seed = 0,
Rank = bot.Rank,
BattlePoint = bot.BattlePoint,
ClassId = bot.ClassId,
CharaId = bot.CharaId,
IsMasterRank = bot.IsMasterRank,
MasterPoint = bot.MasterPoint,
},
});
}
}

View File

@@ -5,6 +5,7 @@ using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Repositories.Collectibles;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Sleeve;
@@ -20,17 +21,13 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class SleeveController : SVSimController
{
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly ICurrencySpendService _spend;
private readonly IViewerEntitlements _entitlements;
private readonly IInventoryService _inv;
private readonly ICollectionRepository _collection;
public SleeveController(SVSimDbContext db, RewardGrantService rewards, ICurrencySpendService spend, IViewerEntitlements entitlements, ICollectionRepository collection)
public SleeveController(SVSimDbContext db, IInventoryService inv, ICollectionRepository collection)
{
_db = db;
_rewards = rewards;
_spend = spend;
_entitlements = entitlements;
_inv = inv;
_collection = collection;
}
@@ -42,12 +39,13 @@ public class SleeveController : SVSimController
// is_purchased_product is "viewer owns at least one sleeve granted by this product".
// Loading the viewer's sleeve-id set once and checking each product against it avoids
// an N+1 over products.
var ownedSleeveIds = _entitlements.IsFreeplay
? (await _collection.GetAllSleeveIds()).Select(id => (long)id).ToHashSet()
: (await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Sleeves.Select(s => (long)s.Id))
.ToListAsync()).ToHashSet();
var viewerForInfo = await _db.Viewers
.Include(v => v.Sleeves)
.FirstOrDefaultAsync(v => v.Id == viewerId);
if (viewerForInfo is null) return Unauthorized();
var cosmeticsForInfo = await _inv.EffectiveCosmeticsAsync(viewerForInfo);
var ownedSleeveIds = cosmeticsForInfo.SleeveIds.Select(id => (long)id).ToHashSet();
var series = await _db.SleeveShopSeries
.Where(s => s.IsEnabled)
@@ -113,18 +111,17 @@ public class SleeveController : SVSimController
if (product.SeriesId != request.SeriesId)
return BadRequest(new { error = "series_product_mismatch" });
var viewer = await LoadViewerGraphAsync(viewerId);
await using var tx = await _inv.BeginAsync(viewerId, HttpContext.RequestAborted);
if (_entitlements.IsFreeplay)
if (tx.IsFreeplay)
return BadRequest(new { error = "already_purchased" });
if (IsProductPurchased(product, viewer.Sleeves.Select(s => (long)s.Id).ToHashSet()))
if (IsProductPurchased(product, tx.Viewer.Sleeves.Select(s => (long)s.Id).ToHashSet()))
return BadRequest(new { error = "already_purchased" });
// Pricing: capture-confirmed shape is single-price-per-currency (no intro/regular tiers
// like BuildDeck). At least one of crystal/rupy must match the chosen sales_type;
// sales_type==0 means "free", which requires both prices == 0.
var rewardList = new List<RewardListEntry>();
switch (request.SalesType)
{
case 0: // free
@@ -134,39 +131,27 @@ public class SleeveController : SVSimController
case 1: // crystal
if (product.PriceCrystal is null)
return BadRequest(new { error = "price_not_available_for_currency" });
var crystalRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Crystal, product.PriceCrystal.Value);
if (!crystalRes.Success) return BadRequest(new { error = "insufficient_crystals" });
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)crystalRes.PostStateTotal });
{ var r = await tx.TrySpendAsync(SpendCurrency.Crystal, product.PriceCrystal.Value); if (!r.Success) return BadRequest(new { error = "insufficient_crystals" }); }
break;
case 2: // rupy
if (product.PriceRupy is null)
return BadRequest(new { error = "price_not_available_for_currency" });
var rupyRes = await _spend.TrySpendAsync(viewer, SpendCurrency.Rupee, product.PriceRupy.Value);
if (!rupyRes.Success) return BadRequest(new { error = "insufficient_rupees" });
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)rupyRes.PostStateTotal });
{ var r = await tx.TrySpendAsync(SpendCurrency.Rupee, product.PriceRupy.Value); if (!r.Success) return BadRequest(new { error = "insufficient_rupees" }); }
break;
}
// Grant each catalog reward through the central dispatcher — covers sleeve (6), emblem
// (7), and any future bundled grants. ApplyAsync returns post-state-aware reward entries
// suitable for emission as-is.
// Grant each catalog reward through the central dispatcher.
foreach (var r in product.Rewards.OrderBy(r => r.OrderIndex))
await tx.GrantAsync((UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
var result = await tx.CommitAsync(HttpContext.RequestAborted);
return new SleeveBuyResponse
{
var granted = await _rewards.ApplyAsync(viewer, (UserGoodsType)r.RewardType, r.RewardDetailId, r.RewardNumber);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
{
RewardType = g.RewardType,
RewardId = g.RewardId,
RewardNum = g.RewardNum,
});
}
}
await _db.SaveChangesAsync();
return new SleeveBuyResponse { RewardList = rewardList };
RewardList = result.RewardList
.Select(g => new RewardListEntry { RewardType = g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
.ToList(),
};
}
/// <summary>
@@ -185,14 +170,4 @@ public class SleeveController : SVSimController
return false;
}
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.Include(v => v.Cards).ThenInclude(c => c.Card)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}

View File

@@ -4,6 +4,7 @@ using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.Database.Services;
using SVSim.Database.Services.Inventory;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.SpotCardExchange;
@@ -14,8 +15,7 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /spot_card_exchange/* — trade spot points for individual cards from the rotating exchange
/// pool. Spot points are earned from battles/missions (not implemented here — earners live in
/// battle/mission finish reward emitters via <see cref="RewardGrantService"/> +
/// <see cref="UserGoodsType.SpotCardPoint"/>).
/// battle/mission finish reward emitters via <see cref="UserGoodsType.SpotCardPoint"/>).
/// </summary>
[Route("spot_card_exchange")]
public class SpotCardExchangeController : SVSimController
@@ -28,16 +28,14 @@ public class SpotCardExchangeController : SVSimController
private const int PreReleaseLimit = 2;
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
private readonly IInventoryService _inv;
private readonly TimeProvider _time;
private readonly ICurrencySpendService _spend;
public SpotCardExchangeController(SVSimDbContext db, RewardGrantService rewards, TimeProvider time, ICurrencySpendService spend)
public SpotCardExchangeController(SVSimDbContext db, IInventoryService inv, TimeProvider time)
{
_db = db;
_rewards = rewards;
_inv = inv;
_time = time;
_spend = spend;
}
[HttpPost("top")]
@@ -126,14 +124,14 @@ public class SpotCardExchangeController : SVSimController
return BadRequest(new { error = "pre_release_limit_reached" });
}
var viewer = await LoadViewerGraphAsync(viewerId);
await using var tx = await _inv.BeginAsync(viewerId);
var rewardList = new List<RewardListEntry>();
// Debit spot points. Client-supplied exchange_point isn't authoritative — server uses
// catalog price. Mirroring the build_deck/sleeve convention: post-state currency entry
// first, then grants.
var spotRes = await _spend.TrySpendAsync(viewer, SpendCurrency.SpotPoint, entry.ExchangePoint);
var spotRes = await tx.TrySpendAsync(SpendCurrency.SpotPoint, entry.ExchangePoint);
if (!spotRes.Success)
return BadRequest(new { error = "insufficient_spot_points" });
rewardList.Add(new RewardListEntry
@@ -143,8 +141,8 @@ public class SpotCardExchangeController : SVSimController
RewardNum = checked((int)spotRes.PostStateTotal),
});
// Grant the card itself via the existing card dispatcher (handles cosmetic cascade).
var granted = await _rewards.ApplyAsync(viewer, UserGoodsType.Card, entry.Id, 1);
// Grant the card itself via the inventory tx (handles cosmetic cascade).
var granted = await tx.GrantAsync(UserGoodsType.Card, entry.Id, 1);
foreach (var g in granted)
{
rewardList.Add(new RewardListEntry
@@ -163,7 +161,7 @@ public class SpotCardExchangeController : SVSimController
ExchangedAt = _time.GetUtcNow().UtcDateTime,
});
await _db.SaveChangesAsync();
await tx.CommitAsync();
return new SpotCardExchangeResponse { RewardList = rewardList };
}
@@ -182,14 +180,4 @@ public class SpotCardExchangeController : SVSimController
return 0;
}
private Task<Viewer> LoadViewerGraphAsync(long viewerId) => _db.Viewers
.Include(v => v.Cards).ThenInclude(c => c.Card)
.Include(v => v.Sleeves)
.Include(v => v.Emblems)
.Include(v => v.LeaderSkins)
.Include(v => v.Degrees)
.Include(v => v.MyPageBackgrounds)
.Include(v => v.Items).ThenInclude(i => i.Item)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
}

View File

@@ -0,0 +1,23 @@
namespace SVSim.EmulatedEntrypoint.Matching;
/// <summary>
/// Cosmetic + identity metadata for an AI opponent. Used to compose
/// <c>oppo_info</c> in the <c>/ai_&lt;fmt&gt;_rank_battle/start</c> response.
/// The wire keys are camelCase (sleeveId, emblemId, etc.) — the DTO handles
/// the JSON serialization; this record is the internal-facing shape.
/// </summary>
public sealed record AIBotProfile(
int AiId,
string CountryCode,
string UserName,
int SleeveId,
int EmblemId,
int DegreeId,
int FieldId,
int IsOfficial,
int ClassId,
int CharaId,
int Rank,
int BattlePoint,
int IsMasterRank,
int MasterPoint);

Some files were not shown because too many files have changed in this diff Show More