Compare commits
42 Commits
57df329923
...
4b5c3b905e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5c3b905e | ||
|
|
eecad64655 | ||
|
|
d815bfa994 | ||
|
|
c8e4bda6af | ||
|
|
7a13e99df7 | ||
|
|
1bdcd8397b | ||
|
|
6b0b467f31 | ||
|
|
05d143d2e8 | ||
|
|
c087f9b08f | ||
|
|
7309488748 | ||
|
|
9813f82edc | ||
|
|
e9c13ae5f4 | ||
|
|
8e41739bde | ||
|
|
a2048a4d54 | ||
|
|
42b0fd3d6e | ||
|
|
94622a526d | ||
|
|
f3c8b46469 | ||
|
|
0f6c8d5e41 | ||
|
|
652011ea74 | ||
|
|
fa89c32110 | ||
|
|
e1b38d5649 | ||
|
|
6e6856db4f | ||
|
|
448153d592 | ||
|
|
2b0cd516f0 | ||
|
|
70ec063c0f | ||
|
|
a6b2c47b98 | ||
|
|
90d420d97a | ||
|
|
81fe842c15 | ||
|
|
4ce1cd172a | ||
|
|
7ff8a7bd92 | ||
|
|
f18cfb58b8 | ||
|
|
4e87957306 | ||
|
|
9f97837211 | ||
|
|
94ed6734c7 | ||
|
|
8199a95356 | ||
|
|
a03bff3ea7 | ||
|
|
91ec14fc7e | ||
|
|
1e9693e56d | ||
|
|
c3da6079b2 | ||
|
|
f85f221bfa | ||
|
|
81771bb0a0 | ||
|
|
4541378885 |
@@ -1,13 +1,25 @@
|
||||
namespace SVSim.BattleNode.Bridge;
|
||||
|
||||
/// <summary>
|
||||
/// DI-injected options for the battle node. NodeServerUrl matches the prod
|
||||
/// do_matching wire format: <c>host:port/socket.io/</c>, no scheme prefix.
|
||||
/// BestHTTP's SocketManager parses it as the Socket.IO v2 endpoint URL.
|
||||
/// DI-injected options for the battle node.
|
||||
/// </summary>
|
||||
public sealed class BattleNodeOptions
|
||||
{
|
||||
public string NodeServerUrl { get; set; } = "localhost:5148/socket.io/";
|
||||
/// <summary>
|
||||
/// The Socket.IO v2 endpoint URL echoed back to the client on <c>/*/do_matching</c>
|
||||
/// success (matching_state 3004/3007/3011). Matches the prod do_matching wire format:
|
||||
/// <c>host[:port]/socket.io/</c> — no scheme prefix, must end with <c>/socket.io/</c>.
|
||||
/// The client's BestHTTP <c>SocketManager</c> parses this string directly; a leading
|
||||
/// <c>http://</c>/<c>https://</c> or a missing trailing slash will make the client
|
||||
/// fail to connect. Host may be an IP, hostname, or FQDN.
|
||||
/// <para>
|
||||
/// Deployment-time value — no hardcoded fallback. Must be provided via the
|
||||
/// <c>"BattleNode:NodeServerUrl"</c> key in <c>appsettings.json</c> (or an
|
||||
/// equivalently-named env var); <see cref="Hosting.BattleNodeExtensions.AddBattleNode"/>
|
||||
/// validates presence at startup and throws if empty.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string NodeServerUrl { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// How long the first arriver's WS waits for a partner before disconnecting.
|
||||
|
||||
@@ -18,15 +18,26 @@ public static class BattleNodeExtensions
|
||||
/// instance the WebSocket handler constructs on connect.
|
||||
/// </summary>
|
||||
/// <param name="configure">
|
||||
/// Optional callback to override <see cref="BattleNodeOptions"/> defaults. The default
|
||||
/// <c>NodeServerUrl</c> assumes the EmulatedEntrypoint host on
|
||||
/// <c>http://localhost:5148</c> and shares the port for the Socket.IO endpoint. Override
|
||||
/// when the node runs on a different port/host or behind a reverse proxy.
|
||||
/// Callback to populate <see cref="BattleNodeOptions"/>. Must set
|
||||
/// <see cref="BattleNodeOptions.NodeServerUrl"/> — there is no hardcoded fallback; the
|
||||
/// value is a deployment concern (localhost during dev, a real host[:port]/socket.io/
|
||||
/// when the node runs behind a reverse proxy or on a separate box). Startup throws
|
||||
/// <see cref="InvalidOperationException"/> if it's still empty after the callback runs.
|
||||
/// </param>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// <see cref="BattleNodeOptions.NodeServerUrl"/> is null, empty, or whitespace after
|
||||
/// <paramref name="configure"/> runs.
|
||||
/// </exception>
|
||||
public static IServiceCollection AddBattleNode(this IServiceCollection services, Action<BattleNodeOptions>? configure = null)
|
||||
{
|
||||
var options = new BattleNodeOptions();
|
||||
configure?.Invoke(options);
|
||||
if (string.IsNullOrWhiteSpace(options.NodeServerUrl))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"BattleNode:NodeServerUrl is not configured. Set it in appsettings.json under " +
|
||||
"the \"BattleNode\" section (format: \"host[:port]/socket.io/\", no scheme prefix).");
|
||||
}
|
||||
services.AddSingleton(options);
|
||||
services.AddSingleton<IBattleSessionStore, InMemoryBattleSessionStore>();
|
||||
services.AddSingleton<IMatchingBridge, MatchingBridge>();
|
||||
|
||||
@@ -2381,6 +2381,7 @@
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"is_enabled": false,
|
||||
"gacha_point": {
|
||||
"exchangeable_point": 400,
|
||||
"increase_gacha_point": 1
|
||||
@@ -2472,6 +2473,7 @@
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"is_enabled": false,
|
||||
"gacha_point": {
|
||||
"exchangeable_point": 400,
|
||||
"increase_gacha_point": 1
|
||||
@@ -2563,6 +2565,7 @@
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"is_enabled": false,
|
||||
"gacha_point": {
|
||||
"exchangeable_point": 400,
|
||||
"increase_gacha_point": 1
|
||||
@@ -2654,6 +2657,7 @@
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"is_enabled": false,
|
||||
"gacha_point": {
|
||||
"exchangeable_point": 400,
|
||||
"increase_gacha_point": 1
|
||||
@@ -2745,6 +2749,7 @@
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"is_enabled": false,
|
||||
"gacha_point": {
|
||||
"exchangeable_point": 400,
|
||||
"increase_gacha_point": 1
|
||||
@@ -2836,6 +2841,7 @@
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"is_enabled": false,
|
||||
"gacha_point": {
|
||||
"exchangeable_point": 400,
|
||||
"increase_gacha_point": 1
|
||||
@@ -2923,6 +2929,7 @@
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"is_enabled": false,
|
||||
"gacha_point": {
|
||||
"exchangeable_point": 400,
|
||||
"increase_gacha_point": 1
|
||||
@@ -3010,6 +3017,7 @@
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"is_enabled": false,
|
||||
"gacha_point": {
|
||||
"exchangeable_point": 400,
|
||||
"increase_gacha_point": 1
|
||||
@@ -3097,6 +3105,7 @@
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"is_enabled": false,
|
||||
"gacha_point": {
|
||||
"exchangeable_point": 400,
|
||||
"increase_gacha_point": 1
|
||||
@@ -3184,6 +3193,7 @@
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": null,
|
||||
"is_enabled": false,
|
||||
"gacha_point": {
|
||||
"exchangeable_point": 400,
|
||||
"increase_gacha_point": 1
|
||||
@@ -3715,7 +3725,7 @@
|
||||
"pack_category": 1,
|
||||
"poster_type": 0,
|
||||
"commence_date": "2026-06-01 02:00:00",
|
||||
"complete_date": "2026-07-01 01:59:59",
|
||||
"complete_date": "2030-12-31 23:59:59",
|
||||
"sleeve_id": 5090001,
|
||||
"special_sleeve_id": 0,
|
||||
"override_draw_effect_pack_id": 80001,
|
||||
@@ -3725,7 +3735,7 @@
|
||||
"is_new": false,
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": "2026-07-01 01:59:59",
|
||||
"sales_period_time": "2030-12-31 23:59:59",
|
||||
"gacha_point": null,
|
||||
"child_gachas": [
|
||||
{
|
||||
@@ -3846,7 +3856,7 @@
|
||||
"pack_category": 1,
|
||||
"poster_type": 0,
|
||||
"commence_date": "2026-06-01 02:00:00",
|
||||
"complete_date": "2026-07-01 01:59:59",
|
||||
"complete_date": "2030-12-31 23:59:59",
|
||||
"sleeve_id": 5090001,
|
||||
"special_sleeve_id": 0,
|
||||
"override_draw_effect_pack_id": 90001,
|
||||
@@ -3856,7 +3866,7 @@
|
||||
"is_new": false,
|
||||
"is_pre_release": false,
|
||||
"open_count_limit": 0,
|
||||
"sales_period_time": "2026-07-01 01:59:59",
|
||||
"sales_period_time": "2030-12-31 23:59:59",
|
||||
"gacha_point": null,
|
||||
"child_gachas": [
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
@@ -24,6 +25,22 @@ public class AchievementCatalogImporter
|
||||
|
||||
var existing = await context.AchievementCatalog
|
||||
.ToDictionaryAsync(e => (e.AchievementType, e.Level));
|
||||
// Fail fast on drift between seed event_type values and the code-side registry.
|
||||
// Missing prefixes silently produce counters no emitter ever writes to; typos likewise.
|
||||
var unregistered = seed
|
||||
.Where(s => s.EventType is not null && !MissionEventKeys.IsRegistered(s.EventType))
|
||||
.Select(s => s.EventType!)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.ToList();
|
||||
if (unregistered.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"[AchievementCatalogImporter] seed rows reference unregistered event_type(s): "
|
||||
+ string.Join(", ", unregistered)
|
||||
+ ". Add the top-level prefix to MissionEventKeys.RegisteredPrefixes, or fix the seed.");
|
||||
}
|
||||
|
||||
int created = 0, updated = 0;
|
||||
var unmappedTypes = new HashSet<int>();
|
||||
foreach (var s in seed)
|
||||
|
||||
@@ -3,6 +3,7 @@ using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
@@ -22,6 +23,21 @@ public class BattlePassMonthlyMissionImporter
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fail fast on drift between seed event_type values and the code-side registry.
|
||||
var unregistered = seed
|
||||
.Where(s => s.EventType is not null && !MissionEventKeys.IsRegistered(s.EventType))
|
||||
.Select(s => s.EventType!)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.ToList();
|
||||
if (unregistered.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"[BattlePassMonthlyMissionImporter] seed rows reference unregistered event_type(s): "
|
||||
+ string.Join(", ", unregistered)
|
||||
+ ". Add the top-level prefix to MissionEventKeys.RegisteredPrefixes, or fix the seed.");
|
||||
}
|
||||
|
||||
var existing = await context.BattlePassMonthlyMissions
|
||||
.ToDictionaryAsync(e => (e.Year, e.Month, e.OrderNum));
|
||||
int created = 0, updated = 0;
|
||||
|
||||
@@ -3,6 +3,7 @@ using SVSim.Bootstrap.Models.Seed;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.Bootstrap.Importers;
|
||||
|
||||
@@ -21,6 +22,21 @@ public class MissionCatalogImporter
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fail fast on drift between seed event_type values and the code-side registry.
|
||||
var unregistered = seed
|
||||
.Where(s => s.EventType is not null && !MissionEventKeys.IsRegistered(s.EventType))
|
||||
.Select(s => s.EventType!)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.ToList();
|
||||
if (unregistered.Count > 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"[MissionCatalogImporter] seed rows reference unregistered event_type(s): "
|
||||
+ string.Join(", ", unregistered)
|
||||
+ ". Add the top-level prefix to MissionEventKeys.RegisteredPrefixes, or fix the seed.");
|
||||
}
|
||||
|
||||
var existing = await context.MissionCatalog.ToDictionaryAsync(e => e.Id);
|
||||
int created = 0, updated = 0;
|
||||
var unmapped = new List<int>();
|
||||
|
||||
36
SVSim.Database/Enums/RankTier.cs
Normal file
36
SVSim.Database/Enums/RankTier.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
namespace SVSim.Database.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Rank-tier bucket names used by mission/achievement catalog rows keyed as
|
||||
/// <c>rank_achieved:{tier}</c>. Boundaries pinned from ranks.csv:
|
||||
/// <list type="bullet">
|
||||
/// <item>1-4 = Beginner 0-3</item>
|
||||
/// <item>5-8 = D0-D3</item>
|
||||
/// <item>9-12 = C0-C3</item>
|
||||
/// <item>13-16 = B0-B3</item>
|
||||
/// <item>17-20 = A0-A3</item>
|
||||
/// <item>21-24 = AA0-AA3</item>
|
||||
/// <item>25 = Master</item>
|
||||
/// <item>26-29 = Grand Master (G026-G029)</item>
|
||||
/// </list>
|
||||
/// The current prod catalog has no <c>rank_achieved:grand_master</c> rows, but the
|
||||
/// emit is still faithful — a grand-master promotion advances the top-level
|
||||
/// <c>rank_achieved</c> counter and the tier-qualified one (which no catalog row
|
||||
/// reads yet, but stays consistent if one lands later).
|
||||
/// </summary>
|
||||
public static class RankTier
|
||||
{
|
||||
/// <summary>Wire rank_id → catalog-facing tier name. Null iff <paramref name="rankId"/> is out of range.</summary>
|
||||
public static string? Name(int rankId) => rankId switch
|
||||
{
|
||||
>= 1 and <= 4 => "beginner",
|
||||
>= 5 and <= 8 => "d",
|
||||
>= 9 and <= 12 => "c",
|
||||
>= 13 and <= 16 => "b",
|
||||
>= 17 and <= 20 => "a",
|
||||
>= 21 and <= 24 => "aa",
|
||||
25 => "master",
|
||||
>= 26 and <= 29 => "grand_master",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
5081
SVSim.Database/Migrations/20260704141307_AddViewerRankProgress.Designer.cs
generated
Normal file
5081
SVSim.Database/Migrations/20260704141307_AddViewerRankProgress.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SVSim.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddViewerRankProgress : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ViewerRankProgress",
|
||||
columns: table => new
|
||||
{
|
||||
ViewerId = table.Column<long>(type: "bigint", nullable: false),
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Format = table.Column<int>(type: "integer", nullable: false),
|
||||
Point = table.Column<int>(type: "integer", nullable: false),
|
||||
MasterPoint = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ViewerRankProgress", x => new { x.ViewerId, x.Id });
|
||||
table.ForeignKey(
|
||||
name: "FK_ViewerRankProgress_Viewers_ViewerId",
|
||||
column: x => x.ViewerId,
|
||||
principalTable: "Viewers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ViewerRankProgress_ViewerId_Format",
|
||||
table: "ViewerRankProgress",
|
||||
columns: new[] { "ViewerId", "Format" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ViewerRankProgress_ViewerId_Format",
|
||||
table: "ViewerRankProgress");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ViewerRankProgress");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4837,6 +4837,34 @@ namespace SVSim.Database.Migrations
|
||||
.HasForeignKey("ViewerId");
|
||||
});
|
||||
|
||||
b.OwnsMany("SVSim.Database.Models.ViewerRankProgress", "RankProgress", b1 =>
|
||||
{
|
||||
b1.Property<long>("ViewerId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("Id"));
|
||||
|
||||
b1.Property<int>("Format")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("MasterPoint")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<int>("Point")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.HasKey("ViewerId", "Id");
|
||||
|
||||
b1.ToTable("ViewerRankProgress");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("ViewerId");
|
||||
});
|
||||
|
||||
b.Navigation("BuildDeckPurchases");
|
||||
|
||||
b.Navigation("Cards");
|
||||
@@ -4868,6 +4896,8 @@ namespace SVSim.Database.Migrations
|
||||
|
||||
b.Navigation("PackStarterClasses");
|
||||
|
||||
b.Navigation("RankProgress");
|
||||
|
||||
b.Navigation("SocialAccountConnections");
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ public class ArenaTwoPickConfig
|
||||
{
|
||||
public int RewardScheduleId { get; set; } = 1;
|
||||
public int ChallengeId { get; set; } = 1;
|
||||
public int ClassXpPerBattle { get; set; } = 100;
|
||||
public int SpotPointsPerBattle { get; set; } = 10;
|
||||
|
||||
public double LegendaryRate { get; set; } = 0.06;
|
||||
|
||||
28
SVSim.Database/Models/Config/BattleXpConfig.cs
Normal file
28
SVSim.Database/Models/Config/BattleXpConfig.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Class XP awarded on battle finish. <see cref="XpPerWin"/> / <see cref="XpPerLoss"/>
|
||||
/// are the global defaults; each per-mode nullable slot overrides them for that mode
|
||||
/// when set. Story is clear-only (no loss variant) — <see cref="StoryXpPerClear"/>
|
||||
/// falls back to <see cref="XpPerWin"/> when null.
|
||||
/// </summary>
|
||||
[ConfigSection("BattleXp")]
|
||||
public class BattleXpConfig
|
||||
{
|
||||
public int XpPerWin { get; set; } = 200;
|
||||
public int XpPerLoss { get; set; } = 50;
|
||||
|
||||
public int? PracticeXpPerWin { get; set; }
|
||||
public int? PracticeXpPerLoss { get; set; }
|
||||
public int? RankXpPerWin { get; set; }
|
||||
public int? RankXpPerLoss { get; set; }
|
||||
public int? FreeXpPerWin { get; set; }
|
||||
public int? FreeXpPerLoss { get; set; }
|
||||
public int? ArenaTwoPickXpPerWin { get; set; }
|
||||
public int? ArenaTwoPickXpPerLoss { get; set; }
|
||||
public int? ColosseumXpPerWin { get; set; }
|
||||
public int? ColosseumXpPerLoss { get; set; }
|
||||
public int? StoryXpPerClear { get; set; }
|
||||
|
||||
public static BattleXpConfig ShippedDefaults() => new();
|
||||
}
|
||||
@@ -4,9 +4,9 @@ namespace SVSim.Database.Models.Config;
|
||||
[ConfigSection("DefaultGrants")]
|
||||
public class DefaultGrantsConfig
|
||||
{
|
||||
public ulong Crystals { get; set; } = 50000;
|
||||
public ulong Rupees { get; set; } = 50000;
|
||||
public ulong Ether { get; set; } = 50000;
|
||||
public ulong Crystals { get; set; } = 0;
|
||||
public ulong Rupees { get; set; } = 0;
|
||||
public ulong Ether { get; set; } = 0;
|
||||
|
||||
public static DefaultGrantsConfig ShippedDefaults() => new();
|
||||
}
|
||||
|
||||
20
SVSim.Database/Models/Config/GameCalendarConfig.cs
Normal file
20
SVSim.Database/Models/Config/GameCalendarConfig.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Reset boundaries for time-windowed game state (daily missions, daily-single packs,
|
||||
/// login bonuses). All timestamps in the codebase are UTC; this config only shifts
|
||||
/// where the day boundary falls.
|
||||
/// </summary>
|
||||
[ConfigSection("GameCalendar")]
|
||||
public class GameCalendarConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// UTC hour (0-23) at which the daily reset occurs. Default 0 = midnight UTC.
|
||||
/// Prod parity: 17 (= 02:00 JST). Changing the value at runtime is safe for the
|
||||
/// ResetReady check but retroactively misaligns any DB rows keyed by DayKey /
|
||||
/// WeekKey / MonthKey — treat as a startup constant in production.
|
||||
/// </summary>
|
||||
public int DailyResetUtcHour { get; set; } = 0;
|
||||
|
||||
public static GameCalendarConfig ShippedDefaults() => new();
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
namespace SVSim.Database.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Story-family placeholder config section. Class XP per clear moved to
|
||||
/// <see cref="BattleXpConfig.StoryXpPerClear"/> (BattleXpMode.Story) as part of the
|
||||
/// unified per-mode XP surface. Kept as an empty section so future story-specific
|
||||
/// knobs (dialogue speed, auto-skip, etc.) have a home.
|
||||
/// </summary>
|
||||
[ConfigSection("Story")]
|
||||
public class StoryConfig
|
||||
{
|
||||
public int ClassXpPerClear { get; set; } = 200;
|
||||
|
||||
public static StoryConfig ShippedDefaults() => new();
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public class Viewer : BaseEntity<long>
|
||||
|
||||
/// <summary>
|
||||
/// UTC instant of the most recent daily-login-bonus claim. Null = never claimed.
|
||||
/// Compared against JST 02:00 day boundary via JstPeriod.DayKey to decide eligibility.
|
||||
/// Compared against the daily reset boundary via IGameCalendarService.ResetReady.
|
||||
/// </summary>
|
||||
public DateTime? LastLoginBonusClaimedAt { get; set; }
|
||||
|
||||
@@ -87,6 +87,8 @@ public class Viewer : BaseEntity<long>
|
||||
|
||||
public List<ViewerFreePackClaim> FreePackClaims { get; set; } = new List<ViewerFreePackClaim>();
|
||||
|
||||
public List<ViewerRankProgress> RankProgress { get; set; } = new List<ViewerRankProgress>();
|
||||
|
||||
public List<MyPageBgRotationEntry> MyPageBgRotation { get; set; } = new List<MyPageBgRotationEntry>();
|
||||
|
||||
public List<ViewerGachaPointBalance> GachaPointBalances { get; set; } = new List<ViewerGachaPointBalance>();
|
||||
|
||||
@@ -3,7 +3,8 @@ namespace SVSim.Database.Models;
|
||||
/// <summary>
|
||||
/// Per-viewer "how many times has this happened" counter. Composite PK
|
||||
/// (ViewerId, EventKey, Period). Period strings: "all-time", "month:YYYY-MM",
|
||||
/// "week:YYYY-W##", "day:YYYY-MM-DD" — all JST-anchored with 02:00 day-boundary.
|
||||
/// "week:YYYY-W##", "day:YYYY-MM-DD" — all UTC, with the reset-window boundary
|
||||
/// set by <c>GameCalendarConfig.DailyResetUtcHour</c>.
|
||||
/// Single source of truth for total_count / done_number on every wire shape.
|
||||
/// </summary>
|
||||
public class ViewerEventCounter
|
||||
|
||||
17
SVSim.Database/Models/ViewerRankProgress.cs
Normal file
17
SVSim.Database/Models/ViewerRankProgress.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Models;
|
||||
|
||||
/// <summary>
|
||||
/// One row per (viewer, format) tracking accumulated rank points and master points.
|
||||
/// Point is 0-based cumulative; the current rank_id is derived at read time from Point
|
||||
/// (and MasterPoint once past 50000). Owned collection on <see cref="Viewer"/>.
|
||||
/// </summary>
|
||||
[Owned]
|
||||
public class ViewerRankProgress
|
||||
{
|
||||
public Format Format { get; set; }
|
||||
public int Point { get; set; }
|
||||
public int MasterPoint { get; set; }
|
||||
}
|
||||
@@ -51,6 +51,22 @@ public interface IViewerRepository
|
||||
/// </summary>
|
||||
Task<Models.Viewer?> LoadForMatchContextAsync(long viewerId);
|
||||
|
||||
/// <summary>
|
||||
/// Focused load for class-XP grants: viewer with owned <c>Classes</c> collection and
|
||||
/// each <c>ViewerClassData.Class</c> nav ref included. Tracked (not AsNoTracking) so
|
||||
/// the caller can mutate <c>Exp</c>/<c>Level</c> and <c>SaveChangesAsync</c>. Returns
|
||||
/// null if the viewer does not exist.
|
||||
/// </summary>
|
||||
Task<Models.Viewer?> LoadForBattleXpGrantAsync(long viewerId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Load a viewer with the joins <see cref="Controllers.RankBattleController"/>'s <c>Finish</c>
|
||||
/// needs: Classes (for the class-XP grant) + RankProgress (for the rank grant). Split-query
|
||||
/// per <c>project_ef_split_query</c> to avoid the cartesian explosion when both nav
|
||||
/// collections are populated on the same viewer.
|
||||
/// </summary>
|
||||
Task<Models.Viewer?> LoadForRankProgressAsync(long viewerId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Sets Viewer.GuildId to <paramref name="guildId"/>. No-op if the viewer does not exist.</summary>
|
||||
Task SetGuildIdAsync(long viewerId, int guildId, CancellationToken ct = default);
|
||||
|
||||
@@ -88,4 +104,12 @@ public interface IViewerRepository
|
||||
/// Batch-loads owned guild-emblem ids for a viewer. Used by <c>/guild/emblem_list</c>.
|
||||
/// </summary>
|
||||
Task<List<long>> GetEmblemListAsync(long viewerId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Counts the viewer's unclaimed <see cref="Models.ViewerPresent"/> rows. Drives
|
||||
/// <c>/mypage/index.unread_present_count</c>, which the client casts to
|
||||
/// <c>Data.MyPage.data.unread_mail_count</c> to render the home-screen crate badge
|
||||
/// (MyPageItemHome.cs:148 → SetUnreadGiftCount).
|
||||
/// </summary>
|
||||
Task<int> CountUnclaimedPresentsAsync(long viewerId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -273,6 +273,24 @@ public class ViewerRepository : IViewerRepository
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId);
|
||||
}
|
||||
|
||||
public Task<Models.Viewer?> LoadForBattleXpGrantAsync(long viewerId, CancellationToken ct = default)
|
||||
{
|
||||
return _dbContext.Set<Models.Viewer>()
|
||||
.Include(v => v.Classes)
|
||||
.ThenInclude(c => c.Class)
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
|
||||
}
|
||||
|
||||
public Task<Models.Viewer?> LoadForRankProgressAsync(long viewerId, CancellationToken ct = default)
|
||||
{
|
||||
return _dbContext.Set<Models.Viewer>()
|
||||
.Include(v => v.Classes)
|
||||
.ThenInclude(c => c.Class)
|
||||
.Include(v => v.RankProgress)
|
||||
.AsSplitQuery()
|
||||
.FirstOrDefaultAsync(v => v.Id == viewerId, ct);
|
||||
}
|
||||
|
||||
public async Task SetGuildIdAsync(long viewerId, int guildId, CancellationToken ct = default)
|
||||
{
|
||||
var viewer = await _dbContext.Set<Models.Viewer>()
|
||||
@@ -378,6 +396,13 @@ public class ViewerRepository : IViewerRepository
|
||||
return viewer?.Emblems.Select(e => (long)e.Id).ToList() ?? new List<long>();
|
||||
}
|
||||
|
||||
public Task<int> CountUnclaimedPresentsAsync(long viewerId, CancellationToken ct = default)
|
||||
{
|
||||
return _dbContext.Set<ViewerPresent>()
|
||||
.Where(p => p.ViewerId == viewerId && p.Status == PresentStatus.Unclaimed)
|
||||
.CountAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<Models.Viewer> BuildDefaultViewer(string displayName, int initialTutorialState = 1)
|
||||
{
|
||||
Models.Viewer viewer = new Models.Viewer
|
||||
|
||||
11
SVSim.Database/Services/BattleXp/BattleXpMode.cs
Normal file
11
SVSim.Database/Services/BattleXp/BattleXpMode.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace SVSim.Database.Services.BattleXp;
|
||||
|
||||
public enum BattleXpMode
|
||||
{
|
||||
Practice,
|
||||
Rank,
|
||||
Free,
|
||||
ArenaTwoPick,
|
||||
Colosseum,
|
||||
Story,
|
||||
}
|
||||
80
SVSim.Database/Services/BattleXp/BattleXpService.cs
Normal file
80
SVSim.Database/Services/BattleXp/BattleXpService.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
|
||||
namespace SVSim.Database.Services.BattleXp;
|
||||
|
||||
public sealed class BattleXpService : IBattleXpService
|
||||
{
|
||||
private readonly IGlobalsRepository _globals;
|
||||
private readonly IGameConfigService _config;
|
||||
private readonly ILogger<BattleXpService> _log;
|
||||
|
||||
// Curve is immutable per boot; cache the first fetch.
|
||||
private List<ClassExpEntry>? _cachedCurve;
|
||||
|
||||
public BattleXpService(IGlobalsRepository globals, IGameConfigService config, ILogger<BattleXpService> log)
|
||||
{
|
||||
_globals = globals;
|
||||
_config = config;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<BattleXpGrantResult> GrantAsync(
|
||||
Viewer viewer, int classId, bool isWin, BattleXpMode mode, CancellationToken ct = default)
|
||||
{
|
||||
var row = viewer.Classes.FirstOrDefault(c => c.Class.Id == classId);
|
||||
if (row is null)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"BattleXpService: viewer {ViewerId} has no ViewerClassData for classId {ClassId}; skipping grant.",
|
||||
viewer.Id, classId);
|
||||
return new BattleXpGrantResult(0, 0, 1, LeveledUp: false);
|
||||
}
|
||||
|
||||
int amount = ResolveAmount(mode, isWin);
|
||||
row.Exp += amount;
|
||||
|
||||
var curve = _cachedCurve ??= await _globals.GetClassExpCurve();
|
||||
// curve[level] semantics: XP required WHILE AT that level to reach the next
|
||||
// (matching classexp.csv seed values and LoadController's client-facing shape).
|
||||
var byLevel = curve.ToDictionary(e => e.Id, e => e.NecessaryExp);
|
||||
int maxLevel = curve.Count == 0 ? row.Level : curve.Max(e => e.Id);
|
||||
|
||||
int startingLevel = row.Level;
|
||||
while (row.Level < maxLevel
|
||||
&& byLevel.TryGetValue(row.Level, out var needed)
|
||||
&& row.Exp >= needed)
|
||||
{
|
||||
row.Exp -= needed;
|
||||
row.Level += 1;
|
||||
}
|
||||
|
||||
return new BattleXpGrantResult(amount, row.Exp, row.Level, LeveledUp: row.Level > startingLevel);
|
||||
}
|
||||
|
||||
private int ResolveAmount(BattleXpMode mode, bool isWin)
|
||||
{
|
||||
var cfg = _config.Get<BattleXpConfig>();
|
||||
|
||||
if (mode == BattleXpMode.Story)
|
||||
return cfg.StoryXpPerClear ?? cfg.XpPerWin;
|
||||
|
||||
int? overrideVal = (mode, isWin) switch
|
||||
{
|
||||
(BattleXpMode.Practice, true) => cfg.PracticeXpPerWin,
|
||||
(BattleXpMode.Practice, false) => cfg.PracticeXpPerLoss,
|
||||
(BattleXpMode.Rank, true) => cfg.RankXpPerWin,
|
||||
(BattleXpMode.Rank, false) => cfg.RankXpPerLoss,
|
||||
(BattleXpMode.Free, true) => cfg.FreeXpPerWin,
|
||||
(BattleXpMode.Free, false) => cfg.FreeXpPerLoss,
|
||||
(BattleXpMode.ArenaTwoPick, true) => cfg.ArenaTwoPickXpPerWin,
|
||||
(BattleXpMode.ArenaTwoPick, false) => cfg.ArenaTwoPickXpPerLoss,
|
||||
(BattleXpMode.Colosseum, true) => cfg.ColosseumXpPerWin,
|
||||
(BattleXpMode.Colosseum, false) => cfg.ColosseumXpPerLoss,
|
||||
_ => null,
|
||||
};
|
||||
return overrideVal ?? (isWin ? cfg.XpPerWin : cfg.XpPerLoss);
|
||||
}
|
||||
}
|
||||
39
SVSim.Database/Services/BattleXp/IBattleXpService.cs
Normal file
39
SVSim.Database/Services/BattleXp/IBattleXpService.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services.BattleXp;
|
||||
|
||||
/// <summary>
|
||||
/// Amounts returned to callers after a class-XP grant. <see cref="TotalXp"/> and
|
||||
/// <see cref="Level"/> are POST-grant, POST-level-up (matching the wire shape's
|
||||
/// <c>class_experience</c> + <c>class_level</c> post-state semantics).
|
||||
/// <see cref="LeveledUp"/> is <c>true</c> iff at least one level threshold was
|
||||
/// crossed during this grant — callers gate <c>class_level_up</c> mission emits
|
||||
/// on this flag rather than caching pre-state themselves.
|
||||
/// </summary>
|
||||
public sealed record BattleXpGrantResult(int GetXp, int TotalXp, int Level, bool LeveledUp);
|
||||
|
||||
public interface IBattleXpService
|
||||
{
|
||||
/// <summary>
|
||||
/// Grants class XP for a battle finish. Caller supplies a viewer loaded via
|
||||
/// <see cref="Repositories.Viewer.IViewerRepository.LoadForBattleXpGrantAsync"/>
|
||||
/// (or equivalent, with <c>.Include(v => v.Classes).ThenInclude(c => c.Class)</c>).
|
||||
/// Caller <c>SaveChangesAsync</c> after this returns.
|
||||
/// <para>
|
||||
/// Amount resolution: mode-specific config override if non-null, else global
|
||||
/// <c>XpPerWin</c>/<c>XpPerLoss</c>. Story ignores <paramref name="isWin"/> (always
|
||||
/// treated as a clear).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Level-up: loops on <c>ClassExpEntry</c> curve; <c>row.Exp</c> stores level-relative
|
||||
/// XP and carries overflow after each level-up. Saturates at curve max level (excess
|
||||
/// piles in <c>Exp</c>).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Guardrail: viewer has no <see cref="ViewerClassData"/> row for
|
||||
/// <paramref name="classId"/> → returns <c>(0, 0, 1)</c>, no mutation, logs Warning.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
Task<BattleXpGrantResult> GrantAsync(
|
||||
Viewer viewer, int classId, bool isWin, BattleXpMode mode, CancellationToken ct = default);
|
||||
}
|
||||
@@ -82,3 +82,9 @@ public sealed record BattleParticipationContext(
|
||||
int BattleType,
|
||||
int DeckFormat,
|
||||
int TwoPickType);
|
||||
|
||||
/// <summary>
|
||||
/// Caller-relative friend flags for another viewer. Used to badge rows in guild-member,
|
||||
/// invite-candidate, and join-request lists on the wire (<c>is_friend</c>, <c>is_friend_apply</c>).
|
||||
/// </summary>
|
||||
public sealed record FriendRelation(bool IsFriend, bool HasOutgoingApply);
|
||||
|
||||
@@ -313,6 +313,36 @@ public sealed class FriendService : IFriendService, IPlayedTogetherWriter
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<long, FriendRelation>> GetFriendRelationsAsync(
|
||||
long viewerId, IReadOnlyList<long> otherViewerIds, CancellationToken ct)
|
||||
{
|
||||
if (otherViewerIds.Count == 0)
|
||||
return new Dictionary<long, FriendRelation>();
|
||||
|
||||
var idSet = otherViewerIds.Where(id => id != viewerId).Distinct().ToList();
|
||||
var friendSet = idSet.Count == 0
|
||||
? new HashSet<long>()
|
||||
: (await _db.ViewerFriends.AsNoTracking()
|
||||
.Where(f => f.OwnerViewerId == viewerId && idSet.Contains(f.FriendViewerId))
|
||||
.Select(f => f.FriendViewerId)
|
||||
.ToListAsync(ct)).ToHashSet();
|
||||
var applySet = idSet.Count == 0
|
||||
? new HashSet<long>()
|
||||
: (await _db.ViewerFriendApplies.AsNoTracking()
|
||||
.Where(a => a.FromViewerId == viewerId && idSet.Contains(a.ToViewerId))
|
||||
.Select(a => a.ToViewerId)
|
||||
.ToListAsync(ct)).ToHashSet();
|
||||
|
||||
var result = new Dictionary<long, FriendRelation>(otherViewerIds.Count);
|
||||
foreach (var id in otherViewerIds)
|
||||
{
|
||||
result[id] = id == viewerId
|
||||
? new FriendRelation(false, false)
|
||||
: new FriendRelation(friendSet.Contains(id), applySet.Contains(id));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private sealed record ViewerProjection(
|
||||
|
||||
@@ -27,4 +27,12 @@ public interface IFriendService
|
||||
|
||||
/// <summary>Deletes both directions of the friendship (A→B and B→A).</summary>
|
||||
Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Batched caller-relative friend-relation lookup. For each id in <paramref name="otherViewerIds"/>
|
||||
/// returns whether the caller is already friends with them and/or has an outgoing pending apply
|
||||
/// to them. Self and unknown ids resolve to <c>(false, false)</c>.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<long, FriendRelation>> GetFriendRelationsAsync(
|
||||
long viewerId, IReadOnlyList<long> otherViewerIds, CancellationToken ct);
|
||||
}
|
||||
|
||||
253
SVSim.Database/Services/MissionEventKeys.cs
Normal file
253
SVSim.Database/Services/MissionEventKeys.cs
Normal file
@@ -0,0 +1,253 @@
|
||||
using SVSim.Database.Enums;
|
||||
|
||||
namespace SVSim.Database.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of every string that can appear as <c>ViewerEventCounter.EventKey</c> or
|
||||
/// mission/achievement <c>EventType</c>. Two goals:
|
||||
///
|
||||
/// <list type="number">
|
||||
/// <item>Give emitters (controllers) a single named entry point per event family so no
|
||||
/// controller inlines the string form.</item>
|
||||
/// <item>Give the catalog seed importers a validation set — every <c>event_type</c> in a
|
||||
/// seed row must start with a prefix registered here, else the importer throws at bootstrap.
|
||||
/// Prevents silent drift between catalog data and emitter code (e.g. a mission that
|
||||
/// references <c>practice_wln</c> and never advances).</item>
|
||||
/// </list>
|
||||
///
|
||||
/// Format convention: colon-hierarchical, most-general first. Callers emit multiple levels
|
||||
/// so a single event increments every level of counter the catalog might reference.
|
||||
/// </summary>
|
||||
public static class MissionEventKeys
|
||||
{
|
||||
// ---- Top-level catalog prefixes (12) — 1:1 with seed JSON event_type strings ----
|
||||
|
||||
public const string PracticeWin = "practice_win";
|
||||
public const string RankedWin = "ranked_win";
|
||||
public const string RankedOrArenaWin = "ranked_or_arena_win";
|
||||
public const string DailyMatchWin = "daily_match_win";
|
||||
public const string StoryChapterFinish = "story_chapter_finish";
|
||||
public const string ClassLevelUp = "class_level_up";
|
||||
public const string RankAchieved = "rank_achieved";
|
||||
public const string ChallengePlay = "challenge_play";
|
||||
public const string ChallengeWin = "challenge_win";
|
||||
public const string ChallengeFullClear = "challenge_full_clear";
|
||||
public const string PlayFollowers = "play_followers";
|
||||
public const string PrivateMatchDistinctOpponent = "private_match_distinct_opponent";
|
||||
|
||||
// ---- Item purchase (per-catalog-entry) ----
|
||||
|
||||
public const string ItemPurchasePrefix = "item_purchase";
|
||||
public static string ItemPurchase(int catalogId) => $"{ItemPurchasePrefix}:{catalogId}";
|
||||
|
||||
// ---- Practice hierarchical builders ----
|
||||
|
||||
public static class Practice
|
||||
{
|
||||
/// <summary>
|
||||
/// Wire <c>difficulty</c> → tier name used in catalog rows. Values 4/6/7 correspond to
|
||||
/// Elite / Elite 2 / Elite 3 respectively (verified via
|
||||
/// practicetext.json + practice-opponents.json + practice_ai_setting.csv cross-reference).
|
||||
/// Other CSV difficulty values (0, 2, 3, 5, 101-109) have no achievement catalog rows —
|
||||
/// null return means "emit only the top-level counter."
|
||||
/// </summary>
|
||||
public static string? TierName(int wireDifficulty) => wireDifficulty switch
|
||||
{
|
||||
4 => "elite",
|
||||
6 => "elite2",
|
||||
7 => "elite3",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Wire <c>enemy_class_id</c> → leader name used in catalog rows. Class ordering
|
||||
/// matches the <c>CardClass</c> enum (1=Forestcraft/Arisa, ..., 8=Portalcraft/Yuwan).
|
||||
/// </summary>
|
||||
public static string? LeaderName(int enemyClassId) => enemyClassId switch
|
||||
{
|
||||
1 => "arisa",
|
||||
2 => "erika",
|
||||
3 => "isabelle",
|
||||
4 => "rowen",
|
||||
5 => "luna",
|
||||
6 => "urias",
|
||||
7 => "eris",
|
||||
8 => "yuwan",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Emits <c>practice_win</c> plus any hierarchical variants whose parts resolve. If the
|
||||
/// wire values don't resolve to a known tier or leader, that level is skipped — the
|
||||
/// lifetime counter still advances.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> WinAll(int wireDifficulty, int enemyClassId)
|
||||
{
|
||||
var tier = TierName(wireDifficulty);
|
||||
var leader = LeaderName(enemyClassId);
|
||||
var list = new List<string>(3) { PracticeWin };
|
||||
if (tier is not null)
|
||||
list.Add($"{PracticeWin}:{tier}");
|
||||
if (tier is not null && leader is not null)
|
||||
list.Add($"{PracticeWin}:{tier}:{leader}");
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Story hierarchical builders ----
|
||||
|
||||
public static class Story
|
||||
{
|
||||
/// <summary>
|
||||
/// Emits <c>story_chapter_finish</c> plus <c>:{family}</c> plus <c>:{family}:{storyId}</c>.
|
||||
/// <paramref name="family"/> is the low-cardinality family label (<c>main</c>,
|
||||
/// <c>limited</c>, <c>event</c>, ...) resolved from <c>StoryApiType</c> upstream.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> ChapterFinishAll(string family, long storyId) => new[]
|
||||
{
|
||||
StoryChapterFinish,
|
||||
$"{StoryChapterFinish}:{family}",
|
||||
$"{StoryChapterFinish}:{family}:{storyId}",
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Class-name mapping (shared by Ranked and ClassLevel families) ----
|
||||
|
||||
public static class Class
|
||||
{
|
||||
/// <summary>
|
||||
/// Wire <c>class_id</c> 1-8 → catalog-facing craft name. Ordering matches the
|
||||
/// <see cref="SVSim.BattleNode.Bridge.CardClass"/> enum (1=Forestcraft ... 8=Portalcraft).
|
||||
/// Duplicated as a string switch rather than reused from the enum's <c>ToString()</c>
|
||||
/// because the catalog uses lowercase — an accidental case change would silently break
|
||||
/// counter alignment.
|
||||
/// </summary>
|
||||
public static string? Name(int classId) => classId switch
|
||||
{
|
||||
1 => "forestcraft",
|
||||
2 => "swordcraft",
|
||||
3 => "runecraft",
|
||||
4 => "dragoncraft",
|
||||
5 => "shadowcraft",
|
||||
6 => "bloodcraft",
|
||||
7 => "havencraft",
|
||||
8 => "portalcraft",
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Ranked / Free / Challenge hierarchical builders ----
|
||||
|
||||
public static class Ranked
|
||||
{
|
||||
/// <summary>
|
||||
/// Rank-battle win: emits <c>ranked_win</c>, the class-qualified variant, and the two
|
||||
/// aggregate keys (<c>ranked_or_arena_win</c>, <c>daily_match_win</c>) that every
|
||||
/// ranked/arena win advances.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> WinAll(int classId)
|
||||
{
|
||||
var list = new List<string>(4)
|
||||
{
|
||||
RankedWin,
|
||||
RankedOrArenaWin,
|
||||
DailyMatchWin,
|
||||
};
|
||||
if (Class.Name(classId) is { } name)
|
||||
list.Add($"{RankedWin}:{name}");
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Free
|
||||
{
|
||||
/// <summary>
|
||||
/// Unranked/free-battle win: only the two aggregates. There is no <c>free_win</c>
|
||||
/// catalog prefix — free battles just count toward "any match" mission lines.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> WinAll() => new[] { RankedOrArenaWin, DailyMatchWin };
|
||||
}
|
||||
|
||||
public static class Challenge
|
||||
{
|
||||
/// <summary>Any TK2 match finish (win OR loss). Advances <c>challenge_play</c>.</summary>
|
||||
public static IReadOnlyList<string> MatchPlayAll() => new[] { ChallengePlay };
|
||||
|
||||
/// <summary>
|
||||
/// TK2 match win: fires both <c>challenge_win</c> and <c>challenge_play</c> (a win is
|
||||
/// also a play) plus the two aggregates. Keeping <c>challenge_play</c> in this list
|
||||
/// makes the invariant "always increment play, additionally increment win" obvious.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> MatchWinAll() => new[]
|
||||
{
|
||||
ChallengeWin, ChallengePlay, RankedOrArenaWin, DailyMatchWin,
|
||||
};
|
||||
|
||||
/// <summary>TK2 run ended with all 5 wins — advances <c>challenge_full_clear</c>.</summary>
|
||||
public static IReadOnlyList<string> FullClearAll() => new[] { ChallengeFullClear };
|
||||
}
|
||||
|
||||
// ---- ClassLevel / Rank hierarchical builders ----
|
||||
|
||||
public static class ClassLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// Class went up at least one level in this battle. Callsite must gate on
|
||||
/// <c>BattleXpGrantResult.LeveledUp</c> — this method doesn't check.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> UpAll(int classId)
|
||||
{
|
||||
var list = new List<string>(2) { ClassLevelUp };
|
||||
if (Class.Name(classId) is { } name)
|
||||
list.Add($"{ClassLevelUp}:{name}");
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Rank
|
||||
{
|
||||
/// <summary>
|
||||
/// Viewer's rank crossed into a new tier. Callsite must gate on
|
||||
/// <c>RankProgressResult.TierAdvanced</c>. Tier name comes from
|
||||
/// <see cref="RankTier.Name(int)"/>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> AchievedAll(int rankId)
|
||||
{
|
||||
var list = new List<string>(2) { RankAchieved };
|
||||
if (RankTier.Name(rankId) is { } name)
|
||||
list.Add($"{RankAchieved}:{name}");
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Seed-import validation ----
|
||||
|
||||
private static readonly IReadOnlySet<string> _registeredPrefixes = new HashSet<string>
|
||||
{
|
||||
PracticeWin, RankedWin, RankedOrArenaWin, DailyMatchWin, StoryChapterFinish,
|
||||
ClassLevelUp, RankAchieved, ChallengePlay, ChallengeWin, ChallengeFullClear,
|
||||
PlayFollowers, PrivateMatchDistinctOpponent,
|
||||
ItemPurchasePrefix,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// True iff <paramref name="eventType"/> is a registered top-level prefix or a hierarchical
|
||||
/// extension of one (<c>prefix</c> alone, or <c>prefix:qualifier</c>). Called by the
|
||||
/// achievement/mission catalog importers to catch drift between seed data and code.
|
||||
/// </summary>
|
||||
public static bool IsRegistered(string eventType)
|
||||
{
|
||||
foreach (var prefix in _registeredPrefixes)
|
||||
{
|
||||
if (eventType == prefix) return true;
|
||||
if (eventType.Length > prefix.Length + 1
|
||||
&& eventType[prefix.Length] == ':'
|
||||
&& eventType.AsSpan(0, prefix.Length).SequenceEqual(prefix))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Exposed for test assertions; do NOT mutate.</summary>
|
||||
public static IReadOnlySet<string> RegisteredPrefixes => _registeredPrefixes;
|
||||
}
|
||||
55
SVSim.Database/Services/RankProgress/IRankProgressService.cs
Normal file
55
SVSim.Database/Services/RankProgress/IRankProgressService.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
|
||||
namespace SVSim.Database.Services.RankProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Wire-shape result of a single rank-battle finish grant. The wire-mapped fields
|
||||
/// (<see cref="Rank"/>, <see cref="AfterBattlePoint"/>, <see cref="AfterMasterPoint"/>,
|
||||
/// <see cref="BattlePoint"/>, <see cref="MasterPoint"/>, <see cref="IsMasterRank"/>,
|
||||
/// <see cref="IsGrandMasterRank"/>) map 1:1 to <c>RankBattleFinishResponseDto</c>
|
||||
/// keys the client reads via GetValueOrDefault in Wizard/RankBattleFinishTask.cs:57-63.
|
||||
/// <see cref="TierAdvanced"/> is a controller-side signal for the rank_achieved mission
|
||||
/// emit and is not serialized to the wire.
|
||||
/// </summary>
|
||||
/// <param name="Rank">rank_id post-grant (1..29).</param>
|
||||
/// <param name="AfterBattlePoint">Point post-grant (accumulated).</param>
|
||||
/// <param name="AfterMasterPoint">MasterPoint post-grant (accumulated).</param>
|
||||
/// <param name="BattlePoint">Signed Point delta (+100/-50/0).</param>
|
||||
/// <param name="MasterPoint">Signed MasterPoint delta.</param>
|
||||
/// <param name="IsMasterRank">True iff Rank == 25.</param>
|
||||
/// <param name="IsGrandMasterRank">True iff Rank >= 26.</param>
|
||||
/// <param name="TierAdvanced">
|
||||
/// True iff this grant crossed the viewer into a higher <see cref="RankTier"/> bucket
|
||||
/// than they held pre-grant. Callsites gate rank_achieved mission emits on this flag.
|
||||
/// </param>
|
||||
public sealed record RankProgressResult(
|
||||
int Rank,
|
||||
int AfterBattlePoint,
|
||||
int AfterMasterPoint,
|
||||
int BattlePoint,
|
||||
int MasterPoint,
|
||||
bool IsMasterRank,
|
||||
bool IsGrandMasterRank,
|
||||
bool TierAdvanced = false);
|
||||
|
||||
public interface IRankProgressService
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies a +100 (win) or -50 (loss) delta to the viewer's rank progression in the
|
||||
/// given format, respecting tier floors from <c>ranks.csv</c>'s LowerLimitPoint column.
|
||||
/// Creates a ViewerRankProgress row for (viewer, format) if none exists. Caller must
|
||||
/// have loaded the viewer with <c>.Include(v => v.RankProgress)</c> and must call
|
||||
/// <c>SaveChangesAsync</c> after this returns.
|
||||
/// </summary>
|
||||
/// <param name="format">Must be Format.Rotation or Format.Unlimited.</param>
|
||||
Task<RankProgressResult> GrantAsync(
|
||||
Viewer viewer, Format format, bool isWin, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Read-only current progression snapshot for (viewer, format). Returns a zero-value
|
||||
/// result if no row exists. Does not mutate the viewer or hit the DB.
|
||||
/// </summary>
|
||||
Task<RankProgressResult> GetAsync(
|
||||
Viewer viewer, Format format, CancellationToken ct = default);
|
||||
}
|
||||
164
SVSim.Database/Services/RankProgress/RankProgressService.cs
Normal file
164
SVSim.Database/Services/RankProgress/RankProgressService.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
|
||||
namespace SVSim.Database.Services.RankProgress;
|
||||
|
||||
public sealed class RankProgressService : IRankProgressService
|
||||
{
|
||||
// Simple constants for v1. Promote to a [ConfigSection("RankProgress")] if per-mode
|
||||
// tuning is needed later (mirror BattleXpConfig's shape).
|
||||
public const int PointsPerWin = 100;
|
||||
public const int PointsPerLoss = 50;
|
||||
|
||||
private const int MasterRankId = 25;
|
||||
private const int FirstGrandMasterRankId = 26;
|
||||
|
||||
private readonly IGlobalsRepository _globals;
|
||||
private readonly ILogger<RankProgressService> _log;
|
||||
|
||||
private Dictionary<int, RankInfoEntry>? _byId;
|
||||
|
||||
public RankProgressService(IGlobalsRepository globals, ILogger<RankProgressService> log)
|
||||
{
|
||||
_globals = globals;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<RankProgressResult> GrantAsync(
|
||||
Viewer viewer, Format format, bool isWin, CancellationToken ct = default)
|
||||
{
|
||||
EnsureSupportedFormat(format);
|
||||
var byId = await LoadRanksAsync();
|
||||
|
||||
var row = viewer.RankProgress.FirstOrDefault(p => p.Format == format);
|
||||
if (row is null)
|
||||
{
|
||||
row = new ViewerRankProgress { Format = format, Point = 0, MasterPoint = 0 };
|
||||
viewer.RankProgress.Add(row);
|
||||
}
|
||||
|
||||
// Snapshot pre-tier for the TierAdvanced signal — compared to post-tier below.
|
||||
string? preTier = RankTier.Name(CurrentRankId(row, byId));
|
||||
|
||||
int deltaPoint = 0;
|
||||
int deltaMp = 0;
|
||||
int masterFloor = byId[MasterRankId].LowerLimitPoint; // 50000
|
||||
|
||||
if (isWin)
|
||||
{
|
||||
if (row.Point < masterFloor)
|
||||
{
|
||||
deltaPoint = PointsPerWin;
|
||||
row.Point += PointsPerWin;
|
||||
}
|
||||
else
|
||||
{
|
||||
deltaMp = PointsPerWin;
|
||||
row.MasterPoint += PointsPerWin;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (row.MasterPoint > 0)
|
||||
{
|
||||
// Once you're at Grand Master (rank_id >= 26), MP can never drop back to
|
||||
// Master. Floor = the Master→GM0 threshold from ranks.csv (5000).
|
||||
int rankBeforeDemotion = CurrentRankId(row, byId);
|
||||
int mpFloor = rankBeforeDemotion >= FirstGrandMasterRankId
|
||||
? byId[MasterRankId].AccumulateMasterPoint
|
||||
: 0;
|
||||
int newMp = Math.Max(row.MasterPoint - PointsPerLoss, mpFloor);
|
||||
deltaMp = newMp - row.MasterPoint;
|
||||
row.MasterPoint = newMp;
|
||||
}
|
||||
else
|
||||
{
|
||||
int currentRank = CurrentRankId(row, byId);
|
||||
int floor = byId[currentRank].LowerLimitPoint;
|
||||
int newPoint = Math.Max(row.Point - PointsPerLoss, floor);
|
||||
deltaPoint = newPoint - row.Point;
|
||||
row.Point = newPoint;
|
||||
}
|
||||
}
|
||||
|
||||
int finalRank = CurrentRankId(row, byId);
|
||||
string? postTier = RankTier.Name(finalRank);
|
||||
// Tier "advanced" only on a promotion (pre != post AND both resolve). Demotion by
|
||||
// point loss would technically change the tier string too, but rank_achieved is
|
||||
// an achievement — it doesn't fire on going backward.
|
||||
bool tierAdvanced = isWin && preTier != postTier && postTier is not null && preTier is not null;
|
||||
|
||||
return new RankProgressResult(
|
||||
Rank: finalRank,
|
||||
AfterBattlePoint: row.Point,
|
||||
AfterMasterPoint: row.MasterPoint,
|
||||
BattlePoint: deltaPoint,
|
||||
MasterPoint: deltaMp,
|
||||
IsMasterRank: finalRank == MasterRankId,
|
||||
IsGrandMasterRank: finalRank >= FirstGrandMasterRankId,
|
||||
TierAdvanced: tierAdvanced);
|
||||
}
|
||||
|
||||
public async Task<RankProgressResult> GetAsync(
|
||||
Viewer viewer, Format format, CancellationToken ct = default)
|
||||
{
|
||||
var byId = await LoadRanksAsync();
|
||||
var row = viewer.RankProgress.FirstOrDefault(p => p.Format == format)
|
||||
?? new ViewerRankProgress { Format = format };
|
||||
int rank = CurrentRankId(row, byId);
|
||||
return new RankProgressResult(
|
||||
Rank: rank,
|
||||
AfterBattlePoint: row.Point,
|
||||
AfterMasterPoint: row.MasterPoint,
|
||||
BattlePoint: 0,
|
||||
MasterPoint: 0,
|
||||
IsMasterRank: rank == MasterRankId,
|
||||
IsGrandMasterRank: rank >= FirstGrandMasterRankId);
|
||||
}
|
||||
|
||||
private static void EnsureSupportedFormat(Format format)
|
||||
{
|
||||
if (format != Format.Rotation && format != Format.Unlimited)
|
||||
throw new ArgumentOutOfRangeException(nameof(format),
|
||||
$"RankProgressService supports only Rotation/Unlimited, got {format}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Highest rank_id whose entry conditions the viewer meets.
|
||||
/// - MP >= previous rank's AccumulateMasterPoint threshold → GM tier (26..29)
|
||||
/// - Point >= 50000 (MasterRankId.LowerLimitPoint) → 25 (Master)
|
||||
/// - Otherwise: smallest rank_id in 1..24 where Point < AccumulatePoint.
|
||||
/// </summary>
|
||||
private static int CurrentRankId(ViewerRankProgress row, Dictionary<int, RankInfoEntry> byId)
|
||||
{
|
||||
for (int gm = 29; gm >= FirstGrandMasterRankId; gm--)
|
||||
{
|
||||
int threshold = byId[gm - 1].AccumulateMasterPoint;
|
||||
if (threshold > 0 && row.MasterPoint >= threshold)
|
||||
return gm;
|
||||
}
|
||||
|
||||
if (row.Point >= byId[MasterRankId].LowerLimitPoint)
|
||||
return MasterRankId;
|
||||
|
||||
for (int r = 1; r <= 24; r++)
|
||||
{
|
||||
if (row.Point < byId[r].AccumulatePoint) return r;
|
||||
}
|
||||
return 24; // guardrail; shouldn't be reachable.
|
||||
}
|
||||
|
||||
private async Task<Dictionary<int, RankInfoEntry>> LoadRanksAsync()
|
||||
{
|
||||
if (_byId is not null) return _byId;
|
||||
var rows = await _globals.GetRankInfo();
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
_log.LogWarning("RankProgressService: RankInfo table is empty; grant will no-op.");
|
||||
}
|
||||
_byId = rows.ToDictionary(r => r.Id);
|
||||
return _byId;
|
||||
}
|
||||
}
|
||||
17
SVSim.EmulatedEntrypoint/Configuration/AdminOptions.cs
Normal file
17
SVSim.EmulatedEntrypoint/Configuration/AdminOptions.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace SVSim.EmulatedEntrypoint.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Config for the /admin/* util endpoints. Bound from the "Admin" section of appsettings.
|
||||
/// </summary>
|
||||
public class AdminOptions
|
||||
{
|
||||
public const string SectionName = "Admin";
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret required in the <c>X-Admin-Secret</c> header on protected admin endpoints
|
||||
/// (see <see cref="Infrastructure.RequireAdminSecretAttribute"/>). Empty / whitespace means
|
||||
/// the endpoint is disabled — the filter fails closed so an unconfigured deployment never
|
||||
/// exposes the admin surface.
|
||||
/// </summary>
|
||||
public string ImportSecret { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -16,21 +16,25 @@ using SVSim.EmulatedEntrypoint.Services;
|
||||
namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Util endpoints for bootstrapping the dev environment. Anonymous-allowed today — security
|
||||
/// audit pending (don't expose these to the public internet).
|
||||
/// Util endpoints for bootstrapping the dev environment. Actions are gated by
|
||||
/// <see cref="RequireAdminSecretAttribute"/>: callers must send the shared secret from
|
||||
/// <c>Admin:ImportSecret</c> in the <c>X-Admin-Secret</c> header. Missing/blank config
|
||||
/// disables the endpoint (fail-closed).
|
||||
/// </summary>
|
||||
public class AdminController : SVSimController
|
||||
{
|
||||
private readonly IViewerRepository _viewerRepository;
|
||||
private readonly SVSimDbContext _dbContext;
|
||||
private readonly ILogger<AdminController> _logger;
|
||||
private readonly IGameCalendarService _calendar;
|
||||
|
||||
public AdminController(IViewerRepository viewerRepository, SVSimDbContext dbContext,
|
||||
ILogger<AdminController> logger)
|
||||
ILogger<AdminController> logger, IGameCalendarService calendar)
|
||||
{
|
||||
_viewerRepository = viewerRepository;
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
_calendar = calendar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -39,6 +43,7 @@ public class AdminController : SVSimController
|
||||
/// Only essential fields are imported today — extend as needed.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[RequireAdminSecret]
|
||||
[HttpPost("import_viewer")]
|
||||
public async Task<ActionResult<ImportViewerResponse>> ImportViewer(ImportViewerRequest request)
|
||||
{
|
||||
@@ -262,11 +267,17 @@ public class AdminController : SVSimController
|
||||
{
|
||||
viewer.MissionData ??= new ViewerMissionData();
|
||||
if (meta.HasReceivedPickTwoMission is { } hrptm)
|
||||
viewer.MissionData.HasReceivedPickTwoMission = hrptm;
|
||||
viewer.MissionData.HasReceivedPickTwoMission = hrptm != 0;
|
||||
if (meta.MissionReceiveType is { } mrt)
|
||||
viewer.MissionData.MissionReceiveType = mrt;
|
||||
if (meta.MissionChangeTime is { } mct)
|
||||
viewer.MissionData.MissionChangeTime = DateTimeOffset.FromUnixTimeSeconds(mct).UtcDateTime;
|
||||
if (meta.MissionChangeTime is { } mct
|
||||
&& DateTime.TryParseExact(mct, "yyyy-MM-dd HH:mm:ss",
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
|
||||
out var mctParsed))
|
||||
{
|
||||
viewer.MissionData.MissionChangeTime = mctParsed;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass B: Missions + ViewerEventCounter
|
||||
@@ -610,13 +621,13 @@ public class AdminController : SVSimController
|
||||
}
|
||||
|
||||
// TODO: unify with MissionAssembler.cs — same logic duplicated here.
|
||||
private static (string EventKey, string Period)? ResolveMissionCounter(MissionCatalogEntry catalog, DateTimeOffset nowUtc)
|
||||
private (string EventKey, string Period)? ResolveMissionCounter(MissionCatalogEntry catalog, DateTimeOffset nowUtc)
|
||||
{
|
||||
if (string.IsNullOrEmpty(catalog.EventType)) return null;
|
||||
var period = catalog.LotType switch
|
||||
{
|
||||
6 => JstPeriod.DayKey(nowUtc),
|
||||
2 => JstPeriod.WeekKey(nowUtc),
|
||||
6 => _calendar.DayKey(nowUtc),
|
||||
2 => _calendar.WeekKey(nowUtc),
|
||||
_ => null
|
||||
};
|
||||
if (period is null) return null;
|
||||
@@ -627,7 +638,7 @@ public class AdminController : SVSimController
|
||||
private static (string EventKey, string Period)? ResolveAchievementCounter(AchievementCatalogEntry catalog)
|
||||
{
|
||||
if (string.IsNullOrEmpty(catalog.EventType)) return null;
|
||||
return (catalog.EventType!, JstPeriod.AllTime);
|
||||
return (catalog.EventType!, GameCalendarPeriods.AllTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,7 +2,10 @@ using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.BattleXp;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Matching;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.ArenaColosseum;
|
||||
@@ -36,6 +39,10 @@ public sealed class ArenaColosseumBattleController : ControllerBase
|
||||
private readonly IMatchingResolver _resolver;
|
||||
private readonly IArenaColosseumRunRepository _runs;
|
||||
private readonly IColosseumProgressionService _progression;
|
||||
private readonly IViewerRepository _viewers;
|
||||
private readonly IBattleXpService _xp;
|
||||
private readonly IMissionProgressService _missionProgress;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ILogger<ArenaColosseumBattleController> _log;
|
||||
|
||||
public ArenaColosseumBattleController(
|
||||
@@ -43,12 +50,20 @@ public sealed class ArenaColosseumBattleController : ControllerBase
|
||||
IMatchingResolver resolver,
|
||||
IArenaColosseumRunRepository runs,
|
||||
IColosseumProgressionService progression,
|
||||
IViewerRepository viewers,
|
||||
IBattleXpService xp,
|
||||
IMissionProgressService missionProgress,
|
||||
SVSimDbContext db,
|
||||
ILogger<ArenaColosseumBattleController> log)
|
||||
{
|
||||
_ctxBuilder = ctxBuilder;
|
||||
_resolver = resolver;
|
||||
_runs = runs;
|
||||
_progression = progression;
|
||||
_viewers = viewers;
|
||||
_xp = xp;
|
||||
_missionProgress = missionProgress;
|
||||
_db = db;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
@@ -162,10 +177,35 @@ public sealed class ArenaColosseumBattleController : ControllerBase
|
||||
await _runs.UpsertAsync(run);
|
||||
}
|
||||
|
||||
int gainXp = 0, totalXp = 0, level = 1;
|
||||
bool leveledUp = false;
|
||||
var viewer = await _viewers.LoadForBattleXpGrantAsync(vid, ct);
|
||||
if (viewer is not null)
|
||||
{
|
||||
var xp = await _xp.GrantAsync(viewer, req.ClassId, req.BattleResult == 1, BattleXpMode.Colosseum, ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
gainXp = xp.GetXp;
|
||||
totalXp = xp.TotalXp;
|
||||
level = xp.Level == 0 ? 1 : xp.Level;
|
||||
leveledUp = xp.LeveledUp;
|
||||
}
|
||||
|
||||
if (leveledUp)
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(
|
||||
vid, MissionEventKeys.ClassLevel.UpAll(req.ClassId), ct: ct);
|
||||
}
|
||||
|
||||
// result_code 3502 ("battle already finished") is the idempotent-retry tolerance per
|
||||
// FinishTaskBase.IsEffectiveErrorCode. Server emits standard data; the translation
|
||||
// middleware sets the wire result_code via data_headers from the response envelope.
|
||||
return Ok(new ColosseumBattleFinishResponseDto { BattleResult = req.BattleResult });
|
||||
return Ok(new ColosseumBattleFinishResponseDto
|
||||
{
|
||||
BattleResult = req.BattleResult,
|
||||
GetClassExperience = gainXp,
|
||||
ClassExperience = totalXp,
|
||||
ClassLevel = level,
|
||||
});
|
||||
}
|
||||
|
||||
private static List<int> ParseIntList(string json) =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.Friend;
|
||||
using SVSim.Database.Services.Replay;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
@@ -20,6 +21,7 @@ public class ArenaTwoPickBattleController : SVSimController
|
||||
private readonly IBattleContextStore _battleContextStore;
|
||||
private readonly IBattleHistoryWriter _historyWriter;
|
||||
private readonly IPlayedTogetherWriter _playedTogetherWriter;
|
||||
private readonly IMissionProgressService _missionProgress;
|
||||
|
||||
public ArenaTwoPickBattleController(
|
||||
IArenaTwoPickService svc,
|
||||
@@ -27,7 +29,8 @@ public class ArenaTwoPickBattleController : SVSimController
|
||||
IMatchingResolver resolver,
|
||||
IBattleContextStore battleContextStore,
|
||||
IBattleHistoryWriter historyWriter,
|
||||
IPlayedTogetherWriter playedTogetherWriter)
|
||||
IPlayedTogetherWriter playedTogetherWriter,
|
||||
IMissionProgressService missionProgress)
|
||||
{
|
||||
_svc = svc;
|
||||
_matchContextBuilder = matchContextBuilder;
|
||||
@@ -35,6 +38,7 @@ public class ArenaTwoPickBattleController : SVSimController
|
||||
_battleContextStore = battleContextStore;
|
||||
_historyWriter = historyWriter;
|
||||
_playedTogetherWriter = playedTogetherWriter;
|
||||
_missionProgress = missionProgress;
|
||||
}
|
||||
|
||||
[HttpPost("do_matching")]
|
||||
@@ -117,6 +121,19 @@ public class ArenaTwoPickBattleController : SVSimController
|
||||
}
|
||||
|
||||
var result = await _svc.RecordBattleResultAsync(vid, isWin);
|
||||
|
||||
// Mission counters — TK2 matches always advance challenge_play, wins additionally
|
||||
// advance challenge_win + the ranked/arena/daily aggregates.
|
||||
await _missionProgress.RecordEventAsync(
|
||||
vid,
|
||||
isWin ? MissionEventKeys.Challenge.MatchWinAll() : MissionEventKeys.Challenge.MatchPlayAll(),
|
||||
ct: ct);
|
||||
if (result.LeveledUp)
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(
|
||||
vid, MissionEventKeys.ClassLevel.UpAll(result.ClassId), ct: ct);
|
||||
}
|
||||
|
||||
return Ok(new BattleFinishResponseDto
|
||||
{
|
||||
BattleResult = result.BattleResult,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.ArenaTwoPick;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
@@ -8,7 +9,13 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
public class ArenaTwoPickController : SVSimController
|
||||
{
|
||||
private readonly IArenaTwoPickService _svc;
|
||||
public ArenaTwoPickController(IArenaTwoPickService svc) => _svc = svc;
|
||||
private readonly IMissionProgressService _missionProgress;
|
||||
|
||||
public ArenaTwoPickController(IArenaTwoPickService svc, IMissionProgressService missionProgress)
|
||||
{
|
||||
_svc = svc;
|
||||
_missionProgress = missionProgress;
|
||||
}
|
||||
|
||||
[HttpPost("top")]
|
||||
public async Task<IActionResult> Top([FromBody] TopRequest _)
|
||||
@@ -46,10 +53,19 @@ public class ArenaTwoPickController : SVSimController
|
||||
}
|
||||
|
||||
[HttpPost("finish")]
|
||||
public async Task<IActionResult> Finish([FromBody] FinishRequest _)
|
||||
public async Task<IActionResult> Finish([FromBody] FinishRequest _, CancellationToken ct = default)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
return await GuardAsync(() => _svc.FinishAsync(vid));
|
||||
return await GuardAsync(async () =>
|
||||
{
|
||||
var outcome = await _svc.FinishAsync(vid);
|
||||
if (outcome.WasFullClear)
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(
|
||||
vid, MissionEventKeys.Challenge.FullClearAll(), ct: ct);
|
||||
}
|
||||
return outcome.Response;
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<IActionResult> GuardAsync<T>(Func<Task<T>> action)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.BattleXp;
|
||||
using SVSim.Database.Services.Friend;
|
||||
using SVSim.Database.Services.Replay;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
@@ -32,6 +36,10 @@ public sealed class FreeBattleController : ControllerBase
|
||||
private readonly IBattleContextStore _battleContextStore;
|
||||
private readonly IBattleHistoryWriter _historyWriter;
|
||||
private readonly IPlayedTogetherWriter _playedTogetherWriter;
|
||||
private readonly IViewerRepository _viewers;
|
||||
private readonly IBattleXpService _xp;
|
||||
private readonly IMissionProgressService _missionProgress;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ILogger<FreeBattleController> _log;
|
||||
|
||||
public FreeBattleController(
|
||||
@@ -40,6 +48,10 @@ public sealed class FreeBattleController : ControllerBase
|
||||
IBattleContextStore battleContextStore,
|
||||
IBattleHistoryWriter historyWriter,
|
||||
IPlayedTogetherWriter playedTogetherWriter,
|
||||
IViewerRepository viewers,
|
||||
IBattleXpService xp,
|
||||
IMissionProgressService missionProgress,
|
||||
SVSimDbContext db,
|
||||
ILogger<FreeBattleController> log)
|
||||
{
|
||||
_resolver = resolver;
|
||||
@@ -47,6 +59,10 @@ public sealed class FreeBattleController : ControllerBase
|
||||
_battleContextStore = battleContextStore;
|
||||
_historyWriter = historyWriter;
|
||||
_playedTogetherWriter = playedTogetherWriter;
|
||||
_viewers = viewers;
|
||||
_xp = xp;
|
||||
_missionProgress = missionProgress;
|
||||
_db = db;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
@@ -97,10 +113,37 @@ public sealed class FreeBattleController : ControllerBase
|
||||
ct);
|
||||
}
|
||||
|
||||
int gainXp = 0, totalXp = 0, level = 1;
|
||||
bool leveledUp = false;
|
||||
var viewer = await _viewers.LoadForBattleXpGrantAsync(vid, ct);
|
||||
if (viewer is not null)
|
||||
{
|
||||
var xp = await _xp.GrantAsync(viewer, req.ClassId, isWin, BattleXpMode.Free, ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
gainXp = xp.GetXp;
|
||||
totalXp = xp.TotalXp;
|
||||
level = xp.Level == 0 ? 1 : xp.Level;
|
||||
leveledUp = xp.LeveledUp;
|
||||
}
|
||||
|
||||
// Mission counters — unranked wins feed ranked_or_arena_win + daily_match_win.
|
||||
if (isWin)
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(
|
||||
vid, MissionEventKeys.Free.WinAll(), ct: ct);
|
||||
}
|
||||
if (leveledUp)
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(
|
||||
vid, MissionEventKeys.ClassLevel.UpAll(req.ClassId), ct: ct);
|
||||
}
|
||||
|
||||
return Ok(new FreeBattleFinishResponseDto
|
||||
{
|
||||
BattleResult = req.BattleResult,
|
||||
// All other fields default to 0 (ClassLevel defaults to 1).
|
||||
GetClassExperience = gainXp,
|
||||
ClassExperience = totalXp,
|
||||
ClassLevel = level,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -396,17 +396,19 @@ public sealed class GuildController : SVSimController
|
||||
|
||||
var viewerIds = members.Select(m => m.ViewerId).ToList();
|
||||
var profiles = await _viewers.LoadGuildProfileBatchAsync(viewerIds, ct);
|
||||
var relations = await _friends.GetFriendRelationsAsync(callerViewerId, viewerIds, ct);
|
||||
|
||||
var result = new List<GuildMemberInfoDto>(members.Count);
|
||||
foreach (var m in members)
|
||||
{
|
||||
profiles.TryGetValue(m.ViewerId, out var p);
|
||||
result.Add(ToMemberDto(m, p));
|
||||
relations.TryGetValue(m.ViewerId, out var r);
|
||||
result.Add(ToMemberDto(m, p, r));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static GuildMemberInfoDto ToMemberDto(GuildMember member, GuildMemberProfile? profile) => new()
|
||||
private static GuildMemberInfoDto ToMemberDto(GuildMember member, GuildMemberProfile? profile, FriendRelation? relation) => new()
|
||||
{
|
||||
ViewerId = member.ViewerId,
|
||||
Name = profile?.Name ?? "",
|
||||
@@ -414,6 +416,8 @@ public sealed class GuildController : SVSimController
|
||||
CountryCode = profile?.CountryCode ?? "",
|
||||
Rank = profile?.Rank ?? 1,
|
||||
DegreeId = profile?.DegreeId ?? 0,
|
||||
IsFriend = relation?.IsFriend == true ? 1 : 0,
|
||||
IsFriendApply = relation?.HasOutgoingApply == true ? 1 : 0,
|
||||
IsOfficialMarkDisplayed = profile?.IsOfficialMarkDisplayed == true ? 1 : 0,
|
||||
Role = (int)member.Role,
|
||||
};
|
||||
|
||||
@@ -24,12 +24,14 @@ public class ItemPurchaseController : SVSimController
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IInventoryService _inv;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly IGameCalendarService _calendar;
|
||||
|
||||
public ItemPurchaseController(SVSimDbContext db, IInventoryService inv, TimeProvider time)
|
||||
public ItemPurchaseController(SVSimDbContext db, IInventoryService inv, TimeProvider time, IGameCalendarService calendar)
|
||||
{
|
||||
_db = db;
|
||||
_inv = inv;
|
||||
_time = time;
|
||||
_calendar = calendar;
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
@@ -43,7 +45,7 @@ public class ItemPurchaseController : SVSimController
|
||||
.ToListAsync();
|
||||
|
||||
var now = _time.GetUtcNow();
|
||||
var monthKey = JstPeriod.MonthKey(now);
|
||||
var monthKey = _calendar.MonthKey(now);
|
||||
var keys = catalog.Select(c => CounterKey(c.Id)).ToList();
|
||||
var counters = await _db.ViewerEventCounters
|
||||
.Where(c => c.ViewerId == viewerId && keys.Contains(c.EventKey))
|
||||
@@ -104,7 +106,7 @@ public class ItemPurchaseController : SVSimController
|
||||
return BadRequest(new { error = "unknown_purchase" });
|
||||
|
||||
var now = _time.GetUtcNow();
|
||||
var period = entry.IsMonthlyReset ? JstPeriod.MonthKey(now) : JstPeriod.AllTime;
|
||||
var period = entry.IsMonthlyReset ? _calendar.MonthKey(now) : GameCalendarPeriods.AllTime;
|
||||
var key = CounterKey(entry.Id);
|
||||
|
||||
var counter = await _db.ViewerEventCounters
|
||||
@@ -158,11 +160,11 @@ public class ItemPurchaseController : SVSimController
|
||||
_ => "debit_type_not_supported",
|
||||
};
|
||||
|
||||
private static string CounterKey(int purchaseId) => $"item_purchase:{purchaseId}";
|
||||
private static string CounterKey(int purchaseId) => MissionEventKeys.ItemPurchase(purchaseId);
|
||||
|
||||
private static int CounterCount(List<ViewerEventCounter> counters, ItemPurchaseCatalogEntry entry, string monthKey)
|
||||
{
|
||||
var period = entry.IsMonthlyReset ? monthKey : JstPeriod.AllTime;
|
||||
var period = entry.IsMonthlyReset ? monthKey : GameCalendarPeriods.AllTime;
|
||||
return counters.FirstOrDefault(c => c.EventKey == CounterKey(entry.Id) && c.Period == period)?.Count ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ public class MyPageController : SVSimController
|
||||
CanGiveDailyLoginBonus = _loginBonus.IsDue(viewer),
|
||||
UnreceivedMissionRewardCount = 0, // TODO(mypage-stub): viewer mission progress
|
||||
ReceiveFriendApplyCount = 0, // TODO(mypage-stub): viewer friend-request inbox
|
||||
UnreadPresentCount = 0, // TODO(mypage-stub): viewer presents/mail
|
||||
UnreadPresentCount = await _viewerRepository.CountUnclaimedPresentsAsync(viewer.Id, HttpContext.RequestAborted),
|
||||
FriendBattleInviteCount = 0, // TODO(mypage-stub): viewer room-invite count
|
||||
GuildNotification = await BuildGuildNotificationAsync(viewer.Id, HttpContext.RequestAborted),
|
||||
LastAnnounceId = 0, // TODO(mypage-stub): globals announcement metadata
|
||||
|
||||
@@ -34,6 +34,7 @@ public class PackController : SVSimController
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly IInventoryService _inv;
|
||||
private readonly IGachaPointService _gachaPoint;
|
||||
private readonly IGameCalendarService _calendar;
|
||||
|
||||
public PackController(
|
||||
IPackRepository packs,
|
||||
@@ -43,7 +44,8 @@ public class PackController : SVSimController
|
||||
IRandom rng,
|
||||
SVSimDbContext db,
|
||||
IInventoryService inv,
|
||||
IGachaPointService gachaPoint)
|
||||
IGachaPointService gachaPoint,
|
||||
IGameCalendarService calendar)
|
||||
{
|
||||
_packs = packs;
|
||||
_opener = opener;
|
||||
@@ -53,6 +55,7 @@ public class PackController : SVSimController
|
||||
_db = db;
|
||||
_inv = inv;
|
||||
_gachaPoint = gachaPoint;
|
||||
_calendar = calendar;
|
||||
}
|
||||
|
||||
[HttpPost("info")]
|
||||
@@ -120,7 +123,7 @@ public class PackController : SVSimController
|
||||
};
|
||||
}
|
||||
|
||||
private static PackConfigDto ToDto(
|
||||
private PackConfigDto ToDto(
|
||||
PackConfigEntry p,
|
||||
IReadOnlyDictionary<int, ViewerPackOpenCount> openCounts,
|
||||
IReadOnlyDictionary<long, int> ownedItemsByItemId,
|
||||
@@ -130,17 +133,25 @@ public class PackController : SVSimController
|
||||
{
|
||||
int openCount = openCounts.TryGetValue(p.Id, out var oc) ? oc.OpenCount : 0;
|
||||
|
||||
// Suppress the daily-single half-off flag once the viewer has claimed this parent
|
||||
// pack's DAILY child today. The client's UI is entirely wire-driven — it renders the
|
||||
// half-off button iff `is_daily_single: true` (GachaPackAreaLayout.cs:420,
|
||||
// PackInfoTask.cs:143 → PackChildGachaInfo.IsDailySingle). Without this gate the button
|
||||
// stays visible after the first successful open and a second click 400s with
|
||||
// `daily_free_already_claimed`. Keep the child entry itself so the CRYSTAL_MULTI
|
||||
// full-price button still activates.
|
||||
bool dailyClaimedToday = !_calendar.ResetReady(oc?.LastDailyFreeAt);
|
||||
|
||||
// Drop type_detail=10 (FREE_PACKS) children whose daily quota for THIS viewer is spent.
|
||||
// Mirrors prod behavior: post-claim /pack/info simply omits the free child from
|
||||
// child_gacha_info (verified in traffic_event_crate_free_pack.ndjson lines 28→32).
|
||||
// Today's claim count >= DailyFreeGachaCount and same UTC date => hide.
|
||||
var today = DateTime.UtcNow.Date;
|
||||
// Reset happens at the daily boundary — new day resets ClaimCount effectively.
|
||||
bool ChildAvailable(PackChildGachaEntry c)
|
||||
{
|
||||
if (c.TypeDetail != CardPackType.FreePacks) return true;
|
||||
if (c.FreeGachaCampaignId is not int campaignId) return true;
|
||||
if (!freeClaimsByCampaignId.TryGetValue(campaignId, out var claim)) return true;
|
||||
if (claim.LastClaimedAt.Date != today) return true;
|
||||
if (_calendar.ResetReady(claim.LastClaimedAt)) return true;
|
||||
int dailyCap = c.DailyFreeGachaCount > 0 ? c.DailyFreeGachaCount : 1;
|
||||
return claim.ClaimCount < dailyCap;
|
||||
}
|
||||
@@ -201,7 +212,7 @@ public class PackController : SVSimController
|
||||
ItemNumber = c.ItemId is long iid && ownedItemsByItemId.TryGetValue(iid, out var ownedCount)
|
||||
? ownedCount
|
||||
: 0,
|
||||
IsDailySingle = c.IsDailySingle,
|
||||
IsDailySingle = c.IsDailySingle && !(c.TypeDetail == CardPackType.Daily && dailyClaimedToday),
|
||||
OverrideIncreaseGachaPoint = c.OverrideIncreaseGachaPoint.ToString(CultureInfo.InvariantCulture),
|
||||
CampaignName = c.CampaignName,
|
||||
PurchaseLimitCount = c.PurchaseLimitCount > 0
|
||||
@@ -329,16 +340,14 @@ public class PackController : SVSimController
|
||||
{
|
||||
if (!TryGetViewerId(out long viewerId)) return Unauthorized();
|
||||
|
||||
// /tutorial/pack_open is a plain alias for /pack/open — the client uses it during the
|
||||
// tutorial's final "open the starter legendary pack" step. The only wire-level
|
||||
// difference is that the response carries tutorial_step=100 so the client transitions
|
||||
// out of the tutorial. Currency/ticket debits, open-count tracking, and pack identity
|
||||
// all flow through the normal path — the tutorial gift's tickets naturally constrain
|
||||
// which packs are openable via this alias to the current throwback starter pair.
|
||||
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" });
|
||||
|
||||
// Skin-card overload not implemented; rotation-starter (class_id) IS supported below.
|
||||
if (request.TargetCardId.HasValue)
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "skin_overload_not_implemented" });
|
||||
@@ -391,14 +400,13 @@ public class PackController : SVSimController
|
||||
// when buying a RUPY_MULTI (type_detail=7) child. The gacha_id alone disambiguates the
|
||||
// child; gacha_type validation against child.TypeDetail would falsely reject every buy.
|
||||
|
||||
// Supported on the normal path: Crystal / CrystalMulti -> spend crystals; Rupy /
|
||||
// RupyMulti -> spend rupees; Daily -> spend rupees, once per UTC day; Ticket /
|
||||
// TicketMulti -> consume child.ItemId from OwnedItemEntry; FreePacks -> no debit,
|
||||
// gated by per-campaign daily quota.
|
||||
// Supported: Crystal / CrystalMulti -> spend crystals; Rupy / RupyMulti -> spend rupees;
|
||||
// Daily -> spend rupees, once per UTC day; Ticket / TicketMulti -> consume child.ItemId
|
||||
// from OwnedItemEntry; FreePacks -> no debit, gated by per-campaign daily quota.
|
||||
// CrystalSpecial / CrystalSelectSkin / CrystalAcquireSkinCardPack and the
|
||||
// FreePackWithSkin / RotationStarterPack overlays need extra selection / banner
|
||||
// plumbing — kept 501 until the relevant flows land.
|
||||
if (!isTutorialPath && child.TypeDetail is not (
|
||||
if (child.TypeDetail is not (
|
||||
CardPackType.Crystal or CardPackType.CrystalMulti or CardPackType.Daily or
|
||||
CardPackType.Ticket or CardPackType.TicketMulti or CardPackType.Rupy or
|
||||
CardPackType.RupyMulti or CardPackType.FreePacks))
|
||||
@@ -415,111 +423,97 @@ public class PackController : SVSimController
|
||||
});
|
||||
var viewer = tx.Viewer;
|
||||
|
||||
// Tutorial alias is only valid pre-END. After state>=100 the viewer has already
|
||||
// completed the tutorial — re-running the path would re-consume the ticket they
|
||||
// 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 (skipped for tutorial path — starter pack is free)
|
||||
if (!isTutorialPath)
|
||||
// Currency check + deduction. TICKET_MULTI is the mechanism the tutorial alias rides
|
||||
// on: the tutorial gift grants the starter ticket, and this branch consumes it, so no
|
||||
// separate tutorial-path bypass is needed.
|
||||
switch (child.TypeDetail)
|
||||
{
|
||||
switch (child.TypeDetail)
|
||||
case CardPackType.Crystal:
|
||||
case CardPackType.CrystalMulti:
|
||||
{
|
||||
case CardPackType.Crystal:
|
||||
case CardPackType.CrystalMulti:
|
||||
{
|
||||
long cost = (long)child.Cost * packNumber;
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
|
||||
break;
|
||||
}
|
||||
case CardPackType.Rupy:
|
||||
case CardPackType.RupyMulti:
|
||||
{
|
||||
long cost = (long)child.Cost * packNumber;
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||
break;
|
||||
}
|
||||
case CardPackType.Daily:
|
||||
{
|
||||
// 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" });
|
||||
long cost = (long)child.Cost * packNumber;
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
|
||||
break;
|
||||
}
|
||||
case CardPackType.Rupy:
|
||||
case CardPackType.RupyMulti:
|
||||
{
|
||||
long cost = (long)child.Cost * packNumber;
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||
break;
|
||||
}
|
||||
case CardPackType.Daily:
|
||||
{
|
||||
// DAILY is the once-per-day half-off crystal single-pack (GachaUI.cs:1046 →
|
||||
// CheckBuyPackWithCrystal, decompile-confirmed). Currency is Crystal, NOT Rupee.
|
||||
var existing = viewer.PackOpenCounts.FirstOrDefault(p => p.PackId == pack.Id);
|
||||
if (!_calendar.ResetReady(existing?.LastDailyFreeAt))
|
||||
return BadRequest(new { error = "daily_free_already_claimed" });
|
||||
|
||||
long cost = (long)child.Cost * packNumber;
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Rupee, cost);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_rupees" });
|
||||
break;
|
||||
}
|
||||
case CardPackType.Ticket:
|
||||
case CardPackType.TicketMulti:
|
||||
long cost = (long)child.Cost * packNumber;
|
||||
var r = await tx.TrySpendAsync(SpendCurrency.Crystal, cost);
|
||||
if (!r.Success) return BadRequest(new { error = "insufficient_crystals" });
|
||||
break;
|
||||
}
|
||||
case CardPackType.Ticket:
|
||||
case CardPackType.TicketMulti:
|
||||
{
|
||||
if (child.ItemId is not long ticketItemId)
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
|
||||
|
||||
int ticketsNeeded = child.Cost * packNumber;
|
||||
var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded);
|
||||
if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" });
|
||||
break;
|
||||
}
|
||||
case CardPackType.FreePacks:
|
||||
{
|
||||
if (child.FreeGachaCampaignId is not int campaignId)
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "free_pack_missing_campaign_id" });
|
||||
|
||||
int dailyCap = child.DailyFreeGachaCount > 0 ? child.DailyFreeGachaCount : 1;
|
||||
var existing = viewer.FreePackClaims.FirstOrDefault(c => c.FreeGachaCampaignId == campaignId);
|
||||
bool resetSinceLastClaim = existing is null || _calendar.ResetReady(existing.LastClaimedAt);
|
||||
if (existing is not null && !resetSinceLastClaim && existing.ClaimCount >= dailyCap)
|
||||
return BadRequest(new { error = "free_pack_already_claimed_today" });
|
||||
|
||||
// pack_number is forced to 1 — free-pack metadata never authorizes multi-opens.
|
||||
// The capture shows pack_number=1 even when daily_free_gacha_count=1 == daily quota.
|
||||
packNumber = 1;
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
if (child.ItemId is not long ticketItemId)
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "ticket_pack_missing_item_id" });
|
||||
|
||||
int ticketsNeeded = child.Cost * packNumber;
|
||||
var debit = await tx.TryDebitAsync(UserGoodsType.Item, ticketItemId, ticketsNeeded);
|
||||
if (!debit.Success) return BadRequest(new { error = "insufficient_tickets" });
|
||||
break;
|
||||
}
|
||||
case CardPackType.FreePacks:
|
||||
{
|
||||
if (child.FreeGachaCampaignId is not int campaignId)
|
||||
return StatusCode(StatusCodes.Status501NotImplemented, new { error = "free_pack_missing_campaign_id" });
|
||||
|
||||
int dailyCap = child.DailyFreeGachaCount > 0 ? child.DailyFreeGachaCount : 1;
|
||||
var today = DateTime.UtcNow.Date;
|
||||
var existing = viewer.FreePackClaims.FirstOrDefault(c => c.FreeGachaCampaignId == campaignId);
|
||||
if (existing is not null && existing.LastClaimedAt.Date == today && existing.ClaimCount >= dailyCap)
|
||||
return BadRequest(new { error = "free_pack_already_claimed_today" });
|
||||
|
||||
// pack_number is forced to 1 — free-pack metadata never authorizes multi-opens.
|
||||
// The capture shows pack_number=1 even when daily_free_gacha_count=1 == daily quota.
|
||||
packNumber = 1;
|
||||
|
||||
if (existing is null)
|
||||
viewer.FreePackClaims.Add(new ViewerFreePackClaim
|
||||
{
|
||||
viewer.FreePackClaims.Add(new ViewerFreePackClaim
|
||||
{
|
||||
FreeGachaCampaignId = campaignId,
|
||||
ClaimCount = 1,
|
||||
LastClaimedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
else if (existing.LastClaimedAt.Date != today)
|
||||
{
|
||||
existing.ClaimCount = 1;
|
||||
existing.LastClaimedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ClaimCount++;
|
||||
existing.LastClaimedAt = DateTime.UtcNow;
|
||||
}
|
||||
break;
|
||||
FreeGachaCampaignId = campaignId,
|
||||
ClaimCount = 1,
|
||||
LastClaimedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
else if (resetSinceLastClaim)
|
||||
{
|
||||
existing.ClaimCount = 1;
|
||||
existing.LastClaimedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.ClaimCount++;
|
||||
existing.LastClaimedAt = DateTime.UtcNow;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.IncrementOpenCount(viewerId, pack.Id, packNumber);
|
||||
if (child.TypeDetail == CardPackType.Daily)
|
||||
{
|
||||
await _packs.IncrementOpenCount(viewerId, pack.Id, packNumber);
|
||||
if (child.TypeDetail == CardPackType.Daily)
|
||||
{
|
||||
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
|
||||
}
|
||||
await _packs.MarkDailyFreeUsed(viewerId, pack.Id, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
// Draw + persist. DAILY single overrides packNumber to 1 (it's a one-card open).
|
||||
@@ -552,42 +546,21 @@ public class PackController : SVSimController
|
||||
foreach (var grp in draw.Cards.GroupBy(c => c.CardId))
|
||||
await tx.GrantAsync(UserGoodsType.Card, grp.Key, grp.Count());
|
||||
|
||||
// Accrue gacha points (skip tutorial path — the starter pack isn't a real open).
|
||||
if (!isTutorialPath)
|
||||
{
|
||||
_gachaPoint.Accrue(viewer, pack, child, drawCount);
|
||||
}
|
||||
_gachaPoint.Accrue(viewer, pack, child, drawCount);
|
||||
|
||||
// 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).
|
||||
// Tutorial alias epilogue: advance TutorialState to END (max-preserve so a higher
|
||||
// sentinel is never regressed) and emit tutorial_step=100 on the wire. The client's
|
||||
// PackOpenTask.Parse runs _userTutorial.Update on the response — this is the END
|
||||
// signal that transitions the client out of the tutorial.
|
||||
int? responseTutorialStep = null;
|
||||
if (isTutorialPath)
|
||||
{
|
||||
if (child.ItemId is long tutorialTicketItemId)
|
||||
{
|
||||
int ticketsToConsume = packNumber;
|
||||
var debit = await tx.TryDebitAsync(UserGoodsType.Item, tutorialTicketItemId, ticketsToConsume);
|
||||
// Silently accept if the viewer doesn't have the ticket (already consumed or never granted)
|
||||
_ = debit;
|
||||
}
|
||||
|
||||
// Max-preserve: never regress the persisted state, even though Gate B already
|
||||
// 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;
|
||||
responseTutorialStep = TutorialEndStep;
|
||||
}
|
||||
|
||||
// CommitAsync saves all mutations and produces reward_list with currency-collision resolved.
|
||||
// Tutorial path never calls TrySpendAsync so no currency op is in the log — correct.
|
||||
var result = await tx.CommitAsync(HttpContext.RequestAborted);
|
||||
var rewardList = result.RewardList.ToRewardList();
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.BattleXp;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Common;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Practice;
|
||||
@@ -16,15 +20,24 @@ public class PracticeController : SVSimController
|
||||
private readonly IGlobalsRepository _globalsRepository;
|
||||
private readonly IMissionProgressService _missionProgress;
|
||||
private readonly IDeckListBuilder _deckListBuilder;
|
||||
private readonly IViewerRepository _viewers;
|
||||
private readonly IBattleXpService _xp;
|
||||
private readonly SVSimDbContext _db;
|
||||
|
||||
public PracticeController(
|
||||
IGlobalsRepository globalsRepository,
|
||||
IMissionProgressService missionProgress,
|
||||
IDeckListBuilder deckListBuilder)
|
||||
IDeckListBuilder deckListBuilder,
|
||||
IViewerRepository viewers,
|
||||
IBattleXpService xp,
|
||||
SVSimDbContext db)
|
||||
{
|
||||
_globalsRepository = globalsRepository;
|
||||
_missionProgress = missionProgress;
|
||||
_deckListBuilder = deckListBuilder;
|
||||
_viewers = viewers;
|
||||
_xp = xp;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -79,31 +92,59 @@ public class PracticeController : SVSimController
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// /practice/finish — accept the recovery_data blob without validation; return zero
|
||||
/// XP / no rewards. Class XP bookkeeping is deferred until a per-class XP store exists.
|
||||
/// /practice/finish — accept the recovery_data blob without validation; grant class XP
|
||||
/// (win or loss) via <see cref="IBattleXpService"/> and return the post-state totals.
|
||||
/// </summary>
|
||||
[HttpPost("finish")]
|
||||
public async Task<PracticeFinishResponse> Finish(PracticeFinishRequest request)
|
||||
{
|
||||
// Mission/achievement progress hook. Catalog rows for practice_win achievements use
|
||||
// opponent NAMES (e.g. "practice_win:elite:arisa") — we only have numeric class_id /
|
||||
// difficulty here, so we emit numeric forms. Bridging numeric→name to match captured
|
||||
// catalog rows is a follow-up; the infrastructure is in place.
|
||||
if (request.IsWin == 1 && TryGetViewerId(out long viewerId))
|
||||
bool isWin = request.IsWin == 1;
|
||||
|
||||
if (!TryGetViewerId(out long viewerId))
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(viewerId, new[]
|
||||
return new PracticeFinishResponse
|
||||
{
|
||||
"practice_win",
|
||||
$"practice_win:{request.Difficulty}",
|
||||
$"practice_win:{request.Difficulty}:{request.EnemyClassId}",
|
||||
});
|
||||
ClassLevel = 1,
|
||||
AchievedInfo = new Dictionary<string, object>(),
|
||||
RewardList = new List<Models.Dtos.Common.Reward>(),
|
||||
};
|
||||
}
|
||||
|
||||
// Mission/achievement progress hook. Wire values (difficulty int, enemy_class_id int)
|
||||
// are mapped to catalog-facing names by MissionEventKeys.Practice — the catalog
|
||||
// authors keys like "practice_win:elite:arisa", so the emitter must match. Wire
|
||||
// difficulties outside 4/6/7 (elite/elite2/elite3) only advance the top-level counter.
|
||||
if (isWin)
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(
|
||||
viewerId,
|
||||
MissionEventKeys.Practice.WinAll(request.Difficulty, request.EnemyClassId));
|
||||
}
|
||||
|
||||
int gainXp = 0, totalXp = 0, level = 1;
|
||||
bool leveledUp = false;
|
||||
var viewer = await _viewers.LoadForBattleXpGrantAsync(viewerId);
|
||||
if (viewer is not null)
|
||||
{
|
||||
var xp = await _xp.GrantAsync(viewer, request.ClassId, isWin, BattleXpMode.Practice);
|
||||
await _db.SaveChangesAsync();
|
||||
gainXp = xp.GetXp;
|
||||
totalXp = xp.TotalXp;
|
||||
level = xp.Level == 0 ? 1 : xp.Level;
|
||||
leveledUp = xp.LeveledUp;
|
||||
}
|
||||
|
||||
if (leveledUp)
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(
|
||||
viewerId, MissionEventKeys.ClassLevel.UpAll(request.ClassId));
|
||||
}
|
||||
|
||||
return new PracticeFinishResponse
|
||||
{
|
||||
GetClassExperience = 0,
|
||||
ClassExperience = 0,
|
||||
ClassLevel = 1,
|
||||
GetClassExperience = gainXp,
|
||||
ClassExperience = totalXp,
|
||||
ClassLevel = level,
|
||||
AchievedInfo = new Dictionary<string, object>(),
|
||||
RewardList = new List<Models.Dtos.Common.Reward>()
|
||||
};
|
||||
|
||||
@@ -2,8 +2,13 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.BattleXp;
|
||||
using SVSim.Database.Services.Friend;
|
||||
using SVSim.Database.Services.RankProgress;
|
||||
using SVSim.Database.Services.Replay;
|
||||
using SVSim.EmulatedEntrypoint.Constants;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
@@ -33,6 +38,11 @@ public sealed class RankBattleController : ControllerBase
|
||||
private readonly IBattleContextStore _battleContextStore;
|
||||
private readonly IBattleHistoryWriter _historyWriter;
|
||||
private readonly IPlayedTogetherWriter _playedTogetherWriter;
|
||||
private readonly IViewerRepository _viewers;
|
||||
private readonly IBattleXpService _xp;
|
||||
private readonly IRankProgressService _rankProgress;
|
||||
private readonly IMissionProgressService _missionProgress;
|
||||
private readonly SVSimDbContext _db;
|
||||
private readonly ILogger<RankBattleController> _log;
|
||||
|
||||
public RankBattleController(
|
||||
@@ -43,6 +53,11 @@ public sealed class RankBattleController : ControllerBase
|
||||
IBattleContextStore battleContextStore,
|
||||
IBattleHistoryWriter historyWriter,
|
||||
IPlayedTogetherWriter playedTogetherWriter,
|
||||
IViewerRepository viewers,
|
||||
IBattleXpService xp,
|
||||
IRankProgressService rankProgress,
|
||||
IMissionProgressService missionProgress,
|
||||
SVSimDbContext db,
|
||||
ILogger<RankBattleController> log)
|
||||
{
|
||||
_resolver = resolver;
|
||||
@@ -52,6 +67,11 @@ public sealed class RankBattleController : ControllerBase
|
||||
_battleContextStore = battleContextStore;
|
||||
_historyWriter = historyWriter;
|
||||
_playedTogetherWriter = playedTogetherWriter;
|
||||
_viewers = viewers;
|
||||
_xp = xp;
|
||||
_rankProgress = rankProgress;
|
||||
_missionProgress = missionProgress;
|
||||
_db = db;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
@@ -81,17 +101,35 @@ public sealed class RankBattleController : ControllerBase
|
||||
public Task<IActionResult> AiStartUnlimited([FromBody] BaseRequest _, CancellationToken ct)
|
||||
=> AiStartInternal(Format.Unlimited, ct);
|
||||
|
||||
// Finish routes route by URL to the shared handler with the format baked in — the URL
|
||||
// is authoritative (mirrors DoMatchingRotation/DoMatchingUnlimited). Deriving format
|
||||
// from the BattleContext is fragile: the client may reach /finish without a prior
|
||||
// /do_matching (e.g. reconnect, replay-of-log flows), and we still need rank
|
||||
// progression to land in the right format bucket. Client-side, RankBattleFinishTask
|
||||
// picks the URL by (Data.CurrentFormat, IsAINetwork) — see decompile lines 12-35.
|
||||
|
||||
[HttpPost("/rotation_rank_battle/finish")]
|
||||
public Task<IActionResult> FinishRotation([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
|
||||
=> FinishInternal(Format.Rotation, req, ct);
|
||||
|
||||
[HttpPost("/unlimited_rank_battle/finish")]
|
||||
public Task<IActionResult> FinishUnlimited([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
|
||||
=> FinishInternal(Format.Unlimited, req, ct);
|
||||
|
||||
[HttpPost("/ai_rotation_rank_battle/finish")]
|
||||
public Task<IActionResult> AiFinishRotation([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
|
||||
=> FinishInternal(Format.Rotation, req, ct);
|
||||
|
||||
[HttpPost("/ai_unlimited_rank_battle/finish")]
|
||||
public Task<IActionResult> AiFinishUnlimited([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
|
||||
=> FinishInternal(Format.Unlimited, req, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Shared finish handler — RankBattleFinishTask parses the same wire shape for
|
||||
/// all four URLs and routes server-side by URL (vs IsAINetwork flag in the client).
|
||||
/// Stubbed for Phase 3: echo battle_result, emit zeros elsewhere. Real rank
|
||||
/// progression math is a separate spec.
|
||||
/// all four URLs. Grants class XP + rank progression (+100 win / -50 loss,
|
||||
/// tier-floored). Format is baked into the route.
|
||||
/// </summary>
|
||||
[HttpPost("/rotation_rank_battle/finish")]
|
||||
[HttpPost("/unlimited_rank_battle/finish")]
|
||||
[HttpPost("/ai_rotation_rank_battle/finish")]
|
||||
[HttpPost("/ai_unlimited_rank_battle/finish")]
|
||||
public async Task<IActionResult> Finish([FromBody] RankBattleFinishRequestDto req, CancellationToken ct)
|
||||
private async Task<IActionResult> FinishInternal(Format format, RankBattleFinishRequestDto req, CancellationToken ct)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
|
||||
@@ -114,10 +152,49 @@ public sealed class RankBattleController : ControllerBase
|
||||
ct);
|
||||
}
|
||||
|
||||
int gainXp = 0, totalXp = 0, level = 1;
|
||||
bool leveledUp = false;
|
||||
var rankResult = new RankProgressResult(0, 0, 0, 0, 0, false, false);
|
||||
var viewer = await _viewers.LoadForRankProgressAsync(vid, ct);
|
||||
if (viewer is not null)
|
||||
{
|
||||
var xp = await _xp.GrantAsync(viewer, req.ClassId, isWin, BattleXpMode.Rank, ct);
|
||||
rankResult = await _rankProgress.GrantAsync(viewer, format, isWin, ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
gainXp = xp.GetXp;
|
||||
totalXp = xp.TotalXp;
|
||||
level = xp.Level == 0 ? 1 : xp.Level;
|
||||
leveledUp = xp.LeveledUp;
|
||||
}
|
||||
|
||||
// Mission counters — AI-rank routes emit the same keys as human-rank on a private server.
|
||||
if (isWin)
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(
|
||||
vid, MissionEventKeys.Ranked.WinAll(req.ClassId), ct: ct);
|
||||
}
|
||||
if (leveledUp)
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(
|
||||
vid, MissionEventKeys.ClassLevel.UpAll(req.ClassId), ct: ct);
|
||||
}
|
||||
if (rankResult.TierAdvanced)
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(
|
||||
vid, MissionEventKeys.Rank.AchievedAll(rankResult.Rank), ct: ct);
|
||||
}
|
||||
|
||||
return Ok(new RankBattleFinishResponseDto
|
||||
{
|
||||
BattleResult = req.BattleResult,
|
||||
// All other fields default to 0 in the DTO (ClassLevel defaults to 1).
|
||||
BattleResult = req.BattleResult,
|
||||
GetClassExperience = gainXp,
|
||||
ClassExperience = totalXp,
|
||||
ClassLevel = level,
|
||||
Rank = rankResult.Rank,
|
||||
AfterBattlePoint = rankResult.AfterBattlePoint,
|
||||
AfterMasterPoint = rankResult.AfterMasterPoint,
|
||||
BattlePoint = rankResult.BattlePoint,
|
||||
MasterPoint = rankResult.MasterPoint,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,6 +307,13 @@ public sealed class RankBattleController : ControllerBase
|
||||
var bot = await _botRoster.PickAsync(selfCtx, pending.BattleId, ct);
|
||||
var seed = Random.Shared.Next();
|
||||
|
||||
// Read the viewer's rank progression for this format so the pre-battle screen
|
||||
// shows the current tier/point rather than "Beginner 0 with 0 pts."
|
||||
var selfViewer = await _viewers.LoadForRankProgressAsync(vid, ct);
|
||||
var selfRank = selfViewer is null
|
||||
? new RankProgressResult(1, 0, 0, 0, 0, false, false)
|
||||
: await _rankProgress.GetAsync(selfViewer, format, ct);
|
||||
|
||||
// Stash battle context for the upcoming /finish so the replay-history hook can
|
||||
// compose a ViewerBattleHistory row. See docs/superpowers/specs/2026-06-10-replay-info-design.md.
|
||||
if (long.TryParse(pending.BattleId, out var battleIdLong))
|
||||
@@ -273,12 +357,12 @@ public sealed class RankBattleController : ControllerBase
|
||||
IsOfficial = selfCtx.IsOfficial,
|
||||
OppoId = bot.AiId,
|
||||
Seed = seed,
|
||||
Rank = 0,
|
||||
BattlePoint = 0,
|
||||
Rank = selfRank.Rank,
|
||||
BattlePoint = selfRank.AfterBattlePoint,
|
||||
ClassId = (int)selfCtx.ClassId,
|
||||
CharaId = int.TryParse(selfCtx.CharaId, out var chId) ? chId : -1,
|
||||
IsMasterRank = 0,
|
||||
MasterPoint = 0,
|
||||
IsMasterRank = selfRank.IsMasterRank ? 1 : 0,
|
||||
MasterPoint = selfRank.AfterMasterPoint,
|
||||
},
|
||||
OppoInfo = new AiBattlePlayerInfo
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SVSim.Database.Entities.Story;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
@@ -70,7 +71,7 @@ public class StoryController : SVSimController
|
||||
public async Task<ActionResult<FinishResponse>> Finish(FinishRequest req)
|
||||
{
|
||||
if (!TryGetViewerId(out long vid)) return Unauthorized();
|
||||
var result = await _service.FinishAsync(ResolveApiType(), req, vid);
|
||||
var outcome = await _service.FinishAsync(ResolveApiType(), req, vid);
|
||||
|
||||
// Emit story-chapter-finish events for mission/achievement progress.
|
||||
var apiType = ResolveApiType();
|
||||
@@ -83,15 +84,16 @@ public class StoryController : SVSimController
|
||||
};
|
||||
if (prefix is not null && req.StoryId != 0)
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(vid, new[]
|
||||
{
|
||||
"story_chapter_finish",
|
||||
$"story_chapter_finish:{prefix}",
|
||||
$"story_chapter_finish:{prefix}:{req.StoryId}",
|
||||
});
|
||||
await _missionProgress.RecordEventAsync(
|
||||
vid, MissionEventKeys.Story.ChapterFinishAll(prefix, req.StoryId));
|
||||
}
|
||||
if (outcome.LeveledUp && outcome.ClassId.HasValue)
|
||||
{
|
||||
await _missionProgress.RecordEventAsync(
|
||||
vid, MissionEventKeys.ClassLevel.UpAll(outcome.ClassId.Value));
|
||||
}
|
||||
|
||||
return result;
|
||||
return outcome.Response;
|
||||
}
|
||||
|
||||
[HttpPost("/main_story/all_finish")]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Attaches the <see cref="RequireAdminSecretAttribute.HeaderName"/> security requirement to
|
||||
/// Swagger operations whose action carries <see cref="RequireAdminSecretAttribute"/>. The
|
||||
/// matching <see cref="OpenApiSecurityScheme"/> is registered by <c>Program.cs</c> under the
|
||||
/// same scheme id (<see cref="SchemeId"/>) so the Swagger UI shows an Authorize dialog and,
|
||||
/// once populated, sends the header on gated endpoints only.
|
||||
/// </summary>
|
||||
public sealed class AdminSecretOperationFilter : IOperationFilter
|
||||
{
|
||||
public const string SchemeId = "AdminSecret";
|
||||
|
||||
public void Apply(OpenApiOperation operation, OperationFilterContext context)
|
||||
{
|
||||
var hasAttribute = context.MethodInfo.GetCustomAttributes(true)
|
||||
.OfType<RequireAdminSecretAttribute>().Any()
|
||||
|| (context.MethodInfo.DeclaringType?.GetCustomAttributes(true)
|
||||
.OfType<RequireAdminSecretAttribute>().Any() ?? false);
|
||||
|
||||
if (!hasAttribute) return;
|
||||
|
||||
operation.Security ??= new List<OpenApiSecurityRequirement>();
|
||||
operation.Security.Add(new OpenApiSecurityRequirement
|
||||
{
|
||||
[new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = SchemeId,
|
||||
}
|
||||
}] = Array.Empty<string>()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SVSim.EmulatedEntrypoint.Configuration;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Gates a controller or action on a shared secret carried in the <c>X-Admin-Secret</c> header,
|
||||
/// compared against <see cref="AdminOptions.ImportSecret"/>. Runs as an authorization filter so
|
||||
/// unauthorized requests short-circuit before model binding.
|
||||
///
|
||||
/// Fail-closed: if the configured secret is null/empty the endpoint is treated as disabled and
|
||||
/// every request gets a 401 (with a warning logged once per request). This means a deployment
|
||||
/// that forgets to set the secret leaves the endpoint locked, not open.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)]
|
||||
public sealed class RequireAdminSecretAttribute : Attribute, IAuthorizationFilter
|
||||
{
|
||||
public const string HeaderName = "X-Admin-Secret";
|
||||
|
||||
public void OnAuthorization(AuthorizationFilterContext context)
|
||||
{
|
||||
var services = context.HttpContext.RequestServices;
|
||||
var options = services.GetRequiredService<IOptions<AdminOptions>>().Value;
|
||||
var logger = services.GetRequiredService<ILogger<RequireAdminSecretAttribute>>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.ImportSecret))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejecting request to {Path}: Admin:ImportSecret is not configured. " +
|
||||
"Set it in appsettings (or the NPGSQL_ADMIN__IMPORTSECRET env var) to enable the endpoint.",
|
||||
context.HttpContext.Request.Path);
|
||||
context.Result = new UnauthorizedResult();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out var provided)
|
||||
|| provided.Count == 0
|
||||
|| string.IsNullOrEmpty(provided[0]))
|
||||
{
|
||||
context.Result = new UnauthorizedResult();
|
||||
return;
|
||||
}
|
||||
|
||||
var providedBytes = Encoding.UTF8.GetBytes(provided[0]!);
|
||||
var expectedBytes = Encoding.UTF8.GetBytes(options.ImportSecret);
|
||||
if (!CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes))
|
||||
{
|
||||
context.Result = new UnauthorizedResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,4 +46,13 @@ public sealed class ColosseumBattleFinishResponseDto
|
||||
{
|
||||
[JsonPropertyName("battle_result")] [Key("battle_result")]
|
||||
public int BattleResult { get; set; }
|
||||
|
||||
[JsonPropertyName("get_class_experience")] [Key("get_class_experience")]
|
||||
public int GetClassExperience { get; set; }
|
||||
|
||||
[JsonPropertyName("class_experience")] [Key("class_experience")]
|
||||
public int ClassExperience { get; set; }
|
||||
|
||||
[JsonPropertyName("class_level")] [Key("class_level")]
|
||||
public int ClassLevel { get; set; } = 1;
|
||||
}
|
||||
|
||||
@@ -30,13 +30,21 @@ public class GuildMemberInfoDto
|
||||
[JsonPropertyName("degree_id"), Key("degree_id"), JsonConverter(typeof(StringifiedIntConverter))]
|
||||
public int DegreeId { get; set; }
|
||||
|
||||
/// <summary>Optional — omit when the caller has no friend relationship with this member.</summary>
|
||||
[JsonPropertyName("is_friend"), Key("is_friend")]
|
||||
public int? IsFriend { get; set; }
|
||||
/// <summary>
|
||||
/// Client reads unconditionally in GuildMemberInfo.cs:48 (json["is_friend"].ToBoolean()) — always emit.
|
||||
/// Base UserInfoBase guards this, but GuildMemberInfo re-reads it on top, unguarded. 0 = not a friend.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_friend"), Key("is_friend"),
|
||||
JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public int IsFriend { get; set; }
|
||||
|
||||
/// <summary>Optional — omit when no pending friend apply.</summary>
|
||||
[JsonPropertyName("is_friend_apply"), Key("is_friend_apply")]
|
||||
public int? IsFriendApply { get; set; }
|
||||
/// <summary>
|
||||
/// Client reads unconditionally in GuildMemberInfo.cs:49 (json["is_friend_apply"].ToBoolean()) — always emit.
|
||||
/// 0 = no pending friend apply.
|
||||
/// </summary>
|
||||
[JsonPropertyName("is_friend_apply"), Key("is_friend_apply"),
|
||||
JsonIgnore(Condition = JsonIgnoreCondition.Never)]
|
||||
public int IsFriendApply { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the official-account badge is shown for this member.
|
||||
|
||||
@@ -124,12 +124,18 @@ public class ImportStoryProgress
|
||||
|
||||
public class ImportMissionMeta
|
||||
{
|
||||
[JsonPropertyName("has_received_pick_two_mission")]
|
||||
public bool? HasReceivedPickTwoMission { get; set; }
|
||||
// Wire key is `is_received_two_pick_mission` (loader-captured verbatim from /load/index
|
||||
// user_info); the field ships as a string "1"/"0" not a bool. Keep the DTO type as int?
|
||||
// so binding matches; the controller normalizes to bool.
|
||||
[JsonPropertyName("is_received_two_pick_mission")]
|
||||
public int? HasReceivedPickTwoMission { get; set; }
|
||||
|
||||
[JsonPropertyName("mission_receive_type")]
|
||||
public int? MissionReceiveType { get; set; }
|
||||
|
||||
// Wire ships this as a "yyyy-MM-dd HH:mm:ss" string, NOT a Unix long (see
|
||||
// FriendService.DefaultMissionChangeTime for the canonical game format). Controller
|
||||
// parses via DateTime.TryParseExact with invariant culture.
|
||||
[JsonPropertyName("mission_change_time")]
|
||||
public long? MissionChangeTime { get; set; }
|
||||
public string? MissionChangeTime { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
|
||||
/// What the battle controller needs from the service to build the standard battle-finish
|
||||
/// envelope (class XP delta + post-state, spot points before/add/after, battle_result echo).
|
||||
/// Achieved-info is built by the controller from IMissionAssembler.
|
||||
/// <see cref="LeveledUp"/> and <see cref="ClassId"/> are controller-side signals for the
|
||||
/// class_level_up mission emit and are not serialized to the wire.
|
||||
/// </summary>
|
||||
public class BattleFinishResultDto
|
||||
{
|
||||
@@ -14,4 +16,6 @@ public class BattleFinishResultDto
|
||||
public int BeforeSpotPoint { get; set; }
|
||||
public int AddSpotPoint { get; set; }
|
||||
public int AfterSpotPoint { get; set; }
|
||||
public bool LeveledUp { get; set; }
|
||||
public int ClassId { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
|
||||
|
||||
/// <summary>
|
||||
/// What <see cref="Services.IArenaTwoPickService.FinishAsync(long)"/> returns to
|
||||
/// <c>ArenaTwoPickController.Finish</c>. The wire response is <see cref="Response"/>;
|
||||
/// <see cref="WasFullClear"/> is a controller-side signal for the
|
||||
/// <c>challenge_full_clear</c> mission emit and is never serialized.
|
||||
/// </summary>
|
||||
public sealed record RunFinishOutcome(FinishResponseDto Response, bool WasFullClear);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
/// <summary>
|
||||
/// What <c>IStoryService.FinishAsync</c> returns to <c>StoryController.Finish</c>.
|
||||
/// <see cref="Response"/> is the wire payload; <see cref="LeveledUp"/> and
|
||||
/// <see cref="ClassId"/> are controller-side signals for the class_level_up mission emit
|
||||
/// and are never serialized. <see cref="ClassId"/> is nullable because skip-shape finishes
|
||||
/// (no class chosen) don't grant XP and therefore can't level anything up.
|
||||
/// </summary>
|
||||
public sealed record StoryFinishOutcome(FinishResponse Response, bool LeveledUp, int? ClassId);
|
||||
@@ -15,6 +15,7 @@ using SVSim.Database.Services.Friend;
|
||||
using SVSim.Database.Services.Replay;
|
||||
using SVSim.EmulatedEntrypoint.Configuration;
|
||||
using SVSim.EmulatedEntrypoint.Extensions;
|
||||
using SVSim.EmulatedEntrypoint.Infrastructure;
|
||||
using SVSim.EmulatedEntrypoint.Matching;
|
||||
using SVSim.EmulatedEntrypoint.Middlewares;
|
||||
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
|
||||
@@ -64,6 +65,19 @@ public class Program
|
||||
// Disambiguate same-named DTOs across families (e.g. Story.StartRequest vs
|
||||
// BasicPuzzle.StartRequest) by qualifying schema ids with the full type name.
|
||||
c.CustomSchemaIds(t => t.FullName?.Replace("+", "."));
|
||||
|
||||
// Register the X-Admin-Secret shared-secret header so the Swagger UI shows an
|
||||
// Authorize dialog for it. AdminSecretOperationFilter then attaches the requirement
|
||||
// only to endpoints that carry [RequireAdminSecret], keeping the padlock off
|
||||
// ordinary game endpoints.
|
||||
c.AddSecurityDefinition(AdminSecretOperationFilter.SchemeId, new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||
{
|
||||
Name = RequireAdminSecretAttribute.HeaderName,
|
||||
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
|
||||
Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey,
|
||||
Description = "Shared secret for /admin/* endpoints (matches Admin:ImportSecret in appsettings).",
|
||||
});
|
||||
c.OperationFilter<AdminSecretOperationFilter>();
|
||||
});
|
||||
builder.Services.AddHttpLogging(opt =>
|
||||
{
|
||||
@@ -71,6 +85,7 @@ public class Program
|
||||
});
|
||||
|
||||
builder.Services.Configure<DeckOptions>(builder.Configuration.GetSection(DeckOptions.SectionName));
|
||||
builder.Services.Configure<AdminOptions>(builder.Configuration.GetSection(AdminOptions.SectionName));
|
||||
|
||||
#region Database Services
|
||||
|
||||
@@ -99,6 +114,10 @@ public class Program
|
||||
builder.Services.AddScoped<IGachaPointService, GachaPointService>();
|
||||
builder.Services.AddScoped<SVSim.Database.Services.Inventory.IInventoryService,
|
||||
SVSim.Database.Services.Inventory.InventoryService>();
|
||||
builder.Services.AddScoped<SVSim.Database.Services.BattleXp.IBattleXpService,
|
||||
SVSim.Database.Services.BattleXp.BattleXpService>();
|
||||
builder.Services.AddScoped<SVSim.Database.Services.RankProgress.IRankProgressService,
|
||||
SVSim.Database.Services.RankProgress.RankProgressService>();
|
||||
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IBattlePassRepository,
|
||||
SVSim.Database.Repositories.BattlePass.BattlePassRepository>();
|
||||
builder.Services.AddScoped<SVSim.Database.Repositories.BattlePass.IViewerBattlePassRepository,
|
||||
@@ -123,6 +142,7 @@ public class Program
|
||||
builder.Services.AddScoped<IMatchContextBuilder, MatchContextBuilder>();
|
||||
builder.Services.AddScoped<IStoryService, StoryService>();
|
||||
builder.Services.AddScoped<ILoginBonusService, LoginBonusService>();
|
||||
builder.Services.AddScoped<IGameCalendarService, GameCalendarService>();
|
||||
builder.Services.AddScoped<IDeckListBuilder, DeckListBuilder>();
|
||||
builder.Services.AddSingleton<IRandom, SystemRandom>();
|
||||
builder.Services.AddSingleton<PuzzleMissionEvaluator>();
|
||||
@@ -160,11 +180,10 @@ public class Program
|
||||
|
||||
builder.Services.AddBattleNode(opt =>
|
||||
{
|
||||
// Matches the prod do_matching wire format: host:port/socket.io/, no scheme prefix.
|
||||
// BestHTTP's SocketManager parses this as the Socket.IO v2 endpoint URL.
|
||||
opt.NodeServerUrl = "localhost:5148/socket.io/";
|
||||
// Any field in BattleNodeOptions can be overridden via the "BattleNode" section
|
||||
// in appsettings*.json — see appsettings.Development.json for DiagnosticLogging.
|
||||
// Every field on BattleNodeOptions is populated from the "BattleNode" section in
|
||||
// appsettings*.json. NodeServerUrl has no hardcoded fallback — AddBattleNode
|
||||
// throws at startup if it's missing/blank. See BattleNodeOptions.NodeServerUrl
|
||||
// for the required wire format.
|
||||
builder.Configuration.GetSection("BattleNode").Bind(opt);
|
||||
});
|
||||
// In-process FCFS pair-up for TK2 PvP /do_matching, plus rank-battle's AI-fallback
|
||||
|
||||
@@ -63,8 +63,17 @@
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="PostBuild" AfterTargets="PostBuildEvent">
|
||||
<Exec Command="COPY "$(ProjectDir)\lib\*" "$(TargetDir)"" />
|
||||
<!-- Copy the Steamworks native libs into the build AND publish output. Was previously
|
||||
an `<Exec Command="COPY ...">` that only ran on Windows; the MSBuild `<Copy>` task
|
||||
is cross-platform, so CI can build on ubuntu-latest without a shell fallback. -->
|
||||
<ItemGroup>
|
||||
<SteamworksNativeLibs Include="$(ProjectDir)lib\**\*" />
|
||||
</ItemGroup>
|
||||
<Target Name="CopySteamworksLibsToOutput" AfterTargets="Build">
|
||||
<Copy SourceFiles="@(SteamworksNativeLibs)" DestinationFolder="$(TargetDir)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
<Target Name="CopySteamworksLibsToPublish" AfterTargets="Publish">
|
||||
<Copy SourceFiles="@(SteamworksNativeLibs)" DestinationFolder="$(PublishDir)" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -6,6 +6,7 @@ using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.BattleXp;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Common.ArenaTwoPick;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Responses.ArenaTwoPick;
|
||||
@@ -20,6 +21,7 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
||||
private readonly IGameConfigService _config;
|
||||
private readonly IViewerRepository _viewers;
|
||||
private readonly IInventoryService _inv;
|
||||
private readonly IBattleXpService _xp;
|
||||
private readonly IRandom _rng;
|
||||
private readonly SVSimDbContext _db;
|
||||
|
||||
@@ -30,11 +32,12 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
||||
IGameConfigService config,
|
||||
IViewerRepository viewers,
|
||||
IInventoryService inv,
|
||||
IBattleXpService xp,
|
||||
IRandom rng,
|
||||
SVSimDbContext db)
|
||||
{
|
||||
_runs = runs; _rewards = rewards; _pool = pool; _config = config;
|
||||
_viewers = viewers; _inv = inv; _rng = rng; _db = db;
|
||||
_viewers = viewers; _inv = inv; _xp = xp; _rng = rng; _db = db;
|
||||
}
|
||||
|
||||
public async Task<TopResponseDto> GetTopAsync(long viewerId)
|
||||
@@ -278,11 +281,27 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
||||
}).ToList(),
|
||||
};
|
||||
}
|
||||
public Task<FinishResponseDto> RetireAsync(long viewerId) => GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: false);
|
||||
public async Task<FinishResponseDto> RetireAsync(long viewerId)
|
||||
{
|
||||
var (response, _) = await GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: false);
|
||||
return response;
|
||||
}
|
||||
|
||||
public Task<FinishResponseDto> FinishAsync(long viewerId) => GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: true);
|
||||
public async Task<RunFinishOutcome> FinishAsync(long viewerId)
|
||||
{
|
||||
var (response, winCount) = await GrantRunRewardsAndDeleteAsync(viewerId, requireComplete: true);
|
||||
// TK2 is 5 battles per run; full clear == 5 wins == 0 losses. Reference the reward
|
||||
// catalog's max WinCount rather than a magic 5 so a future rules-config change tracks.
|
||||
int maxBattles = await ResolveMaxBattleCountAsync();
|
||||
return new RunFinishOutcome(response, WasFullClear: winCount >= maxBattles);
|
||||
}
|
||||
|
||||
private async Task<FinishResponseDto> GrantRunRewardsAndDeleteAsync(long viewerId, bool requireComplete)
|
||||
/// <summary>
|
||||
/// Grants rewards for the given run tier and deletes the run row. Returns the wire
|
||||
/// response DTO plus the run's final WinCount — the latter feeds
|
||||
/// <see cref="FinishAsync"/>'s full-clear detection but isn't exposed on the wire.
|
||||
/// </summary>
|
||||
private async Task<(FinishResponseDto Response, int WinCount)> GrantRunRewardsAndDeleteAsync(long viewerId, bool requireComplete)
|
||||
{
|
||||
var run = await _runs.GetByViewerIdAsync(viewerId)
|
||||
?? throw new ArenaTwoPickException("arena_two_pick_no_active_run");
|
||||
@@ -342,8 +361,9 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
||||
.Select(g => new RewardEntryDto { RewardType = (int)g.RewardType, RewardId = g.RewardId, RewardNum = g.RewardNum })
|
||||
.ToList();
|
||||
|
||||
int winCountAtFinish = run.WinCount;
|
||||
await _runs.DeleteAsync(viewerId);
|
||||
return new FinishResponseDto { Rewards = deltas, RewardList = postStates };
|
||||
return (new FinishResponseDto { Rewards = deltas, RewardList = postStates }, winCountAtFinish);
|
||||
}
|
||||
|
||||
private static SVSim.Database.Models.ArenaTwoPickReward WeightedPick(
|
||||
@@ -375,8 +395,7 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
||||
var viewer = await LoadViewerForGrantsAsync(viewerId);
|
||||
int before = (int)(viewer.Currency?.SpotPoints ?? 0);
|
||||
|
||||
int newClassXp = GrantClassXp(viewer, run.ClassId, aCfg.ClassXpPerBattle);
|
||||
int classLevel = ResolveClassLevel(viewer, run.ClassId);
|
||||
var xp = await _xp.GrantAsync(viewer, run.ClassId, isWin, BattleXpMode.ArenaTwoPick);
|
||||
|
||||
viewer.Currency!.SpotPoints += (ulong)aCfg.SpotPointsPerBattle;
|
||||
int after = (int)viewer.Currency.SpotPoints;
|
||||
@@ -385,29 +404,17 @@ public class ArenaTwoPickService : IArenaTwoPickService
|
||||
return new BattleFinishResultDto
|
||||
{
|
||||
BattleResult = isWin ? 1 : 0,
|
||||
GetClassExperience = aCfg.ClassXpPerBattle,
|
||||
ClassExperience = newClassXp,
|
||||
ClassLevel = classLevel,
|
||||
GetClassExperience = xp.GetXp,
|
||||
ClassExperience = xp.TotalXp,
|
||||
ClassLevel = xp.Level,
|
||||
BeforeSpotPoint = before,
|
||||
AddSpotPoint = aCfg.SpotPointsPerBattle,
|
||||
AfterSpotPoint = after,
|
||||
LeveledUp = xp.LeveledUp,
|
||||
ClassId = run.ClassId,
|
||||
};
|
||||
}
|
||||
|
||||
private static int GrantClassXp(SVSim.Database.Models.Viewer viewer, int classId, int xp)
|
||||
{
|
||||
var row = viewer.Classes.FirstOrDefault(c => c.Class.Id == classId);
|
||||
if (row is null) return 0;
|
||||
row.Exp += xp;
|
||||
return row.Exp;
|
||||
}
|
||||
|
||||
private static int ResolveClassLevel(SVSim.Database.Models.Viewer viewer, int classId)
|
||||
{
|
||||
var row = viewer.Classes.FirstOrDefault(c => c.Class.Id == classId);
|
||||
return row is null ? 1 : row.Level;
|
||||
}
|
||||
|
||||
// --- projection helpers (kept internal so test subclasses could exercise if needed) ---
|
||||
|
||||
internal static EntryInfoDto ProjectEntryInfo(ViewerArenaTwoPickRun run, long viewerId) => new()
|
||||
|
||||
92
SVSim.EmulatedEntrypoint/Services/GameCalendarService.cs
Normal file
92
SVSim.EmulatedEntrypoint/Services/GameCalendarService.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
using System.Globalization;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Reset-boundary primitive. All timestamps are UTC. The boundary hour is a config
|
||||
/// value (<see cref="GameCalendarConfig.DailyResetUtcHour"/>); everything else — daily
|
||||
/// reset checks, bucket keys for <c>ViewerEventCounters</c> — derives from it.
|
||||
/// Because bucket keys and <see cref="ResetReady"/> share the same
|
||||
/// <see cref="MostRecentBoundary"/> math, they can never disagree.
|
||||
/// </summary>
|
||||
public interface IGameCalendarService
|
||||
{
|
||||
/// <summary>
|
||||
/// True if a reset boundary has passed since <paramref name="lastCheckpointUtc"/>.
|
||||
/// Null → true (never checked in).
|
||||
/// </summary>
|
||||
bool ResetReady(DateTime? lastCheckpointUtc);
|
||||
|
||||
/// <summary>Bucket key for the current daily reset window: <c>"day:yyyy-MM-dd"</c>.</summary>
|
||||
string DayKey(DateTimeOffset when);
|
||||
|
||||
/// <summary>Bucket key for the ISO week containing the current daily window's start.</summary>
|
||||
string WeekKey(DateTimeOffset when);
|
||||
|
||||
/// <summary>Bucket key for the calendar month containing the current daily window's start.</summary>
|
||||
string MonthKey(DateTimeOffset when);
|
||||
|
||||
/// <summary>Returns [day, week, month, all-time] for the given instant.</summary>
|
||||
IReadOnlyList<string> AllPeriods(DateTimeOffset when);
|
||||
}
|
||||
|
||||
public static class GameCalendarPeriods
|
||||
{
|
||||
/// <summary>The lifetime bucket string — same constant regardless of reset boundary config.</summary>
|
||||
public const string AllTime = "all-time";
|
||||
}
|
||||
|
||||
public class GameCalendarService : IGameCalendarService
|
||||
{
|
||||
private readonly IGameConfigService _config;
|
||||
|
||||
public GameCalendarService(IGameConfigService config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public bool ResetReady(DateTime? lastCheckpointUtc)
|
||||
{
|
||||
if (lastCheckpointUtc is null) return true;
|
||||
var now = DateTime.UtcNow;
|
||||
var boundary = MostRecentBoundary(now);
|
||||
return DateTime.SpecifyKind(lastCheckpointUtc.Value, DateTimeKind.Utc) < boundary;
|
||||
}
|
||||
|
||||
public string DayKey(DateTimeOffset when)
|
||||
{
|
||||
var d = WindowStartDate(when);
|
||||
return $"day:{d:yyyy-MM-dd}";
|
||||
}
|
||||
|
||||
public string WeekKey(DateTimeOffset when)
|
||||
{
|
||||
var d = WindowStartDate(when);
|
||||
var iso = ISOWeek.GetWeekOfYear(d);
|
||||
var year = ISOWeek.GetYear(d);
|
||||
return $"week:{year:D4}-W{iso:D2}";
|
||||
}
|
||||
|
||||
public string MonthKey(DateTimeOffset when)
|
||||
{
|
||||
var d = WindowStartDate(when);
|
||||
return $"month:{d:yyyy-MM}";
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> AllPeriods(DateTimeOffset when) => new[]
|
||||
{
|
||||
DayKey(when), WeekKey(when), MonthKey(when), GameCalendarPeriods.AllTime,
|
||||
};
|
||||
|
||||
private DateTime MostRecentBoundary(DateTime nowUtc)
|
||||
{
|
||||
int hour = _config.Get<GameCalendarConfig>().DailyResetUtcHour;
|
||||
var todayBoundary = nowUtc.Date.AddHours(hour);
|
||||
return nowUtc >= todayBoundary ? todayBoundary : todayBoundary.AddDays(-1);
|
||||
}
|
||||
|
||||
private DateTime WindowStartDate(DateTimeOffset when) =>
|
||||
MostRecentBoundary(when.UtcDateTime);
|
||||
}
|
||||
@@ -9,7 +9,12 @@ public interface IArenaTwoPickService
|
||||
Task<ClassChooseResponseDto> ChooseClassAsync(long viewerId, int classId);
|
||||
Task<CardChooseResponseDto> ChooseCardAsync(long viewerId, long selectedId);
|
||||
Task<FinishResponseDto> RetireAsync(long viewerId);
|
||||
Task<FinishResponseDto> FinishAsync(long viewerId);
|
||||
/// <summary>
|
||||
/// Ends a completed TK2 run (5 battles played) and grants the reward tier for
|
||||
/// <c>run.WinCount</c>. Returns both the wire-shape rewards and a controller-side
|
||||
/// signal for mission emit (<see cref="RunFinishOutcome.WasFullClear"/>).
|
||||
/// </summary>
|
||||
Task<RunFinishOutcome> FinishAsync(long viewerId);
|
||||
Task<BattleFinishResultDto> RecordBattleResultAsync(long viewerId, bool isWin);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,6 @@ public interface IStoryService
|
||||
Task<InfoResponse> GetInfoAsync(StoryApiType apiType, int sectionId, int? charaId, long viewerId);
|
||||
Task<GetDeckListResponse> GetDeckListAsync(StoryApiType apiType, int storyId, long viewerId);
|
||||
Task<StartResponse> StartAsync(StoryApiType apiType, int[] storyIds, long viewerId);
|
||||
Task<FinishResponse> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId);
|
||||
Task<StoryFinishOutcome> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId);
|
||||
Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId);
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
/// <summary>
|
||||
/// JST-anchored period bucketing for ViewerEventCounters. Day/week/month boundaries are
|
||||
/// 02:00 JST (matching the real-game daily reset). Pure functions, no dependencies.
|
||||
/// </summary>
|
||||
public static class JstPeriod
|
||||
{
|
||||
private static readonly TimeSpan Jst = TimeSpan.FromHours(9);
|
||||
private const string DayPrefix = "day:";
|
||||
private const string WeekPrefix = "week:";
|
||||
private const string MonthPrefix = "month:";
|
||||
public const string AllTime = "all-time";
|
||||
|
||||
/// <summary>
|
||||
/// Converts the given instant to a JST-anchored "effective date" by:
|
||||
/// 1. Shifting to JST (+09:00)
|
||||
/// 2. Subtracting 2 hours so anything before 02:00 JST belongs to the previous day
|
||||
/// </summary>
|
||||
private static DateTime EffectiveJstDate(DateTimeOffset utcOrAny)
|
||||
{
|
||||
var jst = utcOrAny.ToOffset(Jst);
|
||||
return jst.AddHours(-2).Date;
|
||||
}
|
||||
|
||||
public static string DayKey(DateTimeOffset when)
|
||||
{
|
||||
var d = EffectiveJstDate(when);
|
||||
return $"{DayPrefix}{d:yyyy-MM-dd}";
|
||||
}
|
||||
|
||||
public static string WeekKey(DateTimeOffset when)
|
||||
{
|
||||
var d = EffectiveJstDate(when);
|
||||
var iso = ISOWeek.GetWeekOfYear(d);
|
||||
var year = ISOWeek.GetYear(d);
|
||||
return $"{WeekPrefix}{year:D4}-W{iso:D2}";
|
||||
}
|
||||
|
||||
public static string MonthKey(DateTimeOffset when)
|
||||
{
|
||||
var d = EffectiveJstDate(when);
|
||||
return $"{MonthPrefix}{d:yyyy-MM}";
|
||||
}
|
||||
|
||||
/// <summary>Returns [day, week, month, all-time] keys for the given instant.</summary>
|
||||
public static IReadOnlyList<string> AllPeriods(DateTimeOffset when) => new[]
|
||||
{
|
||||
DayKey(when), WeekKey(when), MonthKey(when), AllTime,
|
||||
};
|
||||
}
|
||||
@@ -11,21 +11,17 @@ public class LoginBonusService : ILoginBonusService
|
||||
{
|
||||
private readonly IGameConfigService _config;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly IGameCalendarService _calendar;
|
||||
|
||||
public LoginBonusService(IGameConfigService config, TimeProvider time)
|
||||
public LoginBonusService(IGameConfigService config, TimeProvider time, IGameCalendarService calendar)
|
||||
{
|
||||
_config = config;
|
||||
_time = time;
|
||||
_calendar = calendar;
|
||||
}
|
||||
|
||||
public bool IsDue(Viewer viewer)
|
||||
{
|
||||
if (viewer.LastLoginBonusClaimedAt is null) return true;
|
||||
var now = _time.GetUtcNow();
|
||||
var lastClaim = new DateTimeOffset(
|
||||
DateTime.SpecifyKind(viewer.LastLoginBonusClaimedAt.Value, DateTimeKind.Utc));
|
||||
return JstPeriod.DayKey(lastClaim) != JstPeriod.DayKey(now);
|
||||
}
|
||||
public bool IsDue(Viewer viewer) =>
|
||||
_calendar.ResetReady(viewer.LastLoginBonusClaimedAt);
|
||||
|
||||
public async Task<DailyLoginBonus?> GrantIfDueAsync(IInventoryTransaction tx, CancellationToken ct = default)
|
||||
{
|
||||
|
||||
@@ -9,15 +9,18 @@ public sealed class MissionAssembler : IMissionAssembler
|
||||
private readonly IMissionCatalogRepository _catalog;
|
||||
private readonly IViewerMissionRepository _viewerRepo;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly IGameCalendarService _calendar;
|
||||
|
||||
public MissionAssembler(
|
||||
IMissionCatalogRepository catalog,
|
||||
IViewerMissionRepository viewerRepo,
|
||||
TimeProvider time)
|
||||
TimeProvider time,
|
||||
IGameCalendarService calendar)
|
||||
{
|
||||
_catalog = catalog;
|
||||
_viewerRepo = viewerRepo;
|
||||
_time = time;
|
||||
_calendar = calendar;
|
||||
}
|
||||
|
||||
public async Task<MissionInfoDataDto> BuildAsync(Viewer viewer, CancellationToken ct = default)
|
||||
@@ -54,15 +57,14 @@ public sealed class MissionAssembler : IMissionAssembler
|
||||
if (c.EventType is not null) counterEventKeys.Add(c.EventType);
|
||||
}
|
||||
|
||||
// BP monthly missions for current JST month.
|
||||
var jstNow = now.ToOffset(TimeSpan.FromHours(9)).AddHours(-2);
|
||||
var monthlyMissions = await _catalog.GetMonthlyMissionsAsync(jstNow.Year, jstNow.Month, ct);
|
||||
// BP monthly missions for the current calendar month (UTC).
|
||||
var monthlyMissions = await _catalog.GetMonthlyMissionsAsync(now.Year, now.Month, ct);
|
||||
foreach (var mm in monthlyMissions)
|
||||
{
|
||||
if (mm.EventType is not null) counterEventKeys.Add(mm.EventType);
|
||||
}
|
||||
|
||||
var periods = new[] { JstPeriod.DayKey(now), JstPeriod.WeekKey(now), JstPeriod.MonthKey(now), JstPeriod.AllTime };
|
||||
var periods = _calendar.AllPeriods(now);
|
||||
var counters = counterEventKeys.Count == 0
|
||||
? new List<ViewerEventCounter>()
|
||||
: await _viewerRepo.GetCountersAsync(viewer.Id, counterEventKeys.ToList(), periods, ct);
|
||||
@@ -78,7 +80,7 @@ public sealed class MissionAssembler : IMissionAssembler
|
||||
int total = 0;
|
||||
if (cat.EventType is not null)
|
||||
{
|
||||
string period = cat.LotType == 6 ? JstPeriod.DayKey(now) : JstPeriod.WeekKey(now);
|
||||
string period = cat.LotType == 6 ? _calendar.DayKey(now) : _calendar.WeekKey(now);
|
||||
total = GetCounter(cat.EventType, period);
|
||||
}
|
||||
dto.UserMissionList.Add(new UserMissionDto
|
||||
@@ -105,7 +107,7 @@ public sealed class MissionAssembler : IMissionAssembler
|
||||
foreach (var a in viewerAchievements.OrderBy(a => a.AchievementType))
|
||||
{
|
||||
if (!achievementCatalogByKey.TryGetValue((a.AchievementType, a.Level), out var catalog)) continue;
|
||||
int total = catalog.EventType is null ? 0 : GetCounter(catalog.EventType, JstPeriod.AllTime);
|
||||
int total = catalog.EventType is null ? 0 : GetCounter(catalog.EventType, GameCalendarPeriods.AllTime);
|
||||
int maxLevel = maxLevelByType.TryGetValue(a.AchievementType, out var ml) ? ml : a.Level;
|
||||
dto.UserAchievementList.Add(new UserAchievementDto
|
||||
{
|
||||
@@ -142,7 +144,7 @@ public sealed class MissionAssembler : IMissionAssembler
|
||||
// BP monthly missions block — omit when no rows for current month.
|
||||
if (monthlyMissions.Count > 0)
|
||||
{
|
||||
var startUtc = new DateTimeOffset(jstNow.Year, jstNow.Month, 1, 2, 0, 0, TimeSpan.FromHours(9));
|
||||
var startUtc = new DateTimeOffset(now.Year, now.Month, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var endUtc = startUtc.AddMonths(1).AddSeconds(-1);
|
||||
dto.BattlePassMonthlyMission = new BPMonthlyMissionsDto
|
||||
{
|
||||
@@ -151,7 +153,7 @@ public sealed class MissionAssembler : IMissionAssembler
|
||||
MissionList = monthlyMissions.Select(mm =>
|
||||
{
|
||||
int done = mm.EventType is null ? 0
|
||||
: GetCounter(mm.EventType, JstPeriod.MonthKey(now));
|
||||
: GetCounter(mm.EventType, _calendar.MonthKey(now));
|
||||
var entry = new BPMonthlyMissionDto
|
||||
{
|
||||
Name = mm.Name,
|
||||
|
||||
@@ -9,24 +9,27 @@ public sealed class MissionProgressService : IMissionProgressService
|
||||
private readonly IMissionCatalogRepository _catalog;
|
||||
private readonly IViewerMissionRepository _viewerRepo;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly IGameCalendarService _calendar;
|
||||
|
||||
public MissionProgressService(
|
||||
SVSimDbContext db,
|
||||
IMissionCatalogRepository catalog,
|
||||
IViewerMissionRepository viewerRepo,
|
||||
TimeProvider time)
|
||||
TimeProvider time,
|
||||
IGameCalendarService calendar)
|
||||
{
|
||||
_db = db;
|
||||
_catalog = catalog;
|
||||
_viewerRepo = viewerRepo;
|
||||
_time = time;
|
||||
_calendar = calendar;
|
||||
}
|
||||
|
||||
public async Task RecordEventAsync(long viewerId, IReadOnlyList<string> eventKeys, int delta = 1, CancellationToken ct = default)
|
||||
{
|
||||
if (eventKeys.Count == 0) return;
|
||||
var now = _time.GetUtcNow();
|
||||
var periods = JstPeriod.AllPeriods(now);
|
||||
var periods = _calendar.AllPeriods(now);
|
||||
|
||||
// 1. Increment counters for every (key, period).
|
||||
foreach (var key in eventKeys)
|
||||
@@ -50,7 +53,7 @@ public sealed class MissionProgressService : IMissionProgressService
|
||||
var atLevel = catalogRows.FirstOrDefault(r => r.Level == viewerRow.Level);
|
||||
if (atLevel is null || atLevel.EventType is null) continue;
|
||||
|
||||
var count = await _viewerRepo.GetCounterAsync(viewerId, atLevel.EventType, JstPeriod.AllTime, ct);
|
||||
var count = await _viewerRepo.GetCounterAsync(viewerId, atLevel.EventType, GameCalendarPeriods.AllTime, ct);
|
||||
if (count >= atLevel.RequireNumber && viewerRow.AchievementStatus == 0)
|
||||
{
|
||||
viewerRow.AchievementStatus = 1;
|
||||
|
||||
@@ -7,7 +7,9 @@ using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.Deck;
|
||||
using SVSim.Database.Repositories.BuildDeck;
|
||||
using SVSim.Database.Repositories.Viewer;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.BattleXp;
|
||||
using SVSim.Database.Services.Inventory;
|
||||
using SVSim.Database.Repositories.Story;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos;
|
||||
@@ -25,6 +27,8 @@ public class StoryService : IStoryService
|
||||
private readonly IGameConfigService _configService;
|
||||
private readonly IDeckRepository _deckRepository;
|
||||
private readonly IBuildDeckRepository _buildDecks;
|
||||
private readonly IViewerRepository _viewers;
|
||||
private readonly IBattleXpService _xp;
|
||||
private readonly ILogger<StoryService> _logger;
|
||||
|
||||
public StoryService(
|
||||
@@ -35,6 +39,8 @@ public class StoryService : IStoryService
|
||||
IGameConfigService configService,
|
||||
IDeckRepository deckRepository,
|
||||
IBuildDeckRepository buildDecks,
|
||||
IViewerRepository viewers,
|
||||
IBattleXpService xp,
|
||||
ILogger<StoryService> logger)
|
||||
{
|
||||
_master = master;
|
||||
@@ -44,6 +50,8 @@ public class StoryService : IStoryService
|
||||
_configService = configService;
|
||||
_deckRepository = deckRepository;
|
||||
_buildDecks = buildDecks;
|
||||
_viewers = viewers;
|
||||
_xp = xp;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -478,7 +486,7 @@ public class StoryService : IStoryService
|
||||
resp["mission_parameter"] = Array.Empty<object>();
|
||||
return resp;
|
||||
}
|
||||
public async Task<FinishResponse> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId)
|
||||
public async Task<StoryFinishOutcome> FinishAsync(StoryApiType apiType, FinishRequest req, long viewerId)
|
||||
{
|
||||
var chapter = await _master.GetChapterByIdAsync(req.StoryId);
|
||||
if (chapter is null)
|
||||
@@ -489,9 +497,9 @@ public class StoryService : IStoryService
|
||||
// StorySubChapter lookup and record progress at the sub's id with isFinish+isSkipped
|
||||
// both true (sub-chapters are always narrative-only — no battle settings on the wire).
|
||||
var sub = await _master.FindSubChapterByStoryIdAsync(req.StoryId);
|
||||
if (sub is null) return new FinishResponse();
|
||||
if (sub is null) return new StoryFinishOutcome(new FinishResponse(), LeveledUp: false, ClassId: null);
|
||||
await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: true, isSkipped: true);
|
||||
return new FinishResponse();
|
||||
return new StoryFinishOutcome(new FinishResponse(), LeveledUp: false, ClassId: null);
|
||||
}
|
||||
|
||||
var progress = (await _viewer.GetProgressForChaptersAsync(viewerId, new[] { req.StoryId }))
|
||||
@@ -573,17 +581,24 @@ public class StoryService : IStoryService
|
||||
resp.StoryRewardList.AddRange(storyRewardDeltas);
|
||||
}
|
||||
|
||||
bool leveledUp = false;
|
||||
if (firstClear && isPlayShape)
|
||||
{
|
||||
// XP grant requires a class_id (only sent on play-shape). No-battle chapters
|
||||
// have no class context — prod returns get_class_experience=0 for them.
|
||||
var xp = _configService.Get<StoryConfig>().ClassXpPerClear;
|
||||
resp.GetClassExperience = xp.ToString();
|
||||
// class_experience / class_level updates would consult the viewer's per-class XP
|
||||
// table — placeholder zeros; wire to viewer.Classes[class_id] when that path exists.
|
||||
resp.ClassExperience = 0;
|
||||
resp.ClassLevel = "0";
|
||||
var xpViewer = await _viewers.LoadForBattleXpGrantAsync(viewerId);
|
||||
if (xpViewer is not null && req.ClassId.HasValue)
|
||||
{
|
||||
var xp = await _xp.GrantAsync(xpViewer, req.ClassId.Value, isWin: true, BattleXpMode.Story);
|
||||
await _db.SaveChangesAsync();
|
||||
resp.GetClassExperience = xp.GetXp.ToString();
|
||||
resp.ClassExperience = xp.TotalXp;
|
||||
resp.ClassLevel = xp.Level.ToString();
|
||||
leveledUp = xp.LeveledUp;
|
||||
}
|
||||
}
|
||||
|
||||
return new StoryFinishOutcome(resp, LeveledUp: leveledUp, ClassId: req.ClassId);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -598,7 +613,7 @@ public class StoryService : IStoryService
|
||||
await _viewer.UpsertProgressAsync(viewerId, req.StoryId, isFinish: null, isSkipped: true);
|
||||
}
|
||||
|
||||
return resp;
|
||||
return new StoryFinishOutcome(resp, LeveledUp: false, ClassId: null);
|
||||
}
|
||||
public async Task<FinishResponse> AllFinishAsync(StoryApiType apiType, int[] storyIds, bool isFinish, long viewerId)
|
||||
{
|
||||
|
||||
@@ -6,5 +6,10 @@
|
||||
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Admin": {
|
||||
// Fixed secret so AdminControllerTests can send X-Admin-Secret without going through
|
||||
// a per-test config knob. Tests read it back via AdminControllerTests.TestSecret.
|
||||
"ImportSecret": "test-admin-secret"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,5 +27,20 @@
|
||||
"Deck": {
|
||||
"MaxDeckSlots": 36
|
||||
},
|
||||
"Admin": {
|
||||
// Shared secret required in the X-Admin-Secret header on /admin/* util endpoints
|
||||
// (see RequireAdminSecretAttribute). Leaving this blank disables the endpoint entirely
|
||||
// — fail-closed so an unconfigured deployment never exposes admin surface. Override with
|
||||
// the ADMIN__IMPORTSECRET env var.
|
||||
"ImportSecret": "replaceme"
|
||||
},
|
||||
"BattleNode": {
|
||||
// Socket.IO v2 endpoint URL echoed to the client on /*/do_matching success.
|
||||
// Wire format: "host[:port]/socket.io/" — no scheme prefix, trailing slash REQUIRED.
|
||||
// Host may be an IP, hostname, or FQDN. BestHTTP's SocketManager parses this string
|
||||
// directly; a leading http:// or https:// will make the client fail to connect.
|
||||
// Startup throws if this value is missing or blank; there is no hardcoded fallback.
|
||||
"NodeServerUrl": "localhost:5148/socket.io/"
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ public class AchievementControllerTests
|
||||
var grant = rewardList[0];
|
||||
Assert.That(grant.GetProperty("reward_type").GetInt32(), Is.EqualTo(9));
|
||||
// For currency grants, reward_num is the POST-STATE TOTAL (per project convention,
|
||||
// matches /pack/open behavior). Default-seeded viewer has 50000 rupees → 50100 after grant.
|
||||
// matches /pack/open behavior). Default-seeded viewer starts at 0 rupees → 100 after grant.
|
||||
Assert.That(grant.GetProperty("reward_num").GetInt32(), Is.GreaterThanOrEqualTo(100),
|
||||
"reward_num is post-state total for currencies, must be at least the granted amount");
|
||||
}
|
||||
|
||||
@@ -14,10 +14,12 @@ using SVSim.UnitTests.Infrastructure;
|
||||
namespace SVSim.UnitTests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end coverage for <c>/admin/import_viewer</c>. The endpoint is [AllowAnonymous] so
|
||||
/// these tests don't need to seed a viewer first; the fresh-user path exercises the just-fixed
|
||||
/// nav-graph NRE inside <c>ViewerRepository.RegisterViewer</c>, and the existing-user path
|
||||
/// exercises the owned-type lookup used to dedupe by Steam id.
|
||||
/// End-to-end coverage for <c>/admin/import_viewer</c>. The endpoint is [AllowAnonymous] +
|
||||
/// [RequireAdminSecret], so tests reach it through <see cref="SVSimTestFactory.CreateAdminClient"/>
|
||||
/// which bakes in the <c>X-Admin-Secret</c> header from <c>appsettings.Testing.json</c>. The
|
||||
/// fresh-user path exercises the nav-graph NRE fix inside <c>ViewerRepository.RegisterViewer</c>;
|
||||
/// the existing-user path exercises the owned-type Steam-id lookup. Negative-path tests at the
|
||||
/// bottom of the file cover the header gate itself.
|
||||
/// </summary>
|
||||
public class AdminControllerTests
|
||||
{
|
||||
@@ -27,7 +29,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_fresh_user_creates_viewer_and_returns_ids()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
@@ -71,7 +73,7 @@ public class AdminControllerTests
|
||||
const ulong steamId = 76_561_198_555_666_777UL;
|
||||
long seededId = await factory.SeedViewerAsync(steamId: steamId, displayName: "Original Name");
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
@@ -107,7 +109,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_missing_steam_id_returns_400()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
@@ -122,7 +124,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_imports_owned_cards_and_skips_unknown()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
|
||||
// 10001001 is in the minimal test card set; 99999999 is not.
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -156,7 +158,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_clamps_card_count_to_max_copies()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
@@ -181,7 +183,7 @@ public class AdminControllerTests
|
||||
long viewerId = await factory.SeedViewerAsync(steamId: steamId);
|
||||
await factory.SeedOwnedCardAsync(viewerId, 10001001L, count: 3);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
@@ -207,7 +209,7 @@ public class AdminControllerTests
|
||||
// Registers the ItemEntry master row (70001) and gives an initial owned count to be replaced.
|
||||
await factory.SeedOwnedItemAsync(viewerId, itemId: 70001, count: 1);
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
@@ -242,7 +244,7 @@ public class AdminControllerTests
|
||||
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
@@ -294,7 +296,7 @@ public class AdminControllerTests
|
||||
leaderSkinId = (await db.LeaderSkins.FirstAsync()).Id;
|
||||
}
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
@@ -329,7 +331,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_fresh_user_has_no_decks_when_none_imported()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
@@ -380,7 +382,7 @@ public class AdminControllerTests
|
||||
}
|
||||
""";
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/admin/import_viewer", content);
|
||||
|
||||
@@ -438,7 +440,7 @@ public class AdminControllerTests
|
||||
}
|
||||
""";
|
||||
|
||||
using var client = factory.CreateClient();
|
||||
using var client = factory.CreateAdminClient();
|
||||
using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/admin/import_viewer", content);
|
||||
|
||||
@@ -457,7 +459,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_MissionMeta_RoundTrips()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000001UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -465,9 +467,9 @@ public class AdminControllerTests
|
||||
SteamId = steamId,
|
||||
MissionMeta = new ImportMissionMeta
|
||||
{
|
||||
HasReceivedPickTwoMission = true,
|
||||
HasReceivedPickTwoMission = 1, // wire "1"/"0"; controller normalizes to bool
|
||||
MissionReceiveType = 2,
|
||||
MissionChangeTime = 1_700_000_000L
|
||||
MissionChangeTime = "2023-11-14 22:13:20" // wire "yyyy-MM-dd HH:mm:ss"; parsed as UTC
|
||||
}
|
||||
});
|
||||
resp.EnsureSuccessStatusCode();
|
||||
@@ -482,9 +484,10 @@ public class AdminControllerTests
|
||||
Assert.That(viewer.MissionData, Is.Not.Null);
|
||||
Assert.That(viewer.MissionData!.HasReceivedPickTwoMission, Is.True);
|
||||
Assert.That(viewer.MissionData.MissionReceiveType, Is.EqualTo(2));
|
||||
// MissionChangeTime is DateTime in storage; verify it survives the round-trip from unix seconds.
|
||||
// MissionChangeTime is DateTime in storage; the datetime string parses as UTC
|
||||
// (AssumeUniversal | AdjustToUniversal in AdminController.Pass A).
|
||||
Assert.That(viewer.MissionData.MissionChangeTime,
|
||||
Is.EqualTo(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000L).UtcDateTime));
|
||||
Is.EqualTo(new DateTime(2023, 11, 14, 22, 13, 20, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -501,7 +504,7 @@ public class AdminControllerTests
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000002UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -532,7 +535,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_Missions_UnknownMissionIdSkipped()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000003UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -566,7 +569,7 @@ public class AdminControllerTests
|
||||
});
|
||||
await seedDb.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000004UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -604,7 +607,7 @@ public class AdminControllerTests
|
||||
new MissionCatalogEntry { Id = 9004, LotType = 2, EventType = "battle_win_total", EventArg = null });
|
||||
await seedDb.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000005UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -651,7 +654,7 @@ public class AdminControllerTests
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000007UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -686,7 +689,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_Achievements_UnknownTypeSkipped()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000008UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -723,7 +726,7 @@ public class AdminControllerTests
|
||||
new AchievementCatalogEntry { AchievementType = 602, Level = 1, EventType = "battle_win_total", EventArg = null });
|
||||
await seedDb.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000009UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -773,7 +776,7 @@ public class AdminControllerTests
|
||||
});
|
||||
await seedDb.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000006UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -810,7 +813,7 @@ public class AdminControllerTests
|
||||
db.StoryWorlds.Add(new StoryWorld { Id = 20_000_005 }); // Event
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000007UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -849,7 +852,7 @@ public class AdminControllerTests
|
||||
db.StoryWorlds.Add(new StoryWorld { Id = 11 }); // sub_chapter (own row)
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000008UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -877,7 +880,7 @@ public class AdminControllerTests
|
||||
public async Task ImportViewer_StoryProgress_UnknownStoryIdSkipped()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000009UL;
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
@@ -913,7 +916,7 @@ public class AdminControllerTests
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
const ulong steamId = 70000000000000020UL;
|
||||
|
||||
// First POST: both missions present (explicit slots to avoid the (ViewerId,Slot) unique index).
|
||||
@@ -980,7 +983,7 @@ public class AdminControllerTests
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
const ulong steamId = 70000000000000021UL;
|
||||
|
||||
// First POST: both achievements present.
|
||||
@@ -1043,13 +1046,13 @@ public class AdminControllerTests
|
||||
db.StoryWorlds.Add(new StoryWorld { Id = 50 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
var client = factory.CreateClient();
|
||||
var client = factory.CreateAdminClient();
|
||||
ulong steamId = 70000000000000010UL;
|
||||
|
||||
var req = new ImportViewerRequest
|
||||
{
|
||||
SteamId = steamId,
|
||||
MissionMeta = new ImportMissionMeta { HasReceivedPickTwoMission = true, MissionReceiveType = 1, MissionChangeTime = 1L },
|
||||
MissionMeta = new ImportMissionMeta { HasReceivedPickTwoMission = 1, MissionReceiveType = 1, MissionChangeTime = "2023-01-01 00:00:00" },
|
||||
Missions = new List<ImportMission> { new() { MissionId = 8001, MissionStatus = 1, TotalCount = 5 } },
|
||||
Achievements = new List<ImportAchievement> { new() { AchievementType = 800, Level = 1, NowAchievedLevel = 1, ResultAnnounceSawLevel = 0, TotalCount = 9 } },
|
||||
StoryProgress = new List<ImportStoryProgress> { new() { StoryApiType = 1, StoryId = 50, IsFinish = true } }
|
||||
@@ -1069,4 +1072,35 @@ public class AdminControllerTests
|
||||
Assert.That(verifyDb.ViewerStoryProgress.Count(p => p.ViewerId == viewerId), Is.EqualTo(1));
|
||||
Assert.That(verifyDb.ViewerEventCounters.Count(c => c.ViewerId == viewerId), Is.EqualTo(2));
|
||||
}
|
||||
|
||||
// --- X-Admin-Secret gate ---
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_without_secret_header_returns_401()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient(); // no X-Admin-Secret header
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = 76_561_198_999_999_001UL
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportViewer_with_wrong_secret_returns_401()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Admin-Secret", "not-the-real-secret");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/admin/import_viewer", new ImportViewerRequest
|
||||
{
|
||||
SteamId = 76_561_198_999_999_002UL
|
||||
});
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.Unauthorized));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,13 @@ public class ArenaColosseumBattleControllerTests
|
||||
var resp = await client.PostAsync("/colosseum_battle/finish", JsonContent.Create(req));
|
||||
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
|
||||
// XP wire assertions
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
// XpPerWin=200; classexp.csv L1=50, L2=150 → 200 XP crosses both: L3, Exp=0.
|
||||
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(200));
|
||||
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(doc.RootElement.GetProperty("class_level").GetInt32(), Is.EqualTo(3));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var run = await db.ViewerArenaColosseumRuns.FirstAsync(r => r.ViewerId == vid);
|
||||
@@ -125,6 +132,13 @@ public class ArenaColosseumBattleControllerTests
|
||||
Assert.That(run.LossCount, Is.EqualTo(0));
|
||||
Assert.That(run.BattleCountThisRound, Is.EqualTo(1));
|
||||
Assert.That(run.ResultListJson, Is.EqualTo("[1]"));
|
||||
|
||||
// Persisted class XP on viewer
|
||||
var v = await db.Viewers.Include(x => x.Classes).ThenInclude(c => c.Class)
|
||||
.FirstAsync(x => x.Id == vid);
|
||||
var cls1 = v.Classes.Single(c => c.Class.Id == 1);
|
||||
Assert.That(cls1.Level, Is.EqualTo(3));
|
||||
Assert.That(cls1.Exp, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -114,22 +114,24 @@ public class FreeBattleControllerTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Finish_emits_battle_result_echo_and_four_field_minimum()
|
||||
public async Task Finish_win_grants_class_xp_with_strict_field_subset()
|
||||
{
|
||||
await using var factory = new SVSimTestFactory();
|
||||
var viewerId = await factory.SeedViewerAsync();
|
||||
var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/unlimited_free_battle/finish", FinishBody(battleResult: 1));
|
||||
var resp = await client.PostAsJsonAsync(
|
||||
"/unlimited_free_battle/finish", FinishBody(battleResult: 1, classId: 1));
|
||||
|
||||
Assert.That(resp.IsSuccessStatusCode, Is.True);
|
||||
var raw = await resp.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
var data = doc.RootElement;
|
||||
Assert.That(data.GetProperty("battle_result").GetInt32(), Is.EqualTo(1));
|
||||
Assert.That(data.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(0));
|
||||
// XpPerWin=200; classexp.csv L1=50, L2=150 → 200 XP crosses both: L3, Exp=0.
|
||||
Assert.That(data.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(200));
|
||||
Assert.That(data.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(data.GetProperty("class_level").GetInt32(), Is.EqualTo(1));
|
||||
Assert.That(data.GetProperty("class_level").GetInt32(), Is.EqualTo(3));
|
||||
|
||||
// Strict subset — no rank fields. The client doesn't read them on free-battle
|
||||
// finish; emitting them would be wire-format pollution.
|
||||
@@ -140,16 +142,21 @@ public class FreeBattleControllerTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Finish_with_consistency_result_echoes_2_on_rotation_url()
|
||||
public async Task Finish_loss_grants_loss_xp_on_rotation_url()
|
||||
{
|
||||
await using var factory = new SVSimTestFactory();
|
||||
var viewerId = await factory.SeedViewerAsync();
|
||||
var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/rotation_free_battle/finish", FinishBody(battleResult: 2));
|
||||
var resp = await client.PostAsJsonAsync(
|
||||
"/rotation_free_battle/finish", FinishBody(battleResult: 2, classId: 1));
|
||||
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
Assert.That(doc.RootElement.GetProperty("battle_result").GetInt32(), Is.EqualTo(2));
|
||||
// XpPerLoss=50 exactly meets L1 threshold → L2, Exp=0.
|
||||
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(50));
|
||||
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(doc.RootElement.GetProperty("class_level").GetInt32(), Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -54,4 +54,27 @@ public class MyPageControllerTests
|
||||
var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
Assert.That(root.GetProperty("can_give_daily_login_bonus").GetBoolean(), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MyPage_unread_present_count_reflects_unclaimed_viewer_presents()
|
||||
{
|
||||
// Drives the home-screen crate badge — MyPageTask parses `unread_present_count` into
|
||||
// Data.MyPage.data.unread_mail_count, which MyPageItemHome.SetUnreadGiftCount reads to
|
||||
// show the "N" bubble on the gift button. Stubbed to 0 would hide it even when the
|
||||
// tutorial gift has 5 rewards sitting unclaimed.
|
||||
using var factory = new SVSimTestFactory();
|
||||
await factory.SeedGlobalsAsync();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedTutorialPresentsAsync(viewerId);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var resp = await client.PostAsync("/mypage/index",
|
||||
new StringContent(MyPageRequestJson, Encoding.UTF8, "application/json"));
|
||||
Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK), await resp.Content.ReadAsStringAsync());
|
||||
|
||||
var root = JsonDocument.Parse(await resp.Content.ReadAsStringAsync()).RootElement;
|
||||
int count = root.GetProperty("unread_present_count").GetInt32();
|
||||
Assert.That(count, Is.GreaterThan(0),
|
||||
"unread_present_count must be the viewer's live Unclaimed ViewerPresent count — a stub 0 hides the crate badge.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,4 +299,87 @@ public class PackControllerInfoTests
|
||||
Assert.That(children.Count, Is.EqualTo(1), "Only the ticket child should remain after today's free claim");
|
||||
Assert.That(children[0].GetProperty("type_detail").GetInt32(), Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Info_emits_is_daily_single_true_before_todays_claim()
|
||||
{
|
||||
// Client's DAILY-branch UI (GachaPackAreaLayout.cs:420) renders the half-off button
|
||||
// iff the wire ships `is_daily_single: true`. Pre-claim it must be truthy.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "daily test",
|
||||
ChildGachas =
|
||||
{
|
||||
new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 50, CardCount = 8, IsDailySingle = true },
|
||||
new PackChildGachaEntry { GachaId = 100002, TypeDetail = CardPackType.CrystalMulti, Cost = 100, CardCount = 8 },
|
||||
},
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var daily = doc.RootElement.GetProperty("pack_config_list")[0]
|
||||
.GetProperty("child_gacha_info").EnumerateArray()
|
||||
.Single(c => c.GetProperty("type_detail").GetInt32() == 3);
|
||||
Assert.That(daily.TryGetProperty("is_daily_single", out var flag), Is.True,
|
||||
"is_daily_single must be present when claimable");
|
||||
Assert.That(flag.GetBoolean(), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Info_suppresses_is_daily_single_after_todays_claim()
|
||||
{
|
||||
// Bug repro: after a successful DAILY open, /pack/info kept emitting is_daily_single=true,
|
||||
// so the client re-showed the half-off button. Clicking it again 400'd with
|
||||
// daily_free_already_claimed. Post-claim the flag must be false (or omitted) so the
|
||||
// client's DAILY-branch renders the full-price crystal button instead. The child entry
|
||||
// itself stays so CRYSTAL_MULTI still activates.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 10001, BasePackId = 10001, PackCategory = PackCategory.None,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "daily test",
|
||||
ChildGachas =
|
||||
{
|
||||
new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 50, CardCount = 8, IsDailySingle = true },
|
||||
new PackChildGachaEntry { GachaId = 100002, TypeDetail = CardPackType.CrystalMulti, Cost = 100, CardCount = 8 },
|
||||
},
|
||||
});
|
||||
var v = await db.Viewers.Include(x => x.PackOpenCounts).FirstAsync(x => x.Id == viewerId);
|
||||
v.PackOpenCounts.Add(new ViewerPackOpenCount { PackId = 10001, OpenCount = 1, LastDailyFreeAt = DateTime.UtcNow });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var response = await client.PostAsync("/pack/info", JsonBody(EmptyEnvelope));
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var children = doc.RootElement.GetProperty("pack_config_list")[0]
|
||||
.GetProperty("child_gacha_info").EnumerateArray().ToList();
|
||||
Assert.That(children.Count, Is.EqualTo(2), "child entries stay; only the daily-single flag flips");
|
||||
|
||||
var daily = children.Single(c => c.GetProperty("type_detail").GetInt32() == 3);
|
||||
// With WhenWritingDefault, the wire omits the key entirely when false — either shape is
|
||||
// fine (PackInfoTask.cs:143-150 defaults to false on missing key).
|
||||
bool value = daily.TryGetProperty("is_daily_single", out var flag) && flag.GetBoolean();
|
||||
Assert.That(value, Is.False,
|
||||
"is_daily_single must be false/absent post-claim so the client stops offering the half-off button");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,8 +399,10 @@ public class PackControllerOpenTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Open_daily_marks_last_daily_free_at_and_rejects_second_attempt()
|
||||
public async Task Open_daily_debits_crystals_and_rejects_second_attempt()
|
||||
{
|
||||
// DAILY is the once-per-day half-off CRYSTAL single-pack (GachaUI.cs:1046 →
|
||||
// CheckBuyPackWithCrystal). Cost=50 crystals; second same-day attempt 400s.
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
@@ -412,8 +414,11 @@ public class PackControllerOpenTests
|
||||
Id = 10001, BasePackId = baseId, PackCategory = PackCategory.None,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1, GachaDetail = "daily test",
|
||||
ChildGachas = { new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 0, CardCount = 1, IsDailySingle = true } },
|
||||
ChildGachas = { new PackChildGachaEntry { GachaId = 200001, TypeDetail = CardPackType.Daily, Cost = 50, CardCount = 1, IsDailySingle = true } },
|
||||
});
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
v.Currency.Crystals = 200;
|
||||
v.Currency.Rupees = 200;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
@@ -424,9 +429,17 @@ public class PackControllerOpenTests
|
||||
var first = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
Assert.That(first.StatusCode, Is.EqualTo(HttpStatusCode.OK), await first.Content.ReadAsStringAsync());
|
||||
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.FirstAsync(x => x.Id == viewerId);
|
||||
Assert.That(v.Currency.Crystals, Is.EqualTo(150UL), "200 starting - 50 daily-single crystal cost");
|
||||
Assert.That(v.Currency.Rupees, Is.EqualTo(200UL), "Rupees must not be touched by a DAILY (crystal) open");
|
||||
}
|
||||
|
||||
var second = await client.PostAsync("/pack/open", JsonBody(json));
|
||||
Assert.That(second.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest),
|
||||
"Second daily-free open the same UTC day should be rejected.");
|
||||
"Second daily-single open the same UTC day should be rejected.");
|
||||
}
|
||||
|
||||
// ---------------- Regression tests for wire-shape quirks ----------------
|
||||
@@ -714,71 +727,6 @@ public class PackControllerOpenTests
|
||||
Assert.That(v.Currency.Rupees, Is.EqualTo(0UL), "freeplay must not deduct real DB balance");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TutorialPackOpen_does_not_accrue_gacha_points()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
// Seed the starter pack 99047 with a GachaPointConfig set — the tutorial-path skip
|
||||
// must hold even when the pack is technically point-eligible.
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
db.Classes.Add(new ClassEntry { Id = 0, Name = "Neutral" });
|
||||
var set = new ShadowverseCardSetEntry { Id = 99047, IsInRotation = true };
|
||||
db.CardSets.Add(set);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
set.Cards.Add(new ShadowverseCardEntry
|
||||
{
|
||||
Id = 99047_1010 + i, Name = $"c{i}",
|
||||
Rarity = (Rarity)((i % 4) + 1),
|
||||
Class = db.Classes.Local.First(), IsFoil = false,
|
||||
});
|
||||
}
|
||||
db.Items.Add(new ItemEntry { Id = 90001, Name = "starter-ticket" });
|
||||
db.Packs.Add(new PackConfigEntry
|
||||
{
|
||||
Id = 99047, BasePackId = 99047, PackCategory = PackCategory.LegendCardPack,
|
||||
CommenceDate = DateTime.UtcNow.AddDays(-1), CompleteDate = DateTime.UtcNow.AddDays(30),
|
||||
GachaType = 1,
|
||||
GachaPointConfig = new PackGachaPointConfig { ExchangeablePoint = 400, IncreaseGachaPoint = 1 },
|
||||
ChildGachas =
|
||||
{
|
||||
new PackChildGachaEntry
|
||||
{
|
||||
GachaId = 990475, TypeDetail = CardPackType.TicketMulti, Cost = 0, CardCount = 8,
|
||||
ItemId = 90001,
|
||||
},
|
||||
},
|
||||
});
|
||||
var viewer = await db.Viewers
|
||||
.Include(v => v.Items).ThenInclude(i => i.Item)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
viewer.Items.Add(new OwnedItemEntry { Item = db.Items.Local.First(), Count = 1, Viewer = viewer });
|
||||
viewer.MissionData.TutorialState = 41; // pre-END so the tutorial path is allowed
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
// Install a draw table for 99047 pointing at the 30 seeded card stubs.
|
||||
var seededCardIds = Enumerable.Range(0, 30).Select(i => (long)(99047_1010 + i)).ToArray();
|
||||
await factory.SeedPackDrawTableAsync(99047, seededCardIds);
|
||||
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var body = new StringContent(
|
||||
"""{"parent_gacha_id":99047,"gacha_id":990475,"gacha_type":1,"pack_number":1,"exclude_card_ids":[],"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""",
|
||||
System.Text.Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/tutorial/pack_open", body);
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK),
|
||||
await response.Content.ReadAsStringAsync());
|
||||
|
||||
using var scope2 = factory.Services.CreateScope();
|
||||
var db2 = scope2.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db2.Viewers
|
||||
.Include(x => x.GachaPointBalances)
|
||||
.FirstAsync(x => x.Id == viewerId);
|
||||
Assert.That(v.GachaPointBalances, Is.Empty, "tutorial path must not accrue gacha points");
|
||||
}
|
||||
|
||||
// ---------------- Free pack (type_detail=10) ----------------
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -181,69 +181,43 @@ public class PackControllerTests
|
||||
"Regular /pack/open must never emit tutorial_step — only /tutorial/pack_open does.");
|
||||
}
|
||||
|
||||
[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.
|
||||
// Max-preserve: a state > 100 (e.g., a post-tutorial training sentinel) must not be
|
||||
// clobbered down to 100. Nothing in prod sets state above 100 today, so synthesize
|
||||
// the case directly. The viewer must have the starter ticket + card pool seeded so
|
||||
// the path reaches the tutorial epilogue (rather than 400ing before the state write).
|
||||
using var factory = new SVSimTestFactory();
|
||||
await factory.SeedGlobalsAsync();
|
||||
long viewerId = await factory.SeedViewerAsync(tutorialState: 200);
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
await factory.SeedOwnedItemAsync(viewerId, itemId: 90001, count: 1, itemName: "Starter Legendary Ticket");
|
||||
|
||||
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();
|
||||
}
|
||||
await factory.SeedPackDrawTableAsync(99047, 90001001L, 90001002L, 90001003L);
|
||||
|
||||
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.
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
Assert.That(await factory.GetViewerTutorialStateAsync(viewerId), Is.GreaterThanOrEqualTo(200),
|
||||
"TutorialState must not regress regardless of the alias's accept/reject decision.");
|
||||
"TutorialState must not regress when the alias fires against a viewer past END.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ public class PracticeControllerTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Finish_accepts_any_recovery_data_returns_zero_xp()
|
||||
public async Task Finish_win_grants_class_xp_and_persists_level_up()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
@@ -218,8 +218,44 @@ public class PracticeControllerTests
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK), body);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(0));
|
||||
// BattleXpConfig.XpPerWin default = 200. classexp.csv seeds L1=50, L2=150 → 200 XP
|
||||
// crosses L1 (spends 50, level→2, exp=150) then L2 (spends 150, level→3, exp=0).
|
||||
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(200));
|
||||
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(doc.RootElement.GetProperty("class_level").GetInt32(), Is.EqualTo(3));
|
||||
Assert.That(doc.RootElement.GetProperty("reward_list").GetArrayLength(), Is.EqualTo(0));
|
||||
|
||||
// Persistence check — reload viewer and confirm the ViewerClassData row moved.
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.Classes).ThenInclude(c => c.Class)
|
||||
.FirstAsync(x => x.Id == viewerId);
|
||||
var cls1 = v.Classes.Single(c => c.Class.Id == 1);
|
||||
Assert.That(cls1.Level, Is.EqualTo(3));
|
||||
Assert.That(cls1.Exp, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Finish_loss_grants_default_loss_xp()
|
||||
{
|
||||
using var factory = new SVSimTestFactory();
|
||||
long viewerId = await factory.SeedViewerAsync();
|
||||
using var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var finishJson =
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":"","deck_no":1,"is_win":0,"evolve_count":0,"total_turn":3,"enemy_class_id":3,"difficulty":1,"deck_format":1,"class_id":1,"recovery_data":"{}"}""";
|
||||
|
||||
var response = await client.PostAsync("/practice/finish",
|
||||
new StringContent(finishJson, 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);
|
||||
// BattleXpConfig.XpPerLoss default = 50. classexp.csv L1=50 → 50 XP exactly meets
|
||||
// the threshold: Level=2, Exp=0.
|
||||
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(50));
|
||||
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(doc.RootElement.GetProperty("class_level").GetInt32(), Is.EqualTo(2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
using SVSim.Database;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
using SVSim.UnitTests.Infrastructure;
|
||||
|
||||
@@ -231,21 +234,30 @@ public class RankBattleControllerTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Finish_emits_stubbed_zeros_with_battle_result_echo()
|
||||
public async Task Finish_grants_win_xp_via_ai_variant()
|
||||
{
|
||||
await using var factory = new SVSimTestFactory();
|
||||
var viewerId = await factory.SeedViewerAsync();
|
||||
var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/ai_rotation_rank_battle/finish", FinishBody(battleResult: 1));
|
||||
var resp = await client.PostAsJsonAsync(
|
||||
"/ai_rotation_rank_battle/finish", FinishBody(battleResult: 1, classId: 1));
|
||||
|
||||
Assert.That(resp.IsSuccessStatusCode, Is.True);
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
var data = doc.RootElement;
|
||||
Assert.That(data.GetProperty("battle_result").GetInt32(), Is.EqualTo(1));
|
||||
Assert.That(data.GetProperty("rank").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(data.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(data.GetProperty("class_level").GetInt32(), Is.EqualTo(1));
|
||||
// First-ever win: +100 → Beginner 1 (rank_id=2), Point=100.
|
||||
Assert.That(data.GetProperty("rank").GetInt32(), Is.EqualTo(2));
|
||||
Assert.That(data.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(100));
|
||||
Assert.That(data.GetProperty("battle_point").GetInt32(), Is.EqualTo(100));
|
||||
Assert.That(data.GetProperty("after_master_point").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(data.GetProperty("master_point").GetInt32(), Is.EqualTo(0));
|
||||
// BattleXpConfig.XpPerWin=200; classexp.csv L1=50, L2=150 → 200 XP crosses both
|
||||
// thresholds: land at L3 with 0.
|
||||
Assert.That(data.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(200));
|
||||
Assert.That(data.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(data.GetProperty("class_level").GetInt32(), Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -255,9 +267,139 @@ public class RankBattleControllerTests
|
||||
var viewerId = await factory.SeedViewerAsync();
|
||||
var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/rotation_rank_battle/finish", FinishBody(battleResult: 2));
|
||||
var resp = await client.PostAsJsonAsync("/rotation_rank_battle/finish", FinishBody(battleResult: 2, classId: 1));
|
||||
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
Assert.That(doc.RootElement.GetProperty("battle_result").GetInt32(), Is.EqualTo(2));
|
||||
// battle_result==2 is loss-shaped for rank too: fresh viewer at 0 pts stays at
|
||||
// Beginner 0 (tier floor 0). No point change; +/-0 emitted.
|
||||
Assert.That(doc.RootElement.GetProperty("rank").GetInt32(), Is.EqualTo(1));
|
||||
Assert.That(doc.RootElement.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(doc.RootElement.GetProperty("battle_point").GetInt32(), Is.EqualTo(0));
|
||||
// battle_result==2 is loss-shaped: XpPerLoss=50 exactly meets L1 threshold → L2, Exp=0.
|
||||
Assert.That(doc.RootElement.GetProperty("get_class_experience").GetInt32(), Is.EqualTo(50));
|
||||
Assert.That(doc.RootElement.GetProperty("class_experience").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(doc.RootElement.GetProperty("class_level").GetInt32(), Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Finish_persists_class_xp_to_viewer()
|
||||
{
|
||||
await using var factory = new SVSimTestFactory();
|
||||
var viewerId = await factory.SeedViewerAsync();
|
||||
var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
await client.PostAsJsonAsync(
|
||||
"/unlimited_rank_battle/finish", FinishBody(battleResult: 1, classId: 3));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.Classes).ThenInclude(c => c.Class)
|
||||
.Include(x => x.RankProgress)
|
||||
.FirstAsync(x => x.Id == viewerId);
|
||||
var cls3 = v.Classes.Single(c => c.Class.Id == 3);
|
||||
Assert.That(cls3.Level, Is.EqualTo(3));
|
||||
Assert.That(cls3.Exp, Is.EqualTo(0));
|
||||
// Rank progress persisted under Unlimited with +100.
|
||||
var rp = v.RankProgress.Single(r => r.Format == Format.Unlimited);
|
||||
Assert.That(rp.Point, Is.EqualTo(100));
|
||||
Assert.That(rp.MasterPoint, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Finish_loss_at_D_tier_stays_at_D_floor()
|
||||
{
|
||||
await using var factory = new SVSimTestFactory();
|
||||
var viewerId = await factory.SeedViewerAsync();
|
||||
|
||||
// Preload viewer with Point=1200 (D0 entry) in Rotation.
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.RankProgress).FirstAsync(x => x.Id == viewerId);
|
||||
v.RankProgress.Add(new ViewerRankProgress
|
||||
{
|
||||
Format = Format.Rotation, Point = 1200, MasterPoint = 0,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var resp = await client.PostAsJsonAsync(
|
||||
"/rotation_rank_battle/finish", FinishBody(battleResult: 0, classId: 1));
|
||||
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
var data = doc.RootElement;
|
||||
Assert.That(data.GetProperty("rank").GetInt32(), Is.EqualTo(5)); // still D0
|
||||
Assert.That(data.GetProperty("after_battle_point").GetInt32(), Is.EqualTo(1200)); // floored
|
||||
Assert.That(data.GetProperty("battle_point").GetInt32(), Is.EqualTo(0)); // no drop
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Finish_rotation_and_unlimited_progress_independently()
|
||||
{
|
||||
await using var factory = new SVSimTestFactory();
|
||||
var viewerId = await factory.SeedViewerAsync();
|
||||
var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
await client.PostAsJsonAsync("/rotation_rank_battle/finish", FinishBody(battleResult: 1, classId: 1));
|
||||
await client.PostAsJsonAsync("/rotation_rank_battle/finish", FinishBody(battleResult: 1, classId: 1));
|
||||
await client.PostAsJsonAsync("/unlimited_rank_battle/finish", FinishBody(battleResult: 1, classId: 1));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.RankProgress).FirstAsync(x => x.Id == viewerId);
|
||||
Assert.That(v.RankProgress.Single(r => r.Format == Format.Rotation).Point, Is.EqualTo(200));
|
||||
Assert.That(v.RankProgress.Single(r => r.Format == Format.Unlimited).Point, Is.EqualTo(100));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Finish_ai_variant_persists_progress_same_as_human_variant()
|
||||
{
|
||||
await using var factory = new SVSimTestFactory();
|
||||
var viewerId = await factory.SeedViewerAsync();
|
||||
var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
|
||||
await client.PostAsJsonAsync("/ai_unlimited_rank_battle/finish", FinishBody(battleResult: 1, classId: 1));
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.RankProgress).FirstAsync(x => x.Id == viewerId);
|
||||
Assert.That(v.RankProgress.Single(r => r.Format == Format.Unlimited).Point, Is.EqualTo(100));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AiStart_SelfInfo_carries_viewer_rank_progress()
|
||||
{
|
||||
await using var factory = new SVSimTestFactory();
|
||||
var viewerId = await factory.SeedViewerAsync();
|
||||
await factory.SeedGlobalsAsync();
|
||||
await factory.SeedDeckAsync(viewerId, Format.Unlimited, 1);
|
||||
|
||||
// Preload Point=200 (Beginner 2 = rank_id 3) on Unlimited.
|
||||
using (var scope = factory.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var v = await db.Viewers.Include(x => x.RankProgress).FirstAsync(x => x.Id == viewerId);
|
||||
v.RankProgress.Add(new ViewerRankProgress
|
||||
{
|
||||
Format = Format.Unlimited, Point = 200, MasterPoint = 0,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await RegisterBotBattleAsync(factory, viewerId, Format.Unlimited, deckNo: 1);
|
||||
var client = factory.CreateAuthenticatedClient(viewerId);
|
||||
var resp = await client.PostAsJsonAsync("/ai_unlimited_rank_battle/start", EmptyAuthedBody);
|
||||
|
||||
Assert.That(resp.IsSuccessStatusCode, Is.True);
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
// AiBattlePlayerInfo wire keys are camelCase (self_info/oppo_info aside);
|
||||
// see AiBattleStartResponseDto.cs class comment.
|
||||
var self = doc.RootElement.GetProperty("self_info");
|
||||
Assert.That(self.GetProperty("rank").GetInt32(), Is.EqualTo(3)); // 200 → Beginner 2
|
||||
Assert.That(self.GetProperty("battlePoint").GetInt32(), Is.EqualTo(200));
|
||||
Assert.That(self.GetProperty("isMasterRank").GetInt32(), Is.EqualTo(0));
|
||||
Assert.That(self.GetProperty("masterPoint").GetInt32(), Is.EqualTo(0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,6 +397,20 @@ internal class SVSimTestFactory : WebApplicationFactory<Program>
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret baked into <c>appsettings.Testing.json</c> for <c>/admin/*</c> gating. Kept
|
||||
/// here so tests can construct requests with or without a valid header via the same source.
|
||||
/// </summary>
|
||||
public const string AdminSecret = "test-admin-secret";
|
||||
|
||||
/// <summary>Convenience: bake the X-Admin-Secret header into a fresh client.</summary>
|
||||
public HttpClient CreateAdminClient()
|
||||
{
|
||||
var client = CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Admin-Secret", AdminSecret);
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a deck for the viewer via the real <see cref="IDeckRepository.UpsertDeck"/>
|
||||
/// path. Picks the first seeded class/sleeve/leader-skin from the master tables; tests
|
||||
|
||||
@@ -25,16 +25,16 @@ public class GameConfigurationJsonbTests
|
||||
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
|
||||
var byName = rows.ToDictionary(r => r.SectionName);
|
||||
|
||||
// One row per [ConfigSection]-marked POCO (18 sections today: Player, DefaultGrants,
|
||||
// One row per [ConfigSection]-marked POCO (19 sections today: Player, DefaultGrants,
|
||||
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story, ResourceConfig,
|
||||
// Freeplay, ArenaTwoPick, Matching, CardMasterConfig, ColosseumSeason, ColosseumRounds,
|
||||
// LoginBonus, Guild, SkipTutorial).
|
||||
// LoginBonus, Guild, SkipTutorial, BattleXp).
|
||||
Assert.That(byName.Keys, Is.EquivalentTo(new[]
|
||||
{
|
||||
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
|
||||
"MyRotationSchedule", "Story", "ResourceConfig", "Freeplay", "ArenaTwoPick", "Matching",
|
||||
"CardMasterConfig", "ColosseumSeason", "ColosseumRounds", "LoginBonus", "Guild",
|
||||
"SkipTutorial",
|
||||
"SkipTutorial", "BattleXp", "GameCalendar",
|
||||
}));
|
||||
|
||||
var resources = JsonSerializer.Deserialize<ResourceConfig>(byName["ResourceConfig"].ValueJson)!;
|
||||
@@ -55,9 +55,9 @@ public class GameConfigurationJsonbTests
|
||||
Assert.That(slot8!.Silver, Is.EqualTo(0.7692).Within(1e-9));
|
||||
|
||||
var grants = JsonSerializer.Deserialize<DefaultGrantsConfig>(byName["DefaultGrants"].ValueJson)!;
|
||||
Assert.That(grants.Crystals, Is.EqualTo(50000UL));
|
||||
Assert.That(grants.Rupees, Is.EqualTo(50000UL));
|
||||
Assert.That(grants.Ether, Is.EqualTo(50000UL));
|
||||
Assert.That(grants.Crystals, Is.EqualTo(0UL));
|
||||
Assert.That(grants.Rupees, Is.EqualTo(0UL));
|
||||
Assert.That(grants.Ether, Is.EqualTo(0UL));
|
||||
|
||||
var player = JsonSerializer.Deserialize<PlayerConfig>(byName["Player"].ValueJson)!;
|
||||
Assert.That(player.MaxFriends, Is.EqualTo(20));
|
||||
|
||||
@@ -64,6 +64,7 @@ public class ArenaTwoPickServiceDraftTests
|
||||
scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
|
||||
scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
|
||||
scope.ServiceProvider.GetRequiredService<IInventoryService>(),
|
||||
scope.ServiceProvider.GetRequiredService<SVSim.Database.Services.BattleXp.IBattleXpService>(),
|
||||
new SystemRandom(seed: 1),
|
||||
db);
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ public class ArenaTwoPickServiceEntryTests
|
||||
config,
|
||||
scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
|
||||
inv,
|
||||
scope.ServiceProvider.GetRequiredService<SVSim.Database.Services.BattleXp.IBattleXpService>(),
|
||||
new SystemRandom(seed: 1234),
|
||||
db);
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ public class ArenaTwoPickServiceFinishTests
|
||||
scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
|
||||
scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
|
||||
scope.ServiceProvider.GetRequiredService<IInventoryService>(),
|
||||
scope.ServiceProvider.GetRequiredService<SVSim.Database.Services.BattleXp.IBattleXpService>(),
|
||||
new SystemRandom(seed: 1),
|
||||
db);
|
||||
|
||||
@@ -122,11 +123,24 @@ public class ArenaTwoPickServiceFinishTests
|
||||
var (db, svc, vid) = await SetupWithRunAsync(winCount: 0, lossCount: 5);
|
||||
await using var _ = db;
|
||||
|
||||
var dto = await svc.FinishAsync(vid);
|
||||
Assert.That(dto.Rewards.Single(r => r.RewardType == 9).RewardCount, Is.EqualTo(100));
|
||||
var outcome = await svc.FinishAsync(vid);
|
||||
Assert.That(outcome.Response.Rewards.Single(r => r.RewardType == 9).RewardCount, Is.EqualTo(100));
|
||||
Assert.That(outcome.WasFullClear, Is.False, "0W 5L is not a full clear");
|
||||
Assert.That(await db.ViewerArenaTwoPickRuns.AnyAsync(), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FinishAsync_signals_full_clear_when_run_ended_with_5_wins()
|
||||
{
|
||||
// Full clear = 5W 0L (max battles = 5). Controller consumes this to fire the
|
||||
// challenge_full_clear mission event.
|
||||
var (db, svc, vid) = await SetupWithRunAsync(winCount: 5, lossCount: 0);
|
||||
await using var _ = db;
|
||||
|
||||
var outcome = await svc.FinishAsync(vid);
|
||||
Assert.That(outcome.WasFullClear, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RecordBattleResultAsync_win_increments_win_and_grants_xp_and_spot_points()
|
||||
{
|
||||
@@ -136,13 +150,30 @@ public class ArenaTwoPickServiceFinishTests
|
||||
var result = await svc.RecordBattleResultAsync(vid, isWin: true);
|
||||
|
||||
Assert.That(result.BattleResult, Is.EqualTo(1));
|
||||
Assert.That(result.GetClassExperience, Is.EqualTo(100));
|
||||
Assert.That(result.GetClassExperience, Is.EqualTo(200),
|
||||
"Default BattleXpConfig.XpPerWin");
|
||||
Assert.That(result.AddSpotPoint, Is.EqualTo(10));
|
||||
var run = await db.ViewerArenaTwoPickRuns.FirstAsync(r => r.ViewerId == vid);
|
||||
Assert.That(run.WinCount, Is.EqualTo(2));
|
||||
Assert.That(JsonSerializer.Deserialize<List<bool>>(run.ResultListJson)!.Count, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RecordBattleResultAsync_loss_grants_loss_xp()
|
||||
{
|
||||
var (db, svc, vid) = await SetupWithRunAsync(winCount: 0, lossCount: 0);
|
||||
await using var _ = db;
|
||||
|
||||
var result = await svc.RecordBattleResultAsync(vid, isWin: false);
|
||||
|
||||
Assert.That(result.BattleResult, Is.EqualTo(0));
|
||||
Assert.That(result.GetClassExperience, Is.EqualTo(50),
|
||||
"Default BattleXpConfig.XpPerLoss");
|
||||
Assert.That(result.ClassExperience, Is.EqualTo(0),
|
||||
"Fresh viewer with Exp=0, +50 loss XP exactly meets curve[1]=50 → L2, Exp=0.");
|
||||
Assert.That(result.ClassLevel, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RecordBattleResultAsync_increments_loss_without_terminating()
|
||||
{
|
||||
|
||||
@@ -82,6 +82,6 @@ public class ArenaTwoPickServiceTopTests
|
||||
{
|
||||
// GetTopAsync only uses _runs — every other dep can be null! because the test path
|
||||
// never touches them.
|
||||
return new ArenaTwoPickService(runRepo, null!, null!, null!, null!, null!, null!, db);
|
||||
return new ArenaTwoPickService(runRepo, null!, null!, null!, null!, null!, null!, null!, db);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ public class ArenaTwoPickServiceWeightedRewardsTests
|
||||
scope.ServiceProvider.GetRequiredService<IGameConfigService>(),
|
||||
scope.ServiceProvider.GetRequiredService<IViewerRepository>(),
|
||||
scope.ServiceProvider.GetRequiredService<IInventoryService>(),
|
||||
scope.ServiceProvider.GetRequiredService<SVSim.Database.Services.BattleXp.IBattleXpService>(),
|
||||
rng,
|
||||
db);
|
||||
|
||||
|
||||
233
SVSim.UnitTests/Services/BattleXpServiceTests.cs
Normal file
233
SVSim.UnitTests/Services/BattleXpServiceTests.cs
Normal file
@@ -0,0 +1,233 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.Database.Services.BattleXp;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class BattleXpServiceTests
|
||||
{
|
||||
private sealed class FakeConfig : IGameConfigService
|
||||
{
|
||||
private readonly BattleXpConfig _cfg;
|
||||
public FakeConfig(BattleXpConfig cfg) { _cfg = cfg; }
|
||||
public T Get<T>() where T : class, new() => (T)(object)_cfg;
|
||||
}
|
||||
|
||||
private static IGlobalsRepository CurveRepo(params (int Level, int NecessaryExp)[] rows)
|
||||
{
|
||||
var mock = new Mock<IGlobalsRepository>(MockBehavior.Strict);
|
||||
mock.Setup(x => x.GetClassExpCurve()).ReturnsAsync(
|
||||
rows.Select(r => new ClassExpEntry { Id = r.Level, NecessaryExp = r.NecessaryExp }).ToList());
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static Viewer NewViewerWithClass(int classId, int level = 1, int exp = 0)
|
||||
{
|
||||
var cls = new ClassEntry { Id = classId, Name = $"Class{classId}" };
|
||||
return new Viewer
|
||||
{
|
||||
Id = 1,
|
||||
DisplayName = "v",
|
||||
Classes = { new ViewerClassData { Class = cls, Level = level, Exp = exp } },
|
||||
};
|
||||
}
|
||||
|
||||
private static BattleXpService NewService(BattleXpConfig cfg, params (int Level, int NecessaryExp)[] curve)
|
||||
{
|
||||
var rows = curve.Length > 0
|
||||
? curve
|
||||
: new[] { (1, 100), (2, 150), (3, 250) };
|
||||
return new BattleXpService(CurveRepo(rows), new FakeConfig(cfg), NullLogger<BattleXpService>.Instance);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Win_uses_XpPerWin_and_accumulates_exp()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 40, XpPerLoss = 5 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, classId: 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.GetXp, Is.EqualTo(40));
|
||||
Assert.That(r.TotalXp, Is.EqualTo(40));
|
||||
Assert.That(r.Level, Is.EqualTo(1));
|
||||
Assert.That(v.Classes.Single().Exp, Is.EqualTo(40));
|
||||
Assert.That(v.Classes.Single().Level, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_uses_XpPerLoss()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 40, XpPerLoss = 5 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, classId: 1, isWin: false, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.GetXp, Is.EqualTo(5));
|
||||
Assert.That(r.TotalXp, Is.EqualTo(5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Per_mode_override_wins_over_global()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig
|
||||
{
|
||||
XpPerWin = 40, XpPerLoss = 5,
|
||||
PracticeXpPerWin = 200, PracticeXpPerLoss = 20,
|
||||
});
|
||||
|
||||
var vw = NewViewerWithClass(1);
|
||||
var win = await svc.GrantAsync(vw, 1, isWin: true, BattleXpMode.Practice);
|
||||
Assert.That(win.GetXp, Is.EqualTo(200));
|
||||
|
||||
var vl = NewViewerWithClass(1);
|
||||
var loss = await svc.GrantAsync(vl, 1, isWin: false, BattleXpMode.Practice);
|
||||
Assert.That(loss.GetXp, Is.EqualTo(20));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Rank_mode_falls_back_to_global_when_override_null()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig
|
||||
{
|
||||
XpPerWin = 40, XpPerLoss = 5,
|
||||
PracticeXpPerWin = 200, // Rank has no override — must fall back.
|
||||
});
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.GetXp, Is.EqualTo(40));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Story_uses_StoryXpPerClear_when_set_ignoring_isWin()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig
|
||||
{
|
||||
XpPerWin = 40, XpPerLoss = 5,
|
||||
StoryXpPerClear = 300,
|
||||
});
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: false, BattleXpMode.Story);
|
||||
|
||||
Assert.That(r.GetXp, Is.EqualTo(300));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Story_falls_back_to_XpPerWin_when_StoryXpPerClear_null()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 40, XpPerLoss = 5 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Story);
|
||||
|
||||
Assert.That(r.GetXp, Is.EqualTo(40));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Single_grant_crossing_one_threshold_bumps_level_and_carries_overflow()
|
||||
{
|
||||
// Curve: L1 needs 100 to reach L2. Grant 130 → level 2, Exp 30 carry.
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 130 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.Level, Is.EqualTo(2));
|
||||
Assert.That(r.TotalXp, Is.EqualTo(30));
|
||||
Assert.That(v.Classes.Single().Level, Is.EqualTo(2));
|
||||
Assert.That(v.Classes.Single().Exp, Is.EqualTo(30));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Single_grant_crossing_multiple_thresholds_bumps_multiple_levels()
|
||||
{
|
||||
// Curve: L1=100, L2=150, L3=250.
|
||||
// Grant 500 → L1(100)→L2 (400 left) → L2(150)→L3 (250 left) → stop at maxLevel=3.
|
||||
// Final: Level=3, Exp=250.
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 500 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.Level, Is.EqualTo(3));
|
||||
Assert.That(r.TotalXp, Is.EqualTo(250));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Saturates_at_max_curve_level_with_excess_in_exp()
|
||||
{
|
||||
// Max curve level = 3. Grant enough to overshoot far.
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 10_000 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.Level, Is.EqualTo(3));
|
||||
// 10000 - 100 (L1→L2) - 150 (L2→L3) = 9750 sitting in Exp at L3.
|
||||
Assert.That(r.TotalXp, Is.EqualTo(9750));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Unknown_classId_returns_zero_and_does_not_mutate_viewer()
|
||||
{
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 40 });
|
||||
var v = NewViewerWithClass(classId: 1);
|
||||
|
||||
var r = await svc.GrantAsync(v, classId: 99, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.GetXp, Is.EqualTo(0));
|
||||
Assert.That(r.TotalXp, Is.EqualTo(0));
|
||||
Assert.That(r.Level, Is.EqualTo(1));
|
||||
Assert.That(r.LeveledUp, Is.False);
|
||||
Assert.That(v.Classes.Single().Exp, Is.EqualTo(0),
|
||||
"Class 1's row must not have been touched.");
|
||||
}
|
||||
|
||||
// ---- LeveledUp signal ----
|
||||
|
||||
[Test]
|
||||
public async Task LeveledUp_is_false_when_grant_stays_within_same_level()
|
||||
{
|
||||
// L1 requires 100 to reach L2; grant only 40.
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 40 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.Level, Is.EqualTo(1));
|
||||
Assert.That(r.LeveledUp, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LeveledUp_is_true_when_grant_crosses_a_threshold()
|
||||
{
|
||||
// L1=100 → grant 130 → L2 with 30 carry.
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 130 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.Level, Is.EqualTo(2));
|
||||
Assert.That(r.LeveledUp, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task LeveledUp_stays_true_across_multiple_bumps_in_one_grant()
|
||||
{
|
||||
// Grant crosses L1→L2→L3 in one go.
|
||||
var svc = NewService(new BattleXpConfig { XpPerWin = 500 });
|
||||
var v = NewViewerWithClass(1);
|
||||
|
||||
var r = await svc.GrantAsync(v, 1, isWin: true, BattleXpMode.Rank);
|
||||
|
||||
Assert.That(r.Level, Is.EqualTo(3));
|
||||
Assert.That(r.LeveledUp, Is.True);
|
||||
}
|
||||
}
|
||||
128
SVSim.UnitTests/Services/GameCalendarServiceTests.cs
Normal file
128
SVSim.UnitTests/Services/GameCalendarServiceTests.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using SVSim.Database.Models.Config;
|
||||
using SVSim.Database.Services;
|
||||
using SVSim.EmulatedEntrypoint.Services;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class GameCalendarServiceTests
|
||||
{
|
||||
private sealed class FakeConfig : IGameConfigService
|
||||
{
|
||||
private readonly int _hour;
|
||||
public FakeConfig(int hour) { _hour = hour; }
|
||||
public T Get<T>() where T : class, new() =>
|
||||
(T)(object)new GameCalendarConfig { DailyResetUtcHour = _hour };
|
||||
}
|
||||
|
||||
private static GameCalendarService NewService(int resetHour) =>
|
||||
new(new FakeConfig(resetHour));
|
||||
|
||||
private static DateTimeOffset Utc(int y, int mo, int d, int h, int m, int s) =>
|
||||
new(y, mo, d, h, m, s, TimeSpan.Zero);
|
||||
|
||||
// ------------ ResetReady ------------
|
||||
|
||||
[Test]
|
||||
public void ResetReady_returns_true_when_lastCheckpoint_is_null()
|
||||
{
|
||||
var svc = NewService(0);
|
||||
Assert.That(svc.ResetReady(null), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResetReady_returns_false_when_lastCheckpoint_is_after_todays_boundary()
|
||||
{
|
||||
// hour=0 → today's boundary is 00:00 UTC. lastCheckpoint at 00:00:01 UTC today
|
||||
// is AFTER the boundary → not yet time to reset.
|
||||
var svc = NewService(0);
|
||||
var last = DateTime.UtcNow.Date.AddSeconds(1);
|
||||
Assert.That(svc.ResetReady(last), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResetReady_returns_true_when_lastCheckpoint_is_before_todays_boundary()
|
||||
{
|
||||
var svc = NewService(0);
|
||||
var last = DateTime.UtcNow.Date.AddSeconds(-1); // yesterday, 1s before midnight
|
||||
Assert.That(svc.ResetReady(last), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResetReady_respects_config_reset_hour()
|
||||
{
|
||||
// hour=17. lastCheckpoint at 16:00 UTC today = before today's boundary → reset ready.
|
||||
// (unless we're currently before 17:00 UTC — in which case today's boundary is
|
||||
// "tomorrow-in-the-future" and MostRecent is yesterday 17:00; last at 16:00 today
|
||||
// is AFTER yesterday's 17:00 → NOT ready. Test both sides explicitly rather than
|
||||
// relying on wall clock.)
|
||||
var svc = NewService(17);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Instant well before yesterday's 17:00 UTC — guaranteed to be reset-ready no matter
|
||||
// when we run this.
|
||||
var stale = now.AddDays(-2);
|
||||
Assert.That(svc.ResetReady(stale), Is.True, "48h ago is always reset-ready");
|
||||
}
|
||||
|
||||
// ------------ DayKey ------------
|
||||
|
||||
[Test]
|
||||
public void DayKey_hour0_uses_utc_calendar_day()
|
||||
{
|
||||
var svc = NewService(0);
|
||||
Assert.That(svc.DayKey(Utc(2026, 6, 1, 0, 0, 0)), Is.EqualTo("day:2026-06-01"));
|
||||
Assert.That(svc.DayKey(Utc(2026, 6, 1, 23, 59, 59)), Is.EqualTo("day:2026-06-01"));
|
||||
Assert.That(svc.DayKey(Utc(2026, 6, 2, 0, 0, 0)), Is.EqualTo("day:2026-06-02"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void DayKey_hour17_shifts_boundary_to_17_00_utc()
|
||||
{
|
||||
var svc = NewService(17);
|
||||
// Before today's 17:00 UTC → window started yesterday at 17:00.
|
||||
Assert.That(svc.DayKey(Utc(2026, 6, 1, 16, 59, 59)), Is.EqualTo("day:2026-05-31"));
|
||||
// 17:00 UTC exactly → new window starts.
|
||||
Assert.That(svc.DayKey(Utc(2026, 6, 1, 17, 0, 0)), Is.EqualTo("day:2026-06-01"));
|
||||
// Later same day → same window.
|
||||
Assert.That(svc.DayKey(Utc(2026, 6, 1, 23, 59, 59)), Is.EqualTo("day:2026-06-01"));
|
||||
}
|
||||
|
||||
// ------------ WeekKey / MonthKey ------------
|
||||
|
||||
[Test]
|
||||
public void WeekKey_is_iso_week_of_window_start()
|
||||
{
|
||||
var svc = NewService(0);
|
||||
// 2026-05-25 is Monday (ISO week 22 of 2026).
|
||||
Assert.That(svc.WeekKey(Utc(2026, 5, 25, 12, 0, 0)), Is.EqualTo("week:2026-W22"));
|
||||
// Sunday of that week.
|
||||
Assert.That(svc.WeekKey(Utc(2026, 5, 24, 23, 59, 59)), Is.EqualTo("week:2026-W21"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MonthKey_uses_utc_month()
|
||||
{
|
||||
var svc = NewService(0);
|
||||
Assert.That(svc.MonthKey(Utc(2026, 6, 30, 23, 59, 59)), Is.EqualTo("month:2026-06"));
|
||||
Assert.That(svc.MonthKey(Utc(2026, 7, 1, 0, 0, 0)), Is.EqualTo("month:2026-07"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MonthKey_shifts_with_reset_hour_at_month_edge()
|
||||
{
|
||||
var svc = NewService(17);
|
||||
// 16:59 UTC on Jul 1 → window still in June (started Jun 30 17:00).
|
||||
Assert.That(svc.MonthKey(Utc(2026, 7, 1, 16, 59, 0)), Is.EqualTo("month:2026-06"));
|
||||
Assert.That(svc.MonthKey(Utc(2026, 7, 1, 17, 0, 0)), Is.EqualTo("month:2026-07"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AllPeriods_returns_day_week_month_all_time()
|
||||
{
|
||||
var svc = NewService(0);
|
||||
var periods = svc.AllPeriods(Utc(2026, 5, 27, 12, 0, 0));
|
||||
Assert.That(periods, Is.EquivalentTo(new[] {
|
||||
"day:2026-05-27", "week:2026-W22", "month:2026-05", GameCalendarPeriods.AllTime,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class JstPeriodTests
|
||||
{
|
||||
private static DateTimeOffset Jst(int y, int m, int d, int h, int min, int s) =>
|
||||
new DateTimeOffset(y, m, d, h, min, s, TimeSpan.FromHours(9));
|
||||
|
||||
[Test]
|
||||
public void DayKey_uses_jst_with_02_00_anchor()
|
||||
{
|
||||
// 01:59:59 JST on May 27 belongs to the May 26 "day".
|
||||
Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.DayKey(Jst(2026, 5, 27, 1, 59, 59)),
|
||||
Is.EqualTo("day:2026-05-26"));
|
||||
|
||||
// 02:00:00 JST on May 27 belongs to the May 27 "day".
|
||||
Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.DayKey(Jst(2026, 5, 27, 2, 0, 0)),
|
||||
Is.EqualTo("day:2026-05-27"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void WeekKey_is_iso8601_monday_anchored()
|
||||
{
|
||||
// 2026-05-25 (Monday) 02:00 JST → ISO week 2026-W22.
|
||||
Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.WeekKey(Jst(2026, 5, 25, 2, 0, 0)),
|
||||
Is.EqualTo("week:2026-W22"));
|
||||
|
||||
// The previous Sunday's late evening still belongs to W21 (week reset is Monday 02:00 JST).
|
||||
Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.WeekKey(Jst(2026, 5, 25, 1, 59, 59)),
|
||||
Is.EqualTo("week:2026-W21"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MonthKey_uses_jst_day_anchor_for_month_rollover()
|
||||
{
|
||||
// 01:59 JST on June 1 still belongs to May (day-boundary is 02:00 JST).
|
||||
Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.MonthKey(Jst(2026, 6, 1, 1, 59, 0)),
|
||||
Is.EqualTo("month:2026-05"));
|
||||
|
||||
Assert.That(SVSim.EmulatedEntrypoint.Services.JstPeriod.MonthKey(Jst(2026, 6, 1, 2, 0, 0)),
|
||||
Is.EqualTo("month:2026-06"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AllPeriods_returns_day_week_month_all_time()
|
||||
{
|
||||
var t = Jst(2026, 5, 27, 12, 0, 0);
|
||||
var periods = SVSim.EmulatedEntrypoint.Services.JstPeriod.AllPeriods(t);
|
||||
Assert.That(periods, Is.EquivalentTo(new[] {
|
||||
"day:2026-05-27", "week:2026-W22", "month:2026-05", "all-time",
|
||||
}));
|
||||
}
|
||||
}
|
||||
266
SVSim.UnitTests/Services/MissionEventKeysTests.cs
Normal file
266
SVSim.UnitTests/Services/MissionEventKeysTests.cs
Normal file
@@ -0,0 +1,266 @@
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Services;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class MissionEventKeysTests
|
||||
{
|
||||
// ---- Practice.WinAll ----
|
||||
|
||||
[Test]
|
||||
public void Practice_WinAll_emits_all_three_levels_for_known_tier_and_leader()
|
||||
{
|
||||
// Wire difficulty 4 = "elite", enemy_class_id 1 = "arisa" (Forestcraft).
|
||||
var keys = MissionEventKeys.Practice.WinAll(wireDifficulty: 4, enemyClassId: 1);
|
||||
Assert.That(keys, Is.EqualTo(new[]
|
||||
{
|
||||
"practice_win",
|
||||
"practice_win:elite",
|
||||
"practice_win:elite:arisa",
|
||||
}));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Practice_WinAll_maps_all_three_elite_tiers()
|
||||
{
|
||||
Assert.That(MissionEventKeys.Practice.WinAll(4, 2), Contains.Item("practice_win:elite:erika"));
|
||||
Assert.That(MissionEventKeys.Practice.WinAll(6, 2), Contains.Item("practice_win:elite2:erika"));
|
||||
Assert.That(MissionEventKeys.Practice.WinAll(7, 2), Contains.Item("practice_win:elite3:erika"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Practice_WinAll_covers_all_eight_leaders()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(MissionEventKeys.Practice.WinAll(4, 1), Contains.Item("practice_win:elite:arisa"));
|
||||
Assert.That(MissionEventKeys.Practice.WinAll(4, 2), Contains.Item("practice_win:elite:erika"));
|
||||
Assert.That(MissionEventKeys.Practice.WinAll(4, 3), Contains.Item("practice_win:elite:isabelle"));
|
||||
Assert.That(MissionEventKeys.Practice.WinAll(4, 4), Contains.Item("practice_win:elite:rowen"));
|
||||
Assert.That(MissionEventKeys.Practice.WinAll(4, 5), Contains.Item("practice_win:elite:luna"));
|
||||
Assert.That(MissionEventKeys.Practice.WinAll(4, 6), Contains.Item("practice_win:elite:urias"));
|
||||
Assert.That(MissionEventKeys.Practice.WinAll(4, 7), Contains.Item("practice_win:elite:eris"));
|
||||
Assert.That(MissionEventKeys.Practice.WinAll(4, 8), Contains.Item("practice_win:elite:yuwan"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Practice_WinAll_falls_back_to_top_level_for_non_elite_difficulty()
|
||||
{
|
||||
// Wire difficulty 2 = "Advanced" (or similar) — not in the elite tier registry.
|
||||
// Emit only the top-level counter; hierarchical levels drop off.
|
||||
var keys = MissionEventKeys.Practice.WinAll(wireDifficulty: 2, enemyClassId: 1);
|
||||
Assert.That(keys, Is.EqualTo(new[] { "practice_win" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Practice_WinAll_drops_leader_level_for_unknown_class()
|
||||
{
|
||||
var keys = MissionEventKeys.Practice.WinAll(wireDifficulty: 4, enemyClassId: 99);
|
||||
Assert.That(keys, Is.EqualTo(new[] { "practice_win", "practice_win:elite" }));
|
||||
}
|
||||
|
||||
// ---- Story.ChapterFinishAll ----
|
||||
|
||||
[Test]
|
||||
public void Story_ChapterFinishAll_emits_all_three_levels()
|
||||
{
|
||||
var keys = MissionEventKeys.Story.ChapterFinishAll("main", 42);
|
||||
Assert.That(keys, Is.EqualTo(new[]
|
||||
{
|
||||
"story_chapter_finish",
|
||||
"story_chapter_finish:main",
|
||||
"story_chapter_finish:main:42",
|
||||
}));
|
||||
}
|
||||
|
||||
// ---- ItemPurchase ----
|
||||
|
||||
[Test]
|
||||
public void ItemPurchase_returns_prefixed_id_string()
|
||||
{
|
||||
Assert.That(MissionEventKeys.ItemPurchase(501), Is.EqualTo("item_purchase:501"));
|
||||
}
|
||||
|
||||
// ---- IsRegistered ----
|
||||
|
||||
[Test]
|
||||
public void IsRegistered_accepts_bare_top_level_prefix()
|
||||
{
|
||||
Assert.That(MissionEventKeys.IsRegistered("practice_win"), Is.True);
|
||||
Assert.That(MissionEventKeys.IsRegistered("ranked_or_arena_win"), Is.True);
|
||||
Assert.That(MissionEventKeys.IsRegistered("challenge_full_clear"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsRegistered_accepts_hierarchical_extensions()
|
||||
{
|
||||
Assert.That(MissionEventKeys.IsRegistered("practice_win:elite:arisa"), Is.True);
|
||||
Assert.That(MissionEventKeys.IsRegistered("class_level_up:forestcraft"), Is.True);
|
||||
Assert.That(MissionEventKeys.IsRegistered("item_purchase:501"), Is.True);
|
||||
Assert.That(MissionEventKeys.IsRegistered("rank_achieved:master"), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void IsRegistered_rejects_typos_and_unknown_prefixes()
|
||||
{
|
||||
Assert.That(MissionEventKeys.IsRegistered("practice_wln"), Is.False); // typo
|
||||
Assert.That(MissionEventKeys.IsRegistered("practice_win_extra"), Is.False); // suffix without colon
|
||||
Assert.That(MissionEventKeys.IsRegistered("battle_win_total"), Is.False); // test-only fixture
|
||||
Assert.That(MissionEventKeys.IsRegistered("orphan_event"), Is.False);
|
||||
Assert.That(MissionEventKeys.IsRegistered(""), Is.False);
|
||||
}
|
||||
|
||||
// ---- Seed drift sanity check ----
|
||||
|
||||
// ---- Class name mapping ----
|
||||
|
||||
[Test]
|
||||
public void Class_Name_covers_all_eight_classes()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(MissionEventKeys.Class.Name(1), Is.EqualTo("forestcraft"));
|
||||
Assert.That(MissionEventKeys.Class.Name(2), Is.EqualTo("swordcraft"));
|
||||
Assert.That(MissionEventKeys.Class.Name(3), Is.EqualTo("runecraft"));
|
||||
Assert.That(MissionEventKeys.Class.Name(4), Is.EqualTo("dragoncraft"));
|
||||
Assert.That(MissionEventKeys.Class.Name(5), Is.EqualTo("shadowcraft"));
|
||||
Assert.That(MissionEventKeys.Class.Name(6), Is.EqualTo("bloodcraft"));
|
||||
Assert.That(MissionEventKeys.Class.Name(7), Is.EqualTo("havencraft"));
|
||||
Assert.That(MissionEventKeys.Class.Name(8), Is.EqualTo("portalcraft"));
|
||||
Assert.That(MissionEventKeys.Class.Name(0), Is.Null);
|
||||
Assert.That(MissionEventKeys.Class.Name(9), Is.Null);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Ranked / Free / Challenge ----
|
||||
|
||||
[Test]
|
||||
public void Ranked_WinAll_emits_family_class_and_aggregates()
|
||||
{
|
||||
var keys = MissionEventKeys.Ranked.WinAll(classId: 1);
|
||||
Assert.That(keys, Is.EquivalentTo(new[]
|
||||
{
|
||||
"ranked_win",
|
||||
"ranked_win:forestcraft",
|
||||
"ranked_or_arena_win",
|
||||
"daily_match_win",
|
||||
}));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ranked_WinAll_drops_class_variant_for_unknown_class()
|
||||
{
|
||||
var keys = MissionEventKeys.Ranked.WinAll(classId: 99);
|
||||
Assert.That(keys, Is.EquivalentTo(new[]
|
||||
{
|
||||
"ranked_win", "ranked_or_arena_win", "daily_match_win",
|
||||
}));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Free_WinAll_emits_only_aggregates()
|
||||
{
|
||||
Assert.That(MissionEventKeys.Free.WinAll(),
|
||||
Is.EquivalentTo(new[] { "ranked_or_arena_win", "daily_match_win" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Challenge_MatchPlayAll_top_level_only()
|
||||
{
|
||||
Assert.That(MissionEventKeys.Challenge.MatchPlayAll(),
|
||||
Is.EquivalentTo(new[] { "challenge_play" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Challenge_MatchWinAll_includes_play_win_and_aggregates()
|
||||
{
|
||||
Assert.That(MissionEventKeys.Challenge.MatchWinAll(), Is.EquivalentTo(new[]
|
||||
{
|
||||
"challenge_win", "challenge_play", "ranked_or_arena_win", "daily_match_win",
|
||||
}));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Challenge_FullClearAll_top_level_only()
|
||||
{
|
||||
Assert.That(MissionEventKeys.Challenge.FullClearAll(),
|
||||
Is.EquivalentTo(new[] { "challenge_full_clear" }));
|
||||
}
|
||||
|
||||
// ---- ClassLevel ----
|
||||
|
||||
[Test]
|
||||
public void ClassLevel_UpAll_emits_family_and_class_variant()
|
||||
{
|
||||
var keys = MissionEventKeys.ClassLevel.UpAll(classId: 3);
|
||||
Assert.That(keys, Is.EquivalentTo(new[] { "class_level_up", "class_level_up:runecraft" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClassLevel_UpAll_drops_class_variant_for_unknown_class()
|
||||
{
|
||||
Assert.That(MissionEventKeys.ClassLevel.UpAll(classId: 0),
|
||||
Is.EquivalentTo(new[] { "class_level_up" }));
|
||||
}
|
||||
|
||||
// ---- Rank ----
|
||||
|
||||
[Test]
|
||||
public void Rank_AchievedAll_maps_all_seven_catalog_tiers()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(MissionEventKeys.Rank.AchievedAll(1), Contains.Item("rank_achieved:beginner"));
|
||||
Assert.That(MissionEventKeys.Rank.AchievedAll(5), Contains.Item("rank_achieved:d"));
|
||||
Assert.That(MissionEventKeys.Rank.AchievedAll(9), Contains.Item("rank_achieved:c"));
|
||||
Assert.That(MissionEventKeys.Rank.AchievedAll(13), Contains.Item("rank_achieved:b"));
|
||||
Assert.That(MissionEventKeys.Rank.AchievedAll(17), Contains.Item("rank_achieved:a"));
|
||||
Assert.That(MissionEventKeys.Rank.AchievedAll(21), Contains.Item("rank_achieved:aa"));
|
||||
Assert.That(MissionEventKeys.Rank.AchievedAll(25), Contains.Item("rank_achieved:master"));
|
||||
Assert.That(MissionEventKeys.Rank.AchievedAll(26), Contains.Item("rank_achieved:grand_master"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Rank_AchievedAll_boundary_edges()
|
||||
{
|
||||
// Rank 4 = last Beginner slot; rank 5 = first D slot.
|
||||
Assert.That(RankTier.Name(4), Is.EqualTo("beginner"));
|
||||
Assert.That(RankTier.Name(5), Is.EqualTo("d"));
|
||||
// Rank 24 = last AA; rank 25 = Master; rank 26 = first Grand Master.
|
||||
Assert.That(RankTier.Name(24), Is.EqualTo("aa"));
|
||||
Assert.That(RankTier.Name(25), Is.EqualTo("master"));
|
||||
Assert.That(RankTier.Name(26), Is.EqualTo("grand_master"));
|
||||
// Out of range
|
||||
Assert.That(RankTier.Name(0), Is.Null);
|
||||
Assert.That(RankTier.Name(30), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Rank_AchievedAll_drops_tier_variant_for_out_of_range_rank()
|
||||
{
|
||||
Assert.That(MissionEventKeys.Rank.AchievedAll(0),
|
||||
Is.EquivalentTo(new[] { "rank_achieved" }));
|
||||
Assert.That(MissionEventKeys.Rank.AchievedAll(30),
|
||||
Is.EquivalentTo(new[] { "rank_achieved" }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void All_seed_prefixes_are_registered()
|
||||
{
|
||||
// Every prefix that could appear in a seed row must be in the registry. Enumerating
|
||||
// explicitly rather than reading the seed JSON — the point is to catch someone removing
|
||||
// a prefix from the code without noticing the seed still references it.
|
||||
string[] knownSeedPrefixes = {
|
||||
"practice_win", "ranked_win", "ranked_or_arena_win", "daily_match_win",
|
||||
"story_chapter_finish", "class_level_up", "rank_achieved",
|
||||
"challenge_play", "challenge_win", "challenge_full_clear",
|
||||
"play_followers", "private_match_distinct_opponent",
|
||||
};
|
||||
foreach (var prefix in knownSeedPrefixes)
|
||||
{
|
||||
Assert.That(MissionEventKeys.IsRegistered(prefix), Is.True, $"prefix '{prefix}' not registered");
|
||||
}
|
||||
}
|
||||
}
|
||||
286
SVSim.UnitTests/Services/RankProgressServiceTests.cs
Normal file
286
SVSim.UnitTests/Services/RankProgressServiceTests.cs
Normal file
@@ -0,0 +1,286 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using SVSim.Database.Enums;
|
||||
using SVSim.Database.Models;
|
||||
using SVSim.Database.Repositories.Globals;
|
||||
using SVSim.Database.Services.RankProgress;
|
||||
|
||||
namespace SVSim.UnitTests.Services;
|
||||
|
||||
public class RankProgressServiceTests
|
||||
{
|
||||
// Values mirror SVSim.Bootstrap/Data/ranks.csv verbatim: RankId, NecessaryPoint,
|
||||
// AccumulatePoint, LowerLimitPoint, AccumulateMasterPoint.
|
||||
private static readonly (int Id, int Necessary, int Accumulate, int Lower, int AccMp)[] Ranks =
|
||||
{
|
||||
(1, 100, 100, 0, 0), (2, 100, 200, 0, 0),
|
||||
(3, 500, 700, 0, 0), (4, 500, 1200, 0, 0),
|
||||
(5, 750, 1950, 1200, 0), (6, 750, 2700, 1200, 0),
|
||||
(7, 800, 3500, 1200, 0), (8, 1000, 4500, 1200, 0),
|
||||
(9, 1500, 6000, 4500, 0), (10,1500, 7500, 4500, 0),
|
||||
(11,1750, 9250, 4500, 0), (12,1750, 11000, 4500, 0),
|
||||
(13,2000, 13000, 11000, 0), (14,2000, 15000, 11000, 0),
|
||||
(15,2000, 17000, 11000, 0), (16,2000, 19000, 11000, 0),
|
||||
(17,3000, 23000, 20000, 0), (18,3000, 26000, 20000, 0),
|
||||
(19,3000, 29000, 20000, 0), (20,3000, 32000, 20000, 0),
|
||||
(21,4000, 37000, 33000, 0), (22,4000, 42000, 33000, 0),
|
||||
(23,4000, 46000, 33000, 0), (24,4000, 50000, 33000, 0),
|
||||
(25, 0, 0, 50000, 5000),
|
||||
(26, 0, 0, 50000, 15000),
|
||||
(27, 0, 0, 50000, 25000),
|
||||
(28, 0, 0, 50000, 35000),
|
||||
(29, 0, 0, 50000, 0),
|
||||
};
|
||||
|
||||
private static IGlobalsRepository RankRepo()
|
||||
{
|
||||
var mock = new Mock<IGlobalsRepository>(MockBehavior.Loose);
|
||||
mock.Setup(x => x.GetRankInfo()).ReturnsAsync(
|
||||
Ranks.Select(r => new RankInfoEntry
|
||||
{
|
||||
Id = r.Id,
|
||||
NecessaryPoint = r.Necessary,
|
||||
AccumulatePoint = r.Accumulate,
|
||||
LowerLimitPoint = r.Lower,
|
||||
AccumulateMasterPoint = r.AccMp,
|
||||
}).ToList());
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
private static Viewer NewViewer(Format format, int point, int masterPoint)
|
||||
{
|
||||
var v = new Viewer { Id = 1, DisplayName = "v" };
|
||||
v.RankProgress.Add(new ViewerRankProgress
|
||||
{
|
||||
Format = format, Point = point, MasterPoint = masterPoint,
|
||||
});
|
||||
return v;
|
||||
}
|
||||
|
||||
private static RankProgressService NewService() =>
|
||||
new(RankRepo(), NullLogger<RankProgressService>.Instance);
|
||||
|
||||
[Test]
|
||||
public async Task First_win_from_scratch_awards_100_and_promotes_to_Beginner1()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = new Viewer { Id = 1, DisplayName = "v" };
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(2));
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(100));
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(100));
|
||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(0));
|
||||
Assert.That(r.IsMasterRank, Is.False);
|
||||
Assert.That(r.IsGrandMasterRank, Is.False);
|
||||
var row = v.RankProgress.Single();
|
||||
Assert.That(row.Format, Is.EqualTo(Format.Rotation));
|
||||
Assert.That(row.Point, Is.EqualTo(100));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_at_0_points_floors_at_0()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 0, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(1));
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(0));
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_within_beginner_can_drop_between_subranks()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 100, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(1));
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(50));
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(-50));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_at_D_tier_floor_stays_at_D()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 1200, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(5));
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(1200));
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_within_D_drops_from_D1_toward_D0_and_stops_at_floor()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 1230, masterPoint: 0);
|
||||
|
||||
var r1 = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
Assert.That(r1.AfterBattlePoint, Is.EqualTo(1200));
|
||||
Assert.That(r1.BattlePoint, Is.EqualTo(-30));
|
||||
Assert.That(r1.Rank, Is.EqualTo(5));
|
||||
|
||||
var r2 = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
Assert.That(r2.AfterBattlePoint, Is.EqualTo(1200));
|
||||
Assert.That(r2.BattlePoint, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Win_crossing_50000_lands_at_Master_with_no_MP_change()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 49950, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(25));
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(50050));
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(100));
|
||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(0));
|
||||
Assert.That(r.MasterPoint, Is.EqualTo(0));
|
||||
Assert.That(r.IsMasterRank, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Win_at_Master_awards_MasterPoint_not_Point()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 50000, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
|
||||
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(0));
|
||||
Assert.That(r.MasterPoint, Is.EqualTo(100));
|
||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(100));
|
||||
Assert.That(r.Rank, Is.EqualTo(25));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MP_crossing_5000_promotes_to_GrandMaster_0()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 50000, masterPoint: 4900);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
|
||||
|
||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(5000));
|
||||
Assert.That(r.Rank, Is.EqualTo(26));
|
||||
Assert.That(r.IsGrandMasterRank, Is.True);
|
||||
Assert.That(r.IsMasterRank, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_at_GM0_floor_stays_at_5000_and_does_not_demote_to_Master()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 50000, masterPoint: 5000);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
|
||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(5000));
|
||||
Assert.That(r.MasterPoint, Is.EqualTo(0));
|
||||
Assert.That(r.Rank, Is.EqualTo(26));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Loss_at_Master_with_MP_zero_leaves_Point_at_50000()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 50000, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(50000));
|
||||
Assert.That(r.BattlePoint, Is.EqualTo(0));
|
||||
Assert.That(r.Rank, Is.EqualTo(25));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Rotation_and_Unlimited_progressions_are_separate()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = new Viewer { Id = 1, DisplayName = "v" };
|
||||
v.RankProgress.Add(new ViewerRankProgress { Format = Format.Rotation, Point = 500 });
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Unlimited, isWin: true);
|
||||
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(100));
|
||||
Assert.That(v.RankProgress.Count, Is.EqualTo(2));
|
||||
Assert.That(v.RankProgress.Single(x => x.Format == Format.Rotation).Point, Is.EqualTo(500));
|
||||
Assert.That(v.RankProgress.Single(x => x.Format == Format.Unlimited).Point, Is.EqualTo(100));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Crossover_format_throws()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = new Viewer { Id = 1, DisplayName = "v" };
|
||||
|
||||
Assert.ThrowsAsync<ArgumentOutOfRangeException>(async () =>
|
||||
await svc.GrantAsync(v, Format.Crossover, isWin: true));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAsync_returns_zero_when_no_row_exists()
|
||||
{
|
||||
var svc = NewService();
|
||||
var v = new Viewer { Id = 1, DisplayName = "v" };
|
||||
|
||||
var r = await svc.GetAsync(v, Format.Rotation);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(1));
|
||||
Assert.That(r.AfterBattlePoint, Is.EqualTo(0));
|
||||
Assert.That(r.AfterMasterPoint, Is.EqualTo(0));
|
||||
Assert.That(v.RankProgress, Is.Empty);
|
||||
}
|
||||
|
||||
// ---- TierAdvanced ----
|
||||
|
||||
[Test]
|
||||
public async Task TierAdvanced_is_false_when_win_stays_within_same_tier()
|
||||
{
|
||||
// Point 100 (Beginner 1, rank 2) + 100 win = 200 (Beginner 3, rank 4). Still beginner.
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 100, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
|
||||
|
||||
Assert.That(RankTier.Name(r.Rank), Is.EqualTo("beginner"));
|
||||
Assert.That(r.TierAdvanced, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TierAdvanced_is_true_when_win_crosses_beginner_to_d()
|
||||
{
|
||||
// Point 1150 (Beginner 3, rank 4 — needs 1200 to leave beginner) + 100 = 1250 = D0 (rank 5).
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 1150, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: true);
|
||||
|
||||
Assert.That(r.Rank, Is.EqualTo(5), "Expected promotion to rank 5 (D0)");
|
||||
Assert.That(r.TierAdvanced, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task TierAdvanced_is_false_on_loss_even_if_tier_string_changes()
|
||||
{
|
||||
// Loss can't advance a tier — even if it demoted, the achievement doesn't fire backward.
|
||||
// Point 1200 (D0, rank 5) - 50 loss = 1200 (floored). Still D0. Prove flag stays false.
|
||||
var svc = NewService();
|
||||
var v = NewViewer(Format.Rotation, point: 1200, masterPoint: 0);
|
||||
|
||||
var r = await svc.GrantAsync(v, Format.Rotation, isWin: false);
|
||||
|
||||
Assert.That(r.TierAdvanced, Is.False);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ public class StoryServiceTests
|
||||
configService: StoryServiceTestHelpers.NewConfigService(),
|
||||
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
||||
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
|
||||
viewers: new Mock<SVSim.Database.Repositories.Viewer.IViewerRepository>().Object,
|
||||
xp: new Mock<SVSim.Database.Services.BattleXp.IBattleXpService>().Object,
|
||||
logger: NullLogger<StoryService>.Instance);
|
||||
}
|
||||
|
||||
@@ -81,6 +83,8 @@ public class StoryServiceTests
|
||||
configService: StoryServiceTestHelpers.NewConfigService(),
|
||||
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
||||
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
|
||||
viewers: scope.ServiceProvider.GetRequiredService<SVSim.Database.Repositories.Viewer.IViewerRepository>(),
|
||||
xp: scope.ServiceProvider.GetRequiredService<SVSim.Database.Services.BattleXp.IBattleXpService>(),
|
||||
logger: NullLogger<StoryService>.Instance);
|
||||
}
|
||||
|
||||
@@ -414,6 +418,8 @@ public class StoryServiceTests
|
||||
configService: StoryServiceTestHelpers.NewConfigService(),
|
||||
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
||||
buildDecks: new Mock<SVSim.Database.Repositories.BuildDeck.IBuildDeckRepository>().Object,
|
||||
viewers: new Mock<SVSim.Database.Repositories.Viewer.IViewerRepository>().Object,
|
||||
xp: new Mock<SVSim.Database.Services.BattleXp.IBattleXpService>().Object,
|
||||
logger: NullLogger<StoryService>.Instance);
|
||||
}
|
||||
|
||||
@@ -571,9 +577,9 @@ public class StoryServiceTests
|
||||
|
||||
var resp = await _service.FinishAsync(StoryApiType.Main, req, viewerId: 7L);
|
||||
|
||||
Assert.That(resp.RewardList, Is.Empty);
|
||||
Assert.That(resp.StoryRewardList, Is.Empty);
|
||||
Assert.That(resp.GetClassExperience, Is.EqualTo("0"));
|
||||
Assert.That(resp.Response.RewardList, Is.Empty);
|
||||
Assert.That(resp.Response.StoryRewardList, Is.Empty);
|
||||
Assert.That(resp.Response.GetClassExperience, Is.EqualTo("0"));
|
||||
_viewer.Verify(v => v.UpsertProgressAsync(7L, 100, null, true), Times.Once);
|
||||
}
|
||||
|
||||
@@ -622,16 +628,28 @@ public class StoryServiceTests
|
||||
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
||||
|
||||
// Viewer started at RedEther=0; grant of 100 → post-state total = 100.
|
||||
Assert.That(resp.RewardList, Has.Count.EqualTo(1));
|
||||
Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100"));
|
||||
Assert.That(resp.GetClassExperience, Is.EqualTo("200"));
|
||||
Assert.That(resp.Response.RewardList, Has.Count.EqualTo(1));
|
||||
Assert.That(resp.Response.RewardList[0].RewardNum, Is.EqualTo("100"));
|
||||
// Story XP resolves via DI-registered IBattleXpService → real IGameConfigService
|
||||
// (the local NewConfigService mock is passed to StoryService but the XP service
|
||||
// pulls its own config from DI). BattleXpConfig.ShippedDefaults(): XpPerWin=200,
|
||||
// StoryXpPerClear=null → falls back to XpPerWin=200. Curve L1=50, L2=150 → 200 XP
|
||||
// crosses both thresholds: L3 with 0.
|
||||
Assert.That(resp.Response.GetClassExperience, Is.EqualTo("200"));
|
||||
Assert.That(resp.Response.ClassExperience, Is.EqualTo(0));
|
||||
Assert.That(resp.Response.ClassLevel, Is.EqualTo("3"));
|
||||
_viewer.Verify(v => v.UpsertProgressAsync(viewerId, 100, true, null), Times.Once);
|
||||
|
||||
// Confirm currency persisted: fetch fresh viewer from a new scope.
|
||||
// Confirm currency + class XP persisted: fetch fresh viewer from a new scope.
|
||||
using var verifyScope = factory.Services.CreateScope();
|
||||
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var freshViewer = await db2.Viewers.FirstAsync(v => v.Id == viewerId);
|
||||
var freshViewer = await db2.Viewers
|
||||
.Include(v => v.Classes).ThenInclude(c => c.Class)
|
||||
.FirstAsync(v => v.Id == viewerId);
|
||||
Assert.That(freshViewer.Currency.RedEther, Is.EqualTo(100UL));
|
||||
var cls2 = freshViewer.Classes.Single(c => c.Class.Id == 2);
|
||||
Assert.That(cls2.Level, Is.EqualTo(3));
|
||||
Assert.That(cls2.Exp, Is.EqualTo(0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,7 +670,7 @@ public class StoryServiceTests
|
||||
var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 };
|
||||
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
||||
|
||||
Assert.That(resp.RewardList, Is.Empty);
|
||||
Assert.That(resp.Response.RewardList, Is.Empty);
|
||||
|
||||
// Currency must not have changed from its seed value of 0.
|
||||
using var verifyScope = factory.Services.CreateScope();
|
||||
@@ -680,8 +698,8 @@ public class StoryServiceTests
|
||||
var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 };
|
||||
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
||||
|
||||
Assert.That(resp.RewardList, Has.Count.EqualTo(1));
|
||||
Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100"));
|
||||
Assert.That(resp.Response.RewardList, Has.Count.EqualTo(1));
|
||||
Assert.That(resp.Response.RewardList[0].RewardNum, Is.EqualTo("100"));
|
||||
|
||||
using var verifyScope = factory.Services.CreateScope();
|
||||
var db2 = verifyScope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
@@ -732,14 +750,14 @@ public class StoryServiceTests
|
||||
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
||||
|
||||
// reward_list (post-state) gets BOTH the Card entry AND the cascaded Skin entry.
|
||||
Assert.That(resp.RewardList.Any(r => r.RewardType == "5" && r.RewardId == testCardId.ToString()), Is.True,
|
||||
Assert.That(resp.Response.RewardList.Any(r => r.RewardType == "5" && r.RewardId == testCardId.ToString()), Is.True,
|
||||
"card reward should appear in reward_list");
|
||||
Assert.That(resp.RewardList.Any(r => r.RewardType == "10" && r.RewardId == testSkinId.ToString()), Is.True,
|
||||
Assert.That(resp.Response.RewardList.Any(r => r.RewardType == "10" && r.RewardId == testSkinId.ToString()), Is.True,
|
||||
"cascade skin should appear in reward_list");
|
||||
|
||||
// story_reward_list (deltas) only carries the top-level chapter reward.
|
||||
Assert.That(resp.StoryRewardList.Count(r => r.RewardType == "5"), Is.EqualTo(1));
|
||||
Assert.That(resp.StoryRewardList.Any(r => r.RewardType == "10"), Is.False,
|
||||
Assert.That(resp.Response.StoryRewardList.Count(r => r.RewardType == "5"), Is.EqualTo(1));
|
||||
Assert.That(resp.Response.StoryRewardList.Any(r => r.RewardType == "10"), Is.False,
|
||||
"cascade cosmetics should not appear in story_reward_list deltas");
|
||||
}
|
||||
|
||||
@@ -804,7 +822,7 @@ public class StoryServiceTests
|
||||
var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId);
|
||||
|
||||
// Post-state count on the wire should be 3 (2 owned + 1 granted).
|
||||
var cardEntry = resp.RewardList.SingleOrDefault(r => r.RewardType == "5" && r.RewardId == testCardId.ToString());
|
||||
var cardEntry = resp.Response.RewardList.SingleOrDefault(r => r.RewardType == "5" && r.RewardId == testCardId.ToString());
|
||||
Assert.That(cardEntry, Is.Not.Null, "card reward should appear in reward_list");
|
||||
Assert.That(cardEntry!.RewardNum, Is.EqualTo("3"), "post-state count should be incremented, not reset to 1");
|
||||
}
|
||||
@@ -828,7 +846,7 @@ internal static class StoryServiceTestHelpers
|
||||
{
|
||||
var mock = new Mock<SVSim.Database.Services.IGameConfigService>();
|
||||
mock.Setup(s => s.Get<SVSim.Database.Models.Config.StoryConfig>())
|
||||
.Returns(new SVSim.Database.Models.Config.StoryConfig { ClassXpPerClear = 200 });
|
||||
.Returns(new SVSim.Database.Models.Config.StoryConfig());
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user