Compare commits
30 Commits
71b0c66631
...
22c01ed11a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22c01ed11a | ||
|
|
b18bb9502a | ||
|
|
177b4925a1 | ||
|
|
91412ff821 | ||
|
|
d13082a8ca | ||
|
|
86759125a9 | ||
|
|
82d9668c9b | ||
|
|
6fd8705990 | ||
|
|
ac077dfc13 | ||
|
|
b50a884af9 | ||
|
|
c2c6a95170 | ||
|
|
ad5c9e91ae | ||
|
|
27ebb5114c | ||
|
|
b5e33c15f6 | ||
|
|
d3ef76324f | ||
|
|
f4f2ec380c | ||
|
|
1af0e03eeb | ||
|
|
6e6c8ee779 | ||
|
|
190b50cbaf | ||
|
|
5d8a6626bb | ||
|
|
6819e65160 | ||
|
|
ca678b56d1 | ||
|
|
f6f9216162 | ||
|
|
2034034c1b | ||
|
|
0f6b3f231a | ||
|
|
bc9ffe1d31 | ||
|
|
703f7ff3d7 | ||
|
|
f233a8c8d6 | ||
|
|
36dd25826b | ||
|
|
5aac24d2b9 |
@@ -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,
|
||||
|
||||
3713
SVSim.Database/Migrations/20260528160031_AddViewerClaimedTutorialGift.Designer.cs
generated
Normal file
3713
SVSim.Database/Migrations/20260528160031_AddViewerClaimedTutorialGift.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
37
SVSim.Database/Models/Config/ResourceConfig.cs
Normal file
37
SVSim.Database/Models/Config/ResourceConfig.cs
Normal 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://<cdn>/dl/Manifest/<RES_VER>/<lang>/<Platform>/</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();
|
||||
}
|
||||
14
SVSim.Database/Models/ViewerClaimedTutorialGift.cs
Normal file
14
SVSim.Database/Models/ViewerClaimedTutorialGift.cs
Normal 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!;
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 "<unknown>"
|
||||
/// 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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
49
SVSim.EmulatedEntrypoint/Controllers/AccountController.cs
Normal file
49
SVSim.EmulatedEntrypoint/Controllers/AccountController.cs
Normal 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 <=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>());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 { });
|
||||
}
|
||||
248
SVSim.EmulatedEntrypoint/Controllers/GiftController.cs
Normal file
248
SVSim.EmulatedEntrypoint/Controllers/GiftController.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
51
SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs
Normal file
51
SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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)));
|
||||
|
||||
22
SVSim.EmulatedEntrypoint/Models/Dtos/Common/PortalErrors.cs
Normal file
22
SVSim.EmulatedEntrypoint/Models/Dtos/Common/PortalErrors.cs
Normal 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; } = "";
|
||||
}
|
||||
@@ -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/<RES_VER>/<lang>/<Platform>/</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; }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": "<complete_date>"}</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<string, string?></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")]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 (Altersphere–Colosseum 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:
|
||||
|
||||
62
SVSim.EmulatedEntrypoint/Services/DeckCodeService.cs
Normal file
62
SVSim.EmulatedEntrypoint/Services/DeckCodeService.cs
Normal 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}";
|
||||
}
|
||||
17
SVSim.EmulatedEntrypoint/Services/IDeckCodeService.cs
Normal file
17
SVSim.EmulatedEntrypoint/Services/IDeckCodeService.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
109
SVSim.UnitTests/Controllers/AccountControllerTests.cs
Normal file
109
SVSim.UnitTests/Controllers/AccountControllerTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
109
SVSim.UnitTests/Controllers/DeckBuilderControllerTests.cs
Normal file
109
SVSim.UnitTests/Controllers/DeckBuilderControllerTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
33
SVSim.UnitTests/Controllers/DownloadTimeControllerTests.cs
Normal file
33
SVSim.UnitTests/Controllers/DownloadTimeControllerTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
243
SVSim.UnitTests/Controllers/GiftControllerTests.cs
Normal file
243
SVSim.UnitTests/Controllers/GiftControllerTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
229
SVSim.UnitTests/Controllers/PackControllerTests.cs
Normal file
229
SVSim.UnitTests/Controllers/PackControllerTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
102
SVSim.UnitTests/Controllers/TutorialControllerTests.cs
Normal file
102
SVSim.UnitTests/Controllers/TutorialControllerTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
115
SVSim.UnitTests/Controllers/TutorialFlowEndToEndTests.cs
Normal file
115
SVSim.UnitTests/Controllers/TutorialFlowEndToEndTests.cs
Normal 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"));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
56
SVSim.UnitTests/Services/DeckCodeServiceTests.cs
Normal file
56
SVSim.UnitTests/Services/DeckCodeServiceTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
88
SVSim.UnitTests/Services/ShadowverseSessionServiceTests.cs
Normal file
88
SVSim.UnitTests/Services/ShadowverseSessionServiceTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user