Compare commits

..

30 Commits

Author SHA1 Message Date
gamer147
22c01ed11a fix(viewer): fresh signups start with empty DisplayName
Verified against Wizard.Title/UserNameInput.cs:30 in the 2026-05-23
decompile:

    IsFinished = !string.IsNullOrEmpty(PlayerStaticData.UserName);

Any non-empty seeded value — including the prior " - " placeholder
this method was passing — sets IsFinished=true on the first frame and
silently skips both the input dialog and the /tutorial/update_action #1
+ /account/update_name calls that travel with it. The in-source comment
described the opposite behavior; empty string is what actually triggers
the dialog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:19:03 -04:00
gamer147
b18bb9502a fix(pack): /pack/info reads ItemId via shadow FK, not nav property
PackController.Info's ownedItemsByItemId projection used `i.Item.Id` to
key the dict — EF translates that to the FK column today, but any future
model change that breaks the nav→column mapping would fall back to client
eval and collapse every key to 0 (the default Item constructor's Id),
silently hiding every tutorial pack via item_number=0. EF.Property<int>
reads the shadow FK directly and is robust to nav changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:15:40 -04:00
gamer147
177b4925a1 fix(session): bound the SID→UDID dict with FIFO eviction
The map used to grow unbounded over the process's lifetime — every fresh
signup added an entry that was never reclaimed. Long-running dev hosts
(or any future emulator deployment that doesn't restart often) would
gradually leak memory. Cap at 10k entries by default with a simple FIFO
eviction queue; re-stores of the same SID don't grow the queue.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:13:47 -04:00
gamer147
91412ff821 fix(account): /account/update_name validates name input
Reject empty / whitespace / explicit-null / over-cap names with 400
instead of NREing on null assignment or storing arbitrarily-long
strings the DB column has no cap on. 24-char limit is a conservative
backstop against direct API abuse; the client UI enforces its own
keyboard limit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:11:27 -04:00
gamer147
d13082a8ca fix(gift): receive response is idempotent and echoes persisted state
Three coupled correctness fixes to /tutorial/gift_receive's response:

- received_ids / total_receive_count_list / reward_list are now built
  from `toClaim` (the gifts THIS call granted), not from `requestedIds`.
  Echoing the client's request meant idempotent re-claims re-fired the
  "+N received" popup and direct-assigned the same post-state totals
  again, breaking the documented idempotency contract.
- is_unreceived_present is now `unclaimedPresents.Count > 0`. The
  hardcoded false hid the inbox badge after partial claims even when
  present_list still carried unclaimed gifts.
- tutorial_step echoes the persisted (max-preserved) state instead of
  a hardcoded 41. A replay against a state>=41 viewer used to surface
  41 on the wire and regress the client's tutorial state machine.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:10:06 -04:00
gamer147
86759125a9 fix(gift): tutorial gift_receive ThenIncludes OwnedItemEntry.Item
Same project_ef_nav_include_pitfall as 27ebb51's tutorial pack_open fix
but in the gift path: without .ThenInclude(i => i.Item), the existing
OwnedItemEntry's Item nav defaults to a new ItemEntry() (Id=0), so
RewardGrantService.ApplyAsync's `FirstOrDefault(i => i.Item.Id == detailId)`
misses pre-existing rows. It falls through to add a new entry, and the
(ViewerId, ItemId) unique index added 2026-05-25 throws on SaveChanges →
500 to the client, no tutorial advancement, no currency grant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:08:19 -04:00
gamer147
82d9668c9b fix(pack): /tutorial/pack_open restricted to starter pack + pre-END viewer
The tutorial alias bypassed the currency / type_detail / open-count guards
unconditionally. Combined with the unconditional TutorialState=100 write, any
authenticated viewer could send /tutorial/pack_open with any parent_gacha_id
to draw a pack for free and clobber their state down to 100.

Two gates: parent_gacha_id MUST be 99047 (the legendary starter), and the
viewer's TutorialState MUST be below 100. The state write is also max-preserved
as a belt-and-braces backstop. Mirrors the 31→41 guard in GiftController.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:31:23 -04:00
gamer147
6fd8705990 fix(tutorial): /tutorial/update preserves max TutorialState
The endpoint used to write the client-supplied step verbatim, so a stale or
replayed request with tutorial_step=0 against any later-stage viewer would
regress the persisted state to 0. NextSceneSwitcher routes step==0 to
AreaSelect section 0, which has no chapter data — the client LINQ-Single()
crashes on next /load/index, bricking the viewer. Math.Max-preserve matches
the 31→41 pattern in GiftController.TutorialGiftReceive.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:22:59 -04:00
gamer147
ac077dfc13 fix(pack): tutorial pack_open ThenIncludes OwnedItemEntry.Item
Without .ThenInclude(i => i.Item), the OwnedItemEntry.Item nav defaults to a
new ItemEntry() with Id=0 (project_ef_nav_include_pitfall), so the
FirstOrDefault(i => i.Item.Id == ticketItemId) lookup never matched. The
ticket was never decremented and reward_list omitted the post-state entry —
on the next /tutorial/pack_info the pack stayed visible and the client
re-clicked into plain /pack/open, which 501s on type_detail=5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:15:31 -04:00
gamer147
b50a884af9 fix(viewer): RegisterViewer defaults to post-tutorial TutorialState=100
BuildDefaultViewer hardcoded TutorialState=1 — correct for fresh anonymous
signups (RegisterAnonymousViewer) but wrong for AdminController.ImportViewer
and Steam-social signups, which both go through RegisterViewer and expect a
prod-replica viewer that boots to the home screen. Add an initialTutorialState
parameter (default 1 preserves RegisterAnonymousViewer behavior); RegisterViewer
passes 100.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:07:05 -04:00
gamer147
c2c6a95170 @
fix(tests): SeedViewerAsync tutorialState param is no longer sentinel-overloaded

The previous `if (tutorialState != 0)` block silently dropped overrides for state 0,
so `SeedViewerAsync(tutorialState: 0)` returned whatever BuildDefaultViewer set
(state 1), not state 0. Tests that wanted a fresh-signup viewer were getting one
by accident, and the stale comment claimed the default was 0. Always override.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@
2026-05-28 19:56:06 -04:00
gamer147
ad5c9e91ae feat(download_time): stub start/end endpoints
Spec at docs/api-spec/endpoints/post-login/download_time-{start,end}.md
already documented both endpoints fully against the decompiled
Wizard/DownloadStartTask.cs and Wizard/DownloadFinishTask.cs — the
controller side was the gap.

The client fires /download_time/start before kicking off an Akamai
asset bundle download and /download_time/end on completion. Both are
pure telemetry from our perspective. When NukeIdentityOnStartup wipes
PlayerPrefs broadly (the pre-narrow loader behaviour), the client
decides it needs to download tutorial assets, calls /download_time/start,
and a 404 there surfaces as an HTTP error popup before the download
proceeds. Stubbing with empty data:{} bodies plus result_code:1 is the
documented minimum-viable response.

Acts as belt-and-suspenders against the narrow IdentityWipe (which
preserves the cache index so downloads shouldn't trigger) ever being
bypassed by a different code path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:04:28 -04:00
gamer147
27ebb5114c fix(pack): tutorial flow display + open end-to-end
Four targeted fixes that together let /tutorial/pack_info display
the legendary starter at index 0, let /tutorial/pack_open succeed
on it, and let the pack drop out of the shop after.

1. /pack/info now loads viewer.Items into a Dictionary<long,int>
   and threads it through ToDto so child_gacha_info.item_number
   reflects the viewer's actual owned count of item_id. Previously
   defaulted to 0 for every pack, so the legendary pack 99047
   reported item_number=0 immediately after the gift granted 1×
   ticket id=90001. Verified against the prod tutorial capture.

2. PackRepository.GetActivePacks now orders parent_gacha_id DESC
   to match prod's /pack/info wire order (99047, 92001, 80047,
   16015...10001). The tutorial pack UI runs with controls locked
   and auto-selects index 0 via GachaUI.GetCurrentLegendPackId
   (FirstOrDefault on IsLegendPackId), so the legendary starter
   needs to be the first legend pack in the list.

3. DbCardPoolProvider.GetPool falls back to all in-rotation cards
   when a LegendCardPack's base set has no rows. Pack 99047's
   base_pack_id is 90001, a synthetic "Throwback Rotation" category
   that doesn't correspond to a real card_set in the prod card
   master — its real pool is curated across older rotation sets
   (Altersphere through Colosseum). We don't have that membership
   map captured yet; the rotation fallback is broader than prod
   but produces a valid 8-card draw, which is what the tutorial
   needs to advance to step 100. TODO in code points at the
   real fix.

4. PackController.Open's tutorial path now consumes the granted
   ticket (decrement viewer.Items by packNumber for child.ItemId)
   and emits the post-state count in reward_list as
   {reward_type:4, reward_id:item_id, reward_num:post_count}.
   Without this, the pack stayed at item_number=1 forever, the
   shop kept showing it post-tutorial, and the next click hit
   /pack/open (not /tutorial/pack_open) which 501s on type_detail=5.

Also: docstring on PackConfigDto.SalesPeriodInfo flags the deferred
wire-fidelity fix (prod emits {"sales_period_time": "<complete_date>"}
for limited windows, [] for evergreens; we always emit {}) and the
retype from Dictionary<string,string?> to a typed
PackSalesPeriodInfoDto. Doesn't affect tutorial flow, deferred for
the pack-system rework.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:04:13 -04:00
gamer147
b5e33c15f6 fix(mypage): populate user_item_list from viewer.Items
MyPageTask.Parse (Wizard/MyPageTask.cs:155-163) does
`_userItemDict.Clear();` the moment `user_item_list` is present in
the response body — not when it's non-empty — then re-populates
from the wire. Our /mypage/index was emitting [] by default (the
field initializer on the DTO), which wiped the inventory that
/load/index had just populated.

Downstream consequence: the client's PackChildGachaInfo.CostGoodsCount
reads from _userItemDict, so a wiped dict makes every ticket-cost
pack report CostGoodsCount=0, PackConfig.EnableBuyPack returns false,
and is_hide=1 packs (including the tutorial legendary starter 99047)
disappear from the rotation pack list — even though the gift bundle
just granted the ticket and the DB row exists. The tutorial then
auto-selects whatever non-tutorial pack happens to be at index 0 of
the filtered list, the user can't afford it, and the flow is stuck.

Fix:

- MyPageController.Index now sets UserItemList from viewer.Items
  (already loaded by GetViewerByShortUdid's home-screen graph).
- DTO docstring rewritten to call out the presence-sensitive semantics
  and the load-bearing path through PackConfig.EnableBuyPack, so the
  next developer doesn't get the "empty is fine" hint the old comment
  implied.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:03:50 -04:00
gamer147
d3ef76324f fix(load/index): UserInfo dates as nullable yyyy-MM-dd HH:mm:ss strings
LastPlayTime and MissionChangeTime were typed as DateTime, which STJ
serialised as "0001-01-01T00:00:00.0000000Z" for a fresh viewer
(DateTime.MinValue). Prod's wire shape is "yyyy-MM-dd HH:mm:ss"
(no T, no Z, no fractional seconds) when present and null when
absent — verified against data_dumps/traffic_prod_tutorial.ndjson.

The .NET default format has a real chance of crashing the client's
DateTime.Parse path on any code that reads either field, and the
fields are presence-sensitive (NetworkTask-family Keys.Contains
followed by ToDateTime), so emitting the .NET default reaches the
client as a stale-but-present value.

Switching the properties to string? + FormatProdDateTime helper:
- non-default DateTime -> "yyyy-MM-dd HH:mm:ss"
- DateTime.MinValue -> null (omitted from wire via global
  WhenWritingNull policy in Program.cs)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:03:32 -04:00
gamer147
f4f2ec380c feat(envelope): push required_res_ver from ResourceConfig on game_start
A wiped/fresh client (NukeIdentityOnStartup, new install, or any path
that clears PlayerPrefs) defaults its stored RES_VER to "00000000"
per Cute/SavedataManager.GetResourceVersion. The client builds the
Akamai manifest URL as dl/Manifest/<RES_VER>/<lang>/<Platform>/, and
Akamai 404s the "00000000" path -> Toolbox.AssetManager.InitializeManifest
fails -> the title screen shows "Connection Error / Reconnect"
before any tutorial UI loads.

Fix:

- New ResourceConfig [ConfigSection] in SVSim.Database — single
  field RequiredResVer defaulting to "4670rPsPMVlRTd2" (the value
  prod returned in data_dumps/traffic_prod_tutorial.ndjson and was
  still returning at 2026-05-28 21:00 UTC). Lives in GameConfigs so
  it can be tuned via DB / appsettings without code edits.

- ShadowverseTranslationMiddleware injects IGameConfigService and
  emits required_res_ver in data_headers ONLY on /check/game_start
  responses. NetworkTask.Parse opens a "new data is available" popup
  whenever required_res_ver is present and the URL is anything other
  than GameStartCheck (NetworkTask.cs:128-138); the suppression on
  game_start is what lets us silently bump PlayerPrefs["RES_VER"]
  before ResourceDownloader runs.

- DataHeaders gains a nullable RequiredResVer field. DataWrapper.DataHeaders
  is now Dictionary<string, object?> instead of the typed DataHeaders POCO
  directly — the construction site stays type-safe (the middleware builds
  the typed POCO, then projects through the same STJ +
  ConvertJsonTreeToPlainObject pipeline that DataWrapper.Data uses) so
  null-valued optional fields are absent from the wire instead of being
  written as "key":null. Without this, MessagePack's ContractlessStandardResolver
  walked the typed properties and wrote required_res_ver=null on every
  non-game_start response, tripping the popup on every boot.

- GameConfigurationJsonbTests updated to expect the 9th config section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:03:15 -04:00
gamer147
1af0e03eeb fix(viewer): fresh signup defaults match prod tutorial capture
Three corrections to BuildDefaultViewer + RegisterAnonymousViewer
verified against data_dumps/traffic_prod_tutorial.ndjson:

- TutorialState now 1 (TUTORIAL_STEP0), not 0 (PRE_TUTORIAL_STEP).
  Wizard.Title.NextSceneSwitcher routes step==1 to the Prologue scene;
  any other non-{31,41,100} step routes to AreaSelect at section 0,
  which has no chapter data and crashes the client with a LINQ
  Single() "Sequence contains no matching element" from
  AreaSelectUI.SelectChapter. Prod's first /check/game_start returns
  now_tutorial_step="1"; step 0 is a pre-existence state we never
  want to expose on the wire.

- DisplayName " - " (literal space-dash-space), not "Player".
  Wizard.Title.UserNameInput.Start short-circuits with
  IsFinished=true on !string.IsNullOrEmpty(PlayerStaticData.UserName),
  silently skipping the name dialog AND the tutorial sub-step it
  drives (/tutorial/update_action #1 + /account/update_name). Prod
  uses " - " as the unset placeholder.

- viewer.Info.SelectedEmblem/SelectedDegree assigned from the default
  emblem/degree the loadout grants. Without this, /load/index emits
  selected_emblem_id=0 and selected_degree_id=0 for a fresh viewer
  that owns those cosmetics — prod sends the real granted IDs.

Also surfaces the actual Postgres constraint name in the unique-
violation re-raise (ExtractConstraintName), instead of always
saying "UDID" — the original message was misleading whenever the
real constraint was on owned-collection rows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:02:52 -04:00
gamer147
6e6c8ee779 fix(signup): prestore client SID→UDID mapping so game_start can decrypt
After /tool/signup the client switches to SID-only headers (no UDID), so
the next request's body can't be decrypted unless the server already
knows the SID's UDID. ShadowverseSessionService now mirrors the client's
Cute/Cryptographer.MakeMd5(viewerId + udid) formula (salt
"r!I@ws8e5i="), and ToolController.Signup prestores the mapping at the
end. Verified against a live signup capture: viewerId=1 +
udid=62747917-93bc-454c-abb4-ef423b3c9317 produces the captured SID
dc4aac79d35fe15dfb6262e0071bb03c.

Note: this only fixes the fresh-signup path. Clients restarting with a
cached viewer_id (which skip /tool/signup entirely) still hit the same
issue — separate follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:34:05 -04:00
gamer147
190b50cbaf fix(tutorial): gift_receive reward_list carries post-state totals, not deltas
The client's PlayerStaticData.UpdateHaveUserGoodsNumByJsonData does direct
assignment on each reward_list entry's reward_num, so currency/item totals
must be the new viewer balance — not the gift delta. Fresh accounts were
seeing their cached crystal/rupy balances clobbered down to the gift counts
until the next /load/index. Matches the project_wire_reward_list_post_state
memory and the prod capture (which shows 120 rupy = baseline 20 + gift 100).
2026-05-28 13:16:51 -04:00
gamer147
5d8a6626bb test(tutorial): add end-to-end capture-replay smoke
Adds TutorialFlowEndToEndTests covering the full fresh-signup flow:
account/update_name → tutorial/update (11→21→31) → update_action →
gift_top → gift_receive (verifies 400 crystal + 100 rupy delta and
31→41 state advance) → pack_info → pack_open (verifies tutorial_step=100
and viewer state END). Includes the set-90001 card-pool seed required by
the tutorial pack resolver (mirrors PackControllerTests pattern).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:00:18 -04:00
gamer147
6819e65160 feat(tutorial): /tutorial/pack_open emits tutorial_step=100, advances viewer state
Stack [HttpPost("/tutorial/pack_open")] alias on PackController.Open. Detect
isTutorialPath via HttpContext.Request.Path; gate the type_detail rejection,
currency switch, open-count tracking, and currency reward_list entries behind
!isTutorialPath so the starter legendary pack (99047/990047, type_detail=5)
bypasses the purchasable-pack code path. After grant, set MissionData.TutorialState=100
and emit tutorial_step=100 in PackOpenResponse — this is the sole END transition,
per live-traffic capture. Add pack 99047 to test-fixture packs.json.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:55:08 -04:00
gamer147
ca678b56d1 feat(tutorial): alias /tutorial/pack_info to /pack/info
Stacks a second [HttpPost("/tutorial/pack_info")] absolute route on
PackController.Info so the tutorial flow resolves to the same action
and returns the same pack_config_list as /pack/info (no filtering in v1).
Adds PackControllerTests.cs with TutorialPackInfo_returns_same_list_as_pack_info
verifying byte-identical responses from both URLs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:27:35 -04:00
gamer147
f6f9216162 feat(tutorial): add /tutorial/gift_receive — grant + receipt + idempotent re-claim
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:22:37 -04:00
gamer147
2034034c1b feat(tutorial): add /tutorial/gift_top with hardcoded starter present list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 12:02:54 -04:00
gamer147
0f6b3f231a feat(account): add /account/update_name endpoint
Implements POST /account/update_name — writes Viewer.DisplayName and
returns an empty array per the prod capture. Includes TDD test covering
the persist side-effect.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:56:59 -04:00
gamer147
bc9ffe1d31 feat(tutorial): add /tutorial/update — echo step + persist to viewer
POST /tutorial/update echoes tutorial_step back and saves it to
Viewer.MissionData.TutorialState. is_skip=1 is handled server-side
by honoring whatever tutorial_step value the client sends (client
already sends 100 when skipping). Adds TutorialUpdateRequest DTO,
TutorialUpdateResponse DTO, injects SVSimDbContext into
TutorialController, and adds GetViewerTutorialStateAsync helper to
SVSimTestFactory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:47:09 -04:00
gamer147
703f7ff3d7 feat(tutorial): add /tutorial/update_action fire-and-forget endpoint
Returns an empty data object (result_code=1 from middleware envelope).
Client uses SkipAllNetworkChecks so the response body is never read.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 11:41:11 -04:00
gamer147
f233a8c8d6 fix(viewer): fresh signups start at tutorial_state=0, not 100
Previously RegisterAnonymousViewer auto-completed the tutorial, which
prevented the client from ever entering the tutorial flow. SeedViewerAsync
gains a tutorialState parameter (default 100) so existing tests keep
their pre-completed-tutorial assumption.
2026-05-28 11:27:37 -04:00
gamer147
36dd25826b fix(deck-builder): wire key is cardID/phantomCardID, not snake_case
Client's LitJson serializer emits the C# property name verbatim — the
SetParameter param classes in Wizard/GenerateDeckCodeTask.cs use cardID /
phantomCardID, and the matching Parse() reads jsonData["cardID"]. Snake-case
keys bound to empty in msgpack deserialize, the controller saw 0 cards, and
returned INVALID_DECK — surfaced as a blank deck code in the in-game UI.

Repro lived in data_dumps/traffic.ndjson #19-20. Existing tests pass through
the same JsonPropertyName on both serialize and deserialize, so they happily
round-tripped any consistent key — adding a wire-shape regression test that
posts the literal client JSON would be the right way to catch this class of
bug in the future (out of scope here).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:25:17 -04:00
gamer147
5aac24d2b9 feat(deck-builder): /deck_code mint + /deck resolve with 3-min in-memory TTL
Adds the portal pair (shadowverse-portal.com deck-builder endpoints) as
anonymous routes on the app server. The translation middleware learns a new
[NoWireEncryption] attribute that skips both AES calls but keeps the rest of
the msgpack + base64 + envelope pipeline intact, matching prod's portal wire
profile observed in data_dumps/traffic_prod_deckcode.ndjson.

Storage is a 3-minute IMemoryCache — codes are anonymous-global, 4-char
lowercase alphanumeric (matches the shortest prod sample). Foil bit is
stripped on mint to match prod's normalize-on-encode behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:11:21 -04:00
56 changed files with 6642 additions and 114 deletions

View File

@@ -61,6 +61,46 @@
],
"banners": []
},
{
"parent_gacha_id": 99047,
"base_pack_id": 90001,
"gacha_type": 1,
"pack_category": 1,
"poster_type": 0,
"commence_date": "2026-05-01 02:00:00",
"complete_date": "2030-12-31 23:59:59",
"sleeve_id": 5090001,
"special_sleeve_id": 0,
"override_draw_effect_pack_id": 90001,
"override_ui_effect_pack_id": 90001,
"gacha_detail": "A pack contains 8 cards, including at least one legendary card from Throwback Rotation (Altersphere - Colosseum)!",
"is_hide": true,
"is_new": false,
"is_pre_release": false,
"open_count_limit": 0,
"sales_period_time": null,
"gacha_point": null,
"child_gachas": [
{
"gacha_id": 990047,
"type_detail": 5,
"cost": 1,
"card_count": 8,
"item_id": 90001,
"is_daily_single": false,
"override_increase_gacha_point": 0,
"purchase_limit_count": 0,
"free_gacha_campaign_id": null,
"campaign_name": null
}
],
"banners": [
{
"banner_name": "card_pack_99047_dialog",
"dialog_title": "Dia_BuyCard_006_Title"
}
]
},
{
"parent_gacha_id": 92001,
"base_pack_id": 90001,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace SVSim.Database.Migrations
{
/// <inheritdoc />
public partial class AddViewerClaimedTutorialGift : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ViewerClaimedTutorialGifts",
columns: table => new
{
ViewerId = table.Column<long>(type: "bigint", nullable: false),
PresentId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
ClaimedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ViewerClaimedTutorialGifts", x => new { x.ViewerId, x.PresentId });
table.ForeignKey(
name: "FK_ViewerClaimedTutorialGifts_Viewers_ViewerId",
column: x => x.ViewerId,
principalTable: "Viewers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ViewerClaimedTutorialGifts");
}
}
}

View File

@@ -2407,6 +2407,23 @@ namespace SVSim.Database.Migrations
b.ToTable("ViewerBattlePassProgress");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerClaimedTutorialGift", b =>
{
b.Property<long>("ViewerId")
.HasColumnType("bigint");
b.Property<string>("PresentId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("ClaimedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("ViewerId", "PresentId");
b.ToTable("ViewerClaimedTutorialGifts");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerEventCounter", b =>
{
b.Property<long>("ViewerId")
@@ -3598,6 +3615,17 @@ namespace SVSim.Database.Migrations
.IsRequired();
});
modelBuilder.Entity("SVSim.Database.Models.ViewerClaimedTutorialGift", b =>
{
b.HasOne("SVSim.Database.Models.Viewer", "Viewer")
.WithMany()
.HasForeignKey("ViewerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Viewer");
});
modelBuilder.Entity("SVSim.Database.Models.ViewerEventCounter", b =>
{
b.HasOne("SVSim.Database.Models.Viewer", null)

View File

@@ -0,0 +1,37 @@
namespace SVSim.Database.Models.Config;
/// <summary>
/// Asset-delivery tunables: where the client looks for the resource CDN (Akamai by default;
/// <c>Wizard/SetUp.cs:48</c> hardcodes <c>shadowverse.akamaized.net/</c>) and what manifest
/// version to ask for. Currently a single field, will grow as we self-host content.
/// </summary>
[ConfigSection("ResourceConfig")]
public class ResourceConfig
{
/// <summary>
/// Pushed to the client as <c>data_headers.required_res_ver</c>. The client writes it to
/// <c>PlayerPrefs["RES_VER"]</c> and uses it as the version path component for asset
/// manifest lookups: <c>https://&lt;cdn&gt;/dl/Manifest/&lt;RES_VER&gt;/&lt;lang&gt;/&lt;Platform&gt;/</c>.
/// <para>
/// Default value is the prod-captured version from <c>data_dumps/traffic_prod_tutorial.ndjson</c>
/// (2026-05-28) — i.e., a path Akamai actually serves. When this rotates (or Akamai sunsets
/// ahead of June 2026), update via DB <c>GameConfigs</c> row, appsettings.json, or this
/// shipped default; no code change needed.
/// </para>
/// <para>
/// When the client has no cached <c>RES_VER</c> (e.g., a wiped/fresh install via
/// <c>NukeIdentityOnStartup</c>), it defaults to <c>"00000000"</c>, which Akamai 404s. The
/// fetch failure surfaces as "Connection Error / Reconnect" before any tutorial UI loads,
/// so emitting a valid value here is required for fresh-account boot.
/// </para>
/// </summary>
public string RequiredResVer { get; set; } = "4670rPsPMVlRTd2";
/// <summary>
/// Inline-default tier for <see cref="IGameConfigService"/>. Mirrors property initialisers
/// — kept as a separate factory because the framework requires every [ConfigSection] POCO to
/// expose one (see <c>feedback_config_defaults</c> memory for the collection-defaults rule
/// that motivated the convention).
/// </summary>
public static ResourceConfig ShippedDefaults() => new();
}

View File

@@ -0,0 +1,14 @@
namespace SVSim.Database.Models;
/// <summary>
/// Records that a viewer has claimed a specific tutorial gift present_id. Composite key
/// (ViewerId, PresentId) — viewer can't claim the same present twice.
/// </summary>
public class ViewerClaimedTutorialGift
{
public long ViewerId { get; set; }
public string PresentId { get; set; } = string.Empty;
public DateTime ClaimedAt { get; set; }
public Viewer Viewer { get; set; } = null!;
}

View File

@@ -13,6 +13,12 @@ public class PackRepository : IPackRepository
.Include(p => p.ChildGachas)
.Include(p => p.Banners)
.Where(p => p.CommenceDate <= now && p.CompleteDate >= now)
// parent_gacha_id DESC matches the prod /pack/info wire order. The tutorial pack
// UI runs with controls locked and auto-selects the FIRST entry in
// pack_config_list, so the legendary starter pack (99047) MUST be index 0 for the
// tutorial to progress. Verified against data_dumps/traffic_prod_tutorial.ndjson —
// prod emits [99047, 92001, 80047, 16015..16011, 10032..10001].
.OrderByDescending(p => p.Id)
.ToListAsync();
public async Task<PackConfigEntry?> GetPack(int parentGachaId) =>

View File

@@ -72,7 +72,11 @@ public class ViewerRepository : IViewerRepository
public async Task<Models.Viewer> RegisterViewer(string displayName, SocialAccountType socialType,
ulong socialAccountIdentifier, ulong? shortUdid = null)
{
var viewer = await BuildDefaultViewer(displayName);
// RegisterViewer is the import / Steam-social path. Default to the post-tutorial baseline
// (state 100) so AdminController.ImportViewer materializes prod-replicas at the home screen
// unless the import request explicitly overrides via request.TutorialState. The anonymous
// signup path (RegisterAnonymousViewer) uses the parameter default of 1.
var viewer = await BuildDefaultViewer(displayName, initialTutorialState: 100);
viewer.SocialAccountConnections.Add(new SocialAccountConnection
{
AccountId = socialAccountIdentifier,
@@ -96,7 +100,14 @@ public class ViewerRepository : IViewerRepository
if (udid == Guid.Empty)
throw new InvalidOperationException("Cannot register viewer for empty UDID.");
var viewer = await BuildDefaultViewer("Player");
// Empty DisplayName is load-bearing: the client's Wizard.Title/UserNameInput.Start
// does `IsFinished = !string.IsNullOrEmpty(PlayerStaticData.UserName);` — IsFinished
// true skips the dialog AND the /tutorial/update_action #1 + /account/update_name
// calls that accompany it. Any non-empty value (including the " - " placeholder this
// method used to pass) trips that check and silently bypasses the name-entry sub-step.
// Empty string flows through /load/index → user_info.name → PlayerStaticData.UserName,
// and the title screen surfaces the input dialog.
var viewer = await BuildDefaultViewer("");
viewer.Udid = udid;
_dbContext.Set<Models.Viewer>().Add(viewer);
try
@@ -114,15 +125,41 @@ public class ViewerRepository : IViewerRepository
// SqliteErrorCode 19 (SQLITE_CONSTRAINT). Matched by type-name to avoid pulling a
// Sqlite package dep into SVSim.Database.
_dbContext.Entry(viewer).State = EntityState.Detached;
var existing = await GetViewerByUdid(udid)
?? throw new InvalidOperationException(
$"Got unique-violation on Udid={udid} insert but subsequent lookup found no row. " +
"This shouldn't happen — likely transaction isolation issue.");
return existing;
var existing = await GetViewerByUdid(udid);
if (existing is not null) return existing;
// Lookup-by-UDID missed → the violation wasn't on the UDID index. Pull the constraint
// name out of the inner exception so the caller can see which constraint actually
// blocked the insert (Steam social uniqueness, owned-collection uniqueness, etc.).
string constraintName = ExtractConstraintName(ex);
throw new InvalidOperationException(
$"Got unique-violation on viewer insert for Udid={udid} but the UDID is not in the table. " +
$"The violated constraint was '{constraintName}'. " +
"Original exception preserved as InnerException.",
ex);
}
return viewer;
}
/// <summary>
/// Extracts the violated constraint name from a wrapped backend exception, when available.
/// Postgres surfaces this as <c>PostgresException.ConstraintName</c>. Returns "&lt;unknown&gt;"
/// for other backends or when the name can't be reflected out.
/// </summary>
private static string ExtractConstraintName(DbUpdateException ex)
{
if (ex.InnerException is Npgsql.PostgresException pgEx && !string.IsNullOrEmpty(pgEx.ConstraintName))
{
return pgEx.ConstraintName;
}
// SQLite doesn't expose a constraint name in a structured field — fall back to the message.
if (ex.InnerException is { } inner && inner.GetType().FullName == "Microsoft.Data.Sqlite.SqliteException")
{
return inner.Message;
}
return "<unknown>";
}
/// <summary>
/// Returns true if the given <see cref="DbUpdateException"/> wraps a backend-level unique-
/// constraint violation. Postgres → SqlState "23505"; SQLite → SqliteErrorCode 19.
@@ -164,7 +201,7 @@ public class ViewerRepository : IViewerRepository
await _dbContext.SaveChangesAsync();
}
private async Task<Models.Viewer> BuildDefaultViewer(string displayName)
private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1)
{
Models.Viewer viewer = new Models.Viewer
{
@@ -180,7 +217,11 @@ public class ViewerRepository : IViewerRepository
viewer.Currency.Crystals = grants.Crystals;
viewer.Currency.Rupees = grants.Rupees;
viewer.Currency.RedEther = grants.Ether;
viewer.MissionData.TutorialState = 100; // finishes tutorial for now
// TUTORIAL_STEP0 (= 1) is the fresh-signup default — see RegisterAnonymousViewer for
// why step==0 is unsafe. RegisterViewer (admin-import + Steam-social) passes 100 so
// those callers land at the post-tutorial baseline; import requests can still override
// via the explicit ImportViewerRequest.TutorialState field.
viewer.MissionData.TutorialState = initialTutorialState;
// Load classes WITH their LeaderSkins — DefaultLeaderSkin iterates the nav collection
// and would otherwise be null (audit §6 #3 latent NRE — this is the one).
@@ -209,8 +250,16 @@ public class ViewerRepository : IViewerRepository
var defaultEmblem = await _dbContext.Set<EmblemEntry>().FindAsync(defaultEmblemId);
var defaultBg = await _dbContext.Set<MyPageBackgroundEntry>().FindAsync(defaultBgId);
if (defaultSleeve is not null) viewer.Sleeves.Add(defaultSleeve);
if (defaultDegree is not null) viewer.Degrees.Add(defaultDegree);
if (defaultEmblem is not null) viewer.Emblems.Add(defaultEmblem);
if (defaultDegree is not null)
{
viewer.Degrees.Add(defaultDegree);
viewer.Info.SelectedDegree = defaultDegree;
}
if (defaultEmblem is not null)
{
viewer.Emblems.Add(defaultEmblem);
viewer.Info.SelectedEmblem = defaultEmblem;
}
if (defaultBg is not null) viewer.MyPageBackgrounds.Add(defaultBg);
// Grant one of each class's default leader skin. Filter out the synthetic placeholders

View File

@@ -95,6 +95,8 @@ public class SVSimDbContext : DbContext
public DbSet<ViewerStoryProgress> ViewerStoryProgress => Set<ViewerStoryProgress>();
public DbSet<ViewerStoryBranchUnlock> ViewerStoryBranchUnlocks => Set<ViewerStoryBranchUnlock>();
public DbSet<ViewerClaimedTutorialGift> ViewerClaimedTutorialGifts => Set<ViewerClaimedTutorialGift>();
#endregion
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
@@ -336,6 +338,13 @@ public class SVSimDbContext : DbContext
b.HasIndex(e => new { e.ViewerId, e.Period });
});
modelBuilder.Entity<ViewerClaimedTutorialGift>(b =>
{
b.HasKey(g => new { g.ViewerId, g.PresentId });
b.HasOne(g => g.Viewer).WithMany().HasForeignKey(g => g.ViewerId).OnDelete(DeleteBehavior.Cascade);
b.Property(g => g.PresentId).HasMaxLength(64);
});
base.OnModelCreating(modelBuilder);
}

View File

@@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Account;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /account/* — viewer profile mutations that aren't tied to a specific subsystem.
/// </summary>
public class AccountController : SVSimController
{
/// <summary>
/// Conservative server-side cap on viewer display names. The client's UserNameInput
/// enforces its own limit at the keyboard; this is the backstop against direct API
/// abuse (10-MB names ballooning every subsequent /load/index, etc.). Names are
/// typically &lt;=20 chars in prod traffic.
/// </summary>
private const int MaxDisplayNameLength = 24;
private readonly SVSimDbContext _db;
public AccountController(SVSimDbContext db)
{
_db = db;
}
[HttpPost("update_name")]
public async Task<IActionResult> UpdateName([FromBody] AccountUpdateNameRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
// Defensive null check: the DTO defaults to string.Empty but a JSON body with
// an explicit `"name": null` deserialises through msgpack→JSON→STJ to null, and
// assigning null to viewer.DisplayName (non-nullable in the entity) would NRE.
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new { error = "name_empty" });
if (request.Name.Length > MaxDisplayNameLength)
return BadRequest(new { error = "name_too_long" });
var viewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId);
viewer.DisplayName = request.Name;
await _db.SaveChangesAsync();
// Prod returns `data: []` — empty array, not empty object. Use an empty array literal
// so the translation middleware emits the right msgpack shape.
return Ok(Array.Empty<object>());
}
}

View File

@@ -0,0 +1,87 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.DeckBuilder;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// Portal endpoints — deck-code mint (<c>/deck_code</c>) and resolve (<c>/deck</c>). In prod
/// these live on shadowverse-portal.com which speaks plaintext msgpack (no AES); the loader
/// redirects them to this app server via a Harmony prefix on
/// <c>CustomPreference.GetDeckBuilderServerURL</c>. The <see cref="NoWireEncryptionAttribute"/>
/// tells the translation middleware to skip the AES wrapper for both directions.
///
/// Deliberately does not extend <see cref="SVSimController"/>: portal traffic is anonymous and
/// the routes need to live at the bare paths (<c>/deck_code</c>, <c>/deck</c>) rather than
/// under a <c>/deckbuilder/...</c> template.
/// </summary>
[ApiController]
[AllowAnonymous]
[NoWireEncryption]
public class DeckBuilderController : ControllerBase
{
private readonly IDeckCodeService _codes;
public DeckBuilderController(IDeckCodeService codes)
{
_codes = codes;
}
[HttpPost("deck_code")]
public ActionResult<GenerateDeckCodeResponse> Generate(GenerateDeckCodeRequest req)
{
if (req.CardID is null || req.CardID.Count == 0)
{
return new GenerateDeckCodeResponse
{
Text = "INVALID",
Errors = new() { Type = "INVALID_DECK", Message = "cardID empty" }
};
}
var payload = new DeckPayload
{
DeckFormat = req.DeckFormat.ToString(),
Clan = req.Clan.ToString(),
SubClan = req.SubClan ?? 0,
// Standard decks emit int 0; my-rotation decks emit the rotation id as a string.
// Mixed wire typing matches prod (data_dumps/traffic_prod_deckcode.ndjson).
RotationId = (object?)req.RotationId ?? 0,
// Strip the foil flag (ones digit) — matches prod's normalize-on-encode behaviour
// observed in the traffic dump (e.g. 703441011 → 703441010).
CardID = req.CardID.Select(id => id - (id % 10)).ToList()
};
string code = _codes.Mint(payload);
return new GenerateDeckCodeResponse
{
Text = "OK",
DeckCode = code
};
}
[HttpPost("deck")]
public ActionResult<GetDeckFromCodeResponse> Resolve(GetDeckFromCodeRequest req)
{
var payload = _codes.TryResolve(req.DeckCode ?? "");
if (payload is null)
{
return new GetDeckFromCodeResponse
{
Text = "EXPIRED",
Deck = new DeckPayload(),
Errors = new() { Type = "INVALID_DECK_CODE", Message = "Unknown or expired code" }
};
}
return new GetDeckFromCodeResponse
{
Text = "OK",
Deck = payload
};
}
}

View File

@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Mvc;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /download_time/* — asset-download timing telemetry. The client fires
/// <c>POST /download_time/start</c> right before kicking off an Akamai asset bundle
/// download (<c>Wizard/DownloadStartTask.cs</c>) and <c>POST /download_time/end</c> when
/// it completes (<c>Wizard/DownloadFinishTask.cs</c>). Both are pure telemetry from our
/// perspective — we don't track download timings — but the client surfaces an HTTP error
/// dialog if either 404s, so we ack with empty <c>data: {}</c> bodies.
///
/// <para>Explicit <see cref="RouteAttribute"/> because the base controller token would
/// resolve to <c>/downloadtime</c>, missing the underscore.</para>
/// </summary>
[Route("download_time")]
public class DownloadTimeController : SVSimController
{
/// <summary>
/// Spec: <c>docs/api-spec/endpoints/post-login/download_time-start.md</c>. The client's
/// <c>DownloadStartTask.Parse</c> reads an optional <c>image_type</c> string
/// (<c>"card"</c> → CardDetail loading-screen art, <c>"still"</c> → StoryDetail, anything
/// else → default). We omit it; the client falls through to the default art.
/// </summary>
[HttpPost("start")]
public IActionResult Start([FromBody] BaseRequest request) => Ok(new { });
/// <summary>
/// Spec: <c>docs/api-spec/endpoints/post-login/download_time-end.md</c>. The client's
/// <c>DownloadFinishTask</c> doesn't override <c>Parse</c> at all — only <c>result_code</c>
/// matters. Empty data is the documented minimum-viable response.
/// </summary>
[HttpPost("end")]
public IActionResult End([FromBody] BaseRequest request) => Ok(new { });
}

View File

@@ -0,0 +1,248 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// Tutorial-scoped gift endpoints. We do NOT implement a generic gift system here —
/// only the /tutorial/gift_top and /tutorial/gift_receive aliases needed for the
/// step 31 → 41 reward flow. A full gift inbox is future work; if/when needed,
/// add /gift/top and /gift/receive_gift aliases to this controller.
/// </summary>
public class GiftController : SVSimController
{
/// <summary>The hardcoded tutorial gift bundle every fresh viewer sees at step 31.</summary>
public static readonly IReadOnlyList<PresentDto> TutorialGifts = new[]
{
new PresentDto { PresentId = "71478626", RewardType = "1", RewardDetailId = "0", RewardCount = "400", Message = "For completing the tutorial" },
new PresentDto { PresentId = "71478627", RewardType = "9", RewardDetailId = "0", RewardCount = "100", Message = "For completing the tutorial" },
new PresentDto { PresentId = "71478628", RewardType = "4", RewardDetailId = "1", RewardCount = "3", Message = "For completing the tutorial", ItemType = 1 },
new PresentDto { PresentId = "71478629", RewardType = "4", RewardDetailId = "80001", RewardCount = "40", Message = "For completing the tutorial", ItemType = 2 },
new PresentDto { PresentId = "71478630", RewardType = "4", RewardDetailId = "90001", RewardCount = "1", Message = "For completing the tutorial", ItemType = 2 },
};
private readonly SVSimDbContext _db;
private readonly RewardGrantService _rewards;
public GiftController(SVSimDbContext db, RewardGrantService rewards)
{
_db = db;
_rewards = rewards;
}
[HttpPost("/tutorial/gift_top")]
public async Task<ActionResult<GiftTopResponse>> TutorialGiftTop([FromBody] GiftTopRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var claimedList = await _db.ViewerClaimedTutorialGifts
.Where(g => g.ViewerId == viewerId)
.Select(g => g.PresentId)
.ToListAsync();
var claimed = new HashSet<string>(claimedList);
var nowString = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
var presents = TutorialGifts
.Where(p => !claimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList();
var history = TutorialGifts
.Where(p => claimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList();
return new GiftTopResponse
{
PresentList = presents,
PresentHistoryList = history,
LimitOverPresentList = new(),
};
}
[HttpPost("/tutorial/gift_receive")]
public async Task<ActionResult<GiftReceiveResponse>> TutorialGiftReceive([FromBody] GiftReceiveRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
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.
var alreadyClaimedList = await _db.ViewerClaimedTutorialGifts
.Where(g => g.ViewerId == viewerId && requestedIds.Contains(g.PresentId))
.Select(g => g.PresentId)
.ToListAsync();
var alreadyClaimed = new HashSet<string>(alreadyClaimedList);
var toClaim = TutorialGifts
.Where(p => requestedIds.Contains(p.PresentId) && !alreadyClaimed.Contains(p.PresentId))
.ToList();
// Apply grants via the canonical primitive. Pass the loaded viewer, NOT viewerId.
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));
}
// 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)
{
viewer.MissionData.TutorialState = GiftReceiveTutorialStep;
}
// Persist claim receipts in the same transaction.
var now = DateTime.UtcNow;
foreach (var p in toClaim)
{
_db.ViewerClaimedTutorialGifts.Add(new SVSim.Database.Models.ViewerClaimedTutorialGift
{
ViewerId = viewerId,
PresentId = p.PresentId,
ClaimedAt = now,
});
}
await _db.SaveChangesAsync();
var nowString = now.ToString("yyyy-MM-dd HH:mm:ss");
var allClaimedList = await _db.ViewerClaimedTutorialGifts
.Where(g => g.ViewerId == viewerId)
.Select(g => g.PresentId)
.ToListAsync();
var allClaimed = new HashSet<string>(allClaimedList);
// Derive presentList/historyList up front so IsUnreceivedPresent can read the count
// without re-filtering. unclaimedPresents are the gifts still on offer after this call;
// claimedPresents are everything the viewer has ever received (this call + prior calls).
var unclaimedPresents = TutorialGifts
.Where(p => !allClaimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList();
var claimedPresents = TutorialGifts
.Where(p => allClaimed.Contains(p.PresentId))
.Select(p => Clone(p, nowString))
.ToList();
return new GiftReceiveResponse
{
CardList = new(),
// Echo only the ids actually granted by THIS call. Building this from `requestedIds`
// would falsely confirm a re-grant on idempotent retries: the client would re-show
// the "received N gifts" popup and direct-assign the same post-state totals it already
// applied, double-toasting the user. Sort ascending to match the prod-capture order.
ReceivedIds = toClaim
.Select(p => p.PresentId)
.OrderBy(x => x)
.ToList(),
// Same idempotency contract: only the gifts granted in THIS call belong in the
// per-reward summary list. The client uses this to drive the +N popups.
TotalReceiveCountList = toClaim
.Select(p => new TotalReceiveCountDto
{
RewardType = int.Parse(p.RewardType),
RewardDetailId = long.Parse(p.RewardDetailId),
RewardCount = long.Parse(p.RewardCount),
ItemType = p.ItemType ?? 0,
IsUsable = true,
}).ToList(),
PresentList = unclaimedPresents,
PresentHistoryList = claimedPresents,
// True when there are still unclaimed gifts on offer — drives the inbox badge state.
// 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.
// 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(),
// 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,
};
}
/// <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,
4 => UserGoodsType.Item,
9 => UserGoodsType.Rupy,
_ => throw new InvalidOperationException($"Unmapped gift wire reward_type {wireType}"),
};
private static PresentDto Clone(PresentDto p, string createTime) => new()
{
PresentId = p.PresentId,
RewardType = p.RewardType,
RewardDetailId = p.RewardDetailId,
RewardCount = p.RewardCount,
ConditionNumber = p.ConditionNumber,
PresentLimitType = p.PresentLimitType,
RewardLimitTime = p.RewardLimitTime,
CreateTime = createTime,
ItemType = p.ItemType,
Message = p.Message,
};
}

View File

@@ -100,6 +100,13 @@ public class MyPageController : SVSimController
},
BasicPuzzle = new Models.Dtos.Common.BadgeFlag { IsDisplayBadge = false }, // TODO(mypage-stub): viewer practice-puzzle progress
IsBattlePassPeriod = rotation.IsBattlePassPeriod,
// The client's MyPageTask.Parse (line 155-163) does `_userItemDict.Clear();` whenever
// user_item_list is present in the response — not when it's non-empty — and then
// repopulates from the wire. Emitting [] here wipes the inventory the client populated
// from /load/index, which makes PackChildGachaInfo.CostGoodsCount return 0 and filters
// out is_hide=1 tutorial packs (the legendary starter 99047) via PackConfig.EnableBuyPack.
// Populate from viewer.Items so the client's dict stays in sync with the DB.
UserItemList = viewer.Items.Select(i => new UserItem(i)).ToList(),
SpecialCrystalInfo = new(), // TODO(mypage-stub): same shape/source as /load/index
// CompetitionInfo, ShopNotification, StoryNotification, GuildNotification, GatheringInfo,
// IsHiddenBossAppeared, SubBanner/SubBannerList/HomeDialogList/UserOfflineEvent/UserItemList,

View File

@@ -14,8 +14,8 @@ using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// /pack/* — card-pack shop catalog and pack opening. Tutorial aliases (/tutorial/pack_info,
/// /tutorial/pack_open) are out of scope for v1.
/// /pack/* — card-pack shop catalog and pack opening. /tutorial/pack_info and
/// /tutorial/pack_open are aliased here.
/// </summary>
[Route("pack")]
public class PackController : SVSimController
@@ -46,6 +46,7 @@ public class PackController : SVSimController
}
[HttpPost("info")]
[HttpPost("/tutorial/pack_info")]
public async Task<ActionResult<PackInfoResponse>> Info(BaseRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
@@ -53,13 +54,39 @@ public class PackController : SVSimController
var packs = await _packs.GetActivePacks(DateTime.UtcNow);
var openCounts = await _packs.GetOpenCountsForViewer(viewerId);
// Load owned-item counts so child_gacha_info.item_number reflects the viewer's actual
// ticket inventory (see ToDto). The client filters tutorial packs by item_number > 0
// — without this the legendary starter pack (99047, requires 1× item 90001) and the
// throwback pack (80047, requires 1× item 80001) are hidden even when the tutorial
// gift just granted those tickets, blocking the END transition.
//
// OwnedItemEntry is [Owned] by Viewer, and EF refuses to track owned entities without
// their owner in the result. Project to primitive pairs in the database query before
// materialising into the dictionary — no entity tracking, single round-trip.
//
// Use EF.Property<int>(i, "ItemId") to read the shadow FK directly instead of going
// through the OwnedItemEntry.Item nav. The nav route works today (EF translates
// `i.Item.Id` to the FK column), but a future model change that renames the FK or
// breaks the nav→column mapping would silently fall back to client eval — where
// `i.Item.Id` returns 0 for every row (the default-initialised ItemEntry) and the
// dictionary collapses every ticket to item_number=0. Shadow-FK access bypasses
// that hazard entirely.
var ownedItemsByItemId = await _db.Viewers
.Where(v => v.Id == viewerId)
.SelectMany(v => v.Items)
.Select(i => new { ItemId = (long)EF.Property<int>(i, "ItemId"), i.Count })
.ToDictionaryAsync(x => x.ItemId, x => x.Count);
return new PackInfoResponse
{
PackConfigList = packs.Select(p => ToDto(p, openCounts)).ToList(),
PackConfigList = packs.Select(p => ToDto(p, openCounts, ownedItemsByItemId)).ToList(),
};
}
private static PackConfigDto ToDto(PackConfigEntry p, IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts)
private static PackConfigDto ToDto(
PackConfigEntry p,
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
IReadOnlyDictionary<long, int> ownedItemsByItemId)
{
int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0;
return new PackConfigDto
@@ -86,6 +113,16 @@ public class PackController : SVSimController
Cost = c.Cost,
Count = c.CardCount,
ItemId = c.ItemId?.ToString(CultureInfo.InvariantCulture),
// item_number is viewer-specific — the count of item_id this viewer currently
// owns, NOT a per-pack-catalog value. Verified against the prod tutorial
// capture: legendary pack 99047 reports item_number=1 right after the gift
// granted 1× ticket id=90001; throwback 80047 reports 40 right after the gift
// granted 40× ticket id=80001. Client filters the tutorial pack list to
// packs with non-zero item_number (free packs like 92001 are special-cased
// separately), so this lookup is what makes the tutorial-final pack show up.
ItemNumber = c.ItemId is long iid && ownedItemsByItemId.TryGetValue(iid, out var ownedCount)
? ownedCount
: 0,
IsDailySingle = c.IsDailySingle,
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
}).ToList(),
@@ -110,10 +147,21 @@ public class PackController : SVSimController
}
[HttpPost("open")]
[HttpPost("/tutorial/pack_open")]
public async Task<ActionResult<PackOpenResponse>> Open(PackOpenRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
bool isTutorialPath = HttpContext.Request.Path.StartsWithSegments("/tutorial/pack_open");
// The tutorial alias bypasses the currency / type_detail / open-count guards because
// the legendary starter pack (99047) is a free server-grant during the 41→100 tutorial
// transition. Constrain the alias to that one pack so the bypass isn't a free draw on
// ANY pack the client supplies a parent_gacha_id for.
const int StarterParentGachaId = 99047;
if (isTutorialPath && request.ParentGachaId != StarterParentGachaId)
return BadRequest(new { error = "tutorial_path_only_for_starter_pack" });
// Reject paths up front — class_id/target_card_id overloads aren't implemented.
if (request.ClassId.HasValue)
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "starter_overload_not_implemented" });
@@ -142,55 +190,79 @@ public class PackController : SVSimController
// child; gacha_type validation against child.TypeDetail would falsely reject every buy.
// Supported currency types in v1: CRYSTAL_MULTI=2, DAILY=3, RUPY_MULTI=7. Ticket flows
// (TICKET=4, TICKET_MULTI=5) and the rest are explicitly out of scope.
if (child.TypeDetail is not (2 or 3 or 7))
// (TICKET=4, TICKET_MULTI=5) and the rest are explicitly out of scope for the normal path.
// The tutorial path (type_detail=5, TICKET_MULTI) bypasses this guard — the starter pack
// is a free server-granted bonus, not a purchasable pack.
if (!isTutorialPath && child.TypeDetail is not (2 or 3 or 7))
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "currency_path_not_implemented" });
var viewer = await _db.Viewers.Include(v => v.PackOpenCounts).FirstAsync(v => v.Id == viewerId);
var viewer = await _db.Viewers
.Include(v => v.PackOpenCounts)
.Include(v => v.MissionData)
.Include(v => v.Items).ThenInclude(i => i.Item)
.AsSplitQuery()
.FirstAsync(v => v.Id == viewerId);
// 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
// chose to keep, and (without the max-preserve write below) could regress a higher
// state value. Mirrors the 31<41 guard in GiftController.TutorialGiftReceive.
const int TutorialEndStep = 100;
if (isTutorialPath && viewer.MissionData.TutorialState >= TutorialEndStep)
return BadRequest(new { error = "tutorial_already_complete" });
int packNumber = Math.Max(1, request.PackNumber);
// Currency check + deduction
switch (child.TypeDetail)
// Currency check + deduction (skipped for tutorial path — starter pack is free)
if (!isTutorialPath)
{
case 2: // CRYSTAL_MULTI
switch (child.TypeDetail)
{
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (viewer.Currency.Crystals < cost)
return BadRequest(new { error = "insufficient_crystals" });
viewer.Currency.Crystals -= cost;
break;
}
case 7: // RUPY_MULTI
{
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (viewer.Currency.Rupees < cost)
return BadRequest(new { error = "insufficient_rupees" });
viewer.Currency.Rupees -= cost;
break;
}
case 3: // DAILY single — once per UTC day
{
// TODO(daily-reset): no project-wide daily-reset convention exists yet. Using UTC
// midnight; revisit when the global reset boundary is settled.
var now = DateTime.UtcNow;
var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id);
if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date)
return BadRequest(new { error = "daily_free_already_claimed" });
case 2: // CRYSTAL_MULTI
{
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (viewer.Currency.Crystals < cost)
return BadRequest(new { error = "insufficient_crystals" });
viewer.Currency.Crystals -= cost;
break;
}
case 7: // RUPY_MULTI
{
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (viewer.Currency.Rupees < cost)
return BadRequest(new { error = "insufficient_rupees" });
viewer.Currency.Rupees -= cost;
break;
}
case 3: // DAILY single — once per UTC day
{
// TODO(daily-reset): no project-wide daily-reset convention exists yet. Using UTC
// midnight; revisit when the global reset boundary is settled.
var now = DateTime.UtcNow;
var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id);
if (existing?.LastDailyFreeAt is DateTime last && last.Date == now.Date)
return BadRequest(new { error = "daily_free_already_claimed" });
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (cost > 0 && viewer.Currency.Rupees < cost)
return BadRequest(new { error = "insufficient_rupees" });
if (cost > 0) viewer.Currency.Rupees -= cost;
break;
ulong cost = (ulong)child.Cost * (ulong)packNumber;
if (cost > 0 && viewer.Currency.Rupees < cost)
return BadRequest(new { error = "insufficient_rupees" });
if (cost > 0) viewer.Currency.Rupees -= cost;
break;
}
}
await _db.SaveChangesAsync();
}
await _db.SaveChangesAsync();
// Increment open count + mark daily-free timestamp where relevant
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
if (child.TypeDetail == 3)
// Increment open count + mark daily-free timestamp where relevant.
// Tutorial path skips these — the starter pack is a one-time free grant, not a
// purchasable/trackable open.
if (!isTutorialPath)
{
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
if (child.TypeDetail == 3)
{
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
}
}
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
@@ -204,18 +276,58 @@ public class PackController : SVSimController
// 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>();
var postViewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId);
if (child.TypeDetail == 2)
// Currency reward entries only apply to purchasable packs; tutorial path omits them.
if (!isTutorialPath)
{
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)postViewer.Currency.Crystals });
}
else if (child.TypeDetail == 7 || child.TypeDetail == 3)
{
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees });
var postViewer = await _db.Viewers.FirstAsync(v => v.Id == viewerId);
if (child.TypeDetail == 2)
{
rewardList.Add(new RewardListEntry { RewardType = 2, RewardId = 0, RewardNum = (int)postViewer.Currency.Crystals });
}
else if (child.TypeDetail == 7 || child.TypeDetail == 3)
{
rewardList.Add(new RewardListEntry { RewardType = 9, RewardId = 0, RewardNum = (int)postViewer.Currency.Rupees });
}
}
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
// it, and the second click hits /pack/open (not /tutorial/pack_open) — which 501s on
// type_detail=5 (TICKET_MULTI is out of scope for the normal path). Emitting the
// post-state count in reward_list direct-assigns the client's _userItemDict so the
// UI also goes stale-safe immediately (client does direct assignment per
// project_wire_reward_list_post_state memory).
int? responseTutorialStep = null;
if (isTutorialPath)
{
if (child.ItemId is long ticketItemId)
{
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
});
}
}
// Max-preserve: never regress the persisted state, even though Gate B already
// rejected state>=100 above. Belt-and-braces against a future caller that
// bypasses Gate B (refactor, new alias, etc.). Wire still emits 100 — that's
// the tutorial-END signal the client expects.
if (viewer.MissionData.TutorialState < TutorialEndStep)
viewer.MissionData.TutorialState = TutorialEndStep;
await _db.SaveChangesAsync();
responseTutorialStep = TutorialEndStep;
}
return new PackOpenResponse
{
PackList = draw.Cards.Select(c => new CardPackEntryDto
@@ -225,6 +337,7 @@ public class PackController : SVSimController
Number = 1,
}).ToList(),
RewardList = rewardList,
TutorialStep = responseTutorialStep,
};
}
}

View File

@@ -4,6 +4,7 @@ using SVSim.Database.Repositories.Viewer;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.EmulatedEntrypoint.Controllers;
@@ -11,11 +12,16 @@ public class ToolController : SVSimController
{
private readonly ILogger<ToolController> _logger;
private readonly IViewerRepository _viewerRepository;
private readonly ShadowverseSessionService _sessionService;
public ToolController(ILogger<ToolController> logger, IViewerRepository viewerRepository)
public ToolController(
ILogger<ToolController> logger,
IViewerRepository viewerRepository,
ShadowverseSessionService sessionService)
{
_logger = logger;
_viewerRepository = viewerRepository;
_sessionService = sessionService;
}
/// <summary>
@@ -43,6 +49,13 @@ public class ToolController : SVSimController
?? await _viewerRepository.RegisterAnonymousViewer(udid);
HttpContext.SetViewer(viewer);
// Pre-store the SID the client will compute and use for its very next request. After
// signup the client switches to SID-only headers (no UDID), so without this mapping the
// translation middleware can't decrypt the next body. Formula mirrors the decompiled
// Cute/Certification.SessionId getter — see ShadowverseSessionService.ComputeClientSessionId.
_sessionService.StoreSessionForViewer(viewer.Id, udid);
_logger.LogInformation("Signup resolved for udid={Udid} → viewer_id={ViewerId}, short_udid={ShortUdid}.",
udid, viewer.Id, viewer.ShortUdid);

View File

@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using SVSim.Database;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Tutorial;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Tutorial;
namespace SVSim.EmulatedEntrypoint.Controllers;
/// <summary>
/// Tutorial step bookkeeping. The tutorial itself runs entirely client-side
/// (StoryTutorial*BattleMgr per class); the server only persists step transitions.
/// </summary>
public class TutorialController : SVSimController
{
private readonly SVSimDbContext _db;
public TutorialController(SVSimDbContext db)
{
_db = db;
}
[HttpPost("update_action")]
public IActionResult UpdateAction([FromBody] TutorialUpdateActionRequest request)
{
// Fire-and-forget. Client uses SkipAllNetworkChecks; response body is ignored.
// We still emit an empty object so the translation middleware has a `data` payload to wrap.
return new JsonResult(new { });
}
[HttpPost("update")]
public async Task<ActionResult<TutorialUpdateResponse>> Update([FromBody] TutorialUpdateRequest request)
{
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
var viewer = await _db.Viewers
.Include(v => v.MissionData)
.FirstAsync(v => v.Id == viewerId);
// Preserve max — never regress. Mirrors GiftController.TutorialGiftReceive's 31→41 guard.
// Without this, a stale or replayed request with tutorial_step=0 (or any value below the
// viewer's current state) crashes the client on next /load/index: NextSceneSwitcher routes
// step==0 to AreaSelect section 0, which has no chapter data → LINQ Single() failure.
// Response keeps echoing request.TutorialStep so the client's own transition confirmation
// still works; the client owns the step-it-thinks-it's-moving-to concept and we don't
// want to surface a divergent value mid-flow.
viewer.MissionData.TutorialState = Math.Max(viewer.MissionData.TutorialState, request.TutorialStep);
await _db.SaveChangesAsync();
return new TutorialUpdateResponse { TutorialStep = request.TutorialStep };
}
}

View File

@@ -0,0 +1,12 @@
namespace SVSim.EmulatedEntrypoint.Infrastructure;
/// <summary>
/// Applied to a controller or action that speaks the same msgpack + standard envelope as the
/// rest of the game API but WITHOUT the AES wrapper. Used for endpoints hosted on
/// <c>shadowverse-portal.com</c> (deck builder, deck image), which use plaintext msgpack on the
/// wire — see <c>docs/api-spec/endpoints/deck-builder/*.md</c>. The translation middleware
/// detects the attribute and skips <c>Encryption.Decrypt</c> / <c>Encryption.Encrypt</c>; the
/// base64 wrap on the response and the msgpack ↔ JSON pivot stay the same.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)]
public sealed class NoWireEncryptionAttribute : Attribute { }

View File

@@ -9,8 +9,11 @@ using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json.Linq;
using SVSim.Database.Models;
using SVSim.Database.Models.Config;
using SVSim.Database.Services;
using SVSim.EmulatedEntrypoint.Constants;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Models.Dtos;
using SVSim.EmulatedEntrypoint.Models.Dtos.Internal;
using SVSim.EmulatedEntrypoint.Security;
@@ -25,6 +28,7 @@ public class ShadowverseTranslationMiddleware : IMiddleware
{
private readonly IActionDescriptorCollectionProvider _actionDescriptorCollectionProvider;
private readonly ShadowverseSessionService _sessionService;
private readonly IGameConfigService _gameConfig;
private readonly ILogger<ShadowverseTranslationMiddleware> _logger;
// Serialization policy MUST match what AddJsonOptions configured on the controllers, or the
@@ -40,10 +44,12 @@ public class ShadowverseTranslationMiddleware : IMiddleware
public ShadowverseTranslationMiddleware(
IActionDescriptorCollectionProvider actionDescriptorCollectionProvider,
ShadowverseSessionService sessionService,
IGameConfigService gameConfig,
ILogger<ShadowverseTranslationMiddleware> logger)
{
_actionDescriptorCollectionProvider = actionDescriptorCollectionProvider;
_sessionService = sessionService;
_gameConfig = gameConfig;
_logger = logger;
}
@@ -60,6 +66,19 @@ public class ShadowverseTranslationMiddleware : IMiddleware
return;
}
// Portal endpoints (shadowverse-portal.com — deck builder, deck image) speak msgpack
// and the standard envelope but skip AES on the wire. Detect via [NoWireEncryption] on
// the controller or action; this flag toggles the two Encryption calls below but every
// other step (msgpack pivot, JSON re-serialize for the binder, envelope wrap, base64 of
// the response) stays identical.
bool skipEncryption = false;
if (endpointDescriptor is ControllerActionDescriptor cad)
{
skipEncryption =
cad.MethodInfo.GetCustomAttributes(typeof(NoWireEncryptionAttribute), inherit: true).Length > 0 ||
cad.ControllerTypeInfo.GetCustomAttributes(typeof(NoWireEncryptionAttribute), inherit: true).Length > 0;
}
// Replace response body stream to re-access it.
using MemoryStream tempResponseBody = new MemoryStream();
Stream originalResponsebody = context.Response.Body;
@@ -70,10 +89,12 @@ public class ShadowverseTranslationMiddleware : IMiddleware
await context.Request.Body.CopyToAsync(requestBytesStream);
byte[] requestBytes = requestBytesStream.ToArray();
// Get encryption values for this request
// Get encryption values for this request. Portal endpoints don't carry a SID/UDID pair
// (they're anonymous-on-the-wire), so the lookup is skipped on the skip-encryption path
// — there's nothing to decrypt against.
string sid = context.Request.Headers[NetworkConstants.SessionIdHeaderName];
Guid? mappedUdid = _sessionService.GetUdidFromSessionId(sid);
if (mappedUdid is null)
Guid? mappedUdid = skipEncryption ? null : _sessionService.GetUdidFromSessionId(sid);
if (mappedUdid is null && !skipEncryption)
{
// Per design (2026-05-25): warn and continue. Decrypt will fail with Guid.Empty as
// the AES key, surfacing as a msgpack/decrypt error below — but now the *root cause*
@@ -85,11 +106,13 @@ public class ShadowverseTranslationMiddleware : IMiddleware
}
string udid = mappedUdid.GetValueOrDefault().ToString();
// Decrypt incoming data.
// Decrypt incoming data — unless this is a [NoWireEncryption] endpoint, in which case
// the request body is already raw msgpack (the client sends portal requests via
// _createBodyMsgpack with encrypt=false).
byte[] decryptedBytes;
try
{
decryptedBytes = Encryption.Decrypt(requestBytes, udid);
decryptedBytes = skipEncryption ? requestBytes : Encryption.Decrypt(requestBytes, udid);
}
catch (Exception ex)
{
@@ -155,53 +178,93 @@ public class ShadowverseTranslationMiddleware : IMiddleware
? null
: ConvertJsonTreeToPlainObject(JToken.Parse(responseJson));
// Wrap the response in a datawrapper
// Build the headers as a strongly-typed POCO so this construction site stays type-safe
// (the alternative — a Dictionary<string, object> with literal-string keys here — is the
// anti-pattern documented in the feedback_no_lazy_response_dicts memory).
DataHeaders typedHeaders = new DataHeaders
{
Servertime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
// SID intentionally empty. See docs/api-spec/common/envelope.md §"SID
// rotation" — the client's SessionId is a hash-on-read property, so echoing
// the request's SID poisons its backing field and the next request hashes
// the hash, missing our SID→UDID dict and crashing decryption. To rotate
// sessions in the future, use the "stable-prefix + counter" pattern from
// that doc (Option B), and pre-hash the rotated value to index the map by
// what the client will actually send back on the next request.
Sid = "",
// Pushed ONLY on /check/game_start. NetworkTask.Parse opens the
// "new data is available" popup whenever required_res_ver is present in
// data_headers AND the URL isn't GameStartCheck (NetworkTask.cs:128-138 — the
// popup is unconditionally skipped on game_start). Emitting on game_start
// silently bumps PlayerPrefs["RES_VER"] before ResourceDownloader runs;
// emitting anywhere else would surface a spurious "new data" dialog on every
// boot for any client whose cached RES_VER trails the server's current value.
RequiredResVer = path.Equals("/check/game_start", StringComparison.OrdinalIgnoreCase)
? _gameConfig.Get<ResourceConfig>().RequiredResVer
: null,
// TODO error handling
ResultCode = 1,
// Anonymous endpoints (e.g. /check/special_title with [AllowAnonymous]) reach this
// middleware without an authenticated viewer — the auth handler either declined or
// failed to find a Steam-linked viewer. The wire still needs short_udid / viewer_id
// populated (prod sends real numbers for the title check too, but 0 / 0 satisfies
// the client's BaseTask.Parse which only reads result_code + servertime here).
ShortUdid = skipEncryption ? 0 : (viewer?.ShortUdid ?? 0),
ViewerId = skipEncryption ? 0 : (viewer?.Id ?? 0),
// Echo the decrypted-against UDID. Most clients ignore this field; SignUpTask.Parse
// requires it (validates against Certification.Udid on the response). Comes from
// mappedUdid (the value used for AES); never from controller state.
Udid = skipEncryption ? "" : (mappedUdid?.ToString() ?? "")
};
// Route the typed headers through the same STJ→JToken→dict pipeline that the controller
// response (Data) goes through. STJ honours the global WhenWritingNull policy, so null
// optional fields are absent from the JSON; ConvertJsonTreeToPlainObject preserves
// "absent vs null" all the way to msgpack. Without this, MessagePack's contractless
// resolver would walk the typed properties and emit "key":null for every nullable
// field — RequiredResVer being the load-bearing case (a spurious null fires the
// "new data available" popup via NetworkTask.isResourceVersionUp on every non-
// game_start endpoint).
string headersJson = JsonSerializer.Serialize(typedHeaders, ControllerJsonOptions);
Dictionary<string, object?> headersDict =
(ConvertJsonTreeToPlainObject(JToken.Parse(headersJson)) as Dictionary<string, object?>)
?? throw new InvalidOperationException(
"DataHeaders JSON projection didn't yield a JSON object — this should be unreachable: " +
"DataHeaders is a typed POCO that always serializes to a single JSON object root.");
// Wrap the response in a datawrapper. Portal (no-encryption) endpoints emit an anonymous
// envelope — viewer/udid/sid stay zero/empty — matching the prod portal traffic shape
// captured in data_dumps/traffic_prod_deckcode.ndjson.
DataWrapper wrappedResponseData = new DataWrapper
{
Data = responseData,
DataHeaders = new DataHeaders
{
Servertime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
// SID intentionally empty. See docs/api-spec/common/envelope.md §"SID
// rotation" — the client's SessionId is a hash-on-read property, so echoing
// the request's SID poisons its backing field and the next request hashes
// the hash, missing our SID→UDID dict and crashing decryption. To rotate
// sessions in the future, use the "stable-prefix + counter" pattern from
// that doc (Option B), and pre-hash the rotated value to index the map by
// what the client will actually send back on the next request.
Sid = "",
// TODO error handling
ResultCode = 1,
// Anonymous endpoints (e.g. /check/special_title with [AllowAnonymous]) reach this
// middleware without an authenticated viewer — the auth handler either declined or
// failed to find a Steam-linked viewer. The wire still needs short_udid / viewer_id
// populated (prod sends real numbers for the title check too, but 0 / 0 satisfies
// the client's BaseTask.Parse which only reads result_code + servertime here).
ShortUdid = viewer?.ShortUdid ?? 0,
ViewerId = viewer?.Id ?? 0,
// Echo the decrypted-against UDID. Most clients ignore this field; SignUpTask.Parse
// requires it (validates against Certification.Udid on the response). Comes from
// mappedUdid (the value used for AES); never from controller state.
Udid = mappedUdid?.ToString() ?? ""
}
DataHeaders = headersDict
};
// Convert the response into a messagepack, encrypt it. ContractlessStandardResolver
// walks the DataWrapper's typed properties (DataHeaders) AND the boxed object/list/
// primitive tree under Data — emitting only the keys present in the dictionary.
// walks the boxed object/list/primitive tree under both DataHeaders and Data —
// emitting only the keys present in each dictionary. Null-valued optional fields are
// already stripped upstream by the STJ + ConvertJsonTreeToPlainObject pipeline.
var msgPackOptions = MessagePackSerializerOptions.Standard
.WithResolver(ContractlessStandardResolver.Instance);
// Both branches base64-wrap the response body — the client's NetworkManager.Connect
// reads downloadHandler.text and calls Convert.FromBase64String on the no-encryption
// path (Cute/NetworkManager.cs:194) and CryptAES.decrypt (which also base64-decodes
// internally) on the encrypted path.
byte[] packedData;
try
{
packedData = MessagePackSerializer.Serialize(wrappedResponseData, msgPackOptions);
packedData = Encryption.Encrypt(packedData, udid);
if (!skipEncryption)
{
packedData = Encryption.Encrypt(packedData, udid);
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Response msgpack/encrypt failed for {Path} (viewerId={ViewerId}, udid={Udid}).",
path, viewer?.Id, udid);
"Response msgpack{EncryptStep} failed for {Path} (viewerId={ViewerId}, udid={Udid}).",
skipEncryption ? "" : "/encrypt", path, viewer?.Id, udid);
throw;
}
await originalResponsebody.WriteAsync(Encoding.UTF8.GetBytes(Convert.ToBase64String(packedData)));

View File

@@ -0,0 +1,22 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Common;
/// <summary>
/// The portal (shadowverse-portal.com) wraps every response with an `errors` object that is
/// present even on success — the success-path payload carries a stub `UNKNOWN_ERROR` / "error
/// message" pair that the client ignores when result_code == 1. See
/// <c>docs/api-spec/endpoints/deck-builder/*.md</c>.
/// </summary>
[MessagePackObject]
public class PortalErrors
{
[JsonPropertyName("type")]
[Key("type")]
public string Type { get; set; } = "UNKNOWN_ERROR";
[JsonPropertyName("message")]
[Key("message")]
public string Message { get; set; } = "";
}

View File

@@ -32,4 +32,23 @@ public class DataHeaders
[JsonPropertyName("udid")]
[Key("udid")]
public string Udid { get; set; } = "";
/// <summary>
/// Tells the client the required version path component for asset manifests on the
/// resource server (Akamai CDN, hardcoded to <c>shadowverse.akamaized.net/</c> in
/// <c>Wizard/SetUp.cs:48</c>). <c>NetworkTask.setResourceVersion</c> writes the value
/// to <c>PlayerPrefs["RES_VER"]</c>; the manifest URL becomes
/// <c>dl/Manifest/&lt;RES_VER&gt;/&lt;lang&gt;/&lt;Platform&gt;/</c>. When the client
/// has no cached <c>RES_VER</c> (e.g., after <c>NukeIdentityOnStartup</c> wipes
/// PlayerPrefs), it defaults to <c>"00000000"</c>, which Akamai doesn't serve — the
/// manifest fetch 404s and the client shows "Connection Error / Reconnect" before
/// the tutorial UI ever appears.
/// <para>
/// Nullable to keep it off the wire on responses that don't need it (the global
/// <c>WhenWritingNull</c> policy in Program.cs handles the omission).
/// </para>
/// </summary>
[JsonPropertyName("required_res_ver")]
[Key("required_res_ver")]
public string? RequiredResVer { get; set; }
}

View File

@@ -10,11 +10,20 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.Internal;
public class DataWrapper
{
/// <summary>
/// Additional data about the request, response and user.
/// Wire-shape projection of the response envelope headers. The middleware builds a
/// strongly-typed <see cref="DataHeaders"/> POCO and runs it through the same STJ +
/// <c>ConvertJsonTreeToPlainObject</c> pipeline that the controller's response goes
/// through, yielding this dict with absent keys for null-valued optional fields.
/// Typed as <see cref="Dictionary{TKey,TValue}"/> (not <see cref="object"/>) because
/// the projected shape is fully known — only the per-key value type varies. Direct
/// assignment of the typed POCO would let MessagePack's contractless resolver emit
/// <c>"key":null</c> for nullables, which the client treats as "key present" via
/// <c>Keys.Contains</c> (see <c>NetworkTask.isResourceVersionUp</c> for the
/// load-bearing case).
/// </summary>
[JsonPropertyName("data_headers")]
[Key("data_headers")]
public DataHeaders DataHeaders { get; set; } = new DataHeaders();
[Key("data_headers")]
public Dictionary<string, object?> DataHeaders { get; set; } = new();
/// <summary>
/// The response data from the endpoint.

View File

@@ -99,6 +99,17 @@ public class PackConfigDto
/// when unset. v1 always emits an empty object when the field is null on the entity —
/// matches the active-window case and the client tolerates both shapes via
/// <c>ShopExpirtyInfo</c>'s LitJson parser. Revisit if a capture proves otherwise.
///
/// TODO(2026-05-28): the prod tutorial capture has each active pack with
/// <c>"sales_period_info": {"sales_period_time": "&lt;complete_date&gt;"}</c> — i.e., the
/// pack's <c>complete_date</c> echoed inside the object. Our controller emits <c>{}</c>
/// which the client tolerates (the tutorial flow doesn't filter on this field), but for
/// wire fidelity we should populate it from <c>PackConfigEntry.CompleteDate</c>. While
/// doing that, also retype this field from <c>Dictionary&lt;string, string?&gt;</c> to a
/// typed <c>PackSalesPeriodInfoDto { string SalesPeriodTime }</c> — the current dict
/// shape is the lazy-key anti-pattern documented in
/// <c>feedback_no_lazy_response_dicts</c>. Deferred from the tutorial-bringup pass
/// because it doesn't gate any observable flow.
/// </summary>
[JsonPropertyName("sales_period_info")]
[Key("sales_period_info")]

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Account;
[MessagePackObject]
public class AccountUpdateNameRequest : BaseRequest
{
[JsonPropertyName("name")]
[Key("name")]
public string Name { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,47 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.DeckBuilder;
/// <summary>
/// Covers all three client-side overloads of <c>GenerateDeckCodeTask.SetParameter</c>:
/// standard, crossover (sub_clan present), and my-rotation (rotation_id present, no phantom).
/// Optional fields stay null on shapes that don't carry them.
///
/// Deliberately does NOT inherit from <see cref="BaseRequest"/>: portal endpoints are anonymous
/// (the server ignores viewer_id / steam_id / steam_session_ticket on the wire — see the
/// data_headers in the prod traffic dump where they're all zeroed). The fields still arrive on
/// the wire from the client; System.Text.Json silently drops unknown JSON properties.
/// </summary>
[MessagePackObject]
public class GenerateDeckCodeRequest
{
[JsonPropertyName("clan")]
[Key("clan")]
public int Clan { get; set; }
[JsonPropertyName("sub_clan")]
[Key("sub_clan")]
public int? SubClan { get; set; }
[JsonPropertyName("deck_format")]
[Key("deck_format")]
public int DeckFormat { get; set; }
// Wire key is camelCase mid-word capital — verified in data_dumps/traffic.ndjson live
// capture (`"cardID":[...]`). The client's LitJson serializer emits the C# property name
// verbatim, and the param classes in Wizard/GenerateDeckCodeTask.cs use `cardID` /
// `phantomCardID`. Snake-case would silently bind to empty and the controller would emit
// INVALID_DECK; that was the 2026-05-28 "blank code in the deck builder UI" symptom.
[JsonPropertyName("cardID")]
[Key("cardID")]
public List<long> CardID { get; set; } = new();
[JsonPropertyName("phantomCardID")]
[Key("phantomCardID")]
public List<long>? PhantomCardID { get; set; }
[JsonPropertyName("rotation_id")]
[Key("rotation_id")]
public string? RotationId { get; set; }
}

View File

@@ -0,0 +1,16 @@
using MessagePack;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.DeckBuilder;
/// <summary>
/// Portal resolve-by-code request. Anonymous on the wire — does not extend
/// <see cref="BaseRequest"/>; see <see cref="GenerateDeckCodeRequest"/> for the rationale.
/// </summary>
[MessagePackObject]
public class GetDeckFromCodeRequest
{
[JsonPropertyName("deck_code")]
[Key("deck_code")]
public string DeckCode { get; set; } = "";
}

View File

@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
[MessagePackObject]
public class GiftReceiveRequest : BaseRequest
{
[JsonPropertyName("present_id_array")]
[Key("present_id_array")]
public List<string> PresentIdArray { get; set; } = new();
[JsonPropertyName("state")]
[Key("state")]
public int State { get; set; }
}

View File

@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Gift;
[MessagePackObject]
public class GiftTopRequest : BaseRequest
{
[JsonPropertyName("page")]
[Key("page")]
public int Page { get; set; }
}

View File

@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Tutorial;
/// <summary>
/// <c>POST /tutorial/update_action</c> — fire-and-forget sub-step tracking.
/// Client task: <c>Wizard/TutorialUpdateActionTask.cs</c>. SkipAllNetworkChecks is on,
/// so any return value (including failures) is silently ignored.
/// </summary>
[MessagePackObject]
public class TutorialUpdateActionRequest : BaseRequest
{
[JsonPropertyName("tutorial_step")]
[Key("tutorial_step")]
public int TutorialStep { get; set; }
[JsonPropertyName("tutorial_action_number")]
[Key("tutorial_action_number")]
public int TutorialActionNumber { get; set; }
}

View File

@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Tutorial;
/// <summary>
/// <c>POST /tutorial/update</c> — client reports the step it is moving TO.
/// Client task: <c>Wizard/TutorialUpdateTask.cs</c>.
/// </summary>
[MessagePackObject]
public class TutorialUpdateRequest : BaseRequest
{
/// <summary>The tutorial step the client is moving TO (0, 1, 11, 21, 31, 41, 100).</summary>
[JsonPropertyName("tutorial_step")]
[Key("tutorial_step")]
public int TutorialStep { get; set; }
/// <summary>0 = normal, 1 = user chose Skip Tutorial.</summary>
[JsonPropertyName("is_skip")]
[Key("is_skip")]
public int IsSkip { get; set; }
}

View File

@@ -0,0 +1,21 @@
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder;
[MessagePackObject]
public class GenerateDeckCodeResponse
{
[JsonPropertyName("text")]
[Key("text")]
public string Text { get; set; } = "OK";
[JsonPropertyName("deck_code")]
[Key("deck_code")]
public string DeckCode { get; set; } = "";
[JsonPropertyName("errors")]
[Key("errors")]
public PortalErrors Errors { get; set; } = new();
}

View File

@@ -0,0 +1,54 @@
using MessagePack;
using SVSim.EmulatedEntrypoint.Models.Dtos.Common;
using System.Text.Json.Serialization;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder;
[MessagePackObject]
public class GetDeckFromCodeResponse
{
[JsonPropertyName("text")]
[Key("text")]
public string Text { get; set; } = "OK";
[JsonPropertyName("deck")]
[Key("deck")]
public DeckPayload Deck { get; set; } = new();
[JsonPropertyName("errors")]
[Key("errors")]
public PortalErrors Errors { get; set; } = new();
}
/// <summary>
/// Wire shape inside the <c>deck</c> envelope. Prod emits <c>clan</c> / <c>deck_format</c> as
/// strings but <c>sub_clan</c> / <c>rotation_id</c> as ints — mirror that quirk so the client
/// `.ToInt()` / `.ToString()` paths see what they expect. <c>RotationId</c> is typed as
/// <c>object</c> so we can emit the int literal <c>0</c> on standard decks (matches prod) and a
/// string on MyRotation decks.
/// </summary>
[MessagePackObject]
public class DeckPayload
{
[JsonPropertyName("deck_format")]
[Key("deck_format")]
public string DeckFormat { get; set; } = "1";
[JsonPropertyName("clan")]
[Key("clan")]
public string Clan { get; set; } = "0";
[JsonPropertyName("sub_clan")]
[Key("sub_clan")]
public int SubClan { get; set; }
[JsonPropertyName("rotation_id")]
[Key("rotation_id")]
public object RotationId { get; set; } = 0;
// Wire key is camelCase mid-word capital to mirror the client's `cardID` parser
// (Wizard/GetDeckDataFromCodeTask.cs:44 reads `jsonData["cardID"]`).
[JsonPropertyName("cardID")]
[Key("cardID")]
public List<long> CardID { get; set; } = new();
}

View File

@@ -0,0 +1,96 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
[MessagePackObject]
public class GiftReceiveResponse
{
/// <summary>Cards granted (always empty for tutorial — the starter bundle has no card-type rewards).</summary>
[JsonPropertyName("card_list")]
[Key("card_list")]
public List<object> CardList { get; set; } = new();
[JsonPropertyName("received_ids")]
[Key("received_ids")]
public List<string> ReceivedIds { get; set; } = new();
[JsonPropertyName("total_receive_count_list")]
[Key("total_receive_count_list")]
public List<TotalReceiveCountDto> TotalReceiveCountList { get; set; } = new();
[JsonPropertyName("present_list")]
[Key("present_list")]
public List<PresentDto> PresentList { get; set; } = new();
[JsonPropertyName("present_history_list")]
[Key("present_history_list")]
public List<PresentDto> PresentHistoryList { get; set; } = new();
[JsonPropertyName("is_unreceived_present")]
[Key("is_unreceived_present")]
public bool IsUnreceivedPresent { get; set; }
[JsonPropertyName("reward_list")]
[Key("reward_list")]
public List<GiftRewardListEntry> RewardList { get; set; } = new();
/// <summary>
/// Tutorial step the server is advancing the viewer to as a side-effect of this claim.
/// Nullable: omitted via global WhenWritingNull on non-tutorial uses (none yet) or when
/// the viewer is already past the 31→41 boundary.
/// </summary>
[JsonPropertyName("tutorial_step")]
[Key("tutorial_step")]
public int? TutorialStep { get; set; }
}
/// <summary>
/// Per-reward summary. Prod wire shape: reward_type/reward_detail_id/reward_count are ints
/// (NOT strings, unlike PresentDto). item_type is int (0 for currency, 1/2 for items).
/// </summary>
[MessagePackObject]
public class TotalReceiveCountDto
{
[JsonPropertyName("reward_type")]
[Key("reward_type")]
public int RewardType { get; set; }
[JsonPropertyName("reward_detail_id")]
[Key("reward_detail_id")]
public long RewardDetailId { get; set; }
[JsonPropertyName("reward_count")]
[Key("reward_count")]
public long RewardCount { get; set; }
/// <summary>0 for currency rewards, 1 or 2 for item rewards. Prod wire is int; the client's .ToInt() handles both int and string values.</summary>
[JsonPropertyName("item_type")]
[Key("item_type")]
public int ItemType { get; set; }
[JsonPropertyName("is_usable")]
[Key("is_usable")]
public bool IsUsable { get; set; } = true;
}
/// <summary>
/// Entries in /tutorial/gift_receive's reward_list. Wire shape: reward_type and reward_id are
/// STRINGS, reward_num is INT for currency entries (type 1, 9) and STRING for item entries
/// (type 4). Use string for reward_num to handle both — the client tolerates string→int parse.
/// </summary>
[MessagePackObject]
public class GiftRewardListEntry
{
[JsonPropertyName("reward_type")]
[Key("reward_type")]
public string RewardType { get; set; } = string.Empty;
[JsonPropertyName("reward_id")]
[Key("reward_id")]
public string RewardId { get; set; } = "0";
[JsonPropertyName("reward_num")]
[Key("reward_num")]
public string RewardNum { get; set; } = "0";
}

View File

@@ -0,0 +1,70 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Gift;
[MessagePackObject]
public class GiftTopResponse
{
[JsonPropertyName("present_list")]
[Key("present_list")]
public List<PresentDto> PresentList { get; set; } = new();
[JsonPropertyName("present_history_list")]
[Key("present_history_list")]
public List<PresentDto> PresentHistoryList { get; set; } = new();
[JsonPropertyName("limit_over_present_list")]
[Key("limit_over_present_list")]
public List<PresentDto> LimitOverPresentList { get; set; } = new();
}
/// <summary>
/// Prod sends most numeric-looking fields as STRINGS on this endpoint
/// (present_id, reward_type, reward_detail_id, reward_count, condition_number,
/// present_limit_type). item_type is an INT. We mirror the prod shape exactly.
/// </summary>
[MessagePackObject]
public class PresentDto
{
[JsonPropertyName("present_id")]
[Key("present_id")]
public string PresentId { get; set; } = string.Empty;
[JsonPropertyName("reward_type")]
[Key("reward_type")]
public string RewardType { get; set; } = string.Empty;
[JsonPropertyName("reward_detail_id")]
[Key("reward_detail_id")]
public string RewardDetailId { get; set; } = string.Empty;
[JsonPropertyName("reward_count")]
[Key("reward_count")]
public string RewardCount { get; set; } = string.Empty;
[JsonPropertyName("condition_number")]
[Key("condition_number")]
public string ConditionNumber { get; set; } = "0";
[JsonPropertyName("present_limit_type")]
[Key("present_limit_type")]
public string PresentLimitType { get; set; } = "0";
[JsonPropertyName("reward_limit_time")]
[Key("reward_limit_time")]
public int RewardLimitTime { get; set; }
[JsonPropertyName("create_time")]
[Key("create_time")]
public string CreateTime { get; set; } = string.Empty;
/// <summary>Only present on item/pack-ticket entries (gifts where reward_type=4); omit on currency entries.</summary>
[JsonPropertyName("item_type")]
[Key("item_type")]
public int? ItemType { get; set; }
[JsonPropertyName("message")]
[Key("message")]
public string Message { get; set; } = string.Empty;
}

View File

@@ -267,8 +267,14 @@ public class MyPageIndexResponse
// ── Per-viewer / event state ───────────────────────────────────────────
/// <summary>
/// Updated item counts. Empty list = "no items to update" (client iterates 0 times, no UI change).
/// Per-viewer state — populate from viewer.Items when that wiring lands.
/// Full snapshot of the viewer's owned items — NOT a delta. The client's
/// <c>MyPageTask.Parse</c> (line 155-163) clears <c>_userItemDict</c> the moment it sees
/// this key, then re-populates from the wire list. Emitting <c>[]</c> wipes whatever
/// /load/index populated, breaking any client logic that reads from the dict — most
/// load-bearingly <c>PackChildGachaInfo.CostGoodsCount</c>, which gates tutorial-pack
/// visibility via <c>PackConfig.EnableBuyPack</c>. Controllers MUST populate the full
/// owned-items snapshot from <c>viewer.Items</c>; an empty list is correct only when the
/// viewer genuinely owns nothing.
/// </summary>
[JsonPropertyName("user_item_list")]
[Key("user_item_list")]

View File

@@ -26,6 +26,14 @@ public class PackOpenResponse
[JsonPropertyName("mission_result")]
[Key("mission_result")]
public List<object> MissionResult { get; set; } = new();
/// <summary>
/// Set only on the /tutorial/pack_open path to signal the END (100) transition inline with
/// the pack reward. Global WhenWritingNull keeps it off the wire on regular /pack/open.
/// </summary>
[JsonPropertyName("tutorial_step")]
[Key("tutorial_step")]
public int? TutorialStep { get; set; }
}
[MessagePackObject]

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
using MessagePack;
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.Tutorial;
/// <summary>
/// Server echoes the new step. Capture confirms exact value mirror — no validation,
/// no munging. <c>tutorial_replay_step</c> is in the spec as optional but the live capture
/// never includes it; omit unless we observe a need.
/// </summary>
[MessagePackObject]
public class TutorialUpdateResponse
{
[JsonPropertyName("tutorial_step")]
[Key("tutorial_step")]
public int TutorialStep { get; set; }
}

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using MessagePack;
using SVSim.Database.Models;
using System.Text.Json.Serialization;
@@ -7,6 +8,9 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos;
[MessagePackObject]
public class UserInfo
{
/// <summary>Wire format prod uses for the two datetime fields here. No 'T', no fractions, no zone.</summary>
private const string ProdDateTimeFormat = "yyyy-MM-dd HH:mm:ss";
[JsonPropertyName("device_type")]
[Key("device_type")]
public int DeviceType { get; set; }
@@ -19,9 +23,15 @@ public class UserInfo
[JsonPropertyName("max_friend")]
[Key("max_friend")]
public int MaxFriend { get; set; }
/// <summary>
/// Wire format <c>"yyyy-MM-dd HH:mm:ss"</c> (space-separated, no 'T', no Z, no fractions).
/// Null for fresh accounts that have never played — prod omits/nulls this rather than
/// emitting <c>DateTime.MinValue</c> with .NET's default ISO-8601-with-Z serialization,
/// which can crash the client's DateTime parser.
/// </summary>
[JsonPropertyName("last_play_time")]
[Key("last_play_time")]
public DateTime LastPlayTime { get; set; }
public string? LastPlayTime { get; set; }
[JsonPropertyName("is_received_two_pick_mission")]
[Key("is_received_two_pick_mission")]
public int HasReceivedPickTwoMission { get; set; }
@@ -38,9 +48,10 @@ public class UserInfo
[JsonPropertyName("selected_degree_id")]
[Key("selected_degree_id")]
public int SelectedDegreeId { get; set; }
/// <summary>Same format/null rules as <see cref="LastPlayTime"/>.</summary>
[JsonPropertyName("mission_change_time")]
[Key("mission_change_time")]
public DateTime MissionChangeTime { get; set; }
public string? MissionChangeTime { get; set; }
[JsonPropertyName("mission_receive_type")]
[Key("mission_receive_type")]
public int MissionReceiveType { get; set; }
@@ -61,14 +72,17 @@ public class UserInfo
this.Name = viewer.DisplayName;
this.CountryCode = viewer.Info.CountryCode;
this.MaxFriend = viewer.Info.MaxFriends;
this.LastPlayTime = viewer.LastLogin;
this.LastPlayTime = FormatProdDateTime(viewer.LastLogin);
this.HasReceivedPickTwoMission = viewer.MissionData.HasReceivedPickTwoMission ? 1 : 0;
this.Birthday = viewer.Info.BirthDate.ToString("yyyy-MM-dd");
this.SelectedEmblemId = viewer.Info.SelectedEmblem.Id;
this.SelectedDegreeId = viewer.Info.SelectedDegree.Id;
this.MissionChangeTime = viewer.MissionData.MissionChangeTime;
this.MissionChangeTime = FormatProdDateTime(viewer.MissionData.MissionChangeTime);
this.MissionReceiveType = viewer.MissionData.MissionReceiveType;
this.IsOfficial = viewer.Info.IsOfficial ? 1 : 0;
this.IsOfficialMarkDisplayed = viewer.Info.IsOfficialMarkDisplayed ? 1 : 0;
}
private static string? FormatProdDateTime(DateTime dt)
=> dt == default ? null : dt.ToString(ProdDateTimeFormat, CultureInfo.InvariantCulture);
}

View File

@@ -102,6 +102,11 @@ public class Program
builder.Services.AddSingleton<IRandom, SystemRandom>();
builder.Services.AddSingleton<PuzzleMissionEvaluator>();
// Deck-code mint/resolve uses IMemoryCache for ephemeral (3-min TTL) storage; no DB
// row, no migration. Singleton because the cache + RNG seam are process-wide.
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IDeckCodeService, DeckCodeService>();
#endregion
builder.Services.AddTransient<ShadowverseTranslationMiddleware>();

View File

@@ -16,11 +16,31 @@ public class DbCardPoolProvider : ICardPoolProvider
{
case PackCategory.None:
case PackCategory.LegendCardPack:
return _db.CardSets
{
var pool = _db.CardSets
.Where(s => s.Id == pack.BasePackId)
.SelectMany(s => s.Cards)
.Where(c => !c.IsFoil)
.ToList();
if (pool.Count > 0) return pool;
// BasePackId 90001 (and the 9xxxx range generally) is a synthetic "Throwback
// Rotation" category that doesn't have a corresponding real card_set in the
// prod card master — its real pool is a curated subset of rotation-eligible
// older sets (AltersphereColosseum for 99047; see the gacha_detail string).
// We don't have that membership map, so fall back to all in-rotation cards.
// Broader pool than prod but produces a valid 8-card draw, which is what the
// tutorial flow needs to advance to step 100.
// TODO: import the real Throwback Rotation card-set membership and key the
// pool off that. Source data is in the client's pack-pool master, not yet
// captured.
return _db.CardSets
.Where(s => s.IsInRotation)
.SelectMany(s => s.Cards)
.Where(c => !c.IsFoil)
.Distinct()
.ToList();
}
case PackCategory.SpecialCardPack:
case PackCategory.LimitedSpecialCardPack:

View File

@@ -0,0 +1,62 @@
using Microsoft.Extensions.Caching.Memory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder;
namespace SVSim.EmulatedEntrypoint.Services;
/// <summary>
/// In-memory deck-code store with a 3-minute absolute TTL. Codes are lowercase 4-character
/// alphanumeric tokens — matches the shortest sample observed in prod (e.g. "t7rz" in
/// data_dumps/traffic_prod_deckcode.ndjson). The portal's anonymous global namespace is
/// mirrored here: codes are not scoped to viewer.
/// </summary>
public sealed class DeckCodeService : IDeckCodeService
{
public static readonly TimeSpan Ttl = TimeSpan.FromMinutes(3);
private const string Alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
private const int CodeLength = 4; // 36^4 ≈ 1.7M codes
private const int MaxMintAttempts = 8; // collision retries — saturation is genuinely exceptional
private readonly IMemoryCache _cache;
private readonly IRandom _random;
public DeckCodeService(IMemoryCache cache, IRandom random)
{
_cache = cache;
_random = random;
}
public string Mint(DeckPayload payload)
{
for (int attempt = 0; attempt < MaxMintAttempts; attempt++)
{
string code = GenerateCode();
string key = CacheKey(code);
if (_cache.TryGetValue(key, out _)) continue;
_cache.Set(key, payload, Ttl);
return code;
}
// Hit only if the 4-char namespace is genuinely saturated within a 3-minute window.
// At that load we'd want longer codes; throw loudly so the symptom doesn't get buried.
throw new InvalidOperationException(
$"Deck-code namespace saturated after {MaxMintAttempts} attempts. " +
"Either traffic exploded or the cache is misconfigured.");
}
public DeckPayload? TryResolve(string code)
=> _cache.TryGetValue<DeckPayload>(CacheKey(code), out var payload) ? payload : null;
private string GenerateCode()
{
Span<char> buf = stackalloc char[CodeLength];
for (int i = 0; i < CodeLength; i++)
{
buf[i] = Alphabet[_random.Next(Alphabet.Length)];
}
return new string(buf);
}
internal static string CacheKey(string code) => $"deck_code:{code}";
}

View File

@@ -0,0 +1,17 @@
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder;
namespace SVSim.EmulatedEntrypoint.Services;
public interface IDeckCodeService
{
/// <summary>
/// Stores <paramref name="payload"/> under a freshly minted token and returns it. The token
/// is valid for <see cref="DeckCodeService.Ttl"/> from this call.
/// </summary>
string Mint(DeckPayload payload);
/// <summary>
/// Returns the deck payload for an unexpired code, or null on miss/expired.
/// </summary>
DeckPayload? TryResolve(string code);
}

View File

@@ -1,14 +1,40 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
namespace SVSim.EmulatedEntrypoint.Services;
public class ShadowverseSessionService
{
private readonly ConcurrentDictionary<string, Guid> _sessionIdToUdid;
/// <summary>
/// Salt the client's <c>Cute/Cryptographer.MakeMd5</c> appends to every input before hashing.
/// Must match the decompiled client exactly — the server computes SIDs that the client
/// also computes locally for its outgoing request headers, and any mismatch breaks decrypt.
/// </summary>
private const string MakeMd5Salt = "r!I@ws8e5i=";
public ShadowverseSessionService()
/// <summary>
/// Default cap for the in-memory SID→UDID map. Each entry is roughly 32B SID + 16B Guid
/// plus dict + queue overhead — 10k entries ≈ 1 MB of process memory. Sized for the
/// emulator's expected ceiling, not prod scale. Long-running dev hosts that keep
/// accumulating signups would otherwise grow this dict unboundedly.
/// </summary>
public const int DefaultMaxEntries = 10_000;
private readonly int _maxEntries;
private readonly ConcurrentDictionary<string, Guid> _sessionIdToUdid;
private readonly ConcurrentQueue<string> _insertionOrder;
public ShadowverseSessionService() : this(DefaultMaxEntries) { }
public ShadowverseSessionService(int maxEntries)
{
if (maxEntries <= 0)
throw new ArgumentOutOfRangeException(nameof(maxEntries), "Cap must be positive.");
_maxEntries = maxEntries;
_sessionIdToUdid = new();
_insertionOrder = new();
}
public Guid? GetUdidFromSessionId(string sid)
@@ -23,6 +49,52 @@ public class ShadowverseSessionService
public void StoreUdidForSessionId(string sid, Guid udid)
{
_sessionIdToUdid.AddOrUpdate(sid, _ => udid, (_, _) => udid);
// FIFO eviction: only enqueue on first insertion so the queue doesn't grow when
// an existing SID is re-stored (the only realistic "update" — same SID always
// resolves to the same UDID by construction of ComputeClientSessionId, so this
// path is effectively a no-op semantically).
if (_sessionIdToUdid.TryAdd(sid, udid))
{
_insertionOrder.Enqueue(sid);
EvictIfOverCap();
}
else
{
_sessionIdToUdid[sid] = udid;
}
}
private void EvictIfOverCap()
{
while (_sessionIdToUdid.Count > _maxEntries && _insertionOrder.TryDequeue(out var oldest))
{
_sessionIdToUdid.TryRemove(oldest, out _);
}
}
/// <summary>
/// Replicates the client's <c>Cute/Certification.SessionId</c> getter:
/// <c>MakeMd5(viewerId.ToString() + udid.ToString("D"))</c>. Returned as lowercase hex.
/// The client computes this once after signup and sends it as the SID header on every
/// subsequent request — the server must produce the same value to map back to the UDID.
/// </summary>
public string ComputeClientSessionId(long viewerId, Guid udid)
{
string input = viewerId.ToString(CultureInfo.InvariantCulture)
+ udid.ToString("D")
+ MakeMd5Salt;
byte[] hash = MD5.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexString(hash).ToLowerInvariant();
}
/// <summary>
/// Pre-stores the SID→UDID mapping the client will use for its first SID-only request
/// after <c>/tool/signup</c>. Without this, the translation middleware can't decrypt the
/// next request body (no UDID header, no mapping, falls back to <c>Guid.Empty</c>).
/// </summary>
public void StoreSessionForViewer(long viewerId, Guid udid)
{
string sid = ComputeClientSessionId(viewerId, udid);
StoreUdidForSessionId(sid, udid);
}
}

View File

@@ -0,0 +1,109 @@
using System.Net;
using System.Text;
using System.Text.Json;
using NUnit.Framework;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
public class AccountControllerTests
{
[Test]
public async Task UpdateName_writes_display_name()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = """{"name":"littlefootse","viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/account/update_name",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
// Verify persisted name.
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
Assert.That(viewer.DisplayName, Is.EqualTo("littlefootse"));
}
[TestCase("")]
[TestCase(" ")]
[TestCase("\t\n")]
public async Task UpdateName_rejects_empty_or_whitespace(string name)
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = $$"""{"name":{{JsonSerializer.Serialize(name)}},"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/account/update_name",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
// Display name remains the seeded default ("Test Viewer").
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
Assert.That(viewer.DisplayName, Is.EqualTo("Test Viewer"));
}
[Test]
public async Task UpdateName_rejects_explicit_null()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
using var client = factory.CreateAuthenticatedClient(viewerId);
// Explicit JSON null — used to NRE when the controller assigned request.Name
// (default string.Empty) straight to viewer.DisplayName without a null check.
var requestJson = """{"name":null,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/account/update_name",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task UpdateName_rejects_too_long_name()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
using var client = factory.CreateAuthenticatedClient(viewerId);
// 25 chars > the 24-char server cap.
var name = new string('a', 25);
var requestJson = $$"""{"name":"{{name}}","viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/account/update_name",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task UpdateName_accepts_name_at_cap_boundary()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
using var client = factory.CreateAuthenticatedClient(viewerId);
// Exactly 24 chars — boundary case.
var name = new string('a', 24);
var requestJson = $$"""{"name":"{{name}}","viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/account/update_name",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
Assert.That(viewer.DisplayName, Is.EqualTo(name));
}
}

View File

@@ -0,0 +1,109 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.DeckBuilder;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// End-to-end coverage for the portal pair (/deck_code, /deck). These tests bypass the
/// translation middleware (non-Unity UA) and hit the controllers via plain JSON, which is fine
/// — both endpoints are anonymous and the action signatures don't care which path serialized
/// the body. The middleware's [NoWireEncryption] branch is exercised in the live smoke test.
/// </summary>
public class DeckBuilderControllerTests
{
private static readonly JsonSerializerOptions Json = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
[Test]
public async Task Generate_then_resolve_roundtrips_deck_payload()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient();
var generate = await client.PostAsJsonAsync("/deck_code",
new GenerateDeckCodeRequest
{
Clan = 4,
DeckFormat = 1,
CardID = new() { 100414020, 100414020, 104021030 }
}, Json);
Assert.That(generate.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await generate.Content.ReadAsStringAsync());
var generateBody = await generate.Content.ReadFromJsonAsync<GenerateDeckCodeResponse>(Json);
Assert.That(generateBody, Is.Not.Null);
Assert.That(generateBody!.DeckCode, Has.Length.EqualTo(4));
var resolve = await client.PostAsJsonAsync("/deck",
new GetDeckFromCodeRequest { DeckCode = generateBody.DeckCode }, Json);
Assert.That(resolve.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await resolve.Content.ReadAsStringAsync());
var resolveBody = await resolve.Content.ReadFromJsonAsync<GetDeckFromCodeResponse>(Json);
Assert.That(resolveBody, Is.Not.Null);
Assert.That(resolveBody!.Deck.Clan, Is.EqualTo("4"));
Assert.That(resolveBody.Deck.DeckFormat, Is.EqualTo("1"));
Assert.That(resolveBody.Deck.SubClan, Is.EqualTo(0));
Assert.That(resolveBody.Deck.CardID, Is.EqualTo(new List<long> { 100414020, 100414020, 104021030 }));
}
[Test]
public async Task Generate_strips_foil_flag_from_card_ids()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient();
var generate = await client.PostAsJsonAsync("/deck_code",
new GenerateDeckCodeRequest
{
Clan = 4,
DeckFormat = 1,
// 011 ids are foil variants observed in the prod traffic dump.
CardID = new() { 703441011, 701441011, 100414020 }
}, Json);
var generateBody = await generate.Content.ReadFromJsonAsync<GenerateDeckCodeResponse>(Json);
var resolve = await client.PostAsJsonAsync("/deck",
new GetDeckFromCodeRequest { DeckCode = generateBody!.DeckCode }, Json);
var resolveBody = await resolve.Content.ReadFromJsonAsync<GetDeckFromCodeResponse>(Json);
Assert.That(resolveBody!.Deck.CardID,
Is.EqualTo(new List<long> { 703441010, 701441010, 100414020 }),
"Foil bit (last digit) must be normalized to 0 in the stored payload.");
}
[Test]
public async Task Resolve_returns_invalid_code_error_for_unknown_code()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient();
var resolve = await client.PostAsJsonAsync("/deck",
new GetDeckFromCodeRequest { DeckCode = "zzzz" }, Json);
Assert.That(resolve.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var body = await resolve.Content.ReadFromJsonAsync<GetDeckFromCodeResponse>(Json);
Assert.That(body!.Errors.Type, Is.EqualTo("INVALID_DECK_CODE"));
}
[Test]
public async Task Generate_rejects_empty_card_list()
{
using var factory = new SVSimTestFactory();
using var client = factory.CreateClient();
var generate = await client.PostAsJsonAsync("/deck_code",
new GenerateDeckCodeRequest { Clan = 1, DeckFormat = 1, CardID = new() }, Json);
var body = await generate.Content.ReadFromJsonAsync<GenerateDeckCodeResponse>(Json);
Assert.That(body!.Errors.Type, Is.EqualTo("INVALID_DECK"));
Assert.That(body.DeckCode, Is.Empty);
}
}

View File

@@ -0,0 +1,33 @@
using System.Net;
using System.Text;
using System.Text.Json;
using NUnit.Framework;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
public class DownloadTimeControllerTests
{
[TestCase("/download_time/start")]
[TestCase("/download_time/end")]
public async Task Returns_200_with_empty_data_object(string path)
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync();
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync(path,
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
await response.Content.ReadAsStringAsync());
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Object));
Assert.That(doc.RootElement.EnumerateObject().Count(), Is.EqualTo(0),
"Spec calls for empty `data: {}` — DownloadStartTask's optional image_type stays " +
"absent, DownloadFinishTask doesn't read data at all.");
}
}

View File

@@ -0,0 +1,243 @@
using System.Net;
using System.Text;
using System.Text.Json;
using NUnit.Framework;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
public class GiftControllerTests
{
private const string BaseAuthBlock =
@"""viewer_id"":""0"",""steam_id"":0,""steam_session_ticket"":""""";
[Test]
public async Task GiftTop_returns_five_tutorial_gifts_for_unclaimed_viewer()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 31);
using var client = factory.CreateAuthenticatedClient(viewerId);
var response = await client.PostAsync("/tutorial/gift_top",
new StringContent($$"""{"page":1,{{BaseAuthBlock}}}""", Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = doc.RootElement;
var presents = root.GetProperty("present_list");
Assert.That(presents.GetArrayLength(), Is.EqualTo(5));
// Expect the legendary pack entry (present_id 71478630) to be present.
bool foundLegendaryGift = false;
foreach (var p in presents.EnumerateArray())
{
if (p.GetProperty("present_id").GetString() == "71478630")
{
foundLegendaryGift = true;
Assert.That(p.GetProperty("reward_type").GetString(), Is.EqualTo("4"));
Assert.That(p.GetProperty("reward_detail_id").GetString(), Is.EqualTo("90001"));
Assert.That(p.GetProperty("reward_count").GetString(), Is.EqualTo("1"));
Assert.That(p.GetProperty("item_type").GetInt32(), Is.EqualTo(2));
Assert.That(p.GetProperty("message").GetString(), Is.EqualTo("For completing the tutorial"));
}
}
Assert.That(foundLegendaryGift, Is.True, "Legendary starter pack gift (71478630) must be in present_list.");
Assert.That(root.GetProperty("present_history_list").GetArrayLength(), Is.EqualTo(0));
Assert.That(root.GetProperty("limit_over_present_list").GetArrayLength(), Is.EqualTo(0));
}
[Test]
public async Task GiftReceive_grants_currency_and_items_then_history_is_populated()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 31);
using var client = factory.CreateAuthenticatedClient(viewerId);
var pre = await factory.GetViewerCurrencyAsync(viewerId);
var requestJson = $$"""
{"present_id_array":["71478626","71478627","71478628","71478629","71478630"],"state":1,{{BaseAuthBlock}}}
""";
var response = await client.PostAsync("/tutorial/gift_receive",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = doc.RootElement;
// Five received ids echoed.
var ids = root.GetProperty("received_ids").EnumerateArray()
.Select(e => e.GetString()).ToHashSet();
Assert.That(ids, Is.EquivalentTo(new[] { "71478626", "71478627", "71478628", "71478629", "71478630" }));
// present_list emptied, history populated.
Assert.That(root.GetProperty("present_list").GetArrayLength(), Is.EqualTo(0));
Assert.That(root.GetProperty("present_history_list").GetArrayLength(), Is.EqualTo(5));
// Currency credited: +400 crystals, +100 rupees.
var post = await factory.GetViewerCurrencyAsync(viewerId);
Assert.That(post.Crystals - pre.Crystals, Is.EqualTo(400UL));
Assert.That(post.Rupees - pre.Rupees, Is.EqualTo(100UL));
// reward_list carries post-state TOTALS, not deltas, per project_wire_reward_list_post_state.
// After claiming gifts, the crystal/rupy entries in reward_list should equal viewer's post-grant totals.
var rewardList = root.GetProperty("reward_list").EnumerateArray().ToList();
var crystalEntry = rewardList.First(e => e.GetProperty("reward_type").GetString() == "1");
var rupyEntry = rewardList.First(e => e.GetProperty("reward_type").GetString() == "9");
Assert.That(crystalEntry.GetProperty("reward_num").GetString(),
Is.EqualTo(post.Crystals.ToString()),
"reward_list currency entries must carry POST-STATE TOTALS, not gift deltas (client does direct assignment).");
Assert.That(rupyEntry.GetProperty("reward_num").GetString(),
Is.EqualTo(post.Rupees.ToString()));
}
[Test]
public async Task GiftReceive_advances_tutorial_state_from_31_to_41()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 31);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = $$"""{"present_id_array":["71478626"],"state":1,{{BaseAuthBlock}}}""";
var response = await client.PostAsync("/tutorial/gift_receive",
new StringContent(json, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
var root = doc.RootElement;
// Response carries the new step inline.
Assert.That(root.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(41));
// Only 1 of 5 gifts claimed → 4 remain unclaimed → badge state must be "still has presents".
Assert.That(root.GetProperty("is_unreceived_present").GetBoolean(), Is.True,
"Partial claim leaves 4 gifts unclaimed in present_list — is_unreceived_present " +
"must reflect that so the client's inbox badge keeps surfacing.");
Assert.That(root.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(1));
Assert.That(root.GetProperty("present_list").GetArrayLength(), Is.EqualTo(4));
// Side effect: viewer state advanced to 41.
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(41));
}
[Test]
public async Task GiftReceive_returns_empty_received_ids_on_idempotent_replay()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 31);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = $$"""{"present_id_array":["71478626","71478627"],"state":1,{{BaseAuthBlock}}}""";
// First call grants both gifts.
await client.PostAsync("/tutorial/gift_receive",
new StringContent(json, Encoding.UTF8, "application/json"));
// Second call (replay) must return empty received_ids / total_receive_count_list /
// reward_list — these lists describe what THIS call granted, not what the client
// asked for. Echoing requested ids would re-fire the client's "received N gifts"
// popup and direct-assign the same post-state totals again.
var second = await client.PostAsync("/tutorial/gift_receive",
new StringContent(json, Encoding.UTF8, "application/json"));
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var doc = JsonDocument.Parse(await second.Content.ReadAsStringAsync());
var root = doc.RootElement;
Assert.That(root.GetProperty("received_ids").GetArrayLength(), Is.EqualTo(0),
"Idempotent re-claim grants nothing → received_ids empty.");
Assert.That(root.GetProperty("total_receive_count_list").GetArrayLength(), Is.EqualTo(0));
Assert.That(root.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0));
// present_history_list still includes the originally-claimed gifts.
Assert.That(root.GetProperty("present_history_list").GetArrayLength(), Is.EqualTo(2));
}
[Test]
public async Task GiftReceive_echoes_persisted_tutorial_step_not_hardcoded_41()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
// Viewer is past the tutorial entirely (state=100). The gift_receive endpoint is
// still reachable via /tutorial/gift_receive — a stale client retry, for instance.
// The persistence side max-preserves (keeps state at 100); the response must echo
// 100, not the hardcoded 41 the endpoint used to emit, or the client's tutorial
// state machine regresses on a no-op retry.
long viewerId = await factory.SeedViewerAsync(tutorialState: 100);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = $$"""{"present_id_array":["71478626"],"state":1,{{BaseAuthBlock}}}""";
var response = await client.PostAsync("/tutorial/gift_receive",
new StringContent(json, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.That(doc.RootElement.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(100));
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100));
}
[Test]
public async Task GiftReceive_with_pre_owned_item_increments_existing_row()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 31);
// Seed item 1 (= the 3-item gift's reward_detail_id) with count=5 pre-existing.
// Any non-tutorial source could leave a viewer here — battlepass, future reward,
// admin import. Gift 71478628 grants +3 of item 1; the existing row must be
// found and incremented, not duplicated. The (ViewerId, ItemId) unique index
// added 2026-05-25 would otherwise throw on SaveChanges → 500 to the client.
await factory.SeedOwnedItemAsync(viewerId, itemId: 1, count: 5, itemName: "PreOwnedItem");
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = $$"""{"present_id_array":["71478628"],"state":1,{{BaseAuthBlock}}}""";
var response = await client.PostAsync("/tutorial/gift_receive",
new StringContent(json, Encoding.UTF8, "application/json"));
var bodyStr = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), bodyStr);
// Existing row was incremented to 8 (5 + 3), not duplicated.
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 1), Is.EqualTo(8),
"Pre-existing OwnedItemEntry must be found via the ThenIncluded Item nav; " +
"otherwise RewardGrantService falls through to add a new row and the " +
"(ViewerId, ItemId) unique index throws on SaveChanges.");
// reward_list reflects the post-state total (8), not the gift delta (3).
using var doc = JsonDocument.Parse(bodyStr);
var itemEntry = doc.RootElement.GetProperty("reward_list").EnumerateArray()
.First(e => e.GetProperty("reward_type").GetString() == "4"
&& e.GetProperty("reward_id").GetString() == "1");
Assert.That(itemEntry.GetProperty("reward_num").GetString(), Is.EqualTo("8"),
"RewardNum carries the POST-STATE TOTAL — client direct-assigns it onto the " +
"cached count, so emitting the delta would clobber on-screen inventory.");
}
[Test]
public async Task GiftReceive_second_call_with_same_ids_does_not_double_grant()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 31);
using var client = factory.CreateAuthenticatedClient(viewerId);
var preFirst = await factory.GetViewerCurrencyAsync(viewerId);
var json = $$"""{"present_id_array":["71478626","71478627"],"state":1,{{BaseAuthBlock}}}""";
await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json"));
var midPost = await factory.GetViewerCurrencyAsync(viewerId);
Assert.That(midPost.Crystals - preFirst.Crystals, Is.EqualTo(400UL));
Assert.That(midPost.Rupees - preFirst.Rupees, Is.EqualTo(100UL));
var second = await client.PostAsync("/tutorial/gift_receive", new StringContent(json, Encoding.UTF8, "application/json"));
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var finalPost = await factory.GetViewerCurrencyAsync(viewerId);
Assert.That(finalPost.Crystals, Is.EqualTo(midPost.Crystals), "Second claim of same present_ids must not re-grant.");
Assert.That(finalPost.Rupees, Is.EqualTo(midPost.Rupees));
}
}

View File

@@ -0,0 +1,229 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
public class PackControllerTests
{
[Test]
public async Task PackInfo_item_number_reflects_owned_ticket_count()
{
// Verifies the ownedItemsByItemId projection in PackController.Info — the dict that
// drives child_gacha_info.item_number. Tutorial flow filters packs by item_number > 0,
// so a regression on the projection (e.g. nav-eval collapsing to 0) silently hides
// any pack that requires a ticket.
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
// Seed item 90001 with count 7 — the legendary starter ticket the tutorial gift grants.
await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 7, itemName: "Starter Legendary Ticket");
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/pack/info",
new StringContent(json, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
// Find pack 99047 (the starter legendary) and verify its child gacha reports item_number=7.
var pack99047 = doc.RootElement.GetProperty("pack_config_list").EnumerateArray()
.First(p => p.GetProperty("parent_gacha_id").GetInt32() == 99047);
var childWithTicket = pack99047.GetProperty("child_gacha_info").EnumerateArray()
.First(c => c.TryGetProperty("item_id", out var iid) && iid.GetString() == "90001");
Assert.That(childWithTicket.GetProperty("item_number").GetInt32(), Is.EqualTo(7),
"child_gacha_info.item_number must reflect the viewer's owned count of the gating " +
"item; client filters tutorial packs on item_number > 0.");
}
[Test]
public async Task TutorialPackInfo_returns_same_list_as_pack_info()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
using var client = factory.CreateAuthenticatedClient(viewerId);
var json = """{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var direct = await client.PostAsync("/pack/info", new StringContent(json, Encoding.UTF8, "application/json"));
var tutorial = await client.PostAsync("/tutorial/pack_info", new StringContent(json, Encoding.UTF8, "application/json"));
Assert.That(direct.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(tutorial.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var directBody = await direct.Content.ReadAsStringAsync();
var tutorialBody = await tutorial.Content.ReadAsStringAsync();
Assert.That(tutorialBody, Is.EqualTo(directBody),
"tutorial/pack_info wire shape must match /pack/info exactly (no filtering in v1).");
}
[Test]
public async Task TutorialPackOpen_grants_pack_and_sets_tutorial_step_100()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
// Seed the starter ticket the gift_receive step would have granted. /tutorial/pack_open
// is supposed to decrement this count by `pack_number` (1) and emit a post-state entry
// into reward_list (per project_wire_reward_list_post_state).
await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket");
// Pack 99047 (starter legendary) has base_pack_id=90001. The minimal card seed only
// creates set 10001, so we seed set 90001 explicitly for the pool resolver.
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.CardSets.Add(new ShadowverseCardSetEntry
{
Id = 90001,
Name = "TutorialStarterSet",
IsInRotation = true,
IsBasic = false,
Cards =
[
new ShadowverseCardEntry { Id = 90001001L, Name = "StarterCard1", Rarity = Rarity.Bronze },
new ShadowverseCardEntry { Id = 90001002L, Name = "StarterCard2", Rarity = Rarity.Gold },
new ShadowverseCardEntry { Id = 90001003L, Name = "StarterCard3", Rarity = Rarity.Legendary },
],
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/tutorial/pack_open",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
var body = await response.Content.ReadAsStringAsync();
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.That(root.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(100),
"tutorial/pack_open must include tutorial_step=100 in data — this is the END transition.");
Assert.That(root.GetProperty("pack_list").GetArrayLength(), Is.EqualTo(8),
"Starter pack 99047/990047 delivers 8 cards (child_gacha.card_count=8).");
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100));
// Ticket decrement: the legendary starter ticket (90001) should be consumed.
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(0),
"Tutorial pack_open must decrement the gating ticket; otherwise /tutorial/pack_info " +
"keeps showing the pack and the client re-clicks into /pack/open (501 on type_detail=5).");
// reward_list must carry a post-state item entry for the ticket. RewardType=4 (Item),
// RewardId=90001, RewardNum=0 (post-state total, NOT delta).
var rewardList = root.GetProperty("reward_list");
var ticketEntry = rewardList.EnumerateArray()
.FirstOrDefault(e => e.GetProperty("reward_type").GetInt32() == 4
&& e.GetProperty("reward_id").GetInt64() == 90001);
Assert.That(ticketEntry.ValueKind, Is.Not.EqualTo(JsonValueKind.Undefined),
"reward_list must include a type=4 entry for the consumed ticket (90001) so the " +
"client's _userItemDict updates immediately — project_wire_reward_list_post_state.");
Assert.That(ticketEntry.GetProperty("reward_num").GetInt32(), Is.EqualTo(0),
"RewardNum is the post-state TOTAL, not the delta consumed.");
}
[Test]
public async Task NonTutorial_pack_open_does_not_emit_tutorial_step()
{
// Verify that regular /pack/open still works AND does not include tutorial_step in the response.
// Use the tutorial pack (99047/990047) which has type_detail=5 — the non-tutorial path
// still hits the currency_path_not_implemented guard and returns 501.
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 100);
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/pack/open",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
// Non-tutorial pack/open + type_detail=5 STILL returns 501 — that's the established behavior.
Assert.That((int)response.StatusCode, Is.EqualTo(501),
"Non-tutorial /pack/open with type_detail=5 should still hit the currency_path_not_implemented guard.");
// Even on a 501, no tutorial_step field should appear in the response body.
var body = await response.Content.ReadAsStringAsync();
Assert.That(body.Contains("\"tutorial_step\""), Is.False,
"Regular /pack/open must never emit tutorial_step.");
}
[Test]
public async Task TutorialPackOpen_rejects_non_starter_parent_gacha_id()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 41);
using var client = factory.CreateAuthenticatedClient(viewerId);
// Pick any non-99047 parent_gacha_id seeded by SeedGlobalsAsync (10032 is the most
// recent crystal-multi pack in the catalog). The alias must reject it BadRequest.
var requestJson = """{"parent_gacha_id":10032,"gacha_id":100320,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/tutorial/pack_open",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
"Tutorial alias must only accept the starter pack (99047); otherwise any authenticated " +
"viewer can draw any pack for free via the currency-bypass tutorial path.");
// State must NOT have advanced.
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(41),
"Rejected requests leave TutorialState untouched.");
}
[Test]
public async Task TutorialPackOpen_rejects_completed_viewer()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 100);
await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket");
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/tutorial/pack_open",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
"Tutorial alias must reject viewers past the tutorial-end gate (state>=100); the path " +
"would otherwise re-clobber state and consume a ticket the viewer kept post-tutorial.");
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(1),
"Rejected requests do not consume tickets.");
}
[Test]
public async Task TutorialPackOpen_does_not_downgrade_state_past_100()
{
// This is the max-preserve check. A future state > 100 (e.g., a post-tutorial training
// sentinel) must not be clobbered down to 100. Today nothing in prod sets state above 100,
// so synthesize the case directly.
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
long viewerId = await factory.SeedViewerAsync(tutorialState: 200);
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/tutorial/pack_open",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
// Either the request is rejected (because state>=100, see Gate B above), OR — if the
// implementation reads the gate differently — at minimum the persisted state must not
// regress. Encode the load-bearing invariant: state never goes backwards.
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.GreaterThanOrEqualTo(200),
"TutorialState must not regress regardless of the alias's accept/reject decision.");
}
}

View File

@@ -0,0 +1,102 @@
using System.Net;
using System.Text;
using System.Text.Json;
using NUnit.Framework;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
public class TutorialControllerTests
{
[Test]
public async Task UpdateAction_returns_result_code_1_with_empty_data()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
using var client = factory.CreateAuthenticatedClient(viewerId);
// tutorial_step and tutorial_action_number are fire-and-forget bookkeeping fields;
// send representative values from the live capture (step=1, action=2).
var requestJson =
"""{"tutorial_step":1,"tutorial_action_number":2,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/tutorial/update_action",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var body = await response.Content.ReadAsStringAsync();
// Controllers return the INNER data payload; envelope is middleware's job.
// For the no-op shape the action returns an empty object.
using var doc = JsonDocument.Parse(body);
Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Object));
Assert.That(doc.RootElement.EnumerateObject().Count(), Is.EqualTo(0),
"update_action returns empty data — client uses SkipAllNetworkChecks and reads nothing.");
}
[TestCase(11)]
[TestCase(21)]
[TestCase(31)]
public async Task Update_echoes_requested_step_and_persists(int step)
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
using var client = factory.CreateAuthenticatedClient(viewerId);
var requestJson = $$"""
{"tutorial_step":{{step}},"is_skip":0,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}
""";
var response = await client.PostAsync("/tutorial/update",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.That(doc.RootElement.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(step));
// Side effect: viewer state advanced.
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(step));
}
[Test]
public async Task Update_with_is_skip_1_jumps_to_100()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
using var client = factory.CreateAuthenticatedClient(viewerId);
// The client sends the step it's MOVING TO. is_skip=1 means "skip the rest" — typically
// sent with tutorial_step=100 already (matches what `TutorialUpdateTask` does with the
// is_skip flag), so the server's job is just to honor whatever value is provided.
var requestJson = """{"tutorial_step":100,"is_skip":1,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/tutorial/update",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.That(doc.RootElement.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(100));
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100));
}
[Test]
public async Task Update_does_not_regress_step()
{
using var factory = new SVSimTestFactory();
long viewerId = await factory.SeedViewerAsync(tutorialState: 100);
using var client = factory.CreateAuthenticatedClient(viewerId);
// Stale/replayed request: client thinks state is still 11 and sends an update for it.
var requestJson = """{"tutorial_step":11,"is_skip":0,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var response = await client.PostAsync("/tutorial/update",
new StringContent(requestJson, Encoding.UTF8, "application/json"));
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
Assert.That(doc.RootElement.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(11),
"Response echoes the requested step (the client confirms its own transition).");
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100),
"Persisted state must NOT regress. Math.Max(current, requested) — mirrors the " +
"31→41 max-preserve pattern in GiftController.TutorialGiftReceive.");
}
}

View File

@@ -0,0 +1,115 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
public class TutorialFlowEndToEndTests
{
[Test]
public async Task FreshSignup_through_pack_open_reaches_tutorial_step_100()
{
using var factory = new SVSimTestFactory();
await factory.SeedGlobalsAsync();
// Fresh viewer at PRE_TUTORIAL_STEP (the real prod default after Task 1).
long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
// Pack 99047 (starter legendary) has base_pack_id=90001. Seed the card set used by
// the tutorial pack pool resolver — mirrors the pattern in PackControllerTests.
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
db.CardSets.Add(new ShadowverseCardSetEntry
{
Id = 90001,
Name = "TutorialStarterSet",
IsInRotation = true,
IsBasic = false,
Cards =
[
new ShadowverseCardEntry { Id = 90001001L, Name = "StarterCard1", Rarity = Rarity.Bronze },
new ShadowverseCardEntry { Id = 90001002L, Name = "StarterCard2", Rarity = Rarity.Gold },
new ShadowverseCardEntry { Id = 90001003L, Name = "StarterCard3", Rarity = Rarity.Legendary },
],
});
await db.SaveChangesAsync();
}
using var client = factory.CreateAuthenticatedClient(viewerId);
var preCurrency = await factory.GetViewerCurrencyAsync(viewerId);
// 1. /account/update_name (after name-entry screen).
var nameResp = await Post(client, "/account/update_name",
"""{"name":"e2e_test_user","viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
Assert.That(nameResp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
// 2. Step transitions observed in capture: 11 → 21 → 31.
foreach (var step in new[] { 11, 21, 31 })
{
var json = $$"""{"tutorial_step":{{step}},"is_skip":0,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var resp = await Post(client, "/tutorial/update", json);
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
Assert.That(doc.RootElement.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(step));
}
// 3. /tutorial/update_action — a couple of representative sub-step calls.
await Post(client, "/tutorial/update_action",
"""{"tutorial_step":1,"tutorial_action_number":2,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
// 4. /tutorial/gift_top — surface the bundle.
var topResp = await Post(client, "/tutorial/gift_top",
"""{"page":1,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
using (var doc = JsonDocument.Parse(await topResp.Content.ReadAsStringAsync()))
{
Assert.That(doc.RootElement.GetProperty("present_list").GetArrayLength(), Is.EqualTo(5));
}
// 5. /tutorial/gift_receive — claim them.
var receiveResp = await Post(client, "/tutorial/gift_receive",
"""{"present_id_array":["71478630","71478629","71478628","71478627","71478626"],"state":1,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
Assert.That(receiveResp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
var midCurrency = await factory.GetViewerCurrencyAsync(viewerId);
Assert.That(midCurrency.Crystals - preCurrency.Crystals, Is.EqualTo(400UL));
Assert.That(midCurrency.Rupees - preCurrency.Rupees, Is.EqualTo(100UL));
// gift_receive should also have advanced the tutorial step to 41 server-side.
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(41));
// 6. /tutorial/pack_info — show the 3 active packs.
var packInfoResp = await Post(client, "/tutorial/pack_info",
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""");
Assert.That(packInfoResp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
// 7. /tutorial/pack_open of the starter legendary pack — END transition.
var openBody = """{"parent_gacha_id":99047,"gacha_id":990047,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
var openResp = await Post(client, "/tutorial/pack_open", openBody);
var openRespBody = await openResp.Content.ReadAsStringAsync();
Assert.That(openResp.StatusCode, Is.EqualTo(HttpStatusCode.OK), openRespBody);
using (var doc = JsonDocument.Parse(openRespBody))
{
Assert.That(doc.RootElement.GetProperty("tutorial_step").GetInt32(), Is.EqualTo(100));
}
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.EqualTo(100),
"Viewer reaches TUTORIAL_END after the full flow.");
// The gift granted item 90001 count=1 (via /tutorial/gift_receive entry 71478630).
// /tutorial/pack_open consumes it; assert the ticket is gone post-flow.
Assert.That(await factory.GetOwnedItemCountAsync(viewerId, 90001), Is.EqualTo(0),
"Starter legendary ticket must be consumed by /tutorial/pack_open.");
}
private static Task<HttpResponseMessage> Post(HttpClient client, string url, string body)
=> client.PostAsync(url, new StringContent(body, Encoding.UTF8, "application/json"));
}

View File

@@ -150,7 +150,8 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
/// </summary>
public async Task<long> SeedViewerAsync(
ulong steamId = 76_561_198_000_000_001UL,
string displayName = "Test Viewer")
string displayName = "Test Viewer",
int tutorialState = 100)
{
long viewerId;
long shortUdid;
@@ -173,6 +174,20 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
await db.SaveChangesAsync();
}
// Third scope: write the requested TutorialState. The parameter defaults to 100 —
// the post-tutorial baseline that ~30 existing tests rely on — so callers that don't
// care about the tutorial step keep working unchanged. Pass tutorialState: 1 to seed
// a fresh-signup viewer, or any other value to land mid-tutorial. RegisterViewer's
// own default (set in BuildDefaultViewer) is irrelevant here because this override
// always runs.
using (var scope = Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers.Include(v => v.MissionData).FirstAsync(v => v.Id == viewerId);
viewer.MissionData.TutorialState = tutorialState;
await db.SaveChangesAsync();
}
return viewerId;
}
@@ -358,6 +373,76 @@ internal sealed class SVSimTestFactory : WebApplicationFactory<Program>
await db.SaveChangesAsync();
}
/// <summary>
/// Reads the viewer's current <c>TutorialState</c> from the DB.
/// Tests use this to verify that <c>/tutorial/update</c> persisted the step.
/// </summary>
public async Task<int> GetViewerTutorialStateAsync(long viewerId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers.Include(v => v.MissionData).FirstAsync(v => v.Id == viewerId);
return viewer.MissionData.TutorialState;
}
/// <summary>
/// Reads the viewer's current currency balances from the DB. Used by gift_receive tests
/// to assert delta grants after claiming tutorial presents.
/// </summary>
public async Task<(ulong Crystals, ulong Rupees, ulong RedEther)> GetViewerCurrencyAsync(long viewerId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers.FirstAsync(v => v.Id == viewerId);
return (viewer.Currency.Crystals, viewer.Currency.Rupees, viewer.Currency.RedEther);
}
/// <summary>
/// Seeds an OwnedItemEntry for the viewer. Inserts the ItemEntry master row if missing
/// (Type defaults to 2 = card-pack ticket since both tutorial gift items 80001 and 90001
/// are tickets). Tests use this to set up the ticket inventory that /tutorial/pack_open
/// is supposed to consume.
/// </summary>
public async Task SeedOwnedItemAsync(long viewerId, int itemId, int count, string itemName = "TestItem", int itemType = 2)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var item = await db.Items.FindAsync(itemId);
if (item is null)
{
item = new ItemEntry { Id = itemId, Name = itemName, Type = itemType };
db.Items.Add(item);
await db.SaveChangesAsync();
}
var viewer = await db.Viewers
.Include(v => v.Items).ThenInclude(i => i.Item)
.FirstAsync(v => v.Id == viewerId);
var existing = viewer.Items.FirstOrDefault(i => i.Item.Id == itemId);
if (existing is null)
{
viewer.Items.Add(new OwnedItemEntry { Item = item, Count = count, Viewer = viewer });
}
else
{
existing.Count = count;
}
await db.SaveChangesAsync();
}
/// <summary>
/// Reads the viewer's current owned count for <paramref name="itemId"/>. Returns 0 if no
/// row exists. Tests use this to assert ticket consumption after /tutorial/pack_open.
/// </summary>
public async Task<int> GetOwnedItemCountAsync(long viewerId, int itemId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var viewer = await db.Viewers
.Include(v => v.Items).ThenInclude(i => i.Item)
.FirstAsync(v => v.Id == viewerId);
return viewer.Items.FirstOrDefault(i => i.Item.Id == itemId)?.Count ?? 0;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);

View File

@@ -25,14 +25,19 @@ public class GameConfigurationJsonbTests
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
var byName = rows.ToDictionary(r => r.SectionName);
// One row per [ConfigSection]-marked POCO (8 sections today: Player, DefaultGrants,
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story).
// One row per [ConfigSection]-marked POCO (9 sections today: Player, DefaultGrants,
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig).
Assert.That(byName.Keys, Is.EquivalentTo(new[]
{
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
"MyRotationSchedule", "Story",
"MyRotationSchedule", "Story", "ResourceConfig",
}));
var resources = JsonSerializer.Deserialize<ResourceConfig>(byName["ResourceConfig"].ValueJson)!;
Assert.That(resources.RequiredResVer, Is.EqualTo("4670rPsPMVlRTd2"),
"ShippedDefaults RES_VER is the prod-captured (2026-05-28) Akamai manifest path " +
"— required by the client to load the asset manifest after a wiped/fresh install.");
var mrSchedule = JsonSerializer.Deserialize<MyRotationScheduleConfig>(byName["MyRotationSchedule"].ValueJson)!;
Assert.That(mrSchedule.FreeBattle.Begin, Is.EqualTo(new DateTime(2024, 5, 1, 20, 0, 0, DateTimeKind.Utc)),
"ShippedDefaults reproduces the 2026-05-23 prod capture so a fresh install ships with Custom Rotation enabled");

View File

@@ -141,6 +141,24 @@ public class ViewerRepositoryTests
await repo.RegisterAnonymousViewer(Guid.Empty));
}
[Test]
public async Task RegisterViewer_starts_at_post_tutorial_state()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
var viewer = await repo.RegisterViewer(
"Imported Viewer",
SVSim.Database.Enums.SocialAccountType.Steam,
socialAccountIdentifier: 76_561_198_000_000_999UL);
Assert.That(viewer.MissionData.TutorialState, Is.EqualTo(100),
"RegisterViewer (admin-import + Steam-social signup) must produce a post-tutorial " +
"viewer by default. Import requests can override via request.TutorialState; absence " +
"means 'a prod-replica viewer ready for the home screen', NOT 'replay tutorial'.");
}
[Test]
public async Task GetViewerByUdid_returns_viewer_or_null()
{

View File

@@ -0,0 +1,47 @@
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using SVSim.Database;
using SVSim.Database.Repositories.Viewer;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Repositories;
public class ViewerRepositoryTutorialDefaultTests
{
[Test]
public async Task RegisterAnonymousViewer_starts_at_tutorial_step_1()
{
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
var viewer = await repo.RegisterAnonymousViewer(Guid.NewGuid());
Assert.That(viewer.MissionData.TutorialState, Is.EqualTo(1),
"Fresh signups start at TUTORIAL_STEP0=1 (matches the prod capture in " +
"traffic_prod_tutorial.ndjson where game_start returned now_tutorial_step=\"1\"). " +
"Step 0 (PRE_TUTORIAL_STEP) is a pre-existence state — NextSceneSwitcher would " +
"route it to AreaSelect at section 0, which has no chapter data and crashes the " +
"client. Tests that want a pre-completed tutorial should use SeedViewerAsync " +
"(which defaults to 100).");
}
[Test]
public async Task RegisterAnonymousViewer_starts_with_empty_display_name()
{
// The client's Wizard.Title/UserNameInput.Start does:
// IsFinished = !string.IsNullOrEmpty(PlayerStaticData.UserName);
// Any non-empty seeded value (including the prior " - " placeholder) makes the
// name-input dialog skip itself, and the /tutorial/update_action #1 +
// /account/update_name calls never fire. Empty is what triggers the dialog.
using var factory = new SVSimTestFactory();
using var scope = factory.Services.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IViewerRepository>();
var viewer = await repo.RegisterAnonymousViewer(System.Guid.NewGuid());
Assert.That(viewer.DisplayName, Is.Empty,
"Anonymous signups MUST start with empty DisplayName so the client's " +
"UserNameInput.Start IsNullOrEmpty short-circuit fails and the dialog runs.");
}
}

View File

@@ -0,0 +1,56 @@
using Microsoft.Extensions.Caching.Memory;
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.DeckBuilder;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.UnitTests.Services;
public class DeckCodeServiceTests
{
private static DeckCodeService NewService(out IMemoryCache cache, IRandom? random = null)
{
cache = new MemoryCache(new MemoryCacheOptions());
return new DeckCodeService(cache, random ?? new SystemRandom());
}
[Test]
public void Mint_returns_4char_lowercase_alphanumeric_code()
{
var svc = NewService(out _);
var code = svc.Mint(new DeckPayload { Clan = "1", CardID = new() { 100211010 } });
Assert.That(code, Has.Length.EqualTo(4));
Assert.That(code, Does.Match("^[a-z0-9]+$"));
}
[Test]
public void Resolve_returns_payload_when_code_unexpired()
{
var svc = NewService(out _);
var original = new DeckPayload { Clan = "4", CardID = new() { 100414020, 100414020 } };
var code = svc.Mint(original);
var resolved = svc.TryResolve(code);
Assert.That(resolved, Is.SameAs(original));
}
[Test]
public void Resolve_returns_null_for_unknown_code()
{
var svc = NewService(out _);
Assert.That(svc.TryResolve("nope"), Is.Null);
}
[Test]
public void Resolve_returns_null_after_cache_eviction()
{
// Don't sleep for the 3-minute TTL — drop the entry directly to simulate expiry.
var svc = NewService(out var cache);
var code = svc.Mint(new DeckPayload { Clan = "1", CardID = new() { 100211010 } });
cache.Remove(DeckCodeService.CacheKey(code));
Assert.That(svc.TryResolve(code), Is.Null);
}
}

View File

@@ -0,0 +1,88 @@
using NUnit.Framework;
using SVSim.EmulatedEntrypoint.Services;
namespace SVSim.UnitTests.Services;
public class ShadowverseSessionServiceTests
{
/// <summary>
/// Fixture captured live from a fresh signup against this server. The client computed this
/// exact SID locally and sent it on the next /check/game_start request. Pinning the formula
/// here means any future refactor of <see cref="ShadowverseSessionService.ComputeClientSessionId"/>
/// that drifts from <c>Cute/Cryptographer.MakeMd5(viewerId + udid)</c> will fail this test
/// before the user discovers it as a decrypt failure on game_start.
/// </summary>
[Test]
public void ComputeClientSessionId_matches_captured_fixture()
{
var svc = new ShadowverseSessionService();
const long viewerId = 1;
var udid = new System.Guid("62747917-93bc-454c-abb4-ef423b3c9317");
string sid = svc.ComputeClientSessionId(viewerId, udid);
Assert.That(sid, Is.EqualTo("dc4aac79d35fe15dfb6262e0071bb03c"));
}
[Test]
public void StoreSessionForViewer_makes_sid_resolvable_to_udid()
{
var svc = new ShadowverseSessionService();
const long viewerId = 1;
var udid = new System.Guid("62747917-93bc-454c-abb4-ef423b3c9317");
svc.StoreSessionForViewer(viewerId, udid);
Assert.That(svc.GetUdidFromSessionId("dc4aac79d35fe15dfb6262e0071bb03c"), Is.EqualTo(udid));
}
[Test]
public void StoreUdidForSessionId_evicts_oldest_when_cap_exceeded()
{
// Cap=3, insert 5 distinct SIDs; the two earliest must be evicted.
var svc = new ShadowverseSessionService(maxEntries: 3);
var udid = new System.Guid("62747917-93bc-454c-abb4-ef423b3c9317");
svc.StoreUdidForSessionId("sid-1", udid);
svc.StoreUdidForSessionId("sid-2", udid);
svc.StoreUdidForSessionId("sid-3", udid);
svc.StoreUdidForSessionId("sid-4", udid);
svc.StoreUdidForSessionId("sid-5", udid);
Assert.That(svc.GetUdidFromSessionId("sid-1"), Is.Null, "Oldest entry must be evicted.");
Assert.That(svc.GetUdidFromSessionId("sid-2"), Is.Null, "Second-oldest entry must be evicted.");
Assert.That(svc.GetUdidFromSessionId("sid-3"), Is.EqualTo(udid));
Assert.That(svc.GetUdidFromSessionId("sid-4"), Is.EqualTo(udid));
Assert.That(svc.GetUdidFromSessionId("sid-5"), Is.EqualTo(udid));
}
[Test]
public void StoreUdidForSessionId_re_storing_same_sid_does_not_grow_queue()
{
// Cap=2. Store sid-A, then re-store sid-A many times, then store sid-B and sid-C.
// The re-stores must NOT count toward the cap — sid-A should still resolve after
// sid-B and sid-C land, because only two distinct SIDs are tracked.
var svc = new ShadowverseSessionService(maxEntries: 2);
var udid = new System.Guid("62747917-93bc-454c-abb4-ef423b3c9317");
svc.StoreUdidForSessionId("sid-A", udid);
for (int i = 0; i < 20; i++) svc.StoreUdidForSessionId("sid-A", udid);
svc.StoreUdidForSessionId("sid-B", udid);
Assert.That(svc.GetUdidFromSessionId("sid-A"), Is.EqualTo(udid), "sid-A must still resolve after re-stores.");
Assert.That(svc.GetUdidFromSessionId("sid-B"), Is.EqualTo(udid));
// sid-C pushes us over the cap → sid-A (oldest) evicted.
svc.StoreUdidForSessionId("sid-C", udid);
Assert.That(svc.GetUdidFromSessionId("sid-A"), Is.Null);
Assert.That(svc.GetUdidFromSessionId("sid-B"), Is.EqualTo(udid));
Assert.That(svc.GetUdidFromSessionId("sid-C"), Is.EqualTo(udid));
}
[Test]
public void Constructor_rejects_non_positive_cap()
{
Assert.Throws<System.ArgumentOutOfRangeException>(() => new ShadowverseSessionService(maxEntries: 0));
Assert.Throws<System.ArgumentOutOfRangeException>(() => new ShadowverseSessionService(maxEntries: -1));
}
}